"""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): # noqa: UP042 """Uncalibrated model support category for a candidate selection.""" EXPLICIT = "explicit" INFERRED = "inferred" MIXED = "mixed" class ArticleProcessingStatus(str, Enum): # noqa: UP042 """Workflow execution state for one article.""" COMPLETED = "completed" PARTIAL = "partial" FAILED = "failed" SKIPPED = "skipped" class ArticleClassificationOutcome(str, Enum): # noqa: UP042 """Semantic article-level outcome when processing completes.""" CLASSIFIED = "classified" PENDING_REVIEW = "pending_review" NOT_CLASSIFIED = "not_classified" class ClassificationFinalStatus(str, Enum): # noqa: UP042 """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): # noqa: UP042 """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)