|
|
@@ -0,0 +1,468 @@
|
|
|
+"""Concrete, side-effect-contained retrieval and image adapters."""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import asyncio
|
|
|
+import json
|
|
|
+import re
|
|
|
+import time
|
|
|
+from collections.abc import Mapping, Sequence
|
|
|
+from dataclasses import dataclass
|
|
|
+from hashlib import sha256
|
|
|
+from pathlib import Path
|
|
|
+from typing import Any, Protocol
|
|
|
+from urllib.parse import urljoin
|
|
|
+
|
|
|
+import httpx
|
|
|
+
|
|
|
+from script_build_host.domain.errors import ProtocolViolation
|
|
|
+from script_build_host.infrastructure.canonical_json import canonical_sha256
|
|
|
+from script_build_host.infrastructure.outbound import OutboundPolicy
|
|
|
+from script_build_host.infrastructure.redaction import redact, redact_text
|
|
|
+from script_build_host.tools.gateway import RetrievalResult
|
|
|
+
|
|
|
+
|
|
|
+class SafeHttpClient:
|
|
|
+ """HTTP JSON/bytes client that revalidates every redirect target."""
|
|
|
+
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ client: httpx.AsyncClient,
|
|
|
+ policy: OutboundPolicy,
|
|
|
+ *,
|
|
|
+ timeout_seconds: float = 30.0,
|
|
|
+ max_response_bytes: int = 5_000_000,
|
|
|
+ max_redirects: int = 3,
|
|
|
+ ) -> None:
|
|
|
+ self.client = client
|
|
|
+ self.policy = policy
|
|
|
+ self.timeout_seconds = timeout_seconds
|
|
|
+ self.max_response_bytes = max_response_bytes
|
|
|
+ self.max_redirects = max_redirects
|
|
|
+
|
|
|
+ async def request(
|
|
|
+ self,
|
|
|
+ method: str,
|
|
|
+ url: str,
|
|
|
+ *,
|
|
|
+ json_body: Mapping[str, Any] | None = None,
|
|
|
+ ) -> httpx.Response:
|
|
|
+ current = await self.policy.validate_url(url)
|
|
|
+ for _ in range(self.max_redirects + 1):
|
|
|
+ request = self.client.build_request(
|
|
|
+ method,
|
|
|
+ current,
|
|
|
+ json=dict(json_body) if json_body is not None else None,
|
|
|
+ timeout=self.timeout_seconds,
|
|
|
+ )
|
|
|
+ response = await self.client.send(request, stream=True, follow_redirects=False)
|
|
|
+ if 300 <= response.status_code < 400:
|
|
|
+ location = response.headers.get("location")
|
|
|
+ await response.aclose()
|
|
|
+ if not location:
|
|
|
+ raise ProtocolViolation("redirect response has no location")
|
|
|
+ current = await self.policy.validate_redirect(current, location)
|
|
|
+ method = "GET" if response.status_code in {301, 302, 303} else method
|
|
|
+ json_body = None if method == "GET" else json_body
|
|
|
+ continue
|
|
|
+ content = bytearray()
|
|
|
+ async for chunk in response.aiter_bytes():
|
|
|
+ content.extend(chunk)
|
|
|
+ if len(content) > self.max_response_bytes:
|
|
|
+ await response.aclose()
|
|
|
+ raise ProtocolViolation("upstream response exceeds the configured byte limit")
|
|
|
+ await response.aclose()
|
|
|
+ if response.is_error:
|
|
|
+ raise ProtocolViolation(f"upstream returned HTTP {response.status_code}")
|
|
|
+ return httpx.Response(
|
|
|
+ status_code=response.status_code,
|
|
|
+ headers=response.headers,
|
|
|
+ content=bytes(content),
|
|
|
+ request=request,
|
|
|
+ )
|
|
|
+ raise ProtocolViolation("upstream exceeded the redirect limit")
|
|
|
+
|
|
|
+ async def json(
|
|
|
+ self,
|
|
|
+ method: str,
|
|
|
+ url: str,
|
|
|
+ *,
|
|
|
+ json_body: Mapping[str, Any] | None = None,
|
|
|
+ ) -> Any:
|
|
|
+ response = await self.request(method, url, json_body=json_body)
|
|
|
+ try:
|
|
|
+ return response.json()
|
|
|
+ except ValueError as exc:
|
|
|
+ raise ProtocolViolation("upstream response is not valid JSON") from exc
|
|
|
+
|
|
|
+
|
|
|
+class PatternRetrievalAdapter:
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ endpoint: str,
|
|
|
+ http: SafeHttpClient,
|
|
|
+ *,
|
|
|
+ poll_interval_seconds: float = 1.0,
|
|
|
+ poll_timeout_seconds: float = 600.0,
|
|
|
+ ) -> None:
|
|
|
+ self.endpoint = endpoint
|
|
|
+ self.http = http
|
|
|
+ self.poll_interval_seconds = poll_interval_seconds
|
|
|
+ self.poll_timeout_seconds = poll_timeout_seconds
|
|
|
+
|
|
|
+ async def retrieve(self, *, query: Mapping[str, Any], snapshot: Any) -> RetrievalResult:
|
|
|
+ del snapshot
|
|
|
+ payload = await self.http.json("POST", self.endpoint, json_body=query)
|
|
|
+ task_id = (
|
|
|
+ str(payload.get("session_id") or payload.get("task_id") or "")
|
|
|
+ if isinstance(payload, dict)
|
|
|
+ else ""
|
|
|
+ )
|
|
|
+ deadline = time.monotonic() + self.poll_timeout_seconds
|
|
|
+ while isinstance(payload, dict) and payload.get("status") in {
|
|
|
+ "pending",
|
|
|
+ "running",
|
|
|
+ "queued",
|
|
|
+ }:
|
|
|
+ if time.monotonic() >= deadline:
|
|
|
+ return _limited_result(
|
|
|
+ "pattern",
|
|
|
+ "pattern retrieval timed out",
|
|
|
+ "timeout",
|
|
|
+ task_id=task_id,
|
|
|
+ payload=payload,
|
|
|
+ )
|
|
|
+ poll_url = payload.get("poll_url")
|
|
|
+ if not isinstance(poll_url, str) or not poll_url:
|
|
|
+ if not task_id:
|
|
|
+ raise ProtocolViolation("pattern pending response has no task identity")
|
|
|
+ poll_url = urljoin(self.endpoint.rstrip("/") + "/", task_id)
|
|
|
+ await asyncio.sleep(self.poll_interval_seconds)
|
|
|
+ payload = await self.http.json("GET", poll_url)
|
|
|
+ if isinstance(payload, dict) and payload.get("status") in {"failed", "error"}:
|
|
|
+ return _limited_result(
|
|
|
+ "pattern",
|
|
|
+ "pattern retrieval failed",
|
|
|
+ "upstream failure",
|
|
|
+ task_id=task_id,
|
|
|
+ payload=payload,
|
|
|
+ )
|
|
|
+ return _result(
|
|
|
+ "pattern",
|
|
|
+ payload,
|
|
|
+ metadata={
|
|
|
+ "task_id": _safe_source_id(task_id, rank=0) if task_id else None,
|
|
|
+ "response_sha256": canonical_sha256(payload).wire,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class ExternalRetrievalAdapter:
|
|
|
+ """Pure HTTP client; it contains no legacy-log repository dependency."""
|
|
|
+
|
|
|
+ def __init__(self, endpoint: str, http: SafeHttpClient) -> None:
|
|
|
+ self.endpoint = endpoint
|
|
|
+ self.http = http
|
|
|
+
|
|
|
+ async def retrieve(self, *, query: Mapping[str, Any], snapshot: Any) -> RetrievalResult:
|
|
|
+ del snapshot
|
|
|
+ payload = await self.http.json("POST", self.endpoint, json_body=query)
|
|
|
+ return _result(
|
|
|
+ "external",
|
|
|
+ payload,
|
|
|
+ metadata={"response_sha256": canonical_sha256(payload).wire},
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class HttpDecodeRetrievalAdapter:
|
|
|
+ """Call the configured Decode service with the legacy query contract."""
|
|
|
+
|
|
|
+ def __init__(self, endpoint: str, http: SafeHttpClient) -> None:
|
|
|
+ self.endpoint = endpoint
|
|
|
+ self.http = http
|
|
|
+
|
|
|
+ async def retrieve(self, *, query: Mapping[str, Any], snapshot: Any) -> RetrievalResult:
|
|
|
+ payload = await self.http.json("POST", self.endpoint, json_body=query)
|
|
|
+ rows = (
|
|
|
+ payload.get("data", payload.get("results", payload.get("items", [])))
|
|
|
+ if isinstance(payload, dict)
|
|
|
+ else payload
|
|
|
+ )
|
|
|
+ if not isinstance(rows, list):
|
|
|
+ raise ProtocolViolation("decode response items must be an array")
|
|
|
+ safe_rows = redact(rows)
|
|
|
+ if not isinstance(safe_rows, list):
|
|
|
+ raise ProtocolViolation("redacted decode rows must remain an array")
|
|
|
+ refs: list[str] = []
|
|
|
+ scores: list[float] = []
|
|
|
+ for row in safe_rows:
|
|
|
+ if not isinstance(row, dict):
|
|
|
+ raise ProtocolViolation("decode hit must be an object")
|
|
|
+ identifier = row.get("post_id", row.get("id"))
|
|
|
+ raw_score = row.get("score", row.get("similarity_score"))
|
|
|
+ if (
|
|
|
+ identifier is None
|
|
|
+ or isinstance(raw_score, bool)
|
|
|
+ or not isinstance(raw_score, (int, float))
|
|
|
+ ):
|
|
|
+ raise ProtocolViolation("decode hit must contain an ID and numeric score")
|
|
|
+ refs.append(f"decode:{_safe_source_id(identifier, rank=len(refs))}")
|
|
|
+ scores.append(float(raw_score))
|
|
|
+ datasource_manifest = getattr(snapshot, "datasource_manifest", {})
|
|
|
+ model_manifest = getattr(snapshot, "model_manifest", {})
|
|
|
+ return RetrievalResult(
|
|
|
+ source_refs=tuple(refs),
|
|
|
+ summary=json.dumps(safe_rows, ensure_ascii=False, sort_keys=True),
|
|
|
+ supports=tuple(str(query["return_field"]) for _ in refs),
|
|
|
+ confidence="ranked",
|
|
|
+ limitations=(),
|
|
|
+ metadata={
|
|
|
+ "response_sha256": canonical_sha256(payload).wire,
|
|
|
+ "decode_index": datasource_manifest.get("decode_index"),
|
|
|
+ "embedding_model": model_manifest.get("embedding_model"),
|
|
|
+ "hit_ids": refs,
|
|
|
+ "ranks": list(range(1, len(refs) + 1)),
|
|
|
+ "scores": scores,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class FileDecodeRetrievalAdapter:
|
|
|
+ def __init__(self, index_path: Path) -> None:
|
|
|
+ self.index_path = index_path
|
|
|
+ self._payload, self._digest = _read_json(index_path)
|
|
|
+
|
|
|
+ async def retrieve(self, *, query: Mapping[str, Any], snapshot: Any) -> RetrievalResult:
|
|
|
+ payload, digest = self._payload, self._digest
|
|
|
+ _assert_frozen_source(snapshot, "decode_index", digest)
|
|
|
+ rows = payload if isinstance(payload, list) else payload.get("items", [])
|
|
|
+ if not isinstance(rows, list):
|
|
|
+ raise ProtocolViolation("decode index items must be an array")
|
|
|
+ account = query.get("account_name")
|
|
|
+ keyword = str(query.get("keyword", "")).casefold()
|
|
|
+ scored: list[tuple[int, int, dict[str, Any]]] = []
|
|
|
+ for position, item in enumerate(rows):
|
|
|
+ if not isinstance(item, dict):
|
|
|
+ continue
|
|
|
+ if account and item.get("account") != account:
|
|
|
+ continue
|
|
|
+ text = json.dumps(item, ensure_ascii=False, sort_keys=True).casefold()
|
|
|
+ score = text.count(keyword) if keyword else 1
|
|
|
+ if score:
|
|
|
+ scored.append((score, -position, item))
|
|
|
+ scored.sort(reverse=True, key=lambda item: (item[0], item[1]))
|
|
|
+ top_k = int(query.get("top_k", 3))
|
|
|
+ ranked = scored[:top_k]
|
|
|
+ hits = [item for _, _, item in ranked]
|
|
|
+ refs = tuple(
|
|
|
+ f"decode:{_safe_source_id(item.get('post_id', item.get('id')), rank=index)}"
|
|
|
+ for index, item in enumerate(hits)
|
|
|
+ )
|
|
|
+ safe_hits = redact(hits)
|
|
|
+ if not isinstance(safe_hits, list):
|
|
|
+ raise ProtocolViolation("redacted decode hits must remain an array")
|
|
|
+ embedding_model = str(snapshot.model_manifest.get("embedding_model", "lexical-fixture-v1"))
|
|
|
+ return RetrievalResult(
|
|
|
+ source_refs=refs,
|
|
|
+ summary=json.dumps(safe_hits, ensure_ascii=False, sort_keys=True),
|
|
|
+ supports=tuple(str(query["return_field"]) for _ in hits),
|
|
|
+ confidence="ranked",
|
|
|
+ limitations=("offline frozen index",),
|
|
|
+ metadata={
|
|
|
+ "index_sha256": digest,
|
|
|
+ "embedding_model": embedding_model,
|
|
|
+ "hit_ids": list(refs),
|
|
|
+ "ranks": list(range(1, len(refs) + 1)),
|
|
|
+ "scores": [float(score) for score, _, _ in ranked],
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class FileKnowledgeRetrievalAdapter:
|
|
|
+ def __init__(self, knowledge_path: Path) -> None:
|
|
|
+ self.knowledge_path = knowledge_path
|
|
|
+ self._payload, self._digest = _read_json(knowledge_path)
|
|
|
+
|
|
|
+ async def retrieve(self, *, query: Mapping[str, Any], snapshot: Any) -> RetrievalResult:
|
|
|
+ payload, digest = self._payload, self._digest
|
|
|
+ _assert_frozen_source(snapshot, "knowledge", digest)
|
|
|
+ rows = payload if isinstance(payload, list) else payload.get("items", [])
|
|
|
+ if not isinstance(rows, list):
|
|
|
+ raise ProtocolViolation("knowledge items must be an array")
|
|
|
+ keyword = str(query.get("keyword", "")).casefold()
|
|
|
+ selected = [
|
|
|
+ item
|
|
|
+ for item in rows
|
|
|
+ if isinstance(item, dict) and keyword in json.dumps(item, ensure_ascii=False).casefold()
|
|
|
+ ][: int(query.get("max_count", 3))]
|
|
|
+ refs = tuple(
|
|
|
+ f"knowledge:{_safe_source_id(item.get('id'), rank=index)}"
|
|
|
+ for index, item in enumerate(selected)
|
|
|
+ )
|
|
|
+ safe_selected = redact(selected)
|
|
|
+ if not isinstance(safe_selected, list):
|
|
|
+ raise ProtocolViolation("redacted knowledge hits must remain an array")
|
|
|
+ return RetrievalResult(
|
|
|
+ source_refs=refs,
|
|
|
+ summary=json.dumps(safe_selected, ensure_ascii=False, sort_keys=True),
|
|
|
+ supports=tuple(
|
|
|
+ redact_text(str(item.get("title", ref)))
|
|
|
+ for item, ref in zip(selected, refs, strict=True)
|
|
|
+ ),
|
|
|
+ confidence="lexical",
|
|
|
+ limitations=("frozen local knowledge snapshot",),
|
|
|
+ metadata={
|
|
|
+ "file_sha256": digest,
|
|
|
+ "hit_ids": list(refs),
|
|
|
+ "ranks": list(range(1, len(refs) + 1)),
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class RawArtifactStore(Protocol):
|
|
|
+ async def freeze_bytes(self, content: bytes, *, media_type: str) -> str: ...
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class SafeImageAdapter:
|
|
|
+ http: SafeHttpClient
|
|
|
+ raw_store: RawArtifactStore
|
|
|
+ max_images: int = 12
|
|
|
+ max_image_bytes: int = 10_000_000
|
|
|
+ max_total_image_bytes: int = 50_000_000
|
|
|
+ allowed_mime_types: frozenset[str] = frozenset(
|
|
|
+ {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
|
|
+ )
|
|
|
+
|
|
|
+ async def load(self, *, urls: Sequence[str], snapshot: Any) -> tuple[dict[str, Any], ...]:
|
|
|
+ del snapshot
|
|
|
+ if len(urls) > self.max_images:
|
|
|
+ raise ProtocolViolation("image count exceeds the configured limit")
|
|
|
+ total = 0
|
|
|
+ items: list[dict[str, Any]] = []
|
|
|
+ for url in urls:
|
|
|
+ response = await self.http.request("GET", url)
|
|
|
+ declared_type = response.headers.get("content-type", "").split(";", 1)[0]
|
|
|
+ content_type = _sniff_image_type(response.content)
|
|
|
+ if content_type is None or content_type not in self.allowed_mime_types:
|
|
|
+ raise ProtocolViolation("upstream image MIME type is not permitted")
|
|
|
+ if declared_type and declared_type != content_type:
|
|
|
+ raise ProtocolViolation("upstream image MIME type does not match its bytes")
|
|
|
+ size = len(response.content)
|
|
|
+ if size > self.max_image_bytes:
|
|
|
+ raise ProtocolViolation("image exceeds the configured byte limit")
|
|
|
+ total += size
|
|
|
+ if total > self.max_total_image_bytes:
|
|
|
+ raise ProtocolViolation("images exceed the configured total byte limit")
|
|
|
+ raw_artifact_ref = await self.raw_store.freeze_bytes(
|
|
|
+ response.content,
|
|
|
+ media_type=content_type,
|
|
|
+ )
|
|
|
+ items.append(
|
|
|
+ {
|
|
|
+ "kind": "image",
|
|
|
+ "digest": "sha256:" + sha256(response.content).hexdigest(),
|
|
|
+ "raw_artifact_ref": raw_artifact_ref,
|
|
|
+ "content_type": content_type,
|
|
|
+ "size_bytes": size,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return tuple(items)
|
|
|
+
|
|
|
+
|
|
|
+def _read_json(path: Path) -> tuple[Any, str]:
|
|
|
+ if not path.is_file():
|
|
|
+ raise ProtocolViolation("configured retrieval file does not exist")
|
|
|
+ try:
|
|
|
+ payload = json.loads(path.read_text(encoding="utf-8"))
|
|
|
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
|
+ raise ProtocolViolation("configured retrieval file is not valid UTF-8 JSON") from exc
|
|
|
+ return payload, canonical_sha256(payload).wire
|
|
|
+
|
|
|
+
|
|
|
+def _assert_frozen_source(snapshot: Any, key: str, actual_digest: str) -> None:
|
|
|
+ manifest = getattr(snapshot, "datasource_manifest", {})
|
|
|
+ expected = manifest.get(key) if isinstance(manifest, Mapping) else None
|
|
|
+ expected_digest = expected.get("sha256") if isinstance(expected, Mapping) else None
|
|
|
+ if expected_digest is not None and expected_digest != actual_digest:
|
|
|
+ raise ProtocolViolation(f"{key} changed after the input snapshot was frozen")
|
|
|
+
|
|
|
+
|
|
|
+def _sniff_image_type(content: bytes) -> str | None:
|
|
|
+ if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
|
+ return "image/png"
|
|
|
+ if content.startswith(b"\xff\xd8\xff"):
|
|
|
+ return "image/jpeg"
|
|
|
+ if content.startswith((b"GIF87a", b"GIF89a")):
|
|
|
+ return "image/gif"
|
|
|
+ if len(content) >= 12 and content[:4] == b"RIFF" and content[8:12] == b"WEBP":
|
|
|
+ return "image/webp"
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def _result(
|
|
|
+ source: str,
|
|
|
+ payload: Any,
|
|
|
+ *,
|
|
|
+ metadata: Mapping[str, Any],
|
|
|
+) -> RetrievalResult:
|
|
|
+ rows = payload.get("data", payload.get("results", [])) if isinstance(payload, dict) else payload
|
|
|
+ if not isinstance(rows, list):
|
|
|
+ rows = [payload]
|
|
|
+ safe_rows = redact(rows)
|
|
|
+ if not isinstance(safe_rows, list):
|
|
|
+ raise ProtocolViolation("redacted upstream rows must remain an array")
|
|
|
+ refs = tuple(
|
|
|
+ f"{source}:{_safe_source_id(item.get('channel_content_id', item.get('id')), rank=index)}"
|
|
|
+ if isinstance(item, dict)
|
|
|
+ else f"{source}:{index}"
|
|
|
+ for index, item in enumerate(safe_rows)
|
|
|
+ )
|
|
|
+ return RetrievalResult(
|
|
|
+ source_refs=refs,
|
|
|
+ summary=json.dumps(safe_rows, ensure_ascii=False, sort_keys=True),
|
|
|
+ supports=refs,
|
|
|
+ confidence="upstream",
|
|
|
+ limitations=(),
|
|
|
+ metadata=metadata,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _safe_source_id(value: Any, *, rank: int) -> str:
|
|
|
+ text = str(value if value is not None else rank)
|
|
|
+ if re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", text):
|
|
|
+ return text
|
|
|
+ return "sha256-" + sha256(text.encode("utf-8")).hexdigest()[:20]
|
|
|
+
|
|
|
+
|
|
|
+def _limited_result(
|
|
|
+ source: str,
|
|
|
+ summary: str,
|
|
|
+ limitation: str,
|
|
|
+ *,
|
|
|
+ task_id: str,
|
|
|
+ payload: Any,
|
|
|
+) -> RetrievalResult:
|
|
|
+ return RetrievalResult(
|
|
|
+ source_refs=(),
|
|
|
+ summary=summary,
|
|
|
+ supports=(),
|
|
|
+ confidence="unavailable",
|
|
|
+ limitations=(limitation,),
|
|
|
+ metadata={
|
|
|
+ "task_id": _safe_source_id(task_id, rank=0) if task_id else None,
|
|
|
+ "response_sha256": canonical_sha256(payload).wire,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+__all__ = [
|
|
|
+ "ExternalRetrievalAdapter",
|
|
|
+ "FileDecodeRetrievalAdapter",
|
|
|
+ "FileKnowledgeRetrievalAdapter",
|
|
|
+ "HttpDecodeRetrievalAdapter",
|
|
|
+ "PatternRetrievalAdapter",
|
|
|
+ "RawArtifactStore",
|
|
|
+ "SafeHttpClient",
|
|
|
+ "SafeImageAdapter",
|
|
|
+]
|