genetics-mcp 2.23.0 → 2.25.0

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.
Files changed (33) hide show
  1. package/bin/aarch64-apple-darwin/gcell +0 -0
  2. package/bin/aarch64-unknown-linux-musl/gcell +0 -0
  3. package/bin/manifest.json +5 -5
  4. package/bin/x86_64-apple-darwin/gcell +0 -0
  5. package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
  6. package/bin/x86_64-unknown-linux-musl/gcell +0 -0
  7. package/genetics.mjs +2068 -239
  8. package/package.json +2 -2
  9. package/skills/gazette-report/SKILL.md +11 -10
  10. package/skills/gazette-report/scripts/tone.mjs +17 -5
  11. package/skills/gazette-report/scripts/tone.test.mjs +15 -2
  12. package/skills/gazette-report/template/skeleton.html +10 -10
  13. package/skills/genetics-define/SKILL.md +2 -0
  14. package/skills/genetics-report/SKILL.md +123 -102
  15. package/skills/genetics-report/assets/example/cases-briefing.json +1 -1
  16. package/skills/genetics-report/assets/example/cases.json +3 -3
  17. package/skills/genetics-report/assets/example/report-briefing-designs.json +26 -26
  18. package/skills/genetics-report/assets/example/report-briefing.json +3 -3
  19. package/skills/genetics-report/assets/example/report.json +11 -11
  20. package/skills/genetics-report/assets/mcp-call.mjs +160 -0
  21. package/skills/genetics-report/assets/patch.py +411 -0
  22. package/skills/genetics-report/assets/render.py +223 -0
  23. package/skills/genetics-report/assets/xlsx-plate.py +203 -0
  24. package/skills/genetics-report/references/content-pipeline.md +26 -26
  25. package/skills/genetics-report/references/edition-review.workflow.js +120 -23
  26. package/skills/genetics-report/references/font-embedding.md +1 -1
  27. package/skills/genetics-report/references/history.md +76 -2
  28. package/skills/genetics-report/references/lim-portal-format.md +109 -48
  29. package/skills/genetics-report/references/linear-read-test.workflow.js +50 -14
  30. package/skills/genetics-report/references/process.md +54 -48
  31. package/skills/genetics-report/references/reader-review.workflow.js +118 -33
  32. package/skills/genetics-report/references/visual-first.md +34 -17
  33. package/skills/gazette-report/scripts/package-lock.json +0 -24
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env python3
2
+ """원고 부분 고치기 — **멱등**. 회차마다 스크립트를 다시 짓지 않게 한다.
3
+
4
+ ## 왜 이 파일이 생겼나 (2026-09-22)
5
+
6
+ 포털은 「통째로 바꾸기」(`update_report`)만 준다. 그래서 회차마다 「이 문장을 저 문장으로」를
7
+ 적용하는 파이썬을 고객사 문서 폴더에 새로 지었고, 그것이 쌓였다 — 실측: 앳플리 #31 의
8
+ 문서 폴더에 20개 2,563줄, RDS 8.4 폴더에 46개 4,285줄(`edit-<날짜><글자>.py` 만 34개).
9
+ 매번 같은 뼈대(`rep`·`setv`·`droprows`·멱등·금지어 쓸기)를 다시 썼고, 다시 쓸 때마다 같은
10
+ 결함을 다시 만들었다(2026-09-21: `zip` 이 짧은 쪽에 맞춰 잘리는 탓에 `droprows` 가 두 번째
11
+ 실행에서 남은 줄 하나를 지웠다 — 여기서는 길이 검사로 막는다).
12
+
13
+ **고치는 내용은 그 회차의 것이지만, 고치는 방법은 아니다.** 방법만 여기 두고,
14
+ 회차 폴더에는 **데이터 파일 하나**(edits.json)만 남긴다.
15
+
16
+ ## 쓰는 법
17
+
18
+ python3 patch.py report.json edits.json # 적용
19
+ python3 patch.py report.json edits.json --dry # 재 보기만(파일 안 건드림)
20
+ python3 patch.py base.json draft.json --freeze edits.json # draft 의 set 에 old 를 base 에서 채워 굳힘(원고는 안 건드림)
21
+
22
+ `edits.json` 은 목록이고, 각 항목은 `op` 하나다. `path` 는 원고 JSON 의 자리를
23
+ `["sections", 1, "lede"]` 처럼 적는다(문자열 `"sections[1].lede"` 도 받는다).
24
+ 줄은 번호 대신 **첫 칸 글자**로 가리킬 수 있다 — `rows[label=관리자 통계 빈칸]`(목록 꼴은 `{"label": "…"}`). 앞 op 가 줄을
25
+ 끼우거나 빼도 같은 줄을 가리킨다. 보는 글자는 표 줄의 첫 칸 · 흐름도 줄의 `cells[0].text` · 발행 이력 줄의 `no` 뿐이고
26
+ (도표 `rows` 의 `label` 칸은 보지 않는다), 그 글자인 줄이 **정확히 하나**일 때만 찾는다 — 없거나 둘 이상이면 멈춘다.
27
+ **`[label=X]` 는 X 를 안 바꾸는 칸에만 쓴다** — 그 줄의 첫 칸(흐름도 `cells[0].text` · 발행 이력 `no`)을 바꾸면 두 번째 실행이 X 를 못 찾아
28
+ 멱등이 깨지므로, 그런 `set`·`replace` 는 돌리기 전에 멈춘다. 첫 칸을 바꾸는 편집은 번호 경로로 적는다(`--freeze` 가 `old` 를 채운다).
29
+ 배열 원소는 `assets[id=fig-a]` 처럼 `id` 로도 가리킨다 — 끝자리여도 된다(그 `id` 인 원소가 정확히 하나일 때만). 없는 자리(없는 칸 · 넘친 번호)는
30
+ 만들지 않고 멈춘다 — 칸을 더하려면 그 위 객체를 통째로 `set` 한다.
31
+
32
+ | op | 칸 | 하는 일 |
33
+ |---|---|---|
34
+ | `set` | `path` · `old` · `new` | 그 자리 값이 `old` 면 `new` 로. 이미 `new` 면 건너뛴다 |
35
+ | `replace` | `path` · `old` · `new` | 그 자리 **문자열 안**의 조각을 바꾼다 |
36
+ | `drop_rows` | `path` · `labels` | 앞줄들을 뺀다. 첫 칸 글자가 `labels` 와 **정확히** 같을 때만 |
37
+ | `remove_row` | `path` · `label` | 첫 칸이 그 글자인 줄 하나를 뺀다(자리는 어디든). 없으면 건너뛰고, 둘 이상이면 멈춘다 |
38
+ | `append_row` | `path` · `row` | 없으면 뒤에 붙인다(있으면 건너뛴다) |
39
+ | `insert_row` | `path` · `row` · `before` 또는 `after` | 없으면 첫 칸이 그 글자인 줄 앞(뒤)에 끼운다. 있는지를 자리보다 **먼저** 본다(있으면 건너뛴다) |
40
+ | `forbid` | `words` | 원고 전체에서 그 낱말이 남았는지 **센다**(안 고친다) |
41
+
42
+ 모든 op 는 **멱등**이다 — 두 번 돌리면 두 번째는 「바꿈 0」이어야 하고, 그것이 이 도구의 계약이다.
43
+ 값이 `old` 도 `new` 도 아니면 **멈춘다** — 그 사이에 누가 고쳤다는 뜻이고, 조용히 덮으면 안 된다.
44
+ 끼울 줄(`append_row`·`insert_row` 의 `row`)은 **최종 값**으로 적는다 — 끼운 줄을 다른 op 로 또 고치면 두 번째 실행이 한 번 더 끼운다.
45
+
46
+ ## draft 와 `--freeze` (2026-09-29)
47
+
48
+ 긴 표 행의 `old` 를 손으로 옮기기 어려워, 한 수정 판에서는 스크래치 스크립트가 받은 판의 값을 읽어 `old` 를 채웠다.
49
+ 이제 draft 에는 `set` 의 `new` 만 적고, `--freeze` 가 base(`get_report` 로 받아 둔 판)에서 `old` 를 채워 OUT 에 쓴다.
50
+ 멈추는 때(그때는 OUT 을 안 쓴다): 편집이 0개 · OUT 이 비었거나 받은 판·draft 와 같은 파일 · set 한 자리를 다른 op 가 또 고침 ·
51
+ 줄을 끼우거나 뺀 표(`drop_rows`·`remove_row`·`insert_row` 뒤)의 칸을 번호로 가리키는 `old` 없는 `set` — `old` 는 받은 판 번호로 뜨는데
52
+ 적용은 밀린 번호로 돌아, 두 칸 값이 우연히 같으면 엉뚱한 줄을 고치고도 통과한다(2026-09-30 검토 재현 — 실제 표 칸의 8~17%). 그 줄은
53
+ `[label=…]` 로 가리키거나 `old` 를 손으로 적는다(그때 번호는 앞 op 를 적용한 뒤의 번호) · base 사본에 두 번 돌려 첫 번째에서 건너뛰거나
54
+ 멈춤(base 에 이미 새 값이 있거나, 손으로 적은 번호 경로가 앞 op 가 민 번호가 아니다) · 두 번째에서 멈추거나 「바꿈 0」이 아님(멱등이 아니다 —
55
+ 번호 경로와 같은 표를 줄 op 가 밀었거나, 끼운 줄을 다른 op 로 또 고쳤다). 멈춘 op 가 줄을 끼우거나 뺀 표를 번호로 가리키면 그 까닭을 이름으로 말한다.
56
+ `--dry` 와 `--freeze` 는 함께 쓰지 않는다.
57
+ 남은 금지어는 적용 때와 같은 규칙이다 — OUT 은 쓰고 종료 코드 1 로 끝난다.
58
+ 편집이 도판 칸(`assets…`)을 고치면 적용·굳히기가 한 줄 알린다 — 교체 인자에서 `assets` 를 빼면 서버가 저장된 도판(캡션·출처 줄·alt 포함)을
59
+ 그대로 이으므로, 그 판은 새 판의 `assets` 를 `get_report` 표식째 싣는다.
60
+ **`old` 는 적용할 때 채우지 않는다** — 그러면 「그 사이 누가 고쳤다」를 못 잡는다. 적용에 `old` 없는 `set` 이 오면 멈춘다.
61
+ """
62
+ import argparse
63
+ import copy
64
+ import json
65
+ import os
66
+ import re
67
+ import sys
68
+
69
+ # 자산 바이트·도해 SVG 는 글자가 아니다 — `forbid` 가 훑지 않는다.
70
+ SKIP_KEYS = {'data', 'file', 'ref', 'mime', 'provider', 'svg'}
71
+ _TOK = re.compile(r'([A-Za-z_]\w*)|\[(\d+)\]|\[id=([^\]]+)\]|\[label=([^\]]+)\]|\.')
72
+
73
+
74
+ def parse_path(p):
75
+ """`["sections", 1, "lede"]` 또는 `"sections[1].lede"` → 자리 목록.
76
+
77
+ `assets[id=fig-a]` 꼴도 받는다 — 배열에서 `id` 로 찾는다(자리 번호가 흔들려도 안 깨진다 · 끝자리여도 된다 — `_find_id`).
78
+ `rows[label=…]` 는 첫 칸 글자로 줄을 찾는다(`_find_label`).
79
+ """
80
+ if isinstance(p, list):
81
+ return list(p)
82
+ out = []
83
+ for name, idx, aid, lab in _TOK.findall(str(p)):
84
+ if name:
85
+ out.append(name)
86
+ elif idx:
87
+ out.append(int(idx))
88
+ elif aid:
89
+ out.append({'id': aid})
90
+ elif lab:
91
+ out.append({'label': lab})
92
+ return out
93
+
94
+
95
+ def locate(doc, path):
96
+ """자리 목록 → (부모, 열쇠). 마지막 한 칸은 남긴다 — 그 칸도 있어야 한다(`_step`)."""
97
+ cur = doc
98
+ for k in path[:-1]:
99
+ cur = cur[_step(cur, k, path)]
100
+ return cur, _step(cur, path[-1], path)
101
+
102
+
103
+ def _step(cur, k, path):
104
+ """한 칸의 열쇠 — `[label=…]`·`[id=…]` 는 번호로 푼다. 없는 자리는 트레이스백 대신 경로를 말하고 멈춘다(만들지 않는다)."""
105
+ if isinstance(k, dict) and 'label' in k:
106
+ k = _find_label(cur, k['label'])
107
+ elif isinstance(k, dict):
108
+ k = _find_id(cur, k['id'])
109
+ if not ((isinstance(cur, dict) and isinstance(k, str) and k in cur)
110
+ or (isinstance(cur, list) and isinstance(k, int) and -len(cur) <= k < len(cur))):
111
+ sys.exit('✗ 그 자리가 없습니다 @ %s (%r) — 없는 칸은 만들지 않습니다(칸을 더하려면 그 위 객체를 통째로 set)'
112
+ % (json.dumps(path, ensure_ascii=False), k))
113
+ return k
114
+
115
+
116
+ def _find_id(items, aid):
117
+ """`id` 가 `aid` 인 원소의 번호 — 없거나 둘 이상이면 멈춘다(`_find_label` 과 같은 규칙)."""
118
+ hits = [i for i, x in enumerate(items if isinstance(items, list) else []) if isinstance(x, dict) and x.get('id') == aid]
119
+ if len(hits) != 1:
120
+ sys.exit('✗ id 가 「%s」인 칸이 %d개입니다 — 하나여야 합니다' % (aid, len(hits)))
121
+ return hits[0]
122
+
123
+
124
+ def _label(r):
125
+ """줄을 알아보는 글자 — 표는 첫 칸, 흐름도는 첫 칸의 글."""
126
+ if isinstance(r, dict):
127
+ cells = r.get('cells') or []
128
+ return (cells[0] or {}).get('text', '') if cells else str(r.get('no', ''))
129
+ return r[0] if r else ''
130
+
131
+
132
+ def _find_label(rows, label):
133
+ """첫 칸 글자가 `label` 인 줄의 번호 — 없거나 둘 이상이면 멈춘다(앞 op 가 줄을 끼우거나 빼도 같은 줄)."""
134
+ hits = [i for i, r in enumerate(rows if isinstance(rows, list) else []) if str(_label(r)) == str(label)]
135
+ if len(hits) != 1:
136
+ sys.exit('✗ 첫 칸이 「%s」인 줄이 %d개입니다 — 하나여야 합니다' % (label, len(hits)))
137
+ return hits[0]
138
+
139
+
140
+ def _relabels(e):
141
+ """`[label=X]` 로 찾은 줄의 첫 칸 X 를 바꾸는 set·replace 면 X — 두 번째 실행이 그 줄을 못 찾는다(멱등이 깨진다). 아니면 None."""
142
+ if e.get('op') not in ('set', 'replace') or 'path' not in e:
143
+ return None
144
+ p = parse_path(e['path'])
145
+ for i, k in enumerate(p):
146
+ if not (isinstance(k, dict) and 'label' in k):
147
+ continue
148
+ rest, x, new = p[i + 1:], str(k['label']), e.get('new')
149
+ if e['op'] == 'replace':
150
+ after = x.replace(str(e.get('old')), str(new)) if rest in ([0], ['no'], ['cells', 0, 'text']) else x
151
+ elif not rest:
152
+ after = _label(new) if isinstance(new, (list, dict)) else None
153
+ elif rest in ([0], ['no'], ['cells', 0, 'text']):
154
+ after = new
155
+ elif rest == ['cells', 0]:
156
+ after = new.get('text', '') if isinstance(new, dict) else None
157
+ elif rest == ['cells']:
158
+ after = _label({'cells': new}) if isinstance(new, list) else None
159
+ else:
160
+ after = x
161
+ if after is None or str(after) != x:
162
+ return x
163
+ return None
164
+
165
+
166
+ def walk_strings(o, path=''):
167
+ if isinstance(o, dict):
168
+ for k, v in o.items():
169
+ if k in SKIP_KEYS:
170
+ continue
171
+ yield from walk_strings(v, '%s.%s' % (path, k) if path else k)
172
+ elif isinstance(o, list):
173
+ for i, v in enumerate(o):
174
+ yield from walk_strings(v, '%s[%d]' % (path, i))
175
+ elif isinstance(o, str):
176
+ yield path, o
177
+
178
+
179
+ class Run:
180
+ def __init__(self, doc):
181
+ self.doc, self.done, self.skipped, self.left = doc, 0, 0, []
182
+
183
+ def _where(self, e):
184
+ return e.get('why') or e.get('path') or e.get('op')
185
+
186
+ def set(self, e):
187
+ if 'old' not in e:
188
+ sys.exit('✗ set 에 old 가 없습니다 @ %s — draft 는 --freeze 로 먼저 굳힙니다' % self._where(e))
189
+ parent, key = locate(self.doc, parse_path(e['path']))
190
+ cur = parent[key]
191
+ if cur == e['new']:
192
+ self.skipped += 1
193
+ return
194
+ if cur != e['old']:
195
+ sys.exit('✗ 값이 다릅니다 @ %s\n 지금: %r\n 기대: %r\n'
196
+ ' (그 사이에 누가 고쳤습니다 — 덮지 않고 멈춥니다)'
197
+ % (self._where(e), cur, e['old']))
198
+ parent[key] = e['new']
199
+ self.done += 1
200
+
201
+ def replace(self, e):
202
+ parent, key = locate(self.doc, parse_path(e['path']))
203
+ cur = parent[key]
204
+ if not isinstance(cur, str):
205
+ sys.exit('✗ 문자열이 아닙니다 @ %s — %r' % (self._where(e), type(cur).__name__))
206
+ if e['old'] in cur:
207
+ parent[key] = cur.replace(e['old'], e['new'])
208
+ self.done += 1
209
+ elif e['new'] in cur:
210
+ self.skipped += 1
211
+ else:
212
+ sys.exit('✗ 옛 조각도 새 조각도 없습니다 @ %s\n 지금: %r\n 찾던 것: %r'
213
+ % (self._where(e), cur, e['old']))
214
+
215
+ def drop_rows(self, e):
216
+ """⚠ 길이를 **먼저** 잰다 — zip 은 짧은 쪽에 맞춰 잘리므로, 이 검사가 없으면
217
+ 두 번째 실행이 남은 줄을 지운다(2026-09-21 실측 사고)."""
218
+ parent, key = locate(self.doc, parse_path(e['path']))
219
+ rows, labels = parent[key], e['labels']
220
+ if len(rows) < len(labels) or [_label(r) for r in rows[:len(labels)]] != labels:
221
+ self.skipped += 1
222
+ return
223
+ del rows[:len(labels)]
224
+ self.done += 1
225
+
226
+ def append_row(self, e):
227
+ parent, key = locate(self.doc, parse_path(e['path']))
228
+ rows = parent[key]
229
+ if e['row'] in rows:
230
+ self.skipped += 1
231
+ return
232
+ rows.append(e['row'])
233
+ self.done += 1
234
+
235
+ def insert_row(self, e):
236
+ """`before`/`after` 줄 옆에 끼운다. 있는지를 자리보다 **먼저** 본다 — 두 번째 실행이 자리 줄을 못 찾아도 멈추지 않게."""
237
+ parent, key = locate(self.doc, parse_path(e['path']))
238
+ rows = parent[key]
239
+ if e['row'] in rows:
240
+ self.skipped += 1
241
+ return
242
+ if ('before' in e) == ('after' in e):
243
+ sys.exit('✗ insert_row 는 before · after 중 하나만 적습니다 @ %s' % self._where(e))
244
+ at = _find_label(rows, e['before'] if 'before' in e else e['after'])
245
+ rows.insert(at if 'before' in e else at + 1, e['row'])
246
+ self.done += 1
247
+
248
+ def remove_row(self, e):
249
+ """첫 칸이 `label` 인 줄 하나를 뺀다 — 없으면 이미 뺀 것으로 건너뛰고, 둘 이상이면 어느 줄인지 모르니 멈춘다."""
250
+ parent, key = locate(self.doc, parse_path(e['path']))
251
+ rows = parent[key]
252
+ hits = [i for i, r in enumerate(rows) if str(_label(r)) == str(e['label'])]
253
+ if not hits:
254
+ self.skipped += 1
255
+ return
256
+ if len(hits) > 1:
257
+ sys.exit('✗ 첫 칸이 「%s」인 줄이 %d개입니다 — 어느 줄을 뺄지 모릅니다 @ %s' % (e['label'], len(hits), self._where(e)))
258
+ del rows[hits[0]]
259
+ self.done += 1
260
+
261
+ def forbid(self, e):
262
+ for w in e['words']:
263
+ for p, s in walk_strings(self.doc):
264
+ if w in s:
265
+ i = s.find(w)
266
+ self.left.append((p, w, s[max(0, i - 25):i + 25]))
267
+
268
+
269
+ OPS = {'set': 'set', 'replace': 'replace', 'drop_rows': 'drop_rows', 'remove_row': 'remove_row',
270
+ 'append_row': 'append_row', 'insert_row': 'insert_row', 'forbid': 'forbid'}
271
+
272
+
273
+ SHIFTERS = ('drop_rows', 'remove_row', 'insert_row') # 목록 번호를 미는 op — append_row 는 끝에만 붙여 앞 번호를 안 민다
274
+
275
+
276
+ def freeze(doc, edits):
277
+ """draft → edits: `set` 에 빠진 `old` 를 base(doc)에서 채우고, base 사본에 두 번 돌려 멱등을 잰다(doc 은 안 건드린다)."""
278
+ if not isinstance(edits, list) or not edits:
279
+ sys.exit('✗ 편집이 0개입니다 — draft 는 편집 목록(JSON 배열)이어야 합니다(받은 판과 draft 의 자리를 바꿔 주지 않았는지)')
280
+ # 줄을 끼우거나 뺀 표의 칸을 번호로 굳히지 않는다 — old 는 받은 판 번호로 뜨는데 적용은 밀린 번호로 돈다(머리 주석).
281
+ shifted = []
282
+ for e in edits:
283
+ p = parse_path(e['path']) if 'path' in e else []
284
+ if e['op'] == 'set' and 'old' not in e:
285
+ for s in shifted:
286
+ if len(p) > len(s) and p[:len(s)] == s and isinstance(p[len(s)], int):
287
+ sys.exit('✗ 줄을 끼우거나 뺀 표의 칸을 번호로 굳히지 않습니다 @ %s — 그 줄은 [label=…] 로 가리키거나, '
288
+ 'set 의 old 를 손으로 적습니다(그때 번호는 앞 op 를 적용한 뒤의 번호)' % (e.get('why') or e['path']))
289
+ if e['op'] in SHIFTERS:
290
+ shifted.append(p)
291
+ # 사슬 — set 한 자리(또는 그 안)를 다른 op 가 또 고치면 첫 실행은 되고 두 번째 실행이 멈춘다(표를 통째 set 한 뒤 그 칸을 replace 등)
292
+ paths = [(e['op'], parse_path(e['path'])) for e in edits if 'path' in e]
293
+ for i, (op, p) in enumerate(paths):
294
+ if op != 'set':
295
+ continue
296
+ for j, (op2, q) in enumerate(paths):
297
+ if j != i and q[:len(p)] == p:
298
+ sys.exit('✗ set 한 자리(또는 그 안)를 또 고칩니다 @ %s (%s · %s) — 한 자리는 한 번만'
299
+ % (json.dumps(p, ensure_ascii=False), op, op2))
300
+ out = []
301
+ for e in edits:
302
+ e = copy.deepcopy(e)
303
+ if e['op'] == 'set' and 'old' not in e:
304
+ parent, key = locate(doc, parse_path(e['path']))
305
+ # 손으로 쓴 목록과 같은 꼴 — old 를 new 바로 앞에 끼우고 나머지 칸 순서는 그대로 둔다(실사용 목록은 why 가 new 뒤)
306
+ filled = {}
307
+ for k, v in e.items():
308
+ if k == 'new':
309
+ filled['old'] = copy.deepcopy(parent[key])
310
+ filled[k] = v
311
+ e = filled
312
+ out.append(e)
313
+ trial, first = copy.deepcopy(doc), None
314
+ for n in (1, 2):
315
+ run, at = Run(trial), -1
316
+ try:
317
+ # 사본으로 돈다 — set 이 꽂은 new 를 뒤 op 가 고치면 굳힌 목록의 new 가 조용히 바뀐다(2026-09-30 검토)
318
+ for at, e in enumerate(copy.deepcopy(out)):
319
+ skipped = run.skipped
320
+ getattr(run, OPS[e['op']])(e)
321
+ if n == 1 and run.skipped > skipped: # draft 의 op 는 base 에서 모두 바뀌어야 한다
322
+ sys.exit('✗ 바꿀 것이 없습니다 @ %s — base 에 이미 있거나(고친 뒤의 원고를 base 로 줬는지) 줄 라벨이 안 맞습니다'
323
+ % run._where(e))
324
+ except SystemExit as x:
325
+ sys.exit('✗ 시험 적용 %d번째에서 멈춤 — %s\n%s' % (n, _why_stopped(n, at, out), x.code))
326
+ if n == 2 and run.done:
327
+ sys.exit('✗ 시험 적용 두 번째에서 바꿈 %d — 멱등이 아닙니다' % run.done)
328
+ first = first or run
329
+ return out, first
330
+
331
+
332
+ def _why_stopped(n, at, edits):
333
+ """시험 적용이 멈춘 까닭의 한 줄 — 멈춘 op 가 줄을 끼우거나 뺀 표를 번호로 가리키면 그것을 이름으로 말한다.
334
+
335
+ 첫 번째 실행에서는 앞선 줄 op 만 번호를 밀었고, 두 번째 실행에서는 목록의 모든 줄 op 가 이미 밀어 놓았다.
336
+ 두 번째에서 멈췄다면 첫 번째는 건너뜀 없이 끝까지 돈 뒤라 base 탓일 수 없다 — 멱등이 아닌 것이다.
337
+ """
338
+ p = parse_path(edits[at]['path']) if 0 <= at < len(edits) and 'path' in edits[at] else []
339
+ for e in (edits[:at] if n == 1 else edits):
340
+ s = parse_path(e['path']) if e['op'] in SHIFTERS and 'path' in e else None
341
+ if s is not None and len(p) > len(s) and p[:len(s)] == s and isinstance(p[len(s)], int):
342
+ return ('이 op 는 줄을 끼우거나 뺀 표를 번호로 가리킵니다 — 그 줄은 [label=…] 로 가리키거나, '
343
+ '손으로 적은 번호(set 의 old · replace)를 앞 op 를 적용한 뒤의 번호로 맞춥니다')
344
+ if n == 1:
345
+ return ('base 가 draft 를 쓴 판인지(고친 뒤의 원고를 base 로 줬는지), 손으로 적은 번호 경로(set 의 old · replace)가 '
346
+ '앞 op 를 적용한 뒤의 번호인지 봅니다')
347
+ return ('멱등이 아닙니다(첫 번째는 끝까지 돌았습니다) — 한 자리를 두 op 가 고쳤는지 봅니다: set 한 줄·칸을 다른 철자의 경로'
348
+ '(번호 ↔ [label=…])로 또 고쳤거나, 끼운 줄을 다른 op 로 또 고쳤습니다(끼울 줄은 최종 값으로 적습니다)')
349
+
350
+
351
+ def main():
352
+ ap = argparse.ArgumentParser(description='원고 부분 고치기(멱등) — 회차 데이터는 edits.json 에')
353
+ ap.add_argument('report', help='원고 JSON (get_report 가 준 발행 데이터)')
354
+ ap.add_argument('edits', help='고칠 목록 JSON')
355
+ mode = ap.add_mutually_exclusive_group()
356
+ mode.add_argument('--dry', action='store_true', help='재 보기만 — 파일을 안 건드린다')
357
+ mode.add_argument('--freeze', metavar='OUT',
358
+ help='edits 를 draft 로 읽어 set 의 old 를 report(base)에서 채우고, base 사본에 두 번 돌려 멱등이면 OUT 에 쓴다 — 원고는 안 건드린다')
359
+ a = ap.parse_args()
360
+
361
+ try:
362
+ doc = json.load(open(a.report, encoding='utf-8'))
363
+ edits = json.load(open(a.edits, encoding='utf-8'))
364
+ except ValueError as x:
365
+ sys.exit('✗ JSON 으로 못 읽었습니다 — %s. get_report 로 받은 파일이면 머리줄·꼬리 줄 없이 mcp-call.mjs --json 으로 받습니다' % x)
366
+ if not isinstance(doc, dict):
367
+ sys.exit('✗ 원고가 JSON 객체가 아닙니다 — 원고와 편집 목록의 자리를 바꿔 준 것 아닌지 봅니다')
368
+ if isinstance(edits, dict):
369
+ edits = edits.get('edits') or []
370
+ for i, e in enumerate(edits):
371
+ if e.get('op') not in OPS:
372
+ sys.exit('✗ 모르는 op: %r (edits[%d]) — 있는 것: %s' % (e.get('op'), i, ' · '.join(OPS)))
373
+ for i, e in enumerate(edits):
374
+ x = _relabels(e)
375
+ if x is not None:
376
+ sys.exit('✗ [label=%s] 로 찾은 줄의 첫 칸을 바꿉니다(edits[%d]) — 두 번째 실행이 그 줄을 못 찾아 멱등이 깨집니다. 첫 칸을 바꾸는 편집은 '
377
+ '번호 경로로 적습니다(같은 표에 줄을 끼우거나 빼는 op 가 앞서면 old 를 손으로 — 그때 번호는 앞 op 를 적용한 뒤의 번호)' % (x, i))
378
+ if a.freeze is not None:
379
+ if not a.freeze.strip():
380
+ sys.exit('✗ --freeze 의 OUT 이 비었습니다')
381
+ for src in (a.report, a.edits):
382
+ if os.path.exists(a.freeze) and os.path.samefile(a.freeze, src):
383
+ sys.exit('✗ OUT 이 %s 와 같은 파일입니다 — 받은 판·draft 를 덮지 않게 다른 이름으로' % src)
384
+ out, run = freeze(doc, edits)
385
+ json.dump(out, open(a.freeze, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
386
+ print('굳힘 %d개 → %s (base 사본에 두 번 돌려 두 번째 바꿈 0)' % (len(out), a.freeze))
387
+ a.dry = True
388
+ else:
389
+ run = Run(doc)
390
+ for e in edits:
391
+ getattr(run, OPS[e['op']])(e)
392
+
393
+ if not a.dry:
394
+ json.dump(doc, open(a.report, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
395
+ print('바꿈 %d · 이미 같음 %d%s' % (run.done, run.skipped, ' (dry)' if a.dry else ''))
396
+ if run.left:
397
+ print('남은 금지어 %d곳' % len(run.left))
398
+ for p, w, ctx in run.left[:20]:
399
+ print(' 「%s」 @ %s — …%s…' % (w, p, ctx.replace('\n', ' ')))
400
+ # 두 번째 실행이 0 이어야 한다는 계약을 사람이 눈으로 확인할 수 있게 한 줄 남긴다.
401
+ if run.done and not a.dry:
402
+ print('(멱등 확인: 한 번 더 돌려 「바꿈 0」이면 맞습니다)')
403
+ touched = sum(1 for e in edits if 'path' in e and (parse_path(e['path']) or [None])[0] == 'assets')
404
+ if touched:
405
+ print('도판 칸을 고친 편집 %d개 — 교체 인자에서 assets 를 빼면 서버가 저장된 도판(캡션·출처 줄·alt 포함)을 그대로 잇는다: '
406
+ '이 판은 새 판의 assets 를 get_report 표식째 싣는다' % touched)
407
+ sys.exit(1 if run.left else 0)
408
+
409
+
410
+ if __name__ == '__main__':
411
+ main()