feat(763-4): 추천 미리 셈 끊기면 이어 셈 · 화면 진행 n/m · 저장본 없으면 [지금 셈]

- 진행 기록 `B05_Profile/route_recommend_job.json`(저장본 폴더 밖 — 저장본 glob 에 안 섞임) · `note_job` 재사용 · 갈래마다 갱신 · 끝 = running 거짓
- 진행 묻기 = 이 서버 메모리 먼저 · 없으면 기록(10 분 안 = 다른 서버가 셈 · 넘음 = 끊김 `interrupted`)
- 추천 요청 409 + 「끊김」 기록 = 이어 셈 예약 한 번(기록을 곧바로 「도는 중」으로 · 뒤 요청은 안 겹침)
- 미리 셈 한 갈래 409 = 그 갈래만 건너뜀(옛: 전부 멈춤)
- 409 글 = 「추천 저장본 없음 — 지표면이 바뀌어 다시 셈해야 함」(없는 [재계산] 단추 안내 걷음)
- 화면(RecPreview) — 셈 중이면 줄 마우스 글 「추천 셈 중 n/m」 · 알림 한 번 · 3 초마다 다시 물어 끝나면 그림 · 아니면 확인창 [지금 셈](그 갈래만 recompute) · 지형 바뀌면 받아 둔 선 비움
- 시험 309_3 · 364_4 · 509_5 · 626_1 을 새 글 · 진행 꼴 · [지금 셈]에 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 14:01:28 +09:00
co-authored by Claude Opus 5.5
parent cf09d53924
commit e2b7133225
8 changed files with 243 additions and 26 deletions
@@ -81,6 +81,8 @@ export interface PrecomputeProgress {
done: number;
total: number;
mode: RouteInitialLogic | null;
/** 기록은 「도는 중」인데 서버가 다시 켜져 끊김(763-4) — 추천 요청 409 가 이어 셈을 예약. */
interrupted?: boolean;
}
/** 미리 셈 진행 — 못 읽으면 null(진행 표시만 빠짐). */
+79 -13
View File
@@ -26,8 +26,13 @@ logger = logging.getLogger(__name__)
# 미리 셈 진행(364-4) — 프로젝트마다 하나만 돎 · 도는 동안 또 오면 끝난 뒤 한 번 더(입력이 바뀜)
_RUNNING: dict[str, dict[str, Any]] = {}
# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음
NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다 · [재계산]이면 지금 셉니다."
# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음 · 화면은 머리말로 가름
# (`B05_Profile_UI_RouteEdit_RecPreview.ts` NOT_PREPARED_HEAD · 셈 중이면 진행 · 아니면 [지금 셈])
NOT_PREPARED = "추천 저장본 없음 — 지표면이 바뀌어 다시 셈해야 함"
# 미리 셈 진행 기록(763-4) — 재시동 뒤 「끊김」을 알게 · 저장본 폴더 밖(저장본 glob 에 안 섞임)
JOB_FILE = Path("B05_Profile") / "route_recommend_job.json"
# ponytail: 다른 서버(같은 storage)가 도는 중인지 = 기록 나이 · 갈래 하나 최대 ~2 분 · 넘으면 끊김
JOB_STALE_S = 600.0
@replan.router.post("/{project_id}/route/initial", response_model=None)
@@ -172,6 +177,7 @@ async def compute_initial(
)[0],
)
if found is None:
resume_if_interrupted(project_id, project_root) # 끊긴 미리 셈 이어 셈(763-4)
return JSONResponse(
status_code=409, content={"status": "error", "message": NOT_PREPARED}
)
@@ -296,6 +302,9 @@ async def precompute_defaults(project_id: UUID) -> None:
progress = _RUNNING.get(str(project_id))
if progress is not None:
progress.update(total=len(terrains) * len(variants) * len(modes))
if paths is not None: # 진행 기록 자리 — 끝 기록은 `run_precompute`(763-4)
progress["root"] = paths[0]
_note(paths, progress)
for terrain, (criteria, weights), mode in itertools.product(terrains, variants, modes):
if progress is not None:
progress["mode"] = mode
@@ -308,17 +317,17 @@ async def precompute_defaults(project_id: UUID) -> None:
terrain=terrain,
)
outcome = await compute_initial(project_id, request, precompute=True)
if isinstance(outcome, JSONResponse):
if isinstance(outcome, JSONResponse): # 그 갈래만 건너뜀(763-4)
logger.warning(
"추천 미리 셈 멈춤(%s %s %s): project_id=%s",
"추천 미리 셈 갈래 건너뜀(%s %s %s): project_id=%s",
terrain,
mode,
outcome.status_code,
project_id,
)
return
if progress is not None:
progress["done"] += 1
_note(paths, progress)
logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started)
@@ -343,8 +352,23 @@ async def precompute_terrains(project_id: UUID, project_root: Path) -> list[Any]
return [design, *[source for source in sources if source != design]]
def _note(paths: Any, progress: dict[str, Any], running: bool = True) -> None:
"""진행 기록 쓰기(763-4) — 실패해도 셈은 계속."""
from common_util.common_util_lidar_upload import note_job
if paths is not None:
note_job(
Path(paths[0]) / JOB_FILE,
{
"running": running,
**{k: progress.get(k) for k in ("done", "total", "mode")},
"updated": time.time(),
},
)
async def run_precompute(project_id: UUID) -> None:
"""미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음)."""
"""미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음) · 기록(763-4)."""
key = str(project_id)
if key in _RUNNING:
_RUNNING[key]["again"] = True
@@ -356,20 +380,62 @@ async def run_precompute(project_id: UUID) -> None:
if not _RUNNING[key]["again"]:
break
finally:
_RUNNING.pop(key, None)
progress = _RUNNING.pop(key, {})
if progress.get("root"):
_note((progress["root"],), progress, running=False)
def precompute_progress(project_id: UUID) -> dict[str, Any]:
"""모달 진행 표시 — 돌고 있나 · 끝낸 수 / 셀 수 · 지금 갈래."""
def precompute_progress(project_id: UUID, project_root: Path | None = None) -> dict[str, Any]:
"""진행 표시 — 돌고 있나 · 끝낸 수 / 셀 수 · 지금 갈래 · 끊겼나(763-4).
이 서버 메모리가 먼저 · 없으면 기록: 「도는 중」이 `JOB_STALE_S` 안 = 다른 서버가 셈 ·
넘음 = 끊김."""
progress = _RUNNING.get(str(project_id))
if progress is None:
return {"running": False, "done": 0, "total": 0, "mode": None}
return {"running": True, **{k: progress[k] for k in ("done", "total", "mode")}}
if progress is not None:
return {
"running": True,
"interrupted": False,
**{k: progress[k] for k in ("done", "total", "mode")},
}
idle = {"running": False, "interrupted": False, "done": 0, "total": 0, "mode": None}
if project_root is None:
return idle
try:
saved = json.loads((Path(project_root) / JOB_FILE).read_text(encoding="utf-8"))
except (OSError, ValueError):
return idle
if not isinstance(saved, dict) or not saved.get("running"):
return idle
fresh = time.time() - float(saved.get("updated") or 0) < JOB_STALE_S
return {
**idle,
**{k: saved.get(k) for k in ("done", "total", "mode")},
"running": fresh,
"interrupted": not fresh,
}
def resume_if_interrupted(project_id: UUID, project_root: Path) -> bool:
"""추천 요청이 409 인데 기록이 「끊김」 — 이 서버가 이어 셈 예약 한 번(돌면 안 겹침 · 763-4).
이어 셈 = 미리 셈 다시(있는 갈래는 저장본 꺼내기만 · 0.2 s 안팎)."""
state = precompute_progress(project_id, project_root)
if not state["interrupted"]:
return False
_note((project_root,), state) # 곧바로 「도는 중」 — 뒤 요청이 또 예약하지 않게
schedule_precompute(project_id)
logger.info("추천 미리 셈 이어 셈 예약(끊김 기록): project_id=%s", project_id)
return True
@replan.router.get("/{project_id}/route/initial/progress", response_model=None)
async def initial_progress(project_id: UUID) -> dict[str, Any]:
return {"status": "success", **precompute_progress(project_id)}
try:
paths = await replan._project_paths(project_id)
except LookupError:
paths = None
root = paths[0] if paths is not None else None
return {"status": "success", **precompute_progress(project_id, root)}
def schedule_precompute(project_id: UUID) -> None:
@@ -4,20 +4,32 @@
* (영선 · 토공 최소 · 기울기 최소 · 비중 계산) · 여럿 고름(589-12 · 줄마다 색 · 하나도 안 고르면 꺼짐) ·
* 선만(위 캔버스 · 노선 아래 그림 · 집기 없음).
* 선 = 노선 추천 저장본(`B05_Profile/route_recommend/` · 지금 설계 지형 · 체인이 미리 셈) 그대로 — 다시 셈 · 쓰기 없음 ·
* 저장본 없으면(409) 알림 · 끔. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김).
* 저장본 없으면(409) — 미리 셈 도는 중 = 진행 n/m(줄 마우스 글 · 알림 한 번) · 끝나면 다시 그림 ·
* 아니면 확인창 「지표면이 바뀌어 다시 셈해야 함」 [지금 셈](그 갈래만 `recompute` · 763-4) ·
* 지형이 바뀌면 받아 둔 선을 비움. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김).
* 부르는 곳 = `_Chrome`(도구줄 `recommend` 칸) · `_RouteEdit`(그리기 `overStations` 앞).
* 메뉴 · 선 그리기 = 공용 `A00_Common/map/map_route_preview`(636-2 · 예정노선 가이드 선과 같이 씀).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { askConfirm } from "@ui/ui_template_modal";
import { themeColor } from "@ui/ui_template_palette";
import { createRoutePreviewMenu, paintRoutePreview } from "../A00_Common/map/map_route_preview";
import type { AnalysisTerrain } from "./B05_Profile_Api_Replan";
import { fetchRouteInitial, type RouteInitialLogic } from "./B05_Profile_Api_RouteInitial";
import {
fetchPrecomputeProgress,
fetchRouteInitial,
type RouteInitialLogic,
} from "./B05_Profile_Api_RouteInitial";
import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model";
type Vertex = [number, number];
/** 서버 `NOT_PREPARED` 머리말(`B05_Profile_Router_RouteInitial.py`) — 저장본 없음 가름(763-4). */
const NOT_PREPARED_HEAD = "추천 저장본 없음";
/** 미리 셈 진행 묻기 간격(ms). */
const POLL_MS = 3000;
/** 드롭다운 넷(요구 530 · 「예상노선 추적」 = 초기값이라 뺌). */
const MODES = [
"gradeline",
@@ -54,6 +66,8 @@ export function createRecPreview(opts: {
const lines = new Map<string, Vertex[]>();
const shown = new Map<RouteInitialLogic, Vertex[]>();
const turns = new Map<RouteInitialLogic, number>();
const waiting = new Set<RouteInitialLogic>();
let seenTerrain = opts.terrain();
const menu = createRoutePreviewMenu({
items: MODES.map((mode, index) => ({
value: mode,
@@ -63,32 +77,67 @@ export function createRecPreview(opts: {
onPick: (mode) => void pick(mode as RouteInitialLogic),
});
async function pick(mode: RouteInitialLogic): Promise<void> {
/** 미리 셈이 도는 중이면 진행 n/m 을 보이고 끝난 뒤 다시 고름 — 돌면 true(763-4). */
async function waitForPrecompute(mode: RouteInitialLogic, mine: number): Promise<boolean> {
const progress = await fetchPrecomputeProgress(opts.projectId);
if (!progress?.running || mine !== turns.get(mode)) return false;
const note = `추천 셈 중 ${progress.done}/${progress.total}`;
menu.setItem(mode, { title: note });
if (!waiting.has(mode)) showToast(`${note} — 끝나면 그림`, "info");
waiting.add(mode);
window.setTimeout(() => {
if (mine === turns.get(mode) && menu.values().includes(mode)) void pick(mode);
}, POLL_MS);
return true;
}
async function pick(mode: RouteInitialLogic, recompute = false): Promise<void> {
const mine = (turns.get(mode) ?? 0) + 1;
turns.set(mode, mine);
shown.delete(mode);
opts.redraw();
if (!menu.values().includes(mode)) return;
const key = `${mode}|${opts.terrain()}`;
const terrain = opts.terrain();
if (terrain !== seenTerrain) lines.clear(); // 지형 바뀜 = 옛 선 버림(763-4)
seenTerrain = terrain;
const key = `${mode}|${terrain}`;
try {
if (!lines.has(key)) {
if (!lines.has(key) || recompute) {
const body = (await fetchRouteInitial(opts.projectId, {
mode,
with_profile: true,
terrain: opts.terrain(),
terrain,
...(recompute ? { recompute: true } : {}),
})) as { planned?: number[][] };
const planned = (body.planned ?? []).map(([x, y]) => [x, y] as Vertex);
if (planned.length < 2) throw new Error("추천 선이 비었음");
lines.set(key, planned);
}
if (mine !== turns.get(mode)) return;
waiting.delete(mode);
menu.setItem(mode, { title: "" });
shown.set(mode, lines.get(key)!);
opts.redraw();
} catch (error) {
if (mine !== turns.get(mode)) return;
menu.setItem(mode, { checked: false });
const text = error instanceof Error ? error.message : String(error);
showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 못 봄 — ${text}`, "info");
if (!recompute && (await waitForPrecompute(mode, mine))) return;
waiting.delete(mode);
menu.setItem(mode, { checked: false, title: "" });
const now =
text.startsWith(NOT_PREPARED_HEAD) &&
(await askConfirm({
title: "추천 노선",
message: `「${LOGIC_LABEL[mode]}」 ${text}. 지금 셀까요? 몇 분 걸릴 수 있습니다.`,
okLabel: "지금 셈",
}));
if (now) {
menu.setItem(mode, { checked: true });
showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 셈 중 — 끝나면 그림`, "info");
return void pick(mode, true);
}
if (!text.startsWith(NOT_PREPARED_HEAD))
showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 못 봄 — ${text}`, "info");
}
}
@@ -75,7 +75,7 @@ def test_화면_기본_갈래_저장본_없으면_409_셈_0(wired) -> None: # n
for mode in DEFAULT_MODES:
response = _ask(False, mode=mode)
assert response.status_code == 409
assert "전처리를 다시 하면 생깁니다" in json.loads(response.body)["message"]
assert json.loads(response.body)["message"] == router.NOT_PREPARED
assert wired.count == 0 and not list(store_dir(wired.root).glob("*.json"))
@@ -40,9 +40,16 @@ def test_겹쳐_셈_막음_끝난_뒤_한_번_더(monkeypatch) -> None:
asyncio.run(main())
assert calls == [str(pid), str(pid)]
assert seen[0] == {"running": True, "done": 2, "total": 5, "mode": "earthwork"}
assert seen[0] == {
"running": True,
"interrupted": False, # 끊김 기록(763-4)
"done": 2,
"total": 5,
"mode": "earthwork",
}
assert router.precompute_progress(pid) == {
"running": False,
"interrupted": False,
"done": 0,
"total": 0,
"mode": None,
@@ -63,7 +63,7 @@ def test_미리_셈_없는_지형은_안내_재계산은_셈(two) -> None:
missing = _ask(False, mode="gentle", terrain="sheet")
assert missing.status_code == 409
message = json.loads(missing.body)["message"]
assert "전처리를 다시 하면" in message and "[재계산]" in message
assert message == router.NOT_PREPARED # 화면 = 진행 또는 [지금 셈](763-4)
assert two.count == 0
assert _ask(False, mode="gentle", terrain="sheet", recompute=True)["stored"] is False
assert two.count == 1
@@ -42,10 +42,11 @@ def test_미리보기_넷_하나만_저장본만() -> None:
src = read(B05 / "B05_Profile_UI_RouteEdit_RecPreview.ts")
modes = re.search(r"const MODES = \[(.*?)\]", src, re.S).group(1)
assert re.findall(r'"(\w+)"', modes) == ["gradeline", "earthwork", "gentle", "weighted"]
assert "recompute" not in src and "createRoutePreviewMenu({" in src
# 저장본만 · 다시 셈은 저장본 없을 때 확인창 [지금 셈]만(763-4)
assert src.count("recompute: true") == 1 and "createRoutePreviewMenu({" in src
shared = read(ROOT / "A00_Common" / "map" / "map_route_preview.ts") # 636-2 공용 메뉴 · 그리기
assert "createPickMenu({" in shared and "multi: true" in shared and "offLabel" not in shared
assert "terrain: opts.terrain()" in src # 지금 설계 지형 저장본(D7)
assert "const terrain = opts.terrain();" in src # 지금 설계 지형 저장본(D7)
chrome = read(B05 / "B05_Profile_UI_RouteEdit_Chrome.ts")
assert "recommend: preview.root" in chrome and "terrain: terrain.root" in chrome
menu = read(ROOT / "ui_template" / "ui_template_context_menu.ts")
@@ -0,0 +1,92 @@
"""763-4 추천 미리 셈이 끊기면 이어 셈 — 진행 기록 · 한 갈래 409 는 그 갈래만 건너뜀 · 화면 글 짝.
./venv/Scripts/python.exe -m pytest resources/tester/test_763_4_precompute_resume.py -q
"""
import asyncio
import json
import time
from pathlib import Path
from uuid import uuid4
from fastapi.responses import JSONResponse
from test_224_3_recommend_store import _ask, wired # noqa: F401 — 픽스처
from B05_Profile import B05_Profile_Router_RouteInitial as router
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES
ROOT = Path(__file__).resolve().parents[2]
def _record(root: Path, running: bool, age_s: float) -> None:
path = root / router.JOB_FILE
path.parent.mkdir(parents=True, exist_ok=True)
body = {"running": running, "done": 3, "total": 10, "mode": "gentle"}
path.write_text(json.dumps({**body, "updated": time.time() - age_s}), encoding="utf-8")
def test_진행_기록_읽기(tmp_path) -> None:
pid = uuid4()
assert router.precompute_progress(pid, tmp_path)["running"] is False
_record(tmp_path, True, 5) # 다른 서버가 도는 중
state = router.precompute_progress(pid, tmp_path)
assert state["running"] and not state["interrupted"] and state["done"] == 3
_record(tmp_path, True, router.JOB_STALE_S + 1) # 재시동으로 끊김
state = router.precompute_progress(pid, tmp_path)
assert not state["running"] and state["interrupted"]
_record(tmp_path, False, 5)
assert router.precompute_progress(pid, tmp_path)["interrupted"] is False
def test_끊김_기록_409_이어_셈_한_번(wired, monkeypatch) -> None: # noqa: F811
scheduled: list = []
monkeypatch.setattr(router, "schedule_precompute", scheduled.append)
_record(wired.root, True, router.JOB_STALE_S + 1)
for _ in range(3): # 화면이 여러 번 물어도 예약 한 번(기록이 곧바로 「도는 중」)
answer = _ask(False, mode="gentle")
assert answer.status_code == 409
assert json.loads(answer.body)["message"] == router.NOT_PREPARED
assert len(scheduled) == 1
assert router.precompute_progress(uuid4(), wired.root)["running"] is True
def test_끊김_아니면_예약_없음(wired, monkeypatch) -> None: # noqa: F811
scheduled: list = []
monkeypatch.setattr(router, "schedule_precompute", scheduled.append)
_ask(False, mode="gentle") # 기록 없음
_record(wired.root, True, 5) # 다른 서버가 셈 중 — 안 겹침
_ask(False, mode="gentle")
assert scheduled == []
def test_미리_셈_기록_남기고_끝(wired) -> None: # noqa: F811
asyncio.run(router.run_precompute(uuid4()))
saved = json.loads((wired.root / router.JOB_FILE).read_text(encoding="utf-8"))
assert saved["running"] is False and saved["done"] == saved["total"] == len(DEFAULT_MODES)
def test_한_갈래_409_는_그_갈래만_건너뜀(wired, monkeypatch) -> None: # noqa: F811
real = router.compute_initial
asked: list[str] = []
async def flaky(project_id, request, precompute=False):
asked.append(request.mode)
if request.mode == "gentle":
return JSONResponse(status_code=409, content={"message": "x"})
return await real(project_id, request, precompute)
monkeypatch.setattr(router, "compute_initial", flaky)
asyncio.run(router.precompute_defaults(uuid4()))
assert sorted(asked) == sorted(DEFAULT_MODES)
assert wired.count == len(DEFAULT_MODES) - 1
def test_화면_머리말_서버_글과_같음() -> None:
preview = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts").read_text(
encoding="utf-8"
)
head = preview.split('const NOT_PREPARED_HEAD = "', 1)[1].split('"', 1)[0]
assert router.NOT_PREPARED.startswith(head)
assert "fetchPrecomputeProgress(" in preview and "recompute: true" in preview
assert 'okLabel: "지금 셈"' in preview and "lines.clear()" in preview
assert "<button" not in preview # 부품 상자만(확인창 = ui_template_modal)