| from __future__ import annotations |
|
|
| import csv |
| from pathlib import Path |
|
|
| import pytest |
| from scripts import run_small_classification |
|
|
| from gcmd_classifier.articles import validate_article_records |
| from gcmd_classifier.config import ModelSettings |
| from gcmd_classifier.llm import FakeModelClient |
| from gcmd_classifier.models import ( |
| ArticleClassificationOutcome, |
| ArticleProcessingStatus, |
| ArticleRecord, |
| ArticleResult, |
| ReviewStatus, |
| ) |
| from gcmd_classifier.vocabulary import load_vocabulary |
|
|
| FIXTURE_PATH = Path("tests/fixtures/gcmd_hierarchy_small.json") |
|
|
|
|
| def test_small_run_script_imports_without_live_api_calls() -> None: |
| assert callable(run_small_classification.main) |
| assert callable(run_small_classification.fake_scripted_responses) |
|
|
|
|
| def test_limit_article_load_uses_first_valid_articles() -> None: |
| load_result = validate_article_records( |
| [ |
| {"DOI": "10.example/1", "Title": "One", "Year": 2025, "Abstract": ""}, |
| {"DOI": "10.example/2", "Title": "Two", "Year": 2025, "Abstract": ""}, |
| {"DOI": "10.example/3", "Title": "Three", "Year": 2025, "Abstract": ""}, |
| ] |
| ) |
|
|
| limited = run_small_classification.limit_article_load(load_result, 2) |
|
|
| assert [article.DOI for article in limited.articles] == ["10.example/1", "10.example/2"] |
| assert limited.source_count == 3 |
|
|
|
|
| def test_fake_scripted_responses_support_requested_article_count() -> None: |
| vocabulary = load_vocabulary(FIXTURE_PATH) |
|
|
| actions = run_small_classification.fake_scripted_responses(vocabulary, article_count=3) |
|
|
| assert actions[0]["selected"][0]["candidate_id"].startswith("topic_") |
| assert actions[1]["selected"][0]["candidate_id"].startswith("term_") |
| no_topic_count = sum(1 for action in actions if action.get("no_selection_reason")) |
| assert no_topic_count == 2 |
|
|
|
|
| def test_build_model_client_fake_returns_fake_model() -> None: |
| client = run_small_classification.build_model_client( |
| settings=ModelSettings(provider="fake"), |
| fake=True, |
| vocabulary=load_vocabulary(FIXTURE_PATH), |
| article_count=1, |
| ) |
|
|
| assert isinstance(client, FakeModelClient) |
|
|
|
|
| def test_live_mode_requires_openai_provider() -> None: |
| with pytest.raises(SystemExit, match="requires --provider openai"): |
| run_small_classification.build_model_client( |
| settings=ModelSettings(provider="fake"), |
| fake=False, |
| vocabulary=load_vocabulary(FIXTURE_PATH), |
| article_count=1, |
| ) |
|
|
|
|
| def test_live_mode_requires_credentials(monkeypatch) -> None: |
| monkeypatch.delenv("OPENAI_API_KEY", raising=False) |
|
|
| with pytest.raises(SystemExit, match="requires OPENAI_API_KEY"): |
| run_small_classification.build_model_client( |
| settings=ModelSettings(provider="openai", api_key_env_var="OPENAI_API_KEY"), |
| fake=False, |
| vocabulary=load_vocabulary(FIXTURE_PATH), |
| article_count=1, |
| ) |
|
|
|
|
| def test_console_summary_includes_classification_and_no_classification(capsys) -> None: |
| classified = ArticleResult( |
| DOI="10.example/classified", |
| Title="Classified title", |
| Year=2025, |
| Abstract="Text.", |
| processing_status=ArticleProcessingStatus.COMPLETED, |
| classification_outcome=ArticleClassificationOutcome.CLASSIFIED, |
| classifications=( |
| { |
| "UUID": "topic-atmosphere", |
| "name": "ATMOSPHERE", |
| "level": "Topic", |
| "canonical_path": "ATMOSPHERE", |
| "path_components": ("ATMOSPHERE",), |
| "topic": "ATMOSPHERE", |
| "deterministic_validation": {"valid": True}, |
| "final_status": "accepted", |
| "review_required": True, |
| "warnings": ( |
| { |
| "code": "REVIEW_RECOMMENDED_WEAK_SUPPORT", |
| "message": "Manual scientific review is recommended.", |
| }, |
| ), |
| }, |
| ), |
| review_status=ReviewStatus.NOT_REQUIRED, |
| ) |
| not_classified = ArticleResult( |
| DOI="10.example/no", |
| Title="No title", |
| Year=2025, |
| Abstract="", |
| processing_status=ArticleProcessingStatus.COMPLETED, |
| classification_outcome=ArticleClassificationOutcome.NOT_CLASSIFIED, |
| classifications=(), |
| no_classification_reason="No Topic selected.", |
| review_status=ReviewStatus.NOT_REQUIRED, |
| ) |
|
|
| run_small_classification.print_console_summary((classified, not_classified)) |
|
|
| output = capsys.readouterr().out |
| assert "topic-atmosphere" in output |
| assert "ATMOSPHERE" in output |
| assert "No Topic selected." in output |
|
|
|
|
| def test_review_csv_path_follows_output_dir(tmp_path: Path) -> None: |
| output_dir = tmp_path / "small_run_gpt56" |
| result = ArticleResult( |
| DOI="10.example/no", |
| Title="No title", |
| Year=2025, |
| Abstract="", |
| processing_status=ArticleProcessingStatus.COMPLETED, |
| classification_outcome=ArticleClassificationOutcome.NOT_CLASSIFIED, |
| classifications=(), |
| no_classification_reason="No Topic selected.", |
| review_status=ReviewStatus.NOT_REQUIRED, |
| ) |
|
|
| csv_path = run_small_classification.write_review_csv((result,), output_dir) |
|
|
| assert csv_path == output_dir / "review_table.csv" |
| assert csv_path.exists() |
|
|
|
|
| def test_review_csv_contains_classification_and_article_level_rows(tmp_path: Path) -> None: |
| classified = ArticleResult( |
| DOI="10.example/classified", |
| Title="Classified title", |
| Year=2025, |
| Abstract="Text.", |
| processing_status=ArticleProcessingStatus.COMPLETED, |
| classification_outcome=ArticleClassificationOutcome.CLASSIFIED, |
| classifications=( |
| { |
| "UUID": "topic-atmosphere", |
| "name": "ATMOSPHERE", |
| "level": "Topic", |
| "canonical_path": "ATMOSPHERE", |
| "path_components": ("ATMOSPHERE",), |
| "topic": "ATMOSPHERE", |
| "classifier_evidence": "Evidence text.", |
| "support_type": "explicit", |
| "reason_for_stopping": "Stopped here.", |
| "confidence": {"final": 0.7}, |
| "deterministic_validation": {"valid": True}, |
| "final_status": "accepted", |
| "review_required": True, |
| "warnings": ( |
| { |
| "code": "REVIEW_RECOMMENDED_WEAK_SUPPORT", |
| "message": "Manual scientific review is recommended.", |
| }, |
| ), |
| }, |
| ), |
| review_status=ReviewStatus.NOT_REQUIRED, |
| ) |
| failed = ArticleResult( |
| DOI="10.example/failed", |
| Title="Failed title", |
| Year=2025, |
| Abstract="Text.", |
| processing_status=ArticleProcessingStatus.FAILED, |
| classification_outcome=None, |
| classifications=(), |
| errors=({"code": "MODEL_ERROR", "message": "Model failed."},), |
| review_status=ReviewStatus.NOT_REQUIRED, |
| ) |
|
|
| csv_path = run_small_classification.write_review_csv((classified, failed), tmp_path) |
|
|
| rows = list(csv.DictReader(csv_path.open())) |
| assert len(rows) == 2 |
| assert rows[0]["DOI"] == "10.example/classified" |
| assert rows[0]["level"] == "Topic" |
| assert rows[0]["UUID"] == "topic-atmosphere" |
| assert rows[0]["canonical_path"] == "ATMOSPHERE" |
| assert rows[0]["support_type"] == "explicit" |
| assert rows[0]["confidence_final"] == "0.7" |
| assert rows[0]["classifier_evidence"] == "Evidence text." |
| assert rows[0]["reason_for_stopping"] == "Stopped here." |
| assert rows[0]["deterministic_valid"] == "True" |
| assert rows[0]["review_required"] == "True" |
| assert "REVIEW_RECOMMENDED_WEAK_SUPPORT" in rows[0]["warnings"] |
| assert rows[1]["DOI"] == "10.example/failed" |
| assert rows[1]["UUID"] == "" |
| assert rows[1]["errors"] == "MODEL_ERROR: Model failed." |
|
|
|
|
| def test_progress_helpers_print_safe_truncated_output(capsys) -> None: |
| article = ArticleRecord( |
| DOI="10.example/progress", |
| Title="A very long title " * 12, |
| Year=2025, |
| Abstract="Do not print this abstract.", |
| ) |
| result = ArticleResult( |
| DOI=article.DOI, |
| Title=article.Title, |
| Year=article.Year, |
| Abstract=article.Abstract, |
| processing_status=ArticleProcessingStatus.COMPLETED, |
| classification_outcome=ArticleClassificationOutcome.NOT_CLASSIFIED, |
| classifications=(), |
| no_classification_reason="No Topic selected.", |
| review_status=ReviewStatus.NOT_REQUIRED, |
| ) |
|
|
| run_small_classification.print_article_start(0, 3, article) |
| run_small_classification.print_article_finish(0, 3, result, 1.234) |
|
|
| output = capsys.readouterr().out |
| assert "Starting article 1/3" in output |
| assert "DOI=10.example/progress" in output |
| assert "..." in output |
| assert "Finished article 1/3" in output |
| assert "processing_status=completed" in output |
| assert "classification_outcome=not_classified" in output |
| assert "accepted_classifications=0" in output |
| assert "elapsed_seconds=1.23" in output |
| assert "Do not print this abstract" not in output |
|
|
|
|
| def test_truncate_title_uses_one_line_and_limit() -> None: |
| title = "Title with\nnewlines and " + "x" * 100 |
|
|
| truncated = run_small_classification.truncate_title(title, max_length=30) |
|
|
| assert "\n" not in truncated |
| assert len(truncated) == 30 |
| assert truncated.endswith("...") |
|
|
|
|
| def test_one_line_compacts_tabs_and_newlines() -> None: |
| assert run_small_classification.one_line("A\tlong\n title") == "A long title" |
|
|