package models import "time" // AISkillFinding is one thing a console ops skill noticed. // // ─── Why this table exists ───────────────────────────────────────────────── // // The eight rule skills (SlaGuardian, DoorstepStall, FleetBalancer, // HighValueCod, RiderBatterySafety, HubCongestion, LateDispatch, CashExposure) // run in the operator's browser, on a 60-second React Query interval, and their // findings were thrown away. Nothing persisted them, so three questions had no // answer at all: // // Has this booking been flagged before, and how many times? // Which skills fire most, and which are ignored every time they do? // Did the proposal an operator carried out actually clear the finding? // // The third is the one that matters. The console already re-runs its scan after // executing a proposal to see whether the finding disappears — that evidence // existed for one render and then vanished. Persisting it turns "the skills seem // useful" into something measurable, and it is the raw material for any later // per-rider or per-zone memory. // // ─── What this is NOT ────────────────────────────────────────────────────── // // Not an exception. `consignmentexceptions` records a real operational problem // with a parcel (Lost, Damaged, Misrouted) raised by a person. A finding is a // rule noticing a pattern, most of which resolve themselves without anyone // doing anything. Writing findings into that table would flood a queue people // are supposed to work. // // Not a decision either: `agent_decisions` is the engine's model-made choices, // with embeddings for retrieval. A finding is deterministic and carries no // vector. type AISkillFinding struct { Findingid int `json:"findingid" gorm:"primaryKey;column:findingid;autoIncrement"` // Skillid matches aiskills.skillid, so a finding can be grouped by the rule // that raised it and read alongside the thresholds in force at the time. Skillid string `json:"skillid" gorm:"column:skillid;size:64;not null;index:idx_aiskillfindings_skill_time,priority:1"` // Fingerprint is what makes a finding the SAME finding across polls. The // skills re-evaluate every 60 seconds and will re-raise an unchanged // problem every time; without this the table would grow by the number of // open findings per minute per operator with the page open. // // Computed by the console from the skill id and the scope's booking ids — // deliberately not including severity or counts, which drift while the // underlying problem stays the same. Fingerprint string `json:"fingerprint" gorm:"column:fingerprint;size:120;not null;uniqueIndex:uq_aiskillfindings_fingerprint"` Severity string `json:"severity" gorm:"column:severity;size:20"` // critical, warning, info Title string `json:"title" gorm:"column:title"` // Proposaltool is the verb the finding proposes, e.g. notifyRider, // assignMiler, enforce_cash_handoff. Matches aitools.toolname where one // exists; review-only verbs are recorded too, which is how "this skill // keeps proposing something nobody can act on" becomes visible. Proposaltool string `json:"proposaltool" gorm:"column:proposaltool;size:64;index"` // Bookingcount and Scope: the count is for grouping, the scope is the // booking ids as a JSON array so a specific parcel's history can be found. // Not a join table — a finding's scope is read whole or not at all, and a // row per booking per finding would multiply this table by average scope // size for no query anyone needs. Bookingcount int `json:"bookingcount" gorm:"column:bookingcount;default:0"` Scope string `json:"scope" gorm:"column:scope;type:jsonb"` Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"` // Firstseenat vs Lastseenat: an upsert on fingerprint bumps the last-seen // and the count, so how LONG a finding has been open is answerable. That is // the actual signal of an ignored finding — not that it exists, but that it // has existed for six hours. Firstseenat time.Time `json:"firstseenat" gorm:"column:firstseenat;not null;index:idx_aiskillfindings_skill_time,priority:2"` Lastseenat time.Time `json:"lastseenat" gorm:"column:lastseenat;not null"` Seencount int `json:"seencount" gorm:"column:seencount;default:1"` // Actedat / Actedby / Actionresult record an operator carrying out the // proposal. Null means nobody did — which is data, not a gap. Actedat *time.Time `json:"actedat" gorm:"column:actedat"` Actedby *int `json:"actedby" gorm:"column:actedby"` Actionresult string `json:"actionresult" gorm:"column:actionresult;size:20"` // ok, partial, failed // Clearedat is set when a later scan no longer raises this fingerprint. // Paired with Actedat it answers the question the whole table is for: did // acting clear it, or did it clear on its own? Clearedat *time.Time `json:"clearedat" gorm:"column:clearedat"` } func (AISkillFinding) TableName() string { return "aiskillfindings" }