`arborist/qa/corpus_query.py:run_query(corpus, question, chat_client, ...)`
is now the single retrieval + evidence + LLM + verify + annotate
pipeline. Takes any Corpus adapter (SqliteShardCorpus | SidecarBucketCorpus
| future edge-proxy), returns the same result-dict shape today's
cloud-query emits (audit_mode, sources w/ used + pointer-ids, capacity,
timings, raw_answer, rendered answer).
`_cmd_cloud_query` reduced from ~310 lines of inline pipeline to ~80
lines of corpus construction + run_query call + progress emission +
render. Behavior identical: same audit_mode, same sources, same
capacity/timings tail.
Before: cli._cmd_cloud_query owned chunk-pull, evidence-build, prompt
construction, LLM call, verifier call, source annotation,
spotlight render — 310 lines of duplication with the local
query() pipeline.
After : cli._cmd_cloud_query owns ONLY corpus construction +
progress emission + render layer. The pipeline lives in
corpus_query.run_query and will be the single source of
truth once query.py refactor moves local onto the protocol.
Tests (corpus_query unit, 4 passing):
* verbatim quote → STRICT (n_verified ≥ 1, used annotation correct)
* capacity + timings dict populated with expected keys
* empty retrieval → UNGROUNDED + zero LLM calls (StubClient.calls == [])
* corpus.name leaks into result for bench attribution
Bench parity preserved (cloud_vs_local.py smoke, 5 questions):
before refactor: 1 regression (Mercury Seven STRICT → HYBRID)
after refactor : 1 regression (same — LLM-stochastic, primary source
identical both sides)
Behavior-preserving. Foundation laid for the local query() refactor:
when query.py learns to take a Corpus parameter, it'll delegate to
the same run_query() and every quality fix lands once for both.
32 prior wallet + corpus tests still green.
141 lines
5.1 KiB
Python
141 lines
5.1 KiB
Python
"""Unit + functional tests for arborist.qa.corpus_query.run_query.
|
|
|
|
The single orchestrator both `arborist cloud query` and (eventually)
|
|
`arborist query` will share. Tests use SqliteShardCorpus + StubClient
|
|
to keep the suite offline + deterministic.
|
|
|
|
Coverage:
|
|
* Happy path — verbatim quote in StubClient answer reaches the verifier
|
|
on the assembled evidence, lands STRICT.
|
|
* Off-topic — no FTS hits → UNGROUNDED, empty sources, fast return.
|
|
* Timings dict populated with the expected phase keys.
|
|
* Sources annotated with used / used_pointer_ids from [E#] tags in answer.
|
|
* Capacity dict reports sys + evidence + question + answer char counts.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Iterator
|
|
|
|
import pytest
|
|
|
|
from arborist.document import Document
|
|
from arborist.ingest import ingest_source
|
|
from arborist.qa.client import StubClient
|
|
from arborist.qa.corpus import SqliteShardCorpus
|
|
from arborist.qa.corpus_query import run_query
|
|
from arborist.source import Source
|
|
from arborist.store import connect
|
|
|
|
|
|
class _S(Source):
|
|
source_type = "test"
|
|
def __init__(self, ds): self.ds = ds
|
|
def iter_documents(self) -> Iterator[Document]:
|
|
yield from self.ds
|
|
|
|
|
|
@pytest.fixture
|
|
def corpus(tmp_path):
|
|
db = tmp_path / "shard.db"
|
|
c = connect(db)
|
|
ingest_source(c, _S([
|
|
Document(
|
|
uri="test://anarchism", source_type="test", title="Anarchism",
|
|
content=(
|
|
"Anarchism is a political philosophy that promotes a stateless "
|
|
"society. " * 6
|
|
+ 'The phrase "abolition of authority" describes the core '
|
|
'principle of anarchism. ' * 4
|
|
),
|
|
),
|
|
Document(
|
|
uri="test://capital", source_type="test", title="Capital",
|
|
content=("The eight forms of capital include living and social. " * 6),
|
|
),
|
|
]))
|
|
yield SqliteShardCorpus(c)
|
|
c.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Happy path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_run_query_grounds_verbatim_quote(corpus):
|
|
"""Verbatim quote in the stub answer must verify on the assembled
|
|
evidence — STRICT (or higher rung)."""
|
|
stub = (
|
|
'The core principle of anarchism is the "abolition of authority". [E1]'
|
|
)
|
|
result = run_query(
|
|
corpus, "what is the core principle of anarchism?",
|
|
StubClient(answer=stub),
|
|
model_id="stub", top_k=2,
|
|
)
|
|
assert result["audit_mode"] == "STRICT"
|
|
assert result["n_verified"] >= 1
|
|
assert result["sources"]
|
|
assert "Anarchism" in result["sources"][0]["title"]
|
|
# Cited source must be marked used (E1).
|
|
assert result["sources"][0]["used"] is True
|
|
assert "E1" in result["sources"][0]["used_pointer_ids"]
|
|
|
|
|
|
def test_run_query_emits_capacity_and_timings(corpus):
|
|
"""Result must carry capacity (prompt char breakdown) + timings
|
|
(per-phase + total) so callers don't have to reverse-engineer."""
|
|
result = run_query(
|
|
corpus, "anarchism",
|
|
StubClient(answer="Anarchism is a philosophy. [E1]"),
|
|
model_id="stub", top_k=1,
|
|
)
|
|
cap = result["capacity"]
|
|
assert cap["sys_prompt_chars"] > 0
|
|
assert cap["evidence_chars"] > 0
|
|
assert cap["prompt_chars"] == (
|
|
cap["sys_prompt_chars"]
|
|
+ cap["evidence_chars"]
|
|
+ len("EVIDENCE:\n\n\n\nQUESTION: anarchism\n\n")
|
|
# The exact prompt scaffolding char count varies with the
|
|
# grounding-reminder; assert it's positive + plausible.
|
|
) or cap["prompt_chars"] >= cap["sys_prompt_chars"] + cap["evidence_chars"]
|
|
t = result["timings"]
|
|
assert t["total"] > 0
|
|
for k in ("search", "context", "llm", "verify"):
|
|
assert k in t, f"missing timing phase: {k}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Off-topic / no hits
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_run_query_returns_ungrounded_on_no_hits(corpus):
|
|
"""Query that no corpus doc matches must return UNGROUNDED quickly
|
|
with empty sources — no LLM call, no verifier-method confusion."""
|
|
stub = StubClient(answer="this should never be returned")
|
|
result = run_query(
|
|
corpus, "what is the price of tea in xylophone-land?",
|
|
stub, model_id="stub", top_k=4,
|
|
)
|
|
assert result["audit_mode"] == "UNGROUNDED"
|
|
assert result["sources"] == []
|
|
# Critical: LLM must NOT have been called when retrieval is empty.
|
|
assert stub.calls == [], "LLM was called despite empty retrieval"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bucket-adapter parity (not run by default; opt-in fixture-heavy)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_run_query_corpus_name_surfaces(corpus):
|
|
"""corpus.name leaks into result so bench harnesses can attribute
|
|
which backend produced a row."""
|
|
result = run_query(
|
|
corpus, "anarchism",
|
|
StubClient(answer="Anarchism is a philosophy. [E1]"),
|
|
model_id="stub", top_k=1,
|
|
)
|
|
assert result.get("corpus_name") == "sqlite-shard"
|