arborist/aborist/qa/query.py
russell@unturf.com 71507f9327
qa: pipe wikitext-base-v1 into LLM context (was verifier-only)
Both ask() (single-doc) and query() (multi-source) now run the
assembled context through aborist.wikitext.to_base() before message
construction. Hermes sees clean prose instead of raw wikitext, which:

- lets the model quote verbatim (raw [[Cloud Strife]] was unquotable
  in fluent prose; the model paraphrased to bare "Cloud Strife" and
  the verifier flagged that as UNGROUNDED)
- shrinks token bills ~43% on Wikipedia chunks (measured: 125,152 →
  71,771 chars on the FF7 main-character query)
- aligns LLM-input with what the verifier already strips, so the
  prose-vs-prose substring check is end-to-end consistent

Gated on policy["base_version"] (= wikitext-base-v1) so:
- governance_policy_hash flows the choice — prior answers cached
  under raw-wikitext policy retain distinct cache_keys, no migration
- operators can opt out (set base_version=None) for debugging
- environments without mwparserfromhell soft-fall to raw wikitext
  with no other behavior change

Tests (4): default policy carries base_version (tripwire), ask() user
messages have wikitext markers stripped, opt-out reverts to raw
wikitext, governance_hash differs with vs without base_version.
2026-04-28 17:08:49 -04:00

894 lines
34 KiB
Python

"""Multi-source corpus Q&A.
Pose a question, the tree finds related cached docs, assembles them as context,
asks Hermes, caches the answer.
The flow:
1. FTS5 search across all shards (chunks_fts can't be UNION'd in views,
so we query each shard's index independently and merge by score).
2. Title-boost rerank: hits whose title contains query tokens get a
score bump. Title is a strong topical signal that BM25 alone misses
(BM25 favors short docs with rare body tokens — Tell_(poker) outranks
Back_to_the_Future without a title boost).
3. Pick top-K distinct documents within a character budget.
4. Compute `context_root` = Merkle root over the sorted source
document_roots — that's the "source" dimension of v9.8's 8-dim
cache_key for this multi-source answer.
5. Cache lookup; hit returns the persisted audit_mode.
6. Miss calls Hermes via the OpenAI-compatible client, then runs the
faithfulness check (`verify_quotes`) — every double-quoted span in
the answer is verbatim-matched against the assembled context.
Result classifies the answer:
STRICT every quote (>=1) verified against context
HYBRID some claims sourced, some emergent (training-derived)
UNGROUNDED no quotes verify — purely emergent
7. Persist record with merkle_proof = {context_root, sources: [...]},
audit_mode, and unverified_quotes (the spans the model produced
that didn't appear in any source — corpus-growth signal).
Per-source proofs are not bundled here (the source roots themselves are
already content-addressed). A verifier asks the shards for any specific
chunk's proof on demand.
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from aborist import (
CANONICALIZATION_VERSION,
CHUNKING_VERSION,
SCHEMA_VERSION,
)
from aborist.compress import unpack_chunk
from aborist.merkle import MerkleTree
from aborist.qa.client import ChatClient
from aborist.qa.concepts import (
has_compare_phrasing,
rivalry_excluded,
synonym_expand,
)
from aborist.qa.keys import (
cache_key,
conversation_hash,
governance_policy_hash,
model_profile_hash,
question_hash,
)
from aborist.qa.verify import verify_quotes
try:
from aborist.wikitext import BASE_VERSION as _WIKITEXT_BASE_VERSION
from aborist.wikitext import to_base as _wikitext_to_base
except ImportError: # pragma: no cover
_WIKITEXT_BASE_VERSION = None
_wikitext_to_base = None
from aborist.search import FTS5Backend
from aborist.store import (
append_audit,
connect,
discover_shards,
transaction,
)
_TITLE_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*")
# Kept in sync with FTS5 stopwords in aborist.search.fts5 — both filter
# the same set of question-shaping words. "tell" leaking into title-LIKE
# search caused "tell me about permacomputer" to pull Tell_(poker), the
# Tell-Tale_Heart movie, Tell_City Indiana, etc.
_TITLE_STOPWORDS = frozenset(
"""
the a an is are was were be been being of to in on at for with by from
as about into through during and or but not no nor so yet too very also just
what who where when why how which this that these those such i you he she
it we they me him her us them do does did have has had can could should
would will may might
tell show describe explain summarize say give list find make please
""".split()
)
def _title_query_tokens(s: str) -> set[str]:
return {
t.lower()
for t in _TITLE_TOKEN_RE.findall(s)
if t.lower() not in _TITLE_STOPWORDS and len(t) > 1
}
def _rerank_by_title(
hits: list,
question: str,
boost: float = 10.0,
) -> list:
"""Boost hits whose title overlaps query tokens. Pure ordering aid."""
qtokens = _title_query_tokens(question)
if not qtokens:
return hits
for h in hits:
if not h.title:
continue
ttokens = _title_query_tokens(h.title.replace("_", " "))
overlap = len(qtokens & ttokens)
if overlap:
h.score += overlap * boost
hits.sort(key=lambda h: -h.score)
return hits
def _filter_by_title_relevance(
hits: list,
question: str,
*,
core_match_roots: set[str] | None = None,
body_density_check: callable | None = None,
fallback_top_n: int = 5,
) -> list:
"""Concept-aware relevance filter with three accept paths:
1. Title-token overlap (after synonym expansion). Strongest signal.
2. TF-IDF core keyword overlap — `core_match_roots` is a precomputed
set of source document_roots whose TF-IDF cores contain query
tokens. Closes the gap for neologisms like "permacomputer" that
never appear in titles but are distinctive enough to be TF-IDF
keywords of conversation bodies.
3. Body density — docs mentioning the query token >= N times pass
even without title or core match. Cheap proxy for "actually about
the topic." `body_density_check(hit)` returns bool.
Rivalry exclusion (Intel-titled docs in AMD queries) still applies on
every accept path.
If all three accept paths together produce nothing, fall back to the
top `fallback_top_n` body-BM25 hits — the LLM gets enough context to
say "I don't know" rather than fabricating from a single tangential
source.
"""
qtokens = _title_query_tokens(question)
if not qtokens:
return hits
accept = synonym_expand(qtokens)
exclude = rivalry_excluded(qtokens, compare_phrasing=has_compare_phrasing(question))
core_roots = core_match_roots or set()
kept: list = []
for h in hits:
ttokens = _title_query_tokens(h.title.replace("_", " ")) if h.title else set()
if exclude & ttokens:
continue # rivalry: opposing-side title, drop it
if accept & ttokens:
kept.append(h)
continue
if h.document_root in core_roots:
kept.append(h)
continue
if body_density_check is not None and body_density_check(h):
kept.append(h)
continue
if not kept:
return hits[: max(1, fallback_top_n)] if hits else []
return kept
DEFAULT_QUERY_POLICY = {
"system_prompt": (
"You are answering a question using ONLY the sources provided below. "
"Each source is delimited by '=== Source: <URI> ===' headers.\n\n"
"GROUNDING RULE (most important):\n"
"For EVERY factual claim in your answer, include a verbatim quote "
"from a source enclosed in double quotes (\"...\"). The quoted span "
"must appear word-for-word in one of the sources. If you cannot "
"find a verbatim quote that supports a claim, do not make the "
"claim. Do not paraphrase inside the quotes.\n\n"
"ATTRIBUTION RULES:\n"
"1. A single source may discuss MULTIPLE products, companies, or "
"competitors. Read carefully and only attribute facts to the entity "
"the source explicitly names for that fact.\n"
"2. If the question asks about ONE specific entity (e.g., 'fastest "
"AMD CPU'), do NOT include facts about competitors (Intel, Pentium) "
"as if they were facts about the asked entity. They are different "
"products even when discussed in the same article.\n"
"3. When referencing model numbers like 'Athlon XP 3200+', remember "
"that AMD's PR-rating numbers (3200+, 2500+) are NOT the clock speed "
"in MHz. Quote the source's own wording for clock speeds rather than "
"interpreting model numbers.\n"
"4. If the sources do not contain the answer, say 'I don't know "
"based on the provided sources.' Do not fall back on background "
"knowledge from training."
),
# Restated rule fired as a user message immediately before the sources
# turn. Hermes (and most instruction-tuned 8B models) follow recent
# user-turn instructions more reliably than a system-turn rule that
# decays under long context. Repetition is not redundancy — it raises
# the prior on the response shape we want.
"grounding_reminder": (
"REMINDER: every factual claim in your reply must be wrapped in "
"double quotes (\"...\") and the quoted span must appear "
"word-for-word in one of the sources. No quote, no claim. "
"Example:\n\n"
" Q: who founded Apple?\n"
" Sources: ...Apple Inc. was founded by Steve Jobs, Steve "
"Wozniak, and Ronald Wayne in 1976...\n"
" A: Apple's founders are named in the source: \"Apple Inc. was "
"founded by Steve Jobs, Steve Wozniak, and Ronald Wayne\".\n\n"
"Now answer the question on the next message."
),
"temperature": 0.1,
"top_p": 1.0,
"max_tokens": 768,
# Entity-path policy for the faithfulness verifier. See
# aborist/qa/verify.py:ENTITY_POLICIES. Default 'proximity' promotes
# to STRICT only if N verified entities cluster within W chars of
# each other in source — separating "source documents these entities
# as a group" (cast list / infobox / roster) from "source mentions
# them incidentally in scattered prose". Lives in policy so changing
# it bumps governance_policy_hash and invalidates cache cleanly.
"entity_policy": "proximity",
"entity_proximity_n": 3,
"entity_proximity_window": 300,
# Strip wikitext markup before the LLM ever sees the context. Lets
# Hermes quote prose verbatim and shrinks token bills (~43% on
# Wikipedia chunks). Bumps governance_policy_hash so prior cached
# answers under raw-wikitext policy stay distinct on lookup. No-op
# if mwparserfromhell isn't installed.
"base_version": _WIKITEXT_BASE_VERSION,
}
@dataclass
class _Hit:
document_root: str
document_uri: str
title: str | None
score: float
shard_path: str
chunk_idx: int
def _search_titles(conn, qtokens: list[str], limit: int) -> list[tuple]:
"""SQL LIKE over documents.title — finds the HTTP article that FTS5
misses because list-pages with many URLs have higher 'http' term
frequency than the actual protocol article. Returns rows shaped
to match the FTS5 hit tuple."""
if not qtokens:
return []
clauses = " OR ".join(["LOWER(title) LIKE ?"] * len(qtokens))
params = [f"%{t.lower()}%" for t in qtokens]
params.append(limit)
rows = conn.execute(
f"SELECT document_root, document_uri, title FROM documents "
f"WHERE {clauses} LIMIT ?",
params,
).fetchall()
return rows
def _docs_with_core_keyword_match(
conn, qtokens: list[str], limit: int
) -> list[tuple]:
"""Find SOURCE docs whose TF-IDF core keywords contain a query token.
TF-IDF cores act as enriched titles: a doc's distinctive low-frequency
terms get distilled into the core's content as comma-separated keywords.
A query for "permacomputer" (a neologism that never makes it into a
title) can match the TF-IDF core of a Grok conversation that mentioned
it, because permacomputer is a rare term that ranks high under TF-IDF.
Returns rows of (document_root, document_uri, title) for the SOURCE docs
(not the cores) — the source is what gets fed to the LLM as context.
"""
if not qtokens:
return []
# Word-boundary match against the comma-separated TF-IDF keyword list.
# Prepending/appending ", " lets one LIKE pattern (`%, token, %`) check
# for the token regardless of its position in the keyword string.
# Without this, naive `LIKE '%intel%'` would match "intelligence",
# "intellectual", "intellivision" — drowning real hits like Pentium_4
# (whose TF-IDF core has "intel" as an exact keyword) in noise.
#
# Per-row `match_count` tallies how many distinct query tokens hit
# this doc's TF-IDF core. Multi-token coverage is a strong relevance
# signal — a doc whose core has "intel" + "cpu" + "faster" beats a
# doc whose only signal is "intel" appearing in its TITLE. The
# caller boosts the score by match_count.
case_clauses = " + ".join(
[
"(CASE WHEN LOWER(', ' || c.content || ', ') LIKE ? THEN 1 ELSE 0 END)"
]
* len(qtokens)
)
where_clauses = " OR ".join(
["LOWER(', ' || c.content || ', ') LIKE ?"] * len(qtokens)
)
patterns = [f"%, {t.lower()}, %" for t in qtokens]
params = patterns + patterns + [limit]
rows = conn.execute(
f"""
SELECT
src.document_root,
src.document_uri,
src.title,
MAX({case_clauses}) AS match_count
FROM chunks c
JOIN documents core ON core.document_root = c.document_root
JOIN derivations der ON der.core_root = core.document_root
JOIN documents src ON src.document_root = der.src_root
WHERE core.source_type LIKE 'core:tfidf-%'
AND ({where_clauses})
GROUP BY src.document_root, src.document_uri, src.title
ORDER BY match_count DESC
LIMIT ?
""",
params,
).fetchall()
return rows
def _body_density_passes(
conn, document_root: str, qtokens: set[str], min_mentions: int = 3
) -> bool:
"""Body-token CO-OCCURRENCE relevance.
Pre-2026-04-27 this counted any token's mentions in the body (so
Intel_8086, with 30 "Intel" mentions, would pass for a query of
{intel, fastest, cpu} despite never mentioning "fastest"). The
revised gate requires at least HALF the query tokens to each appear
at least once, AND the total mention count to clear `min_mentions`.
For a 1-token query (e.g. {anarchism}) this simplifies to "the body
must mention the token at least min_mentions times" — same as before.
For multi-token queries, both signals must hold: breadth (most
tokens present) and depth (enough total mentions).
"""
if not qtokens:
return False
rows = conn.execute(
"SELECT content FROM chunks WHERE document_root = ? AND content IS NOT NULL",
(document_root,),
).fetchall()
if not rows:
return False
body = " ".join(unpack_chunk(r["content"]) or "" for r in rows).lower()
counts = {t: body.count(t.lower()) for t in qtokens}
distinct_present = sum(1 for n in counts.values() if n > 0)
total_mentions = sum(counts.values())
breadth_threshold = max(1, (len(qtokens) + 1) // 2)
return distinct_present >= breadth_threshold and total_mentions >= min_mentions
def _search_corpus(
shards_dir: Path | None,
single_db: Path | None,
question: str,
over_fetch: int,
) -> list[_Hit]:
"""Two parallel searches across shards, merged:
- FTS5 body search (BM25 over chunk content).
- SQL title-LIKE search (catches articles whose body is short on the
query terms but whose title is the literal topic — e.g., the HTTP
protocol article doesn't out-frequency-score URL-heavy list pages
but it IS the topic).
Dedupe by document_root. Title hits get a baseline score that
out-ranks FTS5 body hits so the actual topic article rises to the top.
"""
qtokens = _title_query_tokens(question)
# Synonym expansion: a query for "athlon" also fetches AMD-titled docs.
accept_tokens = synonym_expand(qtokens)
paths: list[Path]
if shards_dir is not None:
paths = discover_shards(shards_dir)
elif single_db is not None:
paths = [Path(single_db)]
else:
paths = []
raw: list[tuple] = []
# Roots whose TF-IDF cores contain a query token — collected across
# shards. Used downstream by _filter_by_title_relevance as accept-path 2.
core_match_roots: set[str] = set()
# Per-shard mapping of doc root -> shard path, for body-density lookups
# in accept-path 3. Lets us reach back to the source shard cheaply.
root_to_shard: dict[str, str] = {}
for p in paths:
conn = connect(p)
try:
backend = FTS5Backend(conn)
for h in backend.search(question, limit=over_fetch):
raw.append(
(
h.score,
h.document_root,
h.document_uri,
h.title,
h.chunk_idx,
str(p.resolve()),
)
)
root_to_shard[h.document_root] = str(p.resolve())
# Parallel title search using synonym-expanded tokens.
# The score starts deliberately low so this signal can't drown
# FTS5 BM25 + body relevance. _rerank_by_title later adds
# `overlap*10` to every hit (FTS5 and title-search alike) that
# has title-token overlap, so a doc whose title genuinely IS
# the topic ends up rewarded twice (once here, once in rerank).
# Single-token title matches against generic terms ("intel",
# "cpu") used to score 60+ standalone, dominating top-K with
# legacy 80486-era articles for queries like "fastest intel
# CPU?". Now that contribution is the same scale as FTS5 body
# BM25, which lets Pentium_4 (high body relevance, no title
# overlap) win on its actual topical fit.
for r in _search_titles(conn, list(accept_tokens), over_fetch):
title_lower = (r["title"] or "").lower().replace("_", " ")
overlap = sum(1 for t in accept_tokens if t in title_lower)
if overlap == 0:
continue
title_score = overlap * 10.0
raw.append(
(
title_score,
r["document_root"],
r["document_uri"],
r["title"],
0,
str(p.resolve()),
)
)
root_to_shard[r["document_root"]] = str(p.resolve())
# Core-keyword search: docs whose TF-IDF core keywords match.
# The query token doesn't need to be in title or even in body —
# being a TF-IDF keyword of the doc's core is enough signal.
for r in _docs_with_core_keyword_match(
conn, list(accept_tokens), over_fetch
):
core_match_roots.add(r["document_root"])
# Score scales with how many query tokens hit this doc's
# TF-IDF core. A 3-token coverage (e.g. Pentium_4's core
# carries "intel", "cpu", "faster" for the query "fastest
# intel CPU?") beats single-token title boosts (~80) that
# otherwise saturate the top with Intel_80486DX,
# Intel_8086, etc. — articles that share *one* word with
# the query but aren't the topical answer.
match_count = r["match_count"] or 1
kw_score = 40.0 + 25.0 * match_count
raw.append(
(
kw_score,
r["document_root"],
r["document_uri"],
r["title"],
0,
str(p.resolve()),
)
)
root_to_shard[r["document_root"]] = str(p.resolve())
finally:
conn.close()
raw.sort(key=lambda r: -r[0])
seen: set[str] = set()
out: list[_Hit] = []
for score, root, uri, title, idx, sp in raw:
if root in seen:
continue
seen.add(root)
out.append(
_Hit(
document_root=root,
document_uri=uri,
title=title,
score=score,
shard_path=sp,
chunk_idx=idx,
)
)
return out
def _rerank(
hits: list[_Hit],
question: str,
*,
core_match_roots: set[str] | None = None,
body_density_check: callable | None = None,
) -> list[_Hit]:
"""Filter off-topic, then layer in body-coverage and title-overlap boosts.
Order matters: filter first (drops noise), body-coverage rerank next
(counters BM25's short-doc bias by rewarding topical density across
distinct query tokens), title-overlap rerank last (breaks ties when
a doc IS the named topic).
"""
hits = _filter_by_title_relevance(
hits,
question,
core_match_roots=core_match_roots,
body_density_check=body_density_check,
)
hits = _rerank_by_body_coverage(hits, question)
return _rerank_by_title(hits, question)
def _rerank_by_body_coverage(
hits: list,
question: str,
*,
weight: float = 0.6,
) -> list:
"""Boost score by per-token body coverage to counteract BM25's short-doc bias.
BM25 normalizes by document length, but its `b` parameter under-penalizes
*very* short docs that happen to mention every query token. Result: a
1-paragraph stub on Intel 4040 (1974 microcontroller) outscored a 30-page
Intel Core i7 article for "what is the fastest intel CPU?".
Approach: for each hit's full body, sum sqrt(count) per query token.
Sqrt scaling lets long topical articles meaningfully out-score short
tangential ones without runaway domination by enumerative list pages.
stub article: 5 intel + 1 fastest + 4 cpu -> sqrt(5)+sqrt(1)+sqrt(4) ~ 5.2
long topical: 200 + 10 + 80 -> sqrt(200)+sqrt(10)+sqrt(80) ~ 26.2
enumeration: 1000 + 50 + 300 -> sqrt(1000)+sqrt(50)+sqrt(300) ~ 55.8
Multiply by `weight` (default 0.6) and add to the existing score. The
differentiation in the example above is decisive but bounded: long topical
articles get +15-16, enumerations get +33, stubs get +3. Combined with
FTS5 base scores in the 40s, the long topical article wins comfortably.
Cost: one body fetch per surviving candidate (typically 24-32). Bounded.
"""
import math
qtokens_lower = {t.lower() for t in _title_query_tokens(question)}
if not qtokens_lower:
return hits
for h in hits:
body = _load_doc_text(h.shard_path, h.document_root)
if body is None:
continue
body_lower = body.lower()
coverage = sum(
math.sqrt(body_lower.count(t)) for t in qtokens_lower
)
h.score += coverage * weight
hits.sort(key=lambda h: -h.score)
return hits
def _load_doc_text(shard_path: str, document_root: str) -> str | None:
"""Concatenate all hot chunks of a document. Returns None if cold or missing."""
conn = connect(shard_path)
try:
rows = conn.execute(
"SELECT content FROM chunks "
"WHERE document_root = ? AND content IS NOT NULL "
"ORDER BY idx ASC",
(document_root,),
).fetchall()
finally:
conn.close()
if not rows:
return None
return "\n\n".join(unpack_chunk(r["content"]) or "" for r in rows)
def _context_root(source_roots: list[str]) -> str:
"""Merkle root over sorted source document_roots — the v9.8 'source' dim
for multi-source answers. Sorting makes the root deterministic regardless
of search ranking order."""
if not source_roots:
return "00" * 32
sorted_roots = sorted(source_roots)
if len(sorted_roots) == 1:
return sorted_roots[0]
leaves = [bytes.fromhex(r) for r in sorted_roots]
return MerkleTree.build(leaves).root.hex()
def query(
*,
question: str,
qa_db: Path,
chat_client: ChatClient,
model_id: str,
revision: str = "",
quantization: str = "",
shards_dir: Path | None = None,
single_db: Path | None = None,
top_k: int = 8,
over_fetch: int = 32,
max_context_chars: int = 60000,
policy: dict | None = None,
chain: str = "private",
) -> dict:
"""Answer `question` using the corpus. Cache to qa_db. Returns a result dict."""
policy = policy or DEFAULT_QUERY_POLICY
t_start = time.monotonic()
# 1. Search.
t_phase = time.monotonic()
hits = _search_corpus(shards_dir, single_db, question, over_fetch)
if not hits:
return {
"status": "no_sources",
"msg": "FTS5 search returned no hits",
"timings": {
"search_ms": _ms_since(t_phase),
"total_ms": _ms_since(t_start),
},
}
core_match_roots = getattr(hits, "_core_match_roots", set())
root_to_shard = getattr(hits, "_root_to_shard", {})
qtokens_lower = {t.lower() for t in _title_query_tokens(question)}
def _body_density_check(h) -> bool:
# Lazy per-hit check: open the shard, count token mentions in this doc.
sp = root_to_shard.get(h.document_root) or h.shard_path
if not sp:
return False
c = connect(sp)
try:
return _body_density_passes(c, h.document_root, qtokens_lower)
finally:
c.close()
hits = _rerank(
hits,
question,
core_match_roots=core_match_roots,
body_density_check=_body_density_check,
)
search_ms = _ms_since(t_phase)
# 2. Pull doc texts within budget.
t_phase = time.monotonic()
chosen: list[_Hit] = []
context_parts: list[str] = []
char_budget = max_context_chars
for h in hits[:top_k]:
text = _load_doc_text(h.shard_path, h.document_root)
if not text:
continue
if len(text) > char_budget:
text = text[:char_budget]
context_parts.append(
f"=== Source: {h.document_uri} ===\n{text}"
)
chosen.append(h)
char_budget -= len(text)
if char_budget <= 0:
break
if not chosen:
return {
"status": "no_sources",
"msg": "top-k hits had cold or empty content",
"timings": {
"search_ms": search_ms,
"context_ms": _ms_since(t_phase),
"total_ms": _ms_since(t_start),
},
}
context = "\n\n".join(context_parts)
# Wikitext → prose before the LLM sees it. The model can then quote
# verbatim against the prose form; the verifier compares like-against-
# like. Idempotent if context is already plain prose. Gated on
# policy["base_version"] so this is part of governance_policy_hash.
if policy.get("base_version") and _wikitext_to_base is not None:
context = _wikitext_to_base(context)
context_ms = _ms_since(t_phase)
# 3. Build messages + hashes.
# System sets the policy; a user-turn reminder restates the verbatim-quote
# rule one message before the sources arrive (Hermes follows recent
# user-turn instructions more reliably than system-turn rules under long
# context). The sources + question land last so they are the most-recent
# tokens before generation begins.
messages = [
{"role": "system", "content": policy["system_prompt"]},
]
if policy.get("grounding_reminder"):
messages.append(
{"role": "user", "content": policy["grounding_reminder"]}
)
messages.append(
{
"role": "user",
"content": f"Sources:\n\n{context}\n\n---\n\nQuestion: {question}",
}
)
context_root = _context_root([h.document_root for h in chosen])
qhash = question_hash(question)
mhash = model_profile_hash(model_id, revision, quantization)
chash = conversation_hash(messages)
ghash = governance_policy_hash(policy)
ckey = cache_key(
context_root,
qhash,
mhash,
chash,
ghash,
SCHEMA_VERSION,
CANONICALIZATION_VERSION,
CHUNKING_VERSION,
)
qa_conn = connect(qa_db)
try:
# 4. Cache lookup.
t_phase = time.monotonic()
cached = qa_conn.execute(
"SELECT * FROM providence_cache "
"WHERE cache_key = ? AND falsification_state = 'live'",
(ckey,),
).fetchone()
cache_lookup_ms = _ms_since(t_phase)
if cached is not None:
now = int(time.time())
with transaction(qa_conn):
qa_conn.execute(
"UPDATE providence_cache "
"SET hit_count = hit_count + 1, last_hit_at = ? "
"WHERE cache_key = ?",
(now, ckey),
)
return {
"status": "cache_hit",
"audit_mode": cached["audit_mode"],
"cache_key": ckey,
"context_root": context_root,
"answer_text": cached["answer_text"],
"sources": json.loads(cached["merkle_proof"])["sources"],
"n_quotes": cached["n_quotes"],
"n_verified": cached["n_verified"],
"verifier_method": cached["verifier_method"],
"unverified_quotes": (
json.loads(cached["unverified_quotes"])
if cached["unverified_quotes"]
else []
),
"timings": {
"search_ms": search_ms,
"context_ms": context_ms,
"cache_lookup_ms": cache_lookup_ms,
"llm_ms": None,
"persist_ms": None,
"total_ms": _ms_since(t_start),
},
}
# 5. Cache miss — call LLM.
t_phase = time.monotonic()
answer_text = chat_client.chat_completion(
messages,
model=model_id,
temperature=policy["temperature"],
max_tokens=policy["max_tokens"],
top_p=policy.get("top_p", 1.0),
)
llm_ms = _ms_since(t_phase)
# 5b. Faithfulness check — pull quoted spans, verify each verbatim
# against the assembled context, classify into v9.8 audit_mode.
verdict = verify_quotes(
answer_text,
context,
entity_policy=policy.get("entity_policy", "hybrid"),
proximity_n=policy.get("entity_proximity_n", 3),
proximity_window=policy.get("entity_proximity_window", 300),
)
unverified_blob = (
json.dumps(verdict["unverified_quotes"], separators=(",", ":"))
if verdict["unverified_quotes"]
else None
)
# 6. Persist record + audit event.
t_phase = time.monotonic()
proof_obj = {
"context_root": context_root,
"sources": [
{
"document_root": h.document_root,
"document_uri": h.document_uri,
"title": h.title,
"score": h.score,
"chunk_idx": h.chunk_idx,
"shard": Path(h.shard_path).name,
}
for h in chosen
],
}
proof_blob = json.dumps(proof_obj, separators=(",", ":"))
now = int(time.time())
with transaction(qa_conn):
event_hash = append_audit(
qa_conn,
event_type="providence_query",
subject_root=ckey,
body={
"context_root": context_root,
"n_sources": len(chosen),
"model_id": model_id,
"revision": revision,
"quantization": quantization,
"answer_chars": len(answer_text),
"context_chars": len(context),
"audit_mode": verdict["audit_mode"],
"n_quotes": verdict["n_quotes"],
"n_verified": verdict["n_verified"],
"verifier_method": verdict["verifier_method"],
},
ts=now,
)
qa_conn.execute(
"INSERT INTO providence_cache "
"(cache_key, source_root, document_uri, question_hash, question_text, "
" answer_text, merkle_proof, model_profile_hash, conversation_hash, "
" governance_policy_hash, schema_version, canonicalization_version, "
" chunking_version, falsification_state, chain, audit_event_hash, "
" created_at, hit_count, audit_mode, n_quotes, n_verified, "
" unverified_quotes, verifier_method) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'live', ?, ?, ?, 0, "
" ?, ?, ?, ?, ?)",
(
ckey,
context_root,
"corpus://multi-source",
qhash,
question,
answer_text,
proof_blob,
mhash,
chash,
ghash,
SCHEMA_VERSION,
CANONICALIZATION_VERSION,
CHUNKING_VERSION,
chain,
event_hash,
now,
verdict["audit_mode"],
verdict["n_quotes"],
verdict["n_verified"],
unverified_blob,
verdict["verifier_method"],
),
)
finally:
qa_conn.close()
persist_ms = _ms_since(t_phase)
return {
"status": "cache_miss_then_written",
"audit_mode": verdict["audit_mode"],
"cache_key": ckey,
"context_root": context_root,
"answer_text": answer_text,
"sources": proof_obj["sources"],
"n_quotes": verdict["n_quotes"],
"n_verified": verdict["n_verified"],
"verifier_method": verdict["verifier_method"],
"unverified_quotes": verdict["unverified_quotes"],
"timings": {
"search_ms": search_ms,
"context_ms": context_ms,
"cache_lookup_ms": cache_lookup_ms,
"llm_ms": llm_ms,
"persist_ms": persist_ms,
"total_ms": _ms_since(t_start),
},
}
def _ms_since(t: float) -> float:
"""Wall-time elapsed in milliseconds, rounded to 1 decimal."""
return round((time.monotonic() - t) * 1000, 1)