Files
wx_service/internal/smoke/service/smoke_ai_next_smoke_service.go
T
你çšnepiedg 12619aa4ab feat(database): add repairSmokeAIAdviceIndexes function and corresponding tests
- Implemented repairSmokeAIAdviceIndexes to manage the unique index for fa_smoke_ai_advice.
- Added unit tests for the new function to ensure correct index recreation and validation.
- Updated AutoMigrate to include the new index repair function.
2026-03-16 15:35:32 +08:00

769 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"gorm.io/gorm"
"wx_service/config"
usermodel "wx_service/internal/model"
smokemodel "wx_service/internal/smoke/model"
)
var (
ErrAINextServiceDisabled = errors.New("ai service is not configured")
ErrAINextLocked = errors.New("ai next smoke is locked, ad unlock required")
)
type SmokeAINextSmokeService struct {
db *gorm.DB
cfg config.AIConfig
client *http.Client
}
func NewSmokeAINextSmokeService(db *gorm.DB, cfg config.AIConfig) *SmokeAINextSmokeService {
timeout := cfg.RequestTimeout
if timeout <= 0 {
timeout = 15 * time.Second
}
return &SmokeAINextSmokeService{
db: db,
cfg: cfg,
client: &http.Client{
Timeout: timeout,
},
}
}
type aiNextSmokeInput struct {
AsOf string `json:"as_of"`
PlanDate string `json:"plan_date"`
MinNotBeforeAt string `json:"min_not_before_at"`
DefaultSuggestion NextSmokeSuggestion `json:"default_suggestion"`
Profile *adviceUserProfile `json:"profile,omitempty"`
Recent3Days []recentDaySnapshot `json:"recent_3_days"`
}
type aiNextSmokeOutput struct {
NotBeforeAt string `json:"not_before_at"`
SuggestedAt string `json:"suggested_at"`
TimeNodes []string `json:"time_nodes"`
Advice string `json:"advice"`
}
type recentDaySnapshot struct {
Date string `json:"date"`
TotalNum int `json:"total_num"`
ResistedCount int `json:"resisted_count"`
Nodes []recentDayNode `json:"nodes"`
}
type recentDayNode struct {
Time string `json:"time"`
Num int `json:"num"`
Level int64 `json:"level"`
IsResisted bool `json:"is_resisted"`
Remark string `json:"remark,omitempty"`
}
type AINextSmokeSuggestion struct {
PlanDate string `json:"plan_date"`
NotBeforeAt string `json:"not_before_at"`
SuggestedAt string `json:"suggested_at"`
TimeNodes []string `json:"time_nodes"`
Advice string `json:"advice"`
PromptVersion string `json:"prompt_version"`
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
}
func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *usermodel.User, asOf time.Time, planDate time.Time, promptVersion string, defaultSuggestion NextSmokeSuggestion) (AINextSmokeSuggestion, error) {
if promptVersion == "" {
promptVersion = "v1"
}
planDate = dateOnly(planDate)
cachedAdvice, err := s.getCachedAdvice(ctx, int(user.ID), planDate, promptVersion)
if err != nil {
return AINextSmokeSuggestion{}, err
}
var cachedSuggestion *AINextSmokeSuggestion
if cachedAdvice != nil {
v, buildErr := s.buildFromCache(ctx, cachedAdvice)
if buildErr != nil {
log.Printf("[smoke_ai_next] cache_build_failed uid=%d plan_date=%s advice_id=%d err=%v", user.ID, planDate.Format("2006-01-02"), cachedAdvice.ID, buildErr)
} else if !s.shouldRefreshCache(asOf, planDate, v) {
log.Printf("[smoke_ai_next] cache_hit uid=%d plan_date=%s advice_id=%d", user.ID, planDate.Format("2006-01-02"), cachedAdvice.ID)
return v, nil
} else {
log.Printf("[smoke_ai_next] cache_stale uid=%d plan_date=%s advice_id=%d suggested_at=%s nodes=%d", user.ID, planDate.Format("2006-01-02"), cachedAdvice.ID, v.SuggestedAt, len(v.TimeNodes))
cachedSuggestion = &v
}
}
if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" {
if cachedSuggestion != nil {
log.Printf("[smoke_ai_next] ai_disabled_reuse_cache uid=%d plan_date=%s", user.ID, planDate.Format("2006-01-02"))
return *cachedSuggestion, nil
}
return AINextSmokeSuggestion{}, ErrAINextServiceDisabled
}
allowed, err := s.isAllowed(ctx, user, planDate)
if err != nil {
if cachedSuggestion != nil {
log.Printf("[smoke_ai_next] allow_check_failed_reuse_cache uid=%d plan_date=%s err=%v", user.ID, planDate.Format("2006-01-02"), err)
return *cachedSuggestion, nil
}
return AINextSmokeSuggestion{}, err
}
if !allowed {
if cachedSuggestion != nil {
log.Printf("[smoke_ai_next] no_refresh_permission_reuse_cache uid=%d plan_date=%s", user.ID, planDate.Format("2006-01-02"))
return *cachedSuggestion, nil
}
return AINextSmokeSuggestion{}, ErrAINextLocked
}
// 尝试复用 profile(用于 sleep window / 动机动力);如果读取失败则忽略。
profile := loadAdviceUserProfile(ctx, s.db, int(user.ID))
recent, err := s.loadRecent3Days(ctx, int(user.ID), planDate)
if err != nil {
return AINextSmokeSuggestion{}, err
}
minNotBefore := s.computeMinNotBefore(asOf, planDate, defaultSuggestion, profile)
input := aiNextSmokeInput{
AsOf: asOf.In(time.Local).Format(time.RFC3339),
PlanDate: planDate.Format("2006-01-02"),
MinNotBeforeAt: minNotBefore.In(time.Local).Format(time.RFC3339),
DefaultSuggestion: defaultSuggestion,
Profile: profile,
Recent3Days: recent,
}
inputJSON, _ := json.Marshal(input)
output, outputJSON, modelName, tokensIn, tokensOut, err := s.callAI(ctx, int(user.ID), input)
if err != nil {
return AINextSmokeSuggestion{}, err
}
notBeforeAt, err := parseFlexibleTime(output.NotBeforeAt, planDate)
if err != nil {
return AINextSmokeSuggestion{}, fmt.Errorf("parse not_before_at: %w", err)
}
suggestedAt, err := parseFlexibleTime(output.SuggestedAt, planDate)
if err != nil {
return AINextSmokeSuggestion{}, fmt.Errorf("parse suggested_at: %w", err)
}
// 强制:AI 的“不早于”不可以早于 minNotBefore(避免比默认更激进,且支持明天计划)。
if notBeforeAt.Before(minNotBefore) {
notBeforeAt = minNotBefore
}
if suggestedAt.Before(notBeforeAt) {
suggestedAt = notBeforeAt
}
// 避免睡眠时间:若 profile 有作息,则把 not_before/suggested 落在睡眠区间的情况顺延到起床。
if profile != nil && strings.TrimSpace(profile.WakeUpTime) != "" && strings.TrimSpace(profile.SleepTime) != "" {
if adjusted, ok, _ := adjustToWakeIfInSleep(notBeforeAt, profile.WakeUpTime, profile.SleepTime); ok {
notBeforeAt = adjusted
}
if adjusted, ok, _ := adjustToWakeIfInSleep(suggestedAt, profile.WakeUpTime, profile.SleepTime); ok {
suggestedAt = adjusted
}
if suggestedAt.Before(notBeforeAt) {
suggestedAt = notBeforeAt
}
}
nowUnix := time.Now().Unix()
createTime := nowUnix
updateTime := nowUnix
// 1) 写入 AI 元信息与建议(复用 fa_smoke_ai_advice + type 区分)
adviceRecord := smokemodel.SmokeAIAdvice{
UID: int(user.ID),
Type: SmokeAIAdviceTypeNextSmoke,
AdviceDate: planDate,
PromptVersion: promptVersion,
Provider: "openai-compatible",
Model: modelName,
InputSnapshot: inputJSON,
Advice: strings.TrimSpace(output.Advice),
TokensIn: tokensIn,
TokensOut: tokensOut,
UpdateTime: &updateTime,
}
if cachedAdvice == nil {
adviceRecord.CreateTime = &createTime
if err := s.db.WithContext(ctx).Create(&adviceRecord).Error; err != nil {
return AINextSmokeSuggestion{}, fmt.Errorf("save ai next smoke advice: %w", err)
}
log.Printf("[smoke_ai_next] cache_write_create uid=%d plan_date=%s advice_id=%d", user.ID, planDate.Format("2006-01-02"), adviceRecord.ID)
} else {
adviceRecord.ID = cachedAdvice.ID
adviceRecord.CreateTime = cachedAdvice.CreateTime
var tokensInValue interface{}
if tokensIn != nil {
tokensInValue = *tokensIn
}
var tokensOutValue interface{}
if tokensOut != nil {
tokensOutValue = *tokensOut
}
if err := s.db.WithContext(ctx).
Model(&smokemodel.SmokeAIAdvice{}).
Where("id = ?", cachedAdvice.ID).
Updates(map[string]interface{}{
"provider": adviceRecord.Provider,
"model": adviceRecord.Model,
"input_snapshot": adviceRecord.InputSnapshot,
"advice": adviceRecord.Advice,
"tokens_in": tokensInValue,
"tokens_out": tokensOutValue,
"updatetime": updateTime,
}).Error; err != nil {
return AINextSmokeSuggestion{}, fmt.Errorf("refresh ai next smoke advice: %w", err)
}
log.Printf("[smoke_ai_next] cache_write_refresh uid=%d plan_date=%s advice_id=%d", user.ID, planDate.Format("2006-01-02"), adviceRecord.ID)
}
// 2) 写入时间节点(每个时间点一条)
nodes, err := s.normalizeNodes(output.TimeNodes, asOf, planDate, notBeforeAt, profile)
if err != nil {
return AINextSmokeSuggestion{}, err
}
if len(nodes) > 0 {
if firstNodeAt, err := parseFlexibleTime(nodes[0], planDate); err == nil {
suggestedAt = firstNodeAt.In(time.Local)
if suggestedAt.Before(notBeforeAt) {
suggestedAt = notBeforeAt
}
}
}
if cachedAdvice != nil {
if err := s.db.WithContext(ctx).
Where("ai_advice_id = ?", adviceRecord.ID).
Delete(&smokemodel.SmokeAINextSmoke{}).Error; err != nil {
return AINextSmokeSuggestion{}, fmt.Errorf("clear old ai next smoke nodes: %w", err)
}
}
if err := s.saveNodes(ctx, int(user.ID), planDate, adviceRecord.ID, notBeforeAt, suggestedAt, nodes); err != nil {
return AINextSmokeSuggestion{}, err
}
_ = outputJSON
return AINextSmokeSuggestion{
PlanDate: planDate.Format("2006-01-02"),
NotBeforeAt: notBeforeAt.In(time.Local).Format(time.RFC3339),
SuggestedAt: suggestedAt.In(time.Local).Format(time.RFC3339),
TimeNodes: nodes,
Advice: adviceRecord.Advice,
PromptVersion: adviceRecord.PromptVersion,
Model: adviceRecord.Model,
Provider: adviceRecord.Provider,
}, nil
}
func (s *SmokeAINextSmokeService) GetCached(ctx context.Context, user *usermodel.User, planDate time.Time, promptVersion string) (AINextSmokeSuggestion, bool, error) {
if promptVersion == "" {
promptVersion = "v1"
}
planDate = dateOnly(planDate)
cachedAdvice, err := s.getCachedAdvice(ctx, int(user.ID), planDate, promptVersion)
if err != nil {
return AINextSmokeSuggestion{}, false, err
}
if cachedAdvice == nil {
return AINextSmokeSuggestion{}, false, nil
}
suggestion, err := s.buildFromCache(ctx, cachedAdvice)
if err != nil {
return AINextSmokeSuggestion{}, false, err
}
if s.shouldRefreshCache(time.Now().In(time.Local), planDate, suggestion) {
log.Printf("[smoke_ai_next] cache_auto_expired uid=%d plan_date=%s advice_id=%d", user.ID, planDate.Format("2006-01-02"), cachedAdvice.ID)
return AINextSmokeSuggestion{}, false, nil
}
log.Printf("[smoke_ai_next] cache_auto_hit uid=%d plan_date=%s advice_id=%d", user.ID, planDate.Format("2006-01-02"), cachedAdvice.ID)
return suggestion, true, nil
}
func (s *SmokeAINextSmokeService) getCachedAdvice(ctx context.Context, uid int, planDate time.Time, promptVersion string) (*smokemodel.SmokeAIAdvice, error) {
var record smokemodel.SmokeAIAdvice
err := s.db.WithContext(ctx).
Where("uid = ? AND type = ? AND advice_date = ? AND prompt_version = ? AND (deletetime IS NULL OR deletetime = 0)",
uid, SmokeAIAdviceTypeNextSmoke, dateOnly(planDate).Format("2006-01-02"), promptVersion).
Order("id DESC").
First(&record).Error
if err == nil {
return &record, nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, fmt.Errorf("load cached ai next smoke advice: %w", err)
}
func (s *SmokeAINextSmokeService) buildFromCache(ctx context.Context, advice *smokemodel.SmokeAIAdvice) (AINextSmokeSuggestion, error) {
nodes, notBeforeAt, suggestedAt, err := s.loadNodes(ctx, advice.ID)
if err != nil {
return AINextSmokeSuggestion{}, err
}
return AINextSmokeSuggestion{
PlanDate: dateOnly(advice.AdviceDate).Format("2006-01-02"),
NotBeforeAt: notBeforeAt.In(time.Local).Format(time.RFC3339),
SuggestedAt: suggestedAt.In(time.Local).Format(time.RFC3339),
TimeNodes: nodes,
Advice: advice.Advice,
PromptVersion: advice.PromptVersion,
Model: advice.Model,
Provider: advice.Provider,
}, nil
}
func (s *SmokeAINextSmokeService) loadNodes(ctx context.Context, aiAdviceID uint) ([]string, time.Time, time.Time, error) {
var rows []smokemodel.SmokeAINextSmoke
if err := s.db.WithContext(ctx).
Where("ai_advice_id = ? AND (deletetime IS NULL OR deletetime = 0)", aiAdviceID).
Order("node_at ASC").
Find(&rows).Error; err != nil {
return nil, time.Time{}, time.Time{}, fmt.Errorf("load ai next nodes: %w", err)
}
var notBeforeAt, suggestedAt time.Time
var nodes []string
for _, r := range rows {
switch r.NodeType {
case "not_before":
notBeforeAt = r.NodeAt.In(time.Local)
case "suggested":
suggestedAt = r.NodeAt.In(time.Local)
case "node":
nodes = append(nodes, r.NodeAt.In(time.Local).Format("15:04"))
}
}
if notBeforeAt.IsZero() {
notBeforeAt = time.Now().In(time.Local)
}
if suggestedAt.IsZero() {
suggestedAt = notBeforeAt
}
return nodes, notBeforeAt, suggestedAt, nil
}
func (s *SmokeAINextSmokeService) normalizeNodes(raw []string, asOf time.Time, planDate time.Time, notBeforeAt time.Time, profile *adviceUserProfile) ([]string, error) {
now := asOf.In(time.Local)
seen := map[string]bool{}
out := make([]string, 0, 6)
for _, v := range raw {
if len(out) >= 6 {
break
}
t, err := parseFlexibleTime(v, planDate)
if err != nil {
continue
}
t = t.In(time.Local)
if dateOnly(t) != dateOnly(planDate) {
continue
}
// 计划日期=今天:不能早于当前时间;计划日期=明天:这里通常不会早于 now。
if t.Before(now) && dateOnly(planDate).Equal(dateOnly(now)) {
continue
}
if t.Before(notBeforeAt) {
continue
}
if profile != nil && strings.TrimSpace(profile.WakeUpTime) != "" && strings.TrimSpace(profile.SleepTime) != "" {
if adjusted, ok, _ := adjustToWakeIfInSleep(t, profile.WakeUpTime, profile.SleepTime); ok {
t = adjusted
}
if t.Before(notBeforeAt) {
continue
}
if dateOnly(t) != dateOnly(planDate) {
continue
}
}
label := t.Format("15:04")
if seen[label] {
continue
}
seen[label] = true
out = append(out, label)
}
return out, nil
}
func (s *SmokeAINextSmokeService) shouldRefreshCache(asOf time.Time, planDate time.Time, suggestion AINextSmokeSuggestion) bool {
if len(suggestion.TimeNodes) == 0 {
return true
}
suggestedAt, err := parseFlexibleTime(suggestion.SuggestedAt, planDate)
if err != nil {
return true
}
asOf = asOf.In(time.Local)
planDate = dateOnly(planDate)
if dateOnly(suggestedAt) != planDate {
return true
}
// 当天建议如果已经到点(或过期),视为缓存失效,触发刷新。
if planDate.Equal(dateOnly(asOf)) && !suggestedAt.After(asOf.Add(2*time.Minute)) {
return true
}
return false
}
func (s *SmokeAINextSmokeService) computeMinNotBefore(asOf time.Time, planDate time.Time, defaultSuggestion NextSmokeSuggestion, profile *adviceUserProfile) time.Time {
asOf = asOf.In(time.Local)
planDate = dateOnly(planDate)
today := dateOnly(asOf)
if planDate.After(today) {
min := time.Date(planDate.Year(), planDate.Month(), planDate.Day(), 7, 0, 0, 0, time.Local)
if profile != nil && strings.TrimSpace(profile.WakeUpTime) != "" {
if m, err := parseHHMMToMinutes(profile.WakeUpTime); err == nil {
min = time.Date(planDate.Year(), planDate.Month(), planDate.Day(), m/60, m%60, 0, 0, time.Local)
}
}
return min
}
if defaultSuggestion.NextSmokeAt != nil && !defaultSuggestion.NextSmokeAt.IsZero() {
return defaultSuggestion.NextSmokeAt.In(time.Local)
}
return asOf.Add(5 * time.Minute)
}
func (s *SmokeAINextSmokeService) loadRecent3Days(ctx context.Context, uid int, planDate time.Time) ([]recentDaySnapshot, error) {
planDate = dateOnly(planDate)
today := dateOnly(time.Now().In(time.Local))
end := planDate
if end.After(today) {
end = today
}
start := end.AddDate(0, 0, -2)
var logs []smokemodel.SmokeLog
if err := s.db.WithContext(ctx).
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")).
Order("smoke_time ASC").
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime)) ASC").
Order("id ASC").
Find(&logs).Error; err != nil {
return nil, fmt.Errorf("load recent logs: %w", err)
}
byDay := map[string]*recentDaySnapshot{}
ensure := func(day time.Time) *recentDaySnapshot {
key := dateOnly(day).Format("2006-01-02")
if existing, ok := byDay[key]; ok {
return existing
}
snap := &recentDaySnapshot{Date: key, Nodes: []recentDayNode{}}
byDay[key] = snap
return snap
}
for _, l := range logs {
day := start
if l.SmokeTime != nil {
day = dateOnly(*l.SmokeTime)
}
snap := ensure(day)
isResisted := l.Level == 0 && l.Num == 0
if isResisted {
snap.ResistedCount++
} else if l.Num > 0 {
snap.TotalNum += l.Num
}
if len(snap.Nodes) >= 50 {
continue
}
eventAt, ok := lastEventTime(l)
timeLabel := ""
if ok {
timeLabel = eventAt.In(time.Local).Format("15:04")
}
remark := strings.TrimSpace(l.Remark)
if len(remark) > 80 {
remark = remark[:80]
}
snap.Nodes = append(snap.Nodes, recentDayNode{
Time: timeLabel,
Num: l.Num,
Level: l.Level,
IsResisted: isResisted,
Remark: remark,
})
}
out := make([]recentDaySnapshot, 0, 3)
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
key := d.Format("2006-01-02")
if snap, ok := byDay[key]; ok {
out = append(out, *snap)
} else {
out = append(out, recentDaySnapshot{Date: key, Nodes: []recentDayNode{}})
}
}
return out, nil
}
func (s *SmokeAINextSmokeService) isAllowed(ctx context.Context, user *usermodel.User, planDate time.Time) (bool, error) {
isVIP, err := hasActiveMembership(ctx, s.db, user.MiniProgramID, user.ID, time.Now())
if err != nil {
return false, err
}
if isVIP {
return true, nil
}
return s.isUnlocked(ctx, int(user.ID), planDate)
}
func (s *SmokeAINextSmokeService) isUnlocked(ctx context.Context, uid int, planDate time.Time) (bool, error) {
startOfDay := dateOnly(planDate)
var unlock smokemodel.SmokeAIAdviceUnlock
err := s.db.WithContext(ctx).
Where("uid = ? AND unlock_date = ? AND (deletetime IS NULL OR deletetime = 0)", uid, startOfDay.Format("2006-01-02")).
First(&unlock).Error
if err == nil {
return true, nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, fmt.Errorf("check unlock: %w", err)
}
func (s *SmokeAINextSmokeService) saveNodes(ctx context.Context, uid int, planDate time.Time, aiAdviceID uint, notBeforeAt time.Time, suggestedAt time.Time, nodes []string) error {
nowUnix := time.Now().Unix()
createTime := nowUnix
updateTime := nowUnix
rows := []smokemodel.SmokeAINextSmoke{
{
UID: uid,
PlanDate: planDate,
AIAdviceID: aiAdviceID,
NodeType: "not_before",
NodeAt: notBeforeAt,
CreateTime: &createTime,
UpdateTime: &updateTime,
},
{
UID: uid,
PlanDate: planDate,
AIAdviceID: aiAdviceID,
NodeType: "suggested",
NodeAt: suggestedAt,
CreateTime: &createTime,
UpdateTime: &updateTime,
},
}
ref := planDate.In(time.Local)
for _, label := range nodes {
t, err := parseFlexibleTime(label, ref)
if err != nil {
continue
}
rows = append(rows, smokemodel.SmokeAINextSmoke{
UID: uid,
PlanDate: planDate,
AIAdviceID: aiAdviceID,
NodeType: "node",
NodeAt: t.In(time.Local),
CreateTime: &createTime,
UpdateTime: &updateTime,
})
}
if err := s.db.WithContext(ctx).Create(&rows).Error; err != nil {
return fmt.Errorf("save ai next smoke nodes: %w", err)
}
return nil
}
func (s *SmokeAINextSmokeService) callAI(ctx context.Context, uid int, input aiNextSmokeInput) (aiNextSmokeOutput, []byte, string, *int, *int, error) {
requestModel := preferredSmokeAIModel(s.cfg.Model)
systemPrompt := strings.TrimSpace(`
你是一名专业的戒烟教练与行为改变顾问。你将收到一段 JSON,包含:
- 现在时间(as_of)
- 计划日期(plan_date):你输出的时间必须属于该日期
- 最小不早于时间(min_not_before_at):你输出的 not_before_at 必须 >= 该值
- 最近3天数据(recent_3_days):用于判断近期模式(含忍住记录)
- 后端默认策略(default_suggestion):仅供参考
- 可选的用户 profile:包含作息时间、动机/动力
你必须只输出一段严格的 JSON(不要 Markdown、不要解释文字),格式:
{
"not_before_at": "RFC3339 时间字符串(含时区)",
"suggested_at": "RFC3339 时间字符串(含时区)",
"time_nodes": ["HH:MM", "HH:MM", ...],
"advice": "一句话建议(<=200字)"
}
约束:
1) not_before_at 必须 >= min_not_before_at
2) suggested_at 必须 >= not_before_at
3) 如果 profile 提供了 wake_up_time/sleep_time,建议时间与 time_nodes 不要落在睡眠区间;如不可避免,顺延到起床后;
4) time_nodes 只需要给出 plan_date 的 3~6 个“关键节点”(字符串 HH:MM),并且都不早于 not_before_at。
`)
userPrompt := fmt.Sprintf("输入(JSON)\n%s", mustJSON(input))
appendSmokeAIDebugLog("next_smoke.request", map[string]interface{}{
"uid": uid,
"model": requestModel,
"system_prompt": systemPrompt,
"user_prompt": userPrompt,
"input": input,
})
reqBody := chatCompletionRequest{
Model: requestModel,
Messages: []chatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
Temperature: defaultTemperature,
}
payload, err := json.Marshal(reqBody)
if err != nil {
return aiNextSmokeOutput{}, 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 aiNextSmokeOutput{}, 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 aiNextSmokeOutput{}, nil, "", nil, nil, fmt.Errorf("call ai: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return aiNextSmokeOutput{}, nil, "", nil, nil, fmt.Errorf("read ai response: %w", err)
}
appendSmokeAIDebugLog("next_smoke.response", map[string]interface{}{
"uid": uid,
"model": requestModel,
"http_status": resp.StatusCode,
"response_body": string(body),
})
if resp.StatusCode != http.StatusOK {
return aiNextSmokeOutput{}, nil, "", nil, nil, fmt.Errorf("ai http %d: %s", resp.StatusCode, truncateString(string(body), 512))
}
var parsed chatCompletionResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return aiNextSmokeOutput{}, nil, "", nil, nil, fmt.Errorf("parse ai response: %w", err)
}
if len(parsed.Choices) == 0 {
return aiNextSmokeOutput{}, nil, "", nil, nil, errors.New("ai response has no choices")
}
content := strings.TrimSpace(parsed.Choices[0].Message.Content)
if content == "" {
return aiNextSmokeOutput{}, nil, "", nil, nil, errors.New("ai response content is empty")
}
jsonPart := extractJSONObject(content)
if jsonPart == "" {
return aiNextSmokeOutput{}, nil, "", nil, nil, fmt.Errorf("ai response is not json: %s", truncateString(content, 256))
}
var out aiNextSmokeOutput
if err := json.Unmarshal([]byte(jsonPart), &out); err != nil {
return aiNextSmokeOutput{}, nil, "", nil, nil, fmt.Errorf("unmarshal ai json: %w", err)
}
modelName := parsed.Model
if modelName == "" {
modelName = requestModel
}
var tokensIn, tokensOut *int
if parsed.Usage != nil {
tokensIn = &parsed.Usage.PromptTokens
tokensOut = &parsed.Usage.CompletionTokens
}
return out, []byte(jsonPart), modelName, tokensIn, tokensOut, nil
}
func extractJSONObject(s string) string {
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end < 0 || end <= start {
return ""
}
return s[start : end+1]
}
func parseFlexibleTime(value string, ref time.Time) (time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, errors.New("empty time")
}
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t.In(time.Local), nil
}
if t, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local); err == nil {
return t.In(time.Local), nil
}
// 仅提供 HH:MM:使用 ref 的日期
if len(value) == 5 && value[2] == ':' {
min, err := parseHHMMToMinutes(value)
if err != nil {
return time.Time{}, err
}
r := ref.In(time.Local)
return time.Date(r.Year(), r.Month(), r.Day(), min/60, min%60, 0, 0, time.Local), nil
}
return time.Time{}, fmt.Errorf("unsupported time format: %s", value)
}