🧾 feat(audit): persistir historico de conversas no banco mock

- adiciona a tabela conversation_turns ao schema mock com request_id, conversation_id, user_id, canal, mensagem, resposta, status, intent, domain, action, tool, erro e latencia por turno
- integra o OrquestradorService para registrar historico tanto em turnos concluidos quanto em falhas, aproveitando o trace do turno e os metadados de execucao
- cria o ConversationHistoryService com persistencia e consulta list_turns com filtros simples para auditoria interna
- inclui cobertura para persistencia do historico, leitura filtrada e registro de turnos completed/failed na camada de orquestracao
main
parent 9b6b2a643b
commit 65cd775b2a

@ -7,7 +7,7 @@ from app.core.settings import settings
from app.db.database import Base, engine from app.db.database import Base, engine
from app.db.mock_database import MockBase, mock_engine from app.db.mock_database import MockBase, mock_engine
from app.db.models import Tool from app.db.models import Tool
from app.db.mock_models import Customer, Order, ReviewSchedule, Vehicle from app.db.mock_models import ConversationTurn, Customer, Order, ReviewSchedule, Vehicle
from app.db.mock_seed import seed_mock_data from app.db.mock_seed import seed_mock_data
from app.db.tool_seed import seed_tools from app.db.tool_seed import seed_tools

@ -1,6 +1,7 @@
from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.core.time_utils import utc_now
from app.db.mock_database import MockBase from app.db.mock_database import MockBase
@ -78,3 +79,27 @@ class ReviewSchedule(MockBase):
data_hora = Column(DateTime, nullable=False) data_hora = Column(DateTime, nullable=False)
status = Column(String(20), nullable=False, default="agendado") status = Column(String(20), nullable=False, default="agendado")
created_at = Column(DateTime, server_default=func.current_timestamp()) created_at = Column(DateTime, server_default=func.current_timestamp())
class ConversationTurn(MockBase):
__tablename__ = "conversation_turns"
id = Column(Integer, primary_key=True, index=True)
request_id = Column(String(80), unique=True, nullable=False, index=True)
conversation_id = Column(String(120), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
channel = Column(String(40), nullable=True, index=True)
external_id = Column(String(120), nullable=True, index=True)
username = Column(String(120), nullable=True)
user_message = Column(Text, nullable=False)
assistant_response = Column(Text, nullable=True)
turn_status = Column(String(20), nullable=False, default="completed", index=True)
intent = Column(String(80), nullable=True, index=True)
domain = Column(String(40), nullable=True, index=True)
action = Column(String(80), nullable=True)
tool_name = Column(String(120), nullable=True, index=True)
tool_arguments = Column(Text, nullable=True)
error_detail = Column(Text, nullable=True)
elapsed_ms = Column(Float, nullable=True)
started_at = Column(DateTime, nullable=False, default=utc_now, index=True)
completed_at = Column(DateTime, nullable=True, index=True)

@ -0,0 +1,170 @@
import json
import logging
from datetime import datetime
from typing import Any
from app.db.mock_database import SessionMockLocal
from app.db.mock_models import ConversationTurn, User
logger = logging.getLogger(__name__)
class ConversationHistoryService:
"""Persiste uma trilha simples de auditoria por turno no banco mock."""
def record_turn(
self,
*,
request_id: str,
conversation_id: str,
user_id: int | None,
user_message: str,
assistant_response: str | None,
turn_status: str,
intent: str | None = None,
domain: str | None = None,
action: str | None = None,
tool_name: str | None = None,
tool_arguments: dict[str, Any] | None = None,
error_detail: str | None = None,
started_at: datetime | None = None,
completed_at: datetime | None = None,
elapsed_ms: float | None = None,
) -> None:
db = SessionMockLocal()
try:
channel = None
external_id = None
username = None
if user_id is not None:
user = db.query(User).filter(User.id == user_id).first()
if user:
channel = user.channel
external_id = user.external_id
username = user.username
payload = {
"request_id": str(request_id or ""),
"conversation_id": str(conversation_id or "anonymous"),
"user_id": user_id,
"channel": channel,
"external_id": external_id,
"username": username,
"user_message": str(user_message or ""),
"assistant_response": assistant_response,
"turn_status": str(turn_status or "completed"),
"intent": self._clean_text(intent),
"domain": self._clean_text(domain),
"action": self._clean_text(action),
"tool_name": self._clean_text(tool_name),
"tool_arguments": self._serialize_json(tool_arguments),
"error_detail": error_detail,
}
if started_at is not None:
payload["started_at"] = started_at
if completed_at is not None:
payload["completed_at"] = completed_at
if elapsed_ms is not None:
payload["elapsed_ms"] = elapsed_ms
record = ConversationTurn(**payload)
db.add(record)
db.commit()
except Exception:
db.rollback()
logger.exception(
"Falha ao persistir historico de conversa.",
extra={
"request_id": request_id,
"conversation_id": conversation_id,
"user_id": user_id,
},
)
finally:
db.close()
def list_turns(
self,
*,
user_id: int | None = None,
conversation_id: str | None = None,
request_id: str | None = None,
turn_status: str | None = None,
limit: int = 20,
) -> list[dict[str, Any]]:
db = SessionMockLocal()
try:
query = db.query(ConversationTurn)
if user_id is not None:
query = query.filter(ConversationTurn.user_id == user_id)
normalized_conversation_id = self._clean_text(conversation_id)
if normalized_conversation_id:
query = query.filter(ConversationTurn.conversation_id == normalized_conversation_id)
normalized_request_id = self._clean_text(request_id)
if normalized_request_id:
query = query.filter(ConversationTurn.request_id == normalized_request_id)
normalized_turn_status = self._clean_text(turn_status)
if normalized_turn_status:
query = query.filter(ConversationTurn.turn_status == normalized_turn_status)
safe_limit = max(1, min(int(limit or 20), 100))
rows = (
query.order_by(ConversationTurn.started_at.desc(), ConversationTurn.id.desc())
.limit(safe_limit)
.all()
)
return [self._serialize_row(row) for row in rows]
finally:
db.close()
def _clean_text(self, value: str | None) -> str | None:
text = str(value or "").strip()
return text or None
def _serialize_row(self, row: ConversationTurn) -> dict[str, Any]:
return {
"id": row.id,
"request_id": row.request_id,
"conversation_id": row.conversation_id,
"user_id": row.user_id,
"channel": row.channel,
"external_id": row.external_id,
"username": row.username,
"user_message": row.user_message,
"assistant_response": row.assistant_response,
"turn_status": row.turn_status,
"intent": row.intent,
"domain": row.domain,
"action": row.action,
"tool_name": row.tool_name,
"tool_arguments": self._deserialize_json(row.tool_arguments),
"error_detail": row.error_detail,
"elapsed_ms": row.elapsed_ms,
"started_at": row.started_at.isoformat() if row.started_at else None,
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
}
def _deserialize_json(self, value: str | None) -> dict[str, Any] | None:
text = str(value or "").strip()
if not text:
return None
try:
payload = json.loads(text)
except (TypeError, ValueError):
return None
return payload if isinstance(payload, dict) else None
def _serialize_json(self, value: dict[str, Any] | None) -> str | None:
if not isinstance(value, dict) or not value:
return None
return json.dumps(
value,
ensure_ascii=True,
separators=(",", ":"),
default=str,
)

@ -1,3 +1,4 @@
import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.core.time_utils import utc_now from app.core.time_utils import utc_now
@ -18,6 +19,7 @@ from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.conversation_state_repository import ConversationStateRepository from app.services.orchestration.conversation_state_repository import ConversationStateRepository
from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.orchestration.message_planner import MessagePlanner from app.services.orchestration.message_planner import MessagePlanner
from app.services.orchestration.conversation_history_service import ConversationHistoryService
from app.services.orchestration.state_repository_factory import get_conversation_state_repository from app.services.orchestration.state_repository_factory import get_conversation_state_repository
from app.services.flows.order_flow import OrderFlowMixin from app.services.flows.order_flow import OrderFlowMixin
from app.services.orchestration.prompt_builders import ( from app.services.orchestration.prompt_builders import (
@ -48,6 +50,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
self.registry = ToolRegistry(db, extra_handlers=self._build_orchestration_tool_handlers()) self.registry = ToolRegistry(db, extra_handlers=self._build_orchestration_tool_handlers())
self.tool_executor = ToolExecutor(registry=self.registry) self.tool_executor = ToolExecutor(registry=self.registry)
self.policy = ConversationPolicy(service=self) self.policy = ConversationPolicy(service=self)
self.history_service = ConversationHistoryService()
def _build_orchestration_tool_handlers(self) -> dict: def _build_orchestration_tool_handlers(self) -> dict:
return { return {
@ -59,14 +62,19 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
async def handle_message(self, message: str, user_id: int | None = None) -> str: async def handle_message(self, message: str, user_id: int | None = None) -> str:
"""Processa mensagem, executa tool quando necessario e retorna resposta final.""" """Processa mensagem, executa tool quando necessario e retorna resposta final."""
turn_started_at = utc_now()
turn_started_perf = perf_counter()
turn_history_persisted = False
self._turn_trace = { self._turn_trace = {
"request_id": str(uuid4()), "request_id": str(uuid4()),
"conversation_id": f"user:{user_id}" if user_id is not None else "anonymous", "conversation_id": f"user:{user_id}" if user_id is not None else "anonymous",
"user_id": user_id, "user_id": user_id,
"started_at": turn_started_at,
} }
self._log_turn_event("turn_received", message=message) self._log_turn_event("turn_received", message=message)
async def finish(response: str, queue_notice: str | None = None) -> str: async def finish(response: str, queue_notice: str | None = None) -> str:
nonlocal turn_history_persisted
composed = self._compose_order_aware_response( composed = self._compose_order_aware_response(
response=response, response=response,
user_id=user_id, user_id=user_id,
@ -76,9 +84,18 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
base_response=composed, base_response=composed,
user_id=user_id, user_id=user_id,
) )
self._turn_trace["elapsed_ms"] = round((perf_counter() - turn_started_perf) * 1000, 2)
self._log_turn_event("turn_completed", response=final_response) self._log_turn_event("turn_completed", response=final_response)
if not turn_history_persisted:
self._finalize_turn_history(
user_message=message,
assistant_response=final_response,
turn_status="completed",
)
turn_history_persisted = True
return final_response return final_response
try:
self._upsert_user_context(user_id=user_id) self._upsert_user_context(user_id=user_id)
if hasattr(self, "policy") and self._is_order_selection_reset_message(message): if hasattr(self, "policy") and self._is_order_selection_reset_message(message):
reset_override = await self._try_handle_immediate_context_reset( reset_override = await self._try_handle_immediate_context_reset(
@ -187,6 +204,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
message=routing_message, message=routing_message,
user_id=user_id, user_id=user_id,
) )
self._capture_turn_decision_trace(turn_decision)
llm_extracted_entities = await self._extract_entities_with_llm( llm_extracted_entities = await self._extract_entities_with_llm(
message=routing_message, message=routing_message,
user_id=user_id, user_id=user_id,
@ -443,6 +461,17 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
queue_notice=queue_notice, queue_notice=queue_notice,
) )
return await finish(text, queue_notice=queue_notice) return await finish(text, queue_notice=queue_notice)
except Exception as exc:
if not turn_history_persisted:
self._turn_trace["elapsed_ms"] = round((perf_counter() - turn_started_perf) * 1000, 2)
self._finalize_turn_history(
user_message=message,
assistant_response=None,
turn_status="failed",
error_detail=self._format_turn_error(exc),
)
turn_history_persisted = True
raise
async def _try_execute_orchestration_control_tool( async def _try_execute_orchestration_control_tool(
self, self,
@ -1842,6 +1871,60 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
conversation_context=conversation_context, conversation_context=conversation_context,
) )
def _capture_turn_decision_trace(self, turn_decision: dict | None) -> None:
trace = getattr(self, "_turn_trace", None)
if not isinstance(trace, dict) or not isinstance(turn_decision, dict):
return
trace["intent"] = str(turn_decision.get("intent") or "").strip() or None
trace["domain"] = str(turn_decision.get("domain") or "").strip() or None
trace["action"] = str(turn_decision.get("action") or "").strip() or None
def _capture_tool_invocation_trace(self, tool_name: str, arguments: dict | None) -> None:
trace = getattr(self, "_turn_trace", None)
if not isinstance(trace, dict):
return
trace["tool_name"] = str(tool_name or "").strip() or None
trace["tool_arguments"] = dict(arguments or {}) if isinstance(arguments, dict) else None
def _finalize_turn_history(
self,
*,
user_message: str,
assistant_response: str | None,
turn_status: str,
error_detail: str | None = None,
) -> None:
history_service = getattr(self, "history_service", None)
if history_service is None:
return
trace = getattr(self, "_turn_trace", {}) or {}
history_service.record_turn(
request_id=str(trace.get("request_id") or ""),
conversation_id=str(trace.get("conversation_id") or "anonymous"),
user_id=trace.get("user_id"),
user_message=str(user_message or ""),
assistant_response=assistant_response,
turn_status=str(turn_status or "completed"),
intent=trace.get("intent"),
domain=trace.get("domain"),
action=trace.get("action"),
tool_name=trace.get("tool_name"),
tool_arguments=trace.get("tool_arguments"),
error_detail=error_detail,
started_at=trace.get("started_at"),
completed_at=utc_now(),
elapsed_ms=trace.get("elapsed_ms"),
)
def _format_turn_error(self, exc: Exception) -> str:
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, dict):
return json.dumps(detail, ensure_ascii=True, separators=(",", ":"), default=str)
return str(detail)
return f"{type(exc).__name__}: {exc}"
def _log_turn_event(self, event: str, **payload) -> None: def _log_turn_event(self, event: str, **payload) -> None:
trace = getattr(self, "_turn_trace", {}) or {} trace = getattr(self, "_turn_trace", {}) or {}
logger.info( logger.info(
@ -1933,6 +2016,7 @@ class OrquestradorService(ReviewFlowMixin, OrderFlowMixin):
arguments=arguments, arguments=arguments,
user_id=user_id, user_id=user_id,
) )
self._capture_tool_invocation_trace(tool_name=tool_name, arguments=arguments)
started_at = perf_counter() started_at = perf_counter()
try: try:
result = await self.tool_executor.execute(tool_name, arguments, user_id=user_id) result = await self.tool_executor.execute(tool_name, arguments, user_id=user_id)

@ -0,0 +1,226 @@
import unittest
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db.mock_database import MockBase
from app.db.mock_models import ConversationTurn, User
from app.services.orchestration.conversation_history_service import ConversationHistoryService
class _FakeQuery:
def __init__(self, result):
self.result = result
def filter(self, *args, **kwargs):
return self
def first(self):
return self.result
class _FakeSession:
def __init__(self, user=None):
self.user = user
self.added = []
self.committed = False
self.rolled_back = False
self.closed = False
def query(self, model):
return _FakeQuery(self.user)
def add(self, item):
self.added.append(item)
def commit(self):
self.committed = True
def rollback(self):
self.rolled_back = True
def close(self):
self.closed = True
class ConversationHistoryServiceTests(unittest.TestCase):
def test_record_turn_persists_conversation_audit_record(self):
session = _FakeSession(
user=SimpleNamespace(
id=7,
channel="telegram",
external_id="12345",
username="cliente_teste",
)
)
service = ConversationHistoryService()
started_at = datetime(2026, 3, 16, 18, 0, 0)
completed_at = datetime(2026, 3, 16, 18, 0, 5)
with patch(
"app.services.orchestration.conversation_history_service.SessionMockLocal",
return_value=session,
):
service.record_turn(
request_id="req-123",
conversation_id="user:7",
user_id=7,
user_message="quero comprar um carro",
assistant_response="Encontrei 2 veiculo(s).",
turn_status="completed",
intent="order_create",
domain="sales",
action="collect_order_create",
tool_name="consultar_estoque",
tool_arguments={"preco_max": 80000},
error_detail=None,
started_at=started_at,
completed_at=completed_at,
elapsed_ms=512.4,
)
self.assertTrue(session.committed)
self.assertTrue(session.closed)
self.assertEqual(len(session.added), 1)
record = session.added[0]
self.assertEqual(record.request_id, "req-123")
self.assertEqual(record.conversation_id, "user:7")
self.assertEqual(record.user_id, 7)
self.assertEqual(record.channel, "telegram")
self.assertEqual(record.external_id, "12345")
self.assertEqual(record.username, "cliente_teste")
self.assertEqual(record.intent, "order_create")
self.assertEqual(record.domain, "sales")
self.assertEqual(record.action, "collect_order_create")
self.assertEqual(record.tool_name, "consultar_estoque")
self.assertEqual(record.tool_arguments, '{"preco_max":80000}')
self.assertEqual(record.started_at, started_at)
self.assertEqual(record.completed_at, completed_at)
self.assertEqual(record.elapsed_ms, 512.4)
def test_list_turns_filters_and_orders_recent_first(self):
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
MockBase.metadata.create_all(bind=engine)
self.addCleanup(engine.dispose)
db = SessionLocal()
user = User(channel="telegram", external_id="999", name="Cliente Teste", username="cliente_teste")
db.add(user)
db.commit()
db.refresh(user)
user_id = user.id
db.add_all(
[
ConversationTurn(
request_id="req-old",
conversation_id=f"user:{user_id}",
user_id=user_id,
channel="telegram",
external_id="999",
username="cliente_teste",
user_message="oi",
assistant_response="ola",
turn_status="completed",
intent="general",
domain="general",
action="answer_user",
tool_name=None,
tool_arguments=None,
started_at=datetime(2026, 3, 16, 10, 0, 0),
completed_at=datetime(2026, 3, 16, 10, 0, 1),
elapsed_ms=100.0,
),
ConversationTurn(
request_id="req-new",
conversation_id=f"user:{user_id}",
user_id=user_id,
channel="telegram",
external_id="999",
username="cliente_teste",
user_message="quero comprar",
assistant_response="pedido criado",
turn_status="completed",
intent="order_create",
domain="sales",
action="call_tool",
tool_name="realizar_pedido",
tool_arguments='{"vehicle_id":1}',
started_at=datetime(2026, 3, 16, 11, 0, 0),
completed_at=datetime(2026, 3, 16, 11, 0, 2),
elapsed_ms=230.0,
),
ConversationTurn(
request_id="req-failed",
conversation_id="user:999",
user_id=None,
channel=None,
external_id=None,
username=None,
user_message="erro",
assistant_response=None,
turn_status="failed",
intent="general",
domain="general",
action="answer_user",
tool_name=None,
tool_arguments=None,
error_detail="RuntimeError: boom",
started_at=datetime(2026, 3, 16, 12, 0, 0),
completed_at=datetime(2026, 3, 16, 12, 0, 1),
elapsed_ms=300.0,
),
]
)
db.commit()
db.close()
service = ConversationHistoryService()
with patch(
"app.services.orchestration.conversation_history_service.SessionMockLocal",
SessionLocal,
):
items = service.list_turns(user_id=user_id, turn_status="completed", limit=5)
self.assertEqual(len(items), 2)
self.assertEqual(items[0]["request_id"], "req-new")
self.assertEqual(items[1]["request_id"], "req-old")
self.assertEqual(items[0]["tool_arguments"], {"vehicle_id": 1})
self.assertEqual(items[0]["turn_status"], "completed")
self.assertEqual(items[0]["user_id"], user_id)
def test_list_turns_can_filter_by_request_id(self):
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
MockBase.metadata.create_all(bind=engine)
self.addCleanup(engine.dispose)
db = SessionLocal()
db.add(
ConversationTurn(
request_id="req-only",
conversation_id="user:42",
user_id=None,
user_message="teste",
assistant_response="ok",
turn_status="completed",
started_at=datetime(2026, 3, 16, 13, 0, 0),
completed_at=datetime(2026, 3, 16, 13, 0, 1),
)
)
db.commit()
db.close()
service = ConversationHistoryService()
with patch(
"app.services.orchestration.conversation_history_service.SessionMockLocal",
SessionLocal,
):
items = service.list_turns(request_id="req-only", limit=5)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["request_id"], "req-only")
self.assertEqual(items[0]["conversation_id"], "user:42")

@ -1,5 +1,6 @@
import os import os
import unittest import unittest
from types import SimpleNamespace
os.environ.setdefault("DEBUG", "false") os.environ.setdefault("DEBUG", "false")
@ -1111,6 +1112,196 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(str(decision.get("action") or ""), "answer_user") self.assertEqual(str(decision.get("action") or ""), "answer_user")
self.assertEqual(str(decision.get("response_to_user") or "").strip(), "Resposta direta do contrato.") self.assertEqual(str(decision.get("response_to_user") or "").strip(), "Resposta direta do contrato.")
async def test_handle_message_persists_completed_turn_history(self):
state = FakeState(
contexts={
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
history_calls = []
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service.history_service = SimpleNamespace(record_turn=lambda **kwargs: history_calls.append(kwargs))
service._empty_extraction_payload = service.normalizer.empty_extraction_payload
service._log_turn_event = lambda *args, **kwargs: None
service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response
async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None):
return base_response
service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service._upsert_user_context = lambda user_id: None
async def fake_extract_turn_decision(message: str, user_id: int | None):
return {
"intent": "general",
"domain": "general",
"action": "answer_user",
"entities": service.normalizer.empty_extraction_payload(),
"missing_fields": [],
"selection_index": None,
"tool_name": None,
"tool_arguments": {},
"response_to_user": "Resposta direta do contrato.",
}
service._extract_turn_decision_with_llm = fake_extract_turn_decision
async def fake_try_handle_immediate_context_reset(**kwargs):
return None
service._try_handle_immediate_context_reset = fake_try_handle_immediate_context_reset
async def fake_try_resolve_pending_order_selection(**kwargs):
return None
service._try_resolve_pending_order_selection = fake_try_resolve_pending_order_selection
async def fake_try_continue_queued_order(**kwargs):
return None
service._try_continue_queued_order = fake_try_continue_queued_order
async def fake_extract_message_plan(message: str, user_id: int | None):
return {
"orders": [
{
"domain": "general",
"message": message,
"entities": service.normalizer.empty_extraction_payload(),
}
]
}
service._extract_message_plan_with_llm = fake_extract_message_plan
service._prepare_message_for_single_order = lambda message, user_id, routing_plan=None: (message, None, None)
service._resolve_entities_for_message_plan = lambda message_plan, routed_message: service.normalizer.empty_extraction_payload()
async def fake_extract_entities(message: str, user_id: int | None):
return service.normalizer.empty_extraction_payload()
service._extract_entities_with_llm = fake_extract_entities
async def fake_extract_missing_sales_search_context_with_llm(**kwargs):
return {}
service._extract_missing_sales_search_context_with_llm = fake_extract_missing_sales_search_context_with_llm
service._domain_from_intents = lambda intents: "general"
service._handle_context_switch = lambda **kwargs: None
service._update_active_domain = lambda **kwargs: None
async def fake_try_execute_orchestration_control_tool(**kwargs):
return None
service._try_execute_orchestration_control_tool = fake_try_execute_orchestration_control_tool
async def fake_try_execute_business_tool_from_turn_decision(**kwargs):
return None
service._try_execute_business_tool_from_turn_decision = fake_try_execute_business_tool_from_turn_decision
async def fake_try_handle_review_management(**kwargs):
return None
service._try_handle_review_management = fake_try_handle_review_management
async def fake_try_confirm_pending_review(**kwargs):
return None
service._try_confirm_pending_review = fake_try_confirm_pending_review
async def fake_try_collect_and_schedule_review(**kwargs):
return None
service._try_collect_and_schedule_review = fake_try_collect_and_schedule_review
async def fake_try_collect_and_cancel_order(**kwargs):
return None
service._try_collect_and_cancel_order = fake_try_collect_and_cancel_order
async def fake_try_handle_order_listing(**kwargs):
return None
service._try_handle_order_listing = fake_try_handle_order_listing
async def fake_try_collect_and_create_order(**kwargs):
return None
service._try_collect_and_create_order = fake_try_collect_and_create_order
response = await service.handle_message(
"ola",
user_id=1,
)
self.assertEqual(response, "Resposta direta do contrato.")
self.assertEqual(len(history_calls), 1)
self.assertEqual(history_calls[0]["user_message"], "ola")
self.assertEqual(history_calls[0]["assistant_response"], "Resposta direta do contrato.")
self.assertEqual(history_calls[0]["turn_status"], "completed")
self.assertEqual(history_calls[0]["intent"], "general")
async def test_handle_message_persists_failed_turn_history(self):
state = FakeState(
contexts={
1: {
"active_domain": "general",
"generic_memory": {},
"shared_memory": {},
"order_queue": [],
"pending_order_selection": None,
"pending_switch": None,
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
history_calls = []
service = OrquestradorService.__new__(OrquestradorService)
service.state = state
service.normalizer = EntityNormalizer()
service.policy = ConversationPolicy(service=service)
service.history_service = SimpleNamespace(record_turn=lambda **kwargs: history_calls.append(kwargs))
service._empty_extraction_payload = service.normalizer.empty_extraction_payload
service._log_turn_event = lambda *args, **kwargs: None
service._compose_order_aware_response = lambda response, user_id, queue_notice=None: response
async def fake_maybe_auto_advance_next_order(base_response: str, user_id: int | None):
return base_response
service._maybe_auto_advance_next_order = fake_maybe_auto_advance_next_order
service._upsert_user_context = lambda user_id: None
async def fake_extract_turn_decision(message: str, user_id: int | None):
raise RuntimeError("falha controlada no turno")
service._extract_turn_decision_with_llm = fake_extract_turn_decision
with self.assertRaises(RuntimeError):
await service.handle_message(
"ola",
user_id=1,
)
self.assertEqual(len(history_calls), 1)
self.assertEqual(history_calls[0]["user_message"], "ola")
self.assertIsNone(history_calls[0]["assistant_response"])
self.assertEqual(history_calls[0]["turn_status"], "failed")
self.assertIn("RuntimeError", history_calls[0]["error_detail"])
self.assertIn("falha controlada no turno", history_calls[0]["error_detail"])
async def test_handle_message_prioritizes_order_flow_over_model_answer_for_purchase_intent(self): async def test_handle_message_prioritizes_order_flow_over_model_answer_for_purchase_intent(self):
state = FakeState( state = FakeState(
contexts={ contexts={

Loading…
Cancel
Save