Files
Aislo/common_util/common_util_initial_snapshot.py
T
eomsangdonandClaude Opus 5 2e57d29156 fix(B05,B03): 초기 설계 실패 시 초기값 부재 처리 — 실패 마커·안내 메일·[초기화] 거부
증상: 파일 입력 직후 초기 계산값과 B05 [초기화] 결과가 다름.

원인
- 자동설계 체인이 5단계까지 전부 성공해야만 `save_initial_snapshot()` 호출.
  중간에 깨지면 `initial_snapshot/` 미생성 → [초기화]가 복원 대신 재계산 폴백.
- 재계산 폴백의 지표면 기준이 체인과 다름 — 체인은 stage 1 확정값
  (`get_surface_confirmation_params`), [초기화]는 config 기본값
  (`surface_confirmation_defaults`). 실측: 프로젝트 stage1 `classification`/5m 대
  config `csf`/1m.
- 체인이 깨져도 WF1 은 초기 분석 완료 메일을 그대로 발송.

수정 (2026-09-02 사용자 확정 — 부분 결과는 분석 안 됨과 다르지 않으므로 부분
스냅샷은 만들지 않음)
- `initial_design.failed` 마커 신설 — 프로젝트 루트(스냅샷 4트리 밖), 실패 사유 기록.
  체인 진입 시 옛 마커 제거, 실패 5지점 + 스냅샷 저장 실패에서 기록.
- WF1 이 마커를 읽어 완료 메일 대신 `send_initial_design_failed_email()` 발송
  (관리자 주소 `ADMIN_EMAIL`, 없으면 주소 없이 안내).
- B05 [초기화]: 스냅샷 있으면 복원, 실패 마커 있으면 409 `initial_design_failed`
  로 거부(DELETE 앞에서 반환 — 데이터 무변경), 옛 프로젝트만 종전 재계산 폴백.
- 재계산 폴백의 지표면 기준을 `get_surface_confirmation_params()` 로 통일.

자체검증: `pytest tmp/tests/ -q` 366 passed / 14 skipped / 0 failed (+4).
공용 브라우저 실측 — 실패 프로젝트 [초기화] 409 응답·`routes` 행 무변경 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:03:18 +09:00

260 lines
10 KiB
Python

"""초기값 스냅샷 — 자동설계 체인이 만든 첫 결과를 그대로 떠 두고 [초기화]가 되돌린다.
CLAUDE.md 5장(조작·데이터 흐름 정책)의 **초기값** 층이다. 자동설계 체인이 끝난 직후
한 번 찍고, 그 뒤로는 읽기 전용이다 — 어떤 저장 경로도 이 폴더에 쓰지 않는다.
[초기화]가 재계산이 아니라 복원이어야 하는 이유: [저장]·[확정]이 새 행을 만들지 않고
최신 `routes` 행을 제자리 갱신하고 종·횡단 정본 파일도 덮어쓰므로, 초기 상태는 따로
떠 두지 않으면 남지 않는다. 재계산으로 되살리려 해도 `structures.json`·
`pipe_points.json` 편집분이 그대로 남아 초기값과 다른 결과가 나온다(2026-08-29).
"""
import json
import shutil
from pathlib import Path
from typing import Any
import aiomysql
# 스냅샷 폴더는 워크플로우 단계가 아니므로 PROJECT_STORAGE_LAYOUT_V2에 넣지 않는다.
SNAPSHOT_DIRNAME = "initial_snapshot"
_DB_DUMP_NAME = "db.json"
# 초기 설계 체인이 도는 동안만 존재하는 마커(진입 차단 판정용).
DESIGNING_LOCK_NAME = "initial_design.lock"
# 초기 설계 체인이 실패로 끝났음을 남기는 마커.
DESIGN_FAILED_NAME = "initial_design.failed"
# 프로젝트 루트 기준 상대 경로 — 정본 파일이 사는 자리 전부.
_FILE_TREES = (
"B05_Profile/route",
"B06_Section/longitudinal",
"B06_Section/cross_sections",
"B04_PreProcess/drainage/edits",
)
# `routes.id`를 참조하는 표는 스키마상 이 넷이 전부다(001_create_schema.sql:539~556).
_CHILD_TABLES = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections")
def snapshot_dir(project_root: Path) -> Path:
return Path(project_root) / SNAPSHOT_DIRNAME
def designing_lock_path(project_root: Path) -> Path:
"""초기 설계 체인이 도는 동안만 존재하는 마커.
이게 있으면 B05·B06은 아직 들어갈 때가 아니다(CLAUDE.md 5장 — 계산 중 편집이 섞이면
초기값이 오염된다). 스냅샷 대상 4트리 **밖**인 프로젝트 루트에 두어 복원에 딸려
들어가지 않게 한다. `workflow_state`의 stage 2는 체인이 끝나도 IN_PROGRESS라 판정에
못 쓰고, route 존재 여부도 체인 3단계에서 이미 생겨 못 쓴다.
"""
return Path(project_root) / DESIGNING_LOCK_NAME
def is_designing(project_root: Path) -> bool:
return designing_lock_path(project_root).is_file()
def mark_designing(project_root: Path) -> None:
path = designing_lock_path(project_root)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("designing", encoding="utf-8")
except OSError:
pass # 마커 실패가 체인을 막지는 않는다 — 문이 일찍 열릴 뿐이다.
def clear_designing(project_root: Path) -> None:
try:
designing_lock_path(project_root).unlink(missing_ok=True)
except OSError:
pass
def design_failed_path(project_root: Path) -> Path:
"""초기 설계 체인이 실패로 끝났음을 남기는 마커.
"스냅샷이 없다"는 사실만으로는 **실패한 프로젝트**와 스냅샷 기능 이전의 **옛
프로젝트**를 가를 수 없다. 앞은 [초기화]가 재계산으로 얼버무리면 안 되고(부분 결과는
분석 안 됨과 다르지 않다, 2026-09-02 사용자 확정), 뒤는 종전 재계산 폴백이 유일한
수단이다. 락과 같은 자리 — 스냅샷 대상 4트리 **밖**이라 복원에 딸려 들어가지 않는다.
"""
return Path(project_root) / DESIGN_FAILED_NAME
def is_design_failed(project_root: Path) -> bool:
return design_failed_path(project_root).is_file()
def read_design_failure(project_root: Path) -> str:
"""실패 사유를 읽는다. 마커가 없거나 못 읽으면 빈 문자열."""
try:
return design_failed_path(project_root).read_text(encoding="utf-8").strip()
except OSError:
return ""
def mark_design_failed(project_root: Path, reason: str) -> None:
path = design_failed_path(project_root)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(reason, encoding="utf-8")
except OSError:
pass # 마커 실패가 체인을 막지는 않는다 — 안내가 덜 정확해질 뿐이다.
def clear_design_failed(project_root: Path) -> None:
try:
design_failed_path(project_root).unlink(missing_ok=True)
except OSError:
pass
def discard_initial_snapshot(project_root: Path) -> bool:
"""초기값을 무효화한다 — 지표면·노선이 바뀌어 옛 초기값이 더는 기준이 아닐 때.
그 자리에서 다시 뜨지 않는다. 재설계 체인은 **사용자 입력을 유지한 채** 재계산하므로
그 결과를 찍으면 사용자 편집이 섞인 가짜 초기값이 된다. 지워 두면 다음 [초기화]가
재계산 폴백을 타면서 새 초기값을 만들고, 체인이 그때 촬영한다(2026-08-29 사용자 확정).
"""
target = snapshot_dir(Path(project_root))
if not target.exists():
return False
shutil.rmtree(target, ignore_errors=True)
return True
def has_initial_snapshot(project_root: Path) -> bool:
return (snapshot_dir(project_root) / _DB_DUMP_NAME).is_file()
def _copy_tree(source: Path, target: Path) -> None:
if not source.is_dir():
return
if target.exists():
shutil.rmtree(target)
shutil.copytree(source, target)
async def _dump_rows(
connection: aiomysql.Connection, table: str, route_id: int
) -> list[dict[str, Any]]:
async with connection.cursor(aiomysql.DictCursor) as cursor:
# 표 이름은 이 모듈의 상수에서만 오므로 자리표시자 대상이 아니다.
await cursor.execute(f"SELECT * FROM {table} WHERE route_id = %s", (route_id,)) # noqa: S608
return list(await cursor.fetchall())
def _json_safe(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""TIMESTAMP 등 JSON이 모르는 값을 문자열로 낮춘다."""
return [
{key: (value if _is_json_native(value) else str(value)) for key, value in row.items()}
for row in rows
]
def _is_json_native(value: Any) -> bool:
return value is None or isinstance(value, (bool, int, float, str, list, dict))
async def save_initial_snapshot(
connection: aiomysql.Connection, project_root: Path, route_id: int
) -> None:
"""자동설계 체인 성공 직후 한 번 부른다. 이미 있으면 덮어쓰지 않는다."""
root = Path(project_root)
target = snapshot_dir(root)
if has_initial_snapshot(root):
return
target.mkdir(parents=True, exist_ok=True)
for tree in _FILE_TREES:
_copy_tree(root / tree, target / tree.replace("/", "__"))
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute("SELECT * FROM routes WHERE id = %s", (route_id,))
route = await cursor.fetchone()
if not route:
return
dump: dict[str, Any] = {"routes": _json_safe([dict(route)])}
for table in _CHILD_TABLES:
dump[table] = _json_safe(await _dump_rows(connection, table, route_id))
(target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8")
def wipe_edited_masters(project_root: Path) -> list[str]:
"""사용자 편집 정본을 걷어낸다 — 재계산으로 **진짜 초기값**을 만들기 위한 사전 정리.
자동설계 체인은 계획노선 CSV와 config 기본값으로 도는 결정적 절차라, 이 둘만 없으면
원래 나왔어야 할 값이 나온다. 남겨 두면 `resolve_extra_stations`가 사용자가 편집한
관·구조물에서 측점을 다시 파생해 "초기값"이 오염된다(2026-08-29 실측에서 확인).
스냅샷 복원 경로에서는 부르지 않는다 — 거기서는 스냅샷본이 통째로 덮어쓴다.
"""
removed: list[str] = []
root = Path(project_root)
for rel in ("B05_Profile/route/structures.json", "B04_PreProcess/drainage/edits"):
path = root / rel
try:
if path.is_dir():
shutil.rmtree(path)
removed.append(rel)
elif path.is_file():
path.unlink()
removed.append(rel)
except OSError:
pass # 지우지 못하면 그만큼 초기값이 덜 깨끗할 뿐, 재계산은 계속한다.
return removed
def restore_snapshot_files(project_root: Path) -> None:
"""스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다."""
root = Path(project_root)
source = snapshot_dir(root)
for tree in _FILE_TREES:
_copy_tree(source / tree.replace("/", "__"), root / tree)
async def restore_initial_snapshot(
connection: aiomysql.Connection, project_root: Path, project_id: str
) -> int | None:
"""`routes`와 자식 4표를 스냅샷 값으로 다시 세우고 새 route id를 돌려준다.
호출자가 기존 `routes` 행을 지운 **뒤에** 부른다(자식은 FK CASCADE로 함께 지워진다).
파일 복원은 트랜잭션 밖이라 `restore_snapshot_files()`를 따로 부른다.
"""
dump_path = snapshot_dir(Path(project_root)) / _DB_DUMP_NAME
if not dump_path.is_file():
return None
dump = json.loads(dump_path.read_text(encoding="utf-8"))
route = (dump.get("routes") or [None])[0]
if not route:
return None
route = dict(route)
route.pop("id", None)
route["project_id"] = project_id
new_id = await _insert_row(connection, "routes", route)
for table in _CHILD_TABLES:
for row in dump.get(table, []):
child = dict(row)
child.pop("id", None)
child["route_id"] = new_id
if "project_id" in child:
child["project_id"] = project_id
await _insert_row(connection, table, child)
return new_id
async def _insert_row(connection: aiomysql.Connection, table: str, row: dict[str, Any]) -> int:
columns = list(row.keys())
placeholders = ", ".join(["%s"] * len(columns))
names = ", ".join(f"`{name}`" for name in columns)
async with connection.cursor() as cursor:
# 표 이름은 상수, 열 이름은 스냅샷이 뜬 실제 스키마에서 온다.
await cursor.execute(
f"INSERT INTO {table} ({names}) VALUES ({placeholders})", # noqa: S608
tuple(row[name] for name in columns),
)
return int(cursor.lastrowid)