From cebce4f7581173e40133f6c8e46d6da243409ba0 Mon Sep 17 00:00:00 2001 From: 12975 <1297598740@qq.com> Date: Wed, 28 Jan 2026 22:13:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20AI=20=E8=AE=B0?= =?UTF-8?q?=E8=B4=A6=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85=E6=8B=AC=20API=20?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=99=A8=E5=92=8C=E6=A0=B8=E5=BF=83=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E9=80=BB=E8=BE=91=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/ai_handler.go | 51 +++++++++++- internal/service/ai_bookkeeping_service.go | 97 ++++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/internal/handler/ai_handler.go b/internal/handler/ai_handler.go index 62d18c9..749a5e7 100644 --- a/internal/handler/ai_handler.go +++ b/internal/handler/ai_handler.go @@ -1,6 +1,7 @@ package handler import ( + "encoding/json" "net/http" "accounting-app/internal/service" @@ -26,6 +27,11 @@ type ChatRequest struct { Message string `json:"message" binding:"required"` } +// InsightRequest represents an insight generation request +type InsightRequest struct { + ContextData map[string]interface{} `json:"context_data" binding:"required"` +} + // TranscribeRequest represents a transcription request type TranscribeRequest struct { // Audio file is sent as multipart form data @@ -43,6 +49,7 @@ func (h *AIHandler) RegisterRoutes(rg *gin.RouterGroup) { ai.POST("/chat", h.Chat) ai.POST("/transcribe", h.Transcribe) ai.POST("/confirm", h.Confirm) + ai.POST("/insight", h.Insight) } } @@ -80,11 +87,53 @@ func (h *AIHandler) Chat(c *gin.Context) { }) } +// Insight handles daily insight generation +// POST /api/v1/ai/insight +func (h *AIHandler) Insight(c *gin.Context) { + var req InsightRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "error": "Invalid request: " + err.Error(), + }) + return + } + + userID := uint(1) + if id, exists := c.Get("user_id"); exists { + userID = id.(uint) + } + + insightJSON, err := h.aiService.GenerateDailyInsight(c.Request.Context(), userID, req.ContextData) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": "Failed to generate insight: " + err.Error(), + }) + return + } + + // Try to parse the JSON string from LLM to ensure structure + var result map[string]interface{} + if err := json.Unmarshal([]byte(insightJSON), &result); err != nil { + // Fallback if LLM output isn't valid JSON (rare with correct prompt) + result = map[string]interface{}{ + "spending": insightJSON, + "budget": "暂无建议", + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": result, + }) +} + // Transcribe handles audio transcription // POST /api/v1/ai/transcribe // Requirements: 12.2, 12.6 func (h *AIHandler) Transcribe(c *gin.Context) { - // Get audio file from form + // ... existing body ... file, header, err := c.Request.FormFile("audio") if err != nil { c.JSON(http.StatusBadRequest, gin.H{ diff --git a/internal/service/ai_bookkeeping_service.go b/internal/service/ai_bookkeeping_service.go index dfc35c9..9430481 100644 --- a/internal/service/ai_bookkeeping_service.go +++ b/internal/service/ai_bookkeeping_service.go @@ -494,6 +494,103 @@ func (s *LLMService) parseIntentSimple(text string) (*AITransactionParams, strin return params, message, nil } +// GenerateReport generates a report based on the provided prompt using LLM +func (s *LLMService) GenerateReport(ctx context.Context, prompt string) (string, error) { + if s.config.OpenAIAPIKey == "" || s.config.OpenAIBaseURL == "" { + return "", errors.New("OpenAI API not configured") + } + + messages := []ChatMessage{ + { + Role: "user", + Content: prompt, + }, + } + + reqBody := ChatCompletionRequest{ + Model: s.config.ChatModel, + Messages: messages, + Temperature: 0.7, // Higher temperature for creative insights + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", s.config.OpenAIBaseURL+"/chat/completions", bytes.NewReader(jsonBody)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+s.config.OpenAIAPIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("generate report request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("generate report failed with status %d: %s", resp.StatusCode, string(body)) + } + + var chatResp ChatCompletionResponse + if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + if len(chatResp.Choices) == 0 { + return "", errors.New("no response from AI") + } + + return chatResp.Choices[0].Message.Content, nil +} + +// GenerateDailyInsight generates a daily insight report +func (s *AIBookkeepingService) GenerateDailyInsight(ctx context.Context, userID uint, data map[string]interface{}) (string, error) { + // 1. Serialize context data to JSON for the prompt + dataBytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal context data: %w", err) + } + + // 2. Construct Prompt + prompt := fmt.Sprintf(`[SYSTEM: You are a personal financial analyst. Your task is to provide a brief, warm, and actionable daily financial insight based on the provided data.] + +DATA: +%s + +TASK: +Output a JSON object with exactly two fields: "spending" and "budget". +1. "spending": A comment on today's spending (max 40 chars). Warm tone. Mention weekday if relevant. Praise streaks. +2. "budget": Actionable advice on budget status (max 40 chars). + +OUTPUT FORMAT (JSON ONLY, NO MARKDOWN): +{"spending": "...", "budget": "..."}`, string(dataBytes)) + + // 3. Call LLM + report, err := s.llmService.GenerateReport(ctx, prompt) + if err != nil { + return "", err + } + + // Clean up markdown if present + report = strings.TrimSpace(report) + if strings.HasPrefix(report, "```") { + if idx := strings.Index(report, "\n"); idx != -1 { + report = report[idx+1:] + } + if idx := strings.LastIndex(report, "```"); idx != -1 { + report = report[:idx] + } + } + + return strings.TrimSpace(report), nil +} + // MapAccountName maps natural language account name to account ID func (s *LLMService) MapAccountName(ctx context.Context, name string, userID uint) (*uint, string, error) { if name == "" {