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:
jerry
2026-09-05 15:49:54 +08:00
co-authored by Claude Fable 5
parent 8467dec3a8
commit 4749f77544
43 changed files with 2450 additions and 283 deletions
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+1 -1
View File
@@ -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):
+13
View File
@@ -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:
+92 -2
View File
@@ -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
+148 -12
View File
@@ -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 ""
+3 -5
View File
@@ -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>
+267
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
)
+141
View File
@@ -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)
+81
View File
@@ -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
+162
View File
@@ -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 == ""
+97 -12
View File
@@ -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")
+134
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+25 -16
View File
@@ -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
View File
@@ -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: '编辑后自动保存',
+72
View File
@@ -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;
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 输出维度
+4 -5
View File
@@ -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():
+4 -4
View File
@@ -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):
+7 -3
View File
@@ -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
View File
@@ -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: