Files
clientflow_backend/app/action_llm_client.py

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