You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

197 lines
7.2 KiB

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",
}