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:
89
apps/web/src/lib/checklistOps.ts
Normal file
89
apps/web/src/lib/checklistOps.ts
Normal 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" } } }));
|
||||
}
|
||||
27
apps/web/src/lib/citations.ts
Normal file
27
apps/web/src/lib/citations.ts
Normal 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;
|
||||
}
|
||||
@@ -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
23
apps/web/src/lib/toast.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user