This commit is contained in:
2026-07-04 11:10:52 +05:30
parent bd6427f5db
commit c8adaf7815
18 changed files with 1140 additions and 92 deletions

View File

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