File size: 12,866 Bytes
0f2ecac
 
1163af8
0539596
0f2ecac
0539596
0f2ecac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413d5a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0539596
 
1163af8
0539596
 
 
 
 
 
 
1163af8
0539596
 
 
 
 
 
 
 
1163af8
0539596
 
 
 
 
 
 
1163af8
0539596
 
 
 
 
 
 
 
1163af8
0539596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e726170
 
 
 
 
 
 
 
0539596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
"""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)