Files
cad-agent/dwg_review/findings.py
T
gjm f6d61f8d07 feat(review): 新增确定性检查前置与结论结构化内核
- checks.py:确定性检查(find_zero_length / find_duplicate_entities 传 window
  + 纯 Python 派生空文字、未闭合多段线)。问题带 source=deterministic,handle 来自
  工具/数据本身,不依赖 LLM 回忆
- findings.py:解析 LLM 的 JSON 结论 + handle 交叉校验(引用未知 handle 时保留问题、
  剥掉 handle、verified=False)+ findings 装配
- config.py:新增 DETERMINISTIC_CHECKS / VALIDATE_HANDLES / CHECK_UNCLOSED_POLYLINES
  开关与 ZERO_LENGTH_TOLERANCE / DUP_TOLERANCE / DUP_GROUP_LIMIT

实机验证:find_zero_length / find_duplicate_entities 的 window 参数在基座上生效
(schema 未声明,C++ 侧 parseCommonQuery 实际支持)。
2026-10-14 19:53:31 +08:00

208 lines
6.9 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.
"""结构化结论:解析 LLM 的 JSON 输出、交叉校验 handle、装配 findings。
设计要点:
- `review_frame` 的结论是**受约束的 JSON**(`{summary, issues[]}`),不再是自由文本,
因此不需要第二次 LLM 调用来「抽取」结构。
- 解析失败时降级为 `summary=原文, issues=[]`:信息不丢,只是不进批注链路。
- LLM 引用的 handle 若不在已采集证据中 → **保留问题、剥掉 handle**(verified=False),
既不会丢发现,也绝不会批注到不存在的实体。
"""
import json
import re
# evidence 中带 handle 的数据源(items[].handle)
_SOURCES = ("entities", "texts", "dimensions", "blocks", "polylines")
_SEVERITIES = ("high", "medium", "low")
_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.S)
_MISS = object() # 解析失败的哨兵(区别于合法的 JSON null)
def _try_load(text):
try:
return json.loads(text)
except (json.JSONDecodeError, TypeError):
return _MISS
def _iter_items(envelope):
"""健壮迭代数据信封的 items。
`result_json` 在解析失败时会返回**原始字符串**,因此必须做 dict/list 守卫。
"""
if not isinstance(envelope, dict):
return []
items = envelope.get("items")
if not isinstance(items, list):
return []
return [i for i in items if isinstance(i, dict)]
def _outermost_json(text):
"""抓文本中第一个 `{`/`[` 起、括号配平的整段 JSON(正确处理字符串内的括号)。"""
start = None
for i, ch in enumerate(text):
if ch in "{[":
start, open_c = i, ch
break
if start is None:
return None
close_c = "}" if open_c == "{" else "]"
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
ch = text[i]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
elif ch == open_c:
depth += 1
elif ch == close_c:
depth -= 1
if depth == 0:
cand = text[start : i + 1]
return cand if _try_load(cand) is not _MISS else None
return None
def extract_json_block(text):
"""从可能含围栏/前后缀的文本中抽出可解析的 JSON 字符串;抽不到返回 None。"""
t = (text or "").strip()
if not t:
return None
if _try_load(t) is not _MISS: # 整体就是 JSON
return t
m = _FENCE.search(t)
if m:
inner = m.group(1).strip()
if _try_load(inner) is not _MISS:
return inner
t = inner
return _outermost_json(t)
def parse_conclusion(text, *, log=print):
"""把 LLM 结论解析为 {"summary": str, "issues": [dict], "raw": str}。
支持:`{summary, issues}` 对象、裸 issues 数组、围栏包裹、前后夹带解释文字。
解析不到结构化内容时降级为 summary=原文、issues=[]。
"""
block = extract_json_block(text)
if block is not None:
data = json.loads(block)
if isinstance(data, dict):
raw_issues = data.get("issues")
issues = (
[i for i in raw_issues if isinstance(i, dict)]
if isinstance(raw_issues, list)
else []
)
summary = data.get("summary")
return {
"summary": str(summary).strip() if summary is not None else "",
"issues": issues,
"raw": text,
}
if isinstance(data, list):
return {
"summary": "",
"issues": [i for i in data if isinstance(i, dict)],
"raw": text,
}
log("[结论解析] 非结构化输出,降级为纯文本 summary")
return {"summary": (text or "").strip(), "issues": [], "raw": text}
def collect_known_handles(evidence, extra_handles=None):
"""汇总证据里出现过的全部 handle(统一大写),外加 extra_handles。"""
known = set()
for key in _SOURCES:
for item in _iter_items((evidence or {}).get(key)):
handle = item.get("handle")
if handle:
known.add(str(handle).upper())
for handle in extra_handles or ():
if handle:
known.add(str(handle).upper())
return known
def normalize_issue(issue, *, frame_index, source, known_handles, validate_handles):
"""补 frame/source/verified 并做 handle 交叉校验,返回统一 issue 结构。"""
issue = issue if isinstance(issue, dict) else {}
handle = issue.get("handle")
handle = str(handle) if handle else None
verified = True
# 确定性问题的 handle 来自工具/数据本身,恒可信;只校验 LLM 的引用
if source != "deterministic" and handle and validate_handles:
if handle.upper() not in known_handles:
verified, handle = False, None
severity = issue.get("severity")
if severity not in _SEVERITIES:
severity = "low"
out = {
"frame": frame_index,
"category": str(issue.get("category") or "图面整体"),
"severity": severity,
"message": str(issue.get("message") or "").strip(),
"handle": handle,
"source": source,
"verified": verified,
}
handles = issue.get("handles")
if isinstance(handles, list) and handles:
out["handles"] = [str(h) for h in handles if h]
return out
def build_finding(evidence, conclusion, checks_issues=None, *,
validate_handles=True, log=print):
"""把确定性检查与 LLM 结论合并、校验后装配成一个 findings 元素。"""
evidence = evidence or {}
index = evidence.get("index")
checks = checks_issues or []
known = collect_known_handles(
evidence, extra_handles=[i.get("handle") for i in checks]
)
det = [
normalize_issue(i, frame_index=index, source="deterministic",
known_handles=known, validate_handles=validate_handles)
for i in checks
]
llm = [
normalize_issue(i, frame_index=index, source="llm",
known_handles=known, validate_handles=validate_handles)
for i in (conclusion or {}).get("issues", [])
]
issues = det + llm
return {
"frame": index,
"sheet": (evidence.get("frame") or {}).get("sheet_size"),
"summary": (conclusion or {}).get("summary", ""),
"issues": issues,
"counts": {
"deterministic": len(det),
"llm": len(llm),
"unverified": sum(1 for i in issues if not i["verified"]),
},
}
def flatten_issues(findings):
"""拍平所有图框的 issues,供 annotator / save_report 使用。"""
out = []
for finding in findings or []:
out.extend(finding.get("issues") or [])
return out