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,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)先回应用户当下问题(2–3 句)
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user