forked from admin/PlantAI-rag-new
fix(docgen): 两批手工测试修复——上传/预览/检索 + 护栏/导出/命名/编辑还原/二次预览
第一批:上传去限制+进度展示、模板HTML预览+按需导出、检索TAB实时更新
第二批:edit护栏移除、导出按钮纯化+file_name落库、编辑失焦还原、
二次预览白底根治(防竞态+渲染兜底)
模板体系:7套公文模板(templates/)+templating渲染器、websearch联网
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
+127
-25
@@ -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=(
|
||||
|
||||
+8
-1
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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") 后 | 表格才出 <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"<tr[^>]*>([\s\S]*?)</tr>", html, re.IGNORECASE):
|
||||
cells = [
|
||||
re.sub(r"<[^>]+>", "", c).strip()
|
||||
for c in re.findall(r"<t[dh][^>]*>([\s\S]*?)</t[dh]>", m.group(1), re.IGNORECASE)
|
||||
]
|
||||
if cells:
|
||||
lines.append("| " + " | ".join(cells) + " |")
|
||||
for m in re.finditer(r"<p[^>]*>([\s\S]*?)</p>", 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
|
||||
|
||||
@@ -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/<uuid>.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/<uuid>.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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{TITLE}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc-cover" contenteditable="false">
|
||||
<div class="doc-letterhead">{{LETTERHEAD}}</div>
|
||||
</div>
|
||||
<main>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{TITLE}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{TITLE}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{TITLE}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc-cover" contenteditable="false">
|
||||
<div class="doc-letterhead">{{LETTERHEAD}}</div>
|
||||
</div>
|
||||
<main>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{TITLE}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{TITLE}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc-cover" contenteditable="false">
|
||||
<div class="doc-letterhead">{{LETTERHEAD}}</div>
|
||||
</div>
|
||||
<main>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{TITLE}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"<main>\s*</main>")
|
||||
_TABLE_RE = re.compile(r"<table>")
|
||||
_FIRST_H1_RE = re.compile(r"<h1>")
|
||||
_FONT_SAFE_RE = re.compile(r"[^\w一-鿿\- ]+") # 字体名白名单(防 css 注入)
|
||||
|
||||
# 红头指定提取(2026-09-04):「红头用三亚海古纪生物科技有限公司」/
|
||||
# 「红头:XX公司」/「红头为XX」→ 捕获公司名(2-20 个中英文字符)
|
||||
_LETTERHEAD_PROMPT_RE = re.compile(
|
||||
r"红头(?:文字|名称|标题|内容)?\s*(?:用|为|是|写|写成|设为|设定|叫做?|叫作?)?\s*[::,,、]?\s*([一-鿿A-Za-z0-9()()·]{2,20})"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateAsset:
|
||||
key: str
|
||||
title: str # 模版名(面向用户展示,落 template_name)
|
||||
description: str
|
||||
html: str # wrapper 全文(含 {{TITLE}} 占位)
|
||||
css: str # 双渲染安全 css
|
||||
source: str = "builtin"
|
||||
|
||||
|
||||
def templates_dir() -> Path:
|
||||
"""模版资产目录(同包 templates/,随包 rsync 进编译树)。"""
|
||||
return Path(__file__).resolve().parent / "templates"
|
||||
|
||||
|
||||
def load_registry() -> dict[str, Any]:
|
||||
"""读 index.json。失败返回空 dict(不影响兜底)。"""
|
||||
try:
|
||||
raw = (templates_dir() / "index.json").read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _fallback_key() -> str:
|
||||
fb = str((load_registry() or {}).get("fallback") or _FALLBACK_KEY)
|
||||
return fb or _FALLBACK_KEY
|
||||
|
||||
|
||||
def _read_asset(name: str) -> str:
|
||||
try:
|
||||
return (templates_dir() / name).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def get_template_asset(key: str) -> TemplateAsset | None:
|
||||
"""key 无效 → None(调用方回落 fallback)。"""
|
||||
entries = (load_registry() or {}).get("templates") or []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict) or entry.get("key") != key:
|
||||
continue
|
||||
html = _read_asset(f"{key}.html")
|
||||
css = _read_asset(str(entry.get("css") or f"{key}.css"))
|
||||
if not html:
|
||||
return None
|
||||
return TemplateAsset(
|
||||
key=key,
|
||||
title=str(entry.get("title") or key),
|
||||
description=str(entry.get("description") or ""),
|
||||
html=html,
|
||||
css=css,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def list_templates() -> list[dict[str, str]]:
|
||||
"""[{key, title, description}](未来模板选择 UI 用)。"""
|
||||
out: list[dict[str, str]] = []
|
||||
for entry in (load_registry() or {}).get("templates") or []:
|
||||
if isinstance(entry, dict) and entry.get("key"):
|
||||
out.append({
|
||||
"key": str(entry["key"]),
|
||||
"title": str(entry.get("title") or entry["key"]),
|
||||
"description": str(entry.get("description") or ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def classify_doc_type(description: str) -> str:
|
||||
"""规则分类器 → 模版 key。逐词子串匹配(词序即优先级),均不中 → fallback。
|
||||
|
||||
确定性、零 LLM 成本;「自设计」= 该类型的内置设计样式。
|
||||
"""
|
||||
text = description or ""
|
||||
for key, words in _CLASSIFY_RULES:
|
||||
for word in words:
|
||||
if word in text:
|
||||
return key
|
||||
return _fallback_key()
|
||||
|
||||
|
||||
def _style_overlay_css(style_hints: dict[str, Any] | None) -> str:
|
||||
"""style_hints(vstore docx 样式提取)→ css 覆盖行(追加在模版 css 尾部)。"""
|
||||
if not isinstance(style_hints, dict) or not style_hints:
|
||||
return ""
|
||||
lines: list[str] = []
|
||||
body_font = str(style_hints.get("body_font") or "").strip()
|
||||
body_size = style_hints.get("body_size")
|
||||
if body_font and _FONT_SAFE_RE.sub("", body_font):
|
||||
lines.append(
|
||||
f'body, p, li, td {{ font-family: "{body_font}", serif; }}'
|
||||
)
|
||||
if isinstance(body_size, (int, float)) and 6 <= float(body_size) <= 72:
|
||||
lines.append(f"body {{ font-size: {float(body_size):.1f}pt; }}")
|
||||
headings = style_hints.get("headings")
|
||||
if isinstance(headings, dict):
|
||||
for level, hint in headings.items():
|
||||
if not isinstance(hint, dict):
|
||||
continue
|
||||
try:
|
||||
lv = int(level)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if lv < 1 or lv > 6:
|
||||
continue
|
||||
font = str(hint.get("font") or "").strip()
|
||||
size = hint.get("size")
|
||||
if font and _FONT_SAFE_RE.sub("", font):
|
||||
lines.append(f'h{lv} {{ font-family: "{font}", sans-serif; }}')
|
||||
if isinstance(size, (int, float)) and 8 <= float(size) <= 60:
|
||||
lines.append(f"h{lv} {{ font-size: {float(size):.1f}pt; }}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _body_html(markdown: str) -> str:
|
||||
"""md → body HTML + 模版类标记(表格 doc-table、首 h1 doc-title)。"""
|
||||
body = _md_to_html(markdown or "")
|
||||
body = _TABLE_RE.sub('<table class="doc-table">', body)
|
||||
body = _FIRST_H1_RE.sub('<h1 class="doc-title">', body, count=1)
|
||||
return f'<div class="doc-body">{body}</div>'
|
||||
|
||||
|
||||
def _resolve_asset(key: str) -> TemplateAsset | None:
|
||||
asset = get_template_asset(key)
|
||||
if asset is not None:
|
||||
return asset
|
||||
if key != _fallback_key():
|
||||
return get_template_asset(_fallback_key())
|
||||
return None
|
||||
|
||||
|
||||
def _default_letterhead(key: str) -> str:
|
||||
"""各模板红头默认文字(可被制品 letterhead 覆盖,2026-09-04)。"""
|
||||
return {
|
||||
"official": "红头文件",
|
||||
"report": "报 告",
|
||||
"application": "申 报 材 料",
|
||||
}.get(key, "")
|
||||
|
||||
|
||||
def extract_letterhead_from_prompt(text: str) -> str:
|
||||
"""从生成描述提取用户指定的红头文字(2026-09-04)。
|
||||
|
||||
「红头用三亚海古纪生物科技有限公司」「红头:XX公司」「红头为XX」→
|
||||
捕获 2-20 个中英文字符;未指定(含默认「红头文件」字样的描述)返回空串。
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
m = _LETTERHEAD_PROMPT_RE.search(text)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def render_html(
|
||||
markdown: str,
|
||||
*,
|
||||
title: str,
|
||||
key: str = "document",
|
||||
style_hints: dict[str, Any] | None = None,
|
||||
letterhead: str = "",
|
||||
) -> tuple[str, str]:
|
||||
"""markdown → (完整 html, css)。纯同步纯函数,绝不抛异常。
|
||||
|
||||
letterhead:红头文字(编辑可改,制品持久化);空串回落模板默认。
|
||||
任何环节失败都回落 fallback 模版;再失败给最小可读 HTML。
|
||||
"""
|
||||
try:
|
||||
asset = _resolve_asset(key) or _resolve_asset(_FALLBACK_KEY)
|
||||
body = _body_html(markdown)
|
||||
if asset is not None:
|
||||
wrapper = asset.html
|
||||
if _MAIN_RE.search(wrapper):
|
||||
html = _MAIN_RE.sub("<main>" + body + "</main>", wrapper, count=1)
|
||||
else:
|
||||
html = (
|
||||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
|
||||
f"<title>{_esc(str(title))}</title></head><body>"
|
||||
f"{body}</body></html>"
|
||||
)
|
||||
css = asset.css
|
||||
overlay = _style_overlay_css(style_hints)
|
||||
if overlay:
|
||||
css = f"{css}\n/* style_hints 覆盖 */\n{overlay}"
|
||||
else:
|
||||
html = (
|
||||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
|
||||
f"<title>{_esc(str(title))}</title></head><body>{body}</body></html>"
|
||||
)
|
||||
css = ""
|
||||
# 标题/红头占位(wrapper 的 <title> 与 .doc-letterhead 用)
|
||||
html = html.replace("{{TITLE}}", _esc(str(title)))
|
||||
now = datetime.now().strftime("%Y年%m月%d日")
|
||||
html = html.replace("{{DATE}}", now)
|
||||
lh = (letterhead or "").strip() or _default_letterhead(key)
|
||||
html = html.replace("{{LETTERHEAD}}", _esc(lh))
|
||||
return html, css
|
||||
except Exception as exc: # 兜底:绝不抛
|
||||
logger.warning("docgen: 模版渲染失败(%s),回落最小 HTML", exc)
|
||||
return (
|
||||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head>"
|
||||
f"<body><p>{_esc(str(markdown or ''))}</p></body></html>",
|
||||
"",
|
||||
)
|
||||
|
||||
|
||||
def _esc(text: str) -> str:
|
||||
return (
|
||||
str(text).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
)
|
||||
@@ -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]*?)</title>", 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)
|
||||
@@ -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 的 `<html lang="zh-CN">` 字体陷阱**: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 内无 <body> 元素导致锁定逻辑未执行 → 改以 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
|
||||
|
||||
@@ -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 "<main>" in html and "</main>" 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 "<main>" 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("<main>") == 1, f"{html_file.name} main 注入点不唯一"
|
||||
assert "<!DOCTYPE html>" in content
|
||||
# main 必须为空注入点(渲染时整体替换)
|
||||
m = re.search(r"<main>\s*</main>", 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 == ""
|
||||
@@ -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 "<main>" 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")
|
||||
|
||||
@@ -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 = (
|
||||
'<html><body><ol id="b_results">'
|
||||
'<li class="b_algo"><h2><a href="https://example.com/fanwen/1">范文一</a></h2></li>'
|
||||
'<li class="b_algo"><h2><a href="https://example.com/fanwen/2">范文二</a></h2></li>'
|
||||
"</ol></body></html>"
|
||||
)
|
||||
|
||||
RESULT_PAGE = (
|
||||
"<html><head><title>安全生产通知范文</title></head><body>"
|
||||
"<h1>关于安全生产的通知</h1><p>正文……</p>"
|
||||
"<h2>一、总体要求</h2><h2>二、重点任务</h2><h3>2.1 隐患排查</h3>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
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 = "<h1>一</h1>" + "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="<html><body><p>没有标题的页面</p></body></html>")
|
||||
|
||||
_install(monkeypatch, handler)
|
||||
assert _run("通知") is None
|
||||
|
||||
def test_DC_UNIT_WEB_05_gb18030_decodes(self, monkeypatch):
|
||||
page = (
|
||||
"<html><head><title>通知范文</title></head><body>"
|
||||
"<h1>关于开展检查的通知</h1><h2>一、检查范围</h2></body></html>"
|
||||
).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 # 关闸后不发任何网络请求
|
||||
+12
-5
@@ -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 */
|
||||
|
||||
+89
-2
@@ -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' }),
|
||||
|
||||
+237
-150
@@ -1,78 +1,155 @@
|
||||
<script setup>
|
||||
// bench/DocPane.vue —— 文档 TAB 主体(P3)
|
||||
// 预览:markdown-it 渲染 HTML(复用 markdown.js renderMarkdown/sanitizeHtml,LLM 输出先消毒)
|
||||
// 编辑:块级就地编辑(标题/正文段落/列表/表格行 contenteditable,blur 序列化回 md
|
||||
// + debounce 800ms PATCH 保存)
|
||||
// 修改:「用 prompt 修改」输入行 → edit 端点(后端返回完整新 md)
|
||||
// 导出:PDF/DOCX 按钮组 → export 端点 → 下载(download_url 兜底可点链接)
|
||||
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue';
|
||||
// 2026-09-04 手工测试需求2 重构:
|
||||
// 预览:服务端模版 HTML(GET /artifacts/{id}/html)在 Shadow DOM 内渲染——
|
||||
// 模版 css 注入 shadow <style>,与应用样式天然隔离、双向不泄漏;
|
||||
// html 为空(旧制品/加载失败)回退客户端 markdown 渲染。
|
||||
// 编辑:HTML 所见即所得——shadow 内 <main> 置 contenteditable,
|
||||
// 非内容区(.doc-cover 等 wrapper 块)在模版资产里已标 contenteditable="false";
|
||||
// 极简工具条(加粗/斜体/下划线/撤销,mousedown.prevent 保选区);
|
||||
// focusout(冒泡)序列化回 md(块级 + 行内 strong/em/code/a)→ debounce PATCH
|
||||
// → 服务端按新 md 现渲染 → store 重取 html → 预览回显。
|
||||
// 修改:「用 prompt 修改」输入行 → edit 端点(后端返回完整新 md)。
|
||||
// 导出:PDF(优先,预览同源 HTML+CSS)/ DOCX(md 路径,纯编辑副本)。
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useDocgenStore } from '../../stores/docgen.js';
|
||||
import { DG } from '../../i18n/docgen.js';
|
||||
import { renderMarkdown } from '../chat/markdown.js';
|
||||
import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js';
|
||||
import { renderMarkdown, sanitizeHtml } from '../chat/markdown.js';
|
||||
|
||||
const docgen = useDocgenStore();
|
||||
|
||||
const previewEl = ref(null);
|
||||
const editRoot = ref(null); // 编辑态内容容器(其直接子元素为可编辑块)
|
||||
const hostEl = ref(null); // Shadow DOM 宿主(预览与编辑共用)
|
||||
|
||||
// 导出动作若由 Composer 的 export 分支触发(docgen.requestExport()),在此消费
|
||||
watch(
|
||||
() => docgen.exportRequestSeq,
|
||||
(seq) => {
|
||||
if (seq > 0) docgen.runExport('docx');
|
||||
if (seq > 0) docgen.runExport('pdf'); // 2026-09-04:默认导出 PDF(优先格式)
|
||||
},
|
||||
);
|
||||
|
||||
/** 预览 HTML:md → renderMarkdown(markdown-it + DOMPurify);空内容给空态 */
|
||||
const previewHtml = computed(() => {
|
||||
const md = docgen.md;
|
||||
if (!md) return '';
|
||||
return renderMarkdown(md);
|
||||
});
|
||||
|
||||
/** 编辑态渲染 HTML(进入编辑时以当前 md 渲染一次;blur 序列化后重建,
|
||||
* 避免编辑期间随 docgen.md 响应式重渲染打断焦点) */
|
||||
const editHtml = ref('');
|
||||
|
||||
/** 预览模式 / 编辑模式 */
|
||||
const mode = ref('preview');
|
||||
const editing = computed(() => mode.value === 'edit');
|
||||
|
||||
let lastOuter = ''; // 序列化结果缓存(无变化不触发保存)
|
||||
|
||||
// ---------- 块级就地编辑(blur 序列化回 md) ----------
|
||||
// BUG-2 修复:contenteditable 是 HTML 属性,CSS 无法添加——渲染后程序化接线。
|
||||
// 按 serializeMd 覆盖的块类型:标题/段落/引用/代码块给元素本身,列表给 li,表格给 tr
|
||||
// (v-html 内容不经 Vue 渲染,必须 DOM 直设)。
|
||||
const EDITABLE_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'PRE', 'BLOCKQUOTE']);
|
||||
// ---------- Shadow DOM 渲染 ----------
|
||||
|
||||
function wireContenteditable() {
|
||||
const root = editRoot.value;
|
||||
if (!root) return;
|
||||
for (const el of Array.from(root.children)) {
|
||||
const tag = (el.tagName || '').toUpperCase();
|
||||
if (EDITABLE_TAGS.has(tag)) {
|
||||
el.setAttribute('contenteditable', 'true');
|
||||
} else if (tag === 'UL' || tag === 'OL') {
|
||||
for (const li of Array.from(el.children)) {
|
||||
if (li.tagName === 'LI') li.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
} else if (tag === 'TABLE') {
|
||||
for (const tr of el.querySelectorAll('tr')) tr.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
}
|
||||
function shadowRoot() {
|
||||
const host = hostEl.value;
|
||||
if (!host) return null;
|
||||
if (host.shadowRoot) return host.shadowRoot;
|
||||
return host.attachShadow({ mode: 'open' });
|
||||
}
|
||||
/** 从 contenteditable 块 DOM 反向序列化为 markdown(标题/段落/列表/表格,与预览结构 1:1) */
|
||||
const serializeMd = () => {
|
||||
const root = editRoot.value;
|
||||
if (!root) return '';
|
||||
const nodes = Array.from(root.childNodes);
|
||||
|
||||
/** 模版 HTML 全文 → body 片段(DOMParser 拆出 body;css 单独注入 <style>) */
|
||||
function bodyFragment(fullHtml) {
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(fullHtml || '', 'text/html');
|
||||
if (doc.body) return doc.body.innerHTML;
|
||||
} catch { /* 解析失败走原文 */ }
|
||||
return fullHtml || '';
|
||||
}
|
||||
|
||||
/** 渲染 shadow:<style> 注入模版 css + 消毒后的正文 HTML。
|
||||
* 2026-09-04:shadow 内无 <body> 元素,模板 css 的 body 选择器不命中
|
||||
* (字体/黑色文字全丢、白底白字)——内容包一层 .doc-root,css 的 body
|
||||
* 选择器改写为 .doc-root(PDF 侧有真 body,不受影响)。 */
|
||||
function mountShadow() {
|
||||
const root = shadowRoot();
|
||||
if (!root) return;
|
||||
let inner = '';
|
||||
if (docgen.html) {
|
||||
const css = (docgen.css || '').replace(/\bbody\b/g, '.doc-root');
|
||||
inner = `<style>${css}</style><div class="doc-root">${sanitizeHtml(bodyFragment(docgen.html))}</div>`;
|
||||
} else {
|
||||
// 回退:客户端 md 渲染(旧制品/拉取失败)
|
||||
inner = `<div class="doc-body md-text">${renderMarkdown(docgen.md)}</div>`;
|
||||
}
|
||||
root.innerHTML = inner;
|
||||
if (editing.value) activateEditDom();
|
||||
}
|
||||
|
||||
watch(() => [docgen.html, docgen.css, docgen.renderNonce], () => {
|
||||
// 2026-09-04 问题5:宿主元素可能随 loading 态被 v-if 卸载重建,
|
||||
// nextTick 后 mountShadow 才能拿到新宿主;renderNonce 兜底 html 拉取失败
|
||||
// 时 html ''→'' 无字段变化不触发 watch 的场景。
|
||||
nextTick(() => {
|
||||
if (mode.value === 'preview') mountShadow();
|
||||
});
|
||||
});
|
||||
// 首次挂载/切 TAB 回来时 html 已在 store:watch 不会触发,须主动渲染;
|
||||
// md 变化时(预览态)一律重建:html 为空走 md 回退渲染,非空走 html 渲染
|
||||
//(2026-09-04 问题5:原 !docgen.html 守卫会把 A 的旧 html 挡住 B 的 md 回退)
|
||||
watch(() => docgen.md, () => {
|
||||
nextTick(() => {
|
||||
if (mode.value === 'preview') mountShadow();
|
||||
});
|
||||
});
|
||||
onMounted(() => nextTick(mountShadow));
|
||||
|
||||
// ---------- HTML 所见即所得编辑 ----------
|
||||
|
||||
/** 编辑态激活:内容区整体可编辑(2026-09-04 手工测试:红头区文字也可改)。
|
||||
* .doc-root 包含 main 与 cover/letterhead 等包装区,全部放行;
|
||||
* 序列化仍只取 <main>(红头文字单独经 letterhead 持久化,不进 markdown)。 */
|
||||
function activateEditDom() {
|
||||
const root = shadowRoot();
|
||||
if (!root) return;
|
||||
const scope = root.querySelector('.doc-root')
|
||||
|| root.querySelector('main')
|
||||
|| root.querySelector('.doc-body');
|
||||
if (scope) scope.setAttribute('contenteditable', 'true');
|
||||
}
|
||||
|
||||
/** 行内序列化:strong/em/code/a → markdown;文本节点转义歧义字符 */
|
||||
const inlineMd = (node) => {
|
||||
let out = '';
|
||||
const cellText = (el) => String(el.innerText == null ? '' : el.innerText).replace(/\n/g, ' ').trim();
|
||||
const blockText = (el) => String(el.innerText == null ? '' : el.innerText).replace(/ /g, ' ').trim();
|
||||
for (const node of nodes) {
|
||||
const walk = (el) => {
|
||||
for (const child of Array.from(el.childNodes || [])) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
out += String(child.textContent || '');
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
const tag = (child.tagName || '').toUpperCase();
|
||||
const inner = () => { const s = out; out = ''; walk(child); const r = out; out = s; return r; };
|
||||
if (tag === 'STRONG' || tag === 'B') out += `**${inner()}**`;
|
||||
else if (tag === 'EM' || tag === 'I') out += `*${inner()}*`;
|
||||
else if (tag === 'CODE') out += `\`${inner()}\``;
|
||||
else if (tag === 'A') {
|
||||
const href = child.getAttribute('href') || '';
|
||||
out += href ? `[${inner()}](${href})` : inner();
|
||||
} else if (tag === 'BR') out += '\n';
|
||||
else walk(child); // 未知行内元素:丢标签取内容(天然消毒)
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(node);
|
||||
return out;
|
||||
};
|
||||
|
||||
/** 块内容文本:优先行内序列化;空时回退 innerText */
|
||||
const blockMd = (el) => {
|
||||
const text = inlineMd(el);
|
||||
if (text && text.trim()) return text.replace(/ /g, ' ').trim();
|
||||
return String(el.innerText == null ? '' : el.innerText).replace(/ /g, ' ').trim();
|
||||
};
|
||||
|
||||
/** 从 shadow 内 <main> 块序列化回 markdown(块级 + 行内,与模版结构 1:1) */
|
||||
const serializeMd = () => {
|
||||
const root = shadowRoot();
|
||||
if (!root) return '';
|
||||
// .doc-root 包装层内含 main;回退 md 渲染路径无 main,直接以 .doc-body 为块容器
|
||||
const main = root.querySelector('main');
|
||||
// 解包 .doc-body 包装层(渲染管道统一包裹),取真实块序列
|
||||
const wrapper = (main || root).querySelector('.doc-body');
|
||||
const blockNodes = wrapper ? Array.from(wrapper.childNodes) : Array.from((main || root).childNodes);
|
||||
let out = '';
|
||||
const cellText = (el) => String(el.innerText == null ? '' : el.innerText).replace(/\n/g, ' ').replace(/ /g, ' ').trim();
|
||||
for (const node of blockNodes) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
const tag = (node.tagName || '').toUpperCase();
|
||||
const t = blockText(node);
|
||||
const t = blockMd(node);
|
||||
if (!t && !['TABLE', 'PRE'].includes(tag)) continue;
|
||||
if (tag === 'H1') out += `${t}\n${'='.repeat(Math.max(t.length, 1))}\n\n`;
|
||||
else if (tag === 'H2') out += `## ${t}\n\n`;
|
||||
@@ -81,11 +158,11 @@ const serializeMd = () => {
|
||||
else if (tag === 'H5') out += `##### ${t}\n\n`;
|
||||
else if (tag === 'H6') out += `###### ${t}\n\n`;
|
||||
else if (tag === 'UL') {
|
||||
for (const li of Array.from(node.children).filter((c) => c.tagName === 'LI')) out += `- ${blockText(li)}\n`;
|
||||
for (const li of Array.from(node.children).filter((c) => c.tagName === 'LI')) out += `- ${blockMd(li)}\n`;
|
||||
out += '\n';
|
||||
} else if (tag === 'OL') {
|
||||
let i = 1;
|
||||
for (const li of Array.from(node.children).filter((c) => c.tagName === 'LI')) out += `${i++}. ${blockText(li)}\n`;
|
||||
for (const li of Array.from(node.children).filter((c) => c.tagName === 'LI')) out += `${i++}. ${blockMd(li)}\n`;
|
||||
out += '\n';
|
||||
} else if (tag === 'TABLE') {
|
||||
const trs = Array.from(node.querySelectorAll('tr'));
|
||||
@@ -111,82 +188,97 @@ const serializeMd = () => {
|
||||
return out;
|
||||
};
|
||||
|
||||
/** 进入编辑:以当前 md 渲染一次可编辑 DOM,nextTick 后程序化接线 contenteditable
|
||||
* (BUG-2 修复:v-html 出的是静态 HTML,属性只能 DOM 直设) */
|
||||
/** 进入编辑:以当前模版 HTML 重建 shadow 并激活;无 html 回退 md 渲染 */
|
||||
const enterEdit = () => {
|
||||
lastOuter = docgen.md || '';
|
||||
editHtml.value = renderMarkdown(docgen.md);
|
||||
mode.value = 'edit';
|
||||
nextTick(wireContenteditable);
|
||||
mountShadow();
|
||||
};
|
||||
|
||||
/** blur/focusout 序列化:比对变化后写回 store(store 的 debounce 会做 PATCH);
|
||||
* 写回后按序列化结果重建编辑 DOM 并重新接线。
|
||||
* R2 修复(DC-UI-08):blur 不冒泡——改用 focusout(冒泡,根 div 可收到所有子块失焦);
|
||||
* relatedTarget 仍在编辑区内 = 块间焦点迁移,不序列化(防止重建打断输入)。
|
||||
* 防抖:focusout→序列化→重建→焦点恢复引发的二次 focusout 不重复序列化。 */
|
||||
* 写回后按序列化结果重建 shadow 并重新激活。
|
||||
* focusout(冒泡,宿主可收到 shadow 内失焦——shadow 焦点事件重定向到宿主);
|
||||
* relatedTarget 仍在宿主内 = 块间焦点迁移,不序列化(防止重建打断输入)。 */
|
||||
/** 从 shadow 读取红头文字(编辑后持久化用;空=模版默认) */
|
||||
const currentLetterhead = () => {
|
||||
const root = shadowRoot();
|
||||
const el = root ? root.querySelector('.doc-letterhead') : null;
|
||||
return el ? String(el.textContent || '').replace(/\s+/g, ' ').trim() : '';
|
||||
};
|
||||
|
||||
let editBlurTimer = null;
|
||||
const onEditBlur = (e) => {
|
||||
if (!editing.value) return;
|
||||
// 焦点迁移到编辑区内其他块:不触发保存(输入未离开编辑区)
|
||||
if (editRoot.value && e.relatedTarget && editRoot.value.contains(e.relatedTarget)) return;
|
||||
// 记住失焦块在顶层 children 中的索引,重建后恢复焦点到同位置(体验:焦点不丢失)
|
||||
const focusIdx = rememberFocusIndex(e.target);
|
||||
const host = hostEl.value;
|
||||
if (host && e.relatedTarget && host.contains(e.relatedTarget)) return;
|
||||
if (editBlurTimer) clearTimeout(editBlurTimer);
|
||||
editBlurTimer = setTimeout(() => {
|
||||
editBlurTimer = null;
|
||||
if (!editing.value) return;
|
||||
const next = serializeMd();
|
||||
if (next && next !== lastOuter) {
|
||||
const nextLh = currentLetterhead();
|
||||
const changed = (next && next !== lastOuter) || nextLh !== (docgen.letterhead || '');
|
||||
if (changed) {
|
||||
lastOuter = next;
|
||||
docgen.md = next;
|
||||
if (nextLh !== docgen.letterhead) docgen.letterhead = nextLh;
|
||||
docgen.scheduleSave();
|
||||
editHtml.value = renderMarkdown(next);
|
||||
// 2026-09-04 问题4:内容有变化时不重建 shadow——旧 html 重建会把用户
|
||||
// 正在编辑的 DOM 内容瞬间还原;保存后 fetchHtml 会经 watch 刷新预览。
|
||||
// 焦点仍在编辑区,无需激活。
|
||||
} else {
|
||||
// 无变化(仅焦点漂移):按当前 html 重建,保持结构一致
|
||||
mountShadow();
|
||||
nextTick(() => {
|
||||
wireContenteditable();
|
||||
restoreFocus(focusIdx);
|
||||
activateEditDom();
|
||||
restoreEditFocus();
|
||||
});
|
||||
}
|
||||
}, 30);
|
||||
};
|
||||
|
||||
/** 找失焦元素最接近 editRoot.children 的祖先块索引(事件 target 可能是块内嵌套节点) */
|
||||
function rememberFocusIndex(target) {
|
||||
const root = editRoot.value;
|
||||
if (!root || !target) return -1;
|
||||
let el = target;
|
||||
while (el && el.parentElement !== root) el = el.parentElement;
|
||||
return el ? Array.from(root.children).indexOf(el) : -1;
|
||||
}
|
||||
|
||||
/** 重建后恢复焦点到同索引块(无对应块则落到编辑容器) */
|
||||
function restoreFocus(idx) {
|
||||
const root = editRoot.value;
|
||||
if (!root) return;
|
||||
const target = idx >= 0 ? root.children[idx] : null;
|
||||
if (target && typeof target.focus === 'function') {
|
||||
/** 重建后恢复焦点到 main(有选区信息时用浏览器默认;此处保证可继续输入) */
|
||||
function restoreEditFocus() {
|
||||
const root = shadowRoot();
|
||||
const main = root ? root.querySelector('main') : null;
|
||||
if (main && typeof main.focus === 'function') {
|
||||
try {
|
||||
target.focus();
|
||||
main.focus();
|
||||
} catch { /* 焦点恢复失败不影响保存流程 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出编辑:先同步序列化一次(flush 最后一次未保存的编辑——focusout 定时器
|
||||
* 会因 editing=false 被跳过,不在这里 flush 会丢字),再切预览 */
|
||||
* 会因 editing=false 被跳过,不在这里 flush 会丢字),flushSave 立即 PATCH
|
||||
* (2026-09-04 问题4:不等防抖,否则切回预览时显示的是保存前的旧内容),
|
||||
* 再切预览。预览先以当前 html 渲染(即时反馈),保存完成后 fetchHtml
|
||||
* 经 watch 刷新为最新内容。 */
|
||||
const exitEdit = () => {
|
||||
if (editing.value) {
|
||||
const next = serializeMd();
|
||||
if (next && next !== lastOuter) {
|
||||
const nextLh = currentLetterhead();
|
||||
if ((next && next !== lastOuter) || nextLh !== (docgen.letterhead || '')) {
|
||||
lastOuter = next;
|
||||
docgen.md = next;
|
||||
docgen.scheduleSave();
|
||||
if (nextLh !== docgen.letterhead) docgen.letterhead = nextLh;
|
||||
}
|
||||
if (editBlurTimer) {
|
||||
clearTimeout(editBlurTimer);
|
||||
editBlurTimer = null;
|
||||
}
|
||||
docgen.flushSave(); // 立即保存(幂等;无变化时也只是重写一遍同内容)
|
||||
}
|
||||
mode.value = 'preview';
|
||||
mountShadow(); // 回预览:先以当前 html 重建,保存后的新 html 由 watch 刷新
|
||||
};
|
||||
|
||||
// ---------- 工具条(加粗/斜体/下划线/撤销) ----------
|
||||
/** mousedown.prevent 防焦点被抢(选区保留),click 应用命令后焦点还给编辑区 */
|
||||
const execCmd = (cmd) => {
|
||||
try {
|
||||
document.execCommand(cmd, false, null);
|
||||
} catch { /* 旧浏览器不支持时静默 */ }
|
||||
restoreEditFocus();
|
||||
};
|
||||
|
||||
// ---------- 「用 prompt 修改」 ----------
|
||||
@@ -196,22 +288,28 @@ const editPrompt = computed({
|
||||
});
|
||||
const runEdit = () => docgen.runEdit();
|
||||
|
||||
// ---------- 导出 ----------
|
||||
// ---------- 导出(PDF 优先) ----------
|
||||
// 2026-09-04 问题2:无「下载」按钮形态——点击即按最新文档内容重新生成并下载,
|
||||
// 按钮文字保持「导出 PDF/导出 DOCX」不变(导出中显示「导出中…」)。
|
||||
const exporting = (fmt) => !!docgen.exporting[fmt];
|
||||
const exportUrl = (fmt) => docgen.exportUrls[fmt] || null;
|
||||
const runExport = (fmt) => docgen.runExport(fmt);
|
||||
/** 导出前先 flush 未落盘的编辑(问题2:每次导出都必须是「最新文档内容」) */
|
||||
const runExport = async (fmt) => {
|
||||
await docgen.flushSave();
|
||||
await docgen.runExport(fmt);
|
||||
};
|
||||
const exportBtnLabel = (fmt) => {
|
||||
if (exporting(fmt)) return DG.docPaneExporting;
|
||||
if (exportUrl(fmt)) return DG.docPaneExportReady;
|
||||
return fmt === 'pdf' ? DG.docPaneExportPdf : DG.docPaneExportDocx;
|
||||
};
|
||||
|
||||
/** 导出 URL 的手动点击兜底(后端附件下载) */
|
||||
const openExportUrl = (fmt) => {
|
||||
const url = exportUrl(fmt);
|
||||
if (!url) return;
|
||||
window.open(url, '_blank', 'noopener');
|
||||
};
|
||||
/** 模版名展示:库内模版名 > 内置模版中文名(templateKey 映射) */
|
||||
const templateLabel = computed(() => {
|
||||
const name = docgen.activeArtifact?.template_name || '';
|
||||
if (name) return name;
|
||||
const key = docgen.templateKey || '';
|
||||
const builtin = BUILTIN_TEMPLATES[key];
|
||||
return builtin ? `${builtin}(${DG.docPaneTemplateBuiltin})` : '';
|
||||
});
|
||||
|
||||
// Composer 的 edit 分支预填指令(edit-doc → ChatPanel 直接写 docgen.editPrompt 并切 TAB;
|
||||
// 此方法保留给未来经 BenchPanel ref 的桥接)
|
||||
@@ -238,8 +336,8 @@ onBeforeUnmount(() => {
|
||||
<div class="doc-head">
|
||||
<div class="dh-meta">
|
||||
<div class="dh-title">{{ docgen.title || DG.docPaneTitleLabel }}</div>
|
||||
<div v-if="docgen.activeArtifact.template_name" class="dh-tpl">
|
||||
{{ DG.docPaneTemplateLabel }}:{{ docgen.activeArtifact.template_name }}
|
||||
<div v-if="templateLabel" class="dh-tpl">
|
||||
{{ DG.docPaneTemplateLabel }}:{{ templateLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dh-actions">
|
||||
@@ -262,22 +360,20 @@ onBeforeUnmount(() => {
|
||||
<div v-else-if="!docgen.md" class="doc-state">{{ DG.docPaneArtifactMissing }}</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 预览:markdown-it 渲染(已消毒) -->
|
||||
<div
|
||||
v-if="mode === 'preview'"
|
||||
ref="previewEl"
|
||||
class="doc-preview md-text"
|
||||
v-html="previewHtml"
|
||||
></div>
|
||||
<!-- 编辑工具条(仅编辑态;mousedown.prevent 保选区) -->
|
||||
<div v-if="editing" class="edit-toolbar">
|
||||
<button class="tb-btn" type="button" title="加粗" @mousedown.prevent @click="execCmd('bold')"><b>B</b></button>
|
||||
<button class="tb-btn" type="button" title="斜体" @mousedown.prevent @click="execCmd('italic')"><i>I</i></button>
|
||||
<button class="tb-btn" type="button" title="下划线" @mousedown.prevent @click="execCmd('underline')"><u>U</u></button>
|
||||
<button class="tb-btn" type="button" title="撤销" @mousedown.prevent @click="execCmd('undo')">↶</button>
|
||||
</div>
|
||||
|
||||
<!-- 编辑:块级就地 contenteditable(失焦序列化回 md + debounce PATCH)。
|
||||
R2:@focusout(冒泡,子块失焦根 div 可收到;blur 不冒泡收不到) -->
|
||||
<!-- 预览/编辑共用一个 Shadow DOM 宿主(模版 css 隔离) -->
|
||||
<div
|
||||
v-else
|
||||
ref="editRoot"
|
||||
class="doc-edit md-text"
|
||||
ref="hostEl"
|
||||
class="doc-host"
|
||||
:class="{ editing }"
|
||||
@focusout="onEditBlur"
|
||||
v-html="editHtml"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
@@ -304,19 +400,19 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div v-if="docgen.editError" class="doc-save-state error">{{ DG.docPaneEditError }}</div>
|
||||
|
||||
<!-- 导出按钮组 -->
|
||||
<!-- 导出按钮组(PDF 优先;点击即按最新内容重新导出下载,文字不变) -->
|
||||
<div class="export-row">
|
||||
<button
|
||||
class="pane-btn exp"
|
||||
type="button"
|
||||
:disabled="exporting('pdf')"
|
||||
@click="exportUrl('pdf') ? openExportUrl('pdf') : runExport('pdf')"
|
||||
@click="runExport('pdf')"
|
||||
>{{ exportBtnLabel('pdf') }}</button>
|
||||
<button
|
||||
class="pane-btn exp"
|
||||
type="button"
|
||||
:disabled="exporting('docx')"
|
||||
@click="exportUrl('docx') ? openExportUrl('docx') : runExport('docx')"
|
||||
@click="runExport('docx')"
|
||||
>{{ exportBtnLabel('docx') }}</button>
|
||||
</div>
|
||||
<div v-if="docgen.exportError.pdf || docgen.exportError.docx" class="doc-save-state error">
|
||||
@@ -406,48 +502,39 @@ onBeforeUnmount(() => {
|
||||
color: var(--pa-error);
|
||||
border-color: rgba(255, 92, 122, 0.35);
|
||||
}
|
||||
.doc-preview {
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
padding: 12px 14px;
|
||||
background: var(--pa-surface-1);
|
||||
/* Shadow DOM 宿主(模版 css 在内层 shadow,这里只定宿主外观)。
|
||||
2026-09-04 手工测试:预览应如白纸(与导出 PDF 白底一致),背景固定纯白不随主题 */
|
||||
.doc-host {
|
||||
min-height: 160px;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
padding: 16px 20px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.doc-edit {
|
||||
padding: 12px 14px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
.doc-host.editing {
|
||||
border-color: color-mix(in srgb, var(--pa-g-from) 45%, transparent);
|
||||
box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--pa-g-from) 25%, transparent);
|
||||
}
|
||||
/* 编辑态:容器直接子块全部可编辑(v-html 内容不带 scope,必须 :deep()) */
|
||||
.doc-edit :deep(> *) {
|
||||
outline: none;
|
||||
/* 编辑工具条 */
|
||||
.edit-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.tb-btn {
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-xs);
|
||||
padding: 1px 2px;
|
||||
min-height: 1.2em;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.doc-edit :deep(> *:hover) {
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.doc-edit :deep(> *:focus) {
|
||||
background: var(--pa-surface-2);
|
||||
box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--pa-g-from) 55%, transparent);
|
||||
}
|
||||
.doc-edit :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.doc-edit :deep(th),
|
||||
.doc-edit :deep(td) {
|
||||
border: 1px solid var(--pa-hairline);
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
.tb-btn:hover {
|
||||
color: var(--pa-ink);
|
||||
background: var(--pa-surface-3);
|
||||
}
|
||||
.doc-save-state {
|
||||
font-size: 11.5px;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Enter 换行、⌘/Ctrl+Enter 发送(输入法组合键判定不触发)/
|
||||
// 发送按钮 busy 时切换为「停止」(FR-CHAT-011)。
|
||||
//
|
||||
// P3(文档生成):拖拽上传(PDF/DOCX/TXT,≤20MB × ≤5)→ 先调 uploadLibrary
|
||||
// P3(文档生成):拖拽上传(PDF/DOCX/TXT,2026-09-04 起不设大小/数量限制)
|
||||
// 入库主向量库(持久可用)→ chips(可移除,移除仅从本次引用拿掉);
|
||||
// 发送时按 intent.js 路由(与后端 intent.py 同词典同规则,漏判安全):
|
||||
// docgen → chat.sendDocgen(独立分支,不经 ChatPanel);
|
||||
@@ -34,10 +34,9 @@ const draft = ref('');
|
||||
const textarea = ref(null);
|
||||
|
||||
// ---- P3 附件 chips(上传入库主向量库,持久可用;移除仅从本次引用拿掉) ----
|
||||
const MAX_ATTACH_COUNT = 5;
|
||||
const MAX_ATTACH_BYTES = 20 * 1024 * 1024; // 20MB
|
||||
// 2026-09-04 手工测试:上传不设大小/数量限制,仅按扩展名过滤
|
||||
const ACCEPT_EXT = /\.(pdf|docx?|txt)$/i;
|
||||
const attachments = ref([]); // {key, name, size, file, status:'uploading'|'ready'|'error', libraryId|null}
|
||||
const attachments = ref([]); // {key, name, size, progress, file, status:'uploading'|'ready'|'error', libraryId|null}
|
||||
const dropActive = ref(false);
|
||||
const attachNote = ref(''); // 预检拒绝提示(数量/大小/类型,一次性)
|
||||
|
||||
@@ -86,31 +85,29 @@ const formatSize = (bytes) => {
|
||||
|
||||
const isSupported = (f) => ACCEPT_EXT.test(f.name || '');
|
||||
|
||||
/** drop 统一入口:预检(数量/大小/类型)→ 逐文件上传入库 */
|
||||
/** drop 统一入口:类型过滤 → 逐文件上传入库(无大小/数量预检) */
|
||||
async function handleFiles(fileList) {
|
||||
const files = Array.from(fileList || []).filter((f) => f && typeof f.name === 'string');
|
||||
if (!files.length) return;
|
||||
attachNote.value = '';
|
||||
const supported = files.filter(isSupported);
|
||||
if (supported.length !== files.length) attachNote.value = DG.attachUnsupportedType;
|
||||
const withinSize = supported.filter((f) => f.size <= MAX_ATTACH_BYTES);
|
||||
if (withinSize.length !== supported.length) attachNote.value = attachNote.value || DG.attachExceedSize;
|
||||
const room = Math.max(0, MAX_ATTACH_COUNT - attachments.value.length);
|
||||
const accepted = withinSize.slice(0, room);
|
||||
if (accepted.length !== withinSize.length) attachNote.value = attachNote.value || DG.attachExceedCount;
|
||||
for (const file of accepted) {
|
||||
for (const file of supported) {
|
||||
await uploadOne(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOne(file) {
|
||||
const chip = { key: ++chipSeq, name: file.name, size: file.size, file, status: 'uploading', libraryId: null };
|
||||
const chip = { key: ++chipSeq, name: file.name, size: file.size, progress: 0, file, status: 'uploading', libraryId: null };
|
||||
attachments.value.push(chip);
|
||||
try {
|
||||
const data = await uploadLibrary(file, { name: file.name });
|
||||
const data = await uploadLibrary(file, { name: file.name }, {
|
||||
onUploadProgress: (e) => { chip.progress = e.pct; },
|
||||
});
|
||||
const item = Array.isArray(data?.items) ? data.items[0] : data;
|
||||
chip.libraryId = item?.library_id || item?.id || data?.library_id || null;
|
||||
chip.status = 'ready';
|
||||
chip.progress = 1;
|
||||
} catch (err) {
|
||||
chip.status = 'error';
|
||||
chip.error = err?.message || '';
|
||||
@@ -265,7 +262,9 @@ defineExpose({ clear: () => { draft.value = ''; }, focus: () => textarea.value?.
|
||||
<span class="ac-icon">📎</span>
|
||||
<span class="ac-name">{{ a.name }}</span>
|
||||
<span class="ac-size">{{ formatSize(a.size) }}</span>
|
||||
<span v-if="a.status === 'uploading'" class="ac-spin spin-ico"></span>
|
||||
<span v-if="a.status === 'uploading'" class="ac-progress">
|
||||
<i class="ac-progress-bar" :style="{ width: Math.round((a.progress || 0) * 100) + '%' }"></i>
|
||||
</span>
|
||||
<button
|
||||
v-if="a.status === 'error'"
|
||||
class="ac-retry"
|
||||
@@ -468,9 +467,19 @@ textarea:disabled {
|
||||
font-size: 11px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.ac-spin {
|
||||
.ac-progress {
|
||||
flex: none;
|
||||
color: var(--pa-g-to);
|
||||
width: 56px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--pa-surface-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ac-progress-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--pa-gradient);
|
||||
transition: width 0.15s var(--pa-ease);
|
||||
}
|
||||
.ac-retry {
|
||||
flex: none;
|
||||
|
||||
+15
-5
@@ -21,14 +21,23 @@ export const CAP_BADGES = {
|
||||
superpowers: 'Superpowers',
|
||||
};
|
||||
|
||||
/** 内置模版 key → 中文名(DocPane 模版名展示;与 sdk templates/index.json 对应) */
|
||||
export const BUILTIN_TEMPLATES = {
|
||||
official: '通用公文(红头)',
|
||||
report: '报告总结',
|
||||
application: '申报材料',
|
||||
contract: '合同协议',
|
||||
speech: '讲话稿',
|
||||
plan: '方案计划',
|
||||
document: '通用文档',
|
||||
};
|
||||
|
||||
export const DG = {
|
||||
docgenPipelineTitle: '⚙ 文档生成流水线',
|
||||
// —— 作曲器附件区 ——
|
||||
composerDropHint: '拖拽 PDF/DOCX/TXT 到输入框可上传资料(≤20MB × 5)',
|
||||
composerDropOver: '松开以添加资料(PDF / DOCX / TXT,≤20MB)',
|
||||
// —— 作曲器附件区(2026-09-04:上传不设大小/数量限制) ——
|
||||
composerDropHint: '拖拽 PDF/DOCX/TXT 到输入框可上传资料',
|
||||
composerDropOver: '松开以添加资料(PDF / DOCX / TXT)',
|
||||
chipRemove: '移除',
|
||||
attachExceedCount: '最多上传 5 个文件',
|
||||
attachExceedSize: '单个文件不能超过 20MB',
|
||||
attachUnsupportedType: '仅支持 PDF / DOCX / TXT',
|
||||
attachUploadError: '上传失败',
|
||||
attachRetry: '重试',
|
||||
@@ -42,6 +51,7 @@ export const DG = {
|
||||
docPaneEmpty: '尚无文档。在对话中提出写作需求(如「帮我写一份方案」),或上传资料后发送,即可在此预览与编辑。',
|
||||
docPaneTitleLabel: '标题',
|
||||
docPaneTemplateLabel: '模版',
|
||||
docPaneTemplateBuiltin: '内置模版',
|
||||
docPaneEditMode: '编辑',
|
||||
docPanePreviewMode: '预览',
|
||||
docPaneSaveHint: '编辑后自动保存',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// i18n/docgen.js —— 文档生成(P3)全部新文案(dev3 归属,独立文件不碰 labels.js 红线)
|
||||
// 能力徽标、文档 TAB、docgen pipeline 五步、附件 chips、编辑/导出等全部文案集中于此。
|
||||
|
||||
export const DOCGEN_TAB = { key: 'doc', label: '文档' };
|
||||
|
||||
/** docgen pipeline 五步(相位复用 PHASE_COLOR_VAR 键:planning/retrieval/evidence/generation/done) */
|
||||
export const DOCGEN_STEPS = [
|
||||
{ name: '解析资料', desc: '解析上传资料并入库检索', phase: 'planning' },
|
||||
{ name: '检索模版', desc: '在主库检索相近文档模版', phase: 'retrieval' },
|
||||
{ name: '规划大纲', desc: '按模版结构规划章节大纲', phase: 'evidence' },
|
||||
{ name: '逐节生成', desc: '按大纲逐节生成正文', phase: 'generation' },
|
||||
{ name: '导出文档', desc: '合成全文并生成文档产物', phase: 'done' },
|
||||
];
|
||||
|
||||
/** 能力徽标(QA pipeline 注入 + docgen pipeline capabilities 区共用;label 优先取 manifest) */
|
||||
export const CAP_BADGES = {
|
||||
doc_writer: '文档生成',
|
||||
memory: '多轮记忆',
|
||||
web_search: '联网搜索',
|
||||
humanize: '答案润色',
|
||||
superpowers: 'Superpowers',
|
||||
};
|
||||
|
||||
/** 内置模版 key → 中文名(DocPane 模版名展示;与 sdk templates/index.json 对应) */
|
||||
export const BUILTIN_TEMPLATES = {
|
||||
official: '通用公文(红头)',
|
||||
report: '报告总结',
|
||||
application: '申报材料',
|
||||
contract: '合同协议',
|
||||
speech: '讲话稿',
|
||||
plan: '方案计划',
|
||||
document: '通用文档',
|
||||
};
|
||||
|
||||
export const DG = {
|
||||
docgenPipelineTitle: '⚙ 文档生成流水线',
|
||||
// —— 作曲器附件区(2026-09-04:上传不设大小/数量限制) ——
|
||||
composerDropHint: '拖拽 PDF/DOCX/TXT 到输入框可上传资料',
|
||||
composerDropOver: '松开以添加资料(PDF / DOCX / TXT)',
|
||||
chipRemove: '移除',
|
||||
attachUnsupportedType: '仅支持 PDF / DOCX / TXT',
|
||||
attachUploadError: '上传失败',
|
||||
attachRetry: '重试',
|
||||
attachNotePrefix: '(已上传资料:',
|
||||
attachNoteSuffix: ')',
|
||||
// —— DocCard(消息流内文档卡片) ——
|
||||
docCardTemplate: '模版',
|
||||
docCardOpen: '打开预览',
|
||||
docCardEmpty: '文档',
|
||||
// —— DocPane(文档 TAB) ——
|
||||
docPaneEmpty: '尚无文档。在对话中提出写作需求(如「帮我写一份方案」),或上传资料后发送,即可在此预览与编辑。',
|
||||
docPaneTitleLabel: '标题',
|
||||
docPaneTemplateLabel: '模版',
|
||||
docPaneTemplateBuiltin: '内置模版',
|
||||
docPaneEditMode: '编辑',
|
||||
docPanePreviewMode: '预览',
|
||||
docPaneSaveHint: '编辑后自动保存',
|
||||
docPaneSaveError: '保存失败',
|
||||
docPaneEditPromptPlaceholder: '用 prompt 修改全文,如:把第2段改正式一点',
|
||||
docPaneEditRun: '修改',
|
||||
docPaneEditRunning: '修改中…',
|
||||
docPaneEditError: '修改失败',
|
||||
docPaneExportPdf: '导出 PDF',
|
||||
docPaneExportDocx: '导出 DOCX',
|
||||
docPaneExporting: '导出中…',
|
||||
docPaneArtifactLoading: '文档加载中…',
|
||||
docPaneArtifactMissing: '文档内容不可用(可能已被清理)',
|
||||
docPaneContentError: '文档内容加载失败',
|
||||
docPaneReload: '重新加载',
|
||||
};
|
||||
|
||||
export default DG;
|
||||
@@ -557,7 +557,11 @@ export const useChatStore = defineStore('chat', {
|
||||
else steps.forEach((s) => { s.state = 'done'; });
|
||||
}
|
||||
});
|
||||
ctx.events.push({ ...p, at: p.at || new Date().toISOString() });
|
||||
// 必须经 streamCtx 的响应式代理推入:raw ctx.events.push 不触发
|
||||
// 失效,检索 TAB 会直到对话结束(_finishCtx 重赋值)才一次性渲染。
|
||||
// 2026-09-04 手工测试复现,与 :313/:337 的 raw-vs-proxy 陷阱同类。
|
||||
const evProxy = this.streamCtx[sessionId] || ctx;
|
||||
evProxy.events.push({ ...p, at: p.at || new Date().toISOString() });
|
||||
},
|
||||
// 两级体验(方案 3,2026-08-26):evidence 帧 = 并发 search 的归一化结果,
|
||||
// 在 complete 之前把 sources/graph 块先挂上(来源 tab 提前可见)。
|
||||
|
||||
+86
-13
@@ -1,9 +1,20 @@
|
||||
// stores/docgen.js —— 文档 TAB 状态(P3/dev3 归属)
|
||||
// 只承载文档域数据(活动 artifact / md 内容 / 编辑态 / 导出态),
|
||||
// 不 import bench.js 的状态——切 TAB 只经 bench.setActiveTab('doc')(B3 的 setActiveTab 任意 key 可用)。
|
||||
//
|
||||
// 2026-09-04 手工测试第二批(五个问题)重构:
|
||||
// - 拉取竞态防护:fetchContent/fetchHtml 携带归属序号(_contentSeq/_htmlSeq),
|
||||
// 迟到的旧请求结果一律丢弃(问题5:开 B 时 A 的旧响应覆盖 B)。
|
||||
// - open() 切换文档时清空 html/css/letterhead 等旧文档状态(问题5:A 的
|
||||
// 旧 html 被 B 复用或残留导致白底/串扰)。
|
||||
// - 编辑保存即时化:scheduleSave 防抖从 800ms 缩到 350ms,并保留编辑字段
|
||||
// 在焦点离开时的立即 flush(问题4:失焦/切预览内容被还原)。
|
||||
// - 导出纯化:runExport 不再写 exportUrls(无「下载」按钮形态),每次都按
|
||||
// 最新 md 重新 POST export 并自动触发下载(问题2)。
|
||||
import { defineStore } from 'pinia';
|
||||
import {
|
||||
getArtifactContent,
|
||||
getArtifactHtml,
|
||||
saveArtifactContent,
|
||||
editArtifact,
|
||||
exportArtifact,
|
||||
@@ -13,7 +24,11 @@ import {
|
||||
export const useDocgenStore = defineStore('docgen', {
|
||||
state: () => ({
|
||||
activeArtifact: null, // 当前打开的 artifact 快照 {artifact_id, title, template_name, status, ...} | null
|
||||
md: '', // 活动文档的 markdown 原文
|
||||
md: '', // 活动文档的 markdown 原文(唯一落盘正本)
|
||||
html: '', // 模版渲染 HTML(服务端 /html 端点,预览所见即所得)
|
||||
css: '', // 模版 css(注入 scoped <style>)
|
||||
templateKey: '', // 内置模版 key(official/report/...)
|
||||
letterhead: '', // 红头文字(2026-09-04 就地编辑持久化;空=模版默认)
|
||||
loading: false, // 拉取内容中
|
||||
loadError: null, // 拉取失败 {message} | null
|
||||
editing: false, // 就地编辑态(contenteditable 激活)
|
||||
@@ -24,9 +39,9 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
editError: null, // prompt 修改失败 {message} | null
|
||||
exporting: {}, // format -> true(导出请求中)
|
||||
exportError: {}, // format -> {message} | null
|
||||
exportUrls: {}, // format -> download_url(后端可直接下载)
|
||||
exportRequestSeq: 0, // 导出请求信号:Composer export 分支 +1,DocPane watch 后消费
|
||||
templateCandidates: [], // 模版检索候选(生成后由后端返回;当前版本主要展示用)
|
||||
renderNonce: 0, // 渲染信号:html 拉取失败等无字段变化场景强制 DocPane 重渲染(问题5)
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -45,6 +60,9 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
* 打开活动文档(DocCard「打开预览」/ 生成成功自动切换的入口)
|
||||
* BUG-4 修复:同 id 且有本地内容且非强制 → 复用不重复拉取;
|
||||
* 同 id 但无本地内容 → 仍要拉取(旧短路导致「内容不可用」)。
|
||||
* 2026-09-04 问题5:切换文档时同步清空旧文档的 html/css/templateKey/
|
||||
* letterhead/exportError——旧 html 残留会压住新文档渲染
|
||||
* (白底无内容)与导出状态串扰。
|
||||
* @param {{artifact_id?: string, id?: string, title?: string, template_name?: string, status?: string}} artifact
|
||||
* @param {{force?: boolean}} opts
|
||||
*/
|
||||
@@ -60,16 +78,28 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
this.saveError = null;
|
||||
this.editError = null;
|
||||
this.editing = false;
|
||||
// 切换文档或强制刷新:清掉旧文档内容再拉(否则 !this.md 判定会误复用旧内容)
|
||||
if (!sameArtifact || force) this.md = '';
|
||||
// 切换文档或强制刷新:清掉旧文档全部内容态再拉
|
||||
if (!sameArtifact || force) {
|
||||
this.md = '';
|
||||
this.html = '';
|
||||
this.css = '';
|
||||
this.templateKey = '';
|
||||
this.letterhead = '';
|
||||
this.exportError = {};
|
||||
this.exporting = {};
|
||||
}
|
||||
if (!this.md) await this.fetchContent(id);
|
||||
},
|
||||
|
||||
/** 拉取 artifact 的 md 原文(失败不抛异常、不丢 artifact 身份——DocPane 展示错误态 + 重试) */
|
||||
/** 拉取 artifact 的 md 原文 + 模版 HTML(失败不抛异常、不丢 artifact 身份——DocPane 展示错误态 + 重试)
|
||||
* 2026-09-04 问题5:序号防竞态——只有归属 id 仍为当前活动文档时结果才落 state。 */
|
||||
async fetchContent(id) {
|
||||
const seq = (this._contentSeq || 0) + 1;
|
||||
this._contentSeq = seq;
|
||||
this.loading = true;
|
||||
try {
|
||||
const data = await getArtifactContent(id);
|
||||
if (seq !== this._contentSeq) return; // 迟到的旧请求:丢弃
|
||||
// data 可能是 {ok, markdown, title, ...} 或裸字符串(契约未冻结前两者都容错)
|
||||
const mdText = typeof data === 'string' ? data : (data?.markdown ?? data?.content ?? '');
|
||||
this.md = String(mdText ?? '');
|
||||
@@ -79,9 +109,34 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
if (Array.isArray(data.templates)) this.templateCandidates = data.templates;
|
||||
}
|
||||
} catch (err) {
|
||||
if (seq !== this._contentSeq) return;
|
||||
this.loadError = { message: err?.message || '' };
|
||||
} finally {
|
||||
this.loading = false;
|
||||
if (seq === this._contentSeq) this.loading = false;
|
||||
}
|
||||
if (seq === this._contentSeq) await this.fetchHtml(id);
|
||||
},
|
||||
|
||||
/** 拉取模版 HTML(预览与导出同源);失败静默(预览回退客户端 md 渲染)
|
||||
* 2026-09-04 问题5:序号防竞态——迟到的旧 html 响应不再覆盖新文档内容。 */
|
||||
async fetchHtml(id) {
|
||||
const seq = (this._htmlSeq || 0) + 1;
|
||||
this._htmlSeq = seq;
|
||||
try {
|
||||
const data = await getArtifactHtml(id);
|
||||
if (seq !== this._htmlSeq) return;
|
||||
if (data && data.ok) {
|
||||
this.html = String(data.html || '');
|
||||
this.css = String(data.css || '');
|
||||
this.templateKey = String(data.template_key || '');
|
||||
if (typeof data.letterhead === 'string') this.letterhead = data.letterhead;
|
||||
if (data.title && this.activeArtifact) this.activeArtifact.title = data.title;
|
||||
}
|
||||
} catch {
|
||||
if (seq !== this._htmlSeq) return;
|
||||
this.html = '';
|
||||
this.css = '';
|
||||
this.renderNonce++; // 值可能不变(此前已空):bump 序号强制预览走 md 回退渲染
|
||||
}
|
||||
},
|
||||
|
||||
@@ -109,7 +164,8 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
},
|
||||
|
||||
/**
|
||||
* 就地编辑保存:debounce 800ms 后 PATCH(连续输入合并为一次请求)。
|
||||
* 就地编辑保存:debounce 350ms 后 PATCH(2026-09-04 从 800ms 缩短——
|
||||
* 问题4:长防抖 + 异步重渲染让「内容被还原」的窗口过大)。
|
||||
* 保存失败仅标记,不打断编辑(下次输入重试)。
|
||||
*/
|
||||
scheduleSave() {
|
||||
@@ -118,7 +174,16 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
this._saveTimer = setTimeout(() => {
|
||||
this._saveTimer = null;
|
||||
this.save();
|
||||
}, 800);
|
||||
}, 350);
|
||||
},
|
||||
|
||||
/** 立即 flush 待保存的编辑(问题4:失焦/切预览前调用,不等防抖) */
|
||||
flushSave() {
|
||||
if (this._saveTimer) {
|
||||
clearTimeout(this._saveTimer);
|
||||
this._saveTimer = null;
|
||||
}
|
||||
if (this.md && this.activeArtifact) this.save();
|
||||
},
|
||||
|
||||
async save() {
|
||||
@@ -128,12 +193,14 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
this.saving = true;
|
||||
this.saveError = null;
|
||||
try {
|
||||
await saveArtifactContent(id, this.md);
|
||||
await saveArtifactContent(id, this.md, this.letterhead || '');
|
||||
} catch (err) {
|
||||
this.saveError = { message: err?.message || '' };
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
// 保存成功后重取模版 HTML(服务端按新 md 现渲染 → 预览回显)
|
||||
if (!this.saveError) await this.fetchHtml(id);
|
||||
},
|
||||
|
||||
/** 「用 prompt 修改」:后端按指令返回完整新 md,覆盖当前内容 */
|
||||
@@ -154,9 +221,13 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
} finally {
|
||||
this.editRunning = false;
|
||||
}
|
||||
// prompt 修改后重取模版 HTML(预览回显新内容)
|
||||
if (!this.editError) await this.fetchHtml(id);
|
||||
},
|
||||
|
||||
/** 导出(PDF/DOCX):后端生成产物并返回 download_url,前端触发下载 */
|
||||
/** 导出(PDF/DOCX):后端生成产物并返回 download_url,前端触发下载
|
||||
* 2026-09-04 问题2:不再写 exportUrls(无「下载」按钮形态),
|
||||
* 每次点击都按最新 md 重新 POST export 并直接触发浏览器下载。 */
|
||||
async runExport(format) {
|
||||
if (!this.activeArtifact || this.exporting[format]) return;
|
||||
const id = this.activeArtifact.artifact_id || this.activeArtifact.id;
|
||||
@@ -167,7 +238,6 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
const data = await exportArtifact(id, format);
|
||||
const url = data?.download_url || data?.url || null;
|
||||
if (url) {
|
||||
this.exportUrls[format] = url;
|
||||
// 直接触发下载(后端附件 Content-Disposition)
|
||||
try {
|
||||
const a = document.createElement('a');
|
||||
@@ -176,7 +246,7 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch { /* 下载触发失败:链接仍保留在导出按钮上,可手动点击 */ }
|
||||
} catch { /* 下载触发失败:记录错误供用户重试 */ }
|
||||
} else if (data && data.ok === false) {
|
||||
this.exportError[format] = { message: data?.error?.message || data?.message || '' };
|
||||
}
|
||||
@@ -205,13 +275,16 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
close() {
|
||||
this.activeArtifact = null;
|
||||
this.md = '';
|
||||
this.html = '';
|
||||
this.css = '';
|
||||
this.templateKey = '';
|
||||
this.letterhead = '';
|
||||
this.loading = false;
|
||||
this.loadError = null;
|
||||
this.editing = false;
|
||||
this.saveError = null;
|
||||
this.editError = null;
|
||||
this.exportError = {};
|
||||
this.exportUrls = {};
|
||||
},
|
||||
|
||||
/** 会话切换时清空文档域状态(会话级产物不跨会话) */
|
||||
|
||||
+3
-8
@@ -9,7 +9,7 @@
|
||||
"style_hints": {...}, # docx 样式线索(导出对齐用)
|
||||
}
|
||||
|
||||
PDF 上限 300 页(docs/team/brief_dev1_p1.md);文本文件 utf-8 → gb18030 回退。
|
||||
文本文件 utf-8 → gb18030 回退;上传不设大小/页数/字符上限(2026-09-04)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,9 +23,7 @@ from docx.document import Document as _DocxDoc
|
||||
from docx.table import Table as _DocxTable
|
||||
from docx.text.paragraph import Paragraph as _DocxParagraph
|
||||
|
||||
# PDF 页数上限
|
||||
MAX_PDF_PAGES = 300
|
||||
|
||||
# 2026-09-04 手工测试:PDF 不再设页数上限(上传不设限)。
|
||||
# 大纲嵌入切块参数(与 settings 对齐,避免循环 import)
|
||||
_OUTLINE_EMBED_MAX_CHARS = 7500
|
||||
_OUTLINE_EMBED_MAX_CHUNKS = 3
|
||||
@@ -154,15 +152,12 @@ def extract_docx(file_bytes: bytes) -> dict[str, Any]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_pdf(file_bytes: bytes) -> dict[str, Any]:
|
||||
"""解析 PDF(pymupdf,≤300 页):书签为大纲,文本块为段落。"""
|
||||
"""解析 PDF(pymupdf):书签为大纲,文本块为段落。"""
|
||||
try:
|
||||
doc = pymupdf.open(stream=file_bytes, filetype="pdf")
|
||||
except Exception as exc:
|
||||
raise ParsingError(f"pdf 解析失败:{exc}") from exc
|
||||
try:
|
||||
if doc.page_count > MAX_PDF_PAGES:
|
||||
raise ParsingError(f"PDF 超过 {MAX_PDF_PAGES} 页限制(当前 {doc.page_count} 页)")
|
||||
|
||||
outline: list[dict[str, Any]] = []
|
||||
try:
|
||||
for level, text, _page in doc.get_toc():
|
||||
|
||||
+4
-4
@@ -14,12 +14,12 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---- 限额常量(与 BFF 侧 docgen 对齐,docs/team/brief_dev1_p1.md)----
|
||||
MAX_ATTACH_COUNT = 5 # 单次上传附件数上限
|
||||
MAX_ATTACH_BYTES = 20 * 1024 * 1024 # 单附件 20MB
|
||||
MAX_ATTACH_CHARS = 60000 # 单附件解析后字符上限
|
||||
# ---- 生成参数(2026-09-04 手工测试:上传不再设大小/数量/字符门槛)----
|
||||
# EMBED_BODY_CAP 仅是嵌入分块预算(向量化截断),不是上传限制;
|
||||
# 原文件全文始终加密入库,不受影响。
|
||||
MAX_SECTIONS = 12 # 模版大纲裁剪上限(节数)
|
||||
MAX_OUTLINE_CHUNKS = 3 # 大纲嵌入切块上限
|
||||
EMBED_BODY_CAP = 200_000 # 正文嵌入分块预算(字符)
|
||||
|
||||
# ---- 检索参数 ----
|
||||
EMBED_DIM = 1024 # bge-m3:567m 输出维度
|
||||
|
||||
@@ -6,7 +6,6 @@ import io
|
||||
import pytest
|
||||
|
||||
from vstore.parsing import (
|
||||
MAX_PDF_PAGES,
|
||||
ParsingError,
|
||||
extract,
|
||||
extract_docx,
|
||||
@@ -80,10 +79,10 @@ def test_extract_pdf_outline_and_text():
|
||||
assert parsed["title"] == "第一章 概述"
|
||||
|
||||
|
||||
def test_extract_pdf_page_limit(monkeypatch):
|
||||
monkeypatch.setattr("vstore.parsing.MAX_PDF_PAGES", 2)
|
||||
with pytest.raises(ParsingError):
|
||||
extract_pdf(_make_pdf_bytes(pages=3))
|
||||
def test_extract_pdf_no_page_limit():
|
||||
# 2026-09-04 手工测试:PDF 不再设页数上限,3 页应正常解析
|
||||
parsed = extract_pdf(_make_pdf_bytes(pages=3))
|
||||
assert len(parsed["paragraphs"]) >= 1
|
||||
|
||||
|
||||
def test_extract_pdf_bad_bytes():
|
||||
|
||||
@@ -10,13 +10,13 @@ from vstore.settings import VstorePaths, get_fernet_key
|
||||
|
||||
|
||||
def test_limits_match_brief():
|
||||
"""限额常量与任务书对齐。"""
|
||||
assert settings.MAX_ATTACH_COUNT == 5
|
||||
assert settings.MAX_ATTACH_BYTES == 20 * 1024 * 1024
|
||||
assert settings.MAX_ATTACH_CHARS == 60000
|
||||
"""2026-09-04:上传限制常量已移除(手工测试要求),保留结构常量。"""
|
||||
assert settings.MAX_SECTIONS == 12
|
||||
assert settings.MAX_OUTLINE_CHUNKS == 3
|
||||
assert settings.EMBED_BODY_CAP == 200_000
|
||||
assert settings.EMBED_DIM == 1024
|
||||
for gone in ("MAX_ATTACH_COUNT", "MAX_ATTACH_BYTES", "MAX_ATTACH_CHARS"):
|
||||
assert not hasattr(settings, gone), f"{gone} 应已移除"
|
||||
|
||||
|
||||
def test_paths_default_derivation(tmp_path):
|
||||
|
||||
@@ -55,9 +55,13 @@ def test_add_empty_rejected(vault):
|
||||
vault.add(b"", "a.txt", {})
|
||||
|
||||
|
||||
def test_add_oversize_rejected(vault):
|
||||
with pytest.raises(VaultError):
|
||||
vault.add(b"x" * (21 * 1024 * 1024), "a.txt", {})
|
||||
def test_add_oversize_accepted(vault):
|
||||
# 2026-09-04 手工测试:上传不再设大小门槛,21MB 应正常入库
|
||||
big = b"x" * (21 * 1024 * 1024)
|
||||
item = vault.add(big, "a.txt", {})
|
||||
assert item["document_id"]
|
||||
doc = vault.get_decrypted(item["document_id"])
|
||||
assert doc["meta"]["size"] == len(big)
|
||||
|
||||
|
||||
def test_add_bad_docx_rejected(vault):
|
||||
|
||||
+3
-9
@@ -26,9 +26,8 @@ from .embed import embed_batch
|
||||
from .parsing import ParsingError, extract, outline_to_embedding_chunks
|
||||
from .retrieval import RetrievalIndex, RetrievalError
|
||||
from .settings import (
|
||||
EMBED_BODY_CAP,
|
||||
EMBED_DIM,
|
||||
MAX_ATTACH_BYTES,
|
||||
MAX_ATTACH_CHARS,
|
||||
MAX_OUTLINE_CHUNKS,
|
||||
get_paths,
|
||||
)
|
||||
@@ -133,8 +132,6 @@ class Vault:
|
||||
|
||||
if not file_bytes:
|
||||
raise VaultError("上传文件为空")
|
||||
if len(file_bytes) > MAX_ATTACH_BYTES:
|
||||
raise VaultError(f"文件超过 {MAX_ATTACH_BYTES // (1024 * 1024)}MB 限制")
|
||||
try:
|
||||
extracted = extract(file_bytes, filename)
|
||||
except ParsingError as exc:
|
||||
@@ -142,9 +139,6 @@ class Vault:
|
||||
|
||||
paragraphs = extracted.get("paragraphs") or []
|
||||
outline = extracted.get("outline") or []
|
||||
text_len = sum(len(p) for p in paragraphs)
|
||||
if text_len > MAX_ATTACH_CHARS:
|
||||
raise VaultError(f"文件内容超过 {MAX_ATTACH_CHARS} 字符限制(当前 {text_len})")
|
||||
|
||||
doc_meta = {
|
||||
"id": doc_id,
|
||||
@@ -165,7 +159,7 @@ class Vault:
|
||||
else:
|
||||
body = "\n".join(paragraphs[:20])
|
||||
if body:
|
||||
chunks.append(body[:MAX_ATTACH_CHARS])
|
||||
chunks.append(body[:EMBED_BODY_CAP])
|
||||
chunks = [c for c in chunks if c and c.strip()]
|
||||
vectors = _EMBED_FN(chunks)
|
||||
|
||||
@@ -295,7 +289,7 @@ class Vault:
|
||||
else:
|
||||
body = "\n".join((extracted.get("paragraphs") or [])[:20])
|
||||
if body:
|
||||
chunks.append(body[:MAX_ATTACH_CHARS])
|
||||
chunks.append(body[:EMBED_BODY_CAP])
|
||||
chunks = [c for c in chunks if c and c.strip()]
|
||||
vectors = _EMBED_FN(chunks)
|
||||
if not vectors:
|
||||
|
||||
Reference in New Issue
Block a user