From 70dfbf151c8035336463303402cb1612de45e07c Mon Sep 17 00:00:00 2001 From: dex0shubham Date: Sat, 29 Aug 2026 13:53:48 +0100 Subject: [PATCH 1/7] fix(utils): write console progress to stderr instead of stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConsoleProgressDisplay wrote every progress frame to sys.stdout. Progress is diagnostic output, so stderr is the correct stream for it — tqdm and most progress renderers default there for the same reason — and stdout must stay clean for programs that carry a machine-readable protocol on it. The stdio MCP servers put newline-delimited JSON-RPC on stdout, where an interleaved progress bar makes a response body unparseable (#1134). ConsoleProgressDisplay now takes an optional stream, defaulting to stderr. The stream is resolved per write rather than captured at construction, so a later rebinding of sys.stderr (pytest capture, for instance) is honoured. All writes and the four bare flushes route through it, and the emoji-capability probe now inspects that stream rather than stdout, so a cp1252 stderr still degrades correctly. The existing cp1252 tests in tests/deduplication/test_deduplication.py patched sys.stdout to assert emoji auto-disabling; they now patch the stream progress is actually written to. Their intent is unchanged. Closes #1134 (point 1 only; the SEMANTICA_KG_PATH persistence and README items remain with @akaszubski) --- semantica/utils/progress_tracker.py | 57 ++++++-- tests/deduplication/test_deduplication.py | 28 ++-- tests/utils/test_progress_stream.py | 160 ++++++++++++++++++++++ 3 files changed, 217 insertions(+), 28 deletions(-) create mode 100644 tests/utils/test_progress_stream.py diff --git a/semantica/utils/progress_tracker.py b/semantica/utils/progress_tracker.py index f27768d4..4798b4b9 100644 --- a/semantica/utils/progress_tracker.py +++ b/semantica/utils/progress_tracker.py @@ -43,7 +43,7 @@ from contextlib import contextmanager from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple, Union from .logging import get_logger @@ -144,14 +144,37 @@ class ProgressDisplay(ABC): class ConsoleProgressDisplay(ProgressDisplay): """Console progress display with real-time updates.""" - def __init__(self, use_emoji: bool = True, update_interval: float = 0.1): + def __init__( + self, + use_emoji: bool = True, + update_interval: float = 0.1, + stream: Optional[TextIO] = None, + ): + """Initialize the console display. + + Args: + use_emoji: Whether to decorate output with emoji. + update_interval: Minimum seconds between redraws. + stream: Where progress is written. Defaults to ``sys.stderr``. + + Progress is diagnostic output, so stderr is the correct stream + for it, and stdout must stay clean for programs that carry a + machine-readable protocol on it — the stdio MCP servers put + newline-delimited JSON-RPC there, and a progress bar on stdout + corrupts that stream. + + Left as ``None``, the stream is resolved on each write rather + than captured here, so a later rebinding of ``sys.stderr`` + (pytest capture, for instance) is honoured. + """ self.use_emoji = use_emoji - - # Check if stdout supports emojis (especially on Windows) + self._stream = stream + + # Check if the target stream supports emojis (especially on Windows) if self.use_emoji: try: - # Try encoding a test emoji with stdout's encoding - encoding = getattr(sys.stdout, "encoding", None) + # Try encoding a test emoji with the stream's encoding + encoding = getattr(self.stream, "encoding", None) if encoding: "🧠".encode(encoding) except (UnicodeEncodeError, LookupError, AttributeError): @@ -162,6 +185,11 @@ class ConsoleProgressDisplay(ProgressDisplay): self.current_lines: Dict[str, str] = {} self.lock = threading.Lock() + @property + def stream(self) -> TextIO: + """The stream progress is written to; ``sys.stderr`` unless overridden.""" + return self._stream if self._stream is not None else sys.stderr + def _should_update(self) -> bool: """Check if enough time has passed for update.""" now = time.time() @@ -259,15 +287,16 @@ class ConsoleProgressDisplay(ProgressDisplay): return f"{base_msg}: {message}" def _safe_write(self, text: str) -> None: - """Safely write text to stdout handling encoding errors.""" + """Safely write text to the progress stream handling encoding errors.""" + stream = self.stream try: - sys.stdout.write(text) + stream.write(text) except UnicodeEncodeError: # Fallback: encode with replacement and write decoded # Use ascii as safe fallback if encoding is unknown or caused error - encoding = getattr(sys.stdout, "encoding", None) or "ascii" + encoding = getattr(stream, "encoding", None) or "ascii" safe_text = text.encode(encoding, errors="replace").decode(encoding) - sys.stdout.write(safe_text) + stream.write(safe_text) def update(self, item: ProgressItem) -> None: """Update console progress display.""" @@ -302,11 +331,11 @@ class ConsoleProgressDisplay(ProgressDisplay): self._display_item_line(pipeline_item) self._safe_write("\n") - sys.stdout.flush() + self.stream.flush() else: # Original single-item display self._display_item_line(item) - sys.stdout.flush() + self.stream.flush() def _display_item_line(self, item: ProgressItem) -> None: """Display a single progress item line.""" @@ -468,13 +497,13 @@ class ConsoleProgressDisplay(ProgressDisplay): f"Completed: {completed} | Failed: {failed} | Total Time: {total_time:.2f}s\n" ) self._safe_write("=" * 80 + "\n") - sys.stdout.flush() + self.stream.flush() def clear(self) -> None: """Clear console display.""" with self.lock: self._safe_write("\r" + " " * 100 + "\r") - sys.stdout.flush() + self.stream.flush() self.current_lines.clear() diff --git a/tests/deduplication/test_deduplication.py b/tests/deduplication/test_deduplication.py index 8dc7b7c2..54402a46 100644 --- a/tests/deduplication/test_deduplication.py +++ b/tests/deduplication/test_deduplication.py @@ -291,8 +291,8 @@ class TestDeduplication(unittest.TestCase): class TestProgressTrackerEncoding(unittest.TestCase): """Regression tests for issue #531 — Unicode crash on cp1252 Windows consoles.""" - def _make_cp1252_stdout(self): - """Return a stdout-like object that raises UnicodeEncodeError for non-cp1252 chars.""" + def _make_cp1252_stream(self): + """Return a stream that raises UnicodeEncodeError for non-cp1252 chars.""" class CP1252Writer: encoding = "cp1252" def write(self, text): @@ -304,39 +304,39 @@ class TestProgressTrackerEncoding(unittest.TestCase): def test_safe_write_does_not_crash_on_cp1252(self): """_safe_write must not raise UnicodeEncodeError on a cp1252 console.""" display = ConsoleProgressDisplay() - orig = sys.stdout - sys.stdout = self._make_cp1252_stdout() + orig = sys.stderr + sys.stderr = self._make_cp1252_stream() try: display._safe_write("🧠 Semantica - 📊 Current Progress\n") except UnicodeEncodeError: - self.fail("_safe_write raised UnicodeEncodeError on cp1252 stdout") + self.fail("_safe_write raised UnicodeEncodeError on a cp1252 stream") finally: - sys.stdout = orig + sys.stderr = orig def test_update_pipeline_header_does_not_crash_on_cp1252(self): """update() pipeline header write must not crash on a cp1252 console (issue #531).""" from semantica.utils.progress_tracker import ProgressItem display = ConsoleProgressDisplay() display.use_emoji = True # force emoji path to exercise the fixed branch - orig = sys.stdout - sys.stdout = self._make_cp1252_stdout() + orig = sys.stderr + sys.stderr = self._make_cp1252_stream() try: display._safe_write("🧠 Semantica - 📊 Current Progress\n") display._safe_write("=" * 150 + "\n") except UnicodeEncodeError: self.fail("Pipeline header write raised UnicodeEncodeError on cp1252 stdout") finally: - sys.stdout = orig + sys.stderr = orig def test_emoji_detection_disables_on_cp1252(self): - """ConsoleProgressDisplay should auto-disable emoji when stdout is cp1252.""" - orig = sys.stdout - sys.stdout = self._make_cp1252_stdout() + """ConsoleProgressDisplay should auto-disable emoji when the progress stream is cp1252.""" + orig = sys.stderr + sys.stderr = self._make_cp1252_stream() try: display = ConsoleProgressDisplay() - self.assertFalse(display.use_emoji, "use_emoji should be False on cp1252 stdout") + self.assertFalse(display.use_emoji, "use_emoji should be False on a cp1252 progress stream") finally: - sys.stdout = orig + sys.stderr = orig class TestResultLimiting(unittest.TestCase): diff --git a/tests/utils/test_progress_stream.py b/tests/utils/test_progress_stream.py new file mode 100644 index 00000000..0e851710 --- /dev/null +++ b/tests/utils/test_progress_stream.py @@ -0,0 +1,160 @@ +"""Console progress must be written to stderr, never to stdout. + +Progress is diagnostic output. Writing it to stdout corrupts any program that +carries a machine-readable protocol there — the stdio MCP servers put +newline-delimited JSON-RPC on stdout, and a progress bar interleaved with a +response body makes that response unparseable (#1134). + +Both servers currently defend against this by setting +SEMANTICA_DISABLE_PROGRESS, and a console display is only attached when the +console is interactive. Those are containments, not the fix: they give up +progress output entirely, and every future entry point has to remember them. +Writing to the correct stream in the first place is what these tests pin. +""" + +import importlib +import io +import sys + +import pytest + + +@pytest.fixture +def progress_module(monkeypatch): + """Import the real progress_tracker, bypassing a mocked sys.modules entry. + + tests/test_extractors_dispatch.py assigns a MagicMock over + 'semantica.utils.progress_tracker' at import time and never restores it, so + a plain module-level import here returns mocks when that file has already + run. Dropping the cached entry via monkeypatch re-imports the real module + and restores whatever was there afterwards. + """ + name = "semantica.utils.progress_tracker" + monkeypatch.delitem(sys.modules, name, raising=False) + module = importlib.import_module(name) + assert hasattr(module, "__file__"), "expected the real module, got a stand-in" + return module + + +@pytest.fixture +def display_cls(progress_module): + return progress_module.ConsoleProgressDisplay + + +@pytest.fixture +def make_item(progress_module): + def _make(**overrides): + defaults = dict( + module="kg", + submodule="Reasoner", + message="Inferring facts", + status="running", + total_items=10, + processed_items=3, + ) + defaults.update(overrides) + return progress_module.ProgressItem(**defaults) + + return _make + + +class TestProgressStreamDefaults: + def test_defaults_to_stderr(self, display_cls): + assert display_cls().stream is sys.stderr + + def test_default_is_not_stdout(self, display_cls): + """The whole point: stdout stays clean for protocol traffic.""" + assert display_cls().stream is not sys.stdout + + def test_stream_follows_rebinding(self, display_cls, monkeypatch): + """Resolved per write, so pytest capture and later rebinds are honoured.""" + display = display_cls() + replacement = io.StringIO() + monkeypatch.setattr(sys, "stderr", replacement) + assert display.stream is replacement + + def test_explicit_stream_overrides_the_default(self, display_cls): + buffer = io.StringIO() + assert display_cls(stream=buffer).stream is buffer + + +class TestProgressWritesGoToTheStream: + def test_update_writes_to_the_configured_stream(self, display_cls, make_item): + buffer = io.StringIO() + display = display_cls(stream=buffer, use_emoji=False, update_interval=0.0) + + display.update(make_item()) + + assert buffer.getvalue(), "progress should have been rendered" + + def test_update_writes_nothing_to_stdout(self, display_cls, make_item, monkeypatch): + """Regression guard for #1134: stdout must stay untouched.""" + fake_stdout = io.StringIO() + monkeypatch.setattr(sys, "stdout", fake_stdout) + buffer = io.StringIO() + + display = display_cls(stream=buffer, use_emoji=False, update_interval=0.0) + display.update(make_item()) + display.clear() + + assert fake_stdout.getvalue() == "", ( + f"console progress leaked to stdout: {fake_stdout.getvalue()!r}" + ) + + def test_default_display_writes_nothing_to_stdout( + self, display_cls, make_item, monkeypatch + ): + """Same guard without an explicit stream, i.e. the real default path.""" + fake_stdout = io.StringIO() + fake_stderr = io.StringIO() + monkeypatch.setattr(sys, "stdout", fake_stdout) + monkeypatch.setattr(sys, "stderr", fake_stderr) + + display = display_cls(use_emoji=False, update_interval=0.0) + display.update(make_item()) + + assert fake_stdout.getvalue() == "" + assert fake_stderr.getvalue(), "progress should have gone to stderr" + + def test_clear_flushes_the_stream_not_stdout(self, display_cls, monkeypatch): + flushed = [] + + class RecordingStream(io.StringIO): + def flush(self): + flushed.append("stream") + + class ExplodingStdout(io.StringIO): + def flush(self): # pragma: no cover - fails the test if reached + raise AssertionError("progress must not flush stdout") + + monkeypatch.setattr(sys, "stdout", ExplodingStdout()) + display = display_cls( + stream=RecordingStream(), use_emoji=False, update_interval=0.0 + ) + + display.clear() + + assert flushed == ["stream"] + + +class TestEncodingFallback: + def test_falls_back_when_the_stream_cannot_encode(self, display_cls): + """A cp1252-style console must not raise on emoji; it degrades instead.""" + + class AsciiOnly(io.StringIO): + encoding = "ascii" + + def write(self, text): + text.encode("ascii") # raises UnicodeEncodeError on emoji + return super().write(text) + + buffer = AsciiOnly() + display = display_cls(stream=buffer, use_emoji=True, update_interval=0.0) + + display._safe_write("progress \U0001f504 bar\n") + + assert "progress" in buffer.getvalue() + + +if __name__ == "__main__": + pytest.main([__file__]) From 3448ac06897bd048969afe6fba4166858bbb90cf Mon Sep 17 00:00:00 2001 From: dex0shubham Date: Sat, 29 Aug 2026 15:00:04 +0100 Subject: [PATCH 2/7] test(utils): restore both module bindings in the progress fixture Importing a submodule rebinds it as an attribute of its parent package, so restoring only the sys.modules entry left semantica.utils.progress_tracker and sys.modules['semantica.utils.progress_tracker'] pointing at different objects for every test that ran afterwards. Addresses review feedback on #1254. --- tests/utils/test_progress_stream.py | 38 +++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/tests/utils/test_progress_stream.py b/tests/utils/test_progress_stream.py index 0e851710..aa4e023b 100644 --- a/tests/utils/test_progress_stream.py +++ b/tests/utils/test_progress_stream.py @@ -20,20 +20,44 @@ import pytest @pytest.fixture -def progress_module(monkeypatch): +def progress_module(): """Import the real progress_tracker, bypassing a mocked sys.modules entry. tests/test_extractors_dispatch.py assigns a MagicMock over 'semantica.utils.progress_tracker' at import time and never restores it, so a plain module-level import here returns mocks when that file has already - run. Dropping the cached entry via monkeypatch re-imports the real module - and restores whatever was there afterwards. + run. Dropping the cached entry re-imports the real module. + + Both bindings are restored afterwards: importing a submodule also rebinds it + as an attribute of its parent package, so restoring only the sys.modules + entry would leave `semantica.utils.progress_tracker` and + `sys.modules["semantica.utils.progress_tracker"]` pointing at different + objects for every test that follows. """ name = "semantica.utils.progress_tracker" - monkeypatch.delitem(sys.modules, name, raising=False) - module = importlib.import_module(name) - assert hasattr(module, "__file__"), "expected the real module, got a stand-in" - return module + attr = name.rsplit(".", 1)[1] + parent = importlib.import_module("semantica.utils") + + missing = object() + saved_entry = sys.modules.get(name, missing) + saved_attr = getattr(parent, attr, missing) + + sys.modules.pop(name, None) + try: + module = importlib.import_module(name) + assert hasattr(module, "__file__"), "expected the real module, got a stand-in" + yield module + finally: + if saved_entry is missing: + sys.modules.pop(name, None) + else: + sys.modules[name] = saved_entry + + if saved_attr is missing: + if hasattr(parent, attr): + delattr(parent, attr) + else: + setattr(parent, attr, saved_attr) @pytest.fixture From 5cf59fdd8896bc77c086e7569b6cdaff366c52de Mon Sep 17 00:00:00 2001 From: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:01:16 +0500 Subject: [PATCH 3/7] feat(llms): add first class Anthropic provider wrapper --- docs/guides/llm-integrations.md | 51 ++++++++++++++ semantica/llms/__init__.py | 10 ++- semantica/llms/anthropic.py | 115 ++++++++++++++++++++++++++++++++ tests/test_llm_anthropic.py | 69 +++++++++++++++++++ 4 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 semantica/llms/anthropic.py create mode 100644 tests/test_llm_anthropic.py diff --git a/docs/guides/llm-integrations.md b/docs/guides/llm-integrations.md index c2e70f05..4dc03666 100644 --- a/docs/guides/llm-integrations.md +++ b/docs/guides/llm-integrations.md @@ -28,6 +28,7 @@ The `semantica.llms` module provides a unified interface for connecting to Large ## When To Use / When Not To Use **Use LLM integrations for:** + - Text generation, summarization, and question-answering tasks - Complex reasoning that requires natural language understanding - Structured data extraction from unstructured text @@ -35,6 +36,7 @@ The `semantica.llms` module provides a unified interface for connecting to Large - Tasks where context, ambiguity, or domain knowledge matter **Deterministic tools may be better for:** + - Pattern matching that regular expressions can handle - Simple rule-based classification with clear criteria - Mathematical calculations or statistical analysis @@ -42,6 +44,7 @@ The `semantica.llms` module provides a unified interface for connecting to Large - Data transformations with known logic **A full LLM may be unnecessary for:** + - Simple keyword search or exact string matching - Deterministic workflows with predefined decision trees - High-frequency, low-latency operations where inference overhead matters @@ -143,6 +146,54 @@ risk_data = oai.generate_structured( The default model `gpt-3.5-turbo` is fine for classification and light extraction. Switch to `gpt-4o` for complex multi-step regulatory reasoning or document understanding. +## Anthropic — Complex Reasoning and Structured Extraction + +**Anthropic** provides the Claude model family, built with an emphasis on careful, instruction-following behavior and strong performance on multi-step reasoning, long-document analysis, and code-related tasks. Claude models tend to be more cautious about ambiguous instructions than other providers , useful when the cost of a confidently wrong answer is high. + +The `Anthropic` provider wraps the Claude API. Reach for it when the task involves reasoning through several dependent steps (not just single-turn extraction), when you're processing long source documents that need to stay in context, or when you need schema-validated structured output rather than best-effort JSON. + +```python +from semantica.llms import Anthropic + +claude = Anthropic(model="claude-sonnet-5", api_key="YOUR_ANTHROPIC_KEY") +# api_key falls back to the ANTHROPIC_API_KEY environment variable + +# Always health-check before the first call in a long-running process +if not claude.is_available(): + raise RuntimeError("Anthropic provider unreachable — check ANTHROPIC_API_KEY") + +# Plain generation — multi-step reasoning over a contract clause +verdict = claude.generate( + "A vendor contract has a 30-day termination-for-convenience clause " + "but a 90-day data-return obligation that survives termination. " + "If the customer terminates on day 1, when must vendor-held data " + "be returned? Answer with the date basis only.", + temperature=0.1, +) +print(verdict) +# "Day 120 from termination notice , the 90-day return period runs from +# the termination date (day 30), not from the notice date." + +# Structured, schema-validated output +from pydantic import BaseModel + +class ContractRisk(BaseModel): + clause: str + risk_level: str + days_to_deadline: int + +risk = claude.generate_typed( + "Extract the termination clause risk from: vendor contract, " + "30-day termination for convenience, 90-day post-termination " + "data return obligation.", + schema=ContractRisk, +) +print(risk.risk_level, risk.days_to_deadline) +# "medium" 90 +``` + +Model selection: `claude-haiku` for high-volume classification where cost matters more than depth, `claude-sonnet` as the default for most extraction and reasoning tasks, `claude-opus` when a task genuinely needs the deepest reasoning available and latency/cost are secondary. + ## LiteLLM — One Interface, 100+ Providers **LiteLLM** is a universal adapter that provides a single interface to over 100 different LLM providers, including Anthropic Claude, Azure OpenAI, AWS Bedrock, Google Vertex AI, and local Ollama instances. It acts as a translation layer, converting your unified API calls into provider-specific requests, enabling easy switching between providers without code changes. diff --git a/semantica/llms/__init__.py b/semantica/llms/__init__.py index da00ff93..3f06de81 100644 --- a/semantica/llms/__init__.py +++ b/semantica/llms/__init__.py @@ -10,9 +10,10 @@ Supported Providers: - OpenAI: OpenAI API (GPT-3.5, GPT-4, etc.) - HuggingFaceLLM: HuggingFace Transformers for local LLM inference - LiteLLM: Unified interface to 100+ LLM providers (OpenAI, Anthropic, Groq, Azure, Bedrock, Vertex AI, etc.) + - Anthropic: Anthropic Claude API (Claude sonnet, Opus, Haiku, etc.) Example Usage: - >>> from semantica.llms import Groq, OpenAI, HuggingFaceLLM, LiteLLM + >>> from semantica.llms import Groq, OpenAI, HuggingFaceLLM, LiteLLM, Anthropic >>> >>> # Groq provider >>> groq = Groq(model="llama-3.1-8b-instant", api_key="your-key") @@ -32,6 +33,10 @@ Example Usage: >>> # Or use other providers via LiteLLM >>> llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514") >>> response = llm.generate("Hello, world!") + >>> + >>> # Anthropic provider + >>> claude = Anthropic(model="claude-sonnet-5", api_key="the-key") + >>> response = claude.generate("Hello, world!") Author: Semantica Contributors License: MIT @@ -41,6 +46,7 @@ from .groq import Groq from .openai import OpenAI from .huggingface import HuggingFaceLLM from .litellm import LiteLLM +from .anthropic import Anthropic -__all__ = ["Groq", "OpenAI", "HuggingFaceLLM", "LiteLLM"] +__all__ = ["Groq", "OpenAI", "HuggingFaceLLM", "LiteLLM", "Anthropic"] diff --git a/semantica/llms/anthropic.py b/semantica/llms/anthropic.py new file mode 100644 index 00000000..2983e50e --- /dev/null +++ b/semantica/llms/anthropic.py @@ -0,0 +1,115 @@ +""" +Anthropic LLM Provider + +Wrapper for Anthropic Claude API provider with clean interface +""" + +from typing import Any, Dict, Optional + +from ..semantic_extract.providers import AnthropicProvider +from ..utils.exceptions import ProcessingError +from ..utils.logging import get_logger + +logger = get_logger("llms.anthropic") + +class Anthropic: + """ + Anthropic Claude LLM provider wrapper. + + Provides clean interface to Anthropic's Claude API. + + Example: + >>> from semantica.llms import Anthropic + >>> claude = Anthropic(model="claude-sonnet-5", api_key="the-key") + >>> response = claude.generate("What is API key?") + """ + + def __init__( + self, + model: str = "claude-sonnet-5", + api_key: Optional[str] = None, + **kwargs + ): + """ + Init Anthropic provider. + + Args: + model: Model name(default is sonnet 5) + api_key: Anthropic API key (default: from ANTHROPIC_API_KEY env var) + **kwargs: Addition provider options + """ + self.provider = AnthropicProvider(api_key=api_key, model=model, **kwargs) + self.model = model + self.api_key = api_key + + + def is_available(self) -> bool: + """ Check if Anthropic provider is available""" + return self.provider.is_available() + + def generate(self, prompt: str, **kwargs) -> str: + """ + Generate text from prompt. + + Args: + prompt: Input prompt text + **kwargs: Generation options (temperature, max_tokens, etc.) + + Returns: + Generated text response + + Raises: + ProcessingError: If provider is not available or generation fails + """ + + if not self.is_available(): + raise ProcessingError( + "Anthropic provider not available. set ANTHROPIC_API_KEY or pass api_key." + ) + return self.provider.generate(prompt, **kwargs) + + def generate_structured(self, prompt: str, **kwargs) -> Dict [str, Any]: + """ + Generates structured JSON output. + + Args: + prompt: Input prompt text + **kwargs: Generation options + + Returns: + An instance of `schema`, populated from the model's response + + Raises: + ProcessingError: If provider is not available or generation fails + """ + if not self.is_available(): + raise ProcessingError( + "Anthropic provider not available. Set ANTHROPIC_API_KEY or pass api_key." + ) + return self.provider.generate_structured(prompt, **kwargs) + + def generate_typed(self, prompt: str, schema: Any, max_retries: int =3, **kwargs) -> Any: + """ + Generate output validated against a Pydantic schema. + + Args: + prompt: Input prompt text + schema: Pydantic model class to validate the output against + max_retries: Number of retries if validation fails (default: 3) + **kwargs: Generation options + + Returns: + An instance of `schema`, populated from model's reponse + + Raises: + ProcessingError: If provider is not available or generation fails + """ + + if not self.is_available(): + raise ProcessingError( + "Anthropic provider not available. Set ANTHROPIC_API_KEY or pass api_key." + ) + return self.provider.generate_typed(prompt, schema, max_retries=max_retries, **kwargs) + + + diff --git a/tests/test_llm_anthropic.py b/tests/test_llm_anthropic.py new file mode 100644 index 00000000..d61979b2 --- /dev/null +++ b/tests/test_llm_anthropic.py @@ -0,0 +1,69 @@ +"""Tests for the Anthropic LLM provider wrapper (semantica.llms.Anthropic).""" + +from unittest.mock import MagicMock + +import pytest + +from semantica.llms import Anthropic +from semantica.utils.exceptions import ProcessingError + + +def test_construction_stores_model_and_api_key(): + """Anthropic(...) should not crash and should remember what it was given.""" + claude = Anthropic(model="claude-sonnet-5", api_key="fake-key") + assert claude.model == "claude-sonnet-5" + assert claude.api_key == "fake-key" + + +def test_is_available_false_with_no_key(): + """Without a real key/package, is_available() must be a real False, not truthy junk.""" + claude = Anthropic(api_key=None) + assert claude.is_available() is False + + +def test_generate_raises_clear_error_when_unavailable(): + """generate() must fail loudly.""" + claude = Anthropic(api_key=None) + with pytest.raises(ProcessingError, match="Anthropic provider not available"): + claude.generate("hello") + + +def test_generate_forwards_to_the_real_provider_when_available(): + """When available, generate() must actually call through to the real provider.""" + claude = Anthropic(api_key="fake-key") + + claude.provider = MagicMock() + claude.provider.is_available.return_value = True + claude.provider.generate.return_value = "a fake response" + + result = claude.generate("hello", temperature=0.5) + + assert result == "a fake response" + claude.provider.generate.assert_called_once_with("hello", temperature=0.5) + + +def test_generate_structured_forwards_to_the_real_provider(): + claude = Anthropic(api_key="fake-key") + claude.provider = MagicMock() + claude.provider.is_available.return_value = True + claude.provider.generate_structured.return_value = {"key": "value"} + + result = claude.generate_structured("hello") + + assert result == {"key": "value"} + claude.provider.generate_structured.assert_called_once_with("hello") + + +def test_generate_typed_forwards_schema_and_max_retries(): + claude = Anthropic(api_key="fake-key") + claude.provider = MagicMock() + claude.provider.is_available.return_value = True + fake_schema = object() + claude.provider.generate_typed.return_value = "typed result" + + result = claude.generate_typed("hello", fake_schema, max_retries=5) + + assert result == "typed result" + claude.provider.generate_typed.assert_called_once_with( + "hello", fake_schema, max_retries=5 + ) \ No newline at end of file From ca7f743dabff7e1c3d1a68e22347b0a333f23dcc Mon Sep 17 00:00:00 2001 From: Zohaib Hassnain <109234410+ZohaibHassan16@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:15:19 +0500 Subject: [PATCH 4/7] fix(llms): address Qodo review findings on Anthropic wrapper --- docs/guides/llm-integrations.md | 18 +++++++++++------- semantica/llms/__init__.py | 2 +- semantica/llms/anthropic.py | 17 +++++++++-------- tests/test_llm_anthropic.py | 15 +++++++++++---- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/docs/guides/llm-integrations.md b/docs/guides/llm-integrations.md index 4dc03666..78b32df4 100644 --- a/docs/guides/llm-integrations.md +++ b/docs/guides/llm-integrations.md @@ -148,21 +148,25 @@ The default model `gpt-3.5-turbo` is fine for classification and light extractio ## Anthropic — Complex Reasoning and Structured Extraction -**Anthropic** provides the Claude model family, built with an emphasis on careful, instruction-following behavior and strong performance on multi-step reasoning, long-document analysis, and code-related tasks. Claude models tend to be more cautious about ambiguous instructions than other providers , useful when the cost of a confidently wrong answer is high. +**Anthropic** provides the Claude model family, built with an emphasis on careful, instruction-following behavior and strong performance on multi-step reasoning, long-document analysis, and code-related tasks. Claude models tend to be more cautious about ambiguous instructions than other providers. That matters when the cost of a confidently wrong answer is high. The `Anthropic` provider wraps the Claude API. Reach for it when the task involves reasoning through several dependent steps (not just single-turn extraction), when you're processing long source documents that need to stay in context, or when you need schema-validated structured output rather than best-effort JSON. +Install with `pip install "semantica[llm-anthropic]"` (or just `pip install anthropic`) before using this provider. + ```python from semantica.llms import Anthropic -claude = Anthropic(model="claude-sonnet-5", api_key="YOUR_ANTHROPIC_KEY") +claude = Anthropic(model="claude-3-sonnet-20240229", api_key="YOUR_ANTHROPIC_KEY") # api_key falls back to the ANTHROPIC_API_KEY environment variable -# Always health-check before the first call in a long-running process +# is_available() only confirms a client was constructed from some key. +# It does not validate the key or check network reachability - an +# invalid or expired key still passes this check and fails at generate(). if not claude.is_available(): - raise RuntimeError("Anthropic provider unreachable — check ANTHROPIC_API_KEY") + raise RuntimeError("Anthropic provider not configured - set ANTHROPIC_API_KEY") -# Plain generation — multi-step reasoning over a contract clause +# Plain generation - multi-step reasoning over a contract clause verdict = claude.generate( "A vendor contract has a 30-day termination-for-convenience clause " "but a 90-day data-return obligation that survives termination. " @@ -171,7 +175,7 @@ verdict = claude.generate( temperature=0.1, ) print(verdict) -# "Day 120 from termination notice , the 90-day return period runs from +# "Day 120 from termination notice. The 90-day return period runs from # the termination date (day 30), not from the notice date." # Structured, schema-validated output @@ -192,7 +196,7 @@ print(risk.risk_level, risk.days_to_deadline) # "medium" 90 ``` -Model selection: `claude-haiku` for high-volume classification where cost matters more than depth, `claude-sonnet` as the default for most extraction and reasoning tasks, `claude-opus` when a task genuinely needs the deepest reasoning available and latency/cost are secondary. +Model selection follows the same tier structure as the other providers: a Haiku model for high-volume classification where cost matters more than depth, a Sonnet model as the default for most extraction and reasoning tasks, an Opus model when a task genuinely needs the deepest reasoning available and latency/cost are secondary. Check Anthropic's docs for the current model identifiers, since they're versioned and change over time. ## LiteLLM — One Interface, 100+ Providers diff --git a/semantica/llms/__init__.py b/semantica/llms/__init__.py index 3f06de81..390a9e8d 100644 --- a/semantica/llms/__init__.py +++ b/semantica/llms/__init__.py @@ -35,7 +35,7 @@ Example Usage: >>> response = llm.generate("Hello, world!") >>> >>> # Anthropic provider - >>> claude = Anthropic(model="claude-sonnet-5", api_key="the-key") + >>> claude = Anthropic(model="claude-3-sonnet-20240229", api_key="the-key") >>> response = claude.generate("Hello, world!") Author: Semantica Contributors diff --git a/semantica/llms/anthropic.py b/semantica/llms/anthropic.py index 2983e50e..6af86854 100644 --- a/semantica/llms/anthropic.py +++ b/semantica/llms/anthropic.py @@ -4,7 +4,7 @@ Anthropic LLM Provider Wrapper for Anthropic Claude API provider with clean interface """ -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Union from ..semantic_extract.providers import AnthropicProvider from ..utils.exceptions import ProcessingError @@ -20,13 +20,13 @@ class Anthropic: Example: >>> from semantica.llms import Anthropic - >>> claude = Anthropic(model="claude-sonnet-5", api_key="the-key") + >>> claude = Anthropic(model="claude-3-sonnet-20240229", api_key="the-key") >>> response = claude.generate("What is API key?") """ def __init__( self, - model: str = "claude-sonnet-5", + model: str = "claude-3-sonnet-20240229", api_key: Optional[str] = None, **kwargs ): @@ -34,7 +34,7 @@ class Anthropic: Init Anthropic provider. Args: - model: Model name(default is sonnet 5) + model: Model name (default: claude-3-sonnet-20240229) api_key: Anthropic API key (default: from ANTHROPIC_API_KEY env var) **kwargs: Addition provider options """ @@ -68,17 +68,18 @@ class Anthropic: ) return self.provider.generate(prompt, **kwargs) - def generate_structured(self, prompt: str, **kwargs) -> Dict [str, Any]: + def generate_structured(self, prompt: str, **kwargs) -> Union[Dict[str, Any], List[Any]]: """ Generates structured JSON output. Args: prompt: Input prompt text **kwargs: Generation options - + Returns: - An instance of `schema`, populated from the model's response - + Parsed JSON response. A dict for a top-level JSON object, or a + list if the model returns a top-level JSON array. + Raises: ProcessingError: If provider is not available or generation fails """ diff --git a/tests/test_llm_anthropic.py b/tests/test_llm_anthropic.py index d61979b2..847db5d5 100644 --- a/tests/test_llm_anthropic.py +++ b/tests/test_llm_anthropic.py @@ -10,13 +10,20 @@ from semantica.utils.exceptions import ProcessingError def test_construction_stores_model_and_api_key(): """Anthropic(...) should not crash and should remember what it was given.""" - claude = Anthropic(model="claude-sonnet-5", api_key="fake-key") - assert claude.model == "claude-sonnet-5" + claude = Anthropic(model="claude-3-sonnet-20240229", api_key="fake-key") + assert claude.model == "claude-3-sonnet-20240229" assert claude.api_key == "fake-key" -def test_is_available_false_with_no_key(): - """Without a real key/package, is_available() must be a real False, not truthy junk.""" +def test_is_available_false_with_no_key(monkeypatch): + """Without a real key, is_available() must be a real False, not truthy junk. + + api_key=None alone isn't enough to prove this: AnthropicProvider falls + back to the ANTHROPIC_API_KEY environment variable, so this test has to + clear it too or it would pass/fail depending on whoever's machine or CI + runner happens to run it. + """ + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) claude = Anthropic(api_key=None) assert claude.is_available() is False From 9d98eedaa15f6c3f900d96d7fc4c1c4d49eb7183 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Sun, 30 Aug 2026 13:33:18 +0530 Subject: [PATCH 5/7] fix(llms): replace retired default model and tidy Anthropic wrapper claude-3-sonnet-20240229 was retired 2025-07-21, so the wrapper's default model and every copy-paste doc example would fail at generate() time out of the box. Switch to claude-sonnet-4-6 everywhere (wrapper default, __init__ docstring, docs guide, tests). Also cleans up leftover docstring typos/spacing from the previous review pass and adds unavailable-path test coverage for generate_structured()/generate_typed() to match generate(), clearing ANTHROPIC_API_KEY in those tests so they don't flake on a runner that has a real key set. --- docs/guides/llm-integrations.md | 2 +- semantica/llms/__init__.py | 2 +- semantica/llms/anthropic.py | 37 ++++++++++++++------------------- tests/test_llm_anthropic.py | 33 +++++++++++++++++++++++------ 4 files changed, 45 insertions(+), 29 deletions(-) diff --git a/docs/guides/llm-integrations.md b/docs/guides/llm-integrations.md index 78b32df4..fa8d9483 100644 --- a/docs/guides/llm-integrations.md +++ b/docs/guides/llm-integrations.md @@ -157,7 +157,7 @@ Install with `pip install "semantica[llm-anthropic]"` (or just `pip install anth ```python from semantica.llms import Anthropic -claude = Anthropic(model="claude-3-sonnet-20240229", api_key="YOUR_ANTHROPIC_KEY") +claude = Anthropic(model="claude-sonnet-4-6", api_key="YOUR_ANTHROPIC_KEY") # api_key falls back to the ANTHROPIC_API_KEY environment variable # is_available() only confirms a client was constructed from some key. diff --git a/semantica/llms/__init__.py b/semantica/llms/__init__.py index 390a9e8d..f36aff24 100644 --- a/semantica/llms/__init__.py +++ b/semantica/llms/__init__.py @@ -35,7 +35,7 @@ Example Usage: >>> response = llm.generate("Hello, world!") >>> >>> # Anthropic provider - >>> claude = Anthropic(model="claude-3-sonnet-20240229", api_key="the-key") + >>> claude = Anthropic(model="claude-sonnet-4-6", api_key="the-key") >>> response = claude.generate("Hello, world!") Author: Semantica Contributors diff --git a/semantica/llms/anthropic.py b/semantica/llms/anthropic.py index 6af86854..573d761b 100644 --- a/semantica/llms/anthropic.py +++ b/semantica/llms/anthropic.py @@ -12,6 +12,7 @@ from ..utils.logging import get_logger logger = get_logger("llms.anthropic") + class Anthropic: """ Anthropic Claude LLM provider wrapper. @@ -20,31 +21,30 @@ class Anthropic: Example: >>> from semantica.llms import Anthropic - >>> claude = Anthropic(model="claude-3-sonnet-20240229", api_key="the-key") + >>> claude = Anthropic(model="claude-sonnet-4-6", api_key="the-key") >>> response = claude.generate("What is API key?") """ def __init__( - self, - model: str = "claude-3-sonnet-20240229", - api_key: Optional[str] = None, - **kwargs + self, + model: str = "claude-sonnet-4-6", + api_key: Optional[str] = None, + **kwargs ): """ - Init Anthropic provider. + Initialize Anthropic provider. Args: - model: Model name (default: claude-3-sonnet-20240229) + model: Model name (default: claude-sonnet-4-6) api_key: Anthropic API key (default: from ANTHROPIC_API_KEY env var) - **kwargs: Addition provider options + **kwargs: Additional provider options """ self.provider = AnthropicProvider(api_key=api_key, model=model, **kwargs) self.model = model self.api_key = api_key - def is_available(self) -> bool: - """ Check if Anthropic provider is available""" + """Check if Anthropic provider is available.""" return self.provider.is_available() def generate(self, prompt: str, **kwargs) -> str: @@ -61,10 +61,9 @@ class Anthropic: Raises: ProcessingError: If provider is not available or generation fails """ - if not self.is_available(): raise ProcessingError( - "Anthropic provider not available. set ANTHROPIC_API_KEY or pass api_key." + "Anthropic provider not available. Set ANTHROPIC_API_KEY or pass api_key." ) return self.provider.generate(prompt, **kwargs) @@ -89,28 +88,24 @@ class Anthropic: ) return self.provider.generate_structured(prompt, **kwargs) - def generate_typed(self, prompt: str, schema: Any, max_retries: int =3, **kwargs) -> Any: + def generate_typed(self, prompt: str, schema: Any, max_retries: int = 3, **kwargs) -> Any: """ Generate output validated against a Pydantic schema. - + Args: prompt: Input prompt text schema: Pydantic model class to validate the output against max_retries: Number of retries if validation fails (default: 3) **kwargs: Generation options - + Returns: - An instance of `schema`, populated from model's reponse + An instance of `schema`, populated from the model's response Raises: ProcessingError: If provider is not available or generation fails """ - if not self.is_available(): raise ProcessingError( - "Anthropic provider not available. Set ANTHROPIC_API_KEY or pass api_key." + "Anthropic provider not available. Set ANTHROPIC_API_KEY or pass api_key." ) return self.provider.generate_typed(prompt, schema, max_retries=max_retries, **kwargs) - - - diff --git a/tests/test_llm_anthropic.py b/tests/test_llm_anthropic.py index 847db5d5..cc72b73f 100644 --- a/tests/test_llm_anthropic.py +++ b/tests/test_llm_anthropic.py @@ -10,8 +10,8 @@ from semantica.utils.exceptions import ProcessingError def test_construction_stores_model_and_api_key(): """Anthropic(...) should not crash and should remember what it was given.""" - claude = Anthropic(model="claude-3-sonnet-20240229", api_key="fake-key") - assert claude.model == "claude-3-sonnet-20240229" + claude = Anthropic(model="claude-sonnet-4-6", api_key="fake-key") + assert claude.model == "claude-sonnet-4-6" assert claude.api_key == "fake-key" @@ -28,8 +28,13 @@ def test_is_available_false_with_no_key(monkeypatch): assert claude.is_available() is False -def test_generate_raises_clear_error_when_unavailable(): - """generate() must fail loudly.""" +def test_generate_raises_clear_error_when_unavailable(monkeypatch): + """generate() must fail loudly. + + Clears ANTHROPIC_API_KEY for the same reason as test_is_available_false_with_no_key: + otherwise this test flakes depending on whether the runner's environment has a key set. + """ + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) claude = Anthropic(api_key=None) with pytest.raises(ProcessingError, match="Anthropic provider not available"): claude.generate("hello") @@ -65,7 +70,7 @@ def test_generate_typed_forwards_schema_and_max_retries(): claude = Anthropic(api_key="fake-key") claude.provider = MagicMock() claude.provider.is_available.return_value = True - fake_schema = object() + fake_schema = object() claude.provider.generate_typed.return_value = "typed result" result = claude.generate_typed("hello", fake_schema, max_retries=5) @@ -73,4 +78,20 @@ def test_generate_typed_forwards_schema_and_max_retries(): assert result == "typed result" claude.provider.generate_typed.assert_called_once_with( "hello", fake_schema, max_retries=5 - ) \ No newline at end of file + ) + + +def test_generate_structured_raises_clear_error_when_unavailable(monkeypatch): + """generate_structured() must fail loudly, same as generate().""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + claude = Anthropic(api_key=None) + with pytest.raises(ProcessingError, match="Anthropic provider not available"): + claude.generate_structured("hello") + + +def test_generate_typed_raises_clear_error_when_unavailable(monkeypatch): + """generate_typed() must fail loudly, same as generate() and generate_structured().""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + claude = Anthropic(api_key=None) + with pytest.raises(ProcessingError, match="Anthropic provider not available"): + claude.generate_typed("hello", object()) From cac6dfbe45e080797b9f6d03dc447eeb932bdba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=9E=97?= <2281216234@qq.com> Date: Sun, 30 Aug 2026 18:16:15 +0800 Subject: [PATCH 6/7] fix(explorer): surface server error detail in graph loading failures (#1260) The nodes/edges fetch loops were throwing away the response body whenever the request returned a non-OK status. Because of that, errors like a `503` caused by a missing `SEMANTICA_API_KEY` only showed up as: `Fetch failed: 503` even though the backend was already returning a more useful message in the response `detail`. This change reads the JSON error body and includes `detail` in the thrown error when it's a string, so `GraphLoadingOverlay` can show the actual backend error to the user. Closes #1256 --- .../workspaces/GraphWorkspace/useLoadGraph.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts b/explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts index f23c563a..cc39df99 100644 --- a/explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts +++ b/explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts @@ -318,6 +318,20 @@ interface EdgeListResponse { const PAGE_LIMIT = 1000; +/** Surface the server's `detail` message (e.g. auth/setup guidance) on non-OK responses. */ +async function fetchErrorDetail(response: Response): Promise { + try { + const body: unknown = await response.json(); + const detail = (body as { detail?: unknown } | null)?.detail; + if (typeof detail === "string" && detail.trim()) { + return ` — ${detail.trim()}`; + } + } catch { + // Non-JSON or unreadable body: fall back to the status-only message. + } + return ""; +} + async function fetchAllNodes( signal: AbortSignal, onProgress?: (progress: GraphLoadProgress) => void, @@ -335,7 +349,7 @@ async function fetchAllNodes( const response = await fetch(url.toString(), { signal }); if (!response.ok) { - throw new Error(`Fetch failed: ${response.status}`); + throw new Error(`Fetch failed: ${response.status}${await fetchErrorDetail(response)}`); } const data: NodeListResponse = await response.json(); @@ -390,7 +404,7 @@ async function fetchAllEdges( const response = await fetch(url.toString(), { signal }); if (!response.ok) { - throw new Error(`Fetch failed: ${response.status}`); + throw new Error(`Fetch failed: ${response.status}${await fetchErrorDetail(response)}`); } const data: EdgeListResponse = await response.json(); From f6cd62411b9849d364edd50ced21c8397a90ee02 Mon Sep 17 00:00:00 2001 From: Shubham Srivastava Date: Sun, 30 Aug 2026 11:23:43 +0100 Subject: [PATCH 7/7] test(integrations): make crewai and langchain test dirs packages (#1252) Both directories contain a test_degradation.py. Neither had an __init__.py, so under pytest's default prepend import mode both modules were imported as plain 'test_degradation' and the second collided with the first: import file mismatch: imported module 'test_degradation' has this __file__ attribute: tests/integrations/crewai/test_degradation.py which is not the same as the test file we want to collect: tests/integrations/langchain/test_degradation.py That aborted collection for tests/integrations/, so the langchain graceful-degradation tests never ran. tests/integrations/__init__.py already exists, and most directories under tests/ carry one; these two subpackages were simply missed. Collection goes from 335 collected, 1 error to 337 collected. Closes #1251 --- tests/integrations/crewai/__init__.py | 1 + tests/integrations/langchain/__init__.py | 1 + 2 files changed, 2 insertions(+) create mode 100644 tests/integrations/crewai/__init__.py create mode 100644 tests/integrations/langchain/__init__.py diff --git a/tests/integrations/crewai/__init__.py b/tests/integrations/crewai/__init__.py new file mode 100644 index 00000000..ff5c7e0b --- /dev/null +++ b/tests/integrations/crewai/__init__.py @@ -0,0 +1 @@ +# tests/integrations/crewai package diff --git a/tests/integrations/langchain/__init__.py b/tests/integrations/langchain/__init__.py new file mode 100644 index 00000000..c78b7fb4 --- /dev/null +++ b/tests/integrations/langchain/__init__.py @@ -0,0 +1 @@ +# tests/integrations/langchain package