Implement multi-stage Docker build with security hardening, add health check endpoint, optimize in-memory search with O(1) wikilink lookup, extract inline tags from markdown content, and enhance documentation with architecture diagrams and performance metrics

This commit is contained in:
2026-03-22 19:03:34 -04:00
parent d9add8dcba
commit d311a09527
11 changed files with 1097 additions and 182 deletions
+145 -31
View File
@@ -2,6 +2,7 @@ import os
import asyncio
import logging
import re
import threading
from pathlib import Path
from datetime import datetime, timezone
from typing import Dict, List, Optional, Any
@@ -16,6 +17,15 @@ index: Dict[str, Dict[str, Any]] = {}
# Vault config: {name: path}
vault_config: Dict[str, str] = {}
# Thread-safe lock for index updates
_index_lock = threading.Lock()
# O(1) lookup table for wikilink resolution: {filename_lower: [{vault, path}, ...]}
_file_lookup: Dict[str, List[Dict[str, str]]] = {}
# Maximum content size stored per file for in-memory search (bytes)
SEARCH_CONTENT_LIMIT = 100_000
# Supported text-based file extensions
SUPPORTED_EXTENSIONS = {
".md", ".txt", ".log", ".py", ".js", ".ts", ".jsx", ".tsx",
@@ -30,7 +40,15 @@ SUPPORTED_EXTENSIONS = {
def load_vault_config() -> Dict[str, str]:
"""Read VAULT_N_NAME / VAULT_N_PATH env vars and return {name: path}."""
"""Read VAULT_N_NAME / VAULT_N_PATH env vars and return {name: path}.
Scans environment variables ``VAULT_1_NAME``/``VAULT_1_PATH``,
``VAULT_2_NAME``/``VAULT_2_PATH``, etc. in sequential order.
Stops at the first missing pair.
Returns:
Dict mapping vault display names to filesystem paths.
"""
vaults: Dict[str, str] = {}
n = 1
while True:
@@ -43,8 +61,25 @@ def load_vault_config() -> Dict[str, str]:
return vaults
# Regex for extracting inline #tags from markdown body (excludes code blocks)
_INLINE_TAG_RE = re.compile(r'(?:^|\s)#([a-zA-Z][a-zA-Z0-9_/-]{1,50})', re.MULTILINE)
# Regex patterns for stripping code blocks before inline tag extraction
_CODE_BLOCK_RE = re.compile(r'```.*?```', re.DOTALL)
_INLINE_CODE_RE = re.compile(r'`[^`]+`')
def _extract_tags(post: frontmatter.Post) -> List[str]:
"""Extract tags from frontmatter metadata."""
"""Extract tags from frontmatter metadata.
Handles tags as comma-separated string, list, or other types.
Strips leading ``#`` from each tag.
Args:
post: Parsed frontmatter Post object.
Returns:
List of cleaned tag strings.
"""
tags = post.metadata.get("tags", [])
if isinstance(tags, str):
tags = [t.strip().lstrip("#") for t in tags.split(",") if t.strip()]
@@ -55,8 +90,36 @@ def _extract_tags(post: frontmatter.Post) -> List[str]:
return tags
def _extract_inline_tags(content: str) -> List[str]:
"""Extract inline #tag patterns from markdown content.
Strips fenced and inline code blocks before scanning to avoid
false positives from code comments or shell commands.
Args:
content: Raw markdown content (without frontmatter).
Returns:
Deduplicated list of inline tag strings.
"""
stripped = _CODE_BLOCK_RE.sub('', content)
stripped = _INLINE_CODE_RE.sub('', stripped)
return list(set(_INLINE_TAG_RE.findall(stripped)))
def _extract_title(post: frontmatter.Post, filepath: Path) -> str:
"""Extract title from frontmatter or derive from filename."""
"""Extract title from frontmatter or derive from filename.
Falls back to the file stem with hyphens/underscores replaced by spaces
when no ``title`` key is present in frontmatter.
Args:
post: Parsed frontmatter Post object.
filepath: Path to the source file.
Returns:
Human-readable title string.
"""
title = post.metadata.get("title", "")
if not title:
title = filepath.stem.replace("-", " ").replace("_", " ")
@@ -64,7 +127,17 @@ def _extract_title(post: frontmatter.Post, filepath: Path) -> str:
def parse_markdown_file(raw: str) -> frontmatter.Post:
"""Parse markdown frontmatter, falling back to plain content if YAML is invalid."""
"""Parse markdown frontmatter, falling back to plain content if YAML is invalid.
When the YAML block is malformed, strips it and returns a Post with
empty metadata so that rendering can still proceed.
Args:
raw: Full raw markdown string including optional frontmatter.
Returns:
``frontmatter.Post`` with ``.content`` and ``.metadata`` attributes.
"""
try:
return frontmatter.loads(raw)
except Exception as exc:
@@ -78,7 +151,19 @@ def parse_markdown_file(raw: str) -> frontmatter.Post:
def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
"""Synchronously scan a single vault directory."""
"""Synchronously scan a single vault directory and build file index.
Walks the vault tree, reads supported files, extracts metadata
(tags, title, content preview) and stores a capped content snapshot
for in-memory full-text search.
Args:
vault_name: Display name of the vault.
vault_path: Absolute filesystem path to the vault root.
Returns:
Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str).
"""
vault_root = Path(vault_path)
files: List[Dict[str, Any]] = []
tag_counts: Dict[str, int] = {}
@@ -113,6 +198,9 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
if ext == ".md":
post = parse_markdown_file(raw)
tags = _extract_tags(post)
# Merge inline #tags found in content body
inline_tags = _extract_inline_tags(post.content)
tags = list(set(tags) | set(inline_tags))
title = _extract_title(post, fpath)
content_preview = post.content[:200].strip()
@@ -121,6 +209,7 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
"title": title,
"tags": tags,
"content_preview": content_preview,
"content": raw[:SEARCH_CONTENT_LIMIT],
"size": stat.st_size,
"modified": modified,
"extension": ext,
@@ -138,7 +227,12 @@ def _scan_vault(vault_name: str, vault_path: str) -> Dict[str, Any]:
async def build_index() -> None:
"""Build the full in-memory index for all configured vaults."""
"""Build the full in-memory index for all configured vaults.
Runs vault scans concurrently in a thread pool, then performs
an atomic swap of the global index and lookup table under a lock
to ensure thread-safe reads during reload.
"""
global index, vault_config
vault_config = load_vault_config()
@@ -156,14 +250,35 @@ async def build_index() -> None:
for name, task in tasks:
new_index[name] = await task
index.clear()
index.update(new_index)
# Build O(1) lookup table for wikilink resolution
new_lookup: Dict[str, List[Dict[str, str]]] = {}
for vname, vdata in new_index.items():
for f in vdata["files"]:
entry = {"vault": vname, "path": f["path"]}
fname = f["path"].rsplit("/", 1)[-1].lower()
fpath_lower = f["path"].lower()
for key in (fname, fpath_lower):
if key not in new_lookup:
new_lookup[key] = []
new_lookup[key].append(entry)
# Atomic swap under lock for thread safety during concurrent reads
with _index_lock:
index.clear()
index.update(new_index)
_file_lookup.clear()
_file_lookup.update(new_lookup)
total_files = sum(len(v["files"]) for v in index.values())
logger.info(f"Index built: {len(index)} vaults, {total_files} total files")
async def reload_index() -> Dict[str, Any]:
"""Force a full re-index and return stats."""
"""Force a full re-index of all vaults and return per-vault statistics.
Returns:
Dict mapping vault names to their file/tag counts.
"""
await build_index()
stats = {}
for name, data in index.items():
@@ -172,39 +287,38 @@ async def reload_index() -> Dict[str, Any]:
def get_vault_names() -> List[str]:
"""Return the list of all indexed vault names."""
return list(index.keys())
def get_vault_data(vault_name: str) -> Optional[Dict[str, Any]]:
"""Return the full index data for a vault, or ``None`` if not found."""
return index.get(vault_name)
def find_file_in_index(link_target: str, current_vault: str) -> Optional[Dict[str, str]]:
"""Find a file matching a wikilink target. Search current vault first, then all."""
"""Find a file matching a wikilink target using O(1) lookup table.
Searches by filename first, then by full relative path.
Prefers results from *current_vault* when multiple matches exist.
Args:
link_target: The wikilink target (e.g. ``"My Note"`` or ``"folder/My Note"``).
current_vault: Name of the vault the link originates from.
Returns:
Dict with ``vault`` and ``path`` keys, or ``None`` if not found.
"""
target_lower = link_target.lower().strip()
if not target_lower.endswith(".md"):
target_lower += ".md"
def _search_vault(vname: str, vdata: Dict[str, Any]):
for f in vdata["files"]:
fpath = f["path"].lower()
fname = fpath.rsplit("/", 1)[-1]
if fname == target_lower or fpath == target_lower:
return {"vault": vname, "path": f["path"]}
candidates = _file_lookup.get(target_lower, [])
if not candidates:
return None
# Search current vault first
if current_vault in index:
result = _search_vault(current_vault, index[current_vault])
if result:
return result
# Search all other vaults
for vname, vdata in index.items():
if vname == current_vault:
continue
result = _search_vault(vname, vdata)
if result:
return result
return None
# Prefer current vault when multiple vaults contain a match
for c in candidates:
if c["vault"] == current_vault:
return c
return candidates[0]
+318 -65
View File
@@ -1,14 +1,16 @@
import re
import html as html_mod
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from typing import Optional, List, Dict, Any
import frontmatter
import mistune
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException, Query, Body
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, PlainTextResponse
from fastapi.responses import HTMLResponse, FileResponse
from pydantic import BaseModel, Field
from backend.indexer import (
build_index,
@@ -17,6 +19,7 @@ from backend.indexer import (
get_vault_data,
find_file_in_index,
parse_markdown_file,
_extract_tags,
SUPPORTED_EXTENSIONS,
)
from backend.search import search, get_all_tags
@@ -27,29 +30,182 @@ logging.basicConfig(
)
logger = logging.getLogger("obsigate")
app = FastAPI(title="ObsiGate", version="1.0.0")
# ---------------------------------------------------------------------------
# Pydantic response models
# ---------------------------------------------------------------------------
class VaultInfo(BaseModel):
"""Summary information about a configured vault."""
name: str = Field(description="Display name of the vault")
file_count: int = Field(description="Number of indexed files")
tag_count: int = Field(description="Number of unique tags")
class BrowseItem(BaseModel):
"""A single entry (file or directory) returned by the browse endpoint."""
name: str
path: str
type: str = Field(description="'file' or 'directory'")
children_count: Optional[int] = None
size: Optional[int] = None
extension: Optional[str] = None
class BrowseResponse(BaseModel):
"""Paginated directory listing for a vault."""
vault: str
path: str
items: List[BrowseItem]
class FileContentResponse(BaseModel):
"""Rendered file content with metadata."""
vault: str
path: str
title: str
tags: List[str]
frontmatter: Dict[str, Any]
html: str
raw_length: int
extension: str
is_markdown: bool
class FileRawResponse(BaseModel):
"""Raw text content of a file."""
vault: str
path: str
raw: str
class FileSaveResponse(BaseModel):
"""Confirmation after saving a file."""
status: str
vault: str
path: str
size: int
class FileDeleteResponse(BaseModel):
"""Confirmation after deleting a file."""
status: str
vault: str
path: str
class SearchResultItem(BaseModel):
"""A single search result."""
vault: str
path: str
title: str
tags: List[str]
score: int
snippet: str
modified: str
class SearchResponse(BaseModel):
"""Full-text search response."""
query: str
vault_filter: str
tag_filter: Optional[str]
count: int
results: List[SearchResultItem]
class TagsResponse(BaseModel):
"""Tag aggregation response."""
vault_filter: Optional[str]
tags: Dict[str, int]
class ReloadResponse(BaseModel):
"""Index reload confirmation with per-vault stats."""
status: str
vaults: Dict[str, Any]
class HealthResponse(BaseModel):
"""Application health status."""
status: str
version: str
vaults: int
total_files: int
# ---------------------------------------------------------------------------
# Application lifespan (replaces deprecated on_event)
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: build index on startup, cleanup on shutdown."""
logger.info("ObsiGate starting \u2014 building index...")
await build_index()
logger.info("ObsiGate ready.")
yield
app = FastAPI(title="ObsiGate", version="1.1.0", lifespan=lifespan)
# Resolve frontend path relative to this file
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
# ---------------------------------------------------------------------------
# Startup
# Path safety helper
# ---------------------------------------------------------------------------
@app.on_event("startup")
async def startup_event():
logger.info("ObsiGate starting — building index...")
await build_index()
logger.info("ObsiGate ready.")
def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
"""Resolve a relative path safely within the vault root.
Prevents directory traversal attacks by ensuring the resolved
absolute path is a descendant of *vault_root*.
Args:
vault_root: The vault's root directory (absolute).
relative_path: The user-supplied relative path.
Returns:
Resolved absolute ``Path``.
Raises:
HTTPException(403): When the resolved path escapes the vault root.
"""
resolved = (vault_root / relative_path).resolve()
vault_resolved = vault_root.resolve()
try:
resolved.relative_to(vault_resolved)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
return resolved
# ---------------------------------------------------------------------------
# Markdown rendering helpers
# Markdown rendering helpers (singleton renderer)
# ---------------------------------------------------------------------------
# Cached mistune renderer — avoids re-creating on every request
_markdown_renderer = mistune.create_markdown(
escape=False,
plugins=["table", "strikethrough", "footnotes", "task_lists"],
)
def _convert_wikilinks(content: str, current_vault: str) -> str:
"""Convert [[wikilinks]] and [[target|display]] to HTML links."""
"""Convert ``[[wikilinks]]`` and ``[[target|display]]`` to clickable HTML.
Resolved links get a ``data-vault`` / ``data-path`` attribute pair.
Unresolved links are rendered as ``<span class="wikilink-missing">``.
Args:
content: Markdown string potentially containing wikilinks.
current_vault: Active vault name for resolution priority.
Returns:
Markdown string with wikilinks replaced by HTML anchors.
"""
def _replace(match):
target = match.group(1).strip()
display = match.group(2).strip() if match.group(2) else target
@@ -67,22 +223,48 @@ def _convert_wikilinks(content: str, current_vault: str) -> str:
def _render_markdown(raw_md: str, vault_name: str) -> str:
"""Render markdown string to HTML with wikilink support."""
"""Render a markdown string to HTML with wikilink support.
Uses the cached singleton mistune renderer for performance.
Args:
raw_md: Raw markdown text (frontmatter already stripped).
vault_name: Current vault for wikilink resolution context.
Returns:
HTML string.
"""
converted = _convert_wikilinks(raw_md, vault_name)
md = mistune.create_markdown(
escape=False,
plugins=["table", "strikethrough", "footnotes", "task_lists"],
)
return md(converted)
return _markdown_renderer(converted)
# ---------------------------------------------------------------------------
# API Endpoints
# ---------------------------------------------------------------------------
@app.get("/api/vaults")
@app.get("/api/health", response_model=HealthResponse)
async def api_health():
"""Health check endpoint for Docker and monitoring.
Returns:
Application status, version, vault count and total file count.
"""
total_files = sum(len(v["files"]) for v in index.values())
return {
"status": "ok",
"version": app.version,
"vaults": len(index),
"total_files": total_files,
}
@app.get("/api/vaults", response_model=List[VaultInfo])
async def api_vaults():
"""List configured vaults with file counts."""
"""List all configured vaults with file and tag counts.
Returns:
List of vault summary objects.
"""
result = []
for name, data in index.items():
result.append({
@@ -93,15 +275,27 @@ async def api_vaults():
return result
@app.get("/api/browse/{vault_name}")
@app.get("/api/browse/{vault_name}", response_model=BrowseResponse)
async def api_browse(vault_name: str, path: str = ""):
"""Browse directories and files in a vault at a given path level."""
"""Browse directories and files in a vault at a given path level.
Returns sorted entries (directories first, then files) with metadata.
Hidden files/directories (starting with ``"."`` ) are excluded.
Args:
vault_name: Name of the vault to browse.
path: Relative directory path within the vault (empty = root).
Returns:
``BrowseResponse`` with vault name, path, and item list.
"""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
target = vault_root / path if path else vault_root
# Path traversal protection
target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve()
if not target.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
@@ -161,15 +355,23 @@ EXT_TO_LANG = {
}
@app.get("/api/file/{vault_name}/raw")
@app.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file")):
"""Return raw file content."""
"""Return raw file content as plain text.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileRawResponse`` with vault, path, and raw text content.
"""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = vault_root / path
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
@@ -180,13 +382,21 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
@app.get("/api/file/{vault_name}/download")
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file")):
"""Download a file as attachment."""
"""Download a file as an attachment.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileResponse`` with ``application/octet-stream`` content-type.
"""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = vault_root / path
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
@@ -198,28 +408,37 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
)
@app.put("/api/file/{vault_name}/save")
async def api_file_save(vault_name: str, path: str = Query(..., description="Relative path to file"), body: dict = {}):
"""Save file content."""
@app.put("/api/file/{vault_name}/save", response_model=FileSaveResponse)
async def api_file_save(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
body: dict = Body(...),
):
"""Save (overwrite) a file's content.
Expects a JSON body with a ``content`` key containing the new text.
The path is validated against traversal attacks before writing.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
body: JSON body with ``content`` string.
Returns:
``FileSaveResponse`` confirming the write.
"""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = vault_root / path
# Security: ensure path is within vault
try:
file_path.resolve().relative_to(vault_root.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
# Get content from body
content = body.get('content', '')
try:
file_path.write_text(content, encoding="utf-8")
logger.info(f"File saved: {vault_name}/{path}")
@@ -231,20 +450,25 @@ async def api_file_save(vault_name: str, path: str = Query(..., description="Rel
raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}")
@app.delete("/api/file/{vault_name}")
@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file")):
"""Delete a file."""
"""Delete a file from the vault.
The path is validated against traversal attacks before deletion.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileDeleteResponse`` confirming the deletion.
"""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = vault_root / path
try:
file_path.resolve().relative_to(vault_root.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
@@ -260,15 +484,27 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
@app.get("/api/file/{vault_name}")
@app.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file")):
"""Return rendered HTML + metadata for a file."""
"""Return rendered HTML and metadata for a file.
Markdown files are parsed for frontmatter, rendered with wikilink
support, and returned with extracted tags. Other supported file
types are syntax-highlighted as code blocks.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileContentResponse`` with HTML, metadata, and tags.
"""
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = vault_root / path
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
@@ -279,14 +515,8 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
if ext == ".md":
post = parse_markdown_file(raw)
# Extract metadata
tags = post.metadata.get("tags", [])
if isinstance(tags, str):
tags = [t.strip().lstrip("#") for t in tags.split(",") if t.strip()]
elif isinstance(tags, list):
tags = [str(t).strip().lstrip("#") for t in tags]
else:
tags = []
# Extract metadata using shared indexer logic
tags = _extract_tags(post)
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
html_content = _render_markdown(post.content, vault_name)
@@ -321,27 +551,50 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
}
@app.get("/api/search")
@app.get("/api/search", response_model=SearchResponse)
async def api_search(
q: str = Query("", description="Search query"),
vault: str = Query("all", description="Vault filter"),
tag: Optional[str] = Query(None, description="Tag filter"),
):
"""Full-text search across vaults."""
"""Full-text search across vaults with relevance scoring.
Supports combining free-text queries with tag filters.
Results are ranked by a multi-factor scoring algorithm.
Args:
q: Free-text search string.
vault: Vault name or ``"all"`` to search everywhere.
tag: Comma-separated tag names to require.
Returns:
``SearchResponse`` with ranked results and snippets.
"""
results = search(q, vault_filter=vault, tag_filter=tag)
return {"query": q, "vault_filter": vault, "tag_filter": tag, "count": len(results), "results": results}
@app.get("/api/tags")
@app.get("/api/tags", response_model=TagsResponse)
async def api_tags(vault: Optional[str] = Query(None, description="Vault filter")):
"""Return all unique tags with counts."""
"""Return all unique tags with occurrence counts.
Args:
vault: Optional vault name to restrict tag aggregation.
Returns:
``TagsResponse`` with tags sorted by descending count.
"""
tags = get_all_tags(vault_filter=vault)
return {"vault_filter": vault, "tags": tags}
@app.get("/api/index/reload")
@app.get("/api/index/reload", response_model=ReloadResponse)
async def api_reload():
"""Force a re-index of all vaults."""
"""Force a full re-index of all configured vaults.
Returns:
``ReloadResponse`` with per-vault file and tag counts.
"""
stats = await reload_index()
return {"status": "ok", "vaults": stats}
+85 -28
View File
@@ -1,34 +1,44 @@
import re
import logging
from pathlib import Path
from typing import List, Dict, Any, Optional
from backend.indexer import index, get_vault_data
from backend.indexer import index
logger = logging.getLogger("obsigate.search")
# Default maximum number of search results returned
DEFAULT_SEARCH_LIMIT = 200
def _normalize_tag_filter(tag_filter: Optional[str]) -> List[str]:
"""Parse a comma-separated tag filter string into a clean list.
Strips whitespace and leading ``#`` from each tag.
Args:
tag_filter: Raw tag filter string (e.g. ``"docker,linux"``).
Returns:
List of normalised tag strings, empty list if input is falsy.
"""
if not tag_filter:
return []
return [tag.strip().lstrip("#") for tag in tag_filter.split(",") if tag.strip()]
def _read_file_content(vault_name: str, file_path: str) -> str:
"""Read raw markdown content of a file from disk."""
vault_data = get_vault_data(vault_name)
if not vault_data:
return ""
vault_root = Path(vault_data["path"])
full_path = vault_root / file_path
try:
return full_path.read_text(encoding="utf-8", errors="replace")
except Exception:
return ""
def _extract_snippet(content: str, query: str, context_chars: int = 120) -> str:
"""Extract a text snippet around the first occurrence of query."""
"""Extract a text snippet around the first occurrence of *query*.
Returns up to ``context_chars`` characters before and after the match.
Falls back to the first 200 characters when the query is not found.
Args:
content: Full text to search within.
query: The search term.
context_chars: Number of context characters on each side.
Returns:
Snippet string, optionally prefixed/suffixed with ``...``.
"""
lower_content = content.lower()
lower_query = query.lower()
pos = lower_content.find(lower_query)
@@ -51,10 +61,30 @@ def search(
query: str,
vault_filter: str = "all",
tag_filter: Optional[str] = None,
limit: int = DEFAULT_SEARCH_LIMIT,
) -> List[Dict[str, Any]]:
"""
Full-text search across indexed vaults.
Returns scored results with snippets.
"""Full-text search across indexed vaults with relevance scoring.
Scoring heuristics (when a text query is provided):
- **+20** exact title match (case-insensitive)
- **+10** partial title match
- **+5** query found in file path
- **+3** query matches a tag name
- **+1 per occurrence** in content (capped at 10)
When only tag filters are active, all matching files receive score 1.
Results are sorted descending by score and capped at *limit*.
Uses the in-memory cached content from the index — **no disk I/O**.
Args:
query: Free-text search string.
vault_filter: Vault name or ``"all"``.
tag_filter: Comma-separated tag names to require.
limit: Maximum number of results to return.
Returns:
List of result dicts sorted by descending relevance score.
"""
query = query.strip() if query else ""
has_query = len(query) > 0
@@ -63,6 +93,7 @@ def search(
if not has_query and not selected_tags:
return []
query_lower = query.lower()
results: List[Dict[str, Any]] = []
for vault_name, vault_data in index.items():
@@ -70,6 +101,7 @@ def search(
continue
for file_info in vault_data["files"]:
# Tag filter: all selected tags must be present
if selected_tags and not all(tag in file_info["tags"] for tag in selected_tags):
continue
@@ -77,14 +109,32 @@ def search(
snippet = file_info.get("content_preview", "")
if has_query:
# Title match (high weight)
if query.lower() in file_info["title"].lower():
title_lower = file_info["title"].lower()
# Exact title match (highest weight)
if query_lower == title_lower:
score += 20
# Partial title match
elif query_lower in title_lower:
score += 10
# Content match
content = _read_file_content(vault_name, file_info["path"])
if query.lower() in content.lower():
score += 1
# Path match (folder/filename relevance)
if query_lower in file_info["path"].lower():
score += 5
# Tag name match
for tag in file_info.get("tags", []):
if query_lower in tag.lower():
score += 3
break # count once per file
# Content match — use cached content (no disk I/O)
content = file_info.get("content", "")
content_lower = content.lower()
if query_lower in content_lower:
# Frequency-based scoring, capped to avoid over-weighting
occurrences = content_lower.count(query_lower)
score += min(occurrences, 10)
snippet = _extract_snippet(content, query)
else:
# Tag-only filter: all matching files get score 1
@@ -102,11 +152,18 @@ def search(
})
results.sort(key=lambda x: -x["score"])
return results
return results[:limit]
def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]:
"""Aggregate tag counts across vaults."""
"""Aggregate tag counts across vaults, sorted by descending count.
Args:
vault_filter: Optional vault name to restrict to a single vault.
Returns:
Dict mapping tag names to their total occurrence count.
"""
merged: Dict[str, int] = {}
for vault_name, vault_data in index.items():
if vault_filter and vault_name != vault_filter: