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:
@@ -84,6 +84,12 @@ class User:
|
||||
async def put(self, path: str, **kw: Any) -> httpx.Response:
|
||||
return await self.client.put(path, headers=self.headers, **kw)
|
||||
|
||||
async def patch(self, path: str, **kw: Any) -> httpx.Response:
|
||||
return await self.client.patch(path, headers={**self.headers, **kw.pop("headers", {})}, **kw)
|
||||
|
||||
async def delete(self, path: str) -> httpx.Response:
|
||||
return await self.client.delete(path, headers=self.headers)
|
||||
|
||||
@property
|
||||
def tenant_id(self) -> str:
|
||||
return str(self.me["tenant"]["id"])
|
||||
|
||||
154
server/tests/test_checklist_edit.py
Normal file
154
server/tests/test_checklist_edit.py
Normal 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"
|
||||
@@ -70,8 +70,15 @@ def doc(
|
||||
declared: set[str] | None = None,
|
||||
sentences: list[Sentence] | None = None,
|
||||
parents: dict[str, Sentence] | None = None,
|
||||
continuations: dict[str, Sentence] | None = None,
|
||||
) -> Doc:
|
||||
return Doc(sentences=sentences or [], parents=parents or {}, continuations={}, notes=[], declared=declared or set())
|
||||
return Doc(
|
||||
sentences=sentences or [],
|
||||
parents=parents or {},
|
||||
continuations=continuations or {},
|
||||
notes=[],
|
||||
declared=declared or set(),
|
||||
)
|
||||
|
||||
|
||||
def test_basis_check() -> None:
|
||||
@@ -216,3 +223,82 @@ def test_heading_is_context_not_the_requirement() -> None:
|
||||
d = doc(sentences=[heading, item], parents={item.key: heading})
|
||||
assert without_headings([heading, item], d) == [item]
|
||||
assert without_headings([heading], d) == [heading] # 只引了标题:保留
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 2026-11-03 待确认审计后的规则修正
|
||||
|
||||
|
||||
def test_table_borders_are_not_underlines() -> None:
|
||||
"""表格框线两端接着竖线,不算下划线;真正的下划线不接竖线(国动样本功能表被误认成斜体下划线条款)。"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from bid_doctor.document.pipeline.text_layer import _underlines # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
border = {"x0": 86.8, "x1": 501.6, "top": 379.5, "bottom": 379.5}
|
||||
under = {"x0": 118.1, "x1": 188.8, "top": 327.9, "bottom": 327.9}
|
||||
verticals = [{"x0": x, "x1": x, "top": 300.0, "bottom": 400.0} for x in (86.8, 501.6)]
|
||||
page = SimpleNamespace(lines=[border, under, *verticals], rects=[])
|
||||
assert _underlines(page) == [(118.1, 188.8, 327.9)]
|
||||
|
||||
|
||||
def test_quote_ignores_our_markup_and_end_punctuation() -> None:
|
||||
"""我们加进输入的格式标记和〔续下页〕不是原文,比对前去掉;短句整句复制时句末标点可以不带。原文文字不放宽。"""
|
||||
s = sent("联合体享受10%价格扣除,用扣除后的价格参与评审。")
|
||||
by_id = {"p1s1": s}
|
||||
assert check_evidence({"sid": "p1s1", "quote": "联合体享受<u>10%</u>价格扣除"}, by_id)[0] is None
|
||||
assert check_evidence({"sid": "p1s1", "quote": "用扣除后的价格参与评审〔续下页〕"}, by_id)[0] is None
|
||||
short = sent("1.软件开发;")
|
||||
assert check_evidence({"sid": "p1s1", "quote": "1.软件开发"}, {"p1s1": short})[0] is None
|
||||
assert check_evidence({"sid": "p1s1", "quote": "软件开发"}, {"p1s1": short})[0] == "quote"
|
||||
assert check_evidence({"sid": "p1s1", "quote": "价格扣除"}, by_id)[0] == "quote" # 只摘关键词照样退回
|
||||
|
||||
|
||||
def test_chinese_numbers_in_values() -> None:
|
||||
assert check_value("1", [sent("经采购人授权,每月至少开展一次运行检查。")])
|
||||
assert check_value("12", [sent("培训不少于十二课时,每期不少于十二人。")])
|
||||
assert not check_value("1", [sent("对接苏服办统一身份认证。")]) # 「统一」里的「一」不是数字
|
||||
|
||||
|
||||
def test_value_in_same_row_or_heading() -> None:
|
||||
"""评分表的分值可能在同一行隔几格的「分值」列,或者在评分因素的标题行里。"""
|
||||
from bid_doctor.checklist.extract import _context # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
row = Sentence(key="d:p1:s1", page_no=1, seq=1, text="提供完整方案的得满分。", quads=[[100, 200, 300, 214]])
|
||||
points = Sentence(key="d:p1:s9", page_no=1, seq=9, text="4", quads=[[450, 205, 460, 213]])
|
||||
other = Sentence(key="d:p1:s10", page_no=1, seq=10, text="5", quads=[[450, 300, 460, 312]])
|
||||
head = Sentence(key="d:p1:s0", page_no=1, seq=0, text="5、视频演示(10分)", quads=[[90, 180, 200, 194]])
|
||||
chunk = [head, row, sent("下一行的说明。", seq=2), sent("再下一行。", seq=3), points, other]
|
||||
ctx = _context([row], chunk, parents={row.key: head})
|
||||
assert points in ctx and other not in ctx and head in ctx
|
||||
assert check_value("4", ctx) and check_value("10", ctx)
|
||||
|
||||
|
||||
def test_chinese_numbered_section_parents() -> None:
|
||||
"""「三、……否则投标无效」下面的 1. 2.,以及「1.报价符合性审查:」下面不带编号的句子(S02 第 20 页)。"""
|
||||
from bid_doctor.document.pipeline.structure import ancestors, parents
|
||||
|
||||
ss = [
|
||||
sent("三、符合条件(必须提供,否则投标无效)", seq=1),
|
||||
sent("1.报价符合性审查:", seq=2),
|
||||
sent("投标报价不得超过采购项目最高限价;", seq=3),
|
||||
sent("2.《投标函》(加盖电子签章);", seq=4),
|
||||
sent("四、综合评审评分项", seq=5),
|
||||
sent("1.技术方案", seq=6),
|
||||
]
|
||||
par = parents(ss)
|
||||
assert par["d:p1:s2"].seq == 1 and par["d:p1:s3"].seq == 2 and par["d:p1:s4"].seq == 1
|
||||
assert par["d:p1:s6"].seq == 5
|
||||
assert [p.seq for p in ancestors("d:p1:s3", par)] == [2, 1]
|
||||
d = doc(sentences=ss, parents=par)
|
||||
assert check_basis("explicit_invalid", [ss[2]], ss, d) # 依据在上级的上级
|
||||
assert not check_basis("explicit_invalid", [ss[5]], ss, d)
|
||||
|
||||
|
||||
def test_model_input_marks_only_for_marking_task() -> None:
|
||||
from bid_doctor.checklist.extract import _render # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
a = Sentence(key="d:p1:s1", page_no=1, seq=1, text="须提供授权书,采购人有权", quads=[], fmt_runs=[(3, 6, "iu")])
|
||||
b = Sentence(key="d:p2:s1", page_no=2, seq=1, text="永久使用。", quads=[])
|
||||
d = doc(sentences=[a, b], continuations={a.key: b})
|
||||
assert _render([a, b], d).splitlines()[-2:] == ["[p1s1] 须提供授权书,采购人有权〔续下页〕", "[p2s1] 永久使用。"]
|
||||
assert "<iu>授权书</iu>" in _render([a, b], d, marks=True)
|
||||
|
||||
Reference in New Issue
Block a user