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>
This commit is contained in:
YANG JIANKUAN
2026-11-03 21:32:15 +08:00
parent a95e27a290
commit e3f234ca0b
47 changed files with 3125 additions and 164 deletions

View File

@@ -0,0 +1,154 @@
"""读标的修改类接口(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"