package comm import ( "fmt" "strings" "time" "gorm.io/gorm" ) /* 记忆项的**共享查询层**。 放在 comm 是为了解决一个具体问题:`mcp` 是独立进程、独立服务,它的工具从不 RpcCall、只裸查 MySQL(见 tool_get_user_tasks 的 `mysql.Table(...).Where("uid = ?", uid)`)。如果 memory 的过滤条件、分类白名单、 排序口径在 home 和 mcp 里各写一遍,改了一边另一边不会报错,只会让 EMAI 答的 和 App 里显示的对不上——而且没人会发现。 所以两边都调这里。**新增过滤条件只改这一个文件。** ⚠️ 这里只放「读」。写操作(新增/改/删)仍只在 home 的 memory 模块里, MCP 侧的写工具经它的 HTTP 接口不了——那是刻意的:写路径的校验、幂等、 提醒重算都在 memory 模块内,复制一份必然漂移。 */ // MemoryQuery 记忆项查询条件。零值表示不限制。 type MemoryQuery struct { Uid string StartDate string // YYYY-MM-DD,含 EndDate string // YYYY-MM-DD,含 Categories []string States []int32 Limit int Offset int } // ApplyMemoryQuery 把条件套到一个已经 Table(memory_item) 过的 *gorm.DB 上。 // // ⚠️ **uid 一定会被拼进去**,即使 q.Uid 为空也会拼一个不可能命中的条件—— // 少一个 uid 条件就是把全库的记忆项返回给调用方,而这个函数有两个调用方, // 其中一个(MCP)是公网可达的。宁可返回空也不要返回别人的数据。 func ApplyMemoryQuery(tx *gorm.DB, q MemoryQuery) *gorm.DB { if strings.TrimSpace(q.Uid) == "" { return tx.Where("1 = 0") } tx = tx.Where("uid = ?", q.Uid) if q.StartDate != "" { tx = tx.Where("happen_date >= ?", q.StartDate) } if q.EndDate != "" { tx = tx.Where("happen_date <= ?", q.EndDate) } if len(q.Categories) > 0 { tx = tx.Where("category in ?", q.Categories) } if len(q.States) > 0 { tx = tx.Where("state in ?", q.States) } return tx } // OrderMemoryItems 统一排序口径:先日期,再时刻(无时刻的排当天最后),最后 id。 // // 两边必须一致,否则「App 里第一条」和「EMAI 说的第一条」会是不同的东西。 func OrderMemoryItems(tx *gorm.DB) *gorm.DB { return tx.Order("happen_date asc"). Order("happen_time = '' asc"). Order("happen_time asc"). Order("id asc") } // NormalizeMemoryCategories 过滤掉不在白名单里的分类。 // // 返回 (合法分类, 第一个非法分类)。全部合法时第二个返回值为空串。 // 调用方决定是拒绝还是忽略——MCP 侧倾向忽略(模型填错分类不该让整次查询失败), // HTTP 接口侧倾向拒绝(客户端填错是 bug,早点暴露)。 func NormalizeMemoryCategories(in []string) ([]string, string) { out := make([]string, 0, len(in)) bad := "" for _, c := range in { c = strings.ToLower(strings.TrimSpace(c)) if c == "" { continue } if !IsValidMemoryCategory(c) { if bad == "" { bad = c } continue } out = append(out, c) } return out, bad } /* 重复项在周期统计里的展开。 一条 `daily` 闹钟在库里**只有一行**,happen_date 是当初定下它的那天。于是按 `happen_date` 落在周期内来计数时,锚点在几个月前的重复闹钟在本周的 alarm_total 里是 0 —— 而用户这一周每天都被它叫醒过。 ⚠️ **只展开闹钟**,刻意的: - 花销展开等于**虚构金额**。「每月房租」展开进「你这周花了多少」,报出去的是 一个用户根本没记过的数字,而这份统计的全部价值就在数字是准的。 - 待办的 done/undone 按次拆不开:一行只有一个 state,把一条「每周复盘」的 重复待办按 7 次都算成已完成,完成率就成了假的。 - 灵感本来就不该重复。 ⚠️ 放 comm 是因为 memory 的 computeStats 与 mcp 的 get_memory_stats 各查各的库 (mcp 是独立进程),两边各写一份迟早漂成「App 里显示 7 次、EMAI 说 1 次」。 */ // MemoryExtraOccurrences 一条重复项在 [start,end] 里**除已计入的那一行之外**还发生了几次。 // // 返回的是增量而不是总次数:调用方的 group by 已经把锚点落在周期内的那一行数过一次了, // 这里再返回总数就会重复计。锚点在周期之前的那种,那一行没被数到,返回的就是全部次数。 func MemoryExtraOccurrences(rule string, weekday int32, anchor, start, end time.Time) int { if rule == "" || rule == MemoryRepeatOnce { return 0 } hits := 0 for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { // 定下这条闹钟之前的日子不算数 if d.Before(anchor) { continue } if MemoryRepeatHits(rule, weekday, d, anchor) { hits++ } } // 锚点那一行已经被调用方数过一次,扣掉它。 // hits==0 时不扣:那说明这条规则在本周期一次都没命中(比如周一的闹钟、 // 周期里恰好没有周一),此时返回 -1 会把调用方本来数对的那一行抹掉。 if hits > 0 && !anchor.Before(start) && !anchor.After(end) { hits-- } return hits } // ApplyRepeatCandidates 取「会重复、且锚点不晚于 end」的项——周期统计要展开的就是这些。 // // 锚点晚于 end 的是未来才开始的,本周期一次都不会响。 func ApplyRepeatCandidates(tx *gorm.DB, uid, end string, categories []string) *gorm.DB { if strings.TrimSpace(uid) == "" { return tx.Where("1 = 0") } tx = tx.Where("uid = ?", uid). Where("repeat_rule <> '' and repeat_rule <> ?", MemoryRepeatOnce) if end != "" { tx = tx.Where("happen_date <= ?", end) } if len(categories) > 0 { tx = tx.Where("category in ?", categories) } return tx } // MemoryRelativeRange 把「最近 N 天 / 本周 / 本月」这类相对说法换算成日期区间。 // // 给 MCP 工具用:模型问「我这个月花了多少」时不该自己算日期, // 它对「今天几号」的认知来自对话上下文,很不可靠。 func MemoryRelativeRange(kind string, now time.Time) (start, end string, err error) { switch strings.ToLower(strings.TrimSpace(kind)) { case "today": d := FormatMemoryDate(now) return d, d, nil case "yesterday": d := FormatMemoryDate(now.AddDate(0, 0, -1)) return d, d, nil case "this_week": s, e := MemoryWeekRange(now) return FormatMemoryDate(s), FormatMemoryDate(e), nil case "last_week": s, e := MemoryWeekRange(now.AddDate(0, 0, -7)) return FormatMemoryDate(s), FormatMemoryDate(e), nil case "this_month": s, e := MemoryMonthRange(now) return FormatMemoryDate(s), FormatMemoryDate(e), nil case "last_month": prev := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, 0, -1) s, e := MemoryMonthRange(prev) return FormatMemoryDate(s), FormatMemoryDate(e), nil case "next_7_days": return FormatMemoryDate(now), FormatMemoryDate(now.AddDate(0, 0, 7)), nil case "last_7_days": return FormatMemoryDate(now.AddDate(0, 0, -7)), FormatMemoryDate(now), nil } return "", "", fmt.Errorf("不支持的时间范围: %s", kind) } // MemoryRelativeRanges 支持的相对范围,给工具描述用 var MemoryRelativeRanges = []string{ "today", "yesterday", "this_week", "last_week", "this_month", "last_month", "next_7_days", "last_7_days", }