feat: agent IA phase 4 — doublons #166, notifications externes #168 (Discord/Telegram/SMTP/webhook), taches planifiees #170
CI / lint (push) Successful in 2m53s
CI / security (push) Successful in 2m5s
CI / test (push) Successful in 4m52s
CI / build (push) Successful in 2m5s
CI / e2e (push) Successful in 16m14s

This commit is contained in:
2026-10-04 13:11:25 -04:00
parent fe9f7b49f7
commit 4ce677902a
31 changed files with 2347 additions and 14 deletions
+88
View File
@@ -0,0 +1,88 @@
"""Duplicate detection & merge endpoints (#166).
Read endpoints require vault access; the merge endpoint is destructive
(backup first in the service layer) and additionally requires the
confirmation token pattern used by mutating routes — here enforced by an
explicit ``confirm=true`` body flag, mirroring the agent two-step flow.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from backend.auth.middleware import check_vault_access, require_auth
from backend.services import duplicates as _duplicates
from backend.services.errors import ServiceError
router = APIRouter(prefix="/api/duplicates", tags=["duplicates"])
class DuplicatePair(BaseModel):
"""One candidate duplicate pair."""
model_config = ConfigDict(extra="allow")
file_a: str = Field(description="First file (vault-relative)")
file_b: str = Field(description="Second file (vault-relative)")
score: float = Field(description="Blended similarity in [0, 1]")
class DuplicatesResponse(BaseModel):
"""Response for GET /api/duplicates."""
model_config = ConfigDict(extra="allow")
vault: str = Field(description="Vault name")
threshold: float = Field(description="Applied threshold")
files_scanned: int = Field(description="Markdown files compared")
truncated: bool = Field(description="True when the scan hit the file cap")
pairs: list[DuplicatePair] = Field(description="Candidate pairs, best score first")
class MergeResponse(BaseModel):
"""Response for POST /api/duplicates/merge."""
model_config = ConfigDict(extra="allow")
strategy: str = Field(description="Applied merge strategy")
target: str = Field(description="Surviving note")
deleted: str = Field(description="Absorbed note (deleted after merge)")
@router.get("", response_model=DuplicatesResponse)
async def api_duplicates_list(
vault: str = Query(..., description="Vault name"),
threshold: float = Query(0.75, ge=0.3, le=1.0, description="Minimum similarity"),
limit: int = Query(20, ge=1, le=200, description="Max pairs"),
subdir: str = Query("", description="Directory scope"),
current_user: dict[str, Any] = Depends(require_auth),
):
"""List candidate duplicate notes ordered by descending score."""
if not check_vault_access(vault, current_user):
raise HTTPException(403, f"No access to vault '{vault}'")
try:
return _duplicates.find_duplicate_pairs(vault, threshold=threshold, limit=limit, subdir=subdir)
except ServiceError as e:
raise HTTPException(e.status or 400, e.message) from e
@router.post("/merge", response_model=MergeResponse)
async def api_duplicates_merge(
body: dict[str, Any] = Body(...),
current_user: dict[str, Any] = Depends(require_auth),
):
"""Merge *source_path* into *target_path* (``confirm: true`` required)."""
vault = str(body.get("vault") or "")
if not check_vault_access(vault, current_user):
raise HTTPException(403, f"No access to vault '{vault}'")
if body.get("confirm") is not True:
raise HTTPException(400, "Fusion destructive : confirmez avec {confirm: true}")
try:
return _duplicates.merge_duplicates(
vault,
str(body.get("source_path") or ""),
str(body.get("target_path") or ""),
strategy=str(body.get("strategy") or "append"),
)
except ServiceError as e:
raise HTTPException(e.status or 400, e.message) from e
+96
View File
@@ -0,0 +1,96 @@
"""External notification channels endpoints (#168).
Channel CRUD is admin-only (secrets involved); sending a test notification
requires authentication. Responses mask secrets (``***`` + ``has_secret``).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from backend import notify as _notify
from backend.auth.middleware import require_admin, require_auth
from backend.schemas import StatusResponse
router = APIRouter(prefix="/api/notify", tags=["notify"])
class NotifyChannel(BaseModel):
"""Public view of a notification channel (secrets masked)."""
model_config = ConfigDict(extra="allow")
id: str = Field(description="Channel id")
name: str = Field(description="Display name")
type: str = Field(description="discord | telegram | smtp | webhook")
enabled: bool = Field(description="Whether the channel receives broadcasts")
config: dict[str, Any] = Field(description="Channel config (secrets masked)")
has_secret: bool = Field(description="True when a secret is configured")
class NotifySendResult(BaseModel):
"""Outcome of a test send / broadcast."""
model_config = ConfigDict(extra="allow")
ok: bool = Field(description="True when every delivery succeeded")
deliveries: list[dict[str, Any]] = Field(default_factory=list)
@router.get("/channels", response_model=list[NotifyChannel])
async def api_notify_list(current_user=Depends(require_admin)):
"""List notification channels (admin)."""
return _notify.list_channels()
@router.post("/channels", response_model=NotifyChannel)
async def api_notify_create(body: dict = Body(...), current_user=Depends(require_admin)):
"""Create a channel (``{name, type, config}``). Secrets go to the secret store."""
try:
return _notify.create_channel(
str(body.get("name") or ""),
str(body.get("type") or ""),
dict(body.get("config") or {}),
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.patch("/channels/{channel_id}", response_model=NotifyChannel)
async def api_notify_update(channel_id: str, body: dict = Body(...), current_user=Depends(require_admin)):
"""Update a channel (name / enabled / config)."""
try:
result = _notify.update_channel(channel_id, body)
except ValueError as e:
raise HTTPException(400, str(e)) from e
if result is None:
raise HTTPException(404, "Channel not found")
return result
@router.delete("/channels/{channel_id}", response_model=StatusResponse)
async def api_notify_delete(channel_id: str, current_user=Depends(require_admin)):
"""Delete a channel and its secret."""
if not _notify.delete_channel(channel_id):
raise HTTPException(404, "Channel not found")
return {"status": "deleted"}
@router.post("/test", response_model=NotifySendResult)
async def api_notify_test(body: dict = Body(...), current_user=Depends(require_auth)):
"""Send a test notification (broadcast or single ``channel_id``)."""
title = str(body.get("title") or "Test ObsiGate")
message = str(body.get("message") or "Notification de test.")
channel_id = str(body.get("channel_id") or "")
if channel_id:
channel = next((c for c in _notify._read_channels() if c.get("id") == channel_id), None)
if channel is None:
raise HTTPException(404, "Channel not found")
try:
_notify.send_via_channel(channel, title, message, "manual")
except Exception as e:
raise HTTPException(502, f"Envoi échoué : {e}") from e
return {"ok": True, "deliveries": [{"channel_id": channel_id, "ok": True}]}
deliveries = _notify.broadcast("manual", title, message)
return {"ok": all(d.get("ok") for d in deliveries), "deliveries": deliveries}
+118
View File
@@ -0,0 +1,118 @@
"""Scheduled tasks endpoints (#170).
Tasks reuse the existing mutation/notification services — this router only
validates, persists and triggers. File-writing actions check vault access
at creation time; the background tick re-checks nothing (system context) but
records failures and notifies on ``schedule_failure`` (#168).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from backend import scheduler as _scheduler
from backend.auth.middleware import check_vault_access, require_auth
from backend.schemas import StatusResponse
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
class ScheduledTask(BaseModel):
"""A programmed automatic task."""
model_config = ConfigDict(extra="allow")
id: str = Field(description="Task id")
name: str = Field(description="Display name")
action: dict[str, Any] = Field(description="{kind, params}")
schedule: dict[str, Any] = Field(description="{kind, ...}")
enabled: bool = Field(description="Whether the tick executes it")
created_by: str = Field(description="Owner username")
created_at: str = Field(description="ISO-8601 creation time")
last_run_at: str | None = Field(default=None)
last_status: str | None = Field(default=None)
last_error: str | None = Field(default=None)
run_count: int = Field(default=0)
next_run_at: str = Field(description="ISO-8601 next due time")
class TaskRunResult(BaseModel):
"""Outcome of a manual or due run."""
model_config = ConfigDict(extra="allow")
task_id: str = Field(description="Task id")
ok: bool = Field(description="True on success")
result: dict[str, Any] | None = Field(default=None)
error: str | None = Field(default=None)
def _check_action_vault(action: dict[str, Any], user: dict[str, Any]) -> None:
from backend.services.errors import ServiceError
from backend.services.vaults import get_vault_root
kind = (action or {}).get("kind")
params = (action or {}).get("params") or {}
if kind in ("create_file", "append_to_file"):
vault = str(params.get("vault") or "")
if not check_vault_access(vault, user):
raise HTTPException(403, f"No access to vault '{vault}'")
try:
get_vault_root(vault)
except ServiceError as e:
raise HTTPException(404, f"Unknown vault '{vault}'") from e
@router.get("/tasks", response_model=list[ScheduledTask])
async def api_scheduler_list(current_user: dict[str, Any] = Depends(require_auth)):
"""List scheduled tasks (newest first)."""
return _scheduler.list_tasks()
@router.post("/tasks", response_model=ScheduledTask)
async def api_scheduler_create(body: dict = Body(...), current_user: dict[str, Any] = Depends(require_auth)):
"""Create a task (``{name, action, schedule, enabled?}``)."""
action = dict(body.get("action") or {})
_check_action_vault(action, current_user)
try:
return _scheduler.create_task(
str(body.get("name") or ""),
action,
dict(body.get("schedule") or {}),
created_by=str(current_user.get("username", "api")),
enabled=bool(body.get("enabled", True)),
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.patch("/tasks/{task_id}", response_model=ScheduledTask)
async def api_scheduler_update(task_id: str, body: dict = Body(...), current_user: dict[str, Any] = Depends(require_auth)):
"""Update a task (name / enabled / action / schedule)."""
if "action" in body:
_check_action_vault(dict(body["action"] or {}), current_user)
try:
result = _scheduler.update_task(task_id, body)
except ValueError as e:
raise HTTPException(400, str(e)) from e
if result is None:
raise HTTPException(404, "Task not found")
return result
@router.delete("/tasks/{task_id}", response_model=StatusResponse)
async def api_scheduler_delete(task_id: str, current_user: dict[str, Any] = Depends(require_auth)):
"""Delete a task."""
if not _scheduler.delete_task(task_id):
raise HTTPException(404, "Task not found")
return {"status": "deleted"}
@router.post("/tasks/{task_id}/run", response_model=TaskRunResult)
async def api_scheduler_run(task_id: str, current_user: dict[str, Any] = Depends(require_auth)):
"""Execute a task immediately (manual run)."""
try:
return _scheduler.run_task(task_id, manual=True)
except KeyError:
raise HTTPException(404, "Task not found") from None