genetics-mcp 2.9.1 → 2.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,891 +1,474 @@
1
1
  # 콘텐츠 파이프라인 — 데이터 모델과 원고 규칙
2
2
 
3
- 내부 작업 기록을 **고객이 읽고 검수할 수 있는 단일 HTML**로 바꾸는 쪽의 사양이다.
4
- 색·타이포는 `references/design-system.md`, 편집기 마크업 계약은 `references/wysiwyg.md`,
5
- 폰트는 `references/font-embedding.md`가 담당한다. 여기서는 **무엇을 어떤 필드에 적고,
6
- 빌더가 그것을 어떤 규칙으로 조판하는가**만 다룬다.
3
+ 내부 작업 기록을 **관보 서식의 고객 문서**로 바꾸는 쪽의 사양임.
4
+ 다루는 것은 다섯 가지임 — 자료 수집 · 절과 건 번호 · 한국어 원고 규칙 · 고객 언어 치환(제목 재작성 포함) · 어휘 게이트.
7
5
 
8
- 새 프로젝트에서 손대는 파일은 둘뿐이다.
6
+ **조판 규칙은 여기 적지 않음.**
7
+ 활자 스케일 · 괘선 · 간격 격자 · 글줄 폭 근거(`1ch` · `--measure`) · 인쇄 페이지 문법 · 검사기 8종은 전부 `gazette-report` 스킬이 원전임.
8
+ 두 벌로 두면 갈라짐.
9
9
 
10
- | 파일 | 담는 것 | 스키마 |
11
- |---|---|---|
12
- | `report.json` | 문서 뼈대 — 표지·절 구성·요약·확인 요청·등급 정의·치환표·금지어 | 2장 |
13
- | `cases.json` | 건별 사실 — 증상·원인·조치·확인 방법·확인 요청 | 3장 |
14
-
15
- `assets/build.py`는 보통 손대지 않는다. 프로젝트마다 달라지는 것은 전부 위 두 파일로 빠져 있다.
16
-
17
- ```bash
18
- python3 ~/.claude/skills/genetics-report/assets/build.py report.json # → cfg["out"]
19
- python3 ~/.claude/skills/genetics-report/assets/qa.py report.json # 실패 0 이어야 통과
20
- ```
10
+ | 찾는 것 | 어디 |
11
+ |---|---|
12
+ | 문서 골격 · 표지 칸 · `kind` 별 본문 장치 · 장 색(`tone`) · PART · 요약 근거 게이트 | `references/lim-portal-format.md` |
13
+ | 활자 · 괘선 · 인쇄 · 검사기 실행법 | `gazette-report` 스킬 |
14
+ | 수집 절차 · 독자 정의 · 작업 순서 | `references/process.md` · SKILL.md 0단계 |
15
+ | 폰트 서브셋 · 커버리지 | `references/font-embedding.md` |
16
+ | 이 문서 | 각 칸에 **무엇을 적는가**, 그리고 그 글을 **어떻게 쓰는가** |
21
17
 
22
- > **고유명사 주의.** 이 문서의 예시에 나오는 고객사·수행사 이름·Figma,
23
- > 그리고 모든 증상·건명·수치는 **하나의 실제 프로젝트에서 가져온 예시**다(18건짜리 실사례,
24
- > 그 프로젝트의 `report/report.json` · `cases.json`).
25
- > 규칙만 가져가고 이름은 각자의 것으로 갈아 끼운다.
18
+ > 예시는 전부 `assets/example/report.json` · `assets/example/cases.json` 에서 인용함.
19
+ > 그 두 파일이 v2 데이터의 최소 완본이고, 이 문서의 규칙과 짝임.
26
20
 
27
21
  ---
28
22
 
29
23
  ## 0. 가장 먼저 정의할 것 — 독자
30
24
 
31
- 다른 모든 규칙이 여기서 연역된다. 이 단계를 건너뛰면 나머지는 그냥 취향 목록이 된다.
25
+ 다른 모든 규칙이 여기서 연역됨.
26
+ 이 단계를 건너뛰면 나머지는 그냥 취향 목록이 됨.
32
27
 
33
- | 정의할 것 | 예시 프로젝트의 답 | 이 답이 결정하는 것 |
28
+ | 정의할 것 | 예시의 답 | 이 답이 결정하는 것 |
34
29
  |---|---|---|
35
- | 독자는 누구인가 | 고객사 검수 담당 — 읽고 **직접 확인해 볼** 사람 | 모든 건에 `how_to_check`가 사실상 필수인 이유 (3.3) |
36
- | 독자가 조회할 수 있는 참조는 무엇인가 | 자기가 올린 Figma 코멘트 **하나뿐** | `source_url`을 접수건에만 다는 이유 (3.4) |
37
- | 독자가 결정해 줘야 할 것이 있는가 | 있다 → 05장 `open_items` + 건별 `remaining` | 문서에 CTA 절이 존재하는 이유 (2.5) |
38
- | 독자가 알 필요 없는 것은 무엇인가 | 릴리스 절차, 티켓 운영, 채택 안 된 설계안 | `text_fix`·`forbid` 규칙 전체 (6장) |
30
+ | 독자는 누구인가 | 고객사 검수 담당 — 읽고 **직접 확인해 볼** 사람 | 모든 건에 `how_to_check` 가 사실상 필수인 이유 (1.1) |
31
+ | 독자가 조회할 수 있는 참조는 무엇인가 | 자기가 올린 제보 원본 **하나뿐** | `source_url` 을 접수건에만 다는 이유 (1.2) |
32
+ | 독자가 결정해 줘야 할 것이 있는가 | 있음 → `open_items` + 건별 `remaining` | 문서에 `kind:"asks"` 절이 존재하는 이유 |
33
+ | 독자가 알 필요 없는 것은 무엇인가 | 릴리스 절차 · 티켓 운영 · 채택 안 된 설계안 | 4장 치환 규칙 전체 |
39
34
 
40
- **핵심 논리:** 내부 티켓 번호를 빼는 이유는 "기밀"이 아니라 **"무의미"**다. 고객은 그 번호를
41
- 조회할 수 없다. 조회할 수 없는 참조를 실으면 문서가 독자가 아니라 자기 조직을 향해 말하는
42
- 꼴이 된다. 이 잣대 — *독자가 그것으로 무엇을 할 수 있는가* — 가 6·7장 모든 결정의 기준이다.
35
+ **핵심 논리.**
36
+ 내부 티켓 번호를 빼는 이유는 「기밀」이 아니라 **「무의미」**임.
37
+ 고객은 그 번호를 조회할 수 없음.
38
+ 조회할 수 없는 참조를 실으면 문서가 독자가 아니라 자기 조직을 향해 말하는 꼴이 됨.
39
+ 이 잣대 — *독자가 그것으로 무엇을 할 수 있는가* — 가 4·5장 모든 결정의 기준임.
43
40
 
44
41
  ---
45
42
 
46
- ## 1. 파이프라인 전체 구조
47
-
48
- ```
49
- report.json ─┬─→ build.py ─→ 단일 HTML ─→ qa.py (exit 1 이면 게이트 실패)
50
- cases.json ──┘ │
51
- figure.html ────────┘ (선택, 원본 그대로 삽입)
52
- ```
53
-
54
- `build(cfg_path)` 한 함수가 전부를 조립한다. 순서는 고정이다.
55
-
56
- | 단계 | 하는 일 | 근거 |
57
- |---|---|---|
58
- | 1 | `report.json` 로드, `cfg['cases']`를 **report.json 위치 기준 상대경로**로 로드 | 설정 파일과 데이터가 함께 이동한다 |
59
- | 2 | `text_fix` 적용 — 메모리 안에서만 (6장) | 원본 JSON은 절대 수정하지 않는다 |
60
- | 3 | `groups`로 건을 나눠 절 번호 `{no}.{i}` 부여 (4장) | |
61
- | 4 | `sections` 순서대로 본문 조립, 각 텍스트는 `chunk()`/`link()` 통과 (5장) | |
62
- | 5 | `palette.derive()` → `:root` 주입, `audit()` 대비 검사 assert | 색을 손으로 고르지 않는다 |
63
- | 6 | CSS·JS·폰트를 인라인 → 외부 리소스 0 | 메일 첨부 한 장으로 열려야 한다 |
64
- | 7 | `§` 잔존·`forbid` 유출을 **assert로 검사** (8장) | 유출은 타협 불가 |
65
-
66
- ### 1.1 원본 불변 원칙
67
-
68
- ```python
69
- # build.py — build() 안, cases 로드 직후
70
- for c in cases:
71
- for k in fields:
72
- if c.get(k):
73
- for a, b in fixes:
74
- c[k] = c[k].replace(a, b)
75
- ```
76
-
77
- | 규칙 | 근거 | 어기면 생기는 일 |
78
- |---|---|---|
79
- | **빌드는 입력 JSON 파일을 절대 고쳐 쓰지 않는다.** 고객 언어로의 변환은 전부 빌드 시점 메모리 안에서 일어난다 | 내부 원본(배포 서술, 코드 규모 수치)이 JSON에 남아 있어야 나중에 "우리가 실제로 뭘 했는지"를 되짚을 수 있다. **고객본은 원본의 파생물이지 대체물이 아니다** | 원본을 직접 고치면 내부 사실이 영구 유실된다. 다음 회차에 "저장 경로가 몇 곳이었지?"를 물을 근거가 사라진다 |
80
- | **외부 리소스 0.** CSS/JS를 문자열로 읽어 인라인, 폰트는 base64 | 메일 첨부·사내망·오프라인에서 열려야 한다 | `qa.py`의 외부 리소스 검사가 실패로 잡는다. 고객 PC에서 스타일 없는 날 HTML이 열린다 |
81
-
82
- ### 1.2 색은 손으로 고르지 않는다
83
-
84
- `assets/palette.py`가 팔레트 6색(primary·secondary·bg·surface·ink·mid)에서 문서용 토큰을
85
- **유도**하고, `audit()`이 **17쌍의 대비를 되잰다.** 9개 팔레트 전부가 통과한다.
86
- `build.py`가 그 결과를 `:root`로 주입하므로 `report.css`는 색 토큰을 선언하지 않는다.
87
-
88
- ```python
89
- tokens = P.derive(P.PALETTES[cfg.get('palette', 'korean-navy')] ...)
90
- bad = [r for r in P.audit(tokens) if not r[5]]
91
- assert not bad, f'팔레트 대비 미달: {[(r[0], r[3]) for r in bad]}'
92
- ```
93
-
94
- 콘텐츠 쪽에서 알아야 할 것은 하나다. **`--accent`는 배경·괘선 전용이고 글자로 쓰지 않는다.**
95
- 글자는 `--accent-mid`(24px+ Bold, 3:1) / `--accent-ink`(작은 글자, 4.5:1),
96
- 칩은 `--accent-chip` + `--accent-on` 쌍으로만 쓴다. 자세한 역할은 `references/design-system.md`.
43
+ ## 1. 자료 수집 — `cases.json`
97
44
 
98
- ---
45
+ 평면 배열임.
46
+ **배열 순서가 곧 문서 내 정렬 순서**이고, 그룹 안에서 1부터 건 번호가 붙음.
99
47
 
100
- ## 2. `report.json` 스키마
48
+ ### 1.1 다섯 칸 — 이것이 본체다
101
49
 
102
- **필수**는 없으면 빌드가 `KeyError`로 죽는 키, **조건부 필수**는 특정 `sections[].kind`가
103
- 있을 때만 필요한 키, **선택**은 `.get()`으로 읽어 기본값이 있는 키다.
50
+ 건마다 **증상 · 원인 · 조치 · 확인 방법 · 확인 요청** 다섯 칸을 채움.
51
+ 관보 렌더러는 이 다섯을 `.sheet__grid` 표의 다섯 행으로 세움(`gazette.py:445-457`).
104
52
 
105
- ### 2.1 최상위 키 전체
106
-
107
- | 키 | 필수 | 타입 | 의미 · 기본값 |
53
+ | 칸 | 필드 | 독자 관점의 뜻 | 비면 |
108
54
  |---|---|---|---|
109
- | `out` | 필수 | str | 산출 HTML 경로. **report.json 위치 기준 상대경로** |
110
- | `cases` | 필수 | str | 건 배열 JSON 경로. 같은 기준 |
111
- | `title_tag` | 필수 | str | `<title>` — 브라우저 탭·파일 저장 이름 |
112
- | `kicker` | 필수 | str | 표지 상단 소제목. 예: `"수행사 · 고객사"` |
113
- | `title` | 필수 | str | 표지 대제목 |
114
- | `subtitle` | 필수 | str | 표지 부제 — 이 문서가 무엇인지 한 문장 |
115
- | `meta` | 필수 | `[[k, v], …]` | 표지 하단 정의 목록. **2원소 배열의 배열**(dict 아님) |
116
- | `grades` | 필수 | `{라벨: [클래스, 설명]}` | 확인 등급. **선언 순서 = 강도 순서** (2.3) |
117
- | `groups` | 필수 | `[{key, no}]` | 건 분류와 절 번호 (4.1) |
118
- | `sections` | 필수 | `[{no, id, name, kind, …}]` | 문서 순서대로의 절 정의 (2.2) |
119
- | `summary` | 조건부 | `[{lead, body}]` | `kind:"summary"` 절이 있으면 필수 |
120
- | `open_items` | 선택 | `[{title, body, refs?}]` | `kind:"asks"` 절의 본문. 기본 `[]` |
121
- | `cta` | 선택 | `[str]` | `kind:"asks"` 절 맨 위 Primary 블록. **HTML 그대로 통과** (2.5) |
122
- | `stats` | 선택 | `[{value, label, unit?, accent?}]` | 첫 절 앞 지표 밴드. 기본 없음 |
123
- | `figure` | 선택 | str | `kind:"summary"` 절 끝에 삽입할 HTML 조각 경로 (2.6) |
124
- | `footer` | 선택 | `[str]` | 꼬리말 조각들. 기본 `[]` |
125
- | `labels` | 선택 | `{키: 문구}` | 고정 문구 재정의 (2.7). 기본 `{}` |
126
- | `text_fix` | 선택 | `[{from, to}]` | 고객 언어 치환 (6장). 기본 `[]` |
127
- | `forbid` | 선택 | `[정규식]` | 산출물 금칙어. 걸리면 **빌드 실패** (8장) |
128
- | `allow_external` | 선택 | `[str]` | 허용할 외부 URL 부분문자열. **`qa.py`만 읽는다** |
129
- | `palette` | 선택 | str \| dict | 팔레트 이름 또는 6색 dict. 기본 `"korean-navy"` |
130
- | `font` | 선택 | dict | `{embed, file, family, weight, license}` (2.8) |
131
- | `lang` | 선택 | str | `<html lang>`. 기본 `"ko"` |
132
- | `description` | 선택 | str | `<meta name="description">`. 기본 `""` |
133
- | `editor` | 선택 | bool | `<body data-report-edit>`. 기본 `true` |
134
- | `nav_width` | 선택 | str | 좌측 목차 폭 `--nav-w`. 기본 `"224px"` |
135
- | `pad_x` | 선택 | str | 좌우 여백 `--pad-x`. 기본 `"clamp(20px,4vw,68px)"` |
136
-
137
- ### 2.2 `sections[]` — 절 정의
138
-
139
- 문서에 나오는 순서 그대로 적는다. 목차·본문이 이 배열 하나에서 나온다.
140
-
141
- | 필드 | 필수 | 의미 |
142
- |---|---|---|
143
- | `no` | 필수 | 화면에 찍히는 절 번호 문자열. 예: `"01"`. 번호 없는 절은 `""` |
144
- | `id` | 필수 | HTML `id` = 목차 링크 대상. 예: `"sec-3"` |
145
- | `name` | 필수 | 절 제목. 예: `"접수하신 9건"` |
146
- | `kind` | 필수 | 아래 5종 중 하나 |
147
- | `group` | `kind:"cases"`일 때 필수 | 어느 `groups[].key`의 건을 여기에 펼칠지 |
148
- | `lede` | 선택 | 절 제목 아래 한 문단. `link()`를 통과하므로 `§` 참조를 쓸 수 있다 |
149
- | `paragraphs` | `kind:"note"`일 때 필수 | 문단 배열 |
55
+ | 증상 | `symptom` | 「화면에서 무엇이 잘못 보였나」 — 제보자가 자기 경험과 대조할 유일한 앵커 | 그 행이 통째로 빠짐 |
56
+ | 원인 | `cause` | 「왜 그랬나」 — 조치를 신뢰할지 판단할 근거 | 같음 |
57
+ | 조치 | `fix` | 「무엇을 바꿨고 어디까지 확인했나」 — 본문 최장 | 같음 |
58
+ | 확인 방법 | `how_to_check` | **독자가 직접 할 일.** 「이 순서로 열어보면 확인된다」 | 같음 |
59
+ | 확인 요청 | `remaining` | **독자가 결정해 줘야 할 일.** 대조표의 「미결」 열이 이것으로 갈림 | 그 행이 빠지고 대조표는 「완료」로 표시 |
150
60
 
151
- **`kind` 6종** — 문서 아키타입(`references/design-system.md`)과 1:1로 대응한다.
152
-
153
- | kind | 렌더 | 읽는 설정 |
154
- |---|---|---|
155
- | `summary` | `.ex-grid` — `lead`(단언) + `body`(부연) 카드들, 이어서 `figure` | `cfg['summary']`, `cfg['figure']` |
156
- | `status` | 등급 범례 + 전건 판정표 `<table class="verdict">` | `cfg['grades']`, 전 건 |
157
- | `cases` | 그 그룹의 건 카드들 | `cfg['groups']`, cases 파일 |
158
- | `asks` | `cta` 블록 + `open_items` 카드들 | `cfg['cta']`, `cfg['open_items']` |
159
- | `note` | `.appendix` — 회색 작은 활자 꼬리말 문단 | `s['paragraphs']` |
160
- | `lesson` | 교과서 본문 — 개념·예제·정리·코드·그림 블록 | `s['blocks']` — 스키마는 `references/textbook-mode.md` |
161
-
162
- | 규칙 | 근거 | 어기면 생기는 일 |
61
+ | 규칙 | 근거 | 어기면 |
163
62
  |---|---|---|
164
- | `kind:"cases"` 절의 `no`는 **정수로 파싱 가능**해야 한다 | 빌드가 `data-secno="{int(s['no'])}"`를 찍고, 편집기 `renumber()`가 그 값으로 건 번호를 다시 매긴다 | `int()`가 `ValueError`로 죽는다 |
165
- | `kind:"cases"` 절의 `no`는 대응 `groups[].no`와 **같아야** 한다 | 빌드는 `groups[].no`로 번호를 찍고, 편집기는 `data-secno`(=`sections[].no`)로 다시 찍는다. 두 출처가 다르면 값이 갈린다 | 문서를 편집기로 **여는 순간** 모든 건 번호가 조용히 바뀐다(`renumber()`가 초기화 때 한 번 돈다). 전달본과 열어 본 문서의 번호가 달라진다 |
166
- | `kind:"cases"` 절의 순서 = `groups` 배열 순서 | 판정표는 `groups` 순서로, 목차 `<ol>`은 `sections` 순서로 만들어진다. 편집기의 `syncVerdict()`는 **문서 순서**로 판정표를 다시 만든다 | 두 순서가 어긋나면 판정표만 거꾸로 나오고, 편집기에서 한 글자만 고쳐도 표가 조용히 재정렬된다 |
167
- | `id`는 문서 안에서 유일 | 목차 링크 대상 | 목차가 엉뚱한 곳으로 간다 |
168
-
169
- ### 2.3 `grades` — 확인 등급
170
-
171
- ```json
172
- "grades": {
173
- "운영 환경 확인": ["m-live", "운영 환경에서 실제로 동작시켜 결과까지 확인"],
174
- "실데이터 대조": ["m-data", "운영 데이터를 건별로 대조해 확인"],
175
- "재발 점검 적용": ["m-test", "같은 문제가 다시 생기면 걸러지도록 상시 점검 적용"],
176
- "반영 확인": ["m-code", "수정이 운영에 반영된 것까지 확인 (화면 재현은 미실시)"]
177
- }
178
- ```
63
+ | 모든 건에 `how_to_check` 를 채움 | 고객이 자기 화면에서 직접 밟을 수 있는 순서가 없으면 검수 요청이 아니라 통보임 | **렌더러가 조용히 그 행만 빼고 표를 세움.** 전 건 중 하나만 반쪽인 것을 눈으로는 못 찾음 |
64
+ | `remaining` 은 **우리가 끝낼 수 없어 상대의 판단·확인이 필요한 것만.** 없으면 비움 | 대조표의 「미결/완료」가 독자의 할 일 목록임(`gazette.py:497`) | 전건에 뭔가 적으면 그 열이 무의미해짐 |
65
+ | 다섯 칸이 다 비면 그 건은 **아예 안 실림** | `if not rows: continue`(`gazette.py:465`) | 건수는 세는데 문서에는 없음 |
179
66
 
180
- 키가 화면에 찍히는 라벨이고, 값은 `[CSS 클래스, 설명문]`이다. 클래스는 `report.css`에
181
- 정의된 `m-live` / `m-data` / `m-test` / `m-code` 넷을 쓴다(4단계를 넘기려면 CSS도 늘려야 한다).
67
+ ### 1.2 나머지 칸
182
68
 
183
- | 규칙 | 근거 | 어기면 생기는 일 |
69
+ | 필드 | 필수 | 뜻 |
184
70
  |---|---|---|
185
- | **등급 라벨을 처음부터 고객 언어로 짓는다.** 내부 절차명(`코드 확인`·`자동 검사`)을 그대로 쓰지 않는다 | `코드 확인`은 "코드만 보고 끝냈다"로 읽혀 **실제보다 약하게** 들리고, 무엇을 확인했는지도 알려주지 않는다. `반영 확인`은 확인한 범위를 말한다 | 가장 약한 등급이 실제보다 더 약해 보여 문서 전체 신뢰가 깎인다 |
186
- | 각 등급 설명에 **한계까지 적는다** — `"(화면 재현은 미실시)"` | **등급 체계의 신뢰는 가장 약한 등급이 자기 약점을 스스로 밝힐 때 생긴다** | 등급이 자화자찬 라벨이 된다. 독자가 등급을 안 믿으면 등급 체계 전체가 무의미해진다 |
187
- | **선언 순서 = 근거 강도 순서** | 범례는 `for k in grades if mc.get(k)` — dict 선언 순서를 그대로 따른다. 건수 순이 아니라 강도 순이라 독자가 "가장 확실한 것부터" 읽는다 | 건수 순으로 두면 우연히 많은 등급이 맨 위에 와 강도 서열이 안 보인다 |
188
- | `status` 절 `lede`에서 등급의 의미를 **문서 안에 명문화** | 예시: `"등급은 저희가 어디까지 직접 확인했는지를 뜻하며, 확인하지 못한 범위는 각 건과 05에 그대로 적었습니다."` | 등급의 의미를 독자가 추측하게 된다 |
189
- | 건수 0인 등급은 범례에서 자동으로 빠진다 | `if mc.get(k)` | — |
71
+ | `group` | **필수** | `groups[].key` 중 하나. MCP 스키마에서도 유일한 필수 칸임 |
72
+ | `title` | 사실상 필수 | 보고서 표제. **티켓 제목이 아니라 다시 쓴 문장** (5장) |
73
+ | `grade` | 선택 | 확인 등급 — `grades` 의 키와 글자가 같아야 함 |
74
+ | `source_url` | 선택 | 고객이 **아는 유일한 외부 참조.** `http(s)` 가 아니면 서버가 거절 |
75
+ | `source_text` | 선택 | 그 링크의 글자. 생략하면 `제보 원본 보기` |
190
76
 
191
- `cases[].grade` 값이 `grades`에 없으면 `KeyError`로 즉사한다. 오타 방지책은 이것뿐이다.
77
+ `grade` 값이 `grades` 에 없어도 **죽지 않음** — 자리 판별이 실패해 가장 약한 표식으로 떨어짐(`gazette.py:751-762`).
78
+ 오타가 조용히 넘어가는 자리라 발행 뒤 눈으로 확인해야 함.
192
79
 
193
- ### 2.4 `summary` — 01 요약
80
+ `group` 이 `groups[].key` 중 하나가 아니면 그 건은 **어디에도 안 실림.**
81
+ 렌더러는 ⚠ 로 세고(`gazette.py:786`), 서버는 발행을 거절함(`report_publish.php:190`).
194
82
 
195
- ```json
196
- {"lead": "접수해 주신 9건은 모두 조치를 마치고 운영에 반영했습니다.",
197
- "body": "성격은 세 갈래였습니다. … 문제(§3.1~§3.2), … 문제(§3.3, §3.4, …)입니다."}
198
- ```
83
+ ### 1.3 스키마에 **넣지 않는** 것
199
84
 
200
- | 필드 | 필수 | 렌더 |
201
- |---|---|---|
202
- | `lead` | 필수 | `.ex-lead` — 굵은 한 문장 단언 |
203
- | `body` | 필수 | 부연 한 문단 |
204
-
205
- 둘 다 `link(e(...))`만 통과한다 — **`chunk()`를 거치지 않는다**(5.6). `§` 참조를 쓸 수 있다.
206
-
207
- **4칸으로 쓴다.** 검수자가 첫 화면에서 판단을 끝낼 수 있어야 한다.
208
-
209
- 1. 접수한 것은 어떻게 됐는가
210
- 2. 우리가 추가로 찾은 것 중 중요한 것
211
- 3. 여러 건을 관통하는 공통 원인 (있으면 `figure`로 그림)
212
- 4. 상대가 무엇을 하면 되는가
213
-
214
- `lead`는 **완전문**으로 쓴다. 여기가 판단이 일어나는 자리다(SKILL.md 카피 게이트).
215
-
216
- ### 2.5 `open_items` · `cta` — 05 확인 요청
217
-
218
- ```json
219
- {"title": "검증에 쓴 표본이 작습니다",
220
- "body": "화자 구분 정확도로 말씀드린 92.3%는 상담 녹음 한 건으로 잰 수치입니다. …",
221
- "refs": ["§3.6", "§4.9"]}
222
- ```
223
-
224
- | 필드 | 필수 | 통과하는 파이프 | 주의 |
225
- |---|---|---|---|
226
- | `title` | 필수 | `e()` **만** | **`link()`를 타지 않는다.** 여기에 `§`를 쓰면 8장의 `assert '§' not in doc`에 걸려 빌드가 실패한다. 참조는 `refs`에 넣는다 |
227
- | `body` | 필수 | `link(e(...))` | `§` 참조 가능 |
228
- | `refs` | 선택 | 각 원소가 `link(e(...))` | `["§3.6", "§4.9"]` → `해당 절 — 3.6 · 4.9` |
229
-
230
- `cta`는 문자열 배열이고 **`e()`를 거치지 않는다** — `link(p)`만 통과한다.
231
-
232
- | 규칙 | 근거 | 어기면 생기는 일 |
233
- |---|---|---|
234
- | `cta` 문자열 안의 `<`·`&`는 **직접 이스케이프**한다 | 이 자리는 `<strong>`으로 한 구절만 강조하려고 raw HTML을 허용한 곳이다 | 태그가 깨지거나 의도치 않은 마크업이 열린다 |
235
- | `cta`에서 강조하는 것은 **상대가 할 일**과 **파괴적 동작 경고**뿐 | 예시: `"[발송] 버튼은 실제로 고객에게 알림톡이 나가므로"` — 검수자가 확인하다가 실제 발송을 일으키는 사고를 막는다 | 안내 문서가 사고를 유발한다 |
236
- | `title`은 **완전문**으로 | 여기가 판단이 일어나는 자리다. `"표본 부족"` 같은 명사구는 심각도를 감춘다 | 독자가 "그래서 어쩌라는 건가"를 되묻는다 |
237
-
238
- ### 2.6 `stats` · `figure`
239
-
240
- ```json
241
- {"value": "6", "unit": "가지", "label": "확인을 부탁드리는 사항", "accent": true}
242
- ```
243
-
244
- | 필드 | 필수 | 의미 |
245
- |---|---|---|
246
- | `value` | 필수 | 큰 숫자 |
247
- | `unit` | 선택 | 숫자 뒤 작은 단위. 기본 `""` |
248
- | `label` | 필수 | 아래 설명 한 줄 |
249
- | `accent` | 선택 | `true`면 `.stat.is-open` — 값이 `--accent-ink`로 바뀐다. **"상대가 해야 할 일" 칸 하나에만** |
250
-
251
- `figure`는 HTML 조각 파일 경로다. `kind:"summary"` 절 끝에 **원본 그대로** 삽입된다.
252
-
253
- | 규칙 | 근거 | 어기면 생기는 일 |
85
+ | 넣지 않는 것 | 이유 | 대신 |
254
86
  |---|---|---|
255
- | 인라인 SVG의 색은 **전부 `var(--토큰)`**으로 쓴다 | 인라인 SVG는 사용자 정의 속성을 상속한다. 팔레트를 갈아 끼워도 그림이 따라온다 | `qa.py`의 "팔레트 밖 하드코딩 색" 검사가 경고를 올리고, 팔레트를 바꾸면 그림만 옛 색으로 남는다 |
256
- | `<svg>`에 `role="img"` + `aria-label`을 단다 | 도형이 말하는 구조를 글로도 남긴다 | 접근성 검사와 스크린리더에서 그림이 빈칸이 된다 |
87
+ | 내부 티켓 번호 | 고객이 조회할 수 없는 참조 (0장) | `forbid` 에 번호 패턴을 등재해 유출을 빌드가 막음 (4.3) |
88
+ | 이슈 트래커 이름 | 같은 이유 | `forbid` 에 `[Aa]sana` 같은 패턴 |
89
+ | 원본 티켓 제목 | 검색용 문자열이지 읽기용 문장이 아님 (5장) | `title` 을 처음부터 표제로 씀 |
90
+ | 근거 수치 별도 필드 | `fix` 본문에 이미 녹아 있음. 중복 필드는 아무도 안 읽는데 채워야 하는 칸이 됨 | `fix` 안에 서술 |
257
91
 
258
- ### 2.7 `labels` — 고정 문구 재정의
92
+ > **이식 규칙.**
93
+ > 새 프로젝트의 스키마는 **렌더러가 실제로 읽는 필드만**으로 정의함.
94
+ > 나머지는 작업 중 메모로 다른 파일에 분리하거나 지움.
95
+ > 안 그러면 다음 사람이 아무도 읽지 않는 필드를 건마다 채우는 낭비를 함.
259
96
 
260
- 빌드가 찍는 한국어 고정 문구는 전부 `labels`로 덮을 수 있다. 키와 기본값 전체:
97
+ ### 1.4 `grades` 의 값 배열에서 관보가 읽는 것
261
98
 
262
- | 키 | 기본값 | 나오는 곳 |
263
- |---|---|---|
264
- | `toc` | `목차` | 좌측 목차 제목 · `<nav aria-label>` |
265
- | `symptom` | `증상` | 건 카드 |
266
- | `cause` | `원인` | 건 카드 |
267
- | `fix` | `조치` | 건 카드 |
268
- | `check` | `확인 방법` | 건 카드 |
269
- | `ask` | `확인 요청` | 건 카드 |
270
- | `source` | `제보 원본 보기` | 건 카드 외부 링크 (`↗`는 코드가 붙인다) |
271
- | `col_sec` | `절` | 판정표 열 머리 |
272
- | `col_title` | `건명` | 판정표 열 머리 |
273
- | `col_grade` | `확인 등급` | 판정표 열 머리 |
274
- | `col_open` | `확인 요청` | 판정표 열 머리 |
275
- | `refs` | `해당 절` | `open_items[].refs` 앞머리 |
276
-
277
- `labels`로 덮을 수 **없는** 문구: 판정표 본문의 `있음`/`—`, 범례의 `N건`. 다른 언어로 낼 일이
278
- 생기면 `build.py`를 고쳐야 한다.
279
-
280
- ### 2.8 `font`
281
-
282
- ```json
283
- "font": {"embed": true, "file": "fonts/pretendard-report.woff2",
284
- "family": "Pretendard", "weight": "400 700", "license": "…"}
285
- ```
99
+ 등급을 **어떻게 매기는가**는 `references/process.md` 3단계가 원전임.
100
+ 여기서는 데이터 모양만 적음.
286
101
 
287
- `file`은 **`assets/` 디렉터리 기준** 상대경로다(report.json 기준이 아니다).
288
- `embed:false`로 끄면 시안 반복이 빨라진다. 서브셋을 다시 만드는 법·커버리지·왜 가변 1종인지는
289
- `references/font-embedding.md`에 있다. **여기서 다시 설명하지 않는다.**
102
+ `grades` 는 `{이름: [클래스, 설명]}` 이고 **키 선언 순서가 곧 강도 순서**임(`gazette.py:80-83`).
290
103
 
291
- | 함정 | 내용 |
104
+ | 사실 | 귀결 |
292
105
  |---|---|
293
- | `embed:true`인데 `file` 경로가 없으면 | 빌드는 **조용히 건너뛰고** `폰트 임베드 생략`만 찍는다. `qa.py`의 `@font-face 임베드 존재` 검사가 실패로 잡는다 — 그래서 빌드 로그를 눈으로 확인하고 QA를 반드시 돌린다 |
106
+ | 값 배열의 0번(클래스)·1번(설명)을 관보는 **안 읽음** | 등급 설명문을 문서에 실으려면 `appendix` 나 `guide` 에 사람이 따로 적어야 함 |
107
+ | 값 배열 2·3번에 이름을 주면 자리 판별보다 그것이 이김 | 4단을 넘길 때만 씀 |
108
+ | **등급 라벨을 처음부터 고객 언어로 짓는다** | `코드 확인`은 「코드만 보고 끝냈다」로 읽혀 **실제보다 약하게** 들림. `반영 확인`은 확인한 범위를 말함 |
294
109
 
295
110
  ---
296
111
 
297
- ## 3. `cases.json` 스키마
298
-
299
- 평면 배열이다. **배열 순서가 곧 문서 내 정렬 순서**이며, 그룹 안에서 1부터 절 번호가 붙는다.
112
+ ## 2. 절 번호와 상호참조
300
113
 
301
- 예시 프로젝트의 18개 객체는 전부 아래 9키를 갖는다(값이 없으면 빈 문자열).
302
- 빌드는 `if c.get(k)` 진릿값으로 판정하므로 **빈 문자열 == 없음**으로 동작한다.
114
+ ### 2.1 번호는 렌더러가 매긴다 — 데이터가 정하지 않는다
303
115
 
304
- ### 3.1 필드 전체
116
+ **v1 과 가장 크게 달라진 자리임.**
305
117
 
306
- | 필드 | 필수 | 독자 관점의 의미 | 빌드 사용처 |
307
- |---|---|---|---|
308
- | `group` | **필수** | 이 건이 어느 절에 속하는가 | `groups[].key`와 대조 → 절 번호 |
309
- | `title` | **필수** | 보고서 표제. **티켓 제목이 아니라 다시 쓴 문장** (7장) | 건 카드 `<h3>`, 목차, 판정표 |
310
- | `grade` | **필수** | 확인 등급 — "얼마나 믿어도 되는가" | 칩·범례·판정표. `grades` 키와 일치해야 함 |
311
- | `symptom` | **필수** | "화면에서 무엇이 잘못 보였나" — 제보자가 자기 경험과 대조할 유일한 앵커 | `.row.r-sym` |
312
- | `cause` | **필수** | "왜 그랬나" — 조치를 신뢰할지 판단할 근거. 증상과 나란히(`.dx`) | `.row.r-cause` |
313
- | `fix` | **필수** | "무엇을 바꿨고 어디까지 확인했나" — 본문 최장. 청킹의 주 대상 | `.row.r-fix` |
314
- | `how_to_check` | 선택(실질 필수) | **독자가 직접 할 일.** "이 순서로 열어보면 확인된다" | `.row.r-check` |
315
- | `remaining` | 선택 | **독자가 결정해 줘야 할 일.** 라벨이 `확인 요청`인 것이 핵심 | `.row.r-open`, 판정표 `확인 요청` 열 |
316
- | `source_url` | 선택 | 고객이 **아는 유일한 외부 참조** | `제보 원본 보기 ↗` |
317
-
318
- `title`·`grade`·`symptom`·`cause`·`fix`는 대괄호 접근이라 **키가 없으면 `KeyError`**로 빌드가 죽는다.
319
- `group`만은 예외로 `.get()`이다(`[c for c in cases if c.get('group') == g['key']]`) — 키가 없으면
320
- 그 건이 **어느 그룹에도 안 잡히고**, 바로 뒤의 `assert placed == len(cases)`가
321
- `그룹에 배정되지 않은 건이 있다: 17/18`로 잡는다. 죽는 지점이 다를 뿐 결과는 같다.
322
- 나머지 셋(`how_to_check`·`remaining`·`source_url`)은 `.get()`이라 없어도 빌드가 지나간다.
323
-
324
- ### 3.2 스키마에 **넣지 않는** 것
325
-
326
- | 넣지 않는 것 | 이유 | 대신 |
327
- |---|---|---|
328
- | 내부 티켓 번호 | 고객이 조회할 수 없는 참조 (0장) | `forbid`에 번호 패턴을 등재해 유출을 빌드가 막는다 (8장) |
329
- | 이슈 트래커 이름 | 같은 이유 | `forbid`에 `[Aa]sana` 같은 패턴 |
330
- | 원본 티켓 제목 | 검색용 문자열이지 읽기용 문장이 아니다 (7장) | `title`을 처음부터 표제로 쓴다 |
331
- | 근거 수치 별도 필드 | `fix` 본문에 이미 녹아 있다. 중복 필드는 아무도 안 읽는데 채워야 하는 칸이 된다 | `fix` 안에 서술 |
332
-
333
- > **이식 규칙:** 새 프로젝트의 스키마는 **빌드가 실제로 읽는 필드만**으로 정의한다.
334
- > 나머지는 "작업 중 메모"로 다른 파일에 분리하거나 지운다. 안 그러면 다음 사람이
335
- > 아무도 읽지 않는 필드를 건마다 채우는 낭비를 한다.
336
-
337
- ### 3.3 `how_to_check` — 빌드는 봐주고 QA는 안 봐준다
338
-
339
- | 규칙 | 근거 | 어기면 생기는 일 |
340
- |---|---|---|
341
- | 모든 건에 `how_to_check`를 채운다 | **고객이 자기 화면에서 직접 밟을 수 있는 순서**가 없으면 검수 요청이 아니라 통보다 | 빌드는 통과하지만(`row()`가 빈 문자열이면 블록을 통째로 생략) `qa.py`의 구조 일관성 검사가 `확인 방법 17/18`로 잡아 **실패**시킨다. 눈으로는 18건 중 1건이 반쪽인 걸 못 찾는다 |
342
- | `remaining`은 **우리가 끝낼 수 없어 상대의 판단·확인이 필요한 것만.** 없으면 비운다 | 있는 건과 없는 건이 판정표 `확인 요청` 열에서 `있음`/`—`로 갈린다 — 이 열이 독자의 할 일 목록이다 | 전건에 뭔가 적으면 그 열이 무의미해진다 |
343
- | `how_to_check`와 `remaining` 중 하나만 있으면 `.ck.one`으로 1단 렌더 | 빈 칸이 남지 않는다 | — |
344
-
345
- ### 3.4 필드 설계에서 배울 것
346
-
347
- | 결정 | 근거 | 어기면 생기는 일 |
118
+ | 층위 | 형식 | 누가 정하는가 |
348
119
  |---|---|---|
349
- | `remaining`의 라벨을 "잔여 작업"이 아니라 **`확인 요청`**으로 | 같은 데이터를 "미완료 작업"이라 부르면 **변명문**이 되고, "확인 요청"이라 부르면 독자에게 **할 일을 넘기는 문장**이 된다 | 라벨 하나로 문서가 사과문으로 읽힌다. 데이터는 그대로인데 성격이 바뀐다 |
350
- | `source_url`은 **"고객이 이미 아는 것"만** 건다 | 예시 프로젝트 실측: 접수건 9건 전부에 Figma 코멘트 링크, 자체발견 9건에는 전부 빈 문자열. 자체발견 건에는 고객이 아는 참조가 애초에 없다 | 내부 트래커 링크를 채워 넣게 된다 → 독자가 못 여는 링크. `qa.py`의 외부 리소스 검사에도 걸린다(`allow_external`에 없는 호스트) |
351
- | 등급을 낮게 매기는 걸 두려워하지 않는다 | 예시 실측 분포: 운영 환경 확인 10 · 실데이터 대조 4 · 반영 확인 3 · 재발 점검 적용 1 | 확인 못 한 것을 확인한 것처럼 쓰면, 나중에 그 한 건이 문서 전체의 신뢰를 깎는다 |
120
+ | 절 | `제1절` `제2절` … | **`sections` 배열의 자리.** `ch = '%02d' % i`(`gazette.py:794`) |
121
+ | 절 앵커 | `#sec-1` … | 같음. `'id': 'sec-%d' % i`(`gazette.py:811`) |
122
+ | 건 번호 | `04-1` `05-1` … | 그 절의 자리 + 그룹 안 순번(`gazette.py:797-798`) |
123
+ | 건 앵커 | `#case-04-1` … | 같음 |
352
124
 
353
- ---
125
+ **⚠ 정정 — 데이터의 `sections[].no` 와 `sections[].id` 는 렌더러가 읽지 않음.**
126
+ 실측: `"no": "01"` · `"id": "sec-1"` 만 주고 렌더하면 화면에는 `제1절` 이 찍힘.
127
+ `no` 를 바꿔도 아무 일도 일어나지 않음.
128
+ 절 번호를 `01` 로 찍고 싶으면 **`label` 에 적음**(`gazette.py:812`) — 예시가 그렇게 함.
129
+ 데이터의 `id` 로 문서 안 링크를 걸면 **깨짐** — 앵커는 언제나 `sec-{자리}` 임.
354
130
 
355
- ## 4. 절 번호와 상호참조
131
+ ### 2.2 `groups[].no` 를 절 자리에 맞춘다 ★ 이것이 유일한 정합 규칙
356
132
 
357
- ### 4.1 두 층위
133
+ 두 곳이 건 번호를 **따로** 계산함.
358
134
 
359
- | 층위 | 형식 | 부여 방식 |
135
+ | 어디 | 무엇으로 계산 | 코드 |
360
136
  |---|---|---|
361
- | 절(장) | `01` `02` `03` … | `sections[].no` — 사람이 적는다 |
362
- | 건 번호 | `3.1`…`3.9` / `4.1`… | `groups`로 건을 나눈 뒤 각 그룹 안에서 1부터 |
363
-
364
- ```python
365
- groups = cfg['groups']
366
- for g in groups:
367
- g['cases'] = [c for c in cases if c.get('group') == g['key']]
368
- for i, c in enumerate(g['cases'], 1):
369
- c['sec'] = f"{int(g['no'])}.{i}"
370
- placed = sum(len(g['cases']) for g in groups)
371
- assert placed == len(cases), '그룹에 배정되지 않은 건이 있다: …'
372
- ```
137
+ | 렌더러(앵커·본문 표기) | 그 `kind:"cases"` 절이 `sections` 에서 **몇 번째인가** | `gazette.py:793-798` |
138
+ | 서버(요약 근거 게이트) | `groups[].no` | `report_publish.php:249-258` |
373
139
 
374
- **왜 첫 자리를 절 번호로 쓰는가:** 건 번호의 첫 자리가 **"이 건이 접수건이냐 자체발견이냐"를
375
- 번호만 보고 알게** 한다. 예시 프로젝트에서 `3.x`는 "내가 제보한 것", `4.x`는 "그쪽이 찾아낸 것"이다.
140
+ 둘이 어긋나면 이렇게 됨.
376
141
 
377
- 그룹 개수는 2개로 고정이 아니다. `groups`에 셋을 적고 `sections`에 `kind:"cases"` 절을 셋
378
- 두면 `3.x`/`4.x`/`5.x`가 된다.
379
-
380
- | 규칙 | 근거 | 어기면 생기는 일 |
381
- |---|---|---|
382
- | 모든 건의 `group`이 `groups[].key` 중 하나여야 한다 | `assert placed == len(cases)`가 지킨다 | 오타 하나로 건이 **문서에서 통째로 사라진다.** assert가 `17/18`로 즉시 알려 준다 |
383
- | 앵커 id는 점을 하이픈으로 (`3.1` → `s3-1`) | `aid = lambda c: 's' + c['sec'].replace('.', '-')`. **HTML id의 점은 CSS 셀렉터에서 클래스 구분자로 읽힌다** | 스타일·스크립트가 조용히 빗나간다 |
142
+ | 상황 | 결과 |
143
+ |---|---|
144
+ | `groups[].no` = 3, 그 절이 4번째 | 요약에 `§3.1` 이라 적어야 서버가 통과시킴. 그런데 렌더러가 만든 앵커는 `#case-04-1` 이라 **링크가 죽음** |
145
+ | `groups[].no` = 4, 그 절이 4번째 | 통과하고 링크도 살아 있음 |
384
146
 
385
- ### 4.2 `§` 마커 — 원고 문법
147
+ > **규칙: `groups[].no` 는 그 그룹을 펼치는 `kind:"cases"` 절의 `sections` 안 자리(1부터)와 같아야 한다.**
148
+ > 예시가 그렇게 되어 있음 — `no: "4"` 인 그룹은 4번째 절, `no: "5"` 는 5번째 절.
149
+ > 절을 하나 끼워 넣으면 **뒤의 `groups[].no` 를 전부 밀어야 함.**
386
150
 
387
- ```python
388
- SECREF = re.compile(r'§(\d+)\.(\d{1,2})')
151
+ ### 2.3 `§` 마커 — 원고 문법
389
152
 
390
- def link(t):
391
- """`§3.1` 같은 절 참조를 그 절로 가는 링크로 바꾸고 § 기호는 버린다."""
392
- return SECREF.sub(
393
- lambda m: f'<a class="xref" href="#s{m.group(1)}-{m.group(2)}">'
394
- f'{m.group(1)}.{m.group(2)}</a>', t)
395
- ```
153
+ 원고에는 `§4.1` 처럼 적음.
154
+ 렌더러가 링크로 바꾸고 기호를 버림(`gazette.py:42-49`).
155
+ `§` 는 법령·논문 조판 기호이지 업무 보고서의 활자가 아님.
396
156
 
397
157
  | 단계 | 하는 일 |
398
158
  |---|---|
399
- | **집필** | 원고에 **최종 번호를 직접** 쓴다 — `§3.1`, `§4.7`. 절 번호는 `groups[].no` + 그룹 내 순번으로 결정되므로 집필 시점에 이미 확정된다 |
400
- | **빌드** | `§3.1` → `<a class="xref" href="#s3-1">3.1</a>`. **`§` 기호가 여기서 소멸** |
401
- | **검사** | `assert '§' not in doc` — 하나라도 남으면 빌드 실패 |
159
+ | 집필 | `§4.1` — 앞자리는 그 절의 자리, 뒷자리는 그룹 안 순번 |
160
+ | 렌더 | `<a class="ref" href="#case-04-1">4-1</a>`. `§` 소멸 |
161
+ | 검사 | 링크로 안 바뀐 `§n.n` 이 남으면 **빌드가 죽음**(`build.py:390-393`) |
402
162
 
403
- 원고 안에서만 쓰는 마커이므로 표기는 `§3.1`, `§3.1~§3.2`, `§3.1·§3.2`, `(§4.4)` 아무렇게나 써도
404
- 된다. 정규식은 `§` + 숫자 + `.` + 1~2자리만 본다.
163
+ 코드 블록 안의 `§3.1` 은 검사에서 제외됨 — 거기서는 그냥 글자임.
405
164
 
406
- **예시 프로젝트 실측:** 최종 HTML에 `class="xref"` 앵커 52개, 죽은 링크 0.
165
+ ### 2.4 표기가 갈리는 자리 — 근거는 `refs[]` 로 단다
407
166
 
408
- ### 4.3 왜 `§`를 최종 출력에서 없애는가
167
+ 실측(예시 렌더 결과).
409
168
 
410
- | 근거 | 내용 |
411
- |---|---|
412
- | **활자 규범** | `§`는 법령·논문 조판 기호다. 고객사 검수 담당이 읽는 업무 보고서의 활자가 아니다 |
413
- | **이동 비용** | `§`는 "저기에 뭔가 있다"만 알리고 독자가 손으로 찾아가게 만든다. `3.1` 링크는 클릭 한 번이다. **검수자는 건과 건을 오가며 대조하는 독자**이므로 이 비용이 문서의 실사용성을 좌우한다 |
414
- | **부수 효과 — 이게 진짜 이득** | `§`가 살아 있으면 **`link()`를 안 타는 텍스트가 있다는 뜻**이다. 그래서 `§` 자체가 유출 검사 1순위 토큰이 된다 |
415
-
416
- ### 4.4 `link()`를 타지 않는 자리 (알고 쓸 것)
417
-
418
- | 자리 | 통과하는 것 | 결과 |
169
+ | 어디 | 앵커 | 화면 글자 |
419
170
  |---|---|---|
420
- | `open_items[].title` | `e()`만 | `§`를 쓰면 **빌드가 assert로 실패한다.** 참조는 `refs`로 |
421
- | `sections[].name` | `e()`만 | 절 제목에 참조를 쓸 일은 없다 |
422
- | `cases[].title` | `e()`만 | 건 제목에 참조를 쓰지 않는다 |
423
- | `stats[].label`, `meta`, `footer` | `e()`만 | 같음 |
171
+ | 본문 인라인 `§4.1` | `#case-04-1` | `4-1` |
172
+ | `summary[].refs` · `open_items[].refs` | `#case-04-1` | `04-1` |
173
+ | 건 제목 옆 번호 · 차례 하위 항목 · 대조표 | `#case-04-1` | `04-1` |
424
174
 
425
- **규칙:** 참조를 쓸 수 있는 자리는 `lede` · `summary.lead/body` · `open_items.body/refs` ·
426
- `cta` · `note.paragraphs` · 건의 `symptom/cause/fix/how_to_check/remaining` 뿐이다.
427
- 다른 자리에 `§`를 쓰면 빌드가 즉시 실패하므로 **조용히 잘못될 일은 없다.**
175
+ 링크는 셋 다 맞게 걸림.
176
+ 그런데 **같은 건이 문서 안에서 `4-1` 과 `04-1` 두 이름으로 불림.**
177
+ `_link()` 는 자리수를 안 채우고(`gazette.py:47`), `ref_text()` 는 채움(`gazette.py:741`).
178
+ 원고에서 고칠 수 있는 것이 아님 — `§04.1` 이라 적어도 결과는 `4-1` 임.
428
179
 
429
- ### 4.5 일반화 규칙
430
-
431
- | 규칙 | 근거 | 어기면 생기는 일 |
432
- |---|---|---|
433
- | 참조 마커에 **원고 전용 sentinel 문자**를 쓰고, 최종 출력에서 **반드시 제거** | 남으면 곧 버그 신호가 되므로 공짜 검사기를 얻는다 | sentinel을 안 쓰면 "변환 안 된 참조"를 기계가 구분할 방법이 없다 |
434
- | 그 제거를 **경고가 아니라 assert**로 | 경고는 아무도 안 본다 | 8.1 참조 |
180
+ > **귀결: 요약과 확인 요청의 근거는 `refs[]` 로 단다.**
181
+ > 본문 안 인라인 `§n.n` 은 문장 흐름상 꼭 필요할 때만 씀.
182
+ > `refs` 는 `§` 를 떼고 `["4.1"]` 로 적어도 되고 `["04-1"]`·`["01절"]` 도 받음(`gazette.py:716-727`).
435
183
 
436
184
  ---
437
185
 
438
- ## 5. `chunk()` — 한국어 본문 분절
186
+ ## 3. 한국어 원고 규칙 — 문장 단위 줄바꿈
439
187
 
440
- ### 5.0 존재 이유
188
+ ### 3.1 렌더러가 무엇을 하는가
441
189
 
442
- > 산문 한 덩어리로 두면 색을 아무리 칠해도 읽히지 않는다. 반대로 아무 괄호 숫자나 목록으로
443
- > 바꾸면 문장이 부서진다. — `chunk()` 독스트링
190
+ 한글 보고서는 마침표에서 줄을 바꿈.
191
+ 문장을 이어 붙인 한글 문단은 기계가 쓴 글처럼 읽힘.
444
192
 
445
- **핵심 통찰: 가독성 문제를 CSS로 풀려 하지 말고 텍스트 구조로 풀어라.**
446
- 배경색·강조색·행간을 아무리 조정해도 400자 산문 덩어리는 읽히지 않는다.
193
+ 렌더러가 마크업을 다 세운 뒤 `sentbreak.py` 를 태워 문장마다 `<span class="sn">` 을 새김(`gazette.py:970-988`).
194
+ `.sn` 은 블록으로 서고 이어지는 줄만 내어씀.
195
+ **낱말은 하나도 바뀌지 않음** — 원고를 고치는 도구가 아님.
447
196
 
448
- 적용 대상은 건의 다섯 필드(`symptom` `cause` `fix` `how_to_check` `remaining`)뿐이다.
197
+ 문장 경계 판별은 `boundary.py` 가 함.
198
+ 날짜 · 버전 · 경로 · 머리글자를 걸러 냄.
449
199
 
450
- **예시 프로젝트 실측(88개 필드):** 열거 5 · 두 문장 분절 20 · 단일 문단 63.
200
+ ### 3.2 경계 판별이 놓치는 자리 — 실측
451
201
 
452
- ### 5.1 규칙 (1) — `(1)(2)` 열거 마커 인식
202
+ `boundary.count()` 로 직접 잰 결과임.
203
+ 왼쪽 숫자가 잡아낸 경계 수임.
453
204
 
454
- ```python
455
- marks = list(re.finditer(r'\((\d)\)', text))
456
- is_list = len(marks) >= 2
457
- ```
458
-
459
- | 규칙 | 근거 | 어기면 생기는 일 |
205
+ | 잰 값 | 원고 | 왜 |
460
206
  |---|---|---|
461
- | 마커가 **2개 이상**일 때만 목록 | 항목이 하나인 열거는 열거가 아니다. `(1)` 단독은 대개 각주·부연이다 | 부연 하나가 1항목짜리 `<ol>`로 승격돼 형식만 요란해진다 |
462
- | 마커는 **한 자리 숫자**만 (`\((\d)\)`) | 한 건 안의 갈래가 10개를 넘으면 그건 한 건이 아니라 여러 건이다 | `(10)`은 마커로 인식되지 않고 산문에 그대로 남는다 |
463
- | 첫 마커 앞 텍스트는 **head 문단**으로 분리 (`if idx == 0 and m.start() > 0`) | 총론과 각론은 다른 층위다 | **head가 첫 항목 본문에 흡수된다.** 예: `"이 건에는 성격이 다른 네 가지가 함께 제보되었습니다. (1) …"`에서 head가 없으면 총론 문장이 1번 항목 안으로 들어가 **항목 수가 하나 어긋나 보이고 총론이 각론으로 오독**된다 |
464
-
465
- ### 5.2 규칙 (2) — 조사(JOSA)로 열거 마커와 항목 참조를 판별 ★ 핵심
466
-
467
- ```python
468
- JOSA = '은는이가을를도와과의에서로'
469
- ...
470
- for m in marks:
471
- nxt = text[m.end():m.end() + 1]
472
- if nxt and nxt in JOSA: # "(1)은 …" → 참조. 목록이 아니다.
473
- is_list = False
474
- break
475
- ```
207
+ | 0 | `저장 순서를 바꿈.운영 반영까지 확인함.` | 마침표 뒤 **공백이 없음** |
208
+ | 1 | `저장 순서를 바꿈. 운영 반영까지 확인함.` | 정상 |
209
+ | 0 | `조치를 마침. iOS 쪽은 차기 회차 예정임.` | 다음 문장이 **라틴 소문자**로 시작 |
210
+ | 1 | `조치를 마침. IOS 쪽은 차기 회차 예정임.` | 대문자는 잡힘 |
211
+ | 0 | `조치를 마침. api.php 를 확인함.` | 같은 이유 |
212
+ | 0 | `0.3초로 줄임. 2026. 8. 24. 기준임.` | 다음 문장이 **숫자 날짜**로 시작 — 날짜를 먼저 가리므로 |
213
+ | 1 | `0.3초로 줄임. 2026년 8월 24일 기준임.` | 한글 날짜는 잡힘 |
214
+ | 0 | `2026. 8. 24.` 단독 | 날짜는 문장이 아님 |
215
+ | 1 | `A. 첫째. B. 둘째.` | 머리글자 `A.`·`B.` 는 배제, `첫째.` 뒤만 경계 |
216
+ | 2 | `조치함! 확인 바람? 회신 필요함.` | `!` `?` 도 경계임 |
217
+ | 1 | `총 3건임. (1) 저장 (2) 목록 (3) 표시.` | 열거는 안 쪼개짐 — 한 문장임 |
218
+
219
+ | 원고 규칙 | 근거 |
220
+ |---|---|
221
+ | **마침표 뒤에 공백 하나를 반드시 둔다** | 없으면 두 문장이 한 덩어리로 남음 |
222
+ | **문장을 라틴 소문자로 시작하지 않는다** | `iOS`·`api.php` 로 문장을 열면 앞 문장에 붙음. 한글 낱말을 앞에 세울 것 — `앱 쪽은 iOS …` |
223
+ | **문장을 숫자 날짜로 시작하지 않는다** | `2026. 8. 24.` 는 문장 끝이 아니라고 판정됨. 한글 날짜로 쓰거나 문장 안쪽으로 보낼 것 |
224
+ | **문장 중간에 마침표를 남기지 않는다** | 버전·경로는 그대로 두어도 안전하지만, 약어 마침표는 경계를 흔듦 |
476
225
 
477
- 독스트링이 근거를 그대로 적어 두었다.
226
+ ### 3.3 문장을 짧게 끊는다
478
227
 
479
- ```
480
- 한국어에서 `(1)` 은 두 가지로 쓰이기 때문이다.
481
- · 열거 마커 — "(1) 같은 날짜의 예약은 …" → 목록으로
482
- · 항목 참조 — "(1)은 새 일지를 열 때 …" → 산문 그대로 ("1번은" 의 뜻)
483
- 뒤에 **조사가 바로 붙으면 참조**다. 이걸 목록으로 쪼개면 조사만 남는다.
484
- ```
228
+ 자동 조판이 문장을 세워 주지만 **문장의 길이는 사람의 몫임.**
485
229
 
486
- **실패 예 — 이 규칙을 빼면 이렇게 된다.** 원문(예시 프로젝트 3.3건 `cause`):
230
+ | 규칙 | 근거 |
231
+ |---|---|
232
+ | 한 문장에 사실 하나 | 두 사실을 접속으로 이으면 한 줄이 접히고, 접힌 둘째 줄과 다음 문장의 첫 줄이 구별되지 않음 |
233
+ | 복문을 두 문장으로 푼다 | 「같은 배포에 ~가 함께 포함됐음」은 배포 단위를 아는 사람만 이해하는 구조임. 사실을 앞세우고 부연을 뒤로 |
234
+ | 명사구로 끝내지 않는다(제목은 예외) | `(… 남아 보임)` 같은 괄호 부기는 완결 문장으로 바꿈 |
235
+ | 조치 서술은 **「변경 → 검증」 두 박자** | 무엇을 바꿨는지 한 문장, 어디까지 확인했는지 한 문장. 예시의 `fix` 가 전부 이 모양임 |
487
236
 
488
- > `(1)은 새 일지를 열 때 '이전 방문 일지'를 불러오는 기준에 날짜 조건이 없어, … (2)는 화면 표시 오류가 아니라 … (3)(4)는 원인이 달라 별도 건으로 분리했습니다.`
237
+ 글줄 폭이 몇 자인가 · 왜 그 폭인가는 여기서 다루지 않음 → `gazette-report`.
489
238
 
490
- JOSA 규칙 없이 돌리면:
239
+ ### 3.4 열거는 어디에 두는가
491
240
 
492
- ```
493
- (1) → 은 새 일지를 열 때 '이전 방문 일지'를 불러오는 기준에 날짜 조건이 없어, …
494
- (2) → 는 화면 표시 오류가 아니라 그 예약의 일지가 실제로 발송되지 않은 상태…
495
- (3·4) → 는 원인이 달라 별도 건으로 분리했습니다.
496
- ```
241
+ **⚠ 정정 — `(1)(2)` 를 문단 안에 적어도 목록으로 서지 않음.**
242
+ v1 의 `chunk()` 가 하던 일임.
243
+ 관보 렌더러는 그 함수를 부르지 않음 — 본문 조각은 `link(e(...))` 만 통과함(`gazette.py:120-122`).
244
+ 문단 안의 `(1)(2)` 는 그냥 글자로 남음.
497
245
 
498
- **조사만 남은 문장 파편이 목록 항목이 된다.** 영어 `(1) …`에는 없는 **한국어 고유의 실패**다.
246
+ 목록으로 서는 자리는 넷뿐임.
499
247
 
500
- | 대안 | 왜 안 되는가 |
248
+ | 자리 | 데이터 |
501
249
  |---|---|
502
- | 마커 뒤 공백 유무로 판별 | `"(1) 은"`처럼 띄어 쓰는 표기가 언제든 들어온다 |
503
- | 마커 뒤 조사 집합으로 판별 | **유일하게 견고하다.** 한국어 조사는 닫힌 집합이고, 열거 마커 뒤에는 조사가 오지 않는다 |
504
-
505
- 한 마커라도 참조로 판정되면 **문단 전체를 산문으로 되돌린다**(`break`). 같은 문단 안에서
506
- 마커의 역할이 섞이지 않는다는 전제이고, 실제 데이터가 이를 따른다.
507
- (실측: 예시 프로젝트 88개 필드 중 이 규칙으로 산문 복귀한 문단 1개.)
508
-
509
- > **이 규칙은 지우기 쉽다.** 코드만 보면 "왜 조사 목록이 여기 있지?" 싶다.
510
- > 그래서 위 실패 예를 이 문서에 남긴다. 근거 없는 규칙은 다음 사람이 되돌린다.
511
-
512
- ### 5.3 규칙 (3) — `(3)(4)` 병합
513
-
514
- ```python
515
- if not body: # "(3)(4)" — 다음 번호와 한 덩어리
516
- pending.append(m.group(1))
517
- continue
518
- items.append(('·'.join(pending + [m.group(1)]), body))
519
- pending = []
520
- ...
521
- if pending and items: # 끝에 매달린 번호는 마지막 항목에
522
- items[-1] = ('·'.join([items[-1][0]] + pending), items[-1][1])
523
- ```
524
-
525
- 마커 사이 본문이 비면 **다음 번호와 하나의 항목**으로 합치고 라벨을 `3·4`로 만든다.
526
-
527
- **예시 프로젝트 실측 라벨 분포:** `1×5, 2×5, 3×3, 4×1, 3·4×1` — 병합 라벨이 실제로 렌더된다.
250
+ | 강조 박스 항목 | `kind:"note"` 절의 `paragraphs[]` |
251
+ | 이 문서 읽는 법 | `guide[].a[]` |
252
+ | PART 가 답하는 질문 | `parts[].questions[]` |
253
+ | 확인 요청 | `open_items[]` |
528
254
 
529
- | 규칙 | 근거 | 어기면 생기는 일 |
530
- |---|---|---|
531
- | 본문 없는 마커는 다음 마커와 병합 | 원문 `"(3)(4) 두 건은 …"`은 **두 항목을 함께 가리키는 하나의 진술**이므로 라벨도 하나여야 한다 | `(3)`이 본문 없는 빈 `<li>`로 나오고 내용이 전부 `(4)`에 들어간다. 독자는 "3번은 왜 비었지?"를 의심한다 |
532
- | 끝에 매달린 `pending`을 마지막 항목에 붙임 | `"… (5)(6)"`처럼 문단이 번호로 끝나는 경우의 방어 | 마지막 번호가 조용히 증발한다 |
533
-
534
- > **⚠ 렌더 주의(현행 CSS 확인):** `report.css`의 `ol.enum li`는 첫 칸이 `23px` 고정이고
535
- > `.n`도 `width:23px; height:23px`다. `3·4`까지는 들어가지만 `3·4·5`는 넘칠 수 있다.
536
- > 세 개 이상을 한 라벨로 묶어야 할 상황이면 그건 문장을 다시 쓸 신호다.
537
-
538
- ### 5.4 규칙 (4) — 두 문장 단위 분절
539
-
540
- ```python
541
- SENT = re.compile(r'(?<=다\.)\s+|(?<=요\.)\s+|(?<=오\.)\s+')
542
- ...
543
- sents = [x.strip() for x in SENT.split(text) if x.strip()]
544
- if len(text) > 180 and len(sents) > 2:
545
- ...두 문장씩 <p> 로 묶는다...
546
- return f'<p>{link(e(text))}</p>'
547
- ```
255
+ 건의 다섯 칸에 갈래가 넷 이상 생기면 **그건 한 건이 아니라 여러 건임.**
256
+ 건을 쪼개고 `§` 로 서로 가리키게 함.
548
257
 
549
- | 규칙 | 근거 | 어기면 생기는 일 |
550
- |---|---|---|
551
- | 문장 경계를 마침표가 아니라 **한국어 종결어미 + 마침표**로 잡는다 (`다.` `요.` `오.`) | 마침표만 쓰면 `"0.52초"`, `"2026.08.10"`, `"5.9MB"` 같은 숫자·날짜에서 오분절한다. 한국어 서면체 종결은 이 셋으로 거의 닫히므로 lookbehind로 두면 **오분절률이 사실상 0** | 날짜·소수점마다 문단이 갈라져 문서가 부서진다 |
552
- | lookbehind 뒤에 `\s+`를 요구 | 문단 끝 마침표에서는 안 끊긴다 | 마지막에 빈 문단이 생긴다 |
553
- | **`전체 길이 > 180자` AND `문장 3개 이상`** 두 조건을 AND로 | 길이만 보면 한 문장짜리 긴 문장을 쪼갤 수 없어 무의미하게 통과한다. 문장 수만 보면 짧은 문장 3개(예: 확인 방법 문구)까지 3문단으로 쪼개져 **파편처럼 보인다** | 둘 중 하나만 쓰면 반대쪽 실패가 그대로 남는다 |
554
- | **2문장씩** 묶는다 | 1문장/문단은 개조식으로 보여 보고서 톤이 깨지고, 3문장 이상은 다시 덩어리가 된다 | 리듬이 무너진다 |
555
- | 나머지 홀수 문장은 마지막 문단에 붙인다 | 1문장짜리 꼬리 문단을 만들지 않는다 | 끝에 외톨이 문단이 생긴다 |
258
+ ---
556
259
 
557
- **실측 예** (예시 프로젝트 3.1건 `fix`, 원문은 한 덩어리):
260
+ ## 4. 고객 언어 치환
558
261
 
559
- ```html
560
- <p>사전상담 진입 시 해당 고객 정보의 실제 주인이 로그인한 계정인지 확인하도록 바꿨습니다. 주인이 없는 고객 정보는 번호가 일치하는 계정에 연결되고, 이미 다른 계정의 정보라면 번호가 같아도 진입이 차단됩니다.</p>
561
- <p>이 수정은 운영 환경에 반영된 것을 확인했습니다. 함께 전체 계정을 조사한 결과, 사용 중인 계정 78개 중 전화번호가 겹치는 경우는 2건(계정 5개)뿐이고 모두 주인이 정해져 있으며, 주인이 없는 고객 정보는 없었습니다.</p>
562
- ```
262
+ ### 4.1 ⚠ `text_fix` 는 관보 경로에서 돌지 않는다
563
263
 
564
- "무엇을 바꿨나" 2문장 / "어디까지 확인했나" 2문장으로 자연스럽게 갈렸다. 기계 분절인데 의미
565
- 단위와 맞아떨어진 것은 우연이 아니다 — **조치 서술이 대개 "변경 → 검증" 두 박자로 쓰이기 때문**이다.
264
+ **SKILL.md 와 예시 데이터가 아직 반대로 적고 있는 지점이라 먼저 적음.**
566
265
 
567
- **집필 요령:** `fix`를 쓸 때 이 두 박자를 의식하면 조판이 저절로 맞는다.
266
+ `build.py:516-522` 가 치환을 적용함.
267
+ 그런데 그 코드는 `build_gazette()` 가 돌아간 **뒤**의 옛 경로에 있음(`build.py:464-465` 에서 갈림).
268
+ `build_gazette()` 는 `text_fix` 를 한 번도 읽지 않음.
568
269
 
569
- ### 5.5 처리 순서 (고정)
270
+ 실측(예시 데이터에 `{"from":"조회 색인","to":"ZZZTESTZZZ"}` 를 넣고 렌더).
570
271
 
571
272
  ```
572
- 열거 판정 → (열거면) head 문단 + <ol class="enum"> 로 종료
573
- → (아니면) 두 문장 분절 → <p>…</p>
574
- 어느 경로든: e() 이스케이프 → link() 적용
273
+ ZZZTESTZZZ 0회
274
+ 조회 색인 1회 ← 원문 그대로 살아남음
575
275
  ```
576
276
 
577
- | 규칙 | 근거 | 어기면 생기는 일 |
578
- |---|---|---|
579
- | `e()`(`html.escape(..., quote=True)`)를 **먼저**, `link()`를 **나중에** | `link()`가 생성한 `<a>` 태그는 이스케이프 대상이 아니다 | 순서를 바꾸면 생성한 `<a href="#s3-1">`가 통째로 이스케이프되어 **태그 소스가 화면에 그대로 보인다** |
580
-
581
- ### 5.6 청킹을 적용하지 **않는** 자리
582
-
583
- `summary`(01 요약)와 `open_items`(05 확인 요청), `lede`, `cta`, `note.paragraphs`는
584
- `chunk()`를 거치지 않고 `link(e(...))`만 통과한다.
585
-
586
- | 규칙 | 근거 | 어기면 생기는 일 |
587
- |---|---|---|
588
- | 이미 구조화된 텍스트에는 기계 분절을 걸지 않는다 | 요약은 `lead`(한 문장 단언) + `body`(부연)로 **집필 단계에서 이미 구조화**되어 있다 | 구조 위에 구조를 얹으면 리듬이 깨진다. `lead`가 두 동강 나 단언의 힘이 사라진다 |
589
-
590
- > **원칙: 청킹은 "구조 없는 산문"에만 쓰는 도구다.** 입력이 이미 구조를 갖고 있으면 그 구조를 신뢰한다.
591
-
592
- ---
593
-
594
- ## 6. `text_fix` — 고객 언어 치환
595
-
596
- ### 6.1 형식과 적용 범위
597
-
598
- ```json
599
- "text_fix": [
600
- {"from": "예약 정보가 없어도 저장이 정상 처리되도록 저장 경로 6곳을 수정했고,",
601
- "to": "예약 정보가 없어도 저장이 정상 처리되도록 저장이 일어나는 모든 경로를 수정했고,"}
602
- ]
603
- ```
277
+ MCP 발행 스키마에도 `text_fix` 칸이 없음(`mcp/genetics.mjs`).
278
+ 서버 화이트리스트는 통과시키지만(`report_publish.php:45`) 렌더러가 안 읽으므로 결과는 같음.
604
279
 
605
- **완전 일치 문자열 치환**, 배열 순서대로 순차 적용, **건의 다섯 필드에만**
606
- (`symptom` `cause` `fix` `how_to_check` `remaining`). `summary`·`open_items`·`lede`는
607
- `report.json` 안에서 이미 고객 언어로 쓰므로 치환 대상이 아니다.
280
+ > **귀결: 고객 언어 치환은 이제 원고 단계에서 사람이 한다.**
281
+ > `cases.json` 에 처음부터 고객 언어로 적음.
282
+ > 예시 데이터에 `text_fix` 가 없는 것은 그래서임 — 본문이 이미 치환된 상태로 쓰였음.
283
+ > 어딘가에 남아 있는 `text_fix` 를 보면 **그 문서는 치환이 안 된 것**으로 의심할 것.
608
284
 
609
- `text_fix`가 `build.py`가 아니라 `report.json`에 있는 이유: **치환 규칙 자체가 프로젝트 산출물**이기
610
- 때문이다. 어떤 내부 용어를 어떤 고객 언어로 바꿨는지가 그 프로젝트의 판단이고, 다음 회차에
611
- 그대로 재사용된다.
285
+ ### 4.2 치환 유형 — 원고를 쓰면서 적용한다
612
286
 
613
- ### 6.2 4가지 금기
287
+ 내부 기록을 그대로 옮기면 걸리는 자리 열 가지임.
288
+ **전부 개조식으로 적음** — 6장의 어휘 게이트가 경어체를 0건으로 요구함.
614
289
 
615
- | 금기 | 처리 | 강제 장치 |
616
- |---|---|---|
617
- | **내부 티켓 번호·트래커 이름** | 문서에 절대 싣지 않음 | `forbid` 정규식 → **빌드 실패** |
618
- | **개발 배포 서술** | "배포" → "운영 반영", 스토어 빌드 → 앱 업데이트 | `text_fix` + `forbid`에 `운영 배포`·`스토어 빌드` |
619
- | **내부 검증 산출물** | 코드 규모·테스트 건수 등 수치 제거, 보장 범위 서술로 | `text_fix` + `forbid`에 `자동 검증 \d건` |
620
- | **설계 검토 이력** | 채택되지 않은 대안 논의는 **통째로 삭제** | `text_fix`의 `"to": ""` |
621
-
622
- ### 6.3 치환 유형 분류표
623
-
624
- 모든 예시는 예시 프로젝트 `report.json`의 `text_fix`에서 그대로 인용했다.
625
-
626
- | # | 유형 | 변환 방향 | 왜 | 실제 예 (from → to) |
290
+ | # | 유형 | 방향 | 왜 | 예 (내부 → 고객) |
627
291
  |---|---|---|---|---|
628
- | **A** | 검수 환경·계정 지칭어 | 조직 내부 명칭 → **용도**로 지칭 | 고객에게 "테스트 매장"은 **자기 매장 중 하나**로 읽힌다. "우리 매장에서 테스트했나?"라는 오해를 부른다 | `테스트 매장의 테스트 고객에 1분짜리 녹음을 실제 업로드 경로로 올려,` → `검수용 매장·고객으로 1분짜리 녹음을 실제로 올려,`<br>`테스트 계정으로 앱에서 탈퇴한 뒤` → `확인용 계정으로 앱에서 탈퇴한 뒤` |
629
- | **B** | 배포 이벤트 → 상태 서술 | "배포했다"(우리 행위) → "운영에 반영했다"(고객이 겪는 상태). ISO 날짜 → 한국어 날짜 | 고객은 릴리스 절차를 모르고 알 필요도 없다. 알아야 할 것은 "지금 내 매장에 적용돼 있나"뿐 | `2026-08-09 운영 배포를 마쳤고, …` → `2026년 8월 9일 운영 환경에 반영했고, …`<br>`운영 서버에 반영해 실제 응답 시간을 측정했습니다.` → `운영 환경에 반영해 …` |
630
- | **C** | 티켓 분리 서술 → **문서 내 절 참조** | "별도 티켓으로 뺐다"(조회 불가) → "§4.3에 정리했다"(클릭 가능) | 티켓은 고객이 볼 수 없는 곳이다. **같은 문서 안의 좌표로 바꾸면 "빠진 것"이 "다른 데 적힌 것"이 된다** | `… 별건은 별도 티켓으로 분리해 처리했습니다.` → `… 별건은 §4.3에 따로 정리했습니다.`<br>`위 탈퇴 건을 조사하다 발견한 별개 결함입니다.` → `§4.1을 조사하는 과정에서 함께 발견한 별개 문제입니다.` |
631
- | **D** | 내부 작업 규모·산출물 수치 제거 | 코드/테스트 카운트 → **보장 범위** 서술 | "6곳"은 고객이 검증할 수 없는 수치라 신뢰를 만들지 못하고 **"왜 6곳뿐?"이라는 불안만** 만든다. 대신 **완전성**을 말한다 | `저장 경로 6곳을 수정했고,` → `저장이 일어나는 모든 경로를 수정했고,`<br>`… 검사하는 자동 검증 2건을 추가했습니다.` → `같은 문제가 다시 나타나면 곧바로 걸러지도록 상시 점검을 걸어 두었습니다.` |
632
- | **E** | 배포 채널 용어 → 사용자 체감 사건 | 스토어 빌드/버전 → "앱 업데이트" | 매장 직원의 세계에서 일어나는 사건은 "앱이 업데이트된다"뿐이다 | `다음 스토어 버전이 배포된 뒤 로그아웃하고 …` → `다음 앱 업데이트가 적용된 뒤 …`<br>`앱 쪽 수정은 다음 스토어 빌드부터 사용자에게 적용되고,` → `앱 쪽 수정은 다음 앱 업데이트부터 적용되고,` |
633
- | **F** | 기술 명사 → 관찰 가능한 대상 | 쿠키/세션/업체 → 화면에서 보이는 것 | 독자가 그 명사를 **눈으로 확인할 수 없으면** 문장이 검증 불가능해진다 | `쿠키 정리는 앱 심사 종료 후 재검토가 필요합니다.` → `앱에 남는 로그인 흔적을 완전히 지우는 작업은 앱 심사가 끝난 뒤 다시 검토가 필요합니다.`<br>`음성인식 업체 교체가 필요해` → `음성 인식 서비스 교체가 필요해`<br>`세션이 잠깐 되살아나던` → `로그인 상태가 잠깐 되살아나던` |
634
- | **G** | 증거의 출처를 내부 도구에서 지움 | "서버 응답으로 확인" → "직접 확인" | 확인했다는 **사실**이 요점이고, 어떤 계층에서 봤는지는 내부 사정 | `… 남아 있는 것을 서버 응답으로 직접 확인했습니다.` → `… 남아 있는 것을 직접 확인했습니다.` |
635
- | **H** | 한 문장 두 사실 → 두 문장 재편 | 복문 해체 | "같은 배포에 ~가 함께 포함됐습니다"는 **배포 단위를 아는 사람만** 이해하는 문장 구조. 사실을 앞세우고 부연을 뒤로 | `같은 배포에 녹음 삭제 시 일지 상세에서도 바로 사라지도록 하는 수정(이전에는 삭제 후에도 잠시 남아 보임)이 함께 포함됐습니다.` → `함께 반영된 수정이 하나 더 있습니다. 녹음을 삭제하면 일지 상세에서도 즉시 사라집니다(이전에는 삭제 후에도 잠시 남아 보였습니다).` — 명사구 `(… 남아 보임)`이 완결 문장으로 바뀐 것도 같은 원리 |
636
- | **I** | 내부 관용어 → 중립 어휘 | 팀 은어 정규화 | 같은 개념을 두 이름으로 부르면 독자가 **다른 것으로** 읽는다 | `별도 작업으로 분리했습니다.` → `별건으로 분리했습니다.`<br>`처음 준비한 자동 정리 방식은` → `처음 준비한 일괄 정리 방식은` |
637
- | **J** | **삭제** (`"to": ""`) | 설계 검토 이력·내부 잔여 정리는 서술 대상이 아님 | 채택되지 않은 대안은 **고객의 판단·행동을 바꾸지 않는다.** 화면 동작에 영향 없는 내부 정리도 마찬가지. 문서에 남으면 **"아직 안 끝났다"로만** 읽힌다 | `처음 검토안 중 '로그인 여부 판정을 …'은 … 제외했습니다. ` → `""`<br>` 이 밖에 지금은 쓰이지 않는 잔여 기능 정리가 별도 건으로 남아 있습니다(화면 동작에는 영향 없음).` → `""` |
638
-
639
- ### 6.4 허용/금지의 경계선
640
-
641
- | 금지 | 허용 | 왜 |
642
- |---|---|---|
643
- | `별도 티켓` (`forbid`에 등재) | `별도 건` / `별건` | **"건"은 고객도 세는 단위이고, "티켓"은 우리 도구의 이름이다.** 실제로 예시 프로젝트 3.3건 `cause`에 `"(3)(4)는 원인이 달라 별도 건으로 분리했습니다."`가 그대로 살아 있다 |
292
+ | A | 검수 환경·계정 지칭어 | 조직 내부 명칭 → **용도**로 지칭 | 고객에게 「테스트 매장」은 **자기 매장 중 하나**로 읽힘 | `테스트 매장의 테스트 고객에 올려` → `검수용 매장·고객으로 올려` |
293
+ | B | 배포 이벤트 → 상태 서술 | 「배포했다」 → 「운영에 반영됨」. ISO 날짜 → 한국어 날짜 | 고객은 릴리스 절차를 모르고 알 필요도 없음. 알아야 할 것은 「지금 내 쪽에 적용돼 있나」뿐 | `2026-08-09 운영 배포를 마쳤고` → `2026년 8월 9일 운영 환경에 반영함` |
294
+ | C | 티켓 분리 서술 → **문서 내 절 참조** | 「별도 티켓으로 뺐다」 → 「§5.1 에 정리함」 | 티켓은 고객이 볼 수 없는 곳임. 같은 문서 안의 좌표로 바꾸면 **「빠진 것」이 「다른 데 적힌 것」이 됨** | `별도 티켓으로 분리해 처리함` → `§5.1 에 따로 정리함` |
295
+ | D | 내부 작업 규모 수치 제거 | 코드·테스트 카운트 → **보장 범위** 서술 | 「6곳」은 고객이 검증할 수 없는 수치라 신뢰를 못 만들고 **「왜 6곳뿐?」이라는 불안만** 만듦 | `저장 경로 6곳을 수정함` → `저장이 일어나는 모든 경로를 수정함` |
296
+ | E | 배포 채널 용어 → 사용자 체감 사건 | 스토어 빌드·버전 → 「앱 업데이트」 | 현장 인원의 세계에서 일어나는 사건은 「앱이 업데이트된다」뿐임 | `다음 스토어 빌드부터 적용됨` → `다음 앱 업데이트부터 적용됨` |
297
+ | F | 기술 명사 → 관찰 가능한 대상 | 쿠키·세션·업체 → 화면에서 보이는 것 | 독자가 그 명사를 **눈으로 확인할 수 없으면** 문장이 검증 불가능해짐 | `세션이 잠깐 되살아나던` → `로그인 상태가 잠깐 되살아나던` |
298
+ | G | 증거의 출처를 내부 도구에서 지움 | 「서버 응답으로 확인」 → 「직접 확인」 | 확인했다는 **사실**이 요점이고, 어느 계층에서 봤는지는 내부 사정 | `서버 응답으로 직접 확인함` → `직접 확인함` |
299
+ | H | 한 문장 두 사실 → 두 문장 재편 | 복문 해체 | 3.3 의 문장 길이 규칙과 같은 뿌리임 | `같은 배포에 …(이전에는 잠시 남아 보임)이 함께 포함됐음` → `함께 반영된 수정이 하나 더 있음. 녹음을 삭제하면 일지 상세에서도 즉시 사라짐.` |
300
+ | I | 내부 관용어 → 중립 어휘 | 팀 은어 정규화 | 같은 개념을 두 이름으로 부르면 독자가 **다른 것으로** 읽음 | `자동 정리 방식` → `일괄 정리 방식` |
301
+ | J | **삭제** | 설계 검토 이력·내부 잔여 정리는 서술 대상이 아님 | 채택되지 않은 대안은 **고객의 판단·행동을 바꾸지 않음.** 문서에 남으면 「아직 안 끝났다」로만 읽힘 | `처음 검토안 중 …은 제외함` → (통째로 삭제) |
644
302
 
645
- > **삭제 규칙의 공백 처리:** `"to": ""`인 규칙의 `from`은 **앞이나 뒤에 공백을 포함**시킨다
646
- > (` 쿠키 정리는…`). 문장을 통째로 지울 때 **인접 공백을 함께 지워야 이중 공백이 남지 않는다.**
647
- > 이걸 놓치면 산출물에 `"…했습니다. 다음…"`처럼 미세한 흠집이 남고, 육안 검수로는 거의 안 잡힌다.
303
+ **경계선 하나.**
304
+ `별도 티켓` 은 금지, `별건`·`별도 건` 은 허용.
305
+ 「건」은 고객도 세는 단위이고 「티켓」은 우리 도구의 이름임.
648
306
 
649
- ### 6.5 완전 일치 치환의 한계 — 반드시 알고 쓸 것
307
+ ### 4.3 `forbid` — 로컬 렌더에서만 산다
650
308
 
651
- **예시 프로젝트 실측: 24개 규칙 중 23개는 정확히 1회씩 발화, 1개는 0회.**
309
+ `forbid` 는 산출물 전체 문자열에 거는 금칙어 정규식 목록임.
310
+ `guard()` 가 **파일을 쓰기 전에** 검사하고, 걸리면 빌드가 죽음(`build.py:394-396`).
652
311
 
653
- 0회 규칙:
312
+ 실측(예시에 `"forbid": ["조회 색인"]` 을 넣고 렌더).
654
313
 
655
- ```json
656
- {"from": "별도 건으로 분리해 처리\n중입니다.", "to": "§4.5에 따로 정리했습니다."},
657
- {"from": "별도 건으로 분리해 처리 중입니다.", "to": "§4.5에 따로 정리했습니다."}
314
+ ```
315
+ AssertionError: 금지 표현 유출: 조회 색인 → ['조회 색인']
658
316
  ```
659
317
 
660
- **같은 문장의 줄바꿈 변형을 두 번 등록해 두었다.** 원문 어딘가에 문장 중간 개행이 있었고
661
- 치환이 조용히 빗나간 사고의 흔적이다. 지금 데이터에는 개행 변형이 없으므로 첫 규칙은 사문이다.
318
+ | 경로 | `forbid` 가 도는가 |
319
+ |---|---|
320
+ | `python3 assets/render.py report.json` | **돈다.** 위 실측 |
321
+ | MCP 발행(`publish_report`·`update_report`) | **안 돈다.** `REPORT_CLIENT_KEYS` 에 없어 서버가 입구에서 깎아 냄(`report_publish.php:31-67`·주석 76행) |
662
322
 
663
- 또 하나의 흔적:
323
+ > **귀결: 포털 발행만 하는 문서에는 금칙어 관문이 없다.**
324
+ > 그 자리를 메우는 것은 `dry_run` 의 `warnings` 와 사람의 눈임.
325
+ > 로컬 렌더를 한 번 돌려 `forbid` 를 통과시키는 것이 지금으로서는 유일한 자동 방어임.
664
326
 
665
- ```json
666
- {"from": "내부 검증 환경 환경에서 로그아웃 후", "to": "검증 환경에서 로그아웃 후"}
667
- ```
327
+ 한국어 금칙어의 함정 하나.
328
+ **한국어에는 낱말 경계가 없음** — `머지` 가 「**나**머지 절은」을 잡음.
329
+ `\b` 도 전방탐색도 안 들음(뒤에 공백이 오므로).
330
+ **앞**을 막아야 함: `(?<!나)머지`.
668
331
 
669
- 원문에 **"환경 환경"**이라는 중복어가 실제로 있었다. 앞선 어떤 치환 패스가 만든 잔해다.
670
- **치환의 연쇄가 새 결함을 만든다**는 실례.
332
+ ### 4.4 치환 규칙 ↔ 금칙어 1:1 짝짓기
671
333
 
672
- | 교훈 | 귀결 |
673
- |---|---|
674
- | 완전 일치 치환은 공백·개행 변형에 **무방비**하고, 빗나가도 **조용하다** | 8장의 `forbid`가 반드시 짝으로 있어야 한다 |
675
- | 치환의 연쇄가 새 결함(이중어)을 만든다 | 치환 규칙 순서에 의존하지 말고, 최종 산출물을 사람이 한 번 읽는다 |
676
- | 빌드가 규칙별 발화 횟수를 세지 않는다 | 사문 규칙과 빗나간 규칙을 구분할 수 없다. 규칙을 지울 때는 손으로 확인한다 |
677
-
678
- ### 6.6 ★ 가장 이식 가치가 높은 패턴 — 치환 규칙 ↔ `forbid` 1:1 짝짓기
334
+ 치환을 사람이 하게 되면서 **빗나감이 더 조용해졌음.**
335
+ 그래서 짝짓기가 더 중요해짐.
679
336
 
680
337
  | `forbid` 패턴 | 짝지어진 방어 대상 |
681
338
  |---|---|
682
- | `1217\d{12}` | 티켓 번호가 어디선가 새어 들어옴 |
683
- | `[Aa]sana` | 트래커 이름 자체가 새어 들어옴 |
684
- | `운영 배포` | 치환 유형 **B**가 빗나감 |
685
- | `별도 티켓` | 치환 유형 **C**가 빗나감 |
686
- | `자동 검증 \d건` | 치환 유형 **D**가 빗나감 |
687
- | (별도 등재 없음 — `assert '§' not in doc`) | `link()`를 안 타는 텍스트가 생김 |
688
-
689
- **논리:** 완전 일치 치환은 원문이 한 글자만 바뀌어도 **조용히** 실패한다(6.5의 `\n` 사례가 실증).
690
- 그래서 **치환 규칙을 추가할 때마다, 그 규칙이 없앴어야 할 금칙어를 `forbid`에 함께 등록한다.**
691
- 치환이 빗나가면 금칙어가 살아남고, **빌드가 그 자리에서 죽는다.**
339
+ | 티켓 번호 숫자 패턴 | 번호가 어디선가 새어 들어옴 |
340
+ | 트래커 이름 | 이름 자체가 새어 들어옴 |
341
+ | `운영 배포` | 유형 **B** 를 빠뜨림 |
342
+ | `별도 티켓` | 유형 **C** 를 빠뜨림 |
343
+ | `자동 검증 \d건` | 유형 **D** 를 빠뜨림 |
692
344
 
693
- > **규칙: `text_fix` 항목 추가 = `forbid` 패턴 추가. 짝이 없는 치환 규칙은 방어되지 않는다.**
694
-
695
- `forbid`는 `text_fix`가 적용되지 않는 자리(`summary`, `open_items`, `lede`, 건 `title`)도
696
- 포함해 **산출물 전체 문자열**을 검사한다. 그래서 요약을 사람이 새로 쓰다가 "운영 배포"를
697
- 적어도 빌드가 잡는다.
345
+ > **규칙: 치환 유형을 하나 정할 때마다, 그 유형이 없앴어야 할 낱말을 `forbid` 에 함께 등록한다.**
346
+ > 짝이 없는 치환 규칙은 방어되지 않음.
698
347
 
699
348
  ---
700
349
 
701
- ## 7. 제목 재작성
350
+ ## 5. 제목 재작성
702
351
 
703
- ### 7.1 원칙
352
+ ### 5.1 원칙
704
353
 
705
- `cases[].title`은 **처음부터 보고서 표제로 쓴다.** 티켓 제목을 옮겨 놓고 나중에 고치는 자리가
706
- 아니다(빌드에 제목 덮어쓰기 표 같은 것은 없다 — `title`이 그대로 나간다).
354
+ `cases[].title` 은 **처음부터 보고서 표제로 씀.**
355
+ 티켓 제목을 옮겨 놓고 나중에 고치는 자리가 아님 — 렌더러에 제목 덮어쓰기 표 같은 것은 없음.
707
356
 
708
- **티켓 제목은 "찾기 위한 문자열"이고 보고서 표제는 "읽기 위한 문장"이다.** 목적이 다르므로
709
- 재사용하지 않고 전건 새로 쓴다.
357
+ **티켓 제목은 「찾기 위한 문자열」이고 보고서 표제는 「읽기 위한 문장」임.**
358
+ 목적이 다르므로 재사용하지 않고 전건 새로 씀.
710
359
 
711
- ### 7.2 버리는 것 / 남기는 것
360
+ ### 5.2 버리는 것 / 남기는 것
712
361
 
713
- | 버린다 | 이유 |
362
+ | 버림 | 이유 |
714
363
  |---|---|
715
- | 경로 구분자 `>` (`예약 > 컨설팅 일지 작성 버그`) | 티켓 트리 좌표. 문서에는 절 번호가 이미 있다 |
364
+ | 경로 구분자 `>` (`예약 > 일지 작성 버그`) | 티켓 트리 좌표. 문서에는 절 번호가 이미 있음 |
716
365
  | 일련번호 `#2` | 티켓 안에서만 뜻이 통하는 구분자 |
717
- | 워크플로 태그 `[후속]` | **우리 팀의 작업 상태**이지 문제의 성질이 아니다 |
718
- | 심각도 라벨 `(심각)` | 심각도는 **본문 증상이 스스로 말해야** 한다. 라벨은 독자의 판단을 대신해 버린다 |
719
- | 불확실 표시 `?` (`동기화 버그?`) | 제보 시점의 추측. **보고서는 원인이 확정된 뒤에 쓰인다** |
720
- | 내부 기술어 (`RN 앱`·`토큰 부활 경합`·`동기화`·`세션`) | 독자 어휘가 아니다 |
721
- | 단어 "버그"·"오류"·"이슈" | 명사가 아니라 **증상 서술**로 대체 |
722
- | 복수 증상 접속 `+` | 대표 증상 하나로 좁힌다 |
723
- | 대시 `—` 대비 구조 | `qa.py`의 카피 게이트가 헤드라인의 대시를 **실패**로 잡는다 |
724
- | 외래어 (`리젝`) | `반려`로 |
725
-
726
- | 남긴다 | 이유 |
727
- |---|---|
728
- | 영향의 크기를 알리는 괄호 부기 | 예시 프로젝트 실측 2개뿐: `(앱 심사 반려 사유)`, `(방침 확정)`. **"이건 왜 중요한가 / 왜 결함이 아닌가"를 제목만으로 알려야 할 때만** |
366
+ | 워크플로 태그 `[후속]` | **우리 팀의 작업 상태**이지 문제의 성질이 아님 |
367
+ | 심각도 라벨 `(심각)` | 심각도는 **본문 증상이 스스로 말해야 함.** 라벨은 독자의 판단을 대신해 버림 |
368
+ | 불확실 표시 `?` | 제보 시점의 추측. **보고서는 원인이 확정된 뒤에 쓰임** |
369
+ | 내부 기술어 (`RN 앱`·`토큰 부활 경합`·`세션`) | 독자 어휘가 아님 |
370
+ | 낱말 `버그`·`오류`·`이슈` · 외래어 `리젝` | 명사가 아니라 **증상 서술**로 대체. `리젝` 은 `반려` 로 |
371
+ | 복수 증상 접속 `+` | 대표 증상 하나로 좁힘 |
372
+ | 대시 `—` 대비 구조 | 어휘 게이트가 밀도로 잡음 (6장) |
729
373
 
730
- ### 7.3 before/after — 예시 프로젝트
374
+ **남기는 것은 하나뿐임** — 영향의 크기를 알리는 괄호 부기.
375
+ `(앱 심사 반려 사유)` 처럼 「이건 왜 중요한가」를 제목만으로 알려야 할 때만 씀.
731
376
 
732
- `after`는 `cases.json`의 `title` 그대로다. `before`는 그 프로젝트의 트래커에 있던 원 제목으로,
733
- **최종 자산에는 남기지 않는다**(3.2).
377
+ ### 5.3 before / after
734
378
 
735
379
  | before (티켓) | after (표제) | 버린 것 |
736
380
  |---|---|---|
737
- | `예약 생성 후 사전상담의 타 유저 계정에 로그인되는 이슈 #2` | `같은 전화번호를 쓰는 계정에서 사전상담이 열리지 않던 문제` | `#2`. **바로 앞 건과 제목이 완전히 동일했다** → 증상 실체로 다시 써서 두 건이 서로 다름을 제목이 스스로 말하게 함 |
738
- | `예약 > 컨설팅 일지 작성 버그 (심각)` | `같은 날 두 번째 예약의 컨설팅 일지에 이전 내용이 잘못 채워지던 문제` | `>` 경로, `버그`, `(심각)` → **조건(같은 날 두 번째) + 현상(이전 내용이 채워짐)** |
739
- | `시술 담당자 안바뀌는 오류 + 트레이드도 연동 안됨` | `예약 담당 디자이너를 바꿔도 시술 담당자가 그대로 남던 문제` | `+` 복수 증상 → 대표 1개, 구어체 `안바뀌는` → 서술체 |
740
- | `AI 녹음 관련 버그 > 컨설팅일지 동기화 버그?` | `저장한 AI 상담이 컨설팅 일지에 나타나지 않던 문제` | `>` 경로, `버그` 2회, `?` 추측, `동기화` → **사용자 행위(저장) + 관찰 결과(나타나지 않음)** |
741
- | `회원탈퇴해도 탈퇴가 안되고 로그인되는 이슈 (앱 심사 리젝 사유)` | `앱에서 회원탈퇴한 계정이 다시 로그인되던 문제 (앱 심사 반려 사유)` | `이슈`, 외래어 `리젝`. **괄호 부기는 유지** — 영향의 크기를 알리므로 |
742
- | `[후속] RN 앱 토큰 부활 경합 — 탈퇴/로그아웃 직후 세션이 되살아남` | `로그아웃·탈퇴 직후 이전 로그인 상태로 되돌아가던 문제` | `[후속]`, `RN`·`토큰`·`경합`, `—` 대시, `세션` → `이전 로그인 상태` |
381
+ | `예약 > 컨설팅 일지 작성 버그 (심각)` | `같은 날 두 번째 예약의 일지에 이전 내용이 잘못 채워지던 상황` | `>` 경로 · `버그` · `(심각)` → **조건 + 현상** |
382
+ | `AI 녹음 관련 버그 > 일지 동기화 버그?` | `저장한 상담이 일지에 나타나지 않던 상황` | `>` · `버그` 2회 · `?` 추측 → **행위 + 관찰 결과** |
383
+ | `[후속] RN 앱 토큰 부활 경합 — 탈퇴 직후 세션이 되살아남` | `로그아웃·탈퇴 직후 이전 로그인 상태로 되돌아가던 상황` | `[후속]` · `RN`·`토큰`·`경합` · `—` 대시 · `세션` |
743
384
 
744
- ### 7.4 시제·어미 규칙 ★ 한 글자가 문서 인상을 뒤집는다
385
+ ### 5.4 시제·어미 — 한 글자가 문서 인상을 뒤집는다
745
386
 
746
- **예시 프로젝트 실측: 18개 중 16개가 `…던 문제`로 끝난다.**
747
-
748
- | 규칙 | 근거 | 어기면 생기는 일 |
387
+ | 규칙 | 근거 | 어기면 |
749
388
  |---|---|---|
750
- | 표제는 **과거형**(`…되던 문제`)으로 쓴다 | 과거형이 **"지금은 고쳐졌다"를 제목만으로 전달**한다. 티켓은 문제가 살아 있을 때 쓰이므로 현재형이지만, **보고서는 조치가 끝난 뒤 쓰이므로 과거형**이다 | 현재형(`…되는 문제`)으로 쓰면 **목차만 훑는 결정권자에게 미해결 목록으로 읽힌다.** 18건을 다 고쳐 놓고 18건이 남은 것처럼 보이는 최악의 결과 |
751
- | **결함이 아닌 항목은 `문제`로 끝내지 않는다** | 실측 2건: `네이버예약 예약 가져오기 동작 제거 (방침 확정)`(방침 반영), `삭제한 녹음이 다시 나타날 수 있던 연결 기록 정리`(예방적 정리) | 전부 "문제"로 통일하면 **없던 결함을 만들어 낸다.** 어미가 항목의 성격 분류자 역할을 한다 |
752
- | 명사구 종결(`…던 문제`, `…기록 정리`)은 **허용** | 목차로 훑는 항목이라 완전문으로 바꾸면 오히려 읽기 어렵다. 상위 스킬(doc-pptx)의 "조사·체언 종결 금지"를 여기서만 완화한다(SKILL.md 카피 게이트) | 제목이 문장이 되어 목차가 장황해진다 |
753
- | 제목에 **대시 대비 구조 금지** | `qa.py`가 `<h1><h2><h3>` 안의 ` — `를 실패로 잡는다 | 빌드는 되고 QA에서 막힌다 |
754
-
755
- ### 7.5 제목 중복 회피
756
-
757
- 같은 원인에서 갈라져 나온 건은 티켓 제목이 똑같아지기 쉽다. **제목만 보고 두 건이 다르다는 걸
758
- 알 수 있어야 한다.** 위 표 첫 줄이 그 사례다 — 두 건의 티켓 제목이 문자 그대로 동일했고,
759
- 각각의 **증상 실체**(`다른 고객의 정보가 열리던` / `사전상담이 열리지 않던`)로 다시 썼다.
389
+ | 표제는 **과거형**(`…되던 상황`)으로 씀 | 과거형이 「지금은 고쳐졌다」를 제목만으로 전달함. 티켓은 문제가 살아 있을 때 쓰이므로 현재형이지만, 보고서는 조치가 끝난 뒤 쓰이므로 과거형임 | 현재형(`…되는 문제`)은 **목차만 훑는 결정권자에게 미해결 목록으로 읽힘.** 다 고쳐 놓고 다 남은 것처럼 보이는 최악의 결과 |
390
+ | 어미는 **`…던 상황`** 을 기본으로 씀 | 개조식 전환 뒤의 표기임. 예시 두 건 모두 `…던 상황` 임 | `…던 문제` 도 통하지만 문서 안에서 섞어 쓰면 두 종류가 있는 것처럼 읽힘 |
391
+ | **결함이 아닌 항목은 다른 어미로 끝냄** | 방침 반영(`… 동작 제거 (방침 확정)`) · 예방적 정리(`… 연결 기록 정리`) | 전부 「문제/상황」으로 통일하면 **없던 결함을 만들어 냄.** 어미가 항목의 성격 분류자 역할을 함 |
392
+ | 명사구 종결은 **허용** | 목차로 훑는 항목이라 완전문으로 바꾸면 오히려 읽기 어려움 | 제목이 문장이 되어 목차가 장황해짐 |
393
+ | **요약의 `lead` 와 확인 요청의 `title` 은 완전문** | 여기가 판단이 일어나는 자리임 | `표본 부족` 같은 명사구는 심각도를 감춤 |
394
+ | 같은 원인에서 갈라진 건은 **증상 실체로 갈라 씀** | 티켓 제목이 똑같아지기 쉬움. 제목만 보고 두 건이 다르다는 걸 알 수 있어야 함 | `다른 고객의 정보가 열리던` / `사전상담이 열리지 않던` 처럼 안 가르면 중복으로 읽힘 |
760
395
 
761
396
  ---
762
397
 
763
- ## 8. 빌드 자체 검사
398
+ ## 6. 어휘·톤 게이트
764
399
 
765
- ### 8.1 assert — 빌드를 죽이는 검사
400
+ 폐기한 `qa.py` 의 카피 게이트를 회수한 자리임.
401
+ 지금 이 검사를 실제로 도는 것은 `gazette-report` 의 `tone.mjs` 임.
402
+ 아래는 그 검사기가 **무엇을 재는가**임 — 실행법은 그쪽 스킬에 있음.
766
403
 
767
- `build()`가 던지는 것 전부. **경고가 아니라 예외다. 어느 하나라도 걸리면 파일이 안 나온다** — 네 검사 모두 `out.write_text()` **앞에서** 돈다. 검사에 걸린 채로 디스크에 남은 파일을 다음 사람이 고객에게 보내는 일이 없도록 순서를 그렇게 잡았다.
404
+ ### 6.1 S1 — 한 번만 나와도 고친다
768
405
 
769
- | 검사 | 코드 | 막는 사고 |
770
- |---|---|---|
771
- | 그룹 미배정 건 | `assert placed == len(cases)` | 오타 하나로 건이 문서에서 통째로 사라짐 |
772
- | 팔레트 대비 미달 | `assert not bad` (`P.audit` 17쌍) | 읽을 수 없는 색 조합이 나감 |
773
- | 원고 마커 잔존 | `assert '§' not in doc` | `link()`를 안 타는 텍스트가 생겨 `§`가 인쇄됨 |
774
- | 금칙어 유출 | `for pat in cfg.get('forbid', []): assert not re.findall(pat, doc)` — `forbid`는 선택이라 없으면 이 검사가 통째로 비활성이다 | 티켓 번호·내부 용어가 고객본에 나감 |
775
- | 미정의 등급 | (암묵) `GCLASS[c['grade']]` → `KeyError` | 등급 오타 |
776
- | 필수 필드 누락 | (암묵) `c["symptom"]` 등 → `KeyError` | 스키마 위반 |
777
-
778
- 성공하면 이렇게 찍는다. **이 세 줄을 눈으로 확인한다.**
779
-
780
- ```
781
- 생성 · ../고객사-수정내역-20260810.html 523,464B
782
- 폰트 임베드 300,544B → base64 400,728B
783
- 건 18 · 등급 {'실데이터 대조': 4, '운영 환경 확인': 10, '재발 점검 적용': 1, '반영 확인': 3} · 확인요청 6
784
- 유출 점검: 통과
785
- ```
786
-
787
- | 줄 | 확인할 것 |
788
- |---|---|
789
- | 폰트 | `폰트 임베드 생략`이 찍혔는데 `font.embed`를 끈 적이 없다면 파일 경로가 틀렸다 |
790
- | 건 수 | `report.json`의 `stats`·`meta`·절 이름에 적은 숫자와 **손으로 대조한다** (8.3) |
791
- | 등급 분포 | 특정 등급에 몰려 있으면 등급을 성의 없이 매긴 것이다 |
792
-
793
- ### 8.2 `qa.py`로 이어지는 것
794
-
795
- 빌드는 **원고 규칙**만 본다. 산출물 검사는 `qa.py`가 맡는다(항목 전체는 `references/qa.md`).
796
- 콘텐츠 파이프라인과 직결되는 것만 옮기면:
797
-
798
- | 검사 | 잡는 것 |
406
+ | 검사 | 기준 |
799
407
  |---|---|
800
- | 구조 일관성 — `절 번호 / 증상 / 원인 / 조치 / 확인 방법 / 등급 칩`이 각각 건 수와 일치하는가 | **한 건만 `how_to_check`가 비어도 실패.** 눈으로는 못 찾는다 |
801
- | 목차 항목 수 · 판정표 행 수 == 건 수 | 편집기 셀렉터와 마크업이 어긋나 목차·표가 비는 사고 |
802
- | 죽은 절 참조 — `href="#sN-M"`가 실제 존재하는 `id`를 가리키는가 | 지운 건을 참조하는 `§`가 남음 |
803
- | `§` 잔존 · `forbid` 패턴 | 빌드 assert와 **같은 검사를 산출물에서 한 번 더** |
804
- | 카피 게이트 — 헤드라인 대시, AI 슬롭 어휘 8종 | 7.4의 제목 규칙 |
805
- | 고객본 내보내기 시뮬레이션 | 편집기 레이어가 실제로 걷어내지는가 (아래) |
806
-
807
- **예시 프로젝트 현재 상태: 통과 48 · 경고 1 · 실패 0.** 경고 1은 `report.css`에 남은
808
- `rgba()` 리터럴 2건(표지 구분선, xref 밑줄)이다 — 팔레트와 대조할 수 없다는 경고이지 오류가 아니다.
408
+ | **경어체 잔존** | `습니다 · 십시오 · 합니다 · 입니다 · 됩니다 · 드립니다 · 주세요 · 하세요 · 해요` → **0건** |
409
+ | **한 문장 한 줄** | 문장 경계가 있는데 `<span class="sn">` 도 `<br>` 도 없는 블록 → **0개** |
809
410
 
810
- ### 8.3 검사가 **못 잡는 것** — 건수 리터럴
411
+ 개조식은 `~함 / ~했음 / ~필요 / ~한 상황` 임.
412
+ 문체를 안 바꾸면 관보 문법 위에서 겉돎 — **낱말이 아니라 조판의 문제임.**
811
413
 
812
- `report.json`의 아래 자리에 적은 숫자는 **데이터에서 파생되지 않는다.**
813
-
814
- | 자리 | 예시 |
815
- |---|---|
816
- | `subtitle` | `"… 처리한 18건의 수정 내역과 …"` |
817
- | `description` | `"… 18건의 수정 내역과 확인 요청 사항"` |
818
- | `meta` | `["처리 건수", "18건 (접수 9 · 자체 발견 9)"]` |
819
- | `stats[].value` | `"18"`, `"9"`, `"9"`, `"6"` |
820
- | `sections[].name` | `"접수하신 9건"`, `"함께 발견해 처리한 9건"` |
821
- | `sections[].lede` | `"18건 전부의 처리 결과와 …"` |
414
+ ### 6.2 S2 — 늘어짐 지표 (천 자당 밀도)
822
415
 
823
- **건을 추가·삭제하면 이 숫자들이 조용히 거짓말을 한다.** 빌드도 QA도 잡지 못한다.
824
- 빌드 로그의 `건 18 · … · 확인요청 6`을 위 자리들과 손으로 대조하는 것이 유일한 방어다.
825
- 건 수가 바뀌는 작업을 했다면 **이 목록을 체크리스트로 한 번 훑는다.**
416
+ 3회 이상 나오면서 한도를 넘을 때만 지적됨.
826
417
 
827
- ### 8.4 편집기 레이어의 계약 (콘텐츠 쪽에서 알아야 할 것)
418
+ | 지표 | 한도 | 처방 |
419
+ |---|---|---|
420
+ | 문두 접속사 (`또한`·`따라서`·`즉`·`나아가`·`아울러`·`게다가`·`더욱이`·`그리고`) | 1.5 | 70% 이상 제거. 논리 흐름은 문장 내용으로 표현 |
421
+ | 추측·관측 종결 (`보임`·`판단됨`·`여겨짐`·`듯함`) | 1.0 | 근거가 있으면 단정. 없으면 「확인하지 못함」으로 범위를 명시 |
422
+ | 이중 완곡 (`가능성이 있을 수`·`수도 있을`) | 0.2 | 완곡은 하나만 |
423
+ | 「이는 ~」 지시 (`이는 `·`이 점에서`·`이 말은`) | 1.0 | 앞 문장과 통합. 메타 진입 삭제 후 본 서술 직진 |
424
+ | 형식명사 결합 (`라는 점에 있`·`다는 것이`·`할 필요가 있`) | 1.0 | 구체 명사·동사로 치환 |
425
+ | 괄호 부연 (6자 이상) | 2.5 | 근거·출처 표기는 남기고 설명 부연은 본문화 |
426
+ | 낫표 `「」` | 5.0 | 용어 첫 등장 1회만. 이후는 평문 |
427
+ | 대시 `—` | 0.5 | 쉼표·괄호·별도 문장으로 분해 |
428
+ | 인라인 강조 (`<b>`·`<strong>`) | 5.0 | 굵게는 요지에만. 구조 라벨은 세지 않음 |
828
429
 
829
- 빌드는 편집 레이어를 `<style data-rte-src>` / `<script data-rte-src>`와
830
- `<!-- REPORT-EDITOR:BEGIN --> … <!-- REPORT-EDITOR:END -->`로 감싸 출력한다.
831
- `editor.js`의 **고객본 내보내기가 정확히 이 표식을 찾아 들어낸다.**
832
- `qa.py`가 그 내보내기를 시뮬레이션해 잔존물(`data-rte-ui`, `data-report-edit`, …)을 검사한다.
430
+ ### 6.3 게이트가 못 잡는 것 — 건수 리터럴
833
431
 
834
- 콘텐츠 쪽 귀결은 셋이다.
432
+ 아래 자리에 적은 숫자는 **데이터에서 파생되지 않음.**
835
433
 
836
- | 사실 | 귀결 |
434
+ | 자리 | 예 |
837
435
  |---|---|
838
- | 판정표에는 `data-rte-derived`가 붙는다 | **판정표는 편집기가 문서에서 다시 만든다.** 손으로 행을 넣어도 다음 동기화에서 사라진다. 내용을 고치려면 건 카드를 고친다 |
839
- | 목차도 문서에서 재생성된다 | 같음. 목차 항목을 직접 고치지 않는다 |
840
- | 절 번호도 `data-secno`에서 다시 매겨진다 | 2.2의 `no` 일치 규칙이 여기서 온다 |
436
+ | `subtitle` · `description` | `이번 회차에 처리한 2건과 …` |
437
+ | `meta` | `["처리 건수", "2건 (접수 1 · 자체 발견 1)"]` |
438
+ | `stats[].value` | `"2"` · `"1"` |
439
+ | `sections[].name` | `접수하신 1건` |
440
+ | `sections[].lede` · `notice.text` · `bar[].value` | `접수 1건 처리 완료` |
441
+ | `remain` | 생략하면 `open_items` 개수로 자동. **적으면 그 값이 이김** |
841
442
 
842
- 마크업 계약 전체는 `references/wysiwyg.md`.
443
+ **건을 추가·삭제하면 이 숫자들이 조용히 거짓말을 함.**
444
+ 렌더 로그의 `절 5 · 건 2 · 확인요청 1` 을 위 자리들과 손으로 대조하는 것이 유일한 방어임.
843
445
 
844
446
  ---
845
447
 
846
- ## 9. 이식 체크리스트
847
-
848
- 새 프로젝트를 시작할 때 순서대로.
849
-
850
- 1. **독자를 못 박는다** (0장). 누가 읽고, 읽고 나서 무엇을 하는가.
851
- 2. **등급 체계를 정한다** (2.3). 강한 순으로 선언하고, 가장 약한 등급의 설명에 한계를 적는다.
852
- 3. **`groups`와 `sections`를 정한다** (2.2, 4.1). `no`를 두 곳에서 일치시킨다.
853
- 4. **건을 채운다** (3장). `how_to_check`는 전건 필수라고 생각하고 채운다.
854
- 5. **제목을 전건 새로 쓴다** (7장). 과거형, 결함 아닌 건은 다른 어미.
855
- 6. **요약 4칸을 쓴다** (2.4). `lead`는 완전문.
856
- 7. **원고에 `§3.1` 참조를 직접 쓴다** (4.2).
857
- 8. **`text_fix`를 쓰면서 `forbid`를 같이 늘린다** (6.6). 짝이 없는 치환은 방어되지 않는다.
858
- 9. **빌드 → 로그 3줄 확인 → 건수 리터럴 대조**(8.3) **→ `qa.py` 실패 0 → 브라우저로 눈 검수.**
448
+ ## 7. 이 문서가 책임지는 검수 항목
859
449
 
860
- ### 9.1 그대로 옮길 만한 것 (다른 문서 시스템으로 갈 때)
450
+ 검수 절차 전체는 `references/process.md` 7단계가 원전임.
451
+ 여기서 나온 규칙 중 **기계가 안 잡는 것**만 다시 적음.
861
452
 
862
- | 패턴 | 왜 |
453
+ | 항목 | 무엇을 대조하는가 |
863
454
  |---|---|
864
- | 치환 규칙 ↔ 금칙어 1:1 짝짓기 | 조용한 실패를 시끄러운 실패로 바꾸는 유일한 방법 |
865
- | 원고용 sentinel → 렌더에서 소멸 + 잔존을 하드 실패로 | 공짜 검사기를 얻는다 |
866
- | `chunk()`의 JOSA 판별 + 종결어미 문장 분절 | 한국어 보고서 자동 조판의 재사용 가능한 핵심. **실패 예(5.2)를 함께 옮길 것** — 규칙만 옮기면 다음 사람이 "이거 왜 있지?" 하고 지운다 |
867
- | 등급에 한계 설명을 함께 싣기 + 강도 순 정렬 | 등급 체계 신뢰의 근원 |
868
- | 제목 전건 재작성 + 과거형 어미 | 목차만 훑는 독자에게 상태를 전달 |
869
- | `e()` → `link()` 순서 고정 | 순서를 바꾸면 태그가 화면에 보인다 |
870
- | 구조 일관성 검사(각 요소 수 == 건 수) | 반쪽 렌더는 눈으로 못 찾는다 |
455
+ | 번호 정합 | `groups[].no` == 그 그룹을 펴는 `kind:"cases"` 절의 자리 (2.2) |
456
+ | 건수 리터럴 | 렌더 로그의 `절 N · 건 N · 확인요청 N` 을 6.3 의 자리들과 손으로 대조 |
457
+ | 치환 누락 | 4.2 의 열 유형을 원고에서 훑음. `text_fix` 는 안 도므로 사람이 유일한 관문임 |
458
+ | 표기 갈림 | 인라인 `§n.n` 을 쓴 자리가 있으면 `refs[]` 로 옮길 수 있는지 검토 (2.4) |
871
459
 
872
460
  ---
873
461
 
874
- ## 10. 미확정
875
-
876
- 소스와 실사례만으로 확정할 수 없어 남긴 것.
877
-
878
- 1. **요약(`summary`)과 확인 요청(`open_items`)의 집필 절차.** 지금은 사람 또는 상위 에이전트가
879
- `report.json`에 직접 쓴다. `lead`/`body`를 어떤 지침으로 뽑는지는 규약으로 굳어 있지 않다.
880
- 4칸 구성(2.4)만 관례로 남아 있다.
881
- 2. **`grades` 값의 판정 주체·시점.** `cases[].grade`가 어떤 기준으로 부여되는지(누가, 언제,
882
- 무엇을 근거로)는 데이터에 결과만 있고 절차가 없다. 자기 신고를 검증할 장치가 없다.
883
- 3. **절 `lede`의 사실 주장.** 예: `"두 건은 접수된 어느 건보다 영향이 컸습니다"`. 사람이
884
- 판단해 쓴 문장이고 데이터에서 파생되지 않는다. 그 판단 기준은 기록되어 있지 않다.
885
- 4. **건수 리터럴의 자동화 여부.** 8.3의 자리들을 `{cases:count}` 같은 치환으로 바꿀 수 있으나,
886
- 그러면 `qa.py`의 "미치환 플레이스홀더" 검사와 표기 방식을 함께 설계해야 한다. 지금은
887
- 손 대조로 남겨 두었다.
888
- 5. **`ol.enum .n` 23px 고정폭에서 `3·4·5`가 실제로 넘치는지.** CSS 값으로는 넘칠 수 있는
889
- 조건이나 브라우저 렌더로 확인하지 않았다. 예시 프로젝트에는 `3·4`까지만 나왔다.
890
- 6. **`labels`로 덮을 수 없는 문구**(판정표의 `있음`/`—`, 범례의 `N건`). 한국어 외 언어로 낼
891
- 요구가 실제로 있을지 미정이라 `build.py`에 남겨 두었다.
462
+ ## 8. 미확정 · 확인 못 한 것
463
+
464
+ 1. **서버가 실제로 422 를 돌려주는지 런타임으로 확인하지 못함.**
465
+ `report_publish.php:157-287` 의 검증 코드는 읽었고, 어긋난 `kind`·없는 그룹·근거 없는 요약이 오류 목록에 담기는 것까지는 코드로 확인함.
466
+ 응답 코드 자체는 확인 못 함.
467
+ 2. **`dry_run` 의 `warnings` 가 렌더러의 ⚠ 와 같은 목록인지 확인 못 함.**
468
+ 로컬 렌더의 ⚠ 는 실측했음.
469
+ 3. **건수 리터럴의 자동화 여부.**
470
+ `{cases:count}` 같은 치환으로 바꿀 수 있으나 미치환 플레이스홀더 검사를 함께 설계해야 함.
471
+ 지금은 손 대조로 남겨 둠.
472
+ 4. **`assets/example/report.json` 의 `sections[].no`·`id` 는 지금 아무 일도 하지 않음.**
473
+ 같은 파일의 `label` 이 실제로 찍히는 값임.
474
+ 이 문서에 그 사실을 적었으나 예시 파일 자체는 고치지 않았음 — 이번 작업의 범위 밖임.