Files
PlantAI-rag-new/sdk/app/services/docgen/templating.py
jerryandClaude Fable 5 4749f77544 fix(docgen): 两批手工测试修复——上传/预览/检索 + 护栏/导出/命名/编辑还原/二次预览
第一批:上传去限制+进度展示、模板HTML预览+按需导出、检索TAB实时更新
第二批:edit护栏移除、导出按钮纯化+file_name落库、编辑失焦还原、
       二次预览白底根治(防竞态+渲染兜底)
模板体系:7套公文模板(templates/)+templating渲染器、websearch联网

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 15:49:54 +08:00

268 lines
9.8 KiB
Python
Raw Permalink 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.
"""services/docgen/templating.py —— 内置 HTML 模版系统(2026-09-04 手工测试需求2)。
模版资产:templates/ 目录(html wrapper + css,浏览器与 PyMuPDF Story 双渲染)。
CSS 双渲染纪律:仅 Story 安全子集(block flow/tables/fonts/margins,
无 flex/grid/position/@media/@page margin——见各 css 文件头注释)。
Cython 约束:仅 dataclass/dict/stdlib;不引 pydantic。全部函数不抛异常。
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from .md2pdf import _md_to_html # 复用导出同一渲染器(markdown-it-py)
logger = logging.getLogger(__name__)
_FALLBACK_KEY = "document"
# 分类词典(词序即优先级;复用 intent.py DOC_TYPE 词汇,不修改 intent.py)
_CLASSIFY_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("official", (
"通知", "通告", "公告", "请示", "批复", "函", "纪要", "会议纪要",
"公示", "决定", "命令", "意见",
)),
("report", (
"报告", "调研", "分析", "总结", "汇报", "述职", "可行性", "评估",
)),
("application", (
"申请书", "申请", "申报", "申报书", "申报材料", "标书", "项目书",
"策划书", "推荐信",
)),
("contract", ("合同", "协议", "声明", "证明")),
("speech", (
"讲话稿", "致辞", "演讲稿", "倡议书", "邀请函", "感谢信", "慰问信",
"介绍信", "发言",
)),
("plan", ("方案", "计划", "制度", "规定", "办法", "细则", "章程")),
)
_MAIN_RE = re.compile(r"<main>\s*</main>")
_TABLE_RE = re.compile(r"<table>")
_FIRST_H1_RE = re.compile(r"<h1>")
_FONT_SAFE_RE = re.compile(r"[^\w一-鿿\- ]+") # 字体名白名单(防 css 注入)
# 红头指定提取(2026-09-04):「红头用三亚海古纪生物科技有限公司」/
# 「红头:XX公司」/「红头为XX」→ 捕获公司名(2-20 个中英文字符)
_LETTERHEAD_PROMPT_RE = re.compile(
r"红头(?:文字|名称|标题|内容)?\s*(?:用|为|是|写|写成|设为|设定|叫做?|叫作?)?\s*[::,,、]?\s*([一-鿿A-Za-z0-9()()·]{2,20})"
)
@dataclass
class TemplateAsset:
key: str
title: str # 模版名(面向用户展示,落 template_name)
description: str
html: str # wrapper 全文(含 {{TITLE}} 占位)
css: str # 双渲染安全 css
source: str = "builtin"
def templates_dir() -> Path:
"""模版资产目录(同包 templates/,随包 rsync 进编译树)。"""
return Path(__file__).resolve().parent / "templates"
def load_registry() -> dict[str, Any]:
"""读 index.json。失败返回空 dict(不影响兜底)。"""
try:
raw = (templates_dir() / "index.json").read_text(encoding="utf-8")
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _fallback_key() -> str:
fb = str((load_registry() or {}).get("fallback") or _FALLBACK_KEY)
return fb or _FALLBACK_KEY
def _read_asset(name: str) -> str:
try:
return (templates_dir() / name).read_text(encoding="utf-8")
except OSError:
return ""
def get_template_asset(key: str) -> TemplateAsset | None:
"""key 无效 → None(调用方回落 fallback)。"""
entries = (load_registry() or {}).get("templates") or []
for entry in entries:
if not isinstance(entry, dict) or entry.get("key") != key:
continue
html = _read_asset(f"{key}.html")
css = _read_asset(str(entry.get("css") or f"{key}.css"))
if not html:
return None
return TemplateAsset(
key=key,
title=str(entry.get("title") or key),
description=str(entry.get("description") or ""),
html=html,
css=css,
)
return None
def list_templates() -> list[dict[str, str]]:
"""[{key, title, description}](未来模板选择 UI 用)。"""
out: list[dict[str, str]] = []
for entry in (load_registry() or {}).get("templates") or []:
if isinstance(entry, dict) and entry.get("key"):
out.append({
"key": str(entry["key"]),
"title": str(entry.get("title") or entry["key"]),
"description": str(entry.get("description") or ""),
})
return out
def classify_doc_type(description: str) -> str:
"""规则分类器 → 模版 key。逐词子串匹配(词序即优先级),均不中 → fallback。
确定性、零 LLM 成本;「自设计」= 该类型的内置设计样式。
"""
text = description or ""
for key, words in _CLASSIFY_RULES:
for word in words:
if word in text:
return key
return _fallback_key()
def _style_overlay_css(style_hints: dict[str, Any] | None) -> str:
"""style_hints(vstore docx 样式提取)→ css 覆盖行(追加在模版 css 尾部)。"""
if not isinstance(style_hints, dict) or not style_hints:
return ""
lines: list[str] = []
body_font = str(style_hints.get("body_font") or "").strip()
body_size = style_hints.get("body_size")
if body_font and _FONT_SAFE_RE.sub("", body_font):
lines.append(
f'body, p, li, td {{ font-family: "{body_font}", serif; }}'
)
if isinstance(body_size, (int, float)) and 6 <= float(body_size) <= 72:
lines.append(f"body {{ font-size: {float(body_size):.1f}pt; }}")
headings = style_hints.get("headings")
if isinstance(headings, dict):
for level, hint in headings.items():
if not isinstance(hint, dict):
continue
try:
lv = int(level)
except (TypeError, ValueError):
continue
if lv < 1 or lv > 6:
continue
font = str(hint.get("font") or "").strip()
size = hint.get("size")
if font and _FONT_SAFE_RE.sub("", font):
lines.append(f'h{lv} {{ font-family: "{font}", sans-serif; }}')
if isinstance(size, (int, float)) and 8 <= float(size) <= 60:
lines.append(f"h{lv} {{ font-size: {float(size):.1f}pt; }}")
return "\n".join(lines)
def _body_html(markdown: str) -> str:
"""md → body HTML + 模版类标记(表格 doc-table、首 h1 doc-title)。"""
body = _md_to_html(markdown or "")
body = _TABLE_RE.sub('<table class="doc-table">', body)
body = _FIRST_H1_RE.sub('<h1 class="doc-title">', body, count=1)
return f'<div class="doc-body">{body}</div>'
def _resolve_asset(key: str) -> TemplateAsset | None:
asset = get_template_asset(key)
if asset is not None:
return asset
if key != _fallback_key():
return get_template_asset(_fallback_key())
return None
def _default_letterhead(key: str) -> str:
"""各模板红头默认文字(可被制品 letterhead 覆盖,2026-09-04)。"""
return {
"official": "红头文件",
"report": "报 告",
"application": "申 报 材 料",
}.get(key, "")
def extract_letterhead_from_prompt(text: str) -> str:
"""从生成描述提取用户指定的红头文字(2026-09-04)。
「红头用三亚海古纪生物科技有限公司」「红头:XX公司」「红头为XX」→
捕获 2-20 个中英文字符;未指定(含默认「红头文件」字样的描述)返回空串。
"""
if not text:
return ""
m = _LETTERHEAD_PROMPT_RE.search(text)
return m.group(1).strip() if m else ""
def render_html(
markdown: str,
*,
title: str,
key: str = "document",
style_hints: dict[str, Any] | None = None,
letterhead: str = "",
) -> tuple[str, str]:
"""markdown → (完整 html, css)。纯同步纯函数,绝不抛异常。
letterhead:红头文字(编辑可改,制品持久化);空串回落模板默认。
任何环节失败都回落 fallback 模版;再失败给最小可读 HTML。
"""
try:
asset = _resolve_asset(key) or _resolve_asset(_FALLBACK_KEY)
body = _body_html(markdown)
if asset is not None:
wrapper = asset.html
if _MAIN_RE.search(wrapper):
html = _MAIN_RE.sub("<main>" + body + "</main>", wrapper, count=1)
else:
html = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"<title>{_esc(str(title))}</title></head><body>"
f"{body}</body></html>"
)
css = asset.css
overlay = _style_overlay_css(style_hints)
if overlay:
css = f"{css}\n/* style_hints 覆盖 */\n{overlay}"
else:
html = (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
f"<title>{_esc(str(title))}</title></head><body>{body}</body></html>"
)
css = ""
# 标题/红头占位(wrapper 的 <title> 与 .doc-letterhead 用)
html = html.replace("{{TITLE}}", _esc(str(title)))
now = datetime.now().strftime("%Y年%m月%d日")
html = html.replace("{{DATE}}", now)
lh = (letterhead or "").strip() or _default_letterhead(key)
html = html.replace("{{LETTERHEAD}}", _esc(lh))
return html, css
except Exception as exc: # 兜底:绝不抛
logger.warning("docgen: 模版渲染失败(%s),回落最小 HTML", exc)
return (
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head>"
f"<body><p>{_esc(str(markdown or ''))}</p></body></html>",
"",
)
def _esc(text: str) -> str:
return (
str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
)