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

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*