From aa7e2bd5705d7642f0fd4d81fb62302f0e2a9ad4 Mon Sep 17 00:00:00 2001 From: RCF-117 Date: Thu, 2 Jul 2026 13:54:18 +0800 Subject: [PATCH 1/3] feat: add route sort day tool --- backend/app/tools/__init__.py | 5 + backend/app/tools/route_sort_day.py | 120 ++++++++++++++++++++++ backend/tests/test_route_sort_day_tool.py | 66 ++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 backend/app/tools/__init__.py create mode 100644 backend/app/tools/route_sort_day.py create mode 100644 backend/tests/test_route_sort_day_tool.py diff --git a/backend/app/tools/__init__.py b/backend/app/tools/__init__.py new file mode 100644 index 0000000..3840daf --- /dev/null +++ b/backend/app/tools/__init__.py @@ -0,0 +1,5 @@ +"""Agent tool functions with deterministic, testable behavior.""" + +from app.tools.route_sort_day import route_sort_day + +__all__ = ["route_sort_day"] diff --git a/backend/app/tools/route_sort_day.py b/backend/app/tools/route_sort_day.py new file mode 100644 index 0000000..7d84f83 --- /dev/null +++ b/backend/app/tools/route_sort_day.py @@ -0,0 +1,120 @@ +import math +from collections.abc import Sequence +from typing import Protocol, TypeVar + +from app.schemas import POICreate + + +class RouteStop(Protocol): + slot: str + poi: POICreate + + +StopT = TypeVar("StopT", bound=RouteStop) + + +def _has_coord(poi: POICreate) -> bool: + return poi.lng is not None and poi.lat is not None + + +def _haversine_m(a_lng: float, a_lat: float, b_lng: float, b_lat: float) -> int: + radius = 6371000.0 + p1, p2 = math.radians(a_lat), math.radians(b_lat) + dphi = math.radians(b_lat - a_lat) + dlmb = math.radians(b_lng - a_lng) + h = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2 + return int(2 * radius * math.asin(math.sqrt(h))) + + +def _nearest_attractions( + attractions: Sequence[StopT], + start_lng: float | None, + start_lat: float | None, +) -> list[StopT]: + with_coords = [ + (stop, float(stop.poi.lng), float(stop.poi.lat)) + for stop in attractions + if _has_coord(stop.poi) + ] + without_coords = [stop for stop in attractions if not _has_coord(stop.poi)] + if len(with_coords) <= 1: + return [item[0] for item in with_coords] + without_coords + + remaining = with_coords[:] + ordered: list[StopT] = [] + + if start_lng is not None and start_lat is not None: + current_lng, current_lat = start_lng, start_lat + else: + first, current_lng, current_lat = remaining.pop(0) + ordered.append(first) + + while remaining: + next_stop, next_lng, next_lat = min( + remaining, + key=lambda item: _haversine_m( + current_lng, + current_lat, + item[1], + item[2], + ), + ) + ordered.append(next_stop) + remaining.remove((next_stop, next_lng, next_lat)) + current_lng, current_lat = next_lng, next_lat + + return ordered + without_coords + + +def _split_attractions( + attractions: list[StopT], + has_lunch: bool, + has_evening_anchor: bool, +) -> tuple[list[StopT], list[StopT]]: + if not has_lunch or not has_evening_anchor: + return attractions, [] + half = math.ceil(len(attractions) / 2) + return attractions[:half], attractions[half:] + + +def route_sort_day( + stops: Sequence[StopT], + start_lng: float | None = None, + start_lat: float | None = None, +) -> list[StopT]: + """Return a deterministic day timeline sorted by slot semantics and distance. + + The tool keeps fixed meal/hotel slots on a simple day backbone, sorts + attractions with nearest-neighbor routing, and leaves unclassified stops at + the end in their original order. + """ + original = list(stops) + meals = { + stop.slot: stop + for stop in original + if stop.slot in ("breakfast", "lunch", "dinner") + } + hotel = next((stop for stop in original if stop.slot == "hotel"), None) + attractions = [stop for stop in original if stop.slot == "attraction"] + sorted_attractions = _nearest_attractions(attractions, start_lng, start_lat) + morning, afternoon = _split_attractions( + sorted_attractions, + has_lunch="lunch" in meals, + has_evening_anchor=("dinner" in meals or hotel is not None), + ) + + ordered: list[StopT] = [] + if "breakfast" in meals: + ordered.append(meals["breakfast"]) + ordered.extend(morning) + if "lunch" in meals: + ordered.append(meals["lunch"]) + ordered.extend(afternoon) + if "dinner" in meals: + ordered.append(meals["dinner"]) + if hotel is not None: + ordered.append(hotel) + + used = {id(stop) for stop in ordered} + ordered.extend(stop for stop in original if id(stop) not in used) + return ordered diff --git a/backend/tests/test_route_sort_day_tool.py b/backend/tests/test_route_sort_day_tool.py new file mode 100644 index 0000000..37539d2 --- /dev/null +++ b/backend/tests/test_route_sort_day_tool.py @@ -0,0 +1,66 @@ +from app.schemas import POICreate +from app.tools.route_sort_day import route_sort_day +from app.workflow import PlanStop + + +def _stop(name: str, slot: str, category: str, lng=None, lat=None) -> PlanStop: + return PlanStop( + slot=slot, + poi=POICreate(name=name, category=category, lng=lng, lat=lat), + ) + + +def test_route_sort_day_keeps_timeline_slots_and_sorts_attractions_by_distance(): + stops = [ + _stop("酒店", "hotel", "stay", 104.08, 30.65), + _stop("午餐", "lunch", "eat", 104.06, 30.65), + _stop("远景点", "attraction", "play", 104.20, 30.65), + _stop("早餐", "breakfast", "eat", 104.00, 30.65), + _stop("近景点", "attraction", "play", 104.02, 30.65), + _stop("中景点", "attraction", "play", 104.04, 30.65), + _stop("晚餐", "dinner", "eat", 104.07, 30.65), + ] + + ordered = route_sort_day(stops, start_lng=104.0, start_lat=30.65) + + assert [s.poi.name for s in ordered] == [ + "早餐", + "近景点", + "中景点", + "午餐", + "远景点", + "晚餐", + "酒店", + ] + + +def test_route_sort_day_puts_missing_coordinates_after_sortable_attractions(): + stops = [ + _stop("早餐", "breakfast", "eat", 104.00, 30.65), + _stop("未知坐标景点", "attraction", "play"), + _stop("远景点", "attraction", "play", 104.20, 30.65), + _stop("近景点", "attraction", "play", 104.02, 30.65), + _stop("午餐", "lunch", "eat", 104.06, 30.65), + ] + + ordered = route_sort_day(stops, start_lng=104.0, start_lat=30.65) + + assert [s.poi.name for s in ordered] == [ + "早餐", + "近景点", + "远景点", + "未知坐标景点", + "午餐", + ] + + +def test_route_sort_day_does_not_mutate_input_stops(): + stops = [ + _stop("午餐", "lunch", "eat", 104.06, 30.65), + _stop("近景点", "attraction", "play", 104.02, 30.65), + _stop("早餐", "breakfast", "eat", 104.00, 30.65), + ] + + route_sort_day(stops, start_lng=104.0, start_lat=30.65) + + assert [s.poi.name for s in stops] == ["午餐", "近景点", "早餐"] From 33d914b7afbd3db824d70070069b6042f7a1deca Mon Sep 17 00:00:00 2001 From: RCF-117 Date: Thu, 2 Jul 2026 14:13:03 +0800 Subject: [PATCH 2/3] refactor: harden route sort day tool schema --- backend/app/tools/__init__.py | 3 +- backend/app/tools/route_sort_day.py | 168 ++++++++++++++++------ backend/app/tools/schemas.py | 34 +++++ backend/tests/test_route_sort_day_tool.py | 76 +++++++++- 4 files changed, 231 insertions(+), 50 deletions(-) create mode 100644 backend/app/tools/schemas.py diff --git a/backend/app/tools/__init__.py b/backend/app/tools/__init__.py index 3840daf..4f59dcb 100644 --- a/backend/app/tools/__init__.py +++ b/backend/app/tools/__init__.py @@ -1,5 +1,6 @@ """Agent tool functions with deterministic, testable behavior.""" from app.tools.route_sort_day import route_sort_day +from app.tools.schemas import RouteSortResult, RouteSortWarning, RouteStop -__all__ = ["route_sort_day"] +__all__ = ["RouteSortResult", "RouteSortWarning", "RouteStop", "route_sort_day"] diff --git a/backend/app/tools/route_sort_day.py b/backend/app/tools/route_sort_day.py index 7d84f83..4395b99 100644 --- a/backend/app/tools/route_sort_day.py +++ b/backend/app/tools/route_sort_day.py @@ -1,19 +1,11 @@ import math from collections.abc import Sequence -from typing import Protocol, TypeVar -from app.schemas import POICreate +from app.tools.schemas import RouteSortResult, RouteSortWarning, RouteStop, StopRole -class RouteStop(Protocol): - slot: str - poi: POICreate - - -StopT = TypeVar("StopT", bound=RouteStop) - - -def _has_coord(poi: POICreate) -> bool: +def _has_coord(stop: RouteStop) -> bool: + poi = stop.poi return poi.lng is not None and poi.lat is not None @@ -26,22 +18,92 @@ def _haversine_m(a_lng: float, a_lat: float, b_lng: float, b_lat: float) -> int: return int(2 * radius * math.asin(math.sqrt(h))) +_ROLE_ALIASES: dict[str, StopRole] = { + "breakfast": "breakfast", + "morning_meal": "breakfast", + "早餐": "breakfast", + "早饭": "breakfast", + "早茶": "breakfast", + "lunch": "lunch", + "noon_meal": "lunch", + "午餐": "lunch", + "午饭": "lunch", + "dinner": "dinner", + "evening_meal": "dinner", + "晚餐": "dinner", + "晚饭": "dinner", + "attraction": "attraction", + "sight": "attraction", + "sightseeing": "attraction", + "poi": "attraction", + "play": "attraction", + "景点": "attraction", + "游玩": "attraction", + "体验": "attraction", + "hotel": "hotel", + "lodging": "hotel", + "stay": "hotel", + "住宿": "hotel", + "酒店": "hotel", +} + + +def _normalize_role(stop: RouteStop) -> tuple[StopRole, RouteSortWarning | None]: + slot_key = (stop.slot or "").strip().lower() + category = stop.poi.category + if slot_key in _ROLE_ALIASES: + return _ROLE_ALIASES[slot_key], None + + if category == "play": + return "attraction", RouteSortWarning( + code="category_role_fallback", + stop_name=stop.poi.name, + slot=stop.slot, + category=category, + message="Unrecognized slot; inferred attraction role from POI category.", + ) + if category == "stay": + return "hotel", RouteSortWarning( + code="category_role_fallback", + stop_name=stop.poi.name, + slot=stop.slot, + category=category, + message="Unrecognized slot; inferred hotel role from POI category.", + ) + if category == "eat": + return "unknown", RouteSortWarning( + code="ambiguous_meal_role", + stop_name=stop.poi.name, + slot=stop.slot, + category=category, + message="Meal category does not identify breakfast, lunch, or dinner.", + ) + + return "unknown", RouteSortWarning( + code="unknown_role", + stop_name=stop.poi.name, + slot=stop.slot, + category=category, + message="Could not infer a route sorting role.", + ) + + def _nearest_attractions( - attractions: Sequence[StopT], + attractions: Sequence[RouteStop], start_lng: float | None, start_lat: float | None, -) -> list[StopT]: +) -> list[RouteStop]: with_coords = [ (stop, float(stop.poi.lng), float(stop.poi.lat)) for stop in attractions - if _has_coord(stop.poi) + if _has_coord(stop) ] - without_coords = [stop for stop in attractions if not _has_coord(stop.poi)] + without_coords = [stop for stop in attractions if not _has_coord(stop)] if len(with_coords) <= 1: return [item[0] for item in with_coords] + without_coords remaining = with_coords[:] - ordered: list[StopT] = [] + ordered: list[RouteStop] = [] if start_lng is not None and start_lat is not None: current_lng, current_lat = start_lng, start_lat @@ -67,10 +129,10 @@ def _nearest_attractions( def _split_attractions( - attractions: list[StopT], + attractions: list[RouteStop], has_lunch: bool, has_evening_anchor: bool, -) -> tuple[list[StopT], list[StopT]]: +) -> tuple[list[RouteStop], list[RouteStop]]: if not has_lunch or not has_evening_anchor: return attractions, [] half = math.ceil(len(attractions) / 2) @@ -78,43 +140,61 @@ def _split_attractions( def route_sort_day( - stops: Sequence[StopT], + stops: Sequence[RouteStop], start_lng: float | None = None, start_lat: float | None = None, -) -> list[StopT]: +) -> RouteSortResult: """Return a deterministic day timeline sorted by slot semantics and distance. - The tool keeps fixed meal/hotel slots on a simple day backbone, sorts - attractions with nearest-neighbor routing, and leaves unclassified stops at - the end in their original order. + Input slots may use canonical English values or common Chinese aliases. + The algorithm works on normalized roles and returns warnings for fallback + decisions that an agent harness may want to inspect. """ original = list(stops) - meals = { - stop.slot: stop - for stop in original - if stop.slot in ("breakfast", "lunch", "dinner") + warnings: list[RouteSortWarning] = [] + grouped: dict[StopRole, list[RouteStop]] = { + "breakfast": [], + "lunch": [], + "dinner": [], + "attraction": [], + "hotel": [], + "unknown": [], } - hotel = next((stop for stop in original if stop.slot == "hotel"), None) - attractions = [stop for stop in original if stop.slot == "attraction"] + for stop in original: + role, warning = _normalize_role(stop) + grouped[role].append(stop) + if warning is not None: + warnings.append(warning) + if not _has_coord(stop): + warnings.append( + RouteSortWarning( + code="missing_coordinates", + stop_name=stop.poi.name, + slot=stop.slot, + category=stop.poi.category, + message="Stop has no complete coordinate pair.", + ) + ) + + attractions = grouped["attraction"] sorted_attractions = _nearest_attractions(attractions, start_lng, start_lat) morning, afternoon = _split_attractions( sorted_attractions, - has_lunch="lunch" in meals, - has_evening_anchor=("dinner" in meals or hotel is not None), + has_lunch=bool(grouped["lunch"]), + has_evening_anchor=bool(grouped["dinner"] or grouped["hotel"]), ) - ordered: list[StopT] = [] - if "breakfast" in meals: - ordered.append(meals["breakfast"]) + ordered: list[RouteStop] = [] + ordered.extend(grouped["breakfast"]) ordered.extend(morning) - if "lunch" in meals: - ordered.append(meals["lunch"]) + ordered.extend(grouped["lunch"]) ordered.extend(afternoon) - if "dinner" in meals: - ordered.append(meals["dinner"]) - if hotel is not None: - ordered.append(hotel) - - used = {id(stop) for stop in ordered} - ordered.extend(stop for stop in original if id(stop) not in used) - return ordered + ordered.extend(grouped["dinner"]) + ordered.extend(grouped["hotel"]) + ordered.extend(grouped["unknown"]) + + return RouteSortResult( + stops=ordered, + degraded=bool(warnings), + warnings=warnings, + ) diff --git a/backend/app/tools/schemas.py b/backend/app/tools/schemas.py new file mode 100644 index 0000000..1f88869 --- /dev/null +++ b/backend/app/tools/schemas.py @@ -0,0 +1,34 @@ +from typing import Literal + +from pydantic import BaseModel, Field + +from app.schemas import POICreate + +StopRole = Literal["breakfast", "lunch", "dinner", "attraction", "hotel", "unknown"] +RouteSortWarningCode = Literal[ + "missing_coordinates", + "unknown_role", + "category_role_fallback", + "ambiguous_meal_role", +] + + +class RouteStop(BaseModel): + slot: str = "" + poi: POICreate + arrive_time: str | None = None + stay_minutes: int | None = Field(default=None, ge=1) + + +class RouteSortWarning(BaseModel): + code: RouteSortWarningCode + stop_name: str + slot: str = "" + category: str | None = None + message: str = "" + + +class RouteSortResult(BaseModel): + stops: list[RouteStop] + degraded: bool = False + warnings: list[RouteSortWarning] = Field(default_factory=list) diff --git a/backend/tests/test_route_sort_day_tool.py b/backend/tests/test_route_sort_day_tool.py index 37539d2..aa0b64c 100644 --- a/backend/tests/test_route_sort_day_tool.py +++ b/backend/tests/test_route_sort_day_tool.py @@ -1,10 +1,10 @@ from app.schemas import POICreate +from app.tools.schemas import RouteStop from app.tools.route_sort_day import route_sort_day -from app.workflow import PlanStop -def _stop(name: str, slot: str, category: str, lng=None, lat=None) -> PlanStop: - return PlanStop( +def _stop(name: str, slot: str, category: str, lng=None, lat=None) -> RouteStop: + return RouteStop( slot=slot, poi=POICreate(name=name, category=category, lng=lng, lat=lat), ) @@ -23,7 +23,9 @@ def test_route_sort_day_keeps_timeline_slots_and_sorts_attractions_by_distance() ordered = route_sort_day(stops, start_lng=104.0, start_lat=30.65) - assert [s.poi.name for s in ordered] == [ + assert ordered.degraded is False + assert ordered.warnings == [] + assert [s.poi.name for s in ordered.stops] == [ "早餐", "近景点", "中景点", @@ -45,7 +47,9 @@ def test_route_sort_day_puts_missing_coordinates_after_sortable_attractions(): ordered = route_sort_day(stops, start_lng=104.0, start_lat=30.65) - assert [s.poi.name for s in ordered] == [ + assert ordered.degraded is True + assert [w.code for w in ordered.warnings] == ["missing_coordinates"] + assert [s.poi.name for s in ordered.stops] == [ "早餐", "近景点", "远景点", @@ -64,3 +68,65 @@ def test_route_sort_day_does_not_mutate_input_stops(): route_sort_day(stops, start_lng=104.0, start_lat=30.65) assert [s.poi.name for s in stops] == ["午餐", "近景点", "早餐"] + + +def test_route_sort_day_accepts_chinese_slots_and_preserves_original_values(): + stops = [ + _stop("酒店", "住宿", "stay", 104.08, 30.65), + _stop("午餐", "午餐", "eat", 104.06, 30.65), + _stop("远景点", "景点", "play", 104.20, 30.65), + _stop("早餐", "早餐", "eat", 104.00, 30.65), + _stop("近景点", "游玩", "play", 104.02, 30.65), + _stop("晚餐", "晚饭", "eat", 104.07, 30.65), + ] + + ordered = route_sort_day(stops, start_lng=104.0, start_lat=30.65) + + assert ordered.degraded is False + assert [s.poi.name for s in ordered.stops] == [ + "早餐", + "近景点", + "午餐", + "远景点", + "晚餐", + "酒店", + ] + assert ordered.stops[0].slot == "早餐" + + +def test_route_sort_day_falls_back_from_category_without_guessing_meal_slots(): + stops = [ + _stop("空 slot 景点", "", "play", 104.02, 30.65), + _stop("空 slot 酒店", "", "stay", 104.08, 30.65), + _stop("空 slot 餐厅", "", "eat", 104.06, 30.65), + ] + + ordered = route_sort_day(stops, start_lng=104.0, start_lat=30.65) + + assert ordered.degraded is True + assert [s.poi.name for s in ordered.stops] == [ + "空 slot 景点", + "空 slot 酒店", + "空 slot 餐厅", + ] + assert [w.code for w in ordered.warnings] == [ + "category_role_fallback", + "category_role_fallback", + "ambiguous_meal_role", + ] + + +def test_route_sort_day_warns_when_inferring_role_from_unknown_slot(): + stops = [ + _stop("早餐", "breakfast", "eat", 104.00, 30.65), + _stop("未知活动", "自由活动", "play", 104.03, 30.65), + _stop("景点", "attraction", "play", 104.02, 30.65), + ] + + ordered = route_sort_day(stops, start_lng=104.0, start_lat=30.65) + + assert ordered.degraded is True + assert [s.poi.name for s in ordered.stops] == ["早餐", "景点", "未知活动"] + assert [(w.code, w.stop_name) for w in ordered.warnings] == [ + ("category_role_fallback", "未知活动") + ] From c71decce39f614595baabf3fba8fe655cf228948 Mon Sep 17 00:00:00 2001 From: RCF-117 Date: Thu, 2 Jul 2026 14:44:55 +0800 Subject: [PATCH 3/3] refactor: refine route sort day tool contracts --- backend/app/tools/__init__.py | 13 +++++-- backend/app/tools/route_sort_day.py | 19 +++++++-- backend/app/tools/schemas.py | 16 ++++++-- backend/tests/test_route_sort_day_tool.py | 47 +++++++++++++++++------ 4 files changed, 73 insertions(+), 22 deletions(-) diff --git a/backend/app/tools/__init__.py b/backend/app/tools/__init__.py index 4f59dcb..43e5fc6 100644 --- a/backend/app/tools/__init__.py +++ b/backend/app/tools/__init__.py @@ -1,6 +1,13 @@ """Agent tool functions with deterministic, testable behavior.""" -from app.tools.route_sort_day import route_sort_day -from app.tools.schemas import RouteSortResult, RouteSortWarning, RouteStop +from app.tools.route_sort_day import normalize_stop_role, route_sort_day +from app.tools.schemas import RouteSortResult, RouteSortWarning, RouteStop, ToolPOI -__all__ = ["RouteSortResult", "RouteSortWarning", "RouteStop", "route_sort_day"] +__all__ = [ + "normalize_stop_role", + "RouteSortResult", + "RouteSortWarning", + "RouteStop", + "ToolPOI", + "route_sort_day", +] diff --git a/backend/app/tools/route_sort_day.py b/backend/app/tools/route_sort_day.py index 4395b99..cfdc237 100644 --- a/backend/app/tools/route_sort_day.py +++ b/backend/app/tools/route_sort_day.py @@ -48,7 +48,14 @@ def _haversine_m(a_lng: float, a_lat: float, b_lng: float, b_lat: float) -> int: } -def _normalize_role(stop: RouteStop) -> tuple[StopRole, RouteSortWarning | None]: +def normalize_stop_role(stop: RouteStop) -> tuple[StopRole, RouteSortWarning | None]: + """Normalize Chinese/English stop slots into canonical route roles. + + Example: + >>> stop = RouteStop(slot="景点", poi=ToolPOI(name="宽窄巷子", category="play")) + >>> normalize_stop_role(stop)[0] + 'attraction' + """ slot_key = (stop.slot or "").strip().lower() category = stop.poi.category if slot_key in _ROLE_ALIASES: @@ -57,6 +64,7 @@ def _normalize_role(stop: RouteStop) -> tuple[StopRole, RouteSortWarning | None] if category == "play": return "attraction", RouteSortWarning( code="category_role_fallback", + severity="info", stop_name=stop.poi.name, slot=stop.slot, category=category, @@ -65,6 +73,7 @@ def _normalize_role(stop: RouteStop) -> tuple[StopRole, RouteSortWarning | None] if category == "stay": return "hotel", RouteSortWarning( code="category_role_fallback", + severity="info", stop_name=stop.poi.name, slot=stop.slot, category=category, @@ -81,6 +90,7 @@ def _normalize_role(stop: RouteStop) -> tuple[StopRole, RouteSortWarning | None] return "unknown", RouteSortWarning( code="unknown_role", + severity="error", stop_name=stop.poi.name, slot=stop.slot, category=category, @@ -148,7 +158,8 @@ def route_sort_day( Input slots may use canonical English values or common Chinese aliases. The algorithm works on normalized roles and returns warnings for fallback - decisions that an agent harness may want to inspect. + decisions that an agent harness may want to inspect. It does not assign + meal times; callers should provide breakfast/lunch/dinner roles explicitly. """ original = list(stops) warnings: list[RouteSortWarning] = [] @@ -161,7 +172,7 @@ def route_sort_day( "unknown": [], } for stop in original: - role, warning = _normalize_role(stop) + role, warning = normalize_stop_role(stop) grouped[role].append(stop) if warning is not None: warnings.append(warning) @@ -195,6 +206,6 @@ def route_sort_day( return RouteSortResult( stops=ordered, - degraded=bool(warnings), + degraded=any(w.severity in ("warning", "error") for w in warnings), warnings=warnings, ) diff --git a/backend/app/tools/schemas.py b/backend/app/tools/schemas.py index 1f88869..6556141 100644 --- a/backend/app/tools/schemas.py +++ b/backend/app/tools/schemas.py @@ -2,9 +2,9 @@ from pydantic import BaseModel, Field -from app.schemas import POICreate - StopRole = Literal["breakfast", "lunch", "dinner", "attraction", "hotel", "unknown"] +ToolPOICategory = Literal["eat", "play", "stay", "other"] +WarningSeverity = Literal["info", "warning", "error"] RouteSortWarningCode = Literal[ "missing_coordinates", "unknown_role", @@ -13,15 +13,25 @@ ] +class ToolPOI(BaseModel): + name: str + category: ToolPOICategory + lng: float | None = None + lat: float | None = None + address: str | None = None + amap_id: str | None = None + + class RouteStop(BaseModel): slot: str = "" - poi: POICreate + poi: ToolPOI arrive_time: str | None = None stay_minutes: int | None = Field(default=None, ge=1) class RouteSortWarning(BaseModel): code: RouteSortWarningCode + severity: WarningSeverity = "warning" stop_name: str slot: str = "" category: str | None = None diff --git a/backend/tests/test_route_sort_day_tool.py b/backend/tests/test_route_sort_day_tool.py index aa0b64c..cffe40e 100644 --- a/backend/tests/test_route_sort_day_tool.py +++ b/backend/tests/test_route_sort_day_tool.py @@ -1,12 +1,11 @@ -from app.schemas import POICreate -from app.tools.schemas import RouteStop -from app.tools.route_sort_day import route_sort_day +from app.tools.schemas import RouteStop, ToolPOI +from app.tools.route_sort_day import normalize_stop_role, route_sort_day def _stop(name: str, slot: str, category: str, lng=None, lat=None) -> RouteStop: return RouteStop( slot=slot, - poi=POICreate(name=name, category=category, lng=lng, lat=lat), + poi=ToolPOI(name=name, category=category, lng=lng, lat=lat), ) @@ -48,7 +47,9 @@ def test_route_sort_day_puts_missing_coordinates_after_sortable_attractions(): ordered = route_sort_day(stops, start_lng=104.0, start_lat=30.65) assert ordered.degraded is True - assert [w.code for w in ordered.warnings] == ["missing_coordinates"] + assert [(w.code, w.severity) for w in ordered.warnings] == [ + ("missing_coordinates", "warning") + ] assert [s.poi.name for s in ordered.stops] == [ "早餐", "近景点", @@ -109,10 +110,10 @@ def test_route_sort_day_falls_back_from_category_without_guessing_meal_slots(): "空 slot 酒店", "空 slot 餐厅", ] - assert [w.code for w in ordered.warnings] == [ - "category_role_fallback", - "category_role_fallback", - "ambiguous_meal_role", + assert [(w.code, w.severity) for w in ordered.warnings] == [ + ("category_role_fallback", "info"), + ("category_role_fallback", "info"), + ("ambiguous_meal_role", "warning"), ] @@ -125,8 +126,30 @@ def test_route_sort_day_warns_when_inferring_role_from_unknown_slot(): ordered = route_sort_day(stops, start_lng=104.0, start_lat=30.65) - assert ordered.degraded is True + assert ordered.degraded is False assert [s.poi.name for s in ordered.stops] == ["早餐", "景点", "未知活动"] - assert [(w.code, w.stop_name) for w in ordered.warnings] == [ - ("category_role_fallback", "未知活动") + assert [(w.code, w.severity, w.stop_name) for w in ordered.warnings] == [ + ("category_role_fallback", "info", "未知活动") ] + + +def test_normalize_stop_role_is_public_and_returns_warning_severity(): + role, warning = normalize_stop_role(_stop("随便逛", "自由活动", "play")) + + assert role == "attraction" + assert warning is not None + assert warning.code == "category_role_fallback" + assert warning.severity == "info" + + +def test_route_sort_day_marks_unknown_role_as_error(): + unknown = RouteStop( + slot="自由活动", + poi=ToolPOI(name="未知地点", category="other", lng=104.03, lat=30.65), + ) + + ordered = route_sort_day([unknown], start_lng=104.0, start_lat=30.65) + + assert ordered.degraded is True + assert ordered.warnings[0].code == "unknown_role" + assert ordered.warnings[0].severity == "error"