Spaces:
Running
Running
| """ | |
| 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 ──────────────────────────────────────────────────────────────────── | |
| 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"</([a-zA-Z][a-zA-Z0-9]*)>", 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("<script") != block.count("</script>"): | |
| issues.append("Tag <script> non bilanciato") | |
| if block.count("<style") != block.count("</style>"): | |
| issues.append("Tag <style> non bilanciato") | |
| return issues | |
| def _check_js_structure(text: str) -> list[str]: | |
| """Rileva problemi strutturali in blocchi JavaScript.""" | |
| issues: list[str] = [] | |
| import re | |
| blocks = re.findall(r"```(?:javascript|js)\s*(.*?)```", text, re.DOTALL | re.IGNORECASE) | |
| for block in blocks[:1]: | |
| # Parentesi graffe sbilanciate (escluse stringhe e commenti — approssimazione) | |
| stripped = re.sub(r"//[^\n]*", "", block) # rimuovi commenti riga | |
| stripped = re.sub(r"/\*.*?\*/", "", stripped, flags=re.DOTALL) # commenti blocco | |
| stripped = re.sub(r'"[^"\\]*(?:\\.[^"\\]*)*"', '""', stripped) # stringhe | |
| stripped = re.sub(r"'[^'\\]*(?:\\.[^'\\]*)*'", "''", stripped) | |
| braces = stripped.count("{") - stripped.count("}") | |
| parens = stripped.count("(") - stripped.count(")") | |
| if abs(braces) > 0: | |
| issues.append(f"Parentesi graffe sbilanciate ({braces:+d})") | |
| if abs(parens) > 0: | |
| issues.append(f"Parentesi tonde sbilanciate ({parens:+d})") | |
| return issues | |
| # ── Main Verifier Class ─────────────────────────────────────────────────────── | |
| class ResponseVerifier: | |
| """ | |
| Verifica e ripara l'output LLM. | |
| Usato da UnifiedAgentLoop dopo ogni risposta LLM. | |
| """ | |
| def verify_and_repair(self, goal: str, output: str) -> VerifyResult: | |
| """ | |
| Esegue tutti i repair pass e restituisce VerifyResult. | |
| Non-blocking, non richiede LLM. | |
| """ | |
| current = output | |
| all_repairs: list[str] = [] | |
| # 1. Markdown sanitization | |
| current, md_repairs = sanitize_markdown(current) | |
| all_repairs.extend(md_repairs) | |
| # 2. JSON repair (solo se sembra JSON) | |
| if "{" in current and "}" in current: | |
| current, json_repairs = repair_json(current) | |
| all_repairs.extend(json_repairs) | |
| # 3. Coherence check | |
| quality, issues, retry_hint = check_coherence(goal, current) | |
| all_repairs.extend(issues) | |
| retry_suggested = quality < QUALITY_RETRY_THRESHOLD | |
| return VerifyResult( | |
| output=current, | |
| repairs=all_repairs, | |
| quality=quality, | |
| retry_suggested=retry_suggested, | |
| retry_hint=retry_hint, | |
| ) | |
| def build_retry_prompt(self, goal: str, bad_output: str, hint: str) -> str: | |
| """Costruisce prompt migliorato per il retry.""" | |
| return ( | |
| f"La risposta precedente non era soddisfacente.\n" | |
| f"PROBLEMA: {hint}\n\n" | |
| # S592: bad_output 300→500 — più contesto della risposta precedente per retry | |
| f"Risposta precedente (non usare):\n{bad_output[:500]}...\n\n" | |
| f"Obiettivo originale: {goal}\n\n" | |
| f"Ora rispondi correttamente, in italiano, in modo diretto e completo." | |
| ) | |