igerasimov's picture
Fix Hugging Face runtime compatibility with Python 3.10
dc54127
Raw
History Blame Contribute Delete
9.98 kB
"""Batch orchestration for the GCMD classifier MVP."""
from __future__ import annotations
import logging
import time
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from gcmd_classifier.config import ModelSettings
from gcmd_classifier.llm.base import ModelClient
from gcmd_classifier.logging_config import get_logger, log_event
from gcmd_classifier.models import (
ArticleClassificationOutcome,
ArticleLoadResult,
ArticleProcessingStatus,
ArticleRecord,
ArticleResult,
ArticleValidationIssue,
OutputError,
RunSummary,
)
from gcmd_classifier.persistence.cache import (
ArticleResultCache,
build_cache_identity,
)
from gcmd_classifier.persistence.json_store import JsonResultStore
from gcmd_classifier.pipeline.service import (
APPLICATION_VERSION,
classify_article,
failed_article_result,
)
from gcmd_classifier.vocabulary.index import VocabularyIndex
ArticleStartCallback = Callable[[int, int, ArticleRecord], None]
ArticleFinishCallback = Callable[[int, int, ArticleResult, float], None]
class BatchRunResult(BaseModel):
"""Structured batch result with article outputs and run summary."""
model_config = ConfigDict(extra="forbid", frozen=True)
results: tuple[ArticleResult, ...] = Field(default_factory=tuple)
summary: RunSummary
def run_batch(
*,
article_load_result: ArticleLoadResult,
vocabulary: VocabularyIndex,
model_client: ModelClient,
settings: ModelSettings,
store: JsonResultStore,
cache: ArticleResultCache | None = None,
force_reprocess: bool = False,
run_id: str | None = None,
application_version: str = APPLICATION_VERSION,
relevant_config: dict[str, Any] | None = None,
logger: logging.Logger | None = None,
on_article_start: ArticleStartCallback | None = None,
on_article_finish: ArticleFinishCallback | None = None,
) -> BatchRunResult:
"""Process valid articles in source order while preserving completed results."""
active_logger = logger or get_logger(__name__)
started = time.perf_counter()
started_at = _now_iso()
actual_run_id = run_id or f"run-{started_at}"
invalid_errors = tuple(_issue_to_error(issue) for issue in article_load_result.errors)
results: list[ArticleResult] = []
cache_hits = 0
cache_misses = 0
log_event(
active_logger,
"batch_started",
stage="batch",
source_records=article_load_result.source_count,
valid_records=len(article_load_result.articles),
invalid_records=_invalid_record_count(article_load_result.errors),
)
total_articles = len(article_load_result.articles)
for index, article in enumerate(article_load_result.articles):
article_started = time.perf_counter()
if on_article_start is not None:
on_article_start(index, total_articles, article)
try:
result: ArticleResult | None = None
identity = build_cache_identity(
article=article,
vocabulary=vocabulary,
settings=settings,
application_version=application_version,
relevant_config=relevant_config,
)
if cache is not None and not force_reprocess:
result = cache.get(identity)
if result is not None:
cache_hits += 1
log_event(
active_logger,
"cache_hit",
DOI=article.DOI,
index=index,
stage="cache",
cache_key=identity.cache_key,
)
else:
cache_misses += 1
log_event(
active_logger,
"cache_miss",
DOI=article.DOI,
index=index,
stage="cache",
cache_key=identity.cache_key,
)
elif cache is not None:
cache_misses += 1
if result is None:
result = classify_article(
article=article,
vocabulary=vocabulary,
model_client=model_client,
settings=settings,
run_id=actual_run_id,
application_version=application_version,
relevant_config=relevant_config,
logger=active_logger,
)
if cache is not None:
cache.put(identity, result)
else:
result = result.model_copy(
update={
"processing_metadata": result.processing_metadata.model_copy(
update={"run_id": actual_run_id}
)
}
)
except Exception as exc:
result = failed_article_result(
article=article,
error=OutputError(
code=exc.__class__.__name__,
message=str(exc),
stage="batch",
DOI=article.DOI,
),
vocabulary=vocabulary,
settings=settings,
run_id=actual_run_id,
application_version=application_version,
relevant_config=relevant_config,
)
log_event(
active_logger,
"article_failed",
DOI=article.DOI,
index=index,
stage="batch",
error_code=exc.__class__.__name__,
)
store.save_article_result(result)
results.append(result)
if on_article_finish is not None:
on_article_finish(index, total_articles, result, time.perf_counter() - article_started)
summary = _run_summary(
run_id=actual_run_id,
started_at=started_at,
completed_at=_now_iso(),
duration_seconds=time.perf_counter() - started,
article_load_result=article_load_result,
results=tuple(results),
invalid_errors=invalid_errors,
cache_hits=cache_hits,
cache_misses=cache_misses,
)
store.write_consolidated(results=tuple(results), summary=summary)
log_event(
active_logger,
"batch_finished",
stage="batch",
run_id=actual_run_id,
processed_articles=len(results),
cache_hits=cache_hits,
cache_misses=cache_misses,
errors=summary.total_errors,
)
return BatchRunResult(results=tuple(results), summary=summary)
def _run_summary(
*,
run_id: str,
started_at: str,
completed_at: str,
duration_seconds: float,
article_load_result: ArticleLoadResult,
results: tuple[ArticleResult, ...],
invalid_errors: tuple[OutputError, ...],
cache_hits: int,
cache_misses: int,
) -> RunSummary:
warnings_count = sum(len(result.warnings) for result in results) + sum(
len(record.warnings) for result in results for record in result.classifications
)
result_errors = tuple(error for result in results for error in result.errors)
errors = (*invalid_errors, *result_errors)
completed = sum(
result.processing_status is ArticleProcessingStatus.COMPLETED for result in results
)
partial = sum(result.processing_status is ArticleProcessingStatus.PARTIAL for result in results)
failed = sum(result.processing_status is ArticleProcessingStatus.FAILED for result in results)
skipped = sum(result.processing_status is ArticleProcessingStatus.SKIPPED for result in results)
classifications = sum(len(result.classifications) for result in results)
processed = len(results)
return RunSummary(
run_id=run_id,
started_at=started_at,
completed_at=completed_at,
articles_received=article_load_result.source_count,
valid_article_records=len(article_load_result.articles),
invalid_source_records=_invalid_record_count(article_load_result.errors),
processed_articles=processed,
cache_hits=cache_hits,
cache_misses=cache_misses,
total_warnings=warnings_count,
total_errors=len(errors),
duration_seconds=duration_seconds,
articles_completed=completed,
articles_partial=partial,
articles_failed=failed,
articles_skipped=skipped,
articles_not_classified=sum(
result.classification_outcome is ArticleClassificationOutcome.NOT_CLASSIFIED
for result in results
),
articles_requiring_review=sum(
any(record.review_required for record in result.classifications) for result in results
),
accepted_classifications=classifications,
average_classifications_per_article=(classifications / processed if processed else None),
average_processing_time_seconds=(duration_seconds / processed if processed else None),
total_model_calls=sum(
result.processing_metadata.model_calls or 0
for result in results
if not result.processing_metadata.cache_used
),
errors=errors,
)
def _issue_to_error(issue: ArticleValidationIssue) -> OutputError:
return OutputError(
code=issue.code,
message=issue.message,
stage="article_loading",
details={"field": issue.field},
index=issue.index,
DOI=issue.DOI,
)
def _invalid_record_count(issues: tuple[ArticleValidationIssue, ...]) -> int:
indices = {issue.index for issue in issues if issue.index is not None}
return len(indices)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat() # noqa: UP017