Ce que fait chaque pièce du système, et surtout pourquoi elle existe. Six vues courtes, un schéma par vue — pas de longue page à faire défiler.
Une chaîne de programmes déterministes fabrique le contenu sur le serveur ; le site, lui, ne fait qu’afficher. Trois idées suffisent à lire tout le reste : le site ne sait rien écrire, l’API écrit des demandes plutôt que des résultats, et les modèles d’IA travaillent enfermés.
flowchart LR
sources[("`📡 **Une vingtaine de sources**
RSS · API · social`")]
subgraph hote["Sur le serveur — programmes déterministes"]
direction LR
collecte["`🧺 **Collecte**
toutes les 15 min`"]
analyse["`🔬 **Analyse**
modèle enfermé`"]
publication["`📦 **Publication**
bascule atomique`"]
end
subgraph web["Exposé au web"]
direction TB
site["`📰 **Site statique**
lecture seule`"]
api["`🔐 **API privée**
derrière Cloudflare Access`"]
end
julien(["🙋 Julien"])
sources --> collecte --> analyse --> publication
publication -- "`édition active
(data/current)`" --> site
publication -- "projections" --> api
julien -- "lit le site" --> site
julien -- "pilote le Desk" --> api
api -. "`journal de commandes,
appliqué par le serveur`" .-> collecte
📰 Le site ne sait rien écrire
C’est un dossier de fichiers servi par nginx en lecture seule. Publier, c’est remplacer un raccourci (data/current) qui pointe vers une nouvelle édition : instantané, atomique, réversible — sans rebuild ni redémarrage.
🔐 L’API journalise, le serveur applique
Ajouter une source depuis le Desk ne modifie rien : une ligne entre dans un journal de commandes. Une minute plus tard, un programme du serveur lit ce journal, teste réellement la source, et met à jour le catalogue. L’API, elle, ne lit ni n’écrit jamais le catalogue, et ne va jamais chercher une source sur internet.
🔬 Le modèle travaille enfermé
Pendant l’analyse, le modèle ne peut ni lire un fichier, ni ouvrir un terminal, ni toucher au réseau, ni publier. Il reçoit un texte, il rend un texte. Toute la validation est faite par du code déterministe autour de lui.
🛎️ La machine ne parle que si nécessaire
Les alertes raisonnent en conditions — un fait actionnable qui s’ouvre, s’aggrave, dure ou disparaît — jamais en compteurs. Un chiffre qui bouge ne réveille personne.
Un article traverse quatre états, toujours dans le même ordre, et chaque flèche a un seul responsable. L’invariant : un acquittement n’est jamais une publication — la collecte ne modifie jamais directement le flux public.
flowchart LR
a["`1 · **Collecté**
figé dans SQLite
avec sa provenance`"]
b["`2 · **À analyser**
dans la file durable`"]
c["`3 · **Analysé**
décision validée
puis acquittée`"]
d["`4 · **Publié**
dans une édition activée`"]
a -- "collecteur" --> b
b -- "`modèle enfermé
+ validation`" --> c
c -- "`publisher,
bascule atomique`" --> d
s["`⑤ · **Signaux sociaux**
monde parallèle : jamais
mêlés aux articles`"]
🎬 Suivez un article
1 / 7
08:00 — Une source publie
Un billet paraît sur le blog d’un éditeur suivi. Le système ne le sait pas encore : rien ne se passe en continu, tout se passe par passages réguliers.
08:15 — Le collecteur passe
Le tick des 15 minutes interroge la source, télécharge le corps de l’article et en fige un instantané : texte, URL finale, date, empreinte du contenu. À partir d’ici, plus personne ne retourne sur le réseau pour cet article — on travaille sur la copie figée, rejouable et auditable.
Il entre dans la file d’analyse
L’article attend dans une file durable avec ses semblables. Si son corps n’a pas pu être téléchargé, il est différé, pas abandonné : la collecte réessaie avec patience (30 min, puis le double, jusqu’à 12 h) et aucune décision n’est jamais fabriquée à partir d’un simple résumé RSS.
Le préparateur compose un lot
Un lot de 6 à 8 articles est assemblé, avec un budget de texte borné et les preuves à citer. Le préparateur fournit aussi les seuls catalogues autorisés — le modèle ne lira jamais l’édition publiée directement.
Un modèle enfermé décide
Chaque partition du lot part dans un processus confiné : pas de fichiers, pas de terminal, pas de réseau, pas de publication. Le modèle rend des décisions éditoriales ; du code déterministe les revalide toutes avant d’aller plus loin.
Le publisher construit une édition
Une seule autorité écrit du contenu public : le publisher. Il revalide tout — décisions, provenance, preuves citées — puis construit une édition complète et immuable dans data/.releases/.
La bascule — l’article est en ligne
Le raccourci data/current est remplacé d’un coup : c’est la seule opération qui rend un contenu public. Le site n’a rien eu à reconstruire, et revenir en arrière serait tout aussi simple. Les décisions ne sont acquittées qu’après, et seulement si le contenu n’a pas changé entre-temps.
🙋 Où Julien décide
Ajouter une source (essai réel puis confirmation du tier), publier un exposé pédagogique, promouvoir ou rejeter un article, autoriser l’implémentation d’une tâche, déployer : chacun de ces gestes est explicite et humain. Rien de tout cela n’est automatique.
⏳ Différé n’est pas en retard
Deux alertes distinctes existent parce que ce sont deux problèmes différents : « le pipeline traîne » (analysis-lag) et « une page est inaccessible » (snapshot-unavailable). Une source durablement muette devient un arbitrage humain, jamais une purge automatique.
Trois conteneurs Docker — tous en lecture seule, deux exposés au web — et une poignée de services systemd qui, eux, ont le droit d’agir sur le serveur. La frontière entre les deux mondes est le cœur du modèle de sécurité.
flowchart TB
internet(("🌍 Internet")) --> cf["`**Cloudflare**
(+ Access pour le privé)`"] --> traefik["`**Traefik**
routeur d'entrée`"]
subgraph docker["Conteneurs Docker — tous read-only"]
direction LR
nginx["`📰 **ai-knowledge-base**
nginx sert le site
+ data/ monté en :ro`"]
api["`🔐 **ai-kb-api**
FastAPI : notes, chat,
recherche, commandes`"]
lite["`💾 **ai-kb-litestream**
sauvegarde continue
des bases SQLite`"]
end
subgraph hote["Services systemd sur l'hôte — les seuls à pouvoir écrire"]
direction LR
cmd["`🧾 consommateur de
commandes sources
(~ chaque minute)`"]
bridge["`🗂️ pont Kanban
des tâches`"]
hermes["`🧠 passerelle Hermes
derrière l'Analyste`"]
end
traefik --> nginx
traefik -- "/api" --> api
api -. "`questions de l'Analyste
(réseau local uniquement)`" .-> hermes
api -. "écrit ses commandes" .-> cmd
lite --> r2[("`☁️ **Cloudflare R2**
copies de secours`")]
📰 ai-knowledge-base — la vitrine
nginx sert le site construit et, via une liste blanche stricte, les seuls JSON publics. Tout le reste de data/ répond 404 : l’état privé est structurellement inaccessible depuis le web.
🔐 ai-kb-api — l’espace privé
La seule pièce qui écrit — et uniquement dans data/state/. Elle est volontairement myope : elle lit des projections préparées par le pipeline et journalise des commandes que le serveur appliquera. Toute requête privée exige un jeton Cloudflare Access vérifié cryptographiquement.
💾 ai-kb-litestream — le filet
Quand son profil backup est activé, chaque écriture dans les trois bases SQLite est répliquée en continu vers Cloudflare R2. Une restauration ramène alors l’état à quelques secondes près. Pas de route web : il ne parle qu’à R2.
🧠 La passerelle Hermes — l’Analyste
Le chat du Desk ne parle qu’à une passerelle locale, jamais à internet. Au démarrage, l’API vérifie même que la présence d’une clé d’API facturée à l’usage ferait échouer le service : aucun coût accidentel possible.
🧾 Les mains du serveur
Consommateur de commandes, pont Kanban, previews jetables : des services systemd durcis (système en lecture seule, capacités vides, accès minimal) qui font le travail que les conteneurs n’ont pas le droit de faire.
data/ contient deux mondes qui ne se mélangent jamais : le monde public, servi par nginx sur liste blanche, et le monde privé (data/state/), que le web ne peut pas atteindre.
flowchart LR
subgraph pub["🌐 Monde public — servi sur liste blanche"]
direction TB
current["`**data/current**
raccourci vers l'édition active`"]
releases["`data/.releases/…
éditions immuables,
l'historique complet`"]
current -- "pointe vers" --> releases
end
subgraph priv["🔒 Monde privé — data/state, jamais servi"]
direction TB
ing[("`**ingestion.sqlite3**
la vérité de la collecte`")]
ws[("`**workspace.v1.sqlite3**
l'espace privé de Julien
+ journal de commandes`")]
idx[("`**search-index.v1.sqlite3**
l'index de recherche`")]
proj["`projections *.json
santé, Desk, catalogue`"]
end
pipeline["⚙️ pipeline"] -- "construit les éditions" --> releases
pipeline -- "écrit" --> ing
pipeline -- "écrit" --> proj
api2["🔐 API"] -- "écrit" --> ws
api2 -. "lit seulement" .-> ing
ing & ws & idx -. "`copie continue
vers Cloudflare R2`" .-> lite["💾 litestream"]
🧾 ingestion.sqlite3 — la mémoire de la collecte
Chaque article collecté, chacune de ses versions, sa provenance et l’état HTTP de chaque source. C’est la référence : le publisher, l’indexeur et l’API la lisent, seul le collecteur y écrit.
🗂️ workspace.v1.sqlite3 — l’espace de Julien
Tout ce que le site produit et que le pipeline ne produit pas : statuts de lecture, notes, conversations de l’Analyste, brouillons d’exposés, demandes de tâches — et le fameux journal de commandes.
🔎 search-index.v1.sqlite3 — la recherche
Texte intégral + vecteurs sur tout le corpus. Le filtre « publié » est recalculé à chaque requête : retirer un contenu est effectif immédiatement, sans réindexation.
📸 « Snapshot » — un mot, trois sens
Le terme revient partout ; il désigne trois choses distinctes :
L’instantané d’un article — le sens central. Le contenu figé à la collecte, avec empreinte et provenance. C’est ce qui rend l’analyse rejouable et interdit au modèle de toucher au réseau : on analyse la copie, jamais la page vivante.
L’édition publique — le dossier immuable dans data/.releases/ qu’une bascule de raccourci rend visible.
La copie de preview — les données publiques figées montées dans un conteneur jetable pour relire une branche avant fusion.
Une trentaine de scripts, mais quatre familles seulement. Le tick de 15 minutes collecte, la chaîne d’analyse décide et publie, quelques commandes servent les gestes de Julien, et des garde-fous verrouillent chaque build.
flowchart LR
f["`1 · **feedback**
applique tes arbitrages`"] --> s["`2 · **signaux sociaux**
collecte AINews`"] --> i["`3 · **ingestion**
collecte les sources d'articles`"] --> x["`4 · **index de recherche**
met à jour l'index`"] --> d["`5 · **projection Desk**
rafraîchit le tableau de bord`"]
🔁 Le tick de 15 minutes
npm run ingest:cycle — l’ordonnanceur ci-dessus. Il n’alerte que si une condition s’ouvre, s’aggrave, dure ou se ferme.
apply_feedback — transforme tes promotions/rejets en préférences durables
collect_social_signals — les posts AINews, à part des articles
ingest_sources — le collecteur : télécharge, fige, met en file
build_search_index — l’indexeur, seul écrivain de l’index
🔬 La chaîne d’analyse
Déclenchée par cron Hermes, jamais à la main dans un cycle normal.
analyze:prepare — compose les lots de 6–8 articles et leurs preuves
editorial_analysis_pipeline — lance les modèles confinés en parallèle
analyze:publish — le publisher : édition immuable + bascule atomique
🙋 Les gestes de Julien
source:add — essai réel d’une source, puis confirmation du tier
knowledge:apply — fait passer les exposés publiés vers le pipeline
le feedback et la console sources passent par le site, en commandes journalisées
🛡️ Les garde-fous du build
Autour de chaque npm run build — y compris dans l’image Docker :
test:privacy — aucune donnée privée dans le HTML statique
test:panel — rien d’offert à un visiteur anonyme
test:links — chaque lien interne est réellement servi par nginx
sync:data — copie l’édition active vers le frontend, avant chaque build et session de dev
Les mots de la maison. Cliquez une carte pour la retourner — recliquez pour revenir au terme.