Ver Fonte

feat(取数): 复现旧版脚本构建真实检索能力

新增旧版 OpenRouter 客户端,覆盖单条向量嵌入和结构化聊天响应,并对返回维度、数值类型与 JSON 结构做严格校验。

复现本地 decode 向量索引加载、账号筛选、字段筛选、相似度排序和原始解码文件回读,加入路径越界、索引数量与维度一致性保护。

复现旧版知识库模型选择、小红书搜索与详情补全、知乎搜索和图片理解流程,同时保持所有外部结果经过脱敏和确定性引用封装。

为安全 HTTP 客户端补充受控请求头透传和 identity 编码,并通过伪上游数据覆盖 HTTP 白名单、向量检索、知识检索及外部平台数据形状。

将 numpy 声明为正式运行依赖并刷新锁文件,确保向量索引能力在新环境可安装复现。
SamLee há 1 dia atrás
pai
commit
1e872e1a80

+ 1 - 0
script_build_host/pyproject.toml

@@ -9,6 +9,7 @@ dependencies = [
     "cyber-agent",
     "cyber-agent",
     "fastapi>=0.115",
     "fastapi>=0.115",
     "httpx>=0.28",
     "httpx>=0.28",
+    "numpy>=1.26",
     "pydantic-settings>=2.6",
     "pydantic-settings>=2.6",
     "sqlalchemy[asyncio]>=2.0.36",
     "sqlalchemy[asyncio]>=2.0.36",
     "uvicorn>=0.35",
     "uvicorn>=0.35",

+ 10 - 0
script_build_host/src/script_build_host/adapters/__init__.py

@@ -1,5 +1,11 @@
 """Concrete Host adapters."""
 """Concrete Host adapters."""
 
 
+from .legacy_retrieval import (
+    LegacyExternalRetrievalAdapter,
+    LegacyLlmKnowledgeRetrievalAdapter,
+    LegacyOpenRouterClient,
+    LegacyVectorDecodeRetrievalAdapter,
+)
 from .persona import FilePersonaSource
 from .persona import FilePersonaSource
 from .prompts import DatabaseFirstPromptSource
 from .prompts import DatabaseFirstPromptSource
 from .retrieval import (
 from .retrieval import (
@@ -22,6 +28,10 @@ __all__ = [
     "FileKnowledgeRetrievalAdapter",
     "FileKnowledgeRetrievalAdapter",
     "FilePersonaSource",
     "FilePersonaSource",
     "HttpDecodeRetrievalAdapter",
     "HttpDecodeRetrievalAdapter",
+    "LegacyExternalRetrievalAdapter",
+    "LegacyLlmKnowledgeRetrievalAdapter",
+    "LegacyOpenRouterClient",
+    "LegacyVectorDecodeRetrievalAdapter",
     "PatternRetrievalAdapter",
     "PatternRetrievalAdapter",
     "RawArtifactStore",
     "RawArtifactStore",
     "SafeHttpClient",
     "SafeHttpClient",

+ 720 - 0
script_build_host/src/script_build_host/adapters/legacy_retrieval.py

@@ -0,0 +1,720 @@
+"""Adapters for the input services used by the legacy Script Build runtime.
+
+The code intentionally reproduces only the old tools that were present in the
+Script Agent allowlist.  It has no dependency on legacy logging or output tables.
+"""
+# ruff: noqa: RUF001
+
+from __future__ import annotations
+
+import asyncio
+import html
+import json
+from collections.abc import Mapping
+from hashlib import sha256
+from pathlib import Path
+from typing import Any
+
+from script_build_host.adapters.retrieval import SafeHttpClient
+from script_build_host.domain.errors import ProtocolViolation
+from script_build_host.infrastructure.canonical_json import canonical_sha256
+from script_build_host.infrastructure.redaction import redact
+from script_build_host.tools.gateway import RetrievalResult
+
+_ATOM_FIELD_KEYS = ("主题原子点", "形式原子点", "作用原子点", "感受原子点")
+_ATOM_KEY_TO_COLUMN = {
+    "主题原子点": "主题",
+    "形式原子点": "形式",
+    "作用原子点": "作用",
+    "感受原子点": "感受",
+}
+_COLUMN_TO_ATOM_KEY = {value: key for key, value in _ATOM_KEY_TO_COLUMN.items()}
+
+
+class LegacyOpenRouterClient:
+    """Small credential-contained client for the two old OpenRouter calls."""
+
+    def __init__(
+        self,
+        http: SafeHttpClient,
+        *,
+        api_key: str,
+        embedding_endpoint: str,
+        chat_endpoint: str,
+        embedding_model: str,
+        embedding_dimension: int,
+    ) -> None:
+        self._http = http
+        self._headers = {
+            "Authorization": f"Bearer {api_key}",
+            "Content-Type": "application/json",
+        }
+        self.embedding_endpoint = embedding_endpoint
+        self.chat_endpoint = chat_endpoint
+        self.embedding_model = embedding_model
+        self.embedding_dimension = embedding_dimension
+
+    async def embed_one(self, text: str) -> list[float]:
+        payload = await self._http.json(
+            "POST",
+            self.embedding_endpoint,
+            json_body={
+                "model": self.embedding_model,
+                "input": [(text or "")[:300]],
+                "encoding_format": "float",
+                "dimensions": self.embedding_dimension,
+            },
+            headers=self._headers,
+        )
+        rows = payload.get("data") if isinstance(payload, dict) else None
+        if not isinstance(rows, list) or len(rows) != 1 or not isinstance(rows[0], dict):
+            raise ProtocolViolation("embedding response has an invalid data array")
+        vector = rows[0].get("embedding")
+        if not isinstance(vector, list) or len(vector) != self.embedding_dimension:
+            raise ProtocolViolation("embedding response dimension does not match the index")
+        if any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in vector):
+            raise ProtocolViolation("embedding response contains a non-numeric value")
+        return [float(value) for value in vector]
+
+    async def chat_json(
+        self,
+        *,
+        model: str,
+        messages: list[dict[str, Any]],
+        max_tokens: int | None = None,
+    ) -> Any:
+        body: dict[str, Any] = {"model": model, "messages": messages, "temperature": 0}
+        if max_tokens is not None:
+            body["max_tokens"] = max_tokens
+        payload = await self._http.json(
+            "POST", self.chat_endpoint, json_body=body, headers=self._headers
+        )
+        choices = payload.get("choices") if isinstance(payload, dict) else None
+        if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
+            raise ProtocolViolation("chat response has no choice")
+        message = choices[0].get("message")
+        content = message.get("content") if isinstance(message, dict) else None
+        if not isinstance(content, str) or not content.strip():
+            raise ProtocolViolation("chat response has no text content")
+        return _parse_json_text(content)
+
+
+class LegacyVectorDecodeRetrievalAdapter:
+    """Local legacy vector index plus the legacy OpenRouter query embedding."""
+
+    def __init__(
+        self,
+        *,
+        index_root: Path,
+        raw_root: Path,
+        openrouter: LegacyOpenRouterClient | None,
+        min_score: float = 0.7,
+    ) -> None:
+        self.index_root = index_root.resolve()
+        self.raw_root = raw_root.resolve()
+        self.openrouter = openrouter
+        self.min_score = min_score
+        self._loaded: tuple[list[dict[str, Any]], Any, dict[str, Any]] | None = None
+        self._load_lock = asyncio.Lock()
+
+    async def retrieve(self, *, query: Mapping[str, Any], snapshot: Any) -> RetrievalResult:
+        del snapshot
+        chunks, embeddings, manifest = await self._index()
+        keyword = str(query.get("keyword") or "").strip()
+        account = str(query.get("account_name") or "").strip() or None
+        match_fields = query.get("match_fields") or {}
+        if not isinstance(match_fields, Mapping):
+            raise ProtocolViolation("decode match_fields must be an object")
+        candidate_indices = [
+            index
+            for index, chunk in enumerate(chunks)
+            if _chunk_matches_filter(chunk, match_fields)
+            and (account is None or chunk.get("account") == account)
+        ]
+        top_k = int(query.get("top_k", 3))
+        ranked: list[tuple[str, float | None, dict[str, Any]]] = []
+        if not keyword:
+            seen: set[str] = set()
+            for index in candidate_indices:
+                post_id = str(chunks[index]["post_id"])
+                if post_id in seen:
+                    continue
+                seen.add(post_id)
+                ranked.append((post_id, None, chunks[index]))
+                if len(ranked) >= top_k:
+                    break
+        else:
+            if self.openrouter is None:
+                raise ProtocolViolation(
+                    "semantic decode search requires SCRIPT_BUILD_OPENROUTER_API_KEY"
+                )
+            vector = await self.openrouter.embed_one(keyword)
+            ranked.extend(
+                await asyncio.to_thread(
+                    _rank_decode_posts,
+                    chunks,
+                    embeddings,
+                    candidate_indices,
+                    vector,
+                    self.min_score,
+                    top_k,
+                )
+            )
+
+        return_field = str(query["return_field"])
+        output: list[dict[str, Any]] = []
+        refs: list[str] = []
+        scores: list[float | None] = []
+        for rank, (post_id, score, chunk) in enumerate(ranked):
+            post = await asyncio.to_thread(self._read_post, str(chunk["json_relpath"]))
+            formatted = {
+                "post_id": post_id,
+                "score": round(score, 4) if score is not None else None,
+                "account": chunk.get("account"),
+                "return_field": return_field,
+                "data": _format_post_decode_result(post, return_field),
+            }
+            output.append(formatted)
+            refs.append(f"decode:{_safe_id(post_id, rank)}")
+            scores.append(round(score, 4) if score is not None else None)
+        safe_output = redact(output)
+        if not isinstance(safe_output, list):
+            raise ProtocolViolation("redacted decode result must remain an array")
+        index_digest = canonical_sha256(manifest).wire
+        return RetrievalResult(
+            source_refs=tuple(refs),
+            summary=json.dumps(safe_output, ensure_ascii=False, sort_keys=True),
+            supports=tuple(return_field for _ in refs),
+            confidence="ranked",
+            limitations=(),
+            metadata={
+                "index_manifest_sha256": index_digest,
+                "embedding_model": manifest.get("model"),
+                "hit_ids": refs,
+                "ranks": list(range(1, len(refs) + 1)),
+                "scores": scores,
+            },
+        )
+
+    async def _index(self) -> tuple[list[dict[str, Any]], Any, dict[str, Any]]:
+        if self._loaded is not None:
+            return self._loaded
+        async with self._load_lock:
+            if self._loaded is None:
+                self._loaded = await asyncio.to_thread(_load_decode_index, self.index_root)
+        return self._loaded
+
+    def _read_post(self, relpath_text: str) -> dict[str, Any]:
+        relpath = Path(relpath_text)
+        parts = relpath.parts
+        if parts and parts[0] == "script_decode_raw_data":
+            relpath = Path(*parts[1:])
+        path = (self.raw_root / relpath).resolve()
+        if not path.is_relative_to(self.raw_root) or not path.is_file():
+            raise ProtocolViolation("decode post path is outside the configured raw root")
+        try:
+            payload = json.loads(path.read_text(encoding="utf-8"))
+        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+            raise ProtocolViolation("decode post is not valid UTF-8 JSON") from exc
+        if not isinstance(payload, dict):
+            raise ProtocolViolation("decode post root must be an object")
+        return payload
+
+
+class LegacyLlmKnowledgeRetrievalAdapter:
+    def __init__(
+        self,
+        knowledge_path: Path,
+        *,
+        openrouter: LegacyOpenRouterClient,
+        model: str,
+    ) -> None:
+        self.openrouter = openrouter
+        self.model = model
+        try:
+            payload = json.loads(knowledge_path.read_text(encoding="utf-8"))
+        except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
+            raise ProtocolViolation("knowledge source is not valid UTF-8 JSON") from exc
+        if not isinstance(payload, list):
+            raise ProtocolViolation("knowledge source root must be an array")
+        self.items = [item for item in payload if isinstance(item, dict)]
+        self.digest = canonical_sha256(payload).wire
+
+    async def retrieve(self, *, query: Mapping[str, Any], snapshot: Any) -> RetrievalResult:
+        del snapshot
+        max_count = int(query.get("max_count", 3))
+        index = [
+            {
+                "id": item.get("id"),
+                "title": item.get("title", ""),
+                "purpose": item.get("purpose", ""),
+                "steps": item.get("steps", []),
+            }
+            for item in self.items
+        ]
+        selected = await self.openrouter.chat_json(
+            model=self.model,
+            messages=[
+                {
+                    "role": "system",
+                    "content": (
+                        "你是一个创作知识检索助手。根据给定的检索关键词,从知识条目列表中"
+                        '找出最相关的条目 ID。只返回 JSON 对象,格式:{"ids": [<id>, ...]}。'
+                        "不要输出任何其他内容。"
+                    ),
+                },
+                {
+                    "role": "user",
+                    "content": (
+                        f"检索关键词:{query.get('keyword', '')}\n最多返回 {max_count} 个最相关的"
+                        f"条目 ID(按相关度从高到低排列)。\n\n知识条目列表:\n"
+                        + json.dumps(index, ensure_ascii=False, indent=2)
+                    ),
+                },
+            ],
+        )
+        ids = selected.get("ids", []) if isinstance(selected, dict) else []
+        by_id = {item.get("id"): item for item in self.items}
+        output = []
+        for identifier in ids[:max_count] if isinstance(ids, list) else []:
+            item = by_id.get(identifier)
+            if item is not None:
+                output.append(
+                    {
+                        "id": item.get("id"),
+                        "title": item.get("title", ""),
+                        "purpose": item.get("purpose", ""),
+                        "content": _knowledge_content(item),
+                    }
+                )
+        refs = tuple(
+            f"knowledge:{_safe_id(item.get('id'), rank)}" for rank, item in enumerate(output)
+        )
+        return RetrievalResult(
+            source_refs=refs,
+            summary=json.dumps(output, ensure_ascii=False, sort_keys=True),
+            supports=tuple(
+                str(item.get("title") or ref) for item, ref in zip(output, refs, strict=True)
+            ),
+            confidence="model-selected",
+            limitations=(),
+            metadata={"file_sha256": self.digest, "hit_ids": list(refs)},
+        )
+
+
+class LegacyExternalRetrievalAdapter:
+    """Exact active XHS/Zhihu retrieval path from the old Script tool."""
+
+    def __init__(
+        self,
+        *,
+        xhs_search_endpoint: str,
+        xhs_detail_endpoint: str,
+        zhihu_search_endpoint: str,
+        http: SafeHttpClient,
+        openrouter: LegacyOpenRouterClient | None = None,
+        image_model: str = "google/gemini-3-flash-preview",
+    ) -> None:
+        self.xhs_search_endpoint = xhs_search_endpoint
+        self.xhs_detail_endpoint = xhs_detail_endpoint
+        self.zhihu_search_endpoint = zhihu_search_endpoint
+        self.http = http
+        self.openrouter = openrouter
+        self.image_model = image_model
+
+    async def retrieve(self, *, query: Mapping[str, Any], snapshot: Any) -> RetrievalResult:
+        del snapshot
+        keyword = str(query.get("keyword") or "")
+        max_count = int(query.get("max_count", 5))
+        platform = query.get("platform_channel", "xhs")
+        rows = (
+            await self._search_xhs(keyword, max_count)
+            if platform == "xhs"
+            else await self._search_zhihu(keyword, max_count)
+        )
+        await asyncio.gather(*(self._add_image_understanding(row) for row in rows))
+        safe_rows = redact(rows)
+        if not isinstance(safe_rows, list):
+            raise ProtocolViolation("redacted external result must remain an array")
+        refs = tuple(
+            f"external:{_safe_id(item.get('channel_content_id', item.get('id')), rank)}"
+            for rank, 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=("image understanding unavailable",) if self.openrouter is None else (),
+            metadata={"response_sha256": canonical_sha256(rows).wire},
+        )
+
+    async def _search_xhs(self, keyword: str, max_count: int) -> list[dict[str, Any]]:
+        cursor: str | int = ""
+        output: list[dict[str, Any]] = []
+        for _ in range(2):
+            body = await self.http.json(
+                "POST",
+                self.xhs_search_endpoint,
+                json_body={
+                    "keyword": keyword,
+                    "content_type": "图文",
+                    "sort_type": "综合",
+                    "publish_time": "",
+                    "cursor": cursor,
+                },
+            )
+            if not isinstance(body, dict) or body.get("code") not in (0, "0"):
+                raise ProtocolViolation("XHS search returned a non-zero code")
+            block = body.get("data") or {}
+            raw_items = block.get("data") if isinstance(block, dict) else []
+            items = [item for item in raw_items or [] if isinstance(item, dict)]
+            ids = [str(item["id"]).strip() for item in items if str(item.get("id") or "").strip()]
+            detail_values = await asyncio.gather(
+                *(self._xhs_detail(identifier) for identifier in ids)
+            )
+            detail_map = {
+                identifier: value
+                for identifier, value in zip(ids, detail_values, strict=True)
+                if value is not None
+            }
+            for item in items:
+                identifier = str(item.get("id") or "").strip()
+                if identifier and identifier in detail_map:
+                    output.append(detail_map[identifier])
+                elif not identifier:
+                    output.append(_xhs_card(item))
+                if len(output) >= max_count:
+                    return output[:max_count]
+            if not block.get("has_more") or block.get("next_cursor") in (None, ""):
+                break
+            cursor = block["next_cursor"]
+        return output[:max_count]
+
+    async def _xhs_detail(self, identifier: str) -> dict[str, Any] | None:
+        try:
+            body = await self.http.json(
+                "POST", self.xhs_detail_endpoint, json_body={"content_id": identifier}
+            )
+        except ProtocolViolation:
+            return None
+        inner = (
+            body.get("data") if isinstance(body, dict) and body.get("code") in (0, "0") else None
+        )
+        note = inner.get("data") if isinstance(inner, dict) else None
+        return _xhs_detail(note) if isinstance(note, dict) else None
+
+    async def _search_zhihu(self, keyword: str, max_count: int) -> list[dict[str, Any]]:
+        payload = {
+            "type": "zhihu",
+            "keyword": keyword,
+            "cursor": "",
+            "content_type": "图文",
+            "max_count": max_count,
+        }
+        last: Any = None
+        for _ in range(3):
+            body = await self.http.json("POST", self.zhihu_search_endpoint, json_body=payload)
+            last = body
+            data = (
+                body.get("data")
+                if isinstance(body, dict) and body.get("code") in (0, "0")
+                else None
+            )
+            if isinstance(data, list) and data:
+                return [item for item in data if isinstance(item, dict)][:max_count]
+        raise ProtocolViolation(f"Zhihu search returned no data ({canonical_sha256(last).wire})")
+
+    async def _add_image_understanding(self, row: dict[str, Any]) -> None:
+        raw_images = row.get("images")
+        images = _dedupe_urls(raw_images if isinstance(raw_images, list) else [])
+        row["images"] = images
+        if not images or self.openrouter is None:
+            row["images_text_escape"] = [{"image_url": url, "text": ""} for url in images]
+            return
+        prompt = (
+            f"帖子标题:{row.get('title', '')}\n帖子正文:{row.get('body_text', '')}\n\n"
+            "请详细描述每张图片的主体、场景、文字、布局、构图和色彩。只输出 JSON 数组,"
+            "每项包含 image_url 和 text。"
+        )
+        content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
+        content.extend({"type": "image_url", "image_url": {"url": url}} for url in images)
+        try:
+            parsed = await self.openrouter.chat_json(
+                model=self.image_model,
+                messages=[{"role": "user", "content": content}],
+                max_tokens=8192,
+            )
+        except ProtocolViolation:
+            parsed = []
+        descriptions: dict[str, str] = {}
+        if isinstance(parsed, list):
+            for index, item in enumerate(parsed):
+                if isinstance(item, dict):
+                    url = str(
+                        item.get("image_url")
+                        or item.get("url")
+                        or (images[index] if index < len(images) else "")
+                    )
+                    descriptions[url] = str(item.get("text") or item.get("description") or "")
+        row["images_text_escape"] = [
+            {"image_url": url, "text": html.escape(descriptions.get(url, ""))} for url in images
+        ]
+
+
+def _load_decode_index(index_root: Path) -> tuple[list[dict[str, Any]], Any, dict[str, Any]]:
+    try:
+        import numpy as np
+    except ImportError as exc:  # pragma: no cover - deployment dependency check
+        raise ProtocolViolation("numpy is required for the legacy decode index") from exc
+    try:
+        manifest = json.loads((index_root / "manifest.json").read_text(encoding="utf-8"))
+        chunks = [
+            json.loads(line)
+            for line in (index_root / "meta.jsonl").read_text(encoding="utf-8").splitlines()
+            if line.strip()
+        ]
+        embeddings = np.load(index_root / "embeddings.npy", mmap_mode="r")
+    except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
+        raise ProtocolViolation("legacy decode index is missing or invalid") from exc
+    if not isinstance(manifest, dict) or len(chunks) != embeddings.shape[0]:
+        raise ProtocolViolation("legacy decode index metadata and vectors do not match")
+    if manifest.get("dimension") != embeddings.shape[1]:
+        raise ProtocolViolation("legacy decode index dimension does not match its manifest")
+    return chunks, embeddings, manifest
+
+
+def _rank_decode_posts(
+    chunks: list[dict[str, Any]],
+    embeddings: Any,
+    candidate_indices: list[int],
+    vector: list[float],
+    min_score: float,
+    top_k: int,
+) -> list[tuple[str, float, dict[str, Any]]]:
+    import numpy as np
+
+    if not candidate_indices:
+        return []
+    query = np.asarray(vector, dtype=np.float32)
+    matrix = embeddings[np.asarray(candidate_indices, dtype=np.int64)].astype(np.float32)
+    query_norm = np.linalg.norm(query)
+    norms = np.linalg.norm(matrix, axis=1)
+    scores = (matrix @ query) / (np.where(norms == 0, 1e-12, norms) * query_norm)
+    posts: dict[str, tuple[float, dict[str, Any]]] = {}
+    for local_index, raw_score in enumerate(scores):
+        score = float(raw_score)
+        if score < min_score:
+            continue
+        chunk = chunks[candidate_indices[local_index]]
+        post_id = str(chunk["post_id"])
+        if post_id not in posts or score > posts[post_id][0]:
+            posts[post_id] = (score, chunk)
+    return [
+        (post_id, score, chunk)
+        for post_id, (score, chunk) in sorted(
+            posts.items(), key=lambda item: (-item[1][0], item[0])
+        )[:top_k]
+    ]
+
+
+def _chunk_matches_filter(chunk: Mapping[str, Any], fields: Mapping[str, Any]) -> bool:
+    if not fields:
+        return True
+    for column, subfield in fields.items():
+        if chunk.get("column") == column and (
+            subfield is None or chunk.get("sub_field") == subfield
+        ):
+            return True
+    return False
+
+
+def _format_post_decode_result(post: dict[str, Any], return_field: str) -> list[dict[str, Any]]:
+    paragraphs = [value for value in post.get("段落列表") or [] if isinstance(value, dict)]
+    if return_field == "主脉络":
+        return [_main_thread(value) for value in paragraphs]
+    if return_field == "元素":
+        return [_elements(value) for value in paragraphs]
+    if return_field in {"主题", "形式", "作用", "感受"}:
+        return [_column(value, return_field) for value in paragraphs]
+    raise ProtocolViolation("unsupported decode return field")
+
+
+def _atom_name(atom: Mapping[str, Any]) -> str:
+    return str(atom.get("维度名") or atom.get("维度") or "")
+
+
+def _atom_value(atom: Mapping[str, Any]) -> str:
+    return str(atom.get("维度值") or atom.get("原子点") or "")
+
+
+def _main_thread(paragraph: dict[str, Any]) -> dict[str, Any]:
+    dimensions = []
+    for key in _ATOM_FIELD_KEYS:
+        for atom in paragraph.get(key) or []:
+            if isinstance(atom, dict) and atom.get("维度类型") == "主维度":
+                dimensions.append({"维度名": _atom_name(atom), "维度值": _atom_value(atom)})
+    return {
+        "id": paragraph.get("id", ""),
+        "名称": paragraph.get("名称", ""),
+        "主题描述": paragraph.get("主题", ""),
+        "详细描述": paragraph.get("描述", ""),
+        "子项": [
+            _main_thread(value) for value in paragraph.get("子项") or [] if isinstance(value, dict)
+        ],
+        "主维度": dimensions,
+    }
+
+
+def _elements(paragraph: dict[str, Any]) -> dict[str, Any]:
+    values = []
+    for key, column in _ATOM_KEY_TO_COLUMN.items():
+        for atom in paragraph.get(key) or []:
+            if isinstance(atom, dict) and atom.get("维度类型") in {"从维度", "子元素"}:
+                values.append(
+                    {"类型": column, "维度名": _atom_name(atom), "维度值": _atom_value(atom)}
+                )
+    output: dict[str, Any] = {
+        "id": paragraph.get("id", ""),
+        "名称": paragraph.get("名称", ""),
+        "元素": values,
+    }
+    children = [value for value in paragraph.get("子项") or [] if isinstance(value, dict)]
+    if children:
+        output["子项"] = [_elements(value) for value in children]
+    return output
+
+
+def _column(paragraph: dict[str, Any], column: str) -> dict[str, Any]:
+    atoms = []
+    for atom in paragraph.get(_COLUMN_TO_ATOM_KEY[column]) or []:
+        if isinstance(atom, dict):
+            atoms.append(
+                {
+                    "维度名": _atom_name(atom),
+                    "维度值": _atom_value(atom),
+                    "维度类型": str(atom.get("维度类型") or ""),
+                    "维度名描述": str(atom.get("维度名描述") or ""),
+                    "维度值描述": str(atom.get("维度值描述") or ""),
+                }
+            )
+    output: dict[str, Any] = {
+        "id": paragraph.get("id", ""),
+        "名称": paragraph.get("名称", ""),
+        "描述": paragraph.get(column, ""),
+        "原子点": atoms,
+    }
+    children = [value for value in paragraph.get("子项") or [] if isinstance(value, dict)]
+    if children:
+        output["子项"] = [_column(value, column) for value in children]
+    return output
+
+
+def _knowledge_content(item: Mapping[str, Any]) -> str:
+    parts = [f"目标:{item.get('purpose', '')}"]
+    for index, step in enumerate(item.get("steps", []) or [], 1):
+        if not isinstance(step, dict):
+            continue
+        outputs = step.get("outputs") or []
+        values = ";".join(
+            str(output.get("value"))
+            for output in outputs
+            if isinstance(output, dict) and output.get("value")
+        )
+        text = f"步骤{index}(目的:{step.get('intent', '')})\n  指引:{step.get('directive', '')}"
+        parts.append(text + (f"\n  产出:{values}" if values else ""))
+    return "\n".join(parts)
+
+
+def _xhs_detail(value: Mapping[str, Any]) -> dict[str, Any]:
+    images = [
+        item.get("image_url") if isinstance(item, dict) else item
+        for item in value.get("image_url_list") or []
+    ]
+    videos = [
+        (item.get("video_url") or item.get("url")) if isinstance(item, dict) else item
+        for item in value.get("video_url_list") or []
+    ]
+    return {
+        "channel_content_id": str(value.get("channel_content_id") or ""),
+        "title": value.get("title") or "",
+        "content_type": value.get("content_type") or "note",
+        "body_text": value.get("body_text") or "",
+        "like_count": _integer(value.get("like_count")),
+        "publish_timestamp": _timestamp_ms(value.get("publish_timestamp")),
+        "images": _dedupe_urls(images),
+        "videos": _dedupe_urls(videos),
+        "channel": "xhs",
+        "link": value.get("content_link") or "",
+    }
+
+
+def _xhs_card(value: Mapping[str, Any]) -> dict[str, Any]:
+    raw_card = value.get("note_card")
+    card: Mapping[str, Any] = raw_card if isinstance(raw_card, dict) else {}
+    images = [
+        item.get("image_url") if isinstance(item, dict) else item
+        for item in card.get("image_list") or []
+    ]
+    raw_interact = card.get("interact_info")
+    interact: Mapping[str, Any] = raw_interact if isinstance(raw_interact, dict) else {}
+    identifier = str(value.get("id") or "")
+    return {
+        "channel_content_id": identifier,
+        "title": card.get("display_title") or "",
+        "content_type": "note",
+        "body_text": card.get("desc") or "",
+        "like_count": _integer(interact.get("liked_count")),
+        "publish_timestamp": _timestamp_ms(card.get("publish_timestamp")),
+        "images": _dedupe_urls(images),
+        "videos": [],
+        "channel": "xhs",
+        "link": f"https://www.xiaohongshu.com/explore/{identifier}" if identifier else "",
+    }
+
+
+def _dedupe_urls(values: list[Any]) -> list[str]:
+    return list(dict.fromkeys(str(value).strip() for value in values if str(value or "").strip()))
+
+
+def _integer(value: Any) -> int:
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return 0
+
+
+def _timestamp_ms(value: Any) -> int:
+    number = _integer(value)
+    return number * 1000 if 0 < number < 1_000_000_000_000 else number
+
+
+def _parse_json_text(value: str) -> Any:
+    text = value.strip()
+    if text.startswith("```"):
+        text = text.split("```", 2)[1]
+        if text.startswith("json"):
+            text = text[4:]
+    try:
+        return json.loads(text.strip())
+    except json.JSONDecodeError as exc:
+        raise ProtocolViolation("model response is not valid JSON") from exc
+
+
+def _safe_id(value: Any, rank: int) -> str:
+    text = str(value if value is not None else rank)
+    if (
+        text
+        and len(text) <= 128
+        and all(character.isalnum() or character in "._:-" for character in text)
+    ):
+        return text
+    return "sha256-" + sha256(text.encode()).hexdigest()[:20]
+
+
+__all__ = [
+    "LegacyExternalRetrievalAdapter",
+    "LegacyLlmKnowledgeRetrievalAdapter",
+    "LegacyOpenRouterClient",
+    "LegacyVectorDecodeRetrievalAdapter",
+]

+ 5 - 4
script_build_host/src/script_build_host/adapters/retrieval.py

@@ -46,13 +46,16 @@ class SafeHttpClient:
         url: str,
         url: str,
         *,
         *,
         json_body: Mapping[str, Any] | None = None,
         json_body: Mapping[str, Any] | None = None,
+        headers: Mapping[str, str] | None = None,
     ) -> httpx.Response:
     ) -> httpx.Response:
         current = await self.policy.validate_url(url)
         current = await self.policy.validate_url(url)
+        request_headers = {"Accept-Encoding": "identity", **dict(headers or {})}
         for _ in range(self.max_redirects + 1):
         for _ in range(self.max_redirects + 1):
             request = self.client.build_request(
             request = self.client.build_request(
                 method,
                 method,
                 current,
                 current,
                 json=dict(json_body) if json_body is not None else None,
                 json=dict(json_body) if json_body is not None else None,
+                headers=request_headers,
                 timeout=self.timeout_seconds,
                 timeout=self.timeout_seconds,
             )
             )
             response = await self.client.send(request, stream=True, follow_redirects=False)
             response = await self.client.send(request, stream=True, follow_redirects=False)
@@ -88,8 +91,9 @@ class SafeHttpClient:
         url: str,
         url: str,
         *,
         *,
         json_body: Mapping[str, Any] | None = None,
         json_body: Mapping[str, Any] | None = None,
+        headers: Mapping[str, str] | None = None,
     ) -> Any:
     ) -> Any:
-        response = await self.request(method, url, json_body=json_body)
+        response = await self.request(method, url, json_body=json_body, headers=headers)
         try:
         try:
             return response.json()
             return response.json()
         except ValueError as exc:
         except ValueError as exc:
@@ -342,12 +346,9 @@ class SafeImageAdapter:
         items: list[dict[str, Any]] = []
         items: list[dict[str, Any]] = []
         for url in urls:
         for url in urls:
             response = await self.http.request("GET", url)
             response = await self.http.request("GET", url)
-            declared_type = response.headers.get("content-type", "").split(";", 1)[0]
             content_type = _sniff_image_type(response.content)
             content_type = _sniff_image_type(response.content)
             if content_type is None or content_type not in self.allowed_mime_types:
             if content_type is None or content_type not in self.allowed_mime_types:
                 raise ProtocolViolation("upstream image MIME type is not permitted")
                 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)
             size = len(response.content)
             if size > self.max_image_bytes:
             if size > self.max_image_bytes:
                 raise ProtocolViolation("image exceeds the configured byte limit")
                 raise ProtocolViolation("image exceeds the configured byte limit")

+ 258 - 0
script_build_host/tests/test_legacy_active_inputs.py

@@ -0,0 +1,258 @@
+from __future__ import annotations
+
+import json
+from types import SimpleNamespace
+
+import httpx
+import numpy as np
+import pytest
+
+from script_build_host.adapters.legacy_retrieval import (
+    LegacyExternalRetrievalAdapter,
+    LegacyLlmKnowledgeRetrievalAdapter,
+    LegacyOpenRouterClient,
+    LegacyVectorDecodeRetrievalAdapter,
+)
+from script_build_host.adapters.retrieval import SafeHttpClient
+from script_build_host.infrastructure.outbound import OutboundPolicy
+
+
+async def _public_resolver(_: str, __: int) -> tuple[str, ...]:
+    return ("93.184.216.34",)
+
+
+def _decode_fixture(tmp_path):
+    index = tmp_path / "index"
+    raw = tmp_path / "raw" / "acct"
+    index.mkdir()
+    raw.mkdir(parents=True)
+    (index / "manifest.json").write_text(
+        json.dumps({"model": "embed", "dimension": 2}), encoding="utf-8"
+    )
+    chunks = [
+        {
+            "chunk_id": "p1|1",
+            "post_id": "p1",
+            "account": "acct",
+            "json_relpath": "script_decode_raw_data/acct/p1.json",
+            "column": "主题",
+            "sub_field": "维度名",
+        },
+        {
+            "chunk_id": "p2|1",
+            "post_id": "p2",
+            "account": "other",
+            "json_relpath": "script_decode_raw_data/acct/p1.json",
+            "column": "形式",
+            "sub_field": "维度值",
+        },
+    ]
+    (index / "meta.jsonl").write_text(
+        "".join(json.dumps(item, ensure_ascii=False) + "\n" for item in chunks),
+        encoding="utf-8",
+    )
+    np.save(index / "embeddings.npy", np.asarray([[1, 0], [0, 1]], dtype=np.float16))
+    (raw / "p1.json").write_text(
+        json.dumps(
+            {
+                "段落列表": [
+                    {
+                        "id": "段落1",
+                        "名称": "开头",
+                        "主题": "主题正文",
+                        "描述": "完整描述",
+                        "主题原子点": [{"维度类型": "主维度", "维度名": "对象", "维度值": "测试"}],
+                    }
+                ]
+            },
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    return index, tmp_path / "raw"
+
+
+@pytest.mark.asyncio
+async def test_http_is_permitted_only_for_the_separate_legacy_allowlist() -> None:
+    policy = OutboundPolicy(
+        allowed_hosts=frozenset({"legacy.example"}),
+        allowed_ports=frozenset({80}),
+        allowed_http_hosts=frozenset({"legacy.example"}),
+        resolver=_public_resolver,
+    )
+    assert await policy.validate_url("http://legacy.example/input") == (
+        "http://legacy.example/input"
+    )
+    denied = OutboundPolicy(
+        allowed_hosts=frozenset({"legacy.example"}),
+        allowed_ports=frozenset({80}),
+        resolver=_public_resolver,
+    )
+    with pytest.raises(Exception, match="HTTPS"):
+        await denied.validate_url("http://legacy.example/input")
+
+
+@pytest.mark.asyncio
+async def test_legacy_decode_supports_account_only_and_semantic_vector_search(tmp_path) -> None:
+    index, raw = _decode_fixture(tmp_path)
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        assert request.headers["authorization"] == "Bearer secret"
+        return httpx.Response(200, json={"data": [{"index": 0, "embedding": [1, 0]}]})
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
+        safe = SafeHttpClient(
+            client,
+            OutboundPolicy(frozenset({"openrouter.example"}), resolver=_public_resolver),
+        )
+        openrouter = LegacyOpenRouterClient(
+            safe,
+            api_key="secret",
+            embedding_endpoint="https://openrouter.example/embeddings",
+            chat_endpoint="https://openrouter.example/chat",
+            embedding_model="embed",
+            embedding_dimension=2,
+        )
+        adapter = LegacyVectorDecodeRetrievalAdapter(
+            index_root=index, raw_root=raw, openrouter=openrouter
+        )
+        account_result = await adapter.retrieve(
+            query={
+                "return_field": "主脉络",
+                "keyword": "",
+                "account_name": "acct",
+                "match_fields": {},
+                "top_k": 3,
+            },
+            snapshot=SimpleNamespace(),
+        )
+        semantic_result = await adapter.retrieve(
+            query={
+                "return_field": "主题",
+                "keyword": "测试",
+                "account_name": None,
+                "match_fields": {"主题": "维度名"},
+                "top_k": 3,
+            },
+            snapshot=SimpleNamespace(),
+        )
+    assert account_result.source_refs == ("decode:p1",)
+    assert "主维度" in account_result.summary
+    assert semantic_result.metadata["scores"] == [1.0]
+    assert "主题正文" in semantic_result.summary
+
+
+@pytest.mark.asyncio
+async def test_legacy_knowledge_uses_full_llm_selection_contract(tmp_path) -> None:
+    source = tmp_path / "knowledge.json"
+    source.write_text(
+        json.dumps(
+            [
+                {
+                    "id": 7,
+                    "title": "结构",
+                    "purpose": "搭建结构",
+                    "steps": [
+                        {
+                            "intent": "开头",
+                            "directive": "先给结果",
+                            "outputs": [{"value": "钩子"}],
+                        }
+                    ],
+                }
+            ],
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    bodies = []
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        bodies.append(json.loads(request.content))
+        return httpx.Response(
+            200,
+            json={"choices": [{"message": {"content": '{"ids":[7]}'}}]},
+        )
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
+        safe = SafeHttpClient(
+            client,
+            OutboundPolicy(frozenset({"openrouter.example"}), resolver=_public_resolver),
+        )
+        openrouter = LegacyOpenRouterClient(
+            safe,
+            api_key="secret",
+            embedding_endpoint="https://openrouter.example/embeddings",
+            chat_endpoint="https://openrouter.example/chat",
+            embedding_model="embed",
+            embedding_dimension=2,
+        )
+        result = await LegacyLlmKnowledgeRetrievalAdapter(
+            source, openrouter=openrouter, model="knowledge-model"
+        ).retrieve(query={"keyword": "结构", "max_count": 3}, snapshot=SimpleNamespace())
+    assert bodies[0]["model"] == "knowledge-model"
+    assert "先给结果" in bodies[0]["messages"][1]["content"]
+    assert "钩子" in result.summary
+
+
+@pytest.mark.asyncio
+async def test_legacy_external_reproduces_xhs_detail_and_zhihu_shapes() -> None:
+    requests: list[tuple[str, dict[str, object]]] = []
+
+    def handler(request: httpx.Request) -> httpx.Response:
+        body = json.loads(request.content)
+        requests.append((request.url.path, body))
+        if request.url.path == "/xhs/search":
+            return httpx.Response(
+                200,
+                json={"code": 0, "data": {"data": [{"id": "note-1"}], "has_more": False}},
+            )
+        if request.url.path == "/xhs/detail":
+            return httpx.Response(
+                200,
+                json={
+                    "code": 0,
+                    "data": {
+                        "data": {
+                            "channel_content_id": "note-1",
+                            "title": "标题",
+                            "body_text": "正文",
+                            "like_count": "12",
+                            "publish_timestamp": 100,
+                            "image_url_list": [
+                                {"image_url": "https://image.example/a.jpg"},
+                                {"image_url": "https://image.example/a.jpg"},
+                            ],
+                        }
+                    },
+                },
+            )
+        return httpx.Response(200, json={"code": 0, "data": [{"id": "zhihu-1"}]})
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
+        safe = SafeHttpClient(
+            client,
+            OutboundPolicy(frozenset({"legacy.example"}), resolver=_public_resolver),
+        )
+        adapter = LegacyExternalRetrievalAdapter(
+            xhs_search_endpoint="https://legacy.example/xhs/search",
+            xhs_detail_endpoint="https://legacy.example/xhs/detail",
+            zhihu_search_endpoint="https://legacy.example/zhihu",
+            http=safe,
+        )
+        xhs = await adapter.retrieve(
+            query={"keyword": "测试", "platform_channel": "xhs", "max_count": 5},
+            snapshot=SimpleNamespace(),
+        )
+        zhihu = await adapter.retrieve(
+            query={"keyword": "测试", "platform_channel": "zhihu", "max_count": 5},
+            snapshot=SimpleNamespace(),
+        )
+    xhs_rows = json.loads(xhs.summary)
+    assert xhs_rows[0]["publish_timestamp"] == 100_000
+    assert xhs_rows[0]["images"] == ["https://image.example/a.jpg"]
+    assert xhs_rows[0]["images_text_escape"] == [
+        {"image_url": "https://image.example/a.jpg", "text": ""}
+    ]
+    assert zhihu.source_refs == ("external:zhihu-1",)
+    assert requests[0][1]["content_type"] == "图文"

+ 142 - 1
script_build_host/uv.lock

@@ -3,7 +3,8 @@ revision = 3
 requires-python = ">=3.11"
 requires-python = ">=3.11"
 resolution-markers = [
 resolution-markers = [
     "python_full_version >= '3.15'",
     "python_full_version >= '3.15'",
-    "python_full_version < '3.15'",
+    "python_full_version >= '3.12' and python_full_version < '3.15'",
+    "python_full_version < '3.12'",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -586,6 +587,143 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
     { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
 ]
 ]
 
 
+[[package]]
+name = "numpy"
+version = "2.4.6"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.12'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
+    { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
+    { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
+    { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
+    { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
+    { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
+    { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
+    { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
+    { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
+    { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
+    { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
+    { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
+    { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
+    { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
+    { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
+    { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
+    { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
+    { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
+    { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
+    { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
+    { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
+    { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
+    { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
+    { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
+    { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
+    { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
+    { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
+    { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
+    { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
+    { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
+    { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.5.1"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version >= '3.15'",
+    "python_full_version >= '3.12' and python_full_version < '3.15'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" },
+    { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" },
+    { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" },
+    { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" },
+    { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" },
+    { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" },
+    { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" },
+    { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" },
+    { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" },
+    { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" },
+    { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" },
+    { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" },
+    { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" },
+    { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" },
+    { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" },
+    { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" },
+    { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" },
+    { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" },
+    { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" },
+    { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" },
+    { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
+]
+
 [[package]]
 [[package]]
 name = "packaging"
 name = "packaging"
 version = "26.2"
 version = "26.2"
@@ -923,6 +1061,8 @@ dependencies = [
     { name = "cyber-agent" },
     { name = "cyber-agent" },
     { name = "fastapi" },
     { name = "fastapi" },
     { name = "httpx" },
     { name = "httpx" },
+    { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
+    { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
     { name = "pydantic-settings" },
     { name = "pydantic-settings" },
     { name = "sqlalchemy", extra = ["asyncio"] },
     { name = "sqlalchemy", extra = ["asyncio"] },
     { name = "uvicorn" },
     { name = "uvicorn" },
@@ -946,6 +1086,7 @@ requires-dist = [
     { name = "fastapi", specifier = ">=0.115" },
     { name = "fastapi", specifier = ">=0.115" },
     { name = "httpx", specifier = ">=0.28" },
     { name = "httpx", specifier = ">=0.28" },
     { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13" },
     { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13" },
+    { name = "numpy", specifier = ">=1.26" },
     { name = "pydantic-settings", specifier = ">=2.6" },
     { name = "pydantic-settings", specifier = ">=2.6" },
     { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" },
     { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" },
     { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
     { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },