@chrono-meta/fh-gate 3.4.0 → 3.5.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.
- package/.claude-plugin/marketplace.json +9 -3
- package/README.md +2 -2
- package/docs/codex-compat.md +1 -1
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +89 -4
- package/package.json +8 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +2 -2
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/CHANGELOG.md +22 -0
- package/plugins/fh-preprep/.claude-plugin/plugin.json +22 -0
- package/plugins/fh-preprep/README.md +24 -0
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/SKILL.md +75 -3
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/mk_slide_fixtures.py +152 -1
- package/plugins/fh-preprep/skills/preprep/lane_attr_consistency.py +370 -0
- package/plugins/fh-preprep/skills/preprep/lane_geometry.py +339 -0
- package/plugins/fh-preprep/skills/preprep/lane_screen_parity.py +232 -0
- package/plugins/fh-preprep/skills/preprep/oox.py +305 -0
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep.py +52 -4
- package/plugins/fh-preprep/skills/preprep/safe_install.py +369 -0
- package/plugins/{fh-commons → fh-preprep}/skills/preprep/surfaces.example.yaml +29 -0
- package/plugins/fh-preprep/skills/preprep/test_preprep_lanes_rp.py +1417 -0
- package/plugins/fh-preprep/skills/preprep/usage_ledger.py +191 -0
- package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
- package/scripts/fh-run.sh +13 -0
- package/scripts/field_canon_preload.sh +1 -1
- package/scripts/selfcheck.sh +12 -8
- package/scripts/test_fh_run_resolver_lanes.sh +116 -0
- package/scripts/test_files_manifest_shipping_lanes.sh +31 -0
- package/scripts/test_preprep_adjacent_dup_lanes.sh +1 -1
- package/scripts/test_preprep_diagram_lanes.sh +1 -1
- package/scripts/test_preprep_drift_anchor.sh +27 -12
- package/scripts/test_preprep_drift_anchor_lanes.sh +4 -2
- package/scripts/test_preprep_font_lanes.sh +7 -7
- package/scripts/test_preprep_progression_lanes.sh +1 -1
- package/scripts/test_preprep_promise_lanes.sh +1 -1
- package/scripts/test_preprep_retired_lanes.sh +45 -2
- package/scripts/test_preprep_slide_refs_lanes.sh +5 -5
- package/scripts/test_usage_ledger_lanes.sh +243 -0
- package/scripts/usage_report.py +211 -0
- package/plugins/fh-commons/skills/preprep/lane_geometry.py +0 -181
- package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +0 -184
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/README.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/canon_terms.example.yaml +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/diagram_from_json.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_negative.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_positive.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/font_revert_probe.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_negative.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_positive.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_negative.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_positive.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/interslide_deps.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/jargon_terms.example.yaml +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_adjacent_dup.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_diagram.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_font.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_progression.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_promise.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_refs.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_relations.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/c1_baseline.txt +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/gate.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep_wire.py +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/presentation_checklist.md +0 -0
- /package/plugins/{fh-commons → fh-preprep}/skills/preprep/test_lane_font.py +0 -0
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
"""
|
|
13
13
|
import os
|
|
14
14
|
from pptx import Presentation
|
|
15
|
-
from pptx.util import Emu
|
|
15
|
+
from pptx.util import Emu, Pt
|
|
16
16
|
from pptx.dml.color import RGBColor
|
|
17
|
+
from pptx.enum.text import PP_ALIGN
|
|
18
|
+
from pptx.enum.shapes import MSO_CONNECTOR
|
|
19
|
+
from pptx.enum.dml import MSO_LINE_DASH_STYLE as MSO_LINE
|
|
17
20
|
|
|
18
21
|
SLIDE_W, SLIDE_H = Emu(24384000), Emu(13716000)
|
|
19
22
|
ACCENT = RGBColor(0xFA, 0xE1, 0x00)
|
|
@@ -155,6 +158,146 @@ def geometry_baseline_pair(base_path, edited_path):
|
|
|
155
158
|
prs2.save(edited_path)
|
|
156
159
|
|
|
157
160
|
|
|
161
|
+
# ── P1 결박 (이름 AND 글자) known-pair ───────────────────────────────────────
|
|
162
|
+
def p1_binding(path):
|
|
163
|
+
"""네 도형 전부 **같은 크기의 소폭 이동**(+30,000 EMU, JIT 안)을 한다 — 즉 기하만 보면
|
|
164
|
+
넷 다 후보다. 갈리는 것은 **결박 가능성**뿐이다:
|
|
165
|
+
|
|
166
|
+
bound_bar 양쪽 글자 같음 → bound. 후보로 뜬다
|
|
167
|
+
silent_bar 양쪽 글자 없음 → name_only. 🟥 **후보로 뜬다** — 막대·선이 여기 산다.
|
|
168
|
+
P1 의 원적 사건이 바로 이 부류라 잘라내면 안 된다
|
|
169
|
+
remix_bar 양쪽 글자 다름 → mismatch. 다른 내용이 같은 이름을 입었다 → 제외
|
|
170
|
+
halftext 한쪽만 글자 있음 → mismatch. 같은 이유로 제외
|
|
171
|
+
|
|
172
|
+
🟥 이 픽스처의 핵심은 «이동량이 넷 다 같다»는 것이다 — 결박 규칙 말고는 변수가 없으므로
|
|
173
|
+
임계·이동량이 아니라 **결박 규칙만** 재는 계기가 된다."""
|
|
174
|
+
prs = _new_deck()
|
|
175
|
+
for k in (0, 1):
|
|
176
|
+
s = _blank(prs)
|
|
177
|
+
d = 30000 * k
|
|
178
|
+
_box(s, 'bound_bar', 1000000 + d, 1000000, 900000, 400000, '같은 막대')
|
|
179
|
+
_box(s, 'remix_bar', 1000000 + d, 2000000, 900000, 400000, '앞 도해' if k == 0 else '뒤 도해')
|
|
180
|
+
_box(s, 'silent_bar', 1000000 + d, 3000000, 900000, 400000, None)
|
|
181
|
+
_box(s, 'halftext', 1000000 + d, 4000000, 900000, 400000, None if k == 0 else '뒤늦은 글자')
|
|
182
|
+
prs.save(path)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def p1_intended(path):
|
|
186
|
+
"""선언 면제(`geometry.intended`) known-pair. 두 도형이 **같은 소폭 이동**을 한다.
|
|
187
|
+
|
|
188
|
+
staged_bar x 와 cx 가 함께 바뀐다 → `attrs: [x, cx]` 선언과 정확히 일치 → 면제 대상
|
|
189
|
+
stray_bar y 만 바뀐다 → 선언과 속성이 다르다 → **면제되면 안 된다**
|
|
190
|
+
|
|
191
|
+
🟥 둘째가 이 픽스처의 요점이다 — 면제를 «도형 이름»에 걸면 stray 도 삼켜진다."""
|
|
192
|
+
prs = _new_deck()
|
|
193
|
+
s1 = _blank(prs)
|
|
194
|
+
_box(s1, 'staged_bar', 1000000, 1000000, 900000, 400000, '격리')
|
|
195
|
+
_box(s1, 'stray_bar', 1000000, 3000000, 900000, 400000, '옆칸')
|
|
196
|
+
s2 = _blank(prs)
|
|
197
|
+
_box(s2, 'staged_bar', 970000, 1000000, 1030000, 400000, '격리') # x -30,000 · cx +130,000
|
|
198
|
+
_box(s2, 'stray_bar', 1000000, 3030000, 900000, 400000, '옆칸') # y +30,000 뿐
|
|
199
|
+
prs.save(path)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── P4 attr-consistency known-pair ───────────────────────────────────────────
|
|
203
|
+
def _sized(slide, name, x, y, w, h, text, pt, algn=None):
|
|
204
|
+
"""명시 `sz` 를 가진 상자. 🟥 P4 의 크기 축은 명시 sz 만 읽으므로 픽스처도 명시해야 한다
|
|
205
|
+
— 상속 크기로 만들면 known-positive 가 UNMEASURED 로 새서 계기가 죽는다."""
|
|
206
|
+
tb = slide.shapes.add_textbox(Emu(x), Emu(y), Emu(w), Emu(h))
|
|
207
|
+
tb.name = name
|
|
208
|
+
p = tb.text_frame.paragraphs[0]
|
|
209
|
+
if algn is not None:
|
|
210
|
+
p.alignment = algn
|
|
211
|
+
r = p.add_run()
|
|
212
|
+
r.text = text
|
|
213
|
+
r.font.size = Pt(pt)
|
|
214
|
+
return tb
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _dashed(slide, name, x, y, w, pt_w, dash):
|
|
218
|
+
ln = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, Emu(x), Emu(y), Emu(x + w), Emu(y))
|
|
219
|
+
ln.name = name
|
|
220
|
+
ln.line.width = Pt(pt_w)
|
|
221
|
+
ln.line.dash_style = dash
|
|
222
|
+
return ln
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def p4(path, positive):
|
|
226
|
+
"""축 넷을 한 벌로. positive 는 넷 다 갈리고, negative 는 **같은 구조로 전부 일치**한다.
|
|
227
|
+
|
|
228
|
+
🟥 negative 를 «도형을 뺀 것»으로 만들지 않는다 — 그러면 「안 떴다」가 「볼 게 없었다」와
|
|
229
|
+
구분이 안 되고(이 저장소가 이름 붙인 「레인이 초록인 이유는 셋」의 ②), 죽은 컨트롤이 된다.
|
|
230
|
+
두 팔의 도형 수·이름·자리는 같고 **갈리는 값 하나씩만** 다르다."""
|
|
231
|
+
prs = _new_deck()
|
|
232
|
+
s = _blank(prs)
|
|
233
|
+
# dash — 같은 종류 점선 두 개, 굵기만 갈린다
|
|
234
|
+
_dashed(s, 'rule_a', 1000000, 1000000, 6000000, 1.0, MSO_LINE.DASH)
|
|
235
|
+
_dashed(s, 'rule_b', 1000000, 1600000, 6000000, 3.0 if positive else 1.0, MSO_LINE.DASH)
|
|
236
|
+
# algn — 같은 폭·같은 y 한 줄, 정렬만 갈린다
|
|
237
|
+
_sized(s, 'row_l', 2000000, 4000000, 5000000, 800000, '왼쪽 칸', 24, PP_ALIGN.LEFT)
|
|
238
|
+
_sized(s, 'row_r', 9000000, 4000000, 5000000, 800000, '오른 칸', 24,
|
|
239
|
+
PP_ALIGN.CENTER if positive else PP_ALIGN.LEFT)
|
|
240
|
+
# mirror — 화면 중심 기준 거울(폭 5,000,000 · 중심 ±5,192,000), 크기만 갈린다
|
|
241
|
+
_sized(s, 'mir_l', 4000000, 8000000, 5000000, 800000, '만든 쪽', 32, PP_ALIGN.CENTER)
|
|
242
|
+
_sized(s, 'mir_r', 15384000, 8000000, 5000000, 800000, '받는 쪽',
|
|
243
|
+
28 if positive else 32, PP_ALIGN.CENTER)
|
|
244
|
+
# echo — 둘째 장에 같은 문장, 크기만 갈린다
|
|
245
|
+
_sized(s, 'echo_1', 2000000, 11000000, 9000000, 800000, '되돌릴 수 없는 곳', 32, PP_ALIGN.LEFT)
|
|
246
|
+
s2 = _blank(prs)
|
|
247
|
+
_sized(s2, 'echo_2', 2000000, 11000000, 9000000, 800000, '되돌릴 수 없는 곳',
|
|
248
|
+
26 if positive else 32, PP_ALIGN.LEFT)
|
|
249
|
+
prs.save(path)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── P1 뒤집힘 known-pair ─────────────────────────────────────────────────────
|
|
253
|
+
def p1_flip(path):
|
|
254
|
+
"""두 장 · 상자 기하는 **한 EMU도 안 바뀐다**. 바뀌는 것은 flipH 하나뿐이다.
|
|
255
|
+
|
|
256
|
+
🟥 이 픽스처의 요점: 자리·크기만 보는 계기는 여기서 «어긋남 0» 을 낸다.
|
|
257
|
+
실사고가 정확히 그 모양이었다(화살표가 반대를 가리키는데 델타가 0건).
|
|
258
|
+
`steady` 는 양쪽 다 무플립이라 뜨면 안 된다(과차단 컨트롤)."""
|
|
259
|
+
from pptx.enum.shapes import MSO_CONNECTOR as _C
|
|
260
|
+
prs = _new_deck()
|
|
261
|
+
for k in (0, 1):
|
|
262
|
+
s = _blank(prs)
|
|
263
|
+
a = s.shapes.add_connector(_C.STRAIGHT, Emu(2000000), Emu(2000000),
|
|
264
|
+
Emu(2800000), Emu(2800000))
|
|
265
|
+
a.name = 'turned'
|
|
266
|
+
if k == 1: # 자리는 그대로, 방향만 뒤집는다
|
|
267
|
+
a._element.spPr.xfrm.set('flipH', '1')
|
|
268
|
+
b = s.shapes.add_connector(_C.STRAIGHT, Emu(6000000), Emu(2000000),
|
|
269
|
+
Emu(6800000), Emu(2800000))
|
|
270
|
+
b.name = 'steady'
|
|
271
|
+
prs.save(path)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ── L14 screen-parity known-pair ─────────────────────────────────────────────
|
|
275
|
+
def screen_parity(deck_path, man_ok, man_bad):
|
|
276
|
+
"""덱 2장 + 원고 두 벌. 🟥 덱은 **한 벌만** 만든다 — 원고만 바꿔서 「원고 쪽이 갈렸다」를
|
|
277
|
+
한 변수로 재기 위해서다(덱까지 같이 바꾸면 무엇이 신호인지 못 가른다).
|
|
278
|
+
|
|
279
|
+
man_ok 덱과 완전히 같다 → 짝 없음 0 · 문구 다름 0
|
|
280
|
+
man_bad ① 「사라진 줄」 을 원고만 들고 있다 → 짝 없음 (원고에만)
|
|
281
|
+
② 「받아들인다」 를 덱만 들고 있다 → 짝 없음 (화면에만)
|
|
282
|
+
③ 「증거 없이 반박을 믿는 셈이다」 ↔ 덱 「…믿는 셈」 → 문구 다름
|
|
283
|
+
"""
|
|
284
|
+
prs = _new_deck()
|
|
285
|
+
s1 = _blank(prs)
|
|
286
|
+
_box(s1, 'h1', 1000000, 500000, 14000000, 800000, '첫 장 제목')
|
|
287
|
+
_box(s1, 'b1', 1000000, 2000000, 14000000, 800000, '증거 없이 반박을 믿는 셈')
|
|
288
|
+
s2 = _blank(prs)
|
|
289
|
+
_box(s2, 'h2', 1000000, 500000, 14000000, 800000, '둘째 장 제목')
|
|
290
|
+
_box(s2, 'b2', 1000000, 2000000, 14000000, 800000, '받아들인다')
|
|
291
|
+
prs.save(deck_path)
|
|
292
|
+
|
|
293
|
+
open(man_ok, 'w', encoding='utf-8').write(
|
|
294
|
+
'### S1 · 첫 장 제목\n🖥\n첫 장 제목\n증거 없이 반박을 믿는 셈\n🗣\n말.\n\n'
|
|
295
|
+
'### S2 · 둘째 장 제목\n🖥\n둘째 장 제목\n받아들인다\n🗣\n말.\n')
|
|
296
|
+
open(man_bad, 'w', encoding='utf-8').write(
|
|
297
|
+
'### S1 · 첫 장 제목\n🖥\n첫 장 제목\n증거 없이 반박을 믿는 셈이다\n사라진 줄\n🗣\n말.\n\n'
|
|
298
|
+
'### S2 · 둘째 장 제목\n🖥\n둘째 장 제목\n🗣\n말.\n')
|
|
299
|
+
|
|
300
|
+
|
|
158
301
|
def build_all(outdir):
|
|
159
302
|
os.makedirs(outdir, exist_ok=True)
|
|
160
303
|
r1(os.path.join(outdir, 'r1_pos.pptx'), True)
|
|
@@ -166,7 +309,15 @@ def build_all(outdir):
|
|
|
166
309
|
r5(os.path.join(outdir, 'r5_pos.pptx'), True)
|
|
167
310
|
r5(os.path.join(outdir, 'r5_neg.pptx'), False)
|
|
168
311
|
p1(os.path.join(outdir, 'p1.pptx'))
|
|
312
|
+
p1_binding(os.path.join(outdir, 'p1_binding.pptx'))
|
|
313
|
+
p1_intended(os.path.join(outdir, 'p1_intended.pptx'))
|
|
314
|
+
p1_flip(os.path.join(outdir, 'p1_flip.pptx'))
|
|
315
|
+
screen_parity(os.path.join(outdir, 'sp_deck.pptx'),
|
|
316
|
+
os.path.join(outdir, 'sp_ok.md'),
|
|
317
|
+
os.path.join(outdir, 'sp_bad.md'))
|
|
169
318
|
p3(os.path.join(outdir, 'p3.pptx'))
|
|
319
|
+
p4(os.path.join(outdir, 'p4_pos.pptx'), True)
|
|
320
|
+
p4(os.path.join(outdir, 'p4_neg.pptx'), False)
|
|
170
321
|
geometry_baseline_pair(os.path.join(outdir, 'geo_base.pptx'),
|
|
171
322
|
os.path.join(outdir, 'geo_edited.pptx'))
|
|
172
323
|
return outdir
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""P4 attr-consistency — **덱 자신의 분포가 판정 기준이다.**
|
|
3
|
+
|
|
4
|
+
## 왜 이 레인이 생겼나 (2026-09-10, 실사고에서 역산)
|
|
5
|
+
|
|
6
|
+
「120장 전수 육안 검수를 했다」고 보고했는데 운영자가 *"전체적으로 제대로 본 게 맞는지 심히
|
|
7
|
+
의심된다"* 고 했고, **측정이 운영자 쪽이 맞다는 것을 보였다** — 그 뒤 이 방식으로 스캔하자
|
|
8
|
+
눈으로도 운영자 지적으로도 안 나온 결함이 **5건** 나왔다:
|
|
9
|
+
|
|
10
|
+
s54 같은 장 안에서 같은 종류 점선이 1.0pt 와 3.0pt
|
|
11
|
+
s58·59 노란 점선 박스의 위/아래 여백이 0.55" 와 1.20"
|
|
12
|
+
s59 좌우 대칭 한 쌍이 30pt ↔ 32pt
|
|
13
|
+
s70 「반박」 32pt ↔ 거울 자리 「나온 것」 28pt
|
|
14
|
+
s29 열 제목 28↔32 · 1행 32↔26
|
|
15
|
+
|
|
16
|
+
🟥 **원인은 성실함이 아니라 «누가 채점표를 썼나»였다.** 나는 내가 만든 체크리스트로 나를
|
|
17
|
+
채점했고, 그러면 내가 안 적은 축은 구조적으로 안 보인다. ⇒ 이 레인은 채점표를 **저자에게서
|
|
18
|
+
덱 자신에게** 옮긴다: 임계를 코드에 박지 않고, **같은 역할의 도형들이 실제로 무슨 값을 쓰고
|
|
19
|
+
있는가**를 덱에서 뽑아 그 안의 «혼자 다른 것»만 낸다.
|
|
20
|
+
|
|
21
|
+
## 형태 — 축 넷이 전부 같은 한 문장이다
|
|
22
|
+
|
|
23
|
+
«덱 자신이 정의하는 역할 키»로 묶고 → 그 무리 안에서 «한 속성»이 갈리는 것을 낸다
|
|
24
|
+
|
|
25
|
+
dash 키=(장, 점선 종류) 속성=선 굵기 「같은 점선인데 굵기가 다르다」
|
|
26
|
+
algn 키=(장, 폭, y) 속성=정렬 방식 「같은 줄인데 정렬이 다르다」
|
|
27
|
+
mirror 키=(장, y, 화면 중심 거울) 속성=글자 크기 「좌우 대칭인데 크기가 다르다」
|
|
28
|
+
echo 키=같은 문장(덱 전체) 속성=글자 크기·x 「같은 말인데 매번 다르게 생겼다」
|
|
29
|
+
|
|
30
|
+
축을 늘리는 것이 **코드가 아니라 표 한 줄**이 되도록 이 모양으로 짰다. `echo` 는 나머지 셋과
|
|
31
|
+
달리 **장을 가로지르는** 키를 쓴다 — 같은 문장이 여러 장에 나올 때 크기·자리가 갈리는가.
|
|
32
|
+
(실측: 방법론 이름 (a)(b)(c) 셋이 8장에 걸쳐 **네 가지 배치**로 나온다.)
|
|
33
|
+
|
|
34
|
+
## 🟥 정직한 한계 — 인용 전에 읽어라
|
|
35
|
+
|
|
36
|
+
- **이 레인은 «다르다»만 본다. «틀렸다»는 안 본다.** 크기 대비가 강조를 나르는 자리는 정상이고
|
|
37
|
+
(실측: s6/s7 의 44↔32pt 는 빌드 강조 교체다), 그건 `attr_consistency.intended` 로 **선언**한다 —
|
|
38
|
+
기계는 «선언되었나»만 보고 «옳은가»는 안 본다(CLAUDE.md §Mechanization Boundary).
|
|
39
|
+
- **advisory 고정.** 오탐의 최저비용 무마가 «다른 쪽을 같이 맞춰 버리는 것»이라, 종료코드에
|
|
40
|
+
태우면 덱을 평평하게 미는 압력이 된다. 후보를 내고 **판정은 사람이 한다.**
|
|
41
|
+
- **정렬·거울 축은 키가 엄격해서(정확히 같은 폭·같은 y) 못 보는 자리가 많다.** 한 픽셀 어긋난
|
|
42
|
+
«같은 줄»은 이 레인에 안 잡힌다. 좁아진 것이지 닫힌 게 아니다.
|
|
43
|
+
- **크기 축은 명시 `sz` 만 읽는다.** 플레이스홀더/테마에서 물려받는 크기는 XML 에 안 적히므로
|
|
44
|
+
**UNMEASURED 다, 「같다」가 아니다.** 그런 도형은 통째로 뺀다.
|
|
45
|
+
- **n=1 코퍼스(120장 한국어 덱)에서 벼려졌다.** 다른 덱에 옮기면 거울 허용 오차와
|
|
46
|
+
`min_echo_chars` 를 자기 분포에서 다시 재라.
|
|
47
|
+
"""
|
|
48
|
+
import zipfile, re, os, collections, html
|
|
49
|
+
|
|
50
|
+
EMU_IN = 914400.0
|
|
51
|
+
EMU_PT = 12700.0
|
|
52
|
+
AXES = ('dash', 'algn', 'mirror', 'echo')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── 추출 ─────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
import oox as _oox
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _slide_order(z):
|
|
61
|
+
return _oox.slide_order(z)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _slide_size_in(z):
|
|
65
|
+
sz = _oox.slide_size(z)
|
|
66
|
+
return (sz[0] / EMU_IN, sz[1] / EMU_IN) if sz else (13.333, 7.5)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _shapes(z, sn):
|
|
70
|
+
"""한 장의 도형 전부(그룹은 풀어서 절대 좌표). 🟥 lane_geometry.shapes() 와 달리 **이름 중복도 버리지 않는다** —
|
|
71
|
+
이 레인은 «같은 이름을 짝지어 대조»하는 게 아니라 «한 장 안의 분포»를 보기 때문이다. 대신 «한 장 안에서
|
|
72
|
+
같은 이름이 둘» 이면 `dup=True` 로 표시해 면제 조회가 그것을 모호로 취급한다(R5 A6 · R6 A5)."""
|
|
73
|
+
out = []
|
|
74
|
+
walked = _oox.walk_slide(z, sn)
|
|
75
|
+
# R7 A7: 이름 중복은 «장 위의 모든 도형» 에서 센다 — xfrm 없는 자리표시자를 걸러낸 뒤 세면 그것과 겹치는 이름을 못 본다
|
|
76
|
+
cnt = collections.Counter(s_['name'] for s_ in walked if s_['name'] is not None)
|
|
77
|
+
for s in walked:
|
|
78
|
+
if s['kind'] not in ('sp', 'cxnSp', 'pic') or s['name'] is None or s['x'] is None:
|
|
79
|
+
continue
|
|
80
|
+
paras = s['paras']
|
|
81
|
+
szs = tuple(sz for p in paras for _t, sz in p['runs'] if sz is not None)
|
|
82
|
+
# R3 A10: 크기는 «글자 구간» 에 붙는다 — 인접 같은 크기 런을 합친 (글자수, 크기). R6 B7: 글자수는 공백 정규화 후.
|
|
83
|
+
# R7 A6 → R8 A4: 공백 정규화는 «문단 전체» 에서 한 번. 🟥 그리고 공백은 자기 크기를 갖지 않는다 —
|
|
84
|
+
# «앞 글자» 의 크기를 물려받는다(문단 첫 공백은 뒤 글자). R7 은 공백 런의 크기를 그대로 세어
|
|
85
|
+
# "abc "@28+"def"@32 와 "abc"@28+" def"@32(같은 화면) 가 갈렸고, <a:br/> 은 «1자@Nonept» 라는 거짓 증거를 냈다.
|
|
86
|
+
# 잔여(이름으로): 공백 «만» 다른 크기인 런(codex R7 A6) 은 이 레인이 못 본다 — 그것은 글자 크기가 아니라 «틈 너비» 다.
|
|
87
|
+
spans = []
|
|
88
|
+
for p in paras:
|
|
89
|
+
chars = [(ch, sz) for t, sz in p['runs'] for ch in t]
|
|
90
|
+
norm = []
|
|
91
|
+
for ch, sz in chars:
|
|
92
|
+
if ch.isspace():
|
|
93
|
+
if norm and norm[-1][0] == ' ':
|
|
94
|
+
continue
|
|
95
|
+
norm.append((' ', None))
|
|
96
|
+
else:
|
|
97
|
+
norm.append((ch, sz))
|
|
98
|
+
while norm and norm[0][0] == ' ':
|
|
99
|
+
norm.pop(0)
|
|
100
|
+
while norm and norm[-1][0] == ' ':
|
|
101
|
+
norm.pop()
|
|
102
|
+
prev_sz = None
|
|
103
|
+
for k_, (ch, sz) in enumerate(norm):
|
|
104
|
+
if ch == ' ':
|
|
105
|
+
norm[k_] = (' ', prev_sz if prev_sz is not None else next((z for _c, z in norm[k_ + 1:] if _c != ' '), None))
|
|
106
|
+
else:
|
|
107
|
+
prev_sz = sz
|
|
108
|
+
for ch, sz in norm:
|
|
109
|
+
if spans and spans[-1][1] == sz:
|
|
110
|
+
spans[-1] = (spans[-1][0] + 1, sz)
|
|
111
|
+
else:
|
|
112
|
+
spans.append((1, sz))
|
|
113
|
+
out.append(dict(
|
|
114
|
+
slide=sn, name=s['name'],
|
|
115
|
+
x=s['x'] / EMU_IN, y=s['y'] / EMU_IN, w=s['cx'] / EMU_IN, h=s['cy'] / EMU_IN,
|
|
116
|
+
szs=szs,
|
|
117
|
+
algn=tuple((p['algn'] or '(기본)') for p in paras) or ('(기본)',),
|
|
118
|
+
spans=tuple(spans),
|
|
119
|
+
lnw=round(s['ln_w'] / EMU_PT, 2) if s['ln_w'] is not None else None, dash=s['dash'],
|
|
120
|
+
text=_oox.shape_text(paras), dup=cnt[s['name']] > 1))
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load(path):
|
|
125
|
+
z = zipfile.ZipFile(path)
|
|
126
|
+
order = _slide_order(z)
|
|
127
|
+
sw, _sh = _slide_size_in(z)
|
|
128
|
+
S = [_shapes(z, sn) for sn in order]
|
|
129
|
+
# 🟥 «몇 장» 은 발표 순서(위치)지 파일 번호가 아니다 — slide9.xml 이 1번째면 1p 다(codex 09-11).
|
|
130
|
+
for pos, sh in enumerate(S, 1):
|
|
131
|
+
for sp in sh:
|
|
132
|
+
sp['slide'] = pos
|
|
133
|
+
return S, sw
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── 면제 채널 (P1 과 같은 규율: 선언되었나만 본다 · 사유 필수 · 조용히 안 삼킨다) ──────
|
|
137
|
+
|
|
138
|
+
def _intent_index(intended):
|
|
139
|
+
idx, errs = collections.defaultdict(list), []
|
|
140
|
+
for n, e in enumerate(intended or [], 1):
|
|
141
|
+
if not isinstance(e, dict):
|
|
142
|
+
errs.append(f'attr_consistency.intended[{n}] : 매핑이 아니다 — 면제 안 함')
|
|
143
|
+
continue
|
|
144
|
+
ax, sl = e.get('axis'), e.get('slides') or []
|
|
145
|
+
why = (e.get('why') or '').strip()
|
|
146
|
+
if ax not in AXES:
|
|
147
|
+
errs.append(f'attr_consistency.intended[{n}] : axis 가 {AXES} 중 하나여야 한다 — 면제 안 함')
|
|
148
|
+
continue
|
|
149
|
+
if not sl:
|
|
150
|
+
errs.append(f'attr_consistency.intended[{n}] : slides 가 비었다 — 면제 안 함')
|
|
151
|
+
continue
|
|
152
|
+
if not isinstance(sl, list) or (e.get('shapes') is not None and not isinstance(e.get('shapes'), list)):
|
|
153
|
+
errs.append(f'attr_consistency.intended[{n}] : slides·shapes 는 목록이어야 한다 (문자열 "ab" 는 a·b 로 읽힌다) — 면제 안 함') # R3 A9
|
|
154
|
+
continue
|
|
155
|
+
if e.get('shapes') is not None and not all(isinstance(v, str) for v in e.get('shapes')):
|
|
156
|
+
errs.append(f'attr_consistency.intended[{n}] : shapes 원소는 문자열이어야 한다 (받은 값 {e.get("shapes")!r}) — 면제 안 함') # R8 A3: 섞이면 sorted 가 TypeError 로 레인 전체를 UNMEASURED 로 접었다
|
|
157
|
+
continue
|
|
158
|
+
if not all(isinstance(v, int) and not isinstance(v, bool) for v in sl):
|
|
159
|
+
errs.append(f'attr_consistency.intended[{n}] : slides 원소는 정수여야 한다 (받은 값 {sl!r} — 1.9·true 는 1 이 아니다) — 면제 안 함') # R4 A7
|
|
160
|
+
continue
|
|
161
|
+
if not why:
|
|
162
|
+
errs.append(f'attr_consistency.intended[{n}] : 🟥 why 가 비었다 — 사유 없는 면제는 오류다')
|
|
163
|
+
continue
|
|
164
|
+
for s in sl:
|
|
165
|
+
idx[(ax, int(s))].append((tuple(sorted(e.get('shapes') or [])), why, n))
|
|
166
|
+
return idx, errs
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
AMBIG = '\u2205' # «같은 장 안에 같은 이름 둘» — 선언으로 못 가르는 표식(R5 A6). 어떤 선언과도 안 맞는다.
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _mark_ambig(shapes_):
|
|
173
|
+
"""도형 목록 → 이름 목록. 그중 하나라도 «자기 장 안에 같은 이름이 또 있으면»(dup) AMBIG 를 덧붙여 면제를 막는다.
|
|
174
|
+
R6 A5: 후보 쌍만 보면 «그 밖의» 같은 이름을 못 본다 — dup 은 장 전체에서 센 값이다."""
|
|
175
|
+
names = [s_['name'] for s_ in shapes_]
|
|
176
|
+
if any(s_.get('dup') for s_ in shapes_):
|
|
177
|
+
names.append(AMBIG)
|
|
178
|
+
return names
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _why_all(idx, axis, slide, names):
|
|
182
|
+
"""이 장·축·도형 집합에 걸리는 «모든» 선언 [(why, n) …]. R3 B14: 첫 선언만 고르면 순서 의존이 된다.
|
|
183
|
+
R5 A6: 같은 이름이 둘이면 «어느 것» 인지 선언으로 못 가른다 — 면제 불가(집합화가 셋째 도형을 지웠다)."""
|
|
184
|
+
if AMBIG in names:
|
|
185
|
+
return []
|
|
186
|
+
got = sorted(set(names))
|
|
187
|
+
return [(why, n) for want, why, n in idx.get((axis, int(slide)), []) if not want or list(want) == got]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _why(idx, axis, slide, names):
|
|
191
|
+
"""면제는 «축 + 장 + 도형 집합»에 걸린다. `shapes` 를 안 적으면 그 장의 그 축 전체."""
|
|
192
|
+
r = _why_all(idx, axis, slide, names)
|
|
193
|
+
return r[0] if r else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── 축 넷 ────────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
def ax_dash(S, sw, cfg):
|
|
199
|
+
"""같은 장 안에서 «같은 종류 점선»의 굵기가 갈리는가."""
|
|
200
|
+
for sn, sh in enumerate(S, 1):
|
|
201
|
+
g = collections.defaultdict(dict)
|
|
202
|
+
for a in sh:
|
|
203
|
+
if a['dash'] and a['lnw'] is not None:
|
|
204
|
+
g[a['dash']].setdefault(a['lnw'], []).append(a)
|
|
205
|
+
for d, byw in g.items():
|
|
206
|
+
if len(byw) > 1:
|
|
207
|
+
detail = ' ↔ '.join(f"{w}pt({','.join(n['name'][:10] for n in ns[:2])})"
|
|
208
|
+
for w, ns in sorted(byw.items()))
|
|
209
|
+
yield sn, _mark_ambig([a_ for ns in byw.values() for a_ in ns]), \
|
|
210
|
+
f"{sn:>3}p [dash] «{d}» 굵기가 갈린다 — {detail}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def ax_algn(S, sw, cfg):
|
|
214
|
+
"""같은 장에서 «같은 폭 · 같은 y»(= 한 줄)인데 정렬 방식이 갈리는가."""
|
|
215
|
+
for sn, sh in enumerate(S, 1):
|
|
216
|
+
rows = collections.defaultdict(list)
|
|
217
|
+
for a in sh:
|
|
218
|
+
if a['text']: # 글자 있는 도형만이 «정렬»을 가진다 — 크기가 상속이어도 정렬은 있다
|
|
219
|
+
rows[(round(a['w'], 2), round(a['y'], 1))].append(a)
|
|
220
|
+
for (w, y), v in rows.items():
|
|
221
|
+
if len(v) > 1 and len({a['algn'] for a in v}) > 1:
|
|
222
|
+
detail = ' · '.join(f"{a['name'][:12]}={'/'.join(a['algn'])}" for a in v)
|
|
223
|
+
yield sn, _mark_ambig(v), \
|
|
224
|
+
f"{sn:>3}p [algn] 폭 {w}\" y {y}\" 한 줄인데 정렬이 갈린다 — {detail}"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _size_seq(spans):
|
|
228
|
+
return tuple(sz for _n, sz in spans)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def ax_mirror(S, sw, cfg):
|
|
232
|
+
"""좌우 대칭 쌍(같은 y · 화면 중심 기준 거울)의 글자 크기가 갈리는가."""
|
|
233
|
+
tol = float(cfg.get('mirror_tol_in', 0.4))
|
|
234
|
+
cx0 = sw / 2.0
|
|
235
|
+
for sn, sh in enumerate(S, 1):
|
|
236
|
+
A = [a for a in sh if a['szs'] and a['w'] < sw * 0.9]
|
|
237
|
+
for i, a in enumerate(A):
|
|
238
|
+
for b in A[i + 1:]:
|
|
239
|
+
if abs(a['y'] - b['y']) > 0.05:
|
|
240
|
+
continue
|
|
241
|
+
ca, cb = a['x'] + a['w'] / 2, b['x'] + b['w'] / 2
|
|
242
|
+
if abs((ca - cx0) + (cb - cx0)) > tol: # 거울 자리인가
|
|
243
|
+
continue
|
|
244
|
+
if abs(ca - cb) < 1.0: # 너무 붙어 있으면 쌍이 아니다
|
|
245
|
+
continue
|
|
246
|
+
# R4 B8: 거울 쌍은 글자가 다른 게 정상이라 «글자 수» 는 비교 대상이 아니다 — 크기 «순열» 만.
|
|
247
|
+
# R5 A5: 단, 글자가 «같으면» 구간 전체를 비교한다(AB@28+CD@32 vs A@28+BCD@32 — B 가 커졌다)
|
|
248
|
+
differs = (a['spans'] != b['spans']) if a['text'] == b['text'] else (_size_seq(a['spans']) != _size_seq(b['spans']))
|
|
249
|
+
if differs:
|
|
250
|
+
yield sn, _mark_ambig([a, b]), (
|
|
251
|
+
f"{sn:>3}p [mirror] 좌우 대칭인데 크기가 갈린다 — "
|
|
252
|
+
f"{a['name'][:12]} {'/'.join(map(str, a['szs']))}pt «{a['text'][:14]}» ↔ "
|
|
253
|
+
f"{b['name'][:12]} {'/'.join(map(str, b['szs']))}pt «{b['text'][:14]}»")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def ax_echo(S, sw, cfg):
|
|
257
|
+
"""**장을 가로지르는 축** — 같은 문장이 여러 장에서 다른 크기·다른 x 로 나오는가.
|
|
258
|
+
|
|
259
|
+
🟥 «같은 말을 할 때 같은 자리에 있는가»는 넘길 때만 보이고 한 장짜리 렌더에는 구조적으로
|
|
260
|
+
안 나타난다(SKILL §방법론 ⓑ). 실측: 방법론 이름 셋이 8장에 네 가지 배치로 나왔다."""
|
|
261
|
+
mn = int(cfg.get('min_echo_chars', 8))
|
|
262
|
+
byt = collections.defaultdict(list)
|
|
263
|
+
for sh in S:
|
|
264
|
+
for a in sh:
|
|
265
|
+
t = a['text']
|
|
266
|
+
if a['szs'] and len(t) >= mn:
|
|
267
|
+
byt[t].append(a)
|
|
268
|
+
for t, v in sorted(byt.items()):
|
|
269
|
+
slides = sorted({a['slide'] for a in v})
|
|
270
|
+
if len(slides) < 2:
|
|
271
|
+
continue
|
|
272
|
+
szset = {a['spans'] for a in v} # R3 A10: 구간 단위
|
|
273
|
+
xset = {round(a['x'], 1) for a in v}
|
|
274
|
+
if len(szset) < 2 and len(xset) < 2:
|
|
275
|
+
continue
|
|
276
|
+
what = []
|
|
277
|
+
if len(szset) > 1:
|
|
278
|
+
what.append('크기 ' + ' ↔ '.join('+'.join(f'{n_}자@{sz}pt' for n_, sz in s) for s in sorted(szset, key=str)))
|
|
279
|
+
if len(xset) > 1:
|
|
280
|
+
what.append('x ' + ' ↔ '.join(f'{x}"' for x in sorted(xset)))
|
|
281
|
+
yield tuple(slides), _mark_ambig(v), ( # R2 A7 · R5 A6(장 안 중복만 모호)
|
|
282
|
+
f"{'·'.join(str(s) for s in slides[:6])}p [echo] «{t[:30]}» 가 "
|
|
283
|
+
f"{len(slides)}장에서 갈린다 — {' · '.join(what)}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
AXIS_FN = {'dash': ax_dash, 'algn': ax_algn, 'mirror': ax_mirror, 'echo': ax_echo}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ── 진입점 ───────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
def collect(path, cfg):
|
|
292
|
+
"""(lines, suppressed, errors, stats)"""
|
|
293
|
+
S, sw = load(path)
|
|
294
|
+
idx, errors = _intent_index(cfg.get('intended'))
|
|
295
|
+
want = [a for a in (cfg.get('axes') or AXES) if a in AXIS_FN]
|
|
296
|
+
unknown = [a for a in (cfg.get('axes') or []) if a not in AXIS_FN]
|
|
297
|
+
errors += [f'attr_consistency.axes : 모르는 축 «{a}» — 무시 (있는 축: {AXES})' for a in unknown]
|
|
298
|
+
lines, suppressed = [], []
|
|
299
|
+
stats = collections.Counter()
|
|
300
|
+
# 명시 크기가 없는 도형은 크기 축에서 UNMEASURED — 0 으로 접지 않고 센다
|
|
301
|
+
stats['shapes'] = sum(len(sh) for sh in S)
|
|
302
|
+
stats['sized'] = sum(1 for sh in S for a in sh if a['szs'])
|
|
303
|
+
hits = collections.Counter()
|
|
304
|
+
for ax in want:
|
|
305
|
+
for slide, names, line in AXIS_FN[ax](S, sw, cfg):
|
|
306
|
+
slides_all = list(slide) if isinstance(slide, tuple) else [slide]
|
|
307
|
+
# 그룹이 여러 장에 걸치면(echo) 전부 «같은 선언 n» 에 걸려야 면제 — R3 B14: 장마다 첫 선언을 고르지 말고
|
|
308
|
+
# 장 전체에 공통인 n 을 찾는다(부분 선언이 완전 선언을 가리지 않게)
|
|
309
|
+
per = [dict((n_, w_) for w_, n_ in _why_all(idx, ax, sl_, names)) for sl_ in slides_all]
|
|
310
|
+
common = set(per[0]) if per else set()
|
|
311
|
+
for d_ in per[1:]:
|
|
312
|
+
common &= set(d_)
|
|
313
|
+
hit = (per[0][min(common)], min(common)) if common else None
|
|
314
|
+
if hit is not None:
|
|
315
|
+
suppressed.append(line + f' ← 선언: {hit[0]}')
|
|
316
|
+
stats['suppressed'] += 1
|
|
317
|
+
hits[hit[1]] += 1
|
|
318
|
+
else:
|
|
319
|
+
lines.append(line)
|
|
320
|
+
stats[ax] += 1
|
|
321
|
+
# 🟥 죽은 선언 보고 — 아무 후보도 안 거는 intended 항목(덱 세션 실측 09-11: 유령 후보에 붙은 why)
|
|
322
|
+
for n in sorted({n for v in idx.values() for _w, _y, n in v}):
|
|
323
|
+
if hits.get(n, 0) == 0:
|
|
324
|
+
errors.append(f'attr_consistency.intended[{n}] : 🟥 죽은 선언 — 이 덱에서 아무 후보도 면제하지 않는다. 지우거나 고쳐라')
|
|
325
|
+
stats['dead_intended'] += 1
|
|
326
|
+
return lines, suppressed, errors, dict(stats)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _resolve(root, p):
|
|
330
|
+
return os.path.normpath(os.path.join(root, os.path.expanduser(p)))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def scan(cfg, root):
|
|
334
|
+
"""preprep.py 가 부르는 레인 진입점. 🟥 **advisory 고정** — findings 를 안 낸다."""
|
|
335
|
+
deck_s = (cfg.get('surfaces_by_id') or {}).get('built_deck')
|
|
336
|
+
if not deck_s:
|
|
337
|
+
return [], ['P4 attr-consistency : built_deck 미선언 — NOT_CONFIGURED (0 아님)']
|
|
338
|
+
deck = _resolve(root, deck_s['path'])
|
|
339
|
+
if not os.path.exists(deck):
|
|
340
|
+
return [], [f'P4 attr-consistency : built_deck 실물 없음({deck}) — UNMEASURED (0 아님)']
|
|
341
|
+
spec = cfg.get('attr_consistency') or {}
|
|
342
|
+
try:
|
|
343
|
+
lines, sup, errs, st = collect(deck, spec)
|
|
344
|
+
except Exception as e:
|
|
345
|
+
return [], [f'P4 attr-consistency : 계기 오류({type(e).__name__}: {e}) — UNMEASURED (0 아님)']
|
|
346
|
+
per = ' · '.join(f'{a} {st.get(a, 0)}' for a in AXES if a in (spec.get('axes') or AXES))
|
|
347
|
+
notes = [f'P4 attr-consistency : 후보 {len(lines)}건 ({per}) — 🟥 advisory, '
|
|
348
|
+
f'«다르다»지 «틀렸다»가 아니다. 판정은 사람이 한다',
|
|
349
|
+
f' 대상 도형 {st.get("shapes", 0)}개 중 명시 크기 있는 것 {st.get("sized", 0)}개 '
|
|
350
|
+
f'— 나머지는 크기 축에서 UNMEASURED (0 아님)']
|
|
351
|
+
notes += [' ' + l for l in lines]
|
|
352
|
+
notes += [' 🟥 ' + e for e in errs]
|
|
353
|
+
notes += [' ~ (면제)' + l for l in sup]
|
|
354
|
+
if sup:
|
|
355
|
+
notes.append(f' ~ 선언된 것 {len(sup)}건을 면제했다 — 「없다」가 아니라 「알고 남겼다」')
|
|
356
|
+
return [], notes
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
if __name__ == '__main__':
|
|
360
|
+
import sys, json
|
|
361
|
+
p = sys.argv[1]
|
|
362
|
+
conf = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
|
|
363
|
+
L, SU, ER, ST = collect(p, conf)
|
|
364
|
+
print(f'후보 {len(L)}건 · 면제 {len(SU)} · 선언오류 {len(ER)} · {ST}')
|
|
365
|
+
for l in L:
|
|
366
|
+
print(' ', l)
|
|
367
|
+
for e in ER:
|
|
368
|
+
print(' 🟥', e)
|
|
369
|
+
for s in SU:
|
|
370
|
+
print(' ~', s)
|