feat(check): 偏离栏直读、响应错位判断;复查复用和硬预算(C2)

- deviation.py:模型判已响应的条目,证据所在表格行的偏离格写着「偏离 / 部分满足」等
  就改判负偏离;照抄要求格、响应格写的是别的内容的改判「响应可能对不上」
- reuse.py:按「提示词 + 要求 + 候选句原文」算缓存键,存模型原始结论;复查时内容没变、
  证据找得回的条目直接沿用,不调模型
- 每次检查送给模型的字数有上限,超出的条目列为请人工核对,写进覆盖范围
- 开发集 17 份:召回 61% → 73%,负偏离未说明 64% → 90%,规矩投标文件问题数不变

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 17:47:12 +08:00
parent 81c58798d2
commit df3ebbb6a1
12 changed files with 430 additions and 23 deletions

View File

@@ -339,3 +339,24 @@ async def test_manual_checks_are_kept_per_project(user: User) -> None:
assert any("列入「需人工核对」2 条" in x for x in again["coverage"]["not_checked"])
kept = (await user.get(f"/projects/{project_id}/manual-checks")).json()["items"]
assert [i["checked"] for i in kept] == [True, False]
async def test_recheck_reuses_unchanged_model_results(user: User) -> None:
"""复查复用(C2):同一份投标文件再查,送给模型的内容没变,模型那一层沿用上次的结论,不再调模型。"""
from bid_doctor.ai.models import AiCall
from bid_doctor.core.db import TenantContext, tenant_tx
project_id, bid, first = await checked_project(user)
second = await run_check(user, project_id, [bid["id"]])
assert second["counts"]["fatal"] == first["counts"]["fatal"]
reused = [x for x in second["coverage"]["checked"] if "沿用上次的结论" in x]
assert reused, second["coverage"]["checked"]
ctx = TenantContext(tenant_id=UUID(user.tenant_id), user_id=UUID(str(user.me["user"]["id"])))
async with tenant_tx(ctx) as s:
from sqlalchemy import func, select
n = (await s.execute(select(func.count()).where(AiCall.job_id == UUID(second["id"])))).scalar_one()
assert n == 0
results = (await user.get(f"/check-runs/{second['id']}/item-results")).json()["items"]
assert all(r["bid_citations"] for r in results if r["verdict"] == "responded") # 沿用的结论证据也找回来了

View File

@@ -191,3 +191,91 @@ def test_manual_topics_merge_by_topic_and_skip_irrelevant() -> None:
assert list(got) == ["DECRYPT", "SIGN_ENCRYPT", "PAPER", "SUBMIT"]
assert got["SIGN_ENCRYPT"] == ["须用CA数字证书加密投标文件"]
assert got["SUBMIT"][0].startswith("投标文件递交截止") # 写明截止时间的做代表
def test_direct_read_turns_negative_deviation_cell_into_finding() -> None:
"""偏离栏直读:证据句所在的表格行里偏离格写「偏离」,模型判了已响应也改判负偏离(check-eval §8)。"""
from bid_doctor.check import deviation
def c(seq: int, text: str, x0: float, y: float) -> Sentence:
s = Sentence(key=f"b:p1:s{seq}", page_no=1, seq=seq, text=text, quads=[[x0 + 2, y + 4, x0 + 90, y + 16]])
s.cell = [x0, y, x0 + 100, y + 30]
return s
req, resp, dev = c(1, "支持链路聚合", 100, 50), c(2, "满足,支持链路聚合", 200, 50), c(3, "偏离", 300, 50)
req2, resp2, dev2 = c(4, "支持堆叠", 100, 80), c(5, "满足,支持堆叠", 200, 80), c(6, "无偏离", 300, 80)
prose = Sentence(
key="b:p1:s7", page_no=1, seq=7, text="如不满足上述要求,按负偏离处理。", quads=[[74, 120, 500, 132]]
)
d = match.BidDoc(uuid4(), 1, "技术分册", [req, resp, dev, req2, resp2, dev2, prose])
results = {
uuid4(): match.Result("responded", "ai", "verified", evidence=[(d, resp)]),
uuid4(): match.Result("responded", "ai", "verified", evidence=[(d, resp2)]),
uuid4(): match.Result("responded", "ai", "verified", evidence=[(d, prose)]),
}
assert deviation.direct_read(results, [d]) == 1
first, second, third = results.values()
assert first.verdict == "negative_deviation" and [s.text for _, s in first.evidence] == [
"满足,支持链路聚合",
"偏离",
]
assert first.detail["model_verdict"] == "responded"
assert second.verdict == third.verdict == "responded"
def test_mismatch_read_only_when_response_cell_is_unrelated() -> None:
"""响应对象错位(D09):证据只是照抄的要求格、同一行响应格写的是别的内容 → 未找到响应。
指引、相关内容、只写满足的不算。"""
from bid_doctor.check import deviation
def c(seq: int, text: str, x0: float, y: float) -> Sentence:
s = Sentence(key=f"b:p1:s{seq}", page_no=1, seq=seq, text=text, quads=[[x0 + 2, y + 4, x0 + 90, y + 16]])
s.cell = [x0, y, x0 + 100, y + 30]
return s
rows = [
("支持云管理、VLAN隔离、标准交换三种工作模式", "显示器升降行程:150mm"),
("支持端口汇聚、端口流量统计", "满足,支持端口汇聚和端口流量统计"),
("批量数据处理在非工作时段内完成", "满足,详见技术方案第12页"),
("系统支持国产操作系统", "满足"),
]
sents, results, items = [], {}, {}
for n, (req, resp) in enumerate(rows):
a, b, dev = (
c(n * 3 + 1, req, 100, 50 + n * 40),
c(n * 3 + 2, resp, 200, 50 + n * 40),
c(n * 3 + 3, "无偏离", 300, 50 + n * 40),
)
sents += [a, b, dev]
k = uuid4()
items[k] = {"title": req, "requirement_text": req}
results[k] = (a, b)
d = match.BidDoc(uuid4(), 1, "技术分册", sents)
res = {k: match.Result("responded", "ai", "verified", evidence=[(d, a)]) for k, (a, _) in results.items()}
assert deviation.mismatch_read(res, items, [d]) == 1
verdicts = [r.verdict for r in res.values()]
assert verdicts == ["not_found", "responded", "responded", "responded"]
first = next(iter(res.values()))
assert first.detail["mismatch"] == "显示器升降行程:150mm" and first.reason_code == "response_mismatch"
async def test_over_budget_items_are_undetermined_without_model_calls() -> None:
"""硬预算(C2):超出预算的批次不调模型,条目记「超出预算」,覆盖范围另外写明。"""
from bid_doctor.core.db import TenantContext
d, _ = bid()
items = {
uuid4(): {
"title": "服务器内存不低于32GB",
"requirement_text": "服务器内存不低于32GB",
"categories": ["tech_param"],
}
}
async def progress(_: float) -> None:
return None
ctx = TenantContext(tenant_id=uuid4(), user_id=uuid4())
results, stats = await match.run(ctx, uuid4(), items, [d], progress, budget=10)
(r,) = results.values()
assert (r.verdict, r.reason_code, stats.calls, stats.over_budget) == ("undetermined", "over_budget", 0, 1)