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,155 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Canonical, non-XPath semantic paths for the HWPX agent facade."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .model import AgentContractError, NODE_KINDS
|
|
11
|
+
|
|
12
|
+
MAX_PATH_CHARS = 2048
|
|
13
|
+
MAX_PATH_SEGMENTS = 32
|
|
14
|
+
PATH_ATTRIBUTES = frozenset({"id", "name"})
|
|
15
|
+
|
|
16
|
+
_SEGMENT_RE = re.compile(
|
|
17
|
+
r"^(?P<kind>[a-z][a-z-]*)\["
|
|
18
|
+
r"(?:(?P<index>0*[1-9][0-9]*)|"
|
|
19
|
+
r"@(?P<attribute>[a-z]+)=(?P<value>\"(?:[^\"\\]|\\.)*\"))\]$"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class PathSegment:
|
|
25
|
+
kind: str
|
|
26
|
+
index: int | None = None
|
|
27
|
+
attribute: str | None = None
|
|
28
|
+
value: str | None = None
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if self.kind not in NODE_KINDS or self.kind in {"document", "unsupported"}:
|
|
32
|
+
raise AgentContractError("unknown_kind", f"unknown path kind: {self.kind}", target="path")
|
|
33
|
+
positional = self.index is not None
|
|
34
|
+
attributed = self.attribute is not None or self.value is not None
|
|
35
|
+
if positional == attributed:
|
|
36
|
+
raise AgentContractError(
|
|
37
|
+
"invalid_syntax", "path segment requires exactly one index or attribute", target="path"
|
|
38
|
+
)
|
|
39
|
+
if self.index is not None and (isinstance(self.index, bool) or self.index < 1):
|
|
40
|
+
raise AgentContractError("invalid_syntax", "path indexes are one-based", target="path")
|
|
41
|
+
if attributed and self.attribute not in PATH_ATTRIBUTES:
|
|
42
|
+
raise AgentContractError(
|
|
43
|
+
"invalid_syntax", f"unsupported path attribute: {self.attribute}", target="path"
|
|
44
|
+
)
|
|
45
|
+
if attributed and (not isinstance(self.value, str) or not self.value or len(self.value) > 256):
|
|
46
|
+
raise AgentContractError("resource_limit", "path attribute value is invalid", target="path")
|
|
47
|
+
|
|
48
|
+
def canonical(self) -> str:
|
|
49
|
+
if self.index is not None:
|
|
50
|
+
return f"{self.kind}[{self.index}]"
|
|
51
|
+
value = json.dumps(self.value, ensure_ascii=False, separators=(",", ":"))
|
|
52
|
+
return f"{self.kind}[@{self.attribute}={value}]"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class SemanticPath:
|
|
57
|
+
segments: tuple[PathSegment, ...] = ()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def canonical(self) -> str:
|
|
61
|
+
if not self.segments:
|
|
62
|
+
return "/"
|
|
63
|
+
return "/" + "/".join(segment.canonical() for segment in self.segments)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def parent(self) -> "SemanticPath | None":
|
|
67
|
+
if not self.segments:
|
|
68
|
+
return None
|
|
69
|
+
return SemanticPath(self.segments[:-1])
|
|
70
|
+
|
|
71
|
+
def child(self, segment: PathSegment) -> "SemanticPath":
|
|
72
|
+
if len(self.segments) >= MAX_PATH_SEGMENTS:
|
|
73
|
+
raise AgentContractError("resource_limit", "path nesting exceeds limit", target="path")
|
|
74
|
+
return SemanticPath((*self.segments, segment))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_path(value: str) -> SemanticPath:
|
|
78
|
+
"""Parse and canonicalize a bounded semantic path without invoking XPath."""
|
|
79
|
+
|
|
80
|
+
if not isinstance(value, str) or not value:
|
|
81
|
+
raise AgentContractError("invalid_syntax", "path must be a non-empty string", target="path")
|
|
82
|
+
if len(value) > MAX_PATH_CHARS:
|
|
83
|
+
raise AgentContractError("resource_limit", "path is too long", target="path")
|
|
84
|
+
if value == "/":
|
|
85
|
+
return SemanticPath()
|
|
86
|
+
if not value.startswith("/") or value.endswith("/"):
|
|
87
|
+
raise AgentContractError("invalid_syntax", "path must be absolute and canonical", target="path")
|
|
88
|
+
raw_segments: list[str] = []
|
|
89
|
+
start = 1
|
|
90
|
+
quoted = False
|
|
91
|
+
escaped = False
|
|
92
|
+
for index, char in enumerate(value[1:], start=1):
|
|
93
|
+
if quoted:
|
|
94
|
+
if escaped:
|
|
95
|
+
escaped = False
|
|
96
|
+
elif char == "\\":
|
|
97
|
+
escaped = True
|
|
98
|
+
elif char == '"':
|
|
99
|
+
quoted = False
|
|
100
|
+
elif char == '"':
|
|
101
|
+
quoted = True
|
|
102
|
+
elif char == "/":
|
|
103
|
+
raw_segments.append(value[start:index])
|
|
104
|
+
start = index + 1
|
|
105
|
+
if quoted:
|
|
106
|
+
raise AgentContractError("invalid_syntax", "unterminated path string", target="path")
|
|
107
|
+
raw_segments.append(value[start:])
|
|
108
|
+
if any(not segment for segment in raw_segments):
|
|
109
|
+
raise AgentContractError("invalid_syntax", "path contains an empty segment", target="path")
|
|
110
|
+
if len(raw_segments) > MAX_PATH_SEGMENTS:
|
|
111
|
+
raise AgentContractError("resource_limit", "path nesting exceeds limit", target="path")
|
|
112
|
+
segments: list[PathSegment] = []
|
|
113
|
+
for raw in raw_segments:
|
|
114
|
+
match = _SEGMENT_RE.fullmatch(raw)
|
|
115
|
+
if match is None:
|
|
116
|
+
raise AgentContractError("invalid_syntax", f"invalid path segment: {raw!r}", target="path")
|
|
117
|
+
kind = match.group("kind")
|
|
118
|
+
index_text = match.group("index")
|
|
119
|
+
if index_text is not None:
|
|
120
|
+
segments.append(PathSegment(kind=kind, index=int(index_text)))
|
|
121
|
+
continue
|
|
122
|
+
quoted_value = match.group("value")
|
|
123
|
+
try:
|
|
124
|
+
decoded = json.loads(quoted_value)
|
|
125
|
+
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
126
|
+
raise AgentContractError("invalid_syntax", "invalid path string literal", target="path") from exc
|
|
127
|
+
segments.append(
|
|
128
|
+
PathSegment(kind=kind, attribute=match.group("attribute"), value=decoded)
|
|
129
|
+
)
|
|
130
|
+
return SemanticPath(tuple(segments))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def canonicalize_path(value: str) -> str:
|
|
134
|
+
return parse_path(value).canonical
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def indexed_segment(kind: str, index: int) -> PathSegment:
|
|
138
|
+
return PathSegment(kind=kind, index=index)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def identified_segment(kind: str, value: str, *, attribute: str = "id") -> PathSegment:
|
|
142
|
+
return PathSegment(kind=kind, attribute=attribute, value=value)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = [
|
|
146
|
+
"MAX_PATH_CHARS",
|
|
147
|
+
"MAX_PATH_SEGMENTS",
|
|
148
|
+
"PATH_ATTRIBUTES",
|
|
149
|
+
"PathSegment",
|
|
150
|
+
"SemanticPath",
|
|
151
|
+
"canonicalize_path",
|
|
152
|
+
"identified_segment",
|
|
153
|
+
"indexed_segment",
|
|
154
|
+
"parse_path",
|
|
155
|
+
]
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Bounded selector v1 parser and in-memory semantic-tree evaluator."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import unicodedata
|
|
9
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from .model import (
|
|
14
|
+
MAX_QUERY_RESULTS,
|
|
15
|
+
MAX_SELECTOR_CHARS,
|
|
16
|
+
AgentContractError,
|
|
17
|
+
AgentNode,
|
|
18
|
+
SELECTOR_ATTRIBUTES,
|
|
19
|
+
SELECTOR_KINDS,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
MAX_SELECTOR_STEPS = 8
|
|
23
|
+
MAX_SELECTOR_FILTERS = 4
|
|
24
|
+
_KIND_RE = re.compile(r"[a-z][a-z-]*")
|
|
25
|
+
_ATTR_RE = re.compile(r"\[([a-z]+)=(\"(?:[^\"\\]|\\.)*\"|[^\]\s]+)\]")
|
|
26
|
+
_CONTAINS_RE = re.compile(r":contains\((\"(?:[^\"\\]|\\.)*\")\)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class QueryRecord(Protocol):
|
|
30
|
+
kind: str
|
|
31
|
+
path: str
|
|
32
|
+
parent_path: str | None
|
|
33
|
+
attributes: Mapping[str, str]
|
|
34
|
+
search_text: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class SelectorStep:
|
|
39
|
+
kind: str
|
|
40
|
+
attributes: tuple[tuple[str, str], ...] = ()
|
|
41
|
+
contains: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class SemanticSelector:
|
|
46
|
+
steps: tuple[SelectorStep, ...]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class QueryResult:
|
|
51
|
+
selector: str
|
|
52
|
+
revision: str
|
|
53
|
+
nodes: tuple[AgentNode, ...]
|
|
54
|
+
truncated: bool
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, object]:
|
|
57
|
+
return {
|
|
58
|
+
"schemaVersion": "hwpx.agent-query-result/v1",
|
|
59
|
+
"selector": self.selector,
|
|
60
|
+
"revision": self.revision,
|
|
61
|
+
"count": len(self.nodes),
|
|
62
|
+
"truncated": self.truncated,
|
|
63
|
+
"nodes": [node.to_dict() for node in self.nodes],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize_search_text(value: object) -> str:
|
|
68
|
+
text = unicodedata.normalize("NFKC", str(value or ""))
|
|
69
|
+
return " ".join(text.split()).casefold()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _split_steps(selector: str) -> list[str]:
|
|
73
|
+
result: list[str] = []
|
|
74
|
+
start = 0
|
|
75
|
+
quoted = False
|
|
76
|
+
escaped = False
|
|
77
|
+
bracket_depth = 0
|
|
78
|
+
paren_depth = 0
|
|
79
|
+
for index, char in enumerate(selector):
|
|
80
|
+
if quoted:
|
|
81
|
+
if escaped:
|
|
82
|
+
escaped = False
|
|
83
|
+
elif char == "\\":
|
|
84
|
+
escaped = True
|
|
85
|
+
elif char == '"':
|
|
86
|
+
quoted = False
|
|
87
|
+
continue
|
|
88
|
+
if char == '"':
|
|
89
|
+
quoted = True
|
|
90
|
+
elif char == "[":
|
|
91
|
+
bracket_depth += 1
|
|
92
|
+
elif char == "]":
|
|
93
|
+
bracket_depth -= 1
|
|
94
|
+
elif char == "(":
|
|
95
|
+
paren_depth += 1
|
|
96
|
+
elif char == ")":
|
|
97
|
+
paren_depth -= 1
|
|
98
|
+
elif char == ">" and bracket_depth == 0 and paren_depth == 0:
|
|
99
|
+
result.append(selector[start:index].strip())
|
|
100
|
+
start = index + 1
|
|
101
|
+
if bracket_depth < 0 or paren_depth < 0:
|
|
102
|
+
raise AgentContractError("invalid_syntax", "unbalanced selector", target="selector")
|
|
103
|
+
if quoted or bracket_depth or paren_depth:
|
|
104
|
+
raise AgentContractError("invalid_syntax", "unbalanced selector", target="selector")
|
|
105
|
+
result.append(selector[start:].strip())
|
|
106
|
+
if any(not step for step in result):
|
|
107
|
+
raise AgentContractError("invalid_syntax", "empty selector step", target="selector")
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _decode_literal(value: str) -> str:
|
|
112
|
+
if value.startswith('"'):
|
|
113
|
+
try:
|
|
114
|
+
decoded = json.loads(value)
|
|
115
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
116
|
+
raise AgentContractError("invalid_syntax", "invalid selector string", target="selector") from exc
|
|
117
|
+
else:
|
|
118
|
+
decoded = value
|
|
119
|
+
if not isinstance(decoded, str) or not decoded or len(decoded) > 256:
|
|
120
|
+
raise AgentContractError("resource_limit", "selector value is invalid", target="selector")
|
|
121
|
+
return decoded
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_step(raw: str) -> SelectorStep:
|
|
125
|
+
kind_match = _KIND_RE.match(raw)
|
|
126
|
+
if kind_match is None:
|
|
127
|
+
raise AgentContractError("invalid_syntax", "selector step requires a kind", target="selector")
|
|
128
|
+
kind = kind_match.group(0)
|
|
129
|
+
if kind not in SELECTOR_KINDS:
|
|
130
|
+
raise AgentContractError("unknown_kind", f"unknown selector kind: {kind}", target="selector")
|
|
131
|
+
cursor = kind_match.end()
|
|
132
|
+
attributes: list[tuple[str, str]] = []
|
|
133
|
+
contains: str | None = None
|
|
134
|
+
while cursor < len(raw):
|
|
135
|
+
attr_match = _ATTR_RE.match(raw, cursor)
|
|
136
|
+
if attr_match is not None:
|
|
137
|
+
name = attr_match.group(1)
|
|
138
|
+
if name not in SELECTOR_ATTRIBUTES:
|
|
139
|
+
raise AgentContractError(
|
|
140
|
+
"unknown_property", f"unsupported selector attribute: {name}", target="selector"
|
|
141
|
+
)
|
|
142
|
+
if any(existing == name for existing, _ in attributes):
|
|
143
|
+
raise AgentContractError("invalid_syntax", "duplicate selector attribute", target="selector")
|
|
144
|
+
attributes.append((name, _decode_literal(attr_match.group(2))))
|
|
145
|
+
if len(attributes) > MAX_SELECTOR_FILTERS:
|
|
146
|
+
raise AgentContractError("resource_limit", "too many selector filters", target="selector")
|
|
147
|
+
cursor = attr_match.end()
|
|
148
|
+
continue
|
|
149
|
+
contains_match = _CONTAINS_RE.match(raw, cursor)
|
|
150
|
+
if contains_match is not None and contains is None:
|
|
151
|
+
contains = _decode_literal(contains_match.group(1))
|
|
152
|
+
cursor = contains_match.end()
|
|
153
|
+
continue
|
|
154
|
+
raise AgentContractError(
|
|
155
|
+
"invalid_syntax", f"invalid selector near {raw[cursor:]!r}", target="selector"
|
|
156
|
+
)
|
|
157
|
+
return SelectorStep(kind=kind, attributes=tuple(attributes), contains=contains)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_selector(value: str) -> SemanticSelector:
|
|
161
|
+
if not isinstance(value, str) or not value.strip():
|
|
162
|
+
raise AgentContractError("invalid_syntax", "selector must be non-empty", target="selector")
|
|
163
|
+
if len(value) > MAX_SELECTOR_CHARS:
|
|
164
|
+
raise AgentContractError("resource_limit", "selector is too long", target="selector")
|
|
165
|
+
raw_steps = _split_steps(value.strip())
|
|
166
|
+
if len(raw_steps) > MAX_SELECTOR_STEPS:
|
|
167
|
+
raise AgentContractError("resource_limit", "selector nesting exceeds limit", target="selector")
|
|
168
|
+
return SemanticSelector(tuple(_parse_step(step) for step in raw_steps))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _matches(record: QueryRecord, step: SelectorStep) -> bool:
|
|
172
|
+
if record.kind != step.kind:
|
|
173
|
+
return False
|
|
174
|
+
for name, expected in step.attributes:
|
|
175
|
+
actual = record.attributes.get(name)
|
|
176
|
+
if actual is None or normalize_search_text(actual) != normalize_search_text(expected):
|
|
177
|
+
return False
|
|
178
|
+
if (
|
|
179
|
+
step.contains is not None
|
|
180
|
+
and normalize_search_text(step.contains) not in normalize_search_text(record.search_text)
|
|
181
|
+
):
|
|
182
|
+
return False
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def evaluate_selector(
|
|
187
|
+
records: Sequence[QueryRecord] | Iterable[QueryRecord],
|
|
188
|
+
selector: SemanticSelector,
|
|
189
|
+
*,
|
|
190
|
+
limit: int,
|
|
191
|
+
) -> tuple[list[QueryRecord], bool]:
|
|
192
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_QUERY_RESULTS:
|
|
193
|
+
raise AgentContractError(
|
|
194
|
+
"resource_limit", f"query limit must be 1..{MAX_QUERY_RESULTS}", target="limit"
|
|
195
|
+
)
|
|
196
|
+
ordered = list(records)
|
|
197
|
+
by_path = {record.path: record for record in ordered}
|
|
198
|
+
matches: list[QueryRecord] = []
|
|
199
|
+
for record in ordered:
|
|
200
|
+
if not _matches(record, selector.steps[-1]):
|
|
201
|
+
continue
|
|
202
|
+
cursor = record
|
|
203
|
+
valid = True
|
|
204
|
+
for step in reversed(selector.steps[:-1]):
|
|
205
|
+
if cursor.parent_path is None:
|
|
206
|
+
valid = False
|
|
207
|
+
break
|
|
208
|
+
parent = by_path.get(cursor.parent_path)
|
|
209
|
+
if parent is None or not _matches(parent, step):
|
|
210
|
+
valid = False
|
|
211
|
+
break
|
|
212
|
+
cursor = parent
|
|
213
|
+
if valid:
|
|
214
|
+
if len(matches) == limit:
|
|
215
|
+
return matches, True
|
|
216
|
+
matches.append(record)
|
|
217
|
+
return matches, False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
__all__ = [
|
|
221
|
+
"MAX_SELECTOR_FILTERS",
|
|
222
|
+
"MAX_SELECTOR_STEPS",
|
|
223
|
+
"QueryRecord",
|
|
224
|
+
"QueryResult",
|
|
225
|
+
"SelectorStep",
|
|
226
|
+
"SemanticSelector",
|
|
227
|
+
"evaluate_selector",
|
|
228
|
+
"normalize_search_text",
|
|
229
|
+
"parse_selector",
|
|
230
|
+
]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Private, contract-neutral bindings for existing non-body stories.
|
|
3
|
+
|
|
4
|
+
The public semantic path grammar intentionally remains frozen at the Feature
|
|
5
|
+
024 catalog. This module recognizes one narrower command-only seam: an
|
|
6
|
+
existing section header selected by its native id or page type. It does not
|
|
7
|
+
project headers into the public view, accept package paths, or expose XML.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .model import AgentContractError
|
|
18
|
+
from .path import MAX_PATH_CHARS
|
|
19
|
+
|
|
20
|
+
HEADER_STORY_KIND = "header"
|
|
21
|
+
HEADER_STORY_EDITABLE_PROPERTIES = frozenset({"text"})
|
|
22
|
+
HEADER_PAGE_TYPES = frozenset({"BOTH", "EVEN", "ODD"})
|
|
23
|
+
|
|
24
|
+
_HEADER_STORY_RE = re.compile(
|
|
25
|
+
r"^/section\[(?P<section>0*[1-9][0-9]*)\]/header\["
|
|
26
|
+
r"@(?P<attribute>id|page-type)=(?P<value>\"(?:[^\"\\]|\\.)*\")\]$"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class HeaderStoryPath:
|
|
32
|
+
"""Parsed command-only path for one existing section header."""
|
|
33
|
+
|
|
34
|
+
section_index: int
|
|
35
|
+
attribute: str
|
|
36
|
+
value: str
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def kind(self) -> str:
|
|
40
|
+
return HEADER_STORY_KIND
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def canonical(self) -> str:
|
|
44
|
+
value = json.dumps(self.value, ensure_ascii=False, separators=(",", ":"))
|
|
45
|
+
return (
|
|
46
|
+
f"/section[{self.section_index}]/"
|
|
47
|
+
f"header[@{self.attribute}={value}]"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def parent_path(self) -> str:
|
|
52
|
+
return f"/section[{self.section_index}]"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class HeaderStoryBinding:
|
|
57
|
+
"""Request-local native binding for an existing logical header."""
|
|
58
|
+
|
|
59
|
+
path: str
|
|
60
|
+
parent_path: str
|
|
61
|
+
stable_id: str
|
|
62
|
+
native_id: str
|
|
63
|
+
page_type: str
|
|
64
|
+
section_index: int
|
|
65
|
+
native: Any
|
|
66
|
+
text: str
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def kind(self) -> str:
|
|
70
|
+
return HEADER_STORY_KIND
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def binding_key(self) -> str:
|
|
74
|
+
# Native ids are not promised to be document-global. Section scope is
|
|
75
|
+
# part of the logical binding even though the receipt exposes the same
|
|
76
|
+
# kind:id stable-id convention used by projected semantic nodes.
|
|
77
|
+
return f"{self.section_index}:{self.native_id}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def try_parse_header_story_path(value: object) -> HeaderStoryPath | None:
|
|
81
|
+
"""Return a private header path, or ``None`` for the public path parser.
|
|
82
|
+
|
|
83
|
+
Only exact supported forms are intercepted. Unsupported forms such as
|
|
84
|
+
``header[1]`` deliberately continue through :func:`parse_path` and retain
|
|
85
|
+
the frozen ``unknown_kind`` behavior.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
if not isinstance(value, str) or "/header[@" not in value:
|
|
89
|
+
return None
|
|
90
|
+
if len(value) > MAX_PATH_CHARS:
|
|
91
|
+
raise AgentContractError("resource_limit", "path is too long", target="path")
|
|
92
|
+
match = _HEADER_STORY_RE.fullmatch(value)
|
|
93
|
+
if match is None:
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
decoded = json.loads(match.group("value"))
|
|
97
|
+
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
98
|
+
raise AgentContractError(
|
|
99
|
+
"invalid_syntax", "invalid header story path string", target="path"
|
|
100
|
+
) from exc
|
|
101
|
+
if not isinstance(decoded, str) or not decoded or len(decoded) > 256:
|
|
102
|
+
raise AgentContractError(
|
|
103
|
+
"resource_limit", "header story selector is invalid", target="path"
|
|
104
|
+
)
|
|
105
|
+
attribute = match.group("attribute")
|
|
106
|
+
if attribute == "page-type" and decoded not in HEADER_PAGE_TYPES:
|
|
107
|
+
raise AgentContractError(
|
|
108
|
+
"invalid_syntax",
|
|
109
|
+
"header page type must be BOTH, EVEN, or ODD",
|
|
110
|
+
target="path",
|
|
111
|
+
)
|
|
112
|
+
return HeaderStoryPath(
|
|
113
|
+
section_index=int(match.group("section")),
|
|
114
|
+
attribute=attribute,
|
|
115
|
+
value=decoded,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_header_story_path(value: str) -> HeaderStoryPath:
|
|
120
|
+
"""Parse an exact private header path for focused internal tests."""
|
|
121
|
+
|
|
122
|
+
parsed = try_parse_header_story_path(value)
|
|
123
|
+
if parsed is None:
|
|
124
|
+
raise AgentContractError(
|
|
125
|
+
"invalid_syntax", "unsupported header story path", target="path"
|
|
126
|
+
)
|
|
127
|
+
return parsed
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve_header_story(document: Any, path: HeaderStoryPath) -> HeaderStoryBinding:
|
|
131
|
+
"""Resolve one unique direct logical header without scanning descendants."""
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
sections = document.sections
|
|
135
|
+
except (AttributeError, TypeError, ValueError) as exc:
|
|
136
|
+
raise AgentContractError(
|
|
137
|
+
"unsupported_content", "document section structure is unavailable", target=path.canonical
|
|
138
|
+
) from exc
|
|
139
|
+
if path.section_index > len(sections):
|
|
140
|
+
raise AgentContractError(
|
|
141
|
+
"not_found", "header story section does not exist", target=path.canonical
|
|
142
|
+
)
|
|
143
|
+
section = sections[path.section_index - 1]
|
|
144
|
+
try:
|
|
145
|
+
headers = tuple(section.properties.headers)
|
|
146
|
+
if path.attribute == "id":
|
|
147
|
+
matches = [header for header in headers if header.id == path.value]
|
|
148
|
+
else:
|
|
149
|
+
matches = [
|
|
150
|
+
header for header in headers if header.apply_page_type == path.value
|
|
151
|
+
]
|
|
152
|
+
except (AttributeError, TypeError, ValueError) as exc:
|
|
153
|
+
raise AgentContractError(
|
|
154
|
+
"unsupported_content", "section header structure is invalid", target=path.canonical
|
|
155
|
+
) from exc
|
|
156
|
+
|
|
157
|
+
if not matches:
|
|
158
|
+
raise AgentContractError(
|
|
159
|
+
"not_found", f"existing header story not found: {path.canonical}", target=path.canonical
|
|
160
|
+
)
|
|
161
|
+
if len(matches) > 1:
|
|
162
|
+
raise AgentContractError(
|
|
163
|
+
"ambiguous_target",
|
|
164
|
+
f"header story selector is not unique: {path.canonical}",
|
|
165
|
+
target=path.canonical,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
header = matches[0]
|
|
169
|
+
try:
|
|
170
|
+
native_id = header.id
|
|
171
|
+
page_type = header.apply_page_type
|
|
172
|
+
text = header.text
|
|
173
|
+
except (AttributeError, TypeError, ValueError) as exc:
|
|
174
|
+
raise AgentContractError(
|
|
175
|
+
"unsupported_content", "header story cannot be read safely", target=path.canonical
|
|
176
|
+
) from exc
|
|
177
|
+
if not native_id or len(native_id) > 256:
|
|
178
|
+
raise AgentContractError(
|
|
179
|
+
"unsupported_content", "header story has no bounded native identity", target=path.canonical
|
|
180
|
+
)
|
|
181
|
+
if page_type not in HEADER_PAGE_TYPES:
|
|
182
|
+
raise AgentContractError(
|
|
183
|
+
"unsupported_content", "header story has an unsupported page type", target=path.canonical
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return HeaderStoryBinding(
|
|
187
|
+
path=path.canonical,
|
|
188
|
+
parent_path=path.parent_path,
|
|
189
|
+
stable_id=f"header:{native_id}",
|
|
190
|
+
native_id=native_id,
|
|
191
|
+
page_type=page_type,
|
|
192
|
+
section_index=path.section_index,
|
|
193
|
+
native=header,
|
|
194
|
+
text=text,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
__all__ = [
|
|
199
|
+
"HEADER_PAGE_TYPES",
|
|
200
|
+
"HEADER_STORY_EDITABLE_PROPERTIES",
|
|
201
|
+
"HEADER_STORY_KIND",
|
|
202
|
+
"HeaderStoryBinding",
|
|
203
|
+
"HeaderStoryPath",
|
|
204
|
+
"parse_header_story_path",
|
|
205
|
+
"resolve_header_story",
|
|
206
|
+
"try_parse_header_story_path",
|
|
207
|
+
]
|