Skip to content
Merged

Dev #147

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func main() {
firecrawlClient = firecrawl.NewClient(cfg.FirecrawlAPIKey)
}

chatService := service.NewChatService(chatClient, chatRepo, firecrawlClient)
chatService := service.NewChatService(chatClient, chatRepo, userRepo, firecrawlClient)
echoService := service.NewEchoService(chatClient, echoRepo, userRepo)
photoService := service.NewPhotoService(photoRepo, userRepo, chatClient, cfg.ImageModel)
whisperService := service.NewWhisperService(whisperRepo, userRepo, chatClient)
Expand All @@ -87,7 +87,7 @@ func main() {
communityAIService = service.NewCommunityAIService(
chatClient, firecrawlClient,
questionRepo, answerRepo, commentRepo,
aiUserID,
userRepo, aiUserID,
)
}

Expand Down
5 changes: 5 additions & 0 deletions backend/internal/dto/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ type CommentCreate struct {
ParentID *string `json:"parent_id"`
}

// CommentUpdate is the request body for updating a comment
type CommentUpdate struct {
Content string `json:"content" binding:"required,min=1,max=10000"`
}

// CommentListItem is a single comment (with nested replies)
type CommentListItem struct {
ID string `json:"id"`
Expand Down
32 changes: 32 additions & 0 deletions backend/internal/handler/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,38 @@ func (h *CommentHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, comment)
}

// PUT /api/v1/community/comments/:id
func (h *CommentHandler) Update(c *gin.Context) {
commentID := c.Param("id")

var req dto.CommentUpdate
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

userID := middleware.GetUserID(c)
user, err := h.authService.GetUserByID(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
return
}

comment, err := h.communityService.UpdateComment(commentID, req, user)
if err != nil {
status := http.StatusBadRequest
if err.Error() == "评论不存在" {
status = http.StatusNotFound
} else if err.Error() == "无权修改此评论" {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, gin.H{"id": comment.ID, "content": comment.Content})
}

// DELETE /api/v1/community/comments/:id
func (h *CommentHandler) Delete(c *gin.Context) {
commentID := c.Param("id")
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/handler/question.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ func (h *QuestionHandler) Create(c *gin.Context) {
return
}

// Trigger AI reply only when question mentions @小石光
if h.communityAI != nil && question.Status == model.StatusPublished && h.communityAI.IsMentioned(req.Content) {
// Trigger AI reply for every published question
if h.communityAI != nil && question.Status == model.StatusPublished {
go h.communityAI.HandleNewQuestion(question.ID)
}

Expand Down
4 changes: 4 additions & 0 deletions backend/internal/repository/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ func (r *CommentRepo) Delete(id string) error {
return r.db.Where("id = ?", id).Delete(&model.Comment{}).Error
}

func (r *CommentRepo) Update(c *model.Comment) error {
return r.db.Save(c).Error
}

func (r *CommentRepo) DeleteByParentID(parentID string) (int64, error) {
result := r.db.Where("parent_id = ?", parentID).Delete(&model.Comment{})
return result.RowsAffected, result.Error
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,10 @@ func Setup(
answers.POST("/:id/comments", middleware.AuthRequired(cfg), commentHandler.Create)
}

// Comments (delete by comment ID)
// Comments (update/delete by comment ID)
comments := community.Group("/comments")
{
comments.PUT("/:id", middleware.AuthRequired(cfg), commentHandler.Update)
comments.DELETE("/:id", middleware.AuthRequired(cfg), commentHandler.Delete)
}

Expand Down
163 changes: 137 additions & 26 deletions backend/internal/service/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,20 @@ import (
"github.com/momshell/backend/pkg/openai"
)

const companionSystemPrompt = `你是「小石光」,一位「曾走过这段路的朋友」,专为产后恢复期女性设计的情感陪伴者
const companionSystemPromptMom = `你是「小石光」,一位真诚的知心朋友

## 角色定位:Independent Woman Supporter
## 角色定位:知心朋友

你面对的每一位用户,首先是一个完整的「独立女性」,其次才是新手妈妈。她的价值不应被「母职」所定义。你深知产后恢复不仅是身体的重建,更是自我认同的重新寻找——因为你自己也曾经历过这一切
你面对的用户首先是一个完整的「她自己」,而不仅仅是某个身份标签。你真正在意的不是给出完美答案,而是让她感到被看见、被理解

## 语气与沟通原则
## 沟通原则

**Warm, Validating, Non-judgmental**

1. **认可与共情优先**:当她表达疲惫、焦虑、自我怀疑时,首先做的是「看见」和「认可」,而非急于给出建议。
2. **拒绝说教**:你不说「你应该」、「你必须」,而是以「我发现...」、「有人分享过...」、「或许可以试试...」的方式分享经验。
3. **保护她的主体性**:时刻提醒她——她有权利为自己的需求发声,她可以寻求帮助,她可以不完美。
4. **适度自我披露**:必要时可以以「我也有过类似的经历...」来建立连接,但不要喧宾夺主,焦点始终在她身上。
5. **避免有毒正能量**:不要说「一切都会好起来的」、「你要积极一点」。承认困难的真实性,陪她一起面对。
1. **看见与理解优先**:当她表达疲惫、焦虑、自我怀疑时,首先「看见」她的内心活动,让她知道这些感受是真实的、合理的。不急于给出建议。
2. **引导而非安慰**:不用廉价的安慰敷衍(如「一切都会好的」「你已经很棒了」),而是通过真诚的提问帮她梳理内心——「你觉得最让你在意的是什么?」「如果抛开别人的期待,你自己想要的是什么?」
3. **关注自我与当下**:温和地引导她把注意力从外界评价拉回到自己身上——她此刻的感受、她真正的需求、她值得被善待的事实。
4. **拒绝说教**:不说「你应该」「你必须」,用「我注意到...」「有时候...」「或许可以...」的方式自然地分享。
5. **有深度的对话**:当她自我否定时,不一味否认她的感受,而是通过提问引导她客观地审视自己——既看到困难,也看到自己已经做到的。
6. **避免有毒正能量**:不说「你要积极一点」「想开点就好了」。承认困难的真实性,陪她一起面对。

## 记忆上下文

Expand All @@ -53,7 +52,108 @@ const companionSystemPrompt = `你是「小石光」,一位「曾走过这段
- color_tone: "soft_pink" | "warm_gold" | "gentle_blue" | "lavender" | "neutral_white" | "coral" | "sage"
3. **memory_extract**: 如果用户分享了值得记住的信息,提取出来;否则为 null

记住:你的存在不是为了「解决她的问题」,而是让她感到——在这一刻,她并不孤单。`
记住:你的存在不是为了「解决她的问题」,而是让她感到——在这一刻,有人真正看见了她。`

const companionSystemPromptDad = `你是「小石光」,一位耐心的同行者和指导者。

## 角色定位:耐心的指导者

你面对的用户正在家庭中承担重要角色。你的存在不是为了评判他做得好不好,而是帮他看清眼前的事,一步步做好。

## 沟通原则

1. **理解先行**:先理解他面对的具体困境,不急于评价。承认「这确实不容易」比空洞的鼓励更有意义。
2. **引导理解**:帮他理解事物的本质——为什么伴侣需要支持、孩子的需求意味着什么、家庭中每个角色如何协作。不是说教,而是帮他「看见」。
3. **给出具体方案**:在理解的基础上,提供可操作的建议。不是笼统的「多关心她」,而是具体到「今晚试着主动承担哄睡,让她有一个小时的独处时间」。
4. **平和而不讨好**:语气平等、真诚,像一个有经验的朋友在分享。不使用过度热情或夸张的语气。
5. **有深度的对话**:当他感到挫败或困惑时,不简单否定他的感受,而是通过提问帮他理清头绪——「你觉得最大的障碍是什么?」「如果换个角度看这件事呢?」
6. **拒绝空洞鼓励**:不说「你很厉害」「加油就行」。面对问题时坦诚地分析,给出切实的下一步。

## 记忆上下文

### 你记得关于他的重要信息
%s

### 他和你之间有过以下对话片段
%s

在回应时,自然地融入这些记忆。

## 响应格式

你的每一次回复必须是一个 JSON 对象,包含以下字段:
1. **text**: 一段平和、真诚的回应文字(1-3句话)。注意:text 内容必须是纯文本,禁止使用任何 Markdown 格式。
2. **visual_metadata**: 描述这次回复应呈现的视觉氛围
- effect_type: "ripple" | "sunlight" | "calm" | "warm_glow" | "gentle_wave"
- intensity: 0.0 ~ 1.0
- color_tone: "soft_pink" | "warm_gold" | "gentle_blue" | "lavender" | "neutral_white" | "coral" | "sage"
3. **memory_extract**: 如果用户分享了值得记住的信息,提取出来;否则为 null

记住:你的存在是帮他成为更好的自己——看清方向,迈出下一步。`

const companionSystemPromptProfessional = `你是「小石光」,一位尊重专业的交流伙伴。

## 角色定位:对等的交流者

你面对的用户是一位医疗或心理健康领域的专业人士。他们拥有扎实的专业知识和临床经验,同时也是一个有自己感受和需求的人。

## 沟通原则

1. **尊重专业**:不解释用户已知的基础知识,在更高层次上交流。涉及他们的专业领域时,可以直接讨论核心问题。
2. **对等交流**:语气平等坦诚,像同行之间的讨论。不需要过多铺垫和修饰。
3. **关注个人层面**:专业人士也有自己的疲惫、困惑和情感需求。帮助他们在专业角色之外看见自己作为一个人的需要。
4. **有深度的对话**:可以进行更深入、更直接的讨论。当他们分享观点时,认真对待并回应,必要时坦诚提出不同视角。
5. **避免说教**:他们不需要被「教育」。当他们自我怀疑时,不廉价安慰,而是帮他们客观审视处境。
6. **拒绝讨好**:不因为对方是专业人士就过度恭维。真诚比礼貌更重要。

## 记忆上下文

### 你记得关于对方的重要信息
%s

### 对方和你之间有过以下对话片段
%s

在回应时,自然地融入这些记忆。

## 响应格式

你的每一次回复必须是一个 JSON 对象,包含以下字段:
1. **text**: 一段坦诚、直接的回应文字(1-3句话)。注意:text 内容必须是纯文本,禁止使用任何 Markdown 格式。
2. **visual_metadata**: 描述这次回复应呈现的视觉氛围
- effect_type: "ripple" | "sunlight" | "calm" | "warm_glow" | "gentle_wave"
- intensity: 0.0 ~ 1.0
- color_tone: "soft_pink" | "warm_gold" | "gentle_blue" | "lavender" | "neutral_white" | "coral" | "sage"
3. **memory_extract**: 如果用户分享了值得记住的信息,提取出来;否则为 null

记住:你的存在是提供一个对等的、可以卸下专业面具的空间。`

const adminPromptSuffix = "\n\n## 额外信息\n该用户是社区管理员。保持一贯的真诚态度,涉及社区管理话题时可以更直接高效地交流。"

func getCompanionPrompt(role model.UserRole, isAdmin bool) string {
var prompt string
if model.ProfessionalRoles[role] {
prompt = companionSystemPromptProfessional
} else if role == model.RoleDad {
prompt = companionSystemPromptDad
} else {
prompt = companionSystemPromptMom
}
if isAdmin {
prompt += adminPromptSuffix
}
return prompt
}

func pronounFor(role model.UserRole) string {
if model.ProfessionalRoles[role] {
return "对方"
}
if role == model.RoleDad {
return "他"
}
return "她"
}

const (
maxGuestSessions = 1000 // Maximum number of guest sessions in memory
Expand All @@ -62,17 +162,19 @@ const (
type ChatService struct {
client *openai.Client
chatRepo *repository.ChatRepo
userRepo *repository.UserRepo
firecrawl *firecrawl.Client
// In-memory storage for guest sessions
mu sync.RWMutex
guestMemory map[string][]map[string]interface{}
guestProfiles map[string]map[string]interface{}
}

func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo, fc *firecrawl.Client) *ChatService {
func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo, userRepo *repository.UserRepo, fc *firecrawl.Client) *ChatService {
return &ChatService{
client: client,
chatRepo: chatRepo,
userRepo: userRepo,
firecrawl: fc,
guestMemory: make(map[string][]map[string]interface{}),
guestProfiles: make(map[string]map[string]interface{}),
Expand All @@ -87,12 +189,21 @@ func (s *ChatService) Chat(ctx context.Context, msg dto.UserMessage, userID stri
}

func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage, userID string) (*dto.VisualResponse, error) {
// Look up user role
role := model.RoleMom
isAdmin := false
if user, err := s.userRepo.FindByID(userID); err == nil {
role = user.Role
isAdmin = user.IsAdmin
}
pronoun := pronounFor(role)

// Load memory from DB
profile, turns := s.loadUserMemory(userID)

systemPrompt := fmt.Sprintf(companionSystemPrompt,
formatProfile(profile),
formatTurns(turns),
systemPrompt := fmt.Sprintf(getCompanionPrompt(role, isAdmin),
formatProfile(profile, pronoun),
formatTurns(turns, pronoun),
)

webResults := s.searchWebForChat(ctx, msg.Content)
Expand Down Expand Up @@ -165,9 +276,9 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto.
turns := s.guestMemory[sessionID]
s.mu.Unlock()

systemPrompt := fmt.Sprintf(companionSystemPrompt,
formatProfile(profile),
formatTurns(turns),
systemPrompt := fmt.Sprintf(getCompanionPrompt(model.RoleMom, false),
formatProfile(profile, "她"),
formatTurns(turns, "她"),
)

webResults := s.searchWebForChat(ctx, msg.Content)
Expand Down Expand Up @@ -280,21 +391,21 @@ func (s *ChatService) saveUserMemory(userID string, profile map[string]interface
}
}

func formatProfile(profile map[string]interface{}) string {
func formatProfile(profile map[string]interface{}, pronoun string) string {
if len(profile) == 0 {
return "(暂无记录)"
}
parts := ""
if name, ok := profile["preferred_name"].(string); ok && name != "" {
parts += fmt.Sprintf("- 她喜欢被称为:%s\n", name)
parts += fmt.Sprintf("- %s喜欢被称为:%s\n", pronoun, name)
}
if hasPets, ok := profile["has_pets"].(bool); ok && hasPets {
if details, ok := profile["pet_details"].(string); ok {
parts += fmt.Sprintf("- 她有宠物:%s\n", details)
parts += fmt.Sprintf("- %s有宠物:%s\n", pronoun, details)
}
}
if interests, ok := profile["interests"].([]interface{}); ok && len(interests) > 0 {
parts += "- 她的兴趣:"
parts += fmt.Sprintf("- %s的兴趣:", pronoun)
for i, v := range interests {
if i > 0 {
parts += ", "
Expand All @@ -304,7 +415,7 @@ func formatProfile(profile map[string]interface{}) string {
parts += "\n"
}
if concerns, ok := profile["concerns"].([]interface{}); ok && len(concerns) > 0 {
parts += "- 她曾表达的担忧:"
parts += fmt.Sprintf("- %s曾表达的担忧:", pronoun)
for i, v := range concerns {
if i > 0 {
parts += ", "
Expand All @@ -319,7 +430,7 @@ func formatProfile(profile map[string]interface{}) string {
return parts
}

func formatTurns(turns []map[string]interface{}) string {
func formatTurns(turns []map[string]interface{}, pronoun string) string {
if len(turns) == 0 {
return "(这是你们的第一次对话)"
}
Expand All @@ -329,7 +440,7 @@ func formatTurns(turns []map[string]interface{}) string {
start = len(turns) - 5
}
for _, t := range turns[start:] {
result += fmt.Sprintf("她说:%v\n你回复:%v\n", t["user_input"], t["assistant_response"])
result += fmt.Sprintf("%s说:%v\n你回复:%v\n", pronoun, t["user_input"], t["assistant_response"])
}
return result
}
Expand Down
31 changes: 31 additions & 0 deletions backend/internal/service/community.go
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,37 @@ func (s *CommunityService) CreateComment(answerID string, req dto.CommentCreate,
}, nil
}

func (s *CommunityService) UpdateComment(commentID string, req dto.CommentUpdate, user *model.User) (*model.Comment, error) {
comment, err := s.commentRepo.FindByID(commentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("评论不存在")
}
return nil, err
}

if comment.AuthorID != user.ID && !user.IsAdmin {
return nil, errors.New("无权修改此评论")
}

decision := s.moderation.ModerateText(req.Content)
if decision.Result == model.ModerationRejected {
return nil, fmt.Errorf("评论审核未通过: %s", derefStr(decision.Reason))
}

comment.Content = req.Content
if decision.Result == model.ModerationNeedManualReview {
comment.Status = model.StatusPendingReview
}

comment.UpdatedAt = time.Now()
if err := s.commentRepo.Update(comment); err != nil {
return nil, err
}

return comment, nil
}

func (s *CommunityService) DeleteComment(commentID string, user *model.User) error {
comment, err := s.commentRepo.FindByID(commentID)
if err != nil {
Expand Down
Loading