feat(review): 取证层多源化 + 视觉多图(整体+2x2 象限)
- 新增 evidence_model.py:统一证据模型(source_type + images 列表),访问器兼容旧 image_b64/image_mime 单图字段 - phases/evidence.py:新增 collect() 多源分发 seam(仅 CAD)、capture_images() 整体+等分象限多图(结束复位视口)、sub_windows() - phases/review.py:多图逐张下采样喂 LLM,payload 增加 source_type - config.py:EVIDENCE_DETAIL_TILES=4 / EVIDENCE_MAX_IMAGES=5(默认开启多图) - 新增 tests/test_evidence_model.py,更新 evidence/review 断言与 fakes 归一
This commit is contained in:
1 parent
22db93b6f5
commit
1c9fc72839
10 files changed
+396
-32
No files matched your search
+16
-3
@@ -10,6 +10,7 @@
|
||||
|
||||
import json
|
||||
|
||||
from dwg_review import evidence_model
|
||||
from dwg_review.cad_tools import CadTools
|
||||
from dwg_review.checklist import DEFAULT_CHECKLIST
|
||||
from dwg_review.context import ReviewContext
|
||||
@@ -313,10 +314,23 @@ def make_evidence(
|
||||
bbox=(0.0, 0.0, 100.0, 50.0),
|
||||
image_b64=None,
|
||||
image_mime=None,
|
||||
images=None,
|
||||
source_type=None,
|
||||
**over,
|
||||
):
|
||||
"""构造 collect_frame 的返回值(evidence)。"""
|
||||
"""构造 collect_frame 的返回值(evidence)。
|
||||
|
||||
兼容旧的 `image_b64`/`image_mime` 单图入参:若给了 image_b64 而未给 images,
|
||||
则归一进 `images` 列表(与 `evidence_model.evidence_images` 的归一保持一致)。
|
||||
"""
|
||||
if images is None:
|
||||
images = []
|
||||
if image_b64:
|
||||
images.append(
|
||||
evidence_model.make_image(image_b64, image_mime or "image/png")
|
||||
)
|
||||
ev = {
|
||||
"source_type": source_type or evidence_model.SOURCE_CAD,
|
||||
"index": index,
|
||||
"frame": frame(bbox),
|
||||
"window": {
|
||||
@@ -330,8 +344,7 @@ def make_evidence(
|
||||
"dimensions": {"total": 0, "items": []},
|
||||
"blocks": {"total": 0, "items": []},
|
||||
"polylines": {"total": 0, "items": []},
|
||||
"image_b64": image_b64,
|
||||
"image_mime": image_mime,
|
||||
"images": images,
|
||||
}
|
||||
ev.update(over)
|
||||
return ev
|
||||
+57
-5
@@ -55,8 +55,8 @@ def test_collect_frame_assembles_evidence():
|
||||
assert ev["dimensions"]["total"] == 2
|
||||
assert ev["blocks"]["total"] == 0
|
||||
assert ev["polylines"]["total"] == 3
|
||||
assert ev["image_b64"] == "QUJD"
|
||||
assert ev["image_mime"] == "image/png"
|
||||
assert ev["source_type"] == "cad"
|
||||
assert ev["images"] == [{"b64": "QUJD", "mime": "image/png", "label": "overall"}]
|
||||
|
||||
|
||||
def test_collect_frame_calls_expected_tools():
|
||||
@@ -80,12 +80,64 @@ def test_collect_frame_calls_expected_tools():
|
||||
def test_collect_frame_without_image():
|
||||
ctx = make_ctx(client=build_evidence_client(image=ok_text({})))
|
||||
ev = collect_frame(ctx, 1, frame([0, 0, 10, 10]))
|
||||
assert ev["image_b64"] is None
|
||||
assert ev["image_mime"] is None
|
||||
assert ev["images"] == []
|
||||
|
||||
|
||||
def test_collect_frame_tolerates_empty_structured_data():
|
||||
ctx = make_ctx(client=FakeMcpClient()) # 全部默认 ok_text({})
|
||||
ev = collect_frame(ctx, 1, frame([0, 0, 10, 10]))
|
||||
assert ev["entities"] == {}
|
||||
assert ev["image_b64"] is None
|
||||
assert ev["images"] == []
|
||||
|
||||
|
||||
def test_sub_windows_2x2():
|
||||
from dwg_review.phases.evidence import sub_windows
|
||||
|
||||
win = {"min_x": 0.0, "min_y": 0.0, "max_x": 10.0, "max_y": 10.0}
|
||||
subs = sub_windows(win, 4)
|
||||
assert len(subs) == 4
|
||||
# 行优先:左上 → 右上 → 左下 → 右下
|
||||
assert subs[0] == {"min_x": 0.0, "min_y": 5.0, "max_x": 5.0, "max_y": 10.0}
|
||||
assert subs[1] == {"min_x": 5.0, "min_y": 5.0, "max_x": 10.0, "max_y": 10.0}
|
||||
assert subs[2] == {"min_x": 0.0, "min_y": 0.0, "max_x": 5.0, "max_y": 5.0}
|
||||
assert subs[3] == {"min_x": 5.0, "min_y": 0.0, "max_x": 10.0, "max_y": 5.0}
|
||||
|
||||
|
||||
def test_sub_windows_disabled():
|
||||
from dwg_review.phases.evidence import sub_windows
|
||||
|
||||
win = {"min_x": 0.0, "min_y": 0.0, "max_x": 10.0, "max_y": 10.0}
|
||||
assert sub_windows(win, 0) == []
|
||||
assert sub_windows(win, 1) == []
|
||||
|
||||
|
||||
def test_collect_frame_captures_multiple_images(monkeypatch):
|
||||
from dwg_review import config
|
||||
from dwg_review.phases.evidence import collect_frame
|
||||
|
||||
monkeypatch.setattr(config, "EVIDENCE_DETAIL_TILES", 4)
|
||||
ctx = make_ctx(client=build_evidence_client())
|
||||
ev = collect_frame(ctx, 1, frame([0, 0, 100, 50]))
|
||||
assert [img["label"] for img in ev["images"]] == [
|
||||
"overall",
|
||||
"tile-1",
|
||||
"tile-2",
|
||||
"tile-3",
|
||||
"tile-4",
|
||||
]
|
||||
|
||||
|
||||
def test_collect_dispatches_cad_source():
|
||||
from dwg_review.phases.evidence import collect
|
||||
|
||||
ctx = make_ctx(client=build_evidence_client())
|
||||
ev = collect(ctx, 1, frame([0, 0, 10, 10]))
|
||||
assert ev is not None
|
||||
assert ev["source_type"] == "cad"
|
||||
|
||||
|
||||
def test_collect_returns_none_for_unsupported_source():
|
||||
from dwg_review.phases.evidence import collect
|
||||
|
||||
ctx = make_ctx(client=FakeMcpClient())
|
||||
assert collect(ctx, 1, {"source_type": "pdf", "bbox": [0, 0, 1, 1]}) is None
|
||||
@@ -0,0 +1,100 @@
|
||||
"""`dwg_review.evidence_model` 单元测试。"""
|
||||
|
||||
from dwg_review import evidence_model as em
|
||||
|
||||
|
||||
def test_make_image_defaults():
|
||||
assert em.make_image("QUJD") == {"b64": "QUJD", "mime": "image/png", "label": None}
|
||||
|
||||
|
||||
def test_make_image_custom():
|
||||
assert em.make_image("QUJD", "image/jpeg", "tile-1") == {
|
||||
"b64": "QUJD",
|
||||
"mime": "image/jpeg",
|
||||
"label": "tile-1",
|
||||
}
|
||||
|
||||
|
||||
def test_make_image_empty_mime_falls_back():
|
||||
assert em.make_image("x", "")["mime"] == "image/png"
|
||||
|
||||
|
||||
def test_source_type_of_default_cad():
|
||||
assert em.source_type_of({}) == em.SOURCE_CAD
|
||||
assert em.source_type_of(None) == em.SOURCE_CAD
|
||||
assert em.source_type_of({"source_type": ""}) == em.SOURCE_CAD
|
||||
|
||||
|
||||
def test_source_type_of_explicit():
|
||||
assert em.source_type_of({"source_type": "pdf"}) == "pdf"
|
||||
assert em.source_type_of({"source_type": "image"}) == "image"
|
||||
|
||||
|
||||
def test_evidence_images_new_form():
|
||||
ev = {
|
||||
"images": [
|
||||
em.make_image("A", label="overall"),
|
||||
em.make_image("B", label="tile-1"),
|
||||
]
|
||||
}
|
||||
out = em.evidence_images(ev)
|
||||
assert [i["b64"] for i in out] == ["A", "B"]
|
||||
assert [i["label"] for i in out] == ["overall", "tile-1"]
|
||||
|
||||
|
||||
def test_evidence_images_filters_empty_b64():
|
||||
ev = {"images": [{"b64": "", "mime": "image/png"}, {"b64": "A"}]}
|
||||
out = em.evidence_images(ev)
|
||||
assert len(out) == 1
|
||||
assert out[0]["b64"] == "A"
|
||||
|
||||
|
||||
def test_evidence_images_legacy_fallback():
|
||||
ev = {"image_b64": "LEG", "image_mime": "image/jpeg"}
|
||||
assert em.evidence_images(ev) == [
|
||||
{"b64": "LEG", "mime": "image/jpeg", "label": None}
|
||||
]
|
||||
|
||||
|
||||
def test_evidence_images_legacy_default_mime():
|
||||
assert em.evidence_images({"image_b64": "LEG"})[0]["mime"] == "image/png"
|
||||
|
||||
|
||||
def test_evidence_images_empty():
|
||||
assert em.evidence_images({}) == []
|
||||
assert em.evidence_images(None) == []
|
||||
assert em.evidence_images({"images": []}) == []
|
||||
|
||||
|
||||
def test_evidence_images_prefers_new_over_legacy():
|
||||
ev = {"images": [em.make_image("NEW")], "image_b64": "OLD"}
|
||||
assert [i["b64"] for i in em.evidence_images(ev)] == ["NEW"]
|
||||
|
||||
|
||||
def test_primary_image_prefers_overall():
|
||||
ev = {
|
||||
"images": [
|
||||
em.make_image("A", label="tile-1"),
|
||||
em.make_image("B", label="overall"),
|
||||
]
|
||||
}
|
||||
assert em.primary_image(ev)["b64"] == "B"
|
||||
|
||||
|
||||
def test_primary_image_first_when_no_overall():
|
||||
ev = {"images": [em.make_image("A", label="tile-1"), em.make_image("B")]}
|
||||
assert em.primary_image(ev)["b64"] == "A"
|
||||
|
||||
|
||||
def test_primary_image_none_when_empty():
|
||||
assert em.primary_image({}) is None
|
||||
|
||||
|
||||
def test_image_labels():
|
||||
ev = {
|
||||
"images": [
|
||||
em.make_image("A", label="overall"),
|
||||
em.make_image("B", label="tile-1"),
|
||||
]
|
||||
}
|
||||
assert em.image_labels(ev) == ["overall", "tile-1"]
|
||||
+18
-1
@@ -28,8 +28,8 @@ from tests.fakes import (
|
||||
def test_evidence_payload_strips_image():
|
||||
ev = make_evidence(image_b64="QUJD", image_mime="image/png")
|
||||
payload = evidence_payload(ev)
|
||||
assert "images" not in payload
|
||||
assert "image_b64" not in payload
|
||||
assert "image_mime" not in payload
|
||||
assert payload["frame_index"] == 1
|
||||
|
||||
|
||||
@@ -243,3 +243,20 @@ def test_review_frame_intermediate_tool_turn_json_not_parsed():
|
||||
out = review_frame(ctx, make_evidence(), "sys", log=lambda *a: None)
|
||||
assert out["summary"] == "最终结论"
|
||||
assert out["issues"] == []
|
||||
|
||||
|
||||
def test_review_frame_includes_multiple_image_blocks():
|
||||
llm = FakeLlmClient(script=[llm_text("ok")])
|
||||
ctx = make_ctx(client=FakeMcpClient(), llm=llm)
|
||||
ev = make_evidence(
|
||||
images=[
|
||||
{"b64": "A", "mime": "image/png", "label": "overall"},
|
||||
{"b64": "B", "mime": "image/png", "label": "tile-1"},
|
||||
]
|
||||
)
|
||||
review_frame(ctx, ev, "sys", log=lambda *a: None)
|
||||
user_content = llm.calls[0]["messages"][1]["content"]
|
||||
image_blocks = [b for b in user_content if b.get("type") == "image_url"]
|
||||
assert len(image_blocks) == 2
|
||||
assert any(b.get("text") == "(截图:overall)" for b in user_content)
|
||||
assert any(b.get("text") == "(截图:tile-1)" for b in user_content)
|
||||
Reference in new issue
Block a user