Files
PlantAI-rag-new/sdk/app/routers/docgen.py
T

944 lines
39 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 logging
import os
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, get_db
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_markdown,
pdf_from_html_document,
)
from ..services.docgen.editor import apply_edit
from ..services.docgen.intent import route
from ..services.docgen.pipeline import generate_document, name_for_prompt
from ..services.docgen.templating import 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 # 生成描述上限
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
# ---------------------------------------------------------------------------
# 制品目录配额 / 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",)
if route(text, has_attachments=False, has_active_artifact=has_active_artifact) 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 选模版;未指定时返回 vstore 推荐的第一条。"""
if template_id:
for t in templates:
if str(t.get("id")) == template_id:
return t
return None
return templates[0] if templates else None
@router.post("/generate")
async def generate_doc(
payload: DocgenGenerateRequest,
db: Database = Depends(get_db),
):
"""同步生成(600s 兜底读超时在 pipeline 内部)。
流程:TTL 惰性清理 → 意图护栏 → 模版检索(失败→空模版集,不阻断)→
模版命中 → 配额检查 → 资料落库行 → pipeline 生成(资料 vstore 拉取与
缺失占位由服务层负责)→ 配额复查 → artifact 落库。
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
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 资料;否则按给定顺序取(缺的跳过)
# 资料的 vstore 全文拉取属服务层(client.py/pipeline.py)职责:检索失败 →
# 空模版集合;document 拉取失败 → 资料缺失占位提示(不阻断生成),
# 具体兜底形态待 dev1 服务层交付后联动联测。
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]
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)
await db.execute(
"INSERT INTO doc_artifacts (id, session_id, status, title, template_name, "
"md_path, docx_path, pdf_path, error, created_at) "
"VALUES (?, ?, 'error', ?, ?, NULL, NULL, NULL, ?, ?)",
(artifact_id, payload.session_id or "", title,
(template or {}).get("name", "") or (template or {}).get("title", ""),
"生成服务未就绪(dev1 服务层未交付)", created),
)
return _envelope("UPSTREAM_DOWN", message="文档生成服务未就绪,请稍后重试")
except Exception as exc:
logger.exception("docgen: 生成失败")
await db.execute(
"INSERT INTO doc_artifacts (id, session_id, status, title, template_name, "
"md_path, docx_path, pdf_path, error, created_at) "
"VALUES (?, ?, 'error', ?, ?, NULL, NULL, NULL, ?, ?)",
(artifact_id, payload.session_id or "", title,
(template or {}).get("name", "") or (template or {}).get("title", ""),
str(exc)[:500], created),
)
return _envelope("INTERNAL", message=f"文档生成失败:{exc}")
markdown = str(result.get("markdown") or "")
md_rel = str(result.get("md_path") or f"artifacts/{artifact_id}.md")
if markdown:
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)
# 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())
if quota_after is not None:
logger.warning("docgen: 生成后制品目录已超配额(下一次 generate/export 将被拒绝)")
except Exception as exc:
logger.warning("docgen: 生成后配额复查失败(忽略): %s", exc)
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, payload.session_id or "", title,
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,
"artifact": {
"id": artifact_id,
"title": title,
"status": "done",
"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,
},
}
# ---------------------------------------------------------------------------
# 制品: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":
# DOCX 保持 markdown 路径(纯编辑副本;PDF 才是所见即所得)
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}