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
@@ -32,6 +32,7 @@ import (
|
|||||||
webfetch "git.yeij.top/AskaEth/Cyrene_v2/plugins/web-fetch"
|
webfetch "git.yeij.top/AskaEth/Cyrene_v2/plugins/web-fetch"
|
||||||
websearch "git.yeij.top/AskaEth/Cyrene_v2/plugins/web-search"
|
websearch "git.yeij.top/AskaEth/Cyrene_v2/plugins/web-search"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/background"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
|
||||||
llmlog "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
|
llmlog "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
|
||||||
tracepkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/trace"
|
tracepkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/trace"
|
||||||
@@ -244,6 +245,37 @@ func main() {
|
|||||||
perfMon := perfpkg.NewMonitor(120, 10*time.Second)
|
perfMon := perfpkg.NewMonitor(120, 10*time.Second)
|
||||||
perfMon.Start()
|
perfMon.Start()
|
||||||
|
|
||||||
|
// Wire tracing into the dialogue pipeline
|
||||||
|
orch.SetTracer(pipelineTracer{traceLogger})
|
||||||
|
|
||||||
|
// === 12.5 Background Thinker ===
|
||||||
|
thinker := background.New(true, 5*time.Minute, 30*time.Minute)
|
||||||
|
thinker.SetLLM(&memory.LLMAdapterBridge{Adapter: llmSelector.DefaultAdapter()})
|
||||||
|
thinker.SetHistoryProvider(func(sessionID string, limit int) []model.Message {
|
||||||
|
msgs := ctxStore.Get(sessionID)
|
||||||
|
if len(msgs) > limit {
|
||||||
|
msgs = msgs[len(msgs)-limit:]
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
})
|
||||||
|
thinker.Start(context.Background())
|
||||||
|
orch.SetPostChatHook(thinker.TriggerPostChat)
|
||||||
|
|
||||||
|
// === 12.6 Memory decay scheduler ===
|
||||||
|
if memSvc != nil {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(6 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
if n, err := memSvc.ApplyDecayAll(context.Background()); err != nil {
|
||||||
|
logger.Infof("[memory] decay run failed: %v", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
logger.Infof("[memory] decay applied (%d memories updated)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
gwDeps := api.Dependencies{
|
gwDeps := api.Dependencies{
|
||||||
AuthMgr: authMgr,
|
AuthMgr: authMgr,
|
||||||
Orchestrator: orch,
|
Orchestrator: orch,
|
||||||
@@ -267,6 +299,13 @@ func main() {
|
|||||||
wsHub := ws.NewHub(orch)
|
wsHub := ws.NewHub(orch)
|
||||||
gwDeps.WSHub = wsHub
|
gwDeps.WSHub = wsHub
|
||||||
|
|
||||||
|
// Push background thoughts to connected web clients
|
||||||
|
go func() {
|
||||||
|
for th := range thinker.ThoughtChan() {
|
||||||
|
wsHub.Broadcast(map[string]any{"type": "thinking", "content": th.Content})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
apiRouter := api.New(gwDeps)
|
apiRouter := api.New(gwDeps)
|
||||||
|
|
||||||
wsMux := http.NewServeMux()
|
wsMux := http.NewServeMux()
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ func (a *traceAdapter) Recent(n int) []map[string]any {
|
|||||||
|
|
||||||
func (a *traceAdapter) Clear() { a.Logger.Clear() }
|
func (a *traceAdapter) Clear() { a.Logger.Clear() }
|
||||||
|
|
||||||
|
// pipelineTracer adapts trace.Logger to orchestrator.Tracer
|
||||||
|
// (StartTrace's return value is discarded at the call site).
|
||||||
|
type pipelineTracer struct{ *tracepkg.Logger }
|
||||||
|
|
||||||
|
func (p pipelineTracer) StartTrace(id, userMsg string) { p.Logger.StartTrace(id, userMsg) }
|
||||||
|
|
||||||
func spanToMap(s tracepkg.Span) map[string]any {
|
func spanToMap(s tracepkg.Span) map[string]any {
|
||||||
m := map[string]any{
|
m := map[string]any{
|
||||||
"name": s.Name, "label": s.Label,
|
"name": s.Name, "label": s.Label,
|
||||||
|
|||||||
@@ -4,11 +4,23 @@ package background
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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.
|
// Thinker manages autonomous background thinking.
|
||||||
type Thinker struct {
|
type Thinker struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -20,6 +32,9 @@ type Thinker struct {
|
|||||||
thoughts []Thought
|
thoughts []Thought
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
postChatCh chan string
|
postChatCh chan string
|
||||||
|
llm LLMClient
|
||||||
|
history HistoryProvider
|
||||||
|
out chan Thought
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thought represents an autonomous thought generated by the AI.
|
// 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),
|
thoughts: make([]Thought, 0),
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
postChatCh: make(chan string, 10),
|
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.
|
// Start begins the background thinking loops.
|
||||||
func (t *Thinker) Start(ctx context.Context) {
|
func (t *Thinker) Start(ctx context.Context) {
|
||||||
if !t.enabled {
|
if !t.enabled {
|
||||||
@@ -80,12 +102,9 @@ func (t *Thinker) UpdateActivity() {
|
|||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ThoughtChan returns a channel that emits new thoughts.
|
// ThoughtChan returns the channel that emits new thoughts.
|
||||||
// Not yet wired — will connect to NATS for event broadcasting.
|
func (t *Thinker) ThoughtChan() <-chan Thought {
|
||||||
func (t *Thinker) ThoughtChan() chan Thought {
|
return t.out
|
||||||
ch := make(chan Thought, 10)
|
|
||||||
// In future: push thoughts to this channel
|
|
||||||
return ch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Thinker) silenceLoop(ctx context.Context) {
|
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) {
|
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{
|
thought := Thought{
|
||||||
Content: "[思考] 当前无主动想法",
|
Content: content,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
@@ -140,9 +163,60 @@ func (t *Thinker) generateThought(trigger string, sessionID string) {
|
|||||||
}
|
}
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case t.out <- thought:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
logger.Infof("[thinker] %s thought generated for session %s", trigger, sessionID)
|
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.
|
// RecentThoughts returns the most recent thoughts.
|
||||||
func (t *Thinker) RecentThoughts(n int) []Thought {
|
func (t *Thinker) RecentThoughts(n int) []Thought {
|
||||||
t.mu.Lock()
|
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()
|
||||||
|
}
|
||||||
@@ -28,6 +28,13 @@ type ToolExecutor interface {
|
|||||||
Execute(ctx context.Context, name string, params map[string]any) (string, error)
|
Execute(ctx context.Context, name string, params map[string]any) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracer records full-chain trace spans for each conversation round.
|
||||||
|
type Tracer interface {
|
||||||
|
StartTrace(id, userMsg string)
|
||||||
|
AddSpan(traceID, name, label, status string, durationMs int64, detail string)
|
||||||
|
FinishTrace(id, status, errMsg string)
|
||||||
|
}
|
||||||
|
|
||||||
// maxToolRounds caps consecutive tool-call rounds to avoid runaway loops.
|
// maxToolRounds caps consecutive tool-call rounds to avoid runaway loops.
|
||||||
const maxToolRounds = 3
|
const maxToolRounds = 3
|
||||||
|
|
||||||
@@ -47,6 +54,8 @@ type Pipeline struct {
|
|||||||
personaInjector *persona.Injector
|
personaInjector *persona.Injector
|
||||||
memoryExtractor MemoryExtractor
|
memoryExtractor MemoryExtractor
|
||||||
toolExecutor ToolExecutor
|
toolExecutor ToolExecutor
|
||||||
|
tracer Tracer
|
||||||
|
postChatHook func(sessionID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPipeline creates a new dialogue pipeline.
|
// NewPipeline creates a new dialogue pipeline.
|
||||||
@@ -77,49 +86,115 @@ func (p *Pipeline) SetToolExecutor(t ToolExecutor) {
|
|||||||
p.toolExecutor = t
|
p.toolExecutor = t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTracer injects full-chain tracing.
|
||||||
|
func (p *Pipeline) SetTracer(t Tracer) {
|
||||||
|
p.tracer = t
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPostChatHook registers a callback fired after each completed round
|
||||||
|
// (used by the background thinker).
|
||||||
|
func (p *Pipeline) SetPostChatHook(fn func(sessionID string)) {
|
||||||
|
p.postChatHook = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// span records a trace span when tracing is enabled.
|
||||||
|
func (p *Pipeline) span(traceID, name, label, status string, start time.Time, detail string) {
|
||||||
|
if p.tracer == nil || traceID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.tracer.AddSpan(traceID, name, label, status, time.Since(start).Milliseconds(), detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// traceStream wraps a token channel to record the LLM span and finish the
|
||||||
|
// trace when the stream completes.
|
||||||
|
func (p *Pipeline) traceStream(traceID, modelName string, in <-chan model.StreamToken) <-chan model.StreamToken {
|
||||||
|
out := make(chan model.StreamToken, 64)
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
start := time.Now()
|
||||||
|
status, errMsg := "success", ""
|
||||||
|
for tok := range in {
|
||||||
|
out <- tok
|
||||||
|
if tok.Done {
|
||||||
|
if strings.HasPrefix(tok.Content, "[Error:") {
|
||||||
|
status, errMsg = "error", tok.Content
|
||||||
|
}
|
||||||
|
p.tracer.AddSpan(traceID, "llm_call", "LLM 生成", status, time.Since(start).Milliseconds(), modelName)
|
||||||
|
p.tracer.FinishTrace(traceID, status, errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ProcessInput processes a user message through the full pipeline.
|
// ProcessInput processes a user message through the full pipeline.
|
||||||
// Returns a channel of StreamToken for the LLM response.
|
// Returns a channel of StreamToken for the LLM response.
|
||||||
func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userID, userMessage string) (<-chan model.StreamToken, *model.Intent, error) {
|
func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userID, userMessage string) (<-chan model.StreamToken, *model.Intent, error) {
|
||||||
|
// 0. Start trace
|
||||||
|
traceID := ""
|
||||||
|
if p.tracer != nil {
|
||||||
|
traceID = fmt.Sprintf("t-%d", time.Now().UnixNano())
|
||||||
|
p.tracer.StartTrace(traceID, userMessage)
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Cache check
|
// 1. Cache check
|
||||||
if cached := p.cache.Get(userMessage); cached != nil {
|
if cached := p.cache.Get(userMessage); cached != nil {
|
||||||
ch := make(chan model.StreamToken, 1)
|
ch := make(chan model.StreamToken, 1)
|
||||||
ch <- model.StreamToken{Content: cached.Content, Done: true}
|
ch <- model.StreamToken{Content: cached.Content, Done: true}
|
||||||
close(ch)
|
close(ch)
|
||||||
|
|
||||||
|
p.span(traceID, "cache_hit", "语义缓存命中", "success", time.Now(), "")
|
||||||
|
if p.tracer != nil {
|
||||||
|
p.tracer.FinishTrace(traceID, "success", "")
|
||||||
|
}
|
||||||
intent := &model.Intent{Type: model.IntentGreeting, Confidence: 1.0, RawText: userMessage}
|
intent := &model.Intent{Type: model.IntentGreeting, Confidence: 1.0, RawText: userMessage}
|
||||||
return ch, intent, nil
|
return ch, intent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Intent analysis
|
// 2. Intent analysis
|
||||||
|
t0 := time.Now()
|
||||||
intent := p.analyzeIntent(userMessage)
|
intent := p.analyzeIntent(userMessage)
|
||||||
|
p.span(traceID, "intent_analysis", "意图分析", "success", t0, intent.Type)
|
||||||
|
|
||||||
// 3. Build system prompt
|
// 3. Build system prompt
|
||||||
|
t1 := time.Now()
|
||||||
systemPrompt := p.personaInjector.BuildSystemPrompt()
|
systemPrompt := p.personaInjector.BuildSystemPrompt()
|
||||||
|
p.span(traceID, "persona", "人设注入", "success", t1, "")
|
||||||
|
|
||||||
// 4. Sub-session dispatch (parallel)
|
// 4. Sub-session dispatch (parallel)
|
||||||
|
t2 := time.Now()
|
||||||
subInput := subsession.ProviderInput{
|
subInput := subsession.ProviderInput{
|
||||||
UserMessage: userMessage,
|
UserMessage: userMessage,
|
||||||
History: p.ctxStore.Get(sessionID),
|
History: p.ctxStore.Get(sessionID),
|
||||||
Intent: intent,
|
Intent: intent,
|
||||||
}
|
}
|
||||||
subResults := p.subMgr.Dispatch(ctx, subInput, 500*time.Millisecond)
|
subResults := p.subMgr.Dispatch(ctx, subInput, 500*time.Millisecond)
|
||||||
|
p.span(traceID, "subsession_dispatch", "子会话分派", "success", t2, fmt.Sprintf("%d 个结果", len(subResults)))
|
||||||
|
|
||||||
// 5. Build LLM context
|
// 5. Build LLM context
|
||||||
|
t3 := time.Now()
|
||||||
llmMessages := p.ctxBuilder.Build(systemPrompt, sessionID, subResults, "")
|
llmMessages := p.ctxBuilder.Build(systemPrompt, sessionID, subResults, "")
|
||||||
llmMessages = append(llmMessages, model.LLMMessage{Role: "user", Content: userMessage})
|
llmMessages = append(llmMessages, model.LLMMessage{Role: "user", Content: userMessage})
|
||||||
|
p.span(traceID, "context_build", "上下文拼装", "success", t3, fmt.Sprintf("%d 条消息", len(llmMessages)))
|
||||||
|
|
||||||
// 6. LLM synthesis (streaming, with tool calling when available)
|
// 6. LLM synthesis (streaming, with tool calling when available)
|
||||||
adapter := p.llmSelector.Select(model.PurposeChat)
|
adapter := p.llmSelector.Select(model.PurposeChat)
|
||||||
var tokenCh <-chan model.StreamToken
|
var tokenCh <-chan model.StreamToken
|
||||||
if p.toolExecutor != nil && len(p.toolExecutor.Tools()) > 0 {
|
if p.toolExecutor != nil && len(p.toolExecutor.Tools()) > 0 {
|
||||||
tokenCh = p.streamWithTools(ctx, adapter, llmMessages)
|
tokenCh = p.streamWithTools(ctx, adapter, llmMessages, traceID)
|
||||||
} else {
|
} else {
|
||||||
var err error
|
var err error
|
||||||
tokenCh, err = adapter.ChatStream(ctx, llmMessages)
|
tokenCh, err = adapter.ChatStream(ctx, llmMessages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if p.tracer != nil {
|
||||||
|
p.tracer.FinishTrace(traceID, "error", err.Error())
|
||||||
|
}
|
||||||
return nil, intent, err
|
return nil, intent, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if p.tracer != nil {
|
||||||
|
tokenCh = p.traceStream(traceID, adapter.ModelName(), tokenCh)
|
||||||
|
}
|
||||||
|
|
||||||
// 7. Store user message
|
// 7. Store user message
|
||||||
p.ctxStore.AddWithUser(sessionID, userID, model.Message{Role: "user", Content: userMessage})
|
p.ctxStore.AddWithUser(sessionID, userID, model.Message{Role: "user", Content: userMessage})
|
||||||
@@ -132,7 +207,7 @@ func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userID, userMess
|
|||||||
// calls, the tools are executed and their results are fed back for another
|
// calls, the tools are executed and their results are fed back for another
|
||||||
// round (up to maxToolRounds). The final round omits tools to force a text
|
// round (up to maxToolRounds). The final round omits tools to force a text
|
||||||
// answer.
|
// answer.
|
||||||
func (p *Pipeline) streamWithTools(ctx context.Context, adapter *llm.Adapter, messages []model.LLMMessage) <-chan model.StreamToken {
|
func (p *Pipeline) streamWithTools(ctx context.Context, adapter *llm.Adapter, messages []model.LLMMessage, traceID string) <-chan model.StreamToken {
|
||||||
out := make(chan model.StreamToken, 64)
|
out := make(chan model.StreamToken, 64)
|
||||||
go func() {
|
go func() {
|
||||||
defer close(out)
|
defer close(out)
|
||||||
@@ -170,10 +245,14 @@ func (p *Pipeline) streamWithTools(ctx context.Context, adapter *llm.Adapter, me
|
|||||||
// and append the results for the next round.
|
// and append the results for the next round.
|
||||||
msgs = append(msgs, model.LLMMessage{Role: "assistant", ToolCalls: calls})
|
msgs = append(msgs, model.LLMMessage{Role: "assistant", ToolCalls: calls})
|
||||||
for _, call := range calls {
|
for _, call := range calls {
|
||||||
|
tcStart := time.Now()
|
||||||
result, err := p.toolExecutor.Execute(ctx, call.Name, call.Arguments)
|
result, err := p.toolExecutor.Execute(ctx, call.Name, call.Arguments)
|
||||||
|
status := "success"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = "Error: " + err.Error()
|
result = "Error: " + err.Error()
|
||||||
|
status = "error"
|
||||||
}
|
}
|
||||||
|
p.span(traceID, "tool_call", call.Name, status, tcStart, truncateDetail(result, 80))
|
||||||
msgs = append(msgs, model.LLMMessage{
|
msgs = append(msgs, model.LLMMessage{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
ToolCallID: call.ID,
|
ToolCallID: call.ID,
|
||||||
@@ -196,6 +275,9 @@ func (p *Pipeline) FinishRound(sessionID, userID, assistantReply string) {
|
|||||||
p.ctxStore.AddWithUser(sessionID, userID, model.Message{Role: "assistant", Content: assistantReply})
|
p.ctxStore.AddWithUser(sessionID, userID, model.Message{Role: "assistant", Content: assistantReply})
|
||||||
}
|
}
|
||||||
p.PostProcess(sessionID, userID)
|
p.PostProcess(sessionID, userID)
|
||||||
|
if p.postChatHook != nil {
|
||||||
|
p.postChatHook(sessionID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostProcess triggers async memory extraction after a conversation round completes.
|
// PostProcess triggers async memory extraction after a conversation round completes.
|
||||||
@@ -206,6 +288,15 @@ func (p *Pipeline) PostProcess(sessionID, userID string) {
|
|||||||
p.memoryExtractor.ExtractFromConversation(nil, userID, history)
|
p.memoryExtractor.ExtractFromConversation(nil, userID, history)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// truncateDetail shortens a string for trace span details.
|
||||||
|
func truncateDetail(s string, n int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:n]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
// --- Intent Analysis ---
|
// --- Intent Analysis ---
|
||||||
|
|
||||||
// controlWords are common IoT control verbs.
|
// controlWords are common IoT control verbs.
|
||||||
|
|||||||
@@ -221,6 +221,24 @@ func (r *Repository) RecordAccess(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUserIDs returns distinct user IDs that have memories.
|
||||||
|
func (r *Repository) ListUserIDs(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT user_id FROM memories`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list user ids: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// PurgeDecayed removes memories below the decay threshold.
|
// PurgeDecayed removes memories below the decay threshold.
|
||||||
func (r *Repository) PurgeDecayed(ctx context.Context, userID string) (int64, error) {
|
func (r *Repository) PurgeDecayed(ctx context.Context, userID string) (int64, error) {
|
||||||
result, err := r.db.ExecContext(ctx,
|
result, err := r.db.ExecContext(ctx,
|
||||||
|
|||||||
@@ -161,6 +161,23 @@ func (s *Service) ApplyDecay(ctx context.Context, userID string) (int, error) {
|
|||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApplyDecayAll applies decay for every user that has memories.
|
||||||
|
func (s *Service) ApplyDecayAll(ctx context.Context) (int, error) {
|
||||||
|
users, err := s.repo.ListUserIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for _, uid := range users {
|
||||||
|
n, err := s.ApplyDecay(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
total += n
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Keyword extraction (sliding window n-grams) ---
|
// --- Keyword extraction (sliding window n-grams) ---
|
||||||
|
|
||||||
// extractNGrams extracts all n-gram substrings from text.
|
// extractNGrams extracts all n-gram substrings from text.
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export function useWebSocket() {
|
|||||||
case 'device_update':
|
case 'device_update':
|
||||||
if (msg.devices) setIotDevices(msg.devices)
|
if (msg.devices) setIotDevices(msg.devices)
|
||||||
break
|
break
|
||||||
|
case 'thinking':
|
||||||
|
if (msg.content) {
|
||||||
|
addMessage({
|
||||||
|
id: crypto.randomUUID(), session_id: currentSessionId || '',
|
||||||
|
role: 'system', content: `💭 ${msg.content}`, created_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
case 'error':
|
case 'error':
|
||||||
addMessage({ id: crypto.randomUUID(), session_id: '', role: 'system',
|
addMessage({ id: crypto.randomUUID(), session_id: '', role: 'system',
|
||||||
content: `Error: ${msg.error}`, created_at: new Date().toISOString() })
|
content: `Error: ${msg.error}`, created_at: new Date().toISOString() })
|
||||||
|
|||||||
Reference in new issue
Block a user