Files
wyndham-ARR/arr_processing/source_message.py
2026-07-29 16:38:05 +08:00

113 lines
4.5 KiB
Python

"""Build the program message consumed by the runtime ``fetch_oss_file`` tool."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Protocol
from urllib.parse import urlparse
from arr_ingestion.contracts import ArtifactRef, ROLE_CONTRACTS
from arr_processing.contracts import ProcessingRequest, canonical_json_bytes
from arr_processing.errors import ProcessingError
from arr_storage.aliyun_oss_v2 import AliyunOssConfig
from arr_storage.contracts import valid_object_key
PROGRAM_INPUT_VERSION = "arr-opera-daily-program-input-2"
class ProcessingSourceResolver(Protocol):
def source_for_attempt(self, job_id: str, attempt_no: int) -> ArtifactRef:
...
@dataclass(frozen=True)
class OssProcessingMessageBuilder:
"""Resolve the ARR-owned source object and expose only non-secret OSS routing.
``fetch_oss_file`` owns the download and its credential provider. The Agent
receives the same attachment-shaped descriptor used by the existing hotel
runtime: bucket, endpoint host, exact object key, hash and size. AccessKey,
Secret, local paths and signed URLs never enter the message.
"""
config: AliyunOssConfig
sources: ProcessingSourceResolver
def message(self, request: ProcessingRequest) -> str:
source = self.sources.source_for_attempt(request.job_id, request.attempt_no)
self._validate_source(request, source)
if request.submission_grant is None:
raise ProcessingError(
"PROCESSING_SUBMISSION_GRANT_MISSING",
"direct result submission grant is unavailable",
)
payload: Dict[str, Any] = {
"contract_version": PROGRAM_INPUT_VERSION,
"job_id": request.job_id,
"attempt_no": request.attempt_no,
"source_file_id": request.source_file_id,
"processor_version": request.processor_version,
"rule_set_sha256": request.rule_set_sha256,
"oss_attachments": [
{
"id": request.source_file_id,
"name": source.original_filename,
"content_type": source.mime_type,
"size": source.byte_size,
"sha256": source.sha256,
"current_or_history": "current",
"source_ref": {"source": "oss_attachments", "index": 0},
"oss": {
"bucket": self.config.bucket,
"endpoint": self._endpoint_host(),
"object_key": source.object_key,
},
}
],
"oss_inline_images": [],
"attachment_fetch_policy": {
"tool_name": "fetch_oss_file",
"required_before_skill_call": True,
"applies_to": ["oss_attachments"],
"output_field": "current_attachments",
},
"result_submission": {
"tool_name": "arr_submit_processing_result",
"submission_grant": request.submission_grant,
"job_id": request.job_id,
"attempt_no": request.attempt_no,
"payload_source": "structured-result.json",
"required_after_successful_skill_call": True,
},
}
return canonical_json_bytes(payload).decode("utf-8")
@staticmethod
def _validate_source(request: ProcessingRequest, source: ArtifactRef) -> None:
expected_mime = ROLE_CONTRACTS["source_xml"][2]
if (
not isinstance(source, ArtifactRef)
or source.role != "source_xml"
or source.file_kind != "opera_xml"
or source.mime_type != expected_mime
or source.original_filename != "source.xml"
or not valid_object_key(source.object_key)
or request.job_id not in source.object_key.split("/")
):
raise ProcessingError(
"PROCESSING_SOURCE_INVALID",
"processing source object is unavailable",
)
def _endpoint_host(self) -> str:
if self.config.endpoint:
parsed = urlparse(self.config.endpoint)
if parsed.scheme != "https" or not parsed.hostname:
raise ProcessingError(
"PROCESSING_SOURCE_INVALID",
"OSS endpoint is unavailable",
)
return parsed.netloc
return f"oss-{self.config.region}.aliyuncs.com"