fix(ai): contexte applicatif de l'assistant, dossier vide et liens fiables (#88, BUG-041, BUG-042)
- BUG-041 : le contexte d'un dossier vide ne renvoie plus 404 ; il degrade vers le prompt General enrichi d'un bloc « Dossier vide ». - BUG-042 : liens de fichiers deterministes (nom -> presse-papiers, dossier -> arborescence, chemin -> viewer) et resolution du chemin contre l'index du vault (exact -> suffixe -> basename) avant ouverture, fin des 'File not found'. - #88 : app_context (documents ouverts, repertoire/vault courants, recherche + resultats affiches) et fichiers recemment modifies injectes dans le prompt General. Tests : pytest 963 passed / 6 skipped, ruff 0, mypy 0, frontend 52/52 + validate-imports 36 modules.
This commit is contained in:
+73
-2
@@ -499,13 +499,84 @@ Règles :
|
||||
"""
|
||||
|
||||
|
||||
def build_general_system_prompt(vaults: list[str] | None = None) -> str:
|
||||
"""System prompt for the General assistant (app help + actions)."""
|
||||
def _format_app_context(app_context: dict[str, Any] | None, recent_files: list[dict[str, Any]] | None) -> str:
|
||||
"""Render the live application state for the General assistant prompt.
|
||||
|
||||
The General assistant has no document context; without this block it only
|
||||
knows the app exists. Passing what the user currently sees (open documents,
|
||||
current directory, active search, recently modified files) lets it answer
|
||||
"résume ce que je fais / où j'en suis" style questions.
|
||||
"""
|
||||
app_context = app_context or {}
|
||||
lines: list[str] = []
|
||||
|
||||
vault = app_context.get("vault") or app_context.get("current_vault")
|
||||
if vault:
|
||||
lines.append(f"- Vault sélectionné : {vault}")
|
||||
directory = app_context.get("directory")
|
||||
if directory:
|
||||
lines.append(f"- Répertoire courant : {directory}")
|
||||
current_path = app_context.get("current_path")
|
||||
if current_path:
|
||||
lines.append(f"- Document affiché dans le viewer : {current_path}")
|
||||
|
||||
docs = app_context.get("open_documents") or []
|
||||
rendered_docs = []
|
||||
for doc in docs:
|
||||
if not isinstance(doc, dict) or not doc.get("path"):
|
||||
continue
|
||||
rendered_docs.append(f"{doc['path']} (vault {doc['vault']})" if doc.get("vault") else str(doc["path"]))
|
||||
if rendered_docs:
|
||||
lines.append("- Documents ouverts dans les onglets/panneaux : " + ", ".join(rendered_docs))
|
||||
|
||||
query = app_context.get("search_query")
|
||||
if query:
|
||||
total = app_context.get("search_total")
|
||||
suffix = f" ({total} résultat(s))" if isinstance(total, int) else ""
|
||||
lines.append(f"- Recherche en cours : « {query} »{suffix}")
|
||||
|
||||
results = app_context.get("search_results") or []
|
||||
rendered_results = [str(r.get("path")) for r in results[:10] if isinstance(r, dict) and r.get("path")]
|
||||
if rendered_results:
|
||||
lines.append("- Premiers résultats affichés : " + ", ".join(rendered_results))
|
||||
|
||||
if recent_files:
|
||||
rendered_recent = [
|
||||
f"{f.get('vault')}/{f.get('path')}" for f in recent_files[:10] if f.get("path")
|
||||
]
|
||||
if rendered_recent:
|
||||
lines.append("- Fichiers récemment modifiés : " + ", ".join(rendered_recent))
|
||||
|
||||
if not lines:
|
||||
return ""
|
||||
return (
|
||||
"## Contexte applicatif actuel\n"
|
||||
"Voici ce que l'utilisateur voit ou fait en ce moment dans ObsiGate. "
|
||||
"Sers-t'en pour comprendre sa demande ; ne le répète pas inutilement.\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def build_general_system_prompt(
|
||||
vaults: list[str] | None = None,
|
||||
app_context: dict[str, Any] | None = None,
|
||||
recent_files: list[dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""System prompt for the General assistant (app help + actions).
|
||||
|
||||
``app_context`` carries the live UI state (open documents, current
|
||||
directory, active search) and ``recent_files`` the last modified files, so
|
||||
the assistant knows what the user is doing rather than answering blind.
|
||||
"""
|
||||
prompt = GENERAL_SYSTEM_PROMPT
|
||||
if vaults:
|
||||
prompt += "\nVaults disponibles : " + ", ".join(sorted(vaults)) + "\n"
|
||||
else:
|
||||
prompt += "\nAucun vault n'est actuellement configuré.\n"
|
||||
block = _format_app_context(app_context, recent_files)
|
||||
if block:
|
||||
prompt += "\n" + block
|
||||
return prompt
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ class BooksLMContextRequest(BaseModel):
|
||||
default_factory=list,
|
||||
description="Ad-hoc directories added with the '@' command (any mode)",
|
||||
)
|
||||
app_context: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Live client UI state for the General assistant: open_documents, "
|
||||
"current_path, directory, vault, search_query, search_total, search_results.",
|
||||
)
|
||||
|
||||
|
||||
class BooksLMChatRequest(BaseModel):
|
||||
@@ -104,6 +109,11 @@ class BooksLMChatRequest(BaseModel):
|
||||
description="Conversation snapshot returned alongside a ``confirmation`` event, "
|
||||
"echoed back to resume the agent run.",
|
||||
)
|
||||
app_context: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Live client UI state for the General assistant: open_documents, "
|
||||
"current_path, directory, vault, search_query, search_total, search_results.",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_mode(mode: str | None) -> str:
|
||||
@@ -154,6 +164,34 @@ def _build_context(
|
||||
return base
|
||||
|
||||
|
||||
def _submitted_app_context(req) -> dict[str, Any] | None:
|
||||
"""Return the client-submitted live app state (may be None)."""
|
||||
ctx = getattr(req, "app_context", None)
|
||||
return ctx if isinstance(ctx, dict) else None
|
||||
|
||||
|
||||
def _recent_files_for_prompt(current_user, limit: int = 10) -> list[dict[str, Any]]:
|
||||
"""Best-effort list of the user's most recently modified files.
|
||||
|
||||
Used to give the General assistant a sense of what the user has been
|
||||
working on. Never raises: any failure yields an empty list.
|
||||
"""
|
||||
try:
|
||||
from backend.services.recent import list_recent
|
||||
|
||||
username = current_user.get("username") if isinstance(current_user, dict) else None
|
||||
user_vaults = (
|
||||
current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
if isinstance(current_user, dict)
|
||||
else []
|
||||
)
|
||||
data = list_recent(username, user_vaults, limit=limit, mode="modified")
|
||||
return list(data.get("files", []))
|
||||
except Exception: # pragma: no cover - defensive, prompt enrichment only
|
||||
logger.debug("Could not gather recent files for assistant prompt", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def _resolve_system_prompt(req, current_user) -> str:
|
||||
"""Resolve the vault access and build the assistant system prompt.
|
||||
|
||||
@@ -179,11 +217,28 @@ def _resolve_system_prompt(req, current_user) -> str:
|
||||
effective_mode = context.get("scope", mode)
|
||||
|
||||
if effective_mode == "general":
|
||||
prompt = build_general_system_prompt(list(index.keys()))
|
||||
prompt = build_general_system_prompt(
|
||||
list(index.keys()),
|
||||
app_context=_submitted_app_context(req),
|
||||
recent_files=_recent_files_for_prompt(current_user),
|
||||
)
|
||||
elif effective_mode == "documents":
|
||||
prompt = build_system_prompt(context, scope="documents")
|
||||
elif context["file_count"] == 0:
|
||||
raise HTTPException(status_code=404, detail="Aucun fichier markdown trouvé dans ce dossier")
|
||||
# Empty (or unreadable) directory: don't block the request. Answer as
|
||||
# the General assistant would, telling the model the folder is empty so
|
||||
# it can still help (create a file, explain the app, etc.).
|
||||
prompt = build_general_system_prompt(
|
||||
list(index.keys()),
|
||||
app_context=_submitted_app_context(req),
|
||||
recent_files=_recent_files_for_prompt(current_user),
|
||||
)
|
||||
prompt += (
|
||||
f"\n## Dossier vide\nLe dossier « {req.directory or '/'} » "
|
||||
f"(vault {req.vault}) ne contient aucun fichier markdown exploitable. "
|
||||
"Réponds quand même à la demande de l'utilisateur sans contexte "
|
||||
"documentaire, et propose une action de création si c'est pertinent.\n"
|
||||
)
|
||||
else:
|
||||
prompt = build_system_prompt(context, scope="directory")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user