updates on the ai agents and time series prediction and updates on the api to

This commit is contained in:
2026-10-09 13:50:30 +05:30
parent 29690c56f2
commit 153be40e5c
42 changed files with 4889 additions and 186 deletions

View File

@@ -3,9 +3,22 @@ package models
import "time"
type AgentDecision struct {
ID uint64 `gorm:"primaryKey"`
DecisionType string `gorm:"size:50;index"`
BookingID *uint64 `gorm:"index"`
ID uint64 `gorm:"primaryKey"`
DecisionType string `gorm:"size:50;index"`
BookingID *uint64 `gorm:"index"`
// TenantID scopes retrieval. Without it, similarity search would return one
// client's operational history as precedent for a decision about another —
// and because the engine feeds that precedent to the model, one tenant's
// past would shape another tenant's dispatch.
//
// Nullable because the engine does not always know the tenant (a B2C
// booking carries a nil tenantid by design). A NULL row is recallable only
// by a NULL-tenant query, never by a tenant's own — the safer default,
// given console logins are already unscoped when tenantid is NULL.
//
// Added before the table filled on purpose: adding it afterwards is a
// backfill with no way to attribute the rows already there.
TenantID *uint64 `gorm:"column:tenantid;index"`
Context string `gorm:"type:jsonb"`
Decision string `gorm:"type:jsonb"`
Reasoning string `gorm:"type:text"`

92
models/ai_findings.go Normal file
View File

@@ -0,0 +1,92 @@
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" }

53
models/demand_forecast.go Normal file
View File

@@ -0,0 +1,53 @@
package models
import "time"
// DemandForecast is one zone-day the engine expects.
//
// ─── Why this is stored rather than computed on read ───────────────────────
//
// The forecast is produced in Python (AI_engine/prediction), because the model
// is: Prophet where it beats a seasonal baseline, the baseline otherwise. The
// console and any staffing decision need it in Go. So the engine writes here
// and the backend serves it — the same split the agent registry and the
// decision log already use, and the reason the /internal/* surface exists.
//
// It is also the honest shape: a forecast is a thing produced at a moment by a
// model, not a function of the current table. Recomputing it on every read
// would make yesterday's number unrecoverable, which is exactly what you want
// when asking "was the forecast any good".
type DemandForecast struct {
Forecastid int `json:"forecastid" gorm:"primaryKey;column:forecastid;autoIncrement"`
// Zone is the first three digits of a pickup pincode — the same grain the
// hub console scopes on (pickuppincode LIKE '641%') and the same grain
// internal/prediction calibrates ETA at. Keeping one definition of "zone"
// across both is deliberate.
Zone string `json:"zone" gorm:"column:zone;size:8;not null;uniqueIndex:uq_demandforecast_zone_day,priority:1"`
// Forday is the day being predicted, not the day it was predicted on.
Forday time.Time `json:"forday" gorm:"column:forday;not null;uniqueIndex:uq_demandforecast_zone_day,priority:2;index"`
Expectedbookings int `json:"expectedbookings" gorm:"column:expectedbookings;not null"`
// Model and Reason record WHICH model produced this and why it was chosen —
// "prophet beat the weekly baseline over 12 folds (18% lower MAE)", or
// "prophet is not installed in this image". Stored because the choice is
// made per zone from that zone's own history, so without it nobody can tell
// whether a bad forecast came from a bad model or from a thin series.
Model string `json:"model" gorm:"column:model;size:32;not null"`
Reason string `json:"reason" gorm:"column:reason"`
// Observations is how many days of history the forecast was fitted on, and
// Baselinemae/Modelmae are the backtest scores. A forecast with 31
// observations and a model barely beating the baseline deserves less trust
// than one with 400, and this is what lets a reader see that rather than
// taking the number at face value.
Observations int `json:"observations" gorm:"column:observations;default:0"`
Baselinemae *float64 `json:"baselinemae" gorm:"column:baselinemae"`
Modelmae *float64 `json:"modelmae" gorm:"column:modelmae"`
Generatedat time.Time `json:"generatedat" gorm:"column:generatedat;not null"`
}
func (DemandForecast) TableName() string { return "demandforecast" }

View File

@@ -14,11 +14,59 @@ type Tenant struct {
// parcel; not for a food order, where it just slows every drop down.
// Defaults off: turning it on platform-wide would block deliveries for
// clients whose customer app has no way to show the code yet.
Requiredeliveryotp bool `json:"requiredeliveryotp" gorm:"column:requiredeliveryotp;default:false"`
Requiredeliveryotp bool `json:"requiredeliveryotp" gorm:"column:requiredeliveryotp;default:false"`
// Deliverycategory is WHAT this client ships, from the same vocabulary
// pricing uses (constants.DeliveryCategories) — a tenant whose category is
// not a pricing category cannot be priced, so the two are one list.
//
// It decides whether reverse logistics applies: a returned meal is waste,
// not inventory, so Food clients get no RTO path. See
// constants.ReverseLogisticsAllowed for the reasoning.
//
// Empty on every tenant onboarded before this field existed, and empty
// must keep behaving as it always did — which is why
// ReverseLogisticsAllowed treats an unknown category as returnable rather
// than defaulting the new flag to off.
Deliverycategory string `json:"deliverycategory" gorm:"column:deliverycategory;size:32"`
// Reverselogisticsenabled is the OPERATIONAL consequence, stored rather
// than derived on every read.
//
// Two reasons it is its own column. First, an operator may need to turn
// returns off for a non-Food client (a clearance line that is final sale)
// or on for a Food client (a caterer who takes back equipment), and a
// derived value cannot be overridden. Second, deriving it would mean that
// editing a client's category silently changes whether live parcels can be
// returned — a stored flag makes that an explicit second decision.
// A POINTER, deliberately, and this is load-bearing.
//
// GORM omits a zero-value field from an INSERT when the tag declares a
// default — so a plain `bool` set to false was silently dropped and the
// column default (true) applied, creating Food clients with reverse
// logistics ENABLED: the exact case this feature exists to prevent,
// failing silently. A test caught it.
//
// Dropping the default instead is worse: ADD COLUMN ... NOT NULL with no
// default fails outright on a populated tenants table.
//
// A pointer gives both. nil means "not stated" and the column default
// (true) applies — which is what every row predating this field wants. A
// non-nil false is written as false, because GORM never omits a non-nil
// pointer. Read it through ReturnsEnabled(), never directly.
Reverselogisticsenabled *bool `json:"reverselogisticsenabled" gorm:"column:reverselogisticsenabled;default:true"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
// ReturnsEnabled is the one way to read Reverselogisticsenabled.
//
// nil means the client predates the field, and those clients were all using
// returns — so nil is TRUE. Reading the pointer directly invites a nil deref
// or, worse, treating "not stated" as "disabled" and silently withdrawing a
// capability a client is already using.
func (t Tenant) ReturnsEnabled() bool {
return t.Reverselogisticsenabled == nil || *t.Reverselogisticsenabled
}
func (Tenant) TableName() string {
return "tenants"
}