mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-18 21:52:59 +08:00
404 lines
16 KiB
Python
404 lines
16 KiB
Python
"""视觉模型适配器、薄客户端和 CLI 契约测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from backend.config import Settings
|
|
from backend.visual import (
|
|
VISUAL_DIMENSIONS,
|
|
VISUAL_MODEL_ID,
|
|
VISUAL_PREPROCESS_VERSION,
|
|
VisualEmbeddingError,
|
|
VisualInferenceClient,
|
|
VisualModelRunner,
|
|
VisualSearchService,
|
|
validate_embedding,
|
|
)
|
|
|
|
|
|
def test_visual_identity_and_unconfigured_health_are_deterministic(tmp_path: Path) -> None:
|
|
"""没有合法权重时服务只能返回脱敏的配置错误。"""
|
|
settings = Settings(_env_file=None, database_url="postgresql+psycopg://test:test@localhost/test", data_root=tmp_path / "data", image_root=tmp_path / "images")
|
|
health = VisualModelRunner(settings).health()
|
|
assert health == {
|
|
"status": "degraded",
|
|
"available": False,
|
|
"model": VISUAL_MODEL_ID,
|
|
"dimensions": VISUAL_DIMENSIONS,
|
|
"preprocess_version": VISUAL_PREPROCESS_VERSION,
|
|
"error": "visual_model_not_configured",
|
|
}
|
|
assert "weights" not in json.dumps(health, ensure_ascii=False)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("vector", "code"),
|
|
[([0.0] * VISUAL_DIMENSIONS, "visual_embedding_zero_norm"), ([float("nan")] + [0.0] * (VISUAL_DIMENSIONS - 1), "visual_embedding_non_finite"), ([1.0], "visual_embedding_dimensions_mismatch")],
|
|
)
|
|
def test_visual_vector_validation_reports_stable_codes(vector: list[float], code: str) -> None:
|
|
"""零范数、非 finite 和错误维度都不能落库。"""
|
|
with pytest.raises(VisualEmbeddingError, match=code):
|
|
validate_embedding(vector)
|
|
|
|
|
|
def test_visual_client_rejects_default_endpoint_without_model_configuration(tmp_path: Path) -> None:
|
|
"""默认本地端点没有权重配置时返回 model_not_configured,而非伪造成功。"""
|
|
settings = Settings(_env_file=None, database_url="postgresql+psycopg://test:test@localhost/test", data_root=tmp_path / "data", image_root=tmp_path / "images")
|
|
with pytest.raises(VisualEmbeddingError) as captured:
|
|
VisualInferenceClient(settings).embed(b"not-an-image")
|
|
assert captured.value.code == "visual_model_not_configured"
|
|
|
|
|
|
def test_visual_match_filters_weak_candidates_before_storage_check() -> None:
|
|
"""视觉候选低于最低相似度时不应提供,且文件校验仍在 Session 关闭后执行。"""
|
|
query_sha = "a" * 64
|
|
candidate_sha = "b" * 64
|
|
query = SimpleNamespace(
|
|
id="query",
|
|
sha256=query_sha,
|
|
storage_key=query_sha + ".png",
|
|
size_bytes=10,
|
|
meme_context={},
|
|
)
|
|
candidate = SimpleNamespace(
|
|
id="candidate",
|
|
sha256=candidate_sha,
|
|
storage_key=candidate_sha + ".png",
|
|
size_bytes=12,
|
|
meme_context={"title": "candidate"},
|
|
)
|
|
task = SimpleNamespace(
|
|
task_type="meme_context_generation",
|
|
status="running",
|
|
claim_generation=0,
|
|
payload={
|
|
"meme_id": "query",
|
|
"image_sha256": query_sha,
|
|
"visual_model": VISUAL_MODEL_ID,
|
|
"visual_dimensions": VISUAL_DIMENSIONS,
|
|
"preprocess_version": VISUAL_PREPROCESS_VERSION,
|
|
},
|
|
)
|
|
|
|
class _Resources:
|
|
"""记录数据库环境和文件检查的先后关系。"""
|
|
|
|
def __init__(self) -> None:
|
|
self.active_sessions = 0
|
|
self.events: list[str] = []
|
|
|
|
def environment(self, _scope):
|
|
resources = self
|
|
|
|
class _Environment:
|
|
def __enter__(self):
|
|
resources.active_sessions += 1
|
|
resources.events.append("environment_enter")
|
|
self.tasks = SimpleNamespace(get=lambda _task_id: task)
|
|
self.memes = SimpleNamespace(get=lambda meme_id: query if meme_id == "query" else candidate)
|
|
self.visual = SimpleNamespace(
|
|
get=lambda _meme_id, **_kwargs: SimpleNamespace(image_sha256=query_sha, embedding=[1.0])
|
|
if _meme_id == "query"
|
|
else None,
|
|
match=lambda *_args, **_kwargs: [(SimpleNamespace(), candidate, 0.5)],
|
|
)
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
resources.active_sessions -= 1
|
|
resources.events.append("environment_exit")
|
|
return False
|
|
|
|
return _Environment()
|
|
|
|
def blob_store_for_scope(self, _scope):
|
|
assert self.active_sessions == 0
|
|
self.events.append("blob_store")
|
|
resources = self
|
|
|
|
class _Blob:
|
|
def resolve(self, _key):
|
|
assert resources.active_sessions == 0
|
|
resources.events.append("resolve")
|
|
return Path("/tmp/image.png")
|
|
|
|
def exists_with_identity(self, _key, **_kwargs):
|
|
assert resources.active_sessions == 0
|
|
resources.events.append("file_check")
|
|
return True
|
|
|
|
return _Blob()
|
|
|
|
resources = _Resources()
|
|
settings = SimpleNamespace(
|
|
visual_model=VISUAL_MODEL_ID,
|
|
visual_model_dimensions=VISUAL_DIMENSIONS,
|
|
visual_preprocess_version=VISUAL_PREPROCESS_VERSION,
|
|
)
|
|
result = VisualSearchService(settings, resources).match(task_id="task-1", require_storage=True)
|
|
|
|
assert result["results"] == []
|
|
assert resources.active_sessions == 0
|
|
assert resources.events.index("environment_exit") < resources.events.index("blob_store")
|
|
assert "file_check" not in resources.events
|
|
|
|
|
|
def test_visual_client_health_rejects_mismatched_model_identity(tmp_path: Path) -> None:
|
|
"""视觉服务即使自报 available,也不能以错误模型身份被 API 采用。"""
|
|
settings = Settings(
|
|
_env_file=None,
|
|
database_url="postgresql+psycopg://test:test@localhost/test",
|
|
data_root=tmp_path / "data",
|
|
image_root=tmp_path / "images",
|
|
visual_health_url="http://visual:8276/health",
|
|
)
|
|
|
|
class Response:
|
|
"""提供固定健康 JSON 的最小 HTTP 响应夹具。"""
|
|
|
|
status = 200
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
return False
|
|
|
|
def read(self, _size: int = -1) -> bytes:
|
|
return json.dumps(
|
|
{
|
|
"status": "ok",
|
|
"available": True,
|
|
"model": "wrong-model",
|
|
"dimensions": VISUAL_DIMENSIONS,
|
|
"preprocess_version": VISUAL_PREPROCESS_VERSION,
|
|
}
|
|
).encode("utf-8")
|
|
|
|
health = VisualInferenceClient(settings, opener=lambda *_args, **_kwargs: Response()).health()
|
|
assert health["available"] is False
|
|
assert health["error"] == "visual_model_identity_mismatch"
|
|
|
|
|
|
def test_visual_runner_requires_fixed_official_source_for_file_weights(tmp_path: Path) -> None:
|
|
"""仅提供 checkpoint 而没有固定 DINOv2 源码时不得尝试 TorchScript 或随机模型。"""
|
|
weights = tmp_path / "model.pth"
|
|
weights.write_bytes(b"checkpoint")
|
|
settings = Settings(
|
|
_env_file=None,
|
|
database_url="postgresql+psycopg://test:test@localhost/test",
|
|
data_root=tmp_path / "data",
|
|
image_root=tmp_path / "images",
|
|
visual_weights_path=weights,
|
|
)
|
|
health = VisualModelRunner(settings).health()
|
|
assert health["error"] == "visual_model_source_not_configured"
|
|
assert health["available"] is False
|
|
|
|
|
|
def test_visual_source_marker_is_required_for_fixed_revision(tmp_path: Path) -> None:
|
|
"""源码目录缺少固定提交标记时必须拒绝加载,防止架构版本漂移。"""
|
|
source = tmp_path / "dinov2"
|
|
for relative in ("dinov2/__init__.py", "dinov2/hub/backbones.py", "dinov2/models/vision_transformer.py"):
|
|
target = source / relative
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text("# test source\n", encoding="utf-8")
|
|
assert not VisualModelRunner._source_repository_valid(source, VISUAL_MODEL_ID)
|
|
(source / ".mememeow-dinov2-source-commit").write_text("7764ea0f912e53c92e82eb78a2a1631e92725fc8\n", encoding="ascii")
|
|
assert VisualModelRunner._source_repository_valid(source, VISUAL_MODEL_ID)
|
|
|
|
|
|
def test_visual_checkpoint_loader_rejects_non_state_dict_without_unsafe_fallback(tmp_path: Path) -> None:
|
|
"""checkpoint 不是 state dict 时返回稳定格式错误,不能退回不安全的 pickle 加载。"""
|
|
class FakeTorch:
|
|
"""只实现 checkpoint 读取接口的安全加载夹具。"""
|
|
|
|
@staticmethod
|
|
def load(_path: str, *, map_location: str, weights_only: bool) -> object:
|
|
"""返回非法顶层对象并记录调用约束。"""
|
|
assert map_location == "cpu"
|
|
assert weights_only is True
|
|
return ["not-a-state-dict"]
|
|
|
|
with pytest.raises(VisualEmbeddingError) as captured:
|
|
VisualModelRunner._load_checkpoint(FakeTorch(), tmp_path / "model.pth")
|
|
assert captured.value.code == "visual_checkpoint_format_invalid"
|
|
|
|
|
|
def test_visual_official_loader_uses_strict_checkpoint_and_fp32(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""官方加载路径固定使用 pretrained=False、strict state dict 和 FP32。"""
|
|
source = tmp_path / "dinov2"
|
|
for relative in ("dinov2/__init__.py", "dinov2/hub/backbones.py", "dinov2/models/vision_transformer.py"):
|
|
target = source / relative
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text("# test source\n", encoding="utf-8")
|
|
(source / ".mememeow-dinov2-source-commit").write_text("7764ea0f912e53c92e82eb78a2a1631e92725fc8\n", encoding="ascii")
|
|
weights = tmp_path / "model.pth"
|
|
weights.write_bytes(b"checkpoint")
|
|
|
|
calls: dict[str, object] = {}
|
|
|
|
class FakeModel:
|
|
"""模拟官方模型最小接口,保留加载器关键契约。"""
|
|
|
|
embed_dim = VISUAL_DIMENSIONS
|
|
|
|
def load_state_dict(self, state_dict: object, *, strict: bool) -> None:
|
|
"""记录严格加载参数。"""
|
|
calls["state_dict"] = state_dict
|
|
calls["strict"] = strict
|
|
|
|
def float(self) -> "FakeModel":
|
|
"""记录 FP32 转换。"""
|
|
calls["float"] = True
|
|
return self
|
|
|
|
def builder(*, pretrained: bool) -> FakeModel:
|
|
"""模拟官方 DINOv2 ViT-B/14 构造函数。"""
|
|
calls["pretrained"] = pretrained
|
|
return FakeModel()
|
|
|
|
fake_dinov2 = types.ModuleType("dinov2")
|
|
fake_dinov2.__path__ = [str(source / "dinov2")] # type: ignore[attr-defined]
|
|
fake_hub = types.ModuleType("dinov2.hub")
|
|
fake_hub.__path__ = [str(source / "dinov2" / "hub")] # type: ignore[attr-defined]
|
|
fake_backbones = types.ModuleType("dinov2.hub.backbones")
|
|
fake_backbones.dinov2_vitb14 = builder # type: ignore[attr-defined]
|
|
monkeypatch.setitem(sys.modules, "dinov2", fake_dinov2)
|
|
monkeypatch.setitem(sys.modules, "dinov2.hub", fake_hub)
|
|
monkeypatch.setitem(sys.modules, "dinov2.hub.backbones", fake_backbones)
|
|
|
|
class FakeTorch:
|
|
"""只模拟安全 state dict 加载。"""
|
|
|
|
@staticmethod
|
|
def load(_path: str, *, map_location: str, weights_only: bool) -> object:
|
|
"""返回可被严格加载的字典。"""
|
|
assert map_location == "cpu"
|
|
assert weights_only is True
|
|
return {"weight": 1}
|
|
|
|
settings = types.SimpleNamespace(
|
|
visual_model=VISUAL_MODEL_ID,
|
|
visual_model_dimensions=VISUAL_DIMENSIONS,
|
|
visual_preprocess_version=VISUAL_PREPROCESS_VERSION,
|
|
visual_model_repo=source,
|
|
visual_weights_path=weights,
|
|
visual_weights_sha256=None,
|
|
)
|
|
runner = VisualModelRunner(settings)
|
|
model = runner._load_official_model(FakeTorch(), weights)
|
|
assert isinstance(model, FakeModel)
|
|
assert calls == {"pretrained": False, "state_dict": {"weight": 1}, "strict": True, "float": True}
|
|
|
|
|
|
def test_local_visual_cli_reports_missing_runtime_environment() -> None:
|
|
"""CLI 缺少 task id 时只写稳定 JSON 错误到 stderr。"""
|
|
script = Path("skills/research-meme-context/scripts/local_visual_match.py")
|
|
environment = dict(os.environ)
|
|
environment["MEMEMEOW_AGENT_TASK_ID"] = "task-123"
|
|
environment.pop("MEMEMEOW_DATA_ROOT", None)
|
|
environment.pop("MEMEMEOW_VISUAL_SEARCH_INTERNAL_URL", None)
|
|
environment.pop("MEMEMEOW_VISUAL_MATCH_INTERNAL_URL", None)
|
|
result = subprocess.run([sys.executable, str(script), "--top-k", "2"], capture_output=True, text=True, env=environment, check=False)
|
|
assert result.returncode != 0
|
|
assert json.loads(result.stderr)["error"] == "candidate_manifest_arguments_invalid"
|
|
|
|
|
|
def test_local_visual_cli_rejects_removed_include_self_argument() -> None:
|
|
"""候选清单脚本不再兼容会改变候选范围的旧参数。"""
|
|
script = Path("skills/research-meme-context/scripts/local_visual_match.py")
|
|
environment = dict(os.environ)
|
|
environment["MEMEMEOW_AGENT_TASK_ID"] = "task-123"
|
|
environment.pop("MEMEMEOW_DATA_ROOT", None)
|
|
environment.pop("MEMEMEOW_AGENT_CANDIDATE_MANIFEST", None)
|
|
result = subprocess.run([sys.executable, str(script), "--include-self"], capture_output=True, text=True, env=environment, check=False)
|
|
assert result.returncode != 0
|
|
assert json.loads(result.stderr)["error"] == "candidate_manifest_arguments_invalid"
|
|
|
|
|
|
def test_local_visual_cli_reports_missing_manifest() -> None:
|
|
"""CLI 缺少固定 manifest 时返回稳定错误,不再尝试 callback。"""
|
|
environment = dict(os.environ)
|
|
environment["MEMEMEOW_AGENT_TASK_ID"] = "task-123"
|
|
environment.pop("MEMEMEOW_DATA_ROOT", None)
|
|
environment.pop("MEMEMEOW_AGENT_CANDIDATE_MANIFEST", None)
|
|
environment.pop("MEMEMEOW_VISUAL_SEARCH_INTERNAL_URL", None)
|
|
environment.pop("MEMEMEOW_AGENT_CALLBACK_TOKEN", None)
|
|
result = subprocess.run(
|
|
[sys.executable, "skills/research-meme-context/scripts/local_visual_match.py"],
|
|
capture_output=True,
|
|
text=True,
|
|
env=environment,
|
|
check=False,
|
|
)
|
|
assert result.returncode != 0
|
|
assert json.loads(result.stderr)["error"] == "candidate_manifest_missing"
|
|
|
|
|
|
def test_local_visual_cli_rejects_manifest_hash_mismatch(tmp_path: Path) -> None:
|
|
"""CLI 发现 manifest hash 不一致时拒绝返回候选事实。"""
|
|
candidate_root = tmp_path / "candidates" / "task-123"
|
|
candidate_root.mkdir(parents=True)
|
|
(candidate_root / "manifest.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"protocol_version": 2,
|
|
"snapshot_sha256": "f" * 64,
|
|
"matched_at": "2026-08-31T00:00:00+00:00",
|
|
"candidate_count": 0,
|
|
"query": {
|
|
"meme_id": "query",
|
|
"image_sha256": "a" * 64,
|
|
"model": "dinov2_vitb14",
|
|
"dimensions": 768,
|
|
"preprocess_version": "dinov2-v1-gif-first-frame",
|
|
},
|
|
"candidates": [],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
environment = dict(os.environ)
|
|
environment["MEMEMEOW_AGENT_TASK_ID"] = "task-123"
|
|
environment["MEMEMEOW_DATA_ROOT"] = str(tmp_path)
|
|
environment["MEMEMEOW_AGENT_CANDIDATE_MANIFEST"] = str(candidate_root / "manifest.json")
|
|
result = subprocess.run(
|
|
[sys.executable, "skills/research-meme-context/scripts/local_visual_match.py"],
|
|
capture_output=True,
|
|
text=True,
|
|
env=environment,
|
|
check=False,
|
|
)
|
|
assert result.returncode != 0
|
|
assert json.loads(result.stderr)["error"] == "candidate_manifest_invalid"
|
|
|
|
|
|
def test_local_visual_cli_rejects_manifest_outside_runtime_root(tmp_path: Path) -> None:
|
|
"""CLI 不接受可信运行目录之外的候选清单路径。"""
|
|
outside = tmp_path / "candidates" / "task-123" / "manifest.json"
|
|
outside.parent.mkdir(parents=True)
|
|
outside.write_text("{}", encoding="utf-8")
|
|
environment = dict(os.environ)
|
|
environment["MEMEMEOW_AGENT_TASK_ID"] = "task-123"
|
|
environment["MEMEMEOW_DATA_ROOT"] = str(tmp_path / "runtime")
|
|
environment["MEMEMEOW_AGENT_CANDIDATE_MANIFEST"] = str(outside)
|
|
result = subprocess.run(
|
|
[sys.executable, "skills/research-meme-context/scripts/local_visual_match.py"],
|
|
capture_output=True,
|
|
text=True,
|
|
env=environment,
|
|
check=False,
|
|
)
|
|
assert result.returncode != 0
|
|
assert json.loads(result.stderr)["error"] == "candidate_manifest_path_invalid"
|