hub apis
This commit is contained in:
330
internal/assignment/ai_layer.go
Normal file
330
internal/assignment/ai_layer.go
Normal 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
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
@@ -74,7 +73,7 @@ func AssignCRMMiler(bookingID int) {
|
||||
// Returns (false, err) on hard errors (booking missing, DB failure).
|
||||
func tryAssign(bookingID int) (bool, error) {
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
||||
if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil {
|
||||
return false, fmt.Errorf("load booking: %w", err)
|
||||
}
|
||||
|
||||
@@ -100,12 +99,12 @@ func tryAssign(bookingID int) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
candidate, found := pickBestMiler(nearby)
|
||||
candidate, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := commitAssignment(&booking, candidate); err != nil {
|
||||
if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil {
|
||||
return false, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
@@ -140,58 +139,9 @@ func queryNearbyMilers(lat, lon float64) ([]redis.GeoLocation, error) {
|
||||
return locs, nil
|
||||
}
|
||||
|
||||
// pickBestMiler filters the nearby list for eligibility, then returns the candidate
|
||||
// with the lowest score. score = distance_km*1.0 + active_bookings*2.0 - rating*0.5
|
||||
func pickBestMiler(nearby []redis.GeoLocation) (*milerCandidate, bool) {
|
||||
var best *milerCandidate
|
||||
bestScore := 1e18
|
||||
|
||||
for _, loc := range nearby {
|
||||
milerUserID, err := strconv.Atoi(loc.Name)
|
||||
if err != nil {
|
||||
utils.Warn("CRMAssignment: 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
|
||||
}
|
||||
|
||||
score := loc.Dist*1.0 + float64(activeCount)*2.0 - profile.Rating*0.5
|
||||
|
||||
if score < bestScore {
|
||||
bestScore = score
|
||||
best = &milerCandidate{
|
||||
profile: profile,
|
||||
distanceKm: loc.Dist,
|
||||
activeBookings: activeCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best, best != nil
|
||||
}
|
||||
|
||||
// commitAssignment writes the BookingAssignment row, updates the booking and the
|
||||
// miler's availability status in a single transaction, then publishes to NATS.
|
||||
func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate) error {
|
||||
func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate, agentDecisionID *uint64) error {
|
||||
milerUserID := candidate.profile.Userid
|
||||
|
||||
tx := db.DB.Begin()
|
||||
@@ -201,6 +151,7 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate)
|
||||
Mileruserid: milerUserID,
|
||||
Assignmentstatus: constants.AssignmentAssigned,
|
||||
Assignedat: time.Now(),
|
||||
AgentDecisionID: agentDecisionID,
|
||||
}
|
||||
if err := tx.Create(&assignment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
@@ -231,7 +182,7 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate)
|
||||
"miler_id", milerUserID,
|
||||
"distance_km", candidate.distanceKm,
|
||||
"active_bookings", candidate.activeBookings,
|
||||
"score", candidate.distanceKm*1.0+float64(candidate.activeBookings)*2.0-candidate.profile.Rating*0.5,
|
||||
"agent_decision_id", agentDecisionID,
|
||||
)
|
||||
|
||||
publishAssignment(booking, milerUserID)
|
||||
|
||||
@@ -82,7 +82,7 @@ func tryCustomerAssign(bookingID int) (bool, error) {
|
||||
return false, fmt.Errorf("booking %d has no pickup coordinates", bookingID)
|
||||
}
|
||||
|
||||
// Step 1 — Find the best nearby miler (shared GEO logic from crm_assignment.go).
|
||||
// Step 1 — Find the best nearby miler via AI decision engine (GEORADIUS unchanged).
|
||||
nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude)
|
||||
if err != nil {
|
||||
utils.Warn("B2CAssignment: GEO query failed", "booking_id", bookingID, "error", err)
|
||||
@@ -92,7 +92,7 @@ func tryCustomerAssign(bookingID int) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
miler, found := pickBestMiler(nearby)
|
||||
miler, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
@@ -113,11 +113,11 @@ func tryCustomerAssign(bookingID int) (bool, error) {
|
||||
provider = providerResult{company: "Doormile"}
|
||||
}
|
||||
|
||||
// Step 6 — ETA based on miler-to-pickup distance.
|
||||
// Step 3 — ETA based on miler-to-pickup distance.
|
||||
etaMinutes := calculateETA(miler.distanceKm)
|
||||
|
||||
// Steps 3–5 — Commit to DB and publish to NATS.
|
||||
if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes); err != nil {
|
||||
// Steps 4–6 — Commit to DB and publish to NATS.
|
||||
if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes, agentDecisionID); err != nil {
|
||||
return false, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@ func commitCustomerAssignment(
|
||||
miler *milerCandidate,
|
||||
provider providerResult,
|
||||
etaMinutes float64,
|
||||
agentDecisionID *uint64,
|
||||
) error {
|
||||
milerUserID := miler.profile.Userid
|
||||
|
||||
@@ -213,6 +214,7 @@ func commitCustomerAssignment(
|
||||
Mileruserid: milerUserID,
|
||||
Assignmentstatus: constants.AssignmentAssigned,
|
||||
Assignedat: time.Now(),
|
||||
AgentDecisionID: agentDecisionID,
|
||||
}
|
||||
if err := tx.Create(&ba).Error; err != nil {
|
||||
tx.Rollback()
|
||||
@@ -249,6 +251,7 @@ func commitCustomerAssignment(
|
||||
"estimated_price", provider.estimatedPrice,
|
||||
"eta_minutes", etaMinutes,
|
||||
"miler_distance_km", miler.distanceKm,
|
||||
"agent_decision_id", agentDecisionID,
|
||||
)
|
||||
|
||||
publishCustomerAssignment(booking, milerUserID, provider, etaMinutes)
|
||||
|
||||
Reference in New Issue
Block a user