feat(smoke): 添加个性化戒烟计划生成功能 (Issue #46)
- 新增 Model 层: SmokeQuitPlan, SmokeQuitPlanDay 结构体 - 新增 Service 层: GenerateQuitPlan, GetActivePlan, GetPlanDays, ResetPlan - 新增 Handler 层: POST /generate, GET /, GET /days, POST /reset - 集成 AI 生成 30 天个性化戒烟减量方案 - 支持重置计划功能
This commit is contained in:
@@ -0,0 +1,553 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"wx_service/config"
|
||||
usermodel "wx_service/internal/model"
|
||||
smokemodel "wx_service/internal/smoke/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrQuitPlanNotFound = errors.New("quit plan not found")
|
||||
ErrQuitPlanDayNotFound = errors.New("quit plan day not found")
|
||||
ErrNoUserProfile = errors.New("user profile not found, please complete onboarding first")
|
||||
ErrPlanAlreadyActive = errors.New("already has an active quit plan")
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultQuitPlanPromptVersion = "v1"
|
||||
QuitPlanDays = 30
|
||||
)
|
||||
|
||||
// QuitPlanUserProfile 用户画像(用于生成戒烟计划)
|
||||
type QuitPlanUserProfile struct {
|
||||
BaselineCigsPerDay int `json:"baseline_cigs_per_day"`
|
||||
SmokingYears float64 `json:"smoking_years"`
|
||||
PackPriceCent int `json:"pack_price_cent"`
|
||||
SmokeMotivations []string `json:"smoke_motivations"`
|
||||
QuitMotivations []string `json:"quit_motivations"`
|
||||
WakeUpTime string `json:"wake_up_time"`
|
||||
SleepTime string `json:"sleep_time"`
|
||||
UserSegment string `json:"user_segment"`
|
||||
}
|
||||
|
||||
// QuitPlanDayData 单日计划数据(AI 返回格式)
|
||||
type QuitPlanDayData struct {
|
||||
Day int `json:"day"`
|
||||
Stage string `json:"stage"`
|
||||
TargetCigs int `json:"target_cigs"`
|
||||
TargetReduce bool `json:"target_reduce"`
|
||||
Advice string `json:"advice"`
|
||||
}
|
||||
|
||||
// QuitPlanAIResponse AI 返回的戒烟计划
|
||||
type QuitPlanAIResponse struct {
|
||||
Summary string `json:"summary"` // 30 天计划概述
|
||||
Days []QuitPlanDayData `json:"days"` // 30 天每日计划
|
||||
}
|
||||
|
||||
// SmokeQuitPlanService 戒烟计划服务
|
||||
type SmokeQuitPlanService struct {
|
||||
db *gorm.DB
|
||||
cfg config.AIConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewSmokeQuitPlanService 创建戒烟计划服务
|
||||
func NewSmokeQuitPlanService(db *gorm.DB, cfg config.AIConfig) *SmokeQuitPlanService {
|
||||
timeout := cfg.RequestTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
return &SmokeQuitPlanService{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateQuitPlanRequest 生成戒烟计划请求
|
||||
type GenerateQuitPlanRequest struct {
|
||||
// 可选:指定开始日期,默认今天
|
||||
StartDate *time.Time
|
||||
}
|
||||
|
||||
// GenerateQuitPlan 生成戒烟计划
|
||||
func (s *SmokeQuitPlanService) GenerateQuitPlan(ctx context.Context, user *usermodel.User, req GenerateQuitPlanRequest) (*smokemodel.SmokeQuitPlan, error) {
|
||||
// 检查是否已有活跃计划
|
||||
existing, err := s.GetActivePlan(ctx, int(user.ID))
|
||||
if err != nil && !errors.Is(err, ErrQuitPlanNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, ErrPlanAlreadyActive
|
||||
}
|
||||
|
||||
// 获取用户画像
|
||||
profile, err := s.getUserProfile(ctx, int(user.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, ErrNoUserProfile
|
||||
}
|
||||
|
||||
// 确定开始日期
|
||||
startDate := time.Now().In(time.Local)
|
||||
if req.StartDate != nil {
|
||||
startDate = *req.StartDate
|
||||
}
|
||||
startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, time.Local)
|
||||
endDate := startDate.AddDate(0, 0, QuitPlanDays-1)
|
||||
|
||||
// 调用 AI 生成计划
|
||||
aiResp, modelName, tokensIn, tokensOut, err := s.callAIForQuitPlan(ctx, profile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate quit plan from AI: %w", err)
|
||||
}
|
||||
|
||||
// 保存主计划
|
||||
now := time.Now().Unix()
|
||||
createTime := now
|
||||
updateTime := now
|
||||
|
||||
plan := smokemodel.SmokeQuitPlan{
|
||||
UID: int(user.ID),
|
||||
Status: smokemodel.QuitPlanStatusActive,
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
BaselineCigsPerDay: profile.BaselineCigsPerDay,
|
||||
SmokingYears: profile.SmokingYears,
|
||||
PackPriceCent: profile.PackPriceCent,
|
||||
CurrentStage: smokemodel.QuitPlanStageRecording,
|
||||
CurrentDay: 1,
|
||||
CompletedDays: 0,
|
||||
PromptVersion: DefaultQuitPlanPromptVersion,
|
||||
Provider: "openai-compatible",
|
||||
Model: modelName,
|
||||
TokensIn: tokensIn,
|
||||
TokensOut: tokensOut,
|
||||
Summary: aiResp.Summary,
|
||||
CreateTime: &createTime,
|
||||
UpdateTime: &updateTime,
|
||||
}
|
||||
|
||||
// 事务保存计划及每日明细
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&plan).Error; err != nil {
|
||||
return fmt.Errorf("create quit plan: %w", err)
|
||||
}
|
||||
|
||||
// 保存每日明细
|
||||
for _, dayData := range aiResp.Days {
|
||||
planDate := startDate.AddDate(0, 0, dayData.Day-1)
|
||||
advice := dayData.Advice
|
||||
|
||||
dayRecord := smokemodel.SmokeQuitPlanDay{
|
||||
PlanID: plan.ID,
|
||||
UID: int(user.ID),
|
||||
PlanDate: planDate,
|
||||
Stage: smokemodel.QuitPlanStage(dayData.Stage),
|
||||
Day: dayData.Day,
|
||||
TargetCigs: dayData.TargetCigs,
|
||||
TargetReduced: dayData.TargetReduce,
|
||||
Advice: advice,
|
||||
CreateTime: &createTime,
|
||||
UpdateTime: &updateTime,
|
||||
}
|
||||
if err := tx.Create(&dayRecord).Error; err != nil {
|
||||
return fmt.Errorf("create quit plan day: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// GetActivePlan 获取当前活跃的戒烟计划
|
||||
func (s *SmokeQuitPlanService) GetActivePlan(ctx context.Context, uid int) (*smokemodel.SmokeQuitPlan, error) {
|
||||
var plan smokemodel.SmokeQuitPlan
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("uid = ? AND status = ? AND (deletetime IS NULL OR deletetime = 0)", uid, smokemodel.QuitPlanStatusActive).
|
||||
First(&plan).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrQuitPlanNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get active quit plan: %w", err)
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// GetPlanByID 根据 ID 获取戒烟计划
|
||||
func (s *SmokeQuitPlanService) GetPlanByID(ctx context.Context, uid, planID int) (*smokemodel.SmokeQuitPlan, error) {
|
||||
var plan smokemodel.SmokeQuitPlan
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("id = ? AND uid = ? AND (deletetime IS NULL OR deletetime = 0)", planID, uid).
|
||||
First(&plan).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrQuitPlanNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get quit plan by id: %w", err)
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// GetPlanDays 获取计划的每日明细
|
||||
func (s *SmokeQuitPlanService) GetPlanDays(ctx context.Context, planID int) ([]smokemodel.SmokeQuitPlanDay, error) {
|
||||
var days []smokemodel.SmokeQuitPlanDay
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("plan_id = ? AND (deletetime IS NULL OR deletetime = 0)", planID).
|
||||
Order("day ASC").
|
||||
Find(&days).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get quit plan days: %w", err)
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
|
||||
// GetPlanDayByDate 根据日期获取每日计划
|
||||
func (s *SmokeQuitPlanService) GetPlanDayByDate(ctx context.Context, uid int, date time.Time) (*smokemodel.SmokeQuitPlanDay, error) {
|
||||
dateOnly := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local)
|
||||
var dayPlan smokemodel.SmokeQuitPlanDay
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("uid = ? AND plan_date = ? AND (deletetime IS NULL OR deletetime = 0)", uid, dateOnly.Format("2006-01-02")).
|
||||
First(&dayPlan).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrQuitPlanDayNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get quit plan day by date: %w", err)
|
||||
}
|
||||
return &dayPlan, nil
|
||||
}
|
||||
|
||||
// ResetPlan 重置戒烟计划(生成新计划)
|
||||
func (s *SmokeQuitPlanService) ResetPlan(ctx context.Context, user *usermodel.User, req GenerateQuitPlanRequest) (*smokemodel.SmokeQuitPlan, error) {
|
||||
// 软删除现有活跃计划(如果有)
|
||||
existing, err := s.GetActivePlan(ctx, int(user.ID))
|
||||
if err != nil && !errors.Is(err, ErrQuitPlanNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
now := time.Now().Unix()
|
||||
err := s.db.WithContext(ctx).Model(existing).Updates(map[string]interface{}{
|
||||
"status": smokemodel.QuitPlanStatusFailed,
|
||||
"updatetime": now,
|
||||
}).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mark existing plan as failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成新计划
|
||||
return s.GenerateQuitPlan(ctx, user, req)
|
||||
}
|
||||
|
||||
// UpdatePlanDayProgress 更新每日计划进度(根据当天抽烟记录)
|
||||
func (s *SmokeQuitPlanService) UpdatePlanDayProgress(ctx context.Context, uid int, date time.Time) error {
|
||||
dayPlan, err := s.GetPlanDayByDate(ctx, uid, date)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrQuitPlanDayNotFound) {
|
||||
return nil // 没有计划日期,跳过
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 统计当天的抽烟记录
|
||||
var totalCigs int
|
||||
err = s.db.WithContext(ctx).
|
||||
Table("fa_smoke_log").
|
||||
Where("uid = ? AND smoke_time = ? AND (deletetime IS NULL OR deletetime = 0)",
|
||||
uid, time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local).Format("2006-01-02")).
|
||||
Select("COALESCE(SUM(num), 0) as total_cigs").
|
||||
Row().Scan(&totalCigs)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("count smoke logs: %w", err)
|
||||
}
|
||||
|
||||
// 统计忍住的次数
|
||||
var resistedCnt int64
|
||||
err = s.db.WithContext(ctx).
|
||||
Table("fa_smoke_log").
|
||||
Where("uid = ? AND smoke_time = ? AND level = 0 AND num = 0 AND (deletetime IS NULL OR deletetime = 0)",
|
||||
uid, time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local).Format("2006-01-02")).
|
||||
Count(&resistedCnt).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("count resisted: %w", err)
|
||||
}
|
||||
|
||||
// 更新完成状态
|
||||
achieved := totalCigs <= dayPlan.TargetCigs
|
||||
now := time.Now()
|
||||
nowUnix := now.Unix()
|
||||
resistedCntVal := int(resistedCnt)
|
||||
|
||||
return s.db.WithContext(ctx).Model(dayPlan).Updates(map[string]interface{}{
|
||||
"actual_cigs": totalCigs,
|
||||
"resisted_cnt": resistedCntVal,
|
||||
"achieved": achieved,
|
||||
"completed_at": now,
|
||||
"updatetime": nowUnix,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// getUserProfile 获取用户画像
|
||||
func (s *SmokeQuitPlanService) getUserProfile(ctx context.Context, uid int) (*QuitPlanUserProfile, error) {
|
||||
var profile smokemodel.SmokeUserProfile
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("uid = ? AND deleted_at IS NULL", uid).
|
||||
First(&profile).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load user profile: %w", err)
|
||||
}
|
||||
|
||||
wake := strings.TrimSpace(profile.WakeUpTime)
|
||||
sleep := strings.TrimSpace(profile.SleepTime)
|
||||
|
||||
segment := quitPlanDeriveUserSegment(profile.BaselineCigsPerDay, profile.SmokingYears)
|
||||
|
||||
return &QuitPlanUserProfile{
|
||||
BaselineCigsPerDay: profile.BaselineCigsPerDay,
|
||||
SmokingYears: profile.SmokingYears,
|
||||
PackPriceCent: profile.PackPriceCent,
|
||||
SmokeMotivations: []string(profile.SmokeMotivations),
|
||||
QuitMotivations: []string(profile.QuitMotivations),
|
||||
WakeUpTime: wake,
|
||||
SleepTime: sleep,
|
||||
UserSegment: segment,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// callAIForQuitPlan 调用 AI 生成戒烟计划
|
||||
func (s *SmokeQuitPlanService) callAIForQuitPlan(ctx context.Context, profile *QuitPlanUserProfile) (*QuitPlanAIResponse, string, *int, *int, error) {
|
||||
if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" {
|
||||
return nil, "", nil, nil, ErrAIServiceDisabled
|
||||
}
|
||||
|
||||
systemPrompt := strings.TrimSpace(`
|
||||
你是一位专业的戒烟教练与行为改变专家。你的任务是根据用户的画像数据,生成一份为期30天的个性化戒烟减量方案。
|
||||
|
||||
## 输出要求
|
||||
请严格按照以下 JSON 格式输出,不要输出任何其他内容:
|
||||
|
||||
{
|
||||
"summary": "计划概述(200字以内,包含整体目标和策略)",
|
||||
"days": [
|
||||
{"day": 1, "stage": "recording", "target_cigs": 10, "target_reduce": false, "advice": "建议内容"},
|
||||
{"day": 2, "stage": "recording", "target_cigs": 10, "target_reduce": false, "advice": "建议内容"},
|
||||
...共30天...
|
||||
]
|
||||
}
|
||||
|
||||
## 阶段划分
|
||||
- recording(记录期): Day 1-7,目标建立基线,正常记录但尝试控制
|
||||
- reducing(减量期): Day 8-21,目标逐步减少吸烟量,每周递减
|
||||
- consolidating(巩固期): Day 22-30,目标维持成果,准备最终戒烟
|
||||
|
||||
## 每日字段说明
|
||||
- day: 第几天(1-30)
|
||||
- stage: 阶段(recording/reducing/consolidating)
|
||||
- target_cigs: 当天目标吸烟量(整数)
|
||||
- target_reduce: 是否比前一天减少
|
||||
- advice: 当天的具体建议(50-100字,包含心理建设、替代行为、触发应对等)
|
||||
|
||||
## 生成策略
|
||||
1. 根据用户的 baseline_cigs_per_day 生成递减目标
|
||||
2. 记录期(Day1-7):目标 = baseline,允许小幅波动
|
||||
3. 减量期(Day8-21):逐步递减,最终降到 baseline 的 30-50%
|
||||
4. 巩固期(Day22-30):维持或接近归零
|
||||
5. 建议要个性化,结合用户的戒烟动力、抽烟动机、作息时间
|
||||
6. 用中文输出
|
||||
`)
|
||||
|
||||
smokeMotivations := "无"
|
||||
if len(profile.SmokeMotivations) > 0 {
|
||||
smokeMotivations = strings.Join(profile.SmokeMotivations, "、")
|
||||
}
|
||||
quitMotivations := "无"
|
||||
if len(profile.QuitMotivations) > 0 {
|
||||
quitMotivations = strings.Join(profile.QuitMotivations, "、")
|
||||
}
|
||||
schedule := "未设置"
|
||||
if profile.WakeUpTime != "" && profile.SleepTime != "" {
|
||||
schedule = fmt.Sprintf("%s - %s", profile.WakeUpTime, profile.SleepTime)
|
||||
}
|
||||
|
||||
userPrompt := fmt.Sprintf(`用户画像:
|
||||
- 日均吸烟量:%d 支
|
||||
- 烟龄:%.1f 年
|
||||
- 单包价格:%d 分
|
||||
- 抽烟动机:%s
|
||||
- 戒烟动力:%s
|
||||
- 作息时间:%s
|
||||
- 用户分段:%s
|
||||
|
||||
请生成30天戒烟计划。`, profile.BaselineCigsPerDay, profile.SmokingYears, profile.PackPriceCent, smokeMotivations, quitMotivations, schedule, profile.UserSegment)
|
||||
|
||||
reqBody := quitPlanChatCompletionRequest{
|
||||
Model: s.cfg.Model,
|
||||
Messages: []quitPlanChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
Temperature: 0.7,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, "", nil, nil, fmt.Errorf("marshal ai request: %w", err)
|
||||
}
|
||||
|
||||
endpoint := strings.TrimRight(s.cfg.BaseURL, "/") + "/chat/completions"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, "", nil, nil, fmt.Errorf("build ai request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.APIKey)
|
||||
|
||||
resp, err := s.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, "", nil, nil, fmt.Errorf("call ai: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", nil, nil, fmt.Errorf("read ai response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", nil, nil, fmt.Errorf("ai http %d: %s", resp.StatusCode, quitPlanTruncateString(string(body), 512))
|
||||
}
|
||||
|
||||
var parsed quitPlanChatCompletionResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, "", nil, nil, fmt.Errorf("parse ai response: %w", err)
|
||||
}
|
||||
if len(parsed.Choices) == 0 {
|
||||
return nil, "", nil, nil, errors.New("ai response has no choices")
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(parsed.Choices[0].Message.Content)
|
||||
if content == "" {
|
||||
return nil, "", nil, nil, errors.New("ai response content is empty")
|
||||
}
|
||||
|
||||
// 解析 JSON
|
||||
// 尝试提取 JSON 部分(可能包含markdown代码块)
|
||||
jsonStr := quitPlanExtractJSON(content)
|
||||
var aiResp QuitPlanAIResponse
|
||||
if err := json.Unmarshal([]byte(jsonStr), &aiResp); err != nil {
|
||||
return nil, "", nil, nil, fmt.Errorf("parse ai json: %w, content: %s", err, quitPlanTruncateString(content, 500))
|
||||
}
|
||||
|
||||
// 验证数据
|
||||
if len(aiResp.Days) != 30 {
|
||||
return nil, "", nil, nil, fmt.Errorf("expected 30 days, got %d", len(aiResp.Days))
|
||||
}
|
||||
|
||||
modelName := parsed.Model
|
||||
if modelName == "" {
|
||||
modelName = s.cfg.Model
|
||||
}
|
||||
|
||||
var tokensIn, tokensOut *int
|
||||
if parsed.Usage != nil {
|
||||
tokensIn = &parsed.Usage.PromptTokens
|
||||
tokensOut = &parsed.Usage.CompletionTokens
|
||||
}
|
||||
|
||||
return &aiResp, modelName, tokensIn, tokensOut, nil
|
||||
}
|
||||
|
||||
// quitPlanChatMessage AI 聊天消息
|
||||
type quitPlanChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// quitPlanChatCompletionRequest AI 请求
|
||||
type quitPlanChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []quitPlanChatMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
// quitPlanChatCompletionResponse AI 响应
|
||||
type quitPlanChatCompletionResponse struct {
|
||||
Model string `json:"model"`
|
||||
Choices []struct {
|
||||
Message quitPlanChatMessage `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// quitPlanExtractJSON 从可能包含 markdown 代码块的文本中提取 JSON
|
||||
func quitPlanExtractJSON(content string) string {
|
||||
// 尝试查找 JSON 代码块
|
||||
start := strings.Index(content, "```json")
|
||||
if start >= 0 {
|
||||
content = content[start+7:]
|
||||
end := strings.Index(content, "```")
|
||||
if end >= 0 {
|
||||
content = content[:end]
|
||||
}
|
||||
} else {
|
||||
start = strings.Index(content, "```")
|
||||
if start >= 0 {
|
||||
content = content[start+3:]
|
||||
end := strings.Index(content, "```")
|
||||
if end >= 0 {
|
||||
content = content[:end]
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
// quitPlanDeriveUserSegment 推导用户分段
|
||||
func quitPlanDeriveUserSegment(baselineCigsPerDay int, smokingYears float64) string {
|
||||
if baselineCigsPerDay >= 20 || smokingYears >= 10 {
|
||||
return "heavy"
|
||||
}
|
||||
if baselineCigsPerDay >= 10 || smokingYears >= 3 {
|
||||
return "moderate"
|
||||
}
|
||||
return "newbie"
|
||||
}
|
||||
|
||||
// quitPlanTruncateString 截断字符串
|
||||
func quitPlanTruncateString(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
Reference in New Issue
Block a user