Files
Cyrene_v2/backend/services/ai-core/context/context.go
T
Aska f8196c111c feat(gateway): persist chat sessions and messages in PostgreSQL
- store: new chat_sessions/chat_messages tables with CRUD, ILIKE message
  search, and auto-titling from the first user message
- context: optional Persister on the sliding-window store — messages are
  saved asynchronously on Add, sessions not in memory are restored from
  the database on Get
- orchestrator: ProcessInput/FinishRound now carry userID so persisted
  messages have an owner
- api: real session handlers (list/create/delete), message history and
  clear endpoints, working message search and three-format export
  (markdown/txt/json), fixing the short-ID panic in export
- main: migration, store wiring, ctxStore persister adapter

Tests: chat store handler lifecycle (fake store), context persister
save/restore, stub auth login flow updated.
2026-08-20 20:34:08 +08:00

228 lines
6.2 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"
)
// Persister saves conversation messages durably and can restore them
// (e.g. from PostgreSQL) when a session is not in memory.
type Persister interface {
// SaveMessage persists one message. Called synchronously by Add —
// implementations should be fast or dispatch async work themselves.
SaveMessage(sessionID, userID string, msg model.Message)
// LoadRecent returns the most recent messages of a session, ascending.
LoadRecent(sessionID string, limit int) []model.Message
}
// 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
persister Persister
}
// 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,
}
}
// SetPersister injects durable persistence for conversation messages.
func (s *Store) SetPersister(p Persister) {
s.mu.Lock()
s.persister = p
s.mu.Unlock()
}
// Add appends a message to the session history.
func (s *Store) Add(sessionID string, msg model.Message) {
s.add(sessionID, "", msg)
}
// AddWithUser appends a message and records the owning user for persistence.
func (s *Store) AddWithUser(sessionID, userID string, msg model.Message) {
s.add(sessionID, userID, msg)
}
func (s *Store) add(sessionID, userID string, msg model.Message) {
s.mu.Lock()
sh, ok := s.sessions[sessionID]
if !ok {
sh = &SessionHistory{}
s.sessions[sessionID] = sh
}
persister := s.persister
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()
// Persist asynchronously — never block the conversation on the database.
if persister != nil && (msg.Role == "user" || msg.Role == "assistant") {
go persister.SaveMessage(sessionID, userID, msg)
}
}
// Get retrieves recent messages for a session, trimmed to fit token budget.
// Sessions missing from memory are restored from the persister if available.
func (s *Store) Get(sessionID string) []model.Message {
s.mu.RLock()
sh, ok := s.sessions[sessionID]
persister := s.persister
s.mu.RUnlock()
if !ok {
if persister == nil {
return nil
}
loaded := persister.LoadRecent(sessionID, s.maxTurns)
if len(loaded) == 0 {
return nil
}
sh = &SessionHistory{Messages: loaded}
s.mu.Lock()
s.sessions[sessionID] = sh
s.mu.Unlock()
}
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
}