app/consolidator/) transforme des événements bruts en
faits durables. Il tourne en arrière-plan (déclenché par
POST /v1/consolidate, voir Référence API)
sur les jobs consolidate en attente (pending ou failed —
rejouables).
Le pipeline, événement par événement
1
Extraction
Pour chaque événement du job, l’extracteur LLM configuré
(
HAKI_LLM_PROVIDER=fake|openai) reçoit l’événement et les faits
actifs déjà connus du sujet, pour pouvoir proposer action: "supersede"
plutôt que d’empiler des contradictions.2
Validation Pydantic
Chaque candidat brut est validé par
ExtractedFact.model_validate(...).
Un candidat invalide est rejeté et journalisé, jamais fatal — il
ne fait jamais planter le lot (result["rejected"] += 1).3
Embedding
Les candidats valides sont embarqués en un seul appel batch
(
embedder.embed(texts)), texte = f"{predicate} {json(value)}".
Les événements eux-mêmes sont aussi embarqués une fois (mémoire
épisodique, sprint 10), donnée dérivée recalculable — c’est la seule
écriture tolérée sur un événement après insertion.4
Adjudication contre l'existant
Voir la section dédiée ci-dessous : trouver le fait actif auquel
comparer le candidat.
5
Application
Dédup, supersession ou création de conflit — voir « Les quatre issues »
ci-dessous.
Trouver le fait à adjuguer : exact puis sémantique
_resolve_existing_fact cherche le fait actif du sujet à comparer au
candidat :
- Correspondance exacte de prédicat (chemin rapide — le cas le plus fréquent, quand l’extraction est lexicalement cohérente) ;
- si aucune, repli sémantique : le fait actif dont l’embedding est
le plus proche du candidat (distance cosinus), accepté seulement si la
distance est ≤ 0.28 (
SEMANTIC_MATCH_MAX_DISTANCE).
Les quatre issues d’un candidat
1
Duplicate ou renforcement
Même
(subject, predicate, valeur canonique) déjà présent parmi les
faits non supprimés → aucune nouvelle ligne. Un événement rejoué
(déjà dans source_event_ids) reste un simple doublon — c’est ce qui
rend le rejeu d’un job idempotent. Un nouvel événement qui
réaffirme la même valeur sur le fait actif le renforce à la
place : reinforcement_count et last_reinforced_at sont mis à jour,
le nouvel id d’événement est ajouté à source_event_ids.2
Supersede
action="supersede" avec un fait existant trouvé : l’ancien fait
passe active → superseded (valid_to = occurred_at de l’événement
qui déclenche le changement), le nouveau devient active avec
supersedes_id pointant l’ancien. Les clés que le candidat ne
redéfinit pas sont reprises de l’ancien fait
(value = {**existing.value, **candidate.value}) — une mise à jour de
statut seule ("researching" → "completed") ne doit pas faire perdre
silencieusement un champ comme target que le nouveau message ne
répète pas.3
Conflict
action="create" mais un fait actif existe déjà pour ce prédicat avec
une valeur différente : les deux faits entrent dans un
ConflictSet ouvert (créé ou complété), le nouveau reste candidate
— voir Conflits.4
Create
Aucun fait existant pour ce prédicat : le candidat passe directement
candidate → active.Le scope vient toujours de l’événement
Lesubject_id (et tout le scope) d’un fait créé provient toujours de
l’événement source, jamais du candidat renvoyé par le LLM — même si le
schéma d’extraction porte encore un champ subject_id (conservé pour
compatibilité arrière avec les providers existants). C’est l’invariant de
sécurité « le modèle ne choisit jamais les scopes », appliqué ici à
l’écriture : un candidat dont le subject_id dérive (le LLM nomme une
personne au lieu de réutiliser le sujet de l’événement) créait
silencieusement un fait sous un sujet orphelin, injoignable par tout appel
/v1/context — un vrai bug de perte de données trouvé en auditant les
résultats d’évaluation sprint 10 à l’échelle.
Un seul fait actif par sujet+prédicat, garanti
Toute la phase d’écriture (contrôle de doublon, résolution sémantique, renforcement, création) pour un(project_id, subject_id) donné est
sérialisée par un verrou advisory Postgres à portée transaction
(pg_advisory_xact_lock) avant que la moindre de ces décisions ne soit
prise — deux consolidations concurrentes du même sujet ne peuvent jamais
observer toutes les deux « aucun doublon » et insérer toutes les deux. Un
index unique partiel sur facts (project_id, subject_id, predicate) WHERE status = 'active' renforce cette garantie au niveau base de données :
même un futur chemin d’écriture qui oublierait le verrou ne peut pas créer
deux faits actifs avec le même prédicat exact pour un sujet — il échoue
bruyamment à la place.
Le renforcement ne fusionne jamais un candidat dont la valeur a réellement
changé : mesuré contre l’embedder local réel, une vraie mise à jour de
valeur (ex. bike_count 3 → 4, distance cosinus 0.03) se trouve plus
proche que plusieurs reformulations légitimes de même valeur (jusqu’à
0.19) — aucun seuil de distance ne peut séparer les deux. Le renforcement
exige donc l’égalité canonique exacte de valeur ; tout le reste ouvre
toujours un conflit, comme avant.
Résilience
- Un échec provider ou base de données fait échouer le job
(
status=failed, erreur danspayload), jamais les événements source, qui restent intacts et rejouables au run suivant. - Chaque job est traité dans un savepoint (
session.begin_nested()) : un échec ne fait rollback que sur ce job, pas sur les autres du même run. - Sur un 429 (rate limit) du provider LLM, le batch s’arrête net plutôt que de continuer à échouer job après job : mesuré en conditions réelles sur un run Groq free-tier (budget 6000 tokens/min épuisé au job #3, jobs #4-19 échouant instantanément et gaspillant le retry/backoff de l’appelant).
Référence API — Consolidation
POST /v1/consolidate : déclenchement synchrone, dev/ops.
