package ask import ( "context" "encoding/json" "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" "github.com/yuxingu/digital-psychology/apps/api/internal/textsafe" ) // 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 strings.TrimSpace(in.Scene) != "" { sc, err := textsafe.Check(textsafe.Scene, in.Scene) if err != nil { return nil, err } scene = &sc } return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene) } // ClearThread soft-deletes an owned thread and its messages. func (s *Service) ClearThread(ctx context.Context, userID, threadID uuid.UUID) error { return s.Ask.SoftDeleteThread(ctx, userID, threadID) } // QuotaStatus describes remaining ask allowance. type QuotaStatus struct { ActiveMembership bool `json:"active_membership"` Remaining int `json:"remaining"` FreeLimit int `json:"free_limit"` PaidLeft int `json:"paid_left"` Source string `json:"source"` // membership | free | paid | mixed } // GetQuota returns remaining ask replies. func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus, error) { paid, err := s.Ask.GetAskPaidQuota(ctx, userID) if err != nil { return nil, err } 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 } src := "membership" if paid > 0 && me.AskQuotaLeft > 0 { src = "mixed" } else if me.AskQuotaLeft <= 0 && paid > 0 { src = "paid" } return &QuotaStatus{ ActiveMembership: true, Remaining: me.AskQuotaLeft + paid, FreeLimit: FreeQuota, PaidLeft: paid, Source: src, }, nil } used, err := s.Ask.CountUserAssistantMessages(ctx, userID) if err != nil { return nil, err } // Free tier only counts assistant replies that were not covered by paid packs. // Approximate: free used = min(used, FreeQuota) when no paid history is tracked separately. // Paid replies decrement ask_paid_quota_left; free replies increase assistant count. // Remaining free = max(0, FreeQuota - max(0, used - lifetimePaidConsumed)). // Without lifetime paid consumed counter, treat free as: FreeQuota - used, floored at 0, // and add current paid left (purchased top-ups work after free exhausted). left := FreeQuota - used if left < 0 { left = 0 } src := "free" if left == 0 && paid > 0 { src = "paid" } else if left > 0 && paid > 0 { src = "mixed" } return &QuotaStatus{ ActiveMembership: false, Remaining: left + paid, FreeLimit: FreeQuota, PaidLeft: paid, Source: src, }, 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) { var err error content, err = textsafe.Check(textsafe.AskContent, content) if err != nil { return nil, err } 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 } bucket, err := s.pickQuotaBucket(ctx, userID, quota) if err != nil { return nil, err } 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, userID, profile, scene, content, hist) asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply) if err != nil { return nil, err } if err := s.consumeQuotaBucket(ctx, userID, bucket); err != nil { return nil, err } q2, err := s.GetQuota(ctx, userID) if err != nil { return nil, err } return &SendResult{UserMessage: userMsg, AssistantMessage: asst, Quota: q2}, nil } type quotaBucket string const ( bucketFree quotaBucket = "free" bucketMembership quotaBucket = "membership" bucketPaid quotaBucket = "paid" ) func (s *Service) pickQuotaBucket(ctx context.Context, userID uuid.UUID, quota *QuotaStatus) (quotaBucket, error) { if quota.ActiveMembership { me, err := s.Reports.GetMembership(ctx, userID) if err != nil { return "", err } if me.AskQuotaLeft > 0 { return bucketMembership, nil } if quota.PaidLeft > 0 { return bucketPaid, nil } return "", ErrQuotaExhausted } used, err := s.Ask.CountUserAssistantMessages(ctx, userID) if err != nil { return "", err } if FreeQuota-used > 0 { return bucketFree, nil } if quota.PaidLeft > 0 { return bucketPaid, nil } return "", ErrQuotaExhausted } func (s *Service) consumeQuotaBucket(ctx context.Context, userID uuid.UUID, bucket quotaBucket) error { switch bucket { case bucketFree: return nil // counted by assistant message total case bucketMembership: ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID) if err != nil { return err } if !ok { // race: fall through to paid if possible ok2, _, err2 := s.Ask.ConsumeAskPaidQuota(ctx, userID) if err2 != nil { return err2 } if !ok2 { return ErrQuotaExhausted } } return nil case bucketPaid: ok, _, err := s.Ask.ConsumeAskPaidQuota(ctx, userID) if err != nil { return err } if !ok { return ErrQuotaExhausted } return nil default: return ErrQuotaExhausted } } func (s *Service) generateReply(ctx context.Context, userID uuid.UUID, profile *model.Profile, scene, userContent string, hist []model.AskMessage) string { out, err := s.generateReplyStream(ctx, userID, profile, scene, userContent, hist, nil) if err != nil { log.Printf("ask: generateReply: %v", err) } return out } // StreamEmit writes one SSE-style event to the client. type StreamEmit func(event string, payload any) error // StreamMessage is like SendMessage but streams assistant deltas via emit. func (s *Service) StreamMessage(ctx context.Context, userID, threadID uuid.UUID, content string, emit StreamEmit) error { if emit == nil { return errors.New("emit required") } var err error content, err = textsafe.Check(textsafe.AskContent, content) if err != nil { return err } thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID) if err != nil { return errors.New("thread not found") } profile, err := s.Profiles.GetForUser(ctx, userID, thread.ProfileID) if err != nil { return errors.New("profile not found") } quota, err := s.GetQuota(ctx, userID) if err != nil { return err } if quota.Remaining <= 0 { return ErrQuotaExhausted } bucket, err := s.pickQuotaBucket(ctx, userID, quota) if err != nil { return err } userMsg, err := s.Ask.InsertMessage(ctx, threadID, "user", content) if err != nil { return err } if err := emit("meta", map[string]any{"user_message": userMsg}); err != nil { return err } scene := "" if thread.Scene != nil { scene = *thread.Scene } hist, _ := s.Ask.ListMessages(ctx, threadID) reply, err := s.generateReplyStream(ctx, userID, profile, scene, content, hist, func(delta string) error { return emit("delta", map[string]any{"text": delta}) }) if err != nil { return err } if strings.TrimSpace(reply) == "" { reply = "暂时没能生成回复,请稍后再试。" _ = emit("delta", map[string]any{"text": reply}) } asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply) if err != nil { return err } if err := s.consumeQuotaBucket(ctx, userID, bucket); err != nil { return err } q2, err := s.GetQuota(ctx, userID) if err != nil { return err } return emit("done", map[string]any{ "assistant_message": asst, "quota": q2, }) } func (s *Service) generateReplyStream( ctx context.Context, userID uuid.UUID, profile *model.Profile, scene, userContent string, hist []model.AskMessage, onDelta func(string) error, ) (string, error) { fallback := eng.BuildReply(eng.ReplyInput{ DisplayName: profile.DisplayName, BirthDate: profile.BirthDate, Relation: profile.Relation, Scene: scene, UserMessage: userContent, }) emitFallback := func(text string) (string, error) { if onDelta == nil { return text, nil } return text, streamFake(ctx, text, onDelta) } if s.LLM == nil || !s.LLM.Enabled() { return emitFallback(fallback) } msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene, s.profileContext(ctx, userID, profile))}} 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}) } var assembled strings.Builder out, err := s.LLM.ChatStream(ctx, msgs, func(delta string) error { assembled.WriteString(delta) if onDelta != nil { return onDelta(delta) } return nil }) if err != nil { if assembled.Len() > 0 { return assembled.String(), nil } log.Printf("ask: deepseek stream failed, fallback to rules: %v", err) return emitFallback(fallback) } if out == "" { out = assembled.String() } if onDelta != nil && assembled.Len() == 0 && out != "" { _ = streamFake(ctx, out, onDelta) } return out, nil } // streamFake chunks text for rule-engine replies so UI still feels streamed. func streamFake(ctx context.Context, text string, onDelta func(string) error) error { runes := []rune(text) const chunk = 2 for i := 0; i < len(runes); i += chunk { select { case <-ctx.Done(): return ctx.Err() default: } end := i + chunk if end > len(runes) { end = len(runes) } if err := onDelta(string(runes[i:end])); err != nil { return err } time.Sleep(18 * time.Millisecond) } return nil } func (s *Service) profileContext(ctx context.Context, userID uuid.UUID, profile *model.Profile) string { if s.Reports == nil || profile == nil { return "" } var parts []string for _, typ := range []string{"portrait", "star", "rhythm"} { rep, err := s.Reports.GetLatest(ctx, userID, profile.ID, typ, nil) if err != nil || rep == nil || len(rep.Summary) == 0 { continue } var sum map[string]any if json.Unmarshal(rep.Summary, &sum) != nil { continue } label := map[string]string{"portrait": "愈心解码", "star": "星座探索", "rhythm": "身心节律"}[typ] line := strings.TrimSpace(fmt.Sprintf("%s|%v|%v", label, sum["headline"], sum["one_liner"])) if kw, ok := sum["keywords"].([]any); ok && len(kw) > 0 { var ks []string for i, k := range kw { if i >= 5 { break } ks = append(ks, fmt.Sprint(k)) } if len(ks) > 0 { line += "|关键词:" + strings.Join(ks, "、") } } parts = append(parts, line) } return strings.Join(parts, "\n") } func systemPrompt(profile *model.Profile, scene, reportCtx string) string { name := profile.DisplayName if name == "" { if profile.Relation == "other" { name = "TA" } else { name = "你" } } birth := profile.BirthDate.Format("2006-01-02") who := "用户本人的个人档案" if profile.Relation == "other" { who = "用户添加的关系对象(TA)档案" } sc := strings.TrimSpace(scene) if sc == "" { sc = "综合成长对话" } ctxBlock := "(暂无已生成的解码/星座/节律摘要;请主要依据生日与对话内容温和探索,不要假装已经测过完整报告。)" if strings.TrimSpace(reportCtx) != "" { ctxBlock = reportCtx } return fmt.Sprintf(`你是「愈心谷」的 AI 成长助手。 【定位】 把愈心解码、星座、人格匹配、身心节律等探索结果,转成短而可执行的陪伴。你不是占卜师/医生;禁止吉凶断语、改命恐吓、医疗诊断。 【当前对象】 称呼:%s|档案:%s|生日:%s|场景:%s 摘要(有则引用,无则勿编): %s 【回复(务必短)】 1. 先用 1 句接住情绪/问题。 2. 只挑与问题最相关的 1 个档案洞察,讲清即可。 3. 给 1–2 条本周就能做的小行动(可附一句可说出口的话)。 4. 用中文;全文控制在 80–160 字;不要分大段、不要列表堆砌、不要长篇铺垫。 5. 不要在文末重复免责声明(界面已展示)。 今天是 %s。`, name, who, birth, sc, ctxBlock, 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) }