File size: 11,162 Bytes
ca7a2c2
 
51ba917
ca7a2c2
 
 
 
 
 
 
 
 
 
 
 
51ba917
 
 
 
 
ca7a2c2
 
51ba917
ca7a2c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51ba917
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
"""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."""
    # Try to find existing plan or create default
    plan = planner_service.get_plan(user_id, plan_id)
    if not plan:
        # Auto-create plan if it doesn't exist
        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."""
    # Get original distance for comparison
    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
    
    # Optimize
    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")
    
    # Calculate savings
    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}"}


# =============================================================================
# SMART PLAN ENDPOINT
# =============================================================================

@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()
    
    # Get the plan
    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.")
    
    # Convert PlanItems to dict format for smart plan service
    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
    ]
    
    # Generate smart plan
    smart_plan = await smart_plan_service.generate_smart_plan(
        places=places,
        title=plan.name,
        itinerary_id=plan_id,
        total_days=1,  # Planner is single-day by default
        include_social_research=request.include_social_research,
        freshness=request.freshness,
    )
    
    # Count social research results
    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
    
    # Convert to Pydantic response
    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",
    )