aps-agent/tests/golden/test_multimodal_image.py

336 lines
14 KiB
Python
Raw Normal View History

# ============================================================
# 矩阵 101(方向 JJ):图片/附件元数据提取器黄金测试
# 覆盖:文件名模式提取(订单/物料/日期线索)、元数据候选(路径/大小/类型)、
# 低置信走既有门禁出 P2 卡不写世界、无 OCR 不编造(空候选/数字守卫)、
# VLM 注册覆盖后行为切换、网关接线(/api/multimodal/extract + ingest)。
# ============================================================
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from server.agent_core import harness
from server.aps_domain.multimodal import (
IMAGE_META_MAX_CONFIDENCE,
ExtractionCandidate,
ImageMetaExtractor,
candidates_to_batches,
default_registry,
ingest_candidates,
)
from server.aps_domain.workflow import execute_confirmed
from server.auth.context import IdentityContext, bind_identity, reset_identity
from server.state.seed import seed_world
_PLANNER = IdentityContext(1001, "planner", "计划员", "tenant-multimodal-image",
roles=("planner",))
_THRESHOLD = 0.7 # 默认确认阈值(stub image_meta 封顶 0.6 < 0.7)
def _next_id(world):
counters: dict[str, int] = {}
def next_id(kind: str) -> int:
table = "auditEvents" if kind == "audit" else kind + "s"
if kind not in counters:
rows = world.get(table, [])
counters[kind] = max((r.get("id", 0) for r in rows if isinstance(r, dict)), default=0)
counters[kind] += 1
return counters[kind]
return next_id
class _WorldStore:
def __init__(self, world, next_id_fn, *, tenant_uuid="tenant-multimodal-image",
world_key="personal-1001"):
self.data = world
self._next_id_fn = next_id_fn
self.tenant_uuid = tenant_uuid
self.world_key = world_key
def next_id(self, kind: str) -> int:
return self._next_id_fn(kind)
def save(self) -> None:
pass
def _domain_row_count(world) -> int:
return (len(world.get("flexOrders") or []) + len(world.get("salesOrders") or [])
+ len(world.get("flexMaterials") or []) + len(world.get("materials") or []))
# ---------------- 文件名模式提取 ----------------
def test_image_meta_order_filename_pattern_extracts_order_no():
cands = ImageMetaExtractor().extract(
{"filename": "订单-102285668.xlsx", "size": 4096})
c = cands[0]
assert c.kind == "image_meta" and c.source == "STUB_IMAGE_META"
assert c.complete is True
assert c.value["orderNo"] == "102285668"
assert c.value["attachmentKind"] == "order"
assert c.value["requiresVlm"] is True
assert c.fields["requiresVlm"] is True
assert c.value["mime"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert c.value["size"] == 4096
# 0.2 基础 + 单号 0.1 + 附件类型 0.1 + 大小 0.1 = 0.5(封顶 0.6)
assert c.confidence == pytest.approx(0.5)
assert c.confidence <= IMAGE_META_MAX_CONFIDENCE
def test_image_meta_material_bom_and_month_date():
cands = ImageMetaExtractor().extract(
{"filename": "物料BOM-2026-08.xlsx", "size": 8192})
c = cands[0]
assert c.value["materialCode"] == "BOM-2026-08"
assert c.value["date"] == "2026-08"
assert c.value["attachmentKind"] == "material"
assert c.value["requiresVlm"] is True
# 0.2 + 物料 0.1 + 日期 0.1 + 类型 0.1 + 大小 0.1 = 0.6(封顶)
assert c.confidence == pytest.approx(IMAGE_META_MAX_CONFIDENCE)
def test_image_meta_compact_date_and_order_digit_guard():
e = ImageMetaExtractor()
img = e.extract({"filename": "IMG_20260802.png", "size": 12345})[0]
assert img.value["date"] == "2026-08-02"
assert img.value["attachmentKind"] == "image"
assert img.value["orderNo"] == ""
# 8 位数字紧邻订单前缀:只认单号,不重复识别为日期(不编造)
so = e.extract({"filename": "SO-20260801.csv"})[0]
assert so.value["orderNo"] == "20260801"
assert so.value["date"] == ""
# 9 位订单数字:紧凑日期必须整段 8 位,不得从订单号中截取
long = e.extract({"filename": "订单-102285668.xlsx"})[0]
assert long.value["orderNo"] == "102285668"
assert long.value["date"] == ""
# ---------------- 元数据候选 ----------------
def test_image_meta_metadata_candidate_from_local_path(tmp_path):
p = tmp_path / "IMG_20260802.png"
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
cands = ImageMetaExtractor().extract({"path": str(p)})
assert len(cands) == 1
c = cands[0]
assert c.value["filename"] == "IMG_20260802.png"
assert c.value["path"] == str(p)
assert c.value["size"] == p.stat().st_size
assert c.value["mime"] == "image/png"
assert c.value["date"] == "2026-08-02"
assert c.value["requiresVlm"] is True
assert c.fields["size"] == p.stat().st_size
# 0.2 + 日期 0.1 + 类型 0.1 + 真实大小 0.1 = 0.5
assert c.confidence == pytest.approx(0.5)
# ---------------- 无 OCR 不编造 ----------------
def test_image_meta_no_clues_returns_empty_no_fabrication():
e = ImageMetaExtractor()
assert e.extract({"filename": "random_photo.png"}) == []
assert e.extract({"filename": "资料归档.txt"}) == []
assert e.extract("IMG_unknown.png") == []
with pytest.raises(ValueError):
e.extract({})
with pytest.raises(ValueError):
e.extract(12345)
# ---------------- 既有门禁接入:低置信 → P2 确认卡 ----------------
def test_image_meta_candidates_to_batches_attachments():
cands = ImageMetaExtractor().extract(
{"filename": "订单-102285668.xlsx", "size": 4096})
batches = candidates_to_batches(cands)
assert len(batches) == 1
b = batches[0]
assert b["kind"] == "attachments" and b["sheet"] == "image-meta"
row = b["okRows"][0]
assert row["filename"] == "订单-102285668.xlsx"
assert row["orderNo"] == "102285668"
assert row["requiresVlm"] is True
def test_image_meta_low_confidence_stages_p2_card_without_world_write():
world = seed_world()
next_id = _next_id(world)
token = bind_identity(_PLANNER)
try:
cands = ImageMetaExtractor().extract(
{"filename": "订单-102285668.xlsx", "size": 4096})
assert cands[0].confidence < _THRESHOLD # stub 封顶 0.6,必然低置信
before = _domain_row_count(world)
result = ingest_candidates(world, next_id, cands, session_id="mm-img",
threshold=_THRESHOLD)
finally:
reset_identity(token)
assert result["status"] == "staged"
confirm_id = result["confirmId"]
assert result["block"]["type"] == "confirm-card"
assert "低置信" in result["block"]["props"]["title"]
# 确认前绝不写世界状态(域表 + 直通审计均无变化)
assert _domain_row_count(world) == before
assert not any(e["action"] == "multimodal.apply" for e in world["auditEvents"])
stages = [e for e in world["auditEvents"] if e["action"] == "multimodal.stage"]
assert len(stages) == 1
assert stages[0]["rationale"]["confirmId"] == confirm_id
assert stages[0]["rationale"]["action"] == "import.commit"
assert stages[0]["result"] == "PENDING"
# stage 审计携带候选明细(确认卡冻结参数存于审批记录,UI 块不含 params)
assert stages[0]["rationale"]["candidates"][0]["kind"] == "image_meta"
assert stages[0]["rationale"]["candidates"][0]["source"] == "STUB_IMAGE_META"
def test_image_meta_confirm_records_audit_without_fabricating_domain_rows():
world = seed_world()
next_id = _next_id(world)
token = bind_identity(_PLANNER)
try:
cands = ImageMetaExtractor().extract(
{"filename": "订单-102285668.xlsx", "size": 4096})
staged = ingest_candidates(world, next_id, cands, session_id="mm",
threshold=_THRESHOLD)
cid = staged["confirmId"]
store = _WorldStore(world, next_id)
before = _domain_row_count(world)
message = execute_confirmed(store, cid, True, actor="planner")
# attachments 批次本轮无域表写入分支(真实 VLM 入库接线为外部扩展):
# 确认只落审计与快照,绝不落虚假业务行——元数据保留在确认卡/审计中。
assert "文件导入完成" in message
assert "共 0 条" in message
assert _domain_row_count(world) == before
assert any(e["action"] == "import.commit" for e in world["auditEvents"])
commit = next(e for e in world["auditEvents"] if e["action"] == "import.commit")
assert commit["beforeSnapshot"] is not None
# 驳回:不写世界 + 留痕
cands2 = ImageMetaExtractor().extract({"filename": "IMG_20260802.png"})
staged2 = ingest_candidates(world, next_id, cands2, session_id="mm",
threshold=_THRESHOLD)
message2 = execute_confirmed(store, staged2["confirmId"], False, actor="planner")
assert "已驳回" in message2
assert _domain_row_count(world) == before
assert any(e["action"] == "import.commit.reject" for e in world["auditEvents"])
finally:
reset_identity(token)
# ---------------- VLM 注册覆盖:行为切换 ----------------
def test_image_meta_vlm_registration_overrides_stub():
registry = default_registry()
assert "image_meta" in registry.list_kinds()
stub = registry.extract("image_meta", {"filename": "订单-102285668.xlsx"})
assert stub[0].source == "STUB_IMAGE_META"
assert stub[0].confidence <= IMAGE_META_MAX_CONFIDENCE
assert stub[0].value["requiresVlm"] is True
class FakeVlmExtractor:
"""真实 VLM 外部模型服务的替身:实现 Extractor 协议,kind 与 stub 同名。"""
kind = "image_meta"
source = "VLM_EXT"
def extract(self, raw):
name = str((raw or {}).get("filename") or "")
return [ExtractionCandidate(
kind="image_meta",
value={"filename": name, "ocrText": "真实 OCR 输出(外部 VLM)",
"orderNo": "VLM-001", "requiresVlm": False},
confidence=0.92, source="VLM_EXT",
fields={"ocr": {"engine": "fake-vlm"}}, complete=True)]
with pytest.raises(ValueError): # 重复注册默认拒绝
registry.register(FakeVlmExtractor())
registry.register(FakeVlmExtractor(), replace=True) # 覆盖 stub → 真实提取器
real = registry.extract("image_meta", {"filename": "IMG_1.png"})
assert len(real) == 1
assert real[0].source == "VLM_EXT"
assert real[0].confidence == pytest.approx(0.92)
assert real[0].value["requiresVlm"] is False
assert real[0].value["ocrText"].startswith("真实 OCR")
# ---------------- 网关接线:/api/multimodal/* ----------------
class _AuditStore:
def __init__(self, tenant_uuid: str = "tenant-multimodal-image"):
self.tenant_uuid = tenant_uuid
self.world_key = "personal-1001"
self.data: dict = seed_world()
self._counter = 0
def next_id(self, _kind: str) -> int:
self._counter += 1
return self._counter
def save(self) -> None:
pass
class _CheckpointStore:
def create(self, *_args, **_kwargs) -> dict:
return {"pairId": "mm-img-pair"}
def get(self, pair_id: str) -> dict | None:
return {"pairId": pair_id}
class _ProjectStore:
def active_world_key(self) -> str:
return "default"
def require_active_write(self) -> None:
pass
@pytest.fixture
def client_factory(monkeypatch):
import server.gateway.app as gateway_module
import server.state.projects as projects_module
from tests.auth_provider import install_test_auth
install_test_auth(monkeypatch, "tenant-multimodal-image")
def factory():
store = _AuditStore()
monkeypatch.setattr(gateway_module, "get_store", lambda: store)
monkeypatch.setattr(gateway_module, "get_checkpoints", lambda: _CheckpointStore())
monkeypatch.setattr(projects_module, "get_project_store", lambda: _ProjectStore())
client = TestClient(gateway_module.create_app())
login = client.post("/api/auth/login", json={"username": "planner", "password": "test"})
assert login.status_code == 200, login.text
return client, store
return factory
def test_image_meta_api_extract_and_ingest_staged(client_factory):
client, store = client_factory()
resp = client.post("/api/multimodal/extract", json={
"kind": "image_meta", "filename": "订单-102285668.xlsx"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["kind"] == "image_meta"
assert "image_meta" in body["registeredKinds"]
cands = body["candidates"]
assert len(cands) == 1
assert cands[0]["kind"] == "image_meta"
assert cands[0]["source"] == "STUB_IMAGE_META"
assert cands[0]["confidence"] <= IMAGE_META_MAX_CONFIDENCE
assert cands[0]["value"]["orderNo"] == "102285668"
assert cands[0]["value"]["requiresVlm"] is True
before = len(store.data["flexOrders"]) + len(store.data["salesOrders"])
ingest = client.post("/api/multimodal/ingest", json={
"candidates": cands, "threshold": 0.7})
assert ingest.status_code == 200, ingest.text
staged = ingest.json()
assert staged["status"] == "staged"
assert len(store.data["flexOrders"]) + len(store.data["salesOrders"]) == before
assert any(e["action"] == "multimodal.stage" for e in store.data["auditEvents"])
assert not any(e["action"] == "multimodal.apply" for e in store.data["auditEvents"])