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.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- 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
|
+
]
|