Compare commits

..

No commits in common. '3f1024d3420c79c6761baf175c26b5f10d5ba6b6' and 'cc02d0409748c68145a1f4702207a2281bfe6b2b' have entirely different histories.

@ -7,9 +7,9 @@ GOOGLE_LOCATION=loc_do_seu_projeto
VERTEX_MODEL_NAME=gemini-2.5-flash
# ============================================
# CONFIGURACOES DO BANCO DE DADOS (POSTGRESQL - TOOLS)
# CONFIGURACOES DO BANCO DE DADOS (LOCAL)
# ============================================
# Banco principal (tools)
# Para desenvolvimento local: PostgreSQL direto
DB_HOST=localhost
DB_PORT=5432
@ -23,25 +23,21 @@ DB_NAME=orquestrador_db
# Comentado ate fazer deploy. Descomente em producao.
# CLOUD_SQL_CONNECTION_NAME=optimum-tensor-343619:us-central1:orquestrador-db
# ============================================
# CONFIGURACOES DO BANCO DE DADOS MOCK (MYSQL - DADOS FICTICIOS)
# ============================================
MOCK_DB_HOST=127.0.0.1
MOCK_DB_PORT=3306
MOCK_DB_USER=root
MOCK_DB_PASSWORD=SUA_SENHA
MOCK_DB_NAME=orquestrador_mock
# MOCK_DB_CLOUD_SQL_CONNECTION_NAME=projeto:regiao:instancia-mysql
MOCK_SEED_ENABLED=true
AUTO_SEED_TOOLS=true
AUTO_SEED_MOCK=true
# ============================================
# CONFIGURACOES DE API - GOOGLE GENERATIVE AI (Gemini)
# ============================================
# Descomente e informe a chave apenas se usar Gemini
# GOOGLE_API_KEY=sua-chave-api-aqui
# ============================================
# CONFIGURACOES DE API - FAKERAPI (Dados ficticios)
# ============================================
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
# ============================================
@ -49,10 +45,3 @@ AUTO_SEED_MOCK=true
ENVIRONMENT=development
# DEBUG deve ser false em producao
DEBUG=true
# ============================================
# CLOUD RUN - REDE PARA MYSQL CORPORATIVO (PRODUCAO)
# ============================================
# Ex.: projects/<project>/locations/<region>/connectors/<connector-name>
# RUN_VPC_CONNECTOR=
# RUN_VPC_EGRESS=private-ranges-only

@ -36,8 +36,8 @@ 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 (Tools)** | PostgreSQL | Banco relacional para metadados de ferramentas |
| **Banco Fictício (Mock)** | MySQL | Dados de negócio simulados usados pelos handlers |
| **Banco de Dados** | PostgreSQL | Banco relacional robusto para dados estruturados |
| **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
│ │ └── handlers.py # Handlers consultando MySQL fictício
│ │ └── fakerapi_client.py # Cliente para gerar dados fictícios
│ │
│ ├── repositories/
│ │ └── tool_repository.py # Acesso a dados de ferramentas
@ -157,7 +157,7 @@ Pipeline CI/CD totalmente gerenciado que faz build da imagem, realiza testes e f
┌──────────────────────────────────┐
│ Tool Handlers │
│ - Executa: consultar_estoque │
│ - Busca no MySQL fictício
│ - Busca no PostgreSQL
└──────┬───────────────────────────┘

@ -51,36 +51,6 @@ curl -X POST http://localhost:8000/chat \
---
### 2.1. Consultar Carro Mais Barato
```bash
curl -X POST http://localhost:8000/chat \
-H "Content-Type: application/json" \
-d '{
"message": "Qual e o carro mais barato que voces tem?",
"user_id": "user-123"
}'
```
**Esperado**: Modelo chama `consultar_estoque` com `ordenar_preco=asc` e `limite=1`
---
### 2.2. Consultar Carro Mais Caro
```bash
curl -X POST http://localhost:8000/chat \
-H "Content-Type: application/json" \
-d '{
"message": "Qual e o carro mais caro que voces tem?",
"user_id": "user-123"
}'
```
**Esperado**: Modelo chama `consultar_estoque` com `ordenar_preco=desc` e `limite=1`
---
### 3. Validar Cliente Venda
```bash
@ -177,18 +147,6 @@ curl -X POST http://localhost:8000/mock/consultar-estoque \
-d '{"preco_max": 50000}'
```
```bash
curl -X POST http://localhost:8000/mock/consultar-estoque \
-H "Content-Type: application/json" \
-d '{"ordenar_preco": "asc", "limite": 1}'
```
```bash
curl -X POST http://localhost:8000/mock/consultar-estoque \
-H "Content-Type: application/json" \
-d '{"ordenar_preco": "desc", "limite": 1}'
```
### Ver resposta do Vertex AI
Adicione console.log nos handlers para ver o que o Vertex retorna.

@ -1,7 +1,6 @@
from typing import List, Dict, Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import SQLAlchemyError
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.schemas import (
@ -33,22 +32,6 @@ def get_db():
finally:
db.close()
def _db_error_detail(exc: SQLAlchemyError) -> str:
text = str(exc).lower()
# Heuristica para identificar falhas no MySQL (base ficticia).
mysql_markers = ("mysql", "pymysql", "(2003", "mock", "3306")
if any(marker in text for marker in mysql_markers):
return "Servico temporariamente indisponivel: banco MySQL (dados ficticios) inacessivel."
# Heuristica para identificar falhas no PostgreSQL (tools).
pg_markers = ("postgres", "psycopg", "postgresql", "5432", "tools")
if any(marker in text for marker in pg_markers):
return "Servico temporariamente indisponivel: banco PostgreSQL (tools) inacessivel."
return "Servico temporariamente indisponivel: erro de acesso ao banco de dados."
'''
# Removido momentaniamente para teste do Vertex IA
@router.post("/chat", response_model=ChatResponse)
@ -62,48 +45,33 @@ async def chat(request: ChatRequest, db: Session = Depends(get_db)):
'''
@router.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest, db: Session = Depends(get_db)):
try:
service = OrquestradorService(db)
result = await service.handle_message(message=request.message)
return ChatResponse(response=result)
except SQLAlchemyError as exc:
raise HTTPException(
status_code=503,
detail=_db_error_detail(exc),
)
except ValueError as exc:
# Erros de configuracao de Vertex (regiao/projeto/modelo)
raise HTTPException(status_code=500, detail=f"Configuracao invalida do Vertex AI: {exc}")
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=f"Falha temporaria no LLM/Vertex AI: {exc}")
service = OrquestradorService(db)
result = await service.handle_message(
message=request.message
)
return ChatResponse(response=result)
@router.post("/mock/consultar-estoque")
async def consultar_estoque_endpoint(
body: ConsultarEstoqueRequest,
) -> List[Dict[str, Any]]:
try:
return await consultar_estoque(
preco_max=body.preco_max,
categoria=body.categoria,
ordenar_preco=body.ordenar_preco,
limite=body.limite,
)
except SQLAlchemyError as exc:
raise HTTPException(status_code=503, detail=_db_error_detail(exc))
return await consultar_estoque(
preco_max=body.preco_max,
categoria=body.categoria,
)
@router.post("/mock/validar-cliente-venda")
async def validar_cliente_venda_endpoint(
body: ValidarClienteVendaRequest,
) -> Dict[str, Any]:
try:
return await validar_cliente_venda(
cpf=body.cpf,
valor_veiculo=body.valor_veiculo,
)
except SQLAlchemyError as exc:
raise HTTPException(status_code=503, detail=_db_error_detail(exc))
return await validar_cliente_venda(
cpf=body.cpf,
valor_veiculo=body.valor_veiculo,
)
@router.post("/mock/avaliar-veiculo-troca")
@ -121,23 +89,17 @@ async def avaliar_veiculo_troca_endpoint(
async def agendar_revisao_endpoint(
body: AgendarRevisaoRequest,
) -> Dict[str, Any]:
try:
return await agendar_revisao(
placa=body.placa,
data_hora=body.data_hora,
)
except SQLAlchemyError as exc:
raise HTTPException(status_code=503, detail=_db_error_detail(exc))
return await agendar_revisao(
placa=body.placa,
data_hora=body.data_hora,
)
@router.post("/mock/cancelar-pedido")
async def cancelar_pedido_endpoint(
body: CancelarPedidoRequest,
) -> Dict[str, Any]:
try:
return await cancelar_pedido(
numero_pedido=body.numero_pedido,
motivo=body.motivo,
)
except SQLAlchemyError as exc:
raise HTTPException(status_code=503, detail=_db_error_detail(exc))
return await cancelar_pedido(
numero_pedido=body.numero_pedido,
motivo=body.motivo,
)

@ -1,5 +1,5 @@
from pydantic import BaseModel
from typing import Dict, Any, Optional, Literal
from typing import Dict, Any, Optional
class ChatRequest(BaseModel):
message: str
@ -26,10 +26,8 @@ class ToolResponse(BaseModel):
class ConsultarEstoqueRequest(BaseModel):
preco_max: Optional[float] = None
preco_max: float
categoria: Optional[str] = None
ordenar_preco: Optional[Literal["asc", "desc"]] = None
limite: Optional[int] = None
class ValidarClienteVendaRequest(BaseModel):

@ -12,16 +12,11 @@ class Settings(BaseSettings):
db_password: str
db_name: str
# Mock database (MySQL) for fictitious business data
mock_db_host: str = "127.0.0.1"
mock_db_port: int = 3306
mock_db_user: str = "root"
mock_db_password: str = ""
mock_db_name: str = "orquestrador_mock"
mock_db_cloud_sql_connection_name: str | None = None
mock_seed_enabled: bool = True
auto_seed_tools: bool = True
auto_seed_mock: bool = True
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
@ -29,10 +24,6 @@ class Settings(BaseSettings):
# Cloud SQL
cloud_sql_connection_name: str | None = None
# Cloud Run networking (for deploy script / documentation)
run_vpc_connector: str | None = None
run_vpc_egress: str = "private-ranges-only"
class Config:
env_file = ".env"
extra = "ignore"

@ -3,16 +3,17 @@ from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.settings import settings
if settings.cloud_sql_connection_name:
# Cloud Run - PostgreSQL via Unix socket
# Cloud Run - Formato para PostgreSQL
# Note que usamos 'host' dentro da query string para apontar o socket
DATABASE_URL = (
f"postgresql+psycopg://{settings.db_user}:{settings.db_password}@/{settings.db_name}"
f"postgresql+psycopg2://{settings.db_user}:{settings.db_password}@/{settings.db_name}"
f"?host=/cloudsql/{settings.cloud_sql_connection_name}"
)
else:
# Ambiente local/VPN - PostgreSQL em host/porta configurados
# Ambiente local (via Cloud SQL Proxy)
DATABASE_URL = (
f"postgresql+psycopg://{settings.db_user}:{settings.db_password}@"
f"{settings.db_host}:{settings.db_port}/{settings.db_name}"
f"postgresql+psycopg2://{settings.db_user}:{settings.db_password}@"
f"127.0.0.1:5432/{settings.db_name}"
)
engine = create_engine(

@ -1,40 +1,28 @@
"""
Inicializacao de banco de dados.
Cria tabelas e executa seed inicial em ambos os bancos.
Inicialização de banco de dados
Cria tabelas e faz seed dos dados iniciais
"""
from app.db.database import Base, engine
from app.db.mock_database import MockBase, mock_engine
from app.db.models import Tool
from app.db.mock_models import Customer, Order, ReviewSchedule, Vehicle
from app.db.mock_seed import seed_mock_data
from app.db.tool_seed import seed_tools
from app.db.tool_seed import get_tools_definitions, seed_tools
def init_db():
"""Cria tabelas e executa seed inicial em ambos os bancos."""
print("Inicializando bancos...")
try:
print("Criando tabelas PostgreSQL (tools)...")
Base.metadata.create_all(bind=engine)
print("Populando tools iniciais...")
seed_tools()
print("PostgreSQL OK.")
except Exception as exc:
print(f"Aviso: falha no PostgreSQL (tools): {exc}")
try:
print("Criando tabelas MySQL (dados ficticios)...")
MockBase.metadata.create_all(bind=mock_engine)
print("Populando dados ficticios iniciais...")
seed_mock_data()
print("MySQL mock OK.")
except Exception as exc:
print(f"Aviso: falha no MySQL mock: {exc}")
print("Bancos inicializados com sucesso!")
"""Cria todas as tabelas e faz o seed dos dados iniciais"""
print("📊 Inicializando banco de dados...")
# Cria todas as tabelas
print("🔨 Criando tabelas...")
Base.metadata.create_all(bind=engine)
# Seed das tools
print("📥 Populando tools iniciais...")
seed_tools()
print("✅ Banco de dados inicializado com sucesso!")
if __name__ == "__main__":
init_db()

@ -1,31 +0,0 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.settings import settings
if settings.mock_db_cloud_sql_connection_name:
# Cloud SQL MySQL via Unix socket
MOCK_DATABASE_URL = (
f"mysql+pymysql://{settings.mock_db_user}:{settings.mock_db_password}@/{settings.mock_db_name}"
f"?unix_socket=/cloudsql/{settings.mock_db_cloud_sql_connection_name}"
)
else:
MOCK_DATABASE_URL = (
f"mysql+pymysql://{settings.mock_db_user}:{settings.mock_db_password}@"
f"{settings.mock_db_host}:{settings.mock_db_port}/{settings.mock_db_name}"
)
mock_engine = create_engine(
MOCK_DATABASE_URL,
pool_pre_ping=True,
connect_args={"connect_timeout": 5},
)
SessionMockLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=mock_engine,
)
MockBase = declarative_base()

@ -1,54 +0,0 @@
from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.sql import func
from app.db.mock_database import MockBase
class Vehicle(MockBase):
__tablename__ = "vehicles"
id = Column(Integer, primary_key=True, index=True)
modelo = Column(String(120), nullable=False)
categoria = Column(String(50), nullable=False, index=True)
preco = Column(Float, nullable=False, index=True)
created_at = Column(DateTime, server_default=func.current_timestamp())
class Customer(MockBase):
__tablename__ = "customers"
id = Column(Integer, primary_key=True, index=True)
cpf = Column(String(11), unique=True, nullable=False, index=True)
nome = Column(String(120), nullable=False)
score = Column(Integer, nullable=False)
limite_credito = Column(Float, nullable=False)
possui_restricao = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, server_default=func.current_timestamp())
class Order(MockBase):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
numero_pedido = Column(String(40), unique=True, nullable=False, index=True)
cpf = Column(String(11), ForeignKey("customers.cpf"), nullable=False, index=True)
status = Column(String(20), nullable=False, default="Ativo")
motivo_cancelamento = Column(Text, nullable=True)
data_cancelamento = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.current_timestamp())
updated_at = Column(
DateTime,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
)
class ReviewSchedule(MockBase):
__tablename__ = "review_schedules"
id = Column(Integer, primary_key=True, index=True)
protocolo = Column(String(50), unique=True, nullable=False, index=True)
placa = Column(String(10), nullable=False, index=True)
data_hora = Column(DateTime, nullable=False)
status = Column(String(20), nullable=False, default="agendado")
created_at = Column(DateTime, server_default=func.current_timestamp())

@ -1,96 +0,0 @@
import random
from datetime import datetime
from app.core.settings import settings
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import Customer, Order, Vehicle
VEHICLE_MODELS = [
"Toyota Corolla",
"Honda Civic",
"Chevrolet Onix",
"Hyundai HB20",
"Volkswagen T-Cross",
"Jeep Compass",
"Fiat Argo",
"Nissan Kicks",
"Renault Duster",
"Ford Ranger",
]
CATEGORIES = ["hatch", "sedan", "suv", "pickup"]
NAMES = [
"Ana Souza",
"Bruno Lima",
"Carla Mendes",
"Diego Santos",
"Eduarda Alves",
"Felipe Rocha",
"Gabriela Costa",
"Henrique Martins",
"Isabela Ferreira",
"Joao Ribeiro",
]
def _cpf_from_index(index: int) -> str:
return str(10_000_000_000 + index).zfill(11)
def seed_mock_data() -> None:
if not settings.mock_seed_enabled:
return
rng = random.Random(42)
db = SessionMockLocal()
try:
if db.query(Vehicle).count() == 0:
vehicles = []
for idx in range(60):
model = VEHICLE_MODELS[idx % len(VEHICLE_MODELS)]
category = CATEGORIES[idx % len(CATEGORIES)]
base_price = 55_000 + (idx * 1_700)
noise = rng.randint(-7_000, 9_000)
vehicles.append(
Vehicle(
modelo=f"{model} {2020 + (idx % 6)}",
categoria=category,
preco=float(max(35_000, base_price + noise)),
)
)
db.add_all(vehicles)
db.commit()
if db.query(Customer).count() == 0:
customers = []
for idx in range(120):
entropy = (idx * 9973) % 10_000
customers.append(
Customer(
cpf=_cpf_from_index(idx),
nome=f"{NAMES[idx % len(NAMES)]} {idx + 1}",
score=300 + (entropy % 550),
limite_credito=float(30_000 + (entropy * 12)),
possui_restricao=(idx % 11 == 0),
)
)
db.add_all(customers)
db.commit()
if db.query(Order).count() == 0:
orders = []
for idx in range(40):
created = datetime(2026, 1, 1, 8, 0, 0)
orders.append(
Order(
numero_pedido=f"PED-{2026}{idx + 1:05d}",
cpf=_cpf_from_index(idx),
status="Ativo",
created_at=created,
)
)
db.add_all(orders)
db.commit()
finally:
db.close()

@ -6,53 +6,35 @@ def get_tools_definitions():
return [
{
"name": "consultar_estoque",
"description": (
"Use esta ferramenta para consultar veiculos disponiveis no estoque. "
"Voce pode filtrar por preco maximo e categoria, e tambem ordenar por "
"preco para descobrir o carro mais barato (asc + limite=1) ou mais "
"caro (desc + limite=1)."
),
"description": "Use esta ferramenta para consultar veículos disponíveis no estoque até um preço máximo informado pelo cliente, opcionalmente filtrando por categoria (Hatch, Sedan, SUV, etc.). Ideal quando o cliente quer saber quais carros cabem no orçamento ou comparar opções dentro de uma faixa de preço.",
"parameters": {
"type": "object",
"properties": {
"preco_max": {
"type": "number",
"description": "Preco maximo do veiculo em reais (BRL). Opcional.",
"description": "Preço máximo do veículo em reais (BRL)."
},
"categoria": {
"type": "string",
"description": "Categoria do veiculo, por exemplo: Hatch, Sedan, SUV. Opcional.",
},
"ordenar_preco": {
"type": "string",
"description": "Ordenacao do preco. Use 'asc' para mais barato e 'desc' para mais caro.",
},
"limite": {
"type": "integer",
"description": "Quantidade maxima de veiculos retornados.",
"description": "Categoria do veículo, por exemplo: Hatch, Sedan, SUV. Opcional."
},
},
"required": [],
"required": ["preco_max"],
},
},
{
"name": "validar_cliente_venda",
"description": (
"Use esta ferramenta quando precisar avaliar se o cliente pode financiar "
"um veiculo especifico. Ela recebe o CPF e o valor do veiculo, consulta "
"um score simulado e retorna se o cliente esta aprovado ou reprovado "
"para a compra, juntamente com o score e um limite de credito estimado."
),
"description": "Use esta ferramenta quando precisar avaliar se o cliente pode financiar um veículo específico. Ela recebe o CPF e o valor do veículo, consulta um score simulado e retorna se o cliente está aprovado ou reprovado para a compra, juntamente com o score e um limite de crédito estimado.",
"parameters": {
"type": "object",
"properties": {
"cpf": {
"type": "string",
"description": "CPF do cliente, com ou sem formatacao (apenas digitos tambem e aceito).",
"description": "CPF do cliente, com ou sem formatação (apenas dígitos também é aceito)."
},
"valor_veiculo": {
"type": "number",
"description": "Valor do veiculo em reais (BRL) que o cliente deseja comprar.",
"description": "Valor do veículo em reais (BRL) que o cliente deseja comprar."
},
},
"required": ["cpf", "valor_veiculo"],
@ -60,26 +42,21 @@ def get_tools_definitions():
},
{
"name": "avaliar_veiculo_troca",
"description": (
"Use esta ferramenta quando o cliente quiser saber quanto o carro dele "
"vale como entrada em uma negociacao. Ela recebe modelo, ano e "
"quilometragem do veiculo atual e devolve um valor estimado de avaliacao "
"para troca, ja considerando depreciacao por ano e quilometragem."
),
"description": "Use esta ferramenta quando o cliente quiser saber quanto o carro dele vale como entrada em uma negociação. Ela recebe modelo, ano e quilometragem do veículo atual e devolve um valor estimado de avaliação para troca, já considerando depreciação por ano e quilometragem.",
"parameters": {
"type": "object",
"properties": {
"modelo": {
"type": "string",
"description": "Modelo do veiculo que o cliente deseja oferecer na troca (por exemplo, 'Toyota Corolla').",
"description": "Modelo do veículo que o cliente deseja oferecer na troca (por exemplo, 'Toyota Corolla')."
},
"ano": {
"type": "integer",
"description": "Ano de fabricacao do veiculo do cliente.",
"description": "Ano de fabricação do veículo do cliente."
},
"km": {
"type": "integer",
"description": "Quilometragem atual do veiculo do cliente.",
"description": "Quilometragem atual do veículo do cliente."
},
},
"required": ["modelo", "ano", "km"],
@ -87,22 +64,17 @@ def get_tools_definitions():
},
{
"name": "agendar_revisao",
"description": (
"Use esta ferramenta quando o cliente quiser marcar uma revisao ou "
"manutencao para o veiculo. Ela recebe a placa e a data/hora desejada, "
"cria um agendamento simulado e retorna um identificador, alem do "
"status do agendamento."
),
"description": "Use esta ferramenta quando o cliente quiser marcar uma revisão ou manutenção para o veículo. Ela recebe a placa e a data/hora desejada, cria um agendamento simulado e retorna um identificador, além do status do agendamento.",
"parameters": {
"type": "object",
"properties": {
"placa": {
"type": "string",
"description": "Placa do veiculo que sera levado para revisao.",
"description": "Placa do veículo que será levado para revisão."
},
"data_hora": {
"type": "string",
"description": "Data e hora desejada para a revisao, em formato ISO 8601 (por exemplo, '2026-03-10T09:00:00-03:00').",
"description": "Data e hora desejada para a revisão, em formato ISO 8601 (por exemplo, '2026-03-10T09:00:00-03:00')."
},
},
"required": ["placa", "data_hora"],
@ -110,22 +82,17 @@ def get_tools_definitions():
},
{
"name": "cancelar_pedido",
"description": (
"Use esta ferramenta quando o cliente solicitar o cancelamento de um "
"pedido ja registrado. Ela recebe o numero do pedido e o motivo do "
"cancelamento, atualiza o status para 'Cancelado' e retorna os detalhes "
"do cancelamento para que voce explique o resultado ao cliente."
),
"description": "Use esta ferramenta quando o cliente solicitar o cancelamento de um pedido já registrado. Ela recebe o número do pedido e o motivo do cancelamento, atualiza o status para 'Cancelado' e retorna os detalhes do cancelamento para que você explique o resultado ao cliente.",
"parameters": {
"type": "object",
"properties": {
"numero_pedido": {
"type": "string",
"description": "Numero do pedido que o cliente deseja cancelar.",
"description": "Número do pedido que o cliente deseja cancelar."
},
"motivo": {
"type": "string",
"description": "Motivo do cancelamento informado pelo cliente (por exemplo, atraso, mudanca de planos, condicao de pagamento, etc.).",
"description": "Motivo do cancelamento informado pelo cliente (por exemplo, atraso, mudança de planos, condição de pagamento, etc.)."
},
},
"required": ["numero_pedido", "motivo"],
@ -142,11 +109,6 @@ def seed_tools():
existing_names = {t.name for t in existing}
for tool_def in get_tools_definitions():
if tool_def["name"] in existing_names:
repo.update_by_name(
name=tool_def["name"],
description=tool_def["description"],
parameters=tool_def["parameters"],
)
continue
repo.create(
name=tool_def["name"],

@ -1,12 +1,9 @@
from fastapi import FastAPI
from app.api.routes import router
from app.api.tool_routes import router as tool_router
from app.core.settings import settings
from app.db.database import Base, engine
from app.db.mock_database import MockBase, mock_engine
# 👇 IMPORTANTE: registrar models no metadata
from app.db.models import Tool
from app.db.mock_models import Customer, Order, ReviewSchedule, Vehicle
app = FastAPI(title="AI Orquestrador")
@ -19,25 +16,21 @@ async def startup_event():
"""
Inicializa o banco de dados e executa seeds automaticamente.
"""
print("[Auto-Seed] Iniciando configuracao do banco...")
# PostgreSQL (tools) e MySQL (mock) sobem de forma independente.
try:
print("🚀 [Auto-Seed] Iniciando configuração do banco...")
# 1. Cria as tabelas se não existirem
# O engine deve estar configurado para usar o Unix Socket no Cloud Run
Base.metadata.create_all(bind=engine)
if settings.auto_seed_tools:
from app.db.tool_seed import seed_tools
seed_tools()
print("[Auto-Seed] PostgreSQL de tools inicializado.")
except Exception as e:
print(f"[Auto-Seed] Aviso: falha ao inicializar PostgreSQL (tools): {e}")
try:
MockBase.metadata.create_all(bind=mock_engine)
if settings.auto_seed_mock and settings.mock_seed_enabled:
from app.db.mock_seed import seed_mock_data
seed_mock_data()
print("[Auto-Seed] MySQL de mock inicializado.")
except Exception as e:
print(f"[Auto-Seed] Aviso: falha ao inicializar MySQL (mock): {e}")
# 2. Executa a seed das ferramentas
from app.db.tool_seed import seed_tools
seed_tools()
print("[Auto-Seed] Startup finalizado.")
print("✅ [Auto-Seed] Tabelas e ferramentas configuradas com sucesso.")
except Exception as e:
# IMPORTANTE: Logamos o erro mas NÃO damos 'raise e'
# Isso permite que o Uvicorn abra a porta 8080 e o deploy complete
print(f"⚠️ [Auto-Seed] Aviso: Falha na inicialização automática: {e}")
print("A aplicação tentará operar, verifique a conexão com o Cloud SQL.")

@ -30,14 +30,3 @@ class ToolRepository:
self.db.delete(tool)
self.db.commit()
return tool
def update_by_name(self, name: str, description: str, parameters: dict):
tool = self.db.query(Tool).filter(Tool.name == name).first()
if not tool:
return None
tool.description = description
tool.parameters = parameters
self.db.commit()
self.db.refresh(tool)
return tool

@ -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 []

@ -1,12 +1,13 @@
from typing import Optional, List, Dict, Any
from datetime import datetime
import hashlib
import re
from typing import Any, Dict, List, Optional
import httpx
from fastapi import HTTPException
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import Customer, Order, ReviewSchedule, Vehicle
from app.core.settings import settings
from app.services.fakerapi_client import FakerApiClient
def normalize_cpf(value: str) -> str:
@ -31,75 +32,139 @@ def _stable_int(seed_text: str) -> int:
return int(digest[:16], 16)
async def consultar_estoque(
preco_max: Optional[float] = None,
categoria: Optional[str] = None,
ordenar_preco: Optional[str] = None,
limite: Optional[int] = None,
) -> List[Dict[str, Any]]:
db = SessionMockLocal()
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:
query = db.query(Vehicle)
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.",
)
if preco_max is not None:
query = query.filter(Vehicle.preco <= preco_max)
if categoria:
query = query.filter(Vehicle.categoria == categoria.lower())
if ordenar_preco in ("asc", "desc"):
query = query.order_by(Vehicle.preco.asc() if ordenar_preco == "asc" else Vehicle.preco.desc())
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.",
)
if limite is not None:
try:
limite = max(1, int(limite))
query = query.limit(limite)
except (TypeError, ValueError):
pass
rows = query.all()
return [
{
"id": row.id,
"modelo": row.modelo,
"categoria": row.categoria,
"preco": _parse_float(row.preco),
}
for row in rows
]
finally:
db.close()
async def consultar_estoque(preco_max: float, categoria: Optional[str] = None) -> List[Dict[str, Any]]:
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 _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]:
cpf_norm = normalize_cpf(cpf)
db = SessionMockLocal()
try:
cliente = db.query(Customer).filter(Customer.cpf == cpf_norm).first()
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,
}
)
if cliente:
score = int(cliente.score)
limite = _parse_float(cliente.limite_credito, 0.0)
restricao = bool(cliente.possui_restricao)
nome = cliente.nome
else:
entropy = _stable_int(cpf_norm)
score = int(300 + (entropy % 550))
limite = float(30000 + (entropy % 150000))
restricao = entropy % 7 == 0
nome = "Cliente Simulado"
aprovado = (not restricao) and (valor_veiculo <= limite)
return {
"aprovado": aprovado,
cliente = next((r for r in registros if normalize_cpf(r.get("cpf", "")) == cpf_norm), None)
if not cliente:
entropy = _stable_int(f"{cpf_norm}:{settings.fakerapi_seed}")
cliente = {
"cpf": cpf_norm,
"nome": nome,
"score": score,
"limite_credito": limite,
"possui_restricao": restricao,
"valor_veiculo": valor_veiculo,
"nome": "Cliente Faker",
"score": int(300 + (entropy % 550)),
"limite_credito": float(30000 + (entropy % 150000)),
"possui_restricao": (entropy % 7 == 0),
}
finally:
db.close()
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 {
"aprovado": aprovado,
"cpf": cpf_norm,
"nome": cliente.get("nome"),
"score": cliente.get("score"),
"limite_credito": limite,
"possui_restricao": restricao,
"valor_veiculo": valor_veiculo,
}
async def avaliar_veiculo_troca(modelo: str, ano: int, km: int) -> Dict[str, Any]:
@ -117,74 +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]:
try:
dt = datetime.fromisoformat(data_hora.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(
status_code=400,
detail="data_hora invalida. Use formato ISO 8601, por exemplo: 2026-03-10T09:00:00-03:00",
)
entropy = hashlib.md5(f"{placa}:{data_hora}".encode("utf-8")).hexdigest()[:8].upper()
protocolo = f"REV-{dt.strftime('%Y%m%d')}-{entropy}"
db = SessionMockLocal()
try:
existente = db.query(ReviewSchedule).filter(ReviewSchedule.protocolo == protocolo).first()
if existente:
return {
"protocolo": existente.protocolo,
"placa": existente.placa,
"data_hora": existente.data_hora.isoformat(),
"status": existente.status,
}
agendamento = ReviewSchedule(
protocolo=protocolo,
placa=placa.upper(),
data_hora=dt,
status="agendado",
)
db.add(agendamento)
db.commit()
db.refresh(agendamento)
return {
"protocolo": agendamento.protocolo,
"placa": agendamento.placa,
"data_hora": agendamento.data_hora.isoformat(),
"status": agendamento.status,
}
finally:
db.close()
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]:
db = SessionMockLocal()
try:
pedido = db.query(Order).filter(Order.numero_pedido == numero_pedido).first()
if not pedido:
raise HTTPException(status_code=404, detail="Pedido nao encontrado na base ficticia.")
if pedido.status.lower() == "cancelado":
return {
"numero_pedido": pedido.numero_pedido,
"status": pedido.status,
"motivo": pedido.motivo_cancelamento,
"data_cancelamento": pedido.data_cancelamento.isoformat() if pedido.data_cancelamento else None,
}
pedido.status = "Cancelado"
pedido.motivo_cancelamento = motivo
pedido.data_cancelamento = datetime.utcnow()
db.commit()
db.refresh(pedido)
return {
"numero_pedido": pedido.numero_pedido,
"status": pedido.status,
"motivo": pedido.motivo_cancelamento,
"data_cancelamento": pedido.data_cancelamento.isoformat() if pedido.data_cancelamento else None,
}
finally:
db.close()
raise HTTPException(
status_code=503,
detail="FakerAPI nao suporta cancelamento persistente de pedidos. Endpoint indisponivel neste modo.",
)

@ -1,50 +1,44 @@
#!/bin/bash
# Deploy script for Google Cloud Run + Artifact Registry
# Usage: ./deploy.sh
# Script de deploy para Google Cloud Run com Artifact Registry
# Uso: ./deploy.sh
# Pré-requisitos:
# - gcloud CLI autenticado e com projeto principal setado
# - Artifact Registry Repository já criado (orquestrador)
# - Service Account com roles: Cloud SQL Client, Cloud Run Developer, Artifact Registry Writer
set -euo pipefail
ENV_FILE="${ENV_FILE:-.env.prod}"
if [ ! -f "${ENV_FILE}" ] && [ -f ".env" ]; then
ENV_FILE=".env"
fi
if [ ! -f "${ENV_FILE}" ]; then
echo "ERROR: env file not found. Expected ${ENV_FILE} (or .env)."
exit 1
fi
get_env_value() {
local key="$1"
grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 | cut -d'=' -f2- | tr -d '\r'
}
set -e
# Detectar configuração do gcloud
PROJECT_ID=$(gcloud config get-value project)
REGION="us-central1"
SERVICE_NAME="orquestrador"
REPO_NAME="orquestrador"
IMAGE_NAME="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO_NAME}/${SERVICE_NAME}"
IMAGE_NAME="$REGION-docker.pkg.dev/$PROJECT_ID/$REPO_NAME/$SERVICE_NAME"
if [ -z "${PROJECT_ID}" ]; then
echo "ERROR: gcloud project is not configured."
echo "Run: gcloud config set project <your-project-id>"
if [ -z "$PROJECT_ID" ]; then
echo "❌ Projeto não configurado. Execute: gcloud config set project <seu-projeto>"
exit 1
fi
echo "Starting deploy to Cloud Run"
echo "Project: ${PROJECT_ID}"
echo "Region: ${REGION}"
echo "Env file: ${ENV_FILE}"
echo "🚀 Iniciando deploy para Google Cloud Run"
echo "📦 Projeto: $PROJECT_ID"
echo "🌍 Região: $REGION"
echo "🏗️ Repositório: $REPO_NAME"
echo "1) Checking auth..."
# Passo 1: Validar autenticação
echo ""
echo "1⃣ Verificando autenticação GCP..."
ACTIVE_ACCOUNT=$(gcloud auth list --filter=status:ACTIVE --format="value(account)")
if [ -z "${ACTIVE_ACCOUNT}" ]; then
echo "ERROR: no active gcloud account. Run: gcloud auth login"
if [ -z "$ACTIVE_ACCOUNT" ]; then
echo "❌ Nenhuma conta autenticada. Execute: gcloud auth login"
exit 1
fi
echo "Authenticated as: ${ACTIVE_ACCOUNT}"
echo "✅ Autenticado como: $ACTIVE_ACCOUNT"
echo "2) Enabling required APIs..."
# Passo 2: Habilitar APIs
echo ""
echo "2⃣ Habilitando APIs necessárias..."
gcloud services enable \
run.googleapis.com \
cloudbuild.googleapis.com \
@ -52,60 +46,78 @@ gcloud services enable \
aiplatform.googleapis.com \
sqladmin.googleapis.com \
--quiet
echo "✅ APIs habilitadas"
echo "3) Building image with Cloud Build..."
# Passo 3: Build com Cloud Build
echo ""
echo "3⃣ Fazendo build da imagem Docker com Cloud Build..."
gcloud builds submit \
--config=cloudbuild.yaml \
--substitutions=_REGION="${REGION}",_REPO_NAME="${REPO_NAME}",_IMAGE_NAME="${SERVICE_NAME}"
--substitutions=_REGION="$REGION",_REPO_NAME="$REPO_NAME",_IMAGE_NAME="$SERVICE_NAME"
echo "✅ Build concluído"
echo "4) Deploying to Cloud Run..."
# Passo 4: Deploy para Cloud Run com variáveis de ambiente
echo ""
echo "4⃣ Realizando deploy para Cloud Run com variáveis de ambiente..."
# Ler variáveis do .env (excluindo comentários e linhas vazias, e tratando espaços/caracteres especiais)
ENV_VARS=""
while IFS= read -r line || [[ -n "$line" ]]; do
# Ignorar linhas vazias e comentários
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// }" ]] && continue
# Adicionar à string de variáveis (key=value) e remover \r (quebras de linha Windows)
if [[ "$line" == *"="* ]]; then
# Limpar caracteres de retorno de carro (\r) para evitar erros no Linux
clean_line=$(echo "$line" | tr -d '\r')
ENV_VARS="${ENV_VARS}${clean_line},"
ENV_VARS="$ENV_VARS$clean_line,"
fi
done < "${ENV_FILE}"
ENV_VARS="${ENV_VARS%,}"
CLOUD_SQL_CONN=$(get_env_value "CLOUD_SQL_CONNECTION_NAME")
RUN_VPC_CONN=$(get_env_value "RUN_VPC_CONNECTOR")
RUN_VPC_EGRESS=$(get_env_value "RUN_VPC_EGRESS")
done < .env
DEPLOY_ARGS=(
--image="${IMAGE_NAME}:latest"
--region="${REGION}"
--platform=managed
--memory=512Mi
--cpu=1
--timeout=3600
--max-instances=10
--allow-unauthenticated
)
# Remover última vírgula
ENV_VARS="${ENV_VARS%,}"
if [ -n "${ENV_VARS}" ]; then
DEPLOY_ARGS+=(--set-env-vars="${ENV_VARS}")
if [ -z "$ENV_VARS" ]; then
echo "⚠️ Nenhuma variável de ambiente encontrada em .env"
# Deploy sem variáveis (pode falhar se a app exigir)
gcloud run deploy "$SERVICE_NAME" \
--image="$IMAGE_NAME:latest" \
--region="$REGION" \
--platform=managed \
--allow-unauthenticated
else
echo "WARN: no env vars found in ${ENV_FILE}"
fi
if [ -n "${CLOUD_SQL_CONN}" ]; then
DEPLOY_ARGS+=(--add-cloudsql-instances="${CLOUD_SQL_CONN}")
echo "Cloud SQL socket enabled: ${CLOUD_SQL_CONN}"
# Deploy com as variáveis coletadas
gcloud run deploy "$SERVICE_NAME" \
--image="$IMAGE_NAME:latest" \
--region="$REGION" \
--platform=managed \
--set-env-vars="$ENV_VARS" \
--memory=512Mi \
--cpu=1 \
--timeout=3600 \
--max-instances=10 \
--allow-unauthenticated
fi
if [ -n "${RUN_VPC_CONN}" ]; then
DEPLOY_ARGS+=(--vpc-connector="${RUN_VPC_CONN}")
DEPLOY_ARGS+=(--vpc-egress="${RUN_VPC_EGRESS:-private-ranges-only}")
echo "VPC connector enabled: ${RUN_VPC_CONN}"
fi
gcloud run deploy "${SERVICE_NAME}" "${DEPLOY_ARGS[@]}"
echo "Deploy finished."
echo "Service URL:"
gcloud run services describe "${SERVICE_NAME}" --region="${REGION}" --format='value(status.url)'
echo "✅ Deploy concluído com variáveis de ambiente"
# Passo 5: Sucesso
echo ""
echo "================================"
echo "✅ PROCESSO FINALIZADO!"
echo "================================"
echo ""
echo "📋 Próximos passos:"
echo ""
echo "1. Verifique o status:"
echo " gcloud run services describe $SERVICE_NAME --region=$REGION"
echo ""
echo "2. Obtenha a URL da aplicação:"
echo " gcloud run services describe $SERVICE_NAME --region=$REGION --format='value(status.url)'"
echo ""
echo "3. Teste a API:"
echo " curl https://<url-do-seu-servico>/docs"
echo ""
echo "4. Visualize logs:"
echo " gcloud run services logs read $SERVICE_NAME --region=$REGION --limit=50"

Binary file not shown.
Loading…
Cancel
Save