Files
bid-doctor/server/tests/test_checklist_edit.py
YANG JIANKUAN e3f234ca0b feat: 读标可以走完一份文件;审计待确认区,修规则缺陷、提示词出新版本
读标操作(FR-M2-07、08)
- 后端:清单项详情(ETag)、修改(If-Match 选填,记 edited_fields 和 AI 原值)、
  采纳(补出处或声明「人工确认、无出处」,可带修改)、删除 / 丢弃与恢复、人工新增、
  替换出处、人工建出处(选同一页的句子)、修改记录(来自审计)、确认清单(不可变版本,
  内容没变复用上一版)、版本列表;正式清单有变化时已确认的清单回到草稿;项目已递交只读
- 重新解析不推翻人工决定:丢弃的不再冒出来,采纳的留在正式清单,人工补过的出处不被覆盖
- 前端:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除
  (可撤销)、修改记录、新增条目、确认清单;pdf.js 原文查看器(点出处跳页、按坐标框高亮、
  选句补出处),整屏对照布局,J / K 上下条;跨页的一句话合成一处出处显示

待确认审计(架构 §13 SK11)
- 规则只修真正的缺陷:表格框线不算下划线(文字层 v3);句号后的右括号并进同一句
  (切句 s3-v3);中文编号小节和冒号编号条目的上级条款,依据沿上级链核对;取值认中文
  数字、同一行单元格和评分因素标题;补几种无效写法;比对引文前去掉我们加进输入的标记
- 不放宽:引文最短 8 个字、编号必须对、证据全部通过
- 提示词新版本:substantive v3,qualification / scoring / schedule / tech_param v2;
  引文复制整句、并列多项不拆、跨页只引上一行,除实质性条款外输入改成纯文本
- BD_AI_MODELS 按提示词声明的能力选模型;离线评测工具 evals/run_extract.py
- 待确认:国动 46 → 4,S02 17 → 13,S06 174 → 57

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:32:15 +08:00

155 lines
8.1 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.

"""读标的修改类接口(FR-M2-07、08):采纳、修改、删除恢复、人工新增、补出处、确认清单、修改记录。"""
from __future__ import annotations
from typing import Any
from tests.conftest import User, project_with_tender, run_parse
async def _parsed(user: User) -> tuple[str, str, list[dict[str, Any]]]:
project_id, doc_id = await project_with_tender(user, "tender-full.pdf")
assert (await run_parse(user, doc_id))["status"] == "succeeded"
items = (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]
return project_id, doc_id, items
def _pending_star(items: list[dict[str, Any]]) -> dict[str, Any]:
return next(i for i in items if i["zone"] == "pending" and (i["requirement_text"] or "").startswith("*供应商"))
async def test_accept_pending_with_changes_then_confirm(user: User) -> None:
project_id, _, items = await _parsed(user)
star = _pending_star(items)
assert star["citations"] # 规则兜底的条目有出处,直接采纳即可
r = await user.post(f"/checklist-items/{star['id']}/accept", json={"changes": {"title": "须提供原厂授权书"}})
assert r.status_code == 200, r.text
it = r.json()
assert (it["zone"], it["pending_reason"], it["title"]) == ("formal", None, "须提供原厂授权书")
assert it["edited_fields"] == ["title"] and it["accepted_by"]["name"] == "张工"
assert it["ai_values"]["original"]["title"] == star["title"]
again = await user.post(f"/checklist-items/{star['id']}/accept", json={})
assert again.status_code == 409 and again.json()["code"] == "item_not_pending"
r = await user.post(f"/projects/{project_id}/checklist/confirm")
assert r.status_code == 200, r.text
cl = r.json()
assert (cl["status"], cl["version_no"], cl["confirmed_by"]["name"]) == ("confirmed", 1, "张工")
versions = (await user.get(f"/projects/{project_id}/checklist/versions")).json()["items"]
assert [(v["version_no"], v["kind"], v["item_count"]) for v in versions] == [
(1, "confirmed", cl["counts"]["formal"])
]
assert (await user.get(f"/projects/{project_id}")).json()["next_action"] == "upload_bid"
# 再改正式清单:回到草稿;改回原样再确认,复用 v1,不新增版本
formal = next(i for i in items if i["zone"] == "formal")
r = await user.patch(f"/checklist-items/{formal['id']}", json={"title": "临时改一下"})
assert r.status_code == 200 and r.headers["ETag"]
assert (await user.get(f"/projects/{project_id}/checklist")).json()["status"] == "draft"
await user.patch(f"/checklist-items/{formal['id']}", json={"title": formal["title"]})
cl = (await user.post(f"/projects/{project_id}/checklist/confirm")).json()
assert (cl["status"], cl["version_no"]) == ("confirmed", 1)
async def test_accept_needs_citation_or_ack(user: User) -> None:
project_id, doc_id, items = await _parsed(user)
star = _pending_star(items)
assert (await user.put(f"/checklist-items/{star['id']}/citations", json={"citation_ids": []})).status_code == 200
r = await user.post(f"/checklist-items/{star['id']}/accept", json={})
assert r.status_code == 422 and r.json()["code"] == "citation_required"
# 在原文里选句子补出处,再采纳
page = star["citations"][0]["page_no"]
sentences = (await user.get(f"/documents/{doc_id}/pages/{page}/sentences")).json()["items"]
key = next(s["key"] for s in sentences if s["text"].startswith("*供应商"))
r = await user.post(f"/projects/{project_id}/citations", json={"document_id": doc_id, "sentence_keys": [key]})
assert r.status_code == 201, r.text
cite = r.json()
assert cite["created_by_kind"] == "human" and cite["page_no"] == page and cite["quads"]
bad = await user.post(
f"/projects/{project_id}/citations", json={"document_id": doc_id, "sentence_keys": ["x:p1:s1"]}
)
assert bad.status_code == 422 and bad.json()["code"] == "sentence_not_found"
it = (await user.post(f"/checklist-items/{star['id']}/accept", json={"citation_ids": [cite["id"]]})).json()
assert it["zone"] == "formal" and [c["id"] for c in it["citations"]] == [cite["id"]]
assert "citations" in it["edited_fields"] and not it["no_citation_ack"]
async def test_accept_without_citation_ack(user: User) -> None:
_, _, items = await _parsed(user)
star = _pending_star(items)
await user.put(f"/checklist-items/{star['id']}/citations", json={"citation_ids": []})
it = (await user.post(f"/checklist-items/{star['id']}/accept", json={"no_citation_ack": True})).json()
assert it["zone"] == "formal" and it["no_citation_ack"] and not it["citations"]
async def test_discard_restore_and_reparse_keeps_decisions(user: User) -> None:
project_id, doc_id, items = await _parsed(user)
star = _pending_star(items)
formal = next(i for i in items if i["zone"] == "formal")
assert (await user.delete(f"/checklist-items/{star['id']}")).status_code == 204
await user.patch(f"/checklist-items/{formal['id']}", json={"title": "人工改过的标题"})
# 重新解析:丢弃的不再冒出来,人工改过的字段不被覆盖
assert (await run_parse(user, doc_id))["status"] == "succeeded"
now = {i["id"]: i for i in (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]}
assert star["id"] not in now and now[formal["id"]]["title"] == "人工改过的标题"
counts = (await user.get(f"/projects/{project_id}/checklist")).json()["counts"]
assert counts["deleted"] == 1
restored = (await user.post(f"/checklist-items/{star['id']}/restore")).json()
assert restored["deleted_at"] is None and restored["zone"] == "pending"
history = (await user.get(f"/checklist-items/{star['id']}/history")).json()["items"]
assert [h["action"] for h in history] == [
"checklist_item.restore",
"checklist_item.discard",
"checklist_item.extract",
]
assert history[-1]["actor"]["name"] == "系统"
async def test_update_validation_and_precondition(user: User) -> None:
_, _, items = await _parsed(user)
formal = next(i for i in items if i["zone"] == "formal" and i["importance"] == "normal")
r = await user.patch(
f"/checklist-items/{formal['id']}", json={"importance": "substantive", "categories": ["scoring"]}
)
assert r.status_code == 422 and r.json()["code"] == "validation_failed"
r = await user.patch(f"/checklist-items/{formal['id']}", json={"title": None})
assert r.status_code == 422
r = await user.patch(f"/checklist-items/{formal['id']}", json={"title": "x"}, headers={"If-Match": '"0"'})
assert r.status_code == 412 and r.json()["code"] == "precondition_failed"
etag = (await user.get(f"/checklist-items/{formal['id']}")).headers["ETag"]
r = await user.patch(
f"/checklist-items/{formal['id']}", json={"importance": "important"}, headers={"If-Match": etag}
)
assert r.status_code == 200 and r.json()["importance_basis"] == "human"
(h, *_) = (await user.get(f"/checklist-items/{formal['id']}/history")).json()["items"]
assert h["action"] == "checklist_item.update"
assert h["changes"] == [{"field": "importance", "before": "normal", "after": "important"}]
assert h["actor"]["name"] == "张工"
async def test_manual_item(user: User) -> None:
project_id, _, _ = await _parsed(user)
r = await user.post(
f"/projects/{project_id}/checklist/items", json={"categories": ["format_doc"], "title": "投标函须加盖公章"}
)
assert r.status_code == 201, r.text
it = r.json()
assert (it["origin"], it["origin_label"], it["zone"], it["citations"]) == ("human", "人工添加", "formal", [])
filtered = (await user.get(f"/projects/{project_id}/checklist/items", params={"category": "format_doc"})).json()
assert [i["id"] for i in filtered["items"]] == [it["id"]]
(h,) = (await user.get(f"/checklist-items/{it['id']}/history")).json()["items"]
assert h["action"] == "checklist_item.create"
async def test_confirm_empty_checklist(user: User) -> None:
project = (await user.post("/projects", json={})).json()
r = await user.post(f"/projects/{project['id']}/checklist/confirm")
assert r.status_code == 409 and r.json()["code"] == "checklist_empty"