292 lines
8.7 KiB
Python
292 lines
8.7 KiB
Python
import json
|
|
import re
|
|
from typing import Any, Dict, Tuple
|
|
|
|
import httpx
|
|
|
|
from app.action_prompt import build_action_system_prompt, build_action_user_prompt
|
|
from app.action_catalog import TRIAGE_ACTION_CODES
|
|
from app.action_mapper import ACTION_CODE_ALIASES
|
|
from app.config import settings
|
|
from app.schemas import ActionDecision, AnalyzeRequest, UsageInfo
|
|
|
|
|
|
STRUCTURED_REVIEW_THRESHOLD = 0.80
|
|
|
|
|
|
def extract_first_json_object(raw: str) -> str:
|
|
"""Extrai o primeiro objeto JSON de uma resposta LLM."""
|
|
s = str(raw or "").strip()
|
|
|
|
if s.startswith("```"):
|
|
lines = s.splitlines()
|
|
if lines and lines[0].strip().startswith("```"):
|
|
lines = lines[1:]
|
|
if lines and lines[-1].strip().startswith("```"):
|
|
lines = lines[:-1]
|
|
s = "\n".join(lines).strip()
|
|
|
|
start = s.find("{")
|
|
if start == -1:
|
|
raise ValueError(f"no JSON object found: {s[:300]}")
|
|
|
|
in_string = False
|
|
escaped = False
|
|
depth = 0
|
|
|
|
for i in range(start, len(s)):
|
|
ch = s[i]
|
|
|
|
if escaped:
|
|
escaped = False
|
|
continue
|
|
|
|
if ch == "\\":
|
|
escaped = True
|
|
continue
|
|
|
|
if ch == '"':
|
|
in_string = not in_string
|
|
continue
|
|
|
|
if in_string:
|
|
continue
|
|
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return s[start:i + 1]
|
|
|
|
raise ValueError(f"incomplete JSON object: {s[:500]}")
|
|
|
|
|
|
def fallback_parse_decision_text(raw: str) -> Dict[str, Any]:
|
|
"""Fallback técnico: só extrai campos explícitos, não inventa decisão."""
|
|
s = str(raw or "")
|
|
|
|
action_match = re.search(
|
|
r'["\']action_code["\']\s*:\s*["\']([A-Z0-9_]+)["\']',
|
|
s,
|
|
flags=re.I,
|
|
)
|
|
confidence_match = re.search(
|
|
r'["\']confidence["\']\s*:\s*([0-9]+(?:\.[0-9]+)?)',
|
|
s,
|
|
flags=re.I,
|
|
)
|
|
|
|
if not action_match:
|
|
raise ValueError(f"could not fallback-parse action_code: {s[:500]}")
|
|
|
|
confidence = 0.0
|
|
if confidence_match:
|
|
try:
|
|
confidence = float(confidence_match.group(1))
|
|
except Exception:
|
|
confidence = 0.0
|
|
|
|
return {
|
|
"action_code": action_match.group(1).upper(),
|
|
"confidence": confidence,
|
|
"note": "",
|
|
"customer_intent": "",
|
|
"evidence": "",
|
|
"needs_human_review": confidence < STRUCTURED_REVIEW_THRESHOLD,
|
|
"history_used": False,
|
|
"payment_intent": None,
|
|
}
|
|
|
|
|
|
def extract_json(text: str) -> Dict[str, Any]:
|
|
raw = str(text or "").strip()
|
|
|
|
try:
|
|
data = json.loads(raw)
|
|
if isinstance(data, dict):
|
|
return data
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
candidate = extract_first_json_object(raw)
|
|
data = json.loads(candidate)
|
|
if isinstance(data, dict):
|
|
return data
|
|
except Exception:
|
|
pass
|
|
|
|
return fallback_parse_decision_text(raw)
|
|
|
|
|
|
def _clean_str(value: Any, limit: int = 500) -> str:
|
|
text = str(value or "").strip()
|
|
text = re.sub(r"\s+", " ", text)
|
|
if len(text) > limit:
|
|
text = text[: limit - 1].rstrip() + "…"
|
|
return text
|
|
|
|
|
|
def _as_confidence(value: Any) -> float:
|
|
try:
|
|
confidence = float(value)
|
|
except Exception:
|
|
return 0.0
|
|
if confidence < 0:
|
|
return 0.0
|
|
if confidence > 1:
|
|
return 1.0
|
|
return confidence
|
|
|
|
|
|
def _as_bool(value: Any) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
return str(value or "").strip().lower() in {"1", "true", "yes", "sim"}
|
|
|
|
|
|
def parse_action_code_response(raw: str) -> ActionDecision:
|
|
"""Parse estruturado do classificador LLM.
|
|
|
|
v4928.1.5.27: o LLM deve devolver JSON validado contra allow-list.
|
|
Se o formato/código/confiança não forem válidos, a ação é REVIEW_MANUALLY.
|
|
Não há regras comerciais diretas neste parser.
|
|
"""
|
|
text = str(raw or "").strip()
|
|
|
|
try:
|
|
data = extract_json(text)
|
|
except Exception as exc:
|
|
return ActionDecision(
|
|
action_code="REVIEW_MANUALLY",
|
|
note=f"Resposta LLM inválida/sem JSON estruturado: {str(exc)[:160]}",
|
|
confidence=0.0,
|
|
needs_human_review=True,
|
|
)
|
|
|
|
raw_code = str(data.get("action_code") or "").strip().upper()
|
|
code = ACTION_CODE_ALIASES.get(raw_code, raw_code)
|
|
confidence = _as_confidence(data.get("confidence"))
|
|
needs_human_review = _as_bool(data.get("needs_human_review"))
|
|
|
|
customer_intent = _clean_str(data.get("customer_intent"), 300)
|
|
evidence = _clean_str(data.get("evidence"), 300)
|
|
note = _clean_str(data.get("note"), 500)
|
|
history_used = _as_bool(data.get("history_used"))
|
|
payment_intent = data.get("payment_intent")
|
|
if payment_intent is not None:
|
|
payment_intent = _clean_str(payment_intent, 80) or None
|
|
|
|
if code not in TRIAGE_ACTION_CODES:
|
|
return ActionDecision(
|
|
action_code="REVIEW_MANUALLY",
|
|
note=f"Resposta LLM com action_code fora da lista: {raw_code or 'vazio'}",
|
|
confidence=0.0,
|
|
customer_intent=customer_intent,
|
|
evidence=evidence,
|
|
needs_human_review=True,
|
|
history_used=history_used,
|
|
payment_intent=payment_intent,
|
|
)
|
|
|
|
# Se o LLM sinaliza revisão ou não tem confiança suficiente, mantemos a
|
|
# proposta em metadados mas a task operacional vai para REVIEW_MANUALLY.
|
|
if needs_human_review or confidence < STRUCTURED_REVIEW_THRESHOLD:
|
|
review_note = note or customer_intent or "Classificação LLM com confiança baixa ou revisão pedida."
|
|
if code != "REVIEW_MANUALLY":
|
|
review_note = f"Sugestão LLM: {code} ({confidence:.2f}). {review_note}".strip()
|
|
return ActionDecision(
|
|
action_code="REVIEW_MANUALLY",
|
|
note=review_note,
|
|
confidence=confidence,
|
|
customer_intent=customer_intent,
|
|
evidence=evidence,
|
|
needs_human_review=True,
|
|
history_used=history_used,
|
|
payment_intent=payment_intent,
|
|
)
|
|
|
|
if not note:
|
|
note = customer_intent or evidence or "Ação classificada pelo LLM."
|
|
|
|
return ActionDecision(
|
|
action_code=code,
|
|
note=note,
|
|
confidence=confidence,
|
|
customer_intent=customer_intent,
|
|
evidence=evidence,
|
|
needs_human_review=False,
|
|
history_used=history_used,
|
|
payment_intent=payment_intent if code == "CONFIRM_PAYMENT" else None,
|
|
)
|
|
|
|
|
|
def parse_decision(data: Dict[str, Any]) -> ActionDecision:
|
|
# Mantido para compatibilidade com imports/testes antigos.
|
|
return ActionDecision(
|
|
action_code=str(data.get("action_code") or "REVIEW_MANUALLY"),
|
|
note=str(data.get("note") or "").strip(),
|
|
confidence=float(data.get("confidence") or 0.0),
|
|
customer_intent=str(data.get("customer_intent") or "").strip(),
|
|
evidence=str(data.get("evidence") or "").strip(),
|
|
needs_human_review=bool(data.get("needs_human_review") or False),
|
|
history_used=bool(data.get("history_used") or False),
|
|
payment_intent=data.get("payment_intent"),
|
|
)
|
|
|
|
|
|
async def decide_action_with_llm(request: AnalyzeRequest) -> Tuple[ActionDecision, UsageInfo, Dict[str, Any]]:
|
|
payload = {
|
|
"model": settings.openrouter_model,
|
|
"messages": [
|
|
{"role": "system", "content": build_action_system_prompt()},
|
|
{
|
|
"role": "user",
|
|
"content": build_action_user_prompt(
|
|
request.last_customer_message,
|
|
request.previous_context,
|
|
request.current_state,
|
|
),
|
|
},
|
|
],
|
|
"temperature": 0,
|
|
"max_tokens": 320,
|
|
"reasoning": {
|
|
"effort": "none",
|
|
"exclude": True,
|
|
},
|
|
}
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
async with httpx.AsyncClient(timeout=60) as client:
|
|
response = await client.post(
|
|
settings.openrouter_url,
|
|
headers=headers,
|
|
json=payload,
|
|
)
|
|
|
|
response.raise_for_status()
|
|
raw_response = response.json()
|
|
|
|
content = raw_response["choices"][0]["message"].get("content") or ""
|
|
decision = parse_action_code_response(content)
|
|
|
|
usage_data = raw_response.get("usage") or {}
|
|
|
|
usage = UsageInfo(
|
|
id=raw_response.get("id"),
|
|
model=raw_response.get("model") or settings.openrouter_model,
|
|
provider=raw_response.get("provider"),
|
|
prompt_tokens=usage_data.get("prompt_tokens") or 0,
|
|
completion_tokens=usage_data.get("completion_tokens") or 0,
|
|
total_tokens=usage_data.get("total_tokens") or 0,
|
|
cost=usage_data.get("cost") or 0.0,
|
|
)
|
|
|
|
return decision, usage, raw_response
|