- Go 99.4%
- Dockerfile 0.4%
- Shell 0.2%
Plus de build sur push main ni sur PR : chaque version est taguée et le runner unique ne fait plus de build en double. Image Docker : tags X.Y et latest, qui suit la dernière version. |
||
|---|---|---|
| .forgejo/workflows | ||
| cmd/kvasir | ||
| deploy | ||
| internal | ||
| .dockerignore | ||
| .gitignore | ||
| CLAUDE.md | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| README.md | ||
kvasir
Agent Go autonome qui pilote une ou plusieurs bibliothèques nxt-opds via leur serveur MCP : enrichissement automatique des métadonnées (tags, résumé, classification d'âge, intensité du piment, séries…), réponses conversationnelles sur le catalogue, exécution de gros batches de maintenance.
Un seul binaire kvasir gère plusieurs instances nxt-opds. Chacune est
configurée via un appairage à code one-time, sans copier-coller de secrets.
Sommaire
- Sous-commandes
- Installation rapide
- Appairage avec une instance nxt-opds
- Configuration YAML
- Serveurs MCP additionnels (
mcp) - Backends LLM
- Daemon (
serve) - Maintenance en lot (
batch) - Passe de cohérence globale (
audit) - Analyse des livres (
analyze) - Run one-shot (
run) - Docker / docker-compose
- Logs
- Architecture interne
- Self-update
Sous-commandes
| Commande | Quand l'utiliser |
|---|---|
pair |
Associer ce librarian à une instance nxt-opds via un code généré dans l'admin UI |
unpair |
Dissocier proprement une instance des deux côtés |
serve |
Faire tourner le daemon longue durée (chat, webhooks book-event, ticker, heartbeat) |
batch |
Itérer une maintenance déterministe (pagination en Go, pas en LLM) — idéal pour « traite tous les 16+ » |
audit |
Passe de cohérence sur toute la bibliothèque, avec vue globale : fusionne les séries / auteurs / tags / éditeurs orthographiés de plusieurs façons |
analyze |
Lit les EPUB chapitre par chapitre et enregistre leurs résumés dans edda — déclenché par webhook book.analyze, après chaque book.created (EPUB), et chaque nuit en rattrapage |
run |
Exécution one-shot pilotée par LLM (recherche par titre, prompt ad-hoc) |
mcp |
Brancher d'autres serveurs MCP sur l'agent (recherche externe, navigateur…) : list, add, import, test, enable, disable, remove |
update |
Self-update vers la dernière release Forgejo |
version |
Affiche la version installée |
kvasir help pour l'aide complète, kvasir <cmd> --help pour les flags d'une commande.
Installation rapide
go install git.helheim.net/mimisbrunnr/kvasir-agent/cmd/kvasir@latest
# ou télécharger la release pré-compilée :
# https://git.helheim.net/mimisbrunnr/kvasir-agent/releases
Vérifier :
kvasir version
Appairage
L'appairage utilise un code one-time généré depuis l'UI admin nxt-opds.
Aucun secret ne transite par la ligne de commande ; les chat_secret et
webhook_secret sont négociés entre les deux services pendant l'échange.
Étapes
-
Dans l'admin nxt-opds : cliquer « Associer un librarian ». Un code
XXXX-XXXXvalide 10 minutes s'affiche. -
Sur la machine du librarian :
kvasir pair \ --nxt-opds https://books.example.com \ --code K4Q9-PN2X \ --name example \ --label "Bibliothèque Example"Le YAML
~/.config/kvasir/config.yamlest créé / mis à jour ; nxt-opds stockelibrarian_urlcôté DB. La chat box devient active immédiatement.
Flags utiles
| Flag | Description |
|---|---|
--librarian-url <url> |
URL publique du librarian que nxt-opds doit appeler. Défaut : déduit du champ listen du YAML (http://localhost:8080) ou de public_url. |
--rotate |
Régénère chat_secret + webhook_secret sans nouveau code (utilise le chat_secret actuel pour s'authentifier). |
--force |
Écrase une association existante côté nxt-opds (sinon 409). |
--print-only |
N'écrit rien, affiche juste les blocs YAML à copier-coller. |
Dissocier
kvasir unpair --name example --nxt-opds https://books.example.com
Efface l'entrée du YAML local et appelle (best-effort) nxt-opds pour nettoyer l'association côté DB.
Configuration YAML
Résolution : --config <path> > KVASIR_CONFIG env > ./kvasir.yaml
~/.config/kvasir/config.yaml.
listen: ":8080" # adresse d'écoute HTTP du daemon
public_url: "http://kvasir.lan:8080" # URL annoncée à nxt-opds (sinon dérivé de listen)
interval: "6h" # cadence du ticker en mode serve
audit_interval: "168h" # cadence de la passe de cohérence globale ("0" = désactivée)
# Analyse des livres (résumés par chapitre, lus par le chat sans spoiler) :
# passe de rattrapage nocturne sur les EPUB non analysés.
analysis:
enabled: true # false = pas de passe nocturne (webhook et CLI actifs quand même)
schedule: "03:00" # heure locale HH:MM de la passe nocturne
batch_size: 100 # livres max par passe, les plus anciens d'abord
max_tokens_per_book: 400000 # plafond de tokens (entrée+sortie) par livre, 0 = illimité
model: "" # modèle des analyses sur le backend de l'agent (vide = celui de l'agent)
batch_limit: 10 # nb de livres traités par tick
max_steps: 200 # plafond d'étapes par job
backend: "auto" # auto | ollama | anthropic
model: "" # nom du modèle (sinon défaut du backend)
ollama_url: "http://localhost:11434" # endpoint Ollama ; surchargé par OLLAMA_HOST / --ollama-url
default_instance: "example" # utilisée quand --instance est omis
# Optionnel : clé Google Books — active l'outil google_books_search et le
# place en priorité 1 (avant Babelio / sites éditeurs) pour la recherche de
# métadonnées. Surchargée par la variable d'env GOOGLE_BOOKS_API_KEY.
# google_books_api_key: "AIza..."
instances:
- name: "example" # slug [a-z0-9-]+, unique
mcp_url: "https://books.example.com/mcp"
mcp_token: "<opds_token>" # injecté par `pair`
chat_secret: "<64-hex>" # idem
webhook_secret: "<64-hex>" # idem
label: "Bibliothèque Example"
locale: "fr"
Tout secret est en clair dans le fichier — perms 0600 appliquées
automatiquement. Les variables d'env sont expansées (${OPDS_TOKEN_EX})
au chargement.
Serveurs MCP additionnels (mcp)
L'agent parle toujours au catalogue OPDS de son instance. On peut lui brancher d'autres serveurs MCP : leurs outils s'ajoutent à sa boîte à outils et sont annoncés dans le prompt système, donc le modèle sait quand les appeler.
Les exemples ci-dessous utilisent un serveur fictif bookfinder (recherche de
livres hors catalogue) : remplace-le par le tien.
Deux transports :
| Transport | Quand | Config |
|---|---|---|
stdio |
La forme des serveurs communautaires (npx …, uvx …, un binaire local) — kvasir lance le processus et parle JSON-RPC sur son stdin/stdout |
command, args, env, cwd |
http |
Un endpoint Streamable HTTP distant | url, token |
# Ce que ta config Claude propose déjà (~/.claude.json, Claude Desktop, ./.mcp.json)
kvasir mcp import --list
# Reprendre une entrée telle quelle (commande, arguments, identifiants inclus)
kvasir mcp import --name bookfinder \
--description "Recherche et télécharge des livres hors catalogue."
# …ou la déclarer à la main
kvasir mcp add --name bookfinder --command npx --arg -y --arg mon-serveur-mcp \
--env SERVICE_EMAIL=moi@example.com --env SERVICE_PASSWORD='...' \
--description "Recherche et télécharge des livres hors catalogue."
kvasir mcp list # ce qui est configuré (les valeurs d'env ne sont jamais affichées)
kvasir mcp test bookfinder # handshake + liste des outils, comme le fera le daemon
kvasir mcp disable bookfinder
add et import testent la connexion avant d'écrire : une commande
absente ou un identifiant manquant échoue tout de suite plutôt que de se
révéler plus tard dans une conversation sans les outils. --no-test passe
outre. Après modification, redémarrer le daemon (systemctl restart kvasir)
pour que les agents attachent le serveur.
En YAML :
mcp_servers:
- name: bookfinder # slug [a-z0-9-]+, sert aussi de préfixe
description: "Recherche et télécharge des livres hors catalogue."
command: npx
args: ["-y", "mon-serveur-mcp"]
env: # les identifiants vont ici
SERVICE_EMAIL: "${SERVICE_EMAIL}"
SERVICE_PASSWORD: "${SERVICE_PASSWORD}"
- name: notes
description: "Prise de notes partagée."
transport: http
url: "http://127.0.0.1:4000/mcp"
token: "..."
disabled: true # gardé dans le fichier, pas attaché
Points de comportement à connaître :
descriptionn'est pas décorative : c'est elle qui est injectée dans le prompt (chat et batch) à côté de la liste des outils du serveur. Sans elle, le modèle voit les outils sans savoir à quoi ils servent.- Collision de noms résolue par préfixe : un serveur additionnel peut très
bien exposer un outil dont le nom est déjà pris par le catalogue —
search_booksest le cas typique. Celui du catalogue garde le nom nu, celui du serveur additionnel devient<nom-du-serveur>_search_bookset l'appel lui arrive sous son vrai nom. Rien n'est silencieusement perdu, et le prompt n'explique cette convention que lorsqu'un renommage a réellement eu lieu, en citant le nom exposé. - Un serveur injoignable ne casse rien : il est ignoré avec un log, le catalogue continue de fonctionner, et l'init est retenté au prochain accès. Un serveur stdio qui meurt en cours de route est relancé et re-initialisé automatiquement.
- Le stderr du processus est relayé dans les logs (préfixe
[mcp <nom>]) : c'est là que ces serveurs annoncent un identifiant manquant. - La passe
auditn'y touche pas : elle reste strictement en lecture. - Les processus stdio sont arrêtés proprement à l'extinction du daemon
(stdin fermé, puis SIGTERM/SIGKILL sur le groupe de processus — un wrapper
npxne laisse pas de petit-fils orphelin). - Où écrit le serveur ? Beaucoup de serveurs stdio écrivent dans un dossier
relatif à leur répertoire de travail (
./downloads,./output) sans offrir de variable d'environnement pour le changer. Deux leviers :--cwddéplace la base de tous ces chemins relatifs (attention, certains serveurs y cherchent aussi leur venv ou leurs assets — teste après), ou un lien symbolique du dossier de sortie vers la vraie destination, ce qui laisse lecwdintact. Pense au montage : sur un LXC, un dossier local peut être minuscule là où le partage réseau a des téraoctets. - Sous systemd, mets un chemin absolu dans
command. Le serveur hérite de l'environnement du daemon, et une unité systemd a unPATHminimal :npxouuvxinstallés via nvm/pipx n'y sont pas. Utilise/home/<user>/.nvm/versions/node/<v>/bin/npx, ou pointe directement le binaire/script (command: node,args: ["/opt/mon-serveur-mcp/dist/index.js"]).kvasir mcp testtourne dans TON shell : il peut réussir là où le daemon échouera — vérifie avecsystemctl restart kvasir && journalctl -u kvasir -f.
obscura_mcp_url reste accepté et est replié automatiquement sur une entrée
mcp_servers nommée obscura.
Backends LLM
- Ollama (défaut) — local ou Ollama Cloud. Modèle par défaut :
gemma4:31b-cloud. Override :KVASIR_MODELou--model. - Anthropic (Claude) — activé si
ANTHROPIC_API_KEYest défini OU si--backend anthropic. Plus discipliné sur les boucles longues (recommandé pour de très gros batches).
Variables d'env supplémentaires :
| Variable | Rôle |
|---|---|
KVASIR_BACKEND |
auto (défaut) / ollama / anthropic |
KVASIR_MODEL |
nom de modèle |
OLLAMA_HOST |
endpoint Ollama — l'emporte sur ollama_url du YAML (défaut http://localhost:11434) |
ANTHROPIC_API_KEY |
clé API Claude |
FIRECRAWL_API_KEY |
clé Firecrawl — backend de web_fetch et active l'outil web_search (recherche web via Firecrawl, au lieu de scraper un moteur). Override le YAML |
GOOGLE_BOOKS_API_KEY |
clé Google Books — active l'outil google_books_search en source de métadonnées prioritaire (override le YAML) |
CAMOFOX_URL |
URL d'un serveur camofox-browser (Firefox stealth local, ex. http://127.0.0.1:9377) — backend de web_fetch (essayé après Firecrawl, avant obscura) et active web_search via la macro @google_search quand aucune clé Firecrawl n'est posée. Override le YAML |
CAMOFOX_ACCESS_KEY |
bearer attendu par camofox uniquement s'il est lancé avec CAMOFOX_ACCESS_KEY (exposé hors loopback). Override le YAML |
KVASIR_CONFIG |
chemin du YAML |
Daemon (serve)
kvasir serve --listen :8080 --interval 6h
À démarrage, le daemon :
- Charge le YAML, instancie un Registry d'instances (clients MCP +
Agentparesseusement initialisés). - Announce : POST
/api/librarian/announcesur chaque nxt-opds appairé avec lepublic_urlcourant — auto-réparation après changement de port/hostname/docker network. - Heartbeat : ticker 60 s qui POST
/api/librarian/heartbeatpour que l'admin UI nxt-opds montre la fraîcheur de la liaison. - Ticker batch : toutes les
interval(défaut 6 h), enqueue un jobsearch_books(not_indexed:true, limit=batch_limit)sur chaque instance.
Routes exposées
| Route | Méthode | Description |
|---|---|---|
/healthz |
GET | OK plaintext |
/instances |
GET | JSON public : [{name, label, locale}] |
/chat |
POST | Endpoint de chat appelé par la chat box edda. Auth : Authorization: Bearer <chat_secret>. Body : {message, history, user_token?, user_context?, user_memories?, session_id?, cancel?, capabilities?} — user_context est la fiche que le lecteur a écrite sur lui-même, user_memories les souvenirs qu'edda juge pertinents pour ce message ; les deux sont rendus dans le prompt système de la conversation (et l'agent entretient cette mémoire via les outils MCP remember / recall / forget). capabilities liste ce que le client sait afficher au-delà de la prose (["question"] : questions à choix, voir ci-dessous). Avec Accept: text/event-stream, la réponse est un flux SSE (id: N + data: {"kind":"delta"|"text"|"tool_call"|"tool_result"|"question"|"error"|"final"}, plus : ping toutes les 15 s). La boucle est détachée de la requête : une connexion qui tombe (écran verrouillé, wifi→4G, tunnel, reverse proxy) ne tue plus le travail — le client revient avec le même session_id et l'en-tête Last-Event-ID et reprend où il s'était arrêté (run gardé 30 min, 30 min d'exécution max). {"session_id":…,"cancel":true} abandonne le run (204). Sans Accept: text/event-stream, un seul JSON {reply, error?} en fin de boucle. Les résultats d'outils ne sont jamais transmis au client. |
/webhooks/{instance}/book-event |
POST | Événements catalogue (book.created/updated/deleted/read) émis par nxt-opds. Signature X-Signature: sha256=<hmac> validée contre webhook_secret. |
/trigger/{instance} |
POST | Trigger manuel : body JSON {prompt} ou texte brut. Le prompt remplace l'instruction batch par défaut. |
/audit/{instance} |
POST | Déclenche une passe de cohérence globale hors cadence. Body JSON optionnel {"dry_run": true} (ou ?dry_run=1). |
/instances/{instance}/forget |
POST | Appelé par nxt-opds lors d'un unpair côté UI. Auth : Authorization: Bearer <chat_secret>. |
Questions à choix (ask_user)
En chat, le libraire dispose d'un outil local ask_user pour poser au
lecteur une question à réponses prédéfinies (lequel de plusieurs livres,
quel tome commencer, quel genre, confirmer une action) :
-
arguments :
question(1–300 caractères),options(2 à 8 libellés distincts, non vides, ≤ 80 caractères, jamais « Autre » — le lecteur peut toujours répondre librement),multiple(booléen, défautfalse:true= plusieurs réponses possibles). Des arguments invalides renvoient au modèle une erreur qui rappelle ces limites ; il peut réessayer ; -
un appel valide termine le run après l'étape en cours (les autres outils de l'étape sont exécutés, seul le premier
ask_uservalide compte) : la question est la réponse du tour ; -
client avec
"capabilities":["question"]: une trame{"kind":"question","question":"…","options":["…","…"],"multiple":false}puis lefinalhabituel, sans prose répétant la question (ni trametool_call/tool_resultpourask_user) ; -
client sans la capacité (anciens clients, curl) : la question arrive en prose (
delta, outextpour un backend qui ne streame pas) sous sa forme texte, qui est aussi lereplydu JSON non-streamé :Par quel tome commencer ? 1. Tome 1 2. Tome 2suivie, si
multipleest vrai, d'une ligne vide puis de(plusieurs réponses possibles).
La réponse est un tour normal : history contient le tour assistant dont le
content est cette forme texte, et message vaut le libellé choisi,
plusieurs libellés joints par " ; " dans l'ordre des options, ou le texte
libre du lecteur.
Flags clés
| Flag | Description |
|---|---|
--listen :8080 |
Adresse d'écoute (override le YAML) |
--interval 6h |
Cadence du ticker |
--audit-interval 168h |
Cadence de la passe de cohérence globale (0 la désactive) |
--audit-max-arbitrations 20 |
Clusters ambigus soumis au LLM par passe d'audit (0 = illimité) |
--batch-limit 10 |
Nb de livres traités par tick |
--max-steps 500 |
Étapes max par job (200 par défaut) |
--job-timeout 2h |
Timeout par job (1 h par défaut) — augmenter pour de gros batches |
--prompt "…" |
Prompt remplaçant l'instruction batch par défaut du ticker |
--instance <name> |
Pour run / batch ; quand plusieurs instances sont configurées |
Maintenance en lot (batch)
batch est la commande à utiliser pour les gros chantiers : « note le
piment de tous les 16+ », « enrichi tous les non-indexés », etc. La
pagination tourne dans Go, pas dans le LLM, donc même un petit modèle ne
peut pas couper court en écrivant FIN prématurément.
kvasir batch --instance example --filter age_rating_min=16
Cycle interne :
for offset := 0; ; offset += limit {
ids := search_books(filters, limit, offset)
if len(ids) < limit { break }
for id := range ids {
agent.Run(perBookPrompt(id)) // ~5-10 étapes par livre
}
}
Flags
| Flag | Description |
|---|---|
--filter k=v |
Filtre passé à search_books. Répétable. Types coercés (int/bool/string). |
--limit 50 |
Taille de page (max 100). |
--offset 100 |
Reprend à partir d'un offset arbitraire. |
--max-books 50 |
Plafond global (0 = illimité). Utile pour valider sur un échantillon. |
--max-steps 60 |
Étapes max par livre (5-10 suffisent en général). |
--prompt "…" |
Template par livre, {{ID}} est remplacé. Défaut : enrichissement complet selon le workflow standard. |
--retry-wait 1h |
Pause entre 2 retries après quota / rate-limit / réseau transitoire. |
--max-rate-retries 6 |
Nb de pauses+retry par livre avant abandon de ce livre. |
--dry-run |
Liste les IDs candidats sans invoquer l'agent. |
Exemples
# Lister les candidats sans rien modifier
kvasir batch --instance example --filter age_rating_min=16 --dry-run
# Toute la bibliothèque, en lots de 100, avec une pause d'1 h sur quota
kvasir batch --instance example --filter age_rating_min=16 \
--limit 100 --retry-wait 1h
# Reprendre un batch interrompu (le log "interrompu" donne la valeur d'offset)
kvasir batch --instance example --filter age_rating_min=16 --offset 250
# Tag spécifique
kvasir batch --instance example --filter tag="Dark Romance"
# Échantillon de 10 pour valider un prompt custom
kvasir batch --instance example --filter age_rating_min=16 \
--max-books 10 \
--prompt 'Pour {{ID}} : get_book puis web_fetch Babelio puis update_book(spice_rating:N, last_maintenance_at:-1). Termine par FIN.'
Gestion des quotas LLM
Sur une bibliothèque de plusieurs centaines de livres, Ollama Cloud ou
Anthropic finissent par limiter. Le batch détecte automatiquement HTTP 429,
503, rate limit, overloaded, quota, too many requests, ainsi que les
glitchs réseau transitoires (i/o timeout, connection reset, EOF). Sur
détection :
- log :
[batch ex] id=abc rate-limit (…) — pause 1h0m0s, reprise vers 23:42 (retry 1/6) - sleep
--retry-wait(interruptible Ctrl-C) - retry du même livre
- au-delà de
--max-rate-retries, le livre est abandonné et le loop continue
Ctrl-C pendant une pause : log interrompu — reprendre avec --offset N.
Passe de cohérence globale (audit)
batch regarde un livre à la fois : il ne peut pas voir qu'une série est
écrite « Le Royaume d'Eauroche » sur trois tomes et « Le Royaume d'Eaurroche »
sur le quatrième. audit construit d'abord un inventaire complet du
catalogue, puis réconcilie ce que seule une vue d'ensemble révèle.
kvasir audit --instance example --dry-run # rapport complet, zéro écriture
kvasir audit --instance example # applique les corrections
Ce qu'il corrige
| Cas | Décision |
|---|---|
| Variantes de casse / accents / ponctuation (« science-fiction », « Science-Fiction », « SCIENCE FICTION ») | Déterministe, sans LLM. Pour un tag, le Title Case gagne même s'il est minoritaire (règle d'enrichissement) ; ailleurs c'est la majorité, puis la forme accentuée. |
| Faute de frappe réelle (« Eauroche » / « Eaurroche », un auteur qui perd une lettre) | Un arbitrage LLM par cluster, en lecture seule : le modèle vérifie l'orthographe officielle (Google Books, éditeur, BnF…) et répond CANONIQUE: … ou DISTINCTS. Le nombre de livres n'est qu'un indice — la majorité peut porter la faute. |
series_index décimal (« 1.0 » → « 1 ») |
Déterministe. |
| Deux livres au même tome d'une série, ou un livre d'une série sans tome | Signalé dans le rapport, jamais deviné. |
Les champs audités : series, authors, tags, publishers
(--fields series,tags pour restreindre).
Garde-fous
- Le balayage, le clustering et les écritures sont en Go : un petit modèle
ne peut pas écourter la passe. Le LLM ne sert qu'à trancher une question
bornée à la fois, avec un prompt strictement en lecture seule (aucun
update_bookde sa part). - Une réponse d'arbitrage illisible, ou une orthographe proposée trop éloignée de ce que contient le catalogue, ne fusionne rien : le cluster est reporté au passage suivant.
- Une fusion produit un seul
update_bookpar livre, même quand elle corrige la série et les tags.last_maintenance_atn'est pas touché : un audit corrige l'indexation, il n'enrichit pas — un livre jamais enrichi ne doit pas passer pour traité. - Un livre qui porte déjà la forme canonique n'est pas réécrit : relancer l'audit sur un catalogue propre déclenche zéro écriture.
Flags
| Flag | Description |
|---|---|
--dry-run |
Analyse, arbitrages et rapport complets, sans une seule écriture. |
--fields series,tags |
Restreint les champs audités (défaut : tous). |
--no-llm |
Ne corrige que casse/accents/ponctuation, aucun arbitrage LLM. |
--max-arbitrations 20 |
Plafond de clusters ambigus soumis au LLM par passe (0 = illimité). Le reste est reporté. |
--max-steps 12 |
Étapes max par arbitrage. |
--page-size 100 |
Taille de page du balayage search_books. |
--timeout 2h |
Durée max de la passe. |
En automatique (daemon)
Le daemon lance la passe toutes les audit_interval (défaut 168h, soit
une fois par semaine) :
audit_interval: "168h" # "0" désactive ; un entier nu est lu en heures
À la différence du ticker d'enrichissement, l'audit ne se déclenche pas au démarrage : kvasir est redémarré à chaque auto-update horaire, et rebalayer tout le catalogue à chaque redémarrage ne servirait à rien.
Déclenchement hors cadence :
curl -X POST http://kvasir.lan:8080/audit/example
curl -X POST http://kvasir.lan:8080/audit/example -d '{"dry_run":true}'
Le rapport final atterrit dans le log du daemon :
[audit example] série : « Le Royaume d'Eauroche » ← « Le Royaume d'Eaurroche » (1 livre(s)) [llm]
[audit example] tag : « Science-Fiction » ← « science-fiction » (12 livre(s)), « SCIENCE FICTION » (2 livre(s)) [auto]
[audit example] anomalie series_index_double sur a1b2 (Tome 2) : 2 livre(s) au tome 2 de « Eauroche » : …
[audit example] audit example (appliqué) : 689 livre(s) inspectés, 4 variante(s) fusionnée(s), 1 non tranchée(s), 3 anomalie(s), 17 livre(s) corrigés, 0 échec(s), en 3m12s
Analyse des livres (analyze)
Kvasir lit les EPUB chapitre par chapitre via les outils edda
(get_book_chapters, get_book_text, set_book_analysis) et enregistre un
résumé factuel par chapitre plus une vue d'ensemble. Le chat s'en sert pour
répondre à « résume ce que j'ai lu » sans jamais spoiler : le prompt
interdit de lire ou révéler quoi que ce soit au-delà de la position du
lecteur, transmise par le préfixe « Je lis « T » de A, chapitre « C », à
42 %. » ou par le champ structuré context de la requête /chat
({book_id, chapter, progression} — prioritaire sur le texte).
Configuration :
analysis:
enabled: true # false = pas de passe nocturne (webhook et CLI actifs quand même)
schedule: "03:00" # heure locale HH:MM de la passe nocturne
batch_size: 100 # livres max par passe, les plus anciens d'abord
max_tokens_per_book: 400000 # plafond de tokens (entrée+sortie) par livre, 0 = illimité
model: "" # modèle des analyses sur le backend de l'agent (vide = celui de l'agent)
analysis.model sépare le modèle des analyses de celui du chat : les résumés
n'appellent aucun outil, un modèle sans raisonnement caché y est bien moins
cher et plus rapide. Mesures d'octobre 2026 sur un roman : gemma4:31b-cloud
≈ 0,36 token par caractère et 1 à 3 minutes, glm-5.3-flash:cloud ≈ 0,66 et
~25 minutes. Le contrôle préalable du budget suppose le coût le plus élevé
(0,66) : avec un modèle plus sobre, relever max_tokens_per_book (700 000
laisse passer les livres jusqu'à ~1 million de caractères). Pour kvasir analyze, un -model explicite l'emporte sur analysis.model.
Déclencheurs :
- Webhook
book.analyze(edda relaie le bouton « Analyser ce livre » de la fiche, ou sa demande post-création) ; book.createdsur un EPUB : analyse automatique, dédupliquée avec le webhook si les deux arrivent ensemble ;- Passe nocturne à
analysis.schedule: rattrapage par lots deanalysis.batch_sizelivres non analysés, les plus anciens d'abord, un livre à la fois via la file de jobs. Pas de rattrapage au démarrage (kvasir est redémarré par l'auto-update horaire).
Mécanique : les chapitres de plus de 12 000 caractères sont découpés en
fenêtres paginées (from_progression/max_progression), chaque fenêtre est
résumée (français, 3–6 phrases, personnages introduits), les résumés d'un
même chapitre sont fusionnés, et un « résumé jusqu'ici » roulant (≤ 1500
caractères : les résumés des chapitres les plus récents qui y tiennent, sans
appel au modèle) est porté dans chaque prompt. Chaque complétion est plafonnée
à 8192 tokens, raisonnement caché compris (num_predict sous Ollama) ; une
réponse vide au plafond (modèle qui s'emballe) est retentée une fois, puis le
livre échoue au lieu de brûler le budget. Le budget max_tokens_per_book est
vérifié avant le premier appel avec le coût mesuré d'une analyse (~660
tokens par 1000 caractères de texte : un roman de 600 000 caractères coûte
~400 000 tokens), puis à chaque complétion : un livre qui le dépasse est
ignoré et signalé dans les logs.
CLI (mêmes options que la passe nocturne) :
kvasir analyze --instance example --book 3f2a… # un livre
kvasir analyze --instance example --book 3f2a… --force # ré-analyse même si déjà analysé
kvasir analyze --instance example --all # tout le backlog, les plus anciens d'abord
kvasir analyze --instance example --limit 5 # 5 livres max
kvasir analyze --instance example --limit 5 --max-tokens 200000 # override du plafond par livre
Flags : --book <id>, --all, --limit N, --force, --max-tokens N
(0 = illimité), --timeout 1h (durée max par livre).
Dans le chat, le lecteur peut annoncer sa position ; le champ structuré
context de /chat est prioritaire sur le préfixe textuel :
{"message": "Qui est Fitz ?",
"context": {"book_id": "3f2a…", "chapter": "La chute", "progression": 0.42}}
Run one-shot (run)
Pour les invocations interactives : un livre par titre, un prompt ad-hoc.
# Cibler un livre par titre (positionnel = recherche de titre)
kvasir run --instance example "Le Chevalier et la Phalène"
# Prompt verbatim
kvasir run --instance example --prompt "Liste les 10 derniers livres ajoutés."
# Maintenance pilotée par LLM (alternative au `batch` si on tient à laisser
# l'agent décider de la pagination — plus erratique sur les petits modèles)
kvasir run --instance example \
--prompt "Traite TOUS les livres non indexés un par un, sans limite. Termine par FIN." \
--max-steps 1000
Pour un vrai gros chantier, préférer batch.
Docker / docker-compose
Image distroless ~25 MB, exposée à :8080, config sur volume /config.
# docker-compose.yml fragment
services:
kvasir:
image: git.helheim.net/mimisbrunnr/kvasir-agent:latest
ports: ["8081:8080"]
volumes: ["kvasir-config:/config"]
environment:
KVASIR_CONFIG: /config/config.yaml
KVASIR_BACKEND: anthropic
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
restart: unless-stopped
volumes:
kvasir-config:
Premier lancement (paire avec un nxt-opds dans le même compose) :
docker compose run --rm kvasir pair \
--nxt-opds http://nxt-opds:8080 \
--code XXXX-XXXX --name example \
--librarian-url http://kvasir:8080
docker compose restart kvasir
Un docker-compose.yml complet (nxt-opds + librarian) est fourni dans
../../docker-compose.yml.
Pour une installation propre du stack complet (nxt-opds + librarian, Docker
ou systemd, appairage, vérifications) destinée à un agent ou à un humain qui
suit pas à pas, voir deploy/AGENT-INSTALL.md.
Logs
Le daemon log toutes les étapes du loop agent (chat et batch/webhook/ticker) :
[chat example] ◀ 192.168.1.42 (scope=user, history=4): Quel est mon dernier livre ?
[chat example] tool_call search_books {"limit":1,"sort":"added_desc"}
[chat example] tool_result search_books [ok] Trouvé 689 livre(s)…
[chat example] text: Votre dernier livre ajouté est « Roi Sorcier » de Martha Wells.
[chat example] done in 1.4s (steps=2, tools=1, stop=end_turn)
[chat example] ▶ reply (78 chars, tools=1): Votre dernier livre…
[example job webhook] start: Un nouveau livre vient d'être ajouté…
[example job webhook] tool_call get_book {"id":"abc123"}
[example job webhook] tool_result get_book [ok] **Titre…
[example job webhook] done in 8.2s (tools=4)
[batch example] page offset=100 limit=50 → 50 IDs (total estimé 689)
[batch example] ▶ 101/689 id=d34638c2c8d81822
[batch example] id=d34638c2c8d81822 rate-limit (ollama 429: …) — pause 1h0m0s, reprise vers 23:42:15 (retry 1/6)
Architecture interne
cmd/kvasir/ # CLI : sous-commandes run/serve/batch/audit/pair/unpair/update
internal/config/ # parse YAML, validate slugs, ResolveLibrarianURL, NxtOPDSBaseURL
internal/instances/ # Registry : map nom→Entry{Client, Agent, Lock, Jobs}, lazy init
internal/mcp/ # client MCP HTTP Streamable (JSON-RPC + SSE) ; WithBearer pour le scoping user
internal/llm/ # interface Provider + backends Ollama et Anthropic
internal/agent/ # boucle tool-calling, system prompt batch + chat + audit, Emit hook pour SSE
internal/audit/ # passe de cohérence globale : inventaire, clustering des variantes, patchs
internal/daemon/ # ticker + ticker d'audit + serveur HTTP + workers par instance + announce + heartbeat
internal/updater/ # self-update via Forgejo releases
Points d'attention :
- Une seule goroutine worker par instance : les jobs sont sérialisés par
instance (le
transcriptdu modèle n'est pas thread-safe) mais parallèles entre instances. - Le
Modede l'agent (ModeBatch/ModeChat/ModeAudit) choisit dynamiquement le system prompt pour ne pas confondre l'enrichissement autonome, le chat conversationnel et l'arbitrage d'orthographe en lecture seule. - Le job d'audit ne garde pas
entry.Lockpendant toute la passe : le balayage et lesupdate_booksont du MCP pur, et l'arbitre prend le lock autour de chaque question au LLM. Le tenir gèlerait la chat box pendant des heures (et bloquerait l'arbitre,sync.Mutexn'étant pas réentrant). - Côté chat,
mcp.WithBearer(ctx, user_token)injecte le token utilisateur du flux nxt-opds → tools per-user (list_to_read,list_wishlist, etc.) scopent automatiquement au compte connecté.
Self-update
kvasir update # télécharge et installe la dernière release
kvasir update --dry-run # voir la version cible sans rien faire
kvasir update --force # forcer la réinstallation
Cible : git.helheim.net/api/v1/repos/mimisbrunnr/kvasir-agent/releases/latest.
L'asset correspondant à GOOS-GOARCH est rename'é atomiquement sur le
binaire courant.
Auto-update horaire (systemd)
deploy/systemd/ fournit un timer qui vérifie une nouvelle release toutes
les heures et ne redémarre le daemon que si une version a réellement été
installée (un update sans nouveauté ne coupe pas un batch en cours) :
sudo install -m 0755 deploy/systemd/kvasir-autoupdate /usr/local/bin/
sudo install -m 0644 deploy/systemd/kvasir-update.service /etc/systemd/system/
sudo install -m 0644 deploy/systemd/kvasir-update.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now kvasir-update.timer
Adapter le chemin du binaire ou le nom de l'unité serve (défauts
/usr/local/bin/kvasir et kvasir.service) via Environment= dans
kvasir-update.service.
Vérifier / tester :
systemctl list-timers kvasir-update.timer # prochaine échéance
sudo systemctl start kvasir-update.service # forcer un cycle maintenant
journalctl -u kvasir-update.service # logs (version installée ou « déjà à jour »)
Le wrapper tourne en root (il écrase le binaire et appelle systemctl). La
vérification anonyme de l'API Forgejo suffit largement à une cadence
horaire.
Licence
Voir LICENSE.