"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3). 같은 입력이면 같은 결과 — 셈한 추천을 입력 지문 키로 프로젝트 폴더에 남기고, 같은 입력이 오면 셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 상한 없음). 층 = 서버 계산 캐시(데이터 3층 밖 · 사용자 편집값 아님 — 자동저장 금지와 무관). 자리 `B05_Profile/route_recommend/<키>.json` — initial_snapshot 대상(`B05_Profile/route`) 밖이라 [초기화]에 안 휩쓸리고, 자료 교체(`purge_output_folders`) · 프로젝트 삭제 때 폴더째 지워진다. 키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표 기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때 다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임). """ from __future__ import annotations import asyncio import hashlib import json from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path from typing import Any, AsyncIterator from B05_Profile.B05_Profile_Engine_RouteInitial import ( DEFAULT_WEIGHTS, Terrain, _pool_key, resolve_inputs, resolve_weights, ) from common_util.common_util_json import atomic_write_json ALGO_VERSION = 1 STORE_DIR = "B05_Profile/route_recommend" # 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐 DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow") # 묶음 지문마다 잠금 — 진행 중 같은 묶음(같은 키 포함)은 기다렸다 꺼냄(두 번 셈 막음) _LOCKS: dict[str, asyncio.Lock] = {} def store_dir(project_root: Path) -> Path: return Path(project_root) / STORE_DIR def request_fingerprint( expected: list[tuple[float, float]], terrain: Terrain, streams: list[list[tuple[float, float]]], *, mode: str, weights: dict[str, float] | None, max_grade: float, min_radius_m: float, stream_offset_m: float | None, grade_smooth_m: float | None, target_grade: float | None, with_profile: bool, design_speed: int, ) -> tuple[str, str]: """(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키.""" pool = _pool_key( expected, terrain, streams, max_grade, min_radius_m, *resolve_inputs(max_grade, stream_offset_m, grade_smooth_m, target_grade), ).hex() used = resolve_weights(mode, weights) tail = (mode, sorted(used.items()), bool(with_profile), int(design_speed), ALGO_VERSION) key = hashlib.blake2b((pool + repr(tail)).encode(), digest_size=16).hexdigest() return key, pool def is_default( mode: str, weights: dict[str, float] | None, criteria: dict[str, Any] | None, terrain: Any ) -> bool: """기본값 결과 — 빈 제약 · 50/25/25 · 설계 지형. 표시일 뿐 지우는 데 쓰지 않음.""" empty = not any(value is not None for value in (criteria or {}).values()) plain = mode != "weighted" or resolve_weights(mode, weights) == DEFAULT_WEIGHTS return empty and plain and terrain is None def read_stored(project_root: Path, key: str) -> dict[str, Any] | None: """저장본 한 벌 — 없거나 못 읽거나 판이 다르면 None.""" try: data = json.loads((store_dir(project_root) / f"{key}.json").read_text(encoding="utf-8")) except (OSError, ValueError): return None if not isinstance(data, dict) or data.get("version") != ALGO_VERSION: return None return data def write_stored( project_root: Path, key: str, pool: str, request: dict[str, Any], result: dict[str, Any], default: bool, ) -> bool: """새 키만 적는다 — 이미 있으면 그대로 두고 False(덮어쓰기 없음).""" path = store_dir(project_root) / f"{key}.json" if path.exists(): return False atomic_write_json( path, { "version": ALGO_VERSION, "key": key, "pool": pool, "default": default, "created_at": datetime.now().isoformat(timespec="seconds"), "request": request, "result": result, }, ) return True @asynccontextmanager async def pool_lock(pool: str) -> AsyncIterator[None]: # ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로 async with _LOCKS.setdefault(pool, asyncio.Lock()): yield