feat: 查标第 1 步——上传投标分册 → 检查 → 逐条处理 → 复查沿用

后端
- check 模块:检查预览、发起、进度、取消、记录;六个阶段(准备投标文件、响应匹配、规则、证据校验、
  状态沿用、汇总);问题列表、处理、撤销、批量处理(只限提示级)。
- 每条清单项的检查结论 check_item_result(C9,迁移 0005):「已响应」也带投标原文出处;
  没有校验通过的证据不能判已响应(C3)。级别从清单项算,不让模型定;说明不写整改建议。
- 响应匹配先只有第三层:内存 BM25 检索候选句 + check.match v1 提示词,检索不到不调模型,
  漏答记 missing_answer,模型临时错误只影响那一批。
- 计费:免费版不记流水;专业版 24 小时内复查免扣(记一行 0);expected_charge 不一致时拒绝不扣点;
  失败、取消退回;投标文件读不出文字按输入问题失败、不扣点。
- 投标分册:类型(volume_kind)和版本;电子投标平台加密格式在上传时拦下。
- 解析流水线前几个阶段拆到 document/pipeline/normalize.py,解析和检查共用。
- ops 模块:反馈接口;致命问题确认无误的说明自动进误报反馈(U4)。
- SK1:Valkey 客户端短超时、SSE 连接池上限。SK2:CI 加 Gotenberg、直接跑 init-roles.sh。

前端
- 上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、
  已处理、本次已解决);项目头抽成公共组件,加「查标」标签页;工作台「下一步」覆盖查标阶段。

契约、文档:openapi.yaml 加 item-results、volume_kind、coverage 字段;data-model、api-design §10、
架构 §8 / §13(SK14)、AGENTS.md 同步。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 11:48:10 +08:00
parent 6ab42959df
commit e499de7486
56 changed files with 3735 additions and 183 deletions

View File

@@ -0,0 +1,458 @@
"""检查批次与问题处理(FR-M3-02、07、08,api-design §4.1、§4.5)。
- 计费(data-model §4.3):免费版不记流水;专业版 24 小时内复查免扣(记一行 0);其余在解析额度上预扣 1 次。
预览和发起共用同一个计费函数;前端带来的 expected_charge 和服务端当下算的不一致时拒绝,不扣点(API4)。
- 「预扣 → 建批次 → 入队」在同一个事务里(架构 §3.4)。
- 只有最近一次检查的问题可以处理;致命问题「确认无误」也要写说明,说明同时进误报反馈(U4)。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID
from fastapi.encoders import jsonable_encoder
from sqlalchemy import func, select
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.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation
from bid_doctor.checklist import edit as checklist_edit
from bid_doctor.checklist import service as checklist
from bid_doctor.core import queue
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
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.identity import service as identity
from bid_doctor.ops import service as feedback
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)
WAIVE_WINDOW = timedelta(hours=24)
ERROR_MESSAGES = {
"bid_text_missing": "投标文件里几乎读不出文字(可能都是扫描件),扫描件识别还没有上线",
"file_corrupted": "投标文件无法转换,可能已加密或损坏",
"plan_limit_exceeded": "投标文件页数超出套餐上限",
"checklist_empty": "正式清单还没有条目",
"converter_unavailable": "文档转换服务暂时不可用",
"model_unavailable": "模型服务暂时不可用",
"internal_error": "系统出错了",
}
@dataclass(frozen=True, slots=True)
class Billing:
mode: str # charged / waived_recheck / free_plan
charge: int
waive_until: datetime | None
async def billing_mode(s: AsyncSession, ctx: TenantContext, p: Project) -> Billing:
_, plan = await billing.current_plan(s, ctx)
if plan.code == "free":
return Billing("free_plan", 0, None) # 免费版只限 1 个项目,项目内检查不限次数(01 §4)
last = p.last_charged_check_at
if last is not None and datetime.now(UTC) - last < WAIVE_WINDOW:
return Billing("waived_recheck", 0, last + WAIVE_WINDOW) # 从上一次扣了额度的检查算起,不重新计时(P1)
return Billing("charged", 1, None)
async def _running(s: AsyncSession, project_id: UUID) -> CheckRun | None:
return (
await s.execute(
select(CheckRun).where(CheckRun.project_id == project_id, CheckRun.status.in_(("queued", "running")))
)
).scalar_one_or_none()
async def _tender_ready(s: AsyncSession, project_id: UUID) -> bool:
n = (
await s.execute(
select(func.count()).where(
SourceDocument.project_id == project_id,
SourceDocument.role == "tender",
SourceDocument.status == "ready",
)
)
).scalar_one()
return n > 0
async def preview(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict[str, Any]:
p = await projects.get(s, ctx, project_id)
mode = await billing_mode(s, ctx, p)
bids = await documents.latest_bid_volumes(s, project_id)
blockers: list[str] = []
if p.status in ("submitted", "archived"):
blockers.append("project_read_only")
if not await _tender_ready(s, project_id):
blockers.append("no_parsed_tender")
elif not await checklist.items(s, project_id, zone="formal"):
blockers.append("checklist_empty")
if not bids:
blockers.append("no_bid_documents")
if await _running(s, project_id):
blockers.append("job_already_running")
if mode.charge and await billing.balance(s, ctx, "parse") < 1:
blockers.append("quota_exhausted")
states = await checklist.statuses(s, [project_id])
return {
"can_start": not blockers,
"blockers": blockers,
"billing": mode.mode,
"charge": mode.charge,
"charge_unit": "parse",
"waive_until": mode.waive_until,
"checklist_confirmed": states.get(project_id) == "confirmed",
"pending_item_count": (await checklist.pending_counts(s, [project_id])).get(project_id, 0),
"bid_documents": [await documents.doc_to_api(s, d) for d in bids],
"lot_ids": await projects.bidding_lot_ids(s, project_id),
}
async def start(
s: AsyncSession, ctx: TenantContext, project_id: UUID, document_ids: list[UUID], expected_charge: int
) -> CheckRun:
p = await projects.get(s, ctx, project_id)
projects.ensure_editable(p)
if running := await _running(s, project_id):
raise AppError("job_already_running", job_id=str(running.id))
if not await _tender_ready(s, project_id):
raise AppError("no_parsed_tender")
docs = (
(await s.execute(select(SourceDocument).where(SourceDocument.id.in_(document_ids)))).scalars().all()
if document_ids
else []
)
if not docs or len(docs) != len(set(document_ids)):
raise AppError("no_bid_documents")
if any(d.project_id != project_id or d.role != "bid_volume" for d in docs):
raise AppError("not_found")
mode = await billing_mode(s, ctx, p)
if expected_charge != mode.charge:
raise AppError("billing_changed", preview=jsonable_encoder(await preview(s, ctx, project_id)))
version, confirmed = await checklist_edit.check_snapshot(s, ctx, project_id)
run_no = (
await s.execute(
select(func.coalesce(func.max(CheckRun.run_no), 0) + 1).where(CheckRun.project_id == project_id)
)
).scalar_one()
run = CheckRun(
id=uuid7(),
tenant_id=ctx.tenant_id,
project_id=project_id,
run_no=run_no,
checklist_version_id=version.id,
checklist_confirmed=confirmed,
rule_set_version=RULE_SET_VERSION,
legal_regime=p.legal_regime,
lot_ids=await projects.bidding_lot_ids(s, project_id),
previous_run_id=p.latest_check_run_id,
billing=mode.mode,
created_by=ctx.user_id,
)
if mode.mode == "charged":
run.quota_hold_id = await billing.hold(
s, ctx, unit="parse", ref_type="check_run", ref_id=run.id, project_id=project_id
)
s.add(run)
await s.flush()
for d in docs:
s.add(CheckRunDocument(tenant_id=ctx.tenant_id, check_run_id=run.id, document_id=d.id))
if mode.mode == "waived_recheck":
await billing.waive(
s, ctx, unit="parse", ref_type="check_run", ref_id=run.id, project_id=project_id, reason="recheck_24h"
)
run.queue_job_id = await queue.enqueue(
s, CHECK_TASK, {"tenant_id": str(ctx.tenant_id), "run_id": str(run.id)}, lock=f"project:{project_id}"
)
await audit.record(
s,
ctx,
"check_run.start",
"check_run",
run.id,
project_id=project_id,
after={"run_no": run_no, "billing": mode.mode, "documents": [str(d.id) for d in docs]},
)
await s.flush()
await s.refresh(run)
return run
async def get_run(s: AsyncSession, run_id: UUID) -> CheckRun:
run = (await s.execute(select(CheckRun).where(CheckRun.id == run_id))).scalar_one_or_none()
if run is None:
raise AppError("not_found")
return run
async def request_cancel(s: AsyncSession, run_id: UUID) -> CheckRun:
run = await get_run(s, run_id)
if run.status not in ("queued", "running"):
raise AppError("job_finished")
run.cancel_requested_at = datetime.now(UTC)
await s.flush()
return run
async def list_runs(s: AsyncSession, project_id: UUID) -> list[CheckRun]:
rows = await s.execute(select(CheckRun).where(CheckRun.project_id == project_id).order_by(CheckRun.run_no.desc()))
return list(rows.scalars())
async def run_to_api(s: AsyncSession, ctx: TenantContext, run: CheckRun) -> dict[str, Any]:
p = await projects.get(s, ctx, run.project_id)
doc_ids = list(
(await s.execute(select(CheckRunDocument.document_id).where(CheckRunDocument.check_run_id == run.id))).scalars()
)
docs = (await s.execute(select(SourceDocument).where(SourceDocument.id.in_(doc_ids)))).scalars().all()
version_no = (await checklist_edit.version_numbers(s, [run.checklist_version_id])).get(run.checklist_version_id, 0)
names = await identity.display_names(s, [run.created_by])
error = None
if run.status in ("failed", "cancelled") and run.error_code:
error = {
"code": run.error_code,
"message": ERROR_MESSAGES.get(run.error_code, run.error_code),
"refunded": run.billing == "charged",
}
return {
"id": run.id,
"run_no": run.run_no,
"status": run.status,
"stage": run.stage,
"progress": run.progress,
"cancel_requested": run.cancel_requested_at is not None,
"error": error,
"billing": run.billing,
"is_latest": p.latest_check_run_id == run.id,
"checklist_version_no": version_no,
"checklist_confirmed": run.checklist_confirmed,
"rule_set_version": run.rule_set_version,
"legal_regime": run.legal_regime,
"documents": [await documents.doc_to_api(s, d) for d in docs],
"coverage": run.coverage,
"counts": run.counts,
"created_by": {"id": run.created_by, "name": names.get(run.created_by, "")},
"created_at": run.created_at,
"finished_at": run.finished_at,
}
# ------------------------------------------------------------------ 问题
async def _items_of(s: AsyncSession, run: CheckRun) -> dict[UUID, dict[str, Any]]:
return await checklist_edit.version_items(s, run.checklist_version_id)
async def findings(
s: AsyncSession, run_id: UUID, *, severity: str | None, status: str | None, presence: str | None
) -> list[dict[str, Any]]:
run = await get_run(s, run_id)
q = select(Finding).where(Finding.check_run_id == run_id)
if severity:
q = q.where(Finding.severity == severity)
if status:
q = q.where(Finding.status == status)
if presence:
q = q.where(Finding.presence == presence)
rows = list((await s.execute(q)).scalars())
return await _to_api_many(s, run, rows)
ORDER = {"fatal": 0, "important": 1, "hint": 2}
async def _to_api_many(s: AsyncSession, run: CheckRun, rows: list[Finding]) -> list[dict[str, Any]]:
"""按级别、再按招标原文顺序(清单项的排序键)。"""
items = await _items_of(s, run)
links = (
await s.execute(
select(FindingCitation)
.where(FindingCitation.finding_id.in_([f.id for f in rows]))
.order_by(FindingCitation.position)
)
).scalars()
by_finding: dict[UUID, list[FindingCitation]] = {}
for link in links:
by_finding.setdefault(link.finding_id, []).append(link)
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])
def api(f: Finding) -> dict[str, Any]:
item = items.get(f.checklist_item_id) if f.checklist_item_id else None
ls = by_finding.get(f.id, [])
return {
"id": f.id,
"type": f.type,
"severity": f.severity,
"title": f.title,
"explanation": f.explanation,
"detector": f.detector,
"checklist_item": {
"id": f.checklist_item_id,
"title": item.get("title"),
"importance": item.get("importance"),
"categories": item.get("categories"),
}
if item
else None,
"rule": {"code": f.rule_code, "description": ""} 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],
"search_scope": f.search_scope,
"presence": f.presence,
"carried_status": (f.detail or {}).get("carried_status"),
"status": f.status,
"resolution_note": f.resolution_note,
"resolved_by": {"id": f.resolved_by, "name": names.get(f.resolved_by, "")} if f.resolved_by else None,
"resolved_at": f.resolved_at,
}
rows.sort(key=lambda f: (ORDER[f.severity], sort_keys.get(f.checklist_item_id or UUID(int=0), "~"), f.title))
return [api(f) for f in rows]
async def _get_finding(s: AsyncSession, finding_id: UUID) -> Finding:
f = (await s.execute(select(Finding).where(Finding.id == finding_id))).scalar_one_or_none()
if f is None:
raise AppError("not_found")
return f
async def _editable_run(s: AsyncSession, ctx: TenantContext, run_id: UUID) -> tuple[CheckRun, Project]:
run = await get_run(s, run_id)
p = await projects.get(s, ctx, run.project_id)
projects.ensure_editable(p)
if p.latest_check_run_id != run.id:
raise AppError("run_superseded")
return run, p
async def open_fatal(s: AsyncSession, run_id: UUID) -> int:
return (
await s.execute(
select(func.count()).where(
Finding.check_run_id == run_id,
Finding.severity == "fatal",
Finding.status == "pending",
Finding.presence != "resolved",
)
)
).scalar_one()
def _require_note(severity: str, status: str, note: str | None) -> None:
if (status == "ignored" or (severity == "fatal" and status == "confirmed_ok")) and not (note or "").strip():
raise AppError("note_required")
async def _apply(s: AsyncSession, ctx: TenantContext, f: Finding, p: Project, status: str, note: str | None) -> None:
before = {"status": f.status, "note": f.resolution_note}
done = status != "pending"
f.status = status
f.resolution_note = ((note or "").strip() or None) if done else None
f.resolved_by, f.resolved_at = (ctx.user_id, datetime.now(UTC)) if done else (None, None)
if f.severity == "fatal" and status == "confirmed_ok":
await feedback.record(
s,
ctx,
project_id=p.id,
object_type="finding",
object_id=f.id,
kind="false_positive",
source="fatal_confirm",
note=f.resolution_note,
)
await audit.record(
s,
ctx,
"finding.resolve" if done else "finding.reopen",
"finding",
f.id,
project_id=p.id,
before=before,
after={"status": f.status, "note": f.resolution_note},
)
async def resolve(
s: AsyncSession, ctx: TenantContext, finding_id: UUID, status: str, note: str | None
) -> dict[str, Any]:
f = await _get_finding(s, finding_id)
run, p = await _editable_run(s, ctx, f.check_run_id)
if f.presence == "resolved":
raise AppError("finding_resolved_this_run")
_require_note(f.severity, status, note)
await _apply(s, ctx, f, p, status, note)
await s.flush()
p.open_fatal_count = await open_fatal(s, run.id)
await s.flush()
(out,) = await _to_api_many(s, run, [f])
return {"finding": out, "project": projects.readiness(p)}
async def reopen(s: AsyncSession, ctx: TenantContext, finding_id: UUID) -> dict[str, Any]:
f = await _get_finding(s, finding_id)
run, p = await _editable_run(s, ctx, f.check_run_id)
if f.presence == "resolved":
raise AppError("finding_resolved_this_run")
await _apply(s, ctx, f, p, "pending", None)
await s.flush()
p.open_fatal_count = await open_fatal(s, run.id)
await s.flush()
(out,) = await _to_api_many(s, run, [f])
return {"finding": out, "project": projects.readiness(p)}
async def bulk_resolve(
s: AsyncSession, ctx: TenantContext, run_id: UUID, finding_ids: list[UUID], status: str, note: str | None
) -> dict[str, Any]:
"""只限「提示」级(FR-M3-07);混进了别的级别就整批拒绝,返回不符合的问题编号。"""
run, p = await _editable_run(s, ctx, run_id)
rows = list((await s.execute(select(Finding).where(Finding.id.in_(finding_ids)))).scalars())
if len(rows) != len(set(finding_ids)) or any(f.check_run_id != run.id for f in rows):
raise AppError("not_found")
bad = [str(f.id) for f in rows if f.severity != "hint" or f.presence == "resolved"]
if bad:
raise AppError("bulk_only_hint", finding_ids=bad)
_require_note("hint", status, note)
for f in rows:
await _apply(s, ctx, f, p, status, note)
await s.flush()
p.open_fatal_count = await open_fatal(s, run.id)
await s.flush()
return {"items": await _to_api_many(s, run, rows), "project": projects.readiness(p)}
async def item_results(s: AsyncSession, run_id: UUID) -> list[dict[str, Any]]:
"""每条清单项的检查结论(C9):「已响应」也有出处,可以复核、可以反馈判断有误。"""
run = await get_run(s, run_id)
items = await _items_of(s, run)
rows = list((await s.execute(select(CheckItemResult).where(CheckItemResult.check_run_id == run_id))).scalars())
cites = await citations.to_api_many(s, [c for r in rows for c in r.bid_citation_ids])
return [
{
"checklist_item_id": r.checklist_item_id,
"title": (items.get(r.checklist_item_id) or {}).get("title"),
"verdict": r.verdict,
"layer": r.layer,
"evidence_status": r.evidence_status,
"reason_code": r.reason_code,
"bid_citations": [cites[c] for c in r.bid_citation_ids if c in cites],
"detail": r.detail,
"reused": r.reused_from_run_id is not None,
}
for r in rows
]