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.
296 lines
11 KiB
296 lines
11 KiB
package comm
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"yunyan/lego/sys/log"
|
|
)
|
|
|
|
/*
|
|
记忆中心(拾忆)的跨模块公共定义。
|
|
|
|
放在 comm 而不是 modules/memory 的理由:`mcp` 是独立进程、独立服务,它的工具要按
|
|
同一套口径读同一张表;分类白名单、日期口径、重复规则展开这三件事一旦两边各写一份,
|
|
改了一边不会报错、只会让 EMAI 答得和 App 里显示的不一样。
|
|
*/
|
|
|
|
// 记忆项分类白名单。新增分类在这里加一行即可——不改表、不迁移。
|
|
//
|
|
// ⚠️ 客户端对不认识的分类必须兜底成 todo 渲染(老包收到新分类不能崩),
|
|
// 所以这里加分类是安全的,但客户端的兜底一定要先发出去。
|
|
const (
|
|
MemoryCatAlarm = "alarm" //闹钟
|
|
MemoryCatTodo = "todo" //待办
|
|
MemoryCatIdea = "idea" //灵感
|
|
MemoryCatExpense = "expense" //花销
|
|
)
|
|
|
|
// 记忆项来源
|
|
const (
|
|
MemorySrcMeeting = "meeting" //会议纪要抽取
|
|
MemorySrcAssistant = "assistant" //EMAI 端侧指令
|
|
MemorySrcManual = "manual" //用户手动录入
|
|
MemorySrcDevice = "device" //设备侧
|
|
)
|
|
|
|
// 重复规则
|
|
const (
|
|
MemoryRepeatOnce = "once"
|
|
MemoryRepeatDaily = "daily"
|
|
MemoryRepeatWeekly = "weekly"
|
|
MemoryRepeatWeekdays = "weekdays"
|
|
MemoryRepeatWeekend = "weekend"
|
|
MemoryRepeatMonthly = "monthly"
|
|
)
|
|
|
|
// 周期报告类型
|
|
const (
|
|
MemoryPeriodWeek = "week"
|
|
MemoryPeriodMonth = "month"
|
|
)
|
|
|
|
var memoryCategories = map[string]bool{
|
|
MemoryCatAlarm: true,
|
|
MemoryCatTodo: true,
|
|
MemoryCatIdea: true,
|
|
MemoryCatExpense: true,
|
|
}
|
|
|
|
var memoryRepeatRules = map[string]bool{
|
|
MemoryRepeatOnce: true,
|
|
MemoryRepeatDaily: true,
|
|
MemoryRepeatWeekly: true,
|
|
MemoryRepeatWeekdays: true,
|
|
MemoryRepeatWeekend: true,
|
|
MemoryRepeatMonthly: true,
|
|
}
|
|
|
|
// IsValidMemoryCategory 分类是否在白名单里
|
|
func IsValidMemoryCategory(c string) bool { return memoryCategories[c] }
|
|
|
|
// IsValidMemoryRepeat 重复规则是否合法(空串等同 once)
|
|
func IsValidMemoryRepeat(r string) bool { return r == "" || memoryRepeatRules[r] }
|
|
|
|
// MeetingMinSeconds 一段录音要多长才当「一场会议」看待。
|
|
//
|
|
// 两处用它,必须同一个数:memory 的待办抽取门槛默认值(可由 home.yaml 覆盖),
|
|
// 和 MCP search_meeting_notes 的检索过滤(独立进程,读不到 memory 的配置,
|
|
// 所以那边就是这个常量本身)。
|
|
//
|
|
// 拦的是 6 秒的测试通话、20 秒的单人自述这类东西:它们照样会被总结成六百多字的
|
|
// 纪要(模板强制有「待办事项」一节,模型没真待办就把说话人的愿望改写成行动项),
|
|
// 抽进拾忆是垃圾待办,进 MCP 检索会把真会议挤出结果。
|
|
//
|
|
// ⚠️ 只影响「进不进拾忆/检索」,不影响用户能不能看到纪要。短语音备忘是合法用法。
|
|
const MeetingMinSeconds int32 = 60
|
|
|
|
// MemoryDateLayout 记忆项日期的唯一格式。happen_date 是「用户本地日期」,
|
|
// 不是 UTC 日期——服务端不做时区推断,一律以客户端上报的为准。
|
|
const MemoryDateLayout = "2006-01-02"
|
|
|
|
// NormalizeMemoryDate 把「看起来像日期」的串收敛成 YYYY-MM-DD。
|
|
//
|
|
// ⚠️ 为什么需要它:`memory_report.period_start/period_end` 是 `type:date` 列,
|
|
// 而 DSN 带 `parseTime=True`,于是从库里读回到 Go 的 **string** 字段时拿到的是
|
|
// `2026-09-07T00:00:00+08:00` 而不是 `2026-09-07`。
|
|
// 按 happen_date 比较的那几句 SQL 靠 MySQL 自己做类型转换还能对,但凡是要
|
|
// **在 Go 里解析**这个值的地方都会静默失败——2026-09-18 真机上重复闹钟的展开
|
|
// 就是这么一次都没生效的(ParseMemoryDate 解析不了,直接按「没有重复项」返回 0)。
|
|
//
|
|
// 认不出来时原样返回:调用方该用它拼 SQL 还是照样拼,行为与加这个函数之前一致。
|
|
func NormalizeMemoryDate(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return s
|
|
}
|
|
if t, ok := ParseMemoryDate(s); ok {
|
|
return FormatMemoryDate(t)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ParseMemoryDate 解析日期。主格式是 YYYY-MM-DD,**同时容忍带时间的形式**。
|
|
//
|
|
// ⚠️ 容忍是必须的,不是宽松:`happen_date` / `period_start` 都是 MySQL 的 `type:date` 列,
|
|
// 而 DSN 带 `parseTime=True`,gorm 把它们扫进 Go 的 string 字段时给的是
|
|
// `2026-09-07T00:00:00+08:00`。只认 YYYY-MM-DD 的话,**所有拿库里读出来的日期
|
|
// 做解析的地方都会静默失效**:
|
|
// - remind.go 的 expandItem → memory_upcoming 恒返回 0 个提醒时刻,
|
|
// 整个拾忆提醒在服务端空转(2026-09-18 真机实测确认);
|
|
// - stats.go 的重复项展开 → 重复闹钟永远只算 1 次。
|
|
//
|
|
// 两处都不报错,只是「什么都没发生」。
|
|
func ParseMemoryDate(s string) (time.Time, bool) {
|
|
s = strings.TrimSpace(s)
|
|
if t, err := time.Parse(MemoryDateLayout, s); err == nil {
|
|
return t, true
|
|
}
|
|
// 带时间的形式(RFC3339 / "2006-01-02 15:04:05"):前 10 位就是日期。
|
|
// ⚠️ 只取日期部分、丢掉时间与时区是刻意的——这个字段的语义是「用户本地的那一天」,
|
|
// 不是一个时刻,按时区换算反而会把日期挪一天。
|
|
if len(s) > len(MemoryDateLayout) {
|
|
if t, err := time.Parse(MemoryDateLayout, s[:len(MemoryDateLayout)]); err == nil {
|
|
return t, true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// FormatMemoryDate 按统一格式输出
|
|
func FormatMemoryDate(t time.Time) string { return t.Format(MemoryDateLayout) }
|
|
|
|
// LoadMemoryLocation 解析客户端上报的时区。
|
|
//
|
|
// 认三种形式,按这个顺序:
|
|
// 1. IANA 名(`Asia/Shanghai`)——**这是唯一希望客户端上报的东西**;
|
|
// 2. 固定偏移(`UTC+8` / `GMT+08:00` / `+08:00`);
|
|
// 3. `CST` 这个缩写。
|
|
//
|
|
// ⚠️ 为什么要认 `CST`:客户端早先上报的是 `DateTime.now().timeZoneName`,给出来的
|
|
// 就是它,库里 tz 列至今只有空串和 `CST` 两个值。而 `CST` 是有名的歧义缩写
|
|
// (中国标准时 UTC+8 / 美国中部时 UTC-6 / 古巴 / 澳洲中部都叫这个),Go 的
|
|
// LoadLocation 根本解析不了。这里**按 Asia/Shanghai 解释**,理由是已落库的那批
|
|
// 全部来自国内设备;新版客户端上报 IANA 名,不会再走到这条。
|
|
//
|
|
// ⚠️ 认不出来一律回退到容器本地时区(Asia/Shanghai,见 Dockerfile 与 compose 的 TZ),
|
|
// **绝不回退成 UTC**——那会让所有时刻整体偏移 8 小时,而且是静默的。
|
|
// 但回退本身要打一条 warn:原来是完全静默的,海外用户的提醒时刻全按北京时间算
|
|
// 也没有任何迹象。
|
|
//
|
|
// 容器已 import _ "time/tzdata",缺 tzdata 的镜像也能解析。
|
|
func LoadMemoryLocation(tz string) *time.Location {
|
|
tz = strings.TrimSpace(tz)
|
|
if tz == "" {
|
|
return time.Local
|
|
}
|
|
if loc, err := time.LoadLocation(tz); err == nil {
|
|
return loc
|
|
}
|
|
if loc, ok := parseFixedZone(tz); ok {
|
|
return loc
|
|
}
|
|
if strings.EqualFold(tz, "CST") {
|
|
if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil {
|
|
return loc
|
|
}
|
|
}
|
|
log.Warnf("[memory] 无法识别的时区 %q,已按服务器本地时区(%s)处理;"+
|
|
"该用户的提醒时刻可能偏移——客户端应上报 IANA 名如 Asia/Shanghai", tz, time.Local)
|
|
return time.Local
|
|
}
|
|
|
|
// fixedZonePattern 匹配 UTC+8 / GMT+08:00 / +08:00 / -0530 这几种固定偏移写法。
|
|
var fixedZonePattern = regexp.MustCompile(`^(?i:UTC|GMT)?([+-])(\d{1,2}):?(\d{2})?$`)
|
|
|
|
// parseFixedZone 把固定偏移写法转成 time.Location。
|
|
//
|
|
// 这类值不该出现(时区 ≠ 偏移:同一个偏移夏令时前后是两个时区),但客户端兜底逻辑
|
|
// 或第三方 SDK 给出这种串是常事,认下来总好过整条退回服务器时区。
|
|
func parseFixedZone(s string) (*time.Location, bool) {
|
|
m := fixedZonePattern.FindStringSubmatch(strings.TrimSpace(s))
|
|
if m == nil {
|
|
return nil, false
|
|
}
|
|
hour, err := strconv.Atoi(m[2])
|
|
if err != nil || hour > 14 {
|
|
return nil, false
|
|
}
|
|
minute := 0
|
|
if m[3] != "" {
|
|
if minute, err = strconv.Atoi(m[3]); err != nil || minute >= 60 {
|
|
return nil, false
|
|
}
|
|
}
|
|
offset := hour*3600 + minute*60
|
|
if m[1] == "-" {
|
|
offset = -offset
|
|
}
|
|
return time.FixedZone(strings.ToUpper(strings.TrimSpace(s)), offset), true
|
|
}
|
|
|
|
// ParseMemoryClock 解析 HH:mm。空串或非法返回 (0,0,false)。
|
|
func ParseMemoryClock(s string) (hour, minute int, ok bool) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return 0, 0, false
|
|
}
|
|
t, err := time.Parse("15:04", s)
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
return t.Hour(), t.Minute(), true
|
|
}
|
|
|
|
// NewMemoryClientKey 生成幂等键。
|
|
//
|
|
// ⚠️ client_key 在库里参与 (uid, client_key) 唯一索引,而 **MySQL 的唯一索引对空串
|
|
// 不像对 NULL 那样放行重复**——留空会让同一用户的第二条手动记录直接插入失败。
|
|
// 所以客户端没传时服务端必须现生成一个。用 crypto/rand 而不是 math/rand:
|
|
// 这个值会被当成去重依据,可预测就意味着可被别人抢占。
|
|
func NewMemoryClientKey() string {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
// crypto/rand 失败是极端情况;退化成时间戳仍然唯一(同一用户不可能同纳秒两次插入)
|
|
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// MemoryWeekKey 返回 ISO 周键,形如 2026-W35。
|
|
//
|
|
// ⚠️ 口径固定为 ISO-8601(周一为一周之首)。美国等地区习惯周日起,
|
|
// 这里不做地区适配——一个应用里「上周」必须只有一种含义,否则报告的
|
|
// 时间范围会因用户所在地而异,同一份数据算出两个结果。
|
|
func MemoryWeekKey(t time.Time) string {
|
|
y, w := t.ISOWeek()
|
|
return fmt.Sprintf("%04d-W%02d", y, w)
|
|
}
|
|
|
|
// MemoryMonthKey 返回月键,形如 2026-08
|
|
func MemoryMonthKey(t time.Time) string { return t.Format("2006-01") }
|
|
|
|
// MemoryWeekRange 返回 t 所在 ISO 周的周一与周日(按 t 自身的时区)
|
|
func MemoryWeekRange(t time.Time) (start, end time.Time) {
|
|
// Go 的 Weekday() 里周日是 0,ISO 周一为首,先换算成「距周一几天」
|
|
offset := (int(t.Weekday()) + 6) % 7
|
|
start = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset)
|
|
end = start.AddDate(0, 0, 6)
|
|
return
|
|
}
|
|
|
|
// MemoryMonthRange 返回 t 所在自然月的首末日(按 t 自身的时区)
|
|
func MemoryMonthRange(t time.Time) (start, end time.Time) {
|
|
start = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
|
|
end = start.AddDate(0, 1, -1)
|
|
return
|
|
}
|
|
|
|
// MemoryRepeatHits 判断某一天是否命中重复规则。
|
|
//
|
|
// weekday 用 1-7 表示周一到周日(与客户端 Dart 的 DateTime.weekday 一致,
|
|
// **不是** Go 的 time.Weekday——Go 里周日是 0,直接比会把周日错判成周一)。
|
|
func MemoryRepeatHits(rule string, weekday int32, day time.Time, anchor time.Time) bool {
|
|
dartWeekday := int32(day.Weekday())
|
|
if dartWeekday == 0 {
|
|
dartWeekday = 7 // 周日:Go 的 0 → Dart 的 7
|
|
}
|
|
switch rule {
|
|
case MemoryRepeatDaily:
|
|
return true
|
|
case MemoryRepeatWeekly:
|
|
return dartWeekday == weekday
|
|
case MemoryRepeatWeekdays:
|
|
return dartWeekday >= 1 && dartWeekday <= 5
|
|
case MemoryRepeatWeekend:
|
|
return dartWeekday == 6 || dartWeekday == 7
|
|
case MemoryRepeatMonthly:
|
|
return day.Day() == anchor.Day()
|
|
}
|
|
return false
|
|
}
|
|
|