feat(check): 文件要在、扫描件兜底(C10);响应方式存进清单并可改(C12)

- check/presence.py:「提供《授权委托书》」「须提交投标函」这类要求按标题里有没有这个文件名判断,不调模型。
- 扫描件兜底:要附证明材料的要求没找到、投标文件里有扫描件页时,改为提示级「可能在扫描件里,请人工核对」。
- 响应方式解析时识别、带依据句子存进 checklist.response_modes(迁移 0008);清单页显示、可改(写审计,
  重新解析不覆盖人工改过的);查标用存下来的。新接口 PUT /projects/{id}/checklist/response-modes/{category}。
- check_item_result.layer 加 presence(迁移 0007);契约同步默认接受的两种结论。

开发集 17 份:规矩投标文件的致命问题 120 → 51,致命级召回不变(22 / 32)(docs/architecture/check-eval.md §5)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 15:53:45 +08:00
parent cb0c2fcafe
commit 47c6a1258e
21 changed files with 529 additions and 24 deletions

View File

@@ -85,7 +85,11 @@ def spec(result: Result, item: dict[str, Any]) -> Spec | None:
text = "投标文件只写了满足或响应,没有给出要求里要的具体值或材料。"
return Spec("suspected_unmet", "hint", f"未给出具体值:{name}", text, {**base, "kind": "no_value"})
if result.verdict == "scanned":
text = "候选内容所在的页面是扫描件,系统读不出文字,请人工核对。"
return Spec("manual_review", sev, f"请人工核对:{name}", text, base)
total = result.detail.get("scanned_total") or 0
text = (
f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字,"
"材料可能在扫描件页上,请人工核对。"
)
return Spec("manual_review", "hint", f"请人工核对(可能在扫描件里):{name}", text, base)
why = REASONS.get(result.reason_code or "", result.reason_code or "")
return Spec("manual_review", sev, f"未判定:{name}", f"这条要求本次没有完成判断({why}),请人工核对。", base)

View File

@@ -21,6 +21,7 @@ from uuid import UUID
from bid_doctor.ai import service as ai
from bid_doctor.ai.providers.base import TransientError
from bid_doctor.check import presence
from bid_doctor.check.retrieval import Index
from bid_doctor.core.db import TenantContext
from bid_doctor.document.pipeline.text_layer import Sentence, norm
@@ -51,6 +52,7 @@ class BidDoc:
no: int # 在本次检查里的序号,组成短编号 d<序号>p<页>s<句>
label: str
sentences: list[Sentence]
scanned_pages: list[int] = field(default_factory=list[int]) # 没有文字层的页(扫描件),C10
@dataclass(slots=True)
@@ -86,6 +88,23 @@ def apply_modes(results: dict[UUID, Result], items: dict[UUID, dict[str, Any]],
r.detail = {**r.detail, "mode": "implicit"}
def scanned_fallback(
results: dict[UUID, Result], items: dict[UUID, dict[str, Any]], modes: dict[UUID, Any], docs: list[BidDoc]
) -> None:
"""扫描件兜底(C10):要附证明材料的要求没找到,而投标文件里有扫描件页时,不报「未找到响应」,
改成「可能在扫描件页上,请人工核对」。没有 OCR 时,扫描件里有没有这份材料分不出来。"""
pages = [f"{d.label} 第 {p} 页" for d in docs for p in d.scanned_pages]
if not pages:
return
for item_id, d in modes.items():
r = results.get(item_id)
cats = set(items.get(item_id, {}).get("categories") or [])
if r is None or r.verdict != "not_found" or not (d.evidence or cats & {"qualification", "scoring"}):
continue
r.verdict, r.evidence_status, r.reason_code = "scanned", "none", None
r.detail = {**r.detail, "scanned_pages": pages[:30], "scanned_total": len(pages)}
def short_sid(doc: BidDoc, s: Sentence) -> str:
return f"d{doc.no}p{s.page_no}s{s.seq}"
@@ -217,6 +236,11 @@ async def run(
} # 片段按编号查(同一行的单元格可能是别的条目的候选句)
hits_of: dict[UUID, list[str]] = {} # 每条清单项自己的候选句(同一行的单元格跟在后面显示,不单列)
for item_id, item in items.items():
wanted = presence.names(f"{item.get('title') or ''}{item.get('requirement_text') or ''}")
found = [(d, x) for n in wanted for d in docs if (x := presence.find(n, d.sentences))]
if wanted and len({id(x) for _, x in found}) >= len(wanted): # 点名要交的文件都在:按文件在不在判断,不调模型
results[item_id] = Result("responded", "presence", "verified", evidence=found[: len(wanted)])
continue
hits = index.search(query_text(item), CANDIDATES)
if not hits: # 检索不到候选句:不调模型(m3-plan §2.1)
results[item_id] = Result("not_found", "retrieval", detail={"search_scope": scope})

View File

@@ -0,0 +1,31 @@
"""「文件要在」类要求(presence):「提供《授权委托书》」「须提交投标函」——按文件在不在判断,不调模型(C12、C10)。
从要求原文里取出文件名,在投标文件里找写着这个文件名的句子(通常是标题,短句优先)。找到就算已响应,出处是那一句。
找不到交给后面的检索 + 模型判断;扫描件里的文件(营业执照、证书复印件)这里找不到,由扫描件兜底处理。
"""
from __future__ import annotations
import re
from bid_doctor.document.pipeline.text_layer import Sentence, norm
QUOTED = re.compile(r"《([^》]{2,30})》")
NAMED = re.compile(
r"(?:提供|提交|附|出具)(?:加盖[^,。;]{0,10}的)?([一-鿿()()]{2,16}?(?:函|书|声明|承诺|一览表|明细表|偏离表|响应表))"
)
MAX_TITLE_LEN = 60 # 标题一般不长;正文里顺带提到文件名不算这份文件在
def names(text: str) -> list[str]:
"""要求里点名要交的文件。"""
found = [m.group(1) for m in QUOTED.finditer(text)] or [m.group(1) for m in NAMED.finditer(text)]
return list(dict.fromkeys(re.sub(r"[((].*?[))]", "", n).strip() for n in found if n))
def find(name: str, sentences: list[Sentence]) -> Sentence | None:
key = norm(name)
if len(key) < 3:
return None
hits = [s for s in sentences if key in norm(s.text) and len(norm(s.text)) <= MAX_TITLE_LEN]
return min(hits, key=lambda s: len(s.text)) if hits else None

View File

@@ -119,7 +119,15 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
for no, d in enumerate(docs, start=1):
pages = await normalize.pages(ctx, d.id, on_stage, reuse=True, require_text=False)
label = document_label(d.role, d.seq_no, d.volume_name, d.volume_kind)
bid_docs.append(match.BidDoc(d.id, no, label, text_layer.body_sentences(pages)))
bid_docs.append(
match.BidDoc(
d.id,
no,
label,
text_layer.body_sentences(pages),
[p.page_no for p in pages if p.text_source == "empty"],
)
)
pages_info.append(
{
"id": str(d.id),
@@ -138,6 +146,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
snapshot = await checklist_edit.version_items(s, r.checklist_version_id)
lots = await projects.lot_labels(s, r.project_id)
pending = (await checklist.pending_counts(s, [r.project_id])).get(r.project_id, 0)
stored_modes = list((await checklist.get_or_create(s, ctx, r.project_id)).response_modes or [])
tenders = (
await s.execute(
select(SourceDocument).where(
@@ -154,7 +163,8 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
[text_layer.Page.from_json(pg) for pg in await documents.text_layer(s, ctx, d)]
)
]
modes = response_mode.detect(tender_sents) # 招标文件按类别的响应方式(C12)
# 招标文件按类别的响应方式(C12):用清单里存的(人工改过的为准);解析早于这项功能的项目临时识别
modes = response_mode.from_stored(stored_modes) if stored_modes else response_mode.detect(tender_sents)
bidding = set(r.lot_ids)
items, other_lot = {}, 0
for item_id, payload in snapshot.items():
@@ -179,6 +189,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
results, stats = await match.run(ctx, run_id, items, bid_docs, progress)
match.apply_modes(results, items, decisions)
match.scanned_fallback(results, items, decisions, bid_docs)
await _stage(ctx, run_id, "rules") # 第 2 步:报价形式、形式要件、需人工核对
await _stage(ctx, run_id, "verify") # 前两道校验在匹配时逐条做完;这里只是进度节点

View File

@@ -6,7 +6,7 @@ from uuid import UUID
from fastapi import APIRouter, Header, Response
from pydantic import BaseModel, ConfigDict, Field
from bid_doctor.checklist import edit, service
from bid_doctor.checklist import edit, response_mode, service
from bid_doctor.core.db import tenant_tx
from bid_doctor.identity.deps import Ctx
from bid_doctor.project import service as projects
@@ -143,3 +143,20 @@ async def list_versions(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
await projects.get(s, ctx, project_id)
return {"items": await edit.versions(s, project_id)}
class ResponseModeIn(BaseModel):
mode: Literal["explicit", "implicit"]
@router.put("/projects/{project_id}/checklist/response-modes/{category}")
async def set_response_mode(
project_id: UUID, category: Literal["technical", "commercial"], body: ResponseModeIn, ctx: Ctx
) -> dict[str, Any]:
"""改招标文件某一类要求的响应方式(C12 的人工确认点):查标按它判「没找到响应算不算问题」。"""
async with tenant_tx(ctx) as s:
projects.ensure_editable(await projects.get(s, ctx, project_id))
cl = await service.get_or_create(s, ctx, project_id)
await response_mode.set_mode(s, ctx, cl, category, body.mode)
await s.flush()
return await service.overview(s, project_id)

View File

@@ -17,6 +17,7 @@ class Checklist(Base):
status: Mapped[str] = mapped_column(server_default=FetchedValue())
last_version_no: Mapped[int] = mapped_column(server_default=FetchedValue())
confirmed_version_id: Mapped[UuidCol | None]
response_modes: Mapped[Json] = mapped_column(server_default=FetchedValue())
updated_at: Mapped[DbTs]

View File

@@ -5,14 +5,24 @@
「其余无偏离内容无需赘述」;
- 两种写法都有(S07)按逐条处理,记 conflict,问题说明里引两处原文;一种都没有按逐条处理,记 unknown。
规则优先、纯函数;结果带原文句子,查标写进检查覆盖范围。
TODO(C12):存进清单、在清单页显示并可改(人工确认点);模型兜底规则没认出的写法。
解析时识别并存进清单(checklist.response_modes),清单页显示、可改(人工确认点);查标用存下来的。
TODO(C12):模型兜底规则没认出的写法。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.core.db import TenantContext
from bid_doctor.document import citations
from bid_doctor.document.models import SourceDocument
from bid_doctor.document.pipeline.text_layer import Sentence
EXPLICIT = re.compile(r"逐(条|项|一)[^。;]{0,15}(响应|应答|标明|填写|填列|作出|做出|说明)")
@@ -61,3 +71,64 @@ def detect(sentences: list[Sentence]) -> dict[str, Mode]:
elif m.explicit:
m.mode, m.status = "explicit", "found"
return hits
MAX_BASIS = 2 # 每类每种写法存几句依据
async def save(s: AsyncSession, ctx: TenantContext, cl: Any, doc: SourceDocument, sentences: list[Sentence]) -> None:
"""解析完一份招标文件后识别并存进清单。人工改过的类别不覆盖;补遗、澄清只在写明了响应方式时更新。"""
current = {m["category"]: m for m in cl.response_modes or []}
for c, m in detect(sentences).items():
old = current.get(c)
if old and old.get("origin") == "human":
continue
if doc.role != "tender" and m.status == "unknown":
continue
cites = []
for group in (m.explicit[:MAX_BASIS], m.implicit[:MAX_BASIS]):
for x in group:
cites += await citations.from_sentences(s, ctx, doc, [x], "rule", [])
current[c] = {
"category": c,
"mode": m.mode,
"status": m.status,
"origin": "rule",
"citation_ids": [str(x.id) for x in cites],
}
cl.response_modes = [current[c] for c in CATEGORIES if c in current]
def from_stored(stored: list[dict[str, Any]]) -> dict[str, Mode]:
"""查标用:存下来的响应方式(人工改过的为准)。"""
return {
m["category"]: Mode(
m["category"], m["mode"], "human" if m.get("origin") == "human" else m.get("status", "found")
)
for m in stored
}
async def set_mode(s: AsyncSession, ctx: TenantContext, cl: Any, category: str, mode: str) -> None:
"""用户改某一类的响应方式(人工确认点):记谁在什么时候改的,写审计。"""
current = {m["category"]: m for m in cl.response_modes or []}
before = current.get(category)
current[category] = {
**(before or {"category": category, "citation_ids": []}),
"mode": mode,
"status": "found",
"origin": "human",
"set_by": str(ctx.user_id),
"set_at": datetime.now(UTC).isoformat(),
}
cl.response_modes = [current[c] for c in CATEGORIES if c in current]
await audit.record(
s,
ctx,
"checklist.response_mode",
"checklist",
UUID(str(cl.id)),
project_id=cl.project_id,
before={"category": category, "mode": before.get("mode") if before else None},
after={"category": category, "mode": mode},
)

View File

@@ -208,7 +208,24 @@ async def overview(s: AsyncSession, project_id: UUID) -> dict[str, Any]:
for r, *_ in rules
if r.declared_count is not None and r.declared_count != found_by_rule.get(r.id, 0)
]
stored_modes = list(cl.response_modes or []) if cl else []
mode_cites = await citations.to_api_many(s, [UUID(c) for m in stored_modes for c in m.get("citation_ids") or []])
mode_names = await identity.display_names(s, [UUID(m["set_by"]) for m in stored_modes if m.get("set_by")])
return {
"response_modes": [
{
"category": m["category"],
"mode": m["mode"],
"status": m.get("status", "found"),
"origin": m.get("origin", "rule"),
"citations": [mode_cites[UUID(c)] for c in m.get("citation_ids") or [] if UUID(c) in mode_cites],
"set_by": {"id": m["set_by"], "name": mode_names.get(UUID(m["set_by"]), "")}
if m.get("set_by")
else None,
"set_at": m.get("set_at"),
}
for m in stored_modes
],
"status": cl.status if cl else "draft",
"version_no": cl.last_version_no if cl else 0,
"confirmed_by": {"id": version.created_by, "name": names.get(version.created_by, "")} if version else None,

View File

@@ -18,7 +18,7 @@ from sqlalchemy import select
from bid_doctor.ai.providers.base import TransientError
from bid_doctor.billing import service as billing
from bid_doctor.checklist import extract
from bid_doctor.checklist import extract, response_mode
from bid_doctor.checklist import service as checklist
from bid_doctor.core import events
from bid_doctor.core.db import TenantContext, tenant_tx
@@ -138,6 +138,8 @@ async def _stages(ctx: TenantContext, job_id: UUID) -> dict[str, Any]:
d = (await s.execute(select(SourceDocument).where(SourceDocument.id == doc.id))).scalar_one()
lots = await project_extracted.apply(s, ctx, d, info, extract.legal_regime_by_rule(body), items)
stats = await checklist.merge(s, ctx, d, job_id, items, notes, lots)
cl = await checklist.get_or_create(s, ctx, d.project_id)
await response_mode.save(s, ctx, cl, d, body) # 招标文件的响应方式(C12),清单页显示、可改
return {
**stats,
"lots_found": len([k for k in lots if k != "默认"]),