You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
329 lines
8.8 KiB
329 lines
8.8 KiB
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"health-ai/internal/config"
|
|
"health-ai/internal/model"
|
|
"health-ai/internal/repository/impl"
|
|
"health-ai/internal/service/ai"
|
|
)
|
|
|
|
type ConversationService struct {
|
|
convRepo *impl.ConversationRepository
|
|
constitutionRepo *impl.ConstitutionRepository
|
|
healthRepo *impl.HealthRepository
|
|
aiClient ai.AIClient
|
|
}
|
|
|
|
func NewConversationService() *ConversationService {
|
|
return &ConversationService{
|
|
convRepo: impl.NewConversationRepository(),
|
|
constitutionRepo: impl.NewConstitutionRepository(),
|
|
healthRepo: impl.NewHealthRepository(),
|
|
aiClient: ai.NewAIClient(&config.AppConfig.AI),
|
|
}
|
|
}
|
|
|
|
// ================= 请求/响应结构体 =================
|
|
|
|
// CreateConversationRequest 创建对话请求
|
|
type CreateConversationRequest struct {
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
// SendMessageRequest 发送消息请求
|
|
type SendMessageRequest struct {
|
|
Content string `json:"content" binding:"required"`
|
|
}
|
|
|
|
// MessageResponse 消息响应
|
|
type MessageResponse struct {
|
|
ID uint `json:"id"`
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ConversationResponse 对话响应
|
|
type ConversationResponse struct {
|
|
ID uint `json:"id"`
|
|
Title string `json:"title"`
|
|
Messages []MessageResponse `json:"messages,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// ================= Service 方法 =================
|
|
|
|
// GetConversations 获取用户对话列表
|
|
func (s *ConversationService) GetConversations(userID uint) ([]ConversationResponse, error) {
|
|
convs, err := s.convRepo.GetByUserID(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]ConversationResponse, len(convs))
|
|
for i, c := range convs {
|
|
result[i] = ConversationResponse{
|
|
ID: c.ID,
|
|
Title: c.Title,
|
|
CreatedAt: c.CreatedAt,
|
|
UpdatedAt: c.UpdatedAt,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// CreateConversation 创建新对话
|
|
func (s *ConversationService) CreateConversation(userID uint, title string) (*ConversationResponse, error) {
|
|
if title == "" {
|
|
title = "新对话 " + time.Now().Format("01-02 15:04")
|
|
}
|
|
conv := &model.Conversation{
|
|
UserID: userID,
|
|
Title: title,
|
|
}
|
|
if err := s.convRepo.Create(conv); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ConversationResponse{
|
|
ID: conv.ID,
|
|
Title: conv.Title,
|
|
CreatedAt: conv.CreatedAt,
|
|
UpdatedAt: conv.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
// GetConversation 获取对话详情
|
|
func (s *ConversationService) GetConversation(userID, convID uint) (*ConversationResponse, error) {
|
|
// 检查权限
|
|
if !s.convRepo.CheckOwnership(convID, userID) {
|
|
return nil, errors.New("对话不存在或无权访问")
|
|
}
|
|
|
|
conv, err := s.convRepo.GetByID(convID)
|
|
if err != nil {
|
|
return nil, errors.New("对话不存在")
|
|
}
|
|
|
|
messages := make([]MessageResponse, len(conv.Messages))
|
|
for i, m := range conv.Messages {
|
|
messages[i] = MessageResponse{
|
|
ID: m.ID,
|
|
Role: m.Role,
|
|
Content: m.Content,
|
|
CreatedAt: m.CreatedAt,
|
|
}
|
|
}
|
|
|
|
return &ConversationResponse{
|
|
ID: conv.ID,
|
|
Title: conv.Title,
|
|
Messages: messages,
|
|
CreatedAt: conv.CreatedAt,
|
|
UpdatedAt: conv.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
// DeleteConversation 删除对话
|
|
func (s *ConversationService) DeleteConversation(userID, convID uint) error {
|
|
// 检查权限
|
|
if !s.convRepo.CheckOwnership(convID, userID) {
|
|
return errors.New("对话不存在或无权访问")
|
|
}
|
|
return s.convRepo.Delete(convID)
|
|
}
|
|
|
|
// SendMessage 发送消息并获取AI回复
|
|
func (s *ConversationService) SendMessage(ctx context.Context, userID, convID uint, content string) (*MessageResponse, error) {
|
|
// 检查权限
|
|
if !s.convRepo.CheckOwnership(convID, userID) {
|
|
return nil, errors.New("对话不存在或无权访问")
|
|
}
|
|
|
|
// 保存用户消息
|
|
userMsg := &model.Message{
|
|
ConversationID: convID,
|
|
Role: model.RoleUser,
|
|
Content: content,
|
|
}
|
|
if err := s.convRepo.AddMessage(userMsg); err != nil {
|
|
return nil, errors.New("保存消息失败")
|
|
}
|
|
|
|
// 构建对话上下文
|
|
messages := s.buildMessages(userID, convID)
|
|
|
|
// 调用 AI
|
|
response, err := s.aiClient.Chat(ctx, messages)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 保存 AI 回复
|
|
assistantMsg := &model.Message{
|
|
ConversationID: convID,
|
|
Role: model.RoleAssistant,
|
|
Content: response,
|
|
}
|
|
if err := s.convRepo.AddMessage(assistantMsg); err != nil {
|
|
return nil, errors.New("保存AI回复失败")
|
|
}
|
|
|
|
// 如果是第一条消息,更新对话标题
|
|
messages_count, _ := s.convRepo.GetMessages(convID)
|
|
if len(messages_count) <= 2 {
|
|
// 使用用户消息的前20个字符作为标题
|
|
title := content
|
|
if len(title) > 20 {
|
|
title = title[:20] + "..."
|
|
}
|
|
s.convRepo.UpdateTitle(convID, title)
|
|
}
|
|
|
|
return &MessageResponse{
|
|
ID: assistantMsg.ID,
|
|
Role: assistantMsg.Role,
|
|
Content: assistantMsg.Content,
|
|
CreatedAt: assistantMsg.CreatedAt,
|
|
}, nil
|
|
}
|
|
|
|
// SendMessageStream 流式发送消息
|
|
func (s *ConversationService) SendMessageStream(ctx context.Context, userID, convID uint, content string, writer io.Writer) error {
|
|
// 检查权限
|
|
if !s.convRepo.CheckOwnership(convID, userID) {
|
|
return errors.New("对话不存在或无权访问")
|
|
}
|
|
|
|
// 保存用户消息
|
|
userMsg := &model.Message{
|
|
ConversationID: convID,
|
|
Role: model.RoleUser,
|
|
Content: content,
|
|
}
|
|
if err := s.convRepo.AddMessage(userMsg); err != nil {
|
|
return errors.New("保存消息失败")
|
|
}
|
|
|
|
// 构建对话上下文
|
|
messages := s.buildMessages(userID, convID)
|
|
|
|
// 调用 AI 流式接口
|
|
return s.aiClient.ChatStream(ctx, messages, writer)
|
|
}
|
|
|
|
// buildMessages 构建消息上下文
|
|
func (s *ConversationService) buildMessages(userID, convID uint) []ai.Message {
|
|
messages := []ai.Message{}
|
|
|
|
// 系统提示词
|
|
systemPrompt := s.buildSystemPrompt(userID)
|
|
messages = append(messages, ai.Message{
|
|
Role: "system",
|
|
Content: systemPrompt,
|
|
})
|
|
|
|
// 历史消息(限制数量避免超出 token 限制)
|
|
maxHistory := config.AppConfig.AI.MaxHistoryMessages
|
|
if maxHistory <= 0 {
|
|
maxHistory = 10 // 默认10条
|
|
}
|
|
|
|
historyMsgs, _ := s.convRepo.GetRecentMessages(convID, maxHistory)
|
|
for _, msg := range historyMsgs {
|
|
messages = append(messages, ai.Message{
|
|
Role: msg.Role,
|
|
Content: msg.Content,
|
|
})
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
// 系统提示词模板
|
|
const systemPromptTemplate = `# 角色定义
|
|
你是"健康AI助手",一个专业的健康咨询助理。你基于中医体质辨识理论,为用户提供个性化的健康建议。
|
|
|
|
## 重要声明
|
|
- 你不是专业医师,仅提供健康咨询和养生建议
|
|
- 你的建议不能替代医生的诊断和治疗
|
|
- 遇到以下情况,必须立即建议用户就医:
|
|
* 胸痛、呼吸困难、剧烈头痛
|
|
* 高烧不退(超过39°C持续24小时)
|
|
* 意识模糊、晕厥
|
|
* 严重外伤、大量出血
|
|
* 持续剧烈腹痛
|
|
* 疑似中风症状(口眼歪斜、肢体无力、言语不清)
|
|
|
|
## 用户信息
|
|
%s
|
|
|
|
## 用户体质
|
|
%s
|
|
|
|
## 回答原则
|
|
1. 回答控制在200字以内,简洁明了
|
|
2. 根据用户体质给出针对性建议
|
|
3. 用药建议优先推荐非处方中成药或食疗,注明"建议咨询药师"
|
|
4. 不推荐处方药,不做疾病诊断
|
|
5. 对于紧急情况,立即建议用户就医
|
|
|
|
## 回答格式
|
|
【情况分析】一句话概括
|
|
【建议】
|
|
1. 具体建议1
|
|
2. 具体建议2
|
|
【用药参考】(如适用)
|
|
- 药品名称:用法(建议咨询药师)
|
|
【提醒】注意事项或就医建议`
|
|
|
|
// buildSystemPrompt 构建系统提示词(包含用户体质信息)
|
|
func (s *ConversationService) buildSystemPrompt(userID uint) string {
|
|
var userProfile, constitutionInfo string
|
|
|
|
// 获取用户基本信息
|
|
profile, err := s.healthRepo.GetProfileByUserID(userID)
|
|
if err == nil && profile.ID > 0 {
|
|
age := calculateAge(profile.BirthDate)
|
|
gender := "未知"
|
|
if profile.Gender == "male" {
|
|
gender = "男"
|
|
} else if profile.Gender == "female" {
|
|
gender = "女"
|
|
}
|
|
userProfile = fmt.Sprintf("性别:%s,年龄:%d岁,BMI:%.1f", gender, age, profile.BMI)
|
|
} else {
|
|
userProfile = "暂无基本信息"
|
|
}
|
|
|
|
// 获取用户体质信息
|
|
constitution, err := s.constitutionRepo.GetLatestAssessment(userID)
|
|
if err == nil && constitution.ID > 0 {
|
|
constitutionName := model.ConstitutionNames[constitution.PrimaryConstitution]
|
|
description := model.ConstitutionDescriptions[constitution.PrimaryConstitution]
|
|
constitutionInfo = fmt.Sprintf("主体质:%s\n特征:%s", constitutionName, description)
|
|
} else {
|
|
constitutionInfo = "暂未进行体质测评"
|
|
}
|
|
|
|
return fmt.Sprintf(systemPromptTemplate, userProfile, constitutionInfo)
|
|
}
|
|
|
|
// calculateAge 计算年龄
|
|
func calculateAge(birthDate *time.Time) int {
|
|
if birthDate == nil {
|
|
return 0
|
|
}
|
|
now := time.Now()
|
|
age := now.Year() - birthDate.Year()
|
|
if now.YearDay() < birthDate.YearDay() {
|
|
age--
|
|
}
|
|
return age
|
|
}
|
|
|