feat(v5.9.0): AI Writing Assist - slash /ai, autocompletion, AI properties
- Service app/services/ai_writing.py: 6 actions sans outils (write, summarize, translate, continue, autocomplete, properties) + replis deterministes offline - Endpoints POST /api/agent/writing et /api/agent/writing/properties - Editeur: groupe slash AI (Write/Summarize/Translate/Continue) via E.aiSlash - Autocompletion inline AIAC (suggestion ~900ms, Tab accepte, Escape rejette) - Database: bouton AI fill (suggestions de proprietes Status/Priority/Resume) - Tests tests/test_ai_writing.py (+29) ; version 5.9.0 (VERSION, main.py) - CHANGELOG + ROADMAP v5.9.0 completes
This commit is contained in:
@@ -1,5 +1,33 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v5.9.0 (2026-09-10) — AI Writing Assist (éditeur)
|
||||
|
||||
> L'IA Notion dans l'éditeur : commandes slash `/ai`, autocomplétion en ligne
|
||||
> et remplissage automatique des propriétés d'une base, au-dessus du moteur
|
||||
> FlowDeck Agent (v4.10.0) et de l'endpoint headless `/api/agent/generate`.
|
||||
|
||||
- **Service `app/services/ai_writing.py`** — `AIWritingService` sans outils,
|
||||
six actions (`write`, `summarize`, `translate`, `continue`, `autocomplete`,
|
||||
`properties`). Prompt système strict (Markdown léger, aucun préambule) et
|
||||
replis déterministes hors-ligne (aucun appel réseau sans clé API).
|
||||
- **Commandes slash IA** — nouveau groupe « AI » dans le menu `/` de
|
||||
l'éditeur : **Write with AI**, **Summarize**, **Translate**, **Continue
|
||||
writing**. `Write` ouvre le composeur inline existant ; `Summarize` et
|
||||
`Continue` insèrent le résultat sous le bloc ; `Translate` remplace le
|
||||
contenu de la page dans la langue demandée.
|
||||
- **Autocomplétion** — module `AIAC` : après ~900 ms d'inactivité pendant la
|
||||
frappe dans un bloc texte, une suggestion courte est proposée dans une
|
||||
pastille « Tab » ancrée au bloc ; `Tab` l'insère, `Escape` la rejette.
|
||||
- **AI properties** — bouton ✨ sur chaque ligne de la table database :
|
||||
`POST /api/agent/writing/properties` propose des valeurs (Status, Priority,
|
||||
Résumé…) à partir du titre/contenu, appliquées et persistées via l'API
|
||||
collection existante.
|
||||
- **Endpoints** — `POST /api/agent/writing` (action + prompt/contexte/langue/
|
||||
préfixe) et `POST /api/agent/writing/properties` (suggestions structurées),
|
||||
provider/modèle par utilisateur via les clés LLM existantes.
|
||||
- **Tests** — `tests/test_ai_writing.py` (+29) : service hors-ligne, coercition
|
||||
des valeurs, parsing JSON, routes et câblage des templates. Version 5.9.0.
|
||||
|
||||
## v5.5.0 (2026-09-08) — Partage : réorganisation & indicateurs
|
||||
|
||||
> Vue complète du partage : sidebar « Shared » scindée en « Par moi » /
|
||||
|
||||
+9
-6
@@ -547,12 +547,15 @@ app/
|
||||
- [ ] **Centre de notifications** — cloche in-app (mentions, assignations, commentaires, rappels)
|
||||
- [ ] **Template Meeting Notes** — Attendees, Agenda, Notes, Action Items
|
||||
|
||||
### v5.9.0 — AI Writing Assist (éditeur)
|
||||
> **Objectif** : l'IA Notion dans l'éditeur, au-dessus du moteur v4.9.0 (Agent IA).
|
||||
### v5.9.0 — AI Writing Assist (éditeur) ✅ (2026-09-10)
|
||||
> **Objectif** : l'IA Notion dans l'éditeur, au-dessus du moteur v4.10.0 (Agent IA). **COMPLETED**.
|
||||
|
||||
- [ ] **Slash AI commands** — `/ai write`, `/ai summarize`, `/ai translate`, `/ai continue`
|
||||
- [ ] **Autocomplétion** — suggestions de fin de ligne pendant la frappe
|
||||
- [ ] **AI properties** — auto-remplissage de propriétés (résumé, catégorisation) via l'Agent Engine
|
||||
- [x] **Slash AI commands** — groupe « AI » dans le menu `/` : Write with AI, Summarize, Translate, Continue writing (`E.aiSlash`)
|
||||
- [x] **Autocomplétion** — module `AIAC` : suggestion courte après ~900 ms d'inactivité, pastille « Tab » ancrée au bloc, insertion au `Tab`, rejet à `Escape`
|
||||
- [x] **AI properties** — bouton ✨ par ligne de la table database : `POST /api/agent/writing/properties` propose Status/Priority/Résumé et applique les valeurs
|
||||
- [x] **Service** `app/services/ai_writing.py` — 6 actions sans outils, replis déterministes hors-ligne
|
||||
- [x] **Endpoints** — `POST /api/agent/writing` + `POST /api/agent/writing/properties`
|
||||
- [x] **29 tests** `tests/test_ai_writing.py` ; version 5.9.0
|
||||
|
||||
---
|
||||
|
||||
@@ -636,4 +639,4 @@ Quality DB views, Agent IA Palette → Realtime + S
|
||||
DB avancée, redo, drag&drop,
|
||||
Calendrier, AI duplicate)
|
||||
|
||||
*Dernière mise à jour: 2026-09-08 — v5.13.0 Realtime livré (WebSocket gateway, présence, curseurs live, merge LWW + fallback polling, 12 tests) puis v5.10.0 Interactions de bloc livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, sync realtime, 9 tests, suite 307 verte) puis v5.5.0 (repo) Partage v2 livré (fenêtre Share rechargée, sidebar « Par moi »/« Avec moi », Library dir=made/received, badge 👥 Shared, 👥 workspace local, 6 tests, suite 325 verte) ; reste v5.4 → v6.0*
|
||||
*Dernière mise à jour: 2026-09-10 — v5.9.0 AI Writing Assist livré (slash `/ai`, autocomplétion `AIAC`, AI properties, service `ai_writing.py`, 2 endpoints, 29 tests) ; reste v5.4, v5.11 → v6.0*
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ async def lifespan(_app: FastAPI):
|
||||
from app.services.automations import automation_scheduler
|
||||
automation_task = asyncio.create_task(automation_scheduler())
|
||||
|
||||
logger.info("FlowDeck v5.5.0 started on port %d", settings.app_port)
|
||||
logger.info("FlowDeck v5.9.0 started on port %d", settings.app_port)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -71,7 +71,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="5.5.0",
|
||||
version="5.9.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -367,6 +367,82 @@ async def agent_generate(request: Request):
|
||||
}
|
||||
|
||||
|
||||
# ── AI Writing Assist (v5.9.0) ──
|
||||
|
||||
|
||||
@router.post("/writing")
|
||||
async def agent_writing(request: Request):
|
||||
"""Headless writing actions for the editor (v5.9.0).
|
||||
|
||||
`action` is one of: write, summarize, translate, continue, autocomplete.
|
||||
Returns plain Markdown (`text`) plus `model`/`offline` metadata. The
|
||||
`properties` action is served by `/writing/properties`.
|
||||
"""
|
||||
from app.services.ai_writing import AIWritingService, WRITING_ACTIONS
|
||||
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
action = (body.get("action") or "").strip().lower()
|
||||
if not action:
|
||||
raise HTTPException(status_code=400, detail="action est requis")
|
||||
if action == "properties":
|
||||
raise HTTPException(status_code=400, detail="Utilisez /writing/properties pour l'action properties")
|
||||
if action not in WRITING_ACTIONS:
|
||||
raise HTTPException(status_code=400, detail=f"Action inconnue: {action}")
|
||||
|
||||
prompt = (body.get("prompt") or "").strip()
|
||||
if action == "write" and not prompt and not (body.get("title") or "").strip():
|
||||
raise HTTPException(status_code=400, detail="prompt est requis pour l'action write")
|
||||
|
||||
service = AIWritingService(
|
||||
user_id=user_id,
|
||||
provider=(body.get("provider") or "").strip() or None,
|
||||
model=(body.get("model") or "").strip() or None,
|
||||
)
|
||||
result = await service.run(
|
||||
action,
|
||||
prompt=prompt,
|
||||
context=(body.get("context") or ""),
|
||||
target_language=(body.get("target_language") or "English"),
|
||||
prefix=(body.get("prefix") or ""),
|
||||
title=(body.get("title") or ""),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/writing/properties")
|
||||
async def agent_writing_properties(request: Request):
|
||||
"""Suggest values for a collection page's properties (v5.9.0).
|
||||
|
||||
Body: `{title, content?, properties: [{name, type}], provider?, model?}`.
|
||||
Returns `{ok, suggestions: {property_name: value}, model, offline}`.
|
||||
"""
|
||||
from app.services.ai_writing import AIWritingService
|
||||
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
properties = body.get("properties") or []
|
||||
if not isinstance(properties, list) or not properties:
|
||||
raise HTTPException(status_code=400, detail="properties est requis (liste non vide)")
|
||||
|
||||
service = AIWritingService(
|
||||
user_id=user_id,
|
||||
provider=(body.get("provider") or "").strip() or None,
|
||||
model=(body.get("model") or "").strip() or None,
|
||||
)
|
||||
suggestions = await service.suggest_properties(
|
||||
context=(body.get("content") or ""),
|
||||
title=(body.get("title") or ""),
|
||||
properties=properties,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"suggestions": suggestions,
|
||||
"model": service.model or "",
|
||||
"offline": service._offline_hint(),
|
||||
}
|
||||
|
||||
|
||||
# ── Audit & rollback ──
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""FlowDeck — AI Writing Assist (v5.9.0).
|
||||
|
||||
Headless, tool-free writing helpers used by the editor (slash commands,
|
||||
inline autocomplete) and the database table (AI property suggestions).
|
||||
|
||||
All actions share one entry point, :meth:`AIWritingService.run`, which builds a
|
||||
tight prompt, calls the configured LLM (or the deterministic offline mock) and
|
||||
returns plain Markdown. `properties` additionally returns a structured
|
||||
``suggestions`` mapping so the caller can fill collection properties.
|
||||
|
||||
The service never talks to the DB directly — the router resolves the caller's
|
||||
provider/key and the page context before delegating here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.services.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WRITING_ACTIONS = ("write", "summarize", "translate", "continue", "autocomplete", "properties")
|
||||
|
||||
_MAX_CONTEXT = 20000
|
||||
|
||||
# Property name → (type, offline default) used by the deterministic fallback so
|
||||
# the feature stays useful without a connected provider.
|
||||
_OFFLINE_PROPERTY_DEFAULTS = (
|
||||
(("status", "état", "etat", "stage"), "select", "To do"),
|
||||
(("priority", "priorité", "priorite"), "select", "Medium"),
|
||||
(("done", "terminé", "termine", "complété", "complete"), "checkbox", False),
|
||||
(("summary", "résumé", "resume", "description", "notes"), "text", ""),
|
||||
)
|
||||
|
||||
_SYSTEM_WRITING = (
|
||||
"Tu es l'assistant d'écriture de FlowDeck. "
|
||||
"Réponds UNIQUEMENT avec le contenu 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."
|
||||
)
|
||||
|
||||
|
||||
class AIWritingService:
|
||||
"""Deterministic, provider-agnostic writing assistant."""
|
||||
|
||||
def __init__(self, user_id: int | None = None, provider: str | None = None,
|
||||
model: str | None = None, api_key: str | None = None,
|
||||
api_base: str | None = None):
|
||||
self.user_id = user_id
|
||||
self.provider = (provider or "").strip().lower() or None
|
||||
self.model = (model or "").strip() or None
|
||||
self._api_key = api_key
|
||||
self._api_base = api_base
|
||||
|
||||
# ── LLM plumbing ──
|
||||
|
||||
def _client(self) -> LLMClient:
|
||||
provider = self.provider
|
||||
api_key = self._api_key
|
||||
api_base = self._api_base
|
||||
if provider and self.user_id:
|
||||
try:
|
||||
from app.services.llm_config import get_user_llm_key
|
||||
row = get_user_llm_key(self.user_id, provider)
|
||||
if row and row.get("api_key"):
|
||||
api_key = row["api_key"]
|
||||
api_base = (row.get("api_base") or "").strip() or api_base
|
||||
except Exception: # noqa: BLE001 — never fail on key lookup
|
||||
pass
|
||||
return LLMClient(provider=provider, api_key=api_key, api_base=api_base)
|
||||
|
||||
async def _complete(self, prompt: str, context: str = "") -> tuple[str, str, bool]:
|
||||
llm = self._client()
|
||||
offline = llm.provider == "offline" or not llm._has_credentials()
|
||||
user_content = prompt
|
||||
if context and context.strip():
|
||||
user_content += "\n\n# Contexte\n" + context.strip()[:_MAX_CONTEXT]
|
||||
messages = [
|
||||
{"role": "system", "content": _SYSTEM_WRITING},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
resp = await llm.complete(messages, model=self.model, tools=None, stream=False)
|
||||
return (resp.text or "").strip(), (resp.model or self.model or ""), offline
|
||||
|
||||
# ── Public API ──
|
||||
|
||||
async def run(self, action: str, *, prompt: str = "", context: str = "",
|
||||
target_language: str = "English", prefix: str = "",
|
||||
title: str = "", properties: list | None = None) -> dict:
|
||||
action = (action or "").strip().lower()
|
||||
if action not in WRITING_ACTIONS:
|
||||
raise ValueError(f"Action inconnue: {action or '(vide)'}")
|
||||
|
||||
if action == "properties":
|
||||
suggestions = await self.suggest_properties(
|
||||
context=context, title=title, properties=properties or [])
|
||||
return {"ok": True, "action": action, "text": "", "suggestions": suggestions,
|
||||
"model": self.model or "", "offline": self._offline_hint()}
|
||||
|
||||
prompt_text = self._build_prompt(
|
||||
action, prompt=prompt, context=context,
|
||||
target_language=target_language, prefix=prefix, title=title)
|
||||
|
||||
# No connected provider → deterministic, dependency-free output (the raw
|
||||
# offline planner echoes the prompt, which is wrong for continue/autocomplete).
|
||||
if self._offline_hint():
|
||||
return {"ok": True, "action": action, "model": "",
|
||||
"offline": True,
|
||||
"text": self._offline_text(action, prompt=prompt, context=context,
|
||||
target_language=target_language,
|
||||
prefix=prefix, title=title)}
|
||||
try:
|
||||
text, model, offline = await self._complete(prompt_text, context=context)
|
||||
except Exception as exc: # noqa: BLE001 — surface provider errors to the UI
|
||||
logger.warning("AI writing '%s' failed: %s", action, exc)
|
||||
return {"ok": False, "action": action, "error": str(exc),
|
||||
"text": "", "model": self.model or "", "offline": False}
|
||||
if not text:
|
||||
text = self._offline_text(action, prompt=prompt, context=context,
|
||||
target_language=target_language,
|
||||
prefix=prefix, title=title)
|
||||
offline = True
|
||||
return {"ok": True, "action": action, "text": text,
|
||||
"model": model, "offline": offline}
|
||||
|
||||
# ── Prompt building ──
|
||||
|
||||
def _build_prompt(self, action: str, *, prompt: str, context: str,
|
||||
target_language: str, prefix: str, title: str) -> str:
|
||||
if action == "write":
|
||||
subject = (prompt or title or "ce document").strip()
|
||||
return (f"Rédige le contenu demandé : {subject}. "
|
||||
"Fournis un texte structuré et directement utilisable.")
|
||||
if action == "summarize":
|
||||
return ("Résume le contenu fourni de façon structurée et concise : "
|
||||
"un court paragraphe d'introduction puis 3 à 5 points clés à puces.")
|
||||
if action == "translate":
|
||||
lang = (target_language or "English").strip()
|
||||
return (f"Traduis l'intégralité du contenu fourni en {lang}, "
|
||||
"en conservant fidèlement sa structure (titres, listes, paragraphes). "
|
||||
"Ne traduis pas les noms propres et les termes techniques.")
|
||||
if action == "continue":
|
||||
return ("Poursuis naturellement le texte fourni. "
|
||||
"Écris un à trois paragraphes cohérents avec le style et le sujet, "
|
||||
"sans répéter ce qui précède et sans introduction.")
|
||||
if action == "autocomplete":
|
||||
return (f"Complète la phrase en cours par une suite courte et pertinente "
|
||||
f"(maximum 20 mots). Ne répète pas le texte déjà écrit, ne mets "
|
||||
f"aucun préambule. Texte en cours : {prefix!r}")
|
||||
return prompt
|
||||
|
||||
# ── Offline deterministic fallbacks ──
|
||||
|
||||
def _offline_hint(self) -> bool:
|
||||
try:
|
||||
llm = self._client()
|
||||
return llm.provider == "offline" or not llm._has_credentials()
|
||||
except Exception: # noqa: BLE001
|
||||
return True
|
||||
|
||||
def _offline_text(self, action: str, *, prompt: str, context: str,
|
||||
target_language: str, prefix: str, title: str) -> str:
|
||||
if action == "summarize":
|
||||
return self._offline_summary(context)
|
||||
if action == "translate":
|
||||
return (f"⚠️ **Traduction hors-ligne indisponible** — aucun modèle d'IA connecté.\n\n"
|
||||
f"Connectez un fournisseur dans **Paramètres → Agent & IA** pour traduire "
|
||||
f"ce document en {target_language or 'English'}.")
|
||||
if action == "autocomplete":
|
||||
return self._offline_autocomplete(prefix)
|
||||
if action == "continue":
|
||||
return ("Suite du contenu : développez ici le point précédent avec un exemple "
|
||||
"concret, puis ouvrez la prochaine idée en une phrase de transition.")
|
||||
subject = (prompt or title or "ce document").strip()
|
||||
return (f"## {subject}\n"
|
||||
"\n"
|
||||
"Présentation générale du sujet : objectif, contexte et public visé en "
|
||||
"quelques phrases. (Contenu généré hors-ligne — connectez une clé API "
|
||||
"pour une rédaction complète.)\n"
|
||||
"\n"
|
||||
"## Points clés\n"
|
||||
"• Idée principale 1 et son argument.\n"
|
||||
"• Idée principale 2 avec un exemple concret.\n"
|
||||
"\n"
|
||||
"## Prochaines étapes\n"
|
||||
"• Relire, compléter et mettre en forme ce contenu.")
|
||||
|
||||
@staticmethod
|
||||
def _offline_summary(context: str) -> str:
|
||||
text = (context or "").strip()
|
||||
if not text:
|
||||
return "Résumé : aucun contenu fourni à résumer."
|
||||
headings = re.findall(r"^#{1,4}\s+(.+)$", text, flags=re.MULTILINE)
|
||||
sentences = re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text))
|
||||
lead = next((s.strip() for s in sentences if len(s.strip()) > 40), sentences[0].strip())
|
||||
out = ["**Résumé**", "", lead[:400], ""]
|
||||
bullets = []
|
||||
if headings:
|
||||
bullets = [f"• {h.strip()}" for h in headings[:5]]
|
||||
else:
|
||||
for s in sentences[1:6]:
|
||||
s = s.strip()
|
||||
if len(s) > 30:
|
||||
bullets.append(f"• {s[:180]}")
|
||||
if bullets:
|
||||
out.append("**Points clés**")
|
||||
out.extend(bullets)
|
||||
return "\n".join(out)
|
||||
|
||||
@staticmethod
|
||||
def _offline_autocomplete(prefix: str) -> str:
|
||||
prefix = (prefix or "").strip()
|
||||
if len(prefix) < 8:
|
||||
return ""
|
||||
return " Cette section détaille les points clés à retenir."
|
||||
|
||||
# ── AI properties ──
|
||||
|
||||
async def suggest_properties(self, *, context: str = "", title: str = "",
|
||||
properties: list | None = None) -> dict:
|
||||
"""Return ``{property_name: value}`` suggestions for a collection page.
|
||||
|
||||
``properties`` is a list of ``{name, type}`` dicts. With a connected
|
||||
provider the model is asked for a JSON object; offline we derive
|
||||
deterministic defaults from the property names so the UI stays useful.
|
||||
"""
|
||||
properties = properties or []
|
||||
if not properties:
|
||||
return {}
|
||||
names = [str(p.get("name", "")).strip() for p in properties if isinstance(p, dict)]
|
||||
names = [n for n in names if n]
|
||||
|
||||
llm = self._client()
|
||||
offline = llm.provider == "offline" or not llm._has_credentials()
|
||||
if not offline:
|
||||
schema = {str(p.get("name")): str(p.get("type", "text")) for p in properties if isinstance(p, dict)}
|
||||
prompt = (
|
||||
"À partir du titre et du contenu du document, propose une valeur pour "
|
||||
"chaque propriété. Réponds STRICTEMENT par un objet JSON "
|
||||
"{\"nom_propriété\": valeur} sans texte autour.\n"
|
||||
f"Propriétés attendues : {json.dumps(schema, ensure_ascii=False)}\n"
|
||||
f"Titre : {title or '(sans titre)'}"
|
||||
)
|
||||
try:
|
||||
text, _, _ = await self._complete(prompt, context=context)
|
||||
parsed = self._parse_json_object(text)
|
||||
if parsed:
|
||||
return self._coerce_suggestions(parsed, properties)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("AI properties failed: %s", exc)
|
||||
# fall through to deterministic defaults
|
||||
return self._offline_suggestions(title, context, properties)
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_object(text: str) -> dict:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
m = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
||||
if not m:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(m.group(0))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def _coerce_suggestions(self, parsed: dict, properties: list) -> dict:
|
||||
out: dict = {}
|
||||
by_name = {str(p.get("name", "")).strip().lower(): p for p in properties if isinstance(p, dict)}
|
||||
for key, value in parsed.items():
|
||||
prop = by_name.get(str(key).strip().lower())
|
||||
if not prop:
|
||||
continue
|
||||
out[str(prop.get("name"))] = self._coerce_value(value, prop.get("type", "text"))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _coerce_value(value, prop_type: str):
|
||||
ptype = (prop_type or "text").lower()
|
||||
if ptype == "checkbox":
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "oui", "vrai", "x")
|
||||
if ptype == "number":
|
||||
try:
|
||||
num = float(value)
|
||||
return int(num) if num.is_integer() else num
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _offline_suggestions(title: str, context: str, properties: list) -> dict:
|
||||
out: dict = {}
|
||||
summary_text = ""
|
||||
if context:
|
||||
summary_text = re.sub(r"\s+", " ", context).strip()
|
||||
first = re.split(r"(?<=[.!?])\s+", summary_text)
|
||||
summary_text = next((s for s in first if len(s) > 40), summary_text)[:180]
|
||||
for prop in properties:
|
||||
if not isinstance(prop, dict):
|
||||
continue
|
||||
name = str(prop.get("name", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
low = name.lower()
|
||||
ptype = (prop.get("type") or "text").lower()
|
||||
matched = False
|
||||
for keys, _ptype, default in _OFFLINE_PROPERTY_DEFAULTS:
|
||||
if any(k in low for k in keys):
|
||||
if "summary" in keys or "résumé" in keys or "resume" in keys or "description" in keys or "notes" in keys:
|
||||
out[name] = summary_text or (title or "")
|
||||
else:
|
||||
out[name] = default
|
||||
matched = True
|
||||
break
|
||||
if not matched and ptype in ("text", "title"):
|
||||
if "name" in low or "titre" in low or "title" in low:
|
||||
out[name] = title or ""
|
||||
return out
|
||||
|
||||
|
||||
async def run_action(action: str, **kwargs) -> dict:
|
||||
"""Module-level convenience wrapper (used by tests and simple callers)."""
|
||||
return await AIWritingService().run(action, **kwargs)
|
||||
@@ -84,7 +84,7 @@
|
||||
h += '</div><div class="db-col-resize-handle" data-prop-id="'+prop.id+'"></div></th>';
|
||||
}
|
||||
h += '<th class="db-th db-th-add-prop"><button class="db-add-prop-btn db-show-prop-modal-btn">+ Add property</button></th>';
|
||||
h += '<th class="db-th" style="width:40px;min-width:40px"></th>';
|
||||
h += '<th class="db-th" style="width:72px;min-width:72px"></th>';
|
||||
h += '</tr></thead>';
|
||||
|
||||
// Body
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
h += '</div></td>';
|
||||
}
|
||||
h += '<td class="db-td db-td-open db-hover-only" style="visibility:hidden"><button class="db-open-btn db-open-page-btn" data-page-id="'+cp.id+'" title="Open">↗</button></td>';
|
||||
h += '<td class="db-td db-td-open db-hover-only" style="visibility:hidden;white-space:nowrap"><button class="db-ai-btn db-ai-fill-btn" data-page-id="'+cp.id+'" title="Remplir les propriétés avec l\'IA" style="background:none;border:none;cursor:pointer;font-size:14px;padding:2px 4px;color:var(--text-dim)">✨</button><button class="db-open-btn db-open-page-btn" data-page-id="'+cp.id+'" title="Open">↗</button></td>';
|
||||
h += '</tr>';
|
||||
}
|
||||
// Empty rows + New Page at bottom
|
||||
@@ -141,6 +141,13 @@
|
||||
openPageInSidePeek(parseInt(this.dataset.pageId));
|
||||
};
|
||||
});
|
||||
// AI fill properties
|
||||
containerEl.querySelectorAll('.db-ai-fill-btn').forEach(btn => {
|
||||
btn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
aiFillRow(parseInt(this.dataset.pageId));
|
||||
};
|
||||
});
|
||||
// Hover
|
||||
containerEl.querySelectorAll('.db-row').forEach(row => {
|
||||
row.onmouseenter = function() { this.querySelectorAll('.db-hover-only').forEach(el => el.style.visibility = 'visible'); };
|
||||
@@ -249,6 +256,53 @@
|
||||
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
|
||||
}
|
||||
|
||||
// ── v5.9.0: AI property suggestions ──
|
||||
async function aiFillRow(pageId) {
|
||||
const cp = state.pages.find(p => p.id === pageId);
|
||||
if (!cp) return;
|
||||
const props = state.properties.filter(p => p.prop_type !== 'title');
|
||||
if (!props.length) {
|
||||
if (typeof window.showToast === 'function') window.showToast('Aucune propriété à remplir');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
title: cp.title || '',
|
||||
content: cp.content || '',
|
||||
properties: props.map(p => ({ name: p.name, type: p.prop_type }))
|
||||
};
|
||||
if (typeof window.showToast === 'function') window.showToast('✨ FlowDeck AI : analyse en cours…');
|
||||
try {
|
||||
const r = await fetch('/api/agent/writing/properties', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d || !d.ok) {
|
||||
if (typeof window.showToast === 'function') window.showToast((d && d.error) || 'Échec de la génération');
|
||||
return;
|
||||
}
|
||||
let pv;
|
||||
try { pv = typeof cp.property_values_json === 'string' ? JSON.parse(cp.property_values_json) : (cp.property_values_json || {}); }
|
||||
catch (e) { pv = {}; }
|
||||
let filled = 0;
|
||||
for (const p of props) {
|
||||
const v = d.suggestions ? d.suggestions[p.name] : undefined;
|
||||
if (v !== undefined && v !== null && v !== '') { pv[String(p.id)] = v; filled++; }
|
||||
}
|
||||
cp.property_values_json = pv;
|
||||
await fetch('/db/pages/' + pageId + '/api', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||||
body: JSON.stringify({ title: cp.title, properties: pv })
|
||||
});
|
||||
render();
|
||||
if (typeof window.showToast === 'function') window.showToast(filled ? ('✨ ' + filled + ' propriété(s) remplie(s)') : 'Aucune suggestion disponible');
|
||||
} catch (e) {
|
||||
if (typeof window.showToast === 'function') window.showToast('Erreur IA');
|
||||
}
|
||||
}
|
||||
|
||||
async function addNewPage() {
|
||||
try {
|
||||
const r = await fetch('/api/collections/'+collectionId+'/pages',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:''})});
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
{id:'callout',name:'Callout',icon:'💡'},{id:'table_of_contents',name:'Table of contents',icon:'☰'},{id:'math',name:'Math equation',icon:'∑'},
|
||||
{id:'columns',name:'Column list',icon:'▮'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
|
||||
{id:'table',name:'Table',icon:'⊞'},{id:'button',name:'Button / Automation',icon:'⚡'},
|
||||
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]},
|
||||
]},{name:'AI',items:[
|
||||
{id:'ai_write',name:'Write with AI',icon:'✨'},{id:'ai_summarize',name:'Summarize',icon:'📝'},
|
||||
{id:'ai_translate',name:'Translate',icon:'🌐'},{id:'ai_continue',name:'Continue writing',icon:'➡️'},
|
||||
]},
|
||||
{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼️'}]},
|
||||
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]},
|
||||
{name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}];
|
||||
|
||||
@@ -195,8 +199,8 @@
|
||||
}
|
||||
|
||||
// ── Block type shortcuts (shown on the right of each slash item) ──
|
||||
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',database:'DB',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
|
||||
const _GROUP_LABEL={BASIC:'Basic blocks',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'};
|
||||
const _SHORTCUT={text:'T',heading_1:'#',heading_2:'##',heading_3:'###',heading_4:'####',bulleted_list:'•',numbered_list:'1.',to_do:'[ ]',toggle:'▸',quote:'"',callout:'!',table_of_contents:'TOC',math:'∑',columns:'‖',divider:'———',code:'<>',table:'|',image:'[img]',database:'DB',ai_write:'/ai',ai_summarize:'/ai',ai_translate:'/ai',ai_continue:'/ai',duplicate:'Ctrl+D',turn_into:'⋯',copy_link:'#',delete:'⌫'};
|
||||
const _GROUP_LABEL={BASIC:'Basic blocks',AI:'AI writing',MEDIA:'Media',DATA:'Data',ACTIONS:'Actions'};
|
||||
// ── Hover preview card (matches Notion's visual hint) ──
|
||||
function _previewHtml(blockId){
|
||||
const p=function(inner,cls){return '<div class="slash-prev" style="padding:0">'+inner+'</div>';};
|
||||
@@ -356,6 +360,49 @@
|
||||
};
|
||||
window.AIC=AIC;
|
||||
|
||||
// ═══════════ AI Autocomplete (v5.9.0) ═══════════
|
||||
// After a short idle while typing in a text block, ask the AI for a short
|
||||
// continuation and surface it as a "Tab to accept" pill anchored to the
|
||||
// block. Tab inserts the suggestion; Escape dismisses it.
|
||||
const AIAC={_timer:null,_sug:'',_bid:null,_busy:false,_enabled:true,_req:0,
|
||||
ROOT:null,
|
||||
_allowed:function(b){ if(!b)return false; return ['paragraph','heading_1','heading_2','heading_3','heading_4','bulleted_list','numbered_list','quote','callout','to_do'].indexOf(b.type)>=0; },
|
||||
mk(){ if(this.ROOT)return this.ROOT; const d=document.createElement('div'); d.id='_aiAC'; d.style.cssText='position:fixed;z-index:1150;display:none;max-width:min(520px,90vw)'; document.body.appendChild(d); this.ROOT=d;
|
||||
document.addEventListener('mousedown',function(ev){ if(AIAC.ROOT&&!AIAC.ROOT.contains(ev.target))AIAC.clear(); });
|
||||
return d; },
|
||||
schedule(bid){ if(!this._enabled)return; const E=window.E; if(!E)return; const idx=E.getIdx(bid); const b=idx>=0?E.blocks[idx]:null; if(!this._allowed(b))return this.clear();
|
||||
clearTimeout(this._timer); const self=this; this._timer=setTimeout(function(){ self.fetch(bid); },900); },
|
||||
fetch(bid){ const E=window.E; if(!E)return; const idx=E.getIdx(bid); const b=idx>=0?E.blocks[idx]:null; if(!this._allowed(b))return this.clear();
|
||||
const el=E.getEl(bid); const prefix=((el&&el.textContent)||b.content||'').trim(); if(prefix.length<8)return this.clear();
|
||||
const ctx=(typeof window.fdGetPageContext==='function')?String(window.fdGetPageContext()||''):'';
|
||||
const req=++this._req; this._busy=true; const self=this; const csrf=(E.csrfTok?E.csrfTok():'');
|
||||
fetch('/api/agent/writing',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({action:'autocomplete',prefix:prefix,context:ctx})})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){ self._busy=false; if(req!==self._req)return; if(d&&d.ok&&d.text){ self.show(bid,d.text); } else { self.clear(); } })
|
||||
.catch(function(){ self._busy=false; if(req===self._req)self.clear(); });
|
||||
},
|
||||
show(bid,text){ text=(text||'').trim(); if(!text){return this.clear();} const E=window.E; const el=E&&E.getEl(bid); if(!el)return;
|
||||
this._sug=text; this._bid=bid; this.mk(); const r=el.getBoundingClientRect();
|
||||
this.ROOT.innerHTML='<div style="display:flex;align-items:center;gap:8px;background:var(--bg-modal);border:1px solid var(--border-light);border-radius:8px;padding:6px 10px;box-shadow:0 6px 24px rgba(0,0,0,.4);font-size:13px;color:var(--text-dim)">'
|
||||
+'<span style="opacity:.7">✨</span><span style="color:var(--text);font-style:italic">'+esc(text)+'</span>'
|
||||
+'<span style="margin-left:6px;font-family:var(--font-mono);font-size:11px;background:var(--bg-secondary);border-radius:4px;padding:1px 6px">Tab</span>'
|
||||
+'<button class="aiac-x" title="Dismiss" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:14px">✕</button></div>';
|
||||
const btn=this.ROOT.querySelector('.aiac-x'); if(btn)btn.onclick=function(){ AIAC.clear(); };
|
||||
let left=Math.min(Math.max(8,r.left+24),window.innerWidth-360); let top=r.bottom+4;
|
||||
if(top+40>window.innerHeight)top=r.top-36;
|
||||
this.ROOT.style.left=left+'px'; this.ROOT.style.top=Math.max(8,top)+'px'; this.ROOT.style.display='block';
|
||||
},
|
||||
accept(){ if(!this._sug)return false; const E=window.E; const idx=E&&E.getIdx(this._bid); if(idx<0){ this.clear(); return false; }
|
||||
const b=E.blocks[idx]; const el=E.getEl(this._bid); E.sync(); E.pushHistory();
|
||||
const cur=((el&&el.textContent)||b.content||''); const next=(cur+this._sug).replace(/\s+$/,' ');
|
||||
b.content=next; if(el){ el.textContent=next; } this.clear(); E.dirty=true; E.autoSave(); if(window.__fdRT&&window.__fdRT.syncNow)window.__fdRT.syncNow();
|
||||
if(el){ el.focus(); ce(el); } return true;
|
||||
},
|
||||
clear(){ this._sug=''; this._bid=null; this._req++; if(this.ROOT){ this.ROOT.style.display='none'; this.ROOT.innerHTML=''; } },
|
||||
get active(){ return !!this._sug; }
|
||||
};
|
||||
window.AIAC=AIAC;
|
||||
|
||||
function renderBlock(b,idx){
|
||||
if(b.type==='meeting')return renderMeetingBlock(b,idx);
|
||||
if(b.type==='table')return renderTableBlock(b,idx);
|
||||
@@ -929,7 +976,7 @@
|
||||
setTimeout(()=>{ct.querySelectorAll('.block-embed-collection').forEach(el=>{const cid=parseInt(el.id.replace('inline-db-',''));if(cid&&window.FlowDeckDB)window.FlowDeckDB.renderInto(el,cid,null);});},50);
|
||||
// Initialize AI Meeting Note widgets (block type 'meeting')
|
||||
setTimeout(()=>{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50);
|
||||
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());})});
|
||||
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());if(window.AIAC)window.AIAC.schedule(el.getAttribute('data-bid'));})});
|
||||
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
|
||||
_selApply();
|
||||
},
|
||||
@@ -1374,6 +1421,11 @@ applyAIBlocks(text){
|
||||
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
|
||||
return;
|
||||
}
|
||||
if(window.AIAC&&window.AIAC.active){
|
||||
if(e.key==='Tab'){e.preventDefault();e.stopPropagation();window.AIAC.accept();return;}
|
||||
if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;}
|
||||
if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();}
|
||||
}
|
||||
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen)SM.open(i2,e2);},15);}
|
||||
if(e.key===' '){
|
||||
if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){
|
||||
@@ -1420,6 +1472,7 @@ applyAIBlocks(text){
|
||||
applySlash(id){
|
||||
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
|
||||
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
|
||||
if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; }
|
||||
if(id==='duplicate'){this.duplicateBlock(idx);return;}
|
||||
if(id==='delete'){this.removeBlock(idx);return;}
|
||||
if(id==='copy_link'){const blk=this.blocks[idx];if(blk)this.copyBlockLink(blk.id);return;}
|
||||
@@ -1433,6 +1486,40 @@ applyAIBlocks(text){
|
||||
else if(id==='columns'){setTimeout(()=>{const ne=this.getEl(block.children[0].id);if(ne)ne.focus();},60);}
|
||||
else{setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}
|
||||
},
|
||||
// ── v5.9.0: AI writing slash commands ──
|
||||
aiSlash(id,idx){
|
||||
const action=id.slice(3); const block=this.blocks[idx];
|
||||
if(block){ this.sync(); this.pushHistory(); block.content=''; }
|
||||
if(action==='write'){ this.render(); this.dirty=true; this.autoSave(); if(window.AIC)window.AIC.open(idx); return; }
|
||||
if(action==='translate'){ this.render(); this.dirty=true; this.autoSave();
|
||||
const lang=window.prompt('Traduire en…','English'); if(!lang){return;}
|
||||
this._aiWritingRun('translate',idx,{target_language:lang},'replace'); return; }
|
||||
this.render(); this.dirty=true; this.autoSave();
|
||||
this._aiWritingRun(action,idx,{},'insert');
|
||||
},
|
||||
_aiWritingRun(action,idx,extra,mode){
|
||||
const self=this; this.sync();
|
||||
const ctx=(typeof window.fdGetPageContext==='function')?String(window.fdGetPageContext()||''):'';
|
||||
const payload={action:action,context:ctx};
|
||||
if(action==='continue'){ let parts=[]; for(let i=0;i<idx;i++){ const b=this.blocks[i]; if(b&&b.content)parts.push(b.content); } payload.prefix=parts.join('\n').slice(-4000); }
|
||||
if(extra&&extra.target_language)payload.target_language=extra.target_language;
|
||||
if(extra&&extra.prompt)payload.prompt=extra.prompt;
|
||||
if(extra&&extra.title)payload.title=extra.title;
|
||||
this.showToast('FlowDeck AI : génération…');
|
||||
fetch('/api/agent/writing',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify(payload)})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){ if(d&&d.ok&&d.text){ self.applyAIBlocksAt(d.text,idx,mode); } else { self.showToast((d&&d.error)||'Échec de la génération','error'); } })
|
||||
.catch(function(err){ self.showToast((err&&err.message)||'Erreur réseau','error'); });
|
||||
},
|
||||
applyAIBlocksAt(text,idx,mode){
|
||||
const nb=this.md2b(text||''); if(!nb.length)return;
|
||||
this.sync(); this.pushHistory();
|
||||
if(mode==='replace'){ this.blocks=nb; }
|
||||
else{ const block=this.blocks[idx]; const empty=block&&block.type==='paragraph'&&!(block.content||'').trim();
|
||||
if(empty){ this.blocks.splice(idx,1,...nb); } else { this.blocks.splice(idx+1,0,...nb); } }
|
||||
this.dirty=true; this.autoSave(); this.render(); _rtSync();
|
||||
setTimeout(()=>{ const el=this.getEl(this.blocks[Math.min(idx,this.blocks.length-1)]?.id); if(el){el.focus();ce(el);} },60);
|
||||
},
|
||||
_doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].checked=v;this.dirty=true;this.autoSave();_rtSync();}},
|
||||
_doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].expanded=!this.blocks[idx].expanded;this.render();this.dirty=true;this.autoSave();_rtSync();}},
|
||||
_addColumn(parentId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children.push({id:genId(),type:'paragraph',content:''});this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const ne=this.getEl(b.children[b.children.length-1].id);if(ne)ne.focus();},60);},
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""FlowDeck — AI Writing Assist tests (v5.9.0).
|
||||
|
||||
Covers the offline writing service (write / summarize / translate / continue /
|
||||
autocomplete / properties), the `/api/agent/writing` + `/api/agent/writing/properties`
|
||||
endpoints, and the editor/database front-end wiring (slash commands, inline
|
||||
autocomplete, AI property fill).
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = db_file.name
|
||||
db_file.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-ai-writing"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
os.environ["LLM_PROVIDER"] = "offline"
|
||||
|
||||
from app.main import app
|
||||
from app.db import init_db, get_conn
|
||||
from app.password_utils import hash_password
|
||||
from app.config import settings
|
||||
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
settings.llm_provider = "offline"
|
||||
|
||||
init_db()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) "
|
||||
"VALUES ('admin', 'Admin', '', ?, 1)",
|
||||
(hash_password("test"),),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
yield TestClient(app)
|
||||
|
||||
try:
|
||||
os.unlink(db_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ── Service: constants & validation ──
|
||||
|
||||
def test_writing_actions_constant(client):
|
||||
from app.services.ai_writing import WRITING_ACTIONS
|
||||
for action in ("write", "summarize", "translate", "continue", "autocomplete", "properties"):
|
||||
assert action in WRITING_ACTIONS
|
||||
|
||||
|
||||
def test_unknown_action_raises(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
svc = AIWritingService()
|
||||
with pytest.raises(ValueError):
|
||||
_run(svc.run("nope"))
|
||||
|
||||
|
||||
# ── Service: offline fallbacks ──
|
||||
|
||||
def test_write_offline(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
res = _run(AIWritingService().run("write", prompt="Guide de démarrage"))
|
||||
assert res["ok"] is True
|
||||
assert res["action"] == "write"
|
||||
assert res["text"].strip()
|
||||
assert res["offline"] is True
|
||||
|
||||
|
||||
def test_summarize_offline(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
context = "# Titre\n\nCeci est un paragraphe détaillé qui explique le sujet du document.\n\n## Section\n\nEncore du contenu pour le résumé automatique."
|
||||
res = _run(AIWritingService().run("summarize", context=context))
|
||||
assert res["ok"] is True
|
||||
assert "Résumé" in res["text"]
|
||||
assert "Points clés" in res["text"]
|
||||
|
||||
|
||||
def test_summarize_offline_empty_context(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
res = _run(AIWritingService().run("summarize", context=""))
|
||||
assert res["ok"] is True
|
||||
assert res["text"].strip()
|
||||
|
||||
|
||||
def test_translate_offline(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
res = _run(AIWritingService().run("translate", context="Bonjour le monde",
|
||||
target_language="English"))
|
||||
assert res["ok"] is True
|
||||
assert "English" in res["text"] or "Traduction" in res["text"]
|
||||
|
||||
|
||||
def test_continue_offline(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
res = _run(AIWritingService().run("continue", context="Intro du document."))
|
||||
assert res["ok"] is True
|
||||
assert res["text"].strip()
|
||||
|
||||
|
||||
def test_autocomplete_offline_short_prefix(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
res = _run(AIWritingService().run("autocomplete", prefix="court"))
|
||||
assert res["ok"] is True
|
||||
assert res["text"] == ""
|
||||
|
||||
|
||||
def test_autocomplete_offline_long_prefix(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
res = _run(AIWritingService().run("autocomplete",
|
||||
prefix="Voici une phrase suffisamment longue pour déclencher"))
|
||||
assert res["ok"] is True
|
||||
assert res["text"].strip()
|
||||
# The completion must NOT echo the prompt prefix.
|
||||
assert "suffisamment longue" not in res["text"]
|
||||
|
||||
|
||||
# ── Service: AI properties ──
|
||||
|
||||
def test_properties_offline_defaults(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
props = [{"name": "Status", "type": "select"},
|
||||
{"name": "Priority", "type": "select"},
|
||||
{"name": "Résumé", "type": "text"}]
|
||||
res = _run(AIWritingService().run("properties", title="Refonte du site",
|
||||
context="Refonte complète du site vitrine de l'entreprise.",
|
||||
properties=props))
|
||||
assert res["ok"] is True
|
||||
assert res["suggestions"]["Status"] == "To do"
|
||||
assert res["suggestions"]["Priority"] == "Medium"
|
||||
assert res["suggestions"]["Résumé"]
|
||||
|
||||
|
||||
def test_properties_offline_ignores_unknown(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
props = [{"name": "Couleur préférée", "type": "text"}]
|
||||
res = _run(AIWritingService().run("properties", title="T", properties=props))
|
||||
assert res["ok"] is True
|
||||
assert res["suggestions"] == {}
|
||||
|
||||
|
||||
def test_properties_empty_list(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
res = _run(AIWritingService().suggest_properties(properties=[]))
|
||||
assert res == {}
|
||||
|
||||
|
||||
def test_coerce_value_types(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
svc = AIWritingService()
|
||||
assert svc._coerce_value("true", "checkbox") is True
|
||||
assert svc._coerce_value("non", "checkbox") is False
|
||||
assert svc._coerce_value("42", "number") == 42
|
||||
assert svc._coerce_value("3.5", "number") == 3.5
|
||||
assert svc._coerce_value("hello", "text") == "hello"
|
||||
|
||||
|
||||
def test_parse_json_object(client):
|
||||
from app.services.ai_writing import AIWritingService
|
||||
assert AIWritingService._parse_json_object('```json\n{"Status": "Done"}\n```') == {"Status": "Done"}
|
||||
assert AIWritingService._parse_json_object("not json") == {}
|
||||
assert AIWritingService._parse_json_object("") == {}
|
||||
|
||||
|
||||
# ── Router: /api/agent/writing ──
|
||||
|
||||
def test_route_write(client):
|
||||
r = client.post("/api/agent/writing", json={"action": "write", "prompt": "Un guide"})
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert d["ok"] is True
|
||||
assert d["action"] == "write"
|
||||
assert d["text"].strip()
|
||||
|
||||
|
||||
def test_route_summarize(client):
|
||||
r = client.post("/api/agent/writing", json={
|
||||
"action": "summarize",
|
||||
"context": "# Titre\n\nUn long paragraphe explicatif sur le fonctionnement du produit."})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
assert r.json()["text"].strip()
|
||||
|
||||
|
||||
def test_route_autocomplete(client):
|
||||
r = client.post("/api/agent/writing", json={
|
||||
"action": "autocomplete",
|
||||
"prefix": "Une phrase assez longue pour lancer la completion"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
|
||||
def test_route_translate(client):
|
||||
r = client.post("/api/agent/writing", json={
|
||||
"action": "translate", "context": "Bonjour", "target_language": "English"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
|
||||
def test_route_missing_action(client):
|
||||
r = client.post("/api/agent/writing", json={})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_route_unknown_action(client):
|
||||
r = client.post("/api/agent/writing", json={"action": "bogus"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_route_write_requires_prompt(client):
|
||||
r = client.post("/api/agent/writing", json={"action": "write"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_route_write_with_title_only(client):
|
||||
r = client.post("/api/agent/writing", json={"action": "write", "title": "Mon titre"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["text"].strip()
|
||||
|
||||
|
||||
def test_route_properties_action_rejected_here(client):
|
||||
r = client.post("/api/agent/writing", json={
|
||||
"action": "properties", "properties": [{"name": "Status", "type": "select"}]})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── Router: /api/agent/writing/properties ──
|
||||
|
||||
def test_route_properties(client):
|
||||
r = client.post("/api/agent/writing/properties", json={
|
||||
"title": "Tâche importante",
|
||||
"content": "Il faut absolument traiter ce point avant la fin de la semaine.",
|
||||
"properties": [{"name": "Status", "type": "select"},
|
||||
{"name": "Priority", "type": "select"}]})
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert d["ok"] is True
|
||||
assert d["suggestions"]["Status"] == "To do"
|
||||
assert d["suggestions"]["Priority"] == "Medium"
|
||||
|
||||
|
||||
def test_route_properties_requires_properties(client):
|
||||
r = client.post("/api/agent/writing/properties", json={"title": "x"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_route_properties_empty_list(client):
|
||||
r = client.post("/api/agent/writing/properties", json={"properties": []})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── Front-end wiring (templates) ──
|
||||
|
||||
def _read_template(name: str) -> str:
|
||||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
with open(os.path.join(base, "app", "templates", name), encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def test_editor_has_ai_slash_commands(client):
|
||||
src = _read_template("_page_editor_scripts.html")
|
||||
for cmd in ("ai_write", "ai_summarize", "ai_translate", "ai_continue"):
|
||||
assert cmd in src
|
||||
assert "AI writing" in src
|
||||
assert "aiSlash" in src
|
||||
assert "/api/agent/writing" in src
|
||||
|
||||
|
||||
def test_editor_has_autocomplete(client):
|
||||
src = _read_template("_page_editor_scripts.html")
|
||||
assert "AIAC" in src
|
||||
assert "autocomplete" in src
|
||||
assert "AIAC.schedule" in src
|
||||
|
||||
|
||||
def test_database_has_ai_properties(client):
|
||||
src = _read_template("_database_table_scripts.html")
|
||||
assert "db-ai-fill-btn" in src
|
||||
assert "aiFillRow" in src
|
||||
assert "/api/agent/writing/properties" in src
|
||||
Reference in New Issue
Block a user