feat: ASRX hybrid pipeline, identity history, worker fixes, checkpoint system

This commit is contained in:
Accusys
2026-06-02 07:13:23 +08:00
parent e3066c3f49
commit e1572907ae
198 changed files with 43705 additions and 8910 deletions

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,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,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*