mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-17 04:41:01 +08:00
300 lines
15 KiB
Python
300 lines
15 KiB
Python
"""视觉候选 snapshot 的版本化 canonicalization 和完整性校验。
|
|
|
|
该模块位于视觉匹配与任务持久化之间,只处理可序列化的任务事实,不读取数据库、
|
|
图片文件或运行时路径。snapshot 由后端生成并供 Task、attempt 和 workspace 共同引用。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import re
|
|
from datetime import datetime
|
|
from pathlib import PurePosixPath
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
|
|
VISUAL_MATCH_SNAPSHOT_PROTOCOL_VERSION = 2
|
|
MAX_VISUAL_MATCH_CANDIDATES = 50
|
|
SNAPSHOT_SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
|
|
IMAGE_SHA256_RE = re.compile(r"[0-9a-fA-F]{64}\Z")
|
|
SNAPSHOT_FIELDS = frozenset({"protocol_version", "snapshot_sha256", "matched_at", "query", "candidates"})
|
|
QUERY_FIELDS = frozenset({"meme_id", "image_sha256", "model", "dimensions", "preprocess_version"})
|
|
CANDIDATE_FIELDS = frozenset({"rank", "meme_id", "image_sha256", "size_bytes", "score", "relative_path", "context"})
|
|
MANIFEST_FIELDS = frozenset({"protocol_version", "snapshot_sha256", "matched_at", "candidate_count", "query", "candidates"})
|
|
SAFE_CONTEXT_FIELDS = frozenset({"title", "summary", "subjects", "visible_text", "references", "meaning", "keywords", "search_queries", "uncertainties"})
|
|
CONTEXT_LIST_FIELDS = frozenset({"subjects", "visible_text", "references", "keywords", "search_queries", "uncertainties"})
|
|
CONTEXT_URL_RE = re.compile(r"(?i)\b[a-z][a-z0-9+.-]*://")
|
|
|
|
|
|
class VisualMatchSnapshotError(ValueError):
|
|
"""snapshot 输入或持久化内容不符合版本化契约时使用的稳定错误。"""
|
|
|
|
def __init__(self, code: str, message: str | None = None) -> None:
|
|
"""保存稳定错误码,供任务服务映射而不暴露底层校验细节。"""
|
|
super().__init__(message or code)
|
|
self.code = code
|
|
|
|
|
|
def _string(value: object, *, code: str, name: str, maximum: int = 255) -> str:
|
|
"""校验非空短字符串,拒绝控制字符和不受约束的超长字段。"""
|
|
if not isinstance(value, str) or not value or len(value) > maximum or any(ord(char) < 0x20 for char in value):
|
|
raise VisualMatchSnapshotError(code, f"snapshot {name} 无效")
|
|
return value
|
|
|
|
|
|
def _sha256(value: object, *, code: str, name: str) -> str:
|
|
"""校验图片或 snapshot 的十六进制 SHA-256 字符串。"""
|
|
if not isinstance(value, str) or not IMAGE_SHA256_RE.fullmatch(value):
|
|
raise VisualMatchSnapshotError(code, f"snapshot {name} 无效")
|
|
return value.lower()
|
|
|
|
|
|
def _relative_path(value: object) -> str:
|
|
"""校验 task-relative 候选文件名,拒绝绝对路径、跳转和控制字符。"""
|
|
if not isinstance(value, str) or not value or len(value) > 512 or "\\" in value or any(ord(char) < 0x20 for char in value):
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选相对路径无效")
|
|
parts = value.split("/")
|
|
if any(not part or part in {".", ".."} for part in parts):
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选相对路径无效")
|
|
path = PurePosixPath(value)
|
|
if path.is_absolute() or not path.parts:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选相对路径无效")
|
|
if value == "manifest.json":
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选相对路径占用 manifest 文件")
|
|
return value
|
|
|
|
|
|
def _canonical_json(value: Mapping[str, object]) -> bytes:
|
|
"""将已校验 snapshot 转为禁止 NaN 的稳定 JSON 字节序列。"""
|
|
try:
|
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
except (TypeError, ValueError, UnicodeError) as exc:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot 不是可序列化 JSON") from exc
|
|
|
|
|
|
def _snapshot_digest(value: Mapping[str, object]) -> str:
|
|
"""计算不包含自身 hash 字段的 snapshot canonical SHA-256。"""
|
|
payload = copy.deepcopy(dict(value))
|
|
payload.pop("snapshot_sha256", None)
|
|
return hashlib.sha256(_canonical_json(payload)).hexdigest()
|
|
|
|
|
|
def _canonical_context(value: object) -> dict[str, object]:
|
|
"""保留可供研究参考的固定语境字段,并移除 URL 与内部扩展。"""
|
|
if not isinstance(value, Mapping):
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选 context 无效")
|
|
result: dict[str, object] = {}
|
|
for key in SAFE_CONTEXT_FIELDS:
|
|
if key not in value:
|
|
continue
|
|
raw = value[key]
|
|
if key in CONTEXT_LIST_FIELDS:
|
|
if not isinstance(raw, list):
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选 context 列表无效")
|
|
cleaned: list[str] = []
|
|
for item in raw:
|
|
if not isinstance(item, str) or any(ord(char) < 0x20 or ord(char) == 0x7F for char in item):
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选 context 文本无效")
|
|
if not CONTEXT_URL_RE.search(item):
|
|
cleaned.append(item)
|
|
result[key] = cleaned
|
|
continue
|
|
if raw is not None and not isinstance(raw, str):
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选 context 文本无效")
|
|
if isinstance(raw, str) and (any(ord(char) < 0x20 or ord(char) == 0x7F for char in raw) or CONTEXT_URL_RE.search(raw)):
|
|
continue
|
|
result[key] = raw
|
|
try:
|
|
copied = copy.deepcopy(result)
|
|
_canonical_json(copied)
|
|
except (TypeError, ValueError, UnicodeError) as exc:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选 context 不是可序列化 JSON") from exc
|
|
return copied
|
|
|
|
|
|
def _candidate(value: Mapping[str, object], *, rank: int) -> dict[str, object]:
|
|
"""校验并复制一个候选,屏蔽 storage key、URL 等非 snapshot 字段。"""
|
|
if not isinstance(value, Mapping):
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选结构无效")
|
|
candidate_id = _string(value.get("meme_id"), code="visual_match_snapshot_invalid", name="meme_id")
|
|
image_sha256 = _sha256(value.get("image_sha256"), code="visual_match_snapshot_invalid", name="image_sha256")
|
|
raw_size = value.get("size_bytes")
|
|
if not isinstance(raw_size, int) or isinstance(raw_size, bool) or raw_size < 0:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选 size_bytes 无效")
|
|
raw_score = value.get("score")
|
|
if not isinstance(raw_score, (int, float)) or isinstance(raw_score, bool) or not math.isfinite(float(raw_score)) or not -1.000001 <= float(raw_score) <= 1.000001:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选 score 无效")
|
|
copied_context = _canonical_context(value.get("context", {}))
|
|
return {
|
|
"rank": rank,
|
|
"meme_id": candidate_id,
|
|
"image_sha256": image_sha256,
|
|
"size_bytes": raw_size,
|
|
"score": float(raw_score),
|
|
"relative_path": _relative_path(value.get("relative_path")),
|
|
"context": copied_context,
|
|
}
|
|
|
|
|
|
def build_visual_match_snapshot(
|
|
*,
|
|
query_meme_id: object,
|
|
image_sha256: object,
|
|
model: object,
|
|
dimensions: object,
|
|
preprocess_version: object,
|
|
candidates: Sequence[Mapping[str, object]],
|
|
matched_at: datetime | str,
|
|
) -> dict[str, object]:
|
|
"""构造 protocol v2 snapshot,并返回带完整性 hash 的深拷贝。
|
|
|
|
输入是后端已经完成 scope、模型和存储校验的查询及候选;输出可直接写入 Task
|
|
JSONB。候选会按 score 降序、Meme ID 升序重排,重复 Meme ID 会被拒绝。
|
|
"""
|
|
if isinstance(dimensions, bool) or not isinstance(dimensions, int) or dimensions <= 0:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot dimensions 无效")
|
|
if isinstance(matched_at, datetime):
|
|
if matched_at.tzinfo is None:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot matched_at 缺少时区")
|
|
matched_value = matched_at.isoformat()
|
|
else:
|
|
matched_value = _string(matched_at, code="visual_match_snapshot_invalid", name="matched_at", maximum=64)
|
|
try:
|
|
datetime.fromisoformat(matched_value.replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot matched_at 无效") from exc
|
|
query = {
|
|
"meme_id": _string(query_meme_id, code="visual_match_snapshot_invalid", name="query meme_id"),
|
|
"image_sha256": _sha256(image_sha256, code="visual_match_snapshot_invalid", name="query image_sha256"),
|
|
"model": _string(model, code="visual_match_snapshot_invalid", name="model"),
|
|
"dimensions": dimensions,
|
|
"preprocess_version": _string(preprocess_version, code="visual_match_snapshot_invalid", name="preprocess_version", maximum=128),
|
|
}
|
|
prepared: list[dict[str, object]] = []
|
|
seen: set[str] = set()
|
|
seen_paths: set[str] = set()
|
|
if not isinstance(candidates, Sequence) or len(candidates) > MAX_VISUAL_MATCH_CANDIDATES:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot 候选数量超出限制")
|
|
for item in candidates:
|
|
candidate_id = item.get("meme_id") if isinstance(item, Mapping) else None
|
|
candidate_key = _string(candidate_id, code="visual_match_snapshot_invalid", name="candidate meme_id")
|
|
if candidate_key in seen:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot 候选重复")
|
|
seen.add(candidate_key)
|
|
candidate = _candidate(item, rank=0)
|
|
relative_path = str(candidate["relative_path"])
|
|
if relative_path in seen_paths:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot 候选路径重复")
|
|
seen_paths.add(relative_path)
|
|
prepared.append(candidate)
|
|
prepared.sort(key=lambda item: (-float(item["score"]), str(item["meme_id"])))
|
|
for index, item in enumerate(prepared, start=1):
|
|
item["rank"] = index
|
|
snapshot: dict[str, object] = {
|
|
"protocol_version": VISUAL_MATCH_SNAPSHOT_PROTOCOL_VERSION,
|
|
"query": query,
|
|
"matched_at": matched_value,
|
|
"candidates": prepared,
|
|
}
|
|
snapshot["snapshot_sha256"] = _snapshot_digest(snapshot)
|
|
return copy.deepcopy(snapshot)
|
|
|
|
|
|
def validate_visual_match_snapshot(value: object, *, expected_sha256: str | None = None) -> dict[str, object]:
|
|
"""完整校验持久 snapshot 并返回不可被调用方修改的副本。
|
|
|
|
输入来自 JSONB 或 resume attempt;输出保证 protocol、排序、候选字段和 hash
|
|
均符合当前契约。``expected_sha256`` 用于 claim 恢复时拒绝错配事实。
|
|
"""
|
|
if not isinstance(value, Mapping) or set(value) != SNAPSHOT_FIELDS or value.get("protocol_version") != VISUAL_MATCH_SNAPSHOT_PROTOCOL_VERSION:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot protocol 版本无效")
|
|
query = value.get("query")
|
|
if not isinstance(query, Mapping) or set(query) != QUERY_FIELDS:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot query 无效")
|
|
candidates = value.get("candidates")
|
|
if not isinstance(candidates, list):
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot candidates 无效")
|
|
for candidate in candidates:
|
|
if not isinstance(candidate, Mapping) or set(candidate) != CANDIDATE_FIELDS:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot 候选字段无效")
|
|
normalized_context = _canonical_context(candidate.get("context"))
|
|
if normalized_context != candidate.get("context"):
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot 候选 context 含非公开字段")
|
|
rebuilt = build_visual_match_snapshot(
|
|
query_meme_id=query.get("meme_id"),
|
|
image_sha256=query.get("image_sha256"),
|
|
model=query.get("model"),
|
|
dimensions=query.get("dimensions"),
|
|
preprocess_version=query.get("preprocess_version"),
|
|
candidates=candidates,
|
|
matched_at=value.get("matched_at"),
|
|
)
|
|
supplied = value.get("snapshot_sha256")
|
|
if not isinstance(supplied, str) or not SNAPSHOT_SHA256_RE.fullmatch(supplied) or supplied != rebuilt["snapshot_sha256"]:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot hash 无效")
|
|
if expected_sha256 is not None and supplied != expected_sha256:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "snapshot hash 与任务事实不一致")
|
|
return rebuilt
|
|
|
|
|
|
def visual_match_snapshot_summary(value: object, *, expected_sha256: str | None = None) -> dict[str, object]:
|
|
"""返回公开任务 DTO 可使用的脱敏 snapshot 摘要。"""
|
|
snapshot = validate_visual_match_snapshot(value, expected_sha256=expected_sha256)
|
|
return {
|
|
"protocol_version": snapshot["protocol_version"],
|
|
"snapshot_sha256": snapshot["snapshot_sha256"],
|
|
"matched_at": snapshot["matched_at"],
|
|
"candidate_count": len(snapshot["candidates"]),
|
|
}
|
|
|
|
|
|
def visual_match_snapshot_manifest(value: object, *, expected_sha256: str | None = None) -> dict[str, object]:
|
|
"""返回可写入候选目录的 manifest,保留语境但不带物理存储信息。"""
|
|
snapshot = validate_visual_match_snapshot(value, expected_sha256=expected_sha256)
|
|
return {
|
|
"protocol_version": snapshot["protocol_version"],
|
|
"snapshot_sha256": snapshot["snapshot_sha256"],
|
|
"matched_at": snapshot["matched_at"],
|
|
"candidate_count": len(snapshot["candidates"]),
|
|
"query": copy.deepcopy(snapshot["query"]),
|
|
"candidates": copy.deepcopy(snapshot["candidates"]),
|
|
}
|
|
|
|
|
|
def validate_visual_match_snapshot_manifest(value: object, *, expected_sha256: str | None = None) -> dict[str, object]:
|
|
"""校验候选目录 manifest,并返回与 snapshot 一致的脱敏副本。
|
|
|
|
输入来自宿主物化器写入的普通文件;除摘要字段外,manifest 必须完整保留
|
|
同一 protocol v2 snapshot 的查询和候选事实,供 Runner 在启动外部进程前复核。
|
|
"""
|
|
if not isinstance(value, Mapping) or set(value) != MANIFEST_FIELDS:
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选 manifest 字段无效")
|
|
candidates = value.get("candidates")
|
|
candidate_count = value.get("candidate_count")
|
|
if not isinstance(candidates, list) or not isinstance(candidate_count, int) or isinstance(candidate_count, bool) or candidate_count != len(candidates):
|
|
raise VisualMatchSnapshotError("visual_match_snapshot_invalid", "候选 manifest 数量无效")
|
|
snapshot = {
|
|
"protocol_version": value.get("protocol_version"),
|
|
"snapshot_sha256": value.get("snapshot_sha256"),
|
|
"matched_at": value.get("matched_at"),
|
|
"query": value.get("query"),
|
|
"candidates": candidates,
|
|
}
|
|
validated = validate_visual_match_snapshot(snapshot, expected_sha256=expected_sha256)
|
|
return visual_match_snapshot_manifest(validated, expected_sha256=expected_sha256)
|
|
|
|
|
|
__all__ = [
|
|
"VISUAL_MATCH_SNAPSHOT_PROTOCOL_VERSION",
|
|
"MAX_VISUAL_MATCH_CANDIDATES",
|
|
"VisualMatchSnapshotError",
|
|
"build_visual_match_snapshot",
|
|
"validate_visual_match_snapshot",
|
|
"visual_match_snapshot_summary",
|
|
"visual_match_snapshot_manifest",
|
|
"validate_visual_match_snapshot_manifest",
|
|
]
|