Files
PlantAI-rag-new/sdk/app/routers/test_docgen_api.py
adminandClaude Fable 5 f18286f022 fix(r4-47a/52): DOCX 红头标题样式同源解析 + 意图词典补总结/概括/归纳
#47a: 红头/标题字号颜色从 render_html 产物解析(新增 docgen/html_css.py:
内嵌 style 块 + 调用方传入的模板 css,组合选择器展开、px->pt),解析失败
回退历史常量(FF0000/22pt、h1/h2/h3 22/16/14pt,行为不变)。converter 与
导出路由把 css 传入 DOCX 转换,与 PDF/预览同源;非红头模板(如 report 灰
#333)不误渲染红头。r3 的 doc-body 包裹/表格/图片结构不回归。
#52: intent.py WRITE_VERB 补总结/概括/归纳,新增动词自指保护(唯一命中的
DOC_TYPE 等于命中动词本身时不算写文档意图,如「总结一下植物光合作用的过程」
仍 qa);cases_intent.md 同步规则与用例 31-34(共 34 条)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 14:19:04 +08:00

852 lines
35 KiB
Python
Raw Permalink 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.
"""app/routers/test_docgen_api.py —— docgen 路由层单元测试(P2)。
TestClient + mock 服务层(monkeypatch routers.docgen 模块内的契约函数),
不连真实 vstore/LLM。数据库用临时 SQLite(dependency_overrides,先例:
app/test_sessions_api.py)。制品落盘目录 monkeypatch 到 tmp_path。
覆盖:library 上传(成功/空文件/vstore 不可用)、generate(成功/意图护栏/
检索失败空模版集/服务未就绪落 error 行)、content(读/存)、edit、export+download
(RFC 5987 文件名)、主库管理(列表过滤/单条/改 meta/删除/rebuild-index)、
ArtifactBlock 块联合、配额 _check_quota(507 QUOTA_EXCEEDED 拒绝 generate/export)
与 TTL 惰性清理 _prune_expired_artifacts(generate 前执行,TL 裁决 2026-09-03)。
"""
from __future__ import annotations
import asyncio
import pytest
from fastapi.testclient import TestClient
from app.db import Database, get_db
from app.main import app
from app.routers import docgen as docgen_mod
def _run(coro):
"""同步跑一个协程(新建 event loop,避免与 pytest-asyncio 竞争)。"""
return asyncio.new_event_loop().run_until_complete(coro)
def _drain_pending():
"""跑完 generate 暂存的后台任务协程(请求返回后,fresh loop)。"""
import app.routers.docgen as mod
pending = getattr(mod, "_pending_test_tasks", [])
while pending:
_run(pending.pop())
# ---------------------------------------------------------------------------
# fixtures
# ---------------------------------------------------------------------------
# 注意(TL 联测诊断 2026-09-03):db fixture 必须为纯 sync + 手动事件循环
# (QA 验收脚本 tests/docgen/test_docgen_api_acceptance.py 同款可靠模式)。
# pytest-asyncio 1.4.0 的 event_loop fixture 跨文件复用有 bug:async fixture
# 与 sync 测试混用、多文件合跑时 loop finalizers 跨文件残留导致 async fixture
# 链崩溃('requested an async fixture')。fixture 链必须全 sync。
@pytest.fixture()
def db(tmp_path):
database = Database(tmp_path / "docgen" / "plantai.db")
_run(database.connect())
yield database
_run(database.close())
@pytest.fixture()
def client(db, tmp_path, monkeypatch):
app.dependency_overrides[get_db] = lambda: db
# 制品落盘目录 → tmp(不碰真实 data/docgen)
monkeypatch.setattr(docgen_mod, "_artifact_dir", lambda settings=None: tmp_path / "docgen")
try:
yield TestClient(app)
finally:
app.dependency_overrides.clear()
@pytest.fixture(autouse=True)
def _reset_mocks(monkeypatch):
"""每个用例前把契约函数重置为「放行」形态(意图通过、服务可用)。
每用例独立 Semaphore(防未 drain 任务跨用例泄漏名额 → 误 429);
teardown drain 掉暂存的后台任务协程(fresh loop 里跑完,释放名额)。
"""
import app.routers.docgen as _mod
monkeypatch.setattr(_mod, "_DOCGEN_SEMAPHORE", asyncio.Semaphore(2))
_mod._pending_test_tasks = [] # 属性不存在时 setattr(spawn_sync 也会初始化)
async def intent_ok(text: str) -> bool:
return True
async def ingest_ok(filename, content, *, name, category="", tags="",
description="", as_template=True):
return {"document_id": "lib-1", "enc_path": "enc/doc1.bin",
"embed_sha": "abc", "size": len(content)}
async def search_ok(query, *, as_template=None, top_k=20):
return [{"id": "tpl-1", "name": "实验报告模板"}]
async def generate_ok(description, *, documents, template=None, **kwargs):
return {"markdown": f"# {description}", "md_path": "artifacts/gen.md"}
async def edit_ok(markdown: str, instruction: str):
return {"markdown": markdown + "\n\n编辑完成"}
async def docx_ok(markdown: str, title: str):
return b"DOCX-BYTES"
async def pdf_ok(html: str, css: str, title: str):
return b"PDF-BYTES"
async def update_meta_ok(document_id: str, metadata: dict):
return {"updated": 1}
async def delete_ok(document_id: str):
return {"deleted": 1}
async def rebuild_ok():
return {"rebuilt": True}
async def docx_html_ok(html: str, title: str, css: str = ""):
return b"DOCX-HTML-BYTES"
def render_ok(markdown, *, title, key="document", letterhead=""):
return (f"<html><body><main><h1>{title}</h1>{markdown}</main></body></html>", "")
async def bioflow_render_ok(markdown, *, title, letterhead=""):
return render_ok(markdown, title=title, letterhead=letterhead)
# 缺陷 #52:LLM env 齐备(任务守卫不误杀);缺键专项用例自行覆盖
monkeypatch.setenv("DOCGEN_LLM_BASE_URL", "https://api.deepseek.com")
monkeypatch.setenv("DOCGEN_LLM_API_KEY", "test-key")
monkeypatch.setenv("DOCGEN_LLM_MODEL", "deepseek-chat")
# 后台任务在请求所在 loop 里无法原地跑完(TestClient 每请求一新 loop):
# spawn 时暂存协程(未绑定 loop),请求返回后由 _drain_pending 在新 loop 跑完
docgen_mod._pending_test_tasks = []
def spawn_sync(coro):
docgen_mod._pending_test_tasks.append(coro)
return None
monkeypatch.setattr(docgen_mod, "is_docgen_intent", intent_ok)
monkeypatch.setattr(docgen_mod, "ingest_bytes", ingest_ok)
monkeypatch.setattr(docgen_mod, "search_documents", search_ok)
monkeypatch.setattr(docgen_mod, "generate_document", generate_ok)
monkeypatch.setattr(docgen_mod, "apply_edit", edit_ok)
monkeypatch.setattr(docgen_mod, "docx_from_markdown", docx_ok)
monkeypatch.setattr(docgen_mod, "docx_from_html_document", docx_html_ok)
monkeypatch.setattr(docgen_mod, "render_html", render_ok)
monkeypatch.setattr(docgen_mod, "render_bioflow_report", bioflow_render_ok)
monkeypatch.setattr(docgen_mod, "pdf_from_html_document", pdf_ok)
monkeypatch.setattr(docgen_mod, "update_metadata", update_meta_ok)
monkeypatch.setattr(docgen_mod, "delete_document", delete_ok)
monkeypatch.setattr(docgen_mod, "rebuild_index", rebuild_ok)
monkeypatch.setattr(docgen_mod, "_spawn_task", spawn_sync)
yield
# teardown:drain 暂存的后台任务(释放 Semaphore 名额,防跨用例 BUSY 泄漏)。
# 防御性 try/except:db fixture 可能先于本 fixture 关闭(fixture 逆序析构),
# 届时任务写库抛「连接已关」——只影响清理,不得掩盖 monkeypatch 恢复。
try:
_drain_pending()
except Exception:
pass
async def _upstream_down_async(*args, **kwargs):
raise docgen_mod.DocgenUnavailable("down")
# ---------------------------------------------------------------------------
# library 上传
# ---------------------------------------------------------------------------
def test_library_upload_ok(client, db):
resp = client.post(
"/api/docgen/library",
files={"file": ("资料.md", "## 水稻栽培要点".encode("utf-8"), "text/markdown")},
data={"name": "水稻栽培资料", "category": "农学", "as_template": "true"},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["ok"] is True
assert body["items"][0]["library_id"] == "lib-1"
assert body["items"][0]["name"] == "水稻栽培资料"
assert body["items"][0]["as_template"] is True
def test_library_upload_empty_file_422(client):
resp = client.post(
"/api/docgen/library",
files={"file": ("empty.md", b"", "text/markdown")},
data={},
)
assert resp.status_code == 422
body = resp.json()
assert body["ok"] is False
assert body["error"]["code"] == "VALIDATION"
def test_library_upload_vstore_down_503(client, monkeypatch):
async def ingest_down(*args, **kwargs):
raise docgen_mod.DocgenUnavailable("vstore down")
monkeypatch.setattr(docgen_mod, "ingest_bytes", ingest_down)
resp = client.post(
"/api/docgen/library",
files={"file": ("a.md", b"hello", "text/markdown")},
data={},
)
assert resp.status_code == 503
body = resp.json()
assert body["error"]["code"] == "UPSTREAM_DOWN"
assert body["error"]["degraded"] is True
# ---------------------------------------------------------------------------
# generate
# ---------------------------------------------------------------------------
def test_generate_ok(client, db, tmp_path):
"""缺陷 #52 契约:generate → 202 {ok,task_id,status:"queued"};任务同步跑完后
轮询 done 且 artifact 回填(测试内 _spawn_task 同步执行)。"""
resp = client.post(
"/api/docgen/generate",
json={"description": "写一份水稻实验报告", "session_id": "s1",
"library_ids": [], "template_id": "tpl-1"},
)
assert resp.status_code == 202, resp.text
body = resp.json()
assert body["ok"] is True
task_id = body["task_id"]
assert body["status"] == "queued"
assert len(task_id) == 36 # uuid4
_drain_pending()
# 轮询任务(后台任务已跑完 → done + artifact 回填)
resp = client.get(f"/api/docgen/tasks/{task_id}")
assert resp.status_code == 200, resp.text
task = resp.json()["task"]
assert task["id"] == task_id
assert task["session_id"] == "s1"
assert task["status"] == "done"
assert task["error"] is None
artifact = task["artifact"]
assert artifact is not None
assert artifact["template_name"] == "实验报告模板"
assert artifact["has_html"] is False
assert artifact["letterhead"] == ""
assert artifact["file_name"]
# 会话任务列表
resp = client.get("/api/docgen/tasks", params={"session_id": "s1"})
assert resp.status_code == 200
assert [t["id"] for t in resp.json()["tasks"]] == [task_id]
# 落库行
def _check():
async def inner():
row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = ?", (artifact["id"],))
assert row is not None
assert row["status"] == "done"
assert row["session_id"] == "s1"
return _run(inner())
_check()
# 制品文件已写
assert (tmp_path / "docgen" / "artifacts" / "gen.md").read_text(encoding="utf-8").startswith("# 写一份")
def test_generate_intent_rejected_422_real_route(client):
"""真实 route 判定:非写文档意图(问答类)→ 422 INTENT_REJECTED(TL R1 验收)。"""
resp = client.post("/api/docgen/generate", json={"description": "今天天气怎么样"})
assert resp.status_code == 422
body = resp.json()
assert body["error"]["code"] == "INTENT_REJECTED"
def test_generate_empty_description_422(client):
resp = client.post("/api/docgen/generate", json={"description": " "})
assert resp.status_code == 422
assert resp.json()["error"]["code"] == "VALIDATION"
def test_generate_search_failure_skips_template(client, db, monkeypatch):
"""vstore 检索失败 → 空模版集合(生成跳过模版,不阻断)。"""
calls = {}
async def search_down(query, *, as_template=None, top_k=20):
raise docgen_mod.DocgenUnavailable("vstore down")
async def generate_capture(description, *, documents, template=None, **kwargs):
calls["template"] = template
calls["docs"] = documents
return {"markdown": "# 无模版生成", "md_path": "artifacts/gen2.md"}
monkeypatch.setattr(docgen_mod, "search_documents", search_down)
monkeypatch.setattr(docgen_mod, "generate_document", generate_capture)
resp = client.post("/api/docgen/generate", json={"description": "写一份总结"})
assert resp.status_code == 202
task_id = resp.json()["task_id"]
_drain_pending()
# 后台任务已跑完:确认模版空集 + 空库快照透传给了生成主体
assert calls["template"] is None
assert calls["docs"] == [] # 空库
task = client.get(f"/api/docgen/tasks/{task_id}").json()["task"]
assert task["status"] == "done"
def test_generate_pipeline_unavailable_task_error(client, db, monkeypatch):
"""缺陷 #52:生成服务不可用 → 202 后任务落 error(中文可读),不落 artifact。"""
monkeypatch.setattr(docgen_mod, "generate_document", _upstream_down_async)
resp = client.post("/api/docgen/generate", json={"description": "写一份总结"})
assert resp.status_code == 202
task_id = resp.json()["task_id"]
_drain_pending()
task = client.get(f"/api/docgen/tasks/{task_id}").json()["task"]
assert task["status"] == "error"
assert task["error"]
assert task["artifact"] is None
def _check():
async def inner():
rows = await db.fetchall("SELECT * FROM doc_artifacts")
assert len(rows) == 0
return _run(inner())
_check()
# ---------------------------------------------------------------------------
# 制品:content / edit / export / download
# ---------------------------------------------------------------------------
def _make_artifact(client) -> str:
resp = client.post("/api/docgen/generate", json={"description": "写一份测试文档"})
assert resp.status_code == 202, resp.text
_drain_pending()
task = client.get(f"/api/docgen/tasks/{resp.json()['task_id']}").json()["task"]
assert task["status"] == "done", task
return task["artifact"]["id"]
def test_artifact_content_ok(client):
aid = _make_artifact(client)
resp = client.get(f"/api/docgen/artifacts/{aid}/content")
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["markdown"].startswith("# 写一份测试文档")
assert body["status"] == "done"
def test_artifact_content_not_found(client):
resp = client.get("/api/docgen/artifacts/nope/content")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
def test_artifact_content_patch_saves(client, tmp_path):
aid = _make_artifact(client)
resp = client.patch(
f"/api/docgen/artifacts/{aid}/content", json={"markdown": "# 修改后的内容"}
)
assert resp.status_code == 200
assert resp.json()["markdown"] == "# 修改后的内容"
assert (tmp_path / "docgen" / "artifacts" / "gen.md").read_text(encoding="utf-8") == "# 修改后的内容"
def test_artifact_edit_ok(client):
aid = _make_artifact(client)
resp = client.post(
f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "把标题改成结论"}
)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["markdown"].endswith("编辑完成")
def test_artifact_edit_no_intent_guard(client, monkeypatch):
"""2026-09-04 语义:edit 端点护栏已移除(被调用本身即活动文档上下文),
route 判 qa 也放行(HEAD 4749f77)。"""
aid = _make_artifact(client)
def route_no(text, has_attachments, has_active_artifact):
return "qa"
monkeypatch.setattr(docgen_mod, "route", route_no)
resp = client.post(f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "帮我查个文献"})
assert resp.status_code == 200, resp.text
assert resp.json()["ok"] is True
def test_artifact_edit_unavailable_503(client, monkeypatch):
aid = _make_artifact(client)
monkeypatch.setattr(docgen_mod, "apply_edit", _upstream_down_async)
resp = client.post(f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "加一段"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
def test_artifact_edit_missing_artifact_404(client):
resp = client.post("/api/docgen/artifacts/nope/edit", json={"instruction": "加一段"})
assert resp.status_code == 404
def test_export_docx_and_download(client, tmp_path):
aid = _make_artifact(client)
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "docx"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["ok"] is True
assert body["download_url"].endswith(f"/api/docgen/artifacts/{aid}/download?format=docx")
# 落盘
assert (tmp_path / "docgen" / "artifacts" / f"{aid}.docx").read_bytes() == b"DOCX-HTML-BYTES"
# 下载
resp = client.get(f"/api/docgen/artifacts/{aid}/download?format=docx")
assert resp.status_code == 200
assert resp.content == b"DOCX-HTML-BYTES"
assert resp.headers["content-disposition"].startswith("attachment;")
assert resp.headers["content-type"].startswith("application/vnd.openxmlformats")
def test_export_pdf(client):
aid = _make_artifact(client)
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "pdf"})
assert resp.status_code == 200
assert "download?format=pdf" in resp.json()["download_url"]
resp = client.get(f"/api/docgen/artifacts/{aid}/download?format=pdf")
assert resp.status_code == 200
assert resp.content == b"PDF-BYTES"
assert resp.headers["content-type"] == "application/pdf"
def test_export_invalid_format_422(client):
aid = _make_artifact(client)
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "xlsx"})
assert resp.status_code == 422
assert resp.json()["error"]["code"] == "VALIDATION"
def test_export_unavailable_503(client, monkeypatch):
"""#47a 语义:HTML 转换失败回退 md2docx;两条路径都不可用才 503。"""
aid = _make_artifact(client)
monkeypatch.setattr(docgen_mod, "docx_from_html_document", _upstream_down_async)
monkeypatch.setattr(docgen_mod, "docx_from_markdown", _upstream_down_async)
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "docx"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
def test_download_without_export_404(client):
aid = _make_artifact(client)
resp = client.get(f"/api/docgen/artifacts/{aid}/download?format=pdf")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
# ---------------------------------------------------------------------------
# 主库管理
# ---------------------------------------------------------------------------
def _seed_library(client, db):
def _seed():
async def inner():
now = "2026-09-03T00:00:00Z"
await db.execute(
"INSERT INTO doc_library (id, name, category, tags, description, as_template, "
"enc_path, embed_sha, active, created_at) VALUES "
"('l1', '水稻资料', '农学', '水稻,栽培', 'desc1', 1, 'e1', 's1', 1, ?)", (now,)
)
await db.execute(
"INSERT INTO doc_library (id, name, category, tags, description, as_template, "
"enc_path, embed_sha, active, created_at) VALUES "
"('l2', '小麦资料', '农学', '小麦', 'desc2', 0, 'e2', 's2', 1, ?)", (now,)
)
return _run(inner())
_seed()
def test_library_list_and_filters(client, db):
_seed_library(client, db)
resp = client.get("/api/docgen/library")
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert len(body["items"]) == 2
# q 过滤
resp = client.get("/api/docgen/library", params={"q": "水稻"})
assert [i["id"] for i in resp.json()["items"]] == ["l1"]
# as_template 过滤
resp = client.get("/api/docgen/library", params={"as_template": "true"})
assert [i["id"] for i in resp.json()["items"]] == ["l1"]
# category 过滤
resp = client.get("/api/docgen/library", params={"category": "农学"})
assert len(resp.json()["items"]) == 2
def test_library_get_and_patch_meta(client, db):
_seed_library(client, db)
resp = client.get("/api/docgen/library/l1")
assert resp.status_code == 200
assert resp.json()["item"]["name"] == "水稻资料"
resp = client.patch(
"/api/docgen/library/l1",
json={"name": "水稻栽培手册", "as_template": False},
)
assert resp.status_code == 200
item = resp.json()["item"]
assert item["name"] == "水稻栽培手册"
assert item["as_template"] is False
def test_library_patch_vstore_error_tolerated(client, db, monkeypatch):
"""vstore 元数据透传失败 → BFF 已落库,仍 200。"""
_seed_library(client, db)
monkeypatch.setattr(docgen_mod, "update_metadata", _upstream_down_async)
resp = client.patch("/api/docgen/library/l1", json={"description": "新描述"})
assert resp.status_code == 200
assert resp.json()["item"]["description"] == "新描述"
def test_library_delete_ok_and_vstore_error_tolerated(client, db, monkeypatch):
_seed_library(client, db)
monkeypatch.setattr(docgen_mod, "delete_document", _upstream_down_async)
resp = client.delete("/api/docgen/library/l1")
assert resp.status_code == 204
def _check():
async def inner():
row = await db.fetchone("SELECT * FROM doc_library WHERE id = 'l1'")
assert row is None
return _run(inner())
_check()
def test_library_delete_missing_404(client):
resp = client.delete("/api/docgen/library/nope")
assert resp.status_code == 404
def test_rebuild_index_ok(client):
resp = client.post("/api/docgen/library/rebuild-index")
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["rebuilt"] is True
def test_rebuild_index_vstore_down_503(client, monkeypatch):
monkeypatch.setattr(docgen_mod, "rebuild_index", _upstream_down_async)
resp = client.post("/api/docgen/library/rebuild-index")
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
# ---------------------------------------------------------------------------
# 块联合:ArtifactBlock
# ---------------------------------------------------------------------------
def test_block_union_accepts_artifact_block():
from app.schemas import Block, MessageOut
msg = MessageOut(
id="m1", role="assistant",
blocks=[{"type": "artifact", "artifact_id": "a1", "title": "报告",
"template_name": "实验报告", "status": "done"}],
)
block = msg.blocks[0]
assert block.type == "artifact"
assert block.artifact_id == "a1"
assert msg.model_dump()["blocks"][0]["type"] == "artifact"
def test_block_union_rejects_unknown_type():
from app.schemas import MessageOut
with pytest.raises(Exception):
MessageOut(id="m1", role="assistant", blocks=[{"type": "nope"}])
# ---------------------------------------------------------------------------
# 配额 / TTL(TL 裁决 2026-09-03:V1 由 BFF 实现)
# ---------------------------------------------------------------------------
class _TinySettings:
"""docgen_quota_gb = 0:任何文件体积都超限(artifacts/ 只要有文件)。"""
docgen_quota_gb = 0.0
docgen_artifact_ttl_days = 30
def test_generate_quota_exceeded_507(client, db, tmp_path, monkeypatch):
# 先正常生成一个制品(占用 artifacts/ 体积)
aid = _make_artifact(client)
assert (tmp_path / "docgen" / "artifacts" / "gen.md").exists()
monkeypatch.setattr(docgen_mod, "get_settings", lambda: _TinySettings())
resp = client.post("/api/docgen/generate", json={"description": "再写一份测试报告"})
assert resp.status_code == 507
body = resp.json()
assert body["ok"] is False
assert body["error"]["code"] == "QUOTA_EXCEEDED"
assert body["error"]["degraded"] is True
def test_export_quota_exceeded_507(client, db, tmp_path, monkeypatch):
aid = _make_artifact(client)
assert (tmp_path / "docgen" / "artifacts" / "gen.md").exists()
monkeypatch.setattr(docgen_mod, "get_settings", lambda: _TinySettings())
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "docx"})
assert resp.status_code == 507
assert resp.json()["error"]["code"] == "QUOTA_EXCEEDED"
def test_check_quota_passes_when_under_limit(client, db, tmp_path):
"""配额充足(默认 50GB 设置走真实 get_settings 时仅用 tmp 目录)→ 不拒绝。"""
aid = _make_artifact(client) # 默认设置下生成成功即证明配额未误拦
resp = client.post("/api/docgen/generate", json={"description": "再写一份测试报告"})
assert resp.status_code == 202
resp2 = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "docx"})
assert resp2.status_code == 200
def _insert_artifact_row(db, aid, created_at, status="done"):
def _ins():
async def inner():
await db.execute(
"INSERT INTO doc_artifacts (id, session_id, status, title, template_name, "
"md_path, docx_path, pdf_path, error, created_at) VALUES "
"(?, '', ?, 't', '', ?, ?, ?, NULL, ?)",
(aid, status, "artifacts/old.md", "artifacts/old.docx", "artifacts/old.pdf",
created_at),
)
return _run(inner())
_ins()
def test_generate_prunes_expired_artifacts(client, db, tmp_path):
"""generate 开始前:过期行(TTL 超 30 天)→ 文件删除 + status 置 expired。"""
from datetime import datetime, timedelta, timezone
old = (datetime.now(timezone.utc) - timedelta(days=31)).strftime("%Y-%m-%dT%H:%M:%SZ")
fresh = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
# 造两行:过期(有文件)+ 新鲜(有文件)
(tmp_path / "docgen" / "artifacts").mkdir(parents=True, exist_ok=True)
for aid, ts in (("exp-1", old), ("fresh-1", fresh)):
_insert_artifact_row(db, aid, ts)
for fname in ("old.md", "old.docx", "old.pdf"):
(tmp_path / "docgen" / "artifacts" / fname).write_text("x", encoding="utf-8")
resp = client.post("/api/docgen/generate", json={"description": "写一份测试报告"})
assert resp.status_code == 202
def _check():
async def inner():
row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = 'exp-1'")
assert row["status"] == "expired"
assert row["md_path"] is None and row["docx_path"] is None and row["pdf_path"] is None
fresh_row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = 'fresh-1'")
assert fresh_row["status"] == "done"
return _run(inner())
_check()
# 过期行文件已删除;新鲜行文件仍在
assert not (tmp_path / "docgen" / "artifacts" / "old.md").exists()
assert not (tmp_path / "docgen" / "artifacts" / "old.docx").exists()
assert not (tmp_path / "docgen" / "artifacts" / "old.pdf").exists()
def test_prune_expired_missing_files_tolerated(client, db, tmp_path):
"""过期行文件缺失 → 清理容错(不抛异常),行仍置 expired。"""
from datetime import datetime, timedelta, timezone
old = (datetime.now(timezone.utc) - timedelta(days=40)).strftime("%Y-%m-%dT%H:%M:%SZ")
_insert_artifact_row(db, "exp-nofile", old)
# 不创建任何文件
resp = client.post("/api/docgen/generate", json={"description": "写一份测试报告"})
assert resp.status_code == 202
def _check():
async def inner():
row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = 'exp-nofile'")
assert row["status"] == "expired"
return _run(inner())
_check()
# ---------------------------------------------------------------------------
# 意图护栏 R1(TL 2026-09-03:按端点类型传参;edit 放行编辑指令;export 无护栏)
# ---------------------------------------------------------------------------
def test_edit_endpoint_accepts_edit_instruction_real_route(client):
"""TL R1 验收:edit 端点「润色一下第一段」→ 200(route 判 edit,放行)。"""
aid = _make_artifact(client)
resp = client.post(f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "润色一下第一段"})
assert resp.status_code == 200, resp.text
assert resp.json()["ok"] is True
def test_export_no_intent_guard_real_route(client):
"""TL R1 验收:export 端点无护栏,任意合法 format → 200。"""
aid = _make_artifact(client)
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "docx"})
assert resp.status_code == 200, resp.text
assert resp.json()["ok"] is True
def test_export_invalid_format_still_422(client):
"""TL R1 验收:export 护栏取消后 format 非法仍 422(pydantic Literal 白名单)。"""
aid = _make_artifact(client)
resp = client.post(f"/api/docgen/artifacts/{aid}/export", json={"format": "xlsx"})
assert resp.status_code == 422
assert resp.json()["error"]["code"] == "VALIDATION"
def test_edit_endpoint_qa_instruction_passes_real_route(client):
"""2026-09-04 语义:edit 端点无护栏(HEAD 4749f77),问答类指令也走编辑链路。"""
aid = _make_artifact(client)
resp = client.post(f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "帮我查个文献"})
assert resp.status_code == 200, resp.text
assert resp.json()["ok"] is True
# ---------------------------------------------------------------------------
# 缺陷 #52 异步任务化专项
# ---------------------------------------------------------------------------
def test_generate_returns_202_and_task_roundtrip(client, db):
resp = client.post("/api/docgen/generate", json={"description": "写一份异步测试文档", "session_id": "s52"})
assert resp.status_code == 202
body = resp.json()
assert set(body.keys()) == {"ok", "task_id", "status"}
assert body["ok"] is True and body["status"] == "queued"
# 任务不存在 → 404
resp = client.get("/api/docgen/tasks/does-not-exist")
assert resp.status_code == 404
_drain_pending()
# 任务详情契约字段(附录 B)
task = client.get(f"/api/docgen/tasks/{body['task_id']}").json()["task"]
assert task["status"] == "done"
assert isinstance(task["progress"], list)
assert task["artifact"]["title"]
def test_generate_missing_llm_env_task_error_names_keys(client, db, monkeypatch):
"""DOCGEN_LLM_* 不齐 → 任务 error 写明缺哪个键。"""
monkeypatch.delenv("DOCGEN_LLM_API_KEY", raising=False)
resp = client.post("/api/docgen/generate", json={"description": "写一份测试文档"})
assert resp.status_code == 202
_drain_pending()
task = client.get(f"/api/docgen/tasks/{resp.json()['task_id']}").json()["task"]
assert task["status"] == "error"
assert "DOCGEN_LLM_API_KEY" in (task["error"] or "")
def test_generate_busy_429_when_semaphore_full(client, db, monkeypatch):
"""满并发 → 429 BUSY 信封(快路径不建任务)。"""
import asyncio
# 意图护栏放行(本用例只关心并发名额,不测 route 判型)
monkeypatch.setattr(docgen_mod, "route", lambda *a, **k: "docgen")
monkeypatch.setattr(docgen_mod, "_DOCGEN_SEMAPHORE", asyncio.Semaphore(1))
resp1 = client.post("/api/docgen/generate", json={"description": "写一份占用名额"})
assert resp1.status_code == 202
# 第二个请求:满并发(名额被第一个任务占用)
resp2 = client.post("/api/docgen/generate", json={"description": "再写一份"})
assert resp2.status_code == 429
body = resp2.json()
assert body["ok"] is False
assert body["error"]["code"] == "BUSY"
# 跑完第一个任务(finally 释放名额),任务终态 done
_drain_pending()
task = client.get(f"/api/docgen/tasks/{resp1.json()['task_id']}").json()["task"]
assert task["status"] == "done"
def test_startup_cleanup_marks_running_tasks_error(db, tmp_path):
"""服务重启:running/queued → status='error', error='服务重启,任务中断,请重试'。"""
from app.routers import docgen as docgen_mod2
# 直接写一个 running 行(不经 API)
def _seed():
async def inner():
now = "2026-09-10T00:00:00Z"
await db.execute(
"INSERT INTO docgen_tasks (id, session_id, status, artifact_id, title, "
"error, progress, created_at, updated_at) VALUES "
"('t-running', 's', 'running', NULL, 't', NULL, '[]', ?, ?)",
(now, now),
)
return _run(inner())
_seed()
# 模拟重启:重新 connect()(幂等迁移 + 启动清理)
database = Database(db.path)
_run(database.connect())
def _check():
async def inner():
row = await db.fetchone("SELECT * FROM docgen_tasks WHERE id = 't-running'")
assert row["status"] == "error"
assert row["error"] == "服务重启,任务中断,请重试"
return _run(inner())
_check()
_run(database.close())
# ---------------------------------------------------------------------------
# r3-42-01 内置模板回退语义(2026-09-10)
# ---------------------------------------------------------------------------
def test_pick_template_builtin_key_resolves_without_vstore_hit():
"""template_id 是内置 key → 直接解析为内置模版,不依赖 vstore 检索命中。"""
from app.services.docgen.templating import get_template_asset, list_templates
keys = [e["key"] for e in list_templates()]
assert "official" in keys and "plan" in keys and "document" in keys
# vstore 检索结果为空集(库内无条目)
tpl = docgen_mod._pick_template([], "official")
assert tpl is not None
assert tpl["id"] == "official"
assert tpl["source"] == "builtin"
assert tpl["name"] == "通用公文(红头)"
# 内置模版必须真实存在(get_template_asset 非空才解析)
assert get_template_asset("official") is not None
def test_pick_template_non_builtin_id_still_vstore():
"""非内置 key → vstore 检索按 id 命中;未命中 → None(自动推荐)。"""
tpl = docgen_mod._pick_template([{"id": "lib-9", "name": "库内模板"}], "lib-9")
assert tpl is not None and tpl["name"] == "库内模板"
assert docgen_mod._pick_template([], "lib-9") is None
# 未指定 template_id → vstore 推荐第一条
assert docgen_mod._pick_template([{"id": "lib-1"}], None)["id"] == "lib-1"
def test_generate_builtin_official_uses_key(client, db, monkeypatch):
"""r3-42-01 复现:vstore 检索空 + template_id=official → 产物 key=official。"""
async def search_empty(query, *, as_template=None, top_k=20):
return []
monkeypatch.setattr(docgen_mod, "search_documents", search_empty)
resp = client.post("/api/docgen/generate", json={
"description": "撰写一份水稻病虫害防治方案", "session_id": "r3-42",
"template_id": "official",
})
assert resp.status_code == 202, resp.text
_drain_pending()
task = client.get(f"/api/docgen/tasks/{resp.json()['task_id']}").json()["task"]
assert task["status"] == "done", task["error"]
artifact = task["artifact"]
assert artifact["template_key"] == "official", artifact
assert artifact["template_name"] == "通用公文(红头)", artifact