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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)