fix(agent): address gadfly — don't preamble-veto long answers; dedup ignores <url> wrappers
- isSubstantiveAnswer: restore the original "≥200 chars ⇒ real answer"
unconditional accept (the preamble filter now applies only in the
borderline 80–200 band). Applying preambleRe regardless of length regressed
the recovery path: a long front-loaded answer opening with a conversational
word ("Sure,", "Let me explain:", "First,") was wrongly discarded
(flagged by glm-5.2 + claude-code/opus). The NVD-style planning-narration
case is already handled by the citation-dominance check, not this filter.
- citations dedup: compare with <url> angle-bracket wrappers stripped, so a
front-loaded turn that listed the same sources unwrapped still suppresses the
duplicate block.
- doc: spell out the citationLabelRe leading class members (+ list markers);
drop the stale "gated by preamble" note on recoverMinChars.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+30
-18
@@ -45,15 +45,26 @@ func finalOutput(msgs []llm.Message, terminal string) string {
|
|||||||
}
|
}
|
||||||
if citations {
|
if citations {
|
||||||
// Preserve the citations addendum below the recovered answer, unless the
|
// Preserve the citations addendum below the recovered answer, unless the
|
||||||
// recovered turn already carries it verbatim (guards against a duplicate
|
// recovered turn already carries it (guards against a duplicate sources
|
||||||
// sources block when the front-loaded turn included its own citations).
|
// block when the front-loaded turn included its own citations). The
|
||||||
if tail := strings.TrimSpace(terminal); !strings.Contains(rec, tail) {
|
// 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 + "\n\n" + tail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rec
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// backRefRe matches a terminal turn that merely points back to an earlier
|
// backRefRe matches a terminal turn that merely points back to an earlier
|
||||||
// message instead of stating the answer ("(Already answered above.)",
|
// message instead of stating the answer ("(Already answered above.)",
|
||||||
// "see above", "as I said", ...).
|
// "see above", "as I said", ...).
|
||||||
@@ -66,11 +77,12 @@ var preambleRe = regexp.MustCompile(`(?i)^(let me|let'?s|i'?ll|i will|first[, ]|
|
|||||||
// citationLabelRe matches a terminal turn that OPENS with a sources/citations
|
// 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
|
// 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
|
// earlier tool-call turn and closes with only its sources. Leading markdown
|
||||||
// emphasis / list / block-quote / ATX-heading markers (with their whitespace,
|
// emphasis (*, _), list (-, +, *), block-quote (>), and ATX-heading (#) markers
|
||||||
// via \s in the class) are tolerated before the label, and closing emphasis
|
// — with their whitespace, since \s is in the class — are tolerated before the
|
||||||
// (** / __) plus whitespace between the label and the colon/dash separator.
|
// label, as is closing emphasis (** / __) plus whitespace between the label and
|
||||||
// Anchored at ^ so a normal answer that merely mentions "sources" mid-sentence,
|
// the colon/dash separator. Anchored at ^ so a normal answer that merely
|
||||||
// or ends with a "Sources:" section AFTER its prose, is never matched.
|
// 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*_]*[::\-—]`)
|
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
|
// linkRe matches a whole markdown link "[label](url)" or a bare URL. Used both
|
||||||
@@ -89,7 +101,8 @@ const (
|
|||||||
// longer than this, so it is never treated as weak.
|
// longer than this, so it is never treated as weak.
|
||||||
weakFinalMaxChars = 120
|
weakFinalMaxChars = 120
|
||||||
// recoverMinChars: a prior assistant turn this long is treated as a real
|
// recoverMinChars: a prior assistant turn this long is treated as a real
|
||||||
// answer regardless of how it opens (still gated by the preamble check).
|
// answer regardless of how it opens (the preamble filter is not applied at
|
||||||
|
// this length — see isSubstantiveAnswer).
|
||||||
recoverMinChars = 200
|
recoverMinChars = 200
|
||||||
// recoverFloorChars / recoverRatio gate the borderline band: a shorter
|
// recoverFloorChars / recoverRatio gate the borderline band: a shorter
|
||||||
// prior turn must clear the floor and — unless the terminal is a citations
|
// prior turn must clear the floor and — unless the terminal is a citations
|
||||||
@@ -164,20 +177,19 @@ func lastSubstantiveAssistantText(msgs []llm.Message, terminal string, citations
|
|||||||
// isSubstantiveAnswer reports whether txt (a prior assistant turn) reads like a
|
// isSubstantiveAnswer reports whether txt (a prior assistant turn) reads like a
|
||||||
// real answer rather than a preamble, relative to the terminal text.
|
// 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,
|
// A sufficiently long turn (>= recoverMinChars) is accepted unconditionally: a
|
||||||
// so that check applies BEFORE the length shortcut — otherwise a long planning
|
// multi-hundred-char turn is an answer even when it opens conversationally
|
||||||
// narration would be recovered. A sufficiently long non-preamble turn is
|
// ("Sure, here's…", "Let me explain: …"), so the preamble filter is NOT applied
|
||||||
// accepted; a shorter one must clear a floor and, unless the terminal is a
|
// to it — applying it there would drop a legitimate long front-loaded answer.
|
||||||
// citations addendum (not a rival answer, so its length is irrelevant), also
|
// 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 — unless the terminal is
|
||||||
|
// a citations addendum (not a rival answer, so its length is irrelevant) — also
|
||||||
// clearly dwarf the terminal.
|
// clearly dwarf the terminal.
|
||||||
func isSubstantiveAnswer(txt, terminal string, citations bool) bool {
|
func isSubstantiveAnswer(txt, terminal string, citations bool) bool {
|
||||||
if preambleRe.MatchString(txt) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(txt) >= recoverMinChars {
|
if len(txt) >= recoverMinChars {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if len(txt) < recoverFloorChars {
|
if len(txt) < recoverFloorChars || preambleRe.MatchString(txt) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return citations || len(txt) >= recoverRatio*len(terminal)
|
return citations || len(txt) >= recoverRatio*len(terminal)
|
||||||
|
|||||||
Reference in New Issue
Block a user