feat: 读标补齐五类清单与项目基本信息,修复国动样本暴露的 4 个问题

- 文字层 v2:页眉页脚带单独成句;写到页尾的句子和下一页首句接续(出处两页各一条)
- 结构识别(最小版):十进制编号、冒号引出的列表找上级条款;上级条款写明无效的,条目照样认定
- 抽取:资格、评分、日期与递交、技术参数、项目基本信息各一份提示词;实质性条款出 v2
  (认上级条款、补「做无效响应处理」等写法);各类取值核对;按首句合并;
  拆开被合成一条的列举条款;描述标注方式的句子和表头碎片不当条款;* 号标注
- 项目:解析出的名称、编号、采购人、预算、截止时间、分包写回项目,用户改过的不覆盖
- 前端:清单项显示分值、参数要求、日期、证明材料
- 测试 40 个,新增两页虚构样例

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-03 19:32:25 +08:00
parent ee4bb23488
commit a95e27a290
29 changed files with 1971 additions and 230 deletions

View File

@@ -0,0 +1,119 @@
"""把解析出的项目基本信息和分包写回项目(FR-M2-03、04)。
- AI 值和人工值分开存:用户改过的字段不覆盖;项目名称只在还是「未命名项目」时自动填。
- 补遗带来的新值先放进待确认(FR-M2-10),用户确认后才生效;首份招标文件直接生效。
- 分包:解析出包时替换掉建项目时的默认包;已经有包的项目不动(要改由用户在选包页处理)。
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.checklist.extract import Extracted, ProjectInfo, amount_yuan
from bid_doctor.core.db import TenantContext
from bid_doctor.core.ids import uuid7
from bid_doctor.document import citations
from bid_doctor.document.models import SourceDocument
from bid_doctor.project import service as projects
from bid_doctor.project.models import Lot, Project, ProjectField
MONEY = ("budget_total", "ceiling_total")
COLUMNS = ("name", "code", "purchaser", "agency", "procurement_method", "legal_regime")
async def apply(
s: AsyncSession,
ctx: TenantContext,
doc: SourceDocument,
info: ProjectInfo,
regime_by_rule: str,
items: list[Extracted],
) -> dict[str, UUID]:
"""写入字段、分包和投标截止时间。返回 {包号: 分包编号},给清单项挂所属包用。"""
p = await projects.get(s, ctx, doc.project_id)
values: dict[str, tuple[Any, Any]] = {}
for key, f in info.fields.items():
value = amount_yuan(f.value, f.unit) if key in MONEY else f.value
if key == "legal_regime" and value not in ("gov_procurement", "tendering"):
continue
values[key] = (value, f.sentence)
if "legal_regime" not in values and regime_by_rule != "unknown":
values["legal_regime"] = (regime_by_rule, None)
deadline = _deadline(items)
if deadline:
values["deadline_at"] = deadline
for key, (value, sentence) in values.items():
cite = None
if sentence is not None:
(c,) = await citations.from_sentences(s, ctx, doc, [sentence], "ai", ["id", "quote", "basis"])
cite = c.id
await _set_field(s, ctx, p, doc, key, value, cite)
return await _lots(s, ctx, p, info)
def _deadline(items: list[Extracted]) -> tuple[str, Any] | None:
for e in items:
sched = e.spec.get("schedule") or {}
if e.zone == "formal" and sched.get("date_kind") == "bid_deadline" and sched.get("at"):
return sched["at"], e.sentences[0]
return None
async def _set_field(
s: AsyncSession, ctx: TenantContext, p: Project, doc: SourceDocument, key: str, value: Any, cite: UUID | None
) -> None:
f = (
await s.execute(select(ProjectField).where(ProjectField.project_id == p.id, ProjectField.field_key == key))
).scalar_one_or_none()
if f is None:
f = ProjectField(tenant_id=ctx.tenant_id, project_id=p.id, field_key=key)
s.add(f)
if doc.role in ("addendum", "clarification") and f.ai_value is not None and f.ai_value != value:
f.pending_value, f.pending_citation_id = value, cite # 补遗的新值:等用户确认(FR-M2-10)
else:
f.ai_value, f.ai_citation_id, f.ai_document_id = value, cite, doc.id
await s.flush()
if f.human_value is not None or f.pending_value is not None:
return
if key == "name" and p.name != projects.UNNAMED:
return # 用户建项目时填了名称:以用户的为准
if key in COLUMNS:
setattr(p, key, value)
elif key == "deadline_at":
p.deadline_at = datetime.fromisoformat(value)
async def _lots(s: AsyncSession, ctx: TenantContext, p: Project, info: ProjectInfo) -> dict[str, UUID]:
existing = (await s.execute(select(Lot).where(Lot.project_id == p.id).order_by(Lot.sort_order))).scalars().all()
if info.lots and all(lot.is_default for lot in existing):
await s.execute(delete(Lot).where(Lot.project_id == p.id, Lot.is_default))
for i, lot in enumerate(info.lots):
budget, ceiling = lot.get("budget") or {}, lot.get("ceiling") or {}
b, c = (
amount_yuan(budget.get("value"), budget.get("unit")),
amount_yuan(ceiling.get("value"), ceiling.get("unit")),
)
s.add(
Lot(
id=uuid7(),
tenant_id=ctx.tenant_id,
project_id=p.id,
lot_no=str(lot["lot_no"])[:40],
name=lot.get("name"),
origin="ai",
sort_order=i,
edited_fields=[],
budget_amount=Decimal(b) if b else None,
ceiling_amount=Decimal(c) if c else None,
ai_values={k: v for k, v in lot.items() if k != "sentence"},
)
)
await s.flush()
existing = (await s.execute(select(Lot).where(Lot.project_id == p.id))).scalars().all()
return {lot.lot_no: lot.id for lot in existing}