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:
jackyu66git
2026-08-03 11:37:53 +08:00
co-authored by Cursor
parent 15a9db374a
commit bd22d9dddd
248 changed files with 26309 additions and 842 deletions
+245
View File
@@ -0,0 +1,245 @@
package ask
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/google/uuid"
eng "github.com/yuxingu/digital-psychology/apps/api/internal/ask"
"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"
)
// FreeQuota is the number of assistant replies allowed without membership.
const FreeQuota = 3
const historyLimit = 10
// Service handles ask threads, quota, and replies.
type Service struct {
Profiles *repository.ProfileRepo
Reports *repository.ReportRepo
Ask *repository.AskRepo
LLM *deepseek.Client // optional; nil or disabled → rule engine
}
// CreateThreadInput for POST /ask/threads.
type CreateThreadInput struct {
ProfileID uuid.UUID
Scene string
}
// CreateThread binds a conversation to an owned profile.
func (s *Service) CreateThread(ctx context.Context, userID uuid.UUID, in CreateThreadInput) (*model.AskThread, error) {
if _, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID); err != nil {
return nil, errors.New("profile not found")
}
var scene *string
if sc := strings.TrimSpace(in.Scene); sc != "" {
scene = &sc
}
return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene)
}
// QuotaStatus describes remaining ask allowance.
type QuotaStatus struct {
ActiveMembership bool `json:"active_membership"`
Remaining int `json:"remaining"`
FreeLimit int `json:"free_limit"`
Source string `json:"source"` // membership | free
}
// GetQuota returns remaining ask replies.
func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus, error) {
vip, err := s.Reports.HasActiveMembership(ctx, userID)
if err != nil {
return nil, err
}
if vip {
me, err := s.Reports.GetMembership(ctx, userID)
if err != nil {
return nil, err
}
return &QuotaStatus{
ActiveMembership: true,
Remaining: me.AskQuotaLeft,
FreeLimit: FreeQuota,
Source: "membership",
}, nil
}
used, err := s.Ask.CountUserAssistantMessages(ctx, userID)
if err != nil {
return nil, err
}
left := FreeQuota - used
if left < 0 {
left = 0
}
return &QuotaStatus{
ActiveMembership: false,
Remaining: left,
FreeLimit: FreeQuota,
Source: "free",
}, nil
}
// ListMessages returns history for an owned thread.
func (s *Service) ListMessages(ctx context.Context, userID, threadID uuid.UUID) ([]model.AskMessage, error) {
if _, err := s.Ask.GetThreadForUser(ctx, userID, threadID); err != nil {
return nil, errors.New("thread not found")
}
return s.Ask.ListMessages(ctx, threadID)
}
// SendResult is the user+assistant turn after a send.
type SendResult struct {
UserMessage *model.AskMessage `json:"user_message"`
AssistantMessage *model.AskMessage `json:"assistant_message"`
Quota *QuotaStatus `json:"quota"`
}
// SendMessage stores user content, consumes quota, generates assistant reply.
func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, content string) (*SendResult, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("content required")
}
if len([]rune(content)) > 2000 {
return nil, errors.New("content too long")
}
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
if err != nil {
return nil, errors.New("thread not found")
}
profile, err := s.Profiles.GetForUser(ctx, userID, thread.ProfileID)
if err != nil {
return nil, errors.New("profile not found")
}
quota, err := s.GetQuota(ctx, userID)
if err != nil {
return nil, err
}
if quota.Remaining <= 0 {
return nil, ErrQuotaExhausted
}
userMsg, err := s.Ask.InsertMessage(ctx, threadID, "user", content)
if err != nil {
return nil, err
}
scene := ""
if thread.Scene != nil {
scene = *thread.Scene
}
hist, _ := s.Ask.ListMessages(ctx, threadID)
reply := s.generateReply(ctx, profile, scene, content, hist)
asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply)
if err != nil {
return nil, err
}
if quota.ActiveMembership {
if ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID); err != nil {
return nil, err
} else if !ok {
// race
}
}
q2, err := s.GetQuota(ctx, userID)
if err != nil {
return nil, err
}
return &SendResult{UserMessage: userMsg, AssistantMessage: asst, Quota: q2}, nil
}
func (s *Service) generateReply(ctx context.Context, profile *model.Profile, scene, userContent string, hist []model.AskMessage) string {
fallback := eng.BuildReply(eng.ReplyInput{
DisplayName: profile.DisplayName,
BirthDate: profile.BirthDate,
Relation: profile.Relation,
Scene: scene,
UserMessage: userContent,
})
if s.LLM == nil || !s.LLM.Enabled() {
return fallback
}
msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene)}}
// history excluding the just-inserted user message duplicate handling: include prior + current user
start := 0
if len(hist) > historyLimit*2 {
start = len(hist) - historyLimit*2
}
for _, m := range hist[start:] {
role := m.Role
if role != "user" && role != "assistant" {
continue
}
msgs = append(msgs, deepseek.Message{Role: role, Content: m.Content})
}
// hist already contains the new user message from InsertMessage
out, err := s.LLM.Chat(ctx, msgs)
if err != nil {
log.Printf("ask: deepseek failed, fallback to rules: %v", err)
return fallback
}
if !strings.Contains(out, "不构成") && !strings.Contains(out, "参考") {
out = out + "\n\n以上是自我探索与生活方式参考,不构成医疗或占卜预测。"
}
return out
}
func systemPrompt(profile *model.Profile, scene string) string {
name := profile.DisplayName
if name == "" {
if profile.Relation == "other" {
name = "TA"
} else {
name = "你"
}
}
birth := profile.BirthDate.Format("2006-01-02")
rel := "我的档案"
if profile.Relation == "other" {
rel = "TA 的档案"
}
sc := scene
if sc == "" {
sc = "自我探索"
}
return fmt.Sprintf(`你是「愈心谷」的 AI 成长助手,了解用户的智能伙伴。
定位:帮助认识自己、理解关系、整理情绪与生活节奏——像一位细致的成长顾问,而不是算命师。
禁止:算命、运势、吉凶、预测未来、改命、合盘/合婚话术、医疗诊断或疗效承诺、恐吓话术。
推荐用语:了解、探索、分析、建议、成长方向、生活建议、沟通方式、情绪调节。
当前解读对象:%s(%s),生日 %s,场景倾向:%s。
请用中文做「有结构的详细回复」(约 280–450 字),建议结构:
1)先回应用户当下问题(23 句)
2)结合档案风格做一层分析(性格/沟通/关系/情绪/生活节奏中相关的 1–2 维)
3)给出 2–4 条可执行小建议(尽量具体到本周可做)
4)如合适,给一句可直接说出口的对话示例
语气温暖、具体、不空洞;避免鸡汤套话与玄学预测。
结尾提醒:内容为自我探索与生活方式参考,不构成医疗或占卜预测。
今天是 %s。`, name, rel, birth, sc, time.Now().Format("2006-01-02"))
}
// ErrQuotaExhausted when free or membership ask quota is 0.
var ErrQuotaExhausted = errors.New("ask quota exhausted")
// IsQuotaExhausted reports ErrQuotaExhausted.
func IsQuotaExhausted(err error) bool {
return errors.Is(err, ErrQuotaExhausted)
}