aps-agent/tests/golden/test_multimodal_image.py

336 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 矩阵 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"])