package assignment import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "strconv" "time" "doormile/constants" "doormile/db" "doormile/models" "doormile/utils" "github.com/redis/go-redis/v9" ) // ─── Request / response types ──────────────────────────────────────────────── type aiCandidate struct { MilerID int `json:"miler_id"` DistanceKm float64 `json:"distance_km"` Rating float64 `json:"rating"` ActiveBookings int64 `json:"active_bookings"` OnTimeRate30d float64 `json:"on_time_rate_30d"` CompletedToday int64 `json:"completed_today"` HubID int `json:"hub_id"` HubLoad int64 `json:"hub_load"` HubCapacity int `json:"hub_capacity"` } type aiDecisionRequest struct { Booking aiBookingInfo `json:"booking"` Candidates []aiCandidate `json:"candidates"` Context aiRequestCtx `json:"context"` } type aiBookingInfo struct { PickupLat float64 `json:"pickup_lat"` PickupLon float64 `json:"pickup_lon"` DeliveryLat float64 `json:"delivery_lat"` DeliveryLon float64 `json:"delivery_lon"` ItemCategory string `json:"item_category"` Weight float64 `json:"weight"` ServiceType string `json:"service_type"` } type aiRequestCtx struct { Hour int `json:"hour"` IsPeak bool `json:"is_peak"` Zone string `json:"zone"` } type aiDecisionResponse struct { ChosenMilerID int `json:"chosen_miler_id"` Escalate bool `json:"escalate"` AgentDecisionID uint64 `json:"agent_decision_id"` Reasoning string `json:"reasoning"` } // ─── Main entry point ──────────────────────────────────────────────────────── // selectMilerWithAI collects all eligible milers from the GEORADIUS result, calls // the AI decision engine, and returns the chosen miler plus the decision ID for // audit. Falls back to the original distance/load/rating formula when the AI // layer is unreachable or times out. Returns (nil, nil, false) when there are no // eligible candidates or when the AI layer escalates the booking. func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation) (*milerCandidate, *uint64, bool) { candidates, aiCandidates := collectEligibleCandidates(nearby) if len(candidates) == 0 { return nil, nil, false } decision, err := callDecisionEngine(booking, aiCandidates) if err != nil { utils.Warn("AI_LAYER_FALLBACK: decide-assignment unreachable, using legacy scoring", "booking_id", booking.Bookingid, "error", err) best := pickBestFromCandidates(candidates) return best, nil, best != nil } utils.Info("Assignment: AI layer responded", "booking_id", booking.Bookingid, "escalate", decision.Escalate, "chosen_miler_id", decision.ChosenMilerID, "agent_decision_id", decision.AgentDecisionID, "reasoning", decision.Reasoning, ) if decision.Escalate { utils.Warn("Assignment: AI layer escalated — skipping assignment this attempt", "booking_id", booking.Bookingid, "reasoning", decision.Reasoning, ) return nil, nil, false } var decisionID *uint64 if decision.AgentDecisionID != 0 { id := decision.AgentDecisionID decisionID = &id } for _, c := range candidates { if c.profile.Userid == decision.ChosenMilerID { return c, decisionID, true } } // AI returned an ID that is not in our eligibility set — fall back safely. utils.Warn("AI_LAYER_FALLBACK: chosen miler not in eligible set, using legacy scoring", "booking_id", booking.Bookingid, "chosen_miler_id", decision.ChosenMilerID, ) best := pickBestFromCandidates(candidates) return best, nil, best != nil } // ─── Candidate collection ──────────────────────────────────────────────────── // collectEligibleCandidates iterates the GEORADIUS result, applies eligibility // filters (availability, active-booking cap), and fetches per-miler stats needed // by the AI layer. Returns parallel slices so callers can use either. func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, []aiCandidate) { var candidates []*milerCandidate var aiCandidates []aiCandidate for _, loc := range nearby { milerUserID, err := strconv.Atoi(loc.Name) if err != nil { utils.Warn("Assignment: skipping non-numeric GEO member", "name", loc.Name) continue } var profile models.MilerProfile if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil { continue } if profile.Availabilitystatus != constants.MilerAvailable { continue } var activeCount int64 db.DB.Model(&models.BookingAssignment{}). Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{ constants.AssignmentAssigned, constants.AssignmentAccepted, }). Count(&activeCount) if activeCount >= maxActive { continue } onTimeRate, completedToday := fetchMilerStats(milerUserID) hubID, hubLoad, hubCapacity := fetchHubData(profile.Hubid) candidates = append(candidates, &milerCandidate{ profile: profile, distanceKm: loc.Dist, activeBookings: activeCount, }) aiCandidates = append(aiCandidates, aiCandidate{ MilerID: milerUserID, DistanceKm: loc.Dist, Rating: profile.Rating, ActiveBookings: activeCount, OnTimeRate30d: onTimeRate, CompletedToday: completedToday, HubID: hubID, HubLoad: hubLoad, HubCapacity: hubCapacity, }) } return candidates, aiCandidates } // ─── Per-miler stats ───────────────────────────────────────────────────────── func fetchMilerStats(milerUserID int) (onTimeRate float64, completedToday int64) { type statsRow struct { Total int64 OnTime int64 } var row statsRow db.DB.Raw(` SELECT COUNT(*) AS total, COUNT(CASE WHEN assignmentstatus = 'Completed_OnTime' THEN 1 END) AS on_time FROM bookingassignments WHERE mileruserid = ? AND createdat > NOW() - INTERVAL '30 days' `, milerUserID).Scan(&row) if row.Total == 0 { onTimeRate = 0.85 // neutral assumption for milers with no recent history } else { onTimeRate = float64(row.OnTime) / float64(row.Total) } today := time.Now().Truncate(24 * time.Hour) db.DB.Model(&models.BookingAssignment{}). Where("mileruserid = ? AND createdat >= ? AND assignmentstatus = ?", milerUserID, today, constants.AssignmentCompleted). Count(&completedToday) return } // ─── Hub stats ─────────────────────────────────────────────────────────────── // fetchHubData returns the hub_load (active assignments across the hub) and // hub_capacity for the given hub. Defaults to 50 for capacity when the column // doesn't exist or the miler has no assigned hub. func fetchHubData(hubID *int) (resolvedHubID int, hubLoad int64, hubCapacity int) { hubCapacity = 50 // safe default — also used if hubs.capacity column is absent if hubID == nil { return 0, 0, hubCapacity } resolvedHubID = *hubID // hub_load: total active assignments whose miler belongs to this hub db.DB.Raw(` SELECT COUNT(*) FROM bookingassignments ba JOIN milerprofiles mp ON ba.mileruserid = mp.userid WHERE mp.hubid = ? AND ba.assignmentstatus IN ('Assigned', 'Accepted', 'Pickup_Scheduled') `, resolvedHubID).Scan(&hubLoad) // hub_capacity: gracefully handle column not existing yet var cap int if err := db.DB.Raw(`SELECT capacity FROM hubs WHERE hubid = ?`, resolvedHubID).Scan(&cap).Error; err == nil && cap > 0 { hubCapacity = cap } return } // ─── Fallback scorer ───────────────────────────────────────────────────────── // pickBestFromCandidates applies the original formula to an already-collected // eligible slice: score = distance_km*1.0 + active_bookings*2.0 - rating*0.5 func pickBestFromCandidates(candidates []*milerCandidate) *milerCandidate { var best *milerCandidate bestScore := 1e18 for _, c := range candidates { score := c.distanceKm*1.0 + float64(c.activeBookings)*2.0 - c.profile.Rating*0.5 if score < bestScore { bestScore = score best = c } } return best } // ─── AI layer HTTP call ────────────────────────────────────────────────────── func callDecisionEngine(booking *models.PickupBooking, candidates []aiCandidate) (aiDecisionResponse, error) { baseURL := os.Getenv("AI_LAYER_BASE_URL") if baseURL == "" { baseURL = "https://routemate.workolik.com" } now := time.Now() hour := now.Hour() isPeak := (hour >= 7 && hour <= 10) || (hour >= 17 && hour <= 21) reqBody := aiDecisionRequest{ Booking: aiBookingInfo{ PickupLat: booking.Pickuplatitude, PickupLon: booking.Pickuplongitude, DeliveryLat: booking.Deliverylatitude, DeliveryLon: booking.Deliverylongitude, ItemCategory: b2cFirstParcelCategory(booking), Weight: b2cChargeableWeight(booking), ServiceType: b2cFirstServiceType(booking), }, Candidates: candidates, Context: aiRequestCtx{ Hour: hour, IsPeak: isPeak, Zone: booking.Deliverycity, }, } body, err := json.Marshal(reqBody) if err != nil { return aiDecisionResponse{}, fmt.Errorf("marshal AI request: %w", err) } utils.Info("Assignment: AI layer outgoing payload", "booking_id", booking.Bookingid, "payload", string(body), ) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/api/v1/doormile/decide-assignment", bytes.NewReader(body)) if err != nil { return aiDecisionResponse{}, fmt.Errorf("build AI request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return aiDecisionResponse{}, fmt.Errorf("AI layer call: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return aiDecisionResponse{}, fmt.Errorf("AI layer HTTP %d", resp.StatusCode) } var decision aiDecisionResponse if err := json.NewDecoder(resp.Body).Decode(&decision); err != nil { return aiDecisionResponse{}, fmt.Errorf("decode AI response: %w", err) } return decision, nil }