Skip to content

Commit b8779c0

Browse files
authored
Merge pull request #41 from Commute-ai/copilot/integrate-ai-agents-service-client
Integrate AI-Agents Service Client
2 parents ab8df6b + 5c601bc commit b8779c0

6 files changed

Lines changed: 259 additions & 186 deletions

File tree

app/api/v1/endpoints/routes.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,13 @@ async def search_routes(request: RouteSearchRequest) -> Any:
6161
first=request.num_itineraries,
6262
)
6363

64-
# Enhance each leg with AI insights (with graceful degradation)
64+
# Enhance each itinerary with AI insights (with graceful degradation)
6565
for itinerary in itineraries:
66-
for leg in itinerary.legs:
67-
try:
68-
ai_insight = await ai_agents_service.get_route_insight(leg)
69-
leg.ai_insight = ai_insight
70-
except Exception as e: # pylint: disable=broad-except
71-
# Gracefully degrade - log warning but continue without AI insight
72-
logger.warning("Failed to get AI insight for leg: %s", str(e))
73-
leg.ai_insight = None
66+
try:
67+
await ai_agents_service.get_itinerary_insight(itinerary)
68+
except Exception as e: # pylint: disable=broad-except
69+
# Gracefully degrade - log warning but continue without AI insights
70+
logger.warning("Failed to get AI insights for itinerary: %s", str(e))
7471

7572
logger.info("Route search successful: found %d itineraries", len(itineraries))
7673

app/schemas/itinary.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,6 @@ class Itinerary(BaseModel):
5959
walk_distance: float = Field(..., description="Total walking distance in meters")
6060
walk_time: int = Field(..., description="Total walking time in seconds")
6161
legs: List[Leg]
62+
ai_description: Optional[str] = Field(
63+
default=None, description="AI-generated description of the overall itinerary"
64+
)

app/schemas/routes.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,3 @@ class RouteSearchResponse(BaseModel):
3939
destination: Coordinates
4040
itineraries: List[Itinerary] = Field(..., description="List of route itineraries")
4141
search_time: datetime = Field(..., description="Time when the search was performed")
42-
ai_description: Optional[str] = Field(
43-
default=None, description="AI-generated description of the route options"
44-
)

app/services/ai_agents_service.py

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from app.core.config import settings
77
from app.schemas.health import ServiceHealth
8-
from app.schemas.itinary import Leg
8+
from app.schemas.itinary import Itinerary
99

1010
logger = logging.getLogger(__name__)
1111

@@ -65,53 +65,84 @@ async def health_check(self) -> ServiceHealth:
6565
message=f"AI-agents API check failed: {str(e)}",
6666
)
6767

68-
async def get_route_insight(self, leg: Leg) -> Optional[str]:
68+
async def get_itinerary_insight(self, itinerary: Itinerary) -> None:
6969
"""
70-
Get AI-generated insight for a specific leg of the journey.
70+
Get AI-generated insights for a complete itinerary and enrich it with AI data.
71+
72+
This method sends the entire itinerary to the AI service and receives back:
73+
- ai_description: Overall description of the itinerary
74+
- ai_insights: List of insights for each leg
75+
76+
The method directly modifies the itinerary object in place, setting:
77+
- itinerary.ai_description
78+
- leg.ai_insight for each leg
7179
7280
Args:
73-
leg: The leg object containing journey segment information
81+
itinerary: The itinerary object containing the complete journey information
7482
7583
Returns:
76-
Optional[str]: AI-generated insight text, or None if unavailable
84+
None - modifies the itinerary object in place
7785
7886
Raises:
79-
No exceptions - gracefully degrades by returning None on errors
87+
No exceptions - gracefully degrades by returning without modification on errors
8088
"""
8189
try:
8290
client = self._get_client()
8391

84-
# Prepare request payload with leg information
85-
payload: dict[str, str | int | float | dict[str, str] | None] = {
86-
"mode": leg.mode.value,
87-
"duration": leg.duration,
88-
"distance": leg.distance,
89-
"from_place": leg.from_place.name,
90-
"to_place": leg.to_place.name,
92+
# Prepare request payload with itinerary information
93+
payload = {
94+
"start": itinerary.start.isoformat(),
95+
"end": itinerary.end.isoformat(),
96+
"duration": itinerary.duration,
97+
"walk_distance": itinerary.walk_distance,
98+
"walk_time": itinerary.walk_time,
99+
"legs": [
100+
{
101+
"mode": leg.mode.value,
102+
"duration": leg.duration,
103+
"distance": leg.distance,
104+
"from_place": leg.from_place.name,
105+
"to_place": leg.to_place.name,
106+
"route": (
107+
{
108+
"short_name": leg.route.short_name,
109+
"long_name": leg.route.long_name,
110+
}
111+
if leg.route
112+
else None
113+
),
114+
}
115+
for leg in itinerary.legs
116+
],
91117
}
92118

93-
# Include route information if available (e.g., bus number)
94-
if leg.route:
95-
payload["route"] = {
96-
"short_name": leg.route.short_name,
97-
"long_name": leg.route.long_name,
98-
}
99-
100-
response = await client.post("/insights/route", json=payload)
119+
response = await client.post("/api/v1/insight/itinerary", json=payload)
101120

102121
if response.status_code == 200:
103122
data = response.json()
104-
return data.get("insight")
105123

106-
logger.warning("AI agents service returned non-200 status: %s", response.status_code)
107-
return None
124+
# Set the itinerary-level AI description
125+
itinerary.ai_description = data.get("ai_description")
126+
127+
# Set AI insights for each leg
128+
ai_insights = data.get("ai_insights", [])
129+
for idx, leg in enumerate(itinerary.legs):
130+
if idx < len(ai_insights):
131+
leg.ai_insight = ai_insights[idx]
132+
else:
133+
leg.ai_insight = None
134+
135+
return
136+
137+
logger.warning(
138+
"AI agents service returned non-200 status for itinerary insight: %s",
139+
response.status_code,
140+
)
108141

109142
except httpx.TimeoutException:
110-
logger.warning("AI agents service request timed out")
111-
return None
143+
logger.warning("AI agents service request timed out for itinerary insight")
112144
except Exception as e: # pylint: disable=broad-except
113-
logger.warning("Failed to get AI insight: %s", str(e))
114-
return None
145+
logger.warning("Failed to get AI itinerary insight: %s", str(e))
115146

116147

117148
# Singleton instance for dependency injection

tests/endpoints/test_routes.py

Lines changed: 61 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ def test_search_routes_valid_edge_coordinates(client: TestClient, sample_itinera
379379

380380

381381
def test_search_routes_without_ai_description(client: TestClient, sample_itineraries):
382-
"""Test that route response works without ai_description (graceful degradation)."""
382+
"""Test that route response works without ai_description in itinerary (graceful degradation)."""
383383
with patch("app.api.v1.endpoints.routes.routing_service") as mock_service:
384384
mock_service.get_itinaries = AsyncMock(return_value=sample_itineraries)
385385

@@ -394,29 +394,30 @@ def test_search_routes_without_ai_description(client: TestClient, sample_itinera
394394
assert response.status_code == 200
395395
data = response.json()
396396

397-
# Verify ai_description is present but can be None
398-
assert "ai_description" in data
399-
# When not provided, it should be None
400-
assert data["ai_description"] is None
397+
# Verify ai_description is not in the response (removed from RouteSearchResponse)
398+
assert "ai_description" not in data
399+
# But it should be in each itinerary
400+
assert "ai_description" in data["itineraries"][0]
401401

402402

403403
def test_search_routes_with_ai_description(client: TestClient, sample_itineraries):
404-
"""Test that route response validates correctly when ai_description is provided."""
404+
"""Test that itinerary schema validates correctly when ai_description is provided."""
405405
# Test the schema validation directly
406406
from app.schemas.geo import Coordinates
407407
from app.schemas.routes import RouteSearchResponse
408408

409-
# Schema should validate with ai_description
409+
# Schema should validate with ai_description in itinerary
410+
itinerary_with_description = sample_itineraries[0]
411+
itinerary_with_description.ai_description = "This is a fast route with minimal walking."
412+
410413
response_data = RouteSearchResponse(
411414
origin=Coordinates(latitude=60.1699, longitude=24.9384),
412415
destination=Coordinates(latitude=60.2055, longitude=24.6559),
413-
itineraries=sample_itineraries,
416+
itineraries=[itinerary_with_description],
414417
search_time=datetime.now(timezone.utc),
415-
ai_description="This route offers multiple options with varying travel times.",
416418
)
417419
assert (
418-
response_data.ai_description
419-
== "This route offers multiple options with varying travel times."
420+
response_data.itineraries[0].ai_description == "This is a fast route with minimal walking."
420421
)
421422

422423
# Schema should validate without ai_description
@@ -426,22 +427,30 @@ def test_search_routes_with_ai_description(client: TestClient, sample_itinerarie
426427
itineraries=sample_itineraries,
427428
search_time=datetime.now(timezone.utc),
428429
)
429-
assert response_data_no_ai.ai_description is None
430+
# ai_description field removed from RouteSearchResponse
431+
assert not hasattr(response_data_no_ai, "ai_description") or (
432+
hasattr(response_data_no_ai, "ai_description")
433+
and response_data_no_ai.ai_description is None
434+
)
430435

431436

432437
def test_search_routes_with_ai_insights_success(client: TestClient, sample_itineraries):
433-
"""Test successful route search with AI insights for each leg."""
438+
"""Test successful route search with AI insights for each leg and itinerary."""
434439
with patch("app.api.v1.endpoints.routes.routing_service") as mock_routing_service:
435440
with patch("app.api.v1.endpoints.routes.ai_agents_service") as mock_ai_service:
436441
mock_routing_service.get_itinaries = AsyncMock(return_value=sample_itineraries)
437442

438-
# Mock AI service to return insights
439-
async def mock_get_insight(leg):
440-
if leg.mode == TransportMode.WALK:
441-
return "Short walk to the bus stop."
442-
return "Express bus with comfortable seats."
443+
# Mock AI service to enrich itinerary in place
444+
async def mock_get_itinerary_insight(itinerary):
445+
itinerary.ai_description = (
446+
"This route offers a good balance of walking and public transport."
447+
)
448+
itinerary.legs[0].ai_insight = "Short walk to the bus stop."
449+
itinerary.legs[1].ai_insight = "Express bus with comfortable seats."
443450

444-
mock_ai_service.get_route_insight = AsyncMock(side_effect=mock_get_insight)
451+
mock_ai_service.get_itinerary_insight = AsyncMock(
452+
side_effect=mock_get_itinerary_insight
453+
)
445454

446455
response = client.post(
447456
"/api/v1/routes/search",
@@ -465,8 +474,13 @@ async def mock_get_insight(leg):
465474
# Check second leg (BUS)
466475
assert itinerary["legs"][1]["ai_insight"] == "Express bus with comfortable seats."
467476

468-
# Verify AI service was called for each leg
469-
assert mock_ai_service.get_route_insight.call_count == 2
477+
# Verify AI description for the itinerary
478+
assert (
479+
itinerary["ai_description"]
480+
== "This route offers a good balance of walking and public transport."
481+
)
482+
# Verify AI service was called for itinerary insight
483+
assert mock_ai_service.get_itinerary_insight.call_count == 1
470484

471485

472486
def test_search_routes_with_ai_service_unavailable(client: TestClient, sample_itineraries):
@@ -475,8 +489,14 @@ def test_search_routes_with_ai_service_unavailable(client: TestClient, sample_it
475489
with patch("app.api.v1.endpoints.routes.ai_agents_service") as mock_ai_service:
476490
mock_routing_service.get_itinaries = AsyncMock(return_value=sample_itineraries)
477491

478-
# Mock AI service to return None (service unavailable)
479-
mock_ai_service.get_route_insight = AsyncMock(return_value=None)
492+
# Mock AI service to do nothing (service unavailable)
493+
async def mock_get_itinerary_insight_unavailable(itinerary):
494+
# Service unavailable - does not modify itinerary
495+
pass
496+
497+
mock_ai_service.get_itinerary_insight = AsyncMock(
498+
side_effect=mock_get_itinerary_insight_unavailable
499+
)
480500

481501
response = client.post(
482502
"/api/v1/routes/search",
@@ -498,25 +518,24 @@ def test_search_routes_with_ai_service_unavailable(client: TestClient, sample_it
498518
# AI insights should be None
499519
assert itinerary["legs"][0]["ai_insight"] is None
500520
assert itinerary["legs"][1]["ai_insight"] is None
521+
# AI description should be None
522+
assert itinerary["ai_description"] is None
501523

502524

503525
def test_search_routes_with_ai_service_partial_failure(client: TestClient, sample_itineraries):
504-
"""Test graceful degradation when AI service fails for some legs."""
526+
"""Test graceful degradation when AI service provides partial data."""
505527
with patch("app.api.v1.endpoints.routes.routing_service") as mock_routing_service:
506528
with patch("app.api.v1.endpoints.routes.ai_agents_service") as mock_ai_service:
507529
mock_routing_service.get_itinaries = AsyncMock(return_value=sample_itineraries)
508530

509-
# Mock AI service to succeed for first leg, fail for second
510-
call_count = 0
511-
512-
async def mock_get_insight_partial(leg):
513-
nonlocal call_count
514-
call_count += 1
515-
if call_count == 1:
516-
return "Short walk to the bus stop."
517-
return None # Simulate failure for second leg
531+
# Mock AI service to provide partial data
532+
async def mock_get_itinerary_insight_partial(itinerary):
533+
# Only set description, not leg insights
534+
itinerary.ai_description = "This is a good route."
518535

519-
mock_ai_service.get_route_insight = AsyncMock(side_effect=mock_get_insight_partial)
536+
mock_ai_service.get_itinerary_insight = AsyncMock(
537+
side_effect=mock_get_itinerary_insight_partial
538+
)
520539

521540
response = client.post(
522541
"/api/v1/routes/search",
@@ -529,10 +548,9 @@ async def mock_get_insight_partial(leg):
529548
assert response.status_code == 200
530549
data = response.json()
531550

532-
# Verify first leg has insight
533-
assert data["itineraries"][0]["legs"][0]["ai_insight"] == "Short walk to the bus stop."
534-
535-
# Verify second leg has no insight (graceful degradation)
551+
# Verify itinerary has description but legs don't have insights
552+
assert data["itineraries"][0]["ai_description"] == "This is a good route."
553+
assert data["itineraries"][0]["legs"][0]["ai_insight"] is None
536554
assert data["itineraries"][0]["legs"][1]["ai_insight"] is None
537555

538556

@@ -543,7 +561,9 @@ def test_search_routes_with_ai_service_exception(client: TestClient, sample_itin
543561
mock_routing_service.get_itinaries = AsyncMock(return_value=sample_itineraries)
544562

545563
# Mock AI service to raise an exception
546-
mock_ai_service.get_route_insight = AsyncMock(side_effect=Exception("AI service error"))
564+
mock_ai_service.get_itinerary_insight = AsyncMock(
565+
side_effect=Exception("AI service error")
566+
)
547567

548568
response = client.post(
549569
"/api/v1/routes/search",
@@ -565,6 +585,8 @@ def test_search_routes_with_ai_service_exception(client: TestClient, sample_itin
565585
# AI insights should be None due to graceful degradation
566586
assert itinerary["legs"][0]["ai_insight"] is None
567587
assert itinerary["legs"][1]["ai_insight"] is None
588+
# AI description should be None due to graceful degradation
589+
assert itinerary["ai_description"] is None
568590

569591

570592
def test_leg_schema_with_ai_insight():

0 commit comments

Comments
 (0)