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,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
|