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