diff --git a/doc_wasm/Cargo.lock b/doc_wasm/Cargo.lock
new file mode 100644
index 0000000..17bfbbf
--- /dev/null
+++ b/doc_wasm/Cargo.lock
@@ -0,0 +1,224 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "doc_wasm"
+version = "0.1.0"
+dependencies = [
+ "pulldown-cmark",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getopts"
+version = "0.2.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
+dependencies = [
+ "unicode-width",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pulldown-cmark"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625"
+dependencies = [
+ "bitflags",
+ "getopts",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.121"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/doc_wasm/Cargo.toml b/doc_wasm/Cargo.toml
new file mode 100644
index 0000000..6d638e1
--- /dev/null
+++ b/doc_wasm/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "doc_wasm"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+wasm-bindgen = "0.2"
+pulldown-cmark = "0.11"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+[profile.release]
+lto = true
+opt-level = "s"
+strip = true
diff --git a/doc_wasm/src/lib.rs b/doc_wasm/src/lib.rs
new file mode 100644
index 0000000..6fd532f
--- /dev/null
+++ b/doc_wasm/src/lib.rs
@@ -0,0 +1,29 @@
+use wasm_bindgen::prelude::*;
+
+#[wasm_bindgen]
+pub fn render_markdown(md: &str) -> String {
+ let parser = pulldown_cmark::Parser::new(md);
+ let mut html = String::new();
+ pulldown_cmark::html::push_html(&mut html, parser);
+ // wrap tables
+ html = html.replace("
", "");
+ html
+}
+
+#[wasm_bindgen]
+pub fn module_list() -> String {
+ serde_json::to_string(&[
+ ("01_auth", "安全認證", "Authentication"),
+ ("02_health", "健康檢查", "Health"),
+ ("03_register", "檔案註冊", "File Registration"),
+ ("04_lookup", "檔案屬性查詢", "File Lookup"),
+ ("05_process", "處理流程", "Processing"),
+ ("06_search", "搜尋功能", "Search"),
+ ("07_identity", "身份識別", "Identity"),
+ ("08_identity_agent", "智能身份綁定", "Smart Identity Binding"),
+ ("08_media", "串流與截圖", "Streaming & Thumbnails"),
+ ("09_tmdb", "TMDb 整合", "TMDb Integration"),
+ ("10_pipeline", "生產線", "Pipeline"),
+ ("12_agent", "智慧代理", "AI Agents"),
+ ]).unwrap_or_default()
+}
diff --git a/docs_v1.0/API_WORKSPACE/Makefile b/docs_v1.0/API_WORKSPACE/Makefile
new file mode 100644
index 0000000..1d434d5
--- /dev/null
+++ b/docs_v1.0/API_WORKSPACE/Makefile
@@ -0,0 +1,15 @@
+PYTHON := /opt/homebrew/bin/python3.11
+WASM_PKG := ../../doc_wasm/pkg
+
+deploy:
+ @echo "Building HTML docs from modules..."
+ $(PYTHON) ../../scripts/build_docs.py
+ @echo " ✅ Updated ../doc/ (user docs)"
+ @echo " ✅ Updated ../doc_developer/ (developer docs)"
+ @echo "Building WASM doc..."
+ cd ../../doc_wasm && wasm-pack build --target web --no-opt 2>&1 | tail -2
+ cp $(WASM_PKG)/doc_wasm_bg.wasm ../doc_wasm/pkg/
+ cp $(WASM_PKG)/doc_wasm.js ../doc_wasm/pkg/
+ cp ../../docs_v1.0/API_WORKSPACE/modules/0*.md ../doc_wasm/modules/
+ cp ../../docs_v1.0/API_WORKSPACE/modules/1*.md ../doc_wasm/modules/
+ @echo " ✅ Updated ../doc_wasm/ (WASM docs)"
diff --git a/docs_v1.0/doc_wasm/index.html b/docs_v1.0/doc_wasm/index.html
new file mode 100644
index 0000000..7789a3d
--- /dev/null
+++ b/docs_v1.0/doc_wasm/index.html
@@ -0,0 +1,127 @@
+
+
+
+
+
+Momentry API Docs (WASM)
+
+
+
+
+
+
+
+
diff --git a/docs_v1.0/doc_wasm/modules/01_auth.md b/docs_v1.0/doc_wasm/modules/01_auth.md
new file mode 100644
index 0000000..96b8979
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/01_auth.md
@@ -0,0 +1,280 @@
+
+
+
+
+## Base URL
+
+| Environment | URL | Purpose |
+|-------------|-----|---------|
+| Production | `http://localhost:3002` | Production deployment |
+| External (M5) | `https://m5api.momentry.ddns.net` | Remote access |
+
+## Variables
+
+All examples in this documentation use these environment variables:
+
+```bash
+API="http://localhost:3002"
+KEY="your-api-key-here"
+```
+
+## Authentication
+
+All endpoints under `/api/v1/*` require authentication.
+The following endpoints are public (no auth needed):
+
+- `GET /health`
+- `POST /api/v1/auth/login`
+- `POST /api/v1/auth/logout`
+
+### Three Authentication Modes
+
+The system supports three authentication methods, checked in **priority order** by the middleware:
+
+```
+Middleware priority:
+ 1. Session Cookie (Portal/browser)
+ 2. JWT Bearer (API clients, CLI)
+ 3. API Key Header (legacy compatibility)
+ 4. API Key Query Param (?api_key=)
+```
+
+| Mode | Transport | Expiry | Scope | Best for |
+|------|-----------|--------|-------|----------|
+| **Session Cookie** | `Cookie: session_id=` | 24h | per-browser session | Portal (browser) |
+| **JWT** | `Authorization: Bearer ` | 1h | per-login token | API clients, CLI, scripts |
+| **API Key** | `X-API-Key: ` | 90d | fixed key for automation | Legacy scripts, WordPress |
+
+---
+
+### Login
+
+**Default accounts & API keys:**
+
+| Username | Password | API Key | Role |
+|----------|----------|---------|------|
+| `admin` | `admin` | — | admin |
+| `demo` | `demo` | `muser_demo_key_32chars_abcdef1234567890` | user |
+
+The demo API key is set via `MOMENTRY_DEMO_API_KEY` env var and can be used in place of JWT for marcom integrations:
+
+```bash
+# Using API key instead of JWT
+curl -s "$API/api/v1/files/scan" -H "X-API-Key: muser_demo_key_32chars_abcdef1234567890"
+```
+
+```bash
+# Login as admin
+curl -s -X POST "$API/api/v1/auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username": "admin", "password": "admin"}'
+
+# Login as demo user
+curl -s -X POST "$API/api/v1/auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username": "demo", "password": "demo"}'
+```
+
+#### Success Response
+
+```json
+{
+ "success": true,
+ "jwt": "eyJhbGciOiJIUzI1NiIs...",
+ "api_key": "muser_...",
+ "user": {
+ "username": "admin",
+ "role": "admin"
+ },
+ "expires_at": "2026-05-18T13:00:00Z"
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `jwt` | string | JWT access token. Use as `Authorization: Bearer `. Expires in 1 hour. |
+| `api_key` | string | Legacy API key. Use as `X-API-Key: `. Good for 90 days. |
+| `user.username` | string | Username |
+| `user.role` | string | Role: `admin`, `user`, or `readonly` |
+| `expires_at` | string | ISO8601 timestamp of JWT expiration |
+
+The login endpoint also sets a `Set-Cookie` header for browser-based clients:
+
+```
+Set-Cookie: session_id=; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400
+```
+
+#### Error Response (401)
+
+```json
+{
+ "success": false,
+ "message": "Invalid username or password"
+}
+```
+
+---
+
+### Using JWT
+
+JWT is preferred for API clients (CLI scripts, WordPress). It is validated by the middleware without a database lookup (stateless).
+
+```bash
+# Login and capture JWT
+JWT=$(curl -s -X POST "$API/api/v1/auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"admin"}' | python3 -c "import json,sys;print(json.load(sys.stdin)['jwt'])")
+
+# Use JWT for all subsequent requests
+curl -H "Authorization: Bearer $JWT" "$API/api/v1/files/scan"
+curl -H "Authorization: Bearer $JWT" "$API/api/v1/resource/tmdb"
+```
+
+JWT is short-lived (1 hour). When it expires, request a new one via login.
+
+---
+
+### Using Session Cookie (Browser)
+
+Browser-based clients (Portal) get a session cookie automatically after login. The browser sends the cookie with every request—no manual header needed.
+
+```bash
+# Login captures the session cookie from Set-Cookie header
+curl -v -X POST "$API/api/v1/auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"admin"}' 2>&1 | grep "Set-Cookie"
+
+# Browser automatically sends: Cookie: session_id=
+# No manual header needed for subsequent requests
+```
+
+The session cookie is HttpOnly (not accessible from JavaScript) and SameSite=Strict (protected against CSRF).
+
+---
+
+### Using Legacy API Key
+
+```bash
+curl -H "X-API-Key: $KEY" "$API/api/v1/files/scan"
+
+# Also accepted via Bearer header (non-JWT format) or query parameter:
+curl -H "Authorization: Bearer $KEY" "$API/api/v1/files/scan"
+curl "$API/api/v1/files/scan?api_key=$KEY"
+```
+
+API keys are validated via SHA256 hash lookup in the database. They are long-lived (90 days) and intended for automation.
+
+### Obtaining an API Key (CLI)
+
+```bash
+momentry api-key create "My API Key" --key-type user
+```
+
+---
+
+### Logout
+
+```bash
+# Logout using the session cookie (browser)
+curl -X POST "$API/api/v1/auth/logout" \
+ -H "Cookie: session_id="
+```
+
+#### What logout does
+
+| Auth mode | Effect |
+|-----------|--------|
+| **Session Cookie** | Session deleted from database. Same cookie returns 401 on subsequent requests. |
+| **JWT** | JWT remains valid until expiry. (JWT is stateless — logout adds JWT to a blacklist only if API key mode is used.) |
+| **API Key** | API key remains valid. (Legacy keys are shared across sessions — revoking would break other clients.) |
+
+#### Example: full session lifecycle
+
+```bash
+# 1. Login
+SESSION_ID=$(curl -s -D - -X POST "$API/api/v1/auth/login" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"admin"}' | grep "Set-Cookie" | sed 's/.*session_id=\([^;]*\).*/\1/')
+
+# 2. Use session (works)
+curl -s -o /dev/null -w "HTTP %{http_code}\n" "$API/api/v1/resource/tmdb" \
+ -H "Cookie: session_id=$SESSION_ID"
+# → HTTP 200
+
+# 3. Logout
+curl -s -X POST "$API/api/v1/auth/logout" \
+ -H "Cookie: session_id=$SESSION_ID"
+# → {"success": true}
+
+# 4. Use session again (rejected)
+curl -s -o /dev/null -w "HTTP %{http_code}\n" "$API/api/v1/resource/tmdb" \
+ -H "Cookie: session_id=$SESSION_ID"
+# → HTTP 401
+```
+
+---
+
+### Authentication Flow Summary
+
+```
+Login Request
+ │
+ ▼
+┌──────────────────┐
+│ 1. Check users │ ← users table (argon2 password verify)
+│ table │
+└──────┬───────────┘
+ │
+ ┌───┴───┐
+ │ match │
+ └───┬───┘
+ │
+ ▼
+┌──────────────────┐
+│ 2. Create JWT │ ← 1h expiry, signed with JWT_SECRET
+├──────────────────┤
+│ 3. Create │ ← 24h expiry, stored in sessions table
+│ session │
+├──────────────────┤
+│ 4. Set-Cookie │ ← HttpOnly, SameSite=Strict, Path=/
+├──────────────────┤
+│ 5. Return │ ← JWT + api_key + user info to client
+└──────────────────┘
+```
+
+```
+Protected Request
+ │
+ ▼
+┌──────────────────────┐
+│ Middleware checks: │
+│ │
+│ 1. Cookie session? │ → DB lookup session → get api_key → verify
+│ │
+│ 2. JWT Bearer? │ → verify JWT signature → decode claims
+│ │
+│ 3. X-API-Key? │ → SHA256 hash → DB lookup → verify
+│ │
+│ 4. ?api_key=? │ → same as #3
+│ │
+│ 5. None → 401 │
+└──────────────────────┘
+```
+
+---
+
+### Error Responses
+
+| HTTP | When |
+|------|------|
+| `401` | Missing or invalid authentication |
+| `401` | Session expired or logged out |
+| `401` | JWT expired |
+| `401` | API key revoked or inactive |
+
+---
+
+### Related
+
+- `POST /api/v1/resource/tmdb/check` — test authentication + TMDb API connectivity
+- `GET /health/detailed` — view auth status (integrations section)
diff --git a/docs_v1.0/doc_wasm/modules/02_health.md b/docs_v1.0/doc_wasm/modules/02_health.md
new file mode 100644
index 0000000..46f7df0
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/02_health.md
@@ -0,0 +1,147 @@
+
+
+
+
+## Health Check
+
+### `GET /health`
+
+**Auth**: Public
+**Scope**: system-level
+
+Returns basic server health status — used by load balancers and monitoring.
+
+#### Example
+
+```bash
+curl "$API/health" | jq '{status, version}'
+```
+
+#### Response (200)
+
+```json
+{
+ "status": "ok",
+ "version": "1.0.0",
+ "build_git_hash": "3a6c1865",
+ "build_timestamp": "2026-05-16T13:38:15Z",
+ "uptime_ms": 3015
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `status` | string | `ok` or `degraded` |
+| `version` | string | Semver version |
+| `build_git_hash` | string | Git commit hash |
+| `build_timestamp` | string | Binary build time |
+| `uptime_ms` | integer | Milliseconds since server start |
+
+---
+
+### `GET /health/detailed`
+
+**Auth**: Required
+**Scope**: system-level
+
+Returns full system health including each service status, resource utilization, pipeline readiness, schema migration status, identity file sync status, and external integrations.
+
+> Requires authentication (JWT, session cookie, or API key). The basic `/health` endpoint remains public for load balancer checks.
+
+#### Example
+
+```bash
+curl "$API/health/detailed" | jq '{status, services, resources: {cpu: .resources.cpu_used_percent, memory: .resources.memory_used_percent}}'
+```
+
+#### Response (200)
+
+```json
+{
+ "status": "ok",
+ "version": "1.0.0",
+ "services": {
+ "postgres": {"status": "ok", "latency_ms": 3},
+ "redis": {"status": "ok", "latency_ms": 1},
+ "qdrant": {"status": "ok", "latency_ms": 5}
+ },
+ "resources": {
+ "cpu_used_percent": 12.5,
+ "memory_available_mb": 32768,
+ "memory_used_percent": 31.7
+ },
+ "pipeline": {
+ "scripts_ready": true,
+ "scripts_count": 345,
+ "processors": {
+ "asr": true,
+ "yolo": true,
+ "face": true,
+ "pose": true,
+ "ocr": true,
+ "cut": true,
+ "scene": true,
+ "asrx": true,
+ "visual_chunk": true
+ },
+ "models_ready": true,
+ "models_count": 42,
+ "scripts_integrity": {"matched": 332, "total": 345, "ok": false},
+ "ffmpeg": true
+ },
+ "schema": {
+ "table_exists": true,
+ "applied": [{"filename": "migrate_add_users_table.sql"}],
+ "required": [],
+ "ok": true
+ },
+ "identities": {
+ "directory_exists": true,
+ "files_count": 3481,
+ "index_ok": true,
+ "db_count": 3481,
+ "synced": true
+ },
+ "integrations": {
+ "tmdb": {
+ "api_key_configured": false,
+ "enabled": false,
+ "api_reachable": null
+ }
+ }
+}
+```
+
+#### Response Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `status` | string | `ok` if all essential services healthy |
+| `services` | object | Per-service status (postgres, redis, qdrant) |
+| `services.*.status` | string | `ok`, `error`, or `degraded` |
+| `services.*.latency_ms` | int | Response time in milliseconds |
+| `resources` | object | CPU, memory usage |
+| `pipeline.scripts_ready` | boolean | Scripts directory accessible |
+| `pipeline.scripts_count` | int | Number of Python processor scripts |
+| `pipeline.processors` | object | Per-processor availability |
+| `pipeline.models_ready` | boolean | Models directory accessible |
+| `pipeline.scripts_integrity` | object | SHA256 checksum verification results |
+| `schema.ok` | boolean | All required migrations applied |
+| `identities.synced` | boolean | Identity file count matches DB count |
+| `integrations.tmdb` | object | TMDB API key config and reachability |
+
+#### Health status rules
+
+| Condition | status |
+|-----------|--------|
+| All services ok | `ok` |
+| Any service error | `degraded` |
+| Postgres or Redis error | `degraded` (server still responds) |
+
+---
+
+### Stats Endpoints
+
+| Method | Endpoint | Auth | Description |
+|--------|----------|------|-------------|
+| GET | `/api/v1/stats/sftpgo` | No | SFTPGo service status |
diff --git a/docs_v1.0/doc_wasm/modules/03_register.md b/docs_v1.0/doc_wasm/modules/03_register.md
new file mode 100644
index 0000000..9bae26f
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/03_register.md
@@ -0,0 +1,184 @@
+
+
+
+
+## File Registration
+
+### `POST /api/v1/files/register`
+
+**Auth**: Required
+**Scope**: file-level
+
+Register a video file for processing. Returns the file's metadata and UUID.
+
+**New in v0.1.2**: Registration now **automatically triggers the processing pipeline** — no need to call `POST /api/v1/file/:file_uuid/process` separately. The system will:
+1. Register the file and run ffprobe
+2. Auto-run offline TMDb probe (reads local identity files, no API calls)
+3. Create a monitor job for the worker
+4. Worker starts all 10 processors (Cut → ASR → ASRX → YOLO → OCR → Face → Pose → VisualChunk → Story → 5W1H)
+
+If the file already exists (same content hash), returns the existing record with `already_exists: true`.
+
+#### Request Parameters
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `file_path` | string | Yes | — | Path to video file on disk |
+| `pattern` | string | No | — | Regex pattern for batch register (requires `file_path` to be a directory) |
+| `user_id` | integer | No | — | User ID to associate with registration |
+| `content_hash` | string | No | — | Pre-computed SHA-256 hash (skips computation) |
+
+#### Example
+
+```bash
+# Register a single file
+curl -s -X POST "$API/api/v1/files/register" \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: $KEY" \
+ -d '{"file_path": "/path/to/video.mp4"}'
+
+# Batch register files matching a pattern in a directory
+curl -s -X POST "$API/api/v1/files/register" \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: $KEY" \
+ -d '{"file_path": "/path/to/dir", "pattern": ".*\\.mp4$"}'
+```
+
+#### Response (200)
+
+```json
+{
+ "success": true,
+ "file_uuid": "3a6c1865...",
+ "file_name": "video.mp4",
+ "file_path": "/path/to/video.mp4",
+ "file_type": "video",
+ "duration": 120.5,
+ "width": 1920,
+ "height": 1080,
+ "fps": 24.0,
+ "total_frames": 2892,
+ "already_exists": false,
+ "message": "File registered successfully"
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `success` | boolean | Always true on 200 |
+| `file_uuid` | string | 32-char hex UUID of the registered file |
+| `file_name` | string | File name (auto-renamed if name conflict) |
+| `file_path` | string | Canonical path on disk |
+| `file_type` | string | `"video"`, `"audio"`, or `"unknown"` |
+| `duration` | float | Duration in seconds |
+| `width` | integer | Video width in pixels |
+| `height` | integer | Video height in pixels |
+| `fps` | float | Frames per second |
+| `total_frames` | integer | Total frame count |
+| `already_exists` | boolean | True if same content was already registered |
+| `message` | string | Human-readable status |
+
+#### Error Responses
+
+| HTTP | When |
+|------|------|
+| `401` | Missing or invalid API key |
+| `400` | Invalid request body |
+| `404` | File path does not exist |
+
+---
+
+### `GET /api/v1/files/scan`
+
+**Auth**: Required
+**Scope**: file-level
+
+Scan the filesystem directory and list all media files, showing which are registered, processing, or unregistered.
+
+#### Query Parameters
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `page` | integer | No | 1 | Page number (1-based) |
+| `page_size` | integer | No | all | Items per page (alias: `limit`) |
+| `limit` | integer | No | all | Max items (alias for `page_size`) |
+| `pattern` | string | No | — | Regex filter on file name (e.g., `.*\\.mp4$`) |
+| `sort_by` | string | No | `name` | Sort field: `name`, `size`, `modified`, `status` |
+| `sort_order` | string | No | `asc` | Sort direction: `asc` or `desc` |
+
+#### Example
+
+```bash
+# Full scan
+curl -s "$API/api/v1/files/scan" -H "X-API-Key: $KEY" | jq '{total, registered_count, unregistered_count}'
+
+# Paginated (page 1, 5 per page)
+curl -s "$API/api/v1/files/scan?page=1&page_size=5" -H "X-API-Key: $KEY" | jq '{page, total_pages, files: [.files[].file_name]}'
+
+# Regex filter: only mp4 files
+curl -s "$API/api/v1/files/scan?pattern=.*\\.mp4$" -H "X-API-Key: $KEY" | jq '{filtered_total, files: [.files[].file_name]}'
+
+# Sort by file size (largest first)
+curl -s "$API/api/v1/files/scan?sort_by=size&sort_order=desc&page_size=5" -H "X-API-Key: $KEY" | jq '[.files[] | {file_name, file_size}]'
+
+# Sort by modified time (most recent first)
+curl -s "$API/api/v1/files/scan?sort_by=modified&sort_order=desc&page_size=5" -H "X-API-Key: $KEY" | jq '[.files[] | {file_name, modified_time}]'
+
+# Sort by status
+curl -s "$API/api/v1/files/scan?sort_by=status&page_size=5" -H "X-API-Key: $KEY" | jq '[.files[] | {file_name, status}]'
+```
+
+#### Response (200)
+
+```json
+{
+ "files": [
+ {
+ "file_name": "video.mp4",
+ "file_size": 12345678,
+ "is_registered": true,
+ "file_uuid": "3a6c1865...",
+ "status": "completed",
+ "registration_time": "2026-05-16T12:00:00Z",
+ "job_id": 42
+ }
+ ],
+ "total": 107,
+ "filtered_total": 80,
+ "page": 1,
+ "page_size": 20,
+ "total_pages": 4,
+ "registered_count": 26,
+ "unregistered_count": 81
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `files` | array | Array of file info objects (paginated) |
+| `files[].file_name` | string | File name |
+| `files[].relative_path` | string | Path relative to scan root |
+| `files[].file_path` | string | Absolute path on disk |
+| `files[].file_size` | integer | File size in bytes |
+| `files[].modified_time` | string | Last modified timestamp (ISO8601) |
+| `files[].is_registered` | boolean | Whether file is registered in DB |
+| `files[].file_uuid` | string | 32-char hex UUID (only if registered) |
+| `files[].status` | string | `"completed"`, `"processing"`, `"registered"`, `"unregistered"`, or `null` |
+| `files[].registration_time` | string | DB registration timestamp (only if registered) |
+| `files[].job_id` | integer | Processing job ID (only if a job exists) |
+| `total` | integer | Total files found on disk (unfiltered) |
+| `filtered_total` | integer | Files matching regex filter |
+| `page` | integer | Current page number |
+| `page_size` | integer | Items per page |
+| `total_pages` | integer | Total pages |
+| `registered_count` | integer | Files registered in DB |
+| `unregistered_count` | integer | Files not yet registered |
+
+#### Notes
+
+| Feature | Behavior |
+|---------|----------|
+| **Regex** | Case-insensitive (`(?i)` prefix auto-applied). Applied to `file_name`. |
+| **Sort order** | Default (`sort_by=name`): registered files first, then alphabetically. `sort_by=status`: alphabetical by status string. |
+| **Pagination** | `page_size` and `limit` are aliases. Default: show all results. |
+| **Processing order** | `pattern` regex filter → `sort_by`/`sort_order` → `page`/`page_size` slice. |
diff --git a/docs_v1.0/doc_wasm/modules/04_lookup.md b/docs_v1.0/doc_wasm/modules/04_lookup.md
new file mode 100644
index 0000000..019c8af
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/04_lookup.md
@@ -0,0 +1,138 @@
+
+
+
+
+## File Lookup
+
+### `GET /api/v1/files/lookup`
+
+**Auth**: Required
+**Scope**: file-level
+
+Search registered files by file name. Performs a case-insensitive LIKE search on the file name column. Returns basic info about matching files.
+
+#### Query Parameters
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `file_name` | string | Yes | File name to search for (partial matches supported) |
+
+#### Example
+
+```bash
+# Look up a specific file
+curl -s "$API/api/v1/files/lookup?file_name=video.mp4" \
+ -H "X-API-Key: $KEY"
+
+# Partial name search
+curl -s "$API/api/v1/files/lookup?file_name=charade" \
+ -H "X-API-Key: $KEY" | jq '.matches[].file_name'
+```
+
+#### Response (200)
+
+```json
+{
+ "file_name": "video.mp4",
+ "exists": true,
+ "matches": [
+ {
+ "file_uuid": "a03485a40b2df2d3",
+ "file_name": "video.mp4",
+ "file_type": "video",
+ "status": "completed"
+ }
+ ],
+ "next_name": "video (2).mp4"
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `file_name` | string | Searched name |
+| `exists` | boolean | Exact name match exists |
+| `matches` | array | Array of matching registered files |
+| `matches[].file_uuid` | string | 32-char hex UUID |
+| `matches[].file_name` | string | Registered file name |
+| `matches[].file_type` | string | `"video"`, `"audio"`, or `null` |
+| `matches[].status` | string | Registration/processing status |
+| `next_name` | string | Suggested name for avoiding conflicts |
+
+---
+
+## Unregister
+
+### `POST /api/v1/unregister`
+
+**Auth**: Required
+**Scope**: file-level
+
+Delete a registered file from the system. Supports single file by UUID, or batch by directory + regex pattern.
+
+#### What gets deleted
+
+| Removed (default) | Not removed |
+|---------|-------------|
+| Database records (videos, chunks, embeddings, processor_results, pre_chunks) | The original source video file on disk |
+| Processor output JSON files (`{uuid}.*.json`) — unless `delete_output_files: false` | Temp/working directories |
+| In-memory cache entries | |
+| MongoDB cached lists | |
+
+> ⚠️ Database deletion is **irreversible**. To keep output files, set `"delete_output_files": false`.
+
+#### Request Parameters
+
+At least one mode must be specified: either `file_uuid` alone, or `file_path` + `pattern` together.
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `file_uuid` | string | * | — | Single file UUID to delete |
+| `file_path` | string | * | — | Directory path (for batch delete) |
+| `pattern` | string | * | — | Regex pattern (requires `file_path`) |
+| `delete_output_files` | boolean | No | `true` | If `true`, also delete processor output JSON files (`{uuid}.*.json`). Set to `false` to keep them. |
+
+#### Example
+
+```bash
+# Delete a single file by UUID (default: also deletes output JSON files)
+curl -s -X POST "$API/api/v1/unregister" \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: $KEY" \
+ -d '{"file_uuid": "'"$FILE_UUID"'"}'
+
+# Keep output JSON files, only delete DB records
+curl -s -X POST "$API/api/v1/unregister" \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: $KEY" \
+ -d '{"file_uuid": "'"$FILE_UUID"'", "delete_output_files": false}'
+
+# Batch delete all mp4 files in a directory
+curl -s -X POST "$API/api/v1/unregister" \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: $KEY" \
+ -d '{"file_path": "/path/to/dir", "pattern": ".*\\.mp4$"}'
+```
+
+#### Response (200)
+
+```json
+{
+ "success": true,
+ "file_uuid": "a03485a40b2df2d3",
+ "message": "Video unregistered successfully"
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `success` | boolean | True if deletion succeeded |
+| `file_uuid` | string | UUID of the deleted file (single mode) |
+| `message` | string | Human-readable status |
+
+#### Error Responses
+
+| HTTP | When |
+|------|------|
+| `400` | Neither `file_uuid` nor `file_path`+`pattern` provided |
+| `404` | File UUID not found |
+| `401` | Missing or invalid API key |
diff --git a/docs_v1.0/doc_wasm/modules/05_process.md b/docs_v1.0/doc_wasm/modules/05_process.md
new file mode 100644
index 0000000..9f9baf8
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/05_process.md
@@ -0,0 +1,236 @@
+
+
+
+
+## Processing Pipeline
+
+### `POST /api/v1/file/:file_uuid/process`
+
+**Auth**: Required
+**Scope**: file-level
+
+Trigger the processing pipeline for a registered file. Creates a monitor job that the worker picks up and processes sequentially. Returns immediately with the job info—processing runs asynchronously in the background.
+
+#### Request Parameters
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `processors` | string[] | No | all | Specific processors to run: `["cut","asr","asrx","yolo","ocr","face","pose","visual_chunk","story","5w1h"]` |
+| `rules` | string[] | No | all | Rule names to apply (currently unused) |
+
+#### Example
+
+```bash
+# Run all processors
+curl -s -X POST "$API/api/v1/file/$FILE_UUID/process" \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: $KEY" -d '{}'
+
+# Run specific processors only
+curl -s -X POST "$API/api/v1/file/$FILE_UUID/process" \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: $KEY" \
+ -d '{"processors": ["asr", "face", "yolo"]}'
+```
+
+#### Response (200)
+
+```json
+{
+ "success": true,
+ "job_id": 42,
+ "file_uuid": "3a6c1865...",
+ "status": "processing",
+ "pids": [12345, 12346],
+ "message": "Processing triggered for video.mp4"
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `success` | boolean | Always true on 200 |
+| `job_id` | integer | Monitor job ID (for job tracking) |
+| `file_uuid` | string | 32-char hex UUID of the file |
+| `status` | string | `"processing"` |
+| `pids` | integer[] | Process IDs of started processors |
+| `message` | string | Human-readable status |
+
+#### Error Responses
+
+| HTTP | When |
+|------|------|
+| `404` | File UUID not found |
+| `401` | Missing or invalid API key |
+
+---
+
+### `GET /api/v1/file/:file_uuid/probe`
+
+**Auth**: Required
+**Scope**: file-level
+
+Get ffprobe metadata for a registered file. Returns video/audio stream info, codec details, duration, resolution, and frame rate.
+
+#### Example
+
+```bash
+curl -s "$API/api/v1/file/$FILE_UUID/probe" -H "X-API-Key: $KEY"
+```
+
+#### Response (200)
+
+```json
+{
+ "file_uuid": "3a6c1865...",
+ "file_name": "video.mp4",
+ "file_size": 794863677,
+ "duration": 120.5,
+ "width": 1920,
+ "height": 1080,
+ "fps": 24.0,
+ "total_frames": 2892,
+ "cached": true,
+ "format": {
+ "filename": "/path/to/video.mp4",
+ "format_name": "mov,mp4,m4a,3gp",
+ "duration": "120.5",
+ "size": "12345678",
+ "bit_rate": "819200"
+ },
+ "streams": [
+ {
+ "index": 0,
+ "codec_name": "h264",
+ "codec_type": "video",
+ "width": 1920,
+ "height": 1080,
+ "r_frame_rate": "24/1",
+ "duration": "120.5"
+ }
+ ]
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `file_uuid` | string | 32-char hex UUID |
+| `file_name` | string | File name |
+| `file_size` | integer | File size in bytes (from filesystem) |
+| `duration` | float | Duration in seconds |
+| `width` | integer | Video width in pixels |
+| `height` | integer | Video height in pixels |
+| `fps` | float | Frames per second |
+| `total_frames` | integer | Estimated total frames |
+| `cached` | boolean | True if result was from cached probe JSON |
+| `format` | object | Container format info (ffprobe format section) |
+| `streams` | array | Array of stream info objects |
+
+---
+
+### `GET /api/v1/progress/:file_uuid`
+
+**Auth**: Required
+**Scope**: file-level
+
+Get real-time processing progress for a file via Redis pub/sub. Includes per-processor status, current/total frames, ETA, and system resource stats.
+
+#### Pipeline Order
+
+| Order | Processor | Dependencies | Description |
+|-------|-----------|-------------|-------------|
+| 1 | `cut` | — | Scene detection |
+| 2 | `asr` | cut | Speech-to-text (per scene) |
+| 3 | `asrx` | asr | Speaker diarization |
+| 4 | `yolo` | — | Object detection |
+| 5 | `ocr` | — | Text recognition |
+| 6 | `face` | — | Face detection & embedding |
+| 7 | `pose` | — | Pose estimation |
+| 8 | `visual_chunk` | yolo | Visual scene chunks |
+| 9 | `story` | asr, asrx, cut, yolo, face | Scene summaries (template) |
+| 10 | `5w1h` | story | 5W1H analysis (Gemma4 LLM) |
+
+All processors except `story` and `5w1h` run concurrently when their dependencies are met. Story and 5W1H run sequentially after their prerequisites.
+
+#### Example
+
+```bash
+curl -s "$API/api/v1/progress/$FILE_UUID" -H "X-API-Key: $KEY" | jq '{overall_progress, processors: [.processors[] | {processor_type, status}]}'
+```
+
+#### Response (200)
+
+```json
+{
+ "file_uuid": "3a6c1865...",
+ "overall_progress": 71,
+ "cpu_percent": 45.2,
+ "gpu_percent": 30.1,
+ "memory_percent": 62.4,
+ "processors": [
+ {"processor_type": "asr", "status": "complete", "progress": 100},
+ {"processor_type": "yolo", "status": "running", "progress": 65},
+ {"processor_type": "face", "status": "pending", "progress": 0}
+ ]
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `file_uuid` | string | 32-char hex UUID |
+| `overall_progress` | integer | Overall progress percentage (0–100) |
+| `processors` | array | Per-processor status list |
+| `processors[].processor_type` | string | Processor name (`asr`, `cut`, `yolo`, etc.) |
+| `processors[].status` | string | `"pending"`, `"running"`, `"complete"`, or `"failed"` |
+| `processors[].progress` | integer | Per-processor progress (0–100) |
+| `processors[].eta_seconds` | integer | Estimated seconds remaining (running processors) |
+| `processors[].current` | integer | Current frame count |
+| `processors[].total` | integer | Total frame count |
+| `cpu_percent` | float | Current CPU usage |
+| `gpu_percent` | float | Current GPU utilization |
+| `memory_percent` | float | Current memory usage |
+
+---
+
+### `GET /api/v1/jobs`
+
+**Auth**: Required
+**Scope**: system-level
+
+List all processing jobs (monitor jobs) in the system. Shows job status, which file each job is processing, and current processor info.
+
+#### Example
+
+```bash
+curl -s "$API/api/v1/jobs" -H "X-API-Key: $KEY" | jq '{count, jobs: [.jobs[] | {uuid, status}]}'
+```
+
+#### Response (200)
+
+```json
+{
+ "jobs": [
+ {
+ "id": 42,
+ "uuid": "3a6c1865...",
+ "status": "running",
+ "current_processor": "yolo",
+ "created_at": "2026-05-16T12:00:00Z",
+ "started_at": "2026-05-16T12:01:00Z"
+ }
+ ],
+ "count": 15,
+ "page": 1,
+ "page_size": 20
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `jobs` | array | Array of job info objects |
+| `jobs[].id` | integer | Job ID |
+| `jobs[].uuid` | string | File UUID being processed |
+| `jobs[].status` | string | `"pending"`, `"running"`, `"completed"`, `"failed"` |
+| `jobs[].current_processor` | string | Currently active processor, or null |
+| `count` | integer | Total job count |
+| `page` | integer | Current page number |
+| `page_size` | integer | Jobs per page |
diff --git a/docs_v1.0/doc_wasm/modules/06_search.md b/docs_v1.0/doc_wasm/modules/06_search.md
new file mode 100644
index 0000000..e5b13c3
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/06_search.md
@@ -0,0 +1,145 @@
+
+
+
+
+## Search APIs
+
+### `POST /api/v1/search/smart`
+
+**Auth**: Required
+**Scope**: file-level
+
+Semantic vector search using EmbeddingGemma-300m. Generates a query embedding via EmbeddingGemma (port 11436), then searches pgvector `story_parent` and `llm_parent` chunks by cosine similarity.
+
+#### Request Parameters
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `file_uuid` | string | Yes | — | File UUID to search within |
+| `query` | string | Yes | — | Search text |
+| `limit` | integer | No | 5 | Max results to return |
+| `page` | integer | No | 1 | Page number |
+| `page_size` | integer | No | 5 | Items per page |
+
+#### Example
+
+```bash
+curl -s -X POST "$API/api/v1/search/smart" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $JWT" \
+ -d '{"file_uuid": "'"$FILE_UUID"'", "query": "Audrey Hepburn"}'
+```
+
+#### Response (200)
+
+```json
+{
+ "query": "Audrey Hepburn",
+ "results": [
+ {
+ "parent_id": 1087822,
+ "scene_order": 1087822,
+ "start_frame": 104438,
+ "end_frame": 104538,
+ "fps": 24.0,
+ "start_time": 4351.6,
+ "end_time": 4355.76,
+ "summary": "[4352s-4356s, 4s] Cast: Audrey Hepburn. Total: 2 lines, 10 words. Speakers: Audrey Hepburn (2 lines)",
+ "similarity": 0.67
+ }
+ ],
+ "page": 1,
+ "page_size": 5,
+ "strategy": "semantic_vector_search"
+}
+```
+
+---
+
+### `POST /api/v1/search/universal`
+
+**Auth**: Required
+**Scope**: file-level
+
+Multi-type BM25 full-text search across chunks, frames, and persons. Uses PostgreSQL `tsvector`.
+
+#### Request Parameters
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `query` | string | Yes | — | Search text |
+| `file_uuid` | string | No | — | Restrict to specific file |
+| `types` | string[] | No | `["chunk","frame","person"]` | Search types |
+| `limit` | integer | No | 10 | Max results per type |
+| `page` | integer | No | 1 | Page number |
+| `page_size` | integer | No | 20 | Items per page |
+
+#### Example
+
+```bash
+curl -s -X POST "$API/api/v1/search/universal" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $JWT" \
+ -d '{"file_uuid": "'"$FILE_UUID"'", "query": "Cary Grant"}'
+```
+
+#### Response (200)
+
+```json
+{
+ "results": [
+ {
+ "type": "chunk",
+ "chunk_id": "bd80fec92b0b6963d177a2c55bf713e2_2",
+ "chunk_type": "story_child",
+ "start_frame": 5103,
+ "end_frame": 5127,
+ "start_time": 212.64,
+ "end_time": 213.64,
+ "text": "[213s-214s] Cary Grant: \"Olá!\"",
+ "score": 0.9
+ }
+ ],
+ "total": 20,
+ "took_ms": 18
+}
+```
+
+---
+
+### `POST /api/v1/search/frames`
+
+**Auth**: Required
+**Scope**: file-level
+
+Search face detection frames by identity name or trace ID.
+
+---
+
+### `POST /api/v1/search/identity_text`
+
+**Auth**: Required
+**Scope**: file-level
+
+Search text chunks spoken by a specific identity.
+
+---
+
+### Visual Search
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| POST | `/api/v1/search/visual` | Search visual chunks |
+| POST | `/api/v1/search/visual/class` | Search by object class |
+| POST | `/api/v1/search/visual/density` | Search by object density |
+| POST | `/api/v1/search/visual/combination` | Search by object combination |
+| POST | `/api/v1/search/visual/stats` | Visual chunk statistics |
+
+#### Embedding Model
+
+| Detail | Value |
+|--------|-------|
+| **Model** | EmbeddingGemma-300m |
+| **Endpoint** | `POST /api/v1/embeddings` on port 11436 |
+| **Dimension** | 768 |
+| **Storage** | pgvector (`chunk.embedding` column) |
diff --git a/docs_v1.0/doc_wasm/modules/07_identity.md b/docs_v1.0/doc_wasm/modules/07_identity.md
new file mode 100644
index 0000000..4b8e3ef
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/07_identity.md
@@ -0,0 +1,333 @@
+
+
+
+
+## Global Identities
+
+### `GET /api/v1/identities`
+
+**Auth**: Required
+**Scope**: identity-level
+
+List all registered identities with pagination.
+
+#### Example
+
+```bash
+curl -s "$API/api/v1/identities?page=1&page_size=20" -H "X-API-Key: $KEY" | jq '{count, identities: [.identities[] | {name}]}'
+```
+
+---
+
+### `GET /api/v1/identity/:identity_uuid`
+
+**Auth**: Required
+**Scope**: identity-level
+
+Get detailed information for a specific identity, including metadata and TMDb references.
+
+#### Example
+
+```bash
+curl -s "$API/api/v1/identity/$IDENTITY_UUID" -H "X-API-Key: $KEY"
+```
+
+#### Response (200)
+
+```json
+{
+ "success": true,
+ "identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
+ "name": "Cary Grant",
+ "identity_type": "people",
+ "source": "tmdb",
+ "status": "confirmed",
+ "tmdb_id": 112,
+ "tmdb_profile": "{output}/identities/{identity_uuid}/profile.jpg",
+ "metadata": {},
+ "reference_data": {},
+ "created_at": "2026-05-16T12:00:00Z",
+ "updated_at": null
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `identity_uuid` | string | Identity identifier |
+| `name` | string | Identity name |
+| `identity_type` | string | `"people"` or null |
+| `source` | string | `.json`, `auto`, `tmdb`, `user_defined`, or `merged` |
+| `status` | string | `"confirmed"`, `"pending"`, or `"inactive"` |
+| `tmdb_id` | integer | TMDb person ID (only if source = tmdb) |
+| `tmdb_profile` | string | Local profile image path (`{output}/identities/{uuid}/profile.jpg`) |
+| `metadata` | object | Metadata JSON (tmdb_character, cast_order, etc.) |
+| `created_at` | string | Creation timestamp |
+
+---
+
+### `DELETE /api/v1/identity/:identity_uuid`
+
+**Auth**: Required
+**Scope**: identity-level
+
+Delete an identity permanently.
+
+---
+
+### `GET /api/v1/identity/:identity_uuid/files`
+
+**Auth**: Required
+**Scope**: identity-level
+
+Get all files where this identity appears. Returns per-file summary including face count, confidence, and appearance time range.
+
+#### Example
+
+```bash
+curl -s "$API/api/v1/identity/$IDENTITY_UUID/files" -H "X-API-Key: $KEY"
+```
+
+---
+
+### `GET /api/v1/identity/:identity_uuid/faces`
+
+**Auth**: Required
+**Scope**: identity-level
+
+Get all face detection records associated with this identity.
+
+#### Example
+
+```bash
+curl -s "$API/api/v1/identity/$IDENTITY_UUID/faces" -H "X-API-Key: $KEY"
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `file_uuid` | string | File where face was detected |
+| `frame_number` | integer | Frame number of detection |
+| `face_id` | string | Face ID (format: `face_{frame_number}`) |
+| `confidence` | float | Detection confidence |
+
+---
+
+### `GET /api/v1/identity/:identity_uuid/chunks`
+
+**Auth**: Required
+**Scope**: identity-level
+
+Get all text chunks (sentences) spoken while this identity's face was on screen. Useful for finding what a person said.
+
+#### Example
+
+```bash
+curl -s "$API/api/v1/identity/$IDENTITY_UUID/chunks" -H "X-API-Key: $KEY"
+```
+
+#### Response (200)
+
+```json
+{
+ "success": true,
+ "identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
+ "data": [
+ {
+ "id": 0,
+ "file_uuid": "bd80fec92b0b6963d177a2c55bf713e2",
+ "chunk_id": "bd80fec92b0b6963d177a2c55bf713e2_2",
+ "chunk_type": "sentence",
+ "start_frame": 5103,
+ "end_frame": 5127,
+ "fps": 24.0,
+ "start_time": 212.64,
+ "end_time": 213.64,
+ "text_content": "[213s-214s] Cary Grant: \"Olá!\""
+ }
+ ]
+}
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `file_uuid` | string | File identifier |
+| `chunk_id` | string | Sentence chunk identifier |
+| `start_frame` | integer | Frame-accurate start position |
+| `end_frame` | integer | Frame-accurate end position |
+| `fps` | float | Frames per second |
+| `start_time` | float | Start time in seconds |
+| `end_time` | float | End time in seconds |
+| `text_content` | string | Spoken text content |
+
+---
+
+### `POST /api/v1/identity/:identity_uuid/bind`
+
+**Auth**: Required
+**Scope**: identity-level
+
+Bind a face detection to an identity. Associates the face trace with the identity for future search and recognition.
+
+#### Request Parameters
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `file_uuid` | string | Yes | File where face is detected |
+| `face_id` | string | Yes | Face ID (format: `{frame}_{idx}`) |
+
+#### Example
+
+```bash
+curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/bind" \
+ -H "X-API-Key: $KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"file_uuid": "'"$FILE_UUID"'", "face_id": "1_5"}'
+```
+
+---
+
+### `POST /api/v1/identity/:identity_uuid/unbind`
+
+**Auth**: Required
+**Scope**: identity-level
+
+Unbind a face detection from an identity. Removes the identity association from the face record.
+
+---
+
+### `GET /api/v1/identities/search`
+
+**Auth**: Required
+**Scope**: identity-level
+
+Search identities by name (ILIKE search). Returns matching identity records.
+
+#### Example
+
+```bash
+curl -s "$API/api/v1/identities/search?q=Cary" -H "X-API-Key: $KEY"
+```
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `name` | string | Identity name |
+| `source` | string | Identity source |
+| `tmdb_id` | integer | TMDb ID (if source = tmdb) |
+| `file_uuid` | string | Associated file |
+
+---
+
+---
+
+### `POST /api/v1/identity/upload`
+
+**Auth**: Required
+**Scope**: identity-level
+
+Upload an identity.json file to create or update an identity. Accepts the same format as the identity.json files stored on disk.
+
+If an identity with the same `name` already exists, it will be updated with the new values.
+
+#### Request
+
+The request body is an `IdentityFile` object:
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `identity_uuid` | string | Yes | Identity identifier |
+| `name` | string | Yes | Identity display name |
+| `identity_type` | string | No | `"people"` or null |
+| `source` | string | No | `.json`, `auto`, `tmdb`, `user_defined`, or `merged` |
+| `status` | string | No | `"confirmed"`, `"pending"`, or `"inactive"` |
+| `tmdb_id` | integer | No | TMDb person ID |
+| `tmdb_profile` | string | No | TMDb profile image URL |
+| `metadata` | object | No | Arbitrary metadata JSON |
+| `file_bindings` | array | No | Array of `{ file_uuid, trace_ids, face_count }` (informational) |
+
+#### Example
+
+```bash
+curl -s -X POST "$API/api/v1/identity/upload" \
+ -H "X-API-Key: $KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "version": 1,
+ "identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
+ "name": "Cary Grant",
+ "identity_type": "people",
+ "source": ".json",
+ "status": "confirmed",
+ "metadata": {},
+ "file_bindings": []
+ }'
+```
+
+#### Response (200)
+
+```json
+{
+ "success": true,
+ "identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
+ "name": "Cary Grant",
+ "message": "Identity uploaded successfully"
+}
+```
+
+---
+
+---
+
+### `POST /api/v1/identity/:identity_uuid/profile-image`
+
+**Auth**: Required
+**Scope**: identity-level
+
+Upload a profile image (JPEG or PNG) for an identity. The image is saved to `{output}/identities/{uuid}/profile.{ext}`.
+
+Uses `multipart/form-data` with field name `image`.
+
+#### Example
+
+```bash
+curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/profile-image" \
+ -H "X-API-Key: $KEY" \
+ -F "image=@/path/to/photo.jpg"
+```
+
+#### Response (200)
+
+```json
+{
+ "success": true,
+ "identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
+ "path": "/path/to/output/identities/.../profile.jpg",
+ "message": "Profile image saved: profile.jpg"
+}
+```
+
+#### Error Responses
+
+| HTTP | When |
+|------|------|
+| `400` | Missing image field or unsupported format |
+| `404` | Identity not found |
+| `415` | Unsupported image type (use JPEG or PNG) |
+
+---
+
+### `GET /api/v1/identity/:identity_uuid/profile-image`
+
+**Auth**: Required
+**Scope**: identity-level
+
+Retrieve the profile image for an identity. Returns the raw image data with appropriate Content-Type header.
+
+```bash
+curl -s "$API/api/v1/identity/$IDENTITY_UUID/profile-image" \
+ -H "X-API-Key: $KEY" -o profile.jpg
+```
+
+| Response Header | Value |
+|----------------|-------|
+| `content-type` | `image/jpeg` or `image/png` |
+
+
diff --git a/docs_v1.0/doc_wasm/modules/08_identity_agent.md b/docs_v1.0/doc_wasm/modules/08_identity_agent.md
new file mode 100644
index 0000000..f9c86e0
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/08_identity_agent.md
@@ -0,0 +1,65 @@
+
+
+
+
+## Identity Agent
+
+### `POST /api/v1/agents/identity/match-from-photo`
+
+**Auth**: Required
+**Scope**: file-level
+
+Upload a face photo to match against known identities. Detects face via InsightFace, extracts 512D embedding via CoreML FaceNet, then searches pgvector for the closest identity.
+
+#### Request
+
+`multipart/form-data` with field `image` (JPEG/PNG) and optional `file_uuid`.
+
+#### Example
+
+```bash
+curl -s -X POST "$API/api/v1/agents/identity/match-from-photo" \
+ -H "Authorization: Bearer $JWT" \
+ -F "image=@/path/to/face.jpg" \
+ -F "file_uuid=$FILE_UUID"
+```
+
+#### Response (200)
+
+```json
+{
+ "success": true,
+ "matches": [
+ {
+ "identity_uuid": "a9a90105...",
+ "name": "Cary Grant",
+ "similarity": 0.87
+ }
+ ]
+}
+```
+
+---
+
+### `POST /api/v1/agents/identity/match-from-trace`
+
+**Auth**: Required
+**Scope**: file-level
+
+Match a face trace (tracked face across frames) against known identities. Samples 3 angles from the trace, generates embeddings, and searches pgvector.
+
+#### Request Parameters
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `file_uuid` | string | Yes | File containing the trace |
+| `trace_id` | integer | Yes | Face trace ID to match |
+
+#### Example
+
+```bash
+curl -s -X POST "$API/api/v1/agents/identity/match-from-trace" \
+ -H "Authorization: Bearer $JWT" \
+ -H "Content-Type: application/json" \
+ -d '{"file_uuid": "'"$FILE_UUID"'", "trace_id": 10}'
+```
diff --git a/docs_v1.0/doc_wasm/modules/08_media.md b/docs_v1.0/doc_wasm/modules/08_media.md
new file mode 100644
index 0000000..763747d
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/08_media.md
@@ -0,0 +1,146 @@
+
+
+
+
+## Video Streaming & Frame Extraction
+
+All video streaming endpoints support the following common query parameters:
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `mode` | string | No | `normal` | `normal` or `debug` (draws detection overlays) |
+| `audio` | string | No | `on` | `on` or `off` |
+
+---
+
+### `GET /api/v1/file/:file_uuid/video`
+
+Stream the full video file with range support for seeking.
+
+**Auth**: Required
+**Scope**: file-level
+
+#### Response
+
+- **200**: Video stream (`Content-Type` based on file extension)
+- **206**: Partial content (range request)
+- Supports `Range` header for seeking
+
+---
+
+### `GET /api/v1/file/:file_uuid/trace/:trace_id/video`
+
+Stream video with highlights for a specific face trace (follows a single person across frames with bounding box overlay).
+
+**Auth**: Required
+**Scope**: file-level
+
+---
+
+### `GET /api/v1/file/:file_uuid/video/bbox`
+
+Stream video with bounding box overlay for all detected objects/faces.
+
+**Auth**: Required
+**Scope**: file-level
+
+Uses a built-in 5×7 bitmap font renderer to draw labels directly on video frames via FFmpeg `drawtext` filter.
+
+---
+
+### `GET /api/v1/file/:file_uuid/thumbnail`
+
+Extract a single frame from a video as JPEG image. Uses FFmpeg `select` filter.
+
+**Auth**: Required
+**Scope**: file-level
+
+#### Query Parameters
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `frame` | integer | Yes | — | Zero-based frame number to extract |
+| `x` | integer | No | — | Crop start X (left edge). Requires `y`, `w`, `h`. |
+| `y` | integer | No | — | Crop start Y (top edge). Requires `x`, `w`, `h`. |
+| `w` | integer | No | — | Crop width in pixels. Requires `x`, `y`, `h`. |
+| `h` | integer | No | — | Crop height in pixels. Requires `x`, `y`, `w`. |
+
+All four crop params (`x`, `y`, `w`, `h`) must be provided together or omitted.
+
+#### Example
+
+```bash
+# Extract frame 1000 (full frame)
+curl -s "$API/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/thumbnail?frame=1000" \
+ -H "Authorization: Bearer $JWT" -o frame_1000.jpg
+
+# Extract and crop face region (x=320, y=240, w=160, h=160)
+curl -s "$API/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/thumbnail?frame=1000&x=320&y=240&w=160&h=160" \
+ -H "Authorization: Bearer $JWT" -o face_crop.jpg
+```
+
+#### Response
+
+- **200**: `image/jpeg` binary data
+- **404**: File not found
+- **500**: FFmpeg error (e.g., frame number exceeds video duration)
+
+### `GET /api/v1/file/:file_uuid/clip`
+
+Extract a video clip (time range) as MPEG-TS stream. Uses FFmpeg `-ss` fast seek.
+
+**Auth**: Required
+**Scope**: file-level
+
+#### Query Parameters
+
+| Field | Type | Required | Default | Description |
+|-------|------|----------|---------|-------------|
+| `start_frame` | integer | No* | — | Start frame (zero-based). **Frame-accurate** — use this for precision. |
+| `end_frame` | integer | No* | — | End frame (zero-based, inclusive). Requires `start_frame`. |
+| `start_time` | float | No* | — | Start time in seconds. Approximate (FPS-dependent). Fallback if frames not given. |
+| `end_time` | float | No* | — | End time in seconds. Approximate (FPS-dependent). Fallback if frames not given. |
+| `fps` | float | No | video FPS | Override frames-per-second for frame↔time calculation. Defaults to video's detected FPS. |
+| `mode` | string | No | `normal` | `normal` or `debug` (draws "CLIP" overlay) |
+| `audio` | string | No | `on` | `on` or `off` |
+
+Either (`start_frame`+`end_frame`) OR (`start_time`+`end_time`) must be provided.
+
+#### Example
+
+```bash
+# Clip by frame range (primary)
+curl -s "$API/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/clip?start_frame=0&end_frame=47" \
+ -H "Authorization: Bearer $JWT" -o clip.ts
+
+# Clip by time range (fallback)
+curl -s "$API/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/clip?start_time=30&end_time=45" \
+ -H "Authorization: Bearer $JWT" -o clip.ts
+```
+
+#### Response
+
+- **200**: `video/mp2t` MPEG-TS stream
+- **400**: Missing/invalid range parameters
+- **404**: File not found
+- **500**: FFmpeg error
+
+#### Technical Notes
+
+| Detail | Value |
+|--------|-------|
+| **Backend** | FFmpeg (`ffmpeg-full`) |
+| **Seek** | `-ss` before `-i` (fast keyframe seek) |
+| **Format** | MPEG-TS (`mpegts` muxer, pipe-safe) |
+| **Codec** | H.264 + AAC |
+| **Cache** | `Cache-Control: public, max-age=86400` (24h) |
+
+---
+
+| Detail | Value |
+|--------|-------|
+| **Backend** | FFmpeg (`ffmpeg-full`) |
+| **Filter** | `select=eq(n\,FRAME)` to select frame, optional `crop=W:H:X:Y` |
+| **Output** | Single JPEG via pipe (`image2pipe`, `mjpeg` codec) |
+| **Cache** | `Cache-Control: public, max-age=86400` (24h) |
+| **Frame number** | Zero-based (`frame=0` = first frame of video) |
diff --git a/docs_v1.0/doc_wasm/modules/09_tmdb.md b/docs_v1.0/doc_wasm/modules/09_tmdb.md
new file mode 100644
index 0000000..7ea3f27
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/09_tmdb.md
@@ -0,0 +1,109 @@
+
+
+
+
+## TMDb Enrichment
+
+> **Offline operation**: TMDb prefetch now checks local identity files first (`identities/_index.json` + `*.tmdb.json`).
+> If local files exist, no external API call is made. Internet is only needed for initial data seeding.
+
+### Overview
+
+TMDb enrichment is an optional identity enrichment step that can be run after Pipeline face detection completes. The workflow is:
+
+1. **Prefetch** (requires internet): Download movie cast data from TMDb API → cache to `{file_uuid}.tmdb.json`
+2. **Probe**: Read local cache → create identities for **all** cast members (`source='tmdb'`) + save `identity.json` + download profile image to `{OUTPUT}/identities/{uuid}/profile.jpg`
+3. **Match**: The worker automatically matches video faces against TMDb identities when `MOMENTRY_TMDB_PROBE_ENABLED=true`
+
+### `POST /api/v1/agents/tmdb/prefetch`
+
+**Auth**: Required
+**Scope**: file-level
+
+Fetch TMDb cast data for a registered file and cache it locally. This is the only step requiring internet access.
+
+#### Request Parameters
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `file_uuid` | string | Yes | File UUID to enrich |
+
+#### Example
+
+```bash
+curl -s -X POST "$API/api/v1/agents/tmdb/prefetch" \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: $KEY" \
+ -d '{"file_uuid": "'"$FILE_UUID"'"}'
+```
+
+#### Response (200)
+
+```json
+{"success": true, "file_uuid": "...", "cache_path": "/output/...tmdb.json"}
+```
+
+### `POST /api/v1/file/:file_uuid/tmdb-probe`
+
+**Auth**: Required
+**Scope**: file-level
+
+Read local TMDb cache and create/update identities. Requires prefetch to have been run first.
+
+#### Example
+
+```bash
+curl -s -X POST "$API/api/v1/file/$FILE_UUID/tmdb-probe" \
+ -H "X-API-Key: $KEY" | jq '{identities_created, movie_title}'
+```
+
+#### Response (200 — identities created)
+
+```json
+{"success": true, "identities_created": 15, "movie_title": "Charade"}
+```
+
+#### Response (200 — no cache)
+
+```json
+{"success": false, "message": "No TMDb cache found. Run tmdb-prefetch first."}
+```
+
+### `GET /api/v1/resource/tmdb`
+
+**Auth**: Required
+**Scope**: system-level
+
+View TMDb resource status including configuration, identity counts, and cache file count.
+
+#### Example
+
+```bash
+curl -s "$API/api/v1/resource/tmdb" -H "X-API-Key: $KEY" \
+ | jq '{identities_seeded, cache_files}'
+```
+
+### `POST /api/v1/resource/tmdb/check`
+
+**Auth**: Required
+**Scope**: system-level
+
+Ping the TMDb API to verify connectivity and measure latency.
+
+#### Example
+
+```bash
+curl -s -X POST "$API/api/v1/resource/tmdb/check" \
+ -H "X-API-Key: $KEY" | jq '.status'
+```
+
+#### Response
+
+```json
+{
+ "api_key_configured": true,
+ "enabled": false,
+ "api_reachable": true,
+ "api_latency_ms": 120
+}
+```
diff --git a/docs_v1.0/doc_wasm/modules/10_pipeline.md b/docs_v1.0/doc_wasm/modules/10_pipeline.md
new file mode 100644
index 0000000..05c1f4a
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/10_pipeline.md
@@ -0,0 +1,178 @@
+
+
+
+
+## Pipeline
+
+### Dependency Graph
+
+```mermaid
+flowchart TB
+ subgraph Processors["10 Processors"]
+ Cut[Cut] --> ASR[ASR]
+ ASR --> ASRX[ASRX]
+ ASRX --> Story[Story]
+ Cut --> Story
+ YOLO[YOLO] --> VisualChunk[VisualChunk]
+ VisualChunk --> Story
+ Face[Face] --> Story
+ Story --> FiveW1H[5W1H]
+ OCR[OCR]
+ Pose[Pose]
+ end
+
+ subgraph Ingestion["入庫 (Post-Processing)"]
+ ASR --> Rule1[Rule 1 Sentence]
+ ASRX --> Rule1
+ Rule1 --> Vectorize[Auto-Vectorize]
+ Rule1 --> Phase1[Phase 1 Pack]
+
+ Cut --> Rule3[Rule 3 Scene]
+ ASR --> Rule3
+
+ Face --> Trace[Face Trace]
+ Trace --> Qdrant[Qdrant Sync]
+ Trace --> TraceChunks[Trace Chunks]
+ Trace --> TKG[TKG Builder]
+
+ Face --> TMDbMatch[TMDb Match]
+ Face --> SceneMeta[Scene Metadata]
+ YOLO --> SceneMeta
+ Face --> IdentityAgent[Identity Agent]
+ ASRX --> IdentityAgent
+
+ Cut --> Agent5W1H[5W1H Agent]
+ ASR --> Agent5W1H
+ Agent5W1H --> Phase2[Phase 2 Pack]
+ end
+
+ style Processors fill:#1a1a2e,stroke:#e94560
+ style Ingestion fill:#16213e,stroke:#0f3460
+```
+
+### Pipeline Completion Flow
+
+The pipeline is **not complete** until both the 10 processors AND the 入庫 (ingestion) steps have finished. The worker polls every 3 seconds and only marks the job as `completed` when all ingestion steps verify OK.
+
+```
+10 processors done
+ ↓ (job status stays "running")
+Algorithm 1 Trigger: Rule 1 + Vectorize + Phase 1 Pack
+ ↓ (job runs in parallel)
+Algorithm 2 Trigger: Face Trace → TKG, Scene Metadata, Identity Agent, 5W1H Agent
+ ↓ (poll checks every 3s)
+Ingestion verification: rule1 ✓ vectorize ✓ rule3 ✓ face_trace ✓ tkg ✓ scene_meta ✓ 5w1h ✓
+ ↓
+job status = "completed"
+```
+
+### 10 Processor Stages
+
+| # | Processor | Depends On | Description |
+|---|-----------|------------|-------------|
+| 1 | `Cut` | — | Scene boundary detection (PySceneDetect) |
+| 2 | `ASR` | Cut | Automatic speech recognition (faster-whisper) |
+| 3 | `ASRX` | ASR | Speaker diarization + ASR refinement |
+| 4 | `YOLO` | — | Object detection (YOLOv8) |
+| 5 | `OCR` | — | Optical character recognition |
+| 6 | `Face` | — | Face detection + recognition (InsightFace + CoreML) |
+| 7 | `Pose` | — | Pose estimation |
+| 8 | `VisualChunk` | YOLO | Visual object chunking |
+| 9 | `Story` | ASRX + Cut + YOLO + Face | Narrative scene summarization (LLM, with embedding) |
+| 10 | `5W1H` | Story | Who/What/When/Where/Why extraction (LLM, with embedding) |
+
+### 入庫 (Post-Processing / Ingestion)
+
+These steps run after the 10 processors and are **required for pipeline completion**. The worker checks all of them before marking the job as done.
+
+| # | Step | Triggers When | Verification |
+|---|------|--------------|-------------|
+| 1 | **Rule 1 Sentence Chunking** | ASR + ASRX done | `chunk` table has rows with `chunk_type = 'sentence'` |
+| 2 | **Auto-Vectorize** | Rule 1 done | `chunk.embedding` IS NOT NULL for sentence chunks |
+| 3 | **Phase 1 Pack** | Rule 1 done | `release_pack.py --phase 1` executed |
+| 4 | **Rule 3 Scene Chunking** | All 10 processors done + Cut + ASR | `chunk` table has rows with `chunk_type = 'cut'` |
+| 5 | **Face Trace** | All 10 processors done + Face | `face_detections.trace_id` IS NOT NULL |
+| 6 | **Qdrant Face Sync** | Face Trace done | Qdrant face_embedding collection populated |
+| 7 | **Trace Chunks** | Face Trace done | `chunk` table has rows with `chunk_type = 'trace'` |
+| 8 | **TKG Builder** | Face Trace done | `tkg_nodes` + `tkg_edges` tables have rows |
+| 9 | **TMDb Face Matching** | TMDb enabled + Face done | `face_detections.identity_id` IS NOT NULL |
+| 10 | **Heuristic Scene Metadata** | Face + YOLO done | `{file_uuid}.scene_meta.json` exists on disk |
+| 11 | **Identity Agent** | Face + ASRX done | `identities` with `source = 'identity_agent'` |
+| 12 | **5W1H Agent** | Cut + ASR done | `chunk.summary_text` IS NOT NULL for cut chunks |
+| 13 | **Release Pack** | 5W1H Agent done | `release_pack.py --phase 2` executed |
+
+### Ingestion Status
+
+Check real-time ingestion status for a file:
+
+```bash
+curl "$API/api/v1/stats/ingestion-status/{file_uuid}"
+```
+
+Returns per-step `done` / `pending` status with detail counts.
+
+#### Example
+
+```bash
+curl "http://localhost:3003/api/v1/stats/ingestion-status/bd80fec9c42afb0307eb28f22c64c76a" | jq '.steps[] | {name, status, detail}'
+```
+
+#### Response
+
+```json
+{
+ "file_uuid": "bd80fec9c42afb0307eb28f22c64c76a",
+ "steps": [
+ { "name": "rule1_sentence", "status": "pending", "detail": "0 sentence chunks" },
+ { "name": "auto_vectorize", "status": "pending", "detail": "0 embedded" },
+ { "name": "rule3_scene", "status": "pending", "detail": "0 scene chunks" },
+ { "name": "face_trace", "status": "pending", "detail": "0 traces" },
+ { "name": "trace_chunks", "status": "pending", "detail": "0 trace chunks" },
+ { "name": "tkg", "status": "pending", "detail": "0 nodes, 0 edges" },
+ { "name": "identity_match", "status": "pending", "detail": "0 identities" },
+ { "name": "scene_metadata", "status": "pending", "detail": null },
+ { "name": "5w1h", "status": "pending", "detail": "0 scenes with 5W1H" }
+ ]
+}
+```
+
+### Stats Endpoints
+
+| Method | Endpoint | Auth | Description |
+|--------|----------|------|-------------|
+| GET | `/api/v1/stats/sftpgo` | No | SFTPGo service status |
+| GET | `/api/v1/stats/ingestion-status/:file_uuid` | No | Per-file ingestion checklist |
+
+### Configuration
+
+### `POST /api/v1/config/cache`
+
+**Auth**: Required
+**Scope**: system-level
+
+Toggle the Redis cache on or off.
+
+#### Request Parameters
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `enabled` | boolean | Yes | `true` to enable, `false` to disable |
+
+#### Example
+
+```bash
+curl -s -X POST "$API/api/v1/config/cache" \
+ -H "Content-Type: application/json" \
+ -H "X-API-Key: $KEY" \
+ -d '{"enabled": false}'
+```
+
+### Unmounted Routes
+
+The following routes are defined in source code but are **NOT** currently mounted in the router:
+
+| Endpoint | Source file |
+|----------|-------------|
+| `/api/v1/search/persons` | `universal_search.rs` (not mounted) |
+| `/api/v1/who` | `who.rs` |
+| `/api/v1/who/candidates` | `who.rs` |
diff --git a/docs_v1.0/doc_wasm/modules/11_error_codes.md b/docs_v1.0/doc_wasm/modules/11_error_codes.md
new file mode 100644
index 0000000..15f2dad
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/11_error_codes.md
@@ -0,0 +1,57 @@
+
+
+
+
+## Error Response Format
+
+All API errors follow this JSON structure:
+
+```json
+{
+ "success": false,
+ "error": {
+ "code": "E001_NOT_FOUND",
+ "message": "Resource not found",
+ "details": {"resource": "file_uuid", "value": "abc"}
+ }
+}
+```
+
+## Error Code List
+
+### Generic Errors (E0xx)
+
+| Code | HTTP | Description |
+|------|------|-------------|
+| `E001_NOT_FOUND` | 404 | Resource not found (file, identity, chunk) |
+| `E002_DUPLICATE` | 409 | Resource already exists |
+| `E003_VALIDATION` | 400 | Request parameter validation failed |
+| `E004_UNAUTHORIZED` | 401 | Invalid API key or token |
+| `E005_INTERNAL` | 500 | Internal server error |
+
+### Processor Errors (E1xx)
+
+| Code | HTTP | Description |
+|------|------|-------------|
+| `E101_PROCESSOR_FAIL` | 500 | Python script execution failed |
+| `E102_TIMEOUT` | 504 | Processing timeout |
+| `E103_RESUME_FAIL` | 500 | Resume failed (checkpoint not found) |
+| `E104_NO_VIDEO` | 400 | Video file path not found |
+
+### Identity Errors (E2xx)
+
+| Code | HTTP | Description |
+|------|------|-------------|
+| `E201_FACE_NOT_FOUND` | 404 | Face detection not found |
+| `E202_MERGE_CONFLICT` | 409 | Identity merge conflict |
+| `E203_CANDIDATE_EMPTY` | 404 | No candidates available for confirmation |
+
+### TMDb Errors (E3xx)
+
+| Code | HTTP | Description |
+|------|------|-------------|
+| `E301_TMDB_NO_KEY` | 400 | `TMDB_API_KEY` environment variable not set |
+| `E302_TMDB_UNREACHABLE` | 502 | TMDb API unreachable or timed out |
+| `E303_TMDB_CACHE_NOT_FOUND` | 200 | No local TMDb cache; run prefetch first |
+| `E304_TMDB_PROBE_FAILED` | 500 | TMDb probe execution failed |
+| `E305_TMDB_MOVIE_NOT_FOUND` | 404 | No matching TMDb movie found from filename |
diff --git a/docs_v1.0/doc_wasm/modules/12_agent.md b/docs_v1.0/doc_wasm/modules/12_agent.md
new file mode 100644
index 0000000..e17fbc3
--- /dev/null
+++ b/docs_v1.0/doc_wasm/modules/12_agent.md
@@ -0,0 +1,118 @@
+# Agent Endpoints
+
+Agent endpoints provide AI-powered capabilities including translation, identity analysis, and 5W1H extraction.
+
+## POST /api/v1/agents/translate
+
+Translate text between languages using Gemma4 (llama.cpp, port 8082).
+
+### Request
+
+```json
+{
+ "text": "Hello, welcome to Momentry Core.",
+ "target_language": "Traditional Chinese",
+ "source_language": "English"
+}
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `text` | string | ✅ | Text to translate |
+| `target_language` | string | ✅ | Target language name (e.g. "Traditional Chinese", "Japanese") |
+| `source_language` | string | ❌ | Source language (default: "auto") |
+
+### Response
+
+```json
+{
+ "success": true,
+ "translated_text": "您好,歡迎使用 Momentry Core。",
+ "source_language_detected": "English",
+ "model_used": "google_gemma-4-26B-A4B-it-Q5_K_M.gguf"
+}
+```
+
+### Supported Language Pairs (tested)
+
+| Source | Target | Quality |
+|--------|--------|---------|
+| English | Traditional Chinese | ✅ |
+| English | Japanese | ✅ |
+| Chinese | English | ✅ |
+| English | French | ✅ |
+| Chinese | Japanese | ✅ |
+
+### Model
+
+- **Model**: Gemma4 26B (Q5_K_M)
+- **Engine**: llama.cpp at `localhost:8082`
+- **Endpoint**: `/v1/chat/completions` (OpenAI-compatible)
+- **Temperature**: 0.1
+- **Max tokens**: 1024
+
+### Errors
+
+| Status | Condition |
+|--------|-----------|
+| 500 | LLM unreachable or response parse failure |
+| 401 | Missing/invalid auth |
+
+---
+
+## POST /api/v1/agents/5w1h/analyze
+
+Extract 5W1H (Who, What, When, Where, Why, How) from a scene. Uses Gemma4 LLM on port 8082.
+
+### Request
+
+```json
+{
+ "file_uuid": "3abeee81d94597629ed8cb943f182e94",
+ "scene_id": 42
+}
+```
+
+### Response
+
+```json
+{
+ "success": true,
+ "5w1h": {
+ "who": ["Cary Grant"],
+ "what": ["discussing plans"],
+ "when": ["1963"],
+ "where": ["Paris"],
+ "why": ["vacation"],
+ "how": ["in person"]
+ }
+}
+```
+
+## POST /api/v1/agents/5w1h/batch
+
+Batch analyze all scenes in a file for 5W1H extraction. Uses the pipeline's `parent_chunk_5w1h.py --mode llm`.
+
+### Request
+
+```json
+{
+ "file_uuid": "3abeee81d94597629ed8cb943f182e94"
+}
+```
+
+## GET /api/v1/agents/5w1h/status
+
+Get status of the 5W1H agent pipeline for a file.
+
+---
+
+## Embedding Model
+
+| Detail | Value |
+|--------|-------|
+| **Model** | EmbeddingGemma-300m |
+| **Endpoint** | `POST /v1/embeddings` on port 11436 |
+| **Dimension** | 768 |
+| **Used by** | `parent_chunk_5w1h.py --embed`, story, 5W1H, search |
+
diff --git a/docs_v1.0/doc_wasm/pkg/doc_wasm.js b/docs_v1.0/doc_wasm/pkg/doc_wasm.js
new file mode 100644
index 0000000..121d078
--- /dev/null
+++ b/docs_v1.0/doc_wasm/pkg/doc_wasm.js
@@ -0,0 +1,232 @@
+/* @ts-self-types="./doc_wasm.d.ts" */
+
+/**
+ * @returns {string}
+ */
+export function module_list() {
+ let deferred1_0;
+ let deferred1_1;
+ try {
+ const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
+ wasm.module_list(retptr);
+ var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
+ var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
+ deferred1_0 = r0;
+ deferred1_1 = r1;
+ return getStringFromWasm0(r0, r1);
+ } finally {
+ wasm.__wbindgen_add_to_stack_pointer(16);
+ wasm.__wbindgen_export(deferred1_0, deferred1_1, 1);
+ }
+}
+
+/**
+ * @param {string} md
+ * @returns {string}
+ */
+export function render_markdown(md) {
+ let deferred2_0;
+ let deferred2_1;
+ try {
+ const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
+ const ptr0 = passStringToWasm0(md, wasm.__wbindgen_export2, wasm.__wbindgen_export3);
+ const len0 = WASM_VECTOR_LEN;
+ wasm.render_markdown(retptr, ptr0, len0);
+ var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
+ var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
+ deferred2_0 = r0;
+ deferred2_1 = r1;
+ return getStringFromWasm0(r0, r1);
+ } finally {
+ wasm.__wbindgen_add_to_stack_pointer(16);
+ wasm.__wbindgen_export(deferred2_0, deferred2_1, 1);
+ }
+}
+function __wbg_get_imports() {
+ const import0 = {
+ __proto__: null,
+ };
+ return {
+ __proto__: null,
+ "./doc_wasm_bg.js": import0,
+ };
+}
+
+let cachedDataViewMemory0 = null;
+function getDataViewMemory0() {
+ if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
+ cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
+ }
+ return cachedDataViewMemory0;
+}
+
+function getStringFromWasm0(ptr, len) {
+ return decodeText(ptr >>> 0, len);
+}
+
+let cachedUint8ArrayMemory0 = null;
+function getUint8ArrayMemory0() {
+ if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
+ cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
+ }
+ return cachedUint8ArrayMemory0;
+}
+
+function passStringToWasm0(arg, malloc, realloc) {
+ if (realloc === undefined) {
+ const buf = cachedTextEncoder.encode(arg);
+ const ptr = malloc(buf.length, 1) >>> 0;
+ getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
+ WASM_VECTOR_LEN = buf.length;
+ return ptr;
+ }
+
+ let len = arg.length;
+ let ptr = malloc(len, 1) >>> 0;
+
+ const mem = getUint8ArrayMemory0();
+
+ let offset = 0;
+
+ for (; offset < len; offset++) {
+ const code = arg.charCodeAt(offset);
+ if (code > 0x7F) break;
+ mem[ptr + offset] = code;
+ }
+ if (offset !== len) {
+ if (offset !== 0) {
+ arg = arg.slice(offset);
+ }
+ ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
+ const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
+ const ret = cachedTextEncoder.encodeInto(arg, view);
+
+ offset += ret.written;
+ ptr = realloc(ptr, len, offset, 1) >>> 0;
+ }
+
+ WASM_VECTOR_LEN = offset;
+ return ptr;
+}
+
+let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
+cachedTextDecoder.decode();
+const MAX_SAFARI_DECODE_BYTES = 2146435072;
+let numBytesDecoded = 0;
+function decodeText(ptr, len) {
+ numBytesDecoded += len;
+ if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
+ cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
+ cachedTextDecoder.decode();
+ numBytesDecoded = len;
+ }
+ return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
+}
+
+const cachedTextEncoder = new TextEncoder();
+
+if (!('encodeInto' in cachedTextEncoder)) {
+ cachedTextEncoder.encodeInto = function (arg, view) {
+ const buf = cachedTextEncoder.encode(arg);
+ view.set(buf);
+ return {
+ read: arg.length,
+ written: buf.length
+ };
+ };
+}
+
+let WASM_VECTOR_LEN = 0;
+
+let wasmModule, wasmInstance, wasm;
+function __wbg_finalize_init(instance, module) {
+ wasmInstance = instance;
+ wasm = instance.exports;
+ wasmModule = module;
+ cachedDataViewMemory0 = null;
+ cachedUint8ArrayMemory0 = null;
+ return wasm;
+}
+
+async function __wbg_load(module, imports) {
+ if (typeof Response === 'function' && module instanceof Response) {
+ if (typeof WebAssembly.instantiateStreaming === 'function') {
+ try {
+ return await WebAssembly.instantiateStreaming(module, imports);
+ } catch (e) {
+ const validResponse = module.ok && expectedResponseType(module.type);
+
+ if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
+ console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
+
+ } else { throw e; }
+ }
+ }
+
+ const bytes = await module.arrayBuffer();
+ return await WebAssembly.instantiate(bytes, imports);
+ } else {
+ const instance = await WebAssembly.instantiate(module, imports);
+
+ if (instance instanceof WebAssembly.Instance) {
+ return { instance, module };
+ } else {
+ return instance;
+ }
+ }
+
+ function expectedResponseType(type) {
+ switch (type) {
+ case 'basic': case 'cors': case 'default': return true;
+ }
+ return false;
+ }
+}
+
+function initSync(module) {
+ if (wasm !== undefined) return wasm;
+
+
+ if (module !== undefined) {
+ if (Object.getPrototypeOf(module) === Object.prototype) {
+ ({module} = module)
+ } else {
+ console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
+ }
+ }
+
+ const imports = __wbg_get_imports();
+ if (!(module instanceof WebAssembly.Module)) {
+ module = new WebAssembly.Module(module);
+ }
+ const instance = new WebAssembly.Instance(module, imports);
+ return __wbg_finalize_init(instance, module);
+}
+
+async function __wbg_init(module_or_path) {
+ if (wasm !== undefined) return wasm;
+
+
+ if (module_or_path !== undefined) {
+ if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
+ ({module_or_path} = module_or_path)
+ } else {
+ console.warn('using deprecated parameters for the initialization function; pass a single object instead')
+ }
+ }
+
+ if (module_or_path === undefined) {
+ module_or_path = new URL('doc_wasm_bg.wasm', import.meta.url);
+ }
+ const imports = __wbg_get_imports();
+
+ if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
+ module_or_path = fetch(module_or_path);
+ }
+
+ const { instance, module } = await __wbg_load(await module_or_path, imports);
+
+ return __wbg_finalize_init(instance, module);
+}
+
+export { initSync, __wbg_init as default };
diff --git a/docs_v1.0/doc_wasm/pkg/doc_wasm_bg.wasm b/docs_v1.0/doc_wasm/pkg/doc_wasm_bg.wasm
new file mode 100644
index 0000000..a5c27fb
Binary files /dev/null and b/docs_v1.0/doc_wasm/pkg/doc_wasm_bg.wasm differ
diff --git a/src/api/server.rs b/src/api/server.rs
index e414487..49399e9 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -3447,6 +3447,40 @@ async fn unregister(
}
/// Serve documentation HTML pages with cookie-based auth.
+async fn wasm_doc_handler() -> Result {
+ let path = std::path::Path::new("/Users/accusys/momentry_core_0.1/docs_v1.0/doc_wasm/index.html");
+ match tokio::fs::read_to_string(path).await {
+ Ok(html) => Ok(([("content-type", "text/html; charset=utf-8")], html)),
+ Err(_) => Err((StatusCode::NOT_FOUND, "Doc not found")),
+ }
+}
+
+async fn wasm_doc_file_handler(
+ Path(file): Path,
+) -> Result {
+ if file.contains("..") || file.contains("//") {
+ return Err((StatusCode::NOT_FOUND, "Invalid path"));
+ }
+ let base = std::path::Path::new("/Users/accusys/momentry_core_0.1/docs_v1.0/doc_wasm");
+ let path = base.join(&file);
+ if !path.exists() || !path.starts_with(base) {
+ return Err((StatusCode::NOT_FOUND, "File not found"));
+ }
+ let data = tokio::fs::read(&path).await.map_err(|_| (StatusCode::NOT_FOUND, "Read error"))?;
+ let mime = if file.ends_with(".wasm") {
+ "application/wasm"
+ } else if file.ends_with(".js") {
+ "application/javascript"
+ } else if file.ends_with(".md") {
+ "text/markdown; charset=utf-8"
+ } else if file.ends_with(".css") {
+ "text/css"
+ } else {
+ "application/octet-stream"
+ };
+ Ok(([("content-type", mime)], data))
+}
+
async fn doc_handler(
State(state): State,
headers: axum::http::HeaderMap,
@@ -3673,6 +3707,8 @@ pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
.route("/doc", get(doc_handler))
.route("/doc/*file", get(doc_file_handler))
.route("/dev-doc", get(dev_doc_handler))
+ .route("/doc-wasm", get(wasm_doc_handler))
+ .route("/doc-wasm/*file", get(wasm_doc_file_handler))
.route("/api/v1/auth/login", post(login))
.route("/api/v1/auth/logout", post(logout))
.route("/api/v1/stats/sftpgo", get(get_sftpgo_status))
@@ -3813,16 +3849,20 @@ async fn get_ingestion_status(
let tkg_edges = count_sql!(&format!("SELECT COUNT(*) FROM {} WHERE file_uuid = '{file_uuid}'", schema::table_name("tkg_edges")));
let scene_5w1h = count_sql!(&format!("SELECT COUNT(*) FROM {chunk} WHERE file_uuid = '{file_uuid}' AND chunk_type = 'cut' AND summary_text IS NOT NULL AND summary_text != ''"));
- let related_identities: Vec = sqlx::query_as::<_, (String, String)>(&format!(
- "SELECT DISTINCT i.uuid, i.name FROM {identities} i \
+ let related_identities: Vec = match sqlx::query_as::<_, (String, String)>(&format!(
+ "SELECT DISTINCT i.uuid::text, i.name FROM {identities} i \
JOIN {fd} fd ON fd.identity_id = i.id \
WHERE fd.file_uuid = '{file_uuid}' AND fd.identity_id IS NOT NULL \
ORDER BY i.name"
- )).fetch_all(pool).await.unwrap_or_default().into_iter()
- .map(|(uuid, name)| {
- let uuid = uuid.replace('-', "");
- IdentityRef { uuid, name }
- }).collect();
+ )).fetch_all(pool).await {
+ Ok(rows) => rows.into_iter().map(|(uuid, name)| {
+ IdentityRef { uuid: uuid.replace('-', ""), name }
+ }).collect(),
+ Err(e) => {
+ tracing::error!("related_identities query failed: {}", e);
+ vec![]
+ }
+ };
let strangers = count_sql!(&format!(
"SELECT COUNT(DISTINCT trace_id) FROM {fd} \