Add MCP server

This commit is contained in:
Arnaud Delcasse
2025-11-03 11:45:23 +01:00
parent d992a7984f
commit 52de8d363e
18 changed files with 997 additions and 210 deletions

View File

@@ -282,6 +282,8 @@ func (h *ApplicationHandler) GetBeneficiaryData(ctx context.Context, beneficiary
solidarityTransportBookings = append(solidarityTransportBookings, booking)
}
// Don't filter out replaced bookings from beneficiary profile - show all bookings
// Collect unique driver IDs
driverIDs := []string{}
driverIDsMap := make(map[string]bool)

View File

@@ -38,6 +38,16 @@ type SearchJourneysResult struct {
KnowledgeBaseResults []any
}
// SearchJourneyOptions contains per-request options for journey search
type SearchJourneyOptions struct {
DisableSolidarityTransport bool
DisableOrganizedCarpool bool
DisableCarpoolOperators bool
DisableTransit bool
DisableFleetVehicles bool
DisableKnowledgeBase bool
}
// SearchJourneys performs the business logic for journey search
func (h *ApplicationHandler) SearchJourneys(
ctx context.Context,
@@ -46,6 +56,8 @@ func (h *ApplicationHandler) SearchJourneys(
destinationGeo *geojson.Feature,
passengerID string,
solidarityTransportExcludeDriver string,
solidarityExcludeGroupId string,
options *SearchJourneyOptions,
) (*SearchJourneysResult, error) {
var (
// Results
@@ -64,6 +76,19 @@ func (h *ApplicationHandler) SearchJourneys(
if departureGeo != nil && destinationGeo != nil && !departureDateTime.IsZero() {
searched = true
// Default options if not provided
if options == nil {
options = &SearchJourneyOptions{}
}
// Check solution type configurations (global config AND per-request options)
solidarityTransportEnabled := h.config.GetBool("modules.journeys.solutions.solidarity_transport.enabled") && !options.DisableSolidarityTransport
organizedCarpoolEnabled := h.config.GetBool("modules.journeys.solutions.organized_carpool.enabled") && !options.DisableOrganizedCarpool
carpoolOperatorsEnabled := h.config.GetBool("modules.journeys.solutions.carpool_operators.enabled") && !options.DisableCarpoolOperators
transitEnabled := h.config.GetBool("modules.journeys.solutions.transit.enabled") && !options.DisableTransit
fleetVehiclesEnabled := h.config.GetBool("modules.journeys.solutions.fleet_vehicles.enabled") && !options.DisableFleetVehicles
knowledgeBaseEnabled := h.config.GetBool("modules.journeys.solutions.knowledge_base.enabled") && !options.DisableKnowledgeBase
// SOLIDARITY TRANSPORT
var err error
drivers, err = h.services.GetAccountsInNamespacesMap([]string{"solidarity_drivers", "organized_carpool_drivers"})
@@ -74,36 +99,58 @@ func (h *ApplicationHandler) SearchJourneys(
protodep, _ := transformers.GeoJsonToProto(departureGeo)
protodest, _ := transformers.GeoJsonToProto(destinationGeo)
log.Debug().Time("departure time", departureDateTime).Msg("calling driver journeys with ...")
res, err := h.services.GRPC.SolidarityTransport.GetDriverJourneys(ctx, &gen.GetDriverJourneysRequest{
Departure: protodep,
Arrival: protodest,
DepartureDate: timestamppb.New(departureDateTime),
})
if err != nil {
log.Error().Err(err).Msg("error in grpc call to GetDriverJourneys")
} else {
solidarityTransportResults = slices.Collect(func(yield func(*gen.SolidarityTransportDriverJourney) bool) {
for _, dj := range res.DriverJourneys {
if a, ok := drivers[dj.DriverId].Data["archived"]; ok {
if archived, ok := a.(bool); ok {
if archived {
continue
}
}
}
if dj.DriverId == solidarityTransportExcludeDriver {
continue
}
if !yield(dj) {
return
// Get driver IDs to exclude based on group_id (drivers who already have bookings in this group)
excludedDriverIds := make(map[string]bool)
if solidarityExcludeGroupId != "" {
bookingsResp, err := h.services.GRPC.SolidarityTransport.GetSolidarityTransportBookings(ctx, &gen.GetSolidarityTransportBookingsRequest{
StartDate: timestamppb.New(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)),
EndDate: timestamppb.New(time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC)),
})
if err == nil {
for _, booking := range bookingsResp.Bookings {
if booking.GroupId == solidarityExcludeGroupId {
excludedDriverIds[booking.DriverId] = true
}
}
}
}
if solidarityTransportEnabled {
log.Debug().Time("departure time", departureDateTime).Msg("calling driver journeys with ...")
res, err := h.services.GRPC.SolidarityTransport.GetDriverJourneys(ctx, &gen.GetDriverJourneysRequest{
Departure: protodep,
Arrival: protodest,
DepartureDate: timestamppb.New(departureDateTime),
})
sort.Slice(solidarityTransportResults, func(i, j int) bool {
return solidarityTransportResults[i].DriverDistance < solidarityTransportResults[j].DriverDistance
})
if err != nil {
log.Error().Err(err).Msg("error in grpc call to GetDriverJourneys")
} else {
solidarityTransportResults = slices.Collect(func(yield func(*gen.SolidarityTransportDriverJourney) bool) {
for _, dj := range res.DriverJourneys {
if a, ok := drivers[dj.DriverId].Data["archived"]; ok {
if archived, ok := a.(bool); ok {
if archived {
continue
}
}
}
if dj.DriverId == solidarityTransportExcludeDriver {
continue
}
// Skip drivers who already have bookings in the same group
if excludedDriverIds[dj.DriverId] {
continue
}
if !yield(dj) {
return
}
}
})
sort.Slice(solidarityTransportResults, func(i, j int) bool {
return solidarityTransportResults[i].DriverDistance < solidarityTransportResults[j].DriverDistance
})
}
}
// Get departure and destination addresses from properties
@@ -119,124 +166,134 @@ func (h *ApplicationHandler) SearchJourneys(
}
}
radius := float64(5)
// ORGANIZED CARPOOL
organizedCarpoolResultsRes, err := h.services.GRPC.CarpoolService.DriverJourneys(ctx, &carpoolproto.DriverJourneysRequest{
DepartureLat: departureGeo.Point().Lat(),
DepartureLng: departureGeo.Point().Lon(),
ArrivalLat: destinationGeo.Point().Lat(),
ArrivalLng: destinationGeo.Point().Lon(),
DepartureDate: timestamppb.New(departureDateTime),
DepartureAddress: &departureAddress,
ArrivalAddress: &destinationAddress,
DepartureRadius: &radius,
ArrivalRadius: &radius,
})
if err != nil {
log.Error().Err(err).Msg("error retrieving organized carpools")
} else {
organizedCarpoolResults = organizedCarpoolResultsRes.DriverJourneys
sort.Slice(organizedCarpoolResults, func(i, j int) bool {
return *organizedCarpoolResults[i].Distance < *organizedCarpoolResults[j].Distance
if organizedCarpoolEnabled {
radius := float64(5)
organizedCarpoolResultsRes, err := h.services.GRPC.CarpoolService.DriverJourneys(ctx, &carpoolproto.DriverJourneysRequest{
DepartureLat: departureGeo.Point().Lat(),
DepartureLng: departureGeo.Point().Lon(),
ArrivalLat: destinationGeo.Point().Lat(),
ArrivalLng: destinationGeo.Point().Lon(),
DepartureDate: timestamppb.New(departureDateTime),
DepartureAddress: &departureAddress,
ArrivalAddress: &destinationAddress,
DepartureRadius: &radius,
ArrivalRadius: &radius,
})
if err != nil {
log.Error().Err(err).Msg("error retrieving organized carpools")
} else {
organizedCarpoolResults = organizedCarpoolResultsRes.DriverJourneys
sort.Slice(organizedCarpoolResults, func(i, j int) bool {
return *organizedCarpoolResults[i].Distance < *organizedCarpoolResults[j].Distance
})
}
}
var wg sync.WaitGroup
// CARPOOL OPERATORS
carpools := make(chan *geojson.FeatureCollection)
go h.services.InteropCarpool.Search(carpools, *departureGeo, *destinationGeo, departureDateTime)
wg.Add(1)
go func() {
defer wg.Done()
for c := range carpools {
carpoolResults = append(carpoolResults, c)
}
}()
if carpoolOperatorsEnabled {
carpools := make(chan *geojson.FeatureCollection)
go h.services.InteropCarpool.Search(carpools, *departureGeo, *destinationGeo, departureDateTime)
wg.Add(1)
go func() {
defer wg.Done()
for c := range carpools {
carpoolResults = append(carpoolResults, c)
}
}()
}
// TRANSIT
transitch := make(chan *transitous.Itinerary)
go func(transitch chan *transitous.Itinerary, departure *geojson.Feature, destination *geojson.Feature, datetime *time.Time) {
defer close(transitch)
response, err := h.services.TransitRouting.PlanWithResponse(ctx, &transitous.PlanParams{
FromPlace: fmt.Sprintf("%f,%f", departure.Point().Lat(), departure.Point().Lon()),
ToPlace: fmt.Sprintf("%f,%f", destination.Point().Lat(), destination.Point().Lon()),
Time: datetime,
})
if err != nil {
log.Error().Err(err).Msg("error retrieving transit data from Transitous server")
return
}
for _, i := range response.Itineraries {
transitch <- &i
}
}(transitch, departureGeo, destinationGeo, &departureDateTime)
wg.Add(1)
go func() {
defer wg.Done()
paris, _ := time.LoadLocation("Europe/Paris")
requestedDay := departureDateTime.In(paris).Truncate(24 * time.Hour)
if transitEnabled {
transitch := make(chan *transitous.Itinerary)
go func(transitch chan *transitous.Itinerary, departure *geojson.Feature, destination *geojson.Feature, datetime *time.Time) {
defer close(transitch)
response, err := h.services.TransitRouting.PlanWithResponse(ctx, &transitous.PlanParams{
FromPlace: fmt.Sprintf("%f,%f", departure.Point().Lat(), departure.Point().Lon()),
ToPlace: fmt.Sprintf("%f,%f", destination.Point().Lat(), destination.Point().Lon()),
Time: datetime,
})
if err != nil {
log.Error().Err(err).Msg("error retrieving transit data from Transitous server")
return
}
for _, i := range response.Itineraries {
transitch <- &i
}
}(transitch, departureGeo, destinationGeo, &departureDateTime)
wg.Add(1)
go func() {
defer wg.Done()
paris, _ := time.LoadLocation("Europe/Paris")
requestedDay := departureDateTime.In(paris).Truncate(24 * time.Hour)
for itinerary := range transitch {
// Only include journeys that start on the requested day (in Paris timezone)
if !itinerary.StartTime.IsZero() && !itinerary.EndTime.IsZero() {
log.Info().
Time("startTime", itinerary.StartTime).
Time("endTime", itinerary.EndTime).
Str("startTimezone", itinerary.StartTime.Location().String()).
Str("endTimezone", itinerary.EndTime.Location().String()).
Str("startTimeRFC3339", itinerary.StartTime.Format(time.RFC3339)).
Str("endTimeRFC3339", itinerary.EndTime.Format(time.RFC3339)).
Msg("Journey search - received transit itinerary from Transitous")
startInParis := itinerary.StartTime.In(paris)
startDay := startInParis.Truncate(24 * time.Hour)
// Check if journey starts on the requested day
if startDay.Equal(requestedDay) {
transitResults = append(transitResults, itinerary)
} else {
for itinerary := range transitch {
// Only include journeys that start on the requested day (in Paris timezone)
if !itinerary.StartTime.IsZero() && !itinerary.EndTime.IsZero() {
log.Info().
Str("requestedDay", requestedDay.Format("2006-01-02")).
Str("startDay", startDay.Format("2006-01-02")).
Msg("Journey search - filtered out transit journey (not on requested day)")
Time("startTime", itinerary.StartTime).
Time("endTime", itinerary.EndTime).
Str("startTimezone", itinerary.StartTime.Location().String()).
Str("endTimezone", itinerary.EndTime.Location().String()).
Str("startTimeRFC3339", itinerary.StartTime.Format(time.RFC3339)).
Str("endTimeRFC3339", itinerary.EndTime.Format(time.RFC3339)).
Msg("Journey search - received transit itinerary from Transitous")
startInParis := itinerary.StartTime.In(paris)
startDay := startInParis.Truncate(24 * time.Hour)
// Check if journey starts on the requested day
if startDay.Equal(requestedDay) {
transitResults = append(transitResults, itinerary)
} else {
log.Info().
Str("requestedDay", requestedDay.Format("2006-01-02")).
Str("startDay", startDay.Format("2006-01-02")).
Msg("Journey search - filtered out transit journey (not on requested day)")
}
}
}
}
}()
}()
}
// VEHICLES
vehiclech := make(chan fleetsstorage.Vehicle)
go h.vehicleRequest(vehiclech, departureDateTime.Add(-24*time.Hour), departureDateTime.Add(168*time.Hour))
wg.Add(1)
go func() {
defer wg.Done()
for vehicle := range vehiclech {
vehicleResults = append(vehicleResults, vehicle)
}
slices.SortFunc(vehicleResults, sorting.VehiclesByDistanceFrom(*departureGeo))
}()
if fleetVehiclesEnabled {
vehiclech := make(chan fleetsstorage.Vehicle)
go h.vehicleRequest(vehiclech, departureDateTime.Add(-24*time.Hour), departureDateTime.Add(168*time.Hour))
wg.Add(1)
go func() {
defer wg.Done()
for vehicle := range vehiclech {
vehicleResults = append(vehicleResults, vehicle)
}
slices.SortFunc(vehicleResults, sorting.VehiclesByDistanceFrom(*departureGeo))
}()
}
wg.Wait()
// KNOWLEDGE BASE
departureGeoSearch, _ := h.services.Geography.GeoSearch(departureGeo)
kbData := h.config.Get("knowledge_base")
if kb, ok := kbData.([]any); ok {
for _, sol := range kb {
if solution, ok := sol.(map[string]any); ok {
if g, ok := solution["geography"]; ok {
if geography, ok := g.([]any); ok {
for _, gg := range geography {
if geog, ok := gg.(map[string]any); ok {
if layer, ok := geog["layer"].(string); ok {
code := geog["code"]
geo, err := h.services.Geography.Find(layer, fmt.Sprintf("%v", code))
if err == nil {
geog["geography"] = geo
geog["name"] = geo.Properties.MustString("nom")
}
if strings.Compare(fmt.Sprintf("%v", code), departureGeoSearch[layer].Properties.MustString("code")) == 0 {
knowledgeBaseResults = append(knowledgeBaseResults, solution)
break
if knowledgeBaseEnabled {
departureGeoSearch, _ := h.services.Geography.GeoSearch(departureGeo)
kbData := h.config.Get("knowledge_base")
if kb, ok := kbData.([]any); ok {
for _, sol := range kb {
if solution, ok := sol.(map[string]any); ok {
if g, ok := solution["geography"]; ok {
if geography, ok := g.([]any); ok {
for _, gg := range geography {
if geog, ok := gg.(map[string]any); ok {
if layer, ok := geog["layer"].(string); ok {
code := geog["code"]
geo, err := h.services.Geography.Find(layer, fmt.Sprintf("%v", code))
if err == nil {
geog["geography"] = geo
geog["name"] = geo.Properties.MustString("nom")
}
if strings.Compare(fmt.Sprintf("%v", code), departureGeoSearch[layer].Properties.MustString("code")) == 0 {
knowledgeBaseResults = append(knowledgeBaseResults, solution)
break
}
}
}
}

View File

@@ -358,6 +358,18 @@ func (h *ApplicationHandler) GetSolidarityTransportOverview(ctx context.Context,
transformedBookings = filterBookingsByGeography(transformedBookings, departurePolygons, destinationPolygons)
transformedBookings = filterBookingsByPassengerAddressGeography(transformedBookings, beneficiariesMap, passengerAddressPolygons)
// Filter out replaced bookings from upcoming bookings list
filteredBookings := []*solidaritytypes.Booking{}
for _, booking := range transformedBookings {
if booking.Data != nil {
if _, hasReplacedBy := booking.Data["replaced_by"]; hasReplacedBy {
continue // Skip bookings that have been replaced
}
}
filteredBookings = append(filteredBookings, booking)
}
transformedBookings = filteredBookings
// Sort upcoming bookings by date (ascending - earliest first)
sort.Slice(transformedBookings, func(i, j int) bool {
if transformedBookings[i].Journey != nil && transformedBookings[j].Journey != nil {
@@ -396,6 +408,8 @@ func (h *ApplicationHandler) GetSolidarityTransportOverview(ctx context.Context,
transformedBookingsHistory = filterBookingsByGeography(transformedBookingsHistory, histDeparturePolygons, histDestinationPolygons)
transformedBookingsHistory = filterBookingsByPassengerAddressGeography(transformedBookingsHistory, beneficiariesMap, histPassengerAddressPolygons)
// Don't filter out replaced bookings from history - we want to see them in history
// Sort history bookings by date (descending - most recent first)
sort.Slice(transformedBookingsHistory, func(i, j int) bool {
if transformedBookingsHistory[i].Journey != nil && transformedBookingsHistory[j].Journey != nil {
@@ -503,6 +517,8 @@ func (h *ApplicationHandler) GetSolidarityTransportBookings(ctx context.Context,
transformedBookings = filterBookingsByGeography(transformedBookings, departurePolygons, destinationPolygons)
transformedBookings = filterBookingsByPassengerAddressGeography(transformedBookings, beneficiariesMap, passengerAddressPolygons)
// Don't filter out replaced bookings for exports - include all bookings
// Sort bookings by date
sort.Slice(transformedBookings, func(i, j int) bool {
if transformedBookings[i].Journey != nil && transformedBookings[j].Journey != nil {
@@ -744,6 +760,8 @@ func (h *ApplicationHandler) GetSolidarityTransportDriverData(ctx context.Contex
bookings = append(bookings, booking)
}
// Don't filter out replaced bookings from driver profile - show all bookings
// Collect unique passenger IDs
passengerIDs := []string{}
passengerIDsMap := make(map[string]bool)
@@ -1048,7 +1066,19 @@ func (h *ApplicationHandler) GetSolidarityTransportJourneyData(ctx context.Conte
}, nil
}
func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context.Context, driverID, journeyID, passengerID, motivation, message string, doNotSend bool, returnWaitingTimeMinutes int) (string, error) {
func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context.Context, driverID, journeyID, passengerID, motivation, message string, doNotSend bool, returnWaitingTimeMinutes int, replacesBookingID string) (string, error) {
// If this is a replacement booking, get the old booking's group_id
var groupID string
if replacesBookingID != "" {
oldBookingResp, err := h.services.GRPC.SolidarityTransport.GetSolidarityTransportBooking(ctx, &gen.GetSolidarityTransportBookingRequest{
Id: replacesBookingID,
})
if err != nil {
return "", fmt.Errorf("could not get booking to replace: %w", err)
}
groupID = oldBookingResp.Booking.GroupId
}
// Get journey for pricing calculation
journeyRequest := &gen.GetDriverJourneyRequest{
DriverId: driverID,
@@ -1083,6 +1113,12 @@ func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context
returnWaitingDuration := int64(returnWaitingTimeMinutes) * int64(time.Minute)
// Create booking request
dataFields := map[string]*structpb.Value{
"motivation": structpb.NewStringValue(motivation),
"message": structpb.NewStringValue(message),
"do_not_send": structpb.NewBoolValue(doNotSend),
}
bookingRequest := &gen.BookDriverJourneyRequest{
PassengerId: passengerID,
DriverId: driverID,
@@ -1093,19 +1129,45 @@ func (h *ApplicationHandler) CreateSolidarityTransportJourneyBooking(ctx context
DriverCompensationAmount: driverCompensation,
DriverCompensationCurrency: "EUR",
Data: &structpb.Struct{
Fields: map[string]*structpb.Value{
"motivation": structpb.NewStringValue(motivation),
"message": structpb.NewStringValue(message),
"do_not_send": structpb.NewBoolValue(doNotSend),
},
Fields: dataFields,
},
}
// Set group_id if this is a replacement booking
if groupID != "" {
bookingRequest.GroupId = &groupID
}
resp, err := h.services.GRPC.SolidarityTransport.BookDriverJourney(ctx, bookingRequest)
if err != nil {
return "", err
}
// If this is a replacement booking, update the old booking to mark it as replaced
if replacesBookingID != "" {
oldBookingResp, err := h.services.GRPC.SolidarityTransport.GetSolidarityTransportBooking(ctx, &gen.GetSolidarityTransportBookingRequest{
Id: replacesBookingID,
})
if err == nil {
oldBooking, err := solidaritytransformers.BookingProtoToType(oldBookingResp.Booking)
if err == nil && oldBooking != nil {
if oldBooking.Data == nil {
oldBooking.Data = make(map[string]any)
}
oldBooking.Data["replaced_by"] = resp.Booking.Id
// Update the booking
oldBookingProto, _ := solidaritytransformers.BookingTypeToProto(oldBooking)
_, err = h.services.GRPC.SolidarityTransport.UpdateSolidarityTransportBooking(ctx, &gen.UpdateSolidarityTransportBookingRequest{
Booking: oldBookingProto,
})
if err != nil {
log.Error().Err(err).Str("old_booking_id", replacesBookingID).Str("new_booking_id", resp.Booking.Id).Msg("failed to mark old booking as replaced")
}
}
}
}
// Send SMS if not disabled
if !doNotSend && message != "" {
send_message := strings.ReplaceAll(message, "{booking_id}", resp.Booking.Id)
@@ -1253,6 +1315,20 @@ func (h *ApplicationHandler) pricingGeography(loc *geojson.Feature) pricing.Geog
}
}
// CalculateSolidarityTransportPricing is the exported wrapper for calculateSolidarityTransportPricing
func (h *ApplicationHandler) CalculateSolidarityTransportPricing(ctx context.Context, journey *gen.SolidarityTransportDriverJourney, passengerID string) (map[string]pricing.Price, error) {
// Get passenger account
var passenger mobilityaccountsstorage.Account
if passengerID != "" {
passengerResp, err := h.services.GetAccount(passengerID)
if err != nil {
return nil, fmt.Errorf("could not get passenger account: %w", err)
}
passenger = passengerResp
}
return h.calculateSolidarityTransportPricing(ctx, journey, passengerID, passenger)
}
func (h *ApplicationHandler) calculateSolidarityTransportPricing(ctx context.Context, journey *gen.SolidarityTransportDriverJourney, passengerID string, passenger mobilityaccountsstorage.Account) (map[string]pricing.Price, error) {
// Transform proto to type for geography access
journeyType, err := solidaritytransformers.DriverJourneyProtoToType(journey)

View File

@@ -1,28 +1,29 @@
package geo
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/paulmach/orb/geojson"
"github.com/rs/zerolog/log"
)
type GeoService struct {
peliasURL string
geoType string
baseURL string
autocompleteURL string
}
func NewGeoService(peliasURL string) *GeoService {
return &GeoService{peliasURL: peliasURL}
func NewGeoService(geoType, baseURL, autocompleteEndpoint string) *GeoService {
return &GeoService{
geoType: geoType,
baseURL: baseURL,
autocompleteURL: baseURL + autocompleteEndpoint,
}
}
type AutocompleteResult struct {
Features []any
}
func (s *GeoService) Autocomplete(text string) (*AutocompleteResult, error) {
resp, err := http.Get(fmt.Sprintf("%s/autocomplete?text=%s", s.peliasURL, text))
func (s *GeoService) Autocomplete(text string) (*geojson.FeatureCollection, error) {
resp, err := http.Get(s.autocompleteURL + text)
if err != nil {
return nil, err
}
@@ -34,17 +35,11 @@ func (s *GeoService) Autocomplete(text string) (*AutocompleteResult, error) {
return nil, err
}
var response map[string]any
if err := json.Unmarshal(body, &response); err != nil {
featureCollection, err := geojson.UnmarshalFeatureCollection(body)
if err != nil {
log.Error().Err(err).Msg("Failed to unmarshal feature collection")
return nil, err
}
features, ok := response["features"].([]any)
if !ok {
features = []any{}
}
return &AutocompleteResult{
Features: features,
}, nil
return featureCollection, nil
}