Skip to main content
Le Consolidator (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 :
  1. Correspondance exacte de prédicat (chemin rapide — le cas le plus fréquent, quand l’extraction est lexicalement cohérente) ;
  2. 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).
Ce seuil de 0.28 a été calibré empiriquement contre l’embedder réel (fastembed, paraphrase-multilingual-MiniLM-L12-v2), pas déduit d’une valeur de la littérature — scripts/check_semantic_threshold.py. Les paires de même concept mesurées (bike_count/bikes_owned, personal_best_5k/goal_personal_best_time, favorite_color/preferred_color) se regroupent entre 0.09 et 0.23 ; les paires non liées (y compris lexicalement proches, comme bike_count/car_count) restent au-dessus de 0.35. Ce repli sémantique a été ajouté après une mesure de 87.5 % de fuite de contradiction (contradiction leakage) sur l’échantillon d’évaluation sprint 10, causée par un prédicat légèrement différent d’un appel à l’autre qui laissait l’ancien fait, périmé, actif et servi en parallèle du nouveau.

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

Le subject_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 dans payload), 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.