From b757892c67bf7465c3d78b583b757485e1908a44 Mon Sep 17 00:00:00 2001 From: LittleGuo Date: Fri, 16 Oct 2026 19:15:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(review):=20agent=20=E4=BE=A7=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E5=B7=A5=E5=85=B7=20inspect=5Fregion=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=8C=89=E5=8F=AF=E8=AF=BB=E5=88=86=E8=BE=A8?= =?UTF-8?q?=E7=8E=87=E6=94=BE=E5=A4=A7=E6=8C=87=E5=AE=9A=E5=8C=BA=E5=9F=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解决三个缺口:① 放大没复用标定/瓦片数学(LLM 一次 zoom 只出一张,宽区域仍读不清); ②「看清没」由 VLM 自评(易过度自信/幻觉);③ 只能按 handle / 手算 bbox 指认, 图上看到但没 handle 的东西指认不了。 核心思路:把「放大」升级成一个语义化工具——LLM 只指认「要看哪里」,由 agent 复用 tiling.py 的标定数学算出该切几张、每张覆盖多大,用构造保证可读。 新增 dwg_review/inspect.py(agent 侧,不编 DLL): - TOOL_SCHEMA:inspect_region(target, purpose);target 三形态 {handle} / {window} / {label,cx,cy,span},purpose = text(8px) / line(6px)。 - InspectState:单图框内「截图标签 → 世界可见范围」登记表,支持逐级下钻 (返回的 inspect-N 可继续作为 label 指认)。 - resolve_target / _point_box:label+cx+cy → WCS。cy 是图像坐标(上→下), 世界 y 必须翻转(y = y2 - cy*H)——单测专门钉住。 - plan_tiles:复用 world_per_px + tiles_for_box + subdivide;超 INSPECT_MAX_TILES(4) 则硬失败并给出「缩小 target / 分多次请求」的指引(不静默降级);拿不到比例/像素 则退回单张并如实告知。 关键几何:CAD 的 _.ZOOM _W 是等比适配(不拉伸),故「截图上的归一化坐标」必须按 max(W/ew, H/eh) 反算真实可见范围(新增 tiling.visible_box)——直接用送进去的窗口会偏。 zoom_to_window 返回的 view 是「加过 margin 的窗口」,不是可见范围,不能混用。 证据层配套:evidence_model.make_image(..., window=) 记录每张图的可见世界范围; capture_images 用 view_px(overall 图原始像素)+ visible_box 逐张登记。 子循环接入:_run_readonly_tool 在只读白名单校验之前拦截 inspect_region(它不在 READ_ONLY_TOOLS 里,内部只调白名单内的只读 MCP 工具);_review_tools 启用时用 inspect_region 取代三个裸视口工具;inspect_region 计入 _VIEW_TOOLS → 结束复位视口。 配置:INSPECT_ENABLED(True) / INSPECT_MAX_TILES(4)。 端到端实测(真实图纸 1 图框):取证与审查两阶段均在最大化态、结束精确还原(4 项 PASS); LLM 的 11 次取证调用里 2 次是 inspect_region,且都显式给 window + purpose:"text" (x[20000,115000] y[-6500,3000],即 A0 图框底部横条=标题栏所在)。 验收口径达成:报告报出标题栏乱码 ???????????????,而 get_text_content 对全图返回 0 条文字 (本图文字全在块/自定义实体内)→ 块内文字是被视觉放大读出来的。 pytest 343 passed。 --- CODEBUDDY.md | 2 +- ai.memory/project_agent_roadmap.md | 58 ++++- ai.memory/reference_environment.md | 2 +- dwg_review/checklist.py | 22 +- dwg_review/config.py | 5 + dwg_review/evidence_model.py | 17 +- dwg_review/inspect.py | 288 ++++++++++++++++++++++++ dwg_review/phases/evidence.py | 32 ++- dwg_review/phases/review.py | 43 +++- dwg_review/tiling.py | 60 +++++ tests/test_checklist.py | 13 +- tests/test_config.py | 6 + tests/test_evidence.py | 10 +- tests/test_evidence_model.py | 2 +- tests/test_inspect.py | 348 +++++++++++++++++++++++++++++ tests/test_review.py | 61 +++++ tests/test_tiling.py | 59 +++++ verify_review_maximized.py | 20 +- 18 files changed, 1003 insertions(+), 45 deletions(-) create mode 100644 dwg_review/inspect.py create mode 100644 tests/test_inspect.py diff --git a/CODEBUDDY.md b/CODEBUDDY.md index 8c60c6e..583a6b0 100644 --- a/CODEBUDDY.md +++ b/CODEBUDDY.md @@ -51,7 +51,7 @@ python verify_mcp_fault_injection.py # 工具异常保护(类型错误不再 python verify_stdio_bridge.py # stdio ↔ TCP 桥(协议协商 / 转发 / 通知) python verify_annotate.py # 批注回写链路 python verify_drawing_area.py # 绘图区最大化:基线 → max → restore 全回位(实测 3.68x) -python verify_review_maximized.py # 审查全链路:取证+审查两阶段均在最大化态、结束精确还原 +python verify_review_maximized.py # 审查全链路:取证+审查两阶段均在最大化态、结束精确还原;记录 LLM 取证工具调用 ``` 这些脚本通过 TCP 连接 `127.0.0.1:8080` 收发 JSON-RPC。 diff --git a/ai.memory/project_agent_roadmap.md b/ai.memory/project_agent_roadmap.md index 99738aa..61e5d68 100644 --- a/ai.memory/project_agent_roadmap.md +++ b/ai.memory/project_agent_roadmap.md @@ -1,6 +1,6 @@ --- name: Agent 路线与多源架构(2026-10-15) -description: dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、多源证据层、截图去 UI、grounding 实测、热力图 + 自适应瓦片规划 + VLM 分辨率标定、绘图区最大化(set_drawing_area)与空瓦片过滤、审查阶段分辨率对齐 + 放大取证闭环、后续路线 +description: dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、多源证据层、截图去 UI、grounding 实测、热力图 + 自适应瓦片规划 + VLM 分辨率标定、绘图区最大化(set_drawing_area)与空瓦片过滤、审查阶段分辨率对齐、agent 侧虚拟工具 inspect_region(放大取证闭环)、后续路线 type: project --- @@ -72,13 +72,9 @@ Agent 推理层(审查逻辑 / 清单 / 规则) ## 后续路线(未做) -1. **VLM 主动放大(自适应取证)**:复用受控只读取证子循环,让 VLM 请求放大看不清处。 - - **2026-10-16 部分落地**:审查阶段已对齐分辨率 + 提示词已明确放大协议 + 视口复位, - 实测 LLM 真的会连发 `zoom_to_window`+`get_viewport_screenshot`(见下节)。 - 仍缺:① 按区域放大时的 **grounding→WCS 映射**(目前靠 LLM 自己用 bbox 算窗口,够用); - ② 把「看清没」的判定**不交给 VLM**(防过度自信)——现靠回合上限 + 截图预算兜底。 - - 现有 `zoom_to_window`/`zoom_to_entity`/`get_viewport_screenshot` 已在只读白名单, - **无需新增工具**。 +1. **VLM 主动放大(自适应取证)**:**2026-10-16 已落地**(见下节 `inspect_region`)。 + 剩余可选打磨:把「看清没」的判定进一步形式化(目前靠 agent 用标定数学**构造保证**可读 + + 回合上限 + 截图预算兜底),以及 grounding→WCS 之外的其它指认方式。 2. **PDF / 图片源**:在 `phases/evidence.collect()` 加分支,产出**同形** evidence (`window=None`、结构化空信封、`images` 带 label);规划阶段抽象"审查单元" (CAD=图框 / PDF=页 / 图片=单);批注对非 CAD 源降级(`annotator` 已天然跳过无 handle 的 issue)。 @@ -202,3 +198,49 @@ LLM 在受控子循环里 zoom + 截图拿到的是**未最大化的 2110×462** → **拍了 8 张只回灌 3 张**(其余被"已省略"占位)。改为配置项 `REVIEW_OBS_IMAGES` (默认 **6**,`config.py`),并把预算耗尽时的占位文案改成**明确告知模型"不要再请求截图, 请给出结论"**(原先只说"已省略")。实测模型截图次数不变(8),但**能看到 6 张而非 3 张**。 + +## A 收尾:agent 侧虚拟工具 `inspect_region`(2026-10-16) + +**要解决的三个缺口**:① 放大**没复用标定/瓦片数学**(LLM 一次 zoom 只出一张,宽区域仍读不清); +② 「看清没」由 **VLM 自评**(易过度自信/幻觉);③ 只能按 handle / 手算 bbox 指认, +**图上看到但没 handle** 的东西指认不了。 + +**核心思路**:把「放大」升级成一个**语义化工具**——LLM 只指认「要看哪里」, +由 agent 复用 `tiling.py` 的标定数学算出该切几张、每张覆盖多大,**用构造保证可读**。 + +**新增 `dwg_review/inspect.py`**(agent 侧,**不编 DLL**): + +- `TOOL_SCHEMA`:`inspect_region(target, purpose)`。`target` 三形态任选其一—— + `{handle}` / `{window}` / `{label, cx, cy, span}`;`purpose` = `text`(8px) / `line`(6px)。 +- `InspectState`:单图框内的「截图标签 → **世界可见范围**」登记表。初始登记取证阶段的 + `overall`/`tile-*`,每次放大再追加 `inspect-1`、`inspect-2`… → **LLM 可用上一轮返回的标签 + 继续下钻**(逐级放大)。 +- `resolve_target` / `_point_box`:`label+cx+cy` → WCS。**cy 是图像坐标(上→下), + 世界 y 必须翻转**(`y = y2 - cy*H`)——最容易写错的一处,单测专门钉住。 +- `plan_tiles`:复用 `tiling.world_per_px` + `tiles_for_box` + `subdivide`。 + 超出 `INSPECT_MAX_TILES`(默认 4)→ **硬失败**并给出「缩小 target / 分多次请求」的具体指引 + (不静默降级,否则等于给一堆读不清的图);拿不到比例/像素 → 退回单张并如实告知。 + +**关键几何:`tiling.visible_box`**——CAD 的 `_.ZOOM _W` 是**等比适配(不拉伸)**, +指定窗口按视口宽高比放大到刚好装下,多余一维露出更多内容。所以「截图上的归一化坐标」 +要准确映射回 WCS,必须按 `max(W/ew, H/eh)` 反算真实可见范围,**不能**直接用送进去的窗口。 +(`zoom_to_window` 返回的 `view` 是**加过 margin 的窗口**,不是可见范围,别混用。) + +**证据层配套**:`evidence_model.make_image(..., window=)` 记录每张图的可见世界范围; +`capture_images` 用 `view_px`(overall 图的原始像素)+ `visible_box` 逐张登记。 + +**子循环接入**(`phases/review.py`):`_run_readonly_tool` 在**只读白名单校验之前**拦截 +`inspect_region`(它不在 `READ_ONLY_TOOLS` 里,内部只调白名单内的只读 MCP 工具); +`_review_tools` 启用时用 `inspect_region` **取代**三个裸视口工具(`zoom_to_window` / +`zoom_to_entity` / `get_viewport_screenshot`)——一个语义化入口比三个裸工具更好用; +`inspect_region` 计入 `_VIEW_TOOLS` → 结束时复位视口。 +配置:`INSPECT_ENABLED`(True) / `INSPECT_MAX_TILES`(4)。 + +**端到端实测(真实图纸 1 图框,`verify_review_maximized.py`)**: +- 取证与审查两阶段均在最大化态、结束精确还原 → 4 项全 PASS。 +- LLM 的 11 次取证调用里 **2 次是 `inspect_region`**,且**都显式给了 `window` + `purpose:"text"`**: + `x[20000,115000] y[-6500,3000]` 与 `x[-4000,30000] y[-6500,3000]`——A0 图框 + (`y[-6434,77666]`)的**底部横条**,即**标题栏**所在。模型自己把范围收窄到横条再放大。 +- **验收口径达成**:报告报出标题栏**乱码/占位字符 `???????????????`**, + 而 `get_text_content` 对全图返回 **0 条文字**(本图文字全在块/自定义实体内)—— + 即**块内文字是被视觉放大读出来的**,不是结构化数据给的。 diff --git a/ai.memory/reference_environment.md b/ai.memory/reference_environment.md index 62514b5..f881204 100644 --- a/ai.memory/reference_environment.md +++ b/ai.memory/reference_environment.md @@ -47,7 +47,7 @@ type: reference | `verify_vlm_resolution_offline.py` | **离线**(纯 PIL 合成)标定 VLM 读图下限 `R_text`/`R_line`,`--dry-run` 只出图 | | `verify_text_resolution.py` | **CAD 实测**标定(临时加字 + 平行线,测完 `delete_entities` 清理);`--keep` 保留 | | `verify_drawing_area.py` | 绘图区最大化验收:基线 → `set_drawing_area(max)` → `restore`,断言窗口矩形/最大化态/绘图区尺寸全回位(实测 3.68×) | -| `verify_review_maximized.py` | 审查全链路验收:在 `collect`/`review_frame` 入口各采一次窗口矩形,断言**取证与审查两阶段都在最大化态**、结束精确还原;并统计子循环里的 zoom/截图调用次数 | +| `verify_review_maximized.py` | 审查全链路验收:在 `collect`/`review_frame` 入口各采一次窗口矩形,断言**取证与审查两阶段都在最大化态**、结束精确还原;并统计子循环 zoom/截图次数 + 记录 LLM 实际调用的取证工具(含虚拟工具 `inspect_region`) | > 历史:根目录 `test.py` / `test_autoagent.py` / `countOfCircle.py` / `agent_loop.py` / > `dwg_review_agent.py` 五个遗留调试脚本已于 2026-10-14 删除(前三个含硬编码 API Key, diff --git a/dwg_review/checklist.py b/dwg_review/checklist.py index d541043..ce8cb7b 100644 --- a/dwg_review/checklist.py +++ b/dwg_review/checklist.py @@ -53,13 +53,21 @@ def build_system_prompt(checklist, *, allow_tools=True): if allow_tools: lines += [ - "你可以调用**只读**取证工具进一步确认细节;**不要凭猜测下结论**:", - " - **截图里的文字/标注/细节看不清时**:用 `zoom_to_window` 放大该区域" - "(窗口坐标用图框 bbox 或实体 bbox 计算,可适当留边距)," - "再 `get_viewport_screenshot` 复核;已知 handle 时也可直接 `zoom_to_entity` 放大该实体。", - " - 结构化数据不足以判断时:用 `get_entity_detail` / `find_duplicate_entities` /" - " `find_zero_length` / `get_layers` 等查证后再下结论。", - " - 放大截图是**复核手段**:确认清楚后即写入结论,不要为看图而反复放大。", + "你可以调用**只读**取证工具进一步确认细节;**不要凭猜测下结论**。", + "", + "**看不清某处时,调用 `inspect_region` 指认位置**,它会自动按可读分辨率放大并返回清晰截图。", + "`target` 三种指认方式任选其一:", + ' - `{"handle": "3A4"}` —— 已知实体句柄;', + ' - `{"label": "tile-3", "cx": 0.4, "cy": 0.6, "span": 0.2}` —— 某张已给截图上的一个位置', + " (`cx` 从左到右、`cy` **从上到下**,均为 0~1 归一化;`span` 为要看的范围占该截图的比例,默认 0.25);", + ' - `{"window": {"min_x": …, "min_y": …, "max_x": …, "max_y": …}}` —— 直接给世界坐标框。', + '`purpose` 选 `"text"`(要读文字/标注,默认)或 `"line"`(只看走向/连接,覆盖更广)。', + "返回的截图会带 `inspect-N` 标签,可继续作为 `label` 指认更细的位置(逐级下钻)。", + "**你不需要判断截图够不够清楚**——`inspect_region` 会保证返回的图达到可读分辨率;", + "若它提示区域过大,请缩小 `target`(或用 `span` 缩小范围)后重试。", + "", + "结构化数据不足以判断时:用 `get_entity_detail` / `find_duplicate_entities` /", + "`find_zero_length` / `get_layers` 等查证后再下结论。", "", ] diff --git a/dwg_review/config.py b/dwg_review/config.py index c891b61..2f15e6e 100644 --- a/dwg_review/config.py +++ b/dwg_review/config.py @@ -94,6 +94,11 @@ REVIEW_MAX_ITERS = int(os.environ.get("REVIEW_MAX_ITERS", "4")) # 实测:提示词鼓励「看不清→放大重看」后,模型一轮内会发多次 zoom+截图, # 上限过小会让它白拍(拍了却看不到)。6 可覆盖多数复核需求。 REVIEW_OBS_IMAGES = int(os.environ.get("REVIEW_OBS_IMAGES", "6")) +# 是否启用 agent 侧虚拟工具 inspect_region(按可读分辨率放大指定区域)。 +# 关闭则退回「LLM 自己组合 zoom_to_window + get_viewport_screenshot」的旧行为。 +INSPECT_ENABLED = _read_bool("INSPECT_ENABLED", True) +# inspect_region 单次请求最多返回几张瓦片(超出则中止并要求缩小 target) +INSPECT_MAX_TILES = int(os.environ.get("INSPECT_MAX_TILES", "4")) # 报告输出目录 REPORT_DIR = _resolve_path(os.environ.get("REPORT_DIR", "reports")) # 批注回写使用的图层 diff --git a/dwg_review/evidence_model.py b/dwg_review/evidence_model.py index f0cad13..42e6c22 100644 --- a/dwg_review/evidence_model.py +++ b/dwg_review/evidence_model.py @@ -16,9 +16,16 @@ SOURCE_IMAGE = "image" STRUCTURED_SOURCES = ("entities", "texts", "dimensions", "blocks", "polylines") -def make_image(b64, mime="image/png", label=None): - """构造 images 列表元素。""" - return {"b64": b64, "mime": mime or "image/png", "label": label} +def make_image(b64, mime="image/png", label=None, window=None): + """构造 images 列表元素。 + + `window` 为该截图**实际可见的世界范围** `(min_x,min_y,max_x,max_y)`(可选)。 + 审查阶段的 `inspect_region` 靠它把「截图上的归一化坐标」映射回 WCS(逐级下钻)。 + """ + img = {"b64": b64, "mime": mime or "image/png", "label": label} + if window is not None: + img["window"] = window + return img def source_type_of(container): @@ -37,7 +44,7 @@ def evidence_images(evidence): """规范化证据里的图片列表(单点归一)。 优先读 `images`;否则回退旧的 `image_b64` / `image_mime` 单图字段。 - 统一返回 `[{"b64","mime","label"}]`,过滤掉空 b64。 + 统一返回 `[{"b64","mime","label","window"}]`,过滤掉空 b64。 """ if not isinstance(evidence, dict): return [] @@ -55,6 +62,7 @@ def evidence_images(evidence): "b64": b64, "mime": item.get("mime") or "image/png", "label": item.get("label"), + "window": item.get("window"), } ) if images: @@ -67,6 +75,7 @@ def evidence_images(evidence): "b64": b64, "mime": evidence.get("image_mime") or "image/png", "label": None, + "window": None, } ] return [] diff --git a/dwg_review/inspect.py b/dwg_review/inspect.py new file mode 100644 index 0000000..1bc12db --- /dev/null +++ b/dwg_review/inspect.py @@ -0,0 +1,288 @@ +"""agent 侧虚拟工具 `inspect_region`:按**可读分辨率**放大指定区域。 + +动机(A 阶段收尾): + 让 LLM 自己组合 `zoom_to_window` + `get_viewport_screenshot` 时—— + ① 单张截图可能仍读不清(宽区域下采样后字太小); + ② 「看清没」只能由 VLM 自评(易过度自信 / 幻觉)。 + 本模块把「放大」升级为**一个语义化工具**:LLM 只指认「要看哪里」,由 agent 复用 + `tiling.py` 的标定数学算出该切几张、每张覆盖多大,**用构造保证可读**。 + +不变量: + - 只调用**白名单内**的只读 MCP 工具(`zoom_to_window` / `get_viewport_screenshot`), + 不写库、不改图元、不动图层; + - 会改动视口 → 调用方(`phases/review.py`)负责在该图框审查结束后复位。 + +坐标约定: + - 世界坐标:x 向右、y **向上**; + - 截图内归一化坐标:cx 左→右、cy **上→下**(图像坐标,左上原点)—— + `_point_box` 会翻转 y,这是最容易写错的一处。 +""" + +from . import config, evidence_model, tiling +from .llm_client import LlmClient, downsample_image_b64, image_size_b64 +from .phases.evidence import zoom_and_shot + +TOOL_NAME = "inspect_region" + +# zoom 时四周留边比例(与 `evidence.zoom_and_shot` 的默认 margin 一致) +_ZOOM_MARGIN = 0.02 + +# 送进瓦片规划前先把目标框外扩一点,避免把边界上的字切掉 +_EDGE_PAD = 0.05 + +TOOL_SCHEMA = { + "type": "function", + "function": { + "name": TOOL_NAME, + "description": ( + "放大并返回指定区域的**可读分辨率**截图。当你看不清已给截图里的文字/标注/细节时调用;" + "会自动计算需要几张、每张覆盖多大,**无需你判断清晰度**。" + "target 三选一:handle(实体句柄)/ window(世界坐标框)/ " + "label+cx+cy(某张已给截图上的归一化位置)。" + ), + "parameters": { + "type": "object", + "properties": { + "target": { + "type": "object", + "description": "要放大的位置(三种形态任选其一)", + "properties": { + "handle": { + "type": "string", + "description": '实体句柄,如 "3A4"', + }, + "window": { + "type": "object", + "description": "世界坐标框", + "properties": { + "min_x": {"type": "number"}, + "min_y": {"type": "number"}, + "max_x": {"type": "number"}, + "max_y": {"type": "number"}, + }, + "required": ["min_x", "min_y", "max_x", "max_y"], + }, + "label": { + "type": "string", + "description": '已给截图的标签,如 "tile-3" 或之前返回的 "inspect-1"', + }, + "cx": { + "type": "number", + "description": "该截图内的归一化横坐标 0~1(左→右)", + }, + "cy": { + "type": "number", + "description": "该截图内的归一化纵坐标 0~1(上→下)", + }, + "span": { + "type": "number", + "description": "以该点为中心的视野跨度(占该截图宽/高的比例),默认 0.25", + }, + }, + }, + "purpose": { + "type": "string", + "enum": ["text", "line"], + "description": "放大目的:text=读文字/标注(默认,更细);line=看走向/连接(更粗)", + }, + }, + "required": ["target"], + }, + }, +} + + +class InspectState: + """单图框内的「截图标签 → 世界可见范围」登记表。 + + 初始登记取证阶段给的 `overall` / `tile-*`(它们的 `window` 字段), + 之后每次 `inspect_region` 的新截图再追加 `inspect-1`、`inspect-2`…, + 于是 LLM 可以用**上一轮返回的标签**继续下钻。 + """ + + def __init__(self, evidence): + self.labels = {} + for img in evidence_model.evidence_images(evidence): + label = img.get("label") + box = tiling.norm_box(img.get("window")) + if label and box is not None: + self.labels[label] = box + self.seq = 0 + + def register(self, box): + """登记一张新截图,返回它的标签。""" + self.seq += 1 + label = f"inspect-{self.seq}" + self.labels[label] = box + return label + + +def _text(s): + return {"type": "text", "text": s} + + +def readable_px(purpose): + """按用途取 VLM 可读下限(像素)。""" + return config.TEXT_READABLE_PX if purpose == "text" else config.LINE_READABLE_PX + + +def _view_px(evidence): + """绘图区**原始像素**(取 overall 图;无图/无 Pillow → None)。""" + img = evidence_model.primary_image(evidence) + if not img: + return None + return image_size_b64(img.get("b64")) + + +def _bbox_for_handle(evidence, handle): + """在已采集的结构化证据里按 handle 找 bbox;找不到返回 None。""" + want = handle.strip().upper() + for key in evidence_model.STRUCTURED_SOURCES: + env = evidence.get(key) + items = env.get("items") if isinstance(env, dict) else None + for item in items or []: + if not isinstance(item, dict): + continue + got = item.get("handle") + if isinstance(got, str) and got.strip().upper() == want: + box = tiling.norm_box(item.get("bbox")) + if box is not None: + return box + return None + + +def _point_box(base, cx, cy, span): + """截图内归一化点 → 世界框。 + + `cx` 左→右;`cy` **上→下**(图像坐标),故世界 y 需翻转:`y = y2 - cy * H`。 + """ + x1, y1, x2, y2 = base + w, h = x2 - x1, y2 - y1 + cx = min(max(float(cx), 0.0), 1.0) + cy = min(max(float(cy), 0.0), 1.0) + span = min(max(float(span), 0.02), 1.0) + px = x1 + cx * w + py = y2 - cy * h + hw, hh = w * span / 2.0, h * span / 2.0 + return (px - hw, py - hh, px + hw, py + hh) + + +def resolve_target(evidence, target, state): + """解析 `target` → `(世界框, 人类可读说明)`;失败返回 `(None, 错误说明)`。""" + if not isinstance(target, dict): + return None, "target 必须是对象" + + handle = target.get("handle") + if isinstance(handle, str) and handle.strip(): + box = _bbox_for_handle(evidence, handle) + if box is None: + return None, ( + f"未在已采集证据里找到 handle {handle} 的包围盒;" + f"请改用 window 或 label 指认位置" + ) + return box, f"实体 {handle} 的包围盒" + + if target.get("window") is not None: + box = tiling.window_to_box(target.get("window")) + if box is None: + return None, "window 非法:需要 min_x/min_y/max_x/max_y 四个数值" + return box, "给定的世界坐标框" + + label = target.get("label") + if isinstance(label, str) and label.strip(): + label = label.strip() + base = state.labels.get(label) + if base is None: + known = "、".join(sorted(state.labels)) or "(无)" + return None, f"未知截图标签 {label};当前可用:{known}" + try: + cx = float(target.get("cx", 0.5)) + cy = float(target.get("cy", 0.5)) + span = float(target.get("span", 0.25)) + except (TypeError, ValueError): + return None, "cx / cy / span 必须是数值" + return ( + _point_box(base, cx, cy, span), + f"截图 {label} 上 ({cx:.2f}, {cy:.2f}) 附近", + ) + + return None, "target 需给出 handle / window / label 之一" + + +def plan_tiles(box, scale, purpose, view_px, *, max_tiles, overlap, margin): + """把目标框规划成若干 zoom 窗口。 + + 返回 `(windows, note)`;`windows is None` 表示**硬失败**(区域过大,已中止)。 + 拿不到比例 / 绘图区像素时**退回单张**并如实告知(无法保证可读)。 + """ + s = tiling.world_per_px(scale, config.TEXT_HEIGHT_MM, readable_px(purpose)) + eff = tiling.effective_px(view_px, config.IMAGE_MAX_EDGE) + padded = tiling.expand_box(box, _EDGE_PAD) + if padded is None or padded[2] <= padded[0] or padded[3] <= padded[1]: + return None, "目标区域退化(宽或高为 0),无法放大" + + if s <= 0 or eff[0] <= 0 or eff[1] <= 0: + win = tiling.box_to_window(padded) + return [win], "(拿不到出图比例或绘图区像素,已退回单张;文字可能仍读不清)" + + cols, rows = tiling.tiles_for_box(padded, s * eff[0], s * eff[1], overlap) + need = cols * rows + if need > max_tiles: + return None, ( + f"该区域按可读分辨率需 {need} 张截图,超过单次上限 {max_tiles};" + f"请缩小 target(例如用 label + cx + cy + span 指定更小范围)或分多次请求" + ) + wins = tiling.subdivide(padded, cols, rows, overlap, frame_win=None) + if not wins: + return None, "目标区域退化,无法放大" + return wins, f"按 {purpose} 判据切为 {cols}×{rows} = {need} 张" + + +def inspect_region(ctx, evidence, args, image_budget, state): + """执行一次放大取证,返回 `(content, image_budget)`。""" + args = args if isinstance(args, dict) else {} + purpose = str(args.get("purpose") or "text").strip().lower() + if purpose not in ("text", "line"): + purpose = "text" + + box, note = resolve_target(evidence, args.get("target"), state) + if box is None: + return [_text(f"inspect_region 未执行:{note}")], image_budget + + view_px = _view_px(evidence) + scale = tiling.frame_scale(evidence.get("frame")) + wins, plan_note = plan_tiles( + box, scale, purpose, view_px, + max_tiles=config.INSPECT_MAX_TILES, + overlap=config.TILE_OVERLAP, + margin=_ZOOM_MARGIN, + ) + if wins is None: + return [_text(f"inspect_region 未执行:{plan_note}")], image_budget + + content = [_text(f"inspect_region:{note};{plan_note}。")] + taken = 0 + for i, win in enumerate(wins, 1): + if image_budget[0] <= 0: + content.append(_text( + f"(截图预算已用完,剩余 {len(wins) - i + 1} 张未拍;" + f"请基于已有信息给出结论,不要再请求截图。)" + )) + break + shot = zoom_and_shot(ctx, win, margin=_ZOOM_MARGIN) + if not shot: + continue + image_budget[0] -= 1 + visible = tiling.visible_box(tiling.window_to_box(win), view_px, _ZOOM_MARGIN) + label = state.register(visible) + content.append(_text( + f"(放大截图 {label}:可见世界范围 " + f"x[{visible[0]:.1f}, {visible[2]:.1f}] y[{visible[1]:.1f}, {visible[3]:.1f}])" + )) + b64, mime = downsample_image_b64(shot[0], shot[1]) + content.append(LlmClient.image_content(b64, mime)) + taken += 1 + + if taken == 0: + content.append(_text("(未能取得截图。)")) + return content, image_budget diff --git a/dwg_review/phases/evidence.py b/dwg_review/phases/evidence.py index 735381a..55d471b 100644 --- a/dwg_review/phases/evidence.py +++ b/dwg_review/phases/evidence.py @@ -20,6 +20,9 @@ _LABEL_OVERALL = "overall" # 绘图区最大化用的工具名(旧版插件没有此工具 → 静默降级) _TOOL_DRAWING_AREA = "set_drawing_area" +# zoom 时四周留边比例(与 `zoom_to_window` 的默认 margin 一致) +_ZOOM_MARGIN = 0.02 + @contextlib.contextmanager def maximized_drawing_area(ctx): @@ -95,7 +98,7 @@ def sub_windows(window, tiles): return out -def _zoom_and_shot(ctx, window, margin=0.02): +def zoom_and_shot(ctx, window, margin=_ZOOM_MARGIN): """缩放到窗口、等待渲染、截一张图,返回 (b64, mime) 或 None。""" ctx.tools.call("zoom_to_window", dict(window, margin=margin)) time.sleep(config.RENDER_WAIT) @@ -209,6 +212,8 @@ def capture_images(ctx, window, *, tiles=0, max_images=None, frame=None, - 局部窗口优先由 `_plan_tiles` 按**实体分布**给出(空白区不出图); 不可用时退回 `sub_windows(window, tiles)`,**同样按内容过滤**空白象限 (过滤后为空则退回未过滤集合,保证不漏拍)。 + - 每张图登记其**实际可见世界范围**(`window` 字段)——审查阶段的 `inspect_region` + 靠它把「截图上的归一化坐标」映射回 WCS(逐级下钻)。 - 结束时**复位到整体视口**,保证后续流程从整幅视图起手。 - 受 max_images(默认 config.EVIDENCE_MAX_IMAGES)截断。 """ @@ -216,14 +221,23 @@ def capture_images(ctx, window, *, tiles=0, max_images=None, frame=None, max_images = config.EVIDENCE_MAX_IMAGES images = [] - def _add(shot, label): - if shot and len(images) < max_images: - images.append(evidence_model.make_image(shot[0], shot[1], label)) - # 整体图:既是证据,也用于测量绘图区**实际像素**(决定单张截图的世界覆盖范围) - overall = _zoom_and_shot(ctx, window) + overall = zoom_and_shot(ctx, window) view_px = image_size_b64(overall[0]) if overall else None - _add(overall, _LABEL_OVERALL) + + def _add(shot, label, win): + """登记截图;`win` 为送给 CAD 的窗口,用于反算实际可见世界范围。""" + if shot and len(images) < max_images: + images.append( + evidence_model.make_image( + shot[0], shot[1], label, + window=tiling.visible_box( + tiling.window_to_box(win), view_px, _ZOOM_MARGIN + ), + ) + ) + + _add(overall, _LABEL_OVERALL, window) # 内容过滤:空白区不出图。按**实体 bbox** 判定,规划与回退两条路径共用同一谓词; # 实体数据被截断时不可信,放弃过滤(宁可多拍也不漏)。 @@ -237,10 +251,10 @@ def capture_images(ctx, window, *, tiles=0, max_images=None, frame=None, for i, sub in enumerate(regions, 1): if len(images) >= max_images: break - _add(_zoom_and_shot(ctx, sub), f"tile-{i}") + _add(zoom_and_shot(ctx, sub), f"tile-{i}", sub) # 复位整体视口(不截图) - ctx.tools.call("zoom_to_window", dict(window, margin=0.02)) + ctx.tools.call("zoom_to_window", dict(window, margin=_ZOOM_MARGIN)) return images diff --git a/dwg_review/phases/review.py b/dwg_review/phases/review.py index 055d710..59d151b 100644 --- a/dwg_review/phases/review.py +++ b/dwg_review/phases/review.py @@ -14,7 +14,7 @@ import json -from .. import config, evidence_model +from .. import config, evidence_model, inspect from ..cad_tools import READ_ONLY_TOOLS, is_write_tool from ..findings import parse_conclusion from ..llm_client import LlmClient, downsample_image_b64 @@ -24,7 +24,13 @@ from ..mcp_client import McpError, McpToolError, result_images, result_texts _MAX_OBS_IMAGES = config.REVIEW_OBS_IMAGES # 会改动视口的只读取证工具:用过之后须把视口复位,别把用户的 CAD 留在放大处 -_VIEW_TOOLS = frozenset({"zoom_to_window", "zoom_to_entity"}) +_VIEW_TOOLS = frozenset({"zoom_to_window", "zoom_to_entity", inspect.TOOL_NAME}) + +# 启用 inspect_region 时,这三个裸视口工具不再直接提供给 LLM(由 inspect_region 统一代理), +# 但仍留在只读白名单里,作为兜底仍然可被调用。 +_RAW_VIEW_TOOLS = frozenset( + {"zoom_to_window", "zoom_to_entity", "get_viewport_screenshot"} +) def evidence_payload(evidence): @@ -45,8 +51,17 @@ def evidence_payload(evidence): } -def _run_readonly_tool(ctx, name, args, image_budget): - """执行一个只读取证工具,返回回灌给 LLM 的 content 列表。""" +def _run_readonly_tool(ctx, name, args, image_budget, *, evidence=None, state=None): + """执行一个只读取证工具,返回回灌给 LLM 的 content 列表。 + + `inspect_region` 是 **agent 侧虚拟工具**(不走 MCP),在只读白名单校验**之前**拦截: + 它内部只调白名单内的只读 MCP 工具。 + """ + if name == inspect.TOOL_NAME: + if not config.INSPECT_ENABLED or evidence is None or state is None: + return [{"type": "text", "text": "inspect_region 当前不可用。"}], image_budget + return inspect.inspect_region(ctx, evidence, args, image_budget, state) + if name not in READ_ONLY_TOOLS or is_write_tool(name): return [{"type": "text", "text": f"工具 {name} 不在只读取证白名单内,已拒绝。"}], image_budget @@ -75,6 +90,19 @@ def _run_readonly_tool(ctx, name, args, image_budget): return content, image_budget +def _review_tools(ctx): + """审查子循环提供给 LLM 的工具 schema。 + + 启用 `inspect_region` 时,用它**取代**三个裸视口工具(`zoom_to_window` / + `zoom_to_entity` / `get_viewport_screenshot`)——一个语义化入口比三个裸工具更好用, + 且可读性由 agent 保证;关闭时维持原样(退回旧行为)。 + """ + if config.INSPECT_ENABLED: + names = READ_ONLY_TOOLS - _RAW_VIEW_TOOLS + return ctx.tools.openai_tools(names=names) + [inspect.TOOL_SCHEMA] + return ctx.tools.openai_tools(names=READ_ONLY_TOOLS) + + def _reset_viewport(ctx, evidence): """把视口复位到该图框的整幅视图(审查中的放大不应残留给用户)。""" win = evidence.get("window") @@ -121,8 +149,9 @@ def _review_frame(ctx, evidence, system_prompt, log, view_changed): {"role": "user", "content": user_content}, ] - tools_schema = ctx.tools.openai_tools(names=READ_ONLY_TOOLS) + tools_schema = _review_tools(ctx) image_budget = [_MAX_OBS_IMAGES] + state = inspect.InspectState(evidence) for _ in range(max(0, ctx.max_iters)): resp = ctx.llm.chat(messages, tools=tools_schema) @@ -142,7 +171,9 @@ def _review_frame(ctx, evidence, system_prompt, log, view_changed): log(f" [取证] {name} {args if args else ''}") if name in _VIEW_TOOLS: view_changed[0] = True - content, image_budget = _run_readonly_tool(ctx, name, args, image_budget) + content, image_budget = _run_readonly_tool( + ctx, name, args, image_budget, evidence=evidence, state=state + ) messages.append( {"role": "tool", "tool_call_id": tool_call.id, "content": content} ) diff --git a/dwg_review/tiling.py b/dwg_review/tiling.py index 07e2c16..b0ec5ee 100644 --- a/dwg_review/tiling.py +++ b/dwg_review/tiling.py @@ -163,6 +163,66 @@ def boxes_intersect(a, b, eps=0.0): ) +def box_to_window(box): + """bbox 元组 → window 对象(`zoom_to_window` 的入参);非法返回 None。""" + b = norm_box(box) + if b is None: + return None + return {"min_x": b[0], "min_y": b[1], "max_x": b[2], "max_y": b[3]} + + +def window_to_box(win): + """window 对象 → bbox 元组;非法返回 None。""" + if not isinstance(win, dict): + return None + return norm_box( + [win.get("min_x"), win.get("min_y"), win.get("max_x"), win.get("max_y")] + ) + + +def expand_box(box, frac): + """把 bbox 四周按 `frac` 比例外扩;非法输入返回 None。""" + b = norm_box(box) + if b is None: + return None + try: + f = float(frac or 0.0) + except (TypeError, ValueError): + f = 0.0 + mx, my = (b[2] - b[0]) * f, (b[3] - b[1]) * f + return (b[0] - mx, b[1] - my, b[2] + mx, b[3] + my) + + +def visible_box(box, view_px, margin=0.0): + """`zoom_to_window(box, margin)` 之后**实际可见**的世界范围。 + + CAD 的 `_.ZOOM _W` 是**等比适配**(不拉伸):指定窗口按视口宽高比放大到刚好装下, + 多余的一维露出更多内容。故可见范围 = 以窗口中心为中心、按 `max(W/ew, H/eh)` + 放大后的矩形。`view_px` 为截图的**原始像素**(未下采样);拿不到时退回 + 「窗口外扩 margin」的近似值。 + + 用途:审查阶段把「截图上的归一化坐标」准确映射回 WCS(逐级下钻)。 + """ + b = expand_box(box, margin) + if b is None: + return None + if not view_px or len(view_px) != 2: + return b + try: + ew, eh = int(view_px[0]), int(view_px[1]) + except (TypeError, ValueError): + return b + if ew <= 0 or eh <= 0: + return b + w, h = b[2] - b[0], b[3] - b[1] + if w <= 0 or h <= 0: + return b + k = max(w / ew, h / eh) + vw, vh = ew * k, eh * k + cx, cy = (b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0 + return (cx - vw / 2.0, cy - vh / 2.0, cx + vw / 2.0, cy + vh / 2.0) + + def cluster_text_flags(clusters, text_boxes): """标记每个簇是否含文字类实体(bbox 相交即算,保守)。 diff --git a/tests/test_checklist.py b/tests/test_checklist.py index 309b6cc..73a831b 100644 --- a/tests/test_checklist.py +++ b/tests/test_checklist.py @@ -65,12 +65,15 @@ def test_build_system_prompt_tool_hint_toggle(): assert "只读" not in without -def test_build_system_prompt_instructs_zoom_when_unreadable(): +def test_build_system_prompt_instructs_inspect_region(): prompt = build_system_prompt(DEFAULT_CHECKLIST, allow_tools=True) - assert "看不清" in prompt - assert "zoom_to_window" in prompt - assert "zoom_to_entity" in prompt - assert "get_viewport_screenshot" in prompt + # 放大取证走 inspect_region 这一个语义化入口(不再让 LLM 自己组合裸视口工具) + assert "inspect_region" in prompt + assert "handle" in prompt and "label" in prompt and "window" in prompt + assert "cx" in prompt and "cy" in prompt + assert "从上到下" in prompt # cy 方向必须写清(图像坐标) + assert "inspect-N" in prompt # 返回标签可继续下钻 + assert "不需要判断截图够不够清楚" in prompt def test_build_system_prompt_requires_structured_json_output(): diff --git a/tests/test_config.py b/tests/test_config.py index 82cf840..5b63f40 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -106,6 +106,12 @@ def test_review_obs_images_default_is_sane(): assert config.REVIEW_OBS_IMAGES >= 3 +def test_inspect_defaults_are_sane(): + # agent 侧虚拟工具默认启用;单次瓦片上限为正 + assert config.INSPECT_ENABLED is True + assert config.INSPECT_MAX_TILES >= 1 + + def test_drawing_area_constants_have_expected_types(): assert isinstance(config.DRAWING_AREA_MAXIMIZE, bool) assert isinstance(config.DRAWING_AREA_ENTER_WAIT, float) diff --git a/tests/test_evidence.py b/tests/test_evidence.py index ef385d7..9976ecc 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -77,7 +77,15 @@ def test_collect_frame_assembles_evidence(): assert ev["blocks"]["total"] == 0 assert ev["polylines"]["total"] == 3 assert ev["source_type"] == "cad" - assert ev["images"] == [{"b64": "QUJD", "mime": "image/png", "label": "overall"}] + # 截图登记「实际可见世界范围」:拿不到绘图区像素时退回「窗口外扩 margin」的近似值 + assert ev["images"] == [ + { + "b64": "QUJD", + "mime": "image/png", + "label": "overall", + "window": (-2.0, -1.0, 102.0, 51.0), + } + ] def test_collect_frame_calls_expected_tools(): diff --git a/tests/test_evidence_model.py b/tests/test_evidence_model.py index 22ffa7b..a917edc 100644 --- a/tests/test_evidence_model.py +++ b/tests/test_evidence_model.py @@ -52,7 +52,7 @@ def test_evidence_images_filters_empty_b64(): def test_evidence_images_legacy_fallback(): ev = {"image_b64": "LEG", "image_mime": "image/jpeg"} assert em.evidence_images(ev) == [ - {"b64": "LEG", "mime": "image/jpeg", "label": None} + {"b64": "LEG", "mime": "image/jpeg", "label": None, "window": None} ] diff --git a/tests/test_inspect.py b/tests/test_inspect.py new file mode 100644 index 0000000..6ac6191 --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,348 @@ +"""`dwg_review.inspect` 单元测试(agent 侧虚拟工具 `inspect_region`)。""" + +import base64 +import io + +import pytest + +from dwg_review import config, inspect +from dwg_review.inspect import ( + InspectState, + _point_box, + inspect_region, + plan_tiles, + readable_px, + resolve_target, +) +from dwg_review.phases.review import review_frame +from tests.fakes import ( + FakeLlmClient, + FakeMcpClient, + frame, + llm_calls, + llm_text, + make_ctx, + make_evidence, + ok_image, +) + + +@pytest.fixture +def inspect_env(monkeypatch): + """钉死 inspect 相关配置,使用例不受外部环境变量影响。""" + for name, value in { + "TEXT_HEIGHT_MM": 2.0, + "TEXT_READABLE_PX": 8, + "LINE_READABLE_PX": 6, + "INSPECT_MAX_TILES": 4, + "INSPECT_ENABLED": True, + "TILE_OVERLAP": 0.0, + "IMAGE_MAX_EDGE": 1280, + "RENDER_WAIT": 0.0, + }.items(): + monkeypatch.setattr(config, name, value) + return config + + +def _png_b64(w=2000, h=1414): + from PIL import Image + + buf = io.BytesIO() + Image.new("RGB", (w, h), "black").save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode("ascii") + + +def _evidence(images=None, entities=None, sheet_scale=100.0, sheet_size="A0"): + """图框 (0,0)-(120000,40000)、1:100、A0 的 evidence(含 overall 截图)。""" + ev = make_evidence() + ev["frame"] = frame([0, 0, 120000, 40000], sheet_size=sheet_size, sheet_scale=sheet_scale) + ev["window"] = {"min_x": 0.0, "min_y": 0.0, "max_x": 120000.0, "max_y": 40000.0} + ev["entities"] = entities or { + "total": 1, + "items": [{"handle": "A1", "type": "LINE", "bbox": [0, 0, 1000, 500]}], + } + if images is None: + images = [ + { + "b64": _png_b64(), + "mime": "image/png", + "label": "overall", + "window": (0.0, 0.0, 120000.0, 40000.0), + } + ] + ev["images"] = images + return ev + + +def _client(b64=None): + return FakeMcpClient( + responses={"get_viewport_screenshot": ok_image(b64 or _png_b64())} + ) + + +# --------------------------------------------------------------------------- +# InspectState +# --------------------------------------------------------------------------- +def test_inspect_state_registers_evidence_labels_with_window(): + ev = _evidence(images=[ + {"b64": "A", "mime": "image/png", "label": "overall", "window": (0, 0, 10, 10)}, + {"b64": "B", "mime": "image/png", "label": "tile-1", "window": [0, 0, 5, 5]}, + {"b64": "C", "mime": "image/png", "label": "tile-2"}, # 无 window → 不登记 + ]) + state = InspectState(ev) + assert set(state.labels) == {"overall", "tile-1"} + assert state.labels["tile-1"] == (0.0, 0.0, 5.0, 5.0) + + +def test_inspect_state_register_assigns_incrementing_labels(): + state = InspectState(_evidence()) + assert state.register((1, 2, 3, 4)) == "inspect-1" + assert state.register((0, 0, 1, 1)) == "inspect-2" + assert state.labels["inspect-1"] == (1, 2, 3, 4) + + +# --------------------------------------------------------------------------- +# _point_box / resolve_target +# --------------------------------------------------------------------------- +def test_point_box_flips_y_because_cy_is_image_down(): + base = (0.0, 0.0, 100.0, 50.0) + top = _point_box(base, 0.5, 0.0, 0.5) # 图像顶部 + bottom = _point_box(base, 0.5, 1.0, 0.5) # 图像底部 + assert (top[1] + top[3]) / 2 == pytest.approx(50.0) # → 世界 y 大的一侧 + assert (bottom[1] + bottom[3]) / 2 == pytest.approx(0.0) + + +def test_point_box_centers_and_clamps_span(): + base = (0.0, 0.0, 100.0, 50.0) + box = _point_box(base, 0.5, 0.5, 0.5) + assert box == pytest.approx((25.0, 12.5, 75.0, 37.5)) + assert _point_box(base, 0.5, 0.5, 5.0) == pytest.approx(base) # span 上限 1 + tiny = _point_box(base, 0.5, 0.5, 0.0) + assert (tiny[2] - tiny[0]) == pytest.approx(100.0 * 0.02) # span 下限 0.02 + + +def test_resolve_target_by_handle_is_case_insensitive(): + ev = _evidence() + box, note = resolve_target(ev, {"handle": "a1"}, InspectState(ev)) + assert box == (0.0, 0.0, 1000.0, 500.0) + assert "a1" in note + + +def test_resolve_target_handle_not_found(): + ev = _evidence() + box, note = resolve_target(ev, {"handle": "ZZ"}, InspectState(ev)) + assert box is None + assert "ZZ" in note and "window" in note + + +def test_resolve_target_by_window(): + ev = _evidence() + box, note = resolve_target( + ev, {"window": {"min_x": 1, "min_y": 2, "max_x": 3, "max_y": 4}}, InspectState(ev) + ) + assert box == (1.0, 2.0, 3.0, 4.0) + assert note + + +def test_resolve_target_bad_window(): + ev = _evidence() + box, note = resolve_target(ev, {"window": {"min_x": 1}}, InspectState(ev)) + assert box is None + assert "window 非法" in note + + +def test_resolve_target_by_label_point(): + ev = _evidence() + state = InspectState(ev) + box, note = resolve_target( + ev, {"label": "overall", "cx": 0.5, "cy": 0.5, "span": 0.5}, state + ) + # overall 覆盖 (0,0)-(120000,40000) → 中心 (60000, 20000),跨度为半幅 + assert box == pytest.approx((30000.0, 10000.0, 90000.0, 30000.0)) + assert "overall" in note + + +def test_resolve_target_unknown_label_lists_known(): + ev = _evidence() + box, note = resolve_target(ev, {"label": "tile-9"}, InspectState(ev)) + assert box is None + assert "tile-9" in note and "overall" in note + + +def test_resolve_target_rejects_bad_inputs(): + ev = _evidence() + state = InspectState(ev) + assert resolve_target(ev, "nope", state)[0] is None + assert resolve_target(ev, {}, state)[0] is None + assert resolve_target(ev, {"label": "overall", "cx": "x"}, state)[0] is None + + +# --------------------------------------------------------------------------- +# plan_tiles +# --------------------------------------------------------------------------- +def test_readable_px_by_purpose(inspect_env): + assert readable_px("text") == inspect_env.TEXT_READABLE_PX + assert readable_px("line") == inspect_env.LINE_READABLE_PX + + +def test_plan_tiles_single_tile_for_small_region(inspect_env): + wins, note = plan_tiles( + (0, 0, 1000, 500), 100.0, "text", (2000, 1414), + max_tiles=4, overlap=0.0, margin=0.02, + ) + assert len(wins) == 1 + assert "1×1" in note + # 目标框被外扩 5% 留边 + assert wins[0]["min_x"] < 0 and wins[0]["max_x"] > 1000 + + +def test_plan_tiles_splits_wide_region(inspect_env): + # 66000 世界单位宽(含 5% 留边)/ 单张 32000 → 3 张 + wins, note = plan_tiles( + (0, 0, 60000, 20000), 100.0, "text", (2000, 1414), + max_tiles=9, overlap=0.0, margin=0.02, + ) + assert len(wins) == 3 + assert "3 张" in note + + +def test_plan_tiles_fatal_when_over_cap(inspect_env): + wins, note = plan_tiles( + (0, 0, 100000, 100000), 100.0, "text", (2000, 1414), + max_tiles=4, overlap=0.0, margin=0.02, + ) + assert wins is None + assert "超过单次上限 4" in note + assert "span" in note # 给出缩小 target 的具体指引 + + +def test_plan_tiles_falls_back_to_single_without_scale(inspect_env): + wins, note = plan_tiles( + (0, 0, 1000, 500), 0.0, "text", (2000, 1414), + max_tiles=4, overlap=0.0, margin=0.02, + ) + assert len(wins) == 1 + assert "退回单张" in note + + +def test_plan_tiles_degenerate_box_is_fatal(inspect_env): + wins, note = plan_tiles( + (0, 0, 0, 0), 100.0, "text", (2000, 1414), + max_tiles=4, overlap=0.0, margin=0.02, + ) + assert wins is None + assert "退化" in note + + +# --------------------------------------------------------------------------- +# inspect_region(执行体) +# --------------------------------------------------------------------------- +def test_inspect_region_handle_success(inspect_env): + pytest.importorskip("PIL") + client = _client() + ctx = make_ctx(client=client) + ev = _evidence() + state = InspectState(ev) + + content, budget = inspect_region(ctx, ev, {"target": {"handle": "A1"}}, [6], state) + + assert budget[0] == 5 # 只用掉 1 张 + assert "实体 A1" in content[0]["text"] + assert any(c.get("type") == "image_url" for c in content) + assert "zoom_to_window" in client.called_names() + assert "inspect-1" in state.labels + # 登记的可见范围应**包含**目标框(ZOOM W 等比适配会露出更多) + vis = state.labels["inspect-1"] + assert vis[0] <= 0.0 and vis[2] >= 1000.0 + + +def test_inspect_region_label_target_reports_label_and_range(inspect_env): + pytest.importorskip("PIL") + ctx = make_ctx(client=_client()) + ev = _evidence() + state = InspectState(ev) + + content, budget = inspect_region( + ctx, ev, {"target": {"label": "overall", "cx": 0.5, "cy": 0.5, "span": 0.5}}, [6], state + ) + assert budget[0] == 3 # 中心半幅需 3 张瓦片 + assert "overall" in content[0]["text"] + assert any("可见世界范围" in c.get("text", "") for c in content) + + +def test_inspect_region_purpose_line_is_offered(inspect_env): + pytest.importorskip("PIL") + ctx = make_ctx(client=_client()) + ev = _evidence() + content, _ = inspect_region( + ctx, ev, {"target": {"handle": "A1"}, "purpose": "line"}, [6], InspectState(ev) + ) + assert "line" in content[0]["text"] + + +def test_inspect_region_bad_target_makes_no_mcp_calls(inspect_env): + client = _client() + ctx = make_ctx(client=client) + ev = _evidence() + content, budget = inspect_region( + ctx, ev, {"target": {"label": "nope"}}, [6], InspectState(ev) + ) + assert budget[0] == 6 + assert "未执行" in content[0]["text"] + assert client.called_names() == [] + + +def test_inspect_region_oversized_target_makes_no_mcp_calls(inspect_env): + client = _client() + ctx = make_ctx(client=client) + ev = _evidence() + content, budget = inspect_region( + ctx, ev, + {"target": {"window": {"min_x": 0, "min_y": 0, "max_x": 200000, "max_y": 200000}}}, + [6], InspectState(ev), + ) + assert budget[0] == 6 + assert "超过单次上限" in content[0]["text"] + assert client.called_names() == [] + + +def test_inspect_region_budget_exhausted_returns_no_images(inspect_env): + pytest.importorskip("PIL") + ctx = make_ctx(client=_client()) + ev = _evidence() + content, budget = inspect_region( + ctx, ev, {"target": {"handle": "A1"}}, [0], InspectState(ev) + ) + assert budget[0] == 0 + assert all(c.get("type") == "text" for c in content) + assert any("预算已用完" in c.get("text", "") for c in content) + + +# --------------------------------------------------------------------------- +# 与审查子循环的集成 +# --------------------------------------------------------------------------- +def test_review_frame_can_call_inspect_region(inspect_env): + pytest.importorskip("PIL") + llm = FakeLlmClient(script=[ + llm_calls([("inspect_region", {"target": {"handle": "A1"}})]), + llm_text('{"summary": "结论", "issues": []}'), + ]) + client = _client() + ctx = make_ctx(client=client, llm=llm) + ev = _evidence() + + out = review_frame(ctx, ev, "sys", log=lambda *a: None) + + assert out["summary"] == "结论" + names = client.called_names() + assert "zoom_to_window" in names # inspect_region 内部真的缩放了 + assert names.count("zoom_to_window") == 2 # 放大 1 次 + 结束复位 1 次 + + +def test_review_frame_offers_inspect_region_to_llm(inspect_env): + llm = FakeLlmClient(script=[llm_text("x")]) + ctx = make_ctx(client=FakeMcpClient(), llm=llm) + review_frame(ctx, make_evidence(), "sys", log=lambda *a: None) + names = {t["function"]["name"] for t in llm.calls[0]["tools"]} + assert "inspect_region" in names + assert "zoom_to_window" not in names diff --git a/tests/test_review.py b/tests/test_review.py index d88b871..c4517b0 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -2,9 +2,13 @@ import json +import pytest + +from dwg_review import config from dwg_review.cad_tools import READ_ONLY_TOOLS from dwg_review.mcp_client import McpToolError from dwg_review.phases.review import ( + _review_tools, _run_readonly_tool, evidence_payload, review_frame, @@ -339,3 +343,60 @@ def test_review_frame_no_viewport_reset_without_window(): ev["window"] = None # 非 CAD 源(无窗口)→ 无从复位 review_frame(ctx, ev, "sys", log=lambda *a: None) assert client.called_names().count("zoom_to_window") == 1 + + +# --------------------------------------------------------------------------- +# 虚拟工具 inspect_region 的接入 +# --------------------------------------------------------------------------- +def _tool_names(ctx): + return {t["function"]["name"] for t in _review_tools(ctx)} + + +def test_review_tools_swap_raw_view_tools_for_inspect_region(monkeypatch): + monkeypatch.setattr(config, "INSPECT_ENABLED", True) + names = _tool_names(make_ctx()) + assert "inspect_region" in names + assert "query_entities" in names # 其余只读工具照旧 + assert "zoom_to_window" not in names + assert "zoom_to_entity" not in names + assert "get_viewport_screenshot" not in names + + +def test_review_tools_keep_raw_view_tools_when_disabled(monkeypatch): + monkeypatch.setattr(config, "INSPECT_ENABLED", False) + names = _tool_names(make_ctx()) + assert "inspect_region" not in names + assert {"zoom_to_window", "zoom_to_entity", "get_viewport_screenshot"} <= names + + +def test_readonly_intercepts_inspect_region_before_whitelist(monkeypatch): + # inspect_region 不在 READ_ONLY_TOOLS 里,但必须被**先**拦截(不能判成越权) + monkeypatch.setattr(config, "INSPECT_ENABLED", True) + ctx = make_ctx(client=FakeMcpClient()) + ev = make_evidence() + from dwg_review.inspect import InspectState + + content, budget = _run_readonly_tool( + ctx, "inspect_region", {"target": {}}, [6], evidence=ev, state=InspectState(ev) + ) + assert "不在只读取证白名单内" not in content[0]["text"] + assert "未执行" in content[0]["text"] # 空 target → 明确的用法错误 + assert budget == [6] + + +def test_readonly_inspect_region_unavailable_when_disabled(monkeypatch): + monkeypatch.setattr(config, "INSPECT_ENABLED", False) + ctx = make_ctx(client=FakeMcpClient()) + ev = make_evidence() + from dwg_review.inspect import InspectState + + content, _ = _run_readonly_tool( + ctx, "inspect_region", {}, [6], evidence=ev, state=InspectState(ev) + ) + assert "不可用" in content[0]["text"] + + +def test_readonly_inspect_region_unavailable_without_evidence(): + ctx = make_ctx(client=FakeMcpClient()) + content, _ = _run_readonly_tool(ctx, "inspect_region", {}, [6]) + assert "不可用" in content[0]["text"] diff --git a/tests/test_tiling.py b/tests/test_tiling.py index d7b6b22..0835ede 100644 --- a/tests/test_tiling.py +++ b/tests/test_tiling.py @@ -1,5 +1,7 @@ """`dwg_review.tiling` 单元测试(纯函数,不依赖 CAD / LLM / Pillow)。""" +import pytest + from dwg_review import tiling as tl @@ -221,3 +223,60 @@ def test_plan_regions_clamps_to_frame(): for w in wins: assert w["min_x"] >= 0 and w["max_x"] <= 1000 assert w["min_y"] >= 0 and w["max_y"] <= 500 + + +# --- box / window 互转与可见范围 ------------------------------------------- + +def test_box_window_roundtrip(): + box = (1.0, 2.0, 3.0, 4.0) + win = tl.box_to_window(box) + assert win == {"min_x": 1.0, "min_y": 2.0, "max_x": 3.0, "max_y": 4.0} + assert tl.window_to_box(win) == box + + +def test_box_to_window_invalid(): + assert tl.box_to_window(None) is None + assert tl.box_to_window([1, 2, 3]) is None + + +def test_window_to_box_invalid(): + assert tl.window_to_box(None) is None + assert tl.window_to_box({}) is None + assert tl.window_to_box({"min_x": 1, "min_y": 2}) is None + + +def test_expand_box_by_fraction(): + assert tl.expand_box((0, 0, 100, 50), 0.05) == pytest.approx((-5.0, -2.5, 105.0, 52.5)) + assert tl.expand_box((0, 0, 100, 50), 0.0) == (0.0, 0.0, 100.0, 50.0) + assert tl.expand_box(None, 0.1) is None + # 非法 frac 视为 0(不抛异常) + assert tl.expand_box((0, 0, 10, 10), "x") == (0.0, 0.0, 10.0, 10.0) + + +def test_visible_box_widens_to_viewport_aspect(): + # 1000x500 的窗口在 1280x280(约 4.57:1)视口下等比适配 → 横向露出更多,纵向刚好贴合 + vis = tl.visible_box((0, 0, 1000, 500), (1280, 280), 0.0) + assert vis[0] < 0 and vis[2] > 1000 + assert vis[1] == pytest.approx(0.0) + assert vis[3] == pytest.approx(500.0) + assert (vis[0] + vis[2]) / 2 == pytest.approx(500.0) # 中心不变 + assert (vis[1] + vis[3]) / 2 == pytest.approx(250.0) + + +def test_visible_box_tall_window_is_fit_by_height(): + # 100x1000(很窄很高)在 1280x280 视口下按**高**适配 → 纵向刚好贴合,横向大幅露出 + vis = tl.visible_box((0, 0, 100, 1000), (1280, 280), 0.0) + assert vis[1] == pytest.approx(0.0) and vis[3] == pytest.approx(1000.0) + assert vis[0] < 0 and vis[2] > 100 + assert (vis[0] + vis[2]) / 2 == pytest.approx(50.0) # 中心不变 + + +def test_visible_box_without_view_px_falls_back_to_margin(): + assert tl.visible_box((0, 0, 100, 50), None, 0.1) == pytest.approx( + (-10.0, -5.0, 110.0, 55.0) + ) + assert tl.visible_box((0, 0, 100, 50), (0, 0), 0.0) == (0.0, 0.0, 100.0, 50.0) + + +def test_visible_box_invalid_box(): + assert tl.visible_box(None, (100, 100), 0.1) is None diff --git a/verify_review_maximized.py b/verify_review_maximized.py index ee43fa9..45ca3ee 100644 --- a/verify_review_maximized.py +++ b/verify_review_maximized.py @@ -1,11 +1,13 @@ """验收:审查阶段是否也在「绘图区最大化」状态下进行,且结束后精确还原。 做法:在取证 `collect` 与审查 `review_frame` 入口各采一次窗口矩形, -并在 `McpClient.call_tool` 上挂计数器,统计 LLM 子循环是否真的用了放大工具。 +并在 `McpClient.call_tool` 上挂计数器统计子循环的 zoom/截图次数, +同时记录 LLM 实际调用的**取证工具**(含 agent 侧虚拟工具 `inspect_region`)。 用法:先启动已加载插件的 CAD 并打开图纸,然后 `python verify_review_maximized.py`。 """ import ctypes +import json import sys from ctypes import wintypes @@ -60,10 +62,12 @@ def area(r): # --- 采样点 --------------------------------------------------------------- phase_rects = {"evidence": [], "review": []} zoom_calls = [] +llm_tools = [] # LLM 在审查子循环里实际调用的取证工具(名称 + 参数) _orig_collect = ev.collect _orig_review = rv.review_frame _orig_call = McpClient.call_tool +_orig_run_tool = rv._run_readonly_tool def collect(ctx, index, frame): @@ -82,9 +86,15 @@ def call_tool(self, name, arguments=None, **kw): return _orig_call(self, name, arguments, **kw) +def run_tool(ctx, name, args, budget, **kw): + llm_tools.append((name, args)) + return _orig_run_tool(ctx, name, args, budget, **kw) + + ev.collect = collect rv.review_frame = review McpClient.call_tool = call_tool +rv._run_readonly_tool = run_tool before = rect() print(f"起始窗口矩形 = {before} 面积={area(before)}") @@ -97,12 +107,18 @@ print("=== 结果 ===") print(f"取证阶段采样 = {phase_rects['evidence']}") print(f"审查阶段采样 = {phase_rects['review']}") print(f"结束窗口矩形 = {after} 面积={area(after)}") -print(f"子循环取证调用 = {zoom_calls}") +print(f"子循环 zoom/截图调用 = {len(zoom_calls)} 次") +print(f"LLM 取证工具调用 = {len(llm_tools)} 次") +for name, args in llm_tools: + brief = json.dumps(args, ensure_ascii=False) + print(f" - {name} {brief[:160]}") +inspect_n = sum(1 for name, _ in llm_tools if name == "inspect_region") ok_ev = all(area(r) > area(before) for r in phase_rects["evidence"]) if phase_rects["evidence"] else False ok_rv = all(area(r) > area(before) for r in phase_rects["review"]) if phase_rects["review"] else False print() print(f"[{'PASS' if ok_ev else 'FAIL'}] 取证阶段处于最大化(面积大于基线)") print(f"[{'PASS' if ok_rv else 'FAIL'}] 审查阶段处于最大化(面积大于基线)") print(f"[{'PASS' if after == before else 'FAIL'}] 结束精确还原窗口矩形") +print(f"[{'PASS' if inspect_n else 'INFO'}] LLM 使用了 inspect_region({inspect_n} 次)") sys.exit(0 if (ok_ev and ok_rv and after == before and rc == 0) else 1)