feat: progressive multi-round face matching + pending person API
- Identity agent: per-face max matching, multi-round with derived seeds from high-confidence faces, angle diversity filter (cosine sim < 0.90) - Pending person API: POST /file/:file_uuid/pending-person + GET /file/:file_uuid/pending-persons with status=pending, source=manual - Update API docs (07_identity.md)
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -636,6 +636,8 @@ dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
"zstd",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -55,7 +55,7 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "js
|
||||
mongodb = { version = "2", features = ["tokio-runtime"] }
|
||||
bson = { version = "2", features = ["chrono-0_4"] }
|
||||
qdrant-client = "1.7"
|
||||
reqwest = { version = "0.12", features = ["json", "gzip"] }
|
||||
reqwest = { version = "0.12", features = ["json", "gzip", "zstd"] }
|
||||
pgvector = { version = "0.3", features = ["sqlx"] }
|
||||
|
||||
# HTTP Server
|
||||
|
||||
@@ -923,6 +923,128 @@ curl -s "$API/api/v1/identity/$IDENTITY_UUID/json" \
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/file/:file_uuid/pending-person`
|
||||
|
||||
**Auth**: Required
|
||||
**Scope**: file-level
|
||||
|
||||
Create a manually managed "pending person" under a specific file. A pending person is an identity with `status='pending'` and `source='manual'`, used for unmatched traces that the user wants to manually label before a full identity resolution.
|
||||
|
||||
Optionally binds a list of trace IDs to this new identity.
|
||||
|
||||
#### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"trace_ids": [100, 150, 200],
|
||||
"name": "Mystery Man #1"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `trace_ids` | array[int] | No | `[]` | Trace IDs to bind to this pending person |
|
||||
| `name` | string | No | `"Person N"` | Human-readable name. Auto-generated if omitted |
|
||||
|
||||
#### Example
|
||||
|
||||
```bash
|
||||
# Create pending person with name and no traces
|
||||
curl -s -X POST "$API/api/v1/file/$FILE_UUID/pending-person" \
|
||||
-H "X-API-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Unknown Woman #2", "trace_ids": []}'
|
||||
|
||||
# Create pending person with auto-name and bind traces
|
||||
curl -s -X POST "$API/api/v1/file/$FILE_UUID/pending-person" \
|
||||
-H "X-API-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"trace_ids": [100, 150, 200]}'
|
||||
```
|
||||
|
||||
#### Response (200)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Created pending person: Mystery Man #1 (uuid: 4d96b25b-68f0-4c52-b238-d69f7dfd588b)",
|
||||
"data": {
|
||||
"identity_uuid": "4d96b25b-68f0-4c52-b238-d69f7dfd588b",
|
||||
"identity_id": 55,
|
||||
"name": "Mystery Man #1",
|
||||
"bound_traces": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `identity_uuid` | string | UUID of the newly created pending identity |
|
||||
| `identity_id` | integer | Internal ID of the new identity |
|
||||
| `name` | string | Display name |
|
||||
| `bound_traces` | integer | Number of traces bound |
|
||||
|
||||
#### Side Effects
|
||||
|
||||
- Creates an `identities` row with `status='pending'`, `source='manual'`, `file_uuid=<file_uuid>`
|
||||
- If `trace_ids` provided: `UPDATE face_detections SET identity_id = ...` for matching traces
|
||||
- If `trace_ids` provided: TKG face_track nodes get `identity_id` / `identity_name` in properties
|
||||
- Identity JSON file synced to disk
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/v1/file/:file_uuid/pending-persons`
|
||||
|
||||
**Auth**: Required
|
||||
**Scope**: file-level
|
||||
|
||||
List all pending persons for a file.
|
||||
|
||||
#### Example
|
||||
|
||||
```bash
|
||||
curl -s "$API/api/v1/file/$FILE_UUID/pending-persons" \
|
||||
-H "X-API-Key: $KEY"
|
||||
```
|
||||
|
||||
#### Response (200)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Found 2 pending persons for c36f35685177c981aa139b66bbbccc5b",
|
||||
"data": [
|
||||
{
|
||||
"identity_uuid": "232ecd08-a2bf-4bd0-bd25-0bd8fb7a7dae",
|
||||
"identity_id": 56,
|
||||
"name": "Person 2",
|
||||
"created_at": "2026-06-23 17:13:23",
|
||||
"trace_count": 3,
|
||||
"bound_traces": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `identity_uuid` | string | Identity UUID |
|
||||
| `identity_id` | integer | Internal identity ID |
|
||||
| `name` | string | Display name |
|
||||
| `created_at` | string | Creation timestamp |
|
||||
| `trace_count` | integer | Number of face traces bound to this pending person |
|
||||
| `bound_traces` | array[int] | List of bound trace IDs (currently null, reserved for future expansion) |
|
||||
|
||||
#### Notes
|
||||
|
||||
- Pending persons are normal `identities` rows with `status='pending'` — they can be promoted to confirmed via `PATCH /api/v1/identity/:identity_uuid` (`{"status": "confirmed"}`)
|
||||
- They can be merged into known identities via `POST /api/v1/identity/:identity_uuid/mergeinto`
|
||||
- Use `GET /api/v1/identity/:identity_uuid/traces` to get detailed trace info for each pending person
|
||||
|
||||
---
|
||||
|
||||
## Alias System (BCP 47 Locale Tags)
|
||||
|
||||
Identity aliases support multilingual display names. Aliases are stored in `metadata.aliases` as an array of `{locale, name}` objects.
|
||||
|
||||
@@ -32,7 +32,7 @@ a { color: #0066cc; }
|
||||
<a class="logout-btn" href="#" onclick="fetch('/api/v1/auth/logout',{method:'POST'}).then(()=>window.location.reload());return false">Logout</a>
|
||||
</div>
|
||||
<!-- module: lookup -->
|
||||
<!-- description: File lookup by name and unregistration -->
|
||||
<!-- description: File listing, lookup by name, file detail, faces, identities, JSON download, unregistration -->
|
||||
<!-- depends: 01_auth, 03_register -->
|
||||
|
||||
<h2>File Lookup</h2>
|
||||
@@ -137,6 +137,537 @@ curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</s
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<hr />
|
||||
<h2>File Listing</h2>
|
||||
<h3><code>GET /api/v1/files</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: system-level</p>
|
||||
<p>List all registered files with pagination. Optionally filter by status or fetch a specific file by UUID.</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</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>page_size</code></td>
|
||||
<td>integer</td>
|
||||
<td>No</td>
|
||||
<td>20</td>
|
||||
<td>Items per page</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>status</code></td>
|
||||
<td>string</td>
|
||||
<td>No</td>
|
||||
<td>—</td>
|
||||
<td>Filter by status: <code>registered</code>, <code>processing</code>, <code>completed</code>, <code>failed</code>, <code>indexed</code>, <code>checked_out</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>No</td>
|
||||
<td>—</td>
|
||||
<td>Fetch a specific file (returns as single-item list)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code><span class="c1"># List all files (paginated)</span>
|
||||
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/files?page=1&page_size=10"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span>
|
||||
|
||||
<span class="c1"># Filter by status</span>
|
||||
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/files?status=completed"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span>
|
||||
|
||||
<span class="c1"># Fetch specific file</span>
|
||||
curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/files?file_uuid=</span><span class="nv">$FILE_UUID</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"total"</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">"page"</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">"page_size"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"data"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"file_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"video.mp4"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"file_path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/path/to/video.mp4"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"completed"</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>success</code></td>
|
||||
<td>boolean</td>
|
||||
<td>Always true on 200</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>total</code></td>
|
||||
<td>integer</td>
|
||||
<td>Total file count</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>page</code></td>
|
||||
<td>integer</td>
|
||||
<td>Current page</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>page_size</code></td>
|
||||
<td>integer</td>
|
||||
<td>Items per page</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data</code></td>
|
||||
<td>array</td>
|
||||
<td>Array of file items</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>32-char hex UUID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].file_name</code></td>
|
||||
<td>string</td>
|
||||
<td>Registered file name</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].file_path</code></td>
|
||||
<td>string</td>
|
||||
<td>Full filesystem path</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].status</code></td>
|
||||
<td>string</td>
|
||||
<td>Processing status</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/file/:file_uuid</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Get detailed info for a specific registered file including metadata, duration, FPS, and probe data.</p>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"file_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"video.mp4"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"file_path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/path/to/video.mp4"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"completed"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"duration"</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">"fps"</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">"metadata"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"format"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">"duration"</span><span class="p">:</span><span class="w"> </span><span class="s2">"120.5"</span><span class="p">,</span><span class="w"> </span><span class="nt">"size"</span><span class="p">:</span><span class="w"> </span><span class="s2">"794863677"</span><span class="p">},</span>
|
||||
<span class="w"> </span><span class="nt">"streams"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="nt">"codec_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"h264"</span><span class="p">,</span><span class="w"> </span><span class="nt">"width"</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">"height"</span><span class="p">:</span><span class="w"> </span><span class="mi">1080</span><span class="p">}]</span>
|
||||
<span class="w"> </span><span class="p">},</span>
|
||||
<span class="w"> </span><span class="nt">"created_at"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-05-16T12:00:00Z"</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</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>file_name</code></td>
|
||||
<td>string</td>
|
||||
<td>Registered file name</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>file_path</code></td>
|
||||
<td>string</td>
|
||||
<td>Full filesystem path</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>status</code></td>
|
||||
<td>string</td>
|
||||
<td>Processing status</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>duration</code></td>
|
||||
<td>float</td>
|
||||
<td>Duration in seconds</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>fps</code></td>
|
||||
<td>float</td>
|
||||
<td>Frames per second</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>metadata</code></td>
|
||||
<td>object</td>
|
||||
<td>Full ffprobe metadata (probe.json)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>created_at</code></td>
|
||||
<td>string</td>
|
||||
<td>Registration timestamp (ISO 8601)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Error Codes</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>404</code></td>
|
||||
<td>File UUID not found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/file/:file_uuid/identities</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Get all identities present in a specific file with pagination.</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</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>page_size</code></td>
|
||||
<td>integer</td>
|
||||
<td>No</td>
|
||||
<td>20</td>
|
||||
<td>Items per page</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/identities?page=1&page_size=50"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"fps"</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">"total"</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">"page"</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">"page_size"</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">"data"</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">"identity_id"</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">"identity_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a9a90105-6d6b-46ff-92da-0c3c1a57dff4"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Audrey Hepburn"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"metadata"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">"source"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tmdb"</span><span class="p">,</span><span class="w"> </span><span class="nt">"tmdb_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1234</span><span class="p">},</span>
|
||||
<span class="w"> </span><span class="nt">"face_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">142</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"speaker_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">8</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"start_frame"</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"end_frame"</span><span class="p">:</span><span class="w"> </span><span class="mi">5000</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"start_time"</span><span class="p">:</span><span class="w"> </span><span class="mf">4.17</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"end_time"</span><span class="p">:</span><span class="w"> </span><span class="mf">208.33</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"confidence"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.87</span>
|
||||
<span class="w"> </span><span class="p">}</span>
|
||||
<span class="w"> </span><span class="p">]</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>data[].identity_id</code></td>
|
||||
<td>integer</td>
|
||||
<td>Database identity ID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].identity_uuid</code></td>
|
||||
<td>string/null</td>
|
||||
<td>Global identity UUID (null if unbound)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].name</code></td>
|
||||
<td>string</td>
|
||||
<td>Identity name</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].metadata</code></td>
|
||||
<td>object</td>
|
||||
<td>Source metadata (TMDb, etc.)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].face_count</code></td>
|
||||
<td>integer/null</td>
|
||||
<td>Number of face detections</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].speaker_count</code></td>
|
||||
<td>integer/null</td>
|
||||
<td>Number of speaker segments</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].start_frame</code></td>
|
||||
<td>integer/null</td>
|
||||
<td>First appearance frame</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].end_frame</code></td>
|
||||
<td>integer/null</td>
|
||||
<td>Last appearance frame</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].start_time</code></td>
|
||||
<td>float/null</td>
|
||||
<td>First appearance time (seconds)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].end_time</code></td>
|
||||
<td>float/null</td>
|
||||
<td>Last appearance time (seconds)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].confidence</code></td>
|
||||
<td>float/null</td>
|
||||
<td>Average detection confidence</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/file/:file_uuid/faces</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>List all face detections in a specific file with pagination.</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</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</h4>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/faces?page=1&page_size=100"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"total"</span><span class="p">:</span><span class="w"> </span><span class="mi">1420</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"page"</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">"page_size"</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"data"</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">"face_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"face_100"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"frame_number"</span><span class="p">:</span><span class="w"> </span><span class="mi">1200</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"timestamp"</span><span class="p">:</span><span class="w"> </span><span class="mf">50.0</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"bbox"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mi">100</span><span class="p">,</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span><span class="w"> </span><span class="mi">300</span><span class="p">,</span><span class="w"> </span><span class="mi">400</span><span class="p">],</span>
|
||||
<span class="w"> </span><span class="nt">"confidence"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.95</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"identity_id"</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">"identity_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a9a90105-6d6b-46ff-92da-0c3c1a57dff4"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"trace_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</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>data[].face_id</code></td>
|
||||
<td>string</td>
|
||||
<td>Face detection ID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].frame_number</code></td>
|
||||
<td>integer</td>
|
||||
<td>Frame number in video</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].timestamp</code></td>
|
||||
<td>float</td>
|
||||
<td>Timestamp in seconds</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].bbox</code></td>
|
||||
<td>array</td>
|
||||
<td>Bounding box <code>[x1, y1, x2, y2]</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].confidence</code></td>
|
||||
<td>float</td>
|
||||
<td>Detection confidence</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].identity_id</code></td>
|
||||
<td>integer/null</td>
|
||||
<td>Bound identity ID (null if unbound)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].identity_uuid</code></td>
|
||||
<td>string/null</td>
|
||||
<td>Bound identity UUID (null if unbound)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>data[].trace_id</code></td>
|
||||
<td>integer/null</td>
|
||||
<td>Face trace ID (null if not traced)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/file/:file_uuid/json/:processor</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Download raw JSON output for a specific processor.</p>
|
||||
<h4>Path 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</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>processor</code></td>
|
||||
<td>string</td>
|
||||
<td>Yes</td>
|
||||
<td>Processor name: <code>cut</code>, <code>asrx</code>, <code>yolo</code>, <code>ocr</code>, <code>face</code>, <code>pose</code>, <code>story</code>, etc.</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">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/json/face"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">'.frames | length'</span>
|
||||
</code></pre></div>
|
||||
|
||||
<h4>Response (200)</h4>
|
||||
<p>Returns the raw JSON output of the specified processor. Structure varies by processor type.</p>
|
||||
<h4>Error Codes</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>404</code></td>
|
||||
<td>JSON file not found</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>500</code></td>
|
||||
<td>Failed to parse JSON</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h2>Unregister</h2>
|
||||
<h3><code>POST /api/v1/unregister</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
@@ -293,7 +824,7 @@ curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<p><em>Updated: 2026-05-19 12:49:24</em></p>
|
||||
<p><em>Updated: 2026-06-20 — Added file listing, file detail, file identities, file faces, and JSON download endpoints</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -260,10 +260,11 @@ curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/progress/:file_uuid</code></h3>
|
||||
<h3><code>POST /api/v1/progress/:file_uuid</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Get real-time processing progress for a file via Redis pub/sub. Includes per-processor status, current/total frames, ETA, and system resource stats.</p>
|
||||
<p><strong>Note</strong>: This endpoint uses <strong>POST</strong> method, not GET. The progress data is stored in Redis as a hash, and POST is used to retrieve the latest state.</p>
|
||||
<h4>Pipeline Order</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
@@ -339,7 +340,7 @@ curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>
|
||||
</table>
|
||||
<p>All processors except <code>story</code> and <code>5w1h</code> run concurrently when their dependencies are met. Story and 5W1H run sequentially after their prerequisites.</p>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/progress/</span><span class="nv">$FILE_UUID</span><span class="s2">"</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">'{overall_progress, processors: [.processors[] | {processor_type, status}]}'</span>
|
||||
<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">"</span><span class="nv">$API</span><span class="s2">/api/v1/progress/</span><span class="nv">$FILE_UUID</span><span class="s2">"</span><span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>jq<span class="w"> </span><span class="s1">'{overall_progress, processors: [.processors[] | {name, status}]}'</span>
|
||||
</code></pre></div>
|
||||
|
||||
<h4>Response (200)</h4>
|
||||
@@ -506,8 +507,152 @@ curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3><code>GET /api/v1/file/:file_uuid/processor-counts</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Get counts of processor JSON output files. See <code>15_tkg.md</code> for full documentation.</p>
|
||||
<hr />
|
||||
<p><em>Updated: 2026-05-19 12:49:24</em></p>
|
||||
<h2>Pipeline Steps (Manual)</h2>
|
||||
<p>These endpoints execute individual pipeline steps. They are typically called by the worker automatically, but can be invoked manually for debugging or re-processing.</p>
|
||||
<h3><code>POST /api/v1/file/:file_uuid/store-asrx</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Store ASRX diarization results as chunk records in the database. Converts ASRX segments into searchable chunk entries.</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">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/store-asrx"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ASRX chunks stored"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"3a6c1865..."</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/file/:file_uuid/rule1</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Execute Rule 1 pipeline step. Applies rule-based chunking to create structured chunk records from processor outputs.</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">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/rule1"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Rule 1 complete: 45 chunks"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"3a6c1865..."</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"chunks"</span><span class="p">:</span><span class="w"> </span><span class="mi">45</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>message</code></td>
|
||||
<td>string</td>
|
||||
<td>Human-readable completion message</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>32-char hex UUID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>chunks</code></td>
|
||||
<td>integer</td>
|
||||
<td>Number of chunks produced</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/file/:file_uuid/vectorize</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Generate vector embeddings for all chunks of a file and store them in Qdrant for semantic search.</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">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/vectorize"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Vectorization complete"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"3a6c1865..."</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/file/:file_uuid/phase1</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Execute Phase 1 of the post-processing pipeline. Combines store-asrx, rule1, and vectorize into a single step.</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">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/phase1"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Phase 1 complete"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"3a6c1865..."</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/file/:file_uuid/complete</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Mark a video as fully processed. Updates the video status to <code>completed</code> and finalizes all pipeline state.</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">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/complete"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Video marked as completed"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"3a6c1865..."</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<hr />
|
||||
<h3>Pipeline Step Order</h3>
|
||||
<div class="codehilite"><pre><span></span><code> process (trigger)
|
||||
│
|
||||
├─→ cut, yolo, ocr, face, pose, asrx (parallel processors)
|
||||
│
|
||||
├─→ store-asrx (store diarization as chunks)
|
||||
│
|
||||
├─→ rule1 (rule-based chunking)
|
||||
│
|
||||
├─→ vectorize (embed chunks to Qdrant)
|
||||
│
|
||||
└─→ complete (mark done)
|
||||
</code></pre></div>
|
||||
|
||||
<p>Phase 1 (<code>/phase1</code>) combines store-asrx + rule1 + vectorize into one call.</p>
|
||||
<hr />
|
||||
<p><em>Updated: 2026-06-20 12:00:00</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -32,7 +32,7 @@ a { color: #0066cc; }
|
||||
<a class="logout-btn" href="#" onclick="fetch('/api/v1/auth/logout',{method:'POST'}).then(()=>window.location.reload());return false">Logout</a>
|
||||
</div>
|
||||
<!-- module: search -->
|
||||
<!-- description: Vector search, BM25, smart search, universal search, visual search -->
|
||||
<!-- description: Vector search, BM25, smart search, universal search, LLM reranked search, frame search -->
|
||||
<!-- depends: 01_auth -->
|
||||
|
||||
<h2>Search APIs</h2>
|
||||
@@ -282,9 +282,251 @@ a { color: #0066cc; }
|
||||
<h3><code>POST /api/v1/search/frames</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: global / file-level</p>
|
||||
<p>Search face detection frames by identity name or trace ID.</p>
|
||||
<p>Search frames by YOLO objects, OCR text, face IDs, or pose detections. Filters frames based on visual content detected during processing.</p>
|
||||
<h4>Request Parameters</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Required</th>
|
||||
<th>Default</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>No</td>
|
||||
<td>—</td>
|
||||
<td>Restrict to specific file</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>object_class</code></td>
|
||||
<td>string</td>
|
||||
<td>No</td>
|
||||
<td>—</td>
|
||||
<td>Filter by YOLO object class (e.g., <code>person</code>, <code>car</code>, <code>dog</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ocr_text</code></td>
|
||||
<td>string</td>
|
||||
<td>No</td>
|
||||
<td>—</td>
|
||||
<td>Filter by OCR text content (ILIKE match)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>face_id</code></td>
|
||||
<td>string</td>
|
||||
<td>No</td>
|
||||
<td>—</td>
|
||||
<td>Filter by face detection ID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>time_range</code></td>
|
||||
<td>[float, float]</td>
|
||||
<td>No</td>
|
||||
<td>—</td>
|
||||
<td>Filter by time range <code>[start_secs, end_secs]</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>limit</code></td>
|
||||
<td>integer</td>
|
||||
<td>No</td>
|
||||
<td>100</td>
|
||||
<td>Max results</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code><span class="c1"># Search for frames containing "person" objects</span>
|
||||
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/search/frames"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"file_uuid": "'</span><span class="s2">"</span><span class="nv">$FILE_UUID</span><span class="s2">"</span><span class="s1">'", "object_class": "person", "limit": 20}'</span>
|
||||
|
||||
<span class="c1"># Search for frames with specific OCR text</span>
|
||||
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/search/frames"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"file_uuid": "'</span><span class="s2">"</span><span class="nv">$FILE_UUID</span><span class="s2">"</span><span class="s1">'", "ocr_text": "hello", "time_range": [10.0, 30.0]}'</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">"frames"</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">"frame_number"</span><span class="p">:</span><span class="w"> </span><span class="mi">1200</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"timestamp"</span><span class="p">:</span><span class="w"> </span><span class="mf">50.0</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"objects"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="nt">"class"</span><span class="p">:</span><span class="w"> </span><span class="s2">"person"</span><span class="p">,</span><span class="w"> </span><span class="nt">"confidence"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.95</span><span class="p">,</span><span class="w"> </span><span class="nt">"bbox"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mi">100</span><span class="p">,</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span><span class="w"> </span><span class="mi">300</span><span class="p">,</span><span class="w"> </span><span class="mi">400</span><span class="p">]}],</span>
|
||||
<span class="w"> </span><span class="nt">"ocr_texts"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"Hello World"</span><span class="p">],</span>
|
||||
<span class="w"> </span><span class="nt">"faces"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="nt">"face_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"face_42"</span><span class="p">,</span><span class="w"> </span><span class="nt">"confidence"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.88</span><span class="p">}],</span>
|
||||
<span class="w"> </span><span class="nt">"pose_persons"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="nt">"trace_id"</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">"bbox"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mi">120</span><span class="p">,</span><span class="w"> </span><span class="mi">60</span><span class="p">,</span><span class="w"> </span><span class="mi">280</span><span class="p">,</span><span class="w"> </span><span class="mi">380</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">"total"</span><span class="p">:</span><span class="w"> </span><span class="mi">15</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>frames</code></td>
|
||||
<td>array</td>
|
||||
<td>Array of matching frame objects</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>frames[].frame_number</code></td>
|
||||
<td>integer</td>
|
||||
<td>Frame number in video</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>frames[].timestamp</code></td>
|
||||
<td>float</td>
|
||||
<td>Timestamp in seconds</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>frames[].file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>File UUID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>frames[].objects</code></td>
|
||||
<td>array/null</td>
|
||||
<td>YOLO detections in this frame</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>frames[].ocr_texts</code></td>
|
||||
<td>array/null</td>
|
||||
<td>OCR text strings in this frame</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>frames[].faces</code></td>
|
||||
<td>array/null</td>
|
||||
<td>Face detections in this frame</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>frames[].pose_persons</code></td>
|
||||
<td>array/null</td>
|
||||
<td>Pose-detected persons in this frame</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>total</code></td>
|
||||
<td>integer</td>
|
||||
<td>Total matching frame count</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/search/identity_text</code></h3>
|
||||
<h3><code>POST /api/v1/search/llm-smart</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: global / file-level</p>
|
||||
<p>Smart search with LLM re-ranking. First fetches candidate results via RRF (Reciprocal Rank Fusion) using the existing smart search, then uses an LLM (Gemma4 on port 8000) to re-rank candidates by relevance to the query.</p>
|
||||
<h4>Request Parameters</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Required</th>
|
||||
<th>Default</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>query</code></td>
|
||||
<td>string</td>
|
||||
<td>Yes</td>
|
||||
<td>—</td>
|
||||
<td>Search text</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>No</td>
|
||||
<td>—</td>
|
||||
<td>File UUID to search within</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>limit</code></td>
|
||||
<td>integer</td>
|
||||
<td>No</td>
|
||||
<td>10</td>
|
||||
<td>Max results to return</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Pipeline</h4>
|
||||
<div class="codehilite"><pre><span></span><code><span class="w"> </span><span class="mf">1.</span><span class="w"> </span><span class="n">smart_search</span><span class="w"> </span><span class="n">→</span><span class="w"> </span><span class="k">fetch</span><span class="w"> </span><span class="n">N</span><span class="w"> </span><span class="n">candidates</span><span class="w"> </span><span class="p">(</span><span class="k">limit</span><span class="w"> </span><span class="n">×</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="n">clamped</span><span class="w"> </span><span class="mi">10</span><span class="o">-</span><span class="mi">20</span><span class="p">)</span>
|
||||
<span class="w"> </span><span class="mf">2.</span><span class="w"> </span><span class="n">LLM</span><span class="w"> </span><span class="n">rerank</span><span class="w"> </span><span class="n">→</span><span class="w"> </span><span class="n">re</span><span class="o">-</span><span class="k">order</span><span class="w"> </span><span class="k">by</span><span class="w"> </span><span class="n">relevance</span><span class="w"> </span><span class="k">using</span><span class="w"> </span><span class="n">Gemma4</span>
|
||||
<span class="w"> </span><span class="mf">3.</span><span class="w"> </span><span class="n">trim</span><span class="w"> </span><span class="n">→</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="n">top</span><span class="w"> </span><span class="n n-Quoted">`limit`</span><span class="w"> </span><span class="n">results</span>
|
||||
</code></pre></div>
|
||||
|
||||
<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">"</span><span class="nv">$API</span><span class="s2">/api/v1/search/llm-smart"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"query": "two people having a conversation about business", "limit": 5}'</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">"query"</span><span class="p">:</span><span class="w"> </span><span class="s2">"two people having a conversation about business"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"results"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"parent_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1234</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"scene_order"</span><span class="p">:</span><span class="w"> </span><span class="mi">1234</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"start_frame"</span><span class="p">:</span><span class="w"> </span><span class="mi">5000</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"end_frame"</span><span class="p">:</span><span class="w"> </span><span class="mi">5200</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"fps"</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">"start_time"</span><span class="p">:</span><span class="w"> </span><span class="mf">208.3</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"end_time"</span><span class="p">:</span><span class="w"> </span><span class="mf">216.7</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"summary"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[208s-217s, 9s] Two people discussing project timeline..."</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"similarity"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.72</span>
|
||||
<span class="w"> </span><span class="p">}</span>
|
||||
<span class="w"> </span><span class="p">],</span>
|
||||
<span class="w"> </span><span class="nt">"page"</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">"page_size"</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">"strategy"</span><span class="p">:</span><span class="w"> </span><span class="s2">"llm_reranked"</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>strategy</code></td>
|
||||
<td>string</td>
|
||||
<td>Always <code>"llm_reranked"</code> for this endpoint</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>results</code></td>
|
||||
<td>array</td>
|
||||
<td>Re-ranked search results (same format as smart search)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Fallback</h4>
|
||||
<p>If LLM reranking fails (model unavailable, timeout), falls back to RRF order without error.</p>
|
||||
<hr />
|
||||
<h3>Visual Search</h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: global / file-level</p>
|
||||
<p>Search text chunks → find associated identities. Returns chunks where face detections overlap with text content.</p>
|
||||
@@ -392,12 +634,13 @@ a { color: #0066cc; }
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3>Visual Search</h3>
|
||||
<h3>Visual Search (Planned)</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Status</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -405,26 +648,31 @@ a { color: #0066cc; }
|
||||
<tr>
|
||||
<td>POST</td>
|
||||
<td><code>/api/v1/search/visual</code></td>
|
||||
<td>Not implemented</td>
|
||||
<td>Search visual chunks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>POST</td>
|
||||
<td><code>/api/v1/search/visual/class</code></td>
|
||||
<td>Not implemented</td>
|
||||
<td>Search by object class</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>POST</td>
|
||||
<td><code>/api/v1/search/visual/density</code></td>
|
||||
<td>Not implemented</td>
|
||||
<td>Search by object density</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>POST</td>
|
||||
<td><code>/api/v1/search/visual/combination</code></td>
|
||||
<td>Not implemented</td>
|
||||
<td>Search by object combination</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>POST</td>
|
||||
<td><code>/api/v1/search/visual/stats</code></td>
|
||||
<td>Not implemented</td>
|
||||
<td>Visual chunk statistics</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -457,7 +705,7 @@ a { color: #0066cc; }
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<p><em>Updated: 2026-05-27 — Added global search support for smart, universal, identity_text APIs</em></p>
|
||||
<p><em>Updated: 2026-06-20 — Added llm-smart search, completed frames search documentation, marked visual search as planned</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -790,7 +790,100 @@ curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</s
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<p><em>Updated: 2026-05-19 12:49:24</em></p>
|
||||
<h3><code>GET /api/v1/file/:file_uuid/stranger/:stranger_id/representative-face</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Get the representative face for a stranger (unidentified face trace).</p>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/stranger/1/representative-face"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"stranger_id"</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">"face_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">85</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"representative"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"frame_number"</span><span class="p">:</span><span class="w"> </span><span class="mi">5000</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"timestamp_secs"</span><span class="p">:</span><span class="w"> </span><span class="mf">208.33</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"bbox"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">"x"</span><span class="p">:</span><span class="w"> </span><span class="mi">200</span><span class="p">,</span><span class="w"> </span><span class="nt">"y"</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span><span class="w"> </span><span class="nt">"width"</span><span class="p">:</span><span class="w"> </span><span class="mi">150</span><span class="p">,</span><span class="w"> </span><span class="nt">"height"</span><span class="p">:</span><span class="w"> </span><span class="mi">150</span><span class="p">},</span>
|
||||
<span class="w"> </span><span class="nt">"confidence"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.92</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"quality_score"</span><span class="p">:</span><span class="w"> </span><span class="mi">20700</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"blur_score"</span><span class="p">:</span><span class="w"> </span><span class="mf">8.5</span>
|
||||
<span class="w"> </span><span class="p">}</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/file/:file_uuid/stranger/:stranger_id/thumbnail</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Extract the best face image for a stranger as JPEG (320×320).</p>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/stranger/1/thumbnail"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span>-o<span class="w"> </span>stranger_1_face.jpg
|
||||
</code></pre></div>
|
||||
|
||||
<h4>Response</h4>
|
||||
<ul>
|
||||
<li><strong>200</strong>: <code>image/jpeg</code> binary data (320×320 cropped face)</li>
|
||||
<li><strong>404</strong>: File or stranger not found</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/file/:file_uuid/chunk/:chunk_id/thumbnail</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Get thumbnail for a specific chunk. Extracts the representative frame for the chunk's time range.</p>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/chunk/chunk_1/thumbnail"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span>-o<span class="w"> </span>chunk_1.jpg
|
||||
</code></pre></div>
|
||||
|
||||
<h4>Response</h4>
|
||||
<ul>
|
||||
<li><strong>200</strong>: <code>image/jpeg</code> binary data</li>
|
||||
<li><strong>404</strong>: File or chunk not found</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/media-proxy</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: system-level</p>
|
||||
<p>Proxy request to fetch media from external URLs. Useful for loading profile images or thumbnails from external services (TMDb, etc.) without exposing the external URL to the client.</p>
|
||||
<h4>Query Parameters</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Required</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>url</code></td>
|
||||
<td>string</td>
|
||||
<td>Yes</td>
|
||||
<td>External URL to proxy</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">"</span><span class="nv">$API</span><span class="s2">/api/v1/media-proxy?url=https://image.tmdb.org/t/p/w500/abc123.jpg"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span>-o<span class="w"> </span>tmdb_profile.jpg
|
||||
</code></pre></div>
|
||||
|
||||
<h4>Response</h4>
|
||||
<ul>
|
||||
<li><strong>200</strong>: Proxied media data (Content-Type from external source)</li>
|
||||
<li><strong>400</strong>: Missing or invalid URL parameter</li>
|
||||
<li><strong>500</strong>: External request failed</li>
|
||||
</ul>
|
||||
<hr />
|
||||
<hr />
|
||||
<p><em>Updated: 2026-06-20 — Added stranger endpoints, chunk thumbnail, and media proxy</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -125,8 +125,124 @@ If local files exist, no external API call is made. Internet is only needed for
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<h3><code>POST /api/v1/tmdb/fetch</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: system-level</p>
|
||||
<p>Fetch TMDb data by filename, create identities with profile images and embeddings. Similar to prefetch+probe combined, but also downloads profile images and generates embeddings.</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>filename</code></td>
|
||||
<td>string</td>
|
||||
<td>Yes</td>
|
||||
<td>Movie filename to search TMDb for</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">"</span><span class="nv">$API</span><span class="s2">/api/v1/tmdb/fetch"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"filename": "charade.mp4"}'</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">"success"</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">"movie_title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Charade (1963)"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"tmdb_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1234</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"identities_created"</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">"profile_images_downloaded"</span><span class="p">:</span><span class="w"> </span><span class="mi">12</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<hr />
|
||||
<p><em>Updated: 2026-05-19 12:49:24</em></p>
|
||||
<h3><code>POST /api/v1/agents/tmdb/match/:file_uuid</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Match TMDb identities to face traces using Qdrant vector similarity. Compares face embeddings against TMDb identity embeddings to find the best matches.</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">"</span><span class="nv">$API</span><span class="s2">/api/v1/agents/tmdb/match/</span><span class="nv">$FILE_UUID</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"matches"</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">"trace_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"identity_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a9a90105-6d6b-46ff-92da-0c3c1a57dff4"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"identity_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Audrey Hepburn"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"confidence"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.92</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"tmdb_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">1234</span>
|
||||
<span class="w"> </span><span class="p">}</span>
|
||||
<span class="w"> </span><span class="p">],</span>
|
||||
<span class="w"> </span><span class="nt">"total_matches"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</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>matches[].trace_id</code></td>
|
||||
<td>integer</td>
|
||||
<td>Face trace ID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>matches[].identity_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>Matched TMDb identity UUID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>matches[].identity_name</code></td>
|
||||
<td>string</td>
|
||||
<td>Identity display name</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>matches[].confidence</code></td>
|
||||
<td>float</td>
|
||||
<td>Cosine similarity score (0.0–1.0)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>matches[].tmdb_id</code></td>
|
||||
<td>integer</td>
|
||||
<td>TMDb person ID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>total_matches</code></td>
|
||||
<td>integer</td>
|
||||
<td>Total successful matches</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3>TMDb Auto-Match</h3>
|
||||
<p>When <code>MOMENTRY_TMDB_PROBE_ENABLED=true</code>, the worker automatically runs TMDb matching during the post-process phase:</p>
|
||||
<ol>
|
||||
<li><strong>Register phase</strong>: Searches TMDb by filename, creates identities with <code>tmdb_id</code>/<code>tmdb_profile</code></li>
|
||||
<li><strong>Post-process phase</strong>: Matches detected faces against TMDb identities via cosine similarity using Qdrant</li>
|
||||
</ol>
|
||||
<p>No manual API call needed if auto-match is enabled.</p>
|
||||
<hr />
|
||||
<p><em>Updated: 2026-06-20 — Added tmdb/fetch and tmdb/match endpoints</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -32,12 +32,46 @@ a { color: #0066cc; }
|
||||
<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 -->
|
||||
<!-- description: Identity operation history, undo, and redo (PATCH, bind, unbind, bind_trace, mergeinto) -->
|
||||
<!-- 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>
|
||||
<p>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.</p>
|
||||
<p>Three independent undo/redo systems exist:</p>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>System</th>
|
||||
<th>Storage</th>
|
||||
<th>Operations Covered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>PATCH</strong></td>
|
||||
<td>PostgreSQL <code>identity_history</code></td>
|
||||
<td><code>update</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Bind</strong></td>
|
||||
<td>PostgreSQL <code>identity_history</code></td>
|
||||
<td><code>bind</code>, <code>unbind</code>, <code>bind_trace</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Merge</strong></td>
|
||||
<td>MongoDB <code>identity_merge_history</code></td>
|
||||
<td>mergeinto</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Delete</strong></td>
|
||||
<td>PostgreSQL <code>identity_history</code></td>
|
||||
<td><code>delete</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3>1. PATCH History & Undo/Redo</h3>
|
||||
<h4>Overview</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -64,11 +98,11 @@ a { color: #0066cc; }
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Redo stack</td>
|
||||
<td>Cleared on new PATCH (<code>is_undone=true</code> records are deleted)</td>
|
||||
<td>Cleared on new PATCH (<code>is_undone=true</code> + <code>operation='update'</code> records are deleted)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Stack Model</h4>
|
||||
<h5>Stack Model</h5>
|
||||
<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)
|
||||
@@ -77,13 +111,13 @@ PATCH 1 → PATCH 2 (undo stack)
|
||||
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>
|
||||
<p>A new PATCH after undo clears only the operation='update' redo stack (PATCH 3 is lost). Bind/merge redo stacks are not affected.</p>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/identity/:identity_uuid/undo</code></h3>
|
||||
<h4><code>POST /api/v1/identity/:identity_uuid/undo</code></h4>
|
||||
<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>
|
||||
<h5>Request (JSON)</h5>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -104,22 +138,22 @@ PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Behavior</h4>
|
||||
<h5>Behavior</h5>
|
||||
<ul>
|
||||
<li>Queries <code>is_undone=false</code> records, ordered by <code>created_at DESC</code></li>
|
||||
<li>Queries <code>is_undone=false</code> records with <code>operation='update'</code>, 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>
|
||||
<h5>Example</h5>
|
||||
<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">"</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/undo"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"steps": 1}'</span>
|
||||
</code></pre></div>
|
||||
|
||||
<h4>Response (200)</h4>
|
||||
<h5>Response (200)</h5>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"success"</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">"identity_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a9a901056d6b46ff92da0c3c1a57dff4"</span><span class="p">,</span>
|
||||
@@ -159,7 +193,7 @@ PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Error Responses</h4>
|
||||
<h5>Error Responses</h5>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -183,11 +217,11 @@ PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/identity/:identity_uuid/redo</code></h3>
|
||||
<h4><code>POST /api/v1/identity/:identity_uuid/redo</code></h4>
|
||||
<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>
|
||||
<h5>Request (JSON)</h5>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -208,22 +242,22 @@ PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Behavior</h4>
|
||||
<h5>Behavior</h5>
|
||||
<ul>
|
||||
<li>Queries <code>is_undone=true</code> records, ordered by <code>created_at DESC</code></li>
|
||||
<li>Queries <code>is_undone=true</code> records with <code>operation='update'</code>, 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>
|
||||
<h5>Example</h5>
|
||||
<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">"</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/redo"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"steps": 1}'</span>
|
||||
</code></pre></div>
|
||||
|
||||
<h4>Response (200)</h4>
|
||||
<h5>Response (200)</h5>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"success"</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">"identity_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a9a901056d6b46ff92da0c3c1a57dff4"</span><span class="p">,</span>
|
||||
@@ -263,7 +297,7 @@ PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Error Responses</h4>
|
||||
<h5>Error Responses</h5>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -287,11 +321,11 @@ PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/identity/:identity_uuid/history</code></h3>
|
||||
<h4><code>GET /api/v1/identity/:identity_uuid/history</code></h4>
|
||||
<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>
|
||||
<p>Query the PATCH operation history for an identity. Returns paginated records with undo/redo stack counts (filtered to <code>operation='update'</code>).</p>
|
||||
<h5>Query Parameters</h5>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -319,7 +353,7 @@ PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Response (200)</h4>
|
||||
<h5>Response (200)</h5>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"success"</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">"identity_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a9a901056d6b46ff92da0c3c1a57dff4"</span><span class="p">,</span>
|
||||
@@ -357,7 +391,7 @@ PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
|
||||
<tr>
|
||||
<td><code>total</code></td>
|
||||
<td>integer</td>
|
||||
<td>Total history records for this identity</td>
|
||||
<td>Total PATCH history records for this identity</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>undo_stack_count</code></td>
|
||||
@@ -396,12 +430,12 @@ PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<h5>Example</h5>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</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&limit=10"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span>
|
||||
</code></pre></div>
|
||||
|
||||
<h4>Error Responses</h4>
|
||||
<h5>Error Responses</h5>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -421,45 +455,746 @@ PATCH 1 → PATCH 2 → PATCH 3 (undo stack)
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3>Comparison: PATCH Undo vs Merge Undo</h3>
|
||||
<h3>2. Bind/Unbind/Trace History & Undo/Redo</h3>
|
||||
<p>All three operations (<code>bind</code>, <code>unbind</code>, <code>bind_trace</code>) share a single history table and undo/redo stack.</p>
|
||||
<h4>Bind Operation Overview</h4>
|
||||
<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 (same table as PATCH)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Snapshot</td>
|
||||
<td><code>{"file_uuid", "face_id" (or "trace_id"), "identity_id_before/after"}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Max records</td>
|
||||
<td>256 per identity (shared limit across all operation types)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Undo steps</td>
|
||||
<td>Unlimited (<code>steps</code> param)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Redo stack</td>
|
||||
<td>Cleared on new bind/unbind/bind_trace (<code>operation IN ('bind','unbind','bind_trace')</code> + <code>is_undone=true</code> records deleted)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stack isolation</td>
|
||||
<td>Bind redo stack is <strong>independent</strong> from PATCH redo stack — clearing one does not affect the other</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h5>Stack Model</h5>
|
||||
<div class="codehilite"><pre><span></span><code>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)
|
||||
</code></pre></div>
|
||||
|
||||
<p>A new bind/unbind/trace after undo clears only the bind redo stack (operations with <code>IN ('bind','unbind','bind_trace')</code>).</p>
|
||||
<h5>Snapshot Format</h5>
|
||||
<p><strong>Before (bind):</strong></p>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aeed71342a899fe4b4c57b7d41bcb692"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"face_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1_5"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"identity_id_before"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<p><strong>After (bind):</strong></p>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aeed71342a899fe4b4c57b7d41bcb692"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"face_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1_5"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"identity_id_after"</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<p><strong>Before (unbind) — binding existed before:</strong></p>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aeed71342a899fe4b4c57b7d41bcb692"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"face_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1_5"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"identity_id_before"</span><span class="p">:</span><span class="w"> </span><span class="mi">9</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<p><strong>After (unbind):</strong></p>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aeed71342a899fe4b4c57b7d41bcb692"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"face_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1_5"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"identity_id_after"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<p>For <code>bind_trace</code>, the snapshot uses <code>trace_id</code> instead of <code>face_id</code>, with <code>identity_id_before</code> capturing the first face's identity in that trace.</p>
|
||||
<hr />
|
||||
<h4><code>POST /api/v1/identity/:identity_uuid/bind/undo</code></h4>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: identity-level</p>
|
||||
<p>Undo the most recent bind/unbind/bind_trace operations. Restores <code>identity_id_before</code> from the snapshot and marks records as undone.</p>
|
||||
<h5>Request (JSON)</h5>
|
||||
<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</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h5>Behavior</h5>
|
||||
<ul>
|
||||
<li>Queries <code>is_undone=false</code> records with <code>operation IN ('bind','unbind','bind_trace')</code>, ordered by <code>created_at DESC</code></li>
|
||||
<li>Restores <code>identity_id_before</code> — for bind this is <code>null</code> (face was unbound), for unbind this is the original identity (face goes back), for bind_trace this is the trace's previous identity</li>
|
||||
<li>Marks the undone records as <code>is_undone=true</code> with <code>undone_at=NOW()</code></li>
|
||||
</ul>
|
||||
<h5>Example</h5>
|
||||
<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">"</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/bind/undo"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"steps": 1}'</span>
|
||||
</code></pre></div>
|
||||
|
||||
<h5>Response (200)</h5>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"success"</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">"identity_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a9a901056d6b46ff92da0c3c1a57dff4"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"operation"</span><span class="p">:</span><span class="w"> </span><span class="s2">"bind"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"undone_count"</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">"affected_rows"</span><span class="p">:</span><span class="w"> </span><span class="mi">53</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>operation</code></td>
|
||||
<td>string</td>
|
||||
<td>The actual operation undone (<code>bind</code>, <code>unbind</code>, or <code>bind_trace</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>undone_count</code></td>
|
||||
<td>integer</td>
|
||||
<td>Number of history records undone</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>affected_rows</code></td>
|
||||
<td>integer</td>
|
||||
<td>Number of <code>face_detections</code> rows updated</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h5>Error Responses</h5>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>400</code></td>
|
||||
<td>No bind 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 />
|
||||
<h4><code>POST /api/v1/identity/:identity_uuid/bind/redo</code></h4>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: identity-level</p>
|
||||
<p>Redo previously undone bind/unbind/bind_trace operations. Restores <code>identity_id_after</code> from the snapshot.</p>
|
||||
<h5>Request (JSON)</h5>
|
||||
<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>
|
||||
<h5>Behavior</h5>
|
||||
<ul>
|
||||
<li>Queries <code>is_undone=true</code> records with <code>operation IN ('bind','unbind','bind_trace')</code>, ordered by <code>created_at DESC</code></li>
|
||||
<li>Restores <code>identity_id_after</code> — for bind this is the identity the face was bound to, for unbind this is <code>null</code></li>
|
||||
<li>Marks records as <code>is_undone=false</code> with <code>undone_at=NULL</code></li>
|
||||
</ul>
|
||||
<h5>Example</h5>
|
||||
<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">"</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/bind/redo"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"steps": 1}'</span>
|
||||
</code></pre></div>
|
||||
|
||||
<h5>Response (200)</h5>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"success"</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">"identity_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a9a901056d6b46ff92da0c3c1a57dff4"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"operation"</span><span class="p">:</span><span class="w"> </span><span class="s2">"unbind"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"redone_count"</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">"affected_rows"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</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>operation</code></td>
|
||||
<td>string</td>
|
||||
<td>The actual operation redone (<code>bind</code>, <code>unbind</code>, or <code>bind_trace</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>redone_count</code></td>
|
||||
<td>integer</td>
|
||||
<td>Number of history records redone</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>affected_rows</code></td>
|
||||
<td>integer</td>
|
||||
<td>Number of <code>face_detections</code> rows updated</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h5>Error Responses</h5>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>400</code></td>
|
||||
<td>No bind 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 />
|
||||
<h4><code>GET /api/v1/identity/:identity_uuid/bind/history</code></h4>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: identity-level</p>
|
||||
<p>Query the bind/unbind/bind_trace operation history for an identity. Returns paginated records with undo/redo stack counts.</p>
|
||||
<h5>Query Parameters</h5>
|
||||
<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>
|
||||
<h5>Response (200)</h5>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"success"</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">"identity_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a9a901056d6b46ff92da0c3c1a57dff4"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"total"</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">"undo_stack_count"</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">"redo_stack_count"</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">"results"</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">"history_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">52</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"operation"</span><span class="p">:</span><span class="w"> </span><span class="s2">"bind_trace"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"is_undone"</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">"created_at"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-05-27T14:00:00Z"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"undone_at"</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">"history_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">51</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"operation"</span><span class="p">:</span><span class="w"> </span><span class="s2">"unbind"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"is_undone"</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">"created_at"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-05-27T13:00:00Z"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"undone_at"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-05-27T14:30:00Z"</span>
|
||||
<span class="w"> </span><span class="p">},</span>
|
||||
<span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"history_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"operation"</span><span class="p">:</span><span class="w"> </span><span class="s2">"bind"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"is_undone"</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">"created_at"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-05-27T12:00:00Z"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"undone_at"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
|
||||
<span class="w"> </span><span class="p">}</span>
|
||||
<span class="w"> </span><span class="p">]</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<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 bind 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>bind</code>, <code>unbind</code>, or <code>bind_trace</code>)</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 operation 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>
|
||||
<h5>Example</h5>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/identity/</span><span class="nv">$IDENTITY_UUID</span><span class="s2">/bind/history?page=1&limit=10"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span>
|
||||
</code></pre></div>
|
||||
|
||||
<h5>Error Responses</h5>
|
||||
<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>3. Merge History & Undo/Redo</h3>
|
||||
<p>Merge operations use MongoDB for richer record-keeping, with a 24-hour undo deadline.</p>
|
||||
<h4>Merge Operation Overview</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Property</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Storage</td>
|
||||
<td>MongoDB <code>identity_merge_history</code> collection</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Snapshot</td>
|
||||
<td>Full source identity state + target identity state + aliases/metadata diffs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Trigger</td>
|
||||
<td>Every mergeinto with <code>keep_history=true</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Undo deadline</td>
|
||||
<td>24 hours (renewed on redo)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Redo support</td>
|
||||
<td>Yes — restores undone merges with new 24hr deadline</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Max records</td>
|
||||
<td>Unlimited</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h4><code>POST /api/v1/identity/merge/:merge_id/undo</code></h4>
|
||||
<p>Already documented in <a href="07_identity.md#post-apiv1identitymergemerge_idundo"><code>07_identity.md</code></a>. See that document for full details.</p>
|
||||
<hr />
|
||||
<h4><code>POST /api/v1/identity/merge/:merge_id/redo</code></h4>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: identity-level</p>
|
||||
<p>Redo a previously undone merge operation within the renewed 24-hour deadline.</p>
|
||||
<h5>Request</h5>
|
||||
<p>No body required. The merge ID is taken from the URL path.</p>
|
||||
<h5>Behavior</h5>
|
||||
<ol>
|
||||
<li>Validates the merge record exists and <code>undone=true</code> (not already active)</li>
|
||||
<li>Checks the 24-hour undo deadline (if expired, the redo is rejected)</li>
|
||||
<li>Restores face bindings: moves all faces from <code>target_identity</code> back to <code>source_identity</code></li>
|
||||
<li>Re-adds aliases that were removed by the undo (aliases with <code>source: "merge"</code> tag)</li>
|
||||
<li>Re-adds metadata fields that were removed by the undo</li>
|
||||
<li>If <code>keep_history=true</code>: sets <code>source_identity.status = 'merged'</code> again</li>
|
||||
<li>If <code>keep_history=false</code>: recreates source identity from the <code>undone_snapshot</code> stored at undo time</li>
|
||||
<li>Syncs both identity JSON files to disk</li>
|
||||
<li>Sets <code>undone=false</code>, clears <code>undone_snapshot</code>, renews <code>undo_deadline = NOW() + 24h</code></li>
|
||||
<li>Records <code>redone_by</code> user for audit</li>
|
||||
</ol>
|
||||
<h5>Example</h5>
|
||||
<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">"</span><span class="nv">$API</span><span class="s2">/api/v1/identity/merge/550e8400-e29b-41d4-a716-446655440000/redo"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span>
|
||||
</code></pre></div>
|
||||
|
||||
<h5>Response (200)</h5>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"success"</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">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Redo merge completed: merged 'stranger_13894' into 'Louis Viret' (52 faces transferred)"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"data"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"merge_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"550e8400-e29b-41d4-a716-446655440000"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"faces_transferred"</span><span class="p">:</span><span class="w"> </span><span class="mi">52</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"aliases_re_added"</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">"metadata_fields_re_added"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</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>merge_id</code></td>
|
||||
<td>string</td>
|
||||
<td>The merge operation ID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>faces_transferred</code></td>
|
||||
<td>integer</td>
|
||||
<td>Number of faces transferred from source to target</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>aliases_re_added</code></td>
|
||||
<td>integer</td>
|
||||
<td>Number of aliases restored to target</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>metadata_fields_re_added</code></td>
|
||||
<td>integer</td>
|
||||
<td>Number of metadata fields restored to target</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h5>Error Responses</h5>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>400</code></td>
|
||||
<td>Merge not undone, deadline expired, or cannot redo</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>404</code></td>
|
||||
<td>Merge record not found</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>500</code></td>
|
||||
<td>Database error</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3>4. Delete History & Undo/Redo</h3>
|
||||
<h4>Delete Operation Overview</h4>
|
||||
<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><code>{"identity": {...full row...}, "unbound_faces": [{file_uuid, face_id, trace_id}, ...]}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Max records</td>
|
||||
<td>1 active delete record per identity (redo stack cleared on new delete)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Undo support</td>
|
||||
<td>Yes — recreates identity row, re-binds faces</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Redo support</td>
|
||||
<td>Yes — re-deletes the identity</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Identity file</td>
|
||||
<td>Deleted on delete, recreated on undo</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Snapshot Format</h4>
|
||||
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"identity"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"id"</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">"uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"a9a90105-6d6b-46ff-92da-0c3c1a57dff4"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Cary Grant"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"identity_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"people"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"source"</span><span class="p">:</span><span class="w"> </span><span class="s2">"tmdb"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"confirmed"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"metadata"</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span>
|
||||
<span class="w"> </span><span class="nt">"tmdb_id"</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">"tmdb_profile"</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="nt">"unbound_faces"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aeed71342a899fe4b4c57b7d41bcb692"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"face_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1_5"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"trace_id"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"aeed71342a899fe4b4c57b7d41bcb692"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"face_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1_6"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"trace_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">906</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>Stack Model</h4>
|
||||
<div class="codehilite"><pre><span></span><code>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
|
||||
</code></pre></div>
|
||||
|
||||
<p>A new delete after an undo clears the delete redo stack (no redo possible for the old delete).</p>
|
||||
<h4>Undo Behavior (via existing <code>POST /api/v1/identity/:identity_uuid/undo</code>)</h4>
|
||||
<ol>
|
||||
<li>Normal identity lookup fails (row was deleted)</li>
|
||||
<li>Checks <code>identity_history</code> for <code>operation='delete' AND is_undone=false</code> matching the UUID in the snapshot</li>
|
||||
<li>Recreates the identity row (new internal <code>id</code>, same UUID)</li>
|
||||
<li>Re-binds all faces listed in <code>unbound_faces</code> to the new identity</li>
|
||||
<li>Deletes the <code>identity_history</code> delete record 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></li>
|
||||
</ol>
|
||||
<h4>Redo Behavior (via existing <code>POST /api/v1/identity/:identity_uuid/redo</code>)</h4>
|
||||
<ol>
|
||||
<li>Identity lookup succeeds (identity was restored by prior undo)</li>
|
||||
<li>Checks <code>identity_history</code> for <code>operation='delete' AND is_undone=true</code> matching the identity_id</li>
|
||||
<li>Deletes <code>identity.json</code> from disk</li>
|
||||
<li>Unbinds all faces (<code>identity_id = NULL</code>)</li>
|
||||
<li>Deletes the identity row</li>
|
||||
<li>Marks the delete history record as <code>is_undone=false</code></li>
|
||||
<li>Returns success</li>
|
||||
</ol>
|
||||
<h4>Error Responses (delete undo/redo)</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP</th>
|
||||
<th>Scenario</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>400</code></td>
|
||||
<td>No delete history available (either no delete or already undone/redone)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>404</code></td>
|
||||
<td>Identity not found (for redo — identity wasn't restored)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>500</code></td>
|
||||
<td>Database error</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3>Comparison: PATCH vs Bind vs Merge vs Delete Undo/Redo</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Aspect</th>
|
||||
<th>PATCH Undo/Redo</th>
|
||||
<th>Merge Undo</th>
|
||||
<th>Bind Undo/Redo</th>
|
||||
<th>Merge Undo/Redo</th>
|
||||
<th>Delete Undo/Redo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Storage</td>
|
||||
<td>PostgreSQL <code>identity_history</code></td>
|
||||
<td>PostgreSQL <code>identity_history</code></td>
|
||||
<td>MongoDB <code>identity_merge_history</code></td>
|
||||
<td>PostgreSQL <code>identity_history</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Operation filter</td>
|
||||
<td><code>operation='update'</code></td>
|
||||
<td><code>operation IN ('bind','unbind','bind_trace')</code></td>
|
||||
<td>—</td>
|
||||
<td><code>operation='delete'</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Trigger</td>
|
||||
<td>Every PATCH</td>
|
||||
<td>Every bind/unbind/bind_trace</td>
|
||||
<td>Every mergeinto with <code>keep_history=true</code></td>
|
||||
<td>Every DELETE</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Undo deadline</td>
|
||||
<td>None (unlimited)</td>
|
||||
<td>24 hours</td>
|
||||
<td>None (unlimited)</td>
|
||||
<td>24 hours (renewed on redo)</td>
|
||||
<td>None (unlimited)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Redo support</td>
|
||||
<td>Yes</td>
|
||||
<td>No</td>
|
||||
<td>Yes</td>
|
||||
<td>Yes</td>
|
||||
<td>Yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Step undo</td>
|
||||
<td>Yes (<code>steps</code> param)</td>
|
||||
<td>No (full undo only)</td>
|
||||
<td>Yes (<code>steps</code> param)</td>
|
||||
<td>No (full undo/redo only)</td>
|
||||
<td>No (single record)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Max records</td>
|
||||
<td>256 per identity</td>
|
||||
<td>256 per identity (shared)</td>
|
||||
<td>Unlimited</td>
|
||||
<td>256 per identity (shared)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>User tracking</td>
|
||||
<td><code>user_id</code> + <code>user_source</code></td>
|
||||
<td><code>user_id</code> + <code>user_source</code></td>
|
||||
<td><code>performed_by_user</code> + <code>undone_by</code> / <code>redone_by</code></td>
|
||||
<td><code>user_id</code> + <code>user_source</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
728
docs_v1.0/doc_developer/15_tkg.html
Normal file
728
docs_v1.0/doc_developer/15_tkg.html
Normal file
@@ -0,0 +1,728 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>15 Tkg - 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">← 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: tkg -->
|
||||
<!-- description: Temporal Knowledge Graph — rebuild, nodes, edges, processor counts -->
|
||||
<!-- depends: 05_process, 07_identity -->
|
||||
|
||||
<h2>Temporal Knowledge Graph (TKG)</h2>
|
||||
<p>TKG is a time-aligned knowledge graph built from multi-processor outputs (face, yolo, ocr, pose, asrx, gaze, lip, appearance). It produces 9 node types and 14 edge types stored in <code>dev.tkg_nodes</code> and <code>dev.tkg_edges</code>.</p>
|
||||
<h3>Node Types</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node Type</th>
|
||||
<th>Description</th>
|
||||
<th>Key Properties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>face_trace</code></td>
|
||||
<td>A tracked face identity over time</td>
|
||||
<td><code>trace_id</code>, <code>face_count</code>, <code>avg_confidence</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>gaze_trace</code></td>
|
||||
<td>Gaze direction over time</td>
|
||||
<td><code>direction</code> (frontal/left/right/up/down + diagonals)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>lip_trace</code></td>
|
||||
<td>Lip movement synced with speech</td>
|
||||
<td><code>speaker_id</code>, <code>lip_area_range</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>text_trace</code></td>
|
||||
<td>Spoken text aligned to time</td>
|
||||
<td><code>speaker_id</code>, <code>text</code>, <code>start_time</code>, <code>end_time</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>appearance_trace</code></td>
|
||||
<td>Human appearance (clothing) over time</td>
|
||||
<td><code>clothing_color</code>, <code>upper_cloth</code>, <code>lower_cloth</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>skin_tone_trace</code></td>
|
||||
<td>Fitzpatrick skin tone classification</td>
|
||||
<td><code>fitzpatrick_type</code> (I–VI)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>accessory</code></td>
|
||||
<td>Detected accessories</td>
|
||||
<td><code>type</code> (glasses/hat/etc.), <code>confidence</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>object</code></td>
|
||||
<td>YOLO-detected object</td>
|
||||
<td><code>class</code>, <code>confidence</code>, <code>frame_count</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>speaker</code></td>
|
||||
<td>ASRX speaker segment</td>
|
||||
<td><code>speaker_id</code>, <code>segment_count</code>, <code>total_duration</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>Edge Types</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Edge Type</th>
|
||||
<th>Source → Target</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>co_occurs</code></td>
|
||||
<td>object ↔ object</td>
|
||||
<td>Two objects appear together in same frame</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>speaker_face</code></td>
|
||||
<td>speaker ↔ face_trace</td>
|
||||
<td>Speaker matched to face trace via lip sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>face_face</code></td>
|
||||
<td>face_trace ↔ face_trace</td>
|
||||
<td>Two face traces interact (mutual gaze)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>mutual_gaze</code></td>
|
||||
<td>gaze_trace ↔ gaze_trace</td>
|
||||
<td>Two people looking at each other</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>lip_sync</code></td>
|
||||
<td>lip_trace ↔ text_trace</td>
|
||||
<td>Lip movement aligned with spoken text</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>has_appearance</code></td>
|
||||
<td>face_trace ↔ appearance_trace</td>
|
||||
<td>Face has specific appearance</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>wears</code></td>
|
||||
<td>face_trace ↔ accessory</td>
|
||||
<td>Face wears an accessory</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/file/:file_uuid/tkg/rebuild</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Rebuild the Temporal Knowledge Graph for a file. Reads processor JSON outputs (face, yolo, ocr, pose, asrx, gaze, lip, appearance) and generates TKG nodes and edges. Clears existing nodes/edges for the file first, then rebuilds from scratch.</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">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/tkg/rebuild"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"result"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"face_trace_nodes"</span><span class="p">:</span><span class="w"> </span><span class="mi">16</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"gaze_trace_nodes"</span><span class="p">:</span><span class="w"> </span><span class="mi">16</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"lip_trace_nodes"</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">"text_trace_nodes"</span><span class="p">:</span><span class="w"> </span><span class="mi">24</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"appearance_trace_nodes"</span><span class="p">:</span><span class="w"> </span><span class="mi">8</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"skin_tone_trace_nodes"</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">"accessory_nodes"</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">"object_nodes"</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">"speaker_nodes"</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">"co_occurrence_edges"</span><span class="p">:</span><span class="w"> </span><span class="mi">94</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"speaker_face_edges"</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">"face_face_edges"</span><span class="p">:</span><span class="w"> </span><span class="mi">8</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"mutual_gaze_edges"</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">"lip_sync_edges"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"has_appearance_edges"</span><span class="p">:</span><span class="w"> </span><span class="mi">16</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"wears_edges"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span>
|
||||
<span class="w"> </span><span class="p">},</span>
|
||||
<span class="w"> </span><span class="nt">"error"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>success</code></td>
|
||||
<td>boolean</td>
|
||||
<td>True if rebuild completed</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>32-char hex UUID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>result</code></td>
|
||||
<td>object</td>
|
||||
<td>Node and edge counts by type</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>error</code></td>
|
||||
<td>string/null</td>
|
||||
<td>Error message if failed</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/file/:file_uuid/tkg/nodes</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Query TKG nodes with pagination and optional type filter.</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>node_type</code></td>
|
||||
<td>string</td>
|
||||
<td>No</td>
|
||||
<td>all</td>
|
||||
<td>Filter by node type: <code>face_trace</code>, <code>gaze_trace</code>, <code>lip_trace</code>, <code>text_trace</code>, <code>appearance_trace</code>, <code>skin_tone_trace</code>, <code>accessory</code>, <code>object</code>, <code>speaker</code></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>100</td>
|
||||
<td>Items per page (max 500)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code><span class="c1"># Get all face_trace nodes</span>
|
||||
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/tkg/nodes"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"node_type": "face_trace", "page": 1, "page_size": 50}'</span>
|
||||
|
||||
<span class="c1"># Get all nodes</span>
|
||||
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/tkg/nodes"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{}'</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">"success"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"total"</span><span class="p">:</span><span class="w"> </span><span class="mi">16</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"page"</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">"page_size"</span><span class="p">:</span><span class="w"> </span><span class="mi">50</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"nodes"</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">"id"</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">"node_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"face_trace"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"external_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"trace_0"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"label"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Face Trace 0"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"trace_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"face_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">142</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"avg_confidence"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.87</span>
|
||||
<span class="w"> </span><span class="p">}</span>
|
||||
<span class="w"> </span><span class="p">}</span>
|
||||
<span class="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>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</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>total</code></td>
|
||||
<td>integer</td>
|
||||
<td>Total matching node count</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>page</code></td>
|
||||
<td>integer</td>
|
||||
<td>Current page</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>page_size</code></td>
|
||||
<td>integer</td>
|
||||
<td>Items per page</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>nodes</code></td>
|
||||
<td>array</td>
|
||||
<td>Array of node objects</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>nodes[].id</code></td>
|
||||
<td>integer</td>
|
||||
<td>Database primary key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>nodes[].node_type</code></td>
|
||||
<td>string</td>
|
||||
<td>Node type (see table above)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>nodes[].external_id</code></td>
|
||||
<td>string</td>
|
||||
<td>External identifier (e.g., <code>trace_0</code>, <code>gaze_1</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>nodes[].label</code></td>
|
||||
<td>string</td>
|
||||
<td>Human-readable label</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>nodes[].properties</code></td>
|
||||
<td>object</td>
|
||||
<td>Type-specific properties as JSON</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/file/:file_uuid/tkg/edges</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Query TKG edges with pagination and optional filters.</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>edge_type</code></td>
|
||||
<td>string</td>
|
||||
<td>No</td>
|
||||
<td>all</td>
|
||||
<td>Filter by edge type: <code>co_occurs</code>, <code>speaker_face</code>, <code>face_face</code>, <code>mutual_gaze</code>, <code>lip_sync</code>, <code>has_appearance</code>, <code>wears</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>source_type</code></td>
|
||||
<td>string</td>
|
||||
<td>No</td>
|
||||
<td>—</td>
|
||||
<td>Filter by source node type</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>target_type</code></td>
|
||||
<td>string</td>
|
||||
<td>No</td>
|
||||
<td>—</td>
|
||||
<td>Filter by target node type</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>page</code></td>
|
||||
<td>integer</td>
|
||||
<td>No</td>
|
||||
<td>1</td>
|
||||
<td>Page number</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>page_size</code></td>
|
||||
<td>integer</td>
|
||||
<td>No</td>
|
||||
<td>100</td>
|
||||
<td>Items per page (max 500)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code><span class="c1"># Get all co_occurrence edges</span>
|
||||
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/tkg/edges"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"edge_type": "co_occurs"}'</span>
|
||||
|
||||
<span class="c1"># Get edges between face_trace and speaker nodes</span>
|
||||
curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/tkg/edges"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"Content-Type: application/json"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-d<span class="w"> </span><span class="s1">'{"source_type": "speaker", "target_type": "face_trace"}'</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">"success"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"total"</span><span class="p">:</span><span class="w"> </span><span class="mi">94</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"page"</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">"page_size"</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"edges"</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">"id"</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">"edge_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"co_occurs"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"source_node_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"target_node_id"</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">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"frame_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">45</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"confidence"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.92</span>
|
||||
<span class="w"> </span><span class="p">}</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>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</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>total</code></td>
|
||||
<td>integer</td>
|
||||
<td>Total matching edge count</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>page</code></td>
|
||||
<td>integer</td>
|
||||
<td>Current page</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>page_size</code></td>
|
||||
<td>integer</td>
|
||||
<td>Items per page</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>edges</code></td>
|
||||
<td>array</td>
|
||||
<td>Array of edge objects</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>edges[].id</code></td>
|
||||
<td>integer</td>
|
||||
<td>Database primary key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>edges[].edge_type</code></td>
|
||||
<td>string</td>
|
||||
<td>Edge type</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>edges[].source_node_id</code></td>
|
||||
<td>integer</td>
|
||||
<td>Source node ID (FK to tkg_nodes)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>edges[].target_node_id</code></td>
|
||||
<td>integer</td>
|
||||
<td>Target node ID (FK to tkg_nodes)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>edges[].properties</code></td>
|
||||
<td>object</td>
|
||||
<td>Edge-specific properties as JSON</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/file/:file_uuid/tkg/node/:node_id</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Get detail for a specific TKG node including its connected edges.</p>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/tkg/node/1"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"success"</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">"node"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"id"</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">"node_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"face_trace"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"external_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"trace_0"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"label"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Face Trace 0"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"trace_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"face_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">142</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"avg_confidence"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.87</span>
|
||||
<span class="w"> </span><span class="p">}</span>
|
||||
<span class="w"> </span><span class="p">},</span>
|
||||
<span class="w"> </span><span class="nt">"connected_edges"</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">"id"</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">"edge_type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"co_occurs"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"source_node_id"</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">"target_node_id"</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nt">"frame_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">45</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">"edge_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</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>node</code></td>
|
||||
<td>object</td>
|
||||
<td>Node detail (same format as nodes query)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>connected_edges</code></td>
|
||||
<td>array</td>
|
||||
<td>Edges connected to this node</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>edge_count</code></td>
|
||||
<td>integer</td>
|
||||
<td>Total connected edge count</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Error Codes</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>404</code></td>
|
||||
<td>Node not found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/file/:file_uuid/processor-counts</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Get counts of processor JSON output files for a file. Scans the output directory for <code>{file_uuid}.{processor}.json</code> files and extracts frame counts, segment counts, and chunk counts from each file.</p>
|
||||
<p>Supports short UUID prefix matching (e.g., <code>d3f9ae8e</code> → resolves to full <code>d3f9ae8e471a1fc4d47022c66091b920</code>).</p>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/processor-counts"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"output_dir"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/Users/accusys/momentry/output_dev"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"processors"</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">"processor"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cut"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"has_json"</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">"frame_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">5391</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"segment_count"</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">"chunk_count"</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">"last_modified"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-06-16T18:48:01.987241061+00:00"</span>
|
||||
<span class="w"> </span><span class="p">},</span>
|
||||
<span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"processor"</span><span class="p">:</span><span class="w"> </span><span class="s2">"face"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"has_json"</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">"frame_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">1112</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"segment_count"</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">"chunk_count"</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">"last_modified"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-06-18T17:21:37.408383765+00:00"</span>
|
||||
<span class="w"> </span><span class="p">},</span>
|
||||
<span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"processor"</span><span class="p">:</span><span class="w"> </span><span class="s2">"asrx"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"has_json"</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">"frame_count"</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">"segment_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">6</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"chunk_count"</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">"last_modified"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-06-18T17:21:40.872063642+00:00"</span>
|
||||
<span class="w"> </span><span class="p">},</span>
|
||||
<span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"processor"</span><span class="p">:</span><span class="w"> </span><span class="s2">"story"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"has_json"</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">"frame_count"</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">"segment_count"</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">"chunk_count"</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">"last_modified"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-06-18T17:22:00.000000000+00:00"</span>
|
||||
<span class="w"> </span><span class="p">},</span>
|
||||
<span class="w"> </span><span class="p">{</span>
|
||||
<span class="w"> </span><span class="nt">"processor"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mediapipe"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"has_json"</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">"frame_count"</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">"segment_count"</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">"chunk_count"</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">"last_modified"</span><span class="p">:</span><span class="w"> </span><span class="kc">null</span>
|
||||
<span class="w"> </span><span class="p">}</span>
|
||||
<span class="w"> </span><span class="p">]</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>Full 32-char hex UUID (resolved from prefix)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>output_dir</code></td>
|
||||
<td>string</td>
|
||||
<td>Output directory scanned</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>processors</code></td>
|
||||
<td>array</td>
|
||||
<td>Per-processor output info</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>processors[].processor</code></td>
|
||||
<td>string</td>
|
||||
<td>Processor name</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>processors[].has_json</code></td>
|
||||
<td>boolean</td>
|
||||
<td>Whether JSON file exists</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>processors[].frame_count</code></td>
|
||||
<td>integer/null</td>
|
||||
<td>Total frames processed (frame-based processors)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>processors[].segment_count</code></td>
|
||||
<td>integer/null</td>
|
||||
<td>Segment count (ASRX segments, etc.)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>processors[].chunk_count</code></td>
|
||||
<td>integer/null</td>
|
||||
<td>Chunk count (Story chunks, etc.)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>processors[].last_modified</code></td>
|
||||
<td>string/null</td>
|
||||
<td>ISO 8601 timestamp of last modification</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Error Codes</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>HTTP</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>404</code></td>
|
||||
<td>File UUID not found in database</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<p><em>Updated: 2026-06-20 12:00:00</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
240
docs_v1.0/doc_developer/16_workspace.html
Normal file
240
docs_v1.0/doc_developer/16_workspace.html
Normal file
@@ -0,0 +1,240 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>16 Workspace - 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">← 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: workspace -->
|
||||
<!-- description: Workspace checkout/checkin — lock, clear, restore file data -->
|
||||
<!-- depends: 04_lookup, 05_process -->
|
||||
|
||||
<h2>Workspace Checkin/Checkout</h2>
|
||||
<p>Workspace checkin/checkout provides a transactional editing model for file data:
|
||||
- <strong>Checkout</strong>: Clears PG tables (face_detections, speaker_detections, pre_chunks) and Qdrant vectors, creating an isolated workspace SQLite for editing.
|
||||
- <strong>Checkin</strong>: Restores data from the workspace SQLite back to PG and Qdrant, marking the file as <code>Indexed</code>.</p>
|
||||
<p>This allows safe concurrent editing — while a file is checked out, its main database records are cleared, preventing conflicts.</p>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/file/:file_uuid/checkout</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Checkout a file workspace. Clears face detections, speaker detections, pre_chunks from PostgreSQL, deletes Qdrant vectors, and creates a workspace SQLite database for isolated editing.</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">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/checkout"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"rows_deleted"</span><span class="p">:</span><span class="w"> </span><span class="mi">1523</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"checked_out"</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>32-char hex UUID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>rows_deleted</code></td>
|
||||
<td>integer</td>
|
||||
<td>Total rows cleared from PG tables</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>status</code></td>
|
||||
<td>string</td>
|
||||
<td><code>"checked_out"</code></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>500</code></td>
|
||||
<td>Checkout failed (DB error, workspace creation error)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/file/:file_uuid/checkin</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Checkin a file workspace. Restores face detections, speaker detections, pre_chunks from workspace SQLite back to PostgreSQL, re-indexes vectors to Qdrant, and sets video status to <code>Indexed</code>.</p>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span>-X<span class="w"> </span>POST<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/checkin"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"pre_chunks_moved"</span><span class="p">:</span><span class="w"> </span><span class="mi">45</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"face_detections_moved"</span><span class="p">:</span><span class="w"> </span><span class="mi">1200</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"speaker_detections_moved"</span><span class="p">:</span><span class="w"> </span><span class="mi">320</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"vectors_moved"</span><span class="p">:</span><span class="w"> </span><span class="mi">45</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"indexed"</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>32-char hex UUID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>pre_chunks_moved</code></td>
|
||||
<td>integer</td>
|
||||
<td>Pre-chunks restored from workspace</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>face_detections_moved</code></td>
|
||||
<td>integer</td>
|
||||
<td>Face detections restored from workspace</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>speaker_detections_moved</code></td>
|
||||
<td>integer</td>
|
||||
<td>Speaker detections restored from workspace</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>vectors_moved</code></td>
|
||||
<td>integer</td>
|
||||
<td>Vectors re-indexed to Qdrant</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>status</code></td>
|
||||
<td>string</td>
|
||||
<td><code>"indexed"</code></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>500</code></td>
|
||||
<td>Checkin failed (DB error, workspace not found, vector index error)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/file/:file_uuid/workspace</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Check if a workspace SQLite database exists for a file.</p>
|
||||
<h4>Example</h4>
|
||||
<div class="codehilite"><pre><span></span><code>curl<span class="w"> </span>-s<span class="w"> </span><span class="s2">"</span><span class="nv">$API</span><span class="s2">/api/v1/file/</span><span class="nv">$FILE_UUID</span><span class="s2">/workspace"</span><span class="w"> </span><span class="se">\</span>
|
||||
<span class="w"> </span>-H<span class="w"> </span><span class="s2">"X-API-Key: </span><span class="nv">$KEY</span><span class="s2">"</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">"file_uuid"</span><span class="p">:</span><span class="w"> </span><span class="s2">"d3f9ae8e471a1fc4d47022c66091b920"</span><span class="p">,</span>
|
||||
<span class="w"> </span><span class="nt">"exists"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span>
|
||||
<span class="p">}</span>
|
||||
</code></pre></div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>32-char hex UUID</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>exists</code></td>
|
||||
<td>boolean</td>
|
||||
<td>True if workspace SQLite exists</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h3>Workflow</h3>
|
||||
<div class="codehilite"><pre><span></span><code> REGISTERED ──→ CHECKED_OUT ──→ INDEXED
|
||||
│ │ │
|
||||
│ checkout checkin
|
||||
│ │ │
|
||||
│ clear PG + Qdrant restore from SQLite
|
||||
│ create workspace re-index vectors
|
||||
│ set status set status
|
||||
</code></pre></div>
|
||||
|
||||
<ol>
|
||||
<li><strong>Register</strong> file → status: <code>REGISTERED</code></li>
|
||||
<li><strong>Process</strong> file → processors run, data stored in PG + Qdrant</li>
|
||||
<li><strong>Checkout</strong> file → clear editable data, create workspace SQLite → status: <code>CHECKED_OUT</code></li>
|
||||
<li><strong>Edit</strong> workspace via Agent Search / identity binding</li>
|
||||
<li><strong>Checkin</strong> file → restore from workspace SQLite → status: <code>INDEXED</code></li>
|
||||
<li><strong>Rebuild TKG</strong> if needed after checkin</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<p><em>Updated: 2026-06-20 12:00:00</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
254
docs_v1.0/doc_developer/99_incomplete.html
Normal file
254
docs_v1.0/doc_developer/99_incomplete.html
Normal file
@@ -0,0 +1,254 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>99 Incomplete - 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">← 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: incomplete -->
|
||||
<!-- description: Incomplete, stub, or undocumented API endpoints — tracking list -->
|
||||
<!-- depends: 01_auth -->
|
||||
|
||||
<h2>Incomplete / Undocumented APIs</h2>
|
||||
<p>This module tracks API endpoints that exist in the codebase but are either undocumented, partially documented, or stubs.</p>
|
||||
<blockquote>
|
||||
<p><strong>Note</strong>: Endpoints listed here should be fully documented and moved to their appropriate module once implemented.</p>
|
||||
</blockquote>
|
||||
<hr />
|
||||
<h2>Identity Binding</h2>
|
||||
<h3><code>POST /api/v1/identity/:identity_uuid/bind</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: identity-level</p>
|
||||
<p>Bind a single face detection to an identity. Unlike <code>bind/trace</code> which binds all faces in a trace, this binds one specific face.</p>
|
||||
<h4>Request Parameters</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Type</th>
|
||||
<th>Required</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>file_uuid</code></td>
|
||||
<td>string</td>
|
||||
<td>Yes</td>
|
||||
<td>File containing the face</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>face_id</code></td>
|
||||
<td>string</td>
|
||||
<td>Yes</td>
|
||||
<td>Face detection ID to bind</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Status</h4>
|
||||
<p>⚠️ <strong>Undocumented</strong> — exists in code but no full request/response documentation.</p>
|
||||
<hr />
|
||||
<h2>Resource Management</h2>
|
||||
<h3><code>POST /api/v1/resource/register</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: system-level</p>
|
||||
<p>Register an external resource (e.g., storage backend, API service).</p>
|
||||
<h4>Status</h4>
|
||||
<p>⚠️ <strong>Undocumented</strong> — endpoint exists but no documentation.</p>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/resource/heartbeat</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: system-level</p>
|
||||
<p>Send heartbeat for a registered resource to verify it's still alive.</p>
|
||||
<h4>Status</h4>
|
||||
<p>⚠️ <strong>Undocumented</strong> — endpoint exists but no documentation.</p>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/resources</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: system-level</p>
|
||||
<p>List all registered resources with their status.</p>
|
||||
<h4>Status</h4>
|
||||
<p>⚠️ <strong>Undocumented</strong> — endpoint exists but no documentation.</p>
|
||||
<hr />
|
||||
<h2>5W1H Agent</h2>
|
||||
<h3><code>POST /api/v1/agents/5w1h/analyze</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Run 5W1H analysis on all cut scenes for a file. Uses LLM (Gemma4) to summarize each scene with who/what/where/when/why/how.</p>
|
||||
<h4>Status</h4>
|
||||
<p>⚠️ <strong>Partially documented</strong> — listed in <code>12_agent.md</code> but missing full request/response examples.</p>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/agents/5w1h/batch</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: system-level</p>
|
||||
<p>Run 5W1H analysis on multiple files at once.</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_uuids</code></td>
|
||||
<td>string[]</td>
|
||||
<td>Yes</td>
|
||||
<td>Array of file UUIDs to analyze</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Status</h4>
|
||||
<p>⚠️ <strong>Partially documented</strong> — listed in <code>12_agent.md</code> but missing full request/response examples.</p>
|
||||
<hr />
|
||||
<h3><code>GET /api/v1/agents/5w1h/status</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: system-level</p>
|
||||
<p>Get 5W1H analysis status across all videos (which files have been analyzed, which are pending).</p>
|
||||
<h4>Status</h4>
|
||||
<p>⚠️ <strong>Partially documented</strong> — listed in <code>12_agent.md</code> but missing full response schema.</p>
|
||||
<hr />
|
||||
<h2>Identity Agent</h2>
|
||||
<h3><code>POST /api/v1/agents/identity/match-from-photo</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: system-level</p>
|
||||
<p>Match an identity using an uploaded photo. Extracts face embedding, finds best trace match.</p>
|
||||
<h4>Status</h4>
|
||||
<p>⚠️ <strong>Partially documented</strong> — exists in <code>08_identity_agent.md</code> but missing full response schema and error cases.</p>
|
||||
<hr />
|
||||
<h3><code>POST /api/v1/agents/identity/match-from-trace</code></h3>
|
||||
<p><strong>Auth</strong>: Required
|
||||
<strong>Scope</strong>: file-level</p>
|
||||
<p>Match an identity using a trace. Multi-angle embedding comparison with propagation.</p>
|
||||
<h4>Status</h4>
|
||||
<p>⚠️ <strong>Partially documented</strong> — exists in <code>08_identity_agent.md</code> but missing full response schema and error cases.</p>
|
||||
<hr />
|
||||
<h2>Stubs / Not Implemented</h2>
|
||||
<h3>Visual Search Endpoints</h3>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>POST</td>
|
||||
<td><code>/api/v1/search/visual</code></td>
|
||||
<td>Stub — defined but not functional</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>POST</td>
|
||||
<td><code>/api/v1/search/visual/class</code></td>
|
||||
<td>Stub — defined but not functional</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>POST</td>
|
||||
<td><code>/api/v1/search/visual/density</code></td>
|
||||
<td>Stub — defined but not functional</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>POST</td>
|
||||
<td><code>/api/v1/search/visual/combination</code></td>
|
||||
<td>Stub — defined but not functional</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>POST</td>
|
||||
<td><code>/api/v1/search/visual/stats</code></td>
|
||||
<td>Stub — defined but not functional</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>Unmounted Routes</h3>
|
||||
<p>These endpoints are defined in source code but not mounted in the router:</p>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Endpoint</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>/api/v1/search/persons</code></td>
|
||||
<td>Defined but not mounted</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/api/v1/who</code></td>
|
||||
<td>Defined but not mounted</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/api/v1/who/candidates</code></td>
|
||||
<td>Defined but not mounted</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<h2>Tracking</h2>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Count</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Undocumented</td>
|
||||
<td>3 (resource management)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Partially documented</td>
|
||||
<td>5 (5W1H ×3, identity agent ×2)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stub/not functional</td>
|
||||
<td>5 (visual search)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Defined but unmounted</td>
|
||||
<td>3 (persons, who, who/candidates)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Total</strong></td>
|
||||
<td><strong>16</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<hr />
|
||||
<p><em>Created: 2026-06-20 — Gap analysis from core API vs doc_wasm sync</em>
|
||||
<em>Updated: 2026-06-20 — Initial tracking list</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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><tr onclick="window.location='14_identity_history.html'" style="cursor:pointer"><td class="cn">14 Identity History</td><td class="en"></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><tr onclick="window.location='15_tkg.html'" style="cursor:pointer"><td class="cn">15 Tkg</td><td class="en"></td></tr><tr onclick="window.location='16_workspace.html'" style="cursor:pointer"><td class="cn">16 Workspace</td><td class="en"></td></tr><tr onclick="window.location='99_incomplete.html'" style="cursor:pointer"><td class="cn">99 Incomplete</td><td class="en"></td></tr></table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -127,13 +127,15 @@ curl -s "$API/api/v1/file/$FILE_UUID/probe" -H "X-API-Key: $KEY"
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/v1/progress/:file_uuid`
|
||||
### `POST /api/v1/progress/:file_uuid`
|
||||
|
||||
**Auth**: Required
|
||||
**Scope**: file-level
|
||||
|
||||
Get real-time processing progress for a file via Redis pub/sub. Includes per-processor status, current/total frames, ETA, and system resource stats.
|
||||
|
||||
**Note**: This endpoint uses **POST** method, not GET. The progress data is stored in Redis as a hash, and POST is used to retrieve the latest state.
|
||||
|
||||
#### Pipeline Order
|
||||
|
||||
| Order | Processor | Dependencies | Description |
|
||||
@@ -154,7 +156,7 @@ All processors except `story` and `5w1h` run concurrently when their dependencie
|
||||
#### Example
|
||||
|
||||
```bash
|
||||
curl -s "$API/api/v1/progress/$FILE_UUID" -H "X-API-Key: $KEY" | jq '{overall_progress, processors: [.processors[] | {processor_type, status}]}'
|
||||
curl -s -X POST "$API/api/v1/progress/$FILE_UUID" -H "X-API-Key: $KEY" | jq '{overall_progress, processors: [.processors[] | {name, status}]}'
|
||||
```
|
||||
|
||||
#### Response (200)
|
||||
|
||||
@@ -923,6 +923,128 @@ curl -s "$API/api/v1/identity/$IDENTITY_UUID/json" \
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/file/:file_uuid/pending-person`
|
||||
|
||||
**Auth**: Required
|
||||
**Scope**: file-level
|
||||
|
||||
Create a manually managed "pending person" under a specific file. A pending person is an identity with `status='pending'` and `source='manual'`, used for unmatched traces that the user wants to manually label before a full identity resolution.
|
||||
|
||||
Optionally binds a list of trace IDs to this new identity.
|
||||
|
||||
#### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"trace_ids": [100, 150, 200],
|
||||
"name": "Mystery Man #1"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `trace_ids` | array[int] | No | `[]` | Trace IDs to bind to this pending person |
|
||||
| `name` | string | No | `"Person N"` | Human-readable name. Auto-generated if omitted |
|
||||
|
||||
#### Example
|
||||
|
||||
```bash
|
||||
# Create pending person with name and no traces
|
||||
curl -s -X POST "$API/api/v1/file/$FILE_UUID/pending-person" \
|
||||
-H "X-API-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Unknown Woman #2", "trace_ids": []}'
|
||||
|
||||
# Create pending person with auto-name and bind traces
|
||||
curl -s -X POST "$API/api/v1/file/$FILE_UUID/pending-person" \
|
||||
-H "X-API-Key: $KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"trace_ids": [100, 150, 200]}'
|
||||
```
|
||||
|
||||
#### Response (200)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Created pending person: Mystery Man #1 (uuid: 4d96b25b-68f0-4c52-b238-d69f7dfd588b)",
|
||||
"data": {
|
||||
"identity_uuid": "4d96b25b-68f0-4c52-b238-d69f7dfd588b",
|
||||
"identity_id": 55,
|
||||
"name": "Mystery Man #1",
|
||||
"bound_traces": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `identity_uuid` | string | UUID of the newly created pending identity |
|
||||
| `identity_id` | integer | Internal ID of the new identity |
|
||||
| `name` | string | Display name |
|
||||
| `bound_traces` | integer | Number of traces bound |
|
||||
|
||||
#### Side Effects
|
||||
|
||||
- Creates an `identities` row with `status='pending'`, `source='manual'`, `file_uuid=<file_uuid>`
|
||||
- If `trace_ids` provided: `UPDATE face_detections SET identity_id = ...` for matching traces
|
||||
- If `trace_ids` provided: TKG face_track nodes get `identity_id` / `identity_name` in properties
|
||||
- Identity JSON file synced to disk
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/v1/file/:file_uuid/pending-persons`
|
||||
|
||||
**Auth**: Required
|
||||
**Scope**: file-level
|
||||
|
||||
List all pending persons for a file.
|
||||
|
||||
#### Example
|
||||
|
||||
```bash
|
||||
curl -s "$API/api/v1/file/$FILE_UUID/pending-persons" \
|
||||
-H "X-API-Key: $KEY"
|
||||
```
|
||||
|
||||
#### Response (200)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Found 2 pending persons for c36f35685177c981aa139b66bbbccc5b",
|
||||
"data": [
|
||||
{
|
||||
"identity_uuid": "232ecd08-a2bf-4bd0-bd25-0bd8fb7a7dae",
|
||||
"identity_id": 56,
|
||||
"name": "Person 2",
|
||||
"created_at": "2026-06-23 17:13:23",
|
||||
"trace_count": 3,
|
||||
"bound_traces": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `identity_uuid` | string | Identity UUID |
|
||||
| `identity_id` | integer | Internal identity ID |
|
||||
| `name` | string | Display name |
|
||||
| `created_at` | string | Creation timestamp |
|
||||
| `trace_count` | integer | Number of face traces bound to this pending person |
|
||||
| `bound_traces` | array[int] | List of bound trace IDs (currently null, reserved for future expansion) |
|
||||
|
||||
#### Notes
|
||||
|
||||
- Pending persons are normal `identities` rows with `status='pending'` — they can be promoted to confirmed via `PATCH /api/v1/identity/:identity_uuid` (`{"status": "confirmed"}`)
|
||||
- They can be merged into known identities via `POST /api/v1/identity/:identity_uuid/mergeinto`
|
||||
- Use `GET /api/v1/identity/:identity_uuid/traces` to get detailed trace info for each pending person
|
||||
|
||||
---
|
||||
|
||||
## Alias System (BCP 47 Locale Tags)
|
||||
|
||||
Identity aliases support multilingual display names. Aliases are stored in `metadata.aliases` as an array of `{locale, name}` objects.
|
||||
|
||||
74
scripts/fix_processor_stats.py
Normal file
74
scripts/fix_processor_stats.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fix processor_results statistics (frames_processed, output_size_bytes)
|
||||
for all jobs that have missing data due to Worker crash.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import psycopg2
|
||||
|
||||
def fix_processor_stats():
|
||||
conn = psycopg2.connect(os.environ.get('DATABASE_URL', 'postgres://accusys@localhost:5432/momentry'))
|
||||
cur = conn.cursor()
|
||||
|
||||
# Find jobs with missing frames_processed
|
||||
cur.execute("""
|
||||
SELECT DISTINCT mj.uuid, mj.id
|
||||
FROM public.monitor_jobs mj
|
||||
JOIN public.processor_results pr ON pr.job_id = mj.id
|
||||
WHERE pr.frames_processed = 0
|
||||
AND pr.status = 'completed'
|
||||
ORDER BY mj.created_at DESC
|
||||
""")
|
||||
|
||||
jobs = cur.fetchall()
|
||||
print(f"Found {len(jobs)} jobs with missing statistics")
|
||||
|
||||
for uuid, job_id in jobs:
|
||||
print(f"\nProcessing UUID: {uuid}")
|
||||
|
||||
# Get total_frames from YOLO output
|
||||
yolo_file = f'/Users/accusys/momentry/output/{uuid}.yolo.json'
|
||||
total_frames = 0
|
||||
|
||||
if os.path.exists(yolo_file):
|
||||
with open(yolo_file, 'r') as f:
|
||||
content = f.read(5000)
|
||||
match = re.search(r'"total_frames": (\d+)', content)
|
||||
if match:
|
||||
total_frames = int(match.group(1))
|
||||
print(f" total_frames: {total_frames}")
|
||||
|
||||
if total_frames > 0:
|
||||
# Update frames_processed
|
||||
cur.execute("""
|
||||
UPDATE public.processor_results
|
||||
SET frames_processed = %s
|
||||
WHERE job_id = %s
|
||||
""", (total_frames, job_id))
|
||||
|
||||
# Update output_size_bytes for each processor
|
||||
processors = ['asr', 'yolo', 'face', 'ocr', 'pose', 'cut', 'appearance', 'asrx']
|
||||
for proc in processors:
|
||||
file_path = f'/Users/accusys/momentry/output/{uuid}.{proc}.json'
|
||||
if os.path.exists(file_path):
|
||||
size = os.path.getsize(file_path)
|
||||
cur.execute("""
|
||||
UPDATE public.processor_results
|
||||
SET output_size_bytes = %s
|
||||
WHERE job_id = %s AND processor_type = %s
|
||||
""", (size, job_id, proc))
|
||||
print(f" {proc}: {size} bytes")
|
||||
|
||||
conn.commit()
|
||||
print(f" ✓ Updated")
|
||||
else:
|
||||
print(f" ⚠ Skipped (no total_frames)")
|
||||
|
||||
conn.close()
|
||||
print(f"\nCompleted: {len(jobs)} jobs processed")
|
||||
|
||||
if __name__ == '__main__':
|
||||
fix_processor_stats()
|
||||
58
scripts/fix_processors_asrx.py
Normal file
58
scripts/fix_processors_asrx.py
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fix existing monitor_jobs records to include ASRX in processors list.
|
||||
|
||||
This script adds 'asrx' to processors array for all monitor_jobs records
|
||||
that don't have it yet.
|
||||
"""
|
||||
|
||||
import os
|
||||
import psycopg2
|
||||
|
||||
def fix_processors():
|
||||
schema = os.environ.get('DATABASE_SCHEMA', 'public')
|
||||
|
||||
conn = psycopg2.connect(os.environ.get('DATABASE_URL', 'postgres://accusys@localhost:5432/momentry'))
|
||||
cur = conn.cursor()
|
||||
|
||||
# Check current state
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) FROM {schema}.monitor_jobs
|
||||
WHERE processors IS NOT NULL
|
||||
AND NOT ('asrx' = ANY(processors))
|
||||
""")
|
||||
missing_asrx = cur.fetchone()[0]
|
||||
|
||||
print(f"Found {missing_asrx} jobs missing ASRX in processors")
|
||||
|
||||
if missing_asrx > 0:
|
||||
# Add ASRX to processors array
|
||||
cur.execute(f"""
|
||||
UPDATE {schema}.monitor_jobs
|
||||
SET processors = array_append(processors, 'asrx')
|
||||
WHERE processors IS NOT NULL
|
||||
AND NOT ('asrx' = ANY(processors))
|
||||
""")
|
||||
|
||||
updated = cur.rowcount
|
||||
conn.commit()
|
||||
|
||||
print(f"Updated {updated} jobs to include ASRX")
|
||||
|
||||
# Verify
|
||||
cur.execute(f"""
|
||||
SELECT uuid, processors FROM {schema}.monitor_jobs
|
||||
WHERE processors IS NOT NULL
|
||||
AND 'asrx' = ANY(processors)
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
""")
|
||||
|
||||
for row in cur.fetchall():
|
||||
print(f"UUID: {row[0]}, Processors: {row[1]}")
|
||||
else:
|
||||
print("All jobs already have ASRX in processors")
|
||||
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
fix_processors()
|
||||
@@ -166,18 +166,21 @@ async fn list_identities(
|
||||
|
||||
let id_table = crate::core::db::schema::table_name("identities");
|
||||
|
||||
let total: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {}", id_table))
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Count error: {}", e),
|
||||
)
|
||||
})?;
|
||||
let total: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE status IS NULL OR status != 'merged'",
|
||||
id_table
|
||||
))
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Count error: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let sql = format!(
|
||||
"SELECT id::int, uuid, name, metadata FROM {} ORDER BY id DESC LIMIT $1 OFFSET $2",
|
||||
"SELECT id::int, uuid, name, metadata FROM {} WHERE status IS NULL OR status != 'merged' ORDER BY id DESC LIMIT $1 OFFSET $2",
|
||||
id_table
|
||||
);
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ pub fn identity_agent_routes() -> Router<AppState> {
|
||||
"/api/v1/agents/identity/match-from-trace",
|
||||
post(match_from_trace),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/agents/identity/generate-seeds",
|
||||
post(generate_seeds_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/agents/identity/run",
|
||||
post(run_identity_handler),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -619,198 +627,373 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 迭代多角度 face embedding 比對 + 傳播 (Qdrant version)
|
||||
/// Round 1: 用 TMDb seed face_embedding 比對 Qdrant embeddings (threshold 0.50)
|
||||
/// Round 2+: 用已匹配 trace 的所有 face 作為 seed,傳播到未匹配 trace
|
||||
fn average_embeddings<'a>(embeddings: impl Iterator<Item = &'a Vec<f32>>) -> Vec<f32> {
|
||||
let mut count = 0usize;
|
||||
let mut sum: Option<Vec<f32>> = None;
|
||||
for emb in embeddings {
|
||||
if emb.len() != 512 {
|
||||
continue;
|
||||
}
|
||||
match &mut sum {
|
||||
None => sum = Some(emb.clone()),
|
||||
Some(s) => {
|
||||
for (i, v) in emb.iter().enumerate() {
|
||||
s[i] += v;
|
||||
}
|
||||
}
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
if let Some(mut s) = sum {
|
||||
let c = count as f32;
|
||||
for v in &mut s {
|
||||
*v /= c;
|
||||
}
|
||||
s
|
||||
} else {
|
||||
vec![0.0f32; 512]
|
||||
}
|
||||
}
|
||||
|
||||
/// Cluster: trace centroid + seeds from Qdrant + stranger clustering.
|
||||
/// Round 1: centroid vs seeds (TH=0.55)
|
||||
/// Round 2+: propagate from matched (TH=0.50)
|
||||
/// Unknown: greedy stranger clustering (TH=0.40)
|
||||
/// Writes identity_ref/stranger_ref to Qdrant payload, TKG nodes, and face_detections.
|
||||
async fn match_faces_iterative(pool: &sqlx::PgPool, file_uuid: &str) -> anyhow::Result<usize> {
|
||||
use crate::core::db::face_embedding_db::FaceEmbeddingDb;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Step 1: 載入 TMDb identities (source='tmdb' 且有 face_embedding)
|
||||
let identities_table = schema::table_name("identities");
|
||||
let tmdb_rows = sqlx::query_as::<_, (i32, String, Vec<f32>)>(
|
||||
&format!("SELECT id, name, face_embedding::real[] FROM {} WHERE source='tmdb' AND face_embedding IS NOT NULL", identities_table)
|
||||
)
|
||||
.fetch_all(pool).await?;
|
||||
let face_db = FaceEmbeddingDb::new();
|
||||
|
||||
if tmdb_rows.is_empty() {
|
||||
tracing::warn!("[FaceMatch] No TMDb identities with face embeddings");
|
||||
return Ok(0);
|
||||
}
|
||||
// Step 1: Load seeds from Qdrant (type=identity_seed)
|
||||
let seeds = face_db.get_seed_embeddings().await?;
|
||||
tracing::info!(
|
||||
"[FaceMatch-Qdrant] Loaded {} TMDb seed identities",
|
||||
tmdb_rows.len()
|
||||
"[FaceMatch] Loaded {} seeds from Qdrant",
|
||||
seeds.len()
|
||||
);
|
||||
|
||||
// Step 2: Load embeddings from Qdrant
|
||||
let face_db = FaceEmbeddingDb::new();
|
||||
// Step 2: Preload identity internal IDs (uuid → (id, name))
|
||||
let id_table = schema::table_name("identities");
|
||||
let seed_identity_map: HashMap<String, (i32, String)> = if !seeds.is_empty() {
|
||||
let uuids: Vec<String> = seeds.iter().map(|(uuid, _, _)| uuid.clone()).collect();
|
||||
if uuids.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
let rows = sqlx::query_as::<_, (i32, String, String)>(&format!(
|
||||
"SELECT id, uuid::text, name FROM {} WHERE uuid::text = ANY($1)",
|
||||
id_table
|
||||
))
|
||||
.bind(&uuids)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, uuid, name)| (uuid, (id, name)))
|
||||
.collect();
|
||||
rows
|
||||
}
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
// Step 3: Load face embeddings from Qdrant for this file
|
||||
let qdrant_embeddings = face_db.get_all_embeddings_for_file(file_uuid).await?;
|
||||
|
||||
if qdrant_embeddings.is_empty() {
|
||||
tracing::warn!(
|
||||
"[FaceMatch-Qdrant] No face embeddings in Qdrant for {}",
|
||||
file_uuid
|
||||
);
|
||||
return match_faces_iterative_pg(pool, file_uuid).await; // Fallback to PG
|
||||
tracing::warn!("[FaceMatch] No face embeddings in Qdrant for {}", file_uuid);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Group: trace_id → Vec<(frame, embedding)>
|
||||
let mut face_track_faces_raw: HashMap<i32, Vec<(i64, Vec<f32>)>> = HashMap::new();
|
||||
// Step 4: Group embeddings by trace_id, keeping confidence
|
||||
let mut trace_faces: HashMap<i32, Vec<(i64, Vec<f32>, f64)>> = HashMap::new();
|
||||
for (_, emb, payload) in &qdrant_embeddings {
|
||||
face_track_faces_raw
|
||||
trace_faces
|
||||
.entry(payload.trace_id)
|
||||
.or_default()
|
||||
.push((payload.frame, emb.clone()));
|
||||
.push((payload.frame, emb.clone(), payload.confidence));
|
||||
}
|
||||
|
||||
// Sample 3 embeddings per trace (front, mid, back)
|
||||
let mut face_track_samples: HashMap<i32, Vec<Vec<f32>>> = HashMap::new();
|
||||
for (tid, mut faces) in face_track_faces_raw {
|
||||
faces.sort_by_key(|(frame, _)| *frame);
|
||||
let n = faces.len();
|
||||
let indices = if n <= 3 {
|
||||
(0..n).collect::<Vec<_>>()
|
||||
} else {
|
||||
vec![0, n / 2, n - 1]
|
||||
};
|
||||
let samples: Vec<Vec<f32>> = indices.iter().map(|&i| faces[i].1.clone()).collect();
|
||||
face_track_samples.insert(tid, samples);
|
||||
}
|
||||
// Step 5: Progressive multi-round matching with derived seeds
|
||||
// Each round: choose a face with best seed sim for matching; separately,
|
||||
// collect the highest-confidence face per trace for building derived seeds.
|
||||
const TH_MIN: f32 = 0.35;
|
||||
const DERIVED_CONF: f64 = 0.90;
|
||||
const MAX_DERIVED_PER_ID: usize = 9;
|
||||
const MAX_FACES_PER_TRACE: usize = 3;
|
||||
const ANGLE_SIM_THRESHOLD: f32 = 0.90;
|
||||
const TH_STRANGER: f32 = 0.40;
|
||||
|
||||
let total_traces = face_track_samples.len();
|
||||
let sample_count: usize = face_track_samples.values().map(|v| v.len()).sum();
|
||||
let total_traces = trace_faces.len();
|
||||
let total_embeddings: usize = trace_faces.values().map(|v| v.len()).sum();
|
||||
tracing::info!(
|
||||
"[FaceMatch-Qdrant] Loaded {} traces, sampled {} embeddings",
|
||||
"[FaceMatch] Loaded {} traces ({} face embeddings) from Qdrant for {}",
|
||||
total_traces,
|
||||
sample_count
|
||||
total_embeddings,
|
||||
file_uuid
|
||||
);
|
||||
|
||||
// Step 3: Match against TMDb seeds
|
||||
const TH: f32 = 0.50;
|
||||
let tmdb_seeds: Vec<(i32, String, Vec<f32>)> = tmdb_rows;
|
||||
let mut matched: HashMap<i32, String> = HashMap::new();
|
||||
let mut matched: HashMap<i32, (String, i32)> = HashMap::new();
|
||||
let mut trace_face_count: HashMap<i32, usize> = HashMap::new();
|
||||
|
||||
for (&tid, samples) in &face_track_samples {
|
||||
let mut best_name = String::new();
|
||||
let mut best_sim = 0.0f32;
|
||||
for (_, ref name, ref tmdb_emb) in &tmdb_seeds {
|
||||
for face_emb in samples {
|
||||
let s = cosine_similarity(face_emb, tmdb_emb);
|
||||
if s > best_sim {
|
||||
best_sim = s;
|
||||
best_name = name.clone();
|
||||
}
|
||||
}
|
||||
// All reference embeddings: start with original TMDb seeds
|
||||
let mut all_refs: Vec<(String, String, Vec<f32>)> = seeds.clone();
|
||||
let thresholds = [0.55f32, 0.50, 0.45, 0.40, 0.35];
|
||||
let mut prev_total = 0usize;
|
||||
|
||||
for (round_idx, &th) in thresholds.iter().enumerate() {
|
||||
if th < TH_MIN {
|
||||
break;
|
||||
}
|
||||
if best_sim >= TH {
|
||||
matched.insert(tid, best_name);
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"[FaceMatch-Qdrant] Round 1: matched {} traces (threshold={})",
|
||||
matched.len(),
|
||||
TH
|
||||
);
|
||||
|
||||
// Round 2+: Propagate
|
||||
let mut round = 2;
|
||||
while matched.len() < face_track_samples.len() {
|
||||
let prev_count = matched.len();
|
||||
let mut new_matches: HashMap<i32, (String, i32)> = HashMap::new();
|
||||
let mut seed_candidates: Vec<(i32, String, i32, Vec<f32>, f64)> = Vec::new();
|
||||
|
||||
// Collect new matches in separate HashMap
|
||||
let mut new_matches: HashMap<i32, String> = HashMap::new();
|
||||
|
||||
for (&tid, samples) in &face_track_samples {
|
||||
for (&tid, faces) in &trace_faces {
|
||||
if matched.contains_key(&tid) {
|
||||
continue;
|
||||
}
|
||||
trace_face_count.entry(tid).or_insert(faces.len());
|
||||
|
||||
for (matched_tid, matched_name) in &matched {
|
||||
if let Some(matched_embs) = face_track_samples.get(matched_tid) {
|
||||
for face_emb in samples {
|
||||
for ref_emb in matched_embs {
|
||||
let s = cosine_similarity(face_emb, ref_emb);
|
||||
if s >= TH {
|
||||
new_matches.insert(tid, matched_name.clone());
|
||||
break;
|
||||
let mut best_sim = 0.0f32;
|
||||
let mut best_name = String::new();
|
||||
let mut best_id = 0i32;
|
||||
// Collect all high-confidence faces in this trace for derived seeds
|
||||
let mut trace_candidates: Vec<(Vec<f32>, f64)> = Vec::new();
|
||||
|
||||
for (_, emb, conf) in faces {
|
||||
for (ref_uuid, ref_name, ref_emb) in &all_refs {
|
||||
let s = cosine_similarity(emb, ref_emb);
|
||||
if s > best_sim {
|
||||
best_sim = s;
|
||||
best_name = ref_name.clone();
|
||||
if let Some(id_str) = ref_uuid.strip_prefix("derived:") {
|
||||
if let Ok(parsed) = id_str.parse::<i32>() {
|
||||
best_id = parsed;
|
||||
}
|
||||
} else if let Some((id, _)) = seed_identity_map.get(ref_uuid) {
|
||||
best_id = *id;
|
||||
}
|
||||
}
|
||||
}
|
||||
if *conf >= DERIVED_CONF {
|
||||
trace_candidates.push((emb.clone(), *conf));
|
||||
}
|
||||
}
|
||||
|
||||
if best_sim >= th && best_id > 0 {
|
||||
new_matches.insert(tid, (best_name.clone(), best_id));
|
||||
|
||||
// Top MAX_FACES_PER_TRACE highest-confidence faces with angular diversity
|
||||
trace_candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
let mut selected: Vec<Vec<f32>> = Vec::new();
|
||||
for (emb, conf) in trace_candidates {
|
||||
if selected.len() >= MAX_FACES_PER_TRACE {
|
||||
break;
|
||||
}
|
||||
if selected.iter().any(|e| cosine_similarity(e, &emb) >= ANGLE_SIM_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
selected.push(emb.clone());
|
||||
seed_candidates.push((best_id, best_name.clone(), tid, emb, conf));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge new matches
|
||||
matched.extend(new_matches);
|
||||
|
||||
if matched.len() == prev_count {
|
||||
let new_count = new_matches.len();
|
||||
if new_count == 0 && round_idx > 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
matched.extend(new_matches);
|
||||
|
||||
// Build derived seeds: pick up to MAX_DERIVED_PER_ID per identity
|
||||
// (max MAX_FACES_PER_TRACE from each trace), sorted by confidence descending
|
||||
seed_candidates.sort_by(|a, b| b.4.partial_cmp(&a.4).unwrap());
|
||||
let mut per_id: HashMap<i32, usize> = HashMap::new();
|
||||
let mut trace_used_faces: HashMap<i32, usize> = HashMap::new();
|
||||
let mut added_seeds = 0usize;
|
||||
for (id, name, tid, emb, _) in &seed_candidates {
|
||||
let cnt = per_id.entry(*id).or_insert(0);
|
||||
if *cnt >= MAX_DERIVED_PER_ID {
|
||||
continue;
|
||||
}
|
||||
let trace_cnt = trace_used_faces.entry(*tid).or_insert(0);
|
||||
if *trace_cnt >= MAX_FACES_PER_TRACE {
|
||||
continue;
|
||||
}
|
||||
*trace_cnt += 1;
|
||||
*cnt += 1;
|
||||
all_refs.push((format!("derived:{}", id), name.clone(), emb.clone()));
|
||||
added_seeds += 1;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[FaceMatch-Qdrant] Round {}: matched {} total",
|
||||
round,
|
||||
matched.len()
|
||||
"[FaceMatch] Round {}: matched {}+{}={} total (TH={}, {} new derived seeds)",
|
||||
round_idx + 1,
|
||||
prev_total,
|
||||
new_count,
|
||||
matched.len(),
|
||||
th,
|
||||
added_seeds
|
||||
);
|
||||
round += 1;
|
||||
|
||||
prev_total = matched.len();
|
||||
}
|
||||
|
||||
// Update face_detections.identity_id AND tkg_nodes.properties (Phase 3)
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let nodes_table = schema::table_name("tkg_nodes");
|
||||
let id_table = schema::table_name("identities");
|
||||
let identities_map: HashMap<String, i32> = tmdb_seeds
|
||||
.iter()
|
||||
.map(|(id, name, _)| (name.clone(), *id))
|
||||
// Step 7: Stranger clustering for unmatched traces
|
||||
let unmatched_ids: Vec<i32> = trace_faces
|
||||
.keys()
|
||||
.filter(|tid| !matched.contains_key(tid))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
// Batch query identity names
|
||||
let identity_names: HashMap<i32, String> = sqlx::query_as::<_, (i32, String)>(&format!(
|
||||
"SELECT id, name FROM {} WHERE id = ANY($1)",
|
||||
id_table
|
||||
))
|
||||
.bind(identities_map.values().collect::<Vec<_>>())
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut stranger_map: HashMap<i32, String> = HashMap::new();
|
||||
let mut assigned_stranger: std::collections::HashSet<i32> = std::collections::HashSet::new();
|
||||
let mut stranger_count = 0usize;
|
||||
|
||||
let mut updated = 0usize;
|
||||
for (tid, name) in &matched {
|
||||
let identity_id = identities_map.get(name);
|
||||
if let Some(id) = identity_id {
|
||||
let rows = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_track_id = $3",
|
||||
fd_table
|
||||
))
|
||||
.bind(*id)
|
||||
.bind(file_uuid)
|
||||
.bind(*tid)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
updated += rows as usize;
|
||||
// Sort by face count descending (most reliable first)
|
||||
let mut sorted_unmatched: Vec<i32> = unmatched_ids.clone();
|
||||
sorted_unmatched.sort_by(|a, b| {
|
||||
trace_face_count
|
||||
.get(b)
|
||||
.unwrap_or(&0)
|
||||
.cmp(trace_face_count.get(a).unwrap_or(&0))
|
||||
});
|
||||
|
||||
// Phase 3: Also update TKG node
|
||||
let external_id = format!("face_track_{}", tid);
|
||||
let identity_name = identity_names.get(id);
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET properties = jsonb_set(\
|
||||
jsonb_set(properties, '{{identity_id}}', $1::jsonb, false),\
|
||||
'{{identity_name}}', $2::jsonb, false)\
|
||||
WHERE file_uuid = $3 AND node_type = 'face_track' AND external_id = $4",
|
||||
nodes_table
|
||||
))
|
||||
.bind(*id)
|
||||
.bind(identity_name.as_deref())
|
||||
.bind(file_uuid)
|
||||
.bind(&external_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
for &tid in &sorted_unmatched {
|
||||
if assigned_stranger.contains(&tid) {
|
||||
continue;
|
||||
}
|
||||
let centroid_a = if let Some(faces) = trace_faces.get(&tid) {
|
||||
average_embeddings(faces.iter().map(|(_, emb, _)| emb))
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
stranger_count += 1;
|
||||
let stranger_id = format!("{}:stranger_{}", file_uuid, stranger_count);
|
||||
assigned_stranger.insert(tid);
|
||||
stranger_map.insert(tid, stranger_id.clone());
|
||||
|
||||
for &other_tid in &sorted_unmatched {
|
||||
if assigned_stranger.contains(&other_tid) || other_tid == tid {
|
||||
continue;
|
||||
}
|
||||
if let Some(faces_b) = trace_faces.get(&other_tid) {
|
||||
let centroid_b = average_embeddings(faces_b.iter().map(|(_, emb, _)| emb));
|
||||
let s = cosine_similarity(¢roid_a, ¢roid_b);
|
||||
if s >= TH_STRANGER {
|
||||
assigned_stranger.insert(other_tid);
|
||||
stranger_map.insert(other_tid, stranger_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("[FaceMatch-Qdrant] Updated {} face_detections", updated);
|
||||
Ok(updated)
|
||||
let stranger_trace_count = stranger_map.len();
|
||||
tracing::info!(
|
||||
"[FaceMatch] Stranger clusters: {} groups, {} traces",
|
||||
stranger_count,
|
||||
stranger_trace_count
|
||||
);
|
||||
|
||||
// Step 8: Write results to TKG nodes + Qdrant payload + face_detections
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let nodes_table = schema::table_name("tkg_nodes");
|
||||
let mut pg_updated = 0usize;
|
||||
|
||||
// Clear old identity assignments before writing new ones
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1",
|
||||
fd_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
// 8a: Matched traces → identity_ref
|
||||
for (&tid, (name, identity_id)) in &matched {
|
||||
// Skip if identity_id is invalid (FK constraint would fail)
|
||||
if *identity_id <= 0 {
|
||||
tracing::warn!(
|
||||
"[FaceMatch] Skipping trace {}: invalid identity_id={}",
|
||||
tid, identity_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let identity_ref = format!("{}:{}", file_uuid, identity_id);
|
||||
|
||||
// TKG node
|
||||
let external_id = format!("face_track_{}", tid);
|
||||
if let Err(e) = sqlx::query(&format!(
|
||||
"UPDATE {} SET properties = jsonb_set(\
|
||||
jsonb_set(properties, '{{identity_ref}}', to_jsonb($1), true),\
|
||||
'{{identity_name}}', to_jsonb($2), true)\
|
||||
WHERE file_uuid = $3 AND node_type = 'face_track' AND external_id = $4",
|
||||
nodes_table
|
||||
))
|
||||
.bind(&identity_ref)
|
||||
.bind(name)
|
||||
.bind(file_uuid)
|
||||
.bind(&external_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[FaceMatch] TKG update failed for trace {}: {:?}", tid, e);
|
||||
}
|
||||
|
||||
// Qdrant payload
|
||||
let _ = face_db
|
||||
.update_identity_ref_by_trace(file_uuid, tid, &identity_ref)
|
||||
.await;
|
||||
|
||||
// PostgreSQL face_detections (backward compat)
|
||||
let rows = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND trace_id = $3",
|
||||
fd_table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(file_uuid)
|
||||
.bind(tid)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map(|r| r.rows_affected())
|
||||
.unwrap_or(0);
|
||||
pg_updated += rows as usize;
|
||||
}
|
||||
|
||||
// 8b: Stranger traces → stranger_ref
|
||||
for (&tid, stranger_ref) in &stranger_map {
|
||||
// TKG node
|
||||
let external_id = format!("face_track_{}", tid);
|
||||
if let Err(e) = sqlx::query(&format!(
|
||||
"UPDATE {} SET properties = jsonb_set(\
|
||||
properties, '{{stranger_ref}}', to_jsonb($1), true)\
|
||||
WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3",
|
||||
nodes_table
|
||||
))
|
||||
.bind(stranger_ref)
|
||||
.bind(file_uuid)
|
||||
.bind(&external_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[FaceMatch] TKG stranger update failed for trace {}: {:?}", tid, e);
|
||||
}
|
||||
|
||||
// Qdrant payload
|
||||
let _ = face_db
|
||||
.update_stranger_ref_by_trace(file_uuid, tid, stranger_ref)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[FaceMatch] Done: {} matched, {} strangers — {} face_detections updated",
|
||||
matched.len(),
|
||||
stranger_trace_count,
|
||||
pg_updated
|
||||
);
|
||||
Ok(pg_updated)
|
||||
}
|
||||
|
||||
/// Fallback: PostgreSQL-based matching (original implementation)
|
||||
@@ -1312,3 +1495,220 @@ pub async fn run_identity_agent(db: &PostgresDb, file_uuid: &str) -> anyhow::Res
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// API handler: POST /api/v1/agents/identity/generate-seeds
|
||||
async fn generate_seeds_handler(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let db = &state.db;
|
||||
let pool = db.pool();
|
||||
|
||||
let count = generate_seed_embeddings(db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": format!("{}", e)})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Auto-trigger identity agent for all ready files
|
||||
if count > 0 {
|
||||
let ready_files = find_ready_files(pool).await.unwrap_or_default();
|
||||
if !ready_files.is_empty() {
|
||||
tracing::info!(
|
||||
"[GenerateSeeds] Auto-triggering identity agent for {} files: {:?}",
|
||||
ready_files.len(),
|
||||
ready_files
|
||||
);
|
||||
for file_uuid in &ready_files {
|
||||
let db = state.db.clone();
|
||||
let fid = file_uuid.clone();
|
||||
tokio::spawn(async move {
|
||||
match run_identity_agent(&db, &fid).await {
|
||||
Ok(_) => tracing::info!(
|
||||
"[GenerateSeeds] Identity agent completed for {}",
|
||||
fid
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"[GenerateSeeds] Identity agent failed for {}: {}",
|
||||
fid,
|
||||
e
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": format!("Generated {} seed embeddings", count),
|
||||
"count": count
|
||||
})))
|
||||
}
|
||||
|
||||
/// Find videos that are ready for identity processing (have face embeddings).
|
||||
async fn find_ready_files(pool: &sqlx::PgPool) -> anyhow::Result<Vec<String>> {
|
||||
let fd_table = crate::core::db::schema::table_name("face_detections");
|
||||
let rows: Vec<(String,)> = sqlx::query_as(&format!(
|
||||
"SELECT DISTINCT file_uuid FROM {} WHERE embedding IS NOT NULL AND identity_id IS NULL",
|
||||
fd_table
|
||||
))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|r| r.0).collect())
|
||||
}
|
||||
|
||||
/// API handler: POST /api/v1/agents/identity/run
|
||||
async fn run_identity_handler(
|
||||
State(state): State<AppState>,
|
||||
axum::Json(body): axum::Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let file_uuid = body
|
||||
.get("file_uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"success": false, "message": "file_uuid required"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
match run_identity_agent(&state.db, file_uuid).await {
|
||||
Ok(()) => Ok(Json(serde_json::json!({
|
||||
"success": true,
|
||||
"message": format!("Identity agent completed for {}", file_uuid),
|
||||
}))),
|
||||
Err(e) => Ok(Json(serde_json::json!({
|
||||
"success": false,
|
||||
"message": format!("Identity agent failed: {}", e),
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read all TMDb identities with profile photos, extract face embeddings, store in Qdrant as seeds.
|
||||
pub async fn generate_seed_embeddings(db: &PostgresDb) -> anyhow::Result<usize> {
|
||||
use crate::core::db::face_embedding_db::FaceEmbeddingDb;
|
||||
use std::path::Path;
|
||||
|
||||
let pool = db.pool();
|
||||
let id_table = schema::table_name("identities");
|
||||
|
||||
let rows = sqlx::query_as::<_, (i32, String, String, i32, String)>(&format!(
|
||||
"SELECT id, name, uuid::text, tmdb_id, tmdb_profile FROM {} \
|
||||
WHERE source='tmdb' AND tmdb_profile IS NOT NULL",
|
||||
id_table
|
||||
))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
tracing::warn!("[GenerateSeeds] No TMDb identities with profile photos");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let scripts_dir = std::env::var("MOMENTRY_SCRIPTS_DIR")
|
||||
.unwrap_or_else(|_| "/Users/accusys/momentry_core_0.1/scripts".to_string());
|
||||
let python_path = std::env::var("MOMENTRY_PYTHON_PATH")
|
||||
.unwrap_or_else(|_| "/opt/homebrew/bin/python3.11".to_string());
|
||||
|
||||
let extract_script = Path::new(&scripts_dir).join("extract_face_embedding.py");
|
||||
let face_db = FaceEmbeddingDb::new();
|
||||
|
||||
let mut success = 0usize;
|
||||
for (id, name, uuid, tmdb_id, profile_url) in &rows {
|
||||
tracing::info!("[GenerateSeeds] Processing {} ({})", name, uuid);
|
||||
|
||||
// Download profile image
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
let resp = client.get(profile_url).send().await;
|
||||
let image_bytes = match resp {
|
||||
Ok(r) if r.status().is_success() => r.bytes().await.unwrap_or_default(),
|
||||
_ => {
|
||||
tracing::warn!("[GenerateSeeds] Failed to download: {} from {}", name, profile_url);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if image_bytes.is_empty() {
|
||||
tracing::warn!("[GenerateSeeds] Empty image for {}", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Save to temp file
|
||||
let temp_dir = std::env::temp_dir().join("momentry_seed_faces");
|
||||
std::fs::create_dir_all(&temp_dir)?;
|
||||
let temp_img = temp_dir.join(format!("{}.jpg", uuid));
|
||||
std::fs::write(&temp_img, &image_bytes)?;
|
||||
|
||||
// Extract embedding with timeout
|
||||
use tokio::time::timeout;
|
||||
let output = timeout(
|
||||
std::time::Duration::from_secs(180),
|
||||
tokio::process::Command::new(&python_path)
|
||||
.arg(&extract_script)
|
||||
.arg(&temp_img)
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Extract embedding timed out for {}", name))??;
|
||||
|
||||
let _ = std::fs::remove_file(&temp_img);
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::warn!(
|
||||
"[GenerateSeeds] Extraction failed for {}: {}",
|
||||
name,
|
||||
stderr.trim()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let extract_result: serde_json::Value = match serde_json::from_str(&stdout) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("[GenerateSeeds] Parse error for {}: {}", name, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let embedding: Vec<f64> = match serde_json::from_value(
|
||||
extract_result.get("embedding").ok_or_else(|| anyhow::anyhow!("No embedding"))?.clone(),
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("[GenerateSeeds] Embedding format error for {}: {}", name, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let embedding_f32: Vec<f32> = embedding.into_iter().map(|v| v as f32).collect();
|
||||
|
||||
// Store in Qdrant
|
||||
match face_db
|
||||
.upsert_seed_embedding(uuid, name, *tmdb_id, &embedding_f32)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
success += 1;
|
||||
tracing::info!("[GenerateSeeds] Stored seed for {}", name);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[GenerateSeeds] Qdrant error for {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[GenerateSeeds] Done: {}/{} seeds generated",
|
||||
success,
|
||||
rows.len()
|
||||
);
|
||||
Ok(success)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::core::db::ResourceRecord;
|
||||
|
||||
@@ -45,6 +46,10 @@ pub fn identity_routes() -> Router<crate::api::types::AppState> {
|
||||
"/api/v1/identity/:identity_uuid/profile-image",
|
||||
post(upload_profile_image).get(get_profile_image),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/identity/:identity_uuid/profile-image/from-face",
|
||||
post(set_profile_from_face),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/identity/:identity_uuid/status",
|
||||
get(get_identity_status),
|
||||
@@ -1279,6 +1284,163 @@ async fn get_profile_image(
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetProfileFromFaceRequest {
|
||||
pub file_uuid: String,
|
||||
pub face_id: Option<String>,
|
||||
pub id: Option<i64>,
|
||||
}
|
||||
|
||||
async fn set_profile_from_face(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path(identity_uuid): Path<String>,
|
||||
Json(req): Json<SetProfileFromFaceRequest>,
|
||||
) -> Result<Json<ProfileImageResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
use crate::core::db::schema;
|
||||
let fd_table = schema::table_name("face_detections");
|
||||
let videos_table = schema::table_name("videos");
|
||||
|
||||
let uuid_clean = identity_uuid.replace('-', "");
|
||||
|
||||
let face_identifier = match (&req.face_id, req.id) {
|
||||
(Some(fid), _) => fid.clone(),
|
||||
(None, Some(id)) => id.to_string(),
|
||||
(None, None) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"success": false, "message": "Either face_id or id is required"})),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let use_id_field = req.id.is_some();
|
||||
|
||||
let row: Option<(i64, i32, i32, i32, i32, f64)> = if use_id_field {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND id = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(&format!(
|
||||
"SELECT frame_number, x, y, width, height, confidence FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": format!("DB error: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let (frame_number, x, y, width, height, confidence) = row.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"success": false, "message": "Face not found"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let video_row: Option<(String, Option<i32>, Option<i32>)> = sqlx::query_as(&format!(
|
||||
"SELECT file_path, width, height FROM {} WHERE file_uuid = $1",
|
||||
videos_table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": format!("DB error: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let (file_path, video_width, video_height) = video_row.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"success": false, "message": "Video file not found"})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let vw = video_width.unwrap_or(1920);
|
||||
let vh = video_height.unwrap_or(1080);
|
||||
|
||||
crate::core::thumbnail::validator::validate_crop(x, y, width, height, vw, vh).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"success": false, "message": format!("Crop validation failed: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let select = format!("select=eq(n\\,{})", frame_number);
|
||||
let vf = format!("{},crop={}:{}:{}:{}", select, width, height, x, y);
|
||||
|
||||
let output = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
&file_path,
|
||||
"-vf",
|
||||
&vf,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"mjpeg",
|
||||
"-",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": format!("FFmpeg failed: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": "FFmpeg extraction failed"})),
|
||||
));
|
||||
}
|
||||
|
||||
crate::core::thumbnail::validator::validate_jpeg(&output.stdout).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"success": false, "message": format!("JPEG validation failed: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let dir = crate::core::identity::storage::identity_dir(&uuid_clean);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("Failed to create dir: {}", e)})))
|
||||
})?;
|
||||
|
||||
let file_name = "profile.jpg";
|
||||
let file_path = dir.join(file_name);
|
||||
std::fs::write(&file_path, &output.stdout).map_err(|e| {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"success": false, "message": format!("Failed to write file: {}", e)})))
|
||||
})?;
|
||||
|
||||
let pool = state.db.pool().clone();
|
||||
let uuid_clone = uuid_clean.clone();
|
||||
let _ = crate::core::identity::storage::save_identity_file_by_pool(&pool, &uuid_clone).await;
|
||||
|
||||
Ok(Json(ProfileImageResponse {
|
||||
success: true,
|
||||
identity_uuid: uuid_clean,
|
||||
path: file_path.to_string_lossy().to_string(),
|
||||
message: format!("Profile image set from face {} (frame {}, confidence {:.2})", face_identifier, frame_number, confidence),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_identity_json(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Path(identity_uuid): Path<String>,
|
||||
|
||||
@@ -93,15 +93,38 @@ pub async fn bind_identity(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Capture old identity_id before bind
|
||||
let old_identity_id: Option<i32> = sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&req.face_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
let face_identifier = match (&req.face_id, req.id) {
|
||||
(Some(fid), _) => fid.clone(),
|
||||
(None, Some(id)) => id.to_string(),
|
||||
(None, None) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Either face_id or id is required"})),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let use_id_field = req.id.is_some();
|
||||
|
||||
let old_identity_id: Option<i32> = if use_id_field {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -110,16 +133,27 @@ pub async fn bind_identity(
|
||||
})?
|
||||
.flatten();
|
||||
|
||||
// Direct UPDATE face_detections.identity_id
|
||||
let result = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3",
|
||||
table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&req.face_id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
let result = if use_id_field {
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND id = $3",
|
||||
table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND face_id = $3",
|
||||
table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -127,6 +161,67 @@ pub async fn bind_identity(
|
||||
)
|
||||
})?;
|
||||
|
||||
let trace_id: Option<i32> = if use_id_field {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND id = $2 LIMIT 1",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2 LIMIT 1",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?
|
||||
.flatten();
|
||||
|
||||
// Update Qdrant + TKG if trace_id exists
|
||||
if let Some(tid) = trace_id {
|
||||
// 1. Update Qdrant payload
|
||||
let face_db = crate::core::db::FaceEmbeddingDb::new();
|
||||
if let Err(e) = face_db
|
||||
.update_identity_by_trace(&req.file_uuid, tid, &uuid_clean)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[bind] Failed to update Qdrant identity_uuid for trace {}: {}",
|
||||
tid, e
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Update TKG face_track node (dual-field design)
|
||||
let tkg_table = crate::core::db::schema::table_name("tkg_nodes");
|
||||
let ext_id = format!("face_track_{}", tid);
|
||||
let identity_ref = format!("{}:identity_{}", req.file_uuid, identity_id);
|
||||
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET properties = properties || $1::jsonb - 'stranger_ref' \
|
||||
WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3",
|
||||
tkg_table
|
||||
))
|
||||
.bind(serde_json::json!({
|
||||
"identity_uuid": uuid_clean,
|
||||
"identity_ref": identity_ref
|
||||
}))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&ext_id)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
}
|
||||
|
||||
// Clear bind redo stack
|
||||
let _ = sqlx::query(&format!(
|
||||
"DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')",
|
||||
@@ -144,10 +239,10 @@ pub async fn bind_identity(
|
||||
crate::api::middleware::AuthSource::ApiKey => "api_key",
|
||||
};
|
||||
let before = serde_json::json!({
|
||||
"file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_before": old_identity_id
|
||||
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_before": old_identity_id
|
||||
});
|
||||
let after = serde_json::json!({
|
||||
"file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_after": identity_id
|
||||
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_after": identity_id
|
||||
});
|
||||
let _ = sqlx::query(&format!(
|
||||
"INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'bind', $2, $3, false, $4, $5)",
|
||||
@@ -161,7 +256,6 @@ pub async fn bind_identity(
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
|
||||
// Sync identity JSON file
|
||||
if let Err(e) =
|
||||
crate::core::identity::storage::save_identity_file_by_pool(state.db.pool(), &uuid_clean)
|
||||
.await
|
||||
@@ -177,7 +271,7 @@ pub async fn bind_identity(
|
||||
success: true,
|
||||
message: format!(
|
||||
"Bound face {} of {} to {}",
|
||||
req.face_id, req.file_uuid, name
|
||||
face_identifier, req.file_uuid, name
|
||||
),
|
||||
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
|
||||
}))
|
||||
@@ -193,15 +287,38 @@ pub async fn unbind_identity(
|
||||
let id_table = crate::core::db::schema::table_name("identities");
|
||||
let history_table = crate::core::db::schema::table_name("identity_history");
|
||||
|
||||
// Capture old identity_id before unbind
|
||||
let old_identity_id: Option<i32> = sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&req.face_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
let face_identifier = match (&req.face_id, req.id) {
|
||||
(Some(fid), _) => fid.clone(),
|
||||
(None, Some(id)) => id.to_string(),
|
||||
(None, None) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Either face_id or id is required"})),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let use_id_field = req.id.is_some();
|
||||
|
||||
let old_identity_id: Option<i32> = if use_id_field {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT identity_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -210,14 +327,25 @@ pub async fn unbind_identity(
|
||||
})?
|
||||
.flatten();
|
||||
|
||||
let result = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&req.face_id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
let result = if use_id_field {
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = NULL WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -225,15 +353,85 @@ pub async fn unbind_identity(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Phase 2.3: Also update TKG node (find face_track_id first)
|
||||
let trace_id_opt: Option<i32> = sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&req.face_id)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
let trace_id: Option<i32> = if use_id_field {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND id = $2 LIMIT 1",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2 LIMIT 1",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?
|
||||
.flatten();
|
||||
|
||||
// Clear Qdrant + TKG if trace_id exists
|
||||
if let Some(tid) = trace_id {
|
||||
// 1. Clear Qdrant payload
|
||||
let face_db = crate::core::db::FaceEmbeddingDb::new();
|
||||
if let Err(e) = face_db
|
||||
.clear_identity_by_trace(&req.file_uuid, tid)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[unbind] Failed to clear Qdrant identity_uuid for trace {}: {}",
|
||||
tid, e
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Update TKG face_track node (restore stranger_ref)
|
||||
let tkg_table = crate::core::db::schema::table_name("tkg_nodes");
|
||||
let ext_id = format!("face_track_{}", tid);
|
||||
let stranger_ref = format!("{}:stranger_trace_{}", req.file_uuid, tid);
|
||||
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET properties = properties || $1::jsonb - 'identity_uuid' - 'identity_ref' \
|
||||
WHERE file_uuid = $2 AND node_type = 'face_track' AND external_id = $3",
|
||||
tkg_table
|
||||
))
|
||||
.bind(serde_json::json!({
|
||||
"stranger_ref": stranger_ref
|
||||
}))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&ext_id)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
}
|
||||
|
||||
let trace_id_opt: Option<i32> = if use_id_field {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(req.id.unwrap())
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(&format!(
|
||||
"SELECT trace_id FROM {} WHERE file_uuid = $1 AND face_id = $2",
|
||||
table
|
||||
))
|
||||
.bind(&req.file_uuid)
|
||||
.bind(&face_identifier)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
}
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
@@ -251,9 +449,7 @@ pub async fn unbind_identity(
|
||||
.await;
|
||||
}
|
||||
|
||||
// Record history if there was a binding
|
||||
if let Some(identity_id) = old_identity_id {
|
||||
// Clear bind redo stack
|
||||
let _ = sqlx::query(&format!(
|
||||
"DELETE FROM {} WHERE identity_id = $1 AND is_undone = true AND operation IN ('bind','unbind','bind_trace')",
|
||||
history_table
|
||||
@@ -262,7 +458,6 @@ pub async fn unbind_identity(
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
|
||||
// Insert history record
|
||||
let uid = auth.user_id.to_string();
|
||||
let usrc = match auth.source {
|
||||
crate::api::middleware::AuthSource::Jwt => "jwt",
|
||||
@@ -270,10 +465,10 @@ pub async fn unbind_identity(
|
||||
crate::api::middleware::AuthSource::ApiKey => "api_key",
|
||||
};
|
||||
let before = serde_json::json!({
|
||||
"file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_before": old_identity_id
|
||||
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_before": old_identity_id
|
||||
});
|
||||
let after = serde_json::json!({
|
||||
"file_uuid": req.file_uuid, "face_id": req.face_id, "identity_id_after": null
|
||||
"file_uuid": req.file_uuid, "face_id": face_identifier, "identity_id_after": null
|
||||
});
|
||||
let _ = sqlx::query(&format!(
|
||||
"INSERT INTO {} (identity_id, operation, before_snapshot, after_snapshot, is_undone, user_id, user_source) VALUES ($1, 'unbind', $2, $3, false, $4, $5)",
|
||||
@@ -315,7 +510,7 @@ pub async fn unbind_identity(
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: format!("Unbound face {} from {}", req.face_id, req.file_uuid),
|
||||
message: format!("Unbound face {} from {}", face_identifier, req.file_uuid),
|
||||
data: Some(serde_json::json!({"rows_affected": result.rows_affected()})),
|
||||
}))
|
||||
}
|
||||
@@ -933,14 +1128,14 @@ pub async fn get_identity_traces(
|
||||
COUNT(*)::bigint AS frame_count,
|
||||
MIN(fd.frame_number)::int AS first_frame,
|
||||
MAX(fd.frame_number)::int AS last_frame,
|
||||
ROUND(MIN(fd.frame_number)::numeric / NULLIF(v.fps, 0)::numeric, 1)::float8 AS first_sec,
|
||||
ROUND(MAX(fd.frame_number)::numeric / NULLIF(v.fps, 0)::numeric, 1)::float8 AS last_sec,
|
||||
COALESCE(ROUND(MIN(fd.frame_number)::numeric / NULLIF(v.fps, 0)::numeric, 1), 0)::float8 AS first_sec,
|
||||
COALESCE(ROUND(MAX(fd.frame_number)::numeric / NULLIF(v.fps, 0)::numeric, 1), 0)::float8 AS last_sec,
|
||||
ROUND(AVG(fd.confidence)::numeric, 4)::float8 AS avg_confidence
|
||||
FROM {} fd
|
||||
LEFT JOIN dev.videos v ON fd.file_uuid = v.file_uuid
|
||||
WHERE fd.identity_id = $1
|
||||
GROUP BY trace_id, v.fps
|
||||
ORDER BY trace_id
|
||||
LEFT JOIN videos v ON fd.file_uuid = v.file_uuid
|
||||
WHERE fd.identity_id = $1 AND fd.trace_id IS NOT NULL
|
||||
GROUP BY fd.file_uuid, fd.trace_id, v.fps
|
||||
ORDER BY fd.trace_id
|
||||
LIMIT $2 OFFSET $3"#,
|
||||
fd_table
|
||||
))
|
||||
@@ -953,7 +1148,7 @@ pub async fn get_identity_traces(
|
||||
|
||||
// Get total count for pagination
|
||||
let total: (i64,) = sqlx::query_as(&format!(
|
||||
"SELECT COUNT(*) FROM (SELECT 1 FROM {} fd WHERE trace_id) sub",
|
||||
"SELECT COUNT(*) FROM (SELECT 1 FROM {} fd WHERE fd.identity_id = $1 AND fd.trace_id IS NOT NULL GROUP BY fd.trace_id) sub",
|
||||
fd_table
|
||||
))
|
||||
.bind(identity_id)
|
||||
@@ -1864,6 +2059,188 @@ pub async fn bind_history(
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pending Person API (file-scoped)
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreatePendingPersonRequest {
|
||||
#[serde(default)]
|
||||
pub trace_ids: Vec<i32>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingPersonItem {
|
||||
pub identity_uuid: String,
|
||||
pub identity_id: i32,
|
||||
pub name: String,
|
||||
pub created_at: String,
|
||||
pub trace_count: i64,
|
||||
pub bound_traces: Option<Vec<i32>>,
|
||||
}
|
||||
|
||||
/// Create a pending person under a file, optionally binding traces.
|
||||
pub async fn create_pending_person(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Extension(_auth): Extension<crate::api::middleware::UserAuth>,
|
||||
Path(file_uuid): Path<String>,
|
||||
Json(req): Json<CreatePendingPersonRequest>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let id_table = crate::core::db::schema::table_name("identities");
|
||||
let fd_table = crate::core::db::schema::table_name("face_detections");
|
||||
let nodes_table = crate::core::db::schema::table_name("tkg_nodes");
|
||||
|
||||
// Auto-generate name if not provided
|
||||
let name = if let Some(n) = &req.name {
|
||||
n.clone()
|
||||
} else {
|
||||
let count: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(*) FROM {} WHERE file_uuid = $1 AND status = 'pending'",
|
||||
id_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
format!("Person {}", count + 1)
|
||||
};
|
||||
|
||||
// Create identity with pending status
|
||||
let identity_row: (i32, String) = sqlx::query_as(&format!(
|
||||
"INSERT INTO {} (name, identity_type, source, status, file_uuid) VALUES ($1, 'people', 'manual', 'pending', $2) RETURNING id, uuid::text",
|
||||
id_table
|
||||
))
|
||||
.bind(&name)
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Failed to create identity: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let (identity_id, identity_uuid): (i32, String) = identity_row;
|
||||
|
||||
// Bind traces if provided
|
||||
let bound_traces = if !req.trace_ids.is_empty() {
|
||||
// Update face_detections
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET identity_id = $1 WHERE file_uuid = $2 AND trace_id = ANY($3)",
|
||||
fd_table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(&file_uuid)
|
||||
.bind(&req.trace_ids)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Failed to bind traces: {}", e)})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Update TKG nodes
|
||||
for &tid in &req.trace_ids {
|
||||
let external_id = format!("face_track_{}", tid);
|
||||
let _ = sqlx::query(&format!(
|
||||
"UPDATE {} SET properties = jsonb_set(\
|
||||
jsonb_set(properties, '{{identity_id}}', $1::jsonb, false),\
|
||||
'{{identity_name}}', $2::jsonb, false)\
|
||||
WHERE file_uuid = $3 AND node_type = 'face_track' AND external_id = $4",
|
||||
nodes_table
|
||||
))
|
||||
.bind(identity_id)
|
||||
.bind(&name)
|
||||
.bind(&file_uuid)
|
||||
.bind(&external_id)
|
||||
.execute(state.db.pool())
|
||||
.await;
|
||||
}
|
||||
Some(req.trace_ids.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Sync identity file
|
||||
let _ = crate::core::identity::storage::save_identity_file_by_pool(
|
||||
state.db.pool(),
|
||||
&identity_uuid,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: format!("Created pending person: {} (uuid: {})", name, identity_uuid),
|
||||
data: Some(serde_json::json!({
|
||||
"identity_uuid": identity_uuid,
|
||||
"identity_id": identity_id,
|
||||
"name": name,
|
||||
"bound_traces": bound_traces.map(|v| v.len()).unwrap_or(0),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
/// List pending persons for a file.
|
||||
pub async fn list_pending_persons(
|
||||
State(state): State<crate::api::types::AppState>,
|
||||
Extension(_auth): Extension<crate::api::middleware::UserAuth>,
|
||||
Path(file_uuid): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Vec<PendingPersonItem>>>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let id_table = crate::core::db::schema::table_name("identities");
|
||||
let fd_table = crate::core::db::schema::table_name("face_detections");
|
||||
|
||||
let rows: Vec<(i32, String, String, chrono::NaiveDateTime)> = sqlx::query_as(&format!(
|
||||
"SELECT id, uuid::text, name, created_at FROM {} WHERE file_uuid = $1 AND status = 'pending' ORDER BY created_at DESC",
|
||||
id_table
|
||||
))
|
||||
.bind(&file_uuid)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut items = Vec::new();
|
||||
for (id, uuid, name, created_at) in rows {
|
||||
let trace_count: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(DISTINCT trace_id) FROM {} WHERE identity_id = $1 AND file_uuid = $2",
|
||||
fd_table
|
||||
))
|
||||
.bind(id)
|
||||
.bind(&file_uuid)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
items.push(PendingPersonItem {
|
||||
identity_uuid: uuid,
|
||||
identity_id: id,
|
||||
name,
|
||||
created_at: created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
trace_count,
|
||||
bound_traces: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
message: format!("Found {} pending persons for {}", items.len(), file_uuid),
|
||||
data: Some(items),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn identity_binding_routes() -> Router<crate::api::types::AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/identity/:identity_uuid/bind", post(bind_identity))
|
||||
@@ -1892,4 +2269,12 @@ pub fn identity_binding_routes() -> Router<crate::api::types::AppState> {
|
||||
.route("/api/v1/identity/merge/:merge_id/undo", post(undo_merge))
|
||||
.route("/api/v1/identity/merge/:merge_id/redo", post(redo_merge))
|
||||
.route("/api/v1/identity/merge/history", get(get_merge_history))
|
||||
.route(
|
||||
"/api/v1/file/:file_uuid/pending-person",
|
||||
post(create_pending_person),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/file/:file_uuid/pending-persons",
|
||||
get(list_pending_persons),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ struct JobDetailResponse {
|
||||
created_at: String,
|
||||
started_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
queue_position: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -286,6 +287,31 @@ async fn trigger_processing(
|
||||
tracing::error!("[TRIGGER] Failed to update monitor job for {}: {}", file_uuid, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Update videos.processing_status to PROCESSING immediately
|
||||
let processor_names_upper: Vec<String> = processors_to_run.iter().map(|p| p.to_uppercase()).collect();
|
||||
let progress: serde_json::Map<String, serde_json::Value> = processors_to_run.iter().map(|p| {
|
||||
(p.to_uppercase(), serde_json::json!({
|
||||
"current_frame": 0, "total_frames": 0, "percentage": 0, "status": "pending"
|
||||
}))
|
||||
}).collect();
|
||||
let status = serde_json::json!({
|
||||
"phase": "PROCESSING",
|
||||
"active_processors": processor_names_upper,
|
||||
"total_frames": 0,
|
||||
"progress": progress
|
||||
});
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {videos_table} SET processing_status = $1, updated_at = CURRENT_TIMESTAMP WHERE file_uuid = $2"
|
||||
))
|
||||
.bind(&status)
|
||||
.bind(&file_uuid)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("[TRIGGER] Failed to update processing status for {}: {}", file_uuid, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let processors_to_run_refs: Vec<&str> = processors_to_run.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
@@ -531,6 +557,21 @@ async fn get_job(Path(uuid): Path<String>) -> Result<Json<JobDetailResponse>, St
|
||||
started_at,
|
||||
updated_at,
|
||||
) = job.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Calculate queue position if status is 'pending'
|
||||
let queue_position = if status == "pending" {
|
||||
sqlx::query_scalar::<_, i64>(&format!(
|
||||
"SELECT COUNT(*) + 1 FROM {} WHERE status = 'pending' AND created_at < (SELECT created_at FROM {} WHERE uuid = $1)",
|
||||
jobs_table, jobs_table
|
||||
))
|
||||
.bind(&uuid)
|
||||
.fetch_one(pg.pool())
|
||||
.await
|
||||
.ok()
|
||||
.map(|pos| pos as i32)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Json(JobDetailResponse {
|
||||
id,
|
||||
@@ -543,6 +584,7 @@ async fn get_job(Path(uuid): Path<String>) -> Result<Json<JobDetailResponse>, St
|
||||
created_at,
|
||||
started_at,
|
||||
updated_at,
|
||||
queue_position,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -655,28 +697,27 @@ async fn get_processor_counts(
|
||||
}
|
||||
|
||||
if let Ok(content) = std::fs::read_to_string(&json_path) {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
// CUT: prioritize scenes count over frame_count
|
||||
if proc_name == "cut" {
|
||||
frame_count = json
|
||||
.get("scenes")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32);
|
||||
} else {
|
||||
// Standard frame_count field
|
||||
frame_count = json
|
||||
.get("frame_count")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
|
||||
// YOLO: frames array
|
||||
if frame_count.is_none() {
|
||||
frame_count = json
|
||||
.get("frames")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32);
|
||||
}
|
||||
}
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
// CUT: prioritize scenes count over frame_count
|
||||
if proc_name == "cut" {
|
||||
frame_count = json
|
||||
.get("scenes")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len() as u32);
|
||||
} else if proc_name == "yolo" {
|
||||
// YOLO: use metadata.total_frames (avoids parsing huge frames array)
|
||||
frame_count = json
|
||||
.get("metadata")
|
||||
.and_then(|m| m.get("total_frames"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
} else {
|
||||
// Standard frame_count field
|
||||
frame_count = json
|
||||
.get("frame_count")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
}
|
||||
|
||||
segment_count = json
|
||||
.get("segments")
|
||||
@@ -738,6 +779,7 @@ pub fn processing_routes() -> Router<AppState> {
|
||||
)
|
||||
.route("/api/v1/progress/:file_uuid", post(get_progress))
|
||||
.route("/api/v1/jobs", post(list_jobs))
|
||||
.route("/api/v1/job/:uuid", get(get_job))
|
||||
.route("/api/v1/config/cache", post(cache_toggle))
|
||||
.route("/api/v1/config/auto-pipeline", post(auto_pipeline_toggle))
|
||||
.route(
|
||||
|
||||
@@ -23,6 +23,14 @@ pub struct FaceEmbeddingPayload {
|
||||
pub yaw: f64,
|
||||
pub pitch: f64,
|
||||
pub roll: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub identity_uuid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub identity_ref: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stranger_ref: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "type")]
|
||||
pub r#type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -166,13 +174,117 @@ impl FaceEmbeddingDb {
|
||||
.context("Failed to batch upsert face embeddings")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Qdrant batch upsert failed: {}", text);
|
||||
anyhow::bail!("Qdrant batch upsert failed (HTTP {}): {}", status, text);
|
||||
}
|
||||
|
||||
Ok(points.len())
|
||||
}
|
||||
|
||||
pub async fn update_identity_by_trace(
|
||||
&self,
|
||||
file_uuid: &str,
|
||||
trace_id: i32,
|
||||
identity_uuid: &str,
|
||||
) -> Result<usize> {
|
||||
let url = format!(
|
||||
"{}/collections/{}/points",
|
||||
self.base_url, self.collection_name
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"filter": {
|
||||
"must": [
|
||||
{
|
||||
"key": "file_uuid",
|
||||
"match": { "value": file_uuid }
|
||||
},
|
||||
{
|
||||
"key": "trace_id",
|
||||
"match": { "value": trace_id }
|
||||
}
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"identity_uuid": identity_uuid
|
||||
}
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("api-key", &self.api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to update identity_uuid in Qdrant")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Qdrant identity update failed: {}", text);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[FaceEmbedding] Updated identity_uuid={} for file={}, trace={}",
|
||||
identity_uuid, file_uuid, trace_id
|
||||
);
|
||||
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
pub async fn clear_identity_by_trace(
|
||||
&self,
|
||||
file_uuid: &str,
|
||||
trace_id: i32,
|
||||
) -> Result<usize> {
|
||||
let url = format!(
|
||||
"{}/collections/{}/points",
|
||||
self.base_url, self.collection_name
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"filter": {
|
||||
"must": [
|
||||
{
|
||||
"key": "file_uuid",
|
||||
"match": { "value": file_uuid }
|
||||
},
|
||||
{
|
||||
"key": "trace_id",
|
||||
"match": { "value": trace_id }
|
||||
}
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"identity_uuid": null
|
||||
}
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("api-key", &self.api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to clear identity_uuid in Qdrant")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Qdrant identity clear failed: {}", text);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[FaceEmbedding] Cleared identity_uuid for file={}, trace={}",
|
||||
file_uuid, trace_id
|
||||
);
|
||||
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
pub async fn search_similar(
|
||||
&self,
|
||||
query_embedding: &[f32],
|
||||
@@ -294,6 +406,26 @@ impl FaceEmbeddingDb {
|
||||
.get("roll")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0),
|
||||
identity_uuid: r
|
||||
.payload
|
||||
.get("identity_uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
identity_ref: r
|
||||
.payload
|
||||
.get("identity_ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
stranger_ref: r
|
||||
.payload
|
||||
.get("stranger_ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
r#type: r
|
||||
.payload
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
};
|
||||
FaceEmbeddingPoint {
|
||||
id,
|
||||
@@ -498,6 +630,26 @@ impl FaceEmbeddingDb {
|
||||
.get("roll")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0),
|
||||
identity_uuid: r
|
||||
.payload
|
||||
.get("identity_uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
identity_ref: r
|
||||
.payload
|
||||
.get("identity_ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
stranger_ref: r
|
||||
.payload
|
||||
.get("stranger_ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
r#type: r
|
||||
.payload
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
};
|
||||
(id, r.vector, payload)
|
||||
})
|
||||
@@ -537,6 +689,258 @@ impl FaceEmbeddingDb {
|
||||
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
pub async fn upsert_seed_embedding(
|
||||
&self,
|
||||
identity_uuid: &str,
|
||||
identity_name: &str,
|
||||
tmdb_id: i32,
|
||||
embedding: &[f32],
|
||||
) -> Result<()> {
|
||||
let url = format!(
|
||||
"{}/collections/{}/points?wait=true",
|
||||
self.base_url, self.collection_name
|
||||
);
|
||||
|
||||
let point_id = identity_uuid.to_string();
|
||||
let payload = serde_json::json!({
|
||||
"file_uuid": "",
|
||||
"trace_id": 0,
|
||||
"frame": 0,
|
||||
"bbox_x": 0.0,
|
||||
"bbox_y": 0.0,
|
||||
"bbox_w": 0.0,
|
||||
"bbox_h": 0.0,
|
||||
"confidence": 0.0,
|
||||
"yaw": 0.0,
|
||||
"pitch": 0.0,
|
||||
"roll": 0.0,
|
||||
"identity_uuid": identity_uuid,
|
||||
"identity_ref": serde_json::Value::Null,
|
||||
"stranger_ref": serde_json::Value::Null,
|
||||
"identity_name": identity_name,
|
||||
"tmdb_id": tmdb_id,
|
||||
"type": "identity_seed",
|
||||
});
|
||||
|
||||
let body = serde_json::json!({
|
||||
"points": [{
|
||||
"id": point_id,
|
||||
"vector": embedding,
|
||||
"payload": payload
|
||||
}]
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("api-key", &self.api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to upsert seed embedding")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Qdrant seed upsert failed: {}", text);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[SeedEmbedding] Stored seed for identity_uuid={}, name={}",
|
||||
identity_uuid, identity_name
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_seed_embeddings(
|
||||
&self,
|
||||
) -> Result<Vec<(String, String, Vec<f32>)>> {
|
||||
let url = format!(
|
||||
"{}/collections/{}/points/scroll",
|
||||
self.base_url, self.collection_name
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"limit": 10000,
|
||||
"with_payload": true,
|
||||
"with_vector": true,
|
||||
"filter": {
|
||||
"must": [
|
||||
{"key": "type", "match": { "value": "identity_seed" }}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("api-key", &self.api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to scroll seed embeddings")?;
|
||||
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Qdrant scroll failed: {} - {}", status, text);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ScrollResult {
|
||||
result: ScrollPoints,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ScrollPoints {
|
||||
points: Vec<PointResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PointResult {
|
||||
id: serde_json::Value,
|
||||
vector: Vec<f32>,
|
||||
payload: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
let parsed: ScrollResult =
|
||||
serde_json::from_str(&text).context("Failed to parse Qdrant scroll response")?;
|
||||
|
||||
let results: Vec<(String, String, Vec<f32>)> = parsed
|
||||
.result
|
||||
.points
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let identity_uuid = r
|
||||
.payload
|
||||
.get("identity_uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let identity_name = r
|
||||
.payload
|
||||
.get("identity_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if identity_uuid.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((identity_uuid, identity_name, r.vector))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub async fn update_identity_ref_by_trace(
|
||||
&self,
|
||||
file_uuid: &str,
|
||||
trace_id: i32,
|
||||
identity_ref: &str,
|
||||
) -> Result<usize> {
|
||||
let url = format!(
|
||||
"{}/collections/{}/points/payload",
|
||||
self.base_url, self.collection_name
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"filter": {
|
||||
"must": [
|
||||
{
|
||||
"key": "file_uuid",
|
||||
"match": { "value": file_uuid }
|
||||
},
|
||||
{
|
||||
"key": "trace_id",
|
||||
"match": { "value": trace_id }
|
||||
}
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"identity_ref": identity_ref
|
||||
}
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("api-key", &self.api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to update identity_ref in Qdrant")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Qdrant identity_ref update failed: {}", text);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[FaceEmbedding] Updated identity_ref={} for file={}, trace={}",
|
||||
identity_ref, file_uuid, trace_id
|
||||
);
|
||||
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
pub async fn update_stranger_ref_by_trace(
|
||||
&self,
|
||||
file_uuid: &str,
|
||||
trace_id: i32,
|
||||
stranger_ref: &str,
|
||||
) -> Result<usize> {
|
||||
let url = format!(
|
||||
"{}/collections/{}/points/payload",
|
||||
self.base_url, self.collection_name
|
||||
);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"filter": {
|
||||
"must": [
|
||||
{
|
||||
"key": "file_uuid",
|
||||
"match": { "value": file_uuid }
|
||||
},
|
||||
{
|
||||
"key": "trace_id",
|
||||
"match": { "value": trace_id }
|
||||
}
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"stranger_ref": stranger_ref
|
||||
}
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("api-key", &self.api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to update stranger_ref in Qdrant")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Qdrant stranger_ref update failed: {}", text);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[FaceEmbedding] Updated stranger_ref={} for file={}, trace={}",
|
||||
stranger_ref, file_uuid, trace_id
|
||||
);
|
||||
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FaceEmbeddingDb {
|
||||
|
||||
@@ -5,6 +5,7 @@ use serde_json;
|
||||
use sqlx::{postgres::PgPoolOptions, PgPool, Row};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn, error};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{schema, Database, QdrantDb};
|
||||
@@ -448,6 +449,7 @@ pub enum ProcessorType {
|
||||
FiveW1H,
|
||||
Appearance,
|
||||
MediaPipe,
|
||||
FaceCluster,
|
||||
}
|
||||
|
||||
impl sqlx::Type<sqlx::Postgres> for ProcessorType {
|
||||
@@ -487,6 +489,7 @@ impl ProcessorType {
|
||||
ProcessorType::FiveW1H => "5w1h",
|
||||
ProcessorType::Appearance => "appearance",
|
||||
ProcessorType::MediaPipe => "mediapipe",
|
||||
ProcessorType::FaceCluster => "face_cluster",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,6 +508,7 @@ impl ProcessorType {
|
||||
"5w1h" => Some(ProcessorType::FiveW1H),
|
||||
"appearance" => Some(ProcessorType::Appearance),
|
||||
"mediapipe" => Some(ProcessorType::MediaPipe),
|
||||
"face_cluster" => Some(ProcessorType::FaceCluster),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -524,13 +528,14 @@ impl ProcessorType {
|
||||
ProcessorType::FiveW1H => 0.1,
|
||||
ProcessorType::Appearance => 0.3,
|
||||
ProcessorType::MediaPipe => 0.3,
|
||||
ProcessorType::FaceCluster => 0.7,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uses_gpu(&self) -> bool {
|
||||
match self {
|
||||
ProcessorType::Yolo | ProcessorType::Face | ProcessorType::Pose | ProcessorType::Hand => true,
|
||||
ProcessorType::MediaPipe => false,
|
||||
ProcessorType::MediaPipe | ProcessorType::FaceCluster => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -550,6 +555,7 @@ impl ProcessorType {
|
||||
ProcessorType::FiveW1H => 256,
|
||||
ProcessorType::Appearance => 512,
|
||||
ProcessorType::MediaPipe => 1024,
|
||||
ProcessorType::FaceCluster => 1024,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,6 +574,7 @@ impl ProcessorType {
|
||||
ProcessorType::FiveW1H => Some("gemma4"),
|
||||
ProcessorType::Appearance => None,
|
||||
ProcessorType::MediaPipe => Some("mediapipe/holistic"),
|
||||
ProcessorType::FaceCluster => Some("sklearn/agglomerative"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,6 +590,7 @@ impl ProcessorType {
|
||||
],
|
||||
ProcessorType::FiveW1H => vec![ProcessorType::Story],
|
||||
ProcessorType::Appearance => vec![ProcessorType::Pose],
|
||||
ProcessorType::FaceCluster => vec![ProcessorType::Face],
|
||||
ProcessorType::Hand => vec![],
|
||||
ProcessorType::MediaPipe => vec![],
|
||||
_ => vec![],
|
||||
@@ -597,6 +605,7 @@ impl ProcessorType {
|
||||
ProcessorType::Yolo,
|
||||
ProcessorType::Ocr,
|
||||
ProcessorType::Face,
|
||||
ProcessorType::FaceCluster,
|
||||
ProcessorType::Pose,
|
||||
ProcessorType::Hand,
|
||||
ProcessorType::Appearance,
|
||||
@@ -611,7 +620,8 @@ impl ProcessorType {
|
||||
| ProcessorType::Pose
|
||||
| ProcessorType::Hand
|
||||
| ProcessorType::Appearance
|
||||
| ProcessorType::MediaPipe => PipelineType::Frame,
|
||||
| ProcessorType::MediaPipe
|
||||
| ProcessorType::FaceCluster => PipelineType::Frame,
|
||||
|
||||
ProcessorType::Cut
|
||||
| ProcessorType::Asr
|
||||
@@ -1074,9 +1084,9 @@ impl PostgresDb {
|
||||
let mj_cols = [
|
||||
"video_id BIGINT",
|
||||
"user_id BIGINT",
|
||||
"processors TEXT[]",
|
||||
"completed_processors TEXT[]",
|
||||
"failed_processors TEXT[]",
|
||||
"processors TEXT[] DEFAULT '{\"asr\",\"cut\",\"yolo\",\"ocr\",\"face\",\"pose\",\"asrx\"}'",
|
||||
"completed_processors TEXT[] DEFAULT '{}'",
|
||||
"failed_processors TEXT[] DEFAULT '{}'",
|
||||
];
|
||||
for col in &mj_cols {
|
||||
let (col_name, col_def) = col.split_once(' ').unwrap_or((col, ""));
|
||||
@@ -1087,6 +1097,10 @@ impl PostgresDb {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
// Update existing rows to have default processors array
|
||||
sqlx::query("UPDATE monitor_jobs SET processors = '{\"asr\",\"cut\",\"yolo\",\"ocr\",\"face\",\"pose\",\"asrx\"}' WHERE processors IS NULL OR processors = '{}'")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("CREATE INDEX IF NOT EXISTS idx_monitor_jobs_status ON monitor_jobs(status)")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
@@ -1869,16 +1883,16 @@ impl PostgresDb {
|
||||
.await?
|
||||
} else {
|
||||
// Insert new job
|
||||
sqlx::query(
|
||||
&format!(
|
||||
r#"
|
||||
INSERT INTO {} (uuid, video_path, status, video_id)
|
||||
VALUES ($1, $2, 'pending', $3)
|
||||
sqlx::query(
|
||||
&format!(
|
||||
r#"
|
||||
INSERT INTO {} (uuid, video_path, status, video_id, processors)
|
||||
VALUES ($1, $2, 'pending', $3, ARRAY['asr','cut','yolo','ocr','face','face_cluster','pose','asrx'])
|
||||
RETURNING id, uuid, video_path, status, current_processor, progress_total, progress_current, error_count, last_error, started_at::TEXT, updated_at::TEXT, created_at::TEXT, processors, completed_processors, failed_processors, video_id
|
||||
"#,
|
||||
jobs_table
|
||||
jobs_table
|
||||
)
|
||||
)
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(video_path)
|
||||
.bind(video_id_i64)
|
||||
@@ -3176,6 +3190,40 @@ impl PostgresDb {
|
||||
Ok(r.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn retry_failed_processor(
|
||||
&self,
|
||||
result_id: i32,
|
||||
max_retries: i32,
|
||||
) -> Result<bool> {
|
||||
let table = schema::table_name("processor_results");
|
||||
use sqlx::Row;
|
||||
|
||||
let current_retry: i32 = sqlx::query_scalar(&format!(
|
||||
"SELECT COALESCE(retry_count, 0) FROM {} WHERE id = $1",
|
||||
table
|
||||
))
|
||||
.bind(result_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if current_retry < max_retries {
|
||||
sqlx::query(&format!(
|
||||
"UPDATE {} SET status = 'pending', error_message = NULL, retry_count = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2",
|
||||
table
|
||||
))
|
||||
.bind(current_retry + 1)
|
||||
.bind(result_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
info!("🔄 Retrying processor (result_id={}, retry_count={}/{})", result_id, current_retry + 1, max_retries);
|
||||
Ok(true)
|
||||
} else {
|
||||
info!("⚠️ Processor exceeded max retries (result_id={}, retry_count={})", result_id, current_retry);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_bm25(
|
||||
&self,
|
||||
query: &str,
|
||||
|
||||
@@ -69,7 +69,8 @@ pub struct IdentityBinding {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct BindIdentityRequest {
|
||||
pub file_uuid: String,
|
||||
pub face_id: String,
|
||||
pub face_id: Option<String>,
|
||||
pub id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -81,7 +82,8 @@ pub struct BindIdentityTraceRequest {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct UnbindIdentityRequest {
|
||||
pub file_uuid: String,
|
||||
pub face_id: String,
|
||||
pub face_id: Option<String>,
|
||||
pub id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
75
src/core/processor/face_clustering.rs
Normal file
75
src/core/processor/face_clustering.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::executor::PythonExecutor;
|
||||
|
||||
const FACE_CLUSTER_TIMEOUT: Duration = Duration::from_secs(3600);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FaceClusterResult {
|
||||
pub clusters: Vec<FaceClusterInfo>,
|
||||
pub frames: Vec<FaceClusterFrame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FaceClusterInfo {
|
||||
pub cluster_id: String,
|
||||
pub face_count: usize,
|
||||
pub representative_face: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FaceClusterFrame {
|
||||
pub frame: u64,
|
||||
pub timestamp: f64,
|
||||
pub faces: Vec<ClusteredFace>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ClusteredFace {
|
||||
pub face_id: String,
|
||||
pub cluster_id: String,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
pub async fn process_face_cluster(
|
||||
video_path: &str,
|
||||
output_path: &str,
|
||||
uuid: Option<&str>,
|
||||
frames: Option<&[i64]>,
|
||||
) -> Result<FaceClusterResult> {
|
||||
let executor = PythonExecutor::new()?;
|
||||
let script_path = executor.script_path("fast_face_clustering_processor.py");
|
||||
|
||||
tracing::info!("[FACE_CLUSTER] Starting face clustering: {}", video_path);
|
||||
|
||||
if !script_path.exists() {
|
||||
tracing::warn!("[FACE_CLUSTER] Script not found, returning empty result");
|
||||
return Ok(FaceClusterResult {
|
||||
clusters: vec![],
|
||||
frames: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
executor
|
||||
.run_with_frames(
|
||||
"fast_face_clustering_processor.py",
|
||||
&[video_path, output_path],
|
||||
uuid,
|
||||
"FACE_CLUSTER",
|
||||
Some(FACE_CLUSTER_TIMEOUT),
|
||||
frames,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to run face clustering script"))?;
|
||||
|
||||
let json_str = std::fs::read_to_string(output_path).context("Failed to read FACE_CLUSTER output")?;
|
||||
|
||||
let result: FaceClusterResult =
|
||||
serde_json::from_str(&json_str).context("Failed to parse FACE_CLUSTER output")?;
|
||||
|
||||
tracing::info!("[FACE_CLUSTER] Result: {} clusters, {} frames", result.clusters.len(), result.frames.len());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod clip;
|
||||
pub mod cut;
|
||||
pub mod executor;
|
||||
pub mod face;
|
||||
pub mod face_clustering;
|
||||
pub mod face_recognition;
|
||||
pub mod hand;
|
||||
pub mod heuristic_scene;
|
||||
@@ -32,6 +33,9 @@ pub use clip::{
|
||||
pub use cut::{process_cut, CutResult, CutScene};
|
||||
pub use executor::{validate_python_env, PythonExecutor, RetryConfig};
|
||||
pub use face::{process_face, Face, FaceFrame, FaceResult};
|
||||
pub use face_clustering::{
|
||||
process_face_cluster, ClusteredFace, FaceClusterFrame, FaceClusterInfo, FaceClusterResult,
|
||||
};
|
||||
pub use face_recognition::{
|
||||
process_face_recognition, register_face, FaceAttributes, FaceCluster, FaceIdentity, FacePose,
|
||||
FaceRecognitionFrame, FaceRecognitionResult, FaceRegistrationResult, RecognizedFace,
|
||||
|
||||
@@ -129,7 +129,7 @@ async fn populate_face_embeddings_to_qdrant(
|
||||
// Load from face_detections table
|
||||
let fd_table = t("face_detections");
|
||||
let rows: Vec<(i32, i64, f64, f64, f64, f64, f64, Option<Vec<f32>>)> = sqlx::query_as(&format!(
|
||||
"SELECT trace_id::int, frame_number::bigint, x::float8, y::float8, width::float8, height::float8, confidence::float8, embedding \
|
||||
"SELECT trace_id::int, frame_number::bigint, x::float8, y::float8, width::float8, height::float8, confidence::float8, embedding::float4[] \
|
||||
FROM {} WHERE file_uuid = $1 AND trace_id IS NOT NULL AND embedding IS NOT NULL",
|
||||
fd_table
|
||||
))
|
||||
@@ -165,11 +165,20 @@ async fn populate_face_embeddings_to_qdrant(
|
||||
yaw,
|
||||
pitch,
|
||||
roll,
|
||||
identity_uuid: None,
|
||||
identity_ref: None,
|
||||
stranger_ref: None,
|
||||
r#type: None,
|
||||
};
|
||||
points.push((point_id, emb.clone(), payload));
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"[TKG-Phase1] Attempting to store {} face embeddings in Qdrant for {}",
|
||||
points.len(),
|
||||
file_uuid
|
||||
);
|
||||
let count = face_db.batch_upsert(points).await?;
|
||||
info!(
|
||||
"[TKG-Phase1] Stored {} face embeddings in Qdrant for {}",
|
||||
@@ -401,19 +410,7 @@ fn detect_mutual_gaze(
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct YoloJson {
|
||||
#[serde(default)]
|
||||
frames: Vec<YoloFrameData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct YoloFrameData {
|
||||
#[serde(default)]
|
||||
frame: u32,
|
||||
#[serde(default)]
|
||||
timestamp: f64,
|
||||
#[serde(default)]
|
||||
detections: Vec<YoloDetEntry>,
|
||||
#[serde(default)]
|
||||
objects: Vec<YoloDetEntry>,
|
||||
frames: HashMap<String, YoloFrameEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1033,7 +1030,7 @@ async fn build_yolo_object_nodes(
|
||||
.with_context(|| format!("Failed to parse {:?}", yolo_path))?;
|
||||
|
||||
let mut class_counts: HashMap<String, i64> = HashMap::new();
|
||||
for fdata in &yolo.frames {
|
||||
for fdata in yolo.frames.values() {
|
||||
let dets = if !fdata.detections.is_empty() {
|
||||
&fdata.detections
|
||||
} else {
|
||||
@@ -1277,9 +1274,9 @@ async fn build_co_occurrence_edges_from_qdrant(
|
||||
|
||||
let mut edge_count = 0;
|
||||
for (frame, faces) in frame_faces.iter() {
|
||||
let yolo_frame = match yolo.frames.iter().find(|f| f.frame == *frame as u32) {
|
||||
Some(f) => f,
|
||||
None => continue,
|
||||
let yolo_frame = match yolo.frames.get(&frame.to_string()) {
|
||||
Some(f) => f,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let dets = if !yolo_frame.detections.is_empty() {
|
||||
@@ -1391,9 +1388,9 @@ async fn build_co_occurrence_edges_from_pg(
|
||||
|
||||
let mut edge_count = 0;
|
||||
for face in &face_rows {
|
||||
let yolo_frame = match yolo.frames.iter().find(|f| f.frame == face.frame_number as u32) {
|
||||
Some(f) => f,
|
||||
None => continue,
|
||||
let yolo_frame = match yolo.frames.get(&face.frame_number.to_string()) {
|
||||
Some(f) => f,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let dets = if !yolo_frame.detections.is_empty() {
|
||||
@@ -2411,7 +2408,9 @@ async fn build_gaze_track_nodes_from_face_json(
|
||||
let nodes_table = t("tkg_nodes");
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO {} (file_uuid, external_id, label, node_type, properties, created_at) \
|
||||
VALUES ($1, $2, $3, 'gaze_track', $4, NOW())",
|
||||
VALUES ($1, $2, $3, 'gaze_track', $4, NOW()) \
|
||||
ON CONFLICT (file_uuid, node_type, external_id) \
|
||||
DO UPDATE SET properties = COALESCE(EXCLUDED.properties, tkg_nodes.properties)",
|
||||
nodes_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
@@ -3063,7 +3062,9 @@ async fn build_lip_track_nodes_from_face_json(
|
||||
let nodes_table = t("tkg_nodes");
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO {} (file_uuid, external_id, label, node_type, properties, created_at) \
|
||||
VALUES ($1, $2, $3, 'lip_track', $4, NOW())",
|
||||
VALUES ($1, $2, $3, 'lip_track', $4, NOW()) \
|
||||
ON CONFLICT (file_uuid, node_type, external_id) \
|
||||
DO UPDATE SET properties = COALESCE(EXCLUDED.properties, tkg_nodes.properties)",
|
||||
nodes_table
|
||||
))
|
||||
.bind(file_uuid)
|
||||
@@ -3814,10 +3815,10 @@ async fn build_hand_object_edges(pool: &PgPool, file_uuid: &str, output_dir: &st
|
||||
|
||||
let yolo_frames: HashMap<u64, &Vec<YoloDetEntry>> = yolo.frames
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
.filter_map(|(frame_key, f)| {
|
||||
let objs = if !f.objects.is_empty() { &f.objects } else { &f.detections };
|
||||
if !objs.is_empty() {
|
||||
Some((f.frame as u64, objs))
|
||||
frame_key.parse::<u64>().ok().map(|n| (n, objs))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -646,6 +646,10 @@ impl JobWorker {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
crate::core::db::ProcessorType::FaceCluster => {
|
||||
info!("Face clustering processor completed for {}", job.uuid);
|
||||
Ok(())
|
||||
}
|
||||
crate::core::db::ProcessorType::Pose => {
|
||||
if let Ok(result) = serde_json::from_str::<
|
||||
crate::core::processor::PoseResult,
|
||||
@@ -1093,6 +1097,33 @@ vector,
|
||||
.filter(|r| job_processors.contains(&r.processor_type.as_str().to_string()))
|
||||
.any(|r| matches!(r.status, crate::core::db::ProcessorJobStatus::Pending));
|
||||
|
||||
const MAX_RETRIES: i32 = 3;
|
||||
|
||||
if any_failed && !any_pending {
|
||||
let failed_processors_to_retry: Vec<i32> = results
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
job_processors.contains(&r.processor_type.as_str().to_string())
|
||||
&& matches!(r.status, crate::core::db::ProcessorJobStatus::Failed)
|
||||
&& r.retry_count < MAX_RETRIES
|
||||
})
|
||||
.map(|r| r.id)
|
||||
.collect();
|
||||
|
||||
if !failed_processors_to_retry.is_empty() {
|
||||
info!("🔄 Attempting to retry {} failed processors...", failed_processors_to_retry.len());
|
||||
|
||||
for result_id in failed_processors_to_retry {
|
||||
if let Ok(true) = self.db.retry_failed_processor(result_id, MAX_RETRIES).await {
|
||||
if let Ok(mut conn) = self.redis.get_conn().await {
|
||||
let redis_key = format!("momentry:progress:{}", uuid);
|
||||
let _: Result<i32, _> = redis::AsyncCommands::del(&mut conn, &redis_key).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let any_skipped = results
|
||||
.iter()
|
||||
.filter(|r| job_processors.contains(&r.processor_type.as_str().to_string()))
|
||||
|
||||
@@ -747,6 +747,27 @@ impl ProcessorPool {
|
||||
pid: 0,
|
||||
})
|
||||
}
|
||||
ProcessorType::FaceCluster => {
|
||||
let result = processor::process_face_cluster(
|
||||
video_path,
|
||||
output_path.to_str().unwrap(),
|
||||
uuid,
|
||||
Some(&sample_frames),
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"FACE_CLUSTER completed, output: {}",
|
||||
output_path.to_str().unwrap()
|
||||
);
|
||||
Ok(ProcessorOutput {
|
||||
data: serde_json::to_value(result)?,
|
||||
chunks_produced: 0,
|
||||
frames_processed: 0,
|
||||
total_frames: 0,
|
||||
retry_count: 0,
|
||||
pid: 0,
|
||||
})
|
||||
}
|
||||
ProcessorType::Pose => {
|
||||
let result = processor::process_pose(
|
||||
video_path,
|
||||
|
||||
Reference in New Issue
Block a user