944 lines
39 KiB
Python
944 lines
39 KiB
Python
"""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}
|