@larkup/tool-video-intelligence 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +150 -0
- package/LICENSE +176 -0
- package/README.md +281 -0
- package/compose.gpu.yaml +14 -0
- package/compose.yaml +71 -0
- package/dist/agent.d.ts +131 -0
- package/dist/agent.js +2087 -0
- package/dist/brief.d.ts +2 -0
- package/dist/brief.js +37 -0
- package/dist/client.d.ts +46 -0
- package/dist/client.js +139 -0
- package/dist/contracts.d.ts +331 -0
- package/dist/contracts.js +1 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +391 -0
- package/dist/runtime.d.ts +96 -0
- package/dist/runtime.js +592 -0
- package/dist/ui.d.ts +82 -0
- package/dist/ui.js +87 -0
- package/package.json +84 -0
- package/runtime/Dockerfile +119 -0
- package/runtime/app/__init__.py +3 -0
- package/runtime/app/__main__.py +19 -0
- package/runtime/app/api/__init__.py +0 -0
- package/runtime/app/api/deps.py +69 -0
- package/runtime/app/api/v1.py +166 -0
- package/runtime/app/config.py +78 -0
- package/runtime/app/db/__init__.py +0 -0
- package/runtime/app/db/schemas.py +162 -0
- package/runtime/app/db/store.py +466 -0
- package/runtime/app/main.py +27 -0
- package/runtime/app/model_configuration.py +157 -0
- package/runtime/app/services/__init__.py +0 -0
- package/runtime/app/services/brain.py +2221 -0
- package/runtime/app/services/embedding.py +473 -0
- package/runtime/app/services/jobs.py +237 -0
- package/runtime/app/services/motion.py +66 -0
- package/runtime/app/services/pipeline.py +1911 -0
- package/runtime/app/services/scene.py +161 -0
- package/runtime/app/services/storage.py +44 -0
- package/runtime/app/services/transcription.py +667 -0
- package/runtime/app/services/vision.py +1441 -0
- package/runtime/app/utils/__init__.py +0 -0
- package/runtime/app/utils/timing.py +99 -0
- package/runtime/app/worker.py +20 -0
- package/runtime/pyproject.toml +56 -0
- package/runtime/requirements-cpu.txt +15 -0
- package/runtime/requirements-smoke.txt +7 -0
- package/runtime/requirements.txt +14 -0
- package/runtime/uv.lock +3637 -0
- package/scripts/grant-cloud-credits.sh +43 -0
- package/scripts/runtime.mjs +156 -0
- package/scripts/validate-indexing.mjs +168 -0
- package/tool.manifest.json +617 -0
|
@@ -0,0 +1,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
|
+
]
|