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,538 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Agent-first proposal document preset for HWPX generation.
|
|
3
|
+
|
|
4
|
+
The preset deliberately uses only public ``HwpxDocument`` APIs. It gives agents
|
|
5
|
+
semantic building blocks (proposal title, metadata, sections, budget tables,
|
|
6
|
+
callouts) without requiring direct XML manipulation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Mapping, Sequence
|
|
14
|
+
import zipfile
|
|
15
|
+
|
|
16
|
+
from hwpx.document import HwpxDocument
|
|
17
|
+
|
|
18
|
+
DEFAULT_PROPOSAL_SECTIONS = (
|
|
19
|
+
"추진 배경 및 문제 정의",
|
|
20
|
+
"제안 내용",
|
|
21
|
+
"구축 및 운영 계획",
|
|
22
|
+
"예산 및 자원 계획",
|
|
23
|
+
"기대 효과",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_REQUIRED_SECTIONS = (
|
|
27
|
+
"title",
|
|
28
|
+
"metadata",
|
|
29
|
+
"executive_summary",
|
|
30
|
+
"background",
|
|
31
|
+
"proposal",
|
|
32
|
+
"implementation_plan",
|
|
33
|
+
"budget",
|
|
34
|
+
"expected_outcomes",
|
|
35
|
+
"closing",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_EMPTY_ASSET_INFO = {"image_count": 0, "image_bytes": 0, "package_bytes": 0}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class ProposalSection:
|
|
43
|
+
"""A semantic section in a proposal document."""
|
|
44
|
+
|
|
45
|
+
title: str
|
|
46
|
+
paragraphs: list[str] = field(default_factory=list)
|
|
47
|
+
bullets: list[str] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class ProposalSpec:
|
|
52
|
+
"""Structured intent for a Korean proposal/planning HWPX document."""
|
|
53
|
+
|
|
54
|
+
title: str
|
|
55
|
+
subtitle: str = ""
|
|
56
|
+
organization: str = ""
|
|
57
|
+
author: str = ""
|
|
58
|
+
date: str = ""
|
|
59
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
60
|
+
executive_summary: str = ""
|
|
61
|
+
sections: list[ProposalSection] = field(default_factory=list)
|
|
62
|
+
budget_items: list[dict[str, str]] = field(default_factory=list)
|
|
63
|
+
expected_outcomes: list[str] = field(default_factory=list)
|
|
64
|
+
closing: str = ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class ProposalStylePreset:
|
|
69
|
+
"""Semantic style-token preset backed by public run-style helpers."""
|
|
70
|
+
|
|
71
|
+
name: str = "clean_korean_proposal"
|
|
72
|
+
title_bold: bool = True
|
|
73
|
+
subtitle_italic: bool = True
|
|
74
|
+
heading_bold: bool = True
|
|
75
|
+
heading_underline: bool = True
|
|
76
|
+
table_header_bold: bool = True
|
|
77
|
+
callout_bold: bool = True
|
|
78
|
+
|
|
79
|
+
def ensure_tokens(self, document: HwpxDocument) -> dict[str, str]:
|
|
80
|
+
"""Create/reuse public char styles and return semantic token IDs."""
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"title": document.ensure_run_style(bold=self.title_bold),
|
|
84
|
+
"subtitle": document.ensure_run_style(italic=self.subtitle_italic),
|
|
85
|
+
"heading": document.ensure_run_style(bold=self.heading_bold),
|
|
86
|
+
"section_heading": document.ensure_run_style(
|
|
87
|
+
bold=self.heading_bold,
|
|
88
|
+
underline=self.heading_underline,
|
|
89
|
+
),
|
|
90
|
+
"body": document.ensure_run_style(),
|
|
91
|
+
"table_header": document.ensure_run_style(bold=self.table_header_bold),
|
|
92
|
+
"table_cell": document.ensure_run_style(),
|
|
93
|
+
"callout": document.ensure_run_style(bold=self.callout_bold),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def normalize_proposal_spec(spec: ProposalSpec | Mapping[str, Any]) -> ProposalSpec:
|
|
98
|
+
"""Return a :class:`ProposalSpec` from dataclass or dict-like input."""
|
|
99
|
+
|
|
100
|
+
if isinstance(spec, ProposalSpec):
|
|
101
|
+
return spec
|
|
102
|
+
if not isinstance(spec, Mapping):
|
|
103
|
+
raise TypeError("proposal spec must be a ProposalSpec or mapping")
|
|
104
|
+
title = str(spec.get("title") or "").strip()
|
|
105
|
+
if not title:
|
|
106
|
+
raise ValueError("proposal spec requires a non-empty title")
|
|
107
|
+
|
|
108
|
+
sections: list[ProposalSection] = []
|
|
109
|
+
for raw in spec.get("sections") or []:
|
|
110
|
+
if isinstance(raw, ProposalSection):
|
|
111
|
+
sections.append(raw)
|
|
112
|
+
continue
|
|
113
|
+
if not isinstance(raw, Mapping):
|
|
114
|
+
raise TypeError("sections must contain mappings or ProposalSection objects")
|
|
115
|
+
section_title = str(raw.get("title") or "").strip()
|
|
116
|
+
if not section_title:
|
|
117
|
+
raise ValueError("each section requires a title")
|
|
118
|
+
paragraphs = _string_list(raw.get("paragraphs") or raw.get("body") or [])
|
|
119
|
+
bullets = _string_list(raw.get("bullets") or [])
|
|
120
|
+
sections.append(ProposalSection(section_title, paragraphs=paragraphs, bullets=bullets))
|
|
121
|
+
|
|
122
|
+
if not sections:
|
|
123
|
+
sections = [ProposalSection(title=name) for name in DEFAULT_PROPOSAL_SECTIONS]
|
|
124
|
+
|
|
125
|
+
metadata = {str(k): str(v) for k, v in dict(spec.get("metadata") or {}).items()}
|
|
126
|
+
budget_items = [
|
|
127
|
+
{str(k): str(v) for k, v in dict(item).items()}
|
|
128
|
+
for item in spec.get("budget_items") or []
|
|
129
|
+
if isinstance(item, Mapping)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
return ProposalSpec(
|
|
133
|
+
title=title,
|
|
134
|
+
subtitle=str(spec.get("subtitle") or ""),
|
|
135
|
+
organization=str(spec.get("organization") or ""),
|
|
136
|
+
author=str(spec.get("author") or ""),
|
|
137
|
+
date=str(spec.get("date") or ""),
|
|
138
|
+
metadata=metadata,
|
|
139
|
+
executive_summary=str(spec.get("executive_summary") or ""),
|
|
140
|
+
sections=sections,
|
|
141
|
+
budget_items=budget_items,
|
|
142
|
+
expected_outcomes=_string_list(spec.get("expected_outcomes") or []),
|
|
143
|
+
closing=str(spec.get("closing") or ""),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def create_proposal_document(
|
|
148
|
+
spec: ProposalSpec | Mapping[str, Any],
|
|
149
|
+
*,
|
|
150
|
+
preset: ProposalStylePreset | str | None = None,
|
|
151
|
+
) -> HwpxDocument:
|
|
152
|
+
"""Create a polished proposal HWPX document from structured intent.
|
|
153
|
+
|
|
154
|
+
The returned document is unsaved so callers can choose the output path.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
normalized = normalize_proposal_spec(spec)
|
|
158
|
+
style_preset = (
|
|
159
|
+
preset
|
|
160
|
+
if isinstance(preset, ProposalStylePreset)
|
|
161
|
+
else ProposalStylePreset(name=str(preset or "clean_korean_proposal"))
|
|
162
|
+
)
|
|
163
|
+
document = HwpxDocument.new()
|
|
164
|
+
tokens = style_preset.ensure_tokens(document)
|
|
165
|
+
|
|
166
|
+
_add_paragraph(document, normalized.title, tokens["title"])
|
|
167
|
+
if normalized.subtitle:
|
|
168
|
+
_add_paragraph(document, normalized.subtitle, tokens["subtitle"])
|
|
169
|
+
_add_paragraph(document, "", tokens["body"])
|
|
170
|
+
|
|
171
|
+
metadata = _metadata_rows(normalized)
|
|
172
|
+
if metadata:
|
|
173
|
+
_add_key_value_table(document, metadata, tokens)
|
|
174
|
+
|
|
175
|
+
summary = normalized.executive_summary.strip()
|
|
176
|
+
if summary:
|
|
177
|
+
_add_paragraph(document, "핵심 요약", tokens["heading"])
|
|
178
|
+
_add_paragraph(document, summary, tokens["callout"])
|
|
179
|
+
|
|
180
|
+
for idx, section in enumerate(normalized.sections, start=1):
|
|
181
|
+
_add_paragraph(document, f"{idx}. {section.title}", tokens["section_heading"])
|
|
182
|
+
for paragraph in section.paragraphs:
|
|
183
|
+
_add_paragraph(document, paragraph, tokens["body"])
|
|
184
|
+
for bullet in section.bullets:
|
|
185
|
+
_add_paragraph(document, f"• {bullet}", tokens["body"])
|
|
186
|
+
|
|
187
|
+
if normalized.budget_items:
|
|
188
|
+
_add_paragraph(document, "예산 및 자원 계획", tokens["heading"])
|
|
189
|
+
_add_budget_table(document, normalized.budget_items, tokens)
|
|
190
|
+
|
|
191
|
+
if normalized.expected_outcomes:
|
|
192
|
+
_add_paragraph(document, "기대 효과", tokens["heading"])
|
|
193
|
+
for outcome in normalized.expected_outcomes:
|
|
194
|
+
_add_paragraph(document, f"• {outcome}", tokens["body"])
|
|
195
|
+
|
|
196
|
+
if normalized.closing:
|
|
197
|
+
_add_paragraph(document, "마무리", tokens["heading"])
|
|
198
|
+
_add_paragraph(document, normalized.closing, tokens["body"])
|
|
199
|
+
|
|
200
|
+
return document
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def inspect_proposal_quality(source: str | Path | HwpxDocument) -> dict[str, Any]:
|
|
204
|
+
"""Return a deterministic proposal-quality report for generated HWPX."""
|
|
205
|
+
|
|
206
|
+
path: Path | None = None
|
|
207
|
+
close_doc = False
|
|
208
|
+
if isinstance(source, HwpxDocument):
|
|
209
|
+
document = source
|
|
210
|
+
else:
|
|
211
|
+
path = Path(source)
|
|
212
|
+
document = HwpxDocument.open(path)
|
|
213
|
+
close_doc = True
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
texts = [(paragraph.text or "").strip() for paragraph in document.paragraphs]
|
|
217
|
+
non_empty = [text for text in texts if text]
|
|
218
|
+
joined = "\n".join(non_empty)
|
|
219
|
+
tables = _safe_table_count(document)
|
|
220
|
+
validation = document.validate()
|
|
221
|
+
asset_info = (
|
|
222
|
+
_asset_info(path)
|
|
223
|
+
if path is not None and path.exists()
|
|
224
|
+
else dict(_EMPTY_ASSET_INFO)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
required = {
|
|
228
|
+
"title": bool(non_empty),
|
|
229
|
+
"metadata": tables >= 1,
|
|
230
|
+
"executive_summary": _contains_any(joined, ["핵심 요약", "요약"]),
|
|
231
|
+
"background": _contains_any(joined, ["배경", "문제"]),
|
|
232
|
+
"proposal": _contains_any(joined, ["제안", "내용"]),
|
|
233
|
+
"implementation_plan": _contains_any(joined, ["운영 계획", "구축", "추진"]),
|
|
234
|
+
"budget": _contains_any(joined, ["예산", "자원"]),
|
|
235
|
+
"expected_outcomes": _contains_any(joined, ["기대 효과", "성과"]),
|
|
236
|
+
"closing": _contains_any(joined, ["마무리", "제출", "서명"]),
|
|
237
|
+
}
|
|
238
|
+
scores = _score_rubric(
|
|
239
|
+
required,
|
|
240
|
+
tables=tables,
|
|
241
|
+
validation_ok=validation.ok,
|
|
242
|
+
asset_info=asset_info,
|
|
243
|
+
)
|
|
244
|
+
style_usage = _style_token_usage(document)
|
|
245
|
+
sample_match = _sample_match_report(
|
|
246
|
+
required,
|
|
247
|
+
tables=tables,
|
|
248
|
+
validation_ok=validation.ok,
|
|
249
|
+
asset_info=asset_info,
|
|
250
|
+
style_usage=style_usage,
|
|
251
|
+
)
|
|
252
|
+
average = round(sum(scores.values()) / len(scores), 2)
|
|
253
|
+
passed = (
|
|
254
|
+
average >= 4.0
|
|
255
|
+
and min(scores.values()) >= 3.0
|
|
256
|
+
and sample_match["average"] >= 4.0
|
|
257
|
+
and validation.ok
|
|
258
|
+
and all(required.values())
|
|
259
|
+
)
|
|
260
|
+
return {
|
|
261
|
+
"report_version": "proposal-quality-v2",
|
|
262
|
+
"generated_file": str(path) if path is not None else None,
|
|
263
|
+
"validation": {"ok": validation.ok, "critical_errors": 0 if validation.ok else len(validation.issues)},
|
|
264
|
+
"outline": {"required_sections_present": all(required.values()), "required_sections": required},
|
|
265
|
+
"style_token_usage": style_usage,
|
|
266
|
+
"table_checks": {"table_count": tables, "has_metadata_table": tables >= 1, "has_budget_table": tables >= 2},
|
|
267
|
+
"asset_checks": asset_info,
|
|
268
|
+
"sample_match": sample_match,
|
|
269
|
+
"rubric_scores": scores,
|
|
270
|
+
"rubric_average": average,
|
|
271
|
+
"pass": passed,
|
|
272
|
+
"visual_review_required": True,
|
|
273
|
+
"visual_review_limitations": [
|
|
274
|
+
"No renderer/pixel-diff gate was used in this phase.",
|
|
275
|
+
"Sample matching is based on deterministic package/XML/text proxies only.",
|
|
276
|
+
],
|
|
277
|
+
"gaps": _quality_gaps(required, scores, validation.ok, sample_match=sample_match),
|
|
278
|
+
}
|
|
279
|
+
finally:
|
|
280
|
+
if close_doc:
|
|
281
|
+
document.close()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _string_list(value: Any) -> list[str]:
|
|
285
|
+
if isinstance(value, str):
|
|
286
|
+
return [value] if value.strip() else []
|
|
287
|
+
if isinstance(value, Sequence):
|
|
288
|
+
return [str(item) for item in value if str(item).strip()]
|
|
289
|
+
return []
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _add_paragraph(document: HwpxDocument, text: str, char_pr_id_ref: str) -> None:
|
|
293
|
+
document.add_paragraph(text, char_pr_id_ref=char_pr_id_ref, inherit_style=False)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _metadata_rows(spec: ProposalSpec) -> list[tuple[str, str]]:
|
|
297
|
+
rows = []
|
|
298
|
+
for label, value in (
|
|
299
|
+
("기관", spec.organization),
|
|
300
|
+
("작성자", spec.author),
|
|
301
|
+
("작성일", spec.date),
|
|
302
|
+
):
|
|
303
|
+
if value:
|
|
304
|
+
rows.append((label, value))
|
|
305
|
+
rows.extend((key, value) for key, value in spec.metadata.items() if value)
|
|
306
|
+
return rows
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _add_key_value_table(document: HwpxDocument, rows: list[tuple[str, str]], tokens: Mapping[str, str]) -> None:
|
|
310
|
+
table = document.add_table(len(rows) + 1, 2, char_pr_id_ref=tokens["table_cell"])
|
|
311
|
+
table.set_cell_text(0, 0, "항목")
|
|
312
|
+
table.set_cell_text(0, 1, "내용")
|
|
313
|
+
_bold_table_row(table, 0)
|
|
314
|
+
for row_index, (key, value) in enumerate(rows, start=1):
|
|
315
|
+
table.set_cell_text(row_index, 0, key)
|
|
316
|
+
table.set_cell_text(row_index, 1, value)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _add_budget_table(document: HwpxDocument, items: list[dict[str, str]], tokens: Mapping[str, str]) -> None:
|
|
320
|
+
headers = ["항목", "금액", "비고"]
|
|
321
|
+
table = document.add_table(len(items) + 1, len(headers), char_pr_id_ref=tokens["table_cell"])
|
|
322
|
+
for col, header in enumerate(headers):
|
|
323
|
+
table.set_cell_text(0, col, header)
|
|
324
|
+
_bold_table_row(table, 0)
|
|
325
|
+
for row_index, item in enumerate(items, start=1):
|
|
326
|
+
table.set_cell_text(row_index, 0, item.get("item") or item.get("name") or "")
|
|
327
|
+
table.set_cell_text(row_index, 1, item.get("amount") or item.get("cost") or "")
|
|
328
|
+
table.set_cell_text(row_index, 2, item.get("note") or item.get("description") or "")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _bold_table_row(table: Any, row_index: int) -> None:
|
|
332
|
+
try:
|
|
333
|
+
row = table.rows[row_index]
|
|
334
|
+
except (AttributeError, IndexError):
|
|
335
|
+
return
|
|
336
|
+
for cell in getattr(row, "cells", []):
|
|
337
|
+
for paragraph in getattr(cell, "paragraphs", []):
|
|
338
|
+
for run in paragraph.runs:
|
|
339
|
+
run.bold = True
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _safe_table_count(document: HwpxDocument) -> int:
|
|
343
|
+
table_tag = "{http://www.hancom.co.kr/hwpml/2011/paragraph}tbl"
|
|
344
|
+
count = 0
|
|
345
|
+
for section in getattr(document, "sections", []):
|
|
346
|
+
section_element = getattr(section, "element", None)
|
|
347
|
+
if section_element is None or not hasattr(section_element, "iter"):
|
|
348
|
+
continue
|
|
349
|
+
count += sum(1 for _ in section_element.iter(table_tag))
|
|
350
|
+
return count
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _asset_info(path: Path | None) -> dict[str, int]:
|
|
354
|
+
if path is None or not path.exists():
|
|
355
|
+
return dict(_EMPTY_ASSET_INFO)
|
|
356
|
+
image_count = 0
|
|
357
|
+
image_bytes = 0
|
|
358
|
+
with zipfile.ZipFile(path) as archive:
|
|
359
|
+
for info in archive.infolist():
|
|
360
|
+
if info.filename.startswith("BinData/"):
|
|
361
|
+
image_count += 1
|
|
362
|
+
image_bytes += int(info.file_size)
|
|
363
|
+
return {
|
|
364
|
+
"image_count": image_count,
|
|
365
|
+
"image_bytes": image_bytes,
|
|
366
|
+
"package_bytes": path.stat().st_size,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _contains_any(text: str, needles: Sequence[str]) -> bool:
|
|
371
|
+
return any(needle in text for needle in needles)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _score_rubric(
|
|
375
|
+
required: Mapping[str, bool],
|
|
376
|
+
*,
|
|
377
|
+
tables: int,
|
|
378
|
+
validation_ok: bool,
|
|
379
|
+
asset_info: Mapping[str, int],
|
|
380
|
+
) -> dict[str, float]:
|
|
381
|
+
structure = (
|
|
382
|
+
5.0
|
|
383
|
+
if all(required.values())
|
|
384
|
+
else max(2.0, 5.0 * sum(required.values()) / len(required))
|
|
385
|
+
)
|
|
386
|
+
asset_bytes = int(asset_info.get("image_bytes", 0))
|
|
387
|
+
package_bytes = int(asset_info.get("package_bytes", 0))
|
|
388
|
+
return {
|
|
389
|
+
"structure": round(structure, 2),
|
|
390
|
+
"visual_hierarchy": 4.0 if required.get("title") and required.get("executive_summary") else 3.0,
|
|
391
|
+
"style_consistency": 4.0,
|
|
392
|
+
"table_readability": 5.0 if tables >= 2 else (4.0 if tables == 1 else 2.0),
|
|
393
|
+
"asset_weight": 5.0 if package_bytes < 5_000_000 and asset_bytes < 1_000_000 else 3.0,
|
|
394
|
+
"validation_cleanliness": 5.0 if validation_ok else 2.0,
|
|
395
|
+
"agent_reproducibility": 4.0,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _style_token_usage(document: HwpxDocument) -> dict[str, Any]:
|
|
400
|
+
expected = [
|
|
401
|
+
"title",
|
|
402
|
+
"subtitle",
|
|
403
|
+
"heading",
|
|
404
|
+
"section_heading",
|
|
405
|
+
"body",
|
|
406
|
+
"table_header",
|
|
407
|
+
"table_cell",
|
|
408
|
+
"callout",
|
|
409
|
+
]
|
|
410
|
+
used_ids: set[str] = set()
|
|
411
|
+
for run in document.iter_runs():
|
|
412
|
+
style_id = run.element.get("charPrIDRef")
|
|
413
|
+
if style_id:
|
|
414
|
+
used_ids.add(str(style_id))
|
|
415
|
+
return {
|
|
416
|
+
"semantic_tokens_expected": expected,
|
|
417
|
+
"semantic_tokens_expected_count": len(expected),
|
|
418
|
+
"unique_run_style_ids": sorted(used_ids),
|
|
419
|
+
"unique_run_style_count": len(used_ids),
|
|
420
|
+
"style_count_available": len(document.char_properties),
|
|
421
|
+
"sample_anchor": {
|
|
422
|
+
"good_samples_style_count_range": [30, 46],
|
|
423
|
+
"bad_sample_style_count": 27,
|
|
424
|
+
"interpretation": "use reusable semantic tokens; do not inflate one-off styles",
|
|
425
|
+
},
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _sample_match_report(
|
|
430
|
+
required: Mapping[str, bool],
|
|
431
|
+
*,
|
|
432
|
+
tables: int,
|
|
433
|
+
validation_ok: bool,
|
|
434
|
+
asset_info: Mapping[str, int],
|
|
435
|
+
style_usage: Mapping[str, Any],
|
|
436
|
+
) -> dict[str, Any]:
|
|
437
|
+
package_bytes = int(asset_info.get("package_bytes", 0))
|
|
438
|
+
image_bytes = int(asset_info.get("image_bytes", 0))
|
|
439
|
+
image_ratio = round(image_bytes / package_bytes, 4) if package_bytes else 0.0
|
|
440
|
+
unique_styles = int(style_usage.get("unique_run_style_count", 0))
|
|
441
|
+
expected_tokens = int(style_usage.get("semantic_tokens_expected_count", 1))
|
|
442
|
+
style_coverage = min(1.0, unique_styles / max(1, min(expected_tokens, 5)))
|
|
443
|
+
|
|
444
|
+
dimensions: dict[str, dict[str, Any]] = {
|
|
445
|
+
"lean_asset_payload": {
|
|
446
|
+
"score": 5.0 if package_bytes < 5_000_000 and image_ratio < 0.6 else 2.5,
|
|
447
|
+
"status": "pass" if package_bytes < 5_000_000 and image_ratio < 0.6 else "fail",
|
|
448
|
+
"measurability": "measurable_now",
|
|
449
|
+
"metrics": {
|
|
450
|
+
"package_bytes": package_bytes,
|
|
451
|
+
"image_bytes": image_bytes,
|
|
452
|
+
"image_to_package_ratio": image_ratio,
|
|
453
|
+
},
|
|
454
|
+
"sample_anchor": "bad-1 shows image_payload_bloat; good samples stay compact by comparison",
|
|
455
|
+
},
|
|
456
|
+
"semantic_style_coverage": {
|
|
457
|
+
"score": 5.0 if style_coverage >= 0.8 else (4.0 if style_coverage >= 0.6 else 3.0),
|
|
458
|
+
"status": "pass" if style_coverage >= 0.6 else "warn",
|
|
459
|
+
"measurability": "proxy_only",
|
|
460
|
+
"metrics": {
|
|
461
|
+
"unique_run_style_count": unique_styles,
|
|
462
|
+
"expected_semantic_token_count": expected_tokens,
|
|
463
|
+
"coverage_ratio": round(style_coverage, 2),
|
|
464
|
+
},
|
|
465
|
+
"sample_anchor": "good samples expose richer bounded style vocabularies than the bad sample",
|
|
466
|
+
},
|
|
467
|
+
"structured_metadata_front_matter": {
|
|
468
|
+
"score": 5.0 if required.get("metadata") else 2.0,
|
|
469
|
+
"status": "pass" if required.get("metadata") else "fail",
|
|
470
|
+
"measurability": "measurable_now",
|
|
471
|
+
"metrics": {"has_metadata_table": bool(required.get("metadata"))},
|
|
472
|
+
"sample_anchor": "good/redacted previews show structured front-matter fields",
|
|
473
|
+
},
|
|
474
|
+
"purposeful_table_readability": {
|
|
475
|
+
"score": 5.0 if tables >= 2 else (4.0 if tables == 1 else 2.0),
|
|
476
|
+
"status": "pass" if tables >= 2 else ("warn" if tables == 1 else "fail"),
|
|
477
|
+
"measurability": "proxy_only",
|
|
478
|
+
"metrics": {"table_count": tables, "requires_metadata_and_budget_tables": True},
|
|
479
|
+
"sample_anchor": "good samples use tables for structure; bad sample proves volume alone is not quality",
|
|
480
|
+
},
|
|
481
|
+
"required_proposal_outline": {
|
|
482
|
+
"score": 5.0 if all(required.values()) else max(2.0, 5.0 * sum(required.values()) / len(required)),
|
|
483
|
+
"status": "pass" if all(required.values()) else "fail",
|
|
484
|
+
"measurability": "measurable_now",
|
|
485
|
+
"metrics": {"required_sections": dict(required)},
|
|
486
|
+
"sample_anchor": "rubric/demo require proposal identity, summary, body, budget, outcomes, closing",
|
|
487
|
+
},
|
|
488
|
+
"compliance_declaration_block": {
|
|
489
|
+
"score": 5.0 if required.get("closing") else 2.0,
|
|
490
|
+
"status": "pass" if required.get("closing") else "fail",
|
|
491
|
+
"measurability": "proxy_only",
|
|
492
|
+
"metrics": {"closing_or_submission_block": bool(required.get("closing"))},
|
|
493
|
+
"sample_anchor": "redacted previews include declaration/consent/closing style blocks",
|
|
494
|
+
},
|
|
495
|
+
"agent_reproducible_generation": {
|
|
496
|
+
"score": 5.0 if validation_ok else 2.0,
|
|
497
|
+
"status": "pass" if validation_ok else "fail",
|
|
498
|
+
"measurability": "measurable_now",
|
|
499
|
+
"metrics": {"schema_validation_ok": validation_ok, "uses_public_api_only": True},
|
|
500
|
+
"sample_anchor": "feasibility/demo reports require validation-clean public API generation",
|
|
501
|
+
},
|
|
502
|
+
}
|
|
503
|
+
average = round(
|
|
504
|
+
sum(float(dimension["score"]) for dimension in dimensions.values()) / len(dimensions),
|
|
505
|
+
2,
|
|
506
|
+
)
|
|
507
|
+
return {
|
|
508
|
+
"sample_set": ["good-1-best.hwpx", "good-2.hwpx", "bad-1.hwpx"],
|
|
509
|
+
"basis": "aggregate package/XML/text proxy traits from existing local samples",
|
|
510
|
+
"dimensions": dimensions,
|
|
511
|
+
"average": average,
|
|
512
|
+
"pass": average >= 4.0 and all(dimension["status"] != "fail" for dimension in dimensions.values()),
|
|
513
|
+
"visual_review_required": True,
|
|
514
|
+
"limitations": [
|
|
515
|
+
"Rendered visual parity is not claimed.",
|
|
516
|
+
"Traits marked proxy_only need human/rendered review before visual-quality sign-off.",
|
|
517
|
+
],
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _quality_gaps(
|
|
522
|
+
required: Mapping[str, bool],
|
|
523
|
+
scores: Mapping[str, float],
|
|
524
|
+
validation_ok: bool,
|
|
525
|
+
*,
|
|
526
|
+
sample_match: Mapping[str, Any] | None = None,
|
|
527
|
+
) -> list[str]:
|
|
528
|
+
gaps = [f"missing required section: {name}" for name, present in required.items() if not present]
|
|
529
|
+
gaps.extend(f"rubric dimension below threshold: {name}" for name, score in scores.items() if score < 3.0)
|
|
530
|
+
if not validation_ok:
|
|
531
|
+
gaps.append("document validation reported issues")
|
|
532
|
+
if sample_match:
|
|
533
|
+
dimensions = sample_match.get("dimensions", {})
|
|
534
|
+
if isinstance(dimensions, Mapping):
|
|
535
|
+
for name, detail in dimensions.items():
|
|
536
|
+
if isinstance(detail, Mapping) and detail.get("status") == "fail":
|
|
537
|
+
gaps.append(f"sample-match dimension failed: {name}")
|
|
538
|
+
return gaps
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Parse pasted government-report text into a document-plan v2 mapping.
|
|
2
|
+
|
|
3
|
+
Moved here in the 5.0 train. It had stayed in python-hwpx through the
|
|
4
|
+
authoring migration, even though a Korean government report is an institutional
|
|
5
|
+
genre and document-plan v2 is this layer's schema — and this package was already
|
|
6
|
+
reaching into core to serve it as the parse_government_report_text tool.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from hwpx_automation.office.authoring import DOCUMENT_PLAN_V2_SCHEMA_VERSION
|
|
15
|
+
from hwpx.tools.table_cleanup import normalize_cell_text # core primitive
|
|
16
|
+
|
|
17
|
+
_MARKDOWN_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$")
|
|
18
|
+
_ROMAN_HEADING_RE = re.compile(r"^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+\.\s*.+$")
|
|
19
|
+
_NUMBER_HEADING_RE = re.compile(r"^\d+\.\s+.+$")
|
|
20
|
+
_HANGUL_HEADING_RE = re.compile(r"^[가-힣]\.\s+.+$")
|
|
21
|
+
_BULLET_RE = re.compile(r"^(□|○|-|※)\s*(.+)$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_government_report_text(text: str, *, title: str = "") -> dict[str, Any]:
|
|
25
|
+
"""Return a ``hwpx.document_plan.v2`` dict from pasted report text."""
|
|
26
|
+
|
|
27
|
+
blocks: list[dict[str, Any]] = []
|
|
28
|
+
bullet_items: list[str] = []
|
|
29
|
+
table_lines: list[list[str]] = []
|
|
30
|
+
table_kind: str | None = None
|
|
31
|
+
|
|
32
|
+
def flush_bullets() -> None:
|
|
33
|
+
nonlocal bullet_items
|
|
34
|
+
if bullet_items:
|
|
35
|
+
blocks.append({"type": "bullets", "items": bullet_items})
|
|
36
|
+
bullet_items = []
|
|
37
|
+
|
|
38
|
+
def flush_table() -> None:
|
|
39
|
+
nonlocal table_lines, table_kind
|
|
40
|
+
if table_lines:
|
|
41
|
+
blocks.append(
|
|
42
|
+
{
|
|
43
|
+
"type": "table",
|
|
44
|
+
"header": table_lines[0],
|
|
45
|
+
"rows": table_lines[1:],
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
table_lines = []
|
|
49
|
+
table_kind = None
|
|
50
|
+
|
|
51
|
+
for raw_line in str(text or "").splitlines():
|
|
52
|
+
line = raw_line.strip()
|
|
53
|
+
if not line:
|
|
54
|
+
flush_bullets()
|
|
55
|
+
flush_table()
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
parsed_table = _parse_table_line(line)
|
|
59
|
+
if parsed_table is not None:
|
|
60
|
+
kind, cells = parsed_table
|
|
61
|
+
flush_bullets()
|
|
62
|
+
if table_kind is not None and table_kind != kind:
|
|
63
|
+
flush_table()
|
|
64
|
+
table_kind = kind
|
|
65
|
+
table_lines.append(cells)
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
flush_table()
|
|
69
|
+
|
|
70
|
+
bullet_match = _BULLET_RE.match(line)
|
|
71
|
+
if bullet_match:
|
|
72
|
+
bullet_items.append(normalize_cell_text(bullet_match.group(2)))
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
flush_bullets()
|
|
76
|
+
|
|
77
|
+
heading = _parse_heading(line)
|
|
78
|
+
if heading is not None:
|
|
79
|
+
level, heading_text = heading
|
|
80
|
+
blocks.append({"type": "heading", "level": level, "text": heading_text})
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
blocks.append({"type": "paragraph", "text": normalize_cell_text(line)})
|
|
84
|
+
|
|
85
|
+
flush_bullets()
|
|
86
|
+
flush_table()
|
|
87
|
+
|
|
88
|
+
if not blocks:
|
|
89
|
+
fallback = normalize_cell_text(title) or "본문"
|
|
90
|
+
blocks.append({"type": "paragraph", "text": fallback})
|
|
91
|
+
|
|
92
|
+
metadata: dict[str, str] = {}
|
|
93
|
+
normalized_title = normalize_cell_text(title)
|
|
94
|
+
if normalized_title:
|
|
95
|
+
metadata["title"] = normalized_title
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"schemaVersion": DOCUMENT_PLAN_V2_SCHEMA_VERSION,
|
|
99
|
+
"title": normalized_title,
|
|
100
|
+
"metadata": metadata,
|
|
101
|
+
"sections": [{"blocks": blocks}],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_heading(line: str) -> tuple[int, str] | None:
|
|
106
|
+
markdown_match = _MARKDOWN_HEADING_RE.match(line)
|
|
107
|
+
if markdown_match:
|
|
108
|
+
return min(len(markdown_match.group(1)), 3), normalize_cell_text(markdown_match.group(2))
|
|
109
|
+
if _ROMAN_HEADING_RE.match(line):
|
|
110
|
+
return 1, normalize_cell_text(line)
|
|
111
|
+
if _NUMBER_HEADING_RE.match(line):
|
|
112
|
+
return 2, normalize_cell_text(line)
|
|
113
|
+
if _HANGUL_HEADING_RE.match(line):
|
|
114
|
+
return 3, normalize_cell_text(line)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_table_line(line: str) -> tuple[str, list[str]] | None:
|
|
119
|
+
if "\t" in line:
|
|
120
|
+
cells = [normalize_cell_text(cell) for cell in line.split("\t")]
|
|
121
|
+
cells = _trim_empty_edge_cells(cells)
|
|
122
|
+
return ("tab", cells) if len(cells) >= 2 else None
|
|
123
|
+
if "|" not in line:
|
|
124
|
+
return None
|
|
125
|
+
cells = [normalize_cell_text(cell) for cell in line.split("|")]
|
|
126
|
+
cells = _trim_empty_edge_cells(cells)
|
|
127
|
+
if len(cells) < 2 or _is_markdown_table_separator(cells):
|
|
128
|
+
return None
|
|
129
|
+
return "pipe", cells
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _trim_empty_edge_cells(cells: list[str]) -> list[str]:
|
|
133
|
+
while cells and not cells[0]:
|
|
134
|
+
cells.pop(0)
|
|
135
|
+
while cells and not cells[-1]:
|
|
136
|
+
cells.pop()
|
|
137
|
+
return cells
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_markdown_table_separator(cells: list[str]) -> bool:
|
|
141
|
+
return all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells)
|