diff --git a/sdk/app/db.py b/sdk/app/db.py
index 23422ef..2751a89 100644
--- a/sdk/app/db.py
+++ b/sdk/app/db.py
@@ -96,6 +96,18 @@ CREATE INDEX IF NOT EXISTS idx_doc_artifacts_session ON doc_artifacts(session_id
CREATE INDEX IF NOT EXISTS idx_doc_artifacts_created ON doc_artifacts(created_at);
"""
+# docgen 模板预览(2026-09-04)增量列:幂等 ALTER(PRAGMA table_info 判列存在)。
+# html_path:生成时落盘的 html 文件相对路径(渲染读取按 md 现渲染,此列作 TTL 清理键)
+# template_key:内置模板 key(official/report/...)
+# template_css:生成时定稿的 style_hints 覆盖 css(渲染/导出免 vstore 往返)
+_DOCGEN_MIGRATIONS = (
+ ("html_path", "ALTER TABLE doc_artifacts ADD COLUMN html_path TEXT"),
+ ("template_key", "ALTER TABLE doc_artifacts ADD COLUMN template_key TEXT NOT NULL DEFAULT ''"),
+ ("template_css", "ALTER TABLE doc_artifacts ADD COLUMN template_css TEXT NOT NULL DEFAULT ''"),
+ ("letterhead", "ALTER TABLE doc_artifacts ADD COLUMN letterhead TEXT NOT NULL DEFAULT ''"),
+ ("file_name", "ALTER TABLE doc_artifacts ADD COLUMN file_name TEXT NOT NULL DEFAULT ''"),
+)
+
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -136,6 +148,7 @@ class Database:
# docgen 增量表(P2):单独执行;失败只记日志,不阻断 BFF 启动
try:
await self._conn.executescript(_DOCGEN_SCHEMA)
+ await self._migrate_docgen_columns()
except Exception:
logger.exception("docgen 表迁移失败(docgen 端点将不可用,其余不受影响)")
await self._conn.commit()
@@ -145,6 +158,14 @@ class Database:
await self._conn.close()
self._conn = None
+ async def _migrate_docgen_columns(self) -> None:
+ """doc_artifacts 增量列幂等迁移(老库已建表时 CREATE IF NOT EXISTS 不会加列)。"""
+ cur = await self._conn.execute("PRAGMA table_info(doc_artifacts)")
+ cols = {row[1] for row in await cur.fetchall()}
+ for name, ddl in _DOCGEN_MIGRATIONS:
+ if name not in cols:
+ await self._conn.execute(ddl)
+
@property
def conn(self) -> aiosqlite.Connection:
if self._conn is None:
diff --git a/sdk/app/routers/docgen.py b/sdk/app/routers/docgen.py
index 9e74100..db0ae10 100644
--- a/sdk/app/routers/docgen.py
+++ b/sdk/app/routers/docgen.py
@@ -41,6 +41,7 @@
from __future__ import annotations
import logging
+import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
@@ -66,22 +67,26 @@ from ..upstream import envelope
from ..services.docgen.client import (
DocgenUnavailable,
delete_document,
+ get_style,
ingest_bytes,
rebuild_index,
search_documents,
update_metadata,
)
-from ..services.docgen.converter import docx_from_markdown, pdf_from_markdown
+from ..services.docgen.converter import (
+ docx_from_markdown,
+ pdf_from_html_document,
+)
from ..services.docgen.editor import apply_edit
-from ..services.docgen.intent import is_docgen_intent, route
-from ..services.docgen.pipeline import generate_document
+from ..services.docgen.intent import route
+from ..services.docgen.pipeline import generate_document, name_for_prompt
+from ..services.docgen.templating import render_html
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/docgen", tags=["docgen"])
-# 上传护栏(先例:extraction.py 50MB PDF)
-MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100MB
+# 2026-09-04 手工测试:上传不再设大小门槛(原 100MB 检查移除)
MAX_DESCRIPTION_CHARS = 2000 # 生成描述上限
@@ -221,7 +226,7 @@ async def _prune_expired_artifacts(db: Database, settings: Any) -> int:
"SELECT * FROM doc_artifacts WHERE created_at < ? AND status != 'expired'", (cutoff,)
)
for row in rows:
- for col in ("md_path", "docx_path", "pdf_path"):
+ for col in ("md_path", "docx_path", "pdf_path", "html_path"):
rel = row[col]
if not rel:
continue
@@ -234,7 +239,7 @@ async def _prune_expired_artifacts(db: Database, settings: Any) -> int:
logger.warning("docgen: TTL 清理删除文件失败 %s: %s", rel, exc)
await db.execute(
"UPDATE doc_artifacts SET status = 'expired', md_path = NULL, docx_path = NULL, "
- "pdf_path = NULL WHERE id = ?",
+ "pdf_path = NULL, html_path = NULL WHERE id = ?",
(row["id"],),
)
return len(rows)
@@ -265,8 +270,6 @@ async def upload_library_item(
await file.close()
if not content:
return _validation("上传文件为空")
- if len(content) > MAX_UPLOAD_BYTES:
- return _validation("文件超过 100MB 限制")
doc_name = (name or "").strip() or filename
try:
@@ -320,7 +323,7 @@ async def upload_library_item(
{
"library_id": out.id,
"name": out.name,
- "size": result.get("size"),
+ "size": result.get("size") or len(content),
"as_template": out.as_template,
}
],
@@ -332,12 +335,9 @@ async def upload_library_item(
# ---------------------------------------------------------------------------
async def _intent_guard(text: str, *, has_active_artifact: bool) -> JSONResponse | None:
- """意图护栏:直接调 dev1 的 route 三参判定(is_docgen_intent 布尔接口不动)。
+ """意图护栏(2026-09-04 起仅 generate 调用——edit 已无护栏,见端点注释)。
- - generate:has_active_artifact=False → route(...) == "docgen" 才放行
- - edit:has_active_artifact=True → route(...) in ("docgen", "edit") 才放行
- (edit 端点被调用本身即存在活动文档,编辑指令如「润色一下第一段」判 edit)
- - export:不经过本护栏(format 已有 pydantic Literal 白名单)
+ - generate:route(...) == "docgen" 才放行
路由异常 → 放行(漏判安全,护栏不阻断)。
"""
try:
@@ -429,11 +429,22 @@ async def generate_doc(
artifact_id = str(uuid.uuid4())
created = _db_now()
title = payload.description.strip()[:60] or "未命名文档"
+ # 2026-09-04:库内模版样式线索(docx style_hints → css 覆盖);失败静默 None
+ template_style: dict[str, Any] | None = None
+ if template and template.get("id"):
+ try:
+ style = await get_style(str(template["id"]))
+ template_style = style if isinstance(style, dict) else None
+ except Exception as exc:
+ logger.warning("docgen: 模版样式提取失败(忽略): %s", exc)
+ web_search = os.environ.get("DOCGEN_WEB_TEMPLATE_SEARCH", "1") != "0"
try:
result = await generate_document(
payload.description,
documents=docs,
template=template,
+ template_style=template_style,
+ web_search=web_search,
)
except DocgenUnavailable as exc:
logger.warning("docgen: 生成服务不可用:%s", exc)
@@ -469,6 +480,27 @@ async def generate_doc(
path.write_text(markdown, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc)
+
+ # 2026-09-04:模版 HTML 落盘(预览缓存件;渲染读取按 md 现渲染,
+ # html_path 仅作 TTL 清理键与 has_html 标记)
+ template_key = str(result.get("template_key") or "")
+ template_css = str(result.get("css") or "")
+ tpl_display = str(result.get("template_name") or "") or (
+ (template or {}).get("name", "") or (template or {}).get("title", "")
+ )
+ html = str(result.get("html") or "")
+ html_rel = ""
+ if html:
+ html_rel = f"artifacts/{artifact_id}.html"
+ safe_rel = _safe_rel(html_rel)
+ if safe_rel:
+ path = _artifact_dir(get_settings()) / safe_rel
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(html, encoding="utf-8")
+ except OSError as exc:
+ logger.warning("docgen: 模版 HTML 落盘失败(不影响返回): %s", exc)
+ html_rel = ""
# 生成成功后顺带复查配额(写盘后体积更准);超限仍返回成功(artifact 已存在)
try:
quota_after = await _check_quota(db, get_settings())
@@ -478,11 +510,14 @@ async def generate_doc(
logger.warning("docgen: 生成后配额复查失败(忽略): %s", exc)
await db.execute(
"INSERT INTO doc_artifacts (id, session_id, status, title, template_name, "
- "md_path, docx_path, pdf_path, error, created_at) "
- "VALUES (?, ?, 'done', ?, ?, ?, NULL, NULL, NULL, ?)",
+ "template_key, template_css, md_path, docx_path, pdf_path, html_path, letterhead, file_name, error, created_at) "
+ "VALUES (?, ?, 'done', ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL, ?)",
(artifact_id, payload.session_id or "", title,
- (template or {}).get("name", "") or (template or {}).get("title", ""),
- md_rel, created),
+ tpl_display, template_key, template_css, md_rel,
+ html_rel or None,
+ str(result.get("letterhead") or "")[:60],
+ str(result.get("file_name") or name_for_prompt(payload.description) or title or "")[:60],
+ created),
)
return {
"ok": True,
@@ -490,7 +525,12 @@ async def generate_doc(
"id": artifact_id,
"title": title,
"status": "done",
- "template_name": (template or {}).get("name", "") or (template or {}).get("title", ""),
+ "template_name": tpl_display,
+ "template_key": template_key,
+ "has_html": bool(html_rel),
+ "letterhead": str(result.get("letterhead") or ""),
+ "file_name": str(result.get("file_name") or name_for_prompt(payload.description) or title or ""),
+ "outline_source": str(result.get("outline_source") or ""),
"created_at": created,
},
}
@@ -559,6 +599,12 @@ async def patch_artifact_content(
"UPDATE doc_artifacts SET md_path = ?, status = 'done', error = NULL WHERE id = ?",
(md_rel, artifact_id),
)
+ # 2026-09-04:红头文字就地编辑持久化(letterhead 非 None 时更新)
+ if payload.letterhead is not None:
+ await db.execute(
+ "UPDATE doc_artifacts SET letterhead = ? WHERE id = ?",
+ ((payload.letterhead or "").strip()[:60], artifact_id),
+ )
return {"ok": True, "markdown": markdown}
@@ -573,9 +619,9 @@ async def edit_artifact(
if row is None:
return _not_found(f"文档 {artifact_id} 不存在")
- guard = await _intent_guard(payload.instruction, has_active_artifact=True)
- if guard is not None:
- return guard
+ # 2026-09-04 手工测试问题1:编辑端点护栏移除——被调用本身即活动文档上下文,
+ # 「把底部的公司名称和日期居右」这类不含编辑动词的排版指令必须放行
+ # (原 route() in ("docgen","edit") 判成 qa 被 422 拦截报「修改失败」)。
current = await _artifact_content(get_settings(), row)
if current is None:
@@ -621,6 +667,47 @@ async def edit_artifact(
return {"ok": True, "markdown": markdown}
+@router.get("/artifacts/{artifact_id}/html")
+async def get_artifact_html(
+ artifact_id: str,
+ db: Database = Depends(get_db),
+):
+ """2026-09-04:模版 HTML 预览。每次按最新 md 现渲染(md 是正本)。
+
+ css 用生成时定稿的 template_css(视觉稳定),非空时优先;
+ 否则按 template_key 现取。返回 {ok, html, css, template_key, title, status}。
+ """
+ row = await _get_artifact_row(db, artifact_id)
+ if row is None:
+ return _not_found(f"文档 {artifact_id} 不存在")
+ markdown = await _artifact_content(get_settings(), row)
+ title = str(row.get("title") or "document")
+ if markdown is None:
+ return {
+ "ok": True,
+ "html": "",
+ "css": "",
+ "template_key": str(row.get("template_key") or ""),
+ "title": title,
+ "status": row.get("status"),
+ }
+ key = str(row.get("template_key") or "")
+ frozen_css = str(row.get("template_css") or "")
+ letterhead = str(row.get("letterhead") or "")
+ html, rendered_css = render_html(
+ markdown, title=title, key=key, letterhead=letterhead
+ )
+ return {
+ "ok": True,
+ "html": html,
+ "css": frozen_css or rendered_css,
+ "template_key": key,
+ "letterhead": letterhead,
+ "title": title,
+ "status": row.get("status"),
+ }
+
+
@router.post("/artifacts/{artifact_id}/export")
async def export_artifact(
artifact_id: str,
@@ -650,9 +737,20 @@ async def export_artifact(
try:
if fmt == "docx":
+ # DOCX 保持 markdown 路径(纯编辑副本;PDF 才是所见即所得)
data = await docx_from_markdown(markdown, title)
else:
- data = await pdf_from_markdown(markdown, title)
+ # 2026-09-04:PDF = 预览同源(模版 HTML+CSS → Story),所见即所得。
+ # html 按最新 md 现渲染;css 用生成时定稿(template_css 非空)
+ # 或按 template_key 现取——保证制品的视觉样式稳定不随模板资产漂移。
+ key = str(row.get("template_key") or "")
+ frozen_css = str(row.get("template_css") or "")
+ letterhead = str(row.get("letterhead") or "")
+ html, rendered_css = render_html(
+ markdown, title=title, key=key, letterhead=letterhead
+ )
+ css = frozen_css or rendered_css
+ data = await pdf_from_html_document(html, css, title)
except DocgenUnavailable as exc:
logger.warning("docgen: 导出转换服务不可用:%s", exc)
return _envelope("UPSTREAM_DOWN", message="文档导出服务未就绪,请稍后重试")
@@ -694,7 +792,11 @@ async def download_artifact(
data = _read_artifact_file(get_settings(), rel) if rel else None
if data is None:
return _not_found("导出文件不存在(请先导出)")
- fname = f"{row.get('title') or artifact_id}.{format}".replace("/", "_")
+ # 2026-09-04 手工测试问题3:文件名不能以整段 prompt 命名——
+ # 用生成时提炼的 file_name(「十一放假通知」这类核心短语);
+ # 旧行无 file_name → 回退 title 截断。
+ base = str(row.get("file_name") or "").strip() or str(row.get("title") or "").strip()
+ fname = f"{(base or artifact_id).replace('/', '_')}.{format}"
return FileResponse(
path=_artifact_dir(get_settings()) / _safe_rel(rel),
media_type=(
diff --git a/sdk/app/schemas.py b/sdk/app/schemas.py
index 27fe29d..82ac768 100644
--- a/sdk/app/schemas.py
+++ b/sdk/app/schemas.py
@@ -297,6 +297,9 @@ class ArtifactOut(BaseModel):
status: str = "running" # running|done|error
title: str = ""
template_name: str = ""
+ template_key: str = "" # 2026-09-04:内置模版 key(official/report/...)
+ has_html: bool = False # 模版 HTML 预览是否可用
+ file_name: str = "" # 2026-09-04:导出文件名基名(prompt 核心意思提炼)
error: str | None = None
created_at: str = Field(default_factory=utcnow_iso)
@@ -317,9 +320,13 @@ class DocgenEditRequest(BaseModel):
class ArtifactContentPatch(BaseModel):
- """PATCH /api/docgen/artifacts/{id}/content:直接保存编辑后的 markdown。"""
+ """PATCH /api/docgen/artifacts/{id}/content:保存编辑后的 markdown。
+
+ 2026-09-04:letterhead 可选——红头文字就地编辑后一并持久化(None=不改)。
+ """
markdown: str
+ letterhead: str | None = None
class DocgenExportRequest(BaseModel):
diff --git a/sdk/app/services/docgen/client.py b/sdk/app/services/docgen/client.py
index 46ee0f3..c6251ff 100644
--- a/sdk/app/services/docgen/client.py
+++ b/sdk/app/services/docgen/client.py
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
DEFAULT_VSTORE_BASE_URL = "http://127.0.0.1:8094"
RETRY_BACKOFF = (0.5, 1.0, 2.0) # 重试退避(秒)
MAX_RETRIES = 3
-DEFAULT_TIMEOUT = 60.0
+DEFAULT_TIMEOUT = 600.0 # 2026-09-04 上传无限制:大文件解析+嵌入可超 60s,与前端 600s 兜底对齐
class DocgenUnavailable(Exception):
diff --git a/sdk/app/services/docgen/converter.py b/sdk/app/services/docgen/converter.py
index 52b403b..d666d00 100644
--- a/sdk/app/services/docgen/converter.py
+++ b/sdk/app/services/docgen/converter.py
@@ -31,6 +31,19 @@ async def pdf_from_markdown(markdown: str, title: str) -> bytes:
return await anyio.to_thread.run_sync(_pdf_bytes, markdown, title)
+async def pdf_from_html_document(html: str, css: str, title: str) -> bytes:
+ """2026-09-04:模版 HTML + css → pdf bytes(async,预览同源导出)。"""
+ import anyio
+
+ return await anyio.to_thread.run_sync(_pdf_html_bytes, html, css, title)
+
+
+def _pdf_html_bytes(html: str, css: str, title: str) -> bytes:
+ with tempfile.TemporaryDirectory() as tmp:
+ out = md2pdf.pdf_from_html(html, css, Path(tmp) / "out.pdf")
+ return out.read_bytes()
+
+
def _docx_bytes(markdown: str, title: str) -> bytes:
"""docx 转换核心(临时文件 → bytes,不残留)。"""
with tempfile.TemporaryDirectory() as tmp:
diff --git a/sdk/app/services/docgen/md2pdf.py b/sdk/app/services/docgen/md2pdf.py
index 623dcb1..9b753b1 100644
--- a/sdk/app/services/docgen/md2pdf.py
+++ b/sdk/app/services/docgen/md2pdf.py
@@ -64,8 +64,12 @@ def _find_fonts() -> tuple[Path, Path, Path]:
def _md_to_html(markdown: str) -> str:
- """markdown → HTML(markdown-it-py gfm-like:表格/列表/引用)。"""
- md = MarkdownIt("commonmark") # gfm-like 需 linkify-it-py(未安装),commonmark 够用
+ """markdown → HTML(markdown-it-py:commonmark + table 启用)。
+
+ 2026-09-04:commonmark 预设默认不启表格(表格被当作纯文本段落)——
+ .enable("table") 后 | 表格才出
(模板渲染/PDF 导出的表格依赖它)。
+ """
+ md = MarkdownIt("commonmark").enable("table") # gfm-like 需 linkify-it-py(未安装),commonmark+table 够用
return md.render(markdown or "")
@@ -193,3 +197,89 @@ def pdf_from_markdown(markdown: str, out_path: str | Path) -> Path:
logger.warning("md2pdf: Story 渲染失败(%s),降级自写排版器", exc)
_fallback_pdf(markdown, out, serif, sans_bold)
return out
+
+
+# ---------------------------------------------------------------------------
+# HTML 模版渲染(2026-09-04 手工测试需求2:预览与导出同源 HTML+CSS)
+# ---------------------------------------------------------------------------
+
+_FAMILY_MAP = (
+ # 映射为不带引号的注册名——MuPDF Story 对带引号家族名解析失败(回退字体
+ # 导致 CJK 提取乱码,2026-09-04 实测),_story_css 自身即用无引号名。
+ (re.compile(r'"(Noto Serif CJK SC|SimSun|宋体)"'), "serif-cjk"),
+ (re.compile(r'"(Noto Sans CJK SC|SimHei|黑体|Microsoft YaHei)"'), "sans-cjk"),
+)
+_BANNED_CSS_RE = re.compile(
+ r"@page[^{]*\{[^}]*\}|@media[^{]*\{[^}]*\}|"
+ r"[^{};]*(?:display\s*:\s*(?:flex|grid)|position\s*:|transform\s*:)[^;}]*;?",
+ re.IGNORECASE,
+)
+# lang 属性会触发 MuPDF 按语言选字体(zh-CN → 非 CJK 字体 → 提取乱码,
+# 2026-09-04 实测);PDF 路径必须剥掉(预览端浏览器不受影响)
+_LANG_ATTR_RE = re.compile(r'\s+lang="[^"]*"', re.IGNORECASE)
+
+
+def _strip_lang(html: str) -> str:
+ return _LANG_ATTR_RE.sub("", html or "")
+
+
+def _adapt_family_names(css: str) -> str:
+ """模版 css → Story 兼容:剥注释(MuPDF CSS 解析器对注释内 CJK/全角标点
+ 会报语法错误并丢弃样式,2026-09-04 实测导致回退字体+提取乱码)、
+ 家族名映射到 @font-face 注册名(必须无引号,带引号注册名同样解析失败)。"""
+ adapted = re.sub(r"/\*[\s\S]*?\*/", "", css or "")
+ for pattern, mapped in _FAMILY_MAP:
+ adapted = pattern.sub(mapped, adapted)
+ adapted = re.sub(r'font-family\s*:\s*serif\b', 'font-family: serif-cjk', adapted)
+ adapted = re.sub(r'font-family\s*:\s*sans-serif\b', 'font-family: sans-cjk', adapted)
+ # 残余带引号注册名也归一(保险)
+ adapted = re.sub(r'"(serif-cjk|sans-cjk)"', r'\1', adapted)
+ return _BANNED_CSS_RE.sub("", adapted)
+
+
+def _html_to_plain_lines(html: str) -> str:
+ """降级排版器输入适配:HTML → 近似 markdown 行(标题 #、表格 |、段落原文)。"""
+ lines: list[str] = []
+ for m in re.finditer(r"<(h[1-6])[^>]*>([\s\S]*?)\1>", html, re.IGNORECASE):
+ level = int(m.group(1)[1])
+ text = re.sub(r"<[^>]+>", "", m.group(2)).strip()
+ if text:
+ lines.append(f"{'#' * level} {text}")
+ for m in re.finditer(r"]*>([\s\S]*?)
", html, re.IGNORECASE):
+ cells = [
+ re.sub(r"<[^>]+>", "", c).strip()
+ for c in re.findall(r"]*>([\s\S]*?)", m.group(1), re.IGNORECASE)
+ ]
+ if cells:
+ lines.append("| " + " | ".join(cells) + " |")
+ for m in re.finditer(r"]*>([\s\S]*?)
", html, re.IGNORECASE):
+ text = re.sub(r"<[^>]+>", "", m.group(1)).strip()
+ if text:
+ lines.append(text)
+ return "\n".join(lines)
+
+
+def pdf_from_html(html: str, css: str, out_path: str | Path) -> Path:
+ """HTML(模版渲染产物)→ PDF。Story 主路线;失败/空文本降级自写排版器。
+
+ css 为模板双渲染 css(家族名经 _adapt_family_names 映射到 Story 字体)。
+ """
+ out = Path(out_path)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ serif, sans, sans_bold = _find_fonts()
+ full_css = _story_css(serif, sans, sans_bold) + "\n" + _adapt_family_names(css or "")
+ story_html = _strip_lang(html)
+ try:
+ _story_to_pdf(story_html, full_css, serif, out)
+ check = pymupdf.open(str(out))
+ try:
+ text = "".join(p.get_text() for p in check)
+ finally:
+ check.close()
+ if text.strip():
+ return out
+ logger.warning("md2pdf: Story(html) 输出无文本,降级自写排版器")
+ except Exception as exc:
+ logger.warning("md2pdf: Story(html) 渲染失败(%s),降级自写排版器", exc)
+ _fallback_pdf(_html_to_plain_lines(html), out, serif, sans_bold)
+ return out
diff --git a/sdk/app/services/docgen/pipeline.py b/sdk/app/services/docgen/pipeline.py
index db9ba31..924551d 100644
--- a/sdk/app/services/docgen/pipeline.py
+++ b/sdk/app/services/docgen/pipeline.py
@@ -3,7 +3,8 @@
流程:取明文资料(vstore 解密)→ 选模版(指定 > 检索 as_template > 跳过)→
大纲(模版 outline 裁剪 ≤12 节;无模版 complete_json,失败重试 1 次再失败
六段式默认)→ 逐节生成合并(单节失败重试 2 次仍失败占位)→ humanize
-按行保护(标题/表格/列表行原样,只 humanize 正文段)→ md 落盘。
+按行保护(标题/表格/列表行原样,只 humanize 正文段)→ 文件名提炼
+(2026-09-04:name_for_prompt 供导出文件名用,非 LLM 路径兜底)→ md 落盘。
契约(dev2,sdk/app/routers/docgen.py 桩段):
generate_document(description, *, documents, template) -> {markdown, md_path}
@@ -20,7 +21,8 @@ from pathlib import Path
from typing import Any
from . import client, llm
-from .settings import MAX_SECTIONS, SECTION_MAX_CHARS
+from . import templating, websearch
+from .settings import MATERIAL_BUDGET_CHARS, MAX_SECTIONS, SECTION_MAX_CHARS
try: # sdk 内运行(app 包)用现有 humanizer;脱离 sdk 运行回退原样
from app.services.humanizer import humanize_answer
@@ -44,6 +46,16 @@ DEFAULT_OUTLINE = [
_HEADING_RE = re.compile(r"^#{1,6}\s+(.*)$")
+# 文件名核心短语(2026-09-04 手工测试问题3):导出文件名不能整段 prompt 命名,
+# 取「<礼貌词>+<动词>+<量词>+文档类型词」的核心短语;前缀按出现顺序逐层剥离
+# (「帮我写一个」「拟一份」「写个」组合皆可),无效字符替换/剔除防路径注入。
+_NAME_POLITE_RE = re.compile(r"^(?:请|帮我|帮忙|来|给我|我想|需要|想要|麻烦|希望|麻烦您)+")
+_NAME_VERB_RE = re.compile(
+ r"^(?:写|撰写|拟|起草|编制|生成|编写|拟定|草拟|制定|写个|写一份|写一个|拟个|起草个|来份)+"
+)
+_NAME_MEASURE_RE = re.compile(r"^(?:一个|一份|一篇|一则|个|份|篇)+")
+_NAME_BAD_CHARS_RE = re.compile(r'[\\/:*?"<>|\r\n\t]')
+
# 行级保护判定(这些行原样保留,不做 humanize)
_ROW_PROTECT_RE = re.compile(
r"^\s*(\|.*\|\s*$|#{1,6}\s+.*$|[-*+]\s+.*$|\d+[.)、]\s+.*$|>\s?.*$|```.*$)"
@@ -109,11 +121,21 @@ def _trim_outline(outline: list[dict[str, Any]], max_sections: int = MAX_SECTION
# ---------------------------------------------------------------------------
async def _collect_materials(documents: list[dict[str, Any]]) -> str:
- """逐条拉取资料明文(vstore 解密)。拉取失败 → 占位提示(不阻断)。"""
+ """逐条拉取资料明文(vstore 解密)。拉取失败 → 占位提示(不阻断)。
+
+ 2026-09-04:资料不再限量(原 [:5] 移除),改为总字符预算
+ MATERIAL_BUDGET_CHARS(60k)——每份按段落顺序轮取,预算耗尽即停,
+ 剩余资料置占位提示。上传无限制 ≠ prompt 无限制。
+ """
parts: list[str] = []
- for doc in documents[:5]: # 最多带 5 份资料(MAX_ATTACH_COUNT)
+ budget = MATERIAL_BUDGET_CHARS
+ truncated_any = False
+ for doc in documents:
doc_id = str(doc.get("id") or "")
name = str(doc.get("name") or doc_id or "未命名资料")
+ if budget <= 0:
+ truncated_any = True
+ continue
try:
content = await client.get_document(doc_id)
except client.DocgenUnavailable:
@@ -122,8 +144,12 @@ async def _collect_materials(documents: list[dict[str, Any]]) -> str:
parts.append(f"【资料 {name}】(未能加载,跳过)")
continue
paragraphs = content.get("paragraphs") or []
- body = "\n".join(paragraphs[:40]) # 每份资料最多带 40 段
- parts.append(f"【资料 {name}】\n{body[: SECTION_MAX_CHARS * 3]}")
+ body = "\n".join(paragraphs[:60]) # 每份资料最多带 60 段
+ body = body[:budget] # 受总预算约束
+ budget -= len(body)
+ parts.append(f"【资料 {name}】\n{body}")
+ if truncated_any:
+ parts.append("(注:另有部分资料因超出上下文预算未纳入本文,可单独指定生成)")
return "\n\n".join(parts)
@@ -259,13 +285,29 @@ async def generate_document(
*,
documents: list[dict[str, Any]],
template: dict[str, Any] | None = None,
+ template_style: dict[str, Any] | None = None,
+ web_search: bool = True,
) -> dict[str, Any]:
- """文档生成主流程。返回 {markdown, md_path}(artifact 落库由 router 负责)。"""
+ """文档生成主流程(2026-09-04 起:拼内容 → 套模版 → 产 HTML)。
+
+ 模版解析链:
+ 1. 库内模版(template 由 router 检索给出)→ outline 驱动分节 +
+ template_style(docx style_hints)注入 css 覆盖;
+ 2. 库内未命中 → classify_doc_type 出内置模版 key(自设计);
+ 且 web_search 开启时联网搜相似范文结构作大纲种子(失败静默降级 LLM 大纲);
+ 3. 生成后 render_html 产 (html, css) 一并返回(制品落库由 router 负责)。
+
+ 返回 {markdown, md_path, html, css, template_key, template_name, outline_source}
+ outline_source ∈ {"library", "web", "llm", "default"}
+ """
# 1. 资料明文(vstore 解密;失败占位不阻断)
materials = await _collect_materials(documents)
# 2. 模版大纲(template 条目由 router 检索给出,这里取明文 outline)
template_outline: list[dict[str, Any]] | None = None
+ template_key = ""
+ template_name = ""
+ outline_source = "llm"
if template:
tpl_id = str(template.get("id") or "")
try:
@@ -274,14 +316,32 @@ async def generate_document(
content = None
if content:
template_outline = _template_outline_from_content(content)
+ if template_outline:
+ outline_source = "library"
+ template_name = str(template.get("name") or "库内模版")
- # 3. 大纲(LLM 给的 title 优先;模版次之;最后用描述截断)
+ # 3. 库内未命中:内置模版分类 + 联网结构参考(best-effort,默认启用)
+ if not template_outline:
+ template_key = templating.classify_doc_type(description)
+ if web_search:
+ try:
+ web_outline = await websearch.search_template_outline(description)
+ except Exception:
+ web_outline = None
+ if web_outline is not None and web_outline.outline:
+ template_outline = [{"level": 1, "text": t} for t in web_outline.outline]
+ outline_source = "web"
+ template_name = "联网范文结构"
+
+ # 4. 大纲(LLM 给的 title 优先;模版名次之;最后用描述截断)
sections, llm_title = await _generate_outline(description, template_outline, materials)
+ if outline_source == "llm" and template_outline is None:
+ outline_source = "default"
title = llm_title or (str(template.get("name") or "") if template else "")
if not title:
title = description.strip().split("\n")[0][:60] or "文档"
- # 4. 逐节生成合并(单节失败重试 2 次仍失败 → 占位)
+ # 5. 逐节生成合并(单节失败重试 2 次仍失败 → 占位)
# FAILOVER-02 修复:LLM 输出自带同文本标题时不再重复插入
parts: list[str] = [f"# {title}"]
written = ""
@@ -296,14 +356,90 @@ async def generate_document(
markdown = "\n\n".join(parts)
- # 5. humanize 按行保护
+ # 6. humanize 按行保护
markdown = _humanize_md(markdown)
- # 6. md 落盘(DOCGEN_ROOT/artifacts/.md;router 再按 artifact_id 落一次)
+ # 7. 套模版渲染 HTML(视觉模版 = 分类 key 骨架 + style_hints 覆盖;绝不抛异常)
+ if not template_key:
+ template_key = templating.classify_doc_type(description)
+ # 红头指定:prompt「红头用XX」优先于库内样式与模版默认(2026-09-04)
+ letterhead = templating.extract_letterhead_from_prompt(description)
+ html, css = templating.render_html(
+ markdown, title=title, key=template_key, style_hints=template_style,
+ letterhead=letterhead,
+ )
+
+ # 8. md 落盘(DOCGEN_ROOT/artifacts/.md;router 再按 artifact_id 落一次)
from .settings import artifacts_dir
out_path = artifacts_dir() / f"{uuid.uuid4().hex}.md"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(markdown, encoding="utf-8")
- return {"markdown": markdown, "md_path": str(out_path)}
+ return {
+ "markdown": markdown,
+ "md_path": str(out_path),
+ "html": html,
+ "css": css,
+ "template_key": template_key,
+ "template_name": template_name,
+ "outline_source": outline_source,
+ "letterhead": letterhead,
+ "file_name": _file_name_from_parts(llm_title, title, description),
+ }
+
+
+def _file_name_from_parts(llm_title: str, title: str, description: str) -> str:
+ """导出文件名:LLM 标题(已提炼)优先 > prompt 核心短语 > title 兜底。
+
+ LLM 大纲路径的 llm_title 即「十一放假通知」这类干净标题;
+ 模版/联网/默认大纲路径 llm_title 为空 → name_for_prompt 规则提炼;
+ 再失败用 title(生成时的兜底标题)。长度限 60、剔除路径非法字符。
+ """
+ base = ((llm_title or "").strip()
+ or name_for_prompt(description)
+ or (title or "").strip()
+ or "文档")
+ return _NAME_BAD_CHARS_RE.sub("", base)[:60] or "文档"
+
+
+def name_for_prompt(description: str) -> str:
+ """从生成描述提炼文档核心名字(2026-09-04 手工测试问题3)。
+
+ 「帮我写一个十一放假通知,放假时间为10月1日…」→「十一放假通知」。
+ 规则:动词前缀+量词+文档类型词(复用 intent 词典词汇)构成核心短语,
+ 向后至多吞一个主题修饰词(如「十一放假」);失败返回空串(调用方兜底)。
+ 纯确定性规则,无 LLM 调用。
+ """
+ text = (description or "").strip()
+ if not text:
+ return ""
+ try:
+ from app.services.docgen.intent import DOC_TYPE # 复用 intent 词典(不修改 intent.py)
+ except ImportError: # 脱离 sdk 运行时兜底小词典
+ DOC_TYPE = ("报告", "通知", "方案", "计划", "总结", "合同", "协议", "申请书")
+ # 逐层剥离前缀:礼貌词 → 动词 → 量词 →(再动词,兼容「写份」「拟一个」等排列)
+ rest = _NAME_POLITE_RE.sub("", text)
+ rest = _NAME_VERB_RE.sub("", rest)
+ rest = _NAME_MEASURE_RE.sub("", rest)
+ rest = _NAME_VERB_RE.sub("", rest)
+ if not rest:
+ return ""
+ for doc_word in DOC_TYPE:
+ idx = rest.find(doc_word)
+ if idx < 0:
+ continue
+ end = idx + len(doc_word)
+ # 向后吞一个主题修饰词(数字/中文片段,≤12 字符,不含空格与标点)
+ tail = rest[end:end + 12]
+ extra = ""
+ for ch in tail:
+ if ch.isspace() or ch in ",。、;:""''()()《》":
+ break
+ extra += ch
+ candidate = rest[:end] + extra
+ if len(candidate) <= 30:
+ return _NAME_BAD_CHARS_RE.sub("", candidate)
+ # 修饰词过长(如把日期都吞进来):只保留前缀+类型词
+ return _NAME_BAD_CHARS_RE.sub("", rest[:end])
+ return ""
diff --git a/sdk/app/services/docgen/settings.py b/sdk/app/services/docgen/settings.py
index 6d65d53..36b998a 100644
--- a/sdk/app/services/docgen/settings.py
+++ b/sdk/app/services/docgen/settings.py
@@ -1,6 +1,6 @@
"""services/docgen/settings.py —— docgen 服务层配置(dataclass,不引 pydantic)。
-- 限额常量与 vstore/settings.py 对齐(docs/team/brief_dev1_p1.md)
+- 上传不设大小/数量/字符门槛;MATERIAL_BUDGET_CHARS 只是注入 prompt 的预算
- 路径:DOCGEN_ROOT(制品目录,默认 data/docgen)
- LLM 键:DOCGEN_LLM_BASE_URL / DOCGEN_LLM_API_KEY / DOCGEN_LLM_MODEL /
DOCGEN_LLM_TIMEOUT(默认 300s)
@@ -12,12 +12,10 @@ from dataclasses import dataclass, field
from pathlib import Path
-# ---- 限额常量(与 vstore 对齐)----
-MAX_ATTACH_COUNT = 5
-MAX_ATTACH_BYTES = 20 * 1024 * 1024
-MAX_ATTACH_CHARS = 60000
+# ---- 生成参数(2026-09-04 手工测试:上传不再设大小/数量/字符门槛)----
MAX_SECTIONS = 12 # 模版大纲裁剪上限(节数)
MAX_OUTLINE_CHUNKS = 3
+MATERIAL_BUDGET_CHARS = 60_000 # 全部资料注入 prompt 的总字符预算(非上传限制)
# ---- 生成参数 ----
GENERATION_TEMPERATURE = 0.3
diff --git a/sdk/app/services/docgen/templates/application.css b/sdk/app/services/docgen/templates/application.css
new file mode 100644
index 0000000..449e928
--- /dev/null
+++ b/sdk/app/services/docgen/templates/application.css
@@ -0,0 +1,74 @@
+/* application.css —— 申报材料。双渲染纪律:仅 Story 安全子集。 */
+body {
+ margin: 0;
+ padding: 0;
+ font-family: "Noto Serif CJK SC", "SimSun", "宋体", serif;
+ font-size: 16px;
+ line-height: 1.8;
+ color: #000000;
+}
+.doc-cover {
+ text-align: center;
+ padding-bottom: 12px;
+ border-bottom: 2px solid #1a5f8a;
+ margin-bottom: 16px;
+}
+.doc-letterhead {
+ display: inline-block;
+ color: #1a5f8a;
+ font-size: 26px;
+ font-weight: bold;
+ letter-spacing: 6px;
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+}
+h1.doc-title {
+ text-align: center;
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 22px;
+ font-weight: bold;
+ margin: 14px 0 16px 0;
+}
+h2 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 16px;
+ margin: 14px 0 6px 0;
+ border-bottom: 1px solid #1a5f8a;
+ padding-bottom: 3px;
+}
+h3, h4, h5, h6 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 15px;
+ margin: 10px 0 4px 0;
+}
+p {
+ margin: 6px 0;
+ text-indent: 2em;
+}
+table.doc-table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 10px 0;
+}
+table.doc-table th, table.doc-table td {
+ border: 1px solid #1a5f8a;
+ padding: 5px 8px;
+ font-size: 14px;
+ text-align: left;
+}
+table.doc-table th {
+ background: #e3eef6;
+ font-weight: bold;
+}
+ul, ol {
+ margin: 6px 0;
+ padding-left: 2.2em;
+}
+li {
+ margin: 2px 0;
+}
+blockquote {
+ margin: 8px 0;
+ padding: 4px 12px;
+ border-left: 3px solid #1a5f8a;
+ color: #000000;
+}
diff --git a/sdk/app/services/docgen/templates/application.html b/sdk/app/services/docgen/templates/application.html
new file mode 100644
index 0000000..895d773
--- /dev/null
+++ b/sdk/app/services/docgen/templates/application.html
@@ -0,0 +1,14 @@
+
+
+
+
+{{TITLE}}
+
+
+
+
+
+
+
diff --git a/sdk/app/services/docgen/templates/contract.css b/sdk/app/services/docgen/templates/contract.css
new file mode 100644
index 0000000..c79e1ab
--- /dev/null
+++ b/sdk/app/services/docgen/templates/contract.css
@@ -0,0 +1,58 @@
+/* contract.css —— 合同协议。双渲染纪律:仅 Story 安全子集。 */
+body {
+ margin: 0;
+ padding: 0;
+ font-family: "Noto Serif CJK SC", "SimSun", "宋体", serif;
+ font-size: 15px;
+ line-height: 1.9;
+ color: #000000;
+}
+h1.doc-title {
+ text-align: center;
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 20px;
+ font-weight: bold;
+ margin: 12px 0 14px 0;
+}
+h2 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 15px;
+ margin: 14px 0 6px 0;
+}
+h3, h4, h5, h6 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 14px;
+ margin: 10px 0 4px 0;
+}
+p {
+ margin: 5px 0;
+ text-indent: 2em;
+}
+table.doc-table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 10px 0;
+}
+table.doc-table th, table.doc-table td {
+ border: 1px solid #444;
+ padding: 5px 8px;
+ font-size: 13px;
+ text-align: left;
+}
+table.doc-table th {
+ background: #eee;
+ font-weight: bold;
+}
+ul, ol {
+ margin: 6px 0;
+ padding-left: 2.2em;
+}
+li {
+ margin: 2px 0;
+}
+blockquote {
+ margin: 8px 0;
+ padding: 4px 12px;
+ border-left: 3px solid #888;
+ color: #000000;
+}
diff --git a/sdk/app/services/docgen/templates/contract.html b/sdk/app/services/docgen/templates/contract.html
new file mode 100644
index 0000000..f9cc0b8
--- /dev/null
+++ b/sdk/app/services/docgen/templates/contract.html
@@ -0,0 +1,11 @@
+
+
+
+
+{{TITLE}}
+
+
+
+
+
+
diff --git a/sdk/app/services/docgen/templates/document.css b/sdk/app/services/docgen/templates/document.css
new file mode 100644
index 0000000..ab62564
--- /dev/null
+++ b/sdk/app/services/docgen/templates/document.css
@@ -0,0 +1,57 @@
+/* document.css —— 通用文档(兜底)。双渲染纪律:仅 Story 安全子集。 */
+body {
+ margin: 0;
+ padding: 0;
+ font-family: "Noto Serif CJK SC", "SimSun", "宋体", serif;
+ font-size: 16px;
+ line-height: 1.8;
+ color: #000000;
+}
+h1.doc-title {
+ text-align: left;
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 21px;
+ font-weight: bold;
+ margin: 10px 0 12px 0;
+}
+h2 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 16px;
+ margin: 14px 0 6px 0;
+}
+h3, h4, h5, h6 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 15px;
+ margin: 10px 0 4px 0;
+}
+p {
+ margin: 6px 0;
+}
+table.doc-table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 10px 0;
+}
+table.doc-table th, table.doc-table td {
+ border: 1px solid #aaa;
+ padding: 5px 8px;
+ font-size: 14px;
+ text-align: left;
+}
+table.doc-table th {
+ background: #f0f0f0;
+ font-weight: bold;
+}
+ul, ol {
+ margin: 6px 0;
+ padding-left: 2.2em;
+}
+li {
+ margin: 2px 0;
+}
+blockquote {
+ margin: 8px 0;
+ padding: 4px 12px;
+ border-left: 3px solid #999;
+ color: #000000;
+}
diff --git a/sdk/app/services/docgen/templates/document.html b/sdk/app/services/docgen/templates/document.html
new file mode 100644
index 0000000..f9cc0b8
--- /dev/null
+++ b/sdk/app/services/docgen/templates/document.html
@@ -0,0 +1,11 @@
+
+
+
+
+{{TITLE}}
+
+
+
+
+
+
diff --git a/sdk/app/services/docgen/templates/index.json b/sdk/app/services/docgen/templates/index.json
new file mode 100644
index 0000000..fd98eb6
--- /dev/null
+++ b/sdk/app/services/docgen/templates/index.json
@@ -0,0 +1,47 @@
+{
+ "templates": [
+ {
+ "key": "official",
+ "title": "通用公文(红头)",
+ "description": "通知、通告、公告、请示、批复、函、会议纪要等正式公文",
+ "css": "official.css"
+ },
+ {
+ "key": "report",
+ "title": "报告总结",
+ "description": "调研报告、分析报告、工作总结、述职汇报、可行性报告",
+ "css": "report.css"
+ },
+ {
+ "key": "application",
+ "title": "申报材料",
+ "description": "申请书、申报书、标书、项目书、推荐信、策划书",
+ "css": "application.css"
+ },
+ {
+ "key": "contract",
+ "title": "合同协议",
+ "description": "合同、协议、声明、证明",
+ "css": "contract.css"
+ },
+ {
+ "key": "speech",
+ "title": "讲话稿",
+ "description": "讲话稿、致辞、演讲稿、倡议书、感谢信、慰问信",
+ "css": "speech.css"
+ },
+ {
+ "key": "plan",
+ "title": "方案计划",
+ "description": "方案、计划、制度、规定、办法、细则、章程",
+ "css": "plan.css"
+ },
+ {
+ "key": "document",
+ "title": "通用文档",
+ "description": "说明书、指南、简报、新闻稿、简历等通用文档",
+ "css": "document.css"
+ }
+ ],
+ "fallback": "document"
+}
diff --git a/sdk/app/services/docgen/templates/official.css b/sdk/app/services/docgen/templates/official.css
new file mode 100644
index 0000000..810de95
--- /dev/null
+++ b/sdk/app/services/docgen/templates/official.css
@@ -0,0 +1,76 @@
+/* official.css —— 通用公文(红头)。双渲染纪律:仅 Story 安全子集(block flow/tables/fonts/margins)。 */
+body {
+ margin: 0;
+ padding: 0;
+ font-family: "Noto Serif CJK SC", "SimSun", "宋体", serif;
+ font-size: 16px;
+ line-height: 1.9;
+ color: #000000;
+}
+.doc-cover {
+ text-align: center;
+ padding-bottom: 14px;
+ border-bottom: 3px solid #c00000;
+ margin-bottom: 18px;
+}
+.doc-letterhead {
+ display: inline-block;
+ color: #c00000;
+ font-size: 30px;
+ font-weight: bold;
+ letter-spacing: 12px;
+ font-family: "Noto Serif CJK SC", "SimSun", "宋体", serif;
+}
+.doc-body {
+ padding: 0 6px;
+}
+h1.doc-title {
+ text-align: center;
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 22px;
+ font-weight: bold;
+ margin: 16px 0 18px 0;
+ letter-spacing: 1px;
+}
+h2 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 16px;
+ margin: 14px 0 6px 0;
+}
+h3, h4, h5, h6 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 15px;
+ margin: 10px 0 4px 0;
+}
+p {
+ margin: 6px 0;
+ text-indent: 2em;
+}
+table.doc-table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 10px 0;
+}
+table.doc-table th, table.doc-table td {
+ border: 1px solid #666;
+ padding: 5px 8px;
+ font-size: 14px;
+ text-align: left;
+}
+table.doc-table th {
+ background: #f2f2f2;
+ font-weight: bold;
+}
+ul, ol {
+ margin: 6px 0;
+ padding-left: 2.2em;
+}
+li {
+ margin: 2px 0;
+}
+blockquote {
+ margin: 8px 0;
+ padding: 4px 12px;
+ border-left: 3px solid #c00000;
+ color: #000000;
+}
diff --git a/sdk/app/services/docgen/templates/official.html b/sdk/app/services/docgen/templates/official.html
new file mode 100644
index 0000000..895d773
--- /dev/null
+++ b/sdk/app/services/docgen/templates/official.html
@@ -0,0 +1,14 @@
+
+
+
+
+{{TITLE}}
+
+
+
+
+
+
+
diff --git a/sdk/app/services/docgen/templates/plan.css b/sdk/app/services/docgen/templates/plan.css
new file mode 100644
index 0000000..bea45c5
--- /dev/null
+++ b/sdk/app/services/docgen/templates/plan.css
@@ -0,0 +1,60 @@
+/* plan.css —— 方案/计划。双渲染纪律:仅 Story 安全子集。 */
+body {
+ margin: 0;
+ padding: 0;
+ font-family: "Noto Serif CJK SC", "SimSun", "宋体", serif;
+ font-size: 16px;
+ line-height: 1.8;
+ color: #000000;
+}
+h1.doc-title {
+ text-align: center;
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 21px;
+ font-weight: bold;
+ margin: 12px 0 14px 0;
+}
+h2 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 16px;
+ margin: 14px 0 6px 0;
+ background: #f0f4f0;
+ padding: 3px 8px;
+}
+h3, h4, h5, h6 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 15px;
+ margin: 10px 0 4px 0;
+}
+p {
+ margin: 6px 0;
+ text-indent: 2em;
+}
+table.doc-table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 10px 0;
+}
+table.doc-table th, table.doc-table td {
+ border: 1px solid #5a7a5a;
+ padding: 5px 8px;
+ font-size: 14px;
+ text-align: left;
+}
+table.doc-table th {
+ background: #e8f0e8;
+ font-weight: bold;
+}
+ul, ol {
+ margin: 6px 0;
+ padding-left: 2.2em;
+}
+li {
+ margin: 2px 0;
+}
+blockquote {
+ margin: 8px 0;
+ padding: 4px 12px;
+ border-left: 3px solid #5a7a5a;
+ color: #000000;
+}
diff --git a/sdk/app/services/docgen/templates/plan.html b/sdk/app/services/docgen/templates/plan.html
new file mode 100644
index 0000000..f9cc0b8
--- /dev/null
+++ b/sdk/app/services/docgen/templates/plan.html
@@ -0,0 +1,11 @@
+
+
+
+
+{{TITLE}}
+
+
+
+
+
+
diff --git a/sdk/app/services/docgen/templates/report.css b/sdk/app/services/docgen/templates/report.css
new file mode 100644
index 0000000..4e24e23
--- /dev/null
+++ b/sdk/app/services/docgen/templates/report.css
@@ -0,0 +1,72 @@
+/* report.css —— 报告总结。双渲染纪律:仅 Story 安全子集。 */
+body {
+ margin: 0;
+ padding: 0;
+ font-family: "Noto Serif CJK SC", "SimSun", "宋体", serif;
+ font-size: 16px;
+ line-height: 1.8;
+ color: #000000;
+}
+.doc-cover {
+ text-align: center;
+ padding-bottom: 12px;
+ border-bottom: 2px solid #333;
+ margin-bottom: 16px;
+}
+.doc-letterhead {
+ display: inline-block;
+ color: #333;
+ font-size: 26px;
+ font-weight: bold;
+ letter-spacing: 8px;
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+}
+h1.doc-title {
+ text-align: center;
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 22px;
+ font-weight: bold;
+ margin: 14px 0 16px 0;
+}
+h2 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 16px;
+ margin: 14px 0 6px 0;
+}
+h3, h4, h5, h6 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 15px;
+ margin: 10px 0 4px 0;
+}
+p {
+ margin: 6px 0;
+ text-indent: 2em;
+}
+table.doc-table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 10px 0;
+}
+table.doc-table th, table.doc-table td {
+ border: 1px solid #888;
+ padding: 5px 8px;
+ font-size: 14px;
+ text-align: left;
+}
+table.doc-table th {
+ background: #e8eef5;
+ font-weight: bold;
+}
+ul, ol {
+ margin: 6px 0;
+ padding-left: 2.2em;
+}
+li {
+ margin: 2px 0;
+}
+blockquote {
+ margin: 8px 0;
+ padding: 4px 12px;
+ border-left: 3px solid #555;
+ color: #000000;
+}
diff --git a/sdk/app/services/docgen/templates/report.html b/sdk/app/services/docgen/templates/report.html
new file mode 100644
index 0000000..895d773
--- /dev/null
+++ b/sdk/app/services/docgen/templates/report.html
@@ -0,0 +1,14 @@
+
+
+
+
+{{TITLE}}
+
+
+
+
+
+
+
diff --git a/sdk/app/services/docgen/templates/speech.css b/sdk/app/services/docgen/templates/speech.css
new file mode 100644
index 0000000..a9d82c7
--- /dev/null
+++ b/sdk/app/services/docgen/templates/speech.css
@@ -0,0 +1,59 @@
+/* speech.css —— 讲话稿/致辞。双渲染纪律:仅 Story 安全子集。 */
+body {
+ margin: 0;
+ padding: 0;
+ font-family: "Noto Serif CJK SC", "SimSun", "宋体", serif;
+ font-size: 16px;
+ line-height: 2.0;
+ color: #000000;
+}
+h1.doc-title {
+ text-align: center;
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 22px;
+ font-weight: bold;
+ margin: 12px 0 16px 0;
+ letter-spacing: 1px;
+}
+h2 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 16px;
+ margin: 16px 0 8px 0;
+}
+h3, h4, h5, h6 {
+ font-family: "Noto Sans CJK SC", "SimHei", "黑体", sans-serif;
+ font-size: 15px;
+ margin: 10px 0 4px 0;
+}
+p {
+ margin: 8px 0;
+ text-indent: 2em;
+}
+table.doc-table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 10px 0;
+}
+table.doc-table th, table.doc-table td {
+ border: 1px solid #999;
+ padding: 5px 8px;
+ font-size: 14px;
+ text-align: left;
+}
+table.doc-table th {
+ background: #f5f0e8;
+ font-weight: bold;
+}
+ul, ol {
+ margin: 6px 0;
+ padding-left: 2.2em;
+}
+li {
+ margin: 2px 0;
+}
+blockquote {
+ margin: 8px 0;
+ padding: 4px 12px;
+ border-left: 3px solid #b08a3e;
+ color: #000000;
+}
diff --git a/sdk/app/services/docgen/templates/speech.html b/sdk/app/services/docgen/templates/speech.html
new file mode 100644
index 0000000..f9cc0b8
--- /dev/null
+++ b/sdk/app/services/docgen/templates/speech.html
@@ -0,0 +1,11 @@
+
+
+
+
+{{TITLE}}
+
+
+
+
+
+
diff --git a/sdk/app/services/docgen/templating.py b/sdk/app/services/docgen/templating.py
new file mode 100644
index 0000000..0a2db22
--- /dev/null
+++ b/sdk/app/services/docgen/templating.py
@@ -0,0 +1,267 @@
+"""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"\s*")
+_TABLE_RE = re.compile(r"