move_detect/server/deploy/manual/build_manual.py
유창욱 9596d1bad6 release: 1.0.0 — 첫 정식 납품 버전
0.9.0 까지가 "감지하는 시스템"이었다면 1.0.0 은 "감지에 대응하는 시스템"이다. 그 차이를
만드는 것은 e00ad69 의 통보 경로이고, 이 커밋은 그것을 실제로 납품 가능한 형태로 만든다.

VERSION 0.9.0 -> 1.0.0

릴리스 규칙 위반 3건 수정 (rules/release-versioning.md)
- 산출물 파일명에 버전·날짜가 없었다. move-detect-cafe24.zip / 2_pi_install-<arch>.tar.gz
  는 같은 날 두 번 빌드하면 구분이 안 되고, 현장이 무엇을 돌리는지 파일명으로 답할 수
  없었다. 이제 move-detect-cafe24-<VERSION>-<YYYYMMDD>.zip /
  move-detect-pi-<VERSION>-<YYYYMMDD>-<arch>-<abi>-<hailo>.tar.gz.
- 두 빌드 스크립트가 VERSION 파일이 없으면 "unknown" 으로 넘어갔다. 무버전 산출물은
  빌드머신을 떠나는 순간 커밋과 대조할 방법이 사라진다 — 이제 exit 1 로 거부한다.
- deploy/README.md 의 stale 파일명 표기 갱신.

납품 동봉물 (skill delivery-audit: "설명서 + 점검 리포트 동봉")
- CHANGELOG.md 신설. 업그레이드 절차와 알려진 제약(모델 미확보 3종)을 명시.
- 역할별 사용 설명서를 패키지에 동봉. 운영자/안전관리자/시스템관리자 3역할 14절 + FAQ 6항,
  실제 화면 캡처 8장을 base64 로 박은 단일 HTML(665KB). 폐쇄망에서 메일·USB 로 돌아다녀도
  도판이 깨지지 않아야 하므로 단일 파일이다.
  생성물은 커밋하지 않는다 — 소스(manual.json + shots/)만 두고 패키저가 매번 빌드한다.
  커밋된 사본은 화면이 바뀐 뒤에도 최신처럼 보이고, 구 UI 캡처가 남은 매뉴얼은 납품물
  자체의 결함이다.
- 감사 리포트를 '납품점검-리포트.md' 로 동봉. 무엇을 점검했고 무엇이 잔여인지 패키지가
  스스로 답하지 못하면, 인수인계 몇 달 뒤 그 답을 아는 사람이 남지 않는다.

README-UPLOAD 에 크론 안내 신설 (누락이었다)
  설치 안내서에 자동 실행 등록이 아예 없었다. 이 릴리스의 핵심인 통보가 크론 없이는 한 건도
  나가지 않는데, 운영자가 읽는 유일한 문서가 그 사실을 말하지 않았다.
  8번(알림 켜기: 크론 2개 + --dry-run 선행 + 경광등 우선) / 9번(설치 점검 /setup) 추가,
  마지막 체크리스트에 3항목 추가.

부수 수정
- 셸 스크립트에 CRLF 가 섞여 bash 가 여는 중괄호에서 죽었다(빌드 자체가 불가). LF 로 정규화.
  cafe24 스크립트에는 bash -n 가드가 이미 있어 이번에 실제로 잡았고, Pi 쪽에는 없어서
  그대로 지나갈 뻔했다 — tests/test_deploy_artifacts.py 에 CRLF·bash -n 가드 추가.

산출물
  dist/move-detect-cafe24-1.0.0-20260818.zip
  dist/move-detect-pi-1.0.0-20260818-aarch64-cp311-hailo8.tar.gz

검증: pytest 통과(skip 3 = 하드웨어·환경) · PHPUnit 224 (skip 0).
매뉴얼 실렌더 확인(목차 정합·도판 8장·콜아웃). 패키지 내부 검증: 버전 스탬프 1.0.0,
마이그레이션 8/8 동봉, config.php 는 CHANGEME 템플릿, tools/ 는 www 밖에만,
웹 루트 PHP 는 index.php/_preflight.php 뿐.
2026-08-18 14:04:28 +09:00

227 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""역할별 사용자 설명서 생성기 — 캡처를 base64 로 박아 넣은 단일 HTML.
WHY A SINGLE FILE
납품처는 폐쇄망이고, 매뉴얼은 메일로 돌거나 USB 로 옮겨 다닌다. 이미지가 옆 폴더에
있으면 그 순간 도판 없는 문서가 된다. 전부 한 파일에 넣으면 어디로 옮겨도 온전하다.
WHY REGENERATE INSTEAD OF EDITING HTML
화면이 바뀌면 도판도 바뀌어야 한다. 구 UI 캡처가 남은 매뉴얼은 납품물 자체의 결함이다
(rules: delivery-audit '매뉴얼 캡처 실물 정합'). 내용은 manual.json, 도판은 shots/ 에
두고 이 스크립트로 다시 뽑는 구조라, 화면이 바뀌면 캡처만 갈아끼우고 재실행하면 된다.
사용법:
python server/deploy/manual/build_manual.py # -> 사용설명서.html
python server/deploy/manual/build_manual.py --out X # 출력 경로 지정
"""
from __future__ import annotations
import argparse
import base64
import datetime
import html
import json
import pathlib
import sys
HERE = pathlib.Path(__file__).resolve().parent
REPO = HERE.parents[2]
CALLOUT_LABEL = {"info": "참고", "warn": "주의", "danger": "경고"}
def data_uri(path: pathlib.Path) -> str:
return "data:image/jpeg;base64," + base64.b64encode(path.read_bytes()).decode("ascii")
CSS = """
:root{
--bg:#eef1f5; --card:#fff; --line:#dde3ea; --line-2:#eef2f6;
--ink:#131824; --ink-2:#464e5c; --ink-3:#646c7a;
--accent:#0b6b7a; --accent-wash:#e2f0f3; --accent-line:#b9d9df;
--warn-bg:#fbf6e6; --warn-line:#efe0b4; --warn-ink:#7a5606;
--crit-bg:#fdf2f2; --crit-line:#f2cccc; --crit-ink:#a51d1d;
--font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif;
--mono:ui-monospace,"Cascadia Mono",Consolas,"D2Coding",monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font);
line-height:1.75;-webkit-font-smoothing:antialiased}
.wrap{max-width:880px;margin:0 auto;padding:0 1.2rem 5rem}
/* 표지 */
.cover{padding:3.5rem 0 2rem;border-bottom:3px solid var(--ink);margin-bottom:2rem}
.cover .kind{margin:0;font-size:.72rem;font-weight:700;letter-spacing:.14em;
text-transform:uppercase;color:var(--ink-3)}
.cover h1{margin:.5rem 0 .3rem;font-size:2.1rem;letter-spacing:-.02em}
.cover .sub{margin:0;color:var(--ink-2);font-size:1rem}
.cover .meta{margin:1.2rem 0 0;font-family:var(--mono);font-size:.76rem;color:var(--ink-3)}
/* 목차 */
.toc{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:1.1rem 1.3rem;margin-bottom:2.5rem}
.toc h2{margin:0 0 .6rem;font-size:.72rem;font-weight:700;letter-spacing:.08em;
text-transform:uppercase;color:var(--ink-3)}
.toc ul{margin:.2rem 0 0;padding-left:1.1rem;list-style:none}
.toc li{margin:.15rem 0}
.toc li::before{content:"·";color:var(--ink-3);margin-right:.5rem}
.toc a{color:var(--accent);text-decoration:none}
.toc a:hover{text-decoration:underline;text-underline-offset:3px}
.toc-role{margin:.9rem 0 0;font-weight:700;color:var(--ink);font-size:.9rem}
.toc-role:first-of-type{margin-top:0}
/* 역할 */
.role{margin:0 0 3rem}
.role-head{padding:1rem 1.2rem;background:var(--accent-wash);
border:1px solid var(--accent-line);border-radius:10px;margin-bottom:1.5rem}
.role-head h2{margin:0;font-size:1.25rem;color:var(--accent)}
.role-head p{margin:.35rem 0 0;font-size:.9rem;color:var(--ink-2)}
section.step{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:1.3rem 1.4rem 1.5rem;margin-bottom:1rem}
section.step h3{margin:0 0 .8rem;font-size:1.05rem;letter-spacing:-.01em}
section.step p{margin:0 0 .7rem;font-size:.94rem;color:var(--ink-2)}
section.step p:last-child{margin-bottom:0}
section.step strong{color:var(--ink)}
figure{margin:0 0 1.1rem}
figure img{width:100%;height:auto;display:block;border:1px solid var(--line);border-radius:8px}
figcaption{margin-top:.35rem;font-size:.74rem;color:var(--ink-3)}
.callout{margin:1rem 0 0;padding:.75rem .9rem;border-radius:8px;font-size:.9rem;
border:1px solid var(--line);background:var(--line-2);color:var(--ink-2)}
.callout .tag{display:inline-block;font-size:.68rem;font-weight:700;letter-spacing:.06em;
margin-right:.45rem;text-transform:uppercase}
.callout.warn{background:var(--warn-bg);border-color:var(--warn-line);color:var(--warn-ink)}
.callout.danger{background:var(--crit-bg);border-color:var(--crit-line);color:var(--crit-ink)}
.callout.info{background:var(--accent-wash);border-color:var(--accent-line);color:var(--accent)}
code{font-family:var(--mono);font-size:.86em;background:var(--line-2);
padding:.1em .35em;border-radius:4px}
kbd{font-family:var(--mono);font-size:.8em;background:var(--card);border:1px solid var(--line);
border-bottom-width:2px;border-radius:4px;padding:.05em .4em}
.chip{display:inline-block;font-size:.72em;font-weight:700;color:#fff;background:var(--crit-ink);
padding:.1em .5em;border-radius:999px}
/* FAQ */
.faq{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:1.3rem 1.4rem}
.faq h2{margin:0 0 1rem;font-size:1.15rem}
.faq dt{font-weight:700;margin-top:1rem;color:var(--ink)}
.faq dt:first-of-type{margin-top:0}
.faq dd{margin:.3rem 0 0;font-size:.93rem;color:var(--ink-2)}
footer.doc{margin-top:2.5rem;padding-top:1rem;border-top:1px solid var(--line);
font-size:.76rem;color:var(--ink-3);line-height:1.7}
@media print{
body{background:#fff}
.wrap{max-width:none;padding:0}
section.step,.toc,.faq,.role-head{break-inside:avoid;box-shadow:none}
.toc{display:none}
}
"""
def render(doc: dict, shots: pathlib.Path, version: str) -> str:
out: list[str] = []
a = out.append
today = datetime.date.today().isoformat()
a("<!doctype html>")
a('<html lang="ko"><head><meta charset="utf-8">')
a('<meta name="viewport" content="width=device-width, initial-scale=1">')
a(f"<title>{html.escape(doc['title'])}</title>")
a(f"<style>{CSS}</style>")
a("</head><body><div class=\"wrap\">")
# 표지
a('<header class="cover">')
a('<p class="kind">사용 설명서</p>')
a(f"<h1>{html.escape(doc['title'])}</h1>")
a(f"<p class=\"sub\">{html.escape(doc['subtitle'])}</p>")
a(f'<p class="meta">버전 {html.escape(version)} · {today} 기준 화면</p>')
a("</header>")
# 목차. 항목 번호는 제목이 이미 달고 있으므로(1., 2., …) 목록 자체는 번호를 매기지
# 않는다. 역할 이름을 <li> 로 끼워 넣고 list-style 로만 숨기면 그 줄이 카운터를 먹어
# 본문 번호와 목차 번호가 어긋난다(7 다음이 9로 뛴다).
a('<nav class="toc"><h2>목차</h2>')
n = 1
for role in doc["roles"]:
a(f'<p class="toc-role">{html.escape(role["name"])}</p><ul>')
for sec in role["sections"]:
anchor = f"sec-{n}"
n += 1
sec["_anchor"] = anchor
a(f'<li><a href="#{anchor}">{html.escape(sec["heading"])}</a></li>')
a("</ul>")
a('<p class="toc-role">자주 묻는 질문</p>')
a("</nav>")
# 본문
for role in doc["roles"]:
a('<div class="role">')
a('<div class="role-head">')
a(f"<h2>{html.escape(role['name'])}</h2>")
a(f"<p>{html.escape(role['summary'])}</p>")
a("</div>")
for sec in role["sections"]:
a(f'<section class="step" id="{sec["_anchor"]}">')
a(f"<h3>{html.escape(sec['heading'])}</h3>")
shot = sec.get("shot")
if shot:
p = shots / shot
if not p.is_file():
# 도판 누락을 조용히 넘기면 구멍 난 매뉴얼이 납품된다.
raise SystemExit(f"ERROR: 캡처가 없습니다: {p}")
a("<figure>")
a(f'<img alt="{html.escape(sec["heading"])} 화면" src="{data_uri(p)}">')
a(f"<figcaption>{html.escape(sec['heading'])} — 실제 화면</figcaption>")
a("</figure>")
for para in sec["body"]:
a(f"<p>{para}</p>") # 본문은 의도된 인라인 마크업을 허용한다
co = sec.get("callout")
if co:
label = CALLOUT_LABEL.get(co["kind"], "참고")
a(f'<div class="callout {co["kind"]}">'
f'<span class="tag">{label}</span>{co["text"]}</div>')
a("</section>")
a("</div>")
# FAQ
a('<div class="faq"><h2>자주 묻는 질문</h2><dl>')
for item in doc["faq"]:
a(f"<dt>{html.escape(item['q'])}</dt><dd>{item['a']}</dd>")
a("</dl></div>")
a('<footer class="doc">')
a(f"이 문서는 버전 {html.escape(version)} 화면을 기준으로 작성되었습니다. "
"화면이 바뀌면 캡처와 함께 다시 생성됩니다.<br>"
"설치·업그레이드 절차는 <code>README-UPLOAD.md</code>, "
"변경 내역은 <code>CHANGELOG.md</code>, "
"점검 결과는 <code>납품점검-리포트.md</code> 를 참조하세요.")
a("</footer>")
a("</div></body></html>")
return "\n".join(out)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=str(HERE / "사용설명서.html"))
ap.add_argument("--shots", default=str(HERE / "shots"))
args = ap.parse_args()
version_file = REPO / "VERSION"
if not version_file.is_file():
raise SystemExit("ERROR: VERSION 파일이 없습니다. 버전 없는 매뉴얼은 만들지 않습니다.")
version = version_file.read_text(encoding="utf-8").strip()
doc = json.loads((HERE / "manual.json").read_text(encoding="utf-8"))
out = pathlib.Path(args.out)
out.write_text(render(doc, pathlib.Path(args.shots), version), encoding="utf-8")
size_kb = out.stat().st_size / 1024
print(f"{out} ({size_kb:.0f} KB, 단일 파일 · 이미지 포함)", file=sys.stderr)
if __name__ == "__main__":
main()