Code / services/cj_assessment_service/models_api.py

services/cj_assessment_service/models_api.py

View this file on Codeberg


  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
      )