python-hwpx-automation 6.0.3__py3-none-any.whl

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 (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,961 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Form-fill quality scorer -- the fitness function of the evalplan GOAL loop.
3
+
4
+ Given ``(produced, gold, blank)`` this scores a filled evaluation-plan form on
5
+ five axes against the gold reference (a correctly submitted form of the same
6
+ family), returning a weighted 0-100 total plus a per-axis *gap report* pinning
7
+ concrete defects and their locations.
8
+
9
+ A. render cleanliness (30) -- real-Hancom render: text overflowing a cell
10
+ border, collapsed/overlapping lines, page-count sanity. **Requires an
11
+ oracle**; with none the axis is ``unverified`` (contributes 0, never a
12
+ silent pass -- Constitution IV/V).
13
+ B. format fidelity (25) -- byte preservation vs *blank*: untouched tables
14
+ carried verbatim, edited tables reusing the blank's formatting vocabulary
15
+ (borderFill / column-width integers). A regenerated form scores ~0. This
16
+ axis measures OUR byte-discipline, so *gold* (re-serialised by Hancom)
17
+ legitimately scores low here -- gold is the reference for A/C/D, not B.
18
+ C. structure conformance (20) -- vs the *gold* skeleton: the same delete/keep
19
+ policy (정기시험 column, 석차등급 / 제출 / ★유의 red tables, 5단계 성취율),
20
+ achievement-standard block count, rubric-area count.
21
+ D. content completeness (15) -- sections [1]~[11] present, score sum == 100,
22
+ 반영비율 non-increasing.
23
+ E. compliance (10) -- 평가계획 lint; manual-only rules are ``needs_review``,
24
+ never a silent pass.
25
+
26
+ Design rails: measure-first, no-silent-true, honest-defer. Every axis reports a
27
+ ``status`` so a partial/undetectable measurement is visible, not disguised as a
28
+ pass. Reuses the byte parsers in :mod:`hwpx.table_patch` and the render oracle in
29
+ :mod:`..rendering.oracle`.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from collections.abc import Sequence
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ from hwpx.table_patch import (
41
+ read_source_bytes as _read_source_bytes,
42
+ )
43
+ from hwpx.table_patch import (
44
+ section_parts as _sections,
45
+ )
46
+ from hwpx.table_patch import (
47
+ table_text as _text_of,
48
+ )
49
+
50
+ from .classification import _classify, _Table, _tables
51
+
52
+ # --------------------------------------------------------------------------- #
53
+ # Data model
54
+ # --------------------------------------------------------------------------- #
55
+
56
+ # status vocabulary for an axis (no-silent-true: an undetectable axis says so)
57
+ MEASURED = "measured"
58
+ UNVERIFIED = "unverified" # could not run the required oracle -> not a pass
59
+ NEEDS_REVIEW = "needs_review" # measured what we can; some rules are manual-only
60
+
61
+ AXES = (
62
+ ("A", "render_cleanliness", 30),
63
+ ("B", "format_fidelity", 25),
64
+ ("C", "structure_conformance", 20),
65
+ ("D", "content_completeness", 15),
66
+ ("E", "compliance", 10),
67
+ )
68
+
69
+
70
+ @dataclass
71
+ class AxisScore:
72
+ key: str
73
+ name: str
74
+ weight: int
75
+ score: float # 0..weight
76
+ status: str # MEASURED | UNVERIFIED | NEEDS_REVIEW
77
+ findings: list[str] = field(default_factory=list) # gap report (with locations)
78
+ detail: dict[str, Any] = field(default_factory=dict)
79
+
80
+ def to_dict(self) -> dict[str, Any]:
81
+ return {
82
+ "key": self.key,
83
+ "name": self.name,
84
+ "weight": self.weight,
85
+ "score": round(self.score, 2),
86
+ "status": self.status,
87
+ "findings": self.findings,
88
+ "detail": self.detail,
89
+ }
90
+
91
+
92
+ @dataclass
93
+ class ScoreCard:
94
+ total: float
95
+ render_checked: bool
96
+ axes: list[AxisScore]
97
+
98
+ def axis(self, key: str) -> AxisScore:
99
+ for a in self.axes:
100
+ if a.key == key:
101
+ return a
102
+ raise KeyError(key)
103
+
104
+ def lowest_axis(self) -> AxisScore:
105
+ """Weakest axis by weight-normalised score, tie-broken A>B>C>D>E.
106
+
107
+ An ``unverified`` axis is not "lowest" for diagnosis (you fix the oracle,
108
+ not the content), so it is excluded unless every axis is unverified.
109
+ """
110
+ order = {k: i for i, (k, _n, _w) in enumerate(AXES)}
111
+ candidates = [a for a in self.axes if a.status != UNVERIFIED] or list(self.axes)
112
+ return min(candidates, key=lambda a: (a.score / a.weight, order[a.key]))
113
+
114
+ def to_dict(self) -> dict[str, Any]:
115
+ return {
116
+ "total": round(self.total, 2),
117
+ "render_checked": self.render_checked,
118
+ "axes": [a.to_dict() for a in self.axes],
119
+ "lowest_axis": self.lowest_axis().key,
120
+ }
121
+
122
+
123
+ _BORDERFILL_RE = re.compile(rb'borderFillIDRef="(\d+)"')
124
+ _WIDTH_RE = re.compile(rb'<(?:[A-Za-z_][\w.-]*:)?cellSz\b[^>]*\bwidth="(\d+)"')
125
+
126
+
127
+ def _all_text(source: str | Path | bytes) -> str:
128
+ return "\n".join(t.text for t in _tables(source))
129
+
130
+
131
+ def _document_text(source: str | Path | bytes) -> str:
132
+ """Complete document text -- table cells AND section paragraphs. Section
133
+ prose (목적/방향/방침 items) lives in paragraphs outside any table, so a
134
+ table-only read blind-spots it (a filled section reads the same as an unfilled
135
+ one). This reads the whole section XML text so the D content check can see
136
+ prose fills."""
137
+ data = _read_source_bytes(source)
138
+ return "\n".join(
139
+ " ".join(_text_of(sec).split()) for sec in _sections(data).values()
140
+ )
141
+
142
+
143
+ def _looks_like_path(x: Any) -> bool:
144
+ if isinstance(x, Path):
145
+ return True
146
+ if isinstance(x, str):
147
+ return ("\n" not in x) and (
148
+ x.endswith((".md", ".txt", ".markdown")) or Path(x).exists()
149
+ )
150
+ return False
151
+
152
+
153
+ def _borderfill_vocab(tables: Sequence[_Table]) -> set[str]:
154
+ v: set[str] = set()
155
+ for t in tables:
156
+ v.update(m.group(1).decode() for m in _BORDERFILL_RE.finditer(t.bytes))
157
+ return v
158
+
159
+
160
+ def _width_vocab(tables: Sequence[_Table]) -> set[int]:
161
+ v: set[int] = set()
162
+ for t in tables:
163
+ v.update(int(m.group(1)) for m in _WIDTH_RE.finditer(t.bytes))
164
+ return v
165
+
166
+
167
+ # --------------------------------------------------------------------------- #
168
+ # A. render cleanliness (oracle)
169
+ # --------------------------------------------------------------------------- #
170
+
171
+
172
+ def detect_overflow_crossings(
173
+ pdf_path: str | Path, *, eps: float = 1.5
174
+ ) -> list[dict[str, Any]]:
175
+ """Words whose bbox interior is crossed by a vertical cell border -- i.e. text
176
+ spilling right across a cell boundary, the signature form-fill overflow mode.
177
+
178
+ Calibrated so a clean gold render reports 0. Only stroke line segments count
179
+ (cell separators); ``re`` rectangle fills / underlines are excluded. Requires
180
+ PyMuPDF; raises ``RuntimeError`` if unavailable (caller degrades to unverified).
181
+ """
182
+ try:
183
+ import fitz
184
+ except Exception as exc: # pragma: no cover - env probe
185
+ raise RuntimeError(f"PyMuPDF unavailable: {exc}") from exc
186
+
187
+ incidents: list[dict[str, Any]] = []
188
+ doc = fitz.open(str(pdf_path))
189
+ try:
190
+ for pno in range(doc.page_count):
191
+ page = doc[pno]
192
+ vsegs: list[tuple[float, float, float]] = []
193
+ for path in page.get_drawings():
194
+ for it in path["items"]:
195
+ if it[0] == "l":
196
+ a, b = it[1], it[2]
197
+ if abs(a.x - b.x) < 0.6 and abs(a.y - b.y) > 3:
198
+ vsegs.append((a.x, min(a.y, b.y), max(a.y, b.y)))
199
+ if not vsegs:
200
+ continue
201
+ for w in page.get_text("words"):
202
+ x0, y0, x1, y1 = (float(value) for value in w[:4])
203
+ txt = str(w[4])
204
+ mid = (y0 + y1) / 2
205
+ for vx, vy0, vy1 in vsegs:
206
+ if x0 + eps < vx < x1 - eps and vy0 <= mid <= vy1:
207
+ incidents.append(
208
+ {"page": pno, "x": round(x0), "y": round(y0), "text": txt}
209
+ )
210
+ break
211
+ finally:
212
+ doc.close()
213
+ return incidents
214
+
215
+
216
+ def score_render(
217
+ produced: str | Path,
218
+ *,
219
+ oracle: Any | None = None,
220
+ produced_pdf: str | Path | None = None,
221
+ expected_pages: int | None = None,
222
+ work_dir: str | Path | None = None,
223
+ ) -> AxisScore:
224
+ """A axis. Renders *produced* (or uses a pre-rendered ``produced_pdf``) and
225
+ penalises overflow crossings + page-count anomaly. Oracle-truthful: with no
226
+ oracle and no pdf the axis is ``unverified`` (score 0, flagged)."""
227
+ key, name, weight = "A", "render_cleanliness", 30
228
+ pdf = str(produced_pdf) if produced_pdf else None
229
+ if pdf is None:
230
+ if oracle is None:
231
+ from ..rendering import resolve_hancom_oracle
232
+
233
+ oracle = resolve_hancom_oracle()
234
+ if oracle is None or not oracle.available():
235
+ return AxisScore(
236
+ key,
237
+ name,
238
+ weight,
239
+ 0.0,
240
+ UNVERIFIED,
241
+ ["no Hancom render oracle reachable -- A is unverified, not a pass"],
242
+ )
243
+ out = None
244
+ if work_dir:
245
+ Path(work_dir).mkdir(parents=True, exist_ok=True)
246
+ out = str(Path(work_dir) / "produced_render.pdf")
247
+ pdf = oracle.render_pdf(str(produced), out)
248
+ if not pdf:
249
+ return AxisScore(
250
+ key,
251
+ name,
252
+ weight,
253
+ 0.0,
254
+ MEASURED,
255
+ ["Hancom failed to render produced document (hard render failure)"],
256
+ )
257
+ try:
258
+ crossings = detect_overflow_crossings(pdf)
259
+ except RuntimeError as exc:
260
+ return AxisScore(
261
+ key,
262
+ name,
263
+ weight,
264
+ 0.0,
265
+ UNVERIFIED,
266
+ [f"render imaging deps unavailable: {exc}"],
267
+ )
268
+
269
+ import fitz # already importable if we got here
270
+
271
+ doc = fitz.open(pdf)
272
+ pages = doc.page_count
273
+ doc.close()
274
+
275
+ findings: list[str] = []
276
+ penalty = 0.0
277
+ if crossings:
278
+ by_page: dict[int, int] = {}
279
+ for c in crossings:
280
+ by_page[c["page"]] = by_page.get(c["page"], 0) + 1
281
+ penalty += min(20.0, len(crossings) * 3.0)
282
+ for c in crossings[:8]:
283
+ findings.append(
284
+ f"overflow: '{c['text']}' crosses a cell border at p{c['page']} ({c['x']},{c['y']})"
285
+ )
286
+ findings.append(
287
+ f"overflow crossings total: {len(crossings)} across pages {sorted(by_page)}"
288
+ )
289
+
290
+ if expected_pages is not None and pages > expected_pages:
291
+ over = pages - expected_pages
292
+ penalty += min(6.0, over * 2.0)
293
+ findings.append(
294
+ f"page-count {pages} exceeds expected {expected_pages} (+{over}) -- possible overflow/pagination"
295
+ )
296
+
297
+ score = max(0.0, weight - penalty)
298
+ detail = {
299
+ "pages": pages,
300
+ "expected_pages": expected_pages,
301
+ "overflow_crossings": len(crossings),
302
+ "pdf": pdf,
303
+ }
304
+ status = MEASURED
305
+ if not findings:
306
+ findings.append(f"render clean: 0 overflow crossings over {pages} pages")
307
+ return AxisScore(key, name, weight, score, status, findings, detail)
308
+
309
+
310
+ # --------------------------------------------------------------------------- #
311
+ # B. format fidelity (byte preservation vs blank)
312
+ # --------------------------------------------------------------------------- #
313
+
314
+
315
+ def score_format_fidelity(produced: str | Path, blank: str | Path) -> AxisScore:
316
+ """B axis. Untouched tables must be carried byte-verbatim from *blank*; edited
317
+ tables must reuse the blank's formatting vocabulary (borderFill IDs + width
318
+ integers) rather than regenerate generic formatting."""
319
+ key, name, weight = "B", "format_fidelity", 25
320
+ ptabs = _tables(produced)
321
+ btabs = _tables(blank)
322
+ if not ptabs:
323
+ return AxisScore(key, name, weight, 0.0, MEASURED, ["produced has no tables"])
324
+
325
+ blank_blobs = {t.bytes for t in btabs}
326
+ bf_vocab = _borderfill_vocab(btabs)
327
+ w_vocab = _width_vocab(btabs)
328
+
329
+ carried = 0
330
+ preserved_credit = 0.0
331
+ regenerated: list[int] = []
332
+ for i, t in enumerate(ptabs):
333
+ if t.bytes in blank_blobs:
334
+ carried += 1
335
+ continue
336
+ # edited / new: does it reuse the blank's formatting vocabulary?
337
+ bf = {m.group(1).decode() for m in _BORDERFILL_RE.finditer(t.bytes)}
338
+ ws = {int(m.group(1)) for m in _WIDTH_RE.finditer(t.bytes)}
339
+ bf_match = (len(bf & bf_vocab) / len(bf)) if bf else 0.0
340
+ w_match = (len(ws & w_vocab) / len(ws)) if ws else 0.0
341
+ cred = 0.5 * bf_match + 0.5 * w_match
342
+ preserved_credit += cred
343
+ if cred < 0.5:
344
+ regenerated.append(i)
345
+
346
+ n = len(ptabs)
347
+ carry_rate = carried / n
348
+ edited = n - carried
349
+ fmt_pres_rate = (preserved_credit / edited) if edited else 1.0
350
+ raw = 0.5 * carry_rate + 0.5 * fmt_pres_rate
351
+ score = weight * raw
352
+
353
+ findings: list[str] = []
354
+ findings.append(
355
+ f"carried verbatim: {carried}/{n} tables ({carry_rate:.0%}); "
356
+ f"edited-format-preservation: {fmt_pres_rate:.0%} over {edited} edited tables"
357
+ )
358
+ if carried == 0 and n >= 5:
359
+ findings.append(
360
+ "REGENERATION: no blank table survives byte-verbatim -- "
361
+ "document was rebuilt, not edited (Constitution VII violation)"
362
+ )
363
+ for i in regenerated[:8]:
364
+ t = ptabs[i]
365
+ findings.append(
366
+ f"regenerated formatting: table #{i} '{t.first_row[:3]}' "
367
+ f"does not reuse blank borderFill/width vocabulary"
368
+ )
369
+ detail = {
370
+ "carried": carried,
371
+ "n_produced": n,
372
+ "carry_rate": round(carry_rate, 3),
373
+ "fmt_preservation": round(fmt_pres_rate, 3),
374
+ "regenerated_tables": regenerated,
375
+ }
376
+ return AxisScore(key, name, weight, score, MEASURED, findings, detail)
377
+
378
+
379
+ # --------------------------------------------------------------------------- #
380
+ # C. structure conformance (vs gold skeleton)
381
+ # --------------------------------------------------------------------------- #
382
+
383
+
384
+ def _skeleton(source: str | Path) -> dict[str, Any]:
385
+ tabs = _tables(source)
386
+ kinds: dict[str, int] = {}
387
+ ratio_has_regular = False
388
+ for t in tabs:
389
+ k = _classify(t)
390
+ kinds[k] = kinds.get(k, 0) + 1
391
+ if k == "ratio" and "정기시험" in " | ".join(t.first_row):
392
+ ratio_has_regular = True
393
+ return {
394
+ "kinds": kinds,
395
+ "ratio_has_regular_exam": ratio_has_regular,
396
+ "n_tables": len(tabs),
397
+ }
398
+
399
+
400
+ def score_structure(
401
+ produced: str | Path,
402
+ gold: str | Path,
403
+ *,
404
+ expected: dict[str, int] | None = None,
405
+ ) -> AxisScore:
406
+ """C axis. **Policy** (delete/keep) is matched to the gold skeleton -- it is
407
+ stable across semesters -- while **block counts** are matched to
408
+ ``expected`` (content-derived: achievement/level/rubric/achieve_rate table
409
+ counts). Gold is 1학기 (4 areas); a 2학기 fill legitimately has different
410
+ counts, so comparing counts to gold would false-penalise a correct fill.
411
+ With ``expected=None`` counts fall back to gold's (same-semester assumption,
412
+ flagged)."""
413
+ key, name, weight = "C", "structure_conformance", 20
414
+ ps = _skeleton(produced)
415
+ gs = _skeleton(gold)
416
+ pk, gk = ps["kinds"], gs["kinds"]
417
+ count_ref = expected if expected is not None else gk
418
+ ref_src = (
419
+ "content-expected"
420
+ if expected is not None
421
+ else "gold (same-semester assumption)"
422
+ )
423
+
424
+ checks: list[tuple[str, bool, str]] = []
425
+ # deletion policy vs gold (stable across semesters)
426
+ for kind, label in [
427
+ ("seokcha", "석차등급 red table"),
428
+ ("submit", "제출 안내 red table"),
429
+ ("notice_star", "★유의 red table"),
430
+ ]:
431
+ gone_in_gold = gk.get(kind, 0) == 0
432
+ gone_in_prod = pk.get(kind, 0) == 0
433
+ ok = gone_in_prod == gone_in_gold
434
+ checks.append(
435
+ (
436
+ f"delete:{label}",
437
+ ok,
438
+ ""
439
+ if ok
440
+ else f"gold has {gk.get(kind, 0)} / produced {pk.get(kind, 0)} -- deletion policy mismatch",
441
+ )
442
+ )
443
+ # 정기시험 column removed from 반영비율 (this subject: 100% 수행)
444
+ ok = ps["ratio_has_regular_exam"] == gs["ratio_has_regular_exam"]
445
+ checks.append(
446
+ (
447
+ "ratio:정기시험 column policy",
448
+ ok,
449
+ ""
450
+ if ok
451
+ else f"gold 정기시험={gs['ratio_has_regular_exam']} / produced={ps['ratio_has_regular_exam']}",
452
+ )
453
+ )
454
+ # block counts vs content-expected (or gold fallback)
455
+ for kind, label in [
456
+ ("achievement", "성취기준·평가기준 blocks"),
457
+ ("level", "영역별 성취수준 blocks"),
458
+ ("rubric", "수행평가 세부기준 blocks"),
459
+ ("achieve_rate", "기준 성취율/성취도 tables"),
460
+ ]:
461
+ g, p = count_ref.get(kind, 0), pk.get(kind, 0)
462
+ ok = g == p
463
+ checks.append(
464
+ (
465
+ f"count:{label}",
466
+ ok,
467
+ "" if ok else f"expected {g} vs produced {p} ({ref_src})",
468
+ )
469
+ )
470
+
471
+ passed = sum(1 for _l, ok, _m in checks if ok)
472
+ total = len(checks)
473
+ score = weight * (passed / total) if total else 0.0
474
+ findings = [
475
+ f"structure checks {passed}/{total} (policy vs gold, counts vs {ref_src})"
476
+ ]
477
+ for label, ok, msg in checks:
478
+ if not ok:
479
+ findings.append(f"MISMATCH {label}: {msg}")
480
+ detail = {
481
+ "produced_kinds": pk,
482
+ "gold_kinds": gk,
483
+ "count_ref": count_ref,
484
+ "passed": passed,
485
+ "total": total,
486
+ }
487
+ return AxisScore(key, name, weight, score, MEASURED, findings, detail)
488
+
489
+
490
+ # --------------------------------------------------------------------------- #
491
+ # D. content completeness
492
+ # --------------------------------------------------------------------------- #
493
+
494
+ _SECTION_RE = re.compile(
495
+ r"(\d+)\s*(평가의 목적|평가의 기본 방향|평가 방침|성취기준|"
496
+ r"평가의 종류와 반영비율|기준 성취율|수행평가 세부기준|정의적|"
497
+ r"수행평가 미응시|평가 유의사항|평가 결과)"
498
+ )
499
+
500
+
501
+ # Curriculum standard codes in ANY subject's numbering: 2-part ([12인기01-01]) and
502
+ # 3-part ([10통사2-01-03]). The 3-part form matters for leftover-sample detection
503
+ # -- a rubric left holding 통합사회 sample codes must not read as clean.
504
+ _STD_CODE_RE = re.compile(r"\[\d\d[가-힣A-Za-z]*\d+(?:-\d+)+\]")
505
+
506
+
507
+ def _clean_anchor(a: str) -> str:
508
+ # drop markdown emphasis so a review anchor like "기본점수 **14**" matches the
509
+ # produced cell text "기본점수 14" (produced strips markup).
510
+ return a.replace("*", "").replace(" ", "")
511
+
512
+
513
+ def _present_frac(anchors: Sequence[str], norm_prod: str) -> float | None:
514
+ anchors = [a for a in anchors if a and len(a.replace("*", "").strip()) >= 3]
515
+ if not anchors:
516
+ return None
517
+ hit = sum(1 for a in anchors if _clean_anchor(a) in norm_prod)
518
+ return hit / len(anchors)
519
+
520
+
521
+ def _content_region_fill(md_text: str, norm_prod: str) -> dict[str, float]:
522
+ """Per-region fill fraction of the review MD's content in the produced doc.
523
+
524
+ Regions (schedule codes / achievement level prose / rubric items / section
525
+ prose) are measured separately so filling one cannot mask the others.
526
+ Falls back to raw standard-code overlap if the structured parse is
527
+ unavailable."""
528
+ regions: dict[str, float] = {}
529
+ try:
530
+ from ..evalplan import parse_review_md
531
+
532
+ c = parse_review_md(md_text)
533
+ except Exception:
534
+ want = set(_STD_CODE_RE.findall(md_text))
535
+ got_norm = norm_prod
536
+ if want:
537
+ regions["codes"] = sum(
538
+ 1 for w in want if w.replace(" ", "") in got_norm
539
+ ) / len(want)
540
+ return regions
541
+
542
+ # schedule: curriculum standard codes
543
+ codes = _STD_CODE_RE.findall(md_text)
544
+ f = _present_frac(list(dict.fromkeys(codes)), norm_prod)
545
+ if f is not None:
546
+ regions["schedule_codes"] = f
547
+ # achievement: the 상 (highest) descriptor of each standard row
548
+ ach = [row[1][:16] for row in c.achievement_std if len(row) >= 2 and row[1]]
549
+ f = _present_frac(ach, norm_prod)
550
+ if f is not None:
551
+ regions["achievement_prose"] = f
552
+ # rubric: the 평가항목 label of each rubric row
553
+ items = [
554
+ r.rows[i][0][:12]
555
+ for r in c.rubrics
556
+ for i in range(len(r.rows))
557
+ if r.rows[i] and r.rows[i][0]
558
+ ]
559
+ f = _present_frac(items, norm_prod)
560
+ if f is not None:
561
+ regions["rubric_items"] = f
562
+ # sections: a distinctive opening phrase of §1/§2/§3/§11 prose
563
+ secs = [s[:18] for s in (c.purposes, c.directions, c.policies, c.analysis) if s]
564
+ # strip leading "가." markers for a cleaner anchor
565
+ secs = [re.sub(r"^[가-힣]\.\s*", "", s) for s in secs]
566
+ f = _present_frac(secs, norm_prod)
567
+ if f is not None:
568
+ regions["section_prose"] = f
569
+ # ratio table (§6): the MD's per-영역 반영비율 numbers AND 영역 names must land in
570
+ # the produced 반영비율 table. The old ratio D-check only demanded *some* subset of
571
+ # percentages summing to 100 -- a leftover SAMPLE (50/50/50 → finds 100) passed.
572
+ # This region pins the actual MD numbers (e.g. 35%/35%/30%) + area names, so a
573
+ # sample ratio scores low and a genuinely-filled one scores high (no-silent-true).
574
+ ratio_anchors = _ratio_anchors(c)
575
+ f = _present_frac(ratio_anchors, norm_prod)
576
+ if f is not None:
577
+ regions["ratio_content"] = f
578
+ return regions
579
+
580
+
581
+ def _ratio_anchors(content: Any) -> list[str]:
582
+ """Distinctive anchors of the review §6 반영비율 table: each 영역's 반영비율 number
583
+ (from the 영역 만점(반영비율) row, e.g. '35%' / '30%') plus each 영역 name. Used by
584
+ D's ratio-content check so a produced table carrying the SAMPLE 반영비율 (not the
585
+ MD's) scores low."""
586
+ anchors: list[str] = []
587
+ # 영역 names from the §6 header (drop the leading 구분 label + trailing 합계)
588
+ hdr = list(getattr(content, "ratio_header", []) or [])
589
+ for name in hdr[1:]:
590
+ if "합계" in name:
591
+ continue
592
+ # the circled-number prefix ('① …') is a strong, non-sample anchor
593
+ n = name.strip()
594
+ if len(n) >= 4:
595
+ anchors.append(n[:18])
596
+ # 반영비율 numbers from the 영역 만점(반영비율) row (the '(NN%)' inside each cell)
597
+ for row in getattr(content, "ratio_rows", []) or []:
598
+ if not row:
599
+ continue
600
+ if "만점" in row[0] or "반영비율" in row[0].replace(" ", ""):
601
+ for cell in row[1:]:
602
+ for m in re.findall(r"\d+\s*%", cell):
603
+ anchors.append(m.replace(" ", ""))
604
+ break
605
+ # de-dup while preserving order
606
+ return list(dict.fromkeys(a for a in anchors if a))
607
+
608
+
609
+ def score_content(
610
+ produced: str | Path,
611
+ *,
612
+ content: str | Path | None = None,
613
+ blank: str | Path | None = None,
614
+ ) -> AxisScore:
615
+ """D axis. Sections present, ratio sums to 100 (왼쪽 우선), and -- when the
616
+ review ``content`` is given -- the produced actually carries THAT content
617
+ (per-region prose overlap) AND has replaced the blank's sample content
618
+ (``blank`` given: no leftover foreign standard codes). Without these a
619
+ structurally-perfect but unfilled form, or one still showing sample content
620
+ in unfilled regions, would falsely score high (no-silent-true)."""
621
+ key, name, weight = "D", "content_completeness", 15
622
+ tabs = _tables(produced)
623
+ # whole-document text (tables + section paragraphs) so section-prose fills,
624
+ # which live in paragraphs, are visible to the content-fidelity check.
625
+ full_text = _document_text(produced)
626
+
627
+ checks: list[tuple[str, bool, str]] = []
628
+ # sections [1]~[11] -- look for the numbered section labels in the section tables
629
+ labels = [
630
+ "평가의 목적",
631
+ "평가의 기본 방향",
632
+ "평가 방침",
633
+ "성취기준 및 성취수준",
634
+ "평가의 종류와 반영비율",
635
+ "기준 성취율과 성취도",
636
+ "수행평가 세부기준",
637
+ "정의적 능력 평가",
638
+ "수행평가 미응시자",
639
+ "평가 유의사항",
640
+ "평가 결과 분석",
641
+ ]
642
+ missing = [
643
+ lab for lab in labels if lab.replace(" ", "") not in full_text.replace(" ", "")
644
+ ]
645
+ checks.append(
646
+ (
647
+ "sections [1]~[11] present",
648
+ not missing,
649
+ "" if not missing else f"missing sections: {missing}",
650
+ )
651
+ )
652
+
653
+ # content fidelity: the produced must carry the review MD's content across
654
+ # ALL major regions -- not just one. Standard codes concentrate in the
655
+ # schedule, so a schedule-only fill would max a code-only check while
656
+ # achievement / rubric / section prose is still blank sample text. Averaging
657
+ # per-region fill fractions keeps D honest (no-silent-true).
658
+ content_match = None
659
+ region_detail: dict[str, float] = {}
660
+ if content is not None:
661
+ md_text = (
662
+ Path(content).read_text(encoding="utf-8")
663
+ if _looks_like_path(content)
664
+ else str(content)
665
+ )
666
+ norm_prod = full_text.replace(" ", "")
667
+ region_detail = _content_region_fill(md_text, norm_prod)
668
+ # foreign-sample penalty: the produced must REPLACE the blank's sample
669
+ # content, not merely add correct content beside it. Standard codes that
670
+ # are in the blank but not in the review MD are leftover sample text; if
671
+ # they survive, the corresponding region is not really filled.
672
+ foreign_frac = 0.0
673
+ if blank is not None:
674
+ blank_codes = set(_STD_CODE_RE.findall(_all_text(blank)))
675
+ md_codes = set(_STD_CODE_RE.findall(md_text))
676
+ foreign = blank_codes - md_codes
677
+ got = set(_STD_CODE_RE.findall(full_text))
678
+ if foreign:
679
+ foreign_frac = len(foreign & got) / len(foreign)
680
+ if region_detail:
681
+ raw = sum(region_detail.values()) / len(region_detail)
682
+ content_match = raw * (1.0 - foreign_frac)
683
+ ok = content_match >= 0.6
684
+ weak = {k: round(v, 2) for k, v in region_detail.items() if v < 0.6}
685
+ msg = f"content_match={content_match:.0%} (fill={raw:.0%}, leftover-sample={foreign_frac:.0%})"
686
+ if weak:
687
+ msg += f"; under-filled regions {weak}"
688
+ checks.append(
689
+ (
690
+ "content matches review MD (all regions, samples replaced)",
691
+ ok,
692
+ "" if ok else msg + " -- produced still holds blank sample content",
693
+ )
694
+ )
695
+
696
+ # 반영비율: parse percentages in the ratio table, sum ~100, non-increasing
697
+ ratio_tabs = [t for t in tabs if _classify(t) == "ratio"]
698
+ ratio_ok = None
699
+ ratio_msg = ""
700
+ if ratio_tabs:
701
+ rt = ratio_tabs[0].text
702
+ pcts = [int(m) for m in re.findall(r"(\d+)\s*%", rt)]
703
+ area_pcts = [p for p in pcts if p not in (100,)]
704
+ if area_pcts:
705
+ total_p = None
706
+ # find a contiguous set that sums to 100
707
+ for k in range(2, min(len(area_pcts), 5) + 1):
708
+ if sum(area_pcts[:k]) == 100:
709
+ total_p = 100
710
+ seg = area_pcts[:k]
711
+ break
712
+ else:
713
+ seg = area_pcts
714
+ # "왼쪽 우선": the first (left-most) area carries the largest weight.
715
+ # gold uses 30/20/20/30 -- NOT strictly monotone -- so require only
716
+ # that the first area ties the maximum, which gold satisfies.
717
+ left_first = bool(seg) and seg[0] == max(seg)
718
+ ratio_ok = (total_p == 100) and left_first
719
+ ratio_msg = (
720
+ f"ratio segment {seg} sum={sum(seg)} left-first-largest={left_first}"
721
+ )
722
+ else:
723
+ ratio_ok = False
724
+ ratio_msg = "no percentages found in ratio table"
725
+ if ratio_ok is not None:
726
+ checks.append(
727
+ (
728
+ "반영비율 sum=100 & non-increasing",
729
+ ratio_ok,
730
+ "" if ratio_ok else ratio_msg,
731
+ )
732
+ )
733
+
734
+ # 반영비율 CONTENT (when the review MD is given): the sum-to-100 check above is
735
+ # satisfied by a SAMPLE 반영비율 (50/50/50 finds 100 somewhere), so it is a blind
736
+ # spot. Require the produced ratio table to actually carry the MD's per-영역
737
+ # 반영비율 numbers (e.g. 35/35/30) -- a leftover sample lacks them and fails.
738
+ if ratio_tabs and content is not None:
739
+ try:
740
+ from ..evalplan import parse_review_md
741
+
742
+ cc = parse_review_md(md_text)
743
+ except Exception:
744
+ cc = None
745
+ want_pcts = [a for a in (_ratio_anchors(cc) if cc else []) if a.endswith("%")]
746
+ if want_pcts:
747
+ rt_norm = ratio_tabs[0].text.replace(" ", "")
748
+ hit = sum(1 for p in want_pcts if p in rt_norm)
749
+ frac_pcts = hit / len(want_pcts)
750
+ rc_ok = frac_pcts >= 0.9
751
+ checks.append(
752
+ (
753
+ "반영비율 carries MD's 영역 반영비율 numbers",
754
+ rc_ok,
755
+ ""
756
+ if rc_ok
757
+ else f"only {hit}/{len(want_pcts)} of the MD 반영비율 numbers {want_pcts} "
758
+ f"present -- ratio table still holds SAMPLE percentages",
759
+ )
760
+ )
761
+
762
+ # weighted: content fidelity ("전 섹션 채움") dominates -- a structurally perfect
763
+ # but unfilled form must not reach the loop's >=90 threshold on structure alone.
764
+ sections_ok = 1.0 if not missing else 0.0
765
+ ratio_frac = 1.0 if (ratio_ok in (True, None)) else 0.0
766
+ if content_match is not None:
767
+ # content fill dominates: section labels are nearly free (they ship in the
768
+ # blank), so a labels-present-but-unfilled form must not clear the gate.
769
+ frac = 0.1 * sections_ok + 0.1 * ratio_frac + 0.8 * content_match
770
+ else:
771
+ frac = 0.5 * sections_ok + 0.5 * ratio_frac
772
+ score = weight * frac
773
+ status = MEASURED
774
+ passed = sum(1 for _l, ok, _m in checks if ok)
775
+ findings = [
776
+ f"content checks {passed}/{len(checks)}"
777
+ + (f"; content_match={content_match:.0%}" if content_match is not None else "")
778
+ ]
779
+ for label, ok, msg in checks:
780
+ if not ok:
781
+ findings.append(f"INCOMPLETE {label}: {msg}")
782
+ if content is None:
783
+ findings.append(
784
+ "note: no review content given -- content-fidelity unchecked (D is structural only)"
785
+ )
786
+ findings.append(
787
+ "note: empty-required-cell detection is coarse (text-presence only)"
788
+ )
789
+ return AxisScore(
790
+ key,
791
+ name,
792
+ weight,
793
+ score,
794
+ status,
795
+ findings,
796
+ {
797
+ "missing_sections": missing,
798
+ "passed": passed,
799
+ "total": len(checks),
800
+ "content_match": content_match,
801
+ "region_fill": region_detail,
802
+ "leftover_sample_frac": round(foreign_frac, 3)
803
+ if content is not None
804
+ else None,
805
+ },
806
+ )
807
+
808
+
809
+ # --------------------------------------------------------------------------- #
810
+ # E. compliance lint
811
+ # --------------------------------------------------------------------------- #
812
+
813
+
814
+ def score_compliance(produced: str | Path) -> AxisScore:
815
+ """E axis. Computable 평가계획 lint rules; manual-only rules -> needs_review.
816
+
817
+ Rules are *gold-calibrated*: gold (the accepted submission) passes every one.
818
+ (Notably gold uses ``~`` in the 성취율 band notation, so a blanket 물결표 ban
819
+ is a false positive and is deferred to manual review, not scored.)
820
+ """
821
+ key, name, weight = "E", "compliance", 10
822
+ tabs = _tables(produced)
823
+ checks: list[tuple[str, bool, str]] = []
824
+
825
+ # (1) 정기시험 column removed (100% 수행 for this subject family)
826
+ ratio_tabs = [t for t in tabs if _classify(t) == "ratio"]
827
+ has_regular = any("정기시험" in " | ".join(t.first_row) for t in ratio_tabs)
828
+ checks.append(
829
+ (
830
+ "정기시험 열 부재",
831
+ not has_regular,
832
+ "" if not has_regular else "반영비율 table still has 정기시험 column",
833
+ )
834
+ )
835
+
836
+ # (2) 반영비율 왼쪽 우선(첫 영역이 최대) -- gold 30/20/20/30 passes
837
+ left_ok = None
838
+ if ratio_tabs:
839
+ pcts = [
840
+ int(m)
841
+ for m in re.findall(r"(\d+)\s*%", ratio_tabs[0].text)
842
+ if int(m) != 100
843
+ ]
844
+ seg = None
845
+ for k in range(2, min(len(pcts), 5) + 1):
846
+ if sum(pcts[:k]) == 100:
847
+ seg = pcts[:k]
848
+ break
849
+ seg = seg or pcts
850
+ left_ok = bool(seg) and seg[0] == max(seg)
851
+ checks.append(
852
+ (
853
+ "반영비율 왼쪽 우선(첫 영역 최대)",
854
+ left_ok,
855
+ "" if left_ok else f"first area {seg[:1]} is not the max of {seg}",
856
+ )
857
+ )
858
+
859
+ # (3) 성취도 3단계(A~C) present in a 성취율 table (진로선택 절대평가)
860
+ rate_tabs = [t for t in tabs if _classify(t) == "achieve_rate"]
861
+ three_grade = (
862
+ any(all(g in t.text for g in ("A", "B", "C")) for t in rate_tabs)
863
+ if rate_tabs
864
+ else None
865
+ )
866
+ if three_grade is not None:
867
+ checks.append(
868
+ (
869
+ "성취도 3단계(A~C)",
870
+ three_grade,
871
+ "" if three_grade else "성취율 table missing A/B/C grade bands",
872
+ )
873
+ )
874
+
875
+ passed = sum(1 for _l, ok, _m in checks if ok)
876
+ total = len(checks)
877
+ score = weight * (passed / total) if total else 0.0
878
+ findings = [
879
+ f"lint {passed}/{total} computable rules pass; "
880
+ "manual-only 유의사항 rules (수행 30%↑, 기본점수 20~40%, 급간 동일, "
881
+ "물결표 문맥) require human review -> needs_review"
882
+ ]
883
+ for label, ok, msg in checks:
884
+ if not ok:
885
+ findings.append(f"LINT {label}: {msg}")
886
+ return AxisScore(
887
+ key,
888
+ name,
889
+ weight,
890
+ score,
891
+ NEEDS_REVIEW,
892
+ findings,
893
+ {"passed": passed, "total": total},
894
+ )
895
+
896
+
897
+ # --------------------------------------------------------------------------- #
898
+ # Top-level scorer
899
+ # --------------------------------------------------------------------------- #
900
+
901
+
902
+ def score_form_fill(
903
+ produced: str | Path,
904
+ gold: str | Path,
905
+ blank: str | Path,
906
+ *,
907
+ oracle: Any | None = None,
908
+ produced_pdf: str | Path | None = None,
909
+ content: str | Path | None = None,
910
+ expected_skeleton: dict[str, int] | None = None,
911
+ expected_pages: int | None = None,
912
+ work_dir: str | Path | None = None,
913
+ run_render: bool = True,
914
+ ) -> ScoreCard:
915
+ """Score ``produced`` against ``gold`` (structure) and ``blank`` (byte
916
+ fidelity), on 5 axes -> weighted 0-100 + gap report. The loop's fitness fn.
917
+
918
+ ``run_render=False`` skips the A oracle render (fast structural-only pass;
919
+ A becomes ``unverified``). Provide ``produced_pdf`` to reuse a prior render.
920
+ """
921
+ if run_render or produced_pdf is not None:
922
+ a = score_render(
923
+ produced,
924
+ oracle=oracle,
925
+ produced_pdf=produced_pdf,
926
+ expected_pages=expected_pages,
927
+ work_dir=work_dir,
928
+ )
929
+ else:
930
+ a = AxisScore(
931
+ "A",
932
+ "render_cleanliness",
933
+ 30,
934
+ 0.0,
935
+ UNVERIFIED,
936
+ ["render skipped (run_render=False) -- A unverified"],
937
+ )
938
+ b = score_format_fidelity(produced, blank)
939
+ c = score_structure(produced, gold, expected=expected_skeleton)
940
+ d = score_content(produced, content=content, blank=blank)
941
+ e = score_compliance(produced)
942
+ axes = [a, b, c, d, e]
943
+ total = sum(ax.score for ax in axes)
944
+ render_checked = a.status == MEASURED
945
+ return ScoreCard(total=total, render_checked=render_checked, axes=axes)
946
+
947
+
948
+ __all__ = [
949
+ "MEASURED",
950
+ "NEEDS_REVIEW",
951
+ "UNVERIFIED",
952
+ "AxisScore",
953
+ "ScoreCard",
954
+ "detect_overflow_crossings",
955
+ "score_compliance",
956
+ "score_content",
957
+ "score_form_fill",
958
+ "score_format_fidelity",
959
+ "score_render",
960
+ "score_structure",
961
+ ]