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:
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
31
server/src/bid_doctor/check/presence.py
Normal file
31
server/src/bid_doctor/check/presence.py
Normal 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
|
||||
@@ -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") # 前两道校验在匹配时逐条做完;这里只是进度节点
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 != "默认"]),
|
||||
|
||||
Reference in New Issue
Block a user