244 lines
7.0 KiB
Python
244 lines
7.0 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
|
|
|
|
|
|
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
|