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) }