22
33import logging
44import re
5- from typing import Any , cast
5+ from typing import cast
66from urllib .parse import urlparse
77
8+ from pydantic import JsonValue
89from sqlmodel import Session
910
1011from app .core .config import settings
1112from 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+ )
1218from app .models .llm .constants import KaapiProvider
1319from app .services .buckets .attachments import is_gcs_uri , resolve_attachments
1420
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
3155def 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+
128160def 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
139171def _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+
234282def 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
0 commit comments