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 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. """ 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 para respostas quase-JSON. Ex.: note com aspas internas não escapadas. 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, ) 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 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) return { "action_code": action_match.group(1).upper(), "note": note, "confidence": confidence, } def extract_json(text: str) -> Dict[str, Any]: raw = str(text or "").strip() try: return json.loads(raw) 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), ) 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 ActionDecision( action_code="REVIEW_MANUALLY", note=f"Resposta LLM inválida para action_code: {text[:120]}", confidence=0.0, ) 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), ) 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": 20, "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