Files
CyberStrikeAI/internal/multiagent/eino_summarize_output.go
T
2026-07-09 11:43:27 +08:00

291 lines
8.4 KiB
Go

package multiagent
import (
"fmt"
"regexp"
"strings"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
var (
summarizationAnalysisBlockRegex = regexp.MustCompile(`(?is)<analysis>\s*.*?\s*</analysis>`)
summarizationSummaryBlockRegex = regexp.MustCompile(`(?is)<summary>\s*(.*?)\s*</summary>`)
userIntentLedgerBlockRegex = regexp.MustCompile(`(?is)<original_user_intent_ledger>\s*(.*?)\s*</original_user_intent_ledger>`)
userIntentLedgerSectionRegex = regexp.MustCompile(`(?is)\s*## 原始用户输入与约束账本(系统保真)\s*<original_user_intent_ledger>\s*.*?\s*</original_user_intent_ledger>\s*`)
)
const (
userIntentLedgerStartMarker = "<original_user_intent_ledger>"
userIntentLedgerEndMarker = "</original_user_intent_ledger>"
)
// stripAnalysisFromSummarizationMessage removes the <analysis> block from a post-processed
// Eino summary user message. Analysis helps one-shot generation quality but should not
// occupy continuation context after compaction.
func stripAnalysisFromSummarizationMessage(msg adk.Message) adk.Message {
if msg == nil {
return msg
}
cloned := *msg
if cloned.Content != "" {
cloned.Content = stripAnalysisFromSummarizationText(cloned.Content)
}
if len(cloned.UserInputMultiContent) > 0 {
parts := make([]schema.MessageInputPart, len(cloned.UserInputMultiContent))
copy(parts, cloned.UserInputMultiContent)
// Only the first text part carries model output plus Eino preamble/transcript path.
for i := range parts {
if parts[i].Type != schema.ChatMessagePartTypeText || parts[i].Text == "" {
continue
}
if i == 0 {
parts[i].Text = stripAnalysisFromSummarizationText(parts[i].Text)
}
break
}
cloned.UserInputMultiContent = parts
}
return &cloned
}
func stripAnalysisFromSummarizationText(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return text
}
stripped := strings.TrimSpace(summarizationAnalysisBlockRegex.ReplaceAllString(text, ""))
if stripped == "" {
return text
}
return stripped
}
// extractSummarizationSummaryBody returns the inner text of the last <summary> block when present.
// Used by tests and optional strict compaction paths.
func extractSummarizationSummaryBody(text string) (string, bool) {
text = strings.TrimSpace(text)
if text == "" {
return "", false
}
all := summarizationSummaryBlockRegex.FindAllStringSubmatch(text, -1)
if len(all) == 0 || len(all[len(all)-1]) < 2 {
return "", false
}
body := strings.TrimSpace(all[len(all)-1][1])
if body == "" {
return "", false
}
return body, true
}
// buildOriginalUserIntentLedgerMessage returns a deterministic, host-generated
// context anchor for raw user inputs. Keep it separate from the model-generated
// working summary so compaction can rewrite the summary without rewriting user intent.
func buildOriginalUserIntentLedgerMessage(originalMessages []adk.Message, maxRunes, entryMaxRunes int) adk.Message {
ledger := buildOriginalUserIntentLedger(originalMessages, maxRunes, entryMaxRunes)
if strings.TrimSpace(ledger) == "" {
return nil
}
return schema.SystemMessage(wrapUserIntentLedger(ledger))
}
func mergeMessageIntoLeadingSystem(msgs []adk.Message, msg adk.Message) []adk.Message {
if msg == nil {
return msgs
}
for i, existing := range msgs {
if existing == nil || existing.Role != schema.System {
continue
}
cloned := *existing
cloned.Content = strings.TrimSpace(strings.TrimSpace(cloned.Content) + "\n\n" + strings.TrimSpace(msg.Content))
out := make([]adk.Message, len(msgs))
copy(out, msgs)
out[i] = &cloned
return out
}
out := make([]adk.Message, 0, len(msgs)+1)
out = append(out, msg)
out = append(out, msgs...)
return out
}
func stripOriginalUserIntentLedgerFromMessages(msgs []adk.Message) []adk.Message {
if len(msgs) == 0 {
return msgs
}
out := make([]adk.Message, 0, len(msgs))
for _, msg := range msgs {
if msg == nil {
continue
}
cloned := *msg
cloned.Content = stripOriginalUserIntentLedgerFromText(cloned.Content)
if len(cloned.UserInputMultiContent) > 0 {
parts := make([]schema.MessageInputPart, len(cloned.UserInputMultiContent))
copy(parts, cloned.UserInputMultiContent)
for i := range parts {
if parts[i].Type == schema.ChatMessagePartTypeText {
parts[i].Text = stripOriginalUserIntentLedgerFromText(parts[i].Text)
}
}
cloned.UserInputMultiContent = parts
}
if strings.TrimSpace(cloned.Content) == "" && len(cloned.UserInputMultiContent) == 0 && len(cloned.ToolCalls) == 0 && cloned.ReasoningContent == "" {
continue
}
out = append(out, &cloned)
}
return out
}
func buildOriginalUserIntentLedger(msgs []adk.Message, maxRunes, entryMaxRunes int) string {
entries := collectOriginalUserIntentEntries(msgs)
if len(entries) == 0 {
return ""
}
var sb strings.Builder
for i, entry := range entries {
line := fmt.Sprintf("- [U%03d] %s\n", i+1, sanitizeUserIntentLedgerEntry(entry, entryMaxRunes))
if maxRunes > 0 && utf8RuneLen(sb.String())+utf8RuneLen(line) > maxRunes {
sb.WriteString("- [...truncated] 用户原始输入账本超过预算;完整压缩前记录见 summarization transcript。\n")
break
}
sb.WriteString(line)
}
return strings.TrimSpace(sb.String())
}
func collectOriginalUserIntentEntries(msgs []adk.Message) []string {
seen := make(map[string]struct{})
entries := make([]string, 0, 16)
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" || isSyntheticContinuationUserText(s) {
return
}
key := normalizeUserIntentLedgerText(s)
if key == "" {
return
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
entries = append(entries, s)
}
for _, msg := range msgs {
if msg == nil {
continue
}
for _, entry := range extractExistingUserIntentLedgerEntries(messageTextForLedger(msg)) {
add(entry)
}
if msg.Role == schema.User {
add(adkUserMessageText(msg))
}
}
return entries
}
func messageTextForLedger(msg adk.Message) string {
if msg == nil {
return ""
}
var b strings.Builder
if strings.TrimSpace(msg.Content) != "" {
b.WriteString(msg.Content)
}
for _, part := range msg.UserInputMultiContent {
if part.Type != schema.ChatMessagePartTypeText || strings.TrimSpace(part.Text) == "" {
continue
}
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(part.Text)
}
return b.String()
}
func extractExistingUserIntentLedgerEntries(text string) []string {
matches := userIntentLedgerBlockRegex.FindAllStringSubmatch(text, -1)
if len(matches) == 0 {
return nil
}
out := make([]string, 0, len(matches))
for _, match := range matches {
if len(match) < 2 {
continue
}
for _, line := range strings.Split(match[1], "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "- [...truncated]") {
continue
}
if strings.HasPrefix(line, "- [U") {
if idx := strings.Index(line, "]"); idx >= 0 && idx+1 < len(line) {
line = strings.TrimSpace(line[idx+1:])
}
}
if line != "" {
out = append(out, line)
}
}
}
return out
}
func stripOriginalUserIntentLedgerFromText(text string) string {
text = strings.TrimSpace(text)
if text == "" {
return ""
}
text = userIntentLedgerSectionRegex.ReplaceAllString(text, "\n")
text = userIntentLedgerBlockRegex.ReplaceAllString(text, "\n")
return strings.TrimSpace(text)
}
func wrapUserIntentLedger(ledger string) string {
return strings.TrimSpace("## 原始用户输入与约束账本(系统保真)\n" +
userIntentLedgerStartMarker + "\n" +
strings.TrimSpace(ledger) + "\n" +
userIntentLedgerEndMarker)
}
func sanitizeUserIntentLedgerEntry(s string, maxRunes int) string {
s = strings.ReplaceAll(strings.TrimSpace(s), userIntentLedgerStartMarker, "[ledger-start]")
s = strings.ReplaceAll(s, userIntentLedgerEndMarker, "[ledger-end]")
s = strings.Join(strings.Fields(s), " ")
if maxRunes > 0 && utf8RuneLen(s) > maxRunes {
return truncateUserIntentLedgerRunes(s, maxRunes) + "..."
}
return s
}
func normalizeUserIntentLedgerText(s string) string {
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
}
func isSyntheticContinuationUserText(s string) bool {
return strings.Contains(s, continuationSessionMarker) ||
strings.Contains(s, "【系统自动续跑 / Auto resume】")
}
func utf8RuneLen(s string) int {
return len([]rune(s))
}
func truncateUserIntentLedgerRunes(s string, n int) string {
if n <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n])
}