feat(check): 规则检查(报价形式、形式要件、响应表),表格按单元格分行

- check/rules.py(规则集 2026.11-r1,不调模型):大小写金额、分项合计、超最高限价、
  多个报价、别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应;
  每条挂投标原文,有依据的挂招标侧出处;缺限价、截止时间、有效期要求时写进覆盖范围
- 文字层的句子带上单元格框,表格按单元格上沿分行
- 按 Fable 审读意见收紧致命级:认不准的降为重要或提示
- 评测:score.py 评规则问题;P07 注入记录改为空白处前后原文,剔除注入到人员表的 D10
- 上传页的检查范围文案同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 16:44:22 +08:00
parent 47c6a1258e
commit db1b1f6f9f
17 changed files with 1792 additions and 20 deletions

View File

@@ -53,6 +53,7 @@ class BidDoc:
label: str
sentences: list[Sentence]
scanned_pages: list[int] = field(default_factory=list[int]) # 没有文字层的页(扫描件),C10
header_footer: list[Sentence] = field(default_factory=list[Sentence]) # 页眉页脚(规则检查看页眉里的项目名称)
@dataclass(slots=True)

File diff suppressed because it is too large Load Diff

View File

@@ -3,15 +3,17 @@
- 和解析任务同一套做法(架构 §3.4):阶段之间检查取消标记;结局由错误类别决定扣不扣点,失败、取消都退回。
- 健全性检查:投标文件几乎读不出文字时按输入问题失败,不扣点、不生成问题(参考项目的同类闸门)。
- 认不出、查不了、没查的都写进检查覆盖范围(coverage),不静默跳过(m3-plan §2.5)。
TODO(第 2 步):规则检查(报价形式、形式要件、需人工核对);投标侧结构识别(偏离表、开标一览表、格式文件)。
- 规则检查(报价形式、形式要件)在 rules.py,项目侧的值(限价、截止时间、有效期要求)带招标侧出处,见 _facts。
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
from zoneinfo import ZoneInfo
import httpx
from sqlalchemy import delete, func, select
@@ -20,7 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.ai.models import AiCall
from bid_doctor.ai.providers.base import TransientError
from bid_doctor.billing import service as billing
from bid_doctor.check import applicability, findings, match
from bid_doctor.check import applicability, findings, match, rules
from bid_doctor.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation
from bid_doctor.check.service import RULE_SET_VERSION, open_fatal
from bid_doctor.checklist import edit as checklist_edit
@@ -35,13 +37,16 @@ from bid_doctor.document import service as documents
from bid_doctor.document.citations import document_label
from bid_doctor.document.models import SourceDocument
from bid_doctor.document.pipeline import normalize, text_layer
from bid_doctor.document.pipeline.text_layer import norm
from bid_doctor.notification.models import Notification
from bid_doctor.project import service as projects
from bid_doctor.project.models import Lot, Project, ProjectField
log = logging.getLogger(__name__)
PROGRESS = {"prepare_bid": 5, "match": 30, "rules": 85, "verify": 90, "carry_over": 93, "summarize": 96}
MIN_BID_CHARS = 200 # 所有分册加起来读不出这么多字:按「投标文件几乎读不出文字」失败
REGIMES_VERIFIED = {"gov_procurement"} # 招标投标体系没有样本,规则未经验证(m3-plan §2.4)
LOCAL_TZ = ZoneInfo("Asia/Shanghai") # 投标截止时间按北京时间取日期
class Cancelled(Exception):
@@ -126,6 +131,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
label,
text_layer.body_sentences(pages),
[p.page_no for p in pages if p.text_source == "empty"],
[x for p in pages for x in p.sentences if x.location == "header_footer"],
)
)
pages_info.append(
@@ -163,6 +169,7 @@ 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)]
)
]
facts = await _facts(s, ctx, r, snapshot)
# 招标文件按类别的响应方式(C12):用清单里存的(人工改过的为准);解析早于这项功能的项目临时识别
modes = response_mode.from_stored(stored_modes) if stored_modes else response_mode.detect(tender_sents)
bidding = set(r.lot_ids)
@@ -191,7 +198,15 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
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, "rules")
hits, rule_cov = rules.run(facts, bid_docs, tender_sents)
covered = {
(d.label, x.page_no, norm(x.text))
for res in results.values()
if res.verdict == "negative_deviation"
for d, x in res.evidence
}
hits = rules.drop_covered(hits, covered)
await _stage(ctx, run_id, "verify") # 前两道校验在匹配时逐条做完;这里只是进度节点
await _stage(ctx, run_id, "carry_over")
async with tenant_tx(ctx) as s:
@@ -201,6 +216,7 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
d.id: d for d in (await s.execute(select(SourceDocument).where(SourceDocument.id.in_(doc_ids)))).scalars()
}
await _write(s, ctx, r, items, results, by_doc)
await _write_rules(s, ctx, r, hits, facts, by_doc)
await _carry_over(s, ctx, r, set(items))
_stage_nowait(r, "summarize")
tokens = (
@@ -211,7 +227,9 @@ async def _stages(ctx: TenantContext, run_id: UUID) -> None:
)
).one()
r.counts = await _counts(s, run_id)
r.coverage = _coverage(r, pages_info, items, results, pending, other_lot, stats, tokens, skipped, modes)
r.coverage = _coverage(
r, pages_info, items, results, pending, other_lot, stats, tokens, skipped, modes, rule_cov
)
await _publish(ctx, r, "running", "summarize", PROGRESS["summarize"])
@@ -219,6 +237,90 @@ def _stage_nowait(r: CheckRun, stage: str) -> None:
r.stage, r.progress = stage, PROGRESS[stage]
async def _facts(s: AsyncSession, ctx: TenantContext, r: CheckRun, snapshot: dict[UUID, dict[str, Any]]) -> rules.Facts:
"""规则检查要的项目侧值:项目基本信息(人工改过的为准)、所投采购包的限价、清单里的投标有效期要求,各带招标侧出处。"""
p = (await s.execute(select(Project).where(Project.id == r.project_id))).scalar_one()
fields = {
f.field_key: f for f in (await s.execute(select(ProjectField).where(ProjectField.project_id == p.id))).scalars()
}
def value(key: str) -> Any:
f = fields.get(key)
return None if f is None else f.human_value if f.human_value is not None else f.ai_value
refs = {k: [f.ai_citation_id] for k, f in fields.items() if f.ai_citation_id and f.human_value is None}
lots = [x for x in (await s.execute(select(Lot).where(Lot.project_id == p.id))).scalars() if not x.is_default]
bidding = [x for x in lots if x.id in set(r.lot_ids)] if r.lot_ids else lots
ceiling, scope, note = None, "total", None
if len(bidding) > 1:
scope, note = "multi", "投了多个采购包,报价按包和限价比对还没有上线"
elif len(bidding) == 1:
ceiling, scope = bidding[0].ceiling_amount, "lot" if len(lots) > 1 else "total"
refs.pop("ceiling_total", None) # 包的限价没有单独的出处;说明里写明取自所投采购包
elif value("ceiling_total"):
ceiling = Decimal(str(value("ceiling_total")))
days, item = rules.required_validity(snapshot)
if item is not None:
refs["validity"] = [UUID(c) for c in snapshot[item].get("citation_ids") or []]
refs["deadline"] = refs.pop("deadline_at", [])
refs["ceiling"] = refs.pop("ceiling_total", [])
return rules.Facts(
name=value("name") or p.name,
code=value("code") or p.code,
purchaser=value("purchaser") or p.purchaser,
agency=value("agency") or p.agency,
deadline=p.deadline_at.astimezone(LOCAL_TZ).date() if p.deadline_at else None,
ceiling=ceiling,
ceiling_scope=scope,
ceiling_note=note,
validity_days=days,
validity_item=item,
refs=refs,
)
async def _write_rules(
s: AsyncSession,
ctx: TenantContext,
r: CheckRun,
hits: list[rules.Hit],
facts: rules.Facts,
by_doc: dict[UUID, SourceDocument],
) -> None:
"""规则问题:挂投标原文的句子;有依据的(限价、有效期要求、项目名称)再挂招标侧出处。"""
for h in hits:
bid_cites = []
for doc_id in dict.fromkeys(d.id for d, _ in h.evidence):
sents = [x for d, x in h.evidence if d.id == doc_id]
bid_cites += await citations.from_sentences(s, ctx, by_doc[doc_id], sents, "rule", [])
f = Finding(
id=uuid7(),
tenant_id=ctx.tenant_id,
project_id=r.project_id,
check_run_id=r.id,
checklist_item_id=h.item,
rule_code=h.code,
match_key=f"rule:{h.code}:{h.target}",
type=h.type,
severity=h.severity,
title=h.title[:200],
explanation=h.explanation,
detector="rule",
detail=h.detail,
checks_passed=[],
presence="new",
)
s.add(f)
await s.flush()
for pos, cid in enumerate(facts.refs.get(h.basis or "", [])):
s.add(
FindingCitation(tenant_id=ctx.tenant_id, finding_id=f.id, citation_id=cid, side="tender", position=pos)
)
for pos, c in enumerate(bid_cites):
s.add(FindingCitation(tenant_id=ctx.tenant_id, finding_id=f.id, citation_id=c.id, side="bid", position=pos))
await s.flush()
async def _write(
s: AsyncSession,
ctx: TenantContext,
@@ -391,6 +493,7 @@ def _coverage(
tokens: Any,
skipped: dict[str, int],
modes: dict[str, response_mode.Mode],
rule_cov: dict[str, Any],
) -> dict[str, Any]:
"""检查覆盖范围:报告首页、查标页顶部都读这里(FR-M3-09、m3-plan §2.5)。"""
undetermined = sum(x.verdict == "undetermined" for x in results.values())
@@ -419,7 +522,9 @@ def _coverage(
implicit = sum(x.verdict in ("implicit_accepted", "implicit_review") for x in results.values())
if implicit:
checked.append(f"其中 {implicit} 条按招标文件「未列入偏离的视为接受」处理,没有复述不算问题")
not_checked.append("报价形式、形式要件、资质匹配:规则检查还没有上线")
checked.append(rule_cov["checked"])
not_checked += rule_cov["not_checked"]
not_checked.append("资质匹配(证书有效期、主体一致):还没有上线")
if not r.checklist_confirmed:
not_checked.append("清单未确认,检查结果以当前清单为准")
return {

View File

@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.billing import service as billing
from bid_doctor.check import rules
from bid_doctor.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation
from bid_doctor.checklist import edit as checklist_edit
from bid_doctor.checklist import service as checklist
@@ -35,7 +36,9 @@ from bid_doctor.project import service as projects
from bid_doctor.project.models import Project
CHECK_TASK = "check_project"
RULE_SET_VERSION = "2026.11-r0" # 第 1 步还没有规则;规则集有版本,检查批次记下当时用的版本(架构 §3.7)
RULE_SET_VERSION = (
"2026.11-r1" # 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r1:报价形式、形式要件(rules.py)
)
WAIVE_WINDOW = timedelta(hours=24)
ERROR_MESSAGES = {
"bid_text_missing": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线",
@@ -307,7 +310,9 @@ async def _to_api_many(s: AsyncSession, run: CheckRun, rows: list[Finding]) -> l
}
if item
else None,
"rule": {"code": f.rule_code, "description": ""} if f.rule_code and not item else None,
"rule": {"code": f.rule_code, "description": rules.LABELS.get(f.rule_code, "")}
if f.rule_code and not item
else None,
"detail": f.detail,
"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],

View File

@@ -59,6 +59,7 @@ class Sentence:
fmt_runs: list[tuple[int, int, str]] = field(default_factory=list) # [起, 止, 格式],格式取 b / i / u 的组合
location: str = "body"
open_end: bool = False # 写到页面末尾还没结束(没有句末标点、末行写满),可能接续到下一页
cell: list[float] | None = None # 在有框线的表格里:所在单元格 [x0, top, x1, bottom](查标按单元格分行)
@property
def marked(self) -> str:
@@ -83,6 +84,7 @@ class Sentence:
"format_runs": [list(r) for r in self.fmt_runs],
"location": self.location,
"open_end": self.open_end,
**({"cell": self.cell} if self.cell else {}),
}
@classmethod
@@ -96,6 +98,7 @@ class Sentence:
fmt_runs=[(a, b, f) for a, b, f in d["format_runs"]],
location=d["location"],
open_end=d.get("open_end", False),
cell=d.get("cell"),
)
@@ -150,6 +153,7 @@ Cell = tuple[float, float, float, float] # 单元格 (x0, top, x1, bottom)
class FragInfo:
right: float | None = None # 单元格的右边界:判断段落末行用它,不用页面右边距
cell_end: bool = False # 单元格的最后一个片段:句子在这里结束
cell: Cell | None = None
def _cell_of(c: dict[str, Any], cells: list[Cell]) -> int | None:
@@ -171,13 +175,13 @@ def _fragments(
for i, cell_chars in by_cell.items():
assert i is not None
x0, top, x1, _ = cells[i]
blocks.append(((top, x0), _line_fragments(cell_chars), FragInfo(right=x1)))
blocks.append(((top, x0), _line_fragments(cell_chars), FragInfo(right=x1, cell=cells[i])))
frags: list[list[dict[str, Any]]] = []
infos: list[FragInfo] = []
for _, fs, info in sorted(blocks, key=lambda b: b[0]):
for j, f in enumerate(fs):
frags.append(f)
infos.append(FragInfo(right=info.right, cell_end=j == len(fs) - 1) if info else FragInfo())
infos.append(FragInfo(right=info.right, cell_end=j == len(fs) - 1, cell=info.cell) if info else FragInfo())
return frags, infos
@@ -310,6 +314,7 @@ def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], infos: list
fmt_runs=runs,
location=location,
open_end=seq == tail_seq,
cell=[round(v, 2) for v in cell] if (cell := infos[g[0][0]].cell) and location == "body" else None,
)
)
return Page(