Release v4928.1.4.2 stable
This commit is contained in:
243
app/action_llm_client.py
Normal file
243
app/action_llm_client.py
Normal file
@@ -0,0 +1,243 @@
|
||||
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
|
||||
Reference in New Issue
Block a user