Skip to content

Commit dce91bc

Browse files
authored
feat(assessment): Add audio input column (#1317)
1 parent 922e7eb commit dce91bc

13 files changed

Lines changed: 318 additions & 327 deletions

File tree

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

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,12 @@
2525
AssessmentSubmission,
2626
)
2727
from app.models.batch_job import BatchJob, BatchJobType
28-
from app.models.config.assessment_blob import VIDEO_COLUMN_TYPE
2928
from app.models.llm.constants import DEFAULT_ASSESSMENT_BATCH_MAX_TOKENS
3029
from app.models.llm.request import ConfigBlob
3130
from app.services.assessment.utils.attachments import (
32-
attachment_type_for_row,
3331
build_anthropic_attachment_parts,
3432
build_gemini_attachment_parts,
3533
resolve_attachment_values,
36-
resolve_item_type,
3734
rewrite_gcs_attachment_urls,
3835
)
3936
from app.services.assessment.validators import (
@@ -130,13 +127,7 @@ def build_openai_jsonl(
130127
# Attachments
131128
for att in attachments:
132129
cell_value = row.get(att.column, "")
133-
input_parts.extend(
134-
resolve_attachment_values(
135-
cell_value,
136-
att,
137-
type_override=attachment_type_for_row(att, row),
138-
)
139-
)
130+
input_parts.extend(resolve_attachment_values(cell_value, att))
140131

141132
if not input_parts:
142133
logger.warning("[build_openai_jsonl] Skipping empty row | idx=%s", idx)
@@ -195,17 +186,11 @@ def build_google_jsonl(
195186
has_video = False
196187
for att in attachments:
197188
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-
):
189+
if cell_value.strip() and att.type == "video":
202190
has_video = True
203191
parts.extend(
204192
build_gemini_attachment_parts(
205-
cell_value,
206-
att,
207-
type_override=type_override,
208-
video_part_config=video_part_config,
193+
cell_value, att, video_part_config=video_part_config
209194
)
210195
)
211196

@@ -282,13 +267,7 @@ def build_anthropic_jsonl(
282267
# Attachments (Anthropic uses url-source image/document blocks)
283268
for att in attachments:
284269
cell_value = row.get(att.column, "")
285-
content.extend(
286-
build_anthropic_attachment_parts(
287-
cell_value,
288-
att,
289-
type_override=attachment_type_for_row(att, row),
290-
)
291-
)
270+
content.extend(build_anthropic_attachment_parts(cell_value, att))
292271

293272
if not content:
294273
logger.warning("[build_anthropic_jsonl] Skipping empty row | idx=%s", idx)

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

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import TYPE_CHECKING, Any, Literal, Optional
1010
from uuid import UUID, uuid4
1111

12-
from pydantic import BaseModel, Field, model_validator
12+
from pydantic import BaseModel, Field
1313
from sqlalchemy import Column, Index, Text, text
1414
from sqlalchemy.dialects import postgresql
1515
from sqlalchemy.dialects.postgresql import JSONB
@@ -84,30 +84,10 @@ class AssessmentAttachment(BaseModel):
8484
"""External-dataset attachment column config (RUN / BATCH-by-ref)."""
8585

8686
column: str = Field(..., description="Dataset column holding the attachment")
87-
type: AttachmentColumnType | Literal["mixed"] = Field(
88-
...,
89-
description=(
90-
"'image'/'pdf'/'video' fix the type; 'mixed' resolves per-row via type_column"
91-
),
87+
type: AttachmentColumnType = Field(
88+
..., description="'image', 'pdf', 'video' or 'audio'"
9289
)
9390
format: Literal["url", "base64"] = Field(..., description="Data format")
94-
type_column: str | None = Field(
95-
None, description="'mixed' only: column whose value decides each row's type"
96-
)
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'",
100-
)
101-
102-
@model_validator(mode="after")
103-
def _validate_mixed(self) -> "AssessmentAttachment":
104-
if self.type == "mixed" and (
105-
self.type_column is None or not self.type_value_map
106-
):
107-
raise ValueError(
108-
"type='mixed' requires 'type_column' and a non-empty 'type_value_map'."
109-
)
110-
return self
11191

11292

11393
# NOTE: Only used for UI. The API-client Assessment pipeline does not use this.

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

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
import re
3-
from typing import Literal
3+
from typing import Literal, get_args
44

55
from pydantic import JsonValue, model_validator
66
from sqlmodel import Field, SQLModel
@@ -18,14 +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"
21+
AttachmentColumnType = Literal["image", "pdf", "video", "audio"]
22+
ColumnType = Literal["text"] | AttachmentColumnType
2423
# Input-column types carrying a media reference rather than prompt text.
25-
ATTACHMENT_COLUMN_TYPES = (IMAGE_COLUMN_TYPE, PDF_COLUMN_TYPE, VIDEO_COLUMN_TYPE)
24+
ATTACHMENT_COLUMN_TYPES = get_args(AttachmentColumnType)
2625

27-
AttachmentColumnType = Literal["image", "pdf", "video"]
28-
ColumnType = Literal["text"] | AttachmentColumnType
26+
# Media column types a provider cannot take, so a config pairing them is rejected at save.
27+
UNSUPPORTED_COLUMN_TYPES_BY_PROVIDER: dict[str, tuple[str, ...]] = dict.fromkeys(
28+
(Provider.OPENAI, Provider.ANTHROPIC), ("video", "audio")
29+
)
2930

3031
# {column} placeholders in a submission template; the capture group is the column name.
3132
PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
@@ -51,8 +52,8 @@ class InputColumn(SQLModel):
5152

5253
@model_validator(mode="after")
5354
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.")
55+
if self.type in ("video", "audio") and self.format == "base64":
56+
raise ValueError(f"A '{self.type}' input column must be url-format.")
5657
return self
5758

5859

@@ -225,3 +226,26 @@ def _validate_submission_placeholders(self):
225226
f"{', '.join(unknown)}"
226227
)
227228
return self
229+
230+
@model_validator(mode="after")
231+
def _validate_column_types_supported(self):
232+
"""Every stage's provider must accept each media column type in input_schema."""
233+
stage_providers = {"assessment": self.assessment.provider}
234+
if self.pre_filters and self.pre_filters.topic_relevance:
235+
stage_providers[
236+
"pre_filter topic_relevance"
237+
] = self.pre_filters.topic_relevance.provider
238+
239+
for stage, provider in stage_providers.items():
240+
unsupported = UNSUPPORTED_COLUMN_TYPES_BY_PROVIDER.get(provider, ())
241+
offending = sorted(
242+
column
243+
for column, spec in self.input_schema.items()
244+
if spec.type in unsupported
245+
)
246+
if offending:
247+
raise ValueError(
248+
f"{stage} provider '{provider}' does not support "
249+
f"{'/'.join(unsupported)} input columns: {', '.join(offending)}"
250+
)
251+
return self

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ def column_kinds(
207207
) -> tuple[list[str], list[AssessmentAttachment]]:
208208
"""Split columns into text names and attachment specs per the config's ``input_schema``.
209209
210-
A column typed image/pdf/video is an attachment (url-format only); anything else is text.
210+
A column typed image/pdf/video/audio is an attachment (url-format only); anything else is text.
211211
"""
212212
text_columns: list[str] = []
213213
attachments: list[AssessmentAttachment] = []

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

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
"""
77

88
import logging
9-
from urllib.parse import urlparse
109
from uuid import UUID, uuid4
1110

1211
from asgi_correlation_id import correlation_id
@@ -36,40 +35,16 @@
3635
open_uploaded_rows,
3736
upload_submission_rows,
3837
)
38+
from app.services.assessment.utils.attachments import (
39+
attachment_type_from_url,
40+
split_attachment_urls,
41+
)
3942
from app.utils import validate_callback_url
4043

4144
logger = logging.getLogger(__name__)
4245

4346
# Attachment cell values are provided as URLs (base64 is unsupported for batch).
4447
_URL_PREFIXES = ("http://", "https://", "gs://")
45-
_EXTENSION_TYPES = {
46-
".pdf": "pdf",
47-
".png": "image",
48-
".jpg": "image",
49-
".jpeg": "image",
50-
".gif": "image",
51-
".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",
61-
}
62-
63-
64-
def _url_attachment_type(url: str) -> str | None:
65-
"""The kind a URL's own extension implies, or None when it names no extension.
66-
67-
Presigned URLs carry a query string, so only the path is read.
68-
"""
69-
path = urlparse(url).path.lower()
70-
return next(
71-
(kind for ext, kind in _EXTENSION_TYPES.items() if path.endswith(ext)), None
72-
)
7348

7449

7550
def _validate_rows_against_schema(
@@ -104,8 +79,11 @@ def _validate_rows_against_schema(
10479
for column, spec in input_schema.items():
10580
column_type = (spec or {}).get("type")
10681
value = (row.get(column) or "").strip()
107-
if value and column_type in ATTACHMENT_COLUMN_TYPES:
108-
if not value.startswith(_URL_PREFIXES):
82+
if not value or column_type not in ATTACHMENT_COLUMN_TYPES:
83+
continue
84+
# A cell may hold several URLs; the builder sends each one separately.
85+
for url in split_attachment_urls(value):
86+
if not url.startswith(_URL_PREFIXES):
10987
raise HTTPException(
11088
status_code=422,
11189
detail=(
@@ -114,7 +92,7 @@ def _validate_rows_against_schema(
11492
),
11593
)
11694
# The provider only reports a mistyped column later, as an opaque format error.
117-
actual = _url_attachment_type(value)
95+
actual = attachment_type_from_url(url)
11896
if actual is not None and actual != column_type:
11997
raise HTTPException(
12098
status_code=422,

‎backend/app/services/assessment/prefilter/topic_relevance.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from app.services.assessment.prefilter import constants
1313
from app.services.assessment.prefilter.request_builder import build_request_line
1414
from app.services.assessment.utils.attachments import (
15-
attachment_type_for_row,
1615
build_gemini_attachment_parts,
1716
resolve_attachment_values,
1817
)
@@ -65,11 +64,10 @@ def build_topic_relevance_requests(
6564
cell = row.get(att.column, "")
6665
if not cell.strip():
6766
continue
68-
override = attachment_type_for_row(att, row)
6967
attachment_parts.extend(
70-
resolve_attachment_values(cell, att, type_override=override)
68+
resolve_attachment_values(cell, att)
7169
if is_openai
72-
else build_gemini_attachment_parts(cell, att, type_override=override)
70+
else build_gemini_attachment_parts(cell, att)
7371
)
7472
lines.append(
7573
build_request_line(

‎backend/app/services/assessment/service.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
InputBinding,
3131
StageStatus,
3232
)
33+
from app.models.config.assessment_blob import UNSUPPORTED_COLUMN_TYPES_BY_PROVIDER
3334
from app.models.config.config import ConfigTag
3435
from app.services.llm.providers.registry import LLMProvider
3536

@@ -173,6 +174,23 @@ def start_assessment(
173174
f"Supported providers: {sorted(_SUPPORTED_BATCH_PROVIDERS)}"
174175
),
175176
)
177+
unsupported_types = UNSUPPORTED_COLUMN_TYPES_BY_PROVIDER.get(
178+
str(provider).removesuffix("-native"), ()
179+
)
180+
unsupported_columns = sorted(
181+
att.column
182+
for att in request.input_binding.attachments
183+
if att.type in unsupported_types
184+
)
185+
if unsupported_columns:
186+
raise HTTPException(
187+
status_code=422,
188+
detail=(
189+
f"Config {cfg.id} v{cfg.version} uses provider '{provider}', "
190+
f"which does not support {'/'.join(unsupported_types)} "
191+
f"attachments: {', '.join(unsupported_columns)}"
192+
),
193+
)
176194
resolved_configs.append(cfg)
177195

178196
assessment = create_assessment(

0 commit comments

Comments
 (0)