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 丢图框块,离线侧暂无等价手段),留待后续打磨。
This commit is contained in:
1 parent
d4a9442e30
commit
6143bb9ab2
32 files changed
+2617
-77
No files matched your search
+105
-20
@@ -1,4 +1,9 @@
|
||||
"""CLI 入口:python -m dwg_review [options]"""
|
||||
"""CLI 入口:python -m dwg_review [source] [options]
|
||||
|
||||
两种运行模式(由 `source` 扩展名决定):
|
||||
- **CAD 在线**(.dwg/.dxf 或不带参数):连 127.0.0.1:8080,审查 CAD 中当前打开的图纸;
|
||||
- **离线**(.pdf / 图片):完全离线,不连 CAD,内存渲染 + VLM 审查。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
@@ -8,10 +13,11 @@ 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 evidence_images
|
||||
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
|
||||
@@ -46,43 +52,69 @@ def cmd_check(ctx):
|
||||
|
||||
|
||||
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", [])
|
||||
print(f" 识别到 {len(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("未识别到任何图框,退出。")
|
||||
print(f"未识别到任何{unit},退出。")
|
||||
return 1
|
||||
|
||||
system_prompt = build_system_prompt(ctx.checklist, allow_tools=True)
|
||||
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):
|
||||
print(
|
||||
f" - 图框 {idx}/{len(frames)} kind={frame.get('kind')} "
|
||||
f"sheet={frame.get('sheet_size')}"
|
||||
)
|
||||
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)
|
||||
print(
|
||||
f" 图元 {(ev.get('entities') or {}).get('total')} 条,"
|
||||
f"截图 {len(evidence_images(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)
|
||||
@@ -91,7 +123,7 @@ def run_review(ctx, args):
|
||||
|
||||
print("[3/4] 审查:确定性检查前置 + 逐图框 LLM 审查(含受控只读取证子循环) ...")
|
||||
for ev in evidences:
|
||||
print(f" - 审查图框 {ev['index']} ...")
|
||||
print(f" - 审查{unit} {ev['index']} ...")
|
||||
checks = (
|
||||
checks_phase.run_deterministic_checks(ctx, ev)
|
||||
if config.DETERMINISTIC_CHECKS
|
||||
@@ -122,6 +154,9 @@ def run_review(ctx, args):
|
||||
|
||||
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):
|
||||
@@ -136,12 +171,20 @@ def run_review(ctx, args):
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="dwg_review", description="CAD 图纸审查 Agent(基于 cad_mcp_frame MCP 基座)"
|
||||
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("--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(逗号分隔)以撤销批注")
|
||||
@@ -150,17 +193,56 @@ def build_parser():
|
||||
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)
|
||||
|
||||
need_llm = not (
|
||||
args.list_tools or args.check or args.plan_only or args.clear or args.restore
|
||||
)
|
||||
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)
|
||||
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
|
||||
@@ -188,6 +270,9 @@ def main(argv=None):
|
||||
except KeyboardInterrupt:
|
||||
print("\n[中断]")
|
||||
return 130
|
||||
finally:
|
||||
if not connect_cad:
|
||||
sources.close_reader(ctx)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user