Files
goclaw/internal/agent/extractive_memory.go
nguyenha935andnguyenha935 2c7903e7ea fix(agent): make the extractive memory fallback work on non-English sessions (#1484)
The extractive fallback runs when an LLM memory flush fails, so it is the last
thing standing between a session and losing its accumulated decisions. Its cue
patterns were English-only ("decided to", "prefer", "the API is"), which makes it
effectively inert for a session conducted in another language: it still returns a
non-empty result, so nothing looks broken, but the result is noise.

Measured on 195k characters of real Vietnamese session text: 9 decision matches
(all of them stray English fragments), 0 preferences, 0 technical facts — about
97% of what it "saved" was incidental file paths. After adding Vietnamese cues the
same corpus yields 21 decisions and 6 preferences on the largest session.

Adds parallel Vietnamese patterns for the same three categories and merges them
into the existing results with deduplication, so English extraction is unchanged
and mixed-language sessions get both. Tests cover Vietnamese-only,
English-only, and mixed histories, plus a guard that ordinary Vietnamese
narration is not misread as a decision or a preference.

Co-authored-by: nguyenha935 <nguyenha935@users.noreply.github.com>
2026-07-29 03:30:42 +07:00

184 lines
6.1 KiB
Go

package agent
import (
"regexp"
"slices"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// Regex patterns for extractive memory fallback.
var (
// Decisions: "decided to", "let's go with", "approved", "agreed on", "chose", "we'll use"
reDecision = regexp.MustCompile(`(?i)(?:decided\s+to|let'?s\s+go\s+with|approved|agreed\s+on|chose|we'?ll\s+use)\s+.{5,120}`)
// User preferences: "I prefer", "don't do", "always", "never", "I want", "please remember"
rePreference = regexp.MustCompile(`(?i)(?:I\s+prefer|don'?t\s+do|always\s+|never\s+|I\s+want|please\s+remember)\s+.{5,120}`)
// Technical facts: "the API is", "endpoint is", "version is", "uses X for Y"
reTechFact = regexp.MustCompile(`(?i)(?:the\s+API\s+is|endpoint\s+is|version\s+is|uses?\s+\S+\s+for)\s+.{3,120}`)
// URLs
reURL = regexp.MustCompile(`https?://[^\s)<>]{8,200}`)
// File paths (Unix-style and common project paths)
reFilePath = regexp.MustCompile(`(?:^|\s)([/~][\w./-]{5,120}|[\w-]+/[\w./-]{5,80})`)
// Dates in common formats
reDate = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}\b`)
// Vietnamese counterparts of the three semantic patterns above. Without
// these the fallback is inert on a Vietnamese deployment: measured against
// 195k chars of real Vietnamese session text, the English patterns produced
// 9 decision matches (all from stray English), 0 preferences and 0 technical
// facts — 97% of everything "saved" was incidental file paths. A fallback
// that exists so a failed LLM flush still preserves context has to work in
// the language the conversation is actually in.
//
// The cues are chosen to be unambiguous verbs/markers rather than bare
// keywords: "chọn" alone appears inside ordinary prose, "đã chọn" states a
// decision.
reDecisionVI = regexp.MustCompile(`(?i)(?:quyết\s+định|đã\s+chọn|chọn\s+phương\s+án|thống\s+nhất|chốt\s+lại|chốt\s+phương\s+án|đồng\s+ý|duyệt|sẽ\s+dùng|sẽ\s+sử\s+dụng)\s+.{5,120}`)
rePreferenceVI = regexp.MustCompile(`(?i)(?:tôi\s+muốn|em\s+muốn|anh\s+muốn|con\s+muốn|nhớ\s+là|lưu\s+ý|đừng\s+|không\s+được\s+|luôn\s+luôn|tuyệt\s+đối\s+không|ưu\s+tiên)\s+.{5,120}`)
reTechFactVI = regexp.MustCompile(`(?i)(?:API\s+là|endpoint\s+là|phiên\s+bản\s+là|cấu\s+hình\s+là|dùng\s+\S+\s+(?:để|cho)|chạy\s+trên|nằm\s+ở)\s+.{3,120}`)
)
// ExtractiveMemoryFallback extracts key information from conversation history
// using regex patterns. Used as a safety net when LLM-based memory flush
// returns NO_REPLY or produces no output, preventing context loss during compaction.
func ExtractiveMemoryFallback(history []providers.Message) string {
if len(history) == 0 {
return ""
}
// Collect only user and assistant content (skip system, tool)
var texts []string
for _, msg := range history {
if msg.Role == "user" || msg.Role == "assistant" {
if content := strings.TrimSpace(msg.Content); content != "" {
texts = append(texts, content)
}
}
}
if len(texts) == 0 {
return ""
}
combined := strings.Join(texts, "\n")
// Extract by category, dedup with a set. Both language sets always run: a
// session can mix languages (Vietnamese prose quoting English error text or
// config keys), so picking one set by detected locale would drop the other
// half. dedup keeps an overlap from being stored twice.
decisions := extractUnique(reDecision, combined)
for _, d := range extractUnique(reDecisionVI, combined) {
decisions = appendIfAbsent(decisions, d)
}
preferences := extractUnique(rePreference, combined)
for _, p := range extractUnique(rePreferenceVI, combined) {
preferences = appendIfAbsent(preferences, p)
}
// Technical facts = regex matches + URLs + dates
techFacts := extractUnique(reTechFact, combined)
for _, f := range extractUnique(reTechFactVI, combined) {
techFacts = appendIfAbsent(techFacts, f)
}
for _, u := range extractUnique(reURL, combined) {
techFacts = appendIfAbsent(techFacts, u)
}
for _, fp := range extractUniqueSubmatch(reFilePath, combined, 1) {
techFacts = appendIfAbsent(techFacts, fp)
}
for _, d := range extractUnique(reDate, combined) {
techFacts = appendIfAbsent(techFacts, d)
}
// Build output — only include non-empty sections
if len(decisions) == 0 && len(techFacts) == 0 && len(preferences) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("## Extracted Context (auto-saved before compaction)\n")
if len(decisions) > 0 {
sb.WriteString("\n### Decisions\n")
for _, d := range decisions {
sb.WriteString("- ")
sb.WriteString(strings.TrimSpace(d))
sb.WriteByte('\n')
}
}
if len(techFacts) > 0 {
sb.WriteString("\n### Key Facts\n")
for _, f := range techFacts {
sb.WriteString("- ")
sb.WriteString(strings.TrimSpace(f))
sb.WriteByte('\n')
}
}
if len(preferences) > 0 {
sb.WriteString("\n### User Preferences\n")
for _, p := range preferences {
sb.WriteString("- ")
sb.WriteString(strings.TrimSpace(p))
sb.WriteByte('\n')
}
}
return sb.String()
}
// extractUnique returns deduplicated matches from a regex pattern.
func extractUnique(re *regexp.Regexp, text string) []string {
matches := re.FindAllString(text, -1)
return dedup(matches)
}
// extractUniqueSubmatch returns deduplicated submatch captures (by group index).
func extractUniqueSubmatch(re *regexp.Regexp, text string, group int) []string {
matches := re.FindAllStringSubmatch(text, -1)
var results []string
for _, m := range matches {
if group < len(m) {
s := strings.TrimSpace(m[group])
if s != "" {
results = append(results, s)
}
}
}
return dedup(results)
}
// dedup removes duplicate strings while preserving order.
func dedup(items []string) []string {
seen := make(map[string]struct{}, len(items))
var result []string
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
return result
}
// appendIfAbsent appends s to slice only if not already present.
func appendIfAbsent(slice []string, s string) []string {
if slices.Contains(slice, s) {
return slice
}
return append(slice, s)
}