igerasimov commited on
Commit
e726170
·
1 Parent(s): 57b6fe2

MVP Milestone 10

Browse files
MVP_PROGRESS.md CHANGED
@@ -775,3 +775,97 @@ Remaining risks or assumptions:
775
  - Candidate ID provenance is retained as structured warning metadata on converted records rather than adding a new generated output field to `ClassificationRecord` in this milestone.
776
  - Non-assignable concept rejection is implemented, but the current vocabulary fixture contains only assignable UUID-bearing records.
777
  - Redundancy cleanup is collection-level only; integration into final article result assembly remains deferred to later milestones.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
775
  - Candidate ID provenance is retained as structured warning metadata on converted records rather than adding a new generated output field to `ClassificationRecord` in this milestone.
776
  - Non-assignable concept rejection is implemented, but the current vocabulary fixture contains only assignable UUID-bearing records.
777
  - Redundancy cleanup is collection-level only; integration into final article result assembly remains deferred to later milestones.
778
+
779
+ ## Milestone 10: Batch Runner, Incremental Persistence, Caching, and Logging
780
+
781
+ Status: completed
782
+
783
+ Scope completed:
784
+ - Single-article orchestration through Topic routing, Term routing, Variable descent, deterministic validation, and redundancy removal
785
+ - Article-level `ArticleResult` generation for classified, not-classified, partial, and failed outcomes
786
+ - Batch processing over validated article records with continuation after article failures
787
+ - Invalid source article diagnostics without fallback DOI generation
788
+ - Incremental JSON checkpoint persistence after each article result
789
+ - Final consolidated machine-readable JSON output
790
+ - File-backed cache identity and cache reuse
791
+ - Structured logging helpers with secret redaction
792
+ - Run summary counters for valid/invalid records, processed articles, cache hits/misses, warnings, errors, and accepted classifications
793
+
794
+ Single-article pipeline behavior implemented:
795
+ - `classify_article` coordinates existing Milestone 6-9 components.
796
+ - No-topic results become `processing_status: completed`, `classification_outcome: not_classified`, empty classifications, and a no-classification reason.
797
+ - Stop-at-Topic, stop-at-Term, and Variable terminal outcomes are converted only after deterministic validation succeeds.
798
+ - Branch-level Variable descent errors are retained as structured errors while successful sibling terminal outcomes are preserved.
799
+ - Articles with accepted classifications and branch errors become `processing_status: partial` with the accepted classifications retained.
800
+ - Failed articles retain source fields exactly and include structured errors.
801
+
802
+ Batch runner behavior implemented:
803
+ - `run_batch` accepts an `ArticleLoadResult`, processes only valid records in source order, and reports invalid source records in the summary.
804
+ - One article failure does not stop later valid articles.
805
+ - Each article result is saved promptly after completion or failure.
806
+ - Batch output avoids duplicate article files for the same DOI-safe key by overwriting the DOI checkpoint path.
807
+ - The runner supports `force_reprocess` to bypass cache reuse.
808
+
809
+ Persistence strategy selected and implemented:
810
+ - The MVP uses one JSON checkpoint file per DOI-safe SHA-256 key plus an optional consolidated `results.json`.
811
+ - This append/checkpoint-style representation avoids rewriting a single large output document after every article.
812
+ - Individual article checkpoint writes and consolidated output writes use temp-file-plus-atomic-replace.
813
+ - Temporary and destination files are created in the same destination directory, so atomic replace occurs on the same filesystem.
814
+ - No database infrastructure was introduced.
815
+
816
+ Cache identity and reuse behavior implemented:
817
+ - `CacheIdentity` includes DOI, exact article fingerprint, vocabulary version/hash, model provider, model name, model temperature, timeout, max retries, Topic/Term/Variable prompt versions, application version, and relevant configuration hash.
818
+ - Cache keys change when article content, vocabulary version, model settings, prompt versions, application version, or relevant configuration changes.
819
+ - Cache hits return article results with `processing_metadata.cache_used: true`.
820
+ - Cache hits remain completed work and are never marked `skipped`.
821
+ - Force reprocessing bypasses cache reuse.
822
+ - Stale cache entries are not reused when identity inputs differ.
823
+
824
+ Logging and diagnostics behavior implemented:
825
+ - `log_event` records structured events through standard logging.
826
+ - `sanitize_log_details` redacts keys that look like API keys, tokens, authorization headers, secrets, passwords, or sensitive headers.
827
+ - Pipeline and batch events include DOI, stage, branch/cache context, status, classification counts, and error counts when available.
828
+ - Structured `OutputError` and `OutputWarning` models are used for article, branch, validation, redundancy, and invalid-source diagnostics.
829
+
830
+ Run summary behavior implemented:
831
+ - `RunSummary` now includes explicit `valid_article_records`, `invalid_source_records`, `processed_articles`, `cache_hits`, `cache_misses`, `total_warnings`, `total_errors`, and `duration_seconds` fields.
832
+ - `schemas/run_summary.schema.json` was regenerated from the updated Pydantic model.
833
+ - Summary output remains machine-readable and validates against the draft JSON Schema.
834
+
835
+ Tests added:
836
+ - `tests/test_cache.py`
837
+ - `tests/test_persistence.py`
838
+ - `tests/test_pipeline.py`
839
+
840
+ Verification:
841
+ - `python -m ruff check .` passed: `All checks passed!`
842
+ - `python -m ruff format --check .` passed: `46 files already formatted`
843
+ - `python -m pytest` passed: `261 passed in 0.77s`
844
+
845
+ Live model/API status:
846
+ - No live model API calls were made.
847
+ - Unit tests use `FakeModelClient` only.
848
+
849
+ Out of scope, not started:
850
+ - Gradio UI
851
+ - Hugging Face launcher
852
+ - Root `app.py`
853
+ - Live-model smoke tests
854
+ - Independent semantic validation
855
+ - Production deployment infrastructure
856
+
857
+ Source-file status:
858
+ - `data/gcmd_hierarchy.json` unchanged by this milestone.
859
+ - `data/articles.json` unchanged by this milestone.
860
+ - `prototype/app_hf_poc.py` unchanged by this milestone.
861
+
862
+ Deviations from approved plan:
863
+ - `src/gcmd_classifier/errors.py` did not require changes because existing typed exceptions and structured `OutputError` diagnostics were sufficient.
864
+ - `src/gcmd_classifier/config.py` did not require changes because existing model settings already covered the cache identity inputs needed for this milestone.
865
+ - `RunSummary` and `schemas/run_summary.schema.json` were updated to represent required Milestone 10 counters explicitly.
866
+
867
+ Remaining risks or assumptions:
868
+ - The checkpoint store uses DOI-safe SHA-256 filenames, so humans inspect consolidated JSON rather than filename-readable DOI values.
869
+ - Cached failed and partial results can be reused when the cache identity matches exactly; future policy may choose to cache only completed results.
870
+ - Batch processing is sequential for the MVP. Parallel execution remains deferred.
871
+ - Run summary model-call totals are available for fake clients and providers that expose request counts or metadata; richer token/cost aggregation remains provider-dependent.
schemas/run_summary.schema.json CHANGED
@@ -232,6 +232,18 @@
232
  "default": null,
233
  "title": "Average Processing Time Seconds"
234
  },
 
 
 
 
 
 
 
 
 
 
 
 
235
  "completed_at": {
236
  "anyOf": [
237
  {
@@ -244,6 +256,19 @@
244
  "default": null,
245
  "title": "Completed At"
246
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  "errors": {
248
  "items": {
249
  "$ref": "#/$defs/OutputError"
@@ -277,6 +302,12 @@
277
  "default": null,
278
  "title": "Input Tokens"
279
  },
 
 
 
 
 
 
280
  "output_tokens": {
281
  "anyOf": [
282
  {
@@ -290,6 +321,12 @@
290
  "default": null,
291
  "title": "Output Tokens"
292
  },
 
 
 
 
 
 
293
  "reduced_classifications": {
294
  "default": 0,
295
  "minimum": 0,
@@ -319,6 +356,12 @@
319
  "default": null,
320
  "title": "Started At"
321
  },
 
 
 
 
 
 
322
  "total_model_calls": {
323
  "anyOf": [
324
  {
@@ -332,6 +375,18 @@
332
  "default": null,
333
  "title": "Total Model Calls"
334
  },
 
 
 
 
 
 
 
 
 
 
 
 
335
  "warnings": {
336
  "items": {
337
  "$ref": "#/$defs/OutputWarning"
 
232
  "default": null,
233
  "title": "Average Processing Time Seconds"
234
  },
235
+ "cache_hits": {
236
+ "default": 0,
237
+ "minimum": 0,
238
+ "title": "Cache Hits",
239
+ "type": "integer"
240
+ },
241
+ "cache_misses": {
242
+ "default": 0,
243
+ "minimum": 0,
244
+ "title": "Cache Misses",
245
+ "type": "integer"
246
+ },
247
  "completed_at": {
248
  "anyOf": [
249
  {
 
256
  "default": null,
257
  "title": "Completed At"
258
  },
259
+ "duration_seconds": {
260
+ "anyOf": [
261
+ {
262
+ "minimum": 0.0,
263
+ "type": "number"
264
+ },
265
+ {
266
+ "type": "null"
267
+ }
268
+ ],
269
+ "default": null,
270
+ "title": "Duration Seconds"
271
+ },
272
  "errors": {
273
  "items": {
274
  "$ref": "#/$defs/OutputError"
 
302
  "default": null,
303
  "title": "Input Tokens"
304
  },
305
+ "invalid_source_records": {
306
+ "default": 0,
307
+ "minimum": 0,
308
+ "title": "Invalid Source Records",
309
+ "type": "integer"
310
+ },
311
  "output_tokens": {
312
  "anyOf": [
313
  {
 
321
  "default": null,
322
  "title": "Output Tokens"
323
  },
324
+ "processed_articles": {
325
+ "default": 0,
326
+ "minimum": 0,
327
+ "title": "Processed Articles",
328
+ "type": "integer"
329
+ },
330
  "reduced_classifications": {
331
  "default": 0,
332
  "minimum": 0,
 
356
  "default": null,
357
  "title": "Started At"
358
  },
359
+ "total_errors": {
360
+ "default": 0,
361
+ "minimum": 0,
362
+ "title": "Total Errors",
363
+ "type": "integer"
364
+ },
365
  "total_model_calls": {
366
  "anyOf": [
367
  {
 
375
  "default": null,
376
  "title": "Total Model Calls"
377
  },
378
+ "total_warnings": {
379
+ "default": 0,
380
+ "minimum": 0,
381
+ "title": "Total Warnings",
382
+ "type": "integer"
383
+ },
384
+ "valid_article_records": {
385
+ "default": 0,
386
+ "minimum": 0,
387
+ "title": "Valid Article Records",
388
+ "type": "integer"
389
+ },
390
  "warnings": {
391
  "items": {
392
  "$ref": "#/$defs/OutputWarning"
src/gcmd_classifier/logging_config.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured logging helpers for the GCMD classifier MVP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ _SECRET_MARKERS = ("api_key", "apikey", "authorization", "token", "secret", "password", "header")
9
+
10
+
11
+ def get_logger(name: str = "gcmd_classifier") -> logging.Logger:
12
+ """Return a package logger without forcing global logging configuration."""
13
+ return logging.getLogger(name)
14
+
15
+
16
+ def sanitize_log_details(details: dict[str, Any]) -> dict[str, Any]:
17
+ """Remove values whose keys look like secrets or sensitive headers."""
18
+ sanitized: dict[str, Any] = {}
19
+ for key, value in details.items():
20
+ lowered = key.lower()
21
+ if any(marker in lowered for marker in _SECRET_MARKERS):
22
+ sanitized[key] = "[REDACTED]"
23
+ elif isinstance(value, dict):
24
+ sanitized[key] = sanitize_log_details(value)
25
+ else:
26
+ sanitized[key] = value
27
+ return sanitized
28
+
29
+
30
+ def log_event(logger: logging.Logger, event: str, **details: Any) -> None:
31
+ """Emit a structured event through standard logging without secrets."""
32
+ safe_details = sanitize_log_details(details)
33
+ logger.info("gcmd_classifier_event", extra={"event": event, "details": safe_details})
src/gcmd_classifier/models.py CHANGED
@@ -331,6 +331,14 @@ class RunSummary(BaseModel):
331
  started_at: str | None = None
332
  completed_at: str | None = None
333
  articles_received: int = Field(ge=0)
 
 
 
 
 
 
 
 
334
  articles_completed: int = Field(default=0, ge=0)
335
  articles_partial: int = Field(default=0, ge=0)
336
  articles_failed: int = Field(default=0, ge=0)
 
331
  started_at: str | None = None
332
  completed_at: str | None = None
333
  articles_received: int = Field(ge=0)
334
+ valid_article_records: int = Field(default=0, ge=0)
335
+ invalid_source_records: int = Field(default=0, ge=0)
336
+ processed_articles: int = Field(default=0, ge=0)
337
+ cache_hits: int = Field(default=0, ge=0)
338
+ cache_misses: int = Field(default=0, ge=0)
339
+ total_warnings: int = Field(default=0, ge=0)
340
+ total_errors: int = Field(default=0, ge=0)
341
+ duration_seconds: float | None = Field(default=None, ge=0.0)
342
  articles_completed: int = Field(default=0, ge=0)
343
  articles_partial: int = Field(default=0, ge=0)
344
  articles_failed: int = Field(default=0, ge=0)
src/gcmd_classifier/persistence/__init__.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistence and cache helpers for the GCMD classifier MVP."""
2
+
3
+ from gcmd_classifier.persistence.cache import (
4
+ ArticleResultCache,
5
+ CachedArticleResult,
6
+ CacheIdentity,
7
+ article_fingerprint,
8
+ build_cache_identity,
9
+ configuration_hash,
10
+ mark_cache_used,
11
+ )
12
+ from gcmd_classifier.persistence.json_store import (
13
+ JsonResultStore,
14
+ PersistedRunOutput,
15
+ doi_to_key,
16
+ )
17
+
18
+ __all__ = [
19
+ "ArticleResultCache",
20
+ "CacheIdentity",
21
+ "CachedArticleResult",
22
+ "JsonResultStore",
23
+ "PersistedRunOutput",
24
+ "article_fingerprint",
25
+ "build_cache_identity",
26
+ "configuration_hash",
27
+ "doi_to_key",
28
+ "mark_cache_used",
29
+ ]
src/gcmd_classifier/persistence/cache.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cache identity and file-backed result cache for article classifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from gcmd_classifier.config import ModelSettings
14
+ from gcmd_classifier.models import ArticleRecord, ArticleResult
15
+ from gcmd_classifier.vocabulary.index import VocabularyIndex
16
+
17
+
18
+ class CacheIdentity(BaseModel):
19
+ """All inputs that can affect reuse of a cached article result."""
20
+
21
+ model_config = ConfigDict(extra="forbid", frozen=True)
22
+
23
+ DOI: str
24
+ article_fingerprint: str
25
+ vocabulary_version: str
26
+ model_provider: str
27
+ model_name: str
28
+ model_temperature: float
29
+ model_timeout_seconds: float
30
+ model_max_retries: int
31
+ prompt_version_topic: str
32
+ prompt_version_term: str
33
+ prompt_version_variable: str
34
+ application_version: str
35
+ configuration_hash: str
36
+
37
+ @property
38
+ def cache_key(self) -> str:
39
+ """Stable cache key derived from the complete identity."""
40
+ payload = json.dumps(self.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
41
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
42
+
43
+
44
+ class CachedArticleResult(BaseModel):
45
+ """Persisted cache entry containing identity and article result."""
46
+
47
+ model_config = ConfigDict(extra="forbid", frozen=True)
48
+
49
+ cache_key: str = Field(min_length=1)
50
+ identity: CacheIdentity
51
+ result: ArticleResult
52
+
53
+
54
+ class ArticleResultCache:
55
+ """Simple file-backed article result cache keyed by cache identity."""
56
+
57
+ def __init__(self, directory: str | Path) -> None:
58
+ self.directory = Path(directory)
59
+ self.directory.mkdir(parents=True, exist_ok=True)
60
+
61
+ def get(self, identity: CacheIdentity) -> ArticleResult | None:
62
+ """Return a completed cached result for the exact identity, if present."""
63
+ path = self._path(identity.cache_key)
64
+ if not path.exists():
65
+ return None
66
+ entry = CachedArticleResult.model_validate(json.loads(path.read_text()))
67
+ if entry.cache_key != identity.cache_key or entry.identity != identity:
68
+ return None
69
+ return mark_cache_used(entry.result)
70
+
71
+ def put(self, identity: CacheIdentity, result: ArticleResult) -> None:
72
+ """Persist a result for later exact-identity reuse."""
73
+ entry = CachedArticleResult(
74
+ cache_key=identity.cache_key,
75
+ identity=identity,
76
+ result=result,
77
+ )
78
+ _atomic_write_json(self._path(identity.cache_key), entry.model_dump(mode="json"))
79
+
80
+ def _path(self, cache_key: str) -> Path:
81
+ return self.directory / f"{cache_key}.json"
82
+
83
+
84
+ def article_fingerprint(article: ArticleRecord) -> str:
85
+ """Hash exact source article values that influence classification."""
86
+ payload = json.dumps(article.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
87
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
88
+
89
+
90
+ def configuration_hash(config: dict[str, Any] | None = None) -> str:
91
+ """Hash relevant non-secret configuration values."""
92
+ payload = json.dumps({} if config is None else config, sort_keys=True, separators=(",", ":"))
93
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
94
+
95
+
96
+ def build_cache_identity(
97
+ *,
98
+ article: ArticleRecord,
99
+ vocabulary: VocabularyIndex,
100
+ settings: ModelSettings,
101
+ application_version: str,
102
+ relevant_config: dict[str, Any] | None = None,
103
+ ) -> CacheIdentity:
104
+ """Build the complete cache identity for one article classification run."""
105
+ return CacheIdentity(
106
+ DOI=article.DOI,
107
+ article_fingerprint=article_fingerprint(article),
108
+ vocabulary_version=vocabulary.vocabulary_version,
109
+ model_provider=settings.provider,
110
+ model_name=settings.model_name,
111
+ model_temperature=settings.temperature,
112
+ model_timeout_seconds=settings.timeout_seconds,
113
+ model_max_retries=settings.max_retries,
114
+ prompt_version_topic=settings.prompt_version_topic,
115
+ prompt_version_term=settings.prompt_version_term,
116
+ prompt_version_variable=settings.prompt_version_variable,
117
+ application_version=application_version,
118
+ configuration_hash=configuration_hash(relevant_config),
119
+ )
120
+
121
+
122
+ def mark_cache_used(result: ArticleResult) -> ArticleResult:
123
+ """Return a cached copy of a result marked as completed work from cache."""
124
+ metadata = result.processing_metadata.model_copy(update={"cache_used": True})
125
+ return result.model_copy(update={"processing_metadata": metadata})
126
+
127
+
128
+ def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
129
+ path.parent.mkdir(parents=True, exist_ok=True)
130
+ temp_path = path.with_name(f".{path.name}.tmp")
131
+ temp_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
132
+ os.replace(temp_path, path)
src/gcmd_classifier/persistence/json_store.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Incremental JSON persistence for article-level classifier results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from gcmd_classifier.models import ArticleResult, RunSummary
14
+
15
+
16
+ class PersistedRunOutput(BaseModel):
17
+ """Consolidated machine-readable run output."""
18
+
19
+ model_config = ConfigDict(extra="forbid", frozen=True)
20
+
21
+ summary: RunSummary
22
+ articles: tuple[ArticleResult, ...] = Field(default_factory=tuple)
23
+
24
+
25
+ class JsonResultStore:
26
+ """Checkpoint-safe JSON store using one atomic article file per DOI.
27
+
28
+ One-file-per-DOI avoids rewriting a large consolidated document after every article.
29
+ A final JSON file can still be emitted by atomically replacing a temp file in the
30
+ destination directory, so the temp and destination files live on the same filesystem.
31
+ """
32
+
33
+ def __init__(self, directory: str | Path, *, consolidated_name: str = "results.json") -> None:
34
+ self.directory = Path(directory)
35
+ self.articles_dir = self.directory / "articles"
36
+ self.consolidated_path = self.directory / consolidated_name
37
+ self.articles_dir.mkdir(parents=True, exist_ok=True)
38
+
39
+ def article_path(self, doi: str) -> Path:
40
+ """Return the stable checkpoint path for an article DOI."""
41
+ return self.articles_dir / f"{doi_to_key(doi)}.json"
42
+
43
+ def save_article_result(self, result: ArticleResult) -> None:
44
+ """Save one article result with an atomic same-directory replace."""
45
+ _atomic_write_json(self.article_path(result.DOI), result.model_dump(mode="json"))
46
+
47
+ def load_article_result(self, doi: str) -> ArticleResult | None:
48
+ """Load a previously saved article result by DOI."""
49
+ path = self.article_path(doi)
50
+ if not path.exists():
51
+ return None
52
+ return ArticleResult.model_validate(json.loads(path.read_text()))
53
+
54
+ def load_all_results(self) -> tuple[ArticleResult, ...]:
55
+ """Load all checkpointed article results in deterministic filename order."""
56
+ results: list[ArticleResult] = []
57
+ for path in sorted(self.articles_dir.glob("*.json")):
58
+ results.append(ArticleResult.model_validate(json.loads(path.read_text())))
59
+ return tuple(results)
60
+
61
+ def write_consolidated(
62
+ self,
63
+ *,
64
+ results: tuple[ArticleResult, ...] | list[ArticleResult],
65
+ summary: RunSummary,
66
+ ) -> Path:
67
+ """Write a consolidated JSON output file with summary and article results."""
68
+ payload = PersistedRunOutput(summary=summary, articles=tuple(results))
69
+ _atomic_write_json(self.consolidated_path, payload.model_dump(mode="json"))
70
+ return self.consolidated_path
71
+
72
+
73
+ def doi_to_key(doi: str) -> str:
74
+ """Create a filesystem-safe, deterministic key for a DOI."""
75
+ return hashlib.sha256(doi.encode("utf-8")).hexdigest()
76
+
77
+
78
+ def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ temp_path = path.with_name(f".{path.name}.tmp")
81
+ temp_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
82
+ os.replace(temp_path, path)
src/gcmd_classifier/pipeline/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pipeline orchestration for the GCMD classifier MVP."""
2
+
3
+ from gcmd_classifier.pipeline.batch import BatchRunResult, run_batch
4
+ from gcmd_classifier.pipeline.service import (
5
+ APPLICATION_VERSION,
6
+ classify_article,
7
+ failed_article_result,
8
+ )
9
+
10
+ __all__ = [
11
+ "APPLICATION_VERSION",
12
+ "BatchRunResult",
13
+ "classify_article",
14
+ "failed_article_result",
15
+ "run_batch",
16
+ ]
src/gcmd_classifier/pipeline/batch.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Batch orchestration for the GCMD classifier MVP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from gcmd_classifier.config import ModelSettings
13
+ from gcmd_classifier.llm.base import ModelClient
14
+ from gcmd_classifier.logging_config import get_logger, log_event
15
+ from gcmd_classifier.models import (
16
+ ArticleClassificationOutcome,
17
+ ArticleLoadResult,
18
+ ArticleProcessingStatus,
19
+ ArticleResult,
20
+ ArticleValidationIssue,
21
+ OutputError,
22
+ RunSummary,
23
+ )
24
+ from gcmd_classifier.persistence.cache import (
25
+ ArticleResultCache,
26
+ build_cache_identity,
27
+ )
28
+ from gcmd_classifier.persistence.json_store import JsonResultStore
29
+ from gcmd_classifier.pipeline.service import (
30
+ APPLICATION_VERSION,
31
+ classify_article,
32
+ failed_article_result,
33
+ )
34
+ from gcmd_classifier.vocabulary.index import VocabularyIndex
35
+
36
+
37
+ class BatchRunResult(BaseModel):
38
+ """Structured batch result with article outputs and run summary."""
39
+
40
+ model_config = ConfigDict(extra="forbid", frozen=True)
41
+
42
+ results: tuple[ArticleResult, ...] = Field(default_factory=tuple)
43
+ summary: RunSummary
44
+
45
+
46
+ def run_batch(
47
+ *,
48
+ article_load_result: ArticleLoadResult,
49
+ vocabulary: VocabularyIndex,
50
+ model_client: ModelClient,
51
+ settings: ModelSettings,
52
+ store: JsonResultStore,
53
+ cache: ArticleResultCache | None = None,
54
+ force_reprocess: bool = False,
55
+ run_id: str | None = None,
56
+ application_version: str = APPLICATION_VERSION,
57
+ relevant_config: dict[str, Any] | None = None,
58
+ logger: logging.Logger | None = None,
59
+ ) -> BatchRunResult:
60
+ """Process valid articles in source order while preserving completed results."""
61
+ active_logger = logger or get_logger(__name__)
62
+ started = time.perf_counter()
63
+ started_at = _now_iso()
64
+ actual_run_id = run_id or f"run-{started_at}"
65
+ invalid_errors = tuple(_issue_to_error(issue) for issue in article_load_result.errors)
66
+ results: list[ArticleResult] = []
67
+ cache_hits = 0
68
+ cache_misses = 0
69
+
70
+ log_event(
71
+ active_logger,
72
+ "batch_started",
73
+ stage="batch",
74
+ source_records=article_load_result.source_count,
75
+ valid_records=len(article_load_result.articles),
76
+ invalid_records=_invalid_record_count(article_load_result.errors),
77
+ )
78
+
79
+ for index, article in enumerate(article_load_result.articles):
80
+ try:
81
+ result: ArticleResult | None = None
82
+ identity = build_cache_identity(
83
+ article=article,
84
+ vocabulary=vocabulary,
85
+ settings=settings,
86
+ application_version=application_version,
87
+ relevant_config=relevant_config,
88
+ )
89
+ if cache is not None and not force_reprocess:
90
+ result = cache.get(identity)
91
+ if result is not None:
92
+ cache_hits += 1
93
+ log_event(
94
+ active_logger,
95
+ "cache_hit",
96
+ DOI=article.DOI,
97
+ index=index,
98
+ stage="cache",
99
+ cache_key=identity.cache_key,
100
+ )
101
+ else:
102
+ cache_misses += 1
103
+ log_event(
104
+ active_logger,
105
+ "cache_miss",
106
+ DOI=article.DOI,
107
+ index=index,
108
+ stage="cache",
109
+ cache_key=identity.cache_key,
110
+ )
111
+ elif cache is not None:
112
+ cache_misses += 1
113
+
114
+ if result is None:
115
+ result = classify_article(
116
+ article=article,
117
+ vocabulary=vocabulary,
118
+ model_client=model_client,
119
+ settings=settings,
120
+ run_id=actual_run_id,
121
+ application_version=application_version,
122
+ relevant_config=relevant_config,
123
+ logger=active_logger,
124
+ )
125
+ if cache is not None:
126
+ cache.put(identity, result)
127
+ else:
128
+ result = result.model_copy(
129
+ update={
130
+ "processing_metadata": result.processing_metadata.model_copy(
131
+ update={"run_id": actual_run_id}
132
+ )
133
+ }
134
+ )
135
+ except Exception as exc:
136
+ result = failed_article_result(
137
+ article=article,
138
+ error=OutputError(
139
+ code=exc.__class__.__name__,
140
+ message=str(exc),
141
+ stage="batch",
142
+ DOI=article.DOI,
143
+ ),
144
+ vocabulary=vocabulary,
145
+ settings=settings,
146
+ run_id=actual_run_id,
147
+ application_version=application_version,
148
+ relevant_config=relevant_config,
149
+ )
150
+ log_event(
151
+ active_logger,
152
+ "article_failed",
153
+ DOI=article.DOI,
154
+ index=index,
155
+ stage="batch",
156
+ error_code=exc.__class__.__name__,
157
+ )
158
+ store.save_article_result(result)
159
+ results.append(result)
160
+
161
+ summary = _run_summary(
162
+ run_id=actual_run_id,
163
+ started_at=started_at,
164
+ completed_at=_now_iso(),
165
+ duration_seconds=time.perf_counter() - started,
166
+ article_load_result=article_load_result,
167
+ results=tuple(results),
168
+ invalid_errors=invalid_errors,
169
+ cache_hits=cache_hits,
170
+ cache_misses=cache_misses,
171
+ )
172
+ store.write_consolidated(results=tuple(results), summary=summary)
173
+ log_event(
174
+ active_logger,
175
+ "batch_finished",
176
+ stage="batch",
177
+ run_id=actual_run_id,
178
+ processed_articles=len(results),
179
+ cache_hits=cache_hits,
180
+ cache_misses=cache_misses,
181
+ errors=summary.total_errors,
182
+ )
183
+ return BatchRunResult(results=tuple(results), summary=summary)
184
+
185
+
186
+ def _run_summary(
187
+ *,
188
+ run_id: str,
189
+ started_at: str,
190
+ completed_at: str,
191
+ duration_seconds: float,
192
+ article_load_result: ArticleLoadResult,
193
+ results: tuple[ArticleResult, ...],
194
+ invalid_errors: tuple[OutputError, ...],
195
+ cache_hits: int,
196
+ cache_misses: int,
197
+ ) -> RunSummary:
198
+ warnings_count = sum(len(result.warnings) for result in results)
199
+ result_errors = tuple(error for result in results for error in result.errors)
200
+ errors = (*invalid_errors, *result_errors)
201
+ completed = sum(
202
+ result.processing_status is ArticleProcessingStatus.COMPLETED for result in results
203
+ )
204
+ partial = sum(result.processing_status is ArticleProcessingStatus.PARTIAL for result in results)
205
+ failed = sum(result.processing_status is ArticleProcessingStatus.FAILED for result in results)
206
+ skipped = sum(result.processing_status is ArticleProcessingStatus.SKIPPED for result in results)
207
+ classifications = sum(len(result.classifications) for result in results)
208
+ processed = len(results)
209
+ return RunSummary(
210
+ run_id=run_id,
211
+ started_at=started_at,
212
+ completed_at=completed_at,
213
+ articles_received=article_load_result.source_count,
214
+ valid_article_records=len(article_load_result.articles),
215
+ invalid_source_records=_invalid_record_count(article_load_result.errors),
216
+ processed_articles=processed,
217
+ cache_hits=cache_hits,
218
+ cache_misses=cache_misses,
219
+ total_warnings=warnings_count,
220
+ total_errors=len(errors),
221
+ duration_seconds=duration_seconds,
222
+ articles_completed=completed,
223
+ articles_partial=partial,
224
+ articles_failed=failed,
225
+ articles_skipped=skipped,
226
+ articles_not_classified=sum(
227
+ result.classification_outcome is ArticleClassificationOutcome.NOT_CLASSIFIED
228
+ for result in results
229
+ ),
230
+ accepted_classifications=classifications,
231
+ average_classifications_per_article=(classifications / processed if processed else None),
232
+ average_processing_time_seconds=(duration_seconds / processed if processed else None),
233
+ total_model_calls=sum(
234
+ result.processing_metadata.model_calls or 0
235
+ for result in results
236
+ if not result.processing_metadata.cache_used
237
+ ),
238
+ errors=errors,
239
+ )
240
+
241
+
242
+ def _issue_to_error(issue: ArticleValidationIssue) -> OutputError:
243
+ return OutputError(
244
+ code=issue.code,
245
+ message=issue.message,
246
+ stage="article_loading",
247
+ details={"field": issue.field},
248
+ index=issue.index,
249
+ DOI=issue.DOI,
250
+ )
251
+
252
+
253
+ def _invalid_record_count(issues: tuple[ArticleValidationIssue, ...]) -> int:
254
+ indices = {issue.index for issue in issues if issue.index is not None}
255
+ return len(indices)
256
+
257
+
258
+ def _now_iso() -> str:
259
+ return datetime.now(UTC).isoformat()
src/gcmd_classifier/pipeline/service.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-article MVP classification orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+ from gcmd_classifier.classification import (
11
+ ClassificationCandidate,
12
+ TermBranchSeed,
13
+ candidate_from_terminal_outcome,
14
+ candidate_from_topic_stop,
15
+ remove_redundant_classifications,
16
+ route_terms,
17
+ route_topics,
18
+ validate_candidates,
19
+ )
20
+ from gcmd_classifier.config import ModelSettings
21
+ from gcmd_classifier.llm.base import ModelClient
22
+ from gcmd_classifier.logging_config import get_logger, log_event
23
+ from gcmd_classifier.models import (
24
+ ArticleClassificationOutcome,
25
+ ArticleProcessingStatus,
26
+ ArticleRecord,
27
+ ArticleResult,
28
+ ClassificationRecord,
29
+ OutputError,
30
+ OutputWarning,
31
+ ProcessingMetadata,
32
+ ReviewStatus,
33
+ )
34
+ from gcmd_classifier.persistence.cache import article_fingerprint, configuration_hash
35
+ from gcmd_classifier.vocabulary.index import VocabularyIndex
36
+
37
+ APPLICATION_VERSION = "0.1.0"
38
+
39
+
40
+ def classify_article(
41
+ *,
42
+ article: ArticleRecord,
43
+ vocabulary: VocabularyIndex,
44
+ model_client: ModelClient,
45
+ settings: ModelSettings,
46
+ run_id: str | None = None,
47
+ application_version: str = APPLICATION_VERSION,
48
+ relevant_config: dict[str, Any] | None = None,
49
+ logger: logging.Logger | None = None,
50
+ ) -> ArticleResult:
51
+ """Classify one valid article through the current MVP pipeline."""
52
+ active_logger = logger or get_logger(__name__)
53
+ started_at = _now_iso()
54
+ started = time.perf_counter()
55
+ model_calls_before = _model_request_count(model_client)
56
+ log_event(active_logger, "article_started", DOI=article.DOI, stage="pipeline")
57
+
58
+ candidates: list[ClassificationCandidate] = []
59
+ warnings: list[OutputWarning] = []
60
+ errors: list[OutputError] = []
61
+ no_classification_reason: str | None = None
62
+
63
+ try:
64
+ topic_result = route_topics(
65
+ article=article,
66
+ vocabulary=vocabulary,
67
+ model_client=model_client,
68
+ settings=settings,
69
+ )
70
+ if topic_result.is_no_topic:
71
+ no_classification_reason = (
72
+ topic_result.no_selection_reason or "No GCMD Topic was supported by the article."
73
+ )
74
+ for topic_branch in topic_result.branches:
75
+ try:
76
+ term_result = route_terms(
77
+ article=article,
78
+ topic_branch=topic_branch,
79
+ vocabulary=vocabulary,
80
+ model_client=model_client,
81
+ settings=settings,
82
+ )
83
+ except Exception as exc:
84
+ errors.append(_error_from_exception(exc, stage="term_routing", DOI=article.DOI))
85
+ log_event(
86
+ active_logger,
87
+ "branch_failed",
88
+ DOI=article.DOI,
89
+ stage="term_routing",
90
+ branch_id=topic_branch.branch_id,
91
+ error_code=exc.__class__.__name__,
92
+ )
93
+ continue
94
+
95
+ if term_result.stop_at_topic is not None:
96
+ candidates.append(candidate_from_topic_stop(term_result.stop_at_topic))
97
+ for term_branch in term_result.term_branches:
98
+ _process_term_branch(
99
+ article=article,
100
+ term_branch=term_branch,
101
+ vocabulary=vocabulary,
102
+ model_client=model_client,
103
+ settings=settings,
104
+ candidates=candidates,
105
+ warnings=warnings,
106
+ errors=errors,
107
+ logger=active_logger,
108
+ )
109
+ except Exception as exc:
110
+ errors.append(_error_from_exception(exc, stage="topic_routing", DOI=article.DOI))
111
+ log_event(
112
+ active_logger,
113
+ "article_failed",
114
+ DOI=article.DOI,
115
+ stage="topic_routing",
116
+ error_code=exc.__class__.__name__,
117
+ )
118
+
119
+ validation_result = validate_candidates(candidates, vocabulary)
120
+ for rejected in validation_result.rejected:
121
+ errors.extend(rejected.deterministic_validation.errors)
122
+ redundancy_result = remove_redundant_classifications(validation_result.accepted, vocabulary)
123
+ warnings.extend(redundancy_result.warnings)
124
+ classifications = redundancy_result.classifications
125
+
126
+ result = _article_result(
127
+ article=article,
128
+ classifications=classifications,
129
+ warnings=tuple(warnings),
130
+ errors=tuple(errors),
131
+ no_classification_reason=no_classification_reason,
132
+ started_at=started_at,
133
+ duration_seconds=time.perf_counter() - started,
134
+ model_calls=_model_calls_used(model_client, model_calls_before),
135
+ run_id=run_id,
136
+ vocabulary=vocabulary,
137
+ settings=settings,
138
+ application_version=application_version,
139
+ relevant_config=relevant_config,
140
+ )
141
+ log_event(
142
+ active_logger,
143
+ "article_finished",
144
+ DOI=article.DOI,
145
+ stage="pipeline",
146
+ processing_status=result.processing_status.value,
147
+ classification_outcome=None
148
+ if result.classification_outcome is None
149
+ else result.classification_outcome.value,
150
+ classifications=len(result.classifications),
151
+ errors=len(result.errors),
152
+ )
153
+ return result
154
+
155
+
156
+ def _process_term_branch(
157
+ *,
158
+ article: ArticleRecord,
159
+ term_branch: TermBranchSeed,
160
+ vocabulary: VocabularyIndex,
161
+ model_client: ModelClient,
162
+ settings: ModelSettings,
163
+ candidates: list[ClassificationCandidate],
164
+ warnings: list[OutputWarning],
165
+ errors: list[OutputError],
166
+ logger: logging.Logger,
167
+ ) -> None:
168
+ from gcmd_classifier.classification import descend_variables
169
+
170
+ try:
171
+ descent_result = descend_variables(
172
+ article=article,
173
+ term_branch=term_branch,
174
+ vocabulary=vocabulary,
175
+ model_client=model_client,
176
+ settings=settings,
177
+ )
178
+ except Exception as exc:
179
+ errors.append(_error_from_exception(exc, stage="variable_descent", DOI=article.DOI))
180
+ log_event(
181
+ logger,
182
+ "branch_failed",
183
+ DOI=article.DOI,
184
+ stage="variable_descent",
185
+ branch_id=term_branch.branch_id,
186
+ error_code=exc.__class__.__name__,
187
+ )
188
+ return
189
+
190
+ candidates.extend(
191
+ candidate_from_terminal_outcome(terminal) for terminal in descent_result.terminals
192
+ )
193
+ warnings.extend(descent_result.warnings)
194
+ errors.extend(descent_result.diagnostics)
195
+ for branch_error in descent_result.errors:
196
+ errors.append(
197
+ OutputError(
198
+ code=branch_error.code,
199
+ message=branch_error.message,
200
+ stage="variable_descent",
201
+ DOI=article.DOI,
202
+ details={
203
+ "branch_id": branch_error.branch_id,
204
+ "parent_branch_id": branch_error.parent_branch_id,
205
+ "parent_uuid": branch_error.parent_uuid,
206
+ "parent_level": branch_error.parent_level,
207
+ },
208
+ )
209
+ )
210
+
211
+
212
+ def _article_result(
213
+ *,
214
+ article: ArticleRecord,
215
+ classifications: tuple[ClassificationRecord, ...],
216
+ warnings: tuple[OutputWarning, ...],
217
+ errors: tuple[OutputError, ...],
218
+ no_classification_reason: str | None,
219
+ started_at: str,
220
+ duration_seconds: float,
221
+ model_calls: int | None,
222
+ run_id: str | None,
223
+ vocabulary: VocabularyIndex,
224
+ settings: ModelSettings,
225
+ application_version: str,
226
+ relevant_config: dict[str, Any] | None,
227
+ ) -> ArticleResult:
228
+ metadata = _metadata(
229
+ article=article,
230
+ started_at=started_at,
231
+ duration_seconds=duration_seconds,
232
+ model_calls=model_calls,
233
+ run_id=run_id,
234
+ vocabulary=vocabulary,
235
+ settings=settings,
236
+ application_version=application_version,
237
+ relevant_config=relevant_config,
238
+ )
239
+ if classifications:
240
+ status = ArticleProcessingStatus.PARTIAL if errors else ArticleProcessingStatus.COMPLETED
241
+ return ArticleResult(
242
+ DOI=article.DOI,
243
+ Title=article.Title,
244
+ Year=article.Year,
245
+ Abstract=article.Abstract,
246
+ processing_status=status,
247
+ classification_outcome=ArticleClassificationOutcome.CLASSIFIED,
248
+ classifications=classifications,
249
+ review_status=ReviewStatus.NOT_REQUIRED,
250
+ warnings=warnings,
251
+ errors=errors,
252
+ processing_metadata=metadata,
253
+ )
254
+ if errors:
255
+ return ArticleResult(
256
+ DOI=article.DOI,
257
+ Title=article.Title,
258
+ Year=article.Year,
259
+ Abstract=article.Abstract,
260
+ processing_status=ArticleProcessingStatus.FAILED,
261
+ classification_outcome=None,
262
+ classifications=(),
263
+ review_status=ReviewStatus.NOT_REQUIRED,
264
+ warnings=warnings,
265
+ errors=errors,
266
+ processing_metadata=metadata,
267
+ )
268
+ return ArticleResult(
269
+ DOI=article.DOI,
270
+ Title=article.Title,
271
+ Year=article.Year,
272
+ Abstract=article.Abstract,
273
+ processing_status=ArticleProcessingStatus.COMPLETED,
274
+ classification_outcome=ArticleClassificationOutcome.NOT_CLASSIFIED,
275
+ classifications=(),
276
+ no_classification_reason=no_classification_reason
277
+ or "No defensible GCMD classification was supported.",
278
+ review_status=ReviewStatus.NOT_REQUIRED,
279
+ warnings=warnings,
280
+ errors=errors,
281
+ processing_metadata=metadata,
282
+ )
283
+
284
+
285
+ def failed_article_result(
286
+ *,
287
+ article: ArticleRecord,
288
+ error: OutputError,
289
+ vocabulary: VocabularyIndex,
290
+ settings: ModelSettings,
291
+ run_id: str | None = None,
292
+ application_version: str = APPLICATION_VERSION,
293
+ relevant_config: dict[str, Any] | None = None,
294
+ ) -> ArticleResult:
295
+ """Build and return a persisted failed article result for batch-level failures."""
296
+ started_at = _now_iso()
297
+ return ArticleResult(
298
+ DOI=article.DOI,
299
+ Title=article.Title,
300
+ Year=article.Year,
301
+ Abstract=article.Abstract,
302
+ processing_status=ArticleProcessingStatus.FAILED,
303
+ classification_outcome=None,
304
+ classifications=(),
305
+ review_status=ReviewStatus.NOT_REQUIRED,
306
+ errors=(error,),
307
+ processing_metadata=_metadata(
308
+ article=article,
309
+ started_at=started_at,
310
+ duration_seconds=0.0,
311
+ model_calls=None,
312
+ run_id=run_id,
313
+ vocabulary=vocabulary,
314
+ settings=settings,
315
+ application_version=application_version,
316
+ relevant_config=relevant_config,
317
+ ),
318
+ )
319
+
320
+
321
+ def _metadata(
322
+ *,
323
+ article: ArticleRecord,
324
+ started_at: str,
325
+ duration_seconds: float,
326
+ model_calls: int | None,
327
+ run_id: str | None,
328
+ vocabulary: VocabularyIndex,
329
+ settings: ModelSettings,
330
+ application_version: str,
331
+ relevant_config: dict[str, Any] | None,
332
+ ) -> ProcessingMetadata:
333
+ completed_at = _now_iso()
334
+ return ProcessingMetadata(
335
+ run_id=run_id,
336
+ started_at=started_at,
337
+ completed_at=completed_at,
338
+ processed_at=completed_at,
339
+ model_provider=settings.provider,
340
+ model_name=settings.model_name,
341
+ model_parameters={
342
+ "temperature": settings.temperature,
343
+ "timeout_seconds": settings.timeout_seconds,
344
+ "max_retries": settings.max_retries,
345
+ },
346
+ prompt_versions={
347
+ "topic": settings.prompt_version_topic,
348
+ "term": settings.prompt_version_term,
349
+ "variable": settings.prompt_version_variable,
350
+ },
351
+ vocabulary_version=vocabulary.vocabulary_version,
352
+ vocabulary_hash=vocabulary.vocabulary_version,
353
+ application_version=application_version,
354
+ configuration_hash=configuration_hash(relevant_config),
355
+ article_fingerprint=article_fingerprint(article),
356
+ cache_used=False,
357
+ processing_time_seconds=duration_seconds,
358
+ model_calls=model_calls,
359
+ title_available=bool(article.Title),
360
+ abstract_available=bool(article.Abstract),
361
+ )
362
+
363
+
364
+ def _error_from_exception(exc: Exception, *, stage: str, DOI: str | None = None) -> OutputError:
365
+ retry_count = getattr(exc, "retry_count", None)
366
+ return OutputError(
367
+ code=exc.__class__.__name__,
368
+ message=str(exc),
369
+ stage=stage,
370
+ DOI=DOI,
371
+ retry_count=retry_count if isinstance(retry_count, int) else None,
372
+ )
373
+
374
+
375
+ def _model_request_count(model_client: ModelClient) -> int | None:
376
+ requests = getattr(model_client, "requests", None)
377
+ return len(requests) if isinstance(requests, list) else None
378
+
379
+
380
+ def _model_calls_used(model_client: ModelClient, before: int | None) -> int | None:
381
+ after = _model_request_count(model_client)
382
+ if before is None or after is None:
383
+ return None
384
+ return after - before
385
+
386
+
387
+ def _now_iso() -> str:
388
+ return datetime.now(UTC).isoformat()
tests/test_cache.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from gcmd_classifier.config import ModelSettings
6
+ from gcmd_classifier.models import (
7
+ ArticleClassificationOutcome,
8
+ ArticleProcessingStatus,
9
+ ArticleRecord,
10
+ ArticleResult,
11
+ ProcessingMetadata,
12
+ ReviewStatus,
13
+ )
14
+ from gcmd_classifier.persistence import ArticleResultCache, build_cache_identity
15
+ from gcmd_classifier.vocabulary import load_vocabulary
16
+
17
+ FIXTURE_PATH = Path("tests/fixtures/gcmd_hierarchy_small.json")
18
+
19
+
20
+ def _article(**overrides: object) -> ArticleRecord:
21
+ values = {
22
+ "DOI": "10.example/cache",
23
+ "Title": "Carbon dioxide observations",
24
+ "Year": 2025,
25
+ "Abstract": "Atmospheric carbon dioxide profiles are discussed.",
26
+ }
27
+ values.update(overrides)
28
+ return ArticleRecord.model_validate(values)
29
+
30
+
31
+ def _result(article: ArticleRecord) -> ArticleResult:
32
+ return ArticleResult(
33
+ DOI=article.DOI,
34
+ Title=article.Title,
35
+ Year=article.Year,
36
+ Abstract=article.Abstract,
37
+ processing_status=ArticleProcessingStatus.COMPLETED,
38
+ classification_outcome=ArticleClassificationOutcome.NOT_CLASSIFIED,
39
+ classifications=(),
40
+ no_classification_reason="No Topic selected.",
41
+ review_status=ReviewStatus.NOT_REQUIRED,
42
+ processing_metadata=ProcessingMetadata(cache_used=False),
43
+ )
44
+
45
+
46
+ def _identity(article: ArticleRecord, settings: ModelSettings | None = None, **kwargs):
47
+ return build_cache_identity(
48
+ article=article,
49
+ vocabulary=load_vocabulary(FIXTURE_PATH),
50
+ settings=settings or ModelSettings(),
51
+ application_version=kwargs.pop("application_version", "0.1.0"),
52
+ relevant_config=kwargs.pop("relevant_config", None),
53
+ )
54
+
55
+
56
+ def test_cache_key_includes_article_fingerprint() -> None:
57
+ original = _identity(_article())
58
+ changed = _identity(_article(Title="Changed title"))
59
+
60
+ assert original.article_fingerprint != changed.article_fingerprint
61
+ assert original.cache_key != changed.cache_key
62
+
63
+
64
+ def test_cache_key_changes_when_article_content_changes() -> None:
65
+ assert (
66
+ _identity(_article(Abstract="A")).cache_key != _identity(_article(Abstract="B")).cache_key
67
+ )
68
+
69
+
70
+ def test_cache_key_changes_when_vocabulary_hash_changes() -> None:
71
+ identity = _identity(_article())
72
+ changed = identity.model_copy(update={"vocabulary_version": "different-vocab"})
73
+
74
+ assert identity.cache_key != changed.cache_key
75
+
76
+
77
+ def test_cache_key_changes_when_model_provider_name_or_parameters_change() -> None:
78
+ default = _identity(_article())
79
+ provider = _identity(_article(), ModelSettings(provider="other"))
80
+ name = _identity(_article(), ModelSettings(model_name="other-model"))
81
+ temperature = _identity(_article(), ModelSettings(temperature=0.2))
82
+
83
+ assert len({default.cache_key, provider.cache_key, name.cache_key, temperature.cache_key}) == 4
84
+
85
+
86
+ def test_cache_key_changes_when_prompt_versions_change() -> None:
87
+ default = _identity(_article())
88
+ changed = _identity(_article(), ModelSettings(prompt_version_topic="topic-v2"))
89
+
90
+ assert default.cache_key != changed.cache_key
91
+
92
+
93
+ def test_cache_key_changes_when_relevant_config_changes() -> None:
94
+ default = _identity(_article(), relevant_config={"candidate_limit": 100})
95
+ changed = _identity(_article(), relevant_config={"candidate_limit": 200})
96
+
97
+ assert default.cache_key != changed.cache_key
98
+
99
+
100
+ def test_cache_hit_returns_cache_used_true_and_completed_status(tmp_path: Path) -> None:
101
+ article = _article()
102
+ identity = _identity(article)
103
+ cache = ArticleResultCache(tmp_path)
104
+ cache.put(identity, _result(article))
105
+
106
+ cached = cache.get(identity)
107
+
108
+ assert cached is not None
109
+ assert cached.processing_metadata.cache_used is True
110
+ assert cached.processing_status is ArticleProcessingStatus.COMPLETED
111
+ assert cached.processing_status is not ArticleProcessingStatus.SKIPPED
112
+
113
+
114
+ def test_stale_cache_is_not_reused(tmp_path: Path) -> None:
115
+ article = _article()
116
+ identity = _identity(article)
117
+ stale_identity = _identity(_article(Title="Changed title"))
118
+ cache = ArticleResultCache(tmp_path)
119
+ cache.put(identity, _result(article))
120
+
121
+ assert cache.get(stale_identity) is None
tests/test_persistence.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from gcmd_classifier.models import (
9
+ ArticleClassificationOutcome,
10
+ ArticleProcessingStatus,
11
+ ArticleResult,
12
+ OutputError,
13
+ ProcessingMetadata,
14
+ ReviewStatus,
15
+ RunSummary,
16
+ )
17
+ from gcmd_classifier.persistence import JsonResultStore
18
+
19
+
20
+ def _result(doi: str, *, failed: bool = False) -> ArticleResult:
21
+ if failed:
22
+ return ArticleResult(
23
+ DOI=doi,
24
+ Title="Failed article",
25
+ Year=2025,
26
+ Abstract="Text.",
27
+ processing_status=ArticleProcessingStatus.FAILED,
28
+ classification_outcome=None,
29
+ classifications=(),
30
+ review_status=ReviewStatus.NOT_REQUIRED,
31
+ errors=(OutputError(code="MODEL_FAILED", message="Model failed."),),
32
+ processing_metadata=ProcessingMetadata(cache_used=False),
33
+ )
34
+ return ArticleResult(
35
+ DOI=doi,
36
+ Title="Completed article",
37
+ Year=2025,
38
+ Abstract="Text.",
39
+ processing_status=ArticleProcessingStatus.COMPLETED,
40
+ classification_outcome=ArticleClassificationOutcome.NOT_CLASSIFIED,
41
+ classifications=(),
42
+ no_classification_reason="No Topic selected.",
43
+ review_status=ReviewStatus.NOT_REQUIRED,
44
+ processing_metadata=ProcessingMetadata(cache_used=False),
45
+ )
46
+
47
+
48
+ def test_article_result_is_saved_after_completion(tmp_path: Path) -> None:
49
+ store = JsonResultStore(tmp_path)
50
+ result = _result("10.example/done")
51
+
52
+ store.save_article_result(result)
53
+
54
+ loaded = store.load_article_result("10.example/done")
55
+ assert loaded == result
56
+
57
+
58
+ def test_failed_article_result_is_saved(tmp_path: Path) -> None:
59
+ store = JsonResultStore(tmp_path)
60
+ result = _result("10.example/failed", failed=True)
61
+
62
+ store.save_article_result(result)
63
+
64
+ assert store.load_article_result("10.example/failed") == result
65
+
66
+
67
+ def test_previous_results_survive_simulated_later_failure(tmp_path: Path) -> None:
68
+ store = JsonResultStore(tmp_path)
69
+ first = _result("10.example/first")
70
+ store.save_article_result(first)
71
+
72
+ with pytest.raises(RuntimeError):
73
+ raise RuntimeError("later article failed before save")
74
+
75
+ assert store.load_article_result("10.example/first") == first
76
+
77
+
78
+ def test_loading_existing_results_works(tmp_path: Path) -> None:
79
+ store = JsonResultStore(tmp_path)
80
+ first = _result("10.example/first")
81
+ second = _result("10.example/second", failed=True)
82
+ store.save_article_result(first)
83
+ store.save_article_result(second)
84
+
85
+ loaded = store.load_all_results()
86
+
87
+ assert {result.DOI for result in loaded} == {"10.example/first", "10.example/second"}
88
+
89
+
90
+ def test_duplicate_output_records_for_same_doi_are_avoided(tmp_path: Path) -> None:
91
+ store = JsonResultStore(tmp_path)
92
+ first = _result("10.example/same")
93
+ second = _result("10.example/same", failed=True)
94
+
95
+ store.save_article_result(first)
96
+ store.save_article_result(second)
97
+
98
+ loaded = store.load_all_results()
99
+ assert len(loaded) == 1
100
+ assert loaded[0].processing_status is ArticleProcessingStatus.FAILED
101
+
102
+
103
+ def test_consolidated_output_is_valid_json(tmp_path: Path) -> None:
104
+ store = JsonResultStore(tmp_path)
105
+ result = _result("10.example/done")
106
+ summary = RunSummary(run_id="run-1", articles_received=1, articles_completed=1)
107
+
108
+ output_path = store.write_consolidated(results=[result], summary=summary)
109
+
110
+ payload = json.loads(output_path.read_text())
111
+ assert payload["summary"]["run_id"] == "run-1"
112
+ assert payload["articles"][0]["DOI"] == "10.example/done"
113
+
114
+
115
+ def test_atomic_checkpoint_temp_file_is_removed_after_success(tmp_path: Path) -> None:
116
+ store = JsonResultStore(tmp_path)
117
+ result = _result("10.example/done")
118
+
119
+ store.save_article_result(result)
120
+
121
+ assert store.article_path(result.DOI).exists()
122
+ assert not list(store.article_path(result.DOI).parent.glob("*.tmp"))
tests/test_pipeline.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from jsonschema import Draft202012Validator
8
+
9
+ from gcmd_classifier.articles import validate_article_records
10
+ from gcmd_classifier.config import ModelSettings
11
+ from gcmd_classifier.llm import FakeModelClient
12
+ from gcmd_classifier.logging_config import sanitize_log_details
13
+ from gcmd_classifier.models import (
14
+ ArticleClassificationOutcome,
15
+ ArticleProcessingStatus,
16
+ ArticleRecord,
17
+ )
18
+ from gcmd_classifier.persistence import ArticleResultCache, JsonResultStore
19
+ from gcmd_classifier.pipeline import classify_article, run_batch
20
+ from gcmd_classifier.vocabulary import load_vocabulary
21
+
22
+ FIXTURE_PATH = Path("tests/fixtures/gcmd_hierarchy_small.json")
23
+ FULL_ARTICLES_PATH = Path("data/articles.json")
24
+ FULL_HIERARCHY_PATH = Path("data/gcmd_hierarchy.json")
25
+ RUN_SUMMARY_SCHEMA_PATH = Path("schemas/run_summary.schema.json")
26
+
27
+
28
+ def _index():
29
+ return load_vocabulary(FIXTURE_PATH)
30
+
31
+
32
+ def _article(abstract: str = "Atmospheric carbon dioxide profiles are discussed.") -> ArticleRecord:
33
+ return ArticleRecord(
34
+ DOI="10.example/pipeline",
35
+ Title="Atmospheric carbon dioxide observations",
36
+ Year=2025,
37
+ Abstract=abstract,
38
+ )
39
+
40
+
41
+ def _decision(candidate_id: str) -> dict:
42
+ return {
43
+ "candidate_id": candidate_id,
44
+ "confidence": 0.8,
45
+ "evidence": f"Evidence for {candidate_id}.",
46
+ "support_type": "explicit",
47
+ "reason": f"Reason for {candidate_id}.",
48
+ }
49
+
50
+
51
+ def _no_topic(reason: str = "No supported Topic.") -> dict:
52
+ return {"selected": [], "no_selection_reason": reason}
53
+
54
+
55
+ def _select_topic(candidate_id: str = "topic_0001") -> dict:
56
+ return {"selected": [_decision(candidate_id)]}
57
+
58
+
59
+ def _select_term(candidate_id: str = "term_0001") -> dict:
60
+ return {"selected": [_decision(candidate_id)], "stop_at_parent": False}
61
+
62
+
63
+ def _select_variable(*candidate_ids: str) -> dict:
64
+ return {
65
+ "selected": [_decision(candidate_id) for candidate_id in candidate_ids],
66
+ "stop_at_parent": False,
67
+ }
68
+
69
+
70
+ def _stop(reason: str) -> dict:
71
+ return {"selected": [], "stop_at_parent": True, "stop_reason": reason}
72
+
73
+
74
+ def test_single_article_deep_variable_classification_is_accepted() -> None:
75
+ client = FakeModelClient(
76
+ [
77
+ _select_topic(),
78
+ _select_term(),
79
+ _select_variable("variable_0001"),
80
+ _select_variable("variable_0001"),
81
+ _select_variable("variable_0001"),
82
+ ]
83
+ )
84
+
85
+ result = classify_article(
86
+ article=_article(),
87
+ vocabulary=_index(),
88
+ model_client=client,
89
+ settings=ModelSettings(),
90
+ )
91
+
92
+ assert result.processing_status is ArticleProcessingStatus.COMPLETED
93
+ assert result.classification_outcome is ArticleClassificationOutcome.CLASSIFIED
94
+ assert [record.UUID for record in result.classifications] == ["vl3-carbon-dioxide-profiles"]
95
+ assert result.classifications[0].deterministic_validation.valid is True
96
+ assert result.processing_metadata.model_calls == 5
97
+
98
+
99
+ def test_article_stops_at_topic_and_becomes_topic_classification() -> None:
100
+ client = FakeModelClient([_select_topic(), _stop("Topic is the deepest supported level.")])
101
+
102
+ result = classify_article(
103
+ article=_article(),
104
+ vocabulary=_index(),
105
+ model_client=client,
106
+ settings=ModelSettings(),
107
+ )
108
+
109
+ assert result.classifications[0].UUID == "topic-atmosphere"
110
+ assert result.classifications[0].level == "Topic"
111
+ assert result.classifications[0].reason_for_stopping == "Topic is the deepest supported level."
112
+
113
+
114
+ def test_article_stops_at_term_and_becomes_term_classification() -> None:
115
+ client = FakeModelClient(
116
+ [_select_topic(), _select_term(), _stop("Term is the deepest supported level.")]
117
+ )
118
+
119
+ result = classify_article(
120
+ article=_article(),
121
+ vocabulary=_index(),
122
+ model_client=client,
123
+ settings=ModelSettings(),
124
+ )
125
+
126
+ assert result.classifications[0].UUID == "term-atmospheric-chemistry"
127
+ assert result.classifications[0].level == "Term"
128
+ assert result.classifications[0].reason_for_stopping == "Term is the deepest supported level."
129
+
130
+
131
+ def test_no_topic_selection_is_completed_not_classified() -> None:
132
+ result = classify_article(
133
+ article=_article(),
134
+ vocabulary=_index(),
135
+ model_client=FakeModelClient([_no_topic("Not Earth science.")]),
136
+ settings=ModelSettings(),
137
+ )
138
+
139
+ assert result.processing_status is ArticleProcessingStatus.COMPLETED
140
+ assert result.classification_outcome is ArticleClassificationOutcome.NOT_CLASSIFIED
141
+ assert result.classifications == ()
142
+ assert result.no_classification_reason == "Not Earth science."
143
+
144
+
145
+ def test_invalid_model_candidate_produces_structured_error() -> None:
146
+ result = classify_article(
147
+ article=_article(),
148
+ vocabulary=_index(),
149
+ model_client=FakeModelClient([_select_topic("missing_topic")]),
150
+ settings=ModelSettings(),
151
+ )
152
+
153
+ assert result.processing_status is ArticleProcessingStatus.FAILED
154
+ assert result.classifications == ()
155
+ assert result.errors[0].stage == "topic_routing"
156
+ assert result.errors[0].code == "UnknownCandidateIDError"
157
+
158
+
159
+ def test_partial_branch_failure_preserves_successful_sibling_outcome() -> None:
160
+ client = FakeModelClient(
161
+ [
162
+ _select_topic(),
163
+ _select_term(),
164
+ _select_variable("variable_0001"),
165
+ _select_variable("variable_0001", "variable_0002"),
166
+ _select_variable("missing_variable"),
167
+ ]
168
+ )
169
+
170
+ result = classify_article(
171
+ article=_article(),
172
+ vocabulary=_index(),
173
+ model_client=client,
174
+ settings=ModelSettings(),
175
+ )
176
+
177
+ assert result.processing_status is ArticleProcessingStatus.PARTIAL
178
+ assert result.classification_outcome is ArticleClassificationOutcome.CLASSIFIED
179
+ assert [record.UUID for record in result.classifications] == ["vl2-methane"]
180
+ assert result.errors
181
+ assert result.errors[0].stage == "variable_descent"
182
+
183
+
184
+ def test_empty_abstract_remains_valid_and_can_be_processed() -> None:
185
+ result = classify_article(
186
+ article=_article(abstract=""),
187
+ vocabulary=_index(),
188
+ model_client=FakeModelClient([_no_topic("Title only is insufficient.")]),
189
+ settings=ModelSettings(),
190
+ )
191
+
192
+ assert result.Abstract == ""
193
+ assert result.processing_metadata.abstract_available is False
194
+ assert result.processing_status is ArticleProcessingStatus.COMPLETED
195
+
196
+
197
+ def test_batch_processes_multiple_valid_articles_and_preserves_order(tmp_path: Path) -> None:
198
+ load_result = validate_article_records(
199
+ [
200
+ {"DOI": "10.example/one", "Title": "One", "Year": 2025, "Abstract": ""},
201
+ {"DOI": "10.example/two", "Title": "Two", "Year": 2025, "Abstract": ""},
202
+ ]
203
+ )
204
+ client = FakeModelClient([_no_topic("No Topic one."), _no_topic("No Topic two.")])
205
+
206
+ batch = run_batch(
207
+ article_load_result=load_result,
208
+ vocabulary=_index(),
209
+ model_client=client,
210
+ settings=ModelSettings(),
211
+ store=JsonResultStore(tmp_path / "results"),
212
+ )
213
+
214
+ assert [result.DOI for result in batch.results] == ["10.example/one", "10.example/two"]
215
+ assert batch.summary.processed_articles == 2
216
+ assert batch.summary.articles_completed == 2
217
+ assert batch.summary.articles_not_classified == 2
218
+
219
+
220
+ def test_batch_continues_after_one_article_failure(tmp_path: Path) -> None:
221
+ load_result = validate_article_records(
222
+ [
223
+ {"DOI": "10.example/one", "Title": "One", "Year": 2025, "Abstract": ""},
224
+ {"DOI": "10.example/two", "Title": "Two", "Year": 2025, "Abstract": ""},
225
+ {"DOI": "10.example/three", "Title": "Three", "Year": 2025, "Abstract": ""},
226
+ ]
227
+ )
228
+ client = FakeModelClient([_no_topic(), _select_topic("missing_topic"), _no_topic()])
229
+
230
+ batch = run_batch(
231
+ article_load_result=load_result,
232
+ vocabulary=_index(),
233
+ model_client=client,
234
+ settings=ModelSettings(),
235
+ store=JsonResultStore(tmp_path / "results"),
236
+ )
237
+
238
+ assert [result.DOI for result in batch.results] == [
239
+ "10.example/one",
240
+ "10.example/two",
241
+ "10.example/three",
242
+ ]
243
+ assert batch.summary.articles_failed == 1
244
+ assert batch.summary.articles_completed == 2
245
+ assert batch.results[1].errors[0].code == "UnknownCandidateIDError"
246
+
247
+
248
+ def test_batch_reports_invalid_source_records_without_inventing_doi(tmp_path: Path) -> None:
249
+ load_result = validate_article_records(
250
+ [
251
+ {"DOI": "10.example/valid", "Title": "Valid", "Year": 2025, "Abstract": ""},
252
+ {"DOI": "", "Title": "Invalid", "Year": 2025, "Abstract": ""},
253
+ ]
254
+ )
255
+
256
+ batch = run_batch(
257
+ article_load_result=load_result,
258
+ vocabulary=_index(),
259
+ model_client=FakeModelClient([_no_topic()]),
260
+ settings=ModelSettings(),
261
+ store=JsonResultStore(tmp_path / "results"),
262
+ )
263
+
264
+ assert batch.summary.articles_received == 2
265
+ assert batch.summary.valid_article_records == 1
266
+ assert batch.summary.invalid_source_records == 1
267
+ assert batch.summary.errors[0].DOI == ""
268
+ assert [result.DOI for result in batch.results] == ["10.example/valid"]
269
+
270
+
271
+ def test_batch_cache_hit_is_completed_not_skipped_and_force_reprocess_bypasses_cache(
272
+ tmp_path: Path,
273
+ ) -> None:
274
+ load_result = validate_article_records(
275
+ [{"DOI": "10.example/cache", "Title": "Cache", "Year": 2025, "Abstract": ""}]
276
+ )
277
+ cache = ArticleResultCache(tmp_path / "cache")
278
+ store = JsonResultStore(tmp_path / "results")
279
+
280
+ first = run_batch(
281
+ article_load_result=load_result,
282
+ vocabulary=_index(),
283
+ model_client=FakeModelClient([_no_topic("First run.")]),
284
+ settings=ModelSettings(),
285
+ store=store,
286
+ cache=cache,
287
+ )
288
+ second = run_batch(
289
+ article_load_result=load_result,
290
+ vocabulary=_index(),
291
+ model_client=FakeModelClient([]),
292
+ settings=ModelSettings(),
293
+ store=store,
294
+ cache=cache,
295
+ )
296
+ forced = run_batch(
297
+ article_load_result=load_result,
298
+ vocabulary=_index(),
299
+ model_client=FakeModelClient([_no_topic("Forced run.")]),
300
+ settings=ModelSettings(),
301
+ store=store,
302
+ cache=cache,
303
+ force_reprocess=True,
304
+ )
305
+
306
+ assert first.summary.cache_misses == 1
307
+ assert second.summary.cache_hits == 1
308
+ assert second.results[0].processing_metadata.cache_used is True
309
+ assert second.results[0].processing_status is ArticleProcessingStatus.COMPLETED
310
+ assert forced.summary.cache_hits == 0
311
+ assert forced.summary.cache_misses == 1
312
+ assert forced.results[0].no_classification_reason == "Forced run."
313
+
314
+
315
+ def test_batch_output_summary_validates_against_schema(tmp_path: Path) -> None:
316
+ load_result = validate_article_records(
317
+ [{"DOI": "10.example/schema", "Title": "Schema", "Year": 2025, "Abstract": ""}]
318
+ )
319
+ batch = run_batch(
320
+ article_load_result=load_result,
321
+ vocabulary=_index(),
322
+ model_client=FakeModelClient([_no_topic()]),
323
+ settings=ModelSettings(),
324
+ store=JsonResultStore(tmp_path / "results"),
325
+ )
326
+
327
+ schema = json.loads(RUN_SUMMARY_SCHEMA_PATH.read_text())
328
+ Draft202012Validator(schema).validate(batch.summary.model_dump(mode="json"))
329
+
330
+
331
+ def test_structured_diagnostics_do_not_include_secrets() -> None:
332
+ details = sanitize_log_details(
333
+ {
334
+ "DOI": "10.example/redacted",
335
+ "api_key": "raw-secret-value",
336
+ "nested": {"token": "raw-secret-value"},
337
+ }
338
+ )
339
+
340
+ assert details["api_key"] == "[REDACTED]"
341
+ assert details["nested"]["token"] == "[REDACTED]"
342
+ assert "raw-secret-value" not in json.dumps(details)
343
+
344
+
345
+ def test_model_retry_counts_and_cache_flags_appear_in_metadata(tmp_path: Path) -> None:
346
+ load_result = validate_article_records(
347
+ [{"DOI": "10.example/meta", "Title": "Meta", "Year": 2025, "Abstract": ""}]
348
+ )
349
+ batch = run_batch(
350
+ article_load_result=load_result,
351
+ vocabulary=_index(),
352
+ model_client=FakeModelClient([_no_topic()]),
353
+ settings=ModelSettings(max_retries=3),
354
+ store=JsonResultStore(tmp_path / "results"),
355
+ )
356
+
357
+ metadata = batch.results[0].processing_metadata
358
+ assert metadata.model_parameters["max_retries"] == 3
359
+ assert metadata.cache_used is False
360
+ assert metadata.model_calls == 1
361
+
362
+
363
+ def test_full_data_smoke_reports_current_invalid_article_without_modifying_sources(
364
+ tmp_path: Path,
365
+ ) -> None:
366
+ hierarchy_before = FULL_HIERARCHY_PATH.read_bytes()
367
+ articles_before = FULL_ARTICLES_PATH.read_bytes()
368
+ raw_articles = json.loads(articles_before)
369
+ load_result = validate_article_records(raw_articles)
370
+ subset_load_result = load_result.model_copy(update={"articles": load_result.articles[:1]})
371
+
372
+ batch = run_batch(
373
+ article_load_result=subset_load_result,
374
+ vocabulary=load_vocabulary(FULL_HIERARCHY_PATH),
375
+ model_client=FakeModelClient([_no_topic()]),
376
+ settings=ModelSettings(),
377
+ store=JsonResultStore(tmp_path / "results"),
378
+ )
379
+
380
+ assert load_result.source_count == 468
381
+ assert len(load_result.articles) == 467
382
+ assert len({article.DOI for article in load_result.articles}) == 467
383
+ assert [error.index for error in load_result.errors] == [467]
384
+ assert load_result.errors[0].code == "EMPTY_REQUIRED_STRING"
385
+ assert batch.summary.processed_articles == 1
386
+ assert FULL_HIERARCHY_PATH.read_bytes() == hierarchy_before
387
+ assert FULL_ARTICLES_PATH.read_bytes() == articles_before
388
+
389
+
390
+ def test_logging_records_processing_events_without_secrets(caplog) -> None:
391
+ logger = logging.getLogger("gcmd_classifier.tests.pipeline")
392
+ caplog.set_level(logging.INFO, logger=logger.name)
393
+
394
+ classify_article(
395
+ article=_article(),
396
+ vocabulary=_index(),
397
+ model_client=FakeModelClient([_no_topic()]),
398
+ settings=ModelSettings(),
399
+ logger=logger,
400
+ relevant_config={"api_key": "should-not-log"},
401
+ )
402
+
403
+ assert any(record.__dict__.get("event") == "article_started" for record in caplog.records)
404
+ serialized = "\n".join(str(record.__dict__) for record in caplog.records)
405
+ assert "should-not-log" not in serialized