forked from admin/PlantAI-rag-new
- 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 单测)
1218 lines
50 KiB
Python
1218 lines
50 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 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}
|