Spaces:
Running
Running
File size: 14,819 Bytes
28a08e7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 | """
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."
)
|