From 67e7d12ff95cdbb09c099edf6948523bb5e6a241 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sat, 23 May 2026 22:23:24 +0100 Subject: [PATCH] Make LLM extraction optional --- pyproject.toml | 5 +++ src/mailplus_intelligence/doctor.py | 20 +++++++++++ src/mailplus_intelligence/llm_extractor.py | 40 +++++++++++++++++++--- tests/test_doctor.py | 2 ++ tests/test_llm_extractor.py | 26 +++++++++++++- 5 files changed, 87 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index abf596c..939c60e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,11 @@ authors = [ ] dependencies = [] +[project.optional-dependencies] +llm = [ + "anthropic>=0.40,<1.0", +] + [project.scripts] mpi = "mailplus_intelligence.cli:main" diff --git a/src/mailplus_intelligence/doctor.py b/src/mailplus_intelligence/doctor.py index 4e27ad5..a54fad3 100644 --- a/src/mailplus_intelligence/doctor.py +++ b/src/mailplus_intelligence/doctor.py @@ -8,6 +8,7 @@ from pathlib import Path from .fixtures import load_metadata_fixture_corpus +from .llm_extractor import resolve_llm_model from .runtime import default_runtime_profile from .schema import apply_schema_v0, current_schema_version from .sqlite import connect_sqlite @@ -107,6 +108,25 @@ def run_fixture_doctor(project_root: str | Path = ".") -> DoctorReport: ) ) + try: + import anthropic # noqa: F401 + + sdk_available = True + except ImportError: + sdk_available = False + + api_key_present = bool(os.environ.get("ANTHROPIC_API_KEY")) + if sdk_available and api_key_present: + llm_status = "ok" + llm_message = f"LLM extraction available; model={resolve_llm_model()}" + elif sdk_available: + llm_status = "gated" + llm_message = f"Anthropic SDK installed; ANTHROPIC_API_KEY missing; model={resolve_llm_model()}" + else: + llm_status = "gated" + llm_message = f"Anthropic SDK not installed; deterministic extraction only; model={resolve_llm_model()}" + checks.append(DoctorCheck("llm", llm_status, llm_message)) + return DoctorReport(tuple(checks)) diff --git a/src/mailplus_intelligence/llm_extractor.py b/src/mailplus_intelligence/llm_extractor.py index 7fecba7..928a3cd 100644 --- a/src/mailplus_intelligence/llm_extractor.py +++ b/src/mailplus_intelligence/llm_extractor.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import os import uuid from dataclasses import dataclass, field from typing import Any @@ -30,6 +31,36 @@ ) _EXTRACTION_LANES_LLM = EXTRACTION_LANES +DEFAULT_LLM_MODEL = "claude-opus-4-7" +LLM_EXTRA_INSTALL_HINT = "pip install 'mailplus-intelligence[llm]'" + + +class LLMNotAvailable(RuntimeError): + """Raised when LLM extraction is requested but cannot run locally.""" + + +def resolve_llm_model(model: str | None = None) -> str: + """Resolve the configured LLM model name.""" + + if model: + return model + return os.environ.get("MAILPLUS_LLM_MODEL") or DEFAULT_LLM_MODEL + + +def _build_anthropic_client() -> Any: + try: + import anthropic + except ImportError as exc: + raise LLMNotAvailable( + f"Anthropic SDK is not installed; run {LLM_EXTRA_INSTALL_HINT} to enable LLM extraction." + ) from exc + + if not os.environ.get("ANTHROPIC_API_KEY"): + raise LLMNotAvailable( + "ANTHROPIC_API_KEY is not set; set it or pass a cassette for offline LLM extraction." + ) + + return anthropic.Anthropic() @dataclass @@ -116,7 +147,7 @@ def extract_with_llm( messages: list[dict[str, Any]], *, client: Any = None, - model: str = "claude-opus-4-7", + model: str | None = None, cassette: dict[str, str] | None = None, usage_stats: LLMUsageStats | None = None, ) -> LLMExtractionResult: @@ -150,11 +181,10 @@ def extract_with_llm( return LLMExtractionResult(candidates=candidates, usage=stats, cassette_hit=True) if client is None: - import anthropic - client = anthropic.Anthropic() + client = _build_anthropic_client() response = client.messages.create( - model=model, + model=resolve_llm_model(model), max_tokens=1024, thinking={"type": "adaptive"}, system=[ @@ -200,7 +230,7 @@ def extract_corpus_with_llm( messages: list[dict[str, Any]], *, client: Any = None, - model: str = "claude-opus-4-7", + model: str | None = None, cassette: dict[str, str] | None = None, ) -> LLMExtractionResult: """Run LLM extraction over all threads, sharing usage stats.""" diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 120677f..2ba6292 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -16,12 +16,14 @@ def test_fixture_doctor_passes_with_live_credentials_gated(self) -> None: self.assertEqual(statuses["fixtures"], "ok") self.assertEqual(statuses["schema"], "ok") self.assertEqual(statuses["live-mailplus"], "gated") + self.assertIn(statuses["llm"], {"ok", "gated"}) self.assertTrue(report.ok) def test_fixture_doctor_output_names_gated_live_access(self) -> None: output = format_doctor_report(run_fixture_doctor()) self.assertIn("live MailPlus credentials intentionally unavailable", output) + self.assertIn("llm", output) self.assertIn("result: ok", output) diff --git a/tests/test_llm_extractor.py b/tests/test_llm_extractor.py index 47edc74..7ad58b7 100644 --- a/tests/test_llm_extractor.py +++ b/tests/test_llm_extractor.py @@ -8,9 +8,12 @@ from mailplus_intelligence.extractor import ExtractionCandidate from mailplus_intelligence.fixtures import load_metadata_fixture_corpus from mailplus_intelligence.llm_extractor import ( + DEFAULT_LLM_MODEL, + LLMNotAvailable, LLMUsageStats, extract_corpus_with_llm, extract_with_llm, + resolve_llm_model, ) from mailplus_intelligence.threading import reconstruct_fixture_threads @@ -45,8 +48,12 @@ def test_cassette_miss_raises_without_credentials(self) -> None: thread = next(t for t in self.threads if t.thread_id) # A non-matching cassette key forces a live API call, which fails without # credentials. Verify the right error is raised rather than a silent pass. - with self.assertRaises((TypeError, Exception)): + with self.assertRaises(LLMNotAvailable) as raised: extract_with_llm(thread, self.messages, cassette={"other-thread": "[]"}) + self.assertTrue( + "mailplus-intelligence[llm]" in str(raised.exception) + or "ANTHROPIC_API_KEY" in str(raised.exception) + ) def test_corpus_cassette_aggregates_across_threads(self) -> None: cassette: dict[str, str] = {} @@ -100,6 +107,23 @@ def test_noise_threads_skipped(self) -> None: result = extract_with_llm(thread, messages, cassette={}) self.assertEqual(result.candidates, []) + def test_model_resolution_prefers_argument_then_environment(self) -> None: + self.assertEqual(resolve_llm_model("claude-test"), "claude-test") + + import os + + old = os.environ.get("MAILPLUS_LLM_MODEL") + try: + os.environ["MAILPLUS_LLM_MODEL"] = "claude-env" + self.assertEqual(resolve_llm_model(), "claude-env") + finally: + if old is None: + os.environ.pop("MAILPLUS_LLM_MODEL", None) + else: + os.environ["MAILPLUS_LLM_MODEL"] = old + + self.assertEqual(resolve_llm_model(), old or DEFAULT_LLM_MODEL) + if __name__ == "__main__": unittest.main()