🐛 fix(sales): recuperar cpf e orcamento tecnico no fluxo de compra

- extrair CPF diretamente da mensagem quando o llm falhar em preencher o draft de pedido
- extrair orcamento de formatos tecnicos como '70 mil', 'R$ 45000' e 'ate 50 mil' sem depender da decisao semantica do modelo
- relistar estoque em mensagens de continuidade quando o draft ja estiver aberto e o contexto de compra estiver suficiente
- cobrir com testes os cenarios de follow-up no Telegram em que o llm nao devolve cpf ou orcamento
main
parent 21661e8306
commit 64b4878cb2

@ -5,7 +5,7 @@ from fastapi import HTTPException
from app.db.mock_database import SessionMockLocal from app.db.mock_database import SessionMockLocal
from app.db.mock_models import User, Vehicle from app.db.mock_models import User, Vehicle
from app.services.orchestration.technical_normalizer import is_valid_cpf from app.services.orchestration.technical_normalizer import extract_budget_from_text, extract_cpf_from_text, is_valid_cpf
from app.services.orchestration.orchestrator_config import ( from app.services.orchestration.orchestrator_config import (
CANCEL_ORDER_REQUIRED_FIELDS, CANCEL_ORDER_REQUIRED_FIELDS,
ORDER_REQUIRED_FIELDS, ORDER_REQUIRED_FIELDS,
@ -56,6 +56,30 @@ class OrderFlowMixin:
def _is_valid_cpf(self, cpf: str) -> bool: def _is_valid_cpf(self, cpf: str) -> bool:
return is_valid_cpf(cpf) return is_valid_cpf(cpf)
def _try_capture_order_cpf_from_message(self, message: str, payload: dict) -> None:
if payload.get("cpf"):
return
cpf = extract_cpf_from_text(message)
if cpf and self._is_valid_cpf(cpf):
payload["cpf"] = cpf
def _try_capture_order_budget_from_message(self, user_id: int | None, message: str, payload: dict) -> None:
if not self._has_explicit_order_request(message) and self.state.get_entry("pending_order_drafts", user_id, expire=True) is None:
return
context = self._get_user_context(user_id)
if not isinstance(context, dict):
return
generic_memory = context.get("generic_memory")
if not isinstance(generic_memory, dict):
generic_memory = {}
context["generic_memory"] = generic_memory
if generic_memory.get("orcamento_max"):
return
budget = extract_budget_from_text(message)
if budget:
generic_memory["orcamento_max"] = int(round(budget))
context.setdefault("shared_memory", {})["orcamento_max"] = int(round(budget))
def _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None: def _try_prefill_order_cpf_from_memory(self, user_id: int | None, payload: dict) -> None:
if user_id is None or payload.get("cpf"): if user_id is None or payload.get("cpf"):
return return
@ -390,6 +414,8 @@ class OrderFlowMixin:
} }
draft["payload"].update(extracted) draft["payload"].update(extracted)
self._try_capture_order_cpf_from_message(message=message, payload=draft["payload"])
self._try_capture_order_budget_from_message(user_id=user_id, message=message, payload=draft["payload"])
self._try_prefill_order_cpf_from_memory(user_id=user_id, payload=draft["payload"]) self._try_prefill_order_cpf_from_memory(user_id=user_id, payload=draft["payload"])
self._try_prefill_order_vehicle_from_context(user_id=user_id, payload=draft["payload"]) self._try_prefill_order_vehicle_from_context(user_id=user_id, payload=draft["payload"])
@ -437,7 +463,7 @@ class OrderFlowMixin:
user_id=user_id, user_id=user_id,
payload=draft["payload"], payload=draft["payload"],
turn_decision=turn_decision, turn_decision=turn_decision,
force=has_intent or explicit_order_request, force=bool(draft) or has_intent or explicit_order_request,
) )
if stock_response: if stock_response:
return stock_response return stock_response

@ -28,6 +28,14 @@ def normalize_cpf(value) -> str | None:
return None return None
def extract_cpf_from_text(text: str) -> str | None:
for match in re.finditer(r"(?<!\d)(\d{3}\.?\d{3}\.?\d{3}-?\d{2})(?!\d)", str(text or "")):
normalized = normalize_cpf(match.group(1))
if normalized:
return normalized
return None
def is_valid_cpf(value) -> bool: def is_valid_cpf(value) -> bool:
digits = normalize_cpf(value) digits = normalize_cpf(value)
if not digits: if not digits:
@ -69,6 +77,31 @@ def normalize_positive_number(value) -> float | None:
return None return None
def extract_budget_from_text(text: str) -> float | None:
candidate = str(text or "")
if not candidate.strip():
return None
mil_match = re.search(r"(?<!\d)(\d[\d\.\,\s]{0,12})\s*mil\b", candidate, flags=re.IGNORECASE)
if mil_match:
return normalize_positive_number(f"{mil_match.group(1)} mil")
currency_match = re.search(r"r\$\s*([\d\.\,\s]+)", candidate, flags=re.IGNORECASE)
if currency_match:
return normalize_positive_number(currency_match.group(0))
normalized = normalize_text(candidate)
keyword_match = re.search(
r"(?:ate|até|de|por|orcamento|orçamento)\s+(\d[\d\.\,\s]{1,12})(?!\d)",
normalized,
flags=re.IGNORECASE,
)
if keyword_match:
return normalize_positive_number(keyword_match.group(1))
return None
def normalize_vehicle_profile(value) -> list[str]: def normalize_vehicle_profile(value) -> list[str]:
if value is None: if value is None:
return [] return []

@ -9,6 +9,7 @@ from fastapi import HTTPException
from app.services.flows.order_flow import OrderFlowMixin from app.services.flows.order_flow import OrderFlowMixin
from app.services.flows.review_flow import ReviewFlowMixin from app.services.flows.review_flow import ReviewFlowMixin
from app.integrations.telegram_satellite_service import _ensure_supported_runtime_configuration
from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.conversation_policy import ConversationPolicy
from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.tools.handlers import _parse_data_hora_revisao from app.services.tools.handlers import _parse_data_hora_revisao
@ -202,6 +203,21 @@ class ReviewFlowHarness(ReviewFlowMixin):
class ConversationAdjustmentsTests(unittest.TestCase): class ConversationAdjustmentsTests(unittest.TestCase):
def test_telegram_satellite_requires_redis_in_production(self):
with patch("app.integrations.telegram_satellite_service.settings.environment", "production"), patch(
"app.integrations.telegram_satellite_service.settings.conversation_state_backend",
"memory",
):
with self.assertRaises(RuntimeError):
_ensure_supported_runtime_configuration()
def test_telegram_satellite_allows_redis_in_production(self):
with patch("app.integrations.telegram_satellite_service.settings.environment", "production"), patch(
"app.integrations.telegram_satellite_service.settings.conversation_state_backend",
"redis",
):
_ensure_supported_runtime_configuration()
def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self): def test_defer_flow_cancel_when_order_cancel_draft_waits_for_reason(self):
state = FakeState( state = FakeState(
entries={ entries={
@ -361,6 +377,77 @@ class CreateOrderFlowWithVehicleTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("Encontrei 2 veiculo(s):", response) self.assertIn("Encontrei 2 veiculo(s):", response)
self.assertIn("Honda Civic 2021", response) self.assertIn("Honda Civic 2021", response)
async def test_order_flow_extracts_budget_from_message_when_llm_misses_it(self):
state = FakeState(
contexts={
10: {
"generic_memory": {},
"shared_memory": {},
"last_stock_results": [],
"selected_vehicle": None,
}
}
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None):
return {"cpf": cpf, "user_id": user_id}
with patch(
"app.services.flows.order_flow.hydrate_mock_customer_from_cpf",
new=fake_hydrate_mock_customer_from_cpf,
):
response = await flow._try_collect_and_create_order(
message="Quero comprar um carro de 50 mil, meu CPF e 12345678909",
user_id=10,
extracted_fields={},
intents={},
turn_decision={"intent": "order_create", "domain": "sales", "action": "collect_order_create"},
)
self.assertIn("Encontrei 2 veiculo(s):", response)
self.assertEqual(state.get_user_context(10)["generic_memory"]["orcamento_max"], 50000)
async def test_order_flow_extracts_cpf_from_followup_message_when_llm_misses_it(self):
state = FakeState(
entries={
"pending_order_drafts": {
10: {
"payload": {},
"expires_at": datetime.utcnow() + timedelta(minutes=30),
}
}
},
contexts={
10: {
"generic_memory": {"orcamento_max": 50000},
"shared_memory": {"orcamento_max": 50000},
"last_stock_results": [],
"selected_vehicle": None,
}
},
)
registry = FakeRegistry()
flow = OrderFlowHarness(state=state, registry=registry)
async def fake_hydrate_mock_customer_from_cpf(cpf: str, user_id: int | None = None):
return {"cpf": cpf, "user_id": user_id}
with patch(
"app.services.flows.order_flow.hydrate_mock_customer_from_cpf",
new=fake_hydrate_mock_customer_from_cpf,
):
response = await flow._try_collect_and_create_order(
message="Meu CPF e 12345678909",
user_id=10,
extracted_fields={},
intents={},
)
self.assertIn("Encontrei 2 veiculo(s):", response)
self.assertEqual(registry.calls[0][0], "consultar_estoque")
async def test_order_flow_lists_stock_from_budget_when_vehicle_is_missing(self): async def test_order_flow_lists_stock_from_budget_when_vehicle_is_missing(self):
state = FakeState( state = FakeState(
entries={ entries={

Loading…
Cancel
Save