"""离线源(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)