@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,1441 @@
1
+ """Captions video clips through a hosted vision-language model.
2
+
3
+ Reached over the Vercel AI Gateway's OpenAI-compatible chat/completions
4
+ endpoint: frames are sent as image_url content parts (a presigned S3 URL
5
+ when a bucket is configured, a base64 data URI otherwise), and the model
6
+ returns per-clip captions. `SemanticVisionService` is the entry point the
7
+ pipeline calls; `GatewayVisionClient` is its HTTP implementation detail.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import json
14
+ import os
15
+ import re
16
+ import threading
17
+ import time
18
+ import uuid
19
+ from collections import deque
20
+ from concurrent.futures import ThreadPoolExecutor, as_completed
21
+ from dataclasses import dataclass
22
+ from typing import Any, Callable
23
+
24
+ import cv2
25
+ import numpy as np
26
+ import requests
27
+
28
+ from app.services.storage import FrameUploader, get_frame_uploader
29
+
30
+ DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1"
31
+ # Verify these ids against the live Vercel AI Gateway model catalog before
32
+ # deploying -- gateway model slugs are not guaranteed stable across providers.
33
+ # The bulk model runs per-clip during indexing (cheap, high-volume); the
34
+ # reasoning model is reserved for watch_original's dense final-verification
35
+ # pass over a bounded range, where accuracy matters more than throughput.
36
+ DEFAULT_MODEL = "google/gemini-3.6-flash"
37
+ # Direct verification uses the same gateway-available multimodal reader as
38
+ # indexing by default. Deployments can opt into a separately configured
39
+ # reasoning model, but an unavailable optional model must never turn a video
40
+ # answer into an empty result.
41
+ DEFAULT_REASONING_MODEL = DEFAULT_MODEL
42
+ # A multimodal request's latency tracks the number of images in it far more
43
+ # than anything else, so one deadline for every request either cuts off the
44
+ # large ones or lets the small ones hang. These bound it per image instead.
45
+ # Cutting a request off early is the expensive outcome: the retry re-sends the
46
+ # same frames, and a batch that keeps timing out degrades into one request per
47
+ # clip, which is what makes an index crawl.
48
+ REQUEST_TIMEOUT_BASE_SECS = 60
49
+ REQUEST_TIMEOUT_PER_IMAGE_SECS = 8
50
+ REQUEST_TIMEOUT_CEILING_SECS = 240
51
+
52
+
53
+ # Exact visual facts such as a value, label, or small icon are often a small
54
+ # part of a wide frame. Keep enough source detail for those facts while the
55
+ # bounded clip planner, batching, and JPEG compression keep the request size
56
+ # predictable.
57
+ MAX_VISION_FRAME_WIDTH = 960
58
+
59
+
60
+ def _timeout_for_images(image_count: int) -> int:
61
+ return min(
62
+ REQUEST_TIMEOUT_CEILING_SECS,
63
+ REQUEST_TIMEOUT_BASE_SECS + REQUEST_TIMEOUT_PER_IMAGE_SECS * max(1, image_count),
64
+ )
65
+
66
+ _SCHEMA = {
67
+ "type": "object",
68
+ "properties": {
69
+ "clips": {
70
+ "type": "array",
71
+ "items": {
72
+ "type": "object",
73
+ "properties": {
74
+ "clipIndex": {"type": "integer"},
75
+ "summary": {"type": "string", "maxLength": 1200},
76
+ "entities": {
77
+ "type": "array",
78
+ "maxItems": 12,
79
+ "items": {
80
+ "type": "object",
81
+ "properties": {
82
+ "name": {"type": "string", "maxLength": 120},
83
+ "what": {"type": "string", "maxLength": 160},
84
+ "howIdentified": {"type": "string", "maxLength": 200},
85
+ },
86
+ "required": ["name", "what", "howIdentified"],
87
+ },
88
+ },
89
+ "visibleText": {
90
+ "type": "array",
91
+ "maxItems": 12,
92
+ "items": {
93
+ "type": "object",
94
+ "properties": {
95
+ "text": {"type": "string", "maxLength": 200},
96
+ "means": {"type": "string", "maxLength": 200},
97
+ },
98
+ "required": ["text", "means"],
99
+ },
100
+ },
101
+ "events": {
102
+ "type": "array",
103
+ "maxItems": 12,
104
+ "items": {
105
+ "type": "object",
106
+ "properties": {
107
+ "what": {"type": "string", "maxLength": 240},
108
+ "basis": {"type": "string", "enum": ["read", "inferred"]},
109
+ },
110
+ "required": ["what", "basis"],
111
+ },
112
+ },
113
+ "supportedClaims": {
114
+ "type": "array",
115
+ "maxItems": 3,
116
+ "items": {"type": "string", "maxLength": 220},
117
+ },
118
+ "sourceQuestions": {
119
+ "type": "array",
120
+ "maxItems": 12,
121
+ "items": {
122
+ "type": "object",
123
+ "properties": {
124
+ "text": {"type": "string", "maxLength": 500},
125
+ "answer": {"type": "string", "maxLength": 500},
126
+ "basis": {
127
+ "type": "string",
128
+ "enum": ["spoken", "visible"],
129
+ },
130
+ },
131
+ "required": ["text", "answer", "basis"],
132
+ },
133
+ },
134
+ "claimQuestion": {"type": "string", "maxLength": 2000},
135
+ "claimVerdict": {
136
+ "type": "string",
137
+ "enum": ["direct", "partial", "not-established"],
138
+ },
139
+ "claimAnswer": {"type": "string", "maxLength": 360},
140
+ "claimBindings": {
141
+ "type": "array",
142
+ "maxItems": 8,
143
+ "items": {
144
+ "type": "object",
145
+ "properties": {
146
+ "subject": {"type": "string", "maxLength": 120},
147
+ "relation": {"type": "string", "maxLength": 80},
148
+ "value": {"type": "string", "maxLength": 120},
149
+ },
150
+ "required": ["subject", "relation", "value"],
151
+ },
152
+ },
153
+ "uncertainty": {"type": "string", "maxLength": 220},
154
+ },
155
+ "required": [
156
+ "clipIndex",
157
+ "summary",
158
+ "claimQuestion",
159
+ "claimVerdict",
160
+ "claimAnswer",
161
+ "claimBindings",
162
+ ],
163
+ },
164
+ }
165
+ },
166
+ "required": ["clips"],
167
+ }
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class ClipCaptionRequest:
172
+ clip_id: str
173
+ start_ms: int
174
+ end_ms: int
175
+ frames: list[tuple[int, np.ndarray]]
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class SemanticObservation:
180
+ start_ms: int
181
+ end_ms: int
182
+ text: str
183
+ confidence: float
184
+
185
+
186
+ class GatewayRateLimiter:
187
+ """Thread-safe sliding-window limiter shared by every gateway call in a job."""
188
+
189
+ def __init__(self, requests_per_minute: int):
190
+ self.limit = max(1, requests_per_minute)
191
+ self._lock = threading.Lock()
192
+ self._window: deque[float] = deque()
193
+
194
+ def acquire(self) -> None:
195
+ while True:
196
+ with self._lock:
197
+ now = time.monotonic()
198
+ while self._window and self._window[0] <= now - 60:
199
+ self._window.popleft()
200
+ if len(self._window) < self.limit:
201
+ self._window.append(now)
202
+ return
203
+ wait_secs = 60 - (now - self._window[0])
204
+ time.sleep(max(0.05, wait_secs))
205
+
206
+
207
+ def _frame_to_url(frame: np.ndarray, uploader: FrameUploader | None, prefix: str) -> str:
208
+ max_width = max(320, int(os.getenv("LARKUP_VIDEO_MAX_VISION_FRAME_WIDTH", MAX_VISION_FRAME_WIDTH)))
209
+ height, width = frame.shape[:2]
210
+ # Many broadcast and screen-recording sources are low resolution. Their
211
+ # labels are still useful evidence, but only when the VLM receives enough
212
+ # pixels to read them. This is a general text/UI readability pass, not a
213
+ # content-specific crop or detector.
214
+ if width != max_width:
215
+ frame = cv2.resize(frame, (max_width, max(1, round(height * max_width / width))))
216
+ ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 70])
217
+ if not ok:
218
+ raise ValueError("could not encode frame as JPEG")
219
+ payload = buffer.tobytes()
220
+ if uploader:
221
+ return uploader.upload(payload, f"{prefix}/{uuid.uuid4().hex}.jpg", "image/jpeg")
222
+ return "data:image/jpeg;base64," + base64.b64encode(payload).decode("ascii")
223
+
224
+
225
+ def _frame_to_inline_data(frame: np.ndarray) -> dict[str, str]:
226
+ """Encode one bounded frame for APIs that accept Gemini-style inline media."""
227
+ max_width = max(320, int(os.getenv("LARKUP_VIDEO_MAX_VISION_FRAME_WIDTH", MAX_VISION_FRAME_WIDTH)))
228
+ height, width = frame.shape[:2]
229
+ if width != max_width:
230
+ frame = cv2.resize(frame, (max_width, max(1, round(height * max_width / width))))
231
+ ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 70])
232
+ if not ok:
233
+ raise ValueError("could not encode frame as JPEG")
234
+ return {"mime_type": "image/jpeg", "data": base64.b64encode(buffer.tobytes()).decode("ascii")}
235
+
236
+
237
+ def _precision_frames(
238
+ frames: list[tuple[int, np.ndarray]],
239
+ max_source_frames: int = 4,
240
+ ) -> list[tuple[int, np.ndarray]]:
241
+ """Keep a bounded chronology spanning the whole clip.
242
+
243
+ A precision pass sends fewer frames than the frame budget so each one can
244
+ be sent at higher fidelity. Both ends of the clip are always kept, so a
245
+ before/after relationship inside the clip survives the reduction.
246
+ """
247
+ if not frames:
248
+ return []
249
+ source_count = min(max_source_frames, len(frames))
250
+ indices = sorted(
251
+ {
252
+ round(index * (len(frames) - 1) / max(1, source_count - 1))
253
+ for index in range(source_count)
254
+ }
255
+ )
256
+ return [frames[index] for index in indices]
257
+
258
+
259
+ # How much detail a note carries, per indexing mode. A note is what the whole
260
+ # index is made of, so this -- not the frame count -- is what "coverage" means
261
+ # to whoever reads the answer later.
262
+ _DEPTH = {
263
+ "fast": (
264
+ "one or two sentences per clip, carrying only what the clip establishes",
265
+ 3,
266
+ ),
267
+ "balanced": (
268
+ "a short paragraph per clip: what happens, who or what is involved, and "
269
+ "any informative text on screen",
270
+ 6,
271
+ ),
272
+ "thorough": (
273
+ "a full paragraph per clip: what happens and how it develops, every "
274
+ "participant and how each was identified, every informative piece of "
275
+ "on-screen text read exactly, and each change of state",
276
+ 12,
277
+ ),
278
+ }
279
+
280
+
281
+ def _build_prompt(
282
+ batch: list[ClipCaptionRequest],
283
+ goal: str,
284
+ questions: list[str],
285
+ spoken_context: dict[str, str] | None = None,
286
+ known_entities: list[str] | None = None,
287
+ depth: str = "balanced",
288
+ ) -> str:
289
+ detail, item_limit = _DEPTH.get(depth, _DEPTH["balanced"])
290
+ prompt = (
291
+ "You are watching a video and taking notes, the way a person would if they had to answer "
292
+ "questions about it later from their notes alone. Each group below is the chronological "
293
+ "frames of one clip, in the order the clips occur.\n"
294
+ "\n"
295
+ "Write notes that carry the MEANING of what is happening, not a description of the pixels. "
296
+ "A note saying what something IS ('the displayed total has reached 240') is worth ten notes "
297
+ "saying something is visible ('a number is on screen'). Never write a note that only lists "
298
+ "what objects are in frame.\n"
299
+ "\n"
300
+ "How to take these notes:\n"
301
+ "- Say what is happening and what changes. A value that differs between two frames is an "
302
+ "event; say so, and say what it changed from and to.\n"
303
+ "- Read every piece of on-screen text that carries information: names, titles, labels, "
304
+ "captions, times, totals, readouts, rankings. Transcribe them exactly, and say what each is "
305
+ "attached to -- which column a value sits under, which person a caption sits beneath, which "
306
+ "item a label points at. Read positions off the pixels; a script's reading direction never "
307
+ "moves something from one side of the frame to the other.\n"
308
+ "- On-screen text and synchronized speech are how people and things get identified, and you "
309
+ "should use them that way. If a caption reads a name beneath someone, that is who they are: "
310
+ "record the name and record what established it. Say someone is unidentified only when the "
311
+ "source genuinely never names them -- not merely because you are being cautious.\n"
312
+ "- Name a person only where this clip shows a readable name label, synchronized speech binds "
313
+ "the name to them, or the supplied evidence explicitly preserves that identity across the cut.\n"
314
+ "- Note appearance where it distinguishes participants: colours of clothing or livery, and "
315
+ "which group wears what. That is often how a viewer tells two groups apart.\n"
316
+ "- Keep an individual's name distinct from a collective one. A group, organization, role, "
317
+ "or place is never a person's name. When only collective labels are visible, say so plainly "
318
+ "instead of substituting one for a person.\n"
319
+ "- Do not transfer a name, number, or caption from one person to another, and do not let a "
320
+ "later close-up or caption retroactively identify whoever acted in an earlier wide shot "
321
+ "unless the frames visibly track the same person across the transition.\n"
322
+ "- Frames are samples, so a clip can show a moment without showing its result. Write what "
323
+ "you saw, and mark anything you are concluding rather than reading with basis 'inferred'.\n"
324
+ "- Never add background knowledge about the subject, place, people, or occasion, and never "
325
+ "invent a name, number, or value you did not see or hear.\n"
326
+ "- Record every question actually spoken or visibly written in this clip in sourceQuestions, "
327
+ "using the source wording and language. Include its source-supported answer when this clip "
328
+ "contains one, otherwise use an empty answer. The supplied goal and Questions to resolve are "
329
+ "instructions to you, never sourceQuestions. Repeated generic headers are separate questions "
330
+ "when their spoken or visible prompt/context differs.\n"
331
+ "- Write the note and event prose in the dominant language of the synchronized speech. If "
332
+ "there is no speech, use the language of the supplied goal/questions. Preserve names and "
333
+ "on-screen text exactly as heard or read. Do not translate a source-language identity into "
334
+ "a generic English description.\n"
335
+ "\n"
336
+ f"Detail level: {detail}. Give at most {item_limit} entities, {item_limit} visibleText "
337
+ f"items, and {item_limit} events per clip -- the most informative ones.\n"
338
+ "\n"
339
+ "Alongside the notes, answer the supplied question for each clip. Set claimVerdict to "
340
+ "'direct' only when this clip itself shows or says the answer, 'partial' when it points "
341
+ "toward it, and 'not-established' when it does not bear on it. Fill claimAnswer for a "
342
+ "direct verdict only. Set claimQuestion to the supplied question exactly. In claimBindings "
343
+ "record each subject-relation-value the clip directly establishes.\n"
344
+ "\n"
345
+ "Return one entry per clip, indexed by the 0-based clip order given. Return JSON only, no "
346
+ "markdown fence, with this exact top-level shape: "
347
+ "{\"clips\":[{\"clipIndex\":0,\"summary\":\"the note\","
348
+ "\"entities\":[{\"name\":\"...\",\"what\":\"...\",\"howIdentified\":\"...\"}],"
349
+ "\"visibleText\":[{\"text\":\"exact text\",\"means\":\"what it conveys\"}],"
350
+ "\"events\":[{\"what\":\"...\",\"basis\":\"read|inferred\"}],"
351
+ "\"sourceQuestions\":[{\"text\":\"exact source question\",\"answer\":\"\","
352
+ "\"basis\":\"spoken|visible\"}],"
353
+ "\"supportedClaims\":[\"...\"],"
354
+ "\"claimQuestion\":\"the supplied question\",\"claimVerdict\":\"direct|partial|not-established\","
355
+ "\"claimAnswer\":\"...\",\"claimBindings\":[{\"subject\":\"...\","
356
+ "\"relation\":\"...\",\"value\":\"...\"}],\"uncertainty\":\"...\"}]}. "
357
+ "Include exactly one object for every supplied clip and no prose outside that JSON."
358
+ )
359
+ if goal:
360
+ prompt += (
361
+ f"\n\nWhat the person reading these notes cares about: {goal[:1200]}. "
362
+ "Cover it in more detail than the rest, without skipping anything else that happens."
363
+ )
364
+ if questions:
365
+ prompt += " Questions to resolve: " + " | ".join(questions[:4])[:1600] + "."
366
+ if known_entities:
367
+ prompt += (
368
+ " Named people or entities that require visual grounding: "
369
+ + " | ".join(known_entities[:20])[:1200]
370
+ + "."
371
+ )
372
+ for index, clip in enumerate(batch):
373
+ prompt += f"\n\nCLIP {index} covers {clip.start_ms / 1000:.1f}s-{clip.end_ms / 1000:.1f}s."
374
+ context = (spoken_context or {}).get(clip.clip_id)
375
+ if context:
376
+ prompt += f" Aligned evidence context for this clip: {context[:4000]}"
377
+ return prompt
378
+
379
+
380
+ def _content_for_batch(batch: list[ClipCaptionRequest], urls_by_clip: dict[str, list[str]]) -> list[dict[str, Any]]:
381
+ content: list[dict[str, Any]] = []
382
+ for index, clip in enumerate(batch):
383
+ content.append({"type": "text", "text": f"--- CLIP {index} frames ---"})
384
+ for url in urls_by_clip[clip.clip_id]:
385
+ content.append({"type": "image_url", "image_url": {"url": url}})
386
+ return content
387
+
388
+
389
+ def _post_with_retry(
390
+ session: requests.Session,
391
+ url: str,
392
+ headers: dict[str, str],
393
+ payload: dict[str, Any],
394
+ attempts: int = 2,
395
+ timeout_secs: int = REQUEST_TIMEOUT_CEILING_SECS,
396
+ ) -> requests.Response:
397
+ last_error: Exception | None = None
398
+ for attempt in range(attempts):
399
+ try:
400
+ response = session.post(url, headers=headers, json=payload, timeout=timeout_secs)
401
+ if response.status_code == 429 or response.status_code >= 500:
402
+ last_error = RuntimeError(
403
+ f"gateway returned {response.status_code}: {response.text[:300]}"
404
+ )
405
+ # A daily/project hard limit cannot recover inside this job.
406
+ # Return the provider diagnostic immediately rather than
407
+ # sleeping and multiplying the same rejected batch.
408
+ if response.status_code == 429 and (
409
+ "PerDay" in response.text or "requests per day" in response.text.lower()
410
+ ):
411
+ break
412
+ if attempt + 1 >= attempts:
413
+ break
414
+ retry_after = response.headers.get("Retry-After", "").strip()
415
+ delay_secs = 0.0
416
+ try:
417
+ delay_secs = float(retry_after)
418
+ except ValueError:
419
+ # Gemini quota responses expose a protobuf-style
420
+ # `retryDelay` (for example, "37s") in the JSON body.
421
+ # Respecting it prevents a synchronized retry burst from
422
+ # consuming another request only to receive the same 429.
423
+ match = re.search(r'"retryDelay"\s*:\s*"([0-9.]+)s"', response.text)
424
+ if match:
425
+ delay_secs = float(match.group(1))
426
+ if delay_secs <= 0:
427
+ # Vercel's free-tier response currently omits Retry-After.
428
+ # Its model window resets on a minute cadence, so a short
429
+ # exponential retry only repeats the same rejected call.
430
+ delay_secs = (
431
+ 60.0
432
+ if "free tier requests on this model are rate-limited"
433
+ in response.text.lower()
434
+ else min(15.0, 1.5 * (2**attempt))
435
+ )
436
+ time.sleep(min(60.0, max(0.5, delay_secs)))
437
+ continue
438
+ return response
439
+ except (RuntimeError, requests.RequestException) as error:
440
+ last_error = error
441
+ if attempt + 1 < attempts:
442
+ time.sleep(min(8.0, 0.5 * (2**attempt)))
443
+ raise RuntimeError(f"gateway request failed after {attempts} attempts: {last_error}")
444
+
445
+
446
+ def _note_lines(entry: dict[str, Any]) -> list[str]:
447
+ """Render the note fields as source-bearing lines.
448
+
449
+ These sit above the claim protocol so retrieval, which strips the protocol
450
+ envelope, still indexes everything the reader actually observed.
451
+ """
452
+ lines: list[str] = []
453
+ for item in entry.get("entities") or []:
454
+ if not isinstance(item, dict):
455
+ continue
456
+ name = str(item.get("name") or "").strip()[:120]
457
+ what = str(item.get("what") or "").strip()[:160]
458
+ how = str(item.get("howIdentified") or "").strip()[:200]
459
+ if name:
460
+ lines.append(
461
+ f"Present: {name}"
462
+ + (f" — {what}" if what else "")
463
+ + (f" (identified by {how})" if how else "")
464
+ )
465
+ for item in entry.get("visibleText") or []:
466
+ if not isinstance(item, dict):
467
+ continue
468
+ text = str(item.get("text") or "").strip()[:200]
469
+ means = str(item.get("means") or "").strip()[:200]
470
+ if text:
471
+ lines.append(f"On screen: {text!r}" + (f" — {means}" if means else ""))
472
+ for item in entry.get("events") or []:
473
+ if not isinstance(item, dict):
474
+ continue
475
+ what = str(item.get("what") or "").strip()[:240]
476
+ basis = str(item.get("basis") or "").strip().lower()
477
+ if what:
478
+ lines.append(f"Happened{' (inferred)' if basis == 'inferred' else ''}: {what}")
479
+ return lines
480
+
481
+
482
+ def _normalized_source_terms(value: str) -> list[str]:
483
+ return re.findall(r"[\w]+", value.casefold(), re.UNICODE)
484
+
485
+
486
+ def _source_question_is_grounded(
487
+ *,
488
+ text: str,
489
+ basis: str,
490
+ entry: dict[str, Any],
491
+ clip: ClipCaptionRequest,
492
+ spoken_context: dict[str, str] | None,
493
+ ) -> bool:
494
+ """Require the claimed source channel to contain the question itself."""
495
+ if basis == "visible":
496
+ source = " ".join(
497
+ str(item.get("text") or "")
498
+ for item in entry.get("visibleText") or []
499
+ if isinstance(item, dict)
500
+ )
501
+ else:
502
+ source = str((spoken_context or {}).get(clip.clip_id) or "")
503
+ source_normalized = " ".join(_normalized_source_terms(source))
504
+ question_terms = _normalized_source_terms(text)
505
+ question_normalized = " ".join(question_terms)
506
+ if not source_normalized or not question_normalized:
507
+ return False
508
+ if question_normalized in source_normalized:
509
+ return True
510
+ # ASR/OCR can differ in one or two words while still preserving a prompt.
511
+ # A high term-coverage threshold rejects the unrelated per-clip analysis
512
+ # instruction without requiring exact punctuation or spelling.
513
+ matched = sum(1 for term in question_terms if term in source_normalized)
514
+ return len(question_terms) >= 3 and matched / len(question_terms) >= 0.75
515
+
516
+
517
+ def _parse_response(
518
+ raw_text: str,
519
+ batch: list[ClipCaptionRequest],
520
+ spoken_context: dict[str, str] | None = None,
521
+ ) -> dict[str, tuple[str, float]]:
522
+ value = raw_text.strip()
523
+ if value.startswith("```"):
524
+ value = value.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
525
+ results: dict[str, tuple[str, float]] = {}
526
+ try:
527
+ # Gateway-routed multimodal models occasionally prefix an otherwise
528
+ # valid object with a short acknowledgement. That prose is not
529
+ # evidence, but rejecting the complete JSON object after it forces a
530
+ # second expensive visual pass. Decode the first object rather than
531
+ # guessing from prose; malformed/non-JSON replies still yield none.
532
+ try:
533
+ parsed = json.loads(value)
534
+ except json.JSONDecodeError:
535
+ object_start = value.find("{")
536
+ parsed = (
537
+ json.JSONDecoder().raw_decode(value[object_start:])[0]
538
+ if object_start >= 0
539
+ else None
540
+ )
541
+ entries = parsed.get("clips") if isinstance(parsed, dict) else None
542
+ if isinstance(entries, list):
543
+ for entry in entries:
544
+ if not isinstance(entry, dict):
545
+ continue
546
+ index = entry.get("clipIndex")
547
+ if not isinstance(index, int) or not (0 <= index < len(batch)):
548
+ continue
549
+ summary = str(entry.get("summary") or "").strip()
550
+ note_lines = _note_lines(entry)
551
+ claims = entry.get("supportedClaims")
552
+ source_questions = entry.get("sourceQuestions")
553
+ claim_question = str(entry.get("claimQuestion") or "").strip()
554
+ claim_verdict = str(entry.get("claimVerdict") or "").strip()
555
+ claim_answer = str(entry.get("claimAnswer") or "").strip()
556
+ raw_bindings = entry.get("claimBindings")
557
+ claim_bindings = []
558
+ if isinstance(raw_bindings, list):
559
+ for binding in raw_bindings[:8]:
560
+ if not isinstance(binding, dict):
561
+ continue
562
+ subject = str(binding.get("subject") or "").strip()[:120]
563
+ relation = str(binding.get("relation") or "").strip()[:80]
564
+ value = str(binding.get("value") or "").strip()[:120]
565
+ if subject and relation and value:
566
+ claim_bindings.append(
567
+ {"subject": subject, "relation": relation, "value": value}
568
+ )
569
+ uncertainty = str(entry.get("uncertainty") or "").strip()
570
+ summary_prefix = (
571
+ "Observed context (not a complete answer): "
572
+ if claim_verdict == "partial" and summary
573
+ else ""
574
+ )
575
+ parts = [summary_prefix + summary]
576
+ # Source-authored questions are an exhaustive retrieval surface,
577
+ # so keep them ahead of optional detail that may fill the note's
578
+ # bounded text budget.
579
+ if isinstance(source_questions, list):
580
+ for source_question in source_questions[:12]:
581
+ if not isinstance(source_question, dict):
582
+ continue
583
+ source_text = str(source_question.get("text") or "").strip()[:500]
584
+ source_answer = str(source_question.get("answer") or "").strip()[:500]
585
+ source_basis = str(source_question.get("basis") or "").strip().lower()
586
+ if (
587
+ source_text
588
+ and source_basis in {"spoken", "visible"}
589
+ and _source_question_is_grounded(
590
+ text=source_text,
591
+ basis=source_basis,
592
+ entry=entry,
593
+ clip=batch[index],
594
+ spoken_context=spoken_context,
595
+ )
596
+ ):
597
+ parts.append(f"Source question ({source_basis}): {source_text}")
598
+ if source_answer:
599
+ parts.append(f"Source answer: {source_answer}")
600
+ parts.extend(note_lines)
601
+ if isinstance(claims, list):
602
+ parts.extend(
603
+ "Direct component: " + str(claim).strip()
604
+ for claim in claims
605
+ if str(claim).strip() and claim_verdict != "not-established"
606
+ )
607
+ if claim_question and claim_verdict in {"direct", "partial", "not-established"}:
608
+ parts.append(f"Claim question: {claim_question}")
609
+ parts.append(f"Claim verdict: {claim_verdict}")
610
+ if claim_verdict == "direct" and claim_answer:
611
+ parts.append(f"Claim answer: {claim_answer}")
612
+ if claim_bindings:
613
+ parts.append(
614
+ "Claim bindings: "
615
+ + json.dumps(claim_bindings, ensure_ascii=False, separators=(",", ":"))
616
+ )
617
+ if uncertainty:
618
+ parts.append(f"Uncertainty: {uncertainty}")
619
+ text = "\n".join(part for part in parts if part)[:4000]
620
+ if text:
621
+ confidence = {
622
+ "direct": 0.62,
623
+ "partial": 0.42,
624
+ "not-established": 0.25,
625
+ }.get(claim_verdict, 0.58)
626
+ results[batch[index].clip_id] = (text, confidence)
627
+ except json.JSONDecodeError:
628
+ pass
629
+ return results
630
+
631
+
632
+ class GatewayVisionClient:
633
+ """OpenAI-compatible VLM client for Vercel AI Gateway and direct OpenAI."""
634
+
635
+ def __init__(self) -> None:
636
+ self.provider = os.getenv("LARKUP_VIDEO_VISION_PROVIDER", "vercel_ai_gateway")
637
+ self.api_key = (
638
+ os.getenv("LARKUP_VIDEO_VISION_API_KEY")
639
+ or os.getenv("AI_GATEWAY_API_KEY")
640
+ or ""
641
+ )
642
+ default_base_url = "https://api.openai.com/v1" if self.provider == "openai" else DEFAULT_BASE_URL
643
+ self.base_url = os.getenv("LARKUP_VIDEO_VISION_BASE_URL", default_base_url).rstrip("/")
644
+ self.model = os.getenv("LARKUP_VIDEO_SEMANTIC_VISION_MODEL", DEFAULT_MODEL)
645
+ self.reasoning_model = os.getenv(
646
+ "LARKUP_VIDEO_REASONING_VISION_MODEL", DEFAULT_REASONING_MODEL
647
+ )
648
+ # A small batch preserves per-clip grounding and lets independent
649
+ # batches run in parallel. Large batches previously spent the whole
650
+ # output budget on hidden reasoning before returning valid JSON.
651
+ self.batch_size = max(1, min(8, int(os.getenv("LARKUP_VIDEO_GATEWAY_BATCH_SIZE", "4"))))
652
+ # VLM latency is driven far more by images than clip count. A thorough
653
+ # verification clip can contain many chronological frames, so never
654
+ # put several of those dense clips into one oversized gateway request.
655
+ # The separate requests still run in parallel below.
656
+ self.max_images_per_request = max(
657
+ 1, min(32, int(os.getenv("LARKUP_VIDEO_GATEWAY_MAX_IMAGES_PER_REQUEST", "20")))
658
+ )
659
+ # Each request covers the same four clips and frames. Raising only
660
+ # parallelism shortens a full-video run without reducing coverage or
661
+ # changing the visual evidence sent to the model.
662
+ self.max_concurrency = max(1, min(24, int(os.getenv("LARKUP_VIDEO_GATEWAY_CONCURRENCY", "24"))))
663
+ self.limiter = GatewayRateLimiter(
664
+ int(os.getenv("LARKUP_VIDEO_GATEWAY_REQUESTS_PER_MINUTE", "60"))
665
+ )
666
+ # How much detail each note carries. Set per job from the brief's
667
+ # indexing mode before dispatch; see SemanticVisionService.describe_clips.
668
+ self.depth = "balanced"
669
+ self.frame_prefix = os.getenv("LARKUP_VIDEO_FRAME_PREFIX", "tmp-frames")
670
+ self._uploader = get_frame_uploader(os.getenv("LARKUP_VIDEO_BUCKET") or None)
671
+ self._sessions = threading.local()
672
+ self.last_error: str | None = None
673
+ self._fatal_error = threading.Event()
674
+ self._fatal_error_lock = threading.Lock()
675
+ self._fatal_error_message: str | None = None
676
+
677
+ def _record_http_error(self, label: str, response: requests.Response) -> None:
678
+ message = f"{label} returned {response.status_code}: {response.text[:300]}"
679
+ self.last_error = message
680
+ # Authentication, billing, and permission failures apply to every
681
+ # batch in this job. Stop queued requests immediately instead of
682
+ # spending a full rate-limit window proving the same failure again.
683
+ if response.status_code in {401, 402, 403}:
684
+ with self._fatal_error_lock:
685
+ self._fatal_error_message = self._fatal_error_message or message
686
+ self._fatal_error.set()
687
+
688
+ def _describe_each(
689
+ self,
690
+ clips: list[ClipCaptionRequest],
691
+ goal: str,
692
+ questions: list[str],
693
+ spoken_context: dict[str, str] | None,
694
+ known_entities: list[str] | None,
695
+ model: str | None,
696
+ max_output_tokens: int | None = None,
697
+ reasoning_effort: str | None = None,
698
+ ) -> dict[str, tuple[str, float]]:
699
+ """Re-request clips one at a time, but all at once.
700
+
701
+ A batch that was truncated, rejected, or came back missing entries is
702
+ recovered by asking for its clips individually. Those requests are
703
+ independent, so running them together costs one round trip instead of
704
+ one per clip -- the difference between seconds and minutes on a batch
705
+ that lost several clips. The shared rate limiter still paces them.
706
+ """
707
+ if not clips:
708
+ return {}
709
+ if len(clips) == 1:
710
+ return self._describe_batch(
711
+ clips,
712
+ goal,
713
+ questions,
714
+ spoken_context,
715
+ known_entities,
716
+ model,
717
+ max_output_tokens,
718
+ reasoning_effort,
719
+ )
720
+ recovered: dict[str, tuple[str, float]] = {}
721
+ with ThreadPoolExecutor(max_workers=min(self.max_concurrency, len(clips))) as pool:
722
+ for result in pool.map(
723
+ lambda clip: self._describe_batch(
724
+ [clip],
725
+ goal,
726
+ questions,
727
+ spoken_context,
728
+ known_entities,
729
+ model,
730
+ max_output_tokens,
731
+ reasoning_effort,
732
+ ),
733
+ clips,
734
+ ):
735
+ recovered.update(result)
736
+ return recovered
737
+
738
+ def _model_for_request(self, model: str) -> str:
739
+ if self.provider == "openai" and model.startswith("openai/"):
740
+ return model.split("/", 1)[1]
741
+ return model
742
+
743
+ def _session(self) -> requests.Session:
744
+ session = getattr(self._sessions, "session", None)
745
+ if session is None:
746
+ session = requests.Session()
747
+ self._sessions.session = session
748
+ return session
749
+
750
+ @property
751
+ def enabled(self) -> bool:
752
+ return bool(self.api_key)
753
+
754
+ def _urls_for_clip(self, clip: ClipCaptionRequest) -> list[str]:
755
+ return [
756
+ _frame_to_url(frame, self._uploader, f"{self.frame_prefix}/{clip.clip_id}")
757
+ for _, frame in clip.frames
758
+ ]
759
+
760
+ def _describe_batch(
761
+ self,
762
+ batch: list[ClipCaptionRequest],
763
+ goal: str,
764
+ questions: list[str],
765
+ spoken_context: dict[str, str] | None = None,
766
+ known_entities: list[str] | None = None,
767
+ model: str | None = None,
768
+ max_output_tokens: int | None = None,
769
+ reasoning_effort: str | None = None,
770
+ ) -> dict[str, tuple[str, float]]:
771
+ if self._fatal_error.is_set():
772
+ return {}
773
+ urls_by_clip = {clip.clip_id: self._urls_for_clip(clip) for clip in batch}
774
+ content = _content_for_batch(batch, urls_by_clip)
775
+ content.append(
776
+ {
777
+ "type": "text",
778
+ "text": _build_prompt(
779
+ batch, goal, questions, spoken_context, known_entities, self.depth
780
+ ),
781
+ }
782
+ )
783
+ payload: dict[str, Any] = {
784
+ "model": self._model_for_request(model or self.model),
785
+ "messages": [{"role": "user", "content": content}],
786
+ # Reasoning tokens share `max_tokens` on gateway-routed models, and
787
+ # a run that hits the cap returns truncated JSON that the parser
788
+ # correctly rejects -- losing every clip in the batch and forcing
789
+ # a slow one-at-a-time recovery. Buying headroom here is far
790
+ # cheaper than paying for those retries.
791
+ # Reasoning tokens share this budget on gateway-routed models, and a
792
+ # run that hits the cap returns truncated JSON the parser correctly
793
+ # rejects -- losing every clip in the batch. Measured: a single
794
+ # terse answer from a current reader spends ~1.2k tokens before it
795
+ # emits any content, so a note carrying entities, readings, and
796
+ # events needs real headroom or it reliably returns nothing.
797
+ "max_tokens": max_output_tokens or max(8_192, 2_600 * len(batch)),
798
+ }
799
+ # Gateway model support for strict response schemas is not uniform.
800
+ # The prompt carries the complete contract, so plain JSON is the
801
+ # reliable default. Deployments may opt into provider-enforced schema
802
+ # validation without changing the evidence protocol.
803
+ if os.getenv("LARKUP_VIDEO_USE_STRICT_JSON_SCHEMA", "").lower() in {"1", "true", "yes"}:
804
+ payload["response_format"] = {
805
+ "type": "json_schema",
806
+ "json_schema": {"name": "clip_observations", "schema": _SCHEMA},
807
+ }
808
+ # The bulk reader extracts visible facts rather than exposing a long
809
+ # reasoning trace. Keep its thinking budget small so output tokens are
810
+ # available for structured evidence and an interactive answer stays
811
+ # responsive; the dedicated reasoning model remains available for a later
812
+ # precision verification pass.
813
+ if self.provider == "vercel_ai_gateway":
814
+ selected_model = model or self.model
815
+ payload["reasoning"] = {
816
+ "effort": reasoning_effort
817
+ or os.getenv(
818
+ "LARKUP_VIDEO_SEMANTIC_REASONING_EFFORT",
819
+ "high"
820
+ if selected_model == self.reasoning_model and self.reasoning_model != self.model
821
+ else "minimal",
822
+ )
823
+ }
824
+ headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
825
+ request_timeout = _timeout_for_images(sum(len(urls) for urls in urls_by_clip.values()))
826
+ self.limiter.acquire()
827
+ if self._fatal_error.is_set():
828
+ return {}
829
+ try:
830
+ response = _post_with_retry(
831
+ self._session(),
832
+ f"{self.base_url}/chat/completions",
833
+ headers,
834
+ payload,
835
+ timeout_secs=request_timeout,
836
+ )
837
+ except RuntimeError as error:
838
+ self.last_error = str(error)[:500]
839
+ if len(batch) > 1 and "429" not in str(error):
840
+ return self._describe_each(
841
+ batch,
842
+ goal,
843
+ questions,
844
+ spoken_context,
845
+ known_entities,
846
+ model,
847
+ max_output_tokens,
848
+ reasoning_effort,
849
+ )
850
+ return {}
851
+ if response.status_code == 400:
852
+ # Some gateway-routed models reject strict json_schema; retry loose.
853
+ payload.pop("response_format", None)
854
+ self.limiter.acquire()
855
+ response = _post_with_retry(
856
+ self._session(),
857
+ f"{self.base_url}/chat/completions",
858
+ headers,
859
+ payload,
860
+ timeout_secs=request_timeout,
861
+ )
862
+ if not response.ok:
863
+ self._record_http_error("gateway", response)
864
+ return {}
865
+ try:
866
+ body = response.json()
867
+ except ValueError as error:
868
+ self.last_error = f"gateway returned invalid JSON: {error}"[:500]
869
+ return {}
870
+ try:
871
+ choice = body["choices"][0]
872
+ text = choice["message"]["content"] or ""
873
+ except (KeyError, IndexError, TypeError) as error:
874
+ self.last_error = f"gateway response had no message content: {error}"[:500]
875
+ return {}
876
+ # A truncated structured response cannot be safely cited. Re-run the
877
+ # same source frames as singleton clips: independent singleton calls
878
+ # retain chronological grounding while avoiding a repeated long batch.
879
+ if choice.get("finish_reason") == "length" and len(batch) > 1:
880
+ return self._describe_each(
881
+ batch,
882
+ goal,
883
+ questions,
884
+ spoken_context,
885
+ known_entities,
886
+ model,
887
+ max_output_tokens,
888
+ reasoning_effort,
889
+ )
890
+ parsed = _parse_response(text, batch, spoken_context)
891
+ if parsed:
892
+ missing = [clip for clip in batch if clip.clip_id not in parsed]
893
+ if missing and len(batch) > 1:
894
+ parsed.update(
895
+ self._describe_each(
896
+ missing,
897
+ goal,
898
+ questions,
899
+ spoken_context,
900
+ known_entities,
901
+ model,
902
+ max_output_tokens,
903
+ reasoning_effort,
904
+ )
905
+ )
906
+ self.last_error = None if len(parsed) == len(batch) else "gateway omitted clip observations"
907
+ return parsed
908
+ # A few gateway-routed vision models acknowledge JSON Schema but
909
+ # occasionally emit an unparseable/empty structured message. Retry
910
+ # that same small frame batch once with ordinary JSON instructions
911
+ # before declaring the source inconclusive. This is a recovery path,
912
+ # not a second analysis strategy, and never manufactures evidence.
913
+ fallback_payload = dict(payload)
914
+ fallback_payload.pop("response_format", None)
915
+ fallback_payload["messages"] = [
916
+ *payload["messages"],
917
+ {
918
+ "role": "user",
919
+ "content": (
920
+ "Your previous response was not usable. Return JSON only with exactly this shape: "
921
+ "{\"clips\":[{\"clipIndex\":0,\"summary\":\"...\",\"supportedClaims\":[\"...\"],"
922
+ "\"sourceQuestions\":[{\"text\":\"...\",\"answer\":\"\","
923
+ "\"basis\":\"spoken|visible\"}],"
924
+ "\"claimQuestion\":\"...\",\"claimVerdict\":\"direct|partial|not-established\","
925
+ "\"claimAnswer\":\"...\",\"claimBindings\":[{\"subject\":\"...\","
926
+ "\"relation\":\"...\",\"value\":\"...\"}],"
927
+ "\"uncertainty\":\"...\"}]}. "
928
+ "Include one entry for every clip."
929
+ ),
930
+ },
931
+ ]
932
+ self.limiter.acquire()
933
+ try:
934
+ fallback = _post_with_retry(
935
+ self._session(),
936
+ f"{self.base_url}/chat/completions",
937
+ headers,
938
+ fallback_payload,
939
+ timeout_secs=request_timeout,
940
+ )
941
+ except RuntimeError as error:
942
+ self.last_error = str(error)[:500]
943
+ return {}
944
+ if not fallback.ok:
945
+ self._record_http_error("gateway fallback", fallback)
946
+ return {}
947
+ try:
948
+ fallback_text = fallback.json()["choices"][0]["message"]["content"] or ""
949
+ except (KeyError, IndexError, TypeError, ValueError) as error:
950
+ self.last_error = f"gateway fallback had no message content: {error}"[:500]
951
+ return {}
952
+ parsed = _parse_response(fallback_text, batch, spoken_context)
953
+ if not parsed:
954
+ self.last_error = "gateway returned no valid clip observations"
955
+ else:
956
+ self.last_error = None
957
+ return parsed
958
+
959
+ def describe_clips(
960
+ self,
961
+ clips: list[ClipCaptionRequest],
962
+ goal: str,
963
+ questions: list[str],
964
+ spoken_context: dict[str, str] | None = None,
965
+ known_entities: list[str] | None = None,
966
+ *,
967
+ use_reasoning_model: bool = False,
968
+ on_progress: Callable[[int, int], None] | None = None,
969
+ interactive: bool = False,
970
+ ) -> dict[str, tuple[str, float]]:
971
+ """Returns clip_id -> (caption_text, confidence) for every clip that yielded evidence.
972
+
973
+ `use_reasoning_model` switches from the bulk indexing model to the
974
+ larger reasoning model -- reserved for watch_original's bounded,
975
+ low-volume, high-stakes final verification pass, never for full-video
976
+ indexing where its cost/latency would dominate the job.
977
+ """
978
+ if not self.enabled or not clips:
979
+ return {}
980
+ self._fatal_error.clear()
981
+ self._fatal_error_message = None
982
+ model = self.reasoning_model if use_reasoning_model else self.model
983
+ reasoning_effort = os.getenv(
984
+ (
985
+ "LARKUP_VIDEO_REASONING_THINKING_LEVEL"
986
+ if use_reasoning_model
987
+ else "LARKUP_VIDEO_SEMANTIC_THINKING_LEVEL"
988
+ ),
989
+ "low" if use_reasoning_model else "minimal",
990
+ ).strip().lower()
991
+ if reasoning_effort not in {"minimal", "low", "medium", "high"}:
992
+ reasoning_effort = "low" if use_reasoning_model else "minimal"
993
+ # A live answer needs one concise, timestamped observation, not the
994
+ # large multi-clip index payload used during offline ingestion. A
995
+ # smaller output ceiling materially reduces gateway latency while the
996
+ # same evidence schema and coverage checks preserve grounding.
997
+ # A close read can carry a dozen chronological frames. Some gateway
998
+ # models account their compact visual reasoning against this same
999
+ # output ceiling, so 1k tokens can truncate otherwise-valid JSON and
1000
+ # turn a useful observation into a failed job. Keep the small fast
1001
+ # budget for ordinary looks, but give denser reads enough room to
1002
+ # finish one concise evidence object instead of retrying the images.
1003
+ # A live look still has to finish one complete evidence object. Current
1004
+ # readers spend most of a small budget before emitting any content, so a
1005
+ # tight ceiling here does not return a shorter answer -- it returns an
1006
+ # unparseable one, and the question comes back unanswered.
1007
+ interactive_frame_count = max((len(clip.frames) for clip in clips), default=0)
1008
+ max_output_tokens = (
1009
+ (4_096 if interactive_frame_count > 8 else 3_072) if interactive else None
1010
+ )
1011
+ batches = self._batches_for(clips)
1012
+ results: dict[str, tuple[str, float]] = {}
1013
+ completed = 0
1014
+ with ThreadPoolExecutor(max_workers=min(self.max_concurrency, len(batches))) as pool:
1015
+ futures = {
1016
+ pool.submit(
1017
+ self._describe_batch,
1018
+ batch,
1019
+ goal,
1020
+ questions,
1021
+ spoken_context,
1022
+ known_entities,
1023
+ model,
1024
+ max_output_tokens,
1025
+ reasoning_effort,
1026
+ ): len(batch)
1027
+ for batch in batches
1028
+ }
1029
+ for future in as_completed(futures):
1030
+ batch_result = future.result()
1031
+ results.update(batch_result)
1032
+ # Report usable evidence, not merely finished HTTP requests.
1033
+ # A rejected batch must not make live progress claim that its
1034
+ # clips were analyzed before the coverage gate fails the job.
1035
+ completed += len(batch_result)
1036
+ if on_progress:
1037
+ on_progress(completed, len(clips))
1038
+ missing_count = len(clips) - len(results)
1039
+ if missing_count:
1040
+ provider_error = (
1041
+ self._fatal_error_message
1042
+ or self.last_error
1043
+ or "one or more provider batches returned no evidence"
1044
+ )
1045
+ self.last_error = (
1046
+ f"semantic vision returned {len(results)}/{len(clips)} clips: {provider_error}"
1047
+ )[:500]
1048
+ else:
1049
+ self.last_error = None
1050
+ return results
1051
+
1052
+ def _batches_for(self, clips: list[ClipCaptionRequest]) -> list[list[ClipCaptionRequest]]:
1053
+ batches: list[list[ClipCaptionRequest]] = []
1054
+ batch: list[ClipCaptionRequest] = []
1055
+ image_count = 0
1056
+ for clip in clips:
1057
+ clip_images = max(1, len(clip.frames))
1058
+ if batch and (
1059
+ len(batch) >= self.batch_size
1060
+ or image_count + clip_images > self.max_images_per_request
1061
+ ):
1062
+ batches.append(batch)
1063
+ batch, image_count = [], 0
1064
+ batch.append(clip)
1065
+ image_count += clip_images
1066
+ if batch:
1067
+ batches.append(batch)
1068
+ return batches
1069
+
1070
+
1071
+ class GeminiVisionClient(GatewayVisionClient):
1072
+ """Native Gemini client for users who configured Google directly in AI Models."""
1073
+
1074
+ def __init__(self) -> None:
1075
+ super().__init__()
1076
+ self.provider = "google"
1077
+ self.fallback_api_key = os.getenv(
1078
+ "LARKUP_VIDEO_GOOGLE_FALLBACK_API_KEY", ""
1079
+ ).strip()
1080
+ # Direct Gemini projects commonly enforce a much lower concurrent
1081
+ # request ceiling than an aggregating gateway.
1082
+ self.max_concurrency = max(
1083
+ 1, min(8, int(os.getenv("LARKUP_VIDEO_GOOGLE_CONCURRENCY", "4")))
1084
+ )
1085
+ self.batch_size = max(
1086
+ 1, min(4, int(os.getenv("LARKUP_VIDEO_GOOGLE_BATCH_SIZE", "1")))
1087
+ )
1088
+ self.max_images_per_request = max(
1089
+ 1, min(16, int(os.getenv("LARKUP_VIDEO_GOOGLE_MAX_IMAGES_PER_REQUEST", "8")))
1090
+ )
1091
+ self.limiter = GatewayRateLimiter(
1092
+ # New Gemini projects commonly start at 15 RPM. Keep headroom for
1093
+ # the planner/brain, which may use the same project and has its own
1094
+ # process-local limiter.
1095
+ int(os.getenv("LARKUP_VIDEO_GOOGLE_REQUESTS_PER_MINUTE", "12"))
1096
+ )
1097
+ self.base_url = os.getenv(
1098
+ "LARKUP_VIDEO_GEMINI_BASE_URL", "https://generativelanguage.googleapis.com/v1beta"
1099
+ ).rstrip("/")
1100
+ self.model = self._native_model(self.model)
1101
+ self.reasoning_model = self._native_model(self.reasoning_model)
1102
+ self.use_interactions_api = os.getenv(
1103
+ "LARKUP_VIDEO_GOOGLE_USE_INTERACTIONS_API", "true"
1104
+ ).strip().lower() not in {"0", "false", "no", "off"}
1105
+
1106
+ @staticmethod
1107
+ def _native_model(model: str) -> str:
1108
+ return model.split("/", 1)[1] if model.startswith("google/") else model
1109
+
1110
+ def _describe_batch(
1111
+ self,
1112
+ batch: list[ClipCaptionRequest],
1113
+ goal: str,
1114
+ questions: list[str],
1115
+ spoken_context: dict[str, str] | None = None,
1116
+ known_entities: list[str] | None = None,
1117
+ model: str | None = None,
1118
+ max_output_tokens: int | None = None,
1119
+ reasoning_effort: str | None = None,
1120
+ ) -> dict[str, tuple[str, float]]:
1121
+ if self._fatal_error.is_set():
1122
+ return {}
1123
+ parts: list[dict[str, Any]] = []
1124
+ for index, clip in enumerate(batch):
1125
+ parts.append({"text": f"--- CLIP {index} frames ---"})
1126
+ for _, frame in clip.frames:
1127
+ parts.append({"inline_data": _frame_to_inline_data(frame)})
1128
+ parts.append(
1129
+ {
1130
+ "text": _build_prompt(
1131
+ batch, goal, questions, spoken_context, known_entities, self.depth
1132
+ )
1133
+ }
1134
+ )
1135
+ selected_model = self._native_model(model or self.model)
1136
+ generation_config: dict[str, Any] = {
1137
+ # Native Gemini does not charge hidden thinking against this cap.
1138
+ # A bounded ceiling keeps extraction responsive while still
1139
+ # allowing a full paragraph plus entities/events for every clip.
1140
+ "maxOutputTokens": max_output_tokens or max(2_048, 1_400 * len(batch)),
1141
+ "responseMimeType": "application/json",
1142
+ "responseSchema": _SCHEMA,
1143
+ }
1144
+ if selected_model.startswith("gemini-3"):
1145
+ generation_config["thinkingConfig"] = {
1146
+ "thinkingLevel": reasoning_effort or "minimal"
1147
+ }
1148
+ elif selected_model.startswith("gemini-2.5"):
1149
+ # Gemini 2.5 uses a numeric budget instead of Gemini 3's level.
1150
+ # Bulk extraction is direct perception plus schema filling, so
1151
+ # disabling hidden thinking preserves the visual answer while
1152
+ # removing avoidable per-batch latency.
1153
+ generation_config["thinkingConfig"] = {"thinkingBudget": 0}
1154
+ generation_config["temperature"] = 0
1155
+ else:
1156
+ generation_config["temperature"] = 0
1157
+ use_interactions = self.use_interactions_api and selected_model.startswith("gemini-3")
1158
+ if use_interactions:
1159
+ interaction_input: list[dict[str, Any]] = []
1160
+ for part in parts:
1161
+ if "text" in part:
1162
+ interaction_input.append({"type": "text", "text": part["text"]})
1163
+ elif "inline_data" in part:
1164
+ interaction_input.append(
1165
+ {
1166
+ "type": "image",
1167
+ "data": part["inline_data"]["data"],
1168
+ "mime_type": part["inline_data"]["mime_type"],
1169
+ }
1170
+ )
1171
+ interaction_generation = {
1172
+ "max_output_tokens": generation_config["maxOutputTokens"],
1173
+ "thinking_level": reasoning_effort or "minimal",
1174
+ }
1175
+ payload = {
1176
+ "model": selected_model,
1177
+ "input": interaction_input,
1178
+ "response_format": {
1179
+ "type": "text",
1180
+ "mime_type": "application/json",
1181
+ "schema": _SCHEMA,
1182
+ },
1183
+ "generation_config": interaction_generation,
1184
+ "store": False,
1185
+ }
1186
+ request_url = f"{self.base_url}/interactions"
1187
+ else:
1188
+ payload = {
1189
+ "contents": [{"role": "user", "parts": parts}],
1190
+ "generationConfig": generation_config,
1191
+ }
1192
+ request_url = f"{self.base_url}/models/{selected_model}:generateContent"
1193
+ headers = {"x-goog-api-key": self.api_key, "Content-Type": "application/json"}
1194
+ request_timeout = _timeout_for_images(sum(len(clip.frames) for clip in batch))
1195
+ self.limiter.acquire()
1196
+ if self._fatal_error.is_set():
1197
+ return {}
1198
+ try:
1199
+ response = _post_with_retry(
1200
+ self._session(),
1201
+ request_url,
1202
+ headers,
1203
+ payload,
1204
+ attempts=1 if self.fallback_api_key else 3,
1205
+ timeout_secs=request_timeout,
1206
+ )
1207
+ except RuntimeError as error:
1208
+ # Managed Cloud can carry a second independently configured key.
1209
+ # Rotate only on quota exhaustion; malformed requests and model
1210
+ # errors must remain visible instead of being duplicated.
1211
+ if self.fallback_api_key and "429" in str(error):
1212
+ headers = {
1213
+ "x-goog-api-key": self.fallback_api_key,
1214
+ "Content-Type": "application/json",
1215
+ }
1216
+ try:
1217
+ response = _post_with_retry(
1218
+ self._session(),
1219
+ request_url,
1220
+ headers,
1221
+ payload,
1222
+ attempts=1,
1223
+ timeout_secs=request_timeout,
1224
+ )
1225
+ except RuntimeError as fallback_error:
1226
+ self.last_error = str(fallback_error)[:500]
1227
+ return {}
1228
+ else:
1229
+ self.last_error = str(error)[:500]
1230
+ if len(batch) > 1 and "429" not in str(error):
1231
+ return self._describe_each(
1232
+ batch,
1233
+ goal,
1234
+ questions,
1235
+ spoken_context,
1236
+ known_entities,
1237
+ model,
1238
+ max_output_tokens,
1239
+ reasoning_effort,
1240
+ )
1241
+ return {}
1242
+ if not response.ok:
1243
+ self._record_http_error("Gemini", response)
1244
+ return {}
1245
+ try:
1246
+ response_payload = response.json()
1247
+ if use_interactions:
1248
+ text = "\n".join(
1249
+ str(content.get("text") or "")
1250
+ for step in response_payload.get("steps") or []
1251
+ if step.get("type") == "model_output"
1252
+ for content in step.get("content") or []
1253
+ if content.get("type") == "text"
1254
+ )
1255
+ else:
1256
+ response_parts = response_payload["candidates"][0]["content"]["parts"]
1257
+ text = "\n".join(str(part.get("text") or "") for part in response_parts)
1258
+ except (KeyError, IndexError, TypeError, ValueError) as error:
1259
+ self.last_error = f"Gemini response had no text content: {error}"[:500]
1260
+ return {}
1261
+ parsed = _parse_response(text, batch, spoken_context)
1262
+ missing = [clip for clip in batch if clip.clip_id not in parsed]
1263
+ # Structured output normally guarantees one item per clip. If a model
1264
+ # still omits one from a multi-clip response, retry only the omitted
1265
+ # source clip so coverage is recovered without repeating good work.
1266
+ if missing and len(batch) > 1:
1267
+ parsed.update(
1268
+ self._describe_each(
1269
+ missing,
1270
+ goal,
1271
+ questions,
1272
+ spoken_context,
1273
+ known_entities,
1274
+ model,
1275
+ max_output_tokens,
1276
+ reasoning_effort,
1277
+ )
1278
+ )
1279
+ self.last_error = None if parsed else "Gemini returned no valid clip observations"
1280
+ return parsed
1281
+
1282
+
1283
+ class SemanticVisionService:
1284
+ """Agent-planned semantic reading over a video's per-clip frame sets."""
1285
+
1286
+ def __init__(self, enabled: bool, disabled: bool) -> None:
1287
+ self.enabled = enabled and not disabled
1288
+ self.last_error: str | None = None
1289
+ provider = os.getenv("LARKUP_VIDEO_VISION_PROVIDER", "vercel_ai_gateway").strip().lower()
1290
+ self._client = GeminiVisionClient() if provider == "google" else GatewayVisionClient()
1291
+
1292
+ def describe_clips(
1293
+ self,
1294
+ clips: dict[str, tuple[int, int, list[tuple[int, Any]]]],
1295
+ brief: dict[str, Any],
1296
+ transcript: list[dict[str, Any]] | None = None,
1297
+ on_progress: Callable[[int, int], None] | None = None,
1298
+ visual_observations: list[dict[str, Any]] | None = None,
1299
+ ) -> list[SemanticObservation]:
1300
+ """`clips` maps clip_id -> (start_ms, end_ms, sampled (time_ms, frame) pairs).
1301
+
1302
+ Bounded `thorough` inspection (watch_original) routes through the
1303
+ larger reasoning model: it runs over a handful of clips at most, so
1304
+ the added cost/latency is negligible next to full-index captioning,
1305
+ while accuracy on the final verification pass matters more.
1306
+ """
1307
+ if not self.enabled or not clips:
1308
+ return []
1309
+ if not self._client.enabled:
1310
+ self.last_error = "A vision provider API key is not configured; semantic vision is disabled"
1311
+ return []
1312
+ goal = str(brief.get("goal") or "")
1313
+ # Coverage is a promise about how much the notes say, not only about how
1314
+ # many frames were read. A bounded interactive look always reads closely:
1315
+ # it exists because something needed establishing.
1316
+ self._client.depth = (
1317
+ "thorough"
1318
+ if brief.get("interactive") is True
1319
+ else str(brief.get("indexingMode") or "balanced").strip().lower()
1320
+ )
1321
+ questions = [
1322
+ str(value).strip() for value in brief.get("expectedQuestions", []) if str(value).strip()
1323
+ ]
1324
+ known_entities = [
1325
+ str(value).strip() for value in brief.get("knownEntities", []) if str(value).strip()
1326
+ ]
1327
+ extraction_focus = [
1328
+ str(value).strip()
1329
+ for value in brief.get("agentExtractionFocus", [])
1330
+ if str(value).strip()
1331
+ ]
1332
+ if extraction_focus:
1333
+ goal = "\n".join(
1334
+ part
1335
+ for part in (
1336
+ goal,
1337
+ "Agent-selected extraction focus: " + " | ".join(extraction_focus[:12])[:1400],
1338
+ )
1339
+ if part
1340
+ )
1341
+ requests_ = [
1342
+ ClipCaptionRequest(clip_id=clip_id, start_ms=start_ms, end_ms=end_ms, frames=frames)
1343
+ for clip_id, (start_ms, end_ms, frames) in clips.items()
1344
+ if frames
1345
+ ]
1346
+ spoken_context: dict[str, str] = {}
1347
+ for request in requests_:
1348
+ speech = " ".join(
1349
+ str(segment.get("text") or "").strip()
1350
+ for segment in (transcript or [])
1351
+ if float(segment.get("endMs") or 0) >= request.start_ms
1352
+ and float(segment.get("startMs") or 0) <= request.end_ms
1353
+ and str(segment.get("text") or "").strip()
1354
+ )[:900]
1355
+ ocr_candidates: list[tuple[float, int, str]] = []
1356
+ seen_ocr: set[str] = set()
1357
+ for observation in visual_observations or []:
1358
+ time_ms = int(observation.get("timeMs") or 0)
1359
+ if not request.start_ms <= time_ms <= request.end_ms:
1360
+ continue
1361
+ for line in observation.get("ocr") or []:
1362
+ text = str(line.get("text") or "").strip()
1363
+ confidence = float(line.get("confidence") or 0)
1364
+ key = text.casefold()
1365
+ if len(text) < 2 or confidence < 0.75 or key in seen_ocr:
1366
+ continue
1367
+ seen_ocr.add(key)
1368
+ ocr_candidates.append((confidence, time_ms, text[:120]))
1369
+ selected_ocr = sorted(ocr_candidates, reverse=True)[:40]
1370
+ selected_ocr.sort(key=lambda item: item[1])
1371
+ ocr = "; ".join(
1372
+ f"{time_ms / 1000:.1f}s {text!r}"
1373
+ for _confidence, time_ms, text in selected_ocr
1374
+ )[:1400]
1375
+ context_parts = []
1376
+ if speech:
1377
+ context_parts.append(f"Synchronized speech: {speech}")
1378
+ if ocr:
1379
+ context_parts.append(f"Machine-read visible text: {ocr}")
1380
+ spoken_context[request.clip_id] = " | ".join(context_parts)
1381
+ # A bounded thorough pass runs over a handful of clips after retrieval
1382
+ # has already narrowed the source, so the separately configured
1383
+ # reasoning reader's cost and latency are negligible there while its
1384
+ # accuracy on a close read is not. Hosts can opt out for a
1385
+ # latency-sensitive deployment.
1386
+ use_reasoning_model = (
1387
+ brief.get("interactive") is True
1388
+ and brief.get("indexingMode") == "thorough"
1389
+ and os.getenv("LARKUP_VIDEO_USE_REASONING_VISION_MODEL", "true").strip().lower()
1390
+ not in {"0", "false", "no", "off"}
1391
+ )
1392
+ if use_reasoning_model:
1393
+ try:
1394
+ requested_frames = int(brief.get("maxFrames") or 0)
1395
+ except (TypeError, ValueError):
1396
+ requested_frames = 0
1397
+ requests_ = [
1398
+ ClipCaptionRequest(
1399
+ clip_id=request.clip_id,
1400
+ start_ms=request.start_ms,
1401
+ end_ms=request.end_ms,
1402
+ frames=_precision_frames(
1403
+ request.frames,
1404
+ max_source_frames=(
1405
+ min(24, max(6, requested_frames))
1406
+ if request.clip_id == "clip_continuous_sequence"
1407
+ else 4
1408
+ ),
1409
+ ),
1410
+ )
1411
+ for request in requests_
1412
+ ]
1413
+ try:
1414
+ captions = self._client.describe_clips(
1415
+ requests_,
1416
+ goal,
1417
+ questions,
1418
+ spoken_context,
1419
+ known_entities,
1420
+ use_reasoning_model=use_reasoning_model,
1421
+ on_progress=on_progress,
1422
+ interactive=brief.get("interactive") is True,
1423
+ )
1424
+ self.last_error = self._client.last_error
1425
+ except Exception as error:
1426
+ # Object/OCR evidence remains useful when the gateway is
1427
+ # unreachable. Do not fail an entire index, but preserve a
1428
+ # bounded diagnostic for cloud operations.
1429
+ self.last_error = f"{type(error).__name__}: {error}"[:500]
1430
+ return []
1431
+ bounds = {clip_id: (start_ms, end_ms) for clip_id, (start_ms, end_ms, _) in clips.items()}
1432
+ return [
1433
+ SemanticObservation(
1434
+ start_ms=bounds[clip_id][0],
1435
+ end_ms=bounds[clip_id][1],
1436
+ text=text,
1437
+ confidence=confidence,
1438
+ )
1439
+ for clip_id, (text, confidence) in captions.items()
1440
+ if text.strip()
1441
+ ]