"""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