""" response_verifier.py — Response Quality Verifier + Repair Pass Verifica e ripara l'output LLM prima che raggiunga l'utente: 1. JSON repair — estrae e corregge JSON corrotto (trailing comma, unquoted keys, ecc.) 2. Markdown sanitization — chiude code fence aperte, corregge heading malformati 3. Coherence check — rileva risposte vuote, description-leak dei tool, risposte fuori tema 4. Retry signal — se qualità < soglia, suggerisce retry con hint correttivo Dipendenze: zero (solo stdlib). """ from __future__ import annotations import json import re from dataclasses import dataclass, field from typing import Any import logging _logger = logging.getLogger("agents.response_verifier") # ── Soglie ──────────────────────────────────────────────────────────────────── QUALITY_RETRY_THRESHOLD = 0.35 # sotto questa soglia → retry QUALITY_WARN_THRESHOLD = 0.55 # sotto questa → repairs loggati ma ok # ── Patterns ────────────────────────────────────────────────────────────────── # Frasi che indicano che il modello sta descrivendo tool invece di usarli _TOOL_DESCRIPTION_PATTERNS = [ r"puoi (usare|utilizzare|eseguire)\s+(il\s+)?tool", r"esegui\s+il\s+comando", r"usa\s+il\s+tool\s+\w+", r"assicurati di (aver )?installato il tool", r"per utilizzare il tool", r"il tool ti fornirà", r"```(bash|sh)\s*\n\s*get_weather", r"```(bash|sh)\s*\n\s*web_search", r"```(bash|sh)\s*\n\s*calculate", ] # Frasi di resa senza contenuto utile _EMPTY_RESPONSE_PATTERNS = [ r"^non ho informazioni", r"^non (posso|riesco) (fornire|darti|aiutarti)", r"^mi dispiace,?\s+non", r"^purtroppo non", r"^come AI non", ] _COMPILED_TOOL_PATTERNS = [re.compile(p, re.IGNORECASE) for p in _TOOL_DESCRIPTION_PATTERNS] _COMPILED_EMPTY_PATTERNS = [re.compile(p, re.IGNORECASE) for p in _EMPTY_RESPONSE_PATTERNS] # ── Result ──────────────────────────────────────────────────────────────────── @dataclass class VerifyResult: output: str repairs: list[str] = field(default_factory=list) quality: float = 1.0 retry_suggested: bool = False retry_hint: str = "" # ── JSON Repair ─────────────────────────────────────────────────────────────── def repair_json(text: str) -> tuple[str, list[str]]: """ Tenta di estrarre e riparare JSON dall'output LLM. Restituisce (json_str_riparato_o_originale, lista_riparazioni). """ repairs: list[str] = [] # 1. Estrai blocco JSON (con o senza ```json ... ```) fenced = re.search(r"```(?:json)?\s*(\{[\s\S]+?\})\s*```", text) raw = fenced.group(1) if fenced else None if not raw: # P16-B3: depth-counting bilanciato — evita estrazione errata su JSON annidati def _depth_extract(s: str) -> str | None: depth = 0; start = -1 for i, ch in enumerate(s): if ch == '{': if depth == 0: start = i depth += 1 elif ch == '}': depth -= 1 if depth == 0 and start != -1: return s[start:i + 1] return None raw = _depth_extract(text) if not raw: return text, repairs # 2. Prova parse diretto try: json.loads(raw) return raw, repairs except json.JSONDecodeError as _exc: _logger.debug("[response_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001 fixed = raw # 3. Rimuovi trailing comma prima di } o ] fixed, n = re.subn(r",\s*([}\]])", r"\1", fixed) if n: repairs.append(f"Rimossi {n} trailing comma nel JSON") # 4. Aggiungi virgolette a chiavi non quotate fixed, n = re.subn(r'(?<=[{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r' "\1":', fixed) if n: repairs.append(f"Quotate {n} chiavi JSON non quotate") # 5. Sostituisci apici singoli con doppi (solo nelle stringhe) if "'" in fixed and '"' not in fixed: fixed = fixed.replace("'", '"') repairs.append("Convertiti apici singoli → doppi nel JSON") # 6. Prova di nuovo try: json.loads(fixed) repairs.append("JSON riparato con successo") return fixed, repairs except json.JSONDecodeError as _exc: _logger.debug("[response_verifier] silenced %s", type(_exc).__name__) # noqa: BLE001 # 7. Non riparabile — restituisci originale return text, repairs # ── Markdown Sanitization ───────────────────────────────────────────────────── def sanitize_markdown(text: str) -> tuple[str, list[str]]: """ Chiude code fence aperte e corregge markdown malformato. """ repairs: list[str] = [] lines = text.split("\n") # 1. Conta code fence aperte fence_count = sum(1 for l in lines if re.match(r"^```", l)) if fence_count % 2 != 0: text = text + "\n```" repairs.append("Chiusa code fence aperta") # 2. Correggi heading senza spazio (es. "##Titolo" → "## Titolo") fixed, n = re.subn(r"^(#{1,6})([^#\s])", r"\1 \2", text, flags=re.MULTILINE) if n: text = fixed repairs.append(f"Corretti {n} heading Markdown malformati") # 3. Rimuovi backtick tripli isolati su riga vuota alla fine text = re.sub(r"\n```\s*$", "\n```", text) return text, repairs # ── Coherence Check ─────────────────────────────────────────────────────────── def check_coherence(goal: str, response: str) -> tuple[float, list[str], str]: """ Verifica la coerenza della risposta rispetto al goal. Ritorna (quality_score 0-1, issues[], retry_hint). """ issues: list[str] = [] score = 1.0 hint = "" stripped = response.strip() # 1. Risposta vuota if not stripped or len(stripped) < 20: issues.append("Risposta troppo breve o vuota") return 0.0, issues, "Rispondi in modo completo e diretto. Non restituire testo vuoto." # 2. Tool description leak — l'agente descrive tool invece di usarli for pat in _COMPILED_TOOL_PATTERNS: if pat.search(stripped): issues.append("L'agente sta descrivendo tool invece di usarli") score -= 0.5 hint = ( "NON descrivere come usare tool o comandi. " "I dati devono essere già stati recuperati. " "Rispondi direttamente con le informazioni richieste." ) break # 3. Resa senza contenuto for pat in _COMPILED_EMPTY_PATTERNS: if pat.match(stripped): issues.append("Risposta di resa senza contenuto utile") score -= 0.4 if not hint: # S577: 100→200 — più contesto nell'hint di repair # S589: goal 200→300 — hint repair più dettagliato # S597: 300→500 — goal lunghi tagliati hint = f"Fornisci una risposta utile e completa all'obiettivo: {goal[:500]}" break # 4. Risposta in lingua sbagliata (controllo leggero) italian_markers = ["è", "sono", "non", "per", "con", "che", "della", "una", "questo"] english_markers = ["the", "is", "are", "for", "with", "that", "this", "have"] italian_score = sum(1 for w in italian_markers if f" {w} " in stripped.lower()) english_score = sum(1 for w in english_markers if f" {w} " in stripped.lower()) if english_score > italian_score + 3: issues.append("Risposta in inglese invece di italiano") score -= 0.2 if not hint: hint = "Rispondi SEMPRE in italiano." # 5. Risposta troppo corta per il tipo di richiesta is_complex = any(k in goal.lower() for k in ["spiega", "analizza", "descrivi", "come funziona"]) if is_complex and len(stripped) < 100: issues.append("Risposta troppo breve per una richiesta complessa") score -= 0.2 if not hint: hint = "Fornisci una risposta più dettagliata e completa." # 6. HTML/JS structural issues — detect broken markup in code blocks if "```html" in stripped.lower(): html_issues = _check_html_structure(stripped) if html_issues: issues.extend(html_issues) score -= 0.15 if not hint: hint = f"Il codice HTML ha problemi strutturali: {'; '.join(html_issues[:2])}. Correggi la struttura." # 7. JS unbalanced braces in code blocks if "```javascript" in stripped.lower() or "```js" in stripped.lower(): js_issues = _check_js_structure(stripped) if js_issues: issues.extend(js_issues) score -= 0.10 if not hint: hint = f"Il codice JavaScript ha problemi strutturali: {'; '.join(js_issues[:2])}." # 8. Mancanza executive summary per risposte lunghe (GAP-UX-1 — regola 20) # Penalità leggera: incoraggia formato **[EMOJI] Esito** all'inizio if len(stripped) > 200: import re as _re2 first_line = stripped.split("\n")[0].strip() has_bold_summary = bool(_re2.match(r'^\*\*[^*].{2,}\*\*', first_line)) if not has_bold_summary: issues.append("Risposta senza executive summary in grassetto (regola 20 — GAP-UX-1)") score -= 0.10 if not hint: hint = ( "Inizia la risposta con **[EMOJI] [Esito conciso max 8 parole]** " "come da regola 20. Es: **✅ Completato** — spiegazione breve." ) return max(0.0, score), issues, hint # ── HTML/JS Structure Checks (S401) ────────────────────────────────────────── def _check_html_structure(text: str) -> list[str]: """Rileva problemi strutturali in blocchi HTML.""" issues: list[str] = [] import re # Estrai blocchi HTML blocks = re.findall(r"```html\s*(.*?)```", text, re.DOTALL | re.IGNORECASE) for block in blocks[:1]: # Tag non bilanciati (esclusi void elements) void_tags = {"area","base","br","col","embed","hr","img","input", "link","meta","param","source","track","wbr"} open_tags = re.findall(r"<([a-zA-Z][a-zA-Z0-9]*)[^>/]*>", block) close_tags = re.findall(r"", block) open_count: dict[str, int] = {} for t in open_tags: tl = t.lower() if tl not in void_tags: open_count[tl] = open_count.get(tl, 0) + 1 for t in close_tags: tl = t.lower() open_count[tl] = open_count.get(tl, 0) - 1 unbalanced = [t for t, c in open_count.items() if c != 0] if unbalanced: # S597: unbalanced[:4]→[:6] — mostra più tag sbilanciati nel report issues.append(f"Tag non bilanciati: {', '.join(unbalanced[:6])}") # Script/style non chiusi if block.count(""): issues.append("Tag