241 lines
8.7 KiB
Go
241 lines
8.7 KiB
Go
package outcomes
|
|
|
|
import (
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"doormile/constants"
|
|
"doormile/models"
|
|
"doormile/utils"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Seeding the decision memory from history, so recall is not useless for weeks.
|
|
//
|
|
// ─── The cold-start problem this solves ────────────────────────────────────
|
|
//
|
|
// `/internal/agent-decisions/similar` filters on `outcome IS NOT NULL`. A row
|
|
// becomes eligible only after the outcome sweeper has judged it, and a decision
|
|
// is only judged once its window has closed. So the sequence for a freshly
|
|
// switched-on memory is:
|
|
//
|
|
// switch embeddings on -> wait for stalls to happen -> wait 48h per decision
|
|
// -> the sweeper labels them -> only now does recall return anything
|
|
//
|
|
// With autonomy gates off and two decision types, that is weeks of recall
|
|
// returning [] — which reads as "retrieval does not help here" rather than
|
|
// "retrieval has nothing to retrieve yet". The first conclusion is wrong and
|
|
// expensive to un-learn.
|
|
//
|
|
// This backfills decisions from bookings whose outcome is ALREADY known.
|
|
//
|
|
// ─── What it does and does not claim ───────────────────────────────────────
|
|
//
|
|
// A backfilled row is not a decision the engine made. It is a record of a
|
|
// situation that occurred and how it ended, shaped so the retrieval path can
|
|
// use it as precedent. That distinction is recorded honestly:
|
|
//
|
|
// decision.action = "none" — nothing was decided; nobody intervened
|
|
// decision.source = "backfill" — so these are distinguishable forever
|
|
// reasoning = states plainly that this is historical, not a decision
|
|
//
|
|
// Why that matters: as_prompt_block renders `action` to the model. Writing a
|
|
// plausible-looking action here would teach the model that an action it never
|
|
// took produced the outcome that followed — which is worse than no memory.
|
|
// "none" is honest: this is what happened when nothing was done.
|
|
//
|
|
// ─── It writes no embeddings ───────────────────────────────────────────────
|
|
//
|
|
// Embedding is the engine's job (AI_engine/core/embeddings.py) and needs the
|
|
// provider key, which this process does not have. Backfilled rows land with
|
|
// NULL context_embedding and are invisible to similarity search until
|
|
// something embeds them. That is deliberate: a backfill that silently created
|
|
// un-embedded rows AND claimed to have seeded the memory would be the worse
|
|
// failure. BackfillStats reports the count so the caller knows what is owed.
|
|
|
|
// BackfillStats is what one run produced.
|
|
type BackfillStats struct {
|
|
Scanned int `json:"scanned"`
|
|
Inserted int `json:"inserted"`
|
|
Skipped int `json:"skipped"`
|
|
// NeedsEmbedding is Inserted — every backfilled row still needs a vector
|
|
// before it can be retrieved. Surfaced separately so it cannot be missed.
|
|
NeedsEmbedding int `json:"needsembedding"`
|
|
}
|
|
|
|
// historicalRow is one past booking whose ending is known.
|
|
//
|
|
// Joins through consignment_booking, never consignments.bookingid — that column
|
|
// does not exist, and the legacy pickupbookings.consignmentid link names only
|
|
// the first order of a multi-destination pickup (hazard H4).
|
|
type historicalRow struct {
|
|
Bookingid int
|
|
Tenantid *uint64
|
|
Deliverypincode string
|
|
Status string
|
|
Createdat time.Time
|
|
Deliveredat *time.Time
|
|
Sladueat *time.Time
|
|
Attemptcount int
|
|
Chargeableweight float64
|
|
}
|
|
|
|
const historicalSQL = `
|
|
SELECT pb.bookingid,
|
|
pb.tenantid AS tenantid,
|
|
COALESCE(c.deliverypincode, '') AS deliverypincode,
|
|
COALESCE(c.status, pb.status) AS status,
|
|
c.createdat AS createdat,
|
|
del.createdat AS deliveredat,
|
|
c.sladueat AS sladueat,
|
|
COALESCE(c.attemptcount, 0) AS attemptcount,
|
|
COALESCE(c.chargeableweight, 0) AS chargeableweight
|
|
FROM consignments c
|
|
JOIN consignment_booking cb ON cb.consignmentid = c.consignmentid
|
|
JOIN pickupbookings pb ON pb.bookingid = cb.bookingid
|
|
LEFT JOIN (
|
|
SELECT DISTINCT ON (consignmentid) consignmentid, createdat
|
|
FROM consignmenthistory
|
|
WHERE eventstatus = ?
|
|
ORDER BY consignmentid, createdat ASC
|
|
) del ON del.consignmentid = c.consignmentid
|
|
WHERE c.deletedat IS NULL
|
|
-- Only parcels whose story has ended. An in-flight parcel has no outcome to
|
|
-- learn from, and guessing one is exactly what this must not do.
|
|
AND (del.createdat IS NOT NULL OR c.status IN ?)
|
|
-- Not already backfilled or decided for. The uniqueness is on
|
|
-- (decision_type, booking_id), enforced here rather than by a constraint
|
|
-- because real decisions legitimately repeat for one booking.
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM agent_decisions d
|
|
WHERE d.booking_id = pb.bookingid AND d.decision_type = ?
|
|
)
|
|
ORDER BY c.createdat DESC
|
|
LIMIT ?
|
|
`
|
|
|
|
// BackfillDecisionType is its own type, kept separate from the engine's
|
|
// `stall_response` and `assignment_failure`. Recall is per-type, so backfilled
|
|
// precedent is retrievable on purpose and never silently mixed into a type the
|
|
// engine thinks it authored.
|
|
const BackfillDecisionType = "historical_delivery"
|
|
|
|
// Backfill writes historical precedent rows. Idempotent: a booking that already
|
|
// has a decision of this type is skipped, so re-running adds only what is new.
|
|
//
|
|
// limit bounds one run — this scans delivery history, which is the largest
|
|
// table pair in the database.
|
|
func Backfill(gdb *gorm.DB, limit int) (BackfillStats, error) {
|
|
if gdb == nil {
|
|
return BackfillStats{}, nil
|
|
}
|
|
if limit <= 0 {
|
|
limit = 2000
|
|
}
|
|
|
|
terminal := []string{
|
|
constants.ConsignmentDelivered,
|
|
"Returned_to_Sender",
|
|
"Missing",
|
|
"Damaged",
|
|
}
|
|
|
|
var rows []historicalRow
|
|
if err := gdb.Raw(historicalSQL,
|
|
constants.ConsignmentDelivered, terminal, BackfillDecisionType, limit,
|
|
).Scan(&rows).Error; err != nil {
|
|
return BackfillStats{}, err
|
|
}
|
|
|
|
stats := BackfillStats{Scanned: len(rows)}
|
|
batch := make([]models.AgentDecision, 0, len(rows))
|
|
|
|
for _, r := range rows {
|
|
outcome := OutcomeFailure
|
|
switch {
|
|
case r.Deliveredat == nil:
|
|
// Terminal but never delivered: returned, lost or damaged.
|
|
outcome = OutcomeFailure
|
|
case r.Sladueat != nil && r.Deliveredat.After(*r.Sladueat):
|
|
// Delivered late. Counting this a success would teach that any
|
|
// eventual delivery is a good outcome — the same trap the
|
|
// stall_response rule avoids.
|
|
outcome = OutcomeFailure
|
|
default:
|
|
outcome = OutcomeSuccess
|
|
}
|
|
|
|
// The facts shape mirrors what AI_engine puts in context.facts, so an
|
|
// embedding of a backfilled row sits in the same space as a real one.
|
|
// If these diverge, retrieval returns neighbours that are near in
|
|
// vector space for the wrong reasons.
|
|
facts := map[string]any{
|
|
"booking_id": r.Bookingid,
|
|
"delivery_pincode": r.Deliverypincode,
|
|
"attempt_count": r.Attemptcount,
|
|
"chargeable_weight": r.Chargeableweight,
|
|
"final_status": r.Status,
|
|
}
|
|
if r.Deliveredat != nil {
|
|
facts["hours_to_deliver"] = int(r.Deliveredat.Sub(r.Createdat).Hours())
|
|
}
|
|
|
|
contextJSON, err := json.Marshal(map[string]any{
|
|
"facts": facts,
|
|
"model": "none",
|
|
"source": "backfill",
|
|
})
|
|
if err != nil {
|
|
stats.Skipped++
|
|
continue
|
|
}
|
|
decisionJSON, err := json.Marshal(map[string]any{
|
|
// Honest: nobody decided anything. See the package comment — a
|
|
// plausible-looking action here would be a fabricated lesson.
|
|
"action": "none",
|
|
"confidence": 0.0,
|
|
"source": "backfill",
|
|
})
|
|
if err != nil {
|
|
stats.Skipped++
|
|
continue
|
|
}
|
|
|
|
recordedAt := utils.DBNow()
|
|
batch = append(batch, models.AgentDecision{
|
|
DecisionType: BackfillDecisionType,
|
|
BookingID: u64(r.Bookingid),
|
|
TenantID: r.Tenantid,
|
|
Context: string(contextJSON),
|
|
Decision: string(decisionJSON),
|
|
Reasoning: "Historical outcome backfilled from delivery records. No agent decision was made for this booking; this row records what happened when nothing intervened.",
|
|
Outcome: &outcome,
|
|
OutcomeRecordedAt: &recordedAt,
|
|
CreatedAt: r.Createdat,
|
|
})
|
|
}
|
|
|
|
if len(batch) == 0 {
|
|
return stats, nil
|
|
}
|
|
if err := gdb.CreateInBatches(&batch, 200).Error; err != nil {
|
|
return stats, err
|
|
}
|
|
stats.Inserted = len(batch)
|
|
stats.NeedsEmbedding = len(batch)
|
|
|
|
utils.Info("outcomes: backfilled historical precedent",
|
|
"scanned", stats.Scanned, "inserted", stats.Inserted, "skipped", stats.Skipped,
|
|
"note", "rows have no embedding yet and are not retrievable until one is written")
|
|
return stats, nil
|
|
}
|
|
|
|
func u64(n int) *uint64 {
|
|
if n <= 0 {
|
|
return nil
|
|
}
|
|
v := uint64(n)
|
|
return &v
|
|
}
|