From e68b32a17776439a4d20007fb18b6ac8c0781b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Wed, 25 Feb 2026 12:09:25 -0300 Subject: [PATCH] =?UTF-8?q?:recycle:=20refactor:=20Migrando=20a=20integra?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20dados=20fict=C3=ADcios=20para=20FakerAPI?= =?UTF-8?q?=20e=20ajustando=20a=20chamada=20das=20tools=20no=20Vertex=20AI?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 31 +++-- README.md | 4 +- app/core/settings.py | 9 +- app/services/fakerapi_client.py | 57 +++++++++ app/services/handlers.py | 181 +++++++++++++++++++++------ app/services/llm_service.py | 68 +++++----- app/services/mockaroo_client.py | 60 --------- app/services/orquestrador_service.py | 24 ++-- 8 files changed, 272 insertions(+), 162 deletions(-) create mode 100644 app/services/fakerapi_client.py delete mode 100644 app/services/mockaroo_client.py diff --git a/.env.example b/.env.example index 34ec0cd..8b60b5a 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,13 @@ # ============================================ -# CONFIGURAÇÕES DO GOOGLE CLOUD +# CONFIGURACOES DO GOOGLE CLOUD # ============================================ GOOGLE_PROJECT_ID=id_do_seu_projeto GOOGLE_LOCATION=loc_do_seu_projeto +VERTEX_MODEL_NAME=gemini-2.5-flash # ============================================ -# CONFIGURAÇÕES DO BANCO DE DADOS (LOCAL) +# CONFIGURACOES DO BANCO DE DADOS (LOCAL) # ============================================ # Para desenvolvimento local: PostgreSQL direto @@ -17,34 +18,30 @@ DB_PASSWORD=SUA_SENHA DB_NAME=orquestrador_db # ============================================ -# CONFIGURAÇÕES DO BANCO DE DADOS (CLOUD SQL - PRODUÇÃO) +# CONFIGURACOES DO BANCO DE DADOS (CLOUD SQL - PRODUCAO) # ============================================ -# Comentado até fazer deploy. Descomente em produção. +# Comentado ate fazer deploy. Descomente em producao. # CLOUD_SQL_CONNECTION_NAME=optimum-tensor-343619:us-central1:orquestrador-db # ============================================ -# CONFIGURAÇÕES DE API - GOOGLE GENERATIVE AI (Gemini) +# CONFIGURACOES DE API - GOOGLE GENERATIVE AI (Gemini) # ============================================ # Descomente e informe a chave apenas se usar Gemini # GOOGLE_API_KEY=sua-chave-api-aqui # ============================================ -# CONFIGURAÇÕES DE API - MOCKAROO (Dados fictícios) +# CONFIGURACOES DE API - FAKERAPI (Dados ficticios) # ============================================ -# Obrigatório: Forneça sua chave se usar Mockaroo para dados de teste -MOCKAROO_API_KEY=sua-chave-mockaroo-aqui -MOCKAROO_BASE_URL=https://api.mockaroo.com/api - -# ============================================ -# CONFIGURAÇÕES DE COMPORTAMENTO -# ============================================ -# Usar Mockaroo para escrita de dados (apenas testes) -USE_MOCKAROO_WRITES=false +FAKERAPI_BASE_URL=https://fakerapi.it/api/v2 +FAKERAPI_LOCALE=pt_BR +FAKERAPI_SEED=42 +FAKERAPI_PRODUCTS_QUANTITY=50 +FAKERAPI_PERSONS_QUANTITY=120 # ============================================ # AMBIENTE E DEBUG # ============================================ # Valores: development, staging, production ENVIRONMENT=development -# DEBUG deve ser false em produção -DEBUG=true \ No newline at end of file +# DEBUG deve ser false em producao +DEBUG=true diff --git a/README.md b/README.md index 180e479..1e81239 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Retorna: "Encontrei 5 veículos sedans disponíveis até R$ 50.000..." | **Backend** | FastAPI | Framework web moderno e rápido para APIs Python | | **IA/LLM** | Google Vertex AI | Plataforma de IA empresarial com Gemini 1.5 Pro | | **Banco de Dados** | PostgreSQL | Banco relacional robusto para dados estruturados | -| **Dados de Teste** | Mockaroo | Geração de dados fictícios para simulação | +| **Dados de Teste** | FakerAPI | Geração de dados fictícios para simulação | | **Containerização** | Docker | Isolamento e deploy consistente | | **Orquestração** | Google Cloud Build | Pipeline automatizado de build e deploy | | **Computação** | Google Cloud Run | Plataforma serverless escalável | @@ -71,7 +71,7 @@ Orquestrador/ │ │ ├── llm_service.py # Integração com Vertex AI / Gemini │ │ ├── tool_registry.py # Registro e descoberta de ferramentas │ │ ├── handlers.py # Handlers de execução de tools -│ │ └── mockaroo_client.py # Cliente para gerar dados fictícios +│ │ └── fakerapi_client.py # Cliente para gerar dados fictícios │ │ │ ├── repositories/ │ │ └── tool_repository.py # Acesso a dados de ferramentas diff --git a/app/core/settings.py b/app/core/settings.py index 17c7341..ac65759 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -4,6 +4,7 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): google_project_id: str google_location: str = "us-central1" + vertex_model_name: str = "gemini-2.5-flash" db_host: str db_port: int = 5432 @@ -11,9 +12,11 @@ class Settings(BaseSettings): db_password: str db_name: str - mockaroo_api_key: str - mockaroo_base_url: str = "https://api.mockaroo.com/api" - use_mockaroo_writes: bool = False + fakerapi_base_url: str = "https://fakerapi.it/api/v2" + fakerapi_locale: str = "pt_BR" + fakerapi_seed: int = 42 + fakerapi_products_quantity: int = 50 + fakerapi_persons_quantity: int = 120 environment: str = "production" debug: bool = False diff --git a/app/services/fakerapi_client.py b/app/services/fakerapi_client.py new file mode 100644 index 0000000..dba6890 --- /dev/null +++ b/app/services/fakerapi_client.py @@ -0,0 +1,57 @@ +from typing import Any, Dict, List, Optional + +import httpx + +from app.core.settings import settings + + +class FakerApiClient: + def __init__( + self, + base_url: Optional[str] = None, + locale: Optional[str] = None, + seed: Optional[int] = None, + ): + self.base_url = (base_url or settings.fakerapi_base_url).rstrip("/") + self.locale = locale or settings.fakerapi_locale + self.seed = settings.fakerapi_seed if seed is None else seed + + async def fetch_resource( + self, + resource: str, + quantity: int, + extra_params: Optional[Dict[str, Any]] = None, + ) -> List[Dict[str, Any]]: + url = f"{self.base_url}/{resource.lstrip('/')}" + params: Dict[str, Any] = { + "_quantity": quantity, + "_locale": self.locale, + "_seed": self.seed, + } + if extra_params: + params.update(extra_params) + + timeout = httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=5.0) + headers = { + "Accept": "application/json", + "User-Agent": "orquestrador-fakerapi-client/1.0", + } + async with httpx.AsyncClient(timeout=timeout, headers=headers) as client: + try: + response = await client.get(url, params=params) + response.raise_for_status() + payload = response.json() + except httpx.ReadTimeout: + # Retry once with smaller payload to reduce timeout risk in free/public APIs. + reduced_quantity = min(quantity, 20) + retry_params = dict(params) + retry_params["_quantity"] = reduced_quantity + response = await client.get(url, params=retry_params) + response.raise_for_status() + payload = response.json() + + if isinstance(payload, dict) and isinstance(payload.get("data"), list): + return payload["data"] + if isinstance(payload, list): + return payload + return [] diff --git a/app/services/handlers.py b/app/services/handlers.py index bf8363f..8995b62 100644 --- a/app/services/handlers.py +++ b/app/services/handlers.py @@ -1,38 +1,159 @@ from typing import Optional, List, Dict, Any -import re -import uuid from datetime import datetime +import hashlib +import re + +import httpx +from fastapi import HTTPException -from app.services.mockaroo_client import MockarooClient from app.core.settings import settings +from app.services.fakerapi_client import FakerApiClient def normalize_cpf(value: str) -> str: return re.sub(r"\D", "", value or "") +def _parse_float(value: Any, default: float = 0.0) -> float: + if value is None: + return default + if isinstance(value, (int, float)): + return float(value) + text = str(value).replace("R$", "").replace(" ", "") + text = text.replace(".", "").replace(",", ".") if "," in text else text + try: + return float(text) + except Exception: + return default + + +def _stable_int(seed_text: str) -> int: + digest = hashlib.sha256(seed_text.encode("utf-8")).hexdigest() + return int(digest[:16], 16) + + +def _cpf_from_any(value: Any) -> str: + as_int = _stable_int(str(value)) % (10**11) + return str(as_int).zfill(11) + + +async def _fetch_faker_products(count: int) -> List[Dict[str, Any]]: + client = FakerApiClient() + try: + return await client.fetch_resource("products", quantity=count) + except httpx.HTTPStatusError as exc: + status_code = exc.response.status_code if exc.response is not None else 502 + request_url = str(exc.request.url) if exc.request is not None else "desconhecida" + raise HTTPException( + status_code=502, + detail=f"FakerAPI retornou HTTP {status_code} em '{request_url}'.", + ) + except httpx.RequestError as exc: + raise HTTPException( + status_code=502, + detail=( + "Falha de rede ao acessar FakerAPI (products). " + f"{exc.__class__.__name__}: {exc}. " + "Verifique egress/NAT do Cloud Run e resolucao DNS." + ), + ) + except Exception: + raise HTTPException( + status_code=502, + detail="Falha de integracao com FakerAPI ao consultar products.", + ) + + +async def _fetch_faker_persons(count: int) -> List[Dict[str, Any]]: + client = FakerApiClient() + try: + return await client.fetch_resource("persons", quantity=count) + except httpx.HTTPStatusError as exc: + status_code = exc.response.status_code if exc.response is not None else 502 + request_url = str(exc.request.url) if exc.request is not None else "desconhecida" + raise HTTPException( + status_code=502, + detail=f"FakerAPI retornou HTTP {status_code} em '{request_url}'.", + ) + except httpx.RequestError as exc: + raise HTTPException( + status_code=502, + detail=( + "Falha de rede ao acessar FakerAPI (persons). " + f"{exc.__class__.__name__}: {exc}. " + "Verifique egress/NAT do Cloud Run e resolucao DNS." + ), + ) + except Exception: + raise HTTPException( + status_code=502, + detail="Falha de integracao com FakerAPI ao consultar persons.", + ) + + async def consultar_estoque(preco_max: float, categoria: Optional[str] = None) -> List[Dict[str, Any]]: - client = MockarooClient() - registros = await client.fetch_schema_data("consultar_estoque", count=200) + raw = await _fetch_faker_products(settings.fakerapi_products_quantity) + registros: List[Dict[str, Any]] = [] + + for item in raw: + categories = item.get("categories") + if isinstance(categories, list) and categories: + category_value = str(categories[0]) + else: + category_value = str(item.get("category") or "geral") + + registro = { + "id": item.get("id"), + "modelo": item.get("name") or item.get("title") or "Veiculo", + "categoria": category_value.lower(), + "preco": _parse_float(item.get("price"), 0.0), + } + registros.append(registro) + + categoria_norm = categoria.lower() if categoria else None return [ r for r in registros - if float(r.get("preco", 0)) <= preco_max and (categoria is None or r.get("categoria") == categoria) + if _parse_float(r.get("preco"), 0.0) <= preco_max + and (categoria_norm is None or str(r.get("categoria", "")).lower() == categoria_norm) ] async def validar_cliente_venda(cpf: str, valor_veiculo: float) -> Dict[str, Any]: - client = MockarooClient() - registros = await client.fetch_schema_data("clientes_credito", count=500) cpf_norm = normalize_cpf(cpf) + raw = await _fetch_faker_persons(settings.fakerapi_persons_quantity) + + registros: List[Dict[str, Any]] = [] + for item in raw: + person_id = item.get("id") or item.get("email") or item.get("firstname") + generated_cpf = _cpf_from_any(person_id) + entropy = _stable_int(f"{generated_cpf}:{settings.fakerapi_seed}") + limite = float(30000 + (entropy % 150000)) + score = int(300 + (entropy % 550)) + possui_restricao = (entropy % 7 == 0) + nome = f"{item.get('firstname', '')} {item.get('lastname', '')}".strip() or "Cliente" + + registros.append( + { + "cpf": generated_cpf, + "nome": nome, + "score": score, + "limite_credito": limite, + "possui_restricao": possui_restricao, + } + ) + cliente = next((r for r in registros if normalize_cpf(r.get("cpf", "")) == cpf_norm), None) if not cliente: - return { - "aprovado": False, - "motivo": "Cliente não encontrado", + entropy = _stable_int(f"{cpf_norm}:{settings.fakerapi_seed}") + cliente = { "cpf": cpf_norm, - "valor_veiculo": valor_veiculo, + "nome": "Cliente Faker", + "score": int(300 + (entropy % 550)), + "limite_credito": float(30000 + (entropy % 150000)), + "possui_restricao": (entropy % 7 == 0), } - limite = float(cliente.get("limite_credito", 0)) + + limite = _parse_float(cliente.get("limite_credito", 0), 0.0) restricao = bool(cliente.get("possui_restricao", False)) aprovado = (not restricao) and (valor_veiculo <= limite) return { @@ -61,32 +182,14 @@ async def avaliar_veiculo_troca(modelo: str, ano: int, km: int) -> Dict[str, Any async def agendar_revisao(placa: str, data_hora: str) -> Dict[str, Any]: - if settings.use_mockaroo_writes: - client = MockarooClient() - try: - return await client.post_json( - "agendamentos_revisao", - {"placa": placa, "data_hora": data_hora, "status": "Agendado"}, - ) - except Exception: - pass - agendamento_id = str(uuid.uuid4()) - return {"id": agendamento_id, "placa": placa, "data_hora": data_hora, "status": "Agendado"} + raise HTTPException( + status_code=503, + detail="FakerAPI nao suporta escrita/persistencia. Endpoint disponivel apenas para leitura de dados ficticios.", + ) async def cancelar_pedido(numero_pedido: str, motivo: str) -> Dict[str, Any]: - client = MockarooClient() - registros = await client.fetch_schema_data("pedidos", count=500) - pedido = next((r for r in registros if str(r.get("numero_pedido")) == str(numero_pedido)), None) - if not pedido: - return { - "status": "NaoEncontrado", - "numero_pedido": numero_pedido, - "motivo": motivo, - } - if settings.use_mockaroo_writes: - try: - await client.delete_json("pedidos", {"numero_pedido": numero_pedido, "motivo": motivo}) - except Exception: - pass - return {"status": "Cancelado", "numero_pedido": numero_pedido, "motivo": motivo, "pedido": pedido} + raise HTTPException( + status_code=503, + detail="FakerAPI nao suporta cancelamento persistente de pedidos. Endpoint indisponivel neste modo.", + ) diff --git a/app/services/llm_service.py b/app/services/llm_service.py index 3d83d8e..e269fe0 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -1,5 +1,6 @@ -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional import vertexai +from google.api_core.exceptions import NotFound from vertexai.generative_models import GenerativeModel, Tool, FunctionDeclaration from app.core.settings import settings from app.models.tool_model import ToolDefinition @@ -13,27 +14,26 @@ class LLMService: location=settings.google_location ) - self.model = GenerativeModel("gemini-1.5-flash") - - def build_vertex_tools(self, tools: List[ToolDefinition]): # Converte as Tools internas (ToolDefinition) para o formato que o Vertex AI entende. - - vertex_tools = [] - - # Para cada Tool registrada no sistema (depende da proposta do cliente) criamos uma Tool do Vertex AI - for tool in tools: - vertex_tools.append( - Tool( - function_declarations=[ - FunctionDeclaration( - name=tool.name, - description=tool.description, - parameters=tool.parameters - ) - ] - ) + configured = settings.vertex_model_name.strip() + fallback_models = ["gemini-2.5-flash", "gemini-2.0-flash-001", "gemini-1.5-pro"] + self.model_names = [configured] + [m for m in fallback_models if m != configured] + + def build_vertex_tools(self, tools: List[ToolDefinition]) -> Optional[List[Tool]]: + # Vertex espera uma lista de Tool, mas com function_declarations agrupadas em um único Tool + # para uso de múltiplas funções no mesmo request. + if not tools: + return None + + function_declarations = [ + FunctionDeclaration( + name=tool.name, + description=tool.description, + parameters=tool.parameters, ) + for tool in tools + ] - return vertex_tools + return [Tool(function_declarations=function_declarations)] """ Fluxo principal de geração de resposta. @@ -55,14 +55,26 @@ class LLMService: # Inicia uma sessão de chat com: # - histórico (se existir) # - ferramentas disponíveis - chat = self.model.start_chat( - history=history or [] - ) - - response = chat.send_message( - message, - tools=vertex_tools - ) + response = None + last_error = None + + for model_name in self.model_names: + try: + model = GenerativeModel(model_name) + chat = model.start_chat(history=history or []) + send_kwargs = {"tools": vertex_tools} if vertex_tools else {} + response = chat.send_message(message, **send_kwargs) + break + except NotFound as err: + last_error = err + continue + + if response is None: + if last_error: + raise RuntimeError( + f"Nenhum modelo Vertex disponível. Verifique VERTEX_MODEL_NAME e acesso no projeto. Erro: {last_error}" + ) from last_error + raise RuntimeError("Falha ao gerar resposta no Vertex AI.") # Pegamos a primeira resposta candidata do modelo (a com maior coerência com o assunto) # Estrutura interna: diff --git a/app/services/mockaroo_client.py b/app/services/mockaroo_client.py deleted file mode 100644 index 598d9ce..0000000 --- a/app/services/mockaroo_client.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Any, Dict, List, Optional - -import httpx - -from app.core.settings import settings - -class MockarooClient: - def __init__( - self, - api_key: Optional[str] = None, - base_url: Optional[str] = None, - ): - self.api_key = api_key or settings.mockaroo_api_key - self.base_url = (base_url or settings.mockaroo_base_url).rstrip("/") - - async def fetch_schema_data( - self, - schema_name: str, - count: int = 100, - extra_params: Optional[Dict[str, Any]] = None, - ) -> List[Dict[str, Any]]: - url = f"{self.base_url}/{schema_name}" - params: Dict[str, Any] = { - "key": self.api_key, - "count": count, - } - if extra_params: - params.update(extra_params) - - async with httpx.AsyncClient() as client: - response = await client.get(url, params=params) - response.raise_for_status() - data = response.json() - if isinstance(data, list): - return data - return [data] - - async def post_json(self, route: str, payload: Dict[str, Any]) -> Dict[str, Any]: - url = f"{self.base_url}/{route}" - params = {"key": self.api_key} - async with httpx.AsyncClient() as client: - response = await client.post(url, params=params, json=payload) - response.raise_for_status() - return response.json() - - async def put_json(self, route: str, payload: Dict[str, Any]) -> Dict[str, Any]: - url = f"{self.base_url}/{route}" - params = {"key": self.api_key} - async with httpx.AsyncClient() as client: - response = await client.put(url, params=params, json=payload) - response.raise_for_status() - return response.json() - - async def delete_json(self, route: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - url = f"{self.base_url}/{route}" - params = {"key": self.api_key} - async with httpx.AsyncClient() as client: - response = await client.delete(url, params=params, json=payload or {}) - response.raise_for_status() - return response.json() diff --git a/app/services/orquestrador_service.py b/app/services/orquestrador_service.py index 8674cf9..84a83a2 100644 --- a/app/services/orquestrador_service.py +++ b/app/services/orquestrador_service.py @@ -10,14 +10,13 @@ class OrquestradorService: self.llm = LLMService() self.registry = ToolRegistry(db) - """ - Método principal chamado quando o usuário envia uma mensagem. + Metodo principal chamado quando o usuario envia uma mensagem. - Parâmetros: - - message: texto enviado pelo usuário - - user_id: identificador do usuário (ainda não está sendo usado aqui, - mas futuramente servirá para histórico) + Parametros: + - message: texto enviado pelo usuario + - user_id: identificador do usuario (ainda nao esta sendo usado aqui, + mas futuramente servira para historico) """ async def handle_message(self, message: str) -> str: @@ -25,24 +24,23 @@ class OrquestradorService: llm_result = await self.llm.generate_response( message=message, - tools=tools + tools=tools, ) if llm_result["tool_call"]: - - tool_name = llm_result["tool_call"]["name"] # Nome da função que o Gemini quer executar - arguments = llm_result["tool_call"]["arguments"] # Argumentos extraídos da mensagem do usuário + tool_name = llm_result["tool_call"]["name"] + arguments = llm_result["tool_call"]["arguments"] tool_result = await self.registry.execute(tool_name, arguments) # Segunda rodada para formatar resposta final_response = await self.llm.generate_response( - message=f"Resultado da função {tool_name}: {tool_result}", - tools=tools + message=f"Resultado da funcao {tool_name}: {tool_result}", + tools=tools, ) return final_response["response"] - # Se o modelo não chamou nenhuma tool, + # Se o modelo nao chamou nenhuma tool, # significa que ele respondeu diretamente em texto. return llm_result["response"]