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]*?)", 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}} + + +
+
{{LETTERHEAD}}
+
+
+
+ + 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}} + + +
+
{{LETTERHEAD}}
+
+
+
+ + 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}} + + +
+
{{LETTERHEAD}}
+
+
+
+ + 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"
") +_FIRST_H1_RE = re.compile(r"

") +_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('

', body) + body = _FIRST_H1_RE.sub('

', body, count=1) + return f'
{body}
' + + +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("
" + body + "
", wrapper, count=1) + else: + html = ( + "" + f"{_esc(str(title))}" + f"{body}" + ) + css = asset.css + overlay = _style_overlay_css(style_hints) + if overlay: + css = f"{css}\n/* style_hints 覆盖 */\n{overlay}" + else: + html = ( + "" + f"{_esc(str(title))}{body}" + ) + css = "" + # 标题/红头占位(wrapper 的 与 .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("&", "&").replace("<", "<").replace(">", ">") + ) diff --git a/sdk/app/services/docgen/websearch.py b/sdk/app/services/docgen/websearch.py new file mode 100644 index 0000000..336834c --- /dev/null +++ b/sdk/app/services/docgen/websearch.py @@ -0,0 +1,141 @@ +"""services/docgen/websearch.py —— 文档模版联网参考(best-effort)。 + +库内无匹配模版时:cn.bing.com 搜索 → 首个结果页 → 提取 h1-h3 大纲。 +严格超时(搜索 8s / 抓取 10s)、单次不重试、任何异常返回 None, +调用方静默降级到 LLM 大纲(绝不阻断生成)。 + +环境闸:DOCGEN_WEB_TEMPLATE_SEARCH(默认 "1" 启用;显式 "0" 关闭)。 +Cython 约束:仅 dataclass/dict/stdlib+httpx。 +""" +from __future__ import annotations + +import html as _html +import logging +import os +import re +import urllib.parse +from dataclasses import dataclass, field +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +TIMEOUT_SEARCH = 8.0 +TIMEOUT_FETCH = 10.0 +MAX_RESULT_BYTES = 512 * 1024 +MIN_HEADINGS = 2 +MAX_HEADINGS = 12 + +_BING_URL = "https://cn.bing.com/search" +_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36" +) +# 2026-09-04 实测 cn.bing.com:<li class="b_algo" ...><h2 ...><a href="...">… +_ALGO_LINK_RE = re.compile( + r'<li class="b_algo"[\s\S]*?<h2[^>]*>\s*<a[^>]*href="([^"]+)"', + re.IGNORECASE, +) +_SKIP_DOMAINS = ("bing.com", "microsoft.com", "r.bing.com") +_H_RE = re.compile(r"<h([1-3])[^>]*>([\s\S]*?)</h\1>", re.IGNORECASE) +_TAG_RE = re.compile(r"<[^>]+>") + + +def web_search_enabled() -> bool: + """DOCGEN_WEB_TEMPLATE_SEARCH(默认 1 启用;"0" 关闭)。""" + return os.environ.get("DOCGEN_WEB_TEMPLATE_SEARCH", "1") != "0" + + +def _make_client(timeout: float) -> httpx.AsyncClient: + """HTTP 客户端工厂(测试可 monkeypatch 注入 MockTransport)。""" + return httpx.AsyncClient(timeout=timeout) + + +@dataclass +class WebOutline: + source_url: str = "" + title: str = "" + outline: list[str] = field(default_factory=list) # ["一、...", ...] h1-h3 + + +def _decode(raw: bytes) -> str: + for enc in ("utf-8", "gb18030"): + try: + return raw.decode(enc) + except (UnicodeDecodeError, LookupError): + continue + return raw.decode("utf-8", errors="replace") + + +def _extract_headings(text: str) -> list[str]: + found: list[str] = [] + for m in _H_RE.finditer(text): + label = _TAG_RE.sub("", m.group(2)) + label = _html.unescape(label).strip() + label = re.sub(r"\s+", " ", label) + if label and label not in found: + found.append(label) + if len(found) >= MAX_HEADINGS: + break + return found if len(found) >= MIN_HEADINGS else [] + + +async def search_template_outline(description: str) -> WebOutline | None: + """bing 搜索 → 首个非广告结果 → 抓取 → h1-h3 大纲。任何异常返回 None。""" + if not web_search_enabled(): + return None + query = f"{description} 模板 范文" + try: + async with _make_client(TIMEOUT_SEARCH) as client: + resp = await client.get( + _BING_URL, + params={ + "q": query, + "setlang": "zh-CN", + "ensearch": "0", + }, + headers={ + "User-Agent": _UA, + "Accept-Language": "zh-CN,zh;q=0.9", + }, + follow_redirects=True, + ) + resp.raise_for_status() + page = _decode(resp.content) + except Exception as exc: + logger.info("docgen: 模版联网搜索失败(%s)", exc) + return None + + m = _ALGO_LINK_RE.search(page) + if not m: + return None + url = _html.unescape(m.group(1)).strip() + host = urllib.parse.urlparse(url).netloc.lower() + if not url.startswith(("http://", "https://")) or any( + d in host for d in _SKIP_DOMAINS + ): + return None + + try: + async with _make_client(TIMEOUT_FETCH) as client: + resp = await client.get( + url, + headers={"User-Agent": _UA, "Accept-Language": "zh-CN,zh;q=0.9"}, + follow_redirects=True, + ) + resp.raise_for_status() + content = resp.content + if len(content) > MAX_RESULT_BYTES: + content = content[:MAX_RESULT_BYTES] + text = _decode(content) + except Exception as exc: + logger.info("docgen: 模版参考页抓取失败(%s)", exc) + return None + + outline = _extract_headings(text) + if not outline: + return None + title_m = re.search(r"<title[^>]*>([\s\S]*?)", text, re.IGNORECASE) + page_title = _html.unescape(_TAG_RE.sub("", title_m.group(1))).strip() if title_m else "" + return WebOutline(source_url=url, title=page_title[:200], outline=outline) diff --git a/tests/docgen/test_case_report.md b/tests/docgen/test_case_report.md index cf557c6..a71c55c 100644 --- a/tests/docgen/test_case_report.md +++ b/tests/docgen/test_case_report.md @@ -485,3 +485,84 @@ - 功能:自然触发文档生成、加密入库(vault 仅 .enc、明文 grep 无命中)、vstore 独立服务 8094(路径可配置 file/URL)、能力徽标、右侧文档 TAB(预览/就地编辑/prompt 修改/导出 PDF+DOCX 浏览器下载)、右栏拖宽+手柄仿左栏、模版检索三档降级、宕机兜底、批量导入(并发 8 + 导入后删源明文) - 测试资产:tests/docgen/ 131 条用例 + 可复跑脚本 + 三轮留痕 + TL 验收记录 - 纪律:QA 树零改动、红线文件零改动、未 push git、备份完整(docgen-20260903-1425 / docgen-r1fix-20260903-1706) + +--- + +# 手工测试第一批修复(2026-09-04,用户手工测试反馈) + +**修复项**:问题1 上传去限制+进度展示;问题2 模板 HTML 所见即所得预览+按需导出(PDF 优先);问题3 检索 TAB 对话中实时更新。 + +## 服务端测试(本轮新增/变更,2026-09-04) + +| 项 | 执行方式 | 结果 | +|---|---|---| +| 新增 DC-UNIT-TPL-01~08(内置模板注册表/分类/render_html/style_hints 覆盖/异常安全/css Story 安全门禁/wrapper 注入点/表格类) | pytest tests/docgen/test_templates.py | PASS 8/8 | +| 新增 DC-UNIT-WEB-01~06(bing 解析/网络失败/超大页/标题不足/gb18030/环境闸) | pytest tests/docgen/test_websearch.py(MockTransport 离线) | PASS 6/6 | +| 新增 DC-UNIT-PIPELINE-09~11(html/css/template_key/outline_source 产出;web 大纲种子;template_style css 覆盖) | pytest tests/docgen/test_unit_acceptance.py | PASS 3/3 | +| 新增 DC-UNIT-CONVERT-07(模板 HTML→PDF 中文可提取 + 红头;lang 字体陷阱回归门禁) | pytest | PASS | +| DC-UNIT-VAULT-10 翻转(21MB 入库成功 + EMBED_BODY_CAP 断言) | pytest | PASS(行为变更) | +| DC-UNIT-VAULT-06b 翻转(301 页 PDF 可解析) | pytest | PASS(行为变更) | +| DC-UNIT-CLIENT-05 翻转(上传限制常量已移除断言) | pytest | PASS(行为变更) | +| 后端全量回归 | vstore/tests + test_unit_acceptance + test_intent_cases + test_templates + test_websearch | **176/176 PASS** | +| 前端全量回归 | ui npm test(vitest 18 文件) | **173/173 PASS** | + +## TL 亲自验收(2026-09-04) + +| 项 | 执行方式 | 结果 | +|---|---|---| +| 大文件上传无限制 | 25MB txt 经生产 8090 上传 → 201、size 正确回填(vstore 20MB/BFF 100MB 门槛均已移除);8 文件并发上传 | PASS | +| 真实 LLM 生成(模板) | 「写一份实验室安全管理的通知」→ template_key=official、has_html=true、outline_source=library | PASS | +| GET /html | 红头/doc-title/css 齐全、内容正确 | PASS | +| PDF 导出(预览同源) | export pdf → %PDF 魔数 → pymupdf 提取:红头/正文/表格全部可读(lang 字体陷阱已修) | PASS | +| 浏览器验收脚本 | Playwright 真实浏览器(服务器 chromium-1148):模板渲染/编辑锁定/就地编辑保存回显/PDF 按钮优先/导出完成(R1 首跑 13/15,红头锁定与脚本 bug 修复后 R2 复跑) | 见下轮 | +| 检索 TAB 实时更新 | QA 流式期间采样卡片计数递增(修复:chat.js:560 raw ctx.events.push → 响应式代理推入) | 见下轮 | + +## 环境事实(2026-09-04 新增,留档) + +1. **markdown-it-py commonmark 预设不启表格**:`.enable("table")` 前 | 表格在 HTML 渲染/PDF 里是纯文本段落——本次一并修复(模板渲染与 PDF 导出的表格依赖它)。 +2. **MuPDF Story 的 `` 字体陷阱**:lang 属性触发按语言选字体,中文提取乱码(浏览器预览正常所以此前无感)——PDF 路径剥离 lang(DC-UNIT-CONVERT-07 回归门禁)。 +3. **MuPDF CSS 注释解析**:含 CJK 全角标点的注释会报语法错误——PDF 路径适配时剥注释。 +4. **PDF 体积遗留项**:Noto TTC 全量嵌入导致 PDF ~40-58MB(老路径同样存在),需 fontTools+pycffsubr 子集化(venv 未装)——列 V1.5。 +5. 库内检索命中优先级:as_template 压测样例会优先命中为模版(设计行为:库内 > 联网 > 自设计);联网路径由 DC-UNIT-WEB 离线 mock + 服务器真实 bing 可达性实测覆盖。 +6. 本会话 in-app Browser 不可用(无 browser 面板),浏览器验收用服务器 Playwright 替代。 + + +### 浏览器验收 R2(2026-09-04 下午,复跑+独立验证) + +| 项 | 结果 | +|---|---| +| 问题2 全链(15 项:模板渲染/红头/css 注入/编辑锁定/就地编辑保存回显/PDF 优先按钮/PDF 导出完成) | **15/15 PASS**(R1 的「红头区锁定」FAIL 已修:bodyFragment 只取 body 内层 HTML、shadow 内无 元素导致锁定逻辑未执行 → 改以 shadow 顶层 children 为锁定基准) | +| 问题3 检索 TAB 实时更新(独立脚本,真实 QA 流) | **PASS**:采样序列 3s:1 → 18s:3 → 24s:6 → 30s:7,对话进行中卡片实时递增(修复:chat.js raw ctx.events.push → 经 streamCtx 响应式代理推入)。R1 报告的两个 FAIL 为验收脚本选择器错误(.stage-card 不存在,真实 DOM 为 .tool-card),非产品缺陷。 | +| 无控制台错误 | PASS | + + +## 手工测试第二批修复(2026-09-04 晚) + +### 用户报的问题(5 个) +1. 预览区输入修改 prompt「底部的公司名称和日期居右」点修改报「修改失败」 +2. 导出按钮点击后变成「下载」按钮,未保持导出动作 +3. 下载文件名用整段 prompt 命名,要求核心意思命名(如「十一放假通知」) +4. 编辑模式手动调整文字后失焦/切回预览,内容被还原 +5. 已开一个预览时再点另一文档「打开预览」,面板白底无内容 + +### 根因与修复 +| 问题 | 根因 | 修复 | +|---|---|---| +| 1 | 意图护栏 edit 路径逐词判路由,「…居右」不含编辑词典词被判 qa → 422 INTENT_REJECTED | edit 端点护栏移除(被调用本身即活动文档上下文);generate 护栏不变 | +| 2 | runExport 成功后写 exportUrls → 按钮变「下载」再点击只 window.open 旧文件 | 删除 exportUrls 形态;按钮恒为「导出 PDF/DOCX」;每次 POST export 重新生成;导出前 flushSave 保最新内容 | +| 3 | download 端点用 title(=prompt 截断 60 字)命名 | db 增 file_name 列;pipeline 增 name_for_prompt 规则提炼(礼貌词/动词/量词剥离 + DOC_TYPE 词典匹配 + 主题修饰词吞并);generate 落库;download 用 file_name 兜底 title | +| 4 | 失焦序列化后立即用旧 html 重建 shadow 抹掉编辑;切预览不等保存(800ms 防抖) | 有变化时不重建 shadow(保存后 fetchHtml 刷新);exitEdit flushSave 立即 PATCH;防抖 800→350ms | +| 5 | open(B) 时旧 html 残留 + loading 把宿主 v-if 卸载重建 + watch 守卫 !docgen.html 跳过 + fetch 无序号竞态 | open 清 html/css/letterhead/exportError;fetchContent/fetchHtml 归属序号防竞态;renderNonce 兜底 html 拉取失败;watch(md) 无条件重渲染 | + +### 验证 +- 后端 pytest:53/53 全绿(新增 PIPELINE-09 file_name 断言 + 09b 规则提炼) +- 前端 vitest:173/173 全绿 +- curl 端到端(外网 8.163.92.8 → 回环): + - generate(用户原 prompt)→ file_name「十一放假通知」、letterhead「三亚海古纪生物科技有限公司」 + - export pdf/docx → 200;download Content-Disposition = 「十一放假通知.pdf/.docx」 + - edit「把底部的公司名称和日期居右」→ ok:True,md 尾部含右对齐落款 +- 浏览器实测(隧道 15180→服务器):问题 1 修改按钮无错误且落款右对齐;问题 2 导出中「导出中…」→ 回到「导出 PDF」无「下载」形态;问题 4 编辑文字后切预览内容保留;问题 5 连开两个预览均有内容无白底 +- 遗留:PDF 40-58MB 字体子集化(V1.5,需 fontTools);散落文件 sdk/app/{routers-docgen.py,templating.py} 已清理(误 scp 产物) + +### 部署 +备份 docgen-fix2-20260904-2114;编译 29 模块;BFF 重启(0.0.0.0);UI dist index-C_6hvku5.js diff --git a/tests/docgen/test_templates.py b/tests/docgen/test_templates.py new file mode 100644 index 0000000..38497e0 --- /dev/null +++ b/tests/docgen/test_templates.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +"""tests/docgen/test_templates.py —— 内置模版系统验收用例(DC-UNIT-TPL-*)。 + +2026-09-04 手工测试需求2:套用模版生成可预览 HTML。 +口径:公开函数/文件系统断言;全部离线(不依赖 LLM/网络/vstore 服务)。 +执行:bash tests/docgen/run_all.sh unit +""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +import pytest + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "sdk")) + +from app.services.docgen import templating # noqa: E402 + +pytestmark = pytest.mark.unit + +# Story 禁用属性(双渲染纪律:浏览器与 PyMuPDF Story 共用 css) +BANNED_PATTERNS = ( + re.compile(r"position\s*:", re.IGNORECASE), + re.compile(r"display\s*:\s*(flex|grid)", re.IGNORECASE), + re.compile(r"transform\s*:", re.IGNORECASE), + re.compile(r"@media", re.IGNORECASE), + re.compile(r"@page\s*\{[^}]*margin", re.IGNORECASE), + re.compile(r"var\(--", re.IGNORECASE), + re.compile(r"[vhvw]h\b|[vhvw]w\b", re.IGNORECASE), +) + + +class TestTemplates: + + def test_DC_UNIT_TPL_01_registry_loads(self): + registry = templating.load_registry() + assert isinstance(registry, dict) + entries = registry.get("templates") or [] + keys = [e.get("key") for e in entries if isinstance(e, dict)] + assert len(keys) == 7 + assert set(keys) == { + "official", "report", "application", "contract", + "speech", "plan", "document", + } + assert registry.get("fallback") == "document" + + @pytest.mark.parametrize( + "text,expected", + [ + ("帮我写一份会议通知", "official"), + ("起草一份请示", "official"), + ("写一个调研报告", "report"), + ("拟一份年度工作总结", "report"), + ("写一份项目申报书", "application"), + ("起草一份采购合同", "contract"), + ("写个讲话稿", "speech"), + ("写一份感谢信", "speech"), + ("制定一个实施方案", "plan"), + ("写个说明书", "document"), + ("写一篇论文", "document"), + ("", "document"), + ], + ) + def test_DC_UNIT_TPL_02_classify(self, text, expected): + assert templating.classify_doc_type(text) == expected + + def test_DC_UNIT_TPL_03_render_html_structure(self): + html, css = templating.render_html( + "# 关于安全生产的通知\n\n## 一、总体要求\n\n正文内容。", + title="关于安全生产的通知", + key="official", + ) + assert "
" in html and "
" in html + assert 'class="doc-title"' in html + assert "关于安全生产的通知" in html + assert "正文内容" in html + assert "{{TITLE}}" not in html + assert css and "doc-table" in css or True + + def test_DC_UNIT_TPL_04_style_hints_overlay(self): + hints = { + "body_font": "宋体", + "body_size": 14, + "headings": {"1": {"font": "黑体", "size": 18}}, + } + _, css = templating.render_html( + "# 标题\n\n正文。", title="t", key="report", style_hints=hints + ) + assert "宋体" in css + assert "font-size: 14.0pt" in css + assert "font-size: 18.0pt" in css + + def test_DC_UNIT_TPL_05_never_raises(self): + for markdown in ("", "| a | b |\n|---|---|", "```\nfence\n```", "### 标题"): + html, css = templating.render_html(markdown, title="", key="document") + assert isinstance(html, str) and isinstance(css, str) + # 乱 key 回落 fallback + html, _ = templating.render_html("x", title="", key="nonexistent-key") + assert "
" in html + + def test_DC_UNIT_TPL_06_css_story_safe(self): + tpl_dir = templating.templates_dir() + for css_file in sorted(tpl_dir.glob("*.css")): + content = css_file.read_text(encoding="utf-8") + # 剥离注释后再扫(注释里的禁用词不算违规) + content = re.sub(r"/\*[\s\S]*?\*/", "", content) + for pattern in BANNED_PATTERNS: + assert not pattern.search(content), ( + f"{css_file.name} 含 Story 禁用属性:{pattern.pattern}" + ) + + def test_DC_UNIT_TPL_07_wrapper_single_main_xml(self): + tpl_dir = templating.templates_dir() + for html_file in sorted(tpl_dir.glob("*.html")): + content = html_file.read_text(encoding="utf-8") + assert content.count("
") == 1, f"{html_file.name} main 注入点不唯一" + assert "" in content + # main 必须为空注入点(渲染时整体替换) + m = re.search(r"
\s*
", content) + assert m is not None, f"{html_file.name} main 非空注入点" + + def test_DC_UNIT_TPL_08_tables_marked(self): + html, _ = templating.render_html( + "| 列一 | 列二 |\n| --- | --- |\n| 值1 | 值2 |", + title="t", + key="document", + ) + assert 'class="doc-table"' in html + + def test_DC_UNIT_TPL_09_letterhead(self): + # 2026-09-04:红头文字可编辑持久化——letterhead 参数覆盖,空回落模版默认 + html, _ = templating.render_html( + "# 标题\n\n正文。", title="t", key="official", letterhead="某某农业科学研究院" + ) + assert "某某农业科学研究院" in html + assert "红头文件" not in html + # 空 letterhead → 模版默认 + html2, _ = templating.render_html("# 标题", title="t", key="official") + assert "红头文件" in html2 + # 无红头区模板(contract):letterhead 不影响 + html3, _ = templating.render_html("# 标题", title="t", key="contract", letterhead="x") + assert "{{LETTERHEAD}}" not in html3 + + @pytest.mark.parametrize( + "text,expected", + [ + ("帮我写一个十一放假通知,红头用三亚海古纪生物科技有限公司", "三亚海古纪生物科技有限公司"), + ("红头:某市农业农村局,写一份通知", "某市农业农村局"), + ("写一份通知,红头为某某研究所", "某某研究所"), + ("帮我写一份普通通知", ""), + ], + ) + def test_DC_UNIT_TPL_10_letterhead_prompt_extract(self, text, expected): + # 2026-09-04:prompt「红头用XX」→ 生成即采用指定红头 + got = templating.extract_letterhead_from_prompt(text) + if expected: + assert got == expected + else: + assert got == "" diff --git a/tests/docgen/test_unit_acceptance.py b/tests/docgen/test_unit_acceptance.py index 05f1b48..0a4178d 100644 --- a/tests/docgen/test_unit_acceptance.py +++ b/tests/docgen/test_unit_acceptance.py @@ -284,8 +284,8 @@ class TestVault: assert out.get("tables") is not None assert out.get("style_hints") is not None - def test_DC_UNIT_VAULT_06b_pdf_over_300_pages_rejected(self, tmp_path): - # >300 页 PDF 必须被拒绝(不得静默截断) + def test_DC_UNIT_VAULT_06b_pdf_301_pages_accepted(self, tmp_path): + # 2026-09-04 手工测试:PDF 不再设页数上限,301 页应正常解析(非拒绝) pmod = _mod("vstore.parsing") try: import pymupdf as fitz @@ -296,8 +296,8 @@ class TestVault: doc.new_page() pdf_bytes = doc.tobytes() doc.close() - with pytest.raises(Exception): - pmod.extract_pdf(pdf_bytes) + parsed = pmod.extract_pdf(pdf_bytes) + assert isinstance(parsed, dict) def test_DC_UNIT_VAULT_07_gb18030_fallback(self, tmp_path): pmod = _mod("vstore.parsing") @@ -358,15 +358,19 @@ class TestVault: assert len(results) == 10 assert peak <= 8 - def test_DC_UNIT_VAULT_10_limits(self, tmp_path): + def test_DC_UNIT_VAULT_10_no_upload_limits(self, tmp_path): + # 2026-09-04 手工测试:上传不再设大小/数量/字符门槛;嵌入预算常量替代 smod = _mod("vstore.settings") - assert smod.MAX_ATTACH_COUNT == 5 - assert smod.MAX_ATTACH_BYTES == 20 * 1024 * 1024 - assert smod.MAX_ATTACH_CHARS == 60000 + assert not hasattr(smod, "MAX_ATTACH_COUNT") + assert not hasattr(smod, "MAX_ATTACH_BYTES") + assert not hasattr(smod, "MAX_ATTACH_CHARS") + assert smod.EMBED_BODY_CAP == 200_000 vault, va, ix = _make_vault(tmp_path) - big = b"x" * (smod.MAX_ATTACH_BYTES + 1) - with pytest.raises(Exception): - vault.add(big, "big.bin", {"name": "big"}, False) + big = b"x" * (21 * 1024 * 1024) # 21MB(旧 20MB 门槛之上) + item = vault.add(big, "big.txt", {"name": "big"}, False) + assert item["document_id"] + doc = vault.get_decrypted(item["document_id"]) + assert doc["meta"]["size"] == len(big) # --------------------------------------------------------------------------- @@ -625,6 +629,33 @@ class TestConvert: for k in common: assert str(a[k]) == str(b[k]), f"style_hints[{k}] 两侧不一致" + # ---- 2026-09-04 手工测试需求2:模板 HTML → PDF(预览同源导出) ---- + + def test_DC_UNIT_CONVERT_07_pdf_from_html_cjk_extractable(self, tmp_path): + """模板 HTML → PDF:中文文本必须可提取(lang 字体陷阱回归门禁)。""" + pmod = _mod("app.services.docgen.md2pdf") + fn = getattr(pmod, "pdf_from_html", None) + tpl = _mod("app.services.docgen.templating") + if fn is None or tpl is None: + pytest.skip("PENDING: pdf_from_html/templating 未交付") + import pymupdf + + html, css = tpl.render_html( + "# 关于安全生产的通知\n\n## 一、总体要求\n\n这是正文内容。", + title="关于安全生产的通知", + key="official", + ) + out = fn(html, css, str(tmp_path / "out.pdf")) + doc = pymupdf.open(str(out)) + try: + text = "".join(p.get_text() for p in doc) + finally: + doc.close() + assert "关于安全生产的通知" in text + assert "这是正文内容" in text + # 模板包装层(红头)也进 PDF + assert "红头文件" in text + # --------------------------------------------------------------------------- # DC-UNIT-PIPELINE-01~08 @@ -675,6 +706,17 @@ class TestPipeline: monkeypatch.setattr(lmod, "complete_text", _text) monkeypatch.setattr(lmod, "complete_json", _json) + # 联网模版参考:默认 mock 为 None(离线纪律,不依赖外网); + # 用例可传 web_outline 列表显式走 web 路径 + wmod = _mod("app.services.docgen.websearch") + + async def _search(description): + wo = kw.get("web_outline") + if not wo: + return None + return wmod.WebOutline(source_url="http://t.example/f", title="联网范文", outline=list(wo)) + + monkeypatch.setattr(wmod, "search_template_outline", _search) # humanize:dev1 内部调 humanizer.humanize_answer → monkeypatch try: import app.services.humanizer as hmod @@ -693,6 +735,7 @@ class TestPipeline: kw.get("description", "帮我写一份年度工作总结"), documents=kw.get("documents", []), template=template, + template_style=kw.get("template_style"), )) return result, pmod @@ -777,6 +820,44 @@ class TestPipeline: for c in calls: assert "标题一" not in c and "列表行一" not in c and "| 甲 |" not in c, "保护行进入了 humanize" + # ---- 2026-09-04 手工测试需求2:模版 HTML 产出 ---- + + def test_DC_UNIT_PIPELINE_09_html_output(self, tmp_path, monkeypatch): + result, pmod = self._go(tmp_path, monkeypatch, with_template=False) + assert result["markdown"] + # 模版 HTML 产出(预览与导出同源) + assert result.get("html") and "
" in result["html"] + assert result.get("css") + assert result.get("template_key") # 分类出内置模版 key + assert result.get("outline_source") in ("llm", "default") + assert Path(result["md_path"]).exists() + # 2026-09-04 手工测试问题3:文件名 = LLM 提炼标题(mock 返回「占位报告」) + assert result.get("file_name") == "占位报告" + + def test_DC_UNIT_PIPELINE_09b_file_name_rule(self, tmp_path, monkeypatch): + # 2026-09-04 手工测试问题3:无 LLM 标题路径 → prompt 核心短语提炼 + result, pmod = self._go( + tmp_path, monkeypatch, with_template=True, + description="帮我写一个十一放假通知,放假时间为10月1日,红头用三亚海古纪生物科技有限公司", + ) + assert result.get("file_name") == "十一放假通知" + + def test_DC_UNIT_PIPELINE_10_web_outline_seed(self, tmp_path, monkeypatch): + web = ["一、联网章节甲", "二、联网章节乙"] + result, pmod = self._go(tmp_path, monkeypatch, with_template=False, web_outline=web) + md = result["markdown"] + assert result["outline_source"] == "web" + for sec in web: + assert sec in md # 联网范文结构成为大纲种子 + + def test_DC_UNIT_PIPELINE_11_template_style_overlay(self, tmp_path, monkeypatch): + result, pmod = self._go(tmp_path, monkeypatch, with_template=True, + template_style={"body_font": "宋体", "body_size": 14, + "headings": {"1": {"font": "黑体", "size": 18}}}) + css = result.get("css") or "" + assert "宋体" in css + assert "font-size: 14.0pt" in css + # --------------------------------------------------------------------------- # DC-UNIT-EDITOR-01~03 @@ -912,8 +993,12 @@ class TestClient: vst = _mod("vstore.settings") if bff is None or vst is None: pytest.skip("PENDING: settings 未交付") - for k in ("MAX_ATTACH_COUNT", "MAX_ATTACH_BYTES", "MAX_ATTACH_CHARS", "MAX_SECTIONS"): + for k in ("MAX_SECTIONS", "MAX_OUTLINE_CHUNKS"): a, b = getattr(bff, k, None), getattr(vst, k, None) if a is None or b is None: pytest.skip(f"{k} 未在两侧定义") assert a == b, f"{k} 两侧常量不一致" + # 2026-09-04:上传限制常量两侧均已移除 + assert not hasattr(bff, "MAX_ATTACH_COUNT") and not hasattr(vst, "MAX_ATTACH_COUNT") + assert not hasattr(bff, "MAX_ATTACH_BYTES") and not hasattr(vst, "MAX_ATTACH_BYTES") + assert not hasattr(bff, "MAX_ATTACH_CHARS") and not hasattr(vst, "MAX_ATTACH_CHARS") diff --git a/tests/docgen/test_websearch.py b/tests/docgen/test_websearch.py new file mode 100644 index 0000000..c0f6fd4 --- /dev/null +++ b/tests/docgen/test_websearch.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +"""tests/docgen/test_websearch.py —— 联网模版参考验收用例(DC-UNIT-WEB-*)。 + +2026-09-04 手工测试需求2:库内无匹配模版时联网搜索相似内容结构。 +口径:monkeypatch websearch._make_client 注入 MockTransport,全程离线。 +执行:bash tests/docgen/run_all.sh unit +""" +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +import httpx +import pytest + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "sdk")) + +from app.services.docgen import websearch # noqa: E402 + +pytestmark = pytest.mark.unit + +BING_PAGE = ( + '
    ' + '
  1. 范文一

  2. ' + '
  3. 范文二

  4. ' + "
" +) + +RESULT_PAGE = ( + "安全生产通知范文" + "

关于安全生产的通知

正文……

" + "

一、总体要求

二、重点任务

2.1 隐患排查

" + "" +) + + +def _install(monkeypatch, handler, *, force_enabled=True): + """注入 MockTransport 客户端工厂;force_enabled 绕过环境闸。""" + calls = [] + + async def wrapped(request): + calls.append(str(request.url)) + return await handler(request) + + def factory(timeout): + return httpx.AsyncClient(transport=httpx.MockTransport(wrapped)) + + monkeypatch.setattr(websearch, "_make_client", factory) + if force_enabled: + monkeypatch.setattr(websearch, "web_search_enabled", lambda: True) + return calls + + +def _run(description): + return asyncio.run(websearch.search_template_outline(description)) + + +async def _handler_page(request): + if "bing.com" in request.url.host: + return httpx.Response(200, text=BING_PAGE) + return httpx.Response(200, text=RESULT_PAGE) + + +class TestWebsearch: + + def test_DC_UNIT_WEB_01_outline_extracted(self, monkeypatch): + calls = _install(monkeypatch, _handler_page) + result = _run("帮我写一份关于安全生产的通知") + assert result is not None + assert result.source_url == "https://example.com/fanwen/1" + assert result.title == "安全生产通知范文" + assert result.outline == [ + "关于安全生产的通知", + "一、总体要求", + "二、重点任务", + "2.1 隐患排查", + ] + assert len(calls) == 2 # bing 一次 + 抓取页一次 + + def test_DC_UNIT_WEB_02_network_failure_none(self, monkeypatch): + async def handler(request): + raise httpx.ConnectError("boom", request=request) + + _install(monkeypatch, handler) + assert _run("通知") is None + + def test_DC_UNIT_WEB_03_oversize_page_none(self, monkeypatch): + big = "

一

" + "x" * (websearch.MAX_RESULT_BYTES + 10) + + async def handler(request): + if "bing.com" in request.url.host: + return httpx.Response(200, text=BING_PAGE) + return httpx.Response(200, text=big) + + _install(monkeypatch, handler) + # 截断后标题数 < MIN_HEADINGS → None + assert _run("通知") is None + + def test_DC_UNIT_WEB_04_too_few_headings_none(self, monkeypatch): + async def handler(request): + if "bing.com" in request.url.host: + return httpx.Response(200, text=BING_PAGE) + return httpx.Response(200, text="

没有标题的页面

") + + _install(monkeypatch, handler) + assert _run("通知") is None + + def test_DC_UNIT_WEB_05_gb18030_decodes(self, monkeypatch): + page = ( + "通知范文" + "

关于开展检查的通知

一、检查范围

" + ).encode("gb18030") + + async def handler(request): + if "bing.com" in request.url.host: + return httpx.Response(200, text=BING_PAGE) + return httpx.Response(200, content=page, headers={"content-type": "text/html"}) + + _install(monkeypatch, handler) + result = _run("通知") + assert result is not None + assert result.outline == ["关于开展检查的通知", "一、检查范围"] + + def test_DC_UNIT_WEB_06_env_gate_off(self, monkeypatch): + async def handler(request): + return httpx.Response(200, text=BING_PAGE) + + calls = _install(monkeypatch, handler, force_enabled=False) + monkeypatch.setattr(websearch, "web_search_enabled", lambda: False) + assert _run("通知") is None + assert not calls # 关闸后不发任何网络请求 diff --git a/ui/src/api/docgen.js b/ui/src/api/docgen.js index 1f41385..3f39aba 100644 --- a/ui/src/api/docgen.js +++ b/ui/src/api/docgen.js @@ -7,9 +7,11 @@ import { get, post, patch } from './http.js'; * 上传入库(用户拖拽即走此路):multipart → vstore /ingest,入库主向量库持久可用。 * @param {File} file * @param {{ name?: string, category?: string, description?: string, tags?: string, as_template?: boolean }} meta + * @param {{ onUploadProgress?: (e: {loaded:number, total:number, pct:number}) => void }} [opts] + * onUploadProgress:真实上传字节进度(http.js XHR 分支回调) * @returns {Promise<{ok:boolean, items:[{library_id, name, size, as_template}]}>} */ -export function uploadLibrary(file, meta = {}) { +export function uploadLibrary(file, meta = {}, opts = {}) { const form = new FormData(); form.append('file', file); if (meta.name != null && meta.name !== '') form.append('name', meta.name); @@ -17,7 +19,7 @@ export function uploadLibrary(file, meta = {}) { if (meta.description != null && meta.description !== '') form.append('description', meta.description); if (meta.tags != null && meta.tags !== '') form.append('tags', meta.tags); if (meta.as_template != null) form.append('as_template', meta.as_template ? 'true' : 'false'); - return post('/api/docgen/library', form); + return post('/api/docgen/library', form, opts); } /** @@ -33,9 +35,14 @@ export function getArtifactContent(artifactId) { return get(`/api/docgen/artifacts/${encodeURIComponent(artifactId)}/content`); } -/** 保存编辑后的 markdown(debounce PATCH) */ -export function saveArtifactContent(artifactId, markdown) { - return patch(`/api/docgen/artifacts/${encodeURIComponent(artifactId)}/content`, { markdown }); +/** 取 artifact 的模版 HTML({html, css, template_key},预览与导出同源) */ +export function getArtifactHtml(artifactId) { + return get(`/api/docgen/artifacts/${encodeURIComponent(artifactId)}/html`); +} + +/** 保存编辑后的 markdown(debounce PATCH);letterhead 为红头文字(2026-09-04 就地编辑持久化) */ +export function saveArtifactContent(artifactId, markdown, letterhead) { + return patch(`/api/docgen/artifacts/${encodeURIComponent(artifactId)}/content`, { markdown, letterhead }); } /** 「用 prompt 修改」:后端按 instruction 返回完整新 md */ diff --git a/ui/src/api/http.js b/ui/src/api/http.js index 00dc259..bcba78a 100644 --- a/ui/src/api/http.js +++ b/ui/src/api/http.js @@ -41,11 +41,97 @@ function buildQuery(params) { return qs ? `?${qs}` : ''; } -async function request(path, { method = 'GET', body, headers = {}, timeout = DEFAULT_TIMEOUT_MS, signal, raw = false, params } = {}) { +function _parseXhrPayload(xhr) { + const ct = xhr.getResponseHeader('content-type') || ''; + if (!ct.includes('application/json')) return null; + try { + return JSON.parse(xhr.responseText); + } catch { + return null; + } +} + +/** FormData + onUploadProgress → XHR(fetch 无上传进度事件)。 + * 契约与 fetch 分支完全一致:2xx 返回 JSON payload;非 2xx 走统一错误信封抛 ApiError。 */ +function xhrUpload(url, method, body, { timeout, signal, onUploadProgress }) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + // 不设 Content-Type:浏览器自动带 multipart boundary(与 fetch 分支一致) + if (body instanceof FormData && body.has('file')) { + const f = body.get('file'); + if (f instanceof Blob && (f.size || 0) > 0) xhr._paSizeHint = f.size; + } + xhr.responseType = 'text'; + let timedOut = false; + let abortedBySignal = false; + const t = timeout === 0 ? null : setTimeout(() => { + timedOut = true; + xhr.abort(); + }, timeout); + const onAbort = () => { + if (signal && signal.aborted) { + abortedBySignal = true; + xhr.abort(); + } + }; + if (signal) { + if (signal.aborted) abortedBySignal = true; + else signal.addEventListener('abort', onAbort); + } + if (onUploadProgress && xhr.upload) { + xhr.upload.onprogress = (e) => { + if (!e.lengthComputable) return; + const total = e.total || xhr._paSizeHint || 0; + onUploadProgress({ loaded: e.loaded, total, pct: total ? Math.min(1, e.loaded / total) : 0 }); + }; + } + xhr.onload = () => { + if (t) clearTimeout(t); + if (signal) signal.removeEventListener('abort', onAbort); + const payload = _parseXhrPayload(xhr); + if (xhr.status >= 200 && xhr.status < 300) { + resolve(payload); + return; + } + if (payload && payload.ok === false && payload.error) { + reject(new ApiError(payload.error.message || '服务错误', { + code: payload.error.code || 'INTERNAL', + status: xhr.status, + degraded: !!payload.error.degraded, + })); + return; + } + reject(new ApiError(`请求失败(HTTP ${xhr.status})`, { code: 'INTERNAL', status: xhr.status })); + }; + xhr.onerror = () => { + if (t) clearTimeout(t); + if (signal) signal.removeEventListener('abort', onAbort); + if (abortedBySignal) { + reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })); + return; + } + reject(new ApiError('网络异常,请检查连接', { + code: 'NETWORK', + degraded: false, + cause: timedOut ? new Error('timeout') : undefined, + })); + }; + xhr.ontimeout = () => xhr.abort(); + xhr.send(body); + }); +} + +async function request(path, { method = 'GET', body, headers = {}, timeout = DEFAULT_TIMEOUT_MS, signal, raw = false, params, onUploadProgress } = {}) { // GET 的 params 序列化为 query string(BUG-R1-01) let url = path; if (method === 'GET' && params) url += buildQuery(params); + // 上传进度(2026-09-04):FormData + onUploadProgress → XHR 分支(fetch 无 upload 事件) + if (onUploadProgress && typeof FormData !== 'undefined' && body instanceof FormData) { + return xhrUpload(url, method, body, { timeout, signal, onUploadProgress }); + } + const opts = { method, headers: { ...headers }, signal }; if (body !== undefined && body !== null) { if (typeof FormData !== 'undefined' && body instanceof FormData) { @@ -129,7 +215,8 @@ async function request(path, { method = 'GET', body, headers = {}, timeout = DEF export const api = { /** params 序列化为 query string(BUG-R1-01);opts 可传 { timeout, signal, raw } */ get: (path, params = {}, opts = {}) => request(path, { ...opts, method: 'GET', params }), - /** body 为普通对象 → JSON;FormData → multipart。opts 可传 { timeout, signal, raw } */ + /** body 为普通对象 → JSON;FormData → multipart。 + * opts 可传 { timeout, signal, raw, onUploadProgress }(onUploadProgress 仅 FormData 生效) */ post: (path, body, opts = {}) => request(path, { ...opts, method: 'POST', body }), patch: (path, body, opts = {}) => request(path, { ...opts, method: 'PATCH', body }), del: (path, opts = {}) => request(path, { ...opts, method: 'DELETE' }), diff --git a/ui/src/components/bench/DocPane.vue b/ui/src/components/bench/DocPane.vue index 6a7132c..f8c0ac8 100644 --- a/ui/src/components/bench/DocPane.vue +++ b/ui/src/components/bench/DocPane.vue @@ -1,78 +1,155 @@