andreapellizzari.it
Indice diario
1167 parole · 6 min

Arcocat, due settimane di hardening: domain contracts, knowledge graph, catalog RAG

Diario in presa diretta delle ultime due settimane su arcocat. Quattro ondate di lavoro che spostano peso dal prompt al runtime: guardrail deterministici, discovery enforcement, routing per brand, streaming, knowledge graph indicizzato, catalog RAG, e otto policy YAML come fonte unica.

#ai#claude#agente#prompt-caching#knowledge-graph#rag#runtime-contracts

Le ultime due settimane su arcocat (il sistema CPQ multi-brand su cinque cataloghi reali che e' il successore di BlumCat) sono state inusuali: niente feature nuove visibili in chat, molto lavoro di hardening sotto la linea di galleggiamento. A posteriori si vede un filo conduttore: la riga di codice che dice "il modello DEVE fare X prima di Y" e' migrata, una alla volta, dal prompt al runtime.

Provo a raccontarla per ondate.

Ondata 1: strumenti per non rompere niente

Per primo ho costruito quello che mi era mancato fino a quel momento: una sicurezza per cambiare cose senza paura. Hot-reload dei metadati di categoria con TTL 5 secondi (basta toccare un file YAML, niente restart del server). Trace persistente in JSONL per ogni turn di conversazione (debug post-mortem leggibile a freddo). Ventisette golden queries pytest end-to-end su tre brand, con assertion non solo sul "tool e' stato chiamato" ma sull'effettivo match degli attributi sui prodotti ritornati. Un health-check L15 wiki vs PIM che mi avrebbe catturato il bug "Lorenzo non e' a Monza" delle settimane precedenti.

Modello di default cambiato da Sonnet a Haiku 4.5: ventuno golden passano in tre minuti e venti contro i tre e cinquanta di Sonnet, costo per query circa quattro volte piu' basso, regressione zero.

Cost middleware leggero che logga in cost.jsonl per ogni turn, con alert console se la sessione supera mezzo dollaro. Niente di sofisticato; funziona.

Ondata 2: determinismo dove la verita' e' finita

Il pattern di bug che mi tormentava era questo: query "forni pirolitici", il modello sceglie autonomamente l'attributo pirolitico: true (che non esiste nel PIM, e' autopulizia: pirolitica), il database ritorna ventuno prodotti ma di questi solo undici sono effettivamente pirolitici. Silent wrong.

Soluzione strutturale: spostare l'enforcement fuori dal prompt e dentro a un validator runtime. Un classificatore deterministico _classify_filter_key(cat, key, val) con quattro stati (native, known_json, unknown_attribute, unknown_value), un pre-dispatch hook che blocca i filtri JSON su attributi non verificati e ritorna un payload UNVERIFIED_ATTRIBUTE con un sample dei valori realmente disponibili. Il modello riceve un errore strutturato, chiama list_distinct_values, ritenta. Sui ventisette golden: unknown_attribute crolla da 10% a 0%, unknown_value da 14% a 7%, e nel 19% dei tentativi bloccati l'auto-recovery funziona senza intervento umano.

Sulla stessa scia, ho costruito un piccolo motore di guardrail con quattro regole YAML attive (distanza cappa piano cottura per la norma EN 60335, peso anta vs meccanismo di sollevamento, numero cerniere per spessore anta, dimensioni vano incasso EU). AST visitor manuale per la sandbox aritmetica, senza eval. Se la regola finisce in BLOCK, re-prompt al modello con il verdict strutturato, max un retry. Il tool LLM-callable check_safety che avevo in precedenza l'ho deprecato: il validator runtime e' deterministico, citabile e testabile, l'LLM li non aggiungeva valore.

Ondata 3: routing e streaming

Il prompt era diventato troppo lungo: diciotto kilochar, con istruzioni branch-specifiche per cinque brand caricate sempre. Ho aggiunto un classificatore regex deterministico in brand_router.py che gira come step zero di ogni conversazione: se la query e' chiaramente brand-specifica, espongo al modello solo i 7-9 tool relativi a quel brand invece dei 16 totali. Prompt char crolla da 18247 a 7916 (meno cinquantasette percento), avg token in per turn da 17000 a 9214 (meno quarantasei percento), latency dei golden cala del tredici-diciotto percento. Tool expand_brand_scope come escape hatch se il modello scopre durante la conversazione che la query e' in realta' cross-brand.

In parallelo, streaming end-to-end via OpenRouter (passthrough trasparente del cache_control Anthropic). TTFT da otto secondi a un secondo e mezzo percepiti, cache HIT confermato in JSONL con 96% di reduction sul prefisso system + tools di 9582 token. Costo prefisso da circa novantasei millesimi di dollaro a circa otto millesimi per call.

rendering diagramma…

Telemetry hardening, sopra a tutto: log di model_observed e cache_creation/read_input_tokens per ogni risposta. La scoperta del log non l'avevo prevista: chiediamo a OpenRouter claude-haiku-4-5, OpenRouter ci routa a anthropic/claude-4.5-haiku-20251001. Behavioral drift invisibile senza la riga di trace.

Ondata 4: dal wiki narrativo al knowledge graph

I wiki narrativi per brand erano un grafo implicito nei link in prosa ("vedi R001", "(F005)", [F012](../path/...)). Un indexer offline Python di circa duecentottanta righe ora lo rende esplicito: scrive in pim.db due tabelle additive (wiki_entities con SHA-256 per change detection, wiki_edges con tipo inferito dal verso del link). Risultato su cinque brand: 166 entita' e 557 edges. Tre nuovi tool LLM-callable per percorrerlo (find_related, find_citing, get_entity_details).

Sopra al wiki, un registry SSoT a parte: canonical_aliases.yaml con quindici concetti commerciali ambigui e i loro override per brand. L'esperto interno edita il file, l'agente impara. Niente codice Python toccato.

Ondata 5: catalog RAG e fonte unica

Quando il catalogo di un brand non e' un singolo datasheet ma un PDF da centinaia di pagine, il wiki narrativo non scala. Ho aggiunto un secondo binario di retrieval indipendente: PyMuPDF per il parsing, BGE-M3 per gli embedding, sqlite-vec per la similarity search, in un DB separato document_vectors.db da venti megabyte. Cinque brand indicizzati: 1973 entita' + 2295 chunks + 7550 mentions edges su 1980 PDF totali. Tre tool LLM-callable con schema dual-purpose (source_type distingue datasheet da catalog_page).

L'osservazione che mi tengo da questa fase: SKU recall basso non e' un problema di embedding, e' un problema di layout extraction. Embedding migliori non risolvono i codici in tabelle a due livelli. Il prossimo step strutturale sara' entity-centric extraction offline (regex SKU + table + page anchor + adjacency + inverted index), non embedding piu' grossi.

Per chiudere il giro, ho consolidato tutte le policy in una directory domain_contracts/ con otto file YAML (valid_brands, brand_routing_patterns, sku_regex, native_columns, canonical_aliases, guardrail_rules, sku_invariants, kg_edge_types), un loader Pydantic v2 con TTL 5 secondi, un cross-validator check_contracts.py che esegue 14 check su DB reale e ritorna exit 0/1/2. Cinque engine separati nel codice ora leggono da questa fonte unica via try-import + fallback hardcoded (backward-compat zero-rischio). Un orchestrator refresh_arcocat.py lega sei passi (wiki indexer, document ingester, SKU audit, drift wiki-vs-pim, alignment, check_contracts, regression 27 golden) in single entry-point. Severity aggregata, exit code, skill arcocat-refresh user-invocable.

Cosa mi tengo

Tre lezioni, in ordine di durabilita'.

La prima: il prompt si accorcia mano a mano che la macchina assorbe le clausole "il modello DEVE..." in contratti runtime. Meno cinquantasette percento di prompt char in due settimane senza perdita di capacita'. Il prompt vuole intent, non enforcement.

La seconda: i nodi della constellation crescono per sedimentazione di layer separati, non per ingrossamento di un layer unico. Il wiki narrativo per l'umano, l'index strutturale per la macchina (knowledge graph), i contratti dichiarativi per il runtime (domain contracts). Sono tre fonti diverse di verita' che si annotano a vicenda, non tre fasi della stessa cosa.

La terza, di metodo: prima costruisci come misurare, poi cambi. I ventisette golden queries pytest e il L15 drift mi hanno permesso di toccare cinque punti del sistema senza paura. Ogni intervento e' chiuso con una run di regression, non con un "secondo me funziona".

Per il dettaglio architetturale aggiornato vedi lo studio sullo stack agentico per PMI bumpato a v2.4 e il playbook di onboarding catalogo a v1.3.