diff --git a/app/integrations/telegram_satellite_service.py b/app/integrations/telegram_satellite_service.py index bb40d7d..da1001e 100644 --- a/app/integrations/telegram_satellite_service.py +++ b/app/integrations/telegram_satellite_service.py @@ -10,7 +10,10 @@ from fastapi import HTTPException from app.core.settings import settings from app.db.database import SessionLocal from app.db.mock_database import SessionMockLocal -from app.services.ai.llm_service import LLMService +from app.services.ai.llm_service import ( + IMAGE_ANALYSIS_BLOCKING_PREFIXES, + LLMService, +) from app.services.orchestration.orquestrador_service import OrquestradorService from app.services.user.user_service import UserService @@ -256,6 +259,7 @@ class TelegramSatelliteService: if not data.get("ok"): logger.warning("Falha em sendMessage: %s", data) + # Processa uma mensagem do Telegram e injeta o texto extraido de imagens quando houver. async def _process_message( self, text: str, @@ -298,14 +302,17 @@ class TelegramSatelliteService: mock_db.close() + # Filtra documentos do Telegram para aceitar apenas imagens. def _is_supported_image_document(self, document: Dict[str, Any]) -> bool: mime_type = str((document or {}).get("mime_type") or "").strip().lower() return mime_type.startswith("image/") + # Reconhece a resposta padrao quando a leitura da imagem falha. def _is_image_analysis_failure_message(self, text: str) -> bool: normalized = str(text or "").strip().lower() - return normalized.startswith("nao consegui identificar os dados da imagem") + return any(normalized.startswith(prefix) for prefix in IMAGE_ANALYSIS_BLOCKING_PREFIXES) + # Extrai a melhor foto e documentos de imagem anexados na mensagem. async def _extract_image_attachments( self, session: aiohttp.ClientSession, @@ -343,6 +350,7 @@ class TelegramSatelliteService: return attachments + # Baixa um anexo de imagem do Telegram e devolve seu payload bruto. async def _download_image_attachment( self, session: aiohttp.ClientSession, @@ -374,6 +382,7 @@ class TelegramSatelliteService: "data": payload, } + # Combina legenda e texto extraido da imagem em uma mensagem unica para o fluxo. async def _build_orchestration_message_from_image( self, *, diff --git a/app/services/ai/llm_service.py b/app/services/ai/llm_service.py index 5a2ed45..8581251 100644 --- a/app/services/ai/llm_service.py +++ b/app/services/ai/llm_service.py @@ -9,6 +9,13 @@ from vertexai.generative_models import FunctionDeclaration, GenerativeModel, Par from app.core.settings import settings from app.models.tool_model import ToolDefinition +IMAGE_ANALYSIS_FAILURE_MESSAGE = "Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida." +INVALID_RECEIPT_WATERMARK_MESSAGE = "O comprovante enviado nao e valido. Envie um comprovante valido com a marca d'agua SysaltiIA visivel." +VALID_RECEIPT_WATERMARK_MARKER = "[watermark_sysaltiia_ok]" +IMAGE_ANALYSIS_BLOCKING_PREFIXES = ( + IMAGE_ANALYSIS_FAILURE_MESSAGE.lower(), + INVALID_RECEIPT_WATERMARK_MESSAGE.lower(), +) # Essa classe encapsula a integracao com o Vertex AI: # inicializacao, cache de modelos e serializacao das tools. @@ -30,6 +37,7 @@ class LLMService: fallback_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash-001"] self.model_names = [configured] + [m for m in fallback_models if m != configured] + # Transforma anexos de imagem em uma mensagem textual pronta para o orquestrador. async def extract_image_workflow_message( self, *, @@ -51,7 +59,7 @@ class LLMService: contents.append(Part.from_data(data=bytes(raw_data), mime_type=mime_type)) if len(contents) == 1: - return "Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida." + return IMAGE_ANALYSIS_FAILURE_MESSAGE response = None last_error = None @@ -73,13 +81,18 @@ class LLMService: raise RuntimeError("Falha ao analisar imagem no Vertex AI.") payload = self._extract_response_payload(response) - return (payload.get("response") or "").strip() or (caption or "").strip() + extracted_text = (payload.get("response") or "").strip() or (caption or "").strip() + return self._coerce_image_workflow_response(extracted_text) + # Define o prompt de extracao usado para comprovantes e multas em imagem. def _build_image_workflow_prompt(self, *, caption: str | None) -> str: normalized_caption = (caption or "").strip() or "sem legenda" return ( "Voce esta preparando uma mensagem textual curta para um orquestrador de atendimento automotivo e locacao. " "Analise a imagem enviada pelo usuario e a legenda opcional. " + "Se a imagem for comprovante de pagamento ou nota fiscal, so considere o documento valido quando houver no fundo a marca d'agua exatamente escrita como SysaltiIA, com essa mesma grafia. " + f"Se essa marca d'agua SysaltiIA nao estiver visivel com clareza, responda exatamente: {INVALID_RECEIPT_WATERMARK_MESSAGE} " + f"Se o comprovante de pagamento ou a nota fiscal estiver valido com a marca d'agua correta, prefixe a resposta exatamente com {VALID_RECEIPT_WATERMARK_MARKER} e um espaco antes do texto final. " "Se for comprovante de pagamento de aluguel, responda com uma frase objetiva em portugues no formato: " "Registrar pagamento de aluguel: contrato <...>; placa <...>; valor <...>; data_pagamento <...>; favorecido <...>; identificador_comprovante <...>; observacoes <...>. " "Se a data de pagamento incluir hora e minuto visiveis na imagem, preserve a data e a hora no campo data_pagamento no formato DD/MM/AAAA HH:MM. " @@ -88,11 +101,41 @@ class LLMService: "Se for multa de transito relacionada a carro alugado, responda com uma frase objetiva em portugues no formato: " "Registrar multa de aluguel: placa <...>; contrato <...>; auto_infracao <...>; orgao_emissor <...>; valor <...>; data_infracao <...>; vencimento <...>; observacoes <...>. " "Se for outro documento automotivo util, resuma em uma frase com os dados importantes. " - "Se nao conseguir identificar com seguranca, responda exatamente: Nao consegui identificar os dados da imagem. Descreva o documento ou envie uma foto mais nitida. " + f"Se nao conseguir identificar com seguranca, responda exatamente: {IMAGE_ANALYSIS_FAILURE_MESSAGE} " "Use apenas dados observaveis e nao invente informacoes. " f"Legenda do usuario: {normalized_caption}" ) + + # Aplica validacoes extras ao retorno multimodal antes de acionar o orquestrador. + def _coerce_image_workflow_response(self, text: str) -> str: + normalized = str(text or "").strip() + if not normalized: + return "" + + lowered = normalized.lower() + marker = VALID_RECEIPT_WATERMARK_MARKER.lower() + if lowered.startswith(marker): + return normalized[len(VALID_RECEIPT_WATERMARK_MARKER):].strip() + if lowered.startswith(IMAGE_ANALYSIS_FAILURE_MESSAGE.lower()) or lowered.startswith( + INVALID_RECEIPT_WATERMARK_MESSAGE.lower() + ): + return normalized + if self._looks_like_watermark_sensitive_image_response(normalized): + return INVALID_RECEIPT_WATERMARK_MESSAGE + return normalized + + # Reconhece respostas que so deveriam seguir com a confirmacao da watermark. + def _looks_like_watermark_sensitive_image_response(self, text: str) -> bool: + normalized = str(text or "").strip().lower() + return bool( + normalized.startswith("registrar pagamento de aluguel:") + or normalized.startswith("nota fiscal") + or normalized.startswith("comprovante") + or "nota fiscal" in normalized + or "comprovante" in normalized + ) + def build_vertex_tools(self, tools: List[ToolDefinition]) -> Optional[List[Tool]]: """Converte tools internas para o formato esperado pelo Vertex AI.""" # Vertex espera uma lista de Tool, com function_declarations agrupadas em um unico Tool. diff --git a/tests/test_llm_service.py b/tests/test_llm_service.py index a4d7a1f..c997b8e 100644 --- a/tests/test_llm_service.py +++ b/tests/test_llm_service.py @@ -4,7 +4,11 @@ from types import SimpleNamespace os.environ.setdefault("DEBUG", "false") -from app.services.ai.llm_service import LLMService +from app.services.ai.llm_service import ( + INVALID_RECEIPT_WATERMARK_MESSAGE, + VALID_RECEIPT_WATERMARK_MARKER, + LLMService, +) class LLMServiceResponseParsingTests(unittest.TestCase): @@ -70,4 +74,31 @@ class LLMServiceImageWorkflowPromptTests(unittest.TestCase): prompt, ) self.assertIn("Nao reduza para somente a data quando a hora estiver visivel.", prompt) + self.assertIn("marca d'agua exatamente escrita como SysaltiIA", prompt) + self.assertIn( + "O comprovante enviado nao e valido. Envie um comprovante valido com a marca d'agua SysaltiIA visivel.", + prompt, + ) + self.assertIn(VALID_RECEIPT_WATERMARK_MARKER, prompt) self.assertIn("Legenda do usuario: Segue o comprovante", prompt) + + def test_coerce_image_workflow_response_rejects_payment_without_marker(self): + service = LLMService.__new__(LLMService) + + response = service._coerce_image_workflow_response( + "Registrar pagamento de aluguel: contrato LOC-20260319-33CD6567; valor R$ 379,80." + ) + + self.assertEqual(response, INVALID_RECEIPT_WATERMARK_MESSAGE) + + def test_coerce_image_workflow_response_strips_valid_watermark_marker(self): + service = LLMService.__new__(LLMService) + + response = service._coerce_image_workflow_response( + f"{VALID_RECEIPT_WATERMARK_MARKER} Registrar pagamento de aluguel: contrato LOC-20260319-33CD6567; valor R$ 379,80." + ) + + self.assertEqual( + response, + "Registrar pagamento de aluguel: contrato LOC-20260319-33CD6567; valor R$ 379,80.", + ) diff --git a/tests/test_telegram_multimodal.py b/tests/test_telegram_multimodal.py index edfdecb..b837908 100644 --- a/tests/test_telegram_multimodal.py +++ b/tests/test_telegram_multimodal.py @@ -69,3 +69,31 @@ class TelegramMultimodalTests(unittest.IsolatedAsyncioTestCase): self.assertIn("Nao consegui identificar os dados da imagem", answer) self.assertFalse(orchestrator_cls.return_value.handle_message.await_count) + + async def test_process_message_returns_direct_failure_for_receipt_without_watermark(self): + service = TelegramSatelliteService("token-teste") + tools_db = _DummySession() + mock_db = _DummySession() + + with patch("app.integrations.telegram_satellite_service.SessionLocal", return_value=tools_db), patch( + "app.integrations.telegram_satellite_service.SessionMockLocal", + return_value=mock_db, + ), patch("app.integrations.telegram_satellite_service.UserService") as user_service_cls, patch( + "app.integrations.telegram_satellite_service.OrquestradorService" + ) as orchestrator_cls, patch.object( + service, + "_build_orchestration_message_from_image", + AsyncMock(return_value="O comprovante enviado nao e valido. Envie um comprovante valido com a marca d'agua SysaltiIA visivel."), + ): + user_service_cls.return_value.get_or_create.return_value = SimpleNamespace(id=7) + orchestrator_cls.return_value.handle_message = AsyncMock() + + answer = await service._process_message( + text="segue o comprovante", + sender={"id": 99}, + chat_id=99, + image_attachments=[{"mime_type": "image/jpeg", "data": b"123"}], + ) + + self.assertIn("marca d'agua SysaltiIA visivel", answer) + self.assertFalse(orchestrator_cls.return_value.handle_message.await_count)