feat: 读标可以走完一份文件;审计待确认区,修规则缺陷、提示词出新版本

读标操作(FR-M2-07、08)
- 后端:清单项详情(ETag)、修改(If-Match 选填,记 edited_fields 和 AI 原值)、
  采纳(补出处或声明「人工确认、无出处」,可带修改)、删除 / 丢弃与恢复、人工新增、
  替换出处、人工建出处(选同一页的句子)、修改记录(来自审计)、确认清单(不可变版本,
  内容没变复用上一版)、版本列表;正式清单有变化时已确认的清单回到草稿;项目已递交只读
- 重新解析不推翻人工决定:丢弃的不再冒出来,采纳的留在正式清单,人工补过的出处不被覆盖
- 前端:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除
  (可撤销)、修改记录、新增条目、确认清单;pdf.js 原文查看器(点出处跳页、按坐标框高亮、
  选句补出处),整屏对照布局,J / K 上下条;跨页的一句话合成一处出处显示

待确认审计(架构 §13 SK11)
- 规则只修真正的缺陷:表格框线不算下划线(文字层 v3);句号后的右括号并进同一句
  (切句 s3-v3);中文编号小节和冒号编号条目的上级条款,依据沿上级链核对;取值认中文
  数字、同一行单元格和评分因素标题;补几种无效写法;比对引文前去掉我们加进输入的标记
- 不放宽:引文最短 8 个字、编号必须对、证据全部通过
- 提示词新版本:substantive v3,qualification / scoring / schedule / tech_param v2;
  引文复制整句、并列多项不拆、跨页只引上一行,除实质性条款外输入改成纯文本
- BD_AI_MODELS 按提示词声明的能力选模型;离线评测工具 evals/run_extract.py
- 待确认:国动 46 → 4,S02 17 → 13,S06 174 → 57

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-03 21:32:15 +08:00
parent a95e27a290
commit e3f234ca0b
47 changed files with 3125 additions and 164 deletions

View File

@@ -1,7 +1,8 @@
"""OpenAI 兼容协议的国产模型(DeepSeek、通义千问、豆包、GLM、Kimi 等,T1)。
供应商地址和模型由运维在部署配置里设置,用户和租户都不能填(架构 §3.6)。
TODO(路由表):按能力配候选列表、按「供应商 × 租户」限流;现在只有一个供应商一个模型。
按提示词声明的能力(fast / reasoning / vision)选模型:BD_AI_MODELS 里配了就用配的,否则用 BD_AI_MODEL。
TODO(路由表):按能力配候选列表和降级、按「供应商 × 租户」限流;现在只有一个供应商。
"""
from __future__ import annotations
@@ -18,10 +19,11 @@ class OpenAICompatProvider:
name = "openai_compat"
async def complete_json(self, system: str, user: str, capability: str, task: str) -> Completion:
del capability, task
del task
s = get_settings()
model = s.ai_models.get(capability) or s.ai_model
body = {
"model": s.ai_model,
"model": model,
"temperature": 0,
"response_format": {"type": "json_object"},
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
@@ -48,7 +50,7 @@ class OpenAICompatProvider:
return Completion(
data=data,
raw=raw,
model=payload.get("model", s.ai_model),
model=payload.get("model", model),
tokens_in=usage.get("prompt_tokens"),
tokens_out=usage.get("completion_tokens"),
)

View File

@@ -6,6 +6,7 @@ import json
from typing import Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit.models import AuditEvent
@@ -48,3 +49,13 @@ async def record(
request_id=ctx.request_id or None,
)
)
async def events_for(session: AsyncSession, object_type: str, object_id: UUID) -> list[AuditEvent]:
"""某个对象的修改记录,按时间倒序(FR-M2-08「谁在何时改了什么」)。"""
rows = await session.execute(
select(AuditEvent)
.where(AuditEvent.object_type == object_type, AuditEvent.object_id == object_id)
.order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc())
)
return list(rows.scalars().all())

View File

@@ -1,17 +1,53 @@
from __future__ import annotations
from typing import Any, Literal
from typing import Annotated, Any, Literal
from uuid import UUID
from fastapi import APIRouter
from fastapi import APIRouter, Header, Response
from pydantic import BaseModel, ConfigDict, Field
from bid_doctor.checklist import service
from bid_doctor.checklist import edit, service
from bid_doctor.core.db import tenant_tx
from bid_doctor.identity.deps import Ctx
from bid_doctor.project import service as projects
router = APIRouter(tags=["checklist"])
Category = Literal["qualification", "invalidity", "scoring", "schedule", "tech_param", "format_doc", "price"]
Importance = Literal["substantive", "important", "normal"]
class ItemFields(BaseModel):
"""ChecklistItemFields:只传要改的字段。"""
model_config = ConfigDict(extra="forbid")
categories: list[Category] | None = Field(default=None, min_length=1)
title: str | None = Field(default=None, min_length=1, max_length=200)
requirement_text: str | None = None
importance: Importance | None = None
lot_id: UUID | None = None
evidence_required: str | None = None
spec: dict[str, Any] | None = None
def changes(self) -> dict[str, Any]:
return self.model_dump(exclude_unset=True)
class ItemCreate(ItemFields):
categories: list[Category] = Field(min_length=1) # pyright: ignore[reportIncompatibleVariableOverride]
title: str = Field(min_length=1, max_length=200) # pyright: ignore[reportIncompatibleVariableOverride]
citation_ids: list[UUID] = Field(default_factory=list[UUID])
class AcceptIn(BaseModel):
citation_ids: list[UUID] | None = None
no_citation_ack: bool = False
changes: ItemFields | None = None
class CitationsIn(BaseModel):
citation_ids: list[UUID]
@router.get("/projects/{project_id}/checklist")
async def get_checklist(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
@@ -21,8 +57,89 @@ async def get_checklist(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
@router.get("/projects/{project_id}/checklist/items")
async def list_items(project_id: UUID, ctx: Ctx, zone: Literal["pending", "formal"] | None = None) -> dict[str, Any]:
# TODO(读标):category、lot_scope、include_deleted 筛选,以及修改、采纳、删除、确认清单等接口
async def list_items(
project_id: UUID,
ctx: Ctx,
zone: Literal["pending", "formal"] | None = None,
category: Category | None = None,
include_deleted: bool = False,
) -> dict[str, Any]:
# TODO(分包):lot_scope(默认只列通用 + 所投的包),选包做好之后加
async with tenant_tx(ctx) as s:
await projects.get(s, ctx, project_id)
return {"items": await service.items(s, project_id, zone)}
return {"items": await service.items(s, project_id, zone, category, include_deleted)}
@router.post("/projects/{project_id}/checklist/items", status_code=201)
async def create_item(project_id: UUID, body: ItemCreate, ctx: Ctx) -> dict[str, Any]:
values = body.changes()
citation_ids = values.pop("citation_ids", [])
async with tenant_tx(ctx) as s:
return await edit.create(s, ctx, project_id, values, citation_ids)
@router.get("/checklist-items/{item_id}")
async def get_item(item_id: UUID, ctx: Ctx, response: Response) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
out = await service.item(s, item_id)
response.headers["ETag"] = service.etag(out)
return out
@router.patch("/checklist-items/{item_id}")
async def update_item(
item_id: UUID,
body: ItemFields,
ctx: Ctx,
response: Response,
if_match: Annotated[str | None, Header()] = None,
) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
out = await edit.update(s, ctx, item_id, body.changes(), if_match)
response.headers["ETag"] = service.etag(out)
return out
@router.delete("/checklist-items/{item_id}", status_code=204)
async def delete_item(item_id: UUID, ctx: Ctx) -> None:
async with tenant_tx(ctx) as s:
await edit.remove(s, ctx, item_id)
@router.post("/checklist-items/{item_id}/restore")
async def restore_item(item_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await edit.restore(s, ctx, item_id)
@router.post("/checklist-items/{item_id}/accept")
async def accept_item(item_id: UUID, body: AcceptIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await edit.accept(
s, ctx, item_id, body.citation_ids, body.no_citation_ack, body.changes.changes() if body.changes else {}
)
@router.put("/checklist-items/{item_id}/citations")
async def set_citations(item_id: UUID, body: CitationsIn, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await edit.set_citations(s, ctx, item_id, body.citation_ids)
@router.get("/checklist-items/{item_id}/history")
async def item_history(item_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return {"items": await edit.history(s, item_id)}
@router.post("/projects/{project_id}/checklist/confirm")
async def confirm_checklist(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await edit.confirm(s, ctx, project_id)
@router.get("/projects/{project_id}/checklist/versions")
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)}

View File

@@ -0,0 +1,457 @@
"""修改清单(FR-M2-07、08,api-design §4.5):采纳、修改、删除恢复、人工新增、替换出处、确认清单。
每一次修改都写审计(全程留痕,红线 1),修改记录就是从审计里读出来的;正式清单有变化时,
已确认的清单回到草稿,历史版本保留。项目已递交、已归档时只读。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.checklist import service
from bid_doctor.checklist.models import (
Checklist,
ChecklistItem,
ChecklistItemCitation,
ChecklistVersion,
ChecklistVersionItem,
)
from bid_doctor.core.crypto import sha256
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.models import Citation
from bid_doctor.identity import service as identity
from bid_doctor.project import service as projects
# 用户可以改的字段(ChecklistItemFields)
FIELDS = ("categories", "title", "requirement_text", "importance", "lot_id", "evidence_required", "spec")
REQUIRED = ("categories", "title", "importance")
SYSTEM = {"id": UUID(int=0), "name": "系统"}
def _json(v: Any) -> Any:
return str(v) if isinstance(v, UUID) else v
async def _editable(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
projects.ensure_editable(await projects.get(s, ctx, project_id))
async def _reopen(s: AsyncSession, project_id: UUID) -> None:
"""正式清单有变化:已确认的清单回到草稿(FR-M2-08)。"""
cl = (await s.execute(select(Checklist).where(Checklist.project_id == project_id))).scalar_one()
if cl.status == "confirmed":
cl.status = "draft"
async def _check(s: AsyncSession, project_id: UUID, values: dict[str, Any]) -> dict[str, Any]:
"""校验并规整要写入的字段;不合格时返回 422,字段级的问题放在 errors 里。"""
errors: list[dict[str, str]] = []
out = dict(values)
for k in REQUIRED:
if k in out and out[k] is None:
errors.append({"field": k, "code": "required", "message": "不能为空"})
if out.get("categories"):
out["categories"] = list(dict.fromkeys(out["categories"]))
if isinstance(out.get("title"), str):
out["title"] = out["title"].strip()
if not out["title"]:
errors.append({"field": "title", "code": "required", "message": "不能为空"})
for k in ("requirement_text", "evidence_required"):
if isinstance(out.get(k), str):
out[k] = out[k].strip() or None
if out.get("lot_id") is not None and out["lot_id"] not in await projects.lot_labels(s, project_id):
errors.append({"field": "lot_id", "code": "not_found", "message": "没有这个分包"})
if "spec" in out and out["spec"] is None:
out["spec"] = {}
if errors:
raise AppError("validation_failed", errors=errors)
return out
def _apply(item: ChecklistItem, values: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
"""写入有变化的字段,记进 edited_fields;返回审计用的改前、改后。"""
before: dict[str, Any] = {}
after: dict[str, Any] = {}
for k, v in values.items():
cur = getattr(item, k)
if cur == v:
continue
before[k], after[k] = _json(cur), _json(v)
setattr(item, k, v)
if not after:
return before, after
if item.origin != "human":
# AI 原值只记第一次修改前的那个,以后界面可以显示「AI 原为……」
ai = dict(item.ai_values or {})
original = dict(ai.get("original") or {})
for k in after:
if k not in item.edited_fields and k not in original:
original[k] = before[k]
item.ai_values = {**ai, "original": original}
if "importance" in after:
item.importance_basis = "human"
item.edited_fields = sorted({*item.edited_fields, *after})
return before, after
def _substantive_ok(item: ChecklistItem) -> None:
if item.importance == "substantive" and "invalidity" not in item.categories:
raise AppError(
"validation_failed",
"★ 条款不满足就会被判无效,必须同时归在「无效条款」",
errors=[{"field": "categories", "code": "substantive_needs_invalidity", "message": "要包含「无效条款」"}],
)
async def _set_citations(s: AsyncSession, item: ChecklistItem, citation_ids: list[UUID]) -> list[str]:
ids = list(dict.fromkeys(citation_ids))
if ids:
found = set(
(
await s.execute(select(Citation.id).where(Citation.id.in_(ids), Citation.project_id == item.project_id))
).scalars()
)
if missing := [str(i) for i in ids if i not in found]:
raise AppError(
"validation_failed",
errors=[{"field": "citation_ids", "code": "not_found", "message": f"没有这条出处:{missing[0]}"}],
)
old = [
str(c)
for c in (
await s.execute(
select(ChecklistItemCitation.citation_id)
.where(ChecklistItemCitation.item_id == item.id)
.order_by(ChecklistItemCitation.position)
)
).scalars()
]
await s.execute(delete(ChecklistItemCitation).where(ChecklistItemCitation.item_id == item.id))
for pos, cid in enumerate(ids):
s.add(ChecklistItemCitation(tenant_id=item.tenant_id, item_id=item.id, citation_id=cid, position=pos))
await s.flush()
if ids:
first = (await s.execute(select(Citation).where(Citation.id == ids[0]))).scalar_one()
item.anchor_sentence_key = item.anchor_sentence_key or first.sentence_keys[0]
item.sort_key = _sort_key(first)
return old
def _sort_key(c: Citation) -> str:
seq = int(c.sentence_keys[0].rsplit(":s", 1)[-1])
return f"{c.page_no:05d}.{seq:05d}"
async def _has_citations(s: AsyncSession, item_id: UUID) -> bool:
n = (
await s.execute(
select(func.count()).select_from(ChecklistItemCitation).where(ChecklistItemCitation.item_id == item_id)
)
).scalar_one()
return n > 0
async def _done(s: AsyncSession, item: ChecklistItem) -> dict[str, Any]:
await s.flush()
await s.refresh(item)
return await service.item(s, item.id)
async def update(
s: AsyncSession, ctx: TenantContext, item_id: UUID, changes: dict[str, Any], if_match: str | None
) -> dict[str, Any]:
item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id)
if if_match and if_match.removeprefix("W/") != service.etag(item):
raise AppError("precondition_failed")
if item.deleted_at is not None:
raise AppError("not_found", "这一条已经删除,恢复后才能修改")
before, after = _apply(item, await _check(s, item.project_id, changes))
_substantive_ok(item)
if after:
await audit.record(
s,
ctx,
"checklist_item.update",
"checklist_item",
item.id,
project_id=item.project_id,
before=before,
after=after,
)
if item.zone == "formal":
await _reopen(s, item.project_id)
return await _done(s, item)
async def accept(
s: AsyncSession,
ctx: TenantContext,
item_id: UUID,
citation_ids: list[UUID] | None,
no_citation_ack: bool,
changes: dict[str, Any],
) -> dict[str, Any]:
"""从待确认区采纳(FR-M2-07):要么有出处,要么声明「人工确认、无出处」(红线 2);可以同时修改字段。"""
item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id)
if item.zone != "pending" or item.deleted_at is not None:
raise AppError("item_not_pending")
before, after = _apply(item, await _check(s, item.project_id, changes))
_substantive_ok(item)
if citation_ids:
before["citation_ids"] = await _set_citations(s, item, citation_ids)
after["citation_ids"] = [str(c) for c in dict.fromkeys(citation_ids)]
item.edited_fields = sorted({*item.edited_fields, "citations"})
elif not no_citation_ack and not await _has_citations(s, item.id):
raise AppError("citation_required")
if no_citation_ack and not citation_ids:
item.no_citation_ack_by = ctx.user_id
after["no_citation_ack"] = True
before["zone"], after["zone"] = "pending", "formal"
before["pending_reason"] = item.pending_reason
item.zone, item.pending_reason = "formal", None
item.accepted_by, item.accepted_at = ctx.user_id, datetime.now(UTC)
await audit.record(
s,
ctx,
"checklist_item.accept",
"checklist_item",
item.id,
project_id=item.project_id,
before=before,
after=after,
)
await _reopen(s, item.project_id)
return await _done(s, item)
async def set_citations(s: AsyncSession, ctx: TenantContext, item_id: UUID, citation_ids: list[UUID]) -> dict[str, Any]:
item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id)
if not citation_ids and item.zone == "formal" and item.origin != "human" and item.no_citation_ack_by is None:
raise AppError("citation_required") # 正式清单里的 AI 条目不能没有出处(红线 2)
old = await _set_citations(s, item, citation_ids)
item.edited_fields = sorted({*item.edited_fields, "citations"})
if citation_ids:
item.no_citation_ack_by = None
await audit.record(
s,
ctx,
"checklist_item.set_citations",
"checklist_item",
item.id,
project_id=item.project_id,
before={"citation_ids": old},
after={"citation_ids": [str(c) for c in dict.fromkeys(citation_ids)]},
)
if item.zone == "formal":
await _reopen(s, item.project_id)
return await _done(s, item)
async def remove(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> None:
"""软删除;待确认区的条目删除就是「丢弃」。删除的条目可以恢复,重新解析也不会再冒出来。"""
item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id)
if item.deleted_at is not None:
return
item.deleted_at, item.deleted_by = datetime.now(UTC), ctx.user_id
action = "checklist_item.discard" if item.zone == "pending" else "checklist_item.delete"
await audit.record(
s, ctx, action, "checklist_item", item.id, project_id=item.project_id, before={"zone": item.zone}
)
if item.zone == "formal":
await _reopen(s, item.project_id)
async def restore(s: AsyncSession, ctx: TenantContext, item_id: UUID) -> dict[str, Any]:
item = await service.load(s, item_id)
await _editable(s, ctx, item.project_id)
if item.deleted_at is not None:
item.deleted_at, item.deleted_by = None, None
await audit.record(
s,
ctx,
"checklist_item.restore",
"checklist_item",
item.id,
project_id=item.project_id,
after={"zone": item.zone},
)
if item.zone == "formal":
await _reopen(s, item.project_id)
return await _done(s, item)
async def create(
s: AsyncSession, ctx: TenantContext, project_id: UUID, values: dict[str, Any], citation_ids: list[UUID]
) -> dict[str, Any]:
"""人工新增(标注「人工添加」,可以没有出处),直接进正式清单。"""
await _editable(s, ctx, project_id)
cl = await service.get_or_create(s, ctx, project_id)
v = await _check(s, project_id, {"importance": "normal", "spec": {}, **values})
item = ChecklistItem(
id=uuid7(),
tenant_id=ctx.tenant_id,
project_id=project_id,
checklist_id=cl.id,
origin="human",
zone="formal",
importance_basis="human" if v["importance"] != "normal" else None,
edited_fields=[],
sort_key="99999",
created_by=ctx.user_id,
**v,
)
_substantive_ok(item)
s.add(item)
await s.flush()
if citation_ids:
await _set_citations(s, item, citation_ids)
await audit.record(
s,
ctx,
"checklist_item.create",
"checklist_item",
item.id,
project_id=project_id,
after={**{k: _json(x) for k, x in v.items()}, "citation_ids": [str(c) for c in citation_ids]},
)
await _reopen(s, project_id)
return await _done(s, item)
async def history(s: AsyncSession, item_id: UUID) -> list[dict[str, Any]]:
"""修改记录:来自审计事件,按时间倒序;AI / 规则提取出来的那一刻补在最后(解析不逐条写审计)。"""
item = await service.load(s, item_id)
events = await audit.events_for(s, "checklist_item", item.id)
out: list[dict[str, Any]] = []
for e in events:
before, after = e.before or {}, e.after or {}
out.append(
{
"action": e.action,
"actor": {"id": e.actor_user_id, "name": e.actor_name or ""} if e.actor_user_id else SYSTEM,
"at": e.created_at,
"changes": [
{"field": f, "before": before.get(f), "after": after.get(f)}
for f in dict.fromkeys([*before, *after])
],
}
)
if item.origin != "human":
original = (item.ai_values or {}).get("original") or {}
out.append(
{
"action": "checklist_item.extract",
"actor": SYSTEM,
"at": item.created_at,
"changes": [
{"field": "title", "before": None, "after": original.get("title", item.title)},
{
"field": "zone",
"before": None,
"after": "pending" if item.pending_reason or item.accepted_at else "formal",
},
],
}
)
return out
def _snapshot(r: dict[str, Any]) -> dict[str, Any]:
keep = (*FIELDS, "summary", "importance_basis", "marking_rule_id", "origin", "no_citation_ack")
return {**{k: _json(r[k]) for k in keep}, "citation_ids": [str(c["id"]) for c in r["citations"]]}
async def confirm(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict[str, Any]:
"""确认清单:把正式清单里未删除的条目存成不可变版本;内容和上一版一样就复用上一版(data-model §5)。
待确认区的条目不进版本,也不参与检查。"""
await _editable(s, ctx, project_id)
cl = await service.get_or_create(s, ctx, project_id)
rows = await service.items(s, project_id, zone="formal")
if not rows:
raise AppError("checklist_empty")
payloads = {str(r["id"]): _snapshot(r) for r in rows}
digest = sha256(json.dumps(payloads, ensure_ascii=False, sort_keys=True, default=str))
last = (
await s.execute(
select(ChecklistVersion)
.where(ChecklistVersion.checklist_id == cl.id, ChecklistVersion.kind == "confirmed")
.order_by(ChecklistVersion.version_no.desc())
.limit(1)
)
).scalar_one_or_none()
if last is not None and last.content_hash == digest:
version = last
else:
cl.last_version_no += 1
version = ChecklistVersion(
id=uuid7(),
tenant_id=ctx.tenant_id,
checklist_id=cl.id,
version_no=cl.last_version_no,
kind="confirmed",
content_hash=digest,
item_count=len(payloads),
created_by=ctx.user_id,
)
s.add(version)
await s.flush()
for item_id, payload in payloads.items():
s.add(
ChecklistVersionItem(
tenant_id=ctx.tenant_id, version_id=version.id, item_id=UUID(item_id), payload=payload
)
)
if cl.status == "confirmed" and cl.confirmed_version_id == version.id:
return await service.overview(s, project_id) # 重复点确认:什么都不变
cl.status, cl.confirmed_version_id = "confirmed", version.id
await audit.record(
s,
ctx,
"checklist.confirm",
"checklist",
cl.id,
project_id=project_id,
after={"version_no": version.version_no, "item_count": version.item_count},
)
await s.flush()
return await service.overview(s, project_id)
async def versions(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
rows = (
(
await s.execute(
select(ChecklistVersion)
.join(Checklist, Checklist.id == ChecklistVersion.checklist_id)
.where(Checklist.project_id == project_id)
.order_by(ChecklistVersion.version_no.desc())
)
)
.scalars()
.all()
)
names = await identity.display_names(s, [v.created_by for v in rows])
return [
{
"id": v.id,
"version_no": v.version_no,
"kind": v.kind,
"item_count": v.item_count,
"created_by": {"id": v.created_by, "name": names.get(v.created_by, "")},
"created_at": v.created_at,
}
for v in rows
]

View File

@@ -25,6 +25,7 @@ from zoneinfo import ZoneInfo
from bid_doctor.ai import service as ai
from bid_doctor.core.db import TenantContext
from bid_doctor.document.pipeline import structure
from bid_doctor.document.pipeline.text_layer import Sentence, norm
CHUNK_CHARS = 6000
@@ -34,7 +35,10 @@ CN_TZ = ZoneInfo("Asia/Shanghai")
INVALID = re.compile(
r"投标无效|响应无效|无效投标|无效响应|作无效处理|按无效|做无效|视为无效|非实质性响应|未实质性响应"
r"|否决其投标|否决投标|不予受理|予以拒绝|拒绝其"
r"|(?:投标|响应)((?:投标|响应))无效" # 「投标(响应)无效」:范本里两种叫法并写
r"|不能通过资格审查|不通过资格审查|资格审查不合格" # 资格审查不通过就不进入评审,等同于响应无效
r"|(?:没有|未作|未做)实质性响应"
r"|否决其投标|否决投标|不予受理|予以拒绝|拒绝其|拒收"
)
# 按上级标题补漏时只认投标本身无效的说法;「不予受理」「予以拒绝」也用于质疑、投诉(国动样本 28.5 节「无效质疑」)
BID_INVALID_HEADING = re.compile(r"投标无效|响应无效|无效投标|无效响应|作无效处理|未实质性响应|否决投标|否决其投标")
@@ -44,6 +48,12 @@ MARKER_TALK = re.compile(r"斜体.{0,8}下划线|下划线.{0,8}斜体|[★**]
DECLARED = re.compile(r"★[^。;]{0,12}?共\s*(\d+)\s*[条项处]")
ASTERISK = re.compile(r"^((?\d{1,3}[)).、.]?\s*)?[**]")
NUMBERS = re.compile(r"\d+(?:\.\d+)?")
# 中文数字只认带量词的(「一次」「两年」「十个工作日」),否则「统一」「唯一」里的「一」也会被当成数字
CN_NUMBER = re.compile(r"([零〇一二两三四五六七八九十百千]+)(?=[次个年月日天周名人台套项份家条倍]|小时|分钟)")
CN_DIGIT = {"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
CN_UNIT = {"十": 10, "百": 100, "千": 1000}
END_PUNCT = "。;;,,、::!!??"
OUR_MARKUP = re.compile(r"</?(?:iu|u|i)>|〔(?:续下页|上级:[^〕]*)〕")
@dataclass(slots=True)
@@ -177,12 +187,16 @@ def chunks(sentences: list[Sentence], continuations: dict[str, Sentence]) -> lis
return out
def _render(chunk: list[Sentence], doc: Doc) -> str:
def _render(chunk: list[Sentence], doc: Doc, marks: bool = False) -> str:
"""给模型看的分块。只有找实质性条款要看字体格式(斜体下划线标注);其他任务给纯文本,
否则模型会把 <u> 这类标记抄进引文,或者只摘标记里的几个字(2026-11-03 审计)。"""
lines = []
for s in chunk:
line = f"[{_sid(s)}] {s.marked}"
line = f"[{_sid(s)}] {s.marked if marks else s.text}"
if parent := doc.parents.get(s.key):
line += f"〔上级:{parent.text[:60]}〕"
if s.key in doc.continuations:
line += "〔续下页〕" # 这句在页尾被截断,下一行是它在下一页的接续部分
lines.append(line)
notes = "\n".join(f"- {n.sentence.text[:200]}" for n in doc.notes) or "(未找到标注规则说明)"
return "## 标注规则说明\n" + notes + "\n\n## 待审分块\n" + "\n".join(lines)
@@ -192,16 +206,17 @@ def check_evidence(
ev: dict[str, Any], by_id: dict[str, Sentence], nxt: dict[str, Sentence] | None = None
) -> tuple[str | None, list[Sentence]]:
"""第一、二道:编号在本分块里;引文是原句的子串,且不少于 8 个字。返回 (没通过的那一道, 引文所在的句子)。
比对前去掉我们自己加进输入的格式标记和〔…〕附注(不是原文,模型偶尔会照抄);原文文字本身不做任何放宽。
表格单元格、跨页会把一句话切成两段(S3),模型摘引文时常把两段连起来。所以引文可以从这一句接续到紧跟着的
下一句(同一分块里的下一句,或跨页接续句),但必须从这一句开始,两句都作为出处。
"""
sid, quote = str(ev.get("sid", "")), norm(str(ev.get("quote", "")))
sid, quote = str(ev.get("sid", "")), norm(OUR_MARKUP.sub("", str(ev.get("quote", ""))))
if sid not in by_id:
return "id", []
s = by_id[sid]
text = norm(s.text)
if not quote or len(quote) < min(8, len(text)):
if not quote or len(quote) < min(8, len(text.rstrip(END_PUNCT))): # 不足 8 个字的短句要整句复制,句末标点不算
return "quote", []
if quote in text:
return None, [s]
@@ -235,13 +250,25 @@ def _next_map(chunk: list[Sentence], doc: Doc) -> dict[str, Sentence]:
return {**out, **{k: v for k, v in doc.continuations.items() if k in out or k == chunk[-1].key}}
def _context(sents: list[Sentence], chunk: list[Sentence], after: int = 2) -> list[Sentence]:
"""核对取值时的上下文:引用句加上它后面两句(表格里分值、参数值常在相邻单元格)。"""
def _context(
sents: list[Sentence], chunk: list[Sentence], after: int = 2, parents: dict[str, Sentence] | None = None
) -> list[Sentence]:
"""核对取值时的上下文:引用句加上它后面两句(表格里分值、参数值常在相邻单元格),
再加上和引用句同一行(同一页、上下范围重叠)的句子:评分表的分值常在隔了几格的「分值」列;
以及引用句的上级条款:分值写在评分因素的标题行(「5、视频演示(10分)」),
而证据里的标题会被 without_headings 去掉。"""
idx = {s.key: i for i, s in enumerate(chunk)}
out = list(sents)
out = list(sents) + [p for s in sents if parents and (p := parents.get(s.key)) and p not in sents]
for s in sents:
if s.key in idx:
out += [x for x in chunk[idx[s.key] + 1 : idx[s.key] + 1 + after] if x not in out]
if s.quads:
top, bottom = min(q[1] for q in s.quads), max(q[3] for q in s.quads)
out += [
x
for x in chunk
if x.page_no == s.page_no and x not in out and any(q[1] < bottom and q[3] > top for q in x.quads)
]
return out
@@ -260,8 +287,22 @@ def _extend(sents: list[Sentence], doc: Doc) -> list[Sentence]:
# ---------------------------------------------------------------- 取值核对(第三道)
def cn_number(s: str) -> int:
"""「十二」→ 12,「两百」→ 200,「三」→ 3。"""
total, digit = 0, 0
for ch in s:
if ch in CN_DIGIT:
digit = CN_DIGIT[ch]
else:
total += (digit or 1) * CN_UNIT[ch]
digit = 0
return total + digit
def _numbers(sents: list[Sentence]) -> set[str]:
return {n.lstrip("0") or "0" for s in sents for n in NUMBERS.findall(s.text)}
"""原文里的数字:阿拉伯数字,以及带量词的中文数字(「每月至少一次」里的 1)。"""
out = {n.lstrip("0") or "0" for s in sents for n in NUMBERS.findall(s.text)}
return out | {str(cn_number(m)) for s in sents for m in CN_NUMBER.findall(s.text)}
def check_basis(basis: str, sents: list[Sentence], chunk: list[Sentence], doc: Doc) -> bool:
@@ -279,7 +320,7 @@ def check_basis(basis: str, sents: list[Sentence], chunk: list[Sentence], doc: D
for j in (idx[s.key] - 1, idx[s.key], idx[s.key] + 1)
if 0 <= j < len(chunk)
]
near += [p for s in sents if (p := doc.parents.get(s.key))] # 上级条款写明了无效情形
near += [p for s in sents for p in structure.ancestors(s.key, doc.parents)] # 上级条款写明了无效情形
return any(INVALID.search(s.text) for s in near)
return False
@@ -331,6 +372,7 @@ class Task:
name: str
prompt_version: int
build: Callable[[dict[str, Any], list[Sentence], list[str], bool, list[Sentence], Doc], Extracted | None]
marks: bool = False # 输入是否带字体格式标记
def _item(
@@ -416,10 +458,10 @@ def _qualification(
def _scoring(
it: dict[str, Any], sents: list[Sentence], passed: list[str], ok: bool, chunk: list[Sentence], doc: Doc
) -> Extracted | None:
ctx = _context(sents, chunk) # 取值可以在引用句后面的相邻单元格里
ctx = _context(sents, chunk, parents=doc.parents) # 取值可以在相邻单元格、同一行或评分因素标题里
part = it.get("part") if it.get("part") in ("price", "technical", "commercial") else None
points = it.get("points")
unit = "" if not points or str(points).endswith(("%", "分")) else " 分" # 价格扣除是百分比,不加「分」
unit = "" if not points or str(points).endswith(("%", "分")) else " 分" # 价格扣除是百分比,不加「分」
title = f"{it.get('factor') or '评分项'}" + (f"({points}{unit})" if points else "")
return _item(
"scoring",
@@ -500,11 +542,11 @@ def _tech_param(
TASKS = [
Task("extract.substantive", 2, _substantive),
Task("extract.qualification", 1, _qualification),
Task("extract.scoring", 1, _scoring),
Task("extract.schedule", 1, _schedule),
Task("extract.tech_param", 1, _tech_param),
Task("extract.substantive", 3, _substantive, marks=True),
Task("extract.qualification", 2, _qualification),
Task("extract.scoring", 2, _scoring),
Task("extract.schedule", 2, _schedule),
Task("extract.tech_param", 2, _tech_param),
]
INFO_TASK = ("extract.project_info", 1)
INFO_KEYS = (
@@ -541,7 +583,7 @@ async def run(
total = len(parts) * len(TASKS) + min(INFO_CHUNKS, len(parts))
done = 0
async def call(task: str, version: int, chunk: list[Sentence]) -> dict[str, Any]:
async def call(task: str, version: int, chunk: list[Sentence], marks: bool = False) -> dict[str, Any]:
nonlocal done
async with gate:
data = await ai.run_json(
@@ -551,14 +593,14 @@ async def run(
task=task,
prompt_id=task,
version=version,
user=_render(chunk, doc),
user=_render(chunk, doc, marks),
)
done += 1
if progress:
await progress(done / total)
return data
jobs = [(t, c, call(t.name, t.prompt_version, c)) for t in TASKS for c in parts]
jobs = [(t, c, call(t.name, t.prompt_version, c, t.marks)) for t in TASKS for c in parts]
info_jobs = [(c, call(*INFO_TASK, c)) for c in parts[:INFO_CHUNKS]]
results = await asyncio.gather(*(j for _, _, j in jobs), *(j for _, j in info_jobs))
@@ -668,10 +710,20 @@ def fallback(items: list[Extracted], doc: Doc) -> list[Extracted]:
skip: set[str] = set()
for s in doc.sentences:
marker = is_marked(s, doc.declared)
parent = doc.parents.get(s.key)
if not marker and parent is not None and BID_INVALID_HEADING.search(parent.text) and "质疑" not in parent.text:
text = s.text.strip()
# 列在「无效情形」下、自己带编号的条目才兜底;以冒号结尾的是引导句,不是条款本身
numbered = bool(structure.DOTTED.match(text) or structure.LIST_ITEM.match(text)) and not text.endswith(
(":", ":")
)
listed = numbered and any(
BID_INVALID_HEADING.search(p.text) and "质疑" not in p.text for p in structure.ancestors(s.key, doc.parents)
)
if not marker and listed:
marker = "explicit_invalid" # 列在「无效情形」标题下的条目:上级标题就是依据
if not marker or s.key in covered or s.key in skip or s.key in doc.note_keys or len(norm(s.text)) < 6:
# 标注规则说明本身不是条款;但列在「无效情形」下的那一条(如国动 25.3.6「不符合……实质性要求的(斜体且有
# 下划线部分……)」)既说明了标注方式,也是一种无效情形,照样兜底
note = s.key in doc.note_keys and not listed
if not marker or s.key in covered or s.key in skip or note or len(norm(s.text)) < 6:
continue
sents = _extend([s], doc)
skip.update(x.key for x in sents)

View File

@@ -5,7 +5,7 @@ from decimal import Decimal
from sqlalchemy import FetchedValue
from sqlalchemy.orm import Mapped, mapped_column
from bid_doctor.core.columns import PK, DbTs, Json, TextArray, Ts, UuidCol
from bid_doctor.core.columns import PK, Bytes, DbTs, Json, TextArray, Ts, UuidCol
from bid_doctor.core.db import Base
@@ -61,7 +61,13 @@ class ChecklistItem(Base):
anchor_sentence_key: Mapped[str | None]
sort_key: Mapped[str | None]
superseded_by_id: Mapped[UuidCol | None]
no_citation_ack_by: Mapped[UuidCol | None]
accepted_by: Mapped[UuidCol | None]
accepted_at: Mapped[Ts | None]
deleted_at: Mapped[Ts | None]
deleted_by: Mapped[UuidCol | None]
created_by: Mapped[UuidCol | None]
created_at: Mapped[DbTs]
updated_at: Mapped[DbTs]
@@ -71,3 +77,26 @@ class ChecklistItemCitation(Base):
item_id: Mapped[UuidCol] = mapped_column(primary_key=True)
citation_id: Mapped[UuidCol] = mapped_column(primary_key=True)
position: Mapped[int]
class ChecklistVersion(Base):
"""不可变快照(FR-M2-08):确认清单时生成。"""
__tablename__ = "checklist_version"
id: Mapped[PK]
tenant_id: Mapped[UuidCol]
checklist_id: Mapped[UuidCol]
version_no: Mapped[int]
kind: Mapped[str]
content_hash: Mapped[Bytes]
item_count: Mapped[int]
created_by: Mapped[UuidCol]
created_at: Mapped[DbTs]
class ChecklistVersionItem(Base):
__tablename__ = "checklist_version_item"
tenant_id: Mapped[UuidCol] = mapped_column(primary_key=True)
version_id: Mapped[UuidCol] = mapped_column(primary_key=True)
item_id: Mapped[UuidCol] = mapped_column(primary_key=True)
payload: Mapped[Json]

View File

@@ -5,15 +5,18 @@ from __future__ import annotations
from typing import Any
from uuid import UUID
from sqlalchemy import delete, func, select
from sqlalchemy import case, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.checklist.extract import Extracted, MarkingNote
from bid_doctor.checklist.models import Checklist, ChecklistItem, ChecklistItemCitation, MarkingRule
from bid_doctor.checklist.models import Checklist, ChecklistItem, ChecklistItemCitation, ChecklistVersion, MarkingRule
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.models import Citation, SourceDocument
from bid_doctor.identity import service as identity
from bid_doctor.project import service as projects
async def get_or_create(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> Checklist:
@@ -37,15 +40,14 @@ async def merge(
"""并入清单。重新解析时按首句编号对齐旧条目:对上的只更新 AI 字段,人工改过的不动;
对不上的旧条目移到待确认区(本次解析未出现),不静默删除(架构 §3.8)。"""
cl = await get_or_create(s, ctx, doc.project_id)
# 已删除(丢弃)的条目也参与对齐:对上了就保持删除,不会因为重新解析又冒出来
old = {
i.anchor_sentence_key: i
for i in (
await s.execute(
select(ChecklistItem).where(
ChecklistItem.origin_document_id == doc.id,
ChecklistItem.origin != "human",
ChecklistItem.deleted_at.is_(None),
)
select(ChecklistItem)
.where(ChecklistItem.origin_document_id == doc.id, ChecklistItem.origin != "human")
.order_by(ChecklistItem.deleted_at.desc().nulls_first())
)
).scalars()
}
@@ -107,19 +109,27 @@ async def merge(
if k not in item.edited_fields:
setattr(item, k, v)
stats["items_updated"] += 1
item.importance_basis = e.importance_basis
if "importance" not in item.edited_fields:
item.importance_basis = e.importance_basis
item.marking_rule_id = rules.get(e.basis) if e.basis else None
item.zone, item.pending_reason = e.zone, e.pending_reason
item.ai_values, item.last_parse_job_id, item.anchor_sentence_key = e.ai_values or None, job_id, anchor
if item.accepted_at is None: # 用户采纳过的条目留在正式清单
item.zone, item.pending_reason = e.zone, e.pending_reason
original = (item.ai_values or {}).get("original") # 人工第一次修改前的 AI 值(edit.update 记的)
item.ai_values = {**e.ai_values, **({"original": original} if original else {})} or None
item.last_parse_job_id, item.anchor_sentence_key = job_id, anchor
item.sort_key = f"{e.sentences[0].page_no:05d}.{e.sentences[0].seq:05d}" if e.sentences else "99999"
stats["items_pending"] += e.zone == "pending"
stats["items_pending"] += item.zone == "pending" and item.deleted_at is None
await s.flush()
if "citations" in item.edited_fields:
continue # 出处是人工补的或换过的:不用这次解析的结果覆盖
await s.execute(delete(ChecklistItemCitation).where(ChecklistItemCitation.item_id == item.id))
if e.sentences:
kind = "ai" if item.origin == "ai" else "rule"
for pos, c in enumerate(await citations.from_sentences(s, ctx, doc, e.sentences, kind, e.checks_passed)):
s.add(ChecklistItemCitation(tenant_id=ctx.tenant_id, item_id=item.id, citation_id=c.id, position=pos))
for item in old.values():
if item.deleted_at is not None:
continue
item.zone, item.pending_reason = "pending", "absent_in_rerun"
stats["items_absent"] += 1
if cl.status == "confirmed":
@@ -130,17 +140,40 @@ async def merge(
async def overview(s: AsyncSession, project_id: UUID) -> dict[str, Any]:
cl = (await s.execute(select(Checklist).where(Checklist.project_id == project_id))).scalar_one_or_none()
zone_or_deleted = case((ChecklistItem.deleted_at.is_not(None), "deleted"), else_=ChecklistItem.zone)
counts = dict(
(
await s.execute(
select(ChecklistItem.zone, func.count())
.where(ChecklistItem.project_id == project_id, ChecklistItem.deleted_at.is_(None))
.group_by(ChecklistItem.zone)
select(zone_or_deleted, func.count())
.where(ChecklistItem.project_id == project_id)
.group_by(zone_or_deleted)
)
)
.tuples()
.all()
)
category = func.unnest(ChecklistItem.categories)
by_category = dict(
(
await s.execute(
select(category, func.count())
.where(
ChecklistItem.project_id == project_id,
ChecklistItem.zone == "formal",
ChecklistItem.deleted_at.is_(None),
)
.group_by(category)
)
)
.tuples()
.all()
)
version = (
(await s.execute(select(ChecklistVersion).where(ChecklistVersion.id == cl.confirmed_version_id))).scalar_one()
if cl and cl.confirmed_version_id
else None
)
names = await identity.display_names(s, [version.created_by] if version else [])
rules = (
await s.execute(
select(MarkingRule, Citation, SourceDocument.role, SourceDocument.seq_no)
@@ -176,9 +209,14 @@ async def overview(s: AsyncSession, project_id: UUID) -> dict[str, Any]:
return {
"status": cl.status if cl else "draft",
"version_no": cl.last_version_no if cl else 0,
"confirmed_by": None,
"confirmed_at": None,
"counts": {"formal": counts.get("formal", 0), "pending": counts.get("pending", 0), "deleted": 0},
"confirmed_by": {"id": version.created_by, "name": names.get(version.created_by, "")} if version else None,
"confirmed_at": version.created_at if version else None,
"counts": {
"formal": counts.get("formal", 0),
"pending": counts.get("pending", 0),
"deleted": counts.get("deleted", 0),
"by_category": by_category,
},
"marking_rules": [
{
"id": r.id,
@@ -195,10 +233,23 @@ async def overview(s: AsyncSession, project_id: UUID) -> dict[str, Any]:
}
async def items(s: AsyncSession, project_id: UUID, zone: str | None) -> list[dict[str, Any]]:
q = select(ChecklistItem).where(ChecklistItem.project_id == project_id, ChecklistItem.deleted_at.is_(None))
async def items(
s: AsyncSession,
project_id: UUID,
zone: str | None = None,
category: str | None = None,
include_deleted: bool = False,
ids: list[UUID] | None = None,
) -> list[dict[str, Any]]:
q = select(ChecklistItem).where(ChecklistItem.project_id == project_id)
if not include_deleted:
q = q.where(ChecklistItem.deleted_at.is_(None))
if zone:
q = q.where(ChecklistItem.zone == zone)
if category:
q = q.where(ChecklistItem.categories.contains([category]))
if ids is not None:
q = q.where(ChecklistItem.id.in_(ids))
rows = (await s.execute(q.order_by(ChecklistItem.sort_key, ChecklistItem.id))).scalars().all()
links = (
await s.execute(
@@ -212,10 +263,36 @@ async def items(s: AsyncSession, project_id: UUID, zone: str | None) -> list[dic
by_item: dict[UUID, list[dict[str, Any]]] = {}
for item_id, c, role, seq in links:
by_item.setdefault(item_id, []).append(citations.to_api(c, citations.document_label(role, seq)))
return [_item(r, by_item.get(r.id, [])) for r in rows]
names = await identity.display_names(s, [r.accepted_by for r in rows if r.accepted_by])
lots = await projects.lot_labels(s, project_id)
return [_item(r, by_item.get(r.id, []), names, lots) for r in rows]
def _item(r: ChecklistItem, cites: list[dict[str, Any]]) -> dict[str, Any]:
async def item(s: AsyncSession, item_id: UUID) -> dict[str, Any]:
r = await load(s, item_id)
(out,) = await items(s, r.project_id, include_deleted=True, ids=[r.id])
return out
async def load(s: AsyncSession, item_id: UUID) -> ChecklistItem:
r = (await s.execute(select(ChecklistItem).where(ChecklistItem.id == item_id))).scalar_one_or_none()
if r is None:
raise AppError("not_found")
return r
def etag(r: ChecklistItem | dict[str, Any]) -> str:
"""清单项的版本:取最后修改时间(api-design §4.5)。"""
at = r["updated_at"] if isinstance(r, dict) else r.updated_at
return f'"{at.timestamp():.6f}"'
ORIGIN_LABEL = {"ai": "AI", "rule": "规则", "human": "人工添加"}
def _item(
r: ChecklistItem, cites: list[dict[str, Any]], names: dict[UUID, str], lots: dict[UUID, str | None]
) -> dict[str, Any]:
return {
"id": r.id,
"categories": r.categories,
@@ -226,21 +303,21 @@ def _item(r: ChecklistItem, cites: list[dict[str, Any]]) -> dict[str, Any]:
"importance_basis": r.importance_basis,
"marking_rule_id": r.marking_rule_id,
"lot_id": r.lot_id,
"lot_no": None,
"lot_no": lots.get(r.lot_id) if r.lot_id else None,
"evidence_required": r.evidence_required,
"spec": r.spec,
"origin": r.origin,
"origin_label": {"ai": "AI", "rule": "规则", "human": "人工添加"}[r.origin],
"origin_label": ORIGIN_LABEL[r.origin],
"zone": r.zone,
"pending_reason": r.pending_reason,
"no_citation_ack": False,
"no_citation_ack": r.no_citation_ack_by is not None,
"citations": cites,
"ai_values": r.ai_values,
"edited_fields": r.edited_fields,
"confidence": float(r.confidence) if r.confidence is not None else None,
"superseded_by": None,
"accepted_by": None,
"deleted_at": None,
"accepted_by": {"id": r.accepted_by, "name": names.get(r.accepted_by, "")} if r.accepted_by else None,
"deleted_at": r.deleted_at,
"updated_at": r.updated_at,
}

View File

@@ -44,6 +44,8 @@ class Settings(BaseSettings):
ai_base_url: str = ""
ai_api_key: str = ""
ai_model: str = ""
# 按能力选模型(T1 场景路由):{"reasoning": "deepseek-reasoner"};没配的能力用 ai_model
ai_models: dict[str, str] = Field(default_factory=dict)
upload_part_size: int = 8 * 1024 * 1024
download_url_ttl_s: int = 300

View File

@@ -29,6 +29,8 @@ CODES: dict[str, tuple[int, str]] = {
"document_role_not_parsable": (409, "这类文件不需要单独解析"),
"document_not_ready": (409, "文件还在处理"),
"upload_incomplete": (409, "文件还没有上传完"),
"item_not_pending": (409, "这一条已经不在待确认区"),
"checklist_empty": (409, "正式清单还没有条目"),
"precondition_failed": (412, "内容已被修改,请刷新后再改"),
"validation_failed": (422, "填写的内容有误"),
"sms_code_invalid": (422, "验证码不正确或已过期"),
@@ -37,6 +39,8 @@ CODES: dict[str, tuple[int, str]] = {
"file_corrupted": (422, "文件已损坏,请换一个文件"),
"bid_file_platform_encrypted": (422, "这是电子投标平台加密后的文件,请上传加密前的 Word 或 PDF"),
"upload_checksum_mismatch": (422, "文件校验不一致,请重新上传"),
"citation_required": (422, "请补上出处,或者声明「人工确认、无出处」"),
"sentence_not_found": (422, "选中的句子不在这份文件里,或不在同一页"),
"ocr_required": (422, "这份文件大部分页面没有文字(可能是扫描件),扫描件识别还没有上线"),
"rate_limited": (429, "操作太频繁,请稍后再试"),
"internal_error": (500, "系统出错了,请稍后再试"),

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import json
from typing import Annotated, Any, Literal
from uuid import UUID
@@ -15,9 +14,10 @@ from bid_doctor.core.crypto import open_object, sign_download, verify_download
from bid_doctor.core.db import TenantContext, tenant_tx
from bid_doctor.core.errors import AppError
from bid_doctor.core.storage import storage
from bid_doctor.document import service, uploads
from bid_doctor.document import citations, service, uploads
from bid_doctor.document.models import DocumentPage, SourceDocument
from bid_doctor.identity.deps import Ctx
from bid_doctor.project import service as projects
router = APIRouter(tags=["uploads", "documents"])
@@ -125,11 +125,7 @@ async def list_pages(document_id: UUID, ctx: Ctx) -> dict[str, Any]:
@router.get("/documents/{document_id}/pages/{page_no}/sentences")
async def list_sentences(document_id: UUID, page_no: int, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
doc = await service.get(s, document_id)
dk = await keys.data_key(s, ctx, doc.project_id)
if not doc.text_layer_key:
raise AppError("document_not_ready")
pages = json.loads(open_object(dk, storage().get(doc.text_layer_key), doc.text_layer_key))
pages = await service.text_layer(s, ctx, await service.get(s, document_id))
page = next((p for p in pages if p["page_no"] == page_no), None)
if page is None:
raise AppError("not_found")
@@ -188,3 +184,17 @@ async def get_parse_job(job_id: UUID, ctx: Ctx) -> dict[str, Any]:
async def cancel_parse_job(job_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return service.job_to_api(await service.request_cancel(s, job_id))
class CitationIn(BaseModel):
document_id: UUID
sentence_keys: list[str] = Field(min_length=1, max_length=200)
@router.post("/projects/{project_id}/citations", status_code=201)
async def create_citation(project_id: UUID, body: CitationIn, ctx: Ctx) -> dict[str, Any]:
"""人工补出处:在阅读器里选中同一页的句子,建一条出处,再挂到清单项上(采纳或替换出处)。"""
async with tenant_tx(ctx) as s:
projects.ensure_editable(await projects.get(s, ctx, project_id))
c = await service.human_citation(s, ctx, project_id, body.document_id, body.sentence_keys)
return (await citations.to_api_many(s, [c.id]))[c.id]

View File

@@ -46,7 +46,7 @@ PROGRESS = {
"merge": 95,
}
# text_layer v2:页眉页脚带单独成句,句子编号和 v1 不同(同一份规范化 PDF 在 v1、v2 下的编号不能混用)
PROFILE = {"converter": "gotenberg-8", "fonts": "gotenberg-default", "text_layer": "v2", "sentence_split": "s3-v2"}
PROFILE = {"converter": "gotenberg-8", "fonts": "gotenberg-default", "text_layer": "v3", "sentence_split": "s3-v3"}
class Cancelled(Exception):

View File

@@ -3,9 +3,12 @@
用处:「25.3 未实质性响应磋商文件的情形」下面列出的 25.3.1~25.3.11,每一条本身都不写「无效」,
要靠上级标题才知道是无效情形。抽取时把上级标题作为上下文给模型,依据校验也认上级标题。
只认两种最常见的写法:
认这几种写法:
1. 十进制分级编号:25.3 → 25.3.1,上级是编号去掉最后一段的那一句;
2. 冒号引出的列表:「有下列情形之一的,投标无效:」后面跟(1)(2)……或 1. 2. ……或 ①②……
3. 中文编号的小节:「三、符合条件(必须提供,否则投标无效)」下面的 1. 2. 3.……(S02 第 20 页);
4. 以冒号结尾的编号条目:「1.报价符合性审查:」下面不带编号的句子,到下一个编号为止。
上级还可以有上级(第 4 种的上级常常是第 3 种),用 ancestors() 取整条链。
TODO(结构识别):章、节层级,表格结构,分包识别。
"""
@@ -18,6 +21,7 @@ from bid_doctor.document.pipeline.text_layer import Sentence
DOTTED = re.compile(r"^(\d{1,3}(?:\.\d{1,3})+)(?![.\d])")
LIST_ITEM = re.compile(r"^((\d{1,2})|\(\d{1,2}\)|\d{1,2}[.、.](?!\d)|[①-⑳]|[a-z][.、))])")
CHAPTER = re.compile(r"^第[一二三四五六七八九十百\d]+[章节]")
CN_SECTION = re.compile(r"^(?:[一二三四五六七八九十]{1,3}、|([一二三四五六七八九十]{1,3}))")
LEAD_MAX = 30 # 冒号引出的列表,最多往后认这么多句
@@ -41,12 +45,28 @@ def parents(sentences: list[Sentence]) -> dict[str, Sentence]:
lead: Sentence | None = None
lead_left = 0
expect = 1 # 冒号引出的列表:序号要从 1 开始连续,接不上就是列表结束了(例如下一节的「26.评审」)
section: Sentence | None = None # 中文编号的小节标题(第 3 种)
section_expect = 1
sub: Sentence | None = None # 以冒号结尾的编号条目(第 4 种)
for s in sentences:
text = s.text.strip()
m = DOTTED.match(text)
no = _list_no(text)
if CHAPTER.match(text) or m:
section, sub = None, None
if CHAPTER.match(text):
lead, by_number = None, {}
no = _list_no(text)
if CN_SECTION.match(text):
section, section_expect, sub = s, 1, None
elif lead is None and section is not None and not m and no is not None:
if no == section_expect:
out[s.key] = section
section_expect += 1
sub = s if text.endswith((":", ":")) else None
else:
section, sub = None, None # 序号接不上:小节里的列表结束了
elif sub is not None and no is None and not m:
out[s.key] = sub
if m:
number = m.group(1)
parent_no = number.rsplit(".", 1)[0]
@@ -66,3 +86,13 @@ def parents(sentences: list[Sentence]) -> dict[str, Sentence]:
if text.endswith((":", ":")) and no is None:
lead, lead_left, expect = s, LEAD_MAX, 1
return out
def ancestors(key: str, parents: dict[str, Sentence], depth: int = 3) -> list[Sentence]:
"""上级、上级的上级……(最多 depth 层)。"""
out: list[Sentence] = []
while key in parents and len(out) < depth:
p = parents[key]
out.append(p)
key = p.key
return out

View File

@@ -191,10 +191,24 @@ def _band_repeats(pages: list[tuple[Any, list[list[dict[str, Any]]]]]) -> set[st
return {t for t, ps in seen.items() if t and len(ps) >= need}
def _underlines(page: Any) -> list[tuple[float, float, float]]:
"""可能是下划线的横线 (x0, x1, y)。表格框线不算:它两端都接着竖线(单元格的角)。
国动样本第 26~32 页的功能表:每格最后一行字正好压在框线上,整句被当成「下划线」,
模型据此把普通技术要求认成斜体下划线的实质性条款(2026-11-03 审计)。"""
hl = [(ln["x0"], ln["x1"], ln["top"]) for ln in page.lines if abs(ln["top"] - ln["bottom"]) < 0.5]
hl += [(r["x0"], r["x1"], r["top"]) for r in page.rects if r["height"] < 1.5 and r["width"] > 2]
vl = [(ln["x0"], ln["top"], ln["bottom"]) for ln in page.lines if abs(ln["x0"] - ln["x1"]) < 0.5]
vl += [(r["x0"], r["top"], r["bottom"]) for r in page.rects if r["width"] < 1.5 and r["height"] > 2]
def joined(x: float, y: float) -> bool:
return any(abs(vx - x) < 1.5 and vt - 1.5 <= y <= vb + 1.5 for vx, vt, vb in vl)
return [(x0, x1, y) for x0, x1, y in hl if not (joined(x0, y) and joined(x1, y))]
def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], repeats: set[str]) -> Page:
"""页眉页脚带里的页码和重复行单独成句(location = header_footer),不并进正文句子(S3 的改进点 2)。"""
hlines = [(ln["x0"], ln["x1"], ln["top"]) for ln in page.lines if abs(ln["top"] - ln["bottom"]) < 0.5]
hlines += [(r["x0"], r["x1"], r["top"]) for r in page.rects if r["height"] < 1.5 and r["width"] > 2]
hlines = _underlines(page)
chars = [c for c in page.chars if str(c["text"]).strip()]
height = float(page.height)
@@ -207,21 +221,28 @@ def _page(doc_id: str, page: Any, frags: list[list[dict[str, Any]]], repeats: se
groups: list[tuple[str, list[tuple[int, dict[str, Any]]]]] = []
open_tail = False
cur: list[tuple[int, dict[str, Any]]] = []
# 句末标点之后紧跟的右括号、右引号(可能在下一行)还属于这一句:「……格式》。);」不切成「……。」和「);」
ended = False
for fi, frag in enumerate(frags):
if is_band(frag):
if ended:
groups.append(("body", cur))
cur, ended = [], False
groups.append(("header_footer", [(fi, c) for c in frag]))
continue
for c in frag:
if ended and c["text"] not in CLOSERS and c["text"] not in END:
groups.append(("body", cur))
cur, ended = [], False
cur.append((fi, c))
if c["text"] in END:
groups.append(("body", cur))
cur = []
if cur and frag[-1]["x1"] < right - 2 * frag[-1]["size"]:
ended = True
if cur and not ended and frag[-1]["x1"] < right - 2 * frag[-1]["size"]:
groups.append(("body", cur))
cur = []
if cur:
groups.append(("body", cur))
open_tail = True # 页面写完了句子还没结束
open_tail = not ended # 页面写完了句子还没结束
tail_seq = max((i for i, (loc, _) in enumerate(groups, 1) if loc == "body"), default=0) if open_tail else 0
sentences: list[Sentence] = []
for seq, (location, g) in enumerate(groups, 1):

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
@@ -12,14 +13,15 @@ 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.core import keys, queue
from bid_doctor.core.crypto import seal_object, sha256
from bid_doctor.core.crypto import open_object, seal_object, sha256
from bid_doctor.core.db import TenantContext
from bid_doctor.core.errors import AppError
from bid_doctor.core.ids import uuid7
from bid_doctor.core.storage import project_prefix, storage
from bid_doctor.document import uploads
from bid_doctor.document import citations, uploads
from bid_doctor.document.citations import document_label
from bid_doctor.document.models import ParseJob, SourceDocument
from bid_doctor.document.models import Citation, ParseJob, SourceDocument
from bid_doctor.document.pipeline.text_layer import Page
from bid_doctor.project import service as projects
PARSABLE = {"tender", "addendum", "clarification"}
@@ -227,3 +229,27 @@ async def summaries(s: AsyncSession, project_ids: list[UUID]) -> dict[UUID, dict
).scalars():
out[job.project_id]["job"] = {"kind": "parse_job", "id": job.id, "progress": job.progress, "label": "解析中"}
return out
async def text_layer(s: AsyncSession, ctx: TenantContext, doc: SourceDocument) -> list[dict[str, Any]]:
"""文字层(逐页的句子和坐标框),加密存在对象存储里(DM1)。"""
if not doc.text_layer_key:
raise AppError("document_not_ready")
dk = await keys.data_key(s, ctx, doc.project_id)
return json.loads(open_object(dk, storage().get(doc.text_layer_key), doc.text_layer_key))
async def human_citation(
s: AsyncSession, ctx: TenantContext, project_id: UUID, document_id: UUID, sentence_keys: list[str]
) -> Citation:
"""人工在阅读器里选中句子建出处(api-design §4.4):句子必须在这份文档的同一页上。"""
doc = await get(s, document_id)
if doc.project_id != project_id:
raise AppError("not_found")
pages = [Page.from_json(p) for p in await text_layer(s, ctx, doc)]
by_key = {x.key: x for p in pages for x in p.sentences}
chosen = [by_key[k] for k in dict.fromkeys(sentence_keys) if k in by_key]
if len(chosen) != len(set(sentence_keys)) or len({x.page_no for x in chosen}) != 1:
raise AppError("sentence_not_found")
(c,) = await citations.from_sentences(s, ctx, doc, chosen, "human", [])
return c

View File

@@ -259,3 +259,12 @@ def _device_label(ua: str | None) -> str:
browser = next((b for b in ("Edg", "Chrome", "Firefox", "Safari") if b in ua), "浏览器")
system = next((n for k, n in (("Windows", "Windows"), ("Mac OS", "macOS"), ("Linux", "Linux")) if k in ua), "")
return f"{browser.replace('Edg', 'Edge')} · {system}".strip(" ·")
async def display_names(s: AsyncSession, user_ids: list[UUID]) -> dict[UUID, str]:
"""操作人姓名(给别的模块展示「谁确认的」用,别的模块不直接读人员表)。"""
ids = list({i for i in user_ids if i})
if not ids:
return {}
rows = await s.execute(select(UserAccount.id, UserAccount.display_name).where(UserAccount.id.in_(ids)))
return dict(rows.tuples().all())

View File

@@ -127,3 +127,9 @@ async def fields(s: AsyncSession, project_id: UUID) -> list[dict[str, Any]]:
}
)
return out
async def lot_labels(s: AsyncSession, project_id: UUID) -> dict[UUID, str | None]:
"""项目的分包编号(给清单项展示「采购包 N」、校验 lot_id 用);默认包没有编号,表示各包通用。"""
rows = await s.execute(select(Lot.id, Lot.lot_no, Lot.is_default).where(Lot.project_id == project_id))
return {i: None if default else no for i, no, default in rows.tuples()}

View File

@@ -84,6 +84,12 @@ class User:
async def put(self, path: str, **kw: Any) -> httpx.Response:
return await self.client.put(path, headers=self.headers, **kw)
async def patch(self, path: str, **kw: Any) -> httpx.Response:
return await self.client.patch(path, headers={**self.headers, **kw.pop("headers", {})}, **kw)
async def delete(self, path: str) -> httpx.Response:
return await self.client.delete(path, headers=self.headers)
@property
def tenant_id(self) -> str:
return str(self.me["tenant"]["id"])

View File

@@ -0,0 +1,154 @@
"""读标的修改类接口(FR-M2-07、08):采纳、修改、删除恢复、人工新增、补出处、确认清单、修改记录。"""
from __future__ import annotations
from typing import Any
from tests.conftest import User, project_with_tender, run_parse
async def _parsed(user: User) -> tuple[str, str, list[dict[str, Any]]]:
project_id, doc_id = await project_with_tender(user, "tender-full.pdf")
assert (await run_parse(user, doc_id))["status"] == "succeeded"
items = (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]
return project_id, doc_id, items
def _pending_star(items: list[dict[str, Any]]) -> dict[str, Any]:
return next(i for i in items if i["zone"] == "pending" and (i["requirement_text"] or "").startswith("*供应商"))
async def test_accept_pending_with_changes_then_confirm(user: User) -> None:
project_id, _, items = await _parsed(user)
star = _pending_star(items)
assert star["citations"] # 规则兜底的条目有出处,直接采纳即可
r = await user.post(f"/checklist-items/{star['id']}/accept", json={"changes": {"title": "须提供原厂授权书"}})
assert r.status_code == 200, r.text
it = r.json()
assert (it["zone"], it["pending_reason"], it["title"]) == ("formal", None, "须提供原厂授权书")
assert it["edited_fields"] == ["title"] and it["accepted_by"]["name"] == "张工"
assert it["ai_values"]["original"]["title"] == star["title"]
again = await user.post(f"/checklist-items/{star['id']}/accept", json={})
assert again.status_code == 409 and again.json()["code"] == "item_not_pending"
r = await user.post(f"/projects/{project_id}/checklist/confirm")
assert r.status_code == 200, r.text
cl = r.json()
assert (cl["status"], cl["version_no"], cl["confirmed_by"]["name"]) == ("confirmed", 1, "张工")
versions = (await user.get(f"/projects/{project_id}/checklist/versions")).json()["items"]
assert [(v["version_no"], v["kind"], v["item_count"]) for v in versions] == [
(1, "confirmed", cl["counts"]["formal"])
]
assert (await user.get(f"/projects/{project_id}")).json()["next_action"] == "upload_bid"
# 再改正式清单:回到草稿;改回原样再确认,复用 v1,不新增版本
formal = next(i for i in items if i["zone"] == "formal")
r = await user.patch(f"/checklist-items/{formal['id']}", json={"title": "临时改一下"})
assert r.status_code == 200 and r.headers["ETag"]
assert (await user.get(f"/projects/{project_id}/checklist")).json()["status"] == "draft"
await user.patch(f"/checklist-items/{formal['id']}", json={"title": formal["title"]})
cl = (await user.post(f"/projects/{project_id}/checklist/confirm")).json()
assert (cl["status"], cl["version_no"]) == ("confirmed", 1)
async def test_accept_needs_citation_or_ack(user: User) -> None:
project_id, doc_id, items = await _parsed(user)
star = _pending_star(items)
assert (await user.put(f"/checklist-items/{star['id']}/citations", json={"citation_ids": []})).status_code == 200
r = await user.post(f"/checklist-items/{star['id']}/accept", json={})
assert r.status_code == 422 and r.json()["code"] == "citation_required"
# 在原文里选句子补出处,再采纳
page = star["citations"][0]["page_no"]
sentences = (await user.get(f"/documents/{doc_id}/pages/{page}/sentences")).json()["items"]
key = next(s["key"] for s in sentences if s["text"].startswith("*供应商"))
r = await user.post(f"/projects/{project_id}/citations", json={"document_id": doc_id, "sentence_keys": [key]})
assert r.status_code == 201, r.text
cite = r.json()
assert cite["created_by_kind"] == "human" and cite["page_no"] == page and cite["quads"]
bad = await user.post(
f"/projects/{project_id}/citations", json={"document_id": doc_id, "sentence_keys": ["x:p1:s1"]}
)
assert bad.status_code == 422 and bad.json()["code"] == "sentence_not_found"
it = (await user.post(f"/checklist-items/{star['id']}/accept", json={"citation_ids": [cite["id"]]})).json()
assert it["zone"] == "formal" and [c["id"] for c in it["citations"]] == [cite["id"]]
assert "citations" in it["edited_fields"] and not it["no_citation_ack"]
async def test_accept_without_citation_ack(user: User) -> None:
_, _, items = await _parsed(user)
star = _pending_star(items)
await user.put(f"/checklist-items/{star['id']}/citations", json={"citation_ids": []})
it = (await user.post(f"/checklist-items/{star['id']}/accept", json={"no_citation_ack": True})).json()
assert it["zone"] == "formal" and it["no_citation_ack"] and not it["citations"]
async def test_discard_restore_and_reparse_keeps_decisions(user: User) -> None:
project_id, doc_id, items = await _parsed(user)
star = _pending_star(items)
formal = next(i for i in items if i["zone"] == "formal")
assert (await user.delete(f"/checklist-items/{star['id']}")).status_code == 204
await user.patch(f"/checklist-items/{formal['id']}", json={"title": "人工改过的标题"})
# 重新解析:丢弃的不再冒出来,人工改过的字段不被覆盖
assert (await run_parse(user, doc_id))["status"] == "succeeded"
now = {i["id"]: i for i in (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]}
assert star["id"] not in now and now[formal["id"]]["title"] == "人工改过的标题"
counts = (await user.get(f"/projects/{project_id}/checklist")).json()["counts"]
assert counts["deleted"] == 1
restored = (await user.post(f"/checklist-items/{star['id']}/restore")).json()
assert restored["deleted_at"] is None and restored["zone"] == "pending"
history = (await user.get(f"/checklist-items/{star['id']}/history")).json()["items"]
assert [h["action"] for h in history] == [
"checklist_item.restore",
"checklist_item.discard",
"checklist_item.extract",
]
assert history[-1]["actor"]["name"] == "系统"
async def test_update_validation_and_precondition(user: User) -> None:
_, _, items = await _parsed(user)
formal = next(i for i in items if i["zone"] == "formal" and i["importance"] == "normal")
r = await user.patch(
f"/checklist-items/{formal['id']}", json={"importance": "substantive", "categories": ["scoring"]}
)
assert r.status_code == 422 and r.json()["code"] == "validation_failed"
r = await user.patch(f"/checklist-items/{formal['id']}", json={"title": None})
assert r.status_code == 422
r = await user.patch(f"/checklist-items/{formal['id']}", json={"title": "x"}, headers={"If-Match": '"0"'})
assert r.status_code == 412 and r.json()["code"] == "precondition_failed"
etag = (await user.get(f"/checklist-items/{formal['id']}")).headers["ETag"]
r = await user.patch(
f"/checklist-items/{formal['id']}", json={"importance": "important"}, headers={"If-Match": etag}
)
assert r.status_code == 200 and r.json()["importance_basis"] == "human"
(h, *_) = (await user.get(f"/checklist-items/{formal['id']}/history")).json()["items"]
assert h["action"] == "checklist_item.update"
assert h["changes"] == [{"field": "importance", "before": "normal", "after": "important"}]
assert h["actor"]["name"] == "张工"
async def test_manual_item(user: User) -> None:
project_id, _, _ = await _parsed(user)
r = await user.post(
f"/projects/{project_id}/checklist/items", json={"categories": ["format_doc"], "title": "投标函须加盖公章"}
)
assert r.status_code == 201, r.text
it = r.json()
assert (it["origin"], it["origin_label"], it["zone"], it["citations"]) == ("human", "人工添加", "formal", [])
filtered = (await user.get(f"/projects/{project_id}/checklist/items", params={"category": "format_doc"})).json()
assert [i["id"] for i in filtered["items"]] == [it["id"]]
(h,) = (await user.get(f"/checklist-items/{it['id']}/history")).json()["items"]
assert h["action"] == "checklist_item.create"
async def test_confirm_empty_checklist(user: User) -> None:
project = (await user.post("/projects", json={})).json()
r = await user.post(f"/projects/{project['id']}/checklist/confirm")
assert r.status_code == 409 and r.json()["code"] == "checklist_empty"

View File

@@ -70,8 +70,15 @@ def doc(
declared: set[str] | None = None,
sentences: list[Sentence] | None = None,
parents: dict[str, Sentence] | None = None,
continuations: dict[str, Sentence] | None = None,
) -> Doc:
return Doc(sentences=sentences or [], parents=parents or {}, continuations={}, notes=[], declared=declared or set())
return Doc(
sentences=sentences or [],
parents=parents or {},
continuations=continuations or {},
notes=[],
declared=declared or set(),
)
def test_basis_check() -> None:
@@ -216,3 +223,82 @@ def test_heading_is_context_not_the_requirement() -> None:
d = doc(sentences=[heading, item], parents={item.key: heading})
assert without_headings([heading, item], d) == [item]
assert without_headings([heading], d) == [heading] # 只引了标题:保留
# ---------------------------------------------------------------- 2026-11-03 待确认审计后的规则修正
def test_table_borders_are_not_underlines() -> None:
"""表格框线两端接着竖线,不算下划线;真正的下划线不接竖线(国动样本功能表被误认成斜体下划线条款)。"""
from types import SimpleNamespace
from bid_doctor.document.pipeline.text_layer import _underlines # pyright: ignore[reportPrivateUsage]
border = {"x0": 86.8, "x1": 501.6, "top": 379.5, "bottom": 379.5}
under = {"x0": 118.1, "x1": 188.8, "top": 327.9, "bottom": 327.9}
verticals = [{"x0": x, "x1": x, "top": 300.0, "bottom": 400.0} for x in (86.8, 501.6)]
page = SimpleNamespace(lines=[border, under, *verticals], rects=[])
assert _underlines(page) == [(118.1, 188.8, 327.9)]
def test_quote_ignores_our_markup_and_end_punctuation() -> None:
"""我们加进输入的格式标记和〔续下页〕不是原文,比对前去掉;短句整句复制时句末标点可以不带。原文文字不放宽。"""
s = sent("联合体享受10%价格扣除,用扣除后的价格参与评审。")
by_id = {"p1s1": s}
assert check_evidence({"sid": "p1s1", "quote": "联合体享受<u>10%</u>价格扣除"}, by_id)[0] is None
assert check_evidence({"sid": "p1s1", "quote": "用扣除后的价格参与评审〔续下页〕"}, by_id)[0] is None
short = sent("1.软件开发;")
assert check_evidence({"sid": "p1s1", "quote": "1.软件开发"}, {"p1s1": short})[0] is None
assert check_evidence({"sid": "p1s1", "quote": "软件开发"}, {"p1s1": short})[0] == "quote"
assert check_evidence({"sid": "p1s1", "quote": "价格扣除"}, by_id)[0] == "quote" # 只摘关键词照样退回
def test_chinese_numbers_in_values() -> None:
assert check_value("1", [sent("经采购人授权,每月至少开展一次运行检查。")])
assert check_value("12", [sent("培训不少于十二课时,每期不少于十二人。")])
assert not check_value("1", [sent("对接苏服办统一身份认证。")]) # 「统一」里的「一」不是数字
def test_value_in_same_row_or_heading() -> None:
"""评分表的分值可能在同一行隔几格的「分值」列,或者在评分因素的标题行里。"""
from bid_doctor.checklist.extract import _context # pyright: ignore[reportPrivateUsage]
row = Sentence(key="d:p1:s1", page_no=1, seq=1, text="提供完整方案的得满分。", quads=[[100, 200, 300, 214]])
points = Sentence(key="d:p1:s9", page_no=1, seq=9, text="4", quads=[[450, 205, 460, 213]])
other = Sentence(key="d:p1:s10", page_no=1, seq=10, text="5", quads=[[450, 300, 460, 312]])
head = Sentence(key="d:p1:s0", page_no=1, seq=0, text="5、视频演示(10分)", quads=[[90, 180, 200, 194]])
chunk = [head, row, sent("下一行的说明。", seq=2), sent("再下一行。", seq=3), points, other]
ctx = _context([row], chunk, parents={row.key: head})
assert points in ctx and other not in ctx and head in ctx
assert check_value("4", ctx) and check_value("10", ctx)
def test_chinese_numbered_section_parents() -> None:
"""「三、……否则投标无效」下面的 1. 2.,以及「1.报价符合性审查:」下面不带编号的句子(S02 第 20 页)。"""
from bid_doctor.document.pipeline.structure import ancestors, parents
ss = [
sent("三、符合条件(必须提供,否则投标无效)", seq=1),
sent("1.报价符合性审查:", seq=2),
sent("投标报价不得超过采购项目最高限价;", seq=3),
sent("2.《投标函》(加盖电子签章);", seq=4),
sent("四、综合评审评分项", seq=5),
sent("1.技术方案", seq=6),
]
par = parents(ss)
assert par["d:p1:s2"].seq == 1 and par["d:p1:s3"].seq == 2 and par["d:p1:s4"].seq == 1
assert par["d:p1:s6"].seq == 5
assert [p.seq for p in ancestors("d:p1:s3", par)] == [2, 1]
d = doc(sentences=ss, parents=par)
assert check_basis("explicit_invalid", [ss[2]], ss, d) # 依据在上级的上级
assert not check_basis("explicit_invalid", [ss[5]], ss, d)
def test_model_input_marks_only_for_marking_task() -> None:
from bid_doctor.checklist.extract import _render # pyright: ignore[reportPrivateUsage]
a = Sentence(key="d:p1:s1", page_no=1, seq=1, text="须提供授权书,采购人有权", quads=[], fmt_runs=[(3, 6, "iu")])
b = Sentence(key="d:p2:s1", page_no=2, seq=1, text="永久使用。", quads=[])
d = doc(sentences=[a, b], continuations={a.key: b})
assert _render([a, b], d).splitlines()[-2:] == ["[p1s1] 须提供授权书,采购人有权〔续下页〕", "[p2s1] 永久使用。"]
assert "<iu>授权书</iu>" in _render([a, b], d, marks=True)