You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
484 lines
20 KiB
Python
484 lines
20 KiB
Python
"""Serviço isolado de geração de tools via LLM para o runtime administrativo.
|
|
|
|
Este módulo é a única camada do admin_app que conversa com o Vertex AI para fins
|
|
de geração de código. Ele é completamente separado do LLMService do product
|
|
(app.services.ai.llm_service) e usa configurações próprias do AdminSettings.
|
|
|
|
Separação arquitetural garantida por:
|
|
- shared.contracts.model_runtime_separation.ModelRuntimeTarget.TOOL_GENERATION
|
|
- config keys: admin_tool_generation_model / admin_tool_generation_fallback_model
|
|
- Nenhuma importação de app.* é permitida neste módulo.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from time import perf_counter
|
|
from typing import Any
|
|
|
|
import vertexai
|
|
from google.api_core.exceptions import GoogleAPIError, NotFound
|
|
from vertexai.generative_models import GenerationConfig, GenerativeModel
|
|
|
|
from admin_app.core.settings import AdminSettings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---- Constantes de geração ---------------------------------------------------
|
|
|
|
_PYTHON_BLOCK_RE = re.compile(
|
|
r"```python\s*\n(.*?)```",
|
|
re.DOTALL | re.IGNORECASE,
|
|
)
|
|
|
|
# Padrões que o código gerado não pode conter.
|
|
# Aplicados antes das validações automáticas existentes no ToolManagementService.
|
|
_DANGEROUS_PATTERNS: tuple[tuple[str, str], ...] = (
|
|
(r"\bexec\s*\(", "uso de exec() proibido em tools geradas"),
|
|
(r"\beval\s*\(", "uso de eval() proibido em tools geradas"),
|
|
(r"\b__import__\s*\(", "uso de __import__() proibido em tools geradas"),
|
|
(r"os\.system\s*\(", "chamada a os.system() proibida em tools geradas"),
|
|
(r"os\.popen\s*\(", "chamada a os.popen() proibida em tools geradas"),
|
|
(r"\bsubprocess\b", "uso de subprocess proibido em tools geradas"),
|
|
(r"from\s+app\.", "importação de app.* proibida em tools geradas"),
|
|
(r"from\s+admin_app\.", "importação de admin_app.* proibida em tools geradas"),
|
|
(r"import\s+app\b", "importação direta de app proibida em tools geradas"),
|
|
(r"import\s+admin_app\b", "importação direta de admin_app proibida em tools geradas"),
|
|
(r"\bopen\s*\(", "acesso a sistema de arquivos via open() proibido em tools geradas"),
|
|
(r"__builtins__", "acesso a __builtins__ proibido em tools geradas"),
|
|
)
|
|
|
|
# Mapeamento de tipo de parâmetro para anotação Python legível
|
|
_TYPE_ANNOTATION_MAP: dict[str, str] = {
|
|
"string": "str",
|
|
"integer": "int",
|
|
"number": "float",
|
|
"boolean": "bool",
|
|
"object": "dict",
|
|
"array": "list",
|
|
}
|
|
|
|
# Cache de modelos Vertex AI instanciados (por nome de modelo)
|
|
_MODEL_CACHE: dict[str, GenerativeModel] = {}
|
|
|
|
# Flag de controle de inicialização do SDK (evita reinit por instância)
|
|
_VERTEX_INITIALIZED: bool = False
|
|
|
|
|
|
class ToolGenerationService:
|
|
"""Gera implementações de tools via Vertex AI no contexto administrativo.
|
|
|
|
Responsabilidades:
|
|
- Construir prompt estruturado a partir dos metadados da tool
|
|
- Chamar o modelo LLM de geração (separado do modelo de atendimento)
|
|
- Extrair o bloco de código Python da resposta
|
|
- Aplicar linting de segurança antes de devolver o código
|
|
- Retornar resultado estruturado para o ToolManagementService
|
|
|
|
Não faz:
|
|
- Não persiste artefatos (responsabilidade do ToolManagementService)
|
|
- Não valida contrato nem assinatura (responsabilidade do ToolManagementService)
|
|
- Não executa o código gerado
|
|
"""
|
|
|
|
def __init__(self, settings: AdminSettings) -> None:
|
|
self.settings = settings
|
|
self._ensure_vertex_initialized()
|
|
|
|
def _ensure_vertex_initialized(self) -> None:
|
|
global _VERTEX_INITIALIZED
|
|
if _VERTEX_INITIALIZED:
|
|
return
|
|
# Reutiliza as credenciais do projeto Google já configuradas nas settings
|
|
# do admin (que leem do .env, idêntico ao product). O isolamento é nos
|
|
# parâmetros de modelo e temperatura — não na conta GCP.
|
|
try:
|
|
import os
|
|
project_id = os.environ.get("GOOGLE_PROJECT_ID", "")
|
|
location = os.environ.get("GOOGLE_LOCATION", "us-central1")
|
|
vertexai.init(project=project_id, location=location)
|
|
_VERTEX_INITIALIZED = True
|
|
logger.info(
|
|
"tool_generation_service_event=vertex_initialized project=%s location=%s",
|
|
project_id,
|
|
location,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"tool_generation_service_event=vertex_init_warning error=%s",
|
|
exc,
|
|
)
|
|
|
|
def _get_model(self, model_name: str) -> GenerativeModel:
|
|
model = _MODEL_CACHE.get(model_name)
|
|
if model is None:
|
|
model = GenerativeModel(model_name)
|
|
_MODEL_CACHE[model_name] = model
|
|
return model
|
|
|
|
def _build_model_sequence(self, preferred_model: str | None) -> list[str]:
|
|
"""Constrói a sequência de modelos a tentar, respeitando o preferred e o fallback."""
|
|
sequence: list[str] = []
|
|
candidates = [
|
|
preferred_model,
|
|
self.settings.admin_tool_generation_model,
|
|
self.settings.admin_tool_generation_fallback_model,
|
|
]
|
|
for candidate in candidates:
|
|
normalized = str(candidate or "").strip()
|
|
if normalized and normalized not in sequence:
|
|
sequence.append(normalized)
|
|
return sequence
|
|
|
|
def _build_generation_prompt(
|
|
self,
|
|
*,
|
|
tool_name: str,
|
|
display_name: str,
|
|
domain: str,
|
|
description: str,
|
|
business_goal: str,
|
|
parameters: list[dict],
|
|
previous_source_code: str | None = None,
|
|
change_request_notes: str | None = None,
|
|
generation_iteration: int = 1,
|
|
) -> str:
|
|
"""Monta o prompt estruturado de geração enviado ao modelo.
|
|
|
|
O prompt descreve o contrato esperado, os restrições de importação,
|
|
os parâmetros e o objetivo operacional da tool.
|
|
"""
|
|
signature_parts: list[str] = []
|
|
parameter_lines: list[str] = []
|
|
|
|
for param in parameters:
|
|
name = str(param.get("name") or "").strip().lower()
|
|
if not name:
|
|
continue
|
|
param_type = str(param.get("parameter_type") or "string").strip().lower()
|
|
description_param = str(param.get("description") or "").strip()
|
|
required = bool(param.get("required", True))
|
|
annotation = _TYPE_ANNOTATION_MAP.get(param_type, "str")
|
|
|
|
if required:
|
|
signature_parts.append(f"{name}: {annotation}")
|
|
else:
|
|
signature_parts.append(f"{name}: {annotation} | None = None")
|
|
|
|
required_label = "obrigatório" if required else "opcional"
|
|
parameter_lines.append(
|
|
f" - {name} ({annotation}, {required_label}): {description_param}"
|
|
)
|
|
|
|
signature = ", ".join(signature_parts)
|
|
if signature:
|
|
full_signature = f"async def run(*, {signature}) -> dict:"
|
|
else:
|
|
full_signature = "async def run() -> dict:"
|
|
|
|
parameters_block = (
|
|
"\n".join(parameter_lines)
|
|
if parameter_lines
|
|
else " (nenhum parâmetro — a tool não recebe entrada contextual)"
|
|
)
|
|
|
|
domain_context_map = {
|
|
"vendas": (
|
|
"O bot atua em um sistema de atendimento para concessionária automotiva. "
|
|
"A tool opera no domínio de vendas: estoque de veículos, negociações, pedidos e cancelamentos."
|
|
),
|
|
"revisao": (
|
|
"O bot atua em um sistema de atendimento de oficina automotiva. "
|
|
"A tool opera no domínio de revisão: agendamentos, remarcações, listagem de serviços."
|
|
),
|
|
"locacao": (
|
|
"O bot atua em um sistema de atendimento de locadora de veículos. "
|
|
"A tool opera no domínio de locação: frota, contratos, pagamentos e devoluções."
|
|
),
|
|
"orquestracao": (
|
|
"O bot atua em um sistema de orquestração conversacional. "
|
|
"A tool opera no domínio de orquestração: controla fluxo, contexto e estado da conversa."
|
|
),
|
|
}
|
|
domain_context = domain_context_map.get(
|
|
str(domain or "").strip().lower(),
|
|
"O bot atua em um sistema de atendimento automatizado.",
|
|
)
|
|
|
|
normalized_previous_source = str(previous_source_code or "").strip()
|
|
normalized_change_request_notes = str(change_request_notes or "").strip()
|
|
prompt_mode = "geracao_inicial"
|
|
refinement_block = ""
|
|
if normalized_previous_source and normalized_change_request_notes:
|
|
prompt_mode = "refatoracao_guiada_por_feedback"
|
|
refinement_block = (
|
|
"MODO DE EXECUCAO:\n"
|
|
"- Esta nao e uma geracao do zero. Refatore a implementacao existente.\n"
|
|
"- Preserve o contrato governado, o objetivo de negocio e os parametros da tool.\n"
|
|
"- Corrija explicitamente os pontos apontados pela revisao humana.\n\n"
|
|
"FEEDBACK HUMANO:\n"
|
|
f"{normalized_change_request_notes}\n\n"
|
|
"CODIGO ANTERIOR A SER REFATORADO:\n"
|
|
f"```python\n{normalized_previous_source}\n```\n\n"
|
|
)
|
|
elif normalized_previous_source:
|
|
prompt_mode = "regeneracao_com_contexto_previo"
|
|
refinement_block = (
|
|
"MODO DE EXECUCAO:\n"
|
|
"- Existe um codigo anterior para esta mesma versao.\n"
|
|
"- Use-o como referencia para manter continuidade e consistencia na implementacao.\n\n"
|
|
"CODIGO ANTERIOR DE REFERENCIA:\n"
|
|
f"```python\n{normalized_previous_source}\n```\n\n"
|
|
)
|
|
|
|
return (
|
|
"CONTEXTO DA EXECUCAO:\n"
|
|
f"- Iteracao de geracao: {int(generation_iteration)}\n"
|
|
f"- Modo do prompt: {prompt_mode}\n\n"
|
|
f"{refinement_block}"
|
|
"Você é um especialista em Python que gera implementações realistas de tools "
|
|
"para um bot de atendimento.\n\n"
|
|
f"CONTEXTO DO DOMÍNIO:\n{domain_context}\n\n"
|
|
"CONTRATO OBRIGATÓRIO:\n"
|
|
"- A função deve ser assíncrona: async def run(...)\n"
|
|
"- Todos os parâmetros devem ser keyword-only (após *)\n"
|
|
"- O tipo de retorno deve ser dict (JSON-serializável)\n"
|
|
"- O módulo pode importar apenas stdlib (datetime, json, re, math, uuid, etc.)\n"
|
|
"- Proibido importar: app.*, admin_app.*, subprocess, os.system, os.popen\n"
|
|
"- Proibido usar: exec(), eval(), __import__(), open()\n\n"
|
|
"TOOL A IMPLEMENTAR:\n"
|
|
f"- Nome técnico: {tool_name}\n"
|
|
f"- Nome de exibição: {display_name}\n"
|
|
f"- Domínio: {domain}\n"
|
|
f"- Descrição funcional: {description}\n"
|
|
f"- Objetivo de negócio: {business_goal}\n\n"
|
|
f"PARÂMETROS DA TOOL:\n{parameters_block}\n\n"
|
|
f"ASSINATURA ESPERADA:\n{full_signature}\n\n"
|
|
"INSTRUÇÕES DE GERAÇÃO:\n"
|
|
"- Gere uma implementação realista que simule o comportamento esperado da tool.\n"
|
|
"- O retorno deve incluir os campos relevantes ao domínio (não apenas echo dos argumentos).\n"
|
|
"- Use dados fictícios mas verossímeis para simular a resposta operacional.\n"
|
|
"- Nenhuma explicação ou comentário fora do código. Retorne apenas o bloco Python.\n"
|
|
"- O módulo deve começar com um docstring descritivo.\n"
|
|
"- Envolva o código em ```python ... ```.\n"
|
|
)
|
|
|
|
def _extract_python_block(self, raw_response: str) -> str | None:
|
|
"""Extrai o primeiro bloco ```python ... ``` da resposta do modelo."""
|
|
normalized = str(raw_response or "").strip()
|
|
match = _PYTHON_BLOCK_RE.search(normalized)
|
|
if match:
|
|
return match.group(1).strip()
|
|
# Fallback: se não há marcador de código mas o conteúdo parece Python
|
|
if normalized.startswith("async def run") or normalized.startswith('"""'):
|
|
return normalized
|
|
return None
|
|
|
|
def _apply_safety_linting(self, source_code: str) -> list[str]:
|
|
"""Verifica padrões perigosos no código gerado antes da validação formal.
|
|
|
|
Retorna lista de issues. Lista vazia = linting passou.
|
|
"""
|
|
issues: list[str] = []
|
|
for pattern, description in _DANGEROUS_PATTERNS:
|
|
if re.search(pattern, source_code, re.MULTILINE):
|
|
issues.append(f"linting: {description}.")
|
|
return issues
|
|
|
|
async def generate_tool_source(
|
|
self,
|
|
*,
|
|
tool_name: str,
|
|
display_name: str,
|
|
domain: str,
|
|
description: str,
|
|
business_goal: str,
|
|
parameters: list[dict],
|
|
preferred_model: str | None = None,
|
|
previous_source_code: str | None = None,
|
|
change_request_notes: str | None = None,
|
|
generation_iteration: int = 1,
|
|
) -> dict[str, Any]:
|
|
"""Gera o código Python da tool a partir dos metadados do draft.
|
|
|
|
Retorna um dicionário com:
|
|
- passed (bool): True se o código foi gerado e passou no linting
|
|
- generated_source_code (str | None): código Python gerado
|
|
- generation_model_used (str | None): modelo que gerou o código
|
|
- prompt_rendered (str): prompt enviado ao modelo (para auditoria)
|
|
- issues (list[str]): problemas encontrados (geração ou linting)
|
|
- elapsed_ms (float): tempo total de geração em milissegundos
|
|
"""
|
|
prompt = self._build_generation_prompt(
|
|
tool_name=tool_name,
|
|
display_name=display_name,
|
|
domain=domain,
|
|
description=description,
|
|
business_goal=business_goal,
|
|
parameters=parameters,
|
|
previous_source_code=previous_source_code,
|
|
change_request_notes=change_request_notes,
|
|
generation_iteration=generation_iteration,
|
|
)
|
|
|
|
model_sequence = self._build_model_sequence(preferred_model)
|
|
generation_config = GenerationConfig(
|
|
temperature=self.settings.admin_tool_generation_temperature,
|
|
max_output_tokens=self.settings.admin_tool_generation_max_output_tokens,
|
|
)
|
|
|
|
raw_response: str | None = None
|
|
generation_model_used: str | None = None
|
|
last_error: Exception | None = None
|
|
started_at = perf_counter()
|
|
|
|
import asyncio
|
|
|
|
for model_name in model_sequence:
|
|
try:
|
|
model = self._get_model(model_name)
|
|
response = await asyncio.wait_for(
|
|
asyncio.to_thread(
|
|
model.generate_content,
|
|
prompt,
|
|
generation_config=generation_config,
|
|
),
|
|
timeout=float(self.settings.admin_tool_generation_timeout_seconds),
|
|
)
|
|
candidate = (
|
|
response.candidates[0]
|
|
if getattr(response, "candidates", None)
|
|
else None
|
|
)
|
|
content = getattr(candidate, "content", None)
|
|
parts = list(getattr(content, "parts", None) or [])
|
|
text_parts = [
|
|
getattr(part, "text", None)
|
|
for part in parts
|
|
if isinstance(getattr(part, "text", None), str)
|
|
]
|
|
raw_response = "\n".join(
|
|
t for t in text_parts if t and t.strip()
|
|
).strip() or None
|
|
|
|
if raw_response is None:
|
|
# Fallback para o atributo .text raiz
|
|
try:
|
|
raw_response = str(response.text or "").strip() or None
|
|
except (AttributeError, ValueError):
|
|
raw_response = None
|
|
|
|
generation_model_used = model_name
|
|
break
|
|
|
|
except asyncio.TimeoutError:
|
|
last_error = TimeoutError(
|
|
f"modelo '{model_name}' excedeu o timeout de "
|
|
f"{self.settings.admin_tool_generation_timeout_seconds}s para geração de tools."
|
|
)
|
|
logger.warning(
|
|
"tool_generation_service_event=timeout model=%s timeout_seconds=%s",
|
|
model_name,
|
|
self.settings.admin_tool_generation_timeout_seconds,
|
|
)
|
|
continue
|
|
|
|
except NotFound as exc:
|
|
last_error = exc
|
|
_MODEL_CACHE.pop(model_name, None)
|
|
logger.warning(
|
|
"tool_generation_service_event=model_not_found model=%s error=%s",
|
|
model_name,
|
|
exc,
|
|
)
|
|
continue
|
|
|
|
except GoogleAPIError as exc:
|
|
last_error = exc
|
|
logger.warning(
|
|
"tool_generation_service_event=api_error model=%s error=%s",
|
|
model_name,
|
|
exc,
|
|
)
|
|
continue
|
|
|
|
except Exception as exc:
|
|
last_error = exc
|
|
logger.warning(
|
|
"tool_generation_service_event=unexpected_error model=%s error=%s class=%s",
|
|
model_name,
|
|
exc,
|
|
exc.__class__.__name__,
|
|
)
|
|
continue
|
|
|
|
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
|
|
|
|
if raw_response is None or generation_model_used is None:
|
|
error_detail = str(last_error) if last_error else "nenhum modelo disponivel respondeu"
|
|
logger.error(
|
|
"tool_generation_service_event=generation_failed tool_name=%s elapsed_ms=%s error=%s",
|
|
tool_name,
|
|
elapsed_ms,
|
|
error_detail,
|
|
)
|
|
return {
|
|
"passed": False,
|
|
"generated_source_code": None,
|
|
"generation_model_used": None,
|
|
"prompt_rendered": prompt,
|
|
"issues": [f"falha na geração via LLM: {error_detail}"],
|
|
"elapsed_ms": elapsed_ms,
|
|
}
|
|
|
|
generated_source_code = self._extract_python_block(raw_response)
|
|
if generated_source_code is None:
|
|
logger.warning(
|
|
"tool_generation_service_event=no_code_block tool_name=%s model=%s elapsed_ms=%s",
|
|
tool_name,
|
|
generation_model_used,
|
|
elapsed_ms,
|
|
)
|
|
return {
|
|
"passed": False,
|
|
"generated_source_code": None,
|
|
"generation_model_used": generation_model_used,
|
|
"prompt_rendered": prompt,
|
|
"issues": ["o modelo não retornou um bloco de código Python identificável."],
|
|
"elapsed_ms": elapsed_ms,
|
|
}
|
|
|
|
linting_issues = self._apply_safety_linting(generated_source_code)
|
|
if linting_issues:
|
|
logger.warning(
|
|
"tool_generation_service_event=linting_failed tool_name=%s model=%s issues=%s elapsed_ms=%s",
|
|
tool_name,
|
|
generation_model_used,
|
|
linting_issues,
|
|
elapsed_ms,
|
|
)
|
|
return {
|
|
"passed": False,
|
|
"generated_source_code": generated_source_code,
|
|
"generation_model_used": generation_model_used,
|
|
"prompt_rendered": prompt,
|
|
"issues": linting_issues,
|
|
"elapsed_ms": elapsed_ms,
|
|
}
|
|
|
|
logger.info(
|
|
"tool_generation_service_event=generation_succeeded tool_name=%s model=%s elapsed_ms=%s",
|
|
tool_name,
|
|
generation_model_used,
|
|
elapsed_ms,
|
|
)
|
|
return {
|
|
"passed": True,
|
|
"generated_source_code": generated_source_code,
|
|
"generation_model_used": generation_model_used,
|
|
"prompt_rendered": prompt,
|
|
"issues": [],
|
|
"elapsed_ms": elapsed_ms,
|
|
}
|