@larkup/tool-video-intelligence 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +150 -0
- package/LICENSE +176 -0
- package/README.md +281 -0
- package/compose.gpu.yaml +14 -0
- package/compose.yaml +71 -0
- package/dist/agent.d.ts +131 -0
- package/dist/agent.js +2087 -0
- package/dist/brief.d.ts +2 -0
- package/dist/brief.js +37 -0
- package/dist/client.d.ts +46 -0
- package/dist/client.js +139 -0
- package/dist/contracts.d.ts +331 -0
- package/dist/contracts.js +1 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +391 -0
- package/dist/runtime.d.ts +96 -0
- package/dist/runtime.js +592 -0
- package/dist/ui.d.ts +82 -0
- package/dist/ui.js +87 -0
- package/package.json +84 -0
- package/runtime/Dockerfile +119 -0
- package/runtime/app/__init__.py +3 -0
- package/runtime/app/__main__.py +19 -0
- package/runtime/app/api/__init__.py +0 -0
- package/runtime/app/api/deps.py +69 -0
- package/runtime/app/api/v1.py +166 -0
- package/runtime/app/config.py +78 -0
- package/runtime/app/db/__init__.py +0 -0
- package/runtime/app/db/schemas.py +162 -0
- package/runtime/app/db/store.py +466 -0
- package/runtime/app/main.py +27 -0
- package/runtime/app/model_configuration.py +157 -0
- package/runtime/app/services/__init__.py +0 -0
- package/runtime/app/services/brain.py +2221 -0
- package/runtime/app/services/embedding.py +473 -0
- package/runtime/app/services/jobs.py +237 -0
- package/runtime/app/services/motion.py +66 -0
- package/runtime/app/services/pipeline.py +1911 -0
- package/runtime/app/services/scene.py +161 -0
- package/runtime/app/services/storage.py +44 -0
- package/runtime/app/services/transcription.py +667 -0
- package/runtime/app/services/vision.py +1441 -0
- package/runtime/app/utils/__init__.py +0 -0
- package/runtime/app/utils/timing.py +99 -0
- package/runtime/app/worker.py +20 -0
- package/runtime/pyproject.toml +56 -0
- package/runtime/requirements-cpu.txt +15 -0
- package/runtime/requirements-smoke.txt +7 -0
- package/runtime/requirements.txt +14 -0
- package/runtime/uv.lock +3637 -0
- package/scripts/grant-cloud-credits.sh +43 -0
- package/scripts/runtime.mjs +156 -0
- package/scripts/validate-indexing.mjs +168 -0
- package/tool.manifest.json +617 -0
|
@@ -0,0 +1,2221 @@
|
|
|
1
|
+
"""Bounded agent planner for video indexing.
|
|
2
|
+
|
|
3
|
+
The planner chooses a small, validated extraction policy. It never executes
|
|
4
|
+
arbitrary tools or code: the shared pipeline remains the only executor and
|
|
5
|
+
clamps every model-proposed budget before using it. Cloud and local runtimes
|
|
6
|
+
use this exact module; only provider credentials differ through environment
|
|
7
|
+
variables.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
18
|
+
from dataclasses import asdict, dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
DEFAULT_AGENT_MODEL = "openai/gpt-5-mini"
|
|
25
|
+
DEFAULT_GATEWAY_URL = "https://ai-gateway.vercel.sh/v1"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _post_agent_request(
|
|
29
|
+
url: str,
|
|
30
|
+
*,
|
|
31
|
+
headers: dict[str, str],
|
|
32
|
+
payload: dict[str, Any],
|
|
33
|
+
timeout_seconds: int,
|
|
34
|
+
attempts: int = 2,
|
|
35
|
+
) -> requests.Response:
|
|
36
|
+
"""Retry only transient provider failures and honor quota reset hints."""
|
|
37
|
+
last_error: Exception | None = None
|
|
38
|
+
for attempt in range(attempts):
|
|
39
|
+
try:
|
|
40
|
+
response = requests.post(
|
|
41
|
+
url,
|
|
42
|
+
headers=headers,
|
|
43
|
+
json=payload,
|
|
44
|
+
timeout=timeout_seconds,
|
|
45
|
+
)
|
|
46
|
+
except requests.RequestException as error:
|
|
47
|
+
last_error = error
|
|
48
|
+
if attempt + 1 < attempts:
|
|
49
|
+
time.sleep(min(8.0, 0.5 * (2**attempt)))
|
|
50
|
+
continue
|
|
51
|
+
raise
|
|
52
|
+
if response.ok or (response.status_code != 429 and response.status_code < 500):
|
|
53
|
+
return response
|
|
54
|
+
if response.status_code == 429 and (
|
|
55
|
+
"PerDay" in response.text or "requests per day" in response.text.lower()
|
|
56
|
+
):
|
|
57
|
+
return response
|
|
58
|
+
if attempt + 1 >= attempts:
|
|
59
|
+
return response
|
|
60
|
+
retry_after = response.headers.get("Retry-After", "").strip()
|
|
61
|
+
try:
|
|
62
|
+
delay_seconds = float(retry_after)
|
|
63
|
+
except ValueError:
|
|
64
|
+
match = re.search(r'"retryDelay"\s*:\s*"([0-9.]+)s"', response.text)
|
|
65
|
+
delay_seconds = float(match.group(1)) if match else 0.0
|
|
66
|
+
if delay_seconds <= 0:
|
|
67
|
+
delay_seconds = 60.0 if response.status_code == 429 else 1.5 * (2**attempt)
|
|
68
|
+
time.sleep(min(60.0, max(0.5, delay_seconds)))
|
|
69
|
+
assert last_error is not None
|
|
70
|
+
raise last_error
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_SOURCE_RANGE_SCHEMA = {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"properties": {
|
|
76
|
+
"startMs": {"type": "number"},
|
|
77
|
+
"endMs": {"type": "number"},
|
|
78
|
+
},
|
|
79
|
+
"required": ["startMs", "endMs"],
|
|
80
|
+
"additionalProperties": False,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
PLANNER_JSON_SCHEMA: dict[str, Any] = {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"summary": {"type": "string", "maxLength": 240},
|
|
87
|
+
"extractionFocus": {
|
|
88
|
+
"type": "array",
|
|
89
|
+
"maxItems": 8,
|
|
90
|
+
"items": {"type": "string", "maxLength": 120},
|
|
91
|
+
},
|
|
92
|
+
"useTranscript": {"type": "boolean"},
|
|
93
|
+
"useOcr": {"type": "boolean"},
|
|
94
|
+
"useObjectDetection": {"type": "boolean"},
|
|
95
|
+
"useSemanticVision": {"type": "boolean"},
|
|
96
|
+
"useVideoEmbeddings": {"type": "boolean"},
|
|
97
|
+
"useSceneCuts": {"type": "boolean"},
|
|
98
|
+
"sampleIntervalSecs": {"type": "number"},
|
|
99
|
+
"prioritySampleIntervalSecs": {"type": "number"},
|
|
100
|
+
"clipWindowSecs": {"type": "number"},
|
|
101
|
+
"framesPerClip": {"type": "integer"},
|
|
102
|
+
"priorityRanges": {
|
|
103
|
+
"type": "array",
|
|
104
|
+
"maxItems": 12,
|
|
105
|
+
"items": {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"properties": {
|
|
108
|
+
"startSecs": {"type": "number"},
|
|
109
|
+
"endSecs": {"type": "number"},
|
|
110
|
+
"reason": {"type": "string", "maxLength": 200},
|
|
111
|
+
},
|
|
112
|
+
"required": ["startSecs", "endSecs", "reason"],
|
|
113
|
+
"additionalProperties": False,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
"required": [
|
|
118
|
+
"summary",
|
|
119
|
+
"extractionFocus",
|
|
120
|
+
"useTranscript",
|
|
121
|
+
"useOcr",
|
|
122
|
+
"useObjectDetection",
|
|
123
|
+
"useSemanticVision",
|
|
124
|
+
"useVideoEmbeddings",
|
|
125
|
+
"useSceneCuts",
|
|
126
|
+
"sampleIntervalSecs",
|
|
127
|
+
"prioritySampleIntervalSecs",
|
|
128
|
+
"clipWindowSecs",
|
|
129
|
+
"framesPerClip",
|
|
130
|
+
"priorityRanges",
|
|
131
|
+
],
|
|
132
|
+
"additionalProperties": False,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
KNOWLEDGE_JSON_SCHEMA: dict[str, Any] = {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {
|
|
138
|
+
"overview": {"type": "string", "maxLength": 1200},
|
|
139
|
+
"participants": {
|
|
140
|
+
"type": "array",
|
|
141
|
+
"maxItems": 64,
|
|
142
|
+
"items": {
|
|
143
|
+
"type": "object",
|
|
144
|
+
"properties": {
|
|
145
|
+
"name": {"type": "string", "maxLength": 120},
|
|
146
|
+
"role": {"type": "string", "maxLength": 160},
|
|
147
|
+
"evidence": {
|
|
148
|
+
"type": "array",
|
|
149
|
+
"maxItems": 6,
|
|
150
|
+
"items": _SOURCE_RANGE_SCHEMA,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
"required": ["name", "role", "evidence"],
|
|
154
|
+
"additionalProperties": False,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
"stateHistory": {
|
|
158
|
+
"type": "array",
|
|
159
|
+
"maxItems": 32,
|
|
160
|
+
"items": {
|
|
161
|
+
"type": "object",
|
|
162
|
+
"properties": {
|
|
163
|
+
"startMs": {"type": "number"},
|
|
164
|
+
"endMs": {"type": "number"},
|
|
165
|
+
"state": {"type": "string", "maxLength": 240},
|
|
166
|
+
"confidence": {"type": "string", "enum": ["direct", "partial"]},
|
|
167
|
+
},
|
|
168
|
+
"required": ["startMs", "endMs", "state", "confidence"],
|
|
169
|
+
"additionalProperties": False,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
"keyEvents": {
|
|
173
|
+
"type": "array",
|
|
174
|
+
"maxItems": 64,
|
|
175
|
+
"items": {
|
|
176
|
+
"type": "object",
|
|
177
|
+
"properties": {
|
|
178
|
+
"startMs": {"type": "number"},
|
|
179
|
+
"endMs": {"type": "number"},
|
|
180
|
+
"event": {"type": "string", "maxLength": 240},
|
|
181
|
+
"confidence": {"type": "string", "enum": ["direct", "partial"]},
|
|
182
|
+
},
|
|
183
|
+
"required": ["startMs", "endMs", "event", "confidence"],
|
|
184
|
+
"additionalProperties": False,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
"narrative": {
|
|
188
|
+
"type": "array",
|
|
189
|
+
"maxItems": 64,
|
|
190
|
+
"items": {
|
|
191
|
+
"type": "object",
|
|
192
|
+
"properties": {
|
|
193
|
+
"startMs": {"type": "number"},
|
|
194
|
+
"endMs": {"type": "number"},
|
|
195
|
+
"text": {"type": "string", "maxLength": 600},
|
|
196
|
+
"confidence": {"type": "string", "enum": ["direct", "partial"]},
|
|
197
|
+
},
|
|
198
|
+
"required": ["startMs", "endMs", "text", "confidence"],
|
|
199
|
+
"additionalProperties": False,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
"context": {
|
|
203
|
+
"type": "array",
|
|
204
|
+
"maxItems": 32,
|
|
205
|
+
"items": {
|
|
206
|
+
"type": "object",
|
|
207
|
+
"properties": {
|
|
208
|
+
"fact": {"type": "string", "maxLength": 240},
|
|
209
|
+
"evidence": {
|
|
210
|
+
"type": "array",
|
|
211
|
+
"maxItems": 6,
|
|
212
|
+
"items": _SOURCE_RANGE_SCHEMA,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
"required": ["fact", "evidence"],
|
|
216
|
+
"additionalProperties": False,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
"uncertainties": {
|
|
220
|
+
"type": "array",
|
|
221
|
+
"maxItems": 16,
|
|
222
|
+
"items": {"type": "string", "maxLength": 240},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
"required": [
|
|
226
|
+
"overview",
|
|
227
|
+
"participants",
|
|
228
|
+
"stateHistory",
|
|
229
|
+
"keyEvents",
|
|
230
|
+
"narrative",
|
|
231
|
+
"context",
|
|
232
|
+
"uncertainties",
|
|
233
|
+
],
|
|
234
|
+
"additionalProperties": False,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
SOURCE_INVENTORY_JSON_SCHEMA: dict[str, Any] = {
|
|
238
|
+
"type": "object",
|
|
239
|
+
"properties": {
|
|
240
|
+
"items": {
|
|
241
|
+
"type": "array",
|
|
242
|
+
"maxItems": 256,
|
|
243
|
+
"items": {
|
|
244
|
+
"type": "object",
|
|
245
|
+
"properties": {
|
|
246
|
+
"kind": {
|
|
247
|
+
"type": "string",
|
|
248
|
+
"enum": [
|
|
249
|
+
"question",
|
|
250
|
+
"heading",
|
|
251
|
+
"slide-item",
|
|
252
|
+
"board-item",
|
|
253
|
+
"list-item",
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
"channel": {"type": "string", "enum": ["spoken", "visible"]},
|
|
257
|
+
"text": {"type": "string", "maxLength": 600},
|
|
258
|
+
"answer": {"type": "string", "maxLength": 600},
|
|
259
|
+
"startMs": {"type": "number"},
|
|
260
|
+
"endMs": {"type": "number"},
|
|
261
|
+
},
|
|
262
|
+
"required": [
|
|
263
|
+
"kind",
|
|
264
|
+
"channel",
|
|
265
|
+
"text",
|
|
266
|
+
"answer",
|
|
267
|
+
"startMs",
|
|
268
|
+
"endMs",
|
|
269
|
+
],
|
|
270
|
+
"additionalProperties": False,
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
"required": ["items"],
|
|
275
|
+
"additionalProperties": False,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
CONSISTENCY_AUDIT_JSON_SCHEMA: dict[str, Any] = {
|
|
279
|
+
"type": "object",
|
|
280
|
+
"properties": {
|
|
281
|
+
"overview": {"type": "string", "maxLength": 1200},
|
|
282
|
+
"stateDecisions": {
|
|
283
|
+
"type": "array",
|
|
284
|
+
"items": {
|
|
285
|
+
"type": "object",
|
|
286
|
+
"properties": {
|
|
287
|
+
"index": {"type": "integer"},
|
|
288
|
+
"keep": {"type": "boolean"},
|
|
289
|
+
"replacementState": {"type": "string", "maxLength": 240},
|
|
290
|
+
"neutralState": {"type": "string", "maxLength": 240},
|
|
291
|
+
"entityMappingSupported": {"type": "boolean"},
|
|
292
|
+
"reason": {"type": "string", "maxLength": 240},
|
|
293
|
+
},
|
|
294
|
+
"required": [
|
|
295
|
+
"index",
|
|
296
|
+
"keep",
|
|
297
|
+
"replacementState",
|
|
298
|
+
"neutralState",
|
|
299
|
+
"entityMappingSupported",
|
|
300
|
+
"reason",
|
|
301
|
+
],
|
|
302
|
+
"additionalProperties": False,
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
"eventDecisions": {
|
|
306
|
+
"type": "array",
|
|
307
|
+
"items": {
|
|
308
|
+
"type": "object",
|
|
309
|
+
"properties": {
|
|
310
|
+
"index": {"type": "integer"},
|
|
311
|
+
"keep": {"type": "boolean"},
|
|
312
|
+
"reason": {"type": "string", "maxLength": 240},
|
|
313
|
+
},
|
|
314
|
+
"required": ["index", "keep", "reason"],
|
|
315
|
+
"additionalProperties": False,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
"participantDecisions": {
|
|
319
|
+
"type": "array",
|
|
320
|
+
"items": {
|
|
321
|
+
"type": "object",
|
|
322
|
+
"properties": {
|
|
323
|
+
"index": {"type": "integer"},
|
|
324
|
+
"keep": {"type": "boolean"},
|
|
325
|
+
"reason": {"type": "string", "maxLength": 240},
|
|
326
|
+
},
|
|
327
|
+
"required": ["index", "keep", "reason"],
|
|
328
|
+
"additionalProperties": False,
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
"contextDecisions": {
|
|
332
|
+
"type": "array",
|
|
333
|
+
"items": {
|
|
334
|
+
"type": "object",
|
|
335
|
+
"properties": {
|
|
336
|
+
"index": {"type": "integer"},
|
|
337
|
+
"keep": {"type": "boolean"},
|
|
338
|
+
"replacementFact": {"type": "string", "maxLength": 240},
|
|
339
|
+
"reason": {"type": "string", "maxLength": 240},
|
|
340
|
+
},
|
|
341
|
+
"required": ["index", "keep", "replacementFact", "reason"],
|
|
342
|
+
"additionalProperties": False,
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
"uncertainties": {
|
|
346
|
+
"type": "array",
|
|
347
|
+
"maxItems": 8,
|
|
348
|
+
"items": {"type": "string", "maxLength": 240},
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
"required": [
|
|
352
|
+
"overview",
|
|
353
|
+
"stateDecisions",
|
|
354
|
+
"eventDecisions",
|
|
355
|
+
"participantDecisions",
|
|
356
|
+
"contextDecisions",
|
|
357
|
+
"uncertainties",
|
|
358
|
+
],
|
|
359
|
+
"additionalProperties": False,
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass(frozen=True)
|
|
364
|
+
class PriorityRange:
|
|
365
|
+
start_secs: float
|
|
366
|
+
end_secs: float
|
|
367
|
+
reason: str
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@dataclass(frozen=True)
|
|
371
|
+
class ExtractionPlan:
|
|
372
|
+
mode: str
|
|
373
|
+
summary: str
|
|
374
|
+
extraction_focus: list[str]
|
|
375
|
+
use_transcript: bool
|
|
376
|
+
use_ocr: bool
|
|
377
|
+
use_object_detection: bool
|
|
378
|
+
use_semantic_vision: bool
|
|
379
|
+
use_video_embeddings: bool
|
|
380
|
+
use_scene_cuts: bool
|
|
381
|
+
sample_interval_secs: float
|
|
382
|
+
priority_sample_interval_secs: float
|
|
383
|
+
clip_window_secs: float
|
|
384
|
+
frames_per_clip: int
|
|
385
|
+
priority_ranges: list[PriorityRange] = field(default_factory=list)
|
|
386
|
+
estimated_seconds: int = 0
|
|
387
|
+
|
|
388
|
+
def to_dict(self) -> dict[str, Any]:
|
|
389
|
+
value = asdict(self)
|
|
390
|
+
value["priorityRanges"] = [
|
|
391
|
+
{
|
|
392
|
+
"startSecs": item.start_secs,
|
|
393
|
+
"endSecs": item.end_secs,
|
|
394
|
+
"reason": item.reason,
|
|
395
|
+
}
|
|
396
|
+
for item in self.priority_ranges
|
|
397
|
+
]
|
|
398
|
+
for snake, camel in (
|
|
399
|
+
("extraction_focus", "extractionFocus"),
|
|
400
|
+
("use_transcript", "useTranscript"),
|
|
401
|
+
("use_ocr", "useOcr"),
|
|
402
|
+
("use_object_detection", "useObjectDetection"),
|
|
403
|
+
("use_semantic_vision", "useSemanticVision"),
|
|
404
|
+
("use_video_embeddings", "useVideoEmbeddings"),
|
|
405
|
+
("use_scene_cuts", "useSceneCuts"),
|
|
406
|
+
("sample_interval_secs", "sampleIntervalSecs"),
|
|
407
|
+
("priority_sample_interval_secs", "prioritySampleIntervalSecs"),
|
|
408
|
+
("clip_window_secs", "clipWindowSecs"),
|
|
409
|
+
("frames_per_clip", "framesPerClip"),
|
|
410
|
+
("estimated_seconds", "estimatedSeconds"),
|
|
411
|
+
):
|
|
412
|
+
value[camel] = value.pop(snake)
|
|
413
|
+
value.pop("priority_ranges", None)
|
|
414
|
+
return value
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@dataclass(frozen=True)
|
|
418
|
+
class PlannerDiagnostics:
|
|
419
|
+
attempted: bool
|
|
420
|
+
provider: str
|
|
421
|
+
model: str
|
|
422
|
+
requests: int
|
|
423
|
+
latency_ms: int
|
|
424
|
+
fallback: bool
|
|
425
|
+
error: str | None = None
|
|
426
|
+
prompt_tokens: int = 0
|
|
427
|
+
completion_tokens: int = 0
|
|
428
|
+
|
|
429
|
+
def to_dict(self) -> dict[str, Any]:
|
|
430
|
+
return {
|
|
431
|
+
"attempted": self.attempted,
|
|
432
|
+
"provider": self.provider,
|
|
433
|
+
"model": self.model,
|
|
434
|
+
"requests": self.requests,
|
|
435
|
+
"latencyMs": self.latency_ms,
|
|
436
|
+
"fallback": self.fallback,
|
|
437
|
+
"error": self.error,
|
|
438
|
+
"promptTokens": self.prompt_tokens,
|
|
439
|
+
"completionTokens": self.completion_tokens,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# How much work each mode may do. The agent picks freely inside its mode's
|
|
444
|
+
# bounds, but the bounds themselves do not overlap in the direction that
|
|
445
|
+
# matters: the densest plan Fast can propose is still lighter than the
|
|
446
|
+
# lightest plan Balanced can, and likewise for Balanced against Thorough. That
|
|
447
|
+
# is what makes the three modes mean something -- with overlapping ranges a
|
|
448
|
+
# Fast run could legitimately come out slower than a Balanced one, which is
|
|
449
|
+
# what a user picking Fast is choosing against.
|
|
450
|
+
MODE_BOUNDS: dict[str, dict[str, tuple[float, float]]] = {
|
|
451
|
+
"fast": {
|
|
452
|
+
"sample": (10.0, 45.0),
|
|
453
|
+
"priority": (2.5, 12.0),
|
|
454
|
+
"clip": (40.0, 120.0),
|
|
455
|
+
"frames": (2, 4),
|
|
456
|
+
},
|
|
457
|
+
"balanced": {
|
|
458
|
+
"sample": (4.0, 10.0),
|
|
459
|
+
"priority": (1.0, 2.5),
|
|
460
|
+
"clip": (15.0, 40.0),
|
|
461
|
+
"frames": (4, 6),
|
|
462
|
+
},
|
|
463
|
+
"thorough": {
|
|
464
|
+
"sample": (1.0, 4.0),
|
|
465
|
+
"priority": (0.3, 1.0),
|
|
466
|
+
"clip": (6.0, 15.0),
|
|
467
|
+
"frames": (6, 14),
|
|
468
|
+
},
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def normalize_mode(value: object) -> str:
|
|
473
|
+
mode = str(value or "balanced").strip().lower()
|
|
474
|
+
return mode if mode in {"fast", "balanced", "thorough"} else "balanced"
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def fallback_plan(mode: str, duration_secs: float, has_audio: bool) -> ExtractionPlan:
|
|
478
|
+
"""Provider-independent safe plan used when an agent endpoint is absent."""
|
|
479
|
+
normalized = normalize_mode(mode)
|
|
480
|
+
# Start each mode in the middle of its own budget, so a fallback plan is
|
|
481
|
+
# representative of the mode rather than its cheapest or densest extreme.
|
|
482
|
+
bounds = MODE_BOUNDS[normalized]
|
|
483
|
+
interval, priority_interval, clip_window = (
|
|
484
|
+
sum(bounds[key]) / 2 for key in ("sample", "priority", "clip")
|
|
485
|
+
)
|
|
486
|
+
frames = round(sum(bounds["frames"]) / 2)
|
|
487
|
+
detect, embeddings, cuts = {
|
|
488
|
+
"fast": (False, False, False),
|
|
489
|
+
"balanced": (True, True, False),
|
|
490
|
+
"thorough": (True, True, True),
|
|
491
|
+
}[normalized]
|
|
492
|
+
estimate = _estimate_runtime_seconds(
|
|
493
|
+
duration_secs=duration_secs,
|
|
494
|
+
sample_interval_secs=interval,
|
|
495
|
+
priority_sample_interval_secs=priority_interval,
|
|
496
|
+
clip_window_secs=clip_window,
|
|
497
|
+
frames_per_clip=frames,
|
|
498
|
+
priority_ranges=[],
|
|
499
|
+
use_transcript=has_audio,
|
|
500
|
+
use_ocr=True,
|
|
501
|
+
use_object_detection=detect,
|
|
502
|
+
use_semantic_vision=True,
|
|
503
|
+
use_video_embeddings=embeddings,
|
|
504
|
+
use_scene_cuts=cuts,
|
|
505
|
+
)
|
|
506
|
+
return ExtractionPlan(
|
|
507
|
+
mode=normalized,
|
|
508
|
+
summary=f"Bounded {normalized} coverage with content-adaptive visual sampling.",
|
|
509
|
+
extraction_focus=[
|
|
510
|
+
"timestamped facts",
|
|
511
|
+
"visible state changes",
|
|
512
|
+
"named entities",
|
|
513
|
+
"key events",
|
|
514
|
+
],
|
|
515
|
+
use_transcript=has_audio,
|
|
516
|
+
use_ocr=True,
|
|
517
|
+
use_object_detection=detect,
|
|
518
|
+
use_semantic_vision=True,
|
|
519
|
+
use_video_embeddings=embeddings,
|
|
520
|
+
use_scene_cuts=cuts,
|
|
521
|
+
sample_interval_secs=interval,
|
|
522
|
+
priority_sample_interval_secs=priority_interval,
|
|
523
|
+
clip_window_secs=clip_window,
|
|
524
|
+
frames_per_clip=frames,
|
|
525
|
+
estimated_seconds=max(15, estimate),
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class AgentPlanner:
|
|
530
|
+
"""Calls one configured text model and validates its proposed extraction policy."""
|
|
531
|
+
|
|
532
|
+
def __init__(self) -> None:
|
|
533
|
+
self.provider = (
|
|
534
|
+
os.getenv("LARKUP_VIDEO_AGENT_PROVIDER", "vercel_ai_gateway")
|
|
535
|
+
.strip()
|
|
536
|
+
.lower()
|
|
537
|
+
)
|
|
538
|
+
self.model = os.getenv("LARKUP_VIDEO_AGENT_MODEL", DEFAULT_AGENT_MODEL).strip()
|
|
539
|
+
self.planner_model = os.getenv(
|
|
540
|
+
"LARKUP_VIDEO_PLANNER_MODEL", self.model
|
|
541
|
+
).strip()
|
|
542
|
+
shared_vision_key = (
|
|
543
|
+
os.getenv("LARKUP_VIDEO_VISION_API_KEY")
|
|
544
|
+
if self.provider
|
|
545
|
+
== os.getenv("LARKUP_VIDEO_VISION_PROVIDER", "vercel_ai_gateway")
|
|
546
|
+
.strip()
|
|
547
|
+
.lower()
|
|
548
|
+
else None
|
|
549
|
+
)
|
|
550
|
+
self.api_key = (
|
|
551
|
+
os.getenv("LARKUP_VIDEO_AGENT_API_KEY")
|
|
552
|
+
or shared_vision_key
|
|
553
|
+
or os.getenv("AI_GATEWAY_API_KEY")
|
|
554
|
+
or ""
|
|
555
|
+
)
|
|
556
|
+
default_url = {
|
|
557
|
+
"anthropic": "https://api.anthropic.com/v1",
|
|
558
|
+
"cohere": "https://api.cohere.ai/compatibility/v1",
|
|
559
|
+
"deepseek": "https://api.deepseek.com",
|
|
560
|
+
"google": "https://generativelanguage.googleapis.com/v1beta",
|
|
561
|
+
"mistral": "https://api.mistral.ai/v1",
|
|
562
|
+
"openai": "https://api.openai.com/v1",
|
|
563
|
+
}.get(self.provider, DEFAULT_GATEWAY_URL)
|
|
564
|
+
self.base_url = os.getenv("LARKUP_VIDEO_AGENT_BASE_URL", default_url).rstrip(
|
|
565
|
+
"/"
|
|
566
|
+
)
|
|
567
|
+
self.enabled = os.getenv(
|
|
568
|
+
"LARKUP_VIDEO_AGENT_ENABLED", "true"
|
|
569
|
+
).strip().lower() not in {
|
|
570
|
+
"0",
|
|
571
|
+
"false",
|
|
572
|
+
"no",
|
|
573
|
+
"off",
|
|
574
|
+
}
|
|
575
|
+
self.requests = 0
|
|
576
|
+
self.latency_ms = 0
|
|
577
|
+
self.prompt_tokens = 0
|
|
578
|
+
self.completion_tokens = 0
|
|
579
|
+
self.last_error: str | None = None
|
|
580
|
+
self.errors: list[str] = []
|
|
581
|
+
self.fallback_used = False
|
|
582
|
+
|
|
583
|
+
def plan(
|
|
584
|
+
self,
|
|
585
|
+
*,
|
|
586
|
+
brief: dict[str, Any],
|
|
587
|
+
duration_secs: float,
|
|
588
|
+
width: int,
|
|
589
|
+
height: int,
|
|
590
|
+
fps: float,
|
|
591
|
+
has_audio: bool,
|
|
592
|
+
signals: dict[str, Any] | None = None,
|
|
593
|
+
previous: ExtractionPlan | None = None,
|
|
594
|
+
visual_samples: list[dict[str, Any]] | None = None,
|
|
595
|
+
) -> ExtractionPlan:
|
|
596
|
+
fallback = previous or fallback_plan(
|
|
597
|
+
str(brief.get("indexingMode")), duration_secs, has_audio
|
|
598
|
+
)
|
|
599
|
+
if not self.enabled or not self.api_key:
|
|
600
|
+
self.last_error = "agent model is disabled or has no API key"
|
|
601
|
+
self.errors.append(self.last_error)
|
|
602
|
+
self.fallback_used = True
|
|
603
|
+
return fallback
|
|
604
|
+
prompt = _planner_prompt(
|
|
605
|
+
brief=brief,
|
|
606
|
+
duration_secs=duration_secs,
|
|
607
|
+
width=width,
|
|
608
|
+
height=height,
|
|
609
|
+
fps=fps,
|
|
610
|
+
has_audio=has_audio,
|
|
611
|
+
signals=signals,
|
|
612
|
+
previous=previous,
|
|
613
|
+
)
|
|
614
|
+
started = time.monotonic()
|
|
615
|
+
try:
|
|
616
|
+
raw, usage = self._complete(
|
|
617
|
+
prompt,
|
|
618
|
+
visual_samples or [],
|
|
619
|
+
model_override=self.planner_model,
|
|
620
|
+
# Planning has a complete deterministic fallback. Never let
|
|
621
|
+
# an optional refinement hold indexing behind a slow model.
|
|
622
|
+
timeout_seconds=15,
|
|
623
|
+
json_schema=PLANNER_JSON_SCHEMA,
|
|
624
|
+
request_attempts=1,
|
|
625
|
+
)
|
|
626
|
+
self.requests += 1
|
|
627
|
+
self.prompt_tokens += usage.get("promptTokens", 0)
|
|
628
|
+
self.completion_tokens += usage.get("completionTokens", 0)
|
|
629
|
+
plan = _validated_plan(
|
|
630
|
+
raw, fallback, duration_secs, bool(brief.get("skipHeavyOperators"))
|
|
631
|
+
)
|
|
632
|
+
self.last_error = None
|
|
633
|
+
return plan
|
|
634
|
+
except Exception as error:
|
|
635
|
+
self.requests += 1
|
|
636
|
+
self.last_error = f"{type(error).__name__}: {error}"[:500]
|
|
637
|
+
self.errors.append(self.last_error)
|
|
638
|
+
self.fallback_used = True
|
|
639
|
+
return fallback
|
|
640
|
+
finally:
|
|
641
|
+
self.latency_ms += round((time.monotonic() - started) * 1_000)
|
|
642
|
+
|
|
643
|
+
def diagnostics(self) -> PlannerDiagnostics:
|
|
644
|
+
return PlannerDiagnostics(
|
|
645
|
+
attempted=self.requests > 0,
|
|
646
|
+
provider=self.provider,
|
|
647
|
+
model=self.model,
|
|
648
|
+
requests=self.requests,
|
|
649
|
+
latency_ms=self.latency_ms,
|
|
650
|
+
fallback=self.fallback_used,
|
|
651
|
+
error=" | ".join(self.errors[-3:]) or None,
|
|
652
|
+
prompt_tokens=self.prompt_tokens,
|
|
653
|
+
completion_tokens=self.completion_tokens,
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
def synthesize_knowledge(
|
|
657
|
+
self,
|
|
658
|
+
*,
|
|
659
|
+
brief: dict[str, Any],
|
|
660
|
+
duration_secs: float,
|
|
661
|
+
plan: ExtractionPlan,
|
|
662
|
+
semantic_observations: list[dict[str, Any]],
|
|
663
|
+
transcript: list[dict[str, Any]],
|
|
664
|
+
overlay_text: list[dict[str, Any]] | None = None,
|
|
665
|
+
) -> dict[str, Any]:
|
|
666
|
+
"""Consolidate timestamped evidence into a generic, auditable index."""
|
|
667
|
+
if not self.enabled or not self.api_key:
|
|
668
|
+
return _fallback_knowledge_summary(semantic_observations)
|
|
669
|
+
prompt = _synthesis_prompt(
|
|
670
|
+
brief=brief,
|
|
671
|
+
duration_secs=duration_secs,
|
|
672
|
+
plan=plan,
|
|
673
|
+
semantic_observations=semantic_observations,
|
|
674
|
+
transcript=transcript,
|
|
675
|
+
overlay_text=overlay_text or [],
|
|
676
|
+
)
|
|
677
|
+
started = time.monotonic()
|
|
678
|
+
try:
|
|
679
|
+
last_error: Exception | None = None
|
|
680
|
+
for attempt in range(2):
|
|
681
|
+
self.requests += 1
|
|
682
|
+
try:
|
|
683
|
+
raw, usage = self._complete(
|
|
684
|
+
(
|
|
685
|
+
prompt
|
|
686
|
+
if attempt == 0 and self.provider != "google"
|
|
687
|
+
else _synthesis_prompt(
|
|
688
|
+
brief=brief,
|
|
689
|
+
duration_secs=duration_secs,
|
|
690
|
+
plan=plan,
|
|
691
|
+
semantic_observations=semantic_observations,
|
|
692
|
+
transcript=transcript,
|
|
693
|
+
overlay_text=overlay_text or [],
|
|
694
|
+
compact=True,
|
|
695
|
+
)
|
|
696
|
+
),
|
|
697
|
+
[],
|
|
698
|
+
max_output_tokens=10_000,
|
|
699
|
+
timeout_seconds=75,
|
|
700
|
+
json_schema=KNOWLEDGE_JSON_SCHEMA,
|
|
701
|
+
request_attempts=1,
|
|
702
|
+
)
|
|
703
|
+
self.prompt_tokens += usage.get("promptTokens", 0)
|
|
704
|
+
self.completion_tokens += usage.get("completionTokens", 0)
|
|
705
|
+
summary = _validated_knowledge_summary(raw, duration_secs)
|
|
706
|
+
if not summary["overview"] or not any(
|
|
707
|
+
summary[key]
|
|
708
|
+
for key in (
|
|
709
|
+
"participants",
|
|
710
|
+
"stateHistory",
|
|
711
|
+
"keyEvents",
|
|
712
|
+
"context",
|
|
713
|
+
)
|
|
714
|
+
):
|
|
715
|
+
raise ValueError(
|
|
716
|
+
"agent synthesis returned no supported knowledge"
|
|
717
|
+
)
|
|
718
|
+
# The audit is a second read of the same evidence. It pays
|
|
719
|
+
# for itself only when the draft actually makes claims that
|
|
720
|
+
# can contradict each other across time -- two states, or a
|
|
721
|
+
# state plus an event that would move it.
|
|
722
|
+
if len(summary["stateHistory"]) >= 2 or (
|
|
723
|
+
summary["stateHistory"] and len(summary["keyEvents"]) >= 2
|
|
724
|
+
):
|
|
725
|
+
try:
|
|
726
|
+
self.requests += 1
|
|
727
|
+
audit_raw, audit_usage = self._complete(
|
|
728
|
+
_consistency_audit_prompt(
|
|
729
|
+
summary=summary,
|
|
730
|
+
semantic_observations=semantic_observations,
|
|
731
|
+
),
|
|
732
|
+
[],
|
|
733
|
+
max_output_tokens=3_000,
|
|
734
|
+
timeout_seconds=75,
|
|
735
|
+
json_schema=CONSISTENCY_AUDIT_JSON_SCHEMA,
|
|
736
|
+
request_attempts=1,
|
|
737
|
+
)
|
|
738
|
+
self.prompt_tokens += audit_usage.get("promptTokens", 0)
|
|
739
|
+
self.completion_tokens += audit_usage.get(
|
|
740
|
+
"completionTokens", 0
|
|
741
|
+
)
|
|
742
|
+
summary = _apply_consistency_audit(summary, audit_raw)
|
|
743
|
+
except Exception as audit_error:
|
|
744
|
+
self.errors.append(
|
|
745
|
+
f"knowledge consistency audit: {type(audit_error).__name__}: {audit_error}"[
|
|
746
|
+
:500
|
|
747
|
+
]
|
|
748
|
+
)
|
|
749
|
+
self.last_error = None
|
|
750
|
+
return summary
|
|
751
|
+
except Exception as error:
|
|
752
|
+
last_error = error
|
|
753
|
+
# A second immediate synthesis request cannot replenish a
|
|
754
|
+
# project quota window. Fall back to the deterministic
|
|
755
|
+
# chronology now instead of making chat/indexing wait on
|
|
756
|
+
# another guaranteed rejection.
|
|
757
|
+
if "429" in str(error):
|
|
758
|
+
break
|
|
759
|
+
assert last_error is not None
|
|
760
|
+
raise last_error
|
|
761
|
+
except Exception as error:
|
|
762
|
+
self.last_error = f"{type(error).__name__}: {error}"[:500]
|
|
763
|
+
self.errors.append(self.last_error)
|
|
764
|
+
self.fallback_used = True
|
|
765
|
+
return _fallback_knowledge_summary(semantic_observations)
|
|
766
|
+
finally:
|
|
767
|
+
self.latency_ms += round((time.monotonic() - started) * 1_000)
|
|
768
|
+
|
|
769
|
+
def extract_source_inventory(
|
|
770
|
+
self,
|
|
771
|
+
*,
|
|
772
|
+
duration_secs: float,
|
|
773
|
+
transcript: list[dict[str, Any]],
|
|
774
|
+
semantic_observations: list[dict[str, Any]],
|
|
775
|
+
overlay_text: list[dict[str, Any]] | None = None,
|
|
776
|
+
) -> list[dict[str, Any]]:
|
|
777
|
+
"""Map every source-authored question and visible list unit in bounded time chunks."""
|
|
778
|
+
fallback = _fallback_source_inventory(semantic_observations, duration_secs)
|
|
779
|
+
if not self.enabled or not self.api_key:
|
|
780
|
+
return fallback
|
|
781
|
+
chunks = _source_inventory_chunks(
|
|
782
|
+
duration_secs=duration_secs,
|
|
783
|
+
transcript=transcript,
|
|
784
|
+
semantic_observations=semantic_observations,
|
|
785
|
+
overlay_text=overlay_text or [],
|
|
786
|
+
)
|
|
787
|
+
if not chunks:
|
|
788
|
+
return fallback
|
|
789
|
+
started = time.monotonic()
|
|
790
|
+
completed: dict[int, list[dict[str, Any]]] = {}
|
|
791
|
+
errors: list[str] = []
|
|
792
|
+
usage_totals = {"promptTokens": 0, "completionTokens": 0}
|
|
793
|
+
|
|
794
|
+
def map_chunk(index: int, chunk: dict[str, Any]):
|
|
795
|
+
raw, usage = self._complete(
|
|
796
|
+
_source_inventory_prompt(chunk),
|
|
797
|
+
[],
|
|
798
|
+
max_output_tokens=10_000,
|
|
799
|
+
timeout_seconds=30,
|
|
800
|
+
json_schema=SOURCE_INVENTORY_JSON_SCHEMA,
|
|
801
|
+
request_attempts=1,
|
|
802
|
+
)
|
|
803
|
+
return index, _validated_source_inventory(raw, duration_secs, chunk), usage
|
|
804
|
+
|
|
805
|
+
try:
|
|
806
|
+
# These requests contain disjoint time ranges and are independent.
|
|
807
|
+
# Four concurrent maps keep an hour-long source to one short wave.
|
|
808
|
+
with ThreadPoolExecutor(max_workers=min(4, len(chunks))) as executor:
|
|
809
|
+
futures = {
|
|
810
|
+
executor.submit(map_chunk, index, chunk): index
|
|
811
|
+
for index, chunk in enumerate(chunks)
|
|
812
|
+
}
|
|
813
|
+
for future in as_completed(futures):
|
|
814
|
+
self.requests += 1
|
|
815
|
+
try:
|
|
816
|
+
index, items, usage = future.result()
|
|
817
|
+
completed[index] = items
|
|
818
|
+
usage_totals["promptTokens"] += usage.get("promptTokens", 0)
|
|
819
|
+
usage_totals["completionTokens"] += usage.get("completionTokens", 0)
|
|
820
|
+
except Exception as error:
|
|
821
|
+
errors.append(
|
|
822
|
+
f"source inventory chunk {futures[future]}: "
|
|
823
|
+
f"{type(error).__name__}: {error}"[:500]
|
|
824
|
+
)
|
|
825
|
+
self.prompt_tokens += usage_totals["promptTokens"]
|
|
826
|
+
self.completion_tokens += usage_totals["completionTokens"]
|
|
827
|
+
if errors:
|
|
828
|
+
self.errors.extend(errors[-3:])
|
|
829
|
+
return _merge_source_inventory(
|
|
830
|
+
fallback,
|
|
831
|
+
*[completed[index] for index in sorted(completed)],
|
|
832
|
+
)
|
|
833
|
+
finally:
|
|
834
|
+
self.latency_ms += round((time.monotonic() - started) * 1_000)
|
|
835
|
+
|
|
836
|
+
def _complete(
|
|
837
|
+
self,
|
|
838
|
+
prompt: str,
|
|
839
|
+
visual_samples: list[dict[str, Any]],
|
|
840
|
+
max_output_tokens: int = 1_400,
|
|
841
|
+
timeout_seconds: int = 60,
|
|
842
|
+
json_schema: dict[str, Any] | None = None,
|
|
843
|
+
request_attempts: int = 2,
|
|
844
|
+
model_override: str | None = None,
|
|
845
|
+
) -> tuple[dict[str, Any], dict[str, int]]:
|
|
846
|
+
configured_model = model_override or self.model
|
|
847
|
+
if self.provider == "google":
|
|
848
|
+
model = (
|
|
849
|
+
configured_model.split("/", 1)[1]
|
|
850
|
+
if configured_model.startswith("google/")
|
|
851
|
+
else configured_model
|
|
852
|
+
)
|
|
853
|
+
parts: list[dict[str, Any]] = [{"text": prompt}]
|
|
854
|
+
for sample in visual_samples[:6]:
|
|
855
|
+
data_url = str(sample.get("dataUrl") or "")
|
|
856
|
+
if "," not in data_url:
|
|
857
|
+
continue
|
|
858
|
+
header, encoded = data_url.split(",", 1)
|
|
859
|
+
parts.extend(
|
|
860
|
+
[
|
|
861
|
+
{
|
|
862
|
+
"text": f"SCOUT FRAME @ {round(float(sample.get('timeMs') or 0))}ms"
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
"inline_data": {
|
|
866
|
+
"mime_type": (
|
|
867
|
+
"image/jpeg"
|
|
868
|
+
if "image/jpeg" in header
|
|
869
|
+
else "image/png"
|
|
870
|
+
),
|
|
871
|
+
"data": encoded,
|
|
872
|
+
}
|
|
873
|
+
},
|
|
874
|
+
]
|
|
875
|
+
)
|
|
876
|
+
generation_config: dict[str, Any] = {
|
|
877
|
+
"maxOutputTokens": max_output_tokens,
|
|
878
|
+
"responseMimeType": "application/json",
|
|
879
|
+
}
|
|
880
|
+
if model.startswith("gemini-3"):
|
|
881
|
+
thinking_level = (
|
|
882
|
+
os.getenv("LARKUP_VIDEO_AGENT_THINKING_LEVEL", "minimal")
|
|
883
|
+
.strip()
|
|
884
|
+
.lower()
|
|
885
|
+
)
|
|
886
|
+
if thinking_level not in {"minimal", "low", "medium", "high"}:
|
|
887
|
+
thinking_level = "minimal"
|
|
888
|
+
generation_config["thinkingConfig"] = {"thinkingLevel": thinking_level}
|
|
889
|
+
else:
|
|
890
|
+
generation_config["temperature"] = 0
|
|
891
|
+
if json_schema is not None:
|
|
892
|
+
generation_config["responseSchema"] = _google_response_schema(
|
|
893
|
+
json_schema
|
|
894
|
+
)
|
|
895
|
+
response = _post_agent_request(
|
|
896
|
+
f"{self.base_url}/models/{model}:generateContent",
|
|
897
|
+
headers={
|
|
898
|
+
"x-goog-api-key": self.api_key,
|
|
899
|
+
"Content-Type": "application/json",
|
|
900
|
+
},
|
|
901
|
+
payload={
|
|
902
|
+
"contents": [{"role": "user", "parts": parts}],
|
|
903
|
+
"generationConfig": generation_config,
|
|
904
|
+
},
|
|
905
|
+
timeout_seconds=timeout_seconds,
|
|
906
|
+
attempts=request_attempts,
|
|
907
|
+
)
|
|
908
|
+
if not response.ok:
|
|
909
|
+
raise RuntimeError(
|
|
910
|
+
f"agent provider returned {response.status_code}: {response.text[:240]}"
|
|
911
|
+
)
|
|
912
|
+
payload = response.json()
|
|
913
|
+
candidate = payload["candidates"][0]
|
|
914
|
+
if candidate.get("finishReason") == "MAX_TOKENS":
|
|
915
|
+
raise RuntimeError("agent response reached its output limit")
|
|
916
|
+
text = "\n".join(
|
|
917
|
+
str(part.get("text") or "")
|
|
918
|
+
for part in candidate["content"]["parts"]
|
|
919
|
+
if not part.get("thought")
|
|
920
|
+
)
|
|
921
|
+
metadata = payload.get("usageMetadata") or {}
|
|
922
|
+
usage = {
|
|
923
|
+
"promptTokens": int(metadata.get("promptTokenCount") or 0),
|
|
924
|
+
"completionTokens": int(metadata.get("candidatesTokenCount") or 0),
|
|
925
|
+
}
|
|
926
|
+
return _json_object(text), usage
|
|
927
|
+
|
|
928
|
+
model = configured_model
|
|
929
|
+
if model.startswith(f"{self.provider}/"):
|
|
930
|
+
model = model.split("/", 1)[1]
|
|
931
|
+
if self.provider == "anthropic":
|
|
932
|
+
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
|
933
|
+
for sample in visual_samples[:6]:
|
|
934
|
+
data_url = str(sample.get("dataUrl") or "")
|
|
935
|
+
if "," not in data_url:
|
|
936
|
+
continue
|
|
937
|
+
header, encoded = data_url.split(",", 1)
|
|
938
|
+
content.extend(
|
|
939
|
+
[
|
|
940
|
+
{
|
|
941
|
+
"type": "text",
|
|
942
|
+
"text": f"SCOUT FRAME @ {round(float(sample.get('timeMs') or 0))}ms",
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
"type": "image",
|
|
946
|
+
"source": {
|
|
947
|
+
"type": "base64",
|
|
948
|
+
"media_type": (
|
|
949
|
+
"image/jpeg" if "image/jpeg" in header else "image/png"
|
|
950
|
+
),
|
|
951
|
+
"data": encoded,
|
|
952
|
+
},
|
|
953
|
+
},
|
|
954
|
+
]
|
|
955
|
+
)
|
|
956
|
+
response = _post_agent_request(
|
|
957
|
+
f"{self.base_url}/messages",
|
|
958
|
+
headers={
|
|
959
|
+
"x-api-key": self.api_key,
|
|
960
|
+
"anthropic-version": "2023-06-01",
|
|
961
|
+
"Content-Type": "application/json",
|
|
962
|
+
},
|
|
963
|
+
payload={
|
|
964
|
+
"model": model,
|
|
965
|
+
"temperature": 0,
|
|
966
|
+
"max_tokens": max_output_tokens,
|
|
967
|
+
"messages": [{"role": "user", "content": content}],
|
|
968
|
+
},
|
|
969
|
+
timeout_seconds=timeout_seconds,
|
|
970
|
+
attempts=request_attempts,
|
|
971
|
+
)
|
|
972
|
+
if not response.ok:
|
|
973
|
+
raise RuntimeError(
|
|
974
|
+
f"agent provider returned {response.status_code}: {response.text[:240]}"
|
|
975
|
+
)
|
|
976
|
+
payload = response.json()
|
|
977
|
+
usage_payload = payload.get("usage") or {}
|
|
978
|
+
usage = {
|
|
979
|
+
"promptTokens": int(usage_payload.get("input_tokens") or 0),
|
|
980
|
+
"completionTokens": int(usage_payload.get("output_tokens") or 0),
|
|
981
|
+
}
|
|
982
|
+
text = "\n".join(
|
|
983
|
+
str(item.get("text") or "")
|
|
984
|
+
for item in payload.get("content") or []
|
|
985
|
+
if isinstance(item, dict) and item.get("type") == "text"
|
|
986
|
+
)
|
|
987
|
+
return _json_object(text), usage
|
|
988
|
+
|
|
989
|
+
content: str | list[dict[str, Any]] = prompt
|
|
990
|
+
if visual_samples:
|
|
991
|
+
content = [{"type": "text", "text": prompt}]
|
|
992
|
+
for sample in visual_samples[:6]:
|
|
993
|
+
content.extend(
|
|
994
|
+
[
|
|
995
|
+
{
|
|
996
|
+
"type": "text",
|
|
997
|
+
"text": f"SCOUT FRAME @ {round(float(sample.get('timeMs') or 0))}ms",
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
"type": "image_url",
|
|
1001
|
+
"image_url": {"url": str(sample.get("dataUrl") or "")},
|
|
1002
|
+
},
|
|
1003
|
+
]
|
|
1004
|
+
)
|
|
1005
|
+
request_payload: dict[str, Any] = {
|
|
1006
|
+
"model": model,
|
|
1007
|
+
"temperature": 0,
|
|
1008
|
+
"max_tokens": max_output_tokens,
|
|
1009
|
+
"response_format": {"type": "json_object"},
|
|
1010
|
+
"messages": [{"role": "user", "content": content}],
|
|
1011
|
+
}
|
|
1012
|
+
if self.provider == "vercel_ai_gateway":
|
|
1013
|
+
request_payload["reasoning"] = {"effort": "minimal"}
|
|
1014
|
+
if json_schema is not None and self.provider in {"openai", "vercel_ai_gateway"}:
|
|
1015
|
+
request_payload["response_format"] = {
|
|
1016
|
+
"type": "json_schema",
|
|
1017
|
+
"json_schema": {
|
|
1018
|
+
"name": "video_knowledge_index",
|
|
1019
|
+
"strict": True,
|
|
1020
|
+
"schema": json_schema,
|
|
1021
|
+
},
|
|
1022
|
+
}
|
|
1023
|
+
response = _post_agent_request(
|
|
1024
|
+
f"{self.base_url}/chat/completions",
|
|
1025
|
+
headers={
|
|
1026
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
1027
|
+
"Content-Type": "application/json",
|
|
1028
|
+
},
|
|
1029
|
+
payload=request_payload,
|
|
1030
|
+
timeout_seconds=timeout_seconds,
|
|
1031
|
+
attempts=request_attempts,
|
|
1032
|
+
)
|
|
1033
|
+
if not response.ok:
|
|
1034
|
+
raise RuntimeError(
|
|
1035
|
+
f"agent provider returned {response.status_code}: {response.text[:240]}"
|
|
1036
|
+
)
|
|
1037
|
+
payload = response.json()
|
|
1038
|
+
usage_payload = payload.get("usage") or {}
|
|
1039
|
+
usage = {
|
|
1040
|
+
"promptTokens": int(
|
|
1041
|
+
usage_payload.get("prompt_tokens")
|
|
1042
|
+
or usage_payload.get("input_tokens")
|
|
1043
|
+
or 0
|
|
1044
|
+
),
|
|
1045
|
+
"completionTokens": int(
|
|
1046
|
+
usage_payload.get("completion_tokens")
|
|
1047
|
+
or usage_payload.get("output_tokens")
|
|
1048
|
+
or 0
|
|
1049
|
+
),
|
|
1050
|
+
}
|
|
1051
|
+
choice = payload["choices"][0]
|
|
1052
|
+
if choice.get("finish_reason") == "length":
|
|
1053
|
+
raise RuntimeError("agent response reached its output limit")
|
|
1054
|
+
message_content = choice["message"]["content"]
|
|
1055
|
+
if isinstance(message_content, list):
|
|
1056
|
+
message_content = "\n".join(
|
|
1057
|
+
str(item.get("text") or "")
|
|
1058
|
+
for item in message_content
|
|
1059
|
+
if isinstance(item, dict)
|
|
1060
|
+
)
|
|
1061
|
+
return _json_object(str(message_content)), usage
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _planner_prompt(
|
|
1065
|
+
*,
|
|
1066
|
+
brief: dict[str, Any],
|
|
1067
|
+
duration_secs: float,
|
|
1068
|
+
width: int,
|
|
1069
|
+
height: int,
|
|
1070
|
+
fps: float,
|
|
1071
|
+
has_audio: bool,
|
|
1072
|
+
signals: dict[str, Any] | None,
|
|
1073
|
+
previous: ExtractionPlan | None,
|
|
1074
|
+
) -> str:
|
|
1075
|
+
mode = normalize_mode(brief.get("indexingMode"))
|
|
1076
|
+
payload = {
|
|
1077
|
+
"requestedMode": mode,
|
|
1078
|
+
"userHint": str(brief.get("goal") or "")[:2000],
|
|
1079
|
+
"knownEntities": list(brief.get("knownEntities") or [])[:50],
|
|
1080
|
+
"expectedQuestions": list(brief.get("expectedQuestions") or [])[:20],
|
|
1081
|
+
"video": {
|
|
1082
|
+
"durationSecs": round(duration_secs, 3),
|
|
1083
|
+
"width": width,
|
|
1084
|
+
"height": height,
|
|
1085
|
+
"fps": round(fps, 3),
|
|
1086
|
+
"hasAudio": has_audio,
|
|
1087
|
+
},
|
|
1088
|
+
"signals": signals or {},
|
|
1089
|
+
"previousPlan": previous.to_dict() if previous else None,
|
|
1090
|
+
}
|
|
1091
|
+
return (
|
|
1092
|
+
"You are the bounded planning brain for a general-purpose video indexer. "
|
|
1093
|
+
"Choose what evidence services should run and where denser sampling is justified. "
|
|
1094
|
+
"Use only the supplied source metadata, user hint, chronological scout signals, OCR snippets, "
|
|
1095
|
+
"timestamped transcript excerpts, and any attached timestamped scout frames. Never assume a "
|
|
1096
|
+
"content genre, invent entities, or encode "
|
|
1097
|
+
"domain-specific rules. Maintain coarse coverage across the entire requested source; priorityRanges "
|
|
1098
|
+
"only add density around source-supported moments. Fast favors latency, Balanced favors useful recall, "
|
|
1099
|
+
"and Thorough favors accuracy. Disable a service when it cannot materially help the requested goal. "
|
|
1100
|
+
"Return JSON only with: summary (string), extractionFocus (array of short strings), useTranscript, "
|
|
1101
|
+
"useOcr, useObjectDetection, useSemanticVision, useVideoEmbeddings, useSceneCuts (booleans), "
|
|
1102
|
+
"sampleIntervalSecs, prioritySampleIntervalSecs, clipWindowSecs, framesPerClip "
|
|
1103
|
+
"(numbers), and priorityRanges (array of {startSecs,endSecs,reason}). Keep at most 12 ranges and "
|
|
1104
|
+
"do not include a range unless a supplied timestamped signal supports it. Keep summary under "
|
|
1105
|
+
"30 words and extractionFocus at 8 items or fewer.\nINPUT:\n"
|
|
1106
|
+
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _evenly_spaced(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
|
|
1111
|
+
if limit <= 0 or not items:
|
|
1112
|
+
return []
|
|
1113
|
+
if len(items) <= limit:
|
|
1114
|
+
return list(items)
|
|
1115
|
+
if limit == 1:
|
|
1116
|
+
return [items[0]]
|
|
1117
|
+
step = (len(items) - 1) / (limit - 1)
|
|
1118
|
+
return [items[round(index * step)] for index in range(limit)]
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _select_synthesis_observations(
|
|
1122
|
+
observations: list[dict[str, Any]],
|
|
1123
|
+
overlay_text: list[dict[str, Any]],
|
|
1124
|
+
limit: int,
|
|
1125
|
+
) -> list[dict[str, Any]]:
|
|
1126
|
+
"""Keep chronological coverage plus the moments where the source changes.
|
|
1127
|
+
|
|
1128
|
+
Both ends of the source are always represented. Beyond that, a moment
|
|
1129
|
+
where a persistent on-screen overlay first appeared or last disappeared
|
|
1130
|
+
is where something in the video changed, whatever the video is about, so
|
|
1131
|
+
those observations survive the budget ahead of an even sample.
|
|
1132
|
+
"""
|
|
1133
|
+
if len(observations) <= limit:
|
|
1134
|
+
return list(observations)
|
|
1135
|
+
change_times = [
|
|
1136
|
+
time_ms
|
|
1137
|
+
for overlay in overlay_text
|
|
1138
|
+
for time_ms in (
|
|
1139
|
+
int(overlay.get("firstSeenMs") or 0),
|
|
1140
|
+
int(overlay.get("lastSeenMs") or 0),
|
|
1141
|
+
)
|
|
1142
|
+
]
|
|
1143
|
+
boundary_width = min(3, len(observations))
|
|
1144
|
+
priority_indexes = [
|
|
1145
|
+
*range(boundary_width),
|
|
1146
|
+
*range(len(observations) - boundary_width, len(observations)),
|
|
1147
|
+
]
|
|
1148
|
+
for index, item in enumerate(observations):
|
|
1149
|
+
start_ms = int(item.get("startMs") or 0)
|
|
1150
|
+
end_ms = int(item.get("endMs") or start_ms)
|
|
1151
|
+
if any(
|
|
1152
|
+
end_ms >= time_ms - 2_000 and start_ms <= time_ms + 2_000
|
|
1153
|
+
for time_ms in change_times
|
|
1154
|
+
):
|
|
1155
|
+
priority_indexes.append(index)
|
|
1156
|
+
priority_indexes = list(dict.fromkeys(priority_indexes))
|
|
1157
|
+
if len(priority_indexes) > limit:
|
|
1158
|
+
sampled_priority = _evenly_spaced(
|
|
1159
|
+
[{"index": index} for index in priority_indexes], limit
|
|
1160
|
+
)
|
|
1161
|
+
selected_indexes = {int(item["index"]) for item in sampled_priority}
|
|
1162
|
+
else:
|
|
1163
|
+
selected_indexes = set(priority_indexes)
|
|
1164
|
+
remaining = limit - len(selected_indexes)
|
|
1165
|
+
sampled_all = _evenly_spaced(
|
|
1166
|
+
[{"index": index} for index in range(len(observations))],
|
|
1167
|
+
min(len(observations), max(remaining * 2, remaining)),
|
|
1168
|
+
)
|
|
1169
|
+
for item in sampled_all:
|
|
1170
|
+
selected_indexes.add(int(item["index"]))
|
|
1171
|
+
if len(selected_indexes) >= limit:
|
|
1172
|
+
break
|
|
1173
|
+
return [observations[index] for index in sorted(selected_indexes)]
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _source_inventory_chunks(
|
|
1177
|
+
*,
|
|
1178
|
+
duration_secs: float,
|
|
1179
|
+
transcript: list[dict[str, Any]],
|
|
1180
|
+
semantic_observations: list[dict[str, Any]],
|
|
1181
|
+
overlay_text: list[dict[str, Any]],
|
|
1182
|
+
) -> list[dict[str, Any]]:
|
|
1183
|
+
window_ms = 15 * 60 * 1_000
|
|
1184
|
+
duration_ms = max(1, round(duration_secs * 1_000))
|
|
1185
|
+
chunks: list[dict[str, Any]] = []
|
|
1186
|
+
for start_ms in range(0, duration_ms, window_ms):
|
|
1187
|
+
end_ms = min(duration_ms, start_ms + window_ms)
|
|
1188
|
+
|
|
1189
|
+
def timed(items: list[dict[str, Any]], text_limit: int):
|
|
1190
|
+
selected: list[dict[str, Any]] = []
|
|
1191
|
+
for item in items:
|
|
1192
|
+
item_start = round(float(item.get("startMs") or 0))
|
|
1193
|
+
item_end = round(float(item.get("endMs") or item_start))
|
|
1194
|
+
text = str(item.get("text") or "").strip()[:text_limit]
|
|
1195
|
+
if text and item_start < end_ms and item_end >= start_ms:
|
|
1196
|
+
selected.append(
|
|
1197
|
+
{"startMs": item_start, "endMs": item_end, "text": text}
|
|
1198
|
+
)
|
|
1199
|
+
return selected
|
|
1200
|
+
|
|
1201
|
+
visible = timed(
|
|
1202
|
+
[
|
|
1203
|
+
{
|
|
1204
|
+
**item,
|
|
1205
|
+
# Claim fields describe the indexing task supplied to the
|
|
1206
|
+
# analyzer. They are not source-authored content and must
|
|
1207
|
+
# never be offered to the source-inventory mapper.
|
|
1208
|
+
"text": "\n".join(
|
|
1209
|
+
line
|
|
1210
|
+
for line in str(item.get("text") or "").splitlines()
|
|
1211
|
+
if not re.match(
|
|
1212
|
+
r"^Claim (?:question|verdict|answer|bindings):",
|
|
1213
|
+
line.strip(),
|
|
1214
|
+
re.I,
|
|
1215
|
+
)
|
|
1216
|
+
),
|
|
1217
|
+
}
|
|
1218
|
+
for item in semantic_observations
|
|
1219
|
+
],
|
|
1220
|
+
2_000,
|
|
1221
|
+
)
|
|
1222
|
+
spoken = timed(transcript, 500)
|
|
1223
|
+
recurring = [
|
|
1224
|
+
{
|
|
1225
|
+
"startMs": round(float(item.get("firstSeenMs") or 0)),
|
|
1226
|
+
"endMs": round(float(item.get("lastSeenMs") or 0)),
|
|
1227
|
+
"text": str(item.get("text") or "").strip()[:300],
|
|
1228
|
+
}
|
|
1229
|
+
for item in overlay_text
|
|
1230
|
+
if str(item.get("text") or "").strip()
|
|
1231
|
+
and float(item.get("firstSeenMs") or 0) < end_ms
|
|
1232
|
+
and float(item.get("lastSeenMs") or 0) >= start_ms
|
|
1233
|
+
]
|
|
1234
|
+
if spoken or visible or recurring:
|
|
1235
|
+
chunks.append(
|
|
1236
|
+
{
|
|
1237
|
+
"range": {"startMs": start_ms, "endMs": end_ms},
|
|
1238
|
+
"spokenEvidence": spoken,
|
|
1239
|
+
"visibleEvidence": visible,
|
|
1240
|
+
"recurringVisibleText": recurring,
|
|
1241
|
+
}
|
|
1242
|
+
)
|
|
1243
|
+
return chunks
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _source_inventory_prompt(chunk: dict[str, Any]) -> str:
|
|
1247
|
+
return (
|
|
1248
|
+
"Create an exhaustive inventory of discrete source-authored units in this timestamped "
|
|
1249
|
+
"portion of a recording. The source can be any kind of recording. Extract only: questions "
|
|
1250
|
+
"actually asked by a speaker or visibly written; headings or titles; individual slide, "
|
|
1251
|
+
"board, or explicitly enumerated list items. Do not turn ordinary narration, conversation, "
|
|
1252
|
+
"descriptions, model instructions, or analysis prompts into inventory items. Preserve the "
|
|
1253
|
+
"source language and wording. For a spoken question, start at the actual interrogative or "
|
|
1254
|
+
"request and omit surrounding banter, answers, and reactions. If a nearby source passage "
|
|
1255
|
+
"explicitly answers a question, "
|
|
1256
|
+
"copy the answer; otherwise use an empty answer. Give the narrowest supplied timestamp "
|
|
1257
|
+
"that supports each item. A recurring header is one item in this portion, while separate "
|
|
1258
|
+
"questions or differently worded items remain separate. Never infer missing words or add "
|
|
1259
|
+
"outside facts. Return JSON only: {items:[{kind:'question'|'heading'|'slide-item'|"
|
|
1260
|
+
"'board-item'|'list-item',channel:'spoken'|'visible',text:string,answer:string,"
|
|
1261
|
+
"startMs:number,endMs:number}]}.\nINPUT:\n"
|
|
1262
|
+
+ json.dumps(chunk, ensure_ascii=False, separators=(",", ":"))
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def _inventory_terms(value: str) -> list[str]:
|
|
1267
|
+
return re.findall(r"[\w\u0600-\u06ff]+", value.casefold(), re.UNICODE)
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def _looks_like_spoken_question(value: str) -> bool:
|
|
1271
|
+
text = value.strip()
|
|
1272
|
+
if re.search(r"[?؟]\s*$", text):
|
|
1273
|
+
return True
|
|
1274
|
+
cue = re.compile(
|
|
1275
|
+
r"^(?:who|what|when|where|why|how|which|whose|whom|is|are|was|were|do|does|did|"
|
|
1276
|
+
r"can|could|would|will|name|list|identify|describe|tell|give|"
|
|
1277
|
+
r"من|ما|ماذا|متى|أين|اين|كيف|كم|هل|أي|اي|لماذا|مين|إيه|ايه|فين|امتى|ازاي|"
|
|
1278
|
+
r"اذكر|أذكر|حدد|سم|سمي)$",
|
|
1279
|
+
re.I,
|
|
1280
|
+
)
|
|
1281
|
+
return any(cue.match(term) for term in _inventory_terms(text)[:4])
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _inventory_item_is_grounded(
|
|
1285
|
+
item: dict[str, Any], source_chunk: dict[str, Any]
|
|
1286
|
+
) -> bool:
|
|
1287
|
+
channel = item["channel"]
|
|
1288
|
+
source_key = "spokenEvidence" if channel == "spoken" else "visibleEvidence"
|
|
1289
|
+
candidate_terms = _inventory_terms(item["text"])
|
|
1290
|
+
if not candidate_terms:
|
|
1291
|
+
return False
|
|
1292
|
+
start_ms = item["startMs"]
|
|
1293
|
+
end_ms = item["endMs"]
|
|
1294
|
+
nearby = [
|
|
1295
|
+
source
|
|
1296
|
+
for source in source_chunk.get(source_key) or []
|
|
1297
|
+
if float(source.get("startMs") or 0) <= end_ms + 5_000
|
|
1298
|
+
and float(source.get("endMs") or source.get("startMs") or 0)
|
|
1299
|
+
>= start_ms - 5_000
|
|
1300
|
+
]
|
|
1301
|
+
if not nearby:
|
|
1302
|
+
nearby = source_chunk.get(source_key) or []
|
|
1303
|
+
source_terms = set(
|
|
1304
|
+
_inventory_terms(" ".join(str(source.get("text") or "") for source in nearby))
|
|
1305
|
+
)
|
|
1306
|
+
matched = sum(1 for term in candidate_terms if term in source_terms)
|
|
1307
|
+
return matched / len(candidate_terms) >= 0.7
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _validated_source_inventory(
|
|
1311
|
+
raw: dict[str, Any],
|
|
1312
|
+
duration_secs: float,
|
|
1313
|
+
source_chunk: dict[str, Any] | None = None,
|
|
1314
|
+
) -> list[dict[str, Any]]:
|
|
1315
|
+
duration_ms = max(1, round(duration_secs * 1_000))
|
|
1316
|
+
allowed_kinds = {"question", "heading", "slide-item", "board-item", "list-item"}
|
|
1317
|
+
allowed_channels = {"spoken", "visible"}
|
|
1318
|
+
items: list[dict[str, Any]] = []
|
|
1319
|
+
for candidate in raw.get("items") or []:
|
|
1320
|
+
if not isinstance(candidate, dict):
|
|
1321
|
+
continue
|
|
1322
|
+
kind = str(candidate.get("kind") or "").strip()
|
|
1323
|
+
channel = str(candidate.get("channel") or "").strip()
|
|
1324
|
+
text = str(candidate.get("text") or "").strip()[:600]
|
|
1325
|
+
if kind not in allowed_kinds or channel not in allowed_channels or not text:
|
|
1326
|
+
continue
|
|
1327
|
+
try:
|
|
1328
|
+
start_ms = min(
|
|
1329
|
+
duration_ms, max(0, round(float(candidate.get("startMs"))))
|
|
1330
|
+
)
|
|
1331
|
+
end_ms = min(
|
|
1332
|
+
duration_ms,
|
|
1333
|
+
max(start_ms, round(float(candidate.get("endMs")))),
|
|
1334
|
+
)
|
|
1335
|
+
except (TypeError, ValueError):
|
|
1336
|
+
continue
|
|
1337
|
+
item = {
|
|
1338
|
+
"kind": kind,
|
|
1339
|
+
"channel": channel,
|
|
1340
|
+
"text": text,
|
|
1341
|
+
"answer": str(candidate.get("answer") or "").strip()[:600],
|
|
1342
|
+
"startMs": start_ms,
|
|
1343
|
+
"endMs": end_ms,
|
|
1344
|
+
}
|
|
1345
|
+
if kind == "question" and channel == "spoken" and not _looks_like_spoken_question(text):
|
|
1346
|
+
continue
|
|
1347
|
+
if source_chunk is not None and not _inventory_item_is_grounded(item, source_chunk):
|
|
1348
|
+
continue
|
|
1349
|
+
items.append(item)
|
|
1350
|
+
return items
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def _fallback_source_inventory(
|
|
1354
|
+
semantic_observations: list[dict[str, Any]], duration_secs: float
|
|
1355
|
+
) -> list[dict[str, Any]]:
|
|
1356
|
+
items: list[dict[str, Any]] = []
|
|
1357
|
+
for observation in semantic_observations:
|
|
1358
|
+
lines = str(observation.get("text") or "").splitlines()
|
|
1359
|
+
for index, line in enumerate(lines):
|
|
1360
|
+
match = re.match(
|
|
1361
|
+
r"^Source question \((spoken|visible)\):\s*(.+)$", line.strip(), re.I
|
|
1362
|
+
)
|
|
1363
|
+
if not match:
|
|
1364
|
+
continue
|
|
1365
|
+
answer_match = (
|
|
1366
|
+
re.match(r"^Source answer:\s*(.*)$", lines[index + 1].strip(), re.I)
|
|
1367
|
+
if index + 1 < len(lines)
|
|
1368
|
+
else None
|
|
1369
|
+
)
|
|
1370
|
+
items.append(
|
|
1371
|
+
{
|
|
1372
|
+
"kind": "question",
|
|
1373
|
+
"channel": match.group(1).lower(),
|
|
1374
|
+
"text": match.group(2).strip()[:600],
|
|
1375
|
+
"answer": (answer_match.group(1).strip()[:600] if answer_match else ""),
|
|
1376
|
+
"startMs": observation.get("startMs") or 0,
|
|
1377
|
+
"endMs": observation.get("endMs") or observation.get("startMs") or 0,
|
|
1378
|
+
}
|
|
1379
|
+
)
|
|
1380
|
+
return _validated_source_inventory({"items": items}, duration_secs)
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _merge_source_inventory(*groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1384
|
+
merged: list[dict[str, Any]] = []
|
|
1385
|
+
seen: set[tuple[str, str, int]] = set()
|
|
1386
|
+
for item in sorted(
|
|
1387
|
+
(item for group in groups for item in group),
|
|
1388
|
+
key=lambda value: (value["startMs"], value["endMs"], value["kind"]),
|
|
1389
|
+
):
|
|
1390
|
+
normalized = re.sub(r"\s+", " ", item["text"].casefold()).strip()
|
|
1391
|
+
key = (item["kind"], normalized, round(item["startMs"] / 5_000))
|
|
1392
|
+
if not normalized or key in seen:
|
|
1393
|
+
continue
|
|
1394
|
+
seen.add(key)
|
|
1395
|
+
merged.append(item)
|
|
1396
|
+
return merged[:2_000]
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def _synthesis_prompt(
|
|
1400
|
+
*,
|
|
1401
|
+
brief: dict[str, Any],
|
|
1402
|
+
duration_secs: float,
|
|
1403
|
+
plan: ExtractionPlan,
|
|
1404
|
+
semantic_observations: list[dict[str, Any]],
|
|
1405
|
+
transcript: list[dict[str, Any]],
|
|
1406
|
+
overlay_text: list[dict[str, Any]],
|
|
1407
|
+
compact: bool = False,
|
|
1408
|
+
) -> str:
|
|
1409
|
+
observation_limit = 28 if compact else 160
|
|
1410
|
+
transcript_limit = 32 if compact else 160
|
|
1411
|
+
selected_observations = _select_synthesis_observations(
|
|
1412
|
+
semantic_observations,
|
|
1413
|
+
overlay_text,
|
|
1414
|
+
observation_limit,
|
|
1415
|
+
)
|
|
1416
|
+
# A note leads with what happened and ends with the readings and events
|
|
1417
|
+
# that support it. Truncating it mid-way keeps the narration and discards
|
|
1418
|
+
# exactly the timestamped specifics the index is built to preserve.
|
|
1419
|
+
evidence = [
|
|
1420
|
+
{
|
|
1421
|
+
"startMs": round(float(item.get("startMs") or 0)),
|
|
1422
|
+
"endMs": round(float(item.get("endMs") or 0)),
|
|
1423
|
+
"text": str(item.get("text") or "")[: 700 if compact else 2_000],
|
|
1424
|
+
}
|
|
1425
|
+
for item in selected_observations
|
|
1426
|
+
if str(item.get("text") or "").strip()
|
|
1427
|
+
]
|
|
1428
|
+
transcript_signal = [
|
|
1429
|
+
{
|
|
1430
|
+
"startMs": round(float(item.get("startMs") or 0)),
|
|
1431
|
+
"endMs": round(float(item.get("endMs") or 0)),
|
|
1432
|
+
"text": str(item.get("text") or "")[: 180 if compact else 300],
|
|
1433
|
+
}
|
|
1434
|
+
for item in _evenly_spaced(transcript, transcript_limit)
|
|
1435
|
+
if str(item.get("text") or "").strip()
|
|
1436
|
+
]
|
|
1437
|
+
coverage_scale = max(1, min(8, round(duration_secs / (15 * 60))))
|
|
1438
|
+
participant_limit = 12 if compact else min(64, max(12, coverage_scale * 8))
|
|
1439
|
+
state_limit = 8 if compact else min(32, max(8, coverage_scale * 4))
|
|
1440
|
+
event_limit = 14 if compact else min(64, max(14, coverage_scale * 8))
|
|
1441
|
+
narrative_limit = 10 if compact else min(48, max(12, coverage_scale * 6))
|
|
1442
|
+
context_limit = 8 if compact else min(32, max(8, coverage_scale * 4))
|
|
1443
|
+
uncertainty_limit = 8 if compact else min(16, max(8, coverage_scale * 2))
|
|
1444
|
+
payload = {
|
|
1445
|
+
"durationMs": round(duration_secs * 1_000),
|
|
1446
|
+
"goal": str(brief.get("goal") or "")[:2_000],
|
|
1447
|
+
"questions": list(brief.get("expectedQuestions") or [])[:20],
|
|
1448
|
+
"extractionFocus": plan.extraction_focus,
|
|
1449
|
+
"sourceLanguage": str(
|
|
1450
|
+
brief.get("detectedLanguage") or brief.get("language") or "auto"
|
|
1451
|
+
)[:32],
|
|
1452
|
+
"semanticEvidence": evidence,
|
|
1453
|
+
"transcriptEvidence": transcript_signal,
|
|
1454
|
+
"recurringOnScreenText": [
|
|
1455
|
+
{
|
|
1456
|
+
"text": str(item.get("text") or "")[:120],
|
|
1457
|
+
"firstSeenMs": int(item.get("firstSeenMs") or 0),
|
|
1458
|
+
"lastSeenMs": int(item.get("lastSeenMs") or 0),
|
|
1459
|
+
"observations": int(item.get("observations") or 0),
|
|
1460
|
+
}
|
|
1461
|
+
for item in overlay_text[:60]
|
|
1462
|
+
],
|
|
1463
|
+
}
|
|
1464
|
+
return (
|
|
1465
|
+
"Build a video knowledge index from timestamped evidence, so that someone who never saw "
|
|
1466
|
+
"the video can answer questions about it. The video may be of any kind; infer nothing from "
|
|
1467
|
+
"its subject matter and add no facts from outside the supplied evidence.\n"
|
|
1468
|
+
"Rules:\n"
|
|
1469
|
+
"- Every participant, state, event, and context fact must cite at least one supplied source range.\n"
|
|
1470
|
+
"- Write what the evidence establishes, in the evidence's own terms. Where two observations "
|
|
1471
|
+
"disagree, prefer the one that repeats across separate source ranges, and record the "
|
|
1472
|
+
"disagreement in uncertainties rather than choosing one silently.\n"
|
|
1473
|
+
"- The evidence records what it observed as lines: a note, 'Present: <name> — <what> "
|
|
1474
|
+
"(identified by <what established it>)', 'On screen: <exact text> — <what it conveys>', and "
|
|
1475
|
+
"'Happened: <event>'. A 'Present:' line that carries an 'identified by' clause has already "
|
|
1476
|
+
"established that identity from the source; use the name. Do not name anyone the evidence "
|
|
1477
|
+
"did not name, and never infer an identity from appearance, a number, a role, or outside "
|
|
1478
|
+
"knowledge.\n"
|
|
1479
|
+
"- Context facts may preserve a directly visible distinction between multiple otherwise unnamed "
|
|
1480
|
+
"subjects (such as position, a visible attribute, or a stated role), with their source range. "
|
|
1481
|
+
"Use a stable descriptor from the evidence; never invent a name to make the distinction useful.\n"
|
|
1482
|
+
"- A later close-up, label, caption, presentation, celebration, or reaction does not identify "
|
|
1483
|
+
"who performed an earlier action. Bind the name to that earlier actor only when an uninterrupted "
|
|
1484
|
+
"sequence tracks the same subject or the supplied evidence explicitly states the relationship.\n"
|
|
1485
|
+
"- stateHistory tracks things that persist and change: a displayed value, a location, a phase, "
|
|
1486
|
+
"a condition. Record each distinct value once with the span it held, and keep the sequence "
|
|
1487
|
+
"self-consistent -- one thing cannot hold two values at the same time, and a value it never "
|
|
1488
|
+
"reached cannot appear between two values it did.\n"
|
|
1489
|
+
"- Edited video re-shows moments: a recap, a repeat, an inset, a slowed-down retake, a preview of "
|
|
1490
|
+
"something still to come. Evidence from a re-shown moment describes when it originally happened, "
|
|
1491
|
+
"not the point in the video where it appears. When a later observation shows the earlier state "
|
|
1492
|
+
"still holding, the apparent change was a re-showing, and it belongs in neither the ledger nor "
|
|
1493
|
+
"the events.\n"
|
|
1494
|
+
"- keyEvents are moments something happened. Include one when the evidence shows it, and say what "
|
|
1495
|
+
"the evidence shows rather than what it implies. An event that would move a tracked value belongs "
|
|
1496
|
+
"here only when the ledger actually moves to that value and stays there.\n"
|
|
1497
|
+
"- narrative is the human notebook: a compact, continuous chronological account that combines "
|
|
1498
|
+
"the synchronized speech, named participants, visual layout, appearance, on-screen labels, states, "
|
|
1499
|
+
"and events. Each entry must make sense after the preceding entry, so preserve who is on which side "
|
|
1500
|
+
"or in which group and carry that identity forward only while the evidence supports it. Replace vague "
|
|
1501
|
+
"descriptions such as 'two people discuss something' with the supported names, relationships, and "
|
|
1502
|
+
"purpose available anywhere in the supplied evidence. Do not invent a bridge across an edit. Write "
|
|
1503
|
+
"narrative text in the dominant spoken language identified by sourceLanguage (or the dominant language "
|
|
1504
|
+
"of transcriptEvidence when it is auto), while preserving names and exact on-screen text as supplied. "
|
|
1505
|
+
"Do not repeatedly say 'the video shows' or describe the act of analysis.\n"
|
|
1506
|
+
"- recurringOnScreenText lists short text that stayed legible across several frames. It marks "
|
|
1507
|
+
"where a display existed and when it changed. It never states what that text means: use it to "
|
|
1508
|
+
"corroborate or locate, never as a fact on its own.\n"
|
|
1509
|
+
"Return JSON only: {overview:string, participants:[{name,role,evidence:[{startMs,endMs}]}], "
|
|
1510
|
+
"stateHistory:[{startMs,endMs,state,confidence:'direct'|'partial'}], "
|
|
1511
|
+
"keyEvents:[{startMs,endMs,event,confidence:'direct'|'partial'}], "
|
|
1512
|
+
"narrative:[{startMs,endMs,text,confidence:'direct'|'partial'}], "
|
|
1513
|
+
"context:[{fact,evidence:[{startMs,endMs}]}], uncertainties:[string]}. "
|
|
1514
|
+
"Use 'direct' when the cited evidence states it outright and 'partial' when it is inferred from "
|
|
1515
|
+
"context. Deduplicate, keep chronological order, and cover the whole supplied span rather than "
|
|
1516
|
+
"clustering on one part of it. Stay within "
|
|
1517
|
+
f"{participant_limit} participants, {state_limit} states, {event_limit} events, "
|
|
1518
|
+
f"{narrative_limit} narrative entries, "
|
|
1519
|
+
f"{context_limit} context facts, and {uncertainty_limit} uncertainties, each under 20 words.\n"
|
|
1520
|
+
"INPUT:\n" + json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
1521
|
+
)
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _consistency_audit_prompt(
|
|
1525
|
+
*,
|
|
1526
|
+
summary: dict[str, Any],
|
|
1527
|
+
semantic_observations: list[dict[str, Any]],
|
|
1528
|
+
) -> str:
|
|
1529
|
+
cited_ranges: list[tuple[int, int]] = []
|
|
1530
|
+
for event in summary.get("keyEvents") or []:
|
|
1531
|
+
cited_ranges.append(
|
|
1532
|
+
(int(event.get("startMs") or 0), int(event.get("endMs") or 0))
|
|
1533
|
+
)
|
|
1534
|
+
for participant in summary.get("participants") or []:
|
|
1535
|
+
for source_range in participant.get("evidence") or []:
|
|
1536
|
+
cited_ranges.append(
|
|
1537
|
+
(
|
|
1538
|
+
int(source_range.get("startMs") or 0),
|
|
1539
|
+
int(source_range.get("endMs") or 0),
|
|
1540
|
+
)
|
|
1541
|
+
)
|
|
1542
|
+
for state in summary.get("stateHistory") or []:
|
|
1543
|
+
cited_ranges.append(
|
|
1544
|
+
(int(state.get("startMs") or 0), int(state.get("endMs") or 0))
|
|
1545
|
+
)
|
|
1546
|
+
for context in summary.get("context") or []:
|
|
1547
|
+
for source_range in context.get("evidence") or []:
|
|
1548
|
+
cited_ranges.append(
|
|
1549
|
+
(
|
|
1550
|
+
int(source_range.get("startMs") or 0),
|
|
1551
|
+
int(source_range.get("endMs") or 0),
|
|
1552
|
+
)
|
|
1553
|
+
)
|
|
1554
|
+
focused_evidence = []
|
|
1555
|
+
for item in semantic_observations[:160]:
|
|
1556
|
+
text = str(item.get("text") or "").strip()
|
|
1557
|
+
if not text:
|
|
1558
|
+
continue
|
|
1559
|
+
start_ms = round(float(item.get("startMs") or 0))
|
|
1560
|
+
end_ms = round(float(item.get("endMs") or 0))
|
|
1561
|
+
if cited_ranges and not any(
|
|
1562
|
+
end_ms >= cited_start - 2_000 and start_ms <= cited_end + 2_000
|
|
1563
|
+
for cited_start, cited_end in cited_ranges
|
|
1564
|
+
):
|
|
1565
|
+
continue
|
|
1566
|
+
focused_evidence.append(
|
|
1567
|
+
# The audit judges claims against these notes, so it needs the
|
|
1568
|
+
# readings and events a note ends with, not only its narration.
|
|
1569
|
+
{"startMs": start_ms, "endMs": end_ms, "text": text[:1_400]}
|
|
1570
|
+
)
|
|
1571
|
+
focused_evidence = _select_synthesis_observations(focused_evidence, [], 24)
|
|
1572
|
+
return (
|
|
1573
|
+
"A draft index was written from the video evidence below. Check each of its claims against that "
|
|
1574
|
+
"evidence and say which survive. This is a consistency check on any kind of video; infer nothing "
|
|
1575
|
+
"from the subject matter and add no facts.\n"
|
|
1576
|
+
"Return one decision for every candidate index in every category.\n"
|
|
1577
|
+
"Keep a claim when the cited evidence states it. Drop it when the evidence only suggests it, when "
|
|
1578
|
+
"it contradicts other evidence at a different time, or when it is not in the evidence at all.\n"
|
|
1579
|
+
"Read candidateStates as one sequence and make it coherent before judging anything else. A thing "
|
|
1580
|
+
"that persists holds one value at a time and moves between values in an order the evidence "
|
|
1581
|
+
"supports. Where the sequence doubles back -- a value appears, changes, then the earlier value is "
|
|
1582
|
+
"shown again later -- the odd one out came from a moment the video re-showed (a recap, repeat, "
|
|
1583
|
+
"inset, or preview), not from the state changing twice. Drop the interloper and keep the "
|
|
1584
|
+
"sequence the later, settled evidence supports.\n"
|
|
1585
|
+
"- stateDecisions: when keep=true, replacementState may restate the claim more precisely in the "
|
|
1586
|
+
"evidence's own words; leave it empty to keep the original wording. neutralState restates the same "
|
|
1587
|
+
"observation with no entity attributed to it, for use when the evidence shows the value but not "
|
|
1588
|
+
"whose it is. Set entityMappingSupported=true only when the evidence itself attaches that value to "
|
|
1589
|
+
"that entity; when it does not, the neutral wording is used instead.\n"
|
|
1590
|
+
"- eventDecisions: keep an event when the evidence shows it happening. Drop it when only its "
|
|
1591
|
+
"aftermath, a reaction, or a later mention is present, or when a later observation shows the "
|
|
1592
|
+
"situation unchanged.\n"
|
|
1593
|
+
"- participantDecisions: keep a name where the evidence names that person -- a 'Present:' "
|
|
1594
|
+
"line whose 'identified by' clause cites source text or speech has already established it, "
|
|
1595
|
+
"and dropping such a name loses a fact the source actually stated. Drop a name the evidence "
|
|
1596
|
+
"only implies: appearance, a number, a role, or outside knowledge never establishes "
|
|
1597
|
+
"identity, and a later label, close-up, presentation, or reaction never retroactively "
|
|
1598
|
+
"identifies the performer of an earlier action without uninterrupted tracking or an "
|
|
1599
|
+
"explicit relationship in the supplied evidence.\n"
|
|
1600
|
+
"- contextDecisions: a fact may describe only what its cited ranges show. replacementFact narrows "
|
|
1601
|
+
"an overreaching one; an empty replacement keeps the original.\n"
|
|
1602
|
+
"Rewrite the overview so it agrees with what survived, and add concise uncertainties for what was "
|
|
1603
|
+
"dropped. Return JSON only as "
|
|
1604
|
+
"{overview:string,stateDecisions:[{index,keep,replacementState,neutralState,entityMappingSupported,reason}],"
|
|
1605
|
+
"eventDecisions:[{index,keep,reason}],participantDecisions:[{index,keep,reason}],"
|
|
1606
|
+
"contextDecisions:[{index,keep,replacementFact,reason}],uncertainties:[string]}.\nINPUT:\n"
|
|
1607
|
+
+ json.dumps(
|
|
1608
|
+
{
|
|
1609
|
+
"candidateStates": summary.get("stateHistory") or [],
|
|
1610
|
+
"candidateEvents": summary.get("keyEvents") or [],
|
|
1611
|
+
"candidateParticipants": summary.get("participants") or [],
|
|
1612
|
+
"candidateContext": summary.get("context") or [],
|
|
1613
|
+
"draftOverview": summary.get("overview") or "",
|
|
1614
|
+
"draftUncertainties": summary.get("uncertainties") or [],
|
|
1615
|
+
"candidateEvidence": focused_evidence,
|
|
1616
|
+
},
|
|
1617
|
+
ensure_ascii=False,
|
|
1618
|
+
separators=(",", ":"),
|
|
1619
|
+
)
|
|
1620
|
+
)
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def _apply_consistency_audit(
|
|
1624
|
+
summary: dict[str, Any], audit: dict[str, Any]
|
|
1625
|
+
) -> dict[str, Any]:
|
|
1626
|
+
state_decisions = {
|
|
1627
|
+
item.get("index"): item
|
|
1628
|
+
for item in audit.get("stateDecisions") or []
|
|
1629
|
+
if isinstance(item, dict) and isinstance(item.get("index"), int)
|
|
1630
|
+
}
|
|
1631
|
+
event_decisions = {
|
|
1632
|
+
item.get("index"): item.get("keep") is True
|
|
1633
|
+
for item in audit.get("eventDecisions") or []
|
|
1634
|
+
if isinstance(item, dict) and isinstance(item.get("index"), int)
|
|
1635
|
+
}
|
|
1636
|
+
participant_decisions = {
|
|
1637
|
+
item.get("index"): item.get("keep") is True
|
|
1638
|
+
for item in audit.get("participantDecisions") or []
|
|
1639
|
+
if isinstance(item, dict) and isinstance(item.get("index"), int)
|
|
1640
|
+
}
|
|
1641
|
+
context_decisions = {
|
|
1642
|
+
item.get("index"): item
|
|
1643
|
+
for item in audit.get("contextDecisions") or []
|
|
1644
|
+
if isinstance(item, dict) and isinstance(item.get("index"), int)
|
|
1645
|
+
}
|
|
1646
|
+
states = list(summary.get("stateHistory") or [])
|
|
1647
|
+
events = list(summary.get("keyEvents") or [])
|
|
1648
|
+
participants = list(summary.get("participants") or [])
|
|
1649
|
+
context = list(summary.get("context") or [])
|
|
1650
|
+
# A partial audit is not authoritative. Apply a category only when every
|
|
1651
|
+
# candidate received an explicit decision.
|
|
1652
|
+
if set(event_decisions) == set(range(len(events))):
|
|
1653
|
+
summary["keyEvents"] = [
|
|
1654
|
+
item
|
|
1655
|
+
for index, item in enumerate(events)
|
|
1656
|
+
if event_decisions.get(index, False)
|
|
1657
|
+
]
|
|
1658
|
+
if set(participant_decisions) == set(range(len(participants))):
|
|
1659
|
+
summary["participants"] = [
|
|
1660
|
+
item
|
|
1661
|
+
for index, item in enumerate(participants)
|
|
1662
|
+
if participant_decisions.get(index, False)
|
|
1663
|
+
]
|
|
1664
|
+
if set(state_decisions) == set(range(len(states))):
|
|
1665
|
+
audited_states = []
|
|
1666
|
+
for index, item in enumerate(states):
|
|
1667
|
+
decision = state_decisions[index]
|
|
1668
|
+
if decision.get("keep") is not True:
|
|
1669
|
+
continue
|
|
1670
|
+
# An attributed state ("X is at 3") is far more useful than a
|
|
1671
|
+
# neutral one ("the value is 3"), so it is kept whenever the
|
|
1672
|
+
# auditor confirms the evidence itself makes that attribution.
|
|
1673
|
+
# The neutral wording is the fallback for when it does not.
|
|
1674
|
+
attributed = str(decision.get("replacementState") or "").strip()[:500]
|
|
1675
|
+
neutral = str(decision.get("neutralState") or "").strip()[:500]
|
|
1676
|
+
replacement = (
|
|
1677
|
+
attributed
|
|
1678
|
+
if decision.get("entityMappingSupported") is True and attributed
|
|
1679
|
+
else neutral or str(item.get("state") or "").strip()[:500]
|
|
1680
|
+
)
|
|
1681
|
+
if not replacement:
|
|
1682
|
+
continue
|
|
1683
|
+
if (
|
|
1684
|
+
audited_states
|
|
1685
|
+
and audited_states[-1]["state"].casefold() == replacement.casefold()
|
|
1686
|
+
):
|
|
1687
|
+
audited_states[-1]["endMs"] = max(
|
|
1688
|
+
audited_states[-1]["endMs"], item["endMs"]
|
|
1689
|
+
)
|
|
1690
|
+
if item.get("confidence") != "direct":
|
|
1691
|
+
audited_states[-1]["confidence"] = "partial"
|
|
1692
|
+
continue
|
|
1693
|
+
audited_states.append({**item, "state": replacement})
|
|
1694
|
+
summary["stateHistory"] = audited_states
|
|
1695
|
+
if set(context_decisions) == set(range(len(context))):
|
|
1696
|
+
audited_context = []
|
|
1697
|
+
for index, item in enumerate(context):
|
|
1698
|
+
decision = context_decisions[index]
|
|
1699
|
+
if decision.get("keep") is not True:
|
|
1700
|
+
continue
|
|
1701
|
+
replacement = str(decision.get("replacementFact") or "").strip()[:500]
|
|
1702
|
+
audited_context.append({**item, "fact": replacement or item["fact"]})
|
|
1703
|
+
summary["context"] = audited_context
|
|
1704
|
+
audited_overview = str(audit.get("overview") or "").strip()[:2_000]
|
|
1705
|
+
if audited_overview:
|
|
1706
|
+
summary["overview"] = audited_overview
|
|
1707
|
+
extra_uncertainties = [
|
|
1708
|
+
str(item).strip()[:500]
|
|
1709
|
+
for item in audit.get("uncertainties") or []
|
|
1710
|
+
if str(item).strip()
|
|
1711
|
+
]
|
|
1712
|
+
summary["uncertainties"] = list(
|
|
1713
|
+
dict.fromkeys([*(summary.get("uncertainties") or []), *extra_uncertainties])
|
|
1714
|
+
)[:40]
|
|
1715
|
+
return summary
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def _validated_knowledge_summary(
|
|
1719
|
+
raw: dict[str, Any], duration_secs: float
|
|
1720
|
+
) -> dict[str, Any]:
|
|
1721
|
+
duration_ms = max(1, round(duration_secs * 1_000))
|
|
1722
|
+
|
|
1723
|
+
def source_range(value: Any) -> dict[str, int] | None:
|
|
1724
|
+
if not isinstance(value, dict):
|
|
1725
|
+
return None
|
|
1726
|
+
try:
|
|
1727
|
+
start = min(duration_ms, max(0, round(float(value.get("startMs")))))
|
|
1728
|
+
end = min(duration_ms, max(start, round(float(value.get("endMs")))))
|
|
1729
|
+
except (TypeError, ValueError):
|
|
1730
|
+
return None
|
|
1731
|
+
return {"startMs": start, "endMs": end}
|
|
1732
|
+
|
|
1733
|
+
def ranges(value: Any) -> list[dict[str, int]]:
|
|
1734
|
+
return [
|
|
1735
|
+
item for candidate in (value or []) if (item := source_range(candidate))
|
|
1736
|
+
][:12]
|
|
1737
|
+
|
|
1738
|
+
participants: list[dict[str, Any]] = []
|
|
1739
|
+
for candidate in raw.get("participants") or []:
|
|
1740
|
+
if not isinstance(candidate, dict):
|
|
1741
|
+
continue
|
|
1742
|
+
name = str(candidate.get("name") or "").strip()[:160]
|
|
1743
|
+
evidence = ranges(candidate.get("evidence"))
|
|
1744
|
+
if name and evidence:
|
|
1745
|
+
participants.append(
|
|
1746
|
+
{
|
|
1747
|
+
"name": name,
|
|
1748
|
+
"role": str(candidate.get("role") or "participant").strip()[:200],
|
|
1749
|
+
"evidence": evidence,
|
|
1750
|
+
}
|
|
1751
|
+
)
|
|
1752
|
+
if len(participants) == 50:
|
|
1753
|
+
break
|
|
1754
|
+
|
|
1755
|
+
def timeline(key: str, text_key: str, limit: int) -> list[dict[str, Any]]:
|
|
1756
|
+
items: list[dict[str, Any]] = []
|
|
1757
|
+
for candidate in raw.get(key) or []:
|
|
1758
|
+
if not isinstance(candidate, dict):
|
|
1759
|
+
continue
|
|
1760
|
+
evidence = source_range(candidate)
|
|
1761
|
+
text = str(candidate.get(text_key) or "").strip()[:500]
|
|
1762
|
+
if not evidence or not text:
|
|
1763
|
+
continue
|
|
1764
|
+
items.append(
|
|
1765
|
+
{
|
|
1766
|
+
**evidence,
|
|
1767
|
+
text_key: text,
|
|
1768
|
+
"confidence": (
|
|
1769
|
+
candidate.get("confidence")
|
|
1770
|
+
if candidate.get("confidence") in {"direct", "partial"}
|
|
1771
|
+
else "partial"
|
|
1772
|
+
),
|
|
1773
|
+
}
|
|
1774
|
+
)
|
|
1775
|
+
if len(items) == limit:
|
|
1776
|
+
break
|
|
1777
|
+
return sorted(items, key=lambda item: (item["startMs"], item["endMs"]))
|
|
1778
|
+
|
|
1779
|
+
context: list[dict[str, Any]] = []
|
|
1780
|
+
for candidate in raw.get("context") or []:
|
|
1781
|
+
if not isinstance(candidate, dict):
|
|
1782
|
+
continue
|
|
1783
|
+
fact = str(candidate.get("fact") or "").strip()[:500]
|
|
1784
|
+
evidence = ranges(candidate.get("evidence"))
|
|
1785
|
+
if fact and evidence:
|
|
1786
|
+
context.append({"fact": fact, "evidence": evidence})
|
|
1787
|
+
if len(context) == 40:
|
|
1788
|
+
break
|
|
1789
|
+
uncertainties = [
|
|
1790
|
+
str(item).strip()[:500]
|
|
1791
|
+
for item in raw.get("uncertainties") or []
|
|
1792
|
+
if str(item).strip()
|
|
1793
|
+
][:40]
|
|
1794
|
+
return {
|
|
1795
|
+
"overview": str(raw.get("overview") or "").strip()[:2_000],
|
|
1796
|
+
"participants": participants,
|
|
1797
|
+
"stateHistory": timeline("stateHistory", "state", 80),
|
|
1798
|
+
"keyEvents": timeline("keyEvents", "event", 80),
|
|
1799
|
+
"narrative": timeline("narrative", "text", 64),
|
|
1800
|
+
"context": context,
|
|
1801
|
+
"uncertainties": uncertainties,
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def _fallback_knowledge_summary(
|
|
1806
|
+
semantic_observations: list[dict[str, Any]],
|
|
1807
|
+
) -> dict[str, Any]:
|
|
1808
|
+
"""Build a useful deterministic chronology when optional model synthesis fails."""
|
|
1809
|
+
ordered = sorted(
|
|
1810
|
+
semantic_observations,
|
|
1811
|
+
key=lambda item: (float(item.get("startMs") or 0), float(item.get("endMs") or 0)),
|
|
1812
|
+
)
|
|
1813
|
+
key_events: list[dict[str, Any]] = []
|
|
1814
|
+
narrative: list[dict[str, Any]] = []
|
|
1815
|
+
participants_by_name: dict[str, dict[str, Any]] = {}
|
|
1816
|
+
state_history: list[dict[str, Any]] = []
|
|
1817
|
+
context_by_fact: dict[str, dict[str, Any]] = {}
|
|
1818
|
+
uncertainties: list[str] = []
|
|
1819
|
+
|
|
1820
|
+
for item in ordered[:80]:
|
|
1821
|
+
start_ms = round(float(item.get("startMs") or 0))
|
|
1822
|
+
end_ms = round(float(item.get("endMs") or start_ms))
|
|
1823
|
+
lines = [line.strip() for line in str(item.get("text") or "").splitlines() if line.strip()]
|
|
1824
|
+
if not lines:
|
|
1825
|
+
continue
|
|
1826
|
+
key_events.append(
|
|
1827
|
+
{
|
|
1828
|
+
"startMs": start_ms,
|
|
1829
|
+
"endMs": end_ms,
|
|
1830
|
+
"event": lines[0][:500],
|
|
1831
|
+
"confidence": "partial",
|
|
1832
|
+
}
|
|
1833
|
+
)
|
|
1834
|
+
narrative.append(
|
|
1835
|
+
{
|
|
1836
|
+
"startMs": start_ms,
|
|
1837
|
+
"endMs": end_ms,
|
|
1838
|
+
"text": lines[0][:500],
|
|
1839
|
+
"confidence": "partial",
|
|
1840
|
+
}
|
|
1841
|
+
)
|
|
1842
|
+
evidence = {"startMs": start_ms, "endMs": end_ms}
|
|
1843
|
+
for line in lines[1:]:
|
|
1844
|
+
participant = re.match(r"^Present:\s*(.+?)\s+—\s+(.+)$", line)
|
|
1845
|
+
if participant:
|
|
1846
|
+
name = participant.group(1).strip()[:160]
|
|
1847
|
+
role = re.sub(r"\s*\(identified by.*$", "", participant.group(2)).strip()[:200]
|
|
1848
|
+
if name and role and any(
|
|
1849
|
+
token in role.casefold()
|
|
1850
|
+
for token in ("person", "participant", "host", "man", "woman", "individual", "speaker")
|
|
1851
|
+
):
|
|
1852
|
+
key = name.casefold()
|
|
1853
|
+
existing = participants_by_name.get(key)
|
|
1854
|
+
if existing:
|
|
1855
|
+
if evidence not in existing["evidence"] and len(existing["evidence"]) < 12:
|
|
1856
|
+
existing["evidence"].append(evidence)
|
|
1857
|
+
else:
|
|
1858
|
+
participants_by_name[key] = {
|
|
1859
|
+
"name": name,
|
|
1860
|
+
"role": role or "participant",
|
|
1861
|
+
"evidence": [evidence],
|
|
1862
|
+
}
|
|
1863
|
+
continue
|
|
1864
|
+
|
|
1865
|
+
visible = re.match(r"^On screen:\s*(.+?)(?:\s+—\s+(.+))?$", line)
|
|
1866
|
+
if visible:
|
|
1867
|
+
shown = visible.group(1).strip()
|
|
1868
|
+
meaning = (visible.group(2) or "visible text").strip()
|
|
1869
|
+
fact = f"{shown} — {meaning}"[:500]
|
|
1870
|
+
if fact:
|
|
1871
|
+
key = fact.casefold()
|
|
1872
|
+
existing = context_by_fact.get(key)
|
|
1873
|
+
if existing:
|
|
1874
|
+
if evidence not in existing["evidence"] and len(existing["evidence"]) < 12:
|
|
1875
|
+
existing["evidence"].append(evidence)
|
|
1876
|
+
elif len(context_by_fact) < 40:
|
|
1877
|
+
context_by_fact[key] = {"fact": fact, "evidence": [evidence]}
|
|
1878
|
+
continue
|
|
1879
|
+
|
|
1880
|
+
if line.startswith("Claim bindings:"):
|
|
1881
|
+
try:
|
|
1882
|
+
bindings = json.loads(line.split(":", 1)[1].strip())
|
|
1883
|
+
except (json.JSONDecodeError, TypeError):
|
|
1884
|
+
bindings = []
|
|
1885
|
+
for binding in bindings if isinstance(bindings, list) else []:
|
|
1886
|
+
if not isinstance(binding, dict):
|
|
1887
|
+
continue
|
|
1888
|
+
state = " ".join(
|
|
1889
|
+
str(binding.get(key) or "").strip()
|
|
1890
|
+
for key in ("subject", "relation", "value")
|
|
1891
|
+
).strip()[:500]
|
|
1892
|
+
if not state:
|
|
1893
|
+
continue
|
|
1894
|
+
candidate = {
|
|
1895
|
+
"startMs": start_ms,
|
|
1896
|
+
"endMs": end_ms,
|
|
1897
|
+
"state": state,
|
|
1898
|
+
"confidence": "direct",
|
|
1899
|
+
}
|
|
1900
|
+
if state_history and state_history[-1]["state"].casefold() == state.casefold():
|
|
1901
|
+
state_history[-1]["endMs"] = max(state_history[-1]["endMs"], end_ms)
|
|
1902
|
+
elif len(state_history) < 80:
|
|
1903
|
+
state_history.append(candidate)
|
|
1904
|
+
continue
|
|
1905
|
+
|
|
1906
|
+
if line.startswith("Direct component:") and len(state_history) < 80:
|
|
1907
|
+
state = line.split(":", 1)[1].strip()[:500]
|
|
1908
|
+
if state:
|
|
1909
|
+
state_history.append(
|
|
1910
|
+
{
|
|
1911
|
+
"startMs": start_ms,
|
|
1912
|
+
"endMs": end_ms,
|
|
1913
|
+
"state": state,
|
|
1914
|
+
"confidence": "direct",
|
|
1915
|
+
}
|
|
1916
|
+
)
|
|
1917
|
+
continue
|
|
1918
|
+
|
|
1919
|
+
if line.startswith("Uncertainty:"):
|
|
1920
|
+
uncertainty = line.split(":", 1)[1].strip()[:500]
|
|
1921
|
+
if uncertainty and uncertainty not in uncertainties:
|
|
1922
|
+
uncertainties.append(uncertainty)
|
|
1923
|
+
|
|
1924
|
+
if not key_events:
|
|
1925
|
+
overview = "No timestamped semantic observations were produced."
|
|
1926
|
+
elif len(key_events) == 1:
|
|
1927
|
+
overview = key_events[0]["event"]
|
|
1928
|
+
else:
|
|
1929
|
+
overview = (
|
|
1930
|
+
f"The sequence opens with {key_events[0]['event']} "
|
|
1931
|
+
f"By the final indexed moment, {key_events[-1]['event']}"
|
|
1932
|
+
)[:2_000]
|
|
1933
|
+
return {
|
|
1934
|
+
"overview": overview,
|
|
1935
|
+
"participants": list(participants_by_name.values())[:50],
|
|
1936
|
+
"stateHistory": state_history,
|
|
1937
|
+
"keyEvents": key_events,
|
|
1938
|
+
"narrative": narrative,
|
|
1939
|
+
"context": list(context_by_fact.values())[:40],
|
|
1940
|
+
"uncertainties": uncertainties,
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
def _json_object(raw: str) -> dict[str, Any]:
|
|
1945
|
+
text = raw.strip()
|
|
1946
|
+
if text.startswith("```"):
|
|
1947
|
+
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
|
1948
|
+
value = json.loads(text)
|
|
1949
|
+
if not isinstance(value, dict):
|
|
1950
|
+
raise ValueError("agent response must be a JSON object")
|
|
1951
|
+
return value
|
|
1952
|
+
|
|
1953
|
+
|
|
1954
|
+
def _google_response_schema(value: Any, *, property_map: bool = False) -> Any:
|
|
1955
|
+
"""Translate the shared strict schema to Gemini's compact supported subset."""
|
|
1956
|
+
if isinstance(value, dict):
|
|
1957
|
+
if property_map:
|
|
1958
|
+
return {key: _google_response_schema(item) for key, item in value.items()}
|
|
1959
|
+
supported = {"type", "properties", "required", "items", "enum"}
|
|
1960
|
+
return {
|
|
1961
|
+
key: _google_response_schema(item, property_map=key == "properties")
|
|
1962
|
+
for key, item in value.items()
|
|
1963
|
+
if key in supported
|
|
1964
|
+
}
|
|
1965
|
+
if isinstance(value, list):
|
|
1966
|
+
return [_google_response_schema(item) for item in value]
|
|
1967
|
+
return value
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def _validated_plan(
|
|
1971
|
+
raw: dict[str, Any],
|
|
1972
|
+
fallback: ExtractionPlan,
|
|
1973
|
+
duration_secs: float,
|
|
1974
|
+
heavy_disabled: bool,
|
|
1975
|
+
) -> ExtractionPlan:
|
|
1976
|
+
mode = fallback.mode
|
|
1977
|
+
bounds = MODE_BOUNDS[mode]
|
|
1978
|
+
|
|
1979
|
+
def bounded_number(key: str, low: float, high: float, default: float) -> float:
|
|
1980
|
+
try:
|
|
1981
|
+
return min(high, max(low, float(raw.get(key, default))))
|
|
1982
|
+
except (TypeError, ValueError):
|
|
1983
|
+
return default
|
|
1984
|
+
|
|
1985
|
+
ranges: list[PriorityRange] = []
|
|
1986
|
+
for candidate in raw.get("priorityRanges") or []:
|
|
1987
|
+
if not isinstance(candidate, dict):
|
|
1988
|
+
continue
|
|
1989
|
+
try:
|
|
1990
|
+
start = max(0.0, float(candidate.get("startSecs")))
|
|
1991
|
+
end = min(duration_secs, float(candidate.get("endSecs")))
|
|
1992
|
+
except (TypeError, ValueError):
|
|
1993
|
+
continue
|
|
1994
|
+
if end <= start:
|
|
1995
|
+
continue
|
|
1996
|
+
ranges.append(
|
|
1997
|
+
PriorityRange(
|
|
1998
|
+
start, end, str(candidate.get("reason") or "source signal")[:160]
|
|
1999
|
+
)
|
|
2000
|
+
)
|
|
2001
|
+
if len(ranges) == 12:
|
|
2002
|
+
break
|
|
2003
|
+
|
|
2004
|
+
focus = [
|
|
2005
|
+
str(item).strip()[:160]
|
|
2006
|
+
for item in raw.get("extractionFocus") or []
|
|
2007
|
+
if str(item).strip()
|
|
2008
|
+
]
|
|
2009
|
+
focus = list(dict.fromkeys(focus))[:12] or fallback.extraction_focus
|
|
2010
|
+
|
|
2011
|
+
def flag(key: str, default: bool) -> bool:
|
|
2012
|
+
value = raw.get(key)
|
|
2013
|
+
return value if isinstance(value, bool) else default
|
|
2014
|
+
|
|
2015
|
+
sample = bounded_number(
|
|
2016
|
+
"sampleIntervalSecs", *bounds["sample"], fallback.sample_interval_secs
|
|
2017
|
+
)
|
|
2018
|
+
priority = bounded_number(
|
|
2019
|
+
"prioritySampleIntervalSecs",
|
|
2020
|
+
*bounds["priority"],
|
|
2021
|
+
fallback.priority_sample_interval_secs,
|
|
2022
|
+
)
|
|
2023
|
+
clip = bounded_number("clipWindowSecs", *bounds["clip"], fallback.clip_window_secs)
|
|
2024
|
+
frames = round(
|
|
2025
|
+
bounded_number("framesPerClip", *bounds["frames"], fallback.frames_per_clip)
|
|
2026
|
+
)
|
|
2027
|
+
use_transcript = flag("useTranscript", fallback.use_transcript)
|
|
2028
|
+
use_ocr = False if heavy_disabled else flag("useOcr", fallback.use_ocr)
|
|
2029
|
+
use_object_detection = (
|
|
2030
|
+
False
|
|
2031
|
+
if heavy_disabled
|
|
2032
|
+
else flag("useObjectDetection", fallback.use_object_detection)
|
|
2033
|
+
)
|
|
2034
|
+
use_semantic_vision = flag("useSemanticVision", fallback.use_semantic_vision)
|
|
2035
|
+
use_video_embeddings = flag("useVideoEmbeddings", fallback.use_video_embeddings)
|
|
2036
|
+
use_scene_cuts = flag("useSceneCuts", fallback.use_scene_cuts)
|
|
2037
|
+
priority = min(sample, priority)
|
|
2038
|
+
estimated = _estimate_runtime_seconds(
|
|
2039
|
+
duration_secs=duration_secs,
|
|
2040
|
+
sample_interval_secs=sample,
|
|
2041
|
+
priority_sample_interval_secs=priority,
|
|
2042
|
+
clip_window_secs=clip,
|
|
2043
|
+
frames_per_clip=frames,
|
|
2044
|
+
priority_ranges=ranges,
|
|
2045
|
+
use_transcript=use_transcript,
|
|
2046
|
+
use_ocr=use_ocr,
|
|
2047
|
+
use_object_detection=use_object_detection,
|
|
2048
|
+
use_semantic_vision=use_semantic_vision,
|
|
2049
|
+
use_video_embeddings=use_video_embeddings,
|
|
2050
|
+
use_scene_cuts=use_scene_cuts,
|
|
2051
|
+
)
|
|
2052
|
+
return ExtractionPlan(
|
|
2053
|
+
mode=mode,
|
|
2054
|
+
summary=str(raw.get("summary") or fallback.summary).strip()[:500],
|
|
2055
|
+
extraction_focus=focus,
|
|
2056
|
+
use_transcript=use_transcript,
|
|
2057
|
+
use_ocr=use_ocr,
|
|
2058
|
+
use_object_detection=use_object_detection,
|
|
2059
|
+
use_semantic_vision=use_semantic_vision,
|
|
2060
|
+
use_video_embeddings=use_video_embeddings,
|
|
2061
|
+
use_scene_cuts=use_scene_cuts,
|
|
2062
|
+
sample_interval_secs=sample,
|
|
2063
|
+
priority_sample_interval_secs=priority,
|
|
2064
|
+
clip_window_secs=clip,
|
|
2065
|
+
frames_per_clip=frames,
|
|
2066
|
+
priority_ranges=ranges,
|
|
2067
|
+
estimated_seconds=estimated,
|
|
2068
|
+
)
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
def _vision_request_seconds(images_per_request: int) -> float:
|
|
2072
|
+
"""How long one multimodal request takes, from its image count.
|
|
2073
|
+
|
|
2074
|
+
Measured against the gateway: a request's latency is driven by how many
|
|
2075
|
+
frames it carries, not by how many clips those frames belong to. A flat
|
|
2076
|
+
per-request constant under-predicts a full batch by several times over,
|
|
2077
|
+
which makes the ETA promise a finish that never arrives.
|
|
2078
|
+
"""
|
|
2079
|
+
base = float(os.getenv("LARKUP_VIDEO_VISION_REQUEST_BASE_SECONDS", "8"))
|
|
2080
|
+
# Current gateway batches are decoded in one multimodal request; image
|
|
2081
|
+
# count adds a small serialization/inference cost, not a fresh request's
|
|
2082
|
+
# latency per frame. The older 4s/image fallback predicted ~13.5 minutes
|
|
2083
|
+
# for a pipeline measured at ~6 minutes. Keep both values configurable,
|
|
2084
|
+
# then replace this forecast with observed clip throughput after batch 1.
|
|
2085
|
+
per_image = float(
|
|
2086
|
+
os.getenv("LARKUP_VIDEO_VISION_REQUEST_PER_IMAGE_SECONDS", "0.35")
|
|
2087
|
+
)
|
|
2088
|
+
return max(1.0, base + per_image * max(1, images_per_request))
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def _estimate_runtime_seconds(
|
|
2092
|
+
*,
|
|
2093
|
+
duration_secs: float,
|
|
2094
|
+
sample_interval_secs: float,
|
|
2095
|
+
priority_sample_interval_secs: float,
|
|
2096
|
+
clip_window_secs: float,
|
|
2097
|
+
frames_per_clip: int,
|
|
2098
|
+
priority_ranges: list[PriorityRange],
|
|
2099
|
+
use_transcript: bool,
|
|
2100
|
+
use_ocr: bool,
|
|
2101
|
+
use_object_detection: bool,
|
|
2102
|
+
use_semantic_vision: bool,
|
|
2103
|
+
use_video_embeddings: bool,
|
|
2104
|
+
use_scene_cuts: bool,
|
|
2105
|
+
actual_clip_count: int | None = None,
|
|
2106
|
+
) -> int:
|
|
2107
|
+
"""Forecast wall time from bounded work, never from model opinion.
|
|
2108
|
+
|
|
2109
|
+
Parallel vision requests are estimated as waves rather than sequential
|
|
2110
|
+
clips. Validation timings can tune these constants without changing an
|
|
2111
|
+
agent plan or the evidence contract.
|
|
2112
|
+
"""
|
|
2113
|
+
duration = max(0.001, duration_secs)
|
|
2114
|
+
base_samples = math.ceil(duration / max(sample_interval_secs, 0.001)) + 1
|
|
2115
|
+
priority_duration = sum(
|
|
2116
|
+
max(0.0, min(duration, item.end_secs) - max(0.0, item.start_secs))
|
|
2117
|
+
for item in priority_ranges
|
|
2118
|
+
)
|
|
2119
|
+
priority_samples = math.ceil(
|
|
2120
|
+
priority_duration / max(priority_sample_interval_secs, 0.001)
|
|
2121
|
+
)
|
|
2122
|
+
sampled_frames = base_samples + priority_samples
|
|
2123
|
+
clip_count = actual_clip_count or max(
|
|
2124
|
+
1, math.ceil(duration / max(clip_window_secs, 0.001))
|
|
2125
|
+
)
|
|
2126
|
+
|
|
2127
|
+
estimate = 12.0
|
|
2128
|
+
if use_transcript:
|
|
2129
|
+
transcription_provider = (
|
|
2130
|
+
os.getenv("LARKUP_VIDEO_TRANSCRIPTION_PROVIDER", "whisper").strip().lower()
|
|
2131
|
+
)
|
|
2132
|
+
# Hosted Nova transcription runs bounded audio chunks concurrently;
|
|
2133
|
+
# local Whisper scales much closer to source duration. Keep these
|
|
2134
|
+
# forecasts provider-aware so Fast does not inherit the old Whisper
|
|
2135
|
+
# ETA after managed workers switch to Nova.
|
|
2136
|
+
estimate += max(
|
|
2137
|
+
12.0 if transcription_provider == "deepgram" else 4.0,
|
|
2138
|
+
duration * (0.018 if transcription_provider == "deepgram" else 0.08),
|
|
2139
|
+
)
|
|
2140
|
+
estimate += sampled_frames * (
|
|
2141
|
+
0.04 + (0.12 if use_ocr else 0.0) + (0.08 if use_object_detection else 0.0)
|
|
2142
|
+
)
|
|
2143
|
+
if use_scene_cuts:
|
|
2144
|
+
estimate += duration * 0.025
|
|
2145
|
+
if use_semantic_vision:
|
|
2146
|
+
batch_size = max(
|
|
2147
|
+
1, min(8, int(os.getenv("LARKUP_VIDEO_GATEWAY_BATCH_SIZE", "4")))
|
|
2148
|
+
)
|
|
2149
|
+
max_images = max(
|
|
2150
|
+
1,
|
|
2151
|
+
min(
|
|
2152
|
+
32, int(os.getenv("LARKUP_VIDEO_GATEWAY_MAX_IMAGES_PER_REQUEST", "20"))
|
|
2153
|
+
),
|
|
2154
|
+
)
|
|
2155
|
+
clips_per_request = max(
|
|
2156
|
+
1, min(batch_size, max_images // max(frames_per_clip, 1))
|
|
2157
|
+
)
|
|
2158
|
+
request_count = math.ceil(clip_count / clips_per_request)
|
|
2159
|
+
if (
|
|
2160
|
+
os.getenv("LARKUP_VIDEO_VISION_PROVIDER", "vercel_ai_gateway")
|
|
2161
|
+
.strip()
|
|
2162
|
+
.lower()
|
|
2163
|
+
== "google"
|
|
2164
|
+
):
|
|
2165
|
+
concurrency = max(
|
|
2166
|
+
1, min(8, int(os.getenv("LARKUP_VIDEO_GOOGLE_CONCURRENCY", "4")))
|
|
2167
|
+
)
|
|
2168
|
+
requests_per_minute = max(
|
|
2169
|
+
1, int(os.getenv("LARKUP_VIDEO_GOOGLE_REQUESTS_PER_MINUTE", "12"))
|
|
2170
|
+
)
|
|
2171
|
+
request_seconds = _vision_request_seconds(
|
|
2172
|
+
clips_per_request * frames_per_clip
|
|
2173
|
+
)
|
|
2174
|
+
else:
|
|
2175
|
+
concurrency = max(
|
|
2176
|
+
1, min(24, int(os.getenv("LARKUP_VIDEO_GATEWAY_CONCURRENCY", "24")))
|
|
2177
|
+
)
|
|
2178
|
+
requests_per_minute = max(
|
|
2179
|
+
1, int(os.getenv("LARKUP_VIDEO_GATEWAY_REQUESTS_PER_MINUTE", "60"))
|
|
2180
|
+
)
|
|
2181
|
+
request_seconds = _vision_request_seconds(
|
|
2182
|
+
clips_per_request * frames_per_clip
|
|
2183
|
+
)
|
|
2184
|
+
concurrency_seconds = request_seconds * max(
|
|
2185
|
+
1, math.ceil(request_count / concurrency)
|
|
2186
|
+
)
|
|
2187
|
+
rate_limit_seconds = (
|
|
2188
|
+
60.0 * ((request_count - 1) // requests_per_minute) + request_seconds
|
|
2189
|
+
)
|
|
2190
|
+
estimate += max(concurrency_seconds, rate_limit_seconds)
|
|
2191
|
+
if use_video_embeddings:
|
|
2192
|
+
estimate += 20.0 + clip_count * 0.15
|
|
2193
|
+
return max(15, round(estimate))
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
def estimate_plan_runtime(
|
|
2197
|
+
plan: ExtractionPlan,
|
|
2198
|
+
duration_secs: float,
|
|
2199
|
+
actual_clip_count: int | None = None,
|
|
2200
|
+
) -> int:
|
|
2201
|
+
"""Recompute an ETA after the executor applies service availability."""
|
|
2202
|
+
estimate = _estimate_runtime_seconds(
|
|
2203
|
+
duration_secs=duration_secs,
|
|
2204
|
+
sample_interval_secs=plan.sample_interval_secs,
|
|
2205
|
+
priority_sample_interval_secs=plan.priority_sample_interval_secs,
|
|
2206
|
+
clip_window_secs=plan.clip_window_secs,
|
|
2207
|
+
frames_per_clip=plan.frames_per_clip,
|
|
2208
|
+
priority_ranges=plan.priority_ranges,
|
|
2209
|
+
use_transcript=plan.use_transcript,
|
|
2210
|
+
use_ocr=plan.use_ocr,
|
|
2211
|
+
use_object_detection=plan.use_object_detection,
|
|
2212
|
+
use_semantic_vision=plan.use_semantic_vision,
|
|
2213
|
+
use_video_embeddings=plan.use_video_embeddings,
|
|
2214
|
+
use_scene_cuts=plan.use_scene_cuts,
|
|
2215
|
+
actual_clip_count=actual_clip_count,
|
|
2216
|
+
)
|
|
2217
|
+
# Planner refinement and the final evidence audit are text-model calls,
|
|
2218
|
+
# independent of source duration. Production validation puts their
|
|
2219
|
+
# combined median close to a minute; keep a small buffer before live unit
|
|
2220
|
+
# throughput replaces this initial forecast.
|
|
2221
|
+
return estimate + 70
|