Skip to content

Commit 2143d25

Browse files
authored
chore(assessment): Video Integration (#1211)
1 parent 0198d78 commit 2143d25

11 files changed

Lines changed: 188 additions & 31 deletions

File tree

‎backend/app/crud/assessment/batch.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
AssessmentSubmission,
2626
)
2727
from app.models.batch_job import BatchJob, BatchJobType
28+
from app.models.config.assessment_blob import VIDEO_COLUMN_TYPE
2829
from app.models.llm.constants import DEFAULT_ASSESSMENT_BATCH_MAX_TOKENS
2930
from app.models.llm.request import ConfigBlob
3031
from app.services.assessment.utils.attachments import (
3132
attachment_type_for_row,
3233
build_anthropic_attachment_parts,
3334
build_gemini_attachment_parts,
3435
resolve_attachment_values,
36+
resolve_item_type,
3537
rewrite_gcs_attachment_urls,
3638
)
3739
from app.services.assessment.validators import (
@@ -189,13 +191,21 @@ def build_google_jsonl(
189191
parts.append({"text": text_prompt})
190192

191193
# Attachments (Gemini uses file_data for inline content)
194+
video_part_config = google_params.get("video_part_config")
195+
has_video = False
192196
for att in attachments:
193197
cell_value = row.get(att.column, "")
198+
type_override = attachment_type_for_row(att, row)
199+
if cell_value.strip() and (
200+
resolve_item_type(att.type, type_override) == VIDEO_COLUMN_TYPE
201+
):
202+
has_video = True
194203
parts.extend(
195204
build_gemini_attachment_parts(
196205
cell_value,
197206
att,
198-
type_override=attachment_type_for_row(att, row),
207+
type_override=type_override,
208+
video_part_config=video_part_config,
199209
)
200210
)
201211

@@ -227,6 +237,9 @@ def build_google_jsonl(
227237
if output_schema:
228238
generation_config["responseMimeType"] = "application/json"
229239
generation_config["responseSchema"] = output_schema
240+
media_resolution = google_params.get("media_resolution")
241+
if media_resolution and has_video:
242+
generation_config["mediaResolution"] = media_resolution
230243
if generation_config:
231244
request["generationConfig"] = generation_config
232245

‎backend/app/models/assessment/assessment.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from sqlmodel import Relationship, SQLModel
1818

1919
from app.core.util import now
20+
from app.models.config.assessment_blob import AttachmentColumnType
2021

2122
if TYPE_CHECKING:
2223
from app.models.batch_job import BatchJob
@@ -83,16 +84,19 @@ class AssessmentAttachment(BaseModel):
8384
"""External-dataset attachment column config (RUN / BATCH-by-ref)."""
8485

8586
column: str = Field(..., description="Dataset column holding the attachment")
86-
type: Literal["image", "pdf", "mixed"] = Field(
87+
type: AttachmentColumnType | Literal["mixed"] = Field(
8788
...,
88-
description="'image'/'pdf' fix the type; 'mixed' resolves per-row via type_column",
89+
description=(
90+
"'image'/'pdf'/'video' fix the type; 'mixed' resolves per-row via type_column"
91+
),
8992
)
9093
format: Literal["url", "base64"] = Field(..., description="Data format")
9194
type_column: str | None = Field(
9295
None, description="'mixed' only: column whose value decides each row's type"
9396
)
94-
type_value_map: dict[str, Literal["image", "pdf"]] | None = Field(
95-
None, description="'mixed' only: maps a type_column value to 'image' or 'pdf'"
97+
type_value_map: dict[str, AttachmentColumnType] | None = Field(
98+
None,
99+
description="'mixed' only: maps a type_column value to 'image', 'pdf' or 'video'",
96100
)
97101

98102
@model_validator(mode="after")

‎backend/app/models/config/assessment_blob.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@
1818
# object-typed dict. Provider strict-mode normalisation is a run-mode concern.
1919
JSON_SCHEMA_OBJECT_TYPE = "object"
2020

21+
IMAGE_COLUMN_TYPE = "image"
22+
PDF_COLUMN_TYPE = "pdf"
23+
VIDEO_COLUMN_TYPE = "video"
24+
# Input-column types carrying a media reference rather than prompt text.
25+
ATTACHMENT_COLUMN_TYPES = (IMAGE_COLUMN_TYPE, PDF_COLUMN_TYPE, VIDEO_COLUMN_TYPE)
26+
27+
AttachmentColumnType = Literal["image", "pdf", "video"]
28+
ColumnType = Literal["text"] | AttachmentColumnType
29+
2130
# {column} placeholders in a submission template; the capture group is the column name.
2231
PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
2332

@@ -36,10 +45,16 @@ class InputColumn(SQLModel):
3645

3746
model_config = {"extra": "forbid"}
3847

39-
type: Literal["text", "image", "pdf"]
48+
type: ColumnType
4049
format: Literal["url", "base64"] | None = None
4150
strict: bool = False
4251

52+
@model_validator(mode="after")
53+
def _validate_video_format(self):
54+
if self.type == VIDEO_COLUMN_TYPE and self.format == "base64":
55+
raise ValueError("A 'video' input column must be url-format.")
56+
return self
57+
4358

4459
class PreFilterParams(TextLLMParams):
4560
"""Flat, mapper-ready LLM params for a pre-filter call.

‎backend/app/services/assessment/api/batch.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
)
6262
from app.models.batch_job import BatchJob, BatchJobType
6363
from app.models.config.assessment_blob import (
64+
ATTACHMENT_COLUMN_TYPES,
6465
AssessmentConfigBlob,
6566
AssessmentPreFilters,
6667
TopicRelevanceFilter,
@@ -206,14 +207,14 @@ def column_kinds(
206207
) -> tuple[list[str], list[AssessmentAttachment]]:
207208
"""Split columns into text names and attachment specs per the config's ``input_schema``.
208209
209-
A column typed image/pdf is an attachment (url-format only); anything else is text.
210+
A column typed image/pdf/video is an attachment (url-format only); anything else is text.
210211
"""
211212
text_columns: list[str] = []
212213
attachments: list[AssessmentAttachment] = []
213214
for column in columns:
214215
spec = input_columns.get(column) or {}
215216
col_type = spec.get("type", "text")
216-
if col_type in ("image", "pdf"):
217+
if col_type in ATTACHMENT_COLUMN_TYPES:
217218
if (spec.get("format") or "url") != "url":
218219
raise ValueError(
219220
f"BATCH attachment column '{column}' must be url-format; base64 is "

‎backend/app/services/assessment/api/submission.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
BatchRunState,
2727
derive_method,
2828
)
29-
from app.models.config.assessment_blob import AssessmentConfigBlob
29+
from app.models.config.assessment_blob import (
30+
ATTACHMENT_COLUMN_TYPES,
31+
AssessmentConfigBlob,
32+
)
3033
from app.models.config.config import ConfigTag
3134
from app.services.assessment.api import batch as batch_service
3235
from app.services.assessment.api.submission_store import (
@@ -39,14 +42,22 @@
3942

4043
# Attachment cell values are provided as URLs (base64 is unsupported for batch).
4144
_URL_PREFIXES = ("http://", "https://", "gs://")
42-
_ATTACHMENT_TYPES = ("image", "pdf")
4345
_EXTENSION_TYPES = {
4446
".pdf": "pdf",
4547
".png": "image",
4648
".jpg": "image",
4749
".jpeg": "image",
4850
".gif": "image",
4951
".webp": "image",
52+
".mp4": "video",
53+
".mov": "video",
54+
".mpeg": "video",
55+
".mpg": "video",
56+
".avi": "video",
57+
".wmv": "video",
58+
".flv": "video",
59+
".webm": "video",
60+
".3gp": "video",
5061
}
5162

5263

@@ -93,7 +104,7 @@ def _validate_rows_against_schema(
93104
for column, spec in input_schema.items():
94105
column_type = (spec or {}).get("type")
95106
value = (row.get(column) or "").strip()
96-
if value and column_type in _ATTACHMENT_TYPES:
107+
if value and column_type in ATTACHMENT_COLUMN_TYPES:
97108
if not value.startswith(_URL_PREFIXES):
98109
raise HTTPException(
99110
status_code=422,

‎backend/app/services/assessment/utils/attachments.py‎

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22

33
import logging
44
import re
5-
from typing import Any, cast
5+
from typing import cast
66
from urllib.parse import urlparse
77

8+
from pydantic import JsonValue
89
from sqlmodel import Session
910

1011
from app.core.config import settings
1112
from app.models.assessment import AssessmentAttachment
13+
from app.models.config.assessment_blob import (
14+
ATTACHMENT_COLUMN_TYPES,
15+
IMAGE_COLUMN_TYPE,
16+
VIDEO_COLUMN_TYPE,
17+
)
1218
from app.models.llm.constants import KaapiProvider
1319
from app.services.buckets.attachments import is_gcs_uri, resolve_attachments
1420

@@ -27,6 +33,24 @@
2733
".heif": "image/heif",
2834
}
2935

36+
_VIDEO_MIME_BY_EXT = {
37+
".mp4": "video/mp4",
38+
".mov": "video/mov",
39+
".mpeg": "video/mpeg",
40+
".mpg": "video/mpg",
41+
".avi": "video/avi",
42+
".wmv": "video/wmv",
43+
".flv": "video/x-flv",
44+
".webm": "video/webm",
45+
".3gp": "video/3gpp",
46+
}
47+
48+
_PDF_MIME = "application/pdf"
49+
_DEFAULT_IMAGE_MIME = "image/png"
50+
_DEFAULT_VIDEO_MIME = "video/mp4"
51+
52+
YOUTUBE_HOSTS = ("youtube.com", "www.youtube.com", "youtu.be", "m.youtube.com")
53+
3054

3155
def split_attachment_urls(value: str) -> list[str]:
3256
"""Split comma/newline separated attachment URLs from a single dataset cell."""
@@ -125,15 +149,23 @@ def _guess_image_mime_from_url(url: str) -> str | None:
125149
return None
126150

127151

152+
def _resolve_video_mime_from_url(url: str) -> str | None:
153+
path = urlparse(url).path or ""
154+
for ext, mime in _VIDEO_MIME_BY_EXT.items():
155+
if path.lower().endswith(ext):
156+
return mime
157+
return None
158+
159+
128160
def resolve_item_type(declared: str, type_override: str | None = None) -> str | None:
129-
"""Resolve an attachment item as 'image' or 'pdf' from the user-declared type.
161+
"""Resolve an attachment item as 'image', 'pdf' or 'video' from the declared type.
130162
131163
A per-row ``type_override`` (for 'mixed' columns) wins, else the column's declared
132164
``type``. Returns None when the type stays unresolved (e.g. a 'mixed' row whose
133165
value didn't map to a concrete type) so callers can skip rather than guess.
134166
"""
135167
item_type = type_override or declared
136-
return item_type if item_type in ("image", "pdf") else None
168+
return item_type if item_type in ATTACHMENT_COLUMN_TYPES else None
137169

138170

139171
def _normalize_type_value(value: str) -> str:
@@ -153,7 +185,7 @@ def attachment_type_for_row(
153185
) -> str | None:
154186
"""For a 'mixed' column, resolve this row's type from type_column + type_value_map.
155187
156-
Returns 'image'/'pdf', or None to let normal detection (extension/declared) decide.
188+
Returns a concrete attachment type, or None to let normal detection decide.
157189
"""
158190
type_column = getattr(att, "type_column", None)
159191
type_value_map = getattr(att, "type_value_map", None)
@@ -162,7 +194,7 @@ def attachment_type_for_row(
162194

163195
normalized_map: dict[str, str] = {}
164196
for raw_values, mapped_type in type_value_map.items():
165-
if mapped_type not in ("image", "pdf"):
197+
if mapped_type not in ATTACHMENT_COLUMN_TYPES:
166198
continue
167199
for value in _split_type_values(raw_values):
168200
normalized_map[value] = mapped_type
@@ -181,8 +213,8 @@ def resolve_attachment_values(
181213
value: str,
182214
att: AssessmentAttachment,
183215
type_override: str | None = None,
184-
) -> list[dict[str, Any]]:
185-
"""Convert one dataset cell into one or more OpenAI-style input objects (by URL)."""
216+
) -> list[dict[str, JsonValue]]:
217+
"""Resolve one dataset cell into OpenAI-supported content params (by URL)."""
186218
value = value.strip()
187219
if not value:
188220
return []
@@ -194,10 +226,13 @@ def resolve_attachment_values(
194226
att.column,
195227
)
196228
return []
197-
resolved: list[dict[str, Any]] = []
229+
# Openai doesn't support video attachments
230+
if item_type == VIDEO_COLUMN_TYPE:
231+
return []
232+
resolved: list[dict[str, JsonValue]] = []
198233
for item_value in split_attachment_urls(value):
199234
url = to_direct_attachment_url(item_value, item_type)
200-
if item_type == "image":
235+
if item_type == IMAGE_COLUMN_TYPE:
201236
resolved.append({"type": "input_image", "image_url": url})
202237
else:
203238
resolved.append({"type": "input_file", "file_url": url})
@@ -208,7 +243,7 @@ def build_anthropic_attachment_parts(
208243
value: str,
209244
att: AssessmentAttachment,
210245
type_override: str | None = None,
211-
) -> list[dict[str, Any]]:
246+
) -> list[dict[str, JsonValue]]:
212247
"""Convert one dataset cell into one or more Anthropic content blocks (by URL)."""
213248
value = value.strip()
214249
if not value:
@@ -221,25 +256,40 @@ def build_anthropic_attachment_parts(
221256
att.column,
222257
)
223258
return []
224-
blocks: list[dict[str, Any]] = []
259+
# Anthropic doesn't support video attachments
260+
if item_type == VIDEO_COLUMN_TYPE:
261+
return []
262+
blocks: list[dict[str, JsonValue]] = []
225263
for item_value in split_attachment_urls(value):
226264
url = to_direct_attachment_url(item_value, item_type)
227-
if item_type == "image":
265+
if item_type == IMAGE_COLUMN_TYPE:
228266
blocks.append({"type": "image", "source": {"type": "url", "url": url}})
229267
else:
230268
blocks.append({"type": "document", "source": {"type": "url", "url": url}})
231269
return blocks
232270

233271

272+
def build_gemini_video_part(
273+
url: str, video_part_config: dict[str, JsonValue] | None = None
274+
) -> dict[str, JsonValue]:
275+
"""One Gemini ``fileData`` video part; a YouTube url must carry no mimeType."""
276+
file_data: dict[str, JsonValue] = {"fileUri": url}
277+
if (urlparse(url).hostname or "").lower() not in YOUTUBE_HOSTS:
278+
file_data["mimeType"] = _resolve_video_mime_from_url(url) or _DEFAULT_VIDEO_MIME
279+
return {"fileData": file_data, **(video_part_config or {})}
280+
281+
234282
def build_gemini_attachment_parts(
235283
value: str,
236284
att: AssessmentAttachment,
237285
type_override: str | None = None,
238-
) -> list[dict[str, Any]]:
286+
video_part_config: dict[str, JsonValue] | None = None,
287+
) -> list[dict[str, JsonValue]]:
239288
"""Convert one dataset cell into one or more Gemini content parts (by URL).
240289
241290
Mirrors the per-item type routing used for the L2 batch so the same
242-
image/pdf handling applies to prefilter (topic relevance) calls.
291+
image/pdf/video handling applies to prefilter (topic relevance) calls.
292+
``video_part_config`` comes from the Google mapper, already wire-shaped.
243293
"""
244294
value = value.strip()
245295
if not value:
@@ -252,12 +302,14 @@ def build_gemini_attachment_parts(
252302
att.column,
253303
)
254304
return []
255-
parts: list[dict[str, Any]] = []
305+
parts: list[dict[str, JsonValue]] = []
256306
for item_value in split_attachment_urls(value):
257307
url = to_direct_attachment_url(item_value, item_type)
258-
if item_type == "image":
259-
mime_type = _guess_image_mime_from_url(url) or "image/png"
308+
if item_type == IMAGE_COLUMN_TYPE:
309+
mime_type = _guess_image_mime_from_url(url) or _DEFAULT_IMAGE_MIME
260310
parts.append({"fileData": {"mimeType": mime_type, "fileUri": url}})
311+
elif item_type == VIDEO_COLUMN_TYPE:
312+
parts.append(build_gemini_video_part(url, video_part_config))
261313
else:
262-
parts.append({"fileData": {"mimeType": "application/pdf", "fileUri": url}})
314+
parts.append({"fileData": {"mimeType": _PDF_MIME, "fileUri": url}})
263315
return parts

‎backend/app/services/llm/mappers.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,9 @@ def map_kaapi_to_google_params(
272272
- thinking_level → thinking_config.thinking_level (text only)
273273
- output_schema → output_schema, converted to Gemini's shape (text only)
274274
- knowledge_base_ids → FileSearch tool store names (text only)
275+
- video_part_config → Gemini videoMetadata defaults carried on every video
276+
content part (text only)
277+
- media_resolution → generationConfig.mediaResolution default (text only)
275278
276279
Returns:
277280
Tuple of:
@@ -334,6 +337,10 @@ def map_kaapi_to_google_params(
334337
output_schema
335338
)
336339

340+
# Gemini 2.5 rejects a Part-level mediaResolution; generationConfig works on 2.5 and 3.
341+
google_params["video_part_config"] = {"videoMetadata": {"fps": 1.0}}
342+
google_params["media_resolution"] = "MEDIA_RESOLUTION_LOW"
343+
337344
elif completion_type == CompletionType.TTS:
338345
# TTS mode - voice, language, response_format
339346
# Apply smart defaults for voice and response_format (following ElevenLabs pattern)

0 commit comments

Comments
 (0)