Files
cad-agent/dwg_review/evidence_model.py
T
gjm 6143bb9ab2 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 丢图框块,离线侧暂无等价手段),留待后续打磨。
2026-10-16 23:07:23 +08:00

108 lines
3.6 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.
"""统一证据模型:多来源(CAD / PDF / 图片)证据的字段常量与访问器。
设计要点:
- evidence 是跨阶段 + 落盘的「线格式」数据,统一用 dict 承载(不用 dataclass)。
- 图片统一为 `images: [{"b64","mime","label"}]`;访问器兼容旧的 `image_b64/image_mime`
单图字段(历史 evidence.json 与旧测试构造仍可读)。
- 本模块不含任何 CAD / 工具逻辑,可被 phases、findings、__main__、tests 共同引用。
"""
SOURCE_CAD = "cad"
SOURCE_PDF = "pdf"
SOURCE_IMAGE = "image"
# 结构化证据键(CAD 侧各查询工具产出的信封);下游 handle 收集 / 派生检查统一引用,
# 避免在多处硬编码同一组键名。
STRUCTURED_SOURCES = ("entities", "texts", "dimensions", "blocks", "polylines")
def empty_envelope(total=0):
"""构造空的**结构化信封** `{"total": N, "items": []}`。
CAD 侧各查询工具统一返回此形(`items` 明细 + `total` 计数)。非 CAD 源
(PDF / 图片)没有结构化数据,用本工厂补齐同形键,使下游 handle 收集 /
派生检查无需分支即可统一遍历(空 items 自然不产出任何检查项)。
"""
return {"total": int(total), "items": []}
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):
"""读取来源类型,缺省视为 CAD。
container 可以是审查单元(unit)/ 图框 / evidence 等任何 dict。
"""
if isinstance(container, dict):
value = container.get("source_type")
if isinstance(value, str) and value:
return value
return SOURCE_CAD
def evidence_images(evidence):
"""规范化证据里的图片列表(单点归一)。
优先读 `images`;否则回退旧的 `image_b64` / `image_mime` 单图字段。
统一返回 `[{"b64","mime","label","window"}]`,过滤掉空 b64。
"""
if not isinstance(evidence, dict):
return []
images = []
raw = evidence.get("images")
if isinstance(raw, list):
for item in raw:
if not isinstance(item, dict):
continue
b64 = item.get("b64")
if not b64:
continue
images.append(
{
"b64": b64,
"mime": item.get("mime") or "image/png",
"label": item.get("label"),
"window": item.get("window"),
}
)
if images:
return images
# 回退:旧的单图字段
b64 = evidence.get("image_b64")
if b64:
return [
{
"b64": b64,
"mime": evidence.get("image_mime") or "image/png",
"label": None,
"window": None,
}
]
return []
def primary_image(evidence):
"""返回首图(label == "overall" 优先),无图返回 None。"""
images = evidence_images(evidence)
if not images:
return None
for img in images:
if img.get("label") == "overall":
return img
return images[0]
def image_labels(evidence):
"""返回图片标签列表(用于日志 / payload 说明)。"""
return [img.get("label") for img in evidence_images(evidence)]