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.
 
 
 
 
 
 

188 lines
6.4 KiB

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
}