Files
meshtastic_mqtt_server/internal/agents/sign/sign.go
T
kevinandClaude Fable 5 6620192322 签到检查功能增强:返回签到时间和内容
问题:
- 用户询问'我什么时候签到的'时,AI 说'系统只记录是否签到,没有时间戳'
- 实际上数据库 signs 表有完整的 sign_time 和 sign_text 字段

解决方案:
- 修改 executeCheck 方法,改用 ListSigns 查询今天的签到记录
- 返回完整的签到详情:签到时间(HH:MM:SS格式)+ 签到内容
- 未签到时仍返回简单提示

改动内容:
- executeCheck 使用 ListSigns 替代 HasSignedOnDay
- 构建 ListOptions 查询今天的签到记录(过滤 NodeID + 时间范围)
- 返回格式:'XXX 今天已经签到过了。\n签到时间:10:30:45\n签到内容:上海-Kevin-GAT562签到'
- 更新测试验证返回内容包含时间和签到文本
- 更新 mockSignStore.ListSigns 支持 NodeID 和 Limit 过滤

使用效果:
- 用户:'我今天签到了吗?' → 返回是否签到 + 时间 + 内容
- 用户:'我什么时候签到的?' → 返回签到时间:10:30:45

测试:
-  未签到场景测试通过
-  已签到场景测试通过(验证时间和内容)
-  所有签到工具测试通过
-  项目编译成功

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-23 21:47:16 +08:00

400 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
CountSigns(opts storepkg.ListOptions) (int64, error)
CountSignsByDay(opts storepkg.ListOptions) ([]storepkg.SignDayCount, error)
ListSigns(opts storepkg.ListOptions) ([]storepkg.SignRecord, 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 := "签到工具。支持三种操作:\n" +
"1. 签到操作(action=sign):记录节点今日签到信息,每个节点每天只能签到一次。必填参数:地区、名字、设备\n" +
"2. 查询操作(action=query):查询签到统计。可按日期范围查询,默认查询今天。返回签到总数和按天的统计数据\n" +
"3. 检查操作(action=check):检查当前节点今天是否已签到。用于回答用户'我今天签到了吗'之类的问题"
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{
"action": map[string]any{
"type": "string",
"enum": []string{"sign", "query", "check"},
"description": "操作类型:sign=签到,query=查询签到统计,check=检查今天是否已签到",
},
"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": "签到时可选:用户原始签到文本。当无法准确拆分地区/名字/设备时,传入用户原文作为签到正文",
},
"date": map[string]any{
"type": "string",
"description": "查询时可选:查询日期,格式 YYYY-MM-DD,例如 \"2024-06-23\"。不填则查询今天",
},
"days": map[string]any{
"type": "integer",
"description": "查询时可选:查询最近N天的数据,例如 7 表示最近7天。不填则只查询date指定的那一天",
},
},
"required": []string{"action"},
},
},
}
}
// 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), &params); err != nil {
return "", fmt.Errorf("failed to parse arguments: %w", err)
}
// 根据 action 参数路由到不同的操作
switch strings.ToLower(strings.TrimSpace(params.Action)) {
case "query":
return t.executeQuery(ctx, params, runtime)
case "check":
return t.executeCheck(ctx, params, runtime)
case "sign", "":
return t.executeSign(ctx, params, runtime)
default:
return "", fmt.Errorf("无效的操作类型:%s,只支持 sign、query 或 check", params.Action)
}
}
// executeSign 执行签到操作
func (t *Tool) executeSign(ctx context.Context, params signParams, runtime agenttool.Runtime) (string, error) {
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
}
// executeCheck 检查当前节点今天是否已签到,并返回签到详情
func (t *Tool) executeCheck(ctx context.Context, params signParams, runtime agenttool.Runtime) (string, error) {
// 节点身份来自消息上下文
node, ok := agenttool.NodeContextFromContext(ctx)
if !ok || strings.TrimSpace(node.NodeID) == "" {
return "", fmt.Errorf("缺少发送节点上下文,无法检查签到状态")
}
now := runtime.Now
if now.IsZero() {
now = time.Now()
}
// 构建查询选项:查询今天的签到记录
loc := now.Location()
if loc == nil {
loc = time.Local
}
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
opts := storepkg.ListOptions{
NodeID: node.NodeID,
Since: &start,
Until: &end,
Limit: 1,
}
// 查询数据库获取今天的签到记录
signs, err := t.store.ListSigns(opts)
if err != nil {
return "", fmt.Errorf("查询签到记录失败:%w", err)
}
if len(signs) == 0 {
return fmt.Sprintf("%s 今天还没有签到。", displayName(node)), nil
}
// 返回签到详情
sign := signs[0]
signTimeStr := sign.SignTime.Format("15:04:05")
return fmt.Sprintf("%s 今天已经签到过了。\n签到时间:%s\n签到内容:%s",
displayName(node), signTimeStr, sign.SignText), nil
}
// executeQuery 执行查询操作
func (t *Tool) executeQuery(ctx context.Context, params signParams, runtime agenttool.Runtime) (string, error) {
now := runtime.Now
if now.IsZero() {
now = time.Now()
}
// 解析查询日期
var targetDate time.Time
if params.Date != "" {
var err error
targetDate, err = time.Parse("2006-01-02", params.Date)
if err != nil {
return "", fmt.Errorf("日期格式错误,应为 YYYY-MM-DD%v", err)
}
} else {
// 默认查询今天
targetDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}
// 构建查询选项
var opts storepkg.ListOptions
if params.Days > 0 {
// 查询最近N天
since := targetDate.AddDate(0, 0, -params.Days+1)
until := targetDate.Add(24*time.Hour - time.Nanosecond)
opts.Since = &since
opts.Until = &until
} else {
// 只查询指定的那一天
since := targetDate
until := targetDate.Add(24*time.Hour - time.Nanosecond)
opts.Since = &since
opts.Until = &until
}
// 获取总数
total, err := t.store.CountSigns(opts)
if err != nil {
return "", fmt.Errorf("查询签到总数失败:%w", err)
}
// 获取按天统计
dayCounts, err := t.store.CountSignsByDay(opts)
if err != nil {
return "", fmt.Errorf("查询按天统计失败:%w", err)
}
// 构建返回消息
var result strings.Builder
if params.Days > 0 {
result.WriteString(fmt.Sprintf("最近 %d 天的签到统计:\n", params.Days))
} else {
result.WriteString(fmt.Sprintf("%s 的签到统计:\n", targetDate.Format("2006-01-02")))
}
result.WriteString(fmt.Sprintf("总计:%d 人次\n\n", total))
if len(dayCounts) > 0 {
result.WriteString("按天统计:\n")
for _, dc := range dayCounts {
result.WriteString(fmt.Sprintf("- %s: %d 人\n", dc.Date, dc.Count))
}
} else {
result.WriteString("该时间段内没有签到记录")
}
return result.String(), 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 {
Action string `json:"action"` // 操作类型:sign=签到,query=查询
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 string `json:"raw_text"` // 签到时使用:用户原始签到文本
Date string `json:"date"` // 查询时使用:日期 YYYY-MM-DD
Days int `json:"days"` // 查询时使用:查询最近N天
}
// 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
},
})
}