Files
doormile_backend/internal/ai/playground/tools.go

179 lines
5.8 KiB
Go

package playground
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// The read tools the backend serves itself. Every other read tool in the
// registry runs inside AI_engine (decide_*), calls an external service
// (sequence_stops, simulate_pricing_quote) or an engine-only internal route
// (list_express_*), and answers "unavailable" in the playground.
//
// Each executor selects named, non-personal columns only — never addresses,
// names, phones or notes — and Redact runs over the result as a backstop.
// Coordinates are rounded to 2 decimals (about 1 km).
const (
scanDefaultLimit = 20
scanMaxLimit = 50
nearbyMaxKm = 10.0
nearbyMaxCount = 20
)
// bookingRow is the only projection of a booking the playground exposes.
type bookingRow struct {
Bookingid int `json:"bookingid"`
Bookingno string `json:"bookingno"`
Tenantid *int `json:"tenantid"`
Status string `json:"status"`
Pickuppincode string `json:"pickuppincode"`
Deliverypincode string `json:"deliverypincode"`
Deliverycity string `json:"deliverycity"`
Pickuplatitude float64 `json:"pickuplat"`
Pickuplongitude float64 `json:"pickuplon"`
Deliverylatitude float64 `json:"deliverylat"`
Deliverylongitude float64 `json:"deliverylon"`
Assignedmileruserid *int `json:"assignedmileruserid"`
Routekm *float64 `json:"routekm"`
Createdat *time.Time `json:"-"`
Createdatist string `json:"createdat_ist,omitempty" gorm:"-"`
}
const bookingColumns = "bookingid, bookingno, tenantid, status, pickuppincode, deliverypincode, deliverycity, " +
"pickuplatitude, pickuplongitude, deliverylatitude, deliverylongitude, assignedmileruserid, routekm, createdat"
func round2(f float64) float64 { return math.Round(f*100) / 100 }
func (r *bookingRow) tidy() {
r.Pickuplatitude, r.Pickuplongitude = round2(r.Pickuplatitude), round2(r.Pickuplongitude)
r.Deliverylatitude, r.Deliverylongitude = round2(r.Deliverylatitude), round2(r.Deliverylongitude)
if r.Createdat != nil {
// pickupbookings.createdat is timestamp WITHOUT zone holding IST digits.
r.Createdatist = r.Createdat.Format("2006-01-02 15:04")
}
}
// Executors returns the read tools this backend can serve. A nil db or rdb
// just leaves the tools that need it out (they then answer "unavailable").
func Executors(db *gorm.DB, rdb *redis.Client) map[string]Executor {
execs := map[string]Executor{}
if db != nil {
execs["get_booking_cache"] = getBooking(db)
execs["scan_bookings"] = scanBookings(db)
}
if rdb != nil {
execs["nearby_milers"] = nearbyMilers(rdb)
}
return execs
}
func decode(input json.RawMessage, into any) error {
if len(input) == 0 {
return nil
}
if err := json.Unmarshal(input, into); err != nil {
return fmt.Errorf("invalid input: %v", err)
}
return nil
}
func getBooking(db *gorm.DB) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
BookingID int `json:"booking_id"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.BookingID <= 0 {
return nil, errors.New("booking_id (a positive integer) is required")
}
var row bookingRow
res := db.WithContext(ctx).Table("pickupbookings").Select(bookingColumns).
Where("bookingid = ?", in.BookingID).Limit(1).Scan(&row)
if res.Error != nil {
return nil, errors.New("booking lookup failed")
}
if res.RowsAffected == 0 {
return map[string]any{"found": false, "booking_id": in.BookingID}, nil
}
row.tidy()
return map[string]any{"found": true, "booking": row, "source": "pickupbookings table"}, nil
}
}
func scanBookings(db *gorm.DB) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
Status string `json:"status"`
Limit int `json:"limit"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.Limit <= 0 {
in.Limit = scanDefaultLimit
}
if in.Limit > scanMaxLimit {
in.Limit = scanMaxLimit
}
q := db.WithContext(ctx).Table("pickupbookings").Select(bookingColumns)
if s := strings.TrimSpace(in.Status); s != "" {
q = q.Where("status = ?", s)
}
var rows []bookingRow
if err := q.Order("bookingid DESC").Limit(in.Limit).Scan(&rows).Error; err != nil {
return nil, errors.New("booking scan failed")
}
byStatus := map[string]int{}
for i := range rows {
rows[i].tidy()
byStatus[rows[i].Status]++
}
return map[string]any{"count": len(rows), "bystatus": byStatus, "bookings": rows, "order": "newest first"}, nil
}
}
func nearbyMilers(rdb *redis.Client) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
RadiusKm float64 `json:"radius_km"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.Lat == nil || in.Lon == nil || math.Abs(*in.Lat) > 90 || math.Abs(*in.Lon) > 180 {
return nil, errors.New("lat and lon (decimal degrees) are required")
}
if in.RadiusKm <= 0 || in.RadiusKm > nearbyMaxKm {
in.RadiusKm = 5
}
locs, err := rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{
GeoSearchQuery: redis.GeoSearchQuery{
Longitude: *in.Lon, Latitude: *in.Lat,
Radius: in.RadiusKm, RadiusUnit: "km", Sort: "ASC", Count: nearbyMaxCount,
},
WithDist: true,
}).Result()
if err != nil {
return nil, errors.New("live rider positions are unavailable")
}
riders := make([]map[string]any, 0, len(locs))
for _, l := range locs {
riders = append(riders, map[string]any{"miler": l.Name, "distancekm": math.Round(l.Dist*100) / 100})
}
return map[string]any{"count": len(riders), "radiuskm": in.RadiusKm, "riders": riders}, nil
}
}