diff --git a/internal/database/database.go b/internal/database/database.go index 53138fb..8e2d264 100755 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -102,6 +102,9 @@ func AutoMigrate(models ...interface{}) error { if err := repairSmokeAINextSmokeIndexes(DB); err != nil { return err } + if err := repairSmokeAIAdviceIndexes(DB); err != nil { + return err + } return nil } diff --git a/internal/database/smoke_index_migration.go b/internal/database/smoke_index_migration.go index 3196bea..adf217c 100644 --- a/internal/database/smoke_index_migration.go +++ b/internal/database/smoke_index_migration.go @@ -56,3 +56,47 @@ func repairSmokeAINextSmokeIndexes(db *gorm.DB) error { return nil } + +func repairSmokeAIAdviceIndexes(db *gorm.DB) error { + if db == nil { + return nil + } + + const ( + tableName = "fa_smoke_ai_advice" + indexName = "uniq_smoke_ai_advice" + ) + + var rows []mysqlIndexColumn + if err := db.Raw(fmt.Sprintf("SHOW INDEX FROM `%s` WHERE Key_name = ?", tableName), indexName).Scan(&rows).Error; err != nil { + return fmt.Errorf("inspect %s: %w", indexName, err) + } + + expected := []string{"uid", "type", "advice_date", "prompt_version"} + actual := make([]string, 0, len(rows)) + for _, row := range rows { + actual = append(actual, row.ColumnName) + } + + if slices.Equal(actual, expected) { + return nil + } + + if len(rows) > 0 { + if err := db.Exec(fmt.Sprintf("ALTER TABLE `%s` DROP INDEX `%s`", tableName, indexName)).Error; err != nil { + return fmt.Errorf("drop %s: %w", indexName, err) + } + } + + if err := db.Exec( + fmt.Sprintf( + "ALTER TABLE `%s` ADD UNIQUE KEY `%s` (`uid`,`type`,`advice_date`,`prompt_version`)", + tableName, + indexName, + ), + ).Error; err != nil { + return fmt.Errorf("create %s: %w", indexName, err) + } + + return nil +} diff --git a/internal/database/smoke_index_migration_test.go b/internal/database/smoke_index_migration_test.go index a5bc991..df2ab4c 100644 --- a/internal/database/smoke_index_migration_test.go +++ b/internal/database/smoke_index_migration_test.go @@ -79,3 +79,54 @@ func TestRepairSmokeAINextSmokeIndexesKeepsCorrectIndex(t *testing.T) { t.Fatalf("unmet expectations: %v", err) } } + +func TestRepairSmokeAIAdviceIndexesRecreatesBrokenIndex(t *testing.T) { + t.Parallel() + + db, mock, cleanup := newMockDB(t) + defer cleanup() + + mock.ExpectQuery("SHOW INDEX FROM `fa_smoke_ai_advice` WHERE Key_name = \\?"). + WithArgs("uniq_smoke_ai_advice"). + WillReturnRows( + sqlmock.NewRows([]string{"Key_name", "Seq_in_index", "Column_name"}). + AddRow("uniq_smoke_ai_advice", 1, "uid"). + AddRow("uniq_smoke_ai_advice", 2, "advice_date"). + AddRow("uniq_smoke_ai_advice", 3, "prompt_version"), + ) + mock.ExpectExec("ALTER TABLE `fa_smoke_ai_advice` DROP INDEX `uniq_smoke_ai_advice`"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("ALTER TABLE `fa_smoke_ai_advice` ADD UNIQUE KEY `uniq_smoke_ai_advice` \\(`uid`,`type`,`advice_date`,`prompt_version`\\)"). + WillReturnResult(sqlmock.NewResult(0, 0)) + + if err := repairSmokeAIAdviceIndexes(db); err != nil { + t.Fatalf("repairSmokeAIAdviceIndexes: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet expectations: %v", err) + } +} + +func TestRepairSmokeAIAdviceIndexesKeepsCorrectIndex(t *testing.T) { + t.Parallel() + + db, mock, cleanup := newMockDB(t) + defer cleanup() + + mock.ExpectQuery("SHOW INDEX FROM `fa_smoke_ai_advice` WHERE Key_name = \\?"). + WithArgs("uniq_smoke_ai_advice"). + WillReturnRows( + sqlmock.NewRows([]string{"Key_name", "Seq_in_index", "Column_name"}). + AddRow("uniq_smoke_ai_advice", 1, "uid"). + AddRow("uniq_smoke_ai_advice", 2, "type"). + AddRow("uniq_smoke_ai_advice", 3, "advice_date"). + AddRow("uniq_smoke_ai_advice", 4, "prompt_version"), + ) + + if err := repairSmokeAIAdviceIndexes(db); err != nil { + t.Fatalf("repairSmokeAIAdviceIndexes: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet expectations: %v", err) + } +} diff --git a/internal/smoke/service/smoke_ai_advice_service.go b/internal/smoke/service/smoke_ai_advice_service.go index 69457b3..f300d20 100644 --- a/internal/smoke/service/smoke_ai_advice_service.go +++ b/internal/smoke/service/smoke_ai_advice_service.go @@ -112,7 +112,7 @@ func (s *SmokeAIAdviceService) GetOrGenerate(ctx context.Context, user *usermode return nil, err } - adviceText, modelName, tokensIn, tokensOut, err := s.callAI(ctx, snapshot) + adviceText, modelName, tokensIn, tokensOut, err := s.callAI(ctx, int(user.ID), snapshot) if err != nil { return nil, err } @@ -338,10 +338,11 @@ func loadAdviceUserProfile(ctx context.Context, db *gorm.DB, uid int) *adviceUse return &out } -func (s *SmokeAIAdviceService) callAI(ctx context.Context, snap adviceSnapshot) (string, string, *int, *int, error) { +func (s *SmokeAIAdviceService) callAI(ctx context.Context, uid int, snap adviceSnapshot) (string, string, *int, *int, error) { if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" { return "", "", nil, nil, ErrAIServiceDisabled } + requestModel := preferredSmokeAIModel(s.cfg.Model) systemPrompt := strings.TrimSpace(` 你是一名专业的戒烟教练与行为改变顾问。你需要基于用户昨天的抽烟总量与时间节点,给出可执行、可量化的戒烟/控烟建议。 @@ -358,9 +359,16 @@ func (s *SmokeAIAdviceService) callAI(ctx context.Context, snap adviceSnapshot) `) userPrompt := fmt.Sprintf("用户昨日数据(JSON):\n%s", mustJSON(snap)) + appendSmokeAIDebugLog("daily_advice.request", map[string]interface{}{ + "uid": uid, + "model": requestModel, + "system_prompt": systemPrompt, + "user_prompt": userPrompt, + "input": snap, + }) reqBody := chatCompletionRequest{ - Model: s.cfg.Model, + Model: requestModel, Messages: []chatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userPrompt}, @@ -391,6 +399,12 @@ func (s *SmokeAIAdviceService) callAI(ctx context.Context, snap adviceSnapshot) if err != nil { return "", "", nil, nil, fmt.Errorf("read ai response: %w", err) } + appendSmokeAIDebugLog("daily_advice.response", map[string]interface{}{ + "uid": uid, + "model": requestModel, + "http_status": resp.StatusCode, + "response_body": string(body), + }) if resp.StatusCode != http.StatusOK { return "", "", nil, nil, fmt.Errorf("ai http %d: %s", resp.StatusCode, truncateString(string(body), 512)) } @@ -407,10 +421,13 @@ func (s *SmokeAIAdviceService) callAI(ctx context.Context, snap adviceSnapshot) if content == "" { return "", "", nil, nil, errors.New("ai response content is empty") } + if jsonPart := extractJSONObject(content); jsonPart != "" { + content = jsonPart + } modelName := parsed.Model if modelName == "" { - modelName = s.cfg.Model + modelName = requestModel } var tokensIn, tokensOut *int @@ -518,7 +535,7 @@ func (s *SmokeAIAdviceService) GetOrGenerateDailySummary( return nil, err } - adviceText, modelName, tokensIn, tokensOut, err := s.callAIDailySummary(ctx, snapshot) + adviceText, modelName, tokensIn, tokensOut, err := s.callAIDailySummary(ctx, int(user.ID), snapshot) if err != nil { return nil, err } @@ -545,10 +562,11 @@ func (s *SmokeAIAdviceService) GetOrGenerateDailySummary( return &record, nil } -func (s *SmokeAIAdviceService) callAIDailySummary(ctx context.Context, snap adviceSnapshot) (string, string, *int, *int, error) { +func (s *SmokeAIAdviceService) callAIDailySummary(ctx context.Context, uid int, snap adviceSnapshot) (string, string, *int, *int, error) { if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" { return "", "", nil, nil, ErrAIServiceDisabled } + requestModel := preferredSmokeAIModel(s.cfg.Model) systemPrompt := strings.TrimSpace(` 你是一名专业的戒烟教练。请基于用户今天的抽烟数据,生成一份简洁的每日总结。 @@ -567,9 +585,16 @@ func (s *SmokeAIAdviceService) callAIDailySummary(ctx context.Context, snap advi `) userPrompt := fmt.Sprintf("用户今日抽烟数据(JSON):\n%s", mustJSON(snap)) + appendSmokeAIDebugLog("daily_summary.request", map[string]interface{}{ + "uid": uid, + "model": requestModel, + "system_prompt": systemPrompt, + "user_prompt": userPrompt, + "input": snap, + }) reqBody := chatCompletionRequest{ - Model: s.cfg.Model, + Model: requestModel, Messages: []chatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userPrompt}, @@ -600,6 +625,12 @@ func (s *SmokeAIAdviceService) callAIDailySummary(ctx context.Context, snap advi if err != nil { return "", "", nil, nil, fmt.Errorf("read ai response: %w", err) } + appendSmokeAIDebugLog("daily_summary.response", map[string]interface{}{ + "uid": uid, + "model": requestModel, + "http_status": resp.StatusCode, + "response_body": string(body), + }) if resp.StatusCode != http.StatusOK { return "", "", nil, nil, fmt.Errorf("ai http %d: %s", resp.StatusCode, truncateString(string(body), 512)) } @@ -619,7 +650,7 @@ func (s *SmokeAIAdviceService) callAIDailySummary(ctx context.Context, snap advi modelName := parsed.Model if modelName == "" { - modelName = s.cfg.Model + modelName = requestModel } var tokensIn, tokensOut *int diff --git a/internal/smoke/service/smoke_ai_debug_log.go b/internal/smoke/service/smoke_ai_debug_log.go new file mode 100644 index 0000000..0b5074d --- /dev/null +++ b/internal/smoke/service/smoke_ai_debug_log.go @@ -0,0 +1,48 @@ +package service + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "time" +) + +const smokeAIDebugLogDir = "/www/wwwroot/code/wx_service/runtime_logs" + +func smokeAIDebugLogPath(now time.Time) string { + day := now.In(time.Local).Format("2006-01-02") + return filepath.Join(smokeAIDebugLogDir, fmt.Sprintf("smoke_ai_%s.log", day)) +} + +func appendSmokeAIDebugLog(scene string, payload map[string]interface{}) { + if payload == nil { + payload = map[string]interface{}{} + } + now := time.Now().In(time.Local) + payload["scene"] = scene + payload["ts"] = now.Format(time.RFC3339) + + body, err := json.MarshalIndent(payload, "", " ") + if err != nil { + log.Printf("[smoke_ai_debug] marshal failed scene=%s err=%v", scene, err) + return + } + + if err := os.MkdirAll(smokeAIDebugLogDir, 0o755); err != nil { + log.Printf("[smoke_ai_debug] mkdir failed scene=%s err=%v", scene, err) + return + } + + f, err := os.OpenFile(smokeAIDebugLogPath(now), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + log.Printf("[smoke_ai_debug] open failed scene=%s err=%v", scene, err) + return + } + defer f.Close() + + if _, err := f.Write(append(body, '\n', '\n')); err != nil { + log.Printf("[smoke_ai_debug] write failed scene=%s err=%v", scene, err) + } +} diff --git a/internal/smoke/service/smoke_ai_model.go b/internal/smoke/service/smoke_ai_model.go new file mode 100644 index 0000000..d46a524 --- /dev/null +++ b/internal/smoke/service/smoke_ai_model.go @@ -0,0 +1,13 @@ +package service + +import "strings" + +// preferredSmokeAIModel 避免把高延迟 reasoning 模型用于首页/计划类交互接口。 +func preferredSmokeAIModel(model string) string { + normalized := strings.TrimSpace(model) + lower := strings.ToLower(normalized) + if strings.Contains(lower, "deepseek") && strings.Contains(lower, "reasoner") { + return "deepseek-chat" + } + return normalized +} diff --git a/internal/smoke/service/smoke_ai_model_test.go b/internal/smoke/service/smoke_ai_model_test.go new file mode 100644 index 0000000..4c340d6 --- /dev/null +++ b/internal/smoke/service/smoke_ai_model_test.go @@ -0,0 +1,28 @@ +package service + +import "testing" + +func TestPreferredSmokeAIModel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "deepseek reasoner fallback", input: "deepseek-reasoner", want: "deepseek-chat"}, + {name: "trim whitespace", input: " deepseek-reasoner ", want: "deepseek-chat"}, + {name: "keep normal model", input: "gpt-4o-mini", want: "gpt-4o-mini"}, + {name: "keep deepseek chat", input: "deepseek-chat", want: "deepseek-chat"}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := preferredSmokeAIModel(tc.input); got != tc.want { + t.Fatalf("preferredSmokeAIModel(%q)=%q, want=%q", tc.input, got, tc.want) + } + }) + } +} diff --git a/internal/smoke/service/smoke_ai_next_smoke_service.go b/internal/smoke/service/smoke_ai_next_smoke_service.go index e9afb48..c9c2b9f 100644 --- a/internal/smoke/service/smoke_ai_next_smoke_service.go +++ b/internal/smoke/service/smoke_ai_next_smoke_service.go @@ -154,7 +154,7 @@ func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *userm } inputJSON, _ := json.Marshal(input) - output, outputJSON, modelName, tokensIn, tokensOut, err := s.callAI(ctx, input) + output, outputJSON, modelName, tokensIn, tokensOut, err := s.callAI(ctx, int(user.ID), input) if err != nil { return AINextSmokeSuggestion{}, err } @@ -249,6 +249,14 @@ func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *userm 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). @@ -612,7 +620,9 @@ func (s *SmokeAINextSmokeService) saveNodes(ctx context.Context, uid int, planDa return nil } -func (s *SmokeAINextSmokeService) callAI(ctx context.Context, input aiNextSmokeInput) (aiNextSmokeOutput, []byte, string, *int, *int, error) { +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) @@ -638,9 +648,16 @@ func (s *SmokeAINextSmokeService) callAI(ctx context.Context, input aiNextSmokeI `) 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: s.cfg.Model, + Model: requestModel, Messages: []chatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userPrompt}, @@ -671,6 +688,12 @@ func (s *SmokeAINextSmokeService) callAI(ctx context.Context, input aiNextSmokeI 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)) } @@ -700,7 +723,7 @@ func (s *SmokeAINextSmokeService) callAI(ctx context.Context, input aiNextSmokeI modelName := parsed.Model if modelName == "" { - modelName = s.cfg.Model + modelName = requestModel } var tokensIn, tokensOut *int