diff --git a/.env.example b/.env.example index cb4bfff..967e1a7 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,10 @@ DEBUG=true # Ex.: projects//locations//connectors/ # RUN_VPC_CONNECTOR= # RUN_VPC_EGRESS=private-ranges-only + +# ============================================ +# TELEGRAM (SERVICO SATELITE) +# ============================================ +TELEGRAM_BOT_TOKEN= +TELEGRAM_POLLING_TIMEOUT=30 +TELEGRAM_REQUEST_TIMEOUT=45 diff --git a/README.md b/README.md index 90d580b..726e548 100644 --- a/README.md +++ b/README.md @@ -254,3 +254,25 @@ O projeto está em evolução contínua com planos para: ## 📧 Contato Para dúvidas, sugestões ou contribuições, entre em contato com o time de desenvolvimento. + +--- + +## Telegram Satellite Service + +Foi adicionado um servico satelite dedicado ao Telegram. + +Arquivos principais: + +- `app/integrations/telegram_satellite_service.py`: interface Telegram via long polling, chamando o `OrquestradorService` + +Como executar: + +```bash +python -m app.integrations.telegram_satellite_service +``` + +Variaveis necessarias: + +- `TELEGRAM_BOT_TOKEN` +- `TELEGRAM_POLLING_TIMEOUT` (opcional, padrao `30`) +- `TELEGRAM_REQUEST_TIMEOUT` (opcional, padrao `45`) diff --git a/app/api/routes.py b/app/api/routes.py index 12a2ec5..872582b 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -27,6 +27,7 @@ router = APIRouter() def get_db(): + """Fornece uma sessao de banco para a request e garante o fechamento.""" db = SessionLocal() try: yield db @@ -35,6 +36,7 @@ def get_db(): def _db_error_detail(exc: SQLAlchemyError) -> str: + """Converte erros de banco em mensagens amigaveis para a API.""" text = str(exc).lower() # Heuristica para identificar falhas no MySQL de tools. @@ -66,6 +68,7 @@ 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)): + """Processa mensagem do usuario via orquestrador e retorna resposta do chat.""" try: service = OrquestradorService(db) result = await service.handle_message(message=request.message) @@ -86,6 +89,7 @@ async def chat(request: ChatRequest, db: Session = Depends(get_db)): async def consultar_estoque_endpoint( body: ConsultarEstoqueRequest, ) -> List[Dict[str, Any]]: + """Consulta estoque de veiculos mock com filtros opcionais.""" try: return await consultar_estoque( preco_max=body.preco_max, @@ -101,6 +105,7 @@ async def consultar_estoque_endpoint( async def validar_cliente_venda_endpoint( body: ValidarClienteVendaRequest, ) -> Dict[str, Any]: + """Valida elegibilidade de cliente para uma venda.""" try: return await validar_cliente_venda( cpf=body.cpf, @@ -114,6 +119,7 @@ async def validar_cliente_venda_endpoint( async def avaliar_veiculo_troca_endpoint( body: AvaliarVeiculoTrocaRequest, ) -> Dict[str, Any]: + """Avalia valor de troca de um veiculo com base em modelo, ano e quilometragem.""" return await avaliar_veiculo_troca( modelo=body.modelo, ano=body.ano, @@ -125,6 +131,7 @@ async def avaliar_veiculo_troca_endpoint( async def agendar_revisao_endpoint( body: AgendarRevisaoRequest, ) -> Dict[str, Any]: + """Agenda revisao para uma placa em data/hora informada.""" try: return await agendar_revisao( placa=body.placa, @@ -137,7 +144,9 @@ async def agendar_revisao_endpoint( @router.post("/mock/cancelar-pedido") async def cancelar_pedido_endpoint( body: CancelarPedidoRequest, + ) -> Dict[str, Any]: + """Cancela pedido de venda existente registrando o motivo.""" try: return await cancelar_pedido( numero_pedido=body.numero_pedido, diff --git a/app/api/schemas.py b/app/api/schemas.py index f766155..7d0dd89 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -1,6 +1,8 @@ from pydantic import BaseModel from typing import Dict, Any, Optional, Literal +# Deifição de como vou pedir os parâmetros de cada item. + class ChatRequest(BaseModel): message: str # user_id: str -> Removido momentaniamente para testar o VertexIA diff --git a/app/api/tool_routes.py b/app/api/tool_routes.py index 0e27bfd..4af230c 100644 --- a/app/api/tool_routes.py +++ b/app/api/tool_routes.py @@ -9,6 +9,7 @@ router = APIRouter(prefix="/tools", tags=["Tools"]) # Dependency para abrir e fechar conexão automaticamente def get_db(): + """Fornece sessao para operacoes de tools e fecha conexao ao final da request.""" db = SessionLocal() try: yield db @@ -16,8 +17,9 @@ def get_db(): db.close() -@router.post("/", response_model=ToolResponse) +@router.post("/", response_model=ToolResponse) # Desenvolver uma tela de cadastro e atualização def create_tool(tool: ToolCreate, db: Session = Depends(get_db)): + """Cria uma nova tool persistida no banco.""" repo = ToolRepository(db) return repo.create( name=tool.name, @@ -28,12 +30,14 @@ def create_tool(tool: ToolCreate, db: Session = Depends(get_db)): @router.get("/", response_model=list[ToolResponse]) def list_tools(db: Session = Depends(get_db)): + """Lista todas as tools cadastradas.""" repo = ToolRepository(db) return repo.get_all() @router.get("/{tool_id}", response_model=ToolResponse) def get_tool(tool_id: int, db: Session = Depends(get_db)): + """Busca uma tool por ID e retorna 404 quando inexistente.""" repo = ToolRepository(db) tool = repo.get_by_id(tool_id) @@ -45,6 +49,7 @@ def get_tool(tool_id: int, db: Session = Depends(get_db)): @router.delete("/{tool_id}") def delete_tool(tool_id: int, db: Session = Depends(get_db)): + """Remove uma tool por ID e retorna 404 quando nao encontrada.""" repo = ToolRepository(db) tool = repo.delete(tool_id) diff --git a/app/core/settings.py b/app/core/settings.py index ba4b5fa..a9fea0c 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -35,6 +35,11 @@ class Settings(BaseSettings): run_vpc_connector: str | None = None run_vpc_egress: str = "private-ranges-only" + # Telegram satellite + telegram_bot_token: str | None = None + telegram_polling_timeout: int = 30 + telegram_request_timeout: int = 45 + class Config: env_file = ".env" extra = "ignore" diff --git a/app/db/mock_seed.py b/app/db/mock_seed.py index ebe8a8a..e3f04c1 100644 --- a/app/db/mock_seed.py +++ b/app/db/mock_seed.py @@ -35,10 +35,12 @@ NAMES = [ def _cpf_from_index(index: int) -> str: + """Gera um CPF numerico deterministico de 11 digitos a partir do indice.""" return str(10_000_000_000 + index).zfill(11) def seed_mock_data() -> None: + """Popula dados mock iniciais de veiculos, clientes e pedidos quando habilitado.""" if not settings.mock_seed_enabled: return diff --git a/app/db/tool_seed.py b/app/db/tool_seed.py index 066d6ea..967fe3f 100644 --- a/app/db/tool_seed.py +++ b/app/db/tool_seed.py @@ -3,6 +3,7 @@ from app.repositories.tool_repository import ToolRepository def get_tools_definitions(): + """Retorna as definicoes padrao de tools usadas para seed e sincronizacao.""" return [ { "name": "consultar_estoque", @@ -135,6 +136,7 @@ def get_tools_definitions(): def seed_tools(): + """Insere ou atualiza as tools padrao no banco de dados.""" db = SessionLocal() try: repo = ToolRepository(db) diff --git a/app/repositories/tool_repository.py b/app/repositories/tool_repository.py index fe584b9..172611b 100644 --- a/app/repositories/tool_repository.py +++ b/app/repositories/tool_repository.py @@ -5,15 +5,19 @@ from app.db.models import Tool class ToolRepository: def __init__(self, db: Session): + """Inicializa o repositorio com a sessao ativa do banco.""" self.db = db def get_all(self): + """Retorna todas as tools cadastradas.""" return self.db.query(Tool).all() def get_by_id(self, tool_id: int): + """Busca uma tool pelo ID.""" return self.db.query(Tool).filter(Tool.id == tool_id).first() def create(self, name: str, description: str, parameters: dict): + """Cria e persiste uma nova tool.""" tool = Tool( name=name, description=description, @@ -25,6 +29,7 @@ class ToolRepository: return tool def delete(self, tool_id: int): + """Remove uma tool por ID quando existente.""" tool = self.get_by_id(tool_id) if tool: self.db.delete(tool) @@ -32,6 +37,7 @@ class ToolRepository: return tool def update_by_name(self, name: str, description: str, parameters: dict): + """Atualiza descricao e parametros de uma tool encontrada pelo nome.""" tool = self.db.query(Tool).filter(Tool.name == name).first() if not tool: return None diff --git a/app/services/handlers.py b/app/services/handlers.py index ac90178..71a4e6d 100644 --- a/app/services/handlers.py +++ b/app/services/handlers.py @@ -10,10 +10,12 @@ from app.db.mock_models import Customer, Order, ReviewSchedule, Vehicle def normalize_cpf(value: str) -> str: + """Normaliza CPF removendo qualquer caractere nao numerico.""" return re.sub(r"\D", "", value or "") def _parse_float(value: Any, default: float = 0.0) -> float: + """Converte entradas numericas/textuais para float com fallback padrao.""" if value is None: return default if isinstance(value, (int, float)): @@ -27,6 +29,7 @@ def _parse_float(value: Any, default: float = 0.0) -> float: def _stable_int(seed_text: str) -> int: + """Gera inteiro deterministico a partir de um texto usando hash SHA-256.""" digest = hashlib.sha256(seed_text.encode("utf-8")).hexdigest() return int(digest[:16], 16) @@ -37,6 +40,7 @@ async def consultar_estoque( ordenar_preco: Optional[str] = None, limite: Optional[int] = None, ) -> List[Dict[str, Any]]: + """Consulta veiculos no estoque com filtros opcionais e ordenacao por preco.""" db = SessionMockLocal() try: query = db.query(Vehicle) @@ -71,6 +75,7 @@ async def consultar_estoque( async def validar_cliente_venda(cpf: str, valor_veiculo: float) -> Dict[str, Any]: + """Avalia aprovacao de compra com base em score, limite e restricao do cliente.""" cpf_norm = normalize_cpf(cpf) db = SessionMockLocal() try: @@ -103,6 +108,7 @@ async def validar_cliente_venda(cpf: str, valor_veiculo: float) -> Dict[str, Any async def avaliar_veiculo_troca(modelo: str, ano: int, km: int) -> Dict[str, Any]: + """Calcula valor estimado de troca usando depreciacao por ano e quilometragem.""" ano_atual = datetime.now().year idade = max(0, ano_atual - ano) base = 80000.0 @@ -117,6 +123,7 @@ 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]: + """Cria ou reaproveita agendamento de revisao a partir de placa e data/hora.""" try: dt = datetime.fromisoformat(data_hora.replace("Z", "+00:00")) except ValueError: @@ -160,6 +167,7 @@ async def agendar_revisao(placa: str, data_hora: str) -> Dict[str, Any]: async def cancelar_pedido(numero_pedido: str, motivo: str) -> Dict[str, Any]: + """Cancela pedido existente e registra motivo e data de cancelamento.""" db = SessionMockLocal() try: pedido = db.query(Order).filter(Order.numero_pedido == numero_pedido).first() diff --git a/app/services/llm_service.py b/app/services/llm_service.py index e269fe0..0d358e7 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -1,7 +1,9 @@ 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 vertexai.generative_models import FunctionDeclaration, GenerativeModel, Tool + from app.core.settings import settings from app.models.tool_model import ToolDefinition @@ -9,9 +11,10 @@ from app.models.tool_model import ToolDefinition class LLMService: def __init__(self): + """Inicializa o cliente Vertex AI e define modelos de fallback.""" vertexai.init( project=settings.google_project_id, - location=settings.google_location + location=settings.google_location, ) configured = settings.vertex_model_name.strip() @@ -19,8 +22,8 @@ class LLMService: 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. + """Converte tools internas para o formato esperado pelo Vertex AI.""" + # Vertex espera uma lista de Tool, com function_declarations agrupadas em um unico Tool. if not tools: return None @@ -35,26 +38,15 @@ class LLMService: return [Tool(function_declarations=function_declarations)] - """ - Fluxo principal de geração de resposta. - - Parâmetros: - - message: mensagem do usuário - - tools: lista de ferramentas disponíveis - - history: histórico da conversa (memória) - """ async def generate_response( self, message: str, tools: List[ToolDefinition], - history: List[Dict[str, Any]] = None + history: List[Dict[str, Any]] = None, ) -> Dict[str, Any]: + """Gera resposta textual ou chamada de tool a partir da mensagem do usuario.""" + vertex_tools = self.build_vertex_tools(tools) - vertex_tools = self.build_vertex_tools(tools) # Convertendo tools para formato do Vertex - - # Inicia uma sessão de chat com: - # - histórico (se existir) - # - ferramentas disponíveis response = None last_error = None @@ -72,28 +64,22 @@ class LLMService: 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}" + f"Nenhum modelo Vertex disponivel. 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: - # response.candidates -> lista de possíveis respostas - # content.parts -> partes da resposta part = response.candidates[0].content.parts[0] - # Verificação se o modelo decidiu chamar alguma função, se decidiu, retornará o nome da função que ele quer executar e o argumento que ele extraiu da mensagem do usuário. if part.function_call: return { "response": None, "tool_call": { "name": part.function_call.name, - "arguments": dict(part.function_call.args) - } + "arguments": dict(part.function_call.args), + }, } - # Caso não ocorra a chamada de uma função, significa que o modelo respondeu diretamente em texto return { "response": response.text, - "tool_call": None + "tool_call": None, } diff --git a/app/services/orquestrador_service.py b/app/services/orquestrador_service.py index 84a83a2..72eee95 100644 --- a/app/services/orquestrador_service.py +++ b/app/services/orquestrador_service.py @@ -7,18 +7,12 @@ from app.services.tool_registry import ToolRegistry class OrquestradorService: def __init__(self, db: Session): + """Inicializa servicos de LLM e registro de tools para a sessao atual.""" self.llm = LLMService() self.registry = ToolRegistry(db) - """ - Metodo principal chamado quando o usuario envia uma mensagem. - - 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: + """Processa mensagem, executa tool quando necessario e retorna resposta final.""" tools = self.registry.get_tools() diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py index 815ef4b..aaf413f 100644 --- a/app/services/tool_registry.py +++ b/app/services/tool_registry.py @@ -1,15 +1,15 @@ -from typing import List, Dict, Callable +from typing import Callable, Dict, List from sqlalchemy.orm import Session from app.models.tool_model import ToolDefinition from app.repositories.tool_repository import ToolRepository from app.services.handlers import ( - consultar_estoque, - validar_cliente_venda, - avaliar_veiculo_troca, agendar_revisao, + avaliar_veiculo_troca, cancelar_pedido, + consultar_estoque, + validar_cliente_venda, ) @@ -25,6 +25,7 @@ HANDLERS: Dict[str, Callable] = { class ToolRegistry: def __init__(self, db: Session): + """Carrega tools do banco e registra apenas as que possuem handler conhecido.""" self._tools = [] repo = ToolRepository(db) db_tools = repo.get_all() @@ -39,33 +40,26 @@ class ToolRegistry: handler=handler, ) - # O método abaixo serve para adicionar uma nova tool ao sistema (podendo inserir a lógica de adcicionar ao BD futuramente). def register_tool(self, name, description, parameters, handler): + """Registra uma tool em memoria para uso pelo orquestrador.""" self._tools.append( ToolDefinition( name=name, description=description, parameters=parameters, - handler=handler + handler=handler, ) ) - - # Retorna todas as tools registradas, isso será usado pelo LLMService para enviar as ferramentas para o modelo def get_tools(self) -> List[ToolDefinition]: + """Retorna a lista atual de tools registradas.""" return self._tools - - """ - Essa função é responsável por executar a tool solicitada pelo modelo. - Parâmetros: - - name: nome da função que o Gemini decidiu chamar - - arguments: argumentos extraídos da mensagem do usuário - """ async def execute(self, name: str, arguments: dict): - tool = next((t for t in self._tools if t.name == name), None) # Procura dentro da lista de tools aquela que tem o mesmo nome + """Executa a tool solicitada pelo modelo com os argumentos extraidos.""" + tool = next((t for t in self._tools if t.name == name), None) if not tool: - raise Exception(f"Tool {name} não encontrada.") + raise Exception(f"Tool {name} nao encontrada.") return await tool.handler(**arguments)