336 lines
14 KiB
Python
336 lines
14 KiB
Python
# ============================================================
|
||
# 矩阵 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"])
|