package topicrouter import ( "context" "errors" "fmt" "strings" "time" "meshtastic_mqtt_server/internal/completion" "meshtastic_mqtt_server/internal/llm" "meshtastic_mqtt_server/internal/message" "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" ) // DefaultSystemPrompt 是话题选择判定模型的默认系统提示词。 // 模型被要求只输出 REPLY 或 IGNORE:REPLY 表示应当回复,IGNORE 表示应当丢弃。 const DefaultSystemPrompt = "你是一个话题过滤器,判断用户最新消息是否属于应当回复的话题范围。\n如果应当回复,请输出 REPLY;如果不应当回复,请输出 IGNORE。\n只输出 REPLY 或 IGNORE,不要输出任何其他内容。" // Config holds the topic selection configuration type Config struct { Enabled bool OpenAIName string Timeout int MaxTokens int SystemPrompt string } // ConfigStore 定义从持久化层读取最新话题选择配置的能力。 // 每次 Judge 都会调用 LoadTopicConfig,从而保证管理员在 /admin/llm/api // 修改配置后立即生效,无需重启。 type ConfigStore interface { LoadTopicConfig() (*Config, error) } // State manages the topic router state type State struct { cfg *Config ai *llm.State store ConfigStore } // Option is a function that configures the State type Option func(*State) // WithConfigStore 注入运行时配置加载器,State 会在每次需要时拉取最新配置。 func WithConfigStore(store ConfigStore) Option { return func(s *State) { s.store = store } } // NewState creates a new topic router state func NewState(cfg *Config, ai *llm.State, options ...Option) (*State, error) { if cfg == nil { cfg = &Config{ Enabled: false, Timeout: 30, MaxTokens: 512, SystemPrompt: DefaultSystemPrompt, } } if ai == nil { return nil, errors.New("topic router requires an LLM state") } if cfg.Enabled && strings.TrimSpace(cfg.OpenAIName) != "" { if _, err := ai.GetProfile(cfg.OpenAIName); err != nil { return nil, fmt.Errorf("invalid LLM provider name in topic router: %w", err) } } state := &State{cfg: cfg, ai: ai} for _, option := range options { option(state) } return state, nil } // effectiveConfig 返回当前生效的配置:优先从 store 加载最新值,加载失败时回退到内存 cfg。 // 调用方拿到的永远是非 nil 指针;内存 cfg 也保持同步以便其它读取点。 func (s *State) effectiveConfig() *Config { if s == nil { return &Config{} } if s.store != nil { if latest, err := s.store.LoadTopicConfig(); err == nil && latest != nil { s.cfg = latest return latest } } if s.cfg == nil { return &Config{} } return s.cfg } // Profile 返回话题选择使用的 LLM profile,OpenAIName 为空时回退到 fallback(主 profile)。 func (s *State) Profile(fallback *llm.Profile) *llm.Profile { if s == nil || s.ai == nil { return fallback } cfg := s.effectiveConfig() name := strings.TrimSpace(cfg.OpenAIName) if name == "" { return fallback } profile, err := s.ai.GetProfile(name) if err != nil { return fallback } return profile } // Config returns a copy of the current configuration func (s *State) Config() Config { if s == nil { return Config{} } return *s.effectiveConfig() } // Judge 对最近一条用户消息做话题判定。 // 返回值 shouldReply:true 表示命中/放行(应进入主回复),false 表示应丢弃不回复。 // 当话题选择未启用、未配置提供商,或判定调用失败时,一律放行(返回 true), // 避免判定接口故障导致所有未命中工具的消息被丢弃。 func Judge(ctx context.Context, state *State, fallback *llm.Profile, messages []message.ChatMessage) (bool, error) { if state == nil { return true, nil } cfg := state.effectiveConfig() if !cfg.Enabled { return true, nil } profile := state.Profile(fallback) if profile == nil || profile.Client == nil { // 未配置话题选择的 AI 提供商,回退到放行 return true, nil } // 取最后一条用户消息作为判定输入 userText := lastUserMessage(messages) if strings.TrimSpace(userText) == "" { return true, nil } systemPrompt := strings.TrimSpace(cfg.SystemPrompt) if systemPrompt == "" { systemPrompt = DefaultSystemPrompt } arkMessages := make([]*model.ChatCompletionMessage, 0, 2) arkMessages = append(arkMessages, &model.ChatCompletionMessage{ Role: "system", Content: &model.ChatCompletionMessageContent{ StringValue: &systemPrompt, }, }) arkMessages = append(arkMessages, &model.ChatCompletionMessage{ Role: "user", Content: &model.ChatCompletionMessageContent{ StringValue: &userText, }, }) maxTokens := cfg.MaxTokens if maxTokens <= 0 { maxTokens = 512 } timeout := time.Duration(cfg.Timeout) * time.Second if cfg.Timeout <= 0 { timeout = 30 * time.Second } resp, err := completion.CompleteChat(ctx, profile, model.CreateChatCompletionRequest{ Model: profile.Config.Model, Messages: arkMessages, MaxTokens: &maxTokens, }, timeout) if err != nil { // 判定调用失败时放行,避免接口故障导致全部丢消息 return true, err } if len(resp.Choices) == 0 { return true, nil } text := "" if resp.Choices[0].Message.Content != nil && resp.Choices[0].Message.Content.StringValue != nil { text = *resp.Choices[0].Message.Content.StringValue } // 解析模型输出:包含 REPLY 即命中(忽略大小写) upper := strings.ToUpper(strings.TrimSpace(text)) if strings.Contains(upper, "REPLY") { return true, nil } return false, nil } // lastUserMessage 返回消息列表中最后一条 role 为 user 的消息内容。 func lastUserMessage(messages []message.ChatMessage) string { for i := len(messages) - 1; i >= 0; i-- { msg := messages[i] role := msg.Role if role == "" { role = "user" } if role == "user" { return msg.Content } } return "" }