发现的缺口:maximized_drawing_area 原先只包住取证循环,审查阶段没被包住 → LLM 在受控子循环里 zoom + 截图拿到的是未最大化的 2110x462(正是上一轮刚解决的 低分辨率问题,在最需要放大看细节的审查阶段又出现了);且审查中的 zoom 不复位视口, 跑完把用户的 CAD 留在最后一次放大处。 改动: 1. __main__.run_review:把 maximized_drawing_area 从「只包取证」扩到「包取证 + 审查」 (两者都要截图);report/annotate 留在 with 之外。 2. phases/review.py:新增 _VIEW_TOOLS;review_frame 拆成外壳 + _review_frame, 外壳 try/finally 里仅当确实动过视口才复位到该图框整幅视图 (zoom_to_window(win, margin=0.02))。纯查结构化数据的路径不受影响。 3. checklist.build_system_prompt:把「可调用只读工具」改写成明确的放大取证协议—— 截图看不清时用 zoom_to_window(坐标由图框/实体 bbox 算)或 zoom_to_entity, 再 get_viewport_screenshot 复核;并约束不要为看图而反复放大。 次生问题(同轮修复):_MAX_OBS_IMAGES 原为硬编码 3,而模型一轮内会发 8 次截图 → 拍了 8 张只回灌 3 张。改为配置项 REVIEW_OBS_IMAGES(默认 6),并把预算耗尽的占位 文案改成明确告知模型「不要再请求截图,请给出结论」。 实测(真实图纸 1 图框,新增 verify_review_maximized.py):取证与审查两阶段窗口均为 [-4,-2,3444,1442](最大化态),结束精确还原 [68,0,2316,960],三项全 PASS; 子循环里 LLM 连发 8 组 zoom_to_window + get_viewport_screenshot,提示词生效。 pytest 304 passed。
195 lines
7.2 KiB
Python
195 lines
7.2 KiB
Python
"""CLI 入口:python -m dwg_review [options]"""
|
||
|
||
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 .annotator import annotate, clear
|
||
from .checklist import build_system_prompt, checklist_summary
|
||
from .context import ReviewContext
|
||
from .evidence_model import 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):
|
||
print("=== 图纸审查 Agent ===")
|
||
print(f"清单:{checklist_summary(ctx.checklist)}")
|
||
if ctx.llm is not None:
|
||
print(f"模型:{ctx.llm.model}")
|
||
print()
|
||
|
||
print("[1/4] 规划:识别图框与概览 ...")
|
||
plan = planning.plan(ctx)
|
||
frames = plan.get("frames", [])
|
||
print(f" 识别到 {len(frames)} 个图框")
|
||
if not frames:
|
||
print("未识别到任何图框,退出。")
|
||
return 1
|
||
|
||
system_prompt = build_system_prompt(ctx.checklist, allow_tools=True)
|
||
|
||
evidences = []
|
||
frame_findings = []
|
||
|
||
# 截屏前把绘图区最大化(窗口铺满 + 干净屏幕)。**取证与审查都要截图**——审查阶段的
|
||
# 受控子循环会按需放大复核,故一并包住,结束在 finally 里还原;旧版插件无此工具时静默降级。
|
||
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')}"
|
||
)
|
||
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 args.plan_only:
|
||
path = _dump_evidence(evidences)
|
||
print(f"[evidence] 已写入 {path}")
|
||
return 0
|
||
|
||
print("[3/4] 审查:确定性检查前置 + 逐图框 LLM 审查(含受控只读取证子循环) ...")
|
||
for ev in evidences:
|
||
print(f" - 审查图框 {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()
|
||
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="CAD 图纸审查 Agent(基于 cad_mcp_frame MCP 基座)"
|
||
)
|
||
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 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
|
||
)
|
||
try:
|
||
ctx = ReviewContext.create(need_llm=need_llm)
|
||
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
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|