updates on the ai agents and time series prediction and updates on the api to
This commit is contained in:
@@ -42,6 +42,8 @@ func Migrate(db *gorm.DB) error {
|
||||
&models.CarrierPricing{},
|
||||
&models.DoormilePricing{},
|
||||
&models.AgentDecision{},
|
||||
&models.AISkillFinding{},
|
||||
&models.DemandForecast{},
|
||||
&models.HubStaffAccount{},
|
||||
&models.HubConversation{},
|
||||
&models.HubMessage{},
|
||||
@@ -89,6 +91,23 @@ func Migrate(db *gorm.DB) error {
|
||||
utils.Error("⚠️ AI registry seed failed", "error", err.Error())
|
||||
}
|
||||
|
||||
// pgvector must exist before the vector column can be declared. This was
|
||||
// missing: the ALTER TABLE below has always run against a database where
|
||||
// the extension was installed by hand, and its failure is logged
|
||||
// non-fatally — so on any database where it was not, the column and its
|
||||
// index silently never existed and every similarity query 500s.
|
||||
//
|
||||
// Creating an extension needs rights a plain application role may not
|
||||
// have. A failure here is logged and not fatal for the same reason the
|
||||
// column add is not: the registry and decision log are not on a booking's
|
||||
// path, and the API must keep serving orders.
|
||||
if res := db.Exec(`CREATE EXTENSION IF NOT EXISTS vector`); res.Error != nil {
|
||||
utils.Error("⚠️ pgvector extension unavailable — decision memory and similarity search are disabled",
|
||||
"error", res.Error)
|
||||
} else {
|
||||
utils.Info("✅ pgvector extension ready")
|
||||
}
|
||||
|
||||
if res := db.Exec(`ALTER TABLE agent_decisions ADD COLUMN IF NOT EXISTS context_embedding vector(1536)`); res.Error != nil {
|
||||
utils.Error("❌ Failed to add context_embedding column", "error", res.Error)
|
||||
} else {
|
||||
@@ -169,5 +188,105 @@ func Migrate(db *gorm.DB) error {
|
||||
utils.Info("✅ bookingstageevents (bookingid, occurredat) index ready")
|
||||
}
|
||||
|
||||
// Reverse logistics for clients onboarded before the field existed.
|
||||
//
|
||||
// They have no delivery category and were all using returns, so they must
|
||||
// keep them — new information must not withdraw a working capability. Done
|
||||
// as a one-off backfill rather than a column default: a default makes GORM
|
||||
// omit an explicit `false` on every future INSERT, which is how Food
|
||||
// clients were silently created with returns enabled.
|
||||
//
|
||||
// Guarded on deliverycategory being empty, so it only ever touches rows
|
||||
// that predate the field and can never re-enable returns for a client an
|
||||
// operator has deliberately switched off.
|
||||
if res := db.Exec(`UPDATE tenants SET reverselogisticsenabled = true
|
||||
WHERE COALESCE(deliverycategory, '') = ''`); res.Error != nil {
|
||||
utils.Error("❌ Failed to backfill tenants.reverselogisticsenabled", "error", res.Error)
|
||||
} else if res.RowsAffected > 0 {
|
||||
utils.Info("✅ reverse logistics kept on for pre-existing clients", "rows", res.RowsAffected)
|
||||
}
|
||||
|
||||
// ── ETA calibration (docs/prediction-plan.md rung 1.1) ──────────────────
|
||||
//
|
||||
// consignment_booking resolves a consignment to its booking. There is no
|
||||
// consignments.bookingid column, and the link runs two ways: through
|
||||
// bookingdestinations for multi-destination customer pickups, and through
|
||||
// the legacy pickupbookings.consignmentid for console/express bookings and
|
||||
// anything written before the fan-out existed. That legacy column names
|
||||
// only the FIRST order of a multi-destination pickup, which is why the
|
||||
// bookingdestinations path is tried first and the fallback is guarded.
|
||||
//
|
||||
// Every query that needs a consignment's booking goes through this view.
|
||||
// Joining consignments to bookings by hand attaches features to the wrong
|
||||
// parcel for multi-destination bookings, silently — the defect CLAUDE.md
|
||||
// §8.5 describes and docs/prediction-plan.md records as hazard H4.
|
||||
if res := db.Exec(`CREATE OR REPLACE VIEW consignment_booking AS
|
||||
SELECT c.consignmentid,
|
||||
COALESCE(bd.bookingid, pb.bookingid) AS bookingid,
|
||||
bd.bookingdestinationid
|
||||
FROM consignments c
|
||||
LEFT JOIN bookingdestinations bd ON bd.consignmentid = c.consignmentid
|
||||
LEFT JOIN pickupbookings pb ON pb.consignmentid = c.consignmentid
|
||||
AND bd.bookingid IS NULL`); res.Error != nil {
|
||||
utils.Error("❌ Failed to create consignment_booking view", "error", res.Error)
|
||||
} else {
|
||||
utils.Info("✅ consignment_booking view ready")
|
||||
}
|
||||
|
||||
// etacalibration holds the grouped p80 of actual-over-routed duration that
|
||||
// internal/prediction multiplies a routed ETA by. Written only by the
|
||||
// calibration sweeper, read at boot. Empty is the normal state until
|
||||
// ROUTE_OPTIMIZER_URL is configured and deliveries accumulate — the
|
||||
// estimator falls back to the existing promise tables while it is.
|
||||
if res := db.Exec(`CREATE TABLE IF NOT EXISTS etacalibration (
|
||||
calibrationid SERIAL PRIMARY KEY,
|
||||
scope VARCHAR(24) NOT NULL,
|
||||
zone VARCHAR(8),
|
||||
hourbucket INTEGER,
|
||||
weekday INTEGER,
|
||||
factor DOUBLE PRECISION NOT NULL,
|
||||
handling DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
samples INTEGER NOT NULL,
|
||||
refreshedat TIMESTAMPTZ NOT NULL
|
||||
)`); res.Error != nil {
|
||||
utils.Error("❌ Failed to create etacalibration table", "error", res.Error)
|
||||
} else {
|
||||
utils.Info("✅ etacalibration table ready")
|
||||
}
|
||||
|
||||
if res := db.Exec(`CREATE INDEX IF NOT EXISTS idx_etacalibration_lookup
|
||||
ON etacalibration (scope, zone, hourbucket, weekday)`); res.Error != nil {
|
||||
utils.Error("❌ Failed to create etacalibration lookup index", "error", res.Error)
|
||||
} else {
|
||||
utils.Info("✅ etacalibration lookup index ready")
|
||||
}
|
||||
|
||||
// The calibration scan joins deliveries to their assignment. Without this
|
||||
// the refresh sequential-scans consignmenthistory on every run.
|
||||
//
|
||||
// CONCURRENTLY is not optional here. A plain CREATE INDEX takes a SHARE
|
||||
// lock, which blocks INSERTs for as long as the build takes — and
|
||||
// consignmenthistory gets a row on EVERY parcel status change. On a table
|
||||
// of any size that means riders cannot complete deliveries while the
|
||||
// migration runs: a revenue-path outage caused by an index for a
|
||||
// background job that is switched off by default.
|
||||
//
|
||||
// The tradeoffs CONCURRENTLY brings, and why they are acceptable:
|
||||
// - It cannot run inside a transaction. db.Exec is autocommit, so this
|
||||
// is fine, but it must never be moved inside a tx block.
|
||||
// - It can fail and leave an INVALID index behind, which is then not
|
||||
// used by the planner and must be dropped by hand. That degrades the
|
||||
// calibration scan to a sequential one; it does not affect any
|
||||
// booking. The log line below names the recovery.
|
||||
if res := db.Exec(`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_consignmenthistory_status_consignment
|
||||
ON consignmenthistory (eventstatus, consignmentid)`); res.Error != nil {
|
||||
utils.Error("⚠️ consignmenthistory index not created — the ETA calibration scan will be slower. "+
|
||||
"If this left an INVALID index, drop it before retrying: "+
|
||||
"DROP INDEX IF EXISTS idx_consignmenthistory_status_consignment",
|
||||
"error", res.Error)
|
||||
} else {
|
||||
utils.Info("✅ consignmenthistory (eventstatus, consignmentid) index ready")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user