219 lines
7.5 KiB
Go
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
|
|
}
|