"""pipeline_service.diagram_gate — 文档配图形态硬门禁(2026-09-15 用户要求)。 需求/设计文档(SDLC)与标书章节(投标)中的各种图**必须**用平台 t2i/i2i 模型生成真实图片(invoke_model → ![图名](产物URL) 嵌入正文)。 禁止 mermaid/plantuml 代码块、ASCII 字符画、文本框线拼「示意图」凑数。 判定是**确定性的**(花钱/质量闸门不靠 LLM 自觉,对齐 platform_model_tools 完备性门禁的分层原则): - mermaid/plantuml/dot 围栏代码块 → 违规 - 制表符画(│├└┌─… 箱线字符 + 同行有实际内容)≥2 行 → 违规 - +---+ 文本边框 ≥2 行 → 违规 纯分隔线(整行只有 ─ 和空白)不算图,避免误伤。 唯一逃逸阀:平台无任何图像生成模型时,产出中如实标注 「配图缺失:平台无可用文生图模型」(DIAGRAM_MISSING_MARK),门禁放行。 消费方: - agent_loop.role_agent_run deliver 拦截(SDLC requirement/design 角色) - pipeline-bidding bid_write_capability.write_chapter / bid_review_capability.review_chapter """ import re # 如实标注配图缺失的豁免标记(包含即放行,不逼死无图像模型的环境) DIAGRAM_MISSING_MARK = "配图缺失" # 需要真图的围栏代码块(画图 DSL 一律禁止) _FENCE_RE = re.compile(r"```[ \t]*(?:mermaid|plantuml|puml|dot|graphviz)\b", re.I) # 箱线字符(制表符画/框图常用) _BOX_CHARS = set("│├└┌┐┘┬┴┤┼╔╗╚╝╠╣╦╩╬═║─━┃┏┓┗┛") # 角/连接字符:出现即判定为箱线图边框行(纯 ─ 分隔线无角字符,不误伤) _CORNER_CHARS = set("┌┐└┘┏┓┗┛╔╗╚╝├┤┬┴┼╠╣╦╩╬") # +----+ 风格文本边框 _PLUS_BOX_RE = re.compile(r"\+[-=]{3,}\+") def _is_tree_entry(line): """目录树条目行(├── src/main.py 这类):箱线字符集中在行首前缀, 其后是非空的普通文本(无箱线字符穿插)→ 是文本清单不是图,豁免。 整行全是箱线字符(┏━━━━┓)不是目录树条目(是图边框)。 反例(判为图):├─组合─ 工单消息——箱线字符与内容穿插。""" s = line i = 0 # 跳过行首的箱线字符与空白(目录树缩进前缀可含 │ + 空格,如 │ ├── name) while i < len(s) and (s[i] in _BOX_CHARS or s[i].isspace()): i += 1 if i == 0: return False rest = s[i:] if not rest.strip(): return False # 整行全是箱线字符/空白(如 ┏━━━━━━┓)→ 是图边框,非目录条目 return not any(ch in _BOX_CHARS for ch in rest) def _is_box_line(line): """该行是否是箱线画的组成部分(确定性判定,纯 ──── 分隔线不算图,防误伤): - 含任一角/连接字符(┌┐└┘┏┓┗┛├┤┬┴┼…)→ 是边框/树形线;或 - ≥2 个箱线字符且除箱线/空白外还有 ≥2 个实际内容字符(如 ├─组合─ 工单)。 豁免:目录树条目行(├── src/ 这类「箱线字符+单个词」)是文本清单不是图。""" n_box = 0 n_other = 0 for ch in line: if ch in _BOX_CHARS: n_box += 1 elif not ch.isspace(): n_other += 1 if any(ch in _CORNER_CHARS for ch in line): # 目录树条目豁免:├── name / └── name/ / │ ├── name(箱线后只有普通文本) if _is_tree_entry(line): return False return True return n_box >= 2 and n_other >= 2 def find_fake_diagrams(text): """检测文本中的伪图,返回违规描述列表(空 = 通过)。确定性判定,无语义猜测。""" problems = [] if not text: return problems if DIAGRAM_MISSING_MARK in text: # 如实标注平台无图像模型 → 逃逸阀放行(标注真实性由 QC/评审按平台模型清单核对) return problems if _FENCE_RE.search(text): problems.append("mermaid/plantuml 画图代码块") lines = text.splitlines() box_lines = sum(1 for ln in lines if _is_box_line(ln)) if box_lines >= 2: problems.append("ASCII/箱线字符画示意图(%d 行)" % box_lines) plus_lines = sum(1 for ln in lines if _PLUS_BOX_RE.search(ln)) if plus_lines >= 2: problems.append("+---+ 文本边框示意图(%d 行)" % plus_lines) return problems def gate_message(problems, scene="文档"): """统一的可行动拒绝话术(说清怎么改,不是只说不行)。""" return ("FAIL: %s中的图必须用平台 t2i/i2i 模型生成真实图片(2026-09-15 硬规定)。" "发现伪图:%s。请逐图改用 invoke_model 生成" "(model 留空自动选型;task 写清图的主体/层次/风格——商务风、白底、含中文标注——" "及图中文字要求;改造已有图带输入图 URL 走 i2i)," "产物 URL 以 ![图名](URL) 嵌入正文,删除 mermaid/ASCII 字符画后重新提交。" "仅当平台确无任何图像生成模型时,才可在文中如实标注「%s:平台无可用文生图模型」。" % (scene, ";".join(sorted(set(problems))), DIAGRAM_MISSING_MARK)) def count_image_embeds(text): """统计 markdown 图片嵌入行数(![...](...),排除空 URL)。正向门禁用: 要求含四张图/架构图的文档至少有一张真图。""" if not text: return 0 return len([m for m in re.finditer(r"!\[[^\]]*\]\(([^)\s]+)\)", text) if m.group(1).strip()])