"""Costruzione dei payload di debug/giudice a partire dalle tracce dei cluster. Funzioni pure (nessun I/O, nessun rilancio) condivise dai vari path di `AnalysisService`: sia "calcola e salva" sia "leggi dalla cache" usano queste, così la rappresentazione del debug non può divergere tra i due. Tenute fuori dal servizio per mantenerlo focalizzato sull'orchestrazione. """ from __future__ import annotations from collections.abc import Sequence from dataclasses import asdict from config.catalog.severity import SEVERITY_ORDER from core.contracts import Severity from core.output.trace import ClusterTrace # Limiti oltre i quali uno span non è più una "parola/entità" ma uno spezzone # di frase (chunk): certi layer ML (NER/baseline con aggregation) fondono token # adiacenti in span lunghi. Nel confronto mostriamo solo i termini discreti. _MAX_TERM_CHARS = 60 _MAX_TERM_WORDS = 8 def _is_term(text: str) -> bool: """True se `text` è un termine discreto (entità/parola), non un chunk.""" return 0 < len(text) <= _MAX_TERM_CHARS and len(text.split()) <= _MAX_TERM_WORDS def _parse_severities(values: list[str] | None) -> set[Severity] | None: if values is None: return None valid = {s.value for s in SEVERITY_ORDER} return {Severity(v) for v in values if v in valid} def _llm_status(verify: bool, has_verifier: bool, simulate: bool = False) -> str: """Stato del giudice per il riepilogo debug: off (non richiesto), unavailable (richiesto ma non costruito), simulated (prompt costruiti ma LLM non valutato), active (agganciato ed eseguito).""" if not verify: return "off" if not has_verifier: return "unavailable" return "simulated" if simulate else "active" def build_debug_payload( traces: Sequence[ClusterTrace], threshold: float, method: str, llm_status: str, ) -> dict: """Costruisce la risposta di debug dalle tracce (pura: nessun rilancio). Unica fonte del dizionario di debug: la usano sia il path "calcola e salva" sia il path "leggi dalla cache", così non possono divergere. """ clusters = [{**asdict(t), "discarded_reason": t.discarded_reason} for t in traces] kept = sum(1 for t in traces if t.kept) below = sum(1 for t in traces if not t.passed_threshold) overlap = sum(1 for t in traces if t.passed_threshold and not t.kept) llm_rejected = sum( 1 for t in traces if t.llm_vote is not None and t.llm_vote < 0 ) llm_scored = sum(1 for t in traces if t.llm_vote is not None) # Prompt costruiti (anche senza valutazione): in simulazione sono tutti i # prompt mostrati, con LLM reale coincidono con le frasi inviate al giudice. llm_prompts = sum(1 for t in traces if t.llm_prompt is not None) return { "method": method, "threshold": round(threshold, 4), "clusters": clusters, "summary": { "total_clusters": len(traces), "kept": kept, "below_threshold": below, "dropped_overlap": overlap, "llm_active": llm_status == "active", "llm_simulated": llm_status == "simulated", "llm_status": llm_status, "llm_scored": llm_scored, "llm_prompts": llm_prompts, "llm_rejected": llm_rejected, }, } def build_llm_check( traces: Sequence[ClusterTrace], llm_status: str, llm_error: str | None = None ) -> dict: """Stato sintetico del giudice per l'avviso nel frontend (puro). `scored`/`total`: frasi valutate sul totale dei cluster. `obscured_without_score`: frasi tenute (oscurate) ma senza voto del giudice — il modello non ha restituito un punteggio per quelle frasi. `error`: messaggio se il modello LLM ha FALLITO (es. non si è caricato): il FE lo mostra come errore, distinto dal caso "ha risposto ma senza punteggi utili". """ scored = sum(1 for t in traces if t.llm_vote is not None) obscured_without_score = sum( 1 for t in traces if t.kept and t.llm_vote is None ) return { "status": llm_status, "scored": scored, "total": len(traces), "obscured_without_score": obscured_without_score, "error": llm_error, }