diff --git a/internal/agents/sign/sign.go b/internal/agents/sign/sign.go new file mode 100644 index 0000000..b444ad9 --- /dev/null +++ b/internal/agents/sign/sign.go @@ -0,0 +1,250 @@ +// Package sign 提供签到工具。当用户想签到时,把签到信息写入 signs 表, +// 每个节点每天只能签到一次。 +// +// 节点身份(node_id / long_name / short_name)由 autoreply 在处理队列消息时 +// 通过 ctx 注入(见 agenttool.NodeContext);text_message 包本身不含名字, +// 队列记录里的 long_name/short_name 经常为空,因此签到工具会在名字缺失时 +// 用 node_id 查 nodeinfo 表补全。签到正文里的地区、名字、设备等字段则由 +// LLM 从用户消息中提取后作为工具参数传入。 +package sign + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "meshtastic_mqtt_server/internal/agenttool" + storepkg "meshtastic_mqtt_server/internal/store" + + "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" +) + +// SignStore 定义签到工具所需的持久化能力,通常由 *store.Store 实现。 +type SignStore interface { + CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*storepkg.SignRecord, error) + HasSignedOnDay(nodeID string, day time.Time) (bool, error) + GetNodeInfo(nodeID string) (*storepkg.NodeInfoRecord, error) +} + +// Tool 是签到工具。 +type Tool struct { + enabled bool + store SignStore +} + +// Name returns the tool name +func (t *Tool) Name() string { return "sign" } + +// Enabled returns whether the tool is enabled +func (t *Tool) Enabled() bool { return t.enabled && t.store != nil } + +// ToolDefinition returns the OpenAI tool definition +func (t *Tool) ToolDefinition(description string) *model.Tool { + desc := "签到工具。当用户想要签到/打卡/上台时调用。会记录该节点今日的签到信息,每个节点每天只能签到一次。" + + "必填参数:地区、名字、设备;可选参数:发射功率、天线长度、身处高度。" + if description != "" { + desc = description + } + return &model.Tool{ + Type: model.ToolTypeFunction, + Function: &model.FunctionDefinition{ + Name: "sign", + Description: desc, + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "region": map[string]any{ + "type": "string", + "description": "地区,例如 \"上海闵行\"、\"安徽\"、\"广东深圳\"", + }, + "name": map[string]any{ + "type": "string", + "description": "签到用户的名字/呼号,例如 \"Kevin\"、\"TaoEngine\"", + }, + "device": map[string]any{ + "type": "string", + "description": "使用的设备型号,例如 \"GAT562\"、\"EBYTE_EoRa_S3\"", + }, + "tx_power": map[string]any{ + "type": "string", + "description": "可选:发射功率,例如 \"25mW\"、\"100mW\"", + }, + "antenna_length": map[string]any{ + "type": "string", + "description": "可选:天线长度,例如 \"5dBi\"、\"1.2m\"", + }, + "altitude": map[string]any{ + "type": "string", + "description": "可选:身处高度,例如 \"30m\"、\"海拔500m\"", + }, + "raw_text": map[string]any{ + "type": "string", + "description": "可选:用户原始签到文本。当无法准确拆分地区/名字/设备时,传入用户原文作为签到正文", + }, + }, + "required": []string{"region", "name", "device"}, + }, + }, + } +} + +// Execute executes the sign tool +func (t *Tool) Execute(ctx context.Context, args string, runtime agenttool.Runtime) (string, error) { + if t.store == nil { + return "", fmt.Errorf("sign store is not configured") + } + + var params signParams + if err := json.Unmarshal([]byte(args), ¶ms); err != nil { + return "", fmt.Errorf("failed to parse arguments: %w", err) + } + + params.Region = strings.TrimSpace(params.Region) + params.Name = strings.TrimSpace(params.Name) + params.Device = strings.TrimSpace(params.Device) + params.RawText = strings.TrimSpace(params.RawText) + if params.Region == "" || params.Name == "" || params.Device == "" { + if params.RawText == "" { + return "", fmt.Errorf("region, name, device 都是必填参数") + } + } + + // 节点身份来自消息上下文,而非 LLM 回填,保证「每节点每天一次」判定可靠。 + node, ok := agenttool.NodeContextFromContext(ctx) + if !ok || strings.TrimSpace(node.NodeID) == "" { + return "", fmt.Errorf("缺少发送节点上下文,无法签到") + } + + now := runtime.Now + if now.IsZero() { + now = time.Now() + } + + // 每个节点每天只能签到一次 + signed, err := t.store.HasSignedOnDay(node.NodeID, now) + if err != nil { + return fmt.Sprintf("签到失败:检查今日签到状态时出错:%v", err), nil + } + if signed { + return fmt.Sprintf("%s 今天已经签到过了,每个节点每天只能签到一次。", displayName(node)), nil + } + + signText := buildSignText(params) + if signText == "" { + // 结构化字段缺失时回退到用户原始文本 + signText = params.RawText + } + longName, shortName := resolveNodeNames(t, node) + + record, err := t.store.CreateSign(node.NodeID, longName, shortName, signText, now) + if err != nil { + return fmt.Sprintf("签到失败:%v", err), nil + } + + return fmt.Sprintf("签到成功!%s\n签到内容:%s", displayName(node), record.SignText), nil +} + +// resolveNodeNames 取出节点的 long_name / short_name。 +// text_message 包本身不含名字,队列记录里的 long_name/short_name 经常为空; +// 此时用 node_id 查 nodeinfo 表补全,保证签到记录里能看到节点名。 +// 仍查不到则返回两个 nil,签到照常进行(仅名字字段为空)。 +func resolveNodeNames(t *Tool, node agenttool.NodeContext) (*string, *string) { + var longName, shortName *string + if ln := strings.TrimSpace(node.LongName); ln != "" { + longName = &ln + } + if sn := strings.TrimSpace(node.ShortName); sn != "" { + shortName = &sn + } + // 队列上下文已有名字就直接用,无需查库 + if longName != nil && shortName != nil { + return longName, shortName + } + if t.store == nil { + return longName, shortName + } + info, err := t.store.GetNodeInfo(node.NodeID) + if err != nil { + return longName, shortName + } + if info == nil { + return longName, shortName + } + if longName == nil && info.LongName != nil { + if v := strings.TrimSpace(*info.LongName); v != "" { + longName = &v + } + } + if shortName == nil && info.ShortName != nil { + if v := strings.TrimSpace(*info.ShortName); v != "" { + shortName = &v + } + } + return longName, shortName +} + +// signParams 是签到工具的入参。 +type signParams struct { + Region string `json:"region"` + Name string `json:"name"` + Device string `json:"device"` + TxPower string `json:"tx_power"` + AntennaLength string `json:"antenna_length"` + Altitude string `json:"altitude"` + // RawText 是用户原始签到文本,结构化字段缺失时作为签到正文回退使用。 + RawText string `json:"raw_text"` +} + +// buildSignText 按参考格式拼装签到正文:地区-名字-设备签到,可选信息附在括号内。 +// 当 region/name/device 任一缺失时返回空串,由调用方回退到 RawText。 +func buildSignText(p signParams) string { + if strings.TrimSpace(p.Region) == "" || strings.TrimSpace(p.Name) == "" || strings.TrimSpace(p.Device) == "" { + return "" + } + text := fmt.Sprintf("%s-%s-%s签到", p.Region, p.Name, p.Device) + + var extras []string + if v := strings.TrimSpace(p.TxPower); v != "" { + extras = append(extras, "发射功率 "+v) + } + if v := strings.TrimSpace(p.AntennaLength); v != "" { + extras = append(extras, "天线 "+v) + } + if v := strings.TrimSpace(p.Altitude); v != "" { + extras = append(extras, "高度 "+v) + } + if len(extras) > 0 { + text += "(" + strings.Join(extras, ",") + ")" + } + return text +} + +func displayName(node agenttool.NodeContext) string { + if node.LongName != "" { + return node.LongName + } + if node.ShortName != "" { + return node.ShortName + } + return node.NodeID +} + +// RawState returns the tool state +func (t *Tool) RawState() any { + return map[string]any{"enabled": t.enabled, "has_store": t.store != nil} +} + +func init() { + agenttool.Register(agenttool.Descriptor{ + Name: "sign", + Load: func(path string, options agenttool.LoadOptions) (agenttool.LoadedTool, error) { + tool := &Tool{enabled: true} + if store, ok := options.Value("store").(SignStore); ok && store != nil { + tool.store = store + } + return tool, nil + }, + }) +} diff --git a/internal/agenttool/registry.go b/internal/agenttool/registry.go index ce50299..ea4e466 100644 --- a/internal/agenttool/registry.go +++ b/internal/agenttool/registry.go @@ -47,6 +47,28 @@ type Runtime struct { Now time.Time } +// NodeContext carries the originating node identity for a tool execution. +// 它由 autoreply 在处理一条队列消息时注入到 ctx 中,供需要识别发送节点的 +// 工具(如签到工具)使用,避免依赖 LLM 从文本里回填节点 ID。 +type NodeContext struct { + NodeID string + LongName string + ShortName string +} + +type nodeContextKey struct{} + +// WithNodeContext 把节点身份信息挂到 ctx 上。 +func WithNodeContext(ctx context.Context, nc NodeContext) context.Context { + return context.WithValue(ctx, nodeContextKey{}, nc) +} + +// NodeContextFromContext 从 ctx 中取出节点身份信息;不存在时第二个返回值为 false。 +func NodeContextFromContext(ctx context.Context) (NodeContext, bool) { + nc, ok := ctx.Value(nodeContextKey{}).(NodeContext) + return nc, ok +} + // LoadedTool is the interface that all tools must implement type LoadedTool interface { Name() string diff --git a/internal/ai/service.go b/internal/ai/service.go index e451f67..163551f 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -7,16 +7,17 @@ import ( "os" "path/filepath" - "meshtastic_mqtt_server/internal/agenttool" _ "meshtastic_mqtt_server/internal/agents/calculator" + _ "meshtastic_mqtt_server/internal/agents/sign" _ "meshtastic_mqtt_server/internal/agents/time" + "meshtastic_mqtt_server/internal/agenttool" "meshtastic_mqtt_server/internal/autoreply" "meshtastic_mqtt_server/internal/conversation" "meshtastic_mqtt_server/internal/llm" storepkg "meshtastic_mqtt_server/internal/store" "meshtastic_mqtt_server/internal/toolmanager" - "meshtastic_mqtt_server/internal/topicrouter" "meshtastic_mqtt_server/internal/toolrouter" + "meshtastic_mqtt_server/internal/topicrouter" "gorm.io/gorm" ) @@ -43,13 +44,15 @@ type TopicRouterStore interface { // Config holds the AI service configuration type Config struct { - LLMProviders []llm.ProviderConfig - DataDir string - Enabled bool - ConsoleLog bool - ToolConfigStore ToolConfigStore - ToolRouterStore ToolRouterStore + LLMProviders []llm.ProviderConfig + DataDir string + Enabled bool + ConsoleLog bool + ToolConfigStore ToolConfigStore + ToolRouterStore ToolRouterStore TopicRouterStore TopicRouterStore + // Store 注入持久化层,供需要 DB 访问的 agent 工具(如签到工具)使用。 + Store *storepkg.Store } // Service manages all AI-related components @@ -194,7 +197,11 @@ func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Servic } // Load tools - toolMgr, err := toolmanager.Load(agentsDir, agenttool.LoadOptions{}) + loadOptions := agenttool.LoadOptions{Values: map[string]any{}} + if cfg.Store != nil { + loadOptions.Values["store"] = cfg.Store + } + toolMgr, err := toolmanager.Load(agentsDir, loadOptions) if err != nil { return nil, fmt.Errorf("failed to load tools: %w", err) } diff --git a/internal/autoreply/service.go b/internal/autoreply/service.go index 10ec2b8..a19f21d 100644 --- a/internal/autoreply/service.go +++ b/internal/autoreply/service.go @@ -9,14 +9,15 @@ import ( "time" "unicode/utf8" + "meshtastic_mqtt_server/internal/agenttool" "meshtastic_mqtt_server/internal/completion" "meshtastic_mqtt_server/internal/conversation" "meshtastic_mqtt_server/internal/llm" "meshtastic_mqtt_server/internal/message" "meshtastic_mqtt_server/internal/stream" "meshtastic_mqtt_server/internal/toolmanager" - "meshtastic_mqtt_server/internal/topicrouter" "meshtastic_mqtt_server/internal/toolrouter" + "meshtastic_mqtt_server/internal/topicrouter" "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model" ) @@ -247,6 +248,14 @@ func truncate(s string, n int) string { return s[:n] + "..." } +// ptrStringValue 安全取出 *string 的值,nil 返回空串。 +func ptrStringValue(p *string) string { + if p == nil { + return "" + } + return *p +} + // processMessage processes a single queued message func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) { // Mark message as processing @@ -339,7 +348,13 @@ func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) { routerModel = routerProfile.Config.Model } s.logf("msg=%d router_model=%s tool_loop start", msg.ID, routerModel) - augmentedMessages, toolUsed, err = toolrouter.RunAgentToolLoop(procCtx, s.toolRouter, profile, systemPrompt, conv.Messages, s.toolMgr, s.emit(msg.ID, routerModel)) + // 把发送节点身份注入 ctx,供需要识别节点的工具(如签到)使用 + nodeCtx := agenttool.WithNodeContext(procCtx, agenttool.NodeContext{ + NodeID: msg.FromNodeID, + LongName: ptrStringValue(msg.LongName), + ShortName: ptrStringValue(msg.ShortName), + }) + augmentedMessages, toolUsed, err = toolrouter.RunAgentToolLoop(nodeCtx, s.toolRouter, profile, systemPrompt, conv.Messages, s.toolMgr, s.emit(msg.ID, routerModel)) if err != nil { s.logf("msg=%d WARN tool_loop err=%v", msg.ID, err) // Continue with original messages if tool loop fails diff --git a/internal/store/sign_store.go b/internal/store/sign_store.go index 6848c82..c5a8e0d 100644 --- a/internal/store/sign_store.go +++ b/internal/store/sign_store.go @@ -45,6 +45,28 @@ func (s *Store) CountSignsByDay(opts ListOptions) ([]SignDayCount, error) { return rows, q.Scan(&rows).Error } +// HasSignedOnDay 判断指定节点在 day 所属的自然日(按 day 的时区)是否已有签到记录。 +// 用 Go 端计算当日起止时间再查询,避免依赖 SQLite/MySQL 各自的日期函数。 +func (s *Store) HasSignedOnDay(nodeID string, day time.Time) (bool, error) { + nodeID = strings.TrimSpace(nodeID) + if nodeID == "" { + return false, fmt.Errorf("node id is required") + } + loc := day.Location() + if loc == nil { + loc = time.Local + } + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) + end := start.AddDate(0, 0, 1) + var count int64 + if err := s.db.Model(&SignRecord{}). + Where("node_id = ? AND sign_time >= ? AND sign_time < ?", nodeID, start, end). + Count(&count).Error; err != nil { + return false, fmt.Errorf("check sign on day: %w", err) + } + return count > 0, nil +} + func (s *Store) GetSignByID(id uint64) (*SignRecord, error) { var row SignRecord if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { diff --git a/internal/toolrouter/loop.go b/internal/toolrouter/loop.go index 04363e4..21643a1 100644 --- a/internal/toolrouter/loop.go +++ b/internal/toolrouter/loop.go @@ -2,6 +2,7 @@ package toolrouter import ( "context" + "encoding/json" "fmt" "strings" "time" @@ -107,6 +108,13 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s } // toolUsed 记录本轮是否真的执行了至少一次工具调用,供调用方决定是否跳过话题选择等后续门控。 toolUsed := false + // 签到意图强制调用:用户明确想签到(「签到/打卡/上台」等)时,模型却不主动调 + // sign 工具的话,会被下游话题判定当成噪音丢弃。这里在循环外预判意图,待模型 + // 该轮未请求任何工具时强制注入一次 sign 调用(用用户原文作为 raw_text), + // 保证签到一定落库、且不会被话题判定丢弃。 + forceSignText := detectSignIntent(chatMessages) + _, signAvailable := toolByName["sign"] + signInvoked := false // 本轮循环中是否已经调用过 sign(含模型主动调与强制调) for i := 0; i < maxAgentToolIterations; i++ { if emit != nil { emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "running", Message: fmt.Sprintf("正在进行第 %d 轮工具判断", i+1), Data: map[string]any{"iteration": i + 1, "max_iterations": maxAgentToolIterations, "tools": availableNames}}) @@ -137,10 +145,19 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s calls = []*model.ToolCall{{ID: "legacy_function_call", Type: model.ToolTypeFunction, Function: *choice.Message.FunctionCall}} } if len(calls) == 0 { - if emit != nil { - emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "success", Message: "模型未请求工具,进入回答生成"}) + // 模型本轮未请求任何工具。若检测到签到意图且 sign 工具可用、本次循环尚未调过 sign, + // 则强制注入一次 sign 调用(以用户原文作为 raw_text),保证签到一定落库。 + if forced := buildForcedSignCall(forceSignText, signAvailable, signInvoked); forced != nil { + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: "sign", Stage: "tool_calls", Status: "running", Message: "检测到签到意图,模型未调用签到工具,强制调用 sign", Data: map[string]any{"tools": []string{"sign"}, "forced": true, "iteration": i + 1}}) + } + calls = []*model.ToolCall{forced} + } else { + if emit != nil { + emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "success", Message: "模型未请求工具,进入回答生成"}) + } + return finalMessages, toolUsed, nil } - return finalMessages, toolUsed, nil } callNames := make([]string, 0, len(calls)) for _, call := range calls { @@ -157,6 +174,9 @@ func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, s finalMessages = append(finalMessages, assistantMessage) decisionMessages = append(decisionMessages, assistantMessage) for _, call := range calls { + if call != nil && call.Function.Name == "sign" { + signInvoked = true + } result := ExecuteAgentToolCall(ctx, call, toolByName, emit) resultContent := &model.ChatCompletionMessageContent{StringValue: &result} toolMessage := &model.ChatCompletionMessage{Role: "tool", ToolCallID: call.ID, Content: resultContent} @@ -195,3 +215,67 @@ func BoolPtr(b bool) *bool { func IntPtr(i int) *int { return &i } + +// signIntentKeywords 是判定签到意图的关键词。命中任一即认为用户想签到。 +var signIntentKeywords = []string{"签到", "打卡", "上台"} + +// detectSignIntent 取最后一条用户消息,若包含签到意图关键词则返回该消息原文 +//(去除前缀的「[来自 ...]」等格式化包装),否则返回空串。 +func detectSignIntent(chatMessages []message.ChatMessage) string { + userText := lastUserMessageText(chatMessages) + if strings.TrimSpace(userText) == "" { + return "" + } + for _, kw := range signIntentKeywords { + if strings.Contains(userText, kw) { + return stripFromPrefix(userText) + } + } + return "" +} + +// stripFromPrefix 去掉 autoreply.formatUserMessage 加上的「[来自 ...] 」前缀, +// 让签到正文只保留用户实际发送的内容。 +func stripFromPrefix(s string) string { + if idx := strings.Index(s, "]"); idx >= 0 && strings.HasPrefix(strings.TrimSpace(s), "[") { + return strings.TrimSpace(s[idx+1:]) + } + return s +} + +// buildForcedSignCall 在满足条件时构造一次强制 sign 调用。条件: +// - 有签到意图原文(signText 非空) +// - sign 工具可用 +// - 本次循环尚未调过 sign(避免重复签到) +// +// 调用参数仅含 raw_text(用户原文),由 sign 工具回退为签到正文。 +func buildForcedSignCall(signText string, signAvailable, signInvoked bool) *model.ToolCall { + if strings.TrimSpace(signText) == "" || !signAvailable || signInvoked { + return nil + } + args, _ := json.Marshal(map[string]string{"raw_text": signText}) + argsStr := string(args) + return &model.ToolCall{ + ID: "forced_sign", + Type: model.ToolTypeFunction, + Function: model.FunctionCall{ + Name: "sign", + Arguments: argsStr, + }, + } +} + +// lastUserMessageText 返回消息列表中最后一条 role 为 user 的消息内容。 +func lastUserMessageText(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 "" +} diff --git a/main.go b/main.go index b0024f8..06e0ed3 100644 --- a/main.go +++ b/main.go @@ -424,6 +424,7 @@ func run(cfg *configpkg.Config) error { ToolConfigStore: store, ToolRouterStore: store, TopicRouterStore: store, + Store: store, }, store.DB(), botSenderAdapter) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err)