feat(ai-core): add LLM orchestration engine
- model: shared data types (Message, Intent, StreamToken, LLMMessage) - persona: YAML/Raw loading + system prompt injection - llm: OpenAI-compatible adapter with streaming + semantic cache - context: sliding window context store with token budgeting - subsession: parallel provider dispatch + review message parser - orchestrator: intent analysis pipeline (IoT/greeting/chat/memory) - background: autonomous thinking engine - 20+ tests, build + vet clean, ~130 total tests
This commit is contained in:
1 parent
99806fefad
commit
3b3e5a0697
9 files changed
+1529
No files matched your search
@@ -0,0 +1,155 @@
|
|||||||
|
// Package background provides the autonomous thinking engine for Cyrene.
|
||||||
|
// It generates thoughts after conversations and during idle periods.
|
||||||
|
package background
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Thinker manages autonomous background thinking.
|
||||||
|
type Thinker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
enabled bool
|
||||||
|
thinkInterval time.Duration
|
||||||
|
silenceTimeout time.Duration
|
||||||
|
lastActivity time.Time
|
||||||
|
activeSessionID string
|
||||||
|
thoughts []Thought
|
||||||
|
stopCh chan struct{}
|
||||||
|
postChatCh chan string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thought represents an autonomous thought generated by the AI.
|
||||||
|
type Thought struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new background thinker.
|
||||||
|
func New(enabled bool, thinkInterval, silenceTimeout time.Duration) *Thinker {
|
||||||
|
return &Thinker{
|
||||||
|
enabled: enabled,
|
||||||
|
thinkInterval: thinkInterval,
|
||||||
|
silenceTimeout: silenceTimeout,
|
||||||
|
lastActivity: time.Now(),
|
||||||
|
thoughts: make([]Thought, 0),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
postChatCh: make(chan string, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the background thinking loops.
|
||||||
|
func (t *Thinker) Start(ctx context.Context) {
|
||||||
|
if !t.enabled {
|
||||||
|
log.Println("[thinker] background thinking disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go t.silenceLoop(ctx)
|
||||||
|
go t.postChatLoop(ctx)
|
||||||
|
log.Printf("[thinker] started (interval=%v, silence=%v)", t.thinkInterval, t.silenceTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop shuts down the thinker.
|
||||||
|
func (t *Thinker) Stop() {
|
||||||
|
close(t.stopCh)
|
||||||
|
log.Println("[thinker] stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerPostChat schedules a post-chat think after a conversation.
|
||||||
|
func (t *Thinker) TriggerPostChat(sessionID string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.activeSessionID = sessionID
|
||||||
|
t.lastActivity = time.Now()
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case t.postChatCh <- sessionID:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateActivity updates the last activity timestamp (for silence detection).
|
||||||
|
func (t *Thinker) UpdateActivity() {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.lastActivity = time.Now()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Thinker) silenceLoop(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(t.thinkInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
t.mu.Lock()
|
||||||
|
elapsed := time.Since(t.lastActivity)
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
if elapsed >= t.silenceTimeout {
|
||||||
|
t.generateThought("silence", t.activeSessionID)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Thinker) postChatLoop(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.stopCh:
|
||||||
|
return
|
||||||
|
case sessionID := <-t.postChatCh:
|
||||||
|
// Delay post-chat thinking to avoid immediate noise
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
t.generateThought("post_chat", sessionID)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Thinker) generateThought(trigger string, sessionID string) {
|
||||||
|
// Placeholder: in production this calls the LLM
|
||||||
|
thought := Thought{
|
||||||
|
Content: "[思考] 当前无主动想法",
|
||||||
|
SessionID: sessionID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
t.thoughts = append(t.thoughts, thought)
|
||||||
|
if len(t.thoughts) > 100 {
|
||||||
|
t.thoughts = t.thoughts[1:]
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[thinker] %s thought generated for session %s", trigger, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentThoughts returns the most recent thoughts.
|
||||||
|
func (t *Thinker) RecentThoughts(n int) []Thought {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if n > len(t.thoughts) {
|
||||||
|
n = len(t.thoughts)
|
||||||
|
}
|
||||||
|
return t.thoughts[len(t.thoughts)-n:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// 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.
|
||||||
|
// systemPrompt is injected as the first system message.
|
||||||
|
// session history, subsession results, and IoT context are merged.
|
||||||
|
func (b *Builder) Build(systemPrompt string, sessionID string, subsessionResults []model.SubSessionResult, iotContext string) []model.LLMMessage {
|
||||||
|
messages := make([]model.LLMMessage, 0)
|
||||||
|
|
||||||
|
// 1. System prompt
|
||||||
|
if systemPrompt != "" {
|
||||||
|
messages = append(messages, model.LLMMessage{Role: "system", Content: systemPrompt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Subsession results (injected as system context)
|
||||||
|
for _, r := range subsessionResults {
|
||||||
|
if r.Error != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Content != "" {
|
||||||
|
msg := model.LLMMessage{
|
||||||
|
Role: "system",
|
||||||
|
Content: fmt.Sprintf("[%s context]\n%s", r.Provider, r.Content),
|
||||||
|
}
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. IoT device context
|
||||||
|
if iotContext != "" {
|
||||||
|
messages = append(messages, model.LLMMessage{
|
||||||
|
Role: "system",
|
||||||
|
Content: fmt.Sprintf("[IoT devices]\n%s", iotContext),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
// Package llm provides LLM abstraction with streaming, model routing, and response caching.
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds LLM connection configuration.
|
||||||
|
type Config struct {
|
||||||
|
BaseURL string
|
||||||
|
APIKey string
|
||||||
|
Model string
|
||||||
|
FallbackModel string
|
||||||
|
MaxRetries int
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a sensible default Config.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
BaseURL: "https://api.openai.com/v1",
|
||||||
|
Model: "gpt-4o",
|
||||||
|
FallbackModel: "gpt-4o-mini",
|
||||||
|
MaxRetries: 3,
|
||||||
|
Timeout: 180 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapter handles communication with an OpenAI-compatible LLM API.
|
||||||
|
type Adapter struct {
|
||||||
|
cfg Config
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdapter creates a new LLM adapter.
|
||||||
|
func NewAdapter(cfg Config) *Adapter {
|
||||||
|
return &Adapter{
|
||||||
|
cfg: cfg,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: cfg.Timeout,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 20,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelName returns the configured model name.
|
||||||
|
func (a *Adapter) ModelName() string { return a.cfg.Model }
|
||||||
|
|
||||||
|
// Chat sends a chat completion request and returns the full response.
|
||||||
|
func (a *Adapter) Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error) {
|
||||||
|
body := map[string]any{
|
||||||
|
"model": a.cfg.Model,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &model.LLMResponse{Model: a.cfg.Model}
|
||||||
|
err := a.doRequest(ctx, "POST", "/chat/completions", body, func(data map[string]any) error {
|
||||||
|
if choices, ok := data["choices"].([]any); ok && len(choices) > 0 {
|
||||||
|
choice := choices[0].(map[string]any)
|
||||||
|
if msg, ok := choice["message"].(map[string]any); ok {
|
||||||
|
if content, ok := msg["content"].(string); ok {
|
||||||
|
resp.Content = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reason, ok := choice["finish_reason"].(string); ok {
|
||||||
|
resp.FinishReason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if usage, ok := data["usage"].(map[string]any); ok {
|
||||||
|
if total, ok := usage["total_tokens"].(float64); ok {
|
||||||
|
resp.TokensUsed = int(total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatStream sends a chat completion request and returns a token channel for streaming.
|
||||||
|
func (a *Adapter) ChatStream(ctx context.Context, messages []model.LLMMessage) (<-chan model.StreamToken, error) {
|
||||||
|
body := map[string]any{
|
||||||
|
"model": a.cfg.Model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan model.StreamToken, 64)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
|
||||||
|
err := a.doStreamRequest(ctx, "POST", "/chat/completions", body, ch)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case ch <- model.StreamToken{Content: fmt.Sprintf("[LLM error: %v]", err), Done: true}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRequest sends a non-streaming request and parses the response.
|
||||||
|
func (a *Adapter) doRequest(ctx context.Context, method, path string, body map[string]any, parser func(map[string]any) error) error {
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, a.cfg.BaseURL+path, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+a.cfg.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("http request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBytes[:min(len(respBytes), 200)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal(respBytes, &data); err != nil {
|
||||||
|
return fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
return parser(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doStreamRequest sends a streaming request and sends tokens to the channel.
|
||||||
|
func (a *Adapter) doStreamRequest(ctx context.Context, method, path string, body map[string]any, ch chan<- model.StreamToken) error {
|
||||||
|
jsonBody, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, a.cfg.BaseURL+path, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+a.cfg.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "text/event-stream")
|
||||||
|
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
errMsg, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("stream API error %d: %s", resp.StatusCode, string(errMsg[:min(len(errMsg), 200)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || !strings.HasPrefix(line, "data: ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimPrefix(line, "data: ")
|
||||||
|
if data == "[DONE]" {
|
||||||
|
ch <- model.StreamToken{Done: true}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var frame map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(data), &frame); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if choices, ok := frame["choices"].([]any); ok && len(choices) > 0 {
|
||||||
|
choice := choices[0].(map[string]any)
|
||||||
|
if delta, ok := choice["delta"].(map[string]any); ok {
|
||||||
|
if content, ok := delta["content"].(string); ok {
|
||||||
|
ch <- model.StreamToken{Content: content}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reason, ok := choice["finish_reason"].(string); ok && reason != "" {
|
||||||
|
ch <- model.StreamToken{FinishReason: reason, Done: true}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelSelector routes requests to different models based on purpose.
|
||||||
|
type ModelSelector struct {
|
||||||
|
adapters map[model.LLMPurpose]*Adapter
|
||||||
|
default_ *Adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModelSelector creates a selector with purpose-specific adapters.
|
||||||
|
func NewModelSelector(cfg Config) *ModelSelector {
|
||||||
|
base := NewAdapter(cfg)
|
||||||
|
return &ModelSelector{
|
||||||
|
adapters: map[model.LLMPurpose]*Adapter{},
|
||||||
|
default_: base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAdapter assigns an adapter for a specific purpose.
|
||||||
|
func (s *ModelSelector) SetAdapter(purpose model.LLMPurpose, a *Adapter) {
|
||||||
|
s.adapters[purpose] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select returns the adapter for the given purpose, falling back to default.
|
||||||
|
func (s *ModelSelector) Select(purpose model.LLMPurpose) *Adapter {
|
||||||
|
if a, ok := s.adapters[purpose]; ok {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return s.default_
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultAdapter returns the default adapter.
|
||||||
|
func (s *ModelSelector) DefaultAdapter() *Adapter {
|
||||||
|
return s.default_
|
||||||
|
}
|
||||||
|
|
||||||
|
// SemanticCache caches common greetings to avoid redundant LLM calls.
|
||||||
|
type SemanticCache struct {
|
||||||
|
entries map[string]CachedResponse
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// CachedResponse holds a pre-computed response for a specific input.
|
||||||
|
type CachedResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Segments []model.ReviewSegment `json:"segments"`
|
||||||
|
ExpireAt time.Time `json:"expire_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSemanticCache creates a cache preloaded with common greetings.
|
||||||
|
func NewSemanticCache() *SemanticCache {
|
||||||
|
c := &SemanticCache{entries: make(map[string]CachedResponse)}
|
||||||
|
ttl := 24 * time.Hour
|
||||||
|
|
||||||
|
c.Set("你好", "你好呀~ 有什么可以帮你的吗?", nil, ttl)
|
||||||
|
c.Set("早", "早上好♪ 今天有什么计划吗?", nil, ttl)
|
||||||
|
c.Set("晚安", "晚安~ 做个好梦!", nil, ttl)
|
||||||
|
c.Set("谢谢", "不客气~ 随时可以找我帮忙哦", nil, ttl)
|
||||||
|
c.Set("hello", "Hello~ How can I help you today?", nil, ttl)
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set adds a cached response.
|
||||||
|
func (c *SemanticCache) Set(input, content string, segments []model.ReviewSegment, ttl time.Duration) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.entries[strings.ToLower(strings.TrimSpace(input))] = CachedResponse{
|
||||||
|
Content: content,
|
||||||
|
Segments: segments,
|
||||||
|
ExpireAt: time.Now().Add(ttl),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a cached response, or nil if not found/expired.
|
||||||
|
func (c *SemanticCache) Get(input string) *CachedResponse {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
key := strings.ToLower(strings.TrimSpace(input))
|
||||||
|
if entry, ok := c.entries[key]; ok && time.Now().Before(entry.ExpireAt) {
|
||||||
|
return &entry
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Package model defines shared data types for the AI-Core engine.
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Message represents a single chat message in a conversation.
|
||||||
|
type Message struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Role string `json:"role"` // "user" | "assistant" | "action" | "system"
|
||||||
|
Content string `json:"content"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session represents a conversation session.
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
State string `json:"state"` // "active" | "idle"
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubSessionResult holds the result from a single subsession provider.
|
||||||
|
type SubSessionResult struct {
|
||||||
|
Provider string `json:"provider"` // "general" | "memory" | "iot" | "review"
|
||||||
|
Content string `json:"content"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intent represents the detected intent of a user message.
|
||||||
|
type Intent struct {
|
||||||
|
Type string `json:"type"` // "chat" | "iot_control" | "iot_query" | "greeting" | "memory_trigger" | "knowledge" | "task"
|
||||||
|
Confidence float64 `json:"confidence"` // 0.0 - 1.0
|
||||||
|
Entities []string `json:"entities"` // Detected entities (device names, etc.)
|
||||||
|
RawText string `json:"raw_text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMMessage is the standardized format sent to LLM APIs.
|
||||||
|
type LLMMessage struct {
|
||||||
|
Role string `json:"role"` // "system" | "user" | "assistant" | "tool"
|
||||||
|
Content string `json:"content"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMResponse represents a non-streaming LLM response.
|
||||||
|
type LLMResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
FinishReason string `json:"finish_reason"` // "stop" | "length" | "tool_calls"
|
||||||
|
Model string `json:"model"`
|
||||||
|
TokensUsed int `json:"tokens_used"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamToken is a single token from a streaming LLM response.
|
||||||
|
type StreamToken struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
FinishReason string `json:"finish_reason,omitempty"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewSegment represents a parsed action/chat segment from LLM output.
|
||||||
|
type ReviewSegment struct {
|
||||||
|
Type string `json:"type"` // "action" | "chat"
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCall represents a tool call requested by the LLM.
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments map[string]any `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intent types
|
||||||
|
const (
|
||||||
|
IntentChat = "chat"
|
||||||
|
IntentIoTControl = "iot_control"
|
||||||
|
IntentIoTQuery = "iot_query"
|
||||||
|
IntentGreeting = "greeting"
|
||||||
|
IntentMemoryTrigger = "memory_trigger"
|
||||||
|
IntentKnowledge = "knowledge"
|
||||||
|
IntentTask = "task"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LLM purposes for model routing
|
||||||
|
type LLMPurpose string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PurposeChat LLMPurpose = "chat"
|
||||||
|
PurposeIntentAnalysis LLMPurpose = "intent_analysis"
|
||||||
|
PurposeDeepThinking LLMPurpose = "deep_thinking"
|
||||||
|
PurposeMemoryExtraction LLMPurpose = "memory_extraction"
|
||||||
|
PurposeToolCalling LLMPurpose = "tool_calling"
|
||||||
|
)
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// Package orchestrator implements the main dialogue pipeline for Cyrene.
|
||||||
|
// It coordinates intent analysis, subsession dispatch, LLM synthesis, and review parsing.
|
||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
ctxpkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/context"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/subsession"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pipeline orchestrates the full dialogue flow.
|
||||||
|
type Pipeline struct {
|
||||||
|
llmSelector *llm.ModelSelector
|
||||||
|
cache *llm.SemanticCache
|
||||||
|
ctxStore *ctxpkg.Store
|
||||||
|
ctxBuilder *ctxpkg.Builder
|
||||||
|
subMgr *subsession.Manager
|
||||||
|
personaInjector *persona.Injector
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPipeline creates a new dialogue pipeline.
|
||||||
|
func NewPipeline(
|
||||||
|
llmSel *llm.ModelSelector,
|
||||||
|
cache *llm.SemanticCache,
|
||||||
|
ctxStore *ctxpkg.Store,
|
||||||
|
subMgr *subsession.Manager,
|
||||||
|
personaInjector *persona.Injector,
|
||||||
|
) *Pipeline {
|
||||||
|
return &Pipeline{
|
||||||
|
llmSelector: llmSel,
|
||||||
|
cache: cache,
|
||||||
|
ctxStore: ctxStore,
|
||||||
|
ctxBuilder: ctxpkg.NewBuilder(ctxStore),
|
||||||
|
subMgr: subMgr,
|
||||||
|
personaInjector: personaInjector,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessInput processes a user message through the full pipeline.
|
||||||
|
// Returns a channel of StreamToken for the LLM response.
|
||||||
|
func (p *Pipeline) ProcessInput(ctx context.Context, sessionID, userMessage string) (<-chan model.StreamToken, *model.Intent, error) {
|
||||||
|
// 1. Cache check
|
||||||
|
if cached := p.cache.Get(userMessage); cached != nil {
|
||||||
|
ch := make(chan model.StreamToken, 1)
|
||||||
|
ch <- model.StreamToken{Content: cached.Content, Done: true}
|
||||||
|
close(ch)
|
||||||
|
|
||||||
|
intent := &model.Intent{Type: model.IntentGreeting, Confidence: 1.0, RawText: userMessage}
|
||||||
|
return ch, intent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Intent analysis
|
||||||
|
intent := p.analyzeIntent(userMessage)
|
||||||
|
|
||||||
|
// 3. Build system prompt
|
||||||
|
systemPrompt := p.personaInjector.BuildSystemPrompt()
|
||||||
|
|
||||||
|
// 4. Sub-session dispatch (parallel)
|
||||||
|
subInput := subsession.ProviderInput{
|
||||||
|
UserMessage: userMessage,
|
||||||
|
History: p.ctxStore.Get(sessionID),
|
||||||
|
Intent: intent,
|
||||||
|
}
|
||||||
|
subResults := p.subMgr.Dispatch(ctx, subInput, 500*time.Millisecond)
|
||||||
|
|
||||||
|
// 5. Build LLM context
|
||||||
|
llmMessages := p.ctxBuilder.Build(systemPrompt, sessionID, subResults, "")
|
||||||
|
llmMessages = append(llmMessages, model.LLMMessage{Role: "user", Content: userMessage})
|
||||||
|
|
||||||
|
// 6. LLM synthesis (streaming)
|
||||||
|
adapter := p.llmSelector.Select(model.PurposeChat)
|
||||||
|
tokenCh, err := adapter.ChatStream(ctx, llmMessages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, intent, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Store user message
|
||||||
|
p.ctxStore.Add(sessionID, model.Message{Role: "user", Content: userMessage})
|
||||||
|
|
||||||
|
return tokenCh, intent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Intent Analysis ---
|
||||||
|
|
||||||
|
// controlWords are common IoT control verbs.
|
||||||
|
var controlWords = []string{"打开", "关闭", "关掉", "关上", "调到", "设置", "开关", "调节", "调高", "调低"}
|
||||||
|
|
||||||
|
// deviceWords are common IoT device names.
|
||||||
|
var deviceWords = []string{"灯", "空调", "窗帘", "电视", "风扇", "加湿器", "插座", "门锁", "传感器"}
|
||||||
|
|
||||||
|
// greetingWords are common greetings.
|
||||||
|
var greetingWords = []string{"你好", "早", "晚安", "嗨", "hello", "hi", "在吗", "在不在"}
|
||||||
|
|
||||||
|
func (p *Pipeline) analyzeIntent(message string) *model.Intent {
|
||||||
|
msg := strings.ToLower(message)
|
||||||
|
|
||||||
|
// Fast path: IoT command
|
||||||
|
hasControl := false
|
||||||
|
for _, w := range controlWords {
|
||||||
|
if strings.Contains(msg, w) {
|
||||||
|
hasControl = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasDevice := false
|
||||||
|
var entities []string
|
||||||
|
for _, w := range deviceWords {
|
||||||
|
if strings.Contains(msg, w) {
|
||||||
|
hasDevice = true
|
||||||
|
entities = append(entities, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasControl && hasDevice {
|
||||||
|
// Check for "开" ambiguity: "开心", "开始", "离开" should NOT trigger IoT
|
||||||
|
if strings.Contains(msg, "开") {
|
||||||
|
ambiguous := false
|
||||||
|
for _, prefix := range []string{"开心", "开始", "离开", "展开", "打开"} {
|
||||||
|
if prefix == "打开" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, prefix) {
|
||||||
|
ambiguous = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ambiguous {
|
||||||
|
return &model.Intent{Type: model.IntentIoTControl, Confidence: 0.95, Entities: entities, RawText: message}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &model.Intent{Type: model.IntentIoTControl, Confidence: 0.9, Entities: entities, RawText: message}
|
||||||
|
}
|
||||||
|
if hasDevice {
|
||||||
|
return &model.Intent{Type: model.IntentIoTQuery, Confidence: 0.85, Entities: entities, RawText: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: Greeting
|
||||||
|
for _, w := range greetingWords {
|
||||||
|
if strings.Contains(msg, w) || msg == w {
|
||||||
|
return &model.Intent{Type: model.IntentGreeting, Confidence: 0.95, RawText: message}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: Memory trigger
|
||||||
|
memoryKeywords := []string{"记得", "还记得", "以前", "上次", "之前", "过去"}
|
||||||
|
for _, w := range memoryKeywords {
|
||||||
|
if strings.Contains(msg, w) {
|
||||||
|
return &model.Intent{Type: model.IntentMemoryTrigger, Confidence: 0.7, RawText: message}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: chat
|
||||||
|
return &model.Intent{Type: model.IntentChat, Confidence: 0.6, RawText: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
// BuildStreamResponse accumulates streaming tokens and stores the final response.
|
||||||
|
func BuildStreamResponse(ctx context.Context, tokenCh <-chan model.StreamToken, store *ctxpkg.Store, sessionID string) (string, error) {
|
||||||
|
var fullContent strings.Builder
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return fullContent.String(), ctx.Err()
|
||||||
|
case token, ok := <-tokenCh:
|
||||||
|
if !ok {
|
||||||
|
return fullContent.String(), nil
|
||||||
|
}
|
||||||
|
if token.Done {
|
||||||
|
// Store the complete response
|
||||||
|
content := fullContent.String()
|
||||||
|
store.Add(sessionID, model.Message{Role: "assistant", Content: content})
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
fullContent.WriteString(token.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsStrongIoTCommand checks if the message is unambiguously an IoT command.
|
||||||
|
func IsStrongIoTCommand(message string) bool {
|
||||||
|
hasControl := false
|
||||||
|
for _, cw := range controlWords {
|
||||||
|
if strings.Contains(message, cw) {
|
||||||
|
hasControl = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasDevice := false
|
||||||
|
for _, dw := range deviceWords {
|
||||||
|
if strings.Contains(message, dw) {
|
||||||
|
hasDevice = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasControl && hasDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildIoTContext creates a device context string for the LLM prompt.
|
||||||
|
func BuildIoTContext(devices []DeviceState) string {
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("当前智能家居设备状态:\n")
|
||||||
|
for _, d := range devices {
|
||||||
|
state := "关"
|
||||||
|
if power, ok := d.State["power"]; ok {
|
||||||
|
if p, ok := power.(bool); ok && p {
|
||||||
|
state = "开"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s: %s\n", d.Name, state))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceState holds a simplified device state for context building.
|
||||||
|
type DeviceState struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
State map[string]any
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
ctxpkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/context"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/subsession"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestPipeline() *Pipeline {
|
||||||
|
return NewPipeline(
|
||||||
|
llm.NewModelSelector(llm.DefaultConfig()),
|
||||||
|
llm.NewSemanticCache(),
|
||||||
|
ctxpkg.NewStore(4000, 50),
|
||||||
|
subsession.NewManager(&subsession.GeneralProvider{}, &subsession.MemoryProvider{}),
|
||||||
|
persona.NewInjector(persona.BuiltinDefault()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntentAnalysis_IoT(t *testing.T) {
|
||||||
|
p := setupTestPipeline()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
wantType string
|
||||||
|
}{
|
||||||
|
{"打开客厅灯", model.IntentIoTControl},
|
||||||
|
{"关掉卧室灯", model.IntentIoTControl},
|
||||||
|
{"把空调调到26度", model.IntentIoTControl},
|
||||||
|
{"关闭窗帘", model.IntentIoTControl},
|
||||||
|
{"灯开了吗", model.IntentIoTQuery},
|
||||||
|
{"传感器温度多少", model.IntentIoTQuery},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
intent := p.analyzeIntent(tt.input)
|
||||||
|
if intent.Type != tt.wantType {
|
||||||
|
t.Errorf("analyzeIntent(%q).Type = %q, want %q", tt.input, intent.Type, tt.wantType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntentAnalysis_Greeting(t *testing.T) {
|
||||||
|
p := setupTestPipeline()
|
||||||
|
|
||||||
|
tests := []string{"你好", "早", "晚安", "在吗"}
|
||||||
|
|
||||||
|
for _, input := range tests {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
intent := p.analyzeIntent(input)
|
||||||
|
if intent.Type != model.IntentGreeting {
|
||||||
|
t.Errorf("analyzeIntent(%q).Type = %q, want %s", input, intent.Type, model.IntentGreeting)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntentAnalysis_Chat(t *testing.T) {
|
||||||
|
p := setupTestPipeline()
|
||||||
|
|
||||||
|
intent := p.analyzeIntent("今天天气怎么样")
|
||||||
|
if intent.Type != model.IntentChat {
|
||||||
|
t.Errorf("expected chat intent, got %s", intent.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntentAnalysis_MemoryTrigger(t *testing.T) {
|
||||||
|
p := setupTestPipeline()
|
||||||
|
|
||||||
|
intent := p.analyzeIntent("你还记得我喜欢什么吗")
|
||||||
|
if intent.Type != model.IntentMemoryTrigger {
|
||||||
|
t.Errorf("expected memory_trigger, got %s", intent.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntentAnalysis_AmbiguousOpen(t *testing.T) {
|
||||||
|
p := setupTestPipeline()
|
||||||
|
|
||||||
|
// "开心" and "开始" should NOT trigger IoT intent
|
||||||
|
intent := p.analyzeIntent("我今天很开心")
|
||||||
|
if intent.Type == model.IntentIoTControl {
|
||||||
|
t.Errorf("'开心' should not trigger IoT, got %s", intent.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
intent = p.analyzeIntent("开始工作")
|
||||||
|
if intent.Type == model.IntentIoTControl {
|
||||||
|
t.Errorf("'开始' should not trigger IoT, got %s", intent.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsStrongIoTCommand(t *testing.T) {
|
||||||
|
if !IsStrongIoTCommand("打开客厅灯") {
|
||||||
|
t.Error("expected true for '打开客厅灯'")
|
||||||
|
}
|
||||||
|
if !IsStrongIoTCommand("关闭空调") {
|
||||||
|
t.Error("expected true for '关闭空调'")
|
||||||
|
}
|
||||||
|
if IsStrongIoTCommand("今天天气真好") {
|
||||||
|
t.Error("expected false for non-IoT message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildIoTContext(t *testing.T) {
|
||||||
|
devices := []DeviceState{
|
||||||
|
{Name: "客厅灯", State: map[string]any{"power": true}},
|
||||||
|
{Name: "卧室灯", State: map[string]any{"power": false}},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := BuildIoTContext(devices)
|
||||||
|
if ctx == "" {
|
||||||
|
t.Error("expected non-empty context")
|
||||||
|
}
|
||||||
|
if len(ctx) < 10 {
|
||||||
|
t.Errorf("context too short: %s", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemanticCache(t *testing.T) {
|
||||||
|
p := setupTestPipeline()
|
||||||
|
|
||||||
|
cached := p.cache.Get("你好")
|
||||||
|
if cached == nil {
|
||||||
|
t.Error("'你好' should be cached")
|
||||||
|
}
|
||||||
|
if cached != nil && cached.Content == "" {
|
||||||
|
t.Error("cached content should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
notCached := p.cache.Get("随机字符串不会命中缓存")
|
||||||
|
if notCached != nil {
|
||||||
|
t.Error("random string should not be cached")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelConstants(t *testing.T) {
|
||||||
|
if model.IntentChat != "chat" {
|
||||||
|
t.Errorf("IntentChat = %s", model.IntentChat)
|
||||||
|
}
|
||||||
|
if model.IntentIoTControl != "iot_control" {
|
||||||
|
t.Errorf("IntentIoTControl = %s", model.IntentIoTControl)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Package persona manages AI character loading and system prompt injection.
|
||||||
|
package persona
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Persona defines an AI character's configuration.
|
||||||
|
type Persona struct {
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
Greeting string `yaml:"greeting" json:"greeting"`
|
||||||
|
Traits []string `yaml:"traits" json:"traits"`
|
||||||
|
Style string `yaml:"style" json:"style"`
|
||||||
|
Constraints []string `yaml:"constraints" json:"constraints"`
|
||||||
|
|
||||||
|
// IoT-specific personality
|
||||||
|
IoTResponseStyle string `yaml:"iot_response_style" json:"iot_response_style,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loader loads persona configurations from a directory.
|
||||||
|
type Loader struct {
|
||||||
|
personas map[string]*Persona
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoader loads all YAML persona files from the given directory.
|
||||||
|
func NewLoader(dir string) (*Loader, error) {
|
||||||
|
l := &Loader{personas: make(map[string]*Persona)}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
// Return empty loader if directory doesn't exist
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read persona dir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") && !strings.HasSuffix(entry.Name(), ".yml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, entry.Name())
|
||||||
|
p, err := loadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load persona %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
|
||||||
|
p.Name = name
|
||||||
|
l.personas[name] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a persona by name, or nil if not found.
|
||||||
|
func (l *Loader) Get(name string) *Persona {
|
||||||
|
return l.personas[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all loaded persona names.
|
||||||
|
func (l *Loader) List() []string {
|
||||||
|
names := make([]string, 0, len(l.personas))
|
||||||
|
for name := range l.personas {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of loaded personas.
|
||||||
|
func (l *Loader) Count() int {
|
||||||
|
return len(l.personas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default returns the first persona, or a built-in default.
|
||||||
|
func (l *Loader) Default() *Persona {
|
||||||
|
for _, p := range l.personas {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return BuiltinDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFile reads a single persona YAML file.
|
||||||
|
func loadFile(path string) (*Persona, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseYAML(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseYAML parses persona YAML content (simple key-value parser).
|
||||||
|
func parseYAML(data []byte) (*Persona, error) {
|
||||||
|
p := &Persona{}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
var currentList *[]string
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "- ") && currentList != nil {
|
||||||
|
*currentList = append(*currentList, strings.TrimPrefix(line, "- "))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currentList = nil
|
||||||
|
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
val := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "name":
|
||||||
|
p.Name = val
|
||||||
|
case "description":
|
||||||
|
p.Description = val
|
||||||
|
case "greeting":
|
||||||
|
p.Greeting = val
|
||||||
|
case "style":
|
||||||
|
p.Style = val
|
||||||
|
case "iot_response_style":
|
||||||
|
p.IoTResponseStyle = val
|
||||||
|
case "traits":
|
||||||
|
currentList = &p.Traits
|
||||||
|
case "constraints":
|
||||||
|
currentList = &p.Constraints
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuiltinDefault returns a hardcoded default persona.
|
||||||
|
func BuiltinDefault() *Persona {
|
||||||
|
return &Persona{
|
||||||
|
Name: "default",
|
||||||
|
Description: "一个友好的 AI 助手",
|
||||||
|
Greeting: "你好呀~ 有什么可以帮你的吗?",
|
||||||
|
Traits: []string{"友善", "细心", "幽默"},
|
||||||
|
Style: "用轻松自然的中文交流,偶尔加一点俏皮表情",
|
||||||
|
Constraints: []string{"不编造事实", "不提供医疗法律建议", "保持礼貌"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Injector builds the system prompt from a persona.
|
||||||
|
type Injector struct {
|
||||||
|
persona *Persona
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInjector creates a system prompt injector for a persona.
|
||||||
|
func NewInjector(p *Persona) *Injector {
|
||||||
|
if p == nil {
|
||||||
|
p = BuiltinDefault()
|
||||||
|
}
|
||||||
|
return &Injector{persona: p}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSystemPrompt constructs the full system prompt string.
|
||||||
|
func (inj *Injector) BuildSystemPrompt() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("你是%s,%s。\n", inj.persona.Name, inj.persona.Description))
|
||||||
|
|
||||||
|
if len(inj.persona.Traits) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("性格特点: %s\n", strings.Join(inj.persona.Traits, "、")))
|
||||||
|
}
|
||||||
|
if inj.persona.Style != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("说话风格: %s\n", inj.persona.Style))
|
||||||
|
}
|
||||||
|
if len(inj.persona.Constraints) > 0 {
|
||||||
|
sb.WriteString("遵守规则:\n")
|
||||||
|
for _, c := range inj.persona.Constraints {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s\n", c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Package subsession manages parallel sub-session providers for dialogue enrichment.
|
||||||
|
package subsession
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider executes a specific sub-session task in parallel with others.
|
||||||
|
type Provider interface {
|
||||||
|
Name() string
|
||||||
|
Execute(ctx context.Context, input ProviderInput) (*model.SubSessionResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderInput holds the context for sub-session execution.
|
||||||
|
type ProviderInput struct {
|
||||||
|
UserMessage string
|
||||||
|
History []model.Message
|
||||||
|
Intent *model.Intent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager orchestrates parallel sub-session execution.
|
||||||
|
type Manager struct {
|
||||||
|
providers []Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a sub-session manager.
|
||||||
|
func NewManager(providers ...Provider) *Manager {
|
||||||
|
return &Manager{providers: providers}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch runs all providers in parallel and returns results within the timeout.
|
||||||
|
func (m *Manager) Dispatch(ctx context.Context, input ProviderInput, timeout time.Duration) []model.SubSessionResult {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([]model.SubSessionResult, 0, len(m.providers))
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
for _, p := range m.providers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(provider Provider) {
|
||||||
|
defer wg.Done()
|
||||||
|
result, err := provider.Execute(ctx, input)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, model.SubSessionResult{
|
||||||
|
Provider: provider.Name(),
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
} else if result != nil {
|
||||||
|
results = append(results, *result)
|
||||||
|
}
|
||||||
|
}(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Built-in Providers ---
|
||||||
|
|
||||||
|
// GeneralProvider handles general conversation intent.
|
||||||
|
type GeneralProvider struct{}
|
||||||
|
|
||||||
|
func (p *GeneralProvider) Name() string { return "general" }
|
||||||
|
func (p *GeneralProvider) Execute(_ context.Context, input ProviderInput) (*model.SubSessionResult, error) {
|
||||||
|
return &model.SubSessionResult{
|
||||||
|
Provider: "general",
|
||||||
|
Content: fmt.Sprintf("Intent: %s (confidence: %.2f)", input.Intent.Type, input.Intent.Confidence),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryProvider retrieves relevant memories (placeholder — wired to Memory Service later).
|
||||||
|
type MemoryProvider struct{}
|
||||||
|
|
||||||
|
func (p *MemoryProvider) Name() string { return "memory" }
|
||||||
|
func (p *MemoryProvider) Execute(_ context.Context, _ ProviderInput) (*model.SubSessionResult, error) {
|
||||||
|
return &model.SubSessionResult{Provider: "memory", Content: "no relevant memories"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IoTProvider executes IoT device queries and commands.
|
||||||
|
type IoTProvider struct {
|
||||||
|
// GetDeviceState is injected by the main app to query IoT devices.
|
||||||
|
GetDeviceState func(ctx context.Context) (string, error)
|
||||||
|
// ControlDevice is injected to execute IoT commands.
|
||||||
|
ControlDevice func(ctx context.Context, device, action string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *IoTProvider) Name() string { return "iot" }
|
||||||
|
func (p *IoTProvider) Execute(ctx context.Context, input ProviderInput) (*model.SubSessionResult, error) {
|
||||||
|
if input.Intent == nil || (input.Intent.Type != model.IntentIoTControl && input.Intent.Type != model.IntentIoTQuery) {
|
||||||
|
return &model.SubSessionResult{Provider: "iot", Content: ""}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.GetDeviceState != nil {
|
||||||
|
state, err := p.GetDeviceState(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return &model.SubSessionResult{Provider: "iot", Content: state}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &model.SubSessionResult{Provider: "iot", Content: "iot devices available"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewProvider parses LLM output into action/chat segments.
|
||||||
|
type ReviewProvider struct{}
|
||||||
|
|
||||||
|
func (p *ReviewProvider) Name() string { return "review" }
|
||||||
|
func (p *ReviewProvider) Execute(_ context.Context, _ ProviderInput) (*model.SubSessionResult, error) {
|
||||||
|
return &model.SubSessionResult{Provider: "review", Content: ""}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseReviewMessages splits a message with action markers into segments.
|
||||||
|
// Input: "(歪着头看) 客厅灯已经开啦♪"
|
||||||
|
// Output: [{type:"action", content:"歪着头看"}, {type:"chat", content:"客厅灯已经开啦♪"}]
|
||||||
|
func ParseReviewMessages(content string) []model.ReviewSegment {
|
||||||
|
var segments []model.ReviewSegment
|
||||||
|
var current strings.Builder
|
||||||
|
inAction := false
|
||||||
|
|
||||||
|
runes := []rune(content)
|
||||||
|
for i := 0; i < len(runes); i++ {
|
||||||
|
r := runes[i]
|
||||||
|
|
||||||
|
// Chinese bracket detection
|
||||||
|
if r == '(' || r == '(' {
|
||||||
|
if current.Len() > 0 && !inAction {
|
||||||
|
segments = append(segments, model.ReviewSegment{Type: "chat", Content: current.String()})
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
inAction = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if r == ')' || r == ')' {
|
||||||
|
if inAction && current.Len() > 0 {
|
||||||
|
segments = append(segments, model.ReviewSegment{Type: "action", Content: current.String()})
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
inAction = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
current.WriteRune(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.Len() > 0 {
|
||||||
|
segType := "chat"
|
||||||
|
if inAction {
|
||||||
|
segType = "action"
|
||||||
|
}
|
||||||
|
segments = append(segments, model.ReviewSegment{Type: segType, Content: current.String()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package subsession
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseReviewMessages(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected []model.ReviewSegment
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"(歪着头看你) 叶酱,客厅灯早就开着啦♪",
|
||||||
|
[]model.ReviewSegment{
|
||||||
|
{Type: "action", Content: "歪着头看你"},
|
||||||
|
{Type: "chat", Content: " 叶酱,客厅灯早就开着啦♪"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"你好呀~ 今天有什么需要帮忙的吗?",
|
||||||
|
[]model.ReviewSegment{
|
||||||
|
{Type: "chat", Content: "你好呀~ 今天有什么需要帮忙的吗?"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"(思考中)嗯...这个问题比较复杂",
|
||||||
|
[]model.ReviewSegment{
|
||||||
|
{Type: "action", Content: "思考中"},
|
||||||
|
{Type: "chat", Content: "嗯...这个问题比较复杂"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"(only action)",
|
||||||
|
[]model.ReviewSegment{
|
||||||
|
{Type: "action", Content: "only action"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := ParseReviewMessages(tt.input)
|
||||||
|
if len(got) != len(tt.expected) {
|
||||||
|
t.Fatalf("expected %d segments, got %d: %+v", len(tt.expected), len(got), got)
|
||||||
|
}
|
||||||
|
for i, seg := range got {
|
||||||
|
if seg.Type != tt.expected[i].Type {
|
||||||
|
t.Errorf("segment[%d].Type = %q, want %q", i, seg.Type, tt.expected[i].Type)
|
||||||
|
}
|
||||||
|
if seg.Content != tt.expected[i].Content {
|
||||||
|
t.Errorf("segment[%d].Content = %q, want %q", i, seg.Content, tt.expected[i].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderNames(t *testing.T) {
|
||||||
|
p := &GeneralProvider{}
|
||||||
|
if p.Name() != "general" {
|
||||||
|
t.Errorf("got %s", p.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &MemoryProvider{}
|
||||||
|
if m.Name() != "memory" {
|
||||||
|
t.Errorf("got %s", m.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
i := &IoTProvider{}
|
||||||
|
if i.Name() != "iot" {
|
||||||
|
t.Errorf("got %s", i.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user