Compare commits

...

6 Commits

@ -7,9 +7,9 @@ GOOGLE_LOCATION=loc_do_seu_projeto
VERTEX_MODEL_NAME=gemini-2.5-flash
# ============================================
# CONFIGURACOES DO BANCO DE DADOS (LOCAL)
# CONFIGURACOES DO BANCO DE DADOS (POSTGRESQL - TOOLS)
# ============================================
# Para desenvolvimento local: PostgreSQL direto
# Banco principal (tools)
DB_HOST=localhost
DB_PORT=5432
@ -23,21 +23,25 @@ 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
# ============================================
@ -45,3 +49,10 @@ FAKERAPI_PERSONS_QUANTITY=120
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** | PostgreSQL | Banco relacional robusto para dados estruturados |
| **Dados de Teste** | FakerAPI | Geração de dados fictícios para simulação |
| **Banco de Dados (Tools)** | PostgreSQL | Banco relacional para metadados de ferramentas |
| **Banco Fictício (Mock)** | MySQL | Dados de negócio simulados usados pelos handlers |
| **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
│ │ └── fakerapi_client.py # Cliente para gerar dados fictícios
│ │ └── handlers.py # Handlers consultando MySQL fictício
│ │
│ ├── 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 PostgreSQL
│ - Busca no MySQL fictício
└──────┬───────────────────────────┘

@ -51,6 +51,36 @@ 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
@ -147,6 +177,18 @@ 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,6 +1,7 @@
from typing import List, Dict, Any
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.api.schemas import (
@ -32,6 +33,22 @@ 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)
@ -45,33 +62,48 @@ 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)):
service = OrquestradorService(db)
result = await service.handle_message(
message=request.message
)
return ChatResponse(response=result)
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}")
@router.post("/mock/consultar-estoque")
async def consultar_estoque_endpoint(
body: ConsultarEstoqueRequest,
) -> List[Dict[str, Any]]:
return await consultar_estoque(
preco_max=body.preco_max,
categoria=body.categoria,
)
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))
@router.post("/mock/validar-cliente-venda")
async def validar_cliente_venda_endpoint(
body: ValidarClienteVendaRequest,
) -> Dict[str, Any]:
return await validar_cliente_venda(
cpf=body.cpf,
valor_veiculo=body.valor_veiculo,
)
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))
@router.post("/mock/avaliar-veiculo-troca")
@ -89,17 +121,23 @@ async def avaliar_veiculo_troca_endpoint(
async def agendar_revisao_endpoint(
body: AgendarRevisaoRequest,
) -> Dict[str, Any]:
return await agendar_revisao(
placa=body.placa,
data_hora=body.data_hora,
)
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))
@router.post("/mock/cancelar-pedido")
async def cancelar_pedido_endpoint(
body: CancelarPedidoRequest,
) -> Dict[str, Any]:
return await cancelar_pedido(
numero_pedido=body.numero_pedido,
motivo=body.motivo,
)
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))

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

@ -12,11 +12,16 @@ class Settings(BaseSettings):
db_password: str
db_name: str
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
# 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
environment: str = "production"
debug: bool = False
@ -24,6 +29,10 @@ 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,17 +3,16 @@ from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.settings import settings
if settings.cloud_sql_connection_name:
# Cloud Run - Formato para PostgreSQL
# Note que usamos 'host' dentro da query string para apontar o socket
# Cloud Run - PostgreSQL via Unix socket
DATABASE_URL = (
f"postgresql+psycopg2://{settings.db_user}:{settings.db_password}@/{settings.db_name}"
f"postgresql+psycopg://{settings.db_user}:{settings.db_password}@/{settings.db_name}"
f"?host=/cloudsql/{settings.cloud_sql_connection_name}"
)
else:
# Ambiente local (via Cloud SQL Proxy)
# Ambiente local/VPN - PostgreSQL em host/porta configurados
DATABASE_URL = (
f"postgresql+psycopg2://{settings.db_user}:{settings.db_password}@"
f"127.0.0.1:5432/{settings.db_name}"
f"postgresql+psycopg://{settings.db_user}:{settings.db_password}@"
f"{settings.db_host}:{settings.db_port}/{settings.db_name}"
)
engine = create_engine(

@ -1,28 +1,40 @@
"""
Inicialização de banco de dados
Cria tabelas e faz seed dos dados iniciais
Inicializacao de banco de dados.
Cria tabelas e executa seed inicial em ambos os bancos.
"""
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.tool_seed import get_tools_definitions, seed_tools
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
def init_db():
"""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!")
"""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!")
if __name__ == "__main__":
init_db()

@ -0,0 +1,31 @@
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()

@ -0,0 +1,54 @@
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())

@ -0,0 +1,96 @@
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,35 +6,53 @@ def get_tools_definitions():
return [
{
"name": "consultar_estoque",
"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.",
"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)."
),
"parameters": {
"type": "object",
"properties": {
"preco_max": {
"type": "number",
"description": "Preço máximo do veículo em reais (BRL)."
"description": "Preco maximo do veiculo em reais (BRL). Opcional.",
},
"categoria": {
"type": "string",
"description": "Categoria do veículo, por exemplo: Hatch, Sedan, SUV. Opcional."
"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.",
},
},
"required": ["preco_max"],
"required": [],
},
},
{
"name": "validar_cliente_venda",
"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.",
"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."
),
"parameters": {
"type": "object",
"properties": {
"cpf": {
"type": "string",
"description": "CPF do cliente, com ou sem formatação (apenas dígitos também é aceito)."
"description": "CPF do cliente, com ou sem formatacao (apenas digitos tambem e aceito).",
},
"valor_veiculo": {
"type": "number",
"description": "Valor do veículo em reais (BRL) que o cliente deseja comprar."
"description": "Valor do veiculo em reais (BRL) que o cliente deseja comprar.",
},
},
"required": ["cpf", "valor_veiculo"],
@ -42,21 +60,26 @@ 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 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.",
"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."
),
"parameters": {
"type": "object",
"properties": {
"modelo": {
"type": "string",
"description": "Modelo do veículo que o cliente deseja oferecer na troca (por exemplo, 'Toyota Corolla')."
"description": "Modelo do veiculo que o cliente deseja oferecer na troca (por exemplo, 'Toyota Corolla').",
},
"ano": {
"type": "integer",
"description": "Ano de fabricação do veículo do cliente."
"description": "Ano de fabricacao do veiculo do cliente.",
},
"km": {
"type": "integer",
"description": "Quilometragem atual do veículo do cliente."
"description": "Quilometragem atual do veiculo do cliente.",
},
},
"required": ["modelo", "ano", "km"],
@ -64,17 +87,22 @@ def get_tools_definitions():
},
{
"name": "agendar_revisao",
"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.",
"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."
),
"parameters": {
"type": "object",
"properties": {
"placa": {
"type": "string",
"description": "Placa do veículo que será levado para revisão."
"description": "Placa do veiculo que sera levado para revisao.",
},
"data_hora": {
"type": "string",
"description": "Data e hora desejada para a revisão, em formato ISO 8601 (por exemplo, '2026-03-10T09:00:00-03:00')."
"description": "Data e hora desejada para a revisao, em formato ISO 8601 (por exemplo, '2026-03-10T09:00:00-03:00').",
},
},
"required": ["placa", "data_hora"],
@ -82,17 +110,22 @@ def get_tools_definitions():
},
{
"name": "cancelar_pedido",
"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.",
"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."
),
"parameters": {
"type": "object",
"properties": {
"numero_pedido": {
"type": "string",
"description": "Número do pedido que o cliente deseja cancelar."
"description": "Numero do pedido que o cliente deseja cancelar.",
},
"motivo": {
"type": "string",
"description": "Motivo do cancelamento informado pelo cliente (por exemplo, atraso, mudança de planos, condição de pagamento, etc.)."
"description": "Motivo do cancelamento informado pelo cliente (por exemplo, atraso, mudanca de planos, condicao de pagamento, etc.).",
},
},
"required": ["numero_pedido", "motivo"],
@ -109,6 +142,11 @@ 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,9 +1,12 @@
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
# 👇 IMPORTANTE: registrar models no metadata
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
app = FastAPI(title="AI Orquestrador")
@ -16,21 +19,25 @@ 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}")
# 2. Executa a seed das ferramentas
from app.db.tool_seed import seed_tools
seed_tools()
print("✅ [Auto-Seed] Tabelas e ferramentas configuradas com sucesso.")
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:
# 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.")
print(f"[Auto-Seed] Aviso: falha ao inicializar MySQL (mock): {e}")
print("[Auto-Seed] Startup finalizado.")

@ -30,3 +30,14 @@ 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

@ -1,57 +0,0 @@
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,13 +1,12 @@
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.core.settings import settings
from app.services.fakerapi_client import FakerApiClient
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import Customer, Order, ReviewSchedule, Vehicle
def normalize_cpf(value: str) -> str:
@ -32,139 +31,75 @@ def _stable_int(seed_text: str) -> int:
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()
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()
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.",
)
query = db.query(Vehicle)
if preco_max is not None:
query = query.filter(Vehicle.preco <= preco_max)
if categoria:
query = query.filter(Vehicle.categoria == categoria.lower())
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 ordenar_preco in ("asc", "desc"):
query = query.order_by(Vehicle.preco.asc() if ordenar_preco == "asc" else Vehicle.preco.desc())
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]] = []
if limite is not None:
try:
limite = max(1, int(limite))
query = query.limit(limite)
except (TypeError, ValueError):
pass
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)
]
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 validar_cliente_venda(cpf: str, valor_veiculo: float) -> Dict[str, Any]:
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,
}
)
db = SessionMockLocal()
try:
cliente = db.query(Customer).filter(Customer.cpf == cpf_norm).first()
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 = {
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,
"cpf": cpf_norm,
"nome": "Cliente Faker",
"score": int(300 + (entropy % 550)),
"limite_credito": float(30000 + (entropy % 150000)),
"possui_restricao": (entropy % 7 == 0),
"nome": nome,
"score": score,
"limite_credito": limite,
"possui_restricao": restricao,
"valor_veiculo": valor_veiculo,
}
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,
}
finally:
db.close()
async def avaliar_veiculo_troca(modelo: str, ano: int, km: int) -> Dict[str, Any]:
@ -182,14 +117,74 @@ 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]:
raise HTTPException(
status_code=503,
detail="FakerAPI nao suporta escrita/persistencia. Endpoint disponivel apenas para leitura de dados ficticios.",
)
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()
async def cancelar_pedido(numero_pedido: str, motivo: str) -> Dict[str, Any]:
raise HTTPException(
status_code=503,
detail="FakerAPI nao suporta cancelamento persistente de pedidos. Endpoint indisponivel neste modo.",
)
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()

@ -1,44 +1,50 @@
#!/bin/bash
# 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
# Deploy script for Google Cloud Run + Artifact Registry
# Usage: ./deploy.sh
set -e
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'
}
# 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 "❌ Projeto não configurado. Execute: gcloud config set project <seu-projeto>"
if [ -z "${PROJECT_ID}" ]; then
echo "ERROR: gcloud project is not configured."
echo "Run: gcloud config set project <your-project-id>"
exit 1
fi
echo "🚀 Iniciando deploy para Google Cloud Run"
echo "📦 Projeto: $PROJECT_ID"
echo "🌍 Região: $REGION"
echo "🏗️ Repositório: $REPO_NAME"
echo "Starting deploy to Cloud Run"
echo "Project: ${PROJECT_ID}"
echo "Region: ${REGION}"
echo "Env file: ${ENV_FILE}"
# Passo 1: Validar autenticação
echo ""
echo "1⃣ Verificando autenticação GCP..."
echo "1) Checking auth..."
ACTIVE_ACCOUNT=$(gcloud auth list --filter=status:ACTIVE --format="value(account)")
if [ -z "$ACTIVE_ACCOUNT" ]; then
echo "❌ Nenhuma conta autenticada. Execute: gcloud auth login"
if [ -z "${ACTIVE_ACCOUNT}" ]; then
echo "ERROR: no active gcloud account. Run: gcloud auth login"
exit 1
fi
echo "✅ Autenticado como: $ACTIVE_ACCOUNT"
echo "Authenticated as: ${ACTIVE_ACCOUNT}"
# Passo 2: Habilitar APIs
echo ""
echo "2⃣ Habilitando APIs necessárias..."
echo "2) Enabling required APIs..."
gcloud services enable \
run.googleapis.com \
cloudbuild.googleapis.com \
@ -46,78 +52,60 @@ gcloud services enable \
aiplatform.googleapis.com \
sqladmin.googleapis.com \
--quiet
echo "✅ APIs habilitadas"
# Passo 3: Build com Cloud Build
echo ""
echo "3⃣ Fazendo build da imagem Docker com Cloud Build..."
echo "3) Building image with Cloud Build..."
gcloud builds submit \
--config=cloudbuild.yaml \
--substitutions=_REGION="$REGION",_REPO_NAME="$REPO_NAME",_IMAGE_NAME="$SERVICE_NAME"
echo "✅ Build concluído"
--substitutions=_REGION="${REGION}",_REPO_NAME="${REPO_NAME}",_IMAGE_NAME="${SERVICE_NAME}"
# Passo 4: Deploy para Cloud Run com variáveis de ambiente
echo ""
echo "4⃣ Realizando deploy para Cloud Run com variáveis de ambiente..."
echo "4) Deploying to Cloud Run..."
# 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
# Remover última vírgula
done < "${ENV_FILE}"
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
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")
DEPLOY_ARGS=(
--image="${IMAGE_NAME}:latest"
--region="${REGION}"
--platform=managed
--memory=512Mi
--cpu=1
--timeout=3600
--max-instances=10
--allow-unauthenticated
)
if [ -n "${ENV_VARS}" ]; then
DEPLOY_ARGS+=(--set-env-vars="${ENV_VARS}")
else
# 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
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}"
fi
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"
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)'

Binary file not shown.
Loading…
Cancel
Save