支持直接审查 PDF 与位图图纸,全链路离线(不连 CAD、不连 8080 端口)。 渲染在内存内完成,复用既有墨迹热力图 + 自适应瓦片规划。 - 新增 dwg_review/sources.py - detect_source / plan_file / collect 统一入口 - PdfReader:pypdfium2 惰性导入,页序即图框序,处理 /Rotate 与 crop 边距 - ImageReader:世界单位 = 源像素,snap_box 整数对齐保证窗口与像素严格一致 - ink_grid:纯 PIL 灰度阈值 → 占用网格(不引入 numpy) - ReviewContext.create(connect_cad=False):离线时 client/tools 均为 None - 全链路 ctx.tools is None 守卫:review 只读子循环 / inspect_region / annotator / checks 的窗口类检查 / 绘图区最大化 - checklist.build_system_prompt(allow_tools=False, offline=True):离线提示词 - __main__:位置参数 source,按扩展名自动判定源类型并决定是否连 CAD - config 新增 8 个常量(PDF_MAX_PAGES / 文本最小字数 / 各源缩放上限 / 墨迹阈值等) - tiling.content_predicate 与 heatmap.cell_box 提升为公开函数 验证: - pytest 457 passed - verify_pdf_source.py:PDF 23/23、图片 9/9(不需 CAD),含实测瓦片字高像素与 旋转映射自检(文本区墨迹 0.311 vs 忽略 /Rotate 反向对照 0.145) - 真实 LLM 端到端:20 页 HVAC 图纸前 3 页跑通,结论具体可核验且未编造 handle 已知局限:页边线在墨迹网格里会把整页连成单簇,瓦片常退化为整页象限(CAD 侧 靠 drop_oversized 丢图框块,离线侧暂无等价手段),留待后续打磨。
218 lines
8.5 KiB
Python
218 lines
8.5 KiB
Python
"""离线源(PDF / 图片)**全链路**单测:规划 → 取证 → 审查 → 汇总,全程不碰 CAD。
|
|
|
|
与 `test_sources*.py` 的分工:那边测 `sources` 模块本身;这里测**跨阶段贯通**——
|
|
离线上下文能否一路走完 `planning` / `evidence` / `checks` / `review` / `findings` /
|
|
`report` / `annotator`,且任何环节都不会去访问 `ctx.tools`(离线时它恒为 None)。
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from dwg_review import config, evidence_model, sources
|
|
from dwg_review.annotator import annotate
|
|
from dwg_review.checks import run_deterministic_checks
|
|
from dwg_review.checklist import build_system_prompt
|
|
from dwg_review.findings import build_finding, flatten_issues
|
|
from dwg_review.phases import evidence as evidence_phase
|
|
from dwg_review.phases import planning, report, review
|
|
from tests.fakes import FakeLlmClient, llm_calls, llm_text, make_ctx
|
|
from tests.test_sources_pdf import _build_pdf
|
|
|
|
|
|
@pytest.fixture
|
|
def pdf2(tmp_path):
|
|
"""两页 PDF:第 1 页有文本层,第 2 页整页有墨迹(会切瓦片)。"""
|
|
path = tmp_path / "two.pdf"
|
|
path.write_bytes(_build_pdf([{"text": "HELLO"}, {"fill": True, "text": ""}]))
|
|
return str(path)
|
|
|
|
|
|
@pytest.fixture
|
|
def offline_ctx(pdf2, monkeypatch):
|
|
monkeypatch.setattr(config, "TILING_ENABLED", True)
|
|
monkeypatch.setattr(config, "DETERMINISTIC_CHECKS", True)
|
|
monkeypatch.setattr(config, "VALIDATE_HANDLES", True)
|
|
ctx = make_ctx(
|
|
connect_cad=False,
|
|
source=evidence_model.SOURCE_PDF,
|
|
source_path=pdf2,
|
|
llm=FakeLlmClient(text=json.dumps({"summary": "未发现明显问题", "issues": []})),
|
|
)
|
|
yield ctx
|
|
sources.close_reader(ctx)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 提示词
|
|
# ---------------------------------------------------------------------------
|
|
def test_offline_prompt_has_no_tool_protocol():
|
|
prompt = build_system_prompt(
|
|
{"name": "T", "categories": [{"title": "几何", "checks": ["x"]}]},
|
|
allow_tools=False,
|
|
offline=True,
|
|
)
|
|
assert "inspect_region" not in prompt
|
|
assert "find_collisions" not in prompt
|
|
assert "handle" in prompt # 明确要求**不要**输出 handle
|
|
assert "不要输出 `handle`" in prompt
|
|
assert "没有实体级结构化数据" in prompt
|
|
|
|
|
|
def test_offline_prompt_still_lists_checklist():
|
|
prompt = build_system_prompt(
|
|
{"name": "T", "categories": [{"title": "干涉/碰撞", "severity": "high",
|
|
"checks": ["构件侵入"]}]},
|
|
allow_tools=False,
|
|
offline=True,
|
|
)
|
|
assert "干涉/碰撞" in prompt and "构件侵入" in prompt
|
|
|
|
|
|
def test_cad_prompt_unchanged_by_default():
|
|
prompt = build_system_prompt({"name": "T", "categories": []}, allow_tools=True)
|
|
assert "inspect_region" in prompt
|
|
assert "结构化图元数据" in prompt
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 全链路
|
|
# ---------------------------------------------------------------------------
|
|
def test_offline_pipeline_end_to_end(offline_ctx, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(config, "REPORT_DIR", str(tmp_path / "reports"))
|
|
ctx = offline_ctx
|
|
|
|
# 1) 规划:每页一个图框,保持页序,完全不调 CAD
|
|
plan = planning.plan(ctx)
|
|
frames = plan["frames"]
|
|
assert [f["page_index"] for f in frames] == [0, 1]
|
|
|
|
# 2) 取证:同形 evidence
|
|
evidences = []
|
|
for i, frame in enumerate(frames, 1):
|
|
ev = evidence_phase.collect(ctx, i, frame)
|
|
assert ev is not None
|
|
evidences.append(ev)
|
|
assert evidences[0]["texts"]["total"] == 1
|
|
assert evidences[0]["images"][0]["label"] == "overall"
|
|
|
|
# 3) 确定性检查:离线只跑纯 Python 派生(空文字 / 未闭合多段线),不报错
|
|
for ev in evidences:
|
|
assert run_deterministic_checks(ctx, ev, log=lambda *a: None) == []
|
|
|
|
# 4) 审查:离线提示词 + 无工具
|
|
prompt = build_system_prompt(ctx.checklist, allow_tools=False, offline=True)
|
|
findings = []
|
|
for ev in evidences:
|
|
conclusion = review.review_frame(ctx, ev, prompt, log=lambda *a: None)
|
|
findings.append(build_finding(ev, conclusion, [], validate_handles=True))
|
|
assert all(f["summary"] == "未发现明显问题" for f in findings)
|
|
|
|
# 5) 汇总 + 落盘(换一个纯文本假 LLM 用于汇总)
|
|
ctx.llm = FakeLlmClient(text="汇总:未发现明显问题。")
|
|
md = report.summarize(ctx, findings, log=lambda *a: None)
|
|
assert md == "汇总:未发现明显问题。"
|
|
md_path, json_path = report.save_report(md, findings, plan, flatten_issues(findings),
|
|
log=lambda *a: None)
|
|
assert md_path.exists() and json_path.exists()
|
|
saved = json.loads(json_path.read_text(encoding="utf-8"))
|
|
assert len(saved["frames"]) == 2
|
|
|
|
# 6) 批注:离线直接跳过,不抛异常
|
|
created, highlighted = annotate(ctx, flatten_issues(findings), log=lambda *a: None)
|
|
assert created == [] and highlighted == []
|
|
|
|
|
|
def test_offline_pipeline_never_touches_tools(offline_ctx):
|
|
"""离线全链路的每一步都不应访问 `ctx.tools`(它恒为 None)。"""
|
|
ctx = offline_ctx
|
|
assert ctx.tools is None and ctx.client is None
|
|
|
|
plan = planning.plan(ctx)
|
|
ev = evidence_phase.collect(ctx, 1, plan["frames"][0])
|
|
conclusion = review.review_frame(ctx, ev, "sys", log=lambda *a: None)
|
|
finding = build_finding(ev, conclusion, [], validate_handles=True)
|
|
assert finding["frame"] == 1
|
|
|
|
|
|
def test_offline_llm_handle_is_stripped(offline_ctx):
|
|
"""离线证据里没有 handle → LLM 若硬报 handle 会被剥掉并标为未核实。"""
|
|
ctx = offline_ctx
|
|
plan = planning.plan(ctx)
|
|
ev = evidence_phase.collect(ctx, 1, plan["frames"][0])
|
|
ctx.llm = FakeLlmClient(
|
|
text=json.dumps(
|
|
{
|
|
"summary": "有问题",
|
|
"issues": [{"category": "几何", "severity": "high",
|
|
"message": "m", "handle": "DEADBEEF"}],
|
|
}
|
|
)
|
|
)
|
|
conclusion = review.review_frame(ctx, ev, "sys", log=lambda *a: None)
|
|
finding = build_finding(ev, conclusion, [], validate_handles=True)
|
|
issue = finding["issues"][0]
|
|
assert issue["handle"] is None
|
|
assert issue["verified"] is False
|
|
assert finding["counts"]["unverified"] == 1
|
|
|
|
|
|
def test_offline_review_provides_no_tools_to_llm(offline_ctx):
|
|
"""离线审查给 LLM 的 tools 列表必须是空(不能让它请求 CAD 工具)。"""
|
|
ctx = offline_ctx
|
|
plan = planning.plan(ctx)
|
|
ev = evidence_phase.collect(ctx, 1, plan["frames"][0])
|
|
review.review_frame(ctx, ev, "sys", log=lambda *a: None)
|
|
assert ctx.llm.calls[-1]["tools"] == []
|
|
|
|
|
|
def test_offline_review_survives_tool_call_attempt(offline_ctx):
|
|
"""LLM 仍尝试调工具时:被拒绝(离线提示),随后照常收口出结论。"""
|
|
ctx = offline_ctx
|
|
plan = planning.plan(ctx)
|
|
ev = evidence_phase.collect(ctx, 1, plan["frames"][0])
|
|
ctx.llm = FakeLlmClient(
|
|
script=[
|
|
llm_calls([("query_entities", {})]),
|
|
llm_text(json.dumps({"summary": "ok", "issues": []})),
|
|
]
|
|
)
|
|
conclusion = review.review_frame(ctx, ev, "sys", log=lambda *a: None)
|
|
assert conclusion["summary"] == "ok"
|
|
|
|
|
|
def test_offline_evidence_is_json_serializable(offline_ctx):
|
|
ctx = offline_ctx
|
|
plan = planning.plan(ctx)
|
|
ev = evidence_phase.collect(ctx, 1, plan["frames"][0])
|
|
trimmed = {k: v for k, v in ev.items() if k not in ("images",)}
|
|
json.dumps(trimmed, ensure_ascii=False) # 不抛异常即可
|
|
|
|
|
|
def test_offline_image_source_pipeline(tmp_path, monkeypatch):
|
|
"""图片源同样能走完全链路。"""
|
|
from PIL import Image, ImageDraw
|
|
|
|
monkeypatch.setattr(config, "TILING_ENABLED", True)
|
|
img = Image.new("RGB", (800, 600), (255, 255, 255))
|
|
ImageDraw.Draw(img).rectangle([50, 50, 400, 300], outline=(0, 0, 0), width=3)
|
|
path = tmp_path / "plan.png"
|
|
img.save(path)
|
|
|
|
ctx = make_ctx(
|
|
connect_cad=False,
|
|
source=evidence_model.SOURCE_IMAGE,
|
|
source_path=str(path),
|
|
llm=FakeLlmClient(text=json.dumps({"summary": "s", "issues": []})),
|
|
)
|
|
try:
|
|
plan = planning.plan(ctx)
|
|
assert plan["drawing_info"]["unit"] == "px"
|
|
ev = evidence_phase.collect(ctx, 1, plan["frames"][0])
|
|
assert ev["source_type"] == "image"
|
|
assert ev["images"][0]["label"] == "overall"
|
|
conclusion = review.review_frame(ctx, ev, "sys", log=lambda *a: None)
|
|
assert conclusion["summary"] == "s"
|
|
finally:
|
|
sources.close_reader(ctx)
|