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:
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)}
|
||||
|
||||
457
server/src/bid_doctor/checklist/edit.py
Normal file
457
server/src/bid_doctor/checklist/edit.py
Normal 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
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "系统出错了,请稍后再试"),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
154
server/tests/test_checklist_edit.py
Normal file
154
server/tests/test_checklist_edit.py
Normal 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"
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user