feat(server): 参考 BidEngine 补五项:原因码、截断重跑、项目信息逐项表态、占位符、提示词规则对应校验
- 待确认记原因码(extract.FAILURES,存在 ai_values.failures),解析结果按原因计数 (verify_failures);前端待确认条目显示具体原因 - 相邻分块重叠约 800 字;模型输出被截断或不是合法 JSON 时对半重跑(最多 3 层), 拆到底仍不完整记 output_incomplete。修复:以前截断时整块条目被当成「没有结果」悄悄丢掉 - 项目基本信息提示词 v2:12 个字段逐项表态 found / ambiguous / not_found,跨分块合并为 found / absent / needs_review(info_fields) - 「____元」「××」「( )」「待填」等占位符不算取值 - 提示词 substantive v4,qualification / scoring / schedule / tech_param v3:每条禁止事项 对应一个原因码,对照表在 prompts/README.md - 顾问(Fable)深读参考项目的结论补进 ref-bid-engine-analysis.md §5;架构 §13 SK12 - 待确认:国动 4 → 2,S02 13 → 3,S06 57 → 55 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
|
||||
from bid_doctor.checklist.extract import (
|
||||
@@ -30,9 +32,10 @@ def test_evidence_checks() -> None:
|
||||
s = sent("★投标人须具备有效的信息系统集成服务资质证书。")
|
||||
by_id = {"p1s1": s}
|
||||
assert check_evidence({"sid": "p1s1", "quote": "投标人须具备有效的信息"}, by_id) == (None, [s])
|
||||
assert check_evidence({"sid": "p9s9", "quote": "投标人须具备有效的信息"}, by_id)[0] == "id"
|
||||
assert check_evidence({"sid": "p1s1", "quote": "投标人须具备"}, by_id)[0] == "quote" # 少于 8 个字
|
||||
assert check_evidence({"sid": "p1s1", "quote": "这句话是模型改写的"}, by_id)[0] == "quote"
|
||||
assert check_evidence({"sid": "p9s9", "quote": "投标人须具备有效的信息"}, by_id)[0] == "sid_unknown"
|
||||
assert check_evidence({"sid": "第1页", "quote": "投标人须具备有效的信息"}, by_id)[0] == "sid_format"
|
||||
assert check_evidence({"sid": "p1s1", "quote": "投标人须具备"}, by_id)[0] == "quote_too_short" # 少于 8 个字
|
||||
assert check_evidence({"sid": "p1s1", "quote": "这句话是模型改写的"}, by_id)[0] == "quote_not_original"
|
||||
|
||||
|
||||
def test_quote_may_run_into_next_sentence() -> None:
|
||||
@@ -41,9 +44,9 @@ def test_quote_may_run_into_next_sentence() -> None:
|
||||
b = sent("用户,连续30分钟压力测试成功率不低于99%。", seq=2)
|
||||
by_id = {"p1s1": a, "p1s2": b}
|
||||
assert check_evidence({"sid": "p1s1", "quote": "支持不少于200个并发用户"}, by_id, {a.key: b}) == (None, [a, b])
|
||||
assert check_evidence({"sid": "p1s1", "quote": "支持不少于200个并发用户"}, by_id)[0] == "quote"
|
||||
assert check_evidence({"sid": "p1s1", "quote": "支持不少于200个并发用户"}, by_id)[0] == "quote_not_original"
|
||||
# 必须从这一句开始,不能整段都在下一句里
|
||||
assert check_evidence({"sid": "p1s1", "quote": "连续30分钟压力测试"}, by_id, {a.key: b})[0] == "quote"
|
||||
assert check_evidence({"sid": "p1s1", "quote": "连续30分钟压力测试"}, by_id, {a.key: b})[0] == "quote_not_original"
|
||||
|
||||
|
||||
def test_split_listed_conditions() -> None:
|
||||
@@ -249,8 +252,8 @@ def test_quote_ignores_our_markup_and_end_punctuation() -> 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" # 只摘关键词照样退回
|
||||
assert check_evidence({"sid": "p1s1", "quote": "软件开发"}, {"p1s1": short})[0] == "quote_too_short"
|
||||
assert check_evidence({"sid": "p1s1", "quote": "价格扣除"}, by_id)[0] == "quote_too_short" # 只摘关键词照样退回
|
||||
|
||||
|
||||
def test_chinese_numbers_in_values() -> None:
|
||||
@@ -302,3 +305,100 @@ def test_model_input_marks_only_for_marking_task() -> None:
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 2026-11-03 参考 BidEngine 的五项改进
|
||||
|
||||
|
||||
def test_placeholder_is_not_a_value() -> None:
|
||||
from bid_doctor.checklist.extract import is_placeholder, value_issue
|
||||
|
||||
s = sent("最高限价:____万元,投标保证金:( )元。")
|
||||
assert is_placeholder("____") and is_placeholder("××万元") and is_placeholder("( )") and is_placeholder("待填")
|
||||
assert not is_placeholder("45") and not is_placeholder("0~5")
|
||||
assert value_issue("____", [s]) == "placeholder_value" # 空白在原文里「找得到」,但不是值
|
||||
assert value_issue("45", [s], "amount_unverified") == "amount_unverified"
|
||||
assert value_issue("45", [sent("最高限价:45万元。")]) is None
|
||||
|
||||
|
||||
def test_chunks_overlap_and_keep_continuations() -> None:
|
||||
from bid_doctor.checklist import extract as x
|
||||
|
||||
ss = [sent(f"第{i}条要求的内容写在这里,长度大约四十个字左右,用来撑满分块。" * 3, seq=i) for i in range(1, 200)]
|
||||
parts = x.chunks(ss, {})
|
||||
assert len(parts) > 2
|
||||
for a, b in itertools.pairwise(parts):
|
||||
tail = b[: len(b) - len([s for s in b if s not in a])]
|
||||
assert tail and tail == a[-len(tail) :] # 下一块开头是上一块的结尾几句
|
||||
assert len(tail) <= x.OVERLAP_SENTENCES
|
||||
# 每一句都至少在一个分块里
|
||||
assert {s.key for p in parts for s in p} == {s.key for s in ss}
|
||||
|
||||
|
||||
async def test_truncated_output_is_split_and_rerun(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""模型输出被截断:把分块对半重跑,拆到底还不完整就记下来;不能当成「这一块没有结果」。"""
|
||||
from uuid import UUID
|
||||
|
||||
from bid_doctor.ai import service as ai
|
||||
from bid_doctor.checklist import extract as x
|
||||
from bid_doctor.core.db import TenantContext
|
||||
|
||||
ss = [sent(f"★第{i}条:供应商须具备第{i}项能力,否则投标无效。", seq=i) for i in range(1, 9)]
|
||||
sizes: list[int] = []
|
||||
|
||||
async def fake_run_json(ctx: object, **kw: object) -> dict[str, object]:
|
||||
user = str(kw["user"])
|
||||
n = user.count("\n[p")
|
||||
sizes.append(n)
|
||||
if kw["task"] == "extract.substantive" and n > 2:
|
||||
raise ai.OutputIncomplete("truncated")
|
||||
if kw["task"] == "extract.substantive":
|
||||
lines = [ln for ln in user.splitlines() if ln.startswith("[p")]
|
||||
items = [
|
||||
{"summary": ln[8:20], "basis": "star", "evidence": [{"sid": ln[1:5].rstrip("]"), "quote": ln[7:]}]}
|
||||
for ln in lines
|
||||
]
|
||||
return {"items": items}
|
||||
return {"items": []} if kw["task"] != "extract.project_info" else {"fields": {}}
|
||||
|
||||
monkeypatch.setattr(x.ai, "run_json", fake_run_json)
|
||||
d = doc(declared=set(), sentences=ss)
|
||||
items, _, stats = await x.run(TenantContext(tenant_id=UUID(int=1), user_id=UUID(int=1)), UUID(int=2), d)
|
||||
assert stats["chunks_split"] >= 3 and stats.get("output_incomplete", 0) == 0
|
||||
assert {s.key for e in items if e.zone == "formal" for s in e.sentences} == {s.key for s in ss}
|
||||
|
||||
|
||||
def test_project_info_status() -> None:
|
||||
from bid_doctor.checklist.extract import _project_info # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
a = sent("项目名称:某市智慧政务平台建设项目。", seq=1)
|
||||
b = sent("最高限价:____万元。", seq=2)
|
||||
chunk = [a, b]
|
||||
found = {
|
||||
"status": "found",
|
||||
"value": "某市智慧政务平台建设项目",
|
||||
"sid": "p1s1",
|
||||
"quote": "项目名称:某市智慧政务平台建设项目。",
|
||||
}
|
||||
nf = {"status": "not_found", "value": None, "sid": None, "quote": None}
|
||||
fields = {
|
||||
"name": found,
|
||||
"ceiling_total": {
|
||||
"status": "found",
|
||||
"value": "____",
|
||||
"unit": "万元",
|
||||
"sid": "p1s2",
|
||||
"quote": "最高限价:____万元。",
|
||||
},
|
||||
"budget_total": {"status": "ambiguous", "value": None},
|
||||
"code": {**nf, "sid": "p1s1"},
|
||||
**{k: nf for k in ("purchaser", "agency", "procurement_method", "legal_regime", "eval_method")},
|
||||
**{k: nf for k in ("accept_consortium", "accept_imported")},
|
||||
} # sme_only 漏答
|
||||
info = _project_info([(chunk, {"fields": fields})])
|
||||
st = {k: v["status"] for k, v in info.status.items()}
|
||||
assert st["name"] == "found" and st["purchaser"] == "absent"
|
||||
assert info.status["ceiling_total"] == {"status": "needs_review", "codes": ["placeholder_value"]}
|
||||
assert info.status["budget_total"]["codes"] == ["ambiguous"]
|
||||
assert info.status["code"]["codes"] == ["not_found_with_evidence"]
|
||||
assert info.status["sme_only"]["codes"] == ["missing_answer"]
|
||||
|
||||
Reference in New Issue
Block a user