feat: 读标补齐五类清单与项目基本信息,修复国动样本暴露的 4 个问题
- 文字层 v2:页眉页脚带单独成句;写到页尾的句子和下一页首句接续(出处两页各一条) - 结构识别(最小版):十进制编号、冒号引出的列表找上级条款;上级条款写明无效的,条目照样认定 - 抽取:资格、评分、日期与递交、技术参数、项目基本信息各一份提示词;实质性条款出 v2 (认上级条款、补「做无效响应处理」等写法);各类取值核对;按首句合并; 拆开被合成一条的列举条款;描述标注方式的句子和表头碎片不当条款;* 号标注 - 项目:解析出的名称、编号、采购人、预算、截止时间、分包写回项目,用户改过的不覆盖 - 前端:清单项显示分值、参数要求、日期、证明材料 - 测试 40 个,新增两页虚构样例 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
119
server/src/bid_doctor/project/extracted.py
Normal file
119
server/src/bid_doctor/project/extracted.py
Normal 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}
|
||||
Reference in New Issue
Block a user