6cf7eb2294
- Added new API endpoint `GET /api/v1/smoke/next_smoke_time` to provide AI-generated suggestions for the next smoking time based on user data. - Introduced a new database table `fa_smoke_ai_next_smoke` to store structured AI time node suggestions. - Updated smoke handler and service to integrate the new AI next smoke time functionality. - Enhanced documentation to reflect the new API endpoint and its usage, including details on how to generate AI time nodes.
667 lines
20 KiB
Go
667 lines
20 KiB
Go
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 (
|
||
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
|
||
}
|
||
if cachedAdvice != nil {
|
||
return s.buildFromCache(ctx, cachedAdvice)
|
||
}
|
||
|
||
if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" {
|
||
return AINextSmokeSuggestion{}, ErrAINextServiceDisabled
|
||
}
|
||
|
||
allowed, err := s.isAllowed(ctx, user, planDate)
|
||
if err != nil {
|
||
return AINextSmokeSuggestion{}, err
|
||
}
|
||
if !allowed {
|
||
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, 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,
|
||
CreateTime: &createTime,
|
||
UpdateTime: &updateTime,
|
||
}
|
||
|
||
if err := s.db.WithContext(ctx).Create(&adviceRecord).Error; err != nil {
|
||
return AINextSmokeSuggestion{}, fmt.Errorf("save ai next smoke advice: %w", err)
|
||
}
|
||
|
||
// 2) 写入时间节点(每个时间点一条)
|
||
nodes, err := s.normalizeNodes(output.TimeNodes, asOf, planDate, notBeforeAt, profile)
|
||
if err != nil {
|
||
return AINextSmokeSuggestion{}, 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
|
||
}
|
||
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).
|
||
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) 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 := s.isVIP(ctx, user)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if isVIP {
|
||
return true, nil
|
||
}
|
||
return s.isUnlocked(ctx, int(user.ID), planDate)
|
||
}
|
||
|
||
func (s *SmokeAINextSmokeService) isVIP(ctx context.Context, user *usermodel.User) (bool, error) {
|
||
now := time.Now()
|
||
var count int64
|
||
if err := s.db.WithContext(ctx).
|
||
Model(&usermodel.UserMembership{}).
|
||
Where("mini_program_id = ? AND user_id = ? AND status = ? AND ends_at > ?",
|
||
user.MiniProgramID, user.ID, "active", now).
|
||
Count(&count).Error; err != nil {
|
||
return false, fmt.Errorf("check vip: %w", err)
|
||
}
|
||
return count > 0, nil
|
||
}
|
||
|
||
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, input aiNextSmokeInput) (aiNextSmokeOutput, []byte, string, *int, *int, error) {
|
||
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))
|
||
|
||
reqBody := chatCompletionRequest{
|
||
Model: s.cfg.Model,
|
||
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)
|
||
}
|
||
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 = s.cfg.Model
|
||
}
|
||
|
||
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)
|
||
}
|