Files
digital-psychology/apps/api/internal/ask/reply.go
T
jackyu66gitandCursor 7ab9add5dd
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s
feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 02:26:16 +08:00

231 lines
5.6 KiB
Go

// 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),
)
}
if sceneHint != "" {
return sceneHint + "\n\n" + strings.TrimSpace(body)
}
return strings.TrimSpace(body)
}
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)
}