支持直接审查 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 丢图框块,离线侧暂无等价手段),留待后续打磨。
213 lines
12 KiB
Python
213 lines
12 KiB
Python
"""集中配置:全部来自环境变量,禁止硬编码密钥。
|
||
|
||
用法:所有模块统一 `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())
|