每账号仅一条 self(migration 40902);合盘只选 TA;账号昵称可改;首页穿衣/颜色/养生贴士。 Co-authored-by: Cursor <cursoragent@cursor.com>
218 lines
6.5 KiB
Go
218 lines
6.5 KiB
Go
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/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"`
|
||
NeedBirth bool `json:"need_birth"`
|
||
}
|
||
|
||
type tipsCacheEntry struct {
|
||
tips DailyTips
|
||
exp time.Time
|
||
}
|
||
|
||
var tipsCache sync.Map // key string → tipsCacheEntry
|
||
|
||
// DailyTips returns personalized tips; falls back when LLM / profile unavailable.
|
||
func (s *Service) DailyTips(ctx context.Context, userID uuid.UUID) (*DailyTips, error) {
|
||
now := time.Now()
|
||
self, ok := s.findSelf(ctx, userID)
|
||
if !ok {
|
||
fb := fallbackTips(now, "", nil)
|
||
fb.NeedBirth = true
|
||
return fb, nil
|
||
}
|
||
|
||
birth := self.BirthDate.Format("2006-01-02")
|
||
yc := yijing.Seed(birth, self.BirthTime, now)
|
||
cacheKey := fmt.Sprintf("%s:%s:%s:%d", userID, birth, now.Format("2006-01-02"), now.Hour()/3)
|
||
if v, hit := tipsCache.Load(cacheKey); hit {
|
||
e := v.(tipsCacheEntry)
|
||
if time.Now().Before(e.exp) {
|
||
out := e.tips
|
||
return &out, nil
|
||
}
|
||
}
|
||
|
||
if s.LLM == nil || !s.LLM.Enabled() {
|
||
fb := fallbackTips(now, birth, self.BirthTime)
|
||
fb.GuaName = yc.GuaName
|
||
fb.DayPart = yc.DayPart
|
||
return fb, nil
|
||
}
|
||
|
||
tips, err := s.generateLLMTips(ctx, yc)
|
||
if err != nil {
|
||
log.Printf("home daily-tips llm: %v", err)
|
||
fb := fallbackTips(now, birth, self.BirthTime)
|
||
fb.GuaName = yc.GuaName
|
||
fb.DayPart = yc.DayPart
|
||
return fb, nil
|
||
}
|
||
tips.Source = "llm"
|
||
tips.GuaName = yc.GuaName
|
||
tips.DayPart = yc.DayPart
|
||
tips.NeedBirth = false
|
||
tipsCache.Store(cacheKey, tipsCacheEntry{tips: *tips, exp: now.Add(45 * time.Minute)})
|
||
return tips, nil
|
||
}
|
||
|
||
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) (*DailyTips, error) {
|
||
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 := yc.PromptLine() + "\n请生成今日穿衣指数、颜色搭配与养生推荐。"
|
||
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)
|
||
}
|
||
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"}},
|
||
}
|
||
clothing := []string{
|
||
"轻薄透气更舒服,外套可备一件薄衫应付温差。",
|
||
"选柔软面料贴身,活动一整天也不易紧绷。",
|
||
"上下同色系更省心,再加一件小配饰点睛即可。",
|
||
"宽松剪裁更自在,适合慢节奏出门与散步。",
|
||
"内里干净简约,外搭一件有质感的外套就够。",
|
||
}
|
||
notes := []string{"清爽干净,不抢戏。", "温柔耐看,适合日常。", "柔和提气色,不显沉。", "安静又有呼吸感。", "干净利落,好搭配。"}
|
||
well := []string{
|
||
"午后泡一杯温茶,给身心一点缓冲。",
|
||
"今晚早点放下屏幕,让眼睛歇一会儿。",
|
||
"走路时把肩膀放松,呼吸会顺很多。",
|
||
"饭后慢走十分钟,比猛坐着更舒服。",
|
||
"喝水提醒自己小口多次,别等口渴再灌。",
|
||
}
|
||
i := (yc.GuaIndex + yc.Line + now.Hour()) % 5
|
||
return &DailyTips{
|
||
ClothingIndex: 62 + (yc.GuaIndex % 31),
|
||
Clothing: clothing[i],
|
||
ColorNote: notes[i],
|
||
Palette: palettes[i],
|
||
Wellness: well[i],
|
||
Source: "fallback",
|
||
GuaName: yc.GuaName,
|
||
DayPart: yc.DayPart,
|
||
}
|
||
}
|