Compare commits

...

6 Commits

Author SHA1 Message Date
Accusys
e1572907ae feat: ASRX hybrid pipeline, identity history, worker fixes, checkpoint system 2026-06-02 07:13:23 +08:00
Accusys
e3066c3f49 Add Charade face matching experience report
Documents the journey from Rust pipeline snowball bug through
5 iterations of pgvector-based matching to the final 11-identity
centroid approach with dual-gate and ambiguity cleanup.
2026-06-02 05:01:56 +08:00
Accusys
3731a1230f docs: add Identity Best-Face API requirement document for frontend team 2026-06-01 21:58:54 +08:00
Accusys
874d688987 feat: deploy hybrid search (semantic+keyword+identity) with RRF fusion
- Replace smart_search with hybrid RRF implementation
- Add speaker_detections table for identity-agent binding
- Fix identity queries: direct SQL to avoid type mismatches
- Add debug logs to job_worker for processor debugging
- Deployed to production (3002) successfully

Key changes:
- search.rs: Complete rewrite with 3 strategies + RRF
- postgres_db.rs: speaker_detections table + identity query fixes
- job_worker.rs: Debug logs for output file checks

Tested:
- Hybrid search works with semantic + keyword + identity
- Identity search: 'identity:Charade' returns correct results
- Chinese keyword search: '調光' matches Charade summaries

Bugs found:
- Case mismatch: 'ASRX' vs 'asrx' in processors field
- Missing CUT dependency for ASRX processor
2026-06-01 15:15:17 +08:00
Accusys
0d58a738a1 feat: add processor state machine and alert mechanism
- Add ProcessorJobStatus enum (8 states: Idle/Waiting/Ready/Pending/Running/Completed/Failed/Skipped)
- Add processor_alerts table (migrations/034)
- Add emit_processor_alert() to redis_client.rs
- Add ConditionResult enum + check_dependencies() to job_worker.rs
2026-05-30 10:03:49 +08:00
Accusys
08167d73b2 docs: add Processor State Machine V1.0 design 2026-05-30 10:03:48 +08:00
205 changed files with 45723 additions and 9183 deletions

View File

@@ -41,8 +41,8 @@ MOMENTRY_PYTHON_PATH=/Users/accusys/momentry_core/venv/bin/python
MOMENTRY_SCRIPTS_DIR=/Users/accusys/momentry_core/scripts
# Logging
RUST_LOG=debug
MOMENTRY_LOG_LEVEL=debug
RUST_LOG=info
MOMENTRY_LOG_LEVEL=info
# Media
MOMENTRY_MEDIA_BASE_URL=https://wp.momentry.ddns.net
@@ -77,5 +77,27 @@ MOMENTRY_LLM_SUMMARY_URL=http://127.0.0.1:8082/v1/chat/completions
MOMENTRY_LLM_SUMMARY_MODEL=google_gemma-4-26B-A4B-it-Q5_K_M.gguf
MOMENTRY_LLM_SUMMARY_ENABLED=true
# LLM Chat (A4B on port 8082)
MOMENTRY_LLM_CHAT_URL=http://127.0.0.1:8082/v1/chat/completions
MOMENTRY_LLM_CHAT_MODEL=google_gemma-4-26B-A4B-it-Q5_K_M.gguf
# LLM Vision (E4B on port 8083)
MOMENTRY_LLM_VISION_URL=http://127.0.0.1:8083/v1/chat/completions
MOMENTRY_LLM_VISION_MODEL=gemma-4-E4B-it-Q4_K_M.gguf
# Embedding (ANE CoreML server)
MOMENTRY_EMBED_URL=http://localhost:11436
# === Binary & Data Paths (for start_momentry.sh) ===
MOMENTRY_LOG_DIR=/Users/accusys/momentry/logs
MOMENTRY_PG_BIN_DIR=/Users/accusys/pgsql/18.3/bin
MOMENTRY_PG_DATA_DIR=/Users/accusys/pgsql/data
MOMENTRY_QDRANT_BIN=/Users/accusys/.cargo/bin/qdrant
MOMENTRY_QDRANT_STORAGE_DIR=/Users/accusys/momentry/qdrant_storage
MOMENTRY_LLAMACPP_BIN=/Users/accusys/llama/bin/llama-server
MOMENTRY_LLM_A4B_MODEL_PATH=/Users/accusys/models/google_gemma-4-26B-A4B-it-Q5_K_M.gguf
MOMENTRY_LLM_A4B_MMPROJ_PATH=/Users/accusys/models/gemma-4-26B-A4B-it.mmproj-f16.gguf
MOMENTRY_LLM_E4B_MODEL_PATH=/Users/accusys/models/gemma-4-E4B-it-Q4_K_M.gguf
MOMENTRY_LLM_E4B_MMPROJ_PATH=/Users/accusys/models/mmproj-gemma-4-E4B-it-BF16.gguf
MOMENTRY_OLLAMA_BIN=/Users/accusys/bin/ollama
MOMENTRY_PLAYGROUND_BIN=target/debug/momentry_playground

View File

@@ -32,6 +32,16 @@ MOMENTRY_LLM_SUMMARY_URL=http://127.0.0.1:8082/v1/chat/completions
MOMENTRY_LLM_SUMMARY_MODEL=google_gemma-4-26B-A4B-it-Q5_K_M.gguf
MOMENTRY_LLM_SUMMARY_TIMEOUT=120
# LLM Chat (A4B)
MOMENTRY_LLM_CHAT_URL=http://127.0.0.1:8082/v1/chat/completions
MOMENTRY_LLM_CHAT_MODEL=google_gemma-4-26B-A4B-it-Q5_K_M.gguf
MOMENTRY_LLM_CHAT_TIMEOUT=120
# LLM Vision (E4B)
MOMENTRY_LLM_VISION_URL=http://127.0.0.1:8083/v1/chat/completions
MOMENTRY_LLM_VISION_MODEL=gemma-4-E4B-it-Q4_K_M.gguf
MOMENTRY_LLM_VISION_TIMEOUT=120
# === Paths ===
MOMENTRY_OUTPUT_DIR=/Users/accusys/momentry/output_dev
MOMENTRY_BACKUP_DIR=/Users/accusys/momentry/backup

31
.gitignore vendored
View File

@@ -15,6 +15,35 @@ __pycache__/
node_modules/
*.log
/tmp/
*.log
*.diff
*.bundle
*.probe.json
*.cut.json
.qdrant-initialized
dump.rdb
fix55.js
checksums.sha256
scripts/swift_processors/.build/
.opencode/
.vscode/
backups/
logs/
output/
models/
data/
storage/
thumbnails/
services/
model_checkpoints/
release/delivery/
release/system/
release/phase*/
release/dev_*.sql
release/migrate_*.sql
release/files/
package-lock.json
package.json
portal/dist/
portal/src-tauri/icons/
momentry_runtime/logs/

View File

@@ -14,6 +14,7 @@ Rust-based digital asset management system with video analysis and RAG capabilit
- **🔴 DELETE / REMOVE / DROP / CLEAR 任何資料前必須先問使用者「要刪嗎?」獲得明確同意後才能執行**
- **🔴 Qdrant collection 刪除、DB truncate、檔案刪除、資料清空 — 一律要先問**
- **🔴 不確定是否該刪 → 先問,不要自己決定**
- **🔴 改變議題前必須先存檔紀錄**:使用 `todowrite` 工具或建立紀錄文件(如 `docs_v1.0/M4_workspace/YYYY-MM-DD_topic_handoff.md`),確保上下文不丟失
### 開發範圍界定
| 範圍 | 狀態 | 說明 |

View File

@@ -134,6 +134,14 @@ path = "src/bin/integrated_player.rs"
name = "release"
path = "src/bin/release.rs"
[[bin]]
name = "vectorize_missing"
path = "src/bin/vectorize_missing.rs"
[[bin]]
name = "sync_qdrant_from_pg"
path = "src/bin/sync_qdrant_from_pg.rs"
[[bin]]
name = "service"
path = "src/bin/service.rs"

277
IDENTITY_BEST_FACE_API.md Normal file
View File

@@ -0,0 +1,277 @@
# Identity Best-Face API
**狀態:** 規劃中
**提出日期:** 2026-06-01
**提出者:** WordPress Portal 前端團隊
---
## 1. 背景
WordPress Portal 的 People 頁面需要在 identity detail view 與 grid card 中顯示代表臉部縮圖。目前前端作法:
1. `GET /identity/{uuid}/traces` → 取得所有 trace 列表(含 `avg_confidence`
2. 對每個 trace 載入第一幀 thumbnail → `GET /file/{uuid}/trace/{tid}/thumbnail`
3. 從有 thumbnail 的 trace 中,選 `avg_confidence` 最高者作為代表圖
### 現有問題
- **品質不佳**trace thumbnail 固定取第一幀,不一定是該 trace 內最清晰或正面的臉部畫面
- **浪費頻寬**:前端需發送大量並行請求(最多 20 trace × thumbnail多數 thumbnail 最終不會被使用
- **無快取**:每次進入 detail view 都要重複載入所有 thumbnail
- **不一致**:同樣 identity 在 grid card 與 detail view 可能顯示不同代表圖
---
## 2. 目標
後端新增一個 endpoint對指定 identity **跨所有 trace** 選出品質最佳(最清晰)的臉部畫面,並提供可直接使用的縮圖 URL支援 disk cache。
---
## 3. API 規格
### `GET /api/v1/identity/:identity_uuid/best-face`
無 query parameter。
#### 成功回應 `200`
```json
{
"success": true,
"identity_uuid": "a6fb22eebefaef17e62af874997c5944",
"name": "Audrey Hepburn",
"source": "fresh",
"best": {
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"trace_id": 42,
"frame_number": 3120,
"timestamp_secs": 124.8,
"bbox": {
"x": 240,
"y": 180,
"width": 120,
"height": 160
},
"confidence": 0.97,
"quality_score": 18624.0,
"blur_score": 2.1,
"thumbnail_url": "/api/v1/file/a6fb22eebefaef17e62af874997c5944/trace/42/thumbnail"
}
}
```
#### 無可用臉部 `200`
```json
{
"success": true,
"identity_uuid": "a6fb22eebefaef17e62af874997c5944",
"name": "Audrey Hepburn",
"source": "fresh",
"best": null
}
```
#### 欄位說明
| 欄位 | 型態 | 說明 |
|------|------|------|
| `success` | boolean | 請求是否成功 |
| `identity_uuid` | string | identity UUID32字元無連字號 |
| `name` | string | identity 名稱 |
| `source` | string | `"fresh"`(即時計算)或 `"cache"`(來自 disk cache |
| `best` | object/null | 最佳臉部資訊,無可用臉部時為 `null` |
| `best.file_uuid` | string | 該臉部所屬檔案 UUID |
| `best.trace_id` | int | 該臉部所屬 trace ID |
| `best.frame_number` | int | 代表臉的影格編號 |
| `best.timestamp_secs` | float | 代表臉的時間戳(秒) |
| `best.bbox` | object | 臉部 bounding box `{x, y, width, height}` |
| `best.confidence` | float | 該臉部的 detection confidence |
| `best.quality_score` | float | 品質分數 = `(width * height) * confidence` |
| `best.blur_score` | float | 模糊度分數ffmpeg blurdetect越低越清晰 |
| `best.thumbnail_url` | string | 縮圖 URL相對路徑可直接用於瀏覽器 |
---
## 4. 實作建議
### 4.1 建議放置位置
**選項 A建議** `src/api/trace_agent_api.rs`
- 原因:核心邏輯重用 `select_rep_face()`(目前為 `pub(crate)`,位於同一檔案),無需修改既有的 function visibility
-`trace_agent_routes()` 中新增路由
**選項 B** `src/api/identity_binding.rs`
- 需將 `select_rep_face` 改為 `pub` 才能跨檔案呼叫
- 路由語意上更接近 identity 操作
### 4.2 演算法
```
1. DISK CACHE CHECK
路徑:{OUTPUT_DIR}/identities/{uuid}/best_face.json
讀取 identity.json 的 updated_at與 cache 中記錄的版本比較
若 cache 未過期 → 直接回傳source: "cache"
若無 cache 或已過期 → 繼續計算
2. QUERY IDENTITY
SELECT id, name FROM identities
WHERE REPLACE(uuid::text, '-', '') = $1
3. QUERY TOP N TRACES
SELECT fd.file_uuid, fd.trace_id,
AVG(fd.confidence)::float8 AS avg_conf
FROM {schema}.face_detections fd
WHERE fd.identity_id = $1
AND fd.confidence > 0.7
AND (fd.metadata->>'qc_ok' IS NULL
OR (fd.metadata->>'qc_ok')::boolean = true)
GROUP BY fd.file_uuid, fd.trace_id
ORDER BY avg_conf DESC
LIMIT 5
4. FOR EACH TRACE (並行)
select_rep_face(pool, file_uuid, trace_id, err_fn)
 → 回傳該 trace 內 blur_score 最低(最清晰)的臉
失敗則 skiplog warning
5. SELECT BEST AMONG RESULTS
主排序blur_score ASC越低越清晰
次排序quality_score DESCblur_score 差距 < 0.5 時)
全部失敗 → best = null
6. WRITE DISK CACHE
路徑:{OUTPUT_DIR}/identities/{uuid}/best_face.json
內容best 欄位 + 計算時間 + identity updated_at
7. RESPONSE
```
### 4.3 效能參數
| 參數 | 值 | 說明 |
|------|----|------|
| TOP N | 5 | 只對 confidence 最高的 5 個 trace 做 blurdetect |
| confidence 門檻 | > 0.7 | 同既有的 `select_rep_face` 邏輯 |
| QC 過濾 | qc_ok = true/null | 同既有邏輯 |
| ffmpeg timeout | inherit from Command | 每個 trace 約 1-3s |
| cache TTL | 直到下一次 bind/unbind/merge | 事件驅動失效 |
### 4.4 快取策略
**寫入時機:** `get_identity_best_face` 計算完成後
**失效時機(刪除 `best_face.json`**
| 觸發 operation | 所在檔案 | 備註 |
|---------------|---------|------|
| `bind_trace` (POST) | `identity_binding.rs` | 新增 face 關聯 |
| `unbind` (POST) | `identity_binding.rs` | 移除 face 關聯 |
| `mergeinto` (POST) | `identity_binding.rs` | source + target 雙雙清除 |
| `profile-image` (POST) | `identity_api.rs` | 使用者上傳新大頭照 |
**Cache 驗證機制:** 儲存計算時的 `identity.updated_at`,每次請求時比對:
- 若 identity 的 `updated_at` 未變 → cache 有效
- 若已變 → 重新計算
### 4.5 建議的新增/修改檔案
| 檔案 | 動作 | 說明 |
|------|------|------|
| `src/api/trace_agent_api.rs` | **新增** handler + struct + route | ~+130 行 |
| `src/api/identity_binding.rs` | **修改** 3 處 + cache invalidation helper | ~+25 行 |
| `src/api/identity_api.rs` | **修改** 1 處profile-image POST | ~+5 行 |
### 4.6 需要的新 struct
**`src/api/trace_agent_api.rs`**(或獨立檔案 `src/core/identity_best_face.rs`
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct BestFaceResponse {
pub success: bool,
pub identity_uuid: String,
pub name: String,
pub source: String,
pub best: Option<BestFaceResult>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BestFaceResult {
pub file_uuid: String,
pub trace_id: i32,
pub frame_number: i64,
pub timestamp_secs: f64,
pub bbox: RepFaceBbox,
pub confidence: f64,
pub quality_score: f64,
pub blur_score: f64,
pub thumbnail_url: String,
}
```
### 4.7 Cache Invalidation Helper Function
```rust
async fn invalidate_best_face_cache(output_dir: &str, uuid_clean: &str) {
let path = format!("{}/identities/{}/best_face.json", output_dir, uuid_clean);
let _ = tokio::fs::remove_file(path).await;
}
```
---
## 5. 前端整合參考(供後端團隊理解使用情境)
WP snippet 72 (`ms-people.js`) 的 `loadPersonDetail` 中,優先使用新 endpoint
```js
async function loadPersonDetail(person) {
if (person.thumb && person._hasProfileImage) return;
try {
const res = await apiFetch('/identity/' + person.id + '/best-face');
if (res?.success && res?.best) {
const b = res.best;
person.thumb = `${API_BASE}/file/${b.file_uuid}/trace/${b.trace_id}/thumbnail?api_key=${API_KEY}`;
person._hasProfileImage = true;
updateDetailAvatar(person);
return;
}
} catch (e) { /* fallback to legacy */ }
// 原邏輯traces → thumbnails → confidence sort
}
```
同樣可用於 grid card 的代表圖載入(`loadGridThumbnails`
```js
// 一次性載入所有 pending identity 的 best-face
const results = await Promise.allSettled(
persons.map(p => apiFetch('/identity/' + p.id + '/best-face'))
);
```
---
## 6. 驗收標準
1. `GET /api/v1/identity/{uuid}/best-face``200` + valid JSON
2. 有 trace 的 identity → `best` 不為 null`blur_score` 為該 identity 所有 trace 中最低
3. 無 trace 的 identity → `best: null`
4. 短時間內重複請求同一 identity → `source: "cache"`,回應時間 < 10ms
5. 綁定新 trace 後再次請求 → `source: "fresh"`cache 已正確失效)
6. `thumbnail_url` 可直接用於 `<img>` 顯示
---
## 7. 風險與注意事項
- **首次請求延遲**:對有大量 trace 的 identity如主角首次請求可能需 5-15 秒。建議前端顯示 loading state
- **ffmpeg 資源**:同時多個請求可能導致高 CPU 使用。可考慮加入 per-identity lock 避免重複計算
- **邊界案例**trace 內的 faces 全部 confidence ≤ 0.7 或 qc_ok=false則該 trace 被跳過,可能導致 `best: null`

View File

@@ -1,105 +1,178 @@
# Momentry Core 配置管理
# Momentry Core Config Management
## 目錄結構
## Directory Structure
```
momentry_core_0.1/
├── .env.example # 配置模板(已納入版本控制)
├── .env # 本地配置(已從版本控制排除)
├── .env.local # 本地覆蓋配置(已從版本控制排除)
├── .env.example # Template (version controlled)
├── .env # Local config (gitignored)
├── .env.development # Playground dev overrides (gitignored)
├── .env.local # Local overrides (gitignored)
├── config/
── README.md # 本文件
└── src/core/config.rs # 配置代碼
── README.md # This file
│ └── port_registry.tsv # Central port registry
└── src/core/config.rs # Config code with lazy_static env reading
```
## 配置加載順序
## Load Order
1. `.env` - 默認本地配置
2. `.env.local` - 本地覆蓋(最高優先級)
For `momentry_playground` (development):
1. `.env` — shared defaults
2. `.env.development` — dev-specific overrides (loaded by playground binary)
## 環境變數列表
For `momentry` (production):
1. `.env` — production config
### 數據庫配置
In Rust: `config.rs` reads env vars with lazy_static, falling back to hardcoded defaults.
| 變數 | 說明 | 默認值 |
|------|------|--------|
| `DATABASE_URL` | PostgreSQL 連接字串 | `postgres://accusys@localhost:5432/momentry` |
## Environment Variables
### Redis 配置
### Server
| 變數 | 說明 | 默認值 |
|------|------|--------|
| `REDIS_URL` | Redis 連接字串 | `redis://:accusys@localhost:6379` |
| `REDIS_PASSWORD` | Redis 密碼 | `accusys` |
| Variable | Description | Default |
|----------|-------------|---------|
| `MOMENTRY_SERVER_PORT` | Server port (3002=prod, 3003=dev) | `3002` |
| `MOMENTRY_REDIS_PREFIX` | Redis key prefix | `momentry:` (prod), `momentry_dev:` (dev) |
### 存儲路徑
### Database
| 變數 | 說明 | 默認值 |
|------|------|--------|
| `MOMENTRY_OUTPUT_DIR` | 輸出目錄 | `/Users/accusys/momentry/output` |
| `MOMENTRY_BACKUP_DIR` | 備份目錄 | `/Users/accusys/momentry/backup/momentry` |
| `MOMENTRY_SCRIPTS_DIR` | 腳本目錄 | `/Users/accusys/momentry_core_0.1/scripts` |
| `MOMENTRY_PYTHON_PATH` | Python 路徑 | `/opt/homebrew/bin/python3.11` |
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://accusys@localhost:5432/momentry` |
| `DATABASE_SCHEMA` | Schema for dev isolation | `dev` |
| `MONGODB_URL` | MongoDB connection string | `mongodb://localhost:27017` |
| `MONGODB_DATABASE` | MongoDB database name | `momentry` (prod), `momentry_dev` (dev) |
| `MONGODB_CACHE_ENABLED` | MongoDB cache toggle | `true` |
| `MONGODB_CACHE_TTL_VIDEOS` | Cache TTL for videos | `300` |
| `MONGODB_CACHE_TTL_SEARCH` | Cache TTL for search | `300` |
| `MONGODB_CACHE_TTL_HYBRID_SEARCH` | Cache TTL for hybrid search | `600` |
| `MONGODB_CACHE_TTL_VIDEO_META` | Cache TTL for video metadata | `3600` |
### 處理器超時(秒)
### Redis
| 變數 | 說明 | 默認值 |
|------|------|--------|
| `MOMENTRY_ASR_TIMEOUT` | ASR 處理超時 | `3600` |
| `MOMENTRY_CUT_TIMEOUT` | CUT 處理超時 | `3600` |
| `MOMENTRY_DEFAULT_TIMEOUT` | 默認超時 | `7200` |
| Variable | Description | Default |
|----------|-------------|---------|
| `REDIS_URL` | Redis connection string | `redis://:accusys@localhost:6379` |
| `REDIS_PASSWORD` | Redis password | `accusys` |
| `REDIS_CACHE_TTL_HEALTH` | Health check cache TTL | `30` |
| `REDIS_CACHE_TTL_VIDEO_META` | Video metadata cache TTL | `3600` |
### 日誌
### Qdrant
| 變數 | 說明 | 默認值 |
|------|------|--------|
| `RUST_LOG` | 日誌級別 | `info` |
| `MOMENTRY_LOG_LEVEL` | 日誌級別(備選) | `info` |
| Variable | Description | Default |
|----------|-------------|---------|
| `QDRANT_URL` | Qdrant server URL | `http://localhost:6333` |
| `QDRANT_API_KEY` | Qdrant API key | `Test3200Test3200Test3200` |
| `QDRANT_COLLECTION` | Collection name | `momentry_rule1` (prod), `momentry_dev_rule1_v2` (dev) |
## 使用方式
### LLM
### 1. 首次設置
| Variable | Description | Default |
|----------|-------------|---------|
| `MOMENTRY_LLM_CHAT_URL` | Chat/function-calling endpoint | `http://127.0.0.1:8082/v1/chat/completions` |
| `MOMENTRY_LLM_CHAT_MODEL` | Chat model name | `google_gemma-4-26B-A4B-it-Q5_K_M.gguf` |
| `MOMENTRY_LLM_VISION_URL` | Vision LLM endpoint (E4B) | falls back to CHAT_URL |
| `MOMENTRY_LLM_VISION_MODEL` | Vision model name (E4B) | falls back to CHAT_MODEL |
| `MOMENTRY_LLM_SUMMARY_URL` | Summary LLM endpoint (5W1H) | falls back to CHAT_URL |
| `MOMENTRY_LLM_SUMMARY_MODEL` | Summary model name | falls back to CHAT_MODEL |
| `MOMENTRY_LLM_SUMMARY_ENABLED` | Toggle 5W1H summary generation | `true` |
| `MOMENTRY_LLM_SUMMARY_TIMEOUT` | 5W1H timeout in seconds | `120` |
| `MOMENTRY_LLM_CHAT_TIMEOUT` | Chat LLM timeout in seconds | `120` |
| `MOMENTRY_LLM_VISION_TIMEOUT` | Vision LLM timeout in seconds | `120` |
### Embedding
| Variable | Description | Default |
|----------|-------------|---------|
| `MOMENTRY_EMBED_URL` | Embedding server URL | `http://localhost:11436` |
### TMDb Integration
| Variable | Description | Default |
|----------|-------------|---------|
| `TMDB_API_KEY` | TMDb API key (required for probe) | (none) |
| `MOMENTRY_TMDB_PROBE_ENABLED` | Enable TMDb probe during register | `false` |
### Paths
| Variable | Description | Default |
|----------|-------------|---------|
| `MOMENTRY_OUTPUT_DIR` | Output directory for processing | `/Users/accusys/momentry/output` |
| `MOMENTRY_BACKUP_DIR` | Backup directory | `/Users/accusys/momentry/backup/momentry` |
| `MOMENTRY_SCRIPTS_DIR` | Python scripts directory | `/Users/accusys/momentry_core_0.1/scripts` |
| `MOMENTRY_PYTHON_PATH` | Python interpreter path | `/opt/homebrew/bin/python3.11` |
| `MOMENTRY_MEDIA_BASE_URL` | Base URL for media serving | (none) |
### Processor Timeouts
| Variable | Description | Default |
|----------|-------------|---------|
| `MOMENTRY_ASR_TIMEOUT` | ASR timeout in seconds | `3600` |
| `MOMENTRY_CUT_TIMEOUT` | CUT timeout in seconds | `3600` |
| `MOMENTRY_DEFAULT_TIMEOUT` | Default timeout in seconds | `7200` |
### Logging
| Variable | Description | Default |
|----------|-------------|---------|
| `RUST_LOG` | Rust log level (tracing) | `info` |
| `MOMENTRY_LOG_LEVEL` | Fallback log level | `info` |
### Worker
| Variable | Description | Default |
|----------|-------------|---------|
| `MOMENTRY_WORKER_ENABLED` | Enable background worker | `true` |
| `MOMENTRY_MAX_CONCURRENT` | Max concurrent jobs | `6` |
| `MOMENTRY_POLL_INTERVAL` | Poll interval in seconds | `10` |
| `MOMENTRY_WORKER_BATCH_SIZE` | Batch size | `5` |
### Synonym Expansion
| Variable | Description | Default |
|----------|-------------|---------|
| `MOMENTRY_SYNONYM_FILES` | Comma-separated paths to synonym JSON files | (none) |
| `MOMENTRY_SYNONYM_FILE` | Single synonym file (deprecated) | (none) |
### Encryption
| Variable | Description | Default |
|----------|-------------|---------|
| `AUDIT_ENCRYPTION_KEY` | 32-byte hex encryption key (64 hex chars) | (none) |
## Port Registry
See `config/port_registry.tsv` for the authoritative list of all ports and their owners.
| Port | Service | Owner | Config Key |
|------|---------|-------|------------|
| 5432 | PostgreSQL | postgres | `DATABASE_URL` |
| 6379 | Redis | redis-server | `REDIS_URL` |
| 6333 | Qdrant | qdrant | `QDRANT_URL` |
| 8082 | LLM Chat (A4B) | llama-server | `MOMENTRY_LLM_CHAT_URL` |
| 8083 | LLM Vision (E4B) | llama-server | `MOMENTRY_LLM_VISION_URL` |
| 11434 | Ollama | ollama | `MOMENTRY_OLLAMA_URL` |
| 11436 | Embedding | embeddinggemma_server.py | `MOMENTRY_EMBED_URL` |
| 27017 | MongoDB | mongod | `MONGODB_URL` |
| 3002 | Production API | momentry | `MOMENTRY_SERVER_PORT` |
| 3003 | Playground API | momentry_playground | `MOMENTRY_SERVER_PORT` |
## Quick Start
```bash
# 複製模板
# 1. Copy template
cp .env.example .env
# 編輯配置
nano .env
# 2. Edit .env for production or use .env.development for playground
# 3. Start all services
./scripts/start_momentry.sh
```
### 2. 本地覆蓋
## Version Control
創建 `.env.local` 設置僅本地適用的配置:
```bash
# .env.local 示例
DATABASE_URL=postgres://local:password@localhost:5432/momentry_dev
MOMENTRY_LOG_LEVEL=debug
```
### 3. 運行應用
```bash
# 加載配置並運行
source .env && cargo run
# 或使用 direnv
direnv allow
```
## 版本控制策略
| 文件 | 版本控制 | 說明 |
|------|---------|------|
| `.env.example` | ✅ 追蹤 | 模板,包含所有選項 |
| `.env` | ❌ 忽略 | 本地敏感配置 |
| `.env.local` | ❌ 忽略 | 本地覆蓋配置 |
## 部署檢查清單
- [ ] 複製 `.env.example``.env`
- [ ] 設置數據庫連接
- [ ] 設置 Redis 密碼
- [ ] 配置目錄路徑
- [ ] 確認日誌級別
| File | Tracked | Purpose |
|------|---------|---------|
| `.env.example` | ✅ Yes | Template with all options documented |
| `.env` | ❌ No | Local sensitive config |
| `.env.development` | ❌ No | Dev-specific overrides |
| `.env.local` | ❌ No | Local overrides (highest priority) |

View File

@@ -16,7 +16,9 @@
6379 redis redis-server REDIS_URL redis://...:6379 start_momentry.sh
6333 qdrant qdrant QDRANT_URL http://...:6333 start_momentry.sh
8081 wordpress Caddy - - Caddyfile
8082 llm llama-server MOMENTRY_LLM_CHAT_URL http://...:8082 start_momentry.sh
8082 llm-chat llama-server MOMENTRY_LLM_CHAT_URL http://...:8082 start_momentry.sh
8083 llm-vision llama-server MOMENTRY_LLM_VISION_URL http://...:8083 start_momentry.sh
9000 php-fpm php-fpm - 9000 brew services
11434 ollama ollama MOMENTRY_OLLAMA_URL http://...:11434 start_momentry.sh
11436 embedding embeddinggemma MOMENTRY_EMBED_URL http://...:11436 start_momentry.sh
27017 mongodb mongod MONGODB_URL mongodb://...:27017 start_momentry.sh
1 # Port Registry - Momentry Core
16 6379
17 6333
18 8081
19 8082
20 8083
21 9000
22 11434
23 11436
24 27017

View File

@@ -0,0 +1,761 @@
# AGENTS.md - Momentry Core
Rust-based digital asset management system with video analysis and RAG capabilities.
---
## ⚠️ CRITICAL: 開發隔離原則
### 絕對禁止事項
- **絕對不可修改 `/Users/accusys/wordpress/` 目錄下的任何檔案**
- **絕對不可修改 n8n 工作流或設定**
- **絕對不可修改 WordPress 或 n8n 的資料庫 table**
- **除非是 release 作業,絕對不可動 port 3002 (production)**
- **🔴 DELETE / REMOVE / DROP / CLEAR 任何資料前必須先問使用者「要刪嗎?」獲得明確同意後才能執行**
- **🔴 Qdrant collection 刪除、DB truncate、檔案刪除、資料清空 — 一律要先問**
- **🔴 不確定是否該刪 → 先問,不要自己決定**
### 開發範圍界定
| 範圍 | 狀態 | 說明 |
|------|------|------|
| `momentry_core_0.1/` | ✅ **可開發** | Momentry Core 主要開發目錄 |
| `momentry_core_0.1/portal/` | ✅ **可開發** | Tauri Portal 前端 |
| `momentry_core_0.1/src/` | ✅ **可開發** | Rust 後端程式碼 |
| `/Users/accusys/wordpress/` | ❌ **禁止修改** | WordPress/Marcom 團隊負責 |
| n8n 工作流 | ❌ **禁止修改** | 自動化流程,與 dev 無關 |
| WordPress/n8n 資料庫 table | ❌ **禁止修改** | Marcom 團隊管理,與 dev 無關 |
### 開發環境
| 服務 | Port | 用途 | 命令 |
|------|------|------|------|
| Playground | 3003 | **唯一開發環境** | `cargo run --bin momentry_playground -- server` |
| Production | 3002 | ❌ 禁止修改 | `cargo run -- server` (僅 release 時) |
| Portal (Tauri) | 1420 | 前端開發 | `npm run tauri dev` |
## ⚠️ 交叉污染防制 (Cross-Contamination Prevention)
**每個執行前必須評估是否會汙染其他獨立作業。**
### Scope Isolation Matrix
| 執行內容 | 允許的 Scope | 禁止影響 | 檢查事項 |
|----------|-------------|----------|----------|
| M4 delivery binary | `target/release/momentry` | Playground (3003), Production (3002) | 確認舊 process 未被誤殺 |
| Playground server | `localhost:3003`, `dev.*` schema | Production (3002), `public.*` schema | `DATABASE_SCHEMA=dev` |
| Production deploy | `localhost:3002`, `public.*` schema | Playground (3003), `dev.*` schema | 先停 production不影響 playground |
| Git commit | 只包含意圖修改的檔案 | 無關的 untracked files | `git status` 確認 stage 內容正確 |
| CI / packaged tests | 測試環境 | 正式資料 | 測試用 DB 不能連到 production |
| Doc changes | 指定文件 | 其他文件、程式碼 | `git diff --stat` 檢查 scope |
| SQL migration | 目標 schema | 其他 schema、無關 table | `WHERE` clause 要精準 |
| `sed` / `grep` / mass edit | 目標檔案集 | 非目標檔案 | 先用 `grep -c` 確認只有目標檔案匹配 |
### Recent Violations / Near-Misses
| 事件 | 問題 | 防止方式 |
|------|------|----------|
| `sed` API doc 編號 | `sed -i '' 's/.../.../g'` 改到所有行 | 先 `grep -c` 確認匹配,`git diff` 再提交 |
| 亂加 `/api/v1/register` route | 不必要的 API 別名,汙染路由表 | 角色切換:路由設計不該由實作方決定 |
| `API_WORKSPACE/` vs `GUIDES/` vs `REFERENCE/` vs `DESIGN/` vs `OPERATIONS/` vs `INTEGRATIONS/` | 文件放到錯誤分類 | API 文件改在 API_WORKSPACE/modules/ 編輯,`make deploy` 生成到 GUIDES/ |
| Build release binary in plan mode | 浪費時間,無意義 | 嚴格遵守 plan/build mode 規定 |
### ⛔ 嚴格測試隔離規則 (Strict Test Isolation)
- **所有測試 (Test) 必須在 Dev (3003) 進行**。
- **絕對禁止 (ABSOLUTELY FORBIDDEN)** 在任何測試指令、Demo 流程或 API 檢查中使用 `localhost:3002`
- 即使是「測試 Unregister」或「檢查版本」若未明確標示為 "Production Deployment",一律視為違規。
- **預設行為**: 所有 curl, CLI, 或程式碼測試指令,預設 URL 必須為 `http://localhost:3003`
### 違反後果
- 修改 WordPress/n8n 可能影響 marcom 團隊工作與生產環境
- 修改 WordPress/n8n 資料庫 table 可能破壞自動化流程與資料完整性
- 修改 port 3002 可能中斷正在使用的服務 (這是非常嚴重的錯誤)
- 所有 dev 測試必須在 playground (3003) 進行
---
## AI Coding Principles (Karpathy-Inspired)
Behavioral guidelines to reduce common LLM coding mistakes.
Source: [andrej-karpathy-skills](https://github.com/forrestchang/andrej-karpathy-skills) (94K stars)
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
### 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
### 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
### 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
### 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" -> "Write tests for invalid inputs, then make them pass"
- "Fix the bug" -> "Write a test that reproduces it, then make it pass"
- "Refactor X" -> "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] -> verify: [check]
2. [Step] -> verify: [check]
3. [Step] -> verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
These guidelines are working if: fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
---
## Terminology (V4.0)
| Term | Scope | Description | Example |
|------|-------|-------------|---------|
| **file_uuid** | Video file | Video file identifier (renamed from `video_uuid`) | `384b0ff44aaaa1f1` |
| **identity_uuid** | Global identity | Global person identity (cross-file) | `a9a90105-6d6b-46ff-92da-0c3c1a57dff4` |
| **face_id** | Single detection | Single face detection (frame-level) | `face_100` |
| **trace_id** | Face tracking | Face tracking ID (Face Tracker output) | `2` |
| **chunk_id** | Sentence chunk | Sentence chunk (from pre_chunks via rules) | `chunk_1` |
| **speaker_id** | Speaker segment | Speaker ID (from ASRX) | `SPEAKER_0` |
| **person_id** | ❌ **Deprecated** | Video-local person ID (removed in V4.0) | - |
### Architecture (V4.0)
```
Face → Identity (Two-layer, direct binding)
person_identities table: REMOVED
file_identities table: ADDED (N:N relationship)
```
### Key Changes (V3.x → V4.0)
| Change | V3.x | V4.0 |
|--------|------|------|
| **video_uuid** | Used everywhere | **file_uuid** |
| **person_identities** | Required (303 records) | **Removed** |
| **person_id APIs** | 28 endpoints | **Removed** (except register/bind) |
| **Face binding** | Person → Identity | **Face → Identity** (direct) |
| **Chunk binding** | Manual | **Auto** (time alignment) |
---
## Build & Run Commands
```bash
# Build project (use debug builds for development/testing)
cargo build
cargo build --bin momentry
cargo build --bin momentry_playground
# Build all binaries
cargo build --bins
# Run CLI
cargo run -- --help
cargo run -- register /path/to/video.mp4
cargo run -- server --host 0.0.0.0 --port 3002
# Run playground (development binary)
cargo run --bin momentry_playground -- server
cargo run --bin momentry_playground -- --help
```
### ⚠️ CRITICAL: `cargo build --release` PROHIBITION
- **NEVER run `cargo build --release` unless the user explicitly says "release the binary" or "正式 release"**
- `cargo build --release` is SLOW and only needed when producing a production binary for deployment
- For all development, testing, debugging, and linting: use `cargo build` or `cargo check`
- If uncertain, ALWAYS ask the user first
## Binaries
| Binary | Purpose | Port | Redis Prefix | Environment |
|--------|---------|------|--------------|-------------|
| `momentry` | Production | 3002 | `momentry:` | `.env` |
| `momentry_playground` | Development | 3003 | `momentry_dev:` | `.env.development` |
| `momentry_player` | Video player | - | - | - |
## Testing
```bash
# Run all tests
cargo test
# Run single test by name
cargo test test_name
# Run with output
cargo test -- --nocapture
# Doc tests
cargo test --doc
```
## Linting & Formatting
```bash
# Format code (edition=2021, max_width=100, tab_spaces=4)
cargo fmt
cargo fmt -- --check
# Lint
cargo clippy
cargo clippy --all-features
# Check for errors
cargo check
cargo check --all-features
```
## Code Style
### General
- Use Rust 2021 edition
- Use tracing for logging (not println!)
- Keep lines under 100 characters
### Imports (order: std → external → local)
```rust
use std::path::Path;
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::core::chunk::Chunk;
```
### Error Handling
- Use `anyhow::Result<T>` for application code
- Use `thiserror` for library code
- Use `.context()` for error context
- Use `anyhow::bail!()` for early returns
```rust
fn example() -> Result<SomeType> {
let output = Command::new("ffprobe")
.args([...])
.output()
.context("Failed to run ffprobe")?;
if !output.status.success() {
anyhow::bail!("Command failed");
}
Ok(result)
}
```
### Naming
- Types/Enums: PascalCase (`VideoRecord`, `ChunkType`)
- Functions/Variables: snake_case (`get_video_by_uuid`)
- Traits: PascalCase with -er suffix (`Database`, `ChunkStore`)
- Files: snake_case (`postgres_db.rs`)
### Types
- Use `serde::{Deserialize, Serialize}` for serializable types
- Use `#[serde(rename_all = "snake_case")]` for enum variants
- Use explicit numeric types (i64, u32, f64)
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoRecord {
pub id: i64,
pub uuid: String,
pub duration: f64,
pub width: u32,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ChunkType {
TimeBased,
Sentence,
Cut,
}
```
### Async Programming
- Use `tokio` runtime with full features
- Use `#[async_trait]` for async trait methods
```rust
#[async_trait]
pub trait Database: Send + Sync {
async fn init() -> Result<Self>
where Self: Sized;
}
```
## Code Structure
```
src/
├── main.rs # CLI entry point
├── lib.rs # Library exports
├── core/
│ ├── api_key/ # API key management (anomaly, blacklist, encryption, etc.)
│ ├── chunk/ # Chunking logic
│ ├── config.rs # Centralized configuration (env vars)
│ ├── db/ # Database (PostgreSQL, MongoDB, Redis, Qdrant)
│ ├── embedding/ # Vector embeddings
│ ├── overlay/ # Video overlay
│ ├── probe/ # ffprobe integration
│ ├── processor/ # ASR, OCR, YOLO, Face, Pose, CUT, ASRX
│ │ └── executor.rs # Unified Python script executor
│ ├── storage/ # File management
│ └── thumbnail/ # Thumbnail extraction
├── api/ # HTTP API (axum)
├── player/ # Video player
├── ui/ # TUI components
└── watcher/ # File system watcher
```
## Key Dependencies
- **Error handling**: `anyhow`, `thiserror`
- **Async**: `tokio` (full features), `async-trait`
- **CLI**: `clap` (derive)
- **Serialization**: `serde`, `serde_json`, `chrono`
- **Database**: `sqlx`, `mongodb`, `redis` (1.0), `qdrant-client`
- **HTTP**: `axum`, `tower`
- **Logging**: `tracing`, `tracing-subscriber`
- **Config**: `once_cell` (lazy static config)
## Environment Variables
### Server
- `MOMENTRY_SERVER_PORT` - API server port (default: `3002` for production, `3003` for playground)
- `MOMENTRY_REDIS_PREFIX` - Redis key prefix (default: `momentry:` for production, `momentry_dev:` for playground)
- `MOMENTRY_API_KEY` - API key for Player online mode testing
### Testing API Key
```bash
export MOMENTRY_API_KEY="muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69"
# Test Player online mode
cargo run --features player --bin momentry_player -- -o
```
### Database
- `DATABASE_URL` - PostgreSQL (default: `postgres://accusys@localhost:5432/momentry`)
### Redis
- `REDIS_URL` - Redis URL (default: `redis://:accusys@localhost:6379`)
- `REDIS_PASSWORD` - Redis password (default: `accusys`)
### Paths
- `MOMENTRY_OUTPUT_DIR` - Output directory (default: `/Users/accusys/momentry/output`)
- `MOMENTRY_BACKUP_DIR` - Backup directory
- `MOMENTRY_PYTHON_PATH` - Python path (default: `/opt/homebrew/bin/python3.11`)
- `MOMENTRY_SCRIPTS_DIR` - Scripts directory
### Processor Timeouts
- `MOMENTRY_ASR_TIMEOUT` - ASR timeout in seconds (default: 3600)
- `MOMENTRY_CUT_TIMEOUT` - CUT timeout in seconds (default: 3600)
- `MOMENTRY_DEFAULT_TIMEOUT` - Default timeout (default: 7200)
### TMDb Integration (Face Clustering)
- `TMDB_API_KEY` - TMDb API key for movie metadata lookup (required for `MOMENTRY_TMDB_PROBE_ENABLED=true`)
- `MOMENTRY_TMDB_PROBE_ENABLED` - Enable TMDb probe during registration (default: `false`)
- Register phase: searches TMDb by filename, creates identities with tmdb_id/tmdb_profile
- Post-process phase: matches detected faces against TMDb identities via cosine similarity
### Synonym Expansion
- `MOMENTRY_SYNONYM_FILES` - Comma-separated paths to synonym JSON files (e.g., `data/english_synonyms.json,data/llm_synonyms.json`)
- `MOMENTRY_SYNONYM_FILE` - Single synonym JSON file path (deprecated, use above)
### Logging
- `RUST_LOG` or `MOMENTRY_LOG_LEVEL` - Log level (default: `info`)
## Notes
- Unit tests exist (86 library tests)
- Video processing uses external tools (ffprobe, Python scripts)
- Multi-database architecture (PostgreSQL, MongoDB, Redis, Qdrant)
- Monitor directory is a separate system (not Rust)
- PythonExecutor provides unified script execution with timeout support
- Redis 1.0.x for improved performance
- FaceNet CoreML model (`models/facenet512.mlpackage`) replaces InsightFace for embedding extraction (MIT license, ANE-accelerated)
### LLM Synonym Generation
Generate synonym database using llama.cpp (Gemma4):
```bash
# Generate full database (162 entries, ~5 minutes)
python3 scripts/generate_synonyms_llamacpp.py
# Quick test
python3 scripts/generate_synonyms_llamacpp.py --test
# Resume from existing file
python3 scripts/generate_synonyms_llamacpp.py --resume
# Output: data/llm_synonyms.json (27 Chinese + 135 English words)
```
## Task Management
### 使用 todowrite 追蹤任務
```bash
# 創建任務清單
/todo 建立配置模組 [in_progress]
/todo 添加單元測試 [pending]
# 更新狀態
/todo 完成標記 [completed]
```
### 任務批次建議
- 一次處理 1-2 個功能
- 每個功能完成後驗證 (clippy + test)
- 驗證通過後再繼續下一個
## Code Review Checklist
完成任務後檢查:
- [ ] `cargo clippy --lib` 通過
- [ ] `cargo test --lib` 通過
- [ ] `cargo fmt -- --check` 通過
- [ ] 文檔已更新 (如需要)
- [ ] 新功能有單元測試
## Commit Guidelines
```bash
# feat: 新功能
git commit -m "feat: add monitor_jobs table"
# fix: 錯誤修復
git commit -m "fix: resolve SQL injection in store_vector"
# refactor: 重構
git commit -m "refactor: use parameterized queries"
# docs: 文檔更新
git commit -m "docs: update AGENTS.md with new modules"
```
## Pre-commit Hook
專案已配置 `.git/hooks/pre-commit`,提交前自動檢查:
```bash
# 檢查內容
1. cargo fmt --check # Rust 格式化檢查
2. cargo clippy --lib # Rust Lint 檢查
3. cargo test --lib # Rust 單元測試
4. ruff check # Python Lint 檢查
5. ruff format --check # Python 格式化檢查
6. markdownlint # Markdown 格式檢查
7. shellcheck # Shell 腳本檢查
# 跳過檢查(不建議)
git commit --no-verify
# 跳過特定檢查
git commit --skip-checks
```
**注意**: Hook 僅檢查已暫存的 Rust/Python/Markdown 文件。
### Python 環境設置
```bash
# 安裝 ruff
pip install ruff==0.11.2
# 格式化 Python 文件
ruff format scripts/
# Lint Python 文件
ruff check scripts/
```
### Markdown 環境設置
```bash
# 安裝 markdownlint-cli (使用系統 Node.js)
npm install -g markdownlint-cli
# 檢查 Markdown 文件
markdownlint docs/
# 配置檔案
.markdownlint.json
```
### Shell 環境設置
```bash
# 安裝 shellcheck
brew install shellcheck
# 檢查 Shell 腳本
shellcheck scripts/*.sh monitor/**/*.sh
```
**注意**: Hook 只檢查 error 等級的 shellcheck 問題style 警告會顯示但不阻擋提交。
## Release Workflow
### Release 前準備
每次 release production binary 前,必須:
1. **建立 Release Tag**
```bash
git tag -a v0.X.X -m "Release vX.X.X - YYYY-MM-DD"
git push origin v0.X.X
```
2. **備份獨立 Source Code**
```bash
# 建立 release 獨立目錄
RELEASE_DIR="/Users/accusys/momentry_core_releases/v0.X.X"
mkdir -p "$RELEASE_DIR"
# 複製完整原始碼(排除不必要的檔案)
rsync -av --exclude='.git' --exclude='target' --exclude='node_modules' \
/Users/accusys/momentry_core_0.1/ "$RELEASE_DIR/"
# 記錄 release 資訊
echo "Release: v0.X.X" > "$RELEASE_DIR/RELEASE_INFO.txt"
echo "Date: $(date)" >> "$RELEASE_DIR/RELEASE_INFO.txt"
echo "Git Commit: $(git rev-parse HEAD)" >> "$RELEASE_DIR/RELEASE_INFO.txt"
echo "Binary: $(ls -la target/release/momentry)" >> "$RELEASE_DIR/RELEASE_INFO.txt"
```
3. **備份 Binary**
```bash
cp target/release/momentry "$RELEASE_DIR/momentry_v0.X.X"
cp target/release/momentry_playground "$RELEASE_DIR/momentry_playground_v0.X.X" 2>/dev/null
```
4. **記錄資料庫 Schema**
```bash
pg_dump -U accusys -d momentry --schema-only > "$RELEASE_DIR/schema_v0.X.X.sql"
```
### 重要性
- 避免 release binary 與 current source code 不一致
- 方便追蹤特定 release 的程式碼狀態
- 必要時可快速復原或比對差異
- 確保資料庫 schema 與程式碼版本對應
## Reference Documents
| 文件 | 用途 |
|------|------|
| `docs/OPENCODE_GUIDE.md` | OpenCode 使用規範 |
| `docs/ARCHITECTURE_EVALUATION.md` | 架構優化待評估項目 (含 GraphRAG) |
| `docs/PENDING_ISSUES.md` | 待解決問題追蹤 |
| `docs/MOMENTRY_CORE_MONITORING.md` | 監控系統規範 |
| `docs/MOMENTRY_CORE_REDIS_KEYS.md` | Redis Key 設計規範 |
| `docs/PYTHON.md` | Python 腳本規範 |
| `docs/FILE_CHANGE_MANAGEMENT.md` | 文件修改管理規範 |
| `docs/YOLO_RESUME_INTEGRATION.md` | YOLO Resume 功能整合記錄 |
| `docs/DOCUMENT_EMBEDDING_STRATEGY.md` | Parent-Child 嵌入策略 |
| `docs/PROCESSING_PIPELINE.md` | 處理流程文檔 |
| `docs/N8N_DEMO_WORKFLOW.md` | n8n 工作流文檔 |
| `docs/FRESH_MAC_INSTALLATION.md` | 全新 Mac 安裝指南 |
| `docs/SERVICES.md` | 服務總覽與管理 |
| `docs/SFTPGO_DEMO_USER.md` | SFTPGo 用戶指南 |
## Document Change Workflow
修改文件前請參考 `docs/FILE_CHANGE_MANAGEMENT.md`,確保:
1. **修改前**:完整閱讀文件、執行預檢清單
2. **修改中**:提供變更計畫、取得確認
3. **修改後**:展示 diff、更新版本歷史
4. **驗證**:執行 lint/test、提交前審查
### AI 工具修改規範
AI 工具修改文件時:
- 必須先完整閱讀文件(不可只讀取部分章節)
- 修改前先提出變更計畫供確認
- 修改後展示 diff 內容
- 更新版本歷史表
## PHP Development
WordPress 作為 Momentry Portal負責 n8n 自動化與 sftpgo 檔案服務的頁面整合。
### 編輯器設定
| 編輯器 | LSP 方案 | 安裝方式 |
|--------|----------|----------|
| VS Code | Intelephense | Extension Marketplace (推薦) |
| Cursor | Intelephense | Extension Marketplace (推薦) |
| CLI | phpactor | `~/bin/phpactor` |
### Intelephense (VS Code/Cursor)
1. 安裝 Extension: 搜尋 "Intelephense"
2. 設定:
```json
{
"intelephense.stubs": ["wordpress"]
}
```
### phpactor (CLI)
```bash
# 安裝方式
brew install composer
curl -sSL https://github.com/phpactor/phpactor/releases/latest/download/phpactor.phar -o ~/bin/phpactor
chmod +x ~/bin/phpactor
# 安裝 WordPress Stubs
cd /Users/accusys/wordpress/web
composer require --dev php-stubs/wordpress-stubs
# 建立 WordPress 索引
cd /Users/accusys/wordpress/web
~/bin/phpactor index:build --reset
# 常用指令
~/bin/phpactor class:search "WP_User" # 搜尋類別
~/bin/phpactor index:query WP_User # 查看類別資訊
~/bin/phpactor navigate /path/to/file.php # 導航到定義
```
### WordPress 程式碼位置
| 類型 | 路徑 |
|------|------|
| 主題 | `/Users/accusys/wordpress/web/wp-content/themes/` |
| 插件 | `/Users/accusys/wordpress/web/wp-content/plugins/` |
### 與 marcom 團隊協作
| 角色 | 負責 |
|------|------|
| marcom 團隊 | Figma 設計 / Elementor 建構 |
| OpenCode | 程式碼實作 / 重構 |
### 開發時程
```
Phase 1: marcom 建構 (現在) → Elementor 頁面建構
Phase 2: 交付審視 (TBD) → 功能確認 / 重構評估
Phase 3: OpenCode 重構 → 純程式碼實作,交付無 Elementor 依賴版本
```
## M4 通知規範
### 固定通知方式
通知 M4 的唯一管道:**`M4_workspace/` 下建立回覆文件 + `git commit`**。不需口頭、即時訊息、郵件。
### 命名規則
```
docs_v1.0/M4_workspace/YYYY-MM-DD_<topic>_response.md (回覆 M4 問題)
docs_v1.0/M4_workspace/YYYY-MM-DD_<topic>.md (主動通報)
docs_v1.0/M4_workspace/YYYY-MM-DD_<topic>_test_report.md (測試報告)
```
### 觸發時機
| 情境 | 動作 |
|------|------|
| M4 提交問題報告到 `M4_workspace/` | 修復後,回覆 `*_response.md` |
| 完成 M4 要求的任務 | 回覆 `*_response.md` |
| 重大變更(模型替換、架構變更) | 主動通知 `*.md` |
| 新測試包產出 | `*_test_report.md` |
### 交付檢查
1. 文件寫入 `docs_v1.0/M4_workspace/`
2. `git add` 包含該文件
3. `git commit` 含相關變更
4. M4 透過 git log 查看
詳細規範見 `docs_v1.0/M4_workspace/M4_NOTIFICATION_PROTOCOL.md`。
## UUID Naming Rule
**Never use bare `uuid` in API route paths, query params, JSON keys, or code variable names. Always qualify:**
| Context | Must use | Never |
|---------|----------|-------|
| Video/file resource | `file_uuid` | `uuid` |
| Identity resource | `identity_uuid` | `uuid` |
| Query parameter | `file_uuid=`, `identity_uuid=` | `uuid=` |
| Route path | `:file_uuid`, `:identity_uuid` | `:uuid` |
| JSON key | `"file_uuid"`, `"identity_uuid"` | `"uuid"` |
This applies to docs, code, API responses, and curl examples. Exceptions: internal database primary key names (e.g. `identities.uuid` column).
## Document Compliance Checklist
Before creating any file in `docs_v1.0/` (API_WORKSPACE, GUIDES, REFERENCE, DESIGN, OPERATIONS, INTEGRATIONS), verify all items below.
**IMPORTANT**: API functional documents are generated from `API_WORKSPACE/modules/`. Edit modules there, then run `make deploy` in `API_WORKSPACE/` to update `GUIDES/`. Never edit generated files in `GUIDES/` directly. See `DESIGN/Modular_Doc_System_V1.0.md` for the full system design.
### P0 — Mandatory (7 items)
| # | Check | Rule |
|---|-------|------|
| 1 | YAML frontmatter | `title`, `version`, `date`, `author`, `status` present |
| 2 | Version history | Table at bottom of file tracking changes |
| 3 | Top info table | scope, status, applicable to, etc. |
| 4 | PascalCase filename | e.g. `DetectorRegistry.md`, not `detector_registry.md` |
| 5 | `_` separator | Within filenames use `_`, never spaces or other chars |
| 6 | English content | Entire file in English |
| 7 | Correct directory | File must reside in appropriate directory: `API_WORKSPACE/modules/` (API endpoint modules), `GUIDES/` (user docs, generated), `REFERENCE/` (data models), `DESIGN/` (architecture), `OPERATIONS/` (infra/release), `INTEGRATIONS/` (n8n/tests) |
### P0b — UUID Naming
| # | Check | Rule |
|---|-------|------|
| 8 | `file_uuid` not bare `uuid` | All file references use `file_uuid` (see UUID Naming Rule above) |
| 9 | `identity_uuid` not bare `uuid` | All identity references use `identity_uuid` |
### P1 — Suggested (3 items)
| # | Check | Note |
|---|-------|------|
| 1 | Cross-references | Link to related docs in API_WORKSPACE/, GUIDES/, REFERENCE/, DESIGN/, OPERATIONS/ |
| 2 | Glossary terms | Define non-obvious terms inline or link glossary |
| 3 | Diagrams | Include Mermaid/ASCII diagram for complex topics |
### Exception
`M4_workspace/` files are exempt from this checklist (free-format reply documents).
---
## Delivery Procedure
完整交付程序M4_workspace → M5 → Release → Deploy → Public
`docs_v1.0/OPERATIONS/DELIVERY_PROCEDURE.md`

View File

@@ -0,0 +1,71 @@
# System Audit — 2026-05-17
## Current State
### Embedding Storage (三重冗余,無主)
| 資料類型 | PG pgvector | Qdrant | JSON 檔案 |
|---------|------------|--------|-----------|
| Sentence 向量 | `chunk.embedding` ✅ | `dev_v1` / `rule1_v2` / `sentence_*` ✅ | ❌ 無 |
| Story 向量 | `chunk.embedding` ✅ | `dev_v1` / `dev_stories` ✅ | `.story_llm.json` ✅ |
| Face 向量 | ❌ 已清除(依使用者指示) | `dev_faces` ✅ (97K) | `.face.json` ✅ |
| Voice 向量 | ❌ 無 | `dev_voice` ✅ (4K) | ❌ 無 |
### Pipeline 問題
| 問題 | 影響 |
|------|------|
| `processor_results.duration_secs` 全為 0 | 無法查各步驟耗時 |
| `processor_results.started_at/completed_at` 全 NULL | 時間線遺失 |
| Redis timing 在 job 完成後被清掉 | 唯一 timing 來源消失 |
| `get_chunk_by_chunk_id_and_uuid` 原本是 stub已修 | Smart search 找不到 PG chunk |
| `server.rs::search()` 未 mount 但仍編譯 | Dead code混淆 Qdrant 用途 |
| Face embedding 只寫 Qdrant 不寫 PG | 已刪除則全失 |
### Qdrant Collections 現況
| Collection | Points | 來源 | UUID |
|-----------|--------|------|------|
| `dev_v1` | 9,936 | PG rebuild | ✅ bd80fec... |
| `dev_faces` | 97,000 | face.json rebuild | ✅ bd80fec... |
| `dev_stories` | 560 | Snapshot | ✅ bd80fec... |
| `dev_voice` | 4,188 | Snapshot | ✅ bd80fec... |
| `dev_rule1_v2` | 3,417 | Snapshot | ✅ bd80fec... |
| `sentence_story` | 4,188 | Snapshot | ✅ bd80fec... |
| `sentence_summary` | 4,188 | Snapshot | ✅ bd80fec... |
## Safeguards & Fixes
### P0 — 必須修
| # | Fix | 做法 |
|---|-----|------|
| 1 | **Pipeline timing 寫入 DB** | `update_processor_result()` 加入 `started_at``completed_at``duration_secs` |
| 2 | **Qdrant 不當主要儲存** | Embedding 以 PG `chunk.embedding` 為 source of truthQdrant 唯讀 cache |
| 3 | **Smart search 只走 PG pgvector** | `search_parent_chunks_semantic` 已正確,無需 Qdrant |
| 4 | **移除 `server.rs::search()` dead code** | 或 mount 到正式 route 並確認可用 |
### P1 — 建議修
| # | Fix | 做法 |
|---|-----|------|
| 5 | **刪除 Qdrant 前先 snapshot** | 自動 snapshot script |
| 6 | **清理多餘 Qdrant collections** | `dev_voice` / `dev_stories` / `dev_rule1_v2` / `sentence_*` 無 server reader可移除 |
| 7 | **Face embedding 寫入 PG 或移除 dead code** | 目前 face Qdrant write 無人讀取,可移除 `sync_face_embeddings` |
| 8 | **UUID 一致性檢查** | 同一 content 不應產生不同 UUID |
### P2 — 可選
| # | Fix | 做法 |
|---|-----|------|
| 9 | `chunk_selector.rs` player binaryhardcode `momentry_rule1` | 改讀 env var 或 PG |
| 10 | AGENTS.md 已加入 delete 安全規則 | ✅ Done |
## Data Recovery Path
| 資料來源 | 可恢復到 | 方法 |
|---------|---------|------|
| `chunk.embedding` (PG) | Qdrant `dev_v1` | SQL → Qdrant upsert |
| `face.json` (磁碟) | Qdrant `dev_faces` | Python script |
| `story_llm.json` (磁碟) | Qdrant `dev_stories` | Python script |
| Qdrant snapshots (phase1) | Qdrant collections | Snapshot upload API |

View File

@@ -0,0 +1,388 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>01 Auth - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: auth -->
<!-- description: Authentication — login, logout, JWT, session cookie, API key -->
<!-- depends: -->
<h2>Base URL</h2>
<table class="table">
<thead>
<tr>
<th>Environment</th>
<th>URL</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td>Production</td>
<td><code>http://localhost:3002</code></td>
<td>Production deployment</td>
</tr>
<tr>
<td>External (M5)</td>
<td><code>https://m5api.momentry.ddns.net</code></td>
<td>Remote access</td>
</tr>
</tbody>
</table>
<h2>Variables</h2>
<p>All examples in this documentation use these environment variables:</p>
<div class="codehilite"><pre><span></span><code><span class="nv">API</span><span class="o">=</span><span class="s2">&quot;http://localhost:3002&quot;</span>
<span class="nv">KEY</span><span class="o">=</span><span class="s2">&quot;your-api-key-here&quot;</span>
</code></pre></div>
<h2>Authentication</h2>
<p>All endpoints under <code>/api/v1/*</code> require authentication.
The following endpoints are public (no auth needed):</p>
<ul>
<li><code>GET /health</code></li>
<li><code>POST /api/v1/auth/login</code></li>
<li><code>POST /api/v1/auth/logout</code></li>
</ul>
<h3>Three Authentication Modes</h3>
<p>The system supports three authentication methods, checked in <strong>priority order</strong> by the middleware:</p>
<div class="codehilite"><pre><span></span><code>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=)
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Mode</th>
<th>Transport</th>
<th>Expiry</th>
<th>Scope</th>
<th>Best for</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Session Cookie</strong></td>
<td><code>Cookie: session_id=&lt;session_id&gt;</code></td>
<td>24h</td>
<td>per-browser session</td>
<td>Portal (browser)</td>
</tr>
<tr>
<td><strong>JWT</strong></td>
<td><code>Authorization: Bearer &lt;token&gt;</code></td>
<td>1h</td>
<td>per-login token</td>
<td>API clients, CLI, scripts</td>
</tr>
<tr>
<td><strong>API Key</strong></td>
<td><code>X-API-Key: &lt;key&gt;</code></td>
<td>90d</td>
<td>fixed key for automation</td>
<td>Legacy scripts, WordPress</td>
</tr>
</tbody>
</table>
<hr />
<h3>Login</h3>
<p><strong>Default accounts &amp; API keys:</strong></p>
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Password</th>
<th>API Key</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>admin</code></td>
<td><code>admin</code></td>
<td></td>
<td>admin</td>
</tr>
<tr>
<td><code>demo</code></td>
<td><code>demo</code></td>
<td><code>muser_demo_key_32chars_abcdef1234567890</code></td>
<td>user</td>
</tr>
</tbody>
</table>
<p>The demo API key is set via <code>MOMENTRY_DEMO_API_KEY</code> env var and can be used in place of JWT for marcom integrations:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Using API key instead of JWT</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: muser_demo_key_32chars_abcdef1234567890&quot;</span>
</code></pre></div>
<div class="codehilite"><pre><span></span><code><span class="c1"># Login as admin</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/login&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;username&quot;: &quot;admin&quot;, &quot;password&quot;: &quot;admin&quot;}&#39;</span>
<span class="c1"># Login as demo user</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/login&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;username&quot;: &quot;demo&quot;, &quot;password&quot;: &quot;demo&quot;}&#39;</span>
</code></pre></div>
<h4>Success Response</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;jwt&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;eyJhbGciOiJIUzI1NiIs...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;api_key&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;muser_...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;user&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;username&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;admin&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;role&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;admin&quot;</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;expires_at&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-05-18T13:00:00Z&quot;</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>jwt</code></td>
<td>string</td>
<td>JWT access token. Use as <code>Authorization: Bearer &lt;jwt&gt;</code>. Expires in 1 hour.</td>
</tr>
<tr>
<td><code>api_key</code></td>
<td>string</td>
<td>Legacy API key. Use as <code>X-API-Key: &lt;key&gt;</code>. Good for 90 days.</td>
</tr>
<tr>
<td><code>user.username</code></td>
<td>string</td>
<td>Username</td>
</tr>
<tr>
<td><code>user.role</code></td>
<td>string</td>
<td>Role: <code>admin</code>, <code>user</code>, or <code>readonly</code></td>
</tr>
<tr>
<td><code>expires_at</code></td>
<td>string</td>
<td>ISO8601 timestamp of JWT expiration</td>
</tr>
</tbody>
</table>
<p>The login endpoint also sets a <code>Set-Cookie</code> header for browser-based clients:</p>
<div class="codehilite"><pre><span></span><code><span class="nt">Set-Cookie</span><span class="o">:</span><span class="w"> </span><span class="nt">session_id</span><span class="o">=&lt;</span><span class="nt">session_id</span><span class="o">&gt;;</span><span class="w"> </span><span class="nt">Path</span><span class="o">=/;</span><span class="w"> </span><span class="nt">HttpOnly</span><span class="o">;</span><span class="w"> </span><span class="nt">SameSite</span><span class="o">=</span><span class="nt">Strict</span><span class="o">;</span><span class="w"> </span><span class="nt">Max-Age</span><span class="o">=</span><span class="nt">86400</span>
</code></pre></div>
<h4>Error Response (401)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Invalid username or password&quot;</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h3>Using JWT</h3>
<p>JWT is preferred for API clients (CLI scripts, WordPress). It is validated by the middleware without a database lookup (stateless).</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Login and capture JWT</span>
<span class="nv">JWT</span><span class="o">=</span><span class="k">$(</span>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/login&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;username&quot;:&quot;admin&quot;,&quot;password&quot;:&quot;admin&quot;}&#39;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>python3<span class="w"> </span>-c<span class="w"> </span><span class="s2">&quot;import json,sys;print(json.load(sys.stdin)[&#39;jwt&#39;])&quot;</span><span class="k">)</span>
<span class="c1"># Use JWT for all subsequent requests</span>
curl<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan&quot;</span>
curl<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/resource/tmdb&quot;</span>
</code></pre></div>
<p>JWT is short-lived (1 hour). When it expires, request a new one via login.</p>
<hr />
<h3>Using Session Cookie (Browser)</h3>
<p>Browser-based clients (Portal) get a session cookie automatically after login. The browser sends the cookie with every request—no manual header needed.</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Login captures the session cookie from Set-Cookie header</span>
curl<span class="w"> </span>-v<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/login&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;username&quot;:&quot;admin&quot;,&quot;password&quot;:&quot;admin&quot;}&#39;</span><span class="w"> </span><span class="m">2</span>&gt;<span class="p">&amp;</span><span class="m">1</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span><span class="s2">&quot;Set-Cookie&quot;</span>
<span class="c1"># Browser automatically sends: Cookie: session_id=&lt;session_id&gt;</span>
<span class="c1"># No manual header needed for subsequent requests</span>
</code></pre></div>
<p>The session cookie is HttpOnly (not accessible from JavaScript) and SameSite=Strict (protected against CSRF).</p>
<hr />
<h3>Using Legacy API Key</h3>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan&quot;</span>
<span class="c1"># Also accepted via Bearer header (non-JWT format) or query parameter:</span>
curl<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan&quot;</span>
curl<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?api_key=</span><span class="nv">$KEY</span><span class="s2">&quot;</span>
</code></pre></div>
<p>API keys are validated via SHA256 hash lookup in the database. They are long-lived (90 days) and intended for automation.</p>
<h3>Obtaining an API Key (CLI)</h3>
<div class="codehilite"><pre><span></span><code>momentry<span class="w"> </span>api-key<span class="w"> </span>create<span class="w"> </span><span class="s2">&quot;My API Key&quot;</span><span class="w"> </span>--key-type<span class="w"> </span>user
</code></pre></div>
<hr />
<h3>Logout</h3>
<div class="codehilite"><pre><span></span><code><span class="c1"># Logout using the session cookie (browser)</span>
curl<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/logout&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Cookie: session_id=&lt;uuid&gt;&quot;</span>
</code></pre></div>
<h4>What logout does</h4>
<table class="table">
<thead>
<tr>
<th>Auth mode</th>
<th>Effect</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Session Cookie</strong></td>
<td>Session deleted from database. Same cookie returns 401 on subsequent requests.</td>
</tr>
<tr>
<td><strong>JWT</strong></td>
<td>JWT remains valid until expiry. (JWT is stateless — logout adds JWT to a blacklist only if API key mode is used.)</td>
</tr>
<tr>
<td><strong>API Key</strong></td>
<td>API key remains valid. (Legacy keys are shared across sessions — revoking would break other clients.)</td>
</tr>
</tbody>
</table>
<h4>Example: full session lifecycle</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># 1. Login</span>
<span class="nv">SESSION_ID</span><span class="o">=</span><span class="k">$(</span>curl<span class="w"> </span>-s<span class="w"> </span>-D<span class="w"> </span>-<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/login&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;username&quot;:&quot;admin&quot;,&quot;password&quot;:&quot;admin&quot;}&#39;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span><span class="s2">&quot;Set-Cookie&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>sed<span class="w"> </span><span class="s1">&#39;s/.*session_id=\([^;]*\).*/\1/&#39;</span><span class="k">)</span>
<span class="c1"># 2. Use session (works)</span>
curl<span class="w"> </span>-s<span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span>-w<span class="w"> </span><span class="s2">&quot;HTTP %{http_code}\n&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/resource/tmdb&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Cookie: session_id=</span><span class="nv">$SESSION_ID</span><span class="s2">&quot;</span>
<span class="c1"># → HTTP 200</span>
<span class="c1"># 3. Logout</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/logout&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Cookie: session_id=</span><span class="nv">$SESSION_ID</span><span class="s2">&quot;</span>
<span class="c1"># → {&quot;success&quot;: true}</span>
<span class="c1"># 4. Use session again (rejected)</span>
curl<span class="w"> </span>-s<span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span>-w<span class="w"> </span><span class="s2">&quot;HTTP %{http_code}\n&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/resource/tmdb&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Cookie: session_id=</span><span class="nv">$SESSION_ID</span><span class="s2">&quot;</span>
<span class="c1"># → HTTP 401</span>
</code></pre></div>
<hr />
<h3>Authentication Flow Summary</h3>
<div class="codehilite"><pre><span></span><code>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
└──────────────────┘
</code></pre></div>
<div class="codehilite"><pre><span></span><code>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 │
└──────────────────────┘
</code></pre></div>
<hr />
<h3>Error Responses</h3>
<table class="table">
<thead>
<tr>
<th>HTTP</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>401</code></td>
<td>Missing or invalid authentication</td>
</tr>
<tr>
<td><code>401</code></td>
<td>Session expired or logged out</td>
</tr>
<tr>
<td><code>401</code></td>
<td>JWT expired</td>
</tr>
<tr>
<td><code>401</code></td>
<td>API key revoked or inactive</td>
</tr>
</tbody>
</table>
<hr />
<h3>Related</h3>
<ul>
<li><code>POST /api/v1/resource/tmdb/check</code> — test authentication + TMDb API connectivity</li>
<li><code>GET /health/detailed</code> — view auth status (integrations section)</li>
</ul>
</div>
</body>
</html>

View File

@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>02 Health - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: health -->
<!-- description: Health check endpoints -->
<!-- depends: 01_auth -->
<h2>Health Check</h2>
<h3><code>GET /health</code></h3>
<p><strong>Auth</strong>: Public
<strong>Scope</strong>: system-level</p>
<p>Returns basic server health status — used by load balancers and monitoring.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/health&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{status, version}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;version&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;1.0.0&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;build_git_hash&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3a6c1865&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;build_timestamp&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-05-16T13:38:15Z&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;uptime_ms&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3015</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>status</code></td>
<td>string</td>
<td><code>ok</code> or <code>degraded</code></td>
</tr>
<tr>
<td><code>version</code></td>
<td>string</td>
<td>Semver version</td>
</tr>
<tr>
<td><code>build_git_hash</code></td>
<td>string</td>
<td>Git commit hash</td>
</tr>
<tr>
<td><code>build_timestamp</code></td>
<td>string</td>
<td>Binary build time</td>
</tr>
<tr>
<td><code>uptime_ms</code></td>
<td>integer</td>
<td>Milliseconds since server start</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>GET /health/detailed</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: system-level</p>
<p>Returns full system health including each service status, resource utilization, pipeline readiness, schema migration status, identity file sync status, and external integrations.</p>
<blockquote>
<p>Requires authentication (JWT, session cookie, or API key). The basic <code>/health</code> endpoint remains public for load balancer checks.</p>
</blockquote>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/health/detailed&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{status, services, resources: {cpu: .resources.cpu_used_percent, memory: .resources.memory_used_percent}}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;version&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;1.0.0&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;services&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;postgres&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;latency_ms&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;redis&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;latency_ms&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;qdrant&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;latency_ms&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">}</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;resources&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;cpu_used_percent&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">12.5</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;memory_available_mb&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">32768</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;memory_used_percent&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">31.7</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;pipeline&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;scripts_ready&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;scripts_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">345</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;processors&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;asr&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;yolo&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;face&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;pose&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;ocr&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;cut&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;scene&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;asrx&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;visual_chunk&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;models_ready&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;models_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">42</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;scripts_integrity&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;matched&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">332</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">345</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;ok&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;ffmpeg&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;schema&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;table_exists&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;applied&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="nt">&quot;filename&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;migrate_add_users_table.sql&quot;</span><span class="p">}],</span>
<span class="w"> </span><span class="nt">&quot;required&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[],</span>
<span class="w"> </span><span class="nt">&quot;ok&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;identities&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;directory_exists&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;files_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3481</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;index_ok&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;db_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3481</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;synced&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;integrations&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;tmdb&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;api_key_configured&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;enabled&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;api_reachable&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<h4>Response Fields</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>status</code></td>
<td>string</td>
<td><code>ok</code> if all essential services healthy</td>
</tr>
<tr>
<td><code>services</code></td>
<td>object</td>
<td>Per-service status (postgres, redis, qdrant)</td>
</tr>
<tr>
<td><code>services.*.status</code></td>
<td>string</td>
<td><code>ok</code>, <code>error</code>, or <code>degraded</code></td>
</tr>
<tr>
<td><code>services.*.latency_ms</code></td>
<td>int</td>
<td>Response time in milliseconds</td>
</tr>
<tr>
<td><code>resources</code></td>
<td>object</td>
<td>CPU, memory usage</td>
</tr>
<tr>
<td><code>pipeline.scripts_ready</code></td>
<td>boolean</td>
<td>Scripts directory accessible</td>
</tr>
<tr>
<td><code>pipeline.scripts_count</code></td>
<td>int</td>
<td>Number of Python processor scripts</td>
</tr>
<tr>
<td><code>pipeline.processors</code></td>
<td>object</td>
<td>Per-processor availability</td>
</tr>
<tr>
<td><code>pipeline.models_ready</code></td>
<td>boolean</td>
<td>Models directory accessible</td>
</tr>
<tr>
<td><code>pipeline.scripts_integrity</code></td>
<td>object</td>
<td>SHA256 checksum verification results</td>
</tr>
<tr>
<td><code>schema.ok</code></td>
<td>boolean</td>
<td>All required migrations applied</td>
</tr>
<tr>
<td><code>identities.synced</code></td>
<td>boolean</td>
<td>Identity file count matches DB count</td>
</tr>
<tr>
<td><code>integrations.tmdb</code></td>
<td>object</td>
<td>TMDB API key config and reachability</td>
</tr>
</tbody>
</table>
<h4>Health status rules</h4>
<table class="table">
<thead>
<tr>
<th>Condition</th>
<th>status</th>
</tr>
</thead>
<tbody>
<tr>
<td>All services ok</td>
<td><code>ok</code></td>
</tr>
<tr>
<td>Any service error</td>
<td><code>degraded</code></td>
</tr>
<tr>
<td>Postgres or Redis error</td>
<td><code>degraded</code> (server still responds)</td>
</tr>
</tbody>
</table>
<hr />
<h3>Stats Endpoints</h3>
<table class="table">
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/v1/stats/sftpgo</code></td>
<td>No</td>
<td>SFTPGo service status</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,444 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>03 Register - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: register -->
<!-- description: File registration — register, scan -->
<!-- depends: 01_auth -->
<h2>File Registration</h2>
<h3><code>POST /api/v1/files/register</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Register a video file for processing. Returns the file's metadata and UUID.</p>
<p><strong>New in v0.1.2</strong>: Registration now <strong>automatically triggers the processing pipeline</strong> — no need to call <code>POST /api/v1/file/:file_uuid/process</code> 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)</p>
<p>If the file already exists (same content hash), returns the existing record with <code>already_exists: true</code>.</p>
<h4>Request Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_path</code></td>
<td>string</td>
<td>Yes</td>
<td></td>
<td>Path to video file on disk</td>
</tr>
<tr>
<td><code>pattern</code></td>
<td>string</td>
<td>No</td>
<td></td>
<td>Regex pattern for batch register (requires <code>file_path</code> to be a directory)</td>
</tr>
<tr>
<td><code>user_id</code></td>
<td>integer</td>
<td>No</td>
<td></td>
<td>User ID to associate with registration</td>
</tr>
<tr>
<td><code>content_hash</code></td>
<td>string</td>
<td>No</td>
<td></td>
<td>Pre-computed SHA-256 hash (skips computation)</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># Register a single file</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/register&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_path&quot;: &quot;/path/to/video.mp4&quot;}&#39;</span>
<span class="c1"># Batch register files matching a pattern in a directory</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/register&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_path&quot;: &quot;/path/to/dir&quot;, &quot;pattern&quot;: &quot;.*\\.mp4$&quot;}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3a6c1865...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video.mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_path&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;/path/to/video.mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;duration&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">120.5</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;width&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1920</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;height&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1080</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;fps&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">24.0</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;total_frames&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">2892</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;already_exists&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;File registered successfully&quot;</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>success</code></td>
<td>boolean</td>
<td>Always true on 200</td>
</tr>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>32-char hex UUID of the registered file</td>
</tr>
<tr>
<td><code>file_name</code></td>
<td>string</td>
<td>File name (auto-renamed if name conflict)</td>
</tr>
<tr>
<td><code>file_path</code></td>
<td>string</td>
<td>Canonical path on disk</td>
</tr>
<tr>
<td><code>file_type</code></td>
<td>string</td>
<td><code>"video"</code>, <code>"audio"</code>, or <code>"unknown"</code></td>
</tr>
<tr>
<td><code>duration</code></td>
<td>float</td>
<td>Duration in seconds</td>
</tr>
<tr>
<td><code>width</code></td>
<td>integer</td>
<td>Video width in pixels</td>
</tr>
<tr>
<td><code>height</code></td>
<td>integer</td>
<td>Video height in pixels</td>
</tr>
<tr>
<td><code>fps</code></td>
<td>float</td>
<td>Frames per second</td>
</tr>
<tr>
<td><code>total_frames</code></td>
<td>integer</td>
<td>Total frame count</td>
</tr>
<tr>
<td><code>already_exists</code></td>
<td>boolean</td>
<td>True if same content was already registered</td>
</tr>
<tr>
<td><code>message</code></td>
<td>string</td>
<td>Human-readable status</td>
</tr>
</tbody>
</table>
<h4>Error Responses</h4>
<table class="table">
<thead>
<tr>
<th>HTTP</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>401</code></td>
<td>Missing or invalid API key</td>
</tr>
<tr>
<td><code>400</code></td>
<td>Invalid request body</td>
</tr>
<tr>
<td><code>404</code></td>
<td>File path does not exist</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>GET /api/v1/files/scan</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Scan the filesystem directory and list all media files, showing which are registered, processing, or unregistered.</p>
<h4>Query Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>page</code></td>
<td>integer</td>
<td>No</td>
<td>1</td>
<td>Page number (1-based)</td>
</tr>
<tr>
<td><code>page_size</code></td>
<td>integer</td>
<td>No</td>
<td>all</td>
<td>Items per page (alias: <code>limit</code>)</td>
</tr>
<tr>
<td><code>limit</code></td>
<td>integer</td>
<td>No</td>
<td>all</td>
<td>Max items (alias for <code>page_size</code>)</td>
</tr>
<tr>
<td><code>pattern</code></td>
<td>string</td>
<td>No</td>
<td></td>
<td>Regex filter on file name (e.g., <code>.*\\.mp4$</code>)</td>
</tr>
<tr>
<td><code>sort_by</code></td>
<td>string</td>
<td>No</td>
<td><code>name</code></td>
<td>Sort field: <code>name</code>, <code>size</code>, <code>modified</code>, <code>status</code></td>
</tr>
<tr>
<td><code>sort_order</code></td>
<td>string</td>
<td>No</td>
<td><code>asc</code></td>
<td>Sort direction: <code>asc</code> or <code>desc</code></td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># Full scan</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{total, registered_count, unregistered_count}&#39;</span>
<span class="c1"># Paginated (page 1, 5 per page)</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?page=1&amp;page_size=5&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{page, total_pages, files: [.files[].file_name]}&#39;</span>
<span class="c1"># Regex filter: only mp4 files</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?pattern=.*\\.mp4</span>$<span class="s2">&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{filtered_total, files: [.files[].file_name]}&#39;</span>
<span class="c1"># Sort by file size (largest first)</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?sort_by=size&amp;sort_order=desc&amp;page_size=5&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;[.files[] | {file_name, file_size}]&#39;</span>
<span class="c1"># Sort by modified time (most recent first)</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?sort_by=modified&amp;sort_order=desc&amp;page_size=5&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;[.files[] | {file_name, modified_time}]&#39;</span>
<span class="c1"># Sort by status</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?sort_by=status&amp;page_size=5&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;[.files[] | {file_name, status}]&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;files&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video.mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_size&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">12345678</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;is_registered&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3a6c1865...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;registration_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-05-16T12:00:00Z&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;job_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">42</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">107</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;filtered_total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">80</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;page&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;page_size&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;total_pages&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">4</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;registered_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">26</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;unregistered_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">81</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>files</code></td>
<td>array</td>
<td>Array of file info objects (paginated)</td>
</tr>
<tr>
<td><code>files[].file_name</code></td>
<td>string</td>
<td>File name</td>
</tr>
<tr>
<td><code>files[].relative_path</code></td>
<td>string</td>
<td>Path relative to scan root</td>
</tr>
<tr>
<td><code>files[].file_path</code></td>
<td>string</td>
<td>Absolute path on disk</td>
</tr>
<tr>
<td><code>files[].file_size</code></td>
<td>integer</td>
<td>File size in bytes</td>
</tr>
<tr>
<td><code>files[].modified_time</code></td>
<td>string</td>
<td>Last modified timestamp (ISO8601)</td>
</tr>
<tr>
<td><code>files[].is_registered</code></td>
<td>boolean</td>
<td>Whether file is registered in DB</td>
</tr>
<tr>
<td><code>files[].file_uuid</code></td>
<td>string</td>
<td>32-char hex UUID (only if registered)</td>
</tr>
<tr>
<td><code>files[].status</code></td>
<td>string</td>
<td><code>"completed"</code>, <code>"processing"</code>, <code>"registered"</code>, <code>"unregistered"</code>, or <code>null</code></td>
</tr>
<tr>
<td><code>files[].registration_time</code></td>
<td>string</td>
<td>DB registration timestamp (only if registered)</td>
</tr>
<tr>
<td><code>files[].job_id</code></td>
<td>integer</td>
<td>Processing job ID (only if a job exists)</td>
</tr>
<tr>
<td><code>total</code></td>
<td>integer</td>
<td>Total files found on disk (unfiltered)</td>
</tr>
<tr>
<td><code>filtered_total</code></td>
<td>integer</td>
<td>Files matching regex filter</td>
</tr>
<tr>
<td><code>page</code></td>
<td>integer</td>
<td>Current page number</td>
</tr>
<tr>
<td><code>page_size</code></td>
<td>integer</td>
<td>Items per page</td>
</tr>
<tr>
<td><code>total_pages</code></td>
<td>integer</td>
<td>Total pages</td>
</tr>
<tr>
<td><code>registered_count</code></td>
<td>integer</td>
<td>Files registered in DB</td>
</tr>
<tr>
<td><code>unregistered_count</code></td>
<td>integer</td>
<td>Files not yet registered</td>
</tr>
</tbody>
</table>
<h4>Notes</h4>
<table class="table">
<thead>
<tr>
<th>Feature</th>
<th>Behavior</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Regex</strong></td>
<td>Case-insensitive (<code>(?i)</code> prefix auto-applied). Applied to <code>file_name</code>.</td>
</tr>
<tr>
<td><strong>Sort order</strong></td>
<td>Default (<code>sort_by=name</code>): registered files first, then alphabetically. <code>sort_by=status</code>: alphabetical by status string.</td>
</tr>
<tr>
<td><strong>Pagination</strong></td>
<td><code>page_size</code> and <code>limit</code> are aliases. Default: show all results.</td>
</tr>
<tr>
<td><strong>Processing order</strong></td>
<td><code>pattern</code> regex filter → <code>sort_by</code>/<code>sort_order</code><code>page</code>/<code>page_size</code> slice.</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>04 Lookup - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: lookup -->
<!-- description: File lookup by name and unregistration -->
<!-- depends: 01_auth, 03_register -->
<h2>File Lookup</h2>
<h3><code>GET /api/v1/files/lookup</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Search registered files by file name. Performs a case-insensitive LIKE search on the file name column. Returns basic info about matching files.</p>
<h4>Query Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_name</code></td>
<td>string</td>
<td>Yes</td>
<td>File name to search for (partial matches supported)</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># Look up a specific file</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/lookup?file_name=video.mp4&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span>
<span class="c1"># Partial name search</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/lookup?file_name=charade&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;.matches[].file_name&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video.mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;exists&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;matches&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a03485a40b2df2d3&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video.mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;next_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video (2).mp4&quot;</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_name</code></td>
<td>string</td>
<td>Searched name</td>
</tr>
<tr>
<td><code>exists</code></td>
<td>boolean</td>
<td>Exact name match exists</td>
</tr>
<tr>
<td><code>matches</code></td>
<td>array</td>
<td>Array of matching registered files</td>
</tr>
<tr>
<td><code>matches[].file_uuid</code></td>
<td>string</td>
<td>32-char hex UUID</td>
</tr>
<tr>
<td><code>matches[].file_name</code></td>
<td>string</td>
<td>Registered file name</td>
</tr>
<tr>
<td><code>matches[].file_type</code></td>
<td>string</td>
<td><code>"video"</code>, <code>"audio"</code>, or <code>null</code></td>
</tr>
<tr>
<td><code>matches[].status</code></td>
<td>string</td>
<td>Registration/processing status</td>
</tr>
<tr>
<td><code>next_name</code></td>
<td>string</td>
<td>Suggested name for avoiding conflicts</td>
</tr>
</tbody>
</table>
<hr />
<h2>Unregister</h2>
<h3><code>POST /api/v1/unregister</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Delete a registered file from the system. Supports single file by UUID, or batch by directory + regex pattern.</p>
<h4>What gets deleted</h4>
<table class="table">
<thead>
<tr>
<th>Removed (default)</th>
<th>Not removed</th>
</tr>
</thead>
<tbody>
<tr>
<td>Database records (videos, chunks, embeddings, processor_results, pre_chunks)</td>
<td>The original source video file on disk</td>
</tr>
<tr>
<td>Processor output JSON files (<code>{uuid}.*.json</code>) — unless <code>delete_output_files: false</code></td>
<td>Temp/working directories</td>
</tr>
<tr>
<td>In-memory cache entries</td>
<td></td>
</tr>
<tr>
<td>MongoDB cached lists</td>
<td></td>
</tr>
</tbody>
</table>
<blockquote>
<p>⚠️ Database deletion is <strong>irreversible</strong>. To keep output files, set <code>"delete_output_files": false</code>.</p>
</blockquote>
<h4>Request Parameters</h4>
<p>At least one mode must be specified: either <code>file_uuid</code> alone, or <code>file_path</code> + <code>pattern</code> together.</p>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>*</td>
<td></td>
<td>Single file UUID to delete</td>
</tr>
<tr>
<td><code>file_path</code></td>
<td>string</td>
<td>*</td>
<td></td>
<td>Directory path (for batch delete)</td>
</tr>
<tr>
<td><code>pattern</code></td>
<td>string</td>
<td>*</td>
<td></td>
<td>Regex pattern (requires <code>file_path</code>)</td>
</tr>
<tr>
<td><code>delete_output_files</code></td>
<td>boolean</td>
<td>No</td>
<td><code>true</code></td>
<td>If <code>true</code>, also delete processor output JSON files (<code>{uuid}.*.json</code>). Set to <code>false</code> to keep them.</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># Delete a single file by UUID (default: also deletes output JSON files)</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/unregister&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_uuid&quot;: &quot;&#39;</span><span class="s2">&quot;</span><span class="nv">$FILE_UUID</span><span class="s2">&quot;</span><span class="s1">&#39;&quot;}&#39;</span>
<span class="c1"># Keep output JSON files, only delete DB records</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/unregister&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_uuid&quot;: &quot;&#39;</span><span class="s2">&quot;</span><span class="nv">$FILE_UUID</span><span class="s2">&quot;</span><span class="s1">&#39;&quot;, &quot;delete_output_files&quot;: false}&#39;</span>
<span class="c1"># Batch delete all mp4 files in a directory</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/unregister&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_path&quot;: &quot;/path/to/dir&quot;, &quot;pattern&quot;: &quot;.*\\.mp4$&quot;}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a03485a40b2df2d3&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Video unregistered successfully&quot;</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>success</code></td>
<td>boolean</td>
<td>True if deletion succeeded</td>
</tr>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>UUID of the deleted file (single mode)</td>
</tr>
<tr>
<td><code>message</code></td>
<td>string</td>
<td>Human-readable status</td>
</tr>
</tbody>
</table>
<h4>Error Responses</h4>
<table class="table">
<thead>
<tr>
<th>HTTP</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>400</code></td>
<td>Neither <code>file_uuid</code> nor <code>file_path</code>+<code>pattern</code> provided</td>
</tr>
<tr>
<td><code>404</code></td>
<td>File UUID not found</td>
</tr>
<tr>
<td><code>401</code></td>
<td>Missing or invalid API key</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,505 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>05 Process - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: process -->
<!-- description: Processing pipeline — trigger, probe, progress, jobs -->
<!-- depends: 01_auth, 03_register -->
<h2>Processing Pipeline</h2>
<h3><code>POST /api/v1/file/:file_uuid/process</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>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.</p>
<h4>Request Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>processors</code></td>
<td>string[]</td>
<td>No</td>
<td>all</td>
<td>Specific processors to run: <code>["cut","asr","asrx","yolo","ocr","face","pose","visual_chunk","story","5w1h"]</code></td>
</tr>
<tr>
<td><code>rules</code></td>
<td>string[]</td>
<td>No</td>
<td>all</td>
<td>Rule names to apply (currently unused)</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># Run all processors</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/process&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{}&#39;</span>
<span class="c1"># Run specific processors only</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/process&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;processors&quot;: [&quot;asr&quot;, &quot;face&quot;, &quot;yolo&quot;]}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;job_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">42</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3a6c1865...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;processing&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;pids&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mi">12345</span><span class="p">,</span><span class="w"> </span><span class="mi">12346</span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Processing triggered for video.mp4&quot;</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>success</code></td>
<td>boolean</td>
<td>Always true on 200</td>
</tr>
<tr>
<td><code>job_id</code></td>
<td>integer</td>
<td>Monitor job ID (for job tracking)</td>
</tr>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>32-char hex UUID of the file</td>
</tr>
<tr>
<td><code>status</code></td>
<td>string</td>
<td><code>"processing"</code></td>
</tr>
<tr>
<td><code>pids</code></td>
<td>integer[]</td>
<td>Process IDs of started processors</td>
</tr>
<tr>
<td><code>message</code></td>
<td>string</td>
<td>Human-readable status</td>
</tr>
</tbody>
</table>
<h4>Error Responses</h4>
<table class="table">
<thead>
<tr>
<th>HTTP</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>404</code></td>
<td>File UUID not found</td>
</tr>
<tr>
<td><code>401</code></td>
<td>Missing or invalid API key</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>GET /api/v1/file/:file_uuid/probe</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Get ffprobe metadata for a registered file. Returns video/audio stream info, codec details, duration, resolution, and frame rate.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/probe&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3a6c1865...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video.mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_size&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">794863677</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;duration&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">120.5</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;width&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1920</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;height&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1080</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;fps&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">24.0</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;total_frames&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">2892</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;cached&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;format&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;filename&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;/path/to/video.mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;format_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;mov,mp4,m4a,3gp&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;duration&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;120.5&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;size&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;12345678&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;bit_rate&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;819200&quot;</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;streams&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;index&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;codec_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;h264&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;codec_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;width&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1920</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;height&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1080</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;r_frame_rate&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;24/1&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;duration&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;120.5&quot;</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>32-char hex UUID</td>
</tr>
<tr>
<td><code>file_name</code></td>
<td>string</td>
<td>File name</td>
</tr>
<tr>
<td><code>file_size</code></td>
<td>integer</td>
<td>File size in bytes (from filesystem)</td>
</tr>
<tr>
<td><code>duration</code></td>
<td>float</td>
<td>Duration in seconds</td>
</tr>
<tr>
<td><code>width</code></td>
<td>integer</td>
<td>Video width in pixels</td>
</tr>
<tr>
<td><code>height</code></td>
<td>integer</td>
<td>Video height in pixels</td>
</tr>
<tr>
<td><code>fps</code></td>
<td>float</td>
<td>Frames per second</td>
</tr>
<tr>
<td><code>total_frames</code></td>
<td>integer</td>
<td>Estimated total frames</td>
</tr>
<tr>
<td><code>cached</code></td>
<td>boolean</td>
<td>True if result was from cached probe JSON</td>
</tr>
<tr>
<td><code>format</code></td>
<td>object</td>
<td>Container format info (ffprobe format section)</td>
</tr>
<tr>
<td><code>streams</code></td>
<td>array</td>
<td>Array of stream info objects</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>GET /api/v1/progress/:file_uuid</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Get real-time processing progress for a file via Redis pub/sub. Includes per-processor status, current/total frames, ETA, and system resource stats.</p>
<h4>Pipeline Order</h4>
<table class="table">
<thead>
<tr>
<th>Order</th>
<th>Processor</th>
<th>Dependencies</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><code>cut</code></td>
<td></td>
<td>Scene detection</td>
</tr>
<tr>
<td>2</td>
<td><code>asr</code></td>
<td>cut</td>
<td>Speech-to-text (per scene)</td>
</tr>
<tr>
<td>3</td>
<td><code>asrx</code></td>
<td>asr</td>
<td>Speaker diarization</td>
</tr>
<tr>
<td>4</td>
<td><code>yolo</code></td>
<td></td>
<td>Object detection</td>
</tr>
<tr>
<td>5</td>
<td><code>ocr</code></td>
<td></td>
<td>Text recognition</td>
</tr>
<tr>
<td>6</td>
<td><code>face</code></td>
<td></td>
<td>Face detection &amp; embedding</td>
</tr>
<tr>
<td>7</td>
<td><code>pose</code></td>
<td></td>
<td>Pose estimation</td>
</tr>
<tr>
<td>8</td>
<td><code>visual_chunk</code></td>
<td>yolo</td>
<td>Visual scene chunks</td>
</tr>
<tr>
<td>9</td>
<td><code>story</code></td>
<td>asr, asrx, cut, yolo, face</td>
<td>Scene summaries (template)</td>
</tr>
<tr>
<td>10</td>
<td><code>5w1h</code></td>
<td>story</td>
<td>5W1H analysis (Gemma4 LLM)</td>
</tr>
</tbody>
</table>
<p>All processors except <code>story</code> and <code>5w1h</code> run concurrently when their dependencies are met. Story and 5W1H run sequentially after their prerequisites.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/progress/</span><span class="nv">$FILE_UUID</span><span class="s2">&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{overall_progress, processors: [.processors[] | {processor_type, status}]}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3a6c1865...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;overall_progress&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">71</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;cpu_percent&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">45.2</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;gpu_percent&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">30.1</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;memory_percent&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">62.4</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;processors&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;processor_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;asr&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;complete&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;progress&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;processor_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;yolo&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;running&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;progress&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">65</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;processor_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;face&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;progress&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>32-char hex UUID</td>
</tr>
<tr>
<td><code>overall_progress</code></td>
<td>integer</td>
<td>Overall progress percentage (0100)</td>
</tr>
<tr>
<td><code>processors</code></td>
<td>array</td>
<td>Per-processor status list</td>
</tr>
<tr>
<td><code>processors[].processor_type</code></td>
<td>string</td>
<td>Processor name (<code>asr</code>, <code>cut</code>, <code>yolo</code>, etc.)</td>
</tr>
<tr>
<td><code>processors[].status</code></td>
<td>string</td>
<td><code>"pending"</code>, <code>"running"</code>, <code>"complete"</code>, or <code>"failed"</code></td>
</tr>
<tr>
<td><code>processors[].progress</code></td>
<td>integer</td>
<td>Per-processor progress (0100)</td>
</tr>
<tr>
<td><code>processors[].eta_seconds</code></td>
<td>integer</td>
<td>Estimated seconds remaining (running processors)</td>
</tr>
<tr>
<td><code>processors[].current</code></td>
<td>integer</td>
<td>Current frame count</td>
</tr>
<tr>
<td><code>processors[].total</code></td>
<td>integer</td>
<td>Total frame count</td>
</tr>
<tr>
<td><code>cpu_percent</code></td>
<td>float</td>
<td>Current CPU usage</td>
</tr>
<tr>
<td><code>gpu_percent</code></td>
<td>float</td>
<td>Current GPU utilization</td>
</tr>
<tr>
<td><code>memory_percent</code></td>
<td>float</td>
<td>Current memory usage</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>GET /api/v1/jobs</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: system-level</p>
<p>List all processing jobs (monitor jobs) in the system. Shows job status, which file each job is processing, and current processor info.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/jobs&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{count, jobs: [.jobs[] | {uuid, status}]}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;jobs&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">42</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3a6c1865...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;running&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;current_processor&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;yolo&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;created_at&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-05-16T12:00:00Z&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;started_at&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-05-16T12:01:00Z&quot;</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">15</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;page&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;page_size&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>jobs</code></td>
<td>array</td>
<td>Array of job info objects</td>
</tr>
<tr>
<td><code>jobs[].id</code></td>
<td>integer</td>
<td>Job ID</td>
</tr>
<tr>
<td><code>jobs[].uuid</code></td>
<td>string</td>
<td>File UUID being processed</td>
</tr>
<tr>
<td><code>jobs[].status</code></td>
<td>string</td>
<td><code>"pending"</code>, <code>"running"</code>, <code>"completed"</code>, <code>"failed"</code></td>
</tr>
<tr>
<td><code>jobs[].current_processor</code></td>
<td>string</td>
<td>Currently active processor, or null</td>
</tr>
<tr>
<td><code>count</code></td>
<td>integer</td>
<td>Total job count</td>
</tr>
<tr>
<td><code>page</code></td>
<td>integer</td>
<td>Current page number</td>
</tr>
<tr>
<td><code>page_size</code></td>
<td>integer</td>
<td>Jobs per page</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,280 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>06 Search - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: search -->
<!-- description: Vector search, BM25, smart search, universal search, visual search -->
<!-- depends: 01_auth -->
<h2>Search APIs</h2>
<h3><code>POST /api/v1/search/smart</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Semantic vector search using EmbeddingGemma-300m. Generates a query embedding via EmbeddingGemma (port 11436), then searches pgvector <code>story_parent</code> and <code>llm_parent</code> chunks by cosine similarity.</p>
<h4>Request Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>Yes</td>
<td></td>
<td>File UUID to search within</td>
</tr>
<tr>
<td><code>query</code></td>
<td>string</td>
<td>Yes</td>
<td></td>
<td>Search text</td>
</tr>
<tr>
<td><code>limit</code></td>
<td>integer</td>
<td>No</td>
<td>5</td>
<td>Max results to return</td>
</tr>
<tr>
<td><code>page</code></td>
<td>integer</td>
<td>No</td>
<td>1</td>
<td>Page number</td>
</tr>
<tr>
<td><code>page_size</code></td>
<td>integer</td>
<td>No</td>
<td>5</td>
<td>Items per page</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/search/smart&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_uuid&quot;: &quot;&#39;</span><span class="s2">&quot;</span><span class="nv">$FILE_UUID</span><span class="s2">&quot;</span><span class="s1">&#39;&quot;, &quot;query&quot;: &quot;Audrey Hepburn&quot;}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;query&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Audrey Hepburn&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;results&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;parent_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1087822</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;scene_order&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1087822</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start_frame&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">104438</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;end_frame&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">104538</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;fps&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">24.0</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">4351.6</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;end_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">4355.76</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;summary&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;[4352s-4356s, 4s] Cast: Audrey Hepburn. Total: 2 lines, 10 words. Speakers: Audrey Hepburn (2 lines)&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;similarity&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.67</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;page&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;page_size&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;strategy&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;semantic_vector_search&quot;</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h3><code>POST /api/v1/search/universal</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Multi-type BM25 full-text search across chunks, frames, and persons. Uses PostgreSQL <code>tsvector</code>.</p>
<h4>Request Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>query</code></td>
<td>string</td>
<td>Yes</td>
<td></td>
<td>Search text</td>
</tr>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>No</td>
<td></td>
<td>Restrict to specific file</td>
</tr>
<tr>
<td><code>types</code></td>
<td>string[]</td>
<td>No</td>
<td><code>["chunk","frame","person"]</code></td>
<td>Search types</td>
</tr>
<tr>
<td><code>limit</code></td>
<td>integer</td>
<td>No</td>
<td>10</td>
<td>Max results per type</td>
</tr>
<tr>
<td><code>page</code></td>
<td>integer</td>
<td>No</td>
<td>1</td>
<td>Page number</td>
</tr>
<tr>
<td><code>page_size</code></td>
<td>integer</td>
<td>No</td>
<td>20</td>
<td>Items per page</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/search/universal&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_uuid&quot;: &quot;&#39;</span><span class="s2">&quot;</span><span class="nv">$FILE_UUID</span><span class="s2">&quot;</span><span class="s1">&#39;&quot;, &quot;query&quot;: &quot;Cary Grant&quot;}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;results&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;chunk&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;chunk_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;bd80fec92b0b6963d177a2c55bf713e2_2&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;chunk_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;story_child&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start_frame&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5103</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;end_frame&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5127</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">212.64</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;end_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">213.64</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;text&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;[213s-214s] Cary Grant: \&quot;Olá!\&quot;&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;score&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.9</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;took_ms&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">18</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h3><code>POST /api/v1/search/frames</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Search face detection frames by identity name or trace ID.</p>
<hr />
<h3><code>POST /api/v1/search/identity_text</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Search text chunks spoken by a specific identity.</p>
<hr />
<h3>Visual Search</h3>
<table class="table">
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/v1/search/visual</code></td>
<td>Search visual chunks</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/v1/search/visual/class</code></td>
<td>Search by object class</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/v1/search/visual/density</code></td>
<td>Search by object density</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/v1/search/visual/combination</code></td>
<td>Search by object combination</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/v1/search/visual/stats</code></td>
<td>Visual chunk statistics</td>
</tr>
</tbody>
</table>
<h4>Embedding Model</h4>
<table class="table">
<thead>
<tr>
<th>Detail</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Model</strong></td>
<td>EmbeddingGemma-300m</td>
</tr>
<tr>
<td><strong>Endpoint</strong></td>
<td><code>POST /api/v1/embeddings</code> on port 11436</td>
</tr>
<tr>
<td><strong>Dimension</strong></td>
<td>768</td>
</tr>
<tr>
<td><strong>Storage</strong></td>
<td>pgvector (<code>chunk.embedding</code> column)</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,510 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>07 Identity - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: identity -->
<!-- description: Global identities — CRUD, detail, files, faces, bind, unbind, search -->
<!-- depends: 01_auth -->
<h2>Global Identities</h2>
<h3><code>GET /api/v1/identities</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>List all registered identities with pagination.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identities?page=1&amp;page_size=20&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{count, identities: [.identities[] | {name}]}&#39;</span>
</code></pre></div>
<hr />
<h3><code>GET /api/v1/identity/:identity_uuid</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Get detailed information for a specific identity, including metadata and TMDb references.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a9a901056d6b46ff92da0c3c1a57dff4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Cary Grant&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;people&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;source&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;tmdb&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;confirmed&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;tmdb_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">112</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;tmdb_profile&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;{output}/identities/{identity_uuid}/profile.jpg&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;metadata&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span>
<span class="w"> </span><span class="nt">&quot;reference_data&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span>
<span class="w"> </span><span class="nt">&quot;created_at&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-05-16T12:00:00Z&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;updated_at&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>identity_uuid</code></td>
<td>string</td>
<td>Identity identifier</td>
</tr>
<tr>
<td><code>name</code></td>
<td>string</td>
<td>Identity name</td>
</tr>
<tr>
<td><code>identity_type</code></td>
<td>string</td>
<td><code>"people"</code> or null</td>
</tr>
<tr>
<td><code>source</code></td>
<td>string</td>
<td><code>.json</code>, <code>auto</code>, <code>tmdb</code>, <code>user_defined</code>, or <code>merged</code></td>
</tr>
<tr>
<td><code>status</code></td>
<td>string</td>
<td><code>"confirmed"</code>, <code>"pending"</code>, or <code>"inactive"</code></td>
</tr>
<tr>
<td><code>tmdb_id</code></td>
<td>integer</td>
<td>TMDb person ID (only if source = tmdb)</td>
</tr>
<tr>
<td><code>tmdb_profile</code></td>
<td>string</td>
<td>Local profile image path (<code>{output}/identities/{uuid}/profile.jpg</code>)</td>
</tr>
<tr>
<td><code>metadata</code></td>
<td>object</td>
<td>Metadata JSON (tmdb_character, cast_order, etc.)</td>
</tr>
<tr>
<td><code>created_at</code></td>
<td>string</td>
<td>Creation timestamp</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>DELETE /api/v1/identity/:identity_uuid</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Delete an identity permanently.</p>
<hr />
<h3><code>GET /api/v1/identity/:identity_uuid/files</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Get all files where this identity appears. Returns per-file summary including face count, confidence, and appearance time range.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/files&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span>
</code></pre></div>
<hr />
<h3><code>GET /api/v1/identity/:identity_uuid/faces</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Get all face detection records associated with this identity.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/faces&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>File where face was detected</td>
</tr>
<tr>
<td><code>frame_number</code></td>
<td>integer</td>
<td>Frame number of detection</td>
</tr>
<tr>
<td><code>face_id</code></td>
<td>string</td>
<td>Face ID (format: <code>face_{frame_number}</code>)</td>
</tr>
<tr>
<td><code>confidence</code></td>
<td>float</td>
<td>Detection confidence</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>GET /api/v1/identity/:identity_uuid/chunks</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Get all text chunks (sentences) spoken while this identity's face was on screen. Useful for finding what a person said.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/chunks&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a9a901056d6b46ff92da0c3c1a57dff4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;data&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;bd80fec92b0b6963d177a2c55bf713e2&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;chunk_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;bd80fec92b0b6963d177a2c55bf713e2_2&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;chunk_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;sentence&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start_frame&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5103</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;end_frame&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5127</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;fps&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">24.0</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">212.64</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;end_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">213.64</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;text_content&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;[213s-214s] Cary Grant: \&quot;Olá!\&quot;&quot;</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>File identifier</td>
</tr>
<tr>
<td><code>chunk_id</code></td>
<td>string</td>
<td>Sentence chunk identifier</td>
</tr>
<tr>
<td><code>start_frame</code></td>
<td>integer</td>
<td>Frame-accurate start position</td>
</tr>
<tr>
<td><code>end_frame</code></td>
<td>integer</td>
<td>Frame-accurate end position</td>
</tr>
<tr>
<td><code>fps</code></td>
<td>float</td>
<td>Frames per second</td>
</tr>
<tr>
<td><code>start_time</code></td>
<td>float</td>
<td>Start time in seconds</td>
</tr>
<tr>
<td><code>end_time</code></td>
<td>float</td>
<td>End time in seconds</td>
</tr>
<tr>
<td><code>text_content</code></td>
<td>string</td>
<td>Spoken text content</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>POST /api/v1/identity/:identity_uuid/bind</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Bind a face detection to an identity. Associates the face trace with the identity for future search and recognition.</p>
<h4>Request Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>Yes</td>
<td>File where face is detected</td>
</tr>
<tr>
<td><code>face_id</code></td>
<td>string</td>
<td>Yes</td>
<td>Face ID (format: <code>{frame}_{idx}</code>)</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/bind&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_uuid&quot;: &quot;&#39;</span><span class="s2">&quot;</span><span class="nv">$FILE_UUID</span><span class="s2">&quot;</span><span class="s1">&#39;&quot;, &quot;face_id&quot;: &quot;1_5&quot;}&#39;</span>
</code></pre></div>
<hr />
<h3><code>POST /api/v1/identity/:identity_uuid/unbind</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Unbind a face detection from an identity. Removes the identity association from the face record.</p>
<hr />
<h3><code>GET /api/v1/identities/search</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Search identities by name (ILIKE search). Returns matching identity records.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identities/search?q=Cary&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>name</code></td>
<td>string</td>
<td>Identity name</td>
</tr>
<tr>
<td><code>source</code></td>
<td>string</td>
<td>Identity source</td>
</tr>
<tr>
<td><code>tmdb_id</code></td>
<td>integer</td>
<td>TMDb ID (if source = tmdb)</td>
</tr>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>Associated file</td>
</tr>
</tbody>
</table>
<hr />
<hr />
<h3><code>POST /api/v1/identity/upload</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Upload an identity.json file to create or update an identity. Accepts the same format as the identity.json files stored on disk.</p>
<p>If an identity with the same <code>name</code> already exists, it will be updated with the new values.</p>
<h4>Request</h4>
<p>The request body is an <code>IdentityFile</code> object:</p>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>identity_uuid</code></td>
<td>string</td>
<td>Yes</td>
<td>Identity identifier</td>
</tr>
<tr>
<td><code>name</code></td>
<td>string</td>
<td>Yes</td>
<td>Identity display name</td>
</tr>
<tr>
<td><code>identity_type</code></td>
<td>string</td>
<td>No</td>
<td><code>"people"</code> or null</td>
</tr>
<tr>
<td><code>source</code></td>
<td>string</td>
<td>No</td>
<td><code>.json</code>, <code>auto</code>, <code>tmdb</code>, <code>user_defined</code>, or <code>merged</code></td>
</tr>
<tr>
<td><code>status</code></td>
<td>string</td>
<td>No</td>
<td><code>"confirmed"</code>, <code>"pending"</code>, or <code>"inactive"</code></td>
</tr>
<tr>
<td><code>tmdb_id</code></td>
<td>integer</td>
<td>No</td>
<td>TMDb person ID</td>
</tr>
<tr>
<td><code>tmdb_profile</code></td>
<td>string</td>
<td>No</td>
<td>TMDb profile image URL</td>
</tr>
<tr>
<td><code>metadata</code></td>
<td>object</td>
<td>No</td>
<td>Arbitrary metadata JSON</td>
</tr>
<tr>
<td><code>file_bindings</code></td>
<td>array</td>
<td>No</td>
<td>Array of <code>{ file_uuid, trace_ids, face_count }</code> (informational)</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/upload&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{</span>
<span class="s1"> &quot;version&quot;: 1,</span>
<span class="s1"> &quot;identity_uuid&quot;: &quot;a9a901056d6b46ff92da0c3c1a57dff4&quot;,</span>
<span class="s1"> &quot;name&quot;: &quot;Cary Grant&quot;,</span>
<span class="s1"> &quot;identity_type&quot;: &quot;people&quot;,</span>
<span class="s1"> &quot;source&quot;: &quot;.json&quot;,</span>
<span class="s1"> &quot;status&quot;: &quot;confirmed&quot;,</span>
<span class="s1"> &quot;metadata&quot;: {},</span>
<span class="s1"> &quot;file_bindings&quot;: []</span>
<span class="s1"> }&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a9a901056d6b46ff92da0c3c1a57dff4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Cary Grant&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Identity uploaded successfully&quot;</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<hr />
<h3><code>POST /api/v1/identity/:identity_uuid/profile-image</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Upload a profile image (JPEG or PNG) for an identity. The image is saved to <code>{output}/identities/{uuid}/profile.{ext}</code>.</p>
<p>Uses <code>multipart/form-data</code> with field name <code>image</code>.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/profile-image&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-F<span class="w"> </span><span class="s2">&quot;image=@/path/to/photo.jpg&quot;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a9a901056d6b46ff92da0c3c1a57dff4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;path&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;/path/to/output/identities/.../profile.jpg&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Profile image saved: profile.jpg&quot;</span>
<span class="p">}</span>
</code></pre></div>
<h4>Error Responses</h4>
<table class="table">
<thead>
<tr>
<th>HTTP</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>400</code></td>
<td>Missing image field or unsupported format</td>
</tr>
<tr>
<td><code>404</code></td>
<td>Identity not found</td>
</tr>
<tr>
<td><code>415</code></td>
<td>Unsupported image type (use JPEG or PNG)</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>GET /api/v1/identity/:identity_uuid/profile-image</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Retrieve the profile image for an identity. Returns the raw image data with appropriate Content-Type header.</p>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/profile-image&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span>-o<span class="w"> </span>profile.jpg
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Response Header</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>content-type</code></td>
<td><code>image/jpeg</code> or <code>image/png</code></td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>08 Identity Agent - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: identity_agent -->
<!-- description: Identity agent — match from photo, match from trace -->
<!-- depends: 01_auth, 07_identity -->
<h2>Identity Agent</h2>
<h3><code>POST /api/v1/agents/identity/match-from-photo</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>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.</p>
<h4>Request</h4>
<p><code>multipart/form-data</code> with field <code>image</code> (JPEG/PNG) and optional <code>file_uuid</code>.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/agents/identity/match-from-photo&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-F<span class="w"> </span><span class="s2">&quot;image=@/path/to/face.jpg&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-F<span class="w"> </span><span class="s2">&quot;file_uuid=</span><span class="nv">$FILE_UUID</span><span class="s2">&quot;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;matches&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a9a90105...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Cary Grant&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;similarity&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.87</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h3><code>POST /api/v1/agents/identity/match-from-trace</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Match a face trace (tracked face across frames) against known identities. Samples 3 angles from the trace, generates embeddings, and searches pgvector.</p>
<h4>Request Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>Yes</td>
<td>File containing the trace</td>
</tr>
<tr>
<td><code>trace_id</code></td>
<td>integer</td>
<td>Yes</td>
<td>Face trace ID to match</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/agents/identity/match-from-trace&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_uuid&quot;: &quot;&#39;</span><span class="s2">&quot;</span><span class="nv">$FILE_UUID</span><span class="s2">&quot;</span><span class="s1">&#39;&quot;, &quot;trace_id&quot;: 10}&#39;</span>
</code></pre></div>
</div>
</body>
</html>

View File

@@ -0,0 +1,303 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>08 Media - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: media -->
<!-- description: Video streaming & frame extraction -->
<!-- depends: 01_auth -->
<h2>Video Streaming &amp; Frame Extraction</h2>
<p>All video streaming endpoints support the following common query parameters:</p>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>mode</code></td>
<td>string</td>
<td>No</td>
<td><code>normal</code></td>
<td><code>normal</code> or <code>debug</code> (draws detection overlays)</td>
</tr>
<tr>
<td><code>audio</code></td>
<td>string</td>
<td>No</td>
<td><code>on</code></td>
<td><code>on</code> or <code>off</code></td>
</tr>
</tbody>
</table>
<hr />
<h3><code>GET /api/v1/file/:file_uuid/video</code></h3>
<p>Stream the full video file with range support for seeking.</p>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<h4>Response</h4>
<ul>
<li><strong>200</strong>: Video stream (<code>Content-Type</code> based on file extension)</li>
<li><strong>206</strong>: Partial content (range request)</li>
<li>Supports <code>Range</code> header for seeking</li>
</ul>
<hr />
<h3><code>GET /api/v1/file/:file_uuid/trace/:trace_id/video</code></h3>
<p>Stream video with highlights for a specific face trace (follows a single person across frames with bounding box overlay).</p>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<hr />
<h3><code>GET /api/v1/file/:file_uuid/video/bbox</code></h3>
<p>Stream video with bounding box overlay for all detected objects/faces.</p>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Uses a built-in 5×7 bitmap font renderer to draw labels directly on video frames via FFmpeg <code>drawtext</code> filter.</p>
<hr />
<h3><code>GET /api/v1/file/:file_uuid/thumbnail</code></h3>
<p>Extract a single frame from a video as JPEG image. Uses FFmpeg <code>select</code> filter.</p>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<h4>Query Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>frame</code></td>
<td>integer</td>
<td>Yes</td>
<td></td>
<td>Zero-based frame number to extract</td>
</tr>
<tr>
<td><code>x</code></td>
<td>integer</td>
<td>No</td>
<td></td>
<td>Crop start X (left edge). Requires <code>y</code>, <code>w</code>, <code>h</code>.</td>
</tr>
<tr>
<td><code>y</code></td>
<td>integer</td>
<td>No</td>
<td></td>
<td>Crop start Y (top edge). Requires <code>x</code>, <code>w</code>, <code>h</code>.</td>
</tr>
<tr>
<td><code>w</code></td>
<td>integer</td>
<td>No</td>
<td></td>
<td>Crop width in pixels. Requires <code>x</code>, <code>y</code>, <code>h</code>.</td>
</tr>
<tr>
<td><code>h</code></td>
<td>integer</td>
<td>No</td>
<td></td>
<td>Crop height in pixels. Requires <code>x</code>, <code>y</code>, <code>w</code>.</td>
</tr>
</tbody>
</table>
<p>All four crop params (<code>x</code>, <code>y</code>, <code>w</code>, <code>h</code>) must be provided together or omitted.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># Extract frame 1000 (full frame)</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/thumbnail?frame=1000&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span>-o<span class="w"> </span>frame_1000.jpg
<span class="c1"># Extract and crop face region (x=320, y=240, w=160, h=160)</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/thumbnail?frame=1000&amp;x=320&amp;y=240&amp;w=160&amp;h=160&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span>-o<span class="w"> </span>face_crop.jpg
</code></pre></div>
<h4>Response</h4>
<ul>
<li><strong>200</strong>: <code>image/jpeg</code> binary data</li>
<li><strong>404</strong>: File not found</li>
<li><strong>500</strong>: FFmpeg error (e.g., frame number exceeds video duration)</li>
</ul>
<h3><code>GET /api/v1/file/:file_uuid/clip</code></h3>
<p>Extract a video clip (time range) as MPEG-TS stream. Uses FFmpeg <code>-ss</code> fast seek.</p>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<h4>Query Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>start_frame</code></td>
<td>integer</td>
<td>No*</td>
<td></td>
<td>Start frame (zero-based). <strong>Frame-accurate</strong> — use this for precision.</td>
</tr>
<tr>
<td><code>end_frame</code></td>
<td>integer</td>
<td>No*</td>
<td></td>
<td>End frame (zero-based, inclusive). Requires <code>start_frame</code>.</td>
</tr>
<tr>
<td><code>start_time</code></td>
<td>float</td>
<td>No*</td>
<td></td>
<td>Start time in seconds. Approximate (FPS-dependent). Fallback if frames not given.</td>
</tr>
<tr>
<td><code>end_time</code></td>
<td>float</td>
<td>No*</td>
<td></td>
<td>End time in seconds. Approximate (FPS-dependent). Fallback if frames not given.</td>
</tr>
<tr>
<td><code>fps</code></td>
<td>float</td>
<td>No</td>
<td>video FPS</td>
<td>Override frames-per-second for frame↔time calculation. Defaults to video's detected FPS.</td>
</tr>
<tr>
<td><code>mode</code></td>
<td>string</td>
<td>No</td>
<td><code>normal</code></td>
<td><code>normal</code> or <code>debug</code> (draws "CLIP" overlay)</td>
</tr>
<tr>
<td><code>audio</code></td>
<td>string</td>
<td>No</td>
<td><code>on</code></td>
<td><code>on</code> or <code>off</code></td>
</tr>
</tbody>
</table>
<p>Either (<code>start_frame</code>+<code>end_frame</code>) OR (<code>start_time</code>+<code>end_time</code>) must be provided.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># Clip by frame range (primary)</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/clip?start_frame=0&amp;end_frame=47&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span>-o<span class="w"> </span>clip.ts
<span class="c1"># Clip by time range (fallback)</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/clip?start_time=30&amp;end_time=45&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span>-o<span class="w"> </span>clip.ts
</code></pre></div>
<h4>Response</h4>
<ul>
<li><strong>200</strong>: <code>video/mp2t</code> MPEG-TS stream</li>
<li><strong>400</strong>: Missing/invalid range parameters</li>
<li><strong>404</strong>: File not found</li>
<li><strong>500</strong>: FFmpeg error</li>
</ul>
<h4>Technical Notes</h4>
<table class="table">
<thead>
<tr>
<th>Detail</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Backend</strong></td>
<td>FFmpeg (<code>ffmpeg-full</code>)</td>
</tr>
<tr>
<td><strong>Seek</strong></td>
<td><code>-ss</code> before <code>-i</code> (fast keyframe seek)</td>
</tr>
<tr>
<td><strong>Format</strong></td>
<td>MPEG-TS (<code>mpegts</code> muxer, pipe-safe)</td>
</tr>
<tr>
<td><strong>Codec</strong></td>
<td>H.264 + AAC</td>
</tr>
<tr>
<td><strong>Cache</strong></td>
<td><code>Cache-Control: public, max-age=86400</code> (24h)</td>
</tr>
</tbody>
</table>
<hr />
<table class="table">
<thead>
<tr>
<th>Detail</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Backend</strong></td>
<td>FFmpeg (<code>ffmpeg-full</code>)</td>
</tr>
<tr>
<td><strong>Filter</strong></td>
<td><code>select=eq(n\,FRAME)</code> to select frame, optional <code>crop=W:H:X:Y</code></td>
</tr>
<tr>
<td><strong>Output</strong></td>
<td>Single JPEG via pipe (<code>image2pipe</code>, <code>mjpeg</code> codec)</td>
</tr>
<tr>
<td><strong>Cache</strong></td>
<td><code>Cache-Control: public, max-age=86400</code> (24h)</td>
</tr>
<tr>
<td><strong>Frame number</strong></td>
<td>Zero-based (<code>frame=0</code> = first frame of video)</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>09 Tmdb - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: tmdb -->
<!-- description: TMDb enrichment endpoints — prefetch, probe, resource, check -->
<!-- depends: 01_auth, 03_register -->
<h2>TMDb Enrichment</h2>
<blockquote>
<p><strong>Offline operation</strong>: TMDb prefetch now checks local identity files first (<code>identities/_index.json</code> + <code>*.tmdb.json</code>).
If local files exist, no external API call is made. Internet is only needed for initial data seeding.</p>
</blockquote>
<h3>Overview</h3>
<p>TMDb enrichment is an optional identity enrichment step that can be run after Pipeline face detection completes. The workflow is:</p>
<ol>
<li><strong>Prefetch</strong> (requires internet): Download movie cast data from TMDb API → cache to <code>{file_uuid}.tmdb.json</code></li>
<li><strong>Probe</strong>: Read local cache → create identities for <strong>all</strong> cast members (<code>source='tmdb'</code>) + save <code>identity.json</code> + download profile image to <code>{OUTPUT}/identities/{uuid}/profile.jpg</code></li>
<li><strong>Match</strong>: The worker automatically matches video faces against TMDb identities when <code>MOMENTRY_TMDB_PROBE_ENABLED=true</code></li>
</ol>
<h3><code>POST /api/v1/agents/tmdb/prefetch</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Fetch TMDb cast data for a registered file and cache it locally. This is the only step requiring internet access.</p>
<h4>Request Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>Yes</td>
<td>File UUID to enrich</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/agents/tmdb/prefetch&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_uuid&quot;: &quot;&#39;</span><span class="s2">&quot;</span><span class="nv">$FILE_UUID</span><span class="s2">&quot;</span><span class="s1">&#39;&quot;}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;...&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;cache_path&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;/output/...tmdb.json&quot;</span><span class="p">}</span>
</code></pre></div>
<h3><code>POST /api/v1/file/:file_uuid/tmdb-probe</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Read local TMDb cache and create/update identities. Requires prefetch to have been run first.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/tmdb-probe&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{identities_created, movie_title}&#39;</span>
</code></pre></div>
<h4>Response (200 — identities created)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;identities_created&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">15</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;movie_title&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Charade&quot;</span><span class="p">}</span>
</code></pre></div>
<h4>Response (200 — no cache)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;No TMDb cache found. Run tmdb-prefetch first.&quot;</span><span class="p">}</span>
</code></pre></div>
<h3><code>GET /api/v1/resource/tmdb</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: system-level</p>
<p>View TMDb resource status including configuration, identity counts, and cache file count.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/resource/tmdb&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{identities_seeded, cache_files}&#39;</span>
</code></pre></div>
<h3><code>POST /api/v1/resource/tmdb/check</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: system-level</p>
<p>Ping the TMDb API to verify connectivity and measure latency.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/resource/tmdb/check&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;.status&#39;</span>
</code></pre></div>
<h4>Response</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;api_key_configured&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;enabled&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;api_reachable&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;api_latency_ms&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">120</span>
<span class="p">}</span>
</code></pre></div>
</div>
</body>
</html>

View File

@@ -0,0 +1,364 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>10 Pipeline - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: pipeline -->
<!-- description: Pipeline processors, ingestion status, stats endpoints -->
<!-- depends: 01_auth -->
<h2>Pipeline</h2>
<h3>Dependency Graph</h3>
<div class="codehilite"><pre><span></span><code><span class="n">flowchart</span><span class="w"> </span><span class="n">TB</span>
<span class="w"> </span><span class="n">subgraph</span><span class="w"> </span><span class="n">Processors</span><span class="p">[</span><span class="s">&quot;10 Processors&quot;</span><span class="p">]</span>
<span class="w"> </span><span class="n">Cut</span><span class="p">[</span><span class="n">Cut</span><span class="p">]</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">ASR</span><span class="p">[</span><span class="n">ASR</span><span class="p">]</span>
<span class="w"> </span><span class="n">ASR</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">ASRX</span><span class="p">[</span><span class="n">ASRX</span><span class="p">]</span>
<span class="w"> </span><span class="n">ASRX</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Story</span><span class="p">[</span><span class="n">Story</span><span class="p">]</span>
<span class="w"> </span><span class="n">Cut</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Story</span>
<span class="w"> </span><span class="n">YOLO</span><span class="p">[</span><span class="n">YOLO</span><span class="p">]</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">VisualChunk</span><span class="p">[</span><span class="n">VisualChunk</span><span class="p">]</span>
<span class="w"> </span><span class="n">VisualChunk</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Story</span>
<span class="w"> </span><span class="n">Face</span><span class="p">[</span><span class="n">Face</span><span class="p">]</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Story</span>
<span class="w"> </span><span class="n">Story</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">FiveW1H</span><span class="p">[</span><span class="mi">5</span><span class="n">W1H</span><span class="p">]</span>
<span class="w"> </span><span class="n">OCR</span><span class="p">[</span><span class="n">OCR</span><span class="p">]</span>
<span class="w"> </span><span class="n">Pose</span><span class="p">[</span><span class="n">Pose</span><span class="p">]</span>
<span class="w"> </span><span class="n">end</span>
<span class="w"> </span><span class="n">subgraph</span><span class="w"> </span><span class="n">Ingestion</span><span class="p">[</span><span class="s">&quot;入庫 (Post-Processing)&quot;</span><span class="p">]</span>
<span class="w"> </span><span class="n">ASR</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Rule1</span><span class="p">[</span><span class="n">Rule</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="n">Sentence</span><span class="p">]</span>
<span class="w"> </span><span class="n">ASRX</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Rule1</span>
<span class="w"> </span><span class="n">Rule1</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Vectorize</span><span class="p">[</span><span class="n">Auto</span><span class="o">-</span><span class="n">Vectorize</span><span class="p">]</span>
<span class="w"> </span><span class="n">Rule1</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Phase1</span><span class="p">[</span><span class="n">Phase</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="n">Pack</span><span class="p">]</span>
<span class="w"> </span><span class="n">Cut</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Rule3</span><span class="p">[</span><span class="n">Rule</span><span class="w"> </span><span class="mi">3</span><span class="w"> </span><span class="n">Scene</span><span class="p">]</span>
<span class="w"> </span><span class="n">ASR</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Rule3</span>
<span class="w"> </span><span class="n">Face</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Trace</span><span class="p">[</span><span class="n">Face</span><span class="w"> </span><span class="n">Trace</span><span class="p">]</span>
<span class="w"> </span><span class="n">Trace</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Qdrant</span><span class="p">[</span><span class="n">Qdrant</span><span class="w"> </span><span class="n">Sync</span><span class="p">]</span>
<span class="w"> </span><span class="n">Trace</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">TraceChunks</span><span class="p">[</span><span class="n">Trace</span><span class="w"> </span><span class="n">Chunks</span><span class="p">]</span>
<span class="w"> </span><span class="n">Trace</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">TKG</span><span class="p">[</span><span class="n">TKG</span><span class="w"> </span><span class="n">Builder</span><span class="p">]</span>
<span class="w"> </span><span class="n">Face</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">TMDbMatch</span><span class="p">[</span><span class="n">TMDb</span><span class="w"> </span><span class="n">Match</span><span class="p">]</span>
<span class="w"> </span><span class="n">Face</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">SceneMeta</span><span class="p">[</span><span class="n">Scene</span><span class="w"> </span><span class="n">Metadata</span><span class="p">]</span>
<span class="w"> </span><span class="n">YOLO</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">SceneMeta</span>
<span class="w"> </span><span class="n">Face</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">IdentityAgent</span><span class="p">[</span><span class="n">Identity</span><span class="w"> </span><span class="n">Agent</span><span class="p">]</span>
<span class="w"> </span><span class="n">ASRX</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">IdentityAgent</span>
<span class="w"> </span><span class="n">Cut</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Agent5W1H</span><span class="p">[</span><span class="mi">5</span><span class="n">W1H</span><span class="w"> </span><span class="n">Agent</span><span class="p">]</span>
<span class="w"> </span><span class="n">ASR</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Agent5W1H</span>
<span class="w"> </span><span class="n">Agent5W1H</span><span class="w"> </span><span class="o">--&gt;</span><span class="w"> </span><span class="n">Phase2</span><span class="p">[</span><span class="n">Phase</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="n">Pack</span><span class="p">]</span>
<span class="w"> </span><span class="n">end</span>
<span class="w"> </span><span class="n">style</span><span class="w"> </span><span class="n">Processors</span><span class="w"> </span><span class="n">fill</span><span class="o">:</span><span class="err">#</span><span class="mi">1</span><span class="n">a1a2e</span><span class="p">,</span><span class="n">stroke</span><span class="o">:</span><span class="err">#</span><span class="n">e94560</span>
<span class="w"> </span><span class="n">style</span><span class="w"> </span><span class="n">Ingestion</span><span class="w"> </span><span class="n">fill</span><span class="o">:</span><span class="err">#</span><span class="mi">16213</span><span class="n">e</span><span class="p">,</span><span class="n">stroke</span><span class="o">:</span><span class="err">#</span><span class="mf">0f</span><span class="mi">3460</span>
</code></pre></div>
<h3>Pipeline Completion Flow</h3>
<p>The pipeline is <strong>not complete</strong> until both the 10 processors AND the 入庫 (ingestion) steps have finished. The worker polls every 3 seconds and only marks the job as <code>completed</code> when all ingestion steps verify OK.</p>
<div class="codehilite"><pre><span></span><code><span class="mf">10</span><span class="w"> </span><span class="n">processors</span><span class="w"> </span><span class="n">done</span>
<span class="w"> </span><span class="err"></span><span class="w"> </span><span class="p">(</span><span class="n">job</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="n">stays</span><span class="w"> </span><span class="s">&quot;running&quot;</span><span class="p">)</span>
<span class="n">Algorithm</span><span class="w"> </span><span class="mf">1</span><span class="w"> </span><span class="n">Trigger</span><span class="p">:</span><span class="w"> </span><span class="n">Rule</span><span class="w"> </span><span class="mf">1</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">Vectorize</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">Phase</span><span class="w"> </span><span class="mf">1</span><span class="w"> </span><span class="n">Pack</span>
<span class="w"> </span><span class="err"></span><span class="w"> </span><span class="p">(</span><span class="n">job</span><span class="w"> </span><span class="kr">run</span><span class="n">s</span><span class="w"> </span><span class="n">in</span><span class="w"> </span><span class="n">parallel</span><span class="p">)</span>
<span class="n">Algorithm</span><span class="w"> </span><span class="mf">2</span><span class="w"> </span><span class="n">Trigger</span><span class="p">:</span><span class="w"> </span><span class="n">Face</span><span class="w"> </span><span class="n">Trace</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">TKG</span><span class="p">,</span><span class="w"> </span><span class="n">Scene</span><span class="w"> </span><span class="n">Metadata</span><span class="p">,</span><span class="w"> </span><span class="n">Identity</span><span class="w"> </span><span class="n">Agent</span><span class="p">,</span><span class="w"> </span><span class="mf">5</span><span class="n">W1H</span><span class="w"> </span><span class="n">Agent</span>
<span class="w"> </span><span class="err"></span><span class="w"> </span><span class="p">(</span><span class="n">poll</span><span class="w"> </span><span class="n">checks</span><span class="w"> </span><span class="n">every</span><span class="w"> </span><span class="mf">3</span><span class="n">s</span><span class="p">)</span>
<span class="n">Ingestion</span><span class="w"> </span><span class="n">verification</span><span class="p">:</span><span class="w"> </span><span class="n">rule1</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">vectorize</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">rule3</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">face_trace</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">tkg</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">scene_meta</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="mf">5</span><span class="n">w1h</span><span class="w"> </span><span class="err"></span>
<span class="w"> </span><span class="err"></span>
<span class="n">job</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&quot;completed&quot;</span>
</code></pre></div>
<h3>10 Processor Stages</h3>
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Processor</th>
<th>Depends On</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><code>Cut</code></td>
<td></td>
<td>Scene boundary detection (PySceneDetect)</td>
</tr>
<tr>
<td>2</td>
<td><code>ASR</code></td>
<td>Cut</td>
<td>Automatic speech recognition (faster-whisper)</td>
</tr>
<tr>
<td>3</td>
<td><code>ASRX</code></td>
<td>ASR</td>
<td>Speaker diarization + ASR refinement</td>
</tr>
<tr>
<td>4</td>
<td><code>YOLO</code></td>
<td></td>
<td>Object detection (YOLOv8)</td>
</tr>
<tr>
<td>5</td>
<td><code>OCR</code></td>
<td></td>
<td>Optical character recognition</td>
</tr>
<tr>
<td>6</td>
<td><code>Face</code></td>
<td></td>
<td>Face detection + recognition (InsightFace + CoreML)</td>
</tr>
<tr>
<td>7</td>
<td><code>Pose</code></td>
<td></td>
<td>Pose estimation</td>
</tr>
<tr>
<td>8</td>
<td><code>VisualChunk</code></td>
<td>YOLO</td>
<td>Visual object chunking</td>
</tr>
<tr>
<td>9</td>
<td><code>Story</code></td>
<td>ASRX + Cut + YOLO + Face</td>
<td>Narrative scene summarization (LLM, with embedding)</td>
</tr>
<tr>
<td>10</td>
<td><code>5W1H</code></td>
<td>Story</td>
<td>Who/What/When/Where/Why extraction (LLM, with embedding)</td>
</tr>
</tbody>
</table>
<h3>入庫 (Post-Processing / Ingestion)</h3>
<p>These steps run after the 10 processors and are <strong>required for pipeline completion</strong>. The worker checks all of them before marking the job as done.</p>
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Step</th>
<th>Triggers When</th>
<th>Verification</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><strong>Rule 1 Sentence Chunking</strong></td>
<td>ASR + ASRX done</td>
<td><code>chunk</code> table has rows with <code>chunk_type = 'sentence'</code></td>
</tr>
<tr>
<td>2</td>
<td><strong>Auto-Vectorize</strong></td>
<td>Rule 1 done</td>
<td><code>chunk.embedding</code> IS NOT NULL for sentence chunks</td>
</tr>
<tr>
<td>3</td>
<td><strong>Phase 1 Pack</strong></td>
<td>Rule 1 done</td>
<td><code>release_pack.py --phase 1</code> executed</td>
</tr>
<tr>
<td>4</td>
<td><strong>Rule 3 Scene Chunking</strong></td>
<td>All 10 processors done + Cut + ASR</td>
<td><code>chunk</code> table has rows with <code>chunk_type = 'cut'</code></td>
</tr>
<tr>
<td>5</td>
<td><strong>Face Trace</strong></td>
<td>All 10 processors done + Face</td>
<td><code>face_detections.trace_id</code> IS NOT NULL</td>
</tr>
<tr>
<td>6</td>
<td><strong>Qdrant Face Sync</strong></td>
<td>Face Trace done</td>
<td>Qdrant face_embedding collection populated</td>
</tr>
<tr>
<td>7</td>
<td><strong>Trace Chunks</strong></td>
<td>Face Trace done</td>
<td><code>chunk</code> table has rows with <code>chunk_type = 'trace'</code></td>
</tr>
<tr>
<td>8</td>
<td><strong>TKG Builder</strong></td>
<td>Face Trace done</td>
<td><code>tkg_nodes</code> + <code>tkg_edges</code> tables have rows</td>
</tr>
<tr>
<td>9</td>
<td><strong>TMDb Face Matching</strong></td>
<td>TMDb enabled + Face done</td>
<td><code>face_detections.identity_id</code> IS NOT NULL</td>
</tr>
<tr>
<td>10</td>
<td><strong>Heuristic Scene Metadata</strong></td>
<td>Face + YOLO done</td>
<td><code>{file_uuid}.scene_meta.json</code> exists on disk</td>
</tr>
<tr>
<td>11</td>
<td><strong>Identity Agent</strong></td>
<td>Face + ASRX done</td>
<td><code>identities</code> with <code>source = 'identity_agent'</code></td>
</tr>
<tr>
<td>12</td>
<td><strong>5W1H Agent</strong></td>
<td>Cut + ASR done</td>
<td><code>chunk.summary_text</code> IS NOT NULL for cut chunks</td>
</tr>
<tr>
<td>13</td>
<td><strong>Release Pack</strong></td>
<td>5W1H Agent done</td>
<td><code>release_pack.py --phase 2</code> executed</td>
</tr>
</tbody>
</table>
<h3>Ingestion Status</h3>
<p>Check real-time ingestion status for a file:</p>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/stats/ingestion-status/{file_uuid}&quot;</span>
</code></pre></div>
<p>Returns per-step <code>done</code> / <code>pending</code> status with detail counts.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span><span class="s2">&quot;http://localhost:3003/api/v1/stats/ingestion-status/bd80fec9c42afb0307eb28f22c64c76a&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;.steps[] | {name, status, detail}&#39;</span>
</code></pre></div>
<h4>Response</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;bd80fec9c42afb0307eb28f22c64c76a&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;steps&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;rule1_sentence&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;detail&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;0 sentence chunks&quot;</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;auto_vectorize&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;detail&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;0 embedded&quot;</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;rule3_scene&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;detail&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;0 scene chunks&quot;</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;face_trace&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;detail&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;0 traces&quot;</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;trace_chunks&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;detail&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;0 trace chunks&quot;</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;tkg&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;detail&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;0 nodes, 0 edges&quot;</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;identity_match&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;detail&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;0 identities&quot;</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;scene_metadata&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;detail&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;5w1h&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pending&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;detail&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;0 scenes with 5W1H&quot;</span><span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<h3>Stats Endpoints</h3>
<table class="table">
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/v1/stats/sftpgo</code></td>
<td>No</td>
<td>SFTPGo service status</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/v1/stats/ingestion-status/:file_uuid</code></td>
<td>No</td>
<td>Per-file ingestion checklist</td>
</tr>
</tbody>
</table>
<h3>Configuration</h3>
<h3><code>POST /api/v1/config/cache</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: system-level</p>
<p>Toggle the Redis cache on or off.</p>
<h4>Request Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>enabled</code></td>
<td>boolean</td>
<td>Yes</td>
<td><code>true</code> to enable, <code>false</code> to disable</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/config/cache&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;enabled&quot;: false}&#39;</span>
</code></pre></div>
<h3>Unmounted Routes</h3>
<p>The following routes are defined in source code but are <strong>NOT</strong> currently mounted in the router:</p>
<table class="table">
<thead>
<tr>
<th>Endpoint</th>
<th>Source file</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/api/v1/search/persons</code></td>
<td><code>universal_search.rs</code> (not mounted)</td>
</tr>
<tr>
<td><code>/api/v1/who</code></td>
<td><code>who.rs</code></td>
</tr>
<tr>
<td><code>/api/v1/who/candidates</code></td>
<td><code>who.rs</code></td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>12 Agent - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<h1>Agent Endpoints</h1>
<p>Agent endpoints provide AI-powered capabilities including translation, identity analysis, and 5W1H extraction.</p>
<h2>POST /api/v1/agents/translate</h2>
<p>Translate text between languages using Gemma4 (llama.cpp, port 8082).</p>
<h3>Request</h3>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;text&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Hello, welcome to Momentry Core.&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;target_language&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Traditional Chinese&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;source_language&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;English&quot;</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>text</code></td>
<td>string</td>
<td></td>
<td>Text to translate</td>
</tr>
<tr>
<td><code>target_language</code></td>
<td>string</td>
<td></td>
<td>Target language name (e.g. "Traditional Chinese", "Japanese")</td>
</tr>
<tr>
<td><code>source_language</code></td>
<td>string</td>
<td></td>
<td>Source language (default: "auto")</td>
</tr>
</tbody>
</table>
<h3>Response</h3>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;translated_text&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;您好,歡迎使用 Momentry Core。&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;source_language_detected&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;English&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;model_used&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;google_gemma-4-26B-A4B-it-Q5_K_M.gguf&quot;</span>
<span class="p">}</span>
</code></pre></div>
<h3>Supported Language Pairs (tested)</h3>
<table class="table">
<thead>
<tr>
<th>Source</th>
<th>Target</th>
<th>Quality</th>
</tr>
</thead>
<tbody>
<tr>
<td>English</td>
<td>Traditional Chinese</td>
<td></td>
</tr>
<tr>
<td>English</td>
<td>Japanese</td>
<td></td>
</tr>
<tr>
<td>Chinese</td>
<td>English</td>
<td></td>
</tr>
<tr>
<td>English</td>
<td>French</td>
<td></td>
</tr>
<tr>
<td>Chinese</td>
<td>Japanese</td>
<td></td>
</tr>
</tbody>
</table>
<h3>Model</h3>
<ul>
<li><strong>Model</strong>: Gemma4 26B (Q5_K_M)</li>
<li><strong>Engine</strong>: llama.cpp at <code>localhost:8082</code></li>
<li><strong>Endpoint</strong>: <code>/v1/chat/completions</code> (OpenAI-compatible)</li>
<li><strong>Temperature</strong>: 0.1</li>
<li><strong>Max tokens</strong>: 1024</li>
</ul>
<h3>Errors</h3>
<table class="table">
<thead>
<tr>
<th>Status</th>
<th>Condition</th>
</tr>
</thead>
<tbody>
<tr>
<td>500</td>
<td>LLM unreachable or response parse failure</td>
</tr>
<tr>
<td>401</td>
<td>Missing/invalid auth</td>
</tr>
</tbody>
</table>
<hr />
<h2>POST /api/v1/agents/5w1h/analyze</h2>
<p>Extract 5W1H (Who, What, When, Where, Why, How) from a scene. Uses Gemma4 LLM on port 8082.</p>
<h3>Request</h3>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3abeee81d94597629ed8cb943f182e94&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;scene_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">42</span>
<span class="p">}</span>
</code></pre></div>
<h3>Response</h3>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;5w1h&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;who&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;Cary Grant&quot;</span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;what&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;discussing plans&quot;</span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;when&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;1963&quot;</span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;where&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;Paris&quot;</span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;why&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;vacation&quot;</span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;how&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;in person&quot;</span><span class="p">]</span>
<span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<h2>POST /api/v1/agents/5w1h/batch</h2>
<p>Batch analyze all scenes in a file for 5W1H extraction. Uses the pipeline's <code>parent_chunk_5w1h.py --mode llm</code>.</p>
<h3>Request</h3>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3abeee81d94597629ed8cb943f182e94&quot;</span>
<span class="p">}</span>
</code></pre></div>
<h2>GET /api/v1/agents/5w1h/status</h2>
<p>Get status of the 5W1H agent pipeline for a file.</p>
<hr />
<h2>Embedding Model</h2>
<table class="table">
<thead>
<tr>
<th>Detail</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Model</strong></td>
<td>EmbeddingGemma-300m</td>
</tr>
<tr>
<td><strong>Endpoint</strong></td>
<td><code>POST /v1/embeddings</code> on port 11436</td>
</tr>
<tr>
<td><strong>Dimension</strong></td>
<td>768</td>
</tr>
<tr>
<td><strong>Used by</strong></td>
<td><code>parent_chunk_5w1h.py --embed</code>, story, 5W1H, search</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>Momentry API 文件</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 900px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 28px; margin-bottom: 8px; }
p.subtitle { color: #666; margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; }
tr { border-bottom: 1px solid #eee; }
tr:last-child { border: none; }
td { padding: 10px 0; }
td.cn { width: 140px; font-weight: 600; color: #333; }
td.en { color: #666; font-size: 14px; }
a { color: #0066cc; text-decoration: none; display: block; }
a:hover td { background: #f8f8f8; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<h1>Momentry API 文件</h1>
<p class="subtitle">API 參考手冊 — 登入後可瀏覽各模組文件</p>
<table><tr onclick="window.location='01_auth.html'" style="cursor:pointer"><td class="cn">安全認證</td><td class="en">Authentication</td></tr><tr onclick="window.location='02_health.html'" style="cursor:pointer"><td class="cn">健康檢查</td><td class="en">Health</td></tr><tr onclick="window.location='03_register.html'" style="cursor:pointer"><td class="cn">檔案註冊</td><td class="en">File Registration</td></tr><tr onclick="window.location='04_lookup.html'" style="cursor:pointer"><td class="cn">檔案屬性查詢</td><td class="en">File Lookup</td></tr><tr onclick="window.location='05_process.html'" style="cursor:pointer"><td class="cn">處理流程</td><td class="en">Processing</td></tr><tr onclick="window.location='06_search.html'" style="cursor:pointer"><td class="cn">搜尋功能</td><td class="en">Search</td></tr><tr onclick="window.location='07_identity.html'" style="cursor:pointer"><td class="cn">身份識別</td><td class="en">Identity</td></tr><tr onclick="window.location='08_identity_agent.html'" style="cursor:pointer"><td class="cn">智能身份綁定</td><td class="en">Smart Identity Binding</td></tr><tr onclick="window.location='08_media.html'" style="cursor:pointer"><td class="cn">串流與截圖</td><td class="en">Streaming & Thumbnails</td></tr><tr onclick="window.location='09_tmdb.html'" style="cursor:pointer"><td class="cn">TMDb 整合</td><td class="en">TMDb Integration</td></tr><tr onclick="window.location='10_pipeline.html'" style="cursor:pointer"><td class="cn">生產線</td><td class="en">Pipeline</td></tr><tr onclick="window.location='12_agent.html'" style="cursor:pointer"><td class="cn">智慧代理</td><td class="en">AI Agents</td></tr></table>
</div>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login - Momentry Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; height: 100vh; }
.card { background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; width: 360px; }
h1 { font-size: 24px; margin-bottom: 24px; text-align: center; }
input { width: 100%; padding: 10px 12px; margin-bottom: 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
button { width: 100%; padding: 10px; background: #0066cc; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; }
button:hover { background: #0052a3; }
.error { color: #cc0000; font-size: 13px; margin-bottom: 12px; display: none; }
</style>
</head>
<body>
<div class="card">
<h1>Momentry Docs</h1>
<form id="loginForm">
<input type="text" id="username" placeholder="Username" value="demo" required>
<input type="password" id="password" placeholder="Password" value="demo" required>
<div class="error" id="error">Invalid credentials</div>
<button type="submit">Login</button>
</form>
</div>
<script>
document.getElementById('loginForm').onsubmit = async function(e) {
e.preventDefault();
const resp = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
});
if (resp.ok) {
window.location.href = '/doc/index.html';
} else {
document.getElementById('error').style.display = 'block';
}
};
</script>
</body>
</html>

View File

@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>11 Error Codes - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<!-- module: error_codes -->
<!-- description: Standard API error codes -->
<!-- depends: -->
<h2>Error Response Format</h2>
<p>All API errors follow this JSON structure:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;error&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;code&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;E001_NOT_FOUND&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Resource not found&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;details&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;resource&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;file_uuid&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;value&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;abc&quot;</span><span class="p">}</span>
<span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<h2>Error Code List</h2>
<h3>Generic Errors (E0xx)</h3>
<table class="table">
<thead>
<tr>
<th>Code</th>
<th>HTTP</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>E001_NOT_FOUND</code></td>
<td>404</td>
<td>Resource not found (file, identity, chunk)</td>
</tr>
<tr>
<td><code>E002_DUPLICATE</code></td>
<td>409</td>
<td>Resource already exists</td>
</tr>
<tr>
<td><code>E003_VALIDATION</code></td>
<td>400</td>
<td>Request parameter validation failed</td>
</tr>
<tr>
<td><code>E004_UNAUTHORIZED</code></td>
<td>401</td>
<td>Invalid API key or token</td>
</tr>
<tr>
<td><code>E005_INTERNAL</code></td>
<td>500</td>
<td>Internal server error</td>
</tr>
</tbody>
</table>
<h3>Processor Errors (E1xx)</h3>
<table class="table">
<thead>
<tr>
<th>Code</th>
<th>HTTP</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>E101_PROCESSOR_FAIL</code></td>
<td>500</td>
<td>Python script execution failed</td>
</tr>
<tr>
<td><code>E102_TIMEOUT</code></td>
<td>504</td>
<td>Processing timeout</td>
</tr>
<tr>
<td><code>E103_RESUME_FAIL</code></td>
<td>500</td>
<td>Resume failed (checkpoint not found)</td>
</tr>
<tr>
<td><code>E104_NO_VIDEO</code></td>
<td>400</td>
<td>Video file path not found</td>
</tr>
</tbody>
</table>
<h3>Identity Errors (E2xx)</h3>
<table class="table">
<thead>
<tr>
<th>Code</th>
<th>HTTP</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>E201_FACE_NOT_FOUND</code></td>
<td>404</td>
<td>Face detection not found</td>
</tr>
<tr>
<td><code>E202_MERGE_CONFLICT</code></td>
<td>409</td>
<td>Identity merge conflict</td>
</tr>
<tr>
<td><code>E203_CANDIDATE_EMPTY</code></td>
<td>404</td>
<td>No candidates available for confirmation</td>
</tr>
</tbody>
</table>
<h3>TMDb Errors (E3xx)</h3>
<table class="table">
<thead>
<tr>
<th>Code</th>
<th>HTTP</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>E301_TMDB_NO_KEY</code></td>
<td>400</td>
<td><code>TMDB_API_KEY</code> environment variable not set</td>
</tr>
<tr>
<td><code>E302_TMDB_UNREACHABLE</code></td>
<td>502</td>
<td>TMDb API unreachable or timed out</td>
</tr>
<tr>
<td><code>E303_TMDB_CACHE_NOT_FOUND</code></td>
<td>200</td>
<td>No local TMDb cache; run prefetch first</td>
</tr>
<tr>
<td><code>E304_TMDB_PROBE_FAILED</code></td>
<td>500</td>
<td>TMDb probe execution failed</td>
</tr>
<tr>
<td><code>E305_TMDB_MOVIE_NOT_FOUND</code></td>
<td>404</td>
<td>No matching TMDb movie found from filename</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>Momentry API 文件</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 900px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 28px; margin-bottom: 8px; }
p.subtitle { color: #666; margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; }
tr { border-bottom: 1px solid #eee; }
tr:last-child { border: none; }
td { padding: 10px 0; }
td.cn { width: 140px; font-weight: 600; color: #333; }
td.en { color: #666; font-size: 14px; }
a { color: #0066cc; text-decoration: none; display: block; }
a:hover td { background: #f8f8f8; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<h1>Momentry API 文件</h1>
<p class="subtitle">API 參考手冊 — 登入後可瀏覽各模組文件</p>
<table><tr onclick="window.location='11_error_codes.html'" style="cursor:pointer"><td class="cn">錯誤碼</td><td class="en">Error Codes</td></tr></table>
</div>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login - Momentry Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; height: 100vh; }
.card { background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; width: 360px; }
h1 { font-size: 24px; margin-bottom: 24px; text-align: center; }
input { width: 100%; padding: 10px 12px; margin-bottom: 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
button { width: 100%; padding: 10px; background: #0066cc; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; }
button:hover { background: #0052a3; }
.error { color: #cc0000; font-size: 13px; margin-bottom: 12px; display: none; }
</style>
</head>
<body>
<div class="card">
<h1>Momentry Docs</h1>
<form id="loginForm">
<input type="text" id="username" placeholder="Username" value="demo" required>
<input type="password" id="password" placeholder="Password" value="demo" required>
<div class="error" id="error">Invalid credentials</div>
<button type="submit">Login</button>
</form>
</div>
<script>
document.getElementById('loginForm').onsubmit = async function(e) {
e.preventDefault();
const resp = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
});
if (resp.ok) {
window.location.href = '/doc/index.html';
} else {
document.getElementById('error').style.display = 'block';
}
};
</script>
</body>
</html>

View File

@@ -0,0 +1,280 @@
<!-- module: auth -->
<!-- description: Authentication — login, logout, JWT, session cookie, API key -->
<!-- depends: -->
## 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=<session_id>` | 24h | per-browser session | Portal (browser) |
| **JWT** | `Authorization: Bearer <token>` | 1h | per-login token | API clients, CLI, scripts |
| **API Key** | `X-API-Key: <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 <jwt>`. Expires in 1 hour. |
| `api_key` | string | Legacy API key. Use as `X-API-Key: <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=<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=<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=<uuid>"
```
#### 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)

View File

@@ -0,0 +1,147 @@
<!-- module: health -->
<!-- description: Health check endpoints -->
<!-- depends: 01_auth -->
## 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 |

View File

@@ -0,0 +1,184 @@
<!-- module: register -->
<!-- description: File registration — register, scan -->
<!-- depends: 01_auth -->
## 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. |

View File

@@ -0,0 +1,138 @@
<!-- module: lookup -->
<!-- description: File lookup by name and unregistration -->
<!-- depends: 01_auth, 03_register -->
## 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 |

View File

@@ -0,0 +1,236 @@
<!-- module: process -->
<!-- description: Processing pipeline — trigger, probe, progress, jobs -->
<!-- depends: 01_auth, 03_register -->
## 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 (0100) |
| `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 (0100) |
| `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 |

View File

@@ -0,0 +1,145 @@
<!-- module: search -->
<!-- description: Vector search, BM25, smart search, universal search, visual search -->
<!-- depends: 01_auth -->
## 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) |

View File

@@ -0,0 +1,65 @@
<!-- module: identity_agent -->
<!-- description: Identity agent — match from photo, match from trace -->
<!-- depends: 01_auth, 07_identity -->
## 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}'
```

View File

@@ -0,0 +1,109 @@
<!-- module: tmdb -->
<!-- description: TMDb enrichment endpoints — prefetch, probe, resource, check -->
<!-- depends: 01_auth, 03_register -->
## 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
}
```

View File

@@ -0,0 +1,178 @@
<!-- module: pipeline -->
<!-- description: Pipeline processors, ingestion status, stats endpoints -->
<!-- depends: 01_auth -->
## 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` |

View File

@@ -0,0 +1,57 @@
<!-- module: error_codes -->
<!-- description: Standard API error codes -->
<!-- depends: -->
## 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 |

View File

@@ -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 |

View File

@@ -0,0 +1,63 @@
# {Module Name} — API Workspace Module
> Use this template when adding or editing API endpoint documentation modules.
## Module Metadata
Every module MUST start with:
```markdown
<!-- module: <short_name> -->
<!-- description: One-line description of what this module covers -->
<!-- depends: <comma-separated list of dependency module names> -->
```
## Endpoint Template
Each endpoint MUST use this structure:
### `METHOD /path/to/endpoint`
**Auth**: Required / Optional / Public
**Scope**: file-level / identity-level / system-level
#### Request Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `param1` | string | Yes | — | Description |
#### Example
```bash
# brief description of what this example demonstrates
curl -s -X METHOD "$API/path" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"param1": "value"}'
```
#### Response (200)
```json
{ "success": true }
```
| Field | Type | Description |
|-------|------|-------------|
| `success` | boolean | Always true on 200 |
#### Error Codes
| Code | HTTP | When |
|------|------|------|
| E0xx | 4xx | Description |
## Rules
1. Each module file covers ONE topic group (e.g., `09_tmdb.md` = all TMDb endpoints)
2. Use `$API` and `$KEY` in all curl examples
3. Use `$FILE_UUID`, `$IDENTITY_UUID` variables for UUID examples
4. Module filename = `NN_topic.md` (NN = execution order, 01-99)
5. `depends` metadata = which modules must be assembled before this one

View File

@@ -0,0 +1,225 @@
#!/opt/homebrew/bin/python3.11
"""Build HTML documentation from module source files."""
import os, markdown, re, glob, shutil
MODULES_DIR = os.path.join(os.path.dirname(__file__), "..", "docs_v1.0", "API_WORKSPACE", "modules")
DOC_DIR = os.path.join(os.path.dirname(__file__), "..", "docs_v1.0", "doc")
DOC_DEV_DIR = os.path.join(os.path.dirname(__file__), "..", "docs_v1.0", "doc_developer")
# User-facing modules (no developer content)
USER_MODULES = {
"01_auth", "02_health", "03_register", "04_lookup", "05_process",
"06_search", "07_identity", "08_identity_agent", "08_media",
"09_tmdb", "10_pipeline", "12_agent",
}
def md_to_html(md_text: str) -> str:
"""Convert Markdown to HTML."""
html = markdown.markdown(md_text, extensions=['fenced_code', 'tables', 'codehilite'])
# Wrap tables
html = re.sub(r'<table>', '<table class="table">', html)
return html
def build_index(files, dev=False):
"""Build index.html."""
links = []
for fname in sorted(files):
name = os.path.splitext(fname)[0]
label = MODULE_LABELS.get(name, name.replace("_", " ").title())
if "" in label:
cn, en = label.split("", 1)
else:
cn, en = label, ""
html_name = fname.replace(".md", ".html")
links.append(f'<tr onclick="window.location=\'{html_name}\'" style="cursor:pointer"><td class="cn">{cn}</td><td class="en">{en}</td></tr>')
title = "Momentry API 開發者文件" if dev else "Momentry API 文件"
subtitle = "開發者專用" if dev else "API 參考手冊 — 登入後可瀏覽各模組文件"
return f"""<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }}
.container {{ max-width: 900px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }}
h1 {{ font-size: 28px; margin-bottom: 8px; }}
p.subtitle {{ color: #666; margin-bottom: 24px; }}
table {{ width: 100%; border-collapse: collapse; }}
tr {{ border-bottom: 1px solid #eee; }}
tr:last-child {{ border: none; }}
td {{ padding: 10px 0; }}
td.cn {{ width: 140px; font-weight: 600; color: #333; }}
td.en {{ color: #666; font-size: 14px; }}
a {{ color: #0066cc; text-decoration: none; display: block; }}
a:hover td {{ background: #f8f8f8; border-radius: 4px; }}
</style>
</head>
<body>
<div class="container">
<h1>{title}</h1>
<p class="subtitle">{subtitle}</p>
<table>{"".join(links)}</table>
</div>
</body>
</html>"""
MODULE_LABELS = {
"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",
"11_error_codes": "錯誤碼Error Codes",
"12_agent": "智慧代理AI Agents",
}
def build_html(md_text: str, title: str) -> str:
"""Wrap MD content in HTML page."""
content = md_to_html(md_text)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{title} - Momentry API Docs</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }}
.container {{ max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }}
h1 {{ font-size: 24px; margin: 24px 0 12px; }}
h2 {{ font-size: 20px; margin: 20px 0 10px; color: #222; }}
h3 {{ font-size: 16px; margin: 16px 0 8px; color: #444; }}
p {{ line-height: 1.6; margin: 8px 0; }}
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }}
th, td {{ border: 1px solid #ddd; padding: 8px 12px; text-align: left; }}
th {{ background: #f0f0f0; font-weight: 600; }}
code {{ background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }}
pre {{ background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }}
pre code {{ background: none; padding: 0; }}
a {{ color: #0066cc; }}
.back {{ display: inline-block; margin-bottom: 20px; color: #666; }}
.back:hover {{ color: #333; }}
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
{content}
</div>
</body>
</html>"""
def login_page() -> str:
return """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login - Momentry Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; height: 100vh; }
.card { background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; width: 360px; }
h1 { font-size: 24px; margin-bottom: 24px; text-align: center; }
input { width: 100%; padding: 10px 12px; margin-bottom: 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
button { width: 100%; padding: 10px; background: #0066cc; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; }
button:hover { background: #0052a3; }
.error { color: #cc0000; font-size: 13px; margin-bottom: 12px; display: none; }
</style>
</head>
<body>
<div class="card">
<h1>Momentry Docs</h1>
<form id="loginForm">
<input type="text" id="username" placeholder="Username" value="demo" required>
<input type="password" id="password" placeholder="Password" value="demo" required>
<div class="error" id="error">Invalid credentials</div>
<button type="submit">Login</button>
</form>
</div>
<script>
document.getElementById('loginForm').onsubmit = async function(e) {
e.preventDefault();
const resp = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
});
if (resp.ok) {
window.location.href = '/doc/index.html';
} else {
document.getElementById('error').style.display = 'block';
}
};
</script>
</body>
</html>"""
def main():
# Clean and recreate doc dirs
for d in [DOC_DIR, DOC_DEV_DIR]:
if os.path.exists(d):
shutil.rmtree(d)
os.makedirs(d)
md_files = sorted(glob.glob(os.path.join(MODULES_DIR, "*.md")))
if not md_files:
print(f"No MD files found in {MODULES_DIR}")
return
user_html = []
dev_html = []
for md_path in md_files:
with open(md_path) as f:
md_text = f.read()
fname = os.path.basename(md_path)
stem = os.path.splitext(fname)[0]
# Skip template
if stem == "_template":
continue
# Skip error codes (developer-only)
if stem == "11_error_codes":
dev_only = True
else:
dev_only = stem not in USER_MODULES
title = stem.replace("_", " ").title()
html = build_html(md_text, title)
if dev_only:
out_path = os.path.join(DOC_DEV_DIR, fname.replace(".md", ".html"))
with open(out_path, "w") as f:
f.write(html)
dev_html.append(fname)
print(f" [dev] {fname}")
else:
out_path = os.path.join(DOC_DIR, fname.replace(".md", ".html"))
with open(out_path, "w") as f:
f.write(html)
user_html.append(fname)
print(f" [doc] {fname}")
# Build indexes + login page
for d, files, label in [(DOC_DIR, user_html, "User"), (DOC_DEV_DIR, dev_html, "Dev")]:
index = build_index(files)
with open(os.path.join(d, "index.html"), "w") as f:
f.write(index)
with open(os.path.join(d, "login.html"), "w") as f:
f.write(login_page())
print(f" {label}: {len(files)} pages -> {d}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,148 @@
#!/bin/bash
# sync_dev_to_public.sh — 比對 dev/public schema同步 pipeline 資料
# Usage: ./sync_dev_to_public.sh [check|sync] [file_uuid]
PSQL="/opt/homebrew/opt/libpq/bin/psql"
set -euo pipefail
SCHEMA="${MOMENTRY_DB_SCHEMA:-dev}"
DB_URL="${DATABASE_URL:-postgres://accusys@localhost:5432/momentry}"
MODE="${1:-check}"
FILE_UUID="${2:-}"
TABLES=("videos" "chunk" "face_detections" "processor_results" "monitor_jobs"
"identities" "identity_bindings" "tkg_nodes" "tkg_edges")
TARGET="public"
if [ -z "$FILE_UUID" ]; then
echo "Usage: $0 [check|sync] <file_uuid>"
echo ""
echo "Examples:"
echo " $0 check bd80fec92b0b6963d177a2c55bf713e2"
echo " $0 sync bd80fec92b0b6963d177a2c55bf713e2"
exit 1
fi
echo "=== Schema Sync: $SCHEMA$TARGET ==="
echo "File UUID: $FILE_UUID"
echo "Mode: $MODE"
echo ""
check_table() {
local table=$1
local col=$2
local src_count dev_count pub_count
dev_count=$($PSQL -At "$DB_URL" -c "SELECT COUNT(*) FROM ${SCHEMA}.${table} WHERE ${col} = '${FILE_UUID}';" 2>/dev/null || echo "ERROR")
pub_count=$($PSQL -At "$DB_URL" -c "SELECT COUNT(*) FROM ${TARGET}.${table} WHERE ${col} = '${FILE_UUID}';" 2>/dev/null || echo "ERROR")
if [ "$dev_count" = "ERROR" ] || [ "$pub_count" = "ERROR" ]; then
echo " ⚠️ $table — query error (table may not exist in $TARGET)"
return 1
fi
if [ "$dev_count" -eq "$pub_count" ]; then
echo "$table$dev_count rows (match)"
return 0
else
echo "$table — dev=$dev_count pub=$pub_count (MISMATCH)"
return 1
fi
}
sync_table() {
local table=$1
local col=$2
local src_count dev_count pub_count
dev_count=$($PSQL -At "$DB_URL" -c "SELECT COUNT(*) FROM ${SCHEMA}.${table} WHERE ${col} = '${FILE_UUID}';" 2>/dev/null || echo "0")
pub_count=$($PSQL -At "$DB_URL" -c "SELECT COUNT(*) FROM ${TARGET}.${table} WHERE ${col} = '${FILE_UUID}';" 2>/dev/null || echo "0")
if [ "$dev_count" = "0" ]; then
echo " ⏭️ $table — dev has 0 rows, skipping"
return
fi
if [ "$dev_count" -eq "$pub_count" ]; then
echo "$table — already synced ($dev_count rows)"
return
fi
echo " 🔄 Syncing $table: dev=$dev_count → pub=$pub_count ..."
# Delete existing public rows, insert from dev
$PSQL "$DB_URL" -q -c "DELETE FROM ${TARGET}.${table} WHERE ${col} = '${FILE_UUID}';" 2>/dev/null || true
# Get columns list (excluding id for SERIAL)
COLS=$($PSQL -At "$DB_URL" -c "
SELECT string_agg(column_name, ', ' ORDER BY ordinal_position)
FROM information_schema.columns
WHERE table_schema='${SCHEMA}' AND table_name='${table}'
AND column_name != 'id'
AND is_updatable='YES';
")
$PSQL "$DB_URL" -q -c "
INSERT INTO ${TARGET}.${table} (${COLS})
SELECT ${COLS}
FROM ${SCHEMA}.${table}
WHERE ${col} = '${FILE_UUID}';
" 2>/dev/null && echo "$table synced" || echo "$table sync FAILED"
}
echo "=== Checking Tables ==="
echo ""
MISMATCH=0
for table in "${TABLES[@]}"; do
# Determine the UUID column name for each table
case "$table" in
videos) col="file_uuid" ;;
chunk) col="file_uuid" ;;
face_detections) col="file_uuid" ;;
processor_results) col="file_uuid" ;;
monitor_jobs) col="uuid" ;;
identities) col="uuid" ;; # identities.uuid is UUID type
identity_bindings) col="uuid" ;;
tkg_nodes) col="file_uuid" ;;
tkg_edges) col="file_uuid" ;;
*) col="file_uuid" ;;
esac
if ! check_table "$table" "$col"; then
MISMATCH=$((MISMATCH + 1))
fi
done
echo ""
if [ "$MISMATCH" -eq 0 ]; then
echo "✅ All tables in sync"
exit 0
fi
if [ "$MODE" != "sync" ]; then
echo "⚠️ $MISMATCH table(s) have mismatches. Run '$0 sync $FILE_UUID' to fix."
exit 1
fi
echo "=== Syncing Tables ==="
echo ""
for table in "${TABLES[@]}"; do
case "$table" in
videos) col="file_uuid" ;;
chunk) col="file_uuid" ;;
face_detections) col="file_uuid" ;;
processor_results) col="file_uuid" ;;
monitor_jobs) col="uuid" ;;
identities) col="uuid" ;;
identity_bindings) col="uuid" ;;
tkg_nodes) col="file_uuid" ;;
tkg_edges) col="file_uuid" ;;
*) col="file_uuid" ;;
esac
sync_table "$table" "$col"
done
echo ""
echo "✅ Sync complete"

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""批量更新 Qdrant collection 中的 file_uuid (舊→新)"""
import json
import subprocess
import sys
QDRANT_URL = "http://localhost:6333"
# UUID mapping: 舊 → 新
UUID_MAP = {
"aeed71342a899fe4b4c57b7d41bcb692": [
"bd80fec92b0b6963d177a2c55bf713e2",
],
}
# Collections to process
COLLECTIONS = [
"momentry_dev_v1",
"momentry_dev_stories",
"momentry_dev_voice",
"momentry_dev_rule1_v2",
"momentry_dev_faces",
"sentence_story",
"sentence_summary",
]
def qdrant_get(path: str) -> dict:
res = subprocess.run(
["curl", "-s", "-X", "GET", f"{QDRANT_URL}{path}"],
capture_output=True, text=True
)
return json.loads(res.stdout) if res.stdout.strip() else {}
def qdrant_post(path: str, body: dict) -> dict:
tmp = "/tmp/qdrant_post.json"
with open(tmp, "w") as f:
json.dump(body, f)
res = subprocess.run(
["curl", "-s", "-X", "POST", f"{QDRANT_URL}{path}",
"-H", "Content-Type: application/json", "-d", f"@{tmp}"],
capture_output=True, text=True
)
return json.loads(res.stdout) if res.stdout.strip() else {}
def qdrant_put(path: str, body: dict) -> dict:
tmp = "/tmp/qdrant_update.json"
with open(tmp, "w") as f:
json.dump(body, f)
res = subprocess.run(
["curl", "-s", "-X", "PUT", f"{QDRANT_URL}{path}",
"-H", "Content-Type: application/json", "-d", f"@{tmp}"],
capture_output=True, text=True
)
return json.loads(res.stdout) if res.stdout.strip() else {}
def scroll_all(collection: str, filter_old: dict) -> list:
"""Scroll all matching points from a collection"""
points = []
offset = None
while True:
body = {
"limit": 1000,
"with_payload": True,
"with_vector": True,
"filter": filter_old,
}
if offset:
body["offset"] = offset
result = qdrant_post(f"/collections/{collection}/points/scroll", body)
batch = result.get("result", {}).get("points", [])
points.extend(batch)
next_offset = result.get("result", {}).get("next_page_offset")
if next_offset is None:
break
offset = next_offset
return points
def update_points(collection: str, points: list, old_uuid: str, new_uuid: str):
"""Update file_uuid in payload for the given points"""
if not points:
return 0
updated = []
for p in points:
pl = p.get("payload", {})
# Check both 'uuid' and 'file_uuid' fields
changed = False
if pl.get("uuid") == old_uuid:
pl["uuid"] = new_uuid
changed = True
if pl.get("file_uuid") == old_uuid:
pl["file_uuid"] = new_uuid
changed = True
if changed:
updated.append({
"id": p["id"],
"vector": p["vector"],
"payload": pl,
})
if not updated:
return 0
# Update in batches of 500
total = len(updated)
for i in range(0, total, 500):
batch = updated[i:i+500]
result = qdrant_put(
f"/collections/{collection}/points?wait=true",
{"points": batch}
)
if result.get("status") != "ok":
print(f" Error at {i}: {result}")
return i
return total
def main():
for collection in COLLECTIONS:
# Check if collection exists
info = qdrant_get(f"/collections/{collection}")
if "result" not in info:
continue
for old_uuid, new_uuids in UUID_MAP.items():
for new_uuid in new_uuids:
# Scroll all points with this old UUID
filter_body = {
"must": [
{"should": [
{"key": "uuid", "match": {"value": old_uuid}},
{"key": "file_uuid", "match": {"value": old_uuid}},
]}
]
}
points = scroll_all(collection, filter_body)
if not points:
continue
print(f"{collection}: {len(points)} points with UUID {old_uuid[:8]}...")
updated = update_points(collection, points, old_uuid, new_uuid)
print(f"{updated} points updated to {new_uuid[:8]}...")
# Verify
print("\n=== Verification ===")
for collection in COLLECTIONS:
for old_uuid, new_uuids in UUID_MAP.items():
for what, uuid in [("old", old_uuid), ("new", new_uuids[0])]:
filter_body = {
"must": [
{"should": [
{"key": "uuid", "match": {"value": uuid}},
{"key": "file_uuid", "match": {"value": uuid}},
]}
]
}
result = qdrant_post(
f"/collections/{collection}/points/count",
{"filter": filter_body}
)
cnt = result.get("result", {}).get("count", 0)
if cnt > 0:
print(f" {collection}: {cnt} points with {what} UUID")
print("✅ Done")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,70 @@
# 3002/3003 Schema Separation Status
Date: 2026-05-17
Status: ✅ Pipeline tables created in `public`; schema incompatibilities remain
## Summary
| Schema | Has pipeline tables | Has auth tables | Used by |
|--------|-------------------|-----------------|---------|
| `public` | ✅ (newly created) | ✅ (original) | 3002 (production) — currently using `dev` as workaround |
| `dev` | ✅ (full, working) | ✅ (synced) | 3003 (playground) |
## What Was Done
### Pipeline tables created in `public` schema (11 tables)
- `videos`, `chunk`, `chunk_vectors`, `cuts`, `frames`
- `monitor_jobs`, `processor_results`, `processor_versions`
- `parent_chunks`, `tkg_edges`, `tkg_nodes`
All include proper sequences, indexes, and constraints matching the `dev` schema.
## Remaining Blockers
### Schema incompatibilities between `dev` and `public`
| Table | dev cols | public cols | Status |
|-------|---------|------------|--------|
| identities | 17 | 16 | ⚠️ Different columns (e.g. `name` vs `real_name`/`actor_name`) |
| face_detections | 16 | 17 | ⚠️ Column count mismatch |
| identity_bindings | 7 | 8 | ⚠️ Column count mismatch |
| person_identities | 16 | 15 | ⚠️ Column count mismatch |
| pre_chunks | 19 | 10 | ⚠️ Significantly different |
| api_keys | 19 | 19 | ✅ Match |
| resources | 9 | 9 | ✅ Match |
| users | 8 | 8 | ✅ Match |
### Identities table key differences
- `public.identities` uses `real_name` + `actor_name` (old schema)
- `dev.identities` uses `name` (new unified schema)
- `dev.identities` has `tmdb_poster`, `file_uuid`, `face_embedding`, `voice_embedding`, `identity_embedding`
- `public.identities` only has `face_embedding`, `voice_embedding` (no `identity_embedding`)
## Options
### Option A: Full data migration (recommended for later)
1. Dump data from old public tables
2. Drop old public tables
3. Recreate from dev schema DDL
4. Migrate data with column mapping
5. Switch 3002 to `DATABASE_SCHEMA=public`
### Option B: Keep current workaround (simplest for now)
- 3002 continues with `DATABASE_SCHEMA=dev`
- 3003 uses `DATABASE_SCHEMA=dev`
- Both share the same schema, but have separate Redis key prefixes + ports
### Option C: Rename dev → public (requires downtime)
1. Stop all services
2. Rename `dev` schema to something else
3. Rename `public` to `public_old`
4. Rename `dev` to `public`
5. Update references
## Current Status
✅ Pipeline tables exist in both schemas
✅ auth tables (users, sessions, jwt_blacklist) exist in both
✅ Redis key prefixes separate (`momentry:` vs `momentry_dev:`)
⚠️ 3002 still uses `DATABASE_SCHEMA=dev` workaround
⛔ Shared tables need migration before 3002 can use `public` schema

View File

@@ -0,0 +1,255 @@
# Charade 臉部匹配經驗總結
## 背景
Charade (1963) 影片 `a6fb22eebefaef17e62af874997c5944` 有 62,298 個人臉偵測結果,分布在 4,378 個 trace 中TKG face tracker 輸出)。目標是將每張臉匹配到正確的 TMDb 演員 identity。
## 問題
### 1. Rust Pipeline (`face_agent.rs`) 的 Snowball 效應
原始 pipeline 透過多輪 propagation 來匹配:
- Seed embedding 匹配 → propagation rounds (2-10 輪)
- 每輪把已匹配的 face 當作新 seed 繼續擴散
- 結果:**Antonio Passalia 被匹配 18,821 張臉**(實際應 < 50
- 原因propagation 會放大初始匹配中的假陽性
### 2. Dev 資料庫污染
`dev` schema 的 `identity_bindings` 表:
- 所有 trace-type binding 的 `file_uuid` 都是 NULL12,828 行)
- 這些 binding 只對應已刪除的 CCBN 檔案 (`63acd3bb`)
- **完全無法用於 sync 到 public schema**
### 3. TMDb Seed Embedding 品質不均
22/23 個 TMDb identity 有 face_embeddingThomas Chelimsky 因無 TMDb 照片而缺少)。但這些 seed 來自單一 TMDb 照片,品質差異大:
| Identity | Seed 品質 | 問題 |
|----------|:---------:|:----:|
| Audrey Hepburn | ✅ 高 | 特徵明顯,易區分 |
| Cary Grant | ✅ 中 | 但 Charade 造型與 seed 照片有差異 |
| Walter Matthau | ❌ 低 | Seed 照片與 Charade 形象差異大 |
| Bernard Musson | ❌ 泛用 | 「典型白人男性」— seed 太泛用 |
| Antonio Passalia | ❌ 泛用 | 同上 |
## 解決方案演進
### V1直接 pgvector 比對 (threshold 0.50)
```sql
CROSS JOIN LATERAL (
SELECT i.id FROM identities i
WHERE 1 - (embedding <=> i.face_embedding) >= 0.50
ORDER BY 1 - (embedding <=> i.face_embedding) DESC LIMIT 1
)
```
**結果**17,066 匹配 (27.4%)
- ✅ Audrey 9,550 (正確)
- ✅ Antonio 降為 151 (不再 snowball)
- ❌ Bernard Musson 847Paul Bonifas 273 — generic seed 假陽性
- ❌ trace-level 衝突(同一 trace 多個 identity
- ❌ Walter Matthau 僅 535seed 不準導致 recall 低)
### V2Trace Conflict Cleanup
在 V1 之後,對每個 conflict trace 做多數決 → 清除 minority identity。
**結果**:移除 836 個污染臉
- ✅ trace-level 衝突降為 0
- ❌ Bernard Musson 仍保留 847trace 內獨佔)
- ❌ 無法解決 generic seed 的根本問題
### V3雙階段 Centroid Matching
設計:
```
Phase 1: Seed matching @ 0.55 (stricter) → 乾淨 base set
Phase 2: Centroid matching @ 0.45 → 用電影內平均臉擴張 recall
```
**結果**27,375 匹配 (43.9%) → trace cleanup → 24,286 (39.0%)
- ✅ Audrey 11,347 (+19%)
- ✅ Cary Grant 3,107 (+56%)
- ✅ Walter Matthau 1,200 (+124%) — centroid 修正 seed!
-**Bernard Musson 2,903 (+243%)** — centroid 放大 generic seed
-**Antonio Passalia 898 (+642%)** — 同上
**教訓**Generic seed 的 centroid 更泛用。Phase 2 的低 threshold 讓問題惡化。
### V4雙重驗證 (Dual Gate)
在 V3 的 Phase 2 加上 seed_sim >= 0.40 條件:
```
centroid_sim >= 0.45 AND seed_sim >= 0.40
```
**結果**23,023 匹配 → gap cleanup → trace cleanup → **22,548 (36.2%)**
- ✅ Bernard / Paul / Antonio / Michel / Clément / Raoul / Roger 仍偏高但 avg_seed_sim 改善
### V5最終版排除 7 個 Generic Identity
核心洞察:**與其過濾假陽性,不如不讓 generic seed 參賽**。
只保留 11 個可靠的 TMDb identity排除 7 個:
- 排除Bernard Musson · Paul Bonifas · Michel Thomass · Antonio Passalia · Clément Harari · Raoul Delfosse · Roger Trapp
- 保留Audrey · Cary · James Coburn · Jacques Marin · Walter Matthau · George Kennedy · Dominique Minot · Monte Landis · Stanley Donen · Ned Glass · Louis Viret
流程:
```
1. Clear all assignments
2. Phase 1 @ 0.55 — only against 11 identities
3. Compute centroids
4. Phase 2 — centroid>=0.45 AND seed>=0.40 (11 centroids)
5. Ambiguity gate (top2 gap < 0.04 → NULL)
6. Trace conflict cleanup
```
**最終結果**
| Identity | 最終 faces | traces | fpt | avg_sim |
|----------|:----------:|:------:|:---:|:-------:|
| Audrey Hepburn | 11,325 | 438 | 25.9 | 0.608 |
| Cary Grant | **5,101** ≪ 大幅增加 | 269 | 19.0 | 0.497 |
| James Coburn | 1,508 | 92 | 16.4 | 0.588 |
| Jacques Marin | 1,438 | 84 | 17.1 | 0.631 |
| Walter Matthau | 1,250 | 55 | 22.7 | 0.494 |
| George Kennedy | 869 | 60 | 14.5 | 0.590 |
| 排除的 7 個 | **0** ✅ | — | — | — |
| Unassigned | 39,750 | — | — | — |
**Cary Grant 從 3,107→5,101 (+64%)**:之前被 Bernard/Antonio 攔截的臉全部釋放。
## 關鍵教訓
### 1. Generic Seed 辨識
可以透過以下指標辨識 generic seed
- **Phase 1 faces / traces 比例低**< 5 fpt
- **被分配到大量短 trace**(表示非連續場景)
- **avg_seed_sim 偏低但 face count 異常高**
### 2. Propagation 是雙面刃
Rust pipeline 的 propagation 可以增加 recall但前提是 seed 要夠純。Generic seed + propagation = snowball。
### 3. Seed 數量 vs 品質
> 不是 identity 越多越好。11 個好 seed 勝過 22 個(含 7 個壞的)。
壞 seed 會攔截好 seed 的配對。排除壞 seed 後,那些臉自然會配到正確的人。
### 4. Centroid Matching 的適用條件
Centroid matching 只有在以下情況才有效:
- Centroid 來自高信賴的 Phase 1 配對threshold >= 0.55
- Centroid 的 Phase 1 base set > 200 faces
- 搭配 seed_sim dual gate 防止 centroid 飄移
### 5. Trace Context 的重要性
- 一個 trace = 同一人face tracker 保證)
- Trace-level conflict cleanup 是必要的後處理
- 但無法解決 trace 層級以下(同一 trace 內)的 contamination
## 可改進的方向
### 短期
1. **手動檢查 Cary Grant 的 5,101 faces**avg_sim 0.497 偏低,部分可能是假陽性
2. **補回已被排除的 identity**:對 Bernard Musson 等用更高 threshold如 0.60 seed只看能否 match 到少數高信賴臉
3. **降低 Ambiguity Gate threshold**:從 0.04 降到 0.03 可再清除一批邊緣配對
### 中期
4. **多 seed 策略**:對每個 identity 用 3-5 張 TMDb 照片,取 centroid 作為 seed
5. **場景約束**:利用 shot boundary 資訊限制跨場景的 identity 分配
6. **雙向驗證**:同時用 face→identity 和 identity→trace 兩種方向互相驗證
### 長期
7. **取代 pgvector face-level matching**:改用 trace-level embedding同一 trace 的所有 face 取平均),再對 trace 做 identity 匹配,減少 single-frame noise
## SQL 核心語法
### pgvector Nearest Neighbor
```sql
SELECT fd.id, m.identity_id
FROM eligible fd
CROSS JOIN LATERAL (
SELECT i.id FROM identities i
WHERE 1 - (fd.embedding::vector <=> i.face_embedding) >= {threshold}
ORDER BY 1 - (fd.embedding::vector <=> i.face_embedding) DESC
LIMIT 1
) m
```
### Centroid 計算
```sql
CREATE TABLE centroids AS
SELECT identity_id, AVG(embedding::vector) as centroid
FROM face_detections
WHERE file_uuid = '{uuid}' AND identity_id IS NOT NULL
GROUP BY identity_id
HAVING COUNT(*) >= 5;
```
### Trace Conflict Cleanup
```sql
WITH conflict_traces AS (
SELECT trace_id FROM face_detections
WHERE file_uuid = '{uuid}' AND identity_id IS NOT NULL
GROUP BY trace_id HAVING COUNT(DISTINCT identity_id) > 1
),
trace_majority AS (
SELECT DISTINCT ON (ct.trace_id) ct.trace_id, fd.identity_id
FROM conflict_traces ct
JOIN face_detections fd ON fd.trace_id = ct.trace_id
WHERE fd.file_uuid = '{uuid}' AND fd.identity_id IS NOT NULL
GROUP BY ct.trace_id, fd.identity_id
ORDER BY ct.trace_id, COUNT(*) DESC
)
UPDATE face_detections fd SET identity_id = NULL
FROM trace_majority tm
WHERE fd.file_uuid = '{uuid}' AND fd.trace_id = tm.trace_id
AND fd.identity_id != tm.identity_id;
```
### Ambiguity Gate
```sql
WITH all_sims AS (
SELECT fd.id, c.identity_id,
1 - (fd.embedding::vector <=> c.centroid) as sim
FROM face_detections fd
CROSS JOIN centroids c
WHERE fd.file_uuid = '{uuid}' AND fd.identity_id IS NOT NULL
),
ranked AS (
SELECT id, sim, LEAD(sim) OVER (PARTITION BY id ORDER BY sim DESC) as sim2
FROM all_sims
),
ambiguous AS (
SELECT id FROM ranked
WHERE rn = 1 AND sim - COALESCE(sim2, 0) < 0.04
)
UPDATE face_detections fd SET identity_id = NULL
FROM ambiguous a WHERE fd.id = a.id;
```
## 資料庫備份
每次關鍵操作都有備份:
| Backup | Rows | 內容 |
|--------|:----:|:------|
| `fd_charade_bak` | 62,298 | 原始無 identity 的 Charade face_detections |
| `fd_state_bak2` | 24,286 | V5 執行前的 assignment snapshot |
| `wp_snippets_backup_20260601_11940.sql` | — | WordPress snippets 備份 |

2
docs_v1.0/API_WORKSPACE/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
_build/
.DS_Store

View File

@@ -0,0 +1,60 @@
# API Workspace
## Purpose
This directory is the **single source of truth** for all API documentation modules.
Generated outputs go to `../GUIDES/` as assembled deliverable documents.
## Workflow
```bash
# 1. Edit a module
vim modules/09_tmdb.md
# 2. Preview the generated output
make _build/API_ENDPOINTS.md
# 3. Check diff against current GUIDES/ content
make check
# 4. Deploy to GUIDES/
make deploy
# 5. Regenerate all
make all
```
## Directory Structure
```
API_WORKSPACE/
├── modules/ ← 11 module files (01_auth ... 11_error_codes)
├── configs/ ← 7 assembly recipies (.toml)
├── narratives/ ← narrative intros for specific output files
├── _build/ ← generated output (gitignored)
├── Makefile ← build targets
├── assemble_docs.sh ← assembly engine
└── README.md
```
## Available `make` Targets
| Target | Output |
|--------|--------|
| `make reference` | `_build/API_REFERENCE.md` |
| `make endpoints` | `_build/API_ENDPOINTS.md` |
| `make quickref` | `_build/API_QUICK_REFERENCE.md` |
| `make errors` | `_build/API_ERROR_CODES.md` |
| `make index` | `_build/API_INDEX.md` |
| `make marcom` | `_build/API_TRAINING_MARCOM.md` |
| `make tmdb` | `_build/TMDb_User_Guide.md` |
| `make all` | All of the above |
| `make deploy` | Copy `_build/*``../GUIDES/` |
| `make check` | `diff` against existing `../GUIDES/` files |
## Adding a New Endpoint
1. Add the endpoint to the appropriate module (e.g., `modules/XX_files.md`)
2. Follow the template in `modules/_template.md`
3. `make all && make check`
4. `make deploy`

View File

@@ -7,7 +7,7 @@
### `POST /api/v1/search/smart`
**Auth**: Required
**Scope**: file-level
**Scope**: global / 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.
@@ -15,13 +15,22 @@ Semantic vector search using EmbeddingGemma-300m. Generates a query embedding vi
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `file_uuid` | string | Yes | — | File UUID to search within |
| `query` | string | Yes | — | Search text |
| `file_uuid` | string | No | — | File UUID to search within. If omitted, searches all files (global search) |
| `limit` | integer | No | 5 | Max results to return |
| `page` | integer | No | 1 | Page number |
| `page_size` | integer | No | 5 | Items per page |
#### Example
#### Example (Global Search)
```bash
curl -s -X POST "$API/api/v1/search/smart" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $JWT" \
-d '{"query": "Audrey Hepburn"}'
```
#### Example (File-specific Search)
```bash
curl -s -X POST "$API/api/v1/search/smart" \
@@ -37,6 +46,7 @@ curl -s -X POST "$API/api/v1/search/smart" \
"query": "Audrey Hepburn",
"results": [
{
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"parent_id": 1087822,
"scene_order": 1087822,
"start_frame": 104438,
@@ -54,12 +64,16 @@ curl -s -X POST "$API/api/v1/search/smart" \
}
```
| Field | Type | Description |
|-------|------|-------------|
| `results[].file_uuid` | string | File UUID where result was found |
---
### `POST /api/v1/search/universal`
**Auth**: Required
**Scope**: file-level
**Scope**: global / file-level
Multi-type BM25 full-text search across chunks, frames, and persons. Uses PostgreSQL `tsvector`.
@@ -68,13 +82,22 @@ Multi-type BM25 full-text search across chunks, frames, and persons. Uses Postgr
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `query` | string | Yes | — | Search text |
| `file_uuid` | string | No | — | Restrict to specific file |
| `file_uuid` | string | No | — | Restrict to specific file. If omitted, searches all files (global search) |
| `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
#### Example (Global Search)
```bash
curl -s -X POST "$API/api/v1/search/universal" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $JWT" \
-d '{"query": "Cary Grant"}'
```
#### Example (File-specific Search)
```bash
curl -s -X POST "$API/api/v1/search/universal" \
@@ -90,6 +113,7 @@ curl -s -X POST "$API/api/v1/search/universal" \
"results": [
{
"type": "chunk",
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"chunk_id": "bd80fec92b0b6963d177a2c55bf713e2_2",
"chunk_type": "story_child",
"start_frame": 5103,
@@ -98,6 +122,25 @@ curl -s -X POST "$API/api/v1/search/universal" \
"end_time": 213.64,
"text": "[213s-214s] Cary Grant: \"Olá!\"",
"score": 0.9
},
{
"type": "frame",
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"frame_number": 5105,
"timestamp": 212.72,
"score": 0.7,
"objects": null,
"ocr_texts": null,
"faces": null
},
{
"type": "person",
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"identity_id": 12,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"name": "Cary Grant",
"appearance_count": 542,
"score": 0.95
}
],
"total": 20,
@@ -105,23 +148,78 @@ curl -s -X POST "$API/api/v1/search/universal" \
}
```
| Field | Type | Description |
|-------|------|-------------|
| `results[].type` | string | Result type: `chunk`, `frame`, or `person` |
| `results[].file_uuid` | string | File UUID where result was found (all types) |
---
### `POST /api/v1/search/frames`
**Auth**: Required
**Scope**: file-level
**Scope**: global / file-level
Search face detection frames by identity name or trace ID.
---
### `POST /api/v1/search/identity_text`
### `GET /api/v1/search/identity_text`
**Auth**: Required
**Scope**: file-level
**Scope**: global / file-level
Search text chunks spoken by a specific identity.
Search text chunks → find associated identities. Returns chunks where face detections overlap with text content.
#### Query Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `q` | string | Yes | — | Search text (ILIKE match) |
| `file_uuid` | string | No | — | Restrict to specific file. If omitted, searches all files (global search) |
| `limit` | integer | No | 50 | Max results |
| `page` | integer | No | 1 | Page number |
| `page_size` | integer | No | 50 | Items per page |
#### Example (Global Search)
```bash
curl -s "$API/api/v1/search/identity_text?q=love" -H "X-API-Key: $KEY"
```
#### Example (File-specific Search)
```bash
curl -s "$API/api/v1/search/identity_text?file_uuid=$FILE_UUID&q=love" -H "X-API-Key: $KEY"
```
#### Response (200)
```json
{
"success": true,
"total": 5,
"results": [
{
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"chunk_id": "llm_parent_..._256_270",
"start_time": 256.256,
"end_time": 270.228,
"text_content": "...lack of affection...",
"identity_id": 9,
"identity_name": "Audrey Hepburn",
"identity_source": "tmdb",
"trace_id": 94
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `results[].file_uuid` | string | File UUID where chunk was found |
| `results[].identity_id` | integer | Identity ID if face was detected |
| `results[].trace_id` | integer | Face trace ID |
---
@@ -145,4 +243,4 @@ Search text chunks spoken by a specific identity.
| **Storage** | pgvector (`chunk.embedding` column) |
---
*Updated: 2026-05-19 12:49:24*
*Updated: 2026-05-27 — Added global search support for smart, universal, identity_text APIs*

View File

@@ -70,7 +70,16 @@ curl -s "$API/api/v1/identity/$IDENTITY_UUID" -H "X-API-Key: $KEY"
**Auth**: Required
**Scope**: identity-level
Delete an identity permanently.
Delete an identity permanently. All face detections bound to this identity are unbound (`identity_id` set to `NULL`). The identity JSON file is deleted from disk.
#### History & Undo/Redo
Every DELETE records a full snapshot of the identity and its unbound faces. See [`14_identity_history.md`](14_identity_history.md#4-delete-history--undoredo) for:
- Undo via `POST /api/v1/identity/:identity_uuid/undo` — recreates identity and re-binds faces
- Redo via `POST /api/v1/identity/:identity_uuid/redo` — re-deletes the identity
**Note**: Delete undo/redo reuses the same endpoints as PATCH undo/redo. The endpoint automatically detects whether the identity was deleted (undo) or needs to be re-deleted (redo) based on the history record.
---
@@ -129,124 +138,75 @@ curl -s -X PATCH "$API/api/v1/identity/$IDENTITY_UUID" \
| HTTP | When |
|------|------|
| `400` | No fields to update or invalid UUID format |
| `404` | Identity not found |
| `500` | Database error |
#### History & Undo/Redo
Every bind records a before/after snapshot. See [`14_identity_history.md`](14_identity_history.md#2-bindunbindtrace-history--undoredo) for:
- `POST /api/v1/identity/:identity_uuid/bind/undo` — Revert a bind
- `POST /api/v1/identity/:identity_uuid/bind/redo` — Reapply an undone bind
- `GET /api/v1/identity/:identity_uuid/bind/history` — Query bind operations
---
### `GET /api/v1/identity/:identity_uuid/files`
## Metadata (Embedded JSON)
**Auth**: Required
**Scope**: identity-level
The `identities.metadata` column is a **JSONB** field that stores arbitrary structured data alongside the identity's core fields (name, status, identity_type). No schema is enforced — any valid JSON object is accepted.
Get all files where this identity appears. Returns per-file summary including face count, confidence, and appearance time range.
### Merge Behavior
#### Example
| Operation | Strategy | Example |
|-----------|----------|---------|
| **PATCH** | Shallow top-level merge: `COALESCE(metadata,'{}'::jsonb) \|\| $1::jsonb` | Sending `{"tmdb_rating": 8.5}` only adds/overwrites `tmdb_rating`; all other existing keys are preserved. |
| **mergeinto** | Recursive deep merge — nested sub-keys are merged individually, not replaced wholesale | Target has `{"tmdb": {"biography": "..."}}`, source has `{"tmdb": {"birthday": "1904-01-18"}}` → result is `{"tmdb": {"biography": "...", "birthday": "1904-01-18"}}`. |
| **Upload (`POST`)** | Direct overwrite — the entire `metadata` field is replaced with the request value. | |
```bash
curl -s "$API/api/v1/identity/$IDENTITY_UUID/files" -H "X-API-Key: $KEY"
```
### Validation
---
| Scenario | Result |
|----------|--------|
| PATCH with non-object metadata (`string`, `array`, `number`, `null`) | `400 Bad Request: "metadata must be a JSON object"` |
| mergeinto with non-object metadata | Accepted (mergeinto validates at application level) |
| Upload with non-object metadata | Accepted (upload replaces directly) |
### `GET /api/v1/identity/:identity_uuid/faces`
### Conventional Keys
**Auth**: Required
**Scope**: identity-level
| Key | Type | Writer | Purpose |
|-----|------|--------|---------|
| `aliases` | `[{locale, name}]` | PATCH, mergeinto | Multilingual display names (see [Alias System](#alias-system-bcp-47-locale-tags)) |
| `merged_into` | `{uuid, at}` | mergeinto | Marks an identity as merged (undo mechanism reads this) |
| `tmdb_*` | various | TMDb probe | Movie metadata (biography, birthday, known_for, etc.). Written only when `MOMENTRY_TMDB_PROBE_ENABLED=true`. |
| `source` | string | mergeinto | Tagged on aliases/metadata when added by merge (`"merge"` value) |
Get all face detection records associated with this identity.
Custom keys are fully supported — no registration required.
#### Example
### Search Coverage
```bash
curl -s "$API/api/v1/identity/$IDENTITY_UUID/faces" -H "X-API-Key: $KEY"
```
The identity search endpoint (`GET /api/v1/identity/search`) matches across three scopes:
| 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 |
1. `i.name` — exact and ILIKE against display name
2. `jsonb_array_elements(i.metadata->'aliases')->>'name'` — locale-tagged alias names
3. `i.metadata::text ILIKE $1` — raw string search across the entire JSON blob (all keys, all values)
---
This means searching for `"1904-01-18"` or `"biography"` will match identities whose metadata contains those strings anywhere.
### `GET /api/v1/identity/:identity_uuid/chunks`
### History Snapshots
**Auth**: Required
**Scope**: identity-level
Every `identity_history` record captures the **full metadata** in both `before_snapshot` and `after_snapshot` (as part of the complete identity JSONB dump). Undo restores the identity row — including metadata — to the `before_snapshot` state.
Get all text chunks (sentences) spoken while this identity's face was on screen. Useful for finding what a person said.
For merge operations, the MongoDB merge history records `metadata_fields_added` and `metadata_fields_added_paths` (dot-separated paths like `"tmdb.biography"`). Merge undo removes only those specific paths, preserving subsequent manual edits to other metadata keys.
#### Example
### Best Practices
```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}`) |
#### Side Effects
- 清除該 face detection row 的 `stranger_id`(設為 NULL
- 不影響 `identities` 表中原有的 stranger auto-identity 記錄
#### 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"}'
```
| Guideline | Reason |
|-----------|--------|
| Deep nesting is allowed in metadata | All metadata merge operations use `jsonb_deep_merge()` — nested sub-keys are merged recursively, not replaced wholesale |
| Use `aliases` for display names | Frontend has built-in locale fallback logic (see [Alias System](#alias-system-bcp-47-locale-tags)) |
| Avoid >1MB per identity | Metadata is included in search indexing (`metadata::text ILIKE`); large blobs degrade query performance |
| Don't rely on metadata ordering | JSONB preserves insertion order but PostgreSQL does not guarantee it across operations |
| No LLM/Gemma4 agent writes to metadata | Only API endpoints (PATCH, mergeinto, upload) and TMDb probe modify `identities.metadata` |
---
@@ -295,6 +255,10 @@ curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/bind/trace" \
| `404` | Identity not found |
| `500` | Database error |
#### History & Undo/Redo
Trace bind operations share the same history/undo/redo system as single-face binds. See [`14_identity_history.md`](14_identity_history.md#2-bindunbindtrace-history--undoredo) for endpoints.
---
### `GET /api/v1/identity/:identity_uuid/traces`
@@ -382,6 +346,13 @@ Unbind a face detection from an identity. Removes the identity association from
- 被 unbind 的 face 不會自動成為 stranger
- 要重新標記為 stranger 需重新跑 Agent API`identity/analyze`
#### History & Undo/Redo
Unbind records a before/after snapshot. See [`14_identity_history.md`](14_identity_history.md#2-bindunbindtrace-history--undoredo) for:
- `POST /api/v1/identity/:identity_uuid/bind/undo` — Revert an unbind
- `POST /api/v1/identity/:identity_uuid/bind/redo` — Reapply an undone unbind
---
### `POST /api/v1/identity/:identity_uuid/mergeinto`
@@ -391,6 +362,13 @@ Unbind a face detection from an identity. Removes the identity association from
Transfer all face bindings from this identity to another identity, then optionally delete or mark the source as merged.
#### Two Merge Cases
| Case | Description | Undo/Redo Support |
|------|-------------|-------------------|
| **stranger → identity** | Merge an auto-generated stranger identity into a known identity (TMDb or user-defined) | ✅ 24hr undo/redo |
| **identity A → identity B** | Merge two known identities (e.g., duplicate entries) | ✅ 24hr undo/redo |
#### Request Parameters
| Field | Type | Required | Default | Description |
@@ -402,8 +380,12 @@ Transfer all face bindings from this identity to another identity, then optional
- 轉移所有 `face_detections.identity_id` 到目標 identity
- 同時清除所有被轉移 rows 的 `stranger_id`
- 將 source name 加入 target aliases (with `source: "merge"` tag)
- 將 source aliases 加入 target aliases (if not already present)
- 將 source metadata fields 加入 target metadata (if not already present)
- `keep_history: true`預設source identity 設為 `status='merged'`,保留記錄
- `keep_history: false`**刪除** source identity 及其 identity JSON 檔案
- **記錄 merge history 到 MongoDB**(支援 undo/redo
#### Example
@@ -411,7 +393,7 @@ Transfer all face bindings from this identity to another identity, then optional
curl -s -X POST "$API/api/v1/identity/$SOURCE_UUID/mergeinto" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"into_uuid": "'"$TARGET_UUID"'", "keep_history": false}'
-d '{"into_uuid": "'"$TARGET_UUID"'", "keep_history": true}'
```
#### Response (200)
@@ -419,11 +401,23 @@ curl -s -X POST "$API/api/v1/identity/$SOURCE_UUID/mergeinto" \
```json
{
"success": true,
"message": "Merged 'stranger_13894' into 'Louis Viret' (52 faces transferred, source deleted)",
"data": { "faces_transferred": 52 }
"message": "Merged 'stranger_13894' into 'Louis Viret' (52 faces transferred, history kept)",
"data": {
"merge_id": "550e8400-e29b-41d4-a716-446655440000",
"faces_transferred": 52,
"aliases_added": 1,
"metadata_fields_added": 2
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `merge_id` | string | Unique merge operation ID (for undo) |
| `faces_transferred` | integer | Number of face detections transferred |
| `aliases_added` | integer | Number of aliases added to target |
| `metadata_fields_added` | integer | Number of metadata fields added to target |
#### Error Responses
| HTTP | When |
@@ -433,25 +427,189 @@ curl -s -X POST "$API/api/v1/identity/$SOURCE_UUID/mergeinto" \
---
### `GET /api/v1/identities/search`
### `POST /api/v1/identity/merge/:merge_id/undo`
**Auth**: Required
**Scope**: identity-level
Search identities by name (ILIKE search). Returns matching identity records.
Undo a merge operation within 24 hours. Restores the source identity and reverts face bindings.
#### Undo Behavior
| Action | Description |
|--------|-------------|
| Restore source identity | If `keep_history=true`: restore status to `confirmed`<br>If `keep_history=false`: recreate identity from MongoDB snapshot |
| Restore faces | Transfer faces back to source identity |
| Remove aliases from target | Remove aliases with `source: "merge"` tag |
| Remove metadata fields from target | Remove fields that were added from source |
| **Preserve manual changes** | Keep aliases/metadata manually added after merge |
#### Example
```bash
curl -s "$API/api/v1/identities/search?q=Cary" -H "X-API-Key: $KEY"
curl -s -X POST "$API/api/v1/identity/merge/550e8400-e29b-41d4-a716-446655440000/undo" \
-H "X-API-Key: $KEY"
```
#### Response (200)
```json
{
"success": true,
"message": "Undo merge completed: 'stranger_13894' restored, 52 faces reverted",
"data": {
"source_identity_restored": {
"uuid": "a9a90105...",
"name": "stranger_13894",
"status": "confirmed"
},
"faces_reverted": 52,
"aliases_removed_from_target": 1,
"metadata_fields_removed_from_target": 2
}
}
```
#### Error Responses
| HTTP | When |
|------|------|
| `400` | Undo deadline expired (>24hr) or already undone |
| `404` | Merge record not found |
| `500` | Database error |
---
### `POST /api/v1/identity/merge/:merge_id/redo`
**Auth**: Required
**Scope**: identity-level
Redo a previously undone merge operation. See [`14_identity_history.md`](14_identity_history.md#post-apiv1identitymergemerge_idredo) for full details.
---
### `GET /api/v1/identity/merge/history`
**Auth**: Required
**Scope**: identity-level
Query merge history records from MongoDB.
#### Query Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `source_uuid` | string | No | — | Filter by source identity UUID |
| `target_uuid` | string | No | — | Filter by target identity UUID |
| `merge_id` | string | No | — | Filter by specific merge ID |
| `undone` | bool | No | — | Filter by undone status |
| `page` | int | No | 1 | Page number |
| `page_size` | int | No | 20 | Items per page |
#### Example
```bash
curl -s "$API/api/v1/identity/merge/history?page=1&page_size=10" \
-H "X-API-Key: $KEY"
```
#### Response (200)
```json
{
"success": true,
"total": 5,
"page": 1,
"page_size": 10,
"results": [
{
"merge_id": "550e8400-e29b-41d4-a716-446655440000",
"source_name": "stranger_13894",
"target_name": "Louis Viret",
"faces_transferred": 52,
"merged_at": "2026-05-27T10:00:00Z",
"undo_deadline": "2026-05-28T10:00:00Z",
"undone": false,
"undo_expired": false
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Identity name |
| `source` | string | Identity source |
| `tmdb_id` | integer | TMDb ID (if source = tmdb) |
| `file_uuid` | string | Associated file |
| `merge_id` | string | Unique merge operation ID |
| `source_name` | string | Source identity name |
| `target_name` | string | Target identity name |
| `faces_transferred` | integer | Number of faces transferred |
| `merged_at` | datetime | When merge occurred |
| `undo_deadline` | datetime | 24hr deadline for undo |
| `undone` | bool | Whether merge was undone |
| `undo_expired` | bool | Whether undo deadline passed |
---
### `GET /api/v1/identities/search`
**Auth**: Required
**Scope**: global / file-level
Search identity name → find associated chunks. Searches identity name and aliases, returns identities with their associated text chunks.
#### Query Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `q` | string | Yes | — | Search text (ILIKE match on name and aliases) |
| `file_uuid` | string | No | — | Restrict to specific file. If omitted, searches all files (global search) |
| `limit` | integer | No | 50 | Max results |
#### Example (Global Search)
```bash
curl -s "$API/api/v1/identities/search?q=Audrey" -H "X-API-Key: $KEY"
```
#### Example (File-specific Search)
```bash
curl -s "$API/api/v1/identities/search?q=Audrey&file_uuid=$FILE_UUID" -H "X-API-Key: $KEY"
```
#### Response (200)
```json
{
"success": true,
"total": 5,
"results": [
{
"identity_id": 9,
"name": "Audrey Hepburn",
"source": "tmdb",
"tmdb_id": 1932,
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"trace_id": 41,
"chunk_id": "llm_parent_..._204_207",
"start_time": 204.162,
"text_content": "...confrontation..."
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `results[].identity_id` | integer | Identity ID |
| `results[].name` | string | Identity name |
| `results[].source` | string | Identity source (`tmdb`, `user_defined`, etc.) |
| `results[].tmdb_id` | integer | TMDb person ID (if source = tmdb) |
| `results[].file_uuid` | string | File where identity appears |
| `results[].trace_id` | integer | Face trace ID |
| `results[].chunk_id` | string | Associated chunk ID |
| `results[].start_time` | float | Chunk start time |
| `results[].text_content` | string | Chunk text content |
---
@@ -628,4 +786,4 @@ PATCH /api/v1/identity/:identity_uuid
This **replaces** the entire `aliases` array. To add to existing aliases, include all existing entries in the request.
---
*Updated: 2026-05-25
*Updated: 2026-05-25 — Added `GET /api/v1/file/:file_uuid/faces` with 4 binding states, filters, strangers table split

View File

@@ -0,0 +1,696 @@
<!-- module: identity_history -->
<!-- description: Identity operation history, undo, and redo (PATCH, bind, unbind, bind_trace, mergeinto) -->
<!-- depends: 01_auth, 07_identity -->
## Identity Operation History
Every mutation on an identity automatically records a before/after snapshot. Use undo/redo to revert or reapply changes, and history to inspect the operation log.
Three independent undo/redo systems exist:
| System | Storage | Operations Covered |
|--------|---------|-------------------|
| **PATCH** | PostgreSQL `identity_history` | `update` |
| **Bind** | PostgreSQL `identity_history` | `bind`, `unbind`, `bind_trace` |
| **Merge** | MongoDB `identity_merge_history` | mergeinto |
| **Delete** | PostgreSQL `identity_history` | `delete` |
---
### 1. PATCH History & Undo/Redo
#### Overview
| Property | Value |
|----------|-------|
| Storage | PostgreSQL `identity_history` table |
| Snapshot | Full identity record (all fields) before and after each PATCH |
| Max records | 256 per identity (oldest auto-deleted when limit exceeded) |
| Undo steps | Unlimited (no expiry, no step limit) |
| Redo stack | Cleared on new PATCH (`is_undone=true` + `operation='update'` records are deleted) |
##### Stack Model
```
PATCH 1 → PATCH 2 → PATCH 3 (undo stack, is_undone=false)
↓ undo
PATCH 1 → PATCH 2 (undo stack)
PATCH 3 (redo stack, is_undone=true)
↓ redo
PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
```
A new PATCH after undo clears only the operation='update' redo stack (PATCH 3 is lost). Bind/merge redo stacks are not affected.
---
#### `POST /api/v1/identity/:identity_uuid/undo`
**Auth**: Required
**Scope**: identity-level
Undo the most recent PATCH operations. Restores the identity's `before_snapshot` and marks the history records as undone.
##### Request (JSON)
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `steps` | integer | No | `1` | Number of undo steps to apply (max records undone in one call) |
##### Behavior
- Queries `is_undone=false` records with `operation='update'`, ordered by `created_at DESC`
- Restores `name`, `identity_type`, `source`, `status`, `metadata`, `tmdb_id`, `tmdb_profile` from the last record's `before_snapshot`
- Marks the undone records as `is_undone=true` with `undone_at=NOW()`
- Syncs `identity.json` to disk
- Updates `_index.json` if name changed
##### Example
```bash
curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/undo" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"steps": 1}'
```
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"undone_count": 1,
"current_state": {
"id": 9,
"uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"name": "Cary Grant",
"identity_type": "people",
"source": "tmdb",
"status": "confirmed",
"metadata": {},
"tmdb_id": 112,
"tmdb_profile": null
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `undone_count` | integer | Number of history records undone |
| `current_state` | object | Full identity state after undo |
##### Error Responses
| HTTP | When |
|------|------|
| `400` | No undo operations available |
| `404` | Identity not found |
| `500` | Database error |
---
#### `POST /api/v1/identity/:identity_uuid/redo`
**Auth**: Required
**Scope**: identity-level
Redo previously undone PATCH operations. Restores the identity's `after_snapshot` and marks the history records as no longer undone.
##### Request (JSON)
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `steps` | integer | No | `1` | Number of redo steps to apply |
##### Behavior
- Queries `is_undone=true` records with `operation='update'`, ordered by `created_at DESC`
- Restores all identity fields from the last record's `after_snapshot`
- Marks records as `is_undone=false` with `undone_at=NULL`
- Syncs `identity.json` to disk
- Updates `_index.json` if name changed
##### Example
```bash
curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/redo" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"steps": 1}'
```
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"redone_count": 1,
"current_state": {
"id": 9,
"uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"name": "John Smith",
"identity_type": "people",
"source": "tmdb",
"status": "confirmed",
"metadata": { "aliases": [...] },
"tmdb_id": 112,
"tmdb_profile": null
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `redone_count` | integer | Number of history records redone |
| `current_state` | object | Full identity state after redo |
##### Error Responses
| HTTP | When |
|------|------|
| `400` | No redo operations available |
| `404` | Identity not found |
| `500` | Database error |
---
#### `GET /api/v1/identity/:identity_uuid/history`
**Auth**: Required
**Scope**: identity-level
Query the PATCH operation history for an identity. Returns paginated records with undo/redo stack counts (filtered to `operation='update'`).
##### Query Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | integer | No | `1` | Page number (1-indexed) |
| `limit` | integer | No | `20` | Items per page (max 100) |
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"total": 5,
"undo_stack_count": 3,
"redo_stack_count": 2,
"results": [
{
"history_id": 42,
"operation": "update",
"is_undone": false,
"created_at": "2026-05-27T12:00:00Z",
"undone_at": null
},
{
"history_id": 41,
"operation": "update",
"is_undone": true,
"created_at": "2026-05-27T11:30:00Z",
"undone_at": "2026-05-27T13:00:00Z"
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `total` | integer | Total PATCH history records for this identity |
| `undo_stack_count` | integer | Records available for undo (`is_undone=false`) |
| `redo_stack_count` | integer | Records available for redo (`is_undone=true`) |
| `results[].history_id` | integer | History record ID |
| `results[].operation` | string | Operation type (`"update"` for PATCH) |
| `results[].is_undone` | boolean | Whether the operation has been undone |
| `results[].created_at` | string | When the PATCH was applied |
| `results[].undone_at` | string | When the undo occurred (null if not undone) |
##### Example
```bash
curl -s "$API/api/v1/identity/$IDENTITY_UUID/history?page=1&limit=10" \
-H "X-API-Key: $KEY"
```
##### Error Responses
| HTTP | When |
|------|------|
| `404` | Identity not found |
| `500` | Database error |
---
### 2. Bind/Unbind/Trace History & Undo/Redo
All three operations (`bind`, `unbind`, `bind_trace`) share a single history table and undo/redo stack.
#### Bind Operation Overview
| Property | Value |
|----------|-------|
| Storage | PostgreSQL `identity_history` table (same table as PATCH) |
| Snapshot | `{"file_uuid", "face_id" (or "trace_id"), "identity_id_before/after"}` |
| Max records | 256 per identity (shared limit across all operation types) |
| Undo steps | Unlimited (`steps` param) |
| Redo stack | Cleared on new bind/unbind/bind_trace (`operation IN ('bind','unbind','bind_trace')` + `is_undone=true` records deleted) |
| Stack isolation | Bind redo stack is **independent** from PATCH redo stack — clearing one does not affect the other |
##### Stack Model
```
bind face_1 (to id=9) → unbind face_1 → bind trace 906 (to id=9)
(undo stack, is_undone=false) (undo stack) (undo stack)
↓ undo (first undone: bind_trace)
bind trace 906 (is_undone=true)
(redo stack)
↓ redo
bind face_1 → unbind face_1 → bind trace 906
(undo stack)
```
A new bind/unbind/trace after undo clears only the bind redo stack (operations with `IN ('bind','unbind','bind_trace')`).
##### Snapshot Format
**Before (bind):**
```json
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_5",
"identity_id_before": null
}
```
**After (bind):**
```json
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_5",
"identity_id_after": 9
}
```
**Before (unbind) — binding existed before:**
```json
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_5",
"identity_id_before": 9
}
```
**After (unbind):**
```json
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_5",
"identity_id_after": null
}
```
For `bind_trace`, the snapshot uses `trace_id` instead of `face_id`, with `identity_id_before` capturing the first face's identity in that trace.
---
#### `POST /api/v1/identity/:identity_uuid/bind/undo`
**Auth**: Required
**Scope**: identity-level
Undo the most recent bind/unbind/bind_trace operations. Restores `identity_id_before` from the snapshot and marks records as undone.
##### Request (JSON)
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `steps` | integer | No | `1` | Number of undo steps to apply |
##### Behavior
- Queries `is_undone=false` records with `operation IN ('bind','unbind','bind_trace')`, ordered by `created_at DESC`
- Restores `identity_id_before` — for bind this is `null` (face was unbound), for unbind this is the original identity (face goes back), for bind_trace this is the trace's previous identity
- Marks the undone records as `is_undone=true` with `undone_at=NOW()`
##### Example
```bash
curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/bind/undo" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"steps": 1}'
```
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"operation": "bind",
"undone_count": 1,
"affected_rows": 53
}
```
| Field | Type | Description |
|-------|------|-------------|
| `operation` | string | The actual operation undone (`bind`, `unbind`, or `bind_trace`) |
| `undone_count` | integer | Number of history records undone |
| `affected_rows` | integer | Number of `face_detections` rows updated |
##### Error Responses
| HTTP | When |
|------|------|
| `400` | No bind undo operations available |
| `404` | Identity not found |
| `500` | Database error |
---
#### `POST /api/v1/identity/:identity_uuid/bind/redo`
**Auth**: Required
**Scope**: identity-level
Redo previously undone bind/unbind/bind_trace operations. Restores `identity_id_after` from the snapshot.
##### Request (JSON)
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `steps` | integer | No | `1` | Number of redo steps to apply |
##### Behavior
- Queries `is_undone=true` records with `operation IN ('bind','unbind','bind_trace')`, ordered by `created_at DESC`
- Restores `identity_id_after` — for bind this is the identity the face was bound to, for unbind this is `null`
- Marks records as `is_undone=false` with `undone_at=NULL`
##### Example
```bash
curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/bind/redo" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"steps": 1}'
```
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"operation": "unbind",
"redone_count": 1,
"affected_rows": 1
}
```
| Field | Type | Description |
|-------|------|-------------|
| `operation` | string | The actual operation redone (`bind`, `unbind`, or `bind_trace`) |
| `redone_count` | integer | Number of history records redone |
| `affected_rows` | integer | Number of `face_detections` rows updated |
##### Error Responses
| HTTP | When |
|------|------|
| `400` | No bind redo operations available |
| `404` | Identity not found |
| `500` | Database error |
---
#### `GET /api/v1/identity/:identity_uuid/bind/history`
**Auth**: Required
**Scope**: identity-level
Query the bind/unbind/bind_trace operation history for an identity. Returns paginated records with undo/redo stack counts.
##### Query Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | integer | No | `1` | Page number (1-indexed) |
| `limit` | integer | No | `20` | Items per page (max 100) |
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"total": 3,
"undo_stack_count": 2,
"redo_stack_count": 1,
"results": [
{
"history_id": 52,
"operation": "bind_trace",
"is_undone": false,
"created_at": "2026-05-27T14:00:00Z",
"undone_at": null
},
{
"history_id": 51,
"operation": "unbind",
"is_undone": true,
"created_at": "2026-05-27T13:00:00Z",
"undone_at": "2026-05-27T14:30:00Z"
},
{
"history_id": 50,
"operation": "bind",
"is_undone": false,
"created_at": "2026-05-27T12:00:00Z",
"undone_at": null
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `total` | integer | Total bind history records for this identity |
| `undo_stack_count` | integer | Records available for undo (`is_undone=false`) |
| `redo_stack_count` | integer | Records available for redo (`is_undone=true`) |
| `results[].history_id` | integer | History record ID |
| `results[].operation` | string | Operation type (`bind`, `unbind`, or `bind_trace`) |
| `results[].is_undone` | boolean | Whether the operation has been undone |
| `results[].created_at` | string | When the operation was applied |
| `results[].undone_at` | string | When the undo occurred (null if not undone) |
##### Example
```bash
curl -s "$API/api/v1/identity/$IDENTITY_UUID/bind/history?page=1&limit=10" \
-H "X-API-Key: $KEY"
```
##### Error Responses
| HTTP | When |
|------|------|
| `404` | Identity not found |
| `500` | Database error |
---
### 3. Merge History & Undo/Redo
Merge operations use MongoDB for richer record-keeping, with a 24-hour undo deadline.
#### Merge Operation Overview
| Property | Value |
|----------|-------|
| Storage | MongoDB `identity_merge_history` collection |
| Snapshot | Full source identity state + target identity state + aliases/metadata diffs |
| Trigger | Every mergeinto with `keep_history=true` |
| Undo deadline | 24 hours (renewed on redo) |
| Redo support | Yes — restores undone merges with new 24hr deadline |
| Max records | Unlimited |
---
#### `POST /api/v1/identity/merge/:merge_id/undo`
Already documented in [`07_identity.md`](07_identity.md#post-apiv1identitymergemerge_idundo). See that document for full details.
---
#### `POST /api/v1/identity/merge/:merge_id/redo`
**Auth**: Required
**Scope**: identity-level
Redo a previously undone merge operation within the renewed 24-hour deadline.
##### Request
No body required. The merge ID is taken from the URL path.
##### Behavior
1. Validates the merge record exists and `undone=true` (not already active)
2. Checks the 24-hour undo deadline (if expired, the redo is rejected)
3. Restores face bindings: moves all faces from `target_identity` back to `source_identity`
4. Re-adds aliases that were removed by the undo (aliases with `source: "merge"` tag)
5. Re-adds metadata fields that were removed by the undo
6. If `keep_history=true`: sets `source_identity.status = 'merged'` again
7. If `keep_history=false`: recreates source identity from the `undone_snapshot` stored at undo time
8. Syncs both identity JSON files to disk
9. Sets `undone=false`, clears `undone_snapshot`, renews `undo_deadline = NOW() + 24h`
10. Records `redone_by` user for audit
##### Example
```bash
curl -s -X POST "$API/api/v1/identity/merge/550e8400-e29b-41d4-a716-446655440000/redo" \
-H "X-API-Key: $KEY"
```
##### Response (200)
```json
{
"success": true,
"message": "Redo merge completed: merged 'stranger_13894' into 'Louis Viret' (52 faces transferred)",
"data": {
"merge_id": "550e8400-e29b-41d4-a716-446655440000",
"faces_transferred": 52,
"aliases_re_added": 1,
"metadata_fields_re_added": 2
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `merge_id` | string | The merge operation ID |
| `faces_transferred` | integer | Number of faces transferred from source to target |
| `aliases_re_added` | integer | Number of aliases restored to target |
| `metadata_fields_re_added` | integer | Number of metadata fields restored to target |
##### Error Responses
| HTTP | When |
|------|------|
| `400` | Merge not undone, deadline expired, or cannot redo |
| `404` | Merge record not found |
| `500` | Database error |
---
### 4. Delete History & Undo/Redo
#### Delete Operation Overview
| Property | Value |
|----------|-------|
| Storage | PostgreSQL `identity_history` table |
| Snapshot | `{"identity": {...full row...}, "unbound_faces": [{file_uuid, face_id, trace_id}, ...]}` |
| Max records | 1 active delete record per identity (redo stack cleared on new delete) |
| Undo support | Yes — recreates identity row, re-binds faces |
| Redo support | Yes — re-deletes the identity |
| Identity file | Deleted on delete, recreated on undo |
#### Snapshot Format
```json
{
"identity": {
"id": 9,
"uuid": "a9a90105-6d6b-46ff-92da-0c3c1a57dff4",
"name": "Cary Grant",
"identity_type": "people",
"source": "tmdb",
"status": "confirmed",
"metadata": {},
"tmdb_id": 112,
"tmdb_profile": null
},
"unbound_faces": [
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_5",
"trace_id": null
},
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_6",
"trace_id": 906
}
]
}
```
#### Stack Model
```
DELETE identity (undo stack, is_undone=false)
↓ undo
Identity recreated, faces re-bound
→ delete history marked is_undone=true
↓ redo (re-delete)
Identity deleted again, faces unbound
→ delete history marked is_undone=false
```
A new delete after an undo clears the delete redo stack (no redo possible for the old delete).
#### Undo Behavior (via existing `POST /api/v1/identity/:identity_uuid/undo`)
1. Normal identity lookup fails (row was deleted)
2. Checks `identity_history` for `operation='delete' AND is_undone=false` matching the UUID in the snapshot
3. Recreates the identity row (new internal `id`, same UUID)
4. Re-binds all faces listed in `unbound_faces` to the new identity
5. Deletes the `identity_history` delete record as `is_undone=true` with `undone_at=NOW()`
6. Syncs `identity.json` to disk
7. Updates `_index.json`
#### Redo Behavior (via existing `POST /api/v1/identity/:identity_uuid/redo`)
1. Identity lookup succeeds (identity was restored by prior undo)
2. Checks `identity_history` for `operation='delete' AND is_undone=true` matching the identity_id
3. Deletes `identity.json` from disk
4. Unbinds all faces (`identity_id = NULL`)
5. Deletes the identity row
6. Marks the delete history record as `is_undone=false`
7. Returns success
#### Error Responses (delete undo/redo)
| HTTP | Scenario |
|------|----------|
| `400` | No delete history available (either no delete or already undone/redone) |
| `404` | Identity not found (for redo — identity wasn't restored) |
| `500` | Database error |
---
### Comparison: PATCH vs Bind vs Merge vs Delete Undo/Redo
| Aspect | PATCH Undo/Redo | Bind Undo/Redo | Merge Undo/Redo | Delete Undo/Redo |
|--------|----------------|----------------|-----------------|------------------|
| Storage | PostgreSQL `identity_history` | PostgreSQL `identity_history` | MongoDB `identity_merge_history` | PostgreSQL `identity_history` |
| Operation filter | `operation='update'` | `operation IN ('bind','unbind','bind_trace')` | — | `operation='delete'` |
| Trigger | Every PATCH | Every bind/unbind/bind_trace | Every mergeinto with `keep_history=true` | Every DELETE |
| Undo deadline | None (unlimited) | None (unlimited) | 24 hours (renewed on redo) | None (unlimited) |
| Redo support | Yes | Yes | Yes | Yes |
| Step undo | Yes (`steps` param) | Yes (`steps` param) | No (full undo/redo only) | No (single record) |
| Max records | 256 per identity | 256 per identity (shared) | Unlimited | 256 per identity (shared) |
| User tracking | `user_id` + `user_source` | `user_id` + `user_source` | `performed_by_user` + `undone_by` / `redone_by` | `user_id` + `user_source` |
---
*Updated: 2026-05-28*

View File

@@ -0,0 +1,36 @@
<!-- narrative: marcom_intro -->
<!-- description: Intro section for Marcom training manual -->
<!-- depends: -->
## About This Manual
This training manual is designed for the Marcom team to understand and use the Momentry Core API.
### Demo Credentials
**API Key**: `muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69`
**SFTPGo** (for video upload):
| Item | Value |
|------|-------|
| SFTP Host | `sftpgo.momentry.ddns.net` |
| SFTP Port | `2022` |
| Username | `demo` |
| Password | `demopassword123` |
| Web UI | `https://sftpgo.momentry.ddns.net` |
### Quick Examples
**List all videos:**
```bash
curl -s -H "X-API-Key: $KEY" "$API/api/v1/files/scan"
```
**Search:**
```bash
curl -s -X POST "$API/api/v1/search" \
-H "Content-Type: application/json" \
-H "X-API-Key: $KEY" \
-d '{"query": "example", "limit": 5}'
```

View File

@@ -0,0 +1,588 @@
# ASRX Hybrid Pipeline v1.0 — 聲紋分離混合架構
| 項目 | 內容 |
|------|------|
| **範圍** | ASRX 處理器重構whisperx → VAD-first hybrid pipeline |
| **狀態** | Draft |
| **適用版本** | Momentry Core V4.0+ |
| **作者** | OpenCode / Warren |
| **建立日期** | 2026-06-01 |
---
## 1. 問題
### 1.1 現有問題
| 問題 | 說明 | 影響 |
|------|------|------|
| **Whisper 合併短句** | `whisper small` 會將兩個人的對話錯認成一個連續段 (A+B → 一句) | ASR segment 內混兩人話語speaker 無法分離 |
| **ASRX v2 speaker_id = null** | `asrx_processor_v2.py` 使用 `whisperx.DiarizationPipeline()` 但該 API 未在 whisperx `__init__.py` 暴露 | 所有 segment speaker 均為 null |
| **文字丟失** | `asrx_processor_custom.py``SelfASRXFixed.process_with_segments()` 只輸出 `text: ""` | Rule 1 合併時無文字可用 |
| **錯誤的聲紋後端** | `asrx_processor_v2.py` 依賴 whisperx 內建 diarization但該功能不穩定 | 準確度 ~85%,需 HF token |
| **多版本混亂** | 7 個 root-level 變體、14 個 asrx_self 檔案,生產環境使用錯誤版本 | 維護困難,不知哪個是對的 |
### 1.2 痛點場景
**兩個說話人短句來回切換**(訪談、對話):
```
Audio: A(2s) → B(1.5s) → A(3s)
Whisper: ───────[0-7s, "A+B+A 全部混在一起"]───────
```
Whisper 在句間停頓處不切段,導致 ASR 時間邊界無法反映 speaker 切換。
---
## 2. 架構
### 2.1 核心原則
1. **VAD 先定邊界** — 用 VAD 在句間停頓處切段,取代 whisper 的邊界
2. **ASR 後做** — 每段各自轉錄,保有獨立文字
3. **聲紋聚類定 speaker** — ECAPA-TDNN + AgglomerativeClustering
### 2.2 5 步 Pipeline
```
Audio
① whisper (一次, 粗略定位)
│ 找到說話段 + 初步文字 + 語種
│ [0-7s, "今天天氣很好我覺得也不錯對啊", zh]
② VAD scan (在每段內細切)
│ 利用句間停頓切開
│ 段1 [0-2s] 段2 [2-3.5s] 段3 [3.5-7s]
③ whisper per refined segment (各段轉錄)
│ 段1 → "今天天氣很好" (zh, 0.98)
│ 段2 → "我覺得也不錯" (zh, 0.97)
│ 段3 → "對啊" (zh, 0.96)
④ ECAPA-TDNN per refined segment (聲紋提取)
│ 段1 → emb[0] (192-dim)
│ 段2 → emb[1] (192-dim)
│ 段3 → emb[2] (192-dim)
⑤ AgglomerativeClustering (聚類定 speaker)
│ emb[0]=SPEAKER_0, emb[1]=SPEAKER_1, emb[2]=SPEAKER_0
輸出:
start end text language speaker_id
0.0 2.0 今天天氣很好 zh SPEAKER_0
2.0 3.5 我覺得也不錯 zh SPEAKER_1
3.5 7.0 對啊 zh SPEAKER_0
```
### 2.3 流程圖
```
┌─────────────────────────────────────────────────────────────────────┐
│ asrx_processor.py │
│ (wrapper) │
│ │
│ ① ffprobe → select best track → ffmpeg → 16kHz WAV │
│ │
│ ② SelfASRXFixed.process(audio_wav, file_uuid) │
│ │ │
│ ├─ Step 1: whisper.transcribe() → rough segments │
│ ├─ Step 2: VAD scan each rough segment │
│ ├─ Step 3: whisper per refined segment → text+language │
│ ├─ Step 4: ECAPA-TDNN per segment → 192-dim embedding │
│ ├─ Step 5: AgglomerativeClustering → speaker_labels │
│ │ │
│ ├─ Step 6: Store embeddings in Qdrant │
│ │ └─ {file_uuid, speaker_id, text, language, start, end} │
│ │ │
│ └─ Step 7: Classify high-quality embeddings │
│ ├─ quality > threshold → reference profile │
│ ├─ 送入聲音分類模型推論性別/屬性 │
│ └─ 寫入 Qdrant (type: speaker_reference) │
│ │
│ ③ 輸出 JSON 格式 (不含 embedding) │
│ │
│ Rust: rule1_ingest.rs │
│ └─ pre_chunks(processor_type='asrx') → chunks │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 3. 檔案組織
### 3.1 最終檔案結構
```
scripts/
├── asrx_processor.py ← production (cleaned custom.py)
└── asrx_self/ ← 核心庫
├── __init__.py ← package marker
├── vad.py ← Silero VAD (新增 scan_within_segment)
├── whisper_local.py ← 🆕 封裝 whisper 載入+轉錄
├── speaker_encoder.py ← ECAPA-TDNN 192-dim
├── speaker_cluster_fixed.py ← AgglomerativeClustering
└── main_fixed.py ← 🔧 重寫為 5 步 pipeline
```
### 3.2 刪除清單
**Root-level 變體**(全部刪除):
| 檔案 | 原因 |
|------|------|
| `asrx_processor.py` | 原始 whisperx 版diarization 壞的 |
| `asrx_processor_v2.py` | 同上Rust 目前錯誤呼叫此檔 |
| `asrx_processor_v2_noalign.py` | 跳過對齊但 diarization 仍壞 |
| `asrx_processor_v2_transcribe.py` | 只轉錄不做 speaker |
| `asrx_processor_simplified.py` | 變體 |
| `asrx_processor_contract_v1.py` | 18KBpyannote需 HF token |
**asrx_self 內被取代的舊版**
| 檔案 | 原因 | 取代者 |
|------|------|--------|
| `main.py` | 用 SpectralClustering有 NaN 問題 | `main_fixed.py` |
| `speaker_cluster.py` | 用 SpectralClustering不穩定 | `speaker_cluster_fixed.py` |
### 3.3 搬離清單
非生產工具搬至 `tools/asrx/`
```
tools/asrx/
├── integrate_face_asrx_speaker.py
├── speaker_player_gui.py
├── speaker_player_gui_face.py
├── speaker_player_interactive.py
├── speaker_audio_player.py
├── test_long_movie.py
├── test_gui_face_player.py
└── docs/
├── FINAL_TEST_REPORT.md
├── GUI_FACE_PLAYER_USAGE.md
├── LONG_MOVIE_TEST_SUMMARY.md
└── SPEAKER_PLAYER_GUIDE.md
```
---
---
## 4. Qdrant 聲紋向量儲存
### 4.1 儲存流程
```
Step 4 輸出: 每個 refined segment 有 {embedding: [192-dim], text, language, start, end}
Step 5 輸出: 每個 segment 被標上 speaker_id {SPEAKER_0, SPEAKER_1, ...}
Step 6: Qdrant 儲存
┌─ 每個 segment → Qdrant point
│ point_id = hash(file_uuid + segment_index) ← 可重複查詢
│ vector = embedding (192-dim)
│ payload = {
│ "file_uuid": str, ← 聚類後填入
│ "speaker_id": str, ← 聚類後填入
│ "text": str, ← ASR 轉錄結果
│ "language": str, ← 語種 (zh/en/...)
│ "start_time": f64, ← 秒
│ "end_time": f64, ← 秒
│ "type": "speaker_embedding" ← 便於區分
│ }
└─
```
### 4.2 Qdrant Collection
| 項目 | 內容 |
|------|------|
| Collection Name | `momentry_speaker` (或共用現有 collection) |
| Vector Dimension | 192 (ECAPA-TDNN 輸出) |
| Distance Metric | Cosine |
| Point ID | `hash(file_uuid + "_" + segment_index)` |
### 4.3 Rust `upsert_speaker_embedding`
```rust
impl QdrantDb {
pub async fn upsert_speaker_embedding(
&self,
point_id: u64,
vector: &[f32],
file_uuid: &str,
speaker_id: &str,
text: &str,
language: &str,
start_time: f64,
end_time: f64,
) -> Result<()> {
// Qdrant PUT /collections/{collection}/points?wait=true
// payload: {file_uuid, speaker_id, text, language, start_time, end_time, type: "speaker_embedding"}
}
}
```
### 4.4 與現有 Face Embedding 的關係
| 類別 | Qdrant Collection | Dim | Payload |
|------|-------------------|-----|---------|
| Face | `momentry` (self.collection_name) | 512 (FaceNet) | `file_uuid, trace_id, frame_number` |
| **Speaker** | `momentry` 或獨立 collection | **192** (ECAPA-TDNN) | `file_uuid, speaker_id, text, language, start, end` |
---
## 5. 模組詳細設計
### 5.1 `vad.py` — 語音活動檢測
| 項目 | 內容 |
|------|------|
| 模型 | Silero VAD (torch.hub, snakers4/silero-vad) |
| 現有函數 | `load_vad_model()`, `extract_speech_segments()` |
| **新增函數** | **`scan_within_segment(wav, start_sec, end_sec, model, utils, min_speech_duration_ms=500)`** |
`scan_within_segment` 作用:
- 在一個時間範圍 `[start_sec, end_sec]` 內執行 VAD 掃描
- 只回傳該範圍內的語音子片段 `[(s1, e1), (s2, e2), ...]`
- 利用句間停頓切分,解決 whisper 合併問題
### 5.2 `whisper_local.py` 🆕 — Whisper 封裝
| 項目 | 內容 |
|------|------|
| 模型 | `whisper.load_model("base")` (可設定) |
| 函數 | `load_model()`, `transcribe_segment(audio, start, end)` |
```python
def transcribe_segment(wav, sample_rate, start_sec, end_sec, model) -> dict:
"""轉錄單一段落,回傳 {text, language, lang_prob, segments}"""
```
每段獨立轉錄,保留語言與信心度。
### 5.3 `speaker_encoder.py` — 聲紋編碼器
| 項目 | 內容 |
|------|------|
| 模型 | SpeechBrain ECAPA-TDNN (`spkrec-ecapa-voxceleb`) |
| 輸出維度 | 192-dim |
| EER | 0.80% (VoxCeleb1) |
| 授權 | MIT (不需要 HuggingFace token) |
| 函數 | `load_speaker_encoder()`, `extract_speaker_embedding()`, `extract_speaker_embeddings_batch()` |
### 5.4 `speaker_cluster_fixed.py` — 說話人聚類
| 項目 | 內容 |
|------|------|
| 演算法 | AgglomerativeClustering (cosine + average linkage) |
| 取代 | `speaker_cluster.py` (SpectralClustering, NaN 問題) |
| 函數 | `robust_speaker_clustering(embeddings, n_speakers=None, max_speakers=10)` |
### 5.5 `main_fixed.py` 🔧 — 核心調度器7 步 Pipeline
```python
class SelfASRXFixed:
def process(self, audio_path, output_path=None, file_uuid=None):
"""
7 步 speaker diarization pipeline
Steps:
1. whisper.transcribe(audio) → rough segments + text + language
2. VAD scan each rough segment → refined segments
3. whisper per refined segment → {text, language, lang_prob}
4. ECAPA-TDNN per refined segment → 192-dim embeddings
5. AgglomerativeClustering → speaker_labels
6. Store all embeddings in Qdrant (if file_uuid provided)
payload: {file_uuid, speaker_id, text, language, start_time, end_time, type: "speaker_embedding"}
7. High-quality embeddings (quality > threshold) → classify + store reference
payload: {type: "speaker_reference", file_uuid, speaker_id, n_segments, avg_quality, ...}
Returns:
{
"segments": [
{
"start": float, "end": float,
"text": str, "language": str,
"lang_prob": float, "speaker": str,
"speaker_id": str, "quality": float
},
...
],
"speaker_stats": {...},
"n_speakers": int,
"total_duration": float,
"references": [
{
"speaker_id": str,
"n_segments": int,
"avg_quality": float,
"gender": str
}
]
}
"""
def _store_speaker_embeddings(self, segments, file_uuid):
"""Step 6: 每個 segment 的 192-dim embedding 存入 Qdrant"""
def _classify_high_quality_speakers(self, segments, embeddings, labels, file_uuid):
"""Step 7: 高品質聲紋分級 + 分類 → Qdrant reference profile"""
**移除**
| 舊方法 | 原因 |
|--------|------|
| `process_with_segments(audio, asr_segments)` | 外部 ASR 邊界來源不可靠 VAD 取代 |
| `process()` VAD-only fallback | 無文字輸出被完整 pipeline 取代 |
### 5.6 `speaker_classifier.py` 🆕 — 高品質聲紋分級與分類
#### 目的
聚類後對每個 cluster embedding 進行品質評估高於閾值的獨立建檔並用外部模型做自動分類
#### 流程
```
Step ⑤ 聚類後,每個 segment 有 {embedding, speaker_id}
└─ Compute quality score per embedding
├─ 低於閾值 → 寫入 Qdrant (一般 speaker_embedding)
└─ 高於閾值 (quality > 0.85)
├─ 獨立建 reference profile
└─ 送入「支持聲音的模型」做分類
├─ 語者性別 (male/female)
├─ 語種口音 (zh-CN / zh-TW / en-US)
└─ 或跨影片 speaker 匹配用
```
#### Quality Score 計算
```python
def compute_embedding_quality(embeddings, labels, threshold=0.85):
"""
每個 embedding 到所屬 cluster centroid 的餘弦相似度
Args:
embeddings: [n_segments, 192]
labels: [n_segments] 聚類標籤
threshold: 高品質門檻
Returns:
qualities: [n_segments] 每個 embedding 的品質分數
high_quality_mask: [n_segments] bool 陣列
"""
from sklearn.metrics.pairwise import cosine_similarity
unique_labels = set(labels)
centroids = {}
for label in unique_labels:
mask = labels == label
centroid = np.mean(embeddings[mask], axis=0)
centroid = centroid / np.linalg.norm(centroid)
centroids[label] = centroid
qualities = []
for i, (emb, label) in enumerate(zip(embeddings, labels)):
sim = cosine_similarity([emb], [centroids[label]])[0][0]
qualities.append(sim)
return np.array(qualities), np.array(qualities) >= threshold
```
#### Reference Profile 格式
```json
{
"point_id": "hash(speaker_reference_" + file_uuid + "_" + speaker_id + "_" + cluster_index)",
"vector": "[192-dim centroid embedding]",
"payload": {
"type": "speaker_reference",
"file_uuid": "",
"speaker_id": "SPEAKER_0",
"n_segments": 25,
"avg_quality": 0.92,
"total_duration": 45.3,
"language": "zh",
"gender": "male",
"text_samples": ["", "", "..."]
}
}
```
#### 支援的聲音分類模型(選項)
| 模型 | 用途 | 優點 | 缺點 |
|------|------|------|------|
| **SpeechBrain gender classifier** | 性別分類 | 已整合 ECAPA-TDNN | 只分 male/female |
| **CLAP** (LAION) | 零樣本音頻分類 | 可自訂 label text | 需額外安裝 |
| **YAMNet** | 聲音事件分類 | Google 出品521 classes | 不擅長語者屬性 |
| **Wav2Vec2-BERT** (speechbrain) | 情感/屬性 | 多維度分類 | 模型較大 |
| **自建 identity classifier** | 跨影片 speaker 匹配 | 與現有 identity 系統對接 | 需累積 reference data |
> **待決定**: 選擇哪個分類模型,由後續 POC 決定。
#### `main_fixed.py` 新增方法
```python
class SelfASRXFixed:
# ... 既有 6 個步驟 ...
def _classify_high_quality_speakers(self, segments, embeddings, labels, file_uuid):
"""
步驟 7: 高品質聲紋分級與分類
1. 計算 quality score
2. 高於閾值者建立 reference profile
3. 用分類模型推論性別/屬性
4. 寫入 Qdrant (type: speaker_reference)
"""
qualities, mask = compute_embedding_quality(embeddings, labels)
for i, (seg, emb, label, quality, is_high) in enumerate(
zip(segments, embeddings, labels, qualities, mask)
):
seg["quality"] = float(quality)
if is_high:
profile = self._build_reference_profile(
emb, seg, file_uuid
)
# 分類 (placeholder)
# gender = classify_gender(embedding)
self._store_speaker_reference(profile)
```
### 5.7 `asrx_processor.py` — 清理後的 wrapper
清理項目:
| 問題 | 位置 | 修法 |
|------|------|------|
| 硬編碼 UUID `dd61fda8...` | line 155 | 移除該 fallback path |
| `os.chdir(script_dir)` | line 112 | 改區域性 Path 操作 |
| ASR 文字丟棄 | line 258 | `text` 來自新 pipeline |
| `_debug` dict | line 222 | 移除 |
| `max_speakers=10` 寫死 | line 201 | 改 CLI 參數 `--max-speakers` |
| 載入外部 ASR segments | line 148-174 | 移除(不再需要) |
---
## 6. 輸出格式
### 6.1 ASRX JSON Output (由 `asrx_processor.py` 寫入)
> **注意**: 192-dim embedding 不在此 JSON 中。embedding 在 Python 端直接送入 QdrantJSON 只保留中繼資料。
```json
{
"language": "zh",
"segments": [
{
"start_time": 0.0,
"end_time": 2.0,
"start_frame": 0,
"end_frame": 60,
"text": "今天天氣很好",
"speaker_id": "SPEAKER_0",
"language": "zh",
"lang_prob": 0.98
},
{
"start_time": 2.0,
"end_time": 3.5,
"start_frame": 60,
"end_frame": 105,
"text": "我覺得也不錯",
"speaker_id": "SPEAKER_1",
"language": "zh",
"lang_prob": 0.97
}
],
"n_speakers": 2,
"speaker_stats": {
"SPEAKER_0": {"count": 1, "duration": 2.0},
"SPEAKER_1": {"count": 1, "duration": 1.5}
}
}
```
### 6.2 Qdrant Point 格式 (由 Python `_store_speaker_embeddings` 寫入)
> Embedding 不經過 Rust直接在 Python 端完成 Qdrant HTTP PUT。
| Qdrant 欄位 | 值 | 說明 |
|-------------|-----|------|
| `id` | `hash(file_uuid + "_" + segment_index)` | 可重複查詢的 point ID |
| `vector` | `[f32; 192]` | ECAPA-TDNN 聲紋向量 |
| `payload.file_uuid` | `str` | 影片識別碼 |
| `payload.speaker_id` | `str` | 聚類後的 speaker 標籤 |
| `payload.text` | `str` | 該段的轉錄文字 |
| `payload.language` | `str` | 語種 (`zh`/`en`) |
| `payload.start_time` | `f64` | 開始時間(秒) |
| `payload.end_time` | `f64` | 結束時間(秒) |
| `payload.type` | `"speaker_embedding"` | 便於與 face_embedding 區分 |
### 6.3 Rust `AsrxResult` 對應
```rust
pub struct AsrxSegment {
pub start_time: f64, // serde(alias = "start")
pub end_time: f64, // serde(alias = "end")
pub start_frame: u64, // default 0
pub end_frame: u64, // default 0
pub text: String,
pub speaker_id: Option<String>,
pub language: Option<String>, // 🆕 新增
pub lang_prob: Option<f64>, // 🆕 新增
}
```
---
## 7. Rust 端變動
| 檔案 | 變動 |
|------|------|
| `src/core/processor/asrx.rs` | `asrx_processor_v2.py``asrx_processor.py` |
| `src/core/processor/asrx.rs` | `AsrxSegment` 新增 `language`, `lang_prob` 欄位 |
| `src/core/processor/asrx.rs` | 傳遞 `--file-uuid` 給 Python 腳本,讓 Python 端可直接寫入 Qdrant |
| `src/core/chunk/rule1_ingest.rs` | 若 `pre_chunks` data 含 `language` 則帶入 chunk metadata |
| `src/core/db/qdrant_db.rs` | 🆕 新增 `upsert_speaker_embedding()` 方法 (可選,若 Python 端直接寫 Qdrant 則不需) |
---
## 8. 遷移計畫
### 實作順序 (依賴關係排序)
| 步驟 | 內容 | 檔案 | 風險 |
|------|------|------|------|
| **S1** | `vad.py`: 新增 `scan_within_segment()` | `asrx_self/vad.py` | 低 |
| **S2** | 🆕 `whisper_local.py`: 封裝 whisper 載入 + 轉錄 | `asrx_self/whisper_local.py` | 低 |
| **S3** | 🔧 `main_fixed.py`: 重寫為 7 步 pipeline | `asrx_self/main_fixed.py` | 中 |
| **S4** | 🆕 `speaker_classifier.py`: 性別分類器 | `asrx_self/speaker_classifier.py` | 低 |
| **S5** | 🔧 `custom.py` cleanup + rename → `asrx_processor.py` | `asrx_processor_custom.py` | 低 |
| **S6** | 🔧 Rust `asrx.rs`: 改指向 + 傳 `--file-uuid` | `src/core/processor/asrx.rs` | 低 |
| **S7** | ✅ 驗證build + playground 測試 | — | 中 |
| **S8** | 🧹 刪除變體 + 搬離工具 | — | 低 |
### 驗證標準
1. `cargo build` 通過
2. Playground 3003: 註冊影片 → ASRX processor 完成
3. 輸出 JSON 中 `speaker_id``null`
4. Qdrant collection 有 `speaker_embedding`
5. 性別正確標記 (male/female)
---
## 9. 版本歷史
| 版本 | 日期 | 修改者 | 說明 |
|------|------|--------|------|
| V1.0 | 2026-06-01 | OpenCode | 初始版本7 步 hybrid pipeline + Qdrant 聲紋儲存 + 高品質分類 |

View File

@@ -0,0 +1,385 @@
---
document_type: "design"
service: "MOMENTRY_CORE"
title: "模組生成式文件產出系統"
date: "2026-05-17"
version: "V1.0"
status: "active"
owner: "M5"
created_by: "OpenCode"
tags:
- "documentation"
- "modular"
- "generated-docs"
- "workspace"
ai_query_hints:
- "查詢模組生成式文件產出系統的設計理念"
- "如何使用 API_WORKSPACE"
- "如何新增 API endpoint 文檔"
- "make deploy 流程"
- "自定義交付文件"
related_documents:
- "STANDARDS/USER_DOCS_STANDARD.md"
- "STANDARDS/DOCS_STANDARD.md"
- "API_WORKSPACE/README.md"
- "API_WORKSPACE/modules/_template.md"
---
# 模組生成式文件產出系統
| 項目 | 內容 |
|------|------|
| 建立者 | OpenCode |
| 建立時間 | 2026-05-17 |
| 文件版本 | V1.0 |
| 目標讀者 | developer, documentation maintainer |
---
## 版本歷史
| 版本 | 日期 | 目的 | 操作人 |
|------|------|------|--------|
| V1.0 | 2026-05-17 | 建立設計文件 | OpenCode |
---
## 1. 設計理念
### 1.1 痛點
傳統 API 文件維護有常見問題:
| 問題 | 具體表現 |
|------|----------|
| **內容重複** | 同一個 endpoint 在快速參考、完整手冊、教育訓練文件中寫三次 |
| **更新遺漏** | 修改 curl 範例後,忘記同步到另一份文件 |
| **交付僵化** | 無法按對象產出不同版本的 API 文件 |
| **版本失靈** | YAML frontmatter 版本號與實際內容脫節 |
### 1.2 核心原則
```
單一真理源modules/)→ 組裝引擎assemble_docs.sh→ 多種交付產品GUIDES/
編輯 ──→ 生成 ──→ 部署
1 處修改模組 make all make deploy
```
| 原則 | 說明 |
|------|------|
| **單一真理源** | 每個 endpoint 只在 `modules/` 中定義一次 |
| **組裝而非撰寫** | 交付文件是 modules 的組合,不是手寫 |
| **開發與交付分離** | `API_WORKSPACE/` 開發,`GUIDES/` 交付 |
| **模組為最小可測試單位** | 每個 module 可獨立驗證正確性 |
| **配置驅動** | `.toml` 配置定義哪些 module 以何種模式組裝成何種輸出 |
### 1.3 檔案類型對照
| 類型 | 角色 | 可編輯 | 位置 |
|------|------|--------|------|
| Module (模組) | 不可再拆的內容最小單位 | ✅ 是 | `API_WORKSPACE/modules/` |
| Config (配方) | 定義組裝規則 | ✅ 是 | `API_WORKSPACE/configs/` |
| Narrative (敘事) | 非結構化的前言/背景 | ✅ 是 | `API_WORKSPACE/narratives/` |
| Assembled (產出) | 從模組組裝的交付文件 | ❌ 否generated | `API_WORKSPACE/_build/``GUIDES/` |
---
## 2. 目錄結構
```
docs_v1.0/
├── API_WORKSPACE/ ← 開發區
│ ├── modules/ ← 端點模組(單一真理源)
│ │ ├── _template.md ← 模組撰寫規範
│ │ ├── 01_auth.md ← 認證、Base URL
│ │ ├── 02_health.md ← 健康檢查
│ │ ├── 03_register.md ← 註冊、掃描
│ │ ├── 04_lookup.md ← 查詢、刪除
│ │ ├── 05_process.md ← 處理、進度、任務
│ │ ├── 06_search.md ← 搜尋向量、n8n、視覺
│ │ ├── 07_identity.md ← 身份 CRUD、bind/unbind
│ │ ├── 08_identity_agent.md ← Identity Agent
│ │ ├── 09_tmdb.md ← TMDb Enrichment
│ │ ├── 10_pipeline.md ← Stats、配置、未掛載端點
│ │ └── 11_error_codes.md ← 錯誤碼對照表
│ │
│ ├── configs/ ← 組裝配方(每個輸出一份)
│ │ ├── reference.toml → API_REFERENCE.md
│ │ ├── endpoints.toml → API_ENDPOINTS.md
│ │ ├── quickref.toml → API_QUICK_REFERENCE.md
│ │ ├── errors.toml → API_ERROR_CODES.md
│ │ ├── index.toml → API_INDEX.md
│ │ ├── marcom.toml → API_TRAINING_MARCOM.md
│ │ └── tmdb.toml → TMDb_User_Guide.md
│ │
│ ├── narratives/ ← 非端點敘事前言
│ │ └── marcom_intro.md
│ │
│ ├── _build/ ← 生成暫存區gitignored
│ ├── Makefile ← 組裝自動化入口
│ ├── assemble_docs.sh ← 組裝引擎
│ └── README.md ← 開發者速查
├── GUIDES/ ← 交付區
│ ├── API_REFERENCE.md (generated)
│ ├── API_ENDPOINTS.md (generated)
│ ├── API_QUICK_REFERENCE.md (generated)
│ ├── API_ERROR_CODES.md (generated)
│ ├── API_INDEX.md (generated)
│ ├── API_TRAINING_MARCOM.md (generated)
│ ├── TMDb_User_Guide.md (generated)
│ ├── Demo_EndToEnd.md (手寫保留)
│ ├── Pipeline_API_Demo.md (手寫保留)
│ └── ... (其他手寫文件)
├── DESIGN/
├── REFERENCE/
├── OPERATIONS/
├── INTEGRATIONS/
└── STANDARDS/
```
---
## 3. 模組規範
### 3.1 檔名規則
- 格式:`NN_<name>.md`NN = 兩位數排序 01-99
- 範例:`03_register.md`, `09_tmdb.md`
- 依賴序號決定組裝時的 endpoint 順序
### 3.2 Module Metadata 註解
每個 module 開頭必須有 metadata 註解:
```markdown
<!-- module: auth -->
<!-- description: Authentication, API Key, Base URL configuration -->
<!-- depends: -->
```
| 欄位 | 必填 | 說明 |
|------|------|------|
| `module` | Yes | 唯一名稱,無空格無數字開頭 |
| `description` | Yes | 一句話說明 |
| `depends` | No | 依賴的其他 module 名稱(逗號分隔) |
### 3.3 Endpoint 結構
每個 endpoint 必須使用一致結構:
```markdown
### `METHOD /path/to/endpoint`
**Auth**: Required / Optional / Public
**Scope**: file-level / identity-level / system-level
#### Request Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
#### Example
```bash
curl -s -X METHOD "$API/path" \
-H "X-API-Key: $KEY" \
-d '{"field": "value"}'
```
#### Response (200)
```json
{ ... }
```
#### Error Codes
| Code | HTTP | When |
|------|------|------|
```
```
### 3.4 變數規則
| 變數 | 用途 | 範例值 |
|------|------|--------|
| `$API` | Base URL | `http://localhost:3003` |
| `$KEY` | API Key | `your-api-key-here` |
| `$FILE_UUID` | File UUID | `3a6c1865...` |
| `$IDENTITY_UUID` | Identity UUID | `a9a90105...` |
---
## 4. 組裝引擎
### 4.1 `assemble_docs.sh`
Shell 腳本,接收三個參數:
| 參數 | 說明 | 範例 |
|------|------|------|
| `--config` | TOML 配方路徑 | `configs/reference.toml` |
| `--modules` | Module 目錄 | `modules/` |
| `--build` | 輸出目錄 | `_build/` |
### 4.2 三種組裝模式
| mode | 行為 | 適用 |
|------|------|------|
| `full` | 完整包含 module 全部內容(除 metadata | API_REFERENCE, API_ENDPOINTS |
| `summary` | 僅擷取 endpoint 表格 + curl 範例 | API_QUICK_REFERENCE |
| `index` | 生成文件總覽(掃描 modules 目錄自動產生索引) | API_INDEX |
### 4.3 組裝流程
```
1. 讀取 config.toml → 解析 title, modules, mode, narrative
2. 生成 YAML frontmatter含 document_type, date, version
3. 生成 title heading + info block
4. (可選)摘自 TOC從 modules ## headings 生成目錄
5. (可選)插入 narrative intro
6. 遍歷 modules
- full mode: 複製整份內容(跳過 <!-- --> 註解)
- summary mode: 只提取 | table | + ```bash code block
- index mode: 自動掃描 modules 目錄生成清單
7. 寫入 _build/ 輸出檔案
```
---
## 5. 配方格式config.toml
```toml
title = "輸出文件標題"
output = "_build/FILENAME.md" # 輸出路徑(相對於 API_WORKSPACE
mode = "full" # full | summary | index
modules = ["01_auth", "03_register"] # 要包含的 module 名稱
narrative = "narratives/xxx.md" # (可選)包含的敘事前言
toc = true # (可選)是否生成目錄
[frontmatter]
document_type = "api_reference" # 用於 YAML frontmatter
service = "MOMENTRY_CORE"
version = "V1.0"
owner = "M5"
created_by = "OpenCode"
```
### 內建配方一覽
| 檔案 | 輸出 | Modules | Mode |
|------|------|---------|------|
| `reference.toml` | API_REFERENCE.md | 01-11 | full |
| `endpoints.toml` | API_ENDPOINTS.md | 01-10 | full |
| `quickref.toml` | API_QUICK_REFERENCE.md | 01-06,09 | summary |
| `errors.toml` | API_ERROR_CODES.md | 11 | full |
| `index.toml` | API_INDEX.md | (auto) | index |
| `marcom.toml` | API_TRAINING_MARCOM.md | 01,03,06 + narrative | full |
| `tmdb.toml` | TMDb_User_Guide.md | 01,03,09 | full |
---
## 6. 工作流程
### 6.1 日常修改
```bash
# 1. 編輯模組
cd API_WORKSPACE
vim modules/09_tmdb.md
# 2. 重新生成單一文件
make tmdb
# 3. 預覽結果
less _build/TMDb_User_Guide.md
# 4. 部署
make deploy
```
### 6.2 新增端點
```bash
# 1. 找到所屬模組
ls modules/
# 決定該 endpoint 屬於哪個模組(如 tmdb, identity, search
# 2. 在對應模組加入 endpoint 文檔
vim modules/09_tmdb.md
# 3. 重新生成所有文件
make all
# 4. 確認所有引用此端點的文件都有正確更新
make check
# 5. 部署
make deploy
```
### 6.3 客製化交付
```bash
# 新增一個客製化配方
cat > configs/integration_partner.toml << TOML
title = "Integration Partner API Guide"
output = "_build/PARTNER_GUIDE.md"
mode = "full"
modules = ["01_auth", "06_search", "09_tmdb", "11_error_codes"]
toc = true
[frontmatter]
document_type = "user_manual"
service = "MOMENTRY_CORE"
version = "V1.0"
owner = "M5"
created_by = "OpenCode"
TOML
# 在 Makefile 中加入對應 target
echo "partner:" >> Makefile
echo ' @$$(SCRIPT) --config configs/integration_partner.toml --modules $$(MODULES) --build $$(BUILD)' >> Makefile
# 生成
make partner
# 部署
make deploy
```
---
## 7. 交付客製化對照表
| 對象 | 需要 modules | make target | 輸出 |
|------|-------------|-------------|------|
| API Developer | 01-11 (all) | `make reference` | API_REFERENCE.md |
| Quick Start User | 01-06,09 | `make quickref` | API_QUICK_REFERENCE.md |
| Marcom Team | 01,03,06 + narrative | `make marcom` | API_TRAINING_MARCOM.md |
| TMDb User | 01,03,09 | `make tmdb` | TMDb_User_Guide.md |
| Integration Partner | 01,06,09,11 | Custom config | PARTNER_GUIDE.md |
---
## 8. GUIDES/ 文件類型說明
| 類型 | 來源 | 說明 |
|------|------|------|
| `API_*.md` (7 files) | Generated from API_WORKSPACE | API 功能文件endpoint 列表 + curl 範例 |
| `Demo_*.md`, `M5API_*.md` | 手寫 | 敘事性指引,含完整 step-by-step 流程 |
| `PORTAL_*.md` | 手寫 | Portal 開發計畫與 Demo 指引 |
| `USER_MANUAL.md` | 手寫 | 系統操作使用手冊 |
> **提醒**:不要直接修改 GUIDES/ 中的 generated files。修改應在 API_WORKSPACE/modules/ 中進行,然後執行 `make deploy`。
---
## 相關文件
- `API_WORKSPACE/README.md` — 開發者快速上手指南
- `API_WORKSPACE/modules/_template.md` — 模組撰寫範本
- `STANDARDS/DOCS_STANDARD.md` — 文件創建規範
- `STANDARDS/USER_DOCS_STANDARD.md` — 使用者文件規範

View File

@@ -0,0 +1,484 @@
---
title: Processor State Machine V1.0
version: 1.0
date: 2026-05-30
author: M5Max128
status: draft
---
# Processor State Machine V1.0
## Overview
| Attribute | Value |
|-----------|-------|
| Scope | Backend, Worker, Pipeline |
| Status | Draft |
| Applicable To | M5Max128, M5Max48 |
| Dependencies | migrations/034, job_worker.rs, redis_client.rs |
| Related Docs | [Pipeline Module](../API_WORKSPACE/modules/10_pipeline.md), [TKG Query API](TKG_QUERY_API_V1.0.md) |
---
## 1. Design Goals
### 1.1 Problem Statement
The Momentry Core pipeline lacks unified state management for processors:
- **Opaque dependency chains**: Processors depend on each other (ASR → Cut, ASRX → ASR, Story → ASRX + Cut + YOLO + Face), but failures or delays are not explicitly tracked
- **No alert mechanism**: When dependencies are not met or resources are exhausted, there is no systematic way to notify operators or trigger retries
- **Coarse-grained status**: Existing `pending/running/completed/failed` states do not capture intermediate conditions like "waiting for dependencies" or "ready but not scheduled"
### 1.2 Solution
Introduce a **State Machine** with **Alert Mechanism**:
- **8 explicit states** for each processor job: `Idle → Waiting → Ready → Pending → Running → Completed/Failed/Skipped`
- **Dependency checking**: `check_dependencies()` validates prerequisites before execution
- **Alert emission**: Emit alerts to Redis pub/sub and PostgreSQL for monitoring and debugging
### 1.3 Scope
This design **complements** the existing polling mechanism:
| Component | Responsibility |
|-----------|---------------|
| **State Machine** | Fine-grained processor status management (Idle → Running → Completed) |
| **Polling** | Coarse-grained ingestion verification (Rule 1 chunks exist? Vectorize done? TKG nodes exist?) |
**Non-Goals**:
- Does NOT replace polling for post-processing steps (入庫)
- Does NOT auto-retry failed processors (future evolution)
- Does NOT manage distributed state across workers
---
## 2. State Definitions
### 2.1 Eight States
| State | Semantics | Trigger | Next States |
|-------|-----------|---------|--------------|
| `Idle` | Initial state, no work assigned | Job created | `Waiting` |
| `Waiting` | Dependencies not met, awaiting prerequisites | Dependency check fails | `Ready`, `Failed` |
| `Ready` | Dependencies met, awaiting execution | Dependency check passes | `Pending` |
| `Pending` | Queued for execution, waiting for worker | Scheduler accepts | `Running` |
| `Running` | Currently processing | Worker starts | `Completed`, `Failed`, `Skipped` |
| `Completed` | Success, output valid | Output validated | - (terminal) |
| `Failed` | Error occurred, unrecoverable | Exception or timeout | - (terminal) |
| `Skipped` | Conditional skip (optional processor) | Unmet optional conditions | - (terminal) |
### 2.2 State Transition Examples
**Example 1: ASR depends on Cut**
```
ASR: Idle → Waiting (Cut not completed)
Cut: Running → Completed
ASR: Waiting → Ready (Cut completed) → Pending → Running → Completed
```
**Example 2: Story depends on multiple processors**
```
Story: Idle → Waiting (ASRX not completed)
ASRX: Running → Completed
Story: Waiting → Waiting (Cut not completed)
Cut: Running → Completed
Story: Waiting → Waiting (YOLO not completed)
YOLO: Running → Completed
Story: Waiting → Waiting (Face not completed)
Face: Running → Completed
Story: Waiting → Ready (all dependencies met) → Pending → Running → Completed
```
**Example 3: Optional processor skipped**
```
Pose: Idle → Ready → Pending → Running
Pose: Running → Skipped (no pose detected, optional processing)
```
---
## 3. State Transitions
### 3.1 Transition Diagram
```mermaid
stateDiagram-v2
[*] --> Idle: Job created
Idle --> Waiting: Initialize
Waiting --> Ready: Dependencies met
Waiting --> Failed: Timeout
Ready --> Pending: Scheduled
Pending --> Running: Worker pickup
Running --> Completed: Success
Running --> Failed: Error
Running --> Skipped: Conditional skip
Completed --> [*]
Failed --> [*]
Skipped --> [*]
```
### 3.2 Transition Rules
| From State | To State | Condition | Action |
|------------|-----------|-----------|--------|
| `Idle` | `Waiting` | Always (initial transition) | - |
| `Waiting` | `Ready` | `check_dependencies() == Ok` | - |
| `Waiting` | `Failed` | Timeout (default 7200s) | Emit `timeout` alert |
| `Ready` | `Pending` | Resource available | - |
| `Pending` | `Running` | Worker starts | - |
| `Running` | `Completed` | Output valid | - |
| `Running` | `Failed` | Exception or output invalid | Emit `output_invalid` alert |
| `Running` | `Skipped` | Optional processor, conditions not met | - |
### 3.3 Edge Cases
| Scenario | Detection | Resolution |
|----------|-----------|------------|
| **Circular dependencies** | `check_dependencies()` detects cycle | Mark as `Failed`, emit `dependency_not_met` alert |
| **Resource exhaustion** | GPU/CPU unavailable | Stay in `Waiting`, emit `resource_exhausted` alert |
| **Partial output** | Output validation fails | Mark as `Failed`, emit `output_invalid` alert |
| **Transient failure** | Network/API timeout | Stay in `Waiting`, retry after delay |
---
## 4. Alert Mechanism
### 4.1 Alert Types
| Type | Trigger | Severity | Action |
|------|---------|----------|--------|
| `dependency_not_met` | `check_dependencies()` fails | Warning | Retry after delay |
| `resource_exhausted` | GPU/CPU unavailable | Warning | Wait + retry |
| `output_invalid` | Validation fails | Error | Mark `Failed` |
| `timeout` | Exceeds `MOMENTRY_*_TIMEOUT` | Error | Mark `Failed` |
### 4.2 Alert Flow
```mermaid
sequenceDiagram
participant Worker as job_worker.rs
participant Checker as check_dependencies()
participant Redis as Redis Pub/Sub
participant PostgreSQL as processor_alerts table
Worker->>Checker: check_dependencies(processor, file_uuid)
alt Dependencies not met
Checker-->>Worker: ConditionResult::NotMet(reason)
Worker->>Redis: emit_processor_alert(file_uuid, processor, "dependency_not_met", reason)
Redis-->>PostgreSQL: INSERT INTO processor_alerts
Worker->>Worker: update_status(file_uuid, processor, Waiting)
else Resource exhausted
Checker-->>Worker: ConditionResult::ResourceExhausted
Worker->>Redis: emit_processor_alert(file_uuid, processor, "resource_exhausted", "GPU unavailable")
Redis-->>PostgreSQL: INSERT INTO processor_alerts
Worker->>Worker: update_status(file_uuid, processor, Waiting)
else Output invalid
Checker-->>Worker: ConditionResult::OutputInvalid(reason)
Worker->>Redis: emit_processor_alert(file_uuid, processor, "output_invalid", reason)
Redis-->>PostgreSQL: INSERT INTO processor_alerts
Worker->>Worker: update_status(file_uuid, processor, Failed)
else OK
Checker-->>Worker: ConditionResult::Ok
Worker->>Worker: update_status(file_uuid, processor, Running)
end
```
### 4.3 Redis Channel
- **Channel**: `momentry:processor:alerts`
- **Message Format**:
```json
{
"file_uuid": "bd80fec9c42afb0307eb28f22c64c76a",
"processor": "ASR",
"alert_type": "dependency_not_met",
"message": "Cut not completed",
"timestamp": "2026-05-30T10:15:30Z"
}
```
- **Consumers**: None (current implementation logs only, future: monitoring service)
### 4.4 PostgreSQL Table
**Table**: `processor_alerts` (defined in `migrations/034_processor_state_machine.sql`)
```sql
CREATE TABLE IF NOT EXISTS processor_alerts (
id SERIAL PRIMARY KEY,
file_uuid VARCHAR(32),
processor_type VARCHAR(32) NOT NULL,
alert_type VARCHAR(32) NOT NULL,
message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_alerts_file_uuid ON processor_alerts(file_uuid);
CREATE INDEX idx_alerts_processor_type ON processor_alerts(processor_type);
CREATE INDEX idx_alerts_alert_type ON processor_alerts(alert_type);
CREATE INDEX idx_alerts_created_at ON processor_alerts(created_at);
```
**Retention Policy**: 30 days (TBD, future: implement cleanup job)
---
## 5. Dependency Checking
### 5.1 ConditionResult Enum
Defined in `src/worker/job_worker.rs`:
```rust
pub enum ConditionResult {
Ok, // All dependencies met
NotMet(String), // Missing dependency (reason)
ResourceExhausted, // GPU/CPU unavailable
OutputInvalid(String), // Validation failed (reason)
}
```
### 5.2 check_dependencies() Logic
Defined in `src/worker/job_worker.rs`:
```rust
pub async fn check_dependencies(
processor: ProcessorType,
file_uuid: &str,
db: &PostgresDb,
) -> Result<ConditionResult> {
match processor {
ProcessorType::ASR => {
// Check if Cut is completed
if !db.is_processor_completed(file_uuid, ProcessorType::Cut).await? {
return Ok(ConditionResult::NotMet("Cut not completed".into()));
}
}
ProcessorType::ASRX => {
// Check if ASR is completed
if !db.is_processor_completed(file_uuid, ProcessorType::ASR).await? {
return Ok(ConditionResult::NotMet("ASR not completed".into()));
}
}
ProcessorType::Story => {
// Check if ASRX + Cut + YOLO + Face are completed
let deps = [
ProcessorType::ASRX,
ProcessorType::Cut,
ProcessorType::YOLO,
ProcessorType::Face,
];
for dep in deps {
if !db.is_processor_completed(file_uuid, dep).await? {
return Ok(ConditionResult::NotMet(format!("{:?} not completed", dep)));
}
}
}
ProcessorType::_5W1H => {
// Check if Story is completed
if !db.is_processor_completed(file_uuid, ProcessorType::Story).await? {
return Ok(ConditionResult::NotMet("Story not completed".into()));
}
}
// Other processors have no dependencies
_ => {}
}
Ok(ConditionResult::Ok)
}
```
### 5.3 Integration with job_worker.rs
```rust
// In execute_processor()
let condition = check_dependencies(processor, file_uuid, &db).await?;
match condition {
ConditionResult::Ok => {
// Proceed to Running state
self.update_status(file_uuid, processor, ProcessorJobStatus::Running).await?;
// Execute processor...
}
ConditionResult::NotMet(reason) => {
// Emit alert and mark as Waiting
emit_processor_alert(file_uuid, processor, "dependency_not_met", &reason).await?;
self.update_status(file_uuid, processor, ProcessorJobStatus::Waiting).await?;
}
ConditionResult::ResourceExhausted => {
// Emit alert and mark as Waiting
emit_processor_alert(file_uuid, processor, "resource_exhausted", "GPU unavailable").await?;
self.update_status(file_uuid, processor, ProcessorJobStatus::Waiting).await?;
}
ConditionResult::OutputInvalid(reason) => {
// Emit alert and mark as Failed
emit_processor_alert(file_uuid, processor, "output_invalid", &reason).await?;
self.update_status(file_uuid, processor, ProcessorJobStatus::Failed).await?;
}
}
```
---
## 6. Integration Points
### 6.1 With TKG Builder
- **TKG Builder** is NOT a processor, it's a **post-processing step** (入庫 step 8)
- Triggers after Face Trace is completed
- **State Machine does NOT manage TKG Builder state**
- TKG Builder has its own verification mechanism in polling
### 6.2 With Face Trace
- **Face Trace** is NOT a processor, it's a **post-processing step** (入庫 step 5)
- Triggers after all 10 processors are completed
- **State Machine does NOT manage Face Trace state**
- Face Trace has its own verification mechanism in polling
### 6.3 With 入庫 Flow
| Component | Manages | Scope |
|-----------|---------|-------|
| **State Machine** | Processor states | `Idle → Waiting → Ready → Pending → Running → Completed/Failed/Skipped` |
| **Polling** | Post-processing verification | Rule 1 chunks, Vectorize, TKG nodes, Face Trace, etc. |
**Key Insight**: Two mechanisms are **independent but complementary**:
1. **State Machine**: Granular processor status, handles dependencies
2. **Polling**: Coarse-grained ingestion verification, handles post-processing
### 6.4 Example Flow
```
=== Processor State Machine (per processor) ===
Cut: Idle → Waiting → Ready → Pending → Running → Completed ✓
ASR: Idle → Waiting (Cut not done) → Waiting → Ready → Pending → Running → Completed ✓
YOLO: Idle → Ready → Pending → Running → Completed ✓
Face: Idle → Ready → Pending → Running → Completed ✓
Story: Idle → Waiting (ASRX not done) → Waiting → Ready → Pending → Running → Completed ✓
=== 入庫 Polling (every 3s) ===
[00:00] Check: Rule 1 chunks exist? → No (ASR not done)
[00:03] Check: Rule 1 chunks exist? → Yes ✓
Check: Vectorize done? → Yes ✓
Check: TKG nodes exist? → No (Face Trace not done)
[00:06] Check: TKG nodes exist? → Yes ✓
Check: All 17 steps verified ✓
Mark job as completed
```
---
## 7. Implementation Checklist
### 7.1 Completed ✅
- [x] Migration 034: `processor_alerts` table
- [x] Enum: `ProcessorJobStatus` (8 states) - `postgres_db.rs:585-594`
- [x] Function: `emit_processor_alert()` - `redis_client.rs`
- [x] Function: `check_dependencies()` - `job_worker.rs`
- [x] Enum: `ConditionResult` - `job_worker.rs`
### 7.2 Pending 🔄
- [ ] Tests: State transitions (unit tests)
- [ ] Tests: Alert emission (integration tests)
- [ ] Tests: Dependency checking (unit tests)
- [ ] Monitoring: Alert dashboard (TBD)
- [ ] Retention: `processor_alerts` cleanup job (TBD)
---
## 8. Performance Considerations
### 8.1 Alert Emission
- **Non-blocking**: Redis pub/sub is fire-and-forget
- **Low latency**: < 1ms per alert
- **No retry**: If Redis is down, alert is lost (acceptable for debugging)
### 8.2 Dependency Checking
- **Synchronous DB queries**: `is_processor_completed()` queries PostgreSQL
- **Cacheable**: Results can be cached for 1-3 seconds (TTL based on processor duration)
- **Index usage**: Queries use `idx_processor_jobs_file_uuid_processor_type` index
### 8.3 State Updates
- **Single-row UPDATE**: `UPDATE processor_jobs SET status = $1 WHERE file_uuid = $2 AND processor_type = $3`
- **Index usage**: Uses `idx_processor_jobs_file_uuid_processor_type` index
- **Low contention**: Each processor has its own row
---
## 9. Future Evolution
### 9.1 Phase 1 (Current)
- Alert emission + PostgreSQL logging
- Manual monitoring via `processor_alerts` table
- No auto-retry
### 9.2 Phase 2 (Near-term)
- Alert consumer service (subscribes to Redis channel)
- Auto-retry for `dependency_not_met` and `resource_exhausted` alerts
- Exponential backoff for retries
### 9.3 Phase 3 (Medium-term)
- Event-driven pipeline (replace polling with Redis Streams)
- Real-time status updates via WebSocket
- Distributed state management (Redis-based)
### 9.4 Phase 4 (Long-term)
- DAG-based scheduling (Airflow/Temporal)
- Cross-worker coordination
- Priority-based resource allocation
---
## 10. Glossary
| Term | Definition |
|------|-----------|
| **State Machine** | Finite state automaton managing processor lifecycle (8 states) |
| **Alert** | Asynchronous notification of state machine events (4 types) |
| **Dependency** | Prerequisite processor that must complete before execution |
| **Polling** | Periodic verification of post-processing steps (every 3s) |
| **入庫** | Post-processing steps after 10 processors complete (17 steps) |
| **file_uuid** | Unique identifier for a video file (32-char hex string) |
| **Processor** | One of 10 processing stages (Cut, ASR, ASRX, YOLO, OCR, Face, Pose, VisualChunk, Story, 5W1H) |
| **Post-processing** | Steps that run after processors (Rule 1, Vectorize, TKG, Face Trace, etc.) |
---
## 11. References
- [Pipeline Module](../API_WORKSPACE/modules/10_pipeline.md) - Pipeline overview and 入庫 steps
- [TKG Query API V1.0](TKG_QUERY_API_V1.0.md) - TKG integration details
- [Processor Refactoring Assessment](Processor_Refactoring_Assessment.md) - Processor refactoring plans
- `migrations/034_processor_state_machine.sql` - Database schema
- `src/core/db/postgres_db.rs` - ProcessorJobStatus enum
- `src/core/db/redis_client.rs` - emit_processor_alert() function
- `src/worker/job_worker.rs` - ConditionResult enum and check_dependencies()
---
## Version History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 1.0 | 2026-05-30 | M5Max128 | Initial design document |

View File

@@ -0,0 +1,128 @@
# Representative Frame API V1.0
Portal 影片代表畫面 API — 沒有指定 frame_number 時自動偵測男女主角找到最佳互動 frame。
---
## 1. Overview
### Purpose
Portal 需要為每個影片顯示一張代表畫面thumbnail內容應為該影片最具代表性的 scene — 通常包含男女主角同框且互看的時刻。
### Principle
**沒有指定 frame_number → auto-detect representative frame**
既有端點不需改動,只需在 `frame` 參數為空時自動偵測。
---
## 2. Endpoint
### `GET /api/v1/file/:file_uuid/thumbnail`
**Query Parameters**:
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `frame` | i64 | ❌ | 指定 frame不傳則 auto-detect |
| `x` | i32 | ❌ | bbox crop x |
| `y` | i32 | ❌ | bbox crop y |
| `w` | i32 | ❌ | bbox crop width |
| `h` | i32 | ❌ | bbox crop height |
**Response**: Pure JPEG bytes (Content-Type: image/jpeg)
**Examples**:
```
GET /api/v1/file/:uuid/thumbnail → auto-detect
GET /api/v1/file/:uuid/thumbnail?frame=38165 → 指定 frame
GET /api/v1/file/:uuid/thumbnail?frame=38165&x=723&y=205&w=221&h=221 → 指定 crop
```
---
## 3. Internal Algorithm
### Auto-detect Fallback Chain
```
Step 1: Auto-detect 主角 (top 2 by face count)
└─ face_detections JOIN identities
Step 2: TKG Bridge — mutual_gaze?
├── 有 mutual_gaze edge → first_frame ✅
└── 無 → face_detections 第一次同框 frame ✅
Step 3: 只有一個主角?
└─ 該主角 face_quality (w×h×confidence) 最高 frame
Step 4: 完全無 identity?
└─ 任 identity 的 face_quality 最高 frame
Step 5: 完全無 face?
└─ 404 "No faces in this file"
```
### TKG Bridge Query
```sql
-- 找兩主角各自的 main trace
SELECT trace_id FROM face_detections
WHERE file_uuid = $1 AND identity_id = $2 AND trace_id IS NOT NULL
GROUP BY trace_id ORDER BY COUNT(*) DESC LIMIT 1;
-- TKG mutual_gaze 查詢
SELECT (e.properties->>'first_frame')::bigint
FROM tkg_edges e
JOIN tkg_nodes a ON a.id = e.source_node_id
JOIN tkg_nodes b ON b.id = e.target_node_id
WHERE e.file_uuid = $1
AND a.external_id = concat('trace_', $4)
AND b.external_id = concat('trace_', $5)
AND e.properties->>'mutual_gaze' = 'true'
LIMIT 1;
-- Fallback: 第一次同框
SELECT MIN(fd_a.frame_number)::bigint
FROM face_detections fd_a
JOIN face_detections fd_b ON fd_a.frame_number = fd_b.frame_number
WHERE fd_a.file_uuid = $1 AND fd_a.identity_id = $2 AND fd_b.identity_id = $3;
```
---
## 4. Implementation
### Files Changed
| File | Change |
|------|--------|
| `src/api/media_api.rs` | `ThumbQuery.frame``Option<i64>`; add auto-detect fallback |
| `src/core/processor/tkg.rs` | Add `query_auto_representative_frame()` + structs (已實作) |
| `src/core/processor/mod.rs` | Export new function + structs (已實作) |
### Existing Trace-level Endpoints (不變)
```
GET /api/v1/file/:uuid/trace/:tid/representative-face → JSON (legacy)
GET /api/v1/file/:uuid/trace/:tid/thumbnail → JPEG (auto via select_rep_face)
```
### No Changes
- ❌ No new DB tables / migrations
- ❌ No changes to `select_rep_face` / blurdetect
- ❌ No chunk / cut / pre_chunks dependency
---
## 5. Version History
| Date | Version | Author | Change |
|------|---------|--------|--------|
| 2026-05-22 | 1.0 | OpenCode | Initial design |
| 2026-05-22 | 1.1 | OpenCode | 簡化為單一 endpoint: frame 為 None 時 auto-detect |
*Updated: 2026-05-22*

View File

@@ -0,0 +1,270 @@
---
document_type: "design_doc"
service: "MOMENTRY_CORE"
title: "Redis Progress Reporting V1.0"
version: "V1.0"
date: "2026-05-17"
author: "M5"
status: "draft"
---
# Redis Progress Reporting V1.0
| 項目 | 內容 |
|------|------|
| Service | `MOMENTRY_CORE` |
| Version | V1.0 |
| Date | 2026-05-17 |
| Author | M5 (OpenCode) |
| Status | Draft |
## 1. Overview
This document defines the standardized progress reporting architecture for Momentry Core processors. It replaces the inconsistent ad-hoc progress patterns found across `scripts/`, `src/worker/`, and `src/api/`.
### 1.1 Problems Addressed
| # | Problem | Detail |
|---|---------|--------|
| 1 | Worker Redis key does not match `OPERATIONS/MOMENTRY_CORE_REDIS_KEYS.md` V1.0 spec | Worker writes `worker:job:{uuid}:processor:{name}` instead of spec `job:{uuid}:processor:{name}` |
| 2 | Progress API reads wrong key | `get_progress()` reads `worker:job:{uuid}:processor:{name}` — unresolved with Playground subscriber which writes `job:{uuid}:processor:{name}` |
| 3 | Swift processors (Face/OCR/Pose) lack RedisPublisher | Progress lost — only stdout text |
| 4 | ASRX/Story/Visual chunk have no incremental progress | Start + complete only, no `current/total` updates |
| 5 | `frames_processed` / `chunks_produced` never updated in real-time | Worker only writes processor hash at start and exit |
| 6 | No `output_count` / `output_type` fields | Impossible to know how many faces/objects/segments were produced |
### 1.2 Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| Progress unit = frames for video processors | All media-level processors work frame by frame |
| Output count separate from progress | Processors may produce N outputs per frame (multiple faces, objects) |
| Pub/sub for real-time, Hash for final state | Pub/sub is transient; Hash persists for API queries |
---
## 2. Redis Key Architecture
### 2.1 Key Patterns
All keys use the configured `REDIS_KEY_PREFIX` (default: `momentry:` for production, `momentry_dev:` for playground).
| Pattern | Type | TTL | Purpose | Owner |
|---------|------|-----|---------|-------|
| `{prefix}progress:{uuid}` | Pub/Sub | — | Real-time progress messages | Python scripts |
| `{prefix}job:{uuid}` | Hash | 24h | Per-video job state | Worker |
| `{prefix}job:{uuid}:processor:{name}` | Hash | 24h | Per-processor final state | Worker |
| `{prefix}job:{uuid}:processor:{name}:output_count` | String | 24h | Output count by type | Worker |
### 2.2 Processor Hash Fields
```
{prefix}job:{uuid}:processor:{name}
├── status String running / completed / failed / pending
├── current u32 Units processed (frames for video processors)
├── total u32 Total units
├── output_count u32 Output items produced (faces, objects, segments)
├── output_type String Type name of output: faces / objects / segments / cuts / etc.
├── pid i32 OS process ID (0 if not running)
├── error String Error message if failed
└── updated_at String ISO 8601 timestamp
```
### 2.3 Migrated Keys
The following key patterns from the original implementation are REMOVED:
| Old Key | Reason |
|---------|--------|
| `{prefix}worker:job:{uuid}:processor:{name}` | Non-standard prefix — not in `MOMENTRY_CORE_REDIS_KEYS.md` spec |
| `{prefix}job:{uuid}:processor:{name}:status` (flat) | Redundant — status stored in Hash |
| `{prefix}job:{uuid}:processor:{name}:progress` (flat) | Replaced by `current` + `total` for percent calculation |
| `{prefix}job:{uuid}:processor:{name}:current` (flat) | Replaced by Hash fields |
| `{prefix}job:{uuid}:processor:{name}:total` (flat) | Replaced by Hash fields |
| `{prefix}job:{uuid}:processor:{name}:started_at` (flat) | Replaced by Hash `updated_at` |
---
## 3. Pub/Sub Message Format
### 3.1 Channel
```
{prefix}progress:{uuid}
```
### 3.2 Message JSON
```json
{
"processor": "face",
"current": 150,
"total": 162696,
"output_count": 423,
"output_type": "faces",
"message": "Processing frame 150",
"timestamp": 1700000000
}
```
### 3.3 Field Definitions
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `processor` | String | ✅ | Processor name: asr / asrx / yolo / ocr / face / pose / cut / story / visual_chunk |
| `current` | u32 | ✅ | Units processed (frames for video processors) |
| `total` | u32 | ✅ | Total units |
| `output_count` | u32 | ❌ | Output items produced so far |
| `output_type` | String | ❌ | Type name: faces / objects / segments / cuts / text_regions / persons / speakers / stories / visual_chunks |
| `message` | String | ❌ | Human-readable progress description |
| `timestamp` | u64 | ✅ | Unix timestamp |
---
## 4. Per-Processor Metrics
| Processor | current/total Unit | output_type | When to Publish |
|-----------|-------------------|-------------|-----------------|
| ASR | frames | `segments` | Every 100 segments processed |
| ASRX | frames | `speakers` | Every processing stage |
| YOLO | frames | `objects` | Every 500 frames |
| OCR | frames | `text_regions` | Every 5% |
| Face | frames | `faces` | Every batch (5% of frames) |
| Pose | frames | `persons` | Every 10% |
| CUT | frames | `cuts` | Every scene detected |
| Story | chunks | `stories` | Every chunk processed |
| Visual chunk | frames | `visual_chunks` | Every chunk processed |
### 4.1 Output Type Enum
```rust
pub enum OutputType {
Segments, // ASR
Speakers, // ASRX
Objects, // YOLO
TextRegions, // OCR
Faces, // Face
Persons, // Pose
Cuts, // CUT
Stories, // Story
VisualChunks, // Visual chunk
}
```
---
## 5. Data Flow
```
┌──────────────────┐ Pub/Sub ┌──────────────────────┐
│ Python Processor │ ───────── progress:{uuid} ──────────→│ Worker (subscriber) │
│ (ASR/YOLO/Face) │ {current, total, │ │
│ │ output_count, output_type} │ ──→ HSET │
└──────────────────┘ │ job:{uuid}: │
│ processor:{name} │
┌──────────────────┐ │ │
│ Swift Processor │ ──→ Python wrapper ──→ pub/sub │ (status, current, │
│ (Face/OCR/Pose) │ (add RedisPublisher) │ total, output_count,│
└──────────────────┘ │ output_type) │
└──────────┬───────────┘
│ HGETALL
┌──────────▼───────────┐
│ Progress API │
│ GET /progress/:uuid │
│ │
│ ─→ compute % │
│ ─→ return JSON │
└─────────────────────┘
```
---
## 6. Implementation Plan
### Phase 1: Python Processor RedisPublisher
| Task | Files | Effort |
|------|-------|--------|
| Add `RedisPublisher` to `face_processor.py` | `scripts/face_processor.py` | Medium |
| Add `RedisPublisher` to `ocr_processor.py` | `scripts/ocr_processor.py` | Medium |
| Add `RedisPublisher` to `pose_processor.py` | `scripts/pose_processor.py` | Medium |
| Add incremental `.progress()` to `asrx_processor_custom.py` | `scripts/asrx_processor_custom.py` | Low |
| Standardize pub/sub message to include `output_count`, `output_type` | All processor scripts | Low |
### Phase 2: Worker
| Task | Files | Effort |
|------|-------|--------|
| Fix Redis key from `worker:job:` to `job:` | `src/worker/processor.rs`, `src/core/db/redis_client.rs` | Low |
| Subscribe to `progress:{uuid}` channel in `run_processor()` | `src/worker/processor.rs` | Medium |
| HSET Processor Hash on each progress message | `src/worker/processor.rs` | Medium |
| Set `output_count` and `output_type` from pub/sub message | `src/worker/processor.rs` | Low |
### Phase 3: Progress API
| Task | Files | Effort |
|------|-------|--------|
| Read `output_count`, `output_type` from Redis Hash | `src/api/server.rs` | Low |
| Compute percentage from `current` / `total` | `src/api/server.rs` | Low |
| Return `output_count`, `output_type` in response JSON | `src/api/server.rs` | Low |
| Remove `worker:` fallback path | `src/api/server.rs` | Low |
### Phase 4: Cleanup
| Task | Files | Effort |
|------|-------|--------|
| Remove old `worker:job:` keys from Redis | Deployment script | Low |
| Remove `update_processor_progress()` DB path (stale `processing_status` JSONB) | `src/core/db/postgres_db.rs` | Medium |
---
## 7. API Response Changes
### ProgressResponse (new fields)
```json
{
"processors": [
{
"name": "face",
"status": "running",
"current": 150,
"total": 162696,
"progress": 0,
"frames_processed": 150,
"output_count": 423,
"output_type": "faces"
}
]
}
```
---
## 8. Dependencies
| Component | Version | Role |
|-----------|---------|------|
| Redis | ≥ 6.0 | Pub/Sub + Hash storage |
| `redis_publisher.py` | Existing | Python → Redis pub/sub client |
| `redis_client.rs` | Existing | Rust Redis client for worker + API |
---
## 9. References
| Doc | Relation |
|-----|----------|
| `OPERATIONS/MOMENTRY_CORE_REDIS_KEYS.md` | Parent spec — this doc supersedes sections 4, 7, 8 |
| `DESIGN/VIDEO_PROCESSING_SPEC.md` §2.3 | Original progress design (ProcessProgress struct) |
| `src/worker/processor.rs` | Worker progress write implementation |
| `scripts/redis_publisher.py` | Python pub/sub client |
| `src/api/server.rs` (get_progress) | Progress API handler |
---
## Version History
| Version | Date | Author | Change |
|---------|------|--------|--------|
| V1.0 | 2026-05-17 | M5 (OpenCode) | Initial draft — replaces ad-hoc progress patterns |

View File

@@ -0,0 +1,242 @@
---
title: Charade Full Movie Pipeline Checklist
version: 1.0
date: 2026-05-27
author: M5Max48
status: in_progress
---
# Charade Full Movie Pipeline Checklist
**File UUID**: `c3c635e3641da80dde10cc555ffcdda5`
**File Name**: Charade (1963) Cary Grant & Audrey Hepburn | Comedy Mystery Romance Thriller | Full Movie.mp4
**Duration**: 6785 seconds (113 minutes)
**Total Frames**: 169,625
---
## P0: Processor Outputs
### Purpose
原始處理器輸出檔案,存放在 `/Users/accusys/momentry/output_dev/`。這些是後續 ingestion 的資料來源。
### Processor Details
| Processor | Expected Output | Size Estimate | Purpose | Status |
|-----------|-----------------|---------------|---------|--------|
| CUT | `c3c635e3641da80dde10cc555ffcdda5.cut.json` | ~170KB | Scene boundary detection切割點用於 Rule 3 chunking | ✅ Done |
| YOLO | `c3c635e3641da80dde10cc555ffcdda5.yolo.json` | ~50-80MB | Object detection每幀的物件類別與位置 | 🔄 Running |
| Face | `c3c635e3641da80dde10cc555ffcdda5.face.json` | ~1.5GB | Face detection + 512-dim embedding (FaceNet CoreML) | 🔄 44% |
| Face Traced | `c3c635e3641da80dde10cc555ffcdda5.face_traced.json` | ~1.2GB | Face tracking同一人物的連續出現 → trace_id | ⏳ Pending (after Face) |
| OCR | `c3c635e3641da80dde10cc555ffcdda5.ocr.json` | ~50KB | Text recognition from frames | ❌ Skipped |
| Pose | `c3c635e3641da80dde10cc555ffcdda5.pose.json` | ~20MB | Body pose estimation | 🔄 Running |
| ASRX | `c3c635e3641da80dde10cc555ffcdda5.asrx.json` | ~8MB | Speaker diarization語者分段 | ✅ Done (reuse from public) |
| Visual Chunk | `c3c635e3641da80dde10cc555ffcdda5.visual_chunk.json` | ~60KB | Visual scene chunk metadata | ✅ Done |
| Scene | `c3c635e3641da80dde10cc555ffcdda5.scene.json` | ~300B | Scene list from CUT | ✅ Done |
| Scene Meta | `c3c635e3641da80dde10cc555ffcdda5.scene_meta.json` | ~50KB | Heuristic scene metadata (人物 + 物件統計) | ⏳ Pending |
| Story LLM | `c3c635e3641da80dde10cc555ffcdda5.story_llm.json` | ~800KB | LLM-generated story summaries per chunk | ✅ Done |
| Story Story | `c3c635e3641da80dde10cc555ffcdda5.story_story.json` | ~800KB | Story parent-child relationships | ✅ Done |
| TMDb | `c3c635e3641da80dde10cc555ffcdda5.tmdb.json` | ~5KB | TMDb cast list with face embeddings | ⏳ Pending |
| 5W1H | `c3c635e3641da80dde10cc555ffcdda5.5w1h.json` | ~500KB | 5W1H agent output (who/when/where/what/why/how) | ✅ Done |
### Key Dependencies
- Face Traced 需要 Face 完成後才能執行 (face_traced.json = face.json + tracking)
- Scene Meta 需要 Face + YOLO 完成
- TMDb 需要 Face Traced 完成後執行 matching
---
## P1: Database Records
### Purpose
將 processor outputs 存入 PostgreSQL供 API query 使用。
### Table Details
| Table | Expected Records | Purpose | Verification Query | Status |
|-------|------------------|---------|-------------------|--------|
| `dev.videos` | 1 row | Video metadata (duration, fps, status) | `SELECT file_uuid, status FROM dev.videos WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | ✅ Registered |
| `dev.monitor_jobs` | 1 row | Processing job state machine | `SELECT uuid, status, completed_processors FROM dev.monitor_jobs WHERE uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | 🔄 Running |
| `dev.pre_chunks` | ~7,000 rows | Raw processor outputs (ASR sentences, YOLO objects, etc.) | `SELECT COUNT(*) FROM dev.pre_chunks WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | ⏳ Pending |
| `dev.face_detections` | ~70,000 rows | Face detection records (每幀每張臉) | `SELECT COUNT(*) FROM dev.face_detections WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | ⏳ Pending |
| `dev.face_detections.embedding` | ~70,000 non-NULL | 512-dim FaceNet embedding (用於 identity matching) | `SELECT COUNT(embedding) FROM dev.face_detections WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | ⏳ Pending |
| `dev.face_detections.trace_id` | ~70,000 non-NULL | Face tracking ID (同一人物跨幀連續出現) | `SELECT COUNT(trace_id) FROM dev.face_detections WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | ⏳ Pending |
| `dev.face_detections.identity_id` | ~50,000 non-NULL | TMDb identity binding (Audrey, Cary, etc.) | `SELECT COUNT(identity_id) FROM dev.face_detections WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | ⏳ Pending |
### Key Points
- `embedding` 必須非 NULL 才能進行 TMDb matching (之前 store_traced_faces.py bug 修復)
- `trace_id``store_traced_faces.py` 從 face_traced.json 計算
- `identity_id``match_faces_to_tmdb.py` 計算 (cosine similarity > 0.5)
---
## P2: Chunk Ingestion
### Purpose
將 raw processor outputs 轉換為 searchable chunks用於 RAG query。
### Chunk Types
| Chunk Type | Expected Count | Purpose | Source | Verification Query | Status |
|------------|----------------|---------|--------|-------------------|--------|
| sentence (Rule 1) | ~1,700 | Sentence-level chunks for text search | ASR output → sentence split | `SELECT COUNT(*) FROM dev.chunk WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5' AND chunk_type = 'sentence'` | ⏳ Pending |
| llm_parent | ~800 | LLM-generated summary parent chunks | Story LLM output | `SELECT COUNT(*) FROM dev.chunk WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5' AND chunk_type = 'llm_parent'` | ⏳ Pending |
| story_parent | ~800 | Story parent chunks (narrative segments) | Story processor | `SELECT COUNT(*) FROM dev.chunk WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5' AND chunk_type = 'story_parent'` | ⏳ Pending |
| story_child | ~1,700 | Story child chunks (linked to sentence) | Story processor | `SELECT COUNT(*) FROM dev.chunk WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5' AND chunk_type = 'story_child'` | ⏳ Pending |
| cut (Rule 3) | ~500 | Scene-level chunks for scene search | CUT output → scene boundaries | `SELECT COUNT(*) FROM dev.chunk WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5' AND chunk_type = 'cut'` | ⏳ Pending |
| trace | ~3,600 | Face trace chunks (identity-centric) | Face Traced output | `SELECT COUNT(*) FROM dev.chunk WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5' AND chunk_type = 'trace'` | ⏳ Pending |
### Ingestion Pipeline
1. **Rule 1**: ASR → sentence split → chunk + embedding → Qdrant
2. **Rule 3**: CUT + ASR → scene chunks → chunk + embedding → Qdrant
3. **Trace**: Face Traced → trace chunks → TKG nodes → Qdrant
### Key Points
- `start_frame` / `end_frame` 必須正確計算 (之前 bug: frame=0)
- Chunks 必須有 `embedding` 才能 search
---
## P3: Vector Embeddings
### Purpose
將 chunks 的 text 轉換為 768-dim embeddings存入 PostgreSQL + Qdrant用於 semantic search。
### Embedding Targets
| Target | Expected Count | Model | Purpose | Verification | Status |
|--------|----------------|-------|---------|--------------|--------|
| PostgreSQL `dev.chunk.embedding` | ~5,000 | Gemma-2-9B (768-dim) | Text semantic search | `SELECT COUNT(embedding) FROM dev.chunk WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | ⏳ Pending |
| Qdrant `momentry_dev_rule1_v2` | ~5,000 points | Gemma-2-9B | Fast vector similarity search | `curl -H "api-key: Test3200Test3200Test3200" "http://localhost:6333/collections/momentry_dev_rule1_v2"` | ⏳ Pending |
| Qdrant `_face` collection | ~70,000 points | FaceNet-512 (512-dim) | Face identity search | Face embeddings sync via `sync_face_embeddings()` | ⏳ Pending |
### Embedding Pipeline
1. **Text chunks**: `embeddinggemma_server.py` (port 11436) → 768-dim embedding
2. **Face embeddings**: FaceNet CoreML (from face.json) → 512-dim embedding (已在 P0 產生)
3. **Sync to Qdrant**: `sync_face_embeddings()` function in Rust
### Key Points
- Text embeddings 使用 Gemma-2-9B (local LLM server)
- Face embeddings 使用 FaceNet-512 (CoreML ANE accelerated)
- Qdrant 提供 fast similarity search (cosine similarity)
---
## P4: Identity Binding
### Purpose
將 detected faces 綁定到 TMDb identities (Audrey Hepburn, Cary Grant, etc.),用於 identity_text search。
### Identity Matching Pipeline
| Step | Expected Result | Method | Verification | Status |
|------|-----------------|--------|--------------|--------|
| TMDb seeds loaded | 23 identities | `tmdb_embed_extractor.py` → TMDb profile face embeddings | `SELECT COUNT(*) FROM dev.identities WHERE source = 'tmdb' AND face_embedding IS NOT NULL` | ✅ Done |
| Face matching | ~50,000 bindings | `match_faces_to_tmdb.py` → cosine similarity > 0.5 | `SELECT COUNT(identity_id) FROM dev.face_detections WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5' AND identity_id IS NOT NULL` | ⏳ Pending |
| Audrey Hepburn faces | ~16,000 | Highest similarity match | `SELECT COUNT(*) FROM dev.face_detections fd JOIN dev.identities i ON fd.identity_id = i.id WHERE fd.file_uuid = 'c3c635e3641da80dde10cc555ffcdda5' AND i.name = 'Audrey Hepburn'` | ⏳ Pending |
| Cary Grant faces | ~5,000 | Second highest match | Same query for Cary Grant | ⏳ Pending |
### Matching Algorithm
```python
# match_faces_to_tmdb.py
for trace_id in traces:
for face_embedding in trace_faces:
for tmdb_identity in tmdb_identities:
similarity = cosine_similarity(face_embedding, tmdb_identity.face_embedding)
if similarity >= 0.5:
match trace_id tmdb_identity
```
### Key Points
- TMDb seeds 需要 `face_embedding` (之前已驗證: 23 identities with embeddings)
- Face `embedding` 必須非 NULL (之前 store_traced_faces.py bug 修復)
- Threshold: 0.5 (可調整)
---
## P5: API Endpoints
### Purpose
驗證 API endpoints 可以正確返回 identity_text search results。
### API Tests
| Endpoint | Purpose | Expected Response | Test Command | Status |
|----------|---------|-------------------|--------------|--------|
| `/api/v1/search/identity_text` | Search chunk text → identities | Results with `identity_name`, `trace_id`, `identity_source` | `curl "http://localhost:3003/api/v1/search/identity_text?file_uuid=c3c635e3641da80dde10cc555ffcdda5&q=Regina&limit=5"` | ⏳ Pending |
| `/api/v1/identities` | List identities with TMDb | Identity list with `tmdb_id`, `face_embedding` | `curl "http://localhost:3003/api/v1/identities?name=Audrey"` | ⏳ Pending |
| `/api/v1/progress/:file_uuid` | Check processing progress | JSON with `status`, `completed_processors` | `curl "http://localhost:3003/api/v1/progress/c3c635e3641da80dde10cc555ffcdda5"` | ⏳ Pending |
### Expected API Response Example
```json
{
"success": true,
"total": 5,
"results": [
{
"chunk_id": "sentence_123",
"start_time": 355.0,
"text_content": "Oh, mine's Regina Lampert.",
"identity_id": 9,
"identity_name": "Audrey Hepburn",
"identity_source": "tmdb",
"trace_id": 169
}
]
}
```
### Key Points
- `identity_text` API 需要 `chunk.start_frame` / `chunk.end_frame` 正確 (之前 bug: frame=0)
- `identity_id` 必須非 NULL 才能返回 identity_name
---
## P6: Completion Criteria
### Purpose
驗證 pipeline 完整完成,所有 ingestion steps 成功。
### Final Verification Checklist
| Criteria | Purpose | Check Command | Expected Result | Status |
|----------|---------|---------------|-----------------|--------|
| All processor outputs exist | 確認所有 processor JSON 檔案產生 | `ls -la output_dev/c3c635e3641da80dde10cc555ffcdda5.*` | 14+ files with size > 0 | ⏳ Pending |
| Job status = completed | 確認 worker 完成 job | `SELECT status FROM dev.monitor_jobs WHERE uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | `completed` | ⏳ Pending |
| Video status = completed | 確認 video state 更新 | `SELECT status FROM dev.videos WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | `completed` | ⏳ Pending |
| All chunks have embeddings | 確認 text embeddings 完成 | `SELECT COUNT(*) = COUNT(embedding) FROM dev.chunk WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | `true` (all chunks have embedding) | ⏳ Pending |
| Face traces assigned | 確認 face tracking 完成 | `SELECT COUNT(*) = COUNT(trace_id) FROM dev.face_detections WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | `true` (all faces have trace_id) | ⏳ Pending |
| TMDb matching done | 確認 identity binding 完成 | `SELECT COUNT(identity_id) > 40000 FROM dev.face_detections WHERE file_uuid = 'c3c635e3641da80dde10cc555ffcdda5'` | `true` (> 40K identity bindings) | ⏳ Pending |
| Qdrant synced | 確認 vector search ready | Check Qdrant points count | Points increased by ~5,000 | ⏳ Pending |
### Success Thresholds
- **Face detections**: ~70,000 (169K frames / 3 sample interval)
- **Identity bindings**: > 40,000 (60% match rate)
- **Chunks with embeddings**: > 4,000 (all chunk types)
- **Qdrant points**: > 90,000 (current) → > 95,000 (after Charade)
---
## Verification Script
```bash
# Run after completion
./scripts/verify_charade_pipeline.sh c3c635e3641da80dde10cc555ffcdda5
```
---
## Notes
- OCR processor failed, skipped
- Face detection using SwiftFace (ANE accelerated)
- TMDb matching using `scripts/match_faces_to_tmdb.py`
- Expected total processing time: ~2-3 hours
---
## Version History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 1.0 | 2026-05-27 | M5Max48 | Initial checklist |

View File

@@ -0,0 +1,49 @@
# Session Summary: Identity Fixes + WP Proxy Fixes + Data Sync
**Date**: 2026-05-29
**Author**: OpenCode
**Status**: Completed (marcom team testing)
## What Was Done (Chronological)
### 1. Production Identity Fixes (3002)
- **James Coburn restored** (id=18738, confirmed)
- **Chantal Goya restored** (id=18737, confirmed)
- **Louis Viret name/status fixed**
- **Sequences fixed**: `identities_id_seq` (48→18734), `face_detections_id_seq` (141383→932413), `identity_history_id_seq`, `identity_bindings_id_seq`, `pre_chunks_id_seq`, `file_identities_id_seq`
- **COALESCE fix** for `reference_data` NULL crash (`postgres_db.rs:3198`, `storage.rs:196`)
### 2. Bug Fixes
- **DELETE identity**: Fixed binding order bug + removed `identity_confidence` column reference
- **PATCH identity**: `jsonb_deep_merge` Nested JSON metadata
- **mergeinto UNDO/REDO**: MongoDB deserialization fix (`Collection<Document>`)
### 3. Library Page Infinite Load Fix
- **Root cause**: WP scan proxy (snippet 48) didn't forward query params → infinite pagination loop
- **Fix**: Added `$request->get_query_params()` forwarding in scan proxy
- **Safety**: Added `maxPages = 10` limit in JS pagination
### 4. Identity Data Sync (Dev → Production)
- **Full replacement** of `public.identities`, `public.identity_bindings`, `public.identity_history` with dev data
- James Coburn id: 18738 → 11
- Bindings: 11,892 → 12,834 (+942)
- **Verification**: 0 differences between schemas
### 5. Snippet 55 Filter
- Added `.filter(f => f.is_registered)` to show only registered files on library page
- Changed `status:'unregistered'``status: f.status || 'unregistered'`
## Key Decisions
- Library page filter: default show registered files only
- Identity sync: full DELETE + INSERT (not UPDATE) to ensure consistency
- No user-defined metadata fields (starred/notes/role) preserved — matches dev exactly
## Handoff to Marcom
- `/people/` page should show correct identity state
- `/library/` page should show only registered files (4 currently)
- Login required for `/library/` — redirects to `/login/` if not authenticated
## Files Modified
- `snippet 48` (/scan WP proxy — query param forwarding)
- `snippet 55` (library page JS — registered-only filter, maxPages safety)
- `docs_v1.0/M4_workspace/2026-05-29_identity_sync_prod.md` (sync record)

View File

@@ -0,0 +1,45 @@
# Identity Data Sync: Dev (3003) → Production (3002)
**Date**: 2026-05-29
**Author**: OpenCode
**Status**: Completed
## Summary
Fully synced all identity-related tables from dev schema to public schema on PostgreSQL `momentry` database.
## What Was Done
1. **Identities table** (`public.identities`): Replaced with `dev.identities` (69 records, original ids preserved)
2. **Identity_bindings** (`public.identity_bindings`): Replaced with `dev.identity_bindings` (12,834 records)
3. **Identity_history** (`public.identity_history`): Replaced with `dev.identity_history` (10 records)
4. **Sequences**: Updated `identities_id_seq`, `identity_bindings_id_seq`, `identity_history_id_seq` to match
### Key Changes
- **James Coburn**: Changed from id=18738 → id=11 (dev's original id)
- **Chantal Goya**: Changed from id=18737 → id=18736 (dev's id)
- **Metadata**: Now matches dev schema — TMDB fields only, no user-defined fields (starred, notes, role, aliases, user_confirmed are removed as expected)
- **Bindings**: Increased from 11,892 → 12,834 (+942 bindings)
### Not Changed
- `face_detections` — identical in both schemas (135,521 records)
- `pre_chunks` — large difference (public: 1.3M vs dev: 3.3M) but NOT related to identity
- All other non-identity tables unchanged
## Verification
```sql
-- Counts match
identities: 69 = 69
identity_bindings: 12,834 = 12,834
identity_history: 10 = 10
-- No differences
id/uuid mismatch: 0
metadata/status/name diffs: 0
```
## Files Referenced
- `AGENTS.md` — Development isolation rules
- `/Users/accusys/momentry_core/docs_v1.0/M4_workspace/2026-05-29_wp_api_url_update.md` — Previous session handoff

View File

@@ -0,0 +1,27 @@
# 2026-05-29: Mergeinto NULL face_id Fix
## Problem
Production server (3002) returned `"error":"error occurred while decoding column 0: unexpected null; try decoding as an 'Option'"` when using mergeinto after clicking undo on a merge.
## Root Cause
`src/api/identity_binding.rs:428` decodes `face_id` from `face_detections` as `String` (non-Option), but **135,521 records** in the production `face_detections` table have NULL `face_id`. When merging an identity whose face_detections include NULL face_ids, the SQLx decode panics.
## Fix
- Changed `(String, Option<i32>)``(Option<String>, Option<i32>)` at line 428
- Changed `face_id_list` to use `filter_map` instead of `map` to skip NULL face_ids
- Changed `faces_count` to use `face_id_list.len()` instead of `face_ids.len()` (matching the actual transferred count)
## Files Changed
- `momentry_core/src/api/identity_binding.rs` — 3 lines changed
## Verification
- 234 library tests pass
- `cargo fmt` passes
- Production binary rebuilt (`target/release/momentry`)
- Production server restarted on port 3002 (PID 92043)
## Identities with NULL face_id (20 identities, ~135k records)
Audrey Hepburn (36k), Cary Grant (15k), Bernard Musson, Walter Matthau, Jacques Marin, George Kennedy, Michel Thomass, Antonio Passalia, etc. — all `type=people, status=confirmed`. These identities were likely imported from bulk face detection data without face_id generation.
## Data Note
The NULL face_ids are a pre-existing data quality issue. The fix prevents crashes but doesn't clean up the NULL data. Faces with NULL face_id won't be tracked in undo history (they stay with the target after undo), but the bulk transfer (`WHERE identity_id = $1`) still works correctly.

View File

@@ -0,0 +1,166 @@
---
title: Hybrid Search Deployment & Testing Report
version: 1.0
date: 2026-06-01
author: OpenCode
status: completed
---
# Hybrid Search Deployment & Testing Report
## Summary
Successfully deployed hybrid search (semantic + keyword + identity with RRF) to production and tested with new video registration.
## Deployment
### Production (Port 3002)
- **Strategy**: `hybrid_semantic+keyword+identity`
- **RRF K**: 60
- **Status**: ✅ Deployed and functional
- **Commit**: Replaced entire smart_search implementation
### Identity Fixes
- Deleted 36 Stranger identities (no file_uuid)
- Deleted 6 test identities
- Fixed 25 TMDb identities → file_uuid=Charade
- Removed 6462 duplicate identity_bindings
- Set file_uuid for 6347 bindings
- Synced 49,881 face_detections (80% of Charade)
## New Video Registration
### Video Details
- **Filename**: "ExaSAN PCIe series - Director Ou Yu-Zhi Shares His Experience.mp4"
- **file_uuid**: `c4e33d129aa8f5512d1d28a92941b047`
- **Duration**: 159.6 seconds
- **Size**: 6.8MB
- **Resolution**: 640x360
- **FPS**: 22
### Processing
- **Processors**: CUT (1 scene), ASRX (6 segments)
- **Output**: `/Users/accusys/momentry/output/c4e33d129aa8f5512d1d28a92941b047.asrx.json`
- **ASRX Content**: 6 Traditional Chinese speech segments (25-30 seconds each)
## Critical Bugs Fixed
### Bug 1: Case Mismatch
- **Problem**: Job had `processors={ASRX}` (uppercase)
- **Cause**: `ProcessorType::from_db_str()` only matches lowercase `"asrx"`
- **Fix**: Changed to `processors={cut,asrx}` (lowercase)
- **Impact**: Worker couldn't start processors
### Bug 2: Missing Dependency
- **Problem**: ASRX depends on CUT being completed
- **Cause**: User specified only ASRX processor
- **Fix**: Added CUT to processors list
- **Impact**: Worker deferred ASRX indefinitely
## Test Results
### Hybrid Search
```bash
curl -X POST "http://localhost:3003/api/v1/search/smart" \
-d '{"query":"剪輯室 調光師"}'
# Results: Found Chinese text matches from existing videos
# Strategy: hybrid_semantic+keyword+identity
# RRF fusion working correctly
```
### Search Coverage
- ✅ Semantic search (Qdrant vectors)
- ✅ Keyword search (BM25 PostgreSQL)
- ✅ Identity search (face bindings)
- ✅ RRF fusion (K=60)
## Design Discovery
### ASRX vs ASR Segments
- **Issue**: Rule 1 expects ASR segments (processor_type='asr')
- **Current**: We ran ASRX (processor_type='asrx')
- **Result**: 0 sentence chunks created
- **Impact**: New video ASRX data not searchable yet
### Root Cause
Rule 1 `fetch_asr_segments()` queries `WHERE processor_type = 'asr'`, but ASRX segments are stored as `'asrx'`.
### Options
1. Run ASR processor separately (ASRX includes ASR internally)
2. Modify Rule 1 to use ASRX segments
3. Keep current design (ASR + ASRX separate)
## Current Status
### Job Status
- **monitor_jobs.job_id=46**: status=`running`
- **completed_processors**: {cut, asrx}
- **Why not completed**: Waiting for ingestion (no sentence chunks, no face traces)
### Ingestion Prerequisites
Per `ingestion_complete()`:
- ❌ Sentence chunks (Rule 1 returned 0)
- ❌ Vector embeddings (no chunks to vectorize)
- ✅ Cut chunks (1 scene)
- ❌ Face traces (Face processor not run)
## Files Modified
### Production Code
- `src/api/search.rs` - Hybrid search implementation
- `src/core/db/postgres_db.rs` - Identity fixes (SQL)
- `docs_v1.0/OPERATIONS/IDENTITY_SYSTEM_V4.0.md` - Updated
### Debug Code Added
- `src/worker/job_worker.rs` - Added debug logs (removed after testing)
## Recommendations
### Immediate
1. Document ASR vs ASRX distinction for Rule 1
2. Consider running ASR + ASRX separately or modifying Rule 1
3. Update worker docs about case sensitivity
### Future
1. Test full processing pipeline (Face, YOLO, Pose)
2. Verify ingestion_complete logic with all processors
3. Add API endpoint for manual vectorization
## Metrics
### Identity Cleanup
- Deleted: 42 identities
- Fixed: 25 identities
- Removed: 6462 duplicates
- Synced: 49,881 faces
### Processing Time
- CUT: ~2 seconds (1 scene)
- ASRX: ~7 minutes (6 segments, 159s video)
- Worker loop detection: ~2 minutes (case mismatch)
### Search Performance
- Query time: <100ms
- Results: 3-5 matches
- Strategy: hybrid_semantic+keyword+identity
- RRF K: 60
---
## Appendix: ASRX Output Sample
```json
{
"segments": [
{
"start": 0.323,
"end": 25.496,
"text": "正常來講我們是剪輯室用完之後再套片給我們的調光師...",
"speaker_id": null
}
]
}
```
**Note**: speaker_id=null indicates diarization phase incomplete or single speaker detected.

View File

@@ -0,0 +1,68 @@
# TMDb Pipeline Test 2026-05-17
## Purpose
Verify full TMDb enrichment pipeline: register → process → TMDb prefetch → probe → identity files → downloads.
## Environment
- **Server**: playground (port 3003)
- **Schema**: `dev`
- **TMDB_API_KEY**: `e9cde52197f6f8df4d9db99da93db1fb`
- **Build**: `momentry_playground` (debug, 0 errors)
## Pre-cleanup
Unregistered old files + deleted output files:
```bash
POST /api/v1/unregister {"file_uuid": "3abeee81..."}
POST /api/v1.unregister {"file_uuid": "23b1c872..."}
```
## Step 1: Register
| File | UUID | Result |
|------|------|--------|
| Charade main | `bd80fec92b0b6963d177a2c55bf713e2` | ✅ Registered (already_exists due to content_hash match) |
| Charade YouTube | `a6fb22eebefaef17e62af874997c5944` | ✅ Fresh registration |
Register phase completed: probe → CUT → scene classification.
## Step 2: Trigger Processing
```bash
POST /api/v1/file/:uuid/process {}
```
Jobs created:
- Main: job_id=167, status=PENDING
- YouTube: job_id=168, status=PENDING
Worker blocked by schema issue: `processor_results` missing `retry_count` column + `jsonb_set(text, text, jsonb)` signature mismatch. Fixed `retry_count` via ALTER TABLE.
## Step 3: TMDb Prefetch (requires pipeline completion first)
```bash
POST /api/v1/agents/tmdb/prefetch
```
## Step 4: TMDb Probe
```bash
POST /api/v1/file/:uuid/tmdb-probe
```
## Known Issues
1. `jsonb_set(jsonb, text, jsonb)` → should be `jsonb_set(jsonb, text[], jsonb)` — pre-existing worker bug
2. `processor_results.retry_count` column missing — fixed via ALTER TABLE
3. Worker requires running as separate process: `./target/debug/momentry_playground worker`
## Endpoint Changes in This Test
| Endpoint | Status |
|----------|--------|
| `GET /api/v1/stats/ingest` | ❌ Removed (stats moved to files/scan + identities) |
| `GET /api/v1/files/scan` | Added `total_chunks`, `searchable_chunks`, `pending_videos` |
| `GET /api/v1/identities` | Added `total_identities`, `tmdb_identities`, `auto_identities` |
| `POST /api/v1/agents/tmdb/prefetch` | ✅ Writes identity files directly |
| `POST /api/v1/file/:uuid/tmdb-probe` | ✅ Upserts from disk identity files |
| `GET /api/v1/identity/:uuid/json` | ✅ Download identity JSON |
| `GET /api/v1/file/:uuid/json/:processor` | ✅ Download processor JSON |
| `POST /api/v1/agents/identity/match-from-photo` | 🆕 New |
| `POST /api/v1/agents/identity/match-from-trace` | 🆕 New |

View File

@@ -0,0 +1,375 @@
# Face Binding States — Data Model Reference
**Version**: 1.0.0
**Date**: 2026-05-25
**Related**: `GET /api/v1/file/:file_uuid/faces`, `identities`, `strangers`, `face_detections`
---
## Glossary
| Term | Definition |
|------|------------|
| **face detection** | A single face bounding box detected in one video frame. Stored in `face_detections` table. |
| **trace** | A sequence of face detections belonging to the same person across consecutive frames. Assigned by the face tracker. `trace_id` groups multiple face detections. |
| **identity** | A known person with a name. Sources: TMDb (movie stars), user-defined (manual entry). Stored in `identities` table with `source='tmdb'` or `source='user_defined'`. |
| **stranger** | An unknown person detected but not matched to any known identity. Created automatically for unmatched traces. Stored in `strangers` table. |
| **binding** | The association between a face detection and either an identity or a stranger. Represented by `identity_id` or `stranger_id` FK in `face_detections`. |
| **TMDb** | The Movie Database. Source of celebrity identity seeds with `face_embedding` for matching. |
| **auto identity** | Legacy term for identities created from `face_clustered.json` analysis. Now migrated to `strangers` table as reference records. |
| **dangling** | A face detection whose `identity_id` points to a deleted identity (e.g., auto identities removed during migration). |
| **unbound** | A face detection with no binding at all — `identity_id IS NULL AND stranger_id IS NULL`. |
| **PK** | Primary Key. A unique identifier for each row in a table. Example: `identities.id`, `strangers.id`, `face_detections.id`. |
| **FK** | Foreign Key. A column that references the PK of another table, creating a relationship. Example: `face_detections.identity_id``identities.id`, `face_detections.stranger_id``strangers.id`. FK ensures referential integrity — a face cannot point to a non-existent identity. |
---
## Three Core Tables
### ER Diagram
```
┌─────────────────────┐ ┌─────────────────────┐
│ identities │ │ strangers │
│─────────────────────│ │─────────────────────│
│ id (PK) │ │ id (PK) │
│ uuid │ │ file_uuid │
│ name │ │ trace_id │
│ source │ │ metadata │
│ tmdb_id │ │ created_at │
│ face_embedding │ │ │
│ metadata │ │ UNIQUE(file_uuid, │
│ status │ │ trace_id) │
│ ... │ │ │
└─────────┬───────────┘ └─────────┬───────────┘
│ │
│ FK │ FK
│ (ON DELETE SET NULL) │ (ON DELETE SET NULL)
│ │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ face_detections │
│─────────────────────────────────────────────────────│
│ id (PK) │
│ file_uuid — Video file identifier │
│ frame_number — Frame where face was detected│
│ timestamp_secs — Frame number / fps │
│ trace_id — Face tracking ID │
│ face_id — Format: `{frame}_{idx}` │
│ identity_id (FK) — → identities.id │
│ stranger_id (FK) — → strangers.id │
│ x, y, width, height — Bounding box │
│ confidence — Detection confidence (01) │
│ embedding — Face embedding vector │
│ metadata — JSON metadata │
└─────────────────────────────────────────────────────┘
```
### Table Summary
| Table | Role | Record Count (public) | Primary Key |
|-------|------|----------------------|-------------|
| `identities` | Known persons (TMDb, user-defined) | 70 | `id`, `uuid` |
| `strangers` | Unknown persons (unmatched traces) | 0N per file | `id`, `(file_uuid, trace_id)` |
| `face_detections` | Individual face detections | 70691 per file | `id` |
### Key Columns in `face_detections`
| Column | Type | Purpose |
|--------|------|---------|
| `identity_id` | INTEGER FK | Points to `identities.id` if matched to known person |
| `stranger_id` | INTEGER FK | Points to `strangers.id` if unmatched trace |
| `trace_id` | INTEGER | Groups faces belonging to same person across frames |
**Design Rule**: `identity_id` and `stranger_id` are mutually exclusive in normal operation. A face should have only one binding.
---
## Four Binding States
### State Definitions
| # | State | `binding` JSON | SQL Condition | Meaning |
|---|-------|----------------|---------------|---------|
| 1 | **identity** | `{"identity_id": 9, "identity_uuid": "...", "identity_name": "Audrey Hepburn"}` | `identity_id IN (SELECT id FROM identities)` | Face matched to a known TMDb or user-defined identity |
| 2 | **stranger** | `{"stranger_id": 845, "metadata": {}}` | `stranger_id IS NOT NULL` | Face belongs to an unmatched trace (unknown person) |
| 3 | **dangling** | `{"old_identity_id": 18052}` | `identity_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM identities WHERE id = face_detections.identity_id)` | Face was bound to an identity that has been deleted (orphaned reference) |
| 4 | **unbound** | `null` | `identity_id IS NULL AND stranger_id IS NULL` | Face has no binding at all |
### State Detection Logic (Rust)
```rust
let binding = if let (Some(iid), Some(iuuid), Some(iname)) =
(identity_id, identity_uuid, identity_name)
{
FaceBinding::Identity { identity_id: iid, identity_uuid: iuuid, identity_name: iname }
} else if let Some(sid) = stranger_id {
FaceBinding::Stranger { stranger_id: sid, metadata: stranger_metadata }
} else if let Some(iid) = identity_id {
FaceBinding::Dangling { old_identity_id: iid }
} else {
FaceBinding::Unbound
};
```
---
## Lifecycle Flow
### Processing Pipeline
```
Video Registration
Face Detection
(face_detections created)
Face Tracking
(trace_id assigned)
┌────────────────┐
│ Identity Agent │
│ Face Matching │
└────────────────┘
┌─────────┴─────────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ MATCHED │ │ UNMATCHED│
│ to TMDb │ │ trace │
└─────┬────┘ └────┬─────┘
│ │
│ │
▼ ▼
identity_id=X stranger_id=S
│ │
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ IDENTITY│ │ STRANGER│
│ state │ │ state │
└─────────┘ └─────────┘
```
### User Operations
```
┌─────────┐ bind ┌─────────┐
│ STRANGER│──────────────▶│ IDENTITY│
└────┬────┘ └────┬────┘
│ │
│ unbind │
│ (if stranger_id │
│ preserved) │
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ STRANGER│◀─────────────│ UNBOUND │
│ (rollback) │ (if no │
└─────────┘ │ stranger)│
└─────────┘
```
### Migration Effect
```
┌─────────────────────┐
│ auto identities │
│ (source='auto') │
│ 943 records │
└─────────┬───────────┘
│ DELETE
┌─────────────────────┐
│ face_detections │
│ identity_id=18052 │
│ (points to deleted) │
└─────────┬───────────┘
│ Cleanup SQL
│ SET identity_id=NULL
┌─────────────────────┐
│ DANGLING → UNBOUND │
│ 18641 faces cleaned │
└─────────────────────┘
```
---
## SQL Query Examples
### Count by State
```sql
SELECT
COUNT(*) FILTER (WHERE identity_id IN (SELECT id FROM identities)) AS identity,
COUNT(*) FILTER (WHERE stranger_id IS NOT NULL) AS stranger,
COUNT(*) FILTER (WHERE identity_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM identities WHERE id = face_detections.identity_id)) AS dangling,
COUNT(*) FILTER (WHERE identity_id IS NULL AND stranger_id IS NULL) AS unbound
FROM face_detections
WHERE file_uuid = 'aeed71342a899fe4b4c57b7d41bcb692';
```
### Filter by State
```sql
-- Identity
SELECT * FROM face_detections fd
WHERE fd.identity_id IN (SELECT id FROM identities);
-- Stranger
SELECT * FROM face_detections WHERE stranger_id IS NOT NULL;
-- Dangling
SELECT * FROM face_detections fd
WHERE fd.identity_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM identities WHERE id = fd.identity_id);
-- Unbound
SELECT * FROM face_detections
WHERE identity_id IS NULL AND stranger_id IS NULL;
```
---
## bind/unbind Behavior
### Current Implementation (stranger_id cleared on bind)
| Operation | SQL Effect | Result |
|-----------|------------|--------|
| `bind_face_to_identity` | `SET identity_id=X, stranger_id=NULL` | Stranger info lost |
| `bind_trace_to_identity` | `SET identity_id=X, stranger_id=NULL` | Stranger info lost |
| `merge_identity` | `SET identity_id=X, stranger_id=NULL` | Stranger info lost |
| `unbind_face` | `SET identity_id=NULL` | Becomes unbound (cannot rollback) |
**Problem**: After bind → unbind, face becomes unbound instead of returning to stranger.
### Proposed Fix (preserve stranger_id on bind)
| Operation | SQL Effect | Result |
|-----------|------------|--------|
| `bind_face_to_identity` | `SET identity_id=X` (keep stranger_id) | Stranger info preserved |
| `bind_trace_to_identity` | `SET identity_id=X` (keep stranger_id) | Stranger info preserved |
| `merge_identity` | `SET identity_id=X` (keep stranger_id) | Stranger info preserved |
| `unbind_face` | `SET identity_id=NULL` | Returns to stranger (if stranger_id exists) |
**Change Required**: Remove `, stranger_id = NULL` from three UPDATE queries in `identity_binding.rs`.
---
## Why Dangling Happens
Dangling occurs when `face_detections.identity_id` points to a deleted row in `identities` table.
### Root Cause
At the time of migration, `face_detections.identity_id` **had no FK constraint** to `identities.id`. This allowed:
1. `DELETE FROM identities WHERE source='auto'` succeeded without error
2. `face_detections.identity_id` values remained unchanged (pointing to deleted IDs)
3. No `ON DELETE SET NULL` triggered because no FK existed
### Prevention
With FK constraint in place:
```sql
ALTER TABLE face_detections
ADD CONSTRAINT fk_face_detections_identity
FOREIGN KEY (identity_id) REFERENCES identities(id) ON DELETE SET NULL;
```
Deleting an identity would automatically set `face_detections.identity_id = NULL` (no dangling).
### Current Status
After migration cleanup:
- Public schema: FK `fk_face_detections_stranger` exists (on `stranger_id`)
- Public schema: FK `fk_face_detections_identity` **does not exist** (historical reason)
- Dev schema: Same state as public
---
## API Endpoint
### `GET /api/v1/file/:file_uuid/faces`
**Purpose**: List all face detections in a file with binding state.
**Query Parameters**:
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `page` | int | 1 | Page number |
| `page_size` | int | 50 | Items per page |
| `binding` | string | — | Filter: `identity`, `stranger`, `dangling`, `unbound` |
| `trace_id` | int | — | Filter by trace ID |
| `min_confidence` | float | — | Minimum confidence (0.01.0) |
| `start_frame` | int | — | Start frame (inclusive) |
| `end_frame` | int | — | End frame (inclusive) |
**Response Example**:
```json
{
"success": true,
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"total": 52244,
"page": 1,
"page_size": 2,
"data": [
{
"id": 661508,
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"frame_number": 21297,
"timestamp_secs": 851.88,
"face_id": "21297_0",
"trace_id": 485,
"bbox": { "x": 1072, "y": 390, "width": 56, "height": 56 },
"confidence": 0.6114,
"binding": {
"identity_id": 9,
"identity_uuid": "c3545906-c82d-4b66-aa1d-150bc02decce",
"identity_name": "Audrey Hepburn"
}
}
]
}
```
---
## Migration Reference
### `migrate_strangers_table.sql` (Summary)
1. `CREATE TABLE strangers`
2. Insert unmatched traces → strangers
3. Preserve auto identity metadata → strangers (NULL file_uuid/trace_id)
4. Update `face_detections.stranger_id` → FK
5. Add FK constraint
6. Delete legacy `identity_bindings` for auto identities
7. Delete `identities` where `source='auto'`
8. Cleanup dangling `identity_id` (set to NULL)
### Cleanup SQL (Dangling)
```sql
UPDATE face_detections fd
SET identity_id = NULL
WHERE NOT EXISTS (SELECT 1 FROM identities i WHERE i.id = fd.identity_id)
AND fd.identity_id IS NOT NULL;
```
---
*Updated: 2026-05-25*

1
docs_v1.0/doc-wasm Symbolic link
View File

@@ -0,0 +1 @@
doc_wasm

View File

@@ -38,7 +38,7 @@ a { color: #0066cc; }
<h2>Search APIs</h2>
<h3><code>POST /api/v1/search/smart</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<strong>Scope</strong>: global / file-level</p>
<p>Semantic vector search using EmbeddingGemma-300m. Generates a query embedding via EmbeddingGemma (port 11436), then searches pgvector <code>story_parent</code> and <code>llm_parent</code> chunks by cosine similarity.</p>
<h4>Request Parameters</h4>
<table class="table">
@@ -53,13 +53,6 @@ a { color: #0066cc; }
</thead>
<tbody>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>Yes</td>
<td></td>
<td>File UUID to search within</td>
</tr>
<tr>
<td><code>query</code></td>
<td>string</td>
<td>Yes</td>
@@ -67,6 +60,13 @@ a { color: #0066cc; }
<td>Search text</td>
</tr>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>No</td>
<td></td>
<td>File UUID to search within. If omitted, searches all files (global search)</td>
</tr>
<tr>
<td><code>limit</code></td>
<td>integer</td>
<td>No</td>
@@ -89,7 +89,14 @@ a { color: #0066cc; }
</tr>
</tbody>
</table>
<h4>Example</h4>
<h4>Example (Global Search)</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/search/smart&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;query&quot;: &quot;Audrey Hepburn&quot;}&#39;</span>
</code></pre></div>
<h4>Example (File-specific Search)</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/search/smart&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
@@ -101,6 +108,7 @@ a { color: #0066cc; }
<span class="w"> </span><span class="nt">&quot;query&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Audrey Hepburn&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;results&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a6fb22eebefaef17e62af874997c5944&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;parent_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1087822</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;scene_order&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1087822</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start_frame&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">104438</span><span class="p">,</span>
@@ -118,10 +126,26 @@ a { color: #0066cc; }
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>results[].file_uuid</code></td>
<td>string</td>
<td>File UUID where result was found</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>POST /api/v1/search/universal</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<strong>Scope</strong>: global / file-level</p>
<p>Multi-type BM25 full-text search across chunks, frames, and persons. Uses PostgreSQL <code>tsvector</code>.</p>
<h4>Request Parameters</h4>
<table class="table">
@@ -147,7 +171,7 @@ a { color: #0066cc; }
<td>string</td>
<td>No</td>
<td></td>
<td>Restrict to specific file</td>
<td>Restrict to specific file. If omitted, searches all files (global search)</td>
</tr>
<tr>
<td><code>types</code></td>
@@ -179,7 +203,14 @@ a { color: #0066cc; }
</tr>
</tbody>
</table>
<h4>Example</h4>
<h4>Example (Global Search)</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/search/universal&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;query&quot;: &quot;Cary Grant&quot;}&#39;</span>
</code></pre></div>
<h4>Example (File-specific Search)</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/search/universal&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
@@ -191,6 +222,7 @@ a { color: #0066cc; }
<span class="w"> </span><span class="nt">&quot;results&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;chunk&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a6fb22eebefaef17e62af874997c5944&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;chunk_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;bd80fec92b0b6963d177a2c55bf713e2_2&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;chunk_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;story_child&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start_frame&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5103</span><span class="p">,</span>
@@ -199,6 +231,25 @@ a { color: #0066cc; }
<span class="w"> </span><span class="nt">&quot;end_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">213.64</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;text&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;[213s-214s] Cary Grant: \&quot;Olá!\&quot;&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;score&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.9</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;frame&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a6fb22eebefaef17e62af874997c5944&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;frame_number&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5105</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;timestamp&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">212.72</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;score&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.7</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;objects&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;ocr_texts&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;faces&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;person&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a6fb22eebefaef17e62af874997c5944&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">12</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a9a901056d6b46ff92da0c3c1a57dff4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Cary Grant&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;appearance_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">542</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;score&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.95</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="p">,</span>
@@ -206,16 +257,140 @@ a { color: #0066cc; }
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>results[].type</code></td>
<td>string</td>
<td>Result type: <code>chunk</code>, <code>frame</code>, or <code>person</code></td>
</tr>
<tr>
<td><code>results[].file_uuid</code></td>
<td>string</td>
<td>File UUID where result was found (all types)</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>POST /api/v1/search/frames</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<strong>Scope</strong>: global / file-level</p>
<p>Search face detection frames by identity name or trace ID.</p>
<hr />
<h3><code>POST /api/v1/search/identity_text</code></h3>
<h3><code>GET /api/v1/search/identity_text</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Search text chunks spoken by a specific identity.</p>
<strong>Scope</strong>: global / file-level</p>
<p>Search text chunks → find associated identities. Returns chunks where face detections overlap with text content.</p>
<h4>Query Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>q</code></td>
<td>string</td>
<td>Yes</td>
<td></td>
<td>Search text (ILIKE match)</td>
</tr>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>No</td>
<td></td>
<td>Restrict to specific file. If omitted, searches all files (global search)</td>
</tr>
<tr>
<td><code>limit</code></td>
<td>integer</td>
<td>No</td>
<td>50</td>
<td>Max results</td>
</tr>
<tr>
<td><code>page</code></td>
<td>integer</td>
<td>No</td>
<td>1</td>
<td>Page number</td>
</tr>
<tr>
<td><code>page_size</code></td>
<td>integer</td>
<td>No</td>
<td>50</td>
<td>Items per page</td>
</tr>
</tbody>
</table>
<h4>Example (Global Search)</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/search/identity_text?q=love&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span>
</code></pre></div>
<h4>Example (File-specific Search)</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/search/identity_text?file_uuid=</span><span class="nv">$FILE_UUID</span><span class="s2">&amp;q=love&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;results&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a6fb22eebefaef17e62af874997c5944&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;chunk_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;llm_parent_..._256_270&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">256.256</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;end_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">270.228</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;text_content&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;...lack of affection...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Audrey Hepburn&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_source&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;tmdb&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;trace_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">94</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>results[].file_uuid</code></td>
<td>string</td>
<td>File UUID where chunk was found</td>
</tr>
<tr>
<td><code>results[].identity_id</code></td>
<td>integer</td>
<td>Identity ID if face was detected</td>
</tr>
<tr>
<td><code>results[].trace_id</code></td>
<td>integer</td>
<td>Face trace ID</td>
</tr>
</tbody>
</table>
<hr />
<h3>Visual Search</h3>
<table class="table">
@@ -282,7 +457,7 @@ a { color: #0066cc; }
</tbody>
</table>
<hr />
<p><em>Updated: 2026-05-19 12:49:24</em></p>
<p><em>Updated: 2026-05-27 — Added global search support for smart, universal, identity_text APIs</em></p>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -294,6 +294,7 @@ curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</s
<hr />
<h3><code>GET /api/v1/file/:file_uuid/thumbnail</code></h3>
<p>Extract a single frame from a video as JPEG image. Uses FFmpeg <code>select</code> filter.</p>
<p>When <code>frame</code> is omitted, the system automatically selects the best representative frame using the TKG bridge (see algorithm below).</p>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<h4>Query Parameters</h4>
@@ -311,9 +312,9 @@ curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</s
<tr>
<td><code>frame</code></td>
<td>integer</td>
<td>Yes</td>
<td></td>
<td>Zero-based frame number to extract</td>
<td>No</td>
<td>auto-detect</td>
<td>Zero-based frame number to extract. Omit for auto-detect.</td>
</tr>
<tr>
<td><code>x</code></td>
@@ -346,8 +347,23 @@ curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</s
</tbody>
</table>
<p>All four crop params (<code>x</code>, <code>y</code>, <code>w</code>, <code>h</code>) must be provided together or omitted.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># Extract frame 1000 (full frame)</span>
<h4>Auto-detect Algorithm</h4>
<p>When <code>frame</code> is not provided, the endpoint finds the best frame using this fallback chain:</p>
<ol>
<li><strong>Main characters</strong>: find the two identities with the most face detections (TMDb source)</li>
<li><strong>Mutual gaze</strong>: if their face traces have a TKG <code>CO_OCCURS_WITH</code> edge with <code>mutual_gaze=true</code>, take <code>first_frame</code></li>
<li><strong>Co-occurrence</strong>: fallback to the first frame where both identities appear together</li>
<li><strong>Single identity</strong>: if only one main identity exists, take its highest-quality face frame</li>
<li><strong>Any identity</strong>: fallback to the best-quality face frame across all identities</li>
<li><strong>Error</strong>: if no face exists, returns <code>404</code></li>
</ol>
<p>The selected frame is constrained to the <strong>first half of the video</strong> (<code>total_frames / 2</code>).</p>
<h4>Examples</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># Auto-detect best representative frame</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/thumbnail&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span>-o<span class="w"> </span>representative.jpg
<span class="c1"># Extract frame 1000 (full frame)</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/bd80fec92b0b6963d177a2c55bf713e2/thumbnail?frame=1000&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span>-o<span class="w"> </span>frame_1000.jpg
@@ -359,10 +375,185 @@ curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</s
<h4>Response</h4>
<ul>
<li><strong>200</strong>: <code>image/jpeg</code> binary data</li>
<li><strong>404</strong>: File not found</li>
<li><strong>404</strong>: File not found / No faces in file (auto-detect)</li>
<li><strong>500</strong>: FFmpeg error (e.g., frame number exceeds video duration)</li>
</ul>
<h3><code>GET /api/v1/file/:file_uuid/clip</code></h3>
<h4>Technical Details</h4>
<table class="table">
<thead>
<tr>
<th>Detail</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Backend</strong></td>
<td>FFmpeg (<code>ffmpeg-full</code>)</td>
</tr>
<tr>
<td><strong>Filter</strong></td>
<td><code>select=eq(n\,FRAME)</code> to select frame, optional <code>crop=W:H:X:Y</code></td>
</tr>
<tr>
<td><strong>Output</strong></td>
<td>Single JPEG via pipe (<code>image2pipe</code>, <code>mjpeg</code> codec)</td>
</tr>
<tr>
<td><strong>Cache</strong></td>
<td><code>Cache-Control: public, max-age=86400</code> (24h)</td>
</tr>
<tr>
<td><strong>Frame number</strong></td>
<td>Zero-based (<code>frame=0</code> = first frame of video)</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>GET /api/v1/file/:file_uuid/representative-frame</code></h3>
<p>Return JSON metadata about the best representative frame for the video. Uses the same auto-detect algorithm as <code>GET /thumbnail</code> (without crop support).</p>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/representative-frame&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;.&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;aeed71342a899fe4b4c57b7d41bcb692&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;frame_number&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">38165</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;timestamp_secs&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">1526.6</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;face_quality&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">37292.97</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;main_identities&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;c3545906-c82d-4b66-aa1d-150bc02decce&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Audrey Hepburn&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;face_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">16456</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2b0ddefe-e2a9-4533-9308-b375594604d5&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Cary Grant&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;face_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">10643</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;traces&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;trace_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">919</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2b0ddefe-e2a9-4533-9308-b375594604d5&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Cary Grant&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;x&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">764</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;y&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">237</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;width&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">199</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;height&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">199</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;confidence&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.8426</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;trace_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">920</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;c3545906-c82d-4b66-aa1d-150bc02decce&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Audrey Hepburn&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;x&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1143</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;y&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">312</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;width&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">215</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;height&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">215</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;confidence&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.8068</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<h4>Response Fields</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>frame_number</code></td>
<td>integer</td>
<td>Selected representative frame number (primary coordinate)</td>
</tr>
<tr>
<td><code>timestamp_secs</code></td>
<td>float</td>
<td>Time in seconds (derived from <code>frame_number / fps</code>)</td>
</tr>
<tr>
<td><code>face_quality</code></td>
<td>float</td>
<td>Quality score <code>area × confidence</code> of the best face at this frame</td>
</tr>
<tr>
<td><code>main_identities</code></td>
<td>array</td>
<td>Top 2 most frequent TMDb identities in the file</td>
</tr>
<tr>
<td><code>main_identities[].name</code></td>
<td>string</td>
<td>Identity display name</td>
</tr>
<tr>
<td><code>main_identities[].face_count</code></td>
<td>integer</td>
<td>Total face detections count</td>
</tr>
<tr>
<td><code>traces</code></td>
<td>array</td>
<td>All face traces present at the selected frame</td>
</tr>
<tr>
<td><code>traces[].trace_id</code></td>
<td>integer</td>
<td>Face trace ID</td>
</tr>
<tr>
<td><code>traces[].identity_uuid</code></td>
<td>string or null</td>
<td>Matched identity UUID</td>
</tr>
<tr>
<td><code>traces[].name</code></td>
<td>string or null</td>
<td>Identity name</td>
</tr>
<tr>
<td><code>traces[].x, y, width, height</code></td>
<td>integer</td>
<td>Bounding box coordinates</td>
</tr>
<tr>
<td><code>traces[].confidence</code></td>
<td>float</td>
<td>Detection confidence (0.01.0)</td>
</tr>
</tbody>
</table>
<h4>Error Responses</h4>
<table class="table">
<thead>
<tr>
<th>HTTP</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>404</code></td>
<td>File not found / No faces in file</td>
</tr>
<tr>
<td><code>500</code></td>
<td>Database error</td>
</tr>
</tbody>
</table>
<p>Extract a video clip (time range) as MPEG-TS stream. Uses FFmpeg <code>-ss</code> fast seek.</p>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>

View File

@@ -209,7 +209,191 @@ a { color: #0066cc; }
</tbody>
</table>
<hr />
<p><em>Updated: 2026-05-19 12:49:24</em></p>
<h2>POST /api/v1/agents/search</h2>
<p>Conversational search assistant. Uses Gemma4 function calling to automatically decide which tools to call based on the user's natural language query. Supports multi-turn conversation.</p>
<h3>Request</h3>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;query&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Audrey Hepburn 和 Cary Grant 第一次同框在哪個 frame&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;conversation_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>query</code></td>
<td>string</td>
<td></td>
<td>自然語言查詢</td>
</tr>
<tr>
<td><code>conversation_id</code></td>
<td>string</td>
<td></td>
<td>延續對話時傳入;新對話不傳</td>
</tr>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td></td>
<td>Portal 有選中檔案時可指定</td>
</tr>
</tbody>
</table>
<h3>Response</h3>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;conversation_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;conv_abc123&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;answer&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;在 Charade (1963) 中Audrey Hepburn 與 Cary Grant 第一次同框在第 38619 幀(約 1544.76 秒)。&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;need_input&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;sources&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;tool&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;tkg_query&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;result&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;{\&quot;first_cooccurrence\&quot;:{\&quot;frame\&quot;:38619,\&quot;timestamp_secs\&quot;:1544.76}}&quot;</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>conversation_id</code></td>
<td>string</td>
<td>後續對話需要傳入此 ID</td>
</tr>
<tr>
<td><code>answer</code></td>
<td>string</td>
<td>Agent 的自然語言回答(或反問)</td>
</tr>
<tr>
<td><code>need_input</code></td>
<td>boolean</td>
<td><code>true</code> 表示 agent 需要更多資訊才能回答</td>
</tr>
<tr>
<td><code>suggestions</code></td>
<td>string[]</td>
<td>建議用戶提供的線索(當 <code>need_input=true</code></td>
</tr>
<tr>
<td><code>sources</code></td>
<td>array</td>
<td>引用的工具執行結果</td>
</tr>
</tbody>
</table>
<h3>Conversation Flow</h3>
<div class="codehilite"><pre><span></span><code>Round 1: POST /agents/search { query: &quot;我想看男女主角同框&quot; }
→ need_input: true, suggestions: [&quot;片名&quot;, &quot;演員&quot;, &quot;年代&quot;]
→ answer: &quot;請問是哪部電影?請提供更多線索&quot;
Round 2: POST /agents/search { query: &quot;奧黛麗赫本&quot;, conversation_id: &quot;...&quot; }
→ need_input: false
→ answer: &quot;找到 Charade (1963)Audrey Hepburn 和 Cary Grant...&quot;
</code></pre></div>
<h3>Available Tools</h3>
<p>Agent 內部使用 Gemma4 function calling 自動調用以下工具:</p>
<table class="table">
<thead>
<tr>
<th>Tool</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>find_file</code></td>
<td>透過片名/演員/年份關鍵字搜尋影片,回傳 file_uuid + has_data 狀態</td>
</tr>
<tr>
<td><code>list_files</code></td>
<td>列出近期註冊的影片</td>
</tr>
<tr>
<td><code>tkg_query</code></td>
<td>查詢人物互動資料7 種子類型top_identities、first_cooccurrence、identity_details、mutual_gaze、interaction_network、identity_traces、file_info</td>
</tr>
<tr>
<td><code>smart_search</code></td>
<td>文字內容 ILIKE 搜尋 chunk可指定 file_uuid 限制範圍)</td>
</tr>
<tr>
<td><code>get_identity_detail</code></td>
<td>查詢單一身份的詳細資料角色、TMDb 資訊)</td>
</tr>
<tr>
<td><code>get_file_info</code></td>
<td>查詢影片基本資訊(片長、解析度)</td>
</tr>
<tr>
<td><code>get_representative_frame</code></td>
<td>查詢影片最具代表性的 frame 資訊</td>
</tr>
</tbody>
</table>
<h3>Design Principles</h3>
<ul>
<li><strong>用戶不需要知道 file_uuid</strong> — Agent 會自動用 <code>find_file</code> 搜尋或反問</li>
<li><strong>不推薦無資料的影片</strong><code>has_data=false</code> 的影片不會被推薦給用戶</li>
<li><strong>多輪對話</strong> — 透過 <code>conversation_id</code> 延續上下文agent 會記得之前的交流</li>
<li><strong>並行工具呼叫</strong> — Gemma4 可以一次呼叫多個工具再綜合回答</li>
</ul>
<h3>Model</h3>
<table class="table">
<thead>
<tr>
<th>Detail</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>LLM</strong></td>
<td>Gemma4 26B (Q5_K_M)</td>
</tr>
<tr>
<td><strong>Engine</strong></td>
<td>llama.cpp at <code>localhost:8082</code></td>
</tr>
<tr>
<td><strong>Endpoint</strong></td>
<td><code>/v1/chat/completions</code> (OpenAI-compatible)</td>
</tr>
<tr>
<td><strong>Temperature</strong></td>
<td>0.1</td>
</tr>
<tr>
<td><strong>Max rounds</strong></td>
<td>5 (tool call iterations)</td>
</tr>
<tr>
<td><strong>Conversation TTL</strong></td>
<td>30 minutes</td>
</tr>
</tbody>
</table>
<hr />
<p><em>Updated: 2026-05-22</em></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,470 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>14 Identity History - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.logout-btn { font-size: 13px; color: #999; text-decoration: none; }
.logout-btn:hover { color: #cc0000; }
</style>
</head>
<body>
<div class="container">
<div class="topbar">
<a class="back" href="index.html">&larr; Back to index</a>
<a class="logout-btn" href="#" onclick="fetch('/api/v1/auth/logout',{method:'POST'}).then(()=>window.location.reload());return false">Logout</a>
</div>
<!-- module: identity_history -->
<!-- description: Identity PATCH operation history, undo, and redo -->
<!-- depends: 01_auth, 07_identity -->
<h2>Identity Operation History</h2>
<p>Every <code>PATCH /api/v1/identity/:identity_uuid</code> automatically records a before/after snapshot in the <code>identity_history</code> table. Use undo/redo to revert or reapply changes, and history to inspect the operation log.</p>
<h3>History System Overview</h3>
<table class="table">
<thead>
<tr>
<th>Property</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Storage</td>
<td>PostgreSQL <code>identity_history</code> table</td>
</tr>
<tr>
<td>Snapshot</td>
<td>Full identity record (all fields) before and after each PATCH</td>
</tr>
<tr>
<td>Max records</td>
<td>256 per identity (oldest auto-deleted when limit exceeded)</td>
</tr>
<tr>
<td>Undo steps</td>
<td>Unlimited (no expiry, no step limit)</td>
</tr>
<tr>
<td>Redo stack</td>
<td>Cleared on new PATCH (<code>is_undone=true</code> records are deleted)</td>
</tr>
</tbody>
</table>
<h4>Stack Model</h4>
<div class="codehilite"><pre><span></span><code>PATCH 1 → PATCH 2 → PATCH 3 (undo stack, is_undone=false)
↓ undo
PATCH 1 → PATCH 2 (undo stack)
PATCH 3 (redo stack, is_undone=true)
↓ redo
PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
</code></pre></div>
<p>A new PATCH after undo clears the redo stack (PATCH 3 is lost).</p>
<hr />
<h3><code>POST /api/v1/identity/:identity_uuid/undo</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Undo the most recent PATCH operations. Restores the identity's <code>before_snapshot</code> and marks the history records as undone.</p>
<h4>Request (JSON)</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>steps</code></td>
<td>integer</td>
<td>No</td>
<td><code>1</code></td>
<td>Number of undo steps to apply (max records undone in one call)</td>
</tr>
</tbody>
</table>
<h4>Behavior</h4>
<ul>
<li>Queries <code>is_undone=false</code> records, ordered by <code>created_at DESC</code></li>
<li>Restores <code>name</code>, <code>identity_type</code>, <code>source</code>, <code>status</code>, <code>metadata</code>, <code>tmdb_id</code>, <code>tmdb_profile</code> from the last record's <code>before_snapshot</code></li>
<li>Marks the undone records as <code>is_undone=true</code> with <code>undone_at=NOW()</code></li>
<li>Syncs <code>identity.json</code> to disk</li>
<li>Updates <code>_index.json</code> if name changed</li>
</ul>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/undo&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;steps&quot;: 1}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a9a901056d6b46ff92da0c3c1a57dff4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;undone_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;current_state&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a9a901056d6b46ff92da0c3c1a57dff4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Cary Grant&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;people&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;source&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;tmdb&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;confirmed&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;metadata&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span>
<span class="w"> </span><span class="nt">&quot;tmdb_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">112</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;tmdb_profile&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
<span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>undone_count</code></td>
<td>integer</td>
<td>Number of history records undone</td>
</tr>
<tr>
<td><code>current_state</code></td>
<td>object</td>
<td>Full identity state after undo</td>
</tr>
</tbody>
</table>
<h4>Error Responses</h4>
<table class="table">
<thead>
<tr>
<th>HTTP</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>400</code></td>
<td>No undo operations available</td>
</tr>
<tr>
<td><code>404</code></td>
<td>Identity not found</td>
</tr>
<tr>
<td><code>500</code></td>
<td>Database error</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>POST /api/v1/identity/:identity_uuid/redo</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Redo previously undone PATCH operations. Restores the identity's <code>after_snapshot</code> and marks the history records as no longer undone.</p>
<h4>Request (JSON)</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>steps</code></td>
<td>integer</td>
<td>No</td>
<td><code>1</code></td>
<td>Number of redo steps to apply</td>
</tr>
</tbody>
</table>
<h4>Behavior</h4>
<ul>
<li>Queries <code>is_undone=true</code> records, ordered by <code>created_at DESC</code></li>
<li>Restores all identity fields from the last record's <code>after_snapshot</code></li>
<li>Marks records as <code>is_undone=false</code> with <code>undone_at=NULL</code></li>
<li>Syncs <code>identity.json</code> to disk</li>
<li>Updates <code>_index.json</code> if name changed</li>
</ul>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/redo&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;steps&quot;: 1}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a9a901056d6b46ff92da0c3c1a57dff4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;redone_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;current_state&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a9a901056d6b46ff92da0c3c1a57dff4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;John Smith&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;people&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;source&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;tmdb&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;confirmed&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;metadata&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;aliases&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="err">...</span><span class="p">]</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;tmdb_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">112</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;tmdb_profile&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
<span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>redone_count</code></td>
<td>integer</td>
<td>Number of history records redone</td>
</tr>
<tr>
<td><code>current_state</code></td>
<td>object</td>
<td>Full identity state after redo</td>
</tr>
</tbody>
</table>
<h4>Error Responses</h4>
<table class="table">
<thead>
<tr>
<th>HTTP</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>400</code></td>
<td>No redo operations available</td>
</tr>
<tr>
<td><code>404</code></td>
<td>Identity not found</td>
</tr>
<tr>
<td><code>500</code></td>
<td>Database error</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>GET /api/v1/identity/:identity_uuid/history</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: identity-level</p>
<p>Query the operation history for an identity. Returns paginated records with undo/redo stack counts.</p>
<h4>Query Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>page</code></td>
<td>integer</td>
<td>No</td>
<td><code>1</code></td>
<td>Page number (1-indexed)</td>
</tr>
<tr>
<td><code>limit</code></td>
<td>integer</td>
<td>No</td>
<td><code>20</code></td>
<td>Items per page (max 100)</td>
</tr>
</tbody>
</table>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identity_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a9a901056d6b46ff92da0c3c1a57dff4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;undo_stack_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;redo_stack_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;results&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;history_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">42</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;operation&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;update&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;is_undone&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;created_at&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-05-27T12:00:00Z&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;undone_at&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;history_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">41</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;operation&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;update&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;is_undone&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;created_at&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-05-27T11:30:00Z&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;undone_at&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-05-27T13:00:00Z&quot;</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>total</code></td>
<td>integer</td>
<td>Total history records for this identity</td>
</tr>
<tr>
<td><code>undo_stack_count</code></td>
<td>integer</td>
<td>Records available for undo (<code>is_undone=false</code>)</td>
</tr>
<tr>
<td><code>redo_stack_count</code></td>
<td>integer</td>
<td>Records available for redo (<code>is_undone=true</code>)</td>
</tr>
<tr>
<td><code>results[].history_id</code></td>
<td>integer</td>
<td>History record ID</td>
</tr>
<tr>
<td><code>results[].operation</code></td>
<td>string</td>
<td>Operation type (<code>"update"</code> for PATCH)</td>
</tr>
<tr>
<td><code>results[].is_undone</code></td>
<td>boolean</td>
<td>Whether the operation has been undone</td>
</tr>
<tr>
<td><code>results[].created_at</code></td>
<td>string</td>
<td>When the PATCH was applied</td>
</tr>
<tr>
<td><code>results[].undone_at</code></td>
<td>string</td>
<td>When the undo occurred (null if not undone)</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/history?page=1&amp;limit=10&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span>
</code></pre></div>
<h4>Error Responses</h4>
<table class="table">
<thead>
<tr>
<th>HTTP</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>404</code></td>
<td>Identity not found</td>
</tr>
<tr>
<td><code>500</code></td>
<td>Database error</td>
</tr>
</tbody>
</table>
<hr />
<h3>Comparison: PATCH Undo vs Merge Undo</h3>
<table class="table">
<thead>
<tr>
<th>Aspect</th>
<th>PATCH Undo/Redo</th>
<th>Merge Undo</th>
</tr>
</thead>
<tbody>
<tr>
<td>Storage</td>
<td>PostgreSQL <code>identity_history</code></td>
<td>MongoDB <code>identity_merge_history</code></td>
</tr>
<tr>
<td>Trigger</td>
<td>Every PATCH</td>
<td>Every mergeinto with <code>keep_history=true</code></td>
</tr>
<tr>
<td>Undo deadline</td>
<td>None (unlimited)</td>
<td>24 hours</td>
</tr>
<tr>
<td>Redo support</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Step undo</td>
<td>Yes (<code>steps</code> param)</td>
<td>No (full undo only)</td>
</tr>
<tr>
<td>Max records</td>
<td>256 per identity</td>
<td>Unlimited</td>
</tr>
</tbody>
</table>
<hr />
<p><em>Updated: 2026-05-28</em></p>
</div>
</body>
</html>

View File

@@ -29,7 +29,7 @@ a:hover td { background: #f8f8f8; border-radius: 4px; }
<a class="logout-btn" href="#" onclick="fetch('/api/v1/auth/logout',{method:'POST'}).then(()=>window.location.reload());return false">Logout</a>
</div>
<p class="subtitle">API 參考手冊 — 登入後可瀏覽各模組文件</p>
<table><tr onclick="window.location='11_error_codes.html'" style="cursor:pointer"><td class="cn">錯誤碼</td><td class="en">Error Codes</td></tr></table>
<table><tr onclick="window.location='11_error_codes.html'" style="cursor:pointer"><td class="cn">錯誤碼</td><td class="en">Error Codes</td></tr><tr onclick="window.location='14_identity_history.html'" style="cursor:pointer"><td class="cn">14 Identity History</td><td class="en"></td></tr></table>
</div>
</body>
</html>

View File

@@ -0,0 +1,358 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Api Access - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<h1>Momentry Core API 存取指南</h1>
<table class="table">
<thead>
<tr>
<th>項目</th>
<th>內容</th>
</tr>
</thead>
<tbody>
<tr>
<td>版本</td>
<td>V1.3</td>
</tr>
<tr>
<td>日期</td>
<td>2026-03-25</td>
</tr>
<tr>
<td>用途</td>
<td>API 存取方式、端點與整合指南</td>
</tr>
</tbody>
</table>
<hr />
<h2>版本歷史</h2>
<table class="table">
<thead>
<tr>
<th>版本</th>
<th>日期</th>
<th>目的</th>
<th>操作人</th>
<th>工具/模型</th>
</tr>
</thead>
<tbody>
<tr>
<td>V1.3</td>
<td>2026-03-25</td>
<td>更新: n8n 搜尋回傳 <code>file_path</code> 取代 <code>media_url</code>,新增 API Key 驗證說明</td>
<td>OpenCode</td>
<td>deepseek-reasoner</td>
</tr>
<tr>
<td>V1.2</td>
<td>2026-03-24</td>
<td>更新網址與服務列表</td>
<td>Warren</td>
<td>OpenCode / MiniMax M2.5</td>
</tr>
<tr>
<td>V1.1</td>
<td>2026-03-23</td>
<td>初始版本</td>
<td>Warren</td>
<td>OpenCode / MiniMax M2.5</td>
</tr>
</tbody>
</table>
<hr />
<h2>基本網址</h2>
<table class="table">
<thead>
<tr>
<th>環境</th>
<th>URL</th>
<th>說明</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>本地開發</strong></td>
<td><code>http://localhost:3002</code></td>
<td>直接訪問 API繞過反向代理</td>
</tr>
<tr>
<td><strong>外部訪問</strong></td>
<td><code>https://m5api.momentry.ddns.net</code></td>
<td>通過 Caddy 反向代理訪問,需網路可達</td>
</tr>
</tbody>
</table>
<h3>何時使用哪個 URL</h3>
<p><strong>使用 <code>localhost:3002</code></strong>
- 開發/測試環境
- 直接在伺服器上操作
- 當反向代理有問題時</p>
<p><strong>使用 <code>m5api.momentry.ddns.net</code></strong>
- n8n workflow 中呼叫 API
- 外部系統整合
- 生產環境</p>
<h2>認證</h2>
<p>所有 <code>/api/v1/*</code> 端點(除了健康檢查 <code>/health</code><code>/health/detailed</code>)都需要 API Key 認證。</p>
<p>請在請求標頭中加入:</p>
<div class="codehilite"><pre><span></span><code>X-API-Key: YOUR_API_KEY
</code></pre></div>
<p><strong>目前示範使用的 API Key</strong>: <code>demo_api_key_12345</code></p>
<blockquote>
<p><strong>注意</strong>: 正式環境請使用安全的 API Key 管理機制,避免在客戶端暴露 API Key。</p>
</blockquote>
<hr />
<h2>影片搜尋 API</h2>
<h3>語意搜尋</h3>
<p><strong>端點:</strong> <code>POST /api/v1/search</code></p>
<p><strong>請求:</strong></p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;query&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;charade&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;limit&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a1b10138a6bbb0cd&quot;</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>欄位</th>
<th>類型</th>
<th>必填</th>
<th>說明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>query</code></td>
<td>字串</td>
<td></td>
<td>搜尋文字</td>
</tr>
<tr>
<td><code>limit</code></td>
<td>整數</td>
<td></td>
<td>最大回傳結果數(預設 10</td>
</tr>
<tr>
<td><code>uuid</code></td>
<td>字串</td>
<td></td>
<td>依影片 UUID 過濾</td>
</tr>
</tbody>
</table>
<p><strong>回應:</strong></p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;results&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a1b10138a6bbb0cd&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;chunk_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;sentence_0006&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;chunk_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;sentence&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">48.8</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;end_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">55.44</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;text&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;fun plot twists, Woody Dialog and charming performances...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;score&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.526</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;query&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;charade&quot;</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h3>n8n 整合搜尋</h3>
<p><strong>端點:</strong> <code>POST /api/v1/n8n/search</code></p>
<p><strong>請求:</strong></p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;query&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;charade&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;limit&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span>
<span class="p">}</span>
</code></pre></div>
<p><strong>回應:</strong></p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;query&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;charade&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;hits&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;sentence_0006&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;vid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;a1b10138a6bbb0cd&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">48.8</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;end&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">55.44</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;title&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Chunk sentence_0006&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;text&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;fun plot twists...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;score&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.526</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_path&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;/Users/accusys/momentry/var/sftpgo/data/demo/Old_Time_Movie_Show_-_Charade_1963.HD.mov&quot;</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<blockquote>
<p><strong>注意</strong>: API 現在返回 <code>file_path</code>(檔案系統路徑)而非 <code>media_url</code>(網頁 URL。如需在網頁中播放影片請將檔案路徑轉換為可訪問的 URL例如透過 SFTPGo 分享連結)。</p>
</blockquote>
<hr />
<h2>影片管理 API</h2>
<h3>列出所有影片</h3>
<p><strong>端點:</strong> <code>GET /api/v1/videos</code></p>
<h3>查詢影片資訊</h3>
<p><strong>端點:</strong> <code>GET /api/v1/lookup?uuid={uuid}</code><code>GET /api/v1/lookup?path={path}</code></p>
<h3>取得處理進度</h3>
<p><strong>端點:</strong> <code>GET /api/v1/progress/{uuid}</code></p>
<hr />
<h2>區塊資料結構</h2>
<p>每個搜尋結果包含影片播放的時間資訊:</p>
<table class="table">
<thead>
<tr>
<th>欄位</th>
<th>說明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>uuid</code></td>
<td>影片識別碼</td>
</tr>
<tr>
<td><code>chunk_id</code></td>
<td>區塊唯一識別碼</td>
</tr>
<tr>
<td><code>chunk_type</code></td>
<td>類型:<code>sentence</code><code>cut</code><code>time_based</code></td>
</tr>
<tr>
<td><code>start_time</code></td>
<td>開始時間(秒)</td>
</tr>
<tr>
<td><code>end_time</code></td>
<td>結束時間(秒)</td>
</tr>
<tr>
<td><code>text</code></td>
<td>語音轉文字內容</td>
</tr>
<tr>
<td><code>score</code></td>
<td>相關性分數0-1</td>
</tr>
</tbody>
</table>
<hr />
<h2>整合範例</h2>
<h3>JavaScript/fetch</h3>
<div class="codehilite"><pre><span></span><code><span class="kd">const</span><span class="w"> </span><span class="nx">response</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="nx">fetch</span><span class="p">(</span><span class="s1">&#39;http://localhost:3002/api/v1/search&#39;</span><span class="p">,</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nx">method</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;POST&#39;</span><span class="p">,</span>
<span class="w"> </span><span class="nx">headers</span><span class="o">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span>
<span class="w"> </span><span class="s1">&#39;Content-Type&#39;</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;application/json&#39;</span><span class="p">,</span>
<span class="w"> </span><span class="s1">&#39;X-API-Key&#39;</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;YOUR_API_KEY&#39;</span><span class="w"> </span><span class="c1">// 替換為實際的 API Key</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nx">body</span><span class="o">:</span><span class="w"> </span><span class="nb">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span><span class="w"> </span><span class="nx">query</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;charade&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">limit</span><span class="o">:</span><span class="w"> </span><span class="mf">5</span><span class="w"> </span><span class="p">})</span>
<span class="p">});</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">data</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="nx">response</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">results</span><span class="p">);</span>
</code></pre></div>
<h3>PHP/cURL</h3>
<div class="codehilite"><pre><span></span><code><span class="x">$ch = curl_init(&#39;http://localhost:3002/api/v1/search&#39;);</span>
<span class="x">curl_setopt($ch, CURLOPT_POST, true);</span>
<span class="x">curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([</span>
<span class="x"> &#39;query&#39; =&gt; &#39;charade&#39;,</span>
<span class="x"> &#39;limit&#39; =&gt; 5</span>
<span class="x">]));</span>
<span class="x">curl_setopt($ch, CURLOPT_HTTPHEADER, [</span>
<span class="x"> &#39;Content-Type: application/json&#39;,</span>
<span class="x"> &#39;X-API-Key: YOUR_API_KEY&#39; // 替換為實際的 API Key</span>
<span class="x">]);</span>
<span class="x">$response = curl_exec($ch);</span>
<span class="x">$data = json_decode($response, true);</span>
</code></pre></div>
<hr />
<h2>影片嵌入網址</h2>
<blockquote>
<p><strong>重要</strong>: API 現在返回 <code>file_path</code>(檔案系統路徑),而非直接可訪問的網址。您需要將檔案路徑轉換為 SFTPGo 分享連結才能嵌入影片。</p>
</blockquote>
<p><strong>檔案路徑轉換為網址:</strong>
- API 返回的 <code>file_path</code> 範例:<code>/Users/accusys/momentry/var/sftpgo/data/demo/video.mp4</code>
- 對應的 SFTPGo 分享連結:<code>https://wp.momentry.ddns.net/demo/video.mp4</code>
- 轉換方式:移除 <code>/Users/accusys/momentry/var/sftpgo/data/</code> 前綴,將剩餘路徑附加到 <code>https://wp.momentry.ddns.net/</code></p>
<p><strong>手動建立分享連結:</strong>
1. 開啟 SFTPGo Web UI<code>http://localhost:8080</code>
2. 使用帳號 <code>demo</code> / 密碼 <code>demopassword123</code> 登入
3. 導航至 <code>Files</code> → 選擇影片檔案
4. 點擊 <code>Share</code><code>Create Link</code>
5. 複製產生的分享連結</p>
<p>使用搜尋結果中的 <code>start_time</code><code>end_time</code> 來嵌入影片片段。</p>
<hr />
<h2>服務列表</h2>
<table class="table">
<thead>
<tr>
<th>服務</th>
<th>網址</th>
<th>用途</th>
</tr>
</thead>
<tbody>
<tr>
<td>Momentry API</td>
<td><code>http://localhost:3002</code></td>
<td>核心 API</td>
</tr>
<tr>
<td>SFTPGo</td>
<td><code>http://localhost:8080</code></td>
<td>檔案儲存</td>
</tr>
<tr>
<td>Qdrant</td>
<td><code>http://localhost:6333</code></td>
<td>向量搜尋</td>
</tr>
<tr>
<td>PostgreSQL</td>
<td><code>localhost:5432</code></td>
<td>資料庫</td>
</tr>
</tbody>
</table>
<hr />
<h2>示範影片</h2>
<ul>
<li><strong>檔案:</strong> <code>Old_Time_Movie_Show_-_Charade_1963.HD.mov</code></li>
<li><strong>UUID</strong> <code>a1b10138a6bbb0cd</code></li>
<li><strong>長度:</strong> 約 6879 秒(約 1.9 小時)</li>
<li><strong>區塊數:</strong> 3886 個(句子 + 場景 + 時間)</li>
</ul>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Api Error Codes - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<hr />
<p>document_type: "api_reference"
service: "MOMENTRY_CORE"
title: "API Error Codes (API 標準錯誤碼)"
date: "2026-05-17"
version: "V1.1"
status: "active"
owner: "M5"
created_by: "OpenCode"</p>
<hr />
<h1>API Error Codes (API 標準錯誤碼)</h1>
<table class="table">
<thead>
<tr>
<th>項目</th>
<th>內容</th>
</tr>
</thead>
<tbody>
<tr>
<td>目標讀者</td>
<td>developer</td>
</tr>
<tr>
<td>預備知識</td>
<td>需有 API Key</td>
</tr>
</tbody>
</table>
<hr />
<h2>Error Response Format</h2>
<p>All API errors follow this JSON structure:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;error&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;code&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;E001_NOT_FOUND&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Resource not found&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;details&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;resource&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;file_uuid&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;value&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;abc&quot;</span><span class="p">}</span>
<span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<h2>Error Code List</h2>
<h3>Generic Errors (E0xx)</h3>
<table class="table">
<thead>
<tr>
<th>Code</th>
<th>HTTP</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>E001_NOT_FOUND</code></td>
<td>404</td>
<td>Resource not found (file, identity, chunk)</td>
</tr>
<tr>
<td><code>E002_DUPLICATE</code></td>
<td>409</td>
<td>Resource already exists</td>
</tr>
<tr>
<td><code>E003_VALIDATION</code></td>
<td>400</td>
<td>Request parameter validation failed</td>
</tr>
<tr>
<td><code>E004_UNAUTHORIZED</code></td>
<td>401</td>
<td>Invalid API key or token</td>
</tr>
<tr>
<td><code>E005_INTERNAL</code></td>
<td>500</td>
<td>Internal server error</td>
</tr>
</tbody>
</table>
<h3>Processor Errors (E1xx)</h3>
<table class="table">
<thead>
<tr>
<th>Code</th>
<th>HTTP</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>E101_PROCESSOR_FAIL</code></td>
<td>500</td>
<td>Python script execution failed</td>
</tr>
<tr>
<td><code>E102_TIMEOUT</code></td>
<td>504</td>
<td>Processing timeout</td>
</tr>
<tr>
<td><code>E103_RESUME_FAIL</code></td>
<td>500</td>
<td>Resume failed (checkpoint not found)</td>
</tr>
<tr>
<td><code>E104_NO_VIDEO</code></td>
<td>400</td>
<td>Video file path not found</td>
</tr>
</tbody>
</table>
<h3>Identity Errors (E2xx)</h3>
<table class="table">
<thead>
<tr>
<th>Code</th>
<th>HTTP</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>E201_FACE_NOT_FOUND</code></td>
<td>404</td>
<td>Face detection not found</td>
</tr>
<tr>
<td><code>E202_MERGE_CONFLICT</code></td>
<td>409</td>
<td>Identity merge conflict</td>
</tr>
<tr>
<td><code>E203_CANDIDATE_EMPTY</code></td>
<td>404</td>
<td>No candidates available for confirmation</td>
</tr>
</tbody>
</table>
<h3>TMDb Errors (E3xx)</h3>
<table class="table">
<thead>
<tr>
<th>Code</th>
<th>HTTP</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>E301_TMDB_NO_KEY</code></td>
<td>400</td>
<td><code>TMDB_API_KEY</code> environment variable not set</td>
</tr>
<tr>
<td><code>E302_TMDB_UNREACHABLE</code></td>
<td>502</td>
<td>TMDb API unreachable or timed out</td>
</tr>
<tr>
<td><code>E303_TMDB_CACHE_NOT_FOUND</code></td>
<td>200</td>
<td>No local TMDb cache; run prefetch first</td>
</tr>
<tr>
<td><code>E304_TMDB_PROBE_FAILED</code></td>
<td>500</td>
<td>TMDb probe execution failed</td>
</tr>
<tr>
<td><code>E305_TMDB_MOVIE_NOT_FOUND</code></td>
<td>404</td>
<td>No matching TMDb movie found from filename</td>
</tr>
</tbody>
</table>
<hr />
</div>
</body>
</html>

View File

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Api Index - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<hr />
<p>document_type: "api_reference"
service: "MOMENTRY_CORE"
title: "Momentry Core API 文件總覽"
date: "2026-05-17"
version: "V1.0"
status: "active"
owner: "M5"
created_by: "OpenCode"</p>
<hr />
<h1>Momentry Core API 文件總覽</h1>
<table class="table">
<thead>
<tr>
<th>項目</th>
<th>內容</th>
</tr>
</thead>
<tbody>
<tr>
<td>目標讀者</td>
<td>developer</td>
</tr>
<tr>
<td>預備知識</td>
<td>需有 API Key</td>
</tr>
</tbody>
</table>
<hr />
<h2>📁 文件結構</h2>
<div class="codehilite"><pre><span></span><code><span class="n">API_WORKSPACE</span><span class="o">/</span>
<span class="err">└──</span><span class="w"> </span><span class="n">modules</span><span class="o">/</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="n">_template</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">One</span><span class="o">-</span><span class="n">line</span><span class="w"> </span><span class="n">description</span><span class="w"> </span><span class="n">of</span><span class="w"> </span><span class="n">what</span><span class="w"> </span><span class="n">this</span><span class="w"> </span><span class="k">module</span><span class="w"> </span><span class="n">covers</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">01</span><span class="n">_auth</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Authentication</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">login</span><span class="p">,</span><span class="w"> </span><span class="n">logout</span><span class="p">,</span><span class="w"> </span><span class="n">JWT</span><span class="p">,</span><span class="w"> </span><span class="n">session</span><span class="w"> </span><span class="n">cookie</span><span class="p">,</span><span class="w"> </span><span class="n">API</span><span class="w"> </span><span class="n">key</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">02</span><span class="n">_health</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Health</span><span class="w"> </span><span class="n">check</span><span class="w"> </span><span class="n">endpoints</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">03</span><span class="n">_register</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">File</span><span class="w"> </span><span class="n">registration</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">register</span><span class="p">,</span><span class="w"> </span><span class="n">scan</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">04</span><span class="n">_lookup</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">File</span><span class="w"> </span><span class="n">lookup</span><span class="w"> </span><span class="n">by</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="k">and</span><span class="w"> </span><span class="n">unregistration</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">05</span><span class="n">_process</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Processing</span><span class="w"> </span><span class="n">pipeline</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">trigger</span><span class="p">,</span><span class="w"> </span><span class="n">probe</span><span class="p">,</span><span class="w"> </span><span class="n">progress</span><span class="p">,</span><span class="w"> </span><span class="n">jobs</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">06</span><span class="n">_search</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Vector</span><span class="w"> </span><span class="n">search</span><span class="p">,</span><span class="w"> </span><span class="n">hybrid</span><span class="w"> </span><span class="n">search</span><span class="p">,</span><span class="w"> </span><span class="n">BM25</span><span class="p">,</span><span class="w"> </span><span class="n">n8n</span><span class="p">,</span><span class="w"> </span><span class="n">visual</span><span class="p">,</span><span class="w"> </span><span class="n">identity</span><span class="w"> </span><span class="n">text</span><span class="w"> </span><span class="n">search</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">07</span><span class="n">_identity</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Global</span><span class="w"> </span><span class="n">identities</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">CRUD</span><span class="p">,</span><span class="w"> </span><span class="n">detail</span><span class="p">,</span><span class="w"> </span><span class="n">files</span><span class="p">,</span><span class="w"> </span><span class="n">faces</span><span class="p">,</span><span class="w"> </span><span class="n">bind</span><span class="p">,</span><span class="w"> </span><span class="n">unbind</span><span class="p">,</span><span class="w"> </span><span class="n">search</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">08</span><span class="n">_identity_agent</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Identity</span><span class="w"> </span><span class="n">agent</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">analyze</span><span class="p">,</span><span class="w"> </span><span class="n">suggest</span><span class="p">,</span><span class="w"> </span><span class="n">merge</span><span class="p">,</span><span class="w"> </span><span class="n">clustering</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">08</span><span class="n">_media</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Video</span><span class="w"> </span><span class="n">streaming</span><span class="w"> </span><span class="o">&amp;</span><span class="w"> </span><span class="n">frame</span><span class="w"> </span><span class="n">extraction</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">09</span><span class="n">_tmdb</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">TMDb</span><span class="w"> </span><span class="n">enrichment</span><span class="w"> </span><span class="n">endpoints</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">prefetch</span><span class="p">,</span><span class="w"> </span><span class="n">probe</span><span class="p">,</span><span class="w"> </span><span class="n">resource</span><span class="p">,</span><span class="w"> </span><span class="n">check</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">10</span><span class="n">_pipeline</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Stats</span><span class="w"> </span><span class="n">endpoints</span><span class="p">,</span><span class="w"> </span><span class="n">inference</span><span class="w"> </span><span class="n">health</span><span class="p">,</span><span class="w"> </span><span class="n">stfpgo</span><span class="w"> </span><span class="n">status</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">11</span><span class="n">_error_codes</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Standard</span><span class="w"> </span><span class="n">API</span><span class="w"> </span><span class="n">error</span><span class="w"> </span><span class="n">codes</span>
<span class="err"></span><span class="w"> </span><span class="err">├──</span><span class="w"> </span><span class="mh">12</span><span class="n">_agent</span><span class="p">.</span><span class="n">md</span><span class="w"> </span><span class="err"></span><span class="w"> </span>
<span class="err">└──</span><span class="w"> </span><span class="p">(</span><span class="n">generated</span><span class="w"> </span><span class="n">files</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">GUIDES</span><span class="o">/</span><span class="p">)</span>
</code></pre></div>
<h2>快速選擇指南</h2>
<table class="table">
<thead>
<tr>
<th>需求</th>
<th>閱讀文件</th>
</tr>
</thead>
<tbody>
<tr>
<td>查看所有 API 端點curl 範例版)</td>
<td><code>GUIDES/API_ENDPOINTS.md</code></td>
</tr>
<tr>
<td>查看快速端點摘要</td>
<td><code>GUIDES/API_QUICK_REFERENCE.md</code></td>
</tr>
<tr>
<td>執行 TMDb Enrichment</td>
<td><code>GUIDES/TMDb_User_Guide.md</code></td>
</tr>
<tr>
<td>查看錯誤碼</td>
<td><code>GUIDES/API_ERROR_CODES.md</code></td>
</tr>
</tbody>
</table>
<h2>文件模組清單</h2>
<ul>
<li><code>_template</code> — One-line description of what this module covers</li>
<li><code>01_auth</code> — Authentication — login, logout, JWT, session cookie, API key</li>
<li><code>02_health</code> — Health check endpoints</li>
<li><code>03_register</code> — File registration — register, scan</li>
<li><code>04_lookup</code> — File lookup by name and unregistration</li>
<li><code>05_process</code> — Processing pipeline — trigger, probe, progress, jobs</li>
<li><code>06_search</code> — Vector search, hybrid search, BM25, n8n, visual, identity text search</li>
<li><code>07_identity</code> — Global identities — CRUD, detail, files, faces, bind, unbind, search</li>
<li><code>08_identity_agent</code> — Identity agent — analyze, suggest, merge, clustering</li>
<li><code>08_media</code> — Video streaming &amp; frame extraction</li>
<li><code>09_tmdb</code> — TMDb enrichment endpoints — prefetch, probe, resource, check</li>
<li><code>10_pipeline</code> — Stats endpoints, inference health, stfpgo status</li>
<li><code>11_error_codes</code> — Standard API error codes</li>
<li><code>12_agent</code></li>
</ul>
<hr />
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>M5Api Pipeline Demo - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<hr />
<p>document_type: "demo_guide"
service: "MOMENTRY_CORE"
title: "M5API Pipeline Demo"
date: "2026-05-16"
version: "V1.0"
status: "active"
owner: "M5"
created_by: "OpenCode"
tags:
- "demo"
- "pipeline"
- "api"
- "m5api"
ai_query_hints:
- "M5API Pipeline demo"
- "如何透過 M5 的 API 執行 Pipeline"
related_documents:
- "GUIDES/Demo_EndToEnd.md"
- "GUIDES/API_ENDPOINTS.md"</p>
<hr />
<h1>Momentry Core — M5API Pipeline Demo</h1>
<table class="table">
<thead>
<tr>
<th>項目</th>
<th>內容</th>
</tr>
</thead>
<tbody>
<tr>
<td>建立者</td>
<td>OpenCode</td>
</tr>
<tr>
<td>建立時間</td>
<td>2026-05-16</td>
</tr>
<tr>
<td>文件版本</td>
<td>V1.0</td>
</tr>
<tr>
<td>目標讀者</td>
<td>developer</td>
</tr>
<tr>
<td>預備知識</td>
<td>需有 API Key、M5 服務已啟動</td>
</tr>
</tbody>
</table>
<hr />
<h2>Prerequisites</h2>
<div class="codehilite"><pre><span></span><code><span class="nv">API</span><span class="o">=</span><span class="s2">&quot;https://m5api.momentry.ddns.net&quot;</span>
<span class="nv">KEY</span><span class="o">=</span><span class="s2">&quot;muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69&quot;</span>
</code></pre></div>
<hr />
<h2>Step 1: System Health Check</h2>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-sf<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/health&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{ip, port, status, version, build_git_hash}&#39;</span>
</code></pre></div>
<p>Response:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;ip&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;192.168.110.201&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;port&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3002</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;version&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;1.0.0&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;build_git_hash&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;c41f7e0c&quot;</span>
<span class="p">}</span>
</code></pre></div>
<p>All core services verified:</p>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-sf<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/health/detailed&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{</span>
<span class="s1"> services, schema: .schema.ok,</span>
<span class="s1"> scripts: .pipeline.scripts_count,</span>
<span class="s1"> integrity: .pipeline.scripts_integrity,</span>
<span class="s1"> procs: [.pipeline.processors | to_entries[] | select(.value==true and .key!=&quot;total_py_files&quot;) | .key]</span>
<span class="s1">}&#39;</span>
</code></pre></div>
<p>Response:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;services&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;postgres&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;redis&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;qdrant&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;mongodb&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span><span class="p">}</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;schema&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;scripts&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">286</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;integrity&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;matched&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">345</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">345</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;ok&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;procs&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;asr&quot;</span><span class="p">,</span><span class="s2">&quot;yolo&quot;</span><span class="p">,</span><span class="s2">&quot;face&quot;</span><span class="p">,</span><span class="s2">&quot;pose&quot;</span><span class="p">,</span><span class="s2">&quot;ocr&quot;</span><span class="p">,</span><span class="s2">&quot;cut&quot;</span><span class="p">,</span><span class="s2">&quot;caption&quot;</span><span class="p">,</span><span class="s2">&quot;scene&quot;</span><span class="p">,</span><span class="s2">&quot;story&quot;</span><span class="p">,</span><span class="s2">&quot;asrx&quot;</span><span class="p">,</span><span class="s2">&quot;probe&quot;</span><span class="p">,</span><span class="s2">&quot;visual_chunk&quot;</span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h2>Step 2: List Registered Files</h2>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files?page=1&amp;page_size=5&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{total, files: [.data[]? | {name: .file_name[0:50], status}]}&#39;</span>
</code></pre></div>
<p>Response:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">56</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;files&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Charade (1963) Cary Grant &amp; Audrey Hepburn ...&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ExaSAN PCIe series - Director Ou Yu-Zhi ...&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Old_Time_Movie_Show_-_Charade_1963.HD.mov&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Old Felix the Cat Cartoon.mp4&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;unregistered&quot;</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;short_clip.mov&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h2>Step 3: Register a New File</h2>
<div class="codehilite"><pre><span></span><code><span class="c1"># POST with file_path (must exist on server filesystem)</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_path&quot;: &quot;/path/to/video.mp4&quot;}&#39;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/register&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{success, file_uuid, file_name, file_type, duration, fps, already_exists}&#39;</span>
</code></pre></div>
<p>Response (new registration):</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3abeee81d94597629ed8cb943f182e94&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Charade (1963) Cary Grant &amp; Audrey Hepburn ...mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;duration&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">6785.014</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;fps&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">23.976</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;already_exists&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span>
<span class="p">}</span>
</code></pre></div>
<p>Response (duplicate content — SHA256 dedup):</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;already_exists&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Content already registered (identical file)&quot;</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h2>Step 4: Probe (ffprobe Metadata)</h2>
<div class="codehilite"><pre><span></span><code><span class="nv">UUID</span><span class="o">=</span><span class="s2">&quot;3abeee81d94597629ed8cb943f182e94&quot;</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">/probe&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{name: .file_name, video: &quot;\(.width)x\(.height)&quot;, fps, duration, cached, streams: [.streams[] | {type: .codec_type, codec: .codec_name}]}&#39;</span>
</code></pre></div>
<p>Response:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Charade (1963) Cary Grant &amp; Audrey Hepburn ...mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;video&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;720x304&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;fps&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">23.976</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;duration&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">6785.014</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;cached&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;streams&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;codec&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;h264&quot;</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;audio&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;codec&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;aac&quot;</span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<p>Error cases:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Non-existent UUID</span>
curl<span class="w"> </span>-sf<span class="w"> </span><span class="s2">&quot;https://m5api.momentry.ddns.net/api/v1/file/bad_uuid/probe&quot;</span>
<span class="c1"># → {&quot;error&quot;:&quot;Video not found&quot;,&quot;file_uuid&quot;:&quot;bad_uuid&quot;} HTTP 404</span>
<span class="c1"># File deleted from disk</span>
<span class="c1"># → {&quot;error&quot;:&quot;File does not exist at registered path&quot;,&quot;file_uuid&quot;:&quot;...&quot;,&quot;file_path&quot;:&quot;...&quot;} HTTP 404</span>
</code></pre></div>
<hr />
<h2>Step 5: Submit Processing Job</h2>
<div class="codehilite"><pre><span></span><code><span class="c1"># Specific processors</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;processors&quot;:[&quot;asr&quot;,&quot;cut&quot;,&quot;yolo&quot;,&quot;face&quot;,&quot;pose&quot;,&quot;ocr&quot;]}&#39;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">/process&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{job_id, file_uuid: .file_uuid[0:16], status}&#39;</span>
</code></pre></div>
<p>Response:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;job_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">167</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3abeee81d9459762&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;PENDING&quot;</span>
<span class="p">}</span>
</code></pre></div>
<blockquote>
<p><strong>All processors</strong>: Send <code>{}</code> (empty body) to run all 12 processors.
Available: <code>asr</code>, <code>cut</code>, <code>yolo</code>, <code>face</code>, <code>pose</code>, <code>ocr</code>, <code>asrx</code>, <code>visual_chunk</code>, <code>scene</code>, <code>story</code>, <code>caption</code></p>
</blockquote>
<hr />
<h2>Step 6: Monitor Progress</h2>
<div class="codehilite"><pre><span></span><code><span class="k">while</span><span class="w"> </span>true<span class="p">;</span><span class="w"> </span><span class="k">do</span>
<span class="w"> </span><span class="nv">PROGRESS</span><span class="o">=</span><span class="k">$(</span>curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/progress/</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">&quot;</span><span class="k">)</span>
<span class="w"> </span><span class="nv">STATUS</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$PROGRESS</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span>-r<span class="w"> </span><span class="s1">&#39;.status // &quot;?&quot;&#39;</span><span class="k">)</span>
<span class="w"> </span><span class="nv">PROCS</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$PROGRESS</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span>-r<span class="w"> </span><span class="s1">&#39;[.processors[]? | &quot;\(.name)=\(.status)(\(.frames_processed))&quot;] | join(&quot; &quot;)&#39;</span><span class="k">)</span>
<span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;</span><span class="k">$(</span>date<span class="w"> </span>+%H:%M:%S<span class="k">)</span><span class="s2">: </span><span class="nv">$PROCS</span><span class="s2">&quot;</span>
<span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$PROCS</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>-q<span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="k">break</span>
<span class="w"> </span>sleep<span class="w"> </span><span class="m">10</span>
<span class="k">done</span>
</code></pre></div>
<p>Typical output:</p>
<div class="codehilite"><pre><span></span><code><span class="mf">12</span><span class="p">:</span><span class="mf">30</span><span class="p">:</span><span class="mf">01</span><span class="p">:</span><span class="w"> </span><span class="n">asr</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">cut</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">yolo</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">face</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="nb">pos</span><span class="n">e</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">ocr</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span>
<span class="mf">12</span><span class="p">:</span><span class="mf">30</span><span class="p">:</span><span class="mf">11</span><span class="p">:</span><span class="w"> </span><span class="n">asr</span><span class="o">=</span><span class="kr">run</span><span class="n">ning</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">cut</span><span class="o">=</span><span class="kr">run</span><span class="n">ning</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">yolo</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">face</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="nb">pos</span><span class="n">e</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">ocr</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span>
<span class="mf">12</span><span class="p">:</span><span class="mf">30</span><span class="p">:</span><span class="mf">21</span><span class="p">:</span><span class="w"> </span><span class="n">asr</span><span class="o">=</span><span class="kr">run</span><span class="n">ning</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">cut</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">yolo</span><span class="o">=</span><span class="kr">run</span><span class="n">ning</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">face</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="nb">pos</span><span class="n">e</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">ocr</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span>
<span class="mf">12</span><span class="p">:</span><span class="mf">30</span><span class="p">:</span><span class="mf">31</span><span class="p">:</span><span class="w"> </span><span class="n">asr</span><span class="o">=</span><span class="kr">run</span><span class="n">ning</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">cut</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">yolo</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">face</span><span class="o">=</span><span class="kr">run</span><span class="n">ning</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="nb">pos</span><span class="n">e</span><span class="o">=</span><span class="n">pending</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span>
<span class="mf">12</span><span class="p">:</span><span class="mf">30</span><span class="p">:</span><span class="mf">41</span><span class="p">:</span><span class="w"> </span><span class="n">asr</span><span class="o">=</span><span class="kr">run</span><span class="n">ning</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span><span class="w"> </span><span class="n">cut</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">yolo</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">face</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="nb">pos</span><span class="n">e</span><span class="o">=</span><span class="kr">run</span><span class="n">ning</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span>
<span class="mf">12</span><span class="p">:</span><span class="mf">30</span><span class="p">:</span><span class="mf">51</span><span class="p">:</span><span class="w"> </span><span class="n">asr</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">cut</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">yolo</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">face</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="nb">pos</span><span class="n">e</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">ocr</span><span class="o">=</span><span class="kr">run</span><span class="n">ning</span><span class="p">(</span><span class="mf">0</span><span class="p">)</span>
<span class="mf">12</span><span class="p">:</span><span class="mf">31</span><span class="p">:</span><span class="mf">01</span><span class="p">:</span><span class="w"> </span><span class="n">asr</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">cut</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">yolo</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">face</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="nb">pos</span><span class="n">e</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span><span class="w"> </span><span class="n">ocr</span><span class="o">=</span><span class="n">completed</span><span class="p">(</span><span class="mf">8951</span><span class="p">)</span>
</code></pre></div>
<p><strong>Status transition chain</strong>: <code>pending → running → completed</code></p>
<p>Check job state:</p>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/jobs?uuid=</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;[.jobs[]? | {id, status}]&#39;</span>
</code></pre></div>
<hr />
<h2>Step 7: Verify Results</h2>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/progress/</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{processors: [.processors[] | {name, status, frames: .frames_processed}]}&#39;</span>
</code></pre></div>
<p>Response:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;processors&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;asr&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;frames&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">162568</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;cut&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;frames&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">162568</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;yolo&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;frames&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">162568</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;face&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;frames&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">162568</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;pose&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;frames&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">162568</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ocr&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;frames&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">162568</span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h2>Step 8: Universal Search</h2>
<div class="codehilite"><pre><span></span><code><span class="c1"># Search for a person name</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s2">&quot;{\&quot;query\&quot;:\&quot;Audrey\&quot;,\&quot;uuid\&quot;:\&quot;</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">\&quot;,\&quot;limit\&quot;:3}&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/search/universal&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{total, hits: [.results[]? | {chunk_id: .chunk_id[0:40], text: .text[0:80], score}]}&#39;</span>
</code></pre></div>
<p>Response:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;hits&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;chunk_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3abeee81d94597629ed8cb943f182e94_998192&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;text&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Shorede stars two legends of classical Hollywood, Audrey Hepburn and Carrie Gran&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;score&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.9</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;chunk_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3abeee81d94597629ed8cb943f182e94_998193&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;text&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Shorede stars two legends of classical Hollywood, Audrey Hepburn and Carrie Gran&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;score&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.9</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<div class="codehilite"><pre><span></span><code><span class="c1"># Search Chinese text</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s2">&quot;{\&quot;query\&quot;:\&quot;導演\&quot;,\&quot;uuid\&quot;:\&quot;</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">\&quot;,\&quot;limit\&quot;:3}&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/search/universal&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{total}&#39;</span>
</code></pre></div>
<p><strong>Search modes</strong>: The universal search endpoint supports:
- Text match (ILIKE on <code>text_content</code> and <code>content</code> columns)
- Time range filtering (<code>time_range: [start, end]</code>)
- Speaker/person ID filtering
- Chunk type filtering
- Visual content filtering (objects, density, classes)</p>
<hr />
<h2>Step 9: Get Chunk Detail</h2>
<div class="codehilite"><pre><span></span><code><span class="nv">CHUNK_ID</span><span class="o">=</span><span class="s2">&quot;3abeee81d94597629ed8cb943f182e94_998192&quot;</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">/chunk/</span><span class="si">${</span><span class="nv">CHUNK_ID</span><span class="si">}</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{chunk_id, chunk_type, text: .text_content, fps, start_frame, end_frame}&#39;</span>
</code></pre></div>
<p>Response:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;chunk_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3abeee81d94597629ed8cb943f182e94_998192&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;chunk_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;sentence&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;text&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Shorede stars two legends of classical Hollywood, Audrey Hepburn and Carrie Gran&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;fps&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">23.976</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;start_frame&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">2395281</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;end_frame&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">2395341</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h2>Step 10: Chunk Fallback (Stale Qdrant Compatibility)</h2>
<p>Old integer-format chunk_ids from stale Qdrant payloads are automatically resolved via <code>WHERE id = int(chunk_id)</code>:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Integer format (old Qdrant payload)</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">/chunk/998192&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{chunk_id, text: .text_content}&#39;</span>
</code></pre></div>
<p>Response (same chunk as above):</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;chunk_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3abeee81d94597629ed8cb943f182e94_998192&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;text&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Shorede stars two legends of classical Hollywood, Audrey Hepburn and Carrie Gran&quot;</span>
<span class="p">}</span>
</code></pre></div>
<p><strong>Both formats work:</strong>
- <code>chunk/{uuid}_{id}</code> → exact <code>chunk_id</code> match
- <code>chunk/{id}</code> → fallback by primary key <code>id</code></p>
<hr />
<h2>Step 11: File Detail</h2>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{file_name, status, file_type, file_path}&#39;</span>
</code></pre></div>
<p>Response:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Charade (1963) Cary Grant &amp; Audrey Hepburn ...mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_path&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;/Users/accusys/momentry/var/sftpgo/data/demo/Charade...&quot;</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h2>Step 12: File Identities</h2>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">/identities&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{total, identities: [.data[]? | {name, face_count, confidence}]}&#39;</span>
</code></pre></div>
<p>Response:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;identities&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Audrey Hepburn&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;face_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">22082</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;confidence&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.93</span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Cary Grant&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;face_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">15334</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;confidence&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">0.91</span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h2>Step 13: Identity Detail</h2>
<div class="codehilite"><pre><span></span><code><span class="c1"># List all global identities</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identities?page=1&amp;page_size=3&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{total, identities: [.data[]? | {name, type: .identity_type, source}]}&#39;</span>
</code></pre></div>
<div class="codehilite"><pre><span></span><code><span class="c1"># Get identity files (cross-file faces)</span>
<span class="nv">IDENTITY_UUID</span><span class="o">=</span><span class="s2">&quot;c3545906-c82d-4b66-aa1d-150bc02decce&quot;</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="si">${</span><span class="nv">IDENTITY_UUID</span><span class="si">}</span><span class="s2">/files&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{total, files: [.data[]? | {file_uuid: .file_uuid[0:16], face_count}]}&#39;</span>
</code></pre></div>
<hr />
<h2>Step 14: Schema &amp; Integrity Verification</h2>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-sf<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/health/detailed&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{</span>
<span class="s1"> ip, port,</span>
<span class="s1"> schema: .schema.ok,</span>
<span class="s1"> migrations: [.schema.applied[]?.filename],</span>
<span class="s1"> integrity: .pipeline.scripts_integrity</span>
<span class="s1">}&#39;</span>
</code></pre></div>
<p>Response:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;ip&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;192.168.110.201&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;port&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3002</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;schema&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;migrations&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="s2">&quot;migrate_add_content_hash.sql&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="s2">&quot;migrate_add_registered_status.sql&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="s2">&quot;migrate_add_schema_version.sql&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="s2">&quot;migrate_cleanup_inactive_identities.sql&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="s2">&quot;migrate_public_schema_v4_tables.sql&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="s2">&quot;migrate_public_schema_v4.sql&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="s2">&quot;migrate_public_v4_complete.sql&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="s2">&quot;migrate_fix_chunk_id_format.sql&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="s2">&quot;migrate_add_identity_indexes.sql&quot;</span>
<span class="w"> </span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;integrity&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">&quot;matched&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">345</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">345</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;ok&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h2>Full Automation Script</h2>
<div class="codehilite"><pre><span></span><code><span class="ch">#!/bin/bash</span>
<span class="nb">set</span><span class="w"> </span>-euo<span class="w"> </span>pipefail
<span class="nv">API</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">API</span><span class="k">:-</span><span class="nv">https</span><span class="p">://m5api.momentry.ddns.net</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="nv">KEY</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">KEY</span><span class="k">:-</span><span class="nv">muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="c1"># 1. Health</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;=== Health ===&quot;</span>
curl<span class="w"> </span>-sf<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/health&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{status, version, build_git_hash}&#39;</span>
<span class="c1"># 2. Register file (argument: file path)</span>
<span class="nv">FILE_PATH</span><span class="o">=</span><span class="s2">&quot;</span><span class="si">${</span><span class="nv">1</span><span class="p">:?Usage: </span><span class="nv">$0</span><span class="p"> &lt;file_path&gt;</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;=== Register ===&quot;</span>
<span class="nv">REG</span><span class="o">=</span><span class="k">$(</span>curl<span class="w"> </span>-sf<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s2">&quot;{\&quot;file_path\&quot;:\&quot;</span><span class="nv">$FILE_PATH</span><span class="s2">\&quot;}&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/register&quot;</span><span class="k">)</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$REG</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{success, file_uuid, file_name}&#39;</span>
<span class="nv">UUID</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$REG</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span>-r<span class="w"> </span><span class="s1">&#39;.file_uuid&#39;</span><span class="k">)</span>
<span class="o">[</span><span class="w"> </span>-z<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$UUID</span><span class="s2">&quot;</span><span class="w"> </span><span class="o">]</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="o">{</span><span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;Registration failed&quot;</span><span class="p">;</span><span class="w"> </span><span class="nb">exit</span><span class="w"> </span><span class="m">1</span><span class="p">;</span><span class="w"> </span><span class="o">}</span>
<span class="c1"># 3. Probe</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;=== Probe ===&quot;</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">/probe&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{name, fps, duration}&#39;</span>
<span class="c1"># 4. Submit job</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;=== Process ===&quot;</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{}&#39;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">/process&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{job_id, status}&#39;</span>
<span class="c1"># 5. Poll progress</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;=== Waiting for pipeline... ===&quot;</span>
<span class="k">while</span><span class="w"> </span>true<span class="p">;</span><span class="w"> </span><span class="k">do</span>
<span class="w"> </span><span class="nv">PROGRESS</span><span class="o">=</span><span class="k">$(</span>curl<span class="w"> </span>-sf<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/progress/</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">&quot;</span><span class="k">)</span>
<span class="w"> </span><span class="nv">STATUS</span><span class="o">=</span><span class="k">$(</span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$PROGRESS</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span>-r<span class="w"> </span><span class="s1">&#39;.status // &quot;?&quot;&#39;</span><span class="k">)</span>
<span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;</span><span class="k">$(</span>date<span class="w"> </span>+%H:%M:%S<span class="k">)</span><span class="s2">: </span><span class="k">$(</span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$PROGRESS</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span>-r<span class="w"> </span><span class="s1">&#39;[.processors[]? | &quot;\(.name)=\(.status)(\(.frames_processed))&quot;] | join(&quot; &quot;)&#39;</span><span class="k">)</span><span class="s2">&quot;</span>
<span class="w"> </span><span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$PROGRESS</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span>-e<span class="w"> </span><span class="s1">&#39;[.processors[]? | select(.status == &quot;pending&quot;)] | length == 0&#39;</span><span class="w"> </span>&gt;/dev/null<span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="k">break</span>
<span class="w"> </span>sleep<span class="w"> </span><span class="m">10</span>
<span class="k">done</span>
<span class="c1"># 6. Search</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;=== Search ===&quot;</span>
curl<span class="w"> </span>-sf<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s2">&quot;{\&quot;query\&quot;:\&quot;test\&quot;,\&quot;uuid\&quot;:\&quot;</span><span class="si">${</span><span class="nv">UUID</span><span class="si">}</span><span class="s2">\&quot;,\&quot;limit\&quot;:3}&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/search/universal&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{total, hits: [.results[]? | {chunk_id: .chunk_id[0:30], text: .text[0:60]}]}&#39;</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;&quot;</span>
<span class="nb">echo</span><span class="w"> </span><span class="s2">&quot;✅ Done: </span><span class="nv">$UUID</span><span class="s2">&quot;</span>
</code></pre></div>
</div>
</body>
</html>

View File

@@ -0,0 +1,923 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tmdb User Guide - Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 960px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 24px; margin: 24px 0 12px; }
h2 { font-size: 20px; margin: 20px 0 10px; color: #222; }
h3 { font-size: 16px; margin: 16px 0 8px; color: #444; }
p { line-height: 1.6; margin: 8px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 14px; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f0f0f0; font-weight: 600; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
pre { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 12px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; }
a { color: #0066cc; }
.back { display: inline-block; margin-bottom: 20px; color: #666; }
.back:hover { color: #333; }
</style>
</head>
<body>
<div class="container">
<a class="back" href="index.html">&larr; Back to index</a>
<hr />
<p>document_type: "user_manual"
service: "MOMENTRY_CORE"
title: "TMDb Enrichment 使用指南"
date: "2026-05-17"
version: "V1.0"
status: "active"
owner: "M5"
created_by: "OpenCode"</p>
<hr />
<h1>TMDb Enrichment 使用指南</h1>
<table class="table">
<thead>
<tr>
<th>項目</th>
<th>內容</th>
</tr>
</thead>
<tbody>
<tr>
<td>目標讀者</td>
<td>developer</td>
</tr>
<tr>
<td>預備知識</td>
<td>需有 API Key</td>
</tr>
</tbody>
</table>
<hr />
<h2>Base URL</h2>
<table class="table">
<thead>
<tr>
<th>Environment</th>
<th>URL</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td>Playground (Dev)</td>
<td><code>http://localhost:3003</code></td>
<td>Development and testing</td>
</tr>
<tr>
<td>Production</td>
<td><code>http://localhost:3002</code></td>
<td>Production deployment</td>
</tr>
<tr>
<td>External (M5)</td>
<td><code>https://m5api.momentry.ddns.net</code></td>
<td>Remote access</td>
</tr>
</tbody>
</table>
<h2>Variables</h2>
<p>All examples in this documentation use these environment variables:</p>
<div class="codehilite"><pre><span></span><code><span class="nv">API</span><span class="o">=</span><span class="s2">&quot;http://localhost:3003&quot;</span>
<span class="nv">KEY</span><span class="o">=</span><span class="s2">&quot;your-api-key-here&quot;</span>
</code></pre></div>
<h2>Authentication</h2>
<p>All endpoints under <code>/api/v1/*</code> require authentication.
The following endpoints are public (no auth needed):</p>
<ul>
<li><code>GET /health</code></li>
<li><code>POST /api/v1/auth/login</code></li>
<li><code>POST /api/v1/auth/logout</code></li>
</ul>
<h3>Three Authentication Modes</h3>
<p>The system supports three authentication methods, checked in <strong>priority order</strong> by the middleware:</p>
<div class="codehilite"><pre><span></span><code>Middleware priority:
1. Session Cookie (Portal/browser)
2. JWT Bearer (API clients: n8n, CLI)
3. API Key Header (legacy compatibility)
4. API Key Query Param (?api_key=)
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Mode</th>
<th>Transport</th>
<th>Expiry</th>
<th>Scope</th>
<th>Best for</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Session Cookie</strong></td>
<td><code>Cookie: session_id=&lt;uuid&gt;</code></td>
<td>24h</td>
<td>per-browser session</td>
<td>Portal (browser)</td>
</tr>
<tr>
<td><strong>JWT</strong></td>
<td><code>Authorization: Bearer &lt;token&gt;</code></td>
<td>1h</td>
<td>per-login token</td>
<td>API clients (n8n, CLI, scripts)</td>
</tr>
<tr>
<td><strong>API Key</strong></td>
<td><code>X-API-Key: &lt;key&gt;</code></td>
<td>90d</td>
<td>fixed key for automation</td>
<td>Legacy scripts, WordPress</td>
</tr>
</tbody>
</table>
<hr />
<h3>Login</h3>
<p><strong>Default accounts &amp; API keys:</strong></p>
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Password</th>
<th>API Key</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>admin</code></td>
<td><code>admin</code></td>
<td></td>
<td>admin</td>
</tr>
<tr>
<td><code>demo</code></td>
<td><code>demo</code></td>
<td><code>muser_demo_key_32chars_abcdef1234567890</code></td>
<td>user</td>
</tr>
</tbody>
</table>
<p>The demo API key is set via <code>MOMENTRY_DEMO_API_KEY</code> env var and can be used in place of JWT for marcom integrations:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Using API key instead of JWT</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: muser_demo_key_32chars_abcdef1234567890&quot;</span>
</code></pre></div>
<div class="codehilite"><pre><span></span><code><span class="c1"># Login as admin</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/login&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;username&quot;: &quot;admin&quot;, &quot;password&quot;: &quot;admin&quot;}&#39;</span>
<span class="c1"># Login as demo user</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/login&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;username&quot;: &quot;demo&quot;, &quot;password&quot;: &quot;demo&quot;}&#39;</span>
</code></pre></div>
<h4>Success Response</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;jwt&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;eyJhbGciOiJIUzI1NiIs...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;api_key&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;muser_...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;user&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;username&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;admin&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;role&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;admin&quot;</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;expires_at&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-05-18T13:00:00Z&quot;</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>jwt</code></td>
<td>string</td>
<td>JWT access token. Use as <code>Authorization: Bearer &lt;jwt&gt;</code>. Expires in 1 hour.</td>
</tr>
<tr>
<td><code>api_key</code></td>
<td>string</td>
<td>Legacy API key. Use as <code>X-API-Key: &lt;key&gt;</code>. Good for 90 days.</td>
</tr>
<tr>
<td><code>user.username</code></td>
<td>string</td>
<td>Username</td>
</tr>
<tr>
<td><code>user.role</code></td>
<td>string</td>
<td>Role: <code>admin</code>, <code>user</code>, or <code>readonly</code></td>
</tr>
<tr>
<td><code>expires_at</code></td>
<td>string</td>
<td>ISO8601 timestamp of JWT expiration</td>
</tr>
</tbody>
</table>
<p>The login endpoint also sets a <code>Set-Cookie</code> header for browser-based clients:</p>
<div class="codehilite"><pre><span></span><code><span class="nt">Set-Cookie</span><span class="o">:</span><span class="w"> </span><span class="nt">session_id</span><span class="o">=&lt;</span><span class="nt">uuid</span><span class="o">&gt;;</span><span class="w"> </span><span class="nt">Path</span><span class="o">=/</span><span class="nt">api</span><span class="o">;</span><span class="w"> </span><span class="nt">HttpOnly</span><span class="o">;</span><span class="w"> </span><span class="nt">SameSite</span><span class="o">=</span><span class="nt">Strict</span><span class="o">;</span><span class="w"> </span><span class="nt">Max-Age</span><span class="o">=</span><span class="nt">86400</span>
</code></pre></div>
<h4>Error Response (401)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Invalid username or password&quot;</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h3>Using JWT</h3>
<p>JWT is preferred for API clients (n8n, CLI scripts, WordPress). It is validated by the middleware without a database lookup (stateless).</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Login and capture JWT</span>
<span class="nv">JWT</span><span class="o">=</span><span class="k">$(</span>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/login&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;username&quot;:&quot;admin&quot;,&quot;password&quot;:&quot;admin&quot;}&#39;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>python3<span class="w"> </span>-c<span class="w"> </span><span class="s2">&quot;import json,sys;print(json.load(sys.stdin)[&#39;jwt&#39;])&quot;</span><span class="k">)</span>
<span class="c1"># Use JWT for all subsequent requests</span>
curl<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan&quot;</span>
curl<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$JWT</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/resource/tmdb&quot;</span>
</code></pre></div>
<p>JWT is short-lived (1 hour). When it expires, request a new one via login.</p>
<hr />
<h3>Using Session Cookie (Browser)</h3>
<p>Browser-based clients (Portal) get a session cookie automatically after login. The browser sends the cookie with every request—no manual header needed.</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Login captures the session cookie from Set-Cookie header</span>
curl<span class="w"> </span>-v<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/login&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;username&quot;:&quot;admin&quot;,&quot;password&quot;:&quot;admin&quot;}&#39;</span><span class="w"> </span><span class="m">2</span>&gt;<span class="p">&amp;</span><span class="m">1</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span><span class="s2">&quot;Set-Cookie&quot;</span>
<span class="c1"># Browser automatically sends: Cookie: session_id=&lt;uuid&gt;</span>
<span class="c1"># No manual header needed for subsequent requests</span>
</code></pre></div>
<p>The session cookie is HttpOnly (not accessible from JavaScript) and SameSite=Strict (protected against CSRF).</p>
<hr />
<h3>Using Legacy API Key</h3>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan&quot;</span>
<span class="c1"># Also accepted via Bearer header (non-JWT format) or query parameter:</span>
curl<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Authorization: Bearer </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan&quot;</span>
curl<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?api_key=</span><span class="nv">$KEY</span><span class="s2">&quot;</span>
</code></pre></div>
<p>API keys are validated via SHA256 hash lookup in the database. They are long-lived (90 days) and intended for automation.</p>
<h3>Obtaining an API Key (CLI)</h3>
<div class="codehilite"><pre><span></span><code>momentry<span class="w"> </span>api-key<span class="w"> </span>create<span class="w"> </span><span class="s2">&quot;My API Key&quot;</span><span class="w"> </span>--key-type<span class="w"> </span>user
</code></pre></div>
<hr />
<h3>Logout</h3>
<div class="codehilite"><pre><span></span><code><span class="c1"># Logout using the session cookie (browser)</span>
curl<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/logout&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Cookie: session_id=&lt;uuid&gt;&quot;</span>
</code></pre></div>
<h4>What logout does</h4>
<table class="table">
<thead>
<tr>
<th>Auth mode</th>
<th>Effect</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Session Cookie</strong></td>
<td>Session deleted from database. Same cookie returns 401 on subsequent requests.</td>
</tr>
<tr>
<td><strong>JWT</strong></td>
<td>JWT remains valid until expiry. (JWT is stateless — logout adds JWT to a blacklist only if API key mode is used.)</td>
</tr>
<tr>
<td><strong>API Key</strong></td>
<td>API key remains valid. (Legacy keys are shared across sessions — revoking would break other clients.)</td>
</tr>
</tbody>
</table>
<h4>Example: full session lifecycle</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># 1. Login</span>
<span class="nv">SESSION_ID</span><span class="o">=</span><span class="k">$(</span>curl<span class="w"> </span>-s<span class="w"> </span>-D<span class="w"> </span>-<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/login&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;username&quot;:&quot;admin&quot;,&quot;password&quot;:&quot;admin&quot;}&#39;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span><span class="s2">&quot;Set-Cookie&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>sed<span class="w"> </span><span class="s1">&#39;s/.*session_id=\([^;]*\).*/\1/&#39;</span><span class="k">)</span>
<span class="c1"># 2. Use session (works)</span>
curl<span class="w"> </span>-s<span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span>-w<span class="w"> </span><span class="s2">&quot;HTTP %{http_code}\n&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/resource/tmdb&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Cookie: session_id=</span><span class="nv">$SESSION_ID</span><span class="s2">&quot;</span>
<span class="c1"># → HTTP 200</span>
<span class="c1"># 3. Logout</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/auth/logout&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Cookie: session_id=</span><span class="nv">$SESSION_ID</span><span class="s2">&quot;</span>
<span class="c1"># → {&quot;success&quot;: true}</span>
<span class="c1"># 4. Use session again (rejected)</span>
curl<span class="w"> </span>-s<span class="w"> </span>-o<span class="w"> </span>/dev/null<span class="w"> </span>-w<span class="w"> </span><span class="s2">&quot;HTTP %{http_code}\n&quot;</span><span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/resource/tmdb&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Cookie: session_id=</span><span class="nv">$SESSION_ID</span><span class="s2">&quot;</span>
<span class="c1"># → HTTP 401</span>
</code></pre></div>
<hr />
<h3>Authentication Flow Summary</h3>
<div class="codehilite"><pre><span></span><code>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=/api
├──────────────────┤
│ 5. Return │ ← JWT + api_key + user info to client
└──────────────────┘
</code></pre></div>
<div class="codehilite"><pre><span></span><code>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 │
└──────────────────────┘
</code></pre></div>
<hr />
<h3>Error Responses</h3>
<table class="table">
<thead>
<tr>
<th>HTTP</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>401</code></td>
<td>Missing or invalid authentication</td>
</tr>
<tr>
<td><code>401</code></td>
<td>Session expired or logged out</td>
</tr>
<tr>
<td><code>401</code></td>
<td>JWT expired</td>
</tr>
<tr>
<td><code>401</code></td>
<td>API key revoked or inactive</td>
</tr>
</tbody>
</table>
<hr />
<h3>Related</h3>
<ul>
<li><code>POST /api/v1/resource/tmdb/check</code> — test authentication + TMDb API connectivity</li>
<li><code>GET /health/detailed</code> — view auth status (integrations section)</li>
</ul>
<hr />
<h2>File Registration</h2>
<h3><code>POST /api/v1/files/register</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Register a video file for processing. Returns the file's metadata and UUID.</p>
<p><strong>New in v0.1.2</strong>: Registration now <strong>automatically triggers the processing pipeline</strong> — no need to call <code>POST /api/v1/file/:uuid/process</code> 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)</p>
<p>If the file already exists (same content hash), returns the existing record with <code>already_exists: true</code>.</p>
<h4>Request Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_path</code></td>
<td>string</td>
<td>Yes</td>
<td></td>
<td>Path to video file on disk</td>
</tr>
<tr>
<td><code>pattern</code></td>
<td>string</td>
<td>No</td>
<td></td>
<td>Regex pattern for batch register (requires <code>file_path</code> to be a directory)</td>
</tr>
<tr>
<td><code>user_id</code></td>
<td>integer</td>
<td>No</td>
<td></td>
<td>User ID to associate with registration</td>
</tr>
<tr>
<td><code>content_hash</code></td>
<td>string</td>
<td>No</td>
<td></td>
<td>Pre-computed SHA-256 hash (skips computation)</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># Register a single file</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/register&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_path&quot;: &quot;/path/to/video.mp4&quot;}&#39;</span>
<span class="c1"># Batch register files matching a pattern in a directory</span>
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/register&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_path&quot;: &quot;/path/to/dir&quot;, &quot;pattern&quot;: &quot;.*\\.mp4$&quot;}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3a6c1865...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video.mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_path&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;/path/to/video.mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;duration&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">120.5</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;width&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1920</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;height&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1080</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;fps&quot;</span><span class="p">:</span><span class="w"> </span><span class="mf">24.0</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;total_frames&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">2892</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;already_exists&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;File registered successfully&quot;</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>success</code></td>
<td>boolean</td>
<td>Always true on 200</td>
</tr>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>32-char hex UUID of the registered file</td>
</tr>
<tr>
<td><code>file_name</code></td>
<td>string</td>
<td>File name (auto-renamed if name conflict)</td>
</tr>
<tr>
<td><code>file_path</code></td>
<td>string</td>
<td>Canonical path on disk</td>
</tr>
<tr>
<td><code>file_type</code></td>
<td>string</td>
<td><code>"video"</code>, <code>"audio"</code>, or <code>"unknown"</code></td>
</tr>
<tr>
<td><code>duration</code></td>
<td>float</td>
<td>Duration in seconds</td>
</tr>
<tr>
<td><code>width</code></td>
<td>integer</td>
<td>Video width in pixels</td>
</tr>
<tr>
<td><code>height</code></td>
<td>integer</td>
<td>Video height in pixels</td>
</tr>
<tr>
<td><code>fps</code></td>
<td>float</td>
<td>Frames per second</td>
</tr>
<tr>
<td><code>total_frames</code></td>
<td>integer</td>
<td>Total frame count</td>
</tr>
<tr>
<td><code>already_exists</code></td>
<td>boolean</td>
<td>True if same content was already registered</td>
</tr>
<tr>
<td><code>message</code></td>
<td>string</td>
<td>Human-readable status</td>
</tr>
</tbody>
</table>
<h4>Error Responses</h4>
<table class="table">
<thead>
<tr>
<th>HTTP</th>
<th>When</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>401</code></td>
<td>Missing or invalid API key</td>
</tr>
<tr>
<td><code>400</code></td>
<td>Invalid request body</td>
</tr>
<tr>
<td><code>404</code></td>
<td>File path does not exist</td>
</tr>
</tbody>
</table>
<hr />
<h3><code>GET /api/v1/files/scan</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Scan the filesystem directory and list all media files, showing which are registered, processing, or unregistered.</p>
<h4>Query Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>page</code></td>
<td>integer</td>
<td>No</td>
<td>1</td>
<td>Page number (1-based)</td>
</tr>
<tr>
<td><code>page_size</code></td>
<td>integer</td>
<td>No</td>
<td>all</td>
<td>Items per page (alias: <code>limit</code>)</td>
</tr>
<tr>
<td><code>limit</code></td>
<td>integer</td>
<td>No</td>
<td>all</td>
<td>Max items (alias for <code>page_size</code>)</td>
</tr>
<tr>
<td><code>pattern</code></td>
<td>string</td>
<td>No</td>
<td></td>
<td>Regex filter on file name (e.g., <code>.*\\.mp4$</code>)</td>
</tr>
<tr>
<td><code>sort_by</code></td>
<td>string</td>
<td>No</td>
<td><code>name</code></td>
<td>Sort field: <code>name</code>, <code>size</code>, <code>modified</code>, <code>status</code></td>
</tr>
<tr>
<td><code>sort_order</code></td>
<td>string</td>
<td>No</td>
<td><code>asc</code></td>
<td>Sort direction: <code>asc</code> or <code>desc</code></td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code><span class="c1"># Full scan</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{total, registered_count, unregistered_count}&#39;</span>
<span class="c1"># Paginated (page 1, 5 per page)</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?page=1&amp;page_size=5&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{page, total_pages, files: [.files[].file_name]}&#39;</span>
<span class="c1"># Regex filter: only mp4 files</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?pattern=.*\\.mp4</span>$<span class="s2">&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{filtered_total, files: [.files[].file_name]}&#39;</span>
<span class="c1"># Sort by file size (largest first)</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?sort_by=size&amp;sort_order=desc&amp;page_size=5&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;[.files[] | {file_name, file_size}]&#39;</span>
<span class="c1"># Sort by modified time (most recent first)</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?sort_by=modified&amp;sort_order=desc&amp;page_size=5&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;[.files[] | {file_name, modified_time}]&#39;</span>
<span class="c1"># Sort by status</span>
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/files/scan?sort_by=status&amp;page_size=5&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;[.files[] | {file_name, status}]&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;files&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;file_name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;video.mp4&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_size&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">12345678</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;is_registered&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;3a6c1865...&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;completed&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;registration_time&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-05-16T12:00:00Z&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;job_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">42</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">107</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;filtered_total&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">80</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;page&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;page_size&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;total_pages&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">4</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;registered_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">26</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;unregistered_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">81</span>
<span class="p">}</span>
</code></pre></div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>files</code></td>
<td>array</td>
<td>Array of file info objects (paginated)</td>
</tr>
<tr>
<td><code>files[].file_name</code></td>
<td>string</td>
<td>File name</td>
</tr>
<tr>
<td><code>files[].relative_path</code></td>
<td>string</td>
<td>Path relative to scan root</td>
</tr>
<tr>
<td><code>files[].file_path</code></td>
<td>string</td>
<td>Absolute path on disk</td>
</tr>
<tr>
<td><code>files[].file_size</code></td>
<td>integer</td>
<td>File size in bytes</td>
</tr>
<tr>
<td><code>files[].modified_time</code></td>
<td>string</td>
<td>Last modified timestamp (ISO8601)</td>
</tr>
<tr>
<td><code>files[].is_registered</code></td>
<td>boolean</td>
<td>Whether file is registered in DB</td>
</tr>
<tr>
<td><code>files[].file_uuid</code></td>
<td>string</td>
<td>32-char hex UUID (only if registered)</td>
</tr>
<tr>
<td><code>files[].status</code></td>
<td>string</td>
<td><code>"completed"</code>, <code>"processing"</code>, <code>"registered"</code>, <code>"unregistered"</code>, or <code>null</code></td>
</tr>
<tr>
<td><code>files[].registration_time</code></td>
<td>string</td>
<td>DB registration timestamp (only if registered)</td>
</tr>
<tr>
<td><code>files[].job_id</code></td>
<td>integer</td>
<td>Processing job ID (only if a job exists)</td>
</tr>
<tr>
<td><code>total</code></td>
<td>integer</td>
<td>Total files found on disk (unfiltered)</td>
</tr>
<tr>
<td><code>filtered_total</code></td>
<td>integer</td>
<td>Files matching regex filter</td>
</tr>
<tr>
<td><code>page</code></td>
<td>integer</td>
<td>Current page number</td>
</tr>
<tr>
<td><code>page_size</code></td>
<td>integer</td>
<td>Items per page</td>
</tr>
<tr>
<td><code>total_pages</code></td>
<td>integer</td>
<td>Total pages</td>
</tr>
<tr>
<td><code>registered_count</code></td>
<td>integer</td>
<td>Files registered in DB</td>
</tr>
<tr>
<td><code>unregistered_count</code></td>
<td>integer</td>
<td>Files not yet registered</td>
</tr>
</tbody>
</table>
<h4>Notes</h4>
<table class="table">
<thead>
<tr>
<th>Feature</th>
<th>Behavior</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Regex</strong></td>
<td>Case-insensitive (<code>(?i)</code> prefix auto-applied). Applied to <code>file_name</code>.</td>
</tr>
<tr>
<td><strong>Sort order</strong></td>
<td>Default (<code>sort_by=name</code>): registered files first, then alphabetically. <code>sort_by=status</code>: alphabetical by status string.</td>
</tr>
<tr>
<td><strong>Pagination</strong></td>
<td><code>page_size</code> and <code>limit</code> are aliases. Default: show all results.</td>
</tr>
<tr>
<td><strong>Processing order</strong></td>
<td><code>pattern</code> regex filter → <code>sort_by</code>/<code>sort_order</code><code>page</code>/<code>page_size</code> slice.</td>
</tr>
</tbody>
</table>
<hr />
<h2>TMDb Enrichment</h2>
<blockquote>
<p>⚠️ <strong>External resource</strong>: TMDb requires internet access, violating Momentry's local-only principle.
All core processing (ASR, YOLO, Face, OCR, Pose, embeddings) runs fully offline.
TMDb enrichment is <strong>optional</strong> and gated behind <code>TMDB_API_KEY</code> + <code>MOMENTRY_TMDB_PROBE_ENABLED</code>.</p>
</blockquote>
<h3>Overview</h3>
<p>TMDb enrichment is an optional identity enrichment step that can be run after Pipeline face detection completes. The workflow is:</p>
<ol>
<li><strong>Prefetch</strong> (requires internet): Download movie cast data from TMDb API → cache to <code>{file_uuid}.tmdb.json</code></li>
<li><strong>Probe</strong>: Read local cache → create identities for <strong>all</strong> cast members (<code>source='tmdb'</code>) + save <code>identity.json</code> + download profile image to <code>{OUTPUT}/identities/{uuid}/profile.jpg</code></li>
<li><strong>Match</strong>: The worker automatically matches video faces against TMDb identities when <code>MOMENTRY_TMDB_PROBE_ENABLED=true</code></li>
</ol>
<h3><code>POST /api/v1/agents/tmdb/prefetch</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Fetch TMDb cast data for a registered file and cache it locally. This is the only step requiring internet access.</p>
<h4>Request Parameters</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>file_uuid</code></td>
<td>string</td>
<td>Yes</td>
<td>File UUID to enrich</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/agents/tmdb/prefetch&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;Content-Type: application/json&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-d<span class="w"> </span><span class="s1">&#39;{&quot;file_uuid&quot;: &quot;&#39;</span><span class="s2">&quot;</span><span class="nv">$FILE_UUID</span><span class="s2">&quot;</span><span class="s1">&#39;&quot;}&#39;</span>
</code></pre></div>
<h4>Response (200)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;file_uuid&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;...&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;cache_path&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;/output/...tmdb.json&quot;</span><span class="p">}</span>
</code></pre></div>
<h3><code>POST /api/v1/file/:file_uuid/tmdb-probe</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: file-level</p>
<p>Read local TMDb cache and create/update identities. Requires prefetch to have been run first.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/tmdb-probe&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{identities_created, movie_title}&#39;</span>
</code></pre></div>
<h4>Response (200 — identities created)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;identities_created&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">15</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;movie_title&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Charade&quot;</span><span class="p">}</span>
</code></pre></div>
<h4>Response (200 — no cache)</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span><span class="nt">&quot;success&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;No TMDb cache found. Run tmdb-prefetch first.&quot;</span><span class="p">}</span>
</code></pre></div>
<h3><code>GET /api/v1/resource/tmdb</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: system-level</p>
<p>View TMDb resource status including configuration, identity counts, and cache file count.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/resource/tmdb&quot;</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;{identities_seeded, cache_files}&#39;</span>
</code></pre></div>
<h3><code>POST /api/v1/resource/tmdb/check</code></h3>
<p><strong>Auth</strong>: Required
<strong>Scope</strong>: system-level</p>
<p>Ping the TMDb API to verify connectivity and measure latency.</p>
<h4>Example</h4>
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$API</span><span class="s2">/api/v1/resource/tmdb/check&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w"> </span>-H<span class="w"> </span><span class="s2">&quot;X-API-Key: </span><span class="nv">$KEY</span><span class="s2">&quot;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">&#39;.status&#39;</span>
</code></pre></div>
<h4>Response</h4>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;api_key_configured&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;enabled&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;api_reachable&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;api_latency_ms&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">120</span>
<span class="p">}</span>
</code></pre></div>
<hr />
</div>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Momentry API Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; padding: 40px; }
.container { max-width: 900px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; }
h1 { font-size: 28px; margin-bottom: 8px; }
p.subtitle { color: #666; margin-bottom: 24px; }
ul { list-style: none; }
li { padding: 8px 0; border-bottom: 1px solid #eee; }
li:last-child { border: none; }
a { color: #0066cc; text-decoration: none; font-size: 16px; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<h1>Momentry API Documentation</h1>
<p class="subtitle">Generated from API_WORKSPACE modules</p>
<ul><li><a href="API_ACCESS.html">Api Access</a></li><li><a href="API_ENDPOINTS.html">Api Endpoints</a></li><li><a href="API_ERROR_CODES.html">Api Error Codes</a></li><li><a href="API_INDEX.html">Api Index</a></li><li><a href="API_QUICK_REFERENCE.html">Api Quick Reference</a></li><li><a href="API_REFERENCE.html">Api Reference</a></li><li><a href="API_TRAINING_MARCOM.html">Api Training Marcom</a></li><li><a href="Demo_EndToEnd.html">Demo Endtoend</a></li><li><a href="M5API_Pipeline_Demo.html">M5Api Pipeline Demo</a></li><li><a href="TMDb_User_Guide.html">Tmdb User Guide</a></li></ul>
</div>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login - Momentry Docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; height: 100vh; }
.card { background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); padding: 40px; width: 360px; }
h1 { font-size: 24px; margin-bottom: 24px; text-align: center; }
input { width: 100%; padding: 10px 12px; margin-bottom: 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
button { width: 100%; padding: 10px; background: #0066cc; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; }
button:hover { background: #0052a3; }
.error { color: #cc0000; font-size: 13px; margin-bottom: 12px; display: none; }
</style>
</head>
<body>
<div class="card">
<h1>Momentry Docs</h1>
<form id="loginForm">
<input type="text" id="username" placeholder="Username" value="demo" required>
<input type="password" id="password" placeholder="Password" value="demo" required>
<div class="error" id="error">Invalid credentials</div>
<button type="submit">Login</button>
</form>
</div>
<script>
document.getElementById('loginForm').onsubmit = async function(e) {
e.preventDefault();
const resp = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
});
if (resp.ok) {
window.location.href = '/doc/index.html';
} else {
document.getElementById('error').style.display = 'block';
}
};
</script>
</body>
</html>

View File

@@ -66,6 +66,7 @@ const MODULES = [
["10_pipeline","生產線","Pipeline"],
["12_agent","智慧代理","AI Agents"],
["13_config","系統設定","System Config"],
["14_identity_history","操作歷史","Operation History (Undo/Redo)"],
];
const el = document.getElementById('content');

View File

@@ -7,7 +7,7 @@
### `POST /api/v1/search/smart`
**Auth**: Required
**Scope**: file-level
**Scope**: global / 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.
@@ -15,13 +15,22 @@ Semantic vector search using EmbeddingGemma-300m. Generates a query embedding vi
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `file_uuid` | string | Yes | — | File UUID to search within |
| `query` | string | Yes | — | Search text |
| `file_uuid` | string | No | — | File UUID to search within. If omitted, searches all files (global search) |
| `limit` | integer | No | 5 | Max results to return |
| `page` | integer | No | 1 | Page number |
| `page_size` | integer | No | 5 | Items per page |
#### Example
#### Example (Global Search)
```bash
curl -s -X POST "$API/api/v1/search/smart" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $JWT" \
-d '{"query": "Audrey Hepburn"}'
```
#### Example (File-specific Search)
```bash
curl -s -X POST "$API/api/v1/search/smart" \
@@ -37,6 +46,7 @@ curl -s -X POST "$API/api/v1/search/smart" \
"query": "Audrey Hepburn",
"results": [
{
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"parent_id": 1087822,
"scene_order": 1087822,
"start_frame": 104438,
@@ -54,12 +64,16 @@ curl -s -X POST "$API/api/v1/search/smart" \
}
```
| Field | Type | Description |
|-------|------|-------------|
| `results[].file_uuid` | string | File UUID where result was found |
---
### `POST /api/v1/search/universal`
**Auth**: Required
**Scope**: file-level
**Scope**: global / file-level
Multi-type BM25 full-text search across chunks, frames, and persons. Uses PostgreSQL `tsvector`.
@@ -68,13 +82,22 @@ Multi-type BM25 full-text search across chunks, frames, and persons. Uses Postgr
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `query` | string | Yes | — | Search text |
| `file_uuid` | string | No | — | Restrict to specific file |
| `file_uuid` | string | No | — | Restrict to specific file. If omitted, searches all files (global search) |
| `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
#### Example (Global Search)
```bash
curl -s -X POST "$API/api/v1/search/universal" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $JWT" \
-d '{"query": "Cary Grant"}'
```
#### Example (File-specific Search)
```bash
curl -s -X POST "$API/api/v1/search/universal" \
@@ -90,6 +113,7 @@ curl -s -X POST "$API/api/v1/search/universal" \
"results": [
{
"type": "chunk",
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"chunk_id": "bd80fec92b0b6963d177a2c55bf713e2_2",
"chunk_type": "story_child",
"start_frame": 5103,
@@ -98,6 +122,25 @@ curl -s -X POST "$API/api/v1/search/universal" \
"end_time": 213.64,
"text": "[213s-214s] Cary Grant: \"Olá!\"",
"score": 0.9
},
{
"type": "frame",
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"frame_number": 5105,
"timestamp": 212.72,
"score": 0.7,
"objects": null,
"ocr_texts": null,
"faces": null
},
{
"type": "person",
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"identity_id": 12,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"name": "Cary Grant",
"appearance_count": 542,
"score": 0.95
}
],
"total": 20,
@@ -105,23 +148,78 @@ curl -s -X POST "$API/api/v1/search/universal" \
}
```
| Field | Type | Description |
|-------|------|-------------|
| `results[].type` | string | Result type: `chunk`, `frame`, or `person` |
| `results[].file_uuid` | string | File UUID where result was found (all types) |
---
### `POST /api/v1/search/frames`
**Auth**: Required
**Scope**: file-level
**Scope**: global / file-level
Search face detection frames by identity name or trace ID.
---
### `POST /api/v1/search/identity_text`
### `GET /api/v1/search/identity_text`
**Auth**: Required
**Scope**: file-level
**Scope**: global / file-level
Search text chunks spoken by a specific identity.
Search text chunks → find associated identities. Returns chunks where face detections overlap with text content.
#### Query Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `q` | string | Yes | — | Search text (ILIKE match) |
| `file_uuid` | string | No | — | Restrict to specific file. If omitted, searches all files (global search) |
| `limit` | integer | No | 50 | Max results |
| `page` | integer | No | 1 | Page number |
| `page_size` | integer | No | 50 | Items per page |
#### Example (Global Search)
```bash
curl -s "$API/api/v1/search/identity_text?q=love" -H "X-API-Key: $KEY"
```
#### Example (File-specific Search)
```bash
curl -s "$API/api/v1/search/identity_text?file_uuid=$FILE_UUID&q=love" -H "X-API-Key: $KEY"
```
#### Response (200)
```json
{
"success": true,
"total": 5,
"results": [
{
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"chunk_id": "llm_parent_..._256_270",
"start_time": 256.256,
"end_time": 270.228,
"text_content": "...lack of affection...",
"identity_id": 9,
"identity_name": "Audrey Hepburn",
"identity_source": "tmdb",
"trace_id": 94
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `results[].file_uuid` | string | File UUID where chunk was found |
| `results[].identity_id` | integer | Identity ID if face was detected |
| `results[].trace_id` | integer | Face trace ID |
---
@@ -145,4 +243,4 @@ Search text chunks spoken by a specific identity.
| **Storage** | pgvector (`chunk.embedding` column) |
---
*Updated: 2026-05-19 12:49:24*
*Updated: 2026-05-27 — Added global search support for smart, universal, identity_text APIs*

View File

@@ -70,7 +70,16 @@ curl -s "$API/api/v1/identity/$IDENTITY_UUID" -H "X-API-Key: $KEY"
**Auth**: Required
**Scope**: identity-level
Delete an identity permanently.
Delete an identity permanently. All face detections bound to this identity are unbound (`identity_id` set to `NULL`). The identity JSON file is deleted from disk.
#### History & Undo/Redo
Every DELETE records a full snapshot of the identity and its unbound faces. See [`14_identity_history.md`](14_identity_history.md#4-delete-history--undoredo) for:
- Undo via `POST /api/v1/identity/:identity_uuid/undo` — recreates identity and re-binds faces
- Redo via `POST /api/v1/identity/:identity_uuid/redo` — re-deletes the identity
**Note**: Delete undo/redo reuses the same endpoints as PATCH undo/redo. The endpoint automatically detects whether the identity was deleted (undo) or needs to be re-deleted (redo) based on the history record.
---
@@ -129,124 +138,75 @@ curl -s -X PATCH "$API/api/v1/identity/$IDENTITY_UUID" \
| HTTP | When |
|------|------|
| `400` | No fields to update or invalid UUID format |
| `404` | Identity not found |
| `500` | Database error |
#### History & Undo/Redo
Every bind records a before/after snapshot. See [`14_identity_history.md`](14_identity_history.md#2-bindunbindtrace-history--undoredo) for:
- `POST /api/v1/identity/:identity_uuid/bind/undo` — Revert a bind
- `POST /api/v1/identity/:identity_uuid/bind/redo` — Reapply an undone bind
- `GET /api/v1/identity/:identity_uuid/bind/history` — Query bind operations
---
### `GET /api/v1/identity/:identity_uuid/files`
## Metadata (Embedded JSON)
**Auth**: Required
**Scope**: identity-level
The `identities.metadata` column is a **JSONB** field that stores arbitrary structured data alongside the identity's core fields (name, status, identity_type). No schema is enforced — any valid JSON object is accepted.
Get all files where this identity appears. Returns per-file summary including face count, confidence, and appearance time range.
### Merge Behavior
#### Example
| Operation | Strategy | Example |
|-----------|----------|---------|
| **PATCH** | Shallow top-level merge: `COALESCE(metadata,'{}'::jsonb) \|\| $1::jsonb` | Sending `{"tmdb_rating": 8.5}` only adds/overwrites `tmdb_rating`; all other existing keys are preserved. |
| **mergeinto** | Recursive deep merge — nested sub-keys are merged individually, not replaced wholesale | Target has `{"tmdb": {"biography": "..."}}`, source has `{"tmdb": {"birthday": "1904-01-18"}}` → result is `{"tmdb": {"biography": "...", "birthday": "1904-01-18"}}`. |
| **Upload (`POST`)** | Direct overwrite — the entire `metadata` field is replaced with the request value. | |
```bash
curl -s "$API/api/v1/identity/$IDENTITY_UUID/files" -H "X-API-Key: $KEY"
```
### Validation
---
| Scenario | Result |
|----------|--------|
| PATCH with non-object metadata (`string`, `array`, `number`, `null`) | `400 Bad Request: "metadata must be a JSON object"` |
| mergeinto with non-object metadata | Accepted (mergeinto validates at application level) |
| Upload with non-object metadata | Accepted (upload replaces directly) |
### `GET /api/v1/identity/:identity_uuid/faces`
### Conventional Keys
**Auth**: Required
**Scope**: identity-level
| Key | Type | Writer | Purpose |
|-----|------|--------|---------|
| `aliases` | `[{locale, name}]` | PATCH, mergeinto | Multilingual display names (see [Alias System](#alias-system-bcp-47-locale-tags)) |
| `merged_into` | `{uuid, at}` | mergeinto | Marks an identity as merged (undo mechanism reads this) |
| `tmdb_*` | various | TMDb probe | Movie metadata (biography, birthday, known_for, etc.). Written only when `MOMENTRY_TMDB_PROBE_ENABLED=true`. |
| `source` | string | mergeinto | Tagged on aliases/metadata when added by merge (`"merge"` value) |
Get all face detection records associated with this identity.
Custom keys are fully supported — no registration required.
#### Example
### Search Coverage
```bash
curl -s "$API/api/v1/identity/$IDENTITY_UUID/faces" -H "X-API-Key: $KEY"
```
The identity search endpoint (`GET /api/v1/identity/search`) matches across three scopes:
| 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 |
1. `i.name` — exact and ILIKE against display name
2. `jsonb_array_elements(i.metadata->'aliases')->>'name'` — locale-tagged alias names
3. `i.metadata::text ILIKE $1` — raw string search across the entire JSON blob (all keys, all values)
---
This means searching for `"1904-01-18"` or `"biography"` will match identities whose metadata contains those strings anywhere.
### `GET /api/v1/identity/:identity_uuid/chunks`
### History Snapshots
**Auth**: Required
**Scope**: identity-level
Every `identity_history` record captures the **full metadata** in both `before_snapshot` and `after_snapshot` (as part of the complete identity JSONB dump). Undo restores the identity row — including metadata — to the `before_snapshot` state.
Get all text chunks (sentences) spoken while this identity's face was on screen. Useful for finding what a person said.
For merge operations, the MongoDB merge history records `metadata_fields_added` and `metadata_fields_added_paths` (dot-separated paths like `"tmdb.biography"`). Merge undo removes only those specific paths, preserving subsequent manual edits to other metadata keys.
#### Example
### Best Practices
```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}`) |
#### Side Effects
- 清除該 face detection row 的 `stranger_id`(設為 NULL
- 不影響 `identities` 表中原有的 stranger auto-identity 記錄
#### 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"}'
```
| Guideline | Reason |
|-----------|--------|
| Deep nesting is allowed in metadata | All metadata merge operations use `jsonb_deep_merge()` — nested sub-keys are merged recursively, not replaced wholesale |
| Use `aliases` for display names | Frontend has built-in locale fallback logic (see [Alias System](#alias-system-bcp-47-locale-tags)) |
| Avoid >1MB per identity | Metadata is included in search indexing (`metadata::text ILIKE`); large blobs degrade query performance |
| Don't rely on metadata ordering | JSONB preserves insertion order but PostgreSQL does not guarantee it across operations |
| No LLM/Gemma4 agent writes to metadata | Only API endpoints (PATCH, mergeinto, upload) and TMDb probe modify `identities.metadata` |
---
@@ -295,6 +255,10 @@ curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/bind/trace" \
| `404` | Identity not found |
| `500` | Database error |
#### History & Undo/Redo
Trace bind operations share the same history/undo/redo system as single-face binds. See [`14_identity_history.md`](14_identity_history.md#2-bindunbindtrace-history--undoredo) for endpoints.
---
### `GET /api/v1/identity/:identity_uuid/traces`
@@ -382,6 +346,13 @@ Unbind a face detection from an identity. Removes the identity association from
- 被 unbind 的 face 不會自動成為 stranger
- 要重新標記為 stranger 需重新跑 Agent API`identity/analyze`
#### History & Undo/Redo
Unbind records a before/after snapshot. See [`14_identity_history.md`](14_identity_history.md#2-bindunbindtrace-history--undoredo) for:
- `POST /api/v1/identity/:identity_uuid/bind/undo` — Revert an unbind
- `POST /api/v1/identity/:identity_uuid/bind/redo` — Reapply an undone unbind
---
### `POST /api/v1/identity/:identity_uuid/mergeinto`
@@ -391,6 +362,13 @@ Unbind a face detection from an identity. Removes the identity association from
Transfer all face bindings from this identity to another identity, then optionally delete or mark the source as merged.
#### Two Merge Cases
| Case | Description | Undo/Redo Support |
|------|-------------|-------------------|
| **stranger → identity** | Merge an auto-generated stranger identity into a known identity (TMDb or user-defined) | ✅ 24hr undo/redo |
| **identity A → identity B** | Merge two known identities (e.g., duplicate entries) | ✅ 24hr undo/redo |
#### Request Parameters
| Field | Type | Required | Default | Description |
@@ -402,8 +380,12 @@ Transfer all face bindings from this identity to another identity, then optional
- 轉移所有 `face_detections.identity_id` 到目標 identity
- 同時清除所有被轉移 rows 的 `stranger_id`
- 將 source name 加入 target aliases (with `source: "merge"` tag)
- 將 source aliases 加入 target aliases (if not already present)
- 將 source metadata fields 加入 target metadata (if not already present)
- `keep_history: true`預設source identity 設為 `status='merged'`,保留記錄
- `keep_history: false`**刪除** source identity 及其 identity JSON 檔案
- **記錄 merge history 到 MongoDB**(支援 undo/redo
#### Example
@@ -411,7 +393,7 @@ Transfer all face bindings from this identity to another identity, then optional
curl -s -X POST "$API/api/v1/identity/$SOURCE_UUID/mergeinto" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"into_uuid": "'"$TARGET_UUID"'", "keep_history": false}'
-d '{"into_uuid": "'"$TARGET_UUID"'", "keep_history": true}'
```
#### Response (200)
@@ -419,11 +401,23 @@ curl -s -X POST "$API/api/v1/identity/$SOURCE_UUID/mergeinto" \
```json
{
"success": true,
"message": "Merged 'stranger_13894' into 'Louis Viret' (52 faces transferred, source deleted)",
"data": { "faces_transferred": 52 }
"message": "Merged 'stranger_13894' into 'Louis Viret' (52 faces transferred, history kept)",
"data": {
"merge_id": "550e8400-e29b-41d4-a716-446655440000",
"faces_transferred": 52,
"aliases_added": 1,
"metadata_fields_added": 2
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `merge_id` | string | Unique merge operation ID (for undo) |
| `faces_transferred` | integer | Number of face detections transferred |
| `aliases_added` | integer | Number of aliases added to target |
| `metadata_fields_added` | integer | Number of metadata fields added to target |
#### Error Responses
| HTTP | When |
@@ -433,25 +427,189 @@ curl -s -X POST "$API/api/v1/identity/$SOURCE_UUID/mergeinto" \
---
### `GET /api/v1/identities/search`
### `POST /api/v1/identity/merge/:merge_id/undo`
**Auth**: Required
**Scope**: identity-level
Search identities by name (ILIKE search). Returns matching identity records.
Undo a merge operation within 24 hours. Restores the source identity and reverts face bindings.
#### Undo Behavior
| Action | Description |
|--------|-------------|
| Restore source identity | If `keep_history=true`: restore status to `confirmed`<br>If `keep_history=false`: recreate identity from MongoDB snapshot |
| Restore faces | Transfer faces back to source identity |
| Remove aliases from target | Remove aliases with `source: "merge"` tag |
| Remove metadata fields from target | Remove fields that were added from source |
| **Preserve manual changes** | Keep aliases/metadata manually added after merge |
#### Example
```bash
curl -s "$API/api/v1/identities/search?q=Cary" -H "X-API-Key: $KEY"
curl -s -X POST "$API/api/v1/identity/merge/550e8400-e29b-41d4-a716-446655440000/undo" \
-H "X-API-Key: $KEY"
```
#### Response (200)
```json
{
"success": true,
"message": "Undo merge completed: 'stranger_13894' restored, 52 faces reverted",
"data": {
"source_identity_restored": {
"uuid": "a9a90105...",
"name": "stranger_13894",
"status": "confirmed"
},
"faces_reverted": 52,
"aliases_removed_from_target": 1,
"metadata_fields_removed_from_target": 2
}
}
```
#### Error Responses
| HTTP | When |
|------|------|
| `400` | Undo deadline expired (>24hr) or already undone |
| `404` | Merge record not found |
| `500` | Database error |
---
### `POST /api/v1/identity/merge/:merge_id/redo`
**Auth**: Required
**Scope**: identity-level
Redo a previously undone merge operation. See [`14_identity_history.md`](14_identity_history.md#post-apiv1identitymergemerge_idredo) for full details.
---
### `GET /api/v1/identity/merge/history`
**Auth**: Required
**Scope**: identity-level
Query merge history records from MongoDB.
#### Query Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `source_uuid` | string | No | — | Filter by source identity UUID |
| `target_uuid` | string | No | — | Filter by target identity UUID |
| `merge_id` | string | No | — | Filter by specific merge ID |
| `undone` | bool | No | — | Filter by undone status |
| `page` | int | No | 1 | Page number |
| `page_size` | int | No | 20 | Items per page |
#### Example
```bash
curl -s "$API/api/v1/identity/merge/history?page=1&page_size=10" \
-H "X-API-Key: $KEY"
```
#### Response (200)
```json
{
"success": true,
"total": 5,
"page": 1,
"page_size": 10,
"results": [
{
"merge_id": "550e8400-e29b-41d4-a716-446655440000",
"source_name": "stranger_13894",
"target_name": "Louis Viret",
"faces_transferred": 52,
"merged_at": "2026-05-27T10:00:00Z",
"undo_deadline": "2026-05-28T10:00:00Z",
"undone": false,
"undo_expired": false
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Identity name |
| `source` | string | Identity source |
| `tmdb_id` | integer | TMDb ID (if source = tmdb) |
| `file_uuid` | string | Associated file |
| `merge_id` | string | Unique merge operation ID |
| `source_name` | string | Source identity name |
| `target_name` | string | Target identity name |
| `faces_transferred` | integer | Number of faces transferred |
| `merged_at` | datetime | When merge occurred |
| `undo_deadline` | datetime | 24hr deadline for undo |
| `undone` | bool | Whether merge was undone |
| `undo_expired` | bool | Whether undo deadline passed |
---
### `GET /api/v1/identities/search`
**Auth**: Required
**Scope**: global / file-level
Search identity name → find associated chunks. Searches identity name and aliases, returns identities with their associated text chunks.
#### Query Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `q` | string | Yes | — | Search text (ILIKE match on name and aliases) |
| `file_uuid` | string | No | — | Restrict to specific file. If omitted, searches all files (global search) |
| `limit` | integer | No | 50 | Max results |
#### Example (Global Search)
```bash
curl -s "$API/api/v1/identities/search?q=Audrey" -H "X-API-Key: $KEY"
```
#### Example (File-specific Search)
```bash
curl -s "$API/api/v1/identities/search?q=Audrey&file_uuid=$FILE_UUID" -H "X-API-Key: $KEY"
```
#### Response (200)
```json
{
"success": true,
"total": 5,
"results": [
{
"identity_id": 9,
"name": "Audrey Hepburn",
"source": "tmdb",
"tmdb_id": 1932,
"file_uuid": "a6fb22eebefaef17e62af874997c5944",
"trace_id": 41,
"chunk_id": "llm_parent_..._204_207",
"start_time": 204.162,
"text_content": "...confrontation..."
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `results[].identity_id` | integer | Identity ID |
| `results[].name` | string | Identity name |
| `results[].source` | string | Identity source (`tmdb`, `user_defined`, etc.) |
| `results[].tmdb_id` | integer | TMDb person ID (if source = tmdb) |
| `results[].file_uuid` | string | File where identity appears |
| `results[].trace_id` | integer | Face trace ID |
| `results[].chunk_id` | string | Associated chunk ID |
| `results[].start_time` | float | Chunk start time |
| `results[].text_content` | string | Chunk text content |
---
@@ -628,4 +786,4 @@ PATCH /api/v1/identity/:identity_uuid
This **replaces** the entire `aliases` array. To add to existing aliases, include all existing entries in the request.
---
*Updated: 2026-05-25
*Updated: 2026-05-25 — Added `GET /api/v1/file/:file_uuid/faces` with 4 binding states, filters, strangers table split

View File

@@ -0,0 +1,696 @@
<!-- module: identity_history -->
<!-- description: Identity operation history, undo, and redo (PATCH, bind, unbind, bind_trace, mergeinto) -->
<!-- depends: 01_auth, 07_identity -->
## Identity Operation History
Every mutation on an identity automatically records a before/after snapshot. Use undo/redo to revert or reapply changes, and history to inspect the operation log.
Three independent undo/redo systems exist:
| System | Storage | Operations Covered |
|--------|---------|-------------------|
| **PATCH** | PostgreSQL `identity_history` | `update` |
| **Bind** | PostgreSQL `identity_history` | `bind`, `unbind`, `bind_trace` |
| **Merge** | MongoDB `identity_merge_history` | mergeinto |
| **Delete** | PostgreSQL `identity_history` | `delete` |
---
### 1. PATCH History & Undo/Redo
#### Overview
| Property | Value |
|----------|-------|
| Storage | PostgreSQL `identity_history` table |
| Snapshot | Full identity record (all fields) before and after each PATCH |
| Max records | 256 per identity (oldest auto-deleted when limit exceeded) |
| Undo steps | Unlimited (no expiry, no step limit) |
| Redo stack | Cleared on new PATCH (`is_undone=true` + `operation='update'` records are deleted) |
##### Stack Model
```
PATCH 1 → PATCH 2 → PATCH 3 (undo stack, is_undone=false)
↓ undo
PATCH 1 → PATCH 2 (undo stack)
PATCH 3 (redo stack, is_undone=true)
↓ redo
PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
```
A new PATCH after undo clears only the operation='update' redo stack (PATCH 3 is lost). Bind/merge redo stacks are not affected.
---
#### `POST /api/v1/identity/:identity_uuid/undo`
**Auth**: Required
**Scope**: identity-level
Undo the most recent PATCH operations. Restores the identity's `before_snapshot` and marks the history records as undone.
##### Request (JSON)
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `steps` | integer | No | `1` | Number of undo steps to apply (max records undone in one call) |
##### Behavior
- Queries `is_undone=false` records with `operation='update'`, ordered by `created_at DESC`
- Restores `name`, `identity_type`, `source`, `status`, `metadata`, `tmdb_id`, `tmdb_profile` from the last record's `before_snapshot`
- Marks the undone records as `is_undone=true` with `undone_at=NOW()`
- Syncs `identity.json` to disk
- Updates `_index.json` if name changed
##### Example
```bash
curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/undo" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"steps": 1}'
```
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"undone_count": 1,
"current_state": {
"id": 9,
"uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"name": "Cary Grant",
"identity_type": "people",
"source": "tmdb",
"status": "confirmed",
"metadata": {},
"tmdb_id": 112,
"tmdb_profile": null
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `undone_count` | integer | Number of history records undone |
| `current_state` | object | Full identity state after undo |
##### Error Responses
| HTTP | When |
|------|------|
| `400` | No undo operations available |
| `404` | Identity not found |
| `500` | Database error |
---
#### `POST /api/v1/identity/:identity_uuid/redo`
**Auth**: Required
**Scope**: identity-level
Redo previously undone PATCH operations. Restores the identity's `after_snapshot` and marks the history records as no longer undone.
##### Request (JSON)
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `steps` | integer | No | `1` | Number of redo steps to apply |
##### Behavior
- Queries `is_undone=true` records with `operation='update'`, ordered by `created_at DESC`
- Restores all identity fields from the last record's `after_snapshot`
- Marks records as `is_undone=false` with `undone_at=NULL`
- Syncs `identity.json` to disk
- Updates `_index.json` if name changed
##### Example
```bash
curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/redo" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"steps": 1}'
```
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"redone_count": 1,
"current_state": {
"id": 9,
"uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"name": "John Smith",
"identity_type": "people",
"source": "tmdb",
"status": "confirmed",
"metadata": { "aliases": [...] },
"tmdb_id": 112,
"tmdb_profile": null
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `redone_count` | integer | Number of history records redone |
| `current_state` | object | Full identity state after redo |
##### Error Responses
| HTTP | When |
|------|------|
| `400` | No redo operations available |
| `404` | Identity not found |
| `500` | Database error |
---
#### `GET /api/v1/identity/:identity_uuid/history`
**Auth**: Required
**Scope**: identity-level
Query the PATCH operation history for an identity. Returns paginated records with undo/redo stack counts (filtered to `operation='update'`).
##### Query Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | integer | No | `1` | Page number (1-indexed) |
| `limit` | integer | No | `20` | Items per page (max 100) |
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"total": 5,
"undo_stack_count": 3,
"redo_stack_count": 2,
"results": [
{
"history_id": 42,
"operation": "update",
"is_undone": false,
"created_at": "2026-05-27T12:00:00Z",
"undone_at": null
},
{
"history_id": 41,
"operation": "update",
"is_undone": true,
"created_at": "2026-05-27T11:30:00Z",
"undone_at": "2026-05-27T13:00:00Z"
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `total` | integer | Total PATCH history records for this identity |
| `undo_stack_count` | integer | Records available for undo (`is_undone=false`) |
| `redo_stack_count` | integer | Records available for redo (`is_undone=true`) |
| `results[].history_id` | integer | History record ID |
| `results[].operation` | string | Operation type (`"update"` for PATCH) |
| `results[].is_undone` | boolean | Whether the operation has been undone |
| `results[].created_at` | string | When the PATCH was applied |
| `results[].undone_at` | string | When the undo occurred (null if not undone) |
##### Example
```bash
curl -s "$API/api/v1/identity/$IDENTITY_UUID/history?page=1&limit=10" \
-H "X-API-Key: $KEY"
```
##### Error Responses
| HTTP | When |
|------|------|
| `404` | Identity not found |
| `500` | Database error |
---
### 2. Bind/Unbind/Trace History & Undo/Redo
All three operations (`bind`, `unbind`, `bind_trace`) share a single history table and undo/redo stack.
#### Bind Operation Overview
| Property | Value |
|----------|-------|
| Storage | PostgreSQL `identity_history` table (same table as PATCH) |
| Snapshot | `{"file_uuid", "face_id" (or "trace_id"), "identity_id_before/after"}` |
| Max records | 256 per identity (shared limit across all operation types) |
| Undo steps | Unlimited (`steps` param) |
| Redo stack | Cleared on new bind/unbind/bind_trace (`operation IN ('bind','unbind','bind_trace')` + `is_undone=true` records deleted) |
| Stack isolation | Bind redo stack is **independent** from PATCH redo stack — clearing one does not affect the other |
##### Stack Model
```
bind face_1 (to id=9) → unbind face_1 → bind trace 906 (to id=9)
(undo stack, is_undone=false) (undo stack) (undo stack)
↓ undo (first undone: bind_trace)
bind trace 906 (is_undone=true)
(redo stack)
↓ redo
bind face_1 → unbind face_1 → bind trace 906
(undo stack)
```
A new bind/unbind/trace after undo clears only the bind redo stack (operations with `IN ('bind','unbind','bind_trace')`).
##### Snapshot Format
**Before (bind):**
```json
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_5",
"identity_id_before": null
}
```
**After (bind):**
```json
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_5",
"identity_id_after": 9
}
```
**Before (unbind) — binding existed before:**
```json
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_5",
"identity_id_before": 9
}
```
**After (unbind):**
```json
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_5",
"identity_id_after": null
}
```
For `bind_trace`, the snapshot uses `trace_id` instead of `face_id`, with `identity_id_before` capturing the first face's identity in that trace.
---
#### `POST /api/v1/identity/:identity_uuid/bind/undo`
**Auth**: Required
**Scope**: identity-level
Undo the most recent bind/unbind/bind_trace operations. Restores `identity_id_before` from the snapshot and marks records as undone.
##### Request (JSON)
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `steps` | integer | No | `1` | Number of undo steps to apply |
##### Behavior
- Queries `is_undone=false` records with `operation IN ('bind','unbind','bind_trace')`, ordered by `created_at DESC`
- Restores `identity_id_before` — for bind this is `null` (face was unbound), for unbind this is the original identity (face goes back), for bind_trace this is the trace's previous identity
- Marks the undone records as `is_undone=true` with `undone_at=NOW()`
##### Example
```bash
curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/bind/undo" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"steps": 1}'
```
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"operation": "bind",
"undone_count": 1,
"affected_rows": 53
}
```
| Field | Type | Description |
|-------|------|-------------|
| `operation` | string | The actual operation undone (`bind`, `unbind`, or `bind_trace`) |
| `undone_count` | integer | Number of history records undone |
| `affected_rows` | integer | Number of `face_detections` rows updated |
##### Error Responses
| HTTP | When |
|------|------|
| `400` | No bind undo operations available |
| `404` | Identity not found |
| `500` | Database error |
---
#### `POST /api/v1/identity/:identity_uuid/bind/redo`
**Auth**: Required
**Scope**: identity-level
Redo previously undone bind/unbind/bind_trace operations. Restores `identity_id_after` from the snapshot.
##### Request (JSON)
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `steps` | integer | No | `1` | Number of redo steps to apply |
##### Behavior
- Queries `is_undone=true` records with `operation IN ('bind','unbind','bind_trace')`, ordered by `created_at DESC`
- Restores `identity_id_after` — for bind this is the identity the face was bound to, for unbind this is `null`
- Marks records as `is_undone=false` with `undone_at=NULL`
##### Example
```bash
curl -s -X POST "$API/api/v1/identity/$IDENTITY_UUID/bind/redo" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"steps": 1}'
```
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"operation": "unbind",
"redone_count": 1,
"affected_rows": 1
}
```
| Field | Type | Description |
|-------|------|-------------|
| `operation` | string | The actual operation redone (`bind`, `unbind`, or `bind_trace`) |
| `redone_count` | integer | Number of history records redone |
| `affected_rows` | integer | Number of `face_detections` rows updated |
##### Error Responses
| HTTP | When |
|------|------|
| `400` | No bind redo operations available |
| `404` | Identity not found |
| `500` | Database error |
---
#### `GET /api/v1/identity/:identity_uuid/bind/history`
**Auth**: Required
**Scope**: identity-level
Query the bind/unbind/bind_trace operation history for an identity. Returns paginated records with undo/redo stack counts.
##### Query Parameters
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | integer | No | `1` | Page number (1-indexed) |
| `limit` | integer | No | `20` | Items per page (max 100) |
##### Response (200)
```json
{
"success": true,
"identity_uuid": "a9a901056d6b46ff92da0c3c1a57dff4",
"total": 3,
"undo_stack_count": 2,
"redo_stack_count": 1,
"results": [
{
"history_id": 52,
"operation": "bind_trace",
"is_undone": false,
"created_at": "2026-05-27T14:00:00Z",
"undone_at": null
},
{
"history_id": 51,
"operation": "unbind",
"is_undone": true,
"created_at": "2026-05-27T13:00:00Z",
"undone_at": "2026-05-27T14:30:00Z"
},
{
"history_id": 50,
"operation": "bind",
"is_undone": false,
"created_at": "2026-05-27T12:00:00Z",
"undone_at": null
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `total` | integer | Total bind history records for this identity |
| `undo_stack_count` | integer | Records available for undo (`is_undone=false`) |
| `redo_stack_count` | integer | Records available for redo (`is_undone=true`) |
| `results[].history_id` | integer | History record ID |
| `results[].operation` | string | Operation type (`bind`, `unbind`, or `bind_trace`) |
| `results[].is_undone` | boolean | Whether the operation has been undone |
| `results[].created_at` | string | When the operation was applied |
| `results[].undone_at` | string | When the undo occurred (null if not undone) |
##### Example
```bash
curl -s "$API/api/v1/identity/$IDENTITY_UUID/bind/history?page=1&limit=10" \
-H "X-API-Key: $KEY"
```
##### Error Responses
| HTTP | When |
|------|------|
| `404` | Identity not found |
| `500` | Database error |
---
### 3. Merge History & Undo/Redo
Merge operations use MongoDB for richer record-keeping, with a 24-hour undo deadline.
#### Merge Operation Overview
| Property | Value |
|----------|-------|
| Storage | MongoDB `identity_merge_history` collection |
| Snapshot | Full source identity state + target identity state + aliases/metadata diffs |
| Trigger | Every mergeinto with `keep_history=true` |
| Undo deadline | 24 hours (renewed on redo) |
| Redo support | Yes — restores undone merges with new 24hr deadline |
| Max records | Unlimited |
---
#### `POST /api/v1/identity/merge/:merge_id/undo`
Already documented in [`07_identity.md`](07_identity.md#post-apiv1identitymergemerge_idundo). See that document for full details.
---
#### `POST /api/v1/identity/merge/:merge_id/redo`
**Auth**: Required
**Scope**: identity-level
Redo a previously undone merge operation within the renewed 24-hour deadline.
##### Request
No body required. The merge ID is taken from the URL path.
##### Behavior
1. Validates the merge record exists and `undone=true` (not already active)
2. Checks the 24-hour undo deadline (if expired, the redo is rejected)
3. Restores face bindings: moves all faces from `target_identity` back to `source_identity`
4. Re-adds aliases that were removed by the undo (aliases with `source: "merge"` tag)
5. Re-adds metadata fields that were removed by the undo
6. If `keep_history=true`: sets `source_identity.status = 'merged'` again
7. If `keep_history=false`: recreates source identity from the `undone_snapshot` stored at undo time
8. Syncs both identity JSON files to disk
9. Sets `undone=false`, clears `undone_snapshot`, renews `undo_deadline = NOW() + 24h`
10. Records `redone_by` user for audit
##### Example
```bash
curl -s -X POST "$API/api/v1/identity/merge/550e8400-e29b-41d4-a716-446655440000/redo" \
-H "X-API-Key: $KEY"
```
##### Response (200)
```json
{
"success": true,
"message": "Redo merge completed: merged 'stranger_13894' into 'Louis Viret' (52 faces transferred)",
"data": {
"merge_id": "550e8400-e29b-41d4-a716-446655440000",
"faces_transferred": 52,
"aliases_re_added": 1,
"metadata_fields_re_added": 2
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `merge_id` | string | The merge operation ID |
| `faces_transferred` | integer | Number of faces transferred from source to target |
| `aliases_re_added` | integer | Number of aliases restored to target |
| `metadata_fields_re_added` | integer | Number of metadata fields restored to target |
##### Error Responses
| HTTP | When |
|------|------|
| `400` | Merge not undone, deadline expired, or cannot redo |
| `404` | Merge record not found |
| `500` | Database error |
---
### 4. Delete History & Undo/Redo
#### Delete Operation Overview
| Property | Value |
|----------|-------|
| Storage | PostgreSQL `identity_history` table |
| Snapshot | `{"identity": {...full row...}, "unbound_faces": [{file_uuid, face_id, trace_id}, ...]}` |
| Max records | 1 active delete record per identity (redo stack cleared on new delete) |
| Undo support | Yes — recreates identity row, re-binds faces |
| Redo support | Yes — re-deletes the identity |
| Identity file | Deleted on delete, recreated on undo |
#### Snapshot Format
```json
{
"identity": {
"id": 9,
"uuid": "a9a90105-6d6b-46ff-92da-0c3c1a57dff4",
"name": "Cary Grant",
"identity_type": "people",
"source": "tmdb",
"status": "confirmed",
"metadata": {},
"tmdb_id": 112,
"tmdb_profile": null
},
"unbound_faces": [
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_5",
"trace_id": null
},
{
"file_uuid": "aeed71342a899fe4b4c57b7d41bcb692",
"face_id": "1_6",
"trace_id": 906
}
]
}
```
#### Stack Model
```
DELETE identity (undo stack, is_undone=false)
↓ undo
Identity recreated, faces re-bound
→ delete history marked is_undone=true
↓ redo (re-delete)
Identity deleted again, faces unbound
→ delete history marked is_undone=false
```
A new delete after an undo clears the delete redo stack (no redo possible for the old delete).
#### Undo Behavior (via existing `POST /api/v1/identity/:identity_uuid/undo`)
1. Normal identity lookup fails (row was deleted)
2. Checks `identity_history` for `operation='delete' AND is_undone=false` matching the UUID in the snapshot
3. Recreates the identity row (new internal `id`, same UUID)
4. Re-binds all faces listed in `unbound_faces` to the new identity
5. Deletes the `identity_history` delete record as `is_undone=true` with `undone_at=NOW()`
6. Syncs `identity.json` to disk
7. Updates `_index.json`
#### Redo Behavior (via existing `POST /api/v1/identity/:identity_uuid/redo`)
1. Identity lookup succeeds (identity was restored by prior undo)
2. Checks `identity_history` for `operation='delete' AND is_undone=true` matching the identity_id
3. Deletes `identity.json` from disk
4. Unbinds all faces (`identity_id = NULL`)
5. Deletes the identity row
6. Marks the delete history record as `is_undone=false`
7. Returns success
#### Error Responses (delete undo/redo)
| HTTP | Scenario |
|------|----------|
| `400` | No delete history available (either no delete or already undone/redone) |
| `404` | Identity not found (for redo — identity wasn't restored) |
| `500` | Database error |
---
### Comparison: PATCH vs Bind vs Merge vs Delete Undo/Redo
| Aspect | PATCH Undo/Redo | Bind Undo/Redo | Merge Undo/Redo | Delete Undo/Redo |
|--------|----------------|----------------|-----------------|------------------|
| Storage | PostgreSQL `identity_history` | PostgreSQL `identity_history` | MongoDB `identity_merge_history` | PostgreSQL `identity_history` |
| Operation filter | `operation='update'` | `operation IN ('bind','unbind','bind_trace')` | — | `operation='delete'` |
| Trigger | Every PATCH | Every bind/unbind/bind_trace | Every mergeinto with `keep_history=true` | Every DELETE |
| Undo deadline | None (unlimited) | None (unlimited) | 24 hours (renewed on redo) | None (unlimited) |
| Redo support | Yes | Yes | Yes | Yes |
| Step undo | Yes (`steps` param) | Yes (`steps` param) | No (full undo/redo only) | No (single record) |
| Max records | 256 per identity | 256 per identity (shared) | Unlimited | 256 per identity (shared) |
| User tracking | `user_id` + `user_source` | `user_id` + `user_source` | `performed_by_user` + `undone_by` / `redone_by` | `user_id` + `user_source` |
---
*Updated: 2026-05-28*

View File

@@ -0,0 +1,28 @@
-- Migration: Create identity_history table for undo/redo support
-- Description: Stores PATCH operation history for identity undo/redo functionality
-- Date: 2026-05-28
-- Create identity_history table
CREATE TABLE IF NOT EXISTS identity_history (
id BIGSERIAL PRIMARY KEY,
identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE,
operation VARCHAR(20) NOT NULL, -- 'update', 'create', 'delete'
before_snapshot JSONB, -- 操作前完整狀態
after_snapshot JSONB, -- 操作後完整狀態
is_undone BOOLEAN DEFAULT FALSE, -- 是否已被 undo
undone_at TIMESTAMPTZ, -- undo 時間
user_id VARCHAR(100), -- 操作者
user_source VARCHAR(50), -- 'wordpress', 'api', 'cli'
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes for efficient undo/redo operations
CREATE INDEX IF NOT EXISTS idx_identity_history_identity_time
ON identity_history(identity_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_identity_history_not_undone
ON identity_history(identity_id, created_at DESC)
WHERE NOT is_undone;
-- Add comment
COMMENT ON TABLE identity_history IS 'Stores identity PATCH operation history for undo/redo support. Max 256 records per identity.';

View File

@@ -0,0 +1,23 @@
-- Migration: 034_processor_state_machine
-- Purpose: Add processor_alerts table for State Machine alert mechanism
-- Date: 2026-05-30
-- Create processor_alerts table
CREATE TABLE IF NOT EXISTS processor_alerts (
id SERIAL PRIMARY KEY,
file_uuid VARCHAR(32),
processor_type VARCHAR(32) NOT NULL,
alert_type VARCHAR(32) NOT NULL,
message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create indexes for efficient querying
CREATE INDEX IF NOT EXISTS idx_alerts_file_uuid ON processor_alerts(file_uuid);
CREATE INDEX IF NOT EXISTS idx_alerts_processor_type ON processor_alerts(processor_type);
CREATE INDEX IF NOT EXISTS idx_alerts_alert_type ON processor_alerts(alert_type);
CREATE INDEX IF NOT EXISTS idx_alerts_created_at ON processor_alerts(created_at);
-- Add comments
COMMENT ON TABLE processor_alerts IS 'Processor state machine alerts for dependency/resource/output issues';
COMMENT ON COLUMN processor_alerts.alert_type IS 'Alert types: dependency_not_met, resource_exhausted, output_invalid, timeout';

View File

@@ -0,0 +1,314 @@
-- ============================================================
-- 3002/3003 Schema Separation: Create pipeline tables in public
-- Generated: 2026-05-17
-- ============================================================
-- Run: /Users/accusys/pgsql/18.3/bin/psql "postgres://accusys@localhost:5432/momentry" -f migrations/3002_public_schema_pipeline_tables.sql
-- ============================================================
BEGIN;
-- ============================================================
-- 1. videos
-- ============================================================
CREATE SEQUENCE IF NOT EXISTS public.videos_id_seq AS integer START WITH 1;
CREATE TABLE IF NOT EXISTS public.videos (
id integer DEFAULT nextval('public.videos_id_seq') NOT NULL,
file_uuid character varying(32) NOT NULL,
file_path text NOT NULL,
file_name text NOT NULL,
duration double precision,
width integer,
height integer,
fps double precision,
probe_json jsonb,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
fs_video boolean DEFAULT false,
fs_json boolean DEFAULT false,
psql_chunk boolean DEFAULT false,
pobject_chunk boolean DEFAULT false,
mobject_chunk boolean DEFAULT false,
pvector_chunk boolean DEFAULT false,
qvector_chunk boolean DEFAULT false,
status character varying(20) DEFAULT 'pending'::character varying,
user_id bigint,
job_id integer,
registration_time timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
file_type character varying(20),
processing_status jsonb DEFAULT '{}'::jsonb,
birth_registration jsonb,
total_frames bigint DEFAULT 0,
parent_uuid character varying(32),
cut_done boolean DEFAULT false,
scene_done boolean DEFAULT false,
audio_tracks jsonb DEFAULT '[]'::jsonb,
cut_count integer DEFAULT 0,
cut_max_duration double precision DEFAULT 0,
content_hash text
);
ALTER SEQUENCE public.videos_id_seq OWNED BY public.videos.id;
-- ============================================================
-- 2. chunk (with pgvector support)
-- ============================================================
CREATE SEQUENCE IF NOT EXISTS public.chunks_id_seq AS integer START WITH 1;
CREATE TABLE IF NOT EXISTS public.chunk (
id integer DEFAULT nextval('public.chunks_id_seq') NOT NULL,
file_uuid character varying(32) NOT NULL,
chunk_type character varying(32) NOT NULL,
start_time double precision NOT NULL,
end_time double precision NOT NULL,
content jsonb NOT NULL,
vector_id character varying(64),
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
file_id integer,
text_content text,
frame_count integer DEFAULT 0,
pre_chunk_ids integer[],
parent_chunk_id character varying(64),
child_chunk_ids text[],
search_vector tsvector,
fps double precision DEFAULT 24.0,
start_frame bigint DEFAULT 0,
end_frame bigint DEFAULT 0,
metadata jsonb,
updated_at timestamp with time zone DEFAULT now(),
visual_stats jsonb,
summary_text text,
chunk_id character varying(128) NOT NULL,
embedding public.vector,
old_chunk_id character varying(128),
chunk_index integer DEFAULT 0,
unique_key character varying
);
ALTER SEQUENCE public.chunks_id_seq OWNED BY public.chunk.id;
-- ============================================================
-- 3. chunk_vectors
-- ============================================================
CREATE SEQUENCE IF NOT EXISTS public.chunk_vectors_id_seq AS integer START WITH 1;
CREATE TABLE IF NOT EXISTS public.chunk_vectors (
id integer DEFAULT nextval('public.chunk_vectors_id_seq') NOT NULL,
chunk_id character varying(64) NOT NULL,
uuid character varying(64) NOT NULL,
chunk_type character varying(32) DEFAULT 'sentence'::character varying NOT NULL,
embedding jsonb,
created_at timestamp with time zone DEFAULT now()
);
ALTER SEQUENCE public.chunk_vectors_id_seq OWNED BY public.chunk_vectors.id;
-- ============================================================
-- 4. cuts
-- ============================================================
CREATE SEQUENCE IF NOT EXISTS public.cuts_id_seq AS integer START WITH 1;
CREATE TABLE IF NOT EXISTS public.cuts (
id integer DEFAULT nextval('public.cuts_id_seq') NOT NULL,
file_uuid character varying(32) NOT NULL,
cut_number integer NOT NULL,
start_frame bigint NOT NULL,
end_frame bigint NOT NULL,
start_time double precision,
end_time double precision,
fps double precision,
metadata jsonb DEFAULT '{}'::jsonb,
created_at timestamp with time zone DEFAULT now()
);
ALTER SEQUENCE public.cuts_id_seq OWNED BY public.cuts.id;
-- ============================================================
-- 5. frames
-- ============================================================
CREATE SEQUENCE IF NOT EXISTS public.frames_id_seq AS integer START WITH 1;
CREATE TABLE IF NOT EXISTS public.frames (
id integer DEFAULT nextval('public.frames_id_seq') NOT NULL,
file_id integer NOT NULL,
frame_number bigint NOT NULL,
timestamp double precision NOT NULL,
fps double precision DEFAULT 24.0,
yolo_objects jsonb,
ocr_results jsonb,
face_results jsonb,
frame_path text,
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
);
ALTER SEQUENCE public.frames_id_seq OWNED BY public.frames.id;
-- ============================================================
-- 6. monitor_jobs
-- ============================================================
CREATE SEQUENCE IF NOT EXISTS public.monitor_jobs_id_seq AS integer START WITH 1;
CREATE TABLE IF NOT EXISTS public.monitor_jobs (
id integer DEFAULT nextval('public.monitor_jobs_id_seq') NOT NULL,
uuid character varying(32) NOT NULL,
video_path character varying(512),
status character varying(20) DEFAULT 'pending'::character varying NOT NULL,
current_processor character varying(20),
progress_total integer DEFAULT 0,
progress_current integer DEFAULT 0,
error_count integer DEFAULT 0,
last_error text,
started_at timestamp without time zone,
updated_at timestamp with time zone,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
video_id bigint,
user_id bigint,
processors text[],
completed_processors text[],
failed_processors text[]
);
ALTER SEQUENCE public.monitor_jobs_id_seq OWNED BY public.monitor_jobs.id;
-- ============================================================
-- 7. processor_results
-- ============================================================
CREATE SEQUENCE IF NOT EXISTS public.processor_results_id_seq AS integer START WITH 1;
CREATE TABLE IF NOT EXISTS public.processor_results (
id integer DEFAULT nextval('public.processor_results_id_seq') NOT NULL,
job_id integer,
video_id bigint,
processor character varying(20),
status character varying(20) DEFAULT 'pending'::character varying NOT NULL,
output_path text,
started_at timestamp with time zone,
completed_at timestamp with time zone,
error_message text,
progress_total integer DEFAULT 0,
progress_current integer DEFAULT 0,
last_checkpoint jsonb,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
duration_secs double precision,
chunks_produced integer DEFAULT 0,
frames_processed integer DEFAULT 0,
output_size_bytes bigint DEFAULT 0,
file_uuid character varying(32),
result jsonb,
output_data jsonb,
retry_count integer DEFAULT 0,
processor_type character varying(64),
uuid character varying(255)
);
ALTER SEQUENCE public.processor_results_id_seq OWNED BY public.processor_results.id;
-- ============================================================
-- 8. processor_versions
-- ============================================================
CREATE TABLE IF NOT EXISTS public.processor_versions (
processor character varying(64) NOT NULL,
model_version character varying(128) NOT NULL,
processor_type character varying(32) DEFAULT 'processor'::character varying NOT NULL,
dependencies text[] DEFAULT '{}'::text[],
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
file_uuid character varying(64)
);
-- ============================================================
-- 9. parent_chunks
-- ============================================================
CREATE SEQUENCE IF NOT EXISTS public.parent_chunks_id_seq AS integer START WITH 1;
CREATE TABLE IF NOT EXISTS public.parent_chunks (
id integer DEFAULT nextval('public.parent_chunks_id_seq') NOT NULL,
uuid character varying(32) NOT NULL,
chunk_id character varying(64),
summary_text text,
summary_tsvector tsvector,
metadata jsonb DEFAULT '{}'::jsonb
);
ALTER SEQUENCE public.parent_chunks_id_seq OWNED BY public.parent_chunks.id;
-- ============================================================
-- 10. tkg_edges
-- ============================================================
CREATE SEQUENCE IF NOT EXISTS public.tkg_edges_id_seq AS bigint START WITH 1;
CREATE TABLE IF NOT EXISTS public.tkg_edges (
id bigint DEFAULT nextval('public.tkg_edges_id_seq') NOT NULL,
edge_type character varying(64) NOT NULL,
source_node_id bigint NOT NULL,
target_node_id bigint NOT NULL,
file_uuid character varying(64) NOT NULL,
properties jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP
);
ALTER SEQUENCE public.tkg_edges_id_seq OWNED BY public.tkg_edges.id;
-- ============================================================
-- 11. tkg_nodes
-- ============================================================
CREATE SEQUENCE IF NOT EXISTS public.tkg_nodes_id_seq AS bigint START WITH 1;
CREATE TABLE IF NOT EXISTS public.tkg_nodes (
id bigint DEFAULT nextval('public.tkg_nodes_id_seq') NOT NULL,
node_type character varying(64) NOT NULL,
external_id character varying(256) NOT NULL,
file_uuid character varying(64) NOT NULL,
label character varying(512),
properties jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP
);
ALTER SEQUENCE public.tkg_nodes_id_seq OWNED BY public.tkg_nodes.id;
-- ============================================================
-- Indexes & Constraints
-- ============================================================
-- videos
CREATE UNIQUE INDEX IF NOT EXISTS idx_videos_file_uuid ON public.videos (file_uuid);
-- chunk
CREATE INDEX IF NOT EXISTS idx_chunk_file_uuid ON public.chunk (file_uuid);
CREATE INDEX IF NOT EXISTS idx_chunk_type ON public.chunk (chunk_type);
CREATE INDEX IF NOT EXISTS idx_chunk_parent ON public.chunk (parent_chunk_id);
CREATE INDEX IF NOT EXISTS idx_chunk_file_type ON public.chunk (file_uuid, chunk_type);
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunk_file_old_id ON public.chunk (file_uuid, old_chunk_id) WHERE old_chunk_id IS NOT NULL;
-- chunk_vectors
CREATE INDEX IF NOT EXISTS idx_chunk_vec_uuid ON public.chunk_vectors (uuid);
CREATE INDEX IF NOT EXISTS idx_chunk_vec_chunk ON public.chunk_vectors (chunk_id);
-- cuts
CREATE INDEX IF NOT EXISTS idx_cuts_file_uuid ON public.cuts (file_uuid);
-- frames
CREATE INDEX IF NOT EXISTS idx_frames_file_id ON public.frames (file_id);
-- monitor_jobs
CREATE UNIQUE INDEX IF NOT EXISTS idx_monitor_jobs_uuid ON public.monitor_jobs (uuid);
CREATE INDEX IF NOT EXISTS idx_monitor_jobs_status ON public.monitor_jobs (status);
-- processor_results
CREATE INDEX IF NOT EXISTS idx_pr_job_id ON public.processor_results (job_id);
CREATE INDEX IF NOT EXISTS idx_pr_uuid ON public.processor_results (uuid);
CREATE UNIQUE INDEX IF NOT EXISTS idx_pr_job_processor_type ON public.processor_results (job_id, processor_type);
-- parent_chunks
CREATE INDEX IF NOT EXISTS idx_parent_chunks_uuid ON public.parent_chunks (uuid);
-- tkg_edges
CREATE INDEX IF NOT EXISTS idx_tkg_edges_file_uuid ON public.tkg_edges (file_uuid);
CREATE INDEX IF NOT EXISTS idx_tkg_edges_type ON public.tkg_edges (edge_type);
-- tkg_nodes
CREATE INDEX IF NOT EXISTS idx_tkg_nodes_file_uuid ON public.tkg_nodes (file_uuid);
CREATE INDEX IF NOT EXISTS idx_tkg_nodes_type ON public.tkg_nodes (node_type);
CREATE INDEX IF NOT EXISTS idx_tkg_nodes_external ON public.tkg_nodes (external_id);
COMMIT;

View File

@@ -12,43 +12,13 @@
<string>staff</string>
<key>WorkingDirectory</key>
<string>/Users/accusys/momentry_core_0.1</string>
<string>/Users/accusys/momentry_core</string>
<key>ProgramArguments</key>
<array>
<string>/Users/accusys/momentry_core_0.1/target/release/momentry</string>
<string>server</string>
<string>--port</string>
<string>3002</string>
<string>/Users/accusys/momentry_core/scripts/wrapper_production.sh</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>DATABASE_URL</key>
<string>postgres://accusys@localhost:5432/momentry</string>
<key>DB_MAX_CONNECTIONS</key>
<string>50</string>
<key>DB_ACQUIRE_TIMEOUT</key>
<string>30</string>
<key>REDIS_URL</key>
<string>redis://:accusys@localhost:6379</string>
<key>REDIS_PASSWORD</key>
<string>accusys</string>
<key>OLLAMA_HOST</key>
<string>http://localhost:11434</string>
<key>QDRANT_URL</key>
<string>http://127.0.0.1:6333</string>
</dict>
<key>RunAtLoad</key>
<true/>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.momentry.embedding</string>
<key>UserName</key>
<string>accusys</string>
<key>GroupName</key>
<string>staff</string>
<key>WorkingDirectory</key>
<string>/Users/accusys/momentry_core</string>
<key>ProgramArguments</key>
<array>
<string>/Users/accusys/momentry_core/scripts/wrapper_embedding.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/accusys/momentry/log/embedding.log</string>
<key>StandardErrorPath</key>
<string>/Users/accusys/momentry/log/embedding.error.log</string>
</dict>
</plist>

View File

@@ -13,7 +13,7 @@
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/gitea/bin/gitea</string>
<string>/Users/accusys/gitea/bin/gitea</string>
<string>web</string>
<string>--config</string>
<string>/Users/accusys/momentry/etc/gitea/app.ini</string>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.momentry.llamacpp-a4b</string>
<key>UserName</key>
<string>accusys</string>
<key>WorkingDirectory</key>
<string>/Users/accusys</string>
<key>ProgramArguments</key>
<array>
<string>/Users/accusys/llama/bin/llama-server</string>
<string>-m</string>
<string>/Users/accusys/models/google_gemma-4-26B-A4B-it-Q5_K_M.gguf</string>
<string>--mmproj</string>
<string>/Users/accusys/models/gemma-4-26B-A4B-it.mmproj-f16.gguf</string>
<string>--host</string>
<string>0.0.0.0</string>
<string>--port</string>
<string>8082</string>
<string>-ngl</string>
<string>99</string>
<string>-c</string>
<string>16384</string>
<string>--temp</string>
<string>0.1</string>
<string>--mlock</string>
<string>--reasoning</string>
<string>off</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/accusys/momentry/log/llamacpp-a4b.log</string>
<key>StandardErrorPath</key>
<string>/Users/accusys/momentry/log/llamacpp-a4b.error.log</string>
</dict>
</plist>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.momentry.llamacpp-e4b</string>
<key>UserName</key>
<string>accusys</string>
<key>WorkingDirectory</key>
<string>/Users/accusys</string>
<key>ProgramArguments</key>
<array>
<string>/Users/accusys/llama/bin/llama-server</string>
<string>-m</string>
<string>/Users/accusys/models/gemma-4-E4B-it-Q4_K_M.gguf</string>
<string>--mmproj</string>
<string>/Users/accusys/models/mmproj-gemma-4-E4B-it-BF16.gguf</string>
<string>--host</string>
<string>0.0.0.0</string>
<string>--port</string>
<string>8083</string>
<string>-ngl</string>
<string>99</string>
<string>-c</string>
<string>16384</string>
<string>--temp</string>
<string>0.1</string>
<string>--mlock</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/accusys/momentry/log/llamacpp-e4b.log</string>
<key>StandardErrorPath</key>
<string>/Users/accusys/momentry/log/llamacpp-e4b.error.log</string>
</dict>
</plist>

View File

@@ -15,13 +15,13 @@
</dict>
<key>WorkingDirectory</key>
<string>/Users/accusys/momentry/var/postgresql</string>
<string>/Users/accusys</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/postgresql@18/bin/postgres</string>
<string>/Users/accusys/pgsql/18.3/bin/postgres</string>
<string>-D</string>
<string>/Users/accusys/momentry/var/postgresql</string>
<string>/Users/accusys/pgsql/data</string>
</array>
<key>RunAtLoad</key>

View File

@@ -9,14 +9,14 @@
<string>accusys</string>
<key>WorkingDirectory</key>
<string>/Users/accusys/workspace/sftpgo</string>
<string>/Users/accusys/momentry/var/sftpgo</string>
<key>ProgramArguments</key>
<array>
<string>/Users/accusys/bin/sftpgo</string>
<string>serve</string>
<string>--config-file</string>
<string>/Users/accusys/momentry/etc/sftpgo/sftpgo.json</string>
<string>-c</string>
<string>/Users/accusys/momentry/etc/sftpgo</string>
</array>
<key>EnvironmentVariables</key>

View File

@@ -0,0 +1,174 @@
<template>
<div ref="container" class="w-full h-full min-h-[300px] bg-gray-900 rounded-lg overflow-hidden"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
const props = defineProps<{
landmarks: number[][] // 468 x [x, y, z]
}>()
const container = ref<HTMLElement>()
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let controls: OrbitControls | null = null
let animId: number
let objects: THREE.Object3D[] = []
function disposeScene() {
cancelAnimationFrame(animId)
for (const obj of objects) {
scene?.remove(obj)
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose()
if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose())
else obj.material?.dispose()
}
if (obj instanceof THREE.Points) {
obj.geometry?.dispose()
if (obj.material) obj.material.dispose()
}
}
objects = []
controls?.dispose()
controls = null
if (renderer) {
renderer.dispose()
renderer = null
}
scene = null
camera = null
}
const FACES_TRI = [
// Eyes
[33, 133, 7], [33, 7, 163], [160, 159, 158], [159, 158, 157],
// Nose
[168, 6, 197], [6, 197, 195], [197, 195, 5],
// Mouth outer
[61, 146, 91], [91, 181, 84], [84, 17, 314], [314, 405, 321],
// Mouth inner
[78, 95, 88], [95, 88, 178], [87, 14, 317], [14, 317, 402],
// Jaw
[10, 338, 297], [297, 332, 284], [284, 251, 389],
// Left eye brow
[46, 53, 52], [53, 52, 65],
// Right eye brow
[276, 283, 282], [283, 282, 295],
// Face oval
[10, 338, 297], [297, 332, 284], [284, 251, 389], [389, 356, 454],
[454, 323, 361], [361, 288, 397], [397, 365, 379], [379, 378, 400],
[400, 377, 152], [152, 148, 176], [176, 149, 150], [150, 136, 172],
[172, 58, 132], [132, 93, 234], [234, 127, 162], [162, 21, 54],
[54, 103, 67], [67, 109, 10]
]
function buildMesh(pts: number[][]): THREE.BufferGeometry {
const verts = new Float32Array(pts.length * 3)
for (let i = 0; i < pts.length; i++) {
verts[i * 3] = (pts[i][0] - 0.5) * 2
verts[i * 3 + 1] = -(pts[i][1] - 0.5) * 2
verts[i * 3 + 2] = pts[i][2] * 2
}
const indices: number[] = []
for (const tri of FACES_TRI) {
if (tri.every(i => i < pts.length)) indices.push(...tri)
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3))
geo.setIndex(indices)
geo.computeVertexNormals()
return geo
}
function init() {
if (!container.value) return
// Dispose previous scene if re-initializing
disposeScene()
const rect = container.value.getBoundingClientRect()
const w = rect.width || 400, h = rect.height || 300
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 10)
camera.position.set(0, 0, 2.5)
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setSize(w, h)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
container.value.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.autoRotate = true
controls.autoRotateSpeed = 2
// Mesh
if (props.landmarks?.length) {
const geo = buildMesh(props.landmarks)
const mat = new THREE.MeshPhongMaterial({
color: 0x4488ff,
flatShading: false,
transparent: true,
opacity: 0.85,
side: THREE.DoubleSide
})
const mesh = new THREE.Mesh(geo, mat)
scene.add(mesh)
objects.push(mesh)
// Points
const ptGeo = new THREE.BufferGeometry()
ptGeo.setAttribute('position', geo.getAttribute('position')!)
const ptMat = new THREE.PointsMaterial({ color: 0x88bbff, size: 0.008 })
const points = new THREE.Points(ptGeo, ptMat)
scene.add(points)
objects.push(points)
}
// Lights
const ambient = new THREE.AmbientLight(0x404060)
scene.add(ambient)
const dir = new THREE.DirectionalLight(0xffffff, 1)
dir.position.set(1, 1, 1)
scene.add(dir)
const dir2 = new THREE.DirectionalLight(0x8888ff, 0.5)
dir2.position.set(-1, -1, 0.5)
scene.add(dir2)
// Resize observer
const resizeObserver = new ResizeObserver(() => {
if (!container.value || !renderer || !camera) return
const r = container.value.getBoundingClientRect()
const w = r.width || 400, h = r.height || 300
renderer.setSize(w, h)
camera.aspect = w / h
camera.updateProjectionMatrix()
})
resizeObserver.observe(container.value)
;(container.value as any).__resizeObserver = resizeObserver
animate()
}
function animate() {
animId = requestAnimationFrame(animate)
controls?.update()
if (renderer && scene && camera) renderer.render(scene, camera)
}
onMounted(() => init())
onBeforeUnmount(() => {
cancelAnimationFrame(animId)
if ((container.value as any)?.__resizeObserver) {
(container.value as any).__resizeObserver.disconnect()
}
disposeScene()
})
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div class="space-y-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-300">身分泳道圖 V2</h3>
<span class="text-xs text-gray-500">{{ identities.length }} identities</span>
</div>
<div class="relative overflow-x-auto" ref="scrollRef">
<svg :width="svgW" :height="rowH * identities.length + 30" class="block">
<!-- time axis -->
<line x1="80" :y1="rowH * identities.length + 5" :x2="svgW" :y2="rowH * identities.length + 5" stroke="#4b5563" stroke-width="1" />
<g v-for="t in ticks" :key="t">
<line :x1="xPos(t)" :y1="rowH * identities.length + 1" :x2="xPos(t)" :y2="rowH * identities.length + 5" stroke="#6b7280" stroke-width="1" />
<text :x="xPos(t)" :y="rowH * identities.length + 16" fill="#9ca3af" font-size="9" text-anchor="middle">{{ t }}s</text>
</g>
<!-- swimlanes -->
<g v-for="(ident, i) in identities" :key="ident.name">
<text x="4" :y="rowH * i + rowH / 2 + 5" fill="#d1d5db" font-size="11" class="select-none">{{ ident.name }}</text>
<rect x="78" :y="rowH * i + 4" width="2" :height="rowH - 8" fill="#374151" rx="2" />
<rect
v-for="seg in ident.segments" :key="seg.start"
:x="xPos(seg.start)" :y="rowH * i + 6"
:width="Math.max(2, xPos(seg.end) - xPos(seg.start))"
:height="rowH - 12"
:fill="ident.color"
:opacity="0.7"
rx="3"
class="cursor-pointer hover:opacity-100"
@click="$emit('selectTrace', seg.trace_id)"
/>
</g>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
identities: SwimlaneIdentity[]
totalDuration: number
}>()
defineEmits<{ selectTrace: [traceId: number] }>()
export interface SwimlaneSegment {
trace_id: number
start: number
end: number
face_count: number
}
export interface SwimlaneIdentity {
name: string
color: string
segments: SwimlaneSegment[]
}
const rowH = 28
const labelW = 80
const padR = 20
const svgW = computed(() => {
const dur = props.totalDuration || 6000
return Math.max(500, labelW + dur / 8)
})
function xPos(sec: number): number {
const dur = props.totalDuration || 6000
return labelW + (sec / dur) * (svgW.value - labelW - padR)
}
const ticks = computed(() => {
const dur = props.totalDuration || 6000
const step = Math.max(30, Math.round(dur / 6 / 30) * 30)
const tks: number[] = []
for (let t = 0; t <= dur; t += step) tks.push(t)
return tks
})
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div :class="['rounded-lg p-3 border', bgColor]">
<div class="flex items-center justify-between">
<span class="font-semibold">{{ name }}</span>
<span :class="statusColor">{{ status === 'ok' ? '●' : '○' }}</span>
</div>
<div v-if="latency" class="text-xs text-gray-400 mt-1">{{ latency }}ms</div>
<div v-if="error" class="text-xs text-red-400 mt-1 truncate">{{ error }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
name?: string
status?: string
latency?: number | null
error?: string | null
}>()
const statusColor = computed(() => {
if (props.status === 'ok') return 'text-green-400'
if (props.status === 'degraded') return 'text-yellow-400'
return 'text-red-400'
})
const bgColor = computed(() => {
if (props.status === 'ok') return 'bg-green-900/20 border-green-700'
if (props.status === 'degraded') return 'bg-yellow-900/20 border-yellow-700'
return 'bg-red-900/20 border-red-700'
})
</script>

View File

@@ -0,0 +1,354 @@
<template>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-lg font-semibold mb-4 text-blue-400">V5: 3D Space-Time Cube</h3>
<div class="text-xs text-gray-500 mb-3 flex gap-2 items-center">
<span>X/Y = 畫面位置</span>
<span>Z = 深度bbox 大小</span>
<span>T = 時間</span>
</div>
<!-- Trace selector -->
<div class="flex gap-2 mb-3">
<select v-model="selectedTraceId"
class="bg-gray-700 text-white px-3 py-1.5 rounded text-sm flex-1">
<option :value="null" disabled>選擇 Trace</option>
<option v-for="t in traceOptions" :key="t.id"
:value="t.id">{{ t.label }}</option>
</select>
<button @click="loadData"
class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-1.5 rounded text-sm"
:disabled="!selectedTraceId || loading">
{{ loading ? '載入中...' : '載入' }}
</button>
</div>
<div ref="container" class="w-full h-[400px] bg-gray-900 rounded-lg overflow-hidden"></div>
<div class="text-xs text-gray-500 mt-2 flex gap-4">
<span>🖱 拖曳旋轉</span>
<span>🔍 滾輪縮放</span>
<span v-if="faceCount">{{ faceCount }} 個檢測點</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { httpFetch, getCurrentConfig } from '@/api/client'
const props = defineProps<{
fileUuid: string
traces?: any[]
frameWidth?: number
frameHeight?: number
}>()
const container = ref<HTMLElement>()
const selectedTraceId = ref<number | null>(null)
const loading = ref(false)
const faceCount = ref(0)
const traceOptions = computed(() => {
return (props.traces || []).map((t: any) => ({
id: t.trace_id,
label: `#${t.trace_id} (${t.face_count} faces, ${(t.duration_sec || 0).toFixed(1)}s)`
}))
})
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let controls: OrbitControls | null = null
let animId: number
let objects: THREE.Object3D[] = []
function disposeScene() {
cancelAnimationFrame(animId)
for (const obj of objects) {
scene?.remove(obj)
if (obj instanceof THREE.Mesh || obj instanceof THREE.Points || obj instanceof THREE.Line) {
obj.geometry?.dispose()
const mat = (obj as any).material
if (mat) {
if (Array.isArray(mat)) mat.forEach((m: any) => m.dispose())
else mat.dispose()
}
}
}
objects = []
controls?.dispose()
controls = null
if (renderer) {
renderer.dispose()
renderer = null
}
scene = null
camera = null
}
type FacePoint = {
frame: number
t: number
x: number
y: number
w: number
h: number
z: number
}
function loadData() {
if (!selectedTraceId.value) return
loading.value = true
const config = getCurrentConfig()
httpFetch(`${config.api_base_url}/api/v1/file/${props.fileUuid}/trace/${selectedTraceId.value}/faces?interpolate=true&limit=2000&dimension=3d`)
.then((res: any) => {
const faces = res?.faces || []
const fw = props.frameWidth || 1920
const fh = props.frameHeight || 1080
const points: FacePoint[] = faces.map((f: any) => {
const w = f.width || 1
const h = f.height || 1
const areaPct = (w * h) / (fw * fh)
const z = f.z_rel !== undefined && f.z_rel !== null
? f.z_rel
: 1.0 - Math.min(areaPct * 50, 1.0)
return {
frame: f.start_frame || 0,
t: f.start_time || 0,
x: f.x || 0,
y: f.y || 0,
w,
h,
z
}
})
faceCount.value = points.length
buildScene(points)
})
.catch((err: any) => {
console.error('Failed to load trace faces:', err)
})
.finally(() => {
loading.value = false
})
}
function buildScene(points: FacePoint[]) {
if (!container.value) return
disposeScene()
// Normalize coordinates to [-1, 1] range
const fw = props.frameWidth || 1920
const fh = props.frameHeight || 1080
const maxT = points.length > 0 ? points[points.length - 1].t : 100
const vertexData = points.map(p => ({
x: (p.x / fw) * 2 - 1,
y: -((p.y / fh) * 2 - 1),
z: p.z * 2 - 1,
t: (p.t / maxT) * 2 - 1
}))
const rect = container.value.getBoundingClientRect()
const w = rect.width || 600, h = rect.height || 400
scene = new THREE.Scene()
scene.background = new THREE.Color(0x111827)
camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 10)
camera.position.set(2.5, 1.8, 3)
camera.lookAt(0, 0, 0)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(w, h)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
container.value.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.08
controls.target.set(0, 0, 0)
controls.update()
// ---- Axes helper with labels ----
const axesLen = 1.2
const axesMat = (color: number) => new THREE.LineBasicMaterial({ color })
// X axis (red) — screen x
const xLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(axesLen, -axesLen, -axesLen)
]),
axesMat(0xff4444)
)
scene.add(xLine)
objects.push(xLine)
// Y axis (green) — screen y
const yLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(-axesLen, axesLen, -axesLen)
]),
axesMat(0x44ff44)
)
scene.add(yLine)
objects.push(yLine)
// Z axis (blue) — depth
const zLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(-axesLen, -axesLen, axesLen)
]),
axesMat(0x4488ff)
)
scene.add(zLine)
objects.push(zLine)
// T axis (yellow) — time (at an angle for 3D effect)
const tLine = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
new THREE.Vector3(axesLen, axesLen, axesLen)
]),
axesMat(0xffdd44)
)
scene.add(tLine)
objects.push(tLine)
// ---- Cube wireframe ----
const cubeSize = axesLen * 2
const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize)
const cubeWire = new THREE.LineSegments(
new THREE.EdgesGeometry(cubeGeo),
new THREE.LineBasicMaterial({ color: 0x444466, transparent: true, opacity: 0.3 })
)
cubeWire.position.set(0, 0, 0)
scene.add(cubeWire)
objects.push(cubeWire)
// ---- Points: color by time (t) ----
if (vertexData.length > 0) {
const positions = new Float32Array(vertexData.length * 3)
const colors = new Float32Array(vertexData.length * 3)
const color = new THREE.Color()
for (let i = 0; i < vertexData.length; i++) {
const p = vertexData[i]
// Position: (x, y, z) with time as movement along diagonal
positions[i * 3] = p.x
positions[i * 3 + 1] = p.y
positions[i * 3 + 2] = p.t * 0.5 // compress time a bit
// Color gradient: blue (early) → cyan → yellow → red (late)
const tNorm = (p.t + 1) / 2 // 0..1
color.setHSL(0.6 - tNorm * 0.6, 0.9, 0.5)
colors[i * 3] = color.r
colors[i * 3 + 1] = color.g
colors[i * 3 + 2] = color.b
}
const ptGeo = new THREE.BufferGeometry()
ptGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
ptGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3))
const ptMat = new THREE.PointsMaterial({
size: 0.03,
vertexColors: true,
transparent: true,
opacity: 0.8,
sizeAttenuation: true
})
const pointsObj = new THREE.Points(ptGeo, ptMat)
scene.add(pointsObj)
objects.push(pointsObj)
// ---- Trajectory line ----
const linePositions = new Float32Array(vertexData.length * 3)
for (let i = 0; i < vertexData.length; i++) {
const p = vertexData[i]
linePositions[i * 3] = p.x
linePositions[i * 3 + 1] = p.y
linePositions[i * 3 + 2] = p.t * 0.5
}
const lineGeo = new THREE.BufferGeometry()
lineGeo.setAttribute('position', new THREE.BufferAttribute(linePositions, 3))
const lineMat = new THREE.LineBasicMaterial({
color: 0x88ccff,
transparent: true,
opacity: 0.35
})
const line = new THREE.Line(lineGeo, lineMat)
scene.add(line)
objects.push(line)
}
// ---- Lights ----
const ambient = new THREE.AmbientLight(0x404060)
scene.add(ambient)
const dir = new THREE.DirectionalLight(0xffffff, 0.8)
dir.position.set(1, 2, 1)
scene.add(dir)
// ---- Grid helper (subtle) ----
const gridHelper = new THREE.GridHelper(2.5, 10, 0x444466, 0x333355)
gridHelper.position.y = -axesLen - 0.05
scene.add(gridHelper)
objects.push(gridHelper)
// Resize
const resizeObserver = new ResizeObserver(() => {
if (!container.value || !renderer || !camera) return
const r = container.value.getBoundingClientRect()
const rw = r.width || 600, rh = r.height || 400
renderer.setSize(rw, rh)
camera.aspect = rw / rh
camera.updateProjectionMatrix()
})
resizeObserver.observe(container.value)
;(container.value as any).__resizeObserver = resizeObserver
animate()
// Notify demo runner via callback URL if present
const cb = new URLSearchParams(window.location.search).get("_callback")
if (cb) {
fetch(cb, { mode: "no-cors" }).catch(() => {})
}
}
function animate() {
animId = requestAnimationFrame(animate)
controls?.update()
if (renderer && scene && camera) renderer.render(scene, camera)
}
onMounted(() => {
tryAutoLoad()
})
watch(() => props.traces, () => {
tryAutoLoad()
}, { deep: false })
function tryAutoLoad() {
if (props.traces?.length && !selectedTraceId.value && !loading.value) {
selectedTraceId.value = props.traces[0].trace_id
loadData()
}
}
onBeforeUnmount(() => {
cancelAnimationFrame(animId)
if ((container.value as any)?.__resizeObserver) {
(container.value as any).__resizeObserver.disconnect()
}
disposeScene()
})
</script>

Some files were not shown because too many files have changed in this diff Show More