Files
majordomo/agent/finalize.go
T
steve 7fd239c8cd
CI / Tidy (pull_request) Successful in 9m26s
CI / Build & Test (pull_request) Successful in 10m17s
fix(agent): "In short supply" is not a short version
Round 5. Two of the eight findings are the em-dash mid-rune slice, which
the orientation matrix had already caught and fixed in 285789c before
this round returned — the harness doing the reviewers' job, which is the
point of it.

Of the rest:

**A marker phrase can open a sentence and still be prose** (kimi).
"That's the chain above. In short supply of alternatives, we went with
B." opens a sentence with "In short" and announces nothing. The marker
must now be followed by the delimiter a model puts after a real
announcement — the colon in "Short version:", the comma in "In short,".
No delimiter means no match, which keeps the terminal: fails closed.

Adding that made the sentence-opening anchor look redundant — a
break-check that removed it survived, because every mid-sentence case in
the table was by then rejected for want of a delimiter. It is not
redundant: "…, and in short, we went with B" is a clause continuation
with a comma. The missing thing was a case for what the anchor alone
does, not the anchor.

**backRefHeadChars was named for the wrong family** (opus; sonnet raised
the same constant from the other side). It is applied only to the
deictic offset test, never to backRefRe. Renamed abovePointerHeadChars.
It stays defined as `= weakFinalMaxChars` rather than inlined, because an
earlier round asked for exactly that linkage and the reason still holds.

**The clause-boundary comment narrated my review history** (opus) — the
same class of finding I took two rounds ago and then reintroduced one
comment over. Rewritten as behaviour, pointing at the matrix.

**analysis1611 was a function returning a constant string** (sonnet) —
now a var.

Accepted, not fixed: a comparative "above," plus a sentence-opening
marker can still reach isSummaryCloser. That needs the comparative false
positive, a real marker, a terminal under 300 bytes, and a same-turn
prior at least 3x longer, all at once; the dwarf ratio is the backstop
and no lexical test separates the comparative from the deictic.

Break-check: seventeen mutations, each killed by a named test.
2026-08-22 00:42:22 -04:00

507 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"regexp"
"strings"
"unicode/utf8"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// finalOutput selects the user-facing answer when the loop reaches a clean
// terminal turn (one with no tool calls).
//
// Normally that terminal turn's text IS the answer: well-behaved models defer
// their answer to the final, tool-free turn. But some models — notably several
// open-weight ones — "front-load" their full answer into an earlier turn that
// ALSO calls a tool (e.g. answer text alongside a citation call), then close
// with a degenerate terminal turn that is not itself the answer. Three shapes
// are recovered from the transcript (zero extra model calls):
//
// - a trivial back-reference ("(Already answered above.)", "see above", …):
// the real answer sits earlier, so recover it and DISCARD the worthless
// closer.
// - a sources/citations-only addendum ("Sources: [x](…), [y](…)"): the model
// front-loaded the prose answer and closed with just its citations (the
// glm-5.2 "cite" pattern behind mort issue #1418). The citations are real,
// useful content — unlike a back-reference — so recover the prior answer and
// KEEP the citations, appended below it.
// - a deferring closer ("Citations are logged. Short version: …", "Done —
// that's the full chain above. Short version: …"): the model points at the
// answer it had already written and compresses it into a one-liner (mort
// run b3cb9ee9 — a 2,089-char answer shrank to a 153-byte closer; mort
// issue #1611 — a 2,245-char analysis shrank to 220 bytes). The
// compression is strictly poorer than the front-loaded answer, so recover
// the prior turn and DISCARD the closer — but only when the prior turn
// clearly dwarfs it, because unlike a bare back-reference this closer DOES
// carry answer content (see modeSummary).
//
// The last two shapes share one signal — the terminal DEFERS: it tells us the
// answer is somewhere the user cannot see (pointsAbove, or the bookkeeping
// ack). They differ only in whether the terminal also carries content of its
// own, so a new deferral phrasing added to pointsAbove is covered in both the
// bare and the "+ compression" variant at once.
//
// A citations addendum is tested first and wins over the other two (a short
// terminal can match more than one shape), so its links are never discarded.
// The back-reference test wins over the summary-closer test: a terminal
// matching both ("Citations are logged. As I said above…") carries no answer
// content of its own, so the looser back-ref recovery bar — not the summary
// closer's dwarf ratio — is the right one. When the terminal text stands
// on its own it is returned unchanged; when it is degenerate but nothing
// better can be recovered, it is returned as-is (a compressed answer still
// beats nothing).
//
// msgs must already include the terminal assistant message as its last element
// (the loop appends it before calling this); terminal is that message's text.
func finalOutput(msgs []llm.Message, terminal string) string {
mode := modeBackRef
switch {
case isCitationsOnly(terminal):
mode = modeCitations
case isWeakFinal(terminal):
mode = modeBackRef
case isSummaryCloser(terminal):
mode = modeSummary
default:
return terminal
}
rec, ok := lastSubstantiveAssistantText(msgs, terminal, mode)
if !ok {
return terminal
}
if mode == modeCitations {
// Preserve the citations addendum below the recovered answer, unless the
// recovered turn already carries it (guards against a duplicate sources
// block when the front-loaded turn included its own citations). The
// containment test ignores <url> angle-bracket wrappers so a turn that
// listed the same sources unwrapped still suppresses the duplicate.
if tail := strings.TrimSpace(terminal); !strings.Contains(stripURLAngles(rec), stripURLAngles(tail)) {
return rec + "\n\n" + tail
}
}
return rec
}
// stripURLAngles removes the <…> wrappers Discord uses to suppress link embeds,
// so the citations dedup compares URLs regardless of that formatting delta.
func stripURLAngles(s string) string {
if !strings.ContainsAny(s, "<>") {
return s
}
return strings.NewReplacer("<", "", ">", "").Replace(s)
}
// recoveryMode selects the bar a prior assistant turn must clear to replace
// the terminal turn (see isSubstantiveAnswer) and what finalOutput does with
// the terminal once recovery succeeds.
type recoveryMode int
const (
// modeBackRef: the terminal is empty or a pure back-reference — worthless
// on its own, so any real prior answer replaces it and it is discarded.
modeBackRef recoveryMode = iota
// modeCitations: the terminal is a sources-only addendum — not a rival
// answer, so the dwarf ratio is skipped and the addendum is kept, appended
// below the recovered answer.
modeCitations
// modeSummary: the terminal acknowledges the citation round and may carry
// a short compression of the front-loaded answer. Unlike a back-reference
// it DOES contain answer content, so it is only replaced when a prior turn
// clearly dwarfs it — the ratio is mandatory at every length, the recovery
// scan stops at the most recent user message (a compression can only be of
// THIS turn's answer; never resurrect one from an earlier question), and
// the closer is discarded (its content is a strict subset of what it
// replaced).
modeSummary
)
// backRefRe matches a terminal turn that merely points back to an earlier
// message instead of stating the answer ("(Already answered above.)",
// "see above", "as I said", ...). It is a list of fixed phrasings; aboveRefRe
// covers the open-ended half of the same family.
var backRefRe = regexp.MustCompile(`(?i)(already answered|see above|as (i )?(said|mentioned|stated|noted)|answered (that )?above|per my (previous|earlier))`)
// aboveRefRe matches a DEICTIC "above" — one pointing at earlier text rather
// than serving as a preposition. What follows the word separates the two uses:
// the deictic use ends its clause ("that's the full chain above.", "as shown
// above,", a line that simply ends in "above"), while the preposition always
// continues into a noun phrase ("above 100°C", "above the fold", "above all,
// the ..."). Only the clause-final form matches, so the open-ended half of the
// back-reference family is covered without enumerating every phrasing a model
// might invent — backRefRe's fixed list kept missing new ones (mort issue
// #1611: "Done — that's the full chain above.").
//
// The terminator set deliberately excludes the ASCII hyphen. `\b` holds
// between "above" and "-", so a literal '-' in the class made every
// hyphenated compound — "above-average", "above-board", "above-ground" —
// read as a clause-final deictic and put a legitimate short answer at risk of
// being discarded. The em dash stays: a model writes "…above — see the
// links", never "above-" as a separator. CR is listed alongside LF so a
// CRLF transcript does not quietly lose every line-final "above".
var aboveRefRe = regexp.MustCompile(`(?i)\babove\b[ \t]*([.,;:!?)\]"'’”—]|\r|\n|$)`)
// pointsAbove reports whether a deictic "above" appears in the terminal's
// OPENING. The offset bound is what makes a bare "above" safe to key on: with
// almost no text before it in THIS message, the reference cannot be pointing
// at the message's own content, so it must point at a turn the user never saw
// (the harness delivers only the final turn).
//
// This is the DEFERRAL SIGNAL SHARED by both recovery shapes — isWeakFinal and
// isSummaryCloser both call it, so a widening here reaches the bare closer and
// the "+ compression" closer at once. Its sibling backRefRe is deliberately
// NOT shared: those fixed phrases are matched anywhere in the text, which is
// only safe under isWeakFinal's 120-byte cap. Folding the two into one
// predicate for tidiness would hand isSummaryCloser an unanchored match across
// 300 bytes — widening the gate, not deduplicating it.
func pointsAbove(t string) bool { return aboveRefLoc(t) != nil }
// aboveRefLoc returns the span of the qualifying deictic reference, or nil.
// One place performs the match and the offset test, so pointsAbove and
// bareAbovePointer cannot disagree about what counts as a pointer.
func aboveRefLoc(t string) []int {
loc := aboveRefRe.FindStringIndex(t)
if loc == nil || loc[0] > abovePointerHeadChars {
return nil
}
return loc
}
// bareAbovePointer reports whether the terminal is a deictic back-reference and
// essentially NOTHING ELSE — the form isWeakFinal may discard outright.
//
// pointsAbove alone is not enough for that, because a pointer can share a short
// terminal with the answer: "That's the chain above. Ship Tuesday." is 37 bytes
// and the decision is the four words the pointer is not. Discarding it in
// favour of an earlier turn throws away the only thing the user needed. Same
// shape as the summary-closer fix — a pointer says where the detail is, it does
// not say the text beside it is disposable.
//
// So the reference's own clause is cut out and whatever remains must be filler:
// nothing, punctuation, or a throat-clearing "Done —". Cutting the CLAUSE (from
// the end of the previous sentence through the reference) rather than testing
// position is also what makes a mixed terminal safe — "Anything above 100
// boils. See the note above." keeps its first sentence and is correctly not
// bare, and a comparative "above," with an interjection after it keeps the rest
// of its own sentence for the same reason.
func bareAbovePointer(t string) bool {
loc := aboveRefLoc(t)
if loc == nil {
return false
}
start := 0
if k := strings.LastIndexAny(t[:loc[0]], clauseBoundaryChars); k >= 0 {
// k is the BYTE index of the boundary rune's first byte, and one of
// those runes is a 3-byte em dash — k+1 would slice into the middle of
// it and leave a stray continuation byte in the remainder, which then
// never trims away and makes a genuinely bare pointer look occupied.
_, w := utf8.DecodeRuneInString(t[k:])
start = k + w
}
rest := strings.TrimSpace(t[:start]) + " " + strings.TrimSpace(t[loc[1]:])
return bareRemainderRe.MatchString(strings.Trim(rest, pointerResidueCutset))
}
// clauseBoundaryChars ends the clause the reference belongs to. Commas,
// semicolons, colons and dashes are in it, not just sentence terminators,
// because the answer can share the reference's SENTENCE — "Ship Tuesday, as
// shown above." is a decision plus a pointer, and cutting back to the previous
// full stop would swallow the decision and make the whole terminal look bare.
// Content on either side of the pointer disqualifies it equally; see
// TestBareAbovePointerOrientations, which checks every placement.
const clauseBoundaryChars = ".!?\n,;:—"
// pointerResidueCutset is trimmed from both ends of what survives cutting the
// pointer's clause — the brackets, quotes, and punctuation a model wraps a
// back-reference in ("(Already answered above.)").
const pointerResidueCutset = " \t\r\n.,;:!?()[]{}\"'“”‘’*_-—"
// bareRemainderRe matches a remainder that carries no answer: empty, or only
// the filler a closer opens with. Shares fillerWords with summaryPreface so the
// two lists cannot drift.
var bareRemainderRe = regexp.MustCompile(`(?i)^(` + fillerWords + fillerSep + `*)*$`)
// compressionMarkerRe matches a model announcing that what follows is the
// short form of something longer ("Short version: …", "TL;DR: …", "In short,
// …"). It is the second half of the deictic summary-closer test: the pointer
// says the full answer is elsewhere, and this says the text beside it is a
// condensation rather than new reasoning.
//
// Both halves are required, because a deictic pointer alone does not mean the
// terminal is disposable. "Given the analysis above, I recommend option B
// because X" opens with a pointer and then states a CONCLUSION the earlier
// turn never contained — discarding it in favour of that turn would throw away
// the answer. A compression marker is the model telling us the opposite.
//
// A marker WITHOUT a pointer stays out of scope, as summaryCloserRe's own
// comment explains: a user who asked for brevity is answered with exactly that
// shape.
//
// The marker must OPEN a sentence AND be followed by a delimiter — the colon
// or comma a model puts after it when it is genuinely introducing the short
// form. Both halves are needed against a different failure each:
//
// - Mid-sentence the same words are ordinary prose carrying new content.
// "Given the analysis above, the bottom line is that we need a different
// vendor" states a conclusion; it does not announce a condensation.
// - At a sentence opening the words can still run on into ordinary prose.
// "In short supply of alternatives, we went with B" opens a sentence with
// "In short" and is not a summary at all.
//
// A marker with no delimiter ("In short we chose B") is not matched, which
// fails closed: the terminal is kept, which is today's behaviour.
var compressionMarkerRe = regexp.MustCompile(`(?i)(^|[.!?:;—]\s*|\n\s*)` +
`(short version|shorter version|short answer|tl;?dr|in short|in brief|in summary|in sum|bottom line|net[- ]net|the gist)` +
`\s*([:,—-]|$)`)
// summaryCloserRe matches a terminal turn that OPENS with a bookkeeping
// acknowledgment of the citation round — "Citations are logged.", "Sources
// cited.", "Logged the citations." — the shape a model produces when it
// front-loaded its answer into an earlier cite-call turn and closes by
// acknowledging the tool results, often followed by a "Short version: …"
// compression of the answer it already wrote. The ack clause must end at a
// sentence terminator ([.!]) DIRECTLY after the verb: "The citations are
// recorded in the court transcript…" is a real answer about citations, not
// bookkeeping, and must never match. A compression marker without the ack
// ("Short version: no.") is deliberately out of scope — a user who asked for
// brevity would be answered with exactly that shape, and misclassifying it
// would hijack a legitimate answer; an unmatched closer merely keeps today's
// behavior (fail closed). Assembled from named fragments so the alternations
// stay legible and extendable.
const (
// fillerWords are the throat-clearing tokens a closer opens with. Shared
// with bareRemainderRe, which has to recognise exactly the same set as
// "not answer content".
fillerWords = `(done|all set|ok(ay)?)`
// fillerSep is the punctuation a filler word trails. Shared for the same
// reason fillerWords is: two copies of one separator class drift.
fillerSep = `[\s,.!:—-]`
summaryPreface = `(` + fillerWords + fillerSep + `+)?` // optional "Done —" style opener
summaryNouns = `(citations?|sources?|references?|claims?)`
// "all" appears here AND in summaryArticle on purpose: as a quantifier
// between noun and verb ("Citations all logged.") and as a determiner
// before the noun ("All claims cited.", "Logged all the citations.").
summaryCopulas = `((are|were|have\s+been|all)\s+)*`
summaryVerbs = `(logged|recorded|cited|saved|noted|captured|filed)`
summaryArticle = `((all|the)\s+)*` // star, not ?: "Logged all the citations."
)
var summaryCloserRe = regexp.MustCompile(`(?i)^` + leadingMarkers + summaryPreface +
`(` + summaryArticle + summaryNouns + `\s+` + summaryCopulas + summaryVerbs +
`|logged\s+` + summaryArticle + summaryNouns + `)[.!]`)
// preambleRe matches intent-announcing prefixes ("Let me search...", "I'll
// check...") so a preamble is never mistaken for the answer during recovery.
var preambleRe = regexp.MustCompile(`(?i)^(let me|let'?s|i'?ll|i will|first[, ]|sure[,. ]|okay[,. ]|on it|checking)`)
// citationLabelRe matches a terminal turn that OPENS with a sources/citations
// heading — the shape a model produces when it front-loads its answer into an
// earlier tool-call turn and closes with only its sources. Leading markdown
// emphasis (*, _), list (-, +, *), block-quote (>), and ATX-heading (#) markers
// — with their whitespace, since \s is in the class — are tolerated before the
// label, as is closing emphasis (** / __) plus whitespace between the label and
// the colon/dash separator. Anchored at ^ so a normal answer that merely
// mentions "sources" mid-sentence, or ends with a "Sources:" section AFTER its
// prose, is never matched.
var citationLabelRe = regexp.MustCompile(`(?i)^` + leadingMarkers +
`(sources?|references?|citations?|works cited|further reading)\b[\s*_]*[:\-—]`)
// leadingMarkers tolerates markdown noise before a label: emphasis (*, _),
// list (-, +, *), block-quote (>), and ATX-heading (#) markers, with their
// whitespace. Shared by citationLabelRe and summaryCloserRe so the two
// classifiers cannot drift apart (the first draft of the summary class
// dropped '+' by hand-copying this set).
const leadingMarkers = `[\s>#*_+-]*`
// linkRe matches a whole markdown link "[label](url)" or a bare URL. Used both
// to require that a citations terminal carries at least one link and to strip
// links out when measuring how citation-dominated the terminal is.
var linkRe = regexp.MustCompile(`\[[^\]]*\]\([^)]*\)|https?://\S+`)
// citationResidueCutset is trimmed from the ends of a citations terminal's
// non-link remainder before measuring it — list bullets, separators, and the
// short per-source annotations models add in parentheses.
const citationResidueCutset = " \t\r\n,;.:|·•*_()[]—-"
const (
// weakFinalMaxChars bounds how long a back-reference closer can be. A
// genuine final answer that merely contains "as I said" mid-sentence is
// longer than this, so it is never treated as weak.
weakFinalMaxChars = 120
// abovePointerHeadChars bounds how far into the terminal a deictic "above"
// may sit and still read as pointing OUTSIDE this message (see
// pointsAbove). It applies to the deictic family only — backRefRe's fixed
// phrases are matched anywhere under the weak cap — so it is named for the
// pointer, not for the back-reference family as a whole.
//
// It IS weakFinalMaxChars: the same guard ("there is not enough text
// before the reference for it to be pointing at content inside this
// turn"), expressed as an offset because a summary closer carries a
// compression AFTER the pointer and so is not itself short. Defined by
// reference rather than by repeating the literal, because tuning the weak
// cap without the offset following it would split one rule into two.
abovePointerHeadChars = weakFinalMaxChars
// recoverMinChars: a prior assistant turn this long is treated as a real
// answer regardless of how it opens (the preamble filter is not applied at
// this length — see isSubstantiveAnswer).
recoverMinChars = 200
// recoverFloorChars / recoverRatio gate the borderline band: a shorter
// prior turn must clear the floor and — unless the terminal is a citations
// addendum, which is not a rival answer — also clearly dwarf the (very
// short) terminal. See isSubstantiveAnswer.
recoverFloorChars = 80
recoverRatio = 3
// citationDominatedDivisor: a citations terminal's non-link remainder must
// be at most len/N of the whole, so a prose answer that merely opens with
// "Source:" and cites a URL mid-sentence is not mistaken for a bare list.
citationDominatedDivisor = 3
// summaryCloserMaxChars bounds a summary closer: room for the ack sentence
// plus a couple of compression sentences (the b3cb9ee9 closer was 153
// bytes — Go len(), which is what every threshold here compares). Beyond
// this the "short version" is substantial enough that replacing it risks
// losing content the front-loaded turn never had.
summaryCloserMaxChars = 300
)
// isWeakFinal reports whether a terminal turn's text fails to stand on its own
// as the answer: empty/whitespace, or a short pure back-reference. The length
// cap is what keeps a genuine answer that merely contains a back-reference
// phrase mid-sentence out of the class; past the cap a deferring terminal is
// isSummaryCloser's business, under the stricter dwarf bar.
//
// The deictic half additionally demands that the pointer be the WHOLE message
// (bareAbovePointer), not merely present in it. Both classes now enforce the
// same rule from opposite ends: a terminal is only disposable when it carries
// no answer of its own — proved here by there being nothing beside the pointer,
// and in isSummaryCloser by the model declaring what is beside it a
// compression.
func isWeakFinal(s string) bool {
t := strings.TrimSpace(s)
if t == "" {
return true
}
return len(t) <= weakFinalMaxChars && (backRefRe.MatchString(t) || bareAbovePointer(t))
}
// isCitationsOnly reports whether a terminal turn is essentially just a
// sources/citations addendum: it OPENS with a citations heading, carries at
// least one link, and — once the heading and links are removed — is dominated
// by that citation structure (only list punctuation and short per-source
// annotations remain). The dominance check is what separates a bare sources
// list (recover the front-loaded answer, keep the links) from a real prose
// answer that merely opens with "Source:" and references a URL mid-sentence
// (leave it as the answer). Unlike a back-reference closer the links are worth
// keeping, so finalOutput appends them to the recovered answer.
//
// A citations terminal whose sources are bare domains (no scheme, no markdown
// link) is intentionally out of scope — there is no reliable link signal, so it
// is left as-is rather than risk misclassifying prose.
func isCitationsOnly(s string) bool {
t := strings.TrimSpace(s)
if !citationLabelRe.MatchString(t) {
return false
}
body := citationLabelRe.ReplaceAllString(t, "")
if !linkRe.MatchString(body) {
return false
}
residue := strings.Trim(linkRe.ReplaceAllString(body, ""), citationResidueCutset)
return len(residue) <= len(t)/citationDominatedDivisor
}
// isSummaryCloser reports whether a terminal turn defers to an earlier answer
// and is short enough that whatever follows the deferral can only be a
// compression of it. Two openers qualify:
//
// - a complete bookkeeping ack sentence — "Citations are logged." — which is
// summaryCloserRe, and carries no answer content of its own;
// - a deictic back-reference in the opening PLUS a compression marker —
// mort issue #1611's "Done — that's the full chain above. Short version:
// …" — which the ack shape alone did not cover.
//
// The second opener needs both halves. A pointer on its own does not make a
// terminal disposable: "Given the analysis above, I recommend option B because
// X" points backwards and then states a conclusion the earlier turn never
// contained, and replacing it with that turn would discard the answer. The
// compression marker is the model saying the opposite — that what follows is
// the short form of something it already wrote.
//
// Whether the fuller answer actually exists is modeSummary's job — the dwarf
// ratio in isSubstantiveAnswer keeps a matching closer in place when nothing
// earlier clearly outweighs it.
//
// pointsAbove is used here; its sibling backRefRe is NOT. Those fixed phrases
// are matched anywhere in the text, and a 300-byte closer has room for a real
// answer that merely mentions "as I said" mid-sentence. pointsAbove is
// offset-bounded, so it stays anchored to the opening.
func isSummaryCloser(s string) bool {
t := strings.TrimSpace(s)
if t == "" || len(t) > summaryCloserMaxChars {
return false
}
return summaryCloserRe.MatchString(t) ||
(pointsAbove(t) && compressionMarkerRe.MatchString(t))
}
// lastSubstantiveAssistantText scans msgs newest→oldest (skipping the terminal
// turn and empty tool-only turns) for the most recent assistant turn whose text
// reads like a real answer. mode selects the recovery bar (see
// isSubstantiveAnswer). Returns ("", false) when nothing qualifies.
func lastSubstantiveAssistantText(msgs []llm.Message, terminal string, mode recoveryMode) (string, bool) {
tt := strings.TrimSpace(terminal)
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if mode == modeSummary && m.Role == llm.RoleUser {
// A summary closer compresses THIS turn's front-loaded answer, so
// the scan must not cross into an earlier question: once the dwarf
// ratio has rejected the current turn's text, walking further back
// would resurrect a stale answer to a DIFFERENT question — strictly
// worse than keeping the closer. (A mid-run steer message is also a
// user-role boundary; recovery then fails closed, which is fine.)
// The other modes keep their historical unbounded scan.
break
}
if m.Role != llm.RoleAssistant {
continue
}
txt := strings.TrimSpace(m.Text())
if txt == "" || txt == tt {
continue // the terminal turn itself, or an empty tool-only turn
}
if isSubstantiveAnswer(txt, tt, mode) {
return txt, true
}
}
return "", false
}
// isSubstantiveAnswer reports whether txt (a prior assistant turn) reads like a
// real answer rather than a preamble, relative to the terminal text.
//
// modeSummary demands the dwarf ratio FIRST, at every length: a summary closer
// carries a real (compressed) answer, so replacing it is only justified when
// the prior turn is clearly the fuller original it was compressed from.
//
// A sufficiently long turn (>= recoverMinChars) is otherwise accepted
// unconditionally: a multi-hundred-char turn is an answer even when it opens
// conversationally ("Sure, here's…", "Let me explain: …"), so the preamble
// filter is NOT applied to it — applying it there would drop a legitimate long
// front-loaded answer. Only in the borderline band does a turn have to clear a
// floor, not read like a short planning preamble ("Let me look that up…"),
// and — for modeBackRef only — also clearly dwarf the terminal (a citations
// addendum is not a rival answer, so its length is irrelevant; a summary
// closer already proved the ratio above).
func isSubstantiveAnswer(txt, terminal string, mode recoveryMode) bool {
dwarfs := len(txt) >= recoverRatio*len(terminal)
if mode == modeSummary && !dwarfs {
return false
}
if len(txt) >= recoverMinChars {
return true
}
if len(txt) < recoverFloorChars || preambleRe.MatchString(txt) {
return false
}
return mode != modeBackRef || dwarfs
}