feat(rag): syncDir — synchro corpus fichiers→base à chaque démarrage

L'auto-ingestion ne jouait qu'à base vide (1er démarrage) : une MAJ OTA du
composant rag_corpus déposait les nouveaux fichiers mais le RAG continuait de
servir l'ancien corpus, sans aucun déclencheur (sauf intent manuel rag_ingest).

Rag.syncDir(dir) : ingère tout fichier .txt/.md absent de la base ou dont le
texte diffère du brut stocké (comparaison exacte). Idempotent, n'embed que le
delta → appelé à CHAQUE buildRag, remplace le cas spécial « base vide ».
Ne supprime jamais (docs admin et docs retirés du dossier restent — suppression
via l'admin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-11 22:51:30 +02:00
parent 3761e03d52
commit 747848a475
2 changed files with 33 additions and 3 deletions

View File

@ -112,6 +112,34 @@ class Rag(
return total
}
/**
* Synchronise le corpus FICHIERS base : ingère tout fichier .txt/.md absent
* de la base ou dont le texte a changé (comparaison exacte sur le brut stocké).
* Idempotent et incrémental : n'embed que le delta appelé à CHAQUE buildRag,
* c'est ce qui fait qu'une mise à jour OTA du composant rag_corpus est prise
* en compte au redémarrage suivant (sans intent manuel). Ne supprime jamais :
* les docs admin (hors fichiers) et les docs retirés du dossier restent
* suppression via l'admin. NB : si l'admin a édité un doc PORTANT LE NOM d'un
* fichier seed, le fichier regagne au redémarrage (les ajouts admin doivent
* utiliser des noms propres, pas écraser le seed).
*/
@Synchronized
fun syncDir(dir: String): Int {
val d = java.io.File(dir)
if (!d.isDirectory) { log("syncDir: '$dir' absent"); return 0 }
val files = d.listFiles { f -> f.isFile && (f.name.endsWith(".txt") || f.name.endsWith(".md")) }
?.sortedBy { it.name } ?: emptyList()
var changed = 0
for (f in files) {
val txt = try { f.readText() } catch (e: Exception) { log("syncDir: lecture ${f.name} échec"); continue }
val src = f.nameWithoutExtension
if (db.docText(src) == txt) continue // déjà à jour → zéro embed
ingest(src, txt); changed++
}
if (changed > 0) log("syncDir '$dir': $changed doc(s) (ré)ingéré(s) sur ${files.size}")
return changed
}
/**
* Récupère le contexte pertinent pour [query] et le formate en bloc prêt à
* injecter, ou null si rien au-dessus du seuil. Le seuil évite d'injecter du

View File

@ -2077,9 +2077,11 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
val emb = com.kazeia.rag.EngineEmbedder(embModel, nThreads = 4, pooling = -1,
queryPrefix = "query: ", passagePrefix = "passage: ", log = { m -> log(m) })
com.kazeia.rag.Rag(this@KazeiaService, emb, "e5-small") { m -> log(m) }.also {
// Auto-ingestion du corpus seed au 1er démarrage (base + docs vides).
if (it.isReady && it.count() == 0 && it.listDocs().isEmpty())
it.ingestDir("${KazeiaApplication.MODELS_DIR}/../rag_corpus")
// Synchro corpus fichiers → base à CHAQUE build (idempotent, n'embed
// que le delta) : couvre le 1er démarrage ET les MAJ OTA du composant
// rag_corpus (nouveaux fichiers ingérés au redémarrage suivant).
if (it.isReady)
it.syncDir("${KazeiaApplication.MODELS_DIR}/../rag_corpus")
it.reingestMissing() // embed les docs admin ajoutés hors-ligne
rag = it
com.kazeia.rag.RagHolder.instance = it