"""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())