"""Additional Pydantic response models for the ObsiGate REST API. These models enrich the auto-generated OpenAPI 3.1 schema (#72). They are intentionally permissive (``extra="allow"``) so that adding a new field to an existing endpoint never breaks response validation — the model documents the stable, public shape while still accepting internal additions. Models whose endpoints are already typed in :mod:`backend.main` (e.g. ``FileContentResponse``) live there; this module only holds the ones used to fill the gaps. """ from typing import Any from pydantic import BaseModel, ConfigDict, Field # --------------------------------------------------------------------------- # Generic helpers # --------------------------------------------------------------------------- class StatusResponse(BaseModel): """Generic ``{"status": "..."}`` acknowledgement.""" model_config = ConfigDict(extra="allow") status: str = Field(default="ok", description="Operation status") class ErrorResponse(BaseModel): """Standard FastAPI error envelope.""" detail: str | list[dict[str, Any]] | None = Field( default=None, description="Human-readable error message, or a list of validation errors", examples=["Vault 'Notes' not found"], ) # --------------------------------------------------------------------------- # Recent files & bookmarks # --------------------------------------------------------------------------- class RecentFileItem(BaseModel): """A recently opened or recently modified file.""" path: str = Field(description="Relative path within the vault") title: str = Field(description="File title") vault: str = Field(description="Vault name") mtime: float | int | str | None = Field(default=None, description="Modification timestamp") mtime_human: str | None = Field(default=None, description="Human-readable modification time") mtime_iso: str | None = Field(default=None, description="ISO-8601 modification time") size_bytes: int | None = Field(default=None, description="File size in bytes") tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags") preview: str | None = Field(default=None, description="Short content preview") bookmarked: bool | None = Field(default=None, description="Whether the file is bookmarked") class RecentResponse(BaseModel): """Response for ``GET /api/recent``.""" files: list[RecentFileItem] total: int = Field(description="Total number of files in the selected mode") limit: int = Field(description="Applied limit") mode: str = Field(description="'opened' or 'modified'") model_config = ConfigDict( json_schema_extra={ "example": { "files": [ { "path": "notes/Accueil.md", "title": "Accueil", "vault": "TestVault", "mtime": 1750000000.0, "mtime_human": "il y a 2 h", "size_bytes": 1024, "tags": ["#accueil"], "preview": "# Bienvenue…", "bookmarked": False, } ], "total": 1, "limit": 20, "mode": "opened", } } ) class BookmarkFileItem(BaseModel): """A bookmarked file.""" path: str = Field(description="Relative path within the vault") title: str = Field(description="File title") vault: str = Field(description="Vault name") mtime: float | int | str | None = Field(default=None, description="Bookmark timestamp") mtime_human: str | None = Field(default=None, description="Human-readable bookmark time") size_bytes: int | None = Field(default=None, description="File size in bytes") tags: list[str] = Field(default_factory=list, description="Up to 5 leading tags") bookmarked: bool | None = Field(default=True, description="Always true for this endpoint") class BookmarksResponse(BaseModel): """Response for ``GET /api/bookmarks``.""" files: list[BookmarkFileItem] total: int = Field(description="Number of bookmarked files") class BookmarkToggleResponse(BaseModel): """Response for ``POST /api/bookmarks/toggle``.""" bookmarked: bool = Field(description="New bookmark state") class SavedSearch(BaseModel): """A persisted search definition.""" model_config = ConfigDict(extra="allow") id: str = Field(description="Unique identifier (millisecond timestamp)") query: str = Field(description="Search query text") vault: str = Field(default="all", description="Vault filter") case_sensitive: bool = Field(default=False) whole_word: bool = Field(default=False) regex: bool = Field(default=False) include_paths: str = Field(default="") exclude_paths: str = Field(default="") created_at: float = Field(description="Creation timestamp (epoch seconds)") # --------------------------------------------------------------------------- # Backups & diffs # --------------------------------------------------------------------------- class BackupsResponse(BaseModel): """Response for ``GET /api/file/{vault}/backups``.""" vault: str path: str backups: list[dict[str, Any]] = Field(description="Backups, newest first") class BacklinksResponse(BaseModel): """Response for ``GET /api/file/{vault}/backlinks``.""" vault: str path: str backlinks: list[dict[str, Any]] = Field(description="Files linking to the target") total: int class BackupsListResponse(BaseModel): """Response for ``GET /api/backups``.""" backups: list[dict[str, Any]] = Field(description="All backups across vaults") total: int total_size_bytes: int class BackupsDeletedResponse(BaseModel): """Response for backup deletion / purge endpoints.""" deleted: int = Field(description="Number of backup files deleted") class BackupContentResponse(BaseModel): """Response for ``GET /api/backups/content``.""" content: str = Field(description="Backup file content (truncated to 100 KB)") name: str = Field(description="Backup file name") size: int = Field(description="Returned content size in characters") class BackupsCompressResponse(BaseModel): """Response for ``POST /api/backups/compress``.""" compressed: int = Field(description="Number of backups processed") saved_bytes: int = Field(description="Bytes saved by gzip compression") dry_run: bool class BackupsAutoResponse(BaseModel): """Response for ``POST /api/backups/auto``.""" backed_up: int = Field(description="Number of files backed up") since_hours: int | float = Field(description="Look-back window in hours") class DiffResponse(BaseModel): """Response containing a unified diff between two file versions (#85 — extrait de backend.main, inchangé).""" vault: str = Field(description="Vault name") path: str = Field(description="Relative file path") version: int = Field(description="Backup version timestamp (left/old side)") compare_with: int | None = Field(default=None, description="Other backup version or null for current file (right/new side)") diff: str = Field(description="Unified diff (empty if no changes)") class RestoreRequest(BaseModel): """Request to restore a file from a backup (#85 — extrait de backend.main, inchangé).""" version: int = Field(description="Timestamp of the backup version to restore") class RestoreResponse(BaseModel): """Response after restoring a file from backup (#85 — extrait de backend.main, inchangé).""" success: bool = Field(description="Whether restore succeeded") vault: str = Field(description="Vault name") path: str = Field(description="Relative file path") restored_from: int = Field(description="Timestamp of the backup used") current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any") # --------------------------------------------------------------------------- # Search / suggest / graph (#85 — extrait de backend.main, inchangé) # --------------------------------------------------------------------------- class SearchResultItem(BaseModel): """A single search result.""" vault: str = Field(description="Vault name") path: str = Field(description="Relative file path") title: str = Field(description="File title") tags: list[str] = Field(description="File tags") score: int = Field(description="Relevance score") snippet: str = Field(description="Content excerpt with highlights") modified: str = Field(description="ISO 8601 modification timestamp") class SearchResponse(BaseModel): """Full-text search response with optional pagination.""" query: str = Field(description="Original search query") vault_filter: str = Field(description="Vault filter applied ('all' or vault name)") tag_filter: str | None = Field(default=None, description="Tag filter applied") count: int = Field(description="Number of results in this response") total: int = Field(default=0, description="Total results before pagination") offset: int = Field(default=0, description="Current pagination offset") limit: int = Field(default=200, description="Page size") results: list[SearchResultItem] = Field(description="Search result items") class TagsResponse(BaseModel): """Tag aggregation response.""" vault_filter: str | None = Field(default=None, description="Vault filter applied") tags: dict[str, int] = Field(description="Tag name → count mapping") class TreeSearchResult(BaseModel): """A single tree search result item.""" vault: str = Field(description="Vault name") path: str = Field(description="Full relative path") name: str = Field(description="File or directory name") type: str = Field(description="'file' or 'directory'") matched_path: str = Field(description="Path segment that matched the query") class TreeSearchResponse(BaseModel): """Tree search response with matching paths.""" query: str = Field(description="Search query") vault_filter: str = Field(description="Vault filter applied") results: list[TreeSearchResult] = Field(description="Matching files and directories") class VaultPathEntry(BaseModel): """A single indexed path (file or directory) in a vault.""" vault: str = Field(description="Vault name") path: str = Field(description="Full relative path") name: str = Field(description="File or directory name") type: str = Field(description="'file' or 'directory'") class VaultPathsResponse(BaseModel): """Flat list of every indexed path in a vault (capped).""" vault: str = Field(description="Vault name") count: int = Field(description="Number of returned entries") results: list[VaultPathEntry] = Field(description="Indexed files and directories") class AdvancedSearchResultItem(BaseModel): """A single advanced search result with highlighted snippet.""" vault: str = Field(description="Vault name") path: str = Field(description="Relative file path") title: str = Field(description="File title") tags: list[str] = Field(description="File tags") score: float = Field(description="TF-IDF relevance score (or fused RRF score in semantic mode)") semantic_score: float = Field(default=0.0, description="Cosine similarity from the semantic index (0 when unavailable)") snippet: str = Field(description="Content excerpt with highlights") modified: str = Field(description="ISO 8601 modification timestamp") extension: str = Field(default="", description="File extension") class SearchFacets(BaseModel): """Faceted counts for search results.""" tags: dict[str, int] = Field(default_factory=dict) vaults: dict[str, int] = Field(default_factory=dict) class AdvancedSearchResponse(BaseModel): """Advanced search response with TF-IDF scoring, facets, and pagination.""" results: list[AdvancedSearchResultItem] = Field(description="Search results") total: int = Field(description="Total number of matching results") offset: int = Field(description="Current pagination offset") limit: int = Field(description="Page size") facets: SearchFacets = Field(description="Faceted counts by tag and vault") query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds") semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready") class TitleSuggestion(BaseModel): """A file title suggestion for autocomplete.""" vault: str = Field(description="Vault name") path: str = Field(description="Relative file path") title: str = Field(description="File title") class SuggestResponse(BaseModel): """Autocomplete suggestions for file titles.""" query: str = Field(description="Original query string") suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions") class TagSuggestion(BaseModel): """A tag suggestion for autocomplete.""" tag: str = Field(description="Tag name") count: int = Field(description="Number of files with this tag") class TagSuggestResponse(BaseModel): """Autocomplete suggestions for tags.""" query: str = Field(description="Original query string") suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions") class GraphNode(BaseModel): """A single node in the graph view.""" id: str = Field(description="Unique node identifier") name: str = Field(description="Display name") type: str = Field(description="'vault', 'directory', or 'file'") path: str = Field(description="Relative path within vault") size: int = Field(default=0, description="File size in bytes") tags: list[str] = Field(default_factory=list, description="Tags from frontmatter") incoming_count: int = Field(default=0, description="Number of incoming wikilinks") outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks") class GraphEdge(BaseModel): """An edge between two nodes in the graph view.""" source: str = Field(description="Source node ID") target: str = Field(description="Target node ID") relation: str = Field(description="'parent', 'wikilink', or 'backlink'") class GraphResponse(BaseModel): """Graph data for a vault or directory.""" vault: str = Field(description="Vault name") path: str = Field(description="Root path for the graph") scope: str = Field(default="directory", description="'directory' or 'full'") nodes: list[GraphNode] = Field(description="Graph nodes (files and directories)") edges: list[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)") class ReloadResponse(BaseModel): """Index reload confirmation with per-vault stats.""" status: str = Field(description="Reload status ('ok' or 'error')") vaults: dict[str, Any] = Field(description="Per-vault file counts after reload") # --------------------------------------------------------------------------- # PDF # --------------------------------------------------------------------------- class PdfInfoResponse(BaseModel): """Response for ``GET /api/file/{vault}/pdf/info``.""" vault: str path: str pages: int = Field(description="Page count") title: str = Field(description="PDF title (metadata or filename)") author: str = Field(default="", description="PDF author") size_bytes: int = Field(description="File size in bytes") model_config = ConfigDict( json_schema_extra={ "example": { "vault": "TestVault", "path": "docs/rapport.pdf", "pages": 12, "title": "Rapport annuel", "author": "ObsiGate", "size_bytes": 524288, } } ) # --------------------------------------------------------------------------- # Search & replace # --------------------------------------------------------------------------- class ReplaceMatch(BaseModel): """A single file affected by a find/replace operation.""" model_config = ConfigDict(extra="allow") vault: str path: str title: str | None = None match_count: int | None = Field(default=None, description="Occurrences found (dry run)") replacements: int | None = Field(default=None, description="Occurrences replaced") preview: list[str] = Field(default_factory=list, description="Context snippets (dry run)") class ReplaceResponse(BaseModel): """Response for ``POST /api/search/replace``.""" model_config = ConfigDict(extra="allow") matches: list[ReplaceMatch] = Field(default_factory=list, description="Dry-run matches") replaced: list[ReplaceMatch] = Field(default_factory=list, description="Applied replacements") total_matches: int | None = Field(default=None, description="Total matches (dry run)") total_replacements: int = Field(description="Total replacements performed or previewed") dry_run: bool | None = Field(default=None, description="True when no file was written") # --------------------------------------------------------------------------- # Vaults, attachments & settings # --------------------------------------------------------------------------- class VaultStatsResponse(BaseModel): """Response for ``POST /api/vaults/add`` and ``GET /api/index/reload/{vault}``.""" model_config = ConfigDict(extra="allow") status: str = Field(default="ok") vault: str stats: dict[str, Any] = Field(default_factory=dict, description="Index statistics for the vault") class VaultActionResponse(BaseModel): """Response for ``DELETE /api/vaults/{vault}``.""" status: str = Field(default="ok") vault: str class VaultStatusEntry(BaseModel): """Per-vault status entry.""" file_count: int tag_count: int path: str = "" watching: bool = False class VaultsStatusResponse(BaseModel): """Response for ``GET /api/vaults/status``.""" vaults: dict[str, VaultStatusEntry] watcher_active: bool sse_clients: int class AttachmentRescanResponse(BaseModel): """Response for ``POST /api/attachments/rescan/{vault}``.""" status: str = Field(default="ok") vault: str attachment_count: int class AttachmentStatsResponse(BaseModel): """Response for ``GET /api/attachments/stats``.""" vaults: dict[str, Any] = Field(description="Vault name → attachment statistics") class VaultSettingsResponse(BaseModel): """Response for the per-vault display settings endpoints.""" model_config = ConfigDict(extra="allow") hideHiddenFiles: bool = Field(default=False, description="Hide dotfiles in the tree") class AllVaultSettingsResponse(BaseModel): """Response for ``GET /api/vaults/settings/all``.""" model_config = ConfigDict(extra="allow") class VaultFileEntry(BaseModel): """A file entry returned by the vault home listing.""" model_config = ConfigDict(extra="allow") name: str path: str vault: str size: int = 0 modified: float = 0 modified_iso: str | None = None extension: str = "" rel_dir: str | None = None class VaultFilesResponse(BaseModel): """Response for ``GET /api/vault/{vault}/files``.""" vault: str directory: str = "" recursive: bool = True count: int files: list[VaultFileEntry] # --------------------------------------------------------------------------- # Configuration, AI keys & diagnostics # --------------------------------------------------------------------------- class AppConfigResponse(BaseModel): """Application configuration (``GET/POST /api/config``).""" model_config = ConfigDict(extra="allow") class AIKeysResponse(BaseModel): """Masked AI provider keys (``GET /api/config/ai-keys``).""" model_config = ConfigDict(extra="allow") class AIKeyDeleteResponse(BaseModel): """Response for ``DELETE /api/config/ai-keys/{provider}``.""" status: str = Field(default="deleted") key: str = Field(description="Deleted environment variable name") class AITestResponse(BaseModel): """Response for ``POST /api/config/ai-keys/test`` (provider → status).""" model_config = ConfigDict(extra="allow") class AIModelsResponse(BaseModel): """Response for ``GET /api/config/ai-models``.""" model_config = ConfigDict(extra="allow") models: list[str] = Field(default_factory=list, description="Available model identifiers") source: str = Field(default="fallback", description="'live', 'fallback' or 'validation'") count: int | None = Field(default=None, description="Number of live models") error: str | None = Field(default=None, description="Provider/network error, if any") note: str | None = Field(default=None, description="Explanatory note when using fallback") capabilities: dict[str, dict[str, bool]] = Field( default_factory=dict, description="Per-model capability flags (chat, embeddings, vision, …)", ) class DiagnosticsResponse(BaseModel): """Response for ``GET /api/diagnostics``.""" model_config = ConfigDict(extra="allow") index: dict[str, Any] = Field(default_factory=dict) inverted_index: dict[str, Any] = Field(default_factory=dict) config: dict[str, Any] = Field(default_factory=dict) class DashboardVaultStat(BaseModel): """Per-vault dashboard statistics.""" name: str file_count: int tag_count: int total_size_bytes: int image_count: int = 0 class DashboardResponse(BaseModel): """Response for ``GET /api/dashboard``.""" vaults: list[DashboardVaultStat] total_files: int total_tags: int total_size_bytes: int total_images: int = 0 # --------------------------------------------------------------------------- # System / health (#85 — extrait de backend.main, comportement inchangé) # --------------------------------------------------------------------------- class HealthResponse(BaseModel): """Application health status. Déplacé depuis :mod:`backend.main` sans modification : pas de ``extra="allow"`` ici, pour préserver la validation actuelle des réponses (les champs enrichis de ``/api/health/detailed`` restent filtrés comme avant). """ status: str = Field(description="Health status ('ok' or 'error')") version: str = Field(description="Application version (x.y.z — latest release tag)") vaults: int = Field(description="Number of configured vaults") total_files: int = Field(description="Total indexed files across all vaults") total_tokens: int = Field(description="Total indexed tokens (approx.) across all vaults", default=0) last_full_index_ts: str = Field(description="ISO timestamp of last full index rebuild", default="") uptime_seconds: int = Field(description="Server uptime in seconds", default=0) git_describe: str = Field(default="", description="Full git describe string (commits beyond tag), empty if no git") git_commit: str = Field(default="", description="Short HEAD commit hash, empty if no git") # --------------------------------------------------------------------------- # Webhooks, sharing & conflicts # --------------------------------------------------------------------------- class WebhookModel(BaseModel): """A configured webhook.""" model_config = ConfigDict(extra="allow") id: str = Field(description="Webhook UUID") name: str url: str = Field(description="Target HTTP(S) URL") events: list[str] = Field(default_factory=list, description="Subscribed event types") secret: str | None = Field(default=None, description="HMAC-SHA256 signing secret") enabled: bool = True created_at: str | None = None last_fired_at: str | None = None class ShareModel(BaseModel): """A public document share.""" model_config = ConfigDict(extra="allow") id: str token: str = Field(description="Opaque share token") vault: str path: str url: str | None = Field(default=None, description="Relative public URL (/s/{token})") created_by: str | None = None created_at: str | None = None expires_at: str | None = None access_count: int = 0 last_accessed: str | None = None class ConflictEntry(BaseModel): """A Syncthing sync-conflict file.""" model_config = ConfigDict(extra="allow") vault: str path: str original_path: str | None = None class ConflictsResponse(BaseModel): """Response for ``GET /api/conflicts``.""" conflicts: list[ConflictEntry] total: int class ConflictResolveResponse(BaseModel): """Response for ``POST /api/conflicts/resolve``.""" status: str = Field(default="resolved") action: str | None = Field(default=None, description="'keep_local' or 'keep_conflict'") # --------------------------------------------------------------------------- # AI status & BooksLM context # --------------------------------------------------------------------------- class AIProviderStatus(BaseModel): """Availability of a single AI provider.""" available: bool model: str | None = Field(default=None, description="Default model when the provider is configured") class AIAutocompleteStatus(BaseModel): """Status of the local Ollama autocomplete backend.""" available: bool = False server_ok: bool = False model_loaded: bool = False model: str = "" error: str | None = None class AIStatusResponse(BaseModel): """Response for ``GET /api/ai/status``.""" configured: bool = Field(description="True when at least one provider has an API key") default_provider: str providers: dict[str, AIProviderStatus] autocomplete: AIAutocompleteStatus class BooksLMContextFile(BaseModel): """A single document included in the BooksLM context.""" path: str title: str content: str type: str = Field(default="markdown", description="'markdown' (and future types)") class BooksLMContextResponse(BaseModel): """Response for ``POST /api/ai/bookslm/context``.""" files: list[BooksLMContextFile] total_chars: int file_count: int directory_tree: str = "" max_total_chars: int = Field(default=200000, description="Configured context character limit") max_files: int = Field(default=200, description="Configured file-count limit") scope: str = Field(default="directory", description="Context scope: 'directory', 'documents' or 'general'")