Files
PlantAI-rag-new/sdk/app/routers/test_docgen_api.py
T
jerryandClaude Fable 5 5888e1d5c1 test(docgen): 测试治理资产——131 条验收用例 + 三轮留痕 + E2E/回归套件
- tests/docgen/:DC-UNIT/API/CONF/E2E/UI/REGRESSION/PERF 分组用例与可复跑脚本,报告逐条留痕(R1-R3 全记录)
- 意图 30 条前后端同表双跑(pytest + node)
- Playwright headless:docgen-ui/docgen-regression spec + b3 benchpanel 第 5 TAB 断言更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 21:13:47 +08:00

628 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)
# ---------------------------------------------------------------------------
# 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):
"""每个用例前把契约函数重置为「放行」形态(意图通过、服务可用)。"""
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):
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(markdown: 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}
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, "pdf_from_markdown", 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)
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):
resp = client.post(
"/api/docgen/generate",
json={"description": "写一份水稻实验报告", "session_id": "s1",
"library_ids": [], "template_id": "tpl-1"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["ok"] is True
aid = body["artifact"]["id"]
assert body["artifact"]["status"] == "done"
assert body["artifact"]["template_name"] == "实验报告模板"
# 落库行
def _check():
async def inner():
row = await db.fetchone("SELECT * FROM doc_artifacts WHERE id = ?", (aid,))
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):
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 == 200
assert calls["template"] is None
assert calls["docs"] == [] # 空库
def test_generate_pipeline_unavailable_503_and_error_row(client, db, monkeypatch):
monkeypatch.setattr(docgen_mod, "generate_document", _upstream_down_async)
resp = client.post("/api/docgen/generate", json={"description": "写一份总结"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "UPSTREAM_DOWN"
# error 行已落库(前端可查状态)
def _check():
async def inner():
rows = await db.fetchall("SELECT * FROM doc_artifacts")
assert len(rows) == 1
assert rows[0]["status"] == "error"
assert rows[0]["error"]
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 == 200, resp.text
return resp.json()["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_intent_rejected(client, monkeypatch):
"""护栏机制:route 判 qa → 422(monkeypatch route;真实判定见新增用例)。"""
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 == 422
assert resp.json()["error"]["code"] == "INTENT_REJECTED"
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-BYTES"
# 下载
resp = client.get(f"/api/docgen/artifacts/{aid}/download?format=docx")
assert resp.status_code == 200
assert resp.content == b"DOCX-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):
aid = _make_artifact(client)
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 == 200
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 == 200
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 == 200
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_rejected_real_route(client):
"""edit 端点传问答类指令:route 判 qa(EDIT_WORD 不命中)→ 422 护栏仍生效。"""
aid = _make_artifact(client)
resp = client.post(f"/api/docgen/artifacts/{aid}/edit", json={"instruction": "帮我查个文献"})
assert resp.status_code == 422
assert resp.json()["error"]["code"] == "INTENT_REJECTED"