| |
| """Run a small manual MVP classification batch. |
| |
| Examples: |
| python scripts/run_small_classification.py --fake |
| python scripts/run_small_classification.py --provider openai --model your-model |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import csv |
| import os |
| import sys |
| from pathlib import Path |
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| SRC_PATH = PROJECT_ROOT / "src" |
| if SRC_PATH.exists() and str(SRC_PATH) not in sys.path: |
| sys.path.insert(0, str(SRC_PATH)) |
|
|
| from gcmd_classifier.articles import load_articles |
| from gcmd_classifier.config import ModelSettings |
| from gcmd_classifier.llm import FakeModelClient |
| from gcmd_classifier.llm.base import ModelClient |
| from gcmd_classifier.llm.openai_provider import OpenAIModelClient |
| from gcmd_classifier.models import ArticleLoadResult, ArticleRecord, ArticleResult |
| from gcmd_classifier.persistence import ArticleResultCache, JsonResultStore |
| from gcmd_classifier.pipeline import run_batch |
| from gcmd_classifier.vocabulary import VocabularyIndex, load_vocabulary |
|
|
| DEFAULT_HIERARCHY_PATH = PROJECT_ROOT / "data" / "gcmd_hierarchy.json" |
| DEFAULT_ARTICLES_PATH = PROJECT_ROOT / "data" / "articles.json" |
| DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "outputs" / "small_run" |
|
|
|
|
| def parse_args(argv: list[str] | None = None) -> argparse.Namespace: |
| """Parse command-line arguments for the small manual run.""" |
| parser = argparse.ArgumentParser(description="Run a small GCMD classifier MVP batch.") |
| parser.add_argument("--articles", type=Path, default=DEFAULT_ARTICLES_PATH) |
| parser.add_argument("--limit", type=int, default=10) |
| parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) |
| parser.add_argument("--provider", type=str, default=None) |
| parser.add_argument("--model", type=str, default=None) |
| parser.add_argument("--fake", action="store_true") |
| return parser.parse_args(argv) |
|
|
|
|
| def main(argv: list[str] | None = None) -> int: |
| """Run the small classification batch and print a concise console summary.""" |
| args = parse_args(argv) |
| if args.limit < 1: |
| raise SystemExit("--limit must be at least 1.") |
|
|
| vocabulary = load_vocabulary(DEFAULT_HIERARCHY_PATH) |
| article_load = load_articles(args.articles) |
| limited_load = limit_article_load(article_load, args.limit) |
| settings = build_settings(args) |
| model_client = build_model_client( |
| settings=settings, |
| fake=args.fake, |
| vocabulary=vocabulary, |
| article_count=len(limited_load.articles), |
| ) |
| store = JsonResultStore(args.output_dir) |
| cache = ArticleResultCache(args.output_dir / "cache") |
| batch = run_batch( |
| article_load_result=limited_load, |
| vocabulary=vocabulary, |
| model_client=model_client, |
| settings=settings, |
| store=store, |
| cache=cache, |
| run_id="manual-small-run", |
| relevant_config={"limit": args.limit, "mode": "fake" if args.fake else "live"}, |
| on_article_start=print_article_start, |
| on_article_finish=print_article_finish, |
| ) |
| review_csv_path = write_review_csv(batch.results, args.output_dir) |
| print_console_summary(batch.results) |
| print( |
| "\nSummary: " |
| f"processed={batch.summary.processed_articles}, " |
| f"classified={batch.summary.accepted_classifications}, " |
| f"not_classified={batch.summary.articles_not_classified}, " |
| f"failed={batch.summary.articles_failed}, " |
| f"invalid_source_records={batch.summary.invalid_source_records}, " |
| f"output={store.consolidated_path}, " |
| f"review_csv={review_csv_path}" |
| ) |
| return 0 |
|
|
|
|
| def build_settings(args: argparse.Namespace) -> ModelSettings: |
| """Build model settings from environment plus CLI overrides.""" |
| settings = ModelSettings.from_environment() |
| updates: dict[str, object] = {} |
| if args.fake: |
| updates["provider"] = "fake" |
| updates["model_name"] = args.model or "fake-model" |
| else: |
| updates["provider"] = args.provider or settings.provider |
| if args.model is not None: |
| updates["model_name"] = args.model |
| return settings.model_copy(update=updates) |
|
|
|
|
| def build_model_client( |
| *, |
| settings: ModelSettings, |
| fake: bool, |
| vocabulary: VocabularyIndex, |
| article_count: int, |
| ) -> ModelClient: |
| """Create the configured model client for a manual run.""" |
| if fake: |
| return FakeModelClient(fake_scripted_responses(vocabulary, article_count)) |
| if settings.provider != "openai": |
| raise SystemExit("Live mode requires --provider openai, or use --fake for demo mode.") |
| if not os.environ.get(settings.api_key_env_var): |
| raise SystemExit( |
| f"Live mode requires {settings.api_key_env_var} to be set. Use --fake for demo mode." |
| ) |
| return OpenAIModelClient(settings) |
|
|
|
|
| def limit_article_load(article_load: ArticleLoadResult, limit: int) -> ArticleLoadResult: |
| """Return an ArticleLoadResult containing the first N valid articles.""" |
| return article_load.model_copy(update={"articles": article_load.articles[:limit]}) |
|
|
|
|
| def fake_scripted_responses(vocabulary: VocabularyIndex, article_count: int) -> list[dict]: |
| """Build deterministic fake responses matching the MVP smoke-test pattern.""" |
| if article_count < 1: |
| return [] |
| topic_position, _, term_position, term = first_term_with_variables(vocabulary) |
| actions = [ |
| topic_response(f"topic_{topic_position:04d}"), |
| term_response(f"term_{term_position:04d}"), |
| ] |
| parent = term |
| while parent.level != "Variable_Level_3": |
| children = vocabulary.variables_for_parent(parent.UUID) |
| if not children: |
| break |
| actions.append(variable_response("variable_0001")) |
| parent = children[0] |
| actions.extend(no_topic_response() for _ in range(max(article_count - 1, 0))) |
| return actions |
|
|
|
|
| def first_term_with_variables(vocabulary: VocabularyIndex): |
| """Return the first Topic/Term position pair whose Term has Variable children.""" |
| for topic_position, topic in enumerate(vocabulary.topics(), start=1): |
| for term_position, term in enumerate(vocabulary.terms_for_topic(topic.UUID), start=1): |
| if vocabulary.variables_for_parent(term.UUID): |
| return topic_position, topic, term_position, term |
| raise RuntimeError("Vocabulary did not contain a Term with Variable children.") |
|
|
|
|
| def decision(candidate_id: str) -> dict: |
| """Return one fake structured candidate decision.""" |
| return { |
| "candidate_id": candidate_id, |
| "confidence": 0.9, |
| "evidence": f"Manual fake-run evidence for {candidate_id}.", |
| "support_type": "explicit", |
| "reason": f"Manual fake-run selected {candidate_id}.", |
| } |
|
|
|
|
| def topic_response(candidate_id: str) -> dict: |
| """Return a fake TopicResponse-shaped payload.""" |
| return {"selected": [decision(candidate_id)]} |
|
|
|
|
| def term_response(candidate_id: str) -> dict: |
| """Return a fake TermResponse-shaped payload.""" |
| return {"selected": [decision(candidate_id)], "stop_at_parent": False} |
|
|
|
|
| def variable_response(candidate_id: str) -> dict: |
| """Return a fake VariableResponse-shaped payload.""" |
| return {"selected": [decision(candidate_id)], "stop_at_parent": False} |
|
|
|
|
| def no_topic_response() -> dict: |
| """Return a fake no-classification TopicResponse-shaped payload.""" |
| return { |
| "selected": [], |
| "no_selection_reason": "Manual fake run did not select a GCMD Topic.", |
| } |
|
|
|
|
| REVIEW_CSV_FIELDS = ( |
| "DOI", |
| "Title", |
| "Year", |
| "classification_outcome", |
| "processing_status", |
| "level", |
| "UUID", |
| "canonical_path", |
| "topic", |
| "term", |
| "support_type", |
| "confidence_final", |
| "classifier_evidence", |
| "reason_for_stopping", |
| "deterministic_valid", |
| "review_required", |
| "warnings", |
| "no_classification_reason", |
| "errors", |
| ) |
|
|
|
|
| def write_review_csv(results: tuple[ArticleResult, ...], output_dir: Path) -> Path: |
| """Write a review-friendly CSV beside the consolidated small-run JSON output.""" |
| output_dir.mkdir(parents=True, exist_ok=True) |
| csv_path = output_dir / "review_table.csv" |
| with csv_path.open("w", newline="") as handle: |
| writer = csv.DictWriter(handle, fieldnames=REVIEW_CSV_FIELDS) |
| writer.writeheader() |
| for result in results: |
| for row in review_rows_for_result(result): |
| writer.writerow(row) |
| return csv_path |
|
|
|
|
| def review_rows_for_result(result: ArticleResult) -> list[dict[str, object]]: |
| """Return one CSV row per accepted classification, or one article-level row.""" |
| outcome = None if result.classification_outcome is None else result.classification_outcome.value |
| common = { |
| "DOI": result.DOI, |
| "Title": result.Title, |
| "Year": result.Year, |
| "classification_outcome": outcome or "", |
| "processing_status": result.processing_status.value, |
| } |
| if result.classifications: |
| rows: list[dict[str, object]] = [] |
| for record in result.classifications: |
| rows.append( |
| { |
| **common, |
| "level": record.level, |
| "UUID": record.UUID, |
| "canonical_path": record.canonical_path, |
| "topic": record.topic, |
| "term": record.term or "", |
| "support_type": "" |
| if record.support_type is None |
| else record.support_type.value, |
| "confidence_final": "" |
| if record.confidence is None or record.confidence.final is None |
| else record.confidence.final, |
| "classifier_evidence": record.classifier_evidence or "", |
| "reason_for_stopping": record.reason_for_stopping or "", |
| "deterministic_valid": record.deterministic_validation.valid, |
| "review_required": record.review_required, |
| "warnings": classification_warnings_text(record), |
| "no_classification_reason": "", |
| "errors": "", |
| } |
| ) |
| return rows |
| return [ |
| { |
| **common, |
| "level": "", |
| "UUID": "", |
| "canonical_path": "", |
| "topic": "", |
| "term": "", |
| "support_type": "", |
| "confidence_final": "", |
| "classifier_evidence": "", |
| "reason_for_stopping": "", |
| "deterministic_valid": "", |
| "review_required": "", |
| "warnings": article_warnings_text(result), |
| "no_classification_reason": result.no_classification_reason or "", |
| "errors": errors_text(result), |
| } |
| ] |
|
|
|
|
| def classification_warnings_text(record) -> str: |
| """Format structured classification warnings compactly for review CSV output.""" |
| return "; ".join(f"{warning.code}: {warning.message}" for warning in record.warnings) |
|
|
|
|
| def article_warnings_text(result: ArticleResult) -> str: |
| """Format structured article warnings compactly for review CSV output.""" |
| return "; ".join(f"{warning.code}: {warning.message}" for warning in result.warnings) |
|
|
|
|
| def errors_text(result: ArticleResult) -> str: |
| """Format structured article errors compactly for review CSV output.""" |
| return "; ".join(f"{error.code}: {error.message}" for error in result.errors) |
|
|
|
|
| def print_article_start(index: int, total: int, article: ArticleRecord) -> None: |
| """Print safe progress before one article starts.""" |
| print( |
| f"Starting article {index + 1}/{total}: " |
| f"DOI={article.DOI} title={truncate_title(article.Title)!r}", |
| flush=True, |
| ) |
|
|
|
|
| def print_article_finish( |
| index: int, |
| total: int, |
| result: ArticleResult, |
| elapsed_seconds: float, |
| ) -> None: |
| """Print safe progress after one article finishes.""" |
| outcome = None if result.classification_outcome is None else result.classification_outcome.value |
| print( |
| f"Finished article {index + 1}/{total}: " |
| f"processing_status={result.processing_status.value} " |
| f"classification_outcome={outcome} " |
| f"accepted_classifications={len(result.classifications)} " |
| f"elapsed_seconds={elapsed_seconds:.2f}", |
| flush=True, |
| ) |
|
|
|
|
| def truncate_title(title: str, max_length: int = 80) -> str: |
| """Return a one-line title truncated for safe progress output.""" |
| compact = one_line(title) |
| if len(compact) <= max_length: |
| return compact |
| return f"{compact[: max_length - 3]}..." |
|
|
|
|
| def print_console_summary(results: tuple[ArticleResult, ...]) -> None: |
| """Print DOI, title, status, outcome, and final classification details.""" |
| print("DOI\tTitle\tprocessing_status\tclassification_outcome\tlevel\tUUID\tcanonical_path") |
| for result in results: |
| outcome = ( |
| None if result.classification_outcome is None else result.classification_outcome.value |
| ) |
| if result.classifications: |
| for record in result.classifications: |
| print( |
| "\t".join( |
| [ |
| result.DOI, |
| one_line(result.Title), |
| result.processing_status.value, |
| str(outcome), |
| record.level, |
| record.UUID, |
| record.canonical_path, |
| ] |
| ) |
| ) |
| else: |
| print( |
| "\t".join( |
| [ |
| result.DOI, |
| one_line(result.Title), |
| result.processing_status.value, |
| str(outcome), |
| "", |
| "", |
| result.no_classification_reason or "", |
| ] |
| ) |
| ) |
| print_messages(result) |
|
|
|
|
| def print_messages(result: ArticleResult) -> None: |
| """Print structured warnings and errors for one article.""" |
| for warning in result.warnings: |
| print(f" WARNING {warning.code}: {warning.message}") |
| for error in result.errors: |
| print(f" ERROR {error.code}: {error.message}") |
|
|
|
|
| def one_line(value: str) -> str: |
| """Keep console rows tab-safe and compact.""" |
| return " ".join(value.split()) |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|