Two enhancements continuing the toy-Hermes design pass: #1 Chain-segment failure localization aborist/qa/dag.py: `localize_failure(audit_mode, n_sources, n_quotes, n_verified)` maps a non-STRICT verdict to the pipeline stage that introduced the failure: retrieval no admitted sources (gate over-rejected, or corpus genuinely lacks the topic) → ingest more / relax breadth context sources retrieved but no quotes extracted (per-source cap dropped relevant content, or model declined to cite) → raise cap / tighten prompt answer quotes extracted but didn't all verify (model fabricated, paraphrased inside quotes, appended citations) → mechanical + re-prompt repair targets exactly this case `failure_stage` lands on the run_dag's verify node payload AND on the result dict so an operator can read the reason at a glance — `failure_stage='answer'` means stop tuning the verifier & fix the model behavior. Debugging becomes typed instead of vague. #2 Re-prompt repair (second tier of the hybrid loop) aborist/qa/repair.py: `reprompt_repair(...)` builds a feedback message naming the failed quotes & asks the model to rewrite using only verbatim citations. Hard rule: only fires when `policy["repair_max_reprompts"] > 0` (default 0); caller enforces the cap by looping at most that many times. aborist/qa/query.py + aborist/qa/runner.py: after mechanical repair, if the answer is still HYBRID/UNGROUNDED with unverified quotes, loop up to `repair_max_reprompts` times. Each iteration: build feedback (assistant turn with current answer + user turn with failed spans), call LLM, verify. Accept the new answer if `n_verified` strictly improved; otherwise break (the model's not converging, don't waste cycles). The mechanical + re-prompt combination handles the cases each tier declines individually: mechanical alone: synthetic_elision split, trailing_artifact trim, no_overlap remove + re-prompt: paraphrase, partial_paraphrase, interior_elision needing semantic judgment, fabrications the model can recognize when shown its own quote `repair_max_reprompts` lives in DEFAULT_QUERY_POLICY + DEFAULT_POLICY so it folds into governance_policy_hash. Default 0 preserves single-shot semantics for callers that don't opt in. Each re-prompt iteration adds a `{action: reprompt_rewrite, diagnosis: model_feedback_loop}` entry to repair_changes; audit chain captures the full transition through the existing providence_repair event. Tests: - dag: localize_failure across all four cases (STRICT, retrieval, context, answer); failure_stage embedded in run_dag verify node. - repair: stub client with sequenced answers (failing first, clean on re-prompt) — assert two LLM calls, STRICT verdict, reprompt_rewrite in the change log. 484 tests pass (dag +5, repair +1). The pre-existing test_burn flake under full-suite ordering remains; passes in isolation.
201 lines
7.8 KiB
Python
201 lines
7.8 KiB
Python
"""Mechanical answer repair — apply sidecar repair suggestions deterministically.
|
|
|
|
When the verifier flags an answer's quoted span as unverified, the
|
|
sidecar (`aborist/qa/inspect.py:_classify_span`) emits a `repair`
|
|
field naming a concrete fix: split a `[...]`-elided quote into two
|
|
verbatim quotes, trim a trailing `(Source: ...)` artifact, restore a
|
|
dropped parenthetical aside, drop a fully-invented claim. This module
|
|
applies those fixes by string substitution — no LLM call, no
|
|
non-determinism.
|
|
|
|
Gated by `policy["repair_enabled"]`. Off by default so existing
|
|
callers don't see answer text mutate under their feet.
|
|
|
|
Repair philosophy follows the toy-Hermes design pass (fox 2026-04-30):
|
|
|
|
- Mechanical first. Cheap, deterministic, idempotent.
|
|
- Re-prompt second (TODO; one extra LLM call to ask the model to
|
|
rewrite around the failed claim). Adds latency; quality bonus.
|
|
- Repair stage is observational from the cache_key's perspective: the
|
|
cache_key inputs are unchanged, only the persisted answer text differs.
|
|
An audit event records the original→repaired transition.
|
|
|
|
The verifier-stays-binary discipline holds: this module never adds
|
|
soft-signal fields to the verifier output. It produces a CHANGED
|
|
answer text + a CHANGE LOG that the audit chain stores.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from .inspect import _classify_span, _normalize
|
|
|
|
|
|
def mechanical_repair(
|
|
answer_text: str, unverified_quotes: list[str], context: str
|
|
) -> dict:
|
|
"""Apply repair suggestions to ``answer_text``.
|
|
|
|
For each unverified quote, run the sidecar classifier to get a
|
|
repair suggestion, then apply it by string substitution where the
|
|
suggestion has a concrete replacement (split, trim, drop). Skips
|
|
suggestions that need semantic judgment (paraphrase downgrade, aside
|
|
restoration without a definitive verbatim form).
|
|
|
|
Returns::
|
|
|
|
{
|
|
"repaired_text": str, # the modified answer (== input if no repair fired)
|
|
"changes": [dict], # per-quote repair log
|
|
}
|
|
|
|
Each change entry::
|
|
|
|
{
|
|
"action": str, # "split_into_two_quotes" | "trim_trailing_artifact" | ...
|
|
"before": str, # the original quoted span
|
|
"after": str | None, # the replacement text, or None if removed
|
|
"diagnosis": str,
|
|
}
|
|
|
|
Idempotent: applying repair twice produces the same result (the
|
|
repaired quotes substring-match in source, so a second pass finds
|
|
nothing more to fix).
|
|
"""
|
|
norm_ctx = _normalize(context)
|
|
repaired = answer_text
|
|
changes: list[dict] = []
|
|
|
|
for quote in unverified_quotes:
|
|
diag = _classify_span(quote, norm_ctx, norm_ctx)
|
|
repair = diag.get("repair") or {}
|
|
action = repair.get("action")
|
|
diagnosis = diag.get("diagnosis", "?")
|
|
|
|
if action == "split_into_two_quotes":
|
|
# `"prefix [...] suffix"` becomes `"prefix" ... "suffix"`. The
|
|
# model's `[...]` ellipsis-marker becomes prose between two
|
|
# verbatim-quoted spans the verifier can independently check.
|
|
quotes = repair.get("quotes") or []
|
|
if len(quotes) == 2 and all(quotes):
|
|
new_segment = f'"{quotes[0]}" ... "{quotes[1]}"'
|
|
old_segment = f'"{quote}"'
|
|
if old_segment in repaired:
|
|
repaired = repaired.replace(old_segment, new_segment, 1)
|
|
changes.append({
|
|
"action": action,
|
|
"diagnosis": diagnosis,
|
|
"before": quote,
|
|
"after": new_segment,
|
|
})
|
|
|
|
elif action == "trim_trailing_artifact":
|
|
# `"prose. (Source: https://...)"` becomes `"prose."`. The
|
|
# verbatim prefix kept; the model-appended tail dropped.
|
|
kept = repair.get("kept_prefix")
|
|
if kept:
|
|
old_segment = f'"{quote}"'
|
|
new_segment = f'"{kept}"'
|
|
if old_segment in repaired:
|
|
repaired = repaired.replace(old_segment, new_segment, 1)
|
|
changes.append({
|
|
"action": action,
|
|
"diagnosis": diagnosis,
|
|
"before": quote,
|
|
"after": kept,
|
|
})
|
|
|
|
elif action == "remove_claim":
|
|
# Drop the line containing this quote entirely. Catches
|
|
# full-invention spans (no_overlap diagnosis) where there's
|
|
# no verbatim source content to substitute.
|
|
new_lines = []
|
|
removed = False
|
|
for line in repaired.splitlines(keepends=True):
|
|
if quote in line and not removed:
|
|
removed = True
|
|
continue
|
|
new_lines.append(line)
|
|
if removed:
|
|
repaired = "".join(new_lines)
|
|
changes.append({
|
|
"action": action,
|
|
"diagnosis": diagnosis,
|
|
"before": quote,
|
|
"after": None,
|
|
})
|
|
|
|
# Skipped: include_aside_for_verbatim (needs precise source-span
|
|
# extraction we'd rather defer to a re-prompt), paraphrase
|
|
# downgrade (needs prose rewriting), partial_paraphrase
|
|
# (split_or_remove judgment). These cases pass through the
|
|
# mechanical loop unchanged & remain UNSUPPORTED in the
|
|
# post-repair verdict — falling back to a re-prompt loop is
|
|
# future work (TODO: re-prompt feedback path).
|
|
|
|
return {"repaired_text": repaired, "changes": changes}
|
|
|
|
|
|
def reprompt_repair(
|
|
*,
|
|
chat_client: Any,
|
|
model_id: str,
|
|
original_messages: list[dict],
|
|
original_answer: str,
|
|
failed_quotes: list[str],
|
|
policy: dict,
|
|
) -> str | None:
|
|
"""Second tier of the hybrid repair loop: add a feedback user-turn
|
|
naming the failed quotes & call the LLM once more.
|
|
|
|
Mechanical repair handles deterministic substitutions (split, trim,
|
|
drop). For the cases it declines (paraphrase, partial_paraphrase,
|
|
interior_elision needing semantic judgment), this function asks the
|
|
model to rewrite around the failed claims using only verbatim
|
|
citations. Caller decides whether to accept the new answer based on
|
|
a re-verify.
|
|
|
|
Returns the new answer text or ``None`` if the LLM call fails or
|
|
there's nothing to feed back. Caller is responsible for verifying
|
|
& deciding whether to keep the new answer.
|
|
|
|
Hard rule: only fires when ``policy["repair_max_reprompts"] > 0``.
|
|
Caller enforces the cap (this function does not loop internally —
|
|
one call per invocation).
|
|
"""
|
|
if not failed_quotes:
|
|
return None
|
|
|
|
failed_listing = "\n".join(
|
|
f"- \"{q[:200]}{'...' if len(q) > 200 else ''}\""
|
|
for q in failed_quotes[:5]
|
|
)
|
|
feedback = (
|
|
"Your previous answer contained quoted spans that did not "
|
|
"verify verbatim against the provided sources:\n\n"
|
|
f"{failed_listing}\n\n"
|
|
"Rewrite the answer using ONLY verbatim quotes that appear "
|
|
"word-for-word in the sources. Do not insert `[...]` ellipsis "
|
|
"markers inside quotes — split into two separate quotes "
|
|
"instead. Do not append `(Source: ...)` citation tails to "
|
|
"quotes — keep the citation as prose outside the quoted "
|
|
"span. If a claim cannot be verbatim-cited from a source, "
|
|
"omit the claim or downgrade it to a non-quoted paraphrase."
|
|
)
|
|
|
|
new_messages = list(original_messages) + [
|
|
{"role": "assistant", "content": original_answer},
|
|
{"role": "user", "content": feedback},
|
|
]
|
|
|
|
try:
|
|
return chat_client.chat_completion(
|
|
new_messages,
|
|
model=model_id,
|
|
temperature=policy.get("temperature", 0.1),
|
|
max_tokens=policy.get("max_tokens", 768),
|
|
top_p=policy.get("top_p", 1.0),
|
|
)
|
|
except Exception:
|
|
return None
|