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:
@@ -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)
|
||||
|
||||
1083
server/src/bid_doctor/check/rules.py
Normal file
1083
server/src/bid_doctor/check/rules.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user