落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
246 lines
7.3 KiB
Go
246 lines
7.3 KiB
Go
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)
|
||
}
|