session: thread conversation_history through providence_query (multi-turn LLM context)

The verifier was assuming single-turn — a session pronoun follow-up
("who created it?" after "what is permaculture?") hit the LLM with
no antecedent in the question, so the model couldn't ground.
Retrieval keywords already flowed ancestor titles → FTS5 (the
Permaculture article was in context), but the LLM saw just "who
created it?" with no subject. → UNGROUNDED.

Wire (verifier-blind for retrieval; cache_key-aware for chat):

- providence_query + run_query: new ``conversation_history`` param
  (list of OpenAI-shape {role, content} dicts). When set, messages
  become [system, ...history..., user(EVIDENCE+QUESTION)] instead
  of [system, user]. Folded into the canonical-messages hash so
  ``conversation_hash`` reflects the chat path — each distinct
  branch gets its own cache_key (fix verified: the same question
  with vs without history now produces different cache_keys).

- CLI session REPL: builds history from path_to_root of
  current_bates (which IS the parent at the moment of the new turn).
  Oldest-first. Strips claim-lattice pointer-line excerpts from
  prior answers via _session_strip_pointers — the [E#] bindings are
  stale across turns (each turn mints a fresh evidence_map) and
  the spotlight excerpts inflate the prompt without antecedent
  value. The bare prose claims remain.

- Off-by-one fix: was stripping path[1:] under the assumption that
  the current node was the just-added turn; in the REPL, current is
  the PARENT before the new turn is minted, so path-to-root IS the
  history. Drop the [1:].

Live test: "what is permaculture?" → STRICT → "who created it?"
→ STRICT (was UNGROUNDED). The model cites the Permaculture and
David Holmgren articles correctly for the pronoun question.

33 verify+session+providence tests pass. The byte-identity test
for run_query without history is preserved — history defaults to
None and skips the extension entirely.
This commit is contained in:
russell@unturf.com 2026-06-01 18:30:37 -04:00
parent a2a92236bc
commit 832297f405
No known key found for this signature in database
3 changed files with 95 additions and 15 deletions

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
@ -7517,6 +7518,35 @@ def _ignore(fn, *a, **kw):
pass
_POINTER_LINE_RE = re.compile(
r'^\s*\[E\d+(?:\s*\|[^\]]*?)?\]\s*$',
flags=re.MULTILINE,
)
_INLINE_POINTER_RE = re.compile(r'\[E\d+(?:\s*\|[^\]]*?)?\]')
def _session_strip_pointers(answer_text: str) -> str:
"""Strip claim-lattice pointer markers from a prior turn's answer
before threading it through as LLM conversation history. The
spotlight-rendered ``[E# | <title> | <chunk_prefix>: "..."]``
blocks (which sit on their own line per claim) get removed; bare
``[E#]`` mid-prose markers also drop. Leaves the prose claims
intact so the model sees: "Spider-Man is a superhero. Created by
Stan Lee." without stale pointer ids that don't match the new
turn's evidence_map.
"""
if not answer_text:
return ""
# Drop standalone pointer lines first (they're the bulk of the
# answer_text size for claim_lattice mode).
s = _POINTER_LINE_RE.sub("", answer_text)
# Then drop any remaining inline [E#] / [E# | …] markers.
s = _INLINE_POINTER_RE.sub("", s)
# Collapse multi-blank-lines.
s = re.sub(r"\n{3,}", "\n\n", s).strip()
return s
def _cmd_session(args: argparse.Namespace) -> int:
"""Merkle-rooted multi-turn Q&A REPL with forks.
@ -7829,16 +7859,40 @@ def _session_repl(args: argparse.Namespace, sess) -> int:
print(f"unknown command: {line} (try /help)")
continue
# Non-command line → new question. Read cited titles
# directly from ancestor node rows (already materialized at
# insert time) and pass as retrieval_keywords. Verifier-
# blind; same discipline as the legacy --retrieval-keywords
# flag (#000001).
# Non-command line → new question. Two pieces of session
# context flow into the call:
#
# 1. retrieval_keywords: ancestor cited titles → widen
# FTS5 retrieval. Verifier-blind; same discipline as
# the legacy --retrieval-keywords flag (#000001).
# 2. conversation_history: ancestor (user/assistant)
# pairs → give the LLM antecedent context for pronoun
# questions ("who created it?" after "what is
# permaculture?"). Folds into conversation_hash so each
# distinct chat path gets its own cache_key.
seen_titles: list[str] = []
for n in sess.path_to_root(sess.current_bates)[1:]:
history: list[dict] = []
# current_bates is the PARENT of the soon-to-be-minted
# node, so its path-to-root IS the conversation history.
# Walk oldest-first so the LLM reads in chronological
# order; skip the synthetic root (seq=0).
ancestors = sess.path_to_root(sess.current_bates)
for n in reversed(ancestors):
if n.seq == 0:
continue # skip synthetic root
for t in n.cited_titles:
if t and t not in seen_titles:
seen_titles.append(t)
history.append({"role": "user", "content": n.question})
# Strip claim-lattice pointer-line excerpts before
# passing prior answers to the model — the [E#] bindings
# are stale (each turn has a fresh evidence_map) and the
# spotlight excerpts inflate the prompt without adding
# antecedent value. The bare claim prose remains.
history.append({
"role": "assistant",
"content": _session_strip_pointers(n.answer_text),
})
policy_local = dict(policy)
if seen_titles:
policy_local["retrieval_keywords"] = " ".join(seen_titles[:8])
@ -7851,6 +7905,7 @@ def _session_repl(args: argparse.Namespace, sess) -> int:
model_id=model,
burn_existing=bool(args.burn),
top_k=args.top_k,
conversation_history=history or None,
)
except Exception as e:
print(f"query failed: {e}")

View file

@ -135,6 +135,7 @@ def run_query(
warrant_check_enabled: bool = False,
policy: dict | None = None,
precomputed_hits: list | None = None,
conversation_history: list[dict] | None = None,
) -> dict:
"""End-to-end claim-lattice query over any Corpus.
@ -385,11 +386,17 @@ def run_query(
f"QUESTION: {question}\n\n"
f"{CLAIM_LATTICE_GROUNDING_REMINDER}"
)
messages = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_prompt},
]
prompt_chars = len(sys_prompt) + len(user_prompt)
# Optional conversation_history is inserted between system and the
# current user turn — gives the model antecedent context for
# pronoun questions ("who created it?" after "what is permaculture?").
# The history's per-turn user/assistant content stays grounded in
# the CURRENT turn's evidence_map: the model is told which pointer
# ids resolve via the new EVIDENCE block, not the prior turns'.
messages = [{"role": "system", "content": sys_prompt}]
if conversation_history:
messages.extend(conversation_history)
messages.append({"role": "user", "content": user_prompt})
prompt_chars = sum(len(m["content"]) for m in messages)
# 4. LLM call.
ts = _time.time()
@ -469,6 +476,7 @@ def run_query(
"document_root": ev.source_root,
"document_uri": ev.document_uri,
"title": ev.title,
"chunk_idx": ev.chunk_idx,
"source_role": ev.source_role,
"used": ev.source_root in used_doc_roots,
"used_pointer_ids": sorted(set(

View file

@ -62,23 +62,35 @@ _DEFAULT_CHUNKING_VERSION = "tok-512-v1"
def _build_canonical_messages_for_hash(
question: str, evidence_text: str
question: str, evidence_text: str,
conversation_history: list[dict] | None = None,
) -> list[dict]:
"""Mirror the message shape run_query passes to chat_completion.
Must stay in sync with arborist/qa/corpus_query.py:run_query
same system prompt, same user-payload template. The byte-identity
test (tests/test_run_query_byte_identity.py) catches drift.
``conversation_history``: optional list of preceding turn
messages (user / assistant pairs). When set, they're inserted
between the system prompt and the current EVIDENCE+QUESTION user
turn so the model has antecedent context for pronouns ("who
created it?" after "what is permaculture?"). Folds into
``conversation_hash`` so each unique chat history gets its own
cache_key.
"""
user = (
f"EVIDENCE:\n\n{evidence_text}\n\n"
f"QUESTION: {question}\n\n"
f"{CLAIM_LATTICE_GROUNDING_REMINDER}"
)
return [
msgs: list[dict] = [
{"role": "system", "content": CLAIM_LATTICE_SYSTEM_PROMPT},
{"role": "user", "content": user},
]
if conversation_history:
msgs.extend(conversation_history)
msgs.append({"role": "user", "content": user})
return msgs
def _compute_cache_key(
@ -282,6 +294,7 @@ def providence_query(
temperature: float = 0.1,
max_tokens: int = 512,
progress: Any = None,
conversation_history: list[dict] | None = None,
) -> dict[str, Any]:
"""Cache-aware claim-lattice query.
@ -459,7 +472,10 @@ def providence_query(
qhash = question_hash(question, mode="equivalence_class")
mhash = model_profile_hash(model_id)
chash = conversation_hash(
_build_canonical_messages_for_hash(question, evidence_repr)
_build_canonical_messages_for_hash(
question, evidence_repr,
conversation_history=conversation_history,
)
)
ghash = governance_policy_hash(policy)
schema_version = policy.get("schema_version", _DEFAULT_SCHEMA_VERSION)
@ -556,6 +572,7 @@ def providence_query(
temperature=temperature, max_tokens=max_tokens,
policy=policy,
precomputed_hits=hits,
conversation_history=conversation_history,
)
progress.emit(
"llm.done",