feat(agent): v4.15.0 - panneau Agent style Notion AI (mentions @ inline + skills / + actions sous chaque réponse)
- Composeur contenteditable : mentions inline (pastilles icône+nom), barre de contexte groupée documents/skills, sélecteur @ (icône+titre+chemin), skills intégrés + agent_skills + commandes admin via '/' - Actions sous chaque réponse agent : copier, insérer dans la page, feedback 👍/👎 - Backend : table agent_feedback, GET /api/agent/mentions, POST /api/agent/feedback, contexte document:<id>/page:<id>/collection:<id> résolu en contenu réel - Inclut le travail v4.14.0 (documents/espace de travail + outils + auto-titre de conversation)
This commit is contained in:
+210
-2
@@ -98,11 +98,28 @@ def test_llm_client_offline_no_credentials(client):
|
||||
assert resp.text != "" or resp.tool_calls
|
||||
|
||||
|
||||
def test_llm_client_offline_mock_creates_document(client):
|
||||
"""A document creation request must resolve to the create_document tool."""
|
||||
from app.services.llm_client import LLMClient
|
||||
llm = LLMClient(provider="offline")
|
||||
resp = asyncio.run(llm.complete(
|
||||
[{"role": "user", "content": 'Faire la création d\'un document "Projet" '
|
||||
'dans le workspace "TEST WS 1"'}],
|
||||
tools=[],
|
||||
))
|
||||
assert resp.tool_calls
|
||||
assert resp.tool_calls[0]["name"] == "create_document"
|
||||
args = resp.tool_calls[0]["arguments"]
|
||||
assert args["title"] == "Projet"
|
||||
assert args["workspace_name"] == "TEST WS 1"
|
||||
|
||||
|
||||
# ── DB schema ──
|
||||
|
||||
def test_agent_tables_exist(client):
|
||||
tables = {"agents", "agent_conversations", "agent_messages",
|
||||
"agent_actions", "agent_skills", "agent_triggers"}
|
||||
"agent_actions", "agent_skills", "agent_triggers",
|
||||
"agent_feedback"}
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -145,6 +162,86 @@ def test_tool_unknown_returns_error(client):
|
||||
assert res.status == "error"
|
||||
|
||||
|
||||
def test_tool_workspace_document_crud_and_search(client):
|
||||
from app.db import get_conn
|
||||
from app.services.tool_registry import ToolRegistry
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('TEST WS 1', ?)",
|
||||
(_admin_id(),))
|
||||
conn.commit()
|
||||
|
||||
reg = ToolRegistry()
|
||||
|
||||
ws = asyncio.run(reg.execute("read_workspaces", {}))
|
||||
assert ws.status == "success"
|
||||
assert any(w["name"] == "TEST WS 1" for w in ws.data["workspaces"])
|
||||
|
||||
res = asyncio.run(reg.execute("create_document",
|
||||
{"title": "Projet", "workspace_name": "TEST WS 1"}))
|
||||
assert res.status == "success", res.message
|
||||
pid = res.data["document_id"]
|
||||
assert res.target_type == "document"
|
||||
assert res.undo == {"action": "delete", "table": "pages", "id": pid}
|
||||
|
||||
rd = asyncio.run(reg.execute("read_document", {"page_id": pid}))
|
||||
assert rd.status == "success"
|
||||
assert rd.data["document"]["title"] == "Projet"
|
||||
|
||||
ws2 = asyncio.run(reg.execute("read_workspaces", {}))
|
||||
row = next(w for w in ws2.data["workspaces"] if w["name"] == "TEST WS 1")
|
||||
assert row["document_count"] == 1
|
||||
|
||||
search = asyncio.run(reg.execute("search_workspace", {"query": "Projet"}))
|
||||
assert any(d["id"] == pid for d in search.data["documents"])
|
||||
|
||||
# write_blocks can also rename the document
|
||||
upd = asyncio.run(reg.execute("write_blocks", {"page_id": pid, "title": "Projet v2"}))
|
||||
assert upd.status == "success"
|
||||
rd2 = asyncio.run(reg.execute("read_document", {"page_id": pid}))
|
||||
assert rd2.data["document"]["title"] == "Projet v2"
|
||||
|
||||
# unknown workspace name → clear error, no document created
|
||||
bad = asyncio.run(reg.execute("create_document",
|
||||
{"title": "X", "workspace_name": "NOPE"}))
|
||||
assert bad.status == "error"
|
||||
assert "introuvable" in bad.message
|
||||
|
||||
|
||||
def test_tool_delete_document_and_undo(client):
|
||||
from app.db import get_conn
|
||||
from app.services.tool_registry import ToolRegistry
|
||||
from app.services.agent_engine import undo_action
|
||||
|
||||
reg = ToolRegistry()
|
||||
res = asyncio.run(reg.execute("create_document", {"title": "À supprimer"}))
|
||||
pid = res.data["document_id"]
|
||||
|
||||
dele = asyncio.run(reg.execute("delete_document", {"page_id": pid}))
|
||||
assert dele.status == "success"
|
||||
assert dele.undo == {"action": "softdelete", "table": "pages", "id": pid}
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
assert row is not None and row["deleted_at"] is not None
|
||||
|
||||
# store an audit row so undo_action can restore the document
|
||||
conv_id = client.post("/api/agent/conversations", json={"title": "t"}).json()["id"]
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_actions (conversation_id, tool_name, target_type,
|
||||
target_id, payload_json, result_json, status, undo_snapshot_json, executed_by)
|
||||
VALUES (?, 'delete_document', 'document', ?, '{}', '{}', 'success', ?, ?)""",
|
||||
(conv_id, str(pid), json.dumps(dele.undo), _admin_id()),
|
||||
)
|
||||
conn.commit()
|
||||
aid = cur.lastrowid
|
||||
|
||||
assert undo_action(aid) is True
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
assert row["deleted_at"] is None
|
||||
|
||||
|
||||
# ── Permissions ──
|
||||
|
||||
def test_permission_manager_roles(client):
|
||||
@@ -226,6 +323,37 @@ def test_engine_audit_and_undo(client):
|
||||
assert a["status"] == "reverted"
|
||||
|
||||
|
||||
def test_engine_run_creates_document_in_workspace_and_titles_conversation(client):
|
||||
from app.services.agent_engine import AgentEngine
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('TEST WS 1', ?)",
|
||||
(_admin_id(),))
|
||||
conn.commit()
|
||||
|
||||
engine = AgentEngine(_admin_id(), workspace_id=None)
|
||||
conv = client.post("/api/agent/conversations",
|
||||
json={"title": "Nouvelle conversation"}).json()["id"]
|
||||
objective = 'Faire la création d\'un document « Projet » dans le workspace « TEST WS 1 »'
|
||||
events = asyncio.run(_run_engine(engine, conv, objective))
|
||||
|
||||
actions = [e for e in events if e["type"] == "action"]
|
||||
assert any(a["tool"] == "create_document" and a["status"] == "success" for a in actions), actions
|
||||
|
||||
with get_conn() as conn:
|
||||
doc = conn.execute("SELECT * FROM pages WHERE title='Projet'").fetchone()
|
||||
ws = conn.execute("SELECT id FROM workspaces WHERE name='TEST WS 1'").fetchone()
|
||||
assert doc is not None
|
||||
assert doc["workspace_id"] == ws["id"]
|
||||
|
||||
# the conversation is renamed with a descriptive title
|
||||
detail = client.get(f"/api/agent/conversations/{conv}").json()
|
||||
title = detail["conversation"]["title"]
|
||||
assert title.lower() != "nouvelle conversation"
|
||||
assert "Projet" in title
|
||||
|
||||
|
||||
def _make_conversation(client) -> int:
|
||||
resp = client.post("/api/agent/conversations", json={"title": "test"})
|
||||
return resp.json()["id"]
|
||||
@@ -306,12 +434,92 @@ def test_router_skills(client):
|
||||
assert apply.json()["conversation_id"]
|
||||
|
||||
|
||||
def test_router_mentions(client):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format) "
|
||||
"VALUES ('', 'Compte rendu projet', '## Résumé\nDécision prise.', 'markdown')")
|
||||
doc_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.execute("INSERT INTO collections (name, icon) VALUES ('Roadmap', '🗺️')")
|
||||
coll_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, property_values_json) "
|
||||
"VALUES (?, 'Page Analyse', '{}')", (coll_id,))
|
||||
page_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.commit()
|
||||
|
||||
r = client.get("/api/agent/mentions")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["mentions"]
|
||||
tokens = {it["token"] for it in items}
|
||||
assert f"document:{doc_id}" in tokens
|
||||
assert f"collection:{coll_id}" in tokens
|
||||
assert f"page:{page_id}" in tokens
|
||||
|
||||
# search filter
|
||||
r2 = client.get("/api/agent/mentions", params={"q": "Compte rendu"})
|
||||
found = r2.json()["mentions"]
|
||||
assert found and all(it["type"] == "document" and it["label"] == "Compte rendu projet" for it in found)
|
||||
|
||||
|
||||
def test_router_feedback(client):
|
||||
cid = _make_conversation(client)
|
||||
ok = client.post("/api/agent/feedback", json={
|
||||
"rating": "up", "conversation_id": cid, "content": "Merci, très utile",
|
||||
})
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["status"] == "recorded"
|
||||
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT rating, snippet FROM agent_feedback").fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["rating"] == "up"
|
||||
assert rows[0]["snippet"] == "Merci, très utile"
|
||||
|
||||
bad = client.post("/api/agent/feedback", json={"rating": "meh"})
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
def test_context_builder_document_mention(client):
|
||||
from app.db import get_conn
|
||||
from app.services.context_builder import ContextBuilder
|
||||
import json as _json
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format) "
|
||||
"VALUES ('ws', 'Note brainstorming', 'Contenu ultra secret du doc.', 'markdown')")
|
||||
mid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format) "
|
||||
"VALUES ('ws', 'Doc blocs', ?, 'blocks')",
|
||||
(_json.dumps([
|
||||
{"id": "b1", "type": "heading", "content": "Titre du bloc"},
|
||||
{"id": "b2", "type": "paragraph", "content": "Paragraphe du bloc.",
|
||||
"children": [{"id": "b3", "type": "paragraph", "content": "Enfant aussi."}]},
|
||||
]),))
|
||||
bid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.commit()
|
||||
|
||||
ctx = ContextBuilder(_admin_id()).build(mentions=[f"document:{mid}"])
|
||||
assert "Note brainstorming" in ctx
|
||||
assert "Contenu ultra secret du doc." in ctx
|
||||
|
||||
ctx2 = ContextBuilder(_admin_id()).build(mentions=[f"document:{bid}"])
|
||||
assert "Titre du bloc" in ctx2
|
||||
assert "Paragraphe du bloc." in ctx2
|
||||
assert "Enfant aussi." in ctx2
|
||||
|
||||
|
||||
def test_router_tools_and_providers(client):
|
||||
tools = client.get("/api/agent/tools").json()["tools"]
|
||||
assert len(tools) >= 10
|
||||
names = {t["name"] for t in tools}
|
||||
for expected in ("search_workspace", "create_collection", "create_page",
|
||||
"update_page", "add_property", "create_view", "read_page"):
|
||||
"update_page", "add_property", "create_view", "read_page",
|
||||
"read_workspaces", "read_document", "create_document"):
|
||||
assert expected in names
|
||||
|
||||
prov = client.get("/api/agent/providers").json()
|
||||
|
||||
Reference in New Issue
Block a user