- 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
182 lines
4.7 KiB
Go
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
|
|
}
|