feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package ask
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -47,16 +48,26 @@ func (s *Service) CreateThread(ctx context.Context, userID uuid.UUID, in CreateT
|
||||
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"`
|
||||
Source string `json:"source"` // membership | free
|
||||
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
|
||||
@@ -66,26 +77,46 @@ func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus,
|
||||
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,
|
||||
Remaining: me.AskQuotaLeft + paid,
|
||||
FreeLimit: FreeQuota,
|
||||
Source: "membership",
|
||||
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,
|
||||
Remaining: left + paid,
|
||||
FreeLimit: FreeQuota,
|
||||
Source: "free",
|
||||
PaidLeft: paid,
|
||||
Source: src,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -131,6 +162,11 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
|
||||
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
|
||||
@@ -142,19 +178,15 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
|
||||
}
|
||||
|
||||
hist, _ := s.Ask.ListMessages(ctx, threadID)
|
||||
reply := s.generateReply(ctx, profile, scene, content, hist)
|
||||
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 quota.ActiveMembership {
|
||||
if ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
// race
|
||||
}
|
||||
if err := s.consumeQuotaBucket(ctx, userID, bucket); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q2, err := s.GetQuota(ctx, userID)
|
||||
@@ -164,7 +196,171 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return errors.New("content required")
|
||||
}
|
||||
if len([]rune(content)) > 2000 {
|
||||
return errors.New("content too long")
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -172,12 +368,18 @@ func (s *Service) generateReply(ctx context.Context, profile *model.Profile, sce
|
||||
Scene: scene,
|
||||
UserMessage: userContent,
|
||||
})
|
||||
if s.LLM == nil || !s.LLM.Enabled() {
|
||||
return fallback
|
||||
emitFallback := func(text string) (string, error) {
|
||||
if onDelta == nil {
|
||||
return text, nil
|
||||
}
|
||||
return text, streamFake(ctx, text, onDelta)
|
||||
}
|
||||
|
||||
msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene)}}
|
||||
// history excluding the just-inserted user message duplicate handling: include prior + current user
|
||||
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
|
||||
@@ -189,20 +391,88 @@ func (s *Service) generateReply(ctx context.Context, profile *model.Profile, sce
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
log.Printf("ask: deepseek failed, fallback to rules: %v", err)
|
||||
return fallback
|
||||
if assembled.Len() > 0 {
|
||||
return assembled.String(), nil
|
||||
}
|
||||
log.Printf("ask: deepseek stream failed, fallback to rules: %v", err)
|
||||
return emitFallback(fallback)
|
||||
}
|
||||
if !strings.Contains(out, "不构成") && !strings.Contains(out, "参考") {
|
||||
out = out + "\n\n以上是自我探索与生活方式参考,不构成医疗或占卜预测。"
|
||||
if out == "" {
|
||||
out = assembled.String()
|
||||
}
|
||||
return out
|
||||
if onDelta != nil && assembled.Len() == 0 && out != "" {
|
||||
_ = streamFake(ctx, out, onDelta)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func systemPrompt(profile *model.Profile, scene string) string {
|
||||
// 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" {
|
||||
@@ -212,28 +482,37 @@ func systemPrompt(profile *model.Profile, scene string) string {
|
||||
}
|
||||
}
|
||||
birth := profile.BirthDate.Format("2006-01-02")
|
||||
rel := "我的档案"
|
||||
who := "用户本人的个人档案"
|
||||
if profile.Relation == "other" {
|
||||
rel = "TA 的档案"
|
||||
who = "用户添加的关系对象(TA)档案"
|
||||
}
|
||||
sc := scene
|
||||
sc := strings.TrimSpace(scene)
|
||||
if sc == "" {
|
||||
sc = "自我探索"
|
||||
sc = "综合成长对话"
|
||||
}
|
||||
ctxBlock := "(暂无已生成的解码/星座/节律摘要;请主要依据生日与对话内容温和探索,不要假装已经测过完整报告。)"
|
||||
if strings.TrimSpace(reportCtx) != "" {
|
||||
ctxBlock = reportCtx
|
||||
}
|
||||
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"))
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user