Files
majordomo/agent/finalize.go
T

185 lines
8.3 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"
"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. Two 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 citations addendum is tested first and wins over the back-reference test (a
// short terminal can be both), so its links are never discarded. 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 bare
// sources list 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 {
citations := isCitationsOnly(terminal)
if !citations && !isWeakFinal(terminal) {
return terminal
}
rec, ok := lastSubstantiveAssistantText(msgs, terminal, citations)
if !ok {
return terminal
}
if citations {
// Preserve the citations addendum below the recovered answer, unless the
// recovered turn already carries it verbatim (guards against a duplicate
// sources block when the front-loaded turn included its own citations).
if tail := strings.TrimSpace(terminal); !strings.Contains(rec, tail) {
return rec + "\n\n" + tail
}
}
return rec
}
// 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", ...).
var backRefRe = regexp.MustCompile(`(?i)(already answered|see above|as (i )?(said|mentioned|stated|noted)|answered (that )?above|per my (previous|earlier))`)
// 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 / ATX-heading markers (with their whitespace,
// via \s in the class) are tolerated before the label, and 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)^[\s>#*_+-]*(sources?|references?|citations?|works cited|further reading)\b[\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
// recoverMinChars: a prior assistant turn this long is treated as a real
// answer regardless of how it opens (still gated by the preamble check).
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
)
// 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.
func isWeakFinal(s string) bool {
t := strings.TrimSpace(s)
if t == "" {
return true
}
return len(t) <= weakFinalMaxChars && backRefRe.MatchString(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
}
// 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. citations selects the recovery bar (see
// isSubstantiveAnswer). Returns ("", false) when nothing qualifies.
func lastSubstantiveAssistantText(msgs []llm.Message, terminal string, citations bool) (string, bool) {
tt := strings.TrimSpace(terminal)
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
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, citations) {
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.
//
// A preamble ("Let me look that up…") is never the answer regardless of length,
// so that check applies BEFORE the length shortcut — otherwise a long planning
// narration would be recovered. A sufficiently long non-preamble turn is
// accepted; a shorter one must clear a floor and, unless the terminal is a
// citations addendum (not a rival answer, so its length is irrelevant), also
// clearly dwarf the terminal.
func isSubstantiveAnswer(txt, terminal string, citations bool) bool {
if preambleRe.MatchString(txt) {
return false
}
if len(txt) >= recoverMinChars {
return true
}
if len(txt) < recoverFloorChars {
return false
}
return citations || len(txt) >= recoverRatio*len(terminal)
}