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