SmartestHome/digest-engine/synth/counter_run.py

272 lines
10 KiB
Python

"""Second-pass verification ("counter run") over every generated digest document.
A synthesis pass can hallucinate a plausible-sounding quote, figure, or
theoretical connection even when its own prompt explicitly forbids it (see the
"No speculation" section of prompts/political.md, and similar language in the
other two prompts). Asking the same model "did you actually see this" in a
fresh call, with the source context in front of it again, catches a real
fraction of that which the generation prompt's instructions alone do not. This
is that second call — the final filter before a document is written to
output/, not a replacement for the instructions in the generation prompts.
Deliberately standalone rather than importing from llm_client.py: the two
modules would otherwise import each other (llm_client would need this module
to filter its own output; this module needs llm_client's Ollama-calling and
fallback-document helpers), and untangling that into a third shared module was
more churn than duplicating about a dozen lines of a plain HTTP POST.
Fails safe in the direction of showing LESS to the user, never more. Three
distinct failure modes, three distinct responses:
- This pass cannot run at all (Ollama unreachable a second time, unparseable
verdict) -> keep the original document, but mark it `unverified` rather
than silently passing it through unchecked. Blanking the section entirely
here would make a transient failure in THIS pass as damaging as the whole
digest being down, which is a worse trade than a visibly-unverified
document.
- Specific windows/narration are flagged ungrounded -> drop just those, keep
the rest.
- Every window in a document is flagged -> withhold the section with an
honest placeholder, the same shape generate_section() already uses when
the LLM host doesn't respond at all.
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
import requests
LOG = logging.getLogger(__name__)
PROMPT_PATH = Path(__file__).parent / "prompts" / "counter_run.md"
def _now_iso():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _withheld_document(document):
section = document.get("section", "")
return {
"generated_at": _now_iso(),
"detail_level": document.get("detail_level"),
"section": section,
"windows": [
{
"id": f"{section}-withheld",
"title": f"{str(section).title()} — withheld pending verification",
"kind": "text",
"content": (
"Nothing in this run's draft for this section could be verified "
"against its sources, so it was withheld rather than shown "
"unverified."
),
}
],
"narration": "",
"withheld": True,
}
def _call_ollama(prompt, temperature):
host = os.environ.get("OLLAMA_HOST", "http://llm-host:11434").rstrip("/")
# A separate override, not a reuse of OLLAMA_MODEL by force: verification is a
# different task from synthesis, and a household running a second, larger model
# for it (or the same one) should be able to say so without the two being coupled.
model = os.environ.get("COUNTER_RUN_MODEL") or os.environ.get("OLLAMA_MODEL", "qwen2.5:14b-instruct")
timeout = float(os.environ.get("OLLAMA_TIMEOUT", "600"))
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {"temperature": temperature},
}
response = requests.post(f"{host}/api/generate", json=payload, timeout=timeout)
response.raise_for_status()
return response.json().get("response", "")
def _build_prompt(document, context):
prompt_text = PROMPT_PATH.read_text(encoding="utf-8")
return (
f"{prompt_text}\n\n"
"## Source context\n\n```json\n"
f"{json.dumps(context, indent=2, ensure_ascii=False, default=str)}\n"
"```\n\n"
"## Document to verify\n\n```json\n"
f"{json.dumps(document, indent=2, ensure_ascii=False, default=str)}\n"
"```\n"
)
def _normalize(text):
return " ".join(str(text).split()).strip().lower()
def _window_haystack(window):
content = window.get("content")
if isinstance(content, list):
return _normalize(" ".join(str(item) for item in content))
return _normalize(content or "")
def _ground_sources(container, context_blob):
"""Strips citations that don't trace back to the context, in place.
A `sources` block is the strongest claim the digest makes — a URL and a quote
together assert "this exists and says this" — and it is also the easiest thing
for a model to compose out of thin air, because a plausible URL looks exactly
like a real one. Both halves are checked mechanically here rather than being
left to the verifying call's own judgement, for the same reason the quote check
below exists: substring presence in the context is a fact, not an opinion, and
no second opinion improves on it.
A source with a fabricated URL is dropped whole. A real source carrying an
invented quote keeps the source and loses the quote — the citation is still
true, only the excerpt was not.
"""
sources = container.get("sources")
if not isinstance(sources, list):
return
kept = []
for source in sources:
if not isinstance(source, dict):
continue
url = str(source.get("url") or "").strip()
if url and _normalize(url) not in context_blob:
LOG.warning("counter-run: dropping a source whose URL is not in the context: %r", url[:120])
continue
quote = str(source.get("quote") or "").strip()
if quote and _normalize(quote) not in context_blob:
LOG.warning("counter-run: clearing a source quote not found in the context: %r", quote[:80])
source = {key: value for key, value in source.items() if key != "quote"}
kept.append(source)
if kept:
container["sources"] = kept
else:
container.pop("sources", None)
def verify_document(document, context):
"""Returns a possibly-filtered copy of `document`. Never raises."""
if document.get("degraded"):
# Already a system-generated "the LLM host did not respond" placeholder, not
# model output — nothing here could be hallucinated, and the context it was
# meant to be grounded in is exactly what it's reporting it never got.
return document
windows = document.get("windows") or []
if not windows:
return document
temperature = float(os.environ.get("COUNTER_RUN_TEMPERATURE", "0.1"))
try:
raw = _call_ollama(_build_prompt(document, context), temperature)
verdict = json.loads(raw)
if not isinstance(verdict, dict):
raise ValueError("verdict was not a JSON object")
except Exception:
LOG.warning(
"counter-run: verification call failed for %s/%s, keeping the original "
"document but marking it unverified",
document.get("section"),
document.get("detail_level"),
exc_info=True,
)
result = dict(document)
result["unverified"] = True
return result
window_verdicts = {
str(entry.get("window_id")): bool(entry.get("grounded", True))
for entry in (verdict.get("window_verdicts") or [])
if isinstance(entry, dict)
}
# A quote the model claims is grounded gets checked mechanically too: this is
# the one claim type a substring search can verify without another LLM call
# trusting itself, so it's the one place a lenient (or dishonest) verdict from
# the counter-run call itself still gets overridden.
context_blob = _normalize(json.dumps(context, ensure_ascii=False, default=str))
quote_failed_windows = set()
for entry in verdict.get("quotes_checked") or []:
if not isinstance(entry, dict):
continue
text = str(entry.get("text") or "")
normalized = _normalize(text)
if not normalized:
continue
actually_found = normalized in context_blob
if entry.get("found_in_context", True) and not actually_found:
LOG.warning(
"counter-run: quote marked found_in_context but not located in "
"context, overriding: %r",
text[:80],
)
if not actually_found:
for window in windows:
if normalized in _window_haystack(window):
quote_failed_windows.add(str(window.get("id")))
kept, dropped = [], []
for window in windows:
window_id = str(window.get("id"))
grounded = window_verdicts.get(window_id, True) and window_id not in quote_failed_windows
if grounded:
kept.append(window)
else:
dropped.append(window_id)
if dropped:
LOG.warning(
"counter-run: dropped %d ungrounded window(s) from %s/%s: %s",
len(dropped),
document.get("section"),
document.get("detail_level"),
dropped,
)
if not kept:
return _withheld_document(document)
# Citations are checked after the window verdicts, not before: there is no point
# grounding the sources of a window that is about to be dropped whole. Markers
# carry their own sources (the globe briefs), so they are walked too.
for window in kept:
_ground_sources(window, context_blob)
for marker in window.get("globe_markers") or []:
if isinstance(marker, dict):
_ground_sources(marker, context_blob)
result = dict(document)
result["windows"] = kept
if not bool(verdict.get("narration_grounded", True)):
LOG.warning(
"counter-run: narration for %s/%s flagged ungrounded, clearing it",
document.get("section"),
document.get("detail_level"),
)
result["narration"] = ""
result["counter_run_checked"] = True
return result
def verify_all(documents, section_contexts):
"""`documents` is {"compact": [doc, ...], "full": [doc, ...]} from
llm_client.generate_all(); each doc's own `section` field looks up its context."""
verified = {}
for detail_level, docs in documents.items():
verified[detail_level] = [
verify_document(doc, section_contexts.get(doc.get("section"), {})) for doc in docs
]
return verified