Terminal / agents /response_verifier.py
Pulka
sync: 3 changed, 0 deleted — b5806eef (2026-06-20 17:44)
0d1f073 verified
Raw
History Blame Contribute Delete
14.8 kB
"""
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"</([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."
)