zcbot/core/pptx_guard.py

139 lines
5.4 KiB
Python

"""pptx_guard.py: 平台层 .pptx 产物机检——拦「整页贴图」式伪导出。
背景(PROGRESS 0.35.1 及本次复发):模型两次绕开 ppt skill 官方导出器
svg_to_pptx.py,用外部渲染器把每页渲成整页 PNG 贴进幻灯片交付——产物零原生
文本、不可编辑,skill 价值归零。官方管线内的门只在模型用了官方脚本时生效,
绕开管线的产物只能在平台层拦:shell / run_python 每步执行后,对本步新产出的
.pptx 就地机检,命中特征即在 tool 结果里注入 [产物机检 ERROR],逼模型当场
返工回官方管线。
判定刻意保守,只拦"确证的伪产物":整本 >= _RASTER_PAGE_RATIO 的页是
「一张覆盖 >= _PIC_AREA_RATIO 版面的图片 + 本页零文本」,且全 deck 原生文本
字符数为 0,且页数 >= 2。正常 deck(原生导出、带背景大图但有文字、单页海报、
含表格图表)都不会命中。解析失败一律放行——这门不做通用校验。
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterator, List, Optional
try:
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
except Exception: # pragma: no cover - 宿主缺 python-pptx 时静默降级为不检
Presentation = None
MSO_SHAPE_TYPE = None
# 单页判「贴图页」:存在一张覆盖 >= 85% 版面的图片,且本页无任何非空文本
_PIC_AREA_RATIO = 0.85
# 整本判废:贴图页占比 >= 80%(容忍手搓时混入个别空白/封面页)
_RASTER_PAGE_RATIO = 0.8
# mtime 与 host 时钟的容差(秒)
_MTIME_EPSILON = 2.0
def _iter_shapes(shapes) -> Iterator:
"""展平 group,产出所有叶子 shape。"""
for sh in shapes:
try:
if sh.shape_type == MSO_SHAPE_TYPE.GROUP:
yield from _iter_shapes(sh.shapes)
continue
except Exception:
pass
yield sh
def find_new_pptx(root: Path, since: float) -> List[Path]:
"""root 下 mtime >= since - epsilon 的 .pptx;跳过隐藏目录(.build 等)。"""
found: List[Path] = []
try:
for p in root.rglob("*.pptx"):
try:
rel_parts = p.relative_to(root).parts[:-1]
except ValueError:
rel_parts = ()
if any(part.startswith(".") for part in rel_parts):
continue
try:
if p.stat().st_mtime >= since - _MTIME_EPSILON:
found.append(p)
except OSError:
continue
except OSError:
pass
return found
def check_raster_deck(path: Path) -> Optional[str]:
"""判「整页贴图」deck。返回 None = 通过;返回字符串 = 判废原因。
打不开 / 解析失败一律放行:这门只拦确证的伪产物,不承担通用文件校验。
"""
if Presentation is None:
return None
try:
prs = Presentation(str(path))
slides = list(prs.slides)
if len(slides) < 2:
return None
page_area = float(prs.slide_width or 0) * float(prs.slide_height or 0)
if page_area <= 0:
return None
raster_pages = 0
total_text_chars = 0
has_native_content = False # 表格/图表也算原生内容
for slide in slides:
big_pic = False
page_text = 0
for sh in _iter_shapes(slide.shapes):
try:
if sh.has_text_frame:
page_text += len(sh.text_frame.text.strip())
st = sh.shape_type
if st in (MSO_SHAPE_TYPE.PICTURE, MSO_SHAPE_TYPE.LINKED_PICTURE):
w = float(sh.width or 0)
h = float(sh.height or 0)
if w * h / page_area >= _PIC_AREA_RATIO:
big_pic = True
elif st in (MSO_SHAPE_TYPE.TABLE, MSO_SHAPE_TYPE.CHART):
has_native_content = True
except Exception:
continue
total_text_chars += page_text
if big_pic and page_text == 0:
raster_pages += 1
if (
total_text_chars == 0
and not has_native_content
and raster_pages / len(slides) >= _RASTER_PAGE_RATIO
):
return (
f"{len(slides)} 页,其中 {raster_pages} 页仅为一张覆盖整版的位图,"
f"且全文档没有任何原生文本/表格/图表 —— 判定为「SVG 渲 PNG 整页贴图」"
f"式伪导出(用户拿到的是不可编辑的图片册,不是 PPT)"
)
return None
except Exception:
return None
def scan_and_report(root: Path, since: float) -> Optional[str]:
"""扫 root 下本步新产出的 .pptx,命中伪导出则返回注入 tool 结果的 ERROR 文本。"""
bad: List[str] = []
for p in find_new_pptx(root, since):
reason = check_raster_deck(p)
if reason:
bad.append(f"- `{p.name}`:{reason}")
if not bad:
return None
return (
"[产物机检 ERROR] 检测到不合格的 .pptx 产物:\n"
+ "\n".join(bad)
+ "\n该文件不可交付:请删除它,回 ppt skill 官方管线用 scripts/svg_to_pptx.py "
"重新导出(它是纯 Python 原生导出,不需要任何渲染依赖;报错就按报错修,"
"修不动就把报错原文如实告知用户)。不要另写导出脚本或改用渲染贴图。"
)