| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- from __future__ import annotations
- import json
- from types import SimpleNamespace
- import httpx
- import pytest
- from script_build_host.adapters.retrieval import (
- ExternalRetrievalAdapter,
- FileDecodeRetrievalAdapter,
- FileKnowledgeRetrievalAdapter,
- HttpDecodeRetrievalAdapter,
- PatternRetrievalAdapter,
- SafeHttpClient,
- SafeImageAdapter,
- )
- from script_build_host.adapters.uploaded_topic import SqlUploadedTopicGateway
- from script_build_host.domain.errors import ProtocolViolation, UnsafeOutboundTarget
- from script_build_host.infrastructure.outbound import OutboundPolicy
- from script_build_host.repositories.legacy_input import LegacySqlAlchemyInputReader
- async def _public_resolver(_: str, __: int) -> tuple[str, ...]:
- return ("93.184.216.34",)
- class _RawArtifacts:
- def __init__(self) -> None:
- self.values: list[bytes] = []
- async def freeze_bytes(self, content: bytes, *, media_type: str) -> str:
- assert media_type == "image/png"
- self.values.append(content)
- return "script-build://raw-artifacts/sha256/" + "a" * 64
- @pytest.mark.asyncio
- async def test_http_retrieval_revalidates_redirect_and_freezes_response_metadata() -> None:
- calls: list[str] = []
- def handler(request: httpx.Request) -> httpx.Response:
- calls.append(str(request.url))
- return httpx.Response(
- 200,
- json={"data": [{"channel_content_id": "post-1", "title": "case"}]},
- )
- async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
- safe = SafeHttpClient(
- client,
- OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
- )
- result = await ExternalRetrievalAdapter("https://api.example.com/search", safe).retrieve(
- query={"keyword": "case"}, snapshot=SimpleNamespace()
- )
- assert calls == ["https://api.example.com/search"]
- assert result.source_refs == ("external:post-1",)
- assert str(result.metadata["response_sha256"]).startswith("sha256:")
- def redirect(_: httpx.Request) -> httpx.Response:
- return httpx.Response(302, headers={"location": "https://127.0.0.1/private"})
- async with httpx.AsyncClient(transport=httpx.MockTransport(redirect)) as client:
- safe = SafeHttpClient(
- client,
- OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
- )
- with pytest.raises(UnsafeOutboundTarget):
- await safe.request("GET", "https://api.example.com/start")
- def oversized(_: httpx.Request) -> httpx.Response:
- return httpx.Response(200, content=b"123456")
- async with httpx.AsyncClient(transport=httpx.MockTransport(oversized)) as client:
- safe = SafeHttpClient(
- client,
- OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
- max_response_bytes=5,
- )
- with pytest.raises(ProtocolViolation, match="byte limit"):
- await safe.request("GET", "https://api.example.com/large")
- @pytest.mark.asyncio
- async def test_external_response_secrets_are_redacted_before_evidence_summary() -> None:
- def handler(_request: httpx.Request) -> httpx.Response:
- return httpx.Response(
- 200,
- json={
- "data": [
- {
- "id": "p1",
- "authorization": "Bearer raw-token",
- "url": "https://source.example/item?token=secret",
- }
- ]
- },
- )
- async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
- safe = SafeHttpClient(
- client,
- OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
- )
- result = await ExternalRetrievalAdapter("https://api.example.com/search", safe).retrieve(
- query={"keyword": "case"}, snapshot=SimpleNamespace()
- )
- assert "raw-token" not in result.summary
- assert "secret" not in result.summary
- @pytest.mark.asyncio
- async def test_pattern_polling_and_image_limits_use_mock_transport() -> None:
- poll_count = 0
- def handler(request: httpx.Request) -> httpx.Response:
- nonlocal poll_count
- if request.url.path == "/pattern":
- return httpx.Response(
- 200,
- json={
- "status": "pending",
- "session_id": "task-1",
- "poll_url": "https://api.example.com/poll/task-1",
- },
- )
- if request.url.path.startswith("/poll"):
- poll_count += 1
- return httpx.Response(200, json={"status": "done", "results": [{"id": 2}]})
- return httpx.Response(
- 200,
- content=b"\x89PNG\r\n\x1a\nimage",
- headers={"content-type": "image/png"},
- )
- async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
- safe = SafeHttpClient(
- client,
- OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
- )
- result = await PatternRetrievalAdapter(
- "https://api.example.com/pattern",
- safe,
- poll_interval_seconds=0.001,
- ).retrieve(query={"message": "shape"}, snapshot=SimpleNamespace())
- raw_store = _RawArtifacts()
- images = await SafeImageAdapter(
- safe,
- raw_store,
- max_images=1,
- max_image_bytes=20,
- max_total_image_bytes=20,
- ).load(urls=["https://api.example.com/image"], snapshot=SimpleNamespace())
- assert poll_count == 1
- assert result.source_refs == ("pattern:2",)
- assert images[0]["digest"].startswith("sha256:")
- assert "url" not in images[0]
- assert images[0]["raw_artifact_ref"].startswith("script-build://raw-artifacts/")
- assert len(raw_store.values) == 1
- @pytest.mark.asyncio
- async def test_pattern_timeout_is_frozen_as_a_limitation() -> None:
- def handler(_request: httpx.Request) -> httpx.Response:
- return httpx.Response(200, json={"status": "pending", "session_id": "task-1"})
- async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
- safe = SafeHttpClient(
- client,
- OutboundPolicy(frozenset({"api.example.com"}), resolver=_public_resolver),
- )
- result = await PatternRetrievalAdapter(
- "https://api.example.com/pattern",
- safe,
- poll_interval_seconds=0.001,
- poll_timeout_seconds=0,
- ).retrieve(query={"message": "shape"}, snapshot=SimpleNamespace())
- assert result.source_refs == ()
- assert result.limitations == ("timeout",)
- assert result.metadata["task_id"] == "task-1"
- @pytest.mark.asyncio
- async def test_file_decode_and_knowledge_freeze_file_hash_and_rank(tmp_path) -> None:
- decode_path = tmp_path / "decode.json"
- decode_path.write_text(
- json.dumps({"items": [{"post_id": 4, "account": "acct", "text": "focus"}]}),
- encoding="utf-8",
- )
- knowledge_path = tmp_path / "knowledge.json"
- knowledge_path.write_text(json.dumps([{"id": "k1", "title": "focus method"}]), encoding="utf-8")
- decode_adapter = FileDecodeRetrievalAdapter(decode_path)
- snapshot = SimpleNamespace(
- model_manifest={"embedding_model": "frozen-embed"},
- datasource_manifest={},
- )
- decode = await decode_adapter.retrieve(
- query={
- "keyword": "focus",
- "account_name": "acct",
- "top_k": 3,
- "return_field": "主脉络",
- },
- snapshot=snapshot,
- )
- knowledge = await FileKnowledgeRetrievalAdapter(knowledge_path).retrieve(
- query={"keyword": "focus", "max_count": 3}, snapshot=snapshot
- )
- assert decode.metadata["embedding_model"] == "frozen-embed"
- assert decode.metadata["ranks"] == [1]
- assert decode.metadata["scores"] == [1.0]
- assert str(knowledge.metadata["file_sha256"]).startswith("sha256:")
- frozen_digest = decode.metadata["index_sha256"]
- decode_path.write_text(json.dumps({"items": []}), encoding="utf-8")
- replay = await decode_adapter.retrieve(
- query={"keyword": "focus", "top_k": 3, "return_field": "主脉络"},
- snapshot=SimpleNamespace(
- model_manifest={},
- datasource_manifest={"decode_index": {"sha256": frozen_digest}},
- ),
- )
- assert replay.source_refs == ("decode:4",)
- with pytest.raises(ProtocolViolation, match="changed"):
- await FileDecodeRetrievalAdapter(decode_path).retrieve(
- query={"keyword": "focus", "top_k": 3, "return_field": "主脉络"},
- snapshot=SimpleNamespace(
- model_manifest={},
- datasource_manifest={"decode_index": {"sha256": frozen_digest}},
- ),
- )
- @pytest.mark.asyncio
- async def test_http_decode_preserves_legacy_query_and_ranked_scores() -> None:
- requests: list[dict[str, object]] = []
- def handler(request: httpx.Request) -> httpx.Response:
- requests.append(json.loads(request.content))
- return httpx.Response(
- 200,
- json={"items": [{"post_id": "p1", "score": 0.91, "text": "safe"}]},
- )
- async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
- safe = SafeHttpClient(
- client,
- OutboundPolicy(frozenset({"decode.example"}), resolver=_public_resolver),
- )
- result = await HttpDecodeRetrievalAdapter(
- "https://decode.example/search",
- safe,
- ).retrieve(
- query={
- "return_field": "主脉络",
- "keyword": "focus",
- "account_name": "acct",
- "match_fields": {},
- "top_k": 3,
- },
- snapshot=SimpleNamespace(
- datasource_manifest={"decode_index": {"sha256": "sha256:" + "1" * 64}},
- model_manifest={"embedding_model": "embed-v1"},
- ),
- )
- assert requests[0]["top_k"] == 3
- assert result.source_refs == ("decode:p1",)
- assert result.metadata["scores"] == [0.91]
- assert result.metadata["ranks"] == [1]
- @pytest.mark.asyncio
- async def test_uploaded_topic_parser_keeps_old_shape_without_writing() -> None:
- gateway = SqlUploadedTopicGateway(SimpleNamespace()) # type: ignore[arg-type]
- parsed = await gateway.parse(
- {
- "选题融合": "topic",
- "target_post": {"channel_account_name": "acct"},
- "灵感点": [
- {
- "点": "point",
- "实质": {"具体元素": [{"名称": "element", "说明": "note"}]},
- }
- ],
- }
- )
- assert parsed["account_name"] == "acct"
- assert parsed["item_count"] == 1
- assert parsed["points"][0]["items"][0]["dimension"] == "实质"
- @pytest.mark.asyncio
- async def test_uploaded_topic_create_writes_a_readable_legacy_graph(database) -> None:
- _, sessions = database
- gateway = SqlUploadedTopicGateway(sessions)
- parsed = await gateway.parse(
- {
- "选题融合": "uploaded topic",
- "target_post": {"channel_account_name": "acct"},
- "关键点": [
- {
- "点": "point",
- "形式": [{"名称": "element", "说明": "note"}],
- }
- ],
- }
- )
- created = await gateway.create(parsed, account_name=None)
- graph = await LegacySqlAlchemyInputReader(sessions).read_topic_graph(
- execution_id=0,
- topic_build_id=created["topic_build_id"],
- topic_id=created["topic_id"],
- )
- assert graph["execution"] == {"id": 0, "status": "upload"}
- assert graph["topic"]["result"] == "uploaded topic"
- assert graph["points"][0]["item_ids"] == [graph["composition_items"][0]["id"]]
|