| """Shared typed models for the GCMD classifier MVP.""" |
|
|
| from enum import Enum |
| from typing import Any, Literal |
|
|
| from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator |
|
|
| HierarchyLevel = Literal[ |
| "Topic", |
| "Term", |
| "Variable_Level_1", |
| "Variable_Level_2", |
| "Variable_Level_3", |
| ] |
| SourceHierarchyLevel = Literal[ |
| "Category", |
| "Topic", |
| "Term", |
| "Variable_Level_1", |
| "Variable_Level_2", |
| "Variable_Level_3", |
| ] |
|
|
|
|
| class CanonicalConceptRecord(BaseModel): |
| """Canonical UUID-bearing GCMD concept record derived from the hierarchy.""" |
|
|
| model_config = ConfigDict(frozen=True) |
|
|
| UUID: str |
| name: str |
| level: HierarchyLevel |
| topic: str |
| term: str | None = None |
| path_components: tuple[str, ...] |
| canonical_path: str |
| parent_uuid: str | None = None |
| parent_name: str | None = None |
| child_uuids: tuple[str, ...] = Field(default_factory=tuple) |
| has_children: bool |
| assignable: bool |
| definition: str | None = None |
| vocabulary_version: str |
|
|
|
|
| class ArticleRecord(BaseModel): |
| """Validated source article record with exact serialized field names.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True, strict=True) |
|
|
| DOI: str |
| Title: str |
| Year: int |
| Abstract: str |
|
|
|
|
| class ArticleValidationIssue(BaseModel): |
| """Structured validation issue for one article source record.""" |
|
|
| model_config = ConfigDict(frozen=True) |
|
|
| index: int | None |
| code: str |
| message: str |
| field: str | None = None |
| DOI: str | None = None |
|
|
|
|
| class ArticleLoadResult(BaseModel): |
| """Aggregate article loading result preserving valid records and all issues.""" |
|
|
| model_config = ConfigDict(frozen=True) |
|
|
| articles: tuple[ArticleRecord, ...] |
| errors: tuple[ArticleValidationIssue, ...] = Field(default_factory=tuple) |
| source_count: int |
|
|
| @property |
| def valid_count(self) -> int: |
| """Number of records that passed validation and duplicate checks.""" |
| return len(self.articles) |
|
|
| @property |
| def has_errors(self) -> bool: |
| """Whether any source records failed validation.""" |
| return bool(self.errors) |
|
|
|
|
| class SupportType(str, Enum): |
| """Uncalibrated model support category for a candidate selection.""" |
|
|
| EXPLICIT = "explicit" |
| INFERRED = "inferred" |
| MIXED = "mixed" |
|
|
|
|
| class ArticleProcessingStatus(str, Enum): |
| """Workflow execution state for one article.""" |
|
|
| COMPLETED = "completed" |
| PARTIAL = "partial" |
| FAILED = "failed" |
| SKIPPED = "skipped" |
|
|
|
|
| class ArticleClassificationOutcome(str, Enum): |
| """Semantic article-level outcome when processing completes.""" |
|
|
| CLASSIFIED = "classified" |
| PENDING_REVIEW = "pending_review" |
| NOT_CLASSIFIED = "not_classified" |
|
|
|
|
| class ClassificationFinalStatus(str, Enum): |
| """Final automated status for one classification candidate.""" |
|
|
| ACCEPTED = "accepted" |
| REDUCED_TO_ANCESTOR = "reduced_to_ancestor" |
| REVIEW_REQUIRED = "review_required" |
| REJECTED = "rejected" |
|
|
|
|
| class ReviewStatus(str, Enum): |
| """Human-review workflow state.""" |
|
|
| NOT_REQUIRED = "not_required" |
| PENDING = "pending" |
| COMPLETED = "completed" |
|
|
|
|
| class StructuredMessage(BaseModel): |
| """Structured warning or error message for output records.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True) |
|
|
| code: str = Field(min_length=1) |
| message: str = Field(min_length=1) |
| stage: str | None = None |
| details: dict[str, Any] | None = None |
| index: int | None = None |
| DOI: str | None = None |
|
|
|
|
| class OutputWarning(StructuredMessage): |
| """Non-fatal warning associated with an article or classification.""" |
|
|
|
|
| class OutputError(StructuredMessage): |
| """Structured failure associated with an article or classification.""" |
|
|
| retry_count: int | None = Field(default=None, ge=0) |
|
|
|
|
| class DeterministicValidationResult(BaseModel): |
| """Vocabulary validation result for a proposed final classification.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True) |
|
|
| valid: bool |
| errors: tuple[OutputError, ...] = Field(default_factory=tuple) |
| warnings: tuple[OutputWarning, ...] = Field(default_factory=tuple) |
|
|
|
|
| class ConfidenceMetadata(BaseModel): |
| """Optional, uncalibrated confidence signals from routing stages.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True) |
|
|
| topic: float | None = Field(default=None, ge=0.0, le=1.0) |
| term: float | None = Field(default=None, ge=0.0, le=1.0) |
| variable_level_1: float | None = Field(default=None, ge=0.0, le=1.0) |
| variable_level_2: float | None = Field(default=None, ge=0.0, le=1.0) |
| variable_level_3: float | None = Field(default=None, ge=0.0, le=1.0) |
| final: float | None = Field(default=None, ge=0.0, le=1.0) |
|
|
|
|
| class OriginalCandidateReference(BaseModel): |
| """Reference to an earlier candidate retained for reductions or diagnostics.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True) |
|
|
| UUID: str = Field(min_length=1) |
| name: str | None = None |
| level: HierarchyLevel | None = None |
| canonical_path: str = Field(min_length=1) |
| path_components: tuple[str, ...] = Field(default_factory=tuple) |
|
|
|
|
| class SemanticValidationResult(BaseModel): |
| """Draft semantic-validation result shape for future pipeline stages.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True) |
|
|
| decision: str | None = None |
| support_level: str | None = None |
| deepest_supported_uuid: str | None = None |
| deepest_supported_path: str | None = None |
| unsupported_components: tuple[str, ...] = Field(default_factory=tuple) |
| evidence: str | None = None |
| errors: tuple[OutputError, ...] = Field(default_factory=tuple) |
|
|
|
|
| class ClassificationRecord(BaseModel): |
| """Article output record for one GCMD classification or retained diagnostic candidate.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True) |
|
|
| UUID: str = Field(min_length=1) |
| name: str = Field(min_length=1) |
| level: HierarchyLevel |
| canonical_path: str = Field(min_length=1) |
| path_components: tuple[str, ...] = Field(min_length=1) |
| topic: str = Field(min_length=1) |
| term: str | None = None |
| parent_uuid: str | None = None |
| branch_id: str | None = None |
| confidence: ConfidenceMetadata | None = None |
| classifier_evidence: str | None = None |
| support_type: SupportType | None = None |
| reason_for_stopping: str | None = None |
| deterministic_validation: DeterministicValidationResult |
| semantic_validation: SemanticValidationResult | None = None |
| final_status: ClassificationFinalStatus |
| review_required: bool = False |
| review_status: ReviewStatus | None = None |
| original_candidate: OriginalCandidateReference | None = None |
| warnings: tuple[OutputWarning, ...] = Field(default_factory=tuple) |
| errors: tuple[OutputError, ...] = Field(default_factory=tuple) |
|
|
| @model_validator(mode="after") |
| def accepted_results_require_valid_deterministic_validation(self) -> "ClassificationRecord": |
| """Prevent accepted final statuses from carrying failed vocabulary validation.""" |
| final_statuses_requiring_validity = { |
| ClassificationFinalStatus.ACCEPTED, |
| ClassificationFinalStatus.REDUCED_TO_ANCESTOR, |
| } |
| if ( |
| self.final_status in final_statuses_requiring_validity |
| and not self.deterministic_validation.valid |
| ): |
| raise ValueError( |
| "accepted or reduced classifications require deterministic_validation.valid" |
| ) |
| return self |
|
|
|
|
| class ProcessingMetadata(BaseModel): |
| """Reproducibility and runtime metadata for an article result.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True) |
|
|
| run_id: str | None = None |
| processed_at: str | None = None |
| started_at: str | None = None |
| completed_at: str | None = None |
| model_provider: str | None = None |
| model_name: str | None = None |
| model_parameters: dict[str, Any] = Field(default_factory=dict) |
| prompt_versions: dict[str, str] = Field(default_factory=dict) |
| vocabulary_version: str | None = None |
| vocabulary_hash: str | None = None |
| application_version: str | None = None |
| configuration_hash: str | None = None |
| article_fingerprint: str | None = None |
| cache_used: bool = False |
| processing_time_seconds: float | None = Field(default=None, ge=0.0) |
| model_calls: int | None = Field(default=None, ge=0) |
| input_tokens: int | None = Field(default=None, ge=0) |
| output_tokens: int | None = Field(default=None, ge=0) |
| estimated_cost: float | None = Field(default=None, ge=0.0) |
| title_available: bool | None = None |
| abstract_available: bool | None = None |
|
|
|
|
| class ArticleResult(BaseModel): |
| """Structured output for one article, preserving source fields exactly.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True) |
|
|
| DOI: str = Field(strict=True) |
| Title: str = Field(strict=True) |
| Year: int = Field(strict=True) |
| Abstract: str = Field(strict=True) |
| processing_status: ArticleProcessingStatus |
| classification_outcome: ArticleClassificationOutcome | None = None |
| classifications: tuple[ClassificationRecord, ...] = Field(default_factory=tuple) |
| no_classification_reason: str | None = None |
| review_status: ReviewStatus = ReviewStatus.NOT_REQUIRED |
| warnings: tuple[OutputWarning, ...] = Field(default_factory=tuple) |
| errors: tuple[OutputError, ...] = Field(default_factory=tuple) |
| processing_metadata: ProcessingMetadata = Field(default_factory=ProcessingMetadata) |
|
|
| @field_validator("Year") |
| @classmethod |
| def reject_boolean_year(cls, value: int) -> int: |
| """Keep article-output validation consistent with ArticleRecord.""" |
| if isinstance(value, bool): |
| raise ValueError("Year must be a strict integer and not a Boolean.") |
| return value |
|
|
| @model_validator(mode="after") |
| def validate_article_result_consistency(self) -> "ArticleResult": |
| """Apply article-level status-scope consistency rules.""" |
| if self.processing_status is ArticleProcessingStatus.COMPLETED: |
| if self.classification_outcome is None: |
| raise ValueError("completed article results require classification_outcome") |
| if ( |
| self.classification_outcome is ArticleClassificationOutcome.NOT_CLASSIFIED |
| and not self.no_classification_reason |
| ): |
| raise ValueError("not_classified article results require no_classification_reason") |
| if ( |
| self.classification_outcome is ArticleClassificationOutcome.NOT_CLASSIFIED |
| and self.classifications |
| ): |
| raise ValueError("not_classified article results cannot include classifications") |
| if ( |
| self.classification_outcome is ArticleClassificationOutcome.CLASSIFIED |
| and not self.classifications |
| ): |
| raise ValueError("classified article results require at least one classification") |
| return self |
|
|
|
|
| class RunSummary(BaseModel): |
| """Draft aggregate run-summary output schema.""" |
|
|
| model_config = ConfigDict(extra="forbid", frozen=True) |
|
|
| run_id: str = Field(min_length=1) |
| started_at: str | None = None |
| completed_at: str | None = None |
| articles_received: int = Field(ge=0) |
| valid_article_records: int = Field(default=0, ge=0) |
| invalid_source_records: int = Field(default=0, ge=0) |
| processed_articles: int = Field(default=0, ge=0) |
| cache_hits: int = Field(default=0, ge=0) |
| cache_misses: int = Field(default=0, ge=0) |
| total_warnings: int = Field(default=0, ge=0) |
| total_errors: int = Field(default=0, ge=0) |
| duration_seconds: float | None = Field(default=None, ge=0.0) |
| articles_completed: int = Field(default=0, ge=0) |
| articles_partial: int = Field(default=0, ge=0) |
| articles_failed: int = Field(default=0, ge=0) |
| articles_skipped: int = Field(default=0, ge=0) |
| articles_not_classified: int = Field(default=0, ge=0) |
| articles_requiring_review: int = Field(default=0, ge=0) |
| accepted_classifications: int = Field(default=0, ge=0) |
| reduced_classifications: int = Field(default=0, ge=0) |
| rejected_candidates: int = Field(default=0, ge=0) |
| average_classifications_per_article: float | None = Field(default=None, ge=0.0) |
| average_processing_time_seconds: float | None = Field(default=None, ge=0.0) |
| total_model_calls: int | None = Field(default=None, ge=0) |
| input_tokens: int | None = Field(default=None, ge=0) |
| output_tokens: int | None = Field(default=None, ge=0) |
| estimated_total_cost: float | None = Field(default=None, ge=0.0) |
| warnings: tuple[OutputWarning, ...] = Field(default_factory=tuple) |
| errors: tuple[OutputError, ...] = Field(default_factory=tuple) |
|
|