init: 毛孩子计划 小程序 + Go 后端 + 内嵌后台
- pets-fe: 微信原生小程序(首页/计划/记录/报告/社区/引导), 服务端驱动、无假数据;弹层改用 scroll-view,打开时隐藏自定义 tabBar - pets-be: Gin + GORM(MySQL, sundynix_ 前缀) + MinIO,统一响应/分页, 微信 code2session 登录,provider-neutral AI(DeepSeek),go:embed React 后台 - 修复:分段选择类型不匹配(字符串 vs 数字)导致选不中 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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 uint) 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 *uint, 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 uint, 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
|
||||
}
|
||||
Reference in New Issue
Block a user