feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package ask
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildReply_profileAware(t *testing.T) {
|
||||
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
|
||||
out := BuildReply(ReplyInput{
|
||||
DisplayName: "我",
|
||||
BirthDate: birth,
|
||||
Relation: "self",
|
||||
Scene: "self",
|
||||
UserMessage: "我想更了解自己",
|
||||
})
|
||||
if !strings.Contains(out, "我") {
|
||||
t.Fatalf("expected name in reply: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "算命") || strings.Contains(out, "运势") || strings.Contains(out, "吉凶") {
|
||||
t.Fatalf("forbidden lexicon in reply: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "不构成") {
|
||||
t.Fatalf("expected disclaimer: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReply_relationFocus(t *testing.T) {
|
||||
birth := time.Date(1992, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
out := BuildReply(ReplyInput{
|
||||
DisplayName: "TA",
|
||||
BirthDate: birth,
|
||||
Relation: "other",
|
||||
UserMessage: "和伴侣沟通总是卡住怎么办",
|
||||
})
|
||||
if !strings.Contains(out, "关系") && !strings.Contains(out, "相处") && !strings.Contains(out, "复述") {
|
||||
t.Fatalf("expected relation-oriented reply: %s", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user