feat(511-B B-5 follow 줄 엶): 계산 자리 follow(P5 P6 F1 F2) 바꿀 수 있음 · 기본 both 그대로 · 「서버」 = 끌기 도구 꼴(끌기 중 화면 셈 · 잠잠 뒤 창구 /calc stage.follow until follow → 서버 Node route · follow 두 단계 · 답 오면 묶음을 노선부터 다시 돌려 서버 값으로 · 곁 자료 plusAt · followedAt 같이 돌려받아 다시 붙임 · 열쇠 = 노선 초안 + 저장 계획고) · 저장 정본 맥락(노선 계획 · 종류 · 정의 · 관 · 구조물 · 추가 측점 · 측점 간격 · 저장 종단) = 창구가 프로젝트 id 로 모음(B06_Section_Router_Calc_Context · 브라우저 정본 칸 버림 · 정본 쓰기 없음) · 묶음 refresh(입력 그대로 다시 돎) · 계획고 옮김 = 끌기 중 화면이 옮긴 입력 그대로(서버 follow 는 옮김 기준 없음) · 검증_sub3 끌기 네 걸음 뒤 서버 = 화면 0 차(관 · 입력 · 첫 계획선 · 종단 PVI 61 · 왕복 231 ms · Node 5 ms) · Node 두 단계 = 묶음 첫 바퀴 0 차(golden) · 까닭 글 follow · pair_B5 걷음 — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 15:56:39 +09:00
co-authored by Claude Opus 5.5
parent 29e1e4be6d
commit de6975363d
13 changed files with 488 additions and 53 deletions
@@ -0,0 +1,93 @@
"""계산 창구 `/calc` 의 서버 맥락(PLAN 511-B B-5) — follow 「서버」(`stage.follow` · until
follow)에 붙이는 저장 정본을 프로젝트 id 로 서버가 모음. 브라우저가 보낸 정본 칸은 버림(경로
막기와 같은 원칙).
plan · types · definition · saved_pipes · saved_structures · saved_plus · interval_m ·
longitudinal
= 화면이 받는 값과 같은 길(GET 노선 계획 · 구조물 종류 · 정의 · 관 지점 · 구조물 · 추가 측점 ·
측점 간격 기본 · 최신 노선 저장 종단) — 정본 쓰기 없음(관 지점 = 화면 GET 과 같은 파생 저장본만).
브라우저 몫 = `BROWSER_KEYS`(묶음 입력 · 초안 전 시설 · 관 세션 장부 · 세션 계획고 초안).
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
from uuid import UUID
BROWSER_KEYS = frozenset({"input", "before_draft", "pipe_ids", "saved_edits", "until"})
PLAN_KEYS = ("planned", "curves", "min_radius_m", "station_snap", "station_snap_step")
async def follow_context(project_id: UUID) -> dict[str, Any] | str:
"""follow 두 단계 맥락 — 못 모으면 까닭 글."""
from B04_PreProcess.B04_PreProcess_Router_Basins import get_pipe_points
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B05_Profile.B05_Profile_Engine_Sections_Node import structure_table
from B05_Profile.B05_Profile_Plus_Stations import load_plus_stations
from B05_Profile.B05_Profile_Router_Replan import _project_paths, read_route_plan
from B05_Profile.B05_Profile_Structures_Router import read_structures
paths = await _project_paths(project_id)
if paths is None:
return "프로젝트 저장 경로 없음"
root = paths[0]
plan, pipes, structures, (types, definition) = await asyncio.gather(
read_route_plan(project_id),
get_pipe_points(project_id),
read_structures(project_id),
asyncio.to_thread(structure_table),
)
for name, got in (("노선", plan), ("관", pipes)):
if not isinstance(got, dict):
return f"{name} 정본을 못 읽음"
if not hasattr(structures, "model_dump"):
return "구조물 정본을 못 읽음"
return {
"plan": {key: plan.get(key) for key in PLAN_KEYS},
"types": types,
"definition": definition,
"saved_pipes": [
{k: v for k, v in pipe.items() if k != "lonlat"} for pipe in pipes["pipe_points"]
],
"saved_structures": structures.model_dump(mode="json")["structures"],
"saved_plus": await asyncio.to_thread(load_plus_stations, root),
"interval_m": SectionGenerationOptions().station_interval_m,
"longitudinal": await _saved_longitudinal(project_id, root),
}
async def _saved_longitudinal(project_id: UUID, root: Path) -> dict[str, Any] | None:
"""최신 노선의 저장 종단(화면 상세 `longitudinal`) — 없거나 못 읽으면 None."""
from B05_Profile.B05_Profile_Repository import get_latest_route
from B06_Section.B06_Section_Repository import get_longitudinal_section
from config.config_db import get_db_pool
async with get_db_pool().acquire() as connection:
route = await get_latest_route(connection, project_id)
row = route and await get_longitudinal_section(connection, project_id, int(route["id"]))
rel = (row or {}).get("longitudinal_file_path")
if not rel:
return None
path = (root / str(rel)).resolve()
if not path.is_relative_to(root.resolve()) or not path.is_file():
return None
data = json.loads(await asyncio.to_thread(path.read_text, encoding="utf-8"))
return data if isinstance(data, dict) else None
async def attach_follow_context(project_id: UUID, inputs: dict[str, Any]) -> str | None:
"""`inputs.stages`(until follow) — 브라우저 몫만 남기고 서버 맥락을 붙임 · 못 붙이면 까닭 글."""
stages = inputs.get("stages")
if not isinstance(stages, dict) or stages.get("until") != "follow":
return None
context = await follow_context(project_id)
if isinstance(context, str):
return context
inputs["stages"] = {
**{k: v for k, v in stages.items() if k in BROWSER_KEYS},
**context,
}
return None