o2o-site-AEO/solution/backend/tests/test_itinerary_grounding.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

229 lines
9.9 KiB
Python

"""일정 응답 해석 — 모델이 준 JSON 에서 화면에 설 수 있는 코스만 남긴다."""
import json
from services.grounding import itinerary as grounding
# 이 파일의 모든 parse_courses 호출이 쓰는 업소명 — 실제 값은 안 중요하다(DB 값을 그대로 통과시키는지만 본다).
_PLACE = "테스트업소"
def _payload(items: list, *, fence: bool = False, search: list | None = None) -> dict:
envelope = {"kind": "itinerary", "version": 1, "title": "추천 일정", "items": items}
text = json.dumps(envelope, ensure_ascii=False)
if fence:
text = f"```json\n{text}\n```"
return {
"choices": [{"message": {"content": text}}],
"search_results": search if search is not None else [
{"title": "군산문화관광", "url": "https://www.gunsan.go.kr/tour/"}
],
}
def _course(name: str, stops: list[str], *, days: int = 2) -> dict:
per_day = max(1, len(stops) // days)
chunks = [stops[i:i + per_day] for i in range(0, len(stops), per_day)][:days]
return {
"name": name,
"duration": "1박 2일",
"audience": "누구에게나",
"why": "이유 한 문장.",
"days": [
{
"label": f"{i + 1}일차",
"startTime": "14:00",
"stops": [
{"name": s, "minutes": 60, "moveMinutes": 10, "searchQuery": s,
"latitude": 35.99, "longitude": 126.71}
for s in chunk
],
}
for i, chunk in enumerate(chunks)
],
}
def test_accepts_five_distinct_courses():
"""검증: 정상 응답 5개."""
items = [_course(f"코스{i}", [f"장소{i}-{j}" for j in range(4)]) for i in range(5)]
courses, dropped = grounding.parse_courses(_payload(items), "1박 2일", _PLACE)
assert len(courses) == 5
assert dropped == []
assert [c["name"] for c in courses] == [f"코스{i}" for i in range(5)]
def test_strips_code_fence():
"""검증: 코드펜스를 두르고 온 응답."""
items = [_course("코스", ["가", "나"])]
courses, dropped = grounding.parse_courses(_payload(items, fence=True), "1박 2일", _PLACE)
assert len(courses) == 1
def test_forces_duration_even_if_model_wrote_something_else():
"""검증: 모델이 duration 을 다르게 적은 코스."""
item = _course("코스", ["가", "나"])
item["duration"] = "반나절"
courses, _ = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE)
assert courses[0]["duration"] == "1박 2일"
def test_drops_course_without_name():
"""검증: 이름 없는 코스."""
item = _course("", ["가", "나"])
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE)
assert courses == []
assert dropped and "name" in dropped[0]
def test_drops_course_without_stops():
"""검증: days 가 있어도 정거장이 하나도 없는 코스."""
item = {"name": "빈 코스", "duration": "1박 2일", "days": [{"label": "1일차", "stops": []}]}
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE)
assert courses == []
assert dropped
def test_drops_a_course_whose_stop_set_duplicates_an_earlier_one():
"""검증: 앞 코스와 정거장 집합이 완전히 같은 코스(순서만 다름)."""
first = _course("원도심 코스", ["가", "나", "다", "라"])
same = _course("미식 코스", ["라", "다", "나", "가"]) # 순서만 다르다
overlap = _course("자연 코스", ["가", "나", "마", "바"]) # 절반 겹치지만 다른 코스다
courses, dropped = grounding.parse_courses(_payload([first, same, overlap]), "1박 2일", _PLACE)
assert [c["name"] for c in courses] == ["원도심 코스", "자연 코스"]
assert dropped and "미식 코스" in dropped[0]
def test_attaches_search_result_as_source_when_model_gave_none():
"""검증: 코스에 source 가 없을 때."""
courses, _ = grounding.parse_courses(_payload([_course("코스", ["가", "나"])]), "1박 2일", _PLACE)
assert courses[0]["source"]["url"] == "https://www.gunsan.go.kr/tour/"
def test_keeps_model_source_when_it_is_a_real_url():
"""검증: 모델이 준 source 가 http 로 시작할 때."""
item = _course("코스", ["가", "나"])
item["source"] = {"name": "군산시", "url": "https://www.gunsan.go.kr/index.gunsan"}
courses, _ = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE)
assert courses[0]["source"]["url"] == "https://www.gunsan.go.kr/index.gunsan"
def test_course_survives_without_any_source():
"""검증: 모델 source 도 없고 search_results 도 빈 응답."""
items = [_course("코스", ["가", "나"])]
courses, _ = grounding.parse_courses(_payload(items, search=[]), "1박 2일", _PLACE)
assert len(courses) == 1
assert "source" not in courses[0]
def test_returns_empty_on_non_json():
"""검증: JSON 이 아닌 응답."""
payload = {"choices": [{"message": {"content": "죄송합니다, 일정을 만들 수 없습니다."}}]}
courses, dropped = grounding.parse_courses(payload, "1박 2일", _PLACE)
assert courses == []
assert dropped
def test_returns_empty_when_items_is_not_a_list():
"""검증: items 가 배열이 아닌 응답."""
payload = {"choices": [{"message": {"content": json.dumps({"items": "없음"})}}]}
courses, dropped = grounding.parse_courses(payload, "1박 2일", _PLACE)
assert courses == []
assert dropped
# ── 하루 시각표 강제 ────────────────────────────
def _stop(name: str, minutes: int = 60, move: int = 10) -> dict:
return {"name": name, "minutes": minutes, "moveMinutes": move, "searchQuery": name,
"latitude": 35.99, "longitude": 126.71}
def test_overrides_start_time_and_label_regardless_of_model_values():
"""검증: 모델이 startTime·label 을 다르게 적어도."""
item = {
"name": "코스", "duration": "1박 2일",
"days": [
{"label": "Day A", "startTime": "10:00", "stops": [_stop("가")]},
{"label": "Day B", "startTime": "08:00", "stops": [_stop("나")]},
],
}
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE)
assert dropped == []
days = courses[0]["days"]
assert (days[0]["label"], days[0]["startTime"]) == ("첫째 날", "15:00")
assert (days[1]["label"], days[1]["startTime"]) == ("둘째 날", "09:00")
def test_trims_stops_that_would_run_past_the_days_end_time():
"""검증: 첫째 날(15:00~19:00)에 정거장을 오래 머무는 것부터 채운다."""
item = {
"name": "코스", "duration": "1박 2일",
"days": [
# 15:10 도착·18:10 종료(180분) → 다음 정거장은 18:20 도착에 18:20+180=21:20 으로 19:00 초과.
{"stops": [_stop("가", minutes=180), _stop("나", minutes=180)]},
{"stops": [_stop("다")]},
],
}
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE)
assert dropped == []
# 첫째 날은 복귀가 있다 — 업소(출발) → 가 → 업소(복귀).
assert [s["name"] for s in courses[0]["days"][0]["stops"]] == [_PLACE, "가", _PLACE]
# 둘째 날(마지막 날)은 체크아웃이라 복귀가 없다 — 업소(출발) → 다.
assert [s["name"] for s in courses[0]["days"][1]["stops"]] == [_PLACE, "다"]
def test_drops_course_when_first_stop_of_a_day_already_overruns():
"""검증: 첫째 날 첫 정거장부터 종료 시각(19:00)을 넘긴다."""
item = {
"name": "코스", "duration": "1박 2일",
"days": [
{"stops": [_stop("가", minutes=300)]}, # 15:10 도착, 300분 → 20:10 종료(19:00 초과)
{"stops": [_stop("나")]},
],
}
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE)
assert courses == []
assert dropped and "시각표" in dropped[0]
def test_drops_course_whose_day_count_is_short_of_the_duration():
"""검증: '1박 2일'인데 하루치 밖에 없다."""
item = {"name": "코스", "duration": "1박 2일", "days": [{"stops": [_stop("가")]}]}
courses, dropped = grounding.parse_courses(_payload([item]), "1박 2일", _PLACE)
assert courses == []
assert dropped and "시각표" in dropped[0]
def test_lodging_is_the_departure_and_return_point():
"""검증: 업소는 실제 정거장(stops[])이다."""
item = {
"name": "코스", "duration": "2박 3일",
"days": [
{"stops": [_stop("가"), _stop("나")]},
{"stops": [_stop("다"), _stop("라")]},
{"stops": [_stop("마")]},
],
}
courses, dropped = grounding.parse_courses(_payload([item]), "2박 3일", "군산모텔", 35.98642, 126.70612)
assert dropped == []
days = courses[0]["days"]
assert [s["name"] for s in days[0]["stops"]] == ["군산모텔", "가", "나", "군산모텔"]
assert [s["name"] for s in days[1]["stops"]] == ["군산모텔", "다", "라", "군산모텔"]
assert [s["name"] for s in days[2]["stops"]] == ["군산모텔", "마"] # 마지막 날 — 복귀 없음
def test_lodging_stop_carries_coordinates_only_when_known():
"""검증: 업소 좌표가 있을 때/없을 때."""
item = {"name": "코스", "duration": "1박 2일",
"days": [{"stops": [_stop("가")]}, {"stops": [_stop("나")]}]}
with_coords, _ = grounding.parse_courses(
_payload([item]), "1박 2일", "군산모텔", 35.98642, 126.70612)
lodging = with_coords[0]["days"][0]["stops"][0]
assert lodging["name"] == "군산모텔"
assert (lodging["latitude"], lodging["longitude"]) == (35.98642, 126.70612)
without_coords, _ = grounding.parse_courses(_payload([item]), "1박 2일", "군산모텔")
lodging2 = without_coords[0]["days"][0]["stops"][0]
assert "latitude" not in lodging2 and "longitude" not in lodging2