🛡️ fix(multimodal): endurecer validacao da watermark SysaltiIA

Exige confirmacao positiva da marca d'agua SysaltiIA no retorno multimodal antes de permitir o registro de comprovantes e notas fiscais.

Bloqueia respostas sensiveis sem o marcador de validacao e preserva o bloqueio direto no satellite do Telegram.

Amplia a cobertura com testes para prompt, coercao da resposta e fluxo bloqueado no multimodal.
main
parent aa3bc3f3e0
commit c22672abda

@ -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,
*,

@ -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.

@ -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.",
)

@ -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)

Loading…
Cancel
Save