verbose live UI + tool-level SSE events + Groq default + regression tests
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
60
tests/test_events.py
Normal file
60
tests/test_events.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Regression: event factory shapes (UI relies on these field names)."""
|
||||
|
||||
from api.analyses.types import Finding
|
||||
from api.runtime import events
|
||||
|
||||
|
||||
def test_run_start_shape():
|
||||
e = events.run_start("why?")
|
||||
assert e == {"type": "run_start", "question": "why?"}
|
||||
|
||||
|
||||
def test_run_end_shape():
|
||||
assert events.run_end(answer="42") == {"type": "run_end", "answer": "42", "error": None}
|
||||
assert events.run_end(error="boom") == {"type": "run_end", "answer": "", "error": "boom"}
|
||||
|
||||
|
||||
def test_plan_ready_shape():
|
||||
e = events.plan_ready("rat", [{"analysis": "x", "args": {}, "why": ""}])
|
||||
assert e["type"] == "plan_ready"
|
||||
assert e["rationale"] == "rat"
|
||||
assert isinstance(e["steps"], list)
|
||||
|
||||
|
||||
def test_analysis_start_shape():
|
||||
e = events.analysis_start("direct_answer", {"q": "?"}, "fits")
|
||||
assert e == {
|
||||
"type": "analysis_start",
|
||||
"analysis": "direct_answer",
|
||||
"args": {"q": "?"},
|
||||
"why": "fits",
|
||||
}
|
||||
|
||||
|
||||
def test_analysis_end_uses_finding():
|
||||
f = Finding(analysis="direct_answer", summary="ok", rows=[{"a": 1}], sql=["SELECT 1"])
|
||||
e = events.analysis_end("direct_answer", f)
|
||||
assert e["type"] == "analysis_end"
|
||||
assert e["analysis"] == "direct_answer"
|
||||
assert e["summary"] == "ok"
|
||||
assert e["error"] is None
|
||||
assert e["row_count"] == 1
|
||||
|
||||
|
||||
def test_tool_call_shapes():
|
||||
s = events.tool_call_start("text_to_sql", input={"q": "?"})
|
||||
assert s == {"type": "tool_call_start", "tool": "text_to_sql", "input": {"q": "?"}}
|
||||
e = events.tool_call_end("text_to_sql", output={"sql": "..."})
|
||||
assert e == {"type": "tool_call_end", "tool": "text_to_sql", "output": {"sql": "..."}, "error": None}
|
||||
|
||||
|
||||
def test_synth_start_shape():
|
||||
assert events.synth_start() == {"type": "synth_start"}
|
||||
|
||||
|
||||
def test_sse_format_round_trip():
|
||||
payload = events.tool_call_start("t", input={"x": 1})
|
||||
formatted = events.sse_format(payload)
|
||||
assert formatted.startswith("event: tool_call_start\n")
|
||||
assert "data: " in formatted
|
||||
assert formatted.endswith("\n\n")
|
||||
42
tests/test_imports.py
Normal file
42
tests/test_imports.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Regression: every module imports cleanly.
|
||||
|
||||
Catches things like:
|
||||
- the `api/semantic` → `api/datasets` rename leaving a stale import in schema.py
|
||||
- the `api/tools/observability` → `api/langfuse_client` move
|
||||
- the `api/anthropic_client` → `api/llm` move
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
MODULES = [
|
||||
"api.main",
|
||||
"api.config",
|
||||
"api.langfuse_client",
|
||||
"api.llm",
|
||||
"api.datasets",
|
||||
"api.prompts",
|
||||
"api.tools.db",
|
||||
"api.tools.schema",
|
||||
"api.tools.text_to_sql",
|
||||
"api.tools.execute_sql",
|
||||
"api.tools.types",
|
||||
"api.analyses.base",
|
||||
"api.analyses.types",
|
||||
"api.analyses.direct_answer",
|
||||
"api.analyses.compare_periods",
|
||||
"api.analyses.registry",
|
||||
"api.plan.planner",
|
||||
"api.plan.types",
|
||||
"api.runtime.runner",
|
||||
"api.runtime.events",
|
||||
"api.runtime.state",
|
||||
"api.runtime.context",
|
||||
"ctrl.seed.load_bird",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", MODULES)
|
||||
def test_module_imports(name):
|
||||
importlib.import_module(name)
|
||||
41
tests/test_langfuse_client.py
Normal file
41
tests/test_langfuse_client.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Regression: langfuse_client surface + span context manager behaviour.
|
||||
|
||||
Bugs captured:
|
||||
1. AttributeError: module 'api.langfuse_client' has no attribute 'span'
|
||||
— the module was at the old observability.py shape with `langfuse_span`.
|
||||
2. RuntimeError: generator didn't stop after throw()
|
||||
— the @contextmanager wrapped the user's `with` body in `try/except`
|
||||
and yielded a second value on exception, which @contextmanager rejects.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from api import langfuse_client as lf
|
||||
|
||||
|
||||
def test_public_surface():
|
||||
assert callable(lf.span)
|
||||
assert callable(lf.flush)
|
||||
assert callable(lf.get_client)
|
||||
|
||||
|
||||
def test_span_returns_nullspan_without_keys(monkeypatch):
|
||||
# Ensure no keys → no client → null span.
|
||||
from api.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "")
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "")
|
||||
# Reset cached client too
|
||||
lf._client = None
|
||||
with lf.span("t") as s:
|
||||
assert isinstance(s, lf._NullSpan)
|
||||
s.update(output={"x": 1}) # no-op should not raise
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_span_body_exception_propagates():
|
||||
"""The body's exception MUST propagate — wrapping it inside @contextmanager
|
||||
causes 'generator didn't stop after throw()' if the except clause yields."""
|
||||
with pytest.raises(ValueError, match="kaboom"):
|
||||
with lf.span("t"):
|
||||
raise ValueError("kaboom")
|
||||
15
tests/test_llm.py
Normal file
15
tests/test_llm.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Regression: LLM dispatcher exposes all three providers and rejects unknowns."""
|
||||
|
||||
import pytest
|
||||
|
||||
from api import llm
|
||||
|
||||
|
||||
def test_providers_registered():
|
||||
assert set(llm._PROVIDERS) == {"groq", "anthropic", "openai"}
|
||||
|
||||
|
||||
def test_unknown_provider_raises(monkeypatch):
|
||||
monkeypatch.setattr(llm.get_settings(), "llm_provider", "definitely-not-a-provider", raising=False)
|
||||
with pytest.raises(ValueError, match="unknown llm_provider"):
|
||||
llm.chat(system="s", user="u")
|
||||
31
tests/test_plan_types.py
Normal file
31
tests/test_plan_types.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Regression: Plan/PlanStep round-trip from_dict / to_dict."""
|
||||
|
||||
from api.plan.types import Plan, PlanStep
|
||||
|
||||
|
||||
def test_planstep_roundtrip():
|
||||
raw = {"analysis": "direct_answer", "args": {"question": "q"}, "why": "fits"}
|
||||
step = PlanStep.from_dict(raw)
|
||||
assert step.analysis == "direct_answer"
|
||||
assert step.args == {"question": "q"}
|
||||
assert step.why == "fits"
|
||||
assert step.to_dict() == raw
|
||||
|
||||
|
||||
def test_planstep_defaults_when_keys_missing():
|
||||
step = PlanStep.from_dict({"analysis": "direct_answer"})
|
||||
assert step.args == {}
|
||||
assert step.why == ""
|
||||
|
||||
|
||||
def test_plan_roundtrip():
|
||||
raw = {
|
||||
"rationale": "single step suffices",
|
||||
"steps": [
|
||||
{"analysis": "direct_answer", "args": {"question": "q"}, "why": "fits"},
|
||||
],
|
||||
}
|
||||
plan = Plan.from_dict(raw)
|
||||
assert plan.rationale == "single step suffices"
|
||||
assert len(plan.steps) == 1
|
||||
assert plan.steps[0].analysis == "direct_answer"
|
||||
30
tests/test_prompts.py
Normal file
30
tests/test_prompts.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Regression: every prompt file loads, render() substitutes placeholders."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api.prompts import PROMPTS_DIR, load, render
|
||||
|
||||
PROMPT_NAMES = sorted(
|
||||
p.stem for p in PROMPTS_DIR.iterdir() if p.suffix == ".txt"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PROMPT_NAMES)
|
||||
def test_prompt_loads(name):
|
||||
text = load(name)
|
||||
assert text # non-empty
|
||||
|
||||
|
||||
def test_render_substitutes():
|
||||
out = render("synthesize.user", question="how many?", findings_block="x: 1")
|
||||
assert "how many?" in out
|
||||
assert "x: 1" in out
|
||||
assert "{question}" not in out and "{findings_block}" not in out
|
||||
|
||||
|
||||
def test_render_unknown_placeholder_raises():
|
||||
# str.format raises KeyError on missing keys
|
||||
with pytest.raises(KeyError):
|
||||
render("synthesize.user", question="q") # missing findings_block
|
||||
46
tests/test_yaml_datasets.py
Normal file
46
tests/test_yaml_datasets.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Regression: dataset YAML files parse and have the expected shape.
|
||||
|
||||
Bug captured: schema_docs.yaml had values that began with a quote character
|
||||
(e.g. `gender: 'M' or 'F'.`), which YAML treats as a quoted scalar — anything
|
||||
after the closing quote is a parse error.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
DATASETS_DIR = Path(__file__).resolve().parent.parent / "api" / "datasets"
|
||||
DATASETS = [p.name for p in DATASETS_DIR.iterdir() if p.is_dir() and not p.name.startswith("_")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dataset", DATASETS)
|
||||
def test_schema_docs_parses(dataset):
|
||||
docs = yaml.safe_load((DATASETS_DIR / dataset / "schema_docs.yaml").read_text())
|
||||
assert docs is not None
|
||||
assert "tables" in docs
|
||||
assert isinstance(docs["tables"], dict) and docs["tables"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dataset", DATASETS)
|
||||
def test_metrics_parses(dataset):
|
||||
metrics = yaml.safe_load((DATASETS_DIR / dataset / "metrics.yaml").read_text())
|
||||
assert metrics is not None
|
||||
assert "metrics" in metrics
|
||||
assert isinstance(metrics["metrics"], dict) and metrics["metrics"]
|
||||
|
||||
|
||||
def test_financial_columns_with_quotes_survive_parse():
|
||||
"""Specific regression: values that contain quoted codes (M, F, OWNER,
|
||||
PRIJEM, etc.) must parse without truncation."""
|
||||
docs = yaml.safe_load(
|
||||
(DATASETS_DIR / "financial" / "schema_docs.yaml").read_text()
|
||||
)
|
||||
cols = docs["tables"]["client"]["columns"]
|
||||
assert '"M"' in cols["gender"] and '"F"' in cols["gender"]
|
||||
|
||||
disp = docs["tables"]["disp"]["columns"]
|
||||
assert '"OWNER"' in disp["type"] and '"DISPONENT"' in disp["type"]
|
||||
|
||||
trans = docs["tables"]["trans"]["columns"]
|
||||
assert '"PRIJEM"' in trans["type"] and '"VYDAJ"' in trans["type"]
|
||||
Reference in New Issue
Block a user