Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user