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

@@ -5,7 +5,7 @@
*/
import { computed } from "vue";
import CitationBlock from "./CitationBlock.vue";
import { category, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason } from "@/lib/labels";
import { category, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason, specFacts } from "@/lib/labels";
import type { ChecklistItem } from "@/lib/queries";
const props = defineProps<{ item: ChecklistItem; pending?: boolean }>();
@@ -16,6 +16,8 @@ const more = computed(() => props.item.citations.length - shown.value.length);
const mark = computed(() => IMPORTANCE_MARK[props.item.importance] ?? "");
/** AI 摘要和标题不同时才单独显示,避免重复 */
const summary = computed(() => (props.item.summary && props.item.summary !== props.item.title ? props.item.summary : ""));
/** 结构化取值:分值、参数要求、日期等,都来自原文(取值校验通过的才进正式清单) */
const facts = computed(() => specFacts(props.item.spec));
</script>
<template>
@@ -33,6 +35,10 @@ const summary = computed(() => (props.item.summary && props.item.summary !== pro
<span v-if="pending && item.pending_reason" class="flag">{{ pendingReason(item.pending_reason) }}</span>
<span v-if="item.no_citation_ack" class="flag">人工确认、无出处</span>
</div>
<p v-if="facts.length" class="facts">
<span v-for="f in facts" :key="f">{{ f }}</span>
</p>
<p v-if="item.evidence_required" class="evid"><span class="muted">证明材料</span>{{ item.evidence_required }}</p>
<p v-if="summary" class="sum"><span class="tag ai">AI 摘要</span>{{ summary }}</p>
<div v-if="shown.length" class="cites">
<CitationBlock v-for="c in shown" :key="c.id" :citation="c" />
@@ -63,6 +69,25 @@ const summary = computed(() => (props.item.summary && props.item.summary !== pro
align-items: baseline;
margin: var(--sp-2) 0 0;
}
.facts {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-3);
margin: var(--sp-2) 0 0;
font-size: var(--fs-caption);
color: var(--text-secondary);
}
.facts span + span::before {
content: "·";
margin-right: var(--sp-3);
color: var(--text-disabled);
}
.evid {
display: flex;
gap: var(--sp-2);
margin: var(--sp-1) 0 0;
font-size: var(--fs-caption);
}
.cites .cap {
margin: var(--sp-1) 0 0;
}

View File

@@ -132,3 +132,41 @@ export function fmtSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/* ---------- 清单项的结构化取值(ItemSpec,openapi.yaml):只把原文里有的值整理出来显示,不推测 ---------- */
const PART: Record<string, string> = { price: "价格分", technical: "技术分", commercial: "商务分" };
const QUAL_KIND: Record<string, string> = { general: "通用资格", specific: "特定资格", credit: "信用要求" };
const OP: Record<string, string> = { ">=": "≥", "<=": "≤", "=": "=", contains: "包含", supports: "支持" };
const DATE_KIND: Record<string, string> = {
question_deadline: "提疑截止",
bid_deadline: "投标截止",
opening: "开标",
deposit_deadline: "保证金截止",
validity: "有效期",
};
const SUBMISSION: Record<string, string> = { electronic: "电子递交", paper: "纸质递交", ca_encrypted: "CA 加密", mixed: "电子 + 纸质" };
type Spec = Record<string, Record<string, unknown> | undefined>;
const str = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
export function specFacts(spec: unknown): string[] {
const s = (spec ?? {}) as Spec;
const out: string[] = [];
const q = s.qualification;
if (q && QUAL_KIND[str(q.kind)]) out.push(QUAL_KIND[str(q.kind)]!);
const sc = s.scoring;
if (sc) {
if (sc.points) out.push(`分值 ${str(sc.points)}`);
if (PART[str(sc.part)]) out.push(PART[str(sc.part)]!);
}
const t = s.tech_param;
if (t && t.value) out.push([str(t.param), OP[str(t.op)] ?? "", `${str(t.value)}${str(t.unit)}`].filter(Boolean).join(" "));
const d = s.schedule;
if (d) {
if (d.at) out.push(`${DATE_KIND[str(d.date_kind)] ?? "时间"} ${fmtDateTime(str(d.at))}`);
else if (DATE_KIND[str(d.date_kind)]) out.push(DATE_KIND[str(d.date_kind)]!);
if (d.deposit_amount) out.push(`保证金 ${str(d.deposit_amount)}`);
if (SUBMISSION[str(d.submission_mode)]) out.push(SUBMISSION[str(d.submission_mode)]!);
}
return out;
}