/
amne
/
dca-agent
Обзор
Документация
Войти
/
amne
/
dca-agent
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
api/handler.go
669 строк
18 KB
СеребристыйМак
feat: scoring fixes, filters, period analytics, weekly-report endpoint, monitor
13 июл 2026, 22:59
13 июл 2026, 22:59
bbb6a6f
Код
Авторство
О чём код?
package api import ( "embed" "encoding/json" "io" "io/fs" "log" "net/http" "os" "path/filepath" "strconv" "strings" "dca-agent/config" "dca-agent/storage" ) type Handler struct { cfg *config.Config store *storage.Store admin *AdminClient llm LLMClient static fs.FS BrowseFolder func() string // native folder picker, set by main on Windows CalltouchToggle func(enabled bool, token, siteID string) // dynamic poller toggle, set by main CalltouchBackfill func(fromDate string) (interface{}, error) // backfill handler, set by main ReportGenerate func(reportType, from, to, operator string) (interface{}, error) // weekly report gen SetOperator func(id int64, operator string) error // manual operator override } // LLMClient interface for AI operations type LLMClient interface { AskAnalyst(question string, dataContext string) (string, int, int, error) GenerateSQL(question string) (string, int, int, error) } // NewHandler creates API handler; pass nil adminClient for standalone mode func NewHandler(cfg *config.Config, store *storage.Store, staticFS embed.FS, adminClient *AdminClient, llmClient LLMClient) *Handler { sub, _ := fs.Sub(staticFS, "static") return &Handler{cfg: cfg, store: store, admin: adminClient, llm: llmClient, static: sub} } func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/status", h.handleStatus) mux.HandleFunc("/api/calls", h.handleCalls) mux.HandleFunc("/api/calls/", h.handleCallDetail) mux.HandleFunc("/api/stats", h.handleStats) mux.HandleFunc("/api/filters", h.handleFilters) mux.HandleFunc("/api/queue", h.handleQueue) mux.HandleFunc("/api/audio/", h.handleAudio) mux.HandleFunc("/api/subscription", h.handleSubscription) mux.HandleFunc("/api/ai/ask", h.handleAIAsk) mux.HandleFunc("/api/settings", h.handleSettings) mux.HandleFunc("/api/browse", h.handleBrowse) mux.HandleFunc("/api/stats/charts", h.handleStatsCharts) mux.HandleFunc("/api/calltouch/backfill", h.handleCalltouchBackfill) mux.HandleFunc("/api/reports/generate", h.handleReportGenerate) mux.HandleFunc("/api/reports", h.handleReportsList) // Static files — SPA fallback mux.HandleFunc("/", h.handleStatic) } func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { queue, _ := h.store.GetQueueStatus() jsonResp(w, map[string]interface{}{ "status": "running", "queue": queue, "config": map[string]interface{}{ "source_folder": h.cfg.Source.Folder.Path, "source_type": h.cfg.Source.Type, }, }) } func (h *Handler) handleCalls(w http.ResponseWriter, r *http.Request) { page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { page = 1 } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit < 1 || limit > 100 { limit = 20 } filters := map[string]string{ "operator": r.URL.Query().Get("operator"), "category": r.URL.Query().Get("category"), "date_from": r.URL.Query().Get("date_from"), "date_to": r.URL.Query().Get("date_to"), "status": r.URL.Query().Get("status"), "search": r.URL.Query().Get("search"), "score_min": r.URL.Query().Get("score_min"), "score_max": r.URL.Query().Get("score_max"), "appointment": r.URL.Query().Get("appointment"), "direction": r.URL.Query().Get("direction"), } sort := r.URL.Query().Get("sort") dir := r.URL.Query().Get("dir") calls, total, err := h.store.ListCalls(filters, page, limit, sort, dir) if err != nil { jsonError(w, 500, err.Error()) return } jsonResp(w, map[string]interface{}{ "calls": calls, "total": total, "page": page, "limit": limit, }) } func (h *Handler) handleCallDetail(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // /api/calls/:id or /api/calls/:id/report or /api/calls/:id/json parts := strings.Split(strings.TrimPrefix(path, "/api/calls/"), "/") if len(parts) == 0 || parts[0] == "" { jsonError(w, 400, "missing call id") return } id, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { jsonError(w, 400, "invalid id") return } call, err := h.store.GetCall(id) if err != nil { jsonError(w, 404, "call not found") return } if len(parts) >= 2 { switch parts[1] { case "report": h.serveFile(w, call.MdPath, "text/markdown") return case "json": h.serveFile(w, call.JsonPath, "application/json") return case "reset": if r.Method != http.MethodPost { jsonError(w, 405, "method not allowed") return } if err := h.store.ResetCall(id); err != nil { jsonError(w, 500, "reset failed: "+err.Error()) return } log.Printf("[api] call #%d reset to pending", id) jsonResp(w, map[string]interface{}{"status": "reset", "id": id}) return case "operator": if r.Method != http.MethodPost { jsonError(w, 405, "method not allowed") return } if h.SetOperator == nil { jsonError(w, 500, "operator update not available") return } var body struct { Operator string `json:"operator"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, 400, "invalid JSON: "+err.Error()) return } if err := h.SetOperator(id, body.Operator); err != nil { jsonError(w, 500, "operator update failed: "+err.Error()) return } log.Printf("[api] call #%d operator set to %q", id, body.Operator) jsonResp(w, map[string]interface{}{"status": "ok", "id": id, "operator": strings.TrimSpace(body.Operator)}) return } } // Default: return call metadata + full JSON result := map[string]interface{}{ "call": call, } if call.JsonPath != "" { data, err := os.ReadFile(call.JsonPath) if err == nil { var jsonData map[string]interface{} json.Unmarshal(data, &jsonData) result["data"] = jsonData } } jsonResp(w, result) } func (h *Handler) handleStats(w http.ResponseWriter, r *http.Request) { dateFrom := r.URL.Query().Get("date_from") dateTo := r.URL.Query().Get("date_to") stats, err := h.store.GetStats(dateFrom, dateTo) if err != nil { jsonError(w, 500, err.Error()) return } jsonResp(w, stats) } func (h *Handler) handleFilters(w http.ResponseWriter, r *http.Request) { operators, _ := h.store.GetDistinctOperators() categories, _ := h.store.GetDistinctCategories() if operators == nil { operators = []string{} } if categories == nil { categories = []string{} } jsonResp(w, map[string]interface{}{ "operators": operators, "categories": categories, }) } func (h *Handler) handleQueue(w http.ResponseWriter, r *http.Request) { queue, err := h.store.GetQueueStatus() if err != nil { jsonError(w, 500, err.Error()) return } jsonResp(w, queue) } func (h *Handler) serveFile(w http.ResponseWriter, path, contentType string) { if path == "" { http.Error(w, "file not found", 404) return } abs, err := filepath.Abs(path) if err != nil { http.Error(w, "invalid path", 500) return } data, err := os.ReadFile(abs) if err != nil { http.Error(w, "file not found", 404) return } w.Header().Set("Content-Type", contentType+"; charset=utf-8") w.Write(data) } func jsonResp(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Access-Control-Allow-Origin", "*") if err := json.NewEncoder(w).Encode(data); err != nil { log.Printf("[api] json encode error: %v", err) } } func jsonError(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func (h *Handler) handleAudio(w http.ResponseWriter, r *http.Request) { idStr := strings.TrimPrefix(r.URL.Path, "/api/audio/") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { jsonError(w, 400, "invalid id") return } call, err := h.store.GetCall(id) if err != nil { jsonError(w, 404, "call not found") return } // Search for audio file relative to config source folder folderPath := h.cfg.Source.Folder.Path searchPaths := []string{ filepath.Join(folderPath, "done", call.Filename), filepath.Join(folderPath, call.Filename), } for _, p := range searchPaths { abs, err := filepath.Abs(p) if err != nil { continue } if _, err := os.Stat(abs); err == nil { http.ServeFile(w, r, abs) return } } jsonError(w, 404, "audio file not found") } func (h *Handler) handleSubscription(w http.ResponseWriter, r *http.Request) { if h.admin == nil { jsonResp(w, map[string]interface{}{ "status": "standalone", "days_left": 30, "minutes_used": 0, "minutes_limit": 2000, }) return } sub, err := h.admin.FetchSubscription() if err != nil { jsonError(w, 500, "subscription fetch failed") return } jsonResp(w, sub) } func (h *Handler) handleAIAsk(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { jsonError(w, 405, "method not allowed") return } if h.llm == nil { jsonError(w, 503, "LLM not available") return } var req struct { Question string `json:"question"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Question == "" { jsonError(w, 400, "question is required") return } // Step 1: LLM generates SQL from question sqlQuery, _, _, err := h.llm.GenerateSQL(req.Question) if err != nil { jsonError(w, 500, "failed to generate query") return } // Clean SQL — remove markdown fences if present sqlQuery = strings.TrimSpace(sqlQuery) sqlQuery = strings.TrimPrefix(sqlQuery, "```sql") sqlQuery = strings.TrimPrefix(sqlQuery, "```") sqlQuery = strings.TrimSuffix(sqlQuery, "```") sqlQuery = strings.TrimSpace(sqlQuery) // Step 2: Execute SQL rows, err := h.store.QueryJSON(sqlQuery) if err != nil { jsonResp(w, map[string]interface{}{ "question": req.Question, "sql": sqlQuery, "error": "SQL error: " + err.Error(), }) return } // Step 3: LLM analyzes results dataJSON, _ := json.Marshal(rows) dataStr := string(dataJSON) if len(dataStr) > 8000 { dataStr = dataStr[:8000] + "... (обрезано)" } answer, _, _, err := h.llm.AskAnalyst(req.Question, dataStr) if err != nil { jsonError(w, 500, "failed to analyze") return } jsonResp(w, map[string]interface{}{ "question": req.Question, "sql": sqlQuery, "rows": len(rows), "answer": answer, }) } func (h *Handler) handleStatsCharts(w http.ResponseWriter, r *http.Request) { dateFrom := r.URL.Query().Get("date_from") dateTo := r.URL.Query().Get("date_to") stats, err := h.store.GetChartsData(dateFrom, dateTo) if err != nil { jsonError(w, 500, "stats error") return } jsonResp(w, stats) } func (h *Handler) handleBrowse(w http.ResponseWriter, r *http.Request) { log.Printf("[browse] handler called") if h.BrowseFolder == nil { log.Printf("[browse] BrowseFolder is nil") jsonError(w, 501, "folder browser not available") return } var path string func() { defer func() { if rec := recover(); rec != nil { log.Printf("[browse] PANIC: %v", rec) } }() path = h.BrowseFolder() }() log.Printf("[browse] result: %q", path) if path == "" { jsonResp(w, map[string]interface{}{"cancelled": true}) return } jsonResp(w, map[string]interface{}{"path": path, "cancelled": false}) } func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: jsonResp(w, map[string]interface{}{ "admin_url": h.cfg.Admin.URL, "client_id": h.cfg.Admin.ClientID, "api_key": h.cfg.Admin.APIKey, "source_folder": h.cfg.Source.Folder.Path, "since": h.cfg.Source.Folder.Since, "api_port": h.cfg.API.Port, "timezone": h.cfg.Timezone, "calltouch_enabled": h.cfg.Calltouch.Enabled, "calltouch_api_token": h.cfg.Calltouch.APIToken, "calltouch_site_id": h.cfg.Calltouch.SiteID, "calltouch_poll_interval": h.cfg.Calltouch.PollInterval, "retention_audio_days": h.cfg.Retention.AudioDays, }) case http.MethodPut: var req struct { AdminURL string `json:"admin_url"` ClientID string `json:"client_id"` APIKey string `json:"api_key"` SourceFolder string `json:"source_folder"` Since string `json:"since"` Timezone string `json:"timezone"` CTEnabled *bool `json:"calltouch_enabled"` CTAPIToken string `json:"calltouch_api_token"` CTSiteID string `json:"calltouch_site_id"` CTPollInterval string `json:"calltouch_poll_interval"` RetentionDays *int `json:"retention_audio_days"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, 400, "invalid json") return } // Apply general changes if req.AdminURL != "" { h.cfg.Admin.URL = req.AdminURL } h.cfg.Admin.ClientID = req.ClientID h.cfg.Admin.APIKey = req.APIKey if req.SourceFolder != "" { h.cfg.Source.Folder.Path = req.SourceFolder } h.cfg.Source.Folder.Since = req.Since // Apply timezone if req.Timezone != "" { h.cfg.Timezone = req.Timezone } // Apply Calltouch changes ctChanged := false if req.CTAPIToken != "" { h.cfg.Calltouch.APIToken = req.CTAPIToken ctChanged = true } if req.CTSiteID != "" { h.cfg.Calltouch.SiteID = req.CTSiteID ctChanged = true } if req.CTPollInterval != "" { h.cfg.Calltouch.PollInterval = req.CTPollInterval ctChanged = true } // Toggle poller dynamically (no restart needed) if req.CTEnabled != nil { h.cfg.Calltouch.Enabled = *req.CTEnabled if h.CalltouchToggle != nil { h.CalltouchToggle(*req.CTEnabled, h.cfg.Calltouch.APIToken, h.cfg.Calltouch.SiteID) } ctChanged = true } // Persist to config.yaml // Apply retention if req.RetentionDays != nil { h.cfg.Retention.AudioDays = *req.RetentionDays } // Persist to config.yaml if err := h.cfg.Save(); err != nil { jsonError(w, 500, "save failed: "+err.Error()) return } // Ensure new folder exists _ = h.cfg.EnsureDirs() msg := "Настройки сохранены." if ctChanged { if req.CTEnabled != nil && *req.CTEnabled { msg += " Calltouch включён." } else if req.CTEnabled != nil && !*req.CTEnabled { msg += " Calltouch выключен." } } log.Printf("[settings] config saved") jsonResp(w, map[string]interface{}{ "status": "saved", "message": msg, }) default: jsonError(w, 405, "method not allowed") } } func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/") // Serve data files (reports) from filesystem if strings.HasPrefix(path, "data/") { fsPath := filepath.Join(h.cfg.Storage.DataDir, strings.TrimPrefix(path, "data/")) http.ServeFile(w, r, fsPath) return } if path == "" { path = "index.html" } // Try to serve from embedded FS f, err := h.static.Open(path) if err != nil { // SPA fallback: serve index.html for unknown routes f, err = h.static.Open("index.html") if err != nil { http.Error(w, "not found", 404) return } } defer f.Close() stat, _ := f.Stat() ext := filepath.Ext(path) ct := "text/plain" switch ext { case ".html": ct = "text/html; charset=utf-8" case ".css": ct = "text/css; charset=utf-8" case ".js": ct = "application/javascript; charset=utf-8" case ".json": ct = "application/json; charset=utf-8" case ".svg": ct = "image/svg+xml" case ".png": ct = "image/png" case ".ico": ct = "image/x-icon" } w.Header().Set("Content-Type", ct) http.ServeContent(w, r, path, stat.ModTime(), f.(readSeeker)) } type readSeeker interface { io.ReadSeeker } // handleCalltouchBackfill — POST /api/calltouch/backfill // Body: {"from": "2026-06-01"} // Downloads all calls from the given date to now. func (h *Handler) handleCalltouchBackfill(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonError(w, 405, "method not allowed") return } var req struct { From string `json:"from"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, 400, "invalid json") return } if req.From == "" { jsonError(w, 400, "missing 'from' date") return } if h.CalltouchBackfill == nil { jsonError(w, 503, "calltouch not available") return } result, err := h.CalltouchBackfill(req.From) if err != nil { jsonError(w, 500, err.Error()) return } jsonResp(w, result) } // handleReportGenerate — POST /api/reports/generate // Body: {"type": "clinic"|"operator", "from": "2026-06-10", "to": "2026-06-17", "operator": "7981..."} // Generates a weekly report via LLM and saves to data/reports/weekly/ func (h *Handler) handleReportGenerate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonError(w, 405, "method not allowed") return } if h.ReportGenerate == nil { jsonError(w, 503, "reports not available") return } var req struct { Type string `json:"type"` From string `json:"from"` To string `json:"to"` Operator string `json:"operator"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, 400, "invalid json") return } result, err := h.ReportGenerate(req.Type, req.From, req.To, req.Operator) if err != nil { jsonError(w, 500, err.Error()) return } jsonResp(w, result) } // handleReportsList — GET /api/reports // Returns list of generated weekly reports. func (h *Handler) handleReportsList(w http.ResponseWriter, r *http.Request) { reportsDir := h.cfg.MDDir() + "/weekly" entries, err := os.ReadDir(reportsDir) if err != nil { jsonResp(w, []interface{}{}) return } var reports []map[string]interface{} for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { continue } info, _ := e.Info() reports = append(reports, map[string]interface{}{ "filename": e.Name(), "size": info.Size(), "modified": info.ModTime().Format("2006-01-02 15:04"), }) } if reports == nil { reports = []map[string]interface{}{} } jsonResp(w, reports) }