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,680 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Planning pipeline and validation helpers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Dict, List, Optional, Sequence, Literal
8
+ from uuid import uuid4
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, conint, constr, model_validator
11
+
12
+ from ..metadata import tools_meta
13
+ from .context import SearchHit, search_paragraphs, window_for_paragraph
14
+ from .diff import ParagraphDiff, ParagraphMatch
15
+ from .handles import stable_node_id
16
+ from .locator import (
17
+ DocumentLocator,
18
+ document_locator_schema,
19
+ locator_identifier,
20
+ locator_path,
21
+ normalize_locator_payload,
22
+ )
23
+ from .txn import IdempotentReplayError, TransactionManager
24
+
25
+
26
+ class PlanModel(BaseModel):
27
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
28
+
29
+
30
+ class PlanLocatorModel(PlanModel):
31
+ document: DocumentLocator = Field(alias="document")
32
+
33
+ @model_validator(mode="before")
34
+ @classmethod
35
+ def _inflate_document(cls, data: object) -> object:
36
+ if isinstance(data, dict):
37
+ return normalize_locator_payload(dict(data), field_name="document")
38
+ return data
39
+
40
+ @property
41
+ def doc_id(self) -> str:
42
+ return locator_identifier(self.document)
43
+
44
+ def path_or_none(self) -> Optional[str]:
45
+ return locator_path(self.document)
46
+
47
+ def to_hwpx_payload(self, *, require_path: bool = True) -> Dict[str, Any]:
48
+ payload = self.model_dump(exclude={"document"})
49
+ path = self.path_or_none()
50
+ if path is None:
51
+ if require_path:
52
+ raise ValueError("document locator must include path or uri")
53
+ payload["path"] = self.doc_id
54
+ else:
55
+ payload["path"] = path
56
+ return payload
57
+
58
+ @classmethod
59
+ def model_json_schema(cls, *args: Any, **kwargs: Any) -> Dict[str, Any]:
60
+ schema = super().model_json_schema(*args, **kwargs)
61
+ properties = schema.get("properties")
62
+ if isinstance(properties, dict) and "document" in properties:
63
+ properties["document"] = document_locator_schema()
64
+ return schema
65
+
66
+
67
+ class Target(PlanModel):
68
+ node_id: Optional[constr(pattern=r"^n_[A-Za-z0-9]{8,}$")] = Field(None, alias="nodeId")
69
+ section_index: Optional[conint(ge=0)] = Field(None, alias="sectionIndex")
70
+ para_index: Optional[conint(ge=0)] = Field(None, alias="paraIndex")
71
+
72
+ @model_validator(mode="after")
73
+ def check_selector(self) -> "Target":
74
+ has_node = self.node_id is not None
75
+ has_indices = self.section_index is not None and self.para_index is not None
76
+ if has_node == has_indices:
77
+ raise ValueError("target must specify nodeId or sectionIndex+paraIndex")
78
+ return self
79
+
80
+
81
+ class ReplaceTextArgs(PlanModel):
82
+ target: Target
83
+ match: constr(min_length=1)
84
+ replacement: str = ""
85
+ limit: conint(ge=1, le=100) = 1
86
+ dry_run: bool = Field(True, alias="dryRun")
87
+ atomic: bool = True
88
+
89
+
90
+ class PlanEditInput(PlanLocatorModel):
91
+ operations: List[ReplaceTextArgs]
92
+ trace_id: Optional[constr(min_length=1)] = Field(None, alias="traceId")
93
+
94
+ @model_validator(mode="after")
95
+ def ensure_operations(self) -> "PlanEditInput":
96
+ if not self.operations:
97
+ raise ValueError("operations must not be empty")
98
+ return self
99
+
100
+
101
+ class PreviewEditInput(PlanModel):
102
+ plan_id: constr(min_length=1) = Field(alias="planId")
103
+ trace_id: Optional[constr(min_length=1)] = Field(None, alias="traceId")
104
+
105
+
106
+ class ApplyEditInput(PlanModel):
107
+ plan_id: constr(min_length=1) = Field(alias="planId")
108
+ confirm: bool
109
+ idempotency_key: Optional[constr(min_length=1)] = Field(None, alias="idempotencyKey")
110
+ trace_id: Optional[constr(min_length=1)] = Field(None, alias="traceId")
111
+
112
+
113
+ class NextAction(PlanModel):
114
+ tool: constr(min_length=1)
115
+ example_json: constr(min_length=2) = Field(alias="exampleJson")
116
+
117
+
118
+ class DiffPreview(PlanModel):
119
+ paragraph_index: conint(ge=0) = Field(alias="paragraphIndex")
120
+ before: str
121
+ after: str
122
+
123
+
124
+ class Candidate(PlanModel):
125
+ node_id: constr(min_length=1) = Field(alias="nodeId")
126
+ paragraph_index: conint(ge=0) = Field(alias="paragraphIndex")
127
+ context: constr(min_length=1)
128
+
129
+
130
+ class PlanSummaryData(PlanModel):
131
+ plan_id: constr(min_length=1) = Field(alias="planId")
132
+ complexity_score: conint(ge=0) = Field(alias="complexityScore")
133
+ safe: bool
134
+ preview_available: bool = Field(alias="previewAvailable")
135
+
136
+
137
+ class PreviewData(PlanModel):
138
+ plan_id: constr(min_length=1) = Field(alias="planId")
139
+ diff: List[DiffPreview]
140
+ complexity_score: conint(ge=0) = Field(alias="complexityScore")
141
+ safe: bool
142
+
143
+
144
+ class ApplyData(PlanModel):
145
+ plan_id: constr(min_length=1) = Field(alias="planId")
146
+ applied: bool
147
+
148
+
149
+ class ResponseData(PlanModel):
150
+ plan: Optional[PlanSummaryData] = None
151
+ preview: Optional[PreviewData] = None
152
+ apply: Optional[ApplyData] = None
153
+
154
+
155
+ ErrorCode = Literal[
156
+ "AMBIGUOUS_TARGET",
157
+ "UNSAFE_WILDCARD",
158
+ "MISSING_NODE",
159
+ "RANGE_OUT_OF_BOUNDS",
160
+ "EMPTY_MATCH",
161
+ "CONFLICTING_TARGETS",
162
+ "PREVIEW_REQUIRED",
163
+ "IDEMPOTENT_REPLAY",
164
+ ]
165
+
166
+
167
+ class ErrorPayload(PlanModel):
168
+ error_code: ErrorCode = Field(alias="errorCode")
169
+ message: str
170
+ candidates: List[Candidate] = Field(default_factory=list)
171
+ hint: Optional[str] = None
172
+
173
+
174
+ class ServerResponse(PlanModel):
175
+ ok: bool
176
+ data: Optional[ResponseData] = None
177
+ error: Optional[ErrorPayload] = None
178
+ next_actions: List[NextAction] = Field(default_factory=list, alias="nextActions")
179
+ api_version: constr(min_length=1) = Field("1.2.0", alias="apiVersion")
180
+ doc_version: constr(min_length=1) = Field(alias="docVersion")
181
+ trace_id: constr(min_length=1) = Field(alias="traceId")
182
+
183
+
184
+ class SearchInput(PlanLocatorModel):
185
+ pattern: constr(min_length=1)
186
+ scope: Optional[constr(min_length=1)] = None
187
+ is_regex: bool = Field(False, alias="isRegex")
188
+ limit: conint(ge=1, le=50) = 20
189
+
190
+
191
+ class SearchHitModel(PlanModel):
192
+ node_id: constr(min_length=1) = Field(alias="nodeId")
193
+ paragraph_index: conint(ge=0) = Field(alias="paragraphIndex")
194
+ match: str
195
+ context: str
196
+
197
+
198
+ class SearchOutput(PlanModel):
199
+ matches: List[SearchHitModel]
200
+
201
+
202
+ class GetContextInput(PlanLocatorModel):
203
+ target: Target
204
+ window: conint(ge=1, le=3) = 1
205
+
206
+
207
+ class ContextOutput(PlanModel):
208
+ before: List[str]
209
+ focus: str
210
+ after: List[str]
211
+
212
+
213
+ class PipelineError(RuntimeError):
214
+ """Exception used to carry pipeline validation failures."""
215
+
216
+ def __init__(
217
+ self,
218
+ error_code: str,
219
+ message: str,
220
+ *,
221
+ candidates: Optional[List[Candidate]] = None,
222
+ hint: Optional[str] = None,
223
+ ) -> None:
224
+ super().__init__(message)
225
+ self.error_code = error_code
226
+ self.message = message
227
+ self.candidates = candidates or []
228
+ self.hint = hint
229
+
230
+
231
+ @dataclass
232
+ class CandidateInfo:
233
+ node_id: str
234
+ paragraph_index: int
235
+ context: str
236
+
237
+ def to_model(self) -> Candidate:
238
+ return Candidate(nodeId=self.node_id, paragraphIndex=self.paragraph_index, context=self.context)
239
+
240
+
241
+ @dataclass
242
+ class DocumentState:
243
+ doc_id: str
244
+ content: str
245
+ version: int = 1
246
+ _paragraphs: List[str] = field(init=False, repr=False)
247
+ _node_lookup: Dict[str, int] = field(init=False, repr=False)
248
+
249
+ def __post_init__(self) -> None:
250
+ self._rebuild()
251
+
252
+ def _rebuild(self) -> None:
253
+ self._paragraphs = self.content.splitlines()
254
+ self._node_lookup = {
255
+ stable_node_id((self.doc_id, index)): index for index in range(len(self._paragraphs))
256
+ }
257
+
258
+ def paragraphs(self) -> List[str]:
259
+ return list(self._paragraphs)
260
+
261
+ def node_for_index(self, index: int) -> str:
262
+ return stable_node_id((self.doc_id, index))
263
+
264
+ def index_for_node(self, node_id: str) -> Optional[int]:
265
+ return self._node_lookup.get(node_id)
266
+
267
+ def set_content(self, content: str, *, bump_version: bool = True) -> None:
268
+ self.content = content
269
+ if bump_version:
270
+ self.version += 1
271
+ self._rebuild()
272
+
273
+
274
+ @dataclass
275
+ class PlanRecord:
276
+ plan_id: str
277
+ doc_id: str
278
+ trace_id: str
279
+ base_version: int
280
+ diffs: List[ParagraphDiff]
281
+ candidates: List[CandidateInfo]
282
+ unsafe: bool
283
+ complexity_score: int
284
+ consumed: bool = False
285
+
286
+ @property
287
+ def safe(self) -> bool:
288
+ return not self.candidates and not self.unsafe
289
+
290
+
291
+ @dataclass
292
+ class PreviewRecord:
293
+ plan_id: str
294
+ doc_id: str
295
+ diff_preview: List[DiffPreview]
296
+ complexity_score: int
297
+ safe: bool
298
+ trace_id: str
299
+
300
+
301
+ class PlanManager:
302
+ """Manage plan/preview/apply lifecycle."""
303
+
304
+ def __init__(self) -> None:
305
+ self._documents: Dict[str, DocumentState] = {}
306
+ self._plans: Dict[str, PlanRecord] = {}
307
+ self._previews: Dict[str, PreviewRecord] = {}
308
+ self._txn = TransactionManager[List[str]]()
309
+
310
+ # ------------------------------------------------------------------
311
+ # Document lifecycle helpers
312
+ # ------------------------------------------------------------------
313
+ def register_document(self, doc_id: str, content: str) -> None:
314
+ state = self._documents.get(doc_id)
315
+ if state is None:
316
+ self._documents[doc_id] = DocumentState(doc_id=doc_id, content=content)
317
+ else:
318
+ state.set_content(content)
319
+
320
+ def has_document(self, doc_id: str) -> bool:
321
+ return doc_id in self._documents
322
+
323
+ def get_document_text(self, doc_id: str) -> str:
324
+ state = self._documents.get(doc_id)
325
+ if state is None:
326
+ raise PipelineError("MISSING_NODE", f"document '{doc_id}' is not registered")
327
+ return state.content
328
+
329
+ def replace_document(self, doc_id: str, content: str) -> None:
330
+ state = self._documents.get(doc_id)
331
+ if state is None:
332
+ self._documents[doc_id] = DocumentState(doc_id=doc_id, content=content)
333
+ else:
334
+ state.set_content(content)
335
+
336
+ def doc_version(self, doc_id: str) -> str:
337
+ state = self._documents.get(doc_id)
338
+ if state is None:
339
+ return "0"
340
+ return str(state.version)
341
+
342
+ # ------------------------------------------------------------------
343
+ # Planning lifecycle
344
+ # ------------------------------------------------------------------
345
+ def create_plan_record(
346
+ self,
347
+ doc_id: str,
348
+ operations: Sequence[ReplaceTextArgs | Dict[str, object]],
349
+ *,
350
+ trace_id: Optional[str] = None,
351
+ ) -> PlanRecord:
352
+ state = self._documents.get(doc_id)
353
+ if state is None:
354
+ raise PipelineError("MISSING_NODE", f"document '{doc_id}' is not registered")
355
+
356
+ resolved_ops: List[ReplaceTextArgs] = []
357
+ for op in operations:
358
+ if isinstance(op, ReplaceTextArgs):
359
+ resolved_ops.append(op)
360
+ else:
361
+ try:
362
+ resolved_ops.append(ReplaceTextArgs.model_validate(op))
363
+ except ValidationError as exc: # pragma: no cover - defensive
364
+ raise PipelineError("EMPTY_MATCH", str(exc)) from exc
365
+
366
+ plan_id = f"pln_{uuid4().hex}"
367
+ paragraphs = state.paragraphs()
368
+
369
+ all_diffs: List[ParagraphDiff] = []
370
+ candidates: List[CandidateInfo] = []
371
+ unsafe = False
372
+
373
+ for op in resolved_ops:
374
+ matches = self._find_matches(state, paragraphs, op)
375
+ if not matches:
376
+ raise PipelineError("MISSING_NODE", "target match not found")
377
+
378
+ if op.limit < len(matches):
379
+ unsafe = True
380
+
381
+ if len(matches) > 1:
382
+ for match in matches[:5]:
383
+ context = match.context(paragraphs[match.paragraph_index])
384
+ candidates.append(
385
+ CandidateInfo(
386
+ node_id=state.node_for_index(match.paragraph_index),
387
+ paragraph_index=match.paragraph_index,
388
+ context=context,
389
+ )
390
+ )
391
+
392
+ for match in matches[: op.limit]:
393
+ all_diffs.append(
394
+ ParagraphDiff(
395
+ paragraph_index=match.paragraph_index,
396
+ start=match.start,
397
+ end=match.end,
398
+ before=match.text,
399
+ after=op.replacement,
400
+ )
401
+ )
402
+
403
+ trace = trace_id or plan_id
404
+ record = PlanRecord(
405
+ plan_id=plan_id,
406
+ doc_id=doc_id,
407
+ trace_id=trace,
408
+ base_version=state.version,
409
+ diffs=all_diffs,
410
+ candidates=candidates,
411
+ unsafe=unsafe,
412
+ complexity_score=len(all_diffs),
413
+ )
414
+ self._plans[plan_id] = record
415
+ return record
416
+
417
+ def get_plan_record(self, plan_id: str) -> Optional[PlanRecord]:
418
+ return self._plans.get(plan_id)
419
+
420
+ def preview_plan_record(self, plan_id: str) -> PreviewRecord:
421
+ plan = self._plans.get(plan_id)
422
+ if plan is None:
423
+ raise PipelineError("PREVIEW_REQUIRED", "plan is not registered")
424
+ if plan.consumed:
425
+ raise PipelineError("PREVIEW_REQUIRED", "plan has already been applied")
426
+
427
+ state = self._documents.get(plan.doc_id)
428
+ if state is None:
429
+ raise PipelineError("MISSING_NODE", f"document '{plan.doc_id}' is not registered")
430
+ if state.version != plan.base_version:
431
+ raise PipelineError("CONFLICTING_TARGETS", "document has changed since planning")
432
+
433
+ if plan.unsafe:
434
+ raise PipelineError("UNSAFE_WILDCARD", "match count exceeds provided limit")
435
+ if plan.candidates:
436
+ raise PipelineError(
437
+ "AMBIGUOUS_TARGET",
438
+ "multiple matches require clarification",
439
+ candidates=[info.to_model() for info in plan.candidates],
440
+ )
441
+
442
+ diff_models = [
443
+ DiffPreview(
444
+ paragraphIndex=diff.paragraph_index,
445
+ before=diff.before,
446
+ after=diff.after,
447
+ )
448
+ for diff in plan.diffs
449
+ ]
450
+
451
+ preview = PreviewRecord(
452
+ plan_id=plan.plan_id,
453
+ doc_id=plan.doc_id,
454
+ diff_preview=diff_models,
455
+ complexity_score=plan.complexity_score,
456
+ safe=plan.safe,
457
+ trace_id=plan.trace_id,
458
+ )
459
+ self._previews[plan.plan_id] = preview
460
+ return preview
461
+
462
+ def apply_plan_record(
463
+ self,
464
+ plan_id: str,
465
+ *,
466
+ confirm: bool,
467
+ idempotency_key: Optional[str] = None,
468
+ ) -> ApplyData:
469
+ plan = self._plans.get(plan_id)
470
+ if plan is None:
471
+ raise PipelineError("PREVIEW_REQUIRED", "plan is not registered")
472
+
473
+ if idempotency_key:
474
+ try:
475
+ self._txn.ensure_idempotency(plan.doc_id, idempotency_key)
476
+ except IdempotentReplayError as exc:
477
+ raise PipelineError("IDEMPOTENT_REPLAY", str(exc)) from exc
478
+
479
+ if plan.consumed:
480
+ raise PipelineError("PREVIEW_REQUIRED", "plan has already been applied")
481
+
482
+ preview = self._previews.get(plan_id)
483
+ if preview is None:
484
+ raise PipelineError("PREVIEW_REQUIRED", "preview must be executed before apply")
485
+
486
+ state = self._documents.get(plan.doc_id)
487
+ if state is None:
488
+ raise PipelineError("MISSING_NODE", f"document '{plan.doc_id}' is not registered")
489
+ if state.version != plan.base_version:
490
+ raise PipelineError("CONFLICTING_TARGETS", "document has changed since planning")
491
+
492
+ if not confirm:
493
+ raise PipelineError("PREVIEW_REQUIRED", "confirm flag must be true")
494
+
495
+ snapshot = state.paragraphs()
496
+
497
+ def mutator(paragraphs: List[str]) -> List[str]:
498
+ working = paragraphs
499
+ for diff in plan.diffs:
500
+ working = diff.apply(working)
501
+ return working
502
+
503
+ def applier(paragraphs: List[str]) -> None:
504
+ state.set_content("\n".join(paragraphs))
505
+
506
+ self._txn.atomic(snapshot, mutator, applier)
507
+
508
+ if idempotency_key:
509
+ self._txn.record_idempotency(plan.doc_id, idempotency_key, {"planId": plan_id})
510
+
511
+ plan.consumed = True
512
+ self._previews.pop(plan_id, None)
513
+
514
+ return ApplyData(planId=plan.plan_id, applied=True)
515
+
516
+ # ------------------------------------------------------------------
517
+ # Searching and context helpers
518
+ # ------------------------------------------------------------------
519
+ def search_document(self, doc_id: str, params: SearchInput) -> List[SearchHit]:
520
+ state = self._documents.get(doc_id)
521
+ if state is None:
522
+ raise PipelineError("MISSING_NODE", f"document '{doc_id}' is not registered")
523
+ paragraphs = state.paragraphs()
524
+ hits = search_paragraphs(
525
+ paragraphs,
526
+ pattern=params.pattern,
527
+ limit=params.limit,
528
+ use_regex=params.is_regex,
529
+ node_resolver=state.node_for_index,
530
+ )
531
+ return hits
532
+
533
+ def context_window(self, doc_id: str, target: Target, *, window: int) -> ContextOutput:
534
+ state = self._documents.get(doc_id)
535
+ if state is None:
536
+ raise PipelineError("MISSING_NODE", f"document '{doc_id}' is not registered")
537
+ index = self._resolve_target_index(state, target)
538
+ paragraphs = state.paragraphs()
539
+ view = window_for_paragraph(paragraphs, index, radius=window)
540
+ return ContextOutput(**view)
541
+
542
+ # ------------------------------------------------------------------
543
+ # Response helpers
544
+ # ------------------------------------------------------------------
545
+ def plan_response(self, record: PlanRecord) -> Dict[str, object]:
546
+ data = ResponseData(
547
+ plan=PlanSummaryData(
548
+ planId=record.plan_id,
549
+ complexityScore=record.complexity_score,
550
+ safe=record.safe,
551
+ previewAvailable=not record.consumed,
552
+ )
553
+ )
554
+ actions = [
555
+ NextAction.model_validate(tools_meta.PLAN_NEXT_ACTION.render(record.plan_id))
556
+ ]
557
+ response = ServerResponse(
558
+ ok=True,
559
+ data=data,
560
+ nextActions=actions,
561
+ docVersion=self.doc_version(record.doc_id),
562
+ traceId=record.trace_id,
563
+ )
564
+ return response.model_dump(by_alias=True, exclude_none=True)
565
+
566
+ def preview_response(self, preview: PreviewRecord) -> Dict[str, object]:
567
+ data = ResponseData(
568
+ preview=PreviewData(
569
+ planId=preview.plan_id,
570
+ diff=preview.diff_preview,
571
+ complexityScore=preview.complexity_score,
572
+ safe=preview.safe,
573
+ )
574
+ )
575
+ actions = [
576
+ NextAction.model_validate(tools_meta.PREVIEW_NEXT_ACTION.render(preview.plan_id))
577
+ ]
578
+ response = ServerResponse(
579
+ ok=True,
580
+ data=data,
581
+ nextActions=actions,
582
+ docVersion=self.doc_version(preview.doc_id),
583
+ traceId=preview.trace_id,
584
+ )
585
+ return response.model_dump(by_alias=True, exclude_none=True)
586
+
587
+ def apply_response(self, plan: PlanRecord, result: ApplyData, trace_id: str) -> Dict[str, object]:
588
+ data = ResponseData(apply=result)
589
+ response = ServerResponse(
590
+ ok=True,
591
+ data=data,
592
+ nextActions=[],
593
+ docVersion=self.doc_version(plan.doc_id),
594
+ traceId=trace_id,
595
+ )
596
+ return response.model_dump(by_alias=True, exclude_none=True)
597
+
598
+ def error_response(
599
+ self,
600
+ doc_id: str,
601
+ trace_id: str,
602
+ error: PipelineError,
603
+ *,
604
+ plan_id: Optional[str] = None,
605
+ next_action: Optional[tools_meta.ExampleTemplate] = None,
606
+ ) -> Dict[str, object]:
607
+ actions: List[NextAction] = []
608
+ if next_action is not None:
609
+ rendered_plan = plan_id or "<plan-id>"
610
+ actions.append(NextAction.model_validate(next_action.render(rendered_plan)))
611
+ payload = ErrorPayload(
612
+ errorCode=error.error_code,
613
+ message=error.message,
614
+ candidates=error.candidates,
615
+ hint=error.hint,
616
+ )
617
+ response = ServerResponse(
618
+ ok=False,
619
+ error=payload,
620
+ nextActions=actions,
621
+ docVersion=self.doc_version(doc_id),
622
+ traceId=trace_id,
623
+ )
624
+ return response.model_dump(by_alias=True, exclude_none=True)
625
+
626
+ # ------------------------------------------------------------------
627
+ # Internal helpers
628
+ # ------------------------------------------------------------------
629
+ def _find_matches(
630
+ self,
631
+ state: DocumentState,
632
+ paragraphs: Sequence[str],
633
+ op: ReplaceTextArgs,
634
+ ) -> List[ParagraphMatch]:
635
+ indices = self._resolve_scope(state, op.target, len(paragraphs))
636
+ matches: List[ParagraphMatch] = []
637
+ for index in indices:
638
+ paragraph = paragraphs[index]
639
+ start = 0
640
+ while True:
641
+ found = paragraph.find(op.match, start)
642
+ if found == -1:
643
+ break
644
+ match = ParagraphMatch(
645
+ paragraph_index=index,
646
+ start=found,
647
+ end=found + len(op.match),
648
+ text=op.match,
649
+ )
650
+ matches.append(match)
651
+ start = found + len(op.match)
652
+ return matches
653
+
654
+ def _resolve_scope(
655
+ self,
656
+ state: DocumentState,
657
+ target: Target,
658
+ paragraph_count: int,
659
+ ) -> List[int]:
660
+ if target.node_id is not None:
661
+ index = state.index_for_node(target.node_id)
662
+ if index is None:
663
+ raise PipelineError("MISSING_NODE", f"node '{target.node_id}' not found")
664
+ return [index]
665
+ index = target.para_index or 0
666
+ if index < 0 or index >= paragraph_count:
667
+ raise PipelineError("RANGE_OUT_OF_BOUNDS", "paragraph index out of range")
668
+ return [index]
669
+
670
+ def _resolve_target_index(self, state: DocumentState, target: Target) -> int:
671
+ if target.node_id is not None:
672
+ index = state.index_for_node(target.node_id)
673
+ if index is None:
674
+ raise PipelineError("MISSING_NODE", f"node '{target.node_id}' not found")
675
+ return index
676
+ index = target.para_index or 0
677
+ paragraphs = len(state.paragraphs())
678
+ if index < 0 or index >= paragraphs:
679
+ raise PipelineError("RANGE_OUT_OF_BOUNDS", "paragraph index out of range")
680
+ return index