feat: 查标第 1 步——上传投标分册 → 检查 → 逐条处理 → 复查沿用
后端 - check 模块:检查预览、发起、进度、取消、记录;六个阶段(准备投标文件、响应匹配、规则、证据校验、 状态沿用、汇总);问题列表、处理、撤销、批量处理(只限提示级)。 - 每条清单项的检查结论 check_item_result(C9,迁移 0005):「已响应」也带投标原文出处; 没有校验通过的证据不能判已响应(C3)。级别从清单项算,不让模型定;说明不写整改建议。 - 响应匹配先只有第三层:内存 BM25 检索候选句 + check.match v1 提示词,检索不到不调模型, 漏答记 missing_answer,模型临时错误只影响那一批。 - 计费:免费版不记流水;专业版 24 小时内复查免扣(记一行 0);expected_charge 不一致时拒绝不扣点; 失败、取消退回;投标文件读不出文字按输入问题失败、不扣点。 - 投标分册:类型(volume_kind)和版本;电子投标平台加密格式在上传时拦下。 - 解析流水线前几个阶段拆到 document/pipeline/normalize.py,解析和检查共用。 - ops 模块:反馈接口;致命问题确认无误的说明自动进误报反馈(U4)。 - SK1:Valkey 客户端短超时、SSE 连接池上限。SK2:CI 加 Gotenberg、直接跑 init-roles.sh。 前端 - 上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、 已处理、本次已解决);项目头抽成公共组件,加「查标」标签页;工作台「下一步」覆盖查标阶段。 契约、文档:openapi.yaml 加 item-results、volume_kind、coverage 字段;data-model、api-design §10、 架构 §8 / §13(SK14)、AGENTS.md 同步。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BIN
server/tests/fixtures/bid-mini-v1.pdf
vendored
Normal file
BIN
server/tests/fixtures/bid-mini-v1.pdf
vendored
Normal file
Binary file not shown.
BIN
server/tests/fixtures/bid-mini-v2.pdf
vendored
Normal file
BIN
server/tests/fixtures/bid-mini-v2.pdf
vendored
Normal file
Binary file not shown.
46
server/tests/fixtures/make_bid_fixtures.py
vendored
Normal file
46
server/tests/fixtures/make_bid_fixtures.py
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
"""生成查标测试用的虚构投标文件(对应 tender-mini.pdf 的三条 ★ 条款)。内容全部虚构。
|
||||
|
||||
cd server && ../.venv/bin/python tests/fixtures/make_bid_fixtures.py # 需要 Gotenberg(:3109)
|
||||
|
||||
- bid-mini-v1.pdf:响应了资质证书和服务器内存,漏了交换机硬件 BFD → 1 个致命问题「未找到响应」;
|
||||
- bid-mini-v2.pdf:补上了交换机硬件 BFD → 复查时这个问题「本次已解决」。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
GOTENBERG = "http://127.0.0.1:3109/forms/chromium/convert/html"
|
||||
|
||||
HEAD = """<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: "Noto Serif CJK SC", "Noto Sans CJK SC", serif; font-size: 12pt; line-height: 1.8; margin: 0; }
|
||||
h1 { font-size: 16pt; text-align: center; }
|
||||
</style></head><body>"""
|
||||
|
||||
COMMON = [
|
||||
"<h1>某市智慧政务系统集成项目投标文件(测试样例,内容虚构)</h1>",
|
||||
"<p>投标人:某某信息技术有限公司(虚构单位,仅用于测试)。</p>",
|
||||
"<p>一、项目理解。本投标文件是测试用的虚构样例,用于验证投标文件检查流程,内容不对应任何真实的投标人或采购项目。"
|
||||
"我方已认真阅读采购文件的全部内容,愿意按采购文件的要求提供货物和服务。</p>",
|
||||
"<p>二、资格证明。投标人须具备有效的信息系统集成服务资质证书:我公司具备有效的信息系统集成服务资质证书,"
|
||||
"证书复印件见附件1。</p>",
|
||||
"<p>三、技术响应。服务器内存不低于32GB的要求:我方服务器内存配置为64GB,并提供产品彩页作为证明材料,见附件2。</p>",
|
||||
]
|
||||
BFD = "<p>交换机支持硬件BFD,最小报文发送间隔不大于50毫秒的要求:我方交换机支持硬件BFD,最小报文发送间隔为3.3毫秒。</p>"
|
||||
TAIL = ["<p>四、售后服务。我公司提供7×24小时售后服务,接到报修后2小时内响应。</p>", "</body></html>"]
|
||||
|
||||
|
||||
def render(name: str, parts: list[str]) -> None:
|
||||
html = HEAD + "".join(parts)
|
||||
r = httpx.post(GOTENBERG, files={"files": ("index.html", html.encode(), "text/html")}, timeout=60)
|
||||
r.raise_for_status()
|
||||
(HERE / name).write_bytes(r.content)
|
||||
print(name, len(r.content))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
render("bid-mini-v1.pdf", [*COMMON, *TAIL])
|
||||
render("bid-mini-v2.pdf", [*COMMON, BFD, *TAIL])
|
||||
253
server/tests/test_check_flow.py
Normal file
253
server/tests/test_check_flow.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""查标第 1 步(m3-plan §5):上传投标文件 → 检查 → 处理问题 → 复查后状态沿用;以及检查的计费(红线 9)。
|
||||
|
||||
假模型按字面重合判断有没有响应,结论没有参考价值;这里验证的是链路、证据、状态沿用和计费。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
|
||||
from tests.conftest import FIXTURES, User, balance, project_with_tender, register, run_parse, upload
|
||||
|
||||
|
||||
async def upload_bid(u: User, project_id: str, fixture: str, replaces: str | None = None) -> dict[str, Any]:
|
||||
done = await upload(u, fixture, (FIXTURES / fixture).read_bytes())
|
||||
assert done.status_code == 200, done.text
|
||||
body: dict[str, Any] = {"upload_id": done.json()["id"], "role": "bid_volume", "volume_kind": "technical"}
|
||||
if replaces:
|
||||
body["replaces_document_id"] = replaces
|
||||
r = await u.post(f"/projects/{project_id}/documents", json=body)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
async def run_check(u: User, project_id: str, doc_ids: list[str], expected: int = 0) -> dict[str, Any]:
|
||||
"""发起检查,并在测试进程里直接跑流水线(不经过 worker)。"""
|
||||
from bid_doctor.check import runner
|
||||
|
||||
r = await u.post(f"/projects/{project_id}/check-runs", json={"document_ids": doc_ids, "expected_charge": expected})
|
||||
assert r.status_code == 201, r.text
|
||||
await runner.run(UUID(u.tenant_id), UUID(r.json()["id"]))
|
||||
return (await u.get(f"/check-runs/{r.json()['id']}")).json()
|
||||
|
||||
|
||||
async def checked_project(u: User) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
||||
project_id, tender_id = await project_with_tender(u)
|
||||
assert (await run_parse(u, tender_id))["status"] == "succeeded"
|
||||
bid = await upload_bid(u, project_id, "bid-mini-v1.pdf")
|
||||
run = await run_check(u, project_id, [bid["id"]])
|
||||
assert run["status"] == "succeeded", run
|
||||
return project_id, bid, run
|
||||
|
||||
|
||||
async def findings(u: User, run_id: str) -> list[dict[str, Any]]:
|
||||
return (await u.get(f"/check-runs/{run_id}/findings")).json()["items"]
|
||||
|
||||
|
||||
async def use_pro_plan(u: User) -> None:
|
||||
from bid_doctor.core.db import TenantContext, tenant_tx
|
||||
|
||||
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(str(u.me["user"]["id"])))
|
||||
async with tenant_tx(ctx) as s:
|
||||
await s.execute(text("UPDATE subscription SET plan_code = 'pro' WHERE tenant_id = :t"), {"t": u.tenant_id})
|
||||
|
||||
|
||||
async def test_check_flow(user: User) -> None:
|
||||
project_id, tender_id = await project_with_tender(user)
|
||||
await run_parse(user, tender_id)
|
||||
preview = (await user.get(f"/projects/{project_id}/check-preview")).json()
|
||||
assert preview["blockers"] == ["no_bid_documents"] and not preview["can_start"]
|
||||
|
||||
bid = await upload_bid(user, project_id, "bid-mini-v1.pdf")
|
||||
assert bid["label"] == "技术分册" and bid["volume_kind"] == "technical" and bid["is_latest_version"]
|
||||
preview = (await user.get(f"/projects/{project_id}/check-preview")).json()
|
||||
assert preview["can_start"] and preview["billing"] == "free_plan" and preview["charge"] == 0
|
||||
assert preview["checklist_confirmed"] is False and [d["id"] for d in preview["bid_documents"]] == [bid["id"]]
|
||||
|
||||
run = await run_check(user, project_id, [bid["id"]])
|
||||
assert run["status"] == "succeeded" and run["is_latest"] and run["checklist_confirmed"] is False
|
||||
assert run["counts"] == {"fatal": 1, "important": 0, "hint": 0, "open_fatal": 1, "resolved_this_run": 0, "new": 1}
|
||||
assert "清单未确认,检查结果以当前清单为准" in run["coverage"]["not_checked"]
|
||||
assert run["coverage"]["items"]["checked"] == 3
|
||||
|
||||
(f,) = await findings(user, run["id"])
|
||||
assert f["type"] == "unresponded" and f["severity"] == "fatal" and f["presence"] == "new"
|
||||
assert "BFD" in f["title"] and f["detector"] == "ai" and f["checklist_item"]["importance"] == "substantive"
|
||||
assert f["tender_citations"][0]["quote"].startswith("★交换机") and f["bid_citations"] == []
|
||||
assert f["search_scope"]["documents"] == ["技术分册"]
|
||||
assert "建议" not in f["explanation"] # 只写事实和出处,不写整改建议
|
||||
|
||||
# 「已响应」也留结论和出处(C9)
|
||||
results = (await user.get(f"/check-runs/{run['id']}/item-results")).json()["items"]
|
||||
responded = [r for r in results if r["verdict"] == "responded"]
|
||||
assert len(results) == 3 and len(responded) == 2
|
||||
for r in responded:
|
||||
(c,) = r["bid_citations"]
|
||||
assert r["evidence_status"] == "verified" and c["document_label"] == "技术分册" and c["quads"]
|
||||
|
||||
project = (await user.get(f"/projects/{project_id}")).json()
|
||||
assert project["status"] == "checked" and project["readiness"] == {
|
||||
"status": "checked",
|
||||
"open_fatal_count": 1,
|
||||
"ready": False,
|
||||
}
|
||||
notes = (await user.get("/notifications")).json()["items"]
|
||||
assert notes[0]["kind"] == "check_succeeded"
|
||||
|
||||
|
||||
async def test_resolve_and_reopen(user: User) -> None:
|
||||
project_id, _, run = await checked_project(user)
|
||||
(f,) = await findings(user, run["id"])
|
||||
r = await user.post(f"/findings/{f['id']}/resolve", json={"status": "confirmed_ok"})
|
||||
assert r.status_code == 422 and r.json()["code"] == "note_required" # 致命问题确认无误也要写说明(U4)
|
||||
r = await user.post(f"/findings/{f['id']}/resolve", json={"status": "ignored", "note": " "})
|
||||
assert r.status_code == 422 and r.json()["code"] == "note_required"
|
||||
|
||||
r = await user.post(f"/findings/{f['id']}/resolve", json={"status": "confirmed_ok", "note": "附件 5 第 3 页有说明"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["finding"]["status"] == "confirmed_ok" and body["finding"]["resolved_by"]["name"] == "张工"
|
||||
assert body["project"] == {"status": "checked", "open_fatal_count": 0, "ready": True}
|
||||
|
||||
from bid_doctor.core.db import TenantContext, tenant_tx
|
||||
|
||||
ctx = TenantContext(tenant_id=UUID(user.tenant_id), user_id=UUID(str(user.me["user"]["id"])))
|
||||
async with tenant_tx(ctx) as s:
|
||||
fb = (await s.execute(text("SELECT object_type, kind, source, note FROM feedback"))).all()
|
||||
assert [tuple(x) for x in fb] == [("finding", "false_positive", "fatal_confirm", "附件 5 第 3 页有说明")]
|
||||
|
||||
r = await user.post(f"/findings/{f['id']}/reopen")
|
||||
assert r.json()["finding"]["status"] == "pending" and r.json()["project"]["open_fatal_count"] == 1
|
||||
|
||||
r = await user.post(
|
||||
f"/check-runs/{run['id']}/findings/bulk-resolve",
|
||||
json={"finding_ids": [f["id"]], "status": "ignored", "note": "x"},
|
||||
)
|
||||
assert r.status_code == 422 and r.json()["code"] == "bulk_only_hint" and r.json()["finding_ids"] == [f["id"]]
|
||||
assert (await user.get(f"/projects/{project_id}")).json()["readiness"]["open_fatal_count"] == 1
|
||||
|
||||
|
||||
async def test_recheck_carries_status_and_resolves(user: User) -> None:
|
||||
project_id, bid, first = await checked_project(user)
|
||||
(f,) = await findings(user, first["id"])
|
||||
await user.post(f"/findings/{f['id']}/resolve", json={"status": "ignored", "note": "BFD 写在附件里"})
|
||||
|
||||
# 同一份投标文件再查:问题还在,沿用上次的处理状态
|
||||
second = await run_check(user, project_id, [bid["id"]])
|
||||
(g,) = await findings(user, second["id"])
|
||||
assert g["presence"] == "carried" and g["status"] == "ignored" and g["carried_status"] == "ignored"
|
||||
assert g["resolution_note"] == "BFD 写在附件里" and second["counts"]["open_fatal"] == 0
|
||||
|
||||
# 历史批次只读
|
||||
r = await user.post(f"/findings/{f['id']}/reopen")
|
||||
assert r.status_code == 409 and r.json()["code"] == "run_superseded"
|
||||
|
||||
# 上次「已修改」又查出来:回到待处理
|
||||
await user.post(f"/findings/{g['id']}/resolve", json={"status": "modified"})
|
||||
third = await run_check(user, project_id, [bid["id"]])
|
||||
(h,) = await findings(user, third["id"])
|
||||
assert h["presence"] == "carried" and h["status"] == "pending" and h["carried_status"] == "modified"
|
||||
|
||||
# 换上补了 BFD 的新版本:问题本次已解决
|
||||
v2 = await upload_bid(user, project_id, "bid-mini-v2.pdf", replaces=bid["id"])
|
||||
assert v2["label"] == "技术分册 v2" and v2["lineage_id"] == bid["lineage_id"]
|
||||
assert not (await user.get(f"/documents/{bid['id']}")).json()["is_latest_version"]
|
||||
preview = (await user.get(f"/projects/{project_id}/check-preview")).json()
|
||||
assert [d["id"] for d in preview["bid_documents"]] == [v2["id"]]
|
||||
fourth = await run_check(user, project_id, [v2["id"]])
|
||||
(k,) = await findings(user, fourth["id"])
|
||||
assert k["presence"] == "resolved" and fourth["counts"]["resolved_this_run"] == 1
|
||||
assert fourth["counts"]["fatal"] == 0 and fourth["counts"]["open_fatal"] == 0
|
||||
r = await user.post(f"/findings/{k['id']}/resolve", json={"status": "ignored", "note": "x"})
|
||||
assert r.status_code == 409 and r.json()["code"] == "finding_resolved_this_run"
|
||||
project = (await user.get(f"/projects/{project_id}")).json()
|
||||
assert project["readiness"]["ready"] is True and project["latest_check_run_id"] == fourth["id"]
|
||||
|
||||
|
||||
async def test_pro_plan_charges_then_waives_recheck(app_client: httpx.AsyncClient) -> None:
|
||||
u = await register(app_client)
|
||||
await use_pro_plan(u)
|
||||
project_id, tender_id = await project_with_tender(u)
|
||||
await run_parse(u, tender_id)
|
||||
bid = await upload_bid(u, project_id, "bid-mini-v1.pdf")
|
||||
assert await balance(u) == 2
|
||||
|
||||
preview = (await u.get(f"/projects/{project_id}/check-preview")).json()
|
||||
assert preview["billing"] == "charged" and preview["charge"] == 1 and preview["waive_until"] is None
|
||||
r = await u.post(f"/projects/{project_id}/check-runs", json={"document_ids": [bid["id"]], "expected_charge": 0})
|
||||
assert r.status_code == 409 and r.json()["code"] == "billing_changed" and r.json()["preview"]["charge"] == 1
|
||||
assert await balance(u) == 2 # 扣点说明和实际不一致:拒绝,不扣点(API4)
|
||||
|
||||
first = await run_check(u, project_id, [bid["id"]], expected=1)
|
||||
assert first["billing"] == "charged" and await balance(u) == 1
|
||||
|
||||
preview = (await u.get(f"/projects/{project_id}/check-preview")).json()
|
||||
assert preview["billing"] == "waived_recheck" and preview["charge"] == 0 and preview["waive_until"]
|
||||
second = await run_check(u, project_id, [bid["id"]], expected=0)
|
||||
assert second["billing"] == "waived_recheck" and await balance(u) == 1
|
||||
|
||||
from bid_doctor.core.db import TenantContext, tenant_tx
|
||||
|
||||
ctx = TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(str(u.me["user"]["id"])))
|
||||
async with tenant_tx(ctx) as s:
|
||||
rows = (
|
||||
await s.execute(
|
||||
text("SELECT kind, amount, reason FROM quota_entry WHERE ref_type = 'check_run' ORDER BY created_at")
|
||||
)
|
||||
).all()
|
||||
assert [tuple(x) for x in rows] == [("hold", -1, None), ("settle", 0, None), ("waive", 0, "recheck_24h")]
|
||||
|
||||
|
||||
async def test_unreadable_bid_fails_and_refunds(app_client: httpx.AsyncClient) -> None:
|
||||
"""投标文件读不出文字(扫描件):按输入问题失败,退回额度,不生成问题。"""
|
||||
u = await register(app_client)
|
||||
await use_pro_plan(u)
|
||||
project_id, tender_id = await project_with_tender(u)
|
||||
await run_parse(u, tender_id)
|
||||
bid = await upload_bid(u, project_id, "blank.pdf")
|
||||
run = await run_check(u, project_id, [bid["id"]], expected=1)
|
||||
assert run["status"] == "failed" and run["error"]["code"] == "bid_text_missing" and run["error"]["refunded"]
|
||||
assert await balance(u) == 2 and await findings(u, run["id"]) == []
|
||||
project = (await u.get(f"/projects/{project_id}")).json()
|
||||
assert project["status"] == "drafting" and project["latest_check_run_id"] is None
|
||||
|
||||
|
||||
async def test_duplicate_check_is_rejected(user: User) -> None:
|
||||
project_id, tender_id = await project_with_tender(user)
|
||||
await run_parse(user, tender_id)
|
||||
bid = await upload_bid(user, project_id, "bid-mini-v1.pdf")
|
||||
body = {"document_ids": [bid["id"]], "expected_charge": 0}
|
||||
assert (await user.post(f"/projects/{project_id}/check-runs", json=body)).status_code == 201
|
||||
r = await user.post(f"/projects/{project_id}/check-runs", json=body)
|
||||
assert r.status_code == 409 and r.json()["code"] == "job_already_running" and r.json()["job_id"]
|
||||
preview = (await user.get(f"/projects/{project_id}/check-preview")).json()
|
||||
assert "job_already_running" in preview["blockers"]
|
||||
|
||||
|
||||
async def test_platform_encrypted_bid_is_rejected(user: User) -> None:
|
||||
done = await upload(user, "投标文件.kedt", b"PK\x03\x04not-really-a-zip")
|
||||
assert done.status_code == 422 and done.json()["code"] == "bid_file_platform_encrypted"
|
||||
assert "苏采云" in done.json()["detail"]
|
||||
|
||||
|
||||
async def test_other_tenant_cannot_see_check(app_client: httpx.AsyncClient) -> None:
|
||||
a = await register(app_client, "甲公司")
|
||||
b = await register(app_client, "乙公司")
|
||||
project_id, _, run = await checked_project(a)
|
||||
(f,) = await findings(a, run["id"])
|
||||
for path in (
|
||||
f"/projects/{project_id}/check-preview",
|
||||
f"/projects/{project_id}/check-runs",
|
||||
f"/check-runs/{run['id']}",
|
||||
f"/check-runs/{run['id']}/item-results",
|
||||
):
|
||||
r = await b.get(path)
|
||||
assert r.status_code == 404, (path, r.status_code, r.text)
|
||||
assert (await b.get(f"/check-runs/{run['id']}/findings")).status_code == 404
|
||||
assert (await b.post(f"/findings/{f['id']}/resolve", json={"status": "modified"})).status_code == 404
|
||||
assert (await b.post(f"/findings/{f['id']}/reopen")).status_code == 404
|
||||
assert (await b.post(f"/check-runs/{run['id']}/cancel")).status_code == 404
|
||||
79
server/tests/test_check_units.py
Normal file
79
server/tests/test_check_units.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""查标的纯函数:检索、投标侧证据校验、问题级别(m3-plan §2)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from bid_doctor.check import findings, match
|
||||
from bid_doctor.check.retrieval import Index, tokens
|
||||
from bid_doctor.document.pipeline.text_layer import Sentence
|
||||
|
||||
|
||||
def sent(seq: int, text: str, page: int = 1) -> Sentence:
|
||||
return Sentence(key=f"doc:p{page}:s{seq}", page_no=page, seq=seq, text=text, quads=[[0, 0, 1, 1]])
|
||||
|
||||
|
||||
def test_tokens_keep_number_unit_and_model_names() -> None:
|
||||
t = tokens("服务器内存不低于32GB,支持10GE和硬件BFD")
|
||||
assert "32gb" in t and "10ge" in t and "bfd" in t and "内存" in t
|
||||
|
||||
|
||||
def test_bm25_ranks_the_responding_sentence_first() -> None:
|
||||
index = Index(
|
||||
[
|
||||
sent(1, "我公司提供7×24小时售后服务。"),
|
||||
sent(2, "服务器内存配置为64GB,并提供产品彩页。"),
|
||||
sent(3, "交换机支持硬件BFD,最小发送间隔3.3毫秒。"),
|
||||
]
|
||||
)
|
||||
(top, *_) = index.search("交换机支持硬件BFD 最小报文发送间隔不大于50毫秒", 3)
|
||||
assert top.sentence.seq == 3
|
||||
assert index.search("完全无关的词语组合", 3) == []
|
||||
|
||||
|
||||
def bid() -> tuple[match.BidDoc, dict[str, tuple[match.BidDoc, Sentence]]]:
|
||||
s = sent(9, "服务器内存不低于32GB的要求:我方服务器内存配置为64GB。")
|
||||
d = match.BidDoc(uuid4(), 1, "技术分册", [s])
|
||||
return d, {"d1p1s9": (d, s)}
|
||||
|
||||
|
||||
def test_responded_needs_verified_evidence() -> None:
|
||||
"""模型说已响应,但证据没通过:不能判已响应,记「未找到响应」并带原因码(C3)。"""
|
||||
_, allowed = bid()
|
||||
ok = match.judge(
|
||||
{"verdict": "responded", "evidence": [{"sid": "d1p1s9", "quote": "我方服务器内存配置为64GB。"}]}, allowed, []
|
||||
)
|
||||
assert ok.verdict == "responded" and ok.evidence_status == "verified" and len(ok.evidence) == 1
|
||||
|
||||
no_ev = match.judge({"verdict": "responded", "evidence": []}, allowed, [])
|
||||
assert (no_ev.verdict, no_ev.evidence_status, no_ev.reason_code) == ("not_found", "unverified", "evidence_missing")
|
||||
|
||||
rewritten = match.judge(
|
||||
{"verdict": "responded", "evidence": [{"sid": "d1p1s9", "quote": "内存为64GB,满足要求。"}]}, allowed, []
|
||||
)
|
||||
assert rewritten.reason_code == "quote_not_original" and rewritten.detail["claimed_pages"] == [1]
|
||||
|
||||
other = match.judge({"verdict": "responded", "evidence": [{"sid": "d1p2s1", "quote": "x" * 10}]}, allowed, [])
|
||||
assert other.reason_code == "sid_unknown"
|
||||
|
||||
missing = match.judge(None, allowed, ["技术分册"])
|
||||
assert (missing.verdict, missing.reason_code) == ("undetermined", "missing_answer")
|
||||
|
||||
|
||||
def test_severity_comes_from_the_item_not_the_model() -> None:
|
||||
assert findings.severity({"importance": "substantive", "categories": ["tech_param", "invalidity"]}) == "fatal"
|
||||
assert findings.severity({"importance": "normal", "categories": ["qualification"]}) == "fatal"
|
||||
assert findings.severity({"importance": "important", "categories": ["tech_param"]}) == "important"
|
||||
assert findings.severity({"importance": "normal", "categories": ["scoring"]}) == "important"
|
||||
assert findings.severity({"importance": "normal", "categories": ["tech_param"]}) == "hint"
|
||||
|
||||
|
||||
def test_no_value_is_a_hint_and_unverified_is_explained() -> None:
|
||||
item = {"title": "服务器内存", "importance": "substantive", "categories": ["tech_param"]}
|
||||
nv = findings.spec(match.Result("no_value", "model", "verified"), item)
|
||||
assert nv is not None and nv.severity == "hint" and nv.title.startswith("未给出具体值")
|
||||
uv = findings.spec(
|
||||
match.Result("not_found", "model", "unverified", "quote_not_original", detail={"claimed_pages": [3]}), item
|
||||
)
|
||||
assert uv is not None and uv.severity == "fatal" and "第 3 页" in uv.explanation and "没能核对" in uv.explanation
|
||||
assert findings.spec(match.Result("responded", "model", "verified"), item) is None
|
||||
Reference in New Issue
Block a user