Files
cad-agent/dwg_review/config.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

213 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.
"""集中配置:全部来自环境变量,禁止硬编码密钥。
用法:所有模块统一 `from . import config` 后读取 config.XXX。
"""
import os
from pathlib import Path
# 仓库根目录(本文件位于 <root>/dwg_review/config.py)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _resolve_path(value: str) -> Path:
"""相对路径按仓库根目录解析(避免依赖当前工作目录)。"""
p = Path(value)
return p if p.is_absolute() else (PROJECT_ROOT / p)
def _read_env(name: str) -> str:
"""读取环境变量。
Windows 下新设置的系统/用户环境变量不会传播到**已启动**的进程,
因此当前进程读不到时,回退到注册表中的 Machine / User 级环境变量。
"""
value = os.environ.get(name)
if value:
return value
if os.name != "nt":
return ""
try:
import winreg
except ImportError: # pragma: no cover
return ""
locations = (
(winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
(winreg.HKEY_CURRENT_USER, r"Environment"),
)
for hive, subkey in locations:
try:
with winreg.OpenKey(hive, subkey) as key:
value, _ = winreg.QueryValueEx(key, name)
if value:
return str(value)
except OSError:
continue
return ""
def _read_bool(name: str, default: bool = True) -> bool:
"""读取布尔型环境变量;未设置或空串时返回 default。"""
value = os.environ.get(name)
if value is None or value.strip() == "":
return default
return value.strip().lower() in ("1", "true", "yes", "on")
# ---------------------------------------------------------------------------
# CAD MCP 基座(TCP JSON-RPC)
# ---------------------------------------------------------------------------
HOST = os.environ.get("CAD_MCP_HOST", "127.0.0.1")
PORT = int(os.environ.get("CAD_MCP_PORT", "8080"))
# 单次 TCP 调用超时(秒)
CALL_TIMEOUT = float(os.environ.get("CAD_MCP_TIMEOUT", "30"))
# 连接/超时类错误的重试次数(写类工具不重试)
CALL_RETRIES = int(os.environ.get("CAD_MCP_RETRIES", "2"))
# 帧大小上限(与基座 8MB 上限对齐,留余量)
MAX_FRAME_BYTES = int(os.environ.get("MAX_FRAME_BYTES", str(4 * 1024 * 1024)))
# ---------------------------------------------------------------------------
# LLM(默认 DeepSeek,视觉模型 deepseek-flash)
# ---------------------------------------------------------------------------
API_KEY = _read_env("DEEPSEEK_API_KEY")
BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
MODEL_NAME = os.environ.get("DEEPSEEK_MODEL", "deepseek-flash")
LLM_TIMEOUT = float(os.environ.get("LLM_TIMEOUT", "120"))
LLM_RETRIES = int(os.environ.get("LLM_RETRIES", "2"))
# ---------------------------------------------------------------------------
# 审查流程
# ---------------------------------------------------------------------------
# 审查清单路径
CHECKLIST_PATH = _resolve_path(os.environ.get("REVIEW_CHECKLIST", "review_checklist.json"))
# 最多审查多少个图框(0 = 全部)
MAX_FRAMES = int(os.environ.get("MAX_FRAMES", "0"))
# 每个图框取数据的条数上限
FRAME_ENTITY_LIMIT = int(os.environ.get("FRAME_ENTITY_LIMIT", "200"))
# 缩放后等待 CAD 渲染的秒数
RENDER_WAIT = float(os.environ.get("RENDER_WAIT", "0.6"))
# 审查阶段受控工具子循环的最大回合数
REVIEW_MAX_ITERS = int(os.environ.get("REVIEW_MAX_ITERS", "4"))
# 审查子循环里回灌给 LLM 的截图数上限(控制视觉 token 成本)。
# 实测:提示词鼓励「看不清→放大重看」后,模型一轮内会发多次 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"))
# 批注回写使用的图层
ANNOTATE_LAYER = os.environ.get("ANNOTATE_LAYER", "AI_REVIEW")
# ---------------------------------------------------------------------------
# 可信度三件套:确定性检查前置 / 结论结构化 / 证据交叉校验
# ---------------------------------------------------------------------------
# 是否在 LLM 审查前跑确定性检查(零长度、重复实体、空文字、未闭合多段线)
DETERMINISTIC_CHECKS = _read_bool("DETERMINISTIC_CHECKS", True)
# 是否校验 LLM 引用的 handle 确实出现在已采集证据中(不在则剥掉 handle)
VALIDATE_HANDLES = _read_bool("VALIDATE_HANDLES", True)
# 是否检查未闭合多段线(中心线/开放轮廓多时误报,可关闭)
CHECK_UNCLOSED_POLYLINES = _read_bool("CHECK_UNCLOSED_POLYLINES", True)
# find_zero_length 的长度阈值
ZERO_LENGTH_TOLERANCE = float(os.environ.get("ZERO_LENGTH_TOLERANCE", "0.001"))
# find_duplicate_entities 的几何量化容差
DUP_TOLERANCE = float(os.environ.get("DUP_TOLERANCE", "0.001"))
# find_duplicate_entities 的 limit(语义是「最大重复组数」,非条目数)
DUP_GROUP_LIMIT = int(os.environ.get("DUP_GROUP_LIMIT", "50"))
# 是否在确定性检查里跑 find_collisions(包围盒干涉**候选**粗筛)。
# 默认关闭:平面图里管线穿墙、标注引线跨图元等属正常相交,AABB 相交 ≠ 真干涉,
# 全量跑会淹没报告;改由审查 LLM 按「干涉/碰撞」清单条目**按需**调用该工具。
CHECK_COLLISIONS = _read_bool("CHECK_COLLISIONS", False)
# find_collisions 的最小重叠比阈值(逐轴重叠比之积,见 find_collisions.cpp 注释)。
# 0.5 已能滤掉「仅角部相接 / 细长交叉」的弱候选,只留明显互相侵入的对。
COLLISION_MIN_OVERLAP = float(os.environ.get("COLLISION_MIN_OVERLAP", "0.5"))
# ---------------------------------------------------------------------------
# 截图(视觉 token 成本控制)
# ---------------------------------------------------------------------------
# 长边上限(超过才缩放),0 表示不缩放
IMAGE_MAX_EDGE = int(os.environ.get("IMAGE_MAX_EDGE", "1280"))
# 缩放后的输出格式:png(默认,无损,适合 CAD 线稿)或 jpeg(体积更小)
IMAGE_FORMAT = os.environ.get("IMAGE_FORMAT", "png").strip().lower()
# JPEG 质量(仅 IMAGE_FORMAT=jpeg 时生效)
IMAGE_JPEG_QUALITY = int(os.environ.get("IMAGE_JPEG_QUALITY", "80"))
# 取证阶段每图框的局部截图数(0=仅整体;4=整体+2x2 象限)
# —— 仅在自适应瓦片规划不可用时作为**回退**路径参数
EVIDENCE_DETAIL_TILES = int(os.environ.get("EVIDENCE_DETAIL_TILES", "4"))
# 每图框图片硬上限(整体 + 局部总张数)。
# 默认 12 才能装下规划器给出的瓦片(实测测试图 A0 需 12 张);调小会截断规划结果。
EVIDENCE_MAX_IMAGES = int(os.environ.get("EVIDENCE_MAX_IMAGES", "12"))
# ---------------------------------------------------------------------------
# 自适应瓦片规划(按实体分布决定截几张、截哪里)
# ---------------------------------------------------------------------------
# 是否启用瓦片规划;False = 退回固定 EVIDENCE_DETAIL_TILES(行为与旧版一致)
TILING_ENABLED = _read_bool("TILING_ENABLED", True)
# 纸面字高(mm)。国标标注字高 2~3mm,取保守下限
TEXT_HEIGHT_MM = float(os.environ.get("TEXT_HEIGHT_MM", "2.0"))
# VLM 可读的**最小像素字高**。实测与**字体强相关**:
# - 离线合成(Consolas 抗锯齿填充字体)12px(白底 10px);
# - 真实 CAD 单线体 .shx(描边字体)6px 即可读对(3/3,两轮)。
# 取 8 折中:兼顾描边与常见 TrueType。标定脚本见 verify_vlm_resolution_offline.py /
# verify_text_resolution.py(后者须用 ACI 7 白字——红字 12px 因对比度不足完全读不出)。
TEXT_READABLE_PX = int(os.environ.get("TEXT_READABLE_PX", "8"))
# VLM 可分辨的**最小线间距像素**(1 条 vs 2 条二选一)。
# 实测 2/3/4px 失败、5/8/12px 成功;取 6 留一点余量。
LINE_READABLE_PX = int(os.environ.get("LINE_READABLE_PX", "6"))
# 单图框瓦片数硬上界(成本兜底)
TILE_MAX_COUNT = int(os.environ.get("TILE_MAX_COUNT", "36"))
# 相邻瓦片重叠比例(防边界切字)
TILE_OVERLAP = float(os.environ.get("TILE_OVERLAP", "0.0"))
# 热力图网格划分(长边切几份)
HEATMAP_CELL_DIV = int(os.environ.get("HEATMAP_CELL_DIV", "32"))
# 热力图聚类前的膨胀格数(合并相近内容;0=不膨胀)
HEATMAP_CLUSTER_DILATE = int(os.environ.get("HEATMAP_CLUSTER_DILATE", "0"))
# 丢弃 bbox 面积超过图框该比例的实体(去图框边框/图签块)
HEATMAP_MAX_AREA_FRAC = float(os.environ.get("HEATMAP_MAX_AREA_FRAC", "0.5"))
# ---------------------------------------------------------------------------
# 非 CAD 源(PDF / 图片):完全离线,不连 CAD
# ---------------------------------------------------------------------------
# 毫米 → PDF 点(1pt = 1/72in)。PDF 世界坐标以**点**为单位,故纸面字高须换算成点。
PT_PER_MM = 72.0 / 25.4
# PDF 最多取几页(0 = 全部)
PDF_MAX_PAGES = int(os.environ.get("PDF_MAX_PAGES", "0"))
# 文本层片段的最小字符数(低于此视为噪声丢弃)。国标图纸文字多为 SHX 描边,
# 导出后是线条而非文本,文本层往往整页为空——**文本抽取只是次要补充**。
PDF_TEXT_MIN_CHARS = int(os.environ.get("PDF_TEXT_MIN_CHARS", "2"))
# 整体图(整页/整图)渲染倍率上限(px/点)。整页长边同时受 IMAGE_MAX_EDGE 约束。
PDF_OVERVIEW_MAX_ZOOM = float(os.environ.get("PDF_OVERVIEW_MAX_ZOOM", "4.0"))
# 瓦片渲染倍率上限(px/点)。规划器算出的倍率通常 ~1.4,此值只作安全兜底。
PDF_TILE_MAX_ZOOM = float(os.environ.get("PDF_TILE_MAX_ZOOM", "6.0"))
# 图片源渲染倍率上限:1.0 = **不放大**(放大会插值出虚假细节)。
IMAGE_MAX_ZOOM = float(os.environ.get("IMAGE_MAX_ZOOM", "1.0"))
# 墨迹分析用的缩略图长边上限(越大越精确但越慢)
INK_ANALYSIS_MAX_EDGE = int(os.environ.get("INK_ANALYSIS_MAX_EDGE", "1024"))
# 灰度阈值:像素值 < 该值视为墨迹(0=黑,255=白)
INK_THRESHOLD = int(os.environ.get("INK_THRESHOLD", "200"))
# 一个网格被判定为「有墨迹」所需的最小墨迹面积占比(滤掉零星噪点)
INK_MIN_CELL_FRAC = float(os.environ.get("INK_MIN_CELL_FRAC", "0.01"))
# ---------------------------------------------------------------------------
# 绘图区最大化(截屏前把 CAD 窗口铺满屏幕 + 进入干净屏幕)
# ---------------------------------------------------------------------------
# 是否在取证前最大化绘图区。实测绘图区面积 2110x462 → 3310x1084(3.68x),
# 同样的视觉 token 预算下细节更清楚。工具不存在(旧版插件)时**静默降级**。
DRAWING_AREA_MAXIMIZE = _read_bool("DRAWING_AREA_MAXIMIZE", True)
# 进入干净屏幕后等待 CAD 渲染 / 重排窗口的秒数
DRAWING_AREA_ENTER_WAIT = float(os.environ.get("DRAWING_AREA_ENTER_WAIT", "1.5"))
# 还原后等待的秒数。插件内部是「700ms 延迟 + SetWindowPlacement」(CLEANSCREENOFF 是
# 异步命令,会把窗口几何改回它记住的布局,必须等它跑完再压回),实测窗口在 0.5~1.0s
# 之间收敛,故取 1.5s 留余量——太小会在界面还没回位时就开始下一步。
DRAWING_AREA_RESTORE_WAIT = float(os.environ.get("DRAWING_AREA_RESTORE_WAIT", "1.5"))
def has_api_key() -> bool:
return bool(API_KEY.strip())