package home import ( "context" "encoding/json" "fmt" "log" "strings" "sync" "time" "unicode/utf8" "github.com/google/uuid" "github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek" "github.com/yuxingu/digital-psychology/apps/api/internal/model" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" "github.com/yuxingu/digital-psychology/apps/api/internal/yijing" ) // ColorChip is one palette swatch. type ColorChip struct { Name string `json:"name"` Hex string `json:"hex"` } // DailyTips is homepage self-card lifestyle tips. type DailyTips struct { ClothingIndex int `json:"clothing_index"` Clothing string `json:"clothing"` ColorNote string `json:"color_note"` Palette []ColorChip `json:"palette"` Wellness string `json:"wellness"` Source string `json:"source"` // llm | fallback GuaName string `json:"gua_name,omitempty"` DayPart string `json:"day_part,omitempty"` AsOf string `json:"as_of"` // YYYY-MM-DD Shichen int `json:"shichen"` ShichenName string `json:"shichen_name"` ValidUntil string `json:"valid_until"` // RFC3339 next 时辰 start NeedBirth bool `json:"need_birth"` } type tipsMemEntry struct { tips DailyTips } var ( tipsMem sync.Map // cacheKey → tipsMemEntry tipsInflight sync.Map // cacheKey → *sync.Mutex generating ) func tipsCacheKey(userID uuid.UUID, start time.Time) string { return userID.String() + ":" + start.UTC().Format(time.RFC3339) } // DailyTips returns tips for the current 时辰; prefers cache, never blocks on LLM. func (s *Service) DailyTips(ctx context.Context, userID uuid.UUID) (*DailyTips, error) { now := time.Now() loc, err := time.LoadLocation("Asia/Shanghai") if err != nil { loc = time.FixedZone("CST", 8*3600) } now = now.In(loc) win := CurrentShichen(now) asOf := now.Format("2006-01-02") self, ok := s.findSelf(ctx, userID) if !ok || userID == uuid.Nil { fb := decorateTips(fallbackTips(now, "", nil), win, asOf, true) return fb, nil } birth := self.BirthDate.Format("2006-01-02") key := tipsCacheKey(userID, win.Start) if v, hit := tipsMem.Load(key); hit { out := v.(tipsMemEntry).tips return decorateTips(&out, win, asOf, false), nil } if s.Tips != nil { if raw, source, err := s.Tips.GetTips(ctx, userID, win.Start); err == nil { var t DailyTips if json.Unmarshal(raw, &t) == nil { t.Source = source tipsMem.Store(key, tipsMemEntry{tips: t}) return decorateTips(&t, win, asOf, false), nil } } else if err != repository.ErrNoTips { log.Printf("home daily-tips db: %v", err) } } // Instant path: deterministic fallback, warm LLM in background. fb := fallbackTips(now, birth, self.BirthTime) out := decorateTips(fb, win, asOf, false) tipsMem.Store(key, tipsMemEntry{tips: *out}) s.persistTips(userID, win, out) s.warmLLMAsync(userID, birth, self.BirthTime, win, asOf, key) return out, nil } func decorateTips(t *DailyTips, win ShichenWindow, asOf string, needBirth bool) *DailyTips { cp := *t cp.AsOf = asOf cp.Shichen = win.Index cp.ShichenName = win.Name + "时" cp.ValidUntil = win.End.Format(time.RFC3339) cp.NeedBirth = needBirth return &cp } func (s *Service) persistTips(userID uuid.UUID, win ShichenWindow, tips *DailyTips) { if s.Tips == nil || tips == nil { return } raw, err := json.Marshal(tips) if err != nil { return } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := s.Tips.UpsertTips(ctx, userID, win.Start, win.Index, raw, tips.Source); err != nil { log.Printf("home daily-tips upsert: %v", err) } } func (s *Service) warmLLMAsync(userID uuid.UUID, birth string, birthTime *string, win ShichenWindow, asOf, key string) { if s.LLM == nil || !s.LLM.Enabled() { return } if _, loaded := tipsInflight.LoadOrStore(key, true); loaded { return } go func() { defer tipsInflight.Delete(key) ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() now := win.Start.Add(30 * time.Minute) // representative instant inside 时辰 if now.After(win.End) { now = win.Start } yc := yijing.Seed(birth, birthTime, now) tips, err := s.generateLLMTips(ctx, yc, now, win) if err != nil { log.Printf("home daily-tips llm warm: %v", err) return } tips.Source = "llm" tips.ClothingIndex = clothingIndexFromSeed(yc, now, win.Index) tips.GuaName = yc.GuaName tips.DayPart = yc.DayPart out := decorateTips(tips, win, asOf, false) tipsMem.Store(key, tipsMemEntry{tips: *out}) s.persistTips(userID, win, out) }() } func clothingIndexFromSeed(yc yijing.Context, now time.Time, shichen int) int { n := yc.GuaIndex*11 + yc.Line*5 + now.YearDay()*3 + shichen*17 return 55 + n%41 } func (s *Service) findSelf(ctx context.Context, userID uuid.UUID) (*model.Profile, bool) { if s.Profiles == nil || userID == uuid.Nil { return nil, false } items, err := s.Profiles.ListByUser(ctx, userID) if err != nil { return nil, false } for i := range items { if items[i].Relation == "self" { return &items[i], true } } return nil, false } func (s *Service) generateLLMTips(ctx context.Context, yc yijing.Context, now time.Time, win ShichenWindow) (*DailyTips, error) { weekday := []string{"日", "一", "二", "三", "四", "五", "六"}[now.Weekday()] sys := `你是愈心谷的生活节律助手。根据用户出生信息与当前时辰的易经卦象种子,给出温和的生活建议。 硬性要求: - 禁止占卜/算命/改命恐吓/吉凶祸福话术 - 禁止医疗疗效承诺 - 用探索向、身心节律语气;可参考卦象意象,不要说「必有」类断言 - 文案必须贴合「此刻时辰」与日期,体现与上一时辰不同的节律侧重 - 只输出 JSON,不要 markdown: {"clothing_index":70,"clothing":"一句穿衣建议","color_note":"一句颜色说明","palette":[{"name":"色名","hex":"#RRGGBB"},{"name":"色名","hex":"#RRGGBB"}],"wellness":"一句养生建议"} - clothing_index 为 55–95 整数;palette 恰好 2 项;文案各不超过 40 字` user := fmt.Sprintf( "%s\n今天是 %s(星期%s),当前%s时。请生成贴合本时辰的穿衣指数、颜色搭配与养生推荐,勿与通用套话雷同。", yc.PromptLine(), now.Format("2006-01-02"), weekday, win.Name, ) raw, err := s.LLM.Chat(ctx, []deepseek.Message{ {Role: "system", Content: sys}, {Role: "user", Content: user}, }) if err != nil { return nil, err } return parseTipsJSON(raw) } func parseTipsJSON(raw string) (*DailyTips, error) { raw = strings.TrimSpace(raw) if i := strings.Index(raw, "{"); i >= 0 { raw = raw[i:] } if j := strings.LastIndex(raw, "}"); j >= 0 { raw = raw[:j+1] } var t DailyTips if err := json.Unmarshal([]byte(raw), &t); err != nil { return nil, err } if t.ClothingIndex < 40 || t.ClothingIndex > 100 { t.ClothingIndex = 72 } t.Clothing = clampRunes(t.Clothing, 48) t.ColorNote = clampRunes(t.ColorNote, 40) t.Wellness = clampRunes(t.Wellness, 48) if len(t.Palette) != 2 { return nil, fmt.Errorf("palette len") } for i := range t.Palette { t.Palette[i].Name = clampRunes(t.Palette[i].Name, 8) if !looksHex(t.Palette[i].Hex) { t.Palette[i].Hex = "#E8DCC8" } } return &t, nil } func clampRunes(s string, max int) string { s = strings.TrimSpace(s) if utf8.RuneCountInString(s) <= max { return s } r := []rune(s) return string(r[:max]) } func looksHex(h string) bool { if len(h) != 7 || h[0] != '#' { return false } for i := 1; i < 7; i++ { c := h[i] if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } return true } func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips { yc := yijing.Seed(birth, birthTime, now) if birth == "" { yc = yijing.Seed(now.Format("2006-01-02"), nil, now) } win := CurrentShichen(now) palettes := [][]ColorChip{ {{Name: "米白", Hex: "#F5F0E8"}, {Name: "雾霾蓝", Hex: "#A8C4D8"}}, {{Name: "燕麦色", Hex: "#E8DCC8"}, {Name: "浅杏", Hex: "#F0C9A8"}}, {{Name: "浅灰", Hex: "#D8D6D4"}, {Name: "雾粉", Hex: "#E8B8C4"}}, {{Name: "象牙白", Hex: "#F7F4EC"}, {Name: "鼠尾草", Hex: "#A8C4B0"}}, {{Name: "浅卡其", Hex: "#DCC8A8"}, {Name: "浅蓝", Hex: "#B0D0E8"}}, {{Name: "天青", Hex: "#9BB8D4"}, {Name: "陶土", Hex: "#C4A484"}}, {{Name: "豆沙", Hex: "#C9A0A0"}, {Name: "雾绿", Hex: "#B5C9B8"}}, {{Name: "奶油", Hex: "#F3EADF"}, {Name: "烟灰", Hex: "#B8B4B0"}}, {{Name: "浅咖", Hex: "#C8B09A"}, {Name: "靛蓝", Hex: "#6B8CAE"}}, {{Name: "杏白", Hex: "#F6EBDD"}, {Name: "藕荷", Hex: "#D4B8C4"}}, {{Name: "竹青", Hex: "#A8BFA8"}, {Name: "沙色", Hex: "#E0D0B8"}}, {{Name: "月白", Hex: "#EEF2F5"}, {Name: "焦糖", Hex: "#C4A070"}}, } clothing := []string{ "轻薄透气更舒服,外套可备一件薄衫应付温差。", "选柔软面料贴身,活动一整天也不易紧绷。", "上下同色系更省心,再加一件小配饰点睛即可。", "宽松剪裁更自在,适合慢节奏出门与散步。", "内里干净简约,外搭一件有质感的外套就够。", "今日宜层搭:薄内搭+透气外衫,方便随时增减。", "选垂感面料更显松弛,走路也会轻一点。", "少用厚重配饰,让肩颈更轻松。", "浅色上衣更提气色,下装选舒适脚感即可。", "风大时加一件防风薄外套,比厚羽绒服更灵活。", "本时辰适合干净线条,少印花更省心。", "运动鞋或软底鞋更友好,站久也不累。", } notes := []string{ "清爽干净,不抢戏。", "温柔耐看,适合日常。", "柔和提气色,不显沉。", "安静又有呼吸感。", "干净利落,好搭配。", "此刻偏清透,层次更轻。", "暖调一点点,气色更稳。", "冷暖各一,对比柔和。", "低饱和更耐看。", "偏自然色,贴近户外光线。", "雾感配色,不刺眼。", "柔对比,适合慢节奏。", } well := []string{ "午后泡一杯温茶,给身心一点缓冲。", "今晚早点放下屏幕,让眼睛歇一会儿。", "走路时把肩膀放松,呼吸会顺很多。", "饭后慢走十分钟,比猛坐着更舒服。", "喝水提醒自己小口多次,别等口渴再灌。", "此刻做三次深呼吸,拉长呼气更易放松。", "站起来伸个懒腰,活动一下髋与肩。", "晚饭少一点刺激,给肠胃留空档。", "睡前把明天三件小事写下来,心会静些。", "听一首慢歌,跟着节奏把呼吸放慢。", "把窗帘开一条缝,让自然光进一点。", "洗手洗脸用温水,给皮肤一点温柔。", } n := len(palettes) i := (yc.GuaIndex*13 + yc.Line*7 + now.YearDay()*3 + win.Index*19) % n if i < 0 { i = -i } return &DailyTips{ ClothingIndex: clothingIndexFromSeed(yc, now, win.Index), Clothing: clothing[i], ColorNote: notes[i], Palette: palettes[i], Wellness: well[i], Source: "fallback", GuaName: yc.GuaName, DayPart: yc.DayPart, AsOf: now.Format("2006-01-02"), } }