| | """Trip Planner Router - API endpoints for plan management.""" |
| |
|
| | import time |
| | from fastapi import APIRouter, HTTPException, Query |
| |
|
| | from app.planner.models import ( |
| | Plan, |
| | PlanItem, |
| | CreatePlanRequest, |
| | CreatePlanResponse, |
| | AddPlaceRequest, |
| | ReorderRequest, |
| | ReplaceRequest, |
| | OptimizeResponse, |
| | PlanResponse, |
| | GetPlanRequest, |
| | GetPlanResponse, |
| | SmartPlanResponse, |
| | DayPlanResponse, |
| | PlaceDetailResponse, |
| | ) |
| | from app.planner.service import planner_service |
| | from app.planner.smart_plan import smart_plan_service |
| |
|
| |
|
| | router = APIRouter(prefix="/planner", tags=["Trip Planner"]) |
| |
|
| |
|
| | @router.post( |
| | "/create", |
| | response_model=CreatePlanResponse, |
| | summary="Create a new trip plan", |
| | description="Creates an empty trip plan for the user.", |
| | ) |
| | async def create_plan( |
| | request: CreatePlanRequest, |
| | user_id: str = Query(default="anonymous", description="User ID"), |
| | ) -> CreatePlanResponse: |
| | """Create a new empty plan.""" |
| | plan = planner_service.create_plan(user_id=user_id, name=request.name) |
| | |
| | return CreatePlanResponse( |
| | plan_id=plan.plan_id, |
| | name=plan.name, |
| | message=f"Created plan '{plan.name}'", |
| | ) |
| |
|
| |
|
| | @router.get( |
| | "/{plan_id}", |
| | response_model=PlanResponse, |
| | summary="Get a trip plan", |
| | description="Retrieves a plan by ID.", |
| | ) |
| | async def get_plan( |
| | plan_id: str, |
| | user_id: str = Query(default="anonymous", description="User ID"), |
| | ) -> PlanResponse: |
| | """Get a plan by ID.""" |
| | plan = planner_service.get_plan(user_id=user_id, plan_id=plan_id) |
| | |
| | if not plan: |
| | raise HTTPException(status_code=404, detail="Plan not found") |
| | |
| | return PlanResponse(plan=plan, message="Plan retrieved") |
| |
|
| |
|
| | @router.get( |
| | "/user/plans", |
| | response_model=list[Plan], |
| | summary="Get all user plans", |
| | description="Retrieves all plans for a user.", |
| | ) |
| | async def get_user_plans( |
| | user_id: str = Query(default="anonymous", description="User ID"), |
| | ) -> list[Plan]: |
| | """Get all plans for a user.""" |
| | return planner_service.get_user_plans(user_id) |
| |
|
| |
|
| | @router.post( |
| | "/{plan_id}/add", |
| | response_model=PlanItem, |
| | summary="Add a place to plan", |
| | description="Adds a new place to the end of the plan.", |
| | ) |
| | async def add_place( |
| | plan_id: str, |
| | request: AddPlaceRequest, |
| | user_id: str = Query(default="anonymous", description="User ID"), |
| | ) -> PlanItem: |
| | """Add a place to the plan.""" |
| | |
| | plan = planner_service.get_plan(user_id, plan_id) |
| | if not plan: |
| | |
| | plan = planner_service.create_plan(user_id=user_id) |
| | |
| | item = planner_service.add_place( |
| | user_id=user_id, |
| | plan_id=plan.plan_id, |
| | place=request.place, |
| | notes=request.notes, |
| | ) |
| | |
| | if not item: |
| | raise HTTPException(status_code=404, detail="Plan not found") |
| | |
| | return item |
| |
|
| |
|
| | @router.delete( |
| | "/{plan_id}/remove/{item_id}", |
| | summary="Remove a place from plan", |
| | description="Removes a place from the plan by item ID.", |
| | ) |
| | async def remove_place( |
| | plan_id: str, |
| | item_id: str, |
| | user_id: str = Query(default="anonymous", description="User ID"), |
| | ) -> dict: |
| | """Remove a place from the plan.""" |
| | success = planner_service.remove_place( |
| | user_id=user_id, |
| | plan_id=plan_id, |
| | item_id=item_id, |
| | ) |
| | |
| | if not success: |
| | raise HTTPException(status_code=404, detail="Item not found") |
| | |
| | return {"status": "success", "message": f"Removed item {item_id}"} |
| |
|
| |
|
| | @router.put( |
| | "/{plan_id}/reorder", |
| | response_model=PlanResponse, |
| | summary="Reorder places in plan", |
| | description="Manually reorder places by providing new order of item IDs.", |
| | ) |
| | async def reorder_places( |
| | plan_id: str, |
| | request: ReorderRequest, |
| | user_id: str = Query(default="anonymous", description="User ID"), |
| | ) -> PlanResponse: |
| | """Reorder places in the plan.""" |
| | success = planner_service.reorder_places( |
| | user_id=user_id, |
| | plan_id=plan_id, |
| | new_order=request.new_order, |
| | ) |
| | |
| | if not success: |
| | raise HTTPException( |
| | status_code=400, |
| | detail="Invalid order. Ensure all item IDs are included." |
| | ) |
| | |
| | plan = planner_service.get_plan(user_id, plan_id) |
| | return PlanResponse(plan=plan, message="Plan reordered") |
| |
|
| |
|
| | @router.post( |
| | "/{plan_id}/optimize", |
| | response_model=OptimizeResponse, |
| | summary="Optimize route (TSP)", |
| | description=""" |
| | Optimizes the route using TSP (Traveling Salesman Problem) algorithm. |
| | |
| | Uses Nearest Neighbor heuristic with 2-opt improvement. |
| | Minimizes total travel distance. |
| | """, |
| | ) |
| | async def optimize_route( |
| | plan_id: str, |
| | user_id: str = Query(default="anonymous", description="User ID"), |
| | start_index: int = Query(default=0, description="Index of starting place"), |
| | ) -> OptimizeResponse: |
| | """Optimize the route using TSP.""" |
| | |
| | plan = planner_service.get_plan(user_id, plan_id) |
| | if not plan: |
| | raise HTTPException(status_code=404, detail="Plan not found") |
| | |
| | if len(plan.items) < 2: |
| | return OptimizeResponse( |
| | plan_id=plan_id, |
| | items=plan.items, |
| | total_distance_km=0, |
| | estimated_duration_min=0, |
| | message="Need at least 2 places to optimize", |
| | ) |
| | |
| | original_distance = plan.total_distance_km or 0 |
| | |
| | |
| | optimized_plan = planner_service.optimize_plan( |
| | user_id=user_id, |
| | plan_id=plan_id, |
| | start_index=start_index, |
| | ) |
| | |
| | if not optimized_plan: |
| | raise HTTPException(status_code=404, detail="Plan not found") |
| | |
| | |
| | distance_saved = original_distance - optimized_plan.total_distance_km if original_distance > 0 else None |
| | |
| | return OptimizeResponse( |
| | plan_id=plan_id, |
| | items=optimized_plan.items, |
| | total_distance_km=optimized_plan.total_distance_km, |
| | estimated_duration_min=optimized_plan.estimated_duration_min, |
| | distance_saved_km=round(distance_saved, 2) if distance_saved else None, |
| | message=f"Route optimized! Total: {optimized_plan.total_distance_km}km, ~{optimized_plan.estimated_duration_min}min", |
| | ) |
| |
|
| |
|
| | @router.put( |
| | "/{plan_id}/replace/{item_id}", |
| | response_model=PlanItem, |
| | summary="Replace a place in plan", |
| | description="Replaces an existing place with a new one.", |
| | ) |
| | async def replace_place( |
| | plan_id: str, |
| | item_id: str, |
| | request: ReplaceRequest, |
| | user_id: str = Query(default="anonymous", description="User ID"), |
| | ) -> PlanItem: |
| | """Replace a place in the plan.""" |
| | item = planner_service.replace_place( |
| | user_id=user_id, |
| | plan_id=plan_id, |
| | item_id=item_id, |
| | new_place=request.new_place, |
| | ) |
| | |
| | if not item: |
| | raise HTTPException(status_code=404, detail="Item not found") |
| | |
| | return item |
| |
|
| |
|
| | @router.delete( |
| | "/{plan_id}", |
| | summary="Delete a plan", |
| | description="Deletes an entire plan.", |
| | ) |
| | async def delete_plan( |
| | plan_id: str, |
| | user_id: str = Query(default="anonymous", description="User ID"), |
| | ) -> dict: |
| | """Delete a plan.""" |
| | success = planner_service.delete_plan(user_id=user_id, plan_id=plan_id) |
| | |
| | if not success: |
| | raise HTTPException(status_code=404, detail="Plan not found") |
| | |
| | return {"status": "success", "message": f"Deleted plan {plan_id}"} |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | @router.post( |
| | "/{plan_id}/get-plan", |
| | response_model=GetPlanResponse, |
| | summary="Generate Smart Plan", |
| | description=""" |
| | Generates an optimized, enriched plan with: |
| | - Social media research for each place |
| | - Optimal timing (e.g., Dragon Bridge at 21h for fire show) |
| | - Tips and highlights per place |
| | - Route optimization |
| | """, |
| | ) |
| | async def get_smart_plan( |
| | plan_id: str, |
| | request: GetPlanRequest = GetPlanRequest(), |
| | user_id: str = Query(default="anonymous", description="User ID"), |
| | ) -> GetPlanResponse: |
| | """ |
| | Generate a smart, enriched travel plan. |
| | |
| | Uses Social Media Tool to research each place and LLM to optimize |
| | timing based on Da Nang local knowledge. |
| | """ |
| | start_time = time.time() |
| | |
| | |
| | plan = planner_service.get_plan(user_id, plan_id) |
| | if not plan: |
| | raise HTTPException(status_code=404, detail="Plan not found") |
| | |
| | if len(plan.items) == 0: |
| | raise HTTPException(status_code=400, detail="Plan is empty. Add places first.") |
| | |
| | |
| | places = [ |
| | { |
| | "place_id": item.place_id, |
| | "name": item.name, |
| | "category": item.category, |
| | "lat": item.lat, |
| | "lng": item.lng, |
| | "rating": item.rating, |
| | } |
| | for item in plan.items |
| | ] |
| | |
| | |
| | smart_plan = await smart_plan_service.generate_smart_plan( |
| | places=places, |
| | title=plan.name, |
| | itinerary_id=plan_id, |
| | total_days=1, |
| | include_social_research=request.include_social_research, |
| | freshness=request.freshness, |
| | ) |
| | |
| | |
| | research_count = sum( |
| | len(p.social_mentions) |
| | for day in smart_plan.days |
| | for p in day.places |
| | ) |
| | |
| | generation_time = (time.time() - start_time) * 1000 |
| | |
| | |
| | days_response = [] |
| | for day in smart_plan.days: |
| | places_response = [ |
| | PlaceDetailResponse( |
| | place_id=p.place_id, |
| | name=p.name, |
| | category=p.category, |
| | lat=p.lat, |
| | lng=p.lng, |
| | recommended_time=p.recommended_time, |
| | suggested_duration_min=p.suggested_duration_min, |
| | tips=p.tips, |
| | highlights=p.highlights, |
| | social_mentions=p.social_mentions, |
| | order=p.order, |
| | ) |
| | for p in day.places |
| | ] |
| | days_response.append( |
| | DayPlanResponse( |
| | day_index=day.day_index, |
| | date=str(day.date) if day.date else None, |
| | places=places_response, |
| | day_summary=day.day_summary, |
| | day_distance_km=day.day_distance_km, |
| | ) |
| | ) |
| | |
| | plan_response = SmartPlanResponse( |
| | itinerary_id=smart_plan.itinerary_id, |
| | title=smart_plan.title, |
| | total_days=smart_plan.total_days, |
| | days=days_response, |
| | summary=smart_plan.summary, |
| | total_distance_km=smart_plan.total_distance_km, |
| | estimated_total_duration_min=smart_plan.estimated_total_duration_min, |
| | generated_at=smart_plan.generated_at, |
| | ) |
| | |
| | return GetPlanResponse( |
| | plan=plan_response, |
| | research_count=research_count, |
| | generation_time_ms=round(generation_time, 2), |
| | message=f"Smart plan generated with {research_count} social mentions", |
| | ) |
| |
|