fix(agent): v4.13.1 - actions contenu document (resume/traduire/ameliorer/meeting) sans outils, apercu + appliquer/rejeter, mock ignore contexte
FlowDeck CI / test (push) Failing after 14s
FlowDeck CI / docker (push) Skipped

This commit is contained in:
2026-09-06 01:14:52 -04:00
parent 0b63ed17bc
commit 75b7f29826
8 changed files with 291 additions and 18 deletions
+26
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
4.13.0
4.13.1
+1 -1
View File
@@ -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,
+56
View File
@@ -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 ──
+31 -8
View File
@@ -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 <X>" 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]
+16 -1
View File
@@ -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
+110 -6
View File
@@ -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 @@
</div>
<template x-for="m in messages" :key="m.id">
<div class="fd-agent-msg" :class="'fd-agent-msg-'+(m.role||'user')">
<div class="fd-agent-msg" :class="'fd-agent-msg-'+(m.role||'user') + (m.proposal ? ' fd-agent-msg-proposal' : '')">
<div class="fd-agent-msg-avatar" x-text="m.role==='assistant'?'🤖':'👤'"></div>
<div class="fd-agent-msg-body">
<div class="fd-agent-msg-meta" x-text="(m.role==='assistant'?'Agent':'Vous')+(m.model?' · '+m.model:'')"></div>
<div class="fd-agent-msg-content" x-text="m.content"></div>
<div class="fd-agent-msg-content" x-text="m.generating ? 'Génération…' : m.content"></div>
<template x-if="m.proposal">
<div class="fd-proposal-bar">
<button class="fd-proposal-btn primary" type="button" @click="approveProposal(m)" :disabled="m.applied || m.offline">
<span x-text="m.applied ? (m.rejected ? 'Rejetée' : '✓ Appliquée') : '✓ Appliquer au document'"></span>
</button>
<button class="fd-proposal-btn" type="button" @click="rejectProposal(m)" :disabled="m.applied">✕ Rejeter</button>
<span class="fd-proposal-note" x-show="m.offline" x-cloak>Hors-ligne — configurez une clé API pour appliquer la modification.</span>
</div>
</template>
</div>
</div>
</template>
@@ -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();
+50 -1
View File
@@ -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
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