Files
PlantAI-rag-new/sdk/app/routers/docgen.py
T
admin 91f6a1c242 feat: 新增论文撰写功能 paper-writer(独立 MCP 服务 + BFF 大脑 + 前端三卡)
- paper_writer_service(8097):FastAPI + MCP streamable-http 单端点,9 工具
  (propose_paper_outline/write_paper_section/get_paper_progress/search_refs/
  revise_paper_section/revise_paper/get_paper/complete_paper/cancel_paper),
  SQLite 三表(papers/paper_sections/citations,WAL),GB/T 7714 引用协议
- BFF:services/paper/{mcp_client,agent}.py(裸 JSON-RPC 客户端 + 规则状态机)、
  routers/paper.py、schemas.py 4 个 Paper*Block + ArtifactBlock 加 paper_id/citation_count、
  config.py 开关、main.py 条件注册、docgen.py 抽 insert_artifact 公共 helper
- 前端:intent.js paperIntent 纯函数、Composer docgen 分支 sub-route、sendPaper/轮询、
  PaperBriefCard/PaperOutlineCard/PaperProgressCard、DocPane cite-chip
- deploy/paper_restart.sh + toggle_paper.sh;compile_sdk.sh 白名单追加 paper 文件
- tests/paper 26 条(工具逻辑 + MCP 协议 + BFF 状态机 + 块 schema + 前端 intent 单测)
2026-09-30 22:29:22 +08:00

1218 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""routers/docgen.py —— 文档生成 BFF 端点(P2:BFF 集成/API)。
端点(API.md §12):
- POST /api/docgen/library 上传入库(multipart,用户拖拽即走此路)
→ 透传 vstore /ingest,BFF 落 doc_library
- POST /api/docgen/generate {description, session_id?, library_ids[]?,
template_id?} → 同步生成(600s 兜底)
- GET /api/docgen/artifacts/{id}/content md 原文
- PATCH …/content 保存编辑 {markdown}
- POST /api/docgen/artifacts/{id}/edit {instruction} → {ok, markdown}
- POST /api/docgen/artifacts/{id}/export {format:"pdf"|"docx"} → {ok, download_url}
- GET /api/docgen/artifacts/{id}/download?format= FileResponse 附件(RFC 5987 文件名)
- GET /api/docgen/library 主库列表(q/category/as_template 过滤,落库过滤)
- GET /api/docgen/library/{id} 单条
- PATCH /api/docgen/library/{id} 改 meta / as_template 开关
- DELETE /api/docgen/library/{id} 删除(BFF 行 + vstore 透传删除,容错)
- POST /api/docgen/library/rebuild-index 透传 vstore(原子切换)
设计约束(跨模块契约,与 dev1 对齐):
- 对 vstore 的全部访问经 services/docgen/client.py(本文件不直接 httpx 调 vstore);
dev1 未交付时自动回落到内置最小接口桩(同签名),生成/编辑返回 503 UPSTREAM_DOWN,
并在 docs/team/report_dev2.md 标记「依赖 dev1 交付后需 TL 联动联测」。
- 意图护栏(TL R1 2026-09-03 修正,按端点类型传参):
- generate:route(desc, False, False) == "docgen" 才放行(写文档意图)
- edit:route(instruction, False, True) in ("docgen", "edit") 才放行
(edit 端点被调用本身即存在活动文档)
- export:无护栏(format 由 pydantic Literal 白名单校验)
非放行 → 422 INTENT_REJECTED 信封(提示走 QA);路由异常放行(漏判安全不阻断)。
- vstore 失败兜底:
- 入库失败 → VALIDATION 信封(前端 chips 标红可重试),不落库
- 检索失败 → 空模版集合(生成跳过模版,不阻断)
- 资料拉取失败 → 该资料缺失占位提示(不阻断生成)
- 配额与 TTL(TL 裁决 2026-09-03,V1 由 BFF 实现):
- _check_quota:artifacts/ 体积超 docgen_quota_gb → 507 QUOTA_EXCEEDED,
拒绝 generate / export;generate 成功后顺带复查(仅告警)
- _prune_expired_artifacts:created_at 超 docgen_artifact_ttl_days →
删 md/docx/pdf 文件 + 行 status 置 expired;generate 开始前惰性执行
- 错误信封沿用 app.upstream.envelope;中文提示。
- 用户消息/会话落库由前端走现有 sessions 机制,本文件不碰 sessions/messages。
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from ..config import get_settings
from ..db import Database, dumps_json, get_db, loads_json
from ..schemas import (
ArtifactContentPatch,
DocgenEditRequest,
DocgenExportRequest,
DocgenGenerateRequest,
LibraryItemOut,
LibraryPatch,
)
from ..upstream import envelope
# ---------------------------------------------------------------------------
# services/docgen/* —— dev1 已交付(2026-09-03 联测对齐后删除接口桩,路由主体零改动)
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_html_document, # async 包装(html2docx 同步版 → bytes),缺陷 #47a
docx_from_markdown,
pdf_from_html_document,
)
from ..services.docgen.editor import apply_edit
# is_docgen_intent:路由模块导出(测试 monkeypatch 锚点;护栏用同步 route 判型)
from ..services.docgen.intent import is_docgen_intent, route
from ..services.docgen.pipeline import generate_document, name_for_prompt
from ..services.docgen.templating import get_template_asset, list_templates, render_html
from ..services.docgen.templating_bioflow import (
BIOFLOW_TEMPLATE_NAME,
render_bioflow_report,
) # P3 生信报告增量(A3 交付,TL 合入 2026-09-06)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/docgen", tags=["docgen"])
# 2026-09-04 手工测试:上传不再设大小门槛(原 100MB 检查移除)
MAX_DESCRIPTION_CHARS = 2000 # 生成描述上限
# 缺陷 #47a(2026-09-10):DOCX 导出走 HTML 同源转换(默认开);置 0 回退
# md2docx 旧路径(记 warning 日志)
DOCX_VIA_HTML = os.environ.get("DOCGEN_DOCX_VIA_HTML", "1") != "0"
def _envelope(code: str, message: str | None = None, degraded: bool | None = None, status: int | None = None) -> JSONResponse:
if degraded is None:
degraded = code == "UPSTREAM_DOWN"
body, http = envelope(code, message=message, degraded=degraded, status=status)
return JSONResponse(status_code=http, content=body)
def _validation(message: str) -> JSONResponse:
return _envelope("VALIDATION", message=message, status=422)
def _not_found(message: str) -> JSONResponse:
return _envelope("NOT_FOUND", message=message, status=404)
def _db_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _check_description(description: str) -> JSONResponse | None:
if not description.strip():
return _validation("请描述要生成的文档内容")
if len(description) > MAX_DESCRIPTION_CHARS:
return _validation(f"描述过长(超过 {MAX_DESCRIPTION_CHARS} 字)")
return None
# ---------------------------------------------------------------------------
# doc_library 行 ↔ 输出模型
# ---------------------------------------------------------------------------
def _row_to_library(row: Any) -> LibraryItemOut:
return LibraryItemOut(
id=row["id"],
name=row["name"],
category=row["category"],
tags=row["tags"],
description=row["description"],
as_template=bool(row["as_template"]),
active=bool(row["active"]),
created_at=row["created_at"],
)
async def _get_library_row(db: Database, library_id: str) -> dict[str, Any] | None:
row = await db.fetchone("SELECT * FROM doc_library WHERE id = ?", (library_id,))
return dict(row) if row is not None else None
async def _get_artifact_row(db: Database, artifact_id: str) -> dict[str, Any] | None:
row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = ?", (artifact_id,))
return dict(row) if row is not None else None
# ---------------------------------------------------------------------------
# 制品文件路径(配置驱动;路径穿越防护)
# ---------------------------------------------------------------------------
def _artifact_dir(settings: Any) -> Path:
p = Path(settings.docgen_root)
return p if p.is_absolute() else Path.cwd() / p
def _safe_rel(rel: str) -> str | None:
"""返回安全的相对路径(禁止穿越 docgen_root),非法返回 None。"""
p = Path(rel)
if p.is_absolute() or ".." in p.parts:
return None
return str(p)
def _read_artifact_file(settings: Any, rel: str) -> bytes | None:
rel_ok = _safe_rel(rel)
if not rel_ok:
return None
path = _artifact_dir(settings) / rel_ok
try:
return path.read_bytes()
except OSError:
return None
async def insert_artifact(
db: Database,
*,
session_id: str,
title: str,
markdown: str,
md_rel: str | None = None,
template_name: str = "",
template_key: str = "",
template_css: str = "",
html: str = "",
letterhead: str = "",
file_name: str = "",
) -> str:
"""写制品 md(+可选 html)落盘 + INSERT doc_artifacts,返回 artifact_id。
docgen 与论文撰写(paper)共用:paper 仅传 markdown/title/template_name。
"""
artifact_id = str(uuid.uuid4())
artifact_dir = _artifact_dir(get_settings())
if not md_rel:
md_rel = f"artifacts/{artifact_id}.md"
if markdown:
safe_rel = _safe_rel(md_rel)
if safe_rel:
path = artifact_dir / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc)
html_rel = ""
if html:
html_rel = f"artifacts/{artifact_id}.html"
safe_rel = _safe_rel(html_rel)
if safe_rel:
path = artifact_dir / 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 = ""
await db.execute(
"INSERT INTO doc_artifacts (id, session_id, status, title, template_name, "
"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, session_id, title,
template_name, template_key, template_css, md_rel,
html_rel or None, letterhead, file_name, _db_now()),
)
return artifact_id
# ---------------------------------------------------------------------------
# 制品目录配额 / TTL 惰性清理(TL 裁决 2026-09-03:V1 由 BFF 实现)
# ---------------------------------------------------------------------------
def _dir_size(path: Path) -> int:
"""递归体积(字节);跳过符号链接,不抛异常。"""
total = 0
try:
for p in path.rglob("*"):
try:
if p.is_file() and not p.is_symlink():
total += p.stat().st_size
except OSError:
continue
except OSError:
return 0
return total
async def _check_quota(db: Database, settings: Any) -> JSONResponse | None:
"""制品目录配额:artifacts/ 体积超 docgen_quota_gb → 507 QUOTA_EXCEEDED。
仅拒绝 generate / export(新产出路径);放行读类端点。
"""
limit_bytes = int(settings.docgen_quota_gb * 1024 * 1024 * 1024)
used = _dir_size(_artifact_dir(settings) / "artifacts")
if used > limit_bytes:
return _envelope(
"QUOTA_EXCEEDED",
message=(
f"制品存储已满(已用 {used / 1024 / 1024 / 1024:.1f}GB,"
f"配额 {settings.docgen_quota_gb:.0f}GB),请清理旧文档后重试"
),
degraded=True,
status=507,
)
return None
async def _prune_expired_artifacts(db: Database, settings: Any) -> int:
"""TTL 惰性清理:created_at 超 docgen_artifact_ttl_days 的 artifact。
删除 md/docx/pdf 文件(忽略文件缺失)+ DB 行 status 置 expired。
在 generate 开始前调用;返回置过期行数。
"""
from datetime import timedelta
cutoff = (datetime.now(timezone.utc) - timedelta(days=settings.docgen_artifact_ttl_days)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
rows = await db.fetchall(
"SELECT * FROM doc_artifacts WHERE created_at < ? AND status != 'expired'", (cutoff,)
)
for row in rows:
for col in ("md_path", "docx_path", "pdf_path", "html_path"):
rel = row[col]
if not rel:
continue
safe_rel = _safe_rel(str(rel))
if not safe_rel:
continue
try:
(_artifact_dir(settings) / safe_rel).unlink(missing_ok=True)
except OSError as exc:
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, html_path = NULL WHERE id = ?",
(row["id"],),
)
return len(rows)
# ---------------------------------------------------------------------------
# 上传入库(multipart)
# ---------------------------------------------------------------------------
@router.post("/library", status_code=201)
async def upload_library_item(
file: UploadFile = File(...),
name: str = Form(""),
category: str = Form(""),
tags: str = Form(""),
description: str = Form(""),
as_template: bool = Form(True),
db: Database = Depends(get_db),
):
"""用户拖拽上传 → 透传 vstore /ingest → BFF 落 doc_library。
vstore ingest 返回 {document_id, enc_path, embed_sha, size}(按契约);
enc_path/embed_sha 直接回填落库。入库失败不落库,返回 VALIDATION 信封
(前端 chips 标红可重试)。
"""
filename = Path(file.filename or "document").name
content = await file.read()
await file.close()
if not content:
return _validation("上传文件为空")
doc_name = (name or "").strip() or filename
try:
result = await ingest_bytes(
filename,
content,
name=doc_name,
category=(category or "").strip(),
tags=(tags or "").strip(),
description=(description or "").strip(),
as_template=bool(as_template),
)
except DocgenUnavailable as exc:
logger.warning("docgen: vstore ingest 不可用:%s", exc)
return _envelope("UPSTREAM_DOWN", message="向量库不可用,资料未能入库,请稍后重试")
except Exception as exc:
logger.exception("docgen: vstore ingest 失败")
return _validation(f"资料入库失败,请检查文件格式后重试({exc})")
if not isinstance(result, dict) or not result.get("document_id"):
return _validation("资料入库失败(向量库返回异常),请重试")
library_id = str(result["document_id"])
created = _db_now()
row = {
"id": library_id,
"name": doc_name,
"category": str(result.get("category") or (category or "").strip()),
"tags": str(result.get("tags") or (tags or "").strip()),
"description": str(result.get("description") or (description or "").strip()),
"as_template": bool(result.get("as_template", bool(as_template))),
"enc_path": result.get("enc_path"),
"embed_sha": result.get("embed_sha"),
"active": 1,
"created_at": created,
}
await db.execute(
"INSERT INTO doc_library (id, name, category, tags, description, as_template, "
"enc_path, embed_sha, active, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
library_id, row["name"], row["category"], row["tags"], row["description"],
int(row["as_template"]), row["enc_path"], row["embed_sha"],
int(row["active"]), created,
),
)
out = _row_to_library(row)
return {
"ok": True,
"items": [
{
"library_id": out.id,
"name": out.name,
"size": result.get("size") or len(content),
"as_template": out.as_template,
}
],
}
# ---------------------------------------------------------------------------
# 意图护栏(TL R1 2026-09-03:按端点类型传参;export 无护栏)
# ---------------------------------------------------------------------------
async def _intent_guard(text: str, *, has_active_artifact: bool) -> JSONResponse | None:
"""意图护栏(2026-09-04 起仅 generate 调用——edit 已无护栏,见端点注释)。
- generate:route(...) == "docgen" 才放行
路由异常 → 放行(漏判安全,护栏不阻断)。
"""
try:
allowed = ("docgen", "edit") if has_active_artifact else ("docgen",)
verdict = route(text, has_attachments=False, has_active_artifact=has_active_artifact)
if verdict not in allowed:
return _envelope(
"INTENT_REJECTED",
message="这条请求更适合知识问答:请切换到问答模式处理",
status=422,
)
except Exception as exc:
logger.warning("docgen: 意图校验异常(放行): %s", exc)
return None
# ---------------------------------------------------------------------------
# 生成
# ---------------------------------------------------------------------------
def _pick_template(templates: list[dict[str, Any]], template_id: str | None) -> dict[str, Any] | None:
"""按 template_id 选模版(r3-42-01,2026-09-10)。
- template_id 是内置 key(official/report/application/contract/speech/
plan/document)→ 直接解析为内置模版(与 templating.py 注册表一致),
不依赖 vstore 检索命中——前端选「通用公文(红头)」传 official 时,
即使库内无该条目也必中 official,不再回落自动推荐(旧缺陷:产物
template_key 与所选不符,实测「方案」+official → plan);
- 非内置 key → vstore 检索结果里按 id 命中;
- 未指定 template_id → vstore 推荐第一条;
- 指定了但哪都未命中 → None(走自动推荐,语义不变)。
"""
if template_id:
tid = str(template_id).strip()
for entry in list_templates():
if entry.get("key") == tid and get_template_asset(tid) is not None:
# 内置模版条目形态与 pipeline 期望一致:id 放 key,
# title 放 name(pipeline 读 name 作 template_name 回退)
return {
"id": tid,
"name": entry.get("title") or tid,
"description": entry.get("description") or "",
"source": "builtin",
}
for t in templates:
if str(t.get("id")) == template_id:
return t
return None
return templates[0] if templates else None
# ---------------------------------------------------------------------------
# 异步生成任务(缺陷 #52,2026-09-10):
# POST /api/docgen/generate → 202 {ok,task_id,status:"queued"}(快路径:
# 校验链/配额/模板检索/资料快照不变);生成主体在后台任务跑,
# GET /api/docgen/tasks/{task_id} 与 GET /api/docgen/tasks?session_id= 轮询
# (契约见 docs/acceptance-r3.md 附录 B)
# ---------------------------------------------------------------------------
_DOCGEN_MAX_CONCURRENT = int(os.environ.get("DOCGEN_MAX_CONCURRENT_TASKS", "2"))
_DOCGEN_SEMAPHORE = asyncio.Semaphore(_DOCGEN_MAX_CONCURRENT)
def _spawn_task(coro: Any) -> Any:
"""asyncio.create_task 的模块级包装:测试可 monkeypatch(避免改全局 asyncio)。"""
return asyncio.create_task(coro)
_PROGRESS_WRITE_INTERVAL_S = 3.0
_LLM_ENV_KEYS = ("DOCGEN_LLM_BASE_URL", "DOCGEN_LLM_API_KEY", "DOCGEN_LLM_MODEL")
def _missing_llm_keys() -> list[str]:
"""DOCGEN_LLM_* 环境变量缺失清单(任务 error 里写明缺哪个键)。"""
return [k for k in _LLM_ENV_KEYS if not (os.environ.get(k) or "").strip()]
def _task_artifact_payload(row: dict[str, Any] | None) -> dict[str, Any] | None:
"""doc_artifacts 行 → 附录 B 的 artifact 对象。"""
if not row:
return None
return {
"id": row.get("id"),
"title": row.get("title") or "",
"template_name": row.get("template_name") or "",
"template_key": row.get("template_key") or "",
"has_html": bool(row.get("html_path")),
"letterhead": row.get("letterhead") or "",
"file_name": row.get("file_name") or "",
}
async def _get_task(db: Database, task_id: str) -> dict[str, Any] | None:
"""docgen_tasks 行 → 附录 B 的 task 对象(done 时补 artifact 详情)。"""
row = await db.fetchone("SELECT * FROM docgen_tasks WHERE id = ?", (task_id,))
if row is None:
return None
artifact = None
aid = row["artifact_id"]
if row["status"] == "done" and aid:
arow = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = ?", (aid,))
artifact = _task_artifact_payload(dict(arow) if arow is not None else None)
progress = loads_json(row["progress"], default=[])
if not isinstance(progress, list):
progress = []
return {
"id": row["id"],
"session_id": row["session_id"] or "",
"status": row["status"] or "queued",
"progress": progress,
"artifact": artifact,
"error": row["error"],
}
async def _run_docgen_task(
task_id: str,
*,
db: Database,
description: str,
session_id: str,
title: str,
template: dict[str, Any] | None,
template_style: dict[str, Any] | None,
docs: list[dict[str, Any]],
web_search: bool,
) -> None:
"""后台生成主体(原 generate_doc 的 pipeline 调用/落盘/artifact 落库/配额复查)。
- 进度列更新限频(节级 + 3s 一次):aiosqlite 单连接写锁,避免抢锁
- done 回填 artifact_id;error 写中文可读文案(DOCGEN_LLM_* 不齐时写明缺哪个键)
- 信号量在快路径已 acquire,此处 finally 释放
"""
last_write = 0.0
last_progress: list[dict[str, Any]] = []
try:
await db.execute(
"UPDATE docgen_tasks SET status = 'running', updated_at = ? WHERE id = ?",
(_db_now(), task_id),
)
missing = _missing_llm_keys()
if missing:
raise RuntimeError(
"文档生成配置不完整:缺少环境变量 " + "、".join(missing)
+ "(请在 prod.env 配置后重启 BFF)"
)
async def on_progress(progress: list[dict[str, Any]]) -> None:
nonlocal last_write, last_progress
if not progress:
return
last_progress = [dict(p) for p in progress]
now = time.monotonic()
if now - last_write >= _PROGRESS_WRITE_INTERVAL_S:
last_write = now
await db.execute(
"UPDATE docgen_tasks SET progress = ?, updated_at = ? WHERE id = ?",
(dumps_json(progress), _db_now(), task_id),
)
result = await generate_document(
description,
documents=docs,
template=template,
template_style=template_style,
web_search=web_search,
on_progress=on_progress,
)
# ---- 落盘 + artifact 落库(抽公共 insert_artifact,论文撰写共用)----
artifact_dir = _artifact_dir(get_settings())
md_rel = ""
raw_md_path = str(result.get("md_path") or "")
if raw_md_path:
try:
md_abs = Path(raw_md_path)
if md_abs.is_absolute() and (md_abs == artifact_dir or artifact_dir in md_abs.parents):
md_rel = str(md_abs.relative_to(artifact_dir))
elif not md_abs.is_absolute():
md_rel = raw_md_path
except ValueError:
md_rel = ""
template_key = str(result.get("template_key") or "")
if not template_key and template is not None and template.get("source") == "builtin":
# 兜底(r3-42-01):pipeline 理论上已定稿内置 key,此处防
# pipeline 结果遗漏——保证 artifact.template_key 与所选一致
template_key = str(template.get("id") 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 "")
try:
quota_after = await _check_quota(db, get_settings())
if quota_after is not None:
logger.warning("docgen: 生成后制品目录已超配额(下一次 generate/export 将被拒绝)")
except Exception as exc:
logger.warning("docgen: 生成后配额复查失败(忽略): %s", exc)
artifact_id = await insert_artifact(
db,
session_id=session_id,
title=title,
markdown=str(result.get("markdown") or ""),
md_rel=md_rel or None,
template_name=tpl_display,
template_key=template_key,
template_css=template_css,
html=html,
letterhead=str(result.get("letterhead") or "")[:60],
file_name=str(result.get("file_name") or name_for_prompt(description) or title or "")[:60],
)
# 终态 progress 快照:全部节 done(限频写库可能截断最后一帧)
final_progress = [
{**p, "state": "done"} for p in last_progress
]
await db.execute(
"UPDATE docgen_tasks SET status = 'done', artifact_id = ?, error = NULL, "
"progress = ?, updated_at = ? WHERE id = ?",
(artifact_id, dumps_json(final_progress), _db_now(), task_id),
)
except DocgenUnavailable as exc:
logger.warning("docgen: 任务 %s 生成服务不可用:%s", task_id, exc)
await db.execute(
"UPDATE docgen_tasks SET status = 'error', error = ?, updated_at = ? WHERE id = ?",
("文档生成服务未就绪,请稍后重试", _db_now(), task_id),
)
except Exception as exc:
logger.exception("docgen: 任务 %s 生成失败", task_id)
message = (
f"文档生成失败:{exc}" if not isinstance(exc, RuntimeError) else str(exc)
)
await db.execute(
"UPDATE docgen_tasks SET status = 'error', error = ?, updated_at = ? WHERE id = ?",
(message[:500], _db_now(), task_id),
)
finally:
_DOCGEN_SEMAPHORE.release()
@router.post("/generate")
async def generate_doc(
payload: DocgenGenerateRequest,
db: Database = Depends(get_db),
):
"""异步生成(缺陷 #52,2026-09-10):快路径校验 → 插任务行 → 后台生成 → 202。
快路径(修复前同步校验链不变):description 校验 → TTL 惰性清理 →
意图护栏 → 配额检查 → 模板检索(失败→空模版集,不阻断)→
资料快照(library_ids 空=全部 active)。生成主体在
_run_docgen_task 后台执行,本端点立即返回
202 {ok, task_id, status:"queued"}(契约见 docs/acceptance-r3.md 附录 B)。
chat 消息落库由前端走现有 sessions 机制,本端点不碰。
"""
bad = _check_description(payload.description)
if bad is not None:
return bad
# TTL 惰性清理(TL 裁决:generate 开始前;失败只记日志不阻断)
try:
pruned = await _prune_expired_artifacts(db, get_settings())
if pruned:
logger.info("docgen: TTL 清理 %d 个过期制品", pruned)
except Exception as exc:
logger.warning("docgen: TTL 清理失败(继续生成): %s", exc)
guard = await _intent_guard(payload.description, has_active_artifact=False)
if guard is not None:
return guard
quota = await _check_quota(db, get_settings())
if quota is not None:
return quota
# 并发护栏(满并发 429 BUSY 信封)。单线程事件循环中 locked()
# 与 acquire() 之间无 await 点,其它协程插不进来,无竞态窗口;
# 抢到名额才建任务,任务 finally 必释放。
if _DOCGEN_SEMAPHORE.locked():
return _envelope(
"BUSY",
message=f"文档生成任务已满(并发上限 {_DOCGEN_MAX_CONCURRENT}),请稍后重试",
status=429,
)
await _DOCGEN_SEMAPHORE.acquire()
templates: list[dict[str, Any]] = []
try:
templates = await search_documents(payload.description, as_template=True, top_k=20)
templates = [t for t in templates if isinstance(t, dict)]
except DocgenUnavailable:
logger.warning("docgen: vstore 检索不可用 → 空模版集合(生成跳过模版)")
except Exception as exc:
logger.warning("docgen: vstore 检索失败 → 空模版集合:%s", exc)
template = _pick_template(templates, payload.template_id)
# 资料快照:library_ids 为空 → 全部 active 资料;否则按给定顺序取(缺的跳过)
if payload.library_ids:
docs: list[dict[str, Any]] = []
for lid in payload.library_ids:
doc = await _get_library_row(db, str(lid))
if doc is not None and doc.get("active"):
docs.append(doc)
else:
rows = await db.fetchall(
"SELECT * FROM doc_library WHERE active = 1 ORDER BY created_at ASC"
)
docs = [dict(r) for r in rows]
task_id = str(uuid.uuid4())
created = _db_now()
title = payload.description.strip()[:60] or "未命名文档"
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:
await db.execute(
"INSERT INTO docgen_tasks (id, session_id, status, artifact_id, title, "
"error, progress, created_at, updated_at) "
"VALUES (?, ?, 'queued', NULL, ?, NULL, '[]', ?, ?)",
(task_id, payload.session_id or "", title, created, created),
)
except Exception as exc:
logger.exception("docgen: 任务行插入失败")
_DOCGEN_SEMAPHORE.release()
return _envelope("INTERNAL", message=f"任务创建失败:{exc}")
_spawn_task(
_run_docgen_task(
task_id,
db=db,
description=payload.description,
session_id=payload.session_id or "",
title=title,
template=template,
template_style=template_style,
docs=docs,
web_search=web_search,
)
)
return JSONResponse(
status_code=202,
content={"ok": True, "task_id": task_id, "status": "queued"},
)
@router.get("/tasks/{task_id}")
async def get_docgen_task(task_id: str, db: Database = Depends(get_db)):
"""任务轮询(缺陷 #52 契约,附录 B):
200 {"ok":true,"task":{id,session_id,status,progress[],artifact|null,error}}。"""
task = await _get_task(db, task_id)
if task is None:
return _not_found(f"任务 {task_id} 不存在")
return {"ok": True, "task": task}
@router.get("/tasks")
async def list_docgen_tasks(
session_id: str = Query("", description="按会话过滤;空=全部(上限 100)"),
db: Database = Depends(get_db),
):
"""会话任务列表(缺陷 #52 契约,附录 B)。"""
if session_id:
rows = await db.fetchall(
"SELECT id FROM docgen_tasks WHERE session_id = ? ORDER BY created_at DESC LIMIT 100",
(session_id,),
)
else:
rows = await db.fetchall(
"SELECT id FROM docgen_tasks ORDER BY created_at DESC LIMIT 100"
)
tasks: list[dict[str, Any]] = []
for row in rows:
task = await _get_task(db, row["id"])
if task is not None:
tasks.append(task)
return {"ok": True, "tasks": tasks}
# ---------------------------------------------------------------------------
# 制品:content / edit / export / download
# ---------------------------------------------------------------------------
async def _artifact_content(settings: Any, row: dict[str, Any]) -> str | None:
"""取制品 md 原文:磁盘优先,无文件则返回 None(由调用方决定占位)。"""
if row.get("md_path"):
data = _read_artifact_file(settings, str(row["md_path"]))
if data is not None:
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("utf-8", errors="replace")
return None
@router.get("/artifacts/{artifact_id}/content")
async def get_artifact_content(
artifact_id: str,
db: Database = Depends(get_db),
):
row = await _get_artifact_row(db, artifact_id)
if row is None:
return _not_found(f"文档 {artifact_id} 不存在")
content = await _artifact_content(get_settings(), row)
if content is None:
# 生成失败或制品缺失:返回 error 信息作为占位,前端展示失败态
return {
"ok": True,
"markdown": "",
"status": row.get("status"),
"error": row.get("error"),
}
return {"ok": True, "markdown": content, "status": row.get("status")}
@router.patch("/artifacts/{artifact_id}/content")
async def patch_artifact_content(
artifact_id: str,
payload: ArtifactContentPatch,
db: Database = Depends(get_db),
):
"""保存编辑:body {markdown} → 覆盖落盘。"""
row = await _get_artifact_row(db, artifact_id)
if row is None:
return _not_found(f"文档 {artifact_id} 不存在")
markdown = payload.markdown
md_rel = str(row.get("md_path") or f"artifacts/{artifact_id}.md")
safe_rel = _safe_rel(md_rel)
if safe_rel:
path = _artifact_dir(get_settings()) / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown, encoding="utf-8")
except OSError as exc:
logger.exception("docgen: 保存编辑落盘失败")
return _envelope("INTERNAL", message=f"保存失败:{exc}")
else:
return _envelope("INTERNAL", message="制品路径非法,保存失败")
await db.execute(
"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}
@router.post("/artifacts/{artifact_id}/edit")
async def edit_artifact(
artifact_id: str,
payload: DocgenEditRequest,
db: Database = Depends(get_db),
):
"""LLM 编辑:{instruction} → {ok, markdown}(编辑结果同步落盘)。"""
row = await _get_artifact_row(db, artifact_id)
if row is None:
return _not_found(f"文档 {artifact_id} 不存在")
# 2026-09-04 手工测试问题1:编辑端点护栏移除——被调用本身即活动文档上下文,
# 「把底部的公司名称和日期居右」这类不含编辑动词的排版指令必须放行
# (原 route() in ("docgen","edit") 判成 qa 被 422 拦截报「修改失败」)。
current = await _artifact_content(get_settings(), row)
if current is None:
return _envelope("INTERNAL", message="制品内容缺失,无法编辑(可重新生成)")
try:
result = await apply_edit(current, payload.instruction)
except DocgenUnavailable as exc:
logger.warning("docgen: 编辑服务不可用:%s", exc)
return _envelope("UPSTREAM_DOWN", message="文档编辑服务未就绪,请稍后重试")
except Exception as exc:
logger.exception("docgen: 编辑失败")
return _envelope("INTERNAL", message=f"编辑失败:{exc}")
if not isinstance(result, dict):
return _envelope("INTERNAL", message="编辑结果异常")
markdown = str(result.get("markdown") or "")
if result.get("md_path"):
md_rel = str(result["md_path"])
safe_rel = _safe_rel(md_rel)
if safe_rel:
path = _artifact_dir(get_settings()) / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 编辑结果落盘失败(返回内容不受影响): %s", exc)
await db.execute(
"UPDATE doc_artifacts SET md_path = ?, status = 'done', error = NULL WHERE id = ?",
(md_rel, artifact_id),
)
else:
# 服务层未给出路径:写入现有 md_path(或默认名)
md_rel = str(row.get("md_path") or f"artifacts/{artifact_id}.md")
safe_rel = _safe_rel(md_rel)
if safe_rel:
path = _artifact_dir(get_settings()) / safe_rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(markdown, encoding="utf-8")
except OSError as exc:
logger.warning("docgen: 编辑结果落盘失败(返回内容不受影响): %s", exc)
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 "")
template_name = str(row.get("template_name") or "")
if template_name == BIOFLOW_TEMPLATE_NAME:
# 生信报告:bioflow_report 模板 + fig:// 渲染(预览与导出同源,FR-BIO-016)
html, rendered_css = render_bioflow_report(
markdown, title=title, letterhead=letterhead
)
else:
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,
payload: DocgenExportRequest,
db: Database = Depends(get_db),
):
"""导出:{format:"pdf"|"docx"} → {ok, download_url}。
导出内容来自「最新编辑版」(md 文件优先,其次取 row 内容,不再经 LLM)。
同步转换(文档规模分钟级);失败返回 INTERNAL 信封。
配额检查:超 docgen_quota_gb → 507 QUOTA_EXCEEDED(导出会产生新文件)。
"""
row = await _get_artifact_row(db, artifact_id)
if row is None:
return _not_found(f"文档 {artifact_id} 不存在")
# TL R1:export 无意图护栏(format 已由 DocgenExportRequest Literal 白名单校验)
quota = await _check_quota(db, get_settings())
if quota is not None:
return quota
fmt = payload.format
markdown = await _artifact_content(get_settings(), row)
if markdown is None:
return _envelope("INTERNAL", message="制品内容缺失,无法导出(可重新生成)")
title = str(row.get("title") or "document")
try:
if fmt == "docx" and DOCX_VIA_HTML:
# 缺陷 #47a(2026-09-10):DOCX 与 PDF 同构——同一份 HTML 产物
# (render_html / render_bioflow_report)→ docx_from_html_document,
# 红头/标题/表格/图片与预览一致。转换失败回退 md2docx 旧路径。
key = str(row.get("template_key") or "")
frozen_css = str(row.get("template_css") or "")
letterhead = str(row.get("letterhead") or "")
template_name = str(row.get("template_name") or "")
try:
if template_name == BIOFLOW_TEMPLATE_NAME:
html, _rendered_css = render_bioflow_report(
markdown, title=title, letterhead=letterhead
)
else:
html, _rendered_css = render_html(
markdown, title=title, key=key, letterhead=letterhead
)
# 缺陷 #47a(2026-09-11):css 一并传给 DOCX 转换,
# 红头/标题样式与预览/PDF 同源;frozen_css 优先(同 PDF 路径)
data = await docx_from_html_document(
html, title, css=frozen_css or _rendered_css
)
except Exception as exc:
logger.warning(
"docgen: HTML→DOCX 转换失败(%s),回退 md2docx 旧路径", exc
)
data = await docx_from_markdown(markdown, title)
elif fmt == "docx":
data = await docx_from_markdown(markdown, title)
else:
# 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 "")
template_name = str(row.get("template_name") or "")
if template_name == BIOFLOW_TEMPLATE_NAME:
# PDF 与预览同源(bioflow 模板 + fig:// 渲染),保证所见即所得
html, rendered_css = render_bioflow_report(
markdown, title=title, letterhead=letterhead
)
else:
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="文档导出服务未就绪,请稍后重试")
except Exception as exc:
logger.exception("docgen: 导出转换失败")
return _envelope("INTERNAL", message=f"导出失败:{exc}")
if not isinstance(data, (bytes, bytearray)):
return _envelope("INTERNAL", message="导出结果异常")
rel = f"artifacts/{artifact_id}.{fmt}"
path = _artifact_dir(get_settings()) / rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(bytes(data))
except OSError as exc:
logger.exception("docgen: 导出落盘失败")
return _envelope("INTERNAL", message=f"导出文件保存失败:{exc}")
col = "docx_path" if fmt == "docx" else "pdf_path"
await db.execute(f"UPDATE doc_artifacts SET {col} = ? WHERE id = ?", (rel, artifact_id))
return {
"ok": True,
"download_url": f"/api/docgen/artifacts/{artifact_id}/download?format={fmt}",
}
@router.get("/artifacts/{artifact_id}/download")
async def download_artifact(
artifact_id: str,
format: str = Query(..., pattern="^(pdf|docx)$"),
db: Database = Depends(get_db),
):
"""FileResponse 附件下载(RFC 5987 文件名)。"""
row = await _get_artifact_row(db, artifact_id)
if row is None:
return _not_found(f"文档 {artifact_id} 不存在")
rel = str(row.get("docx_path" if format == "docx" else "pdf_path") or "")
data = _read_artifact_file(get_settings(), rel) if rel else None
if data is None:
return _not_found("导出文件不存在(请先导出)")
# 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=(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
if format == "docx"
else "application/pdf"
),
filename=fname,
content_disposition_type="attachment",
)
# ---------------------------------------------------------------------------
# 主库管理(落库过滤 + vstore 透传)
# ---------------------------------------------------------------------------
@router.get("/library")
async def list_library(
q: str = Query(""),
category: str = Query(""),
as_template: bool | None = Query(None),
db: Database = Depends(get_db),
):
"""主库列表:q(name/tags/description 模糊)、category、as_template 过滤。"""
clauses: list[str] = []
params: list[Any] = []
if q.strip():
like = f"%{q.strip()}%"
clauses.append("(name LIKE ? OR tags LIKE ? OR description LIKE ?)")
params += [like, like, like]
if category.strip():
clauses.append("category = ?")
params.append(category.strip())
if as_template is not None:
clauses.append("as_template = ?")
params.append(int(as_template))
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
rows = await db.fetchall(f"SELECT * FROM doc_library{where} ORDER BY created_at DESC", params)
return {"ok": True, "items": [_row_to_library(r).model_dump() for r in rows]}
@router.get("/library/{library_id}")
async def get_library_item(library_id: str, db: Database = Depends(get_db)):
row = await _get_library_row(db, library_id)
if row is None:
return _not_found(f"资料 {library_id} 不存在")
return {"ok": True, "item": _row_to_library(row).model_dump()}
@router.patch("/library/{library_id}")
async def patch_library_item(
library_id: str,
payload: LibraryPatch,
db: Database = Depends(get_db),
):
"""改 meta / as_template:BFF 落库 + vstore 透传更新(vstore 失败不阻断,容错)。"""
row = await _get_library_row(db, library_id)
if row is None:
return _not_found(f"资料 {library_id} 不存在")
sets: list[str] = []
params: list[Any] = []
meta: dict[str, Any] = {}
for col, key in (
("name", "name"), ("category", "category"), ("tags", "tags"),
("description", "description"),
):
val = getattr(payload, key)
if val is not None:
sets.append(f"{col} = ?")
params.append(str(val))
meta[key] = str(val)
if payload.as_template is not None:
sets.append("as_template = ?")
params.append(int(payload.as_template))
meta["as_template"] = bool(payload.as_template)
if payload.active is not None:
sets.append("active = ?")
params.append(int(payload.active))
meta["active"] = bool(payload.active)
if sets:
params.append(library_id)
await db.execute(f"UPDATE doc_library SET {', '.join(sets)} WHERE id = ?", params)
if meta:
try:
await update_metadata(library_id, meta)
except DocgenUnavailable as exc:
logger.warning("docgen: vstore 元数据透传不可用(BFF 已落库): %s", exc)
except Exception as exc:
logger.warning("docgen: vstore 元数据透传失败(BFF 已落库): %s", exc)
updated = await _get_library_row(db, library_id)
return {"ok": True, "item": _row_to_library(updated).model_dump() if updated else None}
@router.delete("/library/{library_id}", status_code=204)
async def delete_library_item(library_id: str, db: Database = Depends(get_db)):
"""删除:BFF 行删除 + vstore 透传删除(vstore 失败容错,仍返回 204)。"""
row = await _get_library_row(db, library_id)
if row is None:
return _not_found(f"资料 {library_id} 不存在")
await db.execute("DELETE FROM doc_library WHERE id = ?", (library_id,))
try:
await delete_document(library_id)
except DocgenUnavailable as exc:
logger.warning("docgen: vstore 删除透传不可用(BFF 已删除): %s", exc)
except Exception as exc:
logger.warning("docgen: vstore 删除透传失败(BFF 已删除): %s", exc)
return Response(status_code=204)
@router.post("/library/rebuild-index")
async def rebuild_library_index():
"""透传 vstore rebuild-index(原子切换);vstore 不可用 → UPSTREAM_DOWN。"""
try:
result = await rebuild_index()
except DocgenUnavailable as exc:
logger.warning("docgen: vstore rebuild-index 不可用:%s", exc)
return _envelope("UPSTREAM_DOWN", message="向量库不可用,索引重建失败,请稍后重试")
except Exception as exc:
logger.exception("docgen: rebuild-index 失败")
return _envelope("INTERNAL", message=f"索引重建失败:{exc}")
if not isinstance(result, dict):
return _envelope("INTERNAL", message="索引重建结果异常")
return {"ok": True, **result}