481 lines
21 KiB
Python
481 lines
21 KiB
Python
# ============================================================
|
||
# 矩阵 101(方向 Y):多模态输入与低置信确认框架黄金测试
|
||
# 覆盖:stub 提取候选+置信度、低置信出卡不写世界、高置信直通、
|
||
# 确认后写世界、拒绝不写、未注册提取器显式报错、审计齐全、
|
||
# 阈值可配、扩展提取器注册接口。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from server.agent_core import harness
|
||
from server.aps_domain.multimodal import (
|
||
DEFAULT_CONFIDENCE_THRESHOLD,
|
||
ExtractionCandidate,
|
||
ExtractorNotRegisteredError,
|
||
ExtractorRegistry,
|
||
FileImportExtractor,
|
||
TextOrderExtractor,
|
||
apply_multimodal_candidates,
|
||
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
|
||
from server.timeutil import today0
|
||
|
||
_PLANNER = IdentityContext(1001, "planner", "计划员", "tenant-multimodal", roles=("planner",))
|
||
_THRESHOLD = DEFAULT_CONFIDENCE_THRESHOLD
|
||
|
||
|
||
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", 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 _demo_product(world) -> dict:
|
||
return next(m for m in world["materials"] if m.get("type") == "FINISHED_PRODUCT")
|
||
|
||
|
||
def _orders_csv(product_code: str, ok: int = 1, bad: int = 0) -> bytes:
|
||
head = "订单号,客户,产品编码,数量,交期,优先级\n"
|
||
rows = [f"SO-OK-{i},导入客户,{product_code},{10 + i},2026-08-01,3" for i in range(1, ok + 1)]
|
||
rows += [f"SO-BAD-{i},客户X,UNKNOWN-{i},5,2026-08-01,3" for i in range(1, bad + 1)]
|
||
return (head + "\n".join(rows) + "\n").encode("utf-8")
|
||
|
||
|
||
# ---------------- stub 提取器:结构化候选 + 置信度 ----------------
|
||
|
||
def test_text_order_stub_sparse_template_is_low_confidence():
|
||
cands = TextOrderExtractor().extract("订单 单号 SO-101 数量 5")
|
||
assert len(cands) == 1
|
||
c = cands[0]
|
||
assert c.kind == "text_order" and c.source == "STUB_TEXT"
|
||
assert c.complete is True # 必填(单号+数量)齐
|
||
assert c.confidence == pytest.approx(0.6) # 缺交期/客户/产品 → 低于默认 0.7
|
||
assert "deliveryDate" in c.fields["missing"]
|
||
assert c.value["orderNo"] == "SO-101"
|
||
assert c.value["quantity"] == 5
|
||
assert c.to_dict()["confidence"] == pytest.approx(0.6)
|
||
|
||
|
||
def test_text_order_stub_full_template_is_high_confidence():
|
||
cands = TextOrderExtractor().extract(
|
||
"订单 单号 SO-102 数量 8 交期 2026-08-20 客户 华东公司 产品编码 CTRL-A")
|
||
c = cands[0]
|
||
assert c.complete is True
|
||
assert c.confidence == pytest.approx(1.0)
|
||
assert c.fields["missing"] == []
|
||
assert c.value["deliveryDate"] == "2026-08-20"
|
||
|
||
|
||
def test_text_order_stub_no_match_and_incomplete():
|
||
extractor = TextOrderExtractor()
|
||
assert extractor.extract("今天天气不错") == [] # 无模板命中:不编造
|
||
partial = extractor.extract("订单 数量 5") # 缺单号 → 不完整
|
||
assert len(partial) == 1
|
||
assert partial[0].complete is False
|
||
assert partial[0].confidence == pytest.approx(0.3)
|
||
|
||
|
||
def test_file_import_stub_reuses_importers_preview():
|
||
world = seed_world()
|
||
product = _demo_product(world)
|
||
cands = FileImportExtractor(world=world).extract(
|
||
{"filename": "orders.csv", "data": _orders_csv(product["code"])})
|
||
c = cands[0]
|
||
assert c.kind == "file_import" and c.source == "STUB_FILE"
|
||
assert c.confidence == pytest.approx(1.0)
|
||
assert c.complete is True
|
||
assert c.value["canCommit"] is True
|
||
assert c.value["batches"][0]["kind"] == "orders"
|
||
assert c.fields["batches"]["orders"]["okCount"] == 1
|
||
|
||
|
||
def test_file_import_stub_low_confidence_on_error_rows():
|
||
world = seed_world()
|
||
product = _demo_product(world)
|
||
cands = FileImportExtractor(world=world).extract(
|
||
{"filename": "orders.csv", "data": _orders_csv(product["code"], ok=2, bad=5)})
|
||
c = cands[0]
|
||
assert c.complete is True # 仍有有效行可入库
|
||
assert 0 < c.confidence < _THRESHOLD # 2/7 ≈ 0.286 → 低置信
|
||
assert c.value["totalOk"] == 2
|
||
assert c.value["totalErrors"] == 5
|
||
|
||
|
||
# ---------------- 注册表:未注册显式报错 + 扩展注册接口 ----------------
|
||
|
||
def test_registry_unregistered_kind_raises_explicit_error():
|
||
registry = default_registry(world=seed_world())
|
||
with pytest.raises(ExtractorNotRegisteredError) as excinfo:
|
||
registry.extract("asr", {"text": "语音识别内容"})
|
||
assert "未注册" in str(excinfo.value) and "asr" in str(excinfo.value)
|
||
assert "file_import" in str(excinfo.value) # 提示已注册 kinds
|
||
with pytest.raises(ExtractorNotRegisteredError):
|
||
registry.get("vlm")
|
||
assert registry.get("file_import").kind == "file_import"
|
||
assert registry.get("text_order").kind == "text_order"
|
||
assert set(registry.list_kinds()) == {"file_import", "image_meta", "text_order", "dxf_drawing"}
|
||
|
||
|
||
def test_registry_accepts_extended_asr_vlm_extractor():
|
||
class FakeAsrExtractor:
|
||
kind = "asr"
|
||
source = "ASR_EXT"
|
||
|
||
def extract(self, raw):
|
||
return [ExtractionCandidate(
|
||
kind="asr", value={"text": str((raw or {}).get("text") or "")},
|
||
confidence=0.9, source="ASR_EXT", fields={"text": {"raw": raw}}, complete=True)]
|
||
|
||
registry = ExtractorRegistry()
|
||
registry.register(FakeAsrExtractor())
|
||
cands = registry.extract("asr", {"text": "语音内容"})
|
||
assert len(cands) == 1 and cands[0].kind == "asr"
|
||
assert cands[0].confidence == pytest.approx(0.9)
|
||
with pytest.raises(ValueError): # 重复注册默认拒绝
|
||
registry.register(FakeAsrExtractor())
|
||
registry.register(FakeAsrExtractor(), replace=True) # 显式覆盖允许(接真实服务入口)
|
||
|
||
|
||
# ---------------- 归一化与 fail-closed 写入 ----------------
|
||
|
||
def test_candidates_to_batches_text_order_normalizes():
|
||
cands = TextOrderExtractor().extract("订单 单号 SO-103 数量 12")
|
||
batches = candidates_to_batches(cands)
|
||
assert batches == [{
|
||
"kind": "orders", "sheet": "multimodal-text",
|
||
"okRows": [{"orderNo": "SO-103", "quantity": 12,
|
||
"deliveryDate": today0().strftime("%Y-%m-%d"),
|
||
"customerName": ""}],
|
||
}]
|
||
|
||
|
||
def test_apply_fail_closed_on_incomplete_and_bad_quantity():
|
||
world = seed_world()
|
||
next_id = _next_id(world)
|
||
incomplete = ExtractionCandidate(
|
||
kind="text_order", value={"orderNo": "SO-X", "quantity": ""},
|
||
confidence=0.3, source="STUB_TEXT", complete=False,
|
||
fields={"missing": ["quantity"]})
|
||
before = len(world["salesOrders"]) + len(world["flexOrders"])
|
||
with pytest.raises(ValueError):
|
||
apply_multimodal_candidates(world, next_id, [incomplete])
|
||
bad_qty = ExtractionCandidate(
|
||
kind="text_order", value={"orderNo": "SO-Y", "quantity": "abc"},
|
||
confidence=0.6, source="STUB_TEXT", complete=True)
|
||
with pytest.raises(ValueError):
|
||
apply_multimodal_candidates(world, next_id, [bad_qty])
|
||
assert len(world["salesOrders"]) + len(world["flexOrders"]) == before # 无部分写入
|
||
assert not world.get("auditEvents")
|
||
|
||
|
||
# ---------------- 门禁:高置信直通 / 低置信出卡不写世界 ----------------
|
||
|
||
def test_ingest_high_confidence_direct_apply_no_card():
|
||
world = seed_world()
|
||
next_id = _next_id(world)
|
||
cands = TextOrderExtractor().extract(
|
||
"订单 单号 SO-DIR-1 数量 5 交期 2026-08-20 客户 华东公司 产品编码 CTRL-A")
|
||
result = ingest_candidates(world, next_id, cands, threshold=_THRESHOLD)
|
||
assert result["status"] == "applied"
|
||
assert any(o["orderNo"] == "SO-DIR-1" for o in world["flexOrders"])
|
||
assert any(e["action"] == "multimodal.apply" for e in world["auditEvents"])
|
||
assert not any(e["action"] == "multimodal.stage" for e in world["auditEvents"])
|
||
|
||
|
||
def test_ingest_low_confidence_stages_card_without_world_write():
|
||
world = seed_world()
|
||
next_id = _next_id(world)
|
||
token = bind_identity(_PLANNER)
|
||
try:
|
||
cands = TextOrderExtractor().extract("订单 单号 SO-LOW-1 数量 5")
|
||
result = ingest_candidates(world, next_id, cands, session_id="mm-session",
|
||
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 not any(o["orderNo"] == "SO-LOW-1" for o in world["flexOrders"]) # 确认前绝不写
|
||
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"
|
||
token = bind_identity(_PLANNER)
|
||
try:
|
||
assert harness.is_confirmation_pending(confirm_id) is True
|
||
finally:
|
||
reset_identity(token)
|
||
|
||
|
||
def test_module_confirm_writes_world_reject_does_not():
|
||
world = seed_world()
|
||
next_id = _next_id(world)
|
||
token = bind_identity(_PLANNER)
|
||
try:
|
||
cands = TextOrderExtractor().extract("订单 单号 SO-LOW-2 数量 7")
|
||
staged = ingest_candidates(world, next_id, cands, session_id="mm", threshold=_THRESHOLD)
|
||
cid = staged["confirmId"]
|
||
store = _WorldStore(world, next_id)
|
||
message = execute_confirmed(store, cid, True, actor="planner")
|
||
assert "导入完成" in message
|
||
assert any(o["orderNo"] == "SO-LOW-2" for o in world["flexOrders"]) # 确认后写世界
|
||
assert any(e["action"] == "import.commit" for e in world["auditEvents"])
|
||
assert not any(e["action"] == "multimodal.apply" for e in world["auditEvents"]) # 确认通道审计为 import.commit
|
||
|
||
# 第二条卡:驳回 → 不写世界 + 留痕
|
||
cands2 = TextOrderExtractor().extract("订单 单号 SO-LOW-3 数量 3")
|
||
staged2 = ingest_candidates(world, next_id, cands2, session_id="mm", threshold=_THRESHOLD)
|
||
cid2 = staged2["confirmId"]
|
||
before = len(world["flexOrders"])
|
||
message2 = execute_confirmed(store, cid2, False, actor="planner")
|
||
assert "已驳回" in message2
|
||
assert len(world["flexOrders"]) == before
|
||
assert any(e["action"] == "import.commit.reject" for e in world["auditEvents"])
|
||
finally:
|
||
reset_identity(token)
|
||
|
||
|
||
# ---------------- 网关接线:/api/multimodal/* + 确认卡通道 ----------------
|
||
|
||
class _AuditStore:
|
||
def __init__(self, tenant_uuid: str = "tenant-multimodal"):
|
||
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-pair"}
|
||
|
||
def get(self, pair_id: str) -> dict | None:
|
||
return {"pairId": pair_id}
|
||
|
||
|
||
class _ProjectStore:
|
||
def active_world_key(self) -> str:
|
||
return "personal-1001"
|
||
|
||
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
|
||
|
||
provider = install_test_auth(monkeypatch, "tenant-multimodal")
|
||
|
||
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, provider
|
||
|
||
return factory
|
||
|
||
|
||
def test_api_extract_returns_candidates_and_confidence(client_factory):
|
||
client, store, _ = client_factory()
|
||
resp = client.post("/api/multimodal/extract", json={
|
||
"kind": "text_order", "text": "订单 单号 SO-API-1 数量 5"})
|
||
assert resp.status_code == 200, resp.text
|
||
data = resp.json()
|
||
assert data["kind"] == "text_order"
|
||
assert data["registeredKinds"] == ["dxf_drawing", "file_import", "image_meta", "text_order"]
|
||
assert data["defaultThreshold"] == pytest.approx(0.7)
|
||
assert len(data["candidates"]) == 1
|
||
cand = data["candidates"][0]
|
||
assert cand["confidence"] == pytest.approx(0.6)
|
||
assert cand["complete"] is True
|
||
assert cand["source"] == "STUB_TEXT"
|
||
assert any(e["action"] == "multimodal.extract" for e in store.data["auditEvents"])
|
||
|
||
|
||
def test_api_extract_unregistered_kind_errors_explicitly(client_factory):
|
||
client, _, _ = client_factory()
|
||
for kind in ("asr", "vlm"):
|
||
resp = client.post("/api/multimodal/extract", json={"kind": kind, "text": "外部语音/图片"})
|
||
assert resp.status_code == 200, resp.text
|
||
assert "未注册" in resp.json()["error"]
|
||
assert kind in resp.json()["error"]
|
||
|
||
|
||
def test_api_file_extract_low_confidence_stage_confirm_writes(client_factory):
|
||
client, store, _ = client_factory()
|
||
product = _demo_product(store.data)
|
||
payload = base64.b64encode(_orders_csv(product["code"], ok=2, bad=5)).decode("ascii")
|
||
extract = client.post("/api/multimodal/extract", json={
|
||
"kind": "file_import", "filename": "orders.csv", "dataBase64": payload})
|
||
assert extract.status_code == 200, extract.text
|
||
cands = extract.json()["candidates"]
|
||
assert cands[0]["confidence"] < _THRESHOLD
|
||
assert cands[0]["complete"] is True
|
||
|
||
before_so = len(store.data["salesOrders"])
|
||
before_flex = len(store.data["flexOrders"])
|
||
ingest = client.post("/api/multimodal/ingest", json={
|
||
"sessionId": "web", "candidates": cands, "threshold": _THRESHOLD})
|
||
assert ingest.status_code == 200, ingest.text
|
||
body = ingest.json()
|
||
assert body["status"] == "staged"
|
||
cid = body["confirmId"]
|
||
assert len(store.data["salesOrders"]) == before_so # 出卡不写世界
|
||
assert len(store.data["flexOrders"]) == before_flex
|
||
assert any(e["action"] == "multimodal.stage" for e in store.data["auditEvents"])
|
||
|
||
confirm = client.post("/api/actions/confirm", json={
|
||
"confirmId": cid, "approve": True, "note": "确认低置信文件"})
|
||
assert confirm.status_code == 200, confirm.text
|
||
assert len(store.data["salesOrders"]) == before_so
|
||
assert len(store.data["flexOrders"]) == before_flex + 2 # 确认后写世界
|
||
assert any(e["action"] == "import.commit" for e in store.data["auditEvents"])
|
||
assert not any(e["action"] == "multimodal.apply" for e in store.data["auditEvents"])
|
||
|
||
|
||
def test_api_high_confidence_direct_apply(client_factory):
|
||
client, store, _ = client_factory()
|
||
resp = client.post("/api/multimodal/extract", json={
|
||
"kind": "text_order",
|
||
"text": "订单 单号 SO-HI-1 数量 9 交期 2026-08-20 客户 华东公司 产品编码 CTRL-A"})
|
||
cands = resp.json()["candidates"]
|
||
assert cands[0]["confidence"] == pytest.approx(1.0)
|
||
|
||
before = len(store.data["flexOrders"])
|
||
ingest = client.post("/api/multimodal/ingest", json={"candidates": cands})
|
||
assert ingest.status_code == 200, ingest.text
|
||
body = ingest.json()
|
||
assert body["status"] == "applied"
|
||
assert len(store.data["flexOrders"]) == before + 1
|
||
assert any(o["orderNo"] == "SO-HI-1" for o in store.data["flexOrders"])
|
||
assert any(e["action"] == "multimodal.apply" for e in store.data["auditEvents"])
|
||
assert not any(e["action"] == "multimodal.stage" for e in store.data["auditEvents"])
|
||
|
||
|
||
def test_api_reject_does_not_write(client_factory):
|
||
client, store, _ = client_factory()
|
||
product = _demo_product(store.data)
|
||
payload = base64.b64encode(_orders_csv(product["code"], ok=1, bad=4)).decode("ascii")
|
||
extract = client.post("/api/multimodal/extract", json={
|
||
"kind": "file_import", "filename": "orders.csv", "dataBase64": payload})
|
||
cands = extract.json()["candidates"]
|
||
ingest = client.post("/api/multimodal/ingest", json={"candidates": cands})
|
||
assert ingest.json()["status"] == "staged"
|
||
|
||
before_so = len(store.data["salesOrders"])
|
||
reject = client.post("/api/actions/confirm", json={
|
||
"confirmId": ingest.json()["confirmId"], "approve": False, "note": "数据不对"})
|
||
assert reject.status_code == 200, reject.text
|
||
assert "已驳回" in reject.json()["message"]
|
||
assert len(store.data["salesOrders"]) == before_so # 拒绝不写
|
||
assert any(e["action"] == "import.commit.reject" for e in store.data["auditEvents"])
|
||
assert not any(e["action"] == "multimodal.apply" for e in store.data["auditEvents"])
|
||
|
||
|
||
def test_api_incomplete_candidate_rejected_without_card(client_factory):
|
||
client, store, _ = client_factory()
|
||
extract = client.post("/api/multimodal/extract", json={
|
||
"kind": "text_order", "text": "订单 单号 SO-INC-1"}) # 缺数量
|
||
cands = extract.json()["candidates"]
|
||
assert cands[0]["complete"] is False
|
||
before = len(store.data["flexOrders"]) + len(store.data["salesOrders"])
|
||
ingest = client.post("/api/multimodal/ingest", json={"candidates": cands})
|
||
assert ingest.status_code == 200, ingest.text
|
||
assert ingest.json()["status"] == "incomplete"
|
||
assert len(store.data["flexOrders"]) + len(store.data["salesOrders"]) == before
|
||
assert not any(e["action"] == "multimodal.stage" for e in store.data["auditEvents"])
|
||
assert not any(e["action"] == "multimodal.apply" for e in store.data["auditEvents"])
|
||
|
||
|
||
def test_api_threshold_override_changes_decision(client_factory):
|
||
client, store, _ = client_factory()
|
||
resp = client.post("/api/multimodal/extract", json={
|
||
"kind": "text_order", "text": "订单 单号 SO-TH-1 数量 4"}) # 0.6 置信
|
||
cands = resp.json()["candidates"]
|
||
assert cands[0]["confidence"] == pytest.approx(0.6)
|
||
|
||
before = len(store.data["flexOrders"])
|
||
# 默认 0.7 → 低置信出卡
|
||
staged = client.post("/api/multimodal/ingest", json={"candidates": cands})
|
||
assert staged.json()["status"] == "staged"
|
||
assert len(store.data["flexOrders"]) == before
|
||
# 阈值放宽 0.5 → 同一候选直通
|
||
applied = client.post("/api/multimodal/ingest", json={
|
||
"candidates": cands, "threshold": 0.5})
|
||
assert applied.json()["status"] == "applied"
|
||
assert len(store.data["flexOrders"]) == before + 1
|
||
|
||
|
||
def test_api_audit_trail_complete_for_low_confidence_flow(client_factory):
|
||
"""审计齐全:extract → stage → 确认后 apply(import.commit 通道)→ 拒绝留痕。"""
|
||
client, store, _ = client_factory()
|
||
product = _demo_product(store.data)
|
||
payload = base64.b64encode(_orders_csv(product["code"], ok=1, bad=3)).decode("ascii")
|
||
extract = client.post("/api/multimodal/extract", json={
|
||
"kind": "file_import", "filename": "orders.csv", "dataBase64": payload})
|
||
cands = extract.json()["candidates"]
|
||
ingest = client.post("/api/multimodal/ingest", json={"candidates": cands})
|
||
assert ingest.json()["status"] == "staged"
|
||
|
||
confirm = client.post("/api/actions/confirm", json={
|
||
"confirmId": ingest.json()["confirmId"], "approve": True})
|
||
assert confirm.status_code == 200, confirm.text
|
||
|
||
actions = [e["action"] for e in store.data["auditEvents"]]
|
||
assert "multimodal.extract" in actions
|
||
assert "multimodal.stage" in actions
|
||
assert "import.commit" in actions
|
||
stage = next(e for e in store.data["auditEvents"] if e["action"] == "multimodal.stage")
|
||
assert stage["rationale"]["confirmId"] == ingest.json()["confirmId"]
|
||
assert stage["rationale"]["candidates"][0]["confidence"] < _THRESHOLD
|
||
commit = next(e for e in store.data["auditEvents"] if e["action"] == "import.commit")
|
||
assert commit["beforeSnapshot"] is not None
|