feat: 添加 AI 记账功能,包括 API 处理器和核心服务逻辑。
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user