Code / services/cj_assessment_service/models_api.py
services/cj_assessment_service/models_api.py
1
"""CJ assessment API and service transfer models.
2
3
Purpose:
4
Define typed CJ assessment request/response DTOs and the metadata adapter
5
used when CJ submits comparison tasks to the LLM Provider service.
6
7
Relationships:
8
- `CJLLMComparisonMetadata` is serialized into LLM Provider request
9
metadata and echoed through callbacks.
10
- Callback persistence re-validates echoed request metadata before
11
retaining it on comparison rows.
12
"""
13
14
from __future__ import annotations
15
16
from dataclasses import dataclass
17
from datetime import datetime
18
from typing import Self
19
from uuid import UUID
20
21
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator
22
23
from common_core import EssayComparisonWinner, SourcePayloadVariant
24
from common_core.events.cj_assessment_events import LLMConfigOverrides
25
from common_core.events.cj_pair_matching_contracts import CJPairMatchingStrategyV2
26
from common_core.events.cj_research_plan_events import CJResearchExplicitComparisonV1
27
from common_core.models.error_models import ErrorDetail as CanonicalErrorDetail
28
from services.cj_assessment_service.cj_core_logic.research_context import (
29
ResearchCJBatchContext,
30
)
31
from services.cj_assessment_service.models_prompt import PromptBlockList
32
33
ComparisonTrackingKey = tuple[str, str] | str
34
35
36
class EssayForComparison(BaseModel):
37
"""Represents an essay prepared for comparison.
38
39
Uses string ID to match ELS essay IDs (els_essay_id).
40
"""
41
42
id: str # Changed from int to str for ELS integration
43
text_content: str
44
student_prompt_identity: str | None = None
45
student_prompt_text: str | None = None
46
current_bt_score: float | None = None
47
is_anchor: bool = False # For BT reference selection
48
49
50
class LLMAssessmentResponseSchema(BaseModel):
51
"""Schema for structured LLM responses to essay comparisons."""
52
53
winner: EssayComparisonWinner
54
justification: str | None = None
55
confidence: float | None = Field(None, ge=1.0, le=5.0)
56
57
58
class ComparisonTask(BaseModel):
59
"""A task for comparing two essays."""
60
61
essay_a: EssayForComparison
62
essay_b: EssayForComparison
63
prompt: str
64
prompt_blocks: PromptBlockList | None = Field(
65
default=None,
66
description=(
67
"Structured prompt blocks for cache-friendly composition. "
68
"The legacy prompt string is derived from these blocks when present."
69
),
70
)
71
explicit_comparison: CJResearchExplicitComparisonV1 | None = None
72
73
74
class LLMProviderAdmission(BaseModel):
75
"""Accepted provider-queue admission for one comparison request."""
76
77
queue_id: UUID
78
79
80
class ComparisonAdmissionResult(BaseModel):
81
"""Per-task provider admission outcome retained before callbacks arrive."""
82
83
task: ComparisonTask
84
request_correlation_id: UUID
85
admission: LLMProviderAdmission | None = None
86
error_detail: CanonicalErrorDetail | None = None
87
88
@model_validator(mode="after")
89
def _validate_closed_outcome(self) -> Self:
90
if (self.admission is None) == (self.error_detail is None):
91
raise ValueError("comparison admission requires exactly one outcome")
92
return self
93
94
@property
95
def admitted(self) -> bool:
96
"""Return whether LLM Provider durably accepted the request."""
97
98
return self.admission is not None
99
100
101
class CJLLMComparisonMetadata(BaseModel):
102
"""Typed metadata adapter for CJ → LLM Provider comparison requests.
103
104
This model centralizes the metadata schema that gets serialized into
105
`LLMComparisonRequest.metadata` so we can lock the contract with tests.
106
The adapter captures the identifiers CJ needs to round-trip callbacks
107
and provides extension hooks for CJ-owned comparison identity metadata.
108
"""
109
110
model_config = ConfigDict(extra="allow")
111
112
essay_a_id: str = Field(description="Source essay identifier for comparison slot A")
113
essay_b_id: str = Field(description="Source essay identifier for comparison slot B")
114
bos_batch_id: str | None = Field(
115
default=None,
116
description="Optional BOS batch identifier (stringified UUID)",
117
)
118
cj_batch_id: str | None = Field(
119
default=None,
120
description="Internal CJ batch identifier (stringified integer id)",
121
)
122
cj_source: str | None = Field(
123
default=None,
124
description="Upstream CJ request source (els, bos, eng_runner, etc.)",
125
)
126
cj_request_type: str | None = Field(
127
default=None,
128
description="Classification for the CJ request (cj_comparison, cj_retry, ...)",
129
)
130
comparison_iteration: int | None = Field(
131
default=None,
132
ge=0,
133
description="Optional CJ continuation-wave counter",
134
)
135
comparison_id: str | None = Field(
136
default=None,
137
description="Stable producer-owned explicit comparison identifier",
138
)
139
pair_id: str | None = Field(
140
default=None,
141
description="Stable producer-owned pair identifier",
142
)
143
orientation: str | None = None
144
treatment_ref: str | None = None
145
146
@classmethod
147
def from_comparison_task(
148
cls,
149
task: ComparisonTask,
150
*,
151
bos_batch_id: str | None = None,
152
) -> "CJLLMComparisonMetadata":
153
"""Build metadata for a single comparison task."""
154
explicit_comparison = task.explicit_comparison
155
return cls(
156
essay_a_id=task.essay_a.id,
157
essay_b_id=task.essay_b.id,
158
bos_batch_id=bos_batch_id,
159
comparison_id=(
160
explicit_comparison.comparison_id if explicit_comparison is not None else None
161
),
162
pair_id=explicit_comparison.pair_id if explicit_comparison is not None else None,
163
orientation=explicit_comparison.orientation
164
if explicit_comparison is not None
165
else None,
166
treatment_ref=(
167
explicit_comparison.treatment_ref if explicit_comparison is not None else None
168
),
169
)
170
171
def to_request_metadata(
172
self,
173
extra: dict[str, JsonValue] | None = None,
174
) -> dict[str, JsonValue]:
175
"""Serialize metadata for outbound LLM Provider requests."""
176
metadata: dict[str, JsonValue] = self.model_dump(exclude_none=True)
177
if extra:
178
metadata.update(extra)
179
return metadata
180
181
def with_additional_context(self, **context: JsonValue) -> "CJLLMComparisonMetadata":
182
"""Create a copy with additive metadata while preserving typed fields."""
183
payload = self.model_dump(exclude_none=False)
184
payload.update(context)
185
return CJLLMComparisonMetadata(**payload)
186
187
188
class ComparisonResult(BaseModel):
189
"""The result of a comparison task."""
190
191
task: ComparisonTask
192
llm_assessment: LLMAssessmentResponseSchema | None = None
193
error_detail: CanonicalErrorDetail | None = None
194
raw_llm_response_content: str | None = None
195
196
197
class ErrorResponse(BaseModel):
198
"""API error response wrapper.
199
200
This model wraps ErrorDetail with an HTTP status code for consistent
201
error response format across all API endpoints.
202
"""
203
204
error: CanonicalErrorDetail
205
status_code: int
206
207
208
class FailedComparisonEntry(BaseModel):
209
"""Represents a failed comparison in the retry pool."""
210
211
essay_a_id: str
212
essay_b_id: str
213
comparison_task: ComparisonTask
214
failure_reason: str
215
failed_at: datetime
216
retry_count: int = 0
217
original_batch_id: str
218
correlation_id: UUID
219
220
221
class FailedComparisonPoolStatistics(BaseModel):
222
"""Statistics for the failed comparison pool."""
223
224
total_failed: int = 0
225
retry_attempts: int = 0
226
last_retry_batch: str | None = None
227
successful_retries: int = 0
228
permanently_failed: int = 0
229
230
231
class FailedComparisonPool(BaseModel):
232
"""Complete failed comparison pool structure."""
233
234
failed_comparison_pool: list[FailedComparisonEntry] = Field(default_factory=list)
235
pool_statistics: FailedComparisonPoolStatistics = Field(
236
default_factory=FailedComparisonPoolStatistics
237
)
238
239
240
class RegisterAnchorRequest(BaseModel):
241
"""Request payload for registering anchor essays."""
242
243
assignment_id: str
244
grade: str
245
anchor_label: str | None = None
246
essay_text: str
247
248
@field_validator("assignment_id", "grade")
249
@classmethod
250
def validate_non_empty(cls, value: str) -> str:
251
if not value.strip():
252
raise ValueError("Value cannot be empty")
253
return value
254
255
@field_validator("essay_text")
256
@classmethod
257
def validate_essay_length(cls, value: str) -> str:
258
if len(value) < 100:
259
raise ValueError("Essay text too short (min 100 chars)")
260
return value
261
262
263
@dataclass(frozen=True)
264
class PromptHydrationFailure:
265
"""Represents a prompt hydration failure with diagnostic information.
266
267
This dataclass encapsulates failure reasons for prompt fetching operations,
268
enabling Result-based error discrimination without raising exceptions.
269
"""
270
271
reason: str # "empty_content", "content_storage_error", "unexpected_error", # noqa: E501
272
# "batch_creation_hydration_failed"
273
storage_id: str | None = None
274
275
276
class CJProcessingMetadata(BaseModel):
277
"""Typed metadata model for CJ batch processing context.
278
279
Enforces schema for known metadata keys while preventing
280
unknown keys from being added during batch creation.
281
"""
282
283
model_config = ConfigDict(extra="forbid")
284
285
student_prompt_storage_id: str | None = None
286
student_prompt_text: str | None = None
287
source_reference_storage_id: str | None = None
288
source_reference_text: str | None = None
289
source_reference_variant: SourcePayloadVariant | None = None
290
source_reference_file_sha256: str | None = None
291
source_reference_text_sha256: str | None = None
292
judge_rubric_storage_id: str | None = None
293
judge_rubric_text: str | None = None
294
295
296
class OriginalCJRequestMetadata(BaseModel):
297
"""Persisted snapshot of the runner-supplied CJ request payload."""
298
299
model_config = ConfigDict(extra="forbid")
300
301
assignment_id: str | None = None
302
language: str
303
course_code: str
304
cj_source: str | None = Field(default="els")
305
cj_request_type: str | None = Field(default="cj_comparison")
306
student_prompt_text: str | None = None
307
student_prompt_storage_id: str | None = None
308
source_reference_text: str | None = None
309
source_reference_storage_id: str | None = None
310
source_reference_variant: SourcePayloadVariant | None = None
311
source_reference_file_sha256: str | None = None
312
source_reference_text_sha256: str | None = None
313
judge_rubric_text: str | None = None
314
judge_rubric_storage_id: str | None = None
315
llm_config_overrides: LLMConfigOverrides | None = None
316
batch_config_overrides: dict[str, JsonValue] | None = None
317
max_comparisons_override: int | None = Field(None, ge=1)
318
user_id: str | None = None
319
org_id: str | None = None
320
321
322
class CJEessayMetadata(BaseModel):
323
"""Base metadata overlay for essays stored inside CJ processing tables."""
324
325
model_config = ConfigDict(extra="forbid")
326
327
is_anchor: bool | None = None
328
anchor_grade: str | None = None
329
known_grade: str | None = None
330
anchor_ref_id: str | None = None
331
text_storage_id: str | None = None
332
333
334
class CJAnchorMetadata(CJEessayMetadata):
335
"""Typed metadata overlay used for anchor essays."""
336
337
is_anchor: bool = True
338
anchor_label: str | None = None
339
340
341
class EssayToProcess(BaseModel):
342
"""Reference to an essay for CJ processing."""
343
344
els_essay_id: str = Field(description="ELS essay identifier")
345
text_storage_id: str = Field(description="Content storage API storage ID")
346
347
348
class CJAssessmentRequestData(BaseModel):
349
"""Typed request data for CJ assessment workflow.
350
351
This model provides runtime validation for the CJ assessment workflow.
352
"""
353
354
model_config = ConfigDict(extra="forbid")
355
356
# Required fields (always present)
357
bos_batch_id: str = Field(description="BOS batch identifier")
358
assignment_id: str | None = Field(None, description="Assignment identifier")
359
essays_to_process: list[EssayToProcess] = Field(description="Essays to process in this batch")
360
language: str = Field(description="Language code (e.g., 'en', 'sv')")
361
course_code: str = Field(description="Course code enum value")
362
363
# Request context fields
364
cj_source: str | None = Field(
365
default="els",
366
description="Originating system or tool for the CJ request",
367
)
368
cj_request_type: str | None = Field(
369
default="cj_comparison",
370
description="CJ request classification (e.g., cj_comparison, cj_retry)",
371
)
372
373
# Optional prompt context fields
374
student_prompt_text: str | None = Field(
375
None, description="Hydrated student assignment prompt text"
376
)
377
student_prompt_storage_id: str | None = Field(
378
None, description="Content storage API storage ID for student prompt"
379
)
380
source_reference_text: str | None = Field(
381
None, description="Hydrated source-reference text for source-aware assignments"
382
)
383
source_reference_storage_id: str | None = Field(
384
None, description="Content storage API storage ID for source-reference prompt payload"
385
)
386
source_reference_variant: SourcePayloadVariant | None = Field(
387
None, description="Variant label for the projected source-reference payload"
388
)
389
source_reference_file_sha256: str | None = Field(
390
None,
391
min_length=64,
392
max_length=64,
393
description="SHA-256 of raw source-reference artifact file bytes",
394
)
395
source_reference_text_sha256: str | None = Field(
396
None,
397
min_length=64,
398
max_length=64,
399
description="SHA-256 of normalized source-reference prompt text",
400
)
401
judge_rubric_text: str | None = Field(None, description="Hydrated judge rubric text")
402
judge_rubric_storage_id: str | None = Field(
403
None, description="Content storage API storage ID for judge rubric"
404
)
405
406
# Optional configuration overrides
407
llm_config_overrides: LLMConfigOverrides | None = Field(
408
None, description="LLM parameter overrides for this batch"
409
)
410
batch_config_overrides: dict[str, JsonValue] | None = Field(
411
None, description="Batch processing configuration overrides"
412
)
413
pair_matching_strategy: CJPairMatchingStrategyV2 | None = Field(
414
None,
415
description="Typed request-scoped pair-matching strategy selection",
416
)
417
pair_matching_research: bool = Field(
418
False,
419
description="Whether this request explicitly selects the research lane",
420
)
421
max_comparisons_override: int | None = Field(
422
None, ge=1, description="Runner-specified comparison limit"
423
)
424
425
# Optional identity fields (Phase 3: Entitlements)
426
user_id: str | None = Field(None, description="User ID for credit attribution")
427
org_id: str | None = Field(None, description="Organization ID for credit attribution")
428
research_context: ResearchCJBatchContext | None = Field(
429
None,
430
description="Dataset-neutral graph authority for research-only CJ batches",
431
)