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,725 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Automation-owned HWPX quality-generation workflow.
|
|
3
|
+
|
|
4
|
+
The workflow is intentionally centered in ``python-hwpx-automation`` rather
|
|
5
|
+
than the core ``python-hwpx`` package. It lets agents start from a form HWPX plus an
|
|
6
|
+
idea/brief, apply a built-in quality profile, generate a candidate document,
|
|
7
|
+
inspect quality, and return revision evidence without requiring a per-run
|
|
8
|
+
quality-sample file.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import tempfile
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Mapping
|
|
20
|
+
|
|
21
|
+
try: # python-hwpx >= 2.10.3
|
|
22
|
+
from hwpx.tools.package_validator import validate_package
|
|
23
|
+
except Exception as exc: # pragma: no cover - depends on installed python-hwpx
|
|
24
|
+
validate_package = None
|
|
25
|
+
_PACKAGE_VALIDATOR_IMPORT_ERROR: Exception | None = exc
|
|
26
|
+
else:
|
|
27
|
+
_PACKAGE_VALIDATOR_IMPORT_ERROR = None
|
|
28
|
+
|
|
29
|
+
from .core.document import open_doc
|
|
30
|
+
from .storage import build_hwpx_open_safety_report
|
|
31
|
+
from .upstream import new_document, validate_document_path
|
|
32
|
+
from .utils.helpers import resolve_path
|
|
33
|
+
|
|
34
|
+
try: # python-hwpx >= proposal preset feature
|
|
35
|
+
from .office.authoring.presets import (
|
|
36
|
+
create_proposal_document,
|
|
37
|
+
inspect_proposal_quality,
|
|
38
|
+
)
|
|
39
|
+
except Exception: # pragma: no cover - optional dependency compatibility
|
|
40
|
+
create_proposal_document = None
|
|
41
|
+
inspect_proposal_quality = None
|
|
42
|
+
|
|
43
|
+
_QUALITY_GENERATION_SCHEMA_VERSION = "hwpx.quality-generation.v1"
|
|
44
|
+
_DEFAULT_PROFILE_NAME = "korean_ai_school_application_v1"
|
|
45
|
+
_QUALITY_PLANS: dict[str, dict[str, Any]] = {}
|
|
46
|
+
|
|
47
|
+
_DEFAULT_SECTION_TITLES = (
|
|
48
|
+
"추진 배경 및 필요성",
|
|
49
|
+
"운영 목표",
|
|
50
|
+
"세부 운영 계획",
|
|
51
|
+
"AI 교육과정 편성 및 운영",
|
|
52
|
+
"교원 역량 강화 계획",
|
|
53
|
+
"성과 관리 및 확산 계획",
|
|
54
|
+
)
|
|
55
|
+
# HWPX table sizes are stored in HWP units. The upstream default is compact
|
|
56
|
+
# for generated form fragments, so use a near-page-width table for readable
|
|
57
|
+
# front-matter and budget tables in the quality-generation fallback path.
|
|
58
|
+
_READABLE_TABLE_WIDTH = 95_000
|
|
59
|
+
|
|
60
|
+
_BUILT_IN_PROFILE: dict[str, Any] = {
|
|
61
|
+
"name": _DEFAULT_PROFILE_NAME,
|
|
62
|
+
"ordinary_input_contract": ["form_filename", "idea_brief"],
|
|
63
|
+
"quality_sample_required": False,
|
|
64
|
+
"target_document_traits": {
|
|
65
|
+
"front_matter": "readable document information block near the beginning",
|
|
66
|
+
"heading_hierarchy": "clear numbered sections with short headings",
|
|
67
|
+
"table_usage": "use tables for budget/resource or other structured plans when they improve readability",
|
|
68
|
+
"paragraph_rhythm": "short Korean business-document paragraphs plus bullets",
|
|
69
|
+
"style_tokens": "bounded reusable semantic run styles instead of many one-off styles",
|
|
70
|
+
"validation": "output must reopen and pass package/document validation",
|
|
71
|
+
},
|
|
72
|
+
"minimum_quality": {
|
|
73
|
+
"rubric_average": 4.0,
|
|
74
|
+
"sample_match_average": 4.0,
|
|
75
|
+
"validation_pass": True,
|
|
76
|
+
},
|
|
77
|
+
"revision_policy": {
|
|
78
|
+
"max_default_revision_rounds": 1,
|
|
79
|
+
"deterministic_fixes": [
|
|
80
|
+
"ensure executive summary",
|
|
81
|
+
"ensure required section outline",
|
|
82
|
+
"ensure budget/resource table",
|
|
83
|
+
"ensure expected outcomes",
|
|
84
|
+
"ensure closing/declaration block",
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
"non_goals": [
|
|
88
|
+
"binary .hwp conversion/editing",
|
|
89
|
+
"complex layout reproduction",
|
|
90
|
+
"pixel-perfect rendered parity",
|
|
91
|
+
"per-run target-quality sample requirement",
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def analyze_quality_generation_workflow(
|
|
97
|
+
*,
|
|
98
|
+
form_filename: str,
|
|
99
|
+
idea_brief: str | Mapping[str, Any],
|
|
100
|
+
destination_filename: str | None = None,
|
|
101
|
+
quality_profile: str = _DEFAULT_PROFILE_NAME,
|
|
102
|
+
options: dict[str, Any] | None = None,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
"""Build a non-mutating generation plan from a form and idea/brief."""
|
|
105
|
+
|
|
106
|
+
form_path = resolve_path(form_filename)
|
|
107
|
+
destination_path = resolve_path(destination_filename) if destination_filename else None
|
|
108
|
+
doc = open_doc(form_path)
|
|
109
|
+
outline = _document_outline(doc)
|
|
110
|
+
tables = _table_summaries(doc)
|
|
111
|
+
styles = _style_summary(doc)
|
|
112
|
+
content_spec = _normalize_content_spec(idea_brief, outline=outline)
|
|
113
|
+
plan_id = f"qg_{uuid.uuid4().hex[:16]}"
|
|
114
|
+
profile = _quality_profile(quality_profile)
|
|
115
|
+
analysis: dict[str, Any] = {
|
|
116
|
+
"plan_id": plan_id,
|
|
117
|
+
"schemaVersion": _QUALITY_GENERATION_SCHEMA_VERSION,
|
|
118
|
+
"quality_profile": profile,
|
|
119
|
+
"quality_sample_required": False,
|
|
120
|
+
"inputs": {
|
|
121
|
+
"form": {"filename": form_filename, "path": form_path},
|
|
122
|
+
"idea_brief_kind": "mapping" if isinstance(idea_brief, Mapping) else "text",
|
|
123
|
+
"destination": {
|
|
124
|
+
"filename": destination_filename,
|
|
125
|
+
"path": destination_path,
|
|
126
|
+
"required_for_apply": bool(destination_filename),
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
"form_analysis": {
|
|
130
|
+
"paragraph_count": len(getattr(doc, "paragraphs", [])),
|
|
131
|
+
"table_count": len(tables),
|
|
132
|
+
"outline": outline,
|
|
133
|
+
"tables": tables,
|
|
134
|
+
"styles": styles,
|
|
135
|
+
"unsupported": _unsupported_form_traits(doc),
|
|
136
|
+
},
|
|
137
|
+
"content_spec": content_spec,
|
|
138
|
+
"generation_plan": _generation_plan(content_spec, profile=profile),
|
|
139
|
+
"quality_gates": _quality_gates(profile),
|
|
140
|
+
"mutated": False,
|
|
141
|
+
"next_tool": "apply_quality_generation",
|
|
142
|
+
"options": options or {},
|
|
143
|
+
}
|
|
144
|
+
_QUALITY_PLANS[plan_id] = copy.deepcopy(analysis)
|
|
145
|
+
return analysis
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def apply_quality_generation_workflow(
|
|
149
|
+
*,
|
|
150
|
+
plan_id: str | None = None,
|
|
151
|
+
analysis: dict[str, Any] | None = None,
|
|
152
|
+
form_filename: str | None = None,
|
|
153
|
+
destination_filename: str | None = None,
|
|
154
|
+
idea_brief: str | Mapping[str, Any] | None = None,
|
|
155
|
+
max_revision_rounds: int = 1,
|
|
156
|
+
confirm: bool = True,
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""Generate an HWPX output, inspect quality, and run deterministic revisions."""
|
|
159
|
+
|
|
160
|
+
if not confirm:
|
|
161
|
+
raise ValueError("confirm must be true to apply quality generation")
|
|
162
|
+
plan = _resolve_plan(plan_id=plan_id, analysis=analysis)
|
|
163
|
+
if idea_brief is not None:
|
|
164
|
+
plan["content_spec"] = _normalize_content_spec(
|
|
165
|
+
idea_brief,
|
|
166
|
+
outline=plan.get("form_analysis", {}).get("outline", []),
|
|
167
|
+
)
|
|
168
|
+
plan["generation_plan"] = _generation_plan(
|
|
169
|
+
plan["content_spec"],
|
|
170
|
+
profile=plan.get("quality_profile") or _quality_profile(_DEFAULT_PROFILE_NAME),
|
|
171
|
+
)
|
|
172
|
+
source_filename = form_filename or plan.get("inputs", {}).get("form", {}).get("path")
|
|
173
|
+
if not source_filename:
|
|
174
|
+
raise ValueError("form_filename is required")
|
|
175
|
+
destination = _destination_from_plan(plan, destination_filename)
|
|
176
|
+
Path(destination).parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
|
|
178
|
+
rounds: list[dict[str, Any]] = []
|
|
179
|
+
content_spec = copy.deepcopy(plan["content_spec"])
|
|
180
|
+
rounds.append(_write_and_inspect(destination, content_spec, revision_round=0))
|
|
181
|
+
remaining = max(0, int(max_revision_rounds))
|
|
182
|
+
while remaining and not rounds[-1]["quality"]["pass"]:
|
|
183
|
+
content_spec = _revise_content_spec(content_spec, rounds[-1]["quality"]["gaps"])
|
|
184
|
+
rounds.append(_write_and_inspect(destination, content_spec, revision_round=len(rounds)))
|
|
185
|
+
remaining -= 1
|
|
186
|
+
|
|
187
|
+
final = rounds[-1]
|
|
188
|
+
return {
|
|
189
|
+
"handoff_status": "ready" if final["quality"]["pass"] else "needs_revision",
|
|
190
|
+
"schemaVersion": _QUALITY_GENERATION_SCHEMA_VERSION,
|
|
191
|
+
"plan_id": plan.get("plan_id"),
|
|
192
|
+
"quality_sample_required": False,
|
|
193
|
+
"source": {
|
|
194
|
+
"form_filename": form_filename,
|
|
195
|
+
"form_path": str(resolve_path(str(source_filename))),
|
|
196
|
+
},
|
|
197
|
+
"destination": {
|
|
198
|
+
"path": destination,
|
|
199
|
+
"created": Path(destination).exists(),
|
|
200
|
+
},
|
|
201
|
+
"quality_profile": plan.get("quality_profile") or _quality_profile(_DEFAULT_PROFILE_NAME),
|
|
202
|
+
"content_spec": content_spec,
|
|
203
|
+
"revision_history": rounds,
|
|
204
|
+
"quality": final["quality"],
|
|
205
|
+
"validation": final["validation"],
|
|
206
|
+
"next_action": (
|
|
207
|
+
"use generated file"
|
|
208
|
+
if final["quality"]["pass"]
|
|
209
|
+
else "review quality.gaps and rerun with a richer idea_brief/content_spec"
|
|
210
|
+
),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _resolve_plan(*, plan_id: str | None, analysis: dict[str, Any] | None) -> dict[str, Any]:
|
|
215
|
+
if analysis is not None:
|
|
216
|
+
return copy.deepcopy(analysis)
|
|
217
|
+
if plan_id is None:
|
|
218
|
+
raise ValueError("provide plan_id or analysis")
|
|
219
|
+
try:
|
|
220
|
+
return copy.deepcopy(_QUALITY_PLANS[plan_id])
|
|
221
|
+
except KeyError as exc:
|
|
222
|
+
raise ValueError(f"unknown quality-generation plan_id: {plan_id}") from exc
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _destination_from_plan(plan: Mapping[str, Any], override: str | None) -> str:
|
|
226
|
+
if override:
|
|
227
|
+
return resolve_path(override)
|
|
228
|
+
destination = (plan.get("inputs") or {}).get("destination") or {}
|
|
229
|
+
path = destination.get("path") or destination.get("filename")
|
|
230
|
+
if not path:
|
|
231
|
+
raise ValueError("destination_filename is required")
|
|
232
|
+
return resolve_path(str(path))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _quality_profile(name: str) -> dict[str, Any]:
|
|
236
|
+
if name != _DEFAULT_PROFILE_NAME:
|
|
237
|
+
profile = copy.deepcopy(_BUILT_IN_PROFILE)
|
|
238
|
+
profile["name"] = str(name)
|
|
239
|
+
profile["base_profile"] = _DEFAULT_PROFILE_NAME
|
|
240
|
+
return profile
|
|
241
|
+
return copy.deepcopy(_BUILT_IN_PROFILE)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _normalize_content_spec(
|
|
245
|
+
idea_brief: str | Mapping[str, Any],
|
|
246
|
+
*,
|
|
247
|
+
outline: list[dict[str, Any]],
|
|
248
|
+
) -> dict[str, Any]:
|
|
249
|
+
if isinstance(idea_brief, Mapping):
|
|
250
|
+
payload = copy.deepcopy(dict(idea_brief))
|
|
251
|
+
else:
|
|
252
|
+
payload = {"idea_brief": str(idea_brief or "").strip()}
|
|
253
|
+
|
|
254
|
+
raw_brief = str(payload.get("idea_brief") or payload.get("brief") or "").strip()
|
|
255
|
+
title = str(payload.get("title") or "").strip()
|
|
256
|
+
if not title:
|
|
257
|
+
title = _derive_title(raw_brief, outline)
|
|
258
|
+
organization = str(payload.get("organization") or payload.get("school") or "신청 기관").strip()
|
|
259
|
+
author = str(payload.get("author") or payload.get("team") or "AI 중점학교 운영팀").strip()
|
|
260
|
+
date = str(payload.get("date") or "2026").strip()
|
|
261
|
+
sections = _normalize_sections(payload.get("sections"), raw_brief=raw_brief, outline=outline)
|
|
262
|
+
executive_summary = str(payload.get("executive_summary") or payload.get("summary") or "").strip()
|
|
263
|
+
if not executive_summary:
|
|
264
|
+
executive_summary = _derive_summary(raw_brief, title=title)
|
|
265
|
+
budget_items = _normalize_budget_items(payload.get("budget_items"))
|
|
266
|
+
expected_outcomes = _string_list(payload.get("expected_outcomes") or payload.get("outcomes") or [])
|
|
267
|
+
if not expected_outcomes:
|
|
268
|
+
expected_outcomes = [
|
|
269
|
+
"학생의 AI 기초 소양과 문제 해결 역량을 체계적으로 강화한다.",
|
|
270
|
+
"교원의 AI 활용 수업 설계 역량을 높이고 학교 내 실행 사례를 확산한다.",
|
|
271
|
+
]
|
|
272
|
+
closing = str(payload.get("closing") or "").strip() or "본 계획을 바탕으로 AI 중점학교 운영을 성실히 추진하고 성과를 공유하겠습니다."
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
"title": title,
|
|
276
|
+
"subtitle": str(payload.get("subtitle") or "양식 기반 AI 중점학교 운영계획서").strip(),
|
|
277
|
+
"organization": organization,
|
|
278
|
+
"author": author,
|
|
279
|
+
"date": date,
|
|
280
|
+
"metadata": {
|
|
281
|
+
**{str(k): str(v) for k, v in dict(payload.get("metadata") or {}).items()},
|
|
282
|
+
"문서유형": str(payload.get("document_type") or "신청서 및 운영계획서"),
|
|
283
|
+
"생성방식": "MCP 품질 파이프라인",
|
|
284
|
+
},
|
|
285
|
+
"executive_summary": executive_summary,
|
|
286
|
+
"sections": sections,
|
|
287
|
+
"budget_items": budget_items,
|
|
288
|
+
"expected_outcomes": expected_outcomes,
|
|
289
|
+
"closing": closing,
|
|
290
|
+
"source_brief": raw_brief,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _derive_title(raw_brief: str, outline: list[dict[str, Any]]) -> str:
|
|
295
|
+
for item in outline:
|
|
296
|
+
text = str(item.get("text") or "").strip()
|
|
297
|
+
if "AI" in text and len(text) <= 80:
|
|
298
|
+
return text
|
|
299
|
+
if raw_brief:
|
|
300
|
+
first = re.split(r"[.\n]", raw_brief, maxsplit=1)[0].strip()
|
|
301
|
+
if 8 <= len(first) <= 80:
|
|
302
|
+
return first
|
|
303
|
+
return "2026년 AI 중점학교 신청서 및 운영계획서"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _derive_summary(raw_brief: str, *, title: str) -> str:
|
|
307
|
+
if raw_brief:
|
|
308
|
+
compact = " ".join(raw_brief.split())
|
|
309
|
+
return compact[:220]
|
|
310
|
+
return f"{title}의 운영 목표, 교육과정 편성, 교원 역량 강화, 성과 확산 계획을 구조화해 제시합니다."
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _normalize_sections(value: Any, *, raw_brief: str, outline: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
314
|
+
if isinstance(value, list) and value:
|
|
315
|
+
sections: list[dict[str, Any]] = []
|
|
316
|
+
for item in value:
|
|
317
|
+
if isinstance(item, Mapping):
|
|
318
|
+
title = str(item.get("title") or "").strip()
|
|
319
|
+
if title:
|
|
320
|
+
sections.append(
|
|
321
|
+
{
|
|
322
|
+
"title": title,
|
|
323
|
+
"paragraphs": _string_list(item.get("paragraphs") or item.get("body") or []),
|
|
324
|
+
"bullets": _string_list(item.get("bullets") or []),
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
if sections:
|
|
328
|
+
return sections
|
|
329
|
+
|
|
330
|
+
hints = [str(item.get("text") or "").strip() for item in outline if str(item.get("text") or "").strip()]
|
|
331
|
+
selected_titles = _DEFAULT_SECTION_TITLES
|
|
332
|
+
if hints:
|
|
333
|
+
plausible = [text for text in hints if 5 <= len(text) <= 60 and not text.startswith("[")]
|
|
334
|
+
if len(plausible) >= 3:
|
|
335
|
+
selected_titles = tuple(plausible[:6])
|
|
336
|
+
brief_sentence = _derive_summary(raw_brief, title="운영계획")
|
|
337
|
+
return [
|
|
338
|
+
{
|
|
339
|
+
"title": title,
|
|
340
|
+
"paragraphs": [f"{title}은(는) 학교 여건과 신청 양식의 요구 항목을 반영해 {brief_sentence}"],
|
|
341
|
+
"bullets": [
|
|
342
|
+
"담당자와 추진 일정을 명확히 지정한다.",
|
|
343
|
+
"운영 결과를 점검하고 다음 단계 개선에 반영한다.",
|
|
344
|
+
],
|
|
345
|
+
}
|
|
346
|
+
for title in selected_titles
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _normalize_budget_items(value: Any) -> list[dict[str, str]]:
|
|
351
|
+
if isinstance(value, list) and value:
|
|
352
|
+
items = []
|
|
353
|
+
for item in value:
|
|
354
|
+
if isinstance(item, Mapping):
|
|
355
|
+
items.append({str(k): str(v) for k, v in item.items()})
|
|
356
|
+
if items:
|
|
357
|
+
return items
|
|
358
|
+
return [
|
|
359
|
+
{"item": "AI 교육 운영비", "amount": "계획 수립 후 확정", "note": "수업 운영 및 산출물 제작"},
|
|
360
|
+
{"item": "교원 역량 강화", "amount": "계획 수립 후 확정", "note": "연수 및 컨설팅"},
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _string_list(value: Any) -> list[str]:
|
|
365
|
+
if isinstance(value, str):
|
|
366
|
+
return [value] if value.strip() else []
|
|
367
|
+
if not isinstance(value, list):
|
|
368
|
+
return []
|
|
369
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _document_outline(doc: Any) -> list[dict[str, Any]]:
|
|
373
|
+
outline = []
|
|
374
|
+
for index, para in enumerate(getattr(doc, "paragraphs", [])):
|
|
375
|
+
text = (getattr(para, "text", None) or "").strip()
|
|
376
|
+
if not text:
|
|
377
|
+
continue
|
|
378
|
+
if len(text) <= 90 or _looks_like_heading(text):
|
|
379
|
+
outline.append({"paragraph_index": index, "text": text, "level": 1 if _looks_like_heading(text) else 2})
|
|
380
|
+
return outline[:40]
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _looks_like_heading(text: str) -> bool:
|
|
384
|
+
return bool(re.match(r"^([0-9]+[.)]|[ⅠⅡⅢⅣⅤ]+[.]?|[가-하][.)]|[□■○●])\\s*", text)) or len(text) < 35
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _table_summaries(doc: Any) -> list[dict[str, Any]]:
|
|
388
|
+
tables = []
|
|
389
|
+
for paragraph in getattr(doc, "paragraphs", []):
|
|
390
|
+
for table in getattr(paragraph, "tables", []):
|
|
391
|
+
rows = getattr(table, "rows", [])
|
|
392
|
+
sample_rows = []
|
|
393
|
+
for row in rows[:3]:
|
|
394
|
+
cells = [str(getattr(cell, "text", "") or "").strip() for cell in getattr(row, "cells", [])[:4]]
|
|
395
|
+
sample_rows.append(cells)
|
|
396
|
+
tables.append(
|
|
397
|
+
{
|
|
398
|
+
"index": len(tables),
|
|
399
|
+
"rows": len(rows),
|
|
400
|
+
"cols": max((len(getattr(row, "cells", [])) for row in rows), default=0),
|
|
401
|
+
"sample_rows": sample_rows,
|
|
402
|
+
}
|
|
403
|
+
)
|
|
404
|
+
return tables
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _style_summary(doc: Any) -> dict[str, Any]:
|
|
408
|
+
used_ids: set[str] = set()
|
|
409
|
+
for run in getattr(doc, "iter_runs", lambda: [])():
|
|
410
|
+
style_id = getattr(getattr(run, "element", None), "get", lambda _key: None)("charPrIDRef")
|
|
411
|
+
if style_id:
|
|
412
|
+
used_ids.add(str(style_id))
|
|
413
|
+
return {
|
|
414
|
+
"available_char_style_count": len(getattr(doc, "char_properties", {})),
|
|
415
|
+
"available_paragraph_style_count": len(getattr(doc, "styles", {})),
|
|
416
|
+
"used_run_style_ids": sorted(used_ids),
|
|
417
|
+
"used_run_style_count": len(used_ids),
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _unsupported_form_traits(doc: Any) -> list[dict[str, Any]]:
|
|
422
|
+
unsupported = []
|
|
423
|
+
for table in _table_summaries(doc):
|
|
424
|
+
if table["cols"] > 8:
|
|
425
|
+
unsupported.append(
|
|
426
|
+
{
|
|
427
|
+
"kind": "wide-table",
|
|
428
|
+
"table_index": table["index"],
|
|
429
|
+
"reason": "v1 does not attempt complex layout reproduction",
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
return unsupported
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _generation_plan(content_spec: Mapping[str, Any], *, profile: Mapping[str, Any]) -> dict[str, Any]:
|
|
436
|
+
return {
|
|
437
|
+
"strategy": "proposal-preset-backed-quality-generation",
|
|
438
|
+
"profile": profile.get("name"),
|
|
439
|
+
"builder": "hwpx.presets.create_proposal_document",
|
|
440
|
+
"inspector": "hwpx.presets.inspect_proposal_quality",
|
|
441
|
+
"required_sections": [section.get("title") for section in content_spec.get("sections", [])],
|
|
442
|
+
"revision_policy": profile.get("revision_policy", {}),
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _quality_gates(profile: Mapping[str, Any]) -> dict[str, Any]:
|
|
447
|
+
minimum = profile.get("minimum_quality") or {}
|
|
448
|
+
return {
|
|
449
|
+
"rubric_average_min": minimum.get("rubric_average", 4.0),
|
|
450
|
+
"sample_match_average_min": minimum.get("sample_match_average", 4.0),
|
|
451
|
+
"validation_required": bool(minimum.get("validation_pass", True)),
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _write_and_inspect(destination: str, content_spec: Mapping[str, Any], *, revision_round: int) -> dict[str, Any]:
|
|
456
|
+
destination_path = Path(destination)
|
|
457
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
458
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
459
|
+
prefix=f".{destination_path.stem}.",
|
|
460
|
+
suffix=destination_path.suffix or ".hwpx",
|
|
461
|
+
dir=str(destination_path.parent),
|
|
462
|
+
)
|
|
463
|
+
tmp_path = Path(tmp_name)
|
|
464
|
+
os.close(fd)
|
|
465
|
+
doc = None
|
|
466
|
+
try:
|
|
467
|
+
doc = (
|
|
468
|
+
create_proposal_document(content_spec)
|
|
469
|
+
if create_proposal_document is not None
|
|
470
|
+
else _create_quality_document_fallback(content_spec)
|
|
471
|
+
)
|
|
472
|
+
# Phase F: this user-file commit funnels through the one SavePipeline gate.
|
|
473
|
+
from . import quality as quality_contract
|
|
474
|
+
|
|
475
|
+
quality_contract.assert_write_capability()
|
|
476
|
+
quality_contract.save_through_pipeline(doc, tmp_path)
|
|
477
|
+
report = (
|
|
478
|
+
inspect_proposal_quality(str(tmp_path))
|
|
479
|
+
if inspect_proposal_quality is not None
|
|
480
|
+
else _inspect_quality_fallback(str(tmp_path))
|
|
481
|
+
)
|
|
482
|
+
validation = _runtime_validation(str(tmp_path))
|
|
483
|
+
if not validation.get("openSafety", {}).get("ok"):
|
|
484
|
+
raise RuntimeError(
|
|
485
|
+
"quality-generation output failed open-safety verification: "
|
|
486
|
+
+ str(validation.get("openSafety", {}).get("summary", "unknown failure"))
|
|
487
|
+
)
|
|
488
|
+
os.replace(tmp_path, destination_path)
|
|
489
|
+
finally:
|
|
490
|
+
tmp_path.unlink(missing_ok=True)
|
|
491
|
+
if doc is not None:
|
|
492
|
+
doc.close()
|
|
493
|
+
quality = _quality_result(report, validation)
|
|
494
|
+
return {
|
|
495
|
+
"round": revision_round,
|
|
496
|
+
"destination": destination,
|
|
497
|
+
"quality": quality,
|
|
498
|
+
"validation": validation,
|
|
499
|
+
"report": report,
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _create_quality_document_fallback(content_spec: Mapping[str, Any]) -> Any:
|
|
504
|
+
"""Create a presentable document when proposal presets are unavailable."""
|
|
505
|
+
|
|
506
|
+
doc = new_document()
|
|
507
|
+
title_style = doc.ensure_run_style(bold=True)
|
|
508
|
+
heading_style = doc.ensure_run_style(bold=True, underline=True)
|
|
509
|
+
body_style = doc.ensure_run_style()
|
|
510
|
+
table_header_style = doc.ensure_run_style(bold=True)
|
|
511
|
+
callout_style = doc.ensure_run_style(bold=True)
|
|
512
|
+
|
|
513
|
+
doc.add_paragraph(str(content_spec.get("title") or "운영계획서"), char_pr_id_ref=title_style)
|
|
514
|
+
subtitle = str(content_spec.get("subtitle") or "")
|
|
515
|
+
if subtitle:
|
|
516
|
+
doc.add_paragraph(subtitle, char_pr_id_ref=callout_style)
|
|
517
|
+
|
|
518
|
+
metadata = dict(content_spec.get("metadata") or {})
|
|
519
|
+
for key in ("organization", "author", "date"):
|
|
520
|
+
if content_spec.get(key):
|
|
521
|
+
metadata.setdefault({"organization": "기관", "author": "작성", "date": "연도"}[key], str(content_spec[key]))
|
|
522
|
+
if metadata:
|
|
523
|
+
doc.add_paragraph("문서 정보", char_pr_id_ref=heading_style)
|
|
524
|
+
for key, value in metadata.items():
|
|
525
|
+
doc.add_paragraph(f"{key}: {value}", char_pr_id_ref=body_style)
|
|
526
|
+
|
|
527
|
+
summary = str(content_spec.get("executive_summary") or "")
|
|
528
|
+
if summary:
|
|
529
|
+
doc.add_paragraph("핵심 요약", char_pr_id_ref=heading_style)
|
|
530
|
+
doc.add_paragraph(summary, char_pr_id_ref=callout_style)
|
|
531
|
+
|
|
532
|
+
for index, section in enumerate(content_spec.get("sections") or [], start=1):
|
|
533
|
+
if not isinstance(section, Mapping):
|
|
534
|
+
continue
|
|
535
|
+
title = str(section.get("title") or f"섹션 {index}")
|
|
536
|
+
doc.add_paragraph(f"{index}. {title}", char_pr_id_ref=heading_style)
|
|
537
|
+
for paragraph in _string_list(section.get("paragraphs") or []):
|
|
538
|
+
doc.add_paragraph(paragraph, char_pr_id_ref=body_style)
|
|
539
|
+
for bullet in _string_list(section.get("bullets") or []):
|
|
540
|
+
doc.add_paragraph(f"• {bullet}", char_pr_id_ref=body_style)
|
|
541
|
+
|
|
542
|
+
budget_items = [item for item in content_spec.get("budget_items") or [] if isinstance(item, Mapping)]
|
|
543
|
+
if budget_items:
|
|
544
|
+
doc.add_paragraph("예산 및 자원 계획", char_pr_id_ref=heading_style)
|
|
545
|
+
headers = ("항목", "금액", "비고")
|
|
546
|
+
table = doc.add_table(len(budget_items) + 1, 3, width=_READABLE_TABLE_WIDTH)
|
|
547
|
+
for col, label in enumerate(headers):
|
|
548
|
+
table.set_cell_text(0, col, label)
|
|
549
|
+
for row, item in enumerate(budget_items, start=1):
|
|
550
|
+
table.set_cell_text(row, 0, str(item.get("item", "")))
|
|
551
|
+
table.set_cell_text(row, 1, str(item.get("amount", "")))
|
|
552
|
+
table.set_cell_text(row, 2, str(item.get("note", "")))
|
|
553
|
+
|
|
554
|
+
outcomes = _string_list(content_spec.get("expected_outcomes") or [])
|
|
555
|
+
if outcomes:
|
|
556
|
+
doc.add_paragraph("기대 효과", char_pr_id_ref=heading_style)
|
|
557
|
+
for outcome in outcomes:
|
|
558
|
+
doc.add_paragraph(f"• {outcome}", char_pr_id_ref=body_style)
|
|
559
|
+
|
|
560
|
+
closing = str(content_spec.get("closing") or "")
|
|
561
|
+
if closing:
|
|
562
|
+
doc.add_paragraph("마무리", char_pr_id_ref=heading_style)
|
|
563
|
+
doc.add_paragraph(closing, char_pr_id_ref=body_style)
|
|
564
|
+
|
|
565
|
+
# Keep an otherwise-unused style token visible in the package for quality
|
|
566
|
+
# reports that expect a bounded semantic style vocabulary.
|
|
567
|
+
del table_header_style
|
|
568
|
+
return doc
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _inspect_quality_fallback(path: str) -> dict[str, Any]:
|
|
572
|
+
doc = open_doc(path)
|
|
573
|
+
try:
|
|
574
|
+
texts = [(getattr(paragraph, "text", "") or "").strip() for paragraph in getattr(doc, "paragraphs", [])]
|
|
575
|
+
joined = "\n".join(text for text in texts if text)
|
|
576
|
+
tables = len(_table_summaries(doc))
|
|
577
|
+
validation = validate_document_path(path)
|
|
578
|
+
style_usage = _style_summary(doc)
|
|
579
|
+
finally:
|
|
580
|
+
close = getattr(doc, "close", None)
|
|
581
|
+
if callable(close):
|
|
582
|
+
close()
|
|
583
|
+
|
|
584
|
+
required = {
|
|
585
|
+
"title": bool(joined.strip()),
|
|
586
|
+
"metadata": "문서 정보" in joined or tables >= 1,
|
|
587
|
+
"executive_summary": "요약" in joined,
|
|
588
|
+
"body_sections": sum(1 for text in texts if _looks_like_heading(text)) >= 4,
|
|
589
|
+
"budget": "예산" in joined or "자원" in joined,
|
|
590
|
+
"expected_outcomes": "기대 효과" in joined or "성과" in joined,
|
|
591
|
+
"closing": "마무리" in joined or "추진" in joined,
|
|
592
|
+
}
|
|
593
|
+
style_count = int(style_usage.get("used_run_style_count", 0))
|
|
594
|
+
scores = {
|
|
595
|
+
"outline": 5.0 if required["body_sections"] else 2.5,
|
|
596
|
+
"tables": 5.0 if tables >= 2 else (4.0 if tables == 1 else 2.0),
|
|
597
|
+
"content": 5.0 * sum(required.values()) / len(required),
|
|
598
|
+
"style_tokens": 5.0 if style_count >= 4 else (4.0 if style_count >= 3 else 3.0),
|
|
599
|
+
"validation": 5.0 if validation.ok else 2.0,
|
|
600
|
+
}
|
|
601
|
+
average = round(sum(scores.values()) / len(scores), 2)
|
|
602
|
+
failed = [name for name, present in required.items() if not present]
|
|
603
|
+
return {
|
|
604
|
+
"pass": average >= 4.0 and validation.ok and not failed,
|
|
605
|
+
"report_version": "quality-generation-fallback-v1",
|
|
606
|
+
"outline": {"required_sections_present": not failed, "required": required},
|
|
607
|
+
"table_checks": {"table_count": tables, "has_structured_tables": tables >= 1},
|
|
608
|
+
"style_token_usage": style_usage,
|
|
609
|
+
"sample_match": {
|
|
610
|
+
"average": average,
|
|
611
|
+
"pass": average >= 4.0 and not failed,
|
|
612
|
+
"visual_review_required": True,
|
|
613
|
+
"dimensions": {},
|
|
614
|
+
},
|
|
615
|
+
"rubric_scores": scores,
|
|
616
|
+
"rubric_average": average,
|
|
617
|
+
"gaps": [f"missing required quality trait: {name}" for name in failed],
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _runtime_validation(path: str) -> dict[str, Any]:
|
|
622
|
+
if validate_package is None:
|
|
623
|
+
package_payload = _dependency_unavailable_validation(
|
|
624
|
+
"python-hwpx>=2.10.3 is required for HWPX package validation",
|
|
625
|
+
_PACKAGE_VALIDATOR_IMPORT_ERROR,
|
|
626
|
+
)
|
|
627
|
+
else:
|
|
628
|
+
package = validate_package(path)
|
|
629
|
+
package_payload = {"ok": bool(package.ok), "errors": _report_errors(package)}
|
|
630
|
+
document = validate_document_path(path)
|
|
631
|
+
open_safety = build_hwpx_open_safety_report(Path(path))
|
|
632
|
+
reopened = False
|
|
633
|
+
try:
|
|
634
|
+
doc = open_doc(path)
|
|
635
|
+
reopened = bool(doc.paragraphs is not None)
|
|
636
|
+
close = getattr(doc, "close", None)
|
|
637
|
+
if callable(close):
|
|
638
|
+
close()
|
|
639
|
+
except Exception:
|
|
640
|
+
reopened = False
|
|
641
|
+
return {
|
|
642
|
+
"reopened": reopened,
|
|
643
|
+
"validate_package": package_payload,
|
|
644
|
+
"validate_document": {"ok": bool(document.ok), "errors": _report_errors(document)},
|
|
645
|
+
"openSafety": open_safety,
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _dependency_unavailable_validation(message: str, error: Exception | None) -> dict[str, Any]:
|
|
650
|
+
detail = f"{message}: {error}" if error is not None else message
|
|
651
|
+
return {"ok": False, "errors": [detail]}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _report_errors(report: Any) -> list[Any]:
|
|
655
|
+
for attr in ("errors", "issues", "messages"):
|
|
656
|
+
value = getattr(report, attr, None)
|
|
657
|
+
if value is not None:
|
|
658
|
+
try:
|
|
659
|
+
return list(value)
|
|
660
|
+
except TypeError:
|
|
661
|
+
return [value]
|
|
662
|
+
return []
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _quality_result(report: Mapping[str, Any], validation: Mapping[str, Any]) -> dict[str, Any]:
|
|
666
|
+
sample_match = report.get("sample_match") or {}
|
|
667
|
+
gaps = list(report.get("gaps") or [])
|
|
668
|
+
if not validation.get("reopened"):
|
|
669
|
+
gaps.append("generated document could not be reopened")
|
|
670
|
+
if not validation.get("validate_package", {}).get("ok"):
|
|
671
|
+
gaps.append("package validation failed")
|
|
672
|
+
if not validation.get("validate_document", {}).get("ok"):
|
|
673
|
+
gaps.append("document validation failed")
|
|
674
|
+
if not validation.get("openSafety", {}).get("ok"):
|
|
675
|
+
gaps.append("editor-open safety validation failed")
|
|
676
|
+
passed = bool(
|
|
677
|
+
report.get("pass")
|
|
678
|
+
and validation.get("reopened")
|
|
679
|
+
and validation.get("validate_package", {}).get("ok")
|
|
680
|
+
and validation.get("validate_document", {}).get("ok")
|
|
681
|
+
and validation.get("openSafety", {}).get("ok")
|
|
682
|
+
)
|
|
683
|
+
return {
|
|
684
|
+
"pass": passed,
|
|
685
|
+
"rubric_average": report.get("rubric_average"),
|
|
686
|
+
"sample_match_average": sample_match.get("average"),
|
|
687
|
+
"sample_match_pass": sample_match.get("pass"),
|
|
688
|
+
"visual_review_required": sample_match.get("visual_review_required", True),
|
|
689
|
+
"gaps": gaps,
|
|
690
|
+
"revision_recommended": not passed,
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _revise_content_spec(content_spec: Mapping[str, Any], gaps: list[str]) -> dict[str, Any]:
|
|
695
|
+
revised = copy.deepcopy(dict(content_spec))
|
|
696
|
+
sections = list(revised.get("sections") or [])
|
|
697
|
+
existing_titles = {str(section.get("title")) for section in sections if isinstance(section, Mapping)}
|
|
698
|
+
for title in _DEFAULT_SECTION_TITLES:
|
|
699
|
+
if title not in existing_titles:
|
|
700
|
+
sections.append(
|
|
701
|
+
{
|
|
702
|
+
"title": title,
|
|
703
|
+
"paragraphs": [f"{title}을(를) 보강해 운영 계획의 완성도를 높입니다."],
|
|
704
|
+
"bullets": ["세부 실행 과제를 정리한다.", "성과 확인 방법을 포함한다."],
|
|
705
|
+
}
|
|
706
|
+
)
|
|
707
|
+
revised["sections"] = sections
|
|
708
|
+
if not revised.get("executive_summary"):
|
|
709
|
+
revised["executive_summary"] = _derive_summary(str(revised.get("source_brief") or ""), title=str(revised.get("title") or "운영계획"))
|
|
710
|
+
if not revised.get("budget_items"):
|
|
711
|
+
revised["budget_items"] = _normalize_budget_items([])
|
|
712
|
+
if not revised.get("expected_outcomes"):
|
|
713
|
+
revised["expected_outcomes"] = [
|
|
714
|
+
"AI 기반 수업 혁신 사례를 축적한다.",
|
|
715
|
+
"학생 맞춤형 학습 지원 체계를 강화한다.",
|
|
716
|
+
]
|
|
717
|
+
revised.setdefault("revision_notes", []).append({"reason": "quality gaps", "gaps": list(gaps)})
|
|
718
|
+
return revised
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# Compatibility helpers for MCP tools whose installed python-hwpx build may
|
|
722
|
+
# not yet include proposal presets. Keep the actual implementation private so
|
|
723
|
+
# the quality-generation workflow remains the owner of this fallback behavior.
|
|
724
|
+
create_quality_document_fallback = _create_quality_document_fallback
|
|
725
|
+
inspect_quality_fallback = _inspect_quality_fallback
|