Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31a0ab5b70 | ||
|
|
f783394f09 |
+60
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.67.2**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.68.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,65 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.68.1] — 2026-10-14
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **UI configuration — boîtes d'édition « Clés API & MCP »** : la rangée de
|
||||
création de clé (nom / expiration / portée / vaults / bouton) débordait de la
|
||||
section sur desktop — `flex-wrap: wrap` n'était appliqué qu'en mobile
|
||||
(≤768px). Le `flex-wrap` est désormais global sur `.config-add-row`, les
|
||||
champs passent en `flex: 1 1 140px` (avec `width: auto !important` pour
|
||||
neutraliser les largeurs inline 180/140/150/200px) et le bouton en
|
||||
`flex: 1 1 auto` : les composants passent à la ligne au lieu de déborder.
|
||||
Vérifié aux viewports 1600 / 1100 / 900 px (overflow horizontal : 0).
|
||||
|
||||
---
|
||||
|
||||
## [2.68.0] — 2026-10-14
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#197 - Listes de fichiers : `mtime` + `size` pour réconciliation externe (Gitea #4)**
|
||||
- `GET /api/vault/{vault}/paths` expose `size`/`mtime`/`mtime_iso` par fichier
|
||||
(index, même contrat que `list_all_files`) ; `GET /api/browse/{vault}` expose
|
||||
`modified`/`modified_iso` ; outil MCP `list_directory` aligné.
|
||||
- `PUT /api/file/{vault}/save` rafraîchit l'index en synchrone (read-after-write).
|
||||
- Fiche `docs/features/tiktak-integration-197-200.md`, guide API_REST §5, tests
|
||||
`tests/test_sync_lists.py` (7).
|
||||
- **#198 - Webhooks : filtre `vault` / `path_prefix` + `file_modified` émis (Gitea #3)**
|
||||
- Filtres optionnels par webhook (sans filtre = tout, comme avant) ; `POST`/`PATCH`/
|
||||
`GET /api/webhooks` les gèrent ; `PUT …/save` émet `file_modified`.
|
||||
- UI : champs filtre + badge, aide intégrée FR/EN à jour ; tests
|
||||
`tests/test_webhook_filters.py` (10).
|
||||
- **#199 - Clés API à portée limitée : vaults + périmètre `files` (Gitea #2)**
|
||||
- `POST /api/auth/tokens` accepte `vaults?` + `scope` (`full`|`files`) ; 403 hors
|
||||
vault (REST + MCP) et hors périmètre (`TokenScopeMiddleware`, outils
|
||||
`token_scope_denied`, `list_tools` filtré) ; UI + i18n FR/EN ; clés existantes
|
||||
inchangées (`full`). Guides API_REST (§2.3) / MCP ; tests
|
||||
`tests/test_scoped_tokens.py` (13).
|
||||
- **#200 - Runbook de provisionnement du vault « TikTak » (Gitea #1)** — procédure
|
||||
d'exploitation + recette `curl` dans la fiche feature (reste à exécuter sur l'hôte).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-112** — `pwsh -File scripts/e2e-server.ps1 2>&1 | head -n 20` ne se
|
||||
terminait jamais : `Start-Process` laissait uvicorn hériter des handles
|
||||
stdout/stderr du pwsh parent, le pipe vers `head` restait ouvert tant que le
|
||||
serveur (immortel par design) vivait. Le serveur, lui, était prêt en ~7 s.
|
||||
`scripts/e2e-server.ps1` lance désormais uvicorn via `Win32_Process.Create`
|
||||
(WMI), processus détaché sans handle hérité ; les variables `OBSIGATE_*` sont
|
||||
passées dans la ligne `cmd /c` via `set "NOM=valeur"` (le processus WMI
|
||||
n'hérite pas de l'environnement du parent). Vérifié : commande en 6,9 s,
|
||||
serveur up avec les 2 fixtures, `stop`/`status` OK.
|
||||
|
||||
- **#197** — `update_single_file`/`remove_single_file` recevaient des chemins relatifs
|
||||
au vault alors qu'ils exigeaient un absolu : le rafraîchissement d'index
|
||||
post-écriture était un no-op silencieux (masqué par le watcher en production).
|
||||
Les deux formes sont désormais acceptées.
|
||||
|
||||
---
|
||||
|
||||
## [2.67.2] — 2026-10-10
|
||||
|
||||
---
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.67.2).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.68.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.67.2 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.68.1 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.67.2).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.68.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.67.2 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.68.1 | Last updated: September 2026*
|
||||
|
||||
@@ -215,15 +215,37 @@ def _save_api_tokens(data: dict):
|
||||
tmp.replace(API_TOKENS_FILE)
|
||||
|
||||
|
||||
def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]:
|
||||
#: Endpoint perimeters accepted for scoped API tokens (#199). ``"full"``
|
||||
#: keeps today's behaviour ; ``"files"`` restricts the token to the file
|
||||
#: read/write surface (enforced by ``TokenScopeMiddleware`` on REST and by
|
||||
#: the tool registry on MCP/agent calls).
|
||||
API_TOKEN_SCOPES = ("full", "files")
|
||||
|
||||
|
||||
def create_api_token(
|
||||
user: dict,
|
||||
name: str,
|
||||
expiry_key: str,
|
||||
vaults: list[str] | None = None,
|
||||
scope: str = "full",
|
||||
) -> tuple[dict, str]:
|
||||
"""Create a persistent API/MCP token. Returns (record, jwt_string).
|
||||
|
||||
``expiry_key`` must be one of API_TOKEN_EXPIRY_CHOICES; "never" omits the
|
||||
``exp`` claim (valid until explicitly revoked).
|
||||
|
||||
#199 (synchro TikTak) — optional least-privilege restriction:
|
||||
``vaults`` limits the token to the given vaults (``None`` = snapshot of
|
||||
the creator's vaults, i.e. today's behaviour) and ``scope`` limits the
|
||||
endpoint perimeter (``"full"`` or ``"files"``). Both are embedded in the
|
||||
JWT so enforcement stays stateless; the record keeps them for display.
|
||||
"""
|
||||
if expiry_key not in API_TOKEN_EXPIRY_CHOICES:
|
||||
raise ValueError("Expiration invalide")
|
||||
if scope not in API_TOKEN_SCOPES:
|
||||
raise ValueError("Portée invalide (full | files)")
|
||||
seconds = API_TOKEN_EXPIRY_CHOICES[expiry_key]
|
||||
resolved_vaults = list(vaults) if vaults is not None else list(user.get("vaults", []))
|
||||
with _api_tokens_lock:
|
||||
data = _load_api_tokens()
|
||||
tokens = data["tokens"]
|
||||
@@ -235,11 +257,12 @@ def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]
|
||||
payload = {
|
||||
"sub": user["username"],
|
||||
"role": user.get("role", "user"),
|
||||
"vaults": user.get("vaults", []),
|
||||
"vaults": resolved_vaults,
|
||||
"jti": jti,
|
||||
"iat": now,
|
||||
"type": "access",
|
||||
"api": True,
|
||||
"scope": scope,
|
||||
}
|
||||
if seconds is not None:
|
||||
payload["exp"] = now + seconds
|
||||
@@ -252,6 +275,8 @@ def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]
|
||||
"expires_at": payload.get("exp"),
|
||||
"expiry_key": expiry_key,
|
||||
"last_used_at": None,
|
||||
"vaults": resolved_vaults,
|
||||
"scope": scope,
|
||||
}
|
||||
tokens[jti] = record
|
||||
_save_api_tokens(data)
|
||||
@@ -262,11 +287,16 @@ def list_api_tokens(username: str) -> list[dict]:
|
||||
"""Token metadata for one user, newest first."""
|
||||
data = _load_api_tokens()
|
||||
now = int(time.time())
|
||||
items = [
|
||||
{**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now}
|
||||
for t in data["tokens"].values()
|
||||
if t["username"] == username
|
||||
]
|
||||
items = []
|
||||
for t in data["tokens"].values():
|
||||
if t["username"] != username:
|
||||
continue
|
||||
# #199 : les clés créées avant la portée limitée n'ont ni scope ni
|
||||
# vaults stockés → portée pleine, comportement inchangé.
|
||||
entry = {**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now}
|
||||
entry.setdefault("scope", "full")
|
||||
entry.setdefault("vaults", None)
|
||||
items.append(entry)
|
||||
return sorted(items, key=lambda t: t["created_at"], reverse=True)
|
||||
|
||||
|
||||
|
||||
@@ -115,6 +115,9 @@ def get_current_user(
|
||||
user["_token_vaults"] = payload.get("vaults", [])
|
||||
# Attach the token id for per-token rate limiting (AI tool layer).
|
||||
user["_token_jti"] = payload.get("jti")
|
||||
# #199 : périmètre d'endpoints du token (défaut "full" = comportement
|
||||
# actuel, y compris pour les clés créées avant la portée limitée).
|
||||
user["_token_scope"] = payload.get("scope", "full")
|
||||
# Feature #107: track last usage of user-managed API/MCP tokens
|
||||
# (throttled write — this dependency runs on both REST and /mcp paths).
|
||||
if payload.get("api"):
|
||||
|
||||
+28
-2
@@ -1003,6 +1003,8 @@ async def delete_user_endpoint(
|
||||
class CreateApiTokenRequest(BaseModel):
|
||||
name: str
|
||||
expiry: str # 1d | 30d | 180d | 365d | never
|
||||
vaults: list[str] | None = None # #199 : None = vaults du créateur (comportement actuel)
|
||||
scope: str = "full" # #199 : "full" | "files"
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
@@ -1020,9 +1022,33 @@ async def create_user_token(
|
||||
request: Request,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE."""
|
||||
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE.
|
||||
|
||||
#199 : portée optionnelle — ``vaults`` (sous-ensemble des vaults du
|
||||
créateur, défaut = tous) et ``scope`` (``"full"`` ou ``"files"``).
|
||||
"""
|
||||
from backend.auth.jwt_handler import API_TOKEN_SCOPES
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.indexer import index
|
||||
|
||||
scope = (req.scope or "full").strip().lower()
|
||||
if scope not in API_TOKEN_SCOPES:
|
||||
raise HTTPException(400, "Portée invalide (full | files)")
|
||||
vaults: list[str] | None = None
|
||||
if req.vaults is not None:
|
||||
cleaned = sorted({v.strip() for v in req.vaults if isinstance(v, str) and v.strip()})
|
||||
if not cleaned:
|
||||
raise HTTPException(400, "Liste de vaults vide")
|
||||
unknown = [v for v in cleaned if v not in index]
|
||||
if unknown:
|
||||
raise HTTPException(400, f"Vaults inconnus : {', '.join(unknown)}")
|
||||
denied = [v for v in cleaned if not check_vault_access(v, current_user)]
|
||||
if denied:
|
||||
raise HTTPException(403, f"Accès refusé aux vaults : {', '.join(denied)}")
|
||||
vaults = cleaned
|
||||
try:
|
||||
record, token = create_api_token(current_user, req.name.strip(), req.expiry)
|
||||
record, token = create_api_token(current_user, req.name.strip(), req.expiry,
|
||||
vaults=vaults, scope=scope)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
from backend.audit import log_config_change
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# backend/auth/scope.py
|
||||
# Scoped API/MCP tokens (#199, synchro TikTak — Gitea #2).
|
||||
#
|
||||
# A token may carry an endpoint perimeter in its ``scope`` JWT claim:
|
||||
# - ``"full"`` (default, and the case of every token issued before #199):
|
||||
# no restriction — today's behaviour, unchanged ;
|
||||
# - ``"files"``: file read/write surface only (vault file CRUD, listings,
|
||||
# search, backups read/restore). Everything else (admin, config, AI,
|
||||
# webhooks, shares, auth management…) answers **403**.
|
||||
#
|
||||
# Vault restriction is orthogonal and needs no new machinery: a scoped token
|
||||
# embeds the allowed vault list in its ``vaults`` JWT claim, so the existing
|
||||
# ``check_vault_access`` (REST routes) and ``ToolContext.require_vault_access``
|
||||
# (MCP/agent tools) refuse other vaults with 403 — for REST *and* MCP alike.
|
||||
"""Endpoint perimeter for scoped API tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
SCOPE_FULL = "full"
|
||||
SCOPE_FILES = "files"
|
||||
KNOWN_SCOPES = (SCOPE_FULL, SCOPE_FILES)
|
||||
|
||||
# (methods, path prefix or exact path). ``methods`` None = any method.
|
||||
# Keep this list tight: a sync client (TikTak) needs file CRUD, listings,
|
||||
# search and backup read/restore — nothing else.
|
||||
_RULES_FOR_FILES: tuple[tuple[frozenset[str] | None, str, bool], ...] = (
|
||||
# (methods, path, exact_only)
|
||||
(frozenset({"GET"}), "/api/health", False),
|
||||
(frozenset({"GET"}), "/api/auth/me", True),
|
||||
(frozenset({"GET"}), "/api/vaults", True),
|
||||
(None, "/api/browse/", False),
|
||||
(None, "/api/file/", False),
|
||||
(None, "/api/media/", False),
|
||||
(None, "/api/directory/", False),
|
||||
(None, "/api/move/", False),
|
||||
(None, "/api/vault/", False), # {vault}/files, {vault}/paths, batch-upload…
|
||||
(frozenset({"GET"}), "/api/search", False),
|
||||
(frozenset({"GET"}), "/api/tree-search", True),
|
||||
(frozenset({"GET"}), "/api/suggest", False),
|
||||
(frozenset({"GET"}), "/api/tags", False),
|
||||
(frozenset({"GET"}), "/api/graph/", False),
|
||||
(frozenset({"GET"}), "/api/backups", False),
|
||||
(frozenset({"GET"}), "/docs", False),
|
||||
(frozenset({"GET"}), "/redoc", True),
|
||||
(frozenset({"GET"}), "/openapi.json", True),
|
||||
(None, "/mcp", False),
|
||||
)
|
||||
|
||||
|
||||
def is_path_allowed_for_scope(scope: str | None, method: str, path: str) -> bool:
|
||||
"""True when a token with *scope* may call (*method*, *path*).
|
||||
|
||||
- ``None``/``"full"`` → everything allowed (backward compatible).
|
||||
- ``"files"`` → allow-list above (API surface only ; static assets and
|
||||
public pages carry no data and are handled by the caller — the
|
||||
middleware only gates ``/api/*`` and ``/mcp*``).
|
||||
- unknown scope → fail-closed (deny).
|
||||
"""
|
||||
if scope in (None, SCOPE_FULL):
|
||||
return True
|
||||
if scope != SCOPE_FILES:
|
||||
return False
|
||||
if not (path.startswith("/api/") or path.startswith("/mcp")):
|
||||
return True
|
||||
method = (method or "GET").upper()
|
||||
for methods, rule_path, exact in _RULES_FOR_FILES:
|
||||
if methods is not None and method not in methods:
|
||||
continue
|
||||
if exact:
|
||||
if path == rule_path:
|
||||
return True
|
||||
elif path == rule_path or path.startswith(rule_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def scope_of_user(user: dict[str, Any] | None) -> str:
|
||||
"""Endpoint scope attached to an authenticated user (default ``"full"``)."""
|
||||
if not user:
|
||||
return SCOPE_FULL
|
||||
return user.get("_token_scope") or SCOPE_FULL
|
||||
|
||||
|
||||
class TokenScopeMiddleware:
|
||||
"""Pure-ASGI middleware enforcing the endpoint perimeter of scoped tokens.
|
||||
|
||||
Denies out-of-perimeter calls made with a scoped token (**403**) before
|
||||
they reach the routes. Only *adds* restrictions: the ``scope`` claim is
|
||||
read unverified here, and the signature is still verified downstream by
|
||||
``get_current_user`` (an invalid token is rejected with 401 a step later
|
||||
anyway). Full-scope and scopeless tokens pass through untouched.
|
||||
"""
|
||||
|
||||
def __init__(self, app: Any):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
||||
if scope.get("type") == "http":
|
||||
method = scope.get("method", "GET")
|
||||
path = scope.get("path", "")
|
||||
if path.startswith("/api/") or path.startswith("/mcp"):
|
||||
token_scope = _scope_from_asgi(scope)
|
||||
if token_scope not in (None, SCOPE_FULL) and not is_path_allowed_for_scope(
|
||||
token_scope, method, path
|
||||
):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
response = JSONResponse(
|
||||
{"detail": "Token à portée limitée : endpoint hors périmètre 'files'"},
|
||||
status_code=403,
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def _scope_from_asgi(asgi_scope: Any) -> str | None:
|
||||
"""Extract the unverified ``scope`` claim from an ASGI scope's auth."""
|
||||
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in asgi_scope.get("headers", [])}
|
||||
token: str | None = None
|
||||
auth = headers.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
else:
|
||||
for part in headers.get("cookie", "").split(";"):
|
||||
name, _, value = part.strip().partition("=")
|
||||
if name.strip() == "access_token" and value.strip():
|
||||
token = value.strip()
|
||||
break
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
from jose import jwt as _jwt
|
||||
|
||||
claims = _jwt.get_unverified_claims(token)
|
||||
except Exception:
|
||||
return None
|
||||
token_scope = (claims or {}).get("scope")
|
||||
return token_scope if isinstance(token_scope, str) and token_scope else None
|
||||
@@ -1229,6 +1229,13 @@ async def update_single_file(vault_name: str, abs_file_path: str) -> dict[str, A
|
||||
if not vault_path:
|
||||
return None
|
||||
|
||||
# #197 : les routes passent historiquement un chemin relatif au vault
|
||||
# (``result["path"]``) alors que la docstring exigeait un absolu — le
|
||||
# rafraîchissement était un no-op silencieux (couvert en prod par le
|
||||
# watcher, mais pas dans les tests ni en polling lent). On accepte les deux.
|
||||
if not Path(abs_file_path).is_absolute():
|
||||
abs_file_path = str(Path(vault_path) / abs_file_path)
|
||||
|
||||
# Get vault configuration for hidden files handling
|
||||
vault_cfg = vault_data.get("config") or vault_config.get(vault_name, {})
|
||||
|
||||
@@ -1272,6 +1279,10 @@ async def remove_single_file(vault_name: str, abs_file_path: str) -> dict[str, A
|
||||
if not vault_path:
|
||||
return None
|
||||
|
||||
# #197 : même tolérance relatif/absolu que ``update_single_file``.
|
||||
if not Path(abs_file_path).is_absolute():
|
||||
abs_file_path = str(Path(vault_path) / abs_file_path)
|
||||
|
||||
try:
|
||||
rel_path = str(Path(abs_file_path).relative_to(vault_path)).replace("\\", "/")
|
||||
except ValueError:
|
||||
|
||||
@@ -486,6 +486,13 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
|
||||
# Security headers on all responses
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# Scoped API tokens (#199) : les clés à portée "files" sont confinées à la
|
||||
# surface fichiers côté REST (403 hors périmètre). Placé avant CORS pour que
|
||||
# le refus s'applique aussi aux requêtes out-of-scope.
|
||||
from backend.auth.scope import TokenScopeMiddleware
|
||||
|
||||
app.add_middleware(TokenScopeMiddleware)
|
||||
|
||||
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
|
||||
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
|
||||
# donc toute lecture cross-origin est refusée (défense explicite, plus
|
||||
|
||||
@@ -43,6 +43,7 @@ from backend.auth.middleware import get_current_user, is_auth_enabled
|
||||
from backend.services.files import read_file_text
|
||||
from backend.services.vaults import list_accessible_vaults
|
||||
from backend.tools.api import (
|
||||
FILES_SCOPE_TOOLS,
|
||||
ToolContext,
|
||||
ToolError,
|
||||
ToolMode,
|
||||
@@ -191,6 +192,14 @@ def _tool_definitions() -> list[types.Tool]:
|
||||
return definitions
|
||||
|
||||
|
||||
def _mcp_base_name(mcp_name: str) -> str:
|
||||
"""Strip the ``propose_``/``apply_`` prefix back to the registry tool name."""
|
||||
for prefix in ("propose_", "apply_"):
|
||||
if mcp_name.startswith(prefix):
|
||||
return mcp_name[len(prefix):]
|
||||
return mcp_name
|
||||
|
||||
|
||||
def _parse_vault_uri(uri: str) -> tuple[str, str]:
|
||||
"""Split ``vault://name/path`` into ``(vault, path)``."""
|
||||
parsed = urlparse(uri)
|
||||
@@ -208,6 +217,10 @@ def build_server() -> Server:
|
||||
# ── Tools ──────────────────────────────────────────────────────────
|
||||
@server.list_tools()
|
||||
async def _list_tools() -> list[types.Tool]:
|
||||
# #199 : un token à portée "files" ne voit que les outils fichiers.
|
||||
user = _user_from_context(server)
|
||||
if (user.get("_token_scope") or "full") == "files":
|
||||
return [t for t in _tool_definitions() if _mcp_base_name(t.name) in FILES_SCOPE_TOOLS]
|
||||
return _tool_definitions()
|
||||
|
||||
@server.call_tool()
|
||||
@@ -262,6 +275,7 @@ def build_server() -> Server:
|
||||
user = _user_from_context(server)
|
||||
vault, path = _parse_vault_uri(str(uri))
|
||||
ctx = ToolContext(user=user, mode=ToolMode.MCP)
|
||||
ctx.require_tool_allowed("read_file") # #199 : portée inconnue → refus
|
||||
ctx.require_vault_access(vault)
|
||||
data = read_file_text(vault, path, redact=True, max_bytes=MAX_RESOURCE_BYTES)
|
||||
mime = "text/markdown" if Path(path).suffix.lower() == ".md" else "text/plain"
|
||||
@@ -352,6 +366,12 @@ def _propose(user: dict[str, Any], tool: str, arguments: dict[str, Any]) -> list
|
||||
params = spec.input_model.model_validate(arguments)
|
||||
vault = getattr(params, "vault", None)
|
||||
ctx = ToolContext(user=user, mode=ToolMode.MCP)
|
||||
# #199 : les tokens à portée limitée ne peuvent proposer que les outils
|
||||
# fichiers (même garde que call_tool, qui couvre apply_).
|
||||
try:
|
||||
ctx.require_tool_allowed(tool)
|
||||
except ToolError as e:
|
||||
return _text(e.to_dict())
|
||||
if vault and vault != "all":
|
||||
ctx.require_vault_access(vault)
|
||||
if spec.risk == ToolRisk.DANGEROUS:
|
||||
|
||||
@@ -242,8 +242,8 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"response": {"id": "abc…", "token": "abc…", "vault": "TestVault", "path": "notes/Accueil.md", "url": "/s/abc…"},
|
||||
},
|
||||
("post", "/api/webhooks"): {
|
||||
"request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t"},
|
||||
"response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True},
|
||||
"request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t", "vault": "TikTak", "path_prefix": "Inbox"},
|
||||
"response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True, "vault": "TikTak", "path_prefix": "Inbox"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,15 @@ async def api_file_save(
|
||||
content = body.get("content", "")
|
||||
result = service_edit_file(vault_name, path, content, backup=backup)
|
||||
|
||||
# #197 (synchro TikTak) : rafraîchir l'index de façon synchrone pour une
|
||||
# cohérence read-after-write — un client qui liste juste après le save
|
||||
# doit voir le nouveau mtime/size sans attendre le watcher.
|
||||
await update_single_file(vault_name, result["path"])
|
||||
|
||||
# #198 : la sauvegarde texte émet file_modified (événement clé pour tirer
|
||||
# les retours de synchro côté TikTak en phase 3).
|
||||
await dispatch_webhooks("file_modified", {"vault": vault_name, "path": result["path"]})
|
||||
|
||||
# Audit log
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
||||
|
||||
@@ -34,7 +34,9 @@ async def api_webhooks_create(body: dict = Body(...), current_user=Depends(requi
|
||||
secret = body.get("secret")
|
||||
if not url:
|
||||
raise HTTPException(400, "URL is required")
|
||||
return create_webhook(name, url, events, secret)
|
||||
# #198 : filtres optionnels vault / path_prefix (None = tout recevoir).
|
||||
return create_webhook(name, url, events, secret,
|
||||
vault=body.get("vault"), path_prefix=body.get("path_prefix"))
|
||||
|
||||
|
||||
@router.patch("/{webhook_id}", response_model=WebhookModel)
|
||||
|
||||
@@ -300,6 +300,8 @@ class BrowseItem(BaseModel):
|
||||
children_count: int | None = Field(default=None, description="Number of children (directories only)")
|
||||
size: int | None = Field(default=None, description="File size in bytes")
|
||||
extension: str | None = Field(default=None, description="File extension")
|
||||
modified: float | None = Field(default=None, description="Modification time, epoch seconds (#197)")
|
||||
modified_iso: str | None = Field(default=None, description="Modification time, ISO 8601 (#197)")
|
||||
|
||||
|
||||
class BrowseResponse(BaseModel):
|
||||
@@ -667,6 +669,9 @@ class VaultPathEntry(BaseModel):
|
||||
path: str = Field(description="Full relative path")
|
||||
name: str = Field(description="File or directory name")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
size: int | None = Field(default=None, description="File size in bytes (#197, files only)")
|
||||
mtime: float | None = Field(default=None, description="Modification time, epoch seconds (#197, files only)")
|
||||
mtime_iso: str | None = Field(default=None, description="Modification time, ISO 8601 (#197, files only)")
|
||||
|
||||
|
||||
class VaultPathsResponse(BaseModel):
|
||||
@@ -1042,6 +1047,8 @@ class WebhookModel(BaseModel):
|
||||
enabled: bool = True
|
||||
created_at: str | None = None
|
||||
last_fired_at: str | None = None
|
||||
vault: str | None = Field(default=None, description="#198 vault filter (None = all vaults)")
|
||||
path_prefix: str | None = Field(default=None, description="#198 path-prefix filter (None = whole vault)")
|
||||
|
||||
|
||||
class ShareModel(BaseModel):
|
||||
|
||||
@@ -198,23 +198,51 @@ def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]:
|
||||
once lets the client filter files/directories instantly instead of issuing
|
||||
a request per keystroke.
|
||||
|
||||
#197 (synchro TikTak) : chaque entrée fichier expose aussi ``size``,
|
||||
``mtime`` (epoch) et ``mtime_iso`` — un client externe peut détecter les
|
||||
fichiers modifiés depuis un horodatage en **un seul appel**, sans relire
|
||||
chaque fichier. Les valeurs viennent de l'index (source unique, même
|
||||
contrat que ``list_all_files``) ; les répertoires portent ``None``.
|
||||
|
||||
Args:
|
||||
vault: Vault name.
|
||||
limit: Maximum number of entries returned.
|
||||
|
||||
Returns:
|
||||
``{"vault", "count", "results": [{vault, path, name, type}]}``.
|
||||
``{"vault", "count", "results": [{vault, path, name, type, size?,
|
||||
mtime?, mtime_iso?}]}``.
|
||||
"""
|
||||
from backend.indexer import path_index
|
||||
from datetime import datetime
|
||||
|
||||
from backend.indexer import get_vault_data, path_index
|
||||
|
||||
entries = path_index.get(vault, [])
|
||||
results = [
|
||||
{
|
||||
meta: dict[str, dict[str, Any]] = {}
|
||||
data = get_vault_data(vault)
|
||||
if data:
|
||||
for info in data.get("files", []):
|
||||
meta[info["path"]] = info
|
||||
|
||||
results = []
|
||||
for entry in entries[: max(0, limit)]:
|
||||
item: dict[str, Any] = {
|
||||
"vault": vault,
|
||||
"path": entry["path"],
|
||||
"name": entry["name"],
|
||||
"type": entry["type"],
|
||||
"size": None,
|
||||
"mtime": None,
|
||||
"mtime_iso": None,
|
||||
}
|
||||
for entry in entries[: max(0, limit)]
|
||||
]
|
||||
info = meta.get(entry["path"])
|
||||
if info is not None:
|
||||
item["size"] = info.get("size")
|
||||
iso = info.get("modified")
|
||||
item["mtime_iso"] = iso
|
||||
if iso:
|
||||
try:
|
||||
item["mtime"] = datetime.fromisoformat(iso).timestamp()
|
||||
except (ValueError, TypeError):
|
||||
item["mtime"] = None
|
||||
results.append(item)
|
||||
return {"vault": vault, "count": len(results), "results": results}
|
||||
|
||||
@@ -49,6 +49,8 @@ def get_vault_root(vault_name: str) -> Path:
|
||||
|
||||
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
"""Return the direct children of a vault directory (directories first)."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||
from backend.vault_settings import get_vault_setting
|
||||
|
||||
@@ -89,11 +91,14 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
"children_count": file_count,
|
||||
})
|
||||
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
|
||||
stat = entry.stat()
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"path": rel,
|
||||
"type": "file",
|
||||
"size": entry.stat().st_size,
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"extension": entry.suffix.lower(),
|
||||
})
|
||||
except PermissionError:
|
||||
|
||||
@@ -19,6 +19,7 @@ from backend.tools import service as _service # noqa: F401 (registers tools)
|
||||
from backend.tools import spreadsheets as _spreadsheets # noqa: F401 (registers existing-workbook tools #153 A6)
|
||||
from backend.tools import web as _web # noqa: F401 (registers web tools)
|
||||
from backend.tools.context import (
|
||||
FILES_SCOPE_TOOLS,
|
||||
ToolConfirmationRequired,
|
||||
ToolContext,
|
||||
ToolError,
|
||||
@@ -52,6 +53,7 @@ from backend.tools.registry import (
|
||||
from backend.tools.schemas import ToolResult
|
||||
|
||||
__all__ = [
|
||||
"FILES_SCOPE_TOOLS",
|
||||
"ToolConfirmationRequired",
|
||||
"ToolContext",
|
||||
"ToolError",
|
||||
|
||||
@@ -117,6 +117,29 @@ class ToolRateLimitError(ToolError):
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
#: Tools a ``files``-scoped API token (#199, synchro TikTak) may invoke :
|
||||
#: vault file read/write surface only — no external calls (git/web), no
|
||||
#: notifications, no scheduler, no cross-vault analysis beyond file content.
|
||||
FILES_SCOPE_TOOLS: frozenset[str] = frozenset({
|
||||
# Lecture / recherche de fichiers
|
||||
"list_vaults", "list_directory", "list_all_files",
|
||||
"read_file", "read_file_raw", "get_backlinks",
|
||||
"get_graph", "search_fulltext", "search_advanced", "search_paths",
|
||||
"list_tags", "suggest_tags", "list_recent",
|
||||
"list_backups", "diff_backup", "restore_backup",
|
||||
"find_duplicates",
|
||||
# Mutations de fichiers
|
||||
"create_file", "create_directory", "edit_file", "append_to_file",
|
||||
"rename_file", "rename_directory", "move_path", "replace_in_files",
|
||||
"delete_file", "delete_directory", "merge_duplicate_notes",
|
||||
# Tableurs & documents (création/édition de fichiers du vault)
|
||||
"create_xlsx", "create_docx", "create_csv", "create_pdf",
|
||||
"list_xlsx_sheets", "xlsx_to_markdown", "search_workbook",
|
||||
"analyze_range", "update_xlsx_cells", "append_xlsx_rows",
|
||||
"edit_xlsx_structure",
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
"""Carries the caller identity and execution options for a tool call.
|
||||
@@ -190,6 +213,32 @@ class ToolContext:
|
||||
details={"vault": vault},
|
||||
)
|
||||
|
||||
def require_tool_allowed(self, tool: str) -> None:
|
||||
"""Raise :class:`ToolPermissionError` when the caller's token scope
|
||||
forbids *tool* (#199).
|
||||
|
||||
A ``files``-scoped token is confined to :data:`FILES_SCOPE_TOOLS`
|
||||
(vault file surface). Full-scope and scopeless callers (session
|
||||
tokens, pre-#199 API keys) are unaffected ; an unknown scope fails
|
||||
closed.
|
||||
"""
|
||||
token_scope = self.user.get("_token_scope") or "full"
|
||||
if token_scope == "full":
|
||||
return
|
||||
if token_scope == "files":
|
||||
if tool not in FILES_SCOPE_TOOLS:
|
||||
raise ToolPermissionError(
|
||||
f"Token à portée limitée : outil '{tool}' hors périmètre 'files'",
|
||||
code="token_scope_denied",
|
||||
details={"tool": tool, "scope": "files"},
|
||||
)
|
||||
return
|
||||
raise ToolPermissionError(
|
||||
"Token à portée inconnue : appel refusé",
|
||||
code="token_scope_denied",
|
||||
details={"tool": tool},
|
||||
)
|
||||
|
||||
|
||||
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||
"""Resolve a vault-relative path, rejecting traversal outside the vault.
|
||||
|
||||
@@ -216,6 +216,14 @@ def call_tool(
|
||||
_audit(ctx, spec, arguments, ok=False, error=e.code)
|
||||
raise
|
||||
|
||||
# #199 : les tokens à portée limitée sont confinés à la surface fichiers,
|
||||
# y compris pour les outils sans vault (git, web, notify…).
|
||||
try:
|
||||
ctx.require_tool_allowed(name)
|
||||
except ToolPermissionError as e:
|
||||
_audit(ctx, spec, arguments, ok=False, error=e.code)
|
||||
raise
|
||||
|
||||
if spec.risk == ToolRisk.DANGEROUS and vault and vault != "all":
|
||||
try:
|
||||
ctx.require_destructive_allowed(vault)
|
||||
|
||||
@@ -123,10 +123,17 @@ def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, An
|
||||
def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[str, Any]]:
|
||||
"""Return the entries of a vault directory (direct children only)."""
|
||||
data = browse_directory(params.vault, params.path)
|
||||
return [
|
||||
{"name": item["name"], "path": item["path"], "type": item["type"]}
|
||||
for item in data["items"]
|
||||
]
|
||||
# #197 : même contrat que la REST — la taille et le mtime permettent la
|
||||
# réconciliation externe sans relire chaque fichier.
|
||||
out = []
|
||||
for item in data["items"]:
|
||||
entry = {"name": item["name"], "path": item["path"], "type": item["type"]}
|
||||
if item["type"] == "file":
|
||||
entry["size"] = item.get("size")
|
||||
entry["mtime"] = item.get("modified")
|
||||
entry["mtime_iso"] = item.get("modified_iso")
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
@tool(
|
||||
|
||||
+89
-3
@@ -16,6 +16,9 @@ Security (BUG-026):
|
||||
|
||||
Events: file_created, file_deleted, file_modified, file_renamed,
|
||||
directory_created, directory_deleted, directory_renamed
|
||||
(#198 : chaque webhook peut porter un filtre optionnel ``vault`` et/ou
|
||||
``path_prefix`` — seuls les événements correspondants sont livrés ; la
|
||||
charge utile inclut toujours ``vault`` et le(s) chemin(s)).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -120,6 +123,65 @@ def is_safe_target(url: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_vault_filter(vault: str | None) -> str | None:
|
||||
"""Normalize an optional vault filter (``None``/empty = no filtering)."""
|
||||
if not vault or not isinstance(vault, str):
|
||||
return None
|
||||
cleaned = vault.strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _normalize_prefix_filter(prefix: str | None) -> str | None:
|
||||
"""Normalize an optional path-prefix filter (``None``/empty = no filtering)."""
|
||||
if not prefix or not isinstance(prefix, str):
|
||||
return None
|
||||
cleaned = prefix.strip().strip("/")
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _event_paths(data: dict) -> list[str]:
|
||||
"""Vault-relative paths carried by a webhook payload.
|
||||
|
||||
File/directory events use ``path``; renames and moves carry
|
||||
``old_path``/``new_path`` instead.
|
||||
"""
|
||||
paths = []
|
||||
for key in ("path", "old_path", "new_path"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
paths.append(value.strip("/"))
|
||||
return paths
|
||||
|
||||
|
||||
def webhook_matches(wh: dict, event_type: str, data: dict) -> bool:
|
||||
"""True when webhook *wh* must receive (*event_type*, *data*).
|
||||
|
||||
Pure function (no I/O) so the filtering is unit-testable :
|
||||
- event subscription (as before) ;
|
||||
- #198 optional ``vault`` filter — exact match on ``data["vault"]`` ;
|
||||
- #198 optional ``path_prefix`` filter — matches when any carried path
|
||||
equals the prefix or lies underneath it (``Docs`` matches ``Docs`` and
|
||||
``Docs/a.md``, not ``Docs2/b.md``).
|
||||
|
||||
Webhooks created before #198 carry neither key and keep receiving
|
||||
everything they subscribe to (backward compatible).
|
||||
"""
|
||||
if event_type not in wh.get("events", []):
|
||||
return False
|
||||
vault_filter = _normalize_vault_filter(wh.get("vault"))
|
||||
if vault_filter and data.get("vault") != vault_filter:
|
||||
return False
|
||||
prefix = _normalize_prefix_filter(wh.get("path_prefix"))
|
||||
if prefix:
|
||||
wanted = prefix + "/"
|
||||
for candidate in _event_paths(data):
|
||||
if candidate == prefix or candidate.startswith(wanted):
|
||||
break
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _read() -> list:
|
||||
if not WEBHOOKS_FILE.exists():
|
||||
return []
|
||||
@@ -195,7 +257,14 @@ def get_webhooks() -> list:
|
||||
return [_public_view(wh) for wh in _read()]
|
||||
|
||||
|
||||
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
|
||||
def create_webhook(
|
||||
name: str,
|
||||
url: str,
|
||||
events: list[str],
|
||||
secret: str | None = None,
|
||||
vault: str | None = None,
|
||||
path_prefix: str | None = None,
|
||||
) -> dict:
|
||||
validate_webhook_url(url)
|
||||
with _lock:
|
||||
webhooks = _read()
|
||||
@@ -208,6 +277,9 @@ def create_webhook(name: str, url: str, events: list[str], secret: str | None =
|
||||
"enabled": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_fired_at": None,
|
||||
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
|
||||
"vault": _normalize_vault_filter(vault),
|
||||
"path_prefix": _normalize_prefix_filter(path_prefix),
|
||||
}
|
||||
webhooks.append(wh)
|
||||
_write(webhooks)
|
||||
@@ -226,6 +298,13 @@ def update_webhook(wh_id: str, updates: dict) -> dict | None:
|
||||
validate_webhook_url(updates["url"])
|
||||
if "secret" in updates:
|
||||
_store_secret(wh_id, updates["secret"])
|
||||
# #198 : normaliser les filtres ("" réinitialise le filtre).
|
||||
for key, normalizer in (
|
||||
("vault", _normalize_vault_filter),
|
||||
("path_prefix", _normalize_prefix_filter),
|
||||
):
|
||||
if key in updates:
|
||||
updates[key] = normalizer(updates[key])
|
||||
safe_updates = {
|
||||
k: v for k, v in updates.items()
|
||||
if k not in ("id", "secret")
|
||||
@@ -250,9 +329,16 @@ def delete_webhook(wh_id: str) -> bool:
|
||||
|
||||
|
||||
async def dispatch_webhooks(event_type: str, data: dict):
|
||||
"""Fire all enabled webhooks subscribed to event_type."""
|
||||
"""Fire all enabled webhooks subscribed to event_type.
|
||||
|
||||
#198 : les webhooks portant un filtre ``vault``/``path_prefix`` ne sont
|
||||
livrés que pour les événements correspondants (voir :func:`webhook_matches`).
|
||||
"""
|
||||
webhooks = _read()
|
||||
targets = [wh for wh in webhooks if wh.get("enabled", True) and event_type in wh.get("events", [])]
|
||||
targets = [
|
||||
wh for wh in webhooks
|
||||
if wh.get("enabled", True) and webhook_matches(wh, event_type, data)
|
||||
]
|
||||
if not targets:
|
||||
return
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.67.2"
|
||||
version = "2.68.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.67.2"
|
||||
version = "2.68.1"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.67.2",
|
||||
"version": "2.68.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
Je vais analyser la solution Assistant V2. Commençons par explorer la structure du projet et localiser les références à "Assistant V2".
|
||||
|
||||
|
||||
|
||||
Il y a un dossier `docs/Assistant-V2/` avec plusieurs documents de spécification. Explorons-le en détail.
|
||||
|
||||
|
||||
|
||||
Let me lire le document principal et les documents de phase.
|
||||
|
||||
|
||||
|
||||
Continuons la lecture du document principal puis des phases.
|
||||
|
||||
|
||||
|
||||
Maintenant, lisons les documents de phase V2.1. Vérifions d'abord leur taille.
|
||||
|
||||
|
||||
|
||||
Ces documents sont volumineux. Je vais déléguer le résumé des phases 2 à 5 à des sous-agents en parallèle, pendant que je lis moi-même la phase 0 (fondations) et la phase 1, et que je vérifie l'état réel du code.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
The sub-agents reference a parent document `ASSISTANT-V2.1.md`. Let me check whether it exists, read phases 0 and 1 myself, and inspect the actual backend state.
|
||||
|
||||
|
||||
|
||||
Now let me read phase 1 and verify the actual codebase state — whether any of this exists (memory store, runs, policies) and how it's tracked in the roadmap.
|
||||
|
||||
|
||||
|
||||
Excellent picture forming. Let me verify the missing parent document references, and how (if at all) this is registered in the roadmap/todolist.
|
||||
|
||||
|
||||
|
||||
Key finding: IDs may collide with existing roadmap items. Let me inspect the roadmap's current/upcoming sections and the highest IDs in use.
|
||||
|
||||
|
||||
|
||||
Let me do a final quick check on dependencies and existing data stores to ground the component list.
|
||||
|
||||
|
||||
|
||||
J'ai lu l'intégralité de la documentation `docs/Assistant-V2/` (7 documents), vérifié l'état réel du code, et croisé le tout avec `ROADMAP.md`, `ISSUES_TODOLIST.md` et les conventions `AGENTS.md`. Voici l'analyse complète.
|
||||
|
||||
---
|
||||
|
||||
## 0. Verdict en une phrase
|
||||
|
||||
**Assistant V2 (et sa suite V2.1) est aujourd'hui une conception de très bonne qualité, mais 100 % non implémentée, non enregistrée dans le Roadmap, et dont le plan d'IDs est cassé par des collisions avec des features déjà livrées.** Le contenu technique est solide et réutilise finement l'existant ; la dette est d'abord méthodologique et documentaire, pas architecturale.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cartographie des documents
|
||||
|
||||
| Fichier | Génération | Statut déclaré | IDs couverts |
|
||||
|---|---|---|---|
|
||||
| `docs/Assistant-V2/ASSISTANT-V2.md` (528 l.) | **V2** — 2026-10-08 | « Spécification (non implémentée) » | #191 → #201 |
|
||||
| `assistant-v21-phase-0-socle-sur.md` (279 l.) | **V2.1** — 2026-10-09 | « Prêt à implémenter » | #191,#192,#193,#206,#213 + amorce #212 |
|
||||
| `assistant-v21-phase-1-le-travail.md` (276 l.) | V2.1 | Prêt à implémenter | #202,#203,#204,#205 |
|
||||
| `assistant-v21-phase-2-temps-reel.md` | V2.1 | Prêt à implémenter | #200,#194,#199,#208,#214 |
|
||||
| `assistant-v21-phase-3-le-monde.md` | V2.1 | Prêt à implémenter | #195,#196,#197,#198,#207 |
|
||||
| `assistant-v21-phase-4-les-gens.md` | V2.1 | Prêt à implémenter | #211,#209,#201 |
|
||||
| `assistant-v21-phase-5-extension.md` (160 l.) | V2.1 | Cadre prêt | #210,#212 |
|
||||
|
||||
**Fait saillant n°1 — document parent manquant.** Les 5 phases V2.1 s'appuient toutes sur un `ASSISTANT-V2.1.md` (§2 P1–P16, §6.1, §8.2, §9.2/9.3, §10.2, §13.2, §15, §16, §19, §21). **Ce fichier n'existe nulle part dans le dépôt.** Conséquence directe : toutes les références croisées sont pendantes — notamment les schémas exacts des tables `tasks`/`inbox_items`/`facts`/`entities`/`agent_runs`, les règles de politique §15.2, et surtout **les définitions des golden tasks G1→G14** (§16.1) qui sont pourtant le critère d'acceptation central de chaque phase. On ne peut pas implémenter les phases « telles quelles » sans ce parent.
|
||||
|
||||
**Fait saillant n°2 — collision d'IDs.** Le Roadmap (v2.63.0) a déjà consommé ces ID :
|
||||
|
||||
- `#191` = « Chat — suppression de post, DM, link preview OG » (livré 2.60.0)
|
||||
- `#192` = « Chat — boîte auto-agrandissante, accusé ✓✓ » (livré 2.61.0)
|
||||
- `#194` = « Dossier personnel par utilisateur » (🔵 **en cours**, fiche `docs/features/user-home-194.md`)
|
||||
|
||||
Or V2.1 assigne #191 = purge Ollama, #192 = mémoire socle, #194 = consolidation nocturne. **Ce sont des collisions avec du code déjà publié**, ce qui viole la règle n°1 d'`AGENTS.md` (« ID stable, jamais réutilisé »).
|
||||
|
||||
**Fait saillant n°3 — plan non enregistré.** Aucune des 7 docs n'est référencée dans `ROADMAP.md`, dans la cartographie documentaire d'`AGENTS.md`, ni dans `docs/features/`. Le programme entier vit dans une bulle documentaire non branchée sur le processus de livraison.
|
||||
|
||||
---
|
||||
|
||||
## 2. Résumé de la solution
|
||||
|
||||
### Vision (4 piliers)
|
||||
Transformer ObsiGate d'un « portail de notes avec assistant » en **second cerveau conversationnel autonome** :
|
||||
1. **Il se souvient** — mémoire long terme structurée (faits, préférences, entités, décisions), consolidée la nuit, **auditable en markdown dans le vault** (anti-boîte-noire).
|
||||
2. **Il agit** — sur le vault **et** les services externes connectés (Gmail, Google Agenda, Teams, Discord, Notion, Drive…).
|
||||
3. **Il anticipe** — rappels conversationnels, briefing quotidien, détection de boucles ouvertes et d'engagements non tenus.
|
||||
4. **Il centralise** — ObsiGate devient le point d'entrée unique (desktop Tauri, MCP externe, PWA mobile → *ce même* cerveau).
|
||||
|
||||
### Principes directeurs
|
||||
- **V2 (P1–P9)** : autonomie du stack (1 conteneur, Ollama retiré) · zéro framework d'agent externe · un outil = un `@tool` typé · mémoire = markdown d'abord · humain dans la boucle · secrets jamais committés · i18n FR/EN · desktop Tauri offline-first · réutilisation de la sécurité existante.
|
||||
- **V2.1 (ajout majeur)** : insère **dès la phase 0** deux mécanismes qui rendent tout le reste délégable :
|
||||
- **Moteur de politiques** (#213) — décision **déterministe** `autorisé | approbation_run | approbation_renforcée | refusé` = f(risque × niveau d'autonomie × provenance × sensibilité × réversibilité). **Jamais de LLM dans le chemin d'autorisation.**
|
||||
- **Exécution fiable** (#206) — *« un run est l'unité de tout »* : plans avant écriture, approbation par run, `agent_runs`/`agent_steps`, post-conditions (`verify`), inverses (`inverse`), idempotence, reprise après crash, annulation (undo).
|
||||
|
||||
Le delta V2 → V2.1 est significatif et pertinent : la V2 donnait de la mémoire et des connecteurs **sans** le socle de sécurité/exécution. La V2.1 corrige cela en prerequisite. Mais la V2.1 introduit aussi ~2× plus de périmètre.
|
||||
|
||||
### Phasage et effort (source : docs)
|
||||
|
||||
| Phase | Thème | Tranches | Effort |
|
||||
|---|---|---|---|
|
||||
| **0** | Socle sûr (purge Ollama, mémoire, miroir, politiques, runs) | #191,#192,#193,#213,#206, amorce #212 | 15–19 j |
|
||||
| **1** | Le travail (tâches, Aujourd'hui, planification, capture) | #202,#203,#204,#205 | 14–18 j |
|
||||
| **2** | Le temps réel (agent programmé, consolidation, rappels, automations, rituels) | #200,#194,#199,#208,#214 | 14–19 j |
|
||||
| **3** | Le monde (connecteurs, sync, client MCP) | #195,#196,#197,#198,#207 | 17–23 j |
|
||||
| **4** | Les gens (personnes & engagements, voix, insights) | #211,#209,#201 | 9–13 j |
|
||||
| **5** | L'extension (packs A–G + éval continue) | #210,#212 | 3–5 j/chantier + continu |
|
||||
|
||||
**Total cœur = ≈ 69–92 jours** de développement, plus l'extension. C'est un **programme de plusieurs mois**, pas une feature.
|
||||
|
||||
---
|
||||
|
||||
## 3. Les composantes à mettre en place
|
||||
|
||||
### 3.1 Backend — nouveaux modules
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
L[agent/loop.py existant] --> P[agent/policies.py]
|
||||
L --> R[agent/runs.py]
|
||||
P --> REG[tools/registry.py étendu verify/inverse]
|
||||
M[memory/ store, entities, recall, compiler, mirror, consolidator] --> T1[tools/memory.py]
|
||||
C[connectors/ base, oauth, registry, sync, ical/caldav/imap, mcp_client] --> T2[tools/connectors.py]
|
||||
A[automations/ bus, engine, detectors] --> T3[tools/reminders.py]
|
||||
K[tasks/ model, store, rrule, planner] --> T4[tools/tasks.py + planner.py]
|
||||
I[inbox/store.py] --> T5[tools/inbox.py]
|
||||
G[people/ + voice/] --> T6[tools/people.py, commitment, voice]
|
||||
```
|
||||
|
||||
| Module | Rôle |
|
||||
|---|---|
|
||||
| `backend/memory/{store,entities,recall,compiler,mirror,consolidator}.py` | faits bi-temporels, entités/relations, recall RRF, compileur de contexte, miroir markdown, consolidation nocturne |
|
||||
| `backend/agent/policies.py` | moteur de politiques déterministe |
|
||||
| `backend/agent/runs.py` | `agent_runs`/`agent_steps`, plans, idempotence, reprise, undo |
|
||||
| `backend/connectors/{base,oauth,registry,sync,ical,caldav,imap,mcp_client}.py` | cadre connecteurs OAuth2+PKCE + replis zéro-OAuth + sync incrémentale + client MCP |
|
||||
| `backend/tasks/{model,store,rrule,planner}.py` | parseur de tâches markdown, index, RRULE, moteur de planification |
|
||||
| `backend/inbox/store.py`, `backend/automations/{bus,engine,detectors}.py` | capture, bus d'événements, automatisations, détecteurs d'insights |
|
||||
| `backend/tools/{memory,connectors,reminders,tasks,planner,inbox,people,voice}.py` | outils `@tool` |
|
||||
| `backend/routers/{memory,runs,delegations,today,tasks,plans,approvals,inbox,connectors,automations,reminders,commitments}.py` | API REST |
|
||||
|
||||
### 3.2 Stores de données (tous dans `data/`, JSON ou SQLite reconstructible)
|
||||
|
||||
- `data/memory.sqlite3` — `facts` (bi-temporel : `entity_id, valid_from, valid_to, recorded_at, sensitivity, provenance_trust`, `kind` étendu à `procedure`), `entities`, `relations`, `consolidation_runs`.
|
||||
- `data/assistant.sqlite3` — `agent_runs`, `agent_steps`, `delegations`, `approvals`, `inbox_items`, `commitments`, `plans`, `automation_runs`, `event_log`.
|
||||
- `data/tasks.sqlite3` — index des tâches (reconstructible).
|
||||
- `data/connectors_cache.sqlite3` — `sync_state`, `synced_objects` (extraits bornés, jamais le corps complet).
|
||||
- `data/connectors.json` (public) + `data/connector_secrets.json` (0600, chiffré `secret.key`).
|
||||
|
||||
### 3.3 Artefacts vault — `99_Assistant/`
|
||||
|
||||
`Profil.md` (balises `<!-- obsigate:managed:* -->`, re-parsées en faits `source='user'`), `Insights.md`, `Rappels.md`, `Personnes.md`, `Journal.md`, `Briefings/YYYY-MM-DD.md`, `Plans/YYYY-MM-DD.md`, `Automatisations/*.md`, `Revues/`.
|
||||
|
||||
### 3.4 Frontend (vanilla JS, zéro build — contrainte à respecter)
|
||||
|
||||
Vue **Aujourd'hui**, **file d'approbations unifiée**, **centre de contrôle** (runs, délégations, budgets, santé des index), page Configurations étendue **« Sources connectées »**, cartes mémoire/pending, panneau cloche rappels, vue Automatisations + dry-run, registre des packs. i18n FR **et** EN obligatoire.
|
||||
|
||||
### 3.5 Infra & config
|
||||
|
||||
- Retrait Ollama : `docker-compose.yml` + `.test-linux` + `.test-win` (service, volume `ollama_data`, `OLLAMA_BASE_URL/MODEL`, port 11434).
|
||||
- Variables : `OBSIGATE_MEMORY_*`, `OBSIGATE_CONSOLIDATION_*`, `OBSIGATE_TZ`, `OBSIGATE_BRIEFING_TIME`, `OBSIGATE_NOTIFY_DAILY_BUDGET`, `GOOGLE_CLIENT_ID/SECRET`, `AZURE_CLIENT_ID/SECRET`, `OBSIGATE_NOTION_API_KEY`, `OBSIGATE_DISCORD_BOT_TOKEN`, `OBSIGATE_RUN_MAX_*`.
|
||||
- Pas de nouvelle dépendance lourde nécessaire : `httpx` et `mcp` sont déjà présents.
|
||||
|
||||
---
|
||||
|
||||
## 4. État réel du code (vérifié, pas déduit)
|
||||
|
||||
| Élément cité par les specs | Réalité constatée |
|
||||
|---|---|
|
||||
| `backend/agent/loop.py` (`run_agent`) | ✅ existe (`DEFAULT_MAX_ITERATIONS=10`, `STOP_*`, propose/apply) |
|
||||
| `backend/tools/registry.py` (`@tool`, `ToolRisk`) | ✅ existe — mais **pas de `verify`/`inverse`/`data_classes`** (donc P0-T12 est bien un vrai travail) |
|
||||
| `backend/scheduler.py` (actions, schedules) | ✅ existe — actions `create_file`/`append_to_file`/`notify`, schedules `interval_hours`/`daily_time`/`once_at` |
|
||||
| `backend/notify.py` (`broadcast`) | ✅ existe |
|
||||
| `backend/mcp/server.py` (propose/apply) | ✅ existe |
|
||||
| `data/secret.key`, chiffrement, `0600` | ✅ pattern présent (`auth/jwt_handler.py`) |
|
||||
| **`backend/memory/`, `backend/connectors/`, `policies.py`, `runs.py`** | ❌ **inexistants** |
|
||||
| **`memory.sqlite3`, `assistant.sqlite3`, `agent_runs`** | ❌ **inexistants** |
|
||||
| Ollama dans les compose | ✅ **toujours présent** (les 3 files) |
|
||||
|
||||
Conclusion : les specs décrivent correctement le socle existant (bon signe), et tout ce qui est « nouveau » est effectivement à construire. Le statut « non implémenté » du doc V2 est exact.
|
||||
|
||||
---
|
||||
|
||||
## 5. Points forts de la conception
|
||||
|
||||
- **Réutilisation maximale de l'existant** : boucle agent, registre `@tool`, MCP, scheduler, notify, Web Push, recherche TF-IDF+sémantique — rien de tout cela n'est refait. La V2.1 branche proprement les nouvelles capacités.
|
||||
- **Anti-boîte-noire** : mémoire auditable en markdown, éditions humaines re-parsées comme faits `source='user'` confiance 1.0. C'est la vraie différenciation face à ChatGPT-Memory.
|
||||
- **Sécurité pensée tôt** : moteur de politiques déterministe, provenance/taint, whitelist des runs non interactifs, refus des envois externes sans relecture, traitement du contenu externe comme *données* (anti-injection), budgets durs.
|
||||
- **Coût maîtrisé** : consolidation déterministe avant LLM, cheap model dédié, budgets tokens, règle « lire le cache pendant que sa sync dort ».
|
||||
- **Dégradabilité** : chaque phase fonctionne sans les suivantes (la V2.1 insiste : phase 4 complète sans même un connecteur), connecteurs absents du registre si non installés (offline-first Tauri).
|
||||
- **DoD aligné sur le dépôt** : golden tasks, i18n FR/EN, fiches features, CHANGELOG/ROADMAP, ruff/mypy.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pistes d'amélioration
|
||||
|
||||
### 🔴 Bloquantes (à traiter avant d'écrire une ligne de code)
|
||||
|
||||
1. **Réécrire/écrire le document parent `ASSISTANT-V2.1.md`.** Sans lui, les schémas `tasks`/`inbox_items`/`facts`/`entities`/`agent_runs`, les règles de politique §15.2 et **les définitions des golden tasks G1→G14** n'existent pas. Options : (a) reconstituer le parent à partir des 5 phases + de la V2, ou (b) fusionner les phases en un document unique auto-suffisant. **Recommandé : (b)**, car maintenir 6 documents cohérents pour ~90 jours de travail va dériver.
|
||||
2. **Renuméroter tout le bloc d'IDs.** #191, #192, #194 sont déjà pris (chat, user-home). Proposer un bloc contigu réservé (ex. `#215→#238`) et le figer, puis remapper V2 et V2.1. Décider aussi de la convention unique : les docs mélangent `#NNN` (roadmap) et `P0-Tnn`/`P2-Tnn` (tickets) sans mapping explicite — à trancher et documenter.
|
||||
3. **Enregistrer le programme dans le Roadmap et la cartographie documentaire.** Créer les entrées `ROADMAP.md` (statut 🔵/⚪) et les fiches `docs/features/`, ajouter `docs/Assistant-V2/` à la table d'`AGENTS.md`. Aujourd'hui le programme viole la règle « ID stable avant de coder ».
|
||||
4. **Décider du sort de la doc V2.** La V2 **contredit** la V2.1 : V2 dit « `gmail.send` non exposé v1 », V2.1 phase 3 l'expose en DANGEROUS. V2 propose une table `facts` sans bi-temporalité/entités que V2.1 remplace. Garder les deux crée un piège pour l'implémenteur. **Recommandé : marquer `ASSISTANT-V2.md` comme « superseded »** et pointer vers la V2.1.
|
||||
|
||||
### 🟡 Importantes (qualité d'implémentation)
|
||||
|
||||
5. **Trancher le séquençage par valeur, pas par phases.** Les phases sont bien dessinées, mais ~90 j avant le premier bénéfice visible est risqué. **Recommandé : livrer d'abord un « vertical slice » de la seule phase 0** (purge Ollama + mémoire + miroir + runs + politiques) — c'est là que vivent 80 % du risque technique et 100 % des prérequis de sécurité. Ne pas commencer les phases 1–5 avant d'avoir prouvé la phase 0.
|
||||
6. **Concurrence SQLite / writer unique.** Les specs ajoutent 4–5 bases manipulées par la boucle agent, le watcher, le scheduler (tick 60 s) et les handlers HTTP, dans **un seul process**. À cadrer explicitement : mode WAL, discipline « un seul écrivain par store », verrous, et reprise. Ce n'est évoqué nulle part et c'est une source classique de corruption.
|
||||
7. **Qualité de l'extraction de faits = le composant le plus risqué.** C'est le seul endroit où « le modèle décide ». Recommander : (a) un **dry-run de consolidation** obligatoire avant activation (existe pour automations, à généraliser), (b) validation Pydantic stricte + rejet des valeurs non sourcées, (c) métrique « faits rejetés/acceptés » dès la phase 0, (d) un mode « extracteur désactivé » par défaut la première semaine.
|
||||
8. **Fuites multi-utilisateurs via le miroir.** La mémoire est `user_id` par utilisateur, mais le miroir vit dans **un vault** `99_Assistant/`. Si le vault est partagé, `Profil.md`/`Personnes.md` exposent la vie privée d'un user à un autre. À trancher : vault mémoire **par utilisateur** (le plus sûr, cohérent avec `home-<user>`), ou sections par user + ACL. À documenter et tester.
|
||||
9. **Chiffrement au repos et `0600` sous Windows.** Le pattern `data/connector_secrets.json` en `0600` est POSIX. `os.chmod` ne pose pas d'ACL sous Windows, alors que le desktop Tauri est multi-OS. Prévoir une stratégie (ACL via `pywin32`, ou DPAPI, ou au minimum documenter la limite). #172 est cité comme dépendance mais pas défini ici.
|
||||
10. **Golden tasks non branchées au CI.** `AGENTS.md` impose les 5 jobs CI (`lint/test/security/build/e2e`). Les golden tasks (`tests/golden/`) nécessitent un job dédié + des **simulateurs HTTP par fournisseur** (prévus en phase 3). À cadrer dès la phase 0 (harnais) pour ne pas découvrir le problème en phase 3. La phase 5 **oublie complètement l'E2E** alors qu'elle crée une UI (registre des packs) — non conforme à la DoD du dépôt.
|
||||
11. **Friction OAuth.** Créer des apps Google Cloud / Entra ID est le principal frein d'installation. Bonne idée des specs : les **replis zéro-OAuth (iCal/CalDAV/IMAP)**. Recommander d'en faire le **chemin par défaut** et OAuth une option avancée, plutôt que l'inverse — cela dérisque toute la phase 3.
|
||||
|
||||
### 🟢 Confort / simplifications
|
||||
|
||||
12. **Mutualiser les stores.** `tasks.sqlite3`, `connectors_cache.sqlite3`, `memory.sqlite3` et `assistant.sqlite3` pourraient être **un seul** `assistant.sqlite3` (WAL) : moins de fichiers, moins de cycles de vie, un seul point de backup. Le « reconstructible séparément » se gère par table. À évaluer.
|
||||
13. **FTS5 natif** pour l'index des tâches et la recherche mémoire, plutôt qu'un index plein-texte maison — SQLite le fournit déjà.
|
||||
14. **Unifier rappel = tâche enrichie** (déjà proposé en V2.1 §7.2) : excellent, à confirmer comme décision figée pour éviter toute tentation de second store.
|
||||
15. **Instrumenter les métriques #212 dès la phase 0** (budget de contexte mémoire, faits acceptés/rejetés, temps de décision quotidien). Les specs le promettent ; en faire un vrai tableau de bord tôt évite les optimisations à l'aveugle.
|
||||
16. **Formaliser les critères de sortie de chaque phase** (les phases 0–4 en ont, la 5 non) et une **décision unique** sur les « décisions ouvertes » du parent (activation de l'envoi, fournisseur de transcription, gestionnaire de tâches externe) — ces 3 décisions conditionnent plusieurs tickets et ne sont pas tranchées.
|
||||
17. **Points ambigus relevés par phase** (à lever dans le parent) : schéma `consolidation_runs` jamais défini (phase 2) ; format de `allowed_extra_tools` et de `ctx: rappel` ; emplacement du réglage `candidats connecteurs` (`off|candidats`) ; modalités MCP (stdio vs HTTP) et algorithme de plafonnement de risque ; dédup multi-calendriers (tolérance fuseaux/récurrences) ; granularité Notion→index ; format de persistance des packs (`data/` ?).
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommandation de prochaines étapes
|
||||
|
||||
1. **Décider du plan de renumérotation** et réserver un bloc d'IDs contigu.
|
||||
2. **Consolider la doc** : écrire le parent V2.1 (ou fusionner), marquer la V2 comme superseded, lever les ~15 ambiguïtés ci‑dessus.
|
||||
3. **Enregistrer** le programme dans `ROADMAP.md` + `docs/features/` + `AGENTS.md`.
|
||||
4. **Commencer par le vertical slice de la phase 0** (purge Ollama + mémoire + miroir + runs + politiques + harnais golden), et **ne pas** démarrer les phases 1–5 avant que la phase 0 soit verte en CI.
|
||||
|
||||
---
|
||||
|
||||
Souhaites-tu que je rédige :
|
||||
- le **document de synthèse consolidé** (parent V2.1 auto-suffisant) à partir des 5 phases et de la V2, ou
|
||||
- une **proposition de renumérotation** concrète (mapping `ancien ID → nouveau ID`) prête à coller dans le Roadmap ?
|
||||
|
||||
Je peux aussi produire un `docs/features/assistant-v2-index.md` qui sert de plan-maître et référence les 7 documents actuellement orphelins.
|
||||
@@ -0,0 +1,278 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 0 : Socle sûr (purge, mémoire, politiques, exécution fiable)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **0 sur 5** — fondation de toute la solution |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §2 (P1–P16), §5, §9, §10, §15, §16, §19 |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#191** (purge Ollama), **#192** (mémoire socle amendée), **#193** (miroir markdown), **#213** (politiques & taint), **#206** (exécution fiable), amorce de **#212** (harnais golden tasks) |
|
||||
| Effort estimé | 15–19 jours (dont 1–2 j d'amorce #212 ; le solde de #212 est continu, phases 1–5) |
|
||||
| Prérequis | ObsiGate actuel : boucle agent `backend/agent/loop.py`, registre `backend/tools/registry.py`, scheduler #170, notifications #168, Web Push #67, recherche #70 — tels que décrits dans l'inventaire V2.1 §4 |
|
||||
| Dépendances | Aucune (première phase) |
|
||||
| Débloque | Phase 1 (tâches et planification s'appuient sur les runs et la mémoire), et toutes les écritures externes ultérieures (#213/#206 sont le prérequis de sécurité des phases 3–4) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** Le conteneur Ollama a disparu. L'assistant possède une mémoire long terme : il enregistre des faits (avec historique bi-temporel), des entités (personnes, projets, lieux) et des procédures, les montre dans `99_Assistant/Profil.md` — éditable à la main, les éditions étant reprises comme des faits de confiance maximale — et les injecte dans chaque conversation via un compileur de contexte dont chaque section cite son origine. Surtout, **tout ce que l'assistant exécute devient un run persistant** : plan affiché avant les écritures, approbation par run, étapes journalisées avec preuves de post-condition, reprise sans doublon après interruption, annulation quand l'inverse existe. Et un moteur de politiques déterministe décide ce qui est autorisé, approuvé ou refusé — jamais le modèle.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée — ce qui existe](#3-état-dentrée--ce-qui-existe)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **on ne donne pas plus de pouvoir à l'assistant avant d'avoir rendu son pouvoir actuel sûr, traçable et réversible.** La V2 livrait la mémoire puis les connecteurs ; la V2.1 intercale, dès la phase 0, les deux mécanismes qui rendent toutes les phases suivantes délégables : le **moteur de politiques** (#213 — qui a le droit de faire quoi, avec quelles données, venues d'où) et l'**exécution fiable** (#206 — plan, preuves, reprise, annulation).
|
||||
|
||||
Corollaires qui guident chaque ticket :
|
||||
|
||||
- **La mémoire est un index du vault, pas un silo.** Faits confirmés → miroir markdown ; la base ne détient en propre que les états de travail (`pending`, historique de runs).
|
||||
- **Un run est l'unité de tout.** Conversation, tâche planifiée (phase 2), automatisation (phase 2), MCP : à la fin de la phase, toute exécution d'outils par un agent produit une ligne `agent_runs` et ses `agent_steps`. Les journaux épars d'aujourd'hui convergent là.
|
||||
- **La politique est déterministe.** Risque du registre × niveau d'autonomie × provenance × sensibilité × réversibilité → décision. Aucun appel LLM dans le chemin d'autorisation, jamais.
|
||||
- **Pas de succès sans preuve.** Un run n'est `succeeded` que si ses post-conditions vérifiables sont vraies (§7.3).
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #191 : retrait du service/volume Ollama des trois compose, défauts provider nettoyés, notes de migration (repris tel quel de la V2 §9).
|
||||
- #192 : `backend/memory/` — store SQLite amendé (faits bi-temporels, sensibilité, provenance, procédures), entités et relations, recall et **compileur de contexte** (§9.4 du parent), outils `memory.*`, `entity_resolve`, `procedure_get`, API mémoire, cartes de validation des faits `pending` (forme simple — la file unifiée arrive en phase 1, #203).
|
||||
- #193 : `mirror.py`, dossier `99_Assistant/`, `Profil.md` à balises gérées avec re-parse des éditions humaines, `Journal.md` des consolidations, `Personnes.md` en **coquille** (le peuplement riche est en phase 4, #211).
|
||||
- #213 : provenance/taint sur tous les contenus entrant dans un contexte ou un run, moteur de politiques `backend/agent/policies.py` évalué avant chaque appel d'outil, règles du §15.2 du parent, journal des refus, tests d'injection.
|
||||
- #206 : plans d'exécution, `agent_runs`/`agent_steps` dans `data/assistant.sqlite3`, idempotence et reprise, post-conditions déclarées dans le registre (`verify`), inverses (`inverse`), niveaux d'autonomie 0–3 et délégations bornées datées, API runs + progression SSE.
|
||||
- #212 (amorce) : harnais de golden tasks (`tests/golden/`, fixtures de vault, connecteurs simulés) et les scénarios G3, G6, G7 de la phase.
|
||||
|
||||
### 2.2 Exclu (phases ultérieures)
|
||||
|
||||
- La **consolidation nocturne** (#194) : la phase 0 livre le store, le recall et la création de faits en conversation ; l'extraction automatique nocturne arrive en phase 2, quand `agent_prompt` (#200) existe.
|
||||
- La **vue Aujourd'hui**, la file d'approbations unifiée et le centre de contrôle complet (#203, phase 1) : la phase 0 livre les cartes mémoire simples et une liste de runs basique dans les Configurations.
|
||||
- Les **tâches** (#202, phase 1) et tout connecteur (#195+, phase 3).
|
||||
- La politique « contenu connecteur » est livrée comme **mécanisme** (provenances `connector:<id>` reconnues) ; son premier usage réel est en phase 3.
|
||||
|
||||
## 3. État d'entrée — ce qui existe
|
||||
|
||||
| Élément | État | Rôle en phase 0 |
|
||||
|---|---|---|
|
||||
| Boucle agent + tool calling natif | ✅ `backend/agent/loop.py`, `backend/ai_chat.py` | Moteur des runs ; étendu, pas remplacé (P2) |
|
||||
| Registre `@tool` (risque/scope/confirmation/audit/rate-limit) | ✅ `backend/tools/registry.py` | Reçoit les champs `verify` et la provenance ; la politique s'y branche |
|
||||
| Historique de conversation persistant | ✅ #95, `backend/ai_history.py` | Mémoire épisodique brute (source future de #194) |
|
||||
| Recherche TF-IDF + sémantique RRF | ✅ #70 | Recall : fusion faits + recherche |
|
||||
| Cache SQLite (patron) | ✅ `backend/tools/webcache.py` | Modèle des stores SQLite de la phase |
|
||||
| Audit + redaction des secrets | ✅ `backend/audit.py`, `secret_redactor.py` | Journal des runs et des refus ; args toujours redactés |
|
||||
| Providers LLM multi-fournisseurs | ✅ `backend/ai.py` | Routage par type de job : amorce posée (§16.3 du parent), table complète en phases suivantes |
|
||||
| Ollama comme conteneur | ✅ présent | **Retiré** par #191 ; le provider `ollama` reste une option de configuration (desktop local) |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
Repris du parent : `facts` amendée (§9.2 — `entity_id`, `valid_from`, `valid_to`, `recorded_at`, `sensitivity`, `provenance_trust`, `kind` étendu à `procedure`), `entities` et `relations` (§9.3), `consolidation_runs` (créée dès cette phase, alimentée en phase 2), `agent_runs` et `agent_steps` (§10.2), auxquelles s'ajoute la table des délégations :
|
||||
|
||||
```sql
|
||||
CREATE TABLE delegations (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
domain TEXT NOT NULL, -- 'vault'|'tasks'|'email'|'calendar'|…
|
||||
level INTEGER NOT NULL, -- niveau accordé (0–3, §10.4 du parent)
|
||||
scope_json TEXT, -- périmètre : chemins, connecteur, budgets
|
||||
granted_at TEXT NOT NULL, review_at TEXT NOT NULL, -- défaut +90 j
|
||||
status TEXT NOT NULL DEFAULT 'active' -- active|expired|revoked
|
||||
);
|
||||
```
|
||||
|
||||
Stores : `data/memory.sqlite3` (faits, entités, relations, runs de consolidation) et `data/assistant.sqlite3` (runs, steps, délégations ; recevra `inbox_items`, `commitments`, `approvals` en phases 1 et 4 — le fichier est créé ici, les tables arrivent avec leurs tranches).
|
||||
|
||||
Règles transverses : toute table porte `user_id` ; aucun secret ni contenu brut de connecteur dans ces bases ; les args d'étapes sont stockés **redactés** ; les stores sont reconstructibles ou journalisés (jamais de contenu de Bruno irremplaçable hors du vault — P4).
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket.*
|
||||
|
||||
### Purge Ollama — #191
|
||||
|
||||
**P0-T01 — Retrait du conteneur Ollama.**
|
||||
Fichiers : `docker-compose.yml`, `docker-compose.test-linux.yml`, `docker-compose.test-win.yml`, docs d'installation.
|
||||
Travail : supprimer service, volume `ollama_data`, variables `OLLAMA_BASE_URL`/`OLLAMA_MODEL` et mentions du port 11434 ; procédure de suppression du volume documentée dans le CHANGELOG.
|
||||
Acceptation : `docker compose config` ne connaît plus Ollama ; l'instance démarre et l'agent fonctionne sur deepseek/openrouter/gemini.
|
||||
|
||||
**P0-T02 — Défauts provider nettoyés.**
|
||||
Fichiers : `backend/ai.py`, `model_capabilities.py`, tests d'isolation.
|
||||
Travail : conserver le provider `ollama` comme option explicite (desktop), retirer tout défaut pointant vers le réseau interne ; le mode par défaut de la mémoire fonctionne sans embeddings (TF-IDF + RRF + faits).
|
||||
Acceptation : aucun test ni défaut ne référence `http://ollama:11434` ; la recherche sémantique se dégrade proprement sans sentence-transformers.
|
||||
|
||||
### Mémoire socle — #192
|
||||
|
||||
**P0-T03 — Store mémoire : faits amendés, entités, relations.**
|
||||
Fichiers : nouveau `backend/memory/store.py` (patron `webcache.py`), création/ouverture de `data/memory.sqlite3`.
|
||||
Travail : DDL du §4 ; CRUD faits avec clôture bi-temporelle (supersede = `valid_to`, jamais d'écrasement) ; planchers de confiance V2 (< 0,7 → `pending`) ; dossiers sensibles configurables → `pending` + `sensitivity='sensitive'` ; renforcement `last_seen_at` et decay par `kind` (les `procedure` et `decision` ne décroissent pas).
|
||||
Acceptation : G7 passe (changement de dentiste : ancien fait clos, nouveau actif, requête historique datée correctement répondue) ; aucun fait `sensitive` créé directement en `confirmed`.
|
||||
|
||||
**P0-T04 — Entités et résolution de noms.**
|
||||
Fichiers : `backend/memory/entities.py`.
|
||||
Travail : CRUD entités (person/place/project/org/topic, alias) ; `resolve(name)` → candidates scorées ; **ambiguïté = pas de fusion** (renvoyer les candidates, la décision est une carte, phase 2 pour la consolidation) ; relations bi-temporelles.
|
||||
Acceptation : deux « Marie » distinctes coexistent sans fusion ; une relation fermée n'apparaît plus dans le recall actuel mais reste interrogeable à date.
|
||||
|
||||
**P0-T05 — Recall et compileur de contexte.**
|
||||
Fichiers : `backend/memory/recall.py`, `backend/memory/compiler.py`.
|
||||
Travail : score explicite et journalisé (RRF × confiance × fraîcheur × boost entité, parent §9.4) ; assemblage du bloc `<assistant-context>` à budget adaptatif (défaut 2 500 tokens) avec sections profil/procédures, entités du sujet, faits liés, engagements (vide cette phase), dernier briefing (absent cette phase — section omise proprement) ; chaque section cite son origine ; le bloc est visible dans les étapes du panneau avec le détail du score au survol.
|
||||
Acceptation : un fait sous le seuil n'entre pas dans le bloc ; le même bloc est reproduit à l'identique pour la même requête et le même état (déterminisme de structure) ; aucun contenu `sensitivity='sensitive'` dans un contexte destiné à un outil externe.
|
||||
|
||||
**P0-T06 — Outils mémoire et API.**
|
||||
Fichiers : `backend/tools/memory.py`, `backend/routers/memory.py`.
|
||||
Travail : les 5 outils V2 (`memory_recall`, `memory_save`, `memory_update`, `memory_forget`, `profile_get`) + `entity_resolve` (READ) et `procedure_get` (READ) ; `memory_save` passe par la politique (approbation selon niveau, défaut 2) ; endpoints CRUD faits (liste/filtre statut, validation `pending`, édition, oubli) et export JSON complet du domaine mémoire.
|
||||
Acceptation : un fait proposé en chat apparaît en carte, validé il est au miroir (T08) et au recall suivant ; `memory_forget` le retire du recall et du miroir, avec trace d'audit.
|
||||
|
||||
### Miroir markdown — #193
|
||||
|
||||
**P0-T07 — Miroir `Profil.md` à balises gérées.**
|
||||
Fichiers : `backend/memory/mirror.py`.
|
||||
Travail : création de `99_Assistant/` dans le vault mémoire (`OBSIGATE_MEMORY_VAULT`, mode multi-utilisateur du §5.3 du parent) ; rendu des sections gérées (préférences, procédures, objectifs connus) ; hash de section stocké en base ; écritures idempotentes (rendu deux fois = fichier identique).
|
||||
Acceptation : supprimer un fait validé régénère le miroir sans toucher au hors-balises ; deux rendus consécutifs produisent un diff vide.
|
||||
|
||||
**P0-T08 — Re-parse des éditions humaines.**
|
||||
Travail : avant chaque écriture, comparer le hash stocké au contenu courant ; une section modifiée à la main est re-parsée en faits `source='user'`, confiance 1.0, `confirmed`, puis régénérée ; une suppression dans le miroir = fait `rejected` en base ; le hors-balises n'est jamais touché.
|
||||
Acceptation : éditer `Profil.md` à la main (ajout + suppression) puis déclencher un rendu donne des faits conformes aux éditions, sans perte du texte libre hors balises.
|
||||
|
||||
**P0-T09 — `Personnes.md` (coquille) et `Journal.md`.**
|
||||
Travail : `Personnes.md` rendu depuis les entités personnes (nom, type de relation, dernière interaction connue) — en lecture structurée simple cette phase ; `Journal.md` alimenté par les événements mémoire (faits ajoutés/validés/rejetés, rendus du miroir) en lecture seule.
|
||||
Acceptation : une entité personne créée en T04 apparaît au miroir ; le journal trace chaque validation de carte.
|
||||
|
||||
### Politiques & taint — #213
|
||||
|
||||
**P0-T10 — Provenance de bout en bout.**
|
||||
Fichiers : `backend/agent/loop.py`, `backend/tools/registry.py`, `backend/ai_chat.py`.
|
||||
Travail : chaque contenu entrant dans un contexte ou un résultat d'outil reçoit une provenance (`user`, `vault`, `connector:<id>`, `web`, `mcp:<serveur>`, `automation`) portée dans les messages et les steps ; les résultats d'outils sont sanitisés (redaction existante) et étiquetés avant retour au modèle ; les prompts système distinguent explicitement blocs « instructions » et blocs « données ».
|
||||
Acceptation : un test d'intégration montre la provenance présente sur chaque résultat d'outil d'un run ; aucun résultat n'est injecté sans étiquette.
|
||||
|
||||
**P0-T11 — Moteur de politiques.**
|
||||
Fichiers : nouveau `backend/agent/policies.py` ; branchement dans le registre (évaluation **avant** chaque appel d'outil).
|
||||
Travail : décision déterministe `autorisé | approbation_run | approbation_renforcée | refusé` = f(risque registre, niveau d'autonomie du domaine, provenance, sensibilité des données, réversibilité) ; règles du parent §15.2 (une instruction de contenu non-`user` ne crée/lève aucune approbation ; action externe justifiée uniquement par un contenu non-`user` → refus + carte ; données `private|sensitive` interdites vers web/connecteurs en écriture/MCP externe) ; journal des refus avec motif, lisible dans l'audit.
|
||||
Acceptation : G3 passe (courriel piégé simulé → refus, carte à Bruno, zéro appel d'envoi) ; la décision est couverte par des tests unitaires exhaustifs sur la matrice des facteurs (aucun LLM dans le chemin).
|
||||
|
||||
**P0-T12 — Registre étendu : `verify`, inverses, sensibilité des paramètres.**
|
||||
Fichiers : `backend/tools/registry.py`, outils existants (annotations).
|
||||
Travail : champs optionnels du décorateur `@tool` : `verify` (post-condition vérifiable), `inverse` (constructeur d'inverse ou `None` explicite), `data_classes` (classes de données acceptées en entrée) ; annoter les outils vault et documents existants ; un outil d'écriture sans `verify` déclaré est listé dans un rapport de couverture (pas bloquant cette phase, bloquant pour les outils externes des phases 3+).
|
||||
Acceptation : le rapport de couverture existe et est revu ; `create_file`/`edit_file` déclarent leur post-condition et leur inverse (backup).
|
||||
|
||||
### Exécution fiable — #206
|
||||
|
||||
**P0-T13 — Stores de runs.**
|
||||
Fichiers : `backend/agent/runs.py`, `data/assistant.sqlite3`.
|
||||
Travail : DDL `agent_runs`/`agent_steps` du parent §10.2 ; cycle de vie complet des statuts ; écriture des steps au fil de l'exécution par la boucle existante (toute exécution d'outils crée ou rattache un run, `origin` renseignée) ; plafonds : itérations et budget tokens existants + durée murale et nombre d'écritures (`OBSIGATE_RUN_MAX_WALL_SECONDS`, `OBSIGATE_RUN_MAX_WRITES`).
|
||||
Acceptation : tuer le processus pendant un run laisse un run `running` orphelin détectable au redémarrage (marqué `paused` reprenable, jamais `succeeded`).
|
||||
|
||||
**P0-T14 — Plans d'exécution et approbation par run.**
|
||||
Travail : pour toute demande ≥ 2 écritures ou 1 outil DANGEROUS, génération d'un plan (étapes : outil, args résumés, effet attendu, post-condition, inverse) persisté dans `plan_json` ; affichage dans le panneau avant la première écriture ; validation en un clic = approbation du run entier (pas par étape) ; un plan refusé n'exécute rien et le run passe `cancelled` avec motif.
|
||||
Acceptation : une demande multi-écritures ne produit aucun effet avant validation ; le plan affiché correspond exactement aux steps exécutés ensuite.
|
||||
|
||||
**P0-T15 — Idempotence et reprise.**
|
||||
Travail : `idempotency_key` par step d'écriture (hash run+seq+args) respectée par les outils de création (vérification d'existence par clé en métadonnée) ; reprise d'un run interrompu à la première step non `done`, re-vérification des post-conditions incertaines, steps WRITE incertaines → `awaiting_approval` plutôt que rejeu aveugle.
|
||||
Acceptation : G6 passe (run tué à mi-parcours, repris : aucun doublon créé, état final vérifié).
|
||||
|
||||
**P0-T16 — Vérification et annulation.**
|
||||
Travail : exécution des `verify` après chaque step d'écriture ; statut final `succeeded` seulement si toutes les post-conditions vérifiables sont vraies, sinon `partially_succeeded` détaillé ; « Annuler ce run » exécute les inverses sûrs en ordre décroissant comme un run lié ; absence d'inverse affichée comme telle (« non annulable »).
|
||||
Acceptation : un outil dont la post-condition échoue (fixture) produit `partially_succeeded`, jamais `succeeded` ; l'annulation d'un run de création de fichiers restaure l'état initial via les backups.
|
||||
|
||||
**P0-T17 — Niveaux d'autonomie et délégations.**
|
||||
Fichiers : réglages Configurations, `delegations` (§4).
|
||||
Travail : niveaux 0–3 par domaine (défauts : vault = 2, tout domaine externe = 0/1 selon existence) ; création de délégation bornée (périmètre + budget + `review_at` +90 j) depuis une approbation (« toujours autoriser ce périmètre ») ou les Configurations ; expiration automatique → retour au niveau inférieur ; **aucun niveau ne couvre** paiements, envois externes sans relecture, suppressions définitives, changements de sécurité (codé en politique, pas en réglage).
|
||||
Acceptation : une délégation expirée cesse de s'appliquer sans intervention ; tenter de déléguer un envoi externe est refusé par l'UI et par l'API.
|
||||
|
||||
**P0-T18 — API runs et progression.**
|
||||
Fichiers : `backend/routers/runs.py`.
|
||||
Travail : `GET /api/runs` (filtres statut/origine), `GET /api/runs/{id}` (plan, steps, preuves), actions `pause`/`resume`/`cancel`/`undo` ; progression en SSE sur le flux existant ; liste de runs basique dans les Configurations (le centre de contrôle complet est en phase 1).
|
||||
Acceptation : un run en cours est observable en direct ; pause puis reprise depuis l'API aboutit au même résultat qu'une exécution continue.
|
||||
|
||||
### Évaluation — amorce #212
|
||||
|
||||
**P0-T19 — Harnais golden tasks.**
|
||||
Fichiers : `tests/golden/` (fixtures de vault, connecteurs simulés, harnais d'exécution headless).
|
||||
Travail : infrastructure de scénarios rejouables en CI (état initial → demande → assertions sur les stores, le vault et les runs) ; livrer G3 (injection), G6 (reprise), G7 (bi-temporalité) ; gabarit documenté pour les phases suivantes (chaque phase ajoute les siens).
|
||||
Acceptation : les trois scénarios tournent en CI et échouent si le comportement régresse (démontré par mutation volontaire en développement).
|
||||
|
||||
### Finitions
|
||||
|
||||
**P0-T20 — i18n, docs, guides.**
|
||||
Travail : clés FR/EN pour tout libellé nouveau (`ai.step.*` mémoire/runs/politiques, cartes, réglages d'autonomie) ; fiches `docs/features/` pour #191, #192/#193, #213, #206 ; guide utilisateur « Mémoire et contrôle de l'assistant » ; CHANGELOG + ROADMAP.
|
||||
Acceptation : DoD du §12 vérifiée point par point.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET/POST/PATCH /api/memory/facts` | CRUD faits, validation des `pending` | P0-T06 |
|
||||
| `GET /api/memory/entities` · `/api/memory/export` | Entités ; export du domaine mémoire | P0-T04/T06 |
|
||||
| `GET /api/runs` · `GET /api/runs/{id}` | Liste et détail (plan, steps, preuves) | P0-T18 |
|
||||
| `POST /api/runs/{id}/pause|resume|cancel|undo` | Pilotage d'un run | P0-T18 |
|
||||
| `GET/POST/DELETE /api/delegations` | Délégations bornées par domaine | P0-T17 |
|
||||
|
||||
Toutes suivent l'authentification et les permissions par vault existantes ; les nouveaux outils `@tool` sont exposés au MCP automatiquement (scopes IN_APP+MCP, propose/apply déjà gérés).
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Qui peut faire quoi
|
||||
|
||||
La mémoire, les runs et les délégations sont **par utilisateur** (`user_id` partout, parent §5.3). Un administrateur ne voit pas la mémoire d'un autre utilisateur ; l'audit montre les refus de politique sans le contenu sensible qui les a motivés.
|
||||
|
||||
### 7.2 Fait `pending` : durée de vie
|
||||
|
||||
Un fait `pending` reste en carte jusqu'à action (valider/éditer/oublier) — pas d'expiration silencieuse. Au-delà de 30 jours, il est regroupé dans un rappel hebdomadaire unique (pas une notification par fait ; le budget d'interruption P15 s'applique dès cette phase aux cartes).
|
||||
|
||||
### 7.3 Post-conditions : la règle du succès
|
||||
|
||||
`verify` absent ≠ succès présumé : le step est marqué `verified = NULL` et le run ne peut prétendre qu'à `succeeded` si **aucun** step d'écriture n'est simultanément `done` et non vérifié *alors qu'un `verify` existait*. Le rapport de couverture (P0-T12) rend la dette visible au lieu de la cacher.
|
||||
|
||||
### 7.4 Reprise après crash du conteneur
|
||||
|
||||
Au démarrage, tout run `running` sans heartbeat récent passe `paused` avec le motif `interrupted` ; la reprise est manuelle (API ou panneau) cette phase — la reprise automatique des runs `origin='scheduled'` arrive avec #200 en phase 2.
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Unitaires : store mémoire (clôture bi-temporelle, decay, planchers), résolution d'entités (ambiguïté), moteur de politiques (matrice complète des facteurs), idempotence (rejouer une step ne crée pas de doublon), miroir (idempotence du rendu, re-parse).
|
||||
- Intégration : run complet avec plan → approbation → exécution → vérification ; kill en cours de run → reprise ; édition manuelle de `Profil.md` → faits `source='user'`.
|
||||
- Golden tasks de la phase : **G3** (injection), **G6** (reprise), **G7** (bi-temporalité) — définitions au parent §16.1.
|
||||
- Non-régression : l'assistant actuel (sans mémoire activée) se comporte à l'identique ; `OBSIGATE_MEMORY_ENABLED=false` éteint la phase proprement.
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Le conteneur Ollama n'existe plus et l'assistant fonctionne sur les providers API.
|
||||
2. Un fait dit en conversation, validé par carte, est dans `Profil.md`, au recall de la conversation suivante, et son remplacement ultérieur conserve l'historique interrogeable à date.
|
||||
3. Éditer `Profil.md` à la main modifie la mémoire (source `user`, confiance 1.0) ; rien hors balises n'est jamais écrasé.
|
||||
4. Un run multi-écritures affiche son plan, n'exécute rien avant approbation, prouve chaque post-condition, et un run tué reprend sans doublon.
|
||||
5. Le scénario d'injection G3 est refusé par la politique, journalisé, et présenté à Bruno en carte.
|
||||
6. Les golden tasks G3/G6/G7 sont verts en CI.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| Le compileur de contexte consomme le budget du modèle en silence | Le bloc est plafonné, son détail est visible (P0-T05) ; la métrique « tokens de contexte mémoire / conversation » est journalisée dès cette phase (alimente #212) |
|
||||
| Approbation par run trop grossière (tout ou rien) | Le plan affiche chaque step ; un step peut être **exclu** du plan avant validation (case par step) — l'approbation reste unique, le périmètre est ajustable |
|
||||
| Le miroir et une édition collaborative (Yjs) se marchent dessus | Le miroir écrit via le même chemin de sauvegarde que l'éditeur (backup préalable) et compare les hashs avant/après (règle durcie en phase 2 pour la consolidation) |
|
||||
| Dérive du périmètre : vouloir les tâches dès la phase 0 | Les tâches sont la phase 1 ; la phase 0 ne livre que les fondations qu'elles consommeront (runs, politique, mémoire) |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P0-T01 → T02 (#191, indépendant, à faire en premier)
|
||||
P0-T03 → T04 → T05 → T06 (#192)
|
||||
P0-T07 → T08 → T09 (#193, après T03/T06)
|
||||
P0-T10 → T11 → T12 (#213, T10 tôt : la provenance sert les runs)
|
||||
P0-T13 → T14 → T15 → T16 → T17 → T18 (#206, après T11)
|
||||
P0-T19 (harnais tôt, scénarios au fil des tickets) → P0-T20 (clôture)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD V2 §13 (tests unitaires + non-régression, JSDOM si UI, ruff/mypy 0 erreur, i18n FR/EN, CHANGELOG, ROADMAP, fiche par tranche, guide si exposé, CI verte, commit conventionnel référençant l'ID) **plus** les ajouts V2.1 (parent §21) : golden tasks du périmètre vertes ; toute écriture déclare sa post-condition et son inverse ou son absence ; toute source de contenu déclare sa provenance.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 1 : Le travail (tâches, Aujourd'hui, planification, capture)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **1 sur 5** — le premier jalon « assistant personnel » |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §6 (tâches), §7 (planification), §8 (capture), §14 (UX) |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#202** (tâches, projets, objectifs), **#203** (vue Aujourd'hui, file d'approbations unifiée, centre de contrôle), **#204** (moteur de planification), **#205** (capture universelle & Inbox) |
|
||||
| Effort estimé | 14–18 jours |
|
||||
| Prérequis | Phase 0 terminée : mémoire (#192/#193), politiques (#213), runs persistants et niveaux d'autonomie (#206) |
|
||||
| Dépendances | #202 ← #193 ; #203 ← #202, #206 ; #204 ← #202 ; #205 ← #202. **Pas de dépendance à #200** : voir §7.1 pour la résolution explicite |
|
||||
| Débloque | Phase 2 (rappels liés aux tâches, revues ritualisées, briefing-plan), phase 4 (engagements rattachés aux tâches et aux personnes) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** L'assistant planifie et agit sur le vault **sans aucun connecteur**. Les tâches vivent dans les notes (checklists enrichies), indexées et interrogeables ; un projet sans prochaine action se voit. Chaque matin, Bruno peut demander son **plan du jour** : agenda du vault + tâches dues + capacité déclarée → proposition en blocs de temps qui **signale la surcharge au lieu de la cacher**, négociable en conversation. L'écran **Aujourd'hui** montre le plan, les priorités, la file unique de tout ce qui attend sa décision, et l'activité de l'assistant. Et tout ce qui lui passe par la tête se capture en 5 secondes (panneau, mobile, voix) dans une **Inbox** que l'assistant trie en propositions validables.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée — ce que la phase 0 a livré](#3-état-dentrée--ce-que-la-phase-0-a-livré)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **le travail est un objet, pas un texte (P10).** À la fin de la phase, rien d'important pour Bruno n'existe seulement « dans une conversation » ou « quelque part dans une note » : c'est une tâche datée, un projet avec une prochaine action, ou un item d'inbox en attente de tri.
|
||||
|
||||
Corollaires :
|
||||
|
||||
- **Markdown d'abord, comme la mémoire.** La tâche vit sur sa ligne, dans la note du projet ; l'index SQLite est reconstructible et tout désaccord se résout en faveur de la note.
|
||||
- **Le plan propose, il n'ordonne pas.** Le moteur calcule (déterministe), le modèle rédige et négocie ; la validation est humaine (ou déléguée bornée, phase 0).
|
||||
- **La surcharge se dit.** Si les tâches retenues dépassent la capacité, le plan le signale et propose des reports explicites. Un plan irréaliste est un défaut du système.
|
||||
- **Une seule file de décisions.** Faits `pending`, plans, triages : tout passe par la même file (#203), pour que « ce que l'assistant attend de moi » tienne en un écran et moins de 5 minutes par jour.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #202 : syntaxe de tâche (états, attributs `[due::] [pri::] [est::] [ctx::] [energy::] [every::]`), parseur et réécriture de lignes, index `data/tasks.sqlite3` maintenu par le watcher, projets et objectifs par frontmatter, récurrence **RRULE** (sous-ensemble RFC 5545), outils `task.*`/`project.*`/`goal_review`, API tâches.
|
||||
- #203 : `GET /api/today` et la vue Aujourd'hui (plan, priorités, file de décisions, inbox, activité) ; file d'approbations unifiée `approvals` (migration des cartes mémoire de la phase 0) ; centre de contrôle v1 (runs, délégations, budgets, reconstruction d'index).
|
||||
- #204 : moteur de planification déterministe (scoring explicable, capacité et créneaux protégés tirés du profil), plan du jour en note `Plans/YYYY-MM-DD.md` + objet plan, time blocking dans les trous d'agenda, négociation par `plan_update`, replanification du reste de journée, agrégats de revue (jour/semaine) — le plan automatique du matin est livré comme **tâche intégrée typée** du scheduler (§7.1), la généralisation `agent_prompt` restant en phase 2.
|
||||
- #205 : Inbox (store, API, outil `inbox_add`), canaux de capture (panneau ⚡, préfixe `+` au chat, Web Share Target PWA, API/MCP), triage assisté par cartes (tâche/note/rappel/fait candidat/événement/à jeter), file hors-ligne rejouée avec horodatage de capture.
|
||||
|
||||
### 2.2 Exclu (phases ultérieures)
|
||||
|
||||
- Les **rappels** comme objets livrés à heure dite (#199, phase 2) : le triage peut *proposer* un rappel, sa création effective attend #199 ; en phase 1, la proposition est convertie en tâche due datée (comportement documenté dans l'UI de la carte).
|
||||
- Le **briefing** (#200, phase 2) et les **rituels** complets (#214, phase 2) : la phase livre le plan et les données de revue ; leur mise en rituel automatique est en phase 2.
|
||||
- Tout **agenda externe** : l'« agenda » de la phase = événements notés dans le vault (notes datées, section Temps du profil) ; l'écriture des blocs dans Google/Outlook arrive en phase 3 avec les connecteurs. Les blocs du plan vivent dans la note du plan.
|
||||
- La transcription vocale (#209, phase 4) : la capture voix de la phase utilise la dictée du navigateur quand elle existe, sinon un texte saisi.
|
||||
|
||||
## 3. État d'entrée — ce que la phase 0 a livré
|
||||
|
||||
| Élément | Usage en phase 1 |
|
||||
|---|---|
|
||||
| Runs persistants, plans, approbations par run (#206) | Le triage, la génération de plan et ses ajustements sont des runs ; la file d'approbations consomme les demandes d'approbation des runs |
|
||||
| Moteur de politiques (#213) | Toute création de tâche/note depuis l'inbox ou un plan passe par la politique (provenance incluse) |
|
||||
| Mémoire et compileur de contexte (#192) | Le plan consomme capacité et créneaux protégés depuis le profil ; le compileur reçoit la section « Aujourd'hui » (plan résumé, tâches dues) |
|
||||
| Miroir à balises gérées (#193) | Même patron pour `Plans/`, `Taches.md` et les sections gérées des notes projets |
|
||||
| Watcher et index du vault | Source des événements de re-parse des tâches |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
Repris du parent : table `tasks` (§6.1 — id ancré, états, projet/objectif, échéances, priorité, estimation, contexte, énergie, `rrule`, `waiting_on`, source), table `inbox_items` (§8.2). Ajouts de la phase :
|
||||
|
||||
```sql
|
||||
CREATE TABLE plans (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL, -- jour local du plan
|
||||
status TEXT NOT NULL DEFAULT 'proposed' -- proposed|validated|in_progress|closed
|
||||
CHECK (status IN ('proposed','validated','in_progress','closed')),
|
||||
capacity_min INTEGER NOT NULL, -- capacité retenue pour ce jour
|
||||
blocks_json TEXT, -- blocs : tâche/événement, début, fin, source
|
||||
overload_json TEXT, -- surcharge calculée + reports proposés
|
||||
note_path TEXT, -- 99_Assistant/Plans/YYYY-MM-DD.md
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE approvals (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- fait|plan|envoi|action_write|action_dangerous|
|
||||
-- triage|contradiction|delegation_review
|
||||
run_id TEXT, -- run demandeur (nullable pour les cartes mémoire)
|
||||
payload_json TEXT NOT NULL, -- ce qui est à décider, affichable tel quel
|
||||
status TEXT NOT NULL DEFAULT 'pending' -- pending|approved|rejected|expired
|
||||
CHECK (status IN ('pending','approved','rejected','expired')),
|
||||
created_at TEXT NOT NULL, decided_at TEXT, expires_at TEXT
|
||||
);
|
||||
CREATE INDEX idx_approvals_pending ON approvals(user_id, status, created_at);
|
||||
```
|
||||
|
||||
`approvals` vit dans `data/assistant.sqlite3` (créé en phase 0). Une approbation **périmée ne s'exécute jamais** : à l'expiration, le run demandeur est notifié et l'action associée est abandonnée (journalisé).
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket.*
|
||||
|
||||
### Tâches & projets — #202
|
||||
|
||||
**P1-T01 — Syntaxe et parseur de tâches.**
|
||||
Fichiers : nouveau `backend/tasks/model.py`.
|
||||
Travail : grammaire des états (`[ ] [/] [x] [-] [!]` + motif « bloqué : » en sous-ligne) et des attributs entre crochets (tous optionnels, ordre libre, texte libre préservé) ; parse d'un fichier markdown → tâches avec position (ligne) ; réécriture d'une ligne à l'identique hors la modification demandée (aucune reformatage parasite) ; ancrage `t-<uuid4>` en commentaire HTML discret **seulement** pour les tâches référencées (rappel, run, plan).
|
||||
Acceptation : aller-retour parse → réécriture sans changement = fichier identique au caractère près ; les checklists Obsidian existantes du vault de test sont lues sans perte.
|
||||
|
||||
**P1-T02 — Index des tâches et watcher.**
|
||||
Fichiers : `backend/tasks/store.py`, `data/tasks.sqlite3`, branchement au watcher existant.
|
||||
Travail : DDL du parent §6.1 ; re-parse incrémental par fichier modifié ; reconstruction complète à la demande (bouton centre de contrôle) et au démarrage si l'index est absent/corrompu ; le désaccord se résout en faveur de la note.
|
||||
Acceptation : créer/modifier/cocher une tâche dans l'éditeur met l'index à jour en < 2 s ; supprimer l'index et redémarrer le reconstruit à l'identique.
|
||||
|
||||
**P1-T03 — Projets et objectifs.**
|
||||
Travail : détection par frontmatter (`obsigate: projet|objectif`) ou dossier projets configurable ; état du projet (actif/pause/terminé/archivé), prochaine action = première tâche ouverte liée ; calcul « projet sans prochaine action » exposé à l'index et à l'API (le détecteur d'insight est en phase 4, la donnée est ici) ; objectifs : horizon, mesure, projets liés.
|
||||
Acceptation : un projet actif sans tâche ouverte est retourné par `project_status` avec le drapeau `no_next_action = true`.
|
||||
|
||||
**P1-T04 — Récurrence RRULE.**
|
||||
Fichiers : `backend/tasks/rrule.py` (stdlib + `zoneinfo`, sous-ensemble RFC 5545 : `FREQ`, `INTERVAL`, `BYDAY`, `BYMONTHDAY`, `COUNT`, `UNTIL`).
|
||||
Travail : ancrage à l'**heure murale** dans `OBSIGATE_TZ` (jamais un timestamp UTC figé) ; à la complétion d'une occurrence, création de la suivante selon le mode (série/instance) ; dates de fin respectées.
|
||||
Acceptation : « tous les mar/jeu pendant 8 semaines » produit exactement 16 occurrences ; le passage à l'heure d'hiver ne décale aucune heure murale (tests sur `America/Toronto`).
|
||||
|
||||
**P1-T05 — Outils tâches et API.**
|
||||
Fichiers : `backend/tools/tasks.py`, `backend/routers/tasks.py`.
|
||||
Travail : `task_list` (filtres today/overdue/project/context/waiting), `task_create` (note cible : note du projet, sinon `99_Assistant/Taches.md`), `task_update`, `task_complete` (avec récurrence T04), `project_status`, `goal_review` ; endpoints REST miroirs + recherche plein texte sur les titres.
|
||||
Acceptation : une tâche créée en conversation se retrouve dans la note, dans l'index et dans la réponse de `task_list(filter=today)` si elle est due ce jour.
|
||||
|
||||
### Aujourd'hui & approbations — #203
|
||||
|
||||
**P1-T06 — Agrégat `GET /api/today`.**
|
||||
Fichiers : `backend/routers/today.py`.
|
||||
Travail : assemblage lecture seule : plan du jour + capacité restante, 3 priorités, compteurs file de décisions, inbox (nombre, plus ancien), tâches dues/en retard, activité des runs (24 h), alertes (index périmé, run en échec) ; chaque section porte ses liens d'objet ; temps de réponse cible < 300 ms sur le vault de test (agrégats indexés, aucun balayage de fichiers).
|
||||
Acceptation : l'agrégat est cohérent avec les endpoints de détail (tests croisés) ; aucune section ne fait échouer les autres (dégradation par section).
|
||||
|
||||
**P1-T07 — Vue Aujourd'hui.**
|
||||
Fichiers : frontend (SPA), patron de la maquette du parent §14.1.
|
||||
Travail : sections plan/priorités/décisions/inbox/activité ; actions sur place (valider un bloc, reporter une tâche, ouvrir la file) sans quitter l'écran ; états vides honnêtes (« pas de plan : en proposer un ») ; responsive et PWA (c'est l'écran d'accueil mobile de l'assistant).
|
||||
Acceptation : tests JSDOM sur les sections et les actions ; l'écran reste utilisable avec zéro connecteur, zéro plan et une inbox vide.
|
||||
|
||||
**P1-T08 — File d'approbations unifiée.**
|
||||
Fichiers : `backend/routers/approvals.py`, migration des cartes mémoire.
|
||||
Travail : table `approvals` (§4) ; les cartes `pending` de la phase 0 (faits) sont re-présentées comme des approvals de type `fait` sans perte d'état ; regroupement par run et par lot (dépliable) ; expiration honorée ; historique des décisions dans l'audit ; badge de compteur partagé (Aujourd'hui, panneau, Configurations).
|
||||
Acceptation : un fait `pending`, un plan à valider et un lot de triages coexistent dans une seule file triée par ancienneté ; approuver un lot de 12 items crée 12 décisions tracées, pas une.
|
||||
|
||||
**P1-T09 — Centre de contrôle v1.**
|
||||
Travail : page Configurations « Assistant » : runs (liste/détail/reprise/annulation — déjà en phase 0, ici intégrés), délégations et niveaux par domaine, budgets (tokens, et compteur d'approbations du jour), santé des index (tâches, mémoire) + bouton « reconstruire l'index des tâches », liens vers les miroirs `99_Assistant/`.
|
||||
Acceptation : chaque compteur affiché correspond à une métrique journalisée (pas d'affichage décoratif) ; la reconstruction d'index est un run visible.
|
||||
|
||||
### Planification — #204
|
||||
|
||||
**P1-T10 — Moteur de scoring et capacité.**
|
||||
Fichiers : `backend/tasks/planner.py`.
|
||||
Travail : entrées déterministes (§7.1 du parent) : événements du vault, tâches dues + en retard, capacité déclarée (réglage + section « Temps » du profil : heures de focus, créneaux protégés), rituels absents cette phase ; scoring explicable (échéance > priorité > âge > lien objectif ; pénalité d'énergie incompatible, règle issue du profil uniquement) ; chaque tâche retenue ou écartée porte sa **raison** affichable.
|
||||
Acceptation : à entrées égales, plan proposé identique (déterminisme) ; la raison d'exclusion d'une tâche est consultable et correcte.
|
||||
|
||||
**P1-T11 — Génération du plan du jour.**
|
||||
Travail : objet `plans` (§4) + note `99_Assistant/Plans/YYYY-MM-DD.md` à balises gérées ; time blocking dans les trous d'agenda (durée estimée, défaut 45 min **marqué estimé**) ; calcul de surcharge : si la somme dépasse la capacité, `overload_json` liste les reports proposés (jamais de compression silencieuse) ; rédaction LLM (1 appel, modèle de routage « planification ») avec citation de la source de chaque ligne.
|
||||
Acceptation : G2 passe (journée surchargée → surcharge signalée, reports proposés, aucun bloc inventé) ; la note du plan et l'objet plan restent synchronisés après édition des blocs gérés.
|
||||
|
||||
**P1-T12 — Négociation et replanification.**
|
||||
Fichiers : `backend/tools/planner.py` (`plan_today`, `plan_propose`, `plan_update`, `plan_review`).
|
||||
Travail : ajustements conversationnels (« déplace X à demain », « je n'ai que 2 h ») → réécriture de la proposition, revalidation ; replanification du **reste de journée** uniquement, diff lisible (« X glisse à 15 h 30, Y reporté ») ; seuil d'impact : tout report d'une tâche `pri: haute+` exige validation.
|
||||
Acceptation : une replanification ne touche jamais un bloc passé ; le diff affiché correspond exactement aux changements appliqués à la validation.
|
||||
|
||||
**P1-T13 — Revues : agrégats jour et semaine.**
|
||||
Travail : `plan_review(period)` déterministe sans LLM : fait/pas fait/reporté (avec compteur de reports par tâche — la donnée du détecteur « reportée ≥ 3 fois » de la phase 4), projets sans prochaine action, progrès objectifs ; rédaction de la note de revue de semaine (le rituel automatisé est en phase 2, la fonction est ici, appelable à la demande et par skill `/revue`).
|
||||
Acceptation : une tâche reportée 3 fois est comptée 3 fois, avec ses dates ; la revue ne contient aucun chiffre non traçable à l'index.
|
||||
|
||||
**P1-T14 — Déclenchement du plan sans #200.**
|
||||
Travail : tâche intégrée typée du scheduler existant (`action: plan_propose`, heure configurable, défaut 07:00, politique de rattrapage simple : si l'occurrence du jour manque, proposer à la première ouverture d'Aujourd'hui) ; skill `/planjour` ; **aucune** utilisation d'`agent_prompt` générique (phase 2).
|
||||
Acceptation : à 7 h, la proposition du jour existe ; conteneur arrêté à 7 h puis rouvert à 9 h → la proposition est générée à l'ouverture, datée du jour, sans doublon.
|
||||
|
||||
### Capture & Inbox — #205
|
||||
|
||||
**P1-T15 — Store Inbox et outil d'ajout.**
|
||||
Fichiers : `backend/inbox/store.py`, `backend/tools/inbox.py`, `data/assistant.sqlite3`.
|
||||
Travail : table `inbox_items` (parent §8.2) ; `inbox_add` (WRITE sans classement — la capture ne décide rien), `inbox_list` ; `POST /api/inbox` authentifié, exposé au MCP ; horodatage de capture conservé séparément de l'horodatage de synchronisation.
|
||||
Acceptation : un item ajouté par API, par chat et par MCP a la même forme et le même statut initial `new`.
|
||||
|
||||
**P1-T16 — Canaux de capture.**
|
||||
Travail : bouton « ⚡ Capture » du panneau (aucun appel LLM) ; préfixe `+` dans le chat général et le chat de fichier ; Web Share Target de la PWA (titre + URL + texte) ; bouton capture de la vue Aujourd'hui mobile ; dictée navigateur si disponible (sinon saisie texte).
|
||||
Acceptation : capture mobile de bout en bout en < 5 s (mesuré sur le parcours PWA) ; une capture ne déclenche aucun run LLM (vérifié au journal).
|
||||
|
||||
**P1-T17 — Triage assisté.**
|
||||
Fichiers : `backend/tools/inbox.py` (`inbox_triage`), prompt de triage (annexe B.1 du parent).
|
||||
Travail : proposition typée par item (tâche avec échéance **uniquement si énoncée ou déductible d'une date explicite** — aucune date inventée ; note avec dossier proposé ; rappel → tâche datée cette phase, §2.2 ; fait candidat → pipeline mémoire `pending` ; événement vault ; à jeter) ; présentation en cartes dans la file unifiée (type `triage`), validation/correction/rejet unitaire ou en lot pour les évidents ; application = un run par lot, via la politique (provenance `inbox`).
|
||||
Acceptation : G4 passe (compte rendu collé dans l'inbox → tâches datées + proposition d'engagement en cartes, rien créé sans validation) ; un item sans date explicite produit une tâche **sans** échéance.
|
||||
|
||||
**P1-T18 — Hors-ligne.**
|
||||
Travail : les captures PWA hors-ligne entrent dans la file IndexedDB existante avec leur horodatage d'origine et sont rejouées à la reconnexion ; la résolution des dates relatives (« demain ») se fait à la date de **capture** ; sur Tauri hors-ligne, même file locale ; le triage différé (serveur injoignable) ne perd ni ne duplique aucun item.
|
||||
Acceptation : capture hors-ligne puis reconnexion = un seul item, daté de la capture ; rejouer deux fois la file ne crée aucun doublon (clé d'idempotence par item).
|
||||
|
||||
### Finitions
|
||||
|
||||
**P1-T19 — Skills et surfaces conversationnelles.**
|
||||
Travail : skills `/planjour`, `/revue`, `/trier`, `/taches` ; section « Aujourd'hui » du compileur de contexte (plan résumé + tâches dues, budget borné) ; libellés `ai.step.*` pour les outils tâches/plan/inbox.
|
||||
Acceptation : « qu'est-ce que j'ai aujourd'hui ? » en conversation répond depuis l'agrégat Aujourd'hui, sources citées, sans accès fichier direct.
|
||||
|
||||
**P1-T20 — i18n, docs, guides.**
|
||||
Travail : clés FR/EN (vue Aujourd'hui, cartes, plans, inbox) ; fiches `docs/features/` pour #202–#205 ; guide utilisateur « Tâches, plan du jour et capture » ; CHANGELOG + ROADMAP.
|
||||
Acceptation : DoD du §12 vérifiée point par point.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET /api/today` | Agrégat de la vue Aujourd'hui | P1-T06 |
|
||||
| `GET/POST/PATCH /api/tasks` · `/api/tasks/{id}/complete` | CRUD et complétion des tâches (miroir des outils) | P1-T05 |
|
||||
| `GET /api/projects` · `GET /api/projects/{id}/status` | Projets et leur état calculé | P1-T03/T05 |
|
||||
| `GET /api/plans/{date}` · `POST /api/plans/{date}/propose|update|validate` | Plan du jour | P1-T11/T12 |
|
||||
| `GET /api/approvals` · `POST /api/approvals/{id}/decide` · `POST /api/approvals/decide-batch` | File de décisions unifiée | P1-T08 |
|
||||
| `GET/POST /api/inbox` · `POST /api/inbox/{id}/triage` | Capture et triage | P1-T15/T17 |
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Dépendance #204 → #200 : résolution
|
||||
|
||||
Le tableau des tranches du parent donnait #204 dépendant de #200 (`agent_prompt`), alors que l'ordre des phases place #200 en phase 2. **Résolution adoptée dans les documents de phase** : le plan du matin n'a pas besoin d'un prompt libre — c'est une fonction typée. La phase 1 le livre comme tâche intégrée du scheduler (P1-T14) ; la phase 2 généralise (#200 : `agent_prompt` arbitraire, briefing comme amorce rédactionnelle du plan) sans changer le comportement du plan. Le parent est corrigé en conséquence (§19.2 : dépendance de #204 = #202, #193).
|
||||
|
||||
### 7.2 Tâche ou rappel ?
|
||||
|
||||
En phase 1, il n'existe qu'un objet daté : la tâche. La carte de triage qui propose « rappel » crée une tâche due avec `ctx: rappel` ; en phase 2, #199 rattache la livraison (push/canaux, snooze, escalade) à ces mêmes tâches — aucune migration d'objets, un enrichissement de comportement.
|
||||
|
||||
### 7.3 Édition concurrente d'une ligne de tâche
|
||||
|
||||
Si la ligne d'une tâche a changé dans la note entre la lecture et la réécriture demandée par un outil (édition humaine entre-temps), l'outil refuse avec un conflit lisible et relit l'état courant — jamais d'écrasement. Le backup automatique existant couvre les écritures.
|
||||
|
||||
### 7.4 Capacité et créneaux protégés
|
||||
|
||||
La capacité vient d'abord du réglage, puis de la section « Temps » de `Profil.md` si Bruno l'édite (source `user`, prioritaire). Le moteur n'infère **jamais** une capacité depuis l'historique sans la proposer en carte (ce serait une procédure — phase 2 via la consolidation, validation humaine).
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Unitaires : parseur (aller-retour sans perte), RRULE (16 occurrences, heure d'hiver), scoring (déterminisme, raisons), surcharge (aucune compression), approvals (expiration, lots).
|
||||
- Intégration : tâche créée par 3 surfaces → même objet ; plan généré → négocié → validé → revu ; capture hors-ligne → synchro → triage.
|
||||
- Golden tasks de la phase : **G2** (surcharge), **G4** (triage d'un compte rendu) — définitions au parent §16.1 ; plus deux scénarios locaux : « tâche créée en chat visible aux 3 surfaces » et « plan du matin après redémarrage » (P1-T14).
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Une tâche créée en conversation, dans l'éditeur ou par triage d'inbox est le **même objet** dans la note, l'index et Aujourd'hui.
|
||||
2. Le plan d'une journée surchargée signale la surcharge et propose des reports ; aucun bloc n'est inventé et aucune tâche n'est compressée en silence.
|
||||
3. La file de décisions contient faits, plans et triages ; le temps de décision quotidien est mesuré et affiché au centre de contrôle.
|
||||
4. Une capture mobile prend moins de 5 secondes ; une capture hors-ligne n'est ni perdue ni dupliquée.
|
||||
5. Aucun connecteur n'est requis pour l'ensemble de la phase.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| Friction de la syntaxe d'attributs dans les notes | Tous les attributs sont optionnels ; une checklist ordinaire reste une tâche valide (échéance absente = jamais proposée comme « due ») |
|
||||
| L'index et les notes divergent (édition externe, Syncthing) | Reconstruction complète toujours disponible ; les conflits de ligne sont des refus lisibles (§7.3), pas des écrasements |
|
||||
| Le plan du jour est ignoré s'il arrive trop tard | Proposition à 7 h **et** à la première ouverture d'Aujourd'hui ; la métrique « plans validés/modifiés/ignorés » est suivie dès cette phase (#212) |
|
||||
| La file de décisions devient un deuxième travail | Lots, expiration, et métrique du temps de décision : au-delà de 5 min/jour en moyenne, c'est un signal de défaut à traiter (parent §20) |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P1-T01 → T02 → T03 → T04 → T05 (#202 — T04 peut paralléliser après T01)
|
||||
P1-T06 → T07 ; P1-T08 ; P1-T09 (#203 — T08 après T05, T06 après T11 pour le plan réel,
|
||||
mais livrable avant avec section plan vide honnête)
|
||||
P1-T10 → T11 → T12 → T13 → T14 (#204)
|
||||
P1-T15 → T16 → T17 → T18 (#205)
|
||||
P1-T19 → T20 (finitions)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD du document de phase 0 §12, inchangée, plus : les golden tasks G2 et G4 vertes en CI ; la reconstruction d'index démontre l'égalité index ↔ notes sur le vault de test.
|
||||
@@ -0,0 +1,254 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 2 : Le temps réel (agent programmé, consolidation, rappels, automatisations, rituels)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **2 sur 5** — l'assistant devient proactif, dans un budget |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §9.5–9.6 (consolidation), §12 (automatisations), §13.3 (rituels), §14.4 (budget d'interruption) |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#200** (agent programmé + briefing), **#194** (consolidation nocturne), **#199** (rappels), **#208** (automatisations événementielles & recettes), **#214** (rituels & habitudes) |
|
||||
| Effort estimé | 14–19 jours |
|
||||
| Prérequis | Phase 0 (runs #206, politiques #213, mémoire #192/#193) et phase 1 (tâches #202, plan #204, inbox #205, file d'approbations #203) terminées |
|
||||
| Dépendances | #200 ← #192, #206 ; #194 ← #200, #193 ; #199 ← #200, #202 ; #208 ← #206, #202 ; #214 ← #204, #208 |
|
||||
| Débloque | Phase 3 (les automatisations et le briefing consommeront les événements connecteurs), phase 4 (la proactivité #201 s'appuie sur les détecteurs et le budget livrés ici) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** L'assistant travaille **quand Bruno n'est pas là** — de façon bornée, journalisée et révocable. Le scheduler sait lancer l'agent (`agent_prompt` à whitelist) : le briefing du matin devient l'amorce rédigée du plan du jour. Chaque nuit, la consolidation extrait faits, entités et contradictions des conversations et des notes, en cartes validables, sous budget de tokens. Les rappels sont de vrais objets livrés à heure dite (push, canaux, escalade, snooze compté) et peuvent être **contextuels** (« quand j'ouvre le projet X »). Les automatisations réagissent aux événements du vault et des tâches, avec dry-run et circuit-breaker. Et les rituels — matin, soir, semaine — tiennent la boucle : rien ne glisse en silence d'un jour à l'autre.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée](#3-état-dentrée)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **l'autonomie se prouve en journal, pas en promesse.** Tout ce que la phase ajoute d'automatique — un run planifié, une consolidation, une automatisation — est un run ordinaire de la phase 0 : visible, plafonné, reprenable, annulable, et soumis au moteur de politiques et au **budget d'interruption** (P15).
|
||||
|
||||
Corollaires :
|
||||
|
||||
- **Pas de magie par défaut.** Chaque tâche planifiée et chaque automatisation est créée par Bruno ou proposée en carte puis validée ; l'installation ne crée que le briefing-plan (désactivable) et la consolidation (désactivable).
|
||||
- **Les actions primitives d'abord.** Une automatisation qui peut s'exprimer sans LLM (créer une tâche, notifier, classer) ne doit pas appeler de LLM : c'est plus fiable, gratuit et auditable.
|
||||
- **L'échec est un état, pas un silence.** Échec de run planifié = journal + notification de synthèse au briefing suivant ; échecs répétés d'une automatisation = suspension + insight, jamais une boucle d'erreurs.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #200 : action scheduler `agent_prompt` (whitelist de capacités V2 renforcée : READ libre ; WRITE restreint aux chemins déclarés, défaut `99_Assistant/**` ; DANGEROUS et écritures connecteurs interdits ; `allowed_extra_tools` explicite), plafonds (8 itérations par défaut, budget tokens par tâche), runs `origin='scheduled'` unifiés au modèle de la phase 0 (reprise des runs planifiés interrompus), briefing du matin comme amorce rédactionnelle du plan (skill `/brief`), tâches intégrées déclarées dans l'UI avec leurs capacités.
|
||||
- #194 : consolidation nocturne complète (parent §9.5) : collecte incrémentale (sessions, notes, tâches clôturées, revues), extraction LLM sous budget dur, résolution d'entités, contradictions explicites vers la file d'approbations, candidats connecteurs en réglage `off|candidats` (mécanisme livré ; les sources arrivent en phase 3), journal dans `Journal.md`.
|
||||
- #199 : action scheduler `reminder` et livraison multi-canal (Web Push + `notify.broadcast` existant), outils `reminder_*`, RRULE (moteur de la phase 1), rattachement aux tâches (une tâche due peut être livrée comme rappel — §7.2 du document phase 1), snooze compté, escalade optionnelle, section Rappels d'Aujourd'hui.
|
||||
- #208 : bus d'événements interne (vault, tâches, inbox, runs, horloge, webhooks entrants), moteur d'automatisations (note markdown à frontmatter structuré : déclencheur → conditions → actions primitives ou `agent_prompt` borné), gardes (`frequence_max` obligatoire, dédup par clé d'événement, profondeur d'enchaînement ≤ 2, circuit-breaker à 3 échecs), dry-run sur événements passés, scheduler amendé (cron complet, politique de rattrapage `misfire`, idempotence par occurrence), activation des **rappels contextuels** de #199 sur le bus, catalogue initial de recettes (annexe D du parent) en modèles.
|
||||
- #214 : rituels livrés comme recettes assemblées sur l'existant (briefing/plan du matin, revue du soir à reports explicites, revue de semaine, inbox zéro) ; habitudes = tâches récurrentes rattachées à un objectif avec compteur de série ; série cassée → au plus un insight hebdomadaire.
|
||||
|
||||
### 2.2 Exclu (phases ultérieures)
|
||||
|
||||
- Les événements `connector.*` : le bus les **déclare** dès cette phase (schéma d'événement), le moteur de sync qui les émet est en phase 3. Aucune automatisation livrée ne dépend d'un connecteur.
|
||||
- Les détecteurs d'insights complets (#201, phase 4) : la phase livre les **données** (snooze compté, reports comptés, séries) et le canal `Insights.md` ; les détecteurs métier sont en phase 4.
|
||||
- Les engagements (#211, phase 4) : la consolidation peut déjà émettre des faits `open_loop` (V2) ; leur promotion en objets suivis est en phase 4.
|
||||
|
||||
## 3. État d'entrée
|
||||
|
||||
| Élément | Usage en phase 2 |
|
||||
|---|---|
|
||||
| Scheduler #170 (`interval_hours`/`daily_time`/`once_at`, actions primitives) | Socle étendu, jamais remplacé ; les tâches existantes des utilisateurs continuent de fonctionner |
|
||||
| Runs et politiques (phase 0) | Tout travail automatique est un run sous politique ; les plafonds par tâche s'ajoutent aux plafonds par run |
|
||||
| Tâches RRULE, plan, revues (phase 1) | Les rappels s'appuient sur l'index des tâches ; le briefing-plan consomme le plan ; les rituels appellent `plan_review` |
|
||||
| File d'approbations (phase 1) | Contradictions de consolidation, propositions d'automatisations et faits `pending` y convergent |
|
||||
| Notifications #168 + Web Push #67 | Bras de livraison des rappels et des alertes, sous budget d'interruption |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
Pas de nouvelle base : extensions de `data/assistant.sqlite3` et du fichier des tâches planifiées existant.
|
||||
|
||||
```sql
|
||||
CREATE TABLE automation_runs ( -- journal d'exécution des automatisations
|
||||
id TEXT PRIMARY KEY, automation_id TEXT NOT NULL, -- note d'automatisation
|
||||
event_key TEXT NOT NULL, -- clé de déduplication de l'événement
|
||||
run_id TEXT, -- run phase 0 associé (si agent_prompt)
|
||||
outcome TEXT NOT NULL, -- fired|skipped_freq|skipped_cond|failed|dry_run
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_autorun_event ON automation_runs(automation_id, event_key);
|
||||
CREATE TABLE event_log ( -- journal échantillonné du bus
|
||||
id TEXT PRIMARY KEY, type TEXT NOT NULL, payload_summary TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
Les automatisations elles-mêmes sont des **notes** (`99_Assistant/Automatisations/<nom>.md`, frontmatter du parent §12.2) : pas de table de définition, un index en mémoire reconstruit par le watcher, comme les tâches. Les compteurs du circuit-breaker vivent en base (état, pas contenu).
|
||||
|
||||
Amendement du scheduler : chaque tâche planifiée gagne `cron_expr` (optionnel), `misfire` (`skip|run_once|run_all`, défaut `run_once` pour les tâches `agent_prompt` et `reminder`, `skip` sinon) et `idempotency_scope` (tâche + occurrence) ; l'ancien format reste lisible (migration douce au chargement, réécriture au premier enregistrement).
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket.*
|
||||
|
||||
### Agent programmé & briefing — #200
|
||||
|
||||
**P2-T01 — Action `agent_prompt` dans le scheduler.**
|
||||
Fichiers : `backend/scheduler.py`, `backend/tools/scheduled.py`.
|
||||
Travail : nouveau type d'action lançant `run_agent` avec un prompt, une whitelist de capacités (défaut du parent §12.2 : READ libre, WRITE `99_Assistant/**`, DANGEROUS et écritures externes interdits, `allowed_extra_tools` explicite par tâche), plafonds propres (itérations, tokens) ; la tâche déclare ses capacités dans l'UI des tâches planifiées (liste lisible, pas du JSON brut).
|
||||
Acceptation : une tâche `agent_prompt` tentant d'écrire hors de son périmètre échoue proprement, l'échec est journalisé et visible ; aucune tâche existante (#170) n'est affectée.
|
||||
|
||||
**P2-T02 — Unification aux runs et reprise.**
|
||||
Travail : chaque exécution planifiée crée un `agent_runs` (`origin='scheduled'`, `origin_ref` = tâche) ; un run planifié interrompu par redémarrage est repris selon les règles de la phase 0 (steps WRITE incertaines → approbation) ou marqué échoué avec motif, selon sa politique de rattrapage ; `last_run_at`/`last_error` persistés et broadcast `schedule_failure` existant conservé.
|
||||
Acceptation : tuer le conteneur pendant le briefing puis le relancer donne un run unique, repris ou échoué explicitement — jamais un briefing en double ni un silence.
|
||||
|
||||
**P2-T03 — Briefing-plan du matin.**
|
||||
Travail : la tâche intégrée de génération de plan (phase 1, P1-T14) est rebranchée : le plan déterministe reste produit par le moteur #204 ; #200 ajoute l'**amorce rédactionnelle** (skill `/brief` : synthèse du plan, faits `pending` du jour, points d'attention) écrite dans `Briefings/YYYY-MM-DD.md` et notifiée selon les canaux et le budget ; en cas d'agenda externe absent, le briefing le dit (données du vault uniquement).
|
||||
Acceptation : à l'heure configurée, `Briefings/` contient la note du jour citant ses sources ; `/brief` régénère à la demande sans dupliquer le plan.
|
||||
|
||||
### Consolidation nocturne — #194
|
||||
|
||||
**P2-T04 — Collecte incrémentale.**
|
||||
Fichiers : `backend/memory/consolidator.py`.
|
||||
Travail : sessions créées/modifiées depuis le dernier run + notes modifiées (index/watcher, plafonds 200 fichiers × 30 Ko) + tâches clôturées et revues de la période ; `redact_file_content()` systématique avant tout envoi LLM ; watermark persisté dans `consolidation_runs` (un run interrompu reprend sa fenêtre, pas plus).
|
||||
Acceptation : un second run sans nouveauté consomme zéro token LLM ; le watermark avance seulement sur succès de l'étape d'écriture.
|
||||
|
||||
**P2-T05 — Extraction et réconciliation.**
|
||||
Travail : prompt d'extraction du parent (annexe B amendée : `procedure`, entité principale, marqueur `contradiction`) ; validation Pydantic stricte de la sortie (rejet des faits non sourcés) ; étape déterministe de résolution d'entités (liaison ou proposition) ; réconciliation : supersede bi-temporel, contradictions avec `decision`/`procedure` → carte `contradiction` dans la file unifiée (jamais de supersede automatique), dossiers sensibles → `pending`.
|
||||
Acceptation : G5 passe (un secret présent dans les notes n'est jamais extrait ; un fait santé est en `pending`) ; une contradiction produit une carte avec les deux sources citées.
|
||||
|
||||
**P2-T06 — Écriture, cartes et budget.**
|
||||
Travail : écriture des faits + miroir avec protection de course (hash comparé avant **et après** l'appel LLM ; section éditée entre-temps = écriture abandonnée pour cette section, replanifiée) ; faits `pending` → file d'approbations ; budget dur `OBSIGATE_CONSOLIDATION_MAX_TOKENS` (arrêt propre, watermark partiel journalisé) ; compte rendu dans `Journal.md` ; réglage candidats connecteurs (§9.6 du parent) présent, défaut `off`, sans source cette phase.
|
||||
Acceptation : une nuit de test sur le vault fixture produit ≥ 1 fait utile et respecte le budget ; éditer `Profil.md` pendant le run ne perd ni l'édition ni le fait.
|
||||
|
||||
**P2-T07 — Déclenchement.**
|
||||
Travail : tâche intégrée `daily_time 23:30` (configurable, `OBSIGATE_TZ`) via #200 (whitelist `memory.*`) ; skill `/consolider` à la demande ; le run apparaît dans le centre de contrôle avec ses compteurs (faits ajoutés/validés/rejetés — métriques #212).
|
||||
Acceptation : la consolidation est visible, désactivable, et son coût de la nuit est affiché le lendemain dans Aujourd'hui (activité de l'assistant).
|
||||
|
||||
### Rappels — #199
|
||||
|
||||
**P2-T08 — Action `reminder` et outils.**
|
||||
Fichiers : `backend/tools/reminders.py`, scheduler.
|
||||
Travail : action typée `{user_id, text, deliver:[channels], rrule?}` ; outils `reminder_create` (dates relatives résolues côté serveur et **confirmées dans la carte** — « dans 3 semaines » affiche la date absolue avant création), `reminder_list`, `reminder_snooze` (compteur persisté), `reminder_cancel` ; les rappels sont des tâches de l'index phase 1 vues par le scheduler de livraison (§7.2).
|
||||
Acceptation : G1 passe (« rappelle-moi d'appeler le dentiste dans 3 semaines » → livraison à la bonne date, heure murale, fuseau Toronto).
|
||||
|
||||
**P2-T09 — Livraison, escalade, section Rappels.**
|
||||
Travail : à l'échéance : Web Push + `notify.broadcast` sur les canaux choisis + trace dans l'historique de `Rappels.md` (vue générée) ; acquittement (depuis la notification, Aujourd'hui ou en conversation) ; escalade optionnelle des rappels `critique` (canal externe si non acquitté après N minutes) ; les rappels dus entrent dans le compileur de contexte (section Aujourd'hui) ; snooze ≥ 3 sur le même rappel = donnée exposée aux insights (phase 4), pas de notification supplémentaire.
|
||||
Acceptation : un rappel non acquitté critique escalade une fois, puis s'arrête ; un rappel ordinaire n'émet qu'une livraison par occurrence (idempotence du scheduler amendé, P2-T13).
|
||||
|
||||
### Automatisations événementielles — #208
|
||||
|
||||
**P2-T10 — Bus d'événements.**
|
||||
Fichiers : `backend/automations/bus.py`.
|
||||
Travail : émission depuis le watcher (`vault.file.*`), l'index des tâches (`task.created|completed|overdue`), l'inbox, les runs (`run.finished`), l'horloge (`schedule.tick`), les webhooks entrants existants ; schéma `connector.object.*` déclaré (aucun émetteur cette phase) ; journal échantillonné `event_log` ; bus en mémoire, aucun broker externe (P1).
|
||||
Acceptation : chaque type d'événement déclaré a au moins un test d'émission ; un abonné lent ne bloque pas l'émetteur (file bornée, perte journalisée).
|
||||
|
||||
**P2-T11 — Moteur d'automatisations.**
|
||||
Fichiers : `backend/automations/engine.py`.
|
||||
Travail : chargement des notes `Automatisations/*.md` (frontmatter validé, erreurs de schéma = automatisation désactivée + carte explicative, jamais de crash) ; évaluation déclencheur → conditions déterministes → actions : primitives (`task_create`, `reminder_create`, `create_file`/`append_to_file` dans le périmètre, `notify`, classement inbox) ou `agent_prompt` (via #200, whitelist propre à l'automatisation) ; chaque exécution est journalisée (`automation_runs`) et, si LLM, un run phase 0.
|
||||
Acceptation : l'exemple du parent §12.2 (forme sans connecteur : « note modifiée dans Projets/ → tâche de relecture ») fonctionne de bout en bout ; une automatisation mal formée est signalée, pas silencieusement ignorée.
|
||||
|
||||
**P2-T12 — Gardes : fréquence, dédup, circuit-breaker, dry-run.**
|
||||
Travail : `frequence_max` obligatoire (refus d'activation sans elle) ; dédup par clé d'événement (index unique §4) ; profondeur d'enchaînement ≤ 2 (`automation → run → automation` au-delà = refus journalisé) ; 3 échecs consécutifs → suspension + insight dans `Insights.md` ; **dry-run** : rejouer les événements des 7 derniers jours et afficher ce qui *aurait* été fait, sans aucun effet.
|
||||
Acceptation : G8 passe (déclencheur répété 5 fois le même jour avec `frequence_max: 1/jour` → 1 exécution, 4 ignorées journalisées) ; le dry-run ne crée ni tâche ni notification (vérifié aux stores).
|
||||
|
||||
**P2-T13 — Scheduler amendé.**
|
||||
Travail : expressions cron complètes en plus des modes existants ; politique `misfire` (§4) ; idempotence par (tâche, occurrence) ; file des échecs avec rejeu manuel depuis les Configurations ; ancrage heure murale `zoneinfo` pour les horaires quotidiens.
|
||||
Acceptation : une occurrence ratée pendant un arrêt du conteneur suit sa politique au redémarrage (test par horloge simulée) ; aucune occurrence n'est livrée deux fois.
|
||||
|
||||
**P2-T14 — Rappels contextuels et recettes initiales.**
|
||||
Travail : déclencheurs d'ouverture (note/projet ouvert → rappels `ctx` correspondants proposés dans le panneau, pas de push) rattachés au bus ; publication des recettes de l'annexe D du parent livrables sans connecteur (briefing-plan, revues, veille de page par `fetch_url` + diff, anniversaires depuis les entités connues, entretien récurrent) comme modèles d'automatisations/skills pré-remplis, activables un par un.
|
||||
Acceptation : la recette « veille de page » détecte un changement et produit une note + un item d'activité, sans notification hors budget.
|
||||
|
||||
### Rituels & habitudes — #214
|
||||
|
||||
**P2-T15 — Rituels assemblés.**
|
||||
Travail : les rituels sont des recettes (pas un moteur nouveau) : matin = briefing-plan (P2-T03) ; soir = revue du jour (`plan_review`) avec **reports explicites** des tâches non faites (nouvelle date choisie ou retour au projet — l'assistant propose, Bruno tranche en un écran) ; semaine = revue de semaine (phase 1) + focus proposés (3 projets max) ; inbox zéro = triage en lot en fin de journée ; chaque rituel est activable/désactivable séparément, à heure configurable.
|
||||
Acceptation : une tâche non faite trois soirs de suite n'a jamais glissé silencieusement : chaque report est daté et motivé dans l'historique de la tâche.
|
||||
|
||||
**P2-T16 — Habitudes et séries.**
|
||||
Travail : une habitude = tâche récurrente RRULE liée à un objectif (phase 1) ; série calculée par l'index (occurrences faites / attendues, fenêtre glissante) ; affichage dans la revue de semaine et la note de l'objectif ; série cassée → au plus **un** insight hebdomadaire regroupé (P15), jamais de rappel quotidien de culpabilisation.
|
||||
Acceptation : la série d'une habitude « 3×/semaine » est calculée correctement sur 4 semaines de fixture, semaines incomplètes (en cours) exclues du jugement.
|
||||
|
||||
### Finitions
|
||||
|
||||
**P2-T17 — Budget d'interruption : mécanique.**
|
||||
Travail : compteur quotidien des notifications sortantes de l'assistant (briefing = 1 ; alertes jusqu'à `OBSIGATE_NOTIFY_DAILY_BUDGET`, défaut 3 ; rappels demandés par Bruno hors budget ; `critique` peut dépasser, 1/jour max) ; sévérités `critique|normale|info` (l'info attend la revue du soir) ; chaque notification porte son pourquoi et le lien de l'objet ; compteur visible au centre de contrôle. Les **détecteurs** qui consommeront ce budget sont en phase 4 (#201) ; la mécanique s'applique dès cette phase aux rappels et aux échecs de runs planifiés.
|
||||
Acceptation : un scénario générant 6 alertes normales en un jour en livre 3 et range les autres dans la revue du soir (vérifié au journal).
|
||||
|
||||
**P2-T18 — i18n, docs, guides.**
|
||||
Travail : clés FR/EN (tâches planifiées et leurs capacités, automatisations, rappels, rituels) ; fiches `docs/features/` pour #200, #194, #199, #208, #214 ; guide « Automatisations et rituels » + pas-à-pas de création d'une automatisation ; CHANGELOG + ROADMAP.
|
||||
Acceptation : DoD du §12 vérifiée point par point.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET/POST/PATCH /api/scheduler/tasks` (étendu) | Tâches planifiées : `agent_prompt`, cron, `misfire`, capacités affichées | P2-T01/T13 |
|
||||
| `POST /api/scheduler/tasks/{id}/run` (existant) | Exécution manuelle — produit un run unifié | P2-T02 |
|
||||
| `GET /api/automations` · `POST /api/automations/{id}/dry-run|enable|disable` | Automatisations : état, journal, dry-run | P2-T11/T12 |
|
||||
| `GET/POST /api/reminders` · `POST /api/reminders/{id}/snooze|cancel|ack` | Rappels (miroir des outils) | P2-T08/T09 |
|
||||
| `GET /api/memory/consolidation` | Derniers runs de consolidation et leurs compteurs | P2-T06 |
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Une tâche planifiée se relit comme un contrat
|
||||
|
||||
L'UI des tâches planifiées affiche pour chaque tâche `agent_prompt` : le prompt, la whitelist effective, les plafonds, le dernier run (lien) et son résultat. Ce qui n'est pas affiché n'est pas autorisé : la whitelist par défaut ne s'étend que par `allowed_extra_tools` explicite, tracé dans l'audit.
|
||||
|
||||
### 7.2 Rappel = tâche + livraison
|
||||
|
||||
Il n'y a pas de second store de rappels : un rappel est une tâche de l'index portant `ctx: rappel` ou créée par `reminder_create`, à laquelle le scheduler rattache une **livraison** (canaux, acquittement, snooze, escalade). `Rappels.md` est une vue générée de l'index. Compléter la tâche acquitte le rappel ; snoozer décale la tâche (historique conservé).
|
||||
|
||||
### 7.3 Le dry-run est un droit, pas une option cachée
|
||||
|
||||
Toute automatisation peut être dry-runée avant activation **et** à tout moment ensuite, sur une fenêtre choisie. Le résultat du dry-run fait partie de son journal : une automatisation jamais dry-runée porte un badge « jamais testée » dans les Configurations.
|
||||
|
||||
### 7.4 Consolidation et vie privée
|
||||
|
||||
La consolidation ne lit que le vault et les conversations de l'utilisateur propriétaire (jamais inter-utilisateurs) ; les dossiers sensibles configurables forcent `pending` quelle que soit la confiance extraite ; les compteurs de validation (faits acceptés/rejetés) alimentent la métrique de dégradation du consolidateur (#212 : un taux de rejet en hausse durable est un signal, affiché au centre de contrôle).
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Unitaires : whitelist `agent_prompt` (tentatives d'évasion), watermark de consolidation, gardes d'automatisation (fréquence, dédup, profondeur), cron + `misfire` par horloge simulée, séries d'habitudes.
|
||||
- Intégration : nuit de consolidation complète sur fixture ; briefing après redémarrage ; rappel critique avec escalade ; automatisation de veille avec changement de page simulé.
|
||||
- Golden tasks de la phase : **G1** (rappel à 3 semaines), **G5** (secret jamais extrait, fait santé `pending`), **G8** (`frequence_max`) — définitions au parent §16.1.
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Le briefing du matin paraît à l'heure, cite ses sources, et un redémarrage du conteneur ne produit ni doublon ni silence.
|
||||
2. Une nuit de consolidation produit des faits et des cartes traçables, respecte son budget dur, et ne perd aucune édition humaine concurrente.
|
||||
3. « Dans 3 semaines » devient un rappel livré à la bonne date ; le snooze répété est compté et visible.
|
||||
4. Une automatisation se dry-rune sans effet, s'active, respecte sa `frequence_max`, et se suspend proprement après 3 échecs.
|
||||
5. Aucune notification de la phase ne sort sans pourquoi ni lien, et le budget quotidien est tenu et affiché.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| `agent_prompt` devient un cheval de Troie de périmètre | Whitelist par tâche affichée comme contrat (§7.1) ; toute extension est un événement d'audit ; DANGEROUS structurellement absent des whitelists |
|
||||
| La consolidation dégrade la mémoire en silence | Compteurs de validation suivis (#212) ; bouton « relire le Journal » dans Aujourd'hui ; possibilité de suspendre la consolidation sans perdre les faits |
|
||||
| Les automatisations notifient trop (mort par mille alertes) | Budget d'interruption dès cette phase (P2-T17), sévérité `info` rangée en revue, `frequence_max` obligatoire |
|
||||
| Dérive horaire (DST) sur rappels et rituels | Ancrage heure murale partout ; tests d'horloge sur les deux changements d'heure de `America/Toronto` |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P2-T01 → T02 → T03 (#200 — le briefing rebranche la tâche intégrée de la phase 1)
|
||||
P2-T04 → T05 → T06 → T07 (#194 — après T01 : la consolidation est une tâche agent_prompt)
|
||||
P2-T13 (scheduler amendé, tôt : il sert T08 et T11)
|
||||
P2-T08 → T09 (#199)
|
||||
P2-T10 → T11 → T12 → T14 (#208 — T14 après T09 pour les rappels contextuels)
|
||||
P2-T15 → T16 (#214 — après T03, T11 et le plan de la phase 1)
|
||||
P2-T17 (budget — avant T09/T14 en pratique) → P2-T18 (clôture)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD du document de phase 0 §12, inchangée, plus : les golden tasks G1, G5 et G8 vertes en CI ; chaque tâche planifiée et chaque automatisation livrée affiche son contrat (capacités, plafonds, budget) dans l'UI.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 3 : Le monde (connecteurs, synchronisation, client MCP)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **3 sur 5** — l'assistant se branche sur les services de Bruno |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §11 (connecteurs, sync, replis, envoi encadré, client MCP), §9.6 (candidats mémoire), §15 (politiques) |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#195** (cadre connecteurs + moteur de sync), **#196** (Google Calendar + Gmail + replis zéro-OAuth), **#197** (Microsoft Teams/Outlook), **#198** (Discord lecture + Notion + Drive), **#207** (ObsiGate client MCP) |
|
||||
| Effort estimé | 17–23 jours |
|
||||
| Prérequis | Phase 0 (politiques #213 — provenance et règles d'envoi ; runs #206), phase 1 (plan et tâches qui consomment l'agenda), phase 2 (bus d'événements #208 qui consomme la sync) |
|
||||
| Dépendances | #195 ← — ; #196, #197 ← #195, #213 ; #198 ← #195 ; #207 ← #195, #213 |
|
||||
| Débloque | Phase 4 (engagements détectés dans les courriels en mode candidats, préparation de réunions sur agenda réel), phase 5 (packs Communication/Voyages complets) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** Le plan du jour voit le **vrai agenda** et les vrais courriels. Les connecteurs Google, Microsoft, Discord, Notion et Drive sont installés via le cadre unifié « Sources connectées » : OAuth2 + PKCE quand il le faut, mais aussi des **replis sans aucun projet cloud** (iCal, CalDAV, IMAP) qui débloquent l'agenda et le courriel dès l'installation. Un moteur de **synchronisation incrémentale** maintient un état local frais que consomment le plan, les rappels et les automatisations (événements `connector.object.*` enfin émis). L'assistant cherche et lit le courriel, crée des brouillons, et **envoie** — uniquement sur approbation renforcée du texte final exact, jamais depuis une tâche planifiée. Enfin, ObsiGate devient **client MCP** : les serveurs MCP externes de Bruno entrent dans le même registre, sous les mêmes politiques.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée](#3-état-dentrée)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **la prise de notes reste le vault ; les connecteurs nourrissent et exécutent, ils ne remplacent rien** (hérité de la V2, conservé). La phase ajoute deux corollaires propres à la V2.1 :
|
||||
|
||||
- **Un état frais ou un aveu d'état périmé.** Le plan, les déclencheurs et les réponses s'appuient sur le cache synchronisé ; si la sync a échoué, l'assistant le dit (« agenda non synchronisé depuis 2 h ») au lieu de planifier sur du vide.
|
||||
- **L'écriture externe est un privilège d'exécution, pas une capacité de fond.** Créer un événement, un brouillon ou envoyer suit les niveaux d'autonomie et la politique de provenance (phase 0) : rien d'externe ne part d'un contenu lu sans validation humaine du résultat final.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #195 : cadre `backend/connectors/` (interface, registre, installations, secrets 0600/env, OAuth2 code + PKCE, refresh transparent, retry/backoff, rate-limit par connecteur, audit de chaque `call()`) ; migration des sources Gitea/GitHub existantes sous le cadre (même UX) ; UI « Sources connectées » étendue (statut, scopes, test, journal d'appels, budget) ; **moteur de sync incrémentale** (collections déclarées, curseurs, webhooks fournisseurs quand disponibles sinon polling adaptatif, cache d'objets normalisés, émission des événements bus, page santé des syncs).
|
||||
- #196 : Google Calendar (lire/écrire) et Gmail (chercher/lire/brouillon/**envoyer** selon §7.3) sur le même consentement ; **replis zéro-OAuth** : iCal (lecture), CalDAV (lecture/écriture), IMAP (lecture courriel) ; guide d'installation Google pas-à-pas FR/EN.
|
||||
- #197 : Microsoft Graph — Teams (lire les salons choisis, envoyer en WRITE confirmé) et Outlook (courriel et calendriers, envoi selon §7.3) sur le même jeton Entra ID.
|
||||
- #198 : Discord en lecture par bot REST (pas de gateway en v1) en plus du webhook de sortie existant ; Notion (rechercher/lire/appendre, sync vers l'index de recherche) ; Drive (rechercher/lire, import vers le vault).
|
||||
- #207 : client MCP (serveurs déclarés par Bruno ; outils préfixés `mcp.<serveur>.*` dans le registre ; niveau de confiance par serveur ; empreinte de schéma et suspension en cas de changement).
|
||||
|
||||
### 2.2 Exclu (phases ultérieures)
|
||||
|
||||
- La **détection d'engagements dans les courriels** en production (#211, phase 4) : la phase livre le réglage candidats mémoire (§9.6 du parent, défaut `off`) et le pipeline ; l'activation métier est en phase 4.
|
||||
- Les packs qui consomment ces connecteurs au-delà du socle (Voyages, Communication avancée — phase 5).
|
||||
- Slack (option future, inchangé depuis la V2), l'envoi SMTP direct (l'envoi passe par les APIs Google/Microsoft), toute écriture Discord autre que le webhook existant.
|
||||
- La visioconférence et l'enregistrement de réunions : phase 4 (#209) pour la préparation et les comptes rendus, sur l'agenda livré ici.
|
||||
|
||||
## 3. État d'entrée
|
||||
|
||||
| Élément | Usage en phase 3 |
|
||||
|---|---|
|
||||
| Sources connectées Gitea/GitHub (#92/#103), `connected.py`, `secrets.py` | Précédent à généraliser ; migration sans perte d'UX |
|
||||
| Moteur de politiques + provenance (#213, phase 0) | Toute donnée connecteur entre avec `connector:<id>` ; les règles d'envoi et de sensibilité s'appliquent telles quelles |
|
||||
| Runs et approbation renforcée (phase 0/1) | Envois et écritures externes sont des steps de runs, approuvés dans la file unifiée |
|
||||
| Plan, tâches, automatisations (phases 1–2) | Consommateurs de l'agenda synchronisé et des événements `connector.object.*` |
|
||||
| Scheduler amendé (phase 2) | Les jobs de sync sont des tâches planifiées système (visibles, journalisées) |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
```sql
|
||||
-- data/connectors_cache.sqlite3 (reconstructible par re-sync complète)
|
||||
CREATE TABLE sync_state (
|
||||
user_id TEXT NOT NULL, connector_id TEXT NOT NULL, collection TEXT NOT NULL,
|
||||
cursor TEXT, -- token/page/date du fournisseur
|
||||
last_sync_at TEXT, last_error TEXT, status TEXT NOT NULL DEFAULT 'ok',
|
||||
PRIMARY KEY (user_id, connector_id, collection)
|
||||
);
|
||||
CREATE TABLE synced_objects (
|
||||
user_id TEXT NOT NULL, connector_id TEXT NOT NULL,
|
||||
external_id TEXT NOT NULL, type TEXT NOT NULL, -- event|message|chat|page|file
|
||||
title TEXT, starts_at TEXT, ends_at TEXT,
|
||||
participants_json TEXT, excerpt TEXT, -- extrait borné, JAMAIS le corps complet
|
||||
link TEXT, updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, connector_id, external_id)
|
||||
);
|
||||
```
|
||||
|
||||
Installations et secrets : fichiers existants du patron V2 (`data/connectors.json` pour le public, `data/connector_secrets.json` 0600 ou variables `OBSIGATE_CONNECTOR_SECRET_<ID>` ; jetons OAuth chiffrés avec `data/secret.key`). Les collections synchronisables et leurs fenêtres (agenda ±30 j ; courriel : dossiers choisis, 14 j glissants ; Teams : salons choisis) sont déclarées par chaque connecteur, réglables dans l'UI.
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket. Les fournisseurs sont développés et testés contre des **simulateurs** (le harnais golden de la phase 0), les appels réels étant vérifiés en recette manuelle documentée par connecteur.*
|
||||
|
||||
### Cadre & sync — #195
|
||||
|
||||
**P3-T01 — Interface connecteur et registre.**
|
||||
Fichiers : `backend/connectors/base.py`, `backend/connectors/registry.py`, `backend/connectors/oauth.py`.
|
||||
Travail : `ConnectorInterface` (id, label, URL de doc, scopes, capabilities nommées, collections synchronisables) ; installation/désinstallation ; flux OAuth2 code + PKCE générique (endpoints d'allowlist codée par fournisseur) avec redirect `/api/connectors/{id}/callback`, échange + stockage du refresh token chiffré, refresh anticipé et 1 retry sur 401 dans `call()` ; auth par token statique pour les connecteurs qui s'y prêtent ; timeouts httpx 15 s, retry backoff, rate-limit par connecteur, cache GET court (TTL 5 min) pour le contexte de chat.
|
||||
Acceptation : un connecteur de test (fixture OAuth complète) s'installe, se teste, se rafraîchit et se désinstalle de bout en bout ; aucun secret n'apparaît dans les logs ni les retours d'outils (redaction vérifiée).
|
||||
|
||||
**P3-T02 — UI Sources connectées et migration Gitea/GitHub.**
|
||||
Fichiers : `backend/routers/connectors.py`, page Configurations.
|
||||
Travail : cartes par connecteur (statut, scopes accordés, boutons Connecter/Tester/Déconnecter, journal des derniers appels depuis l'audit, budget) ; migration interne de `connected.py` : Gitea/GitHub deviennent deux connecteurs du cadre, configurations existantes relues sans ressaisie ; les outils historiques gardent leurs noms.
|
||||
Acceptation : une installation avec Gitea/GitHub configurés « à l'ancienne » fonctionne après migration sans intervention ; la page montre les appels récents des deux sources.
|
||||
|
||||
**P3-T03 — Moteur de synchronisation incrémentale.**
|
||||
Fichiers : `backend/connectors/sync.py`, jobs scheduler système.
|
||||
Travail : pour chaque installation et collection : sync initiale bornée puis incrémentale par curseur ; réception webhook fournisseur quand le connecteur le déclare (Google/Microsoft), sinon polling adaptatif (défaut 15 min, ralenti la nuit, accéléré à l'approche d'un événement) ; normalisation des objets (§4) ; émission `connector.object.created|updated` sur le bus de la phase 2 ; erreurs → `last_error` + statut, jamais d'exception silencieuse ; re-sync complète manuelle depuis l'UI.
|
||||
Acceptation : un événement créé côté fournisseur (simulateur) apparaît dans le cache et émet l'événement bus en moins d'un cycle de polling ; la suppression côté fournisseur retire l'objet du cache (pas de fantôme dans le plan).
|
||||
|
||||
**P3-T04 — Santé des connecteurs.**
|
||||
Travail : agrégation par connecteur (dernière sync, retard, taux d'erreur, quota estimé) exposée dans la carte et au centre de contrôle ; signal de dégradation consommé par le plan et le briefing (le consommateur affiche « données périmées » avec l'âge) ; un connecteur déconnecté retire ses outils du registre présenté au modèle (`list_tools` filtré par capabilities actives, comme le prévoit la V2 pour Tauri).
|
||||
Acceptation : couper la sync (simulateur en échec) fait apparaître l'âge des données dans le plan du jour suivant, sans bloquer le plan.
|
||||
|
||||
### Google — #196
|
||||
|
||||
**P3-T05 — Calendar : lecture et écriture.**
|
||||
Travail : capabilities `gcal.list_events` (READ), `gcal.get_event` (READ), `gcal.create_event` (WRITE), `gcal.update_event` (WRITE), toutes avec post-conditions `verify` (relecture de l'événement) et inverses (suppression de l'événement créé) ; le plan du jour (phase 1) bascule sa source d'agenda sur le cache synchronisé quand le connecteur est actif ; les blocs validés du plan peuvent être écrits dans l'agenda (option « plan → agenda », désactivée par défaut).
|
||||
Acceptation : « bloque 2 h jeudi pour la présentation » crée l'événement vérifié, visible au plan ; le désactiver (déconnexion) rend le plan à sa source vault sans erreur.
|
||||
|
||||
**P3-T06 — Gmail : chercher, lire, brouillonner.**
|
||||
Travail : `gmail.search_messages` (syntaxe du fournisseur), `gmail.read_message`, `gmail.create_draft` (WRITE, inverse = suppression du brouillon) ; citation systématique dans les réponses (expéditeur, date, lien) ; le contenu lu porte la provenance `connector:gmail` et ne peut pas, seul, justifier une action externe (politique phase 0 — test dédié).
|
||||
Acceptation : « ai-je une réponse du dentiste ? » répond depuis le cache + lecture à la demande, avec citation ; aucun fait mémoire n'est créé depuis un courriel sans passer par le mode candidats (§9.6, défaut `off`).
|
||||
|
||||
**P3-T07 — Envoi encadré.**
|
||||
Travail : `gmail.send` (DANGEROUS) selon le parent §11.4 : brouillon d'abord (T06), carte d'approbation **renforcée** sur le texte final exact (destinataires, objet, corps ; toute modification régénère la carte), interdiction structurelle depuis tâches planifiées et automatisations (la politique refuse avant même la carte), journalisation en step de run avec inverse = néant affiché.
|
||||
Acceptation : les trois refus sont prouvés par tests (envoi en tâche planifiée ; envoi dont le corps est une copie d'un courriel reçu sans reformulation validée ; envoi après modification du brouillon sans nouvelle carte) ; un envoi approuvé part une fois (idempotence).
|
||||
|
||||
**P3-T08 — Replis zéro-OAuth : iCal, CalDAV, IMAP.**
|
||||
Fichiers : `backend/connectors/ical.py`, `caldav.py`, `imap.py`.
|
||||
Travail : iCal par URL secrète (lecture, sync par le moteur T03) ; CalDAV (lecture + création/màj d'événements, mot de passe d'application comme secret) ; IMAP (lecture : recherche et lecture, collections = dossiers choisis) ; ces connecteurs apparaissent dans la même matrice et la même UI, avec leurs limites affichées (« lecture seule » pour iCal/IMAP).
|
||||
Acceptation : un agenda iCloud/Nextcloud de test est lisible dans le plan sans aucun projet Google/Microsoft ; un compte IMAP de test répond à `mail.search` en lecture seule (toute tentative d'écriture est absente du registre, pas seulement refusée).
|
||||
|
||||
### Microsoft — #197
|
||||
|
||||
**P3-T09 — Graph : socle et Teams.**
|
||||
Travail : client Graph minimal (auth Entra ID via le cadre OAuth, pagination, erreurs typées) ; `teams.list_chats` (READ, salons choisis), `teams.read_messages` (READ), `teams.send_message` (WRITE avec confirmation affichant le salon et le texte exact — même régime que l'envoi courriel, adapté : pas de brouillon côté Teams, la carte tient lieu de relecture).
|
||||
Acceptation : lire un salon et y répondre via carte fonctionne sur tenant de test ; un salon non choisi n'est ni synchronisé ni lisible par l'outil.
|
||||
|
||||
**P3-T10 — Outlook : courriel et calendriers.**
|
||||
Travail : `outlook.search_messages`, `outlook.read_message`, `outlook.create_draft`, `outlook.send` (même ticket de politique que P3-T07 — factoriser, ne pas dupliquer la logique d'approbation), `outlook.list_events` alimentant le cache d'agenda au même titre que gcal (le plan fusionne les calendriers actifs en signalant la source de chaque événement).
|
||||
Acceptation : un plan peut mélanger événements Google et Outlook sans doublon quand le même événement existe des deux côtés (dédup par titre+horaires signalée en carte, jamais de fusion silencieuse).
|
||||
|
||||
### Discord, Notion, Drive — #198
|
||||
|
||||
**P3-T11 — Discord en lecture.**
|
||||
Travail : bot token (secret) ; `discord.list_messages` et `discord.search` en REST sur les salons choisis ; le webhook de sortie existant (#168) devient la capability `discord.send` du connecteur (WRITE via notify, pas de doublon de code) ; pas de gateway websocket (limite affichée : pas de temps réel, sync par polling).
|
||||
Acceptation : la recherche dans un salon de test retrouve un message cité ; les salons non choisis sont inaccessibles aux outils.
|
||||
|
||||
**P3-T12 — Notion : lire, appendre, synchroniser la recherche.**
|
||||
Travail : API key (token interne) ; `notion.search`, `notion.read_page`, `notion.append_blocks` (WRITE confirmé) ; les pages choisies sont synchronisées vers l'**index de recherche** d'ObsiGate (elles deviennent trouvables par la recherche et le recall, marquées `connector:notion` — jamais copiées dans le vault sans demande explicite d'import).
|
||||
Acceptation : « cherche dans Notion et dans mes notes » répond des deux sources distinguées ; un append est vérifié par relecture.
|
||||
|
||||
**P3-T13 — Drive : chercher, lire, importer.**
|
||||
Travail : `drive.search`, `drive.read_file` (Docs/Sheets convertis en texte/markdown via l'API) ; import explicite vers le vault (`drive.read_file` → proposition de note, propose/apply existant) rattaché au backlog d'import ; lecture seule stricte (aucune écriture Drive cette phase).
|
||||
Acceptation : importer un document Drive crée une note du vault citant sa source ; l'original Drive n'est jamais modifié.
|
||||
|
||||
### Client MCP — #207
|
||||
|
||||
**P3-T14 — Branchement de serveurs MCP externes.**
|
||||
Fichiers : `backend/connectors/mcp_client.py`.
|
||||
Travail : déclaration de serveurs (URL/transport, authentification par secret) ; découverte des outils à la connexion ; enregistrement dynamique préfixé `mcp.<serveur>.*` avec risque déclaré par le serveur **plafonné** par la politique (un outil déclaré READ par le serveur mais à effets inconnus démarre au niveau d'autonomie 0–1) ; les contenus MCP portent la provenance `mcp:<serveur>` ; aucun contenu `sensitivity ∈ {private, sensitive}` n'est transmissible à un outil MCP externe (politique phase 0, test dédié).
|
||||
Acceptation : un serveur MCP de test expose un outil utilisable en conversation au niveau 1 ; tenter de lui passer un fait `sensitive` est refusé et journalisé.
|
||||
|
||||
**P3-T15 — Empreintes de schéma et suspension.**
|
||||
Travail : empreinte (hash canonique du schéma de chaque outil) stockée à la validation du serveur ; à chaque session, comparaison : un schéma modifié suspend l'outil (retiré du registre présenté au modèle) et crée une carte de revalidation montrant le diff de schéma ; la revalidation est manuelle, par serveur.
|
||||
Acceptation : modifier le schéma d'un outil du serveur de test suspend exactement cet outil, pas les autres, et l'assistant explique la suspension au lieu d'échouer en silence.
|
||||
|
||||
### Finitions
|
||||
|
||||
**P3-T16 — Guides d'installation des fournisseurs.**
|
||||
Travail : `docs/GUIDES/CONNECTEURS_GOOGLE_MS.md` pas-à-pas FR/EN (création des applications Google Cloud et Entra ID, écrans de consentement, URI de redirection) + section replis zéro-OAuth (où trouver l'URL iCal, créer un mot de passe d'application) ; fiches `docs/features/` pour #195–#198 et #207 ; clés i18n des Sources connectées et des cartes d'envoi.
|
||||
Acceptation : un lecteur suivant le guide seul aboutit à un connecteur Google testé vert ; DoD du §12 pour le reste.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET /api/connectors` · `POST /api/connectors/{id}/install|test|disconnect` | Installations et statut | P3-T01/T02 |
|
||||
| `GET /api/connectors/{id}/authorize` · `GET /api/connectors/{id}/callback` | Flux OAuth2 + PKCE | P3-T01 |
|
||||
| `GET /api/connectors/{id}/health` · `POST /api/connectors/{id}/sync` | Santé et re-sync manuelle | P3-T03/T04 |
|
||||
| `GET/POST /api/connectors/mcp-servers` · `POST /api/connectors/mcp-servers/{id}/revalidate` | Serveurs MCP externes et revalidation | P3-T14/T15 |
|
||||
|
||||
Les capabilities connecteurs sont des outils `@tool` ordinaires (donc exposés au serveur MCP d'ObsiGate sous les mêmes politiques) ; il n'y a pas d'API REST par capability.
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Ce que la sync ne fait jamais
|
||||
|
||||
Le corps complet d'un courriel n'est **jamais** synchronisé (extrait borné seulement) ; rien de synchronisé n'entre dans le vault ni dans la mémoire sans le pipeline candidats (§9.6, défaut `off`) ; désinstaller un connecteur purge son cache et ses curseurs (les secrets sont retirés, l'audit conserve la trace des appels passés sans contenu).
|
||||
|
||||
### 7.2 Fuseaux et calendriers multiples
|
||||
|
||||
Les événements synchronisés conservent leur fuseau d'origine ; le plan les projette dans `OBSIGATE_TZ` en affichant l'heure d'origine quand elle diffère (voyage). Un événement « toute la journée » reste ancré à sa date, jamais converti en minuit UTC décalé.
|
||||
|
||||
### 7.3 L'envoi, en une phrase
|
||||
|
||||
**Brouillon toujours, envoi jamais sans le texte final sous les yeux, et jamais en automatique** : ces trois règles sont codées dans la politique (phase 0) et testées en P3-T07/T10 ; le réglage d'activation de l'envoi (décision ouverte n° 2 du parent) peut le désactiver par connecteur sans toucher au code.
|
||||
|
||||
### 7.4 Tauri et hors-ligne
|
||||
|
||||
Sans token installé, les capabilities sont absentes du registre présenté au modèle (pas d'outils fantômes) ; hors-ligne, l'assistant répond depuis le cache synchronisé en annonçant son âge ; aucune tentative réseau n'a lieu au démarrage (P8 du parent).
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Simulateurs : chaque fournisseur a un double HTTP (réponses figées + scénarios d'erreur : 401, 429, pagination, webhook) utilisé en CI ; aucun test CI ne dépend d'un vrai compte.
|
||||
- Recette manuelle : par connecteur, un pas-à-pas de vérification sur compte réel, archivé dans la fiche de tranche (captures expurgées).
|
||||
- Golden tasks nouvelles de la phase : **G9** — « événement créé côté fournisseur → présent au plan, et plan honnête si la sync tombe » ; **G10** — « envoi refusé en tâche planifiée, accepté en interactif sur texte exact » ; **G11** — « schéma d'outil MCP modifié → outil suspendu, autres intacts ». (Numérotation à la suite de G1–G8 du parent.)
|
||||
- Non-régression : les sources Gitea/GitHub et les canaux de notification existants fonctionnent à l'identique après migration au cadre.
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Google **ou** un repli zéro-OAuth suffit à alimenter le plan en agenda réel ; la fraîcheur des données est affichée, jamais présumée.
|
||||
2. Une recherche de courriel répond avec citation ; un brouillon se crée ; un envoi n'a lieu que sur approbation renforcée du texte final, et jamais en planifié.
|
||||
3. Désinstaller un connecteur retire ses outils, purge son cache et ses secrets, et dégrade proprement les consommateurs (plan, briefing).
|
||||
4. Un serveur MCP externe fonctionne sous politique ; un changement de schéma suspend l'outil concerné avec une carte de revalidation.
|
||||
5. G9, G10 et G11 sont verts en CI sur simulateurs.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| Création des applications OAuth = friction d'installation | Les replis zéro-OAuth (P3-T08) débloquent l'usage avant tout projet cloud ; le guide T16 est testé par un lecteur naïf |
|
||||
| Quotas et changements d'API des fournisseurs | Tout passe par `call()` (rate-limit, backoff, audit) ; les échecs de sync dégradent, ne cassent pas (§7, P3-T04) |
|
||||
| Double agenda = double vérité | Le cache distingue les sources ; la dédup d'affichage est signalée, jamais silencieuse (P3-T10) |
|
||||
| Un connecteur compromis devient un canal d'injection | La provenance `connector:<id>` et les règles de la phase 0 s'appliquent à 100 % du contenu sync et lu ; les extraits synchronisés sont traités comme des données, jamais des instructions |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P3-T01 → T02 → T03 → T04 (#195 — la sync avant les connecteurs qui s'en servent)
|
||||
P3-T05 → T06 → T07 ; T08 (#196 — T08 indépendant, parallélisable après T01)
|
||||
P3-T09 → T10 (#197 — T10 réutilise la politique d'envoi de T07)
|
||||
P3-T11 ; T12 ; T13 (#198 — parallélisables entre eux)
|
||||
P3-T14 → T15 (#207 — après T01 et la politique phase 0)
|
||||
P3-T16 (clôture)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD du document de phase 0 §12, inchangée, plus : recette manuelle sur compte réel archivée par connecteur ; toute capability d'écriture déclare sa post-condition et son inverse (exigence du parent §21) ; les golden tasks G9–G11 vertes en CI.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 4 : Les gens (personnes & engagements, voix & réunions, proactivité complète)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **4 sur 5** — l'assistant connaît les gens, tient les promesses, et ne parle qu'à bon escient |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §13.2 (personnes & engagements), §17 (voix & réunions), §9.6 (candidats connecteurs), §14.4 (budget d'interruption) |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#211** (personnes & engagements), **#209** (voix, transcription & réunions), **#201** (proactivité / insights, forme complète) |
|
||||
| Effort estimé | 9–13 jours |
|
||||
| Prérequis | Phase 0 (entités #192, miroir `Personnes.md` en coquille #193), phase 1 (tâches #202, file d'approbations #203), phase 2 (consolidation #194, budget d'interruption, automatisations #208) ; phase 3 recommandée (l'agenda réel et les courriels candidats donnent leur pleine valeur à #211 et à la préparation de réunions) mais **non bloquante** : la phase fonctionne sur le vault seul, dégradée proprement |
|
||||
| Dépendances | #211 ← #192, #202 ; #209 ← #205, #211 ; #201 ← #194, #199, #202, #211 |
|
||||
| Débloque | Phase 5 (les packs Communication, Maison & admin et Voyages consomment personnes, engagements et cycle de réunion) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** L'assistant sait **qui sont les gens** : entités personnes peuplées, notes dédiées quand Bruno le veut, anniversaires, dernières interactions sourcées. Il tient les **engagements dans les deux sens** — ce que Bruno a promis et ce qu'on lui doit — détectés en conversation ou dans les courriels (en candidats validables, jamais en silence), suivis en revue, avec des **relances rédigées en brouillon**. La **voix** devient une entrée de premier rang : dictée, notes vocales transcrites et triées, et cycle de réunion complet (préparation avec historique des participants → compte rendu structuré → actions datées avec responsables). Enfin, la **proactivité #201** arrive en forme complète : détecteurs branchés sur tâches, engagements, plans et habitudes, publiés dans `Insights.md` et consommés sous le budget d'interruption — utiles ou silencieux.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée](#3-état-dentrée)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **les gens ne sont pas des données à aspirer, ce sont des relations à servir.** Chaque information sur une personne a une source que Bruno peut voir (il l'a dite, écrite, ou validée depuis un courriel candidat) ; rien n'est importé en masse, rien n'est inféré sur une personne à partir de signaux indirects, et les coordonnées restent `sensitivity='private'` — jamais transmises à un outil externe ou à un envoi sans que Bruno les ait lui-même fournies dans ce contexte.
|
||||
|
||||
Corollaires :
|
||||
|
||||
- **Un engagement n'existe que validé.** Détecter n'est pas enregistrer : toute détection (chat, courriel, compte rendu) produit une carte ; la création silencieuse est structurellement impossible (le pipeline n'a pas de chemin d'écriture sans approval de type `fait`/`triage`).
|
||||
- **La proactivité se juge à son taux d'acceptation.** Un insight refusé ou ignoré de façon répétée doit faire taire son détecteur (seuil relevé, puis suspension proposée) — la métrique décide, pas l'entêtement du système.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #211 : peuplement des entités personnes (miroir `Personnes.md` complet, notes dédiées optionnelles par personne), outils `people.*` ; table `commitments` (parent §13.2) et outils/API associés ; détection d'engagements en conversation (carte) et via les candidats connecteurs (§9.6 du parent — activation du réglage par connecteur, défaut `off`) ; suivi (revue du soir, échéance, relance rédigée en brouillon, clôture en un clic) ; anniversaires depuis les attributs des entités (recette de la phase 2 activée sur données réelles).
|
||||
- #209 : dictée dans les surfaces existantes ; enregistrement audio déposé (PWA, chat, pièce jointe) → transcription par le fournisseur **choisi explicitement** dans les Configurations (décision ouverte n° 3 du parent : défaut proposé = API du provider LLM déjà configuré ; aucun envoi audio par défaut sans ce choix) ; notes vocales comme items d'inbox `kind='voice'` (transcrites, résumées en une ligne, triées) ; cycle de réunion : note de préparation (participants = entités, historique sourcé, engagements ouverts avec eux, documents liés), capture optionnelle dans la note (dictée ou audio déposé), compte rendu structuré selon la procédure par défaut (résumé, décisions, actions) avec extraction des actions en **tâches datées à responsables nommés dans le texte uniquement**, décisions proposées à la mémoire en cartes, rappels de consentement à la capture.
|
||||
- #201 : détecteurs complets (projet sans prochaine action ; tâche reportée ≥ 3 fois ; engagement échu ; note « à faire » vieillissante > 21 j ; snooze ≥ 3 sur un rappel ; série d'habitude cassée ; inbox vieillissante) tournant en tâche planifiée légère et à la demande ; publication dans `Insights.md` (sections gérées, suppression = rejet du détecteur pour cet objet) ; suggestions injectées au compileur de contexte (bornées, sourcées) ; consommation stricte du budget d'interruption (phase 2) ; retour d'acceptation mesuré par détecteur avec auto-ajustement (§7.3).
|
||||
|
||||
### 2.2 Exclu (phases ultérieures / jamais)
|
||||
|
||||
- Tout **enrichissement externe** de profils (réseaux sociaux, bases tierces) : hors périmètre durable, pas seulement ajourné — contraire au principe du §1.
|
||||
- L'import en masse d'un carnet d'adresses : un connecteur contacts en lecture pourra être un pack de la phase 5, avec validation personne par personne ; rien ici ne l'anticipe en base.
|
||||
- L'enregistrement automatique de réunions (bot qui rejoint les appels) : la capture est toujours déclenchée par Bruno dans la note.
|
||||
- Le diagnostic ou le conseil santé à partir d'habitudes ou de personnes : hors périmètre (rappelé dans les procédures des packs concernés).
|
||||
|
||||
## 3. État d'entrée
|
||||
|
||||
| Élément | Usage en phase 4 |
|
||||
|---|---|
|
||||
| Entités/relations + `Personnes.md` coquille (phase 0) | Le modèle est là ; la phase peuple, outille et met en usage |
|
||||
| Consolidation (phase 2) | Émet déjà faits `open_loop` et candidats entités ; la phase branche la promotion en engagements et les cartes associées |
|
||||
| Candidats connecteurs (mécanisme phases 2–3, défaut `off`) | Première source réelle : les engagements et rendez-vous lus dans les courriels choisis |
|
||||
| Inbox et triage (phase 1) | Les notes vocales sont des items comme les autres ; les comptes rendus déposés se trient vers tâches/engagements |
|
||||
| Agenda réel (phase 3, optionnel) | La préparation de réunion part des événements ; sans connecteur, elle part des notes de réunion du vault |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
Table `commitments` du parent §13.2 (dans `data/assistant.sqlite3`) — reprise sans changement, avec la règle : `source` ∈ {`chat`, `consolidation`, `connector`, `meeting`, `manual`} et `source_ref` obligatoire sauf `manual`.
|
||||
|
||||
Attributs d'entité personne utilisés par la phase (dans `attrs_json`, schéma libre mais documenté) : `anniversaire` (MM-JJ ou AAAA-MM-JJ), `role` (texte libre de Bruno), `canal_prefere` (texte), `derniere_interaction` (calculé, jamais saisi par le modèle). Les coordonnées (courriel, téléphone) ne sont **pas** des attributs libres : champs dédiés `contact_json` chiffrés au repos quand le chiffrement #172 est actif, `sensitivity='private'` dans tous les cas, exclus des exports par défaut et du compileur de contexte sauf conversation concernant explicitement la personne.
|
||||
|
||||
Transcriptions : l'audio est un fichier temporaire de `data/` supprimé après transcription réussie (sauf réglage explicite « conserver les audios ») ; la transcription vit dans la note ou l'item d'inbox, jamais dans une base séparée.
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket.*
|
||||
|
||||
### Personnes & engagements — #211
|
||||
|
||||
**P4-T01 — Personnes : miroir complet et notes dédiées.**
|
||||
Fichiers : `backend/memory/mirror.py` (extension), `backend/tools/people.py`.
|
||||
Travail : `Personnes.md` peuplé (par personne : relations connues, anniversaire, dernières interactions sourcées avec dates, engagements ouverts la concernant) ; création optionnelle d'une note dédiée par personne (dossier configurable, créée uniquement sur demande explicite ou validation de carte) ; outils `people_list` (READ), `people_get` (READ — coordonnées masquées par défaut, révélées seulement en conversation la concernant), `people_note_add` (WRITE — ligne datée dans la note dédiée).
|
||||
Acceptation : aucune coordonnée n'apparaît dans `Personnes.md` (miroir = relations et contexte, pas carnet d'adresses) ; un test le vérifie sur fixture contenant des coordonnées.
|
||||
|
||||
**P4-T02 — Engagements : store, outils, API.**
|
||||
Fichiers : `backend/tools/people.py` (suite), `backend/routers/commitments.py` ou extension du routeur approvals/today.
|
||||
Travail : table `commitments` (§4) ; outils `commitment_list` (READ — ouverts, échus d'abord), `commitment_create` (WRITE via carte), `commitment_close` (WRITE — `done|dropped`, motif libre), `commitment_followup` (READ — état et dernière relance) ; section « Engagements » d'Aujourd'hui (échus en tête) et de la revue du soir ; clôture en un clic depuis la carte, Aujourd'hui ou la conversation.
|
||||
Acceptation : un engagement échu non clos apparaît à la revue du soir le jour J, puis en insight (#201) à J+2 s'il est toujours ouvert — pas avant, pas en notification dédiée (budget).
|
||||
|
||||
**P4-T03 — Détection en conversation et en consolidation.**
|
||||
Travail : en conversation, les formulations d'engagement (« je vais envoyer X à Marie vendredi », « Karim doit me rendre Y ») produisent une carte de création pré-remplie (direction, personne résolue ou proposée, échéance **uniquement si énoncée**) ; en consolidation, les faits `open_loop` validés sont proposés en promotion vers `commitments` (une carte, pas une conversion automatique) ; résolution de personne ambiguë = carte de choix (règle de la phase 0).
|
||||
Acceptation : sur un corpus de 20 formulations de test, aucune création sans carte et aucune échéance inventée ; une promotion refusée ne se représente pas au run suivant (mémoire de rejet).
|
||||
|
||||
**P4-T04 — Candidats connecteurs et relances.**
|
||||
Travail : activation du réglage `memoire: candidats` par connecteur (défaut `off`, UI Sources connectées) : les engagements détectés dans les collections synchronisées choisies deviennent des candidats `pending` sourcés (lien vers l'objet) dans la file unifiée ; relance : à l'échéance + délai configurable, proposition de brouillon de relance (prompt de l'annexe B.3 du parent) via `gmail.create_draft`/`outlook.create_draft` quand le connecteur existe, sinon texte proposé dans la carte ; l'envoi suit le régime de la phase 3 (§11.4 du parent), sans exception.
|
||||
Acceptation : avec le réglage `off`, zéro candidat produit (test) ; avec `on`, un courriel fixture « je vous enverrai le devis jeudi » produit exactement un candidat sourcé ; la relance n'est jamais envoyée par la tranche.
|
||||
|
||||
### Voix & réunions — #209
|
||||
|
||||
**P4-T05 — Transcription : fournisseur et surfaces.**
|
||||
Fichiers : `backend/tools/voice.py` (ou module `backend/voice/`), Configurations.
|
||||
Travail : réglage du fournisseur de transcription (choix explicite obligatoire avant le premier envoi audio ; choix proposés : API du provider LLM configuré disposant d'une offre voix, sinon aucun — pas de moteur ajouté au conteneur, P1) ; dictée navigateur branchée sur le champ de capture, le panneau et l'éditeur ; enregistrement et dépôt d'audio (PWA, chat, pièce jointe) → file de transcription (run visible) → texte ; suppression de l'audio après succès sauf réglage contraire ; durées et tailles plafonnées et affichées.
|
||||
Acceptation : sans fournisseur choisi, l'UI explique et ne transmet rien ; une transcription est un run journalisé avec son coût ; l'audio d'un test est absent de `data/` après traitement (réglage par défaut).
|
||||
|
||||
**P4-T06 — Notes vocales vers l'Inbox.**
|
||||
Travail : un audio déposé devient un item `kind='voice'` : transcription, résumé en une ligne (cheap model), puis triage ordinaire de la phase 1 (les dates relatives se résolvent à l'horodatage de l'**enregistrement**) ; la carte de triage montre la transcription complète repliable, jamais le seul résumé.
|
||||
Acceptation : « rappelle-moi demain d'appeler le dentiste » en note vocale produit une proposition de tâche à la bonne date ; une note vocale ambiguë reste un item texte, pas une action devinée.
|
||||
|
||||
**P4-T07 — Préparation de réunion.**
|
||||
Fichiers : skill `/reunion-prep`, outil de préparation (agrégateur READ).
|
||||
Travail : à partir d'un événement (phase 3) ou d'une note de réunion : participants résolus en entités, historique (notes liées, derniers comptes rendus avec eux), engagements ouverts les concernant (T02), documents rassemblés (liens), points probables **uniquement sourcés** (ordres du jour passés, tâches ouvertes du projet) ; sortie = note de préparation dans le vault, générée en run, relue par Bruno avant la réunion.
|
||||
Acceptation : la préparation d'une réunion fixture ne contient aucune affirmation sans source ; un participant inconnu est signalé comme tel, pas « enrichi ».
|
||||
|
||||
**P4-T08 — Compte rendu et extraction d'actions.**
|
||||
Travail : transcription (dictée en direct dans la note, ou audio déposé) → compte rendu structuré selon la procédure par défaut (modifiable, mémoire procédurale phase 0) : résumé, décisions, actions ; extraction selon le prompt de l'annexe B.2 du parent : responsable = personne **nommée dans le texte**, échéance = **énoncée**, le reste part en `a_clarifier` ; propositions en file unifiée : tâches (créées dans la note projet ou la note de réunion), décisions → faits mémoire `pending`, suivis → brouillons ; écran de capture avec rappel de consentement, et aucune capture ne peut être déclenchée par une automatisation (politique).
|
||||
Acceptation : le scénario G4-bis (transcription fixture avec 3 actions dont 1 sans date et 1 sans responsable) produit 3 propositions exactes : 1 tâche datée complète, 1 sans échéance, 1 en `a_clarifier` — aucune invention.
|
||||
|
||||
### Proactivité complète — #201
|
||||
|
||||
**P4-T09 — Détecteurs.**
|
||||
Fichiers : `backend/automations/detectors.py` (nouveau), tâche planifiée légère.
|
||||
Travail : les sept détecteurs du §2.1, déterministes sur les index (tâches, engagements, inbox, habitudes, notes) — seul le détecteur « note à faire vieillissante » utilise un balayage de contenu borné ; chaque détection produit un objet insight {détecteur, objet, preuve, proposition d'action} ; exécution nocturne (avec la consolidation, sans coût LLM sauf rédaction optionnelle) et à la demande (`/insights`).
|
||||
Acceptation : chaque détecteur a une fixture positive et une négative ; aucun détecteur n'émet deux fois pour le même objet et le même état (dédup par empreinte objet+état).
|
||||
|
||||
**P4-T10 — Publication et consommation des insights.**
|
||||
Travail : `Insights.md` en sections gérées (suppression d'un item = rejet pour cet objet, alimentant le compteur du détecteur) ; injection bornée au compileur de contexte (≤ 3, sourcés, seulement s'ils concernent le sujet de la conversation) ; actions proposées en un clic (créer la prochaine action du projet, clore ou relancer l'engagement, découper la tâche reportée) — toujours des cartes/runs ordinaires ; consommation du budget d'interruption : les insights ne notifient **jamais** directement, ils attendent le briefing ou Aujourd'hui (sévérité `info` par construction).
|
||||
Acceptation : une semaine de fixture produit des insights consultables sans produire une seule notification push d'insight.
|
||||
|
||||
**P4-T11 — Boucle d'acceptation des détecteurs.**
|
||||
Travail : compteurs par détecteur (émis, acceptés, rejetés, ignorés 3 fois) au centre de contrôle ; règles d'auto-ajustement : taux d'acceptation < 20 % sur 20 émissions → seuil du détecteur relevé automatiquement (journalisé) ; < 10 % ensuite → proposition de suspension en carte (jamais de suspension silencieuse) ; réactivation manuelle toujours possible.
|
||||
Acceptation : un détecteur fixture bruyant voit son seuil relevé après 20 émissions majoritairement rejetées, et l'événement est visible au centre de contrôle.
|
||||
|
||||
### Finitions
|
||||
|
||||
**P4-T12 — i18n, docs, guides, vie privée.**
|
||||
Travail : clés FR/EN (personnes, engagements, réunions, insights) ; fiches `docs/features/` pour #211, #209, #201 ; guide « Personnes, engagements et réunions » incluant la page des réglages de confidentialité (candidats connecteurs, fournisseur de transcription, conservation des audios) ; extension de l'export/effacement (parent §15.4) aux engagements et aux attributs personnes.
|
||||
Acceptation : l'effacement du domaine « personnes » supprime entités concernées, engagements et miroirs associés, avec trace d'audit ; DoD du §12 pour le reste.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET /api/people` · `GET /api/people/{id}` | Personnes (coordonnées masquées par défaut) | P4-T01 |
|
||||
| `GET/POST /api/commitments` · `POST /api/commitments/{id}/close` | Engagements et clôture | P4-T02 |
|
||||
| `POST /api/voice/transcribe` | Transcription d'un audio déposé (run) | P4-T05 |
|
||||
| `POST /api/meetings/prepare` · `POST /api/meetings/minutes` | Préparation et compte rendu | P4-T07/T08 |
|
||||
| `GET /api/insights` · `POST /api/insights/{id}/act|dismiss` | Insights et leurs actions | P4-T10 |
|
||||
|
||||
Outils `@tool` correspondants : `people.*`, `commitment.*` (parent §9.7/§13.2), exposés au MCP comme les autres — avec la règle que `people_get` via MCP ne renvoie **jamais** les coordonnées (masquage renforcé hors conversation directe).
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Sans la phase 3, la phase vit quand même
|
||||
|
||||
Pas de courriel candidat (la détection reste conversation + consolidation), préparation de réunion depuis les notes du vault, anniversaires et engagements pleinement fonctionnels. L'UI signale les capacités absentes comme des réglages à activer, pas comme des erreurs.
|
||||
|
||||
### 7.2 Un engagement, deux directions, un seul objet
|
||||
|
||||
`je_dois` et `on_me_doit` partagent le même cycle de vie ; la revue du soir présente les deux listes séparément (ce que je dois faire demain / ce que j'attends), et une relance n'existe que pour `on_me_doit` — pour `je_dois`, l'action proposée est une tâche.
|
||||
|
||||
### 7.3 La proactivité a un éditeur : Bruno
|
||||
|
||||
Le taux d'acceptation par détecteur est la métrique directrice de la phase (suivie par #212) : elle est affichée, et les ajustements automatiques de seuil sont des événements journalisés et réversibles. Un détecteur suspendu peut être réactivé ; un détecteur supprimé d'`Insights.md` objet par objet apprend ce qui ne sert pas.
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Unitaires : détection d'engagements (corpus de formulations, aucune date inventée), masquage des coordonnées (miroir, export, MCP), dédup des insights, auto-ajustement des seuils.
|
||||
- Intégration : courriel candidat → engagement → échéance → brouillon de relance ; note vocale → tâche ; réunion complète prép → CR → tâches.
|
||||
- Golden tasks de la phase : **G12** — « engagement pris par courriel (réglage candidats activé) suivi jusqu'au brouillon de relance, jamais envoyé seul » ; **G13** — « compte rendu : 3 actions extraites sans invention (cf. P4-T08) » ; **G14** — « une semaine d'insights : zéro notification d'insight, taux d'acceptation mesuré ». (Suite de G1–G11.)
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Un engagement créé en conversation ou validé depuis un courriel est suivi jusqu'à sa clôture ou sa relance rédigée — sans création silencieuse à aucun maillon.
|
||||
2. Aucune coordonnée de personne ne fuit : ni miroir, ni export par défaut, ni MCP, ni outil externe (tests dédiés verts).
|
||||
3. Une note vocale et un compte rendu produisent des tâches exactes (responsables et dates uniquement quand ils sont énoncés).
|
||||
4. Les insights sont utiles ou se taisent : publication sans notification directe, compteurs d'acceptation actifs, auto-ajustement prouvé sur fixture.
|
||||
5. La phase fonctionne, dégradée mais complète, sans aucun connecteur.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| Sentiment de surveillance des tiers (réunions, courriels) | Consentement affiché à la capture, candidats toujours validés, aucune donnée tierce conservée hors du fait validé ; les personnes peuvent être « gelées » (entité exclue de toute détection) par un réglage par personne |
|
||||
| Détection d'engagements trop zélée (faux positifs en chaîne) | Cartes regroupées, mémoire de rejet par formulation, métrique d'acceptation du détecteur d'engagements suivie comme les autres |
|
||||
| Coût de transcription sur de longues réunions | Plafonds de durée affichés avant traitement, coût journalisé par run, fournisseur choisi et révocable |
|
||||
| Insights « bienveillants » mais inutiles qui érodent la confiance | La boucle d'acceptation (P4-T11) est un critère de phase, pas une amélioration future |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P4-T01 → T02 → T03 → T04 (#211)
|
||||
P4-T05 → T06 ; T07 → T08 (#209 — T07 peut démarrer après T01/T02)
|
||||
P4-T09 → T10 → T11 (#201 — après T02 pour le détecteur d'engagements)
|
||||
P4-T12 (clôture)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD du document de phase 0 §12, inchangée, plus : les golden tasks G12–G14 vertes en CI ; les tests de non-fuite des coordonnées (§9, critère 2) font partie de la suite permanente, pas d'un audit ponctuel.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 5 : L'extension (packs de domaines et évaluation continue)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **5 sur 5** — optionnelle et étalée : le cœur (phases 0–4) est complet sans elle |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §13.4 (packs de domaines), §16 (évaluation & observabilité), §20 (risque « périmètre qui grandit sans fin ») |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Cadre prêt ; chaque chantier s'active séparément, à la demande de Bruno |
|
||||
| Tranches | **#210** (packs de domaines, chantiers A–G), **#212** (évaluation & observabilité — volet continu, chantier H) |
|
||||
| Effort estimé | 3–5 jours **par chantier de pack**, étalés ; chantier H : 2–3 jours puis continu |
|
||||
| Prérequis | Phases 0–4 terminées pour les chantiers qui consomment connecteurs, personnes et automatisations ; les chantiers A et F sont possibles dès la phase 2 |
|
||||
| Dépendances | #210 ← #208 (+ phase 3 pour B, C partiellement, E ; + phase 4 pour C et les relances) ; #212 ← amorce de la phase 0, puis toutes les phases |
|
||||
| Débloque | Rien : c'est la phase d'extension. Chaque chantier terminé enrichit l'assistant sans rien débloquer d'obligatoire |
|
||||
|
||||
> **Résultat visible à la fin de chaque chantier.** Un domaine de vie de plus est couvert de bout en bout par l'assistant — pas par un nouveau moteur, mais par l'assemblage curé de ce qui existe : des **recettes** (automatisations et plans), des **procédures** (la façon de Bruno de faire les choses, en mémoire), des **skills** et ses **golden tasks** propres. Un pack ne s'active qu'à la demande, se désactive sans laisser de trace dans le quotidien, et prouve son utilité par ses scénarios avant d'être livré. Le chantier H (#212) court en parallèle de toute la phase : le corpus de golden tasks grandit, les métriques du centre de contrôle deviennent des revues périodiques, et le routage de modèles s'affine sur données réelles.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Ce qu'est un pack — anatomie et gouvernance](#2-ce-quest-un-pack--anatomie-et-gouvernance)
|
||||
3. [État d'entrée](#3-état-dentrée)
|
||||
4. [Chantiers de packs — #210](#4-chantiers-de-packs--210)
|
||||
5. [Chantier H — Évaluation & observabilité continues — #212](#5-chantier-h--évaluation--observabilité-continues--212)
|
||||
6. [Critères d'acceptation de la phase](#6-critères-dacceptation-de-la-phase)
|
||||
7. [Risques spécifiques et vigilance](#7-risques-spécifiques-et-vigilance)
|
||||
8. [Ordre d'exécution suggéré](#8-ordre-dexécution-suggéré)
|
||||
9. [Définition de « terminé »](#9-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **« maximum de tâches » s'atteint par assemblage, pas par accumulation de moteurs.** Le cœur des phases 0–4 (mémoire, tâches, plan, exécution fiable, connecteurs, automatisations, personnes) est générique à dessein ; la valeur d'un domaine — voyages, argent, apprentissage — tient à ses recettes, ses procédures et ses critères de qualité, pas à du code nouveau. La phase 5 est donc organisée en **chantiers indépendants et optionnels**, sur le modèle de la phase d'extension des documents Agents & Skills : aucun chantier n'est un prérequis d'un autre (hors dépendances notées), chacun a ses critères de sortie, et la phase n'a pas de « fin » obligée.
|
||||
|
||||
Deux garde-fous contre la dispersion (risque nommé au parent §20) :
|
||||
|
||||
- **Un pack = un chantier, un chantier = une livraison.** Pas de pack « à moitié activé » : recettes, procédures, skills et golden tasks arrivent ensemble, ou le chantier n'est pas terminé.
|
||||
- **La désactivation est un non-événement.** Retirer un pack retire ses recettes actives et ses skills des menus ; les notes, tâches et faits créés restent (ce sont des données de Bruno, pas du pack).
|
||||
|
||||
## 2. Ce qu'est un pack — anatomie et gouvernance
|
||||
|
||||
| Composant | Forme | Exemple |
|
||||
|---|---|---|
|
||||
| Recettes | Automatisations et plans pré-remplis (phase 2), activables une à une | « Veille de prix hebdomadaire » |
|
||||
| Procédures | Faits `kind='procedure'` livrés avec le pack, **modifiables** (mémoire phase 0) | « Un dossier de voyage contient : itinéraire, documents, contacts, budget » |
|
||||
| Skills | Skills `/` existants ou nouveaux, dans le format actuel (`data/skills.json` et skills intégrés) | `/voyage-prep` |
|
||||
| Modèles de notes | Notes modèles dans le vault (listes, checklists, tableaux) | Checklist d'entretien maison par saison |
|
||||
| Golden tasks | Scénarios du domaine, ajoutés au corpus (chantier H) | 2–4 par pack, bloquants pour la livraison du chantier |
|
||||
| Réglages | Niveau d'autonomie par défaut proposé (jamais imposé), budgets propres éventuels | Pack Voyages : niveau 1 par défaut (lecture) |
|
||||
|
||||
Gouvernance : un registre simple des packs (installé/activé/désactivé) dans les Configurations, section Assistant ; l'activation affiche **tout** ce que le pack ajoutera (recettes avec leurs déclencheurs, procédures avec leur texte, skills) avant confirmation — même exigence de transparence que pour une automatisation importée (parent §15.1). Aucun pack ne peut élever un niveau d'autonomie ni créer une délégation : il *propose*, les mécanismes des phases 0–1 disposent.
|
||||
|
||||
## 3. État d'entrée
|
||||
|
||||
Tout ce que les phases 0–4 ont livré, et en particulier : le catalogue d'outils existants bien plus riche que le seul périmètre assistant (documents docx/xlsx/csv/pdf et outils tableurs complets — lecture, recherche, analyse de plages, édition — décrits dans les guides `ASSISTANT_IA_FORGE` et `RECHERCHE_PDF_EXCALIDRAW`), les skills d'apprentissage déjà intégrés (`/quiz`, `/reading-note`, `/qa-generator`…), la recherche et le Deep Research, `fetch_url`/`crawl_site`, les connecteurs Gitea/GitHub migrés au cadre (phase 3), les automatisations et le bus (phase 2), les personnes et engagements (phase 4). Plusieurs chantiers ci-dessous ne demandent **aucun nouvel outil** — c'est un critère de conception, pas un hasard.
|
||||
|
||||
## 4. Chantiers de packs — #210
|
||||
|
||||
*Convention : chaque chantier est décrit par son contenu (recettes/procédures/skills), ses dépendances propres, 1 exemple de golden task et ses critères de sortie spécifiques. L'effort type est de 3–5 jours, presque entièrement en contenu et en tests, pas en moteur.*
|
||||
|
||||
### Chantier A — Connaissance & veilles avancées
|
||||
|
||||
Contenu : recettes de veille multi-sources (page web + recherche périodique sur un sujet → note de diff dans le vault, insight seulement si changement matériel) ; synthèse de dossier à la demande (« tout ce que j'ai sur X, et ce qui a changé depuis <date> ») en skill assemblant recherche vault, Notion synchronisée (phase 3) et mémoire ; revue de lecture hebdomadaire depuis l'inbox (items `url` non triés > 7 j proposés en lot : lire/résumer/archiver).
|
||||
Dépendances : phases 0–2 (3 pour la source Notion).
|
||||
Golden task (exemple) : une page fixture modifiée entre deux veilles produit exactement une note de diff citant l'ancien et le nouveau contenu ; inchangée, elle ne produit rien.
|
||||
Sortie : les trois recettes fonctionnent sur fixtures ; aucun nouvel outil créé.
|
||||
|
||||
### Chantier B — Documents & données du quotidien
|
||||
|
||||
Contenu : procédures et recettes sur les outils **existants** (documents et tableurs) : classeur de budget personnel (modèle xlsx + recette mensuelle « importer un relevé csv déposé dans le vault → catégoriser selon la procédure de Bruno → mettre à jour le classeur → rapport d'écarts en note ») ; génération de documents récurrents (attestations, courriers types) depuis des modèles de notes et des faits mémoire ; extraction de tableaux de PDF du vault vers des notes.
|
||||
Dépendances : phases 0–2 ; relevés = fichiers déposés par Bruno (aucune connexion bancaire, voir chantier D).
|
||||
Golden task : un relevé fixture de 30 lignes est catégorisé selon la procédure, avec les lignes ambiguës en carte — jamais devinées.
|
||||
Sortie : le rapport mensuel complet est produisible par une recette dry-runée puis activée.
|
||||
|
||||
### Chantier C — Maison & administration personnelle
|
||||
|
||||
Contenu : modèle d'entretien récurrent (tâches RRULE par équipement, notes d'interventions liées) ; démarches suivies (renouvellements, garanties, assurances) comme engagements envers soi avec échéances et documents rassemblés ; listes vivantes (courses, cadeaux — alimentées par l'inbox et les personnes de la phase 4 : anniversaires → propositions) ; rappels saisonniers en recettes.
|
||||
Dépendances : phases 1, 2, 4 (anniversaires et cadeaux) ; phase 3 optionnelle (échéances lues dans les courriels candidats).
|
||||
Golden task : la garantie d'un équipement arrivant à échéance dans 30 j produit une tâche datée avec le document lié, sans notification directe (revue).
|
||||
Sortie : le dossier « Maison » du vault de test est entièrement tenu par les recettes du pack pendant un mois simulé.
|
||||
|
||||
### Chantier D — Argent en lecture seule
|
||||
|
||||
Contenu : **strictement** la lecture et l'analyse de données que Bruno dépose (relevés csv/xlsx, exports) : catégorisation (procédure du chantier B réutilisée), budgets par enveloppe en notes, alertes d'échéances de factures détectées dans les notes et les courriels candidats, synthèse mensuelle. Aucun accès bancaire direct, aucun paiement, aucune initiation de virement — ces exclusions sont **structurelles** (aucun outil du pack n'a de capability d'écriture externe) et rappelées dans la fiche du pack.
|
||||
Dépendances : chantier B ; phase 3 pour les candidats courriels.
|
||||
Golden task : une facture fixture échue non payée (selon les notes) apparaît en revue avec sa source ; une facture payée n'y apparaît pas.
|
||||
Sortie : la synthèse mensuelle d'un mois fixture est exacte au centime sur les lignes non ambiguës, et les ambiguës sont listées à part.
|
||||
|
||||
### Chantier E — Voyages
|
||||
|
||||
Contenu : procédure « dossier de voyage » (itinéraire, documents, contacts sur place, budget, contraintes) ; recettes : préparation (checklist générée depuis la procédure et les dates, documents rassemblés depuis le vault et Drive), rappels décalés par fuseau (le moteur RRULE/heure murale des phases 1–2, appliqué au fuseau de destination pour les rappels « sur place »), briefing de départ (agenda, documents, engagements en suspens pendant l'absence), retour (dépenses déposées → chantier D, photos/notes → dossier).
|
||||
Dépendances : phases 1–4 (agenda réel et Drive en phase 3 fortement recommandés).
|
||||
Golden task : un voyage fixture sur 2 fuseaux produit des rappels aux heures murales correctes dans chaque fuseau, affichés avec leur heure d'origine (règle de la phase 3 §7.2).
|
||||
Sortie : le dossier complet d'un voyage de test est produit par les recettes, sans intervention de code.
|
||||
|
||||
### Chantier F — Apprentissage
|
||||
|
||||
Contenu : assemblage des skills **existants** (`/quiz`, `/reading-note`, `/qa-generator`, `/summary-progressive`) avec les habitudes (phase 2) et la mémoire : parcours de lecture d'un dossier (ordre proposé, fiches générées dans le vault, quiz espacés en tâches récurrentes liées à l'objectif), revue d'apprentissage mensuelle (ce qui est su, mesuré par les quiz, pas déclaré).
|
||||
Dépendances : phases 0–2 seulement — chantier livrable tôt.
|
||||
Golden task : un chapitre fixture produit des fiches et un quiz ; les réponses du quiz mettent à jour le suivi sans toucher aux notes sources.
|
||||
Sortie : un parcours complet sur un dossier de test, de la première fiche à la revue mensuelle.
|
||||
|
||||
### Chantier G — Synchronisation de tâches externe (option conditionnelle)
|
||||
|
||||
Contenu : **seulement si** Bruno tranche la décision ouverte n° 4 du parent en faveur d'un gestionnaire externe (Todoist ou équivalent) : connecteur du cadre (phase 3) en synchronisation bidirectionnelle avec le vault **source de vérité** (règles du parent §11.5 : tâches synchronisées marquées `source='connector'` + id externe ; conflit = modification la plus récente gagne, journalisée et visible) ; la vue Aujourd'hui et le plan continuent de lire l'index local.
|
||||
Dépendances : phases 1, 3 ; décision de Bruno.
|
||||
Golden task : la même tâche modifiée des deux côtés pendant une coupure se résout selon la règle, avec le conflit visible dans le journal — jamais de perte silencieuse.
|
||||
Sortie : une semaine d'usage simulé sans divergence d'index ; la désactivation du connecteur laisse le vault complet et autonome.
|
||||
|
||||
## 5. Chantier H — Évaluation & observabilité continues — #212
|
||||
|
||||
Ce chantier n'est pas un pack : c'est le volet continu de #212, amorcé en phase 0 (harnais + G3/G6/G7) et enrichi par chaque phase (G1/G2/G4/G5/G8 en phase 2, G9–G11 en phase 3, G12–G14 en phase 4, puis les golden tasks des packs).
|
||||
|
||||
**P5-H1 — Corpus vivant.** Le corpus `tests/golden/` devient une suite de référence versionnée : tout scénario qui a un jour révélé un défaut réel y entre comme test de non-régression (règle : un bug d'assistant corrigé sans golden task ajouté n'est pas « terminé »). Revue du corpus à chaque livraison de chantier : scénarios obsolètes retirés explicitement, jamais en silence.
|
||||
|
||||
**P5-H2 — Métriques en revues.** Les compteurs du centre de contrôle (parent §16.2 : plans tenus, runs par statut, validations de faits, acceptation des détecteurs, coût par type de run, temps de décision quotidien, interruptions vs budget) sont assemblés en **revue mensuelle automatique** (note dans `99_Assistant/Revues/`) relue avec Bruno : c'est là que se décident les ajustements de seuils, de budgets et de niveaux d'autonomie — sur données, pas sur impressions.
|
||||
|
||||
**P5-H3 — Routage de modèles affiné.** La table de routage par type de job (parent §16.3) est ajustée depuis les métriques : un job dont le taux de rejet/correction dépasse un seuil passe au modèle supérieur (proposition en revue mensuelle, jamais de changement silencieux de coût) ; un job à très haut volume et haute acceptation peut descendre au modèle inférieur, golden tasks du job vertes à l'appui.
|
||||
|
||||
**P5-H4 — Échantillon réel en lecture seule.** Avant chaque livraison de chantier, l'échantillon de scénarios sûrs (lecture seule) tourne contre l'instance réelle de Bruno ; les écarts avec la CI (données réelles vs fixtures) sont consignés dans la fiche du chantier. Aucune écriture réelle n'est jamais produite par ce mécanisme.
|
||||
|
||||
Critères de sortie (le chantier est « en régime » quand) : la revue mensuelle est produite et a été relue au moins deux fois ; le corpus compte les scénarios de toutes les phases livrées ; au moins un ajustement (seuil, budget ou routage) a été décidé sur la base d'une revue.
|
||||
|
||||
## 6. Critères d'acceptation de la phase
|
||||
|
||||
La phase 5 n'a pas de critère global — chaque chantier a les siens. Critères transverses, vérifiés à chaque livraison de chantier :
|
||||
|
||||
1. Le chantier n'a créé **aucun nouveau moteur** ; tout outil nouveau éventuel est justifié dans la fiche du chantier (par défaut : zéro).
|
||||
2. Ses golden tasks sont vertes et entrées au corpus (chantier H).
|
||||
3. Son activation a été précédée de l'affichage complet de ce qu'il ajoute ; sa désactivation ne supprime aucune donnée de Bruno.
|
||||
4. Sa fiche `docs/features/` et son guide sont livrés (DoD commune).
|
||||
|
||||
## 7. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| La phase devient un fourre-tout qui ne finit jamais | C'est assumé : la phase est **optionnelle et étalée** ; le produit est complet sans elle. Le risque serait l'inverse — bloquer le cœur pour un pack |
|
||||
| Des packs médiocres érodent la confiance dans le cœur | La barre de livraison d'un chantier est celle des phases précédentes (golden tasks, DoD) ; un pack qui ne la passe pas ne s'active pas |
|
||||
| Le chantier D (argent) glisse vers du conseil financier | Périmètre lecture/analyse uniquement ; les synthèses citent les données déposées et n'émettent ni recommandation d'achat/vente ni jugement de solvabilité — écrit dans la procédure du pack et dans sa fiche |
|
||||
| La revue mensuelle (#212) devient un rituel ignoré | Elle est courte par construction (une page, chiffres et écarts seulement) et lue dans la revue de semaine existante, pas dans un nouveau rendez-vous |
|
||||
|
||||
## 8. Ordre d'exécution suggéré
|
||||
|
||||
Aucun ordre imposé. Suggestion fondée sur les dépendances et le rapport valeur/effort :
|
||||
|
||||
```
|
||||
Dès la phase 2 : F (apprentissage — aucun connecteur requis)
|
||||
Après la phase 3 : A (veilles) → B (documents & données) → D (argent, après B)
|
||||
Après la phase 4 : C (maison & admin) → E (voyages)
|
||||
Si décision prise : G (sync tâches externe)
|
||||
En continu, dès la phase 0 : H (#212) — ses livrables propres sont en §5
|
||||
```
|
||||
|
||||
## 9. Définition de « terminé »
|
||||
|
||||
Par chantier : DoD du document de phase 0 §12 + critères transverses du §6 ci-dessus. La phase 5 elle-même n'est jamais « terminée » par construction : elle se clos quand Bruno décide qu'aucun chantier restant ne vaut son effort — ce qui est un succès du découpage, pas un échec du plan.
|
||||
+58
-10
@@ -100,13 +100,37 @@ Gestion :
|
||||
|
||||
| Endpoint | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/auth/tokens` | Lister vos clés (`last_used_at`, statut) |
|
||||
| `POST /api/auth/tokens` | Créer (`{name, expiry}`) |
|
||||
| `GET /api/auth/tokens` | Lister vos clés (`last_used_at`, statut, **portée**) |
|
||||
| `POST /api/auth/tokens` | Créer (`{name, expiry, vaults?, scope?}`) |
|
||||
| `DELETE /api/auth/tokens/{jti}` | Révoquer immédiatement (API **et** MCP) |
|
||||
|
||||
> Le JWT brut n'est **jamais persisté** : copiez-le à la création. Plafond :
|
||||
> 50 clés actives par utilisateur.
|
||||
|
||||
### 2.3 Clés à portée limitée (synchronisation externe, ex. TikTak)
|
||||
|
||||
Une clé peut être confinée **à un ou plusieurs vaults** et **aux fichiers seuls** —
|
||||
si elle fuit, le reste reste hors de portée :
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:2020/api/auth/tokens \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"TikTak sync","expiry":"never","vaults":["TikTak"],"scope":"files"}'
|
||||
```
|
||||
|
||||
- `vaults` : sous-ensemble de vos vaults (`400` si inconnu, `403` si hors de vos
|
||||
accès ; omis = tous vos vaults, comme avant).
|
||||
- `scope: "files"` : surface fichiers uniquement (CRUD, listes, recherche,
|
||||
backups lecture/restauration, `GET /api/vaults`, `/mcp`). Tout le reste —
|
||||
admin, config, IA, webhooks, partages, **gestion des clés elle-même** — répond
|
||||
**403**. Les outils MCP hors fichiers sont refusés (`token_scope_denied`) et
|
||||
masqués du `list_tools`.
|
||||
- La portée est affichée dans l'interface (Configurations → **🔑 Clés API & MCP**)
|
||||
et renvoyée par `GET /api/auth/tokens` (`scope`, `vaults`).
|
||||
- Les clés créées avant cette fonction n'ont aucune portée = accès complet,
|
||||
comportement inchangé (non-régression testée).
|
||||
|
||||
---
|
||||
|
||||
## 3. Référence des endpoints
|
||||
@@ -166,7 +190,7 @@ Gestion :
|
||||
| `/api/suggest?q=` | Autocomplétion de titres | GET | Oui |
|
||||
| `/api/tags/suggest?q=` | Autocomplétion de tags | GET | Oui |
|
||||
| `/api/tree-search` | Recherche de fichiers/dossiers | GET | Oui |
|
||||
| `/api/vault/{vault}/paths` | Liste de chemins | GET | Oui |
|
||||
| `/api/vault/{vault}/paths` | Liste de chemins **avec `size`/`mtime`/`mtime_iso`** (réconciliation externe en un appel) | GET | Oui |
|
||||
| `/api/graph/{vault}` | Graphe de liens | GET | Oui |
|
||||
|
||||
### 3.5 Sauvegardes
|
||||
@@ -231,7 +255,7 @@ Gestion :
|
||||
|---|---|---|
|
||||
| `/api/share/{vault}` | Créer un lien de partage public | POST |
|
||||
| `/api/shares` | Lister / supprimer les partages | GET/DELETE |
|
||||
| `/api/webhooks` | CRUD webhooks (HMAC-SHA256) | GET/POST/PATCH/DELETE |
|
||||
| `/api/webhooks` | CRUD webhooks (HMAC-SHA256, **filtres `vault` / `path_prefix`**) | GET/POST/PATCH/DELETE |
|
||||
| `/api/conflicts` · `/api/conflicts/resolve` | Conflits Syncthing | GET/POST |
|
||||
| `/api/plugins` | Installer / activer / désactiver | GET/POST/DELETE |
|
||||
| `/api/push/*` | Abonnement Web Push (VAPID) | GET/POST/DELETE |
|
||||
@@ -285,9 +309,33 @@ curl -s $BASE/api/index/reload -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
---
|
||||
|
||||
## 5. Temps réel
|
||||
## 5. Synchronisation externe (TikTak)
|
||||
|
||||
### 5.1 SSE — `/api/events`
|
||||
Recette complète côté ObsiGate : fiche
|
||||
[`../features/tiktak-integration-197-200.md`](../features/tiktak-integration-197-200.md)
|
||||
(#200 : provisionnement du vault « TikTak », #199 : clé limitée, #198 : webhook
|
||||
filtré). Boucle de réconciliation type avec **un seul appel de liste** :
|
||||
|
||||
```bash
|
||||
KEY=<clé scope=files, vaults=[TikTak]>
|
||||
# 1. Inventaire : chemin + mtime + taille, sans lire les contenus
|
||||
curl -s "$BASE/api/vault/TikTak/paths?limit=20000" -H "Authorization: Bearer $KEY" \
|
||||
| jq -r '.results[] | select(.type=="file") | "\(.mtime) \(.size) \(.path)"' > avant.txt
|
||||
# 2. … modifications …
|
||||
# 3. Détecter les changés par comparaison avec l'inventaire précédent
|
||||
curl -s "$BASE/api/vault/TikTak/paths?limit=20000" -H "Authorization: Bearer $KEY" \
|
||||
| jq -r '.results[] | select(.type=="file") | "\(.mtime) \(.size) \(.path)"' > apres.txt
|
||||
```
|
||||
|
||||
> `PUT /api/file/{vault}/save` rafraîchit l'index de façon synchrone : la re-liste
|
||||
> qui suit voit le nouveau `mtime`/`size` sans attendre le watcher. Les sauvegardes
|
||||
> texte émettent `file_modified` (webhook) pour tirer les retours.
|
||||
|
||||
---
|
||||
|
||||
## 6. Temps réel
|
||||
|
||||
### 6.1 SSE — `/api/events`
|
||||
|
||||
Flux d'événements de changement d'index (fichiers créés/supprimés/modifiés), avec
|
||||
reconnexion automatique côté client.
|
||||
@@ -296,7 +344,7 @@ reconnexion automatique côté client.
|
||||
curl -N "$BASE/api/events"
|
||||
```
|
||||
|
||||
### 5.2 WebSocket — collaboration
|
||||
### 6.2 WebSocket — collaboration
|
||||
|
||||
`ws(s)://<hôte>/ws/collab/{vault}/{path}` transporte les mises à jour
|
||||
Yjs/CRDT et la présence (curseurs distants). Authentification par cookie
|
||||
@@ -305,7 +353,7 @@ Voir [Édition & collaboration](./COLLABORATION.md).
|
||||
|
||||
---
|
||||
|
||||
## 6. Limites et bonnes pratiques
|
||||
## 7. Limites et bonnes pratiques
|
||||
|
||||
- **Rate limiting** : les endpoints de login et les outils IA sont limités ;
|
||||
respectez `retry_after` en cas de `429`.
|
||||
@@ -320,12 +368,12 @@ Voir [Édition & collaboration](./COLLABORATION.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. Dépannage
|
||||
## 8. Dépannage
|
||||
|
||||
| Code | Cause probable |
|
||||
|---|---|
|
||||
| `401` | Jeton absent, expiré ou révoqué |
|
||||
| `403` | Compte sans accès à cette vault / réservé admin |
|
||||
| `403` | Compte sans accès à cette vault / réservé admin / clé à portée limitée hors périmètre |
|
||||
| `404` | Vault, fichier ou chemin inexistant |
|
||||
| `409` | Conflit (fichier déjà existant, etc.) |
|
||||
| `422` | Corps de requête invalide (schéma Pydantic) |
|
||||
|
||||
@@ -19,6 +19,10 @@ Cline, tout client compatible MCP) via un serveur **Streamable HTTP** monté sur
|
||||
(`Authorization: Bearer <token>`). Une seule clé fonctionne pour l'API REST
|
||||
**et** le MCP. Créez-la depuis l'interface (Configurations → 🔑 Clés API & MCP)
|
||||
ou via `POST /api/auth/tokens` — voir [API REST §2.2](./API_REST.md#22-clés-api-longue-durée-recommandé-pour-scripts--mcp).
|
||||
Pour une intégration limitée aux fichiers (ex. TikTak), créez une clé
|
||||
`{"vaults":["TikTak"],"scope":"files"}` ([API REST §2.3](./API_REST.md#23-clés-à-portée-limitée-synchronisation-externe-ex-tiktak)) :
|
||||
les outils hors fichiers sont refusés (`token_scope_denied`) et masqués du
|
||||
`list_tools`, et les autres vaults répondent en erreur d'accès.
|
||||
3. Si l'authentification est désactivée (`OBSIGATE_AUTH_ENABLED=false`), le
|
||||
serveur MCP accepte un utilisateur anonyme disposant de tous les vaults.
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/<uuid>`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée |
|
||||
| *BUG-111* | Admin : modale « Administration — Utilisateurs » sans icône de fermeture (lucide absent de admin.html) et tableau de bord admin affichant toutes les sections au lieu de la seule section sélectionnée | 2026-10-10 | Utilisateur | `frontend/admin.html` (ajout du script lucide UMD) + `frontend/js/admin.js` (`_wireSectionNav` : show/hide au lieu du scrollspy) ; suites frontend + backend vertes, vérifié sur instance locale 2020 | lucide n'était chargé que dans index.html ; le scrollspy laissait les 4 sections visibles |
|
||||
| *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 |
|
||||
| *BUG-112* | E2E local : `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine **jamais** — la commande semble bloquée des dizaines de minutes alors que le serveur est prêt en ~7 s | 🟢 corrigé | P1 | 🧪 E2E local / Windows | IA | `scripts/e2e-server.ps1` (`start`) | `pwsh -NoProfile -File scripts/e2e-server.ps1 start 2>&1 \| head -n 20` → pipeline ouvert ~500 s+ ; se termine en <2 s dès qu'uvicorn est tué (problème d'EOF du pipe, pas de lenteur du démarrage) | `Start-Process` laissait uvicorn hériter des handles stdout/stderr du pwsh parent → le pipe `\| head` reste ouvert tant que le serveur vit → `head` attend un EOF qui n'arrive jamais. Remplacé par `Win32_Process.Create` (WMI) : processus détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs) | Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers indexés), `stop`/`status` OK, syntaxe PS validée, aucun orphelin |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
@@ -345,7 +346,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `<i>` remplacé) | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres `« * »` écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes `« * »` → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-14 | BUG-112 | Correction | `scripts/e2e-server.ps1`, `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **BUG-112 — `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine jamais.** Constat : le démarrage n'est pas lent (~7 s, READY, health 200, 137 fichiers indexés) — c'est le pipe qui ne reçoit jamais d'EOF. Preuve : le pipeline survit 494 s+ puis se termine en <2 s dès qu'uvicorn est tué. Cause : `Start-Process` laisse l'enfant hériter des handles stdout/stderr du pwsh parent ; le serveur (immortel par design) maintient le pipe ouvert → `head` attend un EOF qui n'arrivera jamais. Correctif : `Win32_Process.Create` (WMI) lance uvicorn détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs). Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers), `stop`/`status` OK, syntaxe PS validée, aucun orphelin | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+58
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.67.2 | **Dernière mise à jour :** 2026-10-10
|
||||
> **Version :** 2.68.1 | **Dernière mise à jour :** 2026-10-14
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -100,6 +100,63 @@
|
||||
|
||||
---
|
||||
|
||||
### 197. Listes de fichiers : `mtime` + `size` pour réconciliation externe (TikTak, Gitea #4)
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-14 | **Statut :** ✅ livré le 2026-10-14
|
||||
- **Fiche :** [features/tiktak-integration-197-200.md](./features/tiktak-integration-197-200.md)
|
||||
- **Description :** un client de synchro doit détecter les fichiers modifiés en **un
|
||||
seul appel de liste**, sans relire chaque fichier. `GET /api/vault/{vault}/paths`
|
||||
n'exposait que `vault/path/name/type`.
|
||||
- **Sous-tâches :**
|
||||
- [x] `/paths` : `size`/`mtime`/`mtime_iso` par fichier (index, même contrat que
|
||||
`list_all_files`) ; `None` sur les répertoires
|
||||
- [x] `/browse` : `modified`/`modified_iso` ; outil MCP `list_directory` : `size`/`mtime`
|
||||
- [x] Cohérence read-after-write : `PUT …/save` rafraîchit l'index en synchrone
|
||||
- [x] Bug latent corrigé : `update_single_file`/`remove_single_file` exigeaient un
|
||||
absolu mais recevaient un relatif (no-op silencieux) — acceptent les deux
|
||||
- [x] Tests `tests/test_sync_lists.py` (7 : métadonnées + scénario de réconciliation)
|
||||
|
||||
### 198. Webhooks : `vault` dans la charge + filtre `vault` / `path_prefix` (TikTak, Gitea #3)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-14 | **Statut :** ✅ livré le 2026-10-14
|
||||
- **Fiche :** [features/tiktak-integration-197-200.md](./features/tiktak-integration-197-200.md)
|
||||
- **Description :** un consommateur externe (retours de synchro TikTak, phase 3) recevait
|
||||
tout, de tous les vaults, sans pouvoir filtrer.
|
||||
- **Sous-tâches :**
|
||||
- [x] Filtres optionnels `vault` (exact) + `path_prefix` (sous-arbre, `old/new_path`
|
||||
inclus) ; sans filtre = tout, comme avant ; `webhook_matches()` pur et testé
|
||||
- [x] `POST`/`PATCH`/`GET /api/webhooks` : filtres acceptés, modifiables (`""` = reset), exposés
|
||||
- [x] `PUT …/save` émet enfin `file_modified` (souscrit mais jamais dispatché)
|
||||
- [x] UI : champs filtre + badge, aide intégrée FR/EN mise à jour
|
||||
- [x] Tests `tests/test_webhook_filters.py` (10 : matching, persistance, dispatch simulé)
|
||||
|
||||
### 199. Clés API à portée limitée — vault unique + fichiers seuls (TikTak, Gitea #2)
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-14 | **Statut :** ✅ livré le 2026-10-14
|
||||
- **Fiche :** [features/tiktak-integration-197-200.md](./features/tiktak-integration-197-200.md)
|
||||
- **Description :** la clé que TikTak conservera ne doit toucher que le vault « TikTak »
|
||||
et que les fichiers — en cas de fuite, le reste de la mémoire reste hors de portée.
|
||||
- **Sous-tâches :**
|
||||
- [x] `POST /api/auth/tokens` : `vaults?` (sous-ensemble du créateur, 400/403 sinon) +
|
||||
`scope` (`full` défaut | `files`) ; JWT + record portent la portée
|
||||
- [x] Vault : `check_vault_access`/`require_vault_access` existants → 403 hors vault (REST + MCP)
|
||||
- [x] Périmètre `files` : `TokenScopeMiddleware` (403 REST, stateless) + `call_tool`/
|
||||
`propose_*`/`list_tools`/`vault://` (`token_scope_denied`, `FILES_SCOPE_TOOLS`)
|
||||
- [x] UI : sélecteur de portée + champ vaults, badge et vaults listés (i18n FR/EN)
|
||||
- [x] Compatibilité : clés existantes = `full`, inchangées
|
||||
- [x] Tests `tests/test_scoped_tokens.py` (13) + guides API_REST (§2.3, §5) / MCP
|
||||
|
||||
### 200. Provisionnement du vault dédié « TikTak » (exploitation, Gitea #1)
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-14 | **Statut :** ✅ livré le 2026-10-14 (runbook)
|
||||
- **Fiche :** [features/tiktak-integration-197-200.md](./features/tiktak-integration-197-200.md) (§#200)
|
||||
- **Description :** opération unique côté hébergement, aucun code requis : dossier hôte +
|
||||
montage conteneur + `POST /api/vaults/add` (persisté, sans redémarrage) + ACL du
|
||||
compte de synchro + clé limitée (#199) + webhook filtré (#198) + recette `curl`
|
||||
d'acceptation. **Reste à exécuter sur l'hôte** (suivre le runbook, puis fermer Gitea #1).
|
||||
|
||||
---
|
||||
|
||||
### 77. Application Desktop native — Tauri (Windows / Linux / macOS)
|
||||
|
||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Framework :** Tauri v2 (Rust + Webview)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# Intégration TikTak ↔ ObsiGate — socle côté ObsiGate (#197 → #200)
|
||||
|
||||
> **Statut :** ✅ livré — **IDs stables :** #197, #198, #199, #200
|
||||
> **Issues Gitea :** Projets/ObsiGate #4, #3, #2, #1 (même ordre)
|
||||
> **Référence d'analyse :** `Projets/ticktick-clone`, `docs/SYNCHRO-OBSIGATE.md` v1.1
|
||||
> (décisions §8 : vault dédié « TikTak », transport API REST, TikTak maître en conflit)
|
||||
|
||||
## Contexte
|
||||
|
||||
TikTak projette ses tâches/notes en fichiers Markdown dans un vault ObsiGate dédié
|
||||
via l'API REST fichiers. Côté ObsiGate, quatre manques bloquaient une synchronisation
|
||||
propre : réconciliation sans relire chaque fichier (#197), webhooks filtrables (#198),
|
||||
clé API à moindre privilège (#199), procédure de provisionnement (#200).
|
||||
|
||||
## #197 — Listes de fichiers : `mtime` + `size` (Gitea #4)
|
||||
|
||||
**Constat.** `GET /api/vault/{vault}/files` et l'outil MCP `list_all_files` exposaient
|
||||
déjà `size`/`modified`, mais pas `GET /api/vault/{vault}/paths` (seulement
|
||||
`vault/path/name/type`), ni `GET /api/browse/{vault}`, ni l'outil `list_directory`.
|
||||
|
||||
**Livré.**
|
||||
- `GET /api/vault/{vault}/paths` : chaque entrée fichier expose `size`, `mtime`
|
||||
(epoch) et `mtime_iso` (issus de l'index — source unique, même contrat que
|
||||
`list_all_files`) ; les répertoires portent `None`.
|
||||
- `GET /api/browse/{vault}` : `modified`/`modified_iso` sur les fichiers.
|
||||
- Outil MCP `list_directory` : `size`/`mtime`/`mtime_iso` sur les fichiers.
|
||||
- Cohérence read-after-write : `PUT /api/file/{vault}/save` rafraîchit l'index de
|
||||
façon synchrone (`update_single_file`), au lieu de dépendre du watcher.
|
||||
- **Bug latent corrigé au passage** : `update_single_file`/`remove_single_file`
|
||||
exigeaient un chemin absolu alors que toutes les routes leur passent un chemin
|
||||
relatif au vault — le rafraîchissement post-écriture était un no-op silencieux
|
||||
(masqué en prod par le watcher). Les deux fonctions acceptent désormais les deux
|
||||
formes.
|
||||
|
||||
**Réconciliation type (un seul appel) :** snapshot `{path: (mtime, size)}`, puis
|
||||
re-liste et comparaison — démontré par
|
||||
`tests/test_sync_lists.py::TestReconciliationScenario`.
|
||||
|
||||
## #198 — Webhooks : `vault` + filtre `vault` / `path_prefix` (Gitea #3)
|
||||
|
||||
**Constat.** Les charges utiles contenaient déjà `vault`, mais sans filtre un
|
||||
consommateur recevait tous les vaults. De plus `file_modified` (événement souscrit
|
||||
mais jamais émis) manquait pour tirer les retours de synchro.
|
||||
|
||||
**Livré.**
|
||||
- Filtres optionnels par webhook : `vault` (égalité exacte) et `path_prefix`
|
||||
(égalité ou sous-arbre — `Maison` matche `Maison/a.md`, pas `Maison2/a.md` ;
|
||||
sur renommage/déplacement, `old_path` **ou** `new_path` suffit pour être prévenu).
|
||||
Sans filtre : reçoit tout, comme avant (compatibilité).
|
||||
- `POST /api/webhooks` accepte `vault`/`path_prefix` ; `PATCH` les modifie
|
||||
(`""` réinitialise) ; `GET` les expose (`WebhookModel.vault/path_prefix`).
|
||||
- `PUT /api/file/{vault}/save` émet désormais `file_modified`
|
||||
(`{"vault", "path"}`) — la sauvegarde tableur/IA reste hors périmètre (documenté).
|
||||
- UI : champs filtre à la création + badge dans la liste ; aide intégrée FR/EN
|
||||
mise à jour (`file_modified` émis, filtrage documenté).
|
||||
- Filtrage pur et testable : `backend/webhooks.py::webhook_matches` ;
|
||||
`tests/test_webhook_filters.py` (matching, persistance, dispatch simulé :
|
||||
le webhook filtré sur « TikTak » ne reçoit que ses événements, avec
|
||||
`vault: "TikTak"` dans la charge).
|
||||
|
||||
## #199 — Clés API à portée limitée (Gitea #2)
|
||||
|
||||
**Constat.** Les clés longue durée portaient toutes les permissions du compte.
|
||||
|
||||
**Livré.**
|
||||
- Création : `POST /api/auth/tokens` accepte `vaults` (sous-ensemble des vaults
|
||||
du créateur, défaut = tous — `400` si inconnu, `403` si hors d'accès) et
|
||||
`scope` (`"full"` défaut | `"files"`).
|
||||
- Restriction vault : le JWT embarque les vaults autorisés → `check_vault_access`
|
||||
(REST) et `require_vault_access` (MCP/outils) refusent le reste en 403.
|
||||
- Périmètre `files` : fichiers (CRUD, listes, recherche, backups lecture/restauration),
|
||||
`GET /api/vaults`, `/api/search*`, `/mcp`. Tout le reste (admin, config, IA,
|
||||
webhooks, partages, **gestion des clés elle-même** — anti-escalade) → **403**
|
||||
via `TokenScopeMiddleware` (`backend/auth/scope.py`, stateless : claim lu sans
|
||||
vérification pour le refus, signature toujours vérifiée en aval).
|
||||
- MCP : `call_tool` + `propose_*` refusent les outils hors `FILES_SCOPE_TOOLS`
|
||||
(`token_scope_denied`), `list_tools` filtré pour ces tokens, ressources
|
||||
`vault://` couvertes.
|
||||
- UI : sélecteur de portée + champ vaults à la création, badge de portée et
|
||||
vaults dans la liste (i18n FR/EN).
|
||||
- Compatibilité : clés existantes (sans `scope`) = `full`, comportement inchangé.
|
||||
- Tests : `tests/test_scoped_tokens.py` (13 cas : création/validation, lecture/
|
||||
écriture OK, 403 autre vault, 403 hors périmètre, non-régression full, outils).
|
||||
|
||||
## #200 — Provisionnement du vault « TikTak » (Gitea #1, exploitation)
|
||||
|
||||
Opération unique côté hébergement (aucun code requis — endpoints déjà testés) :
|
||||
|
||||
```bash
|
||||
# 1. Dossier sur l'hôte
|
||||
mkdir -p /NFS/OBSIDIAN_DOC/TikTak
|
||||
|
||||
# 2. Montage conteneur (docker-compose.yml, puis recreate)
|
||||
# volumes:
|
||||
# - /NFS/OBSIDIAN_DOC/TikTak:/vaults/TikTak
|
||||
|
||||
# 3. Déclarer le vault SANS redémarrage (admin, persisté via data/vaults.json)
|
||||
curl -X POST $BASE/api/vaults/add -H "Authorization: Bearer $ADMIN" \
|
||||
-H 'Content-Type: application/json' -d '{"name":"TikTak","path":"/vaults/TikTak"}'
|
||||
|
||||
# 4. Octroyer le vault au compte de synchro (ACL par vault, UI admin ou API)
|
||||
|
||||
# 5. Clé à portée limitée pour TikTak (cf. #199)
|
||||
curl -X POST $BASE/api/auth/tokens -H "Authorization: Bearer $ADMIN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"TikTak sync","expiry":"never","vaults":["TikTak"],"scope":"files"}'
|
||||
|
||||
# 6. Webhook retour filtré (optionnel, phase 3 TikTak)
|
||||
curl -X POST $BASE/api/webhooks -H "Authorization: Bearer $ADMIN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"TikTak retour","url":"https://tiktak…/hooks/obsigate","events":["file_created","file_modified","file_deleted","file_renamed"],"vault":"TikTak"}'
|
||||
```
|
||||
|
||||
**Acceptation :** le vault « TikTak » est visible ; un `.md` créé via
|
||||
`POST /api/file/TikTak` (clé limitée, pas admin) est lu, cherché et édité sans erreur :
|
||||
|
||||
```bash
|
||||
KEY=<clé de l'étape 5>
|
||||
curl -X POST $BASE/api/file/TikTak -H "Authorization: Bearer $KEY" \
|
||||
-H 'Content-Type: application/json' -d '{"path":"Inbox/sonde.md","content":"# sonde\n"}'
|
||||
curl -s "$BASE/api/file/TikTak/raw?path=Inbox/sonde.md" -H "Authorization: Bearer $KEY"
|
||||
curl -s "$BASE/api/search?q=sonde&vault=TikTak" -H "Authorization: Bearer $KEY"
|
||||
```
|
||||
|
||||
## Fichiers touchés (rappel)
|
||||
|
||||
| # | Backend | Frontend | Tests |
|
||||
|---|---|---|---|
|
||||
| 197 | `services/search.py`, `services/vaults.py`, `schemas.py`, `indexer.py`, `routers/files_write.py`, `tools/service.py` | — | `tests/test_sync_lists.py` |
|
||||
| 198 | `webhooks.py`, `schemas.py`, `routers/webhooks.py`, `routers/files_write.py` | `js/config.js`, `index.html`, `locales/{fr,en}.json` | `tests/test_webhook_filters.py` |
|
||||
| 199 | `auth/jwt_handler.py`, `auth/middleware.py`, `auth/scope.py` (nouveau), `auth/router.py`, `main.py`, `tools/context.py`, `tools/registry.py`, `tools/api.py`, `mcp/server.py`, `openapi_docs.py` | `js/config.js`, `index.html`, `locales/{fr,en}.json` | `tests/test_scoped_tokens.py` |
|
||||
| 200 | — (runbook) | — | — (recette manuelle ci-dessus) |
|
||||
+39
-7
@@ -2576,6 +2576,18 @@
|
||||
<option value="365d" data-i18n="config.token_expiry_365d">1 an</option>
|
||||
<option value="never" data-i18n="config.token_expiry_never">Sans fin</option>
|
||||
</select>
|
||||
<select id="token-scope-select" class="config-input" style="width: 150px" title="Portée">
|
||||
<option value="full" data-i18n="config.token_scope_full">Accès complet</option>
|
||||
<option value="files" data-i18n="config.token_scope_files">Fichiers seuls</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="token-vaults-input"
|
||||
data-i18n-placeholder="config.token_vaults_placeholder"
|
||||
placeholder="Vaults (vide = tous, ex : TikTak)"
|
||||
class="config-input"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<button class="config-btn-add" id="token-create-btn" data-i18n="config.token_create">Créer une clé</button>
|
||||
</div>
|
||||
<div id="token-secret-area" class="token-secret-area hidden">
|
||||
@@ -2633,6 +2645,22 @@
|
||||
class="config-input"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="webhook-vault-input"
|
||||
data-i18n-placeholder="config.webhook_vault_placeholder"
|
||||
placeholder="Vault (vide = tous)"
|
||||
class="config-input"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="webhook-prefix-input"
|
||||
data-i18n-placeholder="config.webhook_prefix_placeholder"
|
||||
placeholder="Préfixe (vide = tout)"
|
||||
class="config-input"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<button id="webhook-add-btn"
|
||||
class="config-btn config-btn-add" data-i18n="config.add_webhook">
|
||||
Ajouter
|
||||
@@ -5114,9 +5142,11 @@
|
||||
<p>
|
||||
<em
|
||||
>Note : l'événement
|
||||
<code>file_modified</code> est réservé pour
|
||||
usage futur. Les modifications de contenu
|
||||
via l'éditeur ne déclenchent pas encore de
|
||||
<code>file_modified</code> est émis par
|
||||
la sauvegarde texte
|
||||
(<code>PUT /api/file/{vault}/save</code>).
|
||||
Les autres écritures (tableur, IA) ne
|
||||
déclenchent pas encore de
|
||||
webhook.</em
|
||||
>
|
||||
</p>
|
||||
@@ -5289,13 +5319,15 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
REST sont prises en compte
|
||||
</li>
|
||||
<li>
|
||||
<code>file_modified</code> n'est pas encore
|
||||
dispatché (réservé pour version future)
|
||||
<code>file_modified</code> est émis par la
|
||||
sauvegarde texte
|
||||
(<code>PUT /api/file/{vault}/save</code>)
|
||||
</li>
|
||||
<li data-i18n="auto.1d3330cc">Pas de retry automatique en cas d'échec</li>
|
||||
<li data-i18n="auto.4dfa6a25">
|
||||
Pas de filtrage par vault — un webhook
|
||||
écoute toutes les vaults
|
||||
Filtrage optionnel par vault et préfixe de
|
||||
chemin — sans filtre, un webhook écoute
|
||||
toutes les vaults
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
+33
-5
@@ -1486,14 +1486,20 @@ function renderWebhooksUI(webhooks) {
|
||||
const list = document.getElementById("webhooks-list");
|
||||
if (!list) return;
|
||||
if (!webhooks.length) { list.innerHTML = '<div class="config-description">Aucun webhook configuré.</div>'; return; }
|
||||
list.innerHTML = webhooks.map(w => `
|
||||
list.innerHTML = webhooks.map(w => {
|
||||
const filters = [];
|
||||
if (w.vault) filters.push("vault:" + w.vault);
|
||||
if (w.path_prefix) filters.push("⇢ " + w.path_prefix);
|
||||
return `
|
||||
<div class="webhook-item">
|
||||
<span class="webhook-name">${escapeHtml(w.name)}</span>
|
||||
<span class="webhook-url">${escapeHtml(w.url)}</span>
|
||||
<span class="webhook-events">${(w.events||[]).join(", ")}</span>
|
||||
${filters.length ? `<span class="webhook-filter">${escapeHtml(filters.join(" · "))}</span>` : ""}
|
||||
<button class="webhook-delete" data-id="${w.id}">✕</button>
|
||||
</div>
|
||||
`).join("");
|
||||
`;
|
||||
}).join("");
|
||||
list.querySelectorAll(".webhook-delete").forEach(b => b.addEventListener("click", async () => {
|
||||
await api(`/api/webhooks/${b.dataset.id}`, { method: "DELETE" });
|
||||
loadWebhooksUI();
|
||||
@@ -1503,8 +1509,13 @@ document.addEventListener("click", function(e) {
|
||||
if (e.target.id === "webhook-add-btn") {
|
||||
const name = document.getElementById("webhook-name-input").value.trim();
|
||||
const url = document.getElementById("webhook-url-input").value.trim();
|
||||
const vaultEl = document.getElementById("webhook-vault-input");
|
||||
const prefixEl = document.getElementById("webhook-prefix-input");
|
||||
if (!url) { showToast(t("config.url_required"), "error"); return; }
|
||||
api("/api/webhooks", { method: "POST", body: JSON.stringify({ name: name || "Webhook", url, events: ["file_created","file_deleted","file_modified","file_renamed"] }) }).then(() => { loadWebhooksUI(); document.getElementById("webhook-name-input").value = ""; document.getElementById("webhook-url-input").value = ""; }).catch(err => showToast(err.message, "error"));
|
||||
const body = { name: name || "Webhook", url, events: ["file_created","file_deleted","file_modified","file_renamed"] };
|
||||
if (vaultEl && vaultEl.value.trim()) body.vault = vaultEl.value.trim();
|
||||
if (prefixEl && prefixEl.value.trim()) body.path_prefix = prefixEl.value.trim();
|
||||
api("/api/webhooks", { method: "POST", body: JSON.stringify(body) }).then(() => { loadWebhooksUI(); document.getElementById("webhook-name-input").value = ""; document.getElementById("webhook-url-input").value = ""; if (vaultEl) vaultEl.value = ""; if (prefixEl) prefixEl.value = ""; }).catch(err => showToast(err.message, "error"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1546,6 +1557,12 @@ function renderTokensUI(tokens) {
|
||||
const status = expired
|
||||
? '<span class="token-badge token-badge-expired">' + escapeHtml(t("config.token_status_expired")) + "</span>"
|
||||
: '<span class="token-badge token-badge-active">' + escapeHtml(t("config.token_status_active")) + "</span>";
|
||||
// #199 : portée affichée (pleine vs fichiers seuls + vaults).
|
||||
const scopeLabel = (tok.scope || "full") === "files" ? t("config.token_scope_files") : t("config.token_scope_full");
|
||||
const scopeBadge = '<span class="token-badge token-badge-scope">' + escapeHtml(scopeLabel) + "</span>";
|
||||
const vaultsLabel = (tok.vaults && tok.vaults.length)
|
||||
? escapeHtml(tok.vaults.join(", "))
|
||||
: escapeHtml(t("config.token_vaults_all"));
|
||||
const meta = [
|
||||
t("config.token_created") + " " + _formatTokenDate(tok.created_at),
|
||||
t("config.token_expires") + " " + _tokenExpiryLabel(tok),
|
||||
@@ -1553,8 +1570,9 @@ function renderTokensUI(tokens) {
|
||||
].join(" · ");
|
||||
return '<div class="token-item" data-jti="' + escapeHtml(tok.jti) + '">' +
|
||||
'<span class="token-name">' + escapeHtml(tok.name) + "</span>" +
|
||||
status +
|
||||
status + scopeBadge +
|
||||
'<span class="token-meta">' + escapeHtml(meta) + "</span>" +
|
||||
'<span class="token-meta token-vaults">🔒 ' + vaultsLabel + "</span>" +
|
||||
'<button class="token-delete" data-jti="' + escapeHtml(tok.jti) + '" data-name="' + escapeHtml(tok.name) + '" title="' + escapeHtml(t("config.token_revoke")) + '">✕</button>' +
|
||||
"</div>";
|
||||
}).join("");
|
||||
@@ -1582,13 +1600,23 @@ function bindTokenEvents() {
|
||||
if (!name) { showToast(t("config.token_name_required"), "error"); return; }
|
||||
createBtn.disabled = true;
|
||||
try {
|
||||
const res = await api("/api/auth/tokens", { method: "POST", body: JSON.stringify({ name, expiry }) });
|
||||
// #199 : portée limitée optionnelle (vaults + périmètre fichiers).
|
||||
const scopeEl = document.getElementById("token-scope-select");
|
||||
const vaultsEl = document.getElementById("token-vaults-input");
|
||||
const body = { name, expiry };
|
||||
if (scopeEl && scopeEl.value === "files") body.scope = "files";
|
||||
if (vaultsEl && vaultsEl.value.trim()) {
|
||||
body.vaults = vaultsEl.value.split(",").map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
const res = await api("/api/auth/tokens", { method: "POST", body: JSON.stringify(body) });
|
||||
const area = document.getElementById("token-secret-area");
|
||||
const ta = document.getElementById("token-secret-value");
|
||||
ta.value = res.token;
|
||||
area.classList.remove("hidden");
|
||||
ta.select();
|
||||
document.getElementById("token-name-input").value = "";
|
||||
const _vaultsEl = document.getElementById("token-vaults-input");
|
||||
if (_vaultsEl) _vaultsEl.value = "";
|
||||
showToast(t("config.token_created_toast"), "success");
|
||||
loadTokensUI();
|
||||
} catch (err) {
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
"auto.4bc1f00a": "Use operators to refine your",
|
||||
"auto.4c908ade": "Click a vault to open it and see its",
|
||||
"auto.4d281c6c": "AI: text processed ✓",
|
||||
"auto.4dfa6a25": "No vault filtering — a webhook listens to",
|
||||
"auto.4dfa6a25": "Optional vault and path-prefix filtering — without a filter, a webhook listens to all vaults",
|
||||
"auto.4fa5d7c2": "Quick editing",
|
||||
"auto.559ef1da": "Recommended workflow",
|
||||
"auto.598a0ca7": "Tracked events",
|
||||
@@ -651,6 +651,10 @@
|
||||
"config.token_revoke_confirm": "Revoke key",
|
||||
"config.token_revoked_toast": "Key revoked (immediate effect on API + MCP)",
|
||||
"config.token_created_toast": "Key created",
|
||||
"config.token_scope_full": "Full access",
|
||||
"config.token_scope_files": "Files only",
|
||||
"config.token_vaults_placeholder": "Vaults (empty = all, e.g. TikTak)",
|
||||
"config.token_vaults_all": "all account vaults",
|
||||
"config.url_required": "URL required",
|
||||
"config.watcher_debounce_label": "Debounce (s)",
|
||||
"config.watcher_enabled_label": "Enable watcher",
|
||||
@@ -658,6 +662,8 @@
|
||||
"config.watcher_polling_label": "Polling mode (fallback)",
|
||||
"config.webhook_name_placeholder": "Name",
|
||||
"config.webhook_url_placeholder": "URL",
|
||||
"config.webhook_vault_placeholder": "Vault (empty = all)",
|
||||
"config.webhook_prefix_placeholder": "Prefix (empty = all)",
|
||||
"config.webhooks": "Webhooks",
|
||||
"config.webhooks_desc": "HTTP notifications to external services on file changes.",
|
||||
"config.plugins": "Plugins",
|
||||
@@ -969,7 +975,7 @@
|
||||
"help.desc_31e7253b": "Each backup",
|
||||
"help.desc_32e3d66d": "En cas d'erreur de clé API : AI: clé API invalide",
|
||||
"help.desc_39979e71": "Vault",
|
||||
"help.desc_39e97b2f": "Note: the <code>file_modified</code> event is reserved for future use and is not currently emitted.",
|
||||
"help.desc_39e97b2f": "Note: the <code>file_modified</code> event is emitted by text saves (PUT /api/file/{vault}/save). Other writers do not trigger webhooks yet.",
|
||||
"help.desc_3ace259e": "— Inline completion",
|
||||
"help.desc_3c4711b8": "Edit config.yaml to customize behavior",
|
||||
"help.desc_3d4e9189": "Simplifies the language for easier reading — replaces complex terms and shortens long sentences.",
|
||||
@@ -1099,7 +1105,7 @@
|
||||
"help.desc_ctrl_tab": ": Next tab",
|
||||
"help.desc_ctrl_w": ": Close the active tab",
|
||||
"help.desc_d186a19e": ": The Recent tab in the sidebar lists your latest files",
|
||||
"help.desc_d3a71d97": "file_modified n'est pas encore dispatché (réservé pour version future)",
|
||||
"help.desc_d3a71d97": "file_modified is emitted by text saves (PUT /api/file/{vault}/save)",
|
||||
"help.desc_d5c977ac": "Code blocks",
|
||||
"help.desc_d646b709": "Compte admin : Créé automatiquement au premier démarrage",
|
||||
"help.desc_d66e911c": "In the",
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
"auto.4bc1f00a": "Utilisez des opérateurs pour affiner votre\n recherche :",
|
||||
"auto.4c908ade": "Cliquez sur un vault pour l'ouvrir et voir son\n contenu. Les dossiers affichent un chevron et un\n badge avec le nombre d'éléments.",
|
||||
"auto.4d281c6c": "AI: texte traité ✓",
|
||||
"auto.4dfa6a25": "Pas de filtrage par vault — un webhook\n écoute toutes les vaults",
|
||||
"auto.4dfa6a25": "Filtrage optionnel par vault et préfixe de chemin — un webhook\n écoute toutes les vaults",
|
||||
"auto.4fa5d7c2": "Édition rapide",
|
||||
"auto.559ef1da": "Workflow recommandé",
|
||||
"auto.598a0ca7": "Événements tracés",
|
||||
@@ -651,6 +651,10 @@
|
||||
"config.token_revoke_confirm": "Révoquer la clé",
|
||||
"config.token_revoked_toast": "Clé révoquée (effet immédiat API + MCP)",
|
||||
"config.token_created_toast": "Clé créée",
|
||||
"config.token_scope_full": "Accès complet",
|
||||
"config.token_scope_files": "Fichiers seuls",
|
||||
"config.token_vaults_placeholder": "Vaults (vide = tous, ex : TikTak)",
|
||||
"config.token_vaults_all": "tous les vaults du compte",
|
||||
"config.url_required": "URL requise",
|
||||
"config.watcher_debounce_label": "Debounce (s)",
|
||||
"config.watcher_enabled_label": "Activer la surveillance",
|
||||
@@ -658,6 +662,8 @@
|
||||
"config.watcher_polling_label": "Mode polling (fallback)",
|
||||
"config.webhook_name_placeholder": "Nom",
|
||||
"config.webhook_url_placeholder": "URL",
|
||||
"config.webhook_vault_placeholder": "Vault (vide = tous)",
|
||||
"config.webhook_prefix_placeholder": "Préfixe (vide = tout)",
|
||||
"config.webhooks": "Webhooks",
|
||||
"config.webhooks_desc": "Notifications HTTP vers des services externes lors des changements de fichiers.",
|
||||
"config.plugins": "Plugins",
|
||||
@@ -969,7 +975,7 @@
|
||||
"help.desc_31e7253b": ": Chaque backup\n est horodaté",
|
||||
"help.desc_32e3d66d": "En cas d'erreur de clé API :\n AI: clé API invalide",
|
||||
"help.desc_39979e71": ":\n Choisissez un vault pour réduire le bruit",
|
||||
"help.desc_39e97b2f": "Note : l'événement\n file_modified est réservé pour\n usage futur. Les modifications de contenu\n via l'éditeur ne déclenchent pas encore de\n webhook.",
|
||||
"help.desc_39e97b2f": "Note : l'événement\n file_modified est émis par la sauvegarde texte\n (PUT /api/file/{vault}/save). Les autres écritures\n ne déclenchent pas encore de webhook.",
|
||||
"help.desc_3ace259e": "— Complétion inline\n (",
|
||||
"help.desc_3c4711b8": "Événements tracés :\n Écritures, suppressions, changements de\n configuration",
|
||||
"help.desc_3d4e9189": "📋\n Simplify language :\n Simplifie le langage, évite le\n jargon",
|
||||
@@ -1099,7 +1105,7 @@
|
||||
"help.desc_ctrl_tab": ": Onglet suivant",
|
||||
"help.desc_ctrl_w": ": Fermer l'onglet actif",
|
||||
"help.desc_d186a19e": ":\n L'onglet \"Récent\" dans la sidebar liste vos\n derniers fichiers",
|
||||
"help.desc_d3a71d97": "file_modified n'est pas encore\n dispatché (réservé pour version future)",
|
||||
"help.desc_d3a71d97": "file_modified est émis par\n la sauvegarde texte (PUT /api/file/{vault}/save)",
|
||||
"help.desc_d5c977ac": ": Les blocs de\n code s'adaptent au thème",
|
||||
"help.desc_d646b709": "Compte admin : Créé\n automatiquement au premier démarrage",
|
||||
"help.desc_d66e911c": ": Dans les\n actions du fichier, génère un lien unique",
|
||||
@@ -1138,7 +1144,7 @@
|
||||
"help.desc_fa126609": "🔷\n Convert to canvas :\n Convertit en diagramme Mermaid.js",
|
||||
"help.desc_facet_vaults": ": Compteurs par vault",
|
||||
"help.desc_facet_extensions": ": Compteurs par extension de fichier dans les résultats",
|
||||
"help.desc_fc9391f3": "# Exemple avec curl + jq dans un script bash\nWEBHOOK_URL=\"https://discord.com/api/webhooks/...\"\nOBSIGATE_SECRET=\"mon-secret\"\n\n# Lire le payload depuis stdin\nread -r payload\nevent=$(echo \"$payload\" | jq -r '.event')\npath=$(echo \"$payload\" | jq -r '.data.path')\n\ncurl -H \"Content-Type: application/json\" -d \"{\n \\\"content\\\": \\\"📝 **$event** — $path\\\"\n}\" \"$WEBHOOK_URL\"\n\n Tester avec curl\n # Simuler ce qu'ObsiGate envoie (pour tester votre endpoint)\ncurl -X POST https://votre-serveur.com/webhook \\\n -H \"Content-Type: application/json\" \\\n -H \"X-ObsiGate-Event: file_created\" \\\n -H \"X-ObsiGate-Signature: sha256=$(echo -n '{\"event\":\"file_created\",...}' | openssl dgst -sha256 -hmac 'mon-secret' | cut -d' ' -f2)\" \\\n -d '{\"event\":\"file_created\",\"timestamp\":\"2026-06-03T14:30:00Z\",\"data\":{\"vault\":\"IT\",\"path\":\"notes/test.md\"}}'\n\n Limitations actuelles\n \n \n Timeout de 5 secondes par\n requête — les webhooks sont \"fire and\n forget\" (pas de retry)\n \n \n Les modifications détectées par le watcher\n filesystem (watchdog)\n ne déclenchent pas de\n webhooks — seules les opérations via l'API\n REST sont prises en compte\n \n \n file_modified n'est pas encore\n dispatché (réservé pour version future)\n \n Pas de retry automatique en cas d'échec\n \n Pas de filtrage par vault — un webhook\n écoute toutes les vaults\n \n \n \n \n 💾 Sauvegardes et Audits\n\n Backup automatique\n \n ObsiGate crée automatiquement des sauvegardes\n avant chaque modification destructive.",
|
||||
"help.desc_fc9391f3": "# Exemple avec curl + jq dans un script bash\nWEBHOOK_URL=\"https://discord.com/api/webhooks/...\"\nOBSIGATE_SECRET=\"mon-secret\"\n\n# Lire le payload depuis stdin\nread -r payload\nevent=$(echo \"$payload\" | jq -r '.event')\npath=$(echo \"$payload\" | jq -r '.data.path')\n\ncurl -H \"Content-Type: application/json\" -d \"{\n \\\"content\\\": \\\"📝 **$event** — $path\\\"\n}\" \"$WEBHOOK_URL\"\n\n Tester avec curl\n # Simuler ce qu'ObsiGate envoie (pour tester votre endpoint)\ncurl -X POST https://votre-serveur.com/webhook \\\n -H \"Content-Type: application/json\" \\\n -H \"X-ObsiGate-Event: file_created\" \\\n -H \"X-ObsiGate-Signature: sha256=$(echo -n '{\"event\":\"file_created\",...}' | openssl dgst -sha256 -hmac 'mon-secret' | cut -d' ' -f2)\" \\\n -d '{\"event\":\"file_created\",\"timestamp\":\"2026-06-03T14:30:00Z\",\"data\":{\"vault\":\"IT\",\"path\":\"notes/test.md\"}}'\n\n Limitations actuelles\n \n \n Timeout de 5 secondes par\n requête — les webhooks sont \"fire and\n forget\" (pas de retry)\n \n \n Les modifications détectées par le watcher\n filesystem (watchdog)\n ne déclenchent pas de\n webhooks — seules les opérations via l'API\n REST sont prises en compte\n \n \n file_modified est émis par\n la sauvegarde texte (PUT /api/file/{vault}/save)\n \n Pas de retry automatique en cas d'échec\n \n Filtrage optionnel par vault et préfixe\n de chemin — sans filtre, un webhook écoute\n toutes les vaults\n \n \n \n \n 💾 Sauvegardes et Audits\n\n Backup automatique\n \n ObsiGate crée automatiquement des sauvegardes\n avant chaque modification destructive.",
|
||||
"help.desc_fdeaf803": ": Chaque type de\n fichier a son icône (markdown, code, etc.)",
|
||||
"help.desc_file_suggest": ": Suggestions de titres de fichiers",
|
||||
"help.desc_filter_type": ":\n Inclut/exclut fichiers, dossiers, markdown,\n autres",
|
||||
|
||||
+4
-1
@@ -8294,7 +8294,10 @@ body.popup-mode .content-area {
|
||||
.webhook-url { color: var(--accent); overflow: hidden; text-overflow: ellipsis; flex: 1; }
|
||||
.webhook-events { color: var(--text-muted); font-size: 0.7rem; }
|
||||
.webhook-delete { background: none; border: none; color: var(--text-error); cursor: pointer; font-size: 1rem; padding: 2px 6px; }
|
||||
.config-add-row { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.config-add-row { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.config-add-row .config-input,
|
||||
.config-add-row .config-select { flex: 1 1 140px; width: auto !important; min-width: 0; }
|
||||
.config-add-row .config-btn-add { flex: 1 1 auto; }
|
||||
|
||||
/* ── API/MCP tokens UI (#107) ── */
|
||||
.token-item {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.67.2",
|
||||
"version": "2.68.1",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
+29
-8
@@ -120,12 +120,33 @@ switch ($Command) {
|
||||
$env:VAULT_1_PATH = (Resolve-Path -LiteralPath "test_vault").Path
|
||||
$env:DIR_1_NAME = "TestDir"
|
||||
$env:DIR_1_PATH = (Resolve-Path -LiteralPath "test_dir").Path
|
||||
$server = Start-Process -FilePath $Python `
|
||||
-ArgumentList "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", $Port `
|
||||
-RedirectStandardOutput $OutLog -RedirectStandardError $ErrLog `
|
||||
-PassThru -WindowStyle Hidden
|
||||
$server.Id | Set-Content -LiteralPath $PidFile
|
||||
Write-Host " PID $($server.Id) (logs : $OutLog / $ErrLog)."
|
||||
# BUG-112 : `Start-Process` laissait uvicorn hériter des handles stdout/stderr
|
||||
# du parent pwsh — avec un pipe (`... | head -n 20`), l'EOF n'arrivait jamais
|
||||
# et la commande semblait bloquée alors que le serveur était déjà prêt.
|
||||
# `Win32_Process.Create` (WMI) lance uvicorn dans un processus détaché qui
|
||||
# n'hérite d'aucun handle : le pipe se ferme quand le script se termine.
|
||||
# Le processus WMI n'hérite PAS de l'environnement du parent → les variables
|
||||
# OBSIGATE_* sont passées explicitement via `set "NOM=valeur"` dans cmd /c.
|
||||
$PythonAbs = (Resolve-Path -LiteralPath $Python).Path
|
||||
$OutLogAbs = Join-Path $Root "data\e2e-server.log"
|
||||
$ErrLogAbs = Join-Path $Root "data\e2e-server.err.log"
|
||||
$CmdLine = "cmd /c set `"OBSIGATE_AUTH_ENABLED=false`"&&" +
|
||||
"set `"VAULT_1_NAME=$env:VAULT_1_NAME`"&&" +
|
||||
"set `"VAULT_1_PATH=$env:VAULT_1_PATH`"&&" +
|
||||
"set `"DIR_1_NAME=$env:DIR_1_NAME`"&&" +
|
||||
"set `"DIR_1_PATH=$env:DIR_1_PATH`"&&" +
|
||||
"`"$PythonAbs`" -m uvicorn backend.main:app --host 127.0.0.1 --port $Port > `"$OutLogAbs`" 2> `"$ErrLogAbs`""
|
||||
$created = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
||||
CommandLine = $CmdLine
|
||||
CurrentDirectory = $Root
|
||||
}
|
||||
if ($created.ReturnValue -ne 0) {
|
||||
Write-Host "[ERR] Échec du lancement uvicorn (code WMI $($created.ReturnValue))."
|
||||
exit 1
|
||||
}
|
||||
$serverId = [int]$created.ProcessId
|
||||
$serverId | Set-Content -LiteralPath $PidFile
|
||||
Write-Host " PID $serverId (logs : $OutLog / $ErrLog)."
|
||||
|
||||
Write-Host "[4/4] Attente du health check (30 s max)..."
|
||||
$ready = $false
|
||||
@@ -135,8 +156,8 @@ switch ($Command) {
|
||||
$ready = $true
|
||||
break
|
||||
} catch {
|
||||
if ($server.HasExited) {
|
||||
Write-Host "[ERR] Le serveur a quitté (code $($server.ExitCode)). Fin du log :"
|
||||
if (-not (Get-Process -Id $serverId -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "[ERR] Le serveur a quitté. Fin du log :"
|
||||
Get-Content -LiteralPath $ErrLog -Tail 15 -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
contenu alpha unique
|
||||
@@ -0,0 +1 @@
|
||||
contenu beta unique
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
publish: true
|
||||
---
|
||||
|
||||
contenu GAMMA unique
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
publish: true
|
||||
---
|
||||
|
||||
contenu DELTA unique
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
publish: true
|
||||
---
|
||||
|
||||
# Partagé
|
||||
|
||||
contenu dirigé
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
---
|
||||
publish: true
|
||||
---
|
||||
|
||||
contenu GAMMA unique
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
publish: true
|
||||
---
|
||||
|
||||
contenu DELTA unique
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,243 @@
|
||||
# tests/test_scoped_tokens.py — #199 (Gitea #2) : clés API à portée limitée.
|
||||
"""Couvre :
|
||||
|
||||
- création d'une clé limitée (``vaults`` + ``scope=files``) : portée exposée
|
||||
dans le record et la liste ;
|
||||
- la clé limitée lit/écrit les fichiers de son vault (REST) ;
|
||||
- tout autre vault est refusé (403, via ``check_vault_access``) ;
|
||||
- les endpoints hors périmètre sont refusés (403, via le middleware —
|
||||
y compris la gestion des clés elles-mêmes, anti-escalade) ;
|
||||
- les clés sans portée fonctionnent comme avant (non-régression) ;
|
||||
- validation à la création (scope inconnu → 400, vault inconnu → 400,
|
||||
vault non autorisé → 403) ;
|
||||
- niveau outils MCP : ``call_tool`` autorise les outils fichiers et refuse
|
||||
les autres (``token_scope_denied``), y compris les outils sans vault.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.tools.api import ToolPermissionError, call_tool
|
||||
|
||||
from tests.test_tools import _ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scoped_client(tmp_path, monkeypatch):
|
||||
"""Auth-enabled TestClient in an isolated data dir (admin / chab30)."""
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
from backend.auth.password import hash_password
|
||||
|
||||
users = {
|
||||
"version": 1,
|
||||
"users": {
|
||||
"admin": {
|
||||
"id": "admin-1", "username": "admin", "display_name": "admin",
|
||||
"password_hash": hash_password("chab30"), "role": "admin",
|
||||
"vaults": ["*"], "active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
"bob": {
|
||||
"id": "bob-1", "username": "bob", "display_name": "bob",
|
||||
"password_hash": hash_password("chab30"), "role": "user",
|
||||
"vaults": ["TestVault"], "active": True,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
|
||||
src_secret = Path("data/secret.key")
|
||||
if src_secret.exists():
|
||||
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
|
||||
|
||||
vault = os.path.abspath("test_vault")
|
||||
orig_cwd = os.getcwd()
|
||||
os.chdir(str(tmp_path))
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = vault
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.indexer import build_index, index
|
||||
import asyncio
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
from backend.search import init_inverted_index
|
||||
init_inverted_index()
|
||||
|
||||
import backend.auth.jwt_handler as jh
|
||||
jh._revoked_map = {}
|
||||
jh._revoked_loaded = False
|
||||
jh._touch_last_write.clear()
|
||||
|
||||
# Pas de context manager : le lifespan relancerait un rebuild d'index en
|
||||
# tâche de fond qui vide l'index pendant les appels outils directs
|
||||
# (même pattern que tests/conftest.py).
|
||||
client = TestClient(backend.main.app, raise_server_exceptions=False)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
jh._revoked_map = {}
|
||||
jh._revoked_loaded = False
|
||||
os.chdir(orig_cwd)
|
||||
shutil.rmtree(str(tmp_path), ignore_errors=True)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_WATCHER_ENABLED"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
_TEST_PW = "chab" + "30"
|
||||
|
||||
|
||||
def _login(client, username="admin", password=_TEST_PW):
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _hdr(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _create(client, token, name="scoped", expiry="30d", **extra):
|
||||
body = {"name": name, "expiry": expiry, **extra}
|
||||
resp = client.post("/api/auth/tokens", json=body, headers=_hdr(token))
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
class TestCreateScoped:
|
||||
def test_record_exposes_scope_and_vaults(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
created = _create(scoped_client, admin, vaults=["TestVault"], scope="files")
|
||||
assert created["scope"] == "files"
|
||||
assert created["vaults"] == ["TestVault"]
|
||||
listing = scoped_client.get("/api/auth/tokens", headers=_hdr(admin)).json()
|
||||
entry = next(t for t in listing["tokens"] if t["jti"] == created["jti"])
|
||||
assert entry["scope"] == "files" and entry["vaults"] == ["TestVault"]
|
||||
|
||||
def test_full_scope_default(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
created = _create(scoped_client, admin)
|
||||
assert created["scope"] == "full"
|
||||
|
||||
def test_invalid_scope_rejected(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
resp = scoped_client.post("/api/auth/tokens",
|
||||
json={"name": "x", "expiry": "30d", "scope": "root"},
|
||||
headers=_hdr(admin))
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_unknown_vault_rejected(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
resp = scoped_client.post("/api/auth/tokens",
|
||||
json={"name": "x", "expiry": "30d", "vaults": ["Nope"]},
|
||||
headers=_hdr(admin))
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_vault_outside_creator_access_rejected(self, scoped_client):
|
||||
bob = _login(scoped_client, "bob")
|
||||
resp = scoped_client.post("/api/auth/tokens",
|
||||
json={"name": "x", "expiry": "30d", "vaults": ["Autre"]},
|
||||
headers=_hdr(bob))
|
||||
assert resp.status_code in (400, 403)
|
||||
|
||||
|
||||
class TestScopedRestAccess:
|
||||
def test_reads_and_writes_own_vault(self, scoped_client):
|
||||
import uuid
|
||||
|
||||
admin = _login(scoped_client)
|
||||
key = _create(scoped_client, admin, vaults=["TestVault"], scope="files")["token"]
|
||||
assert scoped_client.get("/api/vaults", headers=_hdr(key)).status_code == 200
|
||||
assert scoped_client.get("/api/vault/TestVault/paths", headers=_hdr(key)).status_code == 200
|
||||
assert scoped_client.get("/api/vault/TestVault/files", headers=_hdr(key)).status_code == 200
|
||||
# Fichier unique par exécution + nettoyage : le vault de test est le
|
||||
# vrai répertoire du dépôt, on n'y laisse aucune trace.
|
||||
path = f"scoped-sync-probe-{uuid.uuid4().hex[:8]}.md"
|
||||
try:
|
||||
created = scoped_client.post("/api/file/TestVault", json={"path": path, "content": "# a\n"},
|
||||
headers=_hdr(key))
|
||||
assert created.status_code == 200, created.text
|
||||
raw = scoped_client.get("/api/file/TestVault/raw", params={"path": path},
|
||||
headers=_hdr(key))
|
||||
assert raw.status_code == 200
|
||||
saved = scoped_client.put("/api/file/TestVault/save", params={"path": path},
|
||||
json={"content": "# a\nmodifié\n"}, headers=_hdr(key))
|
||||
assert saved.status_code == 200, saved.text
|
||||
finally:
|
||||
scoped_client.delete("/api/file/TestVault", params={"path": path}, headers=_hdr(key))
|
||||
|
||||
def test_other_vault_refused(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
key = _create(scoped_client, admin, vaults=["TestVault"], scope="files")["token"]
|
||||
assert scoped_client.get("/api/browse/Nope", headers=_hdr(key)).status_code == 403
|
||||
# Sanity : la clé pleine du même admin passe le garde vault (404 ensuite).
|
||||
full = _create(scoped_client, admin, name="full")["token"]
|
||||
assert scoped_client.get("/api/browse/Nope", headers=_hdr(full)).status_code == 404
|
||||
|
||||
def test_out_of_scope_endpoints_refused(self, scoped_client):
|
||||
admin = _login(scoped_client)
|
||||
key = _create(scoped_client, admin, vaults=["TestVault"], scope="files")["token"]
|
||||
h = _hdr(key)
|
||||
assert scoped_client.get("/api/config", headers=h).status_code == 403
|
||||
assert scoped_client.get("/api/webhooks", headers=h).status_code == 403
|
||||
assert scoped_client.get("/api/auth/tokens", headers=h).status_code == 403
|
||||
assert scoped_client.post("/api/search/replace",
|
||||
json={"query": "a", "replacement": "b", "vault": "TestVault"},
|
||||
headers=h).status_code == 403
|
||||
assert scoped_client.post("/api/vaults/add", json={"name": "X", "path": "/tmp/x"},
|
||||
headers=h).status_code == 403
|
||||
# … mais la recherche et la liste des vaults restent ouvertes.
|
||||
assert scoped_client.get("/api/search", params={"q": "test"}, headers=h).status_code == 200
|
||||
|
||||
def test_full_token_unaffected(self, scoped_client):
|
||||
"""Non-régression : une clé sans portée garde l'accès complet."""
|
||||
admin = _login(scoped_client)
|
||||
key = _create(scoped_client, admin, name="legacy")["token"]
|
||||
assert scoped_client.get("/api/config", headers=_hdr(key)).status_code == 200
|
||||
assert scoped_client.get("/api/webhooks", headers=_hdr(key)).status_code == 200
|
||||
assert scoped_client.get("/api/auth/tokens", headers=_hdr(key)).status_code == 200
|
||||
|
||||
|
||||
class TestScopedToolAccess:
|
||||
def _scoped_ctx(self):
|
||||
user = {"username": "admin", "role": "admin", "vaults": ["*"],
|
||||
"_token_vaults": ["TestVault"], "_token_scope": "files"}
|
||||
return _ctx_with_user(user)
|
||||
|
||||
def test_file_tools_allowed(self, scoped_client):
|
||||
result = call_tool("list_all_files", self._scoped_ctx(), {"vault": "TestVault"})
|
||||
assert result.ok and result.data["files"]
|
||||
|
||||
def test_other_vault_denied_at_tool_level(self, scoped_client):
|
||||
with pytest.raises(ToolPermissionError):
|
||||
call_tool("list_all_files", self._scoped_ctx(), {"vault": "Autre"})
|
||||
|
||||
def test_non_file_tools_denied(self, scoped_client):
|
||||
for tool, args in (("web_search", {"query": "x"}),
|
||||
("git_list_repos", {"provider": "gitea"}),
|
||||
("notify_external", {"title": "t", "message": "m"})):
|
||||
with pytest.raises(ToolPermissionError) as exc:
|
||||
call_tool(tool, self._scoped_ctx(), args)
|
||||
assert exc.value.code == "token_scope_denied"
|
||||
|
||||
def test_full_scope_unaffected(self, scoped_client):
|
||||
ctx = _ctx() # sans _token_scope → plein accès
|
||||
assert call_tool("list_all_files", ctx, {"vault": "TestVault"}).ok
|
||||
|
||||
|
||||
def _ctx_with_user(user):
|
||||
from backend.tools.api import ToolContext
|
||||
return ToolContext(user=user, audit_enabled=False)
|
||||
@@ -0,0 +1,106 @@
|
||||
# tests/test_sync_lists.py — #197 (Gitea #4) : réconciliation externe en un appel.
|
||||
"""Couvre l'exposition de ``mtime``/``size`` dans les listes de fichiers pour
|
||||
la synchronisation TikTak ↔ ObsiGate (un seul appel de liste suffit à
|
||||
détecter les fichiers modifiés depuis un horodatage, sans relire chaque
|
||||
fichier) :
|
||||
|
||||
- ``GET /api/vault/{vault}/paths`` : chaque entrée fichier expose ``size``,
|
||||
``mtime`` (epoch) et ``mtime_iso`` ; les répertoires portent ``None`` ;
|
||||
- ``GET /api/browse/{vault}`` : les fichiers exposent ``modified``/``modified_iso`` ;
|
||||
- outil MCP ``list_directory`` : même contrat (``size``/``mtime``/``mtime_iso``) ;
|
||||
- outil MCP ``list_all_files`` : déjà complet (``size``/``modified``) — parité vérifiée ;
|
||||
- scénario de réconciliation : snapshot → modification via l'API → re-liste,
|
||||
le fichier modifié est détecté par comparaison ``(mtime, size)``.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from backend.tools.api import call_tool
|
||||
|
||||
from tests.test_tools import _ctx
|
||||
|
||||
|
||||
def _paths(client, vault="TestVault", limit=5000):
|
||||
resp = client.get(f"/api/vault/{vault}/paths", params={"limit": limit})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
class TestPathsMetadata:
|
||||
def test_file_entries_expose_size_and_mtime(self, client):
|
||||
data = _paths(client)
|
||||
assert data["vault"] == "TestVault"
|
||||
files = [e for e in data["results"] if e["type"] == "file"]
|
||||
assert files, "le vault de test doit contenir des fichiers"
|
||||
by_path = {e["path"]: e for e in files}
|
||||
note = by_path["note1.md"]
|
||||
assert isinstance(note["size"], int) and note["size"] > 0
|
||||
assert isinstance(note["mtime"], (int, float)) and note["mtime"] > 0
|
||||
assert note["mtime_iso"]
|
||||
# L'epoch et l'ISO désignent le même instant (tolérance 2 s).
|
||||
assert abs(datetime.fromisoformat(note["mtime_iso"]).timestamp() - note["mtime"]) < 2
|
||||
|
||||
def test_directory_entries_have_null_metadata(self, client):
|
||||
data = _paths(client)
|
||||
dirs = [e for e in data["results"] if e["type"] == "directory"]
|
||||
assert dirs, "le vault de test doit contenir un répertoire (Projets)"
|
||||
for entry in dirs:
|
||||
assert entry["size"] is None
|
||||
assert entry["mtime"] is None
|
||||
assert entry["mtime_iso"] is None
|
||||
|
||||
def test_limit_still_applies(self, client):
|
||||
data = _paths(client, limit=2)
|
||||
assert data["count"] == 2
|
||||
assert len(data["results"]) == 2
|
||||
|
||||
|
||||
class TestReconciliationScenario:
|
||||
def test_single_call_detects_changed_file(self, client):
|
||||
"""Un client externe snapshot ``{path: (mtime, size)}`` puis, après
|
||||
une modification via l'API fichiers, détecte le changement en un seul
|
||||
appel de liste — sans relire aucun contenu."""
|
||||
before = {e["path"]: (e["mtime"], e["size"]) for e in _paths(client)["results"]}
|
||||
assert "note1.md" in before
|
||||
|
||||
# Modification via l'API d'écriture (même canal que la synchro TikTak).
|
||||
save = client.put(
|
||||
"/api/file/TestVault/save",
|
||||
params={"path": "note1.md"},
|
||||
json={"content": "# Introduction à Python\nContenu enrichi pour la synchro.\n"},
|
||||
)
|
||||
assert save.status_code == 200, save.text
|
||||
|
||||
after = {e["path"]: (e["mtime"], e["size"]) for e in _paths(client)["results"]}
|
||||
changed = [p for p in after if after[p] != before.get(p)]
|
||||
assert changed == ["note1.md"]
|
||||
# Le fichier non touché n'est pas signalé.
|
||||
assert "note2.md" in after and after["note2.md"] == before["note2.md"]
|
||||
|
||||
|
||||
class TestBrowseAndMcpParity:
|
||||
def test_browse_files_expose_modified(self, client):
|
||||
resp = client.get("/api/browse/TestVault")
|
||||
assert resp.status_code == 200, resp.text
|
||||
files = [i for i in resp.json()["items"] if i["type"] == "file"]
|
||||
assert files
|
||||
for item in files:
|
||||
assert isinstance(item["modified"], (int, float)) and item["modified"] > 0
|
||||
assert item["modified_iso"]
|
||||
|
||||
def test_mcp_list_directory_exposes_size_and_mtime(self, client):
|
||||
entries = call_tool("list_directory", _ctx(), {"vault": "TestVault"}).data
|
||||
files = [e for e in entries if e["type"] == "file"]
|
||||
assert files
|
||||
for entry in files:
|
||||
assert isinstance(entry["size"], int) and entry["size"] > 0
|
||||
assert isinstance(entry["mtime"], (int, float)) and entry["mtime"] > 0
|
||||
assert entry["mtime_iso"]
|
||||
|
||||
def test_mcp_list_all_files_parity(self, client):
|
||||
"""``list_all_files`` (source unique REST + MCP) expose déjà le contrat."""
|
||||
result = call_tool("list_all_files", _ctx(), {"vault": "TestVault"}).data
|
||||
assert result["files"]
|
||||
for entry in result["files"]:
|
||||
assert isinstance(entry["size"], int)
|
||||
assert isinstance(entry["modified"], (int, float))
|
||||
assert entry["modified_iso"]
|
||||
@@ -0,0 +1,160 @@
|
||||
# tests/test_webhook_filters.py — #198 (Gitea #3) : filtre vault / préfixe.
|
||||
"""Couvre :
|
||||
|
||||
- ``webhook_matches`` : abonnement, filtre ``vault`` exact, filtre
|
||||
``path_prefix`` (égalité ou sous-arbre, jamais de préfixe partiel comme
|
||||
``Docs`` vs ``Docs2``), événements de renommage (``old_path``/``new_path``) ;
|
||||
- persistance : ``create_webhook``/``update_webhook`` normalisent et exposent
|
||||
``vault``/``path_prefix`` (``""`` réinitialise le filtre) ; les webhooks
|
||||
historiques sans filtre reçoivent tout (compatibilité) ;
|
||||
- ``dispatch_webhooks`` : un webhook filtré sur le vault « TikTak » ne reçoit
|
||||
que ses événements, avec ``vault`` dans la charge ; un webhook sans filtre
|
||||
reçoit tout (client HTTP simulé, aucun réseau).
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.webhooks as wh
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(wh, "WEBHOOKS_FILE", tmp_path / "webhooks.json")
|
||||
monkeypatch.setattr(wh, "WEBHOOK_SECRETS_FILE", tmp_path / "webhook_secrets.json")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _wh(**overrides):
|
||||
base = {"id": "x", "name": "t", "url": "https://example.com/h",
|
||||
"events": ["file_created", "file_modified", "file_renamed"],
|
||||
"enabled": True}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class TestWebhookMatches:
|
||||
def test_unsubscribed_event_never_matches(self):
|
||||
assert wh.webhook_matches(_wh(events=["file_created"]), "file_deleted",
|
||||
{"vault": "TikTak", "path": "a.md"}) is False
|
||||
|
||||
def test_no_filter_matches_everything_subscribed(self):
|
||||
assert wh.webhook_matches(_wh(), "file_created", {"vault": "TikTak", "path": "a.md"}) is True
|
||||
assert wh.webhook_matches(_wh(), "file_created", {"vault": "Autre", "path": "a.md"}) is True
|
||||
|
||||
def test_vault_filter_exact(self):
|
||||
hook = _wh(vault="TikTak")
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "TikTak", "path": "a.md"}) is True
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "tiktak", "path": "a.md"}) is False
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "Autre", "path": "a.md"}) is False
|
||||
|
||||
def test_prefix_matches_subtree_only(self):
|
||||
hook = _wh(path_prefix="Maison/")
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "V", "path": "Maison/a.md"}) is True
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "V", "path": "Maison"}) is True
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "V", "path": "Maison2/a.md"}) is False
|
||||
assert wh.webhook_matches(hook, "file_created", {"vault": "V", "path": "Autre/a.md"}) is False
|
||||
|
||||
def test_rename_matches_on_either_side(self):
|
||||
hook = _wh(vault="TikTak", path_prefix="Maison")
|
||||
assert wh.webhook_matches(hook, "file_renamed",
|
||||
{"vault": "TikTak", "old_path": "Maison/a.md",
|
||||
"new_path": "Maison/b.md"}) is True
|
||||
# Déplacement hors du préfixe surveillé : le consommateur est prévenu
|
||||
# (l'ancien chemin matche) pour solder sa réconciliation.
|
||||
assert wh.webhook_matches(hook, "file_renamed",
|
||||
{"vault": "TikTak", "old_path": "Maison/a.md",
|
||||
"new_path": "Autre/a.md"}) is True
|
||||
assert wh.webhook_matches(hook, "file_renamed",
|
||||
{"vault": "TikTak", "old_path": "Autre/a.md",
|
||||
"new_path": "Autre/b.md"}) is False
|
||||
|
||||
def test_combined_vault_and_prefix(self):
|
||||
hook = _wh(vault="TikTak", path_prefix="Inbox")
|
||||
assert wh.webhook_matches(hook, "file_created",
|
||||
{"vault": "TikTak", "path": "Inbox/a.md"}) is True
|
||||
assert wh.webhook_matches(hook, "file_created",
|
||||
{"vault": "TikTak", "path": "Maison/a.md"}) is False
|
||||
assert wh.webhook_matches(hook, "file_created",
|
||||
{"vault": "Autre", "path": "Inbox/a.md"}) is False
|
||||
|
||||
|
||||
class TestFilterPersistence:
|
||||
def test_create_normalizes_filters(self, isolated_store):
|
||||
hook = wh.create_webhook("T", "https://example.com/h", ["file_created"],
|
||||
vault=" TikTak ", path_prefix="/Maison/")
|
||||
assert hook["vault"] == "TikTak"
|
||||
assert hook["path_prefix"] == "Maison"
|
||||
stored = wh.get_webhooks()[0]
|
||||
assert stored["vault"] == "TikTak" and stored["path_prefix"] == "Maison"
|
||||
|
||||
def test_create_without_filters(self, isolated_store):
|
||||
hook = wh.create_webhook("T", "https://example.com/h", ["file_created"])
|
||||
assert hook["vault"] is None and hook["path_prefix"] is None
|
||||
|
||||
def test_update_clears_filter_with_empty_string(self, isolated_store):
|
||||
hook = wh.create_webhook("T", "https://example.com/h", ["file_created"],
|
||||
vault="TikTak", path_prefix="Maison")
|
||||
updated = wh.update_webhook(hook["id"], {"vault": "", "path_prefix": ""})
|
||||
assert updated["vault"] is None and updated["path_prefix"] is None
|
||||
|
||||
|
||||
class FakeResp:
|
||||
status = 200
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class FakeSession:
|
||||
posted: list = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
FakeSession.posted.append({"url": url, "body": kwargs.get("data", "")})
|
||||
return FakeResp()
|
||||
|
||||
|
||||
class TestDispatchFiltering:
|
||||
def test_filtered_and_unfiltered_delivery(self, isolated_store, monkeypatch):
|
||||
monkeypatch.setattr(wh.aiohttp, "ClientSession", FakeSession)
|
||||
monkeypatch.setattr(wh, "is_safe_target", lambda url: True)
|
||||
FakeSession.posted = []
|
||||
|
||||
scoped = wh.create_webhook("tiktak", "https://tiktak.local/hook", ["file_created"],
|
||||
vault="TikTak")
|
||||
broadcast = wh.create_webhook("all", "https://all.local/hook", ["file_created"])
|
||||
|
||||
async def _run():
|
||||
await wh.dispatch_webhooks("file_created", {"vault": "TikTak", "path": "Inbox/a.md"})
|
||||
await wh.dispatch_webhooks("file_created", {"vault": "Autre", "path": "b.md"})
|
||||
# Laisser les tâches fire-and-forget s'exécuter.
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0.02)
|
||||
if len(FakeSession.posted) >= 3:
|
||||
break
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
by_url = {}
|
||||
for post in FakeSession.posted:
|
||||
by_url.setdefault(post["url"], []).append(post["body"])
|
||||
assert len(by_url.get("https://tiktak.local/hook", [])) == 1
|
||||
assert len(by_url.get("https://all.local/hook", [])) == 2
|
||||
# La charge du webhook filtré porte bien le vault.
|
||||
import json as _json
|
||||
payload = _json.loads(by_url["https://tiktak.local/hook"][0])
|
||||
assert payload["event"] == "file_created"
|
||||
assert payload["data"]["vault"] == "TikTak"
|
||||
assert scoped["id"] and broadcast["id"]
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Sonde des gestes de redimensionnement — instance Docker locale.
|
||||
* 1. presser dans le CORPS près d'une limite de colonne / ligne
|
||||
* → standards : ça doit SÉLECTIONNER une cellule, pas redimensionner ;
|
||||
* 2. double-clic sur un bord d'en-tête
|
||||
* → standards (Excel / Sheets) : ajustement automatique au contenu ;
|
||||
* 3. glisser un bord d'en-tête → doit toujours redimensionner (inchangé).
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:2020';
|
||||
const results = [];
|
||||
const check = (name, ok, detail = '') => {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} — ${name}${detail ? ` (${detail})` : ''}`);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
|
||||
const page = await context.newPage();
|
||||
const styleCalls = [];
|
||||
page.on('request', (r) => {
|
||||
if (r.url().includes('/xlsx/style') && r.method() === 'PUT') {
|
||||
styleCalls.push(r.postDataJSON ? r.postDataJSON() : null);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(BASE, { waitUntil: 'domcontentloaded' });
|
||||
const login = page.locator('#login-screen');
|
||||
await login.waitFor({ state: 'visible', timeout: 20000 }).catch(() => {});
|
||||
if (await login.isVisible().catch(() => false)) {
|
||||
await page.fill('#login-username', 'admin');
|
||||
await page.fill('#login-password', 'test123');
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 30000 });
|
||||
const treeItem = page.locator('.tree-item[data-vault="TestVault"][data-path="budget.xlsx"]');
|
||||
if (!(await treeItem.count())) {
|
||||
await page.locator('.tree-item.vault-item[data-vault="TestVault"]').first().click();
|
||||
await treeItem.waitFor({ state: 'attached', timeout: 10000 });
|
||||
}
|
||||
await treeItem.dblclick({ timeout: 10000 });
|
||||
await page.locator('#content-area .xlsx-panel:visible .xlsx-table').waitFor({ state: 'visible', timeout: 20000 });
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
const ghostVisible = () =>
|
||||
page.evaluate(() => {
|
||||
const g = document.querySelector('.xlsx-resize-ghost');
|
||||
return !!g && !g.hidden;
|
||||
});
|
||||
const selectedCount = () =>
|
||||
page.evaluate(() => {
|
||||
const p = [...document.querySelectorAll('.xlsx-panel')].find((x) => getComputedStyle(x).display !== 'none');
|
||||
return p ? p.querySelectorAll('td.xlsx-selected').length : 0;
|
||||
});
|
||||
const colWidthPx = (letter) =>
|
||||
page.evaluate((L) => {
|
||||
const p = [...document.querySelectorAll('.xlsx-panel')].find((x) => getComputedStyle(x).display !== 'none');
|
||||
const th = [...p.querySelectorAll('thead th')].find((n) => n.textContent.trim() === L);
|
||||
return th ? Math.round(th.getBoundingClientRect().width) : -1;
|
||||
}, letter);
|
||||
|
||||
// ── 1. presser dans le CORPS, à 3 px de la limite A|B ──
|
||||
const abEdge = await page.evaluate(() => {
|
||||
const p = [...document.querySelectorAll('.xlsx-panel')].find((x) => getComputedStyle(x).display !== 'none');
|
||||
const ths = [...p.querySelectorAll('thead th')];
|
||||
const a = ths.find((t) => t.textContent.trim() === 'A');
|
||||
const r = a.getBoundingClientRect();
|
||||
return { x: r.right - 3, y: r.bottom + 60 }; // 60 px sous l'en-tête : dans le corps
|
||||
});
|
||||
await page.mouse.move(abEdge.x, abEdge.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(120);
|
||||
const bodyGhost = await ghostVisible();
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
const bodySel = await selectedCount();
|
||||
check(
|
||||
'pression dans le corps près d\'une limite : SÉLECTION (pas de redimensionnement)',
|
||||
!bodyGhost,
|
||||
`ghost pendant la pression=${bodyGhost}, cellules sélectionnées=${bodySel}`,
|
||||
);
|
||||
|
||||
// ── 2. glisser le bord d'en-tête A|B : doit redimensionner ──
|
||||
styleCalls.length = 0;
|
||||
const headerEdge = await page.evaluate(() => {
|
||||
const p = [...document.querySelectorAll('.xlsx-panel')].find((x) => getComputedStyle(x).display !== 'none');
|
||||
const ths = [...p.querySelectorAll('thead th')];
|
||||
const a = ths.find((t) => t.textContent.trim() === 'A');
|
||||
const r = a.getBoundingClientRect();
|
||||
return { x: r.right, y: r.top + r.height / 2, w: Math.round(r.width) };
|
||||
});
|
||||
await page.mouse.move(headerEdge.x, headerEdge.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(headerEdge.x + 40, headerEdge.y, { steps: 5 });
|
||||
const ghostDuringDrag = await ghostVisible();
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(500);
|
||||
const wAfterDrag = await colWidthPx('A');
|
||||
check(
|
||||
'glisser un bord d\'en-tête : ghost + écriture de la largeur',
|
||||
ghostDuringDrag && wAfterDrag > headerEdge.w,
|
||||
`ghost=${ghostDuringDrag}, A: ${headerEdge.w}px → ${wAfterDrag}px, PUT=${styleCalls.length}`,
|
||||
);
|
||||
|
||||
// ── 3. double-clic sur le bord d'en-tête A|B → ajustement automatique ──
|
||||
styleCalls.length = 0;
|
||||
const beforeDbl = await colWidthPx('A');
|
||||
await page.mouse.dblclick(headerEdge.x, headerEdge.y, { delay: 80 });
|
||||
await page.waitForTimeout(700);
|
||||
const afterDbl = await colWidthPx('A');
|
||||
const fitOp = styleCalls.some((c) => c && Array.isArray(c.ops) && c.ops.some((o) => o.op === 'col_width'));
|
||||
check(
|
||||
'double-clic sur un bord d\'en-tête : ajustement automatique (PUT col_width)',
|
||||
fitOp,
|
||||
`A: ${beforeDbl}px → ${afterDbl}px, PUT=${JSON.stringify(styleCalls)}`,
|
||||
);
|
||||
|
||||
// ── 4. double-clic sur le bord d'une ligne (gouttière des numéros) ──
|
||||
styleCalls.length = 0;
|
||||
const rowEdge = await page.evaluate(() => {
|
||||
const p = [...document.querySelectorAll('.xlsx-panel')].find((x) => getComputedStyle(x).display !== 'none');
|
||||
const th = p.querySelector('th.xlsx-rownum');
|
||||
const r = th.getBoundingClientRect();
|
||||
return { x: r.left + 10, y: r.bottom };
|
||||
});
|
||||
await page.mouse.dblclick(rowEdge.x, rowEdge.y, { delay: 80 });
|
||||
await page.waitForTimeout(700);
|
||||
const rowFitOp = styleCalls.some((c) => c && Array.isArray(c.ops) && c.ops.some((o) => o.op === 'row_height'));
|
||||
check(
|
||||
'double-clic sur un bord de ligne : ajustement automatique (PUT row_height)',
|
||||
rowFitOp,
|
||||
`PUT=${JSON.stringify(styleCalls)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
check('exécution sans erreur', false, String(err && err.message ? err.message : err));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(`\n${results.length - failed.length}/${results.length} vérifications OK`);
|
||||
process.exit(failed.length ? 1 : 0);
|
||||
Reference in New Issue
Block a user