Files
digital-psychology/apps/api/internal/service/ask/service.go
T
jackyu66gitandCursor bd22d9dddd feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具
落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:37:53 +08:00

246 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}