From 6fe92a0ae1169f8b718847f046b0d20e7d34c80a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Wed, 11 Mar 2026 15:22:16 -0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(orchestration):=20normalizar?= =?UTF-8?q?=20aliases=20do=20contrato=20de=20decisao=20do=20modelo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aceitar aliases de intent retornados pelo Vertex como place_order e create_order - converter aliases de campos faltantes como modelo_carro para vehicle_id no fluxo de compra - redirecionar decisoes de compra quase-validas para collect_order_create em vez de cair no fallback - cobrir com testes os formatos divergentes retornados localmente e no servidor --- .../orchestration/entity_normalizer.py | 81 +++++++++++++++++++ tests/test_turn_decision_contract.py | 57 +++++++++++++ 2 files changed, 138 insertions(+) diff --git a/app/services/orchestration/entity_normalizer.py b/app/services/orchestration/entity_normalizer.py index 3f0d6e0..ab2587d 100644 --- a/app/services/orchestration/entity_normalizer.py +++ b/app/services/orchestration/entity_normalizer.py @@ -14,6 +14,31 @@ logger = logging.getLogger(__name__) # Essa classe concentra normalizacao tecnica e coercoes estruturadas. # A semantica conversacional idealmente vem do modelo, nao daqui. class EntityNormalizer: + _TURN_INTENT_ALIASES = { + "create_order": "order_create", + "place_order": "order_create", + "new_order": "order_create", + "buy_car": "order_create", + "purchase_car": "order_create", + "cancel_order": "order_cancel", + "list_inventory": "inventory_search", + "search_inventory": "inventory_search", + "clear_conversation": "conversation_reset", + } + _TURN_ACTION_ALIASES = { + "collect_order": "collect_order_create", + "collect_review": "collect_review_schedule", + "cancel_flow": "cancel_active_flow", + "reset_context": "clear_context", + } + _ORDER_MISSING_FIELD_ALIASES = { + "modelo_carro": "vehicle_id", + "modelo_do_carro": "vehicle_id", + "modelo_veiculo": "vehicle_id", + "veiculo": "vehicle_id", + "carro": "vehicle_id", + } + def empty_turn_decision(self) -> dict: return TurnDecision().model_dump() @@ -103,6 +128,7 @@ class EntityNormalizer: def coerce_turn_decision(self, payload) -> dict: if not isinstance(payload, dict): return self.empty_turn_decision() + payload = self._normalize_turn_decision_payload(payload) try: model = TurnDecision.model_validate(payload) except ValidationError: @@ -122,6 +148,61 @@ class EntityNormalizer: dumped["missing_fields"] = [str(field) for field in dumped.get("missing_fields") or [] if str(field).strip()] return dumped + def _normalize_turn_decision_payload(self, payload: dict) -> dict: + normalized = dict(payload) + + raw_intent = self.normalize_text(str(normalized.get("intent") or "")).replace("-", "_").replace(" ", "_") + if raw_intent in self._TURN_INTENT_ALIASES: + normalized["intent"] = self._TURN_INTENT_ALIASES[raw_intent] + + raw_action = self.normalize_text(str(normalized.get("action") or "")).replace("-", "_").replace(" ", "_") + if raw_action in self._TURN_ACTION_ALIASES: + normalized["action"] = self._TURN_ACTION_ALIASES[raw_action] + + missing_fields = normalized.get("missing_fields") + if isinstance(missing_fields, list): + normalized["missing_fields"] = self._normalize_turn_missing_fields(missing_fields) + + entities = normalized.get("entities") + if isinstance(entities, dict): + normalized["entities"] = dict(entities) + + if self._should_route_order_alias_to_collection(normalized): + normalized["action"] = "collect_order_create" + normalized["missing_fields"] = [] + normalized["response_to_user"] = None + + return normalized + + def _normalize_turn_missing_fields(self, missing_fields: list) -> list[str]: + normalized_fields: list[str] = [] + for field in missing_fields: + candidate = self.normalize_text(str(field or "")).replace("-", "_").replace(" ", "_") + canonical = self._ORDER_MISSING_FIELD_ALIASES.get(candidate, candidate) + if canonical and canonical not in normalized_fields: + normalized_fields.append(canonical) + return normalized_fields + + def _should_route_order_alias_to_collection(self, payload: dict) -> bool: + if payload.get("intent") != "order_create": + return False + if payload.get("action") != "ask_missing_fields": + return False + + missing_fields = payload.get("missing_fields") or [] + if not missing_fields or any(field != "vehicle_id" for field in missing_fields): + return False + + entities = payload.get("entities") + if not isinstance(entities, dict): + return False + order_fields = entities.get("order_fields") + if not isinstance(order_fields, dict): + return False + if order_fields.get("vehicle_id"): + return False + return True + def normalize_text(self, text: str) -> str: return technical_normalizer.normalize_text(text) diff --git a/tests/test_turn_decision_contract.py b/tests/test_turn_decision_contract.py index f9e053e..f062fd8 100644 --- a/tests/test_turn_decision_contract.py +++ b/tests/test_turn_decision_contract.py @@ -173,6 +173,63 @@ class TurnDecisionContractTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(decision["action"], "answer_user") self.assertEqual(decision["entities"]["order_fields"], {}) + def test_coerce_turn_decision_maps_order_aliases_from_model(self): + normalizer = EntityNormalizer() + + decision = normalizer.coerce_turn_decision( + { + "intent": "place_order", + "domain": "sales", + "action": "answer_user", + "entities": { + "generic_memory": {"orcamento_max": "70000", "cpf": "12345678909"}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {}, + "cancel_order_fields": {}, + }, + "missing_fields": [], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": None, + } + ) + + self.assertEqual(decision["intent"], "order_create") + self.assertEqual(decision["domain"], "sales") + self.assertEqual(decision["action"], "answer_user") + self.assertEqual(decision["entities"]["generic_memory"]["orcamento_max"], 70000) + self.assertEqual(decision["entities"]["generic_memory"]["cpf"], "12345678909") + + def test_coerce_turn_decision_converts_vehicle_alias_missing_field_into_order_collection(self): + normalizer = EntityNormalizer() + + decision = normalizer.coerce_turn_decision( + { + "intent": "create_order", + "domain": "sales", + "action": "ask_missing_fields", + "entities": { + "generic_memory": {"orcamento_max": 70000}, + "review_fields": {}, + "review_management_fields": {}, + "order_fields": {"cpf": "12345678909"}, + "cancel_order_fields": {}, + }, + "missing_fields": ["modelo_carro"], + "selection_index": None, + "tool_name": None, + "tool_arguments": {}, + "response_to_user": "Certo! Para qual modelo de carro voce gostaria de um orcamento de 70 mil?", + } + ) + + self.assertEqual(decision["intent"], "order_create") + self.assertEqual(decision["action"], "collect_order_create") + self.assertEqual(decision["missing_fields"], []) + self.assertIsNone(decision["response_to_user"]) + def test_coerce_turn_decision_rejects_missing_fields_without_response_payload(self): normalizer = EntityNormalizer()