feat(ai): assistant AI multi-contexte + ergonomie sidebar et clavier
CI / lint (push) Successful in 1m12s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m10s
CI / build (push) Successful in 35s
CI / e2e (push) Successful in 11m47s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

Contexte:
- Repertoire (mode historique BooksLM, via menu contextuel dossier)
- Documents (bouton flottant quand des documents sont ouverts)
- General (bouton flottant sans document: aide app + creation de fichiers)
- Le contexte est affiche dans le header; suggestions adaptees au mode
- Blocs obsigate-action rendus en carte avec bouton Appliquer (pas d'execution auto)

Backend:
- /api/ai/bookslm/context et /chat acceptent mode + context_files
- collect_files_context(), empty_context(), build_general_system_prompt()
- mode documents degrade vers general si aucun fichier lisible

Corrige:
- bouton flottant passait le chemin d'un fichier comme repertoire -> erreur
  'Aucun fichier markdown trouve dans ce dossier'
- panneau precedent masque (localStorage) ne se rouvrait plus
- selecteur fournisseur/modele deplace sur une ligne dediee (header actions visibles)
- Entree envoie, Ctrl+Entree insere un saut de ligne

Tests: test_bookslm.py (+13) et tests/frontend/ai.test.mjs (+8)
This commit is contained in:
2026-09-11 09:11:00 -04:00
parent 62023acd4d
commit d6fdcef4d4
14 changed files with 1049 additions and 214 deletions
+172
View File
@@ -574,3 +574,175 @@ class TestBooksLMChatEndpoint:
finally:
import shutil
shutil.rmtree(sub, ignore_errors=True)
# ── Unit tests: explicit documents context + general prompt ────────────
class TestCollectFilesContext:
"""Tests for collect_files_context() (Documents mode)."""
def test_collects_only_requested_files(self, tmp_path):
from backend.bookslm import collect_files_context
vault = tmp_path / "vault"
(vault / "sub").mkdir(parents=True)
(vault / "a.md").write_text("# A", encoding="utf-8")
(vault / "b.md").write_text("# B", encoding="utf-8")
(vault / "sub" / "c.md").write_text("# C", encoding="utf-8")
result = collect_files_context(vault, ["a.md", "sub/c.md"], scope="documents")
assert result["scope"] == "documents"
assert result["file_count"] == 2
assert [f["path"] for f in result["files"]] == ["a.md", "sub/c.md"]
assert result["directory_tree"] == ""
def test_ignores_paths_outside_vault(self, tmp_path):
from backend.bookslm import collect_files_context
vault = tmp_path / "vault"
vault.mkdir()
(vault / "ok.md").write_text("# ok", encoding="utf-8")
outside = tmp_path / "outside.md"
outside.write_text("# secret", encoding="utf-8")
result = collect_files_context(vault, ["../outside.md", "ok.md"])
assert result["file_count"] == 1
assert result["files"][0]["path"] == "ok.md"
def test_deduplicates_paths(self, tmp_path):
from backend.bookslm import collect_files_context
vault = tmp_path / "vault"
vault.mkdir()
(vault / "dup.md").write_text("# dup", encoding="utf-8")
result = collect_files_context(vault, ["dup.md", "dup.md"])
assert result["file_count"] == 1
def test_empty_context(self):
from backend.bookslm import empty_context
ctx = empty_context("general")
assert ctx["file_count"] == 0
assert ctx["files"] == []
assert ctx["scope"] == "general"
class TestGeneralPrompt:
def test_general_prompt_lists_vaults(self):
from backend.bookslm import build_general_system_prompt
prompt = build_general_system_prompt(["Alpha", "Beta"])
assert "Alpha" in prompt
assert "Beta" in prompt
assert "obsigate-action" in prompt
def test_documents_prompt_scope(self):
from backend.bookslm import build_system_prompt
context = {
"files": [{"path": "a.md", "title": "A", "content": "hello"}],
"file_count": 1,
"total_chars": 5,
"directory_tree": "",
}
prompt = build_system_prompt(context, scope="documents")
assert "Documents ouverts" in prompt
assert "hello" in prompt
class TestBooksLMContextModes:
"""Tests for the mode-aware /context endpoint."""
def test_general_mode_needs_no_vault(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"mode": "general"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["scope"] == "general"
assert data["file_count"] == 0
assert data["files"] == []
def test_documents_mode_returns_selected_files(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={
"mode": "documents",
"vault": "TestVault",
"context_files": ["notes/note1.md"],
},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["scope"] == "documents"
assert data["file_count"] == 1
assert data["files"][0]["path"] == "notes/note1.md"
def test_documents_mode_without_files_degrades_to_general(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"mode": "documents", "vault": "TestVault", "context_files": []},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["scope"] == "general"
assert data["file_count"] == 0
def test_documents_mode_requires_vault(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"mode": "documents", "context_files": ["notes/note1.md"]},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 400
def test_unknown_mode_falls_back_to_directory(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/context",
json={"mode": "bogus", "vault": "TestVault", "directory": "notes"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["scope"] == "directory"
class TestBooksLMChatModes:
def test_general_chat_does_not_require_vault(self, bookslm_client):
"""General mode must not 400/404 on a missing vault (only 500 if no key)."""
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={"mode": "general", "message": "Bonjour"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
assert resp.status_code != 422
def test_documents_chat_accepts_context_files(self, bookslm_client):
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/chat",
json={
"mode": "documents",
"vault": "TestVault",
"context_files": ["notes/note1.md"],
"message": "Résume",
},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (200, 500), f"unexpected {resp.status_code}: {resp.text[:200]}"
assert resp.status_code != 422