No description
  • Go 99.4%
  • Dockerfile 0.4%
  • Shell 0.2%
Find a file
Bastien Quelen e2f2bc5b2e ci: build et publication uniquement sur tag v*
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.
2026-10-13 21:33:00 +02:00
.forgejo/workflows ci: build et publication uniquement sur tag v* 2026-10-13 21:33:00 +02:00
cmd/kvasir feat(analysis): clé analysis.model, modèle des analyses distinct de celui du chat (v0.63) 2026-10-10 11:16:17 +02:00
deploy docs: aligne le runbook de déploiement sur edda/kvasir + forge 2026-06-27 19:54:46 +02:00
internal feat(chat): questions à choix du libraire, outil ask_user (v0.64) 2026-10-11 21:39:20 +02:00
.dockerignore refactor: renomme librarian → kvasir-agent (org mimisbrunnr, forge git.helheim.net) 2026-06-27 16:32:37 +02:00
.gitignore refactor: renomme librarian → kvasir-agent (org mimisbrunnr, forge git.helheim.net) 2026-06-27 16:32:37 +02:00
CLAUDE.md docs: utiliser l'agent golang-pro + corrige refs self-update (kvasir update / forge) 2026-06-28 09:54:21 +02:00
Dockerfile refactor: renomme librarian → kvasir-agent (org mimisbrunnr, forge git.helheim.net) 2026-06-27 16:32:37 +02:00
go.mod refactor: renomme librarian → kvasir-agent (org mimisbrunnr, forge git.helheim.net) 2026-06-27 16:32:37 +02:00
go.sum feat: multi-instance librarian with YAML config and pair/unpair flow 2026-05-16 09:37:46 +02:00
LICENSE Licence MIT — © 2026 Bastien Quelen 2026-10-05 20:25:06 +02:00
README.md feat(chat): questions à choix du libraire, outil ask_user (v0.64) 2026-10-11 21:39:20 +02:00

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

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

  1. Dans l'admin nxt-opds : cliquer « Associer un librarian ». Un code XXXX-XXXX valide 10 minutes s'affiche.

  2. 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.yaml est créé / mis à jour ; nxt-opds stocke librarian_url cô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 :

  • description n'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_books est le cas typique. Celui du catalogue garde le nom nu, celui du serveur additionnel devient <nom-du-serveur>_search_books et 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 audit n'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 npx ne 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 : --cwd dé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 le cwd intact. 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 un PATH minimal : npx ou uvx installé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 test tourne dans TON shell : il peut réussir là où le daemon échouera — vérifie avec systemctl 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_MODEL ou --model.
  • Anthropic (Claude) — activé si ANTHROPIC_API_KEY est 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 :

  1. Charge le YAML, instancie un Registry d'instances (clients MCP + Agent paresseusement initialisés).
  2. Announce : POST /api/librarian/announce sur chaque nxt-opds appairé avec le public_url courant — auto-réparation après changement de port/hostname/docker network.
  3. Heartbeat : ticker 60 s qui POST /api/librarian/heartbeat pour que l'admin UI nxt-opds montre la fraîcheur de la liaison.
  4. Ticker batch : toutes les interval (défaut 6 h), enqueue un job search_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éfaut false : 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_user valide compte) : la question est la réponse du tour ;

  • client avec "capabilities":["question"] : une trame {"kind":"question","question":"…","options":["…","…"],"multiple":false} puis le final habituel, sans prose répétant la question (ni trame tool_call/tool_result pour ask_user) ;

  • client sans la capacité (anciens clients, curl) : la question arrive en prose (delta, ou text pour un backend qui ne streame pas) sous sa forme texte, qui est aussi le reply du JSON non-streamé :

    Par quel tome commencer ?
    
    1. Tome 1
    2. Tome 2
    

    suivie, si multiple est 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 :

  1. log : [batch ex] id=abc rate-limit (…) — pause 1h0m0s, reprise vers 23:42 (retry 1/6)
  2. sleep --retry-wait (interruptible Ctrl-C)
  3. retry du même livre
  4. 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_book de 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_book par livre, même quand elle corrige la série et les tags. last_maintenance_at n'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.created sur un EPUB : analyse automatique, dédupliquée avec le webhook si les deux arrivent ensemble ;
  • Passe nocturne à analysis.schedule : rattrapage par lots de analysis.batch_size livres 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 transcript du modèle n'est pas thread-safe) mais parallèles entre instances.
  • Le Mode de 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.Lock pendant toute la passe : le balayage et les update_book sont 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.Mutex n'é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.