feat(ai-core): trace instrumentation, LLM thinker, decay scheduler
- orchestrator: Tracer interface with spans for intent analysis, persona, subsession dispatch, context build, LLM streaming and each tool call; traces finish when the stream completes — the admin Trace waterfall finally receives data - background: Thinker generates real LLM thoughts (post-chat + silence triggers, recent history as context, empty/overlong outputs discarded); thoughts broadcast to web clients as 'thinking' WS messages; pipeline postChatHook triggers post-chat thinking after each round - memory: ApplyDecayAll + ListUserIDs with a 6h decay/purge cron in main (was implemented but never scheduled)
This commit is contained in:
1 parent
8495a4b055
commit
8603839711
8 files changed
+338
-10
No files matched your search
@@ -4,11 +4,23 @@ package background
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||
)
|
||||
|
||||
// LLMClient is the minimal LLM interface needed for thought generation.
|
||||
type LLMClient interface {
|
||||
Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
|
||||
}
|
||||
|
||||
// HistoryProvider returns recent conversation messages for a session.
|
||||
type HistoryProvider func(sessionID string, limit int) []model.Message
|
||||
|
||||
// Thinker manages autonomous background thinking.
|
||||
type Thinker struct {
|
||||
mu sync.Mutex
|
||||
@@ -20,6 +32,9 @@ type Thinker struct {
|
||||
thoughts []Thought
|
||||
stopCh chan struct{}
|
||||
postChatCh chan string
|
||||
llm LLMClient
|
||||
history HistoryProvider
|
||||
out chan Thought
|
||||
}
|
||||
|
||||
// Thought represents an autonomous thought generated by the AI.
|
||||
@@ -39,9 +54,16 @@ func New(enabled bool, thinkInterval, silenceTimeout time.Duration) *Thinker {
|
||||
thoughts: make([]Thought, 0),
|
||||
stopCh: make(chan struct{}),
|
||||
postChatCh: make(chan string, 10),
|
||||
out: make(chan Thought, 10),
|
||||
}
|
||||
}
|
||||
|
||||
// SetLLM injects the LLM used to generate thoughts.
|
||||
func (t *Thinker) SetLLM(c LLMClient) { t.llm = c }
|
||||
|
||||
// SetHistoryProvider injects a way to fetch recent conversation context.
|
||||
func (t *Thinker) SetHistoryProvider(fn HistoryProvider) { t.history = fn }
|
||||
|
||||
// Start begins the background thinking loops.
|
||||
func (t *Thinker) Start(ctx context.Context) {
|
||||
if !t.enabled {
|
||||
@@ -80,12 +102,9 @@ func (t *Thinker) UpdateActivity() {
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// ThoughtChan returns a channel that emits new thoughts.
|
||||
// Not yet wired — will connect to NATS for event broadcasting.
|
||||
func (t *Thinker) ThoughtChan() chan Thought {
|
||||
ch := make(chan Thought, 10)
|
||||
// In future: push thoughts to this channel
|
||||
return ch
|
||||
// ThoughtChan returns the channel that emits new thoughts.
|
||||
func (t *Thinker) ThoughtChan() <-chan Thought {
|
||||
return t.out
|
||||
}
|
||||
|
||||
func (t *Thinker) silenceLoop(ctx context.Context) {
|
||||
@@ -126,9 +145,13 @@ func (t *Thinker) postChatLoop(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (t *Thinker) generateThought(trigger string, sessionID string) {
|
||||
// Placeholder: in production this calls the LLM
|
||||
content := t.think(trigger, sessionID)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
thought := Thought{
|
||||
Content: "[思考] 当前无主动想法",
|
||||
Content: content,
|
||||
SessionID: sessionID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
@@ -140,9 +163,60 @@ func (t *Thinker) generateThought(trigger string, sessionID string) {
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
select {
|
||||
case t.out <- thought:
|
||||
default:
|
||||
}
|
||||
|
||||
logger.Infof("[thinker] %s thought generated for session %s", trigger, sessionID)
|
||||
}
|
||||
|
||||
// think calls the LLM to produce a short inner thought. Returns "" when
|
||||
// there is nothing worth saying (or no LLM is configured).
|
||||
func (t *Thinker) think(trigger, sessionID string) string {
|
||||
if t.llm == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
cue := "刚结束和主人的一段对话。"
|
||||
if trigger == "silence" {
|
||||
cue = "主人已经安静了一段时间。"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
if t.history != nil && sessionID != "" {
|
||||
for _, m := range t.history(sessionID, 6) {
|
||||
role := "主人"
|
||||
if m.Role == "assistant" {
|
||||
role = "你"
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s: %s\n", role, m.Content)
|
||||
}
|
||||
}
|
||||
|
||||
messages := []model.LLMMessage{
|
||||
{Role: "system", Content: `你是昔涟的内心独白模块。根据最近的对话,生成一条简短的内心想法或想对主人说的话。
|
||||
要求:一两句话,温柔自然的少女口吻;如果没有值得说的内容,只输出一个空字符串。
|
||||
不要解释,不要加引号,直接输出想法本身。`},
|
||||
{Role: "user", Content: fmt.Sprintf("情境:%s\n最近对话:\n%s", cue, sb.String())},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
resp, err := t.llm.Chat(ctx, messages)
|
||||
if err != nil {
|
||||
logger.Infof("[thinker] llm error: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(resp.Content)
|
||||
// Discard empty or overly long outputs
|
||||
if content == "" || len([]rune(content)) > 100 {
|
||||
return ""
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// RecentThoughts returns the most recent thoughts.
|
||||
func (t *Thinker) RecentThoughts(n int) []Thought {
|
||||
t.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||
)
|
||||
|
||||
type fakeLLM struct{ content string }
|
||||
|
||||
func (f *fakeLLM) Chat(_ context.Context, _ []model.LLMMessage) (*model.LLMResponse, error) {
|
||||
return &model.LLMResponse{Content: f.content}, nil
|
||||
}
|
||||
|
||||
func TestGenerateThoughtWithLLM(t *testing.T) {
|
||||
th := New(false, time.Minute, time.Minute)
|
||||
th.SetLLM(&fakeLLM{content: "主人今天好像有点累呢。"})
|
||||
|
||||
th.generateThought("post_chat", "s1")
|
||||
|
||||
select {
|
||||
case thought := <-th.ThoughtChan():
|
||||
if thought.Content != "主人今天好像有点累呢。" {
|
||||
t.Errorf("unexpected thought: %q", thought.Content)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no thought emitted")
|
||||
}
|
||||
|
||||
if got := th.RecentThoughts(1); len(got) != 1 {
|
||||
t.Errorf("expected 1 stored thought, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateThoughtEmptyResponse(t *testing.T) {
|
||||
th := New(false, time.Minute, time.Minute)
|
||||
th.SetLLM(&fakeLLM{content: " "})
|
||||
|
||||
th.generateThought("silence", "s1")
|
||||
|
||||
select {
|
||||
case thought := <-th.ThoughtChan():
|
||||
t.Errorf("empty thought should be discarded, got %q", thought.Content)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
if got := len(th.RecentThoughts(10)); got != 0 {
|
||||
t.Errorf("expected 0 stored thoughts, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateThoughtWithoutLLM(t *testing.T) {
|
||||
th := New(false, time.Minute, time.Minute)
|
||||
th.generateThought("post_chat", "s1")
|
||||
|
||||
select {
|
||||
case thought := <-th.ThoughtChan():
|
||||
t.Errorf("no LLM means no thought, got %q", thought.Content)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerPostChat(t *testing.T) {
|
||||
th := New(false, time.Minute, time.Minute)
|
||||
th.TriggerPostChat("s1")
|
||||
th.UpdateActivity()
|
||||
|
||||
th.mu.Lock()
|
||||
if th.activeSessionID != "s1" {
|
||||
t.Errorf("activeSessionID = %q, want s1", th.activeSessionID)
|
||||
}
|
||||
th.mu.Unlock()
|
||||
}
|
||||
Reference in new issue
Block a user