// Package ask builds deterministic growth-assistant replies (rule fallback). // Copy follows .ai/product/lexicon.md — companion for self-understanding, not fortune-telling. package ask import ( "fmt" "strings" "time" "github.com/yuxingu/digital-psychology/apps/api/internal/portrait" ) // ReplyInput is context for a single assistant reply. type ReplyInput struct { DisplayName string BirthDate time.Time Relation string // self | other Scene string UserMessage string } // BuildReply returns a profile-aware assistant message with actionable detail. func BuildReply(in ReplyInput) string { name := in.DisplayName if name == "" { if in.Relation == "other" { name = "TA" } else { name = "你" } } out := portrait.Build(in.BirthDate, name) style := str(out.Summary["style_label"]) if style == "" { style = str(out.Summary["headline"]) } one := str(out.Summary["one_liner"]) tip := str(out.Summary["life_tip"]) overview := str(out.Summary["overview"]) sceneHint := sceneLine(in.Scene, in.UserMessage) focus := detectFocus(in.UserMessage) sectionBody := sectionByFocus(out.Detail, focus) scripts := strSlice(out.Detail["conversation_scripts"]) scriptLine := "" if len(scripts) > 0 { scriptLine = "可以试着说:「" + scripts[0] + "」" } plan := firstPlan(out.Detail["growth_plan"]) var body string switch focus { case "relation": body = fmt.Sprintf( "结合「%s」偏「%s」的档案:%s\n\n关系侧重点:%s\n\n小建议:%s\n%s\n本周可做:%s", name, style, one, pick(sectionBody, "在关系里先复述对方感受,再表达需要。"), tip, scriptLine, pick(plan, tip), ) case "career": body = fmt.Sprintf( "从「%s / %s」看职业节奏:%s\n\n%s\n\n可执行:%s\n本周:%s", name, style, one, pick(sectionBody, overview), tip, pick(plan, "选一件能发挥你节奏优势的小事推进。"), ) case "emotion": body = fmt.Sprintf( "我听到你在整理情绪。对照「%s」:%s\n\n%s\n\n调节建议:%s\n%s\n本周:%s", style, one, pick(sectionBody, "先命名感受,再决定行动。"), tip, scriptLine, pick(plan, "给自己 10 分钟不评判地写下此刻感受。"), ) case "life": body = fmt.Sprintf( "关于生活节奏(%s):%s\n\n%s\n\n今日建议:%s\n本周:%s", style, one, pick(sectionBody, overview), tip, pick(plan, tip), ) default: body = fmt.Sprintf( "我是愈心谷的成长助手,会结合档案陪你一起看。\n\n「%s」更偏「%s」:%s\n\n%s\n\n生活建议:%s\n%s\n本周可做:%s", name, style, one, trimRunes(overview, 120), tip, scriptLine, pick(plan, tip), ) } disclaimer := "以上是自我探索与生活方式参考,不构成医疗或占卜预测。" if sceneHint != "" { return sceneHint + "\n\n" + strings.TrimSpace(body) + "\n\n" + disclaimer } return strings.TrimSpace(body) + "\n\n" + disclaimer } func sectionByFocus(detail map[string]any, focus string) string { titleHint := map[string]string{ "relation": "关系", "career": "事业", "emotion": "情绪", "life": "生活", "self": "性格", }[focus] secs, ok := detail["sections"].([]map[string]any) if !ok { // also []any after some encodings if raw, ok2 := detail["sections"].([]any); ok2 { for _, item := range raw { m, _ := item.(map[string]any) if strings.Contains(str(m["title"]), titleHint) { return str(m["body"]) } } } switch focus { case "relation": return str(detail["relation_style"]) case "career", "self": return str(detail["behavior_pattern"]) default: return str(detail["growth_direction"]) } } for _, s := range secs { if strings.Contains(str(s["title"]), titleHint) { return str(s["body"]) } } if len(secs) > 0 { return str(secs[0]["body"]) } return "" } func firstPlan(v any) string { arr, ok := v.([]map[string]any) if ok && len(arr) > 0 { return str(arr[0]["focus"]) } raw, ok := v.([]any) if ok && len(raw) > 0 { if m, ok := raw[0].(map[string]any); ok { return str(m["focus"]) } } return "" } func pick(a, b string) string { if strings.TrimSpace(a) != "" { return a } return b } func trimRunes(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" } func strSlice(v any) []string { if arr, ok := v.([]string); ok { return arr } raw, ok := v.([]any) if !ok { return nil } out := make([]string, 0, len(raw)) for _, x := range raw { if s, ok := x.(string); ok { out = append(out, s) } } return out } func sceneLine(scene, msg string) string { s := strings.TrimSpace(scene) if s == "" { s = detectScene(msg) } switch s { case "self": return "场景:认识自己" case "relation": return "场景:理解关系" case "career": return "场景:职业探索" case "emotion": return "场景:情绪整理" case "life": return "场景:生活建议" default: return "" } } func detectScene(msg string) string { return detectFocus(msg) } func detectFocus(msg string) string { m := strings.ToLower(msg) switch { case containsAny(m, "关系", "相处", "伴侣", "朋友", "沟通", "TA", "他", "她"): return "relation" case containsAny(m, "工作", "职业", "面试", "事业", "选择"): return "career" case containsAny(m, "情绪", "焦虑", "难过", "压力", "心情", "害怕"): return "emotion" case containsAny(m, "作息", "睡眠", "习惯", "生活", "饮食", "运动"): return "life" default: return "self" } } func containsAny(s string, words ...string) bool { for _, w := range words { if strings.Contains(s, w) { return true } } return false } func str(v any) string { if v == nil { return "" } if s, ok := v.(string); ok { return s } return fmt.Sprint(v) }