package memory import ( "sort" "yunyan/comm" "yunyan/lego/sys/mysql" "yunyan/pb" ) /* 周期统计。 ⚠️ 金额与完成数**全部走 SQL**,绝不让 LLM 算。LLM 只负责把算好的 stat_json 组织成一段适合朗读的话——数字算错比话说得干巴严重得多。 */ // 明细条数上限。一个重度用户一周能攒两百条未完成,全塞进 prompt 会顶爆上下文、 // 拖慢生成,而且**播报出来没人听得完**。总数用 TodoUndone 表达,明细只举几条。 const statItemLimit = 20 type statBrief struct { Id uint64 `json:"id"` Title string `json:"title"` HappenDate string `json:"happen_date"` } type statExpense struct { Currency string `json:"currency"` TotalCents int64 `json:"total_cents"` Count int32 `json:"count"` } type memoryStat struct { PeriodStart string `json:"period_start"` PeriodEnd string `json:"period_end"` TodoTotal int32 `json:"todo_total"` TodoDone int32 `json:"todo_done"` TodoUndone int32 `json:"todo_undone"` AlarmTotal int32 `json:"alarm_total"` IdeaCount int32 `json:"idea_count"` UndoneItems []statBrief `json:"undone_items"` UndoneTruncated bool `json:"undone_truncated"` IdeaItems []statBrief `json:"idea_items"` IdeaTruncated bool `json:"idea_truncated"` Expenses []statExpense `json:"expenses"` } // hasContent 报告有没有值得播报的内容。全空就别生成了。 func (s *memoryStat) hasContent() bool { return s.TodoTotal > 0 || s.AlarmTotal > 0 || s.IdeaCount > 0 || len(s.Expenses) > 0 } // countRepeatAlarmExtras 重复闹钟在周期内比「库里那一行」多出来的次数。 // // 日期解析不了的项跳过(happen_date 是 date 列,正常不会出现,但别为一行坏数据 // 让整份报告失败)。周期最长一个月,逐日判定至多 31 次,成本可以忽略。 func (this *Memory) countRepeatAlarmExtras(uid, start, end string) (int, error) { s, ok1 := comm.ParseMemoryDate(start) e, ok2 := comm.ParseMemoryDate(end) if !ok1 || !ok2 { return 0, nil } items := make([]*pb.DBMemoryItem, 0) if err := comm.ApplyRepeatCandidates(mysql.Table(comm.TableMemoryItem), uid, end, []string{comm.MemoryCatAlarm}).Find(&items).Error; err != nil { return 0, err } total := 0 for _, it := range items { anchor, ok := comm.ParseMemoryDate(it.HappenDate) if !ok { continue } total += comm.MemoryExtraOccurrences(it.RepeatRule, it.Weekday, anchor, s, e) } return total, nil } // computeStats 按日期范围算统计(含 start 与 end 当天) func (this *Memory) computeStats(uid, start, end string) (*memoryStat, error) { // ⚠️ 入参可能来自 memory_report 读回来的 period_start/period_end,那是 `type:date` // 列 + parseTime=True,读成 string 是 `2026-09-07T00:00:00+08:00`。 // 下面的 SQL 靠 MySQL 转换还能对,但 Go 侧解析(重复项展开)会静默失败, // 所以在入口先收敛一次;顺带让 stat_json 里的周期也是干净的日期, // 那份 JSON 是要喂给 LLM 写播报文案的。 start, end = comm.NormalizeMemoryDate(start), comm.NormalizeMemoryDate(end) st := &memoryStat{PeriodStart: start, PeriodEnd: end, UndoneItems: []statBrief{}, IdeaItems: []statBrief{}, Expenses: []statExpense{}} base := func() interface { Count(*int64) error } { return nil } _ = base type catCount struct { Category string State int32 N int64 } rows := make([]catCount, 0) err := mysql.Table(comm.TableMemoryItem). Select("category, state, count(*) as n"). Where("uid = ? and happen_date >= ? and happen_date <= ?", uid, start, end). Group("category, state").Find(&rows).Error if err != nil { return nil, err } for _, r := range rows { switch r.Category { case comm.MemoryCatTodo: st.TodoTotal += int32(r.N) if r.State == int32(pb.MemoryState_MemoryState_Done) { st.TodoDone += int32(r.N) } case comm.MemoryCatAlarm: st.AlarmTotal += int32(r.N) case comm.MemoryCatIdea: st.IdeaCount += int32(r.N) } } st.TodoUndone = st.TodoTotal - st.TodoDone // 重复闹钟按周期内实际响了几次算(见 comm.MemoryExtraOccurrences): // 一条 daily 闹钟在库里只有一行、锚点可能在几个月前,上面那句 group by 数不到它, // 于是「这周被闹钟叫了多少次」恒为 0。只补闹钟,别的分类不展开,理由在 comm 里写了。 if n, e := this.countRepeatAlarmExtras(uid, start, end); e != nil { // 展开失败不算整体失败:宁可少算这一项,也不要让整份报告生成不出来 this.Warnf("周期统计 uid:%s 展开重复闹钟失败已忽略: %v", uid, e) } else { st.AlarmTotal += int32(n) } // 花销按币种聚合。金额是 int64 分,SUM 不会有浮点误差。 expRows := make([]statExpense, 0) err = mysql.Table(comm.TableMemoryItem). Select("currency, sum(amount_cents) as total_cents, count(*) as count"). Where("uid = ? and happen_date >= ? and happen_date <= ? and category = ?", uid, start, end, comm.MemoryCatExpense). Group("currency").Find(&expRows).Error if err != nil { return nil, err } sort.Slice(expRows, func(i, j int) bool { return expRows[i].TotalCents > expRows[j].TotalCents }) st.Expenses = expRows // 明细:未完成待办 undone := make([]*pb.DBMemoryItem, 0) err = mysql.Table(comm.TableMemoryItem). Where("uid = ? and happen_date >= ? and happen_date <= ? and category = ? and state = ?", uid, start, end, comm.MemoryCatTodo, int32(pb.MemoryState_MemoryState_Pending)). Order("happen_date asc").Limit(statItemLimit + 1).Find(&undone).Error if err != nil { return nil, err } if len(undone) > statItemLimit { st.UndoneTruncated = true undone = undone[:statItemLimit] } for _, it := range undone { st.UndoneItems = append(st.UndoneItems, statBrief{Id: it.Id, Title: it.Title, HappenDate: comm.NormalizeMemoryDate(it.HappenDate)}) } // 明细:灵感 ideas := make([]*pb.DBMemoryItem, 0) err = mysql.Table(comm.TableMemoryItem). Where("uid = ? and happen_date >= ? and happen_date <= ? and category = ?", uid, start, end, comm.MemoryCatIdea). Order("happen_date asc").Limit(statItemLimit + 1).Find(&ideas).Error if err != nil { return nil, err } if len(ideas) > statItemLimit { st.IdeaTruncated = true ideas = ideas[:statItemLimit] } for _, it := range ideas { st.IdeaItems = append(st.IdeaItems, statBrief{Id: it.Id, Title: it.Title, HappenDate: comm.NormalizeMemoryDate(it.HappenDate)}) } return st, nil }