Skip to main content

The Tree Grows a New Ring: Recomposing a Civic AI in Hours

· 8 min read
Jean-Noël Schilling
Locki one / french maintainer

On recomposing a multi-repo civic AI system between two rounds of a municipal election

Sunday Night

The first round ended at 18:00. By 21:00, the numbers were in. Four lists had run. None had won outright. A second round was set for the following Sunday.

Then the phone calls happened.

By Monday morning, the landscape had shifted. Michel Van Praet's list — S'unir pour Audierne-Esquibien — announced a fusion with Florent Lardic's Construire l'Avenir. Five candidates in eligible positions on the combined list. Eric Bosser's Cap sur Notre Futur withdrew entirely.

Two lists remained where four had stood. The civic AI tool that had spent weeks indexing, comparing, and presenting all four programmes now described a reality that no longer existed.

The lighthouse was still on. But it was pointing at the wrong sea.

The Contradiction

This is TRIZ territory — two things that seem incompatible:

The knowledge must not change. The context must change immediately.

Every document from every list — the programme proposals, the candidate presentations, the press articles — is a historical record of what was proposed to citizens before the vote. Deleting them would be rewriting history. Citizens who asked "what did Van Praet propose on housing?" still deserve an answer.

But presenting four lists as current competitors when only two remain is misinformation. The comparison mode, the suggestion chips, the system prompts — all framed a four-way race that no longer exists.

The knowledge doesn't change. The context changes. The tree grows a new ring.

The Pipeline Has Seven Layers

Separation of Concerns says: map the pipeline, find which layer owns the failure, fix only that layer. Here is what touches list identity in OCapistaine:

LayerWhat it doesNeeds change?
ChromaDB chunksStores document embeddingsNo — the memory stays
RetrievalSearches all list namesNo — citizens can still query historical lists
Source of truthdisplay_name() — slug to labelYes — citizens see source labels
System promptsTell the LLM how many lists existYes — the LLM must know the new reality
JSONL datasetSource documents for ingestionYes — needs a recomposition document
UI suggestionsChips that guide citizen queriesYes — should suggest 2 lists, not 4
Compare modeSide-by-side programme comparisonYes — should compare 2 active lists

Seven layers. Four need surgery. Three must be left untouched. The constraint is time — citizens will visit the tool before the second round. Every hour of ambiguity is an hour of potential confusion.

The Thread Crosses Repositories

The recomposition information didn't originate in the codebase. It came from Facebook — a post by Van Praet announcing the fusion, naming the five candidates joining Construire l'Avenir: Daniele Priol-Thomas, Adelie Castel, Christian Neveu, Georges Castel, Michel Van Praet. A second post from Lardic confirmed it, with a graphic reading "Fusion des listes — Unis POUR la victoire !"

Meanwhile, the vaettir repository — the infrastructure layer — already had the ceremony page updated with the two-list reality. The holding page at cap.audierne2026.fr read "deux listes restent en lice."

The thread had started in one repo and needed to land in another. This is the hardest problem in multi-repository projects: knowledge that exists in one place must be woven into the fabric of another, without breaking either.

Freezing History First

Before changing anything, we created a branch: freeze/15032026. The main branch as it stood on election night — four lists, 174 documents, every prompt calibrated for a four-way race. Preserved. Recoverable. The tree's previous ring, visible under the bark.

This is not backup paranoia. It is civic responsibility. If anyone questions what the tool showed before the recomposition, the answer is a git checkout away.

The Surgery

The JSONL — a new document, appended as the 175th entry. Not a correction to existing documents. An addition. The recomposition itself becomes citable knowledge:

"La liste S'unir pour Audierne-Esquibien, conduite par Michel Van Praet, rejoint la liste Construire l'Avenir. Cinq colistiers figurent en position eligible..."

When a citizen asks "what happened after the first round?", the RAG can answer with a source.

The system prompts — the refine prompt that told the LLM "Quatre listes electorales sont en lice" now reads "Deux listes sont en lice pour le second tour." But it doesn't erase the four — it adds context: "Listes du premier tour retirees ou fusionnees (leurs documents restent dans la base)." The LLM knows both the current state and the historical state.

The source display — the display_name() function, the single translation point between database slugs and what citizens see. When a retrieved chunk comes from spae, the label now reads "S'unir pour Audierne-Esquibien (fusionnee avec Construire l'Avenir)". One function. One intermediary. Zero confusion.

The UI — compare mode now shows two lists, not four. Suggestion chips guide toward Construire l'Avenir and Passons a l'Action. The label reads "les 2 listes du second tour". But the full LISTS dictionary keeps all four entries — because source expanders still need to display historical list names when old documents surface.

What Was Not Touched

The retrieval layer still searches all five list namespaces (four electoral plus the participatory programme). The Forseti neutrality audit still knows all four list names — it must detect bias even when referencing historical content. The OCR scripts, the build pipelines, the ingestion logic — untouched. Each layer owns its concern. Only the layers that face citizens were updated.

This is the discipline that Separation of Concerns demands: the urge to "clean up everything" is strong. Resist it. The retrieval layer returning a document from a withdrawn list is not a bug — it is memory. The system that forgets what was proposed cannot claim to serve transparency.

The TRIZ Resolution

The contradiction — knowledge must not change, context must change — was resolved by TRIZ Principle #2: Taking Out. Extract the active list set from the historical list set. Let each live in its own layer:

  • Historical set: retrieval, source display, Forseti detection
  • Active set: compare mode, suggestion chips, system prompts

Two realities, coexisting in the same system. The present and the past, each served by the layer designed for it.

What We Got Wrong

Honesty is part of the method.

The display_name() function was updated to show recomposition context — but nobody checked whether the Streamlit source expander actually called it. It didn't. The front-end was reading the raw slug from ChromaDB metadata and displaying it directly. A citizen would have seen spae in their sources with no explanation of the fusion.

We caught it during self-review. But a citizen using the tool between the fix and the review would have seen stale labels. In a system built on transparency, that's not a minor bug — it's a trust gap.

The deeper lesson: when you change a function, tracing its logic is not enough. You have to trace its wiring — every place that should call it but doesn't yet. Separation of Concerns maps the layers. It doesn't verify the connections between them.

The Guinea Pig Problem

Seven layers were changed. Zero were tested before the human tried them. The instinct, when building fast, is to reach for another agent — a "tester agent" to verify the work. But that instinct is wrong.

Testing is not an agent. Testing is a discipline. It's pytest in a fresh checkout. It's a smoke test that asks "does display_name('spae') contain the word 'fusionnee'?" — five lines, no mythology required. The sixteen tests we wrote after finding the gap took one minute to run. They would have caught the problem before a citizen did, if they had existed an hour earlier.

The temptation to build an agent for every gap is real. It feels productive. But sometimes the answer is not a new branch on the tree — it's a pytest command and the humility to run it before declaring victory.

Not every solution is an agent. Some are just good habits.

What This Means

A civic AI tool that cannot adapt to political recomposition between rounds is a tool that will mislead citizens at the moment they need clarity most. The technical challenge is real — seven layers, three repositories, two Facebook posts as source material, and a clock that doesn't stop. But the methodology makes it tractable.

Map the layers. Freeze the history. Touch only what faces the citizen. Test before you ship. Let the memory endure.

The tree grew a new ring. The previous rings are still there, holding up the trunk. And the bark is a little thicker where it healed.


Related: The Day the Lighthouse Dimmed | The Thorn in the Thread