python-hwpx 4.1.1__py3-none-any.whl → 4.2.0__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/evalplan_fill.py +975 -10
- hwpx/guidance_scan.py +26 -0
- hwpx/table_patch.py +79 -0
- {python_hwpx-4.1.1.dist-info → python_hwpx-4.2.0.dist-info}/METADATA +2 -1
- {python_hwpx-4.1.1.dist-info → python_hwpx-4.2.0.dist-info}/RECORD +10 -10
- {python_hwpx-4.1.1.dist-info → python_hwpx-4.2.0.dist-info}/WHEEL +0 -0
- {python_hwpx-4.1.1.dist-info → python_hwpx-4.2.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-4.1.1.dist-info → python_hwpx-4.2.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-4.1.1.dist-info → python_hwpx-4.2.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-4.1.1.dist-info → python_hwpx-4.2.0.dist-info}/top_level.txt +0 -0
hwpx/evalplan_fill.py
CHANGED
|
@@ -54,16 +54,64 @@ def _md_table_header(block: str) -> list[str]:
|
|
|
54
54
|
return []
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
@dataclass
|
|
58
|
+
class RubricItem:
|
|
59
|
+
"""One 평가요소 (evaluation element) with its observable-criteria 배점 ladder —
|
|
60
|
+
``levels`` is ``[(descriptor, 배점), …]`` top score first, verbatim from the MD.
|
|
61
|
+
``subtotal`` marks a 소계 row (the sub-area 만점, not a scored element)."""
|
|
62
|
+
name: str
|
|
63
|
+
levels: list[tuple[str, str]] = field(default_factory=list)
|
|
64
|
+
subtotal: bool = False
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict[str, Any]:
|
|
67
|
+
return {"name": self.name, "levels": [list(l) for l in self.levels],
|
|
68
|
+
"subtotal": self.subtotal}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class RubricSubArea:
|
|
73
|
+
"""A 세부 영역 (가./나. …) or, for a single-table area, one anonymous sub-area
|
|
74
|
+
(``label==""``). ``points`` is the 소계 (0 if the MD gives none)."""
|
|
75
|
+
label: str = ""
|
|
76
|
+
points: int = 0
|
|
77
|
+
items: list[RubricItem] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
return {"label": self.label, "points": self.points,
|
|
81
|
+
"items": [it.to_dict() for it in self.items]}
|
|
82
|
+
|
|
83
|
+
|
|
57
84
|
@dataclass
|
|
58
85
|
class Rubric:
|
|
59
86
|
title: str # "문제해결에 탐색 활용하기"
|
|
60
87
|
points: int # 35
|
|
61
88
|
standards: str # "[12인기02-04][12인기02-05]"
|
|
62
89
|
rows: list[list[str]] = field(default_factory=list) # [평가항목, 채점 기준]
|
|
90
|
+
# --- detailed 배점 rubric (current 평가계획 MD format), empty for the legacy
|
|
91
|
+
# synthetic format; :attr:`detailed` gates the detailed fill route.
|
|
92
|
+
subareas: list[RubricSubArea] = field(default_factory=list)
|
|
93
|
+
base_score: str = "" # 기본점수 (백지·미참여)
|
|
94
|
+
long_score: str = "" # 장기 미인정 결석자 (기본점수 −1)
|
|
95
|
+
task: str = "" # 수행과제
|
|
96
|
+
method: str = "" # 평가 방법
|
|
97
|
+
student_notes: str = "" # 학생 유의사항
|
|
98
|
+
criteria: str = "" # 평가기준(상/중/하) — 2015-개정 (3학년) only
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def detailed(self) -> bool:
|
|
102
|
+
"""True when parsed from the current 평가계획 MD (per-element 배점 ladders)."""
|
|
103
|
+
return bool(self.subareas)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def items(self) -> list[RubricItem]:
|
|
107
|
+
"""All 평가요소 across sub-areas, in document order (flattened view)."""
|
|
108
|
+
return [it for sa in self.subareas for it in sa.items]
|
|
63
109
|
|
|
64
110
|
def to_dict(self) -> dict[str, Any]:
|
|
65
111
|
return {"title": self.title, "points": self.points,
|
|
66
|
-
"standards": self.standards, "rows": self.rows
|
|
112
|
+
"standards": self.standards, "rows": self.rows,
|
|
113
|
+
"subareas": [sa.to_dict() for sa in self.subareas],
|
|
114
|
+
"base_score": self.base_score, "long_score": self.long_score}
|
|
67
115
|
|
|
68
116
|
|
|
69
117
|
@dataclass
|
|
@@ -107,6 +155,59 @@ def _section(md: str, start_pat: str, end_pats: list[str]) -> str:
|
|
|
107
155
|
return rest[:cut]
|
|
108
156
|
|
|
109
157
|
|
|
158
|
+
# a per-standard header: ``**[code]** 진술`` at the start of a line
|
|
159
|
+
_ACH_STD_HEADER = re.compile(r"^\s*\*\*\s*(\[[^\]\n]+\])\s*\*\*\s*(.*?)\s*$")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_achievement_std(block: str) -> list[list[str]]:
|
|
163
|
+
"""Parse §4가 into one row per 성취기준: ``[standard, L1_desc, L2_desc, ...]``.
|
|
164
|
+
|
|
165
|
+
Two markdown authoring shapes are accepted; the level count is read from the
|
|
166
|
+
data, never hard-coded:
|
|
167
|
+
|
|
168
|
+
* **unified** -- a single table ``성취기준 | 상 | 중 | 하`` (or ``| A |…| E |``) with
|
|
169
|
+
one row per standard, level descriptors across the columns. Parsed by
|
|
170
|
+
:func:`_md_table_rows`.
|
|
171
|
+
* **per-standard** -- a sequence of ``**[code]** 진술`` headers, each followed by
|
|
172
|
+
its own ``수준 | 성취수준`` table listing the levels as *rows*. Each is normalised
|
|
173
|
+
to ``[code+진술, <descriptor per level, in table order>]``, so a standard with an
|
|
174
|
+
A~E table becomes a 6-field row (bh = 5) matching the unified shape that
|
|
175
|
+
:func:`fill_achievement` and :func:`_std_level_map` already consume.
|
|
176
|
+
|
|
177
|
+
The per-standard shape is tried first; when no ``**[code]**`` header carries a
|
|
178
|
+
table, the unified single-table parse is returned instead."""
|
|
179
|
+
lines = block.splitlines()
|
|
180
|
+
n = len(lines)
|
|
181
|
+
stds: list[list[str]] = []
|
|
182
|
+
i = 0
|
|
183
|
+
while i < n:
|
|
184
|
+
m = _ACH_STD_HEADER.match(lines[i])
|
|
185
|
+
if not m:
|
|
186
|
+
i += 1
|
|
187
|
+
continue
|
|
188
|
+
standard = f"{m.group(1)} {m.group(2)}".strip()
|
|
189
|
+
# advance to this standard's table (blank/prose lines may intervene), but
|
|
190
|
+
# stop at the next standard header -- a header with no table of its own.
|
|
191
|
+
j = i + 1
|
|
192
|
+
while j < n and not lines[j].lstrip().startswith("|"):
|
|
193
|
+
if _ACH_STD_HEADER.match(lines[j]):
|
|
194
|
+
break
|
|
195
|
+
j += 1
|
|
196
|
+
rows: list[list[str]] = []
|
|
197
|
+
while j < n and lines[j].lstrip().startswith("|"):
|
|
198
|
+
cells = [c.strip() for c in lines[j].strip().strip("|").split("|")]
|
|
199
|
+
if not all(set(c) <= {"-", ":", " "} and c for c in cells):
|
|
200
|
+
rows.append(cells)
|
|
201
|
+
j += 1
|
|
202
|
+
if len(rows) >= 2: # header row + >=1 level row
|
|
203
|
+
descs = [r[-1] for r in rows[1:]] # right-most column = the descriptor
|
|
204
|
+
stds.append([standard, *descs])
|
|
205
|
+
i = j
|
|
206
|
+
else:
|
|
207
|
+
i += 1
|
|
208
|
+
return stds or _md_table_rows(block)
|
|
209
|
+
|
|
210
|
+
|
|
110
211
|
def parse_review_md(md_text: str) -> EvalPlanContent:
|
|
111
212
|
"""Parse a 평가계획 review markdown into structured content."""
|
|
112
213
|
c = EvalPlanContent()
|
|
@@ -134,7 +235,7 @@ def parse_review_md(md_text: str) -> EvalPlanContent:
|
|
|
134
235
|
s4 = sec(4)
|
|
135
236
|
ga = s4.split("**나.")[0]
|
|
136
237
|
na = ("**나." + s4.split("**나.")[1]) if "**나." in s4 else ""
|
|
137
|
-
c.achievement_std =
|
|
238
|
+
c.achievement_std = _parse_achievement_std(ga)
|
|
138
239
|
c.levels = _md_table_rows(na)
|
|
139
240
|
|
|
140
241
|
c.achieve_rate = _md_table_rows(sec(5))
|
|
@@ -166,6 +267,20 @@ def _prose(text: str) -> str:
|
|
|
166
267
|
|
|
167
268
|
|
|
168
269
|
def _parse_rubrics(s7: str) -> list[Rubric]:
|
|
270
|
+
"""Parse §7 수행평가 세부기준 into one :class:`Rubric` per 수행영역.
|
|
271
|
+
|
|
272
|
+
Dispatches on format: the current 평가계획 MD writes each area as an ``#### ①
|
|
273
|
+
title`` H4 heading with per-세부영역 ``평가요소 | 수행수준(채점 기준) | 배점`` tables
|
|
274
|
+
(the *detailed* 배점 rubric) — :func:`_parse_rubrics_detailed`. The older synthetic
|
|
275
|
+
format bolds the header inline (``**① title (NN점)**``) with a single flat 채점
|
|
276
|
+
기준(배점) table — :func:`_parse_rubrics_legacy`. Detection is structural (an H4
|
|
277
|
+
circled heading), never subject-specific."""
|
|
278
|
+
if re.search(r"(?m)^####\s*[" + _CIRCLED + r"]", s7):
|
|
279
|
+
return _parse_rubrics_detailed(s7)
|
|
280
|
+
return _parse_rubrics_legacy(s7)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _parse_rubrics_legacy(s7: str) -> list[Rubric]:
|
|
169
284
|
rubrics: list[Rubric] = []
|
|
170
285
|
# split on the bolded circled headers "**① title (NN점)** ..."
|
|
171
286
|
parts = re.split(r"\*\*([" + _CIRCLED + r"][^*]*?\(\d+점\)[^*]*)\*\*", s7)
|
|
@@ -183,6 +298,158 @@ def _parse_rubrics(s7: str) -> list[Rubric]:
|
|
|
183
298
|
return rubrics
|
|
184
299
|
|
|
185
300
|
|
|
301
|
+
def _strip_inline_md(s: str) -> str:
|
|
302
|
+
"""Drop ``**bold**`` / `` `code` `` emphasis, collapse whitespace — cell text is
|
|
303
|
+
spliced verbatim otherwise (no summarisation)."""
|
|
304
|
+
return _norm(re.sub(r"\*\*|`", "", s or ""))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _iter_md_tables(block: str):
|
|
308
|
+
"""Yield ``(preamble, header_cells, data_rows)`` for every GitHub table in *block*
|
|
309
|
+
in order. ``preamble`` is the non-table text since the previous table — it carries
|
|
310
|
+
the ``[세부 영역 …]`` marker that names each sub-area."""
|
|
311
|
+
lines = block.splitlines()
|
|
312
|
+
i, preamble = 0, []
|
|
313
|
+
while i < len(lines):
|
|
314
|
+
if lines[i].strip().startswith("|"):
|
|
315
|
+
tbl = []
|
|
316
|
+
while i < len(lines) and lines[i].strip().startswith("|"):
|
|
317
|
+
tbl.append(lines[i].strip())
|
|
318
|
+
i += 1
|
|
319
|
+
rows = []
|
|
320
|
+
for t in tbl:
|
|
321
|
+
cells = [c.strip() for c in t.strip("|").split("|")]
|
|
322
|
+
if all(set(c) <= {"-", ":", " "} and c for c in cells):
|
|
323
|
+
continue
|
|
324
|
+
rows.append(cells)
|
|
325
|
+
if rows:
|
|
326
|
+
yield "\n".join(preamble), rows[0], rows[1:]
|
|
327
|
+
preamble = []
|
|
328
|
+
else:
|
|
329
|
+
preamble.append(lines[i])
|
|
330
|
+
i += 1
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _area_bullet(body: str, label: str) -> str:
|
|
334
|
+
"""The value of a ``- **{label}**: …`` meta bullet in an area body ('' if absent)."""
|
|
335
|
+
m = re.search(rf"[-*]\s*\*\*{label}\*\*\s*[::]?\s*(.+)", body)
|
|
336
|
+
return m.group(1).strip() if m else ""
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _area_points(body: str, header: str) -> int:
|
|
340
|
+
"""영역 만점 for an area — from the meta bullet / blockquote ('영역 만점: 50점',
|
|
341
|
+
'> 영역 만점 35점') or a ``(NN점)`` in the heading, else 0."""
|
|
342
|
+
m = re.search(r"영역\s*만점[은]?\**\s*[::]?\s*(\d+)\s*점", body)
|
|
343
|
+
if not m:
|
|
344
|
+
m = re.search(r"\((\d+)\s*점\)", header)
|
|
345
|
+
return int(m.group(1)) if m else 0
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _area_standards(body: str) -> str:
|
|
349
|
+
"""The 성취기준 codes cited by an area, concatenated ('[12인기02-04][12인기02-05]')."""
|
|
350
|
+
m = re.search(r"(?:교육과정\s*성취기준|성취기준\s*/\s*성취수준|성취기준)[^\n]*?[::]\s*(.+)", body)
|
|
351
|
+
src = m.group(1) if m else body
|
|
352
|
+
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)
|
|
353
|
+
return "".join(codes)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _parse_area_rubric(header: str, title: str, body: str) -> Rubric:
|
|
357
|
+
"""One 수행영역 → a detailed :class:`Rubric`: sub-area ``평가요소 | 수행수준 | 배점``
|
|
358
|
+
tables become :class:`RubricSubArea` blocks of :class:`RubricItem` ladders, and the
|
|
359
|
+
[영역 공통] / inline 기본점수 rows become ``base_score`` / ``long_score``. Content is
|
|
360
|
+
read verbatim — no invented 배점, no summarisation."""
|
|
361
|
+
rub = Rubric(title=title, points=_area_points(body, header),
|
|
362
|
+
standards=_area_standards(body),
|
|
363
|
+
task=_strip_inline_md(_area_bullet(body, "수행과제")),
|
|
364
|
+
method=_strip_inline_md(_area_bullet(body, "평가 방법")),
|
|
365
|
+
student_notes=_strip_inline_md(_area_bullet(body, "학생 유의사항")),
|
|
366
|
+
criteria=_area_bullet(body, r"평가기준\(상/중/하\)"))
|
|
367
|
+
for pre, hdr, rows in _iter_md_tables(body):
|
|
368
|
+
hdr_txt = " ".join(hdr)
|
|
369
|
+
if "평가요소" in hdr_txt and "배점" in hdr_txt:
|
|
370
|
+
rub.subareas.append(_parse_subarea(pre, rows, rub))
|
|
371
|
+
elif "구분" in hdr_txt and "배점" in hdr_txt:
|
|
372
|
+
_absorb_base_row(rows, rub) # [영역 공통] table
|
|
373
|
+
_sync_legacy_rows(rub)
|
|
374
|
+
return rub
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _parse_subarea(preamble: str, rows: list[list[str]], rub: Rubric) -> RubricSubArea:
|
|
378
|
+
"""Group a sub-area table's rows into 평가요소 items (new item = non-empty 평가요소
|
|
379
|
+
cell, continuation rows = extra levels). A named sub-area's 소계 row is kept as a
|
|
380
|
+
trailing subtotal item (verbatim md — it marks the 가/나 boundary and the sub-area
|
|
381
|
+
만점); 기본점수 / 장기 미인정 rows (appearing inline in single-table areas) feed
|
|
382
|
+
``rub.base_score`` / ``long_score``."""
|
|
383
|
+
m = re.search(r"[세부 영역\s*([가-힣])\.\s*(.+?)\s*\((\d+)\s*점\)]", preamble or "")
|
|
384
|
+
sa = RubricSubArea(label=f"{m.group(1)}. {m.group(2)}" if m else "",
|
|
385
|
+
points=int(m.group(3)) if m else 0)
|
|
386
|
+
cur: RubricItem | None = None
|
|
387
|
+
for row in rows:
|
|
388
|
+
c0 = _strip_inline_md(row[0]) if len(row) > 0 else ""
|
|
389
|
+
c1 = _strip_inline_md(row[1]) if len(row) > 1 else ""
|
|
390
|
+
c2 = _strip_inline_md(row[2]) if len(row) > 2 else ""
|
|
391
|
+
if "소계" in c0:
|
|
392
|
+
if c2.isdigit():
|
|
393
|
+
sa.points = int(c2)
|
|
394
|
+
if sa.label: # keep 소계 as a subtotal row (가/나 marker)
|
|
395
|
+
sm = re.match(r"(.*?소계)\s*\((.*)\)\s*$", c0)
|
|
396
|
+
name, note = (sm.group(1), sm.group(2)) if sm else (c0, "")
|
|
397
|
+
sa.items.append(RubricItem(name=name, levels=[(note, c2)], subtotal=True))
|
|
398
|
+
cur = None
|
|
399
|
+
continue
|
|
400
|
+
if "장기 미인정" in c0 or "장기미인정" in c0:
|
|
401
|
+
if c2.isdigit():
|
|
402
|
+
rub.long_score = c2
|
|
403
|
+
continue
|
|
404
|
+
if "기본점수" in c0:
|
|
405
|
+
if c2.isdigit():
|
|
406
|
+
rub.base_score = c2
|
|
407
|
+
continue
|
|
408
|
+
if c0: # a new 평가요소
|
|
409
|
+
cur = RubricItem(name=c0)
|
|
410
|
+
sa.items.append(cur)
|
|
411
|
+
if cur is not None and (c1 or c2):
|
|
412
|
+
cur.levels.append((c1, c2))
|
|
413
|
+
return sa
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _absorb_base_row(rows: list[list[str]], rub: Rubric) -> None:
|
|
417
|
+
"""Read [영역 공통] ``구분 | 배점`` rows into ``base_score`` / ``long_score``. 장기
|
|
418
|
+
미인정 rows also carry '기본점수' (—1점), so match the more specific label first."""
|
|
419
|
+
for row in rows:
|
|
420
|
+
c0 = _strip_inline_md(row[0]) if row else ""
|
|
421
|
+
cN = _strip_inline_md(row[-1]) if row else ""
|
|
422
|
+
if not cN.isdigit():
|
|
423
|
+
continue
|
|
424
|
+
if "장기 미인정" in c0 or "장기미인정" in c0:
|
|
425
|
+
rub.long_score = cN
|
|
426
|
+
elif "기본점수" in c0:
|
|
427
|
+
rub.base_score = cN
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _sync_legacy_rows(rub: Rubric) -> None:
|
|
431
|
+
"""Mirror the detailed model into the legacy ``rows`` shape ([name, 'desc **s** / …']
|
|
432
|
+
+ a trailing 기본점수 summary) so legacy introspection / scorers keep working."""
|
|
433
|
+
rows: list[list[str]] = []
|
|
434
|
+
for it in rub.items:
|
|
435
|
+
ladder = " / ".join(f"{d} **{s}**" if s else d for d, s in it.levels)
|
|
436
|
+
rows.append([it.name, ladder])
|
|
437
|
+
if rub.base_score or rub.long_score:
|
|
438
|
+
rows.append([f"기본점수 **{rub.base_score}** · 장기 미인정 결석 **{rub.long_score}**", ""])
|
|
439
|
+
rub.rows = rows
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _parse_rubrics_detailed(s7: str) -> list[Rubric]:
|
|
443
|
+
"""Parse the current-format §7 (``#### ①`` areas) into detailed rubrics."""
|
|
444
|
+
heads = list(re.finditer(r"(?m)^####\s*([" + _CIRCLED + r"])\s*(.+?)\s*$", s7))
|
|
445
|
+
out: list[Rubric] = []
|
|
446
|
+
for k, m in enumerate(heads):
|
|
447
|
+
start = m.end()
|
|
448
|
+
end = heads[k + 1].start() if k + 1 < len(heads) else len(s7)
|
|
449
|
+
out.append(_parse_area_rubric(m.group(0), m.group(2).strip(), s7[start:end]))
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
186
453
|
def _norm(text: str) -> str:
|
|
187
454
|
return " ".join(text.split()).strip()
|
|
188
455
|
|
|
@@ -262,11 +529,17 @@ def plan_structural_ops(blank: str | Path, content: EvalPlanContent | None = Non
|
|
|
262
529
|
# rubric example (연주/비평 for 3학년, 6 국어 영역 for 2학년); the content needs
|
|
263
530
|
# `exp[kind]` of them. Delete the extras (keep the first) so the structure
|
|
264
531
|
# matches the content-derived skeleton.
|
|
532
|
+
detailed_rubrics = bool(content and content.rubrics and content.rubrics[0].detailed)
|
|
265
533
|
if exp:
|
|
266
534
|
for kind in ("achievement", "level", "rubric"):
|
|
267
535
|
idxs = by_kind.get(kind, [])
|
|
268
536
|
want = exp.get(kind, 0)
|
|
269
|
-
|
|
537
|
+
# For detailed rubrics the blank ships filled donor samples FIRST (인권·인포
|
|
538
|
+
# 그래픽) and clean empty templates LAST; the leading 인권 sample carries an
|
|
539
|
+
# extra 세부 항목 column the MD never uses, so keep the trailing clean
|
|
540
|
+
# templates instead (drop the surplus from the front).
|
|
541
|
+
surplus = idxs[:len(idxs) - want] if (kind == "rubric" and detailed_rubrics) else idxs[want:]
|
|
542
|
+
for i in surplus:
|
|
270
543
|
if i not in del_tables:
|
|
271
544
|
del_tables.append(i)
|
|
272
545
|
transcript.append(f"delete_table #{i} (surplus {kind} example) — "
|
|
@@ -1162,6 +1435,488 @@ def _fill_rubric_headings(data: bytes, content: EvalPlanContent) -> tuple[bytes,
|
|
|
1162
1435
|
return pres.data, skipped
|
|
1163
1436
|
|
|
1164
1437
|
|
|
1438
|
+
# --------------------------------------------------------------------------- #
|
|
1439
|
+
# Detailed §7 fill (current 평가계획 MD) -- per-평가요소 배점 ladders. Reshapes the
|
|
1440
|
+
# blank rubric's 평가요소 ladder to the MD's element count (grow / shrink the col0
|
|
1441
|
+
# vertical-merge blocks) and splices every observable criterion + 배점 verbatim.
|
|
1442
|
+
# Every target is located by geometry (label text / grid spans), reusable across the
|
|
1443
|
+
# form family, and every edit goes through the byte-preserving table_patch primitives.
|
|
1444
|
+
# --------------------------------------------------------------------------- #
|
|
1445
|
+
|
|
1446
|
+
def _row_label_value(grid: Any, rep: Any, tb: bytes, matches) -> tuple[int | None, Any, Any]:
|
|
1447
|
+
"""(row, label_cell, value_cell) for the first cell whose text satisfies *matches*;
|
|
1448
|
+
value_cell is the grid cell immediately to its right (spanning-aware)."""
|
|
1449
|
+
from .table_patch import _text_of
|
|
1450
|
+
for r in range(rep.row_count):
|
|
1451
|
+
for c in range(rep.col_count):
|
|
1452
|
+
cell = grid.get((r, c))
|
|
1453
|
+
if cell is None or cell.row != r or cell.col != c:
|
|
1454
|
+
continue
|
|
1455
|
+
if matches(_text_of(tb[cell.start:cell.end])):
|
|
1456
|
+
return r, cell, grid.get((r, c + cell.col_span))
|
|
1457
|
+
return None, None, None
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _fill_2022_header(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
|
|
1461
|
+
"""Overwrite the header value cells (평가 영역명 / 영역 만점 / 학기 / 수행과제 / 학생
|
|
1462
|
+
유의사항) — located by their label, so it works on both the clean template and the
|
|
1463
|
+
donor samples that survive structural pruning."""
|
|
1464
|
+
from .table_patch import fill_cells
|
|
1465
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1466
|
+
|
|
1467
|
+
def _n(t: str) -> str:
|
|
1468
|
+
return t.replace(" ", "").strip()
|
|
1469
|
+
|
|
1470
|
+
specs = [
|
|
1471
|
+
(lambda t: _n(t) == "평가영역명", rub.title, 3),
|
|
1472
|
+
(lambda t: _n(t) == "영역만점", f"{rub.points}점", 1),
|
|
1473
|
+
(lambda t: _n(t) == "학기", "2학기", 1),
|
|
1474
|
+
(lambda t: _n(t) == "수행과제", rub.task, 6),
|
|
1475
|
+
(lambda t: _n(t) == "학생유의사항", rub.student_notes, 6),
|
|
1476
|
+
]
|
|
1477
|
+
cells: list[dict[str, Any]] = []
|
|
1478
|
+
for pred, val, ml in specs:
|
|
1479
|
+
if not val:
|
|
1480
|
+
continue
|
|
1481
|
+
_r, _lc, vc = _row_label_value(grid, rep, tb, pred)
|
|
1482
|
+
if vc is not None:
|
|
1483
|
+
cells.append({"table_index": ti, "row": vc.row, "col": vc.col, "text": val, "max_lines": ml})
|
|
1484
|
+
# 평가 방법: the donor sample's checkbox grid carries the WRONG subject's ☑ marks.
|
|
1485
|
+
# Overwrite the value cell with the MD's method (its ☑ list) and blank the second
|
|
1486
|
+
# checkbox row so no foreign check survives.
|
|
1487
|
+
if rub.method:
|
|
1488
|
+
mr, _mlc, mvc = _row_label_value(grid, rep, tb, lambda t: _n(t) == "평가방법")
|
|
1489
|
+
if mvc is not None:
|
|
1490
|
+
cells.append({"table_index": ti, "row": mvc.row, "col": mvc.col, "text": rub.method, "max_lines": 2})
|
|
1491
|
+
nxt = grid.get((mvc.row + 1, mvc.col))
|
|
1492
|
+
if nxt is not None and nxt.row == mvc.row + 1:
|
|
1493
|
+
cells.append({"table_index": ti, "row": nxt.row, "col": nxt.col, "text": "", "max_lines": 1})
|
|
1494
|
+
fr = fill_cells(data, cells)
|
|
1495
|
+
return fr.data, [s.reason for s in fr.skipped]
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _delete_2022_ae_block(data: bytes, ti: int) -> tuple[bytes, list[str]]:
|
|
1499
|
+
"""Delete the 성취기준 + 성취기준별 성취수준 (A~E) block. The current MD gives no
|
|
1500
|
+
per-area A~E descriptors (it refers to §4), so keeping the sample rows would show
|
|
1501
|
+
empty/foreign A~E — dropping them is the honest render. Bounds: the '성취기준' label
|
|
1502
|
+
row through the row before '평가 방법'."""
|
|
1503
|
+
from .table_patch import apply_table_ops, _text_of
|
|
1504
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1505
|
+
ae_start = method_row = None
|
|
1506
|
+
for r in range(rep.row_count):
|
|
1507
|
+
c0 = grid.get((r, 0))
|
|
1508
|
+
if c0 is None or c0.row != r:
|
|
1509
|
+
continue
|
|
1510
|
+
t = _text_of(tb[c0.start:c0.end]).replace(" ", "").strip()
|
|
1511
|
+
if ae_start is None and t == "성취기준":
|
|
1512
|
+
ae_start = r
|
|
1513
|
+
if t == "평가방법":
|
|
1514
|
+
method_row = r
|
|
1515
|
+
break
|
|
1516
|
+
if ae_start is None or method_row is None or method_row <= ae_start:
|
|
1517
|
+
return data, [f"rubric ti={ti}: A~E block bounds not found (성취기준={ae_start}, 평가방법={method_row})"]
|
|
1518
|
+
rows = list(range(ae_start, method_row))
|
|
1519
|
+
res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti, "rows": sorted(rows, reverse=True)}])
|
|
1520
|
+
if not res.ok:
|
|
1521
|
+
return data, [f"rubric ti={ti}: delete A~E block: {[s.reason for s in res.skipped]}"]
|
|
1522
|
+
return res.data, []
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def _grow_ladder_groups(data: bytes, ti: int, n: int) -> tuple[bytes, list[str]]:
|
|
1526
|
+
"""Reshape the col0 평가요소 ladder to exactly *n* groups. Clones the shortest clean
|
|
1527
|
+
group block (``insert_block_by_clone`` — copies formatting byte-for-byte) to grow,
|
|
1528
|
+
deletes whole surplus blocks to shrink. Fail-closed."""
|
|
1529
|
+
from .table_patch import apply_table_ops
|
|
1530
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1531
|
+
ph, base = _pf_bounds(tb, grid, rep)
|
|
1532
|
+
if ph is None or base is None:
|
|
1533
|
+
return data, [f"rubric ti={ti}: no 평가요소/기본점수 bounds for ladder growth"]
|
|
1534
|
+
groups = _pf_groups(tb, ph, base)
|
|
1535
|
+
g = len(groups)
|
|
1536
|
+
if g == n:
|
|
1537
|
+
return data, []
|
|
1538
|
+
if g < n:
|
|
1539
|
+
r0, h = min(groups, key=lambda gh: gh[1]) # clone the shortest block
|
|
1540
|
+
res = apply_table_ops(data, [{"op": "insert_block_by_clone", "table_index": ti,
|
|
1541
|
+
"ref_rows": [r0, r0 + h - 1], "count": n - g}])
|
|
1542
|
+
if not res.ok:
|
|
1543
|
+
return data, [f"rubric ti={ti}: grow ladder to {n} groups: {[s.reason for s in res.skipped]}"]
|
|
1544
|
+
return res.data, []
|
|
1545
|
+
del_rows: list[int] = []
|
|
1546
|
+
for r0, h in groups[n:]:
|
|
1547
|
+
del_rows.extend(range(r0, r0 + h))
|
|
1548
|
+
res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti,
|
|
1549
|
+
"rows": sorted(set(del_rows), reverse=True)}])
|
|
1550
|
+
if not res.ok:
|
|
1551
|
+
return data, [f"rubric ti={ti}: shrink ladder to {n} groups: {[s.reason for s in res.skipped]}"]
|
|
1552
|
+
return res.data, []
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def _fill_2022_ladder_detailed(data: bytes, ti: int, items: list[RubricItem]) -> tuple[bytes, bool, list[str]]:
|
|
1556
|
+
"""Normalize each 평가요소 group to its element's level count and splice the
|
|
1557
|
+
descriptor + 배점 of every level (verbatim from the MD's structured levels — never
|
|
1558
|
+
the lossy '/'-joined form). ``_grow_ladder_groups`` must have made the group count
|
|
1559
|
+
equal to ``len(items)`` first. Reuses the byte-preserving group reshape helpers."""
|
|
1560
|
+
from .table_patch import fill_cells
|
|
1561
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1562
|
+
ph, base = _pf_bounds(tb, grid, rep)
|
|
1563
|
+
if ph is None or base is None:
|
|
1564
|
+
return data, False, [f"rubric ti={ti}: no ladder bounds"]
|
|
1565
|
+
bat_col = rep.col_count - 1
|
|
1566
|
+
desc_col = _pf_desc_col(tb, ph, base, bat_col)
|
|
1567
|
+
has_sub = desc_col > 1
|
|
1568
|
+
groups = _pf_groups(tb, ph, base)
|
|
1569
|
+
if len(groups) != len(items):
|
|
1570
|
+
return data, False, [f"rubric ti={ti}: {len(groups)} 평가요소 groups != {len(items)} MD elements"]
|
|
1571
|
+
|
|
1572
|
+
d = data
|
|
1573
|
+
for gi in range(len(groups) - 1, -1, -1):
|
|
1574
|
+
m = max(1, len(items[gi].levels))
|
|
1575
|
+
_sp, tb, grid, rep = _grid_of(d, ti)
|
|
1576
|
+
ph2, base2 = _pf_bounds(tb, grid, rep)
|
|
1577
|
+
r0, h = _pf_groups(tb, ph2, base2)[gi]
|
|
1578
|
+
if has_sub:
|
|
1579
|
+
d, _fh, sub_sk = _reduce_to_first_subitem(d, ti, r0, h)
|
|
1580
|
+
if sub_sk:
|
|
1581
|
+
return data, False, [f"rubric ti={ti} group {gi}: {sub_sk[0]}"]
|
|
1582
|
+
_sp, tb, grid, rep = _grid_of(d, ti)
|
|
1583
|
+
ph2, base2 = _pf_bounds(tb, grid, rep)
|
|
1584
|
+
r0, h = _pf_groups(tb, ph2, base2)[gi]
|
|
1585
|
+
d, norm_sk = _normalize_ladder_group(d, ti, r0, h, m, desc_col, bat_col)
|
|
1586
|
+
if norm_sk:
|
|
1587
|
+
return data, False, [f"rubric ti={ti} group {gi}: {norm_sk[0]}"]
|
|
1588
|
+
|
|
1589
|
+
_sp, tb, grid, rep = _grid_of(d, ti)
|
|
1590
|
+
ph2, base2 = _pf_bounds(tb, grid, rep)
|
|
1591
|
+
gg = _pf_groups(tb, ph2, base2)
|
|
1592
|
+
cells: list[dict[str, Any]] = []
|
|
1593
|
+
for gi, (r0, _h) in enumerate(gg):
|
|
1594
|
+
lead = grid.get((r0, 0))
|
|
1595
|
+
if lead is not None:
|
|
1596
|
+
cells.append({"table_index": ti, "row": lead.row, "col": lead.col,
|
|
1597
|
+
"text": items[gi].name, "max_lines": 4})
|
|
1598
|
+
for k, (desc, score) in enumerate(items[gi].levels):
|
|
1599
|
+
r = r0 + k
|
|
1600
|
+
dc = grid.get((r, desc_col))
|
|
1601
|
+
if dc is not None:
|
|
1602
|
+
cells.append({"table_index": ti, "row": dc.row, "col": dc.col, "text": desc, "max_lines": 6})
|
|
1603
|
+
bc = grid.get((r, bat_col))
|
|
1604
|
+
if bc is not None and score:
|
|
1605
|
+
cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
|
|
1606
|
+
if has_sub:
|
|
1607
|
+
sc = grid.get((r0, 1))
|
|
1608
|
+
if sc is not None and sc.col == 1:
|
|
1609
|
+
cells.append({"table_index": ti, "row": sc.row, "col": 1, "text": "", "max_lines": 1})
|
|
1610
|
+
fr = fill_cells(d, cells)
|
|
1611
|
+
return fr.data, True, [s.reason for s in fr.skipped]
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def _fill_2022_base_scores(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
|
|
1615
|
+
"""Fill the 기본점수 / 장기 미인정 결석자 배점 cells (right-most column). The row
|
|
1616
|
+
labels already carry the form's standard text, so only the 배점 is spliced. 장기
|
|
1617
|
+
미인정 rows also contain '기본점수' (−1점), so match the more specific label first."""
|
|
1618
|
+
from .table_patch import fill_cells, _text_of
|
|
1619
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1620
|
+
bat_col = rep.col_count - 1
|
|
1621
|
+
base_row = long_row = None
|
|
1622
|
+
for r in range(rep.row_count):
|
|
1623
|
+
c0 = grid.get((r, 0))
|
|
1624
|
+
if c0 is None or c0.row != r:
|
|
1625
|
+
continue
|
|
1626
|
+
t = _text_of(tb[c0.start:c0.end])
|
|
1627
|
+
if "장기 미인정" in t or "장기미인정" in t:
|
|
1628
|
+
long_row = r
|
|
1629
|
+
elif "기본점수" in t:
|
|
1630
|
+
base_row = r
|
|
1631
|
+
cells: list[dict[str, Any]] = []
|
|
1632
|
+
for row, score in ((base_row, rub.base_score), (long_row, rub.long_score)):
|
|
1633
|
+
if row is None or not score:
|
|
1634
|
+
continue
|
|
1635
|
+
bc = grid.get((row, bat_col))
|
|
1636
|
+
if bc is not None:
|
|
1637
|
+
cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
|
|
1638
|
+
fr = fill_cells(data, cells)
|
|
1639
|
+
return fr.data, [s.reason for s in fr.skipped]
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
def _fill_rubric_detailed_2022(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, bool, list[str]]:
|
|
1643
|
+
"""Fill ONE 2022-개정 (평가 영역명) rubric with a detailed area's content: header
|
|
1644
|
+
values, drop the A~E block, grow the 평가요소 ladder to the element count, splice
|
|
1645
|
+
every level's criterion + 배점, and the 기본점수 배점. Returns (data, ladder_filled,
|
|
1646
|
+
notes)."""
|
|
1647
|
+
notes: list[str] = []
|
|
1648
|
+
data, n = _fill_2022_header(data, ti, rub); notes += n
|
|
1649
|
+
data, n = _delete_2022_ae_block(data, ti); notes += n
|
|
1650
|
+
items = rub.items
|
|
1651
|
+
data, n = _grow_ladder_groups(data, ti, len(items)); notes += n
|
|
1652
|
+
data, filled, n = _fill_2022_ladder_detailed(data, ti, items); notes += n
|
|
1653
|
+
data, n = _fill_2022_base_scores(data, ti, rub); notes += n
|
|
1654
|
+
data = _prune_header_empty_bullets(data, ti)
|
|
1655
|
+
return data, filled, notes
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
def _split_criteria(criteria: str) -> tuple[str, str, str]:
|
|
1659
|
+
"""Split a 평가기준(상/중/하) bullet ('상=… / 중=… / 하=…') into its three
|
|
1660
|
+
descriptors (verbatim, '' for any absent level)."""
|
|
1661
|
+
out = {}
|
|
1662
|
+
for key in ("상", "중", "하"):
|
|
1663
|
+
m = re.search(rf"{key}\s*=\s*(.+?)(?=\s*[//]\s*[상중하]\s*=|$)", criteria or "")
|
|
1664
|
+
out[key] = _strip_inline_md(m.group(1)) if m else ""
|
|
1665
|
+
return out["상"], out["중"], out["하"]
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def _3hak_ladder_bounds(tb: bytes, grid: Any, rep: Any) -> tuple[int | None, int | None]:
|
|
1669
|
+
"""(영역(만점) header row, 기본점수 row) of a 2015-개정 rubric ladder."""
|
|
1670
|
+
from .table_patch import _text_of
|
|
1671
|
+
hr = br = None
|
|
1672
|
+
for r in range(rep.row_count):
|
|
1673
|
+
c0 = grid.get((r, 0))
|
|
1674
|
+
if c0 is not None and c0.row == r:
|
|
1675
|
+
t = _text_of(tb[c0.start:c0.end]).replace(" ", "")
|
|
1676
|
+
if "영역" in t and "만점" in t:
|
|
1677
|
+
hr = r
|
|
1678
|
+
c1 = grid.get((r, 1))
|
|
1679
|
+
if c1 is not None and c1.row == r and br is None and "기본점수" in _text_of(tb[c1.start:c1.end]):
|
|
1680
|
+
br = r
|
|
1681
|
+
return hr, br
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def _fill_3hak_header(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
|
|
1685
|
+
"""Fill the 교육과정성취기준 codes, the 평가기준 상/중/하 descriptors, and 학생 유의
|
|
1686
|
+
사항 — located by label geometry."""
|
|
1687
|
+
from .table_patch import fill_cells
|
|
1688
|
+
|
|
1689
|
+
def _n(t: str) -> str:
|
|
1690
|
+
return t.replace(" ", "").strip()
|
|
1691
|
+
|
|
1692
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1693
|
+
sang, jung, ha = _split_criteria(rub.criteria)
|
|
1694
|
+
specs = [
|
|
1695
|
+
(lambda t: _n(t) in ("교육과정성취기준", "성취기준"), rub.standards, 3),
|
|
1696
|
+
(lambda t: _n(t) == "상", sang, 4),
|
|
1697
|
+
(lambda t: _n(t) == "중", jung, 4),
|
|
1698
|
+
(lambda t: _n(t) == "하", ha, 4),
|
|
1699
|
+
(lambda t: _n(t) == "학생유의사항", rub.student_notes, 4),
|
|
1700
|
+
]
|
|
1701
|
+
cells: list[dict[str, Any]] = []
|
|
1702
|
+
for pred, val, ml in specs:
|
|
1703
|
+
if not val:
|
|
1704
|
+
continue
|
|
1705
|
+
_r, _lc, vc = _row_label_value(grid, rep, tb, pred)
|
|
1706
|
+
if vc is not None:
|
|
1707
|
+
cells.append({"table_index": ti, "row": vc.row, "col": vc.col, "text": val, "max_lines": ml})
|
|
1708
|
+
fr = fill_cells(data, cells)
|
|
1709
|
+
return fr.data, [s.reason for s in fr.skipped]
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def _fill_3hak_base_scores(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
|
|
1713
|
+
from .table_patch import fill_cells, _text_of
|
|
1714
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1715
|
+
bat_col = rep.col_count - 1
|
|
1716
|
+
base_row = long_row = None
|
|
1717
|
+
for r in range(rep.row_count):
|
|
1718
|
+
c1 = grid.get((r, 1))
|
|
1719
|
+
if c1 is None or c1.row != r:
|
|
1720
|
+
continue
|
|
1721
|
+
t = _text_of(tb[c1.start:c1.end])
|
|
1722
|
+
if "장기 미인정" in t or "장기미인정" in t:
|
|
1723
|
+
long_row = r
|
|
1724
|
+
elif "기본점수" in t:
|
|
1725
|
+
base_row = r
|
|
1726
|
+
cells: list[dict[str, Any]] = []
|
|
1727
|
+
for row, score in ((base_row, rub.base_score), (long_row, rub.long_score)):
|
|
1728
|
+
if row is None or not score:
|
|
1729
|
+
continue
|
|
1730
|
+
bc = grid.get((row, bat_col))
|
|
1731
|
+
if bc is not None:
|
|
1732
|
+
cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
|
|
1733
|
+
fr = fill_cells(data, cells)
|
|
1734
|
+
return fr.data, [s.reason for s in fr.skipped]
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def _build_3hak_ladder(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, bool, list[str]]:
|
|
1738
|
+
"""Reshape the 5-column 영역(만점)|평가항목|평가요소|채점 기준|배점 ladder to one physical
|
|
1739
|
+
row per MD level, then subdivide the single 평가요소 merge into per-element blocks and
|
|
1740
|
+
the 평가항목 merge into per-세부영역 blocks (``split_cell_vertical``, byte-preserving).
|
|
1741
|
+
Leaders land on each block's top row; 채점 기준 + 배점 land on every level row."""
|
|
1742
|
+
from .table_patch import apply_table_ops
|
|
1743
|
+
|
|
1744
|
+
subareas = [(sa.label, sa.points, [it for it in sa.items if not it.subtotal])
|
|
1745
|
+
for sa in rub.subareas]
|
|
1746
|
+
subareas = [(lbl, pts, its) for lbl, pts, its in subareas if its]
|
|
1747
|
+
if not subareas:
|
|
1748
|
+
return data, False, [f"rubric ti={ti}: 3학년 rubric has no scored elements"]
|
|
1749
|
+
|
|
1750
|
+
flat: list[tuple[str, str]] = []
|
|
1751
|
+
item_sizes: list[int] = []
|
|
1752
|
+
subarea_sizes: list[int] = []
|
|
1753
|
+
item_leaders: list[tuple[int, str]] = []
|
|
1754
|
+
subarea_leaders: list[tuple[int, str]] = []
|
|
1755
|
+
off = 0
|
|
1756
|
+
for lbl, pts, its in subareas:
|
|
1757
|
+
sa_start, sa_total = off, 0
|
|
1758
|
+
for it in its:
|
|
1759
|
+
levels = it.levels or [("", "")]
|
|
1760
|
+
item_leaders.append((off, it.name))
|
|
1761
|
+
item_sizes.append(len(levels))
|
|
1762
|
+
flat.extend(levels)
|
|
1763
|
+
off += len(levels)
|
|
1764
|
+
sa_total += len(levels)
|
|
1765
|
+
subarea_leaders.append((sa_start, f"{lbl} ({pts}점)" if lbl else ""))
|
|
1766
|
+
subarea_sizes.append(sa_total)
|
|
1767
|
+
n = off
|
|
1768
|
+
|
|
1769
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1770
|
+
hr, br = _3hak_ladder_bounds(tb, grid, rep)
|
|
1771
|
+
if hr is None or br is None:
|
|
1772
|
+
return data, False, [f"rubric ti={ti}: no 영역(만점)/기본점수 ladder bounds"]
|
|
1773
|
+
body0 = hr + 1
|
|
1774
|
+
cur = br - body0
|
|
1775
|
+
# Grow / shrink the body to n rows. Clone a COVERED body row (body0+1): its cells are
|
|
1776
|
+
# rowSpan==1 (채점 기준·배점) while col0/1/2 merges span across it and auto-extend.
|
|
1777
|
+
if n > cur:
|
|
1778
|
+
res = apply_table_ops(data, [{"op": "insert_row_by_clone", "table_index": ti,
|
|
1779
|
+
"ref_row": body0 + 1, "count": n - cur}])
|
|
1780
|
+
if not res.ok:
|
|
1781
|
+
return data, False, [f"rubric ti={ti}: grow ladder to {n} rows: {[s.reason for s in res.skipped]}"]
|
|
1782
|
+
data = res.data
|
|
1783
|
+
elif n < cur:
|
|
1784
|
+
res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti,
|
|
1785
|
+
"rows": list(range(body0 + n, br))}])
|
|
1786
|
+
if not res.ok:
|
|
1787
|
+
return data, False, [f"rubric ti={ti}: shrink ladder to {n} rows: {[s.reason for s in res.skipped]}"]
|
|
1788
|
+
data = res.data
|
|
1789
|
+
|
|
1790
|
+
# Subdivide 평가요소 (col2) per element and 평가항목 (col1) per 세부영역.
|
|
1791
|
+
for col, sizes in ((2, item_sizes), (1, subarea_sizes)):
|
|
1792
|
+
if len(sizes) > 1:
|
|
1793
|
+
res = apply_table_ops(data, [{"op": "split_cell_vertical", "table_index": ti,
|
|
1794
|
+
"row": body0, "col": col, "sizes": sizes}])
|
|
1795
|
+
if not res.ok:
|
|
1796
|
+
return data, False, [f"rubric ti={ti}: split col{col} {sizes}: {[s.reason for s in res.skipped]}"]
|
|
1797
|
+
data = res.data
|
|
1798
|
+
|
|
1799
|
+
from .table_patch import fill_cells
|
|
1800
|
+
_sp, tb, grid, rep = _grid_of(data, ti)
|
|
1801
|
+
hr, _br = _3hak_ladder_bounds(tb, grid, rep)
|
|
1802
|
+
body0 = hr + 1
|
|
1803
|
+
bat_col = rep.col_count - 1
|
|
1804
|
+
cells: list[dict[str, Any]] = []
|
|
1805
|
+
c0 = grid.get((body0, 0))
|
|
1806
|
+
if c0 is not None:
|
|
1807
|
+
cells.append({"table_index": ti, "row": c0.row, "col": 0,
|
|
1808
|
+
"text": f"{rub.title}({rub.points}점)", "max_lines": 4})
|
|
1809
|
+
for roff, lbl in subarea_leaders:
|
|
1810
|
+
c = grid.get((body0 + roff, 1))
|
|
1811
|
+
if c is not None and lbl:
|
|
1812
|
+
cells.append({"table_index": ti, "row": c.row, "col": 1, "text": lbl, "max_lines": 4})
|
|
1813
|
+
for roff, name in item_leaders:
|
|
1814
|
+
c = grid.get((body0 + roff, 2))
|
|
1815
|
+
if c is not None:
|
|
1816
|
+
cells.append({"table_index": ti, "row": c.row, "col": 2, "text": name, "max_lines": 4})
|
|
1817
|
+
for k, (desc, score) in enumerate(flat):
|
|
1818
|
+
c3 = grid.get((body0 + k, 3))
|
|
1819
|
+
if c3 is not None:
|
|
1820
|
+
cells.append({"table_index": ti, "row": c3.row, "col": c3.col, "text": desc, "max_lines": 6})
|
|
1821
|
+
c4 = grid.get((body0 + k, bat_col))
|
|
1822
|
+
if c4 is not None and score:
|
|
1823
|
+
cells.append({"table_index": ti, "row": c4.row, "col": c4.col, "text": score, "max_lines": 1})
|
|
1824
|
+
fr = fill_cells(data, cells)
|
|
1825
|
+
return fr.data, True, [s.reason for s in fr.skipped]
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def _fill_rubric_detailed_3hak(
|
|
1829
|
+
data: bytes, ti: int, rub: Rubric, content: EvalPlanContent
|
|
1830
|
+
) -> tuple[bytes, bool, list[str]]:
|
|
1831
|
+
"""Fill ONE 2015-개정 (교육과정성취기준) rubric with a detailed area's content: header
|
|
1832
|
+
(성취기준 / 평가기준 상·중·하 / 학생 유의사항), the nested 배점 ladder, and the 기본점수
|
|
1833
|
+
배점. Returns (data, ladder_filled, notes)."""
|
|
1834
|
+
notes: list[str] = []
|
|
1835
|
+
data, n = _fill_3hak_header(data, ti, rub); notes += n
|
|
1836
|
+
data, ok, n = _build_3hak_ladder(data, ti, rub); notes += n
|
|
1837
|
+
data, n = _fill_3hak_base_scores(data, ti, rub); notes += n
|
|
1838
|
+
data = _prune_header_empty_bullets(data, ti)
|
|
1839
|
+
return data, ok, notes
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
def _empty_trailing_para_spans(cell_xml: bytes) -> list[tuple[int, int]]:
|
|
1843
|
+
"""Byte spans of every empty ``<hp:p>`` after the first in a cell — the donor
|
|
1844
|
+
template's leftover list-bullet paragraphs, which render as stray/red bullets once
|
|
1845
|
+
the MD text is spliced into the first paragraph. The first paragraph is always
|
|
1846
|
+
kept (a cell needs one)."""
|
|
1847
|
+
s = cell_xml.decode("utf-8")
|
|
1848
|
+
spans: list[tuple[int, int]] = []
|
|
1849
|
+
for i, m in enumerate(re.finditer(r"<hp:p\b.*?</hp:p>", s, re.S)):
|
|
1850
|
+
if i == 0:
|
|
1851
|
+
continue
|
|
1852
|
+
txt = "".join(re.findall(r"<hp:t\b[^>]*>(.*?)</hp:t>", m.group(0), re.S))
|
|
1853
|
+
if not txt.strip():
|
|
1854
|
+
spans.append((len(s[:m.start()].encode("utf-8")), len(s[:m.end()].encode("utf-8"))))
|
|
1855
|
+
return spans
|
|
1856
|
+
|
|
1857
|
+
|
|
1858
|
+
def _prune_header_empty_bullets(data: bytes, ti: int) -> bytes:
|
|
1859
|
+
"""Drop the empty trailing bullet paragraphs left in a rubric's 수행과제 / 학생 유의
|
|
1860
|
+
사항 value cells (byte-preserving: removes whole empty ``<hp:p>`` elements, rewrites
|
|
1861
|
+
only the affected section part)."""
|
|
1862
|
+
from .table_patch import _sections, _iter_table_spans, build_grid, _text_of
|
|
1863
|
+
from .patch import _rewrite_zip_entries
|
|
1864
|
+
|
|
1865
|
+
idx = ti
|
|
1866
|
+
for sp, section in sorted(_sections(data).items()):
|
|
1867
|
+
spans = _iter_table_spans(section)
|
|
1868
|
+
if idx >= len(spans):
|
|
1869
|
+
idx -= len(spans)
|
|
1870
|
+
continue
|
|
1871
|
+
ts, te = spans[idx]
|
|
1872
|
+
tb = section[ts:te]
|
|
1873
|
+
grid, rep = build_grid(tb)
|
|
1874
|
+
edits: list[tuple[int, int]] = []
|
|
1875
|
+
for r in range(rep.row_count):
|
|
1876
|
+
for cprime in range(rep.col_count):
|
|
1877
|
+
cell = grid.get((r, cprime))
|
|
1878
|
+
if cell is None or (cell.row, cell.col) != (r, cprime):
|
|
1879
|
+
continue
|
|
1880
|
+
if _text_of(tb[cell.start:cell.end]).replace(" ", "").strip() not in ("수행과제", "학생유의사항"):
|
|
1881
|
+
continue
|
|
1882
|
+
vc = grid.get((r, cell.col + cell.col_span))
|
|
1883
|
+
if vc is None:
|
|
1884
|
+
continue
|
|
1885
|
+
for a, b in _empty_trailing_para_spans(tb[vc.start:vc.end]):
|
|
1886
|
+
edits.append((ts + vc.start + a, ts + vc.start + b))
|
|
1887
|
+
if not edits:
|
|
1888
|
+
return data
|
|
1889
|
+
new_section = section
|
|
1890
|
+
for a, b in sorted(edits, reverse=True):
|
|
1891
|
+
new_section = new_section[:a] + new_section[b:]
|
|
1892
|
+
return _rewrite_zip_entries(data, {sp: new_section})
|
|
1893
|
+
return data
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
def _fill_rubrics_detailed(
|
|
1897
|
+
data: bytes, content: EvalPlanContent, report: dict[str, Any]
|
|
1898
|
+
) -> tuple[bytes, dict[str, Any]]:
|
|
1899
|
+
"""Fill every §7 rubric from a detailed area. Routes by blank shape: 평가 영역명
|
|
1900
|
+
tables (2학년) → :func:`_fill_rubric_detailed_2022`; 교육과정성취기준 tables (3학년) →
|
|
1901
|
+
:func:`_fill_rubric_detailed_3hak`."""
|
|
1902
|
+
filled = 0
|
|
1903
|
+
for i, rub in enumerate(content.rubrics):
|
|
1904
|
+
idxs = _rubric_indices(data)
|
|
1905
|
+
if i >= len(idxs):
|
|
1906
|
+
report["skipped"].append(f"rubric {i}: no matching blank table")
|
|
1907
|
+
continue
|
|
1908
|
+
ti = idxs[i]
|
|
1909
|
+
if _rubric_is_2022(data, ti):
|
|
1910
|
+
data, ok, notes = _fill_rubric_detailed_2022(data, ti, rub)
|
|
1911
|
+
else:
|
|
1912
|
+
data, ok, notes = _fill_rubric_detailed_3hak(data, ti, rub, content)
|
|
1913
|
+
report["skipped"].extend(f"rubric {i}: {n}" for n in notes)
|
|
1914
|
+
if ok:
|
|
1915
|
+
filled += 1
|
|
1916
|
+
report["filled"] = filled
|
|
1917
|
+
return data, report
|
|
1918
|
+
|
|
1919
|
+
|
|
1165
1920
|
def fill_rubrics(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
|
|
1166
1921
|
"""Fill each 수행평가 세부기준 rubric table from the matching review rubric
|
|
1167
1922
|
(byte-preserving). Replaces the sample 성취기준 codes / 평가항목 labels.
|
|
@@ -1175,6 +1930,12 @@ def fill_rubrics(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str
|
|
|
1175
1930
|
|
|
1176
1931
|
report: dict[str, Any] = {"rubrics": len(content.rubrics), "filled": 0, "skipped": []}
|
|
1177
1932
|
|
|
1933
|
+
# Detailed route (current 평가계획 MD): each area carries a per-element 배점 ladder
|
|
1934
|
+
# that the summary routes below can't represent. Grows the blank's 평가요소 ladder to
|
|
1935
|
+
# the MD's element count and splices every observable criterion + 배점 verbatim.
|
|
1936
|
+
if content.rubrics and content.rubrics[0].detailed:
|
|
1937
|
+
return _fill_rubrics_detailed(data, content, report)
|
|
1938
|
+
|
|
1178
1939
|
# 2022-개정 route: fill each rubric table's label cells + 채점기준 ladder + heading.
|
|
1179
1940
|
idxs0 = _rubric_indices(data)
|
|
1180
1941
|
if idxs0 and _rubric_is_2022(data, idxs0[0]):
|
|
@@ -1480,20 +2241,53 @@ def fill_sections(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[st
|
|
|
1480
2241
|
return pres.data, report
|
|
1481
2242
|
|
|
1482
2243
|
|
|
2244
|
+
def _flatten_schedule_merges(data: bytes, ti: int) -> tuple[bytes, list[str]]:
|
|
2245
|
+
"""Split every vertical merge in the schedule's *data* rows into unit rows.
|
|
2246
|
+
|
|
2247
|
+
The donor ships a subject sample whose 단원명/성취기준 may span two weeks (a
|
|
2248
|
+
``rowSpan==2`` cell). The review has distinct content per week, so a naive
|
|
2249
|
+
one-row-per-week fill writes the first week into the merged cell and can only
|
|
2250
|
+
*skip* the second (its cell is covered) -- the second week's 단원/성취기준 is
|
|
2251
|
+
absorbed. Splitting each vertical merge into unit cells (byte-preserving, the
|
|
2252
|
+
original formatting cloned onto the new cell) restores the 1:1 mapping so no
|
|
2253
|
+
row is absorbed. Header row (0) is left untouched. Content-agnostic."""
|
|
2254
|
+
from .table_patch import apply_table_ops, _direct_cells
|
|
2255
|
+
notes: list[str] = []
|
|
2256
|
+
while True:
|
|
2257
|
+
_sp, tb, _grid, _rep = _grid_of(data, ti)
|
|
2258
|
+
merge = next(((c.row, c.col, c.row_span)
|
|
2259
|
+
for c in sorted(_direct_cells(tb), key=lambda c: (c.row, c.col))
|
|
2260
|
+
if c.row >= 1 and c.row_span > 1), None)
|
|
2261
|
+
if merge is None:
|
|
2262
|
+
break
|
|
2263
|
+
r, col, rs = merge
|
|
2264
|
+
res = apply_table_ops(data, [{"op": "split_cell_vertical", "table_index": ti,
|
|
2265
|
+
"row": r, "col": col, "sizes": [1] * rs}])
|
|
2266
|
+
if not res.ok:
|
|
2267
|
+
notes.append(f"split donor merge r{r}c{col} (rowSpan {rs}) refused: "
|
|
2268
|
+
f"{[s.reason for s in res.skipped]}")
|
|
2269
|
+
break
|
|
2270
|
+
data = res.data
|
|
2271
|
+
notes.append(f"split donor merge r{r}c{col} (rowSpan {rs}) into {rs} unit rows")
|
|
2272
|
+
return data, notes
|
|
2273
|
+
|
|
2274
|
+
|
|
1483
2275
|
def fill_schedule(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
|
|
1484
2276
|
"""Fill the Ⅰ 교수학습 운영 계획 schedule table from ``content.schedule`` (one
|
|
1485
2277
|
logical row per review row, 6 columns), shrink-to-fit at 4 lines. Located by
|
|
1486
|
-
the widest multi-row data table under the Ⅰ heading.
|
|
1487
|
-
|
|
2278
|
+
the widest multi-row data table under the Ⅰ heading. Any vertical merge a donor
|
|
2279
|
+
sample left in the data rows is split first so every review row maps to its own
|
|
2280
|
+
form row (no absorption)."""
|
|
1488
2281
|
from .table_patch import fill_cells
|
|
1489
2282
|
|
|
1490
|
-
report: dict[str, Any] = {"rows": len(content.schedule), "filled": 0, "skipped": []}
|
|
2283
|
+
report: dict[str, Any] = {"rows": len(content.schedule), "filled": 0, "skipped": [], "unmerged": []}
|
|
1491
2284
|
if not content.schedule:
|
|
1492
2285
|
return data, report
|
|
1493
2286
|
ti = _schedule_index(data)
|
|
1494
2287
|
if ti is None:
|
|
1495
2288
|
report["skipped"].append("no schedule table found")
|
|
1496
2289
|
return data, report
|
|
2290
|
+
data, report["unmerged"] = _flatten_schedule_merges(data, ti)
|
|
1497
2291
|
cells = []
|
|
1498
2292
|
for i, row in enumerate(content.schedule):
|
|
1499
2293
|
r = i + 1 # row 0 is the header
|
|
@@ -1708,6 +2502,171 @@ def fill_ratio(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str,
|
|
|
1708
2502
|
return fr.data, report
|
|
1709
2503
|
|
|
1710
2504
|
|
|
2505
|
+
def finalize_evalplan(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
|
|
2506
|
+
"""Deterministic post-fill cleanup: turn a *filled* donor 평가계획 form into a
|
|
2507
|
+
submittable 채움본 without a bespoke driver script.
|
|
2508
|
+
|
|
2509
|
+
Runs the recipe's mechanical steps -- fill the title / teacher / 정의적 cells,
|
|
2510
|
+
prune the donor's instruction scaffolding and orphaned sample headings (bounded
|
|
2511
|
+
by the form's OWN section headings, never by subject or grade), strip the red
|
|
2512
|
+
guidance runs, recolor filled blue slots to body black, and remove trailing
|
|
2513
|
+
table captions. Every decision is structure/pattern based; no subject or grade
|
|
2514
|
+
string appears. Genuine ambiguity (novel instruction phrasing, non-1:1 정의적
|
|
2515
|
+
cell mapping, tie-broken 변형 선택) is intentionally NOT resolved here -- that is
|
|
2516
|
+
the skill's (LLM) judgment.
|
|
2517
|
+
|
|
2518
|
+
``data`` must already be a :func:`fill_evalplan` (``phase="all"``) output; the
|
|
2519
|
+
deletes are computed while the red guidance runs are still intact so a black
|
|
2520
|
+
ordinal sharing a paragraph with a red instruction is removed as a unit. Returns
|
|
2521
|
+
``(clean_bytes, report)``."""
|
|
2522
|
+
import html
|
|
2523
|
+
import io
|
|
2524
|
+
import zipfile
|
|
2525
|
+
|
|
2526
|
+
from .body_patch import (
|
|
2527
|
+
apply_body_ops,
|
|
2528
|
+
direct_paragraph_spans,
|
|
2529
|
+
recolor_runs_by_color,
|
|
2530
|
+
strip_runs_by_color,
|
|
2531
|
+
)
|
|
2532
|
+
from .formfill_quality import _tables
|
|
2533
|
+
from .guidance_scan import is_form_instruction
|
|
2534
|
+
from .table_patch import fill_cells, strip_trailing_table_captions
|
|
2535
|
+
|
|
2536
|
+
T = re.compile(r"<hp:t(?:\s[^>]*)?>(.*?)</hp:t>", re.S)
|
|
2537
|
+
RUN = re.compile(r"<hp:run\b[^>]*?>.*?</hp:run>", re.S)
|
|
2538
|
+
ORDINAL = re.compile(r"^[가-하]\.?$|^\(\d+\)$|^\*+$|^[·・\s]*$")
|
|
2539
|
+
# legit 평가계획 notes that sit among instructions -- form-generic, never a
|
|
2540
|
+
# subject/grade token, so keeping them is not hardcoding a subject.
|
|
2541
|
+
KEEP = ("가급적 동점자",)
|
|
2542
|
+
|
|
2543
|
+
report: dict[str, Any] = {
|
|
2544
|
+
"deleted": 0, "skipped": [], "captions": 0,
|
|
2545
|
+
"title": False, "teacher": False, "affective_rows": 0,
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
def _sec_hdr(d: bytes) -> tuple[str, str]:
|
|
2549
|
+
z = zipfile.ZipFile(io.BytesIO(d))
|
|
2550
|
+
sec = z.read("Contents/section0.xml").decode("utf-8")
|
|
2551
|
+
hdr_name = next(n for n in z.namelist() if n.endswith("header.xml"))
|
|
2552
|
+
return sec, z.read(hdr_name).decode("utf-8")
|
|
2553
|
+
|
|
2554
|
+
def _red_ids(hdr: str) -> set[str]:
|
|
2555
|
+
out: set[str] = set()
|
|
2556
|
+
for cm in re.finditer(
|
|
2557
|
+
r"<hh:charPr\b[^>]*\bid=\"(\d+)\"[^>]*textColor=\"([#0-9A-Fa-f]+)\"", hdr
|
|
2558
|
+
):
|
|
2559
|
+
if cm.group(2).upper() == "#FF0000":
|
|
2560
|
+
out.add(cm.group(1))
|
|
2561
|
+
return out
|
|
2562
|
+
|
|
2563
|
+
def _run_texts(block: str, reds: set[str]) -> tuple[str, str]:
|
|
2564
|
+
black, red = [], []
|
|
2565
|
+
for rm in RUN.finditer(block):
|
|
2566
|
+
run = rm.group(0)
|
|
2567
|
+
txt = html.unescape("".join(T.findall(run)))
|
|
2568
|
+
cid = re.search(r'charPrIDRef="(\d+)"', run)
|
|
2569
|
+
(red if (cid and cid.group(1) in reds) else black).append(txt)
|
|
2570
|
+
return "".join(black), "".join(red)
|
|
2571
|
+
|
|
2572
|
+
# ---- paragraph deletes computed on the FILLED bytes (red still intact) ------
|
|
2573
|
+
sec, hdr = _sec_hdr(data)
|
|
2574
|
+
reds = _red_ids(hdr)
|
|
2575
|
+
spans = direct_paragraph_spans(sec)
|
|
2576
|
+
blocks = [sec[a:b] for (a, b) in spans]
|
|
2577
|
+
texts = [html.unescape("".join(T.findall(bl))).strip() for bl in blocks]
|
|
2578
|
+
has_tbl = ["<hp:tbl" in bl for bl in blocks]
|
|
2579
|
+
del_idx: set[int] = set()
|
|
2580
|
+
|
|
2581
|
+
def first(pred, start: int = 0) -> int | None:
|
|
2582
|
+
return next((i for i in range(start, len(texts)) if pred(i)), None)
|
|
2583
|
+
|
|
2584
|
+
# (2e) 성취수준별 고정분할점수 labels -- red form scaffolding; delete all (the
|
|
2585
|
+
# engine already kept the single subject-matching 성취율 table by band count).
|
|
2586
|
+
del_idx.update(
|
|
2587
|
+
i for i in range(len(texts))
|
|
2588
|
+
if not has_tbl[i] and texts[i].startswith("성취수준별 고정분할점수")
|
|
2589
|
+
)
|
|
2590
|
+
|
|
2591
|
+
# (2a) 최소 성취수준 orphan heading block -- its table was pruned (공통과목 전용);
|
|
2592
|
+
# delete the non-table paragraphs from the heading down to the §5 table.
|
|
2593
|
+
ms = first(lambda i: not has_tbl[i] and texts[i].startswith("다. 최소 성취수준"))
|
|
2594
|
+
sec5 = first(lambda i: has_tbl[i] and "기준 성취율과 성취도" in texts[i])
|
|
2595
|
+
if ms is not None and sec5 is not None:
|
|
2596
|
+
del_idx.update(i for i in range(ms, sec5) if not has_tbl[i])
|
|
2597
|
+
|
|
2598
|
+
# (2f) foreign donor sample headings between the §7 and §8 tables.
|
|
2599
|
+
s7 = first(lambda i: has_tbl[i] and "수행평가 세부기준" in texts[i])
|
|
2600
|
+
s8 = first(lambda i: has_tbl[i] and "정의적 능력 평가" in texts[i],
|
|
2601
|
+
start=(s7 + 1) if s7 is not None else 0)
|
|
2602
|
+
if s7 is not None and s8 is not None:
|
|
2603
|
+
del_idx.update(i for i in range(s7 + 1, s8) if not has_tbl[i] and texts[i])
|
|
2604
|
+
|
|
2605
|
+
# (2b) foreign "(N) area" sub-headers between §4 and §5 (donor sample labels;
|
|
2606
|
+
# the MD's §4 uses 가./나. markers, never "(N) word", so this is safe).
|
|
2607
|
+
s4h = first(lambda i: has_tbl[i] and "성취기준 및 성취수준" in texts[i])
|
|
2608
|
+
s5h = first(lambda i: has_tbl[i] and "기준 성취율과 성취도" in texts[i],
|
|
2609
|
+
start=(s4h + 1) if s4h is not None else 0)
|
|
2610
|
+
if s4h is not None and s5h is not None:
|
|
2611
|
+
del_idx.update(
|
|
2612
|
+
i for i in range(s4h + 1, s5h)
|
|
2613
|
+
if not has_tbl[i] and re.match(r"^\(\d+\)\s*\S", texts[i])
|
|
2614
|
+
)
|
|
2615
|
+
|
|
2616
|
+
# (2c) red-guidance-with-trivial-black + black instruction paragraphs.
|
|
2617
|
+
for i, bl in enumerate(blocks):
|
|
2618
|
+
if has_tbl[i] or i in del_idx or any(k in texts[i] for k in KEEP):
|
|
2619
|
+
continue
|
|
2620
|
+
black_txt, red_txt = _run_texts(bl, reds)
|
|
2621
|
+
if red_txt.strip() and ORDINAL.match(black_txt.strip()):
|
|
2622
|
+
del_idx.add(i)
|
|
2623
|
+
elif is_form_instruction(texts[i]):
|
|
2624
|
+
del_idx.add(i)
|
|
2625
|
+
|
|
2626
|
+
# ---- title + §8 정의적 + teacher fills (paragraph count unchanged) -----------
|
|
2627
|
+
cells: list[dict[str, Any]] = []
|
|
2628
|
+
if content.title:
|
|
2629
|
+
cells.append({"table_index": 0, "row": 0, "col": 0, "text": content.title})
|
|
2630
|
+
report["title"] = True
|
|
2631
|
+
# §8 정의적 능력 평가 표: replace the donor sample with the MD's 측면 rows (1:1).
|
|
2632
|
+
aff = re.findall(
|
|
2633
|
+
r"(\S+적 측면\([^)]*\))\s*[::]\s*(.+?)(?=\s*-\s*\S+적 측면|$)", content.affective or ""
|
|
2634
|
+
)
|
|
2635
|
+
aff_ti = next((gi for gi, t in enumerate(_tables(data)) if "정의적 능력 평가 요소" in t.text), None)
|
|
2636
|
+
if aff_ti is not None and aff:
|
|
2637
|
+
for r, (label, desc) in enumerate(aff[:3], start=1):
|
|
2638
|
+
cells.append({"table_index": aff_ti, "row": r, "col": 0, "text": label.strip(), "max_lines": 3})
|
|
2639
|
+
cells.append({"table_index": aff_ti, "row": r, "col": 1, "text": desc.strip(), "max_lines": 4})
|
|
2640
|
+
report["affective_rows"] = min(len(aff), 3)
|
|
2641
|
+
if cells:
|
|
2642
|
+
data = fill_cells(data, cells).data
|
|
2643
|
+
if content.teacher:
|
|
2644
|
+
# fill the placeholder, then recolor the filled value to body-black so the
|
|
2645
|
+
# red-strip below does not remove it; robust to the label+placeholder being
|
|
2646
|
+
# in one run (2학년) or split black label + red placeholder (3학년).
|
|
2647
|
+
data = apply_body_ops(data, [
|
|
2648
|
+
{"op": "replace_text", "find": "◯◯◯, ◯◯◯", "replace": content.teacher, "count": 1},
|
|
2649
|
+
{"op": "restyle_text", "find": content.teacher, "textColor": "#000000", "count": 1},
|
|
2650
|
+
{"op": "replace_text", "find": "(**)", "replace": "", "count": 4},
|
|
2651
|
+
]).data
|
|
2652
|
+
report["teacher"] = True
|
|
2653
|
+
|
|
2654
|
+
# ---- apply deletes (high->low so indices stay valid) ------------------------
|
|
2655
|
+
dr = apply_body_ops(data, [{"op": "delete_paragraph", "index": i}
|
|
2656
|
+
for i in sorted(del_idx, reverse=True)])
|
|
2657
|
+
data = dr.data
|
|
2658
|
+
report["deleted"] = len(del_idx) - len(dr.skipped)
|
|
2659
|
+
report["skipped"] = [str(s) for s in dr.skipped]
|
|
2660
|
+
|
|
2661
|
+
# ---- strip residual red, recolor filled blue->black, strip captions --------
|
|
2662
|
+
data = strip_runs_by_color(data, ["#FF0000"]).data
|
|
2663
|
+
data = recolor_runs_by_color(data, ["#0000FF"], "#000000").data
|
|
2664
|
+
cap = strip_trailing_table_captions(data)
|
|
2665
|
+
data = cap.data
|
|
2666
|
+
report["captions"] = len(cap.applied)
|
|
2667
|
+
return data, report
|
|
2668
|
+
|
|
2669
|
+
|
|
1711
2670
|
def fill_evalplan(
|
|
1712
2671
|
blank: str | Path,
|
|
1713
2672
|
content: EvalPlanContent,
|
|
@@ -1721,8 +2680,12 @@ def fill_evalplan(
|
|
|
1721
2680
|
정기시험 column, surplus example tables). ``phase="all"`` additionally runs the
|
|
1722
2681
|
content fills -- schedule, achievement, levels, rubrics, section prose -- each
|
|
1723
2682
|
byte-preserving and located by classification/geometry (index-safe against the
|
|
1724
|
-
prior structural deletes).
|
|
1725
|
-
|
|
2683
|
+
prior structural deletes). ``phase="clean"`` runs ``"all"`` and then
|
|
2684
|
+
:func:`finalize_evalplan` -- the deterministic post-fill cleanup (title/teacher/
|
|
2685
|
+
정의적 fills, scaffolding + orphan prune, red-strip, blue->black recolor, caption
|
|
2686
|
+
strip) that yields a submittable 채움본 with no bespoke driver. Returns the apply
|
|
2687
|
+
result dict plus the op-plan transcript and, for ``phase`` in ``{"all","clean"}``,
|
|
2688
|
+
a ``content_report`` per region (plus ``content_report["finalize"]`` for clean)."""
|
|
1726
2689
|
from .table_patch import apply_table_ops
|
|
1727
2690
|
|
|
1728
2691
|
plan = plan_structural_ops(blank, content)
|
|
@@ -1731,13 +2694,15 @@ def fill_evalplan(
|
|
|
1731
2694
|
payload = result.to_dict()
|
|
1732
2695
|
|
|
1733
2696
|
content_report: dict[str, Any] = {}
|
|
1734
|
-
if phase
|
|
2697
|
+
if phase in ("all", "clean"):
|
|
1735
2698
|
data, content_report["schedule"] = fill_schedule(data, content)
|
|
1736
2699
|
data, content_report["achievement"] = fill_achievement(data, content)
|
|
1737
2700
|
data, content_report["levels"] = fill_levels(data, content)
|
|
1738
2701
|
data, content_report["rubrics"] = fill_rubrics(data, content)
|
|
1739
2702
|
data, content_report["ratio"] = fill_ratio(data, content)
|
|
1740
2703
|
data, content_report["sections"] = fill_sections(data, content)
|
|
2704
|
+
if phase == "clean":
|
|
2705
|
+
data, content_report["finalize"] = finalize_evalplan(data, content)
|
|
1741
2706
|
|
|
1742
2707
|
if output is not None:
|
|
1743
2708
|
from pathlib import Path as _P
|
|
@@ -1756,7 +2721,7 @@ def parse_review_file(path: str | Path) -> EvalPlanContent:
|
|
|
1756
2721
|
|
|
1757
2722
|
__all__ = [
|
|
1758
2723
|
"EvalPlanContent", "Rubric", "parse_review_md", "parse_review_file",
|
|
1759
|
-
"expected_skeleton", "plan_structural_ops", "fill_evalplan",
|
|
2724
|
+
"expected_skeleton", "plan_structural_ops", "fill_evalplan", "finalize_evalplan",
|
|
1760
2725
|
"fill_schedule", "fill_achievement", "fill_levels", "fill_rubrics", "fill_ratio",
|
|
1761
2726
|
"fill_sections",
|
|
1762
2727
|
]
|
hwpx/guidance_scan.py
CHANGED
|
@@ -31,6 +31,7 @@ __all__ = [
|
|
|
31
31
|
"Candidate",
|
|
32
32
|
"GuidanceReport",
|
|
33
33
|
"scan_form_guidance",
|
|
34
|
+
"is_form_instruction",
|
|
34
35
|
]
|
|
35
36
|
|
|
36
37
|
# 도형 순회는 markdown_export와 동일 정책: rect/ellipse/polygon만.
|
|
@@ -49,6 +50,31 @@ _GUIDANCE_KEYWORD = re.compile(
|
|
|
49
50
|
)
|
|
50
51
|
_CONDITIONAL_CHOICE = re.compile(r"중에서.{0,20}(만\s*남기|남기고|선택)|해당하는\s*것만\s*남기")
|
|
51
52
|
|
|
53
|
+
# 검정(무색) 안내 문단까지 걸러내는 확장 렉시콘. _GUIDANCE_KEYWORD는 색 후보
|
|
54
|
+
# 스캔용이라 "교과별로 수정"·색 범례 문장 등 검정 지시문을 놓친다. 아래는 한국어
|
|
55
|
+
# 양식의 작성-안내 상투 문구(과목/학년 비의존)로, 색이 아니라 텍스트로만 지시문을
|
|
56
|
+
# 판정해야 하는 문단(빨강 지시는 색 strip이 처리)에 쓴다.
|
|
57
|
+
# 검정(무색) 안내 문단 판정용 지시-문구 렉시콘. "유의)"·"※" 같은 *모호한* 마커는
|
|
58
|
+
# 정상 유의사항 본문에도 쓰이므로 일부러 제외한다(over-삭제 방지) — 색으로 표시된
|
|
59
|
+
# 지시는 body_patch.strip_runs_by_color가 별도로 지운다.
|
|
60
|
+
_FORM_INSTRUCTION = re.compile(
|
|
61
|
+
r"삭제\s*바랍니다|삭제합니다|삭제!!|문의\s*주세요|"
|
|
62
|
+
r"작성해\s*주세요|작성해주세요|수정해\s*주세요|수정해주세요|맞추어\s*작성|"
|
|
63
|
+
r"교과별로\s*수정|교과별\s*특성이\s*드러나게|재구성하여\s*작성|이하는\s*작성|"
|
|
64
|
+
r"해당하는\s*것만\s*남기|추정분할점수|"
|
|
65
|
+
r"(검정|검은|빨간|빨강|파란|파랑)\s*(색\s*)?글씨|(빨간|파란)\s*글자"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_form_instruction(text: str) -> bool:
|
|
70
|
+
"""양식 작성-안내 문단(삭제 지시·색 범례·"교과별로 수정" 등)인지 판정한다.
|
|
71
|
+
|
|
72
|
+
과목/학년 비의존 — 한국어 양식 지시 상투 문구 렉시콘 기반이다. 색 정보 없이
|
|
73
|
+
텍스트만으로 지시문을 걸러야 하는 검정 안내 문단용이며, 색으로 표시된 지시는
|
|
74
|
+
``body_patch.strip_runs_by_color``가 별도로 처리한다. 렉시콘 밖의 *신규* 지시
|
|
75
|
+
표현은 이 예/아니오로 잡히지 않는다 — 그 판단은 스킬(LLM)의 몫이다."""
|
|
76
|
+
return bool(_FORM_INSTRUCTION.search(text or ""))
|
|
77
|
+
|
|
52
78
|
# ── 색 범례 ────────────────────────────────────────────────────────
|
|
53
79
|
_LEGEND_HINT = re.compile(r"글씨|글자")
|
|
54
80
|
_COLOR_WORDS: dict[str, str] = {
|
hwpx/table_patch.py
CHANGED
|
@@ -1760,8 +1760,87 @@ def table_summary(source: str | Path | bytes) -> list[dict[str, Any]]:
|
|
|
1760
1760
|
return out
|
|
1761
1761
|
|
|
1762
1762
|
|
|
1763
|
+
# --- trailing table-caption strip --------------------------------------------
|
|
1764
|
+
#
|
|
1765
|
+
# A donor 평가계획 form can bake a short "(N) <area>" sample caption as TRAILING
|
|
1766
|
+
# text of the same paragraph that wraps a table -- it sits between ``</hp:tbl>``
|
|
1767
|
+
# and that paragraph's ``<hp:linesegarray>``, so it is NOT a section-direct
|
|
1768
|
+
# paragraph that ``delete_paragraph`` can address. This removes only that caption
|
|
1769
|
+
# text (a ``(N) word`` group), closes the run cleanly right after the table, and
|
|
1770
|
+
# round-trips every other byte identical. Pattern-based, subject/grade agnostic.
|
|
1771
|
+
|
|
1772
|
+
_TC_T = re.compile(r"<(?:[A-Za-z_][\w.-]*:)?t\b[^>]*>(.*?)</(?:[A-Za-z_][\w.-]*:)?t>", re.S)
|
|
1773
|
+
_TC_AFTER_TBL = re.compile(
|
|
1774
|
+
r"</(?:[A-Za-z_][\w.-]*:)?tbl>(.*?)(?=<(?:[A-Za-z_][\w.-]*:)?linesegarray)", re.S
|
|
1775
|
+
)
|
|
1776
|
+
_TC_CAPTION = re.compile(r"\(\d+\)\s*\S{1,8}")
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def strip_trailing_table_captions(
|
|
1780
|
+
source: str | Path | bytes, *, output_path: str | Path | None = None
|
|
1781
|
+
) -> CellFillResult:
|
|
1782
|
+
"""Strip short ``(N) <word>`` captions baked as trailing text of a table's
|
|
1783
|
+
wrapper paragraph (between ``</hp:tbl>`` and its ``<hp:linesegarray>``).
|
|
1784
|
+
|
|
1785
|
+
Byte-preserving on every untouched part; namespace-prefix tolerant. Returns a
|
|
1786
|
+
:class:`CellFillResult` whose ``applied`` records each stripped caption. Only a
|
|
1787
|
+
parenthesised-ordinal + short word is removed; anything crossing a paragraph or
|
|
1788
|
+
table boundary is left intact (fail-closed)."""
|
|
1789
|
+
import html
|
|
1790
|
+
|
|
1791
|
+
source_bytes = _read_source_bytes(source)
|
|
1792
|
+
import io
|
|
1793
|
+
import zipfile
|
|
1794
|
+
|
|
1795
|
+
with zipfile.ZipFile(io.BytesIO(source_bytes), "r") as archive:
|
|
1796
|
+
section_names = [
|
|
1797
|
+
info.filename for info in archive.infolist()
|
|
1798
|
+
if not info.is_dir() and re.search(r"section\d+\.xml$", info.filename)
|
|
1799
|
+
]
|
|
1800
|
+
sections = {name: archive.read(name).decode("utf-8") for name in section_names}
|
|
1801
|
+
|
|
1802
|
+
applied: list[CellApplied] = []
|
|
1803
|
+
changed_parts: dict[str, bytes] = {}
|
|
1804
|
+
for name, sec in sections.items():
|
|
1805
|
+
hits: list[str] = []
|
|
1806
|
+
|
|
1807
|
+
def _strip(m: "re.Match[str]") -> str:
|
|
1808
|
+
stuff = m.group(1)
|
|
1809
|
+
# never cross a paragraph or table boundary
|
|
1810
|
+
if "</hp:p>" in stuff or "<hp:p" in stuff or "tbl>" in stuff or "<hp:tbl" in stuff:
|
|
1811
|
+
return m.group(0)
|
|
1812
|
+
txt = html.unescape("".join(_TC_T.findall(stuff))).strip()
|
|
1813
|
+
if re.fullmatch(_TC_CAPTION, txt):
|
|
1814
|
+
hits.append(txt)
|
|
1815
|
+
# drop the caption text + trailing runs; close the table's run cleanly
|
|
1816
|
+
return "</hp:tbl></hp:run>"
|
|
1817
|
+
return m.group(0)
|
|
1818
|
+
|
|
1819
|
+
new = _TC_AFTER_TBL.sub(_strip, sec)
|
|
1820
|
+
if new != sec:
|
|
1821
|
+
changed_parts[name] = new.encode("utf-8")
|
|
1822
|
+
for txt in hits:
|
|
1823
|
+
applied.append(CellApplied(name, -1, -1, -1, txt, ""))
|
|
1824
|
+
|
|
1825
|
+
if not changed_parts:
|
|
1826
|
+
open_safety, _ = _finalize(source_bytes, output_path, source=source)
|
|
1827
|
+
return CellFillResult(source_bytes, (), (), (), True, "none", open_safety)
|
|
1828
|
+
try:
|
|
1829
|
+
output = _patch_zip_entries(source_bytes, changed_parts)
|
|
1830
|
+
zip_method = "partial-local-record-copy"
|
|
1831
|
+
except ValueError:
|
|
1832
|
+
output = _rewrite_zip_entries(source_bytes, changed_parts)
|
|
1833
|
+
zip_method = "zipfile-rewrite-fallback"
|
|
1834
|
+
open_safety, _ = _finalize(output, output_path, source=source)
|
|
1835
|
+
return CellFillResult(
|
|
1836
|
+
output, tuple(applied), (), tuple(changed_parts), output == source_bytes,
|
|
1837
|
+
zip_method, open_safety,
|
|
1838
|
+
)
|
|
1839
|
+
|
|
1840
|
+
|
|
1763
1841
|
__all__ = [
|
|
1764
1842
|
"fill_cells", "build_grid", "GridReport", "CellFillResult", "CellApplied", "CellSkipped",
|
|
1765
1843
|
"ResolvedCellTarget", "resolve_cell_target",
|
|
1766
1844
|
"apply_table_ops", "TableStructureError", "verify_fill", "RenderCheckRequired", "table_summary",
|
|
1845
|
+
"strip_trailing_table_captions",
|
|
1767
1846
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-hwpx
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.0
|
|
4
4
|
Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리
|
|
5
5
|
Author: python-hwpx Maintainers
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -54,6 +54,7 @@ Dynamic: license-file
|
|
|
54
54
|
</p>
|
|
55
55
|
<p align="center">
|
|
56
56
|
<a href="https://pypi.org/project/python-hwpx/"><img src="https://img.shields.io/pypi/v/python-hwpx?color=blue&label=PyPI" alt="PyPI"></a>
|
|
57
|
+
<a href="https://pepy.tech/project/python-hwpx"><img src="https://static.pepy.tech/badge/python-hwpx/month" alt="Downloads"></a>
|
|
57
58
|
<a href="https://pypi.org/project/python-hwpx/"><img src="https://img.shields.io/pypi/pyversions/python-hwpx" alt="Python"></a>
|
|
58
59
|
<a href="https://github.com/airmang/python-hwpx/actions/workflows/tests.yml"><img src="https://img.shields.io/github/actions/workflow/status/airmang/python-hwpx/tests.yml?branch=main&label=tests" alt="Tests"></a>
|
|
59
60
|
<a href="https://airmang.github.io/python-hwpx/corpus-metrics.html"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fairmang.github.io%2Fpython-hwpx%2F_static%2Fbadge-hancom-open.json" alt="Hancom open"></a>
|
|
@@ -3,17 +3,17 @@ hwpx/authoring.py,sha256=BoWGNc50ALMSuyjFUGl1iMCLYb1rFllCKVcuugvsaOw,129001
|
|
|
3
3
|
hwpx/body_patch.py,sha256=yB3X0L3Jns38CpRFOvjvPMnovJ5a2SoVefh6FIy3uYQ,25736
|
|
4
4
|
hwpx/document.py,sha256=_a0snPKzdsdIL9AmzvQzvOloqXB0CVq89vx6GjnKeHw,59186
|
|
5
5
|
hwpx/errors.py,sha256=BgWhEFHiiiz9DoCNRwovHt95dz8HVzQx1C29PGfXMwQ,2619
|
|
6
|
-
hwpx/evalplan_fill.py,sha256=
|
|
6
|
+
hwpx/evalplan_fill.py,sha256=Nar0q2lkoCjNVHDaBegMywp1N1MrSGRMMd0UJgSp61I,130443
|
|
7
7
|
hwpx/experimental.py,sha256=H8fBrmGf0BzoDS6dqwBpUChUestUgOpO7XjMEBXsssg,1499
|
|
8
8
|
hwpx/fill_residue.py,sha256=Uq4DbwFNlbHrO3FQA7Yyk1I0gAsvPw6O8mPD82rmBws,8286
|
|
9
9
|
hwpx/form_fill.py,sha256=VUIU53Qa9Ho2aP72biDvJwnDW7ngdAzu3PSd5A7d1JM,9908
|
|
10
10
|
hwpx/formfill_quality.py,sha256=pRxZYb1cg95F2O78va5glMJ3UqPh1rewJB1Qz-LOlsc,38521
|
|
11
|
-
hwpx/guidance_scan.py,sha256=
|
|
11
|
+
hwpx/guidance_scan.py,sha256=EJXQlNKbHTHeqVeWQXSqryWJ-kzWMkHivOLUyZVxG5g,27039
|
|
12
12
|
hwpx/mutation_report.py,sha256=6hurhDdgGiONeLIeWZJT8lwLtWJZ1VHp1l6G0zy0mQo,19409
|
|
13
13
|
hwpx/package.py,sha256=0rKjGCJbPQvrVBIy07Jpjsu3fI7HhbqFCGWTiTDsJpo,1141
|
|
14
14
|
hwpx/patch.py,sha256=6MEVGAjpaWKK7Oyxydfvk52-VzSJfHi6I-eUCGJHZQM,24846
|
|
15
15
|
hwpx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
-
hwpx/table_patch.py,sha256=
|
|
16
|
+
hwpx/table_patch.py,sha256=Eu4mhZAzftYQpQyA3VFwDn9CSYChUDMvW3JQdb2Tobk,82297
|
|
17
17
|
hwpx/template_formfit.py,sha256=dlxf98FatT5Nl4hN15TuMXqj5NlDTGSnLaqXVo_PoBo,23424
|
|
18
18
|
hwpx/templates.py,sha256=28bYqeJVeDb1Cq8G9NZG9Mhnu4K2GamAKC4QhxvUZyA,1187
|
|
19
19
|
hwpx/_document/__init__.py,sha256=gJ7N5W4EANv_341NJCtbxSh0QrNIBtArRAnjJUe_PE4,98
|
|
@@ -209,10 +209,10 @@ hwpx/visual/page_qa.py,sha256=AuJCySfLIdXjPeekhAc3YnrIWOsN0sI35Pnz8BZ6Pro,7278
|
|
|
209
209
|
hwpx/visual/qa_contracts.py,sha256=1cjoiRTAWgi7NgE8SPc6bKxdoUfKx9nyou1mgC47TxY,10506
|
|
210
210
|
hwpx/visual/qa_metrics.py,sha256=I7RdybN-f1Fvcv2j-ceDPoek51nOkMui1Zrd7TEX_C8,9838
|
|
211
211
|
hwpx/visual/report.py,sha256=2RhXN1KBYOZTim9FNpeUhaaDHR7oFxI6Z2DLUkDIiwE,1717
|
|
212
|
-
python_hwpx-4.
|
|
213
|
-
python_hwpx-4.
|
|
214
|
-
python_hwpx-4.
|
|
215
|
-
python_hwpx-4.
|
|
216
|
-
python_hwpx-4.
|
|
217
|
-
python_hwpx-4.
|
|
218
|
-
python_hwpx-4.
|
|
212
|
+
python_hwpx-4.2.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
|
|
213
|
+
python_hwpx-4.2.0.dist-info/licenses/NOTICE,sha256=KJgtwIIzrXoA0j3PUhwp78ZtnucocEoB7jiuwoL4i7o,3060
|
|
214
|
+
python_hwpx-4.2.0.dist-info/METADATA,sha256=4xqn4MBxGvoHlqV6nSSPo30Yg0cg-5Yn2k0qWRvZdK0,9297
|
|
215
|
+
python_hwpx-4.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
216
|
+
python_hwpx-4.2.0.dist-info/entry_points.txt,sha256=PVUBTBQtBHiflQ9XBjfd004w-LrDibgZjwzxgu8Tx7w,480
|
|
217
|
+
python_hwpx-4.2.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
|
|
218
|
+
python_hwpx-4.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|