feat: 健康洞察——把散落的记录变成能看懂的结论
之前用户记几十条数据只换回一条折线,数据没有回到他身上,所以不记也不亏。 新增 GET /api/pets/:id/insights,只看最近 90 天(更早的对「现在怎么样」 没参考价值,还会让关联分析找出一堆巧合),产出最多 6 条按严重度排序的结论: 1. 体重趋势 —— 单次数字没意义,跨度 ≥14 天的连续同向变化才算。 跌 10% 报警建议血检,涨 15% 提醒控制,跌 5% 持续观察 2. 换粮 → 软便的时间关联 —— 找异常排便前 72 小时内的饮食记录。 这是用户最看不出来的一类:两条记录隔两三天、分属不同类型,翻时间轴翻不出来 3. 异常扎堆 —— 两周内 ≥2 次异常。偶发一次和反复出现完全不是一回事 4. 逾期提醒 —— 设了不看等于没设 5. 同龄体重对比 —— 同物种、月龄差 2 个月内其它宠物的中位数。 样本 <8 只直接不显示:拿 3 只算出来的「中位数」比不给还糟 6. 记录习惯 —— 数据不足时negative空手而归,给一句「再记几条就能看出规律」 每条都带 evidence(支撑它的具体数据)和 action(点击直接跳到对应记录入口), 结论不能只是断言。文案沿用 AI 助手同一套护栏:不做诊断,异常一律写清 什么时候必须就医。 前端落在记录页体重趋势卡上方,三档配色区分严重度。 实测(造 4 条体重下滑 + 换粮 + 软便 + 两次异常): 🔴 体重掉了 13%,建议就医检查(39 天内 4.50→3.90kg) 🔴 两周内记了 2 次异常 🟠 这次「软便」之前 2 天有过饮食变化(7月20日换粮 → 7月22日软便) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,3 +79,14 @@ func (h *Handler) WeightTrend(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, points)
|
||||
}
|
||||
|
||||
// PetInsights GET /api/pets/:id/insights
|
||||
// 把散落的记录变成结论:体重趋势、换粮与软便的关联、异常扎堆、逾期提醒、同龄对比。
|
||||
func (h *Handler) PetInsights(c *gin.Context) {
|
||||
list, err := h.svc.PetInsights(middleware.UserID(c), idParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, list)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
|
||||
g.GET("/pets/:id/records", h.ListRecords)
|
||||
g.POST("/pets/:id/records", h.CreateRecord)
|
||||
g.GET("/pets/:id/records/weight-trend", h.WeightTrend)
|
||||
g.GET("/pets/:id/insights", h.PetInsights)
|
||||
g.DELETE("/records/:id", h.DeleteRecord)
|
||||
|
||||
g.GET("/pets/:id/tasks", h.ListTasks)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// Insight 一条健康洞察。
|
||||
// 这是「记录」这件事的兑现方式:用户记了几十条数据,之前只换回一条折线,
|
||||
// 数据没有回到他身上。洞察要说出他自己看不出来的东西。
|
||||
type Insight struct {
|
||||
Kind string `json:"kind"` // trend | correlation | overdue | benchmark | habit
|
||||
Level string `json:"level"` // info | warn | alert
|
||||
Title string `json:"title"` // 一句话结论
|
||||
Detail string `json:"detail"` // 依据 + 建议
|
||||
Action string `json:"action"` // 关联的记录弹层类型,前端据此给按钮
|
||||
Evidence string `json:"evidence"` // 支撑这条结论的具体数据
|
||||
}
|
||||
|
||||
const (
|
||||
insightInfo = "info"
|
||||
insightWarn = "warn"
|
||||
insightAlert = "alert"
|
||||
)
|
||||
|
||||
// PetInsights 汇总一只宠物当前值得说的事。按严重度排序,最多 6 条。
|
||||
func (s *Service) PetInsights(userID, petID string) ([]Insight, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 只看最近 90 天。更早的数据对「现在怎么样」没有参考价值,
|
||||
// 还会让关联分析找出一堆巧合。
|
||||
since := time.Now().AddDate(0, 0, -90)
|
||||
var records []model.HealthRecord
|
||||
if err := s.db.Where("pet_id = ? AND occurred_at >= ?", petID, since).
|
||||
Order("occurred_at asc").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Insight
|
||||
out = appendIf(out, s.weightTrendInsight(records))
|
||||
out = appendIf(out, s.foodPoopCorrelation(records))
|
||||
out = appendIf(out, s.symptomClusterInsight(records))
|
||||
out = appendIf(out, s.overdueReminderInsight(petID))
|
||||
out = appendIf(out, s.weightBenchmark(pet, records))
|
||||
out = appendIf(out, s.recordHabitInsight(records))
|
||||
|
||||
// alert > warn > info,同级保持原顺序
|
||||
rank := map[string]int{insightAlert: 0, insightWarn: 1, insightInfo: 2}
|
||||
sort.SliceStable(out, func(i, j int) bool { return rank[out[i].Level] < rank[out[j].Level] })
|
||||
if len(out) > 6 {
|
||||
out = out[:6]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func appendIf(list []Insight, in *Insight) []Insight {
|
||||
if in != nil {
|
||||
list = append(list, *in)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func byType(records []model.HealthRecord, t string) []model.HealthRecord {
|
||||
var out []model.HealthRecord
|
||||
for _, r := range records {
|
||||
if r.Type == t {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// weightTrendInsight 体重趋势。单次数字没意义,连续同向变化才有。
|
||||
func (s *Service) weightTrendInsight(records []model.HealthRecord) *Insight {
|
||||
w := byType(records, model.RecordWeight)
|
||||
if len(w) < 3 {
|
||||
return nil
|
||||
}
|
||||
first, last := w[0], w[len(w)-1]
|
||||
if first.NumValue <= 0 || last.NumValue <= 0 {
|
||||
return nil
|
||||
}
|
||||
days := int(last.OccurredAt.Sub(first.OccurredAt).Hours() / 24)
|
||||
if days < 14 {
|
||||
return nil // 时间跨度太短,波动多半是称重误差
|
||||
}
|
||||
pct := (last.NumValue - first.NumValue) / first.NumValue * 100
|
||||
ev := fmt.Sprintf("%d 天内 %.2fkg → %.2fkg(%d 次记录)", days, first.NumValue, last.NumValue, len(w))
|
||||
|
||||
switch {
|
||||
case pct <= -10:
|
||||
return &Insight{
|
||||
Kind: "trend", Level: insightAlert, Action: "symptom",
|
||||
Title: fmt.Sprintf("体重掉了 %.0f%%,建议就医检查", -pct),
|
||||
Detail: "短期内明显掉秤,常见于甲亢、慢性肾病、糖尿病、寄生虫和口腔问题。即使精神食欲看起来正常,也建议做一次血检。",
|
||||
Evidence: ev,
|
||||
}
|
||||
case pct >= 15:
|
||||
return &Insight{
|
||||
Kind: "trend", Level: insightWarn, Action: "food",
|
||||
Title: fmt.Sprintf("体重涨了 %.0f%%,注意控制", pct),
|
||||
Detail: "超重是绝大多数慢性病的起点。摸得到肋骨但看不见明显轮廓才是标准体型,先从减少 10-20% 的喂食量开始。",
|
||||
Evidence: ev,
|
||||
}
|
||||
case pct <= -5:
|
||||
return &Insight{
|
||||
Kind: "trend", Level: insightWarn, Action: "weight",
|
||||
Title: fmt.Sprintf("体重在缓慢下降(%.0f%%)", -pct),
|
||||
Detail: "还不到紧急程度,但值得继续盯。保持每周称重,如果一个月内继续下降就去做次体检。",
|
||||
Evidence: ev,
|
||||
}
|
||||
}
|
||||
return &Insight{
|
||||
Kind: "trend", Level: insightInfo, Action: "weight",
|
||||
Title: "体重保持稳定",
|
||||
Detail: "这段时间的波动在正常范围内,继续保持现在的喂食量和运动量。",
|
||||
Evidence: ev,
|
||||
}
|
||||
}
|
||||
|
||||
// foodPoopCorrelation 换粮/饮食变化 → 排便异常的时间关联。
|
||||
// 这是用户自己最看不出来的一类:两条记录隔了两三天,翻时间轴翻不出规律。
|
||||
func (s *Service) foodPoopCorrelation(records []model.HealthRecord) *Insight {
|
||||
foods := byType(records, model.RecordFood)
|
||||
poops := byType(records, model.RecordPoop)
|
||||
if len(foods) == 0 || len(poops) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, p := range poops {
|
||||
if p.Category == "" || p.Category == "正常" {
|
||||
continue
|
||||
}
|
||||
// 往前找 3 天内的饮食记录
|
||||
for i := len(foods) - 1; i >= 0; i-- {
|
||||
f := foods[i]
|
||||
gap := p.OccurredAt.Sub(f.OccurredAt)
|
||||
if gap < 0 || gap > 72*time.Hour {
|
||||
continue
|
||||
}
|
||||
return &Insight{
|
||||
Kind: "correlation", Level: insightWarn, Action: "food",
|
||||
Title: fmt.Sprintf("这次「%s」之前 %d 天有过饮食变化", p.Category, int(gap.Hours()/24)),
|
||||
Detail: "换粮或加新食物引起的软便很常见。回到原来的粮,之后按 7 天过渡:每天替换约 1/7,软便就放慢速度。持续超过 3 天或带血要就医。",
|
||||
Evidence: fmt.Sprintf("%s 记录「%s」→ %s 出现「%s」",
|
||||
f.OccurredAt.Format("1月2日"), f.Title, p.OccurredAt.Format("1月2日"), p.Category),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// symptomClusterInsight 异常记录扎堆。单次异常不说明什么,短期内反复才是信号。
|
||||
func (s *Service) symptomClusterInsight(records []model.HealthRecord) *Insight {
|
||||
sym := byType(records, model.RecordSymptom)
|
||||
if len(sym) < 2 {
|
||||
return nil
|
||||
}
|
||||
recent := 0
|
||||
cut := time.Now().AddDate(0, 0, -14)
|
||||
var last time.Time
|
||||
for _, r := range sym {
|
||||
if r.OccurredAt.After(cut) {
|
||||
recent++
|
||||
last = r.OccurredAt
|
||||
}
|
||||
}
|
||||
if recent < 2 {
|
||||
return nil
|
||||
}
|
||||
return &Insight{
|
||||
Kind: "habit", Level: insightAlert, Action: "symptom",
|
||||
Title: fmt.Sprintf("两周内记了 %d 次异常", recent),
|
||||
Detail: "反复出现的异常和偶发一次完全不是一回事。把这些记录导出成就医摘要带去医院,比口头描述有用得多。",
|
||||
Evidence: fmt.Sprintf("最近一次:%s", last.Format("1月2日")),
|
||||
}
|
||||
}
|
||||
|
||||
// overdueReminderInsight 已经过期的提醒。设了不看等于没设。
|
||||
func (s *Service) overdueReminderInsight(petID string) *Insight {
|
||||
var rems []model.Reminder
|
||||
if err := s.db.Where("pet_id = ? AND next_due_date IS NOT NULL AND next_due_date < ?",
|
||||
petID, time.Now()).Order("next_due_date asc").Find(&rems).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(rems) == 0 {
|
||||
return nil
|
||||
}
|
||||
r := rems[0]
|
||||
days := int(time.Since(*r.NextDueDate).Hours() / 24)
|
||||
action := "vaccine"
|
||||
if r.Type == model.ReminderDeworm {
|
||||
action = "medicine"
|
||||
}
|
||||
return &Insight{
|
||||
Kind: "overdue", Level: insightAlert, Action: action,
|
||||
Title: fmt.Sprintf("「%s」已经过期 %d 天", r.Title, days),
|
||||
Detail: "疫苗和驱虫拖久了保护力会断档,尤其驱虫,遛弯和接触其它动物的风险是持续的。补上之后记得把下次日期也设好。",
|
||||
Evidence: fmt.Sprintf("原定 %s", r.NextDueDate.Format("2006年1月2日")),
|
||||
}
|
||||
}
|
||||
|
||||
// weightBenchmark 同龄同物种对比。
|
||||
// 这是单机数据做不到、只有平台侧能给的价值。样本不足就不给结论——
|
||||
// 拿 3 只猫算出来的「中位数」比不给还糟。
|
||||
func (s *Service) weightBenchmark(pet *model.Pet, records []model.HealthRecord) *Insight {
|
||||
w := byType(records, model.RecordWeight)
|
||||
if len(w) == 0 || pet.Birthday == nil || pet.Birthday.IsZero() {
|
||||
return nil
|
||||
}
|
||||
mine := w[len(w)-1].NumValue
|
||||
if mine <= 0 {
|
||||
return nil
|
||||
}
|
||||
months := monthsSince(*pet.Birthday)
|
||||
|
||||
// 同物种、月龄相差 2 个月以内的其它宠物,取各自最近一次体重
|
||||
lo := time.Now().AddDate(0, -(months + 2), 0)
|
||||
hi := time.Now().AddDate(0, -maxInt(months-2, 0), 0)
|
||||
var peers []float64
|
||||
rows, err := s.db.Raw(`
|
||||
SELECT r.num_value FROM sundynix_health_records r
|
||||
JOIN (
|
||||
SELECT pet_id, MAX(occurred_at) AS t FROM sundynix_health_records
|
||||
WHERE type = ? GROUP BY pet_id
|
||||
) latest ON latest.pet_id = r.pet_id AND latest.occurred_at = latest.t
|
||||
JOIN sundynix_pets p ON p.id = r.pet_id
|
||||
WHERE r.type = ? AND r.num_value > 0 AND p.id <> ?
|
||||
AND p.type = ? AND p.birthday BETWEEN ? AND ?`,
|
||||
model.RecordWeight, model.RecordWeight, pet.ID, pet.Type, lo, hi).Rows()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var v float64
|
||||
if rows.Scan(&v) == nil && v > 0 {
|
||||
peers = append(peers, v)
|
||||
}
|
||||
}
|
||||
// 样本太少给不出可信的参考值,宁可不显示
|
||||
if len(peers) < 8 {
|
||||
return nil
|
||||
}
|
||||
sort.Float64s(peers)
|
||||
med := peers[len(peers)/2]
|
||||
diff := (mine - med) / med * 100
|
||||
|
||||
level, title := insightInfo, "体重和同龄伙伴差不多"
|
||||
if diff >= 20 {
|
||||
level, title = insightWarn, fmt.Sprintf("比同龄伙伴重 %.0f%%", diff)
|
||||
} else if diff <= -20 {
|
||||
level, title = insightWarn, fmt.Sprintf("比同龄伙伴轻 %.0f%%", -diff)
|
||||
}
|
||||
return &Insight{
|
||||
Kind: "benchmark", Level: level, Action: "weight",
|
||||
Title: title,
|
||||
Detail: "这只是同月龄的横向参考,品种和体型差异很大,不能当成标准。真正要看的还是它自己的体重曲线是否平稳。",
|
||||
Evidence: fmt.Sprintf("%d 月龄 · 它 %.2fkg,%d 只同龄%s的中位数 %.2fkg",
|
||||
months, mine, len(peers), pet.Type, med),
|
||||
}
|
||||
}
|
||||
|
||||
// recordHabitInsight 记录习惯。没数据就没洞察,这条负责把用户拉回来记。
|
||||
func (s *Service) recordHabitInsight(records []model.HealthRecord) *Insight {
|
||||
if len(records) >= 5 {
|
||||
return nil
|
||||
}
|
||||
return &Insight{
|
||||
Kind: "habit", Level: insightInfo, Action: "weight",
|
||||
Title: "再记几条,这里就能看出规律了",
|
||||
Detail: "体重、便便、饮食三类各记几次之后,能自动帮你发现「换粮第几天开始软便」这种自己翻记录看不出来的关联。",
|
||||
Evidence: fmt.Sprintf("目前共 %d 条记录", len(records)),
|
||||
}
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user