Files
aura-crm/production/backend/internal/analytics/summary.go
T

219 lines
7.5 KiB
Go

package analytics
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"production/internal/settings"
"github.com/gofiber/fiber/v2"
)
const (
summaryRequestTimeout = 20 * time.Second
maxGeminiResponseBytes = 1 << 20 // 1MB — defensive cap, same rationale as internal/aiintake
)
// AISummary builds a compact numeric digest from the same aggregates
// Revenue/Operations/Inventory expose, then asks Gemini for a short
// narrative in Russian. Reads the same GeminiAPIKey/GeminiModel settings
// internal/aiintake uses (same free-tier provider) but makes its own HTTP
// call — aiintake wants JSON-schema output, this wants free-form prose, and
// the two response shapes didn't share enough to be worth a common client.
func (h *Handler) AISummary(c *fiber.Ctx) error {
ctx := context.Background()
s, err := settings.Fetch(ctx, h.db)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if s.GeminiAPIKey == "" {
return c.Status(503).JSON(fiber.Map{"error": "ai summary is not configured"})
}
from, to := c.Query("from"), c.Query("to")
revenue, err := h.fetchRevenue(ctx, "day", from, to)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
orders, err := h.fetchOrderVolume(ctx, "day", from, to)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
byStatus, err := h.fetchOrdersByStatus(ctx, from, to)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
byMaster, err := h.fetchOrdersByMaster(ctx, from, to)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
avgHours, err := h.fetchAvgTurnaroundHours(ctx, from, to)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
lowStock, err := h.fetchLowStock(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
digest := buildDigest(revenue, orders, byStatus, byMaster, avgHours, lowStock)
summaryCtx, cancel := context.WithTimeout(ctx, summaryRequestTimeout)
defer cancel()
text, err := h.callGeminiText(summaryCtx, summaryPrompt(digest), s.GeminiAPIKey, s.GeminiModel)
if err != nil {
return c.Status(502).JSON(fiber.Map{"error": "ai provider request failed"})
}
return c.JSON(fiber.Map{"summary": text})
}
// buildDigest is a pure function (no DB/network) so the prompt-construction
// logic is unit testable without a live Postgres or Gemini call.
func buildDigest(revenue []revenueBucket, orders []orderVolumeBucket, byStatus []statusCount,
byMaster []masterWorkload, avgHours *float64, lowStock []lowStockPart) string {
var b strings.Builder
var totalIncome, totalExpense, totalPayroll float64
for _, r := range revenue {
totalIncome += parseAmount(r.Income)
totalExpense += parseAmount(r.Expense)
totalPayroll += parseAmount(r.Payroll)
}
fmt.Fprintf(&b, "Доход: %.2f ₽. Расход: %.2f ₽. Зарплата: %.2f ₽.\n", totalIncome, totalExpense, totalPayroll)
var totalOrders int
for _, o := range orders {
totalOrders += o.Count
}
fmt.Fprintf(&b, "Новых заявок за период: %d.\n", totalOrders)
if len(byStatus) > 0 {
fmt.Fprintf(&b, "По статусам: %s.\n", joinCounts(statusCountsToPairs(byStatus)))
}
if avgHours != nil {
fmt.Fprintf(&b, "Среднее время ремонта: %.1f ч.\n", *avgHours)
} else {
b.WriteString("Среднее время ремонта: нет завершённых заявок за период.\n")
}
if len(byMaster) > 0 {
fmt.Fprintf(&b, "Загрузка мастеров (кол-во заявок): %s.\n", joinCounts(masterWorkloadsToPairs(byMaster)))
}
if len(lowStock) > 0 {
parts := make([]string, len(lowStock))
for i, p := range lowStock {
parts[i] = fmt.Sprintf("%s (%d/%d)", p.Name, p.CurrentStock, p.MinStock)
}
fmt.Fprintf(&b, "Ниже минимального остатка на складе: %s.\n", strings.Join(parts, ", "))
} else {
b.WriteString("Ниже минимального остатка на складе: нет таких позиций.\n")
}
return b.String()
}
func statusCountsToPairs(s []statusCount) []string {
out := make([]string, len(s))
for i, v := range s {
out[i] = fmt.Sprintf("%s=%d", v.Status, v.Count)
}
return out
}
func masterWorkloadsToPairs(m []masterWorkload) []string {
out := make([]string, len(m))
for i, v := range m {
out[i] = fmt.Sprintf("%s=%d", v.Master, v.Count)
}
return out
}
func joinCounts(pairs []string) string {
return strings.Join(pairs, ", ")
}
func parseAmount(s string) float64 {
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return v
}
// summaryPrompt fences the digest the same way internal/aiintake fences
// customer text — the digest here is server-computed, not user-supplied, so
// there's no injection surface, but keeping every LLM call in this codebase
// structured the same way (explicit role, explicit "don't invent numbers"
// instruction) costs nothing and stays consistent if a future digest field
// ever pulled in free-text (e.g. an order's problem_description).
func summaryPrompt(digest string) string {
return "Ты — бизнес-аналитик сервисного центра по ремонту техники. Ниже приведены агрегированные " +
"показатели за выбранный период. Напиши краткую сводку на русском языке (3-5 предложений) для " +
"владельца бизнеса: что выросло, что просело, на что стоит обратить внимание. Пиши по существу, " +
"без вступлений и заключений — только сама сводка. Никогда не выдумывай цифры, которых нет ниже.\n\n" +
"=== ДАННЫЕ ===\n" + digest + "=== КОНЕЦ ДАННЫХ ==="
}
type geminiTextResponse struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
func (h *Handler) callGeminiText(ctx context.Context, prompt, apiKey, model string) (string, error) {
reqBody, err := json.Marshal(map[string]any{
"contents": []map[string]any{
{"parts": []map[string]any{{"text": prompt}}},
},
})
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
url := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent", model)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-goog-api-key", apiKey)
resp, err := h.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("call gemini: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxGeminiResponseBytes))
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("gemini returned %d: %s", resp.StatusCode, respBody)
}
var parsed geminiTextResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return "", fmt.Errorf("unmarshal gemini response: %w", err)
}
if len(parsed.Candidates) == 0 || len(parsed.Candidates[0].Content.Parts) == 0 {
return "", fmt.Errorf("gemini returned no candidates")
}
return parsed.Candidates[0].Content.Parts[0].Text, nil
}