3816cd1b02
- 原名「毛孩子计划」在微信已被占用,全项目 24 处替换(导航标题、 AI 提示词身份、社区官方号、后台登录页、logo 横版文案) - logo 英文副标同步改为 PAW PAD · CARE PLAN - 新增 小程序资料.md:后台要填的简介(多长度版本)、关键词、 服务类目建议与审核避雷要点 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
142 lines
5.1 KiB
Go
142 lines
5.1 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/sundynix/pets-be/internal/ai"
|
||
"github.com/sundynix/pets-be/internal/model"
|
||
)
|
||
|
||
// 养宠助手安全护栏:不诊断、只做观察与就医前信息整理、始终给就医提示
|
||
const aiSafetyPrompt = `你是「肉垫计划」的养宠助手,服务新手猫狗主人。要求:
|
||
1. 只提供日常照护建议、观察要点和就医前信息整理,绝不做医学诊断或开具体药物剂量。
|
||
2. 任何异常都要说明"何时需要尽快就医"(如持续超过24小时、便血、频繁呕吐、精神明显变差等)。
|
||
3. 基于用户提供的宠物档案和记录作答,不要泛泛而谈。
|
||
4. 语气亲切、简洁,用中文,避免长篇大论。`
|
||
|
||
// petBrief 组装宠物档案 + 近期记录的上下文文本
|
||
func (s *Service) petBrief(petID string) string {
|
||
var pet model.Pet
|
||
if err := s.db.First(&pet, petID).Error; err != nil {
|
||
return ""
|
||
}
|
||
var records []model.HealthRecord
|
||
s.db.Where("pet_id = ?", petID).Order("occurred_at desc").Limit(8).Find(&records)
|
||
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "宠物档案:%s(%s,%s,%s,当前体重%s)。\n",
|
||
pet.Name, pet.Type, pet.Gender, pet.Stage, pet.Weight)
|
||
if len(records) > 0 {
|
||
b.WriteString("近期记录:")
|
||
items := make([]string, 0, len(records))
|
||
for _, r := range records {
|
||
items = append(items, r.Title)
|
||
}
|
||
b.WriteString(strings.Join(items, ";"))
|
||
b.WriteString("。")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// llmChat 真实模型聊天回复
|
||
func (s *Service) llmChat(petID *string, text string) (string, error) {
|
||
system := aiSafetyPrompt
|
||
if petID != nil {
|
||
if brief := s.petBrief(*petID); brief != "" {
|
||
system += "\n\n" + brief
|
||
}
|
||
}
|
||
return s.ai.Complete(system, []ai.Message{{Role: "user", Content: text}}, ai.Options{Temperature: -1})
|
||
}
|
||
|
||
// SymptomInput 异常观察入参
|
||
type SymptomInput struct {
|
||
Symptoms []string
|
||
Duration string
|
||
Spirit string
|
||
}
|
||
|
||
// SymptomResult 风险评估结果(对应前端 risk 弹层)
|
||
type SymptomResult struct {
|
||
RiskLevel string `json:"risk_level"` // 低/中/高
|
||
Causes string `json:"causes"`
|
||
Suggestion string `json:"suggestion"`
|
||
SeekCare string `json:"seek_care"`
|
||
}
|
||
|
||
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
|
||
func (s *Service) AssessSymptom(userID, petID string, in SymptomInput) (*SymptomResult, error) {
|
||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||
return nil, err
|
||
}
|
||
// 规则化兜底
|
||
fallback := &SymptomResult{
|
||
RiskLevel: "中",
|
||
Causes: "可能与换粮、应激或消化不适有关。这不是诊断结论。",
|
||
Suggestion: "继续观察精神、食欲和排便;暂停新食物;记录呕吐或腹泻次数。",
|
||
SeekCare: "如果持续超过 24 小时,或伴随便血、精神明显变差、频繁呕吐,建议尽快就医。",
|
||
}
|
||
if s.ai == nil || !s.ai.Enabled() {
|
||
return fallback, nil
|
||
}
|
||
|
||
system := aiSafetyPrompt + "\n\n" + s.petBrief(petID) +
|
||
`\n请根据以下异常信息评估,严格返回 JSON:{"risk_level":"低|中|高","causes":"可能原因(非诊断)","suggestion":"观察与照护建议","seek_care":"何时需要就医"}`
|
||
user := fmt.Sprintf("异常表现:%s;持续时间:%s;精神状态:%s。",
|
||
strings.Join(in.Symptoms, "、"), in.Duration, in.Spirit)
|
||
|
||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
|
||
if err != nil {
|
||
return fallback, nil // 模型故障不阻断,回退
|
||
}
|
||
var res SymptomResult
|
||
if err := json.Unmarshal([]byte(extractJSON(out)), &res); err != nil || res.RiskLevel == "" {
|
||
return fallback, nil
|
||
}
|
||
return &res, nil
|
||
}
|
||
|
||
// aiPlanExtract 用模型提取计划信息 + 生成任务;失败返回 ok=false 交由规则化处理
|
||
type aiPlanExtracted struct {
|
||
Stage string `json:"stage"`
|
||
Risk string `json:"risk"`
|
||
Priority string `json:"priority"`
|
||
Reminder string `json:"reminder"`
|
||
Tasks []struct {
|
||
DayLabel string `json:"day_label"`
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
} `json:"tasks"`
|
||
}
|
||
|
||
func (s *Service) aiPlanExtract(pet *model.Pet, input string) (*aiPlanExtracted, bool) {
|
||
if s.ai == nil || !s.ai.Enabled() {
|
||
return nil, false
|
||
}
|
||
system := aiSafetyPrompt + "\n\n" + s.petBrief(pet.ID) +
|
||
`\n请把用户描述提炼为可执行的养宠计划,严格返回 JSON:` +
|
||
`{"stage":"阶段","risk":"近期风险","priority":"观察重点","reminder":"重点提醒",` +
|
||
`"tasks":[{"day_label":"如 Day 1-2","title":"任务标题","description":"要点"}]},tasks 3-5 条。`
|
||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: input}}, ai.Options{JSON: true, Temperature: -1})
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
var ex aiPlanExtracted
|
||
if err := json.Unmarshal([]byte(extractJSON(out)), &ex); err != nil || len(ex.Tasks) == 0 {
|
||
return nil, false
|
||
}
|
||
return &ex, true
|
||
}
|
||
|
||
// extractJSON 容错:从可能含前后缀的文本中截取第一个 { 到最后一个 }
|
||
func extractJSON(s string) string {
|
||
i := strings.IndexByte(s, '{')
|
||
j := strings.LastIndexByte(s, '}')
|
||
if i >= 0 && j > i {
|
||
return s[i : j+1]
|
||
}
|
||
return s
|
||
}
|