# -*- coding: utf-8 -*-
"""
킨텍스 자동전시시스템 — 지침서 3종 PPTX 일괄 생성기
  · 사용자지침서.pptx  · 운영자지침서.pptx  · 개발자지침서.pptx

- 디자인: docs/deliverables/_gen/gen_dev_plan_pptx.py 스타일 헬퍼 그대로 차용
          (KINTEX Blue #0066B3 / AI Purple #6D4AFF / 맑은 고딕 / 16:9,
           표지·간지·본문 마스터, 도식은 python-pptx 도형 직접 작도)
- 내용 소스(사실 기반): docs/PLANNING.md v3.0, docs/design.md v2.0(§2.8 80화면 인벤토리),
          docs/OPS_RUNBOOK.md, docs/ENV_SETUP.md, docs/BUILD_DEPLOY.md,
          docs/DEVELOPMENT_GUIDE.md, docs/SECURITY.md, mobile/README.md
- 보안 불변: 비밀번호/API키/서버IP/SSH 계정 미기재(env 키 '이름'만 언급, 값 금지)
재실행: python gen_guides_pptx.py
"""
import os
from pptx import Presentation
from pptx.util import Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
from pptx.oxml.ns import qn

# ---------------- 팔레트 (개발계획서 동일) ----------------
BLUE      = RGBColor(0x00, 0x66, 0xB3)
BLUE_DK   = RGBColor(0x00, 0x44, 0x7A)
BLUE_LT   = RGBColor(0xE1, 0xEF, 0xF9)
PURPLE    = RGBColor(0x6D, 0x4A, 0xFF)
PURPLE_LT = RGBColor(0xEC, 0xE8, 0xFF)
INK       = RGBColor(0x10, 0x18, 0x28)
MUTED     = RGBColor(0x66, 0x70, 0x85)
LINE      = RGBColor(0xE4, 0xE7, 0xEC)
BG        = RGBColor(0xF9, 0xFA, 0xFB)
WHITE     = RGBColor(0xFF, 0xFF, 0xFF)
GREEN     = RGBColor(0x12, 0x9E, 0x63)
AMBER     = RGBColor(0xE0, 0x8A, 0x00)
RED       = RGBColor(0xD0, 0x3A, 0x3A)
SLATE     = RGBColor(0x98, 0xA2, 0xB3)
CARD      = RGBColor(0xFF, 0xFF, 0xFF)
HALL_BG   = RGBColor(0xF2, 0xF4, 0xF7)

FONT = "맑은 고딕"
EMU_W, EMU_H = 12192000, 6858000
IN = 914400
def inch(v): return int(v * IN)

# 문서 컨텍스트 (build 시 세팅)
PRS = None
BLANK = None
DOC_FOOTER = ""
DOC_DATE = "2026-07-11"

# ---------------- 헬퍼 ----------------
def slide():
    return PRS.slides.add_slide(BLANK)

def _set_fill(shape, color):
    if color is None:
        shape.fill.background()
    else:
        shape.fill.solid()
        shape.fill.fore_color.rgb = color

def _no_line(shape):
    shape.line.fill.background()

def rect(s, x, y, w, h, fill=None, line=None, line_w=1.0, shape=MSO_SHAPE.RECTANGLE, shadow=False):
    sp = s.shapes.add_shape(shape, inch(x), inch(y), inch(w), inch(h))
    _set_fill(sp, fill)
    if line is None:
        _no_line(sp)
    else:
        sp.line.color.rgb = line
        sp.line.width = Pt(line_w)
    sp.shadow.inherit = False
    if shadow:
        el = sp._element.spPr
        ef = el.find(qn('a:effectLst'))
        if ef is None:
            ef = el.makeelement(qn('a:effectLst'), {})
            el.append(ef)
        sh = ef.makeelement(qn('a:outerShdw'),
                            {'blurRad':'90000','dist':'38100','dir':'5400000','rotWithShape':'0'})
        clr = sh.makeelement(qn('a:srgbClr'), {'val':'101828'})
        alp = clr.makeelement(qn('a:alpha'), {'val':'18000'})
        clr.append(alp); sh.append(clr); ef.append(sh)
    return sp

def text(s, x, y, w, h, content, size=14, color=INK, bold=False, align=PP_ALIGN.LEFT,
         anchor=MSO_ANCHOR.TOP, line_spacing=1.0, space_after=2, wrap=True):
    tb = s.shapes.add_textbox(inch(x), inch(y), inch(w), inch(h))
    tf = tb.text_frame
    tf.word_wrap = wrap
    tf.vertical_anchor = anchor
    tf.margin_left = 0; tf.margin_right = 0; tf.margin_top = 0; tf.margin_bottom = 0
    if isinstance(content, str):
        content = [content]
    for i, para in enumerate(content):
        p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
        p.alignment = align
        p.line_spacing = line_spacing
        p.space_after = Pt(space_after)
        p.space_before = Pt(0)
        if isinstance(para, str):
            segs = [{"t": para}]
        elif isinstance(para, dict):
            segs = [para]
        else:
            segs = para
        for seg in segs:
            r = p.add_run()
            r.text = seg.get("t","")
            r.font.name = FONT
            r.font.size = Pt(seg.get("size", size))
            r.font.bold = seg.get("bold", bold)
            r.font.color.rgb = seg.get("color", color)
            rPr = r._r.get_or_add_rPr()
            ea = rPr.makeelement(qn('a:ea'), {'typeface':FONT})
            rPr.append(ea)
    return tb

def page_bg(s, color=BG):
    rect(s, -0.1, -0.1, 13.53, 7.7, fill=color)

def chip(s, x, y, w, h, label, fill, txtcolor=WHITE, size=9.5, bold=True):
    rect(s, x, y, w, h, fill=fill, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x, y, w, h, label, size=size, color=txtcolor, bold=bold,
         align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)

def card(s, x, y, w, h, fill=CARD, line=LINE):
    return rect(s, x, y, w, h, fill=fill, line=line, line_w=1.0,
                shape=MSO_SHAPE.ROUNDED_RECTANGLE, shadow=True)

def content_header(s, kicker, title, idx):
    page_bg(s, BG)
    rect(s, 0, 0, 13.333, 0.12, fill=BLUE)
    rect(s, 0.55, 0.5, 0.62, 0.62, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.55, 0.5, 0.62, 0.62, f"{idx:02d}", size=20, color=WHITE, bold=True,
         align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    text(s, 1.32, 0.5, 10.5, 0.28, kicker, size=11, color=PURPLE, bold=True)
    text(s, 1.32, 0.74, 11.4, 0.44, title, size=22, color=INK, bold=True)
    rect(s, 0.55, 1.28, 12.23, 0.02, fill=LINE)
    text(s, 0.55, 7.06, 9, 0.3, DOC_FOOTER, size=8.5, color=MUTED)
    text(s, 11.3, 7.06, 1.48, 0.3, DOC_DATE, size=8.5, color=MUTED, align=PP_ALIGN.RIGHT)

def bullets(s, x, y, w, items, dot=BLUE, size=10.5, gap=0.46, color=INK, ls=1.0, dotsz=0.1):
    """items: str 또는 [head, desc] 튜플"""
    for i, it in enumerate(items):
        yy = y + i*gap
        rect(s, x, yy+0.07, dotsz, dotsz, fill=dot, shape=MSO_SHAPE.OVAL)
        if isinstance(it, (tuple, list)):
            text(s, x+0.24, yy, w-0.24, gap, [[{"t":it[0]+" — ","size":size,"color":INK,"bold":True},
                                               {"t":it[1],"size":size-0.6,"color":MUTED}]], line_spacing=ls)
        else:
            text(s, x+0.24, yy, w-0.24, gap, it, size=size, color=color, line_spacing=ls)

def titled_card(s, x, y, w, h, title, tcolor, items, item_size=9.6, gap=0.4):
    card(s, x, y, w, h)
    rect(s, x, y, w, 0.5, fill=tcolor, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x, y, w, 0.5, title, size=12.5, color=WHITE, bold=True,
         align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    for i, it in enumerate(items):
        yy = y+0.66+i*gap
        rect(s, x+0.2, yy+0.07, 0.09, 0.09, fill=tcolor, shape=MSO_SHAPE.OVAL)
        if isinstance(it, (tuple, list)):
            text(s, x+0.4, yy, w-0.55, gap, [[{"t":it[0]+" ","size":item_size,"color":INK,"bold":True},
                                              {"t":it[1],"size":item_size-0.8,"color":MUTED}]], line_spacing=0.95)
        else:
            text(s, x+0.4, yy, w-0.55, gap, it, size=item_size, color=INK, line_spacing=0.95)

def steps(s, x, y, w, items, band=BLUE, sep="›"):
    """가로 단계 파이프라인. items: [(title, sub, color)]"""
    n = len(items); g = 0.14
    bw = (w - (n-1)*g) / n
    for i, (t, sub, cl) in enumerate(items):
        bx = x + i*(bw+g)
        rect(s, bx, y, bw, 0.78, fill=WHITE, line=cl, line_w=1.3, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
        text(s, bx, y+0.1, bw, 0.3, t, size=11, color=cl, bold=True, align=PP_ALIGN.CENTER)
        if sub:
            text(s, bx, y+0.42, bw, 0.3, sub, size=7.6, color=MUTED, align=PP_ALIGN.CENTER)
        if i < n-1:
            text(s, bx+bw-0.03, y, 0.22, 0.78, sep, size=16, color=SLATE, bold=True,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)

# ---------------- 공통 슬라이드: 표지 ----------------
def cover(badge_kr, badge_en, title_lines, subtitle, closing, meta_pairs):
    s = slide()
    rect(s, -0.1, -0.1, 13.53, 7.7, fill=BLUE_DK)
    rect(s, 6.4, -1.5, 9, 11, fill=BLUE, shape=MSO_SHAPE.PARALLELOGRAM)
    rect(s, 9.2, -2.0, 7, 12, fill=PURPLE, shape=MSO_SHAPE.PARALLELOGRAM)
    rect(s, 0.85, 0.8, 0.42, 0.42, fill=WHITE, shape=MSO_SHAPE.OVAL)
    rect(s, 0.97, 0.92, 0.18, 0.18, fill=PURPLE, shape=MSO_SHAPE.OVAL)
    text(s, 1.42, 0.78, 6, 0.46, "KINTEX · WISE AI", size=15, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
    chip(s, 0.85, 2.35, 1.95, 0.42, badge_kr, PURPLE, WHITE, size=12)
    chip(s, 2.92, 2.35, 2.35, 0.42, badge_en, RGBColor(0x2B,0x4C,0x7E), WHITE, size=10.5)
    text(s, 0.85, 2.95, 11, 1.9, [[{"t":ln, "size":46, "color":WHITE, "bold":True}] for ln in title_lines],
         line_spacing=1.02)
    text(s, 0.88, 2.95 + 0.92*len(title_lines) + 0.08, 11, 0.5, subtitle,
         size=16, color=RGBColor(0xC7,0xDD,0xF2))
    text(s, 0.88, 5.35, 11.4, 0.4, closing, size=12.5, color=RGBColor(0xB9,0xC7,0xEA))
    rect(s, 0.85, 6.15, 11.63, 0.02, fill=RGBColor(0x3A,0x5A,0x8C))
    ly = 6.35
    for label, val in meta_pairs[:2]:
        text(s, 0.85, ly, 9, 0.4, [[{"t":label+"  ","size":11,"color":RGBColor(0x90,0xA6,0xC9)},
                                    {"t":val,"size":11,"color":WHITE,"bold":True}]])
        ly += 0.37
    text(s, 9.7, 6.35, 2.78, 0.4, "작성일 "+DOC_DATE, size=11, color=WHITE, bold=True, align=PP_ALIGN.RIGHT)
    text(s, 9.7, 6.72, 2.78, 0.4, "KINTEX 자동전시시스템", size=9.5, color=RGBColor(0x90,0xA6,0xC9), align=PP_ALIGN.RIGHT)

# ---------------- 공통 슬라이드: 목차 ----------------
def toc(items):
    s = slide()
    content_header(s, "CONTENTS", "목차", 0)
    rect(s, 0.55, 0.5, 0.62, 0.62, fill=PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.55, 0.5, 0.62, 0.62, "C", size=22, color=WHITE, bold=True,
         align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    n = len(items)
    per_col = (n + 1) // 2 if n > 5 else n
    col_w = 5.85
    for i, (no, t, d) in enumerate(items):
        col = i // per_col
        row = i % per_col
        x = 0.7 + col * (col_w + 0.35)
        y = 1.62 + row * 1.02
        rect(s, x, y, col_w, 0.88, fill=CARD, line=LINE, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
        rect(s, x, y, 0.09, 0.88, fill=BLUE if col==0 else PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
        text(s, x+0.28, y, 0.9, 0.88, no, size=24, color=(BLUE if col==0 else PURPLE), bold=True, anchor=MSO_ANCHOR.MIDDLE)
        text(s, x+1.15, y+0.15, col_w-1.3, 0.35, t, size=14.5, color=INK, bold=True)
        text(s, x+1.15, y+0.5, col_w-1.3, 0.32, d, size=9.5, color=MUTED)

# ---------------- 공통 슬라이드: 간지 ----------------
def divider(num, kr, en, points):
    s = slide()
    rect(s, -0.1, -0.1, 13.53, 7.7, fill=BLUE_DK)
    rect(s, 8.6, -1.5, 8, 11, fill=BLUE, shape=MSO_SHAPE.PARALLELOGRAM)
    rect(s, 10.9, -2, 6, 12, fill=PURPLE, shape=MSO_SHAPE.PARALLELOGRAM)
    text(s, 1.0, 2.15, 5, 2.2, num, size=150, color=RGBColor(0x2C,0x4E,0x82), bold=True)
    rect(s, 1.15, 3.9, 0.9, 0.09, fill=PURPLE)
    text(s, 1.15, 4.05, 8, 0.8, kr, size=34, color=WHITE, bold=True)
    text(s, 1.18, 4.85, 8, 0.4, en, size=13, color=RGBColor(0x9C,0xB4,0xD8), bold=True)
    for i, p in enumerate(points):
        yy = 2.35 + i*0.62
        rect(s, 8.05, yy+0.06, 0.16, 0.16, fill=PURPLE, shape=MSO_SHAPE.OVAL)
        text(s, 8.35, yy, 4.4, 0.5, p, size=12.5, color=WHITE, anchor=MSO_ANCHOR.MIDDLE)
    return s

# 안내(고지) 배너
def banner(s, y, msg, fill=BLUE, h=0.62, size=13, x=0.55, w=12.23):
    rect(s, x, y, w, h, fill=fill, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, x+0.3, y, w-0.6, h, msg, size=size, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE)

# =====================================================================
# 1) 사용자지침서
# =====================================================================
def build_user():
    global PRS, BLANK, DOC_FOOTER
    PRS = Presentation(); PRS.slide_width = EMU_W; PRS.slide_height = EMU_H
    BLANK = PRS.slide_layouts[6]
    DOC_FOOTER = "킨텍스 자동전시시스템 · 사용자지침서"

    cover("사용자지침서", "User Guide",
          ["킨텍스 자동전시시스템", "사용자 지침서"],
          "역할별 사용자를 위한 화면·업무 흐름 안내서",
          "로그인 → 부스 설계·시각화 → 옥션 → 관람객 등록 → 승인까지 한 흐름",
          [("대상", "주최자 · 참가업체 · 장치/공사업체 · 홀매니저 · 관람객"),
           ("구성", "접속 · 대시보드 · 핵심업무 · 모바일 · FAQ")])

    toc([
        ("01", "시스템 소개 · 접속", "개요 / 로그인·2FA / 아이디기억·비번찾기"),
        ("02", "역할별 홈 · 대시보드", "6역할 포털 / MDI 탭 / 마일스톤 대시보드"),
        ("03", "핵심 업무 흐름", "부스 설계·병합 / 배선·신청 / 시각화 / 옥션 / 등록·승인"),
        ("04", "모바일 앱 사용법", "현장 체크리스트 / 검수 / 시각화 갤러리"),
        ("05", "FAQ · 문의", "자주 묻는 질문 / 지원 채널"),
    ])

    # ---- 01 소개·접속 ----
    divider("01", "시스템 소개 · 접속", "INTRODUCTION & SIGN-IN",
            ["전시 생애주기 폐루프", "역할별 포털", "로그인 · 2차 인증"])

    s = slide(); content_header(s, "01 · 시스템 소개", "무엇을 할 수 있나요", 1)
    text(s, 0.55, 1.45, 12.2, 0.5,
         "킨텍스 자동전시시스템은 홀 배정·부스 설계·유틸리티 배선·규정 검증·시공 발주(옥션)·관람객 운영·경영분석을 하나의 계정으로 잇는 전시 운영 플랫폼입니다.",
         size=11.5, color=MUTED)
    banner(s, 2.05, "핵심  “신청서를 내는 순간, 시공 후 사진을 먼저 봅니다.”  — 나노바나나(AI) 시공 예상 이미지",
           fill=BLUE, size=15)
    loop = [("판매·기획", BLUE), ("AI 설계·시각화", PURPLE), ("공사 옥션", BLUE),
            ("시공", BLUE), ("운영·관람", BLUE), ("경영분석", PURPLE)]
    bw = 1.83; gap = 0.19; x0 = 0.55; yb = 3.05
    for i, (lb, cl) in enumerate(loop):
        x = x0 + i*(bw+gap)
        rect(s, x, yb, bw, 0.7, fill=(PURPLE_LT if cl==PURPLE else BLUE_LT), line=cl, line_w=1.2, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
        text(s, x, yb, bw, 0.7, lb, size=11.5, color=(PURPLE if cl==PURPLE else BLUE_DK), bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
        if i < len(loop)-1:
            text(s, x+bw-0.03, yb, 0.28, 0.7, "→", size=15, color=SLATE, bold=True, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    titled_card(s, 0.55, 4.1, 3.95, 2.55, "내가 얻는 것", BLUE, [
        ("주최자", "자동 견적·배치·마일스톤 트래킹"),
        ("참가업체", "클릭 신청 + 시공 전 부스 사진"),
        ("장치·공사업체", "제출 전 규정검증·옥션 응찰"),
        ("홀매니저", "위반 자동 플래깅·부하 집계"),
        ("관람객", "사전등록·모바일 배지·길찾기"),
    ], item_size=9.8, gap=0.36)
    titled_card(s, 4.65, 4.1, 3.95, 2.55, "AI가 돕는 일", PURPLE, [
        ("배치 3안", "조건 입력 → 3안 자동생성"),
        ("규정 검증", "높이·통로·바닥하중 자동 점검"),
        ("배선 설계", "트렌치 최단 경로·자동 견적"),
        ("시각화", "시공 후 예상 사진(S1~S7)"),
        ("검수 보조", "AI 사전심사·위반 플래그"),
    ], item_size=9.8, gap=0.36)
    titled_card(s, 8.75, 4.1, 4.03, 2.55, "꼭 알아둘 점", BLUE_DK, [
        ("AI 이미지", "‘예상’ 이미지 — 실물과 다를 수 있음"),
        ("워터마크", "계약·인허가 서류로 사용 불가"),
        ("등록업체", "미등록 업체는 초대·응찰 불가"),
        ("최종 판정", "소방·구조는 관할기관·기술사 몫"),
        ("2차 인증", "로그인 시 OTP/이메일 코드 필요"),
    ], item_size=9.8, gap=0.36)

    s = slide(); content_header(s, "01 · 접속 · 로그인", "로그인 · 2차 인증 · 계정 관리", 1)
    steps(s, 0.55, 1.5, 12.23, [
        ("① 이메일·비밀번호", "또는 킨텍스 SSO", BLUE),
        ("② 2차 인증", "OTP 또는 이메일 코드", PURPLE),
        ("③ 행사 선택", "워크스페이스 카드", BLUE),
        ("④ 역할별 홈", "대시보드 진입", GREEN),
    ])
    titled_card(s, 0.55, 2.55, 6.05, 2.15, "로그인 화면(SCR-01)", BLUE, [
        "이메일/비밀번호 또는 ‘킨텍스 내부 계정(SSO)’ 로그인",
        "‘아이디 기억’ 체크 — 이메일만 저장(비밀번호는 저장하지 않음)",
        "1차 로그인 후 2차 인증코드 입력(OTP/이메일)",
        "로그인 후 참여 행사 카드에서 워크스페이스 선택",
        "초대 코드로 참여 — 하단 ‘초대 코드로 참여하기’",
    ], item_size=9.8, gap=0.36)
    titled_card(s, 6.75, 2.55, 6.03, 2.15, "2차 인증·계정(SCR-49~51)", PURPLE, [
        ("회원가입", "SCR-49 웹 회원가입 폼"),
        ("비밀번호 찾기", "SCR-50 이메일 인증 후 재설정"),
        ("OTP 설정", "SCR-51 최초 QR 등록·재설정"),
        ("잠금 해제", "실패 반복 잠금 시 관리자 초기화"),
        ("마이페이지", "SCR-48 OTP 재설정·개인화"),
    ], item_size=9.8, gap=0.36)
    banner(s, 4.95, "안내  미등록 장치업체가 초대를 수락하면 ‘킨텍스 등록업체만 참여할 수 있습니다’ 안내와 함께 차단됩니다.",
           fill=BLUE_DK, size=11.5, h=0.5)
    text(s, 0.55, 5.6, 12.2, 0.9, [
        [{"t":"비밀번호를 잊었을 때  ","size":10.5,"color":INK,"bold":True},
         {"t":"로그인 화면 → ‘비밀번호 찾기’ → 이메일 인증코드 → 새 비밀번호 설정. ","size":10,"color":MUTED}],
        [{"t":"OTP 기기를 바꿨을 때  ","size":10.5,"color":INK,"bold":True},
         {"t":"마이페이지에서 재설정하거나, 접근 불가 시 관리자에게 OTP 초기화를 요청하세요.","size":10,"color":MUTED}],
    ], line_spacing=1.15, space_after=6)

    # ---- 02 역할별 홈·대시보드 ----
    divider("02", "역할별 홈 · 대시보드", "ROLE HOMES & DASHBOARD",
            ["6역할 포털 분리", "MDI 탭 작업환경", "마일스톤 대시보드"])

    s = slide(); content_header(s, "02 · 역할별 포털", "내 역할에 맞는 포털로 들어갑니다", 2)
    text(s, 0.55, 1.4, 12.2, 0.35,
         "역할마다 목적이 다르므로 포털이 분리되어 있습니다. 데스크톱은 설계·대시보드, 모바일은 현장·조회·승인에 최적화됩니다.",
         size=10.5, color=MUTED)
    rect(s, 0.55, 1.9, 12.23, 0.44, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    for x,w,t in [(0.55,2.0,"역할"),(2.55,4.6,"웹 포털(데스크톱)"),(7.15,3.1,"모바일"),(10.25,2.53,"주 사용")]:
        text(s, x+0.15, 1.9, w-0.2, 0.44, t, size=10.5, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
    portals = [
        ("주최자", "주최자 콘솔 — 홀 배정·배치·옥션 발주·BI", "조회·승인·상황판", "M1·M2·M15·M16"),
        ("참가업체", "참가업체 포털 — 부스 신청·설계·유틸리티", "체크인·리드캡처", "M3·M4·M5·M10"),
        ("장치·공사업체", "업체 포털 — 도면 제출·설계 열람·옥션 응찰", "시공·반입·안전", "M3·M15·M8"),
        ("홀매니저·운영", "운영 대시보드 — 검수·규정 플래그·부하", "현장 검수·혼잡", "M2·M6·M14"),
        ("관리자", "백오피스 — 사용자·RBAC·마스터데이터·룰셋", "(웹 전용)", "M18"),
        ("관람객·대중", "공개 홍보 사이트 — 일정·사전등록", "배지·길찾기·매칭", "M12·M10·M13"),
    ]
    ry = 2.42; rh = 0.62
    for i,(r,web,mob,mod) in enumerate(portals):
        y = ry + i*rh
        if i%2==0: rect(s, 0.55, y, 12.23, rh, fill=RGBColor(0xFC,0xFC,0xFD))
        rect(s, 0.55, y, 12.23, 0.01, fill=LINE)
        text(s, 0.67, y, 1.9, rh, r, size=9.8, color=BLUE_DK, bold=True, anchor=MSO_ANCHOR.MIDDLE)
        text(s, 2.7, y, 4.45, rh, web, size=9.2, color=INK, anchor=MSO_ANCHOR.MIDDLE)
        text(s, 7.3, y, 2.95, rh, mob, size=9.2, color=MUTED, anchor=MSO_ANCHOR.MIDDLE)
        text(s, 10.4, y, 2.4, rh, mod, size=8.8, color=PURPLE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
    text(s, 0.55, 6.35, 12.2, 0.4,
         "접속 서브도메인으로 전시관(테넌트)이 고정됩니다 — 자신이 소속되지 않은 전시관의 데이터는 조회·검색되지 않습니다.",
         size=9.5, color=MUTED)

    s = slide(); content_header(s, "02 · 대시보드 · 작업환경", "마일스톤 대시보드와 MDI 탭", 2)
    titled_card(s, 0.55, 1.5, 6.05, 3.3, "주최자 대시보드(SCR-02)", BLUE, [
        ("마일스톤 타임라인", "D-150 배정 → D-30 협의 → D-25 유틸리티 → D-7 서류 → D-0 개장"),
        ("완료·현재·임박", "녹색 체크 / 파란 진행 / 주황 펄스로 표시"),
        ("KPI 카드", "참가업체 신청·배치 확정률·유틸리티 완료·위반 미해결"),
        ("참가업체 현황표", "부스번호·설계 상태·유틸리티·예상 사진 썸네일"),
        ("알림 피드", "승인·반려·마감 이벤트 실시간"),
        ("홀 전경 조감", "S7 미니 이미지 카드(AI 생성)"),
    ], item_size=9.6, gap=0.44)
    titled_card(s, 6.75, 1.5, 6.03, 3.3, "MDI 탭 작업환경(§2.7)", PURPLE, [
        ("여러 문서 동시", "부스 에디터·승인 큐·설계 스튜디오를 탭으로 병행"),
        ("상태 보존", "탭 전환해도 미저장 편집·스크롤·줌 유지"),
        ("미저장 표시", "탭에 ● 표시, 닫을 때 저장 확인"),
        ("탭 열기", "메뉴·링크 클릭 → 새 탭(같은 문서는 1개)"),
        ("단축키", "Ctrl+Tab 전환 · Ctrl+W 닫기 · Ctrl+1~9 이동"),
        ("모바일", "MDI 미적용 — 한 화면 집중(오조작 방지)"),
    ], item_size=9.6, gap=0.44)
    banner(s, 5.0, "팁  좌측 사이드바에서 행사(이벤트)를 바꾸면 그 행사의 탭 세트로 전환됩니다. 미저장 탭이 있으면 먼저 확인합니다.",
           fill=BLUE_DK, size=11, h=0.5)
    text(s, 0.55, 5.7, 12.2, 0.9, [
        [{"t":"화면 딥링크  ","size":10.5,"color":INK,"bold":True},
         {"t":"알림·메일 링크를 클릭하면 해당 문서 탭이 바로 열립니다(예: 검수 상세·배치 에디터). ","size":10,"color":MUTED}],
        [{"t":"세션 복원  ","size":10.5,"color":INK,"bold":True},
         {"t":"새로고침·재접속 시 열려 있던 탭 목록이 복원됩니다(서버 미저장 편집 내용 자체는 복원되지 않음).","size":10,"color":MUTED}],
    ], line_spacing=1.15, space_after=6)

    # ---- 03 핵심 업무 흐름 ----
    divider("03", "핵심 업무 흐름", "CORE WORKFLOWS",
            ["부스 설계 3안·병합", "배선·신청·규정검증", "시각화 · 옥션 · 등록·승인"])

    s = slide(); content_header(s, "03 · 부스 설계 스튜디오", "3안 생성 · 선택 · 병합 (M2·M3)", 3)
    steps(s, 0.55, 1.5, 12.23, [
        ("① 조건 입력", "홀·목표부스·비율", BLUE),
        ("② AI 3안 생성", "1·2·3안 자동", PURPLE),
        ("③ 선택 / 병합", "구역·블록 조합", BLUE),
        ("④ 규정 재검증", "병합 후 필수", AMBER),
        ("⑤ 확정·버전", "부스번호 발급", GREEN),
    ])
    titled_card(s, 0.55, 2.6, 6.05, 2.7, "배치·설계(SCR-03·04·06)", BLUE, [
        ("AI 자동배치", "보라 버튼 → 제약조건 풀이로 3안 생성"),
        ("배치안 비교", "S7 조감 이미지·지표(부스수·통로폭·위반) 비교"),
        ("선택 또는 병합", "1안 통로 + 2안 프리미엄존 + 3안 무대 조합"),
        ("위반 오버레이", "통로 미달 빨강 밴드·바닥하중 초과 주황 빗금"),
        ("부스 설계", "조립부스 옵션 선택 / 독립부스 3안 레이아웃"),
    ], item_size=9.6, gap=0.44)
    titled_card(s, 6.75, 2.6, 6.03, 2.7, "규정 검증·검수(SCR-09~11)", PURPLE, [
        ("제출 전 자동검증", "높이 5m·리깅 6.5~8.5m·복층·방염 체크리스트"),
        ("규정 리포트", "차단/경고 요약 + 규정 근거·위치 보기"),
        ("승인 큐", "홀매니저 AI 사전심사 플래그로 검토"),
        ("검수 상세", "도면 위 위반 오버레이 확인 후 승인/반려"),
        ("최종 판정", "소방·구조 유권해석은 관할기관·구조기술사"),
    ], item_size=9.6, gap=0.44)
    banner(s, 5.5, "중요  ‘병합’으로 제약이 깨질 수 있으므로, 병합 결과는 반드시 규정 검증을 다시 실행한 뒤 확정하세요.",
           fill=AMBER, size=11.5, h=0.5)

    s = slide(); content_header(s, "03 · 유틸리티 · 시각화", "배선 신청과 시각화 갤러리 (M4·M5)", 3)
    titled_card(s, 0.55, 1.5, 6.05, 3.15, "유틸리티 배선·신청(SCR-07·08)", BLUE, [
        ("기기 목록 입력", "전시장비·조명·PC → kW 합산·분전반 자동 산출"),
        ("단말 위치 클릭", "부스 도면 위 클릭 → 트렌치 최단 배선 자동"),
        ("위치표시도 자동", "수기 작도 폐지 — 자동 생성"),
        ("자동 견적", "전기·급배수·압축공기·인터넷 요금 자동"),
        ("마감 리마인더", "D-25 역산 알림 — 인터넷은 현장 추가 불가"),
        ("배선 오버레이", "M5 이미지로 배선 확인"),
    ], item_size=9.5, gap=0.42)
    titled_card(s, 6.75, 1.5, 6.03, 3.15, "시각화 갤러리(SCR-12)", PURPLE, [
        ("표준 샷 세트", "부스 정면·야간 조명·통로 뷰·홀 전경(S1~S7)"),
        ("Before/After", "설계 데이터 → 시공 후 예상 사진"),
        ("워터마크 상시", "모든 이미지에 ‘AI 생성 예상 이미지’ 고지"),
        ("컨펌 자료", "참가업체·장치업체 합의용으로 공유"),
        ("생성 대기", "완료 시 실시간 푸시로 알림"),
    ], item_size=9.5, gap=0.42)
    banner(s, 4.9, "AI 워터마크 고지  나노바나나 생성 이미지는 참고용 예상 이미지입니다 — 계약·심사·인허가 서류로 사용할 수 없습니다.",
           fill=RED, size=11.5, h=0.55)
    text(s, 0.55, 5.7, 12.2, 0.9, [
        [{"t":"유틸리티 신청 팁  ","size":10.5,"color":INK,"bold":True},
         {"t":"인터넷·전화·급배수·압축공기는 현장 추가신청이 어렵습니다. D-25 마감 알림을 놓치지 마세요. ","size":10,"color":MUTED}],
        [{"t":"전기 용량  ","size":10.5,"color":INK,"bold":True},
         {"t":"과소 신청 시 현장 증설이 불가할 수 있어, 기기 목록 기반 자동 산출값을 확인하고 신청하세요.","size":10,"color":MUTED}],
    ], line_spacing=1.15, space_after=6)

    s = slide(); content_header(s, "03 · 공사 옥션 · 관람객", "옥션(견적·낙찰)과 관람객 등록 (M15·M10)", 3)
    steps(s, 0.55, 1.45, 12.23, [
        ("① 옥션 개설", "설계자료 첨부", BLUE),
        ("② 응찰(견적서)", "업체 제출", PURPLE),
        ("③ 실시간 순위", "역경매", BLUE),
        ("④ 낙찰 비교", "동일사양 비교", AMBER),
        ("⑤ 선정·발주", "정산 연계", GREEN),
    ])
    titled_card(s, 0.55, 2.5, 6.05, 2.75, "공사/장치 옥션(SCR-26~29)", BLUE, [
        ("옥션 목록·개설", "AI 설계·물량·시공 예상 이미지를 응찰 근거로"),
        ("응찰·순위(업체)", "견적서 작성·제출 → 실시간 순위 확인"),
        ("낙찰 비교(발주자)", "동일 사양 위 공정 비교로 선정"),
        ("등록업체 게이트", "킨텍스 등록업체만 초대·응찰 가능"),
        ("발주 종결", "낙찰 → M9 정산·결제로 연계"),
    ], item_size=9.6, gap=0.44)
    titled_card(s, 6.75, 2.5, 6.03, 2.75, "관람객 등록·배지·리드(SCR-30~32)", PURPLE, [
        ("사전등록", "공개 사이트 폼(SCR-P4)에서 관람 신청"),
        ("모바일 배지/QR", "관람객 앱에서 배지 발급·체크인"),
        ("현장 체크인", "데스크/키오스크(SCR-31)로 입장"),
        ("리드캡처", "참가업체가 배지 스캔으로 리드 수집(M4)"),
        ("리드 스코어링", "AI가 리드 우선순위 제안(SCR-32)"),
    ], item_size=9.6, gap=0.44)
    banner(s, 5.45, "승인 워크플로  제출물은 홀매니저 승인 큐 → 검수 상세 → 승인/반려를 거칩니다. 반려 시 사유와 위반 위치가 함께 전달됩니다.",
           fill=BLUE_DK, size=11, h=0.5)

    # ---- 04 모바일 ----
    divider("04", "모바일 앱 사용법", "MOBILE APP",
            ["현장 체크리스트·검수", "시각화 갤러리", "오프라인·워터마크"])

    s = slide(); content_header(s, "04 · 모바일 앱", "현장·조회·승인을 위한 모바일 앱", 4)
    text(s, 0.55, 1.4, 12.2, 0.35,
         "모바일 앱(Expo)은 조회·승인·현장 시나리오 전용입니다. 설계·배치 편집은 데스크톱 웹에서 수행합니다.",
         size=10.5, color=MUTED)
    titled_card(s, 0.55, 1.95, 3.95, 3.6, "현장(장치·홀매니저)", BLUE, [
        ("로그인", "2FA/OTP·아이디 기억·비번찾기"),
        ("현장 체크리스트", "48px 큰 터치·오프라인·사진 첨부(SCR-M1)"),
        ("현장 검수", "승인/반려·AI 플래그 확인(SCR-M2)"),
        ("반입 통행증", "QR 통행증·안전 체크(SCR-M10)"),
        ("서류·승인 조회", "이동 중 확인(SCR-M12)"),
    ], item_size=9.6, gap=0.42)
    titled_card(s, 4.65, 1.95, 3.95, 3.6, "참가·관람", PURPLE, [
        ("리드캡처", "배지 스캔으로 방문객 리드 수집(SCR-M4)"),
        ("관람객 배지/QR", "모바일 배지·체크인(SCR-M5)"),
        ("길찾기", "실내 지도·wayfinding(SCR-M6)"),
        ("부스 검색", "플로어플랜·부스 찾기(SCR-M7)"),
        ("비즈매칭", "미팅 요청·일정(SCR-M8·M9)"),
    ], item_size=9.6, gap=0.42)
    titled_card(s, 8.75, 1.95, 4.03, 3.6, "공통 · 유의", BLUE_DK, [
        ("홈/대시보드", "역할별 진입(SCR-M3)"),
        ("시각화 갤러리", "워터마크·고지 항상 표시"),
        ("알림센터", "마감·승인·반려 푸시(SCR-M11)"),
        ("오프라인", "현장 통신 불안정 시 임시 저장"),
        ("보안", "비밀번호 저장 안 함 — 이메일만 기억"),
    ], item_size=9.6, gap=0.42)
    banner(s, 5.85, "설치  앱은 QR 코드 배포로 설치합니다(스토어 불필요). AI 생성 이미지는 앱에서도 워터마크·고지가 항상 표시됩니다.",
           fill=BLUE, size=11, h=0.5)

    # ---- 05 FAQ·문의 ----
    divider("05", "FAQ · 문의", "FAQ & SUPPORT",
            ["자주 묻는 질문", "오류 대처", "지원 채널"])

    s = slide(); content_header(s, "05 · FAQ", "자주 묻는 질문", 5)
    faqs = [
        ("Q. 로그인 후 2차 인증코드를 못 받았어요.", "이메일 인증 방식은 스팸함을 확인하세요. OTP 방식은 인증 앱의 6자리 코드를 입력합니다. 기기 변경 시 마이페이지에서 재설정하거나 관리자에게 OTP 초기화를 요청하세요."),
        ("Q. AI가 만든 예상 사진을 계약서에 넣어도 되나요?", "안 됩니다. 모든 AI 이미지는 ‘예상 이미지’로 워터마크·고지가 붙으며 계약·인허가·심사 서류로 사용할 수 없습니다."),
        ("Q. 3안을 합쳐서 쓰고 싶어요.", "배치안 비교에서 여러 안의 구역/블록을 골라 병합할 수 있습니다. 병합 후에는 반드시 규정 검증을 다시 실행한 뒤 확정하세요."),
        ("Q. 유틸리티 신청 마감을 놓치면?", "인터넷·급배수 등은 현장 추가신청이 어렵습니다. D-25 역산 알림을 활용하고, 부득이한 경우 홀매니저/사무국에 문의하세요."),
        ("Q. 우리 장치업체를 옥션에 초대할 수 없어요.", "킨텍스 등록업체만 초대·응찰할 수 있습니다. 미등록 업체는 등록 절차 후 이용 가능합니다."),
        ("Q. 탭을 닫았는데 편집 내용이 사라졌어요.", "탭 세션은 복원되지만 서버에 저장하지 않은 편집 내용 자체는 복원되지 않습니다. 중요한 작업은 저장 후 닫으세요."),
    ]
    for i,(q,a) in enumerate(faqs):
        x = 0.55 + (i%2)*6.2
        y = 1.55 + (i//2)*1.62
        card(s, x, y, 6.05, 1.5)
        rect(s, x, y, 0.08, 1.5, fill=(BLUE if i%2==0 else PURPLE), shape=MSO_SHAPE.ROUNDED_RECTANGLE)
        text(s, x+0.24, y+0.14, 5.65, 0.5, q, size=10.5, color=INK, bold=True, line_spacing=0.95)
        text(s, x+0.24, y+0.66, 5.65, 0.78, a, size=9, color=MUTED, line_spacing=0.98)

    s = slide(); content_header(s, "05 · 문의 · 지원", "지원 채널 및 도움말", 5)
    titled_card(s, 0.55, 1.6, 6.05, 2.7, "이런 순서로 확인하세요", BLUE, [
        "① 화면 안내(툴팁)·본 사용자지침서 해당 절 확인",
        "② 오류 배너의 요약 메시지·재시도 버튼 사용",
        "③ 마감·승인 관련은 알림센터에서 이력 확인",
        "④ 권한·계정 문제는 관리자(테넌트 관리자)에게",
        "⑤ 규정·현장 문제는 홀매니저·킨텍스 운영팀에",
    ], item_size=10, gap=0.44)
    titled_card(s, 6.75, 1.6, 6.03, 2.7, "역할별 지원 접점", PURPLE, [
        ("참가업체", "설계·유틸리티 → 주최자 사무국"),
        ("장치·공사업체", "등록·옥션 → 킨텍스 등록업체 담당"),
        ("주최자", "배정·정산 → 킨텍스 행사지원팀"),
        ("관람객", "등록·배지 → 공개 사이트 문의(SCR-P6)"),
        ("공통", "계정·권한 → 시스템 관리자"),
    ], item_size=10, gap=0.44)
    banner(s, 4.55, "오류 메시지는 원인 요약만 표시됩니다(내부 정보 비노출). 반복되면 발생 시각·화면명·행사명을 함께 전달하면 빠르게 처리됩니다.",
           fill=BLUE_DK, size=11, h=0.6)
    text(s, 0.55, 5.5, 12.2, 0.8,
         "본 지침서의 화면 번호(SCR-*)는 설계 문서 docs/design.md의 화면 인벤토리(총 80화면)를 기준으로 합니다. 화면 구성은 업데이트로 달라질 수 있습니다.",
         size=9.5, color=MUTED, line_spacing=1.1)

    return PRS

# =====================================================================
# 2) 운영자지침서
# =====================================================================
def build_ops():
    global PRS, BLANK, DOC_FOOTER
    PRS = Presentation(); PRS.slide_width = EMU_W; PRS.slide_height = EMU_H
    BLANK = PRS.slide_layouts[6]
    DOC_FOOTER = "킨텍스 자동전시시스템 · 운영자지침서"

    cover("운영자지침서", "Operations Guide",
          ["킨텍스 자동전시시스템", "운영자 지침서"],
          "킨텍스 운영·관리자를 위한 운영·백오피스·장애 대응 안내서",
          "아키텍처 → 백오피스 → 배포 → 모니터링 → 백업·보안 → 정기 점검",
          [("대상", "테넌트(전시관) 관리자 · 플랫폼 슈퍼관리자 · 운영 담당"),
           ("근거", "OPS_RUNBOOK · SECURITY · BUILD_DEPLOY 준용")])

    toc([
        ("01", "운영 아키텍처 개요", "서비스 토폴로지 / 의존 관계"),
        ("02", "관리자 백오피스", "사용자·RBAC·감사·설정 / 마스터·룰셋·테넌트·AiConfig"),
        ("03", "배포 · CI/CD 운영", "push→자동배포 / health 게이트 / 롤백"),
        ("04", "모니터링 · 장애 대응", "헬스체크·로그·재기동 / 자주 겪는 이슈"),
        ("05", "백업 · 보안 점검", "보안 불변 / 워터마크 강제 / 자격증명 보호"),
        ("06", "정기 점검 체크리스트", "일 · 주 · 월 점검표"),
    ])

    # ---- 01 아키텍처 ----
    divider("01", "운영 아키텍처 개요", "OPERATIONS ARCHITECTURE",
            ["백엔드·워커·프론트", "PostgreSQL·Redis", "webhook 자동배포"])

    s = slide(); content_header(s, "01 · 운영 아키텍처", "서비스 토폴로지 (개발 kintex.zioinfo.co.kr)", 1)
    def node(x,y,w,h,title,sub,fill,tcol,line=None):
        rect(s, x, y, w, h, fill=fill, line=(line or LINE), line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
        text(s, x, y+0.1, w, 0.3, title, size=11, color=tcol, bold=True, align=PP_ALIGN.CENTER)
        if sub: text(s, x, y+0.44, w, 0.28, sub, size=8.4, color=MUTED, align=PP_ALIGN.CENTER)
    # 프론트/nginx
    rect(s, 0.55, 1.55, 12.23, 0.44, fill=BLUE_LT, line=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.7, 1.55, 6, 0.44, "접속 계층 — nginx (kintex.service 도메인 :80/443)", size=10.5, color=BLUE_DK, bold=True, anchor=MSO_ANCHOR.MIDDLE)
    node(0.55, 2.15, 3.9, 0.8, "프론트(웹 정적)", "역할별 번들 · SPA 폴백", WHITE, INK, BLUE)
    node(4.65, 2.15, 3.9, 0.8, "백엔드 API", "kintex.service · REST + WebSocket", WHITE, INK, BLUE)
    node(8.75, 2.15, 4.03, 0.8, "나노바나나 워커", "kintex-nanobanana.service · 큐 소비", PURPLE_LT, PURPLE, PURPLE)
    # 데이터
    rect(s, 0.55, 3.2, 12.23, 0.44, fill=HALL_BG, line=BLUE_DK, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.7, 3.2, 6, 0.44, "데이터·큐 계층", size=10.5, color=BLUE_DK, bold=True, anchor=MSO_ANCHOR.MIDDLE)
    node(0.55, 3.8, 3.9, 0.8, "PostgreSQL + PostGIS", "kintex_db · 공간 데이터", WHITE, INK, BLUE_DK)
    node(4.65, 3.8, 3.9, 0.8, "Redis", "작업 큐·세션·캐시", WHITE, INK, BLUE_DK)
    node(8.75, 3.8, 4.03, 0.8, "오브젝트 스토리지", "렌더 이미지·문서", WHITE, INK, BLUE_DK)
    # 배포
    rect(s, 0.55, 4.85, 12.23, 0.44, fill=PURPLE_LT, line=PURPLE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    text(s, 0.7, 4.85, 6, 0.44, "배포·연동", size=10.5, color=PURPLE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
    node(0.55, 5.45, 3.9, 0.8, "deploy_server (webhook)", "Gitea(zio/kintex) push 수신", WHITE, INK, PURPLE)
    node(4.65, 5.45, 3.9, 0.8, "Gemini (G1)", "워커 env only · 미승인시 degraded", WHITE, INK, PURPLE)
    node(8.75, 5.45, 4.03, 0.8, "헬스 게이트", "GET /health → data.status=UP", WHITE, INK, PURPLE)
    text(s, 0.55, 6.45, 12.2, 0.4,
         "의존  백엔드 → PostgreSQL·Redis · 워커 → Redis·Gemini(G1) · 프론트 → nginx → 백엔드. 헬스 판정 = /health UP + 두 서비스 active + Redis PONG + DB 응답.",
         size=9.3, color=MUTED)

    # ---- 02 백오피스 ----
    divider("02", "관리자 백오피스", "ADMIN BACK-OFFICE",
            ["사용자·RBAC·감사·설정", "마스터데이터·룰셋", "테넌트 온보딩·AiConfig"])

    s = slide(); content_header(s, "02 · 관리자 백오피스", "시스템관리 영역 (M18 · §5B)", 2)
    text(s, 0.55, 1.4, 12.2, 0.35,
         "백오피스(admin.)는 웹 전용입니다. 테넌트 관리자는 자기 전시관에 고정되고, 플랫폼 슈퍼관리자는 테넌트 스위처로 전 전시관을 통제합니다.",
         size=10.5, color=MUTED)
    titled_card(s, 0.55, 1.95, 3.95, 3.9, "시스템관리 (SCR-A1~A6)", BLUE, [
        ("사용자 관리", "6역할 + 등록업체·관람객 계정(SCR-A1)"),
        ("역할·권한 RBAC", "플랫폼·행사 이중 평가(SCR-A2)"),
        ("공통코드", "홀·부스유형·공종14분류·요금(SCR-A3)"),
        ("메뉴 관리", "역할별 포털 IA 구동(SCR-A4)"),
        ("감사로그", "승인·낙찰·룰셋개정·리드접근(SCR-A5)"),
        ("시스템설정", "요율/규정 룰셋 버전(SCR-A6)"),
        ("잠금·OTP 초기화", "실패 잠금 해제·OTP 재설정"),
    ], item_size=9.4, gap=0.42)
    titled_card(s, 4.65, 1.95, 3.95, 3.9, "마스터·룰셋 (SCR-A7·A8)", PURPLE, [
        ("마스터데이터", "홀·요율·요금·부스표준·등록업체(SCR-A7)"),
        ("규정 룰셋 버전", "높이·통로·바닥하중 규칙 개정(SCR-A8)"),
        ("룰셋은 데이터", "코드가 아닌 버전 파일로 관리"),
        ("개정 추적", "리포트에 rulesetVersion·고지 기록"),
        ("전시관 소유", "홀·요율·규정은 각 전시관이 소유"),
        ("근거 없는 추정 금지", "미확보 실측값은 온보딩 시 입력"),
    ], item_size=9.4, gap=0.42)
    titled_card(s, 8.75, 1.95, 4.03, 3.9, "테넌트·AI (SCR-A9·A10)", BLUE_DK, [
        ("테넌트 관리", "전시관 마스터·온보딩(SCR-A9)"),
        ("테넌트 격리", "tenant_id로 전 데이터 스코프"),
        ("서브도메인", "kintex/coex 등 컨텍스트 해소"),
        ("KINTEX = 테넌트#1", "기존 데이터 백필 기준"),
        ("AiConfig", "AI 플랫폼·모델 설정(SCR-A10)"),
        ("Claude 기본", "실패 시 Ollama 자동 폴백"),
        ("크로스 집계", "전 테넌트 BI는 슈퍼관리자만"),
    ], item_size=9.4, gap=0.42)

    # ---- 03 배포 ----
    divider("03", "배포 · CI/CD 운영", "DEPLOY & CI/CD",
            ["push → 자동배포", "health 게이트", "자동·수동 롤백"])

    s = slide(); content_header(s, "03 · 배포 · CI/CD", "push → 자동배포 → 헬스 게이트", 3)
    steps(s, 0.55, 1.5, 12.23, [
        ("① push", "scripts/push_kintex.py", BLUE),
        ("② webhook", "Gitea #47", PURPLE),
        ("③ 빌드", "bootJar · vite build", BLUE),
        ("④ 원자 교체", "deploy_kintex.sh", AMBER),
        ("⑤ health 게이트", "GET /health", GREEN),
    ])
    titled_card(s, 0.55, 2.6, 6.05, 2.7, "정상 배포 경로(자동)", BLUE, [
        ("git pull", "webhook 수신 후 소스 갱신"),
        ("Flyway 마이그", "스키마 전진 적용(전진만 권장)"),
        ("빌드", "프론트 vite build · 백엔드 gradlew bootJar"),
        ("jar 검증", "KintexApplication.class 존재 확인"),
        ("원자 교체", "무중단 교체 후 헬스체크"),
        ("검증", "kintex_smoke_test.py로 라우터 등록 확인"),
    ], item_size=9.6, gap=0.44)
    titled_card(s, 6.75, 2.6, 6.03, 2.7, "금지·주의", RED, [
        ("동시 빌드 금지", "수동 Jenkins 추가 트리거 금지(jar 교체 깨짐)"),
        ("서버빌드 신뢰", "로컬 빌드 이슈보다 서버 빌드 결과 신뢰"),
        ("deploy_server 반영", "kintex 블록 서버 반영·zioinfo-deploy 재시작"),
        ("‘완료’ 오인", "로그 완료여도 1ms no-op면 블록 부재 의심"),
        ("빌드 OOM", "공유 8GB — 타 솔루션과 직렬 빌드"),
        ("운영 배포", "소유자 승인 필수"),
    ], item_size=9.6, gap=0.44)
    banner(s, 5.5, "롤백  deploy_kintex.sh는 헬스 실패 시 이전 jar를 자동 유지·복원합니다. 자동 실패 시 백업 jar 복원 또는 직전 정상 커밋으로 재배포합니다.",
           fill=BLUE_DK, size=11, h=0.5)

    # ---- 04 모니터링·장애 ----
    divider("04", "모니터링 · 장애 대응", "MONITORING & INCIDENT",
            ["헬스체크 우선", "로그·재기동", "자주 겪는 이슈"])

    s = slide(); content_header(s, "04 · 모니터링 · 장애", "헬스체크 · 로그 · 재기동 (런북 요약)", 4)
    titled_card(s, 0.55, 1.5, 6.05, 2.55, "점검 순서", BLUE, [
        ("① 헬스체크", "kintex_smoke_test.py(자동) / /health(수동)"),
        ("② 서비스 상태", "kintex.service·kintex-nanobanana.service active"),
        ("③ 의존", "Redis PONG · DB SELECT 1"),
        ("④ 로그", "journalctl -u kintex.service -n 200"),
        ("⑤ 재기동", "systemctl restart → /health 재확인"),
    ], item_size=9.8, gap=0.46)
    titled_card(s, 6.75, 1.5, 6.03, 2.55, "재기동 원칙", PURPLE, [
        ("백엔드", "restart 후 5초 뒤 /health 확인"),
        ("워커", "이미지 멈춤·큐 적체 시 워커만 재기동"),
        ("nginx", "정적 이상 시 nginx -t 후 reload"),
        ("DB/Redis", "공유 인스턴스 — 타 솔루션 영향 확인 후"),
        ("자동기동", "두 유닛 enable 상태 유지"),
    ], item_size=9.8, gap=0.46)
    # 이슈 표
    text(s, 0.55, 4.25, 6, 0.3, "자주 겪는 이슈", size=12, color=INK, bold=True)
    rect(s, 0.55, 4.62, 12.23, 0.4, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    for x,w,t in [(0.55,4.2,"증상"),(4.75,3.6,"원인 후보"),(8.35,4.43,"조치")]:
        text(s, x+0.12, 4.62, w-0.15, 0.4, t, size=10, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
    issues = [
        ("/health 200인데 특정 API 404", "라우터 미배포(구 jar)", "스모크 러너로 라우터 확인 → 재배포"),
        ("500 relation ... does not exist", "마이그 미적용", "flyway info 확인, 보정 마이그(다음 번호) 추가"),
        ("이미지 생성 안 됨 / degraded", "G1 미승인·워커 다운", "워커 로그·env 확인, 워커 재기동(키는 워커 env only)"),
        ("배포 ‘완료’인데 반영 안 됨", "deploy_server 블록 부재", "서버 kintex 블록 반영·zioinfo-deploy 재시작"),
        ("push 했는데 자동배포 안 됨", "webhook 미설정/시크릿 불일치", "Gitea webhook #47 활성·secret·URL 확인"),
    ]
    ry=5.02; rh=0.4
    for i,(sym,cause,act) in enumerate(issues):
        y=ry+i*rh
        if i%2==0: rect(s, 0.55, y, 12.23, rh, fill=RGBColor(0xFC,0xFC,0xFD))
        rect(s, 0.55, y, 12.23, 0.008, fill=LINE)
        text(s, 0.67, y, 4.1, rh, sym, size=8.6, color=INK, anchor=MSO_ANCHOR.MIDDLE)
        text(s, 4.87, y, 3.5, rh, cause, size=8.6, color=MUTED, anchor=MSO_ANCHOR.MIDDLE)
        text(s, 8.35, y, 4.4, rh, act, size=8.6, color=BLUE_DK, anchor=MSO_ANCHOR.MIDDLE)

    # ---- 05 백업·보안 ----
    divider("05", "백업 · 보안 점검", "BACKUP & SECURITY",
            ["보안 불변 원칙", "AI 워터마크 강제", "자격증명 보호"])

    s = slide(); content_header(s, "05 · 백업 · 보안 점검", "보안 불변 및 배포·리뷰 전 점검", 5)
    titled_card(s, 0.55, 1.5, 6.05, 3.35, "보안 불변(위반 불가)", BLUE_DK, [
        ("외부 API 통제", "온프레미스 우선 · Claude·Gemini만 예외"),
        ("키는 env only", "코드·DB·커밋·로그·응답 기록 금지"),
        ("Gemini는 워커만", "백엔드는 키 미취급(큐 발행·콜백만)"),
        ("자격증명 보호", "IP·SSH·비번·해시 응답/로그 미노출"),
        ("암호화 저장", "admin 비번 env 주입(AES-256-GCM)·하드코딩 금지"),
        ("스택트레이스 차단", "오류는 요약 메시지만 — 테이블/컬럼명 미노출"),
        ("AI 워터마크", "M5 이미지 응답 watermarkRequired 항상 포함"),
    ], item_size=9.4, gap=0.4)
    titled_card(s, 6.75, 1.5, 6.03, 3.35, "배포·리뷰 전 체크리스트", PURPLE, [
        "응답/로그/커밋에 IP·SSH·비번·해시·API키 노출 0(grep)",
        "Gemini/Claude 키 env only — 코드·DB·커밋 미포함",
        "admin 비번 하드코딩 시드 없음(env 재시드)",
        "M5 이미지 응답 watermarkRequired:true 포함",
        "DB/서버 오류 → 요약 메시지(스택·relation 미노출)",
        "운영 스크립트 시크릿 하드코딩 0(마스킹)",
        "/api/admin/** ADMIN 게이트·미등록업체 차단 동작",
    ], item_size=9.4, gap=0.4)
    banner(s, 5.05, "백업·스키마  Flyway는 전진(forward)만 — 잘못된 마이그는 되돌리지 말고 보정 마이그를 추가합니다. 운영 DB 임의 롤백 금지.",
           fill=RED, size=11, h=0.55)
    text(s, 0.55, 5.8, 12.2, 0.8, [
        [{"t":"로그에 자격증명·API키가 보이면  ","size":10.5,"color":INK,"bold":True},
         {"t":"즉시 보안 이슈로 에스컬레이션합니다(SECURITY §2). 감사로그에도 비밀값을 기재하지 않습니다.","size":10,"color":MUTED}],
    ], line_spacing=1.1, space_after=4)

    # ---- 06 정기 점검 ----
    divider("06", "정기 점검 체크리스트", "ROUTINE CHECKLIST",
            ["일간 점검", "주간 점검", "월간 점검"])

    s = slide(); content_header(s, "06 · 정기 점검", "일 · 주 · 월 점검 체크리스트", 6)
    titled_card(s, 0.55, 1.5, 3.95, 4.6, "일간 (Daily)", BLUE, [
        "스모크 러너 실행·전체 통과 확인",
        "/health UP · 두 서비스 active",
        "Redis PONG · DB SELECT 1",
        "워커 큐 소비·이미지 생성 정상",
        "에러 로그 급증·5xx 여부 확인",
        "마감 임박 알림(D-25·D-7) 발송 상태",
        "감사로그 이상 접근 스캔",
    ], item_size=9.6, gap=0.44)
    titled_card(s, 4.65, 1.5, 3.95, 4.6, "주간 (Weekly)", PURPLE, [
        "배포 이력·롤백 발생 여부 검토",
        "Flyway 마이그 상태(info) 확인",
        "디스크·렌더 스토리지 용량 점검",
        "백업 정상 수행·복구 가능성 확인",
        "OTP·로그인 실패 잠금 문의 처리",
        "룰셋/요율 개정 요청 반영 검토",
        "webhook·시크릿 정합 점검",
    ], item_size=9.6, gap=0.44)
    titled_card(s, 8.75, 1.5, 4.03, 4.6, "월간 (Monthly)", BLUE_DK, [
        "보안 불변 체크리스트 전수 점검(grep)",
        "권한(RBAC)·계정 접근 재검토",
        "감사로그 보존·이상 패턴 분석",
        "테넌트 격리·크로스 접근 점검",
        "AiConfig·모델 폴백 동작 확인",
        "의존 서비스(공유 DB/Redis) 용량 추이",
        "장애·변경 이력 회고 및 런북 갱신",
    ], item_size=9.6, gap=0.44)
    text(s, 0.55, 6.25, 12.2, 0.4,
         "점검 결과는 운영 기록으로 남기고, 이상 발견 시 OPS_RUNBOOK 절차(헬스→로그→재기동→롤백)로 대응합니다. 운영 배포·재구성은 소유자 승인이 필요합니다.",
         size=9.3, color=MUTED)

    return PRS

# =====================================================================
# 3) 개발자지침서
# =====================================================================
def build_dev():
    global PRS, BLANK, DOC_FOOTER
    PRS = Presentation(); PRS.slide_width = EMU_W; PRS.slide_height = EMU_H
    BLANK = PRS.slide_layouts[6]
    DOC_FOOTER = "킨텍스 자동전시시스템 · 개발자지침서"

    cover("개발자지침서", "Developer Guide",
          ["킨텍스 자동전시시스템", "개발자 지침서"],
          "개발 참여자를 위한 환경·표준·빌드·보안 가이드",
          "환경 구축 → 스택·구조 → 개발 표준 → 빌드·배포 → 하네스 → 보안 코딩",
          [("대상", "백엔드 · 프론트 · 모바일 · 워커 개발 참여자"),
           ("스택", "React(Vite)+TS · Spring Boot 3.x(Java17)+MyBatis · PostgreSQL(PostGIS) · Redis")])

    toc([
        ("01", "개발환경 구축", "clone · env(키 이름) · 로컬 실행"),
        ("02", "기술 스택 · 구조", "확정 스택 / backend·frontend·mobile·nanobanana"),
        ("03", "개발 표준", "레이어링·MyBatis·PostGIS / API 봉투·오류코드·토큰"),
        ("04", "빌드 · 테스트 · 배포", "게이트 / 테스트 / CI/CD 함정"),
        ("05", "하네스 · 에이전트 워크플로", "planner→designer→dev→qa→devops"),
        ("06", "보안 코딩 규칙", "보안 불변 · 워터마크 · 자격증명 보호"),
    ])

    # ---- 01 환경 ----
    divider("01", "개발환경 구축", "DEV ENVIRONMENT",
            ["도구·clone", "DB·Redis·워커", "env 키 이름만"])

    s = slide(); content_header(s, "01 · 개발환경 구축", "사전 도구 · clone · 로컬 실행", 1)
    titled_card(s, 0.55, 1.5, 6.05, 3.0, "사전 요구 도구", BLUE, [
        ("JDK 17 (LTS)", "Spring Boot 3.x 빌드/실행(gradlew)"),
        ("Node.js 18+", "Vite 빌드(Node 16 불가 — WISE 함정)"),
        ("PostgreSQL 15+/16", "kintex_db"),
        ("PostGIS 3.x", "부스 polygon·트렌치 point·배선 LineString"),
        ("Redis 6+/7", "작업 큐(RenderJob·서류·알림)"),
        ("Python 3.11+", "나노바나나 워커 사이드카"),
    ], item_size=9.6, gap=0.42)
    titled_card(s, 6.75, 1.5, 6.03, 3.0, "초기 세팅 흐름", PURPLE, [
        ("clone", "git clone <gitea>/zio/kintex.git"),
        ("백엔드", "src/backend → ./gradlew build"),
        ("프론트", "src/frontend → npm install"),
        ("워커", "tools/nanobanana → pip install google-genai Pillow"),
        ("DB 준비", "kintex_db + CREATE EXTENSION postgis"),
        ("스키마", "Flyway 순번 마이그레이션으로 적용"),
    ], item_size=9.6, gap=0.42)
    banner(s, 4.7, "환경변수는 이름(키)만 규약입니다 — 실제 비밀값·포트는 저장소에 두지 않고 사내 비밀관리·env에서 주입합니다.",
           fill=BLUE_DK, size=11, h=0.5)
    text(s, 0.55, 5.35, 12.2, 1.05, [
        [{"t":"백엔드 env 키(이름만)  ","size":10,"color":INK,"bold":True},
         {"t":"KINTEX_DB_PASSWORD · KINTEX_JWT_SECRET · REDIS_HOST/REDIS_PORT · RENDER_WORKER_TOKEN · ANTHROPIC_API_KEY · ADMIN_PASSWORD_ENC/ADMIN_KEY_FILE · SMTP_*","size":9.3,"color":MUTED}],
        [{"t":"워커 env 키(이름만)  ","size":10,"color":INK,"bold":True},
         {"t":"GEMINI_API_KEY(워커 전용·G1 승인 대상) · NANOBANANA_MODEL(기본 gemini-3.1-flash-image-preview). 값은 코드·커밋·로그에 기록 금지.","size":9.3,"color":MUTED}],
        [{"t":"원격 DB  ","size":10,"color":INK,"bold":True},
         {"t":"5432 외부 차단 시 SSH 로컬 포워딩으로 jdbc:postgresql://localhost:5432/kintex_db 접속.","size":9.3,"color":MUTED}],
    ], line_spacing=1.12, space_after=5)

    # ---- 02 스택·구조 ----
    divider("02", "기술 스택 · 구조", "STACK & STRUCTURE",
            ["확정 스택(변경 금지)", "패키지 레이어", "프론트·모바일·워커"])

    s = slide(); content_header(s, "02 · 기술 스택", "확정 스택 (변경 금지)", 2)
    rect(s, 0.55, 1.55, 12.23, 0.44, fill=BLUE, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
    for x,w,t in [(0.55,3.0,"레이어"),(3.55,9.23,"표준")]:
        text(s, x+0.15, 1.55, w-0.2, 0.44, t, size=10.5, color=WHITE, bold=True, anchor=MSO_ANCHOR.MIDDLE)
    stack = [
        ("프론트(웹)", "React 18/19 + Vite + TypeScript — 역할별 번들 분리(§2-1)"),
        ("백엔드", "Spring Boot 3.x(Java 17) + MyBatis — REST + WebSocket(STOMP)"),
        ("DB", "PostgreSQL + PostGIS(부스 polygon·트렌치 point·배선 LineString)"),
        ("비동기", "Redis 작업 큐(RenderJob·서류·알림)"),
        ("이미지 생성", "나노바나나 Python 워커 사이드카(tools/nanobanana, google-genai)"),
        ("AI(텍스트)", "Claude 기본 + 설정형 전환(AiTextRouter/AiConfig) — 실패 시 Ollama 폴백"),
        ("인증", "행사 단위 RBAC(JWT HS256) + 2차 인증(OTP/TOTP)"),
    ]
    ry=2.05; rh=0.52
    for i,(k,v) in enumerate(stack):
        y=ry+i*rh
        if i%2==0: rect(s, 0.55, y, 12.23, rh, fill=RGBColor(0xFC,0xFC,0xFD))
        rect(s, 0.55, y, 12.23, 0.008, fill=LINE)
        text(s, 0.7, y, 2.9, rh, k, size=10, color=BLUE_DK, bold=True, anchor=MSO_ANCHOR.MIDDLE)
        text(s, 3.6, y, 9.1, rh, v, size=9.6, color=INK, anchor=MSO_ANCHOR.MIDDLE)
    banner(s, 5.8, "패키지 루트 com.zioinfo.kintex · DB kintex_db · 나노바나나 호출은 tools/nanobanana 워커만(백엔드는 GEMINI_API_KEY 미취급).",
           fill=BLUE_DK, size=10.5, h=0.5)

    s = slide(); content_header(s, "02 · 프로젝트 구조", "백엔드 레이어 · 프론트 · 모바일 · 워커", 2)
    titled_card(s, 0.55, 1.5, 6.05, 4.0, "백엔드 (com.zioinfo.kintex)", BLUE, [
        ("config", "Security·Jwt·WebSocket·Redis·@MapperScan"),
        ("security", "JwtTokenProvider·Filter·KintexPrincipal"),
        ("common", "response(ApiResponse·PageResponse)·exception·audit"),
        ("auth", "AuthService·TotpService·mapper·dto"),
        ("module/m2~m5", "플로어플랜·부스설계·배선·나노바나나"),
        ("module/m10·m15·m16·m18", "도메인(Phase D)"),
        ("system", "사용자·권한·공통코드·메뉴·감사·설정"),
        ("work", "worklog·schedule·message·stats·notice…"),
    ], item_size=9.2, gap=0.4)
    titled_card(s, 6.75, 1.5, 6.03, 4.0, "프론트 · 모바일 · 워커", PURPLE, [
        ("src/frontend", "pages·components·api·store·hooks·routes"),
        ("역할별 번들", "organizer·exhibitor·contractor·ops·admin·public"),
        ("공유 상속", "디자인 시스템·공통 컴포넌트·API 계약 상속"),
        ("axios", "baseURL=/api · 응답 타입 = 계약 shape 1:1"),
        ("mobile/", "Expo SDK 51·expo-router·RN 0.74·TS"),
        ("모바일 저장", "JWT는 expo-secure-store(웹 AsyncStorage)"),
        ("tools/nanobanana", "큐 소비 데몬 → 콜백(RENDER_WORKER_TOKEN)"),
        ("룰셋은 데이터", "rulesets/*.json 버전 파일(코드 아님)"),
    ], item_size=9.2, gap=0.4)

    # ---- 03 개발 표준 ----
    divider("03", "개발 표준", "DEVELOPMENT STANDARDS",
            ["레이어링·MyBatis·PostGIS", "API 봉투·오류코드", "디자인 토큰·네이밍"])

    s = slide(); content_header(s, "03 · 개발 표준", "레이어링 · API 계약 · 코딩 규약", 3)
    titled_card(s, 0.55, 1.5, 6.05, 3.4, "백엔드 레이어링·MyBatis", BLUE, [
        ("계층 규칙", "controller(검증·RBAC) → service(트랜잭션·룰) → mapper"),
        ("컨트롤러", "도메인 로직 금지"),
        ("매퍼", "비즈니스 판단 금지 · @Mapper + XML"),
        ("PostGIS", "ST_MakePolygon·ST_Area·ST_Distance·<-> KNN은 XML"),
        ("룰셋", "규정·요율은 버전 파일 · rulesetVersion·disclaimer 기록"),
        ("네이밍", "Java camel/Pascal · DB SNAKE_CASE · DTO camelCase"),
        ("마이그레이션", "Flyway 순번 · 번호 충돌 금지(최대+1)"),
    ], item_size=9.3, gap=0.4)
    titled_card(s, 6.75, 1.5, 6.03, 3.4, "API·응답 규약", PURPLE, [
        ("응답 봉투", "ApiResponse<T> = { success, data, error }"),
        ("목록", "PageResponse<T> = { items, page, size, total }"),
        ("오류코드", "VALIDATION 400·FORBIDDEN 403·COMPLIANCE_BLOCKED 422"),
        ("미구현", "NOT_IMPLEMENTED 501"),
        ("스코프", "도메인 경로는 {eventId} + 행사 RBAC 가드"),
        ("프론트 타입", "TS strict · 응답 타입 = 계약 shape 1:1 · any 지양"),
        ("코드그룹", "boothType·myRole·severity는 COMMON_CODES 단일 출처"),
    ], item_size=9.3, gap=0.4)
    banner(s, 5.1, "디자인 토큰  색·타이포·간격은 design.md §1 토큰만 사용(신규 토큰 추가 금지). 데이터 시각화는 CSS 변수 주입(하드코딩 hex 금지).",
           fill=BLUE_DK, size=11, h=0.5)
    text(s, 0.55, 5.8, 12.2, 0.7,
         "인증  1차 로그인(ID/PW) → verifyToken → 2차 검증(EMAIL/OTP TOTP RFC6238) → access·refresh. 로그인 실패 잠금 + 관리자 해제. /api/system·/api/admin = hasRole(ADMIN).",
         size=9.5, color=MUTED, line_spacing=1.1)

    # ---- 04 빌드·테스트·배포 ----
    divider("04", "빌드 · 테스트 · 배포", "BUILD · TEST · DEPLOY",
            ["push 전 게이트", "단위·경계면·보안 회귀", "CI/CD 함정"])

    s = slide(); content_header(s, "04 · 빌드 · 테스트 · 배포", "게이트 · 테스트 · 파이프라인", 4)
    steps(s, 0.55, 1.5, 12.23, [
        ("① compile/tsc", "변경분 통과", BLUE),
        ("② 시크릿 차단", ".env·*.key 금지", PURPLE),
        ("③ 마이그 번호", "충돌 검사", BLUE),
        ("④ push", "= 배포 트리거", AMBER),
        ("⑤ health 게이트", "실패 시 롤백", GREEN),
    ])
    titled_card(s, 0.55, 2.6, 6.05, 2.75, "테스트", BLUE, [
        ("백엔드 단위", "룰 엔진(규정 평가·요율 산식·배선 최단경로)"),
        ("공간 쿼리", "PostGIS 통합(testcontainers/로컬 PostGIS)"),
        ("경계면(계약)", "API 응답 shape ↔ 프론트 훅/컴포넌트 교차 대조"),
        ("보안 회귀", "자격증명·PII·스택트레이스·워터마크·응찰 가드"),
        ("워커", "키/네트워크 없이도 import·구조 성립(목/degraded)"),
    ], item_size=9.5, gap=0.44)
    titled_card(s, 6.75, 2.6, 6.03, 2.75, "빌드·배포 함정", RED, [
        ("Node 16 금지", "vite build 불가 — Node 18+ 사용"),
        ("서버빌드 신뢰", "로컬 rollup/win32 크래시는 서버 빌드 신뢰"),
        ("동시 빌드 금지", "target/*.jar 교체 깨짐 — 직렬 빌드"),
        ("자기방어 스크립트", "jar 검증 → 원자 교체 → 헬스 → 실패 시 롤백"),
        ("커밋 시점", "지시가 있을 때만 · 운영 배포 소유자 승인"),
    ], item_size=9.5, gap=0.44)
    banner(s, 5.6, "Flyway는 전진만 — 잘못된 마이그는 되돌리지 말고 보정 마이그(다음 번호)를 추가합니다. baseline-on-migrate=true.",
           fill=AMBER, size=11, h=0.5)

    # ---- 05 하네스 ----
    divider("05", "하네스 · 에이전트 워크플로", "HARNESS & AGENTS",
            ["기획→디자인→구현", "점진 QA", "배포 오케스트레이션"])

    s = slide(); content_header(s, "05 · 하네스 · 에이전트", "에이전트 파이프라인 개요", 5)
    steps(s, 0.55, 1.55, 12.23, [
        ("기획", "planner", PURPLE),
        ("설계", "AA/SA/TA/DA/NA", BLUE),
        ("디자인", "designer", PURPLE),
        ("구현", "backend·front·db·domain", BLUE),
        ("검증", "kintex-qa", GREEN),
        ("배포", "devops-dev", BLUE_DK),
    ])
    titled_card(s, 0.55, 2.65, 6.05, 2.7, "문서 소유권·경유", BLUE, [
        ("PLANNING.md", "범위 권위 — planner만 수정"),
        ("design.md", "화면 스펙 — designer 경유"),
        ("src/", "PLANNING·design 근거로 developer 구현"),
        ("나노바나나", "tools/nanobanana만 경유(visualizer)"),
        ("오케스트레이터", "kintex-impl / kintex-mobile 스킬로 조율"),
    ], item_size=9.6, gap=0.44)
    titled_card(s, 6.75, 2.65, 6.03, 2.7, "품질 게이트(점진 QA)", PURPLE, [
        ("모듈 완성 직후", "경계면·공간 로직·보안 불변·빌드 회귀"),
        ("경계면", "백엔드 shape ↔ 프론트 호출 교차 비교"),
        ("보안 불변", "자격증명·PII·스택트레이스 미노출 강제"),
        ("AI 워터마크", "이미지 워터마크·응찰 게이트 검증"),
        ("반려 기준", "compileJava·tsc 통과까지 반려"),
    ], item_size=9.6, gap=0.44)
    banner(s, 5.6, "브랜치 main 보호 · Conventional Commits(feat/fix/docs…) · 코드 식별자·커밋 제목은 영어, 설명은 한국어 허용.",
           fill=BLUE_DK, size=11, h=0.5)

    # ---- 06 보안 코딩 ----
    divider("06", "보안 코딩 규칙", "SECURE CODING",
            ["보안 불변(위반 시 반려)", "AI 워터마크", "자격증명·오류 응답"])

    s = slide(); content_header(s, "06 · 보안 코딩 규칙", "보안 불변 (위반 시 QA 반려)", 6)
    titled_card(s, 0.55, 1.5, 6.05, 3.9, "지켜야 할 규칙", BLUE_DK, [
        ("자격증명 미노출", "IP·SSH·비번·해시·API키·OTP 시크릿 미노출"),
        ("민감 필드 제외", "사용자/업체 응답은 비민감 필드만"),
        ("스택트레이스 차단", "error.message는 요약만 · 상세는 서버 로그"),
        ("AI 이미지 워터마크", "watermarkRequired+watermarkText+notice 항상"),
        ("등록업체 응찰", "미등록 CONTRACTOR NOT_REGISTERED_COMPANY 403"),
        ("외부 API 금지", "예외 = Claude(env·Ollama 폴백)·Gemini(워커·G1)"),
        ("암호화 저장", "비밀 AES-256-GCM · 비밀번호 BCrypt 해시"),
    ], item_size=9.3, gap=0.4)
    titled_card(s, 6.75, 1.5, 6.03, 3.9, "코드에서 이렇게", PURPLE, [
        ("키 로드", "env에서만 — 코드·DB·커밋·로그 기록 금지"),
        ("Gemini", "워커에서만 로드 · 백엔드는 큐 발행/콜백만"),
        ("admin 비번", "ADMIN_PASSWORD_ENC 복호 → 기동 시 BCrypt 재시드"),
        ("오류 핸들러", "GlobalExceptionHandler·DataAccessException로 누출 차단"),
        ("gitignore", ".env·*.key·*-adminsdk-*.json 커밋 차단"),
        ("워커 실패", "errorMessage는 요약만(스택 유입 차단)"),
        ("감사로그", "민감 액션 기록 · 비밀값 미기재"),
    ], item_size=9.3, gap=0.4)
    banner(s, 5.6, "체크(grep) — 응답·로그·커밋에 IP·SSH·비번·해시·API키·워커토큰 노출 0. 위반은 QA 반려 사유입니다.",
           fill=RED, size=11, h=0.5)

    return PRS

# =====================================================================
# 저장
# =====================================================================
if __name__ == "__main__":
    out_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))  # ...\지침서
    targets = [
        ("사용자지침서.pptx", build_user),
        ("운영자지침서.pptx", build_ops),
        ("개발자지침서.pptx", build_dev),
    ]
    for fname, fn in targets:
        prs = fn()
        path = os.path.join(out_dir, fname)
        prs.save(path)
        print(f"SAVED: {path}  (slides={len(prs.slides._sldIdLst)})")
