@ :~/lab/grafo-fermo-a-030-confidence$
← cd ~/lab

./run grafo-fermo-a-030-confidence

2194 affermazioni, tutte ferme a 0.30: insegnare a un grafo cosa conta

Ho costruito una pipeline multi-agente che monitora YouTube, Discord e RSS e sputa un digest italiano su WhatsApp. Funzionava tutto tranne la cosa che contava: il grafo non imparava mai cosa fosse importante.

status
draft
project
knowledge-digest-agent
updated
2026-07-07
tags
#langgraph#rag#vector-database#qdrant#multi-agent#embeddings
generate cover # alt: Diagramma di una pipeline multi-agente che estrae affermazioni atomiche da video e feed, le confronta in un vector database e produce un digest ordinato per rilevanza

Aprii il pannello KB una mattina e il grafo era una prateria piatta. 2194 affermazioni ingerite da quattro canali YouTube e due feed RSS, tutte con lo stesso identico numero accanto: confidence 0.30, stato staging. Nessun nodo promosso a verified. Mai. Il sistema aggregava contenuti come un raccoglitore diligente, ma non aveva la più pallida idea di cosa fosse notizia e cosa fosse rumore. Questo diario parla di quel numero fermo, e di cosa ci vuole per farlo muovere.

L’idea

L’obiettivo è banale da spiegare e brutale da realizzare: sostituire il lavoro manuale di seguire decine di creator. Un agente che monitora fonti (YouTube, Discord, RSS/Google Alerts), estrae claim atomici, li deduplica dentro una knowledge base vettoriale viva, e ogni mattina spinge su WhatsApp le 3 notizie che contano davvero — con il link alla fonte originale. Non un summarizer: un grafo che cresce, dove la stessa notizia da tre fonti diventa una voce consolidata, non tre duplicati.

Architettura: cinque nodi in fila

La spina è una pipeline LangGraph (StateGraph) che fa scorrere uno stato tipizzato attraverso cinque tappe:

extract_claims → triage_claims → kb_operations → validate_claims → END
  • Extract (gpt-4o): spezza un transcript in claim atomici tipizzati — factual, opinion, prediction, metric. Scarta saluti e filler.
  • Triage: per ogni claim fa embedding (text-embedding-3-large), cerca i top-5 vicini in Qdrant, e decide NOVEL / CONFIRM / CONTRADICT.
  • KB operations: crea un nodo nuovo, oppure arricchisce un nodo esistente (bump di confidence), oppure segnala una contraddizione. Prima passa dal dedup: hash SHA-256 esatto + similarità semantica ≥ 0.95.
  • Validate: cross-reference esterno via web search, solo per claim novel o contraddittori.
  • Synthesis + Distribution: clustering per topico, un LLM italiano che scrive le voci, poi WhatsApp via Evolution API.

L’orchestrazione condiziona i salti con vere edge function — se l’estrazione non produce claim, si va dritti a END; si valida solo se c’è almeno un NOVEL o CONTRADICT. Tutto asincrono, con Celery/Redis a monte e Postgres come source of truth per il dedup.

La spina: perché niente saliva mai da 0.30

Il bug non era un crash. Era peggio: tutto verde, tutto funzionante, risultato inutile. Ho tirato fuori la calcolatrice e ho ricostruito la catena.

Un nodo nasce a KB_INITIAL_CONFIDENCE = 0.3. Sale solo quando viene confermato, e il boost decresce a ogni conferma:

def calculate_confirmation_boost(self, current_confidence, source_count):
    base_boost = 0.15               # confirmation_boost_initial
    boost = base_boost * (0.7 ** max(0, source_count - 1))
    return min(boost, 1.0 - current_confidence)

Con la soglia di promozione a 0.7, per passare da staging a verified servono ~5 conferme indipendenti: 0.30 → 0.45 → 0.55 → 0.63 → 0.68 → 0.72. Fattibile. Tranne che le conferme erano zero. confirmations_count restava incollato a 0 su tutti i 2194 nodi.

Perché? Il triage decideva così:

if top_score >= self._confirm_threshold:      # 0.85
    return CONFIRM
if top_score >= self._contradict_threshold:    # 0.70 → LLM alignment check
    ...
return NOVEL

TRIAGE_CONFIRM_THRESHOLD = 0.85 è troppo alto per text-embedding-3-large. Due frasi che dicono la stessa cosa con parole diverse — “Nvidia ha registrato 58,3 miliardi di ricavi” vs “il fatturato del trimestre di Nvidia tocca i 58B” — atterrano tipicamente a 0.6–0.8 di coseno, non 0.85+. Sopra 0.95 scatta il dedup e il claim viene buttato come duplicato esatto. Il risultato è una fascia di conferma stretta e quasi vuota: quasi tutto cadeva sotto 0.85 → classificato NOVEL → nuovo nodo a 0.30 → prateria piatta. Il grafo non cresceva in profondità, si allargava soltanto.

C’era anche un fossile: nel percorso CONFIRM il triage logga ancora confidence_bump_deferred_to_story_3_5, con tanto di commento # Placeholder: Story 3.5 will implement.... In realtà il bump vero lo fa già enrich_node a valle — quel log è una briciola morta di quando la logica era spezzata. Drift README/realtà da manuale: mi ha fatto perdere mezz’ora a cercare un bug dove non c’era.

Insegnare cosa vale una conferma

Abbassare la soglia a 0.72 (allineata al cluster threshold) sblocca il flusso. Ma qui c’è la lezione controintuitiva del progetto: non tutte le conferme valgono uguale. Se due “fonti” che confermano la stessa notizia sono lo stesso autore, o citano lo stesso identico URL, non è convergenza — è eco. Contarle come due voti indipendenti gonfia la confidence con aria fritta.

Così il boost passa attraverso un analizzatore di diversità delle fonti, tutto euristico (niente LLM, per costo):

same_author_factor    = 0.1   # stesso autore → l'eco vale un decimo
shared_origin_factor   = 0.3   # citano lo stesso URL
derivative_factor      = 0.2   # testi troppo simili (coseno ≥ 0.85)
# indipendenti → 1.0

effective_bump = base_delta * diversity_score

Due creator diversi che arrivano alla stessa conclusione da percorsi diversi valgono un bump pieno. Lo stesso tizio che ripete la notizia su tre canali vale un decimo. È qui che il grafo comincia a capire qualcosa: distingue tre megafoni puntati sullo stesso comunicato stampa da tre teste che convergono davvero.

Gotcha onesti

  • Tre soglie sulla stessa metrica. Dedup 0.95, confirm 0.85, cluster 0.62 — tutte coseno sullo stesso spazio di embedding. Ogni volta che ne tocco una devo ricontrollare che non litighino. La fascia “utile” per una conferma è letteralmente 0.72–0.95, e capire quanto sia popolata richiede di misurare la distribuzione reale sui 2194 claim, non di indovinare.
  • L’importance score è scritto, non costruito. Il piano di refocus specifica una formula pesata — w1·confirmations + w2·source_authority + w3·recency + w4·cluster_size — con tanto di migration ALTER TABLE kb_nodes ADD COLUMN importance_score. Ma un grep in src/ e migrations/ non trova la parola importance da nessuna parte. La colonna non esiste ancora. Per ora il digest ordina i cluster per numerosità (clusters.sort(key=len, reverse=True)), un proxy povero: “più fonti = più importante”. Onestamente: la vera nozione di rilevanza è ancora sulla carta.
  • La sintesi spreca. Il builder genera target * 3 = 45 chiamate LLM per produrre 15 voci filtrate — ~0.20–0.30 $/giorno buttati per generare candidati poi scartati. Il fix (saltare i cluster da un nodo solo con claim < 60 caratteri) è previsto ma non ancora attivo.
  • La distribuzione WhatsApp è configurata ma silente. Il codice del publisher c’è, Evolution API è collegata. Non ho ancora premuto il grilletto: preferisco sistemare segnale/rumore prima di spammare notifiche.

Sul self-bot Discord

Una delle fonti è un connettore Discord che legge messaggi da server privati e li inoltra al webhook interno della piattaforma. È deliberatamente read-only — nessun invio, nessuna reaction, nessun cambio di presenza — con rate limiting randomizzato (30–90 s) e backoff di riconnessione. Va detto chiaro: automatizzare un account utente Discord è una zona grigia rispetto ai ToS, e lo tratto come tale. Lo uso solo su server di cui faccio già parte, il processo è stateless e conservativo per non somigliare a un raschiatore. Token e URL del webhook stanno solo in .env, mai nel repo. Non è una feature di cui vantarsi: è un compromesso di cui essere consapevoli.

Come va

POC solido, non ancora in produzione seria. La pipeline gira end-to-end: ingestione (4 YouTube instradati via proxy, 2 RSS), estrazione, KB su Qdrant, triage, clustering, sintesi in italiano. La parte “grafo che impara” è per metà sbloccata: la soglia di conferma abbassata e l’analisi eco/convergenza sono in codice; l’importance score e la distribuzione selettiva sono i prossimi pezzi. Definizione di “fatto”: ricevo 3 notizie/giorno ordinate per rilevanza, con link, e posso cercare in KB “cosa ho letto su X”. Manca ancora qualche casella.

Cosa ho imparato

Che il pezzo difficile di un knowledge graph non è ingerire, è giudicare. Estrarre claim è un problema risolto; decidere che questo claim conta più di quello, e che questa conferma è vera convergenza e non un’eco, è il 90% del valore e il 90% della fatica. Un sistema che dice “tutto è nuovo, tutto vale 0.30” è tecnicamente perfetto e praticamente muto. La confidence non è un numero che sale da solo: è un modello di cosa consideri prova. Finché non gliel’ho scritto — soglie tarate sulla distribuzione reale degli embedding, non sull’ottimismo — il grafo è rimasto una prateria piatta, verde e inutile.