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