* Extract session pipeline framework, refactor verification, add UsageProcessor skeleton Pluggable framework under services/session_pipeline/ (contract + lib + per-processor runner) so multiple processors can read /data/user_sessions/<key>/*.jsonl on their own cadence with full failure isolation. Verification flow becomes the first plugin; a no-op UsageProcessor reserves the second slot pending a separate brainstorm on extraction logic + storage shape. Schema v28→v29: rename session_extraction_state → session_processor_state with composite PK (processor_name, session_file). Existing rows copied over with processor_name='verification'; legacy table dropped. Migration is idempotent and no-ops the copy step on fresh installs that came up at the new schema. Endpoint: /api/admin/run-verification-detector replaced by parametrized /api/admin/run-session-processor?processor=<name>. Audit action format follows. Scheduler JOBS: verification-detector entry split into session-processor:verification + session-processor:usage. SCHEDULER_VERIFICATION_DETECTOR_INTERVAL retained for operator compatibility (drives both cadence and health-check grace window); SCHEDULER_USAGE_PROCESSOR_INTERVAL added. * Address PR #232 review: scan dead branch + per-processor lock - `SessionProcessorStateRepository.scan_unprocessed_for` dead else: both branches surfaced every jsonl, the SELECT was unused, runner MD5-rehashed every stable session per tick. Replaced with an mtime precheck — stable sessions (mtime <= processed_at) are filtered at scan; modified files still surface for the runner's authoritative `file_hash` invalidation. Naive-local comparison matches the existing health-check idiom (DuckDB TIMESTAMP strips tz on storage). - Per-processor advisory lock around `_run_processor` in `/api/admin/run-session-processor`. Scheduler tick + manual admin POST could otherwise both run, both call create_evidence on overlapping detections, and accumulate duplicate verification_evidence rows (the dedup short-circuit only covers create+contradiction, not evidence per ADR Decision 3). Non-blocking acquire → 409 Conflict on concurrent invocation; release in finally so a runner exception doesn't wedge the processor. Tests: two new scan unit tests (mtime filter + post-mark mtime bump), 409 endpoint test, lock-released-on-exception test. Two existing tests updated for the new "filtered at scan" stat shape (previously asserted skipped == 1, now scanned == 0). * Address PR #232 review #2: parallel scheduler tick + last_run on terminal state Two pre-existing scaffold bugs in services/scheduler/__main__.py amplified by adding more session-pipeline jobs: 1. Serial for-loop over jobs with synchronous httpx.post(timeout=900) — a 10-minute verification run blocked every other job (data-refresh, health-check, usage, corporate-memory) for the whole window. The PR's stated isolation guarantee held inside the runner but broke at the scheduler dispatch layer. 2. last_run advanced only when _call_api returned True. Permanent-failure jobs hot-looped on every tick (30s) instead of cadence (15min). Fix: ThreadPoolExecutor.submit per due job + per-job in_flight set so a long-running job can't be re-launched on subsequent ticks. last_run advances unconditionally in finally; errors still surface via _call_api logging + audit_log on the receiving side. _run_job extracted to module-level for unit testing. New tests: - TestRunJobBookkeeping: advances on success / failure / unhandled raise - TestRunLoopParallelism: in_flight protection prevents duplicate launches across ticks for a single slow job --------- Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
244 lines
11 KiB
Python
244 lines
11 KiB
Python
"""Regression tests for app.instance_config overlay handling (#179 review).
|
|
|
|
Two paths to a working LLM pipeline must both function:
|
|
|
|
1. Operator hand-edits config/instance.yaml — covered by config.loader's
|
|
existing ``_resolve_env_refs`` pass.
|
|
2. Operator hits /api/admin/configure on first-time setup — that handler
|
|
seeds an ``ai:`` block in the writable overlay at
|
|
``${DATA_DIR}/state/instance.yaml`` referencing ``${ANTHROPIC_API_KEY}``.
|
|
|
|
Path 2 used to be dead code: the three LLM consumers
|
|
(``services.corporate_memory.collector.collect_all``,
|
|
``services.session_processors.verification.build_verification_processor``
|
|
and ``services.verification_detector.__main__``) imported from
|
|
``config.loader.load_instance_config`` (overlay-blind), and even if they
|
|
hadn't, ``app.instance_config.load_instance_config`` deep-merged the
|
|
overlay through raw ``yaml.safe_load`` without resolving ``${ENV_VAR}``
|
|
references. The factory then rejected the literal placeholder string as
|
|
an invalid api_key.
|
|
|
|
This file pins both fixes:
|
|
- env-ref resolution runs against the overlay before deep-merge
|
|
- the three consumers reach the overlay-aware loader
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_instance_cache():
|
|
"""Drop the ``app.instance_config._instance_config`` cache between tests.
|
|
|
|
Without this, a test that pollutes the cache leaks into the next one.
|
|
The same reset endpoint that ``/api/admin/server-config`` uses after
|
|
a save is the supported public entry point.
|
|
"""
|
|
from app import instance_config as ic
|
|
ic.reset_cache()
|
|
yield
|
|
ic.reset_cache()
|
|
|
|
|
|
def _write_overlay(data_dir: Path, payload: dict) -> Path:
|
|
"""Drop a writable overlay at the path the loader actually reads."""
|
|
overlay_path = data_dir / "state" / "instance.yaml"
|
|
overlay_path.parent.mkdir(parents=True, exist_ok=True)
|
|
overlay_path.write_text(yaml.dump(payload))
|
|
return overlay_path
|
|
|
|
|
|
class TestOverlayEnvResolution:
|
|
"""${ENV_VAR} placeholders in the overlay must resolve at load time."""
|
|
|
|
def test_env_ref_in_overlay_resolves_when_env_set(self, tmp_path, monkeypatch):
|
|
"""Overlay's ${ANTHROPIC_API_KEY} resolves to the env value."""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "secret123")
|
|
|
|
_write_overlay(tmp_path, {"ai": {"api_key": "${ANTHROPIC_API_KEY}"}})
|
|
|
|
# Block the static base loader so this test is hermetic — the only
|
|
# signal we care about is that the overlay path resolves the ref.
|
|
with patch("config.loader.load_instance_config", return_value={}):
|
|
from app.instance_config import load_instance_config
|
|
cfg = load_instance_config()
|
|
|
|
assert cfg.get("ai", {}).get("api_key") == "secret123"
|
|
|
|
def test_env_ref_in_overlay_left_unresolved_when_env_missing(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""When the env var isn't set, the placeholder collapses to empty.
|
|
|
|
This mirrors ``_resolve_env_refs``'s contract: missing env logs a
|
|
warning and substitutes an empty string. The LLM factory's separate
|
|
env fallback (ANTHROPIC_API_KEY -> AnthropicExtractor) is what
|
|
ultimately surfaces the actionable error to the operator — this
|
|
test pins that the config layer doesn't fabricate a valid-looking
|
|
key when the env is empty.
|
|
"""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
|
|
_write_overlay(tmp_path, {"ai": {"api_key": "${ANTHROPIC_API_KEY}"}})
|
|
|
|
with patch("config.loader.load_instance_config", return_value={}):
|
|
from app.instance_config import load_instance_config
|
|
cfg = load_instance_config()
|
|
|
|
# Empty string, not the literal "${ANTHROPIC_API_KEY}". The factory
|
|
# treats empty as missing and raises the documented ValueError, so
|
|
# the eventual error message points the operator at the env, not
|
|
# at a malformed YAML.
|
|
assert cfg.get("ai", {}).get("api_key") == ""
|
|
|
|
def test_overlay_deep_merges_with_static_base(self, tmp_path, monkeypatch):
|
|
"""Overlay wins per-leaf; sections only in the base still flow through."""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "k")
|
|
|
|
_write_overlay(tmp_path, {"ai": {"api_key": "${ANTHROPIC_API_KEY}"}})
|
|
|
|
# Static base contributes a section the overlay doesn't touch.
|
|
static_base = {
|
|
"instance": {"name": "Test"},
|
|
"datasets": {"foo": {"id": 1}},
|
|
}
|
|
with patch("config.loader.load_instance_config", return_value=static_base):
|
|
from app.instance_config import load_instance_config
|
|
cfg = load_instance_config()
|
|
|
|
assert cfg["instance"]["name"] == "Test"
|
|
assert cfg["datasets"] == {"foo": {"id": 1}}
|
|
assert cfg["ai"]["api_key"] == "k"
|
|
|
|
|
|
class TestConsumersUseOverlayAwareLoader:
|
|
"""The three LLM pipeline consumers must reach the overlay path."""
|
|
|
|
def test_collector_imports_app_instance_config(self):
|
|
"""``collect_all`` imports load_instance_config from app.instance_config."""
|
|
import inspect
|
|
|
|
from services.corporate_memory.collector import collect_all
|
|
|
|
src = inspect.getsource(collect_all)
|
|
# The overlay-aware loader is the only one that merges
|
|
# DATA_DIR/state/instance.yaml; a consumer that imports
|
|
# config.loader.load_instance_config silently misses overlay edits.
|
|
assert "from app.instance_config import load_instance_config" in src
|
|
assert "from config.loader import load_instance_config" not in src
|
|
|
|
def test_verification_processor_factory_uses_overlay_loader(self):
|
|
"""``build_verification_processor`` imports the overlay-aware loader.
|
|
|
|
Post session-pipeline refactor the LLM extractor is constructed by
|
|
services.session_processors.verification.build_verification_processor
|
|
rather than inline in the admin endpoint."""
|
|
import inspect
|
|
|
|
from services.session_processors.verification import build_verification_processor
|
|
|
|
src = inspect.getsource(build_verification_processor)
|
|
assert "from app.instance_config import load_instance_config" in src
|
|
assert "from config.loader import load_instance_config" not in src
|
|
|
|
def test_verification_detector_main_delegates_to_overlay_factory(self):
|
|
"""The verification-detector CLI main reads through the overlay.
|
|
|
|
Post session-pipeline refactor it does so by delegating to
|
|
``build_verification_processor`` (which is itself overlay-aware,
|
|
verified by ``test_verification_processor_factory_uses_overlay_loader``)
|
|
rather than calling the loader inline. Pin the delegation so a
|
|
future "simplify" refactor doesn't accidentally bypass the factory
|
|
and re-introduce direct ``config.loader`` usage."""
|
|
import inspect
|
|
|
|
from services.verification_detector import __main__ as vd_main
|
|
|
|
src = inspect.getsource(vd_main)
|
|
assert "build_verification_processor" in src
|
|
# Whichever loader the CLI ends up calling, it must NOT be the
|
|
# overlay-blind one from config.loader.
|
|
assert "from config.loader import load_instance_config" not in src
|
|
|
|
|
|
class TestSeededOverlayReachesFactory:
|
|
"""End-to-end: seeded overlay + env -> factory receives a usable api_key."""
|
|
|
|
def test_collector_seeded_overlay_flows_through_to_factory(
|
|
self, tmp_path, monkeypatch,
|
|
):
|
|
"""The seeded ai: block + ANTHROPIC_API_KEY env yields a real extractor.
|
|
|
|
Reproduces the path /api/admin/configure produces on first-time
|
|
setup: an overlay containing only ``ai: {api_key: ${ANTHROPIC_API_KEY}, ...}``
|
|
plus the env var set by the operator. With the #179 review fixes,
|
|
the factory must see the resolved key — without them, it would
|
|
either miss the overlay entirely or get the literal placeholder.
|
|
"""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-from-env")
|
|
|
|
_write_overlay(tmp_path, {
|
|
"ai": {
|
|
"provider": "anthropic",
|
|
"api_key": "${ANTHROPIC_API_KEY}",
|
|
"model": "claude-haiku-4-5-20251001",
|
|
},
|
|
})
|
|
|
|
# Static base empty so only the overlay path matters.
|
|
with patch("config.loader.load_instance_config", return_value={}):
|
|
captured = {}
|
|
|
|
def _spy(ai_config):
|
|
captured["ai_config"] = ai_config
|
|
from unittest.mock import MagicMock
|
|
return MagicMock()
|
|
|
|
from connectors.llm import factory as llm_factory
|
|
|
|
with patch.object(llm_factory, "create_extractor_from_env_or_config", _spy):
|
|
# Re-import via the lazy import inside collect_all by mocking
|
|
# the lookup at the package level (matches how collector imports).
|
|
import connectors.llm as llm_pkg
|
|
with patch.object(
|
|
llm_pkg, "create_extractor_from_env_or_config", _spy,
|
|
):
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
user_dir = home / "alice"
|
|
user_dir.mkdir()
|
|
(user_dir / "CLAUDE.local.md").write_text("hello")
|
|
|
|
from services.corporate_memory.collector import collect_all
|
|
with patch(
|
|
"services.corporate_memory.collector.HOME_BASE", home,
|
|
), patch(
|
|
"services.corporate_memory.collector._read_json",
|
|
return_value={},
|
|
):
|
|
# The factory mock returns a MagicMock extractor whose
|
|
# extract_json default returns a MagicMock — the catalog
|
|
# processing code expects a dict-shaped response. We
|
|
# don't care about post-extractor behavior here, only
|
|
# that the factory was called with the resolved overlay.
|
|
try:
|
|
collect_all(dry_run=True)
|
|
except Exception:
|
|
pass
|
|
|
|
assert captured.get("ai_config") is not None, (
|
|
"Factory was never called — collector did not reach the overlay loader"
|
|
)
|
|
assert captured["ai_config"].get("api_key") == "sk-ant-from-env", (
|
|
"Factory received an unresolved or missing api_key — "
|
|
"overlay env-ref resolution is broken"
|
|
)
|