From 74d1fe48aeb4552d1f69aa1e3dc1bba5b353d936 Mon Sep 17 00:00:00 2001 From: Tim Stranske Date: Sat, 28 Feb 2026 21:03:02 -0600 Subject: [PATCH 1/3] Fix LangChain runtime imports and pipeline test stubs --- pyproject.toml | 4 + src/counter_risk/chat/context.py | 4 +- src/counter_risk/chat/providers/base.py | 4 +- .../chat/providers/langchain_runtime.py | 387 ++++++++++++++++++ tests/pipeline/test_run_pipeline.py | 30 +- 5 files changed, 411 insertions(+), 18 deletions(-) create mode 100644 src/counter_risk/chat/providers/langchain_runtime.py diff --git a/pyproject.toml b/pyproject.toml index b9fc2e8a..f58ad2d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,6 +138,10 @@ disable_error_code = ["import-untyped"] module = "counter_risk.pipeline.run" disable_error_code = ["import-untyped"] +[[tool.mypy.overrides]] +module = "counter_risk.chat.context" +disable_error_code = ["import-untyped", "import-not-found"] + [[tool.mypy.overrides]] module = "counter_risk.mosers.workbook_generation" disable_error_code = ["import-untyped"] diff --git a/src/counter_risk/chat/context.py b/src/counter_risk/chat/context.py index 9d7a52be..20b0201a 100644 --- a/src/counter_risk/chat/context.py +++ b/src/counter_risk/chat/context.py @@ -9,7 +9,7 @@ from typing import Any, cast try: - import pyarrow.lib as _pyarrow_lib # type: ignore[import-not-found] + import pyarrow.lib as _pyarrow_lib except (ImportError, ModuleNotFoundError): _PYARROW_IO_ERROR_TYPES: tuple[type[BaseException], ...] = (OSError,) else: @@ -192,7 +192,7 @@ def _load_csv_table(path: Path) -> list[dict[str, Any]]: def _load_parquet_table(path: Path) -> list[dict[str, Any]]: try: - import pandas as pd # type: ignore[import-untyped] + import pandas as pd except (ImportError, ModuleNotFoundError) as exc: raise RunContextError( f"Parquet table found but pandas is unavailable: {path}. " diff --git a/src/counter_risk/chat/providers/base.py b/src/counter_risk/chat/providers/base.py index 9947620e..d16a464f 100644 --- a/src/counter_risk/chat/providers/base.py +++ b/src/counter_risk/chat/providers/base.py @@ -6,15 +6,15 @@ from dataclasses import dataclass from typing import Final, Protocol, cast -from tools.langchain_client import ( +from counter_risk.chat.providers.langchain_runtime import ( PROVIDER_ANTHROPIC, PROVIDER_GITHUB, PROVIDER_OPENAI, build_chat_client, + build_langsmith_metadata, get_provider_model_catalog, missing_provider_dependencies, ) -from tools.llm_provider import build_langsmith_metadata _GITHUB_ENV_KEYS: Final[tuple[str, ...]] = ("GITHUB_TOKEN",) _OPENAI_API_ENV_KEYS: Final[tuple[str, ...]] = ("OPENAI_API_KEY",) diff --git a/src/counter_risk/chat/providers/langchain_runtime.py b/src/counter_risk/chat/providers/langchain_runtime.py new file mode 100644 index 00000000..9989f639 --- /dev/null +++ b/src/counter_risk/chat/providers/langchain_runtime.py @@ -0,0 +1,387 @@ +"""Runtime-safe LangChain helpers for chat providers. + +This module lives under ``src`` so packaged/runtime execution does not depend on +the repository-root ``tools`` package being importable. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Final, cast + +from counter_risk.runtime_paths import resolve_runtime_path + +ENV_PROVIDER = "LANGCHAIN_PROVIDER" +ENV_MODEL = "LANGCHAIN_MODEL" +ENV_TIMEOUT = "LANGCHAIN_TIMEOUT" +ENV_MAX_RETRIES = "LANGCHAIN_MAX_RETRIES" +ENV_SLOT_CONFIG = "LANGCHAIN_SLOT_CONFIG" +ENV_SLOT_PREFIX = "LANGCHAIN_SLOT" +ENV_ANTHROPIC_KEY = "CLAUDE_API_STRANSKE" + +PROVIDER_OPENAI = "openai" +PROVIDER_ANTHROPIC = "anthropic" +PROVIDER_GITHUB = "github-models" + +GITHUB_MODELS_BASE_URL = "https://models.inference.ai.azure.com" +DEFAULT_MODEL = "codex-mini-latest" + +DEFAULT_SLOT_CONFIG_PATH = "config/llm_slots.json" + +LANGCHAIN_OPENAI_DIST = "langchain-openai" +LANGCHAIN_ANTHROPIC_DIST = "langchain-anthropic" + + +def _module_available(module_name: str) -> bool: + return importlib.util.find_spec(module_name) is not None + + +def missing_provider_dependencies(provider: str) -> tuple[str, ...]: + """Return missing package distributions required by *provider*.""" + + normalized = (provider or "").strip().lower() + missing: list[str] = [] + if normalized in {PROVIDER_OPENAI, PROVIDER_GITHUB} and not _module_available( + "langchain_openai" + ): + missing.append(LANGCHAIN_OPENAI_DIST) + if normalized == PROVIDER_ANTHROPIC and not _module_available("langchain_anthropic"): + missing.append(LANGCHAIN_ANTHROPIC_DIST) + return tuple(missing) + + +def _env_int(name: str, default: int) -> int: + value = os.environ.get(name) + if not value: + return default + try: + return int(value) + except ValueError: + return default + + +DEFAULT_TIMEOUT = _env_int(ENV_TIMEOUT, 60) +DEFAULT_MAX_RETRIES = _env_int(ENV_MAX_RETRIES, 2) + + +@dataclass(frozen=True) +class ClientInfo: + client: object + provider: str + model: str + + +@dataclass(frozen=True) +class SlotDefinition: + name: str + provider: str + model: str + + +def _normalize_provider(value: str | None) -> str | None: + if not value: + return None + normalized = value.strip().lower() + if normalized in {"github", "github_models", "github-models"}: + return PROVIDER_GITHUB + if normalized in {"anthropic", "claude"}: + return PROVIDER_ANTHROPIC + if normalized in {"openai"}: + return PROVIDER_OPENAI + return None + + +def _resolve_provider(provider: str | None, *, force_openai: bool) -> tuple[str | None, bool]: + if force_openai: + return PROVIDER_OPENAI, True + if provider is not None: + return _normalize_provider(provider), True + env_provider = os.environ.get(ENV_PROVIDER) + return _normalize_provider(env_provider), bool(env_provider) + + +def _default_slots() -> list[SlotDefinition]: + return [ + SlotDefinition(name="slot1", provider=PROVIDER_OPENAI, model="gpt-5.2"), + SlotDefinition( + name="slot2", provider=PROVIDER_ANTHROPIC, model="claude-sonnet-4-5-20250929" + ), + SlotDefinition(name="slot3", provider=PROVIDER_GITHUB, model=DEFAULT_MODEL), + ] + + +def _load_slot_config() -> list[SlotDefinition]: + config_path = os.environ.get(ENV_SLOT_CONFIG) + path = Path(config_path) if config_path else resolve_runtime_path(DEFAULT_SLOT_CONFIG_PATH) + if not path.is_file(): + return _default_slots() + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return _default_slots() + + slots: list[SlotDefinition] = [] + for idx, entry in enumerate(payload.get("slots", []), start=1): + if not isinstance(entry, dict): + continue + provider = _normalize_provider(str(entry.get("provider", ""))) + model = str(entry.get("model", "")).strip() + if not provider or not model: + continue + name = str(entry.get("name") or f"slot{idx}").strip() or f"slot{idx}" + slots.append(SlotDefinition(name=name, provider=provider, model=model)) + return slots or _default_slots() + + +def _apply_slot_env_overrides(slots: list[SlotDefinition]) -> list[SlotDefinition]: + updated: list[SlotDefinition] = [] + for idx, slot in enumerate(slots, start=1): + provider_key = f"{ENV_SLOT_PREFIX}{idx}_PROVIDER" + model_key = f"{ENV_SLOT_PREFIX}{idx}_MODEL" + provider_override = _normalize_provider(os.environ.get(provider_key)) + model_override = os.environ.get(model_key) + if idx == 1: + model_override = model_override or os.environ.get(ENV_MODEL) + updated.append( + SlotDefinition( + name=slot.name, + provider=provider_override or slot.provider, + model=(model_override or slot.model).strip(), + ) + ) + return updated + + +def _resolve_slots() -> list[SlotDefinition]: + return _apply_slot_env_overrides(_load_slot_config()) + + +def get_provider_model_catalog() -> dict[str, set[str]]: + """Return provider->model mappings from configured slots.""" + + catalog: dict[str, set[str]] = { + PROVIDER_OPENAI: set(), + PROVIDER_ANTHROPIC: set(), + PROVIDER_GITHUB: set(), + } + for slot in _resolve_slots(): + catalog.setdefault(slot.provider, set()).add(slot.model) + if not any(catalog.values()): + for slot in _default_slots(): + catalog.setdefault(slot.provider, set()).add(slot.model) + return catalog + + +_REASONING_MODEL_PATTERN: Final[re.Pattern[str]] = re.compile(r"o[0-9]+(?:-[a-z0-9]+)*") + + +def _is_reasoning_model(model: str) -> bool: + return bool(_REASONING_MODEL_PATTERN.fullmatch(model.lower().strip())) + + +def _build_openai_client( + *, + model: str, + token: str, + timeout: int, + max_retries: int, + base_url: str | None = None, +) -> object | None: + try: + module = importlib.import_module("langchain_openai") + except ImportError: + return None + chat_openai = getattr(module, "ChatOpenAI", None) + if chat_openai is None: + return None + + kwargs: dict[str, object] = { + "model": model, + "api_key": token, + "timeout": timeout, + "max_retries": max_retries, + } + if base_url is not None: + kwargs["base_url"] = base_url + if not _is_reasoning_model(model): + kwargs["temperature"] = 0.1 + try: + return cast(object, chat_openai(**kwargs)) + except Exception: + return None + + +def _build_anthropic_client( + *, model: str, token: str, timeout: int, max_retries: int +) -> object | None: + try: + module = importlib.import_module("langchain_anthropic") + except ImportError: + return None + chat_anthropic = getattr(module, "ChatAnthropic", None) + if chat_anthropic is None: + return None + try: + return cast( + object, + chat_anthropic( + model=model, + anthropic_api_key=token, + temperature=0.1, + timeout=timeout, + max_retries=max_retries, + ), + ) + except Exception: + return None + + +def _build_client_for_provider( + *, + provider: str, + model: str, + timeout: int, + max_retries: int, + github_token: str | None, + openai_token: str | None, + anthropic_token: str | None, +) -> ClientInfo | None: + if provider == PROVIDER_GITHUB and github_token: + client = _build_openai_client( + model=model, + token=github_token, + timeout=timeout, + max_retries=max_retries, + base_url=GITHUB_MODELS_BASE_URL, + ) + if client is not None: + return ClientInfo(client=client, provider=PROVIDER_GITHUB, model=model) + + if provider == PROVIDER_OPENAI and openai_token: + client = _build_openai_client( + model=model, + token=openai_token, + timeout=timeout, + max_retries=max_retries, + ) + if client is not None: + return ClientInfo(client=client, provider=PROVIDER_OPENAI, model=model) + + if provider == PROVIDER_ANTHROPIC and anthropic_token: + client = _build_anthropic_client( + model=model, + token=anthropic_token, + timeout=timeout, + max_retries=max_retries, + ) + if client is not None: + return ClientInfo(client=client, provider=PROVIDER_ANTHROPIC, model=model) + + return None + + +def build_chat_client( + *, + model: str | None = None, + provider: str | None = None, + force_openai: bool = False, + timeout: int | None = None, + max_retries: int | None = None, +) -> ClientInfo | None: + github_token = os.environ.get("GITHUB_TOKEN") + openai_token = os.environ.get("OPENAI_API_KEY") + anthropic_token = os.environ.get(ENV_ANTHROPIC_KEY) + if not github_token and not openai_token and not anthropic_token: + return None + + selected_model = model or os.environ.get(ENV_MODEL) or DEFAULT_MODEL + selected_timeout = DEFAULT_TIMEOUT if timeout is None else timeout + selected_retries = DEFAULT_MAX_RETRIES if max_retries is None else max_retries + + selected_provider, provider_explicit = _resolve_provider(provider, force_openai=force_openai) + if provider_explicit: + if selected_provider is None: + return None + return _build_client_for_provider( + provider=selected_provider, + model=selected_model, + timeout=selected_timeout, + max_retries=selected_retries, + github_token=github_token, + openai_token=openai_token, + anthropic_token=anthropic_token, + ) + + model_override = model or os.environ.get(ENV_MODEL) + used_override = False + for slot in _resolve_slots(): + slot_model = model_override if model_override and not used_override else slot.model + client = _build_client_for_provider( + provider=slot.provider, + model=slot_model, + timeout=selected_timeout, + max_retries=selected_retries, + github_token=github_token, + openai_token=openai_token, + anthropic_token=anthropic_token, + ) + if client is not None: + used_override = True + return client + + return None + + +def build_langsmith_metadata( + *, + operation: str, + repo: str | None = None, + run_id: str | None = None, + issue_or_pr_number: str | None = None, + pr_number: int | None = None, + issue_number: int | None = None, +) -> dict[str, object]: + """Build a LangChain-compatible metadata+tags payload for tracing.""" + + repo_value = repo or os.environ.get("GITHUB_REPOSITORY", "unknown") + run_id_value = ( + run_id or os.environ.get("GITHUB_RUN_ID") or os.environ.get("RUN_ID") or "unknown" + ) + + if issue_or_pr_number is None: + if pr_number is not None: + issue_or_pr_number = str(pr_number) + elif issue_number is not None: + issue_or_pr_number = str(issue_number) + else: + env_pr = os.environ.get("PR_NUMBER", "") + env_issue = os.environ.get("ISSUE_NUMBER", "") + issue_or_pr_number = ( + env_pr if env_pr.isdigit() else env_issue if env_issue.isdigit() else "unknown" + ) + + metadata: dict[str, object] = { + "repo": repo_value, + "run_id": run_id_value, + "issue_or_pr_number": issue_or_pr_number, + "operation": operation, + "pr_number": str(pr_number) if pr_number is not None else None, + "issue_number": str(issue_number) if issue_number is not None else None, + } + if os.environ.get("LANGSMITH_API_KEY"): + metadata["langsmith_project"] = os.environ.get("LANGCHAIN_PROJECT", "workflows-agents") + + tags = [ + "workflows-agents", + f"operation:{operation}", + f"repo:{repo_value}", + f"issue_or_pr:{issue_or_pr_number}", + f"run_id:{run_id_value}", + ] + return {"metadata": metadata, "tags": tags} diff --git a/tests/pipeline/test_run_pipeline.py b/tests/pipeline/test_run_pipeline.py index 1bb01ba4..e1bcb7f3 100644 --- a/tests/pipeline/test_run_pipeline.py +++ b/tests/pipeline/test_run_pipeline.py @@ -1606,7 +1606,7 @@ def test_run_pipeline_writes_risk_outputs_when_proxy_inputs_available( ) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) monkeypatch.setattr( "counter_risk.pipeline.run._write_outputs", @@ -1690,7 +1690,7 @@ def test_run_pipeline_writes_limit_breaches_csv_when_breaches_exist( monkeypatch.setattr("counter_risk.pipeline.run._parse_inputs", lambda _: parsed) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) monkeypatch.setattr( "counter_risk.pipeline.run._write_outputs", @@ -1789,7 +1789,7 @@ def test_run_pipeline_warns_on_missing_limit_entities_by_default( monkeypatch.setattr("counter_risk.pipeline.run._parse_inputs", lambda _: parsed) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) monkeypatch.setattr( "counter_risk.pipeline.run._write_outputs", @@ -1888,7 +1888,7 @@ def test_run_pipeline_strict_missing_limit_entities_fails( monkeypatch.setattr("counter_risk.pipeline.run._parse_inputs", lambda _: parsed) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) monkeypatch.setattr( "counter_risk.pipeline.run._write_outputs", @@ -1932,7 +1932,7 @@ def test_run_pipeline_generates_all_programs_mosers_from_raw_nisa_input( ) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) monkeypatch.setattr( "counter_risk.pipeline.run._write_outputs", @@ -1990,7 +1990,7 @@ def test_run_pipeline_generates_ex_trend_and_trend_mosers_from_raw_nisa_inputs( ) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) monkeypatch.setattr( "counter_risk.pipeline.run._write_outputs", @@ -2056,7 +2056,7 @@ def test_run_pipeline_raw_nisa_generation_produces_parseable_non_vba_workbooks( monkeypatch.setattr( run_module, "_update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) monkeypatch.setattr( run_module, @@ -2377,7 +2377,7 @@ def test_run_pipeline_warn_mode_writes_mapping_updates_and_completes( ) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) monkeypatch.setattr( "counter_risk.pipeline.run._write_outputs", @@ -2487,7 +2487,7 @@ def test_run_pipeline_wraps_output_write_errors( ) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) def _boom(*, run_dir: Path, config: Any, as_of_date: date, warnings: list[str]) -> list[Path]: @@ -2659,9 +2659,10 @@ def _boom( config: Any, parsed_by_variant: dict[str, dict[str, Any]], as_of_date: date, + formatting_profile: str, warnings: list[str], ) -> list[Path]: - _ = (run_dir, config, parsed_by_variant, as_of_date, warnings) + _ = (run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings) raise OSError("historical workbook write failed") monkeypatch.setattr("counter_risk.pipeline.run._update_historical_outputs", _boom) @@ -2684,7 +2685,7 @@ def test_run_pipeline_wraps_manifest_generation_errors( ) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) monkeypatch.setattr( "counter_risk.pipeline.run._write_outputs", @@ -2724,9 +2725,10 @@ def _capture( config: Any, parsed_by_variant: dict[str, dict[str, Any]], as_of_date: date, + formatting_profile: str, warnings: list[str], ) -> list[Path]: - _ = config + _ = (config, formatting_profile) calls.append( { "run_dir": run_dir, @@ -2756,7 +2758,7 @@ def test_run_pipeline_invokes_ppt_link_refresh( ) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) seen: dict[str, Path] = {} @@ -2786,7 +2788,7 @@ def test_run_pipeline_ignores_config_output_root_for_run_directory( ) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, formatting_profile, warnings: [], ) monkeypatch.setattr( "counter_risk.pipeline.run._write_outputs", From 1763c3b8d969735517c56fe9df849737223d783c Mon Sep 17 00:00:00 2001 From: Tim Stranske Date: Sat, 28 Feb 2026 21:28:05 -0600 Subject: [PATCH 2/3] Address PR review feedback on LangChain runtime safety --- .../chat/providers/langchain_runtime.py | 29 +++++++- tests/test_langchain_runtime.py | 66 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 tests/test_langchain_runtime.py diff --git a/src/counter_risk/chat/providers/langchain_runtime.py b/src/counter_risk/chat/providers/langchain_runtime.py index 9989f639..be2f577a 100644 --- a/src/counter_risk/chat/providers/langchain_runtime.py +++ b/src/counter_risk/chat/providers/langchain_runtime.py @@ -24,6 +24,10 @@ ENV_SLOT_CONFIG = "LANGCHAIN_SLOT_CONFIG" ENV_SLOT_PREFIX = "LANGCHAIN_SLOT" ENV_ANTHROPIC_KEY = "CLAUDE_API_STRANSKE" +ENV_LANGSMITH_KEY = "LANGSMITH_API_KEY" +ENV_LANGCHAIN_TRACING_V2 = "LANGCHAIN_TRACING_V2" +ENV_LANGCHAIN_API_KEY = "LANGCHAIN_API_KEY" +ENV_LANGCHAIN_PROJECT = "LANGCHAIN_PROJECT" PROVIDER_OPENAI = "openai" PROVIDER_ANTHROPIC = "anthropic" @@ -33,6 +37,7 @@ DEFAULT_MODEL = "codex-mini-latest" DEFAULT_SLOT_CONFIG_PATH = "config/llm_slots.json" +DEFAULT_LANGCHAIN_PROJECT = "workflows-agents" LANGCHAIN_OPENAI_DIST = "langchain-openai" LANGCHAIN_ANTHROPIC_DIST = "langchain-anthropic" @@ -103,7 +108,8 @@ def _resolve_provider(provider: str | None, *, force_openai: bool) -> tuple[str if provider is not None: return _normalize_provider(provider), True env_provider = os.environ.get(ENV_PROVIDER) - return _normalize_provider(env_provider), bool(env_provider) + normalized_env = _normalize_provider(env_provider) + return normalized_env, normalized_env is not None def _default_slots() -> list[SlotDefinition]: @@ -125,6 +131,8 @@ def _load_slot_config() -> list[SlotDefinition]: payload = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return _default_slots() + if not isinstance(payload, dict): + return _default_slots() slots: list[SlotDefinition] = [] for idx, entry in enumerate(payload.get("slots", []), start=1): @@ -366,6 +374,8 @@ def build_langsmith_metadata( env_pr if env_pr.isdigit() else env_issue if env_issue.isdigit() else "unknown" ) + tracing_enabled = _ensure_langsmith_tracing_env() + metadata: dict[str, object] = { "repo": repo_value, "run_id": run_id_value, @@ -374,8 +384,10 @@ def build_langsmith_metadata( "pr_number": str(pr_number) if pr_number is not None else None, "issue_number": str(issue_number) if issue_number is not None else None, } - if os.environ.get("LANGSMITH_API_KEY"): - metadata["langsmith_project"] = os.environ.get("LANGCHAIN_PROJECT", "workflows-agents") + if tracing_enabled: + metadata["langsmith_project"] = os.environ.get( + ENV_LANGCHAIN_PROJECT, DEFAULT_LANGCHAIN_PROJECT + ) tags = [ "workflows-agents", @@ -385,3 +397,14 @@ def build_langsmith_metadata( f"run_id:{run_id_value}", ] return {"metadata": metadata, "tags": tags} + + +def _ensure_langsmith_tracing_env() -> bool: + api_key = os.environ.get(ENV_LANGSMITH_KEY) + if not api_key: + return False + os.environ.setdefault(ENV_LANGCHAIN_TRACING_V2, "true") + os.environ.setdefault(ENV_LANGCHAIN_PROJECT, DEFAULT_LANGCHAIN_PROJECT) + os.environ.setdefault(ENV_LANGCHAIN_API_KEY, api_key) + os.environ.setdefault(ENV_LANGSMITH_KEY, api_key) + return True diff --git a/tests/test_langchain_runtime.py b/tests/test_langchain_runtime.py new file mode 100644 index 00000000..8c2f9a37 --- /dev/null +++ b/tests/test_langchain_runtime.py @@ -0,0 +1,66 @@ +"""Tests for runtime-safe LangChain provider helpers.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from counter_risk.chat.providers import langchain_runtime as runtime + + +def test_build_chat_client_ignores_invalid_env_provider_and_uses_slot_fallback( + monkeypatch, +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "token") + monkeypatch.setenv(runtime.ENV_PROVIDER, "invalid-provider") + monkeypatch.setattr( + runtime, + "_resolve_slots", + lambda: [ + runtime.SlotDefinition(name="slot1", provider=runtime.PROVIDER_OPENAI, model="gpt-5.2") + ], + ) + + calls: list[tuple[str, str]] = [] + + def _fake_build_client_for_provider(**kwargs): + provider = kwargs["provider"] + model = kwargs["model"] + calls.append((provider, model)) + return runtime.ClientInfo(client=object(), provider=provider, model=model) + + monkeypatch.setattr(runtime, "_build_client_for_provider", _fake_build_client_for_provider) + + client = runtime.build_chat_client() + + assert client is not None + assert client.provider == runtime.PROVIDER_OPENAI + assert calls == [(runtime.PROVIDER_OPENAI, "gpt-5.2")] + + +def test_load_slot_config_falls_back_when_payload_is_not_object( + tmp_path: Path, + monkeypatch, +) -> None: + slot_path = tmp_path / "slots.json" + slot_path.write_text('["not-a-dict"]', encoding="utf-8") + monkeypatch.setenv(runtime.ENV_SLOT_CONFIG, str(slot_path)) + + slots = runtime._load_slot_config() + + assert slots == runtime._default_slots() + + +def test_build_langsmith_metadata_sets_tracing_env_defaults(monkeypatch) -> None: + monkeypatch.setenv(runtime.ENV_LANGSMITH_KEY, "test-key") + monkeypatch.delenv(runtime.ENV_LANGCHAIN_TRACING_V2, raising=False) + monkeypatch.delenv(runtime.ENV_LANGCHAIN_API_KEY, raising=False) + monkeypatch.delenv(runtime.ENV_LANGCHAIN_PROJECT, raising=False) + + payload = runtime.build_langsmith_metadata(operation="counter-risk-chat") + + assert payload["metadata"]["langsmith_project"] == runtime.DEFAULT_LANGCHAIN_PROJECT + assert payload["metadata"]["operation"] == "counter-risk-chat" + assert os.environ[runtime.ENV_LANGCHAIN_TRACING_V2] == "true" + assert os.environ[runtime.ENV_LANGCHAIN_API_KEY] == "test-key" + assert os.environ[runtime.ENV_LANGCHAIN_PROJECT] == runtime.DEFAULT_LANGCHAIN_PROJECT From a2b991c1916a7bd78b8b6783be8dce4636000a10 Mon Sep 17 00:00:00 2001 From: Tim Stranske Date: Sat, 28 Feb 2026 21:34:37 -0600 Subject: [PATCH 3/3] Extend formatting profiles to table PNG renderers --- README.md | 1 + docs/operator_ux_decision.md | 4 ++++ src/counter_risk/renderers/table_png.py | 32 ++++++++++++++++++++++--- tests/renderers/test_table_png.py | 15 ++++++++++++ tests/test_table_png.py | 16 +++++++++---- 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 451e866f..90c0cb75 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ Supported formatting profile values: Current profile application scope: - Historical workbook append rows written by the pipeline. +- CPRS-CH/CPRS-FCM static table PNG renderers when invoked with `formatting_profile`. Legacy packaging path is still available for release validation only: diff --git a/docs/operator_ux_decision.md b/docs/operator_ux_decision.md index c3a2ed5a..f53b59f2 100644 --- a/docs/operator_ux_decision.md +++ b/docs/operator_ux_decision.md @@ -45,6 +45,10 @@ This document defines the operator experience for running the monthly counterpar 7. Click `Open Output Folder` to access produced artifacts. 8. Use `Open Manifest`, `Open Summary`, and `Open PPT Folder` for post-run review. +Formatting profile support currently applies to: +- Historical workbook append rows. +- CPRS table PNG rendering helpers (when that renderer path is used). + ### Runner Backend Command Contract The Excel runner should execute workflow mode, not fixture replay: diff --git a/src/counter_risk/renderers/table_png.py b/src/counter_risk/renderers/table_png.py index 4e8a66bc..21d3160d 100644 --- a/src/counter_risk/renderers/table_png.py +++ b/src/counter_risk/renderers/table_png.py @@ -15,6 +15,8 @@ from pathlib import Path from typing import Any, cast +from counter_risk.formatting import normalize_formatting_profile + RGB = tuple[int, int, int] _SCALE = 2 @@ -182,7 +184,11 @@ def cprs_ch_view_spec() -> dict[str, object]: def render_cprs_ch_png( - exposures_df: object, output_png: Path | str, *, variant: str | None = None + exposures_df: object, + output_png: Path | str, + *, + variant: str | None = None, + formatting_profile: str | None = None, ) -> None: """Render a deterministic CPRS-CH table PNG.""" _render_cprs_table_png( @@ -190,12 +196,17 @@ def render_cprs_ch_png( output_png, layout=_CPRS_CH_LAYOUT, variant=variant, + formatting_profile=formatting_profile, min_rows_by_variant=_CPRS_MIN_ROWS_BY_VARIANT, ) def render_cprs_fcm_png( - exposures_df: object, output_png: Path | str, *, variant: str | None = None + exposures_df: object, + output_png: Path | str, + *, + variant: str | None = None, + formatting_profile: str | None = None, ) -> None: """Render a deterministic CPRS-FCM table PNG.""" _render_cprs_table_png( @@ -203,6 +214,7 @@ def render_cprs_fcm_png( output_png, layout=_CPRS_CH_LAYOUT, variant=variant, + formatting_profile=formatting_profile, min_rows_by_variant=_CPRS_MIN_ROWS_BY_VARIANT, ) @@ -213,11 +225,13 @@ def _render_cprs_table_png( *, layout: _TableLayout, variant: str | None = None, + formatting_profile: str | None = None, min_rows_by_variant: dict[str, int] | None = None, ) -> None: rows = _to_renderable_rows( exposures_df, variant=variant, + formatting_profile=formatting_profile, min_rows_by_variant=min_rows_by_variant, ) destination = Path(output_png) @@ -305,6 +319,7 @@ def _to_renderable_rows( exposures_df: object, *, variant: str | None = None, + formatting_profile: str | None = None, min_rows_by_variant: dict[str, int] | None = None, ) -> list[dict[str, str]]: records = _read_records(exposures_df) @@ -333,6 +348,7 @@ def _to_renderable_rows( raise ValueError(f"exposures_df is missing required columns: {missing}") rendered: list[dict[str, str]] = [] + resolved_profile = normalize_formatting_profile(formatting_profile) for index, record in enumerate(records): normalized: dict[str, str] = {} for column in _TABLE_COLUMNS: @@ -344,12 +360,22 @@ def _to_renderable_rows( continue number = _coerce_number(value, row_index=index, column_name=column.key) - normalized[column.key] = f"{number:,.2f}" + normalized[column.key] = _format_render_number(number, profile=resolved_profile) rendered.append(normalized) return rendered +def _format_render_number(number: float, *, profile: str) -> str: + base = f"{abs(number):,.2f}" + if profile == "currency": + return f"-${base}" if number < 0 else f"${base}" + if profile == "accounting": + return f"(${base})" if number < 0 else f"${base}" + # default/plain + return f"-{base}" if number < 0 else base + + def _normalize_variant_key(variant: str) -> str: normalized = "".join(ch.lower() if ch.isalnum() else "_" for ch in variant).strip("_") while "__" in normalized: diff --git a/tests/renderers/test_table_png.py b/tests/renderers/test_table_png.py index b150008c..17b21065 100644 --- a/tests/renderers/test_table_png.py +++ b/tests/renderers/test_table_png.py @@ -7,6 +7,7 @@ import pytest from counter_risk.renderers.table_png import ( + _to_renderable_rows, cprs_ch_font_spec, cprs_ch_render_backend, cprs_ch_render_backend_notes, @@ -144,6 +145,20 @@ def test_render_cprs_ch_png_malformed_numeric_value_raises(tmp_path: Path) -> No render_cprs_ch_png(bad, output) +def test_to_renderable_rows_formats_currency_profile_with_symbol() -> None: + rows = _to_renderable_rows(_sample_frame(), formatting_profile="currency") + + assert rows[0]["Cash"] == "$125.00" + assert rows[0]["Equity"] == "-$15.00" + + +def test_to_renderable_rows_formats_accounting_profile_with_parentheses() -> None: + rows = _to_renderable_rows(_sample_frame(), formatting_profile="accounting") + + assert rows[0]["Cash"] == "$125.00" + assert rows[0]["Equity"] == "($15.00)" + + def test_render_cprs_fcm_png_none_exposures_df_raises(tmp_path: Path) -> None: output = tmp_path / "none-fcm.png" diff --git a/tests/test_table_png.py b/tests/test_table_png.py index b532d83e..fe18d5be 100644 --- a/tests/test_table_png.py +++ b/tests/test_table_png.py @@ -54,7 +54,7 @@ def test_render_cprs_fcm_png_is_importable_from_renderers_package() -> None: def test_render_cprs_fcm_png_and_ch_png_route_through_shared_internal_helper( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - calls: list[tuple[object, Path | str, str | None]] = [] + calls: list[tuple[object, Path | str, str | None, str | None]] = [] def _fake_helper( exposures_df: object, @@ -62,20 +62,28 @@ def _fake_helper( *, layout: object, variant: str | None = None, + formatting_profile: str | None = None, min_rows_by_variant: dict[str, int] | None = None, ) -> None: - _ = (layout, min_rows_by_variant) - calls.append((exposures_df, output_png, variant)) + _ = (layout, min_rows_by_variant, formatting_profile) + calls.append((exposures_df, output_png, variant, formatting_profile)) monkeypatch.setattr("counter_risk.renderers.table_png._render_cprs_table_png", _fake_helper) frame = _frame_for_variant("all_programs") - render_cprs_ch_png(frame, tmp_path / "ch.png", variant="all_programs") + render_cprs_ch_png( + frame, + tmp_path / "ch.png", + variant="all_programs", + formatting_profile="currency", + ) render_cprs_fcm_png(frame, tmp_path / "fcm.png", variant="all_programs") assert len(calls) == 2 assert calls[0][2] == "all_programs" + assert calls[0][3] == "currency" assert calls[1][2] == "all_programs" + assert calls[1][3] is None @pytest.mark.parametrize("variant", ("all_programs", "ex_trend", "trend"))