diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2561dc7..78e17ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: valkey: image: valkey/valkey:8-alpine ports: ["6379:6379"] + gotenberg: # 投标样例和招标样例都可能是 Word,转换要走 Gotenberg(SK2) + image: gotenberg/gotenberg:8 + ports: ["3109:3000"] env: BD_TEST_PG: 127.0.0.1:5432 BD_TEST_VALKEY: redis://127.0.0.1:6379/1 @@ -35,13 +38,14 @@ jobs: with: version: "0.12.20" - run: uv sync --frozen - - name: 数据库角色(和 deploy/compose/postgres/init-roles.sh 一致) + - name: 数据库角色(直接跑部署用的脚本,两边不会漂移,SK2) env: + PGHOST: 127.0.0.1 PGPASSWORD: dev-owner - run: | - psql -h 127.0.0.1 -U bd_owner -d bid_doctor -v ON_ERROR_STOP=1 \ - -c "CREATE ROLE bd_app LOGIN PASSWORD 'dev-app'" \ - -c "CREATE ROLE bd_auth NOLOGIN BYPASSRLS" + POSTGRES_USER: bd_owner + POSTGRES_DB: bid_doctor + BD_APP_PASSWORD: dev-app + run: sh deploy/compose/postgres/init-roles.sh - working-directory: server run: | ../.venv/bin/ruff check src tests migrations diff --git a/AGENTS.md b/AGENTS.md index e64ee8f..27564df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,4 +204,6 @@ samples/ - 2026-11-03:S01–S10 标注集评测 v1(`docs/architecture/extract-eval.md`,工具和标注集在 `evals/`)。1,829 个标注单元由 AI 标注、逐条裁定,未经人工复核。标注类实质性条款召回 99.7% 达标;写明无效类召回 85.4%、实质性条款准确率 87.8%、资格条件准确率 38.4% 不达标,主因是规则缺陷(合并时待确认条目被并进正式条目、表后「上述……否则无效」说明认不出、模型提前收尾的静默丢失),修复清单 F1–F8 见该文 §4。每份文件实测约 1.4 元。还发现产品转换流程缺 WPS 旧版 DOC 预处理(S07 正文全是乱字)。 - 2026-11-03:按评测修复 F1–F8,口径 Q1–Q4 按建议确认(`docs/decisions.md`);请 Fable 当顾问审读修复,改了它指出的 7 个问题。修掉了几个确定的缺陷(合并时假致命、输出临近上限的静默丢失、WPS 旧版 DOC 乱码、表格串行);报告分数是在同一批样本上调、又用同一批评的,请 Fable 评估后确认有过拟合:只按人工标注算,实质性条款准确率 72%、标注类召回 98.9%,泛化未测。 - 2026-11-03:读标召回率优化暂停。现状、遗留问题(含泛化风险)和下一步整理在 `docs/architecture/extract-status.md`;后续专项优化先建留出集(多省份、招标投标体系、扫描件)并人工标注。 -- 下一步:查标(M3)。 +- 2026-11-04:查标(M3)实现计划 v0.2(`docs/architecture/m3-plan.md`)。请 Fable 做顾问深读参考项目的查标实现(`ref-bid-engine-analysis.md` §6),对照读标教训复评;C1–C11、SK1、SK2 全部按建议确认(`docs/decisions.md`)。要点:先建评测集再写规则,金标准是虚构投标文件的响应清单;每条清单项都留带证据的结论(新增 `check_item_result`);成本靠按章节打批、硬预算和复查复用结果控制;认不出、没查的都写进检查覆盖范围。用户的真实国动投标文件留到第 3 步冻结代码后再用。 +- 2026-11-04:查标第 1 步(链路打通)完成:上传投标分册(类型、版本、加密格式识别)→ 检查(六个阶段,响应匹配先只有检索 + 模型判断,提示词 `check.match` v1)→ 每条清单项留带证据的结论 → 生成问题 → 逐条处理(致命确认无误要写说明并进误报反馈)→ 复查沿用状态;计费三种方式和失败退回都有测试,后端 80 个测试通过。前端加了上传投标文件并检查页和查标页。SK1、SK2 的补丁已做。真实模型在虚构样例上走通了链路,效果还没评。见 `docs/architecture/m3-plan.md` 进度一节。 +- 下一步:查标第 0 步(评测集:问题分类表、响应清单格式、留出集),再做第 2 步(投标侧结构识别和规则)。 diff --git a/apps/web/src/components/ProjectHead.vue b/apps/web/src/components/ProjectHead.vue new file mode 100644 index 0000000..4a8cb21 --- /dev/null +++ b/apps/web/src/components/ProjectHead.vue @@ -0,0 +1,55 @@ + + + diff --git a/apps/web/src/components/ProjectRow.vue b/apps/web/src/components/ProjectRow.vue index 15614b3..1e23ca4 100644 --- a/apps/web/src/components/ProjectRow.vue +++ b/apps/web/src/components/ProjectRow.vue @@ -16,11 +16,14 @@ const due = computed(() => deadline(p.value.deadline_at)); const sub = computed(() => [p.value.purchaser, p.value.code].filter(Boolean).join(" · ")); const job = computed(() => p.value.active_job ?? null); -/** 项目的默认入口:解析中去进度页;还没上传招标文件去上传;其余去清单(骨架阶段唯一的项目内页面) */ +/** 项目的默认入口:解析中去进度页;还没上传招标文件去上传;要检查去上传投标文件;检查过去查标;其余去清单 */ const home = computed(() => { const id = p.value.id; if (job.value?.kind === "parse_job") return { name: "parse", params: { projectId: id, jobId: job.value.id } }; if (p.value.next_action === "upload_tender") return { name: "project-new", query: { project: id } }; + if (p.value.next_action === "upload_bid" || p.value.next_action === "run_check") return { name: "bid-check", params: { projectId: id } }; + if (p.value.latest_check_run_id && (p.value.next_action === "handle_fatal" || p.value.next_action === "export_report")) + return { name: "results", params: { projectId: id, runId: p.value.latest_check_run_id } }; return { name: "checklist", params: { projectId: id } }; }); /** 「下一步」:骨架阶段只有上传、解析进度、清单三个去处,其余动作先落到清单页 */ diff --git a/apps/web/src/lib/events.ts b/apps/web/src/lib/events.ts index 0d84ee2..8e0e84b 100644 --- a/apps/web/src/lib/events.ts +++ b/apps/web/src/lib/events.ts @@ -7,7 +7,7 @@ import { subscribeEvents, type BdEvent } from "@bid-doctor/api-client"; import type { QueryClient } from "@tanstack/vue-query"; import { apiOptions } from "./api"; import { qk } from "./keys"; -import { TERMINAL_JOB, type ParseJob, type ProjectListItem } from "./queries"; +import { TERMINAL_JOB, type CheckRun, type ParseJob, type ProjectListItem } from "./queries"; type ProjectPage = { items: ProjectListItem[]; next_cursor: string | null }; @@ -47,6 +47,25 @@ function onEvent(qc: QueryClient, e: BdEvent) { } return; } + if ((e.type === "job.progress" || e.type === "job.finished") && job && jobId && job.kind === "check_run") { + qc.setQueryData(qk.checkRun(jobId), (old) => + !old || TERMINAL_JOB.has(old.status) || (job.progress ?? 0) < old.progress + ? old + : { ...old, status: job.status ?? old.status, stage: (job.stage as CheckRun["stage"]) ?? old.stage, progress: job.progress ?? old.progress }, + ); + if (e.type === "job.finished") { + void qc.invalidateQueries({ queryKey: qk.checkRun(jobId) }); + void qc.invalidateQueries({ queryKey: qk.findings(jobId) }); + void qc.invalidateQueries({ queryKey: qk.projects }); + void qc.invalidateQueries({ queryKey: qk.quota }); + if (e.project_id) { + void qc.invalidateQueries({ queryKey: qk.project(e.project_id) }); + void qc.invalidateQueries({ queryKey: qk.checkPreview(e.project_id) }); + void qc.invalidateQueries({ queryKey: qk.checkRuns(e.project_id) }); + } + } + return; + } switch (e.type) { case "quota.changed": void qc.invalidateQueries({ queryKey: qk.quota }); diff --git a/apps/web/src/lib/keys.ts b/apps/web/src/lib/keys.ts index ed7df5a..1646e2d 100644 --- a/apps/web/src/lib/keys.ts +++ b/apps/web/src/lib/keys.ts @@ -9,4 +9,10 @@ export const qk = { parseJob: (id: string) => ["parse-job", id] as const, checklist: (projectId: string) => ["checklist", projectId] as const, checklistItems: (projectId: string) => ["checklist-items", projectId] as const, + projectDocuments: (projectId: string) => ["project-documents", projectId] as const, + checkPreview: (projectId: string) => ["check-preview", projectId] as const, + checkRuns: (projectId: string) => ["check-runs", projectId] as const, + checkRun: (id: string) => ["check-run", id] as const, + findings: (runId: string) => ["findings", runId] as const, + itemResults: (runId: string) => ["item-results", runId] as const, }; diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts index a34ee02..98e3268 100644 --- a/apps/web/src/lib/labels.ts +++ b/apps/web/src/lib/labels.ts @@ -155,6 +155,36 @@ export const PARSE_STEPS: readonly { label: string; stages: readonly string[] }[ export const parseStepIndex = (stage: string | null | undefined) => stage ? PARSE_STEPS.findIndex((s) => s.stages.includes(stage)) : -1; +/** 检查阶段:服务端的 6 个 stage 合成界面上的 4 步 */ +export const CHECK_STEPS: readonly { label: string; stages: readonly string[] }[] = [ + { label: "读取投标文件", stages: ["prepare_bid"] }, + { label: "逐条找响应", stages: ["match"] }, + { label: "核对出处", stages: ["rules", "verify"] }, + { label: "沿用处理状态、汇总", stages: ["carry_over", "summarize"] }, +]; +export const checkStepIndex = (stage: string | null | undefined) => + stage ? CHECK_STEPS.findIndex((s) => s.stages.includes(stage)) : -1; + +export const VOLUME_KIND: Dict = { + whole: "整本", + qualification: "资格证明", + commercial: "商务", + technical: "技术", + price: "报价", + other: "其他", +}; +export const VOLUME_KINDS = Object.keys(VOLUME_KIND) as Schemas["VolumeKind"][]; + +/** 级别(§7.5):样式类沿用设计规范的 fatal / major / hint */ +export const SEVERITY: Dict = { fatal: "致命", important: "重要", hint: "提示" }; +export const SEVERITY_CLASS: Dict = { fatal: "fatal", important: "major", hint: "hint" }; +export const FINDING_ACTION: Dict = { + pending: "待处理", + confirmed_ok: "确认无误", + modified: "已修改", + ignored: "已忽略", +}; + export const READY_TIP = "只表示致命问题都已处理,不代表投标文件没有问题。"; /* ---------- 时间 ---------- */ diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 6c62f36..f6f6e46 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -21,6 +21,10 @@ export type ChecklistItem = Schemas["ChecklistItem"] & Required unwrap(api.GET("/me")); @@ -90,3 +94,41 @@ export function useChecklistItems(projectId: MaybeRefOrGetter) { }>, }); } + +export function useProjectDocuments(projectId: MaybeRefOrGetter) { + return useQuery({ + queryKey: computed(() => qk.projectDocuments(toValue(projectId))), + queryFn: () => unwrap(api.GET("/projects/{project_id}/documents", { params: { path: { project_id: toValue(projectId) } } })), + }); +} + +export function useCheckPreview(projectId: MaybeRefOrGetter) { + return useQuery({ + queryKey: computed(() => qk.checkPreview(toValue(projectId))), + queryFn: () => unwrap(api.GET("/projects/{project_id}/check-preview", { params: { path: { project_id: toValue(projectId) } } })), + }); +} + +export function useCheckRuns(projectId: MaybeRefOrGetter) { + return useQuery({ + queryKey: computed(() => qk.checkRuns(toValue(projectId))), + queryFn: () => unwrap(api.GET("/projects/{project_id}/check-runs", { params: { path: { project_id: toValue(projectId) } } })), + }); +} + +/** 检查进度:和解析一样,事件流推送为主、每 5 秒轮询兜底,结束后停止轮询。 */ +export function useCheckRun(id: MaybeRefOrGetter) { + return useQuery({ + queryKey: computed(() => qk.checkRun(toValue(id))), + queryFn: () => unwrap(api.GET("/check-runs/{run_id}", { params: { path: { run_id: toValue(id) } } })), + refetchInterval: (query) => (query.state.data && TERMINAL_JOB.has(query.state.data.status) ? false : 5000), + }); +} + +export function useFindings(runId: MaybeRefOrGetter, enabled: MaybeRefOrGetter) { + return useQuery({ + queryKey: computed(() => qk.findings(toValue(runId))), + queryFn: () => unwrap(api.GET("/check-runs/{run_id}/findings", { params: { path: { run_id: toValue(runId) } } })), + enabled: computed(() => toValue(enabled)), + }); +} diff --git a/apps/web/src/pages/BidCheckPage.vue b/apps/web/src/pages/BidCheckPage.vue new file mode 100644 index 0000000..3972220 --- /dev/null +++ b/apps/web/src/pages/BidCheckPage.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/apps/web/src/pages/ChecklistPage.vue b/apps/web/src/pages/ChecklistPage.vue index 0a741d5..e0c9880 100644 --- a/apps/web/src/pages/ChecklistPage.vue +++ b/apps/web/src/pages/ChecklistPage.vue @@ -18,11 +18,12 @@ import ConfirmDialog from "@/components/ConfirmDialog.vue"; import HistoryDialog from "@/components/HistoryDialog.vue"; import ItemDialog from "@/components/ItemDialog.vue"; import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue"; +import ProjectHead from "@/components/ProjectHead.vue"; import { api, errorTitle } from "@/lib/api"; import { useChecklistOps, useDeletedItems, type ItemFields } from "@/lib/checklistOps"; import { groupCitations, type CitationGroup } from "@/lib/citations"; import { useCrumbs } from "@/lib/crumbs"; -import { category, CATEGORY_ORDER, deadline, fmtDateTime, legalRegime, MARKER, projectStatus } from "@/lib/labels"; +import { category, CATEGORY_ORDER, fmtDateTime, MARKER, projectStatus } from "@/lib/labels"; import { useChecklist, useChecklistItems, useProject, type ChecklistItem, type Citation, type MarkingRule } from "@/lib/queries"; import { toast } from "@/lib/toast"; @@ -42,10 +43,6 @@ const cl = computed(() => checklist.data.value); const readOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived"); useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "读标" }]); -const meta = computed(() => - p.value ? [p.value.purchaser, p.value.legal_regime !== "unknown" ? legalRegime(p.value.legal_regime) : "", p.value.procurement_method, p.value.code].filter(Boolean).join(" · ") : "", -); -const due = computed(() => deadline(p.value?.deadline_at)); /* ---------- 分组与筛选 ---------- */ const all = computed(() => (itemsQ.data.value?.items ?? []).filter((i) => !i.deleted_at)); @@ -246,26 +243,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));