Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View File

@@ -11,11 +11,11 @@ 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.
Suporta markdown, texto antes/depois e quebras de linha.
"""
"""Extrai o primeiro objeto JSON de uma resposta LLM."""
s = str(raw or "").strip()
if s.startswith("```"):
@@ -63,11 +63,7 @@ def extract_first_json_object(raw: str) -> str:
def fallback_parse_decision_text(raw: str) -> Dict[str, Any]:
"""
Fallback para respostas quase-JSON.
Ex.: note com aspas internas não escapadas.
Só extrai campos explícitos; não inventa decisão.
"""
"""Fallback técnico: só extrai campos explícitos, não inventa decisão."""
s = str(raw or "")
action_match = re.search(
@@ -75,39 +71,31 @@ def fallback_parse_decision_text(raw: str) -> Dict[str, Any]:
s,
flags=re.I,
)
confidence_match = re.search(
r'["\']confidence["\']\s*:\s*([0-9]+(?:\.[0-9]+)?)',
s,
flags=re.I,
)
note_match = re.search(
r'["\']note["\']\s*:\s*["\'](.+?)["\']\s*(?:,|\n\s*["\']confidence|})',
s,
flags=re.I | re.S,
)
if not action_match:
raise ValueError(f"could not fallback-parse action_code: {s[:500]}")
confidence = 0.75
confidence = 0.0
if confidence_match:
try:
confidence = float(confidence_match.group(1))
except Exception:
confidence = 0.75
note = ""
if note_match:
note = note_match.group(1).strip()
note = note.replace('\\"', '"')
note = re.sub(r"\s+", " ", note)
confidence = 0.0
return {
"action_code": action_match.group(1).upper(),
"note": note,
"confidence": confidence,
"note": "",
"customer_intent": "",
"evidence": "",
"needs_human_review": confidence < STRUCTURED_REVIEW_THRESHOLD,
"history_used": False,
"payment_intent": None,
}
@@ -115,67 +103,122 @@ def extract_json(text: str) -> Dict[str, Any]:
raw = str(text or "").strip()
try:
return json.loads(raw)
data = json.loads(raw)
if isinstance(data, dict):
return data
except Exception:
pass
try:
candidate = extract_first_json_object(raw)
return json.loads(candidate)
except Exception:
return fallback_parse_decision_text(raw)
def clean_llm_text(raw: str) -> str:
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()
return s.strip().strip('"').strip("'").strip()
def parse_action_code_response(raw: str) -> ActionDecision:
"""Parse da resposta LLM-only.
O formato esperado é apenas o action_code, mas aceitamos JSON antigo
{"action_code": "..."} para compatibilidade durante transição.
"""
text = clean_llm_text(raw)
# Caminho principal: resposta é só o código.
candidate = re.sub(r"[^A-Za-z0-9_].*$", "", text).strip().upper()
candidate = ACTION_CODE_ALIASES.get(candidate, candidate)
if candidate in TRIAGE_ACTION_CODES:
return ActionDecision(action_code=candidate, note="", confidence=0.85)
# Compatibilidade com respostas JSON antigas.
try:
data = extract_json(text)
raw_code = str(data.get("action_code") or "").strip().upper()
code = ACTION_CODE_ALIASES.get(raw_code, raw_code)
if code in TRIAGE_ACTION_CODES:
return ActionDecision(
action_code=code,
note=str(data.get("note") or "").strip(),
confidence=float(data.get("confidence") or 0.85),
)
data = json.loads(candidate)
if isinstance(data, dict):
return data
except Exception:
pass
# Tenta encontrar um código permitido algures no texto, mas sem inventar.
upper_text = text.upper()
for code in sorted(TRIAGE_ACTION_CODES, key=len, reverse=True):
if re.search(rf"\b{re.escape(code)}\b", upper_text):
return ActionDecision(action_code=code, note="", confidence=0.75)
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="REVIEW_MANUALLY",
note=f"Resposta LLM inválida para action_code: {text[:120]}",
confidence=0.0,
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,
)
@@ -185,6 +228,11 @@ def parse_decision(data: Dict[str, Any]) -> 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"),
)
@@ -203,7 +251,7 @@ async def decide_action_with_llm(request: AnalyzeRequest) -> Tuple[ActionDecisio
},
],
"temperature": 0,
"max_tokens": 20,
"max_tokens": 320,
"reasoning": {
"effort": "none",
"exclude": True,