Files
cad-agent/dwg_review/__main__.py
T
gjm 6143bb9ab2 feat(review): PDF/图片离线源适配(C1)—— 无需 CAD 的整条审查链路
支持直接审查 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 丢图框块,离线侧暂无等价手段),留待后续打磨。
2026-10-16 23:07:23 +08:00

280 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CLI 入口:python -m dwg_review [source] [options]
两种运行模式(由 `source` 扩展名决定):
- **CAD 在线**(.dwg/.dxf 或不带参数):连 127.0.0.1:8080,审查 CAD 中当前打开的图纸;
- **离线**(.pdf / 图片):完全离线,不连 CAD,内存渲染 + VLM 审查。
"""
import argparse
import json
import sys
from pathlib import Path
from . import config
from . import checks as checks_phase
from . import findings as findings_mod
from . import sources
from .annotator import annotate, clear
from .checklist import build_system_prompt, checklist_summary
from .context import ReviewContext
from .evidence_model import SOURCE_CAD, evidence_images
from .mcp_client import McpError, McpToolError, result_json
from .phases import evidence as evidence_phase
from .phases import planning, report, review
def _dump_evidence(evidences):
out_dir = Path(config.REPORT_DIR)
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / "evidence.json"
drop = ("images", "image_b64", "image_mime")
trimmed = [{k: v for k, v in e.items() if k not in drop} for e in evidences]
path.write_text(json.dumps(trimmed, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def cmd_list_tools(ctx):
tools = ctx.tools.refresh()
print(f"共 {len(tools)} 个工具:")
for tool in tools:
print(f" - {tool['name']}")
return 0
def cmd_check(ctx):
if not ctx.client.is_available():
print(f"[X] {config.HOST}:{config.PORT} 未监听,请先启动已加载插件的 CAD。")
return 1
print(f"[OK] {config.HOST}:{config.PORT} 在监听")
info = result_json(ctx.tools.call("get_drawing_info", {}), {})
print("图纸信息:", json.dumps(info, ensure_ascii=False)[:400])
return 0
def run_review(ctx, args):
offline = ctx.tools is None
print("=== 图纸审查 Agent ===")
print(f"清单:{checklist_summary(ctx.checklist)}")
if ctx.llm is not None:
print(f"模型:{ctx.llm.model}")
if offline:
print(f"来源:{ctx.source}(离线模式,不连 CAD) {ctx.source_path}")
print()
print("[1/4] 规划:识别图框与概览 ...")
plan = planning.plan(ctx)
frames = plan.get("frames", [])
unit = "页" if offline else "图框"
if offline:
info = plan.get("drawing_info") or {}
print(
f" 共 {len(frames)} {unit}(原文档 {info.get('page_count')} 页,"
f"坐标单位 {info.get('unit')})"
)
else:
print(f" 识别到 {len(frames)} 个图框")
if not frames:
print(f"未识别到任何{unit},退出。")
return 1
system_prompt = build_system_prompt(
ctx.checklist, allow_tools=not offline, offline=offline
)
evidences = []
frame_findings = []
# 截屏前把绘图区最大化(窗口铺满 + 干净屏幕)。**取证与审查都要截图**——审查阶段的
# 受控子循环会按需放大复核,故一并包住,结束在 finally 里还原;旧版插件无此工具时静默降级。
# 离线源没有 CAD,该上下文管理器内部会直接放行。
with evidence_phase.maximized_drawing_area(ctx):
print("[2/4] 取证:逐图框采集(串行) ...")
for idx, frame in enumerate(frames, 1):
if offline:
print(
f" - {unit} {idx}/{len(frames)} 原页 {frame.get('page_index')} "
f"图幅 {frame.get('sheet_size') or '—'}"
)
else:
print(
f" - {unit} {idx}/{len(frames)} kind={frame.get('kind')} "
f"sheet={frame.get('sheet_size')}"
)
ev = evidence_phase.collect(ctx, idx, frame)
if ev is None:
print(" [跳过] 缺少有效包围盒")
continue
evidences.append(ev)
if offline:
print(
f" 文本层 {(ev.get('texts') or {}).get('total')} 条,"
f"图片 {len(evidence_images(ev))} 张"
)
else:
print(
f" 图元 {(ev.get('entities') or {}).get('total')} 条,"
f"截图 {len(evidence_images(ev))} 张"
)
if args.plan_only:
path = _dump_evidence(evidences)
print(f"[evidence] 已写入 {path}")
return 0
print("[3/4] 审查:确定性检查前置 + 逐图框 LLM 审查(含受控只读取证子循环) ...")
for ev in evidences:
print(f" - 审查{unit} {ev['index']} ...")
checks = (
checks_phase.run_deterministic_checks(ctx, ev)
if config.DETERMINISTIC_CHECKS
else []
)
conclusion = review.review_frame(ctx, ev, system_prompt)
finding = findings_mod.build_finding(
ev, conclusion, checks, validate_handles=config.VALIDATE_HANDLES
)
frame_findings.append(finding)
counts = finding["counts"]
preview = finding["summary"]
if len(preview) > 200:
preview = preview[:200] + "..."
print(
f" 结论:{preview}"
f"(确定性 {counts['deterministic']} / LLM {counts['llm']}"
f" / 未核实 {counts['unverified']})"
)
print("[4/4] 汇总报告 ...")
markdown = report.summarize(ctx, frame_findings)
issues = findings_mod.flatten_issues(frame_findings)
report.save_report(markdown, frame_findings, plan, issues)
print()
print(markdown)
if args.annotate:
print()
if offline:
print("[批注回写] 当前为离线源(无 CAD 可回写),已跳过。")
return 0
print(f"[批注回写] {'(dry-run) ' if args.dry_run else ''}结构化问题 {len(issues)} 条")
created, highlighted = annotate(ctx, issues, dry_run=args.dry_run)
if not args.dry_run and (created or highlighted):
hint = "python -m dwg_review --clear"
if created:
hint += f" {','.join(created)}"
if highlighted:
hint += f" --restore {','.join(highlighted)}"
print(f" 撤销:{hint}")
return 0
def build_parser():
parser = argparse.ArgumentParser(
prog="dwg_review",
description="图纸审查 Agent(CAD 走 cad_mcp_frame MCP 基座;PDF / 图片走离线渲染)",
)
parser.add_argument(
"source",
nargs="?",
default=None,
help="图纸路径:.dwg/.dxf 走 CAD 在线模式(需先在 CAD 中打开该图纸);"
".pdf/图片 走完全离线模式。省略则按 CAD 在线模式运行。",
)
parser.add_argument("--list-tools", action="store_true", help="列出基座注册的全部工具")
parser.add_argument("--check", action="store_true", help="检查基座连通性并打印图纸概览")
parser.add_argument("--plan-only", action="store_true", help="只做规划+取证,落盘 evidence.json")
parser.add_argument("--frames", type=int, default=None, help="最多审查的图框数/页数(0=全部)")
parser.add_argument("--annotate", action="store_true", help="审查后把问题批注回写到图上")
parser.add_argument("--dry-run", action="store_true", help="批注只打印不回写")
parser.add_argument("--clear", default=None, help="删除给定 handle(逗号分隔)以撤销批注")
parser.add_argument("--restore", default=None, help="还原给定 handle(逗号分隔)的原始颜色")
parser.add_argument("--checklist", default=None, help="审查清单 JSON 路径")
return parser
def _resolve_source_arg(source_arg):
"""解析位置参数 → (source, source_path);失败返回 (None, 错误信息)。"""
if not source_arg:
return None, None
try:
detected = sources.detect_source(source_arg)
except ValueError as exc:
return None, str(exc)
if detected == SOURCE_CAD:
# Python 侧无法打开 DWG,只能提示用户在 CAD 里打开后运行
print(
f"[!] 参数 {source_arg} 是 CAD 图纸:Python 无法直接打开,"
"将按 **CAD 在线模式** 审查 CAD 中当前打开的图纸。"
)
print(" (请先在 CAD 中打开该图纸;不带参数运行效果相同。)")
return None, None
path = Path(source_arg).expanduser().resolve()
if not path.is_file():
return None, f"文件不存在:{path}"
return (detected, str(path)), None
def main(argv=None):
args = build_parser().parse_args(argv)
if args.checklist:
config.CHECKLIST_PATH = Path(args.checklist)
resolved, err = _resolve_source_arg(args.source)
if err:
print(f"[X] {err}")
return 2
source, source_path = resolved if resolved else (None, None)
connect_cad = source is None
cad_only = bool(args.list_tools or args.check or args.clear or args.restore)
if not connect_cad and cad_only:
print("[X] --list-tools / --check / --clear / --restore 需要连接 CAD,仅支持 CAD 在线模式。")
return 2
need_llm = not (args.list_tools or args.check or args.plan_only or args.clear or args.restore)
try:
ctx = ReviewContext.create(
need_llm=need_llm,
connect_cad=connect_cad,
source=source,
source_path=source_path,
)
except McpError as exc:
print(f"[X] 无法连接 CAD 基座:{exc}")
return 1
except Exception as exc: # noqa: BLE001
print(f"[X] 初始化失败:{exc}")
return 1
if args.frames is not None:
ctx.max_frames = args.frames
try:
if args.list_tools:
return cmd_list_tools(ctx)
if args.check:
return cmd_check(ctx)
if args.clear or args.restore:
handles = [h.strip() for h in (args.clear or "").split(",") if h.strip()]
highlighted = [h.strip() for h in (args.restore or "").split(",") if h.strip()]
clear(ctx, handles, highlighted)
return 0
return run_review(ctx, args)
except (McpError, McpToolError) as exc:
print(f"[X] 调用失败:{exc}")
return 1
except KeyboardInterrupt:
print("\n[中断]")
return 130
finally:
if not connect_cad:
sources.close_reader(ctx)
if __name__ == "__main__":
sys.exit(main())