feat(check): 同一行按单元格;业绩条款不再误跳;重复条目只合并显示(C5 修订)
- 送给模型的「同一行」在有框线的表格里按单元格分行 - 「以合同签订时间为准」不再被当成「签订合同时才交」,要附合同的业绩评分项照常查 - 重复的要求各自判断,问题接口给出 duplicate_ids,查标页同组折叠显示、一起处理 - 决策 C5 修订记入 decisions.md;开发集召回 74%、致命级 25/32 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,8 @@ from bid_doctor.checklist.response_mode import Mode
|
||||
|
||||
# 要求投标时附证明材料(读标的 evidence_required 常常是空的,按原文判断);中标后 / 签合同时才交的不算
|
||||
EVIDENCE = re.compile(r"(提供|附|出具)[^。;]{0,20}(证明|报告|截图|复印件|扫描件|证书|合同|承诺|声明|社保|发票)")
|
||||
AWARD = re.compile(r"(中标后|成交后|签订合同(前|时)|合同签订(前|时))")
|
||||
# 「签订合同时提供」才算中标后;「以合同签订时间为准」(业绩条款常见)不是
|
||||
AWARD = re.compile(r"(中标后|成交后|签订合同(前|时)(?!间)|合同签订(前|时)(?!间))")
|
||||
NO_PROOF = re.compile(r"无需提供|不需提供|无须提供|采购人[^。;]{0,12}(查询|核查|查验)|信用中国|信用记录")
|
||||
SKIP_LABELS = {
|
||||
"condition": "无效情形和评审规定",
|
||||
|
||||
@@ -25,6 +25,7 @@ from bid_doctor.ai.providers.base import TransientError
|
||||
from bid_doctor.check import presence, reuse
|
||||
from bid_doctor.check.retrieval import Index
|
||||
from bid_doctor.core.db import TenantContext
|
||||
from bid_doctor.document.pipeline import layout
|
||||
from bid_doctor.document.pipeline.text_layer import Sentence, norm
|
||||
|
||||
PROMPT = ("check.match", 2)
|
||||
@@ -143,8 +144,11 @@ def _item_block(key: str, item: dict[str, Any], sids: list[str]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def row_mates(s: Sentence, page: list[Sentence]) -> list[Sentence]:
|
||||
"""同一行的其他句子:同一页、上下范围重叠(和读标核对取值时找「同一行」是同一口径)。按从左到右排。"""
|
||||
def row_mates(s: Sentence, page: list[Sentence], cell_rows: dict[int, list[Sentence]] | None = None) -> list[Sentence]:
|
||||
"""同一行的其他句子,从左到右。在有框线的表格里按单元格分行(cell_rows,版面工具算好的):垂直居中的短格、
|
||||
折成几句的长格不会串到上下行;其余按同一页上下范围重叠(和读标核对取值时找「同一行」是同一口径)。"""
|
||||
if s.cell and cell_rows is not None and id(s) in cell_rows:
|
||||
return [x for x in cell_rows[id(s)] if x is not s][:ROW_MATES]
|
||||
if not s.quads:
|
||||
return []
|
||||
top, bottom = min(q[1] for q in s.quads), max(q[3] for q in s.quads)
|
||||
@@ -246,6 +250,7 @@ async def run(
|
||||
for s in d.sentences:
|
||||
pages.setdefault((d.id, s.page_no), []).append(s)
|
||||
rows: dict[str, list[str]] = {} # 候选句 → 同一行的单元格(短编号)
|
||||
cell_rows = {id(c): row for page in pages.values() for row in layout.rows(page) for c in row if c.cell}
|
||||
sent_of = {
|
||||
short_sid(d, x): (d, x) for d in docs for x in d.sentences
|
||||
} # 片段按编号查(同一行的单元格可能是别的条目的候选句)
|
||||
@@ -266,7 +271,7 @@ async def run(
|
||||
sid = short_sid(doc, h.sentence)
|
||||
allowed[sid] = (doc, h.sentence)
|
||||
hits_of.setdefault(item_id, []).append(sid)
|
||||
mates = row_mates(h.sentence, pages[(doc.id, h.sentence.page_no)])
|
||||
mates = row_mates(h.sentence, pages[(doc.id, h.sentence.page_no)], cell_rows)
|
||||
rows[sid] = [short_sid(doc, m) for m in mates]
|
||||
for m in mates: # 同一行的单元格也可以引用(响应、偏离常写在那里)
|
||||
allowed.setdefault(short_sid(doc, m), (doc, m))
|
||||
|
||||
@@ -30,6 +30,7 @@ from bid_doctor.core.ids import uuid7
|
||||
from bid_doctor.document import citations
|
||||
from bid_doctor.document import service as documents
|
||||
from bid_doctor.document.models import SourceDocument
|
||||
from bid_doctor.document.pipeline.text_layer import norm
|
||||
from bid_doctor.identity import service as identity
|
||||
from bid_doctor.ops import service as feedback
|
||||
from bid_doctor.project import service as projects
|
||||
@@ -289,6 +290,14 @@ async def _to_api_many(s: AsyncSession, run: CheckRun, rows: list[Finding]) -> l
|
||||
cites = await citations.to_api_many(s, [link.citation_id for ls in by_finding.values() for link in ls])
|
||||
names = await identity.display_names(s, [f.resolved_by for f in rows if f.resolved_by])
|
||||
sort_keys = await checklist.sort_keys(s, [f.checklist_item_id for f in rows if f.checklist_item_id])
|
||||
# 重复的要求(决策 C5 修订):每条照常单独判断,问题类型相同、要求原文完全相同的折叠显示
|
||||
groups: dict[tuple[str, str], list[UUID]] = {}
|
||||
for f in rows:
|
||||
item = items.get(f.checklist_item_id) if f.checklist_item_id else None
|
||||
text = norm(str((item or {}).get("requirement_text") or (item or {}).get("title") or ""))
|
||||
if item and f.presence != "resolved" and len(text) >= 6:
|
||||
groups.setdefault((f.type, text), []).append(f.id)
|
||||
same = {fid: [x for x in ids if x != fid] for ids in groups.values() if len(ids) > 1 for fid in ids}
|
||||
|
||||
def api(f: Finding) -> dict[str, Any]:
|
||||
item = items.get(f.checklist_item_id) if f.checklist_item_id else None
|
||||
@@ -315,6 +324,7 @@ async def _to_api_many(s: AsyncSession, run: CheckRun, rows: list[Finding]) -> l
|
||||
"tender_citations": [cites[x.citation_id] for x in ls if x.side == "tender" and x.citation_id in cites],
|
||||
"bid_citations": [cites[x.citation_id] for x in ls if x.side == "bid" and x.citation_id in cites],
|
||||
"search_scope": f.search_scope,
|
||||
"duplicate_ids": same.get(f.id, []),
|
||||
"presence": f.presence,
|
||||
"carried_status": (f.detail or {}).get("carried_status"),
|
||||
"status": f.status,
|
||||
|
||||
@@ -360,3 +360,19 @@ async def test_recheck_reuses_unchanged_model_results(user: User) -> None:
|
||||
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") # 沿用的结论证据也找回来了
|
||||
|
||||
|
||||
async def test_duplicate_requirements_are_checked_separately_and_grouped_for_display(user: User) -> None:
|
||||
"""重复的要求(决策 C5 修订):各自判断、各出一个问题,问题接口给出同组的其他问题,界面折叠显示。"""
|
||||
project_id, tender_id = await project_with_tender(user)
|
||||
await run_parse(user, tender_id)
|
||||
for _ in range(2):
|
||||
r = await user.post(
|
||||
f"/projects/{project_id}/checklist/items",
|
||||
json={"categories": ["qualification"], "title": "须提供船舶防爆检验报告"},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
bid = await upload_bid(user, project_id, "bid-mini-v1.pdf")
|
||||
run = await run_check(user, project_id, [bid["id"]])
|
||||
dup = [f for f in await findings(user, run["id"]) if "船舶防爆" in f["title"]]
|
||||
assert len(dup) == 2 and dup[0]["duplicate_ids"] == [dup[1]["id"]] and dup[1]["duplicate_ids"] == [dup[0]["id"]]
|
||||
|
||||
@@ -279,3 +279,17 @@ async def test_over_budget_items_are_undetermined_without_model_calls() -> None:
|
||||
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)
|
||||
|
||||
|
||||
def test_award_time_wording_is_not_award_stage_evidence() -> None:
|
||||
"""「以合同签订时间为准」是业绩的时间口径,不是「签订合同时才交」:要附合同的评分项照常查(check-eval §9)。"""
|
||||
from bid_doctor.check import applicability
|
||||
|
||||
scoring = {
|
||||
"categories": ["scoring"],
|
||||
"title": "项目业绩(10 分)",
|
||||
"requirement_text": "提供2023年以来类似项目业绩,以合同签订时间为准,需提供合同复印件。",
|
||||
}
|
||||
assert applicability.decide(scoring, {}).match
|
||||
later = {**scoring, "requirement_text": "签订合同时提供业绩合同原件备查。"}
|
||||
assert applicability.decide(later, {}).skip == "scoring"
|
||||
|
||||
Reference in New Issue
Block a user