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