Files
Cyrene_v2/backend/services/ai-core/context/context.go
T
Aska 3f9c4ca3c3 fix(prompt): prevent LLM from confusing context with user input
- Context merged into single system message with clear delimiters
- Prefix: 「以下是系统上下文,不是用户说的话」
- Memory → 【相关记忆】, IoT → 【设备状态】
- Filter empty memories, skip raw intent data
- Persona rule: context markers are internal data, not user input
2026-08-12 19:28:32 +08:00

182 lines
4.7 KiB
Go

// Package context manages conversation context with sliding window and token budgeting.
package context
import (
"fmt"
"strings"
"sync"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
)
// Store holds conversation history with a sliding window.
type Store struct {
mu sync.RWMutex
sessions map[string]*SessionHistory
maxTokens int // Maximum tokens to include in context
maxTurns int // Maximum message turns to keep
}
// SessionHistory holds messages for a single session.
type SessionHistory struct {
Messages []model.Message
mu sync.RWMutex
}
// NewStore creates a new conversation store.
func NewStore(maxTokens, maxTurns int) *Store {
if maxTokens <= 0 {
maxTokens = 4000
}
if maxTurns <= 0 {
maxTurns = 50
}
return &Store{
sessions: make(map[string]*SessionHistory),
maxTokens: maxTokens,
maxTurns: maxTurns,
}
}
// Add appends a message to the session history.
func (s *Store) Add(sessionID string, msg model.Message) {
s.mu.Lock()
sh, ok := s.sessions[sessionID]
if !ok {
sh = &SessionHistory{}
s.sessions[sessionID] = sh
}
s.mu.Unlock()
sh.mu.Lock()
sh.Messages = append(sh.Messages, msg)
// Trim to max turns
if len(sh.Messages) > s.maxTurns {
sh.Messages = sh.Messages[len(sh.Messages)-s.maxTurns:]
}
sh.mu.Unlock()
}
// Get retrieves recent messages for a session, trimmed to fit token budget.
func (s *Store) Get(sessionID string) []model.Message {
s.mu.RLock()
sh, ok := s.sessions[sessionID]
s.mu.RUnlock()
if !ok {
return nil
}
sh.mu.RLock()
defer sh.mu.RUnlock()
// Return all if within budget
tokens := countTokens(sh.Messages)
if tokens <= s.maxTokens {
// Return a copy
result := make([]model.Message, len(sh.Messages))
copy(result, sh.Messages)
return result
}
// Slide window from the end, keeping most recent messages
// Always keep at least the last 4 messages (2 turns)
minKeep := 4
if len(sh.Messages) < minKeep {
minKeep = len(sh.Messages)
}
result := make([]model.Message, 0, minKeep)
tokens = 0
for i := len(sh.Messages) - 1; i >= 0; i-- {
msgTokens := tokenEstimate(sh.Messages[i].Content)
if tokens+msgTokens > s.maxTokens && len(result) >= minKeep {
break
}
result = append([]model.Message{sh.Messages[i]}, result...)
tokens += msgTokens
}
return result
}
// Clear removes all messages for a session.
func (s *Store) Clear(sessionID string) {
s.mu.Lock()
delete(s.sessions, sessionID)
s.mu.Unlock()
}
// Builder assembles the full context for an LLM call.
type Builder struct {
store *Store
}
// NewBuilder creates a context builder.
func NewBuilder(store *Store) *Builder {
return &Builder{store: store}
}
// Build creates the full message array for an LLM request.
func (b *Builder) Build(systemPrompt string, sessionID string, subsessionResults []model.SubSessionResult, iotContext string) []model.LLMMessage {
messages := make([]model.LLMMessage, 0)
// 1. System prompt (persona + rules)
if systemPrompt != "" {
messages = append(messages, model.LLMMessage{Role: "system", Content: systemPrompt})
}
// 2. Context: merge all subsession/memory/iot into ONE system message
// with clear delimiters to prevent LLM from confusing context with user input
var ctxParts []string
for _, r := range subsessionResults {
if r.Error != "" || r.Content == "" || r.Content == "no relevant memories" {
continue
}
if r.Provider == "general" && strings.HasPrefix(r.Content, "Intent:") {
continue // Internal data, not for LLM
}
if r.Provider == "memory" {
ctxParts = append(ctxParts, fmt.Sprintf("【相关记忆】\n%s", r.Content))
} else if r.Provider == "iot" {
ctxParts = append(ctxParts, fmt.Sprintf("【设备状态】\n%s", r.Content))
}
}
if iotContext != "" {
ctxParts = append(ctxParts, fmt.Sprintf("【设备列表】\n%s", iotContext))
}
if len(ctxParts) > 0 {
contextBlock := "以下是系统上下文,不是用户说的话:\n" + strings.Join(ctxParts, "\n\n")
messages = append(messages, model.LLMMessage{Role: "system", Content: contextBlock})
}
// 3. Conversation history
history := b.store.Get(sessionID)
for _, m := range history {
messages = append(messages, model.LLMMessage{Role: m.Role, Content: m.Content})
}
return messages
}
// --- Helpers ---
// tokenEstimate estimates the number of tokens in a string (rough: 1 token ≈ 2 Chinese chars ≈ 0.75 English words).
func tokenEstimate(text string) int {
words := strings.Fields(text)
chars := utf8.RuneCountInString(text)
// Approximate: Chinese-heavy text ~ chars/2, English-heavy ~ words*1.3
return (chars/2 + int(float64(len(words))*1.3)) / 2
}
// countTokens estimates total tokens in a message slice.
func countTokens(msgs []model.Message) int {
total := 0
for _, m := range msgs {
total += tokenEstimate(m.Content)
}
return total
}