feat: 添加 AI 记账功能,包括 API 处理器和核心服务逻辑。

This commit is contained in:
2026-01-28 22:13:19 +08:00
parent cce8fad008
commit cebce4f758
2 changed files with 147 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"encoding/json"
"net/http" "net/http"
"accounting-app/internal/service" "accounting-app/internal/service"
@@ -26,6 +27,11 @@ type ChatRequest struct {
Message string `json:"message" binding:"required"` 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 // TranscribeRequest represents a transcription request
type TranscribeRequest struct { type TranscribeRequest struct {
// Audio file is sent as multipart form data // 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("/chat", h.Chat)
ai.POST("/transcribe", h.Transcribe) ai.POST("/transcribe", h.Transcribe)
ai.POST("/confirm", h.Confirm) 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 // Transcribe handles audio transcription
// POST /api/v1/ai/transcribe // POST /api/v1/ai/transcribe
// Requirements: 12.2, 12.6 // Requirements: 12.2, 12.6
func (h *AIHandler) Transcribe(c *gin.Context) { func (h *AIHandler) Transcribe(c *gin.Context) {
// Get audio file from form // ... existing body ...
file, header, err := c.Request.FormFile("audio") file, header, err := c.Request.FormFile("audio")
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{

View File

@@ -494,6 +494,103 @@ func (s *LLMService) parseIntentSimple(text string) (*AITransactionParams, strin
return params, message, nil 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 // MapAccountName maps natural language account name to account ID
func (s *LLMService) MapAccountName(ctx context.Context, name string, userID uint) (*uint, string, error) { func (s *LLMService) MapAccountName(ctx context.Context, name string, userID uint) (*uint, string, error) {
if name == "" { if name == "" {