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,173 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Shared render contracts — the leaf both queue and transport sides import.
|
|
3
|
+
|
|
4
|
+
These contracts were extracted from :mod:`rendering`/:mod:`render_queue` to remove the
|
|
5
|
+
last package import cycle: the v2 job/receipt/status models and the submission
|
|
6
|
+
signature live here, and both former owners re-export them for compatibility.
|
|
7
|
+
This module may import only the standard library and pydantic.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
RENDER_SCHEMA_VERSION_V2 = "hwpx.render.v2"
|
|
21
|
+
SHA256_PATTERN = r"^sha256:[0-9a-f]{64}$"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RenderStatus(str, Enum):
|
|
25
|
+
QUEUED = "queued"
|
|
26
|
+
RUNNING = "running"
|
|
27
|
+
SUCCEEDED = "succeeded"
|
|
28
|
+
FAILED = "failed"
|
|
29
|
+
UNAVAILABLE = "unavailable"
|
|
30
|
+
CANCELLED = "cancelled"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RenderArtifactKind(str, Enum):
|
|
34
|
+
PDF = "pdf"
|
|
35
|
+
PAGE_PNG = "page_png"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RenderArtifactV2(BaseModel):
|
|
39
|
+
"""Content-addressed output; worker-local paths never cross the boundary."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
42
|
+
|
|
43
|
+
kind: RenderArtifactKind
|
|
44
|
+
content_hash: str = Field(pattern=SHA256_PATTERN)
|
|
45
|
+
size_bytes: int = Field(ge=1)
|
|
46
|
+
page_number: int | None = Field(default=None, ge=1)
|
|
47
|
+
|
|
48
|
+
@model_validator(mode="after")
|
|
49
|
+
def page_number_matches_kind(self) -> "RenderArtifactV2":
|
|
50
|
+
if self.kind == RenderArtifactKind.PAGE_PNG and self.page_number is None:
|
|
51
|
+
raise ValueError("page_png artifact requires page_number")
|
|
52
|
+
if self.kind == RenderArtifactKind.PDF and self.page_number is not None:
|
|
53
|
+
raise ValueError("pdf artifact cannot have page_number")
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RenderJobV2(BaseModel):
|
|
58
|
+
"""Measure-first, content-addressed real-Hancom queue request."""
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
61
|
+
|
|
62
|
+
schema_version: Literal[RENDER_SCHEMA_VERSION_V2] = RENDER_SCHEMA_VERSION_V2
|
|
63
|
+
# Colon is deliberately excluded: it is not safe in Windows directory names.
|
|
64
|
+
job_id: str = Field(min_length=8, max_length=200, pattern=r"^[A-Za-z0-9._-]+$")
|
|
65
|
+
workflow_id: str = Field(min_length=8, max_length=200, pattern=r"^[A-Za-z0-9._-]+$")
|
|
66
|
+
idempotency_key: str = Field(min_length=8, max_length=200)
|
|
67
|
+
source_content_hash: str = Field(pattern=SHA256_PATTERN)
|
|
68
|
+
source_size_bytes: int = Field(gt=0)
|
|
69
|
+
source_media_type: Literal["application/hwp+zip"] = "application/hwp+zip"
|
|
70
|
+
submitted_at: AwareDatetime
|
|
71
|
+
requested_backend: Literal["real_hancom"] = "real_hancom"
|
|
72
|
+
requested_outputs: tuple[RenderArtifactKind, ...] = (
|
|
73
|
+
RenderArtifactKind.PDF,
|
|
74
|
+
RenderArtifactKind.PAGE_PNG,
|
|
75
|
+
)
|
|
76
|
+
dpi: int = Field(default=144, ge=72, le=600)
|
|
77
|
+
|
|
78
|
+
@field_validator("requested_outputs")
|
|
79
|
+
@classmethod
|
|
80
|
+
def outputs_are_unique_and_nonempty(
|
|
81
|
+
cls, value: tuple[RenderArtifactKind, ...]
|
|
82
|
+
) -> tuple[RenderArtifactKind, ...]:
|
|
83
|
+
if set(value) != {RenderArtifactKind.PDF, RenderArtifactKind.PAGE_PNG} or len(value) != 2:
|
|
84
|
+
raise ValueError("v2 requires exactly PDF and page_png outputs")
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class RenderReceiptV2(BaseModel):
|
|
89
|
+
"""Terminal/non-terminal receipt bound to input and real worker provenance."""
|
|
90
|
+
|
|
91
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
92
|
+
|
|
93
|
+
schema_version: Literal[RENDER_SCHEMA_VERSION_V2] = RENDER_SCHEMA_VERSION_V2
|
|
94
|
+
job_id: str = Field(min_length=8, max_length=200)
|
|
95
|
+
workflow_id: str = Field(min_length=8, max_length=200)
|
|
96
|
+
input_content_hash: str = Field(pattern=SHA256_PATTERN)
|
|
97
|
+
status: RenderStatus
|
|
98
|
+
backend: str | None = None
|
|
99
|
+
hancom_build: str | None = None
|
|
100
|
+
worker_version: str | None = None
|
|
101
|
+
queued_at: AwareDatetime
|
|
102
|
+
started_at: AwareDatetime | None = None
|
|
103
|
+
completed_at: AwareDatetime | None = None
|
|
104
|
+
artifacts: tuple[RenderArtifactV2, ...] = ()
|
|
105
|
+
page_count: int | None = Field(default=None, ge=0)
|
|
106
|
+
retry_count: int = Field(default=0, ge=0)
|
|
107
|
+
terminal_reason: str | None = None
|
|
108
|
+
render_checked: bool = False
|
|
109
|
+
|
|
110
|
+
@model_validator(mode="after")
|
|
111
|
+
def validate_provenance_and_timeline(self) -> "RenderReceiptV2":
|
|
112
|
+
terminal = self.status in {
|
|
113
|
+
RenderStatus.SUCCEEDED,
|
|
114
|
+
RenderStatus.FAILED,
|
|
115
|
+
RenderStatus.UNAVAILABLE,
|
|
116
|
+
RenderStatus.CANCELLED,
|
|
117
|
+
}
|
|
118
|
+
if self.started_at is not None and self.started_at < self.queued_at:
|
|
119
|
+
raise ValueError("started_at cannot precede queued_at")
|
|
120
|
+
if self.completed_at is not None and self.completed_at < (self.started_at or self.queued_at):
|
|
121
|
+
raise ValueError("completed_at must preserve monotonic timestamps")
|
|
122
|
+
if terminal and (self.completed_at is None or not self.terminal_reason):
|
|
123
|
+
raise ValueError("terminal receipt requires completed_at and terminal_reason")
|
|
124
|
+
if not terminal and (self.completed_at is not None or self.terminal_reason is not None):
|
|
125
|
+
raise ValueError("non-terminal receipt cannot carry terminal completion fields")
|
|
126
|
+
|
|
127
|
+
page_artifacts = [item for item in self.artifacts if item.kind == RenderArtifactKind.PAGE_PNG]
|
|
128
|
+
page_numbers = [item.page_number for item in page_artifacts]
|
|
129
|
+
if len(page_numbers) != len(set(page_numbers)):
|
|
130
|
+
raise ValueError("page artifact numbers must be unique")
|
|
131
|
+
if self.status == RenderStatus.SUCCEEDED:
|
|
132
|
+
if self.started_at is None:
|
|
133
|
+
raise ValueError("successful receipt requires started_at")
|
|
134
|
+
if not page_artifacts or self.page_count != len(page_artifacts):
|
|
135
|
+
raise ValueError("successful receipt requires artifacts and exact page_count")
|
|
136
|
+
if page_numbers != list(range(1, len(page_numbers) + 1)):
|
|
137
|
+
raise ValueError("successful receipt requires contiguous page numbers starting at one")
|
|
138
|
+
pdf_count = sum(item.kind == RenderArtifactKind.PDF for item in self.artifacts)
|
|
139
|
+
if pdf_count != 1:
|
|
140
|
+
raise ValueError("successful receipt requires exactly one PDF artifact")
|
|
141
|
+
if not self.render_checked or not self.backend or not self.hancom_build or not self.worker_version:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"successful receipt requires render_checked and backend/Hancom/worker provenance"
|
|
144
|
+
)
|
|
145
|
+
elif self.artifacts or self.page_count not in (None, 0):
|
|
146
|
+
raise ValueError("non-success receipt cannot claim output artifacts")
|
|
147
|
+
|
|
148
|
+
if self.render_checked and (
|
|
149
|
+
self.status != RenderStatus.SUCCEEDED
|
|
150
|
+
or not self.backend
|
|
151
|
+
or not self.hancom_build
|
|
152
|
+
or not self.worker_version
|
|
153
|
+
):
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"render_checked requires succeeded status and backend/Hancom/worker provenance"
|
|
156
|
+
)
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def binds(self, job: RenderJobV2) -> bool:
|
|
160
|
+
"""Return true only when identity, workflow, and content address match."""
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
self.job_id == job.job_id
|
|
164
|
+
and self.workflow_id == job.workflow_id
|
|
165
|
+
and self.input_content_hash == job.source_content_hash
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def sign_submission(secret: bytes, job: RenderJobV2) -> str:
|
|
170
|
+
"""Sign only immutable request metadata; document bytes are bound by SHA-256."""
|
|
171
|
+
|
|
172
|
+
message = job.model_dump_json(exclude_none=False).encode("utf-8")
|
|
173
|
+
return "sha256=" + hmac.new(secret, message, hashlib.sha256).hexdigest()
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Frozen measure-first corpus and accounting rules for the render service."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from typing import Literal, Sequence
|
|
11
|
+
|
|
12
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
|
|
13
|
+
|
|
14
|
+
from .rendering import RenderStatus
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CorpusBucket(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
19
|
+
|
|
20
|
+
key: str
|
|
21
|
+
document_count: int = Field(gt=0)
|
|
22
|
+
eligible_for_success_availability: bool = True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaselineCorpusContract(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
27
|
+
|
|
28
|
+
schema_version: Literal["hwpx.render-baseline.v1"] = "hwpx.render-baseline.v1"
|
|
29
|
+
corpus_id: Literal["s068-mixed-100-v1"] = "s068-mixed-100-v1"
|
|
30
|
+
buckets: tuple[CorpusBucket, ...]
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def exactly_one_hundred_unique_documents(self) -> "BaselineCorpusContract":
|
|
34
|
+
if sum(item.document_count for item in self.buckets) != 100:
|
|
35
|
+
raise ValueError("render baseline corpus must contain exactly 100 documents")
|
|
36
|
+
keys = [item.key for item in self.buckets]
|
|
37
|
+
if len(keys) != len(set(keys)):
|
|
38
|
+
raise ValueError("render baseline bucket keys must be unique")
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def contract_hash(self) -> str:
|
|
43
|
+
payload = json.dumps(self.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
|
|
44
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
FROZEN_BASELINE_CORPUS = BaselineCorpusContract(
|
|
48
|
+
buckets=(
|
|
49
|
+
CorpusBucket(key="simple_text", document_count=15),
|
|
50
|
+
CorpusBucket(key="tables_and_merges", document_count=20),
|
|
51
|
+
CorpusBucket(key="known_templates", document_count=15),
|
|
52
|
+
CorpusBucket(key="unknown_forms", document_count=15),
|
|
53
|
+
CorpusBucket(key="images_and_shapes", document_count=10),
|
|
54
|
+
CorpusBucket(key="multi_section_multipage", document_count=10),
|
|
55
|
+
CorpusBucket(key="large_near_limit", document_count=10),
|
|
56
|
+
CorpusBucket(key="invalid_or_unsupported", document_count=5, eligible_for_success_availability=False),
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MeasureFirstGate(BaseModel):
|
|
62
|
+
"""Only accounting targets are gates until observed latency/availability is frozen."""
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
65
|
+
|
|
66
|
+
corpus_documents: Literal[100] = 100
|
|
67
|
+
lost_job_target: Literal[0] = 0
|
|
68
|
+
duplicate_terminal_receipt_target: Literal[0] = 0
|
|
69
|
+
terminal_accounting_rate_target: Literal[1.0] = 1.0
|
|
70
|
+
latency_percentiles: tuple[Literal["p50", "p95", "p99"], ...] = ("p50", "p95", "p99")
|
|
71
|
+
availability_target: None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RenderMetricObservation(BaseModel):
|
|
75
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
76
|
+
|
|
77
|
+
receipt_id: str = Field(min_length=8)
|
|
78
|
+
job_id: str = Field(min_length=8)
|
|
79
|
+
submitted_at: AwareDatetime
|
|
80
|
+
terminal_at: AwareDatetime
|
|
81
|
+
status: RenderStatus
|
|
82
|
+
eligible_for_success_availability: bool = True
|
|
83
|
+
|
|
84
|
+
@model_validator(mode="after")
|
|
85
|
+
def terminal_and_monotonic(self) -> "RenderMetricObservation":
|
|
86
|
+
if self.status not in {
|
|
87
|
+
RenderStatus.SUCCEEDED,
|
|
88
|
+
RenderStatus.FAILED,
|
|
89
|
+
RenderStatus.UNAVAILABLE,
|
|
90
|
+
RenderStatus.CANCELLED,
|
|
91
|
+
}:
|
|
92
|
+
raise ValueError("metric observation must be terminal")
|
|
93
|
+
if self.terminal_at < self.submitted_at:
|
|
94
|
+
raise ValueError("terminal_at cannot precede submitted_at")
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def latency_seconds(self) -> float:
|
|
99
|
+
return (self.terminal_at - self.submitted_at).total_seconds()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class QueueMetricSample(BaseModel):
|
|
103
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
104
|
+
|
|
105
|
+
observed_at: AwareDatetime
|
|
106
|
+
queue_depth: int = Field(ge=0)
|
|
107
|
+
oldest_queued_age_seconds: float = Field(ge=0)
|
|
108
|
+
running_jobs: int = Field(ge=0, le=1)
|
|
109
|
+
last_success_at: AwareDatetime | None = None
|
|
110
|
+
worker_available: bool
|
|
111
|
+
degraded_reason: str | None = None
|
|
112
|
+
|
|
113
|
+
@model_validator(mode="after")
|
|
114
|
+
def degraded_state_is_explicit(self) -> "QueueMetricSample":
|
|
115
|
+
if not self.worker_available and not self.degraded_reason:
|
|
116
|
+
raise ValueError("unavailable worker requires degraded_reason")
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class RenderAccountingReport(BaseModel):
|
|
121
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
122
|
+
|
|
123
|
+
expected_jobs: int
|
|
124
|
+
unique_terminal_jobs: int
|
|
125
|
+
terminal_receipts: int
|
|
126
|
+
missing_job_ids: tuple[str, ...]
|
|
127
|
+
duplicate_job_ids: tuple[str, ...]
|
|
128
|
+
orphan_job_ids: tuple[str, ...]
|
|
129
|
+
lost_jobs: int
|
|
130
|
+
duplicate_terminal_receipts: int
|
|
131
|
+
terminal_accounting_rate: float
|
|
132
|
+
success_availability: float | None
|
|
133
|
+
latency_seconds: dict[str, float | None]
|
|
134
|
+
exact_accounting: bool
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _percentile(values: Sequence[float], percentile: float) -> float | None:
|
|
138
|
+
if not values:
|
|
139
|
+
return None
|
|
140
|
+
ordered = sorted(values)
|
|
141
|
+
rank = max(0, math.ceil(percentile * len(ordered)) - 1)
|
|
142
|
+
return ordered[rank]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def account_terminal_receipts(
|
|
146
|
+
expected_job_ids: Sequence[str],
|
|
147
|
+
observations: Sequence[RenderMetricObservation],
|
|
148
|
+
) -> RenderAccountingReport:
|
|
149
|
+
"""Account every expected job exactly once; duplicates never hide losses."""
|
|
150
|
+
|
|
151
|
+
expected = set(expected_job_ids)
|
|
152
|
+
if len(expected) != len(expected_job_ids):
|
|
153
|
+
raise ValueError("expected_job_ids must be unique")
|
|
154
|
+
counts = Counter(item.job_id for item in observations)
|
|
155
|
+
observed = set(counts)
|
|
156
|
+
missing = tuple(sorted(expected - observed))
|
|
157
|
+
duplicates = tuple(sorted(job_id for job_id, count in counts.items() if job_id in expected and count > 1))
|
|
158
|
+
orphans = tuple(sorted(observed - expected))
|
|
159
|
+
canonical = [item for item in observations if item.job_id in expected and counts[item.job_id] == 1]
|
|
160
|
+
eligible = [item for item in canonical if item.eligible_for_success_availability]
|
|
161
|
+
successes = sum(item.status == RenderStatus.SUCCEEDED for item in eligible)
|
|
162
|
+
exact = not missing and not duplicates and not orphans
|
|
163
|
+
availability = successes / len(eligible) if exact and eligible else None
|
|
164
|
+
latencies = [item.latency_seconds for item in canonical]
|
|
165
|
+
duplicate_receipts = sum(max(0, counts[job_id] - 1) for job_id in expected)
|
|
166
|
+
accounted = len(expected & observed)
|
|
167
|
+
return RenderAccountingReport(
|
|
168
|
+
expected_jobs=len(expected),
|
|
169
|
+
unique_terminal_jobs=accounted,
|
|
170
|
+
terminal_receipts=len(observations),
|
|
171
|
+
missing_job_ids=missing,
|
|
172
|
+
duplicate_job_ids=duplicates,
|
|
173
|
+
orphan_job_ids=orphans,
|
|
174
|
+
lost_jobs=len(missing),
|
|
175
|
+
duplicate_terminal_receipts=duplicate_receipts,
|
|
176
|
+
terminal_accounting_rate=accounted / len(expected) if expected else 1.0,
|
|
177
|
+
success_availability=availability,
|
|
178
|
+
latency_seconds={
|
|
179
|
+
"p50": _percentile(latencies, 0.50),
|
|
180
|
+
"p95": _percentile(latencies, 0.95),
|
|
181
|
+
"p99": _percentile(latencies, 0.99),
|
|
182
|
+
},
|
|
183
|
+
exact_accounting=exact,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
__all__ = [
|
|
188
|
+
"BaselineCorpusContract",
|
|
189
|
+
"CorpusBucket",
|
|
190
|
+
"FROZEN_BASELINE_CORPUS",
|
|
191
|
+
"MeasureFirstGate",
|
|
192
|
+
"QueueMetricSample",
|
|
193
|
+
"RenderAccountingReport",
|
|
194
|
+
"RenderMetricObservation",
|
|
195
|
+
"account_terminal_receipts",
|
|
196
|
+
]
|