Spaces:
Running on Zero
Running on Zero
Commit ·
e726170
1
Parent(s): 57b6fe2
MVP Milestone 10
Browse files- MVP_PROGRESS.md +94 -0
- schemas/run_summary.schema.json +55 -0
- src/gcmd_classifier/logging_config.py +33 -0
- src/gcmd_classifier/models.py +8 -0
- src/gcmd_classifier/persistence/__init__.py +29 -0
- src/gcmd_classifier/persistence/cache.py +132 -0
- src/gcmd_classifier/persistence/json_store.py +82 -0
- src/gcmd_classifier/pipeline/__init__.py +16 -0
- src/gcmd_classifier/pipeline/batch.py +259 -0
- src/gcmd_classifier/pipeline/service.py +388 -0
- tests/test_cache.py +121 -0
- tests/test_persistence.py +122 -0
- tests/test_pipeline.py +405 -0
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
|