Skip to content

Commit 8b3aae8

Browse files
committed
feat(api): add navigation module with map provider and ai tools
- add lib/maps/ provider abstraction (MapProvider ABC, GoogleMapProvider, factory) - add navigation/ domain module (model, schemas, repository, service, router) - add 3 AI tools: start_navigation, cancel_navigation, get_navigation_step - add location message handling, off-route detection, Gemini context feed in live.py - add publish_location() to LiveKit bot for Concierge location relay - add navigation system instructions to AI orchestrator - add waypoint cleanup to admin service - add Alembic migration for navigation_sessions and location_waypoints tables - add MAP_PROVIDER, GOOGLE_MAPS_API_KEY config and MapsDep dependency
1 parent d2869cb commit 8b3aae8

27 files changed

Lines changed: 2287 additions & 3 deletions

apps/api/alembic/env.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from src.lib.config import settings
1313
from src.lib.database import Base
1414
from src.medications.model import Medication # noqa: F401
15+
from src.navigation.model import LocationWaypoint, NavigationSession # noqa: F401
1516
from src.notification_logs.model import ( # noqa: F401
1617
NotificationLog,
1718
NotificationPreference,

apps/api/alembic/versions/0001_initial.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,8 +449,128 @@ def upgrade() -> None:
449449
unique=True,
450450
)
451451

452+
# --- navigation_sessions ---
453+
op.create_table(
454+
"navigation_sessions",
455+
sa.Column(
456+
"id",
457+
sa.UUID(),
458+
server_default=sa.text("gen_random_uuid()"),
459+
nullable=False,
460+
),
461+
sa.Column("host_id", sa.UUID(), nullable=False),
462+
sa.Column(
463+
"status",
464+
sa.String(length=20),
465+
server_default="active",
466+
nullable=False,
467+
comment="active | completed | cancelled",
468+
),
469+
sa.Column("destination_name", sa.String(length=500), nullable=False),
470+
sa.Column("destination_lat", sa.Float(), nullable=False),
471+
sa.Column("destination_lng", sa.Float(), nullable=False),
472+
sa.Column("origin_lat", sa.Float(), nullable=False),
473+
sa.Column("origin_lng", sa.Float(), nullable=False),
474+
sa.Column(
475+
"route_data",
476+
postgresql.JSONB(astext_type=sa.Text()),
477+
server_default=sa.text("'{}'::jsonb"),
478+
nullable=False,
479+
),
480+
sa.Column(
481+
"current_step_index",
482+
sa.Integer(),
483+
server_default=sa.text("0"),
484+
nullable=False,
485+
),
486+
sa.Column(
487+
"created_at",
488+
sa.DateTime(timezone=True),
489+
server_default=sa.text("now()"),
490+
nullable=False,
491+
),
492+
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
493+
sa.ForeignKeyConstraint(
494+
["host_id"],
495+
["users.id"],
496+
name=op.f("fk_navigation_sessions_host_id_users"),
497+
ondelete="CASCADE",
498+
),
499+
sa.PrimaryKeyConstraint("id", name=op.f("pk_navigation_sessions")),
500+
)
501+
op.create_index(
502+
"ix_navigation_sessions_host_id",
503+
"navigation_sessions",
504+
["host_id"],
505+
unique=False,
506+
)
507+
op.create_index(
508+
"ix_navigation_sessions_status",
509+
"navigation_sessions",
510+
["status"],
511+
unique=False,
512+
)
513+
514+
# --- location_waypoints ---
515+
op.create_table(
516+
"location_waypoints",
517+
sa.Column(
518+
"id",
519+
sa.UUID(),
520+
server_default=sa.text("gen_random_uuid()"),
521+
nullable=False,
522+
),
523+
sa.Column("host_id", sa.UUID(), nullable=False),
524+
sa.Column("session_id", sa.UUID(), nullable=True),
525+
sa.Column("lat", sa.Float(), nullable=False),
526+
sa.Column("lng", sa.Float(), nullable=False),
527+
sa.Column("altitude", sa.Float(), nullable=True),
528+
sa.Column("accuracy", sa.Float(), nullable=True),
529+
sa.Column("speed", sa.Float(), nullable=True),
530+
sa.Column("heading", sa.Float(), nullable=True),
531+
sa.Column(
532+
"created_at",
533+
sa.DateTime(timezone=True),
534+
server_default=sa.text("now()"),
535+
nullable=False,
536+
),
537+
sa.ForeignKeyConstraint(
538+
["host_id"],
539+
["users.id"],
540+
name=op.f("fk_location_waypoints_host_id_users"),
541+
ondelete="CASCADE",
542+
),
543+
sa.ForeignKeyConstraint(
544+
["session_id"],
545+
["navigation_sessions.id"],
546+
name=op.f("fk_location_waypoints_session_id_navigation_sessions"),
547+
ondelete="SET NULL",
548+
),
549+
sa.PrimaryKeyConstraint("id", name=op.f("pk_location_waypoints")),
550+
)
551+
op.create_index(
552+
"ix_location_waypoints_host_id_created_at",
553+
"location_waypoints",
554+
["host_id", "created_at"],
555+
unique=False,
556+
)
557+
op.create_index(
558+
"ix_location_waypoints_session_id",
559+
"location_waypoints",
560+
["session_id"],
561+
unique=False,
562+
)
563+
452564

453565
def downgrade() -> None:
566+
op.drop_index("ix_location_waypoints_session_id", table_name="location_waypoints")
567+
op.drop_index(
568+
"ix_location_waypoints_host_id_created_at", table_name="location_waypoints"
569+
)
570+
op.drop_table("location_waypoints")
571+
op.drop_index("ix_navigation_sessions_status", table_name="navigation_sessions")
572+
op.drop_index("ix_navigation_sessions_host_id", table_name="navigation_sessions")
573+
op.drop_table("navigation_sessions")
454574
op.drop_index(op.f("ix_device_tokens_token"), table_name="device_tokens")
455575
op.drop_index("ix_device_tokens_user_id", table_name="device_tokens")
456576
op.drop_table("device_tokens")

apps/api/src/admin/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class CleanupRequest(BaseModel):
1414
class CleanupResponse(BaseModel):
1515
deleted_wellness_logs: int
1616
deactivated_tokens: int
17+
deleted_waypoints: int = 0
1718

1819

1920
class InactiveRelationResponse(BaseModel):

apps/api/src/admin/service.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
WellnessAggregateResponse,
1515
)
1616
from src.medications import repository as medication_repo
17+
from src.navigation import repository as navigation_repo
1718
from src.notifications import repository as notification_repo
1819
from src.relations import repository as relation_repo
1920
from src.users import repository as user_repo
@@ -46,13 +47,20 @@ async def cleanup_data(
4647

4748
deleted_logs = 0
4849
deactivated = 0
50+
deleted_waypoints = 0
4951

5052
if resource_type in ("all", "wellness_logs"):
5153
deleted_logs = await repository.delete_old_wellness_logs(db, before)
5254

5355
if resource_type in ("all", "device_tokens"):
5456
deactivated = await repository.deactivate_old_tokens(db, before)
5557

58+
if resource_type in ("all", "waypoints"):
59+
waypoint_cutoff = datetime.now(UTC) - timedelta(days=7)
60+
deleted_waypoints = await navigation_repo.delete_waypoints_before(
61+
db, waypoint_cutoff
62+
)
63+
5664
await _log_audit(
5765
db,
5866
action="cleanup",
@@ -61,12 +69,14 @@ async def cleanup_data(
6169
"retention_days": retention_days,
6270
"deleted_wellness_logs": deleted_logs,
6371
"deactivated_tokens": deactivated,
72+
"deleted_waypoints": deleted_waypoints,
6473
},
6574
)
6675
await db.commit()
6776
return CleanupResponse(
6877
deleted_wellness_logs=deleted_logs,
6978
deactivated_tokens=deactivated,
79+
deleted_waypoints=deleted_waypoints,
7080
)
7181

7282

apps/api/src/common/enums.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,11 @@ class WellnessStatus(str, enum.Enum):
1616
NORMAL = "normal"
1717
WARNING = "warning"
1818
EMERGENCY = "emergency"
19+
20+
21+
class NavigationStatus(str, enum.Enum):
22+
"""Navigation session lifecycle states."""
23+
24+
ACTIVE = "active"
25+
COMPLETED = "completed"
26+
CANCELLED = "cancelled"

apps/api/src/common/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
RES_003: ErrorTuple = ("RES_003", "Medication not found")
2323
RES_004: ErrorTuple = ("RES_004", "User not found")
2424
RES_005: ErrorTuple = ("RES_005", "Device token not found")
25+
RES_006: ErrorTuple = ("RES_006", "Navigation session not found")
26+
RES_007: ErrorTuple = ("RES_007", "No active navigation session")
2527

2628
# ── Validation ─────────────────────────────────────────────────────
2729
VAL_001: ErrorTuple = ("VAL_001", "Invalid caregiver role")
@@ -33,6 +35,8 @@
3335
SVC_003: ErrorTuple = ("SVC_003", "OAuth provider did not return an email address")
3436
SVC_004: ErrorTuple = ("SVC_004", "Storage provider is not configured")
3537
SVC_005: ErrorTuple = ("SVC_005", "File upload failed")
38+
SVC_006: ErrorTuple = ("SVC_006", "Maps provider is not configured")
39+
SVC_007: ErrorTuple = ("SVC_007", "Geocoding returned no results")
3640

3741

3842
def raise_api_error(

apps/api/src/lib/ai/orchestrator.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,17 @@ def _resolve_model() -> str:
4444
"9. When the Host shows a medication schedule, prescription, pharmacy label, "
4545
"or pill organizer to the camera, use `scan_medication_schedule` to extract "
4646
"ALL visible medication names and times in a single call. Confirm the "
47-
"extracted data with the Host before finalizing."
47+
"extracted data with the Host before finalizing.\n"
48+
"10. NAVIGATION — When the Host mentions a destination, call `start_navigation`. "
49+
"Combine camera imagery with GPS data for natural guidance. "
50+
"Give advance notice 20m before intersections. "
51+
"Prefer visible landmarks over street names.\n"
52+
"11. OFF-ROUTE — When off-route is detected, calmly guide back. "
53+
"Never blame the Host for going the wrong way.\n"
54+
"12. ARRIVAL — When within 30m of destination, announce arrival "
55+
"and call `cancel_navigation`.\n"
56+
"13. LOCATION AWARENESS — Use GPS context to mention nearby "
57+
"crosswalks, traffic lights, and other environmental cues."
4858
)
4959

5060
ToolHandler = Callable[[str, dict[str, Any]], Coroutine[Any, Any, Any]]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Gemini function calling tool: cancel current navigation."""
2+
3+
from typing import Any
4+
5+
import structlog
6+
7+
from src.lib.ai.tools.base import BaseTool, ToolContext, register_tool
8+
from src.navigation import repository, service
9+
10+
logger = structlog.get_logger(__name__)
11+
12+
13+
class CancelNavigationTool(BaseTool):
14+
"""AI tool to cancel the current walking navigation session."""
15+
16+
@property
17+
def name(self) -> str:
18+
return "cancel_navigation"
19+
20+
@property
21+
def description(self) -> str:
22+
return (
23+
"Cancel the current navigation. "
24+
"Use when the Host says to stop directions or has arrived."
25+
)
26+
27+
async def execute(
28+
self, *, context: ToolContext | None = None, **kwargs: Any
29+
) -> Any:
30+
if not context or "db" not in context or "host_id" not in context:
31+
return {"error": "Missing required context (db, host_id)"}
32+
33+
db = context["db"]
34+
host_id = context["host_id"]
35+
36+
nav = await repository.find_active_session(db, host_id)
37+
if nav is None:
38+
return {"error": "No active navigation session"}
39+
40+
await service.cancel_navigation(db, nav.id)
41+
42+
logger.info(
43+
"navigation_cancelled",
44+
host_id=str(host_id),
45+
session_id=str(nav.id),
46+
)
47+
48+
return {
49+
"success": True,
50+
"session_id": str(nav.id),
51+
"message": "Navigation cancelled",
52+
}
53+
54+
55+
register_tool(CancelNavigationTool())
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Gemini function calling tool: get current navigation step."""
2+
3+
from typing import Any
4+
5+
import structlog
6+
7+
from src.lib.ai.tools.base import BaseTool, ToolContext, register_tool
8+
from src.navigation import repository
9+
10+
logger = structlog.get_logger(__name__)
11+
12+
13+
class GetNavigationStepTool(BaseTool):
14+
"""AI tool to retrieve the current or next navigation step."""
15+
16+
@property
17+
def name(self) -> str:
18+
return "get_navigation_step"
19+
20+
@property
21+
def description(self) -> str:
22+
return (
23+
"Get the current or next navigation instruction. "
24+
"Use when the Host asks about the next turn or direction."
25+
)
26+
27+
async def execute(
28+
self, *, context: ToolContext | None = None, **kwargs: Any
29+
) -> Any:
30+
if not context or "db" not in context or "host_id" not in context:
31+
return {"error": "Missing required context (db, host_id)"}
32+
33+
db = context["db"]
34+
host_id = context["host_id"]
35+
36+
nav = await repository.find_active_session(db, host_id)
37+
if nav is None:
38+
return {"error": "No active navigation session"}
39+
40+
route_data = nav.route_data
41+
steps = route_data.get("steps", [])
42+
idx = nav.current_step_index
43+
44+
if not steps:
45+
return {"error": "No route steps available"}
46+
47+
if idx >= len(steps):
48+
return {
49+
"message": "You have completed all steps. "
50+
"You should be at your destination.",
51+
"destination": nav.destination_name,
52+
}
53+
54+
current_step = steps[idx]
55+
remaining_steps = len(steps) - idx
56+
57+
result: dict[str, Any] = {
58+
"current_step": idx + 1,
59+
"total_steps": len(steps),
60+
"remaining_steps": remaining_steps,
61+
"instruction": current_step.get("instruction", ""),
62+
"distance_meters": current_step.get("distance_meters", 0),
63+
"maneuver": current_step.get("maneuver"),
64+
"destination": nav.destination_name,
65+
}
66+
67+
# Include next step preview if available
68+
if idx + 1 < len(steps):
69+
next_step = steps[idx + 1]
70+
result["next_instruction"] = next_step.get("instruction", "")
71+
result["next_maneuver"] = next_step.get("maneuver")
72+
73+
return result
74+
75+
76+
register_tool(GetNavigationStepTool())

0 commit comments

Comments
 (0)