From 478ac5f5745f935794cbd1d1ed9c91c13864d68b Mon Sep 17 00:00:00 2001 From: ysb21 Date: Wed, 9 Sep 2026 14:07:46 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=91=EC=9D=80=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- backend/finetune/Modelfile | 13 +- backend/finetune/generate_dataset.py | 28 +-- backend/main.py | 346 +++++++++++---------------- backend/tests/test_cache.py | 6 +- backend/tests/test_helpers.py | 5 +- backend/tests/test_optimization.py | 9 +- frontend/src/App.jsx | 113 ++++----- 7 files changed, 213 insertions(+), 307 deletions(-) diff --git a/backend/finetune/Modelfile b/backend/finetune/Modelfile index cdf20ab..bf3124c 100644 --- a/backend/finetune/Modelfile +++ b/backend/finetune/Modelfile @@ -1,13 +1,12 @@ -# WTGMate 장소추출 파인튜닝 모델(wtgmate-parser)을 Ollama에 등록하기 위한 Modelfile. +# 파인튜닝 모델(wtgmate-parser)을 Ollama에 등록하는 Modelfile. # -# 배포 시: -# 1) 파인튜닝 산출물 GGUF(wtgmate-parser.gguf, ~1.9GB, git 제외)를 이 파일과 같은 폴더에 둔다 -# (Colab 노트북으로 생성하거나 S3에서 내려받는다 — DEPLOY.md 참고). +# 등록: +# 1) GGUF(wtgmate-parser.gguf, ~1.9GB, git 제외)를 이 파일이랑 같은 폴더에 둠 (Colab에서 생성). # 2) ollama create wtgmate-parser -f Modelfile # -# TEMPLATE는 채팅 래핑 없이 프롬프트를 그대로 넘기는 raw passthrough다. 학습을 순수 텍스트 -# 이어쓰기(alpaca_prompt) 형식으로 했기 때문에, 추론 때도 동일 형식이어야 성능이 유지된다. -# (main.py의 call_ollama가 raw=True로 호출하는 것과 짝을 이룬다.) +# TEMPLATE는 채팅 래핑 없이 프롬프트 그대로 넘기는 raw passthrough. +# 학습을 텍스트 이어쓰기(alpaca_prompt)로 해서 추론도 같은 형식이어야 함. +# (main.py call_ollama가 raw=True로 부르는 거랑 짝) FROM ./wtgmate-parser.gguf TEMPLATE {{ .Prompt }} diff --git a/backend/finetune/generate_dataset.py b/backend/finetune/generate_dataset.py index 2e91e4d..fb5c6e4 100644 --- a/backend/finetune/generate_dataset.py +++ b/backend/finetune/generate_dataset.py @@ -1,21 +1,20 @@ """ -1단계: Gemini를 "교사 모델"로 써서 파인튜닝용 합성 데이터를 생성한다. +1단계: Gemini를 교사 모델로 써서 파인튜닝용 합성 데이터를 만든다. -목표 태스크는 backend/main.py 의 /api/parse-tasks 와 완전히 동일하다: - 자연어 일정 문장 -> 방문 장소 JSON 배열 (name, task, priority, lat, lng, address) +태스크는 main.py의 /api/parse-tasks랑 똑같음: + 자연어 일정 문장 -> 장소 JSON 배열 (name, task, priority, lat, lng, address) -여기서 만든 데이터로 2단계(로컬 소형 모델 LoRA 파인튜닝)를 진행하고, -학습이 끝나면 그 모델을 Ollama로 서빙해서 backend/main.py 의 Gemini 호출부를 -그대로 대체하는 것이 최종 목표다. +이 데이터로 2단계에서 소형 모델 LoRA 파인튜닝하고, 학습된 모델을 Ollama로 서빙해서 +main.py의 Gemini 호출부를 대체하는 게 목표. -사용법 (backend 디렉터리에서, venv 활성화 후): +사용법 (backend에서 venv 켜고): python finetune/generate_dataset.py --count 200 -환경변수는 backend/.env 를 그대로 재사용한다 (GEMINI_API_KEY, GEMINI_MODEL). +환경변수는 backend/.env 재사용 (GEMINI_API_KEY, GEMINI_MODEL). 출력: - finetune/data/raw_examples.jsonl - 생성된 원본 예시 (디버깅/검수용) - finetune/data/alpaca_dataset.jsonl - 2단계 LoRA 학습에 바로 넣을 수 있는 형식 + data/raw_examples.jsonl - 원본 예시(검수용) + data/alpaca_dataset.jsonl - LoRA 학습에 바로 넣는 형식 """ import argparse import json @@ -32,8 +31,7 @@ RAW_PATH = DATA_DIR / "raw_examples.jsonl" ALPACA_PATH = DATA_DIR / "alpaca_dataset.jsonl" -# backend/main.py 의 parse_tasks 프롬프트 지시문과 완전히 동일하게 유지한다. -# (파인튜닝 모델도 결국 같은 지시문으로 호출될 것이므로 학습/추론 프롬프트를 맞춰야 한다) +# main.py parse_tasks 지시문이랑 똑같이 유지해야 함(학습/추론 프롬프트를 맞춰야 함). TASK_INSTRUCTION = """아래 일정 문장에서 방문해야 할 장소를 모두 추출해 JSON 배열로 반환해줘. 각 항목은 반드시 다음 필드를 가져야 한다. @@ -84,7 +82,7 @@ def valid_appointment_time(value) -> bool: - """appointment_time은 null(없음) 또는 정상 범위의 'HH:MM' 문자열만 허용한다.""" + """appointment_time은 null이거나 정상 범위 'HH:MM'만 허용.""" if value is None: return True if not isinstance(value, str): @@ -175,7 +173,7 @@ def main(): DATA_DIR.mkdir(parents=True, exist_ok=True) - # 이미 만든 input은 중복 생성하지 않도록 이어쓰기 지원 + # 이미 만든 input은 중복 안 만들게 이어쓰기 지원 seen_inputs = set() if RAW_PATH.exists(): with RAW_PATH.open(encoding="utf-8") as f: @@ -203,7 +201,7 @@ def main(): except Exception as e: msg = str(e) if "GenerateRequestsPerDayPerProjectPerModel" in msg: - # 일일 쿼터 소진: 재시도해도 오늘은 안 풀리므로 즉시 중단 + # 일일 쿼터 소진이면 재시도해도 오늘은 안 되니 바로 중단 print(f" [중단] '{model_name}' 모델의 무료 일일 쿼터를 모두 썼습니다.") print(f" 누적 {collected}개까지 저장됨. 다른 모델(--model)을 쓰거나 내일 다시 시도하세요.") return diff --git a/backend/main.py b/backend/main.py index 8089cb3..fc55932 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,19 +20,15 @@ load_dotenv() -# backend/finetune/ 에서 파인튜닝해 Ollama에 등록한 로컬 모델을 사용한다 (Gemini 대체). +# 파인튜닝해서 Ollama에 올린 로컬 모델 (원래 Gemini 쓰던 걸 대체함) OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "wtgmate-parser") KAKAO_REST_API_KEY = os.getenv("KAKAO_REST_API_KEY") -# Tmap(SK) 보행자 경로안내 API 키. 있으면 도보를 실제 인도 경로/거리/시간으로 계산하고, -# 없으면 기존 추정값(fallback_leg)으로 동작한다. +# 도보(Tmap) / 대중교통(ODsay) 키. 없으면 fallback_leg 추정으로 넘어감. TMAP_APP_KEY = os.getenv("TMAP_APP_KEY") -# ODsay 대중교통 길찾기 API 키. 있으면 대중교통을 실제 경로/시간으로 계산하고, -# 없으면 기존 추정값(fallback_leg)으로 동작한다. (무료 30회/일 — Tmap 대중교통 10/일보다 넉넉하나 여전히 소량) -ODSAY_API_KEY = os.getenv("ODSAY_API_KEY") +ODSAY_API_KEY = os.getenv("ODSAY_API_KEY") # 무료 30회/일이라 아껴 써야 함 -# 허용할 프론트 오리진. 배포 시 ALLOWED_ORIGINS에 도메인을 콤마로 주입한다 -# (예: "https://wtgmate.example.com"). 미설정이면 로컬 개발 편의를 위해 전체 허용("*"). +# 배포 도메인 콤마로 넣기. 안 넣으면 로컬 개발용으로 전부 허용. ALLOWED_ORIGINS = [o.strip() for o in os.getenv("ALLOWED_ORIGINS", "*").split(",") if o.strip()] @@ -42,49 +38,31 @@ app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, - # 이 앱은 쿠키/자격증명을 쓰지 않는다. allow_origins="*"와 allow_credentials=True 조합은 - # 브라우저가 거부하므로 False로 둔다(특정 오리진을 넣어도 자격증명이 필요 없어 False 유지). + # 쿠키 안 쓰니까 False. ("*" + credentials=True는 브라우저가 막음) allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) -# ========================================================= -# 튜닝 상수 -# ========================================================= +# 튜닝 상수들 -# 완전탐색(순열)을 쓸 수 있는 최대 "목적지" 개수 (출발지 제외). -# 9! = 362,880 로 파이썬 루프로도 1초 이내에 충분히 처리 가능. -# 이보다 많아지면 근사/휴리스틱 방식으로 자동 전환한다. +# 완전탐색 돌릴 최대 목적지 수. 9!=36만이라 그냥 돌려도 1초 안에 끝남. 넘으면 OR-Tools로. MAX_BRUTE_FORCE_STOPS = 9 -# AI 추천 모드에서 "중요도 1점당, 방문 순서 1칸당" 부여할 페널티(초). -# priority는 1이 가장 중요 / 5가 가장 여유이므로, 그대로 곱하면 방향이 반대가 된다. -# (priority=1을 낮은 가중치로 취급해버려 "중요한 곳을 늦게 가도 페널티가 작아지는" 오류 발생) -# 그래서 importance = 6 - priority 로 뒤집어서 사용한다 (priority1 -> importance5, priority5 -> importance1). -# 즉 importance(priority) * position * AI_PRIORITY_WEIGHT_SEC 만큼 패널티가 붙어서, -# "중요한 장소일수록 늦게 방문할 때 페널티가 커지도록" 만든다. -# 값을 늘리면 "우선순위를 얼마나 강하게 반영할지"가 커진다. -# 튜닝 근거(2026-08, 실측 스윕): 60 미만이면 우선순위가 과소반영되어 "가장 중요한 곳"이 -# 앞으로 오지 않고, 100을 넘어서면 순서가 우선순위 쪽으로 포화되어 AI 모드가 사실상 -# priority 모드와 같아진다(이전 기본값 300은 이동시간을 약 5배 압도했다). 그 사이 60~100이 -# "중요한 곳을 먼저 가되 나머지는 이동시간으로 최적화"하는 균형 구간이라, 하한(60)에서 -# 여유를 둔 100을 기본값으로 쓴다(우선순위 지배력 약 1.8배). +# AI 모드에서 우선순위를 이동시간(초)이랑 같이 계산하려고 곱하는 가중치. +# priority가 1이 제일 중요라 그대로 곱하면 방향이 반대라 6 - priority로 뒤집어 씀. +# 값 몇 개 넣어보니 너무 작으면 우선순위가 안 먹고 너무 크면 priority 모드랑 똑같아져서 100쯤. AI_PRIORITY_WEIGHT_SEC = 100 -# 약속 시각(appointment_time)을 어긴 순서에 부여하는 페널티(초). -# 어떤 이동시간/우선순위 비용보다도 압도적으로 크게 잡아, "약속을 지키는 순서"가 -# 항상 그렇지 않은 순서보다 먼저 선택되도록 만든다(사실상 하드 제약). +# 약속시각 어겼을 때 페널티. 이동시간/우선순위보다 압도적으로 크게 잡아서 사실상 하드 제약처럼 동작. APPOINTMENT_VIOLATION_PENALTY_SEC = 10_000_000 -# 약속 시각 대비 허용하는 지각(분). 0이면 약속 시각까지 정확히 도착해야 한다. +# 허용 지각(분). 0이면 약속시각까지 딱 맞춰 도착해야 함. APPOINTMENT_TOLERANCE_MIN = 0 -# ========================================================= -# Models -# ========================================================= +# 요청/응답 모델 class ParseRequest(BaseModel): user_text: str @@ -97,11 +75,9 @@ class LocationItem(BaseModel): lat: float lng: float address: Optional[str] = "" - # appointment_time: "HH:MM" 고정 약속 시각(= 도착 마감). LLM이 산문에서 추출한다. - # 값이 있으면 그 시각까지 반드시 도착해야 하는 하드 제약으로 취급한다. + # "HH:MM" 약속시각(도착 마감). LLM이 문장에서 뽑음. 있으면 그 시각까지 도착해야 하는 하드 제약. appointment_time: Optional[str] = None - # duration_min: 그 장소에서 머무는(체류) 시간(분). 사용자가 입력한다. - duration_min: Optional[int] = 0 + duration_min: Optional[int] = 0 # 체류 시간(분), 사용자 입력 class OptimizeRequest(BaseModel): @@ -112,27 +88,23 @@ class OptimizeRequest(BaseModel): # priority : 우선순위 그룹 순서를 절대 기준으로 강제, 그룹 내에서만 이동시간 최소화 # ai : 이동시간 + (우선순위 x 방문순서) 페널티를 종합한 점수 최소화 optimize_mode: str = "ai" - # start_time: "HH:MM" 예상 출발 시각(사용자 입력). 지금일 수도, 내일일 수도 있으므로 - # "현재 시각"이 아니라 사용자가 정한 출발 예정 시각으로 다룬다. - # 있으면 약속 시각 제약과 도착 시각 계산에 사용한다. + # "HH:MM" 출발 예정 시각. '현재 시각'이 아니라 사용자가 정한 값(내일일 수도 있음). + # 있으면 약속 제약/도착시각 계산에 씀. start_time: Optional[str] = None class RouteDetailRequest(BaseModel): ordered_locations: List[LocationItem] travel_mode: str = "car" - # start_time: "HH:MM" 예상 출발 시각. 있으면 각 장소의 도착/출발 시각을 계산해 반환한다. + # 있으면 각 장소 도착/출발 시각을 계산해서 같이 돌려줌. start_time: Optional[str] = None -# ========================================================= -# Common helpers -# ========================================================= +# 공통 헬퍼 def clamp_priority(value: Optional[int]) -> int: try: - # `value or 3`는 value=0일 때도 falsy로 취급되어 3으로 바뀌어버리므로 - # None인 경우에만 기본값 3을 쓰도록 명시적으로 검사한다. + # value=0이 `value or 3`에서 falsy로 걸려 3이 되던 버그가 있어서, None만 기본값 처리. value = int(value) if value is not None else 3 except (TypeError, ValueError): value = 3 @@ -140,8 +112,7 @@ def clamp_priority(value: Optional[int]) -> int: def importance_score(priority: Optional[int]) -> int: - """priority(1=가장 중요 ~ 5=가장 여유)를 '중요도 가중치'로 뒤집는다. - priority=1 -> 5 (가중치 최대), priority=5 -> 1 (가중치 최소).""" + # priority(1=중요~5=여유)를 가중치로 뒤집음. 1->5, 5->1 return 6 - clamp_priority(priority) @@ -155,7 +126,7 @@ def validate_locations(locations: List[LocationItem]): def parse_hhmm(value: Optional[str]) -> Optional[int]: - """'HH:MM' 문자열을 자정 기준 분(minute)으로 변환. 형식이 잘못되면 None.""" + """'HH:MM' -> 자정 기준 분. 형식 이상하면 None.""" if not value: return None try: @@ -171,7 +142,7 @@ def parse_hhmm(value: Optional[str]) -> Optional[int]: def format_hhmm(minutes: Optional[float]) -> Optional[str]: - """분(minute)을 'HH:MM'로 변환. 자정을 넘어가면 24시간으로 나눈 나머지로 표기(같은 날 가정).""" + """분 -> 'HH:MM'. 자정 넘으면 24로 나눈 나머지(같은 날 취급).""" if minutes is None: return None m = int(round(minutes)) % (24 * 60) @@ -184,17 +155,13 @@ def build_schedule( time_matrix: List[List[int]], start_min: Optional[int], ): - """방문 순서(출발지 0을 맨 앞에 포함)를 따라 시간축을 계산한다. + """방문 순서(0=출발지 포함)대로 시간축을 계산. - 각 정거장에서: 도착 = 직전 출발 + 이동시간. 약속 시각이 있으면 - - 일찍 도착하면 약속 시각까지 대기(그때부터 체류 시작), - - 약속 시각(+허용 지각)을 넘겨 도착하면 위반으로 기록. - 이후 체류시간(duration_min)만큼 머문 뒤 다음 장소로 출발한다. + 도착 = 직전 출발 + 이동시간. 약속시각이 있으면 일찍 오면 그 시각까지 대기, + 늦으면 위반으로 기록. 그다음 체류시간만큼 있다가 출발. 반환: (schedule, violations, finish_min) - schedule: [{node, arrival_min, depart_min, appointment_min, late}] - violations: 약속을 어긴 node 인덱스 목록 - finish_min: 마지막 장소에서의 최종 출발(=일정 종료) 시각 + finish_min = 마지막 장소에서의 출발 시각(=일정 끝나는 시각) """ schedule = [] violations = [] @@ -205,7 +172,7 @@ def build_schedule( late = False if pos == 0: - arrival = t # 출발지: 도착=출발 시각 + arrival = t # 출발지는 도착=출발 else: prev = full_order[pos - 1] if t is not None: @@ -216,7 +183,7 @@ def build_schedule( late = True violations.append(node) elif arrival < appt: - t = appt # 일찍 도착 -> 약속 시각까지 대기 + t = appt # 일찍 왔으면 약속시각까지 대기 dwell = int(getattr(all_locations[node], "duration_min", 0) or 0) if t is not None: @@ -234,10 +201,9 @@ def build_schedule( def chronological_penalty_sec(stop_order: List[int], all_locations: List[LocationItem]) -> int: - """출발 시각을 모를 때 쓰는 '약속 시간순 정렬' 페널티. - 도착 시각/지각은 계산할 수 없으므로, 약속 시각이 입력된 장소들끼리만 - '이른 약속 -> 늦은 약속' 순서가 되도록 역순 쌍(inversion) 1개당 큰 페널티를 준다. - (약속이 0~1개면 항상 0)""" + """출발시각을 모를 때 쓰는 페널티. 도착시각을 못 구하니까, + 약속 있는 장소들끼리 '이른 약속 -> 늦은 약속' 순서가 되도록 + 뒤집힌 쌍 하나당 큰 페널티. (약속 0~1개면 0)""" appt_seq = [ m for node in stop_order @@ -258,9 +224,8 @@ def appointment_penalty_sec( time_matrix: List[List[int]], start_min: Optional[int], ) -> int: - """주어진 목적지 방문 순서(출발지 제외)의 약속 관련 페널티(초). - - 출발 시각이 있으면: 실제 도착 시각을 계산해 지각(약속 위반) 개수에 비례한 페널티. - - 출발 시각이 없으면: 도착 계산이 불가하므로 약속 '시간순 정렬'만 유도하는 페널티.""" + """방문 순서의 약속 페널티(초). + 출발시각 있으면 실제 지각 개수로, 없으면 시간순 정렬만 유도.""" if start_min is None: return chronological_penalty_sec(stop_order, all_locations) _, violations, _ = build_schedule([0] + list(stop_order), all_locations, time_matrix, start_min) @@ -272,14 +237,12 @@ def recommended_departure( all_locations: List[LocationItem], time_matrix: List[List[int]], ) -> Tuple[Optional[int], Optional[bool]]: - """확정된 방문 순서에서 '약속을 지킬 수 있는 가장 늦은 출발 시각'을 역산한다. - - build_schedule의 약속 위반 개수는 출발 시각이 늦어질수록 단조 증가한다 - (늦게 나가면 도착도 늦거나 같아지고, 약속 대기는 지연만 시키므로). - 따라서 '위반이 D=0에서의 최소치와 같은 최대 D'를 이진탐색으로 찾는다. - - 모든 약속을 지킬 수 있으면(=최소 위반 0): 그 시각까지는 늦게 나가도 안전 -> feasible=True - - 불가능하면(최소 위반>0): 위반을 최소로 유지하는 가장 늦은 출발 시각 -> feasible=False - 약속이 하나도 없으면 (None, None)을 반환한다(추천 없음).""" + """확정된 순서에서 '약속 지킬 수 있는 가장 늦은 출발시각'을 역산. + + 위반 개수는 출발이 늦어질수록 단조 증가(늦게 나가면 도착도 늦어짐)라서, + 위반이 D=0일 때 최소치와 같은 가장 큰 D를 이진탐색으로 찾음. + 전부 지킬 수 있으면 feasible=True, 애초에 불가능하면 위반 최소인 최대 시각 + False. + 약속이 하나도 없으면 (None, None).""" has_appt = any( parse_hhmm(getattr(all_locations[n], "appointment_time", None)) is not None for n in full_order @@ -291,7 +254,7 @@ def violation_count(dep_min: int) -> int: _, viol, _ = build_schedule(full_order, all_locations, time_matrix, dep_min) return len(viol) - target = violation_count(0) # 단조증가라 D=0이 위반 최소 + target = violation_count(0) # 단조증가라 0시 출발이 위반 최소 lo, hi = 0, 24 * 60 - 1 while lo < hi: mid = (lo + hi + 1) // 2 @@ -315,12 +278,10 @@ def haversine_distance_m(a: LocationItem, b: LocationItem) -> float: return 2 * R * math.asin(math.sqrt(h)) -# ========================================================= -# 1. 로컬 모델(Ollama) 장소 추출 + Kakao 지오코딩 -# ========================================================= +# 장소 추출(Ollama) + Kakao 지오코딩 -# backend/finetune/generate_dataset.py 의 TASK_INSTRUCTION, 그리고 파인튜닝 노트북의 -# alpaca_prompt 와 정확히 동일해야 한다 (학습 때와 다른 형식으로 프롬프트를 주면 성능이 크게 떨어짐). +# 이 지시문은 generate_dataset.py랑 파인튜닝 노트북(alpaca_prompt)이랑 글자까지 똑같이 맞춰야 함. +# 학습 때랑 다른 형식으로 넣으면 소형 모델이라 성능 확 떨어짐. TASK_INSTRUCTION = """아래 일정 문장에서 방문해야 할 장소를 모두 추출해 JSON 배열로 반환해줘. 각 항목은 반드시 다음 필드를 가져야 한다. @@ -350,11 +311,10 @@ def haversine_distance_m(a: LocationItem, b: LocationItem) -> float: async def call_ollama(prompt: str) -> str: - """Ollama에 등록된 파인튜닝 모델(wtgmate-parser)을 호출한다. + """파인튜닝 모델(wtgmate-parser) 호출. - raw=True: Modelfile의 TEMPLATE(채팅 래핑) 없이 prompt를 있는 그대로 모델에 전달한다. - 학습을 채팅 형식이 아니라 순수 텍스트 이어쓰기(alpaca_prompt) 형식으로 했기 때문에, - 추론 때도 반드시 이 형식을 그대로 맞춰야 한다. + raw=True로 Modelfile 채팅 래핑 없이 프롬프트를 그대로 넣는다. + 학습을 채팅형이 아니라 텍스트 이어쓰기(alpaca_prompt)로 해서 추론도 같은 형식이어야 함. """ url = f"{OLLAMA_HOST}/api/generate" payload = { @@ -371,11 +331,10 @@ async def call_ollama(prompt: str) -> str: async def geocode_place(name: str, client: httpx.AsyncClient) -> Optional[Dict[str, object]]: - """카카오 로컬 키워드 검색으로 장소명 -> 실제 좌표/주소를 조회한다. + """카카오 로컬 키워드 검색으로 장소명 -> 실제 좌표/주소. - LLM(Gemini든 파인튜닝한 로컬 모델이든)이 lat/lng를 직접 기억해서 답하는 건 - 본질적으로 신뢰할 수 없다 (특히 소형 모델은 거의 항상 틀림). 그래서 LLM은 - 장소명/할일/우선순위 추출까지만 맡기고, 좌표는 항상 이 함수로 다시 조회해서 덮어쓴다. + LLM이 뱉은 lat/lng는 못 믿는다(소형 모델은 거의 다 틀림). LLM은 장소명/할일/우선순위까지만 + 맡기고 좌표는 여기서 다시 조회해서 덮어쓴다. """ if not KAKAO_REST_API_KEY or not name: return None @@ -402,7 +361,7 @@ async def geocode_place(name: str, client: httpx.AsyncClient) -> Optional[Dict[s async def geocode_locations(locations: List[dict]) -> None: - """locations 리스트를 in-place로 geocode. 조회 실패 시 기존 값(LLM 추정치)을 그대로 둔다.""" + """locations를 in-place로 지오코딩. 실패하면 LLM이 준 값 그대로 둠.""" if not KAKAO_REST_API_KEY: return async with httpx.AsyncClient() as client: @@ -432,7 +391,7 @@ async def parse_tasks(req: ParseRequest): normalized = [] for item in parsed: - # 모델이 뽑은 약속 시각은 "HH:MM"로 정규화하고, 형식이 어긋나면 버린다(None). + # 약속시각은 "HH:MM"로 정규화, 형식 이상하면 버림. appt_raw = item.get("appointment_time") appt = format_hhmm(parse_hhmm(appt_raw)) if appt_raw else None normalized.append( @@ -450,12 +409,13 @@ async def parse_tasks(req: ParseRequest): if not normalized: raise ValueError("장소가 추출되지 않았습니다.") - # 모델이 뱉은 lat/lng/address는 신뢰하지 않고 Kakao로 다시 조회해서 덮어쓴다. + # 좌표는 못 믿으니 Kakao로 다시 조회해서 덮어씀. await geocode_locations(normalized) return {"status": "success", "data": normalized, "is_mock": False} except Exception as e: + # 파싱 실패해도(콜드스타트 등) 데모 안 끊기게 목업으로 폴백. print("로컬 모델(Ollama) 파싱 실패:", e) return {"status": "success", "data": mock_data(), "is_mock": True} @@ -468,13 +428,11 @@ def mock_data(): ] -# ========================================================= -# 2. 실제 이동시간 조회 -# ========================================================= +# 실제 이동시간 조회 def extract_car_path(route: dict) -> List[List[float]]: - """Kakao Mobility 응답의 도로 좌표열을 [[lat, lng], ...] 로 펼친다. - vertexes는 [x1, y1, x2, y2, ...] (x=lng, y=lat) 평면 배열이라 2개씩 끊어 뒤집는다.""" + """Kakao Mobility 응답의 도로 좌표를 [[lat, lng], ...]로 펼침. + vertexes가 [x1,y1,x2,y2,...] (x=경도, y=위도)라 2개씩 끊어서 뒤집음.""" path: List[List[float]] = [] for section in route.get("sections", []): for road in section.get("roads", []): @@ -485,8 +443,8 @@ def extract_car_path(route: dict) -> List[List[float]]: async def get_car_leg(origin: LocationItem, dest: LocationItem, client: httpx.AsyncClient, include_path: bool = False): - """Kakao Mobility 자동차 길찾기. 반환: (duration sec, distance m, path[[lat,lng]...]). - include_path=False면 path는 빈 리스트(행렬 계산 등 좌표열이 불필요한 경우 파싱 생략).""" + """Kakao Mobility 자동차 길찾기. 반환: (초, 미터, path). + include_path=False면 좌표 파싱 생략(빈 리스트).""" if not KAKAO_REST_API_KEY: raise RuntimeError("KAKAO_REST_API_KEY가 없습니다.") @@ -506,10 +464,9 @@ async def get_car_leg(origin: LocationItem, dest: LocationItem, client: httpx.As raise RuntimeError("Kakao 경로 결과가 없습니다.") route0 = data["routes"][0] - # Kakao Mobility는 길찾기에 실패해도 200으로 routes[0]에 result_code/result_msg만 담아 - # 보낸다(예: code 104 출발·도착이 너무 가까움). 이때 summary가 없어 예전엔 KeyError('summary')로 - # 터졌다. result_code(0=성공)를 먼저 확인해, 실패면 사유를 담아 명확히 실패시킨다 - # (→ get_leg_duration이 추정치로 폴백. 아주 짧은 구간이라 추정으로 충분하다). + # Kakao는 실패해도 200에 result_code만 담아 보냄(예: 104=출발/도착 너무 가까움). + # 이때 summary가 없어서 예전엔 KeyError로 터졌음. result_code(0=성공) 먼저 확인. + # 실패하면 위에서 추정 폴백되는데, 이런 건 대개 짧은 구간이라 추정으로 충분함. result_code = route0.get("result_code", 0) if result_code != 0 or "summary" not in route0: raise RuntimeError(f"Kakao 경로 실패(code {result_code}): {route0.get('result_msg', 'summary 없음')}") @@ -520,11 +477,10 @@ async def get_car_leg(origin: LocationItem, dest: LocationItem, client: httpx.As async def get_walk_leg(origin: LocationItem, dest: LocationItem, client: httpx.AsyncClient, include_path: bool = False): - """Tmap(SK) 보행자 경로안내. 반환: (duration sec, distance m, path[[lat,lng]...]). + """Tmap 보행자 경로. 반환: (초, 미터, path). - 응답은 GeoJSON FeatureCollection이며, - - 총거리/총시간은 첫 Point feature의 properties(totalDistance[m], totalTime[sec]), - - 실제 인도 경로는 LineString feature들의 coordinates([경도,위도] 순)에 들어있다. + 응답이 GeoJSON이라 총거리/시간은 첫 Point의 properties에서, + 실제 인도 경로는 LineString들의 coordinates([경도,위도])에서 뽑음. """ if not TMAP_APP_KEY: raise RuntimeError("TMAP_APP_KEY가 없습니다.") @@ -560,7 +516,7 @@ async def get_walk_leg(origin: LocationItem, dest: LocationItem, client: httpx.A geom = f.get("geometry") or {} if include_path and geom.get("type") == "LineString": for coord in geom.get("coordinates") or []: - # coord = [경도(lng), 위도(lat)] -> [lat, lng] + # [경도, 위도] -> [위도, 경도] path.append([coord[1], coord[0]]) if total_distance is None: @@ -569,19 +525,18 @@ async def get_walk_leg(origin: LocationItem, dest: LocationItem, client: httpx.A async def get_transit_leg(origin: LocationItem, dest: LocationItem, client: httpx.AsyncClient, include_path: bool = False): - """ODsay 대중교통 길찾기(searchPubTransPathT). 반환: (duration sec, distance m, path[[lat,lng]...]). + """ODsay 대중교통 길찾기(searchPubTransPathT). 반환: (초, 미터, path). - 응답 `result.path[]` 중 첫 경로(최적)를 사용한다. - - 총시간: `path[0].info.totalTime`(분). - - 총거리: 각 `subPath[].distance`(m) 합(도보 환승 구간 포함). - - 실제 경로: 각 subPath의 `passStopList.stations[]`(x=경도, y=위도)를 순서대로 이어 붙인다. - 역/정류장 좌표 기반 폴리라인이라 loadLane 2차 호출 없이 1회로 끝내 쿼터를 아낀다. + result.path[] 중 첫 경로(최적)만 사용. + 총시간 = info.totalTime(분), 총거리 = subPath distance 합. + 경로는 각 subPath의 passStopList.stations(정류장 좌표)로 그림. + 정류장 좌표 기반이라 loadLane 2차 호출 없이 1번으로 끝내서 쿼터 아낌. """ if not ODSAY_API_KEY: raise RuntimeError("ODSAY_API_KEY가 없습니다.") url = "https://api.odsay.com/v1/api/searchPubTransPathT" - # apiKey는 httpx가 자동 URL 인코딩한다(원문 그대로 전달). + # apiKey는 httpx가 알아서 URL 인코딩해줌. params = { "SX": origin.lng, "SY": origin.lat, @@ -594,7 +549,7 @@ async def get_transit_leg(origin: LocationItem, dest: LocationItem, client: http response.raise_for_status() data = response.json() - # ODsay는 실패 시 200이라도 본문에 error를 담는다(예: 출발-도착이 너무 가까움 → 도보 권장). + # ODsay도 실패 시 200에 error 담아 보냄(예: 너무 가까워서 도보 권장). if data.get("error"): raise RuntimeError(f"ODsay 오류: {data.get('error')}") @@ -621,11 +576,11 @@ async def get_transit_leg(origin: LocationItem, dest: LocationItem, client: http if x is not None and y is not None: path.append([float(y), float(x)]) # [위도, 경도] - # 도보 환승만 있어 정류장 좌표가 없으면, 최소한 출발-도착 직선이라도 그리게 한다. + # 도보 환승만 있어서 정류장 좌표가 없으면 최소한 직선이라도 그림. if include_path and len(path) < 2: path = [[origin.lat, origin.lng], [dest.lat, dest.lng]] - # 총거리 정보가 없으면 직선거리로 보정(시간은 실측 totalTime 사용). + # 거리 정보 없으면 직선거리로 보정(시간은 실측값 씀). if total_distance <= 0: total_distance = haversine_distance_m(origin, dest) @@ -633,8 +588,7 @@ async def get_transit_leg(origin: LocationItem, dest: LocationItem, client: http def is_estimated_mode(mode: str) -> bool: - """해당 이동수단의 거리/시간이 '실제 API'가 아니라 보정 추정값인지 여부. - car: Kakao 키 없으면 추정 / walk: Tmap 키 없으면 추정 / transit: ODsay 키 없으면 추정.""" + """이 이동수단이 실 API 없이 추정으로 도는 중인지. (해당 키가 없으면 추정)""" if mode == "car": return not KAKAO_REST_API_KEY if mode == "walk": @@ -645,8 +599,8 @@ def is_estimated_mode(mode: str) -> bool: def fallback_leg(origin: LocationItem, dest: LocationItem, mode: str): - """실 API가 없거나 실패했을 때 쓰는 추정 계산(직선거리×도로보정×모드별 속도). - car=Kakao, walk=Tmap, transit=ODsay 실 API가 정상일 땐 이 함수를 타지 않는다.""" + """실 API 없거나 실패했을 때 추정 계산. 직선거리 x 도로보정 x 모드별 속도. + 실 API가 정상이면 여기 안 옴.""" straight_m = haversine_distance_m(origin, dest) if mode == "walk": @@ -660,14 +614,14 @@ def fallback_leg(origin: LocationItem, dest: LocationItem, mode: str): duration_sec = (distance_m / 1000) / speed_kmh * 3600 if mode == "transit": - duration_sec += 5 * 60 + duration_sec += 5 * 60 # 대기/환승 여유 - # 폴백(도보/대중교통/자동차 API 실패)은 실제 도로 좌표열이 없으므로 path는 빈 리스트. + # 추정이라 실제 좌표는 없음. path는 빈 리스트. return duration_sec, distance_m, [] async def get_leg_duration(origin: LocationItem, dest: LocationItem, mode: str, client: httpx.AsyncClient, include_path: bool = False): - """반환: (duration sec, distance m, path). path는 자동차 실경로가 있을 때만 채워진다.""" + """반환: (초, 미터, path). 이동수단별로 실 API 시도하고, 실패하면 조용히 추정 폴백.""" if mode == "car" and KAKAO_REST_API_KEY: try: return await get_car_leg(origin, dest, client, include_path=include_path) @@ -689,19 +643,17 @@ async def get_leg_duration(origin: LocationItem, dest: LocationItem, mode: str, return fallback_leg(origin, dest, mode) -# 최종 경로(route-eta) 전용 leg 캐시. -# 같은 좌표쌍+이동수단의 실 API 결과(거리/시간/경로 좌표)를 재사용해, 이동수단 토글이나 -# 동일 장소 재최적화 때 무료 쿼터를 다시 태우지 않는다. -# - 크기 상한(LRU): 상시 가동 시 무한 증가하지 않도록 오래된 항목부터 제거. -# - TTL: 자동차/대중교통은 실시간성이 있어 낡은 값 재사용을 막는다. 도보는 거리 불변이라 무기한. +# 최종 경로(route-eta)용 leg 캐시. +# 같은 좌표쌍+이동수단 결과를 재사용해서, 이동수단 토글이나 재최적화 때 쿼터 안 태움. +# LRU로 크기 제한(상시 가동 시 무한 증가 방지), TTL로 실시간성 있는 자동차/대중교통은 만료시킴(도보는 무기한). _FINAL_LEG_CACHE: "OrderedDict[Tuple, Tuple[Optional[float], Tuple[float, float, List[List[float]]]]]" = OrderedDict() _FINAL_LEG_CACHE_MAX = 512 -_FINAL_LEG_TTL_SEC: Dict[str, int] = {"car": 600, "transit": 600} # walk 미지정 = 무기한 +_FINAL_LEG_TTL_SEC: Dict[str, int] = {"car": 600, "transit": 600} # walk는 무기한 def _now() -> float: - """캐시 TTL 판정용 단조 시계. 테스트에서 이 함수만 교체해 시간 경과를 흉내 낸다 - (time.monotonic을 직접 패치하면 asyncio 이벤트 루프 시계까지 바뀌므로 분리한다).""" + """TTL 판정용 시계. time.monotonic을 직접 패치하면 asyncio 루프 시계까지 바뀌어서 + 테스트에서 이것만 갈아끼우려고 한 겹 뺐음.""" return time.monotonic() @@ -710,20 +662,19 @@ def _final_leg_key(origin: LocationItem, dest: LocationItem, mode: str) -> Tuple async def get_final_leg(origin: LocationItem, dest: LocationItem, mode: str, client: httpx.AsyncClient): - """최종 경로용: 실제 경로(path 포함)를 구하되 결과를 캐시한다. + """최종 경로용: 실제 경로(path 포함)를 구하고 캐시. - 실 API가 실제 좌표열(path)을 준 경우에만 캐시한다. 키가 없거나 일시적 실패로 - 추정 폴백된 결과(path 없음)는 무료이기도 하고, 굳혀두면 이후 실측 기회를 막으므로 - 캐시하지 않는다. + 실 API가 좌표(path)를 준 경우에만 캐시. 추정 폴백된 건(path 없음) 무료이기도 하고 + 굳혀두면 다음에 실측할 기회를 막아서 캐시 안 함. """ key = _final_leg_key(origin, dest, mode) entry = _FINAL_LEG_CACHE.get(key) if entry is not None: expires_at, value = entry if expires_at is None or expires_at > _now(): - _FINAL_LEG_CACHE.move_to_end(key) # LRU: 최근 사용으로 갱신 + _FINAL_LEG_CACHE.move_to_end(key) # 최근 사용으로 갱신 return value - del _FINAL_LEG_CACHE[key] # 만료 → 제거 후 재조회 + del _FINAL_LEG_CACHE[key] # 만료됐으면 지우고 다시 조회 duration, distance, path = await get_leg_duration(origin, dest, mode, client, include_path=True) if path: @@ -732,18 +683,16 @@ async def get_final_leg(origin: LocationItem, dest: LocationItem, mode: str, cli _FINAL_LEG_CACHE[key] = (expires_at, (duration, distance, path)) _FINAL_LEG_CACHE.move_to_end(key) while len(_FINAL_LEG_CACHE) > _FINAL_LEG_CACHE_MAX: - _FINAL_LEG_CACHE.popitem(last=False) # 가장 오래 안 쓴 항목 제거 + _FINAL_LEG_CACHE.popitem(last=False) # 오래된 것부터 버림 return duration, distance, path async def build_time_distance_matrix(locations: List[LocationItem], travel_mode: str): - """모든 장소 쌍의 이동시간/거리 행렬(방문 순서 최적화용). + """모든 장소 쌍의 이동시간/거리 행렬(순서 최적화용). - 순서 결정에는 직선거리 기반 '추정치'만 사용한다. 실제 도로/인도/대중교통 API를 - 쓰면 호출이 O(n^2)로 폭증해(무료 쿼터가 매우 적은 Tmap/대중교통은 데모 1회도 - 못 돌릴 정도) 쿼터를 금방 소진한다. 반면 2~5곳 규모에서는 추정만으로도 방문 - 순서가 실제와 거의 동일하다. 정확한 거리/시간과 지도에 그릴 실제 경로는, 확정된 - '최종 경로'에 대해서만 /api/route-eta가 실 API(n-1회)로 계산한다. + 순서 정할 땐 직선거리 추정만 씀. 실 API로 채우면 O(n^2)라 무료 쿼터(대중교통 10/일)로는 + 데모 한 번도 못 돌림. 2~5곳 규모면 추정만으로도 순서가 실제랑 거의 같음. + 정확한 거리/시간이랑 지도용 실제 경로는 확정된 최종 경로에만 route-eta가 실 API(n-1회)로 구함. """ n = len(locations) time_matrix = [[0] * n for _ in range(n)] @@ -757,23 +706,19 @@ async def build_time_distance_matrix(locations: List[LocationItem], travel_mode: time_matrix[i][j] = max(1, int(round(duration))) distance_matrix[i][j] = max(1, int(round(distance))) - # 행렬 자체는 항상 추정치지만, 사용자에게 표시할 '추정' 여부는 최종 경로 기준으로 - # 판단한다(실 API가 있는 모드면 최종 경로는 실측되므로 estimated=False). + # 행렬은 항상 추정이지만, UI에 표시할 '추정' 여부는 최종 경로 기준으로 봄 + # (키 있는 모드면 최종 경로는 실측이라 estimated=False). estimated = is_estimated_mode(travel_mode) return time_matrix, distance_matrix, estimated -# ========================================================= -# 3. 세 가지 최적화 모드 (핵심 수정 사항) -# ========================================================= -# -# 모든 모드는 "출발지(index 0)는 고정, 나머지 목적지들의 방문 순서만 결정"하는 -# open-path 문제로 취급한다. time_matrix는 build_time_distance_matrix()로 미리 구해둔 -# n x n 행렬(초 단위)을 그대로 쓴다. +# 최적화 모드 3종 +# 세 모드 다 출발지(0)는 고정하고 나머지 목적지 순서만 정하는 open-path 문제. +# time_matrix는 build_time_distance_matrix로 구한 n x n 행렬(초). def path_travel_time(order: List[int], time_matrix: List[List[int]]) -> int: - """0번(출발지)에서 시작해 order 순서대로 방문할 때의 총 이동시간(초).""" + # 0(출발지)부터 order 순서대로 돌 때 총 이동시간(초) total = 0 cur = 0 for node in order: @@ -788,9 +733,8 @@ def solve_shortest( all_locations: Optional[List[LocationItem]] = None, start_min: Optional[int] = None, ) -> List[int]: - """순수 이동시간 최소화. 우선순위는 완전히 무시한다. - 단, 출발 시각이 주어지면 약속 시각을 어기는 순서에는 큰 페널티를 얹어 - '약속을 지키는 순서'가 먼저 선택되도록 한다.""" + """이동시간만 최소화(우선순위 무시). 출발시각 있으면 약속 어기는 순서엔 + 큰 페널티 얹어서 약속 지키는 순서가 먼저 뽑히게 함.""" if len(stop_indices) <= MAX_BRUTE_FORCE_STOPS: best_order, best_cost = None, None for perm in itertools.permutations(stop_indices): @@ -801,25 +745,24 @@ def solve_shortest( best_cost, best_order = cost, list(perm) return best_order - # 장소가 너무 많을 때만 OR-Tools로 근사 (순수 이동시간 최소화 목적) + # 장소 너무 많으면 OR-Tools 근사 return solve_with_ortools(stop_indices, time_matrix, cost_fn=None) def solve_priority(stop_indices: List[int], time_matrix: List[List[int]], locations: List[LocationItem]) -> List[int]: """ - 우선순위 그룹 순서를 절대 기준으로 강제한다. - - priority 값이 작은(중요한) 그룹은 반드시 큰 그룹보다 먼저 전부 방문된다. - - 같은 그룹 안에서는 이동시간이 최소가 되는 순서를 고른다. - DP(bucket, end_node) -> (cost, path) 로 정확히 계산한다. + 우선순위 그룹 순서를 무조건 지킴. + 중요한(숫자 작은) 그룹을 반드시 먼저 다 돌고, 같은 그룹 안에서만 이동시간 최소. + DP(bucket, end_node) -> (cost, path)로 정확히 계산. """ buckets: Dict[int, List[int]] = {} for idx in stop_indices: p = clamp_priority(locations[idx].priority) buckets.setdefault(p, []).append(idx) - ordered_bucket_keys = sorted(buckets.keys()) # 1(최우선) -> 5(여유) + ordered_bucket_keys = sorted(buckets.keys()) # 1(최우선) -> 5 - # dp: {end_node: (cost, path_so_far)} 시작은 출발지(0)에서 cost 0 + # dp: {end_node: (cost, path)} 출발지(0)에서 cost 0으로 시작 dp: Dict[int, Tuple[int, List[int]]] = {0: (0, [])} for key in ordered_bucket_keys: @@ -827,7 +770,7 @@ def solve_priority(stop_indices: List[int], time_matrix: List[List[int]], locati new_dp: Dict[int, Tuple[int, List[int]]] = {} for prev_end, (prev_cost, prev_path) in dp.items(): - # 이 그룹 내부의 모든 방문 순서를 다 시도 (그룹 크기는 보통 작음) + # 그룹 내부 순서 전부 시도(그룹은 보통 작음) for perm in itertools.permutations(bucket_nodes): cost = prev_cost cur = prev_end @@ -840,7 +783,7 @@ def solve_priority(stop_indices: List[int], time_matrix: List[List[int]], locati dp = new_dp - # 마지막 그룹까지 처리한 뒤, 비용이 최소인 경로 선택 + # 마지막 그룹까지 끝나면 비용 최소 경로 선택 best_end = min(dp, key=lambda k: dp[k][0]) return dp[best_end][1] @@ -852,13 +795,11 @@ def solve_ai( start_min: Optional[int] = None, ) -> List[int]: """ - 이동시간 + (중요도 x 방문 순서 위치) 페널티를 합산한 점수를 최소화. - 중요한 장소(priority 숫자가 작음)일수록 "늦게 방문할 때" 페널티가 커지도록 - importance_score(= 6 - priority)를 가중치로 곱한다. - 순서 자체가 점수에 실질적으로 반영된다 (기존 버그였던 "순서 무관 총합 동일" 문제 해결). + 이동시간 + (중요도 x 방문순서 위치) 점수를 최소화. + 중요한 곳일수록 늦게 가면 페널티 커지게 importance(6-priority)를 곱함. + (예전엔 순서 무관하게 총합이 같아지던 버그가 있었는데, position 곱하면서 순서가 점수에 먹힘.) - 출발 시각이 주어지면 약속 시각 위반 페널티를 더해, 약속을 지키는 순서를 - (우선순위/이동시간보다 우선해서) 먼저 선택하도록 한다. + 출발시각 있으면 약속 위반 페널티도 더해서, 약속 지키는 순서가 우선순위/이동시간보다 먼저 뽑힘. """ if len(stop_indices) <= MAX_BRUTE_FORCE_STOPS: best_order, best_score = None, None @@ -873,7 +814,7 @@ def solve_ai( best_score, best_order = score, list(perm) return best_order - # 장소가 많을 때는 OR-Tools에 "다음 노드의 중요도 x 대략적 순번" 근사 비용을 실어 근사한다. + # 장소 많으면 OR-Tools에 "다음 노드 중요도 x 대략 순번" 비용 실어서 근사. def cost_fn(from_node, to_node, position): return time_matrix[from_node][to_node] + importance_score(locations[to_node].priority) * position * AI_PRIORITY_WEIGHT_SEC @@ -882,11 +823,11 @@ def cost_fn(from_node, to_node, position): def solve_with_ortools(stop_indices: List[int], time_matrix: List[List[int]], cost_fn=None) -> List[int]: """ - 장소 수가 MAX_BRUTE_FORCE_STOPS를 초과하는 예외적인 경우의 근사 해법. - cost_fn이 없으면 순수 이동시간 최소화, 있으면 해당 비용함수를 사용. - (OR-Tools 표준 API 특성상 position 인자는 정확한 전역 순번이 아닌 근사치로만 반영됨) + 장소가 MAX_BRUTE_FORCE_STOPS 넘는 예외적인 경우의 근사. + cost_fn 없으면 이동시간만, 있으면 그 비용함수 사용. + (OR-Tools 특성상 position은 정확한 전역 순번이 아니라 근사치임) """ - node_list = [0] + stop_indices # 0=출발지를 다시 포함시켜 로컬 인덱스 구성 + node_list = [0] + stop_indices # 0(출발지) 다시 넣어서 로컬 인덱스 구성 n = len(node_list) local_time = [[time_matrix[node_list[i]][node_list[j]] for j in range(n)] for i in range(n)] @@ -899,7 +840,7 @@ def transit_callback(from_index, to_index): return 0 if cost_fn is None: return local_time[i][j] - # position 근사치로 to-node의 로컬 인덱스를 사용 (완전 정확한 전역 순번은 아님) + # position 자리에 to-node 로컬 인덱스 사용(정확한 전역 순번은 아님) return cost_fn(node_list[i], node_list[j], j) callback_index = routing.RegisterTransitCallback(transit_callback) @@ -908,17 +849,16 @@ def transit_callback(from_index, to_index): parameters = pywrapcp.DefaultRoutingSearchParameters() parameters.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC parameters.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH - # 장소가 많을 때(n>=10)만 도달. 응답 지연을 줄이려 2초로 제한한다. - # (이 규모에선 2초와 5초 해의 품질 차이가 미미하다.) + # 장소 많을 때(n>=10)만 옴. 이 규모에선 2초나 5초나 품질 비슷해서 응답 빠르게 2초로. parameters.time_limit.seconds = 2 solution = routing.SolveWithParameters(parameters) if solution is None: - return stop_indices # 최후의 폴백: 원래 순서 그대로 + return stop_indices # 최후의 폴백: 원래 순서 order = [] index = routing.Start(0) - index = solution.Value(routing.NextVar(index)) # 출발지(0) 다음부터 + index = solution.Value(routing.NextVar(index)) # 출발지 다음부터 while not routing.IsEnd(index): node = manager.IndexToNode(index) order.append(node_list[node]) @@ -927,9 +867,7 @@ def transit_callback(from_index, to_index): return order -# ========================================================= -# 4. /api/optimize-route -# ========================================================= +# /api/optimize-route @app.post("/api/optimize-route") async def optimize_route(req: OptimizeRequest): @@ -946,7 +884,7 @@ async def optimize_route(req: OptimizeRequest): time_matrix, distance_matrix, estimated = await build_time_distance_matrix(all_locations, req.travel_mode) start_min = parse_hhmm(req.start_time) - stop_indices = list(range(1, len(all_locations))) # 0은 출발지, 나머지가 목적지 + stop_indices = list(range(1, len(all_locations))) # 0=출발지, 나머지 목적지 if req.optimize_mode == "shortest": order = solve_shortest(stop_indices, time_matrix, all_locations, start_min) @@ -957,8 +895,8 @@ async def optimize_route(req: OptimizeRequest): optimized_locations = [all_locations[0]] + [all_locations[i] for i in order] - # 확정된 순서에 대해 시간축을 계산해, 약속 위반 여부를 함께 알려준다. - # (여기 time_matrix는 추정치일 수 있으므로 정확한 도착 시각은 /api/route-eta가 계산한다) + # 확정 순서로 시간축 계산해서 약속 위반도 같이 알려줌. + # (여기 time_matrix는 추정이라 정확한 도착시각은 route-eta가 계산) _, violations, _ = build_schedule([0] + order, all_locations, time_matrix, start_min) violated_names = [all_locations[i].name for i in violations] @@ -972,9 +910,7 @@ async def optimize_route(req: OptimizeRequest): } -# ========================================================= -# 5. 최종 경로의 실제 거리 / 시간 계산 -# ========================================================= +# 최종 경로 실제 거리/시간 @app.post("/api/route-eta") async def calculate_route_eta(req: RouteDetailRequest): @@ -995,8 +931,7 @@ async def calculate_route_eta(req: RouteDetailRequest): async with httpx.AsyncClient() as client: for i in range(len(locs) - 1): origin, dest = locs[i], locs[i + 1] - # 최종 경로이므로 실제 도로/인도 좌표열(path)까지 받아 지도에 그리게 한다. - # get_final_leg는 결과를 캐시해 같은 구간 재요청 시 쿼터를 아낀다. + # 최종 경로라 실제 좌표(path)까지 받아서 지도에 그림. get_final_leg가 캐시해줌. duration, distance, path = await get_final_leg( origin, dest, req.travel_mode, client ) @@ -1008,22 +943,21 @@ async def calculate_route_eta(req: RouteDetailRequest): "to": dest.name, "duration_min": round(duration / 60, 1), "distance_km": round(distance / 1000, 2), - # 실 API 경로 좌표열([[lat,lng],...]). 자동차=도로, 도보=인도, 대중교통=정류장. - # 키 없음/실패로 추정 폴백되면 빈 리스트. + # 실 경로 좌표(자동차=도로, 도보=인도, 대중교통=정류장). 추정 폴백이면 빈 리스트. "path": path, }) - # 연속 구간 이동시간으로 시간행렬을 구성한다(스케줄/역산 계산에 재사용). + # 인접 구간 이동시간으로 시간행렬 구성(스케줄/역산에 재사용). n = len(locs) time_matrix = [[0] * n for _ in range(n)] for i in range(n - 1): time_matrix[i][i + 1] = max(1, int(round(leg_secs[i]))) full_order = list(range(n)) - # 유효 출발 시각 결정: - # - 사용자가 지정했으면 그 시각. - # - 미지정(③)인데 약속이 있으면 '약속을 지킬 수 있는 가장 늦은 출발 시각'을 역산해 사용. - # - 미지정 + 약속 없음이면 시간축 계산을 하지 않는다. + # 유효 출발시각: + # - 지정했으면 그 시각 + # - 미지정인데 약속 있으면 '지킬 수 있는 가장 늦은 출발시각' 역산 + # - 미지정 + 약속 없음이면 시간축 계산 안 함 start_min = parse_hhmm(req.start_time) recommended_min: Optional[int] = None recommended_feasible: Optional[bool] = None @@ -1041,7 +975,7 @@ async def calculate_route_eta(req: RouteDetailRequest): "estimated": estimated, } - # 유효 출발 시각이 있으면 시간축(도착/출발/종료·위반)을 계산해 붙인다. + # 유효 출발시각 있으면 시간축(도착/출발/종료/위반) 계산해서 붙임. if eff_start is not None: schedule, viol_nodes, finish = build_schedule(full_order, locs, time_matrix, eff_start) stops = [ @@ -1061,7 +995,7 @@ async def calculate_route_eta(req: RouteDetailRequest): result["total_elapsed_min"] = round(finish - eff_start) if finish is not None else None result["stops"] = stops result["appointment_violations"] = [locs[nd].name for nd in viol_nodes] - # 역산으로 출발 시각을 정한 경우, '추천 출발 시각'임을 함께 알린다. + # 역산으로 정한 경우 '추천 출발시각'이라고 같이 알려줌. if recommended_min is not None: result["recommended_start_time"] = format_hhmm(recommended_min) result["recommended_feasible"] = recommended_feasible diff --git a/backend/tests/test_cache.py b/backend/tests/test_cache.py index 49f543b..6767fbf 100644 --- a/backend/tests/test_cache.py +++ b/backend/tests/test_cache.py @@ -1,7 +1,7 @@ -"""최종 경로 leg 캐시(get_final_leg)의 동작 검증. +"""최종 경로 leg 캐시(get_final_leg) 테스트. -실 API를 흉내 내는 가짜 함수를 주입해, 캐시 히트/미스·LRU 상한·TTL 만료· -폴백(path 없음) 미캐시를 외부 호출 없이 검증한다. +실 API를 가짜 함수로 갈아끼워서 캐시 히트/미스, LRU 상한, TTL 만료, +폴백(path 없음) 미캐시를 외부 호출 없이 확인. """ import asyncio import os diff --git a/backend/tests/test_helpers.py b/backend/tests/test_helpers.py index e5d4a32..bd541b0 100644 --- a/backend/tests/test_helpers.py +++ b/backend/tests/test_helpers.py @@ -1,8 +1,7 @@ """ -main.py의 순수 헬퍼 함수(clamp_priority, importance_score, haversine_distance_m)에 대한 -단위 테스트. 외부 API(Gemini/Kakao) 호출이 없는 로직만 다루므로 별도 키 설정 없이 실행 가능하다. +순수 헬퍼(clamp_priority, importance_score, haversine_distance_m) 단위 테스트. +외부 API 안 타는 로직이라 키 없이도 돌아감. -실행 방법: cd backend python -m unittest discover -s tests -v """ diff --git a/backend/tests/test_optimization.py b/backend/tests/test_optimization.py index cf9fef1..2115300 100644 --- a/backend/tests/test_optimization.py +++ b/backend/tests/test_optimization.py @@ -1,10 +1,7 @@ -"""경로 최적화 solver(solve_shortest / solve_priority / solve_ai)와 스케줄 계산 -(build_schedule / recommended_departure)에 대한 단위 테스트. +"""최적화 solver(solve_shortest/priority/ai)랑 스케줄 계산(build_schedule/recommended_departure) 테스트. -독립적인 완전탐색(브루트포스) 결과와 대조해 solver가 실제로 최적해를 내는지 검증한다. -외부 API 호출 없이 순수 계산만 다루므로 키 없이 실행 가능하다. +완전탐색 결과랑 대조해서 solver가 진짜 최적해를 내는지 확인. 순수 계산이라 키 없이 돌아감. -실행: cd backend python -m unittest discover -s tests -v """ @@ -30,7 +27,7 @@ def loc(name, priority=3, appt=None, dwell=0): - # 좌표는 solver 계산에 쓰이지 않으므로(행렬을 직접 주입) 더미값. + # 행렬을 직접 주입하니까 좌표는 안 쓰임. 더미값. return LocationItem(name=name, task="t", priority=priority, lat=37.5, lng=127.0, appointment_time=appt, duration_min=dwell) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 590827b..e5d01bc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -// 배포 시 VITE_API_BASE_URL로 주입한다(예: nginx가 같은 도메인에서 /api를 프록시하면 '' = 상대경로). -// 값이 없으면 로컬 개발용 백엔드로 폴백한다. +// 배포 땐 VITE_API_BASE_URL로 주입(nginx가 같은 도메인에서 /api 프록시하면 ''로). 없으면 로컬 백엔드. const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:8000'; const emptyRouteResult = { @@ -11,8 +10,7 @@ const emptyRouteResult = { legs: [], }; -// "HH:MM"(24시간제) -> 오전/오후·시(1~12)·분 드롭다운 상태로 분해. -// 값이 없으면 셋 다 빈 문자열(미선택). +// "HH:MM" -> 오전/오후·시(1~12)·분 드롭다운 상태로 분해. 값 없으면 셋 다 빈 문자열. const apptPartsFromHHMM = (hhmm) => { if (!hhmm || typeof hhmm !== 'string' || !hhmm.includes(':')) { return { appt_meridiem: '', appt_hour: '', appt_minute: '' }; @@ -22,7 +20,7 @@ const apptPartsFromHHMM = (hhmm) => { return { appt_meridiem: '', appt_hour: '', appt_minute: '' }; } const meridiem = h < 12 ? '오전' : '오후'; - const hour12 = h % 12 === 0 ? 12 : h % 12; // 0시->12(오전 12시), 12시->12(오후 12시=정오) + const hour12 = h % 12 === 0 ? 12 : h % 12; // 0시->12, 12시->12(정오) return { appt_meridiem: meridiem, appt_hour: String(hour12), appt_minute: String(m) }; }; @@ -35,10 +33,9 @@ const toLocation = (place, task = '방문', priority = 3) => ({ address: place.road_address_name || place.address_name || '', duration_min: 0, appointment_time: null, - // 약속 시각을 LLM이 산문에서 자동 추출했는지 표시(뱃지용). 사용자가 직접 수정하면 false로 내린다. + // LLM이 자동 추출한 약속시각인지(뱃지용). 사용자가 직접 고치면 false로 내림. appointment_from_ai: false, - // 약속 시각 입력용 오전/오후·시·분 드롭다운 상태(출발 예정 시각과 동일한 방식). - // appointment_time("HH:MM")은 이 셋에서 파생한다. + // 약속시각 입력용 드롭다운 상태. appointment_time("HH:MM")은 이 셋에서 만들어짐. appt_meridiem: '', appt_hour: '', appt_minute: '', @@ -185,7 +182,7 @@ function App() { address: '', }); - // "일정 자체"를 보관한다. 현재 선택된 경로 순서와 분리한다. + // '일정 자체'. 계산된 경로 순서와는 따로 관리. const [locations, setLocations] = useState([]); const [inputText, setInputText] = useState(''); @@ -204,18 +201,17 @@ function App() { const [activeRoute, setActiveRoute] = useState('ai'); const [started, setStarted] = useState(false); - // 예상 출발 시각("HH:MM", 24시간제). 비워두면 시간 제약/도착시각 계산을 하지 않는다. + // 출발 예정 시각("HH:MM"). 비우면 시간 제약/도착시각 계산 안 함. const [startTime, setStartTime] = useState(''); - // 출발 시각을 오전/오후 · 시(1~12) · 분(0~59) 세 드롭다운으로 입력받는다. - // 셋 중 하나라도 미선택('')이면 startTime을 비워 시간 제약을 끈다. + // 오전오후 · 시 · 분 드롭다운. 하나라도 비면 startTime을 비워서 시간 제약 끔. const [startMeridiem, setStartMeridiem] = useState(''); // '오전' | '오후' const [startHour, setStartHour] = useState(''); // '1'~'12' const [startMinute, setStartMinute] = useState(''); // '0'~'59' - // 체크 시 출발 시각을 현재 PC 시각으로 계산한다. 기본은 미체크(현재시각을 몰래 강제하지 않음). - // 미체크 + 출발시각 미선택이면 시간 계산 자체를 하지 않는다(도착시각/지각 표시 없음). + // 체크하면 현재 PC 시각으로. 기본은 미체크(현재시각 몰래 강제 안 함). + // 미체크 + 출발시각 미선택이면 시간 계산 자체를 안 함. const [useCurrentTime, setUseCurrentTime] = useState(false); - // 세 결과는 서로 덮어쓰지 않는다. + // 세 모드 결과를 따로 보관(서로 안 덮어씀). const [routeResults, setRouteResults] = useState({ ai: null, shortest: null, @@ -229,7 +225,7 @@ function App() { const polylineInstance = useRef(null); const markersRef = useRef([]); - // 일정이 바뀌면 이전 경로 캐시를 무효화한다. + // 일정 바뀌면 이전 경로 캐시를 무효화하려고 만든 키. const scheduleKey = useMemo(() => { const startKey = [ startLocation?.name || '', @@ -252,8 +248,7 @@ function App() { return `${startKey}###${locationsKey}`; }, [startLocation, locations]); - // 두 경로가 "완전히 같은 방문 순서"인지 비교하기 위한 키. - // 이름/좌표가 순서대로 전부 같으면 같은 경로로 취급한다. + // 두 경로가 방문 순서까지 똑같은지 비교하는 키(이름+좌표 순서대로). const routeSequenceKey = (locs) => (locs || []) .map((loc) => @@ -418,7 +413,7 @@ function App() { }; // ------------------------------- - // Gemini 장소 추출 + // 장소 추출 // ------------------------------- const handleParseText = async () => { if (!inputText.trim()) { @@ -449,14 +444,12 @@ function App() { const parsedList = (Array.isArray(data.data) ? data.data : []).map((item) => ({ ...item, - // 체류 시간(분): 사용자가 확인 단계에서 조정. 기본 30분. + // 체류시간 기본 30분(확인 단계에서 조정). duration_min: Number(item.duration_min) > 0 ? Number(item.duration_min) : 30, - // 약속 시각: LLM이 추출했으면 그 값, 없으면 사용자가 확인 단계에서 입력. + // 약속시각은 LLM이 뽑았으면 그 값, 없으면 사용자가 나중에 입력. appointment_time: item.appointment_time || null, - // 값이 LLM에서 왔으면 뱃지로 알려준다(사용자 수정 시 updateAppointmentPart에서 내림). - appointment_from_ai: Boolean(item.appointment_time), - // 드롭다운 상태도 LLM 값에서 분해해 채운다. - ...apptPartsFromHHMM(item.appointment_time), + appointment_from_ai: Boolean(item.appointment_time), // LLM에서 온 값이면 뱃지 표시 + ...apptPartsFromHHMM(item.appointment_time), // 드롭다운도 그 값에서 채움 })); if (!parsedList.length) { @@ -541,9 +534,8 @@ function App() { old.task || '방문', Number(old.priority) || 3 ), - // 후보 교체는 좌표/주소만 바꾸는 것이므로, 사용자가 입력한 - // 체류 시간과 약속 시각은 그대로 유지한다. (toLocation이 이 둘을 - // 기본값 0/null로 덮어쓰기 때문에 여기서 old 값으로 되살린다) + // 후보 교체는 좌표/주소만 바꾸는 거라 체류시간/약속시각은 유지. + // (toLocation이 0/null로 덮어써서 여기서 old 값으로 되살림) duration_min: old.duration_min ?? 0, appointment_time: old.appointment_time ?? null, appointment_from_ai: old.appointment_from_ai ?? false, @@ -591,8 +583,7 @@ function App() { } const legs = Array.isArray(data.legs) ? data.legs : []; - // 각 leg의 실제 경로 좌표열(자동차=도로, 도보=인도, 대중교통=정류장)을 순서대로 이어붙인다 - // ([[lat,lng],...]). 실 API 실패로 path가 비면 빈 배열이 되어 지도에서 직선 폴백된다. + // 각 leg의 실제 경로 좌표를 순서대로 이어붙임. path가 비면 지도에서 직선 폴백됨. const routePath = legs.flatMap((l) => (Array.isArray(l.path) ? l.path : [])); return { @@ -603,15 +594,15 @@ function App() { routePath, estimated: Boolean(data.estimated), travelMode: mode, - // 출발 시각을 입력했을 때만 채워지는 시간축 정보. + // 출발시각을 넣었을 때만 채워지는 시간축 정보. startTimeUsed: data.start_time || null, finishTime: data.finish_time || null, totalElapsedMin: data.total_elapsed_min ?? null, - // 출발시각 미입력(③)인데 약속이 있어 백엔드가 역산한 '추천 출발시각'. + // 출발시각 안 넣고 약속만 있을 때 백엔드가 역산해준 추천 출발시각. recommendedStartTime: data.recommended_start_time || null, recommendedFeasible: data.recommended_feasible ?? null, stops: Array.isArray(data.stops) ? data.stops : null, - appointmentViolations: Array.isArray(data.appointment_violations) + appointmentViolations: Array.isArray(data.appointment_violations) // 지각한 장소 이름들 ? data.appointment_violations : [], }; @@ -627,15 +618,15 @@ function App() { // ------------------------------- // 경로 계산 공통 함수 // ------------------------------- - // travelModeArg: setTravelMode가 비동기라, 이동수단을 막 바꾼 직후 호출할 때 - // 최신 이동수단을 명시적으로 넘기기 위한 인자(생략하면 현재 상태값 사용). + // travelModeArg: setTravelMode가 비동기라 이동수단 막 바꾼 직후엔 상태가 옛 값이라, + // 최신 값을 직접 넘기려고 둔 인자(생략하면 현재 상태 사용). const calculateRoute = async (mode, { force = false, travelModeArg = travelMode } = {}) => { if (locations.length < 1) { alert('방문할 장소가 1개 이상 필요합니다.'); return; } - // 같은 일정 + 같은 이동수단에 대한 결과가 있으면 캐시 사용 + // 같은 일정 + 같은 이동수단 결과가 있으면 그냥 씀 const cached = routeResults[mode]; if (!force && cached) { @@ -686,10 +677,8 @@ function App() { throw new Error('경로 결과가 올바르지 않습니다.'); } - // 다른 모드가 이미 완전히 동일한 방문 순서를 계산해뒀다면, - // 카카오 실시간 API를 또 호출하지 않고 그 결과를 그대로 재사용한다. - // (같은 순서인데 API를 두 번 따로 부르면 실시간 교통상황 반영 때문에 - // 거리/시간 숫자가 미세하게 달라지는 문제를 방지) + // 다른 모드가 이미 똑같은 순서를 계산해놨으면 실 API 또 안 부르고 그 결과 재사용. + // (같은 순서인데 두 번 부르면 실시간 교통 때문에 거리/시간이 미묘하게 달라짐) const newSequenceKey = routeSequenceKey(orderedList); const reusableEntry = Object.entries(routeResults).find( ([otherMode, otherResult]) => @@ -732,9 +721,8 @@ function App() { calculateRoute('priority', { force: true }); }; - // 이동수단이 바뀌면 최적 방문 순서 자체가 달라질 수 있으므로(예: 도보는 직선거리 기반), - // 기존 순서에 ETA만 다시 구하지 않고, 캐시를 비운 뒤 현재 선택된 모드를 새 이동수단으로 - // "재최적화"한다. 나머지 모드는 캐시를 비워 다음에 선택할 때 새로 계산되게 한다. + // 이동수단 바뀌면 최적 순서 자체가 달라질 수 있어서(속도/보정계수가 다름), ETA만 다시 구하는 게 + // 아니라 캐시 비우고 현재 모드를 새 이동수단으로 재최적화. 나머지 모드는 캐시만 비움. const handleTravelModeChange = async (newMode) => { if (newMode === travelMode) return; @@ -743,12 +731,10 @@ function App() { setTravelMode(newMode); setRouteResults({ ai: null, shortest: null, priority: null }); - // 캐시를 비웠으므로 우선순위 경로도 재계산 필요 상태로 둔다(선택 시 새로 계산되도록). - // 아래에서 활성 모드가 priority면 calculateRoute가 다시 false로 되돌린다. + // 캐시 비웠으니 우선순위 경로도 재계산 필요 상태로. (활성 모드가 priority면 아래서 다시 false로 됨) setIsPriorityDirty(true); - // 이미 경로를 계산해 보여주고 있었을 때만 즉시 재최적화한다. - // (setTravelMode가 비동기이므로 최신 이동수단을 travelModeArg로 명시해서 넘긴다.) + // 이미 경로 보여주고 있을 때만 바로 재최적화. (비동기라 새 이동수단을 직접 넘김) if (hadRoute) { await calculateRoute(modeToRecalc, { force: true, travelModeArg: newMode }); } @@ -773,8 +759,7 @@ function App() { return updated; }); - // 사용자 우선순위 경로만 무효화. - // AI/최단 결과는 그대로 보존한다. + // 우선순위 경로만 무효화(AI/최단은 그대로 둠). setRouteResults((prev) => ({ ...prev, priority: null, @@ -784,32 +769,28 @@ function App() { setActiveRoute('priority'); }; - // 체류 시간(분)과 약속 시각은 모든 모드의 스케줄에 영향을 주므로, - // 값이 바뀌면 캐시된 세 경로 결과를 전부 무효화한다. + // 체류시간/약속시각은 모든 모드 스케줄에 영향이라, 바뀌면 세 결과 전부 무효화. const invalidateAllRoutes = () => { setRouteResults({ ai: null, shortest: null, priority: null }); setIsPriorityDirty(false); }; - // 오전/오후 · 시(1~12) · 분을 24시간제 "HH:MM"으로 합친다. - // 하나라도 미선택이면 '' (시간 제약 없음). + // 오전오후 · 시 · 분을 "HH:MM"으로 합침. 하나라도 비면 ''(시간 제약 없음). const composeStartTime = (meridiem, hour12, minute) => { if (!meridiem || hour12 === '' || minute === '') return ''; - let hour = Number(hour12) % 12; // 12시 -> 0 - if (meridiem === '오후') hour += 12; // 오후 -> +12 (오후 12시는 정오 12시) + let hour = Number(hour12) % 12; // 12 -> 0 + if (meridiem === '오후') hour += 12; // 오후는 +12 (오후 12시=정오) return `${String(hour).padStart(2, '0')}:${String(Number(minute)).padStart(2, '0')}`; }; - // 현재 PC 시각을 24시간제 "HH:MM"으로 반환. + // 현재 PC 시각을 "HH:MM"으로. const currentHHMM = () => { const d = new Date(); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; - // 백엔드에 보낼 출발 시각을 결정한다. - // ① '현재 시간 기준' 체크 -> 현재 PC 시각 - // ② 미체크 + 출발시각 드롭다운 선택 -> 그 시각 - // ③ 미체크 + 미선택 -> null (도착시각/지각 계산 안 함. 단 약속시각 순서는 백엔드가 지켜줌) + // 백엔드에 보낼 출발시각 결정. + // 현재시각 체크 -> PC 시각 / 드롭다운 선택 -> 그 시각 / 둘 다 아니면 null(약속 순서는 백엔드가 지켜줌). const effectiveStartTime = () => { if (useCurrentTime) return currentHHMM(); return startTime || null; @@ -837,7 +818,7 @@ function App() { invalidateAllRoutes(); }; - // 역산으로 추천된 출발 시각("HH:MM")을 실제 출발시각 드롭다운에 채워 확정한다(③ -> ②). + // 역산 추천 출발시각을 실제 드롭다운에 채워서 확정. const applyRecommendedStartTime = (hhmm) => { const parts = apptPartsFromHHMM(hhmm); // {appt_meridiem, appt_hour, appt_minute} if (!parts.appt_hour) return; @@ -846,7 +827,7 @@ function App() { setStartHour(parts.appt_hour); setStartMinute(parts.appt_minute); setStartTime(hhmm); - invalidateAllRoutes(); // 새 출발시각으로 다시 계산하도록 캐시 비움 + invalidateAllRoutes(); // 새 출발시각으로 다시 계산 }; const updateDuration = (index, value) => { @@ -862,8 +843,7 @@ function App() { invalidateAllRoutes(); }; - // 약속 시각 드롭다운(오전오후/시/분) 한 칸을 바꾼다. 셋이 다 채워졌을 때만 - // appointment_time("HH:MM")이 만들어지고(composeStartTime 재사용), 하나라도 비면 null. + // 약속시각 드롭다운 한 칸 변경. 셋 다 차야 appointment_time이 생기고(composeStartTime 재사용), 하나라도 비면 null. const updateAppointmentPart = (index, part, value) => { setLocations((prev) => { const updated = [...prev]; @@ -879,7 +859,7 @@ function App() { ...loc, ...next, appointment_time: composeStartTime(next.appt_meridiem, next.appt_hour, next.appt_minute) || null, - // 사용자가 직접 손대면 더 이상 'AI 자동입력'이 아니므로 뱃지를 내린다. + // 직접 손대면 더 이상 AI 자동입력 아니니까 뱃지 내림. appointment_from_ai: false, }; return updated; @@ -945,8 +925,7 @@ function App() { bounds.extend(position); }); - // 실제 경로 좌표열(routePath)이 있으면 그걸 따라 그리고(자동차·도보·대중교통 공통), - // 없으면(실 API 미사용/실패·경로 미계산) 지점 간 직선으로 폴백한다. + // routePath(실제 경로 좌표) 있으면 그걸 따라 그리고, 없으면 지점 간 직선으로 폴백. const hasRealPath = Array.isArray(routePath) && routePath.length > 1; const drawPath = hasRealPath ? routePath