From 75b7f29826f6cc935658f88f037e16462dd0f30f Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 6 Sep 2026 01:14:52 -0400 Subject: [PATCH] fix(agent): v4.13.1 - actions contenu document (resume/traduire/ameliorer/meeting) sans outils, apercu + appliquer/rejeter, mock ignore contexte --- CHANGELOG.md | 26 ++++++ VERSION | 2 +- app/main.py | 2 +- app/routers/agent.py | 56 ++++++++++++ app/services/llm_client.py | 39 ++++++-- app/templates/_page_editor_scripts.html | 17 +++- app/templates/agent_panel.html | 116 ++++++++++++++++++++++-- tests/test_agent.py | 51 ++++++++++- 8 files changed, 291 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9993b4..c7ed8b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog — FlowDeck +## v4.13.1 (2026-09-06) — Corrections actions contenu Agent & aperçu à approuver + +> Corrige les actions de contenu sur document (« Résumer », « Traduire cette page », « Proposer +> des améliorations », « Créer une note de réunion ») qui partaient en recherche (`search_workspace`) +> sans retour utile, et ajoute un flux **aperçu des changements → Appliquer / Rejeter**. + +### Fix +- **Le contenu du document n'est plus interprété comme des intentions d'outil** : le planificateur + (mock) n'analyse que l'objectif de l'utilisateur, jamais le texte injecté dans `# Contexte`. Un + document contenant « recherche », « collection », etc. ne déclenche plus `search_workspace` / + `create_collection` intempestifs. +- **Ask AI** n'emprunte plus le canal outil : il utilise la génération de texte directe. + +### Added +- **Endpoint `POST /api/agent/generate`** : génération de texte **sans outils** (l'IA répond à + partir du contexte du document fourni, sans chercher/créer dans le workspace). +- **Aperçu & approbation** : les actions de contenu sur document affichent la **proposition** de + l'agent dans le panneau avec boutons **« ✓ Appliquer au document » / « ✕ Rejeter »**. Appliquer + insère la proposition (mode `insert`) ou **remplace** le contenu de la page (mode `replace` pour + « Traduire cette page ») via `window.fdApplyDocument()` ; rejeter la conserve sans modification. + En mode hors-ligne, l'action explique que la modification nécessite un modèle connecté (pas + d'application accidentelle). + +### Infra +- `VERSION` → 4.13.1 ; `app/main.py` version → 4.13.1. + ## v4.13.0 (2026-09-06) — Agent : panneau « Notion AI », ouverture fiable (Ctrl+J) & contexte universel > Refonte complète du panneau Agent (style Notion AI) : ouverture/fermeture **fiable** (API diff --git a/VERSION b/VERSION index 813b83b..56d2fb5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.13.0 +4.13.1 diff --git a/app/main.py b/app/main.py index 03363bb..ead0cca 100644 --- a/app/main.py +++ b/app/main.py @@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="4.13.0", + version="4.13.1", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/agent.py b/app/routers/agent.py index 29e693f..1b53507 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -310,6 +310,62 @@ async def run_conversation(request: Request, conversation_id: int): ) +@router.post("/generate") +async def agent_generate(request: Request): + """Headless text generation (NO tools) for content actions on documents. + + Used by the Agent panel's contextual actions ("Résumer ce document", + "Traduire cette page", "Proposer des améliorations", "AI meeting note"). + Because no tool schema is offered, the model answers with plain text based on + the provided document context instead of issuing search_workspace / tools. + """ + user_id = await _current_user_id(request) + body = await request.json() if request.headers.get("content-type") else {} + prompt = (body.get("prompt") or "").strip() + if not prompt: + raise HTTPException(status_code=400, detail="prompt est requis") + provider = (body.get("provider") or "").strip().lower() or None + model = (body.get("model") or "").strip() or None + context = (body.get("context") or "") or "" + + llm = LLMClient() + if provider: + if provider not in PROVIDERS: + raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}") + user_key = get_user_llm_key(user_id, provider) + if user_key and user_key.get("api_key"): + llm = LLMClient(provider=provider, api_key=user_key["api_key"], + api_base=(user_key.get("api_base") or "").strip() or None) + else: + llm = LLMClient(provider=provider) + offline = llm.provider == "offline" or not llm._has_credentials() + + system = ( + "Tu es l'assistant d'édition de FlowDeck (contenu). " + "Réponds UNIQUEMENT avec le texte demandé, en Markdown léger " + "(paragraphes, listes à puces, titres si utile). " + "N'ajoute aucun préambule, aucun commentaire, aucun bloc de code autour du texte. " + "Fonde ta réponse sur le contexte du document fourni." + ) + user_content = prompt + if context and context.strip(): + user_content += "\n\n# Contexte du document\n" + context.strip()[:20000] + messages = [ + {"role": "system", "content": system}, + {"role": "user", "content": user_content}, + ] + try: + resp = await llm.complete(messages, model=model or None, tools=None, stream=False) + except Exception as exc: # noqa: BLE001 — surface connectivity errors + return {"ok": False, "error": str(exc), "offline": offline, "model": model or ""} + return { + "ok": True, + "text": (resp.text or "").strip(), + "model": resp.model or model or "", + "offline": offline, + } + + # ── Audit & rollback ── diff --git a/app/services/llm_client.py b/app/services/llm_client.py index d6415ae..ca61b7c 100644 --- a/app/services/llm_client.py +++ b/app/services/llm_client.py @@ -190,6 +190,11 @@ class LLMClient: async def _mock_complete(self, messages, model, tools) -> LLMResponse: user_content = self._last_user_content(messages) sys_content = self._system_content(messages) + # Only the user's objective drives the planner. The engine appends the + # workspace/document snapshot under "# Contexte"; that text must never + # trigger keyword heuristics (a doc mentioning "recherche"/"collection" + # used to misroute content requests into tool calls). + objective = user_content.split("\n# Contexte")[0] # Once tool results are already in the conversation, we have acted: # stop issuing new tool calls and conclude. @@ -200,7 +205,7 @@ class LLMClient: ) # Skill-driven: if the objective names a known skill, mirror its template. - skill_hint = self._extract_skill_hint(user_content) + skill_hint = self._extract_skill_hint(objective) if skill_hint == "sprint": return LLMResponse( @@ -218,13 +223,13 @@ class LLMClient: # before the tool-intent heuristics and scoped to the user objective # only, so an injected "# Contexte" that happens to mention "collection" # can't misroute a writing request into a create-collection action. - draft = self._draft_reply(user_content.split("\n# Contexte")[0]) + draft = self._draft_reply(objective) if draft: return LLMResponse(text=draft, model=model) - # Exact keyword → tool intent resolution. + # Exact keyword → tool intent resolution (objective only). for regex, builder in _CREATE_PATTERNS: - m = regex.search(user_content) + m = regex.search(objective) if m: return LLMResponse( tool_calls=[dict(name=name, arguments=self._bind_placeholders(args, sys_content)) for name, args in [builder(m)]], @@ -233,7 +238,7 @@ class LLMClient: ) for regex, builder in _SEARCH_PATTERNS: - m = regex.search(user_content) + m = regex.search(objective) if m: return LLMResponse( tool_calls=[dict(name=name, arguments=args) for name, args in [builder(m)]], @@ -242,9 +247,9 @@ class LLMClient: ) # Loose "collection " detection → treat as a create intent. - low = user_content.lower() + low = objective.lower() if "collection" in low: - m = _COLLECTION_LINE.search(user_content) + m = _COLLECTION_LINE.search(objective) if m: name = m.group(1).strip() return LLMResponse( @@ -256,7 +261,7 @@ class LLMClient: # Plain conversational objective → final answer (no tool). return LLMResponse( - text=self._summarize(user_content), + text=self._summarize(objective), model=model, ) @@ -335,6 +340,24 @@ class LLMClient: "• Planifier le suivi et archiver ce compte-rendu.\n" ) + # ── Traduction / analyse du document (hors-ligne) ── + if re.search(r"traduis|traduit|translate", low): + return ( + "⚠️ **Traduction non disponible en mode hors-ligne** (aucun modèle d'IA " + "connecté).\n\n" + "Connectez un fournisseur dans **Paramètres → Agent & IA**, puis relancez " + "« Traduire cette page » : le document traduit apparaîtra ici, avec un aperçu " + "à approuver ou à rejeter avant application." + ) + if re.search(r"r[ée]sum|am[ée]lior|sugg[èe]re des|propose des", low): + return ( + "⚠️ **Cette action nécessite un modèle d'IA connecté** pour analyser le " + "document.\n\n" + "Configurez une clé API dans **Paramètres → Agent & IA**, puis relancez " + "l'action : l'agent générera la proposition ici, avec un aperçu à approuver " + "ou à rejeter avant application." + ) + # ── Page / document draft (contextual "Ask AI") ── if title: t = title[:80] diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index 73e0c77..a7aac3b 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -629,8 +629,23 @@ // (Notion-style inline generation). The reply is streamed in the agent // panel and inserted into this page via window.__fdInsertAI. askAI(){ + const self=this; const title=(this.pageTitle||'Sans titre').trim(); - this.aiRun('Rédige le contenu de la page FlowDeck intitulée « '+title+' ». Fournis un texte structuré et directement utilisable : paragraphes et, si utile, des listes à puces. Sépare chaque paragraphe par une ligne vide. Ne précède pas le texte de commentaires.'); + if(!(window.fdAgent && window.fdAgent.generate)){ self.showToast('Agent IA indisponible — configurez-le dans Paramètres → Agent & IA'); return; } + if(self.aiBusy) return; + self.aiBusy=true; + self.showToast('FlowDeck Agent : génération en cours…'); + let ctx=''; + try{ if(window.fdGetPageContext) ctx=String(window.fdGetPageContext()||''); }catch(e){} + const prompt='Rédige le contenu de la page FlowDeck intitulée « '+title+' ». Fournis un texte structuré et directement utilisable : paragraphes et, si utile, des listes à puces. Sépare chaque paragraphe par une ligne vide. Ne précède pas le texte de commentaires.'; + window.fdAgent.generate(prompt, ctx).then(function(text){ + self.aiBusy=false; + if(text && text.trim()){ self.applyAIBlocks(text); } + else { self.showToast('Aucun retour généré par l\u2019agent'); } + }).catch(function(err){ + self.aiBusy=false; + self.showToast((err && err.message) ? err.message : 'Erreur de génération'); + }); }, meetingNote(){ // v4.12.0: crée une vraie « boîte de réunion » (bloc 'meeting') au lieu diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index 675b543..23ee13c 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -55,6 +55,13 @@ .fd-agent-msg-meta{font-size:10px;color:var(--text-dim,#888);margin-bottom:2px;text-transform:uppercase;letter-spacing:.5px} .fd-agent-msg-content{font-size:13.5px;color:var(--text,#ececec);white-space:pre-wrap;word-break:break-word;line-height:1.55} .fd-agent-msg-content a{color:var(--accent)} +.fd-agent-msg-proposal .fd-agent-msg-content{background:rgba(35,131,226,.07);border:1px solid rgba(35,131,226,.22);border-radius:10px;padding:10px 12px} +.fd-proposal-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px} +.fd-proposal-btn{background:var(--bg-tertiary,#242424);border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text,#e6e6e6);border-radius:8px;padding:6px 12px;font-size:12.5px;cursor:pointer} +.fd-proposal-btn:hover{background:var(--bg-hover,#2e2e2e)} +.fd-proposal-btn.primary{background:var(--accent,#2383E2);border-color:transparent;color:#fff;font-weight:600} +.fd-proposal-btn:disabled{opacity:.5;cursor:not-allowed} +.fd-proposal-note{font-size:11px;color:var(--text-dim,#999)} .fd-agent-thinking{font-size:12.5px;color:var(--text-dim,#999);padding:10px 12px;border-radius:9px;background:rgba(255,255,255,.04);margin:4px 0 8px;font-style:italic} .fd-ap-err{color:#ef8a8a;font-size:12px;padding:4px 2px} @@ -175,11 +182,20 @@ @@ -376,10 +392,10 @@ var c = this.activeContext || {}; var out = []; if(c.kind === 'page'){ - out.push({icon:'📄', label:'Résumer ce document', prompt:'Résume ce document de façon structurée et concise.'}); - out.push({icon:'🌐', label:'Traduire cette page', prompt:'Traduis le contenu de ce document en anglais.', new:false}); - out.push({icon:'✍️', label:'Proposer des améliorations', prompt:'Analyse ce document et propose des améliorations concrètes de structure et de contenu.'}); - out.push({icon:'📝', label:'Créer une note de réunion', prompt:'Crée un squelette d\u2019AI meeting note structuré à partir de ce document.'}); + out.push({icon:'📄', label:'Résumer ce document', prompt:'Résume ce document de façon structurée et concise.', docRun:true, mode:'insert'}); + out.push({icon:'🌐', label:'Traduire cette page', prompt:'Traduis le contenu de ce document en anglais, en conservant sa structure (titres, listes, paragraphes).', docRun:true, mode:'replace'}); + out.push({icon:'✍️', label:'Proposer des améliorations', prompt:'Analyse ce document et propose une version améliorée (structure, clarté, complétude) sans en perdre le sens.', docRun:true, mode:'insert'}); + out.push({icon:'📝', label:'Créer une note de réunion', prompt:'Rédige une « AI Meeting Note » structurée à partir de ce document : en-tête avec date, sections Résumé, Décisions, Action items et Prochaines étapes.', docRun:true, mode:'insert'}); } else if(c.kind === 'db'){ out.push({icon:'🗄️', label:'Analyser cette base', prompt:'Analyse cette base de données et résume sa structure, ses propriétés et ses données.'}); out.push({icon:'📊', label:'Créer une vue', prompt:'Propose une vue adaptée (board, tableau, calendrier…) pour cette base et explique pourquoi.'}); @@ -399,6 +415,7 @@ }, runQuick(q){ if(!q || !q.prompt) return; + if(q.docRun){ this.runDocAction(q); return; } var self = this; this.ensureConversation().then(function(){ self.input = q.prompt; @@ -406,6 +423,63 @@ }).catch(function(){}); }, + // ── Document actions (no tools) → proposition à approuver / rejeter ── + runDocAction(q){ + var self = this; + this.ensureConversation().then(function(){ + self.errorMsg = ''; + self.messages.push({id:'udoc-'+Date.now(), role:'user', content: q.label}); + self.refreshContext(); + var ctxText = (self.activeContext && self.activeContext.kind === 'page' && self.activeContext.text) ? self.activeContext.text : ''; + var thinkId = 'g-'+Date.now()+Math.random(); + self.messages.push({id: thinkId, role:'assistant', generating: true}); + self.scrollBottom(); + + var payload = {prompt: q.prompt}; + if(ctxText) payload.context = ctxText; + if(self.llmProvider) payload.provider = self.llmProvider; + if(self.llmModel) payload.model = self.llmModel; + + fetch('/api/agent/generate', { + method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) + }).then(function(r){ return r.json(); }).then(function(d){ + self.messages = self.messages.filter(function(m){ return m.id !== thinkId; }); + if(d && d.ok && d.text){ + self.messages.push({ + id:'prop-'+Date.now()+Math.random(), role:'assistant', + content: d.text, proposal: true, mode: q.mode || 'insert', + offline: !!d.offline, model: d.model || '', applied: false, rejected: false + }); + } else { + self.messages.push({id:'err-'+Date.now()+Math.random(), role:'assistant', + content: '⚠️ ' + ((d && d.error) || 'Échec de la génération'), model:''}); + } + self.scrollBottom(); + }).catch(function(err){ + self.messages = self.messages.filter(function(m){ return m.id !== thinkId; }); + self.messages.push({id:'err-'+Date.now()+Math.random(), role:'assistant', + content: '⚠️ ' + ((err && err.message) || 'Erreur réseau'), model:''}); + self.scrollBottom(); + }); + }).catch(function(){}); + }, + approveProposal(m){ + if(!m || m.applied || m.offline) return; + var ok = window.fdApplyDocument ? window.fdApplyDocument(m.content, m.mode) : false; + m.applied = true; + m.rejected = false; + if(!ok){ + m.content = '⚠️ Aucun document éditable n\u2019est ouvert pour appliquer cette proposition.'; + } + this.$nextTick(function(){ this.scrollBottom(); }); + }, + rejectProposal(m){ + if(!m || m.applied) return; + m.applied = true; + m.rejected = true; + this.$nextTick(function(){ this.scrollBottom(); }); + }, + // ── Providers / models ── fetchProviders(){ var self = this; @@ -832,6 +906,36 @@ })(); window.FlowDeckCtx = FlowDeckCtx; + // Apply an AI-produced document change to the open page editor. + // mode 'replace' → remplace tout le contenu de la page ; + // mode 'insert' → insère la proposition en fin de page (comme Ask AI). + window.fdApplyDocument = function(text, mode){ + var E = window.E; + if(!E || !E.blocks || typeof E.mkB !== 'function') return false; + text = (text || '').trim(); + if(!text) return false; + try{ + E.sync(); + if(mode === 'replace'){ + var nb = (typeof E.md2b === 'function') ? E.md2b(text) : null; + if(!nb || !nb.length){ E.blocks = [E.mkB('paragraph', text)]; } + else { E.blocks = nb; } + } else { + if(typeof E.applyAIBlocks === 'function'){ E.applyAIBlocks(text); } + else { + var lines = text.split(/\r?\n/).map(function(s){ return s.trim(); }).filter(Boolean); + var startEmpty = E.blocks.length === 1 && E.blocks[0].type === 'paragraph' && !(E.blocks[0].content || '').trim(); + var nb2 = lines.map(function(l){ return E.mkB('paragraph', l); }); + E.blocks = startEmpty ? nb2 : E.blocks.concat(nb2); + } + } + E.dirty = true; + if(E.autoSave) E.autoSave(); + if(E.render) E.render(); + return true; + }catch(e){ return false; } + }; + // Backward-compatible detection hooks (banner + Ask AI) window.fdPageContextProvider = function(){ var c = FlowDeckCtx.current(); diff --git a/tests/test_agent.py b/tests/test_agent.py index 629fab3..fd3088a 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -661,4 +661,53 @@ def test_router_user_key_test_failure(client, monkeypatch): prov = client.get("/api/agent/providers").json() p = [x for x in prov["providers"] if x["id"] == "openai"][0] - assert p["functional"] is False \ No newline at end of file + assert p["functional"] is False + + +def test_generate_endpoint_returns_text(client, monkeypatch): + """/api/agent/generate performs headless text generation (no tools).""" + from app.routers import agent as agent_router + from app.services.llm_client import LLMResponse + + class FakeLLM(agent_router.LLMClient): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + async def complete(self, messages, *, model=None, tools=None, stream=False): + # The tool schema must never be offered for content generation. + assert tools is None + return LLMResponse(text="Voici le document traduit en anglais.", model="gpt-4o-mini") + + monkeypatch.setattr(agent_router, "LLMClient", FakeLLM) + + r = client.post("/api/agent/generate", json={ + "prompt": "Traduis ce document en anglais.", + "context": "Titre du document : Doc\nContenu en français.", + "model": "gpt-4o-mini", + }) + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert "traduit en anglais" in body["text"] + + +def test_generate_endpoint_requires_prompt(client): + r = client.post("/api/agent/generate", json={"prompt": " "}) + assert r.status_code == 400 + + +def test_mock_planner_ignores_context_keywords(client): + """Document context words (ex: 'cherche', 'collection') must not trigger + tool intents — the planner only inspects the user objective.""" + import asyncio + from app.services.llm_client import LLMClient + + llm = LLMClient(provider="offline") + resp = asyncio.run(llm.complete( + [{"role": "system", "content": "x"}, + {"role": "user", "content": "Résume ce document.\n\n# Contexte\n" + "Le rapport mentionne une recherche sur la collection Z."}], + model="gpt-4o", tools=[{"type": "function"}], + )) + assert resp.tool_calls == [] + assert resp.text \ No newline at end of file