🧠 feat(orquestrador): deixar o modelo decidir o turno e limitar regex a formalizacao tecnica
Introduz o contrato TurnDecision e a extracao estruturada por turno no planner para que intent, domain, action, selecao e resposta venham do modelo, com validacao Pydantic e fallback previsivel quando o JSON vier invalido. Tambem extrai a normalizacao tecnica para um modulo dedicado e passa a usar regex apenas para formalizar CPF, placa, protocolos, datas e outros formatos estruturados, reduzindo heuristicas semanticas dentro do normalizador, da policy e dos fluxos de revisao.main
parent
d27ebf798d
commit
8cf79174ee
@ -0,0 +1,245 @@
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKD", text or "")
|
||||
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
return ascii_text.lower()
|
||||
|
||||
|
||||
def normalize_plate(value) -> str | None:
|
||||
text = str(value or "").strip().upper()
|
||||
if not text:
|
||||
return None
|
||||
if re.fullmatch(r"[A-Z]{3}[0-9][A-Z0-9][0-9]{2}", text) or re.fullmatch(r"[A-Z]{3}[0-9]{4}", text):
|
||||
return text
|
||||
compact = re.sub(r"[^A-Z0-9]", "", text)
|
||||
if re.fullmatch(r"[A-Z]{3}[0-9][A-Z0-9][0-9]{2}", compact) or re.fullmatch(r"[A-Z]{3}[0-9]{4}", compact):
|
||||
return compact
|
||||
return None
|
||||
|
||||
|
||||
def normalize_cpf(value) -> str | None:
|
||||
digits = re.sub(r"\D", "", str(value or ""))
|
||||
if len(digits) == 11:
|
||||
return digits
|
||||
return None
|
||||
|
||||
|
||||
def is_valid_cpf(value) -> bool:
|
||||
digits = normalize_cpf(value)
|
||||
if not digits:
|
||||
return False
|
||||
if digits == digits[0] * 11:
|
||||
return False
|
||||
|
||||
numbers = [int(digit) for digit in digits]
|
||||
sum_first = sum(number * weight for number, weight in zip(numbers[:9], range(10, 1, -1)))
|
||||
first_digit = 11 - (sum_first % 11)
|
||||
first_digit = 0 if first_digit >= 10 else first_digit
|
||||
if first_digit != numbers[9]:
|
||||
return False
|
||||
|
||||
sum_second = sum(number * weight for number, weight in zip(numbers[:10], range(11, 1, -1)))
|
||||
second_digit = 11 - (sum_second % 11)
|
||||
second_digit = 0 if second_digit >= 10 else second_digit
|
||||
return second_digit == numbers[10]
|
||||
|
||||
|
||||
def normalize_positive_number(value) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
number = float(value)
|
||||
return number if number > 0 else None
|
||||
text = normalize_text(str(value))
|
||||
text = text.replace("r$", "").strip()
|
||||
multiplier = 1000 if "mil" in text else 1
|
||||
text = text.replace("mil", "").strip()
|
||||
digits = re.sub(r"[^0-9,.\s]", "", text)
|
||||
if not digits:
|
||||
return None
|
||||
numeric = digits.replace(".", "").replace(" ", "").replace(",", ".")
|
||||
try:
|
||||
number = float(numeric) * multiplier
|
||||
return number if number > 0 else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def normalize_vehicle_profile(value) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
allowed = {"suv", "sedan", "hatch", "pickup"}
|
||||
items = value if isinstance(value, list) else [value]
|
||||
normalized: list[str] = []
|
||||
for item in items:
|
||||
marker = normalize_text(str(item)).strip()
|
||||
if marker in allowed and marker not in normalized:
|
||||
normalized.append(marker)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_bool(value) -> bool | None:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
lowered = normalize_text(str(value or "")).strip()
|
||||
if lowered in {"sim", "true", "1", "yes"}:
|
||||
return True
|
||||
if lowered in {"nao", "false", "0", "no"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def normalize_datetime_connector(text: str) -> str:
|
||||
compact = " ".join(str(text or "").strip().split())
|
||||
return re.sub(r"\s+[aàáâã]s\s+", " ", compact, flags=re.IGNORECASE).strip()
|
||||
|
||||
|
||||
def try_parse_iso_datetime(text: str) -> datetime | None:
|
||||
candidate = str(text or "").strip()
|
||||
if not candidate:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(candidate.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def try_parse_datetime_with_formats(text: str, formats: tuple[str, ...]) -> datetime | None:
|
||||
candidate = str(text or "").strip()
|
||||
if not candidate:
|
||||
return None
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(candidate, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def try_parse_review_absolute_datetime(text: str) -> datetime | None:
|
||||
normalized = normalize_datetime_connector(text)
|
||||
parsed = try_parse_iso_datetime(normalized)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
|
||||
day_first_formats = (
|
||||
"%d/%m/%Y %H:%M",
|
||||
"%d/%m/%Y %H:%M:%S",
|
||||
"%d-%m-%Y %H:%M",
|
||||
"%d-%m-%Y %H:%M:%S",
|
||||
)
|
||||
year_first_formats = (
|
||||
"%Y/%m/%d %H:%M",
|
||||
"%Y/%m/%d %H:%M:%S",
|
||||
"%Y-%m-%d %H:%M",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
return try_parse_datetime_with_formats(normalized, day_first_formats + year_first_formats)
|
||||
|
||||
|
||||
def strip_token_edges(token: str) -> str:
|
||||
cleaned = str(token or "").strip()
|
||||
edge_chars = "[](){}<>,.;:!?\"'`"
|
||||
while cleaned and cleaned[0] in edge_chars:
|
||||
cleaned = cleaned[1:]
|
||||
while cleaned and cleaned[-1] in edge_chars:
|
||||
cleaned = cleaned[:-1]
|
||||
return cleaned
|
||||
|
||||
|
||||
def extract_hhmm_from_text(text: str) -> str | None:
|
||||
cleaned = normalize_datetime_connector(text)
|
||||
for token in cleaned.split():
|
||||
normalized_token = strip_token_edges(token)
|
||||
parts = normalized_token.split(":")
|
||||
if len(parts) not in {2, 3}:
|
||||
continue
|
||||
if not all(part.isdigit() for part in parts):
|
||||
continue
|
||||
hour = int(parts[0])
|
||||
minute = int(parts[1])
|
||||
if 0 <= hour <= 23 and 0 <= minute <= 59:
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_review_datetime_text(value, now_provider=None) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
absolute_dt = try_parse_review_absolute_datetime(text)
|
||||
if absolute_dt is not None:
|
||||
return text
|
||||
|
||||
normalized = normalize_text(text)
|
||||
day_offset = None
|
||||
if "amanha" in normalized:
|
||||
day_offset = 1
|
||||
elif "hoje" in normalized:
|
||||
day_offset = 0
|
||||
if day_offset is None:
|
||||
return text
|
||||
|
||||
time_text = extract_hhmm_from_text(normalized)
|
||||
if not time_text:
|
||||
return text
|
||||
|
||||
hour_text, minute_text = time_text.split(":")
|
||||
current_datetime = now_provider() if callable(now_provider) else datetime.now()
|
||||
target_date = current_datetime + timedelta(days=day_offset)
|
||||
return f"{target_date.strftime('%d/%m/%Y')} {int(hour_text):02d}:{int(minute_text):02d}"
|
||||
|
||||
|
||||
def tokenize_text(text: str) -> list[str]:
|
||||
return [token for token in str(text or "").split() if token]
|
||||
|
||||
|
||||
def clean_protocol_token(token: str) -> str:
|
||||
return strip_token_edges(str(token or "").strip().upper())
|
||||
|
||||
|
||||
def is_valid_protocol_suffix(value: str) -> bool:
|
||||
if not value or len(value) < 4:
|
||||
return False
|
||||
return all(char.isalnum() for char in value)
|
||||
|
||||
|
||||
def normalize_review_protocol(value: str) -> str | None:
|
||||
candidate = clean_protocol_token(value)
|
||||
if not candidate.startswith("REV-"):
|
||||
return None
|
||||
parts = candidate.split("-")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
prefix, date_part, suffix_part = parts
|
||||
if prefix != "REV":
|
||||
return None
|
||||
if len(date_part) != 8 or not date_part.isdigit():
|
||||
return None
|
||||
try:
|
||||
datetime.strptime(date_part, "%Y%m%d")
|
||||
except ValueError:
|
||||
return None
|
||||
if not is_valid_protocol_suffix(suffix_part):
|
||||
return None
|
||||
return f"{prefix}-{date_part}-{suffix_part}"
|
||||
|
||||
|
||||
def extract_review_protocol_from_text(text: str) -> str | None:
|
||||
for token in tokenize_text(text):
|
||||
normalized = normalize_review_protocol(token)
|
||||
if normalized:
|
||||
return normalized
|
||||
return normalize_review_protocol(str(text or ""))
|
||||
|
||||
|
||||
def normalize_order_number(value) -> str | None:
|
||||
order_number = str(value or "").strip().upper()
|
||||
if order_number and re.fullmatch(r"PED-[A-Z0-9\\-]+", order_number):
|
||||
return order_number
|
||||
return None
|
||||
@ -0,0 +1,72 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
# Esse modulo define o contrato estruturado esperado do modelo por turno.
|
||||
TurnDomain = Literal["review", "sales", "general"]
|
||||
TurnIntent = Literal[
|
||||
"review_schedule",
|
||||
"review_list",
|
||||
"review_cancel",
|
||||
"review_reschedule",
|
||||
"order_create",
|
||||
"order_cancel",
|
||||
"inventory_search",
|
||||
"conversation_reset",
|
||||
"queue_continue",
|
||||
"discard_queue",
|
||||
"cancel_active_flow",
|
||||
"general",
|
||||
]
|
||||
TurnAction = Literal[
|
||||
"collect_review_schedule",
|
||||
"collect_review_management",
|
||||
"collect_order_create",
|
||||
"collect_order_cancel",
|
||||
"ask_missing_fields",
|
||||
"answer_user",
|
||||
"call_tool",
|
||||
"clear_context",
|
||||
"continue_queue",
|
||||
"discard_queue",
|
||||
"cancel_active_flow",
|
||||
]
|
||||
|
||||
|
||||
class DecisionEntities(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
# As entidades continuam separadas por tipo de fluxo para facilitar
|
||||
# compatibilidade com os mixins e validadores tecnicos atuais.
|
||||
generic_memory: dict[str, Any] = Field(default_factory=dict)
|
||||
review_fields: dict[str, Any] = Field(default_factory=dict)
|
||||
review_management_fields: dict[str, Any] = Field(default_factory=dict)
|
||||
order_fields: dict[str, Any] = Field(default_factory=dict)
|
||||
cancel_order_fields: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TurnDecision(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
# O modelo decide a intencao, o dominio e a acao do turno.
|
||||
intent: TurnIntent = "general"
|
||||
domain: TurnDomain = "general"
|
||||
action: TurnAction = "answer_user"
|
||||
entities: DecisionEntities = Field(default_factory=DecisionEntities)
|
||||
missing_fields: list[str] = Field(default_factory=list)
|
||||
selection_index: int | None = None
|
||||
tool_name: str | None = None
|
||||
tool_arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
response_to_user: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_contract(self):
|
||||
if self.action == "ask_missing_fields":
|
||||
if not self.missing_fields or not str(self.response_to_user or "").strip():
|
||||
raise ValueError("ask_missing_fields exige missing_fields e response_to_user")
|
||||
if self.action == "call_tool" and not str(self.tool_name or "").strip():
|
||||
raise ValueError("call_tool exige tool_name")
|
||||
if self.selection_index is not None and self.selection_index < 0:
|
||||
raise ValueError("selection_index deve ser maior ou igual a zero")
|
||||
return self
|
||||
@ -0,0 +1,452 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("DEBUG", "false")
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.services.orchestration.conversation_policy import ConversationPolicy
|
||||
from app.services.orchestration.entity_normalizer import EntityNormalizer
|
||||
from app.services.orchestration.message_planner import MessagePlanner
|
||||
from app.services.orchestration.orquestrador_service import OrquestradorService
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = 0
|
||||
|
||||
async def generate_response(self, message: str, tools):
|
||||
self.calls += 1
|
||||
if self.responses:
|
||||
return self.responses.pop(0)
|
||||
return {"response": "", "tool_call": None}
|
||||
|
||||
|
||||
class FakeState:
|
||||
def __init__(self, entries=None, contexts=None):
|
||||
self.entries = entries or {}
|
||||
self.contexts = contexts or {}
|
||||
|
||||
def get_entry(self, bucket: str, user_id: int | None, *, expire: bool = False):
|
||||
if user_id is None:
|
||||
return None
|
||||
return self.entries.get(bucket, {}).get(user_id)
|
||||
|
||||
def set_entry(self, bucket: str, user_id: int | None, value: dict):
|
||||
if user_id is None:
|
||||
return
|
||||
self.entries.setdefault(bucket, {})[user_id] = value
|
||||
|
||||
def pop_entry(self, bucket: str, user_id: int | None):
|
||||
if user_id is None:
|
||||
return None
|
||||
return self.entries.get(bucket, {}).pop(user_id, None)
|
||||
|
||||
|
||||
class FakeToolExecutor:
|
||||
def __init__(self, result=None):
|
||||
self.result = result or {"ok": True}
|
||||
self.calls = []
|
||||
|
||||
async def execute(self, tool_name: str, arguments: dict, user_id: int | None = None):
|
||||
self.calls.append((tool_name, arguments, user_id))
|
||||
if tool_name == "consultar_estoque" and arguments.get("preco_max") and float(arguments["preco_max"]) > 50000:
|
||||
return [
|
||||
{"id": 7, "modelo": "Hyundai HB20 2022", "categoria": "hatch", "preco": 54500.0},
|
||||
{"id": 8, "modelo": "Chevrolet Onix 2023", "categoria": "hatch", "preco": 58900.0},
|
||||
]
|
||||
return self.result
|
||||
|
||||
|
||||
class FakePolicyService:
|
||||
def __init__(self, state):
|
||||
self.state = state
|
||||
self.normalizer = EntityNormalizer()
|
||||
|
||||
def _get_user_context(self, user_id: int | None):
|
||||
if user_id is None:
|
||||
return None
|
||||
return self.state.contexts.get(user_id)
|
||||
|
||||
def _new_tab_memory(self, user_id: int | None):
|
||||
return {}
|
||||
|
||||
def _is_affirmative_message(self, text: str) -> bool:
|
||||
normalized = self.normalizer.normalize_text(text).strip().rstrip(".!?,;:")
|
||||
return normalized in {"sim", "pode", "ok", "confirmo", "aceito", "fechado", "pode sim"}
|
||||
|
||||
def _is_negative_message(self, text: str) -> bool:
|
||||
normalized = self.normalizer.normalize_text(text).strip().rstrip(".!?,;:")
|
||||
return normalized in {"nao", "nao quero"} or normalized.startswith("nao")
|
||||
|
||||
def _clear_user_conversation_state(self, user_id: int | None) -> None:
|
||||
context = self._get_user_context(user_id)
|
||||
if context:
|
||||
context["pending_order_selection"] = None
|
||||
|
||||
async def handle_message(self, message: str, user_id: int | None = None) -> str:
|
||||
return f"handled:{message}"
|
||||
|
||||
def _render_missing_review_fields_prompt(self, missing_fields: list[str]) -> str:
|
||||
return "missing review"
|
||||
|
||||
def _render_missing_review_reschedule_fields_prompt(self, missing_fields: list[str]) -> str:
|
||||
return "missing review reschedule"
|
||||
|
||||
def _render_missing_review_cancel_fields_prompt(self, missing_fields: list[str]) -> str:
|
||||
return "missing review cancel"
|
||||
|
||||
def _render_review_reuse_question(self) -> str:
|
||||
return "reuse review?"
|
||||
|
||||
def _render_missing_order_fields_prompt(self, missing_fields: list[str]) -> str:
|
||||
return "missing order"
|
||||
|
||||
def _render_missing_cancel_order_fields_prompt(self, missing_fields: list[str]) -> str:
|
||||
return "missing cancel order"
|
||||
|
||||
|
||||
class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_extract_turn_decision_retries_once_and_returns_structured_payload(self):
|
||||
llm = FakeLLM(
|
||||
[
|
||||
{"response": "nao eh json", "tool_call": None},
|
||||
{
|
||||
"response": """
|
||||
{
|
||||
"intent": "review_schedule",
|
||||
"domain": "review",
|
||||
"action": "ask_missing_fields",
|
||||
"entities": {
|
||||
"generic_memory": {},
|
||||
"review_fields": {"placa": "abc1234", "data_hora": "10/03/2026 às 09:00"},
|
||||
"review_management_fields": {},
|
||||
"order_fields": {},
|
||||
"cancel_order_fields": {}
|
||||
},
|
||||
"missing_fields": ["modelo", "ano", "km"],
|
||||
"tool_name": null,
|
||||
"tool_arguments": {},
|
||||
"response_to_user": "Preciso do modelo, ano e quilometragem."
|
||||
}
|
||||
""",
|
||||
"tool_call": None,
|
||||
},
|
||||
]
|
||||
)
|
||||
planner = MessagePlanner(llm=llm, normalizer=EntityNormalizer())
|
||||
|
||||
decision = await planner.extract_turn_decision("Quero agendar revisão amanhã às 09:00", user_id=7)
|
||||
|
||||
self.assertEqual(llm.calls, 2)
|
||||
self.assertEqual(decision["intent"], "review_schedule")
|
||||
self.assertEqual(decision["domain"], "review")
|
||||
self.assertEqual(decision["action"], "ask_missing_fields")
|
||||
self.assertEqual(decision["entities"]["review_fields"]["placa"], "ABC1234")
|
||||
self.assertEqual(decision["entities"]["review_fields"]["data_hora"], "10/03/2026 às 09:00")
|
||||
self.assertEqual(decision["missing_fields"], ["modelo", "ano", "km"])
|
||||
|
||||
def test_coerce_turn_decision_rejects_invalid_shape_with_fallback(self):
|
||||
normalizer = EntityNormalizer()
|
||||
|
||||
decision = normalizer.coerce_turn_decision(
|
||||
{
|
||||
"intent": "valor_invalido",
|
||||
"domain": "sales",
|
||||
"action": "call_tool",
|
||||
"entities": [],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(decision["intent"], "general")
|
||||
self.assertEqual(decision["domain"], "general")
|
||||
self.assertEqual(decision["action"], "answer_user")
|
||||
self.assertEqual(decision["entities"]["order_fields"], {})
|
||||
|
||||
def test_coerce_turn_decision_rejects_missing_fields_without_response_payload(self):
|
||||
normalizer = EntityNormalizer()
|
||||
|
||||
decision = normalizer.coerce_turn_decision(
|
||||
{
|
||||
"intent": "review_schedule",
|
||||
"domain": "review",
|
||||
"action": "ask_missing_fields",
|
||||
"entities": {
|
||||
"generic_memory": {},
|
||||
"review_fields": {},
|
||||
"review_management_fields": {},
|
||||
"order_fields": {},
|
||||
"cancel_order_fields": {},
|
||||
},
|
||||
"missing_fields": [],
|
||||
"tool_name": None,
|
||||
"tool_arguments": {},
|
||||
"response_to_user": "",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(decision["intent"], "general")
|
||||
self.assertEqual(decision["action"], "answer_user")
|
||||
|
||||
def test_turn_decision_entities_do_not_rebuild_legacy_intents(self):
|
||||
service = OrquestradorService.__new__(OrquestradorService)
|
||||
service.normalizer = EntityNormalizer()
|
||||
|
||||
extracted = service._extracted_entities_from_turn_decision(
|
||||
{
|
||||
"intent": "order_create",
|
||||
"domain": "sales",
|
||||
"action": "collect_order_create",
|
||||
"entities": {
|
||||
"generic_memory": {"cpf": "12345678909"},
|
||||
"review_fields": {},
|
||||
"review_management_fields": {},
|
||||
"order_fields": {"vehicle_id": 1},
|
||||
"cancel_order_fields": {},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(extracted["intents"], {})
|
||||
self.assertEqual(extracted["order_fields"]["vehicle_id"], 1)
|
||||
|
||||
def test_turn_decision_entity_merge_preserves_generic_memory_from_previous_extraction(self):
|
||||
service = OrquestradorService.__new__(OrquestradorService)
|
||||
service.normalizer = EntityNormalizer()
|
||||
service._empty_extraction_payload = service.normalizer.empty_extraction_payload
|
||||
|
||||
merged = service._merge_extracted_entities(
|
||||
{
|
||||
"generic_memory": {"orcamento_max": 70000},
|
||||
"review_fields": {},
|
||||
"review_management_fields": {},
|
||||
"order_fields": {"cpf": "12345678909"},
|
||||
"cancel_order_fields": {},
|
||||
"intents": {},
|
||||
},
|
||||
{
|
||||
"generic_memory": {},
|
||||
"review_fields": {},
|
||||
"review_management_fields": {},
|
||||
"order_fields": {},
|
||||
"cancel_order_fields": {},
|
||||
"intents": {},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(merged["generic_memory"]["orcamento_max"], 70000)
|
||||
self.assertEqual(merged["order_fields"]["cpf"], "12345678909")
|
||||
|
||||
def test_entity_merge_can_enrich_message_plan_with_full_extraction(self):
|
||||
service = OrquestradorService.__new__(OrquestradorService)
|
||||
service.normalizer = EntityNormalizer()
|
||||
service._empty_extraction_payload = service.normalizer.empty_extraction_payload
|
||||
|
||||
merged = service._merge_extracted_entities(
|
||||
{
|
||||
"generic_memory": {},
|
||||
"review_fields": {},
|
||||
"review_management_fields": {},
|
||||
"order_fields": {"cpf": "12345678909"},
|
||||
"cancel_order_fields": {},
|
||||
"intents": {},
|
||||
},
|
||||
{
|
||||
"generic_memory": {"orcamento_max": 70000},
|
||||
"review_fields": {},
|
||||
"review_management_fields": {},
|
||||
"order_fields": {},
|
||||
"cancel_order_fields": {},
|
||||
"intents": {},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(merged["generic_memory"]["orcamento_max"], 70000)
|
||||
self.assertEqual(merged["order_fields"]["cpf"], "12345678909")
|
||||
|
||||
async def test_turn_decision_call_tool_executes_without_router(self):
|
||||
service = OrquestradorService.__new__(OrquestradorService)
|
||||
service.tool_executor = FakeToolExecutor(result={"numero_pedido": "PED-1", "status": "Ativo"})
|
||||
service.llm = FakeLLM([])
|
||||
service._capture_review_confirmation_suggestion = lambda **kwargs: None
|
||||
service._capture_tool_result_context = lambda **kwargs: None
|
||||
service._should_use_deterministic_response = lambda tool_name: True
|
||||
service._fallback_format_tool_result = lambda tool_name, tool_result: f"{tool_name}:{tool_result['numero_pedido']}"
|
||||
service._build_result_prompt = lambda **kwargs: "unused"
|
||||
service._http_exception_detail = lambda exc: str(exc)
|
||||
service._is_low_value_response = lambda text: False
|
||||
|
||||
async def finish(response: str, queue_notice: str | None = None) -> str:
|
||||
return response if not queue_notice else f"{queue_notice}\n{response}"
|
||||
|
||||
response = await service._try_execute_business_tool_from_turn_decision(
|
||||
message="quero fechar o pedido",
|
||||
user_id=7,
|
||||
turn_decision={
|
||||
"action": "call_tool",
|
||||
"tool_name": "realizar_pedido",
|
||||
"tool_arguments": {"cpf": "12345678909", "vehicle_id": 1},
|
||||
},
|
||||
queue_notice=None,
|
||||
finish=finish,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
service.tool_executor.calls,
|
||||
[("realizar_pedido", {"cpf": "12345678909", "vehicle_id": 1}, 7)],
|
||||
)
|
||||
self.assertEqual(response, "realizar_pedido:PED-1")
|
||||
self.assertEqual(service.llm.calls, 0)
|
||||
|
||||
async def test_empty_stock_search_suggests_nearby_options(self):
|
||||
service = OrquestradorService.__new__(OrquestradorService)
|
||||
service.normalizer = EntityNormalizer()
|
||||
service.tool_executor = FakeToolExecutor(result=[])
|
||||
service._get_user_context = lambda user_id: {
|
||||
"generic_memory": {},
|
||||
"shared_memory": {},
|
||||
"last_stock_results": [],
|
||||
"selected_vehicle": None,
|
||||
}
|
||||
service._capture_tool_result_context = lambda tool_name, tool_result, user_id: None
|
||||
service._normalize_positive_number = service.normalizer.normalize_positive_number
|
||||
|
||||
response = await service._maybe_build_stock_suggestion_response(
|
||||
tool_name="consultar_estoque",
|
||||
arguments={"preco_max": 50000, "limite": 5},
|
||||
tool_result=[],
|
||||
user_id=5,
|
||||
)
|
||||
|
||||
self.assertIn("Nao encontrei veiculos ate R$ 50.000.", response)
|
||||
self.assertIn("Hyundai HB20 2022", response)
|
||||
self.assertIn("Se quiser, responda com o numero da lista ou com o modelo.", response)
|
||||
|
||||
async def test_turn_decision_answer_user_can_short_circuit_router(self):
|
||||
decision = {
|
||||
"intent": "general",
|
||||
"domain": "general",
|
||||
"action": "answer_user",
|
||||
"response_to_user": "Resposta direta do contrato.",
|
||||
}
|
||||
|
||||
self.assertEqual(str(decision.get("action") or ""), "answer_user")
|
||||
self.assertEqual(str(decision.get("response_to_user") or "").strip(), "Resposta direta do contrato.")
|
||||
|
||||
async def test_pending_order_selection_prefers_turn_decision_domain(self):
|
||||
state = FakeState(
|
||||
contexts={
|
||||
9: {
|
||||
"pending_order_selection": {
|
||||
"orders": [
|
||||
{"domain": "review", "message": "agendar revisao", "memory_seed": {}},
|
||||
{"domain": "sales", "message": "fazer pedido", "memory_seed": {}},
|
||||
],
|
||||
"expires_at": datetime.utcnow() + timedelta(minutes=15),
|
||||
},
|
||||
"order_queue": [],
|
||||
"active_domain": "general",
|
||||
"generic_memory": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
policy = ConversationPolicy(service=FakePolicyService(state))
|
||||
|
||||
response = await policy.try_resolve_pending_order_selection(
|
||||
message="quero comprar",
|
||||
user_id=9,
|
||||
turn_decision={"domain": "sales", "intent": "order_create", "action": "collect_order_create"},
|
||||
)
|
||||
|
||||
self.assertIn("Vou comecar por: Venda: fazer pedido", response)
|
||||
|
||||
async def test_pending_order_selection_prefers_turn_decision_selection_index(self):
|
||||
state = FakeState(
|
||||
contexts={
|
||||
9: {
|
||||
"pending_order_selection": {
|
||||
"orders": [
|
||||
{"domain": "review", "message": "agendar revisao", "memory_seed": {}},
|
||||
{"domain": "sales", "message": "fazer pedido", "memory_seed": {}},
|
||||
],
|
||||
"expires_at": datetime.utcnow() + timedelta(minutes=15),
|
||||
},
|
||||
"order_queue": [],
|
||||
"active_domain": "general",
|
||||
"generic_memory": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
policy = ConversationPolicy(service=FakePolicyService(state))
|
||||
|
||||
response = await policy.try_resolve_pending_order_selection(
|
||||
message="esse",
|
||||
user_id=9,
|
||||
turn_decision={"domain": "general", "intent": "general", "action": "answer_user", "selection_index": 1},
|
||||
)
|
||||
|
||||
self.assertIn("Vou comecar por: Venda: fazer pedido", response)
|
||||
|
||||
async def test_try_continue_queue_prefers_turn_decision_action(self):
|
||||
state = FakeState(
|
||||
contexts={
|
||||
9: {
|
||||
"pending_switch": {
|
||||
"target_domain": "sales",
|
||||
"queued_message": "fazer pedido",
|
||||
"memory_seed": {"cpf": "12345678909"},
|
||||
"expires_at": datetime.utcnow() + timedelta(minutes=15),
|
||||
},
|
||||
"active_domain": "general",
|
||||
"generic_memory": {},
|
||||
"pending_order_selection": None,
|
||||
}
|
||||
}
|
||||
)
|
||||
service = FakePolicyService(state)
|
||||
policy = ConversationPolicy(service=service)
|
||||
policy.apply_domain_switch = lambda user_id, target_domain: service._get_user_context(user_id).update(
|
||||
{"active_domain": target_domain, "pending_switch": None}
|
||||
)
|
||||
|
||||
response = await policy.try_continue_queued_order(
|
||||
message="ok",
|
||||
user_id=9,
|
||||
turn_decision={"action": "continue_queue", "intent": "queue_continue", "domain": "sales"},
|
||||
)
|
||||
|
||||
self.assertIn("Agora, sobre a compra do veiculo:", response)
|
||||
|
||||
def test_handle_context_switch_prefers_turn_decision_domain_confirmation(self):
|
||||
state = FakeState(
|
||||
contexts={
|
||||
9: {
|
||||
"pending_switch": {
|
||||
"target_domain": "review",
|
||||
"expires_at": datetime.utcnow() + timedelta(minutes=15),
|
||||
},
|
||||
"active_domain": "sales",
|
||||
"generic_memory": {},
|
||||
"pending_order_selection": None,
|
||||
}
|
||||
}
|
||||
)
|
||||
service = FakePolicyService(state)
|
||||
policy = ConversationPolicy(service=service)
|
||||
policy.apply_domain_switch = lambda user_id, target_domain: service._get_user_context(user_id).update(
|
||||
{"active_domain": target_domain, "pending_switch": None}
|
||||
)
|
||||
|
||||
response = policy.handle_context_switch(
|
||||
message="quero revisar",
|
||||
user_id=9,
|
||||
target_domain_hint="review",
|
||||
turn_decision={"domain": "review", "intent": "review_schedule", "action": "collect_review_schedule"},
|
||||
)
|
||||
|
||||
self.assertEqual(response, "Certo, contexto anterior encerrado. Vamos seguir com agendamento de revisao.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue