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,2762 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Evaluation-plan (평가계획) review-markdown parser + target skeleton.
|
|
3
|
+
|
|
4
|
+
The GOAL-loop recipe fills a blank province form from a structured review markdown
|
|
5
|
+
(Ⅰ 운영계획 + [1]~[11]). This module is the *content* half: it parses that markdown
|
|
6
|
+
into a structured :class:`EvalPlanContent` and derives the **target skeleton**
|
|
7
|
+
(how many achievement / 성취수준 / rubric / 성취율 tables the content requires) that
|
|
8
|
+
the quality scorer's C axis measures against (content-derived counts, not the
|
|
9
|
+
1학기 gold's).
|
|
10
|
+
|
|
11
|
+
Kept deliberately format-tolerant: the review markdown is hand-authored, so the
|
|
12
|
+
parser locates sections by their numbered headers and pulls the GitHub-style
|
|
13
|
+
tables under each. Prose sections are returned as normalised text. No content is
|
|
14
|
+
invented — a missing section yields an empty field, surfaced by the scorer's D
|
|
15
|
+
axis rather than silently filled.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, cast
|
|
24
|
+
|
|
25
|
+
_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _md_table_rows(block: str) -> list[list[str]]:
|
|
29
|
+
"""Every data row of the first GitHub-style table in *block* (header +
|
|
30
|
+
separator dropped), each split into trimmed cells."""
|
|
31
|
+
rows: list[list[str]] = []
|
|
32
|
+
seen_sep = False
|
|
33
|
+
for line in block.splitlines():
|
|
34
|
+
s = line.strip()
|
|
35
|
+
if not s.startswith("|"):
|
|
36
|
+
if rows:
|
|
37
|
+
break # table ended
|
|
38
|
+
continue
|
|
39
|
+
cells = [c.strip() for c in s.strip("|").split("|")]
|
|
40
|
+
if all(set(c) <= {"-", ":", " "} and c for c in cells):
|
|
41
|
+
seen_sep = True
|
|
42
|
+
continue
|
|
43
|
+
rows.append(cells)
|
|
44
|
+
# first row is the header; keep only rows after the separator
|
|
45
|
+
if seen_sep and rows:
|
|
46
|
+
return rows[1:]
|
|
47
|
+
return rows[1:] if len(rows) > 1 else []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _md_table_header(block: str) -> list[str]:
|
|
51
|
+
for line in block.splitlines():
|
|
52
|
+
s = line.strip()
|
|
53
|
+
if s.startswith("|"):
|
|
54
|
+
return [c.strip() for c in s.strip("|").split("|")]
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class RubricItem:
|
|
60
|
+
"""One 평가요소 (evaluation element) with its observable-criteria 배점 ladder —
|
|
61
|
+
``levels`` is ``[(descriptor, 배점), …]`` top score first, verbatim from the MD.
|
|
62
|
+
``subtotal`` marks a 소계 row (the sub-area 만점, not a scored element)."""
|
|
63
|
+
name: str
|
|
64
|
+
levels: list[tuple[str, str]] = field(default_factory=list)
|
|
65
|
+
subtotal: bool = False
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict[str, Any]:
|
|
68
|
+
return {"name": self.name, "levels": [list(level) for level in self.levels],
|
|
69
|
+
"subtotal": self.subtotal}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class RubricSubArea:
|
|
74
|
+
"""A 세부 영역 (가./나. …) or, for a single-table area, one anonymous sub-area
|
|
75
|
+
(``label==""``). ``points`` is the 소계 (0 if the MD gives none)."""
|
|
76
|
+
label: str = ""
|
|
77
|
+
points: int = 0
|
|
78
|
+
items: list[RubricItem] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict[str, Any]:
|
|
81
|
+
return {"label": self.label, "points": self.points,
|
|
82
|
+
"items": [it.to_dict() for it in self.items]}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Rubric:
|
|
87
|
+
title: str # "문제해결에 탐색 활용하기"
|
|
88
|
+
points: int # 35
|
|
89
|
+
standards: str # "[12인기02-04][12인기02-05]"
|
|
90
|
+
rows: list[list[str]] = field(default_factory=list) # [평가항목, 채점 기준]
|
|
91
|
+
# --- detailed 배점 rubric (current 평가계획 MD format), empty for the legacy
|
|
92
|
+
# synthetic format; :attr:`detailed` gates the detailed fill route.
|
|
93
|
+
subareas: list[RubricSubArea] = field(default_factory=list)
|
|
94
|
+
base_score: str = "" # 기본점수 (백지·미참여)
|
|
95
|
+
long_score: str = "" # 장기 미인정 결석자 (기본점수 −1)
|
|
96
|
+
task: str = "" # 수행과제
|
|
97
|
+
method: str = "" # 평가 방법
|
|
98
|
+
student_notes: str = "" # 학생 유의사항
|
|
99
|
+
criteria: str = "" # 평가기준(상/중/하) — 2015-개정 (3학년) only
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def detailed(self) -> bool:
|
|
103
|
+
"""True when parsed from the current 평가계획 MD (per-element 배점 ladders)."""
|
|
104
|
+
return bool(self.subareas)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def items(self) -> list[RubricItem]:
|
|
108
|
+
"""All 평가요소 across sub-areas, in document order (flattened view)."""
|
|
109
|
+
return [it for sa in self.subareas for it in sa.items]
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> dict[str, Any]:
|
|
112
|
+
return {"title": self.title, "points": self.points,
|
|
113
|
+
"standards": self.standards, "rows": self.rows,
|
|
114
|
+
"subareas": [sa.to_dict() for sa in self.subareas],
|
|
115
|
+
"base_score": self.base_score, "long_score": self.long_score}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class EvalPlanContent:
|
|
120
|
+
title: str = ""
|
|
121
|
+
teacher: str = ""
|
|
122
|
+
schedule: list[list[str]] = field(default_factory=list) # Ⅰ, 6 cols/row
|
|
123
|
+
purposes: str = "" # §1
|
|
124
|
+
directions: str = "" # §2
|
|
125
|
+
policies: str = "" # §3
|
|
126
|
+
achievement_std: list[list[str]] = field(default_factory=list) # §4가 [성취기준,상,중,하]
|
|
127
|
+
levels: list[list[str]] = field(default_factory=list) # §4나 [영역,A,B,C]
|
|
128
|
+
achieve_rate: list[list[str]] = field(default_factory=list) # §5 [성취율,성취도]
|
|
129
|
+
ratio_header: list[str] = field(default_factory=list) # §6 header cells
|
|
130
|
+
ratio_rows: list[list[str]] = field(default_factory=list) # §6 data rows
|
|
131
|
+
rubrics: list[Rubric] = field(default_factory=list) # §7
|
|
132
|
+
affective: str = "" # §8
|
|
133
|
+
absentee: str = "" # §9
|
|
134
|
+
cautions: str = "" # §10
|
|
135
|
+
analysis: str = "" # §11
|
|
136
|
+
|
|
137
|
+
def to_dict(self) -> dict[str, Any]:
|
|
138
|
+
return {
|
|
139
|
+
"title": self.title, "teacher": self.teacher,
|
|
140
|
+
"schedule_rows": len(self.schedule),
|
|
141
|
+
"achievement_std_rows": len(self.achievement_std),
|
|
142
|
+
"level_rows": len(self.levels),
|
|
143
|
+
"achieve_rate_rows": len(self.achieve_rate),
|
|
144
|
+
"ratio_areas": len([c for c in self.ratio_header if any(ch in c for ch in _CIRCLED)]),
|
|
145
|
+
"rubrics": [r.to_dict() for r in self.rubrics],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _section(md: str, start_pat: str, end_pats: list[str]) -> str:
|
|
150
|
+
m = re.search(start_pat, md)
|
|
151
|
+
if not m:
|
|
152
|
+
return ""
|
|
153
|
+
rest = md[m.end():]
|
|
154
|
+
ends = [re.search(p, rest) for p in end_pats]
|
|
155
|
+
cut = min((e.start() for e in ends if e), default=len(rest))
|
|
156
|
+
return rest[:cut]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# a per-standard header: ``**[code]** 진술`` at the start of a line
|
|
160
|
+
_ACH_STD_HEADER = re.compile(r"^\s*\*\*\s*(\[[^\]\n]+\])\s*\*\*\s*(.*?)\s*$")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _parse_achievement_std(block: str) -> list[list[str]]:
|
|
164
|
+
"""Parse §4가 into one row per 성취기준: ``[standard, L1_desc, L2_desc, ...]``.
|
|
165
|
+
|
|
166
|
+
Two markdown authoring shapes are accepted; the level count is read from the
|
|
167
|
+
data, never hard-coded:
|
|
168
|
+
|
|
169
|
+
* **unified** -- a single table ``성취기준 | 상 | 중 | 하`` (or ``| A |…| E |``) with
|
|
170
|
+
one row per standard, level descriptors across the columns. Parsed by
|
|
171
|
+
:func:`_md_table_rows`.
|
|
172
|
+
* **per-standard** -- a sequence of ``**[code]** 진술`` headers, each followed by
|
|
173
|
+
its own ``수준 | 성취수준`` table listing the levels as *rows*. Each is normalised
|
|
174
|
+
to ``[code+진술, <descriptor per level, in table order>]``, so a standard with an
|
|
175
|
+
A~E table becomes a 6-field row (bh = 5) matching the unified shape that
|
|
176
|
+
:func:`fill_achievement` and :func:`_std_level_map` already consume.
|
|
177
|
+
|
|
178
|
+
The per-standard shape is tried first; when no ``**[code]**`` header carries a
|
|
179
|
+
table, the unified single-table parse is returned instead."""
|
|
180
|
+
lines = block.splitlines()
|
|
181
|
+
n = len(lines)
|
|
182
|
+
stds: list[list[str]] = []
|
|
183
|
+
i = 0
|
|
184
|
+
while i < n:
|
|
185
|
+
m = _ACH_STD_HEADER.match(lines[i])
|
|
186
|
+
if not m:
|
|
187
|
+
i += 1
|
|
188
|
+
continue
|
|
189
|
+
standard = f"{m.group(1)} {m.group(2)}".strip()
|
|
190
|
+
# advance to this standard's table (blank/prose lines may intervene), but
|
|
191
|
+
# stop at the next standard header -- a header with no table of its own.
|
|
192
|
+
j = i + 1
|
|
193
|
+
while j < n and not lines[j].lstrip().startswith("|"):
|
|
194
|
+
if _ACH_STD_HEADER.match(lines[j]):
|
|
195
|
+
break
|
|
196
|
+
j += 1
|
|
197
|
+
rows: list[list[str]] = []
|
|
198
|
+
while j < n and lines[j].lstrip().startswith("|"):
|
|
199
|
+
cells = [c.strip() for c in lines[j].strip().strip("|").split("|")]
|
|
200
|
+
if not all(set(c) <= {"-", ":", " "} and c for c in cells):
|
|
201
|
+
rows.append(cells)
|
|
202
|
+
j += 1
|
|
203
|
+
if len(rows) >= 2: # header row + >=1 level row
|
|
204
|
+
descs = [r[-1] for r in rows[1:]] # right-most column = the descriptor
|
|
205
|
+
stds.append([standard, *descs])
|
|
206
|
+
i = j
|
|
207
|
+
else:
|
|
208
|
+
i += 1
|
|
209
|
+
return stds or _md_table_rows(block)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_review_md(md_text: str) -> EvalPlanContent:
|
|
213
|
+
"""Parse a 평가계획 review markdown into structured content."""
|
|
214
|
+
c = EvalPlanContent()
|
|
215
|
+
|
|
216
|
+
# title + teacher (first heading + 담당교사 mention)
|
|
217
|
+
m = re.search(r"#\s*(\d{4}학년도[^\n]+?계획)", md_text)
|
|
218
|
+
if m:
|
|
219
|
+
c.title = re.sub(r"\s*\(검토용\)\s*$", "", m.group(1)).strip()
|
|
220
|
+
m = re.search(r"담당교사\s*[::]\s*([^\s·|*]+)", md_text)
|
|
221
|
+
if m:
|
|
222
|
+
c.teacher = m.group(1).strip()
|
|
223
|
+
|
|
224
|
+
# Ⅰ schedule table
|
|
225
|
+
sched = _section(md_text, r"##\s*Ⅰ\.\s*교수학습 운영 계획", [r"##\s*Ⅱ\.", r"^##\s"])
|
|
226
|
+
c.schedule = _md_table_rows(sched)
|
|
227
|
+
|
|
228
|
+
# numbered sections [1]~[11] (### N. ...)
|
|
229
|
+
def sec(n: int) -> str:
|
|
230
|
+
return _section(md_text, rf"###\s*{n}\.\s", [rf"###\s*{n+1}\.\s", r"^##\s"])
|
|
231
|
+
|
|
232
|
+
c.purposes = _prose(sec(1))
|
|
233
|
+
c.directions = _prose(sec(2))
|
|
234
|
+
c.policies = _prose(sec(3))
|
|
235
|
+
|
|
236
|
+
s4 = sec(4)
|
|
237
|
+
ga = s4.split("**나.")[0]
|
|
238
|
+
na = ("**나." + s4.split("**나.")[1]) if "**나." in s4 else ""
|
|
239
|
+
c.achievement_std = _parse_achievement_std(ga)
|
|
240
|
+
c.levels = _md_table_rows(na)
|
|
241
|
+
|
|
242
|
+
c.achieve_rate = _md_table_rows(sec(5))
|
|
243
|
+
|
|
244
|
+
s6 = sec(6)
|
|
245
|
+
c.ratio_header = _md_table_header(s6)
|
|
246
|
+
c.ratio_rows = _md_table_rows(s6)
|
|
247
|
+
|
|
248
|
+
s7 = sec(7)
|
|
249
|
+
c.rubrics = _parse_rubrics(s7)
|
|
250
|
+
|
|
251
|
+
c.affective = _prose(sec(8))
|
|
252
|
+
c.absentee = _prose(sec(9))
|
|
253
|
+
c.cautions = _prose(sec(10))
|
|
254
|
+
c.analysis = _prose(sec(11))
|
|
255
|
+
return c
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _prose(text: str) -> str:
|
|
259
|
+
"""Normalised prose of a numbered section with its leading **title line**
|
|
260
|
+
dropped -- the ``### N. 평가의 목적`` heading text the section regex sweeps in is
|
|
261
|
+
not prose. Everything up to (and excluding) the first ordinal '가.' marker or
|
|
262
|
+
'-'/'*' bullet on its own is the title; the rest is the actual content."""
|
|
263
|
+
lines = [ln.strip() for ln in text.strip().splitlines() if ln.strip()]
|
|
264
|
+
# drop a leading title line (no ordinal marker, no bullet) if content follows
|
|
265
|
+
if lines and not re.match(r"^([가-힣]\.\s|[-*·]\s)", lines[0]) and len(lines) > 1:
|
|
266
|
+
lines = lines[1:]
|
|
267
|
+
return _norm(" ".join(lines))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_rubrics(s7: str) -> list[Rubric]:
|
|
271
|
+
"""Parse §7 수행평가 세부기준 into one :class:`Rubric` per 수행영역.
|
|
272
|
+
|
|
273
|
+
Dispatches on format: the current 평가계획 MD writes each area as an ``#### ①
|
|
274
|
+
title`` H4 heading with per-세부영역 ``평가요소 | 수행수준(채점 기준) | 배점`` tables
|
|
275
|
+
(the *detailed* 배점 rubric) — :func:`_parse_rubrics_detailed`. The older synthetic
|
|
276
|
+
format bolds the header inline (``**① title (NN점)**``) with a single flat 채점
|
|
277
|
+
기준(배점) table — :func:`_parse_rubrics_legacy`. Detection is structural (an H4
|
|
278
|
+
circled heading), never subject-specific."""
|
|
279
|
+
if re.search(r"(?m)^####\s*[" + _CIRCLED + r"]", s7):
|
|
280
|
+
return _parse_rubrics_detailed(s7)
|
|
281
|
+
return _parse_rubrics_legacy(s7)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _parse_rubrics_legacy(s7: str) -> list[Rubric]:
|
|
285
|
+
rubrics: list[Rubric] = []
|
|
286
|
+
# split on the bolded circled headers "**① title (NN점)** ..."
|
|
287
|
+
parts = re.split(r"\*\*([" + _CIRCLED + r"][^*]*?\(\d+점\)[^*]*)\*\*", s7)
|
|
288
|
+
# parts = [pre, header1, body1, header2, body2, ...]
|
|
289
|
+
for i in range(1, len(parts), 2):
|
|
290
|
+
header = parts[i]
|
|
291
|
+
body = parts[i + 1] if i + 1 < len(parts) else ""
|
|
292
|
+
mt = re.match(r"[" + _CIRCLED + r"]\s*(.*?)\s*\((\d+)점\)", header)
|
|
293
|
+
title = mt.group(1).strip() if mt else header.strip()
|
|
294
|
+
points = int(mt.group(2)) if mt else 0
|
|
295
|
+
ms = re.search(r"\[12[가-힣]*\d\d-\d\d\][^\n·]*", header + body)
|
|
296
|
+
standards = ms.group(0).strip() if ms else ""
|
|
297
|
+
rows = _md_table_rows(body)
|
|
298
|
+
rubrics.append(Rubric(title=title, points=points, standards=standards, rows=rows))
|
|
299
|
+
return rubrics
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _strip_inline_md(s: str) -> str:
|
|
303
|
+
"""Drop ``**bold**`` / `` `code` `` emphasis, collapse whitespace — cell text is
|
|
304
|
+
spliced verbatim otherwise (no summarisation)."""
|
|
305
|
+
return _norm(re.sub(r"\*\*|`", "", s or ""))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _iter_md_tables(block: str):
|
|
309
|
+
"""Yield ``(preamble, header_cells, data_rows)`` for every GitHub table in *block*
|
|
310
|
+
in order. ``preamble`` is the non-table text since the previous table — it carries
|
|
311
|
+
the ``[세부 영역 …]`` marker that names each sub-area."""
|
|
312
|
+
lines = block.splitlines()
|
|
313
|
+
i = 0
|
|
314
|
+
preamble: list[str] = []
|
|
315
|
+
while i < len(lines):
|
|
316
|
+
if lines[i].strip().startswith("|"):
|
|
317
|
+
tbl = []
|
|
318
|
+
while i < len(lines) and lines[i].strip().startswith("|"):
|
|
319
|
+
tbl.append(lines[i].strip())
|
|
320
|
+
i += 1
|
|
321
|
+
rows = []
|
|
322
|
+
for t in tbl:
|
|
323
|
+
cells = [c.strip() for c in t.strip("|").split("|")]
|
|
324
|
+
if all(set(c) <= {"-", ":", " "} and c for c in cells):
|
|
325
|
+
continue
|
|
326
|
+
rows.append(cells)
|
|
327
|
+
if rows:
|
|
328
|
+
yield "\n".join(preamble), rows[0], rows[1:]
|
|
329
|
+
preamble = []
|
|
330
|
+
else:
|
|
331
|
+
preamble.append(lines[i])
|
|
332
|
+
i += 1
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _area_bullet(body: str, label: str) -> str:
|
|
336
|
+
"""The value of a ``- **{label}**: …`` meta bullet in an area body ('' if absent)."""
|
|
337
|
+
m = re.search(rf"[-*]\s*\*\*{label}\*\*\s*[::]?\s*(.+)", body)
|
|
338
|
+
return m.group(1).strip() if m else ""
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _area_points(body: str, header: str) -> int:
|
|
342
|
+
"""영역 만점 for an area — from the meta bullet / blockquote ('영역 만점: 50점',
|
|
343
|
+
'> 영역 만점 35점') or a ``(NN점)`` in the heading, else 0."""
|
|
344
|
+
m = re.search(r"영역\s*만점[은]?\**\s*[::]?\s*(\d+)\s*점", body)
|
|
345
|
+
if not m:
|
|
346
|
+
m = re.search(r"\((\d+)\s*점\)", header)
|
|
347
|
+
return int(m.group(1)) if m else 0
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _area_standards(body: str) -> str:
|
|
351
|
+
"""The 성취기준 codes cited by an area, concatenated ('[12인기02-04][12인기02-05]')."""
|
|
352
|
+
m = re.search(r"(?:교육과정\s*성취기준|성취기준\s*/\s*성취수준|성취기준)[^\n]*?[::]\s*(.+)", body)
|
|
353
|
+
src = m.group(1) if m else body
|
|
354
|
+
codes = re.findall(r"\[1\d[가-힣A-Za-z]*\d\d-\d\d\](?:\s*~\s*\[1\d[가-힣A-Za-z]*\d\d-\d\d\])?", src)
|
|
355
|
+
return "".join(codes)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _parse_area_rubric(header: str, title: str, body: str) -> Rubric:
|
|
359
|
+
"""One 수행영역 → a detailed :class:`Rubric`: sub-area ``평가요소 | 수행수준 | 배점``
|
|
360
|
+
tables become :class:`RubricSubArea` blocks of :class:`RubricItem` ladders, and the
|
|
361
|
+
[영역 공통] / inline 기본점수 rows become ``base_score`` / ``long_score``. Content is
|
|
362
|
+
read verbatim — no invented 배점, no summarisation."""
|
|
363
|
+
rub = Rubric(title=title, points=_area_points(body, header),
|
|
364
|
+
standards=_area_standards(body),
|
|
365
|
+
task=_strip_inline_md(_area_bullet(body, "수행과제")),
|
|
366
|
+
method=_strip_inline_md(_area_bullet(body, "평가 방법")),
|
|
367
|
+
student_notes=_strip_inline_md(_area_bullet(body, "학생 유의사항")),
|
|
368
|
+
criteria=_area_bullet(body, r"평가기준\(상/중/하\)"))
|
|
369
|
+
for pre, hdr, rows in _iter_md_tables(body):
|
|
370
|
+
hdr_txt = " ".join(hdr)
|
|
371
|
+
if "평가요소" in hdr_txt and "배점" in hdr_txt:
|
|
372
|
+
rub.subareas.append(_parse_subarea(pre, rows, rub))
|
|
373
|
+
elif "구분" in hdr_txt and "배점" in hdr_txt:
|
|
374
|
+
_absorb_base_row(rows, rub) # [영역 공통] table
|
|
375
|
+
_sync_legacy_rows(rub)
|
|
376
|
+
return rub
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _parse_subarea(preamble: str, rows: list[list[str]], rub: Rubric) -> RubricSubArea:
|
|
380
|
+
"""Group a sub-area table's rows into 평가요소 items (new item = non-empty 평가요소
|
|
381
|
+
cell, continuation rows = extra levels). A named sub-area's 소계 row is kept as a
|
|
382
|
+
trailing subtotal item (verbatim md — it marks the 가/나 boundary and the sub-area
|
|
383
|
+
만점); 기본점수 / 장기 미인정 rows (appearing inline in single-table areas) feed
|
|
384
|
+
``rub.base_score`` / ``long_score``."""
|
|
385
|
+
m = re.search(r"[세부 영역\s*([가-힣])\.\s*(.+?)\s*\((\d+)\s*점\)]", preamble or "")
|
|
386
|
+
sa = RubricSubArea(label=f"{m.group(1)}. {m.group(2)}" if m else "",
|
|
387
|
+
points=int(m.group(3)) if m else 0)
|
|
388
|
+
cur: RubricItem | None = None
|
|
389
|
+
for row in rows:
|
|
390
|
+
c0 = _strip_inline_md(row[0]) if len(row) > 0 else ""
|
|
391
|
+
c1 = _strip_inline_md(row[1]) if len(row) > 1 else ""
|
|
392
|
+
c2 = _strip_inline_md(row[2]) if len(row) > 2 else ""
|
|
393
|
+
if "소계" in c0:
|
|
394
|
+
if c2.isdigit():
|
|
395
|
+
sa.points = int(c2)
|
|
396
|
+
if sa.label: # keep 소계 as a subtotal row (가/나 marker)
|
|
397
|
+
sm = re.match(r"(.*?소계)\s*\((.*)\)\s*$", c0)
|
|
398
|
+
name, note = (sm.group(1), sm.group(2)) if sm else (c0, "")
|
|
399
|
+
sa.items.append(RubricItem(name=name, levels=[(note, c2)], subtotal=True))
|
|
400
|
+
cur = None
|
|
401
|
+
continue
|
|
402
|
+
if "장기 미인정" in c0 or "장기미인정" in c0:
|
|
403
|
+
if c2.isdigit():
|
|
404
|
+
rub.long_score = c2
|
|
405
|
+
continue
|
|
406
|
+
if "기본점수" in c0:
|
|
407
|
+
if c2.isdigit():
|
|
408
|
+
rub.base_score = c2
|
|
409
|
+
continue
|
|
410
|
+
if c0: # a new 평가요소
|
|
411
|
+
cur = RubricItem(name=c0)
|
|
412
|
+
sa.items.append(cur)
|
|
413
|
+
if cur is not None and (c1 or c2):
|
|
414
|
+
cur.levels.append((c1, c2))
|
|
415
|
+
return sa
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _absorb_base_row(rows: list[list[str]], rub: Rubric) -> None:
|
|
419
|
+
"""Read [영역 공통] ``구분 | 배점`` rows into ``base_score`` / ``long_score``. 장기
|
|
420
|
+
미인정 rows also carry '기본점수' (—1점), so match the more specific label first."""
|
|
421
|
+
for row in rows:
|
|
422
|
+
c0 = _strip_inline_md(row[0]) if row else ""
|
|
423
|
+
cN = _strip_inline_md(row[-1]) if row else ""
|
|
424
|
+
if not cN.isdigit():
|
|
425
|
+
continue
|
|
426
|
+
if "장기 미인정" in c0 or "장기미인정" in c0:
|
|
427
|
+
rub.long_score = cN
|
|
428
|
+
elif "기본점수" in c0:
|
|
429
|
+
rub.base_score = cN
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _sync_legacy_rows(rub: Rubric) -> None:
|
|
433
|
+
"""Mirror the detailed model into the legacy ``rows`` shape ([name, 'desc **s** / …']
|
|
434
|
+
+ a trailing 기본점수 summary) so legacy introspection / scorers keep working."""
|
|
435
|
+
rows: list[list[str]] = []
|
|
436
|
+
for it in rub.items:
|
|
437
|
+
ladder = " / ".join(f"{d} **{s}**" if s else d for d, s in it.levels)
|
|
438
|
+
rows.append([it.name, ladder])
|
|
439
|
+
if rub.base_score or rub.long_score:
|
|
440
|
+
rows.append([f"기본점수 **{rub.base_score}** · 장기 미인정 결석 **{rub.long_score}**", ""])
|
|
441
|
+
rub.rows = rows
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _parse_rubrics_detailed(s7: str) -> list[Rubric]:
|
|
445
|
+
"""Parse the current-format §7 (``#### ①`` areas) into detailed rubrics."""
|
|
446
|
+
heads = list(re.finditer(r"(?m)^####\s*([" + _CIRCLED + r"])\s*(.+?)\s*$", s7))
|
|
447
|
+
out: list[Rubric] = []
|
|
448
|
+
for k, m in enumerate(heads):
|
|
449
|
+
start = m.end()
|
|
450
|
+
end = heads[k + 1].start() if k + 1 < len(heads) else len(s7)
|
|
451
|
+
out.append(_parse_area_rubric(m.group(0), m.group(2).strip(), s7[start:end]))
|
|
452
|
+
return out
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _norm(text: str) -> str:
|
|
456
|
+
return " ".join(text.split()).strip()
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def expected_skeleton(content: EvalPlanContent, blank: str | Path | None = None) -> dict[str, int]:
|
|
460
|
+
"""Content-derived block counts for the quality scorer's C axis.
|
|
461
|
+
|
|
462
|
+
The 평가계획 target collapses §4가 into a **single** achievement table (the
|
|
463
|
+
review MD ships one unified 성취기준 table for both the 2015-개정 상/중/하 and the
|
|
464
|
+
2022-개정 A~E families), §4나 into a **single** 학기 단위 성취수준 table, keeps
|
|
465
|
+
one 성취율 table (the band-count that matches the MD), the 정기시험-stripped
|
|
466
|
+
반영비율 table, and one rubric table per 수행영역.
|
|
467
|
+
|
|
468
|
+
The 2022-개정 (2학년) form additionally ships a 「다. 영역별 최소 성취수준」 table
|
|
469
|
+
(공통과목 전용); the owner's form-prep spec deletes it, so the target keeps
|
|
470
|
+
``minlevel: 0``. ``blank`` is accepted for symmetry / future form detection but
|
|
471
|
+
the count target is content-derived and identical across 개정 (collapse-to-one),
|
|
472
|
+
so it is currently unused here."""
|
|
473
|
+
return {
|
|
474
|
+
"achievement": 1 if content.achievement_std else 0,
|
|
475
|
+
"level": 1 if content.levels else 0,
|
|
476
|
+
"minlevel": 0, # 최소 성취수준 (공통과목 전용) -> deleted
|
|
477
|
+
"rubric": len(content.rubrics),
|
|
478
|
+
"achieve_rate": 1 if content.achieve_rate else 0,
|
|
479
|
+
"ratio": 1 if content.ratio_header else 0,
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def plan_structural_ops(blank: str | Path, content: EvalPlanContent | None = None) -> dict[str, Any]:
|
|
484
|
+
"""Confident, gold-policy structural edits for a 평가계획 blank (no content
|
|
485
|
+
fills): delete the red/optional tables and the 정기시험 columns, keeping the
|
|
486
|
+
original formatting byte-for-byte. Targets are located by *classification*
|
|
487
|
+
(reusable across the form family), not fixed indices. Returns
|
|
488
|
+
``{"ops": [...], "transcript": [...]}`` — feed ``ops`` to
|
|
489
|
+
:func:`hwpx.table_patch.apply_table_ops`. Table deletes are emitted in
|
|
490
|
+
descending index order so earlier indices stay valid (column deletes do not
|
|
491
|
+
shift table indices).
|
|
492
|
+
|
|
493
|
+
Deferred (needs content mapping into the blank's rich cells, honest-defer):
|
|
494
|
+
achievement / 성취수준 / rubric block restructuring and cell text fills.
|
|
495
|
+
"""
|
|
496
|
+
from ..form_fill.classification import _classify, _tables
|
|
497
|
+
|
|
498
|
+
tabs = _tables(blank)
|
|
499
|
+
ops: list[dict[str, Any]] = []
|
|
500
|
+
transcript: list[str] = []
|
|
501
|
+
del_tables: list[int] = []
|
|
502
|
+
exp = expected_skeleton(content, blank) if content else None
|
|
503
|
+
by_kind: dict[str, list[int]] = {}
|
|
504
|
+
|
|
505
|
+
# Which 성취율 table to KEEP: the one whose grade-band count matches the review
|
|
506
|
+
# MD (2015-개정 keeps the 3단계 A~C; 2022-개정 keeps the 5단계 A~E). Content-
|
|
507
|
+
# driven, so it generalises across 개정 rather than hard-coding "delete 5단계".
|
|
508
|
+
want_bands = len(content.achieve_rate) if content and content.achieve_rate else None
|
|
509
|
+
keep_rate = _pick_achieve_rate(tabs, want_bands)
|
|
510
|
+
|
|
511
|
+
for i, t in enumerate(tabs):
|
|
512
|
+
kind = _classify(t)
|
|
513
|
+
by_kind.setdefault(kind, []).append(i)
|
|
514
|
+
if kind in ("seokcha", "submit", "notice_star"):
|
|
515
|
+
del_tables.append(i)
|
|
516
|
+
transcript.append(f"delete_table #{i} ({kind}) — red/optional, gold removes it")
|
|
517
|
+
elif kind == "minlevel":
|
|
518
|
+
del_tables.append(i)
|
|
519
|
+
transcript.append(f"delete_table #{i} (영역별 최소 성취수준) — 공통과목 전용, 삭제")
|
|
520
|
+
elif kind == "achieve_rate" and keep_rate is not None and i != keep_rate:
|
|
521
|
+
del_tables.append(i)
|
|
522
|
+
transcript.append(f"delete_table #{i} (성취율 variant) — keep only #{keep_rate} "
|
|
523
|
+
f"(matches MD {want_bands}단계)")
|
|
524
|
+
elif kind == "ratio":
|
|
525
|
+
cols = _regular_exam_cols(t)
|
|
526
|
+
if cols:
|
|
527
|
+
ops.append({"op": "delete_column", "tableIndex": i, "cols": cols})
|
|
528
|
+
transcript.append(f"delete_column #{i} cols {cols} (정기시험) — 100% 수행 subject")
|
|
529
|
+
|
|
530
|
+
# Surplus template-example tables: the blank ships >1 achievement / 성취수준 /
|
|
531
|
+
# rubric example (연주/비평 for 3학년, 6 국어 영역 for 2학년); the content needs
|
|
532
|
+
# `exp[kind]` of them. Delete the extras (keep the first) so the structure
|
|
533
|
+
# matches the content-derived skeleton.
|
|
534
|
+
detailed_rubrics = bool(content and content.rubrics and content.rubrics[0].detailed)
|
|
535
|
+
if exp:
|
|
536
|
+
for kind in ("achievement", "level", "rubric"):
|
|
537
|
+
idxs = by_kind.get(kind, [])
|
|
538
|
+
want = exp.get(kind, 0)
|
|
539
|
+
# For detailed rubrics the blank ships filled donor samples FIRST (인권·인포
|
|
540
|
+
# 그래픽) and clean empty templates LAST; the leading 인권 sample carries an
|
|
541
|
+
# extra 세부 항목 column the MD never uses, so keep the trailing clean
|
|
542
|
+
# templates instead (drop the surplus from the front).
|
|
543
|
+
surplus = idxs[:len(idxs) - want] if (kind == "rubric" and detailed_rubrics) else idxs[want:]
|
|
544
|
+
for i in surplus:
|
|
545
|
+
if i not in del_tables:
|
|
546
|
+
del_tables.append(i)
|
|
547
|
+
transcript.append(f"delete_table #{i} (surplus {kind} example) — "
|
|
548
|
+
f"content needs {want}, blank ships {len(idxs)}")
|
|
549
|
+
|
|
550
|
+
for i in sorted(set(del_tables), reverse=True):
|
|
551
|
+
ops.append({"op": "delete_table", "tableIndex": i})
|
|
552
|
+
# INDEX-SAFE ORDER: column deletes FIRST (they modify a table in place and do
|
|
553
|
+
# not shift table indices, so original indices are valid), THEN table deletes
|
|
554
|
+
# in descending index order (so each delete leaves lower indices unchanged).
|
|
555
|
+
# Emitting table deletes first would shift the ratio table under a later
|
|
556
|
+
# delete_column and silently corrupt the wrong table.
|
|
557
|
+
ops.sort(key=lambda o: (o["op"] == "delete_table", -o.get("tableIndex", 0)))
|
|
558
|
+
return {"ops": ops, "transcript": transcript,
|
|
559
|
+
"expected_skeleton": expected_skeleton(content) if content else None}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _rate_bands(table) -> int:
|
|
563
|
+
"""Number of distinct 성취도 grade bands (A..E) a 성취율 table declares — its
|
|
564
|
+
'N단계'. Read from the grade column so 40%-boundary variants don't inflate it."""
|
|
565
|
+
from hwpx.table_patch import build_grid, table_text
|
|
566
|
+
tb = table.bytes
|
|
567
|
+
grid, rep = build_grid(tb)
|
|
568
|
+
grades: set[str] = set()
|
|
569
|
+
for r in range(1, rep.row_count): # row 0 is the header
|
|
570
|
+
c = grid.get((r, rep.col_count - 1)) # grade is the right-most column
|
|
571
|
+
if c is None:
|
|
572
|
+
continue
|
|
573
|
+
g = table_text(tb[c.start:c.end]).strip()
|
|
574
|
+
if g in ("A", "B", "C", "D", "E"):
|
|
575
|
+
grades.add(g)
|
|
576
|
+
return len(grades)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _pick_achieve_rate(tabs, want_bands: int | None) -> int | None:
|
|
580
|
+
"""Index of the single 성취율 table to KEEP among the blank's sample variants.
|
|
581
|
+
|
|
582
|
+
Chooses the variant whose grade-band count matches the review MD's 성취도 band
|
|
583
|
+
count (``want_bands``) -- 3학년 (2015-개정) keeps the 3단계 A~C, 2학년 (2022-개정)
|
|
584
|
+
keeps the 5단계 A~E. Ties (or ``want_bands`` unknown) fall back to the first
|
|
585
|
+
plain N-band table without a 40%-보장지도 boundary note (the canonical 고정분할
|
|
586
|
+
점수 table), else the first 성취율 table. Returns ``None`` when there is at most
|
|
587
|
+
one 성취율 table (nothing to prune)."""
|
|
588
|
+
from ..form_fill.classification import _classify
|
|
589
|
+
rate = [(i, t) for i, t in enumerate(tabs) if _classify(t) == "achieve_rate"]
|
|
590
|
+
if len(rate) <= 1:
|
|
591
|
+
return None
|
|
592
|
+
if want_bands:
|
|
593
|
+
exact = [i for i, t in rate if _rate_bands(t) == want_bands]
|
|
594
|
+
# prefer the exact-band table WITHOUT the 40% 최소성취수준 보장지도 boundary
|
|
595
|
+
# note (that is the 공통과목 variant, not the canonical 고정분할점수 table)
|
|
596
|
+
plain = [i for i in exact if "보장지도" not in dict(rate)[i].text]
|
|
597
|
+
if plain:
|
|
598
|
+
return plain[0]
|
|
599
|
+
if exact:
|
|
600
|
+
return exact[0]
|
|
601
|
+
return rate[0][0]
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _regular_exam_cols(table) -> list[int]:
|
|
605
|
+
"""Logical column indices of the 정기시험 span in a 반영비율 table header."""
|
|
606
|
+
from hwpx.table_patch import build_grid, table_text
|
|
607
|
+
tb = table.bytes
|
|
608
|
+
grid, rep = build_grid(tb)
|
|
609
|
+
cols: list[int] = []
|
|
610
|
+
for col in range(rep.col_count):
|
|
611
|
+
c = grid.get((0, col))
|
|
612
|
+
if c and "정기시험" in table_text(tb[c.start:c.end]):
|
|
613
|
+
cols.append(col)
|
|
614
|
+
return cols
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# --------------------------------------------------------------------------- #
|
|
618
|
+
# Content fills (phase="all") -- byte-preserving cell/paragraph splices onto the
|
|
619
|
+
# restructured form. Every target is located by *classification* / grid geometry
|
|
620
|
+
# (reusable across the 평가계획 form family), never by a hard-coded 3학년 index; the
|
|
621
|
+
# structural deletions before them shift indices, so each fill re-locates its
|
|
622
|
+
# table fresh. All edits go through the byte-preserving primitives in
|
|
623
|
+
# :mod:`hwpx.table_patch` / :mod:`hwpx.patch` -- no table is ever regenerated.
|
|
624
|
+
# --------------------------------------------------------------------------- #
|
|
625
|
+
|
|
626
|
+
_STD_CODE = re.compile(r"\[1\d[가-힣A-Za-z]*\d\d-\d\d\]")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _rubric_indices(data: bytes):
|
|
630
|
+
from ..form_fill.classification import _classify, _tables
|
|
631
|
+
return [i for i, t in enumerate(_tables(data)) if _classify(t) == "rubric"]
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _classify_index(data: bytes, kind: str) -> int | None:
|
|
635
|
+
"""First table index whose scorer classification is *kind* (or None)."""
|
|
636
|
+
from ..form_fill.classification import _classify, _tables
|
|
637
|
+
for i, t in enumerate(_tables(data)):
|
|
638
|
+
if _classify(t) == kind:
|
|
639
|
+
return i
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _grid_of(data: bytes, table_index: int):
|
|
644
|
+
"""(section_path, table_bytes, grid, report) for a table by document index."""
|
|
645
|
+
from hwpx.table_patch import build_grid, iter_table_spans, section_parts
|
|
646
|
+
for sp, section in sorted(section_parts(data).items()):
|
|
647
|
+
spans = iter_table_spans(section)
|
|
648
|
+
if table_index < len(spans):
|
|
649
|
+
ts, te = spans[table_index]
|
|
650
|
+
tb = section[ts:te]
|
|
651
|
+
grid, rep = build_grid(tb)
|
|
652
|
+
return sp, tb, grid, rep
|
|
653
|
+
table_index -= len(spans)
|
|
654
|
+
raise IndexError("table index out of range across sections")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _cell_text(tb: bytes, grid, row: int, col: int) -> str:
|
|
658
|
+
from hwpx.table_patch import table_text
|
|
659
|
+
c = grid.get((row, col))
|
|
660
|
+
return table_text(tb[c.start:c.end]) if c else ""
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def fill_achievement(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
|
|
664
|
+
"""Reshape the 성취기준 table to ``len(achievement_std)`` clean per-standard blocks
|
|
665
|
+
and fill each from the review MD (byte-preserving).
|
|
666
|
+
|
|
667
|
+
Works for BOTH 개정 by driving the block height off the content (the review MD
|
|
668
|
+
row is ``[code, <level descriptors...>]``): 2015-개정 (3학년) ships a 4-column
|
|
669
|
+
음악 example (성취기준 | 평가준거 | 상/중/하 | 서술) with 3-row 상/중/하 blocks; 2022-
|
|
670
|
+
개정 (2학년) ships a 3-column example (성취기준 | A~E | 서술) with 5-row A~E blocks.
|
|
671
|
+
We (1) drop the extra 평가준거 column when the blank is 4-wide, (2) delete every
|
|
672
|
+
data row except one canonical block whose height == the MD's level count,
|
|
673
|
+
(3) clone that block to N standards, then (4) splice each standard's code into
|
|
674
|
+
the leader cell and its level descriptors down the right-most (서술) column. The
|
|
675
|
+
level-label column (상/중/하 or A~E) carries over verbatim from the clone. A
|
|
676
|
+
no-op if the content has no achievement rows or the blank's block height doesn't
|
|
677
|
+
match the MD's level count (honest-defer, reported)."""
|
|
678
|
+
from hwpx.table_patch import apply_table_ops, direct_table_cells
|
|
679
|
+
|
|
680
|
+
stds = content.achievement_std
|
|
681
|
+
report: dict[str, Any] = {"n": len(stds), "filled": 0, "skipped": []}
|
|
682
|
+
if not stds:
|
|
683
|
+
return data, report
|
|
684
|
+
# descriptors per standard = MD columns after the code (3학년: 상/중/하 = 3;
|
|
685
|
+
# 2학년: A~E = 5). This is the target block height.
|
|
686
|
+
bh = max((len(s) - 1 for s in stds), default=0)
|
|
687
|
+
if bh < 1:
|
|
688
|
+
report["skipped"].append("achievement rows have no level descriptors")
|
|
689
|
+
return data, report
|
|
690
|
+
|
|
691
|
+
ti = _classify_index(data, "achievement")
|
|
692
|
+
if ti is None:
|
|
693
|
+
report["skipped"].append("no achievement table found")
|
|
694
|
+
return data, report
|
|
695
|
+
|
|
696
|
+
_sp, tb, _grid, rep = _grid_of(data, ti)
|
|
697
|
+
# Drop the 평가준거 column (only present in the 4-col 2015-개정 blank): it is the
|
|
698
|
+
# logical column that a clean standard's leader spans (cs2 header) but the MD
|
|
699
|
+
# has no content for. After the drop both 개정 are: col0=leader, col1=level
|
|
700
|
+
# label, col2=서술.
|
|
701
|
+
ops: list[dict[str, Any]] = []
|
|
702
|
+
if rep.col_count == 4:
|
|
703
|
+
ops.append({"op": "delete_column", "table_index": ti, "cols": [1]})
|
|
704
|
+
res = apply_table_ops(data, ops) if ops else None
|
|
705
|
+
data2 = res.data if res is not None else data
|
|
706
|
+
if res is not None and not res.ok:
|
|
707
|
+
report["skipped"].append(f"delete 평가준거 column: {[s.reason for s in res.skipped]}")
|
|
708
|
+
return data, report
|
|
709
|
+
|
|
710
|
+
# keep header + one clean block whose height matches the MD level count
|
|
711
|
+
_sp, tb, _grid, rep = _grid_of(data2, ti)
|
|
712
|
+
desc_col = rep.col_count - 1 # right-most (서술) column
|
|
713
|
+
leaders = sorted((c for c in direct_table_cells(tb) if c.col == 0 and c.row_span == bh),
|
|
714
|
+
key=lambda c: c.row)
|
|
715
|
+
if not leaders:
|
|
716
|
+
report["skipped"].append(
|
|
717
|
+
f"no clean {bh}-row block to seed from (blank block heights "
|
|
718
|
+
f"{sorted({c.row_span for c in direct_table_cells(tb) if c.col == 0 and c.row_span > 1})})")
|
|
719
|
+
return data, report
|
|
720
|
+
first = leaders[0]
|
|
721
|
+
keep = set(range(first.row, first.row + bh)) | {0}
|
|
722
|
+
delrows = sorted((r for r in range(rep.row_count) if r not in keep), reverse=True)
|
|
723
|
+
if delrows:
|
|
724
|
+
res = apply_table_ops(data2, [{"op": "delete_row", "table_index": ti, "rows": delrows}])
|
|
725
|
+
if not res.ok:
|
|
726
|
+
report["skipped"].append(f"prune to one block: {[s.reason for s in res.skipped]}")
|
|
727
|
+
return data, report
|
|
728
|
+
data2 = res.data
|
|
729
|
+
|
|
730
|
+
# grow to N blocks by cloning the single block (now rows 1..bh)
|
|
731
|
+
n = len(stds)
|
|
732
|
+
if n > 1:
|
|
733
|
+
res = apply_table_ops(data2, [{"op": "insert_block_by_clone", "table_index": ti,
|
|
734
|
+
"ref_rows": [1, bh], "count": n - 1}])
|
|
735
|
+
if not res.ok:
|
|
736
|
+
report["skipped"].append(f"clone to {n} blocks: {[s.reason for s in res.skipped]}")
|
|
737
|
+
return data, report
|
|
738
|
+
data2 = res.data
|
|
739
|
+
|
|
740
|
+
# fill each block: leader (rs=bh, col0) = code+text; 서술 col rows = descriptors
|
|
741
|
+
cells: list[dict[str, Any]] = []
|
|
742
|
+
for i, std in enumerate(stds):
|
|
743
|
+
lr = 1 + bh * i
|
|
744
|
+
cells.append({"table_index": ti, "row": lr, "col": 0, "text": std[0], "max_lines": 6})
|
|
745
|
+
for k, desc in enumerate(std[1:1 + bh]):
|
|
746
|
+
cells.append({"table_index": ti, "row": lr + k, "col": desc_col, "text": desc, "max_lines": 5})
|
|
747
|
+
from hwpx.table_patch import fill_cells
|
|
748
|
+
fr = fill_cells(data2, cells)
|
|
749
|
+
report["filled"] = len(fr.applied)
|
|
750
|
+
report["skipped"].extend(s.reason for s in fr.skipped)
|
|
751
|
+
return fr.data, report
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
_GRADE_ROW = re.compile(r"^[A-E]$")
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def fill_levels(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
|
|
758
|
+
"""Fill the 성취수준 table's descriptor column from the review MD levels,
|
|
759
|
+
replacing the blank's sample. Handles BOTH review shapes:
|
|
760
|
+
|
|
761
|
+
* 2015-개정 (3학년): ``levels`` is per-area ``[[영역, A, B, C], ...]`` → the first
|
|
762
|
+
area's A/B/C descriptors fill rows 1-3 of a ``성취수준 | 일반적 특성`` grid.
|
|
763
|
+
* 2022-개정 (2학년): ``levels`` is grade-major ``[[A, 서술], [B, 서술], ...]`` → each
|
|
764
|
+
grade's descriptor fills the matching A/B/C/D/E row of the ``학기 단위 성취수준``
|
|
765
|
+
grid (matched by the row's grade label, so a shape mismatch never corrupts it).
|
|
766
|
+
|
|
767
|
+
Only descriptor cells are touched (byte-preserving)."""
|
|
768
|
+
from hwpx.table_patch import fill_cells, table_text
|
|
769
|
+
|
|
770
|
+
report: dict[str, Any] = {"filled": 0, "skipped": []}
|
|
771
|
+
if not content.levels:
|
|
772
|
+
return data, report
|
|
773
|
+
ti = _classify_index(data, "level")
|
|
774
|
+
if ti is None:
|
|
775
|
+
report["skipped"].append("no level table found")
|
|
776
|
+
return data, report
|
|
777
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
778
|
+
|
|
779
|
+
grade_major = all(len(r) >= 2 and _GRADE_ROW.match(r[0].strip()) for r in content.levels)
|
|
780
|
+
desc_col = rep.col_count - 1
|
|
781
|
+
cells = []
|
|
782
|
+
if grade_major:
|
|
783
|
+
# map grade letter -> its descriptor; fill each table row by its grade label
|
|
784
|
+
by_grade = {r[0].strip(): r[-1] for r in content.levels}
|
|
785
|
+
for row in range(1, rep.row_count):
|
|
786
|
+
c0 = grid.get((row, 0))
|
|
787
|
+
label = table_text(tb[c0.start:c0.end]).strip() if c0 else ""
|
|
788
|
+
if label in by_grade and grid.get((row, desc_col)):
|
|
789
|
+
cells.append({"table_index": ti, "row": row, "col": desc_col,
|
|
790
|
+
"text": by_grade[label], "max_lines": 6})
|
|
791
|
+
else:
|
|
792
|
+
abc = content.levels[0][1:1 + (rep.row_count - 1)] if len(content.levels[0]) >= 2 else []
|
|
793
|
+
for k, desc in enumerate(abc):
|
|
794
|
+
r = 1 + k
|
|
795
|
+
if grid.get((r, desc_col)):
|
|
796
|
+
cells.append({"table_index": ti, "row": r, "col": desc_col, "text": desc, "max_lines": 5})
|
|
797
|
+
fr = fill_cells(data, cells)
|
|
798
|
+
report["filled"] = len(fr.applied)
|
|
799
|
+
report["skipped"].extend(s.reason for s in fr.skipped)
|
|
800
|
+
return fr.data, report
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _batjeom_line(item_label: str, criteria: str) -> str:
|
|
804
|
+
"""Compose one 채점기준 cell line: 'label: level 15 / level 12 / ...' from the
|
|
805
|
+
MD's bolded-배점 criteria string ('명확히 정의·트리 표현 **15** / ...')."""
|
|
806
|
+
clean = criteria.replace("**", "").strip()
|
|
807
|
+
return f"{item_label}: {clean}" if clean else item_label
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _top_batjeom(criteria: str) -> str:
|
|
811
|
+
m = re.findall(r"\*\*(\d+)\*\*", criteria)
|
|
812
|
+
return m[0] if m else ""
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _base_scores(rows: list[list[str]]) -> tuple[str, str, str, str]:
|
|
816
|
+
"""Pull 기본점수 / 장기 미인정 labels+scores from a rubric's trailing summary
|
|
817
|
+
row ('기본점수(백지·미참여) **14** · 장기 미인정 결석 **13**').
|
|
818
|
+
|
|
819
|
+
The label itself can contain a middle-dot ('백지·미참여'), so split on the
|
|
820
|
+
score-bearing clause boundary ('**N** ·'), not on every '·'."""
|
|
821
|
+
base_label, base_score, long_label, long_score = "기본점수", "", "장기 미인정 결석", ""
|
|
822
|
+
for row in rows:
|
|
823
|
+
cell = row[0]
|
|
824
|
+
if "기본점수" not in cell:
|
|
825
|
+
continue
|
|
826
|
+
# boundary: the ' · ' that follows the first '**score**'
|
|
827
|
+
m = re.match(r"(.*?\*\*\d+\*\*)\s*·\s*(.*)", cell)
|
|
828
|
+
if m:
|
|
829
|
+
first, second = m.group(1), m.group(2)
|
|
830
|
+
else:
|
|
831
|
+
first, second = cell, ""
|
|
832
|
+
b = re.findall(r"\*\*(\d+)\*\*", first)
|
|
833
|
+
long = re.findall(r"\*\*(\d+)\*\*", second)
|
|
834
|
+
base_label = re.sub(r"\s*\*\*\d+\*\*", "", first).strip() or base_label
|
|
835
|
+
if second:
|
|
836
|
+
long_label = re.sub(r"\s*\*\*\d+\*\*", "", second).strip() or long_label
|
|
837
|
+
if b:
|
|
838
|
+
base_score = b[0]
|
|
839
|
+
if long:
|
|
840
|
+
long_score = long[0]
|
|
841
|
+
return base_label, base_score, long_label, long_score
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _parse_level_ladder(criteria: str) -> list[tuple[str, str]]:
|
|
845
|
+
"""Parse one 평가항목's 채점기준 ladder ('전처리·완비 **30** / 대부분 **24** / …')
|
|
846
|
+
into ``[(descriptor, 배점), …]`` — one tuple per level, top score first. Each
|
|
847
|
+
level is a ``' / '``-separated clause whose ``**\\d+**`` marker is the 배점 and the
|
|
848
|
+
remaining text the descriptor. Clauses without a score marker are dropped (a level
|
|
849
|
+
must carry a 배점 to be faithfully placeable). This is the flat-ladder counterpart of
|
|
850
|
+
the 3학년 (2015-개정) ``_batjeom_line`` / ``_top_batjeom`` parse, kept as an explicit
|
|
851
|
+
(desc, score) list so the 2022 reshape can lay one grid row per level."""
|
|
852
|
+
out: list[tuple[str, str]] = []
|
|
853
|
+
for clause in re.split(r"\s*/\s*", (criteria or "").strip()):
|
|
854
|
+
m = re.search(r"\*\*(\d+)\*\*", clause)
|
|
855
|
+
if not m:
|
|
856
|
+
continue
|
|
857
|
+
desc = re.sub(r"\s*\*\*\d+\*\*\s*", "", clause).strip()
|
|
858
|
+
out.append((desc, m.group(1)))
|
|
859
|
+
return out
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _pf_bounds(tb: bytes, grid, rep) -> tuple[int | None, int | None]:
|
|
863
|
+
"""(평가요소 header row, 기본점수 row) of a 2022 rubric — the 채점기준 ladder region
|
|
864
|
+
is the physical rows strictly between them."""
|
|
865
|
+
from hwpx.table_patch import table_text
|
|
866
|
+
ph = base = None
|
|
867
|
+
for r in range(rep.row_count):
|
|
868
|
+
c0 = grid.get((r, 0))
|
|
869
|
+
if c0 is None:
|
|
870
|
+
continue
|
|
871
|
+
t0 = table_text(tb[c0.start:c0.end])
|
|
872
|
+
if t0.replace(" ", "").strip() == "평가요소":
|
|
873
|
+
ph = r
|
|
874
|
+
if base is None and "기본점수" in t0:
|
|
875
|
+
base = r
|
|
876
|
+
return ph, base
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _pf_groups(tb: bytes, ph: int, base: int) -> list[tuple[int, int]]:
|
|
880
|
+
"""The col0 평가요소 leader groups (row, rowSpan) strictly inside ``(ph, base)`` —
|
|
881
|
+
one per 평가항목, in document order. Deduped by byte span (a merged leader is one
|
|
882
|
+
group even though it covers several logical rows)."""
|
|
883
|
+
from hwpx.table_patch import direct_table_cells
|
|
884
|
+
seen: set[tuple[int, int]] = set()
|
|
885
|
+
groups: list[tuple[int, int]] = []
|
|
886
|
+
for c in sorted(direct_table_cells(tb), key=lambda c: c.row):
|
|
887
|
+
if c.col == 0 and ph < c.row < base and (c.start, c.end) not in seen:
|
|
888
|
+
seen.add((c.start, c.end))
|
|
889
|
+
groups.append((c.row, c.row_span))
|
|
890
|
+
return groups
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _pf_desc_col(tb: bytes, ph: int, base: int, bat_col: int) -> int:
|
|
894
|
+
"""The 수행수준(채점기준) descriptor column — the widest (max colSpan) data cell in
|
|
895
|
+
the ladder region. A narrower col to its left (col 1) is the optional 세부 항목
|
|
896
|
+
(sub-item) column, present in the 45점 45×9 rubric but not the 30/25점 grids."""
|
|
897
|
+
from hwpx.table_patch import direct_table_cells
|
|
898
|
+
best, best_span = None, 0
|
|
899
|
+
for c in direct_table_cells(tb):
|
|
900
|
+
if ph < c.row < base and 1 <= c.col < bat_col and c.col_span > best_span:
|
|
901
|
+
best_span, best = c.col_span, c.col
|
|
902
|
+
return best if best is not None else 1
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _pf_subitems(tb: bytes, r0: int, h: int) -> list[tuple[int, int]]:
|
|
906
|
+
"""col1 세부 항목 sub-blocks (row, rowSpan) inside a 평가요소 group [r0, r0+h)."""
|
|
907
|
+
from hwpx.table_patch import direct_table_cells
|
|
908
|
+
seen: set[tuple[int, int]] = set()
|
|
909
|
+
subs: list[tuple[int, int]] = []
|
|
910
|
+
for c in sorted(direct_table_cells(tb), key=lambda c: c.row):
|
|
911
|
+
if c.col == 1 and r0 <= c.row < r0 + h and (c.start, c.end) not in seen:
|
|
912
|
+
seen.add((c.start, c.end))
|
|
913
|
+
subs.append((c.row, c.row_span))
|
|
914
|
+
return subs
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _pf_clean_level_row(grid, r: int, desc_col: int, bat_col: int) -> bool:
|
|
918
|
+
"""A row that OWNS both a descriptor cell (at ``desc_col``) and a 배점 cell (at
|
|
919
|
+
``bat_col``) starting at row ``r`` with rowSpan==1 — a self-contained level row
|
|
920
|
+
safe to keep/clone. A row whose 배점 is covered by a vertical merge from above
|
|
921
|
+
(rowSpan>1) is NOT clean: keeping it while dropping its merge-top would hole the
|
|
922
|
+
grid, so those are the rows we delete."""
|
|
923
|
+
d = grid.get((r, desc_col))
|
|
924
|
+
b = grid.get((r, bat_col))
|
|
925
|
+
return bool(d and b and d.row == r and b.row == r and d.row_span == 1 and b.row_span == 1)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _reduce_to_first_subitem(data: bytes, ti: int, r0: int, h: int) -> tuple[bytes, int, list[str]]:
|
|
929
|
+
"""If a 평가요소 group [r0, r0+h) subdivides into >1 col1 세부 항목 block (the 45점
|
|
930
|
+
rubric does), delete every block after the first so the group maps to the MD's ONE
|
|
931
|
+
flat 평가항목 ladder. Byte-preserving (delete_row only); returns (data, new_height,
|
|
932
|
+
skip_reasons). The MD carries no sub-item decomposition, so keeping every sub-block
|
|
933
|
+
would have no faithful source — collapsing to the first block is the honest map."""
|
|
934
|
+
from hwpx.table_patch import apply_table_ops
|
|
935
|
+
_sp, tb, _grid, _rep = _grid_of(data, ti)
|
|
936
|
+
subs = _pf_subitems(tb, r0, h)
|
|
937
|
+
if len(subs) <= 1:
|
|
938
|
+
return data, h, []
|
|
939
|
+
_first_r, first_h = subs[0]
|
|
940
|
+
del_rows: list[int] = []
|
|
941
|
+
for sr, sh in subs[1:]:
|
|
942
|
+
del_rows.extend(range(sr, sr + sh))
|
|
943
|
+
res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti, "rows": sorted(set(del_rows), reverse=True)}])
|
|
944
|
+
if not res.ok:
|
|
945
|
+
return data, h, [f"drop surplus 세부 항목 blocks: {[s.reason for s in res.skipped]}"]
|
|
946
|
+
return res.data, first_h, []
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _normalize_ladder_group(data: bytes, ti: int, r0: int, h: int, m: int,
|
|
950
|
+
desc_col: int, bat_col: int) -> tuple[bytes, list[str]]:
|
|
951
|
+
"""Reshape a 평가요소 group [r0, r0+h) to exactly ``m`` clean level rows (byte-
|
|
952
|
+
preserving delete_row / insert_row_by_clone). Keeps the leader row r0 plus the
|
|
953
|
+
first (m-1) clean interior level rows, deletes the rest, and clones a clean interior
|
|
954
|
+
row up when the group is short. Fail-closed: returns (data unchanged, [reason]) if
|
|
955
|
+
the shape can't be reached cleanly (e.g. too few clean rows to shrink to m)."""
|
|
956
|
+
from hwpx.table_patch import apply_table_ops
|
|
957
|
+
_sp, _tb, grid, _rep = _grid_of(data, ti)
|
|
958
|
+
interior = list(range(r0 + 1, r0 + h))
|
|
959
|
+
clean = [r for r in interior if _pf_clean_level_row(grid, r, desc_col, bat_col)]
|
|
960
|
+
if not _pf_clean_level_row(grid, r0, desc_col, bat_col):
|
|
961
|
+
return data, [f"group r0={r0}: leader row is not a clean level row"]
|
|
962
|
+
keep = clean[: m - 1]
|
|
963
|
+
del_rows = sorted(set(interior) - set(keep), reverse=True)
|
|
964
|
+
d = data
|
|
965
|
+
if del_rows:
|
|
966
|
+
res = apply_table_ops(d, [{"op": "delete_row", "table_index": ti, "rows": del_rows}])
|
|
967
|
+
if not res.ok:
|
|
968
|
+
return data, [f"group r0={r0}: shrink {del_rows}: {[s.reason for s in res.skipped]}"]
|
|
969
|
+
d = res.data
|
|
970
|
+
have = 1 + len(keep)
|
|
971
|
+
if have < m:
|
|
972
|
+
# clone the group's clean interior template (now at r0+1) up to m rows
|
|
973
|
+
res = apply_table_ops(d, [{"op": "insert_row_by_clone", "table_index": ti,
|
|
974
|
+
"ref_row": r0 + 1, "count": m - have}])
|
|
975
|
+
if not res.ok:
|
|
976
|
+
return data, [f"group r0={r0}: grow: {[s.reason for s in res.skipped]}"]
|
|
977
|
+
d = res.data
|
|
978
|
+
elif have > m:
|
|
979
|
+
return data, [f"group r0={r0}: only {len(clean)} clean rows, cannot shrink to {m}"]
|
|
980
|
+
return d, []
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _fill_rubric_2022_ladder(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, bool, list[str]]:
|
|
984
|
+
"""Reshape + fill ONE 2022-개정 rubric's 수행수준 채점기준 ladder from the review MD,
|
|
985
|
+
byte-preserving. Each MD 평가항목 carries a flat level ladder ('desc **30** / desc
|
|
986
|
+
**24** / …'); the blank decomposes each 평가요소 into sample-specific sub-rows. We
|
|
987
|
+
map the blank's col0 평가요소 groups 1:1 to the MD 평가항목 (document order),
|
|
988
|
+
normalize each group to the MD ladder's level count via delete_row /
|
|
989
|
+
insert_row_by_clone (reshape ≠ regeneration — the original cells' formatting is
|
|
990
|
+
carried byte-for-byte), then splice each level's descriptor + 배점 in; the optional
|
|
991
|
+
세부 항목 column is collapsed to the first sub-block and blanked (no MD source).
|
|
992
|
+
|
|
993
|
+
Returns ``(data, filled, notes)``. ``filled`` is True only when EVERY group was
|
|
994
|
+
reconciled and written — a rubric whose group count ≠ MD item count, whose ladder
|
|
995
|
+
won't parse, or whose reshape fails is left byte-identical and reported (fail-closed
|
|
996
|
+
/ honest-defer), so the caller keeps its NEEDS_REVIEW note."""
|
|
997
|
+
from hwpx.table_patch import fill_cells
|
|
998
|
+
|
|
999
|
+
items = [row for row in rub.rows if not row[0].startswith("기본점수")]
|
|
1000
|
+
ladders = [_parse_level_ladder(it[1]) for it in items]
|
|
1001
|
+
if not items or any(not L for L in ladders):
|
|
1002
|
+
return data, False, [f"rubric ti={ti}: 채점기준 ladder unparseable for some 평가항목"]
|
|
1003
|
+
|
|
1004
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1005
|
+
ph, base = _pf_bounds(tb, grid, rep)
|
|
1006
|
+
if ph is None or base is None:
|
|
1007
|
+
return data, False, [f"rubric ti={ti}: no 평가요소/기본점수 bounds for 채점기준 ladder"]
|
|
1008
|
+
bat_col = rep.col_count - 1
|
|
1009
|
+
desc_col = _pf_desc_col(tb, ph, base, bat_col)
|
|
1010
|
+
has_sub = desc_col > 1
|
|
1011
|
+
groups = _pf_groups(tb, ph, base)
|
|
1012
|
+
if len(groups) != len(items):
|
|
1013
|
+
return data, False, [f"rubric ti={ti}: {len(groups)} 평가요소 groups != {len(items)} MD 평가항목"]
|
|
1014
|
+
|
|
1015
|
+
# RESHAPE each group to its ladder's level count. Process LAST→FIRST so earlier
|
|
1016
|
+
# (smaller-index) groups stay valid as later ones change row counts; re-locate the
|
|
1017
|
+
# groups fresh before every edit (indices shift).
|
|
1018
|
+
d = data
|
|
1019
|
+
for gi in range(len(groups) - 1, -1, -1):
|
|
1020
|
+
m = len(ladders[gi])
|
|
1021
|
+
_sp, tb, grid, rep = _grid_of(d, ti)
|
|
1022
|
+
ph2, base2 = _pf_bounds(tb, grid, rep)
|
|
1023
|
+
gg = _pf_groups(tb, cast(int, ph2), cast(int, base2))
|
|
1024
|
+
r0, h = gg[gi]
|
|
1025
|
+
if has_sub:
|
|
1026
|
+
d, _first_h, sub_sk = _reduce_to_first_subitem(d, ti, r0, h)
|
|
1027
|
+
if sub_sk:
|
|
1028
|
+
return data, False, [f"rubric ti={ti} group {gi}: {sub_sk[0]}"]
|
|
1029
|
+
_sp, tb, grid, rep = _grid_of(d, ti)
|
|
1030
|
+
ph2, base2 = _pf_bounds(tb, grid, rep)
|
|
1031
|
+
r0, h = _pf_groups(tb, cast(int, ph2), cast(int, base2))[gi]
|
|
1032
|
+
d, norm_sk = _normalize_ladder_group(d, ti, r0, h, m, desc_col, bat_col)
|
|
1033
|
+
if norm_sk:
|
|
1034
|
+
return data, False, [f"rubric ti={ti} group {gi}: {norm_sk[0]}"]
|
|
1035
|
+
|
|
1036
|
+
# FILL each normalized level row: descriptor + 배점, and blank the 세부 항목 cell.
|
|
1037
|
+
_sp, tb, grid, rep = _grid_of(d, ti)
|
|
1038
|
+
ph2, base2 = _pf_bounds(tb, grid, rep)
|
|
1039
|
+
gg = _pf_groups(tb, cast(int, ph2), cast(int, base2))
|
|
1040
|
+
cells: list[dict[str, Any]] = []
|
|
1041
|
+
for gi, (r0, _h) in enumerate(gg):
|
|
1042
|
+
for k, (desc, score) in enumerate(ladders[gi]):
|
|
1043
|
+
r = r0 + k
|
|
1044
|
+
dc = grid.get((r, desc_col))
|
|
1045
|
+
if dc is not None:
|
|
1046
|
+
cells.append({"table_index": ti, "row": dc.row, "col": dc.col, "text": desc, "max_lines": 4})
|
|
1047
|
+
bc = grid.get((r, bat_col))
|
|
1048
|
+
if bc is not None:
|
|
1049
|
+
cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
|
|
1050
|
+
if has_sub:
|
|
1051
|
+
sc = grid.get((r0, 1))
|
|
1052
|
+
if sc is not None and sc.col == 1:
|
|
1053
|
+
cells.append({"table_index": ti, "row": sc.row, "col": 1, "text": "", "max_lines": 1})
|
|
1054
|
+
fr = fill_cells(d, cells)
|
|
1055
|
+
return fr.data, True, [s.reason for s in fr.skipped]
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _rubric_is_2022(data: bytes, ti: int) -> bool:
|
|
1059
|
+
"""A 2022-개정 rubric table leads its first row with '평가 영역명' (the 2015-개정
|
|
1060
|
+
one leads with '교육과정성취기준')."""
|
|
1061
|
+
from hwpx.table_patch import table_text
|
|
1062
|
+
_sp, tb, grid, _rep = _grid_of(data, ti)
|
|
1063
|
+
c0 = grid.get((0, 0))
|
|
1064
|
+
return bool(c0) and table_text(tb[c0.start:c0.end]).strip().startswith("평가 영역명")
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _item_label(row0: str) -> str:
|
|
1068
|
+
"""Clean a review rubric item's label cell for splicing (drop markdown emphasis)."""
|
|
1069
|
+
return re.sub(r"\*\*(\d+)\*\*", r"\1", row0).replace("**", "").strip()
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _std_level_map(achievement_std: list[list[str]]) -> dict[str, list[str]]:
|
|
1073
|
+
"""Map each 성취기준 code -> its ``[A, B, C, D, E]`` 성취수준 descriptors from the
|
|
1074
|
+
review MD's §4가 table (row = ``[code+진술, A, B, C, D, E]``). Codes with fewer
|
|
1075
|
+
than five descriptor columns are skipped (honest: no partial level fill)."""
|
|
1076
|
+
out: dict[str, list[str]] = {}
|
|
1077
|
+
for row in achievement_std:
|
|
1078
|
+
if not row:
|
|
1079
|
+
continue
|
|
1080
|
+
m = _STD_CODE.search(row[0])
|
|
1081
|
+
if m is None:
|
|
1082
|
+
continue
|
|
1083
|
+
levels = [c.strip() for c in row[1:6]]
|
|
1084
|
+
if len(levels) == 5 and all(levels):
|
|
1085
|
+
out[m.group(0)] = levels
|
|
1086
|
+
return out
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _fill_rubric_ae_levels(
|
|
1090
|
+
ti: int, tb: bytes, grid, rep, rub: Rubric, std_levels: dict[str, list[str]],
|
|
1091
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
1092
|
+
"""Address the A~E descriptor cells of a 2022-개정 rubric's 성취기준별 성취수준 block
|
|
1093
|
+
with the review MD's real 성취수준 descriptors for the rubric's PRIMARY standard.
|
|
1094
|
+
|
|
1095
|
+
A block is a run of rows whose grade-label cell (the col immediately left of the
|
|
1096
|
+
wide 서술 cell) holds 'A'..'E'; the blank ships the descriptors as a foreign sample
|
|
1097
|
+
(통합사회/미술 프로젝트 prose). The blank can ship SEVERAL A~E blocks under one rubric
|
|
1098
|
+
(one per sample standard). We locate every block after the '성취기준' label row and
|
|
1099
|
+
fill the *i*-th block from the *i*-th referenced standard's review 성취수준 (the
|
|
1100
|
+
PRIMARY -- first -- standard drives block 0), splicing that standard's A→col+1 …
|
|
1101
|
+
E→col+5 descriptor into each grade row's 서술 cell. Standard codes come from
|
|
1102
|
+
``rub.standards`` (a ``~`` range contributes only its leading code); a block with
|
|
1103
|
+
no matching standard is left untouched (honest -- no fabricated mapping).
|
|
1104
|
+
|
|
1105
|
+
Returns ``(cells, skipped)``: cell specs for :func:`fill_cells` and fail-closed
|
|
1106
|
+
skip reasons (no A~E block, or a standard has no MD 성취수준 row). Only the 서술
|
|
1107
|
+
descriptor cell of each grade row is addressed -- the grade label and the span-5
|
|
1108
|
+
성취기준 leader are left untouched."""
|
|
1109
|
+
from hwpx.table_patch import direct_table_cells, table_text
|
|
1110
|
+
|
|
1111
|
+
codes = _STD_CODE.findall(rub.standards or "")
|
|
1112
|
+
if not codes:
|
|
1113
|
+
return [], [f"rubric ti={ti}: no standard code in {rub.standards!r} for A~E levels"]
|
|
1114
|
+
|
|
1115
|
+
# '성취기준' label row bounds the block start; the next '평가 방법' row bounds its end.
|
|
1116
|
+
std_label_row = next(
|
|
1117
|
+
(r for r in range(rep.row_count)
|
|
1118
|
+
if (c0 := grid.get((r, 0))) is not None
|
|
1119
|
+
and table_text(tb[c0.start:c0.end]).replace(" ", "").strip() == "성취기준"),
|
|
1120
|
+
None,
|
|
1121
|
+
)
|
|
1122
|
+
if std_label_row is None:
|
|
1123
|
+
return [], [f"rubric ti={ti}: no '성취기준' label row to anchor A~E block"]
|
|
1124
|
+
method_row = next(
|
|
1125
|
+
(r for r in range(std_label_row + 1, rep.row_count)
|
|
1126
|
+
if (c0 := grid.get((r, 0))) is not None
|
|
1127
|
+
and "평가" in table_text(tb[c0.start:c0.end]) and "방법" in table_text(tb[c0.start:c0.end])),
|
|
1128
|
+
rep.row_count,
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
# A grade cell is a direct 1x1 cell holding exactly one of 'A'..'E' in the block;
|
|
1132
|
+
# its 서술 descriptor is the direct cell in the same row starting at grade.col+1.
|
|
1133
|
+
grade_rows: list[tuple[str, int, int]] = [] # (grade, row, grade_col)
|
|
1134
|
+
for c in sorted(direct_table_cells(tb), key=lambda c: c.row):
|
|
1135
|
+
if not (std_label_row < c.row < method_row):
|
|
1136
|
+
continue
|
|
1137
|
+
t = table_text(tb[c.start:c.end]).strip()
|
|
1138
|
+
if t in ("A", "B", "C", "D", "E") and c.col_span == 1:
|
|
1139
|
+
grade_rows.append((t, c.row, c.col))
|
|
1140
|
+
if not grade_rows:
|
|
1141
|
+
return [], [f"rubric ti={ti}: no A~E grade rows in 성취기준별 성취수준 block"]
|
|
1142
|
+
|
|
1143
|
+
# split into contiguous A→…→E blocks (a new 'A' starts a new block); the blank can
|
|
1144
|
+
# ship several sample blocks under one rubric (one per sample standard).
|
|
1145
|
+
blocks: list[list[tuple[str, int, int]]] = []
|
|
1146
|
+
for g, r, col in grade_rows:
|
|
1147
|
+
if g == "A" or not blocks:
|
|
1148
|
+
blocks.append([])
|
|
1149
|
+
blocks[-1].append((g, r, col))
|
|
1150
|
+
|
|
1151
|
+
cells: list[dict[str, Any]] = []
|
|
1152
|
+
skipped: list[str] = []
|
|
1153
|
+
for bi, block in enumerate(blocks):
|
|
1154
|
+
# map the i-th block to the i-th referenced standard; the primary standard
|
|
1155
|
+
# drives block 0. A block past the referenced standards is left untouched.
|
|
1156
|
+
if bi >= len(codes):
|
|
1157
|
+
skipped.append(f"rubric ti={ti}: A~E block {bi} has no {bi}-th referenced standard -- left as-is")
|
|
1158
|
+
continue
|
|
1159
|
+
levels = std_levels.get(codes[bi])
|
|
1160
|
+
if levels is None:
|
|
1161
|
+
skipped.append(f"rubric ti={ti}: standard {codes[bi]} (block {bi}) not in review 성취수준 map")
|
|
1162
|
+
continue
|
|
1163
|
+
lvl_by_grade = dict(zip("ABCDE", levels))
|
|
1164
|
+
for g, r, gcol in block:
|
|
1165
|
+
desc = grid.get((r, gcol + 1))
|
|
1166
|
+
if desc is None or desc.col <= gcol:
|
|
1167
|
+
skipped.append(f"rubric ti={ti}: grade {g} row {r} has no 서술 cell right of col {gcol}")
|
|
1168
|
+
continue
|
|
1169
|
+
text = lvl_by_grade.get(g)
|
|
1170
|
+
if not text:
|
|
1171
|
+
continue
|
|
1172
|
+
# address the 서술 cell by its own top-left logical position (merge-safe).
|
|
1173
|
+
cells.append({"table_index": ti, "row": desc.row, "col": desc.col,
|
|
1174
|
+
"text": text, "max_lines": 4})
|
|
1175
|
+
return cells, skipped
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _locate_rubric_2022_rows(tb: bytes, grid: Any, rep: Any) -> tuple[int | None, int | None, int | None, int | None]:
|
|
1179
|
+
"""Locate the 수행과제 / 성취기준-label / 평가요소 header / 기본점수 rows."""
|
|
1180
|
+
|
|
1181
|
+
from hwpx.table_patch import table_text
|
|
1182
|
+
|
|
1183
|
+
task_row = std_label_row = ph = base = None
|
|
1184
|
+
for r in range(rep.row_count):
|
|
1185
|
+
c0 = grid.get((r, 0))
|
|
1186
|
+
if c0 is None:
|
|
1187
|
+
continue
|
|
1188
|
+
t0 = table_text(tb[c0.start:c0.end]).strip()
|
|
1189
|
+
t0c = t0.replace(" ", "")
|
|
1190
|
+
if task_row is None and t0c == "수행과제":
|
|
1191
|
+
task_row = r
|
|
1192
|
+
if std_label_row is None and t0c == "성취기준":
|
|
1193
|
+
std_label_row = r
|
|
1194
|
+
if t0c == "평가요소":
|
|
1195
|
+
ph = r
|
|
1196
|
+
if base is None and "기본점수" in t0:
|
|
1197
|
+
base = r
|
|
1198
|
+
return task_row, std_label_row, ph, base
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _rubric_2022_header_cells(ti: int, rub: Rubric) -> list[dict[str, Any]]:
|
|
1202
|
+
# header: title / points -- addressed by adjacent label (geometry-free)
|
|
1203
|
+
return [
|
|
1204
|
+
{"table_index": ti, "cell_anchor": {"label": "평가 영역명", "dir": "right"},
|
|
1205
|
+
"text": rub.title, "max_lines": 2},
|
|
1206
|
+
{"table_index": ti, "cell_anchor": {"label": "영역 만점", "dir": "right"},
|
|
1207
|
+
"text": f"{rub.points}점", "max_lines": 1},
|
|
1208
|
+
]
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _rubric_2022_standards_method_row(tb: bytes, grid: Any, rep: Any, std_label_row: int) -> int:
|
|
1212
|
+
from hwpx.table_patch import table_text
|
|
1213
|
+
|
|
1214
|
+
return next(
|
|
1215
|
+
(
|
|
1216
|
+
r for r in range(std_label_row + 1, rep.row_count)
|
|
1217
|
+
if (c0 := grid.get((r, 0))) is not None
|
|
1218
|
+
and "평가" in table_text(tb[c0.start:c0.end])
|
|
1219
|
+
and "방법" in table_text(tb[c0.start:c0.end])
|
|
1220
|
+
),
|
|
1221
|
+
rep.row_count,
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _rubric_2022_standards_leader_rows(tb: bytes, std_label_row: int, method_row: int) -> list[int]:
|
|
1226
|
+
from hwpx.table_patch import direct_table_cells
|
|
1227
|
+
|
|
1228
|
+
return [
|
|
1229
|
+
c.row for c in sorted(direct_table_cells(tb), key=lambda c: c.row)
|
|
1230
|
+
if c.col == 0 and std_label_row < c.row < method_row and c.row_span >= 2
|
|
1231
|
+
]
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _rubric_2022_standards_cells(
|
|
1235
|
+
ti: int, rub: Rubric, tb: bytes, grid: Any, rep: Any, std_label_row: int | None
|
|
1236
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
1237
|
+
"""성취기준 codes: the '성취기준' LABEL row's right neighbour is the '성취기준별
|
|
1238
|
+
성취수준' A~E banner (a section header, NOT a value slot) -- so we write the
|
|
1239
|
+
codes into the A~E block's col-0 LEADER cell(s) instead (the merged cells that
|
|
1240
|
+
span the A..E rows and currently carry the blank's foreign sample codes).
|
|
1241
|
+
The blank can ship SEVERAL sample 성취기준 blocks under one rubric (each its own
|
|
1242
|
+
A~E leader); the MD supplies one codes string per rubric, so we overwrite EVERY
|
|
1243
|
+
leader between the 성취기준 label row and the next 평가 방법 section, leaving no
|
|
1244
|
+
foreign code behind (the first carries the codes; the rest are blanked)."""
|
|
1245
|
+
|
|
1246
|
+
cells: list[dict[str, Any]] = []
|
|
1247
|
+
skipped: list[str] = []
|
|
1248
|
+
if rub.standards and std_label_row is not None:
|
|
1249
|
+
method_row = _rubric_2022_standards_method_row(tb, grid, rep, std_label_row)
|
|
1250
|
+
leaders = _rubric_2022_standards_leader_rows(tb, std_label_row, method_row)
|
|
1251
|
+
if leaders:
|
|
1252
|
+
for j, lr in enumerate(leaders):
|
|
1253
|
+
cells.append({"table_index": ti, "row": lr, "col": 0,
|
|
1254
|
+
"text": rub.standards if j == 0 else "", "max_lines": 4})
|
|
1255
|
+
else:
|
|
1256
|
+
skipped.append(f"rubric ti={ti}: no A~E 성취기준 block leader to place codes")
|
|
1257
|
+
return cells, skipped
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _rubric_2022_task_cells(
|
|
1261
|
+
ti: int, rub: Rubric, grid: Any, rep: Any, task_row: int | None
|
|
1262
|
+
) -> list[dict[str, Any]]:
|
|
1263
|
+
"""수행과제: the blank ships a foreign sample task (통사 인권 문제 …). The MD has no
|
|
1264
|
+
dedicated 수행과제 string, so synthesise a faithful one from THIS area's 평가항목
|
|
1265
|
+
labels (the tasks the rubric actually scores) -- replaces the sample subject."""
|
|
1266
|
+
|
|
1267
|
+
cells: list[dict[str, Any]] = []
|
|
1268
|
+
if task_row is not None:
|
|
1269
|
+
items = [_item_label(row[0]) for row in rub.rows if not row[0].startswith("기본점수")]
|
|
1270
|
+
if items:
|
|
1271
|
+
# the value cell is the FIRST cell to the right of the 수행과제 label's own
|
|
1272
|
+
# col-span (the 45점 rubric's label spans cols 0-1, so grid[(task_row, 1)]
|
|
1273
|
+
# would be the label itself -- start past it, not at a fixed col 1).
|
|
1274
|
+
label_cell = grid.get((task_row, 0))
|
|
1275
|
+
start_col = (label_cell.col + label_cell.col_span) if label_cell is not None else 1
|
|
1276
|
+
task_cell = next((grid.get((task_row, cc)) for cc in range(start_col, rep.col_count)
|
|
1277
|
+
if grid.get((task_row, cc)) is not None), None)
|
|
1278
|
+
if task_cell is not None:
|
|
1279
|
+
cells.append({"table_index": ti, "row": task_cell.row, "col": task_cell.col,
|
|
1280
|
+
"text": "∙" + " ∙".join(items), "max_lines": 3})
|
|
1281
|
+
return cells
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _rubric_2022_element_leader_rows(tb: bytes, ph: int, base: int) -> list[int]:
|
|
1285
|
+
from hwpx.table_patch import direct_table_cells
|
|
1286
|
+
|
|
1287
|
+
seen: set[tuple[int, int]] = set()
|
|
1288
|
+
leaders: list[int] = []
|
|
1289
|
+
for c in sorted(direct_table_cells(tb), key=lambda c: c.row):
|
|
1290
|
+
if c.col == 0 and ph < c.row < base and c.row_span >= 2 and (c.start, c.end) not in seen:
|
|
1291
|
+
seen.add((c.start, c.end))
|
|
1292
|
+
leaders.append(c.row)
|
|
1293
|
+
return leaders
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _rubric_2022_element_label_cells(
|
|
1297
|
+
ti: int, rub: Rubric, leaders: list[int]
|
|
1298
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
1299
|
+
items = [_item_label(row[0]) for row in rub.rows if not row[0].startswith("기본점수")]
|
|
1300
|
+
cells: list[dict[str, Any]] = []
|
|
1301
|
+
skipped: list[str] = []
|
|
1302
|
+
if leaders and items:
|
|
1303
|
+
k = len(leaders)
|
|
1304
|
+
packed = items if len(items) <= k else items[:k - 1] + [" / ".join(items[k - 1:])]
|
|
1305
|
+
for row, label in zip(leaders, packed):
|
|
1306
|
+
cells.append({"table_index": ti, "row": row, "col": 0, "text": label, "max_lines": 3})
|
|
1307
|
+
elif items:
|
|
1308
|
+
skipped.append(f"rubric ti={ti}: no 평가요소 leader cells to place {len(items)} items")
|
|
1309
|
+
return cells, skipped
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _rubric_2022_base_score_cells(
|
|
1313
|
+
ti: int, rub: Rubric, grid: Any, rep: Any, base: int, lastcol: int
|
|
1314
|
+
) -> list[dict[str, Any]]:
|
|
1315
|
+
_base_label, base_score, _long_label, long_score = _base_scores(rub.rows)
|
|
1316
|
+
cells: list[dict[str, Any]] = []
|
|
1317
|
+
if base_score and grid.get((base, lastcol)):
|
|
1318
|
+
cells.append({"table_index": ti, "row": base, "col": lastcol, "text": base_score, "max_lines": 1})
|
|
1319
|
+
if long_score and base + 1 < rep.row_count and grid.get((base + 1, lastcol)):
|
|
1320
|
+
cells.append({"table_index": ti, "row": base + 1, "col": lastcol, "text": long_score, "max_lines": 1})
|
|
1321
|
+
return cells
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _rubric_2022_leader_and_base_cells(
|
|
1325
|
+
ti: int, rub: Rubric, tb: bytes, grid: Any, rep: Any, ph: int | None, base: int | None, lastcol: int
|
|
1326
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
1327
|
+
"""평가요소 수행수준 leader cells (col0 merged cells strictly inside (ph, base))
|
|
1328
|
+
plus the 기본점수 / 장기 미인정 배점 (right-most column of the two base rows)."""
|
|
1329
|
+
|
|
1330
|
+
if ph is None or base is None:
|
|
1331
|
+
return [], [f"rubric ti={ti}: could not bound 평가요소 region (ph={ph}, base={base})"]
|
|
1332
|
+
leaders = _rubric_2022_element_leader_rows(tb, ph, base)
|
|
1333
|
+
label_cells, skipped = _rubric_2022_element_label_cells(ti, rub, leaders)
|
|
1334
|
+
base_cells = _rubric_2022_base_score_cells(ti, rub, grid, rep, base, lastcol)
|
|
1335
|
+
return label_cells + base_cells, skipped
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _fill_rubric_2022(data: bytes, ti: int, rub: Rubric,
|
|
1339
|
+
std_levels: dict[str, list[str]] | None = None) -> tuple[bytes, list[str]]:
|
|
1340
|
+
"""Fill ONE 2022-개정 수행평가 세부기준 rubric table (평가 영역명 layout) from a
|
|
1341
|
+
review rubric, byte-preserving. These tables are heterogeneous rich grids
|
|
1342
|
+
(수행과제 / 성취기준 A~E block / 평가방법 / 학생 유의사항 / 평가요소 수행수준 blocks /
|
|
1343
|
+
기본점수·장기미인정). We overwrite the cleanly-addressable label cells the scorer
|
|
1344
|
+
measures -- the 평가 영역명 (title), 영역 만점 (points), 성취기준 (codes), the 성취
|
|
1345
|
+
기준별 성취수준 A~E descriptors (from the primary standard's review 성취수준, via
|
|
1346
|
+
``std_levels``), the 평가요소 수행수준 leader cells (the review 평가항목 labels), the
|
|
1347
|
+
수행과제 task, and the 기본점수 / 장기 미인정 배점 -- AND reshape+fill the 수행수준
|
|
1348
|
+
채점기준 descriptor ladder (:func:`_fill_rubric_2022_ladder`): each 평가요소 group is
|
|
1349
|
+
normalized (delete_row / insert_row_by_clone -- reshape, NOT regeneration) to the
|
|
1350
|
+
MD ladder's level count, then each level's descriptor + 배점 is spliced in. A rubric
|
|
1351
|
+
whose ladder can't be reconciled cleanly is left as blank sample and reported
|
|
1352
|
+
(NEEDS_REVIEW, fail-closed) rather than corrupted."""
|
|
1353
|
+
from hwpx.table_patch import fill_cells
|
|
1354
|
+
|
|
1355
|
+
skipped: list[str] = []
|
|
1356
|
+
|
|
1357
|
+
# STEP 0 (structure+content): reshape and fill the 수행수준 채점기준 ladder FIRST --
|
|
1358
|
+
# it deletes/clones rows, so the label-cell locations below must be found on the
|
|
1359
|
+
# reshaped grid. Fail-closed: on any reconciliation failure the ladder pass returns
|
|
1360
|
+
# the input bytes unchanged and its notes carry the reason (a NEEDS_REVIEW note is
|
|
1361
|
+
# re-emitted at the end when ``ladder_filled`` is False).
|
|
1362
|
+
data, ladder_filled, ladder_notes = _fill_rubric_2022_ladder(data, ti, rub)
|
|
1363
|
+
skipped.extend(ladder_notes)
|
|
1364
|
+
|
|
1365
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1366
|
+
lastcol = rep.col_count - 1
|
|
1367
|
+
|
|
1368
|
+
task_row, std_label_row, ph, base = _locate_rubric_2022_rows(tb, grid, rep)
|
|
1369
|
+
|
|
1370
|
+
cells: list[dict[str, Any]] = _rubric_2022_header_cells(ti, rub)
|
|
1371
|
+
|
|
1372
|
+
standards_cells, standards_sk = _rubric_2022_standards_cells(ti, rub, tb, grid, rep, std_label_row)
|
|
1373
|
+
cells.extend(standards_cells)
|
|
1374
|
+
skipped.extend(standards_sk)
|
|
1375
|
+
|
|
1376
|
+
# 성취기준별 성취수준 A~E descriptors: the blank ships the 서술 cells as a foreign
|
|
1377
|
+
# sample (통합사회/미술 프로젝트 prose). Splice the primary standard's review 성취수준
|
|
1378
|
+
# into each grade row's 서술 cell (fail-closed: reported, never corrupted).
|
|
1379
|
+
if std_levels:
|
|
1380
|
+
ae_cells, ae_sk = _fill_rubric_ae_levels(ti, tb, grid, rep, rub, std_levels)
|
|
1381
|
+
cells.extend(ae_cells)
|
|
1382
|
+
skipped.extend(ae_sk)
|
|
1383
|
+
|
|
1384
|
+
cells.extend(_rubric_2022_task_cells(ti, rub, grid, rep, task_row))
|
|
1385
|
+
|
|
1386
|
+
leader_base_cells, leader_base_sk = _rubric_2022_leader_and_base_cells(
|
|
1387
|
+
ti, rub, tb, grid, rep, ph, base, lastcol
|
|
1388
|
+
)
|
|
1389
|
+
cells.extend(leader_base_cells)
|
|
1390
|
+
skipped.extend(leader_base_sk)
|
|
1391
|
+
|
|
1392
|
+
fr = fill_cells(data, cells)
|
|
1393
|
+
skipped.extend(s.reason for s in fr.skipped)
|
|
1394
|
+
# honest-defer (no-silent-true): re-emit a NEEDS_REVIEW note ONLY when the 수행수준
|
|
1395
|
+
# 채점기준 descriptor ladder could NOT be reconciled byte-preservingly (STEP 0
|
|
1396
|
+
# returned ladder_filled=False -- e.g. group count ≠ MD item count, or a group's
|
|
1397
|
+
# clean-row shape resisted normalization). When the ladder WAS filled, the note is
|
|
1398
|
+
# omitted so the caller no longer reads this rubric as carrying sample prose.
|
|
1399
|
+
if not ladder_filled:
|
|
1400
|
+
skipped.append(f"rubric ti={ti}: NEEDS_REVIEW — 수행수준 채점기준 descriptor ladder "
|
|
1401
|
+
"left as blank sample (no byte-preserving map to review score ladder)")
|
|
1402
|
+
return fr.data, skipped
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _fill_rubric_headings(data: bytes, content: EvalPlanContent) -> tuple[bytes, list[str]]:
|
|
1406
|
+
"""Rewrite each 2022-개정 rubric's ordinal heading paragraph -- the '가. 인권 문제
|
|
1407
|
+
해결 프로젝트' / '나. 인포그래픽 디자인' line that sits OUTSIDE the table, directly
|
|
1408
|
+
before its '평가 영역명' paragraph -- to '<ordinal>. <review 영역명>', byte-preserving
|
|
1409
|
+
(paragraph text splice only). One heading per rubric, matched in document order to
|
|
1410
|
+
``content.rubrics``; a heading with no matching rubric is left untouched (honest --
|
|
1411
|
+
no fabricated title). Located by structure (an ordinal-marker paragraph immediately
|
|
1412
|
+
followed by '평가 영역명'), so it never touches an in-table cell."""
|
|
1413
|
+
from hwpx.patch import paragraph_chunks, paragraph_patch
|
|
1414
|
+
from hwpx.table_patch import section_parts, table_text
|
|
1415
|
+
|
|
1416
|
+
skipped: list[str] = []
|
|
1417
|
+
sections = section_parts(data)
|
|
1418
|
+
if not sections or not content.rubrics:
|
|
1419
|
+
return data, skipped
|
|
1420
|
+
sp = min(sections)
|
|
1421
|
+
section = sections[sp]
|
|
1422
|
+
texts = [table_text(chunk).strip() for chunk in paragraph_chunks(section)]
|
|
1423
|
+
heads = [i for i in range(len(texts) - 1)
|
|
1424
|
+
if re.match(r"^[가나다라마바사아자차카타파하]\.(\s|$)", texts[i]) and texts[i + 1] == "평가 영역명"]
|
|
1425
|
+
patches: list[dict[str, Any]] = []
|
|
1426
|
+
for k, pidx in enumerate(heads):
|
|
1427
|
+
if k >= len(content.rubrics):
|
|
1428
|
+
break
|
|
1429
|
+
patches.append({"section_path": sp, "paragraph_index": pidx,
|
|
1430
|
+
"text": f"{_ORDINALS[k]}. {content.rubrics[k].title}"})
|
|
1431
|
+
if len(heads) != len(content.rubrics):
|
|
1432
|
+
skipped.append(f"rubric headings: {len(heads)} ordinal headings vs {len(content.rubrics)} rubrics")
|
|
1433
|
+
if not patches:
|
|
1434
|
+
return data, skipped
|
|
1435
|
+
pres = paragraph_patch(data, patches)
|
|
1436
|
+
skipped.extend(s.reason for s in pres.skipped)
|
|
1437
|
+
return pres.data, skipped
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
# --------------------------------------------------------------------------- #
|
|
1441
|
+
# Detailed §7 fill (current 평가계획 MD) -- per-평가요소 배점 ladders. Reshapes the
|
|
1442
|
+
# blank rubric's 평가요소 ladder to the MD's element count (grow / shrink the col0
|
|
1443
|
+
# vertical-merge blocks) and splices every observable criterion + 배점 verbatim.
|
|
1444
|
+
# Every target is located by geometry (label text / grid spans), reusable across the
|
|
1445
|
+
# form family, and every edit goes through the byte-preserving table_patch primitives.
|
|
1446
|
+
# --------------------------------------------------------------------------- #
|
|
1447
|
+
|
|
1448
|
+
def _row_label_value(grid: Any, rep: Any, tb: bytes, matches) -> tuple[int | None, Any, Any]:
|
|
1449
|
+
"""(row, label_cell, value_cell) for the first cell whose text satisfies *matches*;
|
|
1450
|
+
value_cell is the grid cell immediately to its right (spanning-aware)."""
|
|
1451
|
+
from hwpx.table_patch import table_text
|
|
1452
|
+
for r in range(rep.row_count):
|
|
1453
|
+
for c in range(rep.col_count):
|
|
1454
|
+
cell = grid.get((r, c))
|
|
1455
|
+
if cell is None or cell.row != r or cell.col != c:
|
|
1456
|
+
continue
|
|
1457
|
+
if matches(table_text(tb[cell.start:cell.end])):
|
|
1458
|
+
return r, cell, grid.get((r, c + cell.col_span))
|
|
1459
|
+
return None, None, None
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def _fill_2022_header(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
|
|
1463
|
+
"""Overwrite the header value cells (평가 영역명 / 영역 만점 / 학기 / 수행과제 / 학생
|
|
1464
|
+
유의사항) — located by their label, so it works on both the clean template and the
|
|
1465
|
+
donor samples that survive structural pruning."""
|
|
1466
|
+
from hwpx.table_patch import fill_cells
|
|
1467
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1468
|
+
|
|
1469
|
+
def _n(t: str) -> str:
|
|
1470
|
+
return t.replace(" ", "").strip()
|
|
1471
|
+
|
|
1472
|
+
specs = [
|
|
1473
|
+
(lambda t: _n(t) == "평가영역명", rub.title, 3),
|
|
1474
|
+
(lambda t: _n(t) == "영역만점", f"{rub.points}점", 1),
|
|
1475
|
+
(lambda t: _n(t) == "학기", "2학기", 1),
|
|
1476
|
+
(lambda t: _n(t) == "수행과제", rub.task, 6),
|
|
1477
|
+
(lambda t: _n(t) == "학생유의사항", rub.student_notes, 6),
|
|
1478
|
+
]
|
|
1479
|
+
cells: list[dict[str, Any]] = []
|
|
1480
|
+
for pred, val, ml in specs:
|
|
1481
|
+
if not val:
|
|
1482
|
+
continue
|
|
1483
|
+
_r, _lc, vc = _row_label_value(grid, rep, tb, pred)
|
|
1484
|
+
if vc is not None:
|
|
1485
|
+
cells.append({"table_index": ti, "row": vc.row, "col": vc.col, "text": val, "max_lines": ml})
|
|
1486
|
+
# 평가 방법: the donor sample's checkbox grid carries the WRONG subject's ☑ marks.
|
|
1487
|
+
# Overwrite the value cell with the MD's method (its ☑ list) and blank the second
|
|
1488
|
+
# checkbox row so no foreign check survives.
|
|
1489
|
+
if rub.method:
|
|
1490
|
+
_mr, _mlc, mvc = _row_label_value(
|
|
1491
|
+
grid, rep, tb, lambda t: _n(t) == "평가방법"
|
|
1492
|
+
)
|
|
1493
|
+
if mvc is not None:
|
|
1494
|
+
cells.append({"table_index": ti, "row": mvc.row, "col": mvc.col, "text": rub.method, "max_lines": 2})
|
|
1495
|
+
nxt = grid.get((mvc.row + 1, mvc.col))
|
|
1496
|
+
if nxt is not None and nxt.row == mvc.row + 1:
|
|
1497
|
+
cells.append({"table_index": ti, "row": nxt.row, "col": nxt.col, "text": "", "max_lines": 1})
|
|
1498
|
+
fr = fill_cells(data, cells)
|
|
1499
|
+
return fr.data, [s.reason for s in fr.skipped]
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def _delete_2022_ae_block(data: bytes, ti: int) -> tuple[bytes, list[str]]:
|
|
1503
|
+
"""Delete the 성취기준 + 성취기준별 성취수준 (A~E) block. The current MD gives no
|
|
1504
|
+
per-area A~E descriptors (it refers to §4), so keeping the sample rows would show
|
|
1505
|
+
empty/foreign A~E — dropping them is the honest render. Bounds: the '성취기준' label
|
|
1506
|
+
row through the row before '평가 방법'."""
|
|
1507
|
+
from hwpx.table_patch import apply_table_ops, table_text
|
|
1508
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1509
|
+
ae_start = method_row = None
|
|
1510
|
+
for r in range(rep.row_count):
|
|
1511
|
+
c0 = grid.get((r, 0))
|
|
1512
|
+
if c0 is None or c0.row != r:
|
|
1513
|
+
continue
|
|
1514
|
+
t = table_text(tb[c0.start:c0.end]).replace(" ", "").strip()
|
|
1515
|
+
if ae_start is None and t == "성취기준":
|
|
1516
|
+
ae_start = r
|
|
1517
|
+
if t == "평가방법":
|
|
1518
|
+
method_row = r
|
|
1519
|
+
break
|
|
1520
|
+
if ae_start is None or method_row is None or method_row <= ae_start:
|
|
1521
|
+
return data, [f"rubric ti={ti}: A~E block bounds not found (성취기준={ae_start}, 평가방법={method_row})"]
|
|
1522
|
+
rows = list(range(ae_start, method_row))
|
|
1523
|
+
res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti, "rows": sorted(rows, reverse=True)}])
|
|
1524
|
+
if not res.ok:
|
|
1525
|
+
return data, [f"rubric ti={ti}: delete A~E block: {[s.reason for s in res.skipped]}"]
|
|
1526
|
+
return res.data, []
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _grow_ladder_groups(data: bytes, ti: int, n: int) -> tuple[bytes, list[str]]:
|
|
1530
|
+
"""Reshape the col0 평가요소 ladder to exactly *n* groups. Clones the shortest clean
|
|
1531
|
+
group block (``insert_block_by_clone`` — copies formatting byte-for-byte) to grow,
|
|
1532
|
+
deletes whole surplus blocks to shrink. Fail-closed."""
|
|
1533
|
+
from hwpx.table_patch import apply_table_ops
|
|
1534
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1535
|
+
ph, base = _pf_bounds(tb, grid, rep)
|
|
1536
|
+
if ph is None or base is None:
|
|
1537
|
+
return data, [f"rubric ti={ti}: no 평가요소/기본점수 bounds for ladder growth"]
|
|
1538
|
+
groups = _pf_groups(tb, ph, base)
|
|
1539
|
+
g = len(groups)
|
|
1540
|
+
if g == n:
|
|
1541
|
+
return data, []
|
|
1542
|
+
if g < n:
|
|
1543
|
+
r0, h = min(groups, key=lambda gh: gh[1]) # clone the shortest block
|
|
1544
|
+
res = apply_table_ops(data, [{"op": "insert_block_by_clone", "table_index": ti,
|
|
1545
|
+
"ref_rows": [r0, r0 + h - 1], "count": n - g}])
|
|
1546
|
+
if not res.ok:
|
|
1547
|
+
return data, [f"rubric ti={ti}: grow ladder to {n} groups: {[s.reason for s in res.skipped]}"]
|
|
1548
|
+
return res.data, []
|
|
1549
|
+
del_rows: list[int] = []
|
|
1550
|
+
for r0, h in groups[n:]:
|
|
1551
|
+
del_rows.extend(range(r0, r0 + h))
|
|
1552
|
+
res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti,
|
|
1553
|
+
"rows": sorted(set(del_rows), reverse=True)}])
|
|
1554
|
+
if not res.ok:
|
|
1555
|
+
return data, [f"rubric ti={ti}: shrink ladder to {n} groups: {[s.reason for s in res.skipped]}"]
|
|
1556
|
+
return res.data, []
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def _fill_2022_ladder_detailed(data: bytes, ti: int, items: list[RubricItem]) -> tuple[bytes, bool, list[str]]:
|
|
1560
|
+
"""Normalize each 평가요소 group to its element's level count and splice the
|
|
1561
|
+
descriptor + 배점 of every level (verbatim from the MD's structured levels — never
|
|
1562
|
+
the lossy '/'-joined form). ``_grow_ladder_groups`` must have made the group count
|
|
1563
|
+
equal to ``len(items)`` first. Reuses the byte-preserving group reshape helpers."""
|
|
1564
|
+
from hwpx.table_patch import fill_cells
|
|
1565
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1566
|
+
ph, base = _pf_bounds(tb, grid, rep)
|
|
1567
|
+
if ph is None or base is None:
|
|
1568
|
+
return data, False, [f"rubric ti={ti}: no ladder bounds"]
|
|
1569
|
+
bat_col = rep.col_count - 1
|
|
1570
|
+
desc_col = _pf_desc_col(tb, ph, base, bat_col)
|
|
1571
|
+
has_sub = desc_col > 1
|
|
1572
|
+
groups = _pf_groups(tb, ph, base)
|
|
1573
|
+
if len(groups) != len(items):
|
|
1574
|
+
return data, False, [f"rubric ti={ti}: {len(groups)} 평가요소 groups != {len(items)} MD elements"]
|
|
1575
|
+
|
|
1576
|
+
d = data
|
|
1577
|
+
for gi in range(len(groups) - 1, -1, -1):
|
|
1578
|
+
m = max(1, len(items[gi].levels))
|
|
1579
|
+
_sp, tb, grid, rep = _grid_of(d, ti)
|
|
1580
|
+
ph2, base2 = _pf_bounds(tb, grid, rep)
|
|
1581
|
+
r0, h = _pf_groups(tb, cast(int, ph2), cast(int, base2))[gi]
|
|
1582
|
+
if has_sub:
|
|
1583
|
+
d, _fh, sub_sk = _reduce_to_first_subitem(d, ti, r0, h)
|
|
1584
|
+
if sub_sk:
|
|
1585
|
+
return data, False, [f"rubric ti={ti} group {gi}: {sub_sk[0]}"]
|
|
1586
|
+
_sp, tb, grid, rep = _grid_of(d, ti)
|
|
1587
|
+
ph2, base2 = _pf_bounds(tb, grid, rep)
|
|
1588
|
+
r0, h = _pf_groups(tb, cast(int, ph2), cast(int, base2))[gi]
|
|
1589
|
+
d, norm_sk = _normalize_ladder_group(d, ti, r0, h, m, desc_col, bat_col)
|
|
1590
|
+
if norm_sk:
|
|
1591
|
+
return data, False, [f"rubric ti={ti} group {gi}: {norm_sk[0]}"]
|
|
1592
|
+
|
|
1593
|
+
_sp, tb, grid, rep = _grid_of(d, ti)
|
|
1594
|
+
ph2, base2 = _pf_bounds(tb, grid, rep)
|
|
1595
|
+
gg = _pf_groups(tb, cast(int, ph2), cast(int, base2))
|
|
1596
|
+
cells: list[dict[str, Any]] = []
|
|
1597
|
+
for gi, (r0, _h) in enumerate(gg):
|
|
1598
|
+
lead = grid.get((r0, 0))
|
|
1599
|
+
if lead is not None:
|
|
1600
|
+
cells.append({"table_index": ti, "row": lead.row, "col": lead.col,
|
|
1601
|
+
"text": items[gi].name, "max_lines": 4})
|
|
1602
|
+
for k, (desc, score) in enumerate(items[gi].levels):
|
|
1603
|
+
r = r0 + k
|
|
1604
|
+
dc = grid.get((r, desc_col))
|
|
1605
|
+
if dc is not None:
|
|
1606
|
+
cells.append({"table_index": ti, "row": dc.row, "col": dc.col, "text": desc, "max_lines": 6})
|
|
1607
|
+
bc = grid.get((r, bat_col))
|
|
1608
|
+
if bc is not None and score:
|
|
1609
|
+
cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
|
|
1610
|
+
if has_sub:
|
|
1611
|
+
sc = grid.get((r0, 1))
|
|
1612
|
+
if sc is not None and sc.col == 1:
|
|
1613
|
+
cells.append({"table_index": ti, "row": sc.row, "col": 1, "text": "", "max_lines": 1})
|
|
1614
|
+
fr = fill_cells(d, cells)
|
|
1615
|
+
return fr.data, True, [s.reason for s in fr.skipped]
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
def _fill_2022_base_scores(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
|
|
1619
|
+
"""Fill the 기본점수 / 장기 미인정 결석자 배점 cells (right-most column). The row
|
|
1620
|
+
labels already carry the form's standard text, so only the 배점 is spliced. 장기
|
|
1621
|
+
미인정 rows also contain '기본점수' (−1점), so match the more specific label first."""
|
|
1622
|
+
from hwpx.table_patch import fill_cells, table_text
|
|
1623
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1624
|
+
bat_col = rep.col_count - 1
|
|
1625
|
+
base_row = long_row = None
|
|
1626
|
+
for r in range(rep.row_count):
|
|
1627
|
+
c0 = grid.get((r, 0))
|
|
1628
|
+
if c0 is None or c0.row != r:
|
|
1629
|
+
continue
|
|
1630
|
+
t = table_text(tb[c0.start:c0.end])
|
|
1631
|
+
if "장기 미인정" in t or "장기미인정" in t:
|
|
1632
|
+
long_row = r
|
|
1633
|
+
elif "기본점수" in t:
|
|
1634
|
+
base_row = r
|
|
1635
|
+
cells: list[dict[str, Any]] = []
|
|
1636
|
+
for row, score in ((base_row, rub.base_score), (long_row, rub.long_score)):
|
|
1637
|
+
if row is None or not score:
|
|
1638
|
+
continue
|
|
1639
|
+
bc = grid.get((row, bat_col))
|
|
1640
|
+
if bc is not None:
|
|
1641
|
+
cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
|
|
1642
|
+
fr = fill_cells(data, cells)
|
|
1643
|
+
return fr.data, [s.reason for s in fr.skipped]
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def _fill_rubric_detailed_2022(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, bool, list[str]]:
|
|
1647
|
+
"""Fill ONE 2022-개정 (평가 영역명) rubric with a detailed area's content: header
|
|
1648
|
+
values, drop the A~E block, grow the 평가요소 ladder to the element count, splice
|
|
1649
|
+
every level's criterion + 배점, and the 기본점수 배점. Returns (data, ladder_filled,
|
|
1650
|
+
notes)."""
|
|
1651
|
+
notes: list[str] = []
|
|
1652
|
+
data, n = _fill_2022_header(data, ti, rub)
|
|
1653
|
+
notes += n
|
|
1654
|
+
data, n = _delete_2022_ae_block(data, ti)
|
|
1655
|
+
notes += n
|
|
1656
|
+
items = rub.items
|
|
1657
|
+
data, n = _grow_ladder_groups(data, ti, len(items))
|
|
1658
|
+
notes += n
|
|
1659
|
+
data, filled, n = _fill_2022_ladder_detailed(data, ti, items)
|
|
1660
|
+
notes += n
|
|
1661
|
+
data, n = _fill_2022_base_scores(data, ti, rub)
|
|
1662
|
+
notes += n
|
|
1663
|
+
data = _prune_header_empty_bullets(data, ti)
|
|
1664
|
+
return data, filled, notes
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def _split_criteria(criteria: str) -> tuple[str, str, str]:
|
|
1668
|
+
"""Split a 평가기준(상/중/하) bullet ('상=… / 중=… / 하=…') into its three
|
|
1669
|
+
descriptors (verbatim, '' for any absent level)."""
|
|
1670
|
+
out = {}
|
|
1671
|
+
for key in ("상", "중", "하"):
|
|
1672
|
+
m = re.search(rf"{key}\s*=\s*(.+?)(?=\s*[//]\s*[상중하]\s*=|$)", criteria or "")
|
|
1673
|
+
out[key] = _strip_inline_md(m.group(1)) if m else ""
|
|
1674
|
+
return out["상"], out["중"], out["하"]
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def _3hak_ladder_bounds(tb: bytes, grid: Any, rep: Any) -> tuple[int | None, int | None]:
|
|
1678
|
+
"""(영역(만점) header row, 기본점수 row) of a 2015-개정 rubric ladder."""
|
|
1679
|
+
from hwpx.table_patch import table_text
|
|
1680
|
+
hr = br = None
|
|
1681
|
+
for r in range(rep.row_count):
|
|
1682
|
+
c0 = grid.get((r, 0))
|
|
1683
|
+
if c0 is not None and c0.row == r:
|
|
1684
|
+
t = table_text(tb[c0.start:c0.end]).replace(" ", "")
|
|
1685
|
+
if "영역" in t and "만점" in t:
|
|
1686
|
+
hr = r
|
|
1687
|
+
c1 = grid.get((r, 1))
|
|
1688
|
+
if c1 is not None and c1.row == r and br is None and "기본점수" in table_text(tb[c1.start:c1.end]):
|
|
1689
|
+
br = r
|
|
1690
|
+
return hr, br
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
def _fill_3hak_header(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
|
|
1694
|
+
"""Fill the 교육과정성취기준 codes, the 평가기준 상/중/하 descriptors, and 학생 유의
|
|
1695
|
+
사항 — located by label geometry."""
|
|
1696
|
+
from hwpx.table_patch import fill_cells
|
|
1697
|
+
|
|
1698
|
+
def _n(t: str) -> str:
|
|
1699
|
+
return t.replace(" ", "").strip()
|
|
1700
|
+
|
|
1701
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1702
|
+
sang, jung, ha = _split_criteria(rub.criteria)
|
|
1703
|
+
specs = [
|
|
1704
|
+
(lambda t: _n(t) in ("교육과정성취기준", "성취기준"), rub.standards, 3),
|
|
1705
|
+
(lambda t: _n(t) == "상", sang, 4),
|
|
1706
|
+
(lambda t: _n(t) == "중", jung, 4),
|
|
1707
|
+
(lambda t: _n(t) == "하", ha, 4),
|
|
1708
|
+
(lambda t: _n(t) == "학생유의사항", rub.student_notes, 4),
|
|
1709
|
+
]
|
|
1710
|
+
cells: list[dict[str, Any]] = []
|
|
1711
|
+
for pred, val, ml in specs:
|
|
1712
|
+
if not val:
|
|
1713
|
+
continue
|
|
1714
|
+
_r, _lc, vc = _row_label_value(grid, rep, tb, pred)
|
|
1715
|
+
if vc is not None:
|
|
1716
|
+
cells.append({"table_index": ti, "row": vc.row, "col": vc.col, "text": val, "max_lines": ml})
|
|
1717
|
+
fr = fill_cells(data, cells)
|
|
1718
|
+
return fr.data, [s.reason for s in fr.skipped]
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def _fill_3hak_base_scores(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
|
|
1722
|
+
from hwpx.table_patch import fill_cells, table_text
|
|
1723
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1724
|
+
bat_col = rep.col_count - 1
|
|
1725
|
+
base_row = long_row = None
|
|
1726
|
+
for r in range(rep.row_count):
|
|
1727
|
+
c1 = grid.get((r, 1))
|
|
1728
|
+
if c1 is None or c1.row != r:
|
|
1729
|
+
continue
|
|
1730
|
+
t = table_text(tb[c1.start:c1.end])
|
|
1731
|
+
if "장기 미인정" in t or "장기미인정" in t:
|
|
1732
|
+
long_row = r
|
|
1733
|
+
elif "기본점수" in t:
|
|
1734
|
+
base_row = r
|
|
1735
|
+
cells: list[dict[str, Any]] = []
|
|
1736
|
+
for row, score in ((base_row, rub.base_score), (long_row, rub.long_score)):
|
|
1737
|
+
if row is None or not score:
|
|
1738
|
+
continue
|
|
1739
|
+
bc = grid.get((row, bat_col))
|
|
1740
|
+
if bc is not None:
|
|
1741
|
+
cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
|
|
1742
|
+
fr = fill_cells(data, cells)
|
|
1743
|
+
return fr.data, [s.reason for s in fr.skipped]
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def _build_3hak_ladder(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, bool, list[str]]:
|
|
1747
|
+
"""Reshape the 5-column 영역(만점)|평가항목|평가요소|채점 기준|배점 ladder to one physical
|
|
1748
|
+
row per MD level, then subdivide the single 평가요소 merge into per-element blocks and
|
|
1749
|
+
the 평가항목 merge into per-세부영역 blocks (``split_cell_vertical``, byte-preserving).
|
|
1750
|
+
Leaders land on each block's top row; 채점 기준 + 배점 land on every level row."""
|
|
1751
|
+
from hwpx.table_patch import apply_table_ops
|
|
1752
|
+
|
|
1753
|
+
subareas = [(sa.label, sa.points, [it for it in sa.items if not it.subtotal])
|
|
1754
|
+
for sa in rub.subareas]
|
|
1755
|
+
subareas = [(lbl, pts, its) for lbl, pts, its in subareas if its]
|
|
1756
|
+
if not subareas:
|
|
1757
|
+
return data, False, [f"rubric ti={ti}: 3학년 rubric has no scored elements"]
|
|
1758
|
+
|
|
1759
|
+
flat: list[tuple[str, str]] = []
|
|
1760
|
+
item_sizes: list[int] = []
|
|
1761
|
+
subarea_sizes: list[int] = []
|
|
1762
|
+
item_leaders: list[tuple[int, str]] = []
|
|
1763
|
+
subarea_leaders: list[tuple[int, str]] = []
|
|
1764
|
+
off = 0
|
|
1765
|
+
for lbl, pts, its in subareas:
|
|
1766
|
+
sa_start, sa_total = off, 0
|
|
1767
|
+
for it in its:
|
|
1768
|
+
levels = it.levels or [("", "")]
|
|
1769
|
+
item_leaders.append((off, it.name))
|
|
1770
|
+
item_sizes.append(len(levels))
|
|
1771
|
+
flat.extend(levels)
|
|
1772
|
+
off += len(levels)
|
|
1773
|
+
sa_total += len(levels)
|
|
1774
|
+
subarea_leaders.append((sa_start, f"{lbl} ({pts}점)" if lbl else ""))
|
|
1775
|
+
subarea_sizes.append(sa_total)
|
|
1776
|
+
n = off
|
|
1777
|
+
|
|
1778
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1779
|
+
hr, br = _3hak_ladder_bounds(tb, grid, rep)
|
|
1780
|
+
if hr is None or br is None:
|
|
1781
|
+
return data, False, [f"rubric ti={ti}: no 영역(만점)/기본점수 ladder bounds"]
|
|
1782
|
+
body0 = cast(int, hr) + 1
|
|
1783
|
+
cur = br - body0
|
|
1784
|
+
# Grow / shrink the body to n rows. Clone a COVERED body row (body0+1): its cells are
|
|
1785
|
+
# rowSpan==1 (채점 기준·배점) while col0/1/2 merges span across it and auto-extend.
|
|
1786
|
+
if n > cur:
|
|
1787
|
+
res = apply_table_ops(data, [{"op": "insert_row_by_clone", "table_index": ti,
|
|
1788
|
+
"ref_row": body0 + 1, "count": n - cur}])
|
|
1789
|
+
if not res.ok:
|
|
1790
|
+
return data, False, [f"rubric ti={ti}: grow ladder to {n} rows: {[s.reason for s in res.skipped]}"]
|
|
1791
|
+
data = res.data
|
|
1792
|
+
elif n < cur:
|
|
1793
|
+
res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti,
|
|
1794
|
+
"rows": list(range(body0 + n, br))}])
|
|
1795
|
+
if not res.ok:
|
|
1796
|
+
return data, False, [f"rubric ti={ti}: shrink ladder to {n} rows: {[s.reason for s in res.skipped]}"]
|
|
1797
|
+
data = res.data
|
|
1798
|
+
|
|
1799
|
+
# Subdivide 평가요소 (col2) per element and 평가항목 (col1) per 세부영역.
|
|
1800
|
+
for col, sizes in ((2, item_sizes), (1, subarea_sizes)):
|
|
1801
|
+
if len(sizes) > 1:
|
|
1802
|
+
res = apply_table_ops(data, [{"op": "split_cell_vertical", "table_index": ti,
|
|
1803
|
+
"row": body0, "col": col, "sizes": sizes}])
|
|
1804
|
+
if not res.ok:
|
|
1805
|
+
return data, False, [f"rubric ti={ti}: split col{col} {sizes}: {[s.reason for s in res.skipped]}"]
|
|
1806
|
+
data = res.data
|
|
1807
|
+
|
|
1808
|
+
from hwpx.table_patch import fill_cells
|
|
1809
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1810
|
+
hr, _br = _3hak_ladder_bounds(tb, grid, rep)
|
|
1811
|
+
body0 = cast(int, hr) + 1
|
|
1812
|
+
bat_col = rep.col_count - 1
|
|
1813
|
+
cells: list[dict[str, Any]] = []
|
|
1814
|
+
c0 = grid.get((body0, 0))
|
|
1815
|
+
if c0 is not None:
|
|
1816
|
+
cells.append({"table_index": ti, "row": c0.row, "col": 0,
|
|
1817
|
+
"text": f"{rub.title}({rub.points}점)", "max_lines": 4})
|
|
1818
|
+
for roff, lbl in subarea_leaders:
|
|
1819
|
+
c = grid.get((body0 + roff, 1))
|
|
1820
|
+
if c is not None and lbl:
|
|
1821
|
+
cells.append({"table_index": ti, "row": c.row, "col": 1, "text": lbl, "max_lines": 4})
|
|
1822
|
+
for roff, name in item_leaders:
|
|
1823
|
+
c = grid.get((body0 + roff, 2))
|
|
1824
|
+
if c is not None:
|
|
1825
|
+
cells.append({"table_index": ti, "row": c.row, "col": 2, "text": name, "max_lines": 4})
|
|
1826
|
+
for k, (desc, score) in enumerate(flat):
|
|
1827
|
+
c3 = grid.get((body0 + k, 3))
|
|
1828
|
+
if c3 is not None:
|
|
1829
|
+
cells.append({"table_index": ti, "row": c3.row, "col": c3.col, "text": desc, "max_lines": 6})
|
|
1830
|
+
c4 = grid.get((body0 + k, bat_col))
|
|
1831
|
+
if c4 is not None and score:
|
|
1832
|
+
cells.append({"table_index": ti, "row": c4.row, "col": c4.col, "text": score, "max_lines": 1})
|
|
1833
|
+
fr = fill_cells(data, cells)
|
|
1834
|
+
return fr.data, True, [s.reason for s in fr.skipped]
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
def _fill_rubric_detailed_3hak(
|
|
1838
|
+
data: bytes, ti: int, rub: Rubric, content: EvalPlanContent
|
|
1839
|
+
) -> tuple[bytes, bool, list[str]]:
|
|
1840
|
+
"""Fill ONE 2015-개정 (교육과정성취기준) rubric with a detailed area's content: header
|
|
1841
|
+
(성취기준 / 평가기준 상·중·하 / 학생 유의사항), the nested 배점 ladder, and the 기본점수
|
|
1842
|
+
배점. Returns (data, ladder_filled, notes)."""
|
|
1843
|
+
notes: list[str] = []
|
|
1844
|
+
data, n = _fill_3hak_header(data, ti, rub)
|
|
1845
|
+
notes += n
|
|
1846
|
+
data, ok, n = _build_3hak_ladder(data, ti, rub)
|
|
1847
|
+
notes += n
|
|
1848
|
+
data, n = _fill_3hak_base_scores(data, ti, rub)
|
|
1849
|
+
notes += n
|
|
1850
|
+
data = _prune_header_empty_bullets(data, ti)
|
|
1851
|
+
return data, ok, notes
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
def _empty_trailing_para_spans(cell_xml: bytes) -> list[tuple[int, int]]:
|
|
1855
|
+
"""Byte spans of every empty ``<hp:p>`` after the first in a cell — the donor
|
|
1856
|
+
template's leftover list-bullet paragraphs, which render as stray/red bullets once
|
|
1857
|
+
the MD text is spliced into the first paragraph. The first paragraph is always
|
|
1858
|
+
kept (a cell needs one)."""
|
|
1859
|
+
s = cell_xml.decode("utf-8")
|
|
1860
|
+
spans: list[tuple[int, int]] = []
|
|
1861
|
+
for i, m in enumerate(re.finditer(r"<hp:p\b.*?</hp:p>", s, re.DOTALL)):
|
|
1862
|
+
if i == 0:
|
|
1863
|
+
continue
|
|
1864
|
+
txt = "".join(
|
|
1865
|
+
re.findall(
|
|
1866
|
+
r"<hp:t\b[^>]*>(.*?)</hp:t>",
|
|
1867
|
+
m.group(0),
|
|
1868
|
+
re.DOTALL,
|
|
1869
|
+
)
|
|
1870
|
+
)
|
|
1871
|
+
if not txt.strip():
|
|
1872
|
+
spans.append((len(s[:m.start()].encode("utf-8")), len(s[:m.end()].encode("utf-8"))))
|
|
1873
|
+
return spans
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
def _prune_header_empty_bullets(data: bytes, ti: int) -> bytes:
|
|
1877
|
+
"""Drop the empty trailing bullet paragraphs left in a rubric's 수행과제 / 학생 유의
|
|
1878
|
+
사항 value cells (byte-preserving: removes whole empty ``<hp:p>`` elements, rewrites
|
|
1879
|
+
only the affected section part)."""
|
|
1880
|
+
from hwpx.patch import rewrite_package_parts
|
|
1881
|
+
from hwpx.table_patch import (
|
|
1882
|
+
build_grid,
|
|
1883
|
+
iter_table_spans,
|
|
1884
|
+
section_parts,
|
|
1885
|
+
table_text,
|
|
1886
|
+
)
|
|
1887
|
+
|
|
1888
|
+
idx = ti
|
|
1889
|
+
for sp, section in sorted(section_parts(data).items()):
|
|
1890
|
+
spans = iter_table_spans(section)
|
|
1891
|
+
if idx >= len(spans):
|
|
1892
|
+
idx -= len(spans)
|
|
1893
|
+
continue
|
|
1894
|
+
ts, te = spans[idx]
|
|
1895
|
+
tb = section[ts:te]
|
|
1896
|
+
grid, rep = build_grid(tb)
|
|
1897
|
+
edits: list[tuple[int, int]] = []
|
|
1898
|
+
for r in range(rep.row_count):
|
|
1899
|
+
for cprime in range(rep.col_count):
|
|
1900
|
+
cell = grid.get((r, cprime))
|
|
1901
|
+
if cell is None or (cell.row, cell.col) != (r, cprime):
|
|
1902
|
+
continue
|
|
1903
|
+
if table_text(tb[cell.start:cell.end]).replace(" ", "").strip() not in ("수행과제", "학생유의사항"):
|
|
1904
|
+
continue
|
|
1905
|
+
vc = grid.get((r, cell.col + cell.col_span))
|
|
1906
|
+
if vc is None:
|
|
1907
|
+
continue
|
|
1908
|
+
for a, b in _empty_trailing_para_spans(tb[vc.start:vc.end]):
|
|
1909
|
+
edits.append((ts + vc.start + a, ts + vc.start + b))
|
|
1910
|
+
if not edits:
|
|
1911
|
+
return data
|
|
1912
|
+
new_section = section
|
|
1913
|
+
for a, b in sorted(edits, reverse=True):
|
|
1914
|
+
new_section = new_section[:a] + new_section[b:]
|
|
1915
|
+
return rewrite_package_parts(data, {sp: new_section})
|
|
1916
|
+
return data
|
|
1917
|
+
|
|
1918
|
+
|
|
1919
|
+
def _fill_rubrics_detailed(
|
|
1920
|
+
data: bytes, content: EvalPlanContent, report: dict[str, Any]
|
|
1921
|
+
) -> tuple[bytes, dict[str, Any]]:
|
|
1922
|
+
"""Fill every §7 rubric from a detailed area. Routes by blank shape: 평가 영역명
|
|
1923
|
+
tables (2학년) → :func:`_fill_rubric_detailed_2022`; 교육과정성취기준 tables (3학년) →
|
|
1924
|
+
:func:`_fill_rubric_detailed_3hak`."""
|
|
1925
|
+
filled = 0
|
|
1926
|
+
for i, rub in enumerate(content.rubrics):
|
|
1927
|
+
idxs = _rubric_indices(data)
|
|
1928
|
+
if i >= len(idxs):
|
|
1929
|
+
report["skipped"].append(f"rubric {i}: no matching blank table")
|
|
1930
|
+
continue
|
|
1931
|
+
ti = idxs[i]
|
|
1932
|
+
if _rubric_is_2022(data, ti):
|
|
1933
|
+
data, ok, notes = _fill_rubric_detailed_2022(data, ti, rub)
|
|
1934
|
+
else:
|
|
1935
|
+
data, ok, notes = _fill_rubric_detailed_3hak(data, ti, rub, content)
|
|
1936
|
+
report["skipped"].extend(f"rubric {i}: {n}" for n in notes)
|
|
1937
|
+
if ok:
|
|
1938
|
+
filled += 1
|
|
1939
|
+
report["filled"] = filled
|
|
1940
|
+
return data, report
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
def fill_rubrics(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
|
|
1944
|
+
"""Fill each 수행평가 세부기준 rubric table from the matching review rubric
|
|
1945
|
+
(byte-preserving). Replaces the sample 성취기준 codes / 평가항목 labels.
|
|
1946
|
+
|
|
1947
|
+
Routes by 개정: 2015-개정 (3학년, '교육과정성취기준' rubric) shrinks the example item
|
|
1948
|
+
block to the review item count and splices 성취기준 / 상·중·하 / 항목 / 배점 rows;
|
|
1949
|
+
2022-개정 (2학년, '평가 영역명' rubric) overwrites the cleanly-addressable label
|
|
1950
|
+
cells (title / points / 성취기준 / A~E / 수행과제 / 평가요소 leaders / 기본점수 배점),
|
|
1951
|
+
reshapes+fills the 수행수준 채점기준 ladder, and rewrites each rubric's ordinal
|
|
1952
|
+
heading paragraph (가./나./다. + sample project title) to the review 영역명."""
|
|
1953
|
+
|
|
1954
|
+
report: dict[str, Any] = {"rubrics": len(content.rubrics), "filled": 0, "skipped": []}
|
|
1955
|
+
|
|
1956
|
+
# Detailed route (current 평가계획 MD): each area carries a per-element 배점 ladder
|
|
1957
|
+
# that the summary routes below can't represent. Grows the blank's 평가요소 ladder to
|
|
1958
|
+
# the MD's element count and splices every observable criterion + 배점 verbatim.
|
|
1959
|
+
if content.rubrics and content.rubrics[0].detailed:
|
|
1960
|
+
return _fill_rubrics_detailed(data, content, report)
|
|
1961
|
+
|
|
1962
|
+
# 2022-개정 route: fill each rubric table's label cells + 채점기준 ladder + heading.
|
|
1963
|
+
idxs0 = _rubric_indices(data)
|
|
1964
|
+
if idxs0 and _rubric_is_2022(data, idxs0[0]):
|
|
1965
|
+
return _fill_rubrics_2022_route(data, content, report)
|
|
1966
|
+
|
|
1967
|
+
# STEP 1 (structure): shrink each rubric's item block to its review item count.
|
|
1968
|
+
data = _shrink_rubric_item_blocks(data, content, report)
|
|
1969
|
+
# STEP 2 (content): fill each rubric's cells.
|
|
1970
|
+
data = _fill_rubric_cells_legacy(data, content, report)
|
|
1971
|
+
return data, report
|
|
1972
|
+
|
|
1973
|
+
|
|
1974
|
+
def _fill_rubrics_2022_route(
|
|
1975
|
+
data: bytes, content: EvalPlanContent, report: dict[str, Any]
|
|
1976
|
+
) -> tuple[bytes, dict[str, Any]]:
|
|
1977
|
+
std_levels = _std_level_map(content.achievement_std)
|
|
1978
|
+
filled = 0
|
|
1979
|
+
for i, rub in enumerate(content.rubrics):
|
|
1980
|
+
idxs = _rubric_indices(data)
|
|
1981
|
+
if i >= len(idxs):
|
|
1982
|
+
report["skipped"].append(f"rubric {i}: no matching blank table")
|
|
1983
|
+
continue
|
|
1984
|
+
data, sk = _fill_rubric_2022(data, idxs[i], rub, std_levels)
|
|
1985
|
+
report["skipped"].extend(sk)
|
|
1986
|
+
filled += 1
|
|
1987
|
+
# rewrite each rubric's ordinal heading paragraph (outside the table) that holds
|
|
1988
|
+
# the blank's sample project title (가. 인권 문제 해결 프로젝트 …).
|
|
1989
|
+
data, head_sk = _fill_rubric_headings(data, content)
|
|
1990
|
+
report["skipped"].extend(head_sk)
|
|
1991
|
+
report["filled"] = filled
|
|
1992
|
+
return data, report
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
def _shrink_rubric_item_blocks(data: bytes, content: EvalPlanContent, report: dict[str, Any]) -> bytes:
|
|
1996
|
+
"""STEP 1: shrink each rubric's item block to its review item count."""
|
|
1997
|
+
|
|
1998
|
+
from hwpx.table_patch import apply_table_ops
|
|
1999
|
+
|
|
2000
|
+
for i, rub in enumerate(content.rubrics):
|
|
2001
|
+
items = [row for row in rub.rows if not row[0].startswith("기본점수")]
|
|
2002
|
+
n = max(1, len(items))
|
|
2003
|
+
idxs = _rubric_indices(data)
|
|
2004
|
+
if i >= len(idxs):
|
|
2005
|
+
report["skipped"].append(f"rubric {i}: no matching blank table")
|
|
2006
|
+
continue
|
|
2007
|
+
ti = idxs[i]
|
|
2008
|
+
hdr, base, _nrows = _rubric_bounds(data, ti)
|
|
2009
|
+
if hdr is None or base is None:
|
|
2010
|
+
report["skipped"].append(f"rubric {i}: could not bound item block")
|
|
2011
|
+
continue
|
|
2012
|
+
first_item = hdr + 1
|
|
2013
|
+
todel = list(range(first_item + n, base)) # keep n, drop the surplus
|
|
2014
|
+
if todel:
|
|
2015
|
+
res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti, "rows": todel}])
|
|
2016
|
+
if not res.ok:
|
|
2017
|
+
report["skipped"].append(f"rubric {i} shrink: {[s.reason for s in res.skipped]}")
|
|
2018
|
+
continue
|
|
2019
|
+
data = res.data
|
|
2020
|
+
return data
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
def _fill_rubric_cells_legacy(data: bytes, content: EvalPlanContent, report: dict[str, Any]) -> bytes:
|
|
2024
|
+
"""STEP 2: fill each rubric's cells (2015-개정 route)."""
|
|
2025
|
+
|
|
2026
|
+
levels = content.levels
|
|
2027
|
+
for i, rub in enumerate(content.rubrics):
|
|
2028
|
+
idxs = _rubric_indices(data)
|
|
2029
|
+
if i >= len(idxs):
|
|
2030
|
+
continue
|
|
2031
|
+
ti = idxs[i]
|
|
2032
|
+
levels_for_area = levels[i] if i < len(levels) else None
|
|
2033
|
+
data, skipped, filled_count = _fill_rubric_legacy_cells(
|
|
2034
|
+
data, ti, rub, levels_for_area, content.achievement_std
|
|
2035
|
+
)
|
|
2036
|
+
report["filled"] += filled_count
|
|
2037
|
+
report["skipped"].extend(f"rubric {i}: {s.reason}" for s in skipped)
|
|
2038
|
+
return data
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
def _fill_rubric_legacy_cells(
|
|
2042
|
+
data: bytes,
|
|
2043
|
+
ti: int,
|
|
2044
|
+
rub: Rubric,
|
|
2045
|
+
levels_for_area: Sequence[str] | None,
|
|
2046
|
+
achievement_std: Any,
|
|
2047
|
+
) -> tuple[bytes, list[Any], int]:
|
|
2048
|
+
"""Fill one legacy (2015-개정) rubric table's cells.
|
|
2049
|
+
|
|
2050
|
+
Returns (data, skip_reasons, filled_count).
|
|
2051
|
+
"""
|
|
2052
|
+
|
|
2053
|
+
from hwpx.table_patch import fill_cells
|
|
2054
|
+
|
|
2055
|
+
hdr, base, _nrows = _rubric_bounds(data, ti)
|
|
2056
|
+
if hdr is None or base is None:
|
|
2057
|
+
return data, [], 0
|
|
2058
|
+
first_item = hdr + 1
|
|
2059
|
+
items = [row for row in rub.rows if not row[0].startswith("기본점수")]
|
|
2060
|
+
_sp, tb, grid, _rep = _grid_of(data, ti)
|
|
2061
|
+
|
|
2062
|
+
cells: list[dict[str, Any]] = []
|
|
2063
|
+
# r0 성취기준 (replaces 한국사 sample codes)
|
|
2064
|
+
std_cell = _rubric_std_cell(ti, rub, achievement_std)
|
|
2065
|
+
if std_cell is not None:
|
|
2066
|
+
cells.append(std_cell)
|
|
2067
|
+
# r1-3 상/중/하 평가기준 from the matching area's A/B/C descriptors
|
|
2068
|
+
cells.extend(_rubric_level_cells(ti, levels_for_area, grid))
|
|
2069
|
+
# 영역(만점) leader
|
|
2070
|
+
area_cell = _rubric_area_leader_cell(ti, rub, first_item, grid)
|
|
2071
|
+
if area_cell is not None:
|
|
2072
|
+
cells.append(area_cell)
|
|
2073
|
+
# 평가항목 labels -> the merged 평가항목 cell's paragraphs (one line per item)
|
|
2074
|
+
item_cell = _rubric_item_labels_cell(ti, first_item, grid, tb, items)
|
|
2075
|
+
if item_cell is not None:
|
|
2076
|
+
cells.append(item_cell)
|
|
2077
|
+
# per-item 채점기준 rows (col3) + top 배점 (col4)
|
|
2078
|
+
cells.extend(_rubric_item_criteria_cells(ti, first_item, base, grid, items))
|
|
2079
|
+
# 기본점수 / 장기 미인정 rows
|
|
2080
|
+
cells.extend(_rubric_base_score_cells(ti, base, grid, rub.rows))
|
|
2081
|
+
|
|
2082
|
+
fr = fill_cells(data, cells)
|
|
2083
|
+
return fr.data, list(fr.skipped), len(fr.applied)
|
|
2084
|
+
|
|
2085
|
+
|
|
2086
|
+
def _rubric_std_cell(ti: int, rub: Rubric, achievement_std: Any) -> dict[str, Any] | None:
|
|
2087
|
+
std_text = rub.standards or (achievement_std and "")
|
|
2088
|
+
if std_text:
|
|
2089
|
+
return {"table_index": ti, "row": 0, "col": 1, "text": std_text, "max_lines": 3}
|
|
2090
|
+
return None
|
|
2091
|
+
|
|
2092
|
+
|
|
2093
|
+
def _rubric_level_cells(
|
|
2094
|
+
ti: int, levels_for_area: Sequence[str] | None, grid: Any
|
|
2095
|
+
) -> list[dict[str, Any]]:
|
|
2096
|
+
cells: list[dict[str, Any]] = []
|
|
2097
|
+
if levels_for_area is not None and len(levels_for_area) >= 4:
|
|
2098
|
+
for k, desc in enumerate(levels_for_area[1:4]):
|
|
2099
|
+
rr = 1 + k
|
|
2100
|
+
if grid.get((rr, 2)):
|
|
2101
|
+
cells.append({"table_index": ti, "row": rr, "col": 2, "text": desc, "max_lines": 4})
|
|
2102
|
+
return cells
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
def _rubric_area_leader_cell(ti: int, rub: Rubric, first_item: int, grid: Any) -> dict[str, Any] | None:
|
|
2106
|
+
if grid.get((first_item, 0)) is not None:
|
|
2107
|
+
return {"table_index": ti, "row": first_item, "col": 0,
|
|
2108
|
+
"text": f"{rub.title}({rub.points}점)", "max_lines": 3}
|
|
2109
|
+
return None
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
def _rubric_item_labels_cell(
|
|
2113
|
+
ti: int, first_item: int, grid: Any, tb: Any, items: list[list[str]]
|
|
2114
|
+
) -> dict[str, Any] | None:
|
|
2115
|
+
from hwpx.table_patch import cell_paragraph_spans
|
|
2116
|
+
|
|
2117
|
+
item_cell = grid.get((first_item, 1))
|
|
2118
|
+
if item_cell is None:
|
|
2119
|
+
return None
|
|
2120
|
+
n_para = len(cell_paragraph_spans(tb[item_cell.start:item_cell.end]))
|
|
2121
|
+
labels = [it[0] for it in items]
|
|
2122
|
+
if n_para < 1:
|
|
2123
|
+
return None
|
|
2124
|
+
# pack labels across available paragraphs; extras merged into the last
|
|
2125
|
+
if len(labels) <= n_para:
|
|
2126
|
+
packed = labels
|
|
2127
|
+
else:
|
|
2128
|
+
packed = labels[:n_para - 1] + [" · ".join(labels[n_para - 1:])]
|
|
2129
|
+
return {"table_index": ti, "row": first_item, "col": 1, "text": "\n".join(packed), "max_lines": 2}
|
|
2130
|
+
|
|
2131
|
+
|
|
2132
|
+
def _rubric_item_criteria_cells(
|
|
2133
|
+
ti: int, first_item: int, base: int, grid: Any, items: list[list[str]]
|
|
2134
|
+
) -> list[dict[str, Any]]:
|
|
2135
|
+
cells: list[dict[str, Any]] = []
|
|
2136
|
+
for k, it in enumerate(items):
|
|
2137
|
+
rr = first_item + k
|
|
2138
|
+
if rr >= base:
|
|
2139
|
+
break
|
|
2140
|
+
if grid.get((rr, 3)):
|
|
2141
|
+
cells.append({"table_index": ti, "row": rr, "col": 3,
|
|
2142
|
+
"text": _batjeom_line(it[0], it[1]), "max_lines": 3})
|
|
2143
|
+
top = _top_batjeom(it[1])
|
|
2144
|
+
if top and grid.get((rr, 4)):
|
|
2145
|
+
cells.append({"table_index": ti, "row": rr, "col": 4, "text": top, "max_lines": 1})
|
|
2146
|
+
return cells
|
|
2147
|
+
|
|
2148
|
+
|
|
2149
|
+
def _rubric_base_score_cells(
|
|
2150
|
+
ti: int, base: int, grid: Any, rub_rows: list[list[str]]
|
|
2151
|
+
) -> list[dict[str, Any]]:
|
|
2152
|
+
base_label, base_score, long_label, long_score = _base_scores(rub_rows)
|
|
2153
|
+
cells: list[dict[str, Any]] = []
|
|
2154
|
+
if grid.get((base, 1)):
|
|
2155
|
+
cells.append({"table_index": ti, "row": base, "col": 1, "text": base_label, "max_lines": 2})
|
|
2156
|
+
if base_score and grid.get((base, 4)):
|
|
2157
|
+
cells.append({"table_index": ti, "row": base, "col": 4, "text": base_score, "max_lines": 1})
|
|
2158
|
+
if grid.get((base + 1, 1)):
|
|
2159
|
+
cells.append({"table_index": ti, "row": base + 1, "col": 1, "text": long_label, "max_lines": 2})
|
|
2160
|
+
if long_score and grid.get((base + 1, 4)):
|
|
2161
|
+
cells.append({"table_index": ti, "row": base + 1, "col": 4, "text": long_score, "max_lines": 1})
|
|
2162
|
+
return cells
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
def _rubric_bounds(data: bytes, ti: int) -> tuple[int | None, int | None, int]:
|
|
2166
|
+
"""(header_row, 기본점수_row, row_count) of a rubric table -- the item block is
|
|
2167
|
+
the physical rows between them."""
|
|
2168
|
+
from hwpx.table_patch import table_text
|
|
2169
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
2170
|
+
hdr = base = None
|
|
2171
|
+
for r in range(rep.row_count):
|
|
2172
|
+
c0 = grid.get((r, 0))
|
|
2173
|
+
if c0 is not None:
|
|
2174
|
+
t0 = table_text(tb[c0.start:c0.end])
|
|
2175
|
+
if "영역" in t0 and "만점" in t0:
|
|
2176
|
+
hdr = r
|
|
2177
|
+
c1 = grid.get((r, 1))
|
|
2178
|
+
if c1 is not None and base is None and "기본점수" in table_text(tb[c1.start:c1.end]):
|
|
2179
|
+
base = r
|
|
2180
|
+
return hdr, base, rep.row_count
|
|
2181
|
+
|
|
2182
|
+
|
|
2183
|
+
def _prose_items(raw: str, header_words: Sequence[str] = ()) -> list[str]:
|
|
2184
|
+
"""Split a ``_norm``'d prose section ('가. ... 나. ... 다. ...') into 가/나/다
|
|
2185
|
+
items, dropping any leading section-title words the parser swept in."""
|
|
2186
|
+
s = raw.strip()
|
|
2187
|
+
for hw in header_words:
|
|
2188
|
+
if s.startswith(hw):
|
|
2189
|
+
s = s[len(hw):].strip()
|
|
2190
|
+
# split before each Korean-letter ordinal marker '가.'..'하.'
|
|
2191
|
+
parts = re.split(r"(?<!\S)(?=[가-힣]\.\s)", s)
|
|
2192
|
+
items = [re.sub(r"^[가-힣]\.\s*", "", p).strip() for p in parts if p.strip()]
|
|
2193
|
+
# drop residual '- ' bullet / title fragments with no ordinal
|
|
2194
|
+
return [it for it in items if it and not it.startswith("평가")]
|
|
2195
|
+
|
|
2196
|
+
|
|
2197
|
+
_ORDINALS = "가나다라마바사아자차카타파하"
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
def fill_sections(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
|
|
2201
|
+
"""Fill the 가./나./다. prose slots of §1 목적 / §2 기본방향 / §3 방침 with the
|
|
2202
|
+
review MD's items (byte-preserving paragraph splices).
|
|
2203
|
+
|
|
2204
|
+
A slot = a paragraph starting with an ordinal marker ('가.'…), whether empty
|
|
2205
|
+
(§1/§2 ship empty placeholders) or already holding the blank's generic sample
|
|
2206
|
+
prose (§3). Each slot keeps its ordinal ('가. <item>') so the numbering
|
|
2207
|
+
survives, and NO item is dropped: when the review has more items than slots,
|
|
2208
|
+
the surplus is appended -- with its own ordinals -- to the last slot (the
|
|
2209
|
+
blank's fixed placeholder count can't grow, and paragraph_patch only
|
|
2210
|
+
*replaces*). §11 결과분석 is deferred (its 가./(1)(2)(3)/나. sub-structure would
|
|
2211
|
+
be flattened) and keeps the blank's generic content -- reported, not faked.
|
|
2212
|
+
Sections whose slots can't be located are reported."""
|
|
2213
|
+
from hwpx.patch import paragraph_chunks, paragraph_patch
|
|
2214
|
+
from hwpx.table_patch import section_parts, table_text
|
|
2215
|
+
|
|
2216
|
+
report: dict[str, Any] = {"filled": 0, "skipped": [], "deferred": ["analysis(§11): rich sub-structure kept generic"]}
|
|
2217
|
+
sections = section_parts(data)
|
|
2218
|
+
if not sections:
|
|
2219
|
+
return data, report
|
|
2220
|
+
sp = min(sections)
|
|
2221
|
+
section = sections[sp]
|
|
2222
|
+
texts = [table_text(chunk).strip() for chunk in paragraph_chunks(section)]
|
|
2223
|
+
|
|
2224
|
+
def slots_after(header_kw: str, next_kw: str) -> list[int]:
|
|
2225
|
+
try:
|
|
2226
|
+
hi = next(i for i, t in enumerate(texts) if t == header_kw)
|
|
2227
|
+
except StopIteration:
|
|
2228
|
+
return []
|
|
2229
|
+
end = len(texts)
|
|
2230
|
+
for j in range(hi + 1, len(texts)):
|
|
2231
|
+
if texts[j] == next_kw:
|
|
2232
|
+
end = j
|
|
2233
|
+
break
|
|
2234
|
+
# a slot = a paragraph whose text is an ordinal marker (empty or filled)
|
|
2235
|
+
return [j for j in range(hi + 1, end)
|
|
2236
|
+
if re.match(r"^[가나다라마바사아자차카타파하]\.(\s|$)", texts[j])]
|
|
2237
|
+
|
|
2238
|
+
plan = [
|
|
2239
|
+
("purposes", "평가의 목적", "평가의 기본 방향", ("평가의 목적",)),
|
|
2240
|
+
("directions", "평가의 기본 방향", "평가 방침", ("평가의 기본 방향",)),
|
|
2241
|
+
("policies", "평가 방침", "성취기준 및 성취수준", ("평가 방침",)),
|
|
2242
|
+
]
|
|
2243
|
+
patches: list[dict[str, Any]] = []
|
|
2244
|
+
for attr, header, nxt, titlewords in plan:
|
|
2245
|
+
items = _prose_items(getattr(content, attr, ""), titlewords)
|
|
2246
|
+
slots = slots_after(header, nxt)
|
|
2247
|
+
if not slots or not items:
|
|
2248
|
+
if items:
|
|
2249
|
+
report["skipped"].append(f"{attr}: no ordinal placeholder located")
|
|
2250
|
+
continue
|
|
2251
|
+
# number every item, then pack into the fixed slots without dropping any
|
|
2252
|
+
numbered = [f"{_ORDINALS[i]}. {it}" for i, it in enumerate(items)]
|
|
2253
|
+
k = len(slots)
|
|
2254
|
+
packed = numbered if len(numbered) <= k else numbered[:k - 1] + [" ".join(numbered[k - 1:])]
|
|
2255
|
+
for slot_idx, text in zip(slots, packed):
|
|
2256
|
+
patches.append({"section_path": sp, "paragraph_index": slot_idx, "text": text})
|
|
2257
|
+
|
|
2258
|
+
if not patches:
|
|
2259
|
+
report["skipped"].append("no fillable prose placeholders located")
|
|
2260
|
+
return data, report
|
|
2261
|
+
pres = paragraph_patch(data, patches)
|
|
2262
|
+
report["filled"] = len(pres.applied)
|
|
2263
|
+
report["skipped"].extend(s.reason for s in pres.skipped)
|
|
2264
|
+
return pres.data, report
|
|
2265
|
+
|
|
2266
|
+
|
|
2267
|
+
def _flatten_schedule_merges(data: bytes, ti: int) -> tuple[bytes, list[str]]:
|
|
2268
|
+
"""Split every vertical merge in the schedule's *data* rows into unit rows.
|
|
2269
|
+
|
|
2270
|
+
The donor ships a subject sample whose 단원명/성취기준 may span two weeks (a
|
|
2271
|
+
``rowSpan==2`` cell). The review has distinct content per week, so a naive
|
|
2272
|
+
one-row-per-week fill writes the first week into the merged cell and can only
|
|
2273
|
+
*skip* the second (its cell is covered) -- the second week's 단원/성취기준 is
|
|
2274
|
+
absorbed. Splitting each vertical merge into unit cells (byte-preserving, the
|
|
2275
|
+
original formatting cloned onto the new cell) restores the 1:1 mapping so no
|
|
2276
|
+
row is absorbed. Header row (0) is left untouched. Content-agnostic."""
|
|
2277
|
+
from hwpx.table_patch import apply_table_ops, direct_table_cells
|
|
2278
|
+
notes: list[str] = []
|
|
2279
|
+
while True:
|
|
2280
|
+
_sp, tb, _grid, _rep = _grid_of(data, ti)
|
|
2281
|
+
merge = next(((c.row, c.col, c.row_span)
|
|
2282
|
+
for c in sorted(direct_table_cells(tb), key=lambda c: (c.row, c.col))
|
|
2283
|
+
if c.row >= 1 and c.row_span > 1), None)
|
|
2284
|
+
if merge is None:
|
|
2285
|
+
break
|
|
2286
|
+
r, col, rs = merge
|
|
2287
|
+
res = apply_table_ops(data, [{"op": "split_cell_vertical", "table_index": ti,
|
|
2288
|
+
"row": r, "col": col, "sizes": [1] * rs}])
|
|
2289
|
+
if not res.ok:
|
|
2290
|
+
notes.append(f"split donor merge r{r}c{col} (rowSpan {rs}) refused: "
|
|
2291
|
+
f"{[s.reason for s in res.skipped]}")
|
|
2292
|
+
break
|
|
2293
|
+
data = res.data
|
|
2294
|
+
notes.append(f"split donor merge r{r}c{col} (rowSpan {rs}) into {rs} unit rows")
|
|
2295
|
+
return data, notes
|
|
2296
|
+
|
|
2297
|
+
|
|
2298
|
+
def fill_schedule(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
|
|
2299
|
+
"""Fill the Ⅰ 교수학습 운영 계획 schedule table from ``content.schedule`` (one
|
|
2300
|
+
logical row per review row, 6 columns), shrink-to-fit at 4 lines. Located by
|
|
2301
|
+
the widest multi-row data table under the Ⅰ heading. Any vertical merge a donor
|
|
2302
|
+
sample left in the data rows is split first so every review row maps to its own
|
|
2303
|
+
form row (no absorption)."""
|
|
2304
|
+
from hwpx.table_patch import fill_cells
|
|
2305
|
+
|
|
2306
|
+
report: dict[str, Any] = {"rows": len(content.schedule), "filled": 0, "skipped": [], "unmerged": []}
|
|
2307
|
+
if not content.schedule:
|
|
2308
|
+
return data, report
|
|
2309
|
+
ti = _schedule_index(data)
|
|
2310
|
+
if ti is None:
|
|
2311
|
+
report["skipped"].append("no schedule table found")
|
|
2312
|
+
return data, report
|
|
2313
|
+
data, report["unmerged"] = _flatten_schedule_merges(data, ti)
|
|
2314
|
+
cells = []
|
|
2315
|
+
for i, row in enumerate(content.schedule):
|
|
2316
|
+
r = i + 1 # row 0 is the header
|
|
2317
|
+
for col in range(min(6, len(row))):
|
|
2318
|
+
cells.append({"table_index": ti, "row": r, "col": col, "text": row[col], "max_lines": 4})
|
|
2319
|
+
fr = fill_cells(data, cells)
|
|
2320
|
+
report["filled"] = len(fr.applied)
|
|
2321
|
+
report["skipped"].extend(s.reason for s in fr.skipped)
|
|
2322
|
+
return fr.data, report
|
|
2323
|
+
|
|
2324
|
+
|
|
2325
|
+
def _schedule_index(data: bytes) -> int | None:
|
|
2326
|
+
"""Table index of the Ⅰ schedule grid: the first multi-row table whose header
|
|
2327
|
+
row carries the 월/주/성취기준 signature."""
|
|
2328
|
+
from hwpx.table_patch import (
|
|
2329
|
+
build_grid,
|
|
2330
|
+
iter_table_spans,
|
|
2331
|
+
section_parts,
|
|
2332
|
+
table_text,
|
|
2333
|
+
)
|
|
2334
|
+
base = 0
|
|
2335
|
+
for sp, section in sorted(section_parts(data).items()):
|
|
2336
|
+
spans = iter_table_spans(section)
|
|
2337
|
+
for ti, (s, e) in enumerate(spans):
|
|
2338
|
+
tb = section[s:e]
|
|
2339
|
+
grid, rep = build_grid(tb)
|
|
2340
|
+
if rep.row_count < 3:
|
|
2341
|
+
continue
|
|
2342
|
+
hdr = " ".join(table_text(tb[grid[(0, c)].start:grid[(0, c)].end])
|
|
2343
|
+
for c in range(rep.col_count) if grid.get((0, c)))
|
|
2344
|
+
if "월" in hdr and "주" in hdr and "성취기준" in hdr:
|
|
2345
|
+
return base + ti
|
|
2346
|
+
base += len(spans)
|
|
2347
|
+
return None
|
|
2348
|
+
|
|
2349
|
+
|
|
2350
|
+
_PCT_RE = re.compile(r"(\d+)\s*%")
|
|
2351
|
+
|
|
2352
|
+
|
|
2353
|
+
def _percent_or_value(value: str) -> str:
|
|
2354
|
+
match = _PCT_RE.search(value)
|
|
2355
|
+
return match.group(0) if match else value
|
|
2356
|
+
|
|
2357
|
+
|
|
2358
|
+
def _ratio_columns(tb: bytes, grid, rep) -> tuple[int, list[int], int | None]:
|
|
2359
|
+
"""(label_col, area_cols, total_col) of a 반영비율 grid.
|
|
2360
|
+
|
|
2361
|
+
``label_col`` is the right-most column of the header's left-most (구분/평가 종류)
|
|
2362
|
+
cell -- 3학년 spans 1 column, 2학년 spans 2 -- so reading a row's label from col 0
|
|
2363
|
+
is safe but *writing* area values must start after this. ``total_col`` is the
|
|
2364
|
+
header column carrying '합계'. ``area_cols`` are the columns strictly between the
|
|
2365
|
+
label span and 합계 (the per-영역 data columns), 정기시험 already deleted."""
|
|
2366
|
+
from hwpx.table_patch import table_text
|
|
2367
|
+
c00 = grid.get((0, 0))
|
|
2368
|
+
label_cols = [cc for cc in range(rep.col_count) if grid.get((0, cc)) is c00]
|
|
2369
|
+
label_col = label_cols[-1] if label_cols else 0
|
|
2370
|
+
total_col = None
|
|
2371
|
+
for cc in range(rep.col_count):
|
|
2372
|
+
cell = grid.get((0, cc))
|
|
2373
|
+
if cell and "합계" in table_text(tb[cell.start:cell.end]):
|
|
2374
|
+
total_col = cc
|
|
2375
|
+
break
|
|
2376
|
+
area_cols = [cc for cc in range(label_col + 1, rep.col_count) if cc != total_col]
|
|
2377
|
+
return label_col, area_cols, total_col
|
|
2378
|
+
|
|
2379
|
+
|
|
2380
|
+
def _ratio_row_source(label: str, content: EvalPlanContent) -> tuple[str, list[str], str | None] | None:
|
|
2381
|
+
"""Map a produced 반영비율 row *label* to its MD source: a ``(kind, area_values,
|
|
2382
|
+
total)`` tuple, or None to leave the row untouched.
|
|
2383
|
+
|
|
2384
|
+
The blank ships more rows than the MD's 5 data rows (a plain '반영 비율' %-only
|
|
2385
|
+
summary + a '시기/영역' area-name row on top of the MD's 영역 만점/논술형/시기/
|
|
2386
|
+
성취기준/평가요소), so the mapping is by tolerant label keyword, not position:
|
|
2387
|
+
|
|
2388
|
+
* '영역 만점' → the MD 영역 만점(반영비율) cells ('35점(35%)' …)
|
|
2389
|
+
* plain '반영 비율' (NOT 영역 만점) → the bare percentages of 영역 만점 ('35%' …)
|
|
2390
|
+
* '시기/영역' (area-name row) → the MD ratio_header 영역 names
|
|
2391
|
+
* '논술형' → MD 논술형 평가 반영비율
|
|
2392
|
+
* '평가 시기' / '시기' (without 영역) → MD 평가 시기
|
|
2393
|
+
* '성취기준' → MD 성취기준 (this is where the blank's foreign sample codes live)
|
|
2394
|
+
* '평가요소' → MD 평가요소
|
|
2395
|
+
"""
|
|
2396
|
+
lab = label.replace(" ", "")
|
|
2397
|
+
rows = {r[0].replace(" ", ""): r for r in content.ratio_rows}
|
|
2398
|
+
|
|
2399
|
+
def row(*keys: str) -> list[str] | None:
|
|
2400
|
+
for k, value in rows.items():
|
|
2401
|
+
if any(key in k for key in keys):
|
|
2402
|
+
return value
|
|
2403
|
+
return None
|
|
2404
|
+
|
|
2405
|
+
def data_and_total(r: list[str] | None) -> tuple[list[str], str | None] | None:
|
|
2406
|
+
if not r:
|
|
2407
|
+
return None
|
|
2408
|
+
vals = r[1:]
|
|
2409
|
+
total = vals[-1] if len(vals) > len(_area_names(content)) else None
|
|
2410
|
+
# area values are the leading cells; a trailing 합계 (last) is the total
|
|
2411
|
+
area = vals[:len(_area_names(content))]
|
|
2412
|
+
return area, total
|
|
2413
|
+
|
|
2414
|
+
areas = _area_names(content)
|
|
2415
|
+
if "영역만점" in lab:
|
|
2416
|
+
dt = data_and_total(row("영역만점", "만점"))
|
|
2417
|
+
return ("영역 만점", dt[0], dt[1]) if dt else None
|
|
2418
|
+
if lab.startswith("반영비율") or lab == "반영비율":
|
|
2419
|
+
dt = data_and_total(row("영역만점", "만점"))
|
|
2420
|
+
if not dt:
|
|
2421
|
+
return None
|
|
2422
|
+
pcts = [_percent_or_value(value) for value in dt[0]]
|
|
2423
|
+
return ("반영 비율", pcts, dt[1])
|
|
2424
|
+
if "시기/영역" in lab or lab == "시기영역":
|
|
2425
|
+
# the area-name row: fill from the MD ratio_header 영역 names
|
|
2426
|
+
return ("영역명", list(areas), None)
|
|
2427
|
+
if "논술형" in lab:
|
|
2428
|
+
dt = data_and_total(row("논술형"))
|
|
2429
|
+
return ("논술형", dt[0], dt[1]) if dt else None
|
|
2430
|
+
if "평가시기" in lab or (lab.startswith("시기") and "영역" not in lab):
|
|
2431
|
+
dt = data_and_total(row("평가시기", "시기"))
|
|
2432
|
+
return ("평가 시기", dt[0], dt[1]) if dt else None
|
|
2433
|
+
if "성취기준" in lab:
|
|
2434
|
+
dt = data_and_total(row("성취기준"))
|
|
2435
|
+
return ("성취기준", dt[0], dt[1]) if dt else None
|
|
2436
|
+
if "평가요소" in lab:
|
|
2437
|
+
dt = data_and_total(row("평가요소"))
|
|
2438
|
+
return ("평가요소", dt[0], dt[1]) if dt else None
|
|
2439
|
+
return None
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
def _area_names(content: EvalPlanContent) -> list[str]:
|
|
2443
|
+
"""The 영역 names from the MD §6 header ('① 문제해결에 …', …) -- every header cell
|
|
2444
|
+
that is not the leading 구분 label or the trailing 합계."""
|
|
2445
|
+
hdr = content.ratio_header
|
|
2446
|
+
if not hdr:
|
|
2447
|
+
return []
|
|
2448
|
+
inner = hdr[1:]
|
|
2449
|
+
if inner and "합계" in inner[-1]:
|
|
2450
|
+
inner = inner[:-1]
|
|
2451
|
+
return inner
|
|
2452
|
+
|
|
2453
|
+
|
|
2454
|
+
def fill_ratio(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
|
|
2455
|
+
"""Fill the 반영비율 (평가의 종류와 반영비율) table's data cells from the review MD
|
|
2456
|
+
§6 (byte-preserving). The recipe deletes the 정기시험 column but never wrote the
|
|
2457
|
+
수행평가 area data, so the produced table carries 100% sample content (50/50/50 %,
|
|
2458
|
+
통합과학 subjects, 통과 성취기준 codes). This maps ``content.ratio_rows`` onto the
|
|
2459
|
+
produced rows by their left-most LABEL (tolerant keyword match, so 반영 비율 vs
|
|
2460
|
+
영역 만점 both resolve) and fills the per-영역 columns + the 시기/영역 area names +
|
|
2461
|
+
the header 영역 names. Handles BOTH forms (3학년 5-col, 2학년 6-col with a 2-col
|
|
2462
|
+
label span). No-op / reported if the MD ships no §6 table."""
|
|
2463
|
+
from hwpx.table_patch import fill_cells, table_text
|
|
2464
|
+
|
|
2465
|
+
report: dict[str, Any] = {"rows_filled": 0, "skipped": []}
|
|
2466
|
+
if not content.ratio_rows and not content.ratio_header:
|
|
2467
|
+
report["skipped"].append("MD has no §6 ratio table")
|
|
2468
|
+
return data, report
|
|
2469
|
+
ti = _classify_index(data, "ratio")
|
|
2470
|
+
if ti is None:
|
|
2471
|
+
report["skipped"].append("no ratio table found")
|
|
2472
|
+
return data, report
|
|
2473
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
2474
|
+
label_col, area_cols, total_col = _ratio_columns(tb, grid, rep)
|
|
2475
|
+
if not area_cols:
|
|
2476
|
+
report["skipped"].append(f"no area columns detected (label_col={label_col}, total_col={total_col})")
|
|
2477
|
+
return data, report
|
|
2478
|
+
|
|
2479
|
+
cells: list[dict[str, Any]] = []
|
|
2480
|
+
# header 평가 종류 row (r0): the area-name header cells still say '수행평가' -- leave
|
|
2481
|
+
# them (they are the 평가 종류, correctly 수행평가); the 영역 names go in the 시기/영역
|
|
2482
|
+
# row so they are not duplicated.
|
|
2483
|
+
for r in range(1, rep.row_count):
|
|
2484
|
+
c0 = grid.get((r, 0))
|
|
2485
|
+
label = table_text(tb[c0.start:c0.end]).strip() if c0 else ""
|
|
2486
|
+
src = _ratio_row_source(label, content)
|
|
2487
|
+
if src is None:
|
|
2488
|
+
continue
|
|
2489
|
+
kind, area_vals, total = src
|
|
2490
|
+
wrote = False
|
|
2491
|
+
# Dedup horizontally-merged area cells: the blank collapses some rows' 영역
|
|
2492
|
+
# columns into ONE spanned cell (e.g. the '반영 비율' summary is a single
|
|
2493
|
+
# cell over all 영역). Writing per-column would collide on the same physical
|
|
2494
|
+
# cell; instead we write ONE value per distinct cell. A merged summary cell
|
|
2495
|
+
# takes the row's 합계 (its meaning is the aggregate), a merged non-summary
|
|
2496
|
+
# cell takes the joined area values; distinct per-영역 cells take their own
|
|
2497
|
+
# value, and an MD '—' CLEARS the blank's leftover sample (writes empty).
|
|
2498
|
+
seen_cells: set[tuple[int, int]] = set()
|
|
2499
|
+
for k, cc in enumerate(area_cols):
|
|
2500
|
+
cell = grid.get((r, cc))
|
|
2501
|
+
if cell is None:
|
|
2502
|
+
continue
|
|
2503
|
+
key = (cell.start, cell.end)
|
|
2504
|
+
if key in seen_cells:
|
|
2505
|
+
continue
|
|
2506
|
+
seen_cells.add(key)
|
|
2507
|
+
spanned = [ac for ac in area_cols if grid.get((r, ac)) is cell]
|
|
2508
|
+
if len(spanned) > 1:
|
|
2509
|
+
# a merged area cell — one physical cell over several 영역 columns
|
|
2510
|
+
if kind == "반영 비율":
|
|
2511
|
+
val = (total or "").strip()
|
|
2512
|
+
else:
|
|
2513
|
+
parts = [area_vals[area_cols.index(ac)].strip()
|
|
2514
|
+
for ac in spanned if area_cols.index(ac) < len(area_vals)]
|
|
2515
|
+
parts = [p for p in parts if p and p != "—"]
|
|
2516
|
+
val = " / ".join(dict.fromkeys(parts))
|
|
2517
|
+
else:
|
|
2518
|
+
val = area_vals[k].strip() if k < len(area_vals) else ""
|
|
2519
|
+
if val == "—":
|
|
2520
|
+
val = "" # clear leftover sample where the MD has no value
|
|
2521
|
+
cells.append({"table_index": ti, "row": r, "col": cc, "text": val, "max_lines": 3})
|
|
2522
|
+
wrote = True
|
|
2523
|
+
# 합계 column: fill only when the MD supplies a concrete total (not '—')
|
|
2524
|
+
if total and total.strip() not in ("", "—") and total_col is not None and grid.get((r, total_col)):
|
|
2525
|
+
cells.append({"table_index": ti, "row": r, "col": total_col, "text": total.strip(), "max_lines": 1})
|
|
2526
|
+
if wrote:
|
|
2527
|
+
report["rows_filled"] += 1
|
|
2528
|
+
|
|
2529
|
+
if not cells:
|
|
2530
|
+
report["skipped"].append("no ratio rows matched the MD labels")
|
|
2531
|
+
return data, report
|
|
2532
|
+
fr = fill_cells(data, cells)
|
|
2533
|
+
report["filled_cells"] = len(fr.applied)
|
|
2534
|
+
report["skipped"].extend(s.reason for s in fr.skipped)
|
|
2535
|
+
return fr.data, report
|
|
2536
|
+
|
|
2537
|
+
|
|
2538
|
+
def finalize_evalplan(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
|
|
2539
|
+
"""Deterministic post-fill cleanup: turn a *filled* donor 평가계획 form into a
|
|
2540
|
+
submittable 채움본 without a bespoke driver script.
|
|
2541
|
+
|
|
2542
|
+
Runs the recipe's mechanical steps -- fill the title / teacher / 정의적 cells,
|
|
2543
|
+
prune the donor's instruction scaffolding and orphaned sample headings (bounded
|
|
2544
|
+
by the form's OWN section headings, never by subject or grade), strip the red
|
|
2545
|
+
guidance runs, recolor filled blue slots to body black, and remove trailing
|
|
2546
|
+
table captions. Every decision is structure/pattern based; no subject or grade
|
|
2547
|
+
string appears. Genuine ambiguity (novel instruction phrasing, non-1:1 정의적
|
|
2548
|
+
cell mapping, tie-broken 변형 선택) is intentionally NOT resolved here -- that is
|
|
2549
|
+
the skill's (LLM) judgment.
|
|
2550
|
+
|
|
2551
|
+
``data`` must already be a :func:`fill_evalplan` (``phase="all"``) output; the
|
|
2552
|
+
deletes are computed while the red guidance runs are still intact so a black
|
|
2553
|
+
ordinal sharing a paragraph with a red instruction is removed as a unit. Returns
|
|
2554
|
+
``(clean_bytes, report)``."""
|
|
2555
|
+
import html
|
|
2556
|
+
import io
|
|
2557
|
+
import zipfile
|
|
2558
|
+
|
|
2559
|
+
from hwpx.body_patch import (
|
|
2560
|
+
apply_body_ops,
|
|
2561
|
+
direct_paragraph_spans,
|
|
2562
|
+
recolor_runs_by_color,
|
|
2563
|
+
strip_runs_by_color,
|
|
2564
|
+
)
|
|
2565
|
+
from hwpx.table_patch import fill_cells, strip_trailing_table_captions
|
|
2566
|
+
|
|
2567
|
+
from ..form_fill.classification import _tables
|
|
2568
|
+
from ..form_fill.guidance import is_form_instruction
|
|
2569
|
+
|
|
2570
|
+
T = re.compile(r"<hp:t(?:\s[^>]*)?>(.*?)</hp:t>", re.DOTALL)
|
|
2571
|
+
RUN = re.compile(r"<hp:run\b[^>]*?>.*?</hp:run>", re.DOTALL)
|
|
2572
|
+
ORDINAL = re.compile(r"^[가-하]\.?$|^\(\d+\)$|^\*+$|^[·・\s]*$")
|
|
2573
|
+
# legit 평가계획 notes that sit among instructions -- form-generic, never a
|
|
2574
|
+
# subject/grade token, so keeping them is not hardcoding a subject.
|
|
2575
|
+
KEEP = ("가급적 동점자",)
|
|
2576
|
+
|
|
2577
|
+
report: dict[str, Any] = {
|
|
2578
|
+
"deleted": 0, "skipped": [], "captions": 0,
|
|
2579
|
+
"title": False, "teacher": False, "affective_rows": 0,
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
def _sec_hdr(d: bytes) -> tuple[str, str]:
|
|
2583
|
+
z = zipfile.ZipFile(io.BytesIO(d))
|
|
2584
|
+
sec = z.read("Contents/section0.xml").decode("utf-8")
|
|
2585
|
+
hdr_name = next(n for n in z.namelist() if n.endswith("header.xml"))
|
|
2586
|
+
return sec, z.read(hdr_name).decode("utf-8")
|
|
2587
|
+
|
|
2588
|
+
def _red_ids(hdr: str) -> set[str]:
|
|
2589
|
+
out: set[str] = set()
|
|
2590
|
+
for cm in re.finditer(
|
|
2591
|
+
r"<hh:charPr\b[^>]*\bid=\"(\d+)\"[^>]*textColor=\"([#0-9A-Fa-f]+)\"", hdr
|
|
2592
|
+
):
|
|
2593
|
+
if cm.group(2).upper() == "#FF0000":
|
|
2594
|
+
out.add(cm.group(1))
|
|
2595
|
+
return out
|
|
2596
|
+
|
|
2597
|
+
def _run_texts(block: str, reds: set[str]) -> tuple[str, str]:
|
|
2598
|
+
black: list[str] = []
|
|
2599
|
+
red: list[str] = []
|
|
2600
|
+
for rm in RUN.finditer(block):
|
|
2601
|
+
run = rm.group(0)
|
|
2602
|
+
txt = html.unescape("".join(T.findall(run)))
|
|
2603
|
+
cid = re.search(r'charPrIDRef="(\d+)"', run)
|
|
2604
|
+
(red if (cid and cid.group(1) in reds) else black).append(txt)
|
|
2605
|
+
return "".join(black), "".join(red)
|
|
2606
|
+
|
|
2607
|
+
# ---- paragraph deletes computed on the FILLED bytes (red still intact) ------
|
|
2608
|
+
sec, hdr = _sec_hdr(data)
|
|
2609
|
+
reds = _red_ids(hdr)
|
|
2610
|
+
spans = direct_paragraph_spans(sec)
|
|
2611
|
+
blocks = [sec[a:b] for (a, b) in spans]
|
|
2612
|
+
texts = [html.unescape("".join(T.findall(bl))).strip() for bl in blocks]
|
|
2613
|
+
has_tbl = ["<hp:tbl" in bl for bl in blocks]
|
|
2614
|
+
del_idx: set[int] = set()
|
|
2615
|
+
|
|
2616
|
+
def first(pred, start: int = 0) -> int | None:
|
|
2617
|
+
return next((i for i in range(start, len(texts)) if pred(i)), None)
|
|
2618
|
+
|
|
2619
|
+
# (2e) 성취수준별 고정분할점수 labels -- red form scaffolding; delete all (the
|
|
2620
|
+
# engine already kept the single subject-matching 성취율 table by band count).
|
|
2621
|
+
del_idx.update(
|
|
2622
|
+
i for i in range(len(texts))
|
|
2623
|
+
if not has_tbl[i] and texts[i].startswith("성취수준별 고정분할점수")
|
|
2624
|
+
)
|
|
2625
|
+
|
|
2626
|
+
# (2a) 최소 성취수준 orphan heading block -- its table was pruned (공통과목 전용);
|
|
2627
|
+
# delete the non-table paragraphs from the heading down to the §5 table.
|
|
2628
|
+
ms = first(lambda i: not has_tbl[i] and texts[i].startswith("다. 최소 성취수준"))
|
|
2629
|
+
sec5 = first(lambda i: has_tbl[i] and "기준 성취율과 성취도" in texts[i])
|
|
2630
|
+
if ms is not None and sec5 is not None:
|
|
2631
|
+
del_idx.update(i for i in range(ms, sec5) if not has_tbl[i])
|
|
2632
|
+
|
|
2633
|
+
# (2f) foreign donor sample headings between the §7 and §8 tables.
|
|
2634
|
+
s7 = first(lambda i: has_tbl[i] and "수행평가 세부기준" in texts[i])
|
|
2635
|
+
s8 = first(lambda i: has_tbl[i] and "정의적 능력 평가" in texts[i],
|
|
2636
|
+
start=(s7 + 1) if s7 is not None else 0)
|
|
2637
|
+
if s7 is not None and s8 is not None:
|
|
2638
|
+
del_idx.update(i for i in range(s7 + 1, s8) if not has_tbl[i] and texts[i])
|
|
2639
|
+
|
|
2640
|
+
# (2b) foreign "(N) area" sub-headers between §4 and §5 (donor sample labels;
|
|
2641
|
+
# the MD's §4 uses 가./나. markers, never "(N) word", so this is safe).
|
|
2642
|
+
s4h = first(lambda i: has_tbl[i] and "성취기준 및 성취수준" in texts[i])
|
|
2643
|
+
s5h = first(lambda i: has_tbl[i] and "기준 성취율과 성취도" in texts[i],
|
|
2644
|
+
start=(s4h + 1) if s4h is not None else 0)
|
|
2645
|
+
if s4h is not None and s5h is not None:
|
|
2646
|
+
del_idx.update(
|
|
2647
|
+
i for i in range(s4h + 1, s5h)
|
|
2648
|
+
if not has_tbl[i] and re.match(r"^\(\d+\)\s*\S", texts[i])
|
|
2649
|
+
)
|
|
2650
|
+
|
|
2651
|
+
# (2c) red-guidance-with-trivial-black + black instruction paragraphs.
|
|
2652
|
+
for i, bl in enumerate(blocks):
|
|
2653
|
+
if has_tbl[i] or i in del_idx or any(k in texts[i] for k in KEEP):
|
|
2654
|
+
continue
|
|
2655
|
+
black_txt, red_txt = _run_texts(bl, reds)
|
|
2656
|
+
if (
|
|
2657
|
+
red_txt.strip() and ORDINAL.match(black_txt.strip())
|
|
2658
|
+
) or is_form_instruction(texts[i]):
|
|
2659
|
+
del_idx.add(i)
|
|
2660
|
+
|
|
2661
|
+
# ---- title + §8 정의적 + teacher fills (paragraph count unchanged) -----------
|
|
2662
|
+
cells: list[dict[str, Any]] = []
|
|
2663
|
+
if content.title:
|
|
2664
|
+
cells.append({"table_index": 0, "row": 0, "col": 0, "text": content.title})
|
|
2665
|
+
report["title"] = True
|
|
2666
|
+
# §8 정의적 능력 평가 표: replace the donor sample with the MD's 측면 rows (1:1).
|
|
2667
|
+
aff = re.findall(
|
|
2668
|
+
r"(\S+적 측면\([^)]*\))\s*[::]\s*(.+?)(?=\s*-\s*\S+적 측면|$)", content.affective or ""
|
|
2669
|
+
)
|
|
2670
|
+
aff_ti = next((gi for gi, t in enumerate(_tables(data)) if "정의적 능력 평가 요소" in t.text), None)
|
|
2671
|
+
if aff_ti is not None and aff:
|
|
2672
|
+
for r, (label, desc) in enumerate(aff[:3], start=1):
|
|
2673
|
+
cells.append({"table_index": aff_ti, "row": r, "col": 0, "text": label.strip(), "max_lines": 3})
|
|
2674
|
+
cells.append({"table_index": aff_ti, "row": r, "col": 1, "text": desc.strip(), "max_lines": 4})
|
|
2675
|
+
report["affective_rows"] = min(len(aff), 3)
|
|
2676
|
+
if cells:
|
|
2677
|
+
data = fill_cells(data, cells).data
|
|
2678
|
+
if content.teacher:
|
|
2679
|
+
# fill the placeholder, then recolor the filled value to body-black so the
|
|
2680
|
+
# red-strip below does not remove it; robust to the label+placeholder being
|
|
2681
|
+
# in one run (2학년) or split black label + red placeholder (3학년).
|
|
2682
|
+
data = apply_body_ops(data, [
|
|
2683
|
+
{"op": "replace_text", "find": "◯◯◯, ◯◯◯", "replace": content.teacher, "count": 1},
|
|
2684
|
+
{"op": "restyle_text", "find": content.teacher, "textColor": "#000000", "count": 1},
|
|
2685
|
+
{"op": "replace_text", "find": "(**)", "replace": "", "count": 4},
|
|
2686
|
+
]).data
|
|
2687
|
+
report["teacher"] = True
|
|
2688
|
+
|
|
2689
|
+
# ---- apply deletes (high->low so indices stay valid) ------------------------
|
|
2690
|
+
dr = apply_body_ops(data, [{"op": "delete_paragraph", "index": i}
|
|
2691
|
+
for i in sorted(del_idx, reverse=True)])
|
|
2692
|
+
data = dr.data
|
|
2693
|
+
report["deleted"] = len(del_idx) - len(dr.skipped)
|
|
2694
|
+
report["skipped"] = [str(s) for s in dr.skipped]
|
|
2695
|
+
|
|
2696
|
+
# ---- strip residual red, recolor filled blue->black, strip captions --------
|
|
2697
|
+
data = strip_runs_by_color(data, ["#FF0000"]).data
|
|
2698
|
+
data = recolor_runs_by_color(data, ["#0000FF"], "#000000").data
|
|
2699
|
+
cap = strip_trailing_table_captions(data)
|
|
2700
|
+
data = cap.data
|
|
2701
|
+
report["captions"] = len(cap.applied)
|
|
2702
|
+
return data, report
|
|
2703
|
+
|
|
2704
|
+
|
|
2705
|
+
def fill_evalplan(
|
|
2706
|
+
blank: str | Path,
|
|
2707
|
+
content: EvalPlanContent,
|
|
2708
|
+
*,
|
|
2709
|
+
output: str | None = None,
|
|
2710
|
+
phase: str = "structural",
|
|
2711
|
+
) -> dict[str, Any]:
|
|
2712
|
+
"""Apply the recipe to a blank form.
|
|
2713
|
+
|
|
2714
|
+
``phase="structural"`` runs the confident deletions only (red/optional tables,
|
|
2715
|
+
정기시험 column, surplus example tables). ``phase="all"`` additionally runs the
|
|
2716
|
+
content fills -- schedule, achievement, levels, rubrics, section prose -- each
|
|
2717
|
+
byte-preserving and located by classification/geometry (index-safe against the
|
|
2718
|
+
prior structural deletes). ``phase="clean"`` runs ``"all"`` and then
|
|
2719
|
+
:func:`finalize_evalplan` -- the deterministic post-fill cleanup (title/teacher/
|
|
2720
|
+
정의적 fills, scaffolding + orphan prune, red-strip, blue->black recolor, caption
|
|
2721
|
+
strip) that yields a submittable 채움본 with no bespoke driver. Returns the apply
|
|
2722
|
+
result dict plus the op-plan transcript and, for ``phase`` in ``{"all","clean"}``,
|
|
2723
|
+
a ``content_report`` per region (plus ``content_report["finalize"]`` for clean)."""
|
|
2724
|
+
from hwpx.table_patch import apply_table_ops
|
|
2725
|
+
|
|
2726
|
+
plan = plan_structural_ops(blank, content)
|
|
2727
|
+
result = apply_table_ops(blank, plan["ops"])
|
|
2728
|
+
data = result.data
|
|
2729
|
+
payload = result.to_dict()
|
|
2730
|
+
|
|
2731
|
+
content_report: dict[str, Any] = {}
|
|
2732
|
+
if phase in ("all", "clean"):
|
|
2733
|
+
data, content_report["schedule"] = fill_schedule(data, content)
|
|
2734
|
+
data, content_report["achievement"] = fill_achievement(data, content)
|
|
2735
|
+
data, content_report["levels"] = fill_levels(data, content)
|
|
2736
|
+
data, content_report["rubrics"] = fill_rubrics(data, content)
|
|
2737
|
+
data, content_report["ratio"] = fill_ratio(data, content)
|
|
2738
|
+
data, content_report["sections"] = fill_sections(data, content)
|
|
2739
|
+
if phase == "clean":
|
|
2740
|
+
data, content_report["finalize"] = finalize_evalplan(data, content)
|
|
2741
|
+
|
|
2742
|
+
if output is not None:
|
|
2743
|
+
from pathlib import Path as _P
|
|
2744
|
+
_P(output).write_bytes(data)
|
|
2745
|
+
payload["outputPath"] = output
|
|
2746
|
+
payload["transcript"] = plan["transcript"]
|
|
2747
|
+
payload["expected_skeleton"] = plan["expected_skeleton"]
|
|
2748
|
+
payload["content_report"] = content_report
|
|
2749
|
+
payload["_data"] = data
|
|
2750
|
+
return payload
|
|
2751
|
+
|
|
2752
|
+
|
|
2753
|
+
def parse_review_file(path: str | Path) -> EvalPlanContent:
|
|
2754
|
+
return parse_review_md(Path(path).read_text(encoding="utf-8"))
|
|
2755
|
+
|
|
2756
|
+
|
|
2757
|
+
__all__ = [ # noqa: RUF022 - frozen 4.x public ordering
|
|
2758
|
+
"EvalPlanContent", "Rubric", "parse_review_md", "parse_review_file",
|
|
2759
|
+
"expected_skeleton", "plan_structural_ops", "fill_evalplan", "finalize_evalplan",
|
|
2760
|
+
"fill_schedule", "fill_achievement", "fill_levels", "fill_rubrics", "fill_ratio",
|
|
2761
|
+
"fill_sections",
|
|
2762
|
+
]
|