package controllers import ( "encoding/json" "net/url" "strconv" "doormile/db" "doormile/models" "doormile/utils" "github.com/gofiber/fiber/v2" "gorm.io/gorm" "gorm.io/gorm/clause" ) // Skill findings: the console's rule output, made durable. // // See models/ai_findings.go for why this is neither an exception nor a decision. // The short version: the eight ops skills run in the browser and threw their // output away, so nobody could answer whether a skill was useful, whether a // finding had been seen before, or whether acting on one actually cleared it. // POST /api/v1/admin/ai/findings // // Idempotent on fingerprint. The console re-evaluates every 60 seconds and will // re-send an unchanged finding each time; this bumps lastseenat and seencount // rather than inserting a duplicate. Without that, the table would grow by the // number of open findings per minute per operator with the Exceptions page // open — and "how long has this been open" would be unanswerable, which is the // one signal that distinguishes an ignored finding from a new one. func UpsertAIFindings(c *fiber.Ctx) error { type item struct { Skillid string `json:"skillid"` Fingerprint string `json:"fingerprint"` Severity string `json:"severity"` Title string `json:"title"` Proposaltool string `json:"proposaltool"` Bookingids []int `json:"bookingids"` Tenantid *int `json:"tenantid"` } var body struct { Findings []item `json:"findings"` // Cleared carries the fingerprints a scan did NOT raise this time. // Sent by the console because only it knows the full set it evaluated: // the backend cannot distinguish "resolved" from "the operator closed // the tab" on its own. Cleared []string `json:"cleared"` } if err := c.BodyParser(&body); err != nil { return utils.BadRequest(c, "invalid request body") } now := utils.DBNow() written, skipped := 0, 0 for _, f := range body.Findings { if f.Skillid == "" || f.Fingerprint == "" { skipped++ continue } scopeJSON, err := json.Marshal(f.Bookingids) if err != nil { skipped++ continue } row := models.AISkillFinding{ Skillid: f.Skillid, Fingerprint: f.Fingerprint, Severity: f.Severity, Title: f.Title, Proposaltool: f.Proposaltool, Bookingcount: len(f.Bookingids), Scope: string(scopeJSON), Tenantid: f.Tenantid, Firstseenat: now, Lastseenat: now, Seencount: 1, } // ON CONFLICT on the fingerprint: bump the sighting, leave firstseenat // alone. seencount uses a SQL expression rather than a read-modify-write // so two operators with the page open cannot lose each other's bump. // // clearedat is reset to NULL: a finding that cleared and came back is // open again, and leaving the old timestamp would make it look resolved // while it is being re-raised. if err := db.DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "fingerprint"}}, DoUpdates: clause.Assignments(map[string]interface{}{ "lastseenat": now, "seencount": gorm.Expr("aiskillfindings.seencount + 1"), "severity": f.Severity, "bookingcount": len(f.Bookingids), "scope": string(scopeJSON), "clearedat": nil, }), }).Create(&row).Error; err != nil { utils.Warn("UpsertAIFindings: upsert failed", "fingerprint", f.Fingerprint, "error", err) skipped++ continue } written++ } cleared := 0 if len(body.Cleared) > 0 { // Only clear what is still open. Re-clearing an already-cleared row // would move its clearedat forward on every poll and destroy the // "acting cleared it in 4 minutes" measurement. res := db.DB.Model(&models.AISkillFinding{}). Where("fingerprint IN ? AND clearedat IS NULL", body.Cleared). Update("clearedat", now) if res.Error != nil { utils.Warn("UpsertAIFindings: clearing failed", "error", res.Error) } else { cleared = int(res.RowsAffected) } } return utils.OK(c, fiber.Map{"written": written, "skipped": skipped, "cleared": cleared}) } // POST /api/v1/admin/ai/findings/:fingerprint/acted // // Records that an operator carried out a finding's proposal, and how it went. // Separate from the upsert because it is a different event with a different // actor: the upsert is a scan reporting what it sees, this is a person doing // something. Collapsing them would make "nobody acted" indistinguishable from // "the scan has not run since". func RecordAIFindingActed(c *fiber.Ctx) error { // Fiber's c.Params returns the RAW path segment, still percent-encoded. // A fingerprint is "skill:tool:1,2,3", so the console necessarily sends it // through encodeURIComponent and the ':' and ',' arrive as %3A and %2C. // Matching the raw string against the stored one therefore never hits, and // every acted-report 404s — silently, because the console treats this call // as fire-and-forget. // // Found by running the real backend; a mock that echoed the path back // agreed with the assumption and proved nothing. fingerprint, err := url.PathUnescape(c.Params("fingerprint")) if err != nil { return utils.BadRequest(c, "invalid fingerprint") } if fingerprint == "" { return utils.BadRequest(c, "fingerprint is required") } var body struct { Result string `json:"result"` // ok, partial, failed } if err := c.BodyParser(&body); err != nil { return utils.BadRequest(c, "invalid request body") } switch body.Result { case "ok", "partial", "failed": default: // A partial success is a partial success — the console reports six // riders notified out of eight that way, and flattening it to "ok" // here would lose exactly the distinction the executors preserve. return utils.BadRequest(c, "result must be one of: ok, partial, failed") } actor, _ := c.Locals("userid").(int) now := utils.DBNow() res := db.DB.Model(&models.AISkillFinding{}). Where("fingerprint = ?", fingerprint). Updates(map[string]interface{}{ "actedat": now, "actedby": actor, "actionresult": body.Result, }) if res.Error != nil { utils.Error("RecordAIFindingActed: update failed", "error", res.Error) return utils.Internal(c, "failed to record the action") } if res.RowsAffected == 0 { return utils.NotFound(c, "finding not found") } return utils.OK(c, fiber.Map{"fingerprint": fingerprint, "result": body.Result}) } // GET /api/v1/admin/ai/findings // // What the skills have been noticing. Read-only; Doormile staff only, like the // rest of the /admin/ai surface. // // Defaults to open findings (clearedat IS NULL) because that is the operational // question. `?days=N` switches to everything in a window, which is the // measurement question — and those are different enough that one default cannot // serve both. func GetAIFindings(c *fiber.Ctx) error { limit, err := strconv.Atoi(c.Query("limit", "100")) if err != nil || limit < 1 || limit > 500 { limit = 100 } q := db.DB.Model(&models.AISkillFinding{}) if days, err := strconv.Atoi(c.Query("days", "0")); err == nil && days > 0 { if days > 90 { days = 90 } q = q.Where("firstseenat >= ?", utils.DBNow().AddDate(0, 0, -days)) } else { q = q.Where("clearedat IS NULL") } if skill := c.Query("skill"); skill != "" { q = q.Where("skillid = ?", skill) } var rows []models.AISkillFinding if err := q.Order("lastseenat DESC").Limit(limit).Find(&rows).Error; err != nil { utils.Error("GetAIFindings: query failed", "error", err) return utils.Internal(c, "failed to read findings") } return utils.List(c, rows, int64(len(rows))) } // GET /api/v1/admin/ai/findings/stats // // Per skill, over a window: how often it fires, how long its findings stay // open, how often anyone acts, and whether acting cleared them. // // This is the point of the table. "Acted and cleared" versus "cleared on its // own" is what separates a skill that helps from one that narrates — and a // skill whose findings always clear untouched is proposing work that did not // need doing. func GetAIFindingStats(c *fiber.Ctx) error { days, err := strconv.Atoi(c.Query("days", "30")) if err != nil || days < 1 || days > 90 { days = 30 } since := utils.DBNow().AddDate(0, 0, -days) type stat struct { Skillid string `json:"skillid"` Findings int64 `json:"findings"` Stillopen int64 `json:"stillopen"` Actedon int64 `json:"actedon"` Clearedafteract int64 `json:"clearedafteract"` Clearedunacted int64 `json:"clearedunacted"` Avgopenminutes *float64 `json:"avgopenminutes"` } var rows []stat // EXTRACT over (clearedat - firstseenat): both are written with // utils.DBNow, so they share a tagging and their difference is correct // regardless of the IST-digits-labelled-UTC convention. if err := db.DB.Raw(` SELECT skillid, count(*) AS findings, count(*) FILTER (WHERE clearedat IS NULL) AS stillopen, count(*) FILTER (WHERE actedat IS NOT NULL) AS actedon, count(*) FILTER (WHERE actedat IS NOT NULL AND clearedat IS NOT NULL) AS clearedafteract, count(*) FILTER (WHERE actedat IS NULL AND clearedat IS NOT NULL) AS clearedunacted, avg(EXTRACT(EPOCH FROM (clearedat - firstseenat)) / 60.0) FILTER (WHERE clearedat IS NOT NULL) AS avgopenminutes FROM aiskillfindings WHERE firstseenat >= ? GROUP BY skillid ORDER BY findings DESC`, since).Scan(&rows).Error; err != nil { utils.Error("GetAIFindingStats: query failed", "error", err) return utils.Internal(c, "failed to read finding stats") } return utils.OK(c, fiber.Map{ "days": days, "since": since, "skills": rows, }) } // PruneAIFindings drops findings past the retention window. Called from the // outcome sweeper's tick rather than having its own timer — one more table to // keep tidy, not one more goroutine. func PruneAIFindings(retentionDays int) (int, error) { if db.DB == nil || retentionDays <= 0 { return 0, nil } cutoff := utils.DBNow().AddDate(0, 0, -retentionDays) res := db.DB.Where("firstseenat < ?", cutoff).Delete(&models.AISkillFinding{}) if res.Error != nil { return 0, res.Error } return int(res.RowsAffected), nil }