@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.
Files changed (54) hide show
  1. package/.env.example +150 -0
  2. package/LICENSE +176 -0
  3. package/README.md +281 -0
  4. package/compose.gpu.yaml +14 -0
  5. package/compose.yaml +71 -0
  6. package/dist/agent.d.ts +131 -0
  7. package/dist/agent.js +2087 -0
  8. package/dist/brief.d.ts +2 -0
  9. package/dist/brief.js +37 -0
  10. package/dist/client.d.ts +46 -0
  11. package/dist/client.js +139 -0
  12. package/dist/contracts.d.ts +331 -0
  13. package/dist/contracts.js +1 -0
  14. package/dist/index.d.ts +87 -0
  15. package/dist/index.js +391 -0
  16. package/dist/runtime.d.ts +96 -0
  17. package/dist/runtime.js +592 -0
  18. package/dist/ui.d.ts +82 -0
  19. package/dist/ui.js +87 -0
  20. package/package.json +84 -0
  21. package/runtime/Dockerfile +119 -0
  22. package/runtime/app/__init__.py +3 -0
  23. package/runtime/app/__main__.py +19 -0
  24. package/runtime/app/api/__init__.py +0 -0
  25. package/runtime/app/api/deps.py +69 -0
  26. package/runtime/app/api/v1.py +166 -0
  27. package/runtime/app/config.py +78 -0
  28. package/runtime/app/db/__init__.py +0 -0
  29. package/runtime/app/db/schemas.py +162 -0
  30. package/runtime/app/db/store.py +466 -0
  31. package/runtime/app/main.py +27 -0
  32. package/runtime/app/model_configuration.py +157 -0
  33. package/runtime/app/services/__init__.py +0 -0
  34. package/runtime/app/services/brain.py +2221 -0
  35. package/runtime/app/services/embedding.py +473 -0
  36. package/runtime/app/services/jobs.py +237 -0
  37. package/runtime/app/services/motion.py +66 -0
  38. package/runtime/app/services/pipeline.py +1911 -0
  39. package/runtime/app/services/scene.py +161 -0
  40. package/runtime/app/services/storage.py +44 -0
  41. package/runtime/app/services/transcription.py +667 -0
  42. package/runtime/app/services/vision.py +1441 -0
  43. package/runtime/app/utils/__init__.py +0 -0
  44. package/runtime/app/utils/timing.py +99 -0
  45. package/runtime/app/worker.py +20 -0
  46. package/runtime/pyproject.toml +56 -0
  47. package/runtime/requirements-cpu.txt +15 -0
  48. package/runtime/requirements-smoke.txt +7 -0
  49. package/runtime/requirements.txt +14 -0
  50. package/runtime/uv.lock +3637 -0
  51. package/scripts/grant-cloud-credits.sh +43 -0
  52. package/scripts/runtime.mjs +156 -0
  53. package/scripts/validate-indexing.mjs +168 -0
  54. 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