Files
cad-agent/dwg_review/phases/evidence.py
T
gjm b757892c67 feat(review): agent 侧虚拟工具 inspect_region —— 按可读分辨率放大指定区域
解决三个缺口:① 放大没复用标定/瓦片数学(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。
2026-10-16 19:15:32 +08:00

320 lines
12 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.
"""阶段 2:逐审查单元取证(多源证据层 + 视觉多图)。
对每个审查单元:取结构化数据(实体/文字/标注/块/多段线)+ 视觉截图(整体 + 可选局部)。
结构化数据是权威依据,截图仅作视觉上下文。
多源设计:`collect()` 按审查单元来源分发;当前仅实现 CAD 源,PDF / 图片源预留 seam
(未来在对应分支产出**同形** evidence,下游无需改动)。
"""
import contextlib
import math
import time
from .. import config, evidence_model, heatmap, tiling
from ..llm_client import image_size_b64
from ..mcp_client import result_images, result_json
_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):
"""截屏前最大化绘图区(窗口铺满屏幕 + 干净屏幕),退出时还原。
实测绘图区像素 2110x462 → 3310x1084(3.68x);在同样的长边下采样预算下,
有效像素更多、细节更清楚,自适应瓦片张数也更少。
- 受 `config.DRAWING_AREA_MAXIMIZE` 控制;
- 工具不存在(旧版插件)/ 调用失败 → **静默降级**:照常取证,只是绘图区小一些;
- 退出时一定尝试还原;**还原失败不掩盖业务异常**(不 raise)。
"""
if not config.DRAWING_AREA_MAXIMIZE:
yield
return
entered = False
try:
if ctx.tools.has(_TOOL_DRAWING_AREA):
ctx.tools.call(_TOOL_DRAWING_AREA, {"mode": "max"})
entered = True
time.sleep(config.DRAWING_AREA_ENTER_WAIT)
except Exception: # noqa: BLE001 - 界面优化失败不应中断审查
entered = False
try:
yield
finally:
if entered:
try:
ctx.tools.call(_TOOL_DRAWING_AREA, {"mode": "restore"})
time.sleep(config.DRAWING_AREA_RESTORE_WAIT)
except Exception: # noqa: BLE001
pass
def bbox_to_window(bbox):
"""[minx,miny,maxx,maxy] -> window 对象。"""
return {
"min_x": float(bbox[0]),
"min_y": float(bbox[1]),
"max_x": float(bbox[2]),
"max_y": float(bbox[3]),
}
def sub_windows(window, tiles):
"""把 window 等分为 tiles 个子窗口(tiles=4 → 2x2 象限)。
tiles<=1 返回 []。返回 [{"min_x",..,"max_x",..}, ...],行优先(左上→右上→左下→右下)。
"""
if not tiles or tiles <= 1:
return []
cols = int(math.ceil(math.sqrt(tiles)))
rows = int(math.ceil(tiles / cols))
min_x, min_y = window["min_x"], window["min_y"]
max_x, max_y = window["max_x"], window["max_y"]
w = (max_x - min_x) / cols
h = (max_y - min_y) / rows
out = []
for r in range(rows):
for c in range(cols):
if len(out) >= tiles:
break
out.append(
{
"min_x": min_x + c * w,
"min_y": max_y - (r + 1) * h,
"max_x": min_x + (c + 1) * w,
"max_y": max_y - r * h,
}
)
return out
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)
shot = ctx.tools.call("get_viewport_screenshot", {})
images = result_images(shot)
if not images:
return None
return images[0]
def _is_truncated(*envelopes):
"""任一信封 `total > len(items)` 即视为**被截断**(数据不全)。"""
for env in envelopes:
if not isinstance(env, dict):
continue
items = env.get("items")
if not isinstance(items, list):
continue
try:
total = int(env.get("total") or len(items))
except (TypeError, ValueError):
total = len(items)
if total > len(items):
return True
return False
def _text_boxes(entities, texts, dimensions):
"""收集「文字类」实体 bbox。
标准文字 / 标注信封(`get_text_content` / `get_dimensions`)全收;
再加**类名含 text/label/dim 等关键词**的自定义实体(`tiling.is_text_type`)。
"""
boxes = list(heatmap.entity_boxes(texts))
boxes.extend(heatmap.entity_boxes(dimensions))
items = entities.get("items") if isinstance(entities, dict) else None
for it in items or []:
if isinstance(it, dict) and tiling.is_text_type(it.get("type")):
b = heatmap.norm_box(it.get("bbox"))
if b is not None:
boxes.append(b)
return boxes
def _content_predicate(boxes):
"""构造「该窗口内是否有实体」谓词;无实体返回 None。
用于剔除空白瓦片:簇 / 象限的 bbox 只是**包围盒**,L 形或斜向分布会含大片空白。
这里直接按**实体 bbox** 判断——比先量化成占用格更精确(格子的跨界膨胀会把
相邻空白瓦片也判成有内容)。接触即算有内容(保守,宁可多拍不漏)。
"""
boxes = [b for b in (boxes or []) if b]
if not boxes:
return None
def _has_content(win):
box = (win["min_x"], win["min_y"], win["max_x"], win["max_y"])
return any(tiling.boxes_intersect(box, b) for b in boxes)
return _has_content
def _apply_keep(regions, keep):
"""按 keep 过滤窗口;过滤后为空则**退回原集合**(宁可多拍也不漏)。"""
if keep is None or not regions:
return regions
kept = [w for w in regions if keep(w)]
return kept or regions
def _plan_tiles(window, frame, boxes, text_boxes, truncated, view_px):
"""尝试按实体分布规划瓦片窗口;任何条件不满足都返回 [](调用方退回固定策略)。
降级条件:规划被关闭 / 实体数据被截断 / 缺图框 / 无实体 / 拿不到绘图区像素 /
比例未知(`frame_scale` 返 0)。
"""
if not config.TILING_ENABLED or truncated or not frame or not boxes or not view_px:
return []
scale = tiling.frame_scale(frame)
s_text = tiling.world_per_px(scale, config.TEXT_HEIGHT_MM, config.TEXT_READABLE_PX)
# 线条区只需看清走向:同以「最小关注特征尺寸」(纸面 TEXT_HEIGHT_MM)为基准,
# 但像素门槛更低(R_line < R_text)→ 覆盖范围更大、瓦片更少。
s_line = tiling.world_per_px(scale, config.TEXT_HEIGHT_MM, config.LINE_READABLE_PX)
if s_text <= 0 or s_line <= 0:
return []
eff = tiling.effective_px(view_px, config.IMAGE_MAX_EDGE)
if eff[0] <= 0 or eff[1] <= 0:
return []
cell = heatmap.div_to_cell(window, config.HEATMAP_CELL_DIV)
grid = heatmap.build_grid(window, boxes, cell)
clusters = heatmap.cluster_cells(grid, dilate=config.HEATMAP_CLUSTER_DILATE)
if not clusters:
return []
flags = tiling.cluster_text_flags(clusters, text_boxes)
text_idx = {i for i, is_text in enumerate(flags) if is_text}
return tiling.plan_regions(
frame, clusters,
text_clusters=text_idx, s_text=s_text, s_line=s_line, view_px=eff,
max_tiles=config.TILE_MAX_COUNT, overlap=config.TILE_OVERLAP,
keep=_content_predicate(boxes),
)
def capture_images(ctx, window, *, tiles=0, max_images=None, frame=None,
boxes=None, text_boxes=None, truncated=False):
"""采集视觉证据:整体一张 + 局部若干张(自适应瓦片优先,否则等分象限回退)。
- 整体 label="overall",局部 label="tile-1"..;行优先。
- 局部窗口优先由 `_plan_tiles` 按**实体分布**给出(空白区不出图);
不可用时退回 `sub_windows(window, tiles)`,**同样按内容过滤**空白象限
(过滤后为空则退回未过滤集合,保证不漏拍)。
- 每张图登记其**实际可见世界范围**(`window` 字段)——审查阶段的 `inspect_region`
靠它把「截图上的归一化坐标」映射回 WCS(逐级下钻)。
- 结束时**复位到整体视口**,保证后续流程从整幅视图起手。
- 受 max_images(默认 config.EVIDENCE_MAX_IMAGES)截断。
"""
if max_images is None:
max_images = config.EVIDENCE_MAX_IMAGES
images = []
# 整体图:既是证据,也用于测量绘图区**实际像素**(决定单张截图的世界覆盖范围)
overall = zoom_and_shot(ctx, window)
view_px = image_size_b64(overall[0]) if overall else None
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** 判定,规划与回退两条路径共用同一谓词;
# 实体数据被截断时不可信,放弃过滤(宁可多拍也不漏)。
keep = None if truncated else _content_predicate(boxes)
# 局部:优先自适应瓦片,失败退回等分象限(回退路径同样按内容过滤)
regions = _plan_tiles(window, frame, boxes, text_boxes, truncated, view_px)
if not regions:
regions = _apply_keep(sub_windows(window, tiles), keep)
for i, sub in enumerate(regions, 1):
if len(images) >= max_images:
break
_add(zoom_and_shot(ctx, sub), f"tile-{i}", sub)
# 复位整体视口(不截图)
ctx.tools.call("zoom_to_window", dict(window, margin=_ZOOM_MARGIN))
return images
def collect_frame(ctx, index, frame):
"""采集单个图框(CAD 源)的证据;bbox 无效时返回 None。"""
bbox = frame.get("bbox")
if not bbox or len(bbox) != 4:
return None
win = bbox_to_window(bbox)
limit = ctx.frame_limit
# a. 结构化数据(权威;按 window 查询,不依赖缩放)
entities = result_json(ctx.tools.call("query_entities", {"window": win, "limit": limit}), {})
texts = result_json(ctx.tools.call("get_text_content", {"window": win, "limit": limit}), {})
dimensions = result_json(ctx.tools.call("get_dimensions", {"window": win, "limit": limit}), {})
blocks = result_json(
ctx.tools.call("get_block_references", {"window": win, "limit": limit}), {}
)
polylines = result_json(
ctx.tools.call("get_polylines_info", {"window": win, "limit": limit}), {}
)
# b. 视觉:整体 + 局部(优先按实体分布自适应瓦片;数据截断则退回固定策略)
truncated = _is_truncated(entities, texts, dimensions, blocks, polylines)
boxes = heatmap.drop_oversized(
heatmap.entity_boxes(entities), win, config.HEATMAP_MAX_AREA_FRAC
)
images = capture_images(
ctx, win,
tiles=config.EVIDENCE_DETAIL_TILES,
frame=frame,
boxes=boxes,
text_boxes=_text_boxes(entities, texts, dimensions),
truncated=truncated,
)
return {
"source_type": evidence_model.source_type_of(frame),
"index": index,
"frame": frame,
"window": win,
"entities": entities,
"texts": texts,
"dimensions": dimensions,
"blocks": blocks,
"polylines": polylines,
"images": images,
}
def collect(ctx, index, unit):
"""按审查单元来源分发取证。
当前仅实现 CAD 源;PDF / 图片源预留(未来在对应分支接入离线解析 / 直接读图,
产出与 CAD **同形**的 evidence:window=None、结构化键给空信封、images 带 label)。
"""
source = evidence_model.source_type_of(unit)
if source == evidence_model.SOURCE_CAD:
return collect_frame(ctx, index, unit)
# TODO(pdf/image): 接入离线解析(DWG/PDF)与直接读图。
return None