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