arborist/aborist/qa/repair.py
russell@unturf.com 9930fc7f1d
qa: chain-segment failure localization + re-prompt repair tier
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.
2026-04-29 19:27:33 -04:00

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