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

@@ -0,0 +1,89 @@
/**
* 读标的修改类操作(api-design §4.5):采纳、修改、删除恢复、人工新增、补出处、确认清单、修改记录。
* 每次修改后让清单概况、清单项、项目详情(下一步、待确认数)的缓存失效,由 TanStack Query 重新取。
*/
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, toValue, type MaybeRefOrGetter } from "vue";
import { api } from "./api";
import { qk } from "./keys";
import type { ChecklistItem } from "./queries";
export type ItemFields = Schemas["ChecklistItemFields"];
export type HistoryEntry = Schemas["HistoryEntry"];
export type ChecklistVersion = Schemas["ChecklistVersion"];
export type Sentence = Schemas["Sentence"];
export type PageInfo = Schemas["DocumentPage"];
const item = (r: Promise<{ data?: unknown; error?: unknown; response: Response }>) => unwrap(r) as Promise<ChecklistItem>;
const path = (item_id: string) => ({ params: { path: { item_id } } });
export function useChecklistOps(projectId: MaybeRefOrGetter<string>) {
const qc = useQueryClient();
const pid = () => toValue(projectId);
const refresh = async () => {
await Promise.all([
qc.invalidateQueries({ queryKey: qk.checklist(pid()) }),
qc.invalidateQueries({ queryKey: qk.checklistItems(pid()) }),
qc.invalidateQueries({ queryKey: ["checklist-deleted", pid()] }),
qc.invalidateQueries({ queryKey: qk.project(pid()) }),
qc.invalidateQueries({ queryKey: qk.projects }),
]);
};
const run = async <T>(p: Promise<T>): Promise<T> => {
const out = await p;
await refresh();
return out;
};
return {
update: (id: string, changes: ItemFields) => run(item(api.PATCH("/checklist-items/{item_id}", { ...path(id), body: changes }))),
accept: (id: string, body: { citation_ids?: string[]; no_citation_ack?: boolean; changes?: ItemFields }) =>
run(item(api.POST("/checklist-items/{item_id}/accept", { ...path(id), body }))),
remove: (id: string) => run(unwrap(api.DELETE("/checklist-items/{item_id}", path(id)))),
restore: (id: string) => run(item(api.POST("/checklist-items/{item_id}/restore", path(id)))),
setCitations: (id: string, citation_ids: string[]) =>
run(item(api.PUT("/checklist-items/{item_id}/citations", { ...path(id), body: { citation_ids } }))),
create: (body: Schemas["ChecklistItemCreate"]) =>
run(item(api.POST("/projects/{project_id}/checklist/items", { params: { path: { project_id: pid() } }, body }))),
/** 在原文里选中的句子建一条出处(同一页) */
cite: (document_id: string, sentence_keys: string[]) =>
unwrap(api.POST("/projects/{project_id}/citations", { params: { path: { project_id: pid() } }, body: { document_id, sentence_keys } })),
confirm: () => run(unwrap(api.POST("/projects/{project_id}/checklist/confirm", { params: { path: { project_id: pid() } } }))),
};
}
export const fetchHistory = (id: string) => unwrap(api.GET("/checklist-items/{item_id}/history", path(id)));
/** 已删除(含丢弃)的条目:「已删除」分类里看,可以恢复 */
export function useDeletedItems(projectId: MaybeRefOrGetter<string>, enabled: MaybeRefOrGetter<boolean>) {
return useQuery({
queryKey: computed(() => ["checklist-deleted", toValue(projectId)] as const),
queryFn: async () => {
const r = (await unwrap(
api.GET("/projects/{project_id}/checklist/items", {
params: { path: { project_id: toValue(projectId) }, query: { include_deleted: true } },
}),
)) as { items: ChecklistItem[] };
return r.items.filter((i) => i.deleted_at);
},
enabled: computed(() => toValue(enabled)),
});
}
export function useDocumentPages(documentId: MaybeRefOrGetter<string | undefined>) {
return useQuery({
queryKey: computed(() => ["document-pages", toValue(documentId) ?? ""] as const),
queryFn: () => unwrap(api.GET("/documents/{document_id}/pages", { params: { path: { document_id: toValue(documentId)! } } })),
enabled: computed(() => !!toValue(documentId)),
staleTime: Infinity,
});
}
export function fetchSentences(documentId: string, page: number) {
return unwrap(api.GET("/documents/{document_id}/pages/{page_no}/sentences", { params: { path: { document_id: documentId, page_no: page } } }));
}
export function fetchFileUrl(documentId: string) {
return unwrap(api.GET("/documents/{document_id}/file", { params: { path: { document_id: documentId }, query: { variant: "normalized" } } }));
}

View File

@@ -0,0 +1,27 @@
/**
* 出处的显示分组:出处按页定位(每页一条,data-model §5.1),一句话跨页时存成两条。
* 显示时把「上一页那条没写完、紧接着是下一页的接续」的合成一处,页码写「第 47–48 页」,原句接起来。
*/
import type { Citation } from "./queries";
export interface CitationGroup {
head: Citation;
/** 接续到后面几页的部分(一般只有一条) */
tail: Citation[];
}
const ENDED = /[。!?;!?;))」』”]$/;
export function groupCitations(cs: readonly Citation[]): CitationGroup[] {
const out: CitationGroup[] = [];
for (const c of cs) {
const g = out[out.length - 1];
const last = g ? (g.tail[g.tail.length - 1] ?? g.head) : null;
if (g && last && c.document_id === last.document_id && c.page_no === last.page_no + 1 && !ENDED.test(last.quote.trim())) {
g.tail.push(c);
} else {
out.push({ head: c, tail: [] });
}
}
return out;
}

View File

@@ -53,6 +53,41 @@ export const PENDING_REASON: Dict<Schemas["PendingReason"]> = {
};
export const pendingReason = (s: string | null | undefined) => pick(PENDING_REASON, s);
/* ---------- 修改记录(审计事件) ---------- */
export const HISTORY_ACTION: Record<string, string> = {
"checklist_item.extract": "AI 提取",
"checklist_item.create": "人工添加",
"checklist_item.update": "修改",
"checklist_item.accept": "采纳",
"checklist_item.set_citations": "更换出处",
"checklist_item.discard": "丢弃",
"checklist_item.delete": "删除",
"checklist_item.restore": "恢复",
};
export const historyAction = (s: string) => HISTORY_ACTION[s] ?? s;
export const HISTORY_FIELD: Record<string, string> = {
title: "要求",
categories: "分类",
importance: "重要性",
evidence_required: "证明材料",
requirement_text: "原文",
lot_id: "分包",
spec: "取值",
citation_ids: "出处",
zone: "区域",
no_citation_ack: "人工确认、无出处",
};
/** 修改记录里一个值的显示:分类、重要性、区域翻成中文,出处只写条数 */
export function historyValue(field: string, v: unknown): string {
if (v === null || v === undefined || v === "") return "";
if (field === "categories" && Array.isArray(v)) return v.map((c) => category(String(c))).join("、");
if (field === "importance") return pick(IMPORTANCE_NAME, String(v));
if (field === "zone") return v === "pending" ? "待确认" : "正式清单";
if (field === "citation_ids" && Array.isArray(v)) return v.length ? `${v.length} 处` : "无";
if (field === "no_citation_ack") return v ? "是" : "否";
return typeof v === "string" ? v : JSON.stringify(v);
}
export const MARKER: Dict<Schemas["MarkingRule"]["marker"]> = {
star: "★",
asterisk: "*",

23
apps/web/src/lib/toast.ts Normal file
View File

@@ -0,0 +1,23 @@
/** 操作结果提示(设计规范 §7.15):屏幕底部居中,墨色底;可带「撤销」。几秒后自动消失。 */
import { reactive } from "vue";
export interface Toast {
id: number;
text: string;
ok: boolean;
undo?: () => void;
}
export const toasts = reactive<Toast[]>([]);
let seq = 0;
export function toast(text: string, opt: { ok?: boolean; undo?: () => void; ms?: number } = {}): void {
const t: Toast = { id: ++seq, text, ok: opt.ok ?? true, undo: opt.undo };
toasts.push(t);
setTimeout(() => dismiss(t.id), opt.ms ?? (opt.undo ? 6000 : 3000));
}
export function dismiss(id: number): void {
const i = toasts.findIndex((t) => t.id === id);
if (i >= 0) toasts.splice(i, 1);
}