Compare commits

..

38 Commits

Author SHA1 Message Date
YANG JIANKUAN
3344f09563 feat(ocr): 扫描件与图片识别——OCR 进文字层、资质库上传识别、大图浏览
计划见 docs/architecture/vision-plan.md(VS1–VS13,docs/decisions.md)。

- 验证:OCR(RapidOCR + PP-OCRv6 small,CPU)和 DeepSeek Flash 看图在国动投标文件
  和 7 张证书上初测(tech-validation §7,代码 spikes/s5-ocr/)
- OCR 服务 services/ocr(镜像、Compose 服务):整页转正(含 180°)、红章附近按红色通道重读
- 扫描页和插图识别后并入文字层 v6,句子标 source=ocr;出处标「OCR」(迁移 0013);
  依据识别文字的问题降级,规则不查扫描附件;国动端到端见 check-eval §16
- 资质库上传识别:添加资质对话框最上面传证书照片,识别后填表,只预填和 OCR 文字
  对得上的字段;识别值留痕、照片挂成附件(迁移 0014,提示词 vision.cert_fields v1)
- 资质库改为查看详情(只读键值 + 附件缩略图),附件用大图浏览控件看,不开新标签页
  (设计规范 §7.23);PDF 附件按页出预览图

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:50:13 +08:00
YANG JIANKUAN
0b783ababa feat(ui): 报告预览改用 pdf.js、读标页左右布局、「导出」改称「生成」
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 报告页:预览从浏览器自带 PDF 查看器(深色、改不了配色)换成 pdf.js 连续滚动(components/ReportViewer.vue),顶栏带下载;
  「导出 PDF」改为「生成报告 / 重新生成报告」,列表改称「已生成的报告」,最新一份之后又检查或致命数变了时提示重新生成
- 报告 PDF 模板 2026.11-v4:「导出人 / 导出时」改为「生成」;其余页面的「导出的报告」同步改称
- 读标页:改成和查标、概览一样的左右布局,项目头和清单栏滚出屏幕;分类导航、搜索栏和卡片上沿贴在顶栏下方留出间距
- 读标页:响应方式收进清单栏「响应方式」按钮的浮层,招标文件没写明或写法不一致且没人改过时按钮上加提示点;清单状态改为标题下一行
- 概览检查记录:报告列只写份数和「查看报告」,跳到报告标签页并预览那次检查最新的一份
- 招标 / 投标文件切换标签:双箭头放在文字两头,方向改为指向展开的方向
- 项目头:投标截止、状态、就绪三项取值同高对齐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:57:00 +08:00
YANG JIANKUAN
dd8c04001d feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
决策 M6-1–M6-10(docs/architecture/m6-plan.md、docs/decisions.md)。

用户端:
- 额度与套餐页:本期额度、按功能分布、扣点规则、流水(逐条余额、关联项目和任务、导出 CSV)
- 开通 / 续费申请(对公转账,运营确认到账后开通),80% / 100% 提醒
- 专业版到期:整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒(worker 每小时 billing_tick)
- 删除项目(彻底删除、销毁数据密钥),注销账号(企业名称 + 短信验证码)
- 通知铃铛、账号页

运营后台:
- 独立进程 bid-doctor ops(/ops/v1)和 apps/admin;用户名 + 密码 + TOTP + IP 白名单
- 开通申请(到账 / 开通 / 退回 / 发票)、企业查询和调额度、误报 / 漏报、失败任务、审计;不看业务内容
- 左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签)
- 运营人员角色只留结构:ops_admin.role、权限点、每个接口的检查点、菜单按权限过滤

数据库:迁移 0010(排队的订阅、申请单号、通知类型、billing_due)、0011(bd_ops 权限)、0012(运营角色)。
另修:取消解析 / 检查接口报 500;uuid7 进程内单调;data_key 销毁后不再新建;审计 None 存 SQL NULL。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:06:06 +08:00
YANG JIANKUAN
54cbb556d0 feat(ui): 查标第 6 步前端补齐
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
  「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
  (新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:39:19 +08:00
YANG JIANKUAN
808259da87 fix(check): 修国动真实投标文件暴露的 6 处(规则集 r5)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
国动真实投标文件查标一次(check-eval §14):44 个问题里有用的 9 个,
致命、重要两级都是误报。按决策 E3 全部修(§15):
- 响应错位:「完全满足,无偏离。」这类复合肯定写法整体算肯定
- 附加条件:「不以……另行收费」是承诺
- 扫描件:文字层 v5 记每页图片占比,带一行说明的扫描页也认;提示写明扫描页范围
- 不需要这家投标人回答的:无特定资格要求、总括性资格要求、中标后才做的事、
  投标文件没声称适用的联合体和价格扣除
- 「方案应明确 A、B、C……」逐项再检索
- 同一批送出的片段都可引用(引文照样核对);出处没过校验的不沿用

国动重新检查 44 → 11(全是提示,有用的 9 个都在);开发集召回 112 → 111
(模型重跑波动),致命级 25/32 不变,规矩的投标文件问题 239 → 213。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:16:12 +08:00
YANG JIANKUAN
f5edc585f1 fix(ui): 解析完成时结果没到前不显示「0 条」
事件流先报完成、结果随后由重新拉取带回,中间会闪一下「提取要求 0 条」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:16:12 +08:00
YANG JIANKUAN
5b77012fac feat(ui): 查标、概览改为左右布局;招标文件、投标文件左右切换
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 查标结果页、概览和读标页一样:左边内容随页面滚动,右边原文贴住视口,去掉原文侧边抽屉
- DocStage:查标右侧招标文件、投标文件横向排开,当前的占满,另一份露出 80px
  (文件名移到露出的一侧、纸边、带方向箭头的竖排标签,截断处淡入页面底色,悬停时探出一点);
  点露出的一条或另一边的出处就滑过去,两边各自记着页和高亮
- 查标:进页面翻到第一条问题的出处,J / K 时原文跟着翻,优先留在正在看的一边;
  左边变窄后问题行改单列,出处放在说明下面,快捷键提示收成「快捷键」
- 概览:关键日期和采购包合成一张卡放到最上面,项目要点在下
- 原文查看器:直接打开到某页时会并发两次渲染、叠出镜像乱字,改为后一次等前一次停下

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:23:50 +08:00
YANG JIANKUAN
9f1a79ae03 style(ui): 内容区四角改为直角
内容区原来只有左上角 12px 圆角、左下角是直角,看着不统一;去掉 .main 和 .top 的左上角圆角。
设计规范 §5.3、UI demo 和原型的样式同步修改。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:23:40 +08:00
YANG JIANKUAN
3a7e3d7f3f fix(extract): ★ 一律按实质性条款处理(口径 Q2 修订)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
文件把 ★ 定义成计分项(S02「加★为核心技术参数」)时不再降为「重要」:
删掉 star_scored / STAR_IMPORTANT 及相关分支,不再生成 meaning = important 的标注规则。
S02 的 11 条 ★ 要求离线验证全部为实质性。决策记入 docs/decisions.md。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:51:25 +08:00
YANG JIANKUAN
1966ce9b15 fix(ui): 读标、查标走查修复;查标结果页按原型补齐
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
走查(注册 → 读标 → 查标 → 导出报告)发现的问题:
- 下拉:原生 select 换成 SelectBox(Reka UI,规范 §7.21):右侧 chevron 有边距、
  sm 高 32 和搜索框一致、浮层和触发框同宽、选中项加粗带 ✓;响应方式栏的签名不再被挤成竖排
- 读标:「依据 · 第 N 页」改成行内出处链接;证明材料标签不再被挤压
- 概览:关键日期同一种、同一时刻只列一次
- 报告页:「还有 N 项致命」读项目实时值(检查的 counts 是快照)
- 读标抽取:★ 只表示「重要」的文件,清单项也挂到 ★ 规则上(「已识别 N 条」)
- 检查范围文案和后端覆盖范围一致;报告出处不重复写文件名(模板 2026.11-v3)
- 带加载态的按钮图标和文字间距;单选、半选、禁用复选框样式

查标结果页按原型 results.html 补齐:
- 本次处理的问题留在原位(署名行可撤销),修了处理后仍显示处理按钮的问题
- 键盘 J / K / O / 1 / 2 / 3 / X / U / Esc;「提示」批量确认无误;「判断有误?」反馈
- 覆盖范围默认收起成一行;顶部加导出报告入口和致命提示;已处理计数
- 检查进度卡和解析进度卡同一种样式
- 项目头「就绪 / 未就绪」,致命数挂在查标标签上
- 上传投标文件页:已有分册时新增表单收起到「添加分册」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:38:56 +08:00
YANG JIANKUAN
6e0d4c1dd6 feat(addendum): 补遗追加(FR-M2-10)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 招标文件解析后可以追加补遗 / 澄清:单独解析、扣 1 次;新条目标「补遗 N」进正式清单,清单回到草稿(AD1)
- 同一种日期、时间不同的旧条目标「已被补遗 N 更新」,逐条确认更新或保留两条(AD2、AD3)
- 项目字段的新值在概览「采用 / 不采用」:采用时以补遗为准、清掉人工值;采用截止时间时日期条目一起确认;
  没确认前查标用旧值,检查预览提示(AD4)
- 补遗不改项目名称、编号、采购人、代理机构、法律体系(更正公告标题会被抽成项目名称);时间比较按北京时间补时区
- 接口:fields/{key}/pending、checklist-items/{id}/supersession;清单项 origin_document、superseded_by;
  关键日期 replaces;解析结果 dates_superseded、fields_pending;检查预览 pending_updates
- 前端:补遗模式的上传页、解析页结果、概览确认、读标页来源筛选和日期确认、检查预览提示
- 决策 AD1–AD6;方案和落地记录 docs/architecture/addendum-plan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:27:21 +08:00
YANG JIANKUAN
ed6a2a907d feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 字段修改:人工值优先、撤销恢复 AI 值、按字段校验(金额可写「286万」,按元存)、
  冗余列同步、审计留痕、只读项目 409;关键日期显示改后的投标截止时间
- 分包:增删改、选包(至少一个)、一键「不分包」;删包时条目归为通用,删到一个不剩回到不分包
- 清单列表 lot_scope 默认只列通用 + 所投的包,响应带 hidden_count
- 检查记下当时用的项目依据(截止时间、限价、所投的包、法律体系):报告按它写,
  之后改了信息检查结果接口给 basis_changed,查标页提示重新检查
- 前端:概览标签页(项目要点、采购包、关键日期)、字段修改对话框、选包页和手工调整;
  解析完识别到多个包时进选包页;读标页清单范围和所属采购包;查标页写明查哪几个包
- 决策 PI1–PI7;方案和落地记录 docs/architecture/project-info-plan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:46:07 +08:00
YANG JIANKUAN
08e3fe7eff feat(qualification): 资质库和资质匹配(查标第 4 步,规则集 2026.11-r4)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 资质库(FR-M6-08):增删改、证书扫描件(PDF/JPG/PNG,企业级密钥加密、签名下载)、
  late_for 按进行中项目的投标截止日提醒、审计;前端资质库页和对话框,侧栏入口
- 资质匹配(FR-M3-05):证书类别词表(16 类,产品证书、人员证书不进匹配),
  QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED;投标文字层按清单项定级,只有资质库记录的降一级,
  出处显示「依据:资质库记录」(查标页、报告模板 v2);一类证书一个问题;评分项不逐条找响应的也查
- 响应错位规则不再把资质表的证书名称格当成照抄的要求
- 评测:qual_defects.py 派生资质情况表,D12 3/3、D18 2/2、规矩的资质表 0 误报;开发集不退化
- 决策 QL1–QL7;方案和落地记录 docs/architecture/qualification-plan.md,评测 check-eval.md §13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:22:50 +08:00
YANG JIANKUAN
e7fb797925 feat(report): 检查报告 PDF 导出和归档;项目状态流转(递交 / 归档)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- report 模块:导出 → worker 用 Gotenberg 把 HTML 转成 A4 PDF → 按项目数据密钥加密存储 → report
  (不可改、存哈希);report_job 记生成过程(迁移 0009),完成推 report.ready、发通知
- 报告首页:项目信息、投标文件版本、检查依据、规则版本、导出人、检查覆盖范围、汇总、免责声明;
  问题明细带两侧出处和处理人;附录为需人工核对的勾选结果;模板文字不出现「通过 / 合格 / 保证」
- 前端「报告」标签页:导出选项、内嵌预览、历次报告下载;工作台「导出检查报告」直达
- 项目状态流转 POST /projects/{id}/transitions:致命未处理也能递交(确认框说明),
  递交后只读,撤销归档回到归档前的状态,都写审计
- 通知补上检查、报告的标题

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:51:43 +08:00
YANG JIANKUAN
902a130c82 fix(check): 修留出集暴露的 5 处(规则集 2026.11-r3)
- 响应方式:「逐条响应」要说的是要求 / 参数 / 偏离,声明函填写说明不算;「否则视为无效响应」不算默认接受
- 偏离栏按「是不是明确满足」判断:偏离栏、响应情况栏一起看,其他短写法请人核对(最高重要);表体序号认「技-86」等
- 另报价、附加条件按结构判断(另一个不同的总价金额、保留 / 另行 / 调整报价等)
- 空白栏:「精确到年月日」不算没填
- 比对时折叠异体字(戶→户等),原文照存
- 记录决策 E2:先修再跑国动真实投标文件

开发集召回 75%、规矩投标文件问题数不变;留出集参考数字见 check-eval §12(已看过,仅参考)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:34:12 +08:00
YANG JIANKUAN
fee1c0f842 docs(check): 留出集结果(冻结代码跑一次)和要修的 5 处
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:00:48 +08:00
YANG JIANKUAN
b2b8584321 feat(evals): 留出集生成——风格 C、留出集改写导出;评测可模拟人工确认响应方式
- build.py / mutate.py:补风格 C(叙述型),由封存一侧的会话按封存说明实现;开发集风格 A / B 输出逐字节不变
- build.py:复述要求时去行首编号不再吃掉「2路服务器」这类内容数字
- export_rewrite.py:没有风格 A 稿时从未改写的风格 B 稿导出
- run_check.py:--modes 模拟清单页上人工确认的响应方式(C12 的人工确认点)
- check-eval.md:第 3 步冻结点(a60d16e)

查标代码(server/src、prompts)自冻结后未改。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:58:52 +08:00
YANG JIANKUAN
a60d16e169 docs(check): P13 分项逐项对照试读后暂缓,记录原因
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:01:45 +08:00
YANG JIANKUAN
4a5421a412 feat(check): 同一行按单元格;业绩条款不再误跳;重复条目只合并显示(C5 修订)
- 送给模型的「同一行」在有框线的表格里按单元格分行
- 「以合同签订时间为准」不再被当成「签订合同时才交」,要附合同的业绩评分项照常查
- 重复的要求各自判断,问题接口给出 duplicate_ids,查标页同组折叠显示、一起处理
- 决策 C5 修订记入 decisions.md;开发集召回 74%、致命级 25/32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:00:24 +08:00
YANG JIANKUAN
df3ebbb6a1 feat(check): 偏离栏直读、响应错位判断;复查复用和硬预算(C2)
- deviation.py:模型判已响应的条目,证据所在表格行的偏离格写着「偏离 / 部分满足」等
  就改判负偏离;照抄要求格、响应格写的是别的内容的改判「响应可能对不上」
- reuse.py:按「提示词 + 要求 + 候选句原文」算缓存键,存模型原始结论;复查时内容没变、
  证据找得回的条目直接沿用,不调模型
- 每次检查送给模型的字数有上限,超出的条目列为请人工核对,写进覆盖范围
- 开发集 17 份:召回 61% → 73%,负偏离未说明 64% → 90%,规矩投标文件问题数不变

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:47:12 +08:00
YANG JIANKUAN
81c58798d2 feat(check): 需人工核对(P2)和分项 / 单价上限(C7)
- 需人工核对:份数、密封、递交、CA 签章加密、解密、保证金这类文件里看不出来的要求,
  从正式清单按主题合并、带招标原文出处;manual-checks 接口按项目存勾选、写勾选人和时间,
  复查沿用;查标页加「需人工核对」一块,覆盖范围写明列了几条
- 「报价」类清单项:读标从采购需求的清单表规则读出分项 / 单价上限(checklist/price.py);
  查标交给规则 PRICE.OVER_CEILING_ITEM 比对分项报价表,不交给模型;规则集 2026.11-r2
- 版面工具(分页、分行、归栏)挪到 document/pipeline/layout.py,读标和查标共用
- 评测:price_defects.py 从规矩投标文件派生只带报价问题的 4 份(P01、P02、P03)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:31:32 +08:00
YANG JIANKUAN
db1b1f6f9f feat(check): 规则检查(报价形式、形式要件、响应表),表格按单元格分行
- check/rules.py(规则集 2026.11-r1,不调模型):大小写金额、分项合计、超最高限价、
  多个报价、别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应;
  每条挂投标原文,有依据的挂招标侧出处;缺限价、截止时间、有效期要求时写进覆盖范围
- 文字层的句子带上单元格框,表格按单元格上沿分行
- 按 Fable 审读意见收紧致命级:认不准的降为重要或提示
- 评测:score.py 评规则问题;P07 注入记录改为空白处前后原文,剔除注入到人员表的 D10
- 上传页的检查范围文案同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:44:22 +08:00
YANG JIANKUAN
47c6a1258e feat(check): 文件要在、扫描件兜底(C10);响应方式存进清单并可改(C12)
- check/presence.py:「提供《授权委托书》」「须提交投标函」这类要求按标题里有没有这个文件名判断,不调模型。
- 扫描件兜底:要附证明材料的要求没找到、投标文件里有扫描件页时,改为提示级「可能在扫描件里,请人工核对」。
- 响应方式解析时识别、带依据句子存进 checklist.response_modes(迁移 0008);清单页显示、可改(写审计,
  重新解析不覆盖人工改过的);查标用存下来的。新接口 PUT /projects/{id}/checklist/response-modes/{category}。
- check_item_result.layer 加 presence(迁移 0007);契约同步默认接受的两种结论。

开发集 17 份:规矩投标文件的致命问题 120 → 51,致命级召回不变(22 / 32)(docs/architecture/check-eval.md §5)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:53:45 +08:00
YANG JIANKUAN
cb0c2fcafe feat(check): 响应方式识别(C12)和「这条要不要投标人回答」;评测集纠错
- checklist/response_mode.py:按类别(技术 / 商务)识别招标文件的响应方式(逐条响应 / 默认接受),规则优先;
  两种写法冲突按逐条,认不出按逐条并写进覆盖范围。
- check/applicability.py:无效情形(★ / 斜体下划线标注的条款除外)、日期与递交、没要求附材料的评分标准、
  无需提供证明或由采购人查询的资格,不逐条找响应,写进覆盖范围;要求附证明材料按原文判断,中标后才交的不算。
- 默认接受的要求没找到响应:一般要求记 implicit_accepted、不出问题;实质性要求记 implicit_review、提示级
  人工核对;要附证明材料的不免(迁移 0006)。
- match.py:修同一行单元格又是别的条目候选句时的 KeyError(每条清单项单独记候选句,句子从总表里查)。
- 评测集:注入器同一要求的所有表格行一起改;散文、附件、格式文件里也响应了的要求不挂漏响应类问题;
  错位响应必须是另一个参数;复核判不成立的单元不再挂;regen.py 按固定种子重新生成。

开发集 17 份:规矩投标文件报出的问题 639 → 229,误报 21.3 → 12.4 / 100,召回 68% → 65%,致命级 20 → 21 / 32
(docs/architecture/check-eval.md §4)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:25:14 +08:00
YANG JIANKUAN
b7432643dd feat(check): 候选句带同一行的单元格;提示词 check.match v2
检索常命中投标人照抄要求原文的那一格,响应和偏离写在同一行的别的格里,模型看不到。现在候选句在表格里
时,把同一行(同一页、上下范围重叠)的单元格一起交给模型,也可以引用;v2 说明怎么读一行。
开发集 17 份上查标召回 30% → 50%,带标志词的问题 2% → 54%,规矩投标文件上的误报略降,输入 token +44%;
漏响应和响应对象错位变差,下一轮看(docs/architecture/check-eval.md §3)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:58:02 +08:00
YANG JIANKUAN
1d50faccbe fix(evals): 「暂不支持」只套功能类要求;伞状单元和条件性要求不挂问题;低密度投标文件;run_check 加 --v1 对比开关
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:23:16 +08:00
YANG JIANKUAN
112ea5bd41 docs: 用户抽查第二批评测集复核 28 条,全部同意
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:19:53 +08:00
YANG JIANKUAN
912c2b8acf docs: 查标评测开发集第一版(14 份投标文件)结果;汇总脚本 report.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:15:57 +08:00
YANG JIANKUAN
85d6ba67b8 feat(evals): 导出风格 B 改写输入;改正规范里 S01、S05 的响应方式记录;空偏离表注明「无偏离」
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:06:46 +08:00
YANG JIANKUAN
2beccd5fc5 feat(evals): 风格 B(两个分册、改写、自己的编号、干扰项 T01);注入的三处待修项
- build.py:风格 B 分商务资格册和技术册,表格行按 rewrite.json 改写(数字对不上的改写丢弃,规矩靠构造
  保证),用自己的编号,每 12 行技术响应挪到商务资格册(T01);干扰项写进 spec.traps。
- mutate.py:支持多个分册;表格的响应方式以档案的 mode 为准(评分要的技术规格表是 presence,不再把单元
  推成逐条响应);「暂不支持」只套在功能类要求上,数值参数写不满足的值;「提供……方案 / 证明 / 报告」
  这类文件要在的要求不注入参数类问题。
- prompts/style-B.md、prompts/profile.md:风格 B 改写说明和招标文件档案说明(开发集用)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:51:17 +08:00
YANG JIANKUAN
9d3c6e82c1 docs: 用户抽查评测集复核 17 条,全部同意
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:46:58 +08:00
YANG JIANKUAN
d67ea8d017 docs: 查标评测试跑结果(开发集 S02、S04、S10)和发现
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:27:10 +08:00
YANG JIANKUAN
54534be0c1 fix(evals): 漏响应在所有表里都删;评分按句子重叠合并重复的标注单元
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:26:21 +08:00
YANG JIANKUAN
a07663cbdb feat(evals): 查标评测集生成器、注入、评分和只检索基线
- build.py:从「招标文件档案」和读标清单生成规矩投标文件稿;技术参数按 op / value / unit 算满足的值,
  分项报价按分项上限比例分摊,落款日期取截止日前 3 天,偏离表按格式表头决定列(含设备名称列),
  读标漏抽的技术参数也补上响应;routes 支持「+」组合和 prose 散文响应。
- mutate.py:按词库注入要求项问题(单点 / 连续块,带不带标志词)和规则问题,写响应清单;
  符合性条件响应表的问题按表级记一个。
- score.py:按单元去重、类型族比对、读标漏抽单列、端到端召回、无法对照单列。
- run_check.py:加只检索不调模型的基线;render.py:扫描件按最后一次出现定位。
- vocab-dev.json:开发集注入词库(留出集的另行封存,不进 git)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:18:48 +08:00
YANG JIANKUAN
01ddc86281 docs: 确认 C12(响应方式)和 E1(评测集复核方式)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:05:17 +08:00
YANG JIANKUAN
4ed945c1de docs(evals): 查标评测集规范 v0.2、留出集划分;渲染和离线查标脚本;新增待定事项 C12
- evals/check/GUIDELINES.md:请 Fable 审查 v0.1 后修改。响应方式按「文件 × 类别」记(逐条 / 默认接受 /
  证明材料(投标时、中标后)/ 文件要在 / 平台内应答),10 份招标文件逐份核对;规矩投标文件靠构造保证
  (表格脚本生成,LLM 只写散文和改写),对全部要求响应,问题只挂人工复核过的标注单元;注入分单点 /
  连续块 / 全局三种粒度、带不带标志词、两档密度;留出集注入词库和 C 风格说明开工时封存;评分补只检索
  基线、按单元去重、端到端召回。
- evals/check/split.json:开发集 S01 S02 S04 S05 S07 S08 S10,留出集 S03 S06 S09 + 真实国动投标文件。
- evals/check/render.py、run_check.py。
- m3-plan.md:新增待定事项 C12(每条要求怎么才算「响应了」)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:02:40 +08:00
YANG JIANKUAN
e499de7486 feat: 查标第 1 步——上传投标分册 → 检查 → 逐条处理 → 复查沿用
后端
- check 模块:检查预览、发起、进度、取消、记录;六个阶段(准备投标文件、响应匹配、规则、证据校验、
  状态沿用、汇总);问题列表、处理、撤销、批量处理(只限提示级)。
- 每条清单项的检查结论 check_item_result(C9,迁移 0005):「已响应」也带投标原文出处;
  没有校验通过的证据不能判已响应(C3)。级别从清单项算,不让模型定;说明不写整改建议。
- 响应匹配先只有第三层:内存 BM25 检索候选句 + check.match v1 提示词,检索不到不调模型,
  漏答记 missing_answer,模型临时错误只影响那一批。
- 计费:免费版不记流水;专业版 24 小时内复查免扣(记一行 0);expected_charge 不一致时拒绝不扣点;
  失败、取消退回;投标文件读不出文字按输入问题失败、不扣点。
- 投标分册:类型(volume_kind)和版本;电子投标平台加密格式在上传时拦下。
- 解析流水线前几个阶段拆到 document/pipeline/normalize.py,解析和检查共用。
- ops 模块:反馈接口;致命问题确认无误的说明自动进误报反馈(U4)。
- SK1:Valkey 客户端短超时、SSE 连接池上限。SK2:CI 加 Gotenberg、直接跑 init-roles.sh。

前端
- 上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、
  已处理、本次已解决);项目头抽成公共组件,加「查标」标签页;工作台「下一步」覆盖查标阶段。

契约、文档:openapi.yaml 加 item-results、volume_kind、coverage 字段;data-model、api-design §10、
架构 §8 / §13(SK14)、AGENTS.md 同步。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:48:10 +08:00
YANG JIANKUAN
6ab42959df docs: 查标实现计划 v0.2,确认 C1–C11、SK1–SK2;参考项目查标深读
- m3-plan.md:请 Fable 当顾问深读参考项目的查标实现、对照读标教训复评后的计划。
  先建评测集再写规则(金标准是虚构投标文件的响应清单);每条清单项留带证据的结论;
  成本靠按章节打批、硬预算和复查复用控制;认不出、没查的都写进检查覆盖范围。
- decisions.md:C1–C11、SK1–SK2 全部按建议确认;用户的真实国动投标文件作为留出集,第 3 步冻结代码后再用。
- ref-bid-engine-analysis.md:新增 §6 查标深读;§5.4 的检索方案改为内存 BM25。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:48:10 +08:00
234 changed files with 29716 additions and 756 deletions

View File

@@ -20,3 +20,17 @@
# BD_AI_MODEL=deepseek-chat
# 按能力选模型(选填,JSON):没配的能力用 BD_AI_MODEL
# BD_AI_MODELS={"reasoning": "deepseek-reasoner"}
# OCR(vision-plan §4.1):扫描页、插图交给 OCR 服务(services/ocr)。默认 none(扫描页按没有文字处理),prod 必须接
# 本地起服务:.venv/bin/bid-doctor-ocr(:8029),或 docker compose ... up -d ocr
# BD_OCR_PROVIDER=http
# BD_OCR_URL=http://127.0.0.1:8029
# 专业版对公转账的收款信息(决策 M6-6):开通申请里给用户看;没配时显示「(待定)」,prod 下必须配
# BD_PAYEE_NAME=
# BD_PAYEE_BANK=
# BD_PAYEE_ACCOUNT=
# 运营后台(决策 M6-2、M6-4):bid-doctor ops 进程用 bd_ops 做跨企业只读;只放行白名单地址(CIDR,JSON 数组)
# BD_OPS_DATABASE_URL=postgresql+psycopg://bd_ops:dev-ops@127.0.0.1:54329/bid_doctor
# BD_OPS_ALLOWED_IPS=["127.0.0.1/32", "10.0.0.0/8"]

View File

@@ -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,17 +38,21 @@ 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
BD_OPS_PASSWORD: dev-ops
run: sh deploy/compose/postgres/init-roles.sh
- working-directory: server
run: |
../.venv/bin/ruff check src tests migrations
../.venv/bin/ruff format --check src tests migrations
../.venv/bin/ruff check ../services/ocr/src --config ../services/ocr/pyproject.toml
../.venv/bin/ruff format --check ../services/ocr/src
../.venv/bin/pyright
../.venv/bin/lint-imports
../.venv/bin/pytest -q

1
.gitignore vendored
View File

@@ -35,6 +35,7 @@ opensource-ref/
# 技术验证的产物(由样本派生,样本不转载)
spikes/**/out/
spikes/.venv/
spikes/**/.venv/
spikes/**/.env
# 工程骨架:本地环境与产物

View File

@@ -81,14 +81,16 @@
骨架见技术架构 §13。后端 Python 3.12+(uv workspace),前端 Vue 3 + Vite(pnpm workspace)。
```
server/ 后端:api 和 worker 是同一个包、两种进程(bid-doctor api | worker | migrate)
server/ 后端:api、worker、ops 是同一个包、三种进程(bid-doctor api | worker | ops | migrate | ops-admin)
src/bid_doctor/
core/ 配置、数据库与租户事务、错误码、加密、对象存储、队列、事件流、权限点
identity/ billing/ project/ document/ checklist/ ai/ audit/ notification/ 业务模块(架构 §3.2)
app.py worker.py cli.py
migrations/ Alembic;sql/0001_baseline/ 是 data-model 的建表脚本,按模块拆分
tests/ 集成测试(真实 PostgreSQL + 行级安全策略)和单元测试;fixtures/ 是虚构的小样例
services/ocr/ OCR 服务(独立进程,worker 通过 HTTP 调用;RapidOCR,CPU)
apps/web/ 用户端单页应用
apps/admin/ 运营后台(单独构建、单独部署,私有化不交付;接口见 docs/architecture/ops-api.md)
packages/api-client/ 由 docs/architecture/openapi.yaml 生成的类型 + 客户端(不手写接口类型)
prompts/ 带版本号的提示词(已发布的版本不再修改)
deploy/compose/ Docker Compose;deploy/docker/ 镜像
@@ -99,10 +101,11 @@ deploy/compose/ Docker Compose;deploy/docker/ 镜像
| 做什么 | 命令 |
|---|---|
| 装依赖 | `uv sync` 和 `pnpm install --ignore-scripts` |
| 起依赖(PostgreSQL、Valkey、Gotenberg) | `docker compose -f deploy/compose/compose.yaml up -d postgres valkey gotenberg` |
| 起依赖(PostgreSQL、Valkey、Gotenberg、OCR) | `docker compose -f deploy/compose/compose.yaml up -d postgres valkey gotenberg ocr`;OCR 也可以直接 `.venv/bin/bid-doctor-ocr`(:8029)。要识别扫描件,后端进程设 `BD_OCR_PROVIDER=http`(默认 none,扫描页按没有文字处理) |
| 建表 / 升级数据库 | `cd server && ../.venv/bin/bid-doctor migrate` |
| 起后端 | `cd server && ../.venv/bin/bid-doctor api`(:8009),另开一个终端 `../.venv/bin/bid-doctor worker` |
| 起前端 | `pnpm dev`(:5173,`/api` 代理到 :8009)。dev 环境短信验证码固定是 `000000` |
| 起运营后台 | `cd server && ../.venv/bin/bid-doctor ops`(:8019,只放行白名单地址),`pnpm dev:admin`(:5174);运营账号 `../.venv/bin/bid-doctor ops-admin create --username <名> --display-name <姓名>`,打印的 otpauth 地址用身份验证器 App 添加 |
| 后端检查 | `cd server && ../.venv/bin/ruff check src tests migrations && ../.venv/bin/ruff format --check src tests migrations && ../.venv/bin/pyright && ../.venv/bin/lint-imports && ../.venv/bin/pytest -q` |
| 前端检查 | `pnpm typecheck && pnpm build` |
| 改了 openapi.yaml 之后 | `pnpm gen:api`,重新生成的 `schema.d.ts` 一起提交(CI 会比对) |
@@ -204,4 +207,45 @@ 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` 进度一节。
- 2026-11-04:查标第 0 步(评测集)。规范 v0.2(`evals/check/GUIDELINES.md`,Fable 审查后修改):响应方式按「文件 × 类别」记,规矩投标文件靠构造保证,问题只挂复核过的要求,留出集词库和风格 C 封存。C12(响应方式)、E1(复核由 Fable 做、用户抽查 20%)已确认。开发集 S02、S04、S10 六份试跑(`docs/architecture/check-eval.md` §1):查标召回 38%,规矩投标文件的误报大头是不需要响应的条目、表格里看不见同一行的响应格、扫描件和格式文件找不到。
- 2026-11-04:用户抽查 Fable 的复核 17 条,全部同意。
- 2026-11-04:查标评测开发集第一版:7 份招标文件、14 份投标文件(风格 A / B 轮换),注入问题 222 个、Fable 复核通过 188 个。查标召回 40%(致命级 49%),带标志词的问题只有 22%;规矩投标文件报出 699 个问题,近一半在不需要响应的要求上(`docs/architecture/check-eval.md` §2)。
- 2026-11-04:用户抽查第二批复核 28 条,全部同意(两批合计 45 条)。
- 2026-11-04:查标第一轮改进:候选句带同一行的单元格、提示词 check.match v2。开发集 17 份上召回 30% → 50%,带标志词的问题 2% → 54%,误报略降,输入 token +44%;漏响应和响应对象错位变差(`docs/architecture/check-eval.md` §3)。评测集修了待修项、加了 3 份低密度投标文件,第三批复核 55 条、用户抽查 11 条全部同意。
- 2026-11-04:查标第二轮:评测集纠错(漏响应、错位的「变差」多是生成器只改了一处);新增响应方式识别(C12,规则优先)和「这条要不要投标人回答」的判断。开发集 17 份上规矩投标文件报出的问题 639 → 229,误报 21.3 → 12.4 / 100,召回 68% → 65%,致命级 20 → 21 / 32;证明材料缺失几乎查不出(扫描件)。第四批复核 45 条,用户抽查 9 条全部同意(四批共 65 条)。见 `docs/architecture/check-eval.md` §4。
- 2026-11-04:查标第三轮:文件要在(按标题找文件名,不调模型)、扫描件兜底(C10,没找到的证明材料改为提示级「可能在扫描件里,请人工核对」)、响应方式存进清单并在清单页显示、可改(C12 的人工确认点,迁移 0007、0008)。开发集上规矩投标文件的致命问题 120 → 51,致命级召回不变(22 / 32);证明材料缺失仍查不出,要靠 OCR / 多模态(S5)。见 `docs/architecture/check-eval.md` §5。
- 2026-11-04:查标第四轮:规则检查(`check/rules.py`,规则集 2026.11-r1,不调模型)——大小写金额、分项合计、超限价、多个报价、别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应;文字层的句子带上单元格框,表格按单元格分行。请 Fable 从真实投标文件角度审读,按「认不准的不给致命」改了定级和取值范围。开发集 17 份上规则问题 57 / 57 查出、规矩投标文件 0 误报,但规则是看着生成器和开发词库写的,这个分数说明不了泛化,要等留出集和国动真实投标文件。见 `docs/architecture/check-eval.md` §6。
- 2026-11-04:查标第 2 步收尾:需人工核对(份数、密封、递交、CA 签章加密、解密、保证金,从清单按主题合并,查标页勾选、按项目存,P2);「报价」类清单项(C7,读标从清单表规则读出分项 / 单价上限)和规则 `PRICE.OVER_CEILING_ITEM`;评测集派生 4 份报价问题投标文件,21 份上规则问题 62 / 62、误报 0。见 `docs/architecture/check-eval.md` §7。
- 2026-11-04:查标第五轮(不调模型的后处理):偏离栏直读(证据所在表格行的偏离格写着不满足就改判负偏离)、响应错位(照抄要求格 + 响应格写的是别的内容);复查复用(送审内容没变的条目沿用上次的模型结论)和每次检查的硬预算。开发集 17 份召回 61% → 73%,负偏离未说明 64% → 90%,致命级 22 → 24 / 32,规矩投标文件的问题数不变(235)。见 `docs/architecture/check-eval.md` §8。
- 2026-11-04:查标第六轮:送给模型的「同一行」按单元格分行;修了「以合同签订时间为准」被当成中标后才交、业绩评分项被跳过的缺陷;重复条目按用户决定改为各自判断、只合并显示(C5 修订,`docs/decisions.md`);致命项重问按数据(开发集致命级证据校验失败 0 条)暂不做。评测集裁定 1 条。开发集召回 74%、致命级 25 / 32。见 `docs/architecture/check-eval.md` §9。
- 2026-11-04:查标第 3 步冻结(`a60d16e`),跑留出集一次:S03、S06、S09 各一份风格 B 规矩 + 风格 C 马虎(风格 C 由封存一侧的会话实现),96 个注入问题 Fable 复核通过 79,用户抽查 19 条全部同意。留出集召回 54%(开发集 74%)、致命级 56%(开发集 78%)、负偏离未说明 25%(开发集 92%)、规则问题 13 / 20;S03 的规矩投标文件因响应方式识别过度触发报了 1094 个提示,人工确认响应方式后降到 45 个。开发集分数有相当一部分是调出来的;要修的 5 处见 `docs/architecture/check-eval.md` §11。
- 2026-11-04:按用户决定(E2)先修留出集暴露的 5 处(响应方式识别、偏离栏按「是不是明确满足」判断、另报价 / 附加条件按结构判断、空白栏、异体字),规则集 2026.11-r3。开发集不退化(召回 75%、规矩投标文件问题数不变);留出集参考数字召回 54% → 69%、规矩投标文件问题 1134 → 82(已看过,正式成绩仍以冻结代码那次为准)。见 `docs/architecture/check-eval.md` §12。
- 2026-11-04:查标第 5 步检查报告:导出 → 后台用 Gotenberg 生成 A4 PDF(首页覆盖范围和免责声明、问题明细、需人工核对附录,页眉 logo、页脚免责一句和页码)→ 加密归档,可重复下载;前端加「报告」标签页(选项、导出、内嵌预览、历次下载)。补了项目状态流转(递交 / 撤销递交 / 归档 / 撤销归档,致命未处理也能递交,递交后只读,都留痕)。产品可以从上传招标文件一路走到导出报告、标记递交。见 `docs/architecture/m3-plan.md` 进度。
- 2026-11-04:查标第 4 步资质库和资质匹配(`docs/architecture/qualification-plan.md`,QL1–QL7 全部按建议确认)。资质库页可增删改、挂证书扫描件(企业级密钥加密),到期按进行中项目的投标截止日提醒;查标按证书类别词表(16 类,产品证书、人员证书不进匹配)核对有效期、等级、有没有覆盖,投标文字层按清单项定级,只有资质库记录的降一级、出处显示「依据:资质库记录」,规则集 2026.11-r4。开发集不退化;派生的资质情况表上 D12 3 / 3、D18 2 / 2、规矩的资质表 0 误报,但表和词表是对着同一批样本写的,泛化要等国动真实文件(`check-eval.md` §13)。
- 2026-11-04:项目基本信息修改和分包选择(FR-M2-03、04,`docs/architecture/project-info-plan.md`,PI1–PI7 全部按建议确认)。新增「概览」标签页(项目要点可改、带出处、可恢复 AI 值;采购包;关键日期)和选包页(手工调整、一键「不分包」);清单默认只列通用 + 所投的包;检查记下当时用的截止时间、限价、所投的包、法律体系,报告按它写,之后改了信息查标页提示重新检查。样本里分包识别对的只有 S10(S06 把分项认成 5 个包,S01、S08 单包写了包号),改进分包抽取记进读标专项。
- 2026-11-05:补遗追加(FR-M2-10,`docs/architecture/addendum-plan.md`,AD1–AD6 全部按建议确认)。招标文件解析后可以追加补遗 / 澄清(单独解析、扣 1 次),新条目标「补遗 N」进正式清单、清单回到草稿;同一种日期时间不同的旧条目标「已被补遗 N 更新」,项目字段的新值在概览「采用 / 不采用」,采用截止时间时日期条目一起确认,没确认前查标用旧值、检查预览提示。补遗不改项目名称、编号、采购人等(更正公告标题会被抽成项目名称)。真实模型对虚构更正公告抽得对;还没有真实补遗样本。
- 2026-11-05:读标、查标全流程走查(浏览器从注册走到导出报告)。修了走查发现的问题:原生下拉换成按规范 §7.21 的 `SelectBox`(Reka UI,和搜索框同高,长选项不再挤压签名);关键日期去重;报告页致命数改读项目的实时值;★ 只是「重要」的文件清单项也挂到 ★ 规则上;检查范围文案过期;报告出处不重复文件名(模板 2026.11-v3)。查标结果页按原型补齐:本次处理的留在原位、J / K / O / 1 / 2 / 3 / X / U 键盘、「提示」批量确认、「判断有误?」反馈、覆盖范围默认收起、导出报告入口;项目头改为「就绪 / 未就绪」,致命数挂在查标标签上。
- 2026-11-05:查标结果页、概览改成和读标页一样的左右布局(左边内容,右边原文贴住视口),去掉原文侧边抽屉。查标右侧招标文件、投标文件左右切换(`components/DocStage.vue`):当前的占满,另一份露出一窄条(文件名、纸边、带方向箭头的标签,截断处淡入底色),点它或点另一边的出处就滑过去,两边各自记着页和高亮,J / K 时原文跟着翻。概览把关键日期和采购包合成一张卡放到最上面。修了原文查看器直接打开到某页时重复渲染出镜像乱字的问题。内容区四角改为直角(设计规范 §5.3 同步)。
- 2026-11-05:国动真实投标文件跑了一次(`check-eval.md` §14,代码 `5b77012`、规则集 r4、资质库空):重新读标 299 条,查标 26 秒报 44 个问题,逐条对原文后有用 9 个,**致命、重要各 1 个都是误报**(响应格「完全满足,无偏离。」被当成错位;「不……另行收费」被当成附加条件)。另外扫描页只按整页有没有字判断、不需要回答的条目还在出提示。人工看到评分索引表页码和实际页码对不上,产品还不查这一项。结果只记录,6 处问题修不修待用户定。
- 2026-11-05:按用户决定(E3)修了国动暴露的 6 处,规则集 2026.11-r5(`check-eval.md` §15)。国动重新检查 44 → 11 个,全是提示级,有用的 9 个都还在(看过结果才修,只作参考)。开发集不退化:召回 75% → 74%(模型重跑的波动),致命级 25 / 32 不变,规矩的投标文件报出的问题 239 → 213,规则问题 57 / 57。投标分册的文字层升到 v5(每页记图片占比,用来判断扫描件)。
- 2026-11-05:查标第 6 步前端补齐(`m3-plan.md` 进度)。
- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;「已响应」也能反馈判断有误。证书要求写资质库里有没有、在截止日是否有效(新接口 `qualification-matches`,资质库空时不写)。
- 概览:加了检查记录卡和文件卡。
- 工作台:加了「致命」列、统计可点筛选、待处理的致命问题卡(「去处理」定位到那条问题)。
- 日期输入加了日历弹层;查标页加了 ? 键。
- 2026-11-05:M6 剩下的 P0(`docs/architecture/m6-plan.md`,M6-1–M6-8 全部按建议确认)。
- 用户端:额度与套餐页(流水逐条可核、按功能分布、扣点规则、导出 CSV)、开通 / 续费申请、80% / 100% 提醒、通知铃铛、账号页和注销、删除项目(彻底删除)。
- 到期:专业版到期后整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒;worker 每小时跑一次定时任务。
- 运营后台:独立进程 `bid-doctor ops` 和 `apps/admin`,用户名 + 密码 + TOTP + IP 白名单,可以处理开通申请、查企业、调额度、看误报 / 漏报、失败任务和审计,不看业务内容(`ops-api.md`)。新增数据库角色 `bd_ops`,迁移 0010、0011。
- 顺手修了取消解析 / 检查时接口报 500 的问题。后端 156 个测试,浏览器从注册走到注销。
- 2026-11-05:运营后台第二版(M6-9、M6-10):左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签),运营人员角色只留结构(`ops_admin.role`、权限点、每个接口的检查点、菜单按权限过滤,现在只有管理员),迁移 0012。
- 2026-11-05:前端走查修改。报告预览改用 pdf.js(浏览器自带的 PDF 查看器是深色的,改不了);读标页改成和查标一样的左右布局(项目头滚出屏幕,清单卡片和原文区贴住顶栏),响应方式收进清单栏的按钮;报告页「导出」统一改称「生成」(生成一份存进项目,下载才是存到电脑),PDF 模板 2026.11-v4;概览检查记录的报告列只写份数和「查看报告」;招标 / 投标文件切换标签改为双箭头、指向展开方向;项目头三项取值对齐。
- 2026-11-05:扫描件与图片识别计划 v0.2(`docs/architecture/vision-plan.md`)。现状:OCR 和视觉模型都没接,扫描页只做 C10 兜底,招标文件正文里的图片静默忽略,签章有无(FR-M3-04 P0)没做。方案:OCR 给文字和坐标,视觉模型做理解、结论要对上 OCR 行;覆盖读标、查标(附件索引、投标侧证书、签章)、资质库上传识别自动填表。VS1–VS9 已确认:视觉模型用 DeepSeek Flash(实测 V4.1 Flash 收图片),不单独计费,其余按建议(`docs/decisions.md`)。
- 2026-11-05:扫描件识别初测(`tech-validation.md` §7,代码 `spikes/s5-ocr/`),用国动投标文件的 80 页扫描页、22 页图表。OCR 初定 RapidOCR + PP-OCRv6 small(CPU 0.6 秒 / 页,合成扫描件字符差 ≤ 1.1%,横放页自动转正,坐标准);DeepSeek Flash 页面分类、合同 / 执照字段、图表概述可用,102 页约 0.47 元。补了校验规则(多行引用、字段格式、半截值、手写低可信、红章局部按红色通道重读),招标文件里的图片按重要程度分流(VS10),计划升到 v0.3。顺带发现财务报表和一份合同的单位名是「南京长卿信息技术有限公司」,和投标人名称不一致(多半是曾用名),请用户核对。还差证书扫描件(用户在找)和签章标注。
- 2026-11-05:证书识别验证(`tech-validation.md` §7.3):用户的 7 张证书(人员证书、软著、一图两证,有打码)各转 4 个角度,200 个字段全对、打码字段 16 次都没给值。补了整页 180° 检测和「所引 OCR 行置信度低于 0.9 标识别不确定」;提示词是对着这 7 张改的,泛化要下一批证书验证。
- 2026-11-05:**OCR 进文字层**(vision-plan 第 1 步,架构 §13 SK15)。新增 OCR 服务 `services/ocr`(镜像、Compose 服务);扫描页和插图识别后并入文字层(v6),句子标 `source = "ocr"`;出处标「OCR」(迁移 0013);依据识别文字的问题降级,规则不查扫描附件。国动真实投标文件端到端:82 页扫描页、25 张插图读出文字,7 条要求找到了以前读不到的证据;第一次跑附件上的规则噪音 21 个,改后 36 → 16 个提示(`check-eval.md` §16)。后端 167 个测试通过。
- 2026-11-05:**资质库上传识别**(vision-plan 第 2 步,架构 §13 SK16)。按用户要求简化:「添加资质」对话框最上面传证书照片,识别后填进下面的表单,一张图按一张证书处理(VS11、VS12)。只预填和 OCR 文字对得上的字段并标「识别」,打码、对不上的只给建议;软著取登记号、默认长期有效;「五年」给推算按钮;主体不一致、人员 / 产品证书、编号重复都提醒;识别值留痕、照片挂成附件(迁移 0014)。浏览器走查通过;用户确认国动财务报表上的是曾用名,第 3 步做主体一致时要认曾用名(VS13)。后端 175 个测试通过。
- 2026-11-05:资质库改为「查看详情」:点行打开只读的键值视图 + 附件缩略图,「更多」菜单只有查看详情、编辑、删除(去掉了和编辑重复的「管理附件」),表单不再显示「不扣额度」。新增大图浏览控件(设计规范 §7.23):深色蒙层、缩放、拖动、翻页、键盘操作,附件不再开新标签页;PDF 附件按页出预览图(新接口 `.../files/{file_id}/previews`)。
- 下一步:可做的有:读标抽取专项优化(`extract-status.md`)、更多真实投标文件、M6 的 P1(协作权限、资产库)。

13
apps/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>标博士运营后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

25
apps/admin/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@bid-doctor/admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/barlow-semi-condensed": "5.3.0",
"@lucide/vue": "1.50.0",
"vue": "3.5.43",
"vue-router": "4.6.4"
},
"devDependencies": {
"@types/node": "24.19.2",
"@vitejs/plugin-vue": "6.0.9",
"typescript": "5.8.3",
"vite": "7.3.7",
"vue-tsc": "3.3.12"
}
}

53
apps/admin/src/App.vue Normal file
View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
/** 运营后台的框架:左侧菜单(OpsSidebar)+ 内容。登录页不带框架。菜单上的待办数随切换页面刷新,另外每分钟刷新一次。 */
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import { CHANGED, ops, type Operator, type Summary } from "./api";
import OpsSidebar from "./OpsSidebar.vue";
const route = useRoute();
const router = useRouter();
const me = ref<Operator | null>(null);
const summary = ref<Summary | null>(null);
async function loadMe() {
if (route.meta.bare) return;
me.value = await ops<Operator>("/me").catch(() => null);
}
async function loadSummary() {
if (route.meta.bare || !me.value) return;
summary.value = await ops<Summary>("/summary").catch(() => summary.value);
}
let timer: number | undefined;
onMounted(async () => {
await loadMe();
await loadSummary();
timer = window.setInterval(loadSummary, 60_000);
window.addEventListener(CHANGED, loadSummary);
});
onBeforeUnmount(() => {
window.clearInterval(timer);
window.removeEventListener(CHANGED, loadSummary);
});
watch(
() => route.fullPath,
async (_, old) => {
if (route.meta.bare) return;
if (!me.value || old?.startsWith("/login")) await loadMe();
await loadSummary();
},
);
async function logout() {
await ops("/auth/logout", { method: "POST" }).catch(() => undefined);
me.value = null;
summary.value = null;
await router.replace({ name: "login" });
}
</script>
<template>
<RouterView v-if="route.meta.bare" />
<div v-else class="ops-frame">
<OpsSidebar :me="me" :summary="summary" @logout="logout" />
<main class="ops-main"><div class="content"><RouterView /></div></main>
</div>
</template>

View File

@@ -0,0 +1,289 @@
<script setup lang="ts">
/**
* 运营后台的左侧菜单:标准横版 logo(反白)+「运营后台」+ 环境标签(不是生产环境时醒目提示,免得把测试当生产);
* 分组像文件夹一样可以展开收起(记在本机),当前页所在的分组总是展开;菜单项带图标和待办数;底部是当前运营人员和退出。
* 菜单按权限点过滤(nav.ts)。
*/
import { ChevronDown, LogOut } from "@lucide/vue";
import logoUrl from "@design/logo/svg/logo-horizontal-reverse.svg?url";
import { computed, ref, watch } from "vue";
import { RouterLink, useRoute } from "vue-router";
import type { Operator, Summary } from "./api";
import { visibleNav } from "./nav";
const props = defineProps<{ me: Operator | null; summary: Summary | null }>();
const emit = defineEmits<{ logout: [] }>();
const route = useRoute();
const groups = computed(() => visibleNav(props.me?.permissions ?? []));
const KEY = "ops.nav.collapsed";
function load(): string[] {
try {
return JSON.parse(localStorage.getItem(KEY) ?? "[]") as string[];
} catch {
return [];
}
}
const collapsed = ref(new Set(load()));
function save(s: Set<string>) {
collapsed.value = s;
try {
localStorage.setItem(KEY, JSON.stringify([...s]));
} catch {
/* 隐私模式等存不了:只是这次有效 */
}
}
function toggle(key: string) {
const s = new Set(collapsed.value);
if (s.has(key)) s.delete(key);
else s.add(key);
save(s);
}
const isActive = (name: string, also: string[] = []) => route.name === name || also.includes(String(route.name));
const activeGroup = computed(() => groups.value.find((g) => g.items.some((i) => isActive(i.name, i.also)))?.key);
const isOpen = (key: string) => !collapsed.value.has(key);
// 跳到一个收起的分组里的页面(链接、详情页):把那一组展开
watch(activeGroup, (g) => {
if (g && collapsed.value.has(g)) save(new Set([...collapsed.value].filter((k) => k !== g)));
}, { immediate: true });
const ENV: Record<string, string> = { dev: "开发环境", test: "测试环境" };
</script>
<template>
<aside class="ops-side" aria-label="运营后台菜单">
<RouterLink class="brand" :to="{ name: 'requests' }" aria-label="标博士运营后台">
<img :src="logoUrl" alt="标博士" />
</RouterLink>
<div class="sub">
<span>运营后台</span>
<span v-if="me && me.env !== 'prod'" class="env">{{ ENV[me.env] ?? me.env }}</span>
</div>
<nav class="groups">
<section v-for="g in groups" :key="g.key" class="group">
<button
type="button"
class="gh"
:aria-expanded="isOpen(g.key)"
:aria-controls="`grp-${g.key}`"
@click="toggle(g.key)"
>
<span>{{ g.label }}</span>
<ChevronDown class="chev" :class="{ shut: !isOpen(g.key) }" :stroke-width="1.75" />
</button>
<ul v-show="isOpen(g.key)" :id="`grp-${g.key}`">
<li v-for="i in g.items" :key="i.name">
<RouterLink :to="{ name: i.name }" :aria-current="isActive(i.name, i.also) ? 'page' : undefined">
<component :is="i.icon" class="ic" :stroke-width="1.5" />
<span class="lbl">{{ i.label }}</span>
<span v-if="i.badge && summary && summary[i.badge]" class="badge" :class="{ warn: i.badge === 'failed_jobs_7d' }">
{{ summary[i.badge] }}
</span>
</RouterLink>
</li>
</ul>
</section>
</nav>
<div v-if="me" class="me">
<span class="avatar" aria-hidden="true">{{ me.display_name.slice(0, 1) }}</span>
<span class="who">
<b :title="me.display_name">{{ me.display_name }}</b>
<small>{{ me.role_label }} · {{ me.username }}</small>
</span>
<button type="button" class="out" aria-label="退出登录" title="退出登录" @click="emit('logout')">
<LogOut class="ic" :stroke-width="1.5" />
</button>
</div>
</aside>
</template>
<style scoped>
.ops-side {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
height: 100vh;
padding: var(--sp-5) var(--sp-3) var(--sp-4);
background: linear-gradient(180deg, var(--side-top) 0%, var(--side-bottom) 100%);
color: var(--side-fg);
overflow-y: auto;
}
.brand {
display: block;
padding: 0 var(--sp-2);
border-radius: var(--radius-ctl);
}
.brand img {
display: block;
width: 148px;
height: auto;
}
.brand:focus-visible,
.gh:focus-visible,
.groups a:focus-visible,
.out:focus-visible {
outline: 2px solid var(--side-fg-strong);
outline-offset: -2px;
}
.sub {
display: flex;
gap: var(--sp-2);
align-items: center;
margin: var(--sp-3) var(--sp-2) var(--sp-5);
padding-bottom: var(--sp-4);
border-bottom: var(--line) solid var(--side-line);
font-size: var(--fs-caption);
letter-spacing: 0.12em;
color: var(--side-muted);
}
/* 非生产环境:赭色实底标签(红色只给致命问题,U1) */
.env {
padding: 0 6px;
border-radius: var(--radius-xs);
background: var(--warn);
color: var(--c-ink);
font-weight: var(--fw-bold);
letter-spacing: 0;
line-height: 18px;
}
.groups {
flex: 1;
}
.group + .group {
margin-top: var(--sp-3);
}
.gh {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 28px;
padding: 0 var(--sp-2);
border: 0;
border-radius: var(--radius-ctl);
background: none;
font: inherit;
font-size: var(--fs-caption);
color: var(--side-muted);
cursor: pointer;
}
.gh:hover {
color: var(--side-fg-strong);
}
.chev {
width: 14px;
height: 14px;
transition: transform var(--dur-fast) var(--ease);
}
.chev.shut {
transform: rotate(-90deg);
}
ul {
margin: 2px 0 0;
padding: 0;
list-style: none;
}
.groups a {
display: flex;
gap: var(--sp-3);
align-items: center;
height: 38px;
padding: 0 var(--sp-2) 0 var(--sp-3);
border-radius: var(--radius-ctl);
color: var(--side-fg);
text-decoration: none;
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.groups a .ic {
flex: none;
width: 18px;
height: 18px;
}
.groups a .lbl {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groups a:hover {
background: var(--side-hover);
color: var(--side-fg-strong);
}
.groups a[aria-current="page"] {
background: var(--side-active);
color: var(--side-fg-strong);
font-weight: var(--fw-bold);
}
.badge {
min-width: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--side-avatar-bg);
color: var(--side-fg-strong);
font: var(--fw-medium) var(--fs-caption) / 20px var(--font-figures);
text-align: center;
}
.badge.warn {
background: var(--warn);
color: var(--c-ink);
}
.me {
display: flex;
gap: var(--sp-2);
align-items: center;
margin-top: var(--sp-4);
padding: var(--sp-3) var(--sp-2) 0;
border-top: var(--line) solid var(--side-line);
}
.avatar {
display: inline-grid;
flex: none;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--side-avatar-bg);
box-shadow: 0 0 0 1px var(--side-avatar-ring);
color: var(--side-fg-strong);
font-weight: var(--fw-bold);
}
.who {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.who b {
overflow: hidden;
color: var(--side-fg-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.who small {
font-size: var(--fs-caption);
color: var(--side-muted);
}
.out {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius-ctl);
background: none;
color: var(--side-fg);
cursor: pointer;
}
.out:hover {
background: var(--side-hover);
color: var(--side-fg-strong);
}
.ic {
width: 18px;
height: 18px;
}
</style>

15
apps/admin/src/admin.css Normal file
View File

@@ -0,0 +1,15 @@
/* 运营后台的框架(决策 M6-3):颜色、字号、间距只用 tokens.css 的变量;按钮、表格、卡片沿用用户端的组件样式 */
body{margin:0;background:var(--bg-page);color:var(--text-primary)}
.ops-frame{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh;min-width:1280px}
.ops-main{min-width:0}
.ops-main .content{max-width:1440px;padding-top:var(--sp-8)}
.toolbar{display:flex;gap:var(--sp-3);align-items:center;margin-bottom:var(--sp-4)}
.toolbar .input{max-width:360px}
.card{margin-bottom:var(--gap-card)}
.card.flush > .card-h{padding:var(--sp-4) var(--card-x) 0}
table.data td{vertical-align:top}
.nowrap{white-space:nowrap}
.r{text-align:right}
.acts{display:flex;flex-wrap:wrap;gap:var(--sp-2)}
.err-text{color:var(--warn-text)}
.inv{margin:0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}

155
apps/admin/src/api.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* 运营后台接口(/ops/v1,docs/architecture/ops-api.md)。内部工具,类型在这里手写;cookie 会话 + CSRF 头。
*/
export class OpsError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
/** 页面上做完操作(开通、退回……)发这个事件,菜单上的待办数跟着刷新 */
export const CHANGED = "ops:changed";
export const notifyChanged = () => window.dispatchEvent(new Event(CHANGED));
let onUnauthorized: () => void = () => {};
export function setUnauthorized(fn: () => void) {
onUnauthorized = fn;
}
function csrf(): string {
return document.cookie.split("; ").find((c) => c.startsWith("bd_ops_csrf="))?.split("=")[1] ?? "";
}
export async function ops<T>(path: string, init: { method?: string; body?: unknown; query?: Record<string, string | undefined> } = {}): Promise<T> {
const qs = new URLSearchParams(Object.entries(init.query ?? {}).filter((e): e is [string, string] => !!e[1])).toString();
const res = await fetch(`/ops/v1${path}${qs ? `?${qs}` : ""}`, {
method: init.method ?? "GET",
credentials: "include",
headers: { "Content-Type": "application/json", ...(init.method && init.method !== "GET" ? { "X-CSRF-Token": csrf() } : {}) },
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
if (res.status === 204) return undefined as T;
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 401 && path !== "/auth/login") onUnauthorized();
throw new OpsError(res.status, data.code ?? "error", data.detail ?? data.title ?? "请求失败");
}
return data as T;
}
/** 时间一律按北京时间显示:「2026-11-05 14:45」 */
const BJ = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
export const fmt = (iso: string | null | undefined) => (iso ? BJ.format(new Date(iso)) : "—");
export const day = (iso: string | null | undefined) => (iso ? BJ.format(new Date(iso)).slice(0, 10) : "—");
export type Tenant = {
id: string;
name: string;
status: string;
created_at: string;
plan: { code: string | null; expired: boolean; ends_at: string | null };
balance: number | null;
projects: number;
phone_hint: string | null;
open_requests: number;
};
export type TenantDetail = Tenant & {
members: { display_name: string; role: string; status: string; phone_hint: string | null; last_login_at: string | null }[];
subscriptions: { plan_code: string; status: string; source: string; starts_at: string; ends_at: string | null; request_no: string | null }[];
quota_entries: { kind: string; amount: number; reason: string | null; ref_type: string | null; created_at: string; unit: string; by_ops: boolean }[];
jobs: Record<string, Record<string, number>>;
};
export type ActivationRequest = {
id: string;
request_no: string;
tenant: { id: string; name: string };
plan_code: string;
status: "submitted" | "paid" | "activated" | "rejected" | "cancelled";
invoice_info: Record<string, string>;
ops_note: string | null;
invoice_no: string | null;
applicant_phone_hint: string | null;
created_at: string;
handled_at: string | null;
};
export type Feedback = {
id: string;
kind: string;
source: string;
note: string | null;
object_type: string;
tenant_name: string;
title: string | null;
severity: string | null;
rule_code: string | null;
detector: string | null;
finding_type: string | null;
rule_set_version: string | null;
prompt_versions: Record<string, string> | null;
pages: number[];
created_at: string;
};
export type FailedJob = {
kind: string;
id: string;
tenant: { id: string; name: string };
stage: string | null;
error_class: string | null;
error_code: string | null;
error_detail: string | null;
created_at: string;
duration_s: number | null;
ai_calls: number;
};
export type AuditEvent = {
id: string;
tenant: { id: string; name: string };
actor_kind: string;
actor_name: string | null;
action: string;
object_type: string;
object_id: string | null;
project_id: string | null;
scrubbed: boolean;
created_at: string;
};
export type Operator = {
id: string;
username: string;
display_name: string;
role: string;
role_label: string;
permissions: string[];
env: "dev" | "test" | "prod";
};
export type Summary = { open_requests: number; failed_jobs_7d: number; feedback_7d: number };
export const REQUEST_STATUS: Record<string, string> = {
submitted: "等待转账",
paid: "已到账",
activated: "已开通",
rejected: "已退回",
cancelled: "已撤回",
};
export const QUOTA_KIND: Record<string, string> = {
grant: "发放",
hold: "预扣",
settle: "结算",
release: "退回",
expire: "过期",
adjust: "调整",
waive: "免扣",
};
export const JOB_KIND: Record<string, string> = { parse_job: "解析", check_run: "检查", report_job: "报告" };

11
apps/admin/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from "vue";
import "@fontsource/barlow-semi-condensed/500.css";
import "@fontsource/barlow-semi-condensed/600.css";
import "@design/system/tokens.css";
import "@design/system/components.css";
import "@web-styles/app.css";
import "./admin.css";
import App from "./App.vue";
import { router } from "./router";
createApp(App).use(router).mount("#app");

49
apps/admin/src/nav.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* 运营后台的菜单:分组(像文件夹,可以展开收起)→ 菜单项。每一项写清要哪个权限点(ops/auth.py 的 OpsPermission),
* 没有这个权限的人看不到;一个分组下都看不到时整组不显示。以后加页面、加分组、加二级子菜单都只改这里。
*/
import { Building2, FileClock, MessageSquareWarning, ReceiptText, ScrollText, ServerCrash, type LucideIcon } from "@lucide/vue";
import type { Summary } from "./api";
export type NavItem = {
name: string; // 路由名
label: string;
icon: LucideIcon;
perm: string;
/** 菜单上的待办数(来自 /summary) */
badge?: keyof Summary;
/** 这些路由也算在这一项下面(详情页) */
also?: string[];
};
export type NavGroup = { key: string; label: string; icon: LucideIcon; items: NavItem[] };
export const NAV: NavGroup[] = [
{
key: "customers",
label: "客户",
icon: Building2,
items: [
{ name: "requests", label: "开通申请", icon: ReceiptText, perm: "activation.manage", badge: "open_requests" },
{ name: "tenants", label: "企业", icon: Building2, perm: "tenant.view", also: ["tenant"] },
],
},
{
key: "quality",
label: "质量",
icon: MessageSquareWarning,
items: [
{ name: "feedback", label: "误报 / 漏报", icon: MessageSquareWarning, perm: "feedback.view", badge: "feedback_7d" },
{ name: "failed-jobs", label: "失败任务", icon: ServerCrash, perm: "jobs.view", badge: "failed_jobs_7d" },
],
},
{
key: "security",
label: "安全",
icon: ScrollText,
items: [{ name: "audit", label: "审计", icon: FileClock, perm: "audit.view" }],
},
];
export function visibleNav(perms: string[]): NavGroup[] {
return NAV.map((g) => ({ ...g, items: g.items.filter((i) => perms.includes(i.perm)) })).filter((g) => g.items.length);
}

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
/** 审计(FR-M6-07):谁在什么时候对什么做了什么。修改前后的内容是业务内容,这里不显示;删除项目后只剩动作和时间。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, ops, OpsError, type AuditEvent } from "../api";
const action = ref("");
const items = ref<AuditEvent[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: AuditEvent[] }>("/audit-events", { query: { action: action.value.trim() || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const ACTOR: Record<string, string> = { user: "用户", ops: "运营", system: "系统" };
</script>
<template>
<div class="page-title">
<div>
<h1>审计</h1>
<p>最近 200 条。按动作前缀筛选,如 ops.(运营操作)、project.delete、tenant.close。</p>
</div>
</div>
<form class="toolbar" @submit.prevent="load">
<input v-model="action" class="input" placeholder="动作前缀,如 ops." aria-label="动作前缀" />
<button type="submit" class="btn">筛选</button>
</form>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>企业</th><th>操作人</th><th>动作</th><th>对象</th><th></th></tr></thead>
<tbody>
<tr v-for="e in items" :key="e.id">
<td class="num nowrap">{{ fmt(e.created_at) }}</td>
<td><RouterLink class="lnk" :to="{ name: 'tenant', params: { id: e.tenant.id } }">{{ e.tenant.name }}</RouterLink></td>
<td>{{ ACTOR[e.actor_kind] ?? e.actor_kind }}<template v-if="e.actor_name"> · {{ e.actor_name }}</template></td>
<td class="num">{{ e.action }}</td>
<td class="cap muted">{{ e.object_type }} {{ e.object_id?.slice(0, 8) ?? "" }}</td>
<td class="cap muted">{{ e.scrubbed ? "内容已抹掉" : "" }}</td>
</tr>
<tr v-if="!items.length"><td colspan="6" class="muted">没有记录</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
/** 失败任务:解析、检查、报告。错误类别、错误码、阶段、耗时、模型调用次数;不含文档内容。失败的都已自动退回额度(红线 9)。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, JOB_KIND, ops, OpsError, type FailedJob } from "../api";
const items = ref<FailedJob[]>([]);
const err = ref("");
onMounted(async () => {
try {
items.value = (await ops<{ items: FailedJob[] }>("/failed-jobs")).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
});
const CLASS: Record<string, string> = { input: "文件问题", infra: "系统异常", cancelled: "取消" };
</script>
<template>
<div class="page-title">
<div>
<h1>失败任务</h1>
<p>失败的任务都已自动退回额度。系统异常(infra)要排查,文件问题(input)一般是用户的文件读不了。</p>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>任务</th><th>企业</th><th>阶段</th><th>错误</th><th class="r">耗时</th><th class="r">模型调用</th><th>编号</th></tr></thead>
<tbody>
<tr v-for="j in items" :key="j.id">
<td class="num nowrap">{{ fmt(j.created_at) }}</td>
<td>{{ JOB_KIND[j.kind] ?? j.kind }}</td>
<td><RouterLink class="lnk" :to="{ name: 'tenant', params: { id: j.tenant.id } }">{{ j.tenant.name }}</RouterLink></td>
<td>{{ j.stage ?? "—" }}</td>
<td>
<span :class="{ 'err-text': j.error_class === 'infra' }">{{ CLASS[j.error_class ?? ""] ?? j.error_class ?? "" }}</span> {{ j.error_code ?? "" }}
<p v-if="j.error_detail" class="inv">{{ j.error_detail }}</p>
</td>
<td class="num r">{{ j.duration_s != null ? `${Math.round(j.duration_s)} 秒` : "—" }}</td>
<td class="num r">{{ j.ai_calls }}</td>
<td class="num cap muted">{{ j.id.slice(0, 8) }}</td>
</tr>
<tr v-if="!items.length"><td colspan="8" class="muted">没有失败的任务</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
/**
* 误报 / 漏报反馈(J5):逐条复盘,再迭代规则。不显示招标 / 投标原文(决策 M6-7),只给标题、理由、规则编号、
* 提示词版本、页码;要看原文请用户导出报告发过来。致命问题「确认无误」写的说明也会自动进来(U4)。
*/
import { onMounted, ref, watch } from "vue";
import { fmt, ops, OpsError, type Feedback } from "../api";
const kind = ref<"" | "false_positive" | "false_negative">("");
const items = ref<Feedback[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: Feedback[] }>("/feedback", { query: { kind: kind.value || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
watch(kind, load);
const SEV: Record<string, string> = { fatal: "致命", important: "重要", hint: "提示" };
</script>
<template>
<div class="page-title">
<div>
<h1>误报 / 漏报</h1>
<p>只看标题、理由和规则,不看原文。这里的数字只给内部复盘用,界面上不对用户展示统计。</p>
</div>
</div>
<div class="toolbar">
<div class="seg" role="group" aria-label="筛选">
<button type="button" :aria-pressed="kind === ''" @click="kind = ''">全部</button>
<button type="button" :aria-pressed="kind === 'false_positive'" @click="kind = 'false_positive'">误报</button>
<button type="button" :aria-pressed="kind === 'false_negative'" @click="kind = 'false_negative'">漏报</button>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>类型</th><th>企业</th><th>条目</th><th>用户写的</th><th>规则 / 版本</th><th>页码</th></tr></thead>
<tbody>
<tr v-for="f in items" :key="f.id">
<td class="num nowrap">{{ fmt(f.created_at) }}</td>
<td>{{ f.kind === "false_positive" ? "误报" : "漏报" }}<p v-if="f.source !== 'user'" class="inv">{{ f.source }}</p></td>
<td>{{ f.tenant_name }}</td>
<td>
<span v-if="f.severity" class="tag">{{ SEV[f.severity] ?? f.severity }}</span> {{ f.title ?? "(条目已删除)" }}
<p class="inv">{{ f.object_type === "finding" ? "查标问题" : "读标清单项" }}<template v-if="f.finding_type"> · {{ f.finding_type }}</template><template v-if="f.detector"> · {{ f.detector === "ai" ? "模型" : "规则" }}</template></p>
</td>
<td>{{ f.note ?? "—" }}</td>
<td>
<p class="inv">{{ f.rule_code ?? "—" }}</p>
<p class="inv">{{ f.rule_set_version ?? "" }}<template v-if="f.prompt_versions"> · {{ Object.entries(f.prompt_versions).map(([k, v]) => `${k} ${v}`).join("、") }}</template></p>
</td>
<td class="num">{{ f.pages.join("、") || "—" }}</td>
</tr>
<tr v-if="!items.length"><td colspan="7" class="muted">还没有反馈</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
/** 运营登录(决策 M6-4):用户名 + 密码 + 身份验证器 App 上的 6 位动态码。账号用命令行建(bid-doctor ops-admin create)。 */
import logoUrl from "@design/logo/svg/logo-horizontal.svg?url";
import { ref } from "vue";
import { useRouter } from "vue-router";
import { ops, OpsError } from "../api";
const router = useRouter();
const username = ref("");
const password = ref("");
const totp = ref("");
const err = ref("");
const busy = ref(false);
async function submit() {
if (busy.value) return;
busy.value = true;
err.value = "";
try {
await ops("/auth/login", { method: "POST", body: { username: username.value.trim(), password: password.value, totp: totp.value.trim() } });
await router.replace({ name: "requests" });
} catch (e) {
err.value = e instanceof OpsError ? e.message : "登录失败";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="login">
<form class="card" novalidate @submit.prevent="submit">
<img class="logo" :src="logoUrl" alt="标博士" />
<h1>运营后台</h1>
<p class="cap muted">只在内网和白名单地址可用,所有操作都会留痕。</p>
<div class="field fill">
<label for="u">用户名</label>
<input id="u" v-model="username" class="input" autocomplete="username" />
</div>
<div class="field fill">
<label for="p">密码</label>
<input id="p" v-model="password" class="input" type="password" autocomplete="current-password" />
</div>
<div class="field fill">
<label for="t">动态验证码</label>
<input id="t" v-model="totp" class="input" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="身份验证器 App 上的 6 位数字" />
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<button type="submit" class="btn primary" :disabled="busy">登录</button>
</form>
</div>
</template>
<style scoped>
.login {
display: grid;
place-items: center;
min-height: 100vh;
}
.login form {
width: 380px;
padding: var(--sp-8);
}
.logo {
display: block;
width: 168px;
margin-bottom: var(--sp-6);
}
h1 {
margin: 0;
font: var(--fw-bold) var(--fs-h1) / var(--lh-h1) var(--font-serif);
}
.field {
margin-top: var(--sp-4);
}
.btn {
width: 100%;
margin-top: var(--sp-6);
}
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
/**
* 开通申请(G8):用户在线提交 → 对公转账 → 这里「标记到账」→「开通」(默认专业版 1 年;当前专业版没到期时是续费,新一期排队)
* → 开发票后登记发票号。不通过就「退回」并写原因,用户在额度页看得到。每一步都写审计。
*/
import { computed, onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, notifyChanged, ops, OpsError, REQUEST_STATUS, type ActivationRequest } from "../api";
const filter = ref<"open" | "all">("open");
const items = ref<ActivationRequest[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: ActivationRequest[] }>("/activation-requests")).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const rows = computed(() => (filter.value === "open" ? items.value.filter((r) => r.status === "submitted" || r.status === "paid") : items.value));
const acting = ref<{ id: string; kind: "reject" | "invoice" | "activate" } | null>(null);
const input = ref("");
const months = ref(12);
const busy = ref(false);
function start(r: ActivationRequest, kind: "reject" | "invoice" | "activate") {
acting.value = { id: r.id, kind };
input.value = "";
months.value = 12;
}
async function act(r: ActivationRequest, kind: "paid" | "activate" | "reject" | "invoice") {
busy.value = true;
err.value = "";
try {
const body = kind === "reject" ? { note: input.value.trim() } : kind === "invoice" ? { invoice_no: input.value.trim() } : kind === "activate" ? { months: months.value } : undefined;
await ops(`/activation-requests/${r.id}/${kind}`, { method: "POST", body });
acting.value = null;
notifyChanged();
await load();
} catch (e) {
err.value = e instanceof OpsError ? `${r.request_no}:${e.message}` : "操作失败";
} finally {
busy.value = false;
}
}
const INVOICE_TYPE: Record<string, string> = { general: "普票", special: "专票" };
</script>
<template>
<div class="page-title">
<div>
<h1>开通申请</h1>
<p>核对对公转账到账后再开通。转账备注应当是申请单号。</p>
</div>
</div>
<div class="toolbar">
<div class="seg" role="group" aria-label="筛选">
<button type="button" :aria-pressed="filter === 'open'" @click="filter = 'open'">待处理</button>
<button type="button" :aria-pressed="filter === 'all'" @click="filter = 'all'">全部</button>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead>
<tr><th>申请单号</th><th>企业</th><th>发票信息</th><th>状态</th><th>提交时间</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.id">
<td class="num">{{ r.request_no }}</td>
<td>
<RouterLink class="lnk" :to="{ name: 'tenant', params: { id: r.tenant.id } }">{{ r.tenant.name }}</RouterLink>
<p class="inv">申请人 {{ r.applicant_phone_hint ?? "—" }}</p>
</td>
<td>
<p class="inv">
{{ r.invoice_info.title }} · {{ INVOICE_TYPE[r.invoice_info.invoice_type ?? "general"] }}<br />
税号 {{ r.invoice_info.tax_no }}<br />
<template v-if="r.invoice_info.address_phone">{{ r.invoice_info.address_phone }}<br /></template>
<template v-if="r.invoice_info.bank_account">{{ r.invoice_info.bank_account }}<br /></template>
联系人 {{ r.invoice_info.contact_name }} {{ r.invoice_info.contact_phone }}
</p>
</td>
<td>
<span class="tag">{{ REQUEST_STATUS[r.status] }}</span>
<p v-if="r.ops_note" class="inv">原因:{{ r.ops_note }}</p>
<p v-if="r.invoice_no" class="inv">发票 {{ r.invoice_no }}</p>
</td>
<td class="num nowrap">{{ fmt(r.created_at) }}</td>
<td>
<div v-if="acting?.id === r.id" class="acts">
<template v-if="acting.kind === 'activate'">
<label class="cap">周期(月)<input v-model.number="months" class="input sm m" type="number" min="1" max="36" /></label>
<button type="button" class="btn sm primary" :disabled="busy" @click="act(r, 'activate')">确认开通</button>
</template>
<template v-else>
<input v-model="input" class="input sm" :placeholder="acting.kind === 'reject' ? '退回原因(用户看得到)' : '发票号'" />
<button type="button" class="btn sm primary" :disabled="busy || input.trim().length < 2" @click="act(r, acting.kind)">确认</button>
</template>
<button type="button" class="btn sm text" @click="acting = null">取消</button>
</div>
<div v-else class="acts">
<button v-if="r.status === 'submitted'" type="button" class="btn sm" :disabled="busy" @click="act(r, 'paid')">标记到账</button>
<button v-if="r.status === 'submitted' || r.status === 'paid'" type="button" class="btn sm primary" @click="start(r, 'activate')">开通</button>
<button v-if="r.status === 'submitted' || r.status === 'paid'" type="button" class="btn sm text" @click="start(r, 'reject')">退回</button>
<button v-if="r.status === 'activated' && !r.invoice_no" type="button" class="btn sm" @click="start(r, 'invoice')">登记发票</button>
</div>
</td>
</tr>
<tr v-if="!rows.length"><td colspan="6" class="muted">{{ filter === "open" ? "没有待处理的申请" : "还没有申请" }}</td></tr>
</tbody>
</table>
</section>
</template>
<style scoped>
.input.m {
width: 64px;
margin-left: var(--sp-1);
}
.hint-warn {
margin: 0 0 var(--sp-4);
}
</style>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
/**
* 企业详情:成员(手机号脱敏)、套餐历史、额度流水(不带项目名称)、任务统计;调额度(G6:原因必填,不能扣成负数)。
* 不显示项目名称和任何业务内容(m6-plan §2.2)。
*/
import { onMounted, ref } from "vue";
import { day, fmt, JOB_KIND, ops, OpsError, QUOTA_KIND, type TenantDetail } from "../api";
const props = defineProps<{ id: string }>();
const t = ref<TenantDetail | null>(null);
const err = ref("");
async function load() {
try {
t.value = await ops<TenantDetail>(`/tenants/${props.id}`);
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const amount = ref<number | null>(null);
const reason = ref("");
const msg = ref("");
const busy = ref(false);
async function adjust() {
if (!amount.value || reason.value.trim().length < 2) {
msg.value = "填写数量(可以是负数)和原因";
return;
}
busy.value = true;
msg.value = "";
try {
await ops(`/tenants/${props.id}/quota-adjustments`, { method: "POST", body: { unit: "parse", amount: amount.value, reason: reason.value.trim() } });
amount.value = null;
reason.value = "";
msg.value = "已调整";
await load();
} catch (e) {
msg.value = e instanceof OpsError ? e.message : "调整失败";
} finally {
busy.value = false;
}
}
const PLAN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
const SUB: Record<string, string> = { scheduled: "排队中", active: "生效中", expired: "已过期", cancelled: "已结束" };
const JOB_STATUS: Record<string, string> = { queued: "排队", running: "进行中", succeeded: "成功", failed: "失败", cancelled: "取消" };
</script>
<template>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<template v-if="t">
<div class="page-title">
<div>
<h1>{{ t.name }}</h1>
<p>
{{ PLAN[t.plan.code ?? ""] ?? "—" }}<template v-if="t.plan.ends_at"> · 至 {{ day(t.plan.ends_at) }}</template><template v-if="t.plan.expired">(已到期)</template>
· 注册 {{ day(t.created_at) }} · 项目 {{ t.projects }} 个<template v-if="t.status === 'closed'"> · 已注销</template>
</p>
</div>
</div>
<div class="grid">
<section class="card">
<div class="card-h"><h2>成员</h2></div>
<table class="data">
<thead><tr><th>姓名</th><th>手机号</th><th>角色</th><th>最近登录</th></tr></thead>
<tbody>
<tr v-for="(m, i) in t.members" :key="i">
<td>{{ m.display_name }}</td><td class="num">{{ m.phone_hint ?? "—" }}</td><td>{{ m.role === "owner" ? "所有者" : m.role }}</td>
<td class="num">{{ fmt(m.last_login_at) }}</td>
</tr>
</tbody>
</table>
</section>
<section class="card">
<div class="card-h"><h2>调额度</h2><span class="cap muted r">解析余额 {{ t.balance ?? 0 }}</span></div>
<form class="adj" @submit.prevent="adjust">
<input v-model.number="amount" class="input" type="number" placeholder="数量,如 20 或 -5" aria-label="数量" />
<input v-model="reason" class="input" maxlength="200" placeholder="原因(必填,写进流水,用户看得到)" aria-label="原因" />
<button type="submit" class="btn primary" :disabled="busy">调整</button>
</form>
<p v-if="msg" class="cap muted">{{ msg }}</p>
<div class="card-h sub"><h2>任务</h2></div>
<p v-for="(st, kind) in t.jobs" :key="kind" class="cap">
{{ JOB_KIND[kind] ?? kind }}:<template v-for="(n, s, i) in st" :key="s">{{ i ? " · " : "" }}{{ JOB_STATUS[s] ?? s }} {{ n }}</template>
</p>
<p v-if="!Object.keys(t.jobs).length" class="cap muted">还没有任务</p>
</section>
</div>
<section class="card flush">
<div class="card-h"><h2>套餐</h2></div>
<table class="data">
<thead><tr><th>套餐</th><th>状态</th><th>来源</th><th>开始</th><th>结束</th><th>申请单</th></tr></thead>
<tbody>
<tr v-for="(s, i) in t.subscriptions" :key="i">
<td>{{ PLAN[s.plan_code] ?? s.plan_code }}</td><td>{{ SUB[s.status] ?? s.status }}</td>
<td>{{ s.source === "signup" ? "注册" : s.source === "manual" ? "运营开通" : s.source }}</td>
<td class="num">{{ day(s.starts_at) }}</td><td class="num">{{ day(s.ends_at) }}</td><td class="num">{{ s.request_no ?? "—" }}</td>
</tr>
</tbody>
</table>
</section>
<section class="card flush">
<div class="card-h"><h2>最近的额度流水</h2></div>
<table class="data">
<thead><tr><th>时间</th><th>类型</th><th class="r">数量</th><th>任务</th><th>说明</th></tr></thead>
<tbody>
<tr v-for="(e, i) in t.quota_entries" :key="i">
<td class="num nowrap">{{ fmt(e.created_at) }}</td>
<td>{{ QUOTA_KIND[e.kind] ?? e.kind }}<span v-if="e.by_ops" class="cap muted">(运营)</span></td>
<td class="num r">{{ e.amount > 0 ? `+${e.amount}` : e.amount }}</td>
<td>{{ e.ref_type ? JOB_KIND[e.ref_type] : "—" }}</td>
<td class="muted">{{ e.reason === "recheck_24h" ? "24 小时内复查" : e.reason ?? "" }}</td>
</tr>
</tbody>
</table>
</section>
</template>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--gap-card);
}
.card-h .r {
margin-left: auto;
}
.card-h.sub {
margin-top: var(--sp-6);
}
.adj {
display: grid;
grid-template-columns: 140px minmax(0, 1fr) auto;
gap: var(--sp-2);
}
p.cap {
margin: var(--sp-2) 0 0;
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
/** 企业查询:企业名称(模糊)、手机号(精确,按 HMAC 查,库里没有明文)、申请单号。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { day, ops, OpsError, type Tenant } from "../api";
const q = ref("");
const items = ref<Tenant[]>([]);
const err = ref("");
async function search() {
err.value = "";
try {
items.value = (await ops<{ items: Tenant[] }>("/tenants", { query: { q: q.value.trim() || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(search);
const PLAN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
</script>
<template>
<div class="page-title">
<div>
<h1>企业</h1>
<p>只看账号、套餐和用量,不看项目和文件内容。查看详情会留痕。</p>
</div>
</div>
<form class="toolbar" @submit.prevent="search">
<input v-model="q" class="input" placeholder="企业名称、手机号或申请单号(KT-…)" aria-label="搜索企业" />
<button type="submit" class="btn">搜索</button>
</form>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead>
<tr><th>企业</th><th>手机号</th><th>套餐</th><th class="r">解析余额</th><th class="r">项目</th><th>注册</th><th>状态</th></tr>
</thead>
<tbody>
<tr v-for="t in items" :key="t.id">
<td>
<RouterLink class="lnk" :to="{ name: 'tenant', params: { id: t.id } }">{{ t.name }}</RouterLink>
<span v-if="t.open_requests" class="tag">待处理申请 {{ t.open_requests }}</span>
</td>
<td class="num">{{ t.phone_hint ?? "—" }}</td>
<td>
{{ PLAN[t.plan.code ?? ""] ?? "—" }}
<span v-if="t.plan.expired" class="err-text">已到期</span>
<span v-else-if="t.plan.ends_at" class="cap muted">至 {{ day(t.plan.ends_at) }}</span>
</td>
<td class="num r">{{ t.balance ?? "—" }}</td>
<td class="num r">{{ t.projects }}</td>
<td class="num">{{ day(t.created_at) }}</td>
<td>{{ t.status === "closed" ? "已注销" : t.status === "active" ? "正常" : t.status }}</td>
</tr>
<tr v-if="!items.length"><td colspan="7" class="muted">没有找到</td></tr>
</tbody>
</table>
</section>
</template>

20
apps/admin/src/router.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createRouter, createWebHistory } from "vue-router";
import { setUnauthorized } from "./api";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/login", name: "login", component: () => import("./pages/LoginPage.vue"), meta: { bare: true } },
{ path: "/", redirect: "/requests" },
{ path: "/requests", name: "requests", component: () => import("./pages/RequestsPage.vue") },
{ path: "/tenants", name: "tenants", component: () => import("./pages/TenantsPage.vue") },
{ path: "/tenants/:id", name: "tenant", component: () => import("./pages/TenantPage.vue"), props: true },
{ path: "/feedback", name: "feedback", component: () => import("./pages/FeedbackPage.vue") },
{ path: "/failed-jobs", name: "failed-jobs", component: () => import("./pages/FailedJobsPage.vue") },
{ path: "/audit", name: "audit", component: () => import("./pages/AuditPage.vue") },
],
});
setUnauthorized(() => {
if (router.currentRoute.value.name !== "login") void router.replace({ name: "login" });
});

20
apps/admin/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"jsx": "preserve",
"noEmit": true,
"skipLibCheck": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}

25
apps/admin/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { fileURLToPath, URL } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
/**
* 运营后台(决策 M6-3):单独构建、单独部署,私有化不交付。开发时跑在 127.0.0.1:5174,
* 把 /ops 代理到 ops 进程 127.0.0.1:8019(bid-doctor ops)。样式只来自 docs/design/system。
*/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
"@design": fileURLToPath(new URL("../../docs/design", import.meta.url)),
// 和用户端共用组件样式(按钮、输入框、表格、卡片……),不复制(决策 M6-3)
"@web-styles": fileURLToPath(new URL("../web/src/styles", import.meta.url)),
},
},
server: {
host: "127.0.0.1",
port: 5174,
strictPort: true,
proxy: { "/ops": { target: "http://127.0.0.1:8019", changeOrigin: false } },
},
});

View File

@@ -13,6 +13,7 @@
"@bid-doctor/api-client": "workspace:*",
"@fontsource/barlow-semi-condensed": "5.3.0",
"@fontsource/noto-serif-sc": "5.3.0",
"@internationalized/date": "3.12.4",
"@lucide/vue": "1.50.0",
"@tanstack/vue-query": "5.104.1",
"pdfjs-dist": "6.4.299",

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* 专业版开通 / 续费申请(原型 quota.html 的续费对话框,G8,决策 M6-6):
* 提交申请不扣额度、不用先付款;之后对公转账,运营确认到账后开通并开发票。页面不写价格(D2 未定),运营联系确认金额。
* 专票要地址电话和开户行账号。
*/
import { unwrap } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { ref, watch } from "vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { qk } from "@/lib/keys";
import { useMe, type ActivationRequest } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ renew?: boolean }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ submitted: [r: ActivationRequest] }>();
const qc = useQueryClient();
const me = useMe();
const title = ref("");
const type = ref<"general" | "special">("general");
const taxNo = ref("");
const addressPhone = ref("");
const bankAccount = ref("");
const contact = ref("");
const phone = ref("");
const errs = ref<Record<string, string>>({});
const err = ref("");
const busy = ref(false);
watch(open, (v) => {
if (!v) return;
title.value = me.data.value?.tenant.name ?? "";
contact.value = me.data.value?.user.display_name ?? "";
errs.value = {};
err.value = "";
});
function validate(): boolean {
const e: Record<string, string> = {};
if (title.value.trim().length < 2) e.title = "请填写发票抬头,一般就是企业名称";
const t = taxNo.value.trim().toUpperCase();
if (!t) e.taxNo = "请填写纳税人识别号,开发票要用";
else if (!/^[0-9A-Z]{15,20}$/.test(t)) e.taxNo = "纳税人识别号是 15 到 20 位数字或大写字母,请核对";
if (type.value === "special" && !addressPhone.value.trim()) e.addressPhone = "增值税专用发票要填地址电话";
if (type.value === "special" && !bankAccount.value.trim()) e.bankAccount = "增值税专用发票要填开户行和账号";
if (!contact.value.trim()) e.contact = "请填写联系人";
if (!/^1[3-9]\d{9}$/.test(phone.value.trim())) e.phone = "手机号应为 11 位数字";
errs.value = e;
return !Object.keys(e).length;
}
async function submit() {
if (busy.value || !validate()) return;
busy.value = true;
err.value = "";
try {
const r = await unwrap(
api.POST("/activation-requests", {
body: {
plan_code: "pro",
invoice_info: {
title: title.value.trim(),
tax_no: taxNo.value.trim().toUpperCase(),
invoice_type: type.value,
address_phone: addressPhone.value.trim() || undefined,
bank_account: bankAccount.value.trim() || undefined,
contact_name: contact.value.trim(),
contact_phone: phone.value.trim(),
},
},
}),
);
void qc.invalidateQueries({ queryKey: qk.quota });
open.value = false;
toast(`已提交${props.renew ? "续费" : "开通"}申请 ${r.request_no}`);
emit("submitted", r);
} catch (e) {
err.value = isApiError(e, "activation_pending") ? "已经有一份待处理的申请,等它处理完再提交" : errorTitle(e);
} finally {
busy.value = false;
}
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog" :aria-describedby="undefined">
<DialogTitle as="h2">{{ renew ? "续费专业版" : "开通专业版" }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="submit">
<p class="lead">
专业版按年{{ renew ? "续费" : "开通" }}:提交申请 → 对公转账 → 运营确认到账后开通,并开发票。<b>提交申请不扣额度,也不用先付款</b>;金额由运营联系你确认。
</p>
<div class="field fill" :class="{ err: errs.title }">
<label for="ad-title">发票抬头 <small>(必填)</small></label>
<input id="ad-title" v-model="title" class="input" maxlength="100" autocomplete="off" />
<span v-if="errs.title" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.title }}</span>
<span v-else class="msg">额度绑定注册的企业主体,不可转让</span>
</div>
<div class="grid2">
<div class="field">
<label for="ad-type">发票类型 <small>(必填)</small></label>
<SelectBox
id="ad-type"
:model-value="type"
:options="[
{ value: 'general', label: '增值税普通发票' },
{ value: 'special', label: '增值税专用发票' },
]"
@update:model-value="(v) => (type = v === 'special' ? 'special' : 'general')"
/>
</div>
<div class="field" :class="{ err: errs.taxNo }">
<label for="ad-tax">纳税人识别号 <small>(必填)</small></label>
<input id="ad-tax" v-model="taxNo" class="input num" maxlength="20" placeholder="18 位统一社会信用代码" autocomplete="off" />
<span v-if="errs.taxNo" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.taxNo }}</span>
</div>
</div>
<template v-if="type === 'special'">
<div class="field fill" :class="{ err: errs.addressPhone }">
<label for="ad-addr">地址、电话 <small>(必填)</small></label>
<input id="ad-addr" v-model="addressPhone" class="input" maxlength="200" autocomplete="off" />
<span v-if="errs.addressPhone" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.addressPhone }}</span>
</div>
<div class="field fill" :class="{ err: errs.bankAccount }">
<label for="ad-bank">开户行及账号 <small>(必填)</small></label>
<input id="ad-bank" v-model="bankAccount" class="input" maxlength="200" autocomplete="off" />
<span v-if="errs.bankAccount" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.bankAccount }}</span>
</div>
</template>
<div class="grid2">
<div class="field" :class="{ err: errs.contact }">
<label for="ad-contact">联系人 <small>(必填)</small></label>
<input id="ad-contact" v-model="contact" class="input" maxlength="40" autocomplete="off" />
<span v-if="errs.contact" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.contact }}</span>
</div>
<div class="field" :class="{ err: errs.phone }">
<label for="ad-phone">手机号 <small>(必填)</small></label>
<input id="ad-phone" v-model="phone" class="input num" maxlength="11" inputmode="numeric" autocomplete="off" />
<span v-if="errs.phone" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.phone }}</span>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<span class="cap muted left">不扣额度</span>
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
<span class="idle">提交申请</span><span class="busy">正在提交…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.lead {
margin: 0 0 var(--sp-4);
color: var(--text-secondary);
}
.lead b {
color: var(--text-primary);
font-weight: var(--fw-medium);
}
.grid2 {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.body > .field + .field,
.body > .field + .grid2,
.body > .grid2 + .field,
.body > .grid2 + .grid2 {
margin-top: var(--sp-4);
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -3,11 +3,12 @@
* 登录后的页面框架(设计规范 v0.2 §5.3):96px 墨色渐变侧栏 + 透明顶栏(滚动后半透明白)+ 内容区。
* 挂载期间保持一条事件流连接,把服务端的变化推进查询缓存。
*/
import { House, LogOut } from "@lucide/vue";
import { Award, House, LogOut, Wallet } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
import logoUrl from "@design/logo/svg/logo-vertical-reverse.svg?url";
import NotificationBell from "./NotificationBell.vue";
import Toasts from "./Toasts.vue";
import { api } from "@/lib/api";
import { provideCrumbs } from "@/lib/crumbs";
@@ -71,12 +72,18 @@ async function logout() {
<RouterLink :to="{ name: 'workbench' }" :aria-current="onWorkbench ? 'page' : undefined">
<House class="ic" :stroke-width="1.5" /><span>工作台</span>
</RouterLink>
<RouterLink :to="{ name: 'qualifications' }" :aria-current="route.name === 'qualifications' ? 'page' : undefined">
<Award class="ic" :stroke-width="1.5" /><span>资质库</span>
</RouterLink>
<RouterLink :to="{ name: 'quota' }" :aria-current="route.name === 'quota' ? 'page' : undefined">
<Wallet class="ic" :stroke-width="1.5" /><span>额度</span>
</RouterLink>
</nav>
<div class="me">
<RouterLink class="me" :to="{ name: 'account' }" :aria-current="route.name === 'account' ? 'page' : undefined" aria-label="账号">
<span class="avatar" aria-hidden="true">{{ name.slice(0, 1) }}</span>
<b class="uname" :title="name">{{ name }}</b>
<span v-if="plan" class="plan" :data-plan="planKey">{{ plan.name }}</span>
</div>
</RouterLink>
</aside>
<main class="main">
@@ -90,18 +97,25 @@ async function logout() {
</template>
</div>
<div class="top-right">
<span v-if="accounts.length" class="quota-chip tip-below tip-left" tabindex="0" :data-tip="quotaTip" :aria-label="quotaTip">
<RouterLink v-if="accounts.length" class="quota-chip tip-below tip-left" :to="{ name: 'quota' }" :data-tip="quotaTip" :aria-label="quotaTip">
<template v-for="(a, i) in accounts" :key="a.unit">
<span v-if="i > 0" class="sep"></span>
<span class="q" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }">{{ quotaUnit(a.unit) }}<span class="fig">{{ a.balance }}</span></span>
</template>
</span>
</RouterLink>
<NotificationBell />
<span class="vsep" aria-hidden="true"></span>
<button class="icon-btn tip-below tip-left" type="button" aria-label="退出登录" data-tip="退出登录" :disabled="loggingOut" @click="logout">
<LogOut class="ic lg" :stroke-width="1.5" />
</button>
</div>
</header>
<!-- 套餐到期后只读(决策 M6-5):每一页顶上都提醒,额度页里有续费入口 -->
<div v-if="plan?.read_only && route.name !== 'quota'" class="expired-bar" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版已到期,数据只读:可以查看、下载已生成的报告,不能新建项目、解析和检查。</p>
<RouterLink class="btn sm" :to="{ name: 'quota' }">续费</RouterLink>
</div>
<RouterView />
</main>
<Toasts />

View File

@@ -7,11 +7,23 @@
import { computed } from "vue";
import CitationBlock from "./CitationBlock.vue";
import { groupCitations, type CitationGroup } from "@/lib/citations";
import { category, failureText, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason, specFacts } from "@/lib/labels";
import type { ChecklistItem } from "@/lib/queries";
import { RouterLink } from "vue-router";
import { category, CHECK_VERDICT, failureText, fmtDay, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason, specFacts } from "@/lib/labels";
import type { CheckItemResult, ChecklistItem, Citation, QualificationMatch } from "@/lib/queries";
const props = defineProps<{ item: ChecklistItem; pending?: boolean; selected?: boolean; activeCitation?: string | null }>();
const emit = defineEmits<{ select: []; openCitation: [g: CitationGroup] }>();
const props = defineProps<{
item: ChecklistItem;
pending?: boolean;
selected?: boolean;
activeCitation?: string | null;
/** 最近一次检查对这一条的结论(C9);没参与检查的不传 */
check?: CheckItemResult | null;
/** 资质库里的情况(资格条件、评分项里认出证书类别的条目);资质库是用户自己录的,墨色,不用蓝铅笔色 */
library?: QualificationMatch[] | null;
/** library 按哪天判断有效期:「投标截止日」或「今天」 */
libraryAsOf?: string;
}>();
const emit = defineEmits<{ select: []; openCitation: [g: CitationGroup]; openBid: [c: Citation]; feedback: []; findings: [] }>();
/** 一条结论最多默认展开 2 处出处,其余写「另有 N 处」(§7.3);跨页的一句话算一处 */
const groups = computed(() => groupCitations(props.item.citations));
@@ -22,6 +34,9 @@ const mark = computed(() => IMPORTANCE_MARK[props.item.importance] ?? "");
const summary = computed(() => (props.item.summary && props.item.summary !== props.item.title ? props.item.summary : ""));
/** 结构化取值:分值、参数要求、日期等,都来自原文(取值校验通过的才进正式清单) */
const facts = computed(() => specFacts(props.item.spec));
/** 检查结论:已响应、默认接受是好的;其余在查标页有问题可处理 */
const checkOk = computed(() => props.check?.verdict === "responded" || props.check?.verdict === "implicit_accepted");
const bidCites = computed(() => groupCitations(props.check?.bid_citations ?? []).slice(0, 2));
</script>
<template>
@@ -33,12 +48,14 @@ const facts = computed(() => specFacts(props.item.spec));
<div class="t">{{ item.title }}</div>
<div class="meta">
<span v-if="item.origin === 'ai'" class="tag ai">AI</span>
<span v-else class="tag">{{ item.origin_label || (item.origin === "human" ? "人工添加" : "规则") }}</span>
<span v-else-if="item.origin === 'human' || !item.origin_document || item.origin_document.label === '招标文件'" class="tag">{{ item.origin === "human" ? "人工添加" : "规则" }}</span>
<span v-if="item.origin !== 'human' && item.origin_document && item.origin_document.label !== '招标文件'" class="tag">{{ item.origin_document.label }}</span>
<span v-for="c in item.categories" :key="c" class="tag">{{ category(c) }}</span>
<span class="tag">{{ item.lot_no ? `采购包 ${item.lot_no}` : "通用" }}</span>
<span v-if="pending && item.pending_reason" class="flag">{{ [pendingReason(item.pending_reason), failureText(item.ai_values)].filter(Boolean).join(" · ") }}</span>
<span v-if="item.no_citation_ack" class="flag">人工确认、无出处</span>
<span v-if="item.edited_fields.length" class="flag">已修改</span>
<span v-if="item.superseded_by && !item.superseded_by.confirmed" class="flag">已被{{ item.superseded_by.document_label }} 更新,待确认</span>
</div>
<p v-if="facts.length" class="facts">
<span v-for="f in facts" :key="f">{{ f }}</span>
@@ -52,6 +69,34 @@ const facts = computed(() => specFacts(props.item.spec));
<div v-else class="cites">
<div class="cite missing"><p>{{ item.no_citation_ack || item.origin === "human" ? "没有出处" : "没有出处,确认前不参与检查" }}</p></div>
</div>
<p v-for="m in library ?? []" :key="m.family" class="lib cap" @click.stop>
<span class="muted">资质库</span>
<template v-if="m.record">
<span>《{{ m.record.name }}》</span>
<span v-if="m.state === 'expired'" class="warn-text">有效期至 <span class="fig">{{ fmtDay(m.record.valid_to!) }}</span>,早于{{ libraryAsOf }}</span>
<span v-else>{{ m.record.valid_to ? `有效期至 ${fmtDay(m.record.valid_to)}` : "长期有效" }}</span>
<span v-if="m.count > 1" class="muted">(这一类共 <span class="fig">{{ m.count }}</span> 条,这里是有效期最晚的)</span>
</template>
<span v-else class="muted">没有录入「{{ m.family }}」</span>
<RouterLink v-if="m.state !== 'valid'" class="lnk" :to="{ name: 'qualifications' }">去资质库</RouterLink>
</p>
<p v-if="check" class="chk cap" :class="{ bad: !checkOk }" @click.stop>
<span class="muted">查标</span>
<b>{{ CHECK_VERDICT[check.verdict] }}</b>
<button
v-for="g in bidCites"
:key="g.head.id"
type="button"
class="cite-src inline"
:class="{ on: activeCitation === g.head.id }"
@click="emit('select'); emit('openBid', g.head)"
>
{{ g.head.document_label }} 第 <span class="fig">{{ g.head.page_no }}</span> 页
</button>
<span v-if="check.reused" class="muted">沿用上次结论</span>
<button v-if="!checkOk" type="button" class="lnk" @click="emit('findings')">去查标页处理</button>
<button v-else-if="check.verdict === 'responded' && selected" type="button" class="lnk feedback" @click="emit('feedback')">判断有误?</button>
</p>
<div v-if="$slots.actions && (pending || selected)" class="f-actions" @click.stop>
<slot name="actions" />
</div>
@@ -104,7 +149,44 @@ const facts = computed(() => specFacts(props.item.spec));
margin: var(--sp-1) 0 0;
font-size: var(--fs-caption);
}
.evid .muted {
flex: none; /* 标签不跟着长文本一起被挤成竖排 */
}
.t {
overflow-wrap: anywhere; /* 型号、参数这类没有空格的长串 */
}
.cites .cap {
margin: var(--sp-1) 0 0;
}
/* 检查结论:有问题的加深,不用红(级别和处理都在查标页);投标侧出处同样是蓝铅笔色 */
.chk {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
align-items: baseline;
margin: var(--sp-2) 0 0;
}
.chk b {
font-weight: var(--fw-medium);
}
.chk:not(.bad) b {
color: var(--text-secondary);
}
.chk .feedback {
margin-left: auto;
}
.lib {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
align-items: baseline;
margin: var(--sp-2) 0 0;
}
.lib + .lib,
.lib + .chk {
margin-top: var(--sp-1);
}
.warn-text {
color: var(--warn-text);
}
</style>

View File

@@ -3,7 +3,7 @@
* 出处(设计规范 §7.3):左侧 2px 蓝铅笔竖线,上面是「文件 · 第 N 页」,下面照录原句。
* 点出处在原文查看器里翻到那一页、高亮原句;当前打开的出处竖线加粗(.on)。
* 一句话跨页时(tail 是下一页的接续部分)合成一处显示:「第 47–48 页」,原句接起来。
* TODO:OCR 识别出的原句前加「OCR」小字(等接口给出页的 text_source)。
* 原句是扫描页或插图上 OCR 识别出来的,前面加「OCR」小字,提醒核对原图(§7.3)。
*/
import { computed } from "vue";
import type { Citation } from "@/lib/queries";
@@ -11,6 +11,7 @@ import type { Citation } from "@/lib/queries";
const props = defineProps<{ citation: Citation; tail?: Citation[]; active?: boolean }>();
const lastPage = computed(() => props.tail?.at(-1)?.page_no);
const quote = computed(() => [props.citation, ...(props.tail ?? [])].map((c) => c.quote).join(""));
const ocr = computed(() => [props.citation, ...(props.tail ?? [])].some((c) => c.ocr));
const emit = defineEmits<{ open: [] }>();
</script>
@@ -19,7 +20,7 @@ const emit = defineEmits<{ open: [] }>();
<span class="cite-src">
<template v-if="citation.document_label">{{ citation.document_label }} · </template>第 <span class="fig">{{ citation.page_no }}</span><template v-if="lastPage">–<span class="fig">{{ lastPage }}</span></template> 页
</span>
<q>{{ quote }}</q>
<q><abbr v-if="ocr" class="cite-ocr" title="这句是从扫描件或图片上识别出来的,可能有错字,请对照原文核对">OCR</abbr>{{ quote }}</q>
</div>
</template>
@@ -32,6 +33,12 @@ const emit = defineEmits<{ open: [] }>();
.cite:hover {
background: var(--ai-soft);
}
.cite-ocr {
margin-right: var(--sp-1);
font-size: var(--fs-caption);
color: var(--text-secondary);
text-decoration: none;
}
.cite:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
/**
* 日期输入(设计规范 §7.22、§9.3):可以直接键入(接受 2026/11/20、2026年11月20日 等,离开时整理成 2026-11-20),
* 右侧按钮打开日期选择器。选择器周一开始;今天 1px 边框,选中墨底白字;非本月的格子留空;不可选的日期只变灰。
* 交互基础用 Reka UI 的 Popover + Calendar(键盘方向键选日期、Esc 关闭、焦点回到按钮),外观来自 app.css 的 .dt / .cal。
*/
import { getLocalTimeZone, parseDate, today, type DateValue } from "@internationalized/date";
import { Calendar, ChevronLeft, ChevronRight } from "@lucide/vue";
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarHeader,
CalendarHeading,
CalendarNext,
CalendarPrev,
CalendarRoot,
PopoverAnchor,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger,
} from "reka-ui";
import { computed, ref } from "vue";
import { parseDay } from "@/lib/labels";
const props = defineProps<{ id?: string; placeholder?: string; disabled?: boolean; label?: string }>();
const text = defineModel<string>({ required: true });
const emit = defineEmits<{ blur: [] }>();
const open = ref(false);
const input = ref<HTMLInputElement | null>(null);
defineExpose({ select: () => input.value?.select(), focus: () => input.value?.focus() });
/** 输入框里能认出的日期,选择器打开时停在这个月 */
const value = computed<DateValue | undefined>(() => {
const d = parseDay(text.value);
return d ? parseDate(d) : undefined;
});
function pick(v: DateValue | undefined) {
if (!v) return;
text.value = v.toString();
open.value = false;
}
function onBlur() {
const d = parseDay(text.value);
if (d) text.value = d;
emit("blur");
}
</script>
<template>
<PopoverRoot v-model:open="open">
<PopoverAnchor as-child>
<div class="dt">
<input
:id="id"
ref="input"
v-model="text"
class="input"
:placeholder="placeholder ?? '例如:2026-11-20'"
:disabled="disabled"
autocomplete="off"
@blur="onBlur"
/>
<PopoverTrigger as-child>
<button type="button" class="icon-btn sm" :aria-label="`打开日期选择器${label ? `:${label}` : ''}`" :disabled="disabled">
<Calendar class="ic" :stroke-width="1.5" />
</button>
</PopoverTrigger>
</div>
</PopoverAnchor>
<PopoverPortal>
<PopoverContent class="cal" align="start" :side-offset="4" :aria-label="label ?? '选择日期'">
<CalendarRoot
v-slot="{ grid, weekDays }"
:model-value="value"
:default-placeholder="value ?? today(getLocalTimeZone())"
locale="zh-CN"
:week-starts-on="1"
weekday-format="narrow"
initial-focus
@update:model-value="pick"
>
<CalendarHeader class="cal-h">
<CalendarPrev class="icon-btn sm" aria-label="上个月"><ChevronLeft class="ic" :stroke-width="1.5" /></CalendarPrev>
<CalendarHeading v-slot="{ headingValue }"><b>{{ headingValue }}</b></CalendarHeading>
<CalendarNext class="icon-btn sm" aria-label="下个月"><ChevronRight class="ic" :stroke-width="1.5" /></CalendarNext>
</CalendarHeader>
<CalendarGrid v-for="month in grid" :key="month.value.toString()" class="cal-grid">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell v-for="d in weekDays" :key="d" class="wd">{{ d }}</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow v-for="(week, i) in month.rows" :key="i">
<CalendarCell v-for="d in week" :key="d.toString()" :date="d">
<CalendarCellTrigger :day="d" :month="month.value" class="day" />
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</CalendarRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
/**
* 页面右侧的原文区(左右布局,同读标页):一个或两个原文查看器。
* 两个时(查标:招标文件、投标文件)横向排开,当前的那个占满,另一个只在边上露出一窄条(--peek);
* 点露出的那一条、或点到另一边的出处,就滑过去,原来那个从另一侧露出一窄条。两边各自记着翻到的页和高亮。
* 露出的那一条在截断处淡入页面底色,不留硬边;标签上的箭头指向文件所在的一侧,悬停时文件先往外探出一点。
*/
import { ChevronsLeft, ChevronsRight } from "@lucide/vue";
import { computed } from "vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
export interface StageSlot {
key: string;
label: string;
doc: { id: string; label: string } | null;
target: ViewerTarget | null;
emptyText?: string;
}
const props = defineProps<{ slots: StageSlot[] }>();
const active = defineModel<string>("active", { required: true });
const index = computed(() => Math.max(0, props.slots.findIndex((s) => s.key === active.value)));
</script>
<template>
<div class="stage" :class="{ multi: slots.length > 1, second: index > 0 }">
<div class="track">
<div v-for="(s, i) in slots" :key="s.key" class="panel" :class="i === index ? 'on' : i < index ? 'at-left' : 'at-right'" :inert="i !== index || undefined">
<PdfViewer :document-id="s.doc?.id" :document-label="s.doc?.label ?? s.label" :target="s.target" :empty-text="s.emptyText" />
</div>
</div>
<!-- 露出的那一条:放在 track 外面,不跟着 inert -->
<button
v-for="(s, i) in slots"
v-show="slots.length > 1 && i !== index"
:key="`peek-${s.key}`"
type="button"
class="peek"
:class="i < index ? 'left' : 'right'"
:aria-label="`看${s.label}`"
@click="active = s.key"
>
<!-- 箭头指向展开的方向:右边露出的往左拉开,左边露出的往右拉开;文字两头各一个 -->
<span class="tab">
<component :is="i < index ? ChevronsRight : ChevronsLeft" class="ic" :stroke-width="1.75" aria-hidden="true" />
<span class="t">{{ s.label }}</span>
<component :is="i < index ? ChevronsRight : ChevronsLeft" class="ic" :stroke-width="1.75" aria-hidden="true" />
</span>
</button>
</div>
</template>
<style scoped>
.stage {
--peek: 80px;
--stage-gap: var(--sp-2);
position: relative;
overflow: hidden;
min-height: 0;
height: 100%;
}
.track {
display: flex;
gap: var(--stage-gap);
height: 100%;
transition: transform calc(var(--dur-base) * 1.5) var(--ease);
}
/* 第二个是当前的:整条左移,第一个只露出右边 --peek 宽 */
.stage.second .track {
transform: translateX(calc(-100% + 2 * var(--peek) + var(--stage-gap)));
}
.panel {
flex: 1;
min-width: 0;
height: 100%;
}
.stage.multi .panel {
flex: 0 0 calc(100% - var(--peek) - var(--stage-gap));
}
.panel:not(.on) {
opacity: 0.8;
transition: opacity var(--dur-base) var(--ease);
}
/* 露出的一窄条:留下文件名(挪到露出的那一侧),藏起翻页按钮,看得出是哪份文件 */
.panel:not(.on) :deep(.pager) {
visibility: hidden;
}
.panel.at-left :deep(.viewer-bar) {
flex-direction: row-reverse;
}
.panel.at-left :deep(.doc-name) {
text-align: right;
}
/* 悬停露出的那一条:文件往外探出一点,提示可以切过去 */
.stage:has(.peek.right:hover) .track {
transform: translateX(-10px);
}
.stage.second:has(.peek.left:hover) .track {
transform: translateX(calc(-100% + 2 * var(--peek) + var(--stage-gap) + 10px));
}
.peek {
position: absolute;
top: 0;
bottom: 0;
z-index: 2;
display: grid;
place-items: center;
width: var(--peek);
padding: 0;
border: 0;
cursor: pointer;
}
/* 截断处淡入页面底色:外侧不透明,往里渐隐,看不出硬切的边 */
.peek.right {
right: 0;
background: linear-gradient(to left, var(--bg-page), transparent 35%);
}
.peek.left {
left: 0;
background: linear-gradient(to right, var(--bg-page), transparent 35%);
}
.tab {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-1);
padding: var(--sp-2) 3px;
border: var(--line) solid var(--border-subtle);
border-radius: var(--radius-ctl);
background: var(--bg-surface);
color: var(--text-secondary);
transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.tab .t {
writing-mode: vertical-rl;
font-size: var(--fs-caption);
letter-spacing: 2px;
}
.tab .ic {
width: 14px;
height: 14px;
transition: transform var(--dur-fast) var(--ease);
}
.peek:hover .tab,
.peek:focus-visible .tab {
color: var(--text-primary);
border-color: var(--c-ink);
}
.peek.left:hover .ic {
transform: translateX(2px);
}
.peek.right:hover .ic {
transform: translateX(-2px);
}
.peek:focus-visible {
outline: 0;
}
</style>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
/**
* 反馈:判断有误(J5)。查标页的问题(误报)和读标页的「已响应」结论(漏报)共用。
* 反馈只用于改进检查规则,不改变处理状态;理由和补充说明合成一句存进 note。
*/
import { unwrap } from "@bid-doctor/api-client";
import { ref, watch } from "vue";
import ConfirmDialog from "./ConfirmDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { toast } from "@/lib/toast";
export type FeedbackTarget = {
objectType: "finding" | "checklist_item";
objectId: string;
title: string;
/** false_positive:报了不该报的;false_negative:说已响应,其实没有 */
kind: "false_positive" | "false_negative";
reasons: string[];
/** 对话框底部那句说明:反馈不改变什么 */
note: string;
};
const props = defineProps<{ projectId: string; target: FeedbackTarget | null }>();
const emit = defineEmits<{ close: [] }>();
const reason = ref("");
const text = ref("");
const busy = ref(false);
watch(
() => props.target,
(t) => {
reason.value = t?.reasons[0] ?? "";
text.value = "";
},
);
async function send() {
const t = props.target;
if (!t) return;
busy.value = true;
try {
await unwrap(
api.POST("/feedback", {
body: {
project_id: props.projectId,
object_type: t.objectType,
object_id: t.objectId,
kind: t.kind,
note: [reason.value, text.value.trim()].filter(Boolean).join(":").slice(0, 1000),
},
}),
);
emit("close");
toast("已收到反馈");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
busy.value = false;
}
}
</script>
<template>
<ConfirmDialog
:open="!!target"
title="反馈:判断有误"
ok-text="提交反馈"
busy-text="正在提交…"
:busy="busy"
@update:open="(v) => { if (!v) emit('close'); }"
@ok="send"
>
<p class="muted fb-title">「{{ target?.title }}」</p>
<div class="fb-choices" role="radiogroup" aria-label="哪里不对">
<label v-for="r in target?.reasons ?? []" :key="r" class="choice"><input v-model="reason" type="radio" class="ck" name="fb" :value="r" />{{ r }}</label>
</div>
<div class="field fill fb-note">
<label for="fb-note">补充说明 <small>(选填)</small></label>
<textarea id="fb-note" v-model="text" class="input" maxlength="200"></textarea>
<span class="msg">{{ target?.note }}</span>
</div>
</ConfirmDialog>
</template>
<style scoped>
.fb-title {
margin: 0;
}
.fb-choices {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
.fb-note {
margin-top: var(--sp-4);
}
</style>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
/**
* 修改项目基本信息的一个字段(FR-M2-03,project-info-plan §2)。按字段类型给输入控件;人工值优先,可以恢复 AI 值。
* 金额可写「286万」「2,860,000」,服务端换成元(PI7);投标截止时间按北京时间录入。修改留痕(审计)。
*/
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import DateInput from "@/components/DateInput.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { BOOL_FIELDS, FIELD_LABEL, fieldText, LEGAL_REGIME, MONEY_FIELDS, parseDay } from "@/lib/labels";
import type { ProjectField } from "@/lib/queries";
type Key = Schemas["ProjectFieldKey"];
const props = defineProps<{ projectId: string; fieldKey: Key | null; field: ProjectField | null }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ saved: [f: ProjectField, restored: boolean] }>();
const text = ref("");
const flag = ref<boolean | null>(null);
const day = ref("");
const time = ref("");
const err = ref("");
const busy = ref(false);
const key = computed(() => props.fieldKey);
const kind = computed(() =>
!key.value ? "text" : MONEY_FIELDS.has(key.value) ? "money" : BOOL_FIELDS.has(key.value) ? "bool" : key.value === "legal_regime" ? "regime" : key.value === "deadline_at" ? "time" : "text",
);
const canRestore = computed(() => props.field?.source === "human" && props.field.ai_value != null);
/** 时间按北京时间拆成日期和时分 */
function splitTime(iso: string): [string, string] {
const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((x) => [x.type, x.value]));
return [`${parts.year}-${parts.month}-${parts.day}`, `${parts.hour === "24" ? "00" : parts.hour}:${parts.minute}`];
}
watch(open, (v) => {
if (!v) return;
const val = props.field?.value;
err.value = "";
text.value = kind.value === "money" && val != null ? String(val) : typeof val === "string" ? val : kind.value === "regime" ? "unknown" : "";
flag.value = typeof val === "boolean" ? val : null;
[day.value, time.value] = kind.value === "time" && typeof val === "string" ? splitTime(val) : ["", "09:30"];
});
function value(): string | boolean | null | undefined {
if (kind.value === "bool") return flag.value;
if (kind.value === "time") {
const d = parseDay(day.value);
const m = /^(\d{1,2})[::](\d{2})$/.exec(time.value.trim());
if (!d || !m || Number(m[1]) > 23 || Number(m[2]) > 59) {
err.value = "请填日期和时间,例如:2026-11-20 和 09:30";
return undefined;
}
return `${d}T${m[1]!.padStart(2, "0")}:${m[2]}:00+08:00`;
}
const t = text.value.trim();
if (key.value === "name" && !t) {
err.value = "项目名称不能为空";
return undefined;
}
return t || null;
}
async function save(restore = false) {
if (!key.value || busy.value) return;
const v = restore ? null : value();
if (v === undefined) return;
busy.value = true;
err.value = "";
try {
const f = await unwrap(
api.PUT("/projects/{project_id}/fields/{field_key}", { params: { path: { project_id: props.projectId, field_key: key.value } }, body: { value: v } }),
);
emit("saved", f, restore);
open.value = false;
} catch (e) {
const detail = isApiError(e, "validation_failed") ? (e.problem as { errors?: { message: string }[] }).errors?.[0]?.message : undefined;
err.value = detail ?? errorTitle(e);
} finally {
busy.value = false;
}
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog" :aria-describedby="undefined">
<DialogTitle as="h2">修改{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="save()">
<div v-if="kind === 'text'" class="field fill">
<label for="fd-text">{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
<input id="fd-text" v-model="text" class="input" maxlength="500" autocomplete="off" />
</div>
<div v-else-if="kind === 'money'" class="field fill">
<label for="fd-money">{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
<input id="fd-money" v-model="text" class="input num" placeholder="例如:2,860,000 或 286万" autocomplete="off" />
<span class="msg">按招标文件原文填写,存成元;留空表示没写明</span>
</div>
<div v-else-if="kind === 'regime'" class="field fill">
<label for="fd-regime">法律体系</label>
<SelectBox
id="fd-regime"
:model-value="text"
:options="Object.entries(LEGAL_REGIME).map(([value, label]) => ({ value, label }))"
@update:model-value="(v) => (text = v ?? '')"
/>
<span class="msg">决定查标用「投标无效」还是「否决投标」的口径</span>
</div>
<div v-else-if="kind === 'bool'" class="field fill">
<label>{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
<div class="seg" role="group" :aria-label="fieldKey ? FIELD_LABEL[fieldKey] : ''">
<button type="button" :aria-pressed="flag === true" @click="flag = true">是</button>
<button type="button" :aria-pressed="flag === false" @click="flag = false">否</button>
<button type="button" :aria-pressed="flag === null" @click="flag = null">没写明</button>
</div>
</div>
<div v-else class="field fill">
<label for="fd-day">投标截止时间(北京时间)</label>
<div class="dt-row">
<DateInput id="fd-day" v-model="day" label="投标截止日期" />
<input v-model="time" class="input num" aria-label="时分" placeholder="09:30" autocomplete="off" />
</div>
<span class="msg">查标按它核对证书有效期和落款日期,改了以后重新检查才生效</span>
</div>
<p v-if="field && field.ai_value != null" class="cap muted ai">
AI 提取:{{ fieldText(field.key, field.ai_value) }}<template v-if="field.citation">({{ field.citation.document_label }} 第 <span class="fig">{{ field.citation.page_no }}</span> 页)</template>
</p>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<button v-if="canRestore" type="button" class="btn text left" :disabled="busy" @click="save(true)">恢复 AI 值</button>
<span v-else class="cap muted left">修改会留痕</span>
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
<span class="idle">保存</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.dt-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 96px;
gap: var(--sp-2);
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.input.num::placeholder {
font-family: var(--font-sans);
font-size: var(--fs-body);
}
.ai,
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
/**
* 大图浏览(设计规范 §7.23):深色蒙层上看图,不开新标签页。
* - 打开时整张适应窗口;放大、缩小、适应窗口三个按钮,滚轮按光标位置缩放,双击在适应窗口和 2 倍之间切换;
* - 放大后可以拖动;多张图时左右切换,顶栏写「文件名 · 第几张 / 共几张」;
* - 键盘:Esc 关闭,+ / − 缩放,0 适应窗口,← / → 切换。对话框基础用 Reka UI(焦点锁定、Esc、焦点回到触发处)。
*/
import { ChevronLeft, ChevronRight, Maximize, Minus, Plus, X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, nextTick, ref, watch } from "vue";
export interface ViewerImage {
url: string;
title: string;
}
const props = defineProps<{ images: ViewerImage[] }>();
const open = defineModel<boolean>("open", { required: true });
const index = defineModel<number>("index", { default: 0 });
const MIN = 0.5; // 相对「适应窗口」
const MAX_NATURAL = 6; // 最多放大到原图像素的 6 倍
const stage = ref<HTMLElement | null>(null);
const natural = ref({ w: 0, h: 0 });
const fit = ref(1); // 适应窗口时,图片显示尺寸 / 原图尺寸
const zoom = ref(1); // 相对适应窗口
const tx = ref(0);
const ty = ref(0);
const loaded = ref(false);
let drag: { x: number; y: number; tx: number; ty: number; id: number } | null = null;
const current = computed(() => props.images[index.value]);
const percent = computed(() => Math.round(fit.value * zoom.value * 100));
const maxZoom = computed(() => Math.max(1, MAX_NATURAL / (fit.value || 1)));
watch([open, index], () => {
loaded.value = false;
reset();
});
function reset() {
zoom.value = 1;
tx.value = 0;
ty.value = 0;
}
function measure() {
const el = stage.value;
if (!el || !natural.value.w) return;
fit.value = Math.min(1, (el.clientWidth - 48) / natural.value.w, (el.clientHeight - 48) / natural.value.h);
}
function onLoad(ev: Event) {
const img = ev.target as HTMLImageElement;
natural.value = { w: img.naturalWidth, h: img.naturalHeight };
measure();
loaded.value = true;
}
/** 以舞台上的一点(相对舞台中心)为中心缩放到 z */
function zoomTo(z: number, px = 0, py = 0) {
const next = Math.min(maxZoom.value, Math.max(MIN, z));
const k = next / zoom.value;
tx.value = px - (px - tx.value) * k;
ty.value = py - (py - ty.value) * k;
zoom.value = next;
if (next <= 1) {
tx.value = 0;
ty.value = 0;
}
}
function centerOffset(ev: { clientX: number; clientY: number }): [number, number] {
const r = stage.value!.getBoundingClientRect();
return [ev.clientX - r.left - r.width / 2, ev.clientY - r.top - r.height / 2];
}
function onWheel(ev: WheelEvent) {
ev.preventDefault();
zoomTo(zoom.value * Math.exp(-ev.deltaY * 0.0015), ...centerOffset(ev));
}
function onDblClick(ev: MouseEvent) {
if (zoom.value > 1) zoomTo(1);
else zoomTo(2, ...centerOffset(ev));
}
function onDown(ev: PointerEvent) {
if (zoom.value <= 1 || ev.button !== 0) return;
drag = { x: ev.clientX, y: ev.clientY, tx: tx.value, ty: ty.value, id: ev.pointerId };
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
}
function onMove(ev: PointerEvent) {
if (!drag || ev.pointerId !== drag.id) return;
tx.value = drag.tx + ev.clientX - drag.x;
ty.value = drag.ty + ev.clientY - drag.y;
}
function onUp() {
drag = null;
}
function go(step: number) {
const n = props.images.length;
if (n > 1) index.value = (index.value + step + n) % n;
}
function onKey(ev: KeyboardEvent) {
if (ev.key === "+" || ev.key === "=") zoomTo(zoom.value * 1.25);
else if (ev.key === "-" || ev.key === "_") zoomTo(zoom.value / 1.25);
else if (ev.key === "0") zoomTo(1);
else if (ev.key === "ArrowLeft") go(-1);
else if (ev.key === "ArrowRight") go(1);
else return;
ev.preventDefault();
}
function onOpen() {
void nextTick(measure);
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="iv-scrim" />
<DialogContent class="iv" :aria-describedby="undefined" @keydown="onKey" @open-auto-focus="onOpen">
<div class="iv-bar">
<DialogTitle as="h2" class="iv-title">
{{ current?.title }}<template v-if="images.length > 1"> · <span class="fig">{{ index + 1 }}</span> / <span class="fig">{{ images.length }}</span></template>
</DialogTitle>
<div class="iv-tools" role="group" aria-label="缩放">
<button type="button" class="iv-btn" aria-label="缩小(−)" :disabled="zoom <= MIN" @click="zoomTo(zoom / 1.25)"><Minus class="ic" :stroke-width="1.5" /></button>
<button type="button" class="iv-pct" :aria-label="`当前 ${percent}%,点击适应窗口`" @click="zoomTo(1)"><span class="fig">{{ percent }}</span>%</button>
<button type="button" class="iv-btn" aria-label="放大(+)" :disabled="zoom >= maxZoom" @click="zoomTo(zoom * 1.25)"><Plus class="ic" :stroke-width="1.5" /></button>
<button type="button" class="iv-btn" aria-label="适应窗口(0)" :disabled="zoom === 1" @click="zoomTo(1)"><Maximize class="ic" :stroke-width="1.5" /></button>
<DialogClose class="iv-btn" aria-label="关闭(Esc)"><X class="ic" :stroke-width="1.5" /></DialogClose>
</div>
</div>
<div
ref="stage"
class="iv-stage"
:class="{ grab: zoom > 1 }"
@wheel="onWheel"
@dblclick="onDblClick"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onUp"
@pointercancel="onUp"
>
<span v-if="!loaded" class="iv-spin" aria-hidden="true"></span>
<img
v-if="current"
:key="current.url"
:src="current.url"
:alt="current.title"
draggable="false"
:style="{
width: natural.w ? `${natural.w * fit}px` : undefined,
transform: `translate(-50%, -50%) translate(${tx}px, ${ty}px) scale(${zoom})`,
opacity: loaded ? 1 : 0,
}"
@load="onLoad"
/>
<template v-if="images.length > 1">
<button type="button" class="iv-btn iv-nav prev" aria-label="上一张(←)" @click.stop="go(-1)"><ChevronLeft class="ic" :stroke-width="1.5" /></button>
<button type="button" class="iv-btn iv-nav next" aria-label="下一张(→)" @click.stop="go(1)"><ChevronRight class="ic" :stroke-width="1.5" /></button>
</template>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<style>
/* 不加 scoped:Reka 的 DialogOverlay / DialogContent 渲染在 body 下 */
.iv-scrim {
position: fixed;
inset: 0;
z-index: 60;
background: color-mix(in srgb, var(--c-ink) 96%, transparent);
}
.iv {
position: fixed;
inset: 0;
z-index: 61;
display: flex;
flex-direction: column;
color: var(--text-on-dark);
outline: none;
}
.iv-bar {
display: flex;
align-items: center;
gap: var(--sp-4);
height: 56px;
padding: 0 var(--sp-4) 0 var(--sp-6);
}
.iv-title {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
font-size: var(--fs-body);
font-weight: var(--fw-regular);
white-space: nowrap;
text-overflow: ellipsis;
}
.iv-tools {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.iv-btn,
.iv-pct {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--control-h);
min-width: var(--control-h);
padding: 0 var(--sp-2);
border: 0;
border-radius: var(--radius-ctl);
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.iv-pct {
min-width: 64px;
font-size: var(--fs-caption);
}
.iv-btn:hover:not(:disabled),
.iv-pct:hover {
background: color-mix(in srgb, var(--text-on-dark) 14%, transparent);
}
.iv-btn:disabled {
opacity: 0.4;
cursor: default;
}
.iv-btn:focus-visible,
.iv-pct:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.iv-stage {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
touch-action: none;
user-select: none;
}
.iv-stage.grab {
cursor: grab;
}
.iv-stage.grab:active {
cursor: grabbing;
}
.iv-stage img {
position: absolute;
top: 50%;
left: 50%;
max-width: none;
height: auto;
box-shadow: var(--shadow-overlay);
transform-origin: center;
transition: opacity var(--dur-fast) var(--ease);
}
.iv-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: color-mix(in srgb, var(--c-ink) 60%, transparent);
}
.iv-nav.prev {
left: var(--sp-4);
}
.iv-nav.next {
right: var(--sp-4);
}
.iv-spin {
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
border: 2px solid color-mix(in srgb, var(--text-on-dark) 30%, transparent);
border-top-color: var(--text-on-dark);
border-radius: 50%;
animation: iv-spin 0.8s linear infinite;
}
@keyframes iv-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -9,16 +9,18 @@
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import SelectBox from "@/components/SelectBox.vue";
import { errorTitle, isApiError } from "@/lib/api";
import type { ItemFields } from "@/lib/checklistOps";
import { category, CATEGORY_ORDER, IMPORTANCE_MARK, IMPORTANCE_NAME } from "@/lib/labels";
import type { ChecklistItem } from "@/lib/queries";
import type { ChecklistItem, Lot } from "@/lib/queries";
type Mode = "edit" | "accept" | "create";
type Cat = ChecklistItem["categories"][number];
type Imp = ChecklistItem["importance"];
const props = defineProps<{ mode: Mode; item?: ChecklistItem | null; defaultCategory?: Cat }>();
/** lots:项目的分包(不含默认包);有 2 个及以上时可以改条目归属哪个包(FR-M2-04) */
const props = defineProps<{ mode: Mode; item?: ChecklistItem | null; defaultCategory?: Cat; lots?: Lot[] }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{
/** 提交:changes 只含改过的字段;ack = 人工确认、无出处 */
@@ -32,6 +34,7 @@ const cats = ref<Cat[]>([]);
const imp = ref<Imp>("normal");
const evidence = ref("");
const reqText = ref("");
const lotId = ref<string | null>(null);
const ack = ref(false);
const busy = ref(false);
const err = ref("");
@@ -46,6 +49,7 @@ watch(open, (v) => {
imp.value = i?.importance ?? "normal";
evidence.value = i?.evidence_required ?? "";
reqText.value = i?.requirement_text ?? "";
lotId.value = i?.lot_id ?? null;
ack.value = false;
err.value = titleErr.value = catErr.value = "";
});
@@ -75,6 +79,7 @@ function changes(): ItemFields {
if (!i || imp.value !== i.importance) out.importance = imp.value;
const ev = evidence.value.trim() || null;
if (ev !== (i?.evidence_required ?? null)) out.evidence_required = ev;
if ((props.lots?.length ?? 0) > 1 && lotId.value !== (i?.lot_id ?? null)) out.lot_id = lotId.value;
const rt = reqText.value.trim() || null;
if (reqEditable.value && rt !== (i?.requirement_text ?? null)) out.requirement_text = rt;
return out;
@@ -129,6 +134,14 @@ function submit() {
</div>
<span v-if="imp === 'substantive'" class="msg">★ 条款不满足就会被判无效,已同时归在「无效条款」</span>
</div>
<div v-if="(lots?.length ?? 0) > 1" class="field fill">
<label for="it-lot">所属采购包</label>
<SelectBox
id="it-lot"
v-model="lotId"
:options="[{ value: null, label: '各包通用' }, ...(lots ?? []).map((l) => ({ value: l.id, label: `采购包 ${l.lot_no}${l.name ? ` · ${l.name}` : ''}` }))]"
/>
</div>
<div class="field fill">
<label for="it-ev">证明材料 <small>(选填)</small></label>
<input id="it-ev" v-model="evidence" class="input" placeholder="如「提供营业执照副本复印件并加盖公章」" />

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
/**
* 顶栏的通知(原型 app.js 的铃铛,m6-plan §1.6):有未读时带圆点;点开是最近 30 条,点一条跳到相关页面并标为已读。
* 解析 / 检查 / 报告完成、额度用到 80% / 100%、开通结果、到期提醒都在这里。
*/
import { unwrap } from "@bid-doctor/api-client";
import { Bell } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from "reka-ui";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "@/lib/api";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { useNotifications, type Notification } from "@/lib/queries";
const q = useNotifications();
const qc = useQueryClient();
const router = useRouter();
const open = ref(false);
const items = computed(() => q.data.value?.items ?? []);
const unread = computed(() => q.data.value?.unread_count ?? 0);
async function markRead(ids?: string[]) {
await unwrap(api.POST("/notifications/read", { body: ids ? { ids } : {} }));
void qc.invalidateQueries({ queryKey: qk.notifications });
}
async function go(n: Notification) {
open.value = false;
if (!n.read_at) void markRead([n.id]);
if (n.link) await router.push(n.link);
}
</script>
<template>
<PopoverRoot v-model:open="open">
<PopoverTrigger as-child>
<button
class="icon-btn tip-below tip-left bell"
type="button"
:aria-label="unread ? `通知(${unread} 条未读)` : '通知'"
:data-tip="open ? undefined : '通知'"
>
<Bell class="ic lg" :stroke-width="1.5" /><span v-if="unread" class="dot" aria-hidden="true"></span>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent class="menu popper notes" align="end" :side-offset="6" aria-label="通知">
<div class="nh">
<b>通知</b>
<button v-if="unread" type="button" class="lnk cap" @click="markRead()">全部标为已读</button>
</div>
<p v-if="!items.length" class="cap muted none">还没有通知</p>
<ul v-else>
<li v-for="n in items" :key="n.id">
<button type="button" class="note" :class="{ unread: !n.read_at }" @click="go(n)">
<span class="t">{{ n.title }}</span>
<span class="cap muted">
<template v-if="n.project_name">{{ n.project_name }} · </template>{{ fmtDateTime(n.created_at) }}
</span>
</button>
</li>
</ul>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<style scoped>
.bell {
position: relative;
}
.dot {
position: absolute;
top: 6px;
right: 6px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-ink);
box-shadow: 0 0 0 2px var(--bg-page);
}
/* PopoverContent 的根元素上没有 scoped 属性(在 Reka 的定位外层上),从外层选进去 */
[data-reka-popper-content-wrapper] :deep(.notes) {
width: 360px;
max-height: min(480px, 70vh);
padding: 0;
}
.nh {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4) var(--sp-2);
border-bottom: var(--line) solid var(--border-subtle);
}
.none {
margin: 0;
padding: var(--sp-4);
}
ul {
margin: 0;
padding: var(--sp-1);
list-style: none;
}
.note {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: var(--sp-2) var(--sp-3);
border: 0;
border-radius: var(--radius-ctl);
background: none;
font: inherit;
color: var(--text-secondary);
text-align: left;
cursor: pointer;
}
.note:hover {
background: var(--c-n100);
}
.note.unread {
color: var(--text-primary);
}
.note.unread .t {
font-weight: var(--fw-medium);
}
.note.unread .t::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
margin-right: var(--sp-2);
border-radius: 50%;
background: var(--c-ink);
vertical-align: 2px;
}
</style>

View File

@@ -45,6 +45,7 @@ const body = ref<HTMLElement>();
const canvas = ref<HTMLCanvasElement>();
let task: pdfjs.RenderTask | null = null;
let loadSeq = 0;
let renderSeq = 0;
async function load(id: string | undefined) {
const seq = ++loadSeq;
@@ -71,8 +72,13 @@ async function render() {
const doc = pdf.value;
const el = body.value;
if (!doc || !el || !canvas.value) return;
task?.cancel();
// 同一块画布同时只能有一次渲染:后来的等前一次真正停下,中途又有更新的就让给它(打开时直接翻到出处那页会连发两次)
const seq = ++renderSeq;
const prev = task;
prev?.cancel();
await prev?.promise.catch(() => {});
const p = await doc.getPage(page.value);
if (seq !== renderSeq) return;
const base = p.getViewport({ scale: 1 });
const width = Math.max(320, el.clientWidth - 32);
const vp = p.getViewport({ scale: width / base.width });

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
/**
* 项目头(原型的 projHead):项目名、采购人等、投标截止、状态和就绪;下面是「概览 / 读标 / 查标 / 报告」标签页。
* 就绪只在「已检查」时显示(G7),只表示致命问题都已处理,不代表投标文件没有问题(READY_TIP)。
* 状态操作(FR-M6-02):递交 / 撤销递交 / 归档 / 撤销归档。致命问题没处理完也可以递交(红线 4),确认框里说明;
* 递交后项目只读,可以撤销,都留痕。
* 删除项目(J4,决策 M6-1):放在「更多」里,彻底删除、不能恢复,确认时要输入项目名称;额度流水和不含内容的审计保留。
*/
import { unwrap } from "@bid-doctor/api-client";
import { MoreHorizontal } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from "reka-ui";
import { computed, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { qk } from "@/lib/keys";
import { deadline, legalRegime, projectStatus, READY_TIP } from "@/lib/labels";
import type { ProjectDetail } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ project?: ProjectDetail; tab: "overview" | "checklist" | "check" | "report" }>();
const qc = useQueryClient();
const p = computed(() => props.project);
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));
type Action = "submit" | "unsubmit" | "archive" | "unarchive";
const ACTIONS: Record<string, { action: Action; label: string }[]> = {
tracking: [{ action: "submit", label: "标记为已递交" }],
drafting: [{ action: "submit", label: "标记为已递交" }],
checked: [{ action: "submit", label: "标记为已递交" }],
submitted: [
{ action: "unsubmit", label: "撤销递交" },
{ action: "archive", label: "归档" },
],
archived: [{ action: "unarchive", label: "撤销归档" }],
};
const actions = computed(() => (p.value ? ACTIONS[p.value.status] ?? [] : []));
const confirmSubmit = ref(false);
const busy = ref(false);
async function run(action: Action) {
if (!p.value) return;
if (action === "submit" && !confirmSubmit.value) {
confirmSubmit.value = true;
return;
}
busy.value = true;
try {
await unwrap(api.POST("/projects/{project_id}/transitions", { params: { path: { project_id: p.value.id } }, body: { action } }));
confirmSubmit.value = false;
void qc.invalidateQueries({ queryKey: qk.project(p.value.id) });
void qc.invalidateQueries({ queryKey: qk.projects });
toast(action === "submit" ? "已标记为已递交,项目改为只读" : action === "archive" ? "已归档" : "已撤销");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
busy.value = false;
}
}
/* ---------- 删除项目 ---------- */
const router = useRouter();
const delOpen = ref(false);
const delName = ref("");
const delErr = ref("");
function askDelete() {
delName.value = "";
delErr.value = "";
delOpen.value = true;
}
async function doDelete() {
if (!p.value) return;
if (delName.value.trim() !== p.value.name) {
delErr.value = "项目名称不一致,请照上面的名称输入";
return;
}
busy.value = true;
try {
await unwrap(api.DELETE("/projects/{project_id}", { params: { path: { project_id: p.value.id } } }));
delOpen.value = false;
qc.removeQueries({ queryKey: qk.project(p.value.id) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
toast(`已删除项目「${p.value.name}」`);
await router.replace({ name: "workbench" });
} catch (e) {
delErr.value = errorTitle(e);
} finally {
busy.value = false;
}
}
const checkTo = computed(() =>
p.value?.latest_check_run_id
? { name: "results", params: { projectId: p.value.id, runId: p.value.latest_check_run_id } }
: { name: "bid-check", params: { projectId: p.value?.id ?? "" } },
);
</script>
<template>
<header class="ph">
<div class="ph-l1">
<div>
<h1 :title="p?.name">{{ p?.name ?? " " }}</h1>
<p class="ph-meta">{{ meta || "招标文件信息待解析" }}</p>
</div>
<dl v-if="p" class="facts">
<div>
<dt>投标截止</dt>
<dd>
<template v-if="due"><span class="fig m">{{ due.sub }}</span><span :class="due.cls || 'muted'">{{ due.main }}</span></template>
<span v-else class="muted">—</span>
</dd>
</div>
<div>
<dt>状态</dt>
<dd>
<span class="tag">{{ projectStatus(p.status) }}</span>
<button v-for="a in actions" :key="a.action" type="button" class="btn sm text" :disabled="busy" @click="run(a.action)">{{ a.label }}</button>
<DropdownMenuRoot :modal="false">
<DropdownMenuTrigger class="icon-btn sm" aria-label="更多操作"><MoreHorizontal class="ic" :stroke-width="1.5" /></DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent class="menu popper" align="end" :side-offset="4">
<DropdownMenuItem @select="askDelete">删除项目</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</dd>
</div>
<div v-if="p.readiness.ready !== null && p.readiness.ready !== undefined">
<dt>就绪</dt>
<dd>
<span v-if="p.readiness.ready" class="hint-u tip-below tip-left" :data-tip="READY_TIP" tabindex="0">就绪</span>
<span v-else class="hint-u tip-below tip-left" :data-tip="`还有 ${p.readiness.open_fatal_count} 个致命问题没处理。不影响生成报告和递交,决定权在你。`" tabindex="0">未就绪</span>
</dd>
</div>
</dl>
</div>
<nav class="tabs" aria-label="项目">
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'overview'" :to="{ name: 'overview', params: { projectId: p.id } }">概览</RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'checklist'" :to="{ name: 'checklist', params: { projectId: p.id } }">读标</RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'check'" :to="checkTo">查标<span v-if="p.readiness.open_fatal_count" class="sev fatal">致命<span class="fig">{{ p.readiness.open_fatal_count }}</span></span></RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'report'" :to="{ name: 'report', params: { projectId: p.id } }">报告</RouterLink>
</nav>
</header>
<ConfirmDialog v-model:open="confirmSubmit" title="标记为已递交?" ok-text="标记为已递交" busy-text="正在标记…" :busy="busy" @ok="run('submit')">
<p>标记后项目改为只读,可以随时撤销。</p>
<p v-if="p && p.readiness.open_fatal_count">还有 <b class="fig">{{ p.readiness.open_fatal_count }}</b> 项致命问题没有处理。是否递交由你决定,系统不会阻止。</p>
</ConfirmDialog>
<ConfirmDialog v-model:open="delOpen" :title="`删除项目「${p?.name ?? ''}」?`" ok-text="删除项目" busy-text="正在删除…" :busy="busy" @ok="doDelete">
<p>项目下的招标文件、投标文件、清单、检查结果和报告都会彻底删除,<b>不能恢复</b>。需要留底的报告请先下载。</p>
<p class="cap muted">额度流水和一条不含业务内容的操作记录会保留。</p>
<div class="field fill del-name" :class="{ err: delErr }">
<label for="del-name">输入项目名称确认</label>
<input id="del-name" v-model="delName" class="input" :placeholder="p?.name" autocomplete="off" @input="delErr = ''" />
<span v-if="delErr" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ delErr }}</span>
</div>
</ConfirmDialog>
</template>
<style scoped>
.del-name {
margin-top: var(--sp-3);
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* 工作台项目行(设计规范 §7.9,原型 app.css B20):整行可点,「下一步」单独可点。
* 列:项目 · 投标截止 · 状态(含就绪)· 待确认 · 下一步。
* 列:项目 · 投标截止 · 状态(含就绪)· 致命(待处理的致命问题数)· 下一步。
*/
import { ChevronRight } from "@lucide/vue";
import { computed } from "vue";
@@ -16,15 +16,20 @@ 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<RouteLocationRaw>(() => {
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 } };
});
/** 「下一步」:骨架阶段只有上传、解析进度、清单三个去处,其余动作先落到清单页 */
const next = computed<RouteLocationRaw>(() => home.value);
/** 「下一步」:生成报告去报告页,其余同默认入口 */
const next = computed<RouteLocationRaw>(() =>
p.value.next_action === "export_report" ? { name: "report", params: { projectId: p.value.id } } : home.value,
);
</script>
<template>
@@ -45,11 +50,11 @@ const next = computed<RouteLocationRaw>(() => home.value);
<span class="hint-u tip-below" tabindex="0" :data-tip="READY_TIP">就绪</span>
</template>
<template v-else-if="p.readiness.ready === false">
<span class="hint-u tip-below" tabindex="0" :data-tip="`还有 ${p.readiness.open_fatal_count} 个致命问题没处理。不影响导出,决定权在你。`">未就绪</span>
<span class="hint-u tip-below" tabindex="0" :data-tip="`还有 ${p.readiness.open_fatal_count} 个致命问题没处理。不影响生成报告和递交,决定权在你。`">未就绪</span>
</template>
</span>
<span class="c">
<span v-if="p.pending_item_count" class="fig" :aria-label="`待确认 ${p.pending_item_count} 条`">{{ p.pending_item_count }}</span>
<span v-if="p.readiness.open_fatal_count" class="sev fatal">致命<span class="fig">{{ p.readiness.open_fatal_count }}</span></span>
<span v-else class="dash" aria-label="无">—</span>
</span>
<span>

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
/**
* 资质详情(只读):字段的键值视图 + 附件缩略图。点缩略图在大图浏览里看(ImageViewer,不开新标签页);
* PDF 附件按页出预览图(最多前 10 页),和图片一样浏览。底部「编辑」进编辑对话框。
*/
import { unwrap } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import ImageViewer, { type ViewerImage } from "@/components/ImageViewer.vue";
import { api, errorTitle } from "@/lib/api";
import { fmtDay, fmtMonthDay, QUALIFICATION_KIND } from "@/lib/labels";
import type { Qualification } from "@/lib/queries";
interface Preview {
fileId: string;
filename: string;
pages: { page: number; url: string }[];
}
const props = defineProps<{ item: Qualification | null }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ edit: [q: Qualification] }>();
const previews = ref<Preview[]>([]);
const loading = ref(false);
const err = ref("");
const viewerOpen = ref(false);
const viewerIndex = ref(0);
watch(open, async (v) => {
const q = props.item;
if (!v || !q) return;
previews.value = [];
err.value = "";
if (!q.files.length) return;
loading.value = true;
try {
previews.value = await Promise.all(
q.files.map(async (f) => {
const p = await unwrap(
api.GET("/qualifications/{qualification_id}/files/{file_id}/previews", {
params: { path: { qualification_id: q.id, file_id: f.id } },
}),
);
return { fileId: f.id, filename: p.filename, pages: p.pages };
}),
);
} catch (e) {
err.value = errorTitle(e);
} finally {
loading.value = false;
}
});
/** 大图浏览的图片:所有附件的所有页排成一列 */
const images = computed<ViewerImage[]>(() =>
previews.value.flatMap((p) => p.pages.map((pg) => ({ url: pg.url, title: p.pages.length > 1 ? `${p.filename} 第 ${pg.page} 页` : p.filename }))),
);
function show(fileIdx: number) {
viewerIndex.value = previews.value.slice(0, fileIdx).reduce((n, p) => n + p.pages.length, 0);
viewerOpen.value = true;
}
const validity = computed(() => {
const q = props.item;
if (!q) return "";
if (!q.valid_to) return q.valid_from ? `${fmtDay(q.valid_from)} 起,长期有效` : "长期有效";
return q.valid_from ? `${fmtDay(q.valid_from)} 至 ${fmtDay(q.valid_to)}` : `至 ${fmtDay(q.valid_to)}`;
});
function edit() {
const q = props.item;
if (!q) return;
open.value = false;
emit("edit", q);
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent v-if="item" class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">{{ item.name }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<dl class="kv">
<dt>类型</dt>
<dd>{{ QUALIFICATION_KIND[item.kind] }}</dd>
<dt>等级</dt>
<dd><template v-if="item.level">{{ item.level }}</template><span v-else class="dash">—</span></dd>
<dt>证书编号</dt>
<dd class="num"><template v-if="item.cert_no">{{ item.cert_no }}</template><span v-else class="dash">—</span></dd>
<dt>发证机构</dt>
<dd><template v-if="item.issuer">{{ item.issuer }}</template><span v-else class="dash">—</span></dd>
<dt>证书主体</dt>
<dd><template v-if="item.holder">{{ item.holder }}</template><span v-else class="dash">—</span></dd>
<dt>有效期</dt>
<dd>
{{ validity }}
<span v-if="item.late_for.length" class="warn">
早于「{{ item.late_for[0]!.name }}」的投标截止 {{ fmtMonthDay(item.late_for[0]!.deadline) }}<template v-if="item.late_for.length > 1"> 等 <span class="fig">{{ item.late_for.length }}</span> 个项目</template>
</span>
</dd>
<template v-if="item.recognized">
<dt>录入方式</dt>
<dd>从证书照片识别,经人工核对</dd>
</template>
</dl>
<h3 class="sub-h">附件</h3>
<p v-if="!item.files.length" class="cap muted">还没有附件,可以在编辑里补录。</p>
<div v-else-if="loading" class="thumbs"><span v-for="f in item.files" :key="f.id" class="skel thumb"></span></div>
<p v-else-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div v-else class="thumbs">
<button v-for="(p, i) in previews" :key="p.fileId" type="button" class="thumb" :title="`查看 ${p.filename}`" @click="show(i)">
<img v-if="p.pages[0]" :src="p.pages[0].url" :alt="p.filename" loading="lazy" />
<span class="cap">{{ p.filename }}<template v-if="p.pages.length > 1"> · <span class="fig">{{ p.pages.length }}</span> 页</template></span>
</button>
</div>
<div class="foot">
<DialogClose class="btn" type="button">关闭</DialogClose>
<button type="button" class="btn primary" @click="edit">编辑</button>
</div>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
<ImageViewer v-model:open="viewerOpen" v-model:index="viewerIndex" :images="images" />
</template>
<style scoped>
.kv {
margin-top: var(--sp-4);
}
.kv .num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
}
.kv .warn {
display: block;
color: var(--warn-text);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
}
.sub-h {
margin: var(--sp-6) 0 var(--sp-2);
font-size: var(--fs-body);
}
.thumbs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: var(--sp-3);
}
.thumb {
display: flex;
flex-direction: column;
gap: var(--sp-1);
padding: 0;
border: 0;
background: none;
font: inherit;
text-align: left;
cursor: zoom-in;
}
.thumb img,
.skel.thumb {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: contain;
border: var(--line) solid var(--border-subtle);
border-radius: var(--radius-xs);
background: var(--bg-page);
}
.thumb:hover img {
border-color: var(--border-strong);
}
.thumb:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.thumb .cap {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--text-secondary);
}
</style>

View File

@@ -0,0 +1,662 @@
<script setup lang="ts">
/**
* 资质的添加 / 编辑对话框(宽 640,原型 qualifications.html、ui-demo forms.html「资质录入」)。
* - 最上面传证书照片:后台识别(vision-plan 第 2 步),识别结果填进下面的表单。只预填和图上文字对得上的项,标「识别」;
* 打码、模糊、对不上的只给建议,点「采用」才填(红线 5)。一张图按一张证书处理,认出几张时只填第一张;
* 保存时识别值留痕,证书照片挂成附件。编辑时传新证书就是换证。识别不扣额度;
* - 名称、证书编号、有效期至必填(长期有效打开开关);证书编号界面必填、接口可空(qualification-plan §2.3);
* - 有效期至早于进行中项目的投标截止日:出赭色提醒小字,只提醒、不拦截保存;
* - 附件可以稍后补录。新增时选的文件在保存资质后再上传挂上。
*/
import { unwrap, uploadFile } from "@bid-doctor/api-client";
import { ScanText, X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, reactive, ref, watch, type Ref } from "vue";
import DateInput from "@/components/DateInput.vue";
import ImageViewer, { type ViewerImage } from "@/components/ImageViewer.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, apiOptions, errorTitle } from "@/lib/api";
import { extOf, fmtMonthDay, fmtSize, parseDay, QUALIFICATION_KIND, QUALIFICATION_KIND_ORDER } from "@/lib/labels";
import type { Qualification, QualificationScan, ScanCertificate } from "@/lib/queries";
export interface ActiveDeadline {
name: string;
/** YYYY-MM-DD(北京时间) */
day: string;
}
const props = defineProps<{ item: Qualification | null; deadlines: ActiveDeadline[]; focusExpiry?: boolean }>();
const open = defineModel<boolean>("open", { required: true });
/** saved:created / updated 是点了「保存资质」,files 是在对话框里加删附件 */
const emit = defineEmits<{ saved: [q: Qualification, how: "created" | "updated" | "files"] }>();
type Kind = Qualification["kind"];
type Key = "name" | "level" | "cert_no" | "issuer" | "holder" | "valid_from" | "valid_to";
const KIND_OPTIONS = QUALIFICATION_KIND_ORDER.map((k) => ({ value: k, label: QUALIFICATION_KIND[k] }));
const name = ref("");
const kind = ref<Kind>("certificate");
const level = ref("");
const certNo = ref("");
const issuer = ref("");
const holder = ref("");
const from = ref("");
const to = ref("");
const longTerm = ref(false);
const files = ref<Qualification["files"]>([]);
const pending = ref<File | null>(null);
const errs = ref<Record<string, string>>({});
const err = ref("");
const busy = ref(false);
const fileBusy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const toInput = ref<InstanceType<typeof DateInput> | null>(null);
const FIELD: Record<Key, Ref<string>> = { name, level, cert_no: certNo, issuer, holder, valid_from: from, valid_to: to };
/* ---------- 识别(vision-plan 第 2 步) ---------- */
const WHY: Record<string, string> = { low_conf: "可能被打码或看不清", mismatch: "和图上的文字对不上", bad_line: "没找到出处", bad_format: "看起来不像这一项" };
const scanInput = ref<HTMLInputElement | null>(null);
const scanFile = ref<File | null>(null);
const phase = ref<"idle" | "uploading" | "recognizing" | "done" | "failed">("idle");
const scanMsg = ref("");
const scan = ref<QualificationScan | null>(null);
const cert = ref<ScanCertificate | null>(null);
/** 识别填上的值(用户改过就不再标「识别」) */
const filled = reactive<Partial<Record<Key, string>>>({});
/** 识别出了、但不可靠的值:点「采用」才填 */
const hints = reactive<Partial<Record<Key, { value: string; why: string }>>>({});
const masked = ref<Key[]>([]);
/** 资质库里已有编号相同的一条,用户选了更新它 */
const target = ref<string | null>(null);
const focusKey = ref<Key | null>(null);
/** 缩略图点一下放大到对话框全宽,方便对照核对 */
const big = ref(false);
let timer: ReturnType<typeof setTimeout> | undefined;
let run = 0;
watch(open, (v) => {
if (!v) {
void dropScan();
return;
}
const q = props.item;
name.value = q?.name ?? "";
kind.value = q?.kind ?? "certificate";
level.value = q?.level ?? "";
certNo.value = q?.cert_no ?? "";
issuer.value = q?.issuer ?? "";
holder.value = q?.holder ?? "";
from.value = q?.valid_from ?? "";
to.value = q?.valid_to ?? "";
longTerm.value = !!q && !q.valid_to;
files.value = q?.files ?? [];
pending.value = null;
errs.value = {};
err.value = "";
resetScan();
if (props.focusExpiry) setTimeout(() => toInput.value?.select());
});
function resetScan() {
run++;
clearTimeout(timer);
phase.value = "idle";
scanMsg.value = "";
scanFile.value = null;
scan.value = null;
cert.value = null;
masked.value = [];
target.value = null;
focusKey.value = null;
big.value = false;
for (const k of Object.keys(filled) as Key[]) delete filled[k];
for (const k of Object.keys(hints) as Key[]) delete hints[k];
}
/** 丢弃识别结果(关掉对话框、重新上传)。已保存的资质里有证书照片的副本 */
async function dropScan() {
const s = scan.value;
resetScan();
if (s) await api.DELETE("/qualification-scans/{scan_id}", { params: { path: { scan_id: s.id } } }).catch(() => undefined);
}
async function onScanFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const f = input.files?.[0];
input.value = "";
if (!f) return;
await dropScan();
const my = run;
scanFile.value = f;
phase.value = "uploading";
try {
const done = await uploadFile(api, apiOptions, f, "qualification_file");
const s = await unwrap(api.POST("/qualification-scans", { body: { upload_id: done.id } }));
if (my !== run) return;
scan.value = s;
phase.value = "recognizing";
poll(my);
} catch (e) {
if (my !== run) return;
phase.value = "failed";
scanMsg.value = errorTitle(e);
}
}
function poll(my: number) {
timer = setTimeout(async () => {
if (my !== run || !scan.value) return;
try {
const s = await unwrap(api.GET("/qualification-scans/{scan_id}", { params: { path: { scan_id: scan.value.id } } }));
if (my !== run) return;
scan.value = s;
if (s.status === "succeeded") apply(s);
else if (s.status === "failed") {
phase.value = "failed";
scanMsg.value = s.error?.message ?? "识别出错了,请手动填写";
} else poll(my);
} catch (e) {
if (my !== run) return;
phase.value = "failed";
scanMsg.value = errorTitle(e);
}
}, 1200);
}
/** 识别结果填进表单:一张图按一张证书处理,只取第一张 */
function apply(s: QualificationScan) {
const c = s.certificates[0];
if (!c) {
phase.value = "failed";
scanMsg.value = "没有在图里认出证书,请换一张清晰完整的照片,或者手动填写";
return;
}
phase.value = "done";
cert.value = c;
const src: Record<Key, string> = {
name: "name",
level: "level",
cert_no: c.cert_no_field,
issuer: "issuer",
holder: "holder",
valid_from: "valid_from",
valid_to: "valid_to",
};
for (const key of Object.keys(src) as Key[]) {
const f = c.fields[src[key]];
if (!f) {
if (!props.item) FIELD[key].value = "";
continue;
}
const value = key === "name" ? c.name : key === "valid_from" || key === "valid_to" ? (f.day ?? f.value) : f.value;
if (f.check === "ok" || f.check === "date_ok") {
FIELD[key].value = value;
filled[key] = value;
} else {
if (!props.item) FIELD[key].value = "";
hints[key] = { value, why: WHY[f.check] ?? "请核对" };
}
}
masked.value = c.masked.map((m) => (Object.keys(src) as Key[]).find((k) => src[k] === m)).filter((k): k is Key => !!k);
kind.value = c.kind;
if (filled.valid_to) longTerm.value = false;
else if (c.kind === "software_copyright") longTerm.value = true; // 软著没有有效期(qualification-plan §3)
errs.value = {};
}
// 用户改过的字段不再标「识别」
for (const key of Object.keys(FIELD) as Key[]) {
watch(FIELD[key], (v) => {
if (filled[key] !== undefined && v !== filled[key]) delete filled[key];
});
}
function adopt(key: Key) {
const h = hints[key];
if (!h) return;
FIELD[key].value = h.value;
delete hints[key];
if (key === "valid_to") longTerm.value = false;
}
const notes = computed(() => {
const c = cert.value;
if (!c) return [];
const out: string[] = [];
const n = scan.value?.certificates.length ?? 0;
if (n > 1) out.push(`图里认出 ${n} 张证书,只填了第一张。`);
for (const x of c.notes) {
if (x.code === "personnel") out.push("这是人员证书,查标的资质匹配不使用。");
else if (x.code === "product") out.push("这是产品证书,查标的资质匹配不使用。");
else if (x.code === "business_license") out.push("营业执照一般不需要录进资质库。");
else out.push(`证书主体「${x.holder}」和本企业名称不一致,如果是曾用名或子公司请核对。`);
}
return out;
});
/** 发证日期(和图上文字对得上的):有效期起没写时给「填入」建议 */
const issued = computed(() => {
const f = cert.value?.fields.issued_on;
return f && (f.check === "ok" || f.check === "date_ok") ? (f.day ?? "") : "";
});
const existing = computed(() => (!props.item && cert.value?.existing) || null);
/** 缩略图上要画的框:当前字段的出处(识别时用的同一张图,按宽高换成比例) */
const page = computed(() => (cert.value ? scan.value?.pages.find((p) => p.page === cert.value!.page) ?? null : null));
const boxes = computed(() => {
const p = page.value;
const c = cert.value;
if (!p || !c || !focusKey.value) return [];
const k = focusKey.value === "cert_no" ? c.cert_no_field : focusKey.value;
return (c.fields[k]?.boxes ?? []).map(([x0, y0, x1, y1]) => ({
left: `${(x0! / p.width) * 100}%`,
top: `${(y0! / p.height) * 100}%`,
width: `${((x1! - x0!) / p.width) * 100}%`,
height: `${((y1! - y0!) / p.height) * 100}%`,
}));
});
/* ---------- 表单 ---------- */
/** 有效期至早于哪些进行中项目的投标截止日(截止日当天到期仍算有效) */
const late = computed(() => {
const day = longTerm.value ? "" : parseDay(to.value);
return day ? props.deadlines.filter((d) => day < d.day) : [];
});
function validate(): boolean {
const e: Record<string, string> = {};
if (!name.value.trim()) e.name = "请填写资质名称,和证书上的写法一致";
if (!certNo.value.trim()) e.certNo = "请填写证书编号,查标时用它核对";
const f = parseDay(from.value);
if (f === null) e.from = "日期写法不对,例如:2023-11-16";
if (!longTerm.value) {
const t = parseDay(to.value);
if (t === null) e.to = "日期写法不对,例如:2026-11-20";
else if (!t) e.to = "请填写有效期至;长期有效请打开下方开关";
else if (f && t <= f) e.to = "有效期至要晚于有效期起,请核对";
}
errs.value = e;
return !Object.keys(e).length;
}
async function attach(qid: string, file: File): Promise<Qualification> {
const done = await uploadFile(api, apiOptions, file, "qualification_file");
return unwrap(
api.POST("/qualifications/{qualification_id}/files", { params: { path: { qualification_id: qid } }, body: { upload_id: done.id } }),
);
}
const scanning = computed(() => phase.value === "uploading" || phase.value === "recognizing");
async function submit() {
if (!validate() || busy.value || scanning.value) return;
busy.value = true;
err.value = "";
const body = {
name: name.value.trim(),
kind: kind.value,
level: level.value.trim() || null,
cert_no: certNo.value.trim() || null,
issuer: issuer.value.trim() || null,
holder: holder.value.trim() || null,
valid_from: parseDay(from.value) || null,
valid_to: longTerm.value ? null : parseDay(to.value) || null,
// 从识别结果保存:识别值留痕,证书照片挂成附件
scan: scan.value && cert.value ? { scan_id: scan.value.id, index: 0 } : null,
};
const id = props.item?.id ?? target.value;
const how = id ? "updated" : "created";
try {
let q = id
? await unwrap(api.PUT("/qualifications/{qualification_id}", { params: { path: { qualification_id: id } }, body }))
: await unwrap(api.POST("/qualifications", { body }));
if (pending.value) {
try {
q = await attach(q.id, pending.value);
} catch (e) {
// 资质已经存好了(再点保存是修改,不会重复添加);附件没挂上时关掉对话框会让人以为都好了,所以留在对话框里
emit("saved", q, how);
err.value = `资质已保存,附件没有上传成功:${errorTitle(e)}。可以稍后在列表里补录。`;
return;
}
}
emit("saved", q, how);
open.value = false;
} catch (e) {
err.value = errorTitle(e);
} finally {
busy.value = false;
}
}
function pick() {
fileInput.value?.click();
}
async function onFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const f = input.files?.[0];
input.value = "";
if (!f) return;
if (!props.item) {
pending.value = f;
return;
}
fileBusy.value = true;
err.value = "";
try {
const q = await attach(props.item.id, f);
files.value = q.files;
emit("saved", q, "files");
} catch (e) {
err.value = errorTitle(e);
} finally {
fileBusy.value = false;
}
}
/** 附件在大图浏览里看(PDF 按页出预览图),不开新标签页 */
const viewerOpen = ref(false);
const viewerIndex = ref(0);
const viewerImages = ref<ViewerImage[]>([]);
async function view(fileId: string) {
if (!props.item) return;
try {
const p = await unwrap(
api.GET("/qualifications/{qualification_id}/files/{file_id}/previews", {
params: { path: { qualification_id: props.item.id, file_id: fileId } },
}),
);
viewerImages.value = p.pages.map((pg) => ({ url: pg.url, title: p.pages.length > 1 ? `${p.filename} 第 ${pg.page} 页` : p.filename }));
viewerIndex.value = 0;
viewerOpen.value = true;
} catch (e) {
err.value = errorTitle(e);
}
}
async function removeFile(fileId: string) {
if (!props.item) return;
fileBusy.value = true;
try {
await unwrap(
api.DELETE("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: props.item.id, file_id: fileId } } }),
);
files.value = files.value.filter((f) => f.id !== fileId);
emit("saved", { ...props.item, files: files.value }, "files");
} catch (e) {
err.value = errorTitle(e);
} finally {
fileBusy.value = false;
}
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">{{ item ? "编辑资质" : "添加资质" }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="submit">
<div class="scan">
<button v-if="page" type="button" class="thumb" :class="{ big }" :title="big ? '点击缩小' : '点击放大'" @click="big = !big">
<img :src="page.url" :alt="scanFile?.name ?? '证书'" />
<span v-for="(b, k) in boxes" :key="k" class="box" :style="b"></span>
</button>
<div class="scan-b">
<template v-if="phase === 'idle'">
<b>{{ item ? "换证:上传新证书照片,自动填写下面的内容" : "上传证书照片,自动填写下面的内容" }}</b>
<span class="cap muted">PDF / JPG / PNG / WEBP</span>
</template>
<template v-else-if="scanning">
<b role="status"><span class="spin" aria-hidden="true"></span>正在识别…</b>
<span class="cap muted">{{ scanFile?.name }}</span>
</template>
<template v-else-if="phase === 'done'">
<b>已自动填写,标「识别」的请对照证书核对</b>
<span class="cap muted">{{ scanFile?.name }} · 保存后挂成附件</span>
</template>
<p v-else class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ scanMsg }}</p>
<p v-for="(n, k) in notes" :key="k" class="hint-warn"><span class="ico w" aria-hidden="true">!</span>{{ n }}</p>
<p v-if="existing" class="hint-warn">
<span class="ico w" aria-hidden="true">!</span>
<span>
资质库里已有编号相同的「{{ existing.name }}」。
<button v-if="target !== existing.id" type="button" class="lnk" @click="target = existing.id">改为更新它</button>
<template v-else>保存会更新它。<button type="button" class="lnk" @click="target = null">改为另存</button></template>
</span>
</p>
</div>
<button type="button" class="btn sm" :disabled="scanning" @click="scanInput?.click()">
<ScanText class="ic" :stroke-width="1.5" />{{ phase === "idle" ? "上传" : "重新上传" }}
</button>
<input ref="scanInput" type="file" accept=".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp" hidden @change="onScanFile" />
</div>
<div class="form">
<div class="field span2" :class="{ err: errs.name }" @mouseenter="focusKey = 'name'" @focusin="focusKey = 'name'">
<label for="qd-name">名称 <small>(必填)</small><span v-if="filled.name !== undefined" class="tag ai">识别</span></label>
<input id="qd-name" v-model="name" class="input" maxlength="200" placeholder="和证书上的写法一致" autocomplete="off" />
<span v-if="errs.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.name }}</span>
<span v-else-if="hints.name" class="sug">识别为「{{ hints.name.value }}」,{{ hints.name.why }}<button type="button" class="lnk" @click="adopt('name')">采用</button></span>
</div>
<div class="field">
<label for="qd-kind">类型 <small>(必填)</small></label>
<SelectBox
id="qd-kind"
:model-value="kind"
:options="KIND_OPTIONS"
@update:model-value="(v) => (kind = v as Kind)"
/>
</div>
<div class="field" @mouseenter="focusKey = 'level'" @focusin="focusKey = 'level'">
<label for="qd-level">等级 <small>(选填)</small><span v-if="filled.level !== undefined" class="tag ai">识别</span></label>
<input id="qd-level" v-model="level" class="input" maxlength="40" placeholder="例如:CS3、二级" autocomplete="off" />
<span v-if="hints.level" class="sug">识别为「{{ hints.level.value }}」,{{ hints.level.why }}<button type="button" class="lnk" @click="adopt('level')">采用</button></span>
</div>
<div class="field" :class="{ err: errs.certNo }" @mouseenter="focusKey = 'cert_no'" @focusin="focusKey = 'cert_no'">
<label for="qd-no">证书编号 <small>(必填)</small><span v-if="filled.cert_no !== undefined" class="tag ai">识别</span></label>
<input id="qd-no" v-model="certNo" class="input num" maxlength="80" autocomplete="off" />
<span v-if="errs.certNo" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.certNo }}</span>
<span v-else-if="hints.cert_no" class="sug">识别为「{{ hints.cert_no.value }}」,{{ hints.cert_no.why }}<button type="button" class="lnk" @click="adopt('cert_no')">采用</button></span>
<span v-else-if="masked.includes('cert_no') && !certNo" class="sug">证书上被遮挡,请手动填写</span>
</div>
<div class="field" @mouseenter="focusKey = 'issuer'" @focusin="focusKey = 'issuer'">
<label for="qd-issuer">发证机构<span v-if="filled.issuer !== undefined" class="tag ai">识别</span></label>
<input id="qd-issuer" v-model="issuer" class="input" maxlength="120" autocomplete="off" />
<span v-if="hints.issuer" class="sug">识别为「{{ hints.issuer.value }}」,{{ hints.issuer.why }}<button type="button" class="lnk" @click="adopt('issuer')">采用</button></span>
</div>
<div class="field span2" @mouseenter="focusKey = 'holder'" @focusin="focusKey = 'holder'">
<label for="qd-holder">证书主体<span v-if="filled.holder !== undefined" class="tag ai">识别</span></label>
<input id="qd-holder" v-model="holder" class="input" maxlength="120" placeholder="证书上写的企业名或著作权人" autocomplete="off" />
<span v-if="hints.holder" class="sug">识别为「{{ hints.holder.value }}」,{{ hints.holder.why }}<button type="button" class="lnk" @click="adopt('holder')">采用</button></span>
<span v-else-if="masked.includes('holder') && !holder" class="sug">证书上被遮挡,请手动填写</span>
</div>
<div class="field" :class="{ err: errs.from }" @mouseenter="focusKey = 'valid_from'" @focusin="focusKey = 'valid_from'">
<label for="qd-from">有效期起<span v-if="filled.valid_from !== undefined" class="tag ai">识别</span></label>
<DateInput id="qd-from" v-model="from" label="有效期起" placeholder="例如:2023-11-16" />
<span v-if="errs.from" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.from }}</span>
<span v-else-if="hints.valid_from" class="sug">识别为「{{ hints.valid_from.value }}」,{{ hints.valid_from.why }}<button type="button" class="lnk" @click="adopt('valid_from')">采用</button></span>
<span v-else-if="!from && issued" class="sug">证书的发证日期是 <span class="fig">{{ issued }}</span><button type="button" class="lnk" @click="from = issued">填入</button></span>
</div>
<div class="field" :class="{ err: errs.to }" @mouseenter="focusKey = 'valid_to'" @focusin="focusKey = 'valid_to'">
<label for="qd-to">有效期至 <small v-if="!longTerm">(必填)</small><span v-if="filled.valid_to !== undefined" class="tag ai">识别</span></label>
<DateInput id="qd-to" ref="toInput" v-model="to" label="有效期至" :placeholder="longTerm ? '长期有效' : '例如:2026-11-20'" :disabled="longTerm" />
<span v-if="errs.to" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.to }}</span>
<span v-else-if="late.length" class="hint-warn">
<span class="ico w" aria-hidden="true">!</span>
<span>早于「{{ late[0]!.name }}」的投标截止 {{ fmtMonthDay(late[0]!.day) }}<template v-if="late.length > 1"> 等 <span class="fig">{{ late.length }}</span> 个项目</template></span>
</span>
<span v-else-if="hints.valid_to" class="sug">识别为「{{ hints.valid_to.value }}」,{{ hints.valid_to.why }}<button type="button" class="lnk" @click="adopt('valid_to')">采用</button></span>
<span v-else-if="!to && cert?.suggest.valid_to" class="sug">
证书写的有效期是「{{ cert.fields.validity_text?.value }}」<button type="button" class="lnk" @click="(to = cert.suggest.valid_to!), (longTerm = false)">按发证日期推算:<span class="fig">{{ cert.suggest.valid_to }}</span></button>
</span>
</div>
<div class="field span2">
<button
type="button"
class="switch"
role="switch"
:aria-checked="longTerm"
@click="
longTerm = !longTerm;
if (longTerm) to = '';
"
>
<span class="track" aria-hidden="true"></span>长期有效
</button>
</div>
<div class="field span2">
<label>证书扫描件 <small>(可以稍后补录)</small></label>
<div v-for="f in files" :key="f.id" class="file-row">
<span class="ext">{{ extOf(f.filename) }}</span>
<div><b>{{ f.filename }}</b><span class="cap muted">{{ fmtSize(f.size_bytes) }}</span></div>
<span class="acts">
<button type="button" class="btn sm" @click="view(f.id)">查看</button>
<button type="button" class="btn sm" :disabled="fileBusy" @click="removeFile(f.id)">删除</button>
</span>
</div>
<div v-if="phase === 'done' && scanFile" class="file-row">
<span class="ext">{{ extOf(scanFile.name) }}</span>
<div><b>{{ scanFile.name }}</b><span class="cap muted">{{ fmtSize(scanFile.size) }} · 识别用的照片,保存后挂上</span></div>
</div>
<div v-if="pending" class="file-row">
<span class="ext">{{ extOf(pending.name) }}</span>
<div><b>{{ pending.name }}</b><span class="cap muted">{{ fmtSize(pending.size) }} · 保存资质后上传</span></div>
<span class="acts"><button type="button" class="btn sm" @click="pending = null">移除</button></span>
</div>
<button v-if="!pending" type="button" class="drop" :aria-disabled="fileBusy" @click="pick">
<span>{{ fileBusy ? "正在上传…" : files.length || phase === "done" ? "再上传一个附件(PDF / JPG / PNG / WEBP)" : "点击上传 PDF / JPG / PNG / WEBP,单个不超过 20MB" }}</span>
</button>
<input ref="fileInput" type="file" accept=".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp" hidden @change="onFile" />
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy || scanning">
<span class="idle">{{ target && !item ? "更新资质" : "保存资质" }}</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
<ImageViewer v-model:open="viewerOpen" v-model:index="viewerIndex" :images="viewerImages" />
</template>
<style scoped>
/* 编号、日期用数字字体(同原型 .dt > .input) */
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.input.num::placeholder {
font-family: var(--font-sans);
font-size: var(--fs-body);
}
.file-row + .file-row,
.file-row + .drop {
margin-top: var(--sp-2);
}
.drop {
width: 100%;
padding: var(--sp-4);
font: inherit;
cursor: pointer;
}
.file-row .cap {
display: block;
}
.hint-warn {
margin: var(--sp-4) 0 0;
}
.field .hint-warn {
margin: var(--sp-1) 0 0;
}
/* 识别区:缩略图(字段的出处画在上面)+ 状态 + 上传按钮 */
.scan {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
padding: var(--sp-3);
border: var(--line) dashed var(--border-subtle);
border-radius: var(--radius-ctl);
}
.scan-b {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.scan-b b {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.scan-b .hint-warn {
margin: var(--sp-1) 0 0;
}
.thumb {
position: relative;
flex: none;
width: 168px;
padding: 0;
border: var(--line) solid var(--border-subtle);
border-radius: var(--radius-xs);
background: none;
overflow: hidden;
cursor: zoom-in;
}
.thumb.big {
order: 9;
width: 100%;
cursor: zoom-out;
}
.thumb img {
display: block;
width: 100%;
height: auto;
}
.box {
position: absolute;
border-radius: 1px;
background: var(--ai-soft);
box-shadow: 0 0 0 2px var(--ai);
mix-blend-mode: multiply;
pointer-events: none;
}
.spin {
width: 14px;
height: 14px;
border: 2px solid var(--border-subtle);
border-top-color: var(--ai);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
label .tag.ai {
margin-left: var(--sp-2);
vertical-align: 1px;
}
.sug {
display: block;
margin-top: var(--sp-1);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.sug .lnk {
margin-left: var(--sp-1);
}
</style>

View File

@@ -0,0 +1,220 @@
<script setup lang="ts">
/**
* 报告预览:用 pdf.js 把生成的 PDF 逐页画出来,连续滚动,宽度撑满。
* 不用浏览器自带的 PDF 查看器(iframe):Chrome 的是深色工具栏,改不了配色,桌面客户端里也各不一样;
* 这里和原文查看器(PdfViewer)同一套外观。只看不改;下载按钮放在 actions 插槽(顶栏里)。
* 页面多时只画滚到眼前的那几页(IntersectionObserver),改宽度时重画已画过的页。
*/
import { ChevronLeft, ChevronRight } from "@lucide/vue";
import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { markRaw, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
const props = defineProps<{ url?: string | null; label?: string }>();
const pdf = shallowRef<pdfjs.PDFDocumentProxy | null>(null);
const loadErr = ref("");
/** 每页按查看器宽度缩放后的尺寸;没画之前先用它占位,滚动条长度一开始就对 */
const sizes = ref<{ w: number; h: number }[]>([]);
const page = ref(1);
const body = ref<HTMLElement>();
const canvases: HTMLCanvasElement[] = [];
const drawn = new Map<number, number>(); // 页号 → 画的时候的宽度
const tasks = new Map<number, pdfjs.RenderTask>();
let io: IntersectionObserver | null = null;
let loadSeq = 0;
async function load(url: string | null | undefined) {
const seq = ++loadSeq;
reset();
await pdf.value?.loadingTask.destroy();
pdf.value = null;
loadErr.value = "";
if (!url) return;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(String(res.status));
const doc = await pdfjs.getDocument({ data: new Uint8Array(await res.arrayBuffer()) }).promise;
if (seq !== loadSeq) return void doc.loadingTask.destroy();
pdf.value = markRaw(doc);
page.value = 1;
await layout();
body.value?.scrollTo({ top: 0 });
} catch {
if (seq === loadSeq) loadErr.value = "报告加载失败,请刷新后重试";
}
}
function reset() {
for (const t of tasks.values()) t.cancel();
tasks.clear();
drawn.clear();
sizes.value = [];
}
/** 按当前宽度算每页尺寸,再观察每页、滚到眼前时才画 */
async function layout() {
const doc = pdf.value;
const el = body.value;
if (!doc || !el) return;
const width = Math.max(320, el.clientWidth - 32);
const out: { w: number; h: number }[] = [];
for (let n = 1; n <= doc.numPages; n++) {
const vp = (await doc.getPage(n)).getViewport({ scale: 1 });
out.push({ w: width, h: Math.round((vp.height * width) / vp.width) });
}
if (doc !== pdf.value) return;
sizes.value = out;
await nextTick();
io?.disconnect();
io = new IntersectionObserver(onIntersect, { root: el, rootMargin: "400px 0px" });
el.querySelectorAll(".page").forEach((p) => io!.observe(p));
}
function onIntersect(entries: IntersectionObserverEntry[]) {
for (const e of entries) if (e.isIntersecting) void draw(Number((e.target as HTMLElement).dataset.n));
}
async function draw(n: number) {
const doc = pdf.value;
const c = canvases[n - 1];
const size = sizes.value[n - 1];
if (!doc || !c || !size || drawn.get(n) === size.w) return;
drawn.set(n, size.w);
tasks.get(n)?.cancel();
const p = await doc.getPage(n);
const base = p.getViewport({ scale: 1 });
const vp = p.getViewport({ scale: size.w / base.width });
const dpr = window.devicePixelRatio || 1;
c.width = Math.floor(vp.width * dpr);
c.height = Math.floor(vp.height * dpr);
const task = p.render({ canvas: c, viewport: vp, transform: dpr === 1 ? undefined : [dpr, 0, 0, dpr, 0, 0] });
tasks.set(n, task);
try {
await task.promise;
} catch {
if (drawn.get(n) === size.w) drawn.delete(n); // 被取消的下次再画
} finally {
if (tasks.get(n) === task) tasks.delete(n);
}
}
/* ---------- 当前页(滚动时跟着变)、翻页 ---------- */
function onScroll() {
const el = body.value;
if (!el) return;
const mid = el.getBoundingClientRect().top + el.clientHeight / 3;
const pages = el.querySelectorAll<HTMLElement>(".page");
let cur = 1;
pages.forEach((p, i) => {
if (p.getBoundingClientRect().top <= mid) cur = i + 1;
});
page.value = cur;
}
function go(n: number) {
const el = body.value;
const target = el?.querySelectorAll<HTMLElement>(".page")[n - 1];
if (!el || !target) return;
el.scrollTo({ top: target.offsetTop - 16 });
}
watch(
() => props.url,
(u) => void load(u),
);
let ro: ResizeObserver | null = null;
let lastW = 0;
let timer = 0;
onMounted(() => {
void load(props.url);
ro = new ResizeObserver(([e]) => {
const w = Math.round(e?.contentRect.width ?? 0);
if (!w || Math.abs(w - lastW) < 8) return;
lastW = w;
clearTimeout(timer);
timer = window.setTimeout(() => {
const keep = page.value;
void layout().then(() => go(keep));
}, 120);
});
if (body.value) ro.observe(body.value);
});
onBeforeUnmount(() => {
ro?.disconnect();
io?.disconnect();
reset();
void pdf.value?.loadingTask.destroy();
});
</script>
<template>
<section class="viewer rv" aria-label="报告预览">
<div class="viewer-bar">
<span class="doc-name"><b>{{ label || "检查报告" }}</b></span>
<slot name="actions" />
<div class="pager">
<span v-if="sizes.length" class="fig">第 {{ page }} / {{ sizes.length }} 页</span>
<button type="button" class="icon-btn sm" aria-label="上一页" :disabled="page <= 1" @click="go(page - 1)">
<ChevronLeft class="ic" :stroke-width="1.5" />
</button>
<button type="button" class="icon-btn sm" aria-label="下一页" :disabled="page >= sizes.length" @click="go(page + 1)">
<ChevronRight class="ic" :stroke-width="1.5" />
</button>
</div>
</div>
<div ref="body" class="viewer-body" @scroll.passive="onScroll">
<slot v-if="!url" name="empty" />
<p v-else-if="loadErr" class="viewer-empty">{{ loadErr }}</p>
<p v-else-if="!sizes.length" class="viewer-empty">正在加载报告…</p>
<div v-for="(s, i) in sizes" :key="i" class="page" :data-n="i + 1" :style="{ width: `${s.w}px`, height: `${s.h}px` }">
<canvas :ref="(c) => (canvases[i] = c as HTMLCanvasElement)" :style="{ width: `${s.w}px`, height: `${s.h}px` }"></canvas>
</div>
</div>
</section>
</template>
<style scoped>
.rv {
height: 100%;
}
.doc-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.pager .fig {
margin-right: var(--sp-2);
font-size: var(--fs-figure-xs);
color: var(--text-secondary);
}
.viewer-body {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--sp-4);
}
.viewer-empty {
margin: var(--sp-8) var(--sp-6) 0;
color: var(--text-secondary);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
text-align: center;
}
.page {
margin: 0 auto;
background: var(--c-white);
box-shadow: var(--shadow-sheet);
}
.page + .page {
margin-top: var(--sp-4);
}
.page canvas {
display: block;
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
/**
* 下拉选择(规范 §7.21):触发框和输入框同规格,右侧 chevron,展开时旋转;
* 浮层白底、圆角 12,选中项加粗、右侧墨色 ✓。用 Reka UI 的 Select 打底(AR10),不用原生 <select>。
* - size="sm":高 32,用在筛选栏、行内(和旁边的搜索框一样高);
* - 值可以是空串或 null(「全部来源」「各包通用」):Reka 的选项不许空串,这里换成占位值再换回来。
*/
import { ChevronDown } from "@lucide/vue";
import { SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectPortal, SelectRoot, SelectTrigger, SelectViewport } from "reka-ui";
import { computed } from "vue";
export interface SelectOption {
value: string | null;
label: string;
disabled?: boolean;
}
defineOptions({ inheritAttrs: false }); // id、class、aria-label 给触发框(SelectRoot 本身不出元素)
const props = defineProps<{
modelValue: string | null;
options: SelectOption[];
disabled?: boolean;
size?: "sm";
}>();
const emit = defineEmits<{ "update:modelValue": [value: string | null] }>();
const NULL = "\u0000null";
const EMPTY = "\u0000empty";
const enc = (v: string | null) => (v === null ? NULL : v === "" ? EMPTY : v);
const dec = (v: string) => (v === NULL ? null : v === EMPTY ? "" : v);
const current = computed(() => props.options.find((o) => o.value === props.modelValue));
</script>
<template>
<SelectRoot :model-value="enc(modelValue)" :disabled="disabled" @update:model-value="(v) => emit('update:modelValue', dec(String(v)))">
<SelectTrigger v-bind="$attrs" class="select" :class="{ sm: size === 'sm' }">
<span class="val">{{ current?.label ?? "" }}</span>
<ChevronDown class="ic" :stroke-width="1.5" aria-hidden="true" />
</SelectTrigger>
<SelectPortal>
<SelectContent class="menu popper sel-menu" position="popper" align="start" :side-offset="4">
<SelectViewport>
<SelectItem v-for="o in options" :key="enc(o.value)" :value="enc(o.value)" :disabled="o.disabled">
<SelectItemText>{{ o.label }}</SelectItemText>
<SelectItemIndicator class="chk" aria-hidden="true">✓</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>

View File

@@ -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 };
@@ -37,6 +37,7 @@ function onEvent(qc: QueryClient, e: BdEvent) {
if (e.type === "job.finished") {
// 结束后要拿完整结果(error、result),并刷新项目、额度、清单
void qc.invalidateQueries({ queryKey: qk.parseJob(jobId) });
void qc.invalidateQueries({ queryKey: qk.notifications });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
if (e.project_id) {
@@ -47,9 +48,30 @@ function onEvent(qc: QueryClient, e: BdEvent) {
}
return;
}
if ((e.type === "job.progress" || e.type === "job.finished") && job && jobId && job.kind === "check_run") {
qc.setQueryData<CheckRun>(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.notifications });
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 });
void qc.invalidateQueries({ queryKey: qk.notifications });
break;
case "project.changed":
void qc.invalidateQueries({ queryKey: qk.projects });

View File

@@ -2,6 +2,10 @@
export const qk = {
me: ["me"] as const,
quota: ["quota"] as const,
/** 前缀和 quota 一样:额度一变,流水和申请跟着失效 */
quotaEntries: ["quota", "entries"] as const,
activationRequests: ["quota", "activation-requests"] as const,
notifications: ["notifications"] as const,
plans: ["plans"] as const,
projects: ["projects"] as const,
project: (id: string) => ["project", id] as const,
@@ -9,4 +13,16 @@ 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,
manualChecks: (projectId: string) => ["manual-checks", projectId] as const,
reports: (projectId: string) => ["reports", projectId] as const,
itemResults: (runId: string) => ["item-results", runId] as const,
qualifications: ["qualifications"] as const,
/** 前缀和 qualifications 一样:资质库一改,读标页的资质库情况跟着失效 */
qualificationMatches: (projectId: string) => ["qualifications", "matches", projectId] as const,
lots: (projectId: string) => ["lots", projectId] as const,
};

View File

@@ -138,7 +138,7 @@ export const NEXT_ACTION: Dict<Schemas["NextAction"]> = {
upload_bid: "上传投标文件",
run_check: "发起检查",
handle_fatal: "处理致命问题",
export_report: "导出检查报告",
export_report: "生成检查报告",
archive: "归档项目",
none: "查看项目",
};
@@ -155,8 +155,98 @@ 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<Schemas["VolumeKind"]> = {
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<Schemas["Severity"]> = { fatal: "致命", important: "重要", hint: "提示" };
export const SEVERITY_CLASS: Dict<Schemas["Severity"]> = { fatal: "fatal", important: "major", hint: "hint" };
export const FINDING_ACTION: Dict<Schemas["FindingStatus"]> = {
pending: "待处理",
confirmed_ok: "确认无误",
modified: "已修改",
ignored: "已忽略",
};
/** 每条清单项的检查结论(C9),读标页条目下面那一行 */
export const CHECK_VERDICT: Dict<Schemas["CheckItemResult"]["verdict"]> = {
responded: "已响应",
not_found: "未找到响应",
negative_deviation: "负偏离未说明",
suspected_unmet: "疑似不满足",
no_value: "未给出具体值",
undetermined: "未判定",
scanned: "文字部分没找到,请人工核对",
implicit_accepted: "按招标文件默认接受",
implicit_review: "默认接受,请人工核对",
};
export const READY_TIP = "只表示致命问题都已处理,不代表投标文件没有问题。";
export const QUALIFICATION_KIND_ORDER: readonly Schemas["QualificationKind"][] = ["certificate", "system_cert", "software_copyright", "other"];
export const QUALIFICATION_KIND: Dict<Schemas["QualificationKind"]> = {
certificate: "资质证书",
system_cert: "体系认证",
software_copyright: "软件著作权",
other: "其他",
};
export const qualificationKind = (s: string) => pick(QUALIFICATION_KIND, s);
/* ---------- 项目基本信息(FR-M2-03) ---------- */
export const FIELD_LABEL: Dict<Schemas["ProjectFieldKey"]> = {
name: "项目名称",
code: "项目编号",
purchaser: "采购人 / 招标人",
agency: "代理机构",
procurement_method: "采购方式",
legal_regime: "法律体系",
budget_total: "预算金额",
ceiling_total: "最高限价",
eval_method: "评标方法",
accept_consortium: "接受联合体",
accept_imported: "接受进口产品",
sme_only: "专门面向中小企业",
deadline_at: "投标截止时间",
};
export const FIELD_ORDER = Object.keys(FIELD_LABEL) as Schemas["ProjectFieldKey"][];
export const MONEY_FIELDS: ReadonlySet<string> = new Set(["budget_total", "ceiling_total"]);
export const BOOL_FIELDS: ReadonlySet<string> = new Set(["accept_consortium", "accept_imported", "sme_only"]);
/** 金额(元,字符串)显示成「286.00 万元」;不到一万的写元。数字来自招标文件原文,不是报价建议(红线 6) */
export function fmtYuan(v: string | null | undefined): string {
if (v == null || v === "") return "";
const n = Number(v);
if (!Number.isFinite(n)) return v;
return n >= 10000 ? `${(n / 10000).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 万元` : `${n.toLocaleString("zh-CN")} 元`;
}
/** 字段值的显示文字;没有值时为空串 */
export function fieldText(key: string, v: unknown): string {
if (v == null || v === "") return "";
if (MONEY_FIELDS.has(key)) return fmtYuan(String(v));
if (BOOL_FIELDS.has(key)) return v ? "是" : "否";
if (key === "legal_regime") return legalRegime(String(v));
if (key === "deadline_at") return fmtDateTimeFull(String(v));
return String(v);
}
/* ---------- 时间 ---------- */
const pad = (n: number) => String(n).padStart(2, "0");
export const fmtDate = (d: Date) => `${d.getMonth() + 1}月${d.getDate()}日`;
@@ -165,6 +255,45 @@ export const fmtDateTime = (iso: string) => {
const d = new Date(iso);
return `${fmtDate(d)} ${fmtTime(d)}`;
};
/** 日期(YYYY-MM-DD,无时区)显示成「2026年11月15日」 */
export const fmtDay = (day: string) => {
const [y, m, d] = day.split("-").map(Number);
return `${y}年${m}月${d}日`;
};
/** 近期的日期(投标截止日)省掉年份,显示成「11月20日」 */
export const fmtMonthDay = (day: string) => {
const [, m, d] = day.split("-").map(Number);
return `${m}月${d}日`;
};
/**
* 键入的日期整理成 YYYY-MM-DD(设计规范 Q10:可直接键入)。接受 2026-11-20、2026/11/20、2026.11.20、20261120、
* 2026年11月20日;空串返回 "",写错或不存在的日期返回 null。
*/
export function parseDay(text: string): string | null {
const t = text.trim();
if (!t) return "";
const m = /^(\d{4})(?:[-/.年]\s*(\d{1,2})[-/.月]\s*(\d{1,2})日?|(\d{2})(\d{2}))$/.exec(t);
if (!m) return null;
const [y, mo, d] = [Number(m[1]), Number(m[2] ?? m[4]), Number(m[3] ?? m[5])];
const dt = new Date(Date.UTC(y, mo - 1, d));
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null;
return `${y}-${pad(mo)}-${pad(d)}`;
}
/** 完整的日期时间:「2026年11月20日 09:30」(北京时间) */
export function fmtDateTimeFull(iso: string): string {
const d = new Date(iso);
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(d);
const get = (t: string) => parts.find((x) => x.type === t)?.value ?? "";
return `${get("year")}年${get("month")}月${get("day")}日 ${get("hour")}:${get("minute")}`;
}
export const fmtDuration = (ms: number) => {
const s = Math.max(0, Math.round(ms / 1000));
return { min: Math.floor(s / 60), sec: pad(s % 60) };

View File

@@ -1,6 +1,6 @@
/** 服务端数据:全部由 TanStack Query 管理,不放进 Pinia(架构 §4.2)。 */
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { useQuery } from "@tanstack/vue-query";
import { useInfiniteQuery, useQuery } from "@tanstack/vue-query";
import { computed, toValue, type MaybeRefOrGetter } from "vue";
import { api } from "./api";
import { qk } from "./keys";
@@ -21,6 +21,16 @@ export type ChecklistItem = Schemas["ChecklistItem"] & Required<Pick<Schemas["Ch
export type Citation = Schemas["Citation"];
export type MarkingRule = Schemas["MarkingRule"];
export type SessionInfo = Schemas["SessionInfo"];
export type CheckPreview = Schemas["CheckPreview"];
export type CheckRun = Schemas["CheckRun"];
export type Finding = Schemas["Finding"];
export type CheckItemResult = Schemas["CheckItemResult"];
export type ManualCheck = Schemas["ManualCheck"];
export type Qualification = Schemas["Qualification"];
export type QualificationScan = Schemas["QualificationScan"];
export type ScanCertificate = Schemas["ScanCertificate"];
export type Lot = Schemas["Lot"];
export type ProjectField = Schemas["ProjectField"];
export const fetchMe = () => unwrap(api.GET("/me"));
@@ -38,6 +48,34 @@ export function useQuotaAccount(unit: Schemas["QuotaUnit"]) {
return computed(() => q.data.value?.accounts.find((a) => a.unit === unit));
}
export type QuotaEntry = Schemas["QuotaEntry"];
export type ActivationRequest = Schemas["ActivationRequest"];
export type Notification = Schemas["Notification"];
/** 额度流水:按时间倒序,按游标一页页往后取(「加载更多」) */
export function useQuotaEntries() {
return useInfiniteQuery({
queryKey: qk.quotaEntries,
queryFn: ({ pageParam }) =>
unwrap(api.GET("/quota/entries", { params: { query: { limit: 50, ...(pageParam ? { cursor: pageParam } : {}) } } })),
initialPageParam: "",
getNextPageParam: (last) => last.next_cursor ?? undefined,
});
}
export function useActivationRequests() {
return useQuery({ queryKey: qk.activationRequests, queryFn: () => unwrap(api.GET("/activation-requests")) });
}
/** 站内通知:顶栏铃铛。事件流在任务结束时让它失效;另外每分钟兜底刷新一次(运营开通、到期提醒不走事件流) */
export function useNotifications() {
return useQuery({
queryKey: qk.notifications,
queryFn: () => unwrap(api.GET("/notifications", { params: { query: { limit: 30 } } })),
refetchInterval: 60_000,
});
}
export function usePlans() {
return useQuery({ queryKey: qk.plans, queryFn: () => unwrap(api.GET("/plans")), staleTime: 10 * 60_000 });
}
@@ -81,12 +119,105 @@ export function useChecklist(projectId: MaybeRefOrGetter<string>) {
});
}
export function useChecklistItems(projectId: MaybeRefOrGetter<string>) {
/** 清单项。scope:bidding = 通用 + 所投的包(默认,FR-M2-04),all = 全部;hidden_count 是没列出的条数 */
export function useChecklistItems(projectId: MaybeRefOrGetter<string>, scope: MaybeRefOrGetter<"bidding" | "all"> = "bidding") {
return useQuery({
queryKey: computed(() => qk.checklistItems(toValue(projectId))),
queryKey: computed(() => [...qk.checklistItems(toValue(projectId)), toValue(scope)] as const),
queryFn: () =>
unwrap(api.GET("/projects/{project_id}/checklist/items", { params: { path: { project_id: toValue(projectId) } } })) as Promise<{
items: ChecklistItem[];
}>,
unwrap(
api.GET("/projects/{project_id}/checklist/items", {
params: { path: { project_id: toValue(projectId) }, query: { lot_scope: toValue(scope) } },
}),
) as Promise<{ items: ChecklistItem[]; hidden_count: number }>,
});
}
export function useProjectDocuments(projectId: MaybeRefOrGetter<string>) {
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<string>) {
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<string>) {
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<string>) {
return useQuery({
queryKey: computed(() => qk.checkRun(toValue(id))),
queryFn: () => unwrap(api.GET("/check-runs/{run_id}", { params: { path: { run_id: toValue(id) } } })),
enabled: computed(() => !!toValue(id)),
refetchInterval: (query) => (query.state.data && TERMINAL_JOB.has(query.state.data.status) ? false : 5000),
});
}
export function useManualChecks(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.manualChecks(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/manual-checks", { params: { path: { project_id: toValue(projectId) } } })),
});
}
export function useFindings(runId: MaybeRefOrGetter<string>, enabled: MaybeRefOrGetter<boolean>) {
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)),
});
}
/** 每条清单项的检查结论(C9):「已响应」也带投标侧出处,读标页显示在条目下面 */
export function useItemResults(runId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.itemResults(toValue(runId))),
queryFn: () => unwrap(api.GET("/check-runs/{run_id}/item-results", { params: { path: { run_id: toValue(runId) } } })),
enabled: computed(() => !!toValue(runId)),
});
}
/** 项目的历次报告(新的在前) */
export function useReports(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.reports(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/reports", { params: { path: { project_id: toValue(projectId) } } })),
});
}
/** 报告文件的短时签名地址(预览、下载都现取,不缓存) */
export async function reportFileUrl(id: string): Promise<string> {
return (await unwrap(api.GET("/reports/{report_id}/file", { params: { path: { report_id: id } } }))).url;
}
/** 读标清单上每条资质要求在资质库里的情况(资质库空时没有条目) */
export type QualificationMatch = Schemas["QualificationMatch"];
export function useQualificationMatches(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.qualificationMatches(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/qualification-matches", { params: { path: { project_id: toValue(projectId) } } })),
});
}
/** 资质库:一次取全部(最小资质库条目不多),类型筛选和搜索在前端做 */
export function useQualifications() {
return useQuery({ queryKey: qk.qualifications, queryFn: () => unwrap(api.GET("/qualifications")) });
}
/** 分包(含默认包;界面上不显示默认包) */
export function useLots(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.lots(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/lots", { params: { path: { project_id: toValue(projectId) } } })),
});
}

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
/**
* 账号(m6-plan §1.5):企业名称、手机号(脱敏)、姓名、套餐;最下面注销账号(J4、data-model §6.2,决策 M6-8)。
* 注销:输入企业名称 + 短信验证码(发到自己的手机号)。全部项目、资质库彻底删除,剩余额度作废、不退款;
* 开通申请和发票记录按财务要求保留。注销后手机号可以重新注册。
*/
import { unwrap } from "@bid-doctor/api-client";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { useMe, useQuota } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "账号" }]);
const me = useMe();
const quota = useQuota();
const m = computed(() => me.data.value);
const plan = computed(() => quota.data.value?.plan ?? m.value?.plan);
const paidLeft = computed(() => plan.value && plan.value.code !== "free" && !plan.value.read_only);
const open = ref(false);
const name = ref("");
const code = ref("");
const err = ref<{ name?: string; code?: string; other?: string }>({});
const busy = ref(false);
const wait = ref(0);
let timer: number | undefined;
onBeforeUnmount(() => window.clearInterval(timer));
function ask() {
name.value = "";
code.value = "";
err.value = {};
open.value = true;
}
async function sendCode() {
try {
const r = await unwrap(api.POST("/me/sms-codes", { body: { purpose: "close_tenant" } }));
wait.value = r.resend_after_s;
window.clearInterval(timer);
timer = window.setInterval(() => (wait.value = Math.max(0, wait.value - 1)), 1000);
toast(`验证码已发送到 ${m.value?.user.phone_hint ?? "你的手机"}`);
} catch (e) {
err.value = { other: errorTitle(e) };
}
}
const router = useRouter();
const qc = useQueryClient();
async function close() {
const e: typeof err.value = {};
if (name.value.trim() !== m.value?.tenant.name) e.name = "企业名称不一致,请照上面的名称输入";
if (!/^\d{6}$/.test(code.value.trim())) e.code = "请填写 6 位验证码";
err.value = e;
if (Object.keys(e).length) return;
busy.value = true;
try {
await unwrap(api.POST("/tenant/close", { body: { sms_code: code.value.trim(), confirm_tenant_name: name.value.trim() } }));
open.value = false;
qc.clear();
await router.replace({ name: "login" });
toast("账号已注销,业务数据正在后台删除");
} catch (x) {
if (isApiError(x, "sms_code_invalid")) err.value = { code: "验证码不正确或已过期" };
else if (isApiError(x, "tenant_name_mismatch")) err.value = { name: "企业名称不一致,请照上面的名称输入" };
else err.value = { other: errorTitle(x) };
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="content narrow">
<div class="page-title">
<div>
<h1>账号</h1>
<p>一个企业一个账号;额度和数据都归属企业主体。</p>
</div>
</div>
<section class="card" aria-labelledby="h-info">
<div class="card-h"><h2 id="h-info">企业和登录</h2></div>
<dl v-if="m" class="kv">
<dt>企业名称</dt><dd>{{ m.tenant.name }}</dd>
<dt>手机号</dt><dd class="fig">{{ m.user.phone_hint || "—" }}</dd>
<dt>姓名</dt><dd>{{ m.user.display_name }}</dd>
<dt>套餐</dt>
<dd>
{{ plan?.name }}<template v-if="plan?.read_only">(已到期,只读)</template>
<RouterLink class="lnk cap" :to="{ name: 'quota' }">额度与套餐</RouterLink>
</dd>
</dl>
</section>
<section class="card danger" aria-labelledby="h-close">
<div class="card-h"><h2 id="h-close">注销账号</h2></div>
<p>注销后,企业下的全部项目(招标文件、投标文件、清单、检查结果、报告)和资质库都会<b>彻底删除,不能恢复</b>。需要留底的报告请先下载。</p>
<p class="cap muted">开通申请和发票记录按财务要求保留;额度流水和不含业务内容的操作记录保留。注销后这个手机号可以重新注册。</p>
<button type="button" class="btn" @click="ask">注销账号</button>
</section>
</div>
<ConfirmDialog v-model:open="open" title="注销账号?" ok-text="注销账号" busy-text="正在注销…" :busy="busy" @ok="close">
<p>企业「{{ m?.tenant.name }}」下的全部项目和资质库都会彻底删除,<b>不能恢复</b>。</p>
<p v-if="paidLeft" class="warn-text">专业版还没到期(到 <span class="fig">{{ plan?.ends_at?.slice(0, 10) }}</span>),剩余额度作废,不退款。</p>
<div class="field fill" :class="{ err: err.name }">
<label for="cl-name">输入企业名称确认</label>
<input id="cl-name" v-model="name" class="input" :placeholder="m?.tenant.name" autocomplete="off" @input="err.name = undefined" />
<span v-if="err.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.name }}</span>
</div>
<div class="field fill" :class="{ err: err.code }">
<label for="cl-code">短信验证码</label>
<div class="code-row">
<input id="cl-code" v-model="code" class="input num" maxlength="6" inputmode="numeric" autocomplete="one-time-code" @input="err.code = undefined" />
<button type="button" class="btn" :disabled="wait > 0" @click="sendCode">{{ wait > 0 ? `${wait} 秒后重发` : `发送到 ${m?.user.phone_hint ?? "手机"}` }}</button>
</div>
<span v-if="err.code" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.code }}</span>
</div>
<p v-if="err.other" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err.other }}</p>
</ConfirmDialog>
</template>
<style scoped>
.content > .card {
margin-bottom: var(--gap-card);
}
.kv .lnk {
margin-left: var(--sp-3);
}
.danger p {
margin: 0 0 var(--sp-3);
}
.field {
margin-top: var(--sp-3);
}
.code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.warn-text {
color: var(--warn-text);
}
.hint-warn {
margin: var(--sp-3) 0 0;
}
</style>

View File

@@ -0,0 +1,331 @@
<script setup lang="ts">
/**
* 上传投标文件并检查(原型 bid-check.html,FR-M3-01、02)。
* - 按分册上传;同一分册重新上传是新版本,检查默认用每个分册的最新版本;
* - 「检查范围」如实写会查什么、不查什么,报告首页照此写明(红线 3);
* - 扣点说明写在按钮旁边(红线 9),来自 check-preview;发起时带 expected_charge,和服务端算的不一致时不扣点(API4)。
*/
import { ApiError, unwrap, uploadFile, type Schemas } from "@bid-doctor/api-client";
import { Info, Plus, Upload } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ProjectHead from "@/components/ProjectHead.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, apiOptions, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDateTime, fmtSize, VOLUME_KIND, VOLUME_KINDS } from "@/lib/labels";
import { useCheckPreview, useProject, useQuotaAccount, type DocumentInfo } from "@/lib/queries";
import { platform } from "@/platform";
const props = defineProps<{ projectId: string }>();
const ACCEPT = [".pdf", ".docx", ".doc", ".wps"];
const router = useRouter();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const previewQ = useCheckPreview(() => props.projectId);
const parse = useQuotaAccount("parse");
const p = computed(() => project.data.value);
const pv = computed(() => previewQ.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "上传投标文件并检查" }]);
const vols = computed<DocumentInfo[]>(() => pv.value?.bid_documents ?? []);
/* ---------- 上传 ---------- */
const KIND_OPTIONS = VOLUME_KINDS.map((k) => ({ value: k, label: VOLUME_KIND[k] }));
const kind = ref<Schemas["VolumeKind"]>("whole");
/** 已经有分册时,新增分册的表单收起来,点「添加分册」再展开;默认选还没用过的类型 */
const adding = ref(false);
const showNew = computed(() => !vols.value.length || adding.value || !!uploading.value);
function startAdd() {
const used = new Set(vols.value.map((v) => v.volume_kind));
kind.value = VOLUME_KINDS.find((k) => k !== "whole" && !used.has(k)) ?? "other";
adding.value = true;
}
const volName = ref("");
const replacing = ref<DocumentInfo | null>(null);
const uploading = ref<{ name: string; ratio: number } | null>(null);
const upErr = ref("");
const over = ref(false);
async function upload(file: File) {
upErr.value = "";
uploading.value = { name: file.name, ratio: 0 };
try {
const done = await uploadFile(api, apiOptions, file, "project_document", (ratio) => {
uploading.value = { name: file.name, ratio };
});
const target = replacing.value;
await unwrap(
api.POST("/projects/{project_id}/documents", {
params: { path: { project_id: props.projectId } },
body: target
? { upload_id: done.id, role: "bid_volume", replaces_document_id: target.id }
: { upload_id: done.id, role: "bid_volume", volume_kind: kind.value, ...(volName.value.trim() ? { volume_name: volName.value.trim() } : {}) },
}),
);
volName.value = "";
replacing.value = null;
adding.value = false;
await qc.invalidateQueries({ queryKey: qk.checkPreview(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
} catch (e) {
// 格式不支持、加密、电子投标平台加密格式:在上传完成时就拦下,不扣点
const detail = e instanceof ApiError && e.problem.detail ? `(${e.problem.detail})` : "";
upErr.value = `${file.name}:${errorTitle(e)}${detail}`;
} finally {
uploading.value = null;
}
}
async function pick(target: DocumentInfo | null = null) {
if (uploading.value) return;
replacing.value = target;
const f = await platform.pickFile({ accept: ACCEPT });
if (f) void upload(f);
else replacing.value = null;
}
function onDrop(e: DragEvent) {
over.value = false;
const f = e.dataTransfer?.files[0];
if (f && !uploading.value) void upload(f);
}
/* ---------- 发起检查 ---------- */
const busy = ref(false);
const startErr = ref("");
const blockerText: Record<string, string> = {
no_parsed_tender: "还没有解析成功的招标文件",
checklist_empty: "正式清单还没有条目",
no_bid_documents: "先上传投标文件",
job_already_running: "这个项目正在检查",
quota_exhausted: "解析额度已用完",
project_read_only: "项目已递交或已归档",
};
const blockers = computed(() => (pv.value?.blockers ?? []).map((b) => blockerText[b] ?? b));
async function start() {
if (!pv.value?.can_start) return;
busy.value = true;
startErr.value = "";
try {
const run = await unwrap(
api.POST("/projects/{project_id}/check-runs", {
params: { path: { project_id: props.projectId } },
body: { document_ids: vols.value.map((d) => d.id), expected_charge: pv.value.charge },
}),
);
void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.projects });
await router.push({ name: "results", params: { projectId: props.projectId, runId: run.id } });
} catch (e) {
const running = isApiError(e, "job_already_running") ? e.problem.job_id : undefined;
if (typeof running === "string") {
await router.push({ name: "results", params: { projectId: props.projectId, runId: running } });
return;
}
if (isApiError(e, "billing_changed")) {
startErr.value = "扣点情况有变化(例如 24 小时免扣窗口刚好过了),已刷新说明,确认后再点一次。本次没有扣点。";
void qc.invalidateQueries({ queryKey: qk.checkPreview(props.projectId) });
} else {
startErr.value = `${errorTitle(e)}。本次没有扣点。`;
}
} finally {
busy.value = false;
}
}
</script>
<template>
<ProjectHead :project="p" tab="check" />
<div class="content narrow">
<div class="page-title">
<div>
<h1>上传投标文件并检查</h1>
<p>只读加密前的 Word / PDF 稿。每次上传都是新版本,检查结果对应到具体版本。</p>
</div>
</div>
<div v-if="pv && !pv.checklist_confirmed" class="alert" role="note">
<Info class="ic lg" :stroke-width="1.5" />
<p>清单还没确认(或确认后又改过)。这次检查以当前清单为准<template v-if="pv.pending_item_count">;待确认区的 <span class="fig">{{ pv.pending_item_count }}</span> 条不参与检查</template>。</p>
<RouterLink class="btn sm" :to="{ name: 'checklist', params: { projectId } }">去确认清单</RouterLink>
</div>
<form class="card flush" novalidate aria-labelledby="h-form" @submit.prevent="start">
<div class="card-h"><h2 id="h-form">投标文件分册</h2></div>
<div class="card-b form-b">
<div v-for="v in vols" :key="v.id" class="file-row">
<span class="ext">{{ v.detected_format.toUpperCase() }}</span>
<div>
<span class="name"><b>{{ v.label }}</b></span>
<span class="meta">{{ v.original_filename }}<template v-if="v.page_count"> · <span class="fig xs">{{ v.page_count }}</span> 页</template> · {{ fmtSize(v.size_bytes) }} · {{ fmtDateTime(v.created_at) }}</span>
</div>
<span class="acts"><button type="button" class="btn sm" :disabled="!!uploading" @click="pick(v)">上传新版本</button></span>
</div>
<button v-if="!showNew" type="button" class="btn sm add-vol" @click="startAdd"><Plus class="ic" :stroke-width="1.5" />添加分册</button>
<template v-if="showNew">
<div class="new-vol">
<div class="field">
<label for="vk">分册类型</label>
<SelectBox
id="vk"
:model-value="kind"
:options="KIND_OPTIONS"
:disabled="!!uploading"
@update:model-value="(v) => (kind = v as Schemas['VolumeKind'])"
/>
</div>
<div class="field">
<label for="vn">分册名称 <small>(选填)</small></label>
<input id="vn" v-model="volName" class="input" maxlength="40" placeholder="例如:技术册" autocomplete="off" :disabled="!!uploading" />
</div>
</div>
<div
class="drop"
:class="{ 'is-over': over }"
tabindex="0"
role="button"
aria-describedby="drop-sub"
@click="pick()"
@keydown.enter.prevent="pick()"
@keydown.space.prevent="pick()"
@dragenter.prevent="over = true"
@dragover.prevent="over = true"
@dragleave.prevent="over = false"
@drop.prevent="onDrop"
>
<Upload class="ic lg" :stroke-width="1.5" />
<b>{{ uploading ? `正在上传 ${uploading.name}(${Math.round(uploading.ratio * 100)}%)` : "拖入或点击上传分册" }}</b>
<span id="drop-sub">Word / PDF;电子投标客户端加密后的文件读不了,请上传加密前的稿子</span>
</div>
</template>
<p v-if="upErr" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span><span>{{ upErr }}</span></p>
<fieldset class="form-sec scope">
<legend>检查范围<small>报告首页会照此写明</small></legend>
<div>
<h3>会查</h3>
<ul>
<li>正式清单每一条在投标文件里有没有响应:未找到响应、负偏离未说明、疑似不满足</li>
<li>每条结论都带投标文件的原文出处;出处核对不上的,不算「已响应」</li>
<li>报价形式:大写和小写金额、分项报价合计、超过最高限价和分项 / 单价上限、另外给的报价</li>
<li>形式要件:别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应</li>
<li>资质:证书有效期、等级、有没有覆盖(按资质库和投标文件文字)</li>
</ul>
</div>
<div class="no">
<h3>这一版还不查</h3>
<ul>
<li>分项报价表和采购需求清单逐项对照、格式文件固定文字被改、授权代表和签字人一致、声明函填报、签章有无、低价说明、跨页的响应表、证书主体和投标人是否一致:还没有上线</li>
<li>签章真伪(以后也只看有无,不鉴定真伪)</li>
<li>份数、密封、CA 加密、递交方式:文件里看不出来,列入「需人工核对」,递交前自己核对</li>
<li>报价是否合理、该报多少,评分高低</li>
</ul>
</div>
</fieldset>
<p v-if="project.data.value?.bidding_lots?.length" class="cap muted lots-line">
本次查:通用条目 + {{ project.data.value.bidding_lots.map((n) => `采购包 ${n}`).join("、") }}
<RouterLink class="lnk" :to="{ name: 'lots', params: { projectId } }">更改所投的包</RouterLink>
</p>
<!-- AD4:补遗带来的新值没确认前查标用旧值;只提示,不阻止检查 -->
<div v-if="pv?.pending_updates" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>补遗带来的新值还有 <span class="fig">{{ pv.pending_updates }}</span> 处没确认,这次检查按原来的值。可以先在概览和读标页确认。</p>
<RouterLink class="btn sm" :to="{ name: 'overview', params: { projectId } }">去确认</RouterLink>
</div>
<div v-if="startErr" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ startErr }}</p></div>
</div>
<div class="card-f">
<span v-if="pv" class="cost">
<template v-if="pv.billing === 'charged'">扣 <span class="fig">1</span> 次解析额度;检查失败、中断或取消不扣点<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></template>
<template v-else-if="pv.billing === 'waived_recheck'">24 小时内复查,不扣额度<template v-if="pv.waive_until">(到 {{ fmtDateTime(pv.waive_until) }},复查不延长窗口)</template></template>
<template v-else>免费版:这个项目内检查不限次数</template>
<template v-if="blockers.length"> · <span class="hint-warn inline">{{ blockers.join(";") }}</span></template>
</span>
<RouterLink class="btn" :to="{ name: 'checklist', params: { projectId } }">取消</RouterLink>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :aria-busy="busy" :disabled="busy || !pv?.can_start">
<span class="idle">开始检查</span><span class="busy">正在提交…</span>
</button>
</div>
</form>
</div>
</template>
<style scoped>
.alert {
margin-bottom: var(--sp-4);
}
.card-b.form-b {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding-top: var(--sp-2);
padding-bottom: var(--sp-6);
}
.add-vol {
align-self: flex-start;
}
.file-row .name {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.file-row .meta {
display: block;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.new-vol {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: var(--sp-4);
}
.new-vol .field {
max-width: none;
}
.scope {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-6);
border: 0;
margin: var(--sp-2) 0 0;
padding: 0;
}
.scope legend {
grid-column: 1 / -1;
padding: 0;
font-weight: var(--fw-bold);
margin-bottom: var(--sp-2);
}
.scope legend small {
margin-left: var(--sp-2);
font-weight: var(--fw-regular);
color: var(--text-secondary);
}
.scope h3 {
margin: 0 0 var(--sp-1);
font-size: var(--fs-body);
}
.scope ul {
margin: 0;
padding-left: 1.2em;
}
.scope .no {
color: var(--text-secondary);
}
.card-f .cost {
margin-right: auto;
white-space: normal;
}
.hint-warn.inline {
display: inline;
}
</style>

View File

@@ -6,31 +6,52 @@
* - 待确认:采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃;正式清单:修改、补出处、删除(可撤销)、修改记录;
* - 补出处:查看器进入选句模式,点选同一页上的句子,建一条人工出处再挂到条目上;
* - 确认清单:生成不可变版本,查标以这一版为依据;之后再改会回到草稿,历史保留(FR-M2-08)。
* - 检查过的项目:每条下面写最近一次检查的结论(C9),点投标侧出处在右侧看投标原文;「已响应」也能反馈判断有误。
* - 资格条件、评分项里的证书要求:写资质库里有没有、在投标截止日是否有效(资质库空的不写)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { FileText, Info, Plus, Search } from "@lucide/vue";
import { useQuery } from "@tanstack/vue-query";
import { FileText, Info, Plus, Search, SlidersHorizontal } from "@lucide/vue";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from "reka-ui";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink } from "vue-router";
import { RouterLink, useRoute, useRouter } from "vue-router";
import ChecklistItemRow from "@/components/ChecklistItemRow.vue";
import CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import FeedbackDialog, { type FeedbackTarget } from "@/components/FeedbackDialog.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 SelectBox from "@/components/SelectBox.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 { useChecklist, useChecklistItems, useProject, type ChecklistItem, type Citation, type MarkingRule } from "@/lib/queries";
import { qk } from "@/lib/keys";
import { category, CATEGORY_ORDER, fmtDateTime, MARKER, projectStatus } from "@/lib/labels";
import {
useChecklist,
useChecklistItems,
useItemResults,
useLots,
useProject,
useQualificationMatches,
type ChecklistItem,
type Citation,
type MarkingRule,
} from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const project = useProject(() => props.projectId);
const checklist = useChecklist(() => props.projectId);
const itemsQ = useChecklistItems(() => props.projectId);
const lotScope = ref<"bidding" | "all">("bidding"); // 默认只列通用 + 所投的包(FR-M2-04)
const itemsQ = useChecklistItems(() => props.projectId, lotScope);
const lotsQ = useLots(() => props.projectId);
const realLots = computed(() => (lotsQ.data.value?.items ?? []).filter((l) => !l.is_default));
const biddingNos = computed(() => realLots.value.filter((l) => l.is_bidding).map((l) => `采购包 ${l.lot_no}`));
const docsQ = useQuery({
queryKey: computed(() => ["project-documents", props.projectId] as const),
queryFn: () => unwrap(api.GET("/projects/{project_id}/documents", { params: { path: { project_id: props.projectId } } })),
@@ -42,10 +63,30 @@ 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));
/* ---------- 最近一次检查的结论(C9):只看已完成的那次 ---------- */
const router = useRouter();
const runId = computed(() => p.value?.latest_check_run_id ?? "");
const resultsQ = useItemResults(runId);
const checkOf = computed(() => new Map((resultsQ.data.value?.items ?? []).map((r) => [r.checklist_item_id, r])));
function goFindings() {
if (runId.value) void router.push({ name: "results", params: { projectId: props.projectId, runId: runId.value } });
}
/* 资质库的情况:资质库是空的时没有条目,什么都不显示(QL3) */
const libQ = useQualificationMatches(() => props.projectId);
const libOf = computed(() => new Map((libQ.data.value?.items ?? []).map((x) => [x.checklist_item_id, x.families])));
const libAsOf = computed(() => (libQ.data.value?.by_deadline === false ? "今天" : "投标截止日"));
const fbFor = ref<FeedbackTarget | null>(null);
function askFeedback(i: ChecklistItem) {
fbFor.value = {
objectType: "checklist_item",
objectId: i.id,
title: i.title,
kind: "false_negative",
reasons: ["投标文件里其实没有响应这条", "引的那句不是这条的响应", "响应了,但不满足要求", "其他"],
note: "反馈只用于改进检查规则,不改变这次检查的结论。",
};
}
/* ---------- 分组与筛选 ---------- */
const all = computed(() => (itemsQ.data.value?.items ?? []).filter((i) => !i.deleted_at));
@@ -56,13 +97,35 @@ type Cat = "all" | "pending" | "deleted" | ChecklistItem["categories"][number];
const cat = ref<Cat>("all");
const kw = ref("");
const starOnly = ref(false);
/* 补遗(FR-M2-10):按来源文件筛选(解析页「核对补遗」带 ?source=<文档>),只看待确认的日期更新 */
const route = useRoute();
const source = ref(typeof route.query.source === "string" ? route.query.source : "");
const supOnly = ref(false);
const sources = computed(() => {
const m = new Map<string, { id: string; label: string; n: number }>();
for (const i of formal.value) {
const d = i.origin_document;
if (!d) continue;
const e = m.get(d.id) ?? { id: d.id, label: d.label, n: 0 };
e.n++;
m.set(d.id, e);
}
return [...m.values()].sort((a, b) => (a.label === "招标文件" ? -1 : b.label === "招标文件" ? 1 : a.label.localeCompare(b.label, "zh-CN")));
});
const addenda = computed(() => sources.value.filter((x) => x.label !== "招标文件"));
const supPending = (i: ChecklistItem) => !!i.superseded_by && !i.superseded_by.confirmed;
const supCount = computed(() => formal.value.filter(supPending).length);
const deletedQ = useDeletedItems(() => props.projectId, () => cat.value === "deleted");
const deleted = computed(() => deletedQ.data.value ?? []);
const catCount = (c: string) => formal.value.filter((i) => i.categories.includes(c as ChecklistItem["categories"][number])).length;
const cats = computed(() => CATEGORY_ORDER.filter((c) => catCount(c) > 0));
const match = (i: ChecklistItem) => (!starOnly.value || i.importance === "substantive") && (!kw.value || i.title.includes(kw.value.trim()));
const match = (i: ChecklistItem) =>
(!starOnly.value || i.importance === "substantive") &&
(!kw.value || i.title.includes(kw.value.trim())) &&
(!source.value || i.origin_document?.id === source.value) &&
(!supOnly.value || supPending(i));
/** 「全部」视图下每条只出现一次,放在它的第一个分类里(一条要求可以属于多个分类) */
const groups = computed(() => {
@@ -77,7 +140,7 @@ const groups = computed(() => {
.filter((g) => g.rows.length);
});
const listMode = computed(() => (cat.value === "pending" || cat.value === "deleted" ? cat.value : "formal"));
const showTop = computed(() => cat.value === "all" && !kw.value && !starOnly.value);
const showTop = computed(() => cat.value === "all" && !kw.value && !starOnly.value && !source.value && !supOnly.value);
const showPending = computed(() => (cat.value === "all" && showTop.value) || cat.value === "pending");
/** 当前视图里自上而下的条目(J / K 用) */
const visible = computed<ChecklistItem[]>(() => {
@@ -86,6 +149,19 @@ const visible = computed<ChecklistItem[]>(() => {
return cat.value === "pending" ? top : [...top, ...groups.value.flatMap((g) => g.rows)];
});
/** 确认「已被补遗 N 更新」:采用时旧条目删除(可以恢复),保留两条时解除关联(AD3) */
async function resolveSupersession(i: ChecklistItem, accept: boolean) {
try {
await unwrap(api.POST("/checklist-items/{item_id}/supersession", { params: { path: { item_id: i.id } }, body: { accept } }));
void qc.invalidateQueries({ queryKey: qk.checklistItems(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.checklist(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
toast(accept ? "已按补遗更新,旧条目已删除(可以在「已删除」里恢复)" : "两条都保留,请自己核对");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
/* ---------- 标注规则(如「打 ★ 的为实质性要求」)和数量核对 ---------- */
function ruleText(r: MarkingRule): string {
const mk = r.marker === "keyword" ? `「${r.marker_text ?? ""}」字样` : `「${r.marker_text || MARKER[r.marker]}」`;
@@ -111,6 +187,35 @@ function openCitation(c: Citation, tail: Citation[] = []) {
target.value = { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256, more };
}
const openGroup = (g: CitationGroup) => openCitation(g.head, g.tail);
/* ---------- 响应方式(C12):招标文件要求逐条响应,还是只列偏离、没列出的视为接受;查标按它判没找到响应算不算问题 ---------- */
const qc = useQueryClient();
const MODE_CATEGORY: Record<string, string> = { technical: "技术要求", commercial: "商务要求" };
const MODE_OPTIONS = [
{ value: "explicit", label: "逐条响应" },
{ value: "implicit", label: "只列偏离,没列出的视为接受" },
];
const MODE_STATUS: Record<string, string> = { unknown: "招标文件没写明,按逐条响应处理", conflict: "招标文件两处写法不一致,按逐条响应处理" };
const modeBusy = ref("");
// 响应方式一个项目设一次,平时收在清单栏的按钮里;招标文件没写明或两处不一致、又没人改过时,按钮上加提示点
const modeAttn = computed(() => (cl.value?.response_modes ?? []).some((m) => m.origin !== "human" && !!MODE_STATUS[m.status]));
async function setMode(category: "technical" | "commercial", mode: "explicit" | "implicit") {
modeBusy.value = category;
try {
await unwrap(
api.PUT("/projects/{project_id}/checklist/response-modes/{category}", {
params: { path: { project_id: props.projectId, category } },
body: { mode },
}),
);
await qc.invalidateQueries({ queryKey: qk.checklist(props.projectId) });
toast(`已改为「${mode === "implicit" ? "只列偏离,没列出的视为接受" : "逐条响应"}」,下次检查按这个判断`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
modeBusy.value = "";
}
}
function select(i: ChecklistItem, opt: { scroll?: boolean } = {}) {
if (picking.value && picking.value.item.id !== i.id) cancelPick();
selId.value = i.id;
@@ -245,49 +350,80 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</script>
<template>
<div class="reading">
<header class="ph">
<div class="ph-l1">
<div>
<h1 :title="p?.name">{{ p?.name ?? " " }}</h1>
<p class="ph-meta">{{ meta || "招标文件信息待解析" }}</p>
</div>
<dl v-if="p" class="facts">
<div>
<dt>投标截止</dt>
<dd>
<template v-if="due"><span class="fig m">{{ due.sub }}</span><span :class="due.cls || 'muted'">{{ due.main }}</span></template>
<span v-else class="muted">—</span>
</dd>
</div>
<div><dt>状态</dt><dd><span class="tag">{{ projectStatus(p.status) }}</span></dd></div>
</dl>
</div>
<!-- 骨架阶段只有「读标」一页;概览、查标、报告做到时在这里加标签页 -->
<nav class="tabs" aria-label="项目"><a role="tab" aria-selected="true" aria-current="page">读标</a></nav>
</header>
<ProjectHead :project="p" tab="checklist" />
<div class="content">
<div class="content split">
<div class="split-main">
<div class="cl-bar">
<h2>项目检查清单</h2>
<div v-if="cl" class="status">
<template v-if="cl.status === 'confirmed'">
<span class="tag">已确认 v{{ cl.version_no }}</span>
<span v-if="cl.confirmed_by" class="sign">
<div class="cl-title">
<h2>项目检查清单<span v-if="cl" class="tag">{{ cl.status === "confirmed" ? `已确认 v${cl.version_no}` : "草稿" }}</span></h2>
<p v-if="cl" class="status">
<span v-if="cl.status === 'confirmed' && cl.confirmed_by" class="sign cap">
<span aria-hidden="true">✓</span><b>{{ cl.confirmed_by.name }}</b><span v-if="cl.confirmed_at" class="fig">{{ fmtDateTime(cl.confirmed_at) }}</span><span>确认</span>
</span>
</template>
<template v-else>
<span class="tag">草稿</span>
<span class="cap muted">{{ cl.version_no ? `有修改,待重新确认 · 上一版 v${cl.version_no} 已确认` : "还没确认过;确认后,查标以这一版清单为依据" }}</span>
</template>
<span v-else-if="cl.status !== 'confirmed'" class="cap muted">{{ cl.version_no ? `有修改,待重新确认 · 上一版 v${cl.version_no} 已确认` : "还没确认过;确认后,查标以这一版清单为依据" }}</span>
</p>
</div>
<div v-if="cl && !readOnly" class="actions">
<div v-if="cl" class="actions">
<PopoverRoot v-if="cl.response_modes?.length">
<PopoverTrigger as-child>
<button type="button" class="btn mode-btn" :aria-label="modeAttn ? '响应方式(有待确认的)' : '响应方式'">
<SlidersHorizontal class="ic" :stroke-width="1.5" />响应方式<span v-if="modeAttn" class="dot" aria-hidden="true"></span>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent class="menu popper modes-pop" align="end" :side-offset="6" aria-label="招标文件的响应方式">
<p class="cap muted">招标文件要求逐条响应,还是只列偏离、没列出的视为接受。查标按它判断没找到响应算不算问题。</p>
<div v-for="m in cl.response_modes" :key="m.category" class="mode">
<label :for="`mode-${m.category}`">{{ MODE_CATEGORY[m.category] }}</label>
<SelectBox
:id="`mode-${m.category}`"
size="sm"
:model-value="m.mode"
:options="MODE_OPTIONS"
:disabled="readOnly || modeBusy === m.category"
@update:model-value="(v) => v !== m.mode && setMode(m.category, v as 'explicit' | 'implicit')"
/>
<div class="mode-meta">
<button v-if="m.citations.length" type="button" class="cite-src inline" @click="openCitation(m.citations[0]!)">依据 · 第 <span class="fig">{{ m.citations[0]!.page_no }}</span> 页</button>
<span v-if="m.origin === 'human'" class="sign cap"><span aria-hidden="true">✓</span><b>{{ m.set_by?.name }}</b><span>改过</span></span>
<span v-else-if="MODE_STATUS[m.status]" class="hint-warn cap">{{ MODE_STATUS[m.status] }}</span>
</div>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<template v-if="!readOnly">
<RouterLink v-if="tender" class="btn" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗</RouterLink>
<button type="button" class="btn" :disabled="!all.length" @click="openDialog('create')"><Plus class="ic" :stroke-width="1.5" />新增条目</button>
<button v-if="cl.status !== 'confirmed'" type="button" class="btn primary" :disabled="!formal.length" @click="confirmOpen = true">确认清单</button>
</template>
</div>
</div>
<div v-if="addenda.length" class="alert warn warn-row" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>
{{ addenda.map((x) => `${x.label} 带来 ${x.n} 条`).join(",") }}<template v-if="supCount">,{{ supCount }} 处日期等你确认</template>。标博士不判断哪些旧条目被补遗改了或作废了,请核对:改了的旧条目可以修改或删除。
</p>
<button v-for="x in addenda" :key="x.id" type="button" class="btn sm" @click="(source = x.id), (cat = 'all')">只看{{ x.label }}</button>
<button v-if="supCount" type="button" class="btn sm" @click="(supOnly = true), (cat = 'all')">看日期更新</button>
</div>
<div v-if="realLots.length > 1" class="lot-scope" aria-label="清单范围">
<span class="cap muted">采购包</span>
<template v-if="lotScope === 'bidding'">
<span>通用 + {{ biddingNos.join("、") }}</span>
<span v-if="itemsQ.data.value?.hidden_count" class="cap muted">· 不投的包隐藏 <span class="fig">{{ itemsQ.data.value.hidden_count }}</span> 条</span>
<button v-if="itemsQ.data.value?.hidden_count" type="button" class="lnk cap" @click="lotScope = 'all'">显示全部</button>
</template>
<template v-else>
<span>全部采购包的条目</span>
<button type="button" class="lnk cap" @click="lotScope = 'bidding'">只看所投的包</button>
</template>
<RouterLink v-if="!readOnly" class="lnk cap" :to="{ name: 'lots', params: { projectId } }">更改所投的包</RouterLink>
</div>
<div v-for="w in cl?.warnings ?? []" :key="w.code" class="alert warn warn-row" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ w.message }}</p>
@@ -297,8 +433,8 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
<p>项目已{{ projectStatus(p!.status) }},清单只读。要修改,先撤销{{ p!.status === "archived" ? "归档" : "递交" }}。</p>
</div>
<div class="cmp">
<section class="card flush cmp-list" aria-label="项目检查清单">
<div class="cap-edge" aria-hidden="true"></div>
<nav class="cats" aria-label="清单分类">
<button type="button" :aria-current="cat === 'all'" @click="cat = 'all'">全部<span class="fig">{{ formal.length }}</span></button>
<button v-for="c in cats" :key="c" type="button" :aria-current="cat === c" @click="cat = c">{{ category(c) }}<span class="fig">{{ catCount(c) }}</span></button>
@@ -328,6 +464,16 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
<input v-model="kw" class="input" placeholder="搜索清单" aria-label="搜索清单" />
</label>
<label class="choice cap"><input v-model="starOnly" type="checkbox" class="ck" />只看 ★</label>
<SelectBox
v-if="sources.length > 1"
:model-value="source"
size="sm"
class="src"
aria-label="来源"
:options="[{ value: '', label: '全部来源' }, ...sources.map((x) => ({ value: x.id, label: `${x.label}(${x.n})` }))]"
@update:model-value="(v) => (source = v ?? '')"
/>
<label v-if="supCount" class="choice cap"><input v-model="supOnly" type="checkbox" class="ck" />只看日期更新({{ supCount }})</label>
</div>
<template v-if="showTop">
@@ -372,10 +518,20 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
:item="i"
:selected="selId === i.id"
:active-citation="target?.id"
:check="checkOf.get(i.id)"
:library="libOf.get(i.id)"
:library-as-of="libAsOf"
@select="select(i)"
@open-citation="(g) => { selId = i.id; openGroup(g); }"
@open-bid="(c) => openCitation(c)"
@findings="goFindings"
@feedback="askFeedback(i)"
>
<template #actions>
<template v-if="!readOnly && supPending(i)">
<button type="button" class="btn sm" @click="resolveSupersession(i, true)">确认更新(删除旧条目)</button>
<button type="button" class="btn sm text" @click="resolveSupersession(i, false)">保留两条</button>
</template>
<template v-if="!readOnly">
<button type="button" class="btn sm" @click="openDialog('edit', i)">修改</button>
<button type="button" class="btn sm" @click="startPick(i, 'add')">补出处</button>
@@ -404,10 +560,11 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</template>
</div>
</section>
</div>
<aside class="split-doc" aria-label="原文">
<PdfViewer
v-model:picked="picked"
class="cmp-viewer"
:document-id="viewDoc?.id"
:document-label="viewDoc?.label"
:target="target"
@@ -429,12 +586,12 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</div>
</template>
</PdfViewer>
</div>
</div>
</aside>
</div>
<ItemDialog v-model:open="dlg.open" :mode="dlg.mode" :item="dlg.item" :default-category="cat !== 'all' && cat !== 'pending' && cat !== 'deleted' ? cat : undefined" @submit="onSubmit" @pick="onDialogPick" />
<ItemDialog v-model:open="dlg.open" :mode="dlg.mode" :item="dlg.item" :lots="realLots" :default-category="cat !== 'all' && cat !== 'pending' && cat !== 'deleted' ? cat : undefined" @submit="onSubmit" @pick="onDialogPick" />
<HistoryDialog v-model:open="hist.open" :item="hist.item" />
<FeedbackDialog :project-id="projectId" :target="fbFor" @close="fbFor = null" />
<ConfirmDialog v-model:open="confirmOpen" :title="`确认清单 v${nextVersion}?`" ok-text="确认清单" busy-text="正在确认…" :busy="confirming" @ok="doConfirm">
<p>确认后,查标以这一版清单为依据。之后再改清单会回到草稿,历史版本保留。</p>
<div v-if="pending.length" class="alert warn">
@@ -445,6 +602,61 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</template>
<style scoped>
.lst-tb :deep(.src) {
flex: none;
width: auto;
max-width: 200px;
}
.lot-scope {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: baseline;
margin-bottom: var(--sp-3);
}
/* 响应方式(C12):收在清单栏「响应方式」按钮的浮层里 */
.mode-btn {
position: relative;
}
.mode-btn .dot {
position: absolute;
top: 5px;
right: 5px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warn);
box-shadow: 0 0 0 2px var(--bg-surface);
}
[data-reka-popper-content-wrapper] :deep(.modes-pop) {
width: 380px;
max-height: none;
padding: var(--sp-4);
}
.modes-pop > p {
margin: 0;
}
.modes-pop .mode {
display: grid;
gap: var(--sp-1);
margin-top: var(--sp-4);
}
.modes-pop label {
font-size: var(--fs-caption);
color: var(--text-secondary);
}
.modes-pop .mode-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--sp-1) var(--sp-3);
}
.modes-pop .mode-meta:empty {
display: none;
}
.modes-pop .sign {
margin: 0;
}
/* 清单栏:标题 + 清单状态;右侧页面级操作(主按钮一个,Q12) */
.cl-bar {
display: flex;
@@ -452,20 +664,30 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.cl-title {
min-width: 0;
}
.cl-bar h2 {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
white-space: nowrap;
}
.cl-bar h2 .tag {
font-weight: var(--fw-regular);
}
.cl-bar .status {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 2px 0 0;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cl-bar .status .sign {
display: inline-flex;
flex-wrap: nowrap;
margin: 0;
}
.cl-bar .actions {
@@ -477,38 +699,51 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
.warn-row {
margin-bottom: var(--sp-3);
}
/* 整屏布局(原型 shell fit):页面不滚动,清单和原文查看器各自滚动 */
.reading {
display: flex;
flex-direction: column;
height: calc(100vh - var(--topbar-h));
}
.reading > .content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding-bottom: var(--sp-4);
}
.cmp {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(560px, 1fr) minmax(400px, 45%);
gap: var(--gap-card);
}
/* 左右布局(同查标、概览):清单随页面滚动,项目头和清单栏滚出屏幕,只留顶栏;
分类导航、搜索栏贴住(和右边原文区一样离顶栏 --sp-2),右边原文区贴住视口。
卡片不裁切(overflow: visible),里面的 sticky 才跟着页面走 */
.cmp-list {
min-height: 0;
--stick: calc(var(--topbar-h) + var(--sp-2) + var(--line));
overflow: visible;
}
/* 贴住时的卡片上沿:一条 0 高的 sticky 横条,画出圆角上边框;
外面一圈页面底色,盖住从顶栏和卡片之间滚过去的内容,圆角外也不露出底下的条目 */
.cap-edge {
grid-column: 1 / -1;
position: sticky;
top: var(--stick);
z-index: 3;
height: 0;
}
.cap-edge::before {
content: "";
position: absolute;
top: calc(-1 * var(--line));
left: calc(-1 * var(--line));
right: calc(-1 * var(--line));
height: calc(var(--radius-card) + var(--line));
border: var(--line) solid var(--border-subtle);
border-bottom: 0;
border-radius: var(--radius-card) var(--radius-card) 0 0;
box-shadow: 0 0 0 var(--sp-3) var(--bg-page);
clip-path: inset(calc(-1 * var(--sp-3)) calc(-1 * var(--sp-3)) 0);
pointer-events: none;
}
.cmp-list > .lst {
min-height: 0;
overflow: auto;
}
.cmp-viewer {
min-height: 0;
border-left: var(--line) solid var(--border-subtle);
}
.cats {
position: sticky;
top: var(--stick);
align-self: start;
max-height: calc(100vh - var(--stick));
overflow: auto;
border-right: 0;
}
/* J / K 翻到的条目不钻到顶栏和搜索栏下面 */
.lst :deep([data-item]) {
scroll-margin-top: calc(var(--topbar-h) + 72px);
scroll-margin-bottom: var(--sp-4);
}
.cats .hint {
margin: var(--sp-3) 0 0;
@@ -521,7 +756,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
}
.lst-tb {
position: sticky;
top: 0;
top: var(--stick);
z-index: 2;
display: flex;
align-items: center;
@@ -529,7 +764,6 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
padding: var(--sp-3) var(--card-x);
background: var(--bg-surface);
border-bottom: var(--line) solid var(--border-subtle);
border-top-right-radius: var(--radius-card);
}
.lst-tb .search {
flex: 1;

View File

@@ -0,0 +1,369 @@
<script setup lang="ts">
/**
* 选择要投的包(原型 lots.html,FR-M2-04):解析完成后识别到 2 个及以上的包时进入;概览和读标页「更改所投的包」也回到这里。
* - 清单只保留通用条目和所选包的条目,以后可以改;至少选一个包;
* - 识别得不对时手工调整(增删包、改包号 / 名称 / 预算 / 限价),或者一键「这个项目不分包」(PI2);
* 删包时原属这个包的条目归为通用(PI3);只有 1 个包时按不分包处理(PI4);
* - 限价是招标文件里的原文数字,不是系统给出的报价建议(红线 6)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { Info, Plus, Trash2, X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtYuan } from "@/lib/labels";
import { useChecklistItems, useLots, useProject, type Lot } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const router = useRouter();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const lotsQ = useLots(() => props.projectId);
const itemsQ = useChecklistItems(() => props.projectId, "all");
const p = computed(() => project.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目", to: { name: "overview", params: { projectId: props.projectId } } }, { label: "选择所投的包" }]);
const readOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
const lots = computed(() => (lotsQ.data.value?.items ?? []).filter((l) => !l.is_default));
const multi = computed(() => lots.value.length > 1);
/** 正式清单里各包的条目数;通用 = 没有归属的包 */
const counts = computed(() => {
const out = new Map<string | null, number>();
for (const i of itemsQ.data.value?.items ?? []) {
if (i.zone !== "formal") continue;
const k = i.lot_id ?? null;
out.set(k, (out.get(k) ?? 0) + 1);
}
return out;
});
const common = computed(() => counts.value.get(null) ?? 0);
const chosen = ref<Set<string>>(new Set());
watch(
lots,
(ls) => {
chosen.value = new Set(ls.filter((l) => l.is_bidding).map((l) => l.id));
},
{ immediate: true },
);
function toggle(id: string, on: boolean) {
const s = new Set(chosen.value);
if (on) s.add(id);
else s.delete(id);
chosen.value = s;
}
const shown = computed(() => common.value + lots.value.filter((l) => chosen.value.has(l.id)).reduce((a, l) => a + (counts.value.get(l.id) ?? 0), 0));
const hidden = computed(() => lots.value.filter((l) => !chosen.value.has(l.id)).reduce((a, l) => a + (counts.value.get(l.id) ?? 0), 0));
function refresh() {
void qc.invalidateQueries({ queryKey: qk.lots(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.checklistItems(props.projectId) });
}
const saving = ref(false);
async function saveChoice() {
if (!multi.value) {
await router.push({ name: "checklist", params: { projectId: props.projectId } });
return;
}
if (!chosen.value.size || saving.value) return;
saving.value = true;
try {
await unwrap(api.PUT("/projects/{project_id}/bidding-lots", { params: { path: { project_id: props.projectId } }, body: { lot_ids: [...chosen.value] } }));
refresh();
toast(`已选择 ${lots.value.filter((l) => chosen.value.has(l.id)).map((l) => `采购包 ${l.lot_no}`).join("、")}`);
await router.push({ name: "checklist", params: { projectId: props.projectId } });
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
saving.value = false;
}
}
/* ---------- 「这个项目不分包」 ---------- */
const clearOpen = ref(false);
const clearing = ref(false);
async function clearLots() {
clearing.value = true;
try {
await unwrap(api.POST("/projects/{project_id}/lots/clear", { params: { path: { project_id: props.projectId } } }));
clearOpen.value = false;
refresh();
toast("已改为不分包,条目都归为通用");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
clearing.value = false;
}
}
/* ---------- 手工调整 ---------- */
interface Row {
id: string | null;
lot_no: string;
name: string;
budget: string;
ceiling: string;
removed: boolean;
}
const fixOpen = ref(false);
const fixRows = ref<Row[]>([]);
const fixErr = ref("");
const fixBusy = ref(false);
const yuanText = (v: string | null | undefined) => (v ? String(Number(v)) : "");
function openFix() {
fixRows.value = lots.value.map((l) => ({ id: l.id, lot_no: l.lot_no, name: l.name ?? "", budget: yuanText(l.budget_amount), ceiling: yuanText(l.ceiling_amount), removed: false }));
if (!fixRows.value.length) addRow();
fixErr.value = "";
fixOpen.value = true;
}
function addRow() {
fixRows.value.push({ id: null, lot_no: String(fixRows.value.filter((r) => !r.removed).length + 1), name: "", budget: "", ceiling: "", removed: false });
}
const removedItems = computed(() => fixRows.value.filter((r) => r.removed && r.id).reduce((a, r) => a + (counts.value.get(r.id) ?? 0), 0));
async function saveFix() {
const live = fixRows.value.filter((r) => !r.removed);
const nos = live.map((r) => r.lot_no.trim());
if (nos.some((n) => !n)) return (fixErr.value = "包号不能为空");
if (new Set(nos).size !== nos.length) return (fixErr.value = "包号不能重复");
fixBusy.value = true;
fixErr.value = "";
const before = new Map(lots.value.map((l) => [l.id, l]));
try {
// 先删再改再加:改包号时不会和要删的包撞号
for (const r of fixRows.value.filter((x) => x.removed && x.id)) {
await unwrap(api.DELETE("/lots/{lot_id}", { params: { path: { lot_id: r.id! } } }));
}
for (const r of live.filter((x) => x.id)) {
const old = before.get(r.id!) as Lot;
const body: Record<string, string | null> = {};
if (r.lot_no.trim() !== old.lot_no) body.lot_no = r.lot_no.trim();
if (r.name.trim() !== (old.name ?? "")) body.name = r.name.trim();
if (r.budget.trim() !== yuanText(old.budget_amount)) body.budget_amount = r.budget.trim() || null;
if (r.ceiling.trim() !== yuanText(old.ceiling_amount)) body.ceiling_amount = r.ceiling.trim() || null;
if (Object.keys(body).length) await unwrap(api.PATCH("/lots/{lot_id}", { params: { path: { lot_id: r.id! } }, body }));
}
for (const r of live.filter((x) => !x.id)) {
await unwrap(
api.POST("/projects/{project_id}/lots", {
params: { path: { project_id: props.projectId } },
body: { lot_no: r.lot_no.trim(), name: r.name.trim() || undefined, budget_amount: r.budget.trim() || null, ceiling_amount: r.ceiling.trim() || null },
}),
);
}
fixOpen.value = false;
toast("已保存分包");
} catch (e) {
const detail = isApiError(e, "validation_failed") ? (e.problem as { errors?: { message: string }[] }).errors?.[0]?.message : undefined;
fixErr.value = detail ?? errorTitle(e);
} finally {
fixBusy.value = false;
refresh();
}
}
</script>
<template>
<ProjectHead :project="p" tab="overview" />
<div class="content lots-wrap">
<div class="page-title">
<div>
<h1>选择要投的包</h1>
<p v-if="multi">识别到 <span class="fig">{{ lots.length }}</span> 个采购包。清单只保留通用条目和所选包的条目,以后可以改。</p>
<p v-else-if="lots.length === 1">只识别到 1 个采购包,按不分包处理:清单里的条目都适用,这个包的限价照用。</p>
<p v-else>这个项目不分包,清单里的条目都适用。招标文件分了包而没有识别出来时,可以手工添加。</p>
</div>
</div>
<section class="card flush" aria-labelledby="h-lots">
<div class="card-h">
<h2 id="h-lots">采购包</h2>
<span v-if="!readOnly" class="r acts">
<button type="button" class="lnk cap" @click="openFix">{{ lots.length ? "识别得不对?手工调整" : "手工添加采购包" }}</button>
<button v-if="lots.length" type="button" class="lnk cap" @click="clearOpen = true">这个项目不分包</button>
</span>
</div>
<div class="card-b">
<div v-if="lots.length" class="grid" role="group" aria-labelledby="h-lots">
<label v-for="l in lots" :key="l.id" class="choice-card" :class="{ single: !multi }">
<input v-if="multi" type="checkbox" class="ck" :checked="chosen.has(l.id)" :disabled="readOnly" @change="toggle(l.id, ($event.target as HTMLInputElement).checked)" />
<span class="lot-main">
<b>采购包 {{ l.lot_no }}<template v-if="l.name"> · {{ l.name }}</template></b>
<small>专属条目 <span class="fig">{{ counts.get(l.id) ?? 0 }}</span> 条 · {{ l.origin === "human" ? "人工添加" : l.edited_fields.length ? "AI 识别,已修改" : "AI 识别" }}</small>
</span>
<span class="money">
<small>最高限价</small>
<template v-if="l.ceiling_amount">{{ fmtYuan(l.ceiling_amount) }}</template>
<span v-else class="muted">未识别</span>
</span>
</label>
</div>
<div class="common">
<Info class="ic" :stroke-width="1.5" />
<p>通用条目 <span class="fig">{{ common }}</span> 条(资格条件、无效情形、递交要求等)所有包都适用,始终保留。限价是招标文件里的原文数字,不是报价建议。</p>
</div>
</div>
<div class="card-f">
<span v-if="multi" class="sum" :class="{ warn: !chosen.size }">
<template v-if="chosen.size">清单 <span class="fig">{{ shown }}</span> 条:通用 + {{ lots.filter((l) => chosen.has(l.id)).map((l) => `采购包 ${l.lot_no}`).join("、") }}<template v-if="hidden"> · 隐藏 <span class="fig">{{ hidden }}</span> 条</template></template>
<template v-else><span class="ico w" aria-hidden="true">!</span> 至少选一个包</template>
</span>
<div class="r">
<button type="button" class="btn primary swap" :class="{ 'is-loading': saving }" :disabled="saving || (multi && !chosen.size) || (multi && readOnly)" @click="saveChoice">
<span class="idle">查看清单</span><span class="busy">正在保存…</span>
</button>
</div>
</div>
</section>
</div>
<ConfirmDialog v-model:open="clearOpen" title="改为不分包?" ok-text="改为不分包" busy-text="正在保存…" :busy="clearing" @ok="clearLots">
<p>删掉所有采购包,原属各包的条目都归为通用,检查时全部要查。把分项、标段说明误识别成包时用这个。修改会留痕。</p>
</ConfirmDialog>
<DialogRoot v-model:open="fixOpen">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">手工调整分包</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="saveFix">
<p class="muted lead">可以增加、删除采购包,改包号、名称、预算和限价。条目归属哪个包,在读标页逐条改。金额可写「286万」或「2,860,000」。</p>
<table class="data">
<thead><tr><th>包号</th><th>名称</th><th>预算(元)</th><th>最高限价(元)</th><th><span class="vh">删除</span></th></tr></thead>
<tbody>
<tr v-for="(r, i) in fixRows" v-show="!r.removed" :key="r.id ?? `new-${i}`">
<td class="no"><input v-model="r.lot_no" class="input" maxlength="40" :aria-label="`第 ${i + 1} 行包号`" /></td>
<td><input v-model="r.name" class="input" maxlength="200" :aria-label="`第 ${i + 1} 行名称`" /></td>
<td><input v-model="r.budget" class="input num" :aria-label="`第 ${i + 1} 行预算`" /></td>
<td><input v-model="r.ceiling" class="input num" :aria-label="`第 ${i + 1} 行最高限价`" /></td>
<td><button type="button" class="icon-btn sm" :aria-label="`删除第 ${i + 1} 行`" @click="r.removed = true"><Trash2 class="ic" :stroke-width="1.5" /></button></td>
</tr>
</tbody>
</table>
<button type="button" class="btn sm add" @click="addRow"><Plus class="ic" :stroke-width="1.5" />增加一个包</button>
<p v-if="removedItems" class="hint-warn"><span class="ico w" aria-hidden="true">!</span>删掉的包里有 <span class="fig">{{ removedItems }}</span> 条清单项,保存后归为通用。</p>
<p v-if="!fixRows.some((r) => !r.removed)" class="cap muted">一个包都不留,项目就改为不分包。</p>
<p v-if="fixErr" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ fixErr }}</p>
<div class="foot">
<span class="cap muted left">修改会留痕</span>
<DialogClose class="btn" type="button" :disabled="fixBusy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': fixBusy }" :disabled="fixBusy">
<span class="idle">保存</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.lots-wrap {
max-width: 880px;
}
.card-h .r {
margin-left: auto;
}
.acts {
display: flex;
gap: var(--sp-4);
}
.grid {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
/* 选择卡(原型 app.css 的 .choice-card) */
.choice-card {
display: flex;
gap: var(--sp-3);
align-items: flex-start;
padding: var(--sp-3) var(--sp-4);
border: var(--line) solid var(--border-control);
border-radius: var(--radius-ctl);
background: var(--bg-surface);
cursor: pointer;
}
.choice-card:hover {
background: var(--c-n50);
}
.choice-card:has(:checked) {
border-color: var(--c-ink);
box-shadow: inset 0 0 0 1px var(--c-ink);
}
.choice-card.single {
cursor: default;
}
.choice-card .ck {
margin-top: 3px;
}
.choice-card .lot-main {
flex: 1;
min-width: 0;
}
.choice-card b {
display: block;
}
.choice-card small {
display: block;
color: var(--text-secondary);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
}
.money {
text-align: right;
white-space: nowrap;
}
.common {
display: flex;
gap: var(--sp-2);
align-items: flex-start;
margin-top: var(--sp-4);
color: var(--text-secondary);
}
.common p {
margin: 0;
}
.card-f {
display: flex;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-3) var(--sp-5);
border-top: var(--line) solid var(--border-subtle);
}
.card-f .r {
margin-left: auto;
}
.sum.warn {
color: var(--warn-text);
}
.lead {
margin-top: 0;
}
.dialog td .input {
width: 100%;
}
.dialog td.no {
width: 88px;
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
}
.add,
.hint-warn {
margin-top: var(--sp-3);
}
</style>

View File

@@ -0,0 +1,442 @@
<script setup lang="ts">
/**
* 项目概览(原型 project.html,PI1):关键日期(时间线和倒计时,FR-M2-09)和采购包、项目要点(基本信息字段,
* 带出处,可改、可恢复 AI 值)、检查记录(历次检查的结果和报告)、文件(招标方的文件和投标文件的最新版本)。
* 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
*/
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { Pencil, Plus } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref, watchEffect } from "vue";
import { RouterLink } from "vue-router";
import DocStage, { type StageSlot } from "@/components/DocStage.vue";
import FieldDialog from "@/components/FieldDialog.vue";
import type { ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTime, fmtDateTimeFull, fmtYuan, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
import {
useCheckRuns,
useLots,
useProject,
useProjectDocuments,
useReports,
type Citation,
type DocumentInfo,
type ProjectField,
} from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const lotsQ = useLots(() => props.projectId);
const p = computed(() => project.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "概览" }]);
const readOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
const byKey = computed(() => new Map((p.value?.fields ?? []).map((f) => [f.key, f])));
/** 字段的当前值;没有字段记录的(建项目时填的名称、改过的截止时间等)取项目本身的值 */
const COLUMN: Partial<Record<string, keyof NonNullable<typeof p.value>>> = {
name: "name",
code: "code",
purchaser: "purchaser",
agency: "agency",
procurement_method: "procurement_method",
deadline_at: "deadline_at",
};
const rows = computed(() =>
FIELD_ORDER.map((key) => {
const f = byKey.value.get(key) ?? null;
const col = COLUMN[key];
// 名称:建项目时填的名称优先于 AI 抽到的(解析不覆盖用户填的名称),和项目头一致
const value =
key === "name" && p.value && f?.source !== "human" ? p.value.name : f && f.value != null ? f.value : col && p.value ? (p.value[col] as unknown) : null;
return { key, f, value };
}),
);
const realLots = computed(() => (lotsQ.data.value?.items ?? []).filter((l) => !l.is_default));
const dates = computed(() => (p.value?.key_dates ?? []).map((d) => ({ ...d, due: deadline(d.at) })));
const dlg = ref<{ open: boolean; key: Schemas["ProjectFieldKey"] | null; field: ProjectField | null }>({ open: false, key: null, field: null });
function edit(key: Schemas["ProjectFieldKey"], f: ProjectField | null) {
dlg.value = { open: true, key, field: f };
}
function onSaved(f: ProjectField, restored: boolean) {
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
toast(restored ? `已恢复${FIELD_LABEL[f.key]}的 AI 值` : `已修改${FIELD_LABEL[f.key]}`);
}
/* ---------- 补遗带来的新值:采用 / 不采用(AD3) ---------- */
const resolving = ref<string | null>(null);
async function resolvePending(key: Schemas["ProjectFieldKey"], accept: boolean) {
resolving.value = key;
try {
await unwrap(api.POST("/projects/{project_id}/fields/{field_key}/pending", { params: { path: { project_id: props.projectId, field_key: key } }, body: { accept } }));
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.checklistItems(props.projectId) });
toast(accept ? `已采用补遗的${FIELD_LABEL[key]}` : `保留原来的${FIELD_LABEL[key]}`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
resolving.value = null;
}
}
const canAddendum = computed(() => !!p.value && p.value.status !== "tracking" && !readOnly.value);
/* ---------- 检查记录:历次检查和对应的报告 ---------- */
const runsQ = useCheckRuns(() => props.projectId);
const runs = computed(() => runsQ.data.value?.items ?? []);
const reportsQ = useReports(() => props.projectId);
// 一次检查可能生成过很多份报告,这里只写份数、给一个入口,到「报告」标签页看
const reportCount = computed(() => {
const m = new Map<string, number>();
for (const r of reportsQ.data.value?.items ?? []) m.set(r.check_run_id, (m.get(r.check_run_id) ?? 0) + 1);
return m;
});
const RUN_STATE: Record<string, string> = { queued: "排队中", running: "检查中", failed: "检查失败", cancelled: "已取消" };
/* ---------- 文件:招标方的文件全列;投标文件每个分册只列最新版本 ---------- */
const tenderDocs = computed(() => (docsQ.data.value?.items ?? []).filter((d) => d.role !== "bid_volume"));
const bidDocs = computed(() => (docsQ.data.value?.items ?? []).filter((d) => d.role === "bid_volume" && d.is_latest_version !== false));
function parseState(d: DocumentInfo): string {
const s = d.latest_parse_job?.status;
if (s === "succeeded") return "解析成功";
if (s === "queued" || s === "running") return "解析中";
if (s === "failed") return "解析失败";
if (s === "cancelled") return "已取消解析";
return d.status === "failed" ? "读取失败" : "未解析";
}
/* 右侧原文(左右布局,同读标页):默认摆招标文件,点出处翻到那一页并高亮;补遗的出处换成那份补遗 */
const docsQ = useProjectDocuments(() => props.projectId);
const shown = ref<{ doc: { id: string; label: string } | null; target: (ViewerTarget & { id: string }) | null }>({ doc: null, target: null });
watchEffect(() => {
const t = docsQ.data.value?.items.find((d) => d.role === "tender" && d.status === "ready");
if (!shown.value.doc && t) shown.value.doc = { id: t.id, label: "招标文件" };
});
const slots = computed<StageSlot[]>(() => [{ key: "tender", label: "招标文件", ...shown.value, emptyText: "招标文件解析完成后,在这里看原文。" }]);
const side = ref("tender");
function openCitation(c: Citation) {
shown.value = { doc: { id: c.document_id, label: c.document_label }, target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 } };
}
</script>
<template>
<ProjectHead :project="p" tab="overview" />
<div class="content">
<div class="split">
<div class="split-main ov">
<!-- 关键日期和采购包:放在最上面,一张卡片左右两栏(右边是原文,左边不够放两列卡片) -->
<section class="card flush glance" aria-label="关键日期和采购包">
<div class="g-col" aria-labelledby="h-dates">
<div class="g-h"><h3 id="h-dates">关键日期</h3></div>
<p v-if="!dates.length" class="cap muted none">招标文件解析后,这里列出投标截止、开标等日期</p>
<ol v-else class="timeline">
<li v-for="(d, i) in dates" :key="i">
<span class="fig when">{{ fmtDateTimeFull(d.at) }}</span>
<b>{{ d.label }}</b>
<span v-if="d.edited" class="flag box">已修改</span>
<span v-if="d.due && d.due.cls !== 'due-past'" class="cap" :class="d.due.cls || 'muted'">{{ d.due.main }}</span>
<span v-if="d.superseded_pending" class="cap warn-text rep">已被补遗更新,待确认</span>
<span v-if="d.replaces" class="cap muted rep">已按{{ d.replaces.document_label }} 更新 · 原为 {{ d.replaces.at ? fmtDateTimeFull(d.replaces.at) : "—" }}</span>
</li>
</ol>
</div>
<div class="g-col" aria-labelledby="h-lots">
<div class="g-h">
<h3 id="h-lots">采购包</h3>
<RouterLink v-if="!readOnly" class="lnk cap r" :to="{ name: 'lots', params: { projectId } }">{{ realLots.length > 1 ? "更改所投的包" : "调整分包" }}</RouterLink>
</div>
<p v-if="!realLots.length" class="cap muted none">不分包:清单里的条目都适用</p>
<ul v-else class="lots">
<li v-for="l in realLots" :key="l.id" :class="{ off: realLots.length > 1 && !l.is_bidding }">
<b>采购包 {{ l.lot_no }}</b>
<span v-if="realLots.length > 1" class="tag">{{ l.is_bidding ? "投" : "不投" }}</span>
<span v-if="l.name" class="name">{{ l.name }}</span>
<span class="cap muted">
<template v-if="l.budget_amount">预算 {{ fmtYuan(l.budget_amount) }}</template>
<template v-if="l.budget_amount && l.ceiling_amount"> · </template>
<template v-if="l.ceiling_amount">最高限价 {{ fmtYuan(l.ceiling_amount) }}</template>
<template v-if="l.edited_fields.length"> · 已修改</template>
</span>
</li>
</ul>
<p v-if="realLots.length" class="cap muted note">限价是招标文件里的原文数字,不是报价建议。</p>
</div>
</section>
<section class="card flush" aria-labelledby="h-facts">
<div class="card-h">
<h2 id="h-facts">项目要点</h2>
<span class="cap muted r">查标按这里的值核对截止时间、限价和法律体系</span>
</div>
<dl class="facts-list">
<div v-for="{ key, f, value } in rows" :key="key" class="frow">
<dt>{{ FIELD_LABEL[key] }}</dt>
<dd>
<span v-if="value != null && value !== ''" :class="{ fig: key === 'deadline_at' }">{{ fieldText(key, value) }}</span>
<span v-else class="dash">没写明</span>
<span v-if="f?.source === 'human'" class="flag box">已修改</span>
<button v-if="f?.citation" type="button" class="cite-src inline" @click="openCitation(f.citation)">
<template v-if="f.citation.document_label && f.citation.document_label !== '招标文件'">{{ f.citation.document_label }} </template>第 <span class="fig">{{ f.citation.page_no }}</span> 页
</button>
<span v-if="f?.pending" class="pend-row">
<span class="cap warn-text">{{ f.pending.document_label }}:{{ fieldText(key, f.pending.value) }}<template v-if="value != null && value !== ''">(原为 {{ fieldText(key, value) }})</template>,没确认前查标用原值</span>
<button v-if="f.pending.citation" type="button" class="cite-src inline" @click="openCitation(f.pending.citation)">{{ f.pending.document_label }} 第 <span class="fig">{{ f.pending.citation.page_no }}</span> 页</button>
<template v-if="!readOnly">
<button type="button" class="btn sm" :disabled="resolving === key" @click="resolvePending(key, true)">采用</button>
<button type="button" class="btn sm text" :disabled="resolving === key" @click="resolvePending(key, false)">不采用</button>
</template>
</span>
</dd>
<button v-if="!readOnly" type="button" class="icon-btn sm" :aria-label="`修改${FIELD_LABEL[key]}`" @click="edit(key, f ?? { key, value: value as never, source: 'none' })">
<Pencil class="ic" :stroke-width="1.5" />
</button>
</div>
</dl>
</section>
<section class="card flush" aria-labelledby="h-runs">
<div class="card-h">
<h2 id="h-runs">检查记录</h2>
<RouterLink v-if="!readOnly && p && p.status !== 'tracking'" class="btn sm r" :to="{ name: 'bid-check', params: { projectId } }">
{{ runs.length ? "上传新版本并检查" : "上传投标文件并检查" }}
</RouterLink>
</div>
<p v-if="!runs.length" class="cap muted none pad">还没有检查过。上传投标文件后,每次检查的结果和生成的报告都列在这里。</p>
<table v-else class="data runs">
<thead>
<tr><th>批次</th><th>时间</th><th>投标文件</th><th>结果</th><th>报告</th></tr>
</thead>
<tbody>
<tr v-for="r in runs" :key="r.id">
<td>第 <span class="fig">{{ r.run_no }}</span> 次</td>
<td class="fig nowrap">{{ fmtDateTime(r.created_at) }}</td>
<td>{{ (r.documents ?? []).map((d) => d.label).join("、") || "—" }}</td>
<td>
<template v-if="r.status === 'succeeded'">
<!-- 只列有问题的级别:「致命 0」不该是红色 -->
<span class="sevs">
<template v-for="s in (['fatal', 'important', 'hint'] as const)" :key="s">
<span v-if="r.counts?.[s]" class="sev" :class="SEVERITY_CLASS[s]">{{ SEVERITY[s] }}<span class="fig">{{ r.counts[s] }}</span></span>
</template>
<span v-if="!r.counts?.fatal && !r.counts?.important && !r.counts?.hint" class="cap muted">没有发现问题</span>
</span>
<RouterLink class="lnk cap" :to="{ name: 'results', params: { projectId, runId: r.id } }">查看结果</RouterLink>
</template>
<RouterLink v-else class="lnk cap" :to="{ name: 'results', params: { projectId, runId: r.id } }">{{ RUN_STATE[r.status] ?? r.status }}</RouterLink>
</td>
<td>
<template v-if="reportCount.get(r.id)">
<span class="cap muted rep-n"><span class="fig">{{ reportCount.get(r.id) }}</span> 份</span>
<RouterLink class="lnk cap" :to="{ name: 'report', params: { projectId }, query: { run: r.id } }">查看报告</RouterLink>
</template>
<span v-else class="dash" aria-label="没有生成报告">—</span>
</td>
</tr>
</tbody>
</table>
</section>
<section class="card flush" aria-labelledby="h-files">
<div class="card-h">
<h2 id="h-files">文件</h2>
<RouterLink v-if="canAddendum" class="btn sm r" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗 / 澄清</RouterLink>
</div>
<div class="files">
<h3 class="cap muted">招标方</h3>
<p v-if="!tenderDocs.length" class="cap muted none">还没有上传招标文件</p>
<div v-for="d in tenderDocs" :key="d.id" class="file-row">
<span class="ext">{{ d.detected_format.toUpperCase() }}</span>
<div>
<b>{{ d.label }}</b>
<span class="cap muted">{{ d.original_filename }}<template v-if="d.page_count"> · <span class="fig">{{ d.page_count }}</span> 页</template> · {{ parseState(d) }}</span>
</div>
<span class="acts"><RouterLink class="btn sm text" :to="{ name: 'checklist', params: { projectId } }">读标</RouterLink></span>
</div>
<h3 class="cap muted">投标文件(加密前的 Word / PDF 稿)</h3>
<p v-if="!bidDocs.length" class="cap muted none">还没有上传投标文件</p>
<div v-for="d in bidDocs" :key="d.id" class="file-row">
<span class="ext">{{ d.detected_format.toUpperCase() }}</span>
<div>
<b>{{ d.label }}</b>
<span class="cap muted"><template v-if="d.page_count"><span class="fig">{{ d.page_count }}</span> 页 · </template>{{ fmtDateTime(d.created_at) }} 上传</span>
</div>
</div>
</div>
</section>
</div>
<aside class="split-doc" aria-label="原文">
<DocStage v-model:active="side" :slots="slots" />
</aside>
</div>
</div>
<FieldDialog v-model:open="dlg.open" :project-id="projectId" :field-key="dlg.key" :field="dlg.field" @saved="onSaved" />
</template>
<style scoped>
/* 左边:项目要点,下面采购包和关键日期并排;右边原文 */
.ov {
display: flex;
flex-direction: column;
gap: var(--gap-card);
}
.ov > .card {
margin: 0;
}
.glance {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
}
.g-col {
min-width: 0;
padding: var(--sp-4) var(--card-x);
}
.g-col + .g-col {
border-left: var(--line) solid var(--border-subtle);
}
.g-h {
display: flex;
align-items: baseline;
margin-bottom: var(--sp-2);
}
.g-h h3 {
margin: 0;
font-size: var(--fs-body);
font-weight: var(--fw-bold);
}
.g-h .r {
margin-left: auto;
}
.card-h .r {
margin-left: auto;
}
.pad {
padding: 0 var(--card-x) var(--sp-4);
}
.runs {
margin: 0;
}
.runs td {
vertical-align: baseline;
}
.runs .nowrap {
white-space: nowrap;
}
.rep-n {
margin-right: var(--sp-2);
white-space: nowrap;
}
.sevs {
display: inline-flex;
gap: var(--sp-1);
margin-right: var(--sp-2);
}
.files {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: 0 var(--card-x) var(--sp-4);
}
.files h3 {
margin: var(--sp-2) 0 0;
font-weight: var(--fw-regular);
}
.files .file-row b {
font-weight: var(--fw-medium);
}
.files .file-row .cap {
display: block;
}
.facts-list {
margin: 0;
}
.frow {
display: grid;
grid-template-columns: 140px minmax(0, 1fr) auto;
gap: var(--sp-4);
align-items: center;
padding: var(--sp-3) var(--sp-5);
border-top: var(--line) solid var(--border-subtle);
}
.frow dt {
color: var(--text-secondary);
}
.frow dd {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: baseline;
margin: 0;
}
.frow .icon-btn {
color: var(--text-secondary);
}
.frow:hover .icon-btn {
color: var(--text-primary);
}
.warn-text {
color: var(--warn-text);
}
.pend-row {
display: flex;
flex-basis: 100%;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: baseline;
}
.timeline .rep {
flex-basis: 100%;
}
.none,
.note {
margin: 0;
}
.note {
margin-top: var(--sp-3);
}
.lots,
.timeline {
margin: 0;
padding: 0;
list-style: none;
}
.lots li {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
align-items: baseline;
padding: var(--sp-2) 0;
}
.lots li + li {
border-top: var(--line) solid var(--border-subtle);
}
.lots li.off {
color: var(--text-secondary);
}
.lots .name {
flex-basis: 100%;
}
.lots .cap {
flex-basis: 100%;
}
.timeline li {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-3);
align-items: baseline;
padding: var(--sp-1) 0;
}
.timeline .when {
min-width: 11em;
font-size: var(--fs-figure-sm);
}
</style>

View File

@@ -110,13 +110,15 @@ async function retry() {
}
const result = computed(() => j.value?.result ?? null);
/** 补遗 / 澄清(FR-M2-10):结果按补遗写,下一步去读标页核对这份补遗的条目 */
const isAddendum = computed(() => !!doc.data.value && doc.data.value.role !== "tender");
</script>
<template>
<div class="content narrow">
<div class="page-title">
<div>
<h1>解析招标文件</h1>
<h1>{{ isAddendum ? `解析${doc.data.value?.label}` : "解析招标文件" }}</h1>
<p v-if="doc.data.value">
{{ doc.data.value.original_filename }}<template v-if="doc.data.value.page_count"> · <span class="fig">{{ doc.data.value.page_count }}</span> 页</template>
</p>
@@ -139,6 +141,10 @@ const result = computed(() => j.value?.result ?? null);
<span class="skel t"></span><span class="skel"></span><span class="skel"></span>
</template>
<!-- 事件流先报完成、结果随后由重新拉取带回:没到之前不显示「0 条」 -->
<template v-else-if="status === 'succeeded' && !result">
<span class="skel t"></span><span class="skel"></span>
</template>
<template v-else-if="status === 'succeeded'">
<dl class="kv result">
<dt>提取要求</dt>
@@ -147,7 +153,14 @@ const result = computed(() => j.value?.result ?? null);
· 其中 <span class="fig">{{ result.items_pending }}</span> 条没找到可靠出处或把握不大,放在「待确认」里</template>
</dd>
<template v-if="result?.items_updated"><dt>更新</dt><dd><span class="fig">{{ result.items_updated }}</span> 条</dd></template>
<template v-if="result?.lots_found"><dt>采购包</dt><dd>识别到 <span class="fig">{{ result.lots_found }}</span> 个</dd></template>
<template v-if="isAddendum">
<dt>日期</dt>
<dd v-if="result?.dates_superseded"><span class="fig">{{ result.dates_superseded }}</span> 处日期有新值,旧条目标「已被{{ doc.data.value?.label }} 更新」,等你确认</dd>
<dd v-else>没有改动已有的日期</dd>
<template v-if="result?.fields_pending"><dt>项目信息</dt><dd><span class="fig">{{ result.fields_pending }}</span> 个字段有新值,在概览里采用或不采用</dd></template>
<dt>清单</dt><dd>回到草稿。标博士不判断哪些旧条目被补遗改了或作废了,核对后重新确认</dd>
</template>
<template v-else-if="result?.lots_found"><dt>采购包</dt><dd>识别到 <span class="fig">{{ result.lots_found }}</span> 个</dd></template>
</dl>
</template>
@@ -190,7 +203,10 @@ const result = computed(() => j.value?.result ?? null);
<span class="r"><RouterLink class="btn sm" :to="{ name: 'workbench' }">回工作台,完成后通知我</RouterLink></span>
</template>
<template v-else-if="status === 'succeeded'">
<RouterLink class="btn primary" :to="{ name: 'checklist', params: { projectId } }">查看检查清单</RouterLink>
<!-- 识别到 2 个及以上的包时先选包(FR-M2-04);只有 1 个包按不分包处理(PI4) -->
<RouterLink v-if="isAddendum" class="btn primary" :to="{ name: 'checklist', params: { projectId }, query: { source: doc.data.value?.id } }">核对补遗</RouterLink>
<RouterLink v-else-if="(result?.lots_found ?? 0) > 1" class="btn primary" :to="{ name: 'lots', params: { projectId } }">选择要投的包</RouterLink>
<RouterLink v-else class="btn primary" :to="{ name: 'checklist', params: { projectId } }">查看检查清单</RouterLink>
<span class="r"><span class="cost">已结算 <span class="fig">1</span> 次解析 · {{ j.finished_at ? fmtDateTime(j.finished_at) : "" }}</span></span>
</template>
<template v-else>

View File

@@ -4,6 +4,8 @@
* 选好文件就开始分片上传(格式、加密、页数在上传完成时由服务端拦截,不扣点);
* 「开始解析」:建项目 → 把文件挂到项目上 → 发起解析(预扣 1 次,带 expected_charge,API4)。
* 带 ?project=<id> 时给已有的项目补传招标文件(例如上一次上传失败),不再建新项目。
* 再带 &addendum=1 时是追加补遗 / 澄清(FR-M2-10,原型 project-new.html?addendum=1):不填项目名称,文件类型选补遗或澄清答复,
* 单独解析、扣 1 次;新条目标注「补遗 N」并入清单,标博士不判断哪些旧条目已作废,并入后由用户核对。
*/
import { ApiError, unwrap, uploadFile, type Schemas } from "@bid-doctor/api-client";
import { Upload, X } from "@lucide/vue";
@@ -30,7 +32,14 @@ const parse = useQuotaAccount("parse");
const projectId = ref(typeof route.query.project === "string" ? route.query.project : "");
const existing = useProject(() => (route.query.project ? projectId.value : undefined));
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: existing.data.value ? `${existing.data.value.name} · 上传招标文件` : "新建项目" }]);
const addendum = computed(() => !!route.query.project && route.query.addendum === "1");
const role = ref<"addendum" | "clarification">("addendum");
const docWord = computed(() => (addendum.value ? "补遗 / 澄清文件" : "招标文件"));
useCrumbs(() =>
addendum.value
? [{ label: "工作台", to: { name: "workbench" } }, { label: existing.data.value?.name ?? "项目", to: { name: "overview", params: { projectId: projectId.value } } }, { label: "追加补遗" }]
: [{ label: "工作台", to: { name: "workbench" } }, { label: existing.data.value ? `${existing.data.value.name} · 上传招标文件` : "新建项目" }],
);
const limits = computed(() => plans.data.value?.items.find((p) => p.code === me.data.value?.plan.code)?.limits);
const name = ref("");
@@ -90,7 +99,7 @@ const startErr = ref("");
async function start() {
if (up.value.kind !== "done") {
fileErr.value = up.value.kind === "uploading" ? "文件还在上传,传完再开始解析" : "请先上传招标文件";
fileErr.value = up.value.kind === "uploading" ? "文件还在上传,传完再开始解析" : `请先上传${docWord.value}`;
return;
}
const uploadId = up.value.result.id;
@@ -107,7 +116,7 @@ async function start() {
const d = await unwrap(
api.POST("/projects/{project_id}/documents", {
params: { path: { project_id: projectId.value } },
body: { upload_id: uploadId, role: "tender" },
body: { upload_id: uploadId, role: addendum.value ? role.value : "tender" },
}),
);
documentId.value = d.id;
@@ -139,8 +148,9 @@ async function start() {
<div class="content narrow">
<div class="page-title">
<div>
<h1>{{ existing.data.value ? "上传招标文件" : "新建项目" }}</h1>
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
<h1>{{ addendum ? "追加补遗 / 澄清" : existing.data.value ? "上传招标文件" : "新建项目" }}</h1>
<p v-if="addendum">补遗单独解析,新条目标注「补遗 N」并入清单,日期和项目信息的新值等你确认。标博士不判断哪些旧条目已作废,并入后由你核对。</p>
<p v-else>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
</div>
</div>
@@ -156,9 +166,16 @@ async function start() {
<label>项目</label>
<span>{{ existing.data.value.name }}</span>
</div>
<div v-if="addendum" class="field">
<label>文件类型</label>
<div class="seg" role="group" aria-label="文件类型">
<button type="button" :aria-pressed="role === 'addendum'" :disabled="busy" @click="role = 'addendum'">补遗 / 更正公告</button>
<button type="button" :aria-pressed="role === 'clarification'" :disabled="busy" @click="role = 'clarification'">澄清答复</button>
</div>
</div>
<div class="field" :class="{ err: fileErr }">
<label id="l-file">招标文件</label>
<label id="l-file">{{ docWord }}</label>
<div
v-if="!fileOf"
class="drop"
@@ -216,7 +233,7 @@ async function start() {
<div class="card-f">
<!-- 红线 9:扣点规则写在按钮旁边 -->
<span class="cost">将扣 <span class="fig">1</span> 次解析;解析失败、超时或取消不扣点<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></span>
<RouterLink class="btn" :to="{ name: 'workbench' }">取消</RouterLink>
<RouterLink class="btn" :to="addendum ? { name: 'overview', params: { projectId } } : { name: 'workbench' }">取消</RouterLink>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :aria-busy="busy" :disabled="busy || up.kind !== 'done'">
<span class="idle">开始解析</span><span class="busy">正在提交…</span>
</button>

View File

@@ -0,0 +1,284 @@
<script setup lang="ts">
/**
* 资质库(FR-M6-08,原型 qualifications.html):列表 + 详情(只读,附件用大图浏览看)+ 添加 / 编辑对话框 + 删除。
* - 到期按每个进行中项目的投标截止日判断,不按今天(服务端给 late_for);只提醒,不拦截;
* - 没有附件显示「待补录」,不生成内容(红线 5);
* - 删除只有确认框,没有撤销(QL6);
* - 添加资质时可以先传证书照片,识别后自动填写(vision-plan 第 2 步,在对话框里做)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { MoreHorizontal, Plus, Search } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuSeparator, DropdownMenuTrigger } from "reka-ui";
import { computed, ref } from "vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import QualificationDetail from "@/components/QualificationDetail.vue";
import QualificationDialog, { type ActiveDeadline } from "@/components/QualificationDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDay, fmtMonthDay, QUALIFICATION_KIND, QUALIFICATION_KIND_ORDER } from "@/lib/labels";
import { useProjects, useQualifications, type Qualification } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "资质库" }]);
const qc = useQueryClient();
const quals = useQualifications();
const projects = useProjects();
const all = computed(() => quals.data.value?.items ?? []);
const kind = ref<Qualification["kind"] | "">("");
const kw = ref("");
const segs = computed(() => [
{ v: "" as const, label: "全部", n: all.value.length },
...QUALIFICATION_KIND_ORDER.map((k) => ({ v: k, label: QUALIFICATION_KIND[k], n: all.value.filter((q) => q.kind === k).length })).filter((s) => s.n),
]);
const rows = computed(() => {
const k = kw.value.trim().toLowerCase();
return all.value.filter(
(q) => (!kind.value || q.kind === kind.value) && (!k || [q.name, q.cert_no, q.issuer].some((x) => x?.toLowerCase().includes(k))),
);
});
const lateOnes = computed(() => all.value.filter((q) => q.late_for.length));
/** 进行中项目的投标截止日(北京时间的日期),给对话框做到期提醒 */
const deadlines = computed<ActiveDeadline[]>(() => {
const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" });
const today = fmt.format(new Date());
return (projects.data.value?.items ?? [])
.filter((p) => ["tracking", "drafting", "checked"].includes(p.status) && p.deadline_at)
.map((p) => ({ name: p.name, day: fmt.format(new Date(p.deadline_at!)) }))
.filter((d) => d.day >= today)
.sort((a, b) => (a.day < b.day ? -1 : 1));
});
const lateText = (q: Qualification) => {
const first = q.late_for[0]!;
return `早于「${first.name}」的投标截止 ${fmtMonthDay(first.deadline)}${q.late_for.length > 1 ? ` 等 ${q.late_for.length} 个项目` : ""}`;
};
const editing = ref<Qualification | null>(null);
const dialogOpen = ref(false);
const focusExpiry = ref(false);
function edit(q: Qualification | null, expiry = false) {
editing.value = q;
focusExpiry.value = expiry;
dialogOpen.value = true;
}
function onSaved(q: Qualification, how: "created" | "updated" | "files") {
void qc.invalidateQueries({ queryKey: qk.qualifications });
editing.value = q; // 新增后对话框还开着(附件没传上)时,再保存就是修改
if (how === "created") toast(`已添加「${q.name}」`);
else if (how === "updated") toast(`已保存「${q.name}」`);
}
/** 详情(只读):点行、点名称打开 */
const viewing = ref<Qualification | null>(null);
const detailOpen = ref(false);
function detail(q: Qualification) {
viewing.value = q;
detailOpen.value = true;
}
const removing = ref<Qualification | null>(null);
const removeOpen = ref(false);
const removeBusy = ref(false);
function askRemove(q: Qualification) {
removing.value = q;
removeOpen.value = true;
}
async function remove() {
const q = removing.value;
if (!q) return;
removeBusy.value = true;
try {
await unwrap(api.DELETE("/qualifications/{qualification_id}", { params: { path: { qualification_id: q.id } } }));
removeOpen.value = false;
await qc.invalidateQueries({ queryKey: qk.qualifications });
toast(`已删除「${q.name}」`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
removeBusy.value = false;
}
}
</script>
<template>
<div class="content">
<div class="page-title">
<div>
<h1>资质库</h1>
<p>查标时按每个项目的投标截止日核对有效期和等级。只用你录入的资质。</p>
</div>
<div class="actions">
<button class="btn primary" type="button" @click="edit(null)"><Plus class="ic" :stroke-width="1.5" />添加资质</button>
</div>
</div>
<div v-if="lateOnes.length" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p><b>{{ lateOnes.length }} 张证书会在进行中项目的投标截止前到期。</b>到期按每个项目的投标截止日判断,不按今天。</p>
<button v-if="lateOnes.length === 1" class="btn sm" type="button" @click="edit(lateOnes[0]!, true)">更新有效期</button>
</div>
<section class="card flush" aria-labelledby="h-quals">
<div class="card-h">
<h2 id="h-quals">资质</h2>
<div v-if="all.length" class="seg" role="group" aria-label="按类型筛选">
<button v-for="s in segs" :key="s.v" type="button" :aria-pressed="kind === s.v" @click="kind = s.v">
{{ s.label }}<span class="fig">{{ s.n }}</span>
</button>
</div>
<div v-if="all.length" class="r">
<label class="search"><Search class="ic" :stroke-width="1.5" /><input v-model="kw" class="input" type="search" placeholder="名称、证书编号" aria-label="搜索资质" /></label>
</div>
</div>
<div v-if="quals.isPending.value" class="card-b"><span class="skel t"></span><span class="skel"></span><span class="skel"></span></div>
<div v-else-if="quals.isError.value" class="card-b">
<div class="alert warn" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ errorTitle(quals.error.value) }}</p>
<button class="btn sm" type="button" @click="quals.refetch()">重试</button>
</div>
</div>
<div v-else-if="!all.length" class="empty">
<b>还没有录入资质</b>
<p>上传证书照片可以自动填写。查标会按每个项目的投标截止日核对有效期和等级。</p>
<button class="btn primary" type="button" @click="edit(null)"><Plus class="ic" :stroke-width="1.5" />添加资质</button>
</div>
<table v-else class="data hover quals">
<thead>
<tr><th>名称</th><th>类型</th><th>等级</th><th>证书编号</th><th>发证机构</th><th>有效期至</th><th>附件</th><th><span class="vh">更多</span></th></tr>
</thead>
<tbody>
<tr v-for="q in rows" :key="q.id" @click="detail(q)">
<td><button type="button" class="nm" @click.stop="detail(q)">{{ q.name }}</button></td>
<td>{{ QUALIFICATION_KIND[q.kind] }}</td>
<td><template v-if="q.level">{{ q.level }}</template><span v-else class="dash">—</span></td>
<td class="num">{{ q.cert_no }}</td>
<td><template v-if="q.issuer">{{ q.issuer }}</template><span v-else class="dash">—</span></td>
<td class="exp">
<span>{{ q.valid_to ? fmtDay(q.valid_to) : "长期有效" }}</span>
<span v-if="q.late_for.length" class="warn">{{ lateText(q) }}</span>
</td>
<td>
<span v-if="q.files.length"><span class="fig">{{ q.files.length }}</span> 个</span>
<span v-else class="tag status">待补录</span>
</td>
<td class="more-cell" @click.stop>
<DropdownMenuRoot :modal="false">
<DropdownMenuTrigger class="icon-btn sm" aria-label="更多操作"><MoreHorizontal class="ic" :stroke-width="1.5" /></DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent class="menu popper" align="end" :side-offset="4">
<DropdownMenuItem @select="detail(q)">查看详情</DropdownMenuItem>
<DropdownMenuItem @select="edit(q)">编辑</DropdownMenuItem>
<DropdownMenuSeparator as="hr" />
<DropdownMenuItem @select="askRemove(q)">删除</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</td>
</tr>
<tr v-if="!rows.length">
<td colspan="8" class="empty-cell">
<div class="empty sm">
<b>没有找到匹配的资质</b>
<p>换个关键词,或清除筛选。</p>
<button class="btn sm" type="button" @click="(kind = ''), (kw = '')">清除筛选</button>
</div>
</td>
</tr>
</tbody>
</table>
</section>
<p class="note below">业绩、人员、历史标书等资产库在 V1 提供。</p>
<QualificationDetail v-model:open="detailOpen" :item="viewing" @edit="(q) => edit(q)" />
<QualificationDialog v-model:open="dialogOpen" :item="editing" :deadlines="deadlines" :focus-expiry="focusExpiry" @saved="onSaved" />
<ConfirmDialog v-model:open="removeOpen" :title="`删除资质「${removing?.name ?? ''}」?`" ok-text="删除" :busy="removeBusy" busy-text="正在删除…" @ok="remove">
<p>删除后,查标不再用它核对资质,附件一并删除,不能恢复。已生成的检查报告不受影响。</p>
</ConfirmDialog>
</div>
</template>
<style scoped>
.alert {
margin-bottom: var(--gap-card);
}
.card-h .seg {
margin-left: var(--sp-2);
}
.card-h .r {
margin-left: auto;
}
.card-h .search {
width: 240px;
}
.card-h .search .input {
height: var(--control-h-sm);
}
.card-h .search > .ic {
top: 8px;
}
.seg .fig {
margin-left: 2px;
}
table.quals {
cursor: default;
}
table.quals tbody tr {
cursor: pointer;
}
table.quals td:first-child {
white-space: normal;
min-width: 220px;
}
table.quals td {
vertical-align: middle;
}
table.quals .nm {
display: inline;
padding: 0;
border: 0;
background: none;
font: inherit;
font-weight: var(--fw-bold);
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
table.quals .nm:hover {
text-decoration: underline;
text-decoration-color: var(--c-ink);
text-underline-offset: 3px;
}
table.quals td.more-cell {
width: 1%;
padding-left: 0;
}
table.quals .icon-btn {
color: var(--text-secondary);
}
table.quals tr:hover .icon-btn,
table.quals .icon-btn[aria-expanded="true"] {
color: var(--text-primary);
}
table.quals .exp .warn {
display: block;
color: var(--warn-text);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
white-space: nowrap;
}
table.data td.empty-cell {
height: auto;
padding: 0;
}
.note.below {
margin: var(--sp-3) 0 0;
}
</style>

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
/**
* 额度与套餐(原型 quota.html,FR-M6-03、05,m6-plan §1.1–1.3):
* - 套餐和本期起止;开通 / 续费申请(对公转账,运营确认到账后开通,G8)和收款信息;到期后只读(M6-5);
* - 本期额度(剩余、已用、退回)、按功能分布、扣点规则(红线 9:扣点规则可见、可查);
* - 本期流水:每一笔都能对到项目和任务,余额逐条可核;项目删了写「已删除的项目」(G10);可以导出 CSV。
* 检查也从解析额度扣(data-model §4.3),所以只有一个「解析」账户。
*/
import { Download, Plus } from "@lucide/vue";
import { computed, ref } from "vue";
import ActivationDialog from "@/components/ActivationDialog.vue";
import { useCrumbs } from "@/lib/crumbs";
import { fmtDateTime, quotaUnit } from "@/lib/labels";
import { useActivationRequests, useQuota, useQuotaEntries, type QuotaEntry } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "额度与套餐" }]);
const quota = useQuota();
const q = computed(() => quota.data.value);
const plan = computed(() => q.value?.plan);
const acct = computed(() => q.value?.accounts.find((a) => a.unit === "parse"));
const requests = useActivationRequests();
const pending = computed(() => q.value?.pending_request ?? null);
const lastRejected = computed(() => {
const latest = requests.data.value?.items[0];
return latest && latest.status === "rejected" ? latest : null;
});
const day = (iso: string | null | undefined) => (iso ? iso.slice(0, 10) : "");
const expired = computed(() => !!plan.value?.read_only);
const daysLeft = computed(() =>
plan.value?.ends_at ? Math.ceil((new Date(plan.value.ends_at).getTime() - Date.now()) / 86_400_000) : null,
);
/** 开通 / 续费按钮:免费版随时可以申请开通;专业版到期前 30 天起、已到期时可以续费;已续过一期或有待处理的申请时不显示 */
const canApply = computed(() => {
if (!plan.value || pending.value || q.value?.renewal) return false;
if (plan.value.code === "free" || expired.value) return true;
return daysLeft.value !== null && daysLeft.value <= 30;
});
const renewing = computed(() => plan.value?.code !== "free");
const dlg = ref(false);
/* ---------- 流水 ---------- */
const entriesQ = useQuotaEntries();
const rows = computed(() => entriesQ.data.value?.pages.flatMap((p) => p.items) ?? []);
const KIND: Record<string, string> = {
grant: "发放",
hold: "预扣",
settle: "结算",
release: "退回",
expire: "过期",
adjust: "调整",
waive: "免扣(24 小时内复查)",
};
const REASON: Record<string, string> = { recheck_24h: "距上次扣额度的检查不到 24 小时" };
function refText(e: QuotaEntry): string {
const project = e.project ? (e.project.deleted ? "已删除的项目" : e.project.name ?? "") : "";
const task = e.ref?.label ?? (e.ref ? (e.ref.type === "check_run" ? "检查" : "解析") : "");
return [project, task].filter(Boolean).join(" · ");
}
function note(e: QuotaEntry): string {
if (e.reason) return REASON[e.reason] ?? e.reason;
if (e.kind === "release") return "失败、超时或取消,自动退回";
if (e.kind === "settle") return "任务完成";
return "";
}
const signed = (n: number) => (n > 0 ? `+${n}` : String(n));
/** 导出 CSV:把还没加载的页都取完,在浏览器里生成文件 */
const exporting = ref(false);
async function exportCsv() {
exporting.value = true;
try {
while (entriesQ.hasNextPage.value) await entriesQ.fetchNextPage();
const head = ["时间", "类型", "额度", "数量", "余额", "关联项目 · 任务", "说明"];
const lines = rows.value.map((e) =>
[fmtDateTime(e.created_at), KIND[e.kind] ?? e.kind, quotaUnit(e.unit), signed(e.amount), e.balance_after, refText(e), note(e)]
.map((v) => `"${String(v).replaceAll('"', '""')}"`)
.join(","),
);
const blob = new Blob(["" + [head.join(","), ...lines].join("\r\n")], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `额度流水-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(a.href);
} catch {
toast("导出失败,请稍后再试", { ok: false });
} finally {
exporting.value = false;
}
}
</script>
<template>
<div class="content">
<div class="page-title">
<div>
<h1>额度与套餐</h1>
<p v-if="plan">
<template v-if="plan.ends_at">{{ plan.name }} · 本期 <span class="fig">{{ day(plan.starts_at) }}</span> 至 <span class="fig">{{ day(plan.ends_at) }}</span>。额度绑定企业主体,不可转让;本期没用完的不结转。</template>
<template v-else>{{ plan.name }} · <span class="fig">{{ day(plan.starts_at) }}</span> 注册起长期有效,额度不再补发。额度绑定企业主体,不可转让。</template>
</p>
</div>
<div class="actions">
<button v-if="canApply" type="button" class="btn" :class="{ primary: expired || plan?.code === 'free' }" @click="dlg = true">
<Plus class="ic" :stroke-width="1.5" />{{ renewing ? "提交续费申请" : "开通专业版" }}
</button>
</div>
</div>
<div v-if="expired" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版已于 <span class="fig">{{ day(plan?.ends_at) }}</span> 到期,数据只读:可以查看、下载已生成的报告、删除项目,不能新建项目、解析和检查。续费到账后恢复。</p>
</div>
<div v-else-if="daysLeft !== null && daysLeft <= 30 && !q?.renewal" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版还有 <span class="fig">{{ Math.max(daysLeft, 0) }}</span> 天到期,到期后数据只读、额度清零。</p>
</div>
<div v-if="q?.renewal" class="alert" role="note">
<span class="ico i" aria-hidden="true">i</span>
<p>已续费:新一期 <span class="fig">{{ day(q.renewal.starts_at) }}</span> 至 <span class="fig">{{ day(q.renewal.ends_at) }}</span>,到时自动生效并发放额度。</p>
</div>
<section v-if="pending" class="card pend" aria-labelledby="h-pend">
<div class="card-h">
<h2 id="h-pend">{{ renewing ? "续费" : "开通" }}申请 <span class="fig">{{ pending.request_no }}</span></h2>
<span class="tag">{{ pending.status === "paid" ? "已到账,等待开通" : "等待对公转账" }}</span>
</div>
<dl class="kv">
<dt>套餐</dt><dd>专业版 · 1 年</dd>
<dt>收款单位</dt><dd>{{ pending.payment.payee_name }}</dd>
<dt>开户行</dt><dd>{{ pending.payment.payee_bank }}</dd>
<dt>账号</dt><dd class="fig">{{ pending.payment.payee_account }}</dd>
<dt>转账备注</dt><dd>请写申请单号 <b class="fig">{{ pending.request_no }}</b></dd>
</dl>
<p class="cap muted foot-note">金额由运营联系你确认。运营确认到账后开通,并开发票;开通后新一期额度会记进下面的流水。</p>
</section>
<div v-else-if="lastRejected" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>申请 <span class="fig">{{ lastRejected.request_no }}</span> 被退回<template v-if="lastRejected.ops_note">:{{ lastRejected.ops_note }}</template>。可以修改后重新提交。</p>
</div>
<div class="cards">
<section class="card" aria-labelledby="h-acct">
<div class="card-h"><h2 id="h-acct">解析额度</h2><span v-if="acct" class="cap muted r">已用 <span class="fig">{{ Math.round((acct.used_ratio ?? 0) * 100) }}</span>%</span></div>
<template v-if="acct">
<small class="muted">剩余</small>
<div><span class="big" :class="{ warn: (acct.used_ratio ?? 0) >= 0.8 }">{{ acct.balance }}</span><span class="unit">/ {{ acct.period_granted }} 次</span></div>
<div class="bar" :class="{ warn: (acct.used_ratio ?? 0) >= 0.8 }" aria-hidden="true">
<i :style="{ width: `${Math.min(100, Math.round((acct.used_ratio ?? 0) * 100))}%` }"></i>
</div>
<p class="cap muted">
已用 <span class="fig">{{ acct.period_used }}</span> 次 · 退回 <span class="fig">{{ acct.period_released ?? 0 }}</span> 次<template v-if="acct.held"> · 正在预扣 <span class="fig">{{ acct.held }}</span> 次</template>
</p>
</template>
<p v-else class="cap muted">读取中…</p>
</section>
<section class="card" aria-labelledby="h-usage">
<div class="card-h"><h2 id="h-usage">按功能分布</h2></div>
<ul v-if="q" class="usage">
<li><span>读标 · 招标文件</span><span><span class="fig">{{ q.usage.tender }}</span> <small>次解析</small></span></li>
<li><span>读标 · 补遗和澄清</span><span><span class="fig">{{ q.usage.addendum }}</span> <small>次解析</small></span></li>
<li><span>查标</span><span><span class="fig">{{ q.usage.check }}</span> <small>次解析</small></span></li>
<li v-if="q.usage.deleted"><span>已删除的项目</span><span><span class="fig">{{ q.usage.deleted }}</span> <small>次解析</small></span></li>
<li><span>24 小时内复查</span><span><span class="fig">{{ q.usage.waived }}</span> <small>次 · 免扣</small></span></li>
</ul>
</section>
</div>
<section class="card flush" aria-labelledby="h-rules">
<div class="card-h pad"><h2 id="h-rules">扣点规则</h2></div>
<table class="data">
<thead><tr><th>动作</th><th>扣点</th><th>不扣点</th></tr></thead>
<tbody>
<tr>
<td><b>解读一份招标文件、补遗或澄清</b></td>
<td>1 次解析:发起时预扣,成功后结算</td>
<td>解析失败、超时、取消:自动退回</td>
</tr>
<tr>
<td><b>对一个项目做一次检查</b></td>
<td>专业版 1 次解析(投标文件的解析含在内);免费版:这个项目内不限次数</td>
<td>检查中断、系统异常:自动退回<br />同一项目 24 小时内复查:不扣,流水记一行「免扣」。24 小时从上一次扣额度的检查算起</td>
</tr>
</tbody>
</table>
</section>
<section class="card flush" aria-labelledby="h-ledger">
<div class="card-h pad">
<h2 id="h-ledger">流水</h2>
<button type="button" class="btn sm r" :disabled="exporting || !rows.length" @click="exportCsv"><Download class="ic" :stroke-width="1.5" />导出 CSV</button>
</div>
<table class="data ledger">
<thead>
<tr><th>时间</th><th>类型</th><th class="r">数量</th><th class="r">余额</th><th>关联项目 · 任务</th><th>说明</th></tr>
</thead>
<tbody>
<tr v-for="e in rows" :key="e.id">
<td class="fig nowrap">{{ fmtDateTime(e.created_at) }}</td>
<td>{{ KIND[e.kind] ?? e.kind }}</td>
<td class="num r">{{ signed(e.amount) }}</td>
<td class="num r">{{ e.balance_after }}</td>
<td :class="{ muted: e.project?.deleted }">{{ refText(e) || "—" }}</td>
<td class="muted">{{ note(e) }}</td>
</tr>
<tr v-if="!rows.length && !entriesQ.isPending.value"><td colspan="6" class="muted">还没有流水</td></tr>
</tbody>
</table>
<div v-if="entriesQ.hasNextPage.value" class="more">
<button type="button" class="btn sm text" :disabled="entriesQ.isFetchingNextPage.value" @click="entriesQ.fetchNextPage()">加载更多</button>
</div>
</section>
</div>
<ActivationDialog v-model:open="dlg" :renew="renewing" />
</template>
<style scoped>
.content > .alert,
.content > .card {
margin-bottom: var(--gap-card);
}
.cards {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--gap-card);
margin-bottom: var(--gap-card);
}
.cards .card {
margin: 0;
}
.card-h .r {
margin-left: auto;
}
.card-h.pad {
padding: var(--sp-4) var(--card-x) 0;
}
.big {
font: var(--fw-bold) var(--fs-figure) / var(--lh-figure) var(--font-figures);
font-variant-numeric: tabular-nums;
}
.big.warn {
color: var(--warn-text);
}
.unit {
margin-left: 4px;
color: var(--text-secondary);
font-family: var(--font-figures);
}
.bar {
margin: var(--sp-3) 0 var(--sp-2);
}
.usage {
margin: 0;
padding: 0;
list-style: none;
}
.usage li {
display: flex;
justify-content: space-between;
padding: var(--sp-2) 0;
border-top: var(--line) solid var(--border-subtle);
}
.usage li:first-child {
border-top: 0;
}
.usage small {
color: var(--text-secondary);
}
.pend .kv {
margin: 0;
}
.foot-note {
margin: var(--sp-3) 0 0;
}
.ledger .r {
text-align: right;
}
.nowrap {
white-space: nowrap;
}
.more {
padding: var(--sp-2) var(--card-x) var(--sp-3);
}
</style>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
/**
* 报告(原型 report.html,FR-M3-09):左边预览,右边「生成报告」和「已生成的报告」。
* 用词分开(避免「导出」既像生成又像下载):
* - 生成:按最近一次检查和当前的处理情况,在后台做一份新的 PDF,存进项目;致命问题没处理完也可以生成(红线 4);
* - 每份报告是生成那一刻的快照,之后不再改动;处理了问题要重新生成,旧的保留,随项目归档;
* - 下载:把某一份已生成的 PDF 存到电脑上。预览就是这份 PDF 本身(pdf.js 画出来),和下载的文件一致。
*/
import { unwrap } from "@bid-doctor/api-client";
import { AlertCircle, Download, Eye, RefreshCw } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref, watch } from "vue";
import { RouterLink, useRoute } from "vue-router";
import ProjectHead from "@/components/ProjectHead.vue";
import ReportViewer from "@/components/ReportViewer.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { reportFileUrl, useCheckRun, useProject, useReports } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const p = computed(() => project.data.value);
const runId = computed(() => p.value?.latest_check_run_id ?? "");
const runQ = useCheckRun(runId);
const run = computed(() => (runId.value ? runQ.data.value : undefined));
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "报告" }]);
const reportsQ = useReports(() => props.projectId);
const reports = computed(() => reportsQ.data.value?.items ?? []);
const citations = ref(true);
const manualChecks = ref(true);
const generating = ref(false);
// 检查的 counts 是检查结束时的快照;处理问题后的待处理数看项目(和项目头的「就绪」一致)
const openFatal = computed(() => p.value?.readiness.open_fatal_count ?? 0);
async function generate() {
if (!runId.value || generating.value) return;
generating.value = true;
try {
let job = await unwrap(
api.POST("/check-runs/{run_id}/reports", {
params: { path: { run_id: runId.value } },
body: { citations: citations.value, manual_checks: manualChecks.value },
}),
);
for (let i = 0; i < 120 && (job.status === "queued" || job.status === "running"); i++) {
await new Promise((r) => setTimeout(r, 1000));
job = await unwrap(api.GET("/report-jobs/{job_id}", { params: { path: { job_id: job.id } } }));
}
if (job.status !== "succeeded" || !job.report_id) throw new Error(job.error?.message ?? "报告生成失败,请稍后重试");
await qc.invalidateQueries({ queryKey: qk.reports(props.projectId) });
await preview(job.report_id);
toast("报告已生成,可以下载");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
generating.value = false;
}
}
/* ---------- 预览和下载:短时签名地址 ---------- */
const previewId = ref<string | null>(null);
const previewUrl = ref<string | null>(null);
const fileUrl = reportFileUrl;
const previewing = computed(() => reports.value.find((x) => x.id === previewId.value));
/** 最新一份报告之后,检查或致命问题的处理情况变了:提示可以重新生成(只看得出致命数的变化) */
const latest = computed(() => reports.value[0]);
const outdated = computed(() => {
const r = latest.value;
if (!r || !runId.value) return "";
if (r.check_run_id !== runId.value) return "最新一份报告之后又检查过";
if ((r.open_fatal_at_export ?? 0) !== openFatal.value) return "最新一份报告之后处理过致命问题";
return "";
});
async function preview(id: string) {
try {
previewUrl.value = await fileUrl(id);
previewId.value = id;
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
async function download(id: string) {
try {
window.open(await fileUrl(id), "_blank", "noopener");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
// 从概览「查看报告」进来时带着 ?run=:先预览那次检查最新的一份
const route = useRoute();
watch(reports, (list) => {
if (previewId.value || !list[0]) return;
const run = route.query.run;
void preview((list.find((r) => r.check_run_id === run) ?? list[0]).id);
}, { immediate: true });
</script>
<template>
<ProjectHead :project="p" tab="report" />
<div class="content wide">
<div v-if="p && !p.latest_check_run_id" class="empty">
<b>还没有检查结果</b>
<p>报告按最近一次检查生成。先上传投标文件并检查。</p>
<RouterLink class="btn primary" :to="{ name: 'bid-check', params: { projectId } }">去检查</RouterLink>
</div>
<div v-else class="rp">
<div class="split-doc">
<ReportViewer :url="previewUrl" :label="previewing ? `${fmtDateTime(previewing.created_at)} 生成的报告` : '检查报告'">
<template v-if="previewing" #actions>
<button type="button" class="btn sm" @click="download(previewing.id)"><Download class="ic" :stroke-width="1.5" />下载</button>
</template>
<template #empty>
<div class="empty">
<b>还没有生成过报告</b>
<p>在右边选好内容,点「生成报告」,生成的 PDF 会在这里显示,可以下载。</p>
</div>
</template>
</ReportViewer>
</div>
<aside class="rp-side">
<section class="card flush" aria-labelledby="h-gen">
<div class="card-h"><h2 id="h-gen">生成报告</h2></div>
<div class="card-b">
<p v-if="run" class="cap muted">依据第 <span class="fig">{{ run.run_no }}</span> 次检查(<span class="fig">{{ fmtDateTime(run.finished_at ?? run.created_at) }}</span>)和现在的处理情况</p>
<div class="choices">
<label class="choice dis"><input type="checkbox" class="ck" checked disabled /><span>问题明细<small>全部级别,必选</small></span></label>
<label class="choice"><input v-model="manualChecks" type="checkbox" class="ck" /><span>附录:需人工核对</span></label>
<label class="choice"><input v-model="citations" type="checkbox" class="ck" /><span>每条附原文出处</span></label>
</div>
<p v-if="openFatal" class="hint-warn"><AlertCircle class="ic" :stroke-width="1.5" /><span>还有 <b><span class="fig">{{ openFatal }}</span> 项致命问题</b>待处理,报告会如实列出。</span></p>
<p v-if="outdated" class="hint-warn"><AlertCircle class="ic" :stroke-width="1.5" /><span>{{ outdated }},要反映进报告需要重新生成。</span></p>
</div>
<div class="card-f">
<button type="button" class="btn primary swap" :class="{ 'is-loading': generating }" :aria-busy="generating" :disabled="generating || run?.status !== 'succeeded'" @click="generate">
<span class="idle"><RefreshCw v-if="reports.length" class="ic" :stroke-width="1.5" />{{ reports.length ? "重新生成报告" : "生成报告" }}</span><span class="busy">正在生成…</span>
</button>
</div>
</section>
<section class="card flush" aria-labelledby="h-done">
<div class="card-h"><h2 id="h-done">已生成的报告</h2><span v-if="reports.length" class="fig muted">{{ reports.length }}</span></div>
<div class="card-b">
<ul v-if="reports.length" class="exports">
<li v-for="r in reports" :key="r.id" :class="{ cur: r.id === previewId }">
<div>
<b>第 <span class="fig">{{ r.run_no }}</span> 次检查</b>
<small><span class="fig">{{ fmtDateTime(r.created_at) }}</span> · {{ r.generated_by.name }} · <span class="fig">{{ r.page_count }}</span> 页</small>
</div>
<button type="button" class="icon-btn sm" :aria-label="`预览第 ${r.run_no} 次检查的报告`" @click="preview(r.id)"><Eye class="ic" :stroke-width="1.5" /></button>
<button type="button" class="icon-btn sm" :aria-label="`下载第 ${r.run_no} 次检查的报告`" @click="download(r.id)"><Download class="ic" :stroke-width="1.5" /></button>
</li>
</ul>
<p v-else class="cap muted">还没有生成过。</p>
</div>
<div class="card-f cap muted">每份是生成那一刻的结果,之后不再改动,随项目归档。</div>
</section>
</aside>
</div>
</div>
</template>
<style scoped>
.rp {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--aside-w);
gap: var(--gap-card);
align-items: start;
}
.split-doc .empty {
padding: var(--sp-8) var(--card-x);
}
.rp-side {
position: sticky;
top: calc(var(--topbar-h) + var(--sp-2));
}
.rp-side > .card + .card {
margin-top: var(--gap-card);
}
.choices {
display: grid;
gap: var(--sp-3);
margin-top: var(--sp-3);
}
.choice small {
display: block;
color: var(--text-secondary);
font-size: var(--fs-caption);
}
.hint-warn {
margin-top: var(--sp-4);
}
.card-f .btn.primary {
flex: 1;
}
.exports {
list-style: none;
margin: 0;
padding: 0;
}
.exports li {
display: flex;
gap: var(--sp-2);
align-items: center;
padding: var(--sp-3) 0;
border-top: var(--line) solid var(--border-subtle);
}
.exports li:first-child {
border-top: 0;
padding-top: 0;
}
.exports li > div {
flex: 1;
min-width: 0;
}
.exports b {
display: block;
font-weight: var(--fw-bold);
}
.exports small {
display: block;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.exports li.cur b {
text-decoration: underline;
text-underline-offset: 3px;
}
.card-h .fig {
font-weight: var(--fw-medium);
}
</style>

View File

@@ -0,0 +1,799 @@
<script setup lang="ts">
/**
* 查标(原型 results.html,FR-M3-03、07、08)。第 1 步的最小版本:
* - 检查进行中显示进度和取消;失败时写明原因和是否退回额度;
* - 顶部是检查覆盖范围:查了什么、没查什么(m3-plan §2.5,红线 3);
* - 问题按级别筛选,逐条处理:确认无误 / 已修改 / 忽略并注明原因;致命问题确认无误也要写说明(U4),
* 说明框在行内展开;处理后显示署名,可以撤销;
* - 每个问题带招标原文和投标原文两侧出处,「未找到」写明找过哪些分册;点出处在右侧抽屉里看原文;
* - 只有最近一次检查的问题可以处理,历史批次只读。
* - 需人工核对(FR-M3-04、P2):份数、密封、递交、CA 加密等文件里看不出来的要求,按项目勾选,写勾选人和时间;
* - 键盘(规范 §8.2):J / K 上下条、O / Enter 看原文、1 / 2 / 3 处理、X 勾选「提示」、U 撤销、? 快捷键说明;
* 「提示」可以批量确认无误;每个问题可以反馈判断有误;从工作台带 ?f=问题编号 进来时选中那一条。
*/
import { unwrap } from "@bid-doctor/api-client";
import { ChevronDown, ChevronRight, FileText } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue";
import { RouterLink, useRoute } from "vue-router";
import CitationBlock from "@/components/CitationBlock.vue";
import DocStage, { type StageSlot } from "@/components/DocStage.vue";
import FeedbackDialog, { type FeedbackTarget } from "@/components/FeedbackDialog.vue";
import type { ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, fmtDay, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
import { TERMINAL_JOB, useCheckRun, useFindings, useManualChecks, useProject, useProjectDocuments, type Citation, type Finding, type ManualCheck } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string; runId: string }>();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const runQ = useCheckRun(() => props.runId);
const run = computed(() => runQ.data.value);
const done = computed(() => run.value?.status === "succeeded");
const findingsQ = useFindings(() => props.runId, done);
const p = computed(() => project.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "查标" }]);
const readOnly = computed(() => !run.value?.is_latest || p.value?.status === "submitted" || p.value?.status === "archived");
const step = computed(() => checkStepIndex(run.value?.stage));
/* ---------- 进行中 ---------- */
const cancelling = ref(false);
async function cancel() {
cancelling.value = true;
try {
await unwrap(api.POST("/check-runs/{run_id}/cancel", { params: { path: { run_id: props.runId } } }));
void qc.invalidateQueries({ queryKey: qk.checkRun(props.runId) });
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
cancelling.value = false;
}
}
/* ---------- 问题 ---------- */
type Sev = "" | Finding["severity"];
const sev = ref<Sev>("");
const all = computed(() => findingsQ.data.value?.items ?? []);
/** 清单里重复出现的要求(决策 C5 修订):各自判断,同组只显示第一条,处理时一起处理 */
const hidden = computed(() => {
const seen = new Set<string>();
const out = new Set<string>();
for (const f of all.value) {
if ((f.duplicate_ids ?? []).some((id) => seen.has(id))) out.add(f.id);
seen.add(f.id);
}
return out;
});
const group = (f: Finding) => [f, ...all.value.filter((x) => (f.duplicate_ids ?? []).includes(x.id))];
const live = computed(() => all.value.filter((f) => f.presence !== "resolved" && !hidden.value.has(f.id)));
/** 这次打开页面后处理的问题留在原位(署名行),J / K 能回到它按 U 撤销;之前处理的折叠在「已处理」(规范 §8.2) */
const session = ref(new Set<string>());
const inSev = (f: Finding) => !sev.value || f.severity === sev.value;
const todo = computed(() => live.value.filter((f) => (f.status === "pending" || session.value.has(f.id)) && inSev(f)));
const handled = computed(() => live.value.filter((f) => f.status !== "pending" && !session.value.has(f.id) && inSev(f)));
const doneCount = computed(() => live.value.filter((f) => f.status !== "pending").length);
const resolved = computed(() => all.value.filter((f) => f.presence === "resolved"));
const count = (s: Sev) => live.value.filter((f) => f.status === "pending" && (!s || f.severity === s)).length;
const showHandled = ref(false);
const showResolved = ref(false);
/** 行内说明框(U4):忽略、致命问题的确认无误必须写说明 */
const noteFor = ref<{ id: string; status: "confirmed_ok" | "ignored" } | null>(null);
const note = ref("");
const noteErr = ref("");
const busyId = ref<string | null>(null);
const needsNote = (f: Finding, status: string) => status === "ignored" || (status === "confirmed_ok" && f.severity === "fatal");
function act(f: Finding, status: "confirmed_ok" | "modified" | "ignored") {
if (needsNote(f, status)) {
noteFor.value = { id: f.id, status: status as "confirmed_ok" | "ignored" };
note.value = "";
noteErr.value = "";
return;
}
void resolve(f, status);
}
async function resolve(f: Finding, status: "confirmed_ok" | "modified" | "ignored", text?: string) {
if (text !== undefined && text.trim().length < 5) {
noteErr.value = status === "ignored" ? "至少写 5 个字,说明为什么不处理" : "请写一句说明,至少 5 个字";
return;
}
busyId.value = f.id;
try {
for (const x of group(f).filter((g) => g.status === "pending")) {
await unwrap(
api.POST("/findings/{finding_id}/resolve", { params: { path: { finding_id: x.id } }, body: { status, ...(text ? { note: text.trim() } : {}) } }),
);
}
noteFor.value = null;
remember(group(f));
toast(`已标记为「${FINDING_ACTION[status]}」`, { undo: () => void reopen(f) });
refresh();
next(f);
} catch (e) {
if (isApiError(e, "note_required")) noteErr.value = "请填写说明";
else toast(errorTitle(e), { ok: false });
} finally {
busyId.value = null;
}
}
function remember(fs: Finding[]) {
session.value = new Set([...session.value, ...fs.map((x) => x.id)]);
for (const x of fs) picked.value.delete(x.id);
}
/* ---------- 批量处理:只开放给「提示」(FR-M3-07) ---------- */
const picked = ref(new Set<string>());
const pickable = (f: Finding) => !readOnly.value && f.severity === "hint" && f.status === "pending";
const hints = computed(() => todo.value.filter(pickable));
const pickedList = computed(() => live.value.filter((f) => picked.value.has(f.id) && pickable(f)));
const allPicked = computed(() => hints.value.length > 0 && hints.value.every((f) => picked.value.has(f.id)));
function pick(f: Finding, on: boolean) {
const n = new Set(picked.value);
if (on) n.add(f.id);
else n.delete(f.id);
picked.value = n;
}
function pickAll(on: boolean) {
picked.value = on ? new Set(hints.value.map((f) => f.id)) : new Set();
}
const bulkBusy = ref(false);
async function bulkOk() {
const fs = pickedList.value.flatMap(group).filter((x) => x.status === "pending");
if (!fs.length) return;
bulkBusy.value = true;
try {
await unwrap(
api.POST("/check-runs/{run_id}/findings/bulk-resolve", {
params: { path: { run_id: props.runId } },
body: { finding_ids: fs.map((x) => x.id), status: "confirmed_ok" },
}),
);
remember(fs);
const n = pickedList.value.length || fs.length;
toast(`已将 ${n} 项「提示」标记为确认无误,逐条记了处理人和时间`, { undo: () => void Promise.all(fs.map((x) => reopenOne(x.id))).then(refresh) });
refresh();
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
bulkBusy.value = false;
}
}
const reopenOne = (id: string) => unwrap(api.POST("/findings/{finding_id}/reopen", { params: { path: { finding_id: id } } }));
async function reopen(f: Finding) {
try {
for (const x of group(f)) await reopenOne(x.id);
refresh();
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
function refresh() {
void qc.invalidateQueries({ queryKey: qk.findings(props.runId) });
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
}
/* ---------- 资质问题:只按资质库记录判断的,出处是资质库那条记录(QL2,check/qualification.py 存的快照) ---------- */
interface LibrarySnapshot {
name: string;
cert_no: string | null;
level: string | null;
valid_to: string | null;
}
function librarySnapshots(f: Finding): LibrarySnapshot[] {
if (f.type !== "qualification") return [];
const issues = (f.detail as { issues?: { source?: string; library?: LibrarySnapshot }[] }).issues ?? [];
return issues.filter((x) => x.source === "library" && x.library).map((x) => x.library!);
}
const libLine = (r: LibrarySnapshot) =>
[r.cert_no && `编号 ${r.cert_no}`, r.level && `等级 ${r.level}`, r.valid_to ? `有效期至 ${fmtDay(r.valid_to)}` : "长期有效"].filter(Boolean).join(" · ");
/* ---------- 需人工核对 ---------- */
const manualQ = useManualChecks(() => props.projectId);
const manual = computed(() => manualQ.data.value?.items ?? []);
const manualDone = computed(() => manual.value.filter((m) => m.checked).length);
const manualBusy = ref<string | null>(null);
const projectReadOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
async function toggleManual(m: ManualCheck, checked: boolean) {
manualBusy.value = m.check_key;
try {
await unwrap(
api.PUT("/projects/{project_id}/manual-checks", { params: { path: { project_id: props.projectId } }, body: { check_key: m.check_key, checked } }),
);
void qc.invalidateQueries({ queryKey: qk.manualChecks(props.projectId) });
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
manualBusy.value = null;
}
}
/* ---------- 右侧原文区:招标文件、投标文件左右切换(DocStage),两边各自记着文档和高亮 ---------- */
type Side = "tender" | "bid";
type Shown = { doc: { id: string; label: string } | null; target: (ViewerTarget & { id: string }) | null };
const docsQ = useProjectDocuments(() => props.projectId);
const side = ref<Side>("tender");
const shown = ref<Record<Side, Shown>>({ tender: { doc: null, target: null }, bid: { doc: null, target: null } });
// 还没点过出处时,两边先摆上这次检查用的招标文件和第一个投标分册
watchEffect(() => {
const t = docsQ.data.value?.items.find((d) => d.role === "tender" && d.status === "ready");
if (!shown.value.tender.doc && t) shown.value.tender.doc = { id: t.id, label: "招标文件" };
const b = run.value?.documents?.[0];
if (!shown.value.bid.doc && b) shown.value.bid.doc = { id: b.id, label: b.label };
});
const slots = computed<StageSlot[]>(() => [
{ key: "tender", label: "招标文件", ...shown.value.tender, emptyText: "招标文件解析完成后,在这里看原文。" },
{ key: "bid", label: "投标文件", ...shown.value.bid, emptyText: "点问题右侧的投标文件出处,在这里看原文。" },
]);
const activeCite = computed(() => shown.value[side.value].target?.id);
function openCitation(c: Citation, s: Side) {
shown.value[s] = {
doc: { id: c.document_id, label: c.document_label ?? "" },
target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 },
};
side.value = s;
}
/** 当前条的出处:优先留在正在看的那一边 */
function follow(f: Finding) {
const t = f.tender_citations[0];
const b = f.bid_citations[0];
if (b && (side.value === "bid" || !t)) openCitation(b, "bid");
else if (t) openCitation(t, "tender");
}
/* ---------- 当前条与键盘(规范 §8.2):J / K 移动(原文跟着翻),O / Enter 看原文,1 / 2 / 3 处理,X 勾选「提示」,U 撤销 ---------- */
const curId = ref<string | null>(null);
const cur = computed(() => todo.value.find((f) => f.id === curId.value) ?? todo.value[0]);
const kbOn = ref(false);
function setCur(f: Finding, scroll = false) {
const changed = curId.value !== f.id;
curId.value = f.id;
if (changed) follow(f);
if (scroll) requestAnimationFrame(() => document.querySelector(`[data-fid="${f.id}"]`)?.scrollIntoView({ block: "nearest" }));
}
// 从工作台「去处理」进来(?f=问题编号):选中那一条;否则第一次加载完,原文翻到第一条问题的出处
const route = useRoute();
watch(
() => cur.value?.id,
(id, old) => {
if (!id || old || curId.value) return;
const wanted = typeof route.query.f === "string" ? todo.value.find((x) => x.id === route.query.f) : undefined;
if (wanted) setCur(wanted, true);
else if (cur.value) follow(cur.value);
},
{ immediate: true },
);
/** 处理完跳到下一条待处理 */
function next(f: Finding) {
const list = todo.value;
const i = list.findIndex((x) => x.id === f.id);
const n = list.find((x, j) => j > i && x.status === "pending" && x.id !== f.id);
if (n) setCur(n, true);
}
function onKey(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey || document.querySelector(".scrim")) return;
if ((e.target as HTMLElement | null)?.closest("input, textarea, select, [role=listbox], [contenteditable]")) return;
if (e.key === "?") {
e.preventDefault();
document.querySelector<HTMLElement>(".kb-tip")?.focus(); // 快捷键说明是提示气泡,聚焦就显示
return;
}
const f = cur.value;
if (!f) return;
const k = e.key.toLowerCase();
if (k === "j" || k === "k") {
e.preventDefault();
const list = todo.value;
const i = Math.max(0, list.indexOf(f));
const n = list[Math.max(0, Math.min(list.length - 1, i + (k === "j" ? 1 : -1)))];
if (!n) return;
kbOn.value = true;
setCur(n, true);
} else if (k === "o" || (e.key === "Enter" && !(e.target as HTMLElement).closest("button, a, label"))) {
e.preventDefault();
follow(f);
} else if (readOnly.value) {
return;
} else if (k === "u") {
if (f.status !== "pending") void reopen(f);
else toast("当前这一条还没有处理", { ok: false });
} else if (k === "x" && pickable(f)) {
pick(f, !picked.value.has(f.id));
} else if (["1", "2", "3"].includes(e.key) && f.status === "pending" && noteFor.value?.id !== f.id) {
e.preventDefault();
act(f, (["confirmed_ok", "modified", "ignored"] as const)[Number(e.key) - 1]!);
}
}
onMounted(() => window.addEventListener("keydown", onKey));
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
/* ---------- 判断有误?(J5):反馈只用于改进检查规则,不改变处理状态 ---------- */
const fbFor = ref<FeedbackTarget | null>(null);
function askFeedback(f: Finding) {
fbFor.value = {
objectType: "finding",
objectId: f.id,
title: f.title,
kind: "false_positive",
reasons: ["这不是问题(误报)", "级别不对", "出处定位错了", "其他"],
note: "反馈只用于改进检查规则,不改变这条问题的处理状态。",
};
}
/* ---------- 检查覆盖范围:默认收起成一行,展开看全部(报告首页照样全文列出) ---------- */
const showCoverage = ref(false);
const metaLine = computed(() => {
const r = run.value;
if (!r) return "";
const files = (r.documents ?? []).map((d) => d.label).join("、");
return [`第 ${r.run_no} 次检查`, fmtDateTime(r.created_at), files, `清单 v${r.checklist_version_no}(${r.checklist_confirmed ? "已确认" : "未确认"})`]
.filter(Boolean)
.join(" · ");
});
const errorText = computed(() => {
const e = run.value?.error;
if (!e) return "";
return `${e.message}。${e.refunded ? "已扣的额度已退回。" : "这次检查没有扣点。"}`;
});
</script>
<template>
<ProjectHead :project="p" tab="check" />
<div class="content">
<!-- 进行中 -->
<section v-if="run && !TERMINAL_JOB.has(run.status)" class="card flush running" aria-labelledby="h-run" aria-live="polite">
<div class="card-h"><h2 id="h-run">正在检查</h2></div>
<div class="card-b">
<ol class="steps">
<li v-for="(s, i) in CHECK_STEPS" :key="s.label" :class="{ done: i < step, now: i === step }">
<span class="dot" aria-hidden="true">{{ i < step ? "✓" : "" }}</span>
<span>{{ s.label }}<span v-if="i === step" class="vh">(进行中)</span></span>
</li>
</ol>
<div class="progress">
<div class="bar" role="progressbar" :aria-valuenow="run.progress" aria-valuemin="0" aria-valuemax="100" aria-label="检查进度"><i :style="{ width: `${run.progress}%` }"></i></div>
<span class="fig">{{ run.progress }}%</span>
</div>
<p class="note">可以离开这个页面,检查完成后会通知你。</p>
</div>
<div class="card-f">
<button type="button" class="btn sm" :disabled="cancelling || run.cancel_requested" @click="cancel">{{ run.cancel_requested ? "正在取消…" : "取消检查" }}</button>
</div>
</section>
<!-- 失败 / 取消 -->
<div v-else-if="run && run.status !== 'succeeded'" class="alert warn" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ run.status === "cancelled" ? "检查已取消,额度已退回。" : `检查没有完成:${errorText}` }}</p>
<RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
</div>
<div v-else-if="run" class="split">
<div class="split-main">
<div class="rbar">
<div>
<h2>检查结果</h2>
<p class="meta" :title="metaLine">{{ metaLine }}</p>
</div>
<div class="actions">
<div class="row">
<RouterLink class="btn" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
<RouterLink class="btn primary" :to="{ name: 'report', params: { projectId } }"><FileText class="ic" :stroke-width="1.5" />生成报告</RouterLink>
</div>
<span v-if="p?.readiness.open_fatal_count" class="export-note">还有 <span class="fig">{{ p.readiness.open_fatal_count }}</span> 项致命问题待处理,报告会如实列出</span>
</div>
</div>
<div v-if="!run.is_latest" class="alert warn" role="note"><span class="ico w" aria-hidden="true">!</span><p>这是历史检查,只能查看。</p></div>
<!-- PI5:检查之后改了截止时间、限价、所投的包或法律体系;不自动重新检查(可能扣点,由用户决定) -->
<div v-else-if="run.basis_changed" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>项目信息(截止时间、限价、所投的包或法律体系)在这次检查之后改过,这次的结果按检查时的值,重新检查后生效。</p>
<RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
</div>
<section v-if="run.coverage" class="card flush coverage" aria-labelledby="h-cov">
<button type="button" class="cov-h" :aria-expanded="showCoverage" aria-controls="cov-body" @click="showCoverage = !showCoverage">
<component :is="showCoverage ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />
<h2 id="h-cov">检查覆盖范围</h2>
<span class="cap muted">查了 <span class="fig">{{ run.coverage.checked?.length ?? 0 }}</span> 项 · 没查 <span class="fig">{{ run.coverage.not_checked?.length ?? 0 }}</span> 项,报告首页会全部写明</span>
</button>
<div v-show="showCoverage" id="cov-body" class="cov-body">
<div>
<h3>查了</h3>
<ul><li v-for="t in run.coverage.checked ?? []" :key="t">{{ t }}</li></ul>
</div>
<div class="no">
<h3>没查</h3>
<ul><li v-for="t in run.coverage.not_checked ?? []" :key="t">{{ t }}</li></ul>
</div>
</div>
</section>
<div v-if="pickedList.length" class="batchbar" role="region" aria-label="批量处理">
<b>已选 <span class="fig">{{ pickedList.length }}</span> 项「提示」</b>
<span class="r">
<button type="button" class="btn sm" :disabled="bulkBusy" @click="bulkOk">批量确认无误</button>
<button type="button" class="btn sm text" @click="pickAll(false)">取消选择</button>
</span>
</div>
<section class="card flush results" aria-label="检查结果">
<div class="card-h">
<div class="seg" role="group" aria-label="按级别筛选">
<button type="button" :aria-pressed="sev === ''" @click="sev = ''">全部 <span class="fig">{{ count("") }}</span></button>
<button v-for="s in (['fatal', 'important', 'hint'] as const)" :key="s" type="button" :aria-pressed="sev === s" @click="sev = s">
<span class="sev" :class="SEVERITY_CLASS[s]">{{ SEVERITY[s] }}<span class="fig">{{ count(s) }}</span></span>
</button>
</div>
<div class="r">
<label v-if="!readOnly" class="choice cap" :class="{ dis: !hints.length }">
<input type="checkbox" class="ck" :checked="allPicked" :indeterminate="!allPicked && pickedList.length > 0" :disabled="!hints.length" @change="pickAll(($event.target as HTMLInputElement).checked)" />全选「提示」
</label>
<span class="cap muted">已处理 <span class="fig">{{ doneCount }}</span> / <span class="fig">{{ live.length }}</span></span>
<span class="cap muted hint-u tip-below tip-left kb-tip" tabindex="0" data-tip="J / K 上下条(右边原文跟着翻) · O 看原文 · 1 确认无误 · 2 已修改 · 3 忽略 · X 勾选「提示」 · U 撤销 · ? 显示这条说明">快捷键</span>
</div>
</div>
<div v-if="findingsQ.isPending.value" class="lst-pad"><span class="skel t"></span><span class="skel"></span></div>
<template v-else>
<div class="flist" :class="{ 'kb-on': kbOn }" @pointerdown="kbOn = false">
<article
v-for="f in [...todo, ...(showHandled ? handled : [])]"
:key="f.id"
class="finding"
:class="{ sel: cur?.id === f.id, 'has-ck': !readOnly }"
:data-fid="f.id"
:aria-current="cur?.id === f.id ? 'true' : undefined"
@click="todo.includes(f) && setCur(f)"
>
<label v-if="f.status === 'pending' && !readOnly && pickable(f)" class="ckw" data-tip="批量处理" @click.stop>
<input type="checkbox" class="ck" :checked="picked.has(f.id)" :aria-label="`勾选:${f.title}`" @change="pick(f, ($event.target as HTMLInputElement).checked)" />
</label>
<span v-else-if="f.status === 'pending' && !readOnly" class="ckw" data-tip="只有「提示」可以批量处理"><input type="checkbox" class="ck" disabled aria-label="不可批量" /></span>
<div>
<div class="f-head">
<span class="sev" :class="SEVERITY_CLASS[f.severity]">{{ SEVERITY[f.severity] }}</span>
<span class="f-title">{{ f.title }}</span>
<span v-if="(f.duplicate_ids?.length ?? 0) > 0" class="flag" title="同一要求在清单里出现了几处,各自检查、结果相同,处理时一起处理">清单里出现 <span class="fig">{{ (f.duplicate_ids?.length ?? 0) + 1 }}</span> 处</span>
<span v-if="f.presence === 'carried'" class="flag">沿用<template v-if="f.carried_status === 'modified'">:上次已修改,这次仍查出</template></span>
</div>
<p class="f-text">{{ f.explanation }}</p>
<template v-if="f.status !== 'pending'">
<div class="sign" :class="{ ign: f.status === 'ignored' }">
<span aria-hidden="true">{{ f.status === "ignored" ? "–" : "✓" }}</span><b>{{ f.resolved_by?.name }}</b>
<span v-if="f.resolved_at" class="fig">{{ fmtDateTime(f.resolved_at) }}</span><span>{{ FINDING_ACTION[f.status] }}</span>
<button v-if="!readOnly" type="button" class="undo" @click="reopen(f)">撤销</button>
</div>
<p v-if="f.resolution_note" class="sign-note">{{ f.status === "ignored" ? "原因" : "说明" }}:{{ f.resolution_note }}</p>
</template>
<div v-else-if="noteFor?.id === f.id" class="u4">
<div class="field" :class="{ err: noteErr }">
<label :for="`u4-${f.id}`">{{ noteFor.status === "confirmed_ok" ? "确认无误的说明" : "忽略原因" }} <small>(必填,至少 5 个字)</small></label>
<textarea :id="`u4-${f.id}`" v-model="note" class="input" maxlength="200" :aria-invalid="!!noteErr" @keydown.ctrl.enter="resolve(f, noteFor.status, note)" @keydown.esc="noteFor = null"></textarea>
<span class="msg"><template v-if="noteErr"><span class="ico w" aria-hidden="true">!</span>{{ noteErr }}</template><template v-else>{{ noteFor.status === "confirmed_ok" ? "说明会同时作为误报反馈提交" : "「已忽略」在报告里单独列出" }}</template></span>
</div>
<div class="row">
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="resolve(f, noteFor.status, note)">{{ noteFor.status === "confirmed_ok" ? "确认无误" : "忽略这一项" }}</button>
<button type="button" class="btn sm text" @click="noteFor = null">取消</button>
</div>
</div>
<div v-else-if="!readOnly" class="f-actions">
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'confirmed_ok')">确认无误</button>
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'modified')">已修改</button>
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'ignored')">忽略并注明原因</button>
<button type="button" class="lnk cap feedback" @click="askFeedback(f)">判断有误?</button>
</div>
</div>
<aside>
<CitationBlock v-for="c in f.tender_citations" :key="c.id" :citation="c" :active="activeCite === c.id" @open="openCitation(c, 'tender')" />
<CitationBlock v-for="c in f.bid_citations" :key="c.id" :citation="c" :active="activeCite === c.id" @open="openCitation(c, 'bid')" />
<div v-for="(r, i) in librarySnapshots(f)" :key="`lib-${i}`" class="cite lib">
<RouterLink class="cite-src" :to="{ name: 'qualifications' }">依据:资质库记录</RouterLink>
<p><b>{{ r.name }}</b><br />{{ libLine(r) }}</p>
</div>
<div v-if="f.type === 'unresponded' && f.search_scope" class="cite missing">
<span class="cite-src">投标文件 · 未找到</span>
<p>找过:{{ (f.search_scope.documents ?? []).join("、") || "全部分册" }}</p>
</div>
</aside>
</article>
</div>
<div v-if="!todo.length" class="empty sm">
<b>{{ live.length ? (sev ? "这个级别的问题都已处理" : "全部问题都已处理") : "没有发现问题" }}</b>
<p>{{ live.length ? "已处理的在下方,可以撤销。" : "只表示本次检查的范围内没有发现问题,不代表投标文件没有问题。没查的内容见上方「没查」。" }}</p>
</div>
<button v-if="handled.length" type="button" class="group-h" :aria-expanded="showHandled" @click="showHandled = !showHandled">
<component :is="showHandled ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />已处理(<span class="fig">{{ handled.length }}</span>)
</button>
<button v-if="resolved.length" type="button" class="group-h" :aria-expanded="showResolved" @click="showResolved = !showResolved">
<component :is="showResolved ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />本次已解决(<span class="fig">{{ resolved.length }}</span>)<span class="sub">上次有、这次没再发现,已自动关闭</span>
</button>
<template v-if="showResolved">
<div v-for="f in resolved" :key="f.id" class="resolved">
<span class="sev" :class="SEVERITY_CLASS[f.severity]">{{ SEVERITY[f.severity] }}</span><s>{{ f.title }}</s>
<span v-if="f.carried_status && f.carried_status !== 'pending'" class="cap">上次:{{ FINDING_ACTION[f.carried_status] }}</span>
</div>
</template>
</template>
</section>
<section v-if="manual.length" class="card flush manual" aria-labelledby="h-manual">
<div class="card-h">
<h2 id="h-manual">需人工核对</h2>
<span class="cap">文件里看不出来,递交前请自己核对。勾选按项目保存,复查后还在。</span>
<span class="cap r">已核对 <span class="fig">{{ manualDone }}</span> / <span class="fig">{{ manual.length }}</span></span>
</div>
<article v-for="m in manual" :key="m.check_key" class="finding">
<div>
<label class="choice">
<input type="checkbox" class="ck" :checked="m.checked" :disabled="projectReadOnly || manualBusy === m.check_key" @change="toggleManual(m, ($event.target as HTMLInputElement).checked)" />
<span class="f-title">{{ m.label }}</span>
</label>
<p v-if="m.requirement_text" class="f-text">招标文件:{{ m.requirement_text }}<template v-if="(m.related_items?.length ?? 0) > 1">(同类要求共 <span class="fig">{{ m.related_items?.length }}</span> 条)</template></p>
<div v-if="m.checked" class="sign">
<span aria-hidden="true">✓</span><b>{{ m.checked_by?.name }}</b>
<span v-if="m.checked_at" class="fig">{{ fmtDateTime(m.checked_at) }}</span><span>已核对</span>
</div>
</div>
<aside>
<CitationBlock v-if="m.citation" :citation="m.citation" :active="activeCite === m.citation.id" @open="openCitation(m.citation, 'tender')" />
</aside>
</article>
</section>
</div>
<aside class="split-doc" aria-label="原文">
<DocStage v-model:active="side" :slots="slots" />
</aside>
</div>
<div v-else-if="runQ.error.value" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(runQ.error.value) }}</p></div>
</div>
<FeedbackDialog :project-id="projectId" :target="fbFor" @close="fbFor = null" />
</template>
<style scoped>
.running {
max-width: 736px;
}
.running .card-b {
padding-top: var(--sp-2);
}
.running .progress {
margin-top: var(--sp-3);
}
.rbar {
display: flex;
align-items: flex-end;
gap: var(--sp-4);
margin-bottom: var(--sp-4);
}
.rbar h2 {
margin: 0;
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
}
.rbar .meta {
margin: 2px 0 0;
color: var(--text-secondary);
font-size: var(--fs-caption);
}
.rbar .actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--sp-1);
margin-left: auto;
}
.rbar .actions .row {
display: flex;
gap: var(--sp-2);
}
.export-note {
color: var(--text-secondary);
font-size: var(--fs-caption);
white-space: nowrap;
}
.coverage {
margin-bottom: var(--sp-4);
}
.cov-h {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: var(--sp-3) var(--card-x);
border: 0;
background: none;
font: inherit;
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.cov-h h2 {
margin: 0;
font-size: var(--fs-body);
font-weight: var(--fw-bold);
}
.cov-h .ic {
color: var(--text-secondary);
}
.cov-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-6);
padding: 0 var(--card-x) var(--sp-4);
}
.coverage h3 {
margin: 0 0 var(--sp-1);
font-size: var(--fs-body);
}
.coverage ul {
margin: 0;
padding-left: 1.2em;
}
.coverage .no {
color: var(--text-secondary);
}
.results .card-h {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2) var(--sp-3);
}
/* 左右布局里问题行改成单列:出处放在说明下面(原文就在右边),文字不被页边批注栏挤窄 */
.finding {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-3);
}
.finding :deep(.cite:first-child)::before {
display: none;
}
.finding > aside {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-6);
}
.finding > aside > * {
flex: 1 1 260px;
min-width: 0;
}
.f-head .flag {
white-space: nowrap;
}
.results .card-h .r {
display: flex;
align-items: center;
gap: var(--sp-4);
margin-left: auto;
white-space: nowrap;
}
.flist:focus {
outline: 0;
}
.f-actions .feedback {
margin-left: auto;
color: var(--text-secondary);
}
.seg {
display: flex;
gap: var(--sp-2);
}
.seg button {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: 2px var(--sp-2);
border-radius: var(--radius-ctl);
color: var(--text-secondary);
}
.seg button[aria-pressed="true"] {
background: var(--c-n100);
color: var(--text-primary);
}
.manual {
margin-top: var(--sp-4);
}
.manual .card-h {
display: flex;
align-items: baseline;
gap: var(--sp-2);
}
.manual .card-h h2 {
margin: 0;
font-size: var(--fs-body);
}
.manual .card-h .r {
margin-left: auto;
}
.sign.ign {
color: var(--text-secondary);
}
.sign .undo {
border: 0;
background: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
}
.sign-note {
margin: var(--sp-1) 0 0;
color: var(--text-secondary);
font-size: var(--fs-caption);
}
.u4 {
margin-top: var(--sp-3);
}
.u4 .field {
max-width: none;
}
.u4 textarea {
min-height: 64px;
}
.u4 .row {
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
.cite.missing p {
margin: 0;
color: var(--text-secondary);
}
/* 资质库是用户自己录的数据:墨色(人工),不用 AI 出处的蓝铅笔色 */
.cite.lib {
border-left-color: var(--c-ink);
}
.cite.lib .cite-src {
color: var(--text-primary);
}
.cite.lib p {
margin: 2px 0 0;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.cite.lib p b {
color: var(--text-primary);
}
.group-h {
display: flex;
align-items: center;
gap: var(--sp-1);
width: 100%;
padding: var(--sp-3) var(--card-x);
border: 0;
border-top: var(--line) solid var(--border-subtle);
background: none;
font: inherit;
cursor: pointer;
color: var(--text-secondary);
text-align: left;
}
.group-h .sub {
margin-left: var(--sp-2);
font-size: var(--fs-caption);
}
.resolved {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--card-x);
color: var(--text-secondary);
}
</style>

View File

@@ -1,16 +1,20 @@
<script setup lang="ts">
/**
* 工作台(原型 workbench.html):页头 + 统计 + 项目列表;右栏是正在进行的任务和本期额度。
* 原型里的「待处理的致命问题」「最近处理」要等查标和操作记录的接口,骨架阶段不做。
* 工作台(原型 workbench.html,ui-demo page-workbench.html):页头 + 统计 + 项目列表 + 待处理的致命问题;
* 右栏是正在进行的任务和本期额度。统计可以点,按它筛选项目列表(再点一下取消)。
* 「最近处理」要等操作记录的接口,还没做。
*/
import { Plus } from "@lucide/vue";
import { computed } from "vue";
import { unwrap } from "@bid-doctor/api-client";
import { Filter, Plus, X } from "@lucide/vue";
import { useQueries } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink } from "vue-router";
import ProjectRow from "@/components/ProjectRow.vue";
import { errorTitle } from "@/lib/api";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { quotaUnit } from "@/lib/labels";
import { useMe, useProjects, useQuota, useQuotaAccount } from "@/lib/queries";
import { useMe, useProjects, useQuota, useQuotaAccount, type ProjectListItem } from "@/lib/queries";
useCrumbs(() => [{ label: "工作台" }]);
@@ -19,30 +23,58 @@ const projects = useProjects();
const quota = useQuota();
const parse = useQuotaAccount("parse");
/** 套餐到期后只读(决策 M6-5):不能新建项目,顶上有续费提醒 */
const readOnly = computed(() => !!me.data.value?.plan.read_only);
const today = new Date();
const hello = computed(() => {
const name = me.data.value?.user.display_name ?? "";
return `你好,${name}。今天是 ${today.getMonth() + 1}月${today.getDate()}日,周${"日一二三四五六"[today.getDay()]}。`;
});
const list = computed(() => projects.data.value?.items ?? []);
const active = computed(() => list.value.filter((p) => p.status !== "submitted" && p.status !== "archived"));
const stats = computed(() => {
const now = Date.now();
const due7 = active.value.filter((p) => {
const all = computed(() => projects.data.value?.items ?? []);
const active = computed(() => all.value.filter((p) => p.status !== "submitted" && p.status !== "archived"));
const due7 = (p: ProjectListItem) => {
if (!p.deadline_at) return false;
const ms = new Date(p.deadline_at).getTime() - now;
const ms = new Date(p.deadline_at).getTime() - Date.now();
return ms >= 0 && ms <= 7 * 86_400_000;
}).length;
const sum = (f: (p: (typeof list.value)[number]) => number) => active.value.reduce((a, p) => a + f(p), 0);
return [
{ label: "进行中的项目", value: active.value.length, cls: "" },
{ label: "7 天内截止", value: due7, cls: due7 ? "due" : "" },
};
type StatKey = "active" | "due7" | "fatal" | "pending";
const STAT: Record<StatKey, { label: string; fn: (p: ProjectListItem) => boolean; count: (p: ProjectListItem) => number; cls: string }> = {
active: { label: "进行中的项目", fn: () => true, count: () => 1, cls: "" },
due7: { label: "7 天内截止", fn: due7, count: (p) => (due7(p) ? 1 : 0), cls: "due" },
// 致命红只用于致命问题(§3.2)
{ label: "待处理的致命问题", value: sum((p) => p.readiness.open_fatal_count), cls: "fatal" },
{ label: "待确认的清单条目", value: sum((p) => p.pending_item_count ?? 0), cls: "" },
];
fatal: { label: "待处理的致命问题", fn: (p) => p.readiness.open_fatal_count > 0, count: (p) => p.readiness.open_fatal_count, cls: "fatal" },
pending: { label: "待确认的清单条目", fn: (p) => (p.pending_item_count ?? 0) > 0, count: (p) => p.pending_item_count ?? 0, cls: "" },
};
const stats = computed(() =>
(Object.keys(STAT) as StatKey[]).map((key) => ({ key, ...STAT[key], value: active.value.reduce((a, p) => a + STAT[key].count(p), 0) })),
);
/** 点了哪个统计:按它筛选进行中的项目;没点时列全部项目(含已递交、已归档) */
const filter = ref<StatKey | null>(null);
function toggle(k: StatKey) {
filter.value = filter.value === k ? null : k;
}
const list = computed(() => (filter.value ? active.value.filter(STAT[filter.value].fn) : all.value));
/* ---------- 待处理的致命问题:有致命问题的项目,取最近一次检查里还没处理的致命问题 ---------- */
const withFatal = computed(() => active.value.filter((p) => p.readiness.open_fatal_count > 0 && p.latest_check_run_id));
const fatalQs = useQueries({
queries: computed(() =>
withFatal.value.map((p) => ({
queryKey: qk.findings(p.latest_check_run_id!),
queryFn: () => unwrap(api.GET("/check-runs/{run_id}/findings", { params: { path: { run_id: p.latest_check_run_id! } } })),
})),
),
});
const TODO_MAX = 5;
const todos = computed(() =>
withFatal.value.flatMap((p, i) =>
(fatalQs.value[i]?.data?.items ?? [])
.filter((f) => f.severity === "fatal" && f.status === "pending" && f.presence !== "resolved")
.map((f) => ({ p, f, cite: f.bid_citations[0] ?? f.tender_citations[0] ?? null })),
),
);
const fatalTotal = computed(() => withFatal.value.reduce((a, p) => a + p.readiness.open_fatal_count, 0));
const jobs = computed(() => list.value.filter((p) => p.active_job));
const accounts = computed(() => quota.data.value?.accounts.filter((a) => a.period_granted > 0) ?? []);
const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ?? "");
@@ -57,21 +89,26 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
</div>
<div class="actions">
<span v-if="parse" class="cost">解析 1 份招标文件扣 <span class="fig">1</span> 次 · 剩 <span class="fig">{{ parse.balance }}</span> 次</span>
<RouterLink class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
<RouterLink v-if="!readOnly" class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</div>
</div>
<section class="stats" aria-label="项目统计">
<div v-for="s in stats" :key="s.label" class="stat">
<section class="stats" aria-label="项目统计,点击可筛选项目列表">
<button v-for="s in stats" :key="s.key" type="button" class="stat" :aria-pressed="filter === s.key" @click="toggle(s.key)">
<small>{{ s.label }}</small><span class="big" :class="s.value ? s.cls : ''">{{ s.value }}</span>
</div>
<Filter class="ic" :stroke-width="1.5" />
</button>
</section>
<div class="wb">
<div class="stack">
<section class="card flush" aria-labelledby="h-proj">
<div class="card-h">
<h2 id="h-proj">项目</h2>
<span v-if="list.length" class="fig muted">{{ list.length }}</span>
<button v-if="filter" type="button" class="btn text sm chip-clear" @click="filter = null">
{{ STAT[filter].label }}<X class="ic" :stroke-width="1.5" /><span class="vh">,清除筛选</span>
</button>
</div>
<div v-if="projects.isPending.value" class="card-b">
<span class="skel t"></span><span class="skel"></span><span class="skel"></span>
@@ -84,18 +121,42 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
</div>
</div>
<template v-else-if="list.length">
<div class="prow-head" aria-hidden="true"><span>项目</span><span>投标截止</span><span>状态</span><span class="c">待确认</span><span>下一步</span><span></span></div>
<div class="prow-head" aria-hidden="true"><span>项目</span><span>投标截止</span><span>状态</span><span class="c">致命</span><span>下一步</span><span></span></div>
<ul class="prows" aria-label="项目列表">
<ProjectRow v-for="p in list" :key="p.id" :project="p" />
</ul>
</template>
<div v-else-if="filter" class="empty sm">
<b>没有符合条件的项目</b>
<p>清除筛选,看全部项目。</p>
</div>
<div v-else class="empty">
<b>还没有项目</b>
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
<RouterLink class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
<RouterLink v-if="!readOnly" class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</div>
</section>
<section v-if="fatalTotal" class="card flush" aria-labelledby="h-todo">
<div class="card-h">
<h2 id="h-todo">待处理的致命问题</h2>
<span class="sev fatal">致命<span class="fig">{{ fatalTotal }}</span></span>
<button v-if="todos.length > TODO_MAX" type="button" class="lnk cap more" @click="filter = 'fatal'">只看有致命问题的项目</button>
</div>
<ul class="todos">
<li v-for="{ p, f, cite } in todos.slice(0, TODO_MAX)" :key="f.id" class="todo">
<span class="sev fatal">致命</span>
<div>
<b>{{ f.title }}</b>
<span class="psub">{{ p.name }}<template v-if="cite"> · {{ cite.document_label }} 第 <span class="fig">{{ cite.page_no }}</span> 页</template></span>
</div>
<RouterLink class="btn sm" :to="{ name: 'results', params: { projectId: p.id, runId: p.latest_check_run_id! }, query: { f: f.id } }">去处理</RouterLink>
</li>
<li v-if="!todos.length" class="todo"><span class="cap muted">正在读取…</span></li>
</ul>
</section>
</div>
<aside class="stack" aria-label="任务与额度">
<section class="card flush" aria-labelledby="h-task">
<div class="card-h"><h3 id="h-task">正在进行的任务</h3></div>
@@ -156,6 +217,31 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
.none {
margin: 0;
}
.chip-clear,
.more {
margin-left: auto;
}
/* 待处理的致命问题:不加左侧红竖线,靠实心「致命」标签扫读(ui-demo d5) */
.todos {
margin: 0;
padding: 0;
list-style: none;
}
.todo {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
column-gap: var(--sp-4);
align-items: center;
padding: var(--sp-3) var(--card-x);
border-top: var(--line) solid var(--border-subtle);
}
.todo b {
display: block;
font-weight: var(--fw-bold);
}
.todo .psub {
display: block;
}
.task {
display: block;
color: inherit;

View File

@@ -24,18 +24,51 @@ export const router = createRouter({
children: [
{ path: "", name: "workbench", component: () => import("@/pages/WorkbenchPage.vue") },
{ path: "projects/new", name: "project-new", component: () => import("@/pages/ProjectNewPage.vue") },
{ path: "qualifications", name: "qualifications", component: () => import("@/pages/QualificationsPage.vue") },
{ path: "quota", name: "quota", component: () => import("@/pages/QuotaPage.vue") },
{ path: "account", name: "account", component: () => import("@/pages/AccountPage.vue") },
{
path: "projects/:projectId/parse/:jobId",
name: "parse",
component: () => import("@/pages/ParsePage.vue"),
props: true,
},
{
path: "projects/:projectId",
name: "overview",
component: () => import("@/pages/OverviewPage.vue"),
props: true,
},
{
path: "projects/:projectId/lots",
name: "lots",
component: () => import("@/pages/LotsPage.vue"),
props: true,
},
{
path: "projects/:projectId/checklist",
name: "checklist",
component: () => import("@/pages/ChecklistPage.vue"),
props: true,
},
{
path: "projects/:projectId/check",
name: "bid-check",
component: () => import("@/pages/BidCheckPage.vue"),
props: true,
},
{
path: "projects/:projectId/report",
name: "report",
component: () => import("@/pages/ReportPage.vue"),
props: true,
},
{
path: "projects/:projectId/results/:runId",
name: "results",
component: () => import("@/pages/ResultsPage.vue"),
props: true,
},
],
},
{ path: "/:pathMatch(.*)*", redirect: "/" },

View File

@@ -8,7 +8,7 @@
* 与原型的差别:原型把框架挂在 body.app 上,这里挂在 AppShell 的根元素 .app-frame 上。 */
/* =====================================================================
* 1. 页面框架:96px 墨色渐变侧栏 + 透明顶栏 + 内容区左上角圆角
* 1. 页面框架:96px 墨色渐变侧栏 + 透明顶栏;内容区四角都是直角(侧栏贴边,不做圆角衔接)
* ===================================================================== */
.app-frame{display:grid;grid-template-columns:var(--rail-w) minmax(0,1fr);min-height:100vh;min-width:1280px;background:linear-gradient(180deg,var(--side-top) 0%,var(--side-bottom) 100%) fixed}
.sidebar{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;padding:0 0 var(--sp-4) 10px;color:var(--side-fg);min-width:0}
@@ -31,8 +31,8 @@
.plan[data-plan="pro"]{background:var(--plan-pro-bg);color:var(--plan-pro-fg);box-shadow:inset 0 0 0 1px var(--plan-pro-line)}
.plan[data-plan="ent"]{background:var(--plan-ent-bg);color:var(--c-ink)}
.main{min-width:0;display:flex;flex-direction:column;min-height:100vh;background:var(--bg-page);border-top-left-radius:var(--radius-card)}
.top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--sp-3);height:var(--topbar-h);padding:0 var(--pad-page);background:transparent;border-bottom:var(--line) solid transparent;border-top-left-radius:var(--radius-card);transition:background-color var(--dur-base) var(--ease),border-color var(--dur-base) var(--ease)}
.main{min-width:0;display:flex;flex-direction:column;min-height:100vh;background:var(--bg-page)}
.top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--sp-3);height:var(--topbar-h);padding:0 var(--pad-page);background:transparent;border-bottom:var(--line) solid transparent;transition:background-color var(--dur-base) var(--ease),border-color var(--dur-base) var(--ease)}
.top.scrolled{background:var(--topbar-glass);-webkit-backdrop-filter:blur(14px) saturate(1.2);backdrop-filter:blur(14px) saturate(1.2);border-bottom-color:var(--border-subtle)}
.crumbs{display:flex;align-items:center;gap:6px;min-width:0;color:var(--text-secondary);white-space:nowrap;overflow:hidden}
.crumbs > *{min-width:0;overflow:hidden;text-overflow:ellipsis}
@@ -48,6 +48,13 @@
.quota-chip .fig{color:var(--text-primary);font-size:var(--fs-figure-sm)}
.quota-chip .q.warn .fig{color:var(--warn-text);font-weight:var(--fw-bold)}
.quota-chip .sep{width:1px;height:12px;margin:0 var(--sp-1);background:var(--border-subtle)}
a.quota-chip:hover{background:var(--c-n100)}
/* 侧栏底部的账号区可以点(进账号页) */
a.me{text-decoration:none;border-radius:var(--radius-ctl);transition:background-color var(--dur-fast) var(--ease)}
a.me:hover{background:var(--side-hover)}a.me[aria-current="page"]{background:var(--side-active)}
/* 套餐到期的提醒条(决策 M6-5):赭色,不用红(U1) */
.expired-bar{display:flex;gap:var(--sp-3);align-items:center;margin:0 var(--pad-page) var(--gap-card);padding:10px var(--card-x);background:var(--bg-surface);border:var(--line) solid var(--warn);border-radius:var(--radius-card)}
.expired-bar p{flex:1;margin:0;color:var(--warn-text)}
.top .vsep{flex:none;width:1px;height:20px;margin:0 var(--sp-1);background:var(--border-subtle)}
.content{width:100%;padding:20px var(--pad-page) var(--sp-12)}
.content.narrow{max-width:800px}
@@ -67,9 +74,13 @@
.ph .facts{display:flex;gap:var(--sp-8);margin:0 0 0 auto;flex:none}
.ph .facts > div{display:flex;flex-direction:column;gap:2px}
.ph .facts dt{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.ph .facts dd{display:flex;align-items:center;gap:6px;min-height:var(--lh-body);margin:0;white-space:nowrap}
.ph .facts dd{display:flex;align-items:center;gap:6px;height:var(--control-h-sm);margin:0;white-space:nowrap} /* 各列取值同高(状态列有按钮),值都在同一条中线上 */
.ph .tabs{margin-top:var(--sp-3)}
.ph + .content{padding-top:var(--sp-5)}
/* 左右布局(查标、概览,同读标页):左边内容随页面滚动,右边原文区贴住视口、自己翻页 */
.split{display:grid;grid-template-columns:minmax(560px,1fr) minmax(400px,45%);gap:var(--gap-card);align-items:start}
.split-main{min-width:0}
.split-doc{position:sticky;top:calc(var(--topbar-h) + var(--sp-2));height:calc(100vh - var(--topbar-h) - var(--sp-2) - var(--sp-12))} /* 减去 .content 的底边距:滚到底时也贴得住,不钻到顶栏下面 */
/* =====================================================================
* 2. 共用小件
@@ -83,7 +94,7 @@
/* B3 按钮补充状态:加载中两个文字叠在同一格,切换时宽度不变 */
.btn .ic{margin:0 -2px}
.btn.swap{display:inline-grid;place-items:center}
.btn.swap > span{grid-area:1/1;white-space:nowrap}
.btn.swap > span{grid-area:1/1;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn.swap > .busy,.btn.swap.is-loading > .idle{visibility:hidden}
.btn.swap.is-loading > .busy{visibility:visible}
.btn.is-loading{cursor:progress}
@@ -94,6 +105,7 @@
/* B4 输入框 */
.input{width:100%;transition:border-color var(--dur-fast) var(--ease)}
.input.sm{height:var(--control-h-sm)}
.input:hover{border-color:var(--c-ink)}
.input:focus{outline:0;border-color:var(--c-ink);box-shadow:0 0 0 1px var(--c-ink)}
.field.err .input:focus{border-color:var(--warn);box-shadow:0 0 0 1px var(--warn)}
@@ -195,6 +207,16 @@
.stat .big{font:var(--fw-bold) var(--fs-figure)/var(--lh-figure) var(--font-figures);font-variant-numeric:tabular-nums}
.stat .big.fatal{color:var(--sev-fatal)}
.stat .big.due{color:var(--warn-text)}
/* 可点的统计(ui-demo 工作台):点一下按它筛选项目列表,再点一下取消 */
button.stat{cursor:pointer;transition:background-color var(--dur-fast) var(--ease)}
button.stat:hover{background:var(--c-n50)}
button.stat:focus-visible{outline-offset:-2px;border-radius:0}
.stat::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--c-ink);transform:scaleY(0);transform-origin:bottom;transition:transform var(--dur-fast) var(--ease)}
.stat[aria-pressed="true"]::after{transform:scaleY(1)}
.stat[aria-pressed="true"] small{color:var(--text-primary);font-weight:var(--fw-bold)}
.stat > .ic{position:absolute;top:18px;right:var(--card-x);color:var(--text-secondary);opacity:0;transition:opacity var(--dur-fast) var(--ease)}
.stat:hover > .ic,.stat:focus-visible > .ic,.stat[aria-pressed="true"] > .ic{opacity:1}
.stat[aria-pressed="true"] > .ic{color:var(--text-primary)}
/* B19 列表卡片:左侧分类导航 + 列表;列表里的分组标题(.ai 是清单顶部的「待确认」分组) */
.cmp-list{display:grid;grid-template-columns:176px minmax(0,1fr);min-height:0;overflow:hidden}
@@ -252,6 +274,9 @@ textarea.input{height:auto;min-height:80px;padding:var(--sp-2) var(--sp-3);resiz
.form .field{max-width:none}
.lnk{padding:0;border:0;background:none;font:inherit;color:var(--text-primary);text-decoration:underline;text-decoration-color:var(--link-underline);text-underline-offset:3px;cursor:pointer}
.lnk:hover{text-decoration-color:var(--c-ink)}
/* 行内出处(「第 N 页」):蓝铅笔色的按钮,点开原文 */
.cite-src.inline{display:inline;padding:0;border:0;background:none;font:inherit;font-size:var(--fs-caption);color:var(--ai);white-space:nowrap;cursor:pointer}
.cite-src.inline:hover{text-decoration:underline;text-underline-offset:3px}
/* 分段选择(重要性等单选) */
.seg{display:inline-flex;align-items:center;gap:2px}
.seg button{display:inline-flex;align-items:center;gap:6px;height:var(--control-h-sm);padding:0 10px;border:0;border-radius:var(--radius-ctl);background:none;font:var(--fs-body) var(--font-sans);color:var(--text-secondary);white-space:nowrap;cursor:pointer;transition:background-color var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
@@ -272,3 +297,85 @@ textarea.input{height:auto;min-height:80px;padding:var(--sp-2) var(--sp-3);resiz
.toast .ok{display:inline-grid;place-items:center;width:16px;height:16px;border-radius:50%;background:var(--c-green);color:var(--c-white);font-size:11px;line-height:1}
.toast button.lnk{color:var(--text-on-dark);text-decoration-color:currentColor}
.toast .sep{width:1px;align-self:stretch;margin:2px 0;background:var(--on-dark-line)}
/* =====================================================================
* 4. 资质库用到的补充(照搬原型 docs/prototype/assets/app.css 的菜单、开关)
* ===================================================================== */
/* 下拉菜单:列表行的「更多」。定位交给 Reka UI 的 DropdownMenu,这里只管外观 */
.menu{position:absolute;z-index:45;top:calc(100% + 4px);left:0;min-width:100%;max-height:300px;overflow:auto;margin:0;padding:6px;list-style:none;background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);box-shadow:var(--shadow-overlay)}
.menu.popper{position:relative;top:auto;left:auto;min-width:160px}
.menu [role=option],.menu [role=menuitem]{display:flex;align-items:center;gap:var(--sp-2);width:100%;min-height:34px;padding:6px 10px;border:0;border-radius:var(--radius-tag);background:none;font:var(--fs-body)/var(--lh-body) var(--font-sans);color:var(--text-primary);text-align:left;cursor:pointer;white-space:nowrap}
.menu [role=option]:hover,.menu [role=menuitem]:hover,.menu .is-active,.menu [role=menuitem]:focus-visible,.menu [data-highlighted]{background:var(--c-n100);outline:0}
.menu hr{border:0;border-top:var(--line) solid var(--border-subtle);margin:6px 0}
/* 列表的当前条与批量勾选(照搬原型 §7.18):当前条 = 浅底 + 左侧 3px 墨色竖条;键盘操作时再加 2px 内框;勾选条只有复选框是选中状态 */
.finding.sel{background:var(--c-n50);box-shadow:inset 3px 0 0 var(--c-ink)}
.finding{transition:background-color var(--dur-fast) var(--ease)}
.kb-on > .finding.sel{box-shadow:inset 3px 0 0 var(--c-ink),inset 0 0 0 2px var(--focus-ring)}
.finding .ckw{position:absolute;left:14px;top:calc(var(--sp-5) + 2px)}
.finding.has-ck{padding-left:calc(var(--card-x) + 24px)}
.batchbar{position:sticky;top:calc(var(--topbar-h) + var(--sp-2));z-index:10;display:flex;align-items:center;gap:var(--sp-3);height:48px;padding:0 var(--sp-2) 0 var(--card-x);margin-bottom:var(--sp-2);background:var(--bg-surface);border:var(--line) solid var(--c-ink);border-radius:var(--radius-card)}
.batchbar b .fig{font-weight:var(--fw-bold)}
.batchbar .r{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
.choice.dis{color:var(--text-disabled);cursor:not-allowed}
.ck:disabled{background:var(--c-n100);border-color:var(--border-subtle);cursor:not-allowed}
.ck:indeterminate{background:var(--c-ink);border-color:var(--c-ink)}
.ck:indeterminate::after{display:block;width:8px;height:0;border-top:1.75px solid var(--c-white)}
/* 单选(照搬原型 B7) */
.ck[type=radio]{border-radius:50%}
.ck[type=radio]:checked{background:var(--bg-surface);border-color:var(--c-ink)}
.ck[type=radio]:checked::after{width:8px;height:8px;border:0;border-radius:50%;background:var(--c-ink);transform:none}
.ck[type=radio]:disabled:checked{background:var(--c-n100)}
.ck[type=radio]:disabled:checked::after{background:var(--c-muted)}
.ck:disabled:hover{border-color:var(--border-subtle)}
/* 项目头标签页上的致命数(原型 .ph .tabs .sev) */
.tabs [role=tab] .sev{margin-left:6px}
/* 标题折行时级别标签对齐第一行 */
.f-head{align-items:flex-start}
.f-head .sev{margin-top:2px}
/* 下拉选择(规范 §7.21,components/SelectBox.vue):触发框和输入框同规格;浮层复用 .menu */
.select{display:flex;align-items:center;gap:var(--sp-2);width:100%;min-width:0;height:var(--control-h);padding:0 10px 0 var(--sp-3);border:var(--line) solid var(--border-control);border-radius:var(--radius-ctl);background:var(--bg-surface);font:var(--fs-body) var(--font-sans);color:var(--text-primary);text-align:left;cursor:pointer;transition:border-color var(--dur-fast) var(--ease)}
.select.sm{height:var(--control-h-sm)}
.select:hover{border-color:var(--c-ink)}
.select:focus-visible,.select[data-state=open]{outline:0;border-color:var(--c-ink);box-shadow:0 0 0 1px var(--c-ink)}
.select .val{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.select > .ic{flex:none;color:var(--text-secondary);transition:transform var(--dur-fast) var(--ease)}
.select[data-state=open] > .ic{transform:rotate(180deg)}
.select[data-disabled]{background:var(--c-n100);border-color:var(--c-n100);color:var(--text-disabled);cursor:not-allowed}
.field.err .select{border-color:var(--warn);box-shadow:inset 0 0 0 1px var(--warn)}
.menu.sel-menu{z-index:56;min-width:var(--reka-select-trigger-width);max-height:min(300px,var(--reka-select-content-available-height))}
.sel-menu [role=option][data-state=checked]{font-weight:var(--fw-bold)}
.sel-menu .chk{margin-left:auto;padding-left:var(--sp-4);color:var(--c-ink);font-weight:var(--fw-bold)}
.sel-menu [data-disabled]{color:var(--text-disabled);cursor:not-allowed;background:none}
/* 开关(长期有效等) */
.switch{display:inline-flex;align-items:center;gap:var(--sp-2);padding:0;border:0;background:none;font:var(--fs-body) var(--font-sans);color:var(--text-primary);cursor:pointer}
.switch .track{position:relative;flex:none;width:34px;height:20px;border:var(--line) solid var(--border-control);border-radius:var(--radius-tag);background:var(--bg-surface);transition:background-color var(--dur-fast) var(--ease),border-color var(--dur-fast) var(--ease)}
.switch .track::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:var(--radius-xs);background:var(--c-muted);transition:transform var(--dur-fast) var(--ease),background-color var(--dur-fast) var(--ease)}
.switch:hover .track{border-color:var(--c-ink)}
.switch[aria-checked="true"] .track{background:var(--c-ink);border-color:var(--c-ink)}
.switch[aria-checked="true"] .track::after{transform:translateX(14px);background:var(--c-white)}
.switch .st{min-width:2em;font-size:var(--fs-caption);color:var(--text-secondary)}
.switch[aria-checked="true"] .st{color:var(--text-primary)}
.switch[disabled]{color:var(--text-disabled);cursor:not-allowed}
/* B6 日期输入与日期选择(§7.22、§9.3,ui-demo demo.css 的 .dt / .cal):DateInput.vue。
* 选择器是 Reka UI 的 Calendar(表格结构),格子状态看 data-today / data-selected / data-disabled / data-outside-view */
.dt{position:relative;display:block}
.dt > .input{width:100%;padding-right:40px;font-family:var(--font-figures);font-variant-numeric:tabular-nums;font-size:var(--fs-figure-sm)}
.dt > .input::placeholder{font-family:var(--font-sans);font-size:var(--fs-body)}
.dt > .icon-btn{position:absolute;top:2px;right:2px;color:var(--text-secondary)}
.dt > .icon-btn:hover,.dt > .icon-btn[aria-expanded="true"]{color:var(--text-primary)}
.cal{z-index:56;width:288px;padding:var(--sp-3);background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);box-shadow:var(--shadow-overlay)}
.cal-h{display:flex;align-items:center;gap:var(--sp-1);margin-bottom:var(--sp-2)}
.cal-h > div,.cal-h > h2,.cal-h b{flex:1;text-align:center;font-weight:var(--fw-bold)}
.cal-grid{width:100%;border-collapse:separate;border-spacing:2px;table-layout:fixed}
.cal-grid .wd{font-size:var(--fs-caption);line-height:28px;font-weight:var(--fw-regular);color:var(--text-secondary)}
.cal-grid td{padding:0}
.cal-grid .day{display:grid;place-items:center;height:34px;border-radius:var(--radius-ctl);font:var(--fw-medium) var(--fs-figure-sm)/1 var(--font-figures);font-variant-numeric:tabular-nums;color:var(--text-primary);cursor:pointer;outline:none;transition:background-color var(--dur-fast) var(--ease)}
.cal-grid .day:hover{background:var(--c-n100)}
.cal-grid .day:focus-visible{box-shadow:0 0 0 2px var(--focus-ring)}
.cal-grid .day[data-today]{box-shadow:inset 0 0 0 1px var(--border-control)}
.cal-grid .day[data-selected]{background:var(--c-ink);color:var(--text-on-dark);box-shadow:none}
.cal-grid .day[data-selected]:hover{background:var(--primary-hover)}
.cal-grid .day[data-disabled]{color:var(--text-disabled);cursor:not-allowed;background:none;font-weight:var(--fw-regular)}
/* 非本月的格子留空(不显示日期),和不可选日期不混淆(v0.2) */
.cal-grid .day[data-outside-view]{visibility:hidden}

View File

@@ -1,5 +1,5 @@
# 本地开发与 SaaS 内测期的编排(T4)。
# 只起依赖: docker compose -f deploy/compose/compose.yaml up -d postgres valkey gotenberg
# 只起依赖: docker compose -f deploy/compose/compose.yaml up -d postgres valkey gotenberg ocr
# 全部起来: docker compose -f deploy/compose/compose.yaml --profile app up -d --build
# 端口只绑 127.0.0.1;生产环境的密钥从环境变量注入,不写在这里。
name: bid-doctor
@@ -12,6 +12,7 @@ services:
POSTGRES_PASSWORD: ${BD_PG_OWNER_PASSWORD:-dev-owner}
POSTGRES_DB: bid_doctor
BD_APP_PASSWORD: ${BD_PG_APP_PASSWORD:-dev-app}
BD_OPS_PASSWORD: ${BD_PG_OPS_PASSWORD:-dev-ops}
ports: ["127.0.0.1:${BD_PG_PORT:-54329}:5432"]
volumes:
- pgdata:/var/lib/postgresql/data
@@ -40,6 +41,12 @@ services:
# 生产编排(Helm / 生产 compose)用网络策略禁止它的出口流量。
ports: ["127.0.0.1:${BD_GOTENBERG_PORT:-3109}:3000"]
# OCR 服务(vision-plan §4.2):worker 把扫描页、插图发过来,返回文字行和坐标。模型在镜像里,不需要外网
ocr:
image: bid-doctor-ocr
build: { context: ../.., dockerfile: deploy/docker/ocr.Dockerfile }
ports: ["127.0.0.1:${BD_OCR_PORT:-8029}:8000"]
migrate:
profiles: [app]
image: bid-doctor-server
@@ -61,6 +68,8 @@ services:
BD_MIGRATE_DATABASE_URL: postgresql+psycopg://bd_owner:${BD_PG_OWNER_PASSWORD:-dev-owner}@postgres:5432/bid_doctor
BD_VALKEY_URL: redis://valkey:6379/0
BD_GOTENBERG_URL: http://gotenberg:3000
BD_OCR_PROVIDER: http
BD_OCR_URL: http://ocr:8000
BD_STORAGE_DIR: /data/objects
ports: ["127.0.0.1:${BD_API_PORT:-8009}:8000"]
volumes: [objects:/data/objects]
@@ -68,6 +77,21 @@ services:
migrate: { condition: service_completed_successfully }
valkey: { condition: service_healthy }
# 运营后台接口(决策 M6-2):同一镜像的第三种进程,只绑本机回环;IP 白名单放行本机经 Docker 网桥进来的请求
ops:
profiles: [app]
image: bid-doctor-server
command: ["bid-doctor", "ops", "--host", "0.0.0.0", "--port", "8000"]
env_file: [{ path: ../../.env, required: false }]
environment:
<<: *app-env
BD_OPS_DATABASE_URL: postgresql+psycopg://bd_ops:${BD_PG_OPS_PASSWORD:-dev-ops}@postgres:5432/bid_doctor
BD_OPS_ALLOWED_IPS: '["127.0.0.1/32", "172.16.0.0/12"]'
ports: ["127.0.0.1:${BD_OPS_PORT:-8019}:8000"]
depends_on:
migrate: { condition: service_completed_successfully }
valkey: { condition: service_healthy }
worker:
profiles: [app]
image: bid-doctor-server
@@ -78,6 +102,7 @@ services:
depends_on:
migrate: { condition: service_completed_successfully }
valkey: { condition: service_healthy }
ocr: { condition: service_healthy }
volumes:
pgdata: {}

View File

@@ -1,9 +1,11 @@
#!/bin/sh
# 数据库角色(data-model §2.4):bd_owner 建表和迁移;bd_app 给 api / worker,受行级安全策略约束;
# bd_auth 不登录,只拥有登录前查会话的两个函数。只在数据卷第一次初始化时执行。
# bd_auth 不登录,只拥有登录前查会话的两个函数;bd_ops 给运营后台的跨企业查询(决策 M6-2)。
# 只在数据卷第一次初始化时执行;已有的数据卷要手工补建 bd_ops(见 docs/architecture/m6-plan.md)。
set -eu
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<SQL
CREATE ROLE bd_app LOGIN PASSWORD '${BD_APP_PASSWORD}';
CREATE ROLE bd_auth NOLOGIN BYPASSRLS;
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO bd_app;
CREATE ROLE bd_ops LOGIN BYPASSRLS PASSWORD '${BD_OPS_PASSWORD}';
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO bd_app, bd_ops;
SQL

View File

@@ -0,0 +1,25 @@
# OCR 服务镜像(services/ocr,vision-plan §4.2):RapidOCR + PP-OCRv6 small,CPU 推理。
# docker build -f deploy/docker/ocr.Dockerfile -t bid-doctor-ocr .
# 模型在构建时下载并打进镜像:私有化离线安装、运行时不开网络出口(架构 §3.5、§7)。
FROM python:3.13-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.12.20 /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PROJECT_ENVIRONMENT=/app/.venv
WORKDIR /src
COPY pyproject.toml uv.lock .python-version ./
COPY server/pyproject.toml server/
COPY services/ocr/pyproject.toml services/ocr/
RUN uv sync --frozen --no-dev --no-install-workspace --package bid-doctor-ocr
COPY services/ocr services/ocr
RUN uv sync --frozen --no-dev --package bid-doctor-ocr --no-editable
RUN /app/.venv/bin/python -c "from rapidocr import RapidOCR; RapidOCR()"
FROM python:3.13-slim
# opencv(RapidOCR 依赖)要的系统库
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 bd
COPY --from=build /app/.venv /app/.venv
ENV PATH=/app/.venv/bin:$PATH PYTHONUNBUFFERED=1 OMP_NUM_THREADS=4
USER bd
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --retries=6 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')"
CMD ["bid-doctor-ocr", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -7,6 +7,7 @@ ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PROJECT_ENVIRONMENT=/app/.venv
WORKDIR /src
COPY pyproject.toml uv.lock .python-version ./
COPY server/pyproject.toml server/
COPY services/ocr/pyproject.toml services/ocr/
RUN uv sync --frozen --no-dev --no-install-workspace --package bid-doctor
COPY server server
RUN uv sync --frozen --no-dev --package bid-doctor --no-editable

View File

@@ -0,0 +1,98 @@
# 补遗追加实现方案 v0.1(2026-11-05)
> 范围:FR-M2-10 补遗追加(简化版):追加的补遗 / 澄清文件单独解析、扣 1 次解析,新条目标注来源并入清单;日期类字段以最新文件为准,在旧值上标「已被补遗 N 更新」,由用户确认;不自动判断条目「新增 / 变更 / 作废」(P1)。
> 依据:`03-functional-requirements.md`、`02-user-journeys.md` J2 第 1 步、`01-scope-roles.md`(「追加解析 + 来源标注」,影响分析是 P1)、`data-model.md` §5.2「补遗」、`openapi.yaml`(`/projects/{id}/fields/{key}/pending`、`/checklist-items/{id}/supersession`)、原型 `project-new.html?addendum=1` `parsing.html?addendum=1` `checklist.html` `project.html` 的关键日期卡。
> 状态:AD1–AD6 已全部按建议确认(2026-11-05,`docs/decisions.md`)。
## 0. 结论速览
- **一半已经有了**:后端上传时可以选补遗 / 澄清(编号「补遗 N」「澄清 N」),能单独解析、扣 1 次解析、失败退回;新条目记着来自哪份文件;项目字段的新值和旧值不同时先放进待确认(`pending_value`),不覆盖。
- **缺的**:日期类条目的「已被补遗 N 更新」关联;两个确认接口(字段新值、日期条目);并入后清单回到草稿;前端的追加入口、解析结果、确认界面、来源筛选。
- **按 FR 只做日期类的自动关联**。原型里「★ 参数原为 480G、补遗改成 960G」那种逐条对照属于「变更判断」,是 P1,这次不做(AD2)。
- **补遗的新条目直接进正式清单**,和招标文件的新旧条目都查:宁可多报,不漏报(AD1)。
- **没有补遗样本**:10 份样本里一份都没有。这次用虚构的更正公告测链路;国动项目如果有更正公告,请一起给我,这是第一份真实补遗。
## 1. 现状
| 环节 | 现在 | 要补的 |
|---|---|---|
| 上传、编号 | `role = addendum / clarification`,各自从 1 编号 | 前端入口 |
| 解析、扣点 | 和招标文件同一条流水线,扣 1 次解析,失败、超时、取消退回 | 解析结果页按补遗写(新增几条、哪些日期有新值) |
| 清单项来源 | `origin_document_id` 指向补遗;但接口的 `origin_label` 只有「AI」「人工添加」 | `origin_label` 对补遗来的条目写「补遗 1」;读标页按来源筛选;并入后清单回到草稿 |
| 项目字段 | 新值和旧值不同 → `pending_value`,等确认 | 确认接口和界面;确认后同步冗余列 |
| 日期类条目 | 没有关联(`superseded_by_id` 列已建,没写) | 并入时关联、确认接口、界面 |
| 分包 | 补遗不改已有的分包 | 不变 |
## 2. 并入时做什么(后端)
补遗解析完、并入清单时,在现有的 `checklist.merge` 之后多做三件事:
1. **新条目照常进清单**:和招标文件一样按校验结果进正式清单或待确认区,标「补遗 N」。
2. **关联日期类条目**:补遗里每条有具体时间的「日期与递交」条目(投标截止、开标、提疑截止、保证金截止、投标有效期),在更早的文件(招标文件、编号更小的补遗)里找同一种日期、还在正式清单里的条目,时间不同的把旧条目的 `superseded_by_id` 指向新条目,等用户确认。时间相同的不关联(补遗只是复述)。
3. **清单回到草稿**:已确认的清单改为草稿,历史版本保留,要求重新确认(原型:「补遗 1 并入后待重新确认」)。
解析任务的结果里加:`items_added`(新增几条)、`dates_superseded`(几处日期有新值)、`fields_pending`(几个项目字段有新值),解析页按这三个数写。
## 3. 确认(后端)
按契约实现两个接口:
- **`POST /projects/{id}/fields/{key}/pending`**(`accept: true / false`):
- 采用:补遗的值变成 AI 值(出处换成补遗那一句),**清掉人工值**(以补遗为准;用户之后还可以再改),冗余列同步;
- 不采用:丢掉待确认值,保持原值;
- 采用投标截止时间时,清单里「投标截止」日期条目的更新一起确认(AD3),不用在读标页再点一次;
- 都写审计(`project_field.resolve_pending`)。
- **`POST /checklist-items/{id}/supersession`**(`accept: true / false`,对旧条目调用):
- 采用:旧条目软删除(可以恢复),新条目保留;
- 保留两条:解除关联,两条都在,由用户自己处理;
- 都写审计(`checklist_item.resolve_supersession`)。
接口返回里补上已有字段:清单项的 `superseded_by`(新条目编号、来自哪份补遗)、清单概况的 `superseded_pending`(还有几处没确认)、关键日期的 `superseded_pending`。
**没确认之前查标用旧值**(AD4):项目字段的契约就是「确认后才生效」(`data-model.md` §5.2)。检查预览提示「补遗 1 带来的新值还有 N 处没确认」,不阻止发起检查(红线 4)。确认后截止时间变了,已有的检查结果按 PI5 提示重新检查。
## 4. 前端
- **追加入口**:概览页右上角和读标页清单栏各放一个「追加补遗 / 澄清」,进新建项目页的补遗模式(原型 `project-new.html?addendum=1`)。补遗模式下不填项目名称,文件类型选补遗或澄清答复,说明写「补遗单独解析,新条目标注『补遗 N』并入清单。标博士不判断哪些旧条目已作废,并入后由你核对」,按钮旁写扣 1 次解析。招标文件还没解析完时不显示入口(AD5)。
- **解析页**:标题「解析补遗 1」;完成后写「新增 N 条 · M 处日期有新值 · K 个项目字段有新值 · 清单回到草稿,核对后重新确认」,下一步「核对补遗」进读标页、只看这份补遗的条目。
- **概览页**:
- 项目要点里有待确认值的字段写「补遗 1:新值 → 原值」,带出处,给「采用 / 不采用」;
- 关键日期卡:被更新、还没确认的日期显示「已被补遗 1 更新,待确认」;确认后显示新值并注「已按补遗 1 更新 · 原为 11月18日 09:30」(原型 `project.html`)。
- **读标页**:
- 来源筛选:全部 / 招标文件 / 补遗 1 / ……,每项带条数;
- 被更新的日期条目写「已被补遗 1 更新:新值 ……」,给「确认更新(删除旧条目)」和「保留两条」;
- 并入后顶部提示「补遗 1 新增 N 条。标博士不判断哪些旧条目被它改了或作废了,请核对:改了的旧条目可以修改或删除」,带「只看补遗 1」。
- **查标页**:检查预览里有没确认的新值时出上面那条提示。
## 5. 测试
- 虚构一份更正公告样例(`server/tests/fixtures/addendum-mini.pdf`,内容全虚构):改投标截止时间、开标时间,新增一条 ★ 参数,复述一条没改的日期。
- 集成测试:追加 → 解析 → 扣 1 次 → 新条目标「补遗 1」、清单回到草稿、截止时间进待确认、日期条目关联(复述的不关联);字段采用 / 不采用;采用截止时间时日期条目一起确认;日期条目采用(旧条目软删除、可以恢复)/ 保留两条;检查预览的提示;只读项目 409。
- 假模型能不能从样例里抽出日期要看它的规则;抽不出时测试里用接口建条目。浏览器里走一遍追加到确认的全程。
- 用真实模型对这份虚构样例跑一次解析看看抽取结果,花费几分钱(AD6)。
## 6. 工作量和顺序
1. 后端:并入时关联日期、清单回到草稿、两个确认接口、接口字段补齐、检查预览提示,以及测试样例和测试。约半天。
2. 前端:补遗模式的上传页、解析页的补遗结果、概览页的确认、读标页的来源筛选和日期确认。约半天。
## 7. 待确认事项
| 编号 | 事项 | 建议 |
|---|---|---|
| AD1 | 补遗的新条目进正式清单还是待确认区 | **按招标文件同一套规则进正式清单**(没出处、校验没过的照样进待确认区),标「补遗 N」,读标页给「只看补遗 N」和核对提示,清单回到草稿要求重新确认。新旧条目都在、都查,宁可多报,不漏报;全放待确认区的话,用户不逐条采纳就查不到补遗的新要求 |
| AD2 | 哪些条目自动标「已被补遗更新」 | **只做日期类**(同一种日期、时间不同),按 FR。原型里参数类「原为 480G → 现为 960G」的逐条对照是变更判断,P1;先等真实补遗样本看写法 |
| AD3 | 怎么确认 | **字段在概览页逐个「采用 / 不采用」,日期条目在读标页逐条确认**;采用投标截止时间时清单里的「投标截止」条目一起确认。采用时清掉人工值,以补遗为准 |
| AD4 | 没确认之前查标用哪个值 | **旧值**,确认后生效(数据模型原定);检查预览提示还有几处没确认,不阻止检查 |
| AD5 | 招标文件没解析完能不能追加补遗 | **不能**,界面上招标文件解析成功后才显示入口。没有招标文件时补遗的日期没有可关联的旧值 |
| AD6 | 用真实模型对虚构样例跑一次解析 | **跑一次**(DeepSeek,几分钱),看抽取对补遗写法(「原为……现更正为……」)的表现;结果只记录,这次不按它调提示词 |
## 8. 落地记录(2026-11-05)
- 后端:`checklist/addendum.py`(并入时关联日期条目、确认 / 保留两条、采用截止时间时一起确认「投标截止」条目);`project/edit.resolve_pending`(采用时补遗的值变成 AI 值、出处换成补遗那一句、清掉人工值);清单项接口补 `origin_document`、`superseded_by`,`origin_label` 对补遗来的条目写「补遗 N」;清单概况 `superseded_pending`;关键日期 `replaces`(「已按补遗 1 更新 · 原为……」);解析结果 `dates_superseded`、`fields_pending`;检查预览 `pending_updates`。
- **补遗不改项目是谁**:解析补遗时项目名称、编号、采购人、代理机构、法律体系不进待确认。测试里发现,更正公告的标题会被抽成「项目名称」,和招标文件的不同,每份补遗都会冒出一条没意义的待确认。
- **时间比较按北京时间补时区**:模型给的时间有时带时区、有时不带,补遗复述原时间时不能误判成「有新值」。
- ORM 给 JSONB 列写 `None` 存的是 JSON null,不是 SQL NULL,SQL 里数待确认值时要排除 JSON null。
- 前端:补遗模式的上传页(选补遗 / 澄清答复)、解析页的补遗结果和「核对补遗」、概览页「追加补遗 / 澄清」和字段「采用 / 不采用」、关键日期的待确认和「已按补遗 1 更新」、读标页的来源筛选、核对提示、「只看日期更新」和逐条确认、条目上的「补遗 1」标签、检查预览的提示。
- **AD6 结果**:用 DeepSeek Flash 对虚构的更正公告(推后递交截止时间、新增一条 ★ 参数)跑了一次解析(6 次调用):两处改动都抽对了,★ 参数归为技术参数 + 无效条款、新截止时间 12 月 8 日,出处都是「补遗 1 · 第 1 页」,没有多抽。只是一份很规整的虚构样例,「原为……现更正为……」那种写法这份样例里没有,真实补遗到了再看。
- 浏览器里走过:追加补遗 → 解析结果 → 概览采用新截止时间(项目头、要点、关键日期一起变,旧的「投标截止」条目自动删除)→ 读标页只看补遗 1。

View File

@@ -46,15 +46,15 @@
| 步骤(原型页面) | 接口 |
|---|---|
| 注册 / 登录(login) | `POST /auth/sms-codes` → `POST /auth/register` 或 `POST /auth/login` → `GET /me` |
| 工作台(workbench) | `GET /projects`,每行带「下一步」`next_action`、就绪状态、正在跑的任务;`GET /quota` 给顶栏额度 |
| 工作台(workbench) | `GET /projects`,每行带「下一步」`next_action`、就绪状态、正在跑的任务;`GET /quota` 给顶栏额度;「待处理的致命问题」按有致命问题的项目取 `GET /check-runs/{id}/findings`,「去处理」带 `?f=问题编号` 进查标页 |
| 新建项目、上传招标文件(project-new) | `POST /projects` → `POST /uploads` → `PUT /uploads/{id}/parts/{n}` × N → `POST /uploads/{id}/complete` → `POST /projects/{id}/documents` → `POST /documents/{id}/parse-jobs`(预扣 1 次) |
| 解析进度(parsing) | `GET /events` 收 `job.progress`;断线时轮询 `GET /parse-jobs/{id}`;`POST /parse-jobs/{id}/cancel`;失败后重试再调一次 `POST /documents/{id}/parse-jobs` |
| 选包(lots) | `GET /projects/{id}/lots` → `PUT /projects/{id}/bidding-lots`;手工增删改分包 |
| 读标(checklist) | `GET /projects/{id}/checklist`、`GET /projects/{id}/checklist/items`;原文查看器用 `GET /documents/{id}/file?variant=normalized`、`GET /documents/{id}/pages`;选句补出处用 `GET .../pages/{n}/sentences` → `POST /projects/{id}/citations`;修改、采纳、删除、恢复、补遗更新、修改记录、确认清单 |
| 读标(checklist) | `GET /projects/{id}/checklist`、`GET /projects/{id}/checklist/items`;原文查看器用 `GET /documents/{id}/file?variant=normalized`、`GET /documents/{id}/pages`;选句补出处用 `GET .../pages/{n}/sentences` → `POST /projects/{id}/citations`;修改、采纳、删除、恢复、补遗更新、修改记录、确认清单;每条的检查结论用 `GET /check-runs/{id}/item-results`(「已响应」的反馈判断有误走 `POST /feedback`,`object_type = checklist_item`、`kind = false_negative`);资质库的情况用 `GET /projects/{id}/qualification-matches` |
| 上传投标文件、发起检查(bid-check) | 上传同上,`role = bid_volume`;`GET /projects/{id}/check-preview` 给出扣点说明 → `POST /projects/{id}/check-runs` |
| 查标(results) | `GET /check-runs/{id}`、`GET /check-runs/{id}/findings` → `POST /findings/{id}/resolve`、`/reopen`(U 键)、`POST /check-runs/{id}/findings/bulk-resolve`;需人工核对用 `GET/PUT /projects/{id}/manual-checks`;`POST /feedback` |
| 报告(report) | `POST /check-runs/{id}/reports` → 事件 `report.ready` → `GET /reports/{id}/file` |
| 项目概览(project) | `GET /projects/{id}`、基本信息字段的修改和补遗确认、`GET /projects/{id}/documents`、`GET /projects/{id}/check-runs`、`POST /projects/{id}/transitions`(递交 / 归档及撤销)、`DELETE /projects/{id}` |
| 项目概览(project) | `GET /projects/{id}`、基本信息字段的修改和补遗确认、`GET /projects/{id}/documents`(文件卡)、`GET /projects/{id}/check-runs` 和 `GET /projects/{id}/reports`(检查记录卡)、`POST /projects/{id}/transitions`(递交 / 归档及撤销)、`DELETE /projects/{id}` |
| 额度(quota) | `GET /quota`、`GET /quota/entries`、`GET /plans`、`POST /activation-requests` |
| 资质库(qualifications) | `/qualifications` 的增删改;附件先上传,再 `POST /qualifications/{id}/files` |
@@ -101,6 +101,7 @@
- 只有最近一次检查的问题可以处理,历史批次返回 409 `run_superseded`;
- 批量处理只限「提示」级,混进了别的级别就整批拒绝,返回不符合的问题编号。
- **已递交、已归档的项目只读**:所有修改接口都返回 409 `project_read_only`。撤销递交或撤销归档之后才能改(FR-M6-02)。
- **套餐到期后整个账号只读**(决策 M6-5):写操作返回 409 `plan_expired`;退出、续费申请、删除项目、注销账号、通知已读、反馈照常放行(在鉴权依赖里统一判断)。
### 4.6 不在接口里出现的东西
@@ -136,7 +137,7 @@
| 403 | `csrf_failed` `forbidden` | CSRF 头不对;没有这个权限点 |
| 404 | `not_found` `phone_not_registered` | |
| 409 | `concurrent_session` | 已有其他设备在线(API3) |
| 409 | `quota_exhausted` `billing_changed` `plan_limit_exceeded` | 额度、计费、套餐限制 |
| 409 | `quota_exhausted` `billing_changed` `plan_limit_exceeded` `plan_expired` `activation_pending` | 额度、计费、套餐限制和到期 |
| 409 | `job_already_running` `job_finished` | 任务状态冲突 |
| 409 | `project_read_only` `invalid_transition` `run_superseded` `item_not_pending` `document_in_use` 等 | 业务状态不允许这个操作 |
| 412 | `precondition_failed` | If-Match 版本不一致 |
@@ -186,13 +187,14 @@
| 模块 | 已实现 | 还没实现 |
|---|---|---|
| 登录与账号 | 发验证码、注册、登录(含挤下线确认)、退出、`GET /me` | 修改显示名、在线会话列表和下线、注销账号 |
| 额度与套餐 | `GET /plans`、`GET /quota` | 额度流水、开通申请 |
| 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期) | 基本信息字段修改与补遗确认、状态变更、删除、分包的增删改和选包 |
| 登录与账号 | 发验证码、注册、登录(含挤下线确认)、退出、`GET /me`、注销账号(`POST /me/sms-codes` + `POST /tenant/close`,m6-plan) | 修改显示名、在线会话列表和下线 |
| 额度与套餐 | `GET /plans`、`GET /quota`(含按功能分布、排队的续费、待处理的申请)、额度流水、开通申请(m6-plan) | — |
| 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期)、状态变更、基本信息字段修改、补遗新值的采用 / 不采用、分包的增删改、「不分包」和选包、删除(m6-plan) | — |
| 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消、人工建出处 | 删除文档 |
| 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本 | 补遗更新确认(等补遗解析);列表的 `lot_scope`(等选包) |
| 检查、报告、资质 | — | 全部(查标阶段) |
| 通知与事件 | 事件流、通知列表 | 标为已读、反馈 |
| 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别、所投的包筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本、「已被补遗 N 更新」的确认 | — |
| 检查 | 检查预览、发起、进度、取消、检查记录、问题列表(含本次已解决)、每条清单项的检查结论(C9,2026-11-04 新增)、处理、撤销、批量处理(只限提示级);投标分册类型和版本、电子投标平台加密格式识别 | 需人工核对(第 2 步) |
| 报告、资质 | 报告:导出、生成进度、历次报告、签名下载(第 5 步);资质库:增删改、按类型 / 关键词筛选、附件(上传后挂上、签名下载、删除)、`late_for` 到期提醒(第 4 步) | — |
| 通知与事件 | 事件流(含检查进度)、通知列表(标题、链接、项目名称、分页)、标为已读、反馈 | — |
读标修改类接口的实现约定(2026-11-03):

View File

@@ -189,7 +189,7 @@ flowchart LR
- **过时结果直接丢弃**:任务带运行编号,写结果前比对;发现已经有更新的一次运行,就直接丢弃本次结果。
- **取消**:取消标记写在数据库里(权威),同时通过 Valkey 发一条消息,让 worker 立即知道。worker 在阶段之间、每批模型调用之间检查这个标记,不需要每 300ms 轮询数据库。即使消息丢了,worker 到下一个检查点也会读到数据库里的标记。
- **优雅退出**:worker 收到 SIGTERM 后不再领取新任务,等手头这个阶段跑完再退出;超时没跑完的任务由队列的租约机制自动交还给别的 worker。
- **进度推送**:worker 把进度写进数据库,再通过 Valkey 的发布 / 订阅通知;api 用 SSE 推给前端。多个 api 实例都能收到这条通知。前端断线后用轮询兜底,轮询读的是数据库里的进度。
- **进度推送**:worker 把进度写进数据库,再写一条事件到 Valkey stream(SK1);api 用 SSE 推给前端,断线重连时按 `Last-Event-ID` 补发。多个 api 实例都能读到。前端断线后用轮询兜底,轮询读的是数据库里的进度。
- **并发**:worker 内部用 asyncio 并发调用大模型,每个供应商、每个租户都有并发上限(§3.6)。
### 3.5 文档处理流水线
@@ -776,7 +776,7 @@ docs/ # 现有文档
- **合并代码的前提**:类型检查通过、单元测试通过、租户隔离用例通过;改了提示词或路由的,还要跑评测回归。
- **测试分层**:
- 规则引擎和证据校验用单元测试覆盖,要求接近 100%;
- 接口用集成测试,跑在真实的 PostgreSQL 上,用 testcontainers;
- 接口用集成测试,跑在真实的 PostgreSQL 上:本地连 Compose 起的数据库,CI 用服务容器,每次测试会话重建测试库(SK2);
- 关键流程用端到端测试:上传 → 解析 → 检查 → 导出。
- **大模型在测试里用录制回放**,CI 不调用真实模型;评测回归单独跑,需要真实模型。
@@ -843,12 +843,12 @@ S1–S4 风险最高,建议先做;S8(桌面外壳 PoC)等桌面版立项
## 13. 工程骨架的落地记录(2026-11-03)
搭骨架时按代码实际情况做了下面这些调整或补充。SK1、SK2 改了上文的写法,待确认;其余是实现细节,记在这里备查。
搭骨架时按代码实际情况做了下面这些调整或补充。SK1、SK2 改了上文的写法,2026-11-04 已确认(decisions.md C1–C11、SK1–SK2);其余是实现细节,记在这里备查。
| # | 事项 | 做法 | 状态 |
|---|---|---|---|
| SK1 | 进度推送(§3.4) | 用 **Valkey stream** 代替发布 / 订阅:一个结构同时解决实时推送和断线补发(按 `Last-Event-ID` 续读),多个 api 实例都能读到。Valkey 丢了也只是没有推送,前端轮询兜底 | 待确认 |
| SK2 | 接口集成测试(§8) | 不用 testcontainers,改为连 Compose 起的 PostgreSQL,每次测试会话重建 `bid_doctor_test` 库;CI 里用 GitHub Actions 的服务容器。更快,本地和 CI 走同一套配置 | 待确认 |
| SK1 | 进度推送(§3.4) | 用 **Valkey stream** 代替发布 / 订阅:一个结构同时解决实时推送和断线补发(按 `Last-Event-ID` 续读),多个 api 实例都能读到。Valkey 丢了也只是没有推送,前端轮询兜底。发布用的客户端设短超时、SSE 连接池设上限(2026-11-04 补) | 已确认 |
| SK2 | 接口集成测试(§8) | 不用 testcontainers,改为连 Compose 起的 PostgreSQL,每次测试会话重建 `bid_doctor_test` 库;CI 里用 GitHub Actions 的服务容器。更快,本地和 CI 走同一套配置。CI 直接跑 `init-roles.sh`、加 Gotenberg(2026-11-04 补) | 已确认 |
| SK3 | 建任务和入队在同一个事务里(§3.4) | Procrastinate 的 `defer` 用的是它自己的连接,进不了我们的事务,所以直接调用它的 SQL 函数 `procrastinate_defer_jobs_v1`(`core/queue.py`);它的表放在 public schema。pyproject 锁定了大版本,升级时要核对 | 已实现 |
| SK4 | 上传会话(§5.2 第 7 条) | 上传会话的元数据放在 Valkey(24 小时过期),分片按企业级数据密钥加密后写进对象存储;挂到项目上时换成项目密钥 | 已实现 |
| SK5 | ORM 模型 | 表结构以迁移为准,ORM 模型只做列映射、不声明外键,所以写入顺序要自己 `flush` 保证(测试里踩到过两次) | 编码约定 |
@@ -861,9 +861,12 @@ S1–S4 风险最高,建议先做;S8(桌面外壳 PoC)等桌面版立项
| SK11 | 待确认审计后的修正(2026-11-03) | 国动样本 73 条待确认里多数是好结果被校验误拦,逐条审计后按「规则只修真正的缺陷、不迁就模型的错误输出;模型输出不合理的改提示词」处理。**文字层 v3**:两端接竖线的横线是表格框线,不算下划线(功能表整格被误认成斜体下划线条款);句号后紧跟的右括号、右引号并进同一句(切句 s3-v3,句子编号和 v2 不同)。**结构识别**:中文编号小节(「三、……否则投标无效」)下的 1. 2.,以冒号结尾的编号条目下的句子;依据核对沿上级链往上找。**校验**:比对引文前去掉我们自己加进输入的格式标记和〔…〕附注;不足 8 个字的短句整句复制时句末标点可省;取值核对认带量词的中文数字、同一行的单元格和评分因素标题;补「投标(响应)无效」「不能通过资格审查」「没有实质性响应」「拒收」等写法。引文最短 8 个字、编号必须对、证据要全部通过,这些不放宽。**提示词**:substantive v3、qualification / scoring / schedule / tech_param v2,统一证据规则(引文复制整句、并列多项不拆、跨页只引上一行、数字写阿拉伯数字),除实质性条款外输入改成纯文本;资格不收附件范本和价格扣除条件,日期不收合同条款,技术参数一句一条。**模型路由**:`BD_AI_MODELS` 按提示词声明的能力选模型,没配的用 `BD_AI_MODEL`。离线评测工具 `evals/run_extract.py` | 已实现 |
| SK12 | 参考 BidEngine 的五项改进(2026-11-03) | 见 `ref-bid-engine-analysis.md` §5。**原因码**:没通过校验的结果记具体原因(`extract.FAILURES`,存在清单项 `ai_values.failures`),解析结果按原因计数(`verify_failures`),提示词规则和原因码的对照表在 `prompts/README.md`。**分块**:相邻分块重叠约 800 字;模型输出被截断(`finish_reason=length`)或不是合法 JSON 时,把分块对半重跑,最多 3 层,拆到底仍不完整的记 `output_incomplete`(以前当成「这一块没有结果」,审计发现 deepseek-flash 有输出正好停在 8192 token、整块条目悄悄丢掉的情况)。**项目基本信息**:提示词 v2,12 个字段每个都要表态 found / ambiguous / not_found,跨分块合并后分 found / absent(文件本身没写)/ needs_review(抽取有问题),记在解析结果 `info_fields`。**占位符**:「____元」「××」「( )」「待填」不算取值。**提示词**:substantive v4,qualification / scoring / schedule / tech_param v3,每条禁止事项对应一个原因码。三份样本的待确认:国动 4 → 2,S02 13 → 3,S06 57 → 55(S06 有 4 次对半重跑,截断的分块都救回来了) | 已实现 |
| SK13 | S01–S10 标注集评测后的修正(2026-11-03) | 见 `extract-eval.md` §7。规则:合并时待确认条目不再「借道」正式条目(`pending_categories`),表后说明、章识别、★ 节标题、输出接近上限按截断处理、格式范本和整表概括句不收;提示词 substantive v5、qualification v4、schedule v4、tech_param v4;WPS 旧版 DOC 直接修正字体表字符集;表格按单元格切句(切句 s3-v4)。口径 Q1–Q4 见 decisions.md。报告分数有过拟合,只按人工标注算未全部达标,泛化未测,见 `extract-status.md` | 已实现 |
| SK14 | 查标第 1 步(2026-11-04) | 见 `m3-plan.md`「第 1 步落地记录」。解析流水线的前几个阶段(格式核对、转换、文字层、扫描件判断)拆到 `document/pipeline/normalize.py`,解析任务和检查批次共用;投标文件每个版本只解析一次,扫描页不报错,交给检查按「请人工核对」处理(C10)。新增 `check`、`ops`(反馈)模块,worker 任务 `check_project`。Valkey 客户端设 2 秒超时、SSE 连接池上限 500(SK1);CI 加 Gotenberg、直接跑 `init-roles.sh`(SK2) | 已实现 |
| SK15 | OCR 进文字层(2026-11-05,vision-plan 第 1 步) | **OCR 服务** `services/ocr`(uv workspace 成员、镜像 `deploy/docker/ocr.Dockerfile`、Compose 服务 `ocr`):RapidOCR + PP-OCRv6 small,CPU;整页转正(90° / 270° 试识别、180° 用方向分类模型判);找红章,章附近按红色通道重读;返回行坐标、转正后坐标、红章位置。**worker**(`document/pipeline/ocr.py`):`BD_OCR_PROVIDER=http` 时,扫描页整页、文字页里占页面 5% 以上的图片区域渲染成 200 dpi 图片发给服务;OCR 行换算回 PDF 坐标,和文字层重叠的去掉,章上沿圆弧排的字去掉;切句沿用文字层规则,插图里的字各自成句;句子带 `source = "ocr"` 和置信度,接在原有句子后面编号(文字层 v6,原有编号不变)。OCR 服务不可用按基础设施错误退回额度(`ocr_unavailable`)。**查标**:识别文字参与响应匹配;依据识别文字的问题降一级、报价数字降到提示(VS4);规则检查不看扫描附件和插图上的识别文字(整份扫描件除外);扫描件兜底的说法改为「识别了文字但没认出这份材料」。**出处**:`citation.ocr`(迁移 0013),界面和报告在原句前标「OCR」(设计规范 §7.3,报告模板 v5)。国动真实投标文件 183 页识别约 65 秒,见 `check-eval.md` §16 | 已实现 |
| SK16 | 资质库上传识别(2026-11-05,vision-plan 第 2 步) | 「添加资质」对话框最上面传证书照片,识别后填表(单一入口,VS12;一张图一张证书,VS11)。接口 `POST/GET/DELETE /qualification-scans`,页面图走签名地址;worker 任务 `recognize_qualification`(`qualification/recognize.py`):OCR 服务 → DeepSeek Flash 看图(`vision.cert_fields` v1,`ai.run_json` 加 `images` 入参、`ai_call.job_type` 加 `qualification_scan`)→ 字段引用 OCR 行逐个校验(一致、日期一致、识别不确定、不一致、格式不对),只有一致的预填。识别值存 `qualification.ai_values`,照片挂成附件;新增 `qualification.holder`(迁移 0014)。附件支持 WEBP。不扣额度,每企业每天 100 次 | 已实现 |
SK11 修正前后(同一模型 deepseek-chat,即 deepseek-flash 不思考模式)的待确认数:国动 46 → 4,S02 17 → 13,S06 174 → 57;同一句被拆成多条的情况没有了。剩下的待确认多数是表格两栏或竖排文字串行(要按单元格切句才能解决)。实质性条款任务上比较了 flash 不思考、flash 思考(deepseek-reasoner)、deepseek-v4-pro:抽到的条款大体一致、互有多少,后两者慢 15~45 倍、输出 token 多 7~12 倍,没有看出明显更准;选型等标注集评测出结果再定。
用国动便民小程序(71 页)、S02、S06 三份文件实测(真实模型 DeepSeek):单份 33~87 秒,清单 216~1348 条,正式清单里的条目都带页码和原句。发现并已修复:标注规则说明的碎片被当成条款、页码混进正文、跨页拆成两条、「做无效响应处理」等写法漏认、上级标题下列出的无效情形被合成一条或漏掉、「无效质疑」被误当成投标无效、「是否接受联合体」取错证据。
还没做、留了 TODO 的:转换镜像的字体包(S1)、OCR(S5)、分包对应到清单项、模型路由的候选列表、降级和限流、查标全部。S01–S10 标注集评测和修复见 `extract-eval.md`。
还没做、留了 TODO 的:转换镜像的字体包(S1)、分包对应到清单项、模型路由的候选列表、降级和限流、查标全部。S01–S10 标注集评测和修复见 `extract-eval.md`。

View File

@@ -0,0 +1,387 @@
# 查标评测(2026-11-04 起)
> 评测集规范:`evals/check/GUIDELINES.md`;工具:`evals/check/`;数据和输出在 `evals/out/check/`(不进 git)。
> **读数的前提**:金标准由 Fable 复核、用户抽查 20%(决策 E1),未经全部人工复核;本文的数字都是**开发集**上的,只用于找问题、调实现,不能当效果证据。头条效果以留出集(风格 C)和真实国动投标文件为准,第 3 步冻结代码后才跑。
## 1. 试跑(第 0 步,2026-11-04)
**范围**:开发集 S02、S04、S10 各两份投标文件(规矩 + 马虎),都是风格 A(风格 B 的改写还没做)。查标代码是第 1 步的版本:响应匹配只有「检索 + 模型判断」,规则检查还没有。模型 DeepSeek Flash,提示词 `check.match` v1。
**复核**:马虎的投标文件共注入 82 个要求项问题,Fable 复核通过 70 个、不通过 12 个(S02 10 个:默认接受的文件里注入了漏响应、「暂不支持」套在尺寸和费用这类参数上、要求本身是可选项等)。下面只算通过的。用户抽查 17 条(随机 20%,含 Fable 判不通过的 4 条),**全部同意**(2026-11-04)。
### 1.1 结果
| 投标文件 | 清单条数 | 报出问题 | 查标召回 | 致命级召回 | 规矩响应的要求上误报 | 不需要响应的条目上报了问题 |
|---|---|---|---|---|---|---|
| S02 规矩 | 248 | 104 | — | — | 21 / 56 | 39 |
| S02 马虎 | 248 | 117 | 8 / 16(50%) | 2 / 7 | | |
| S04 规矩 | 264 | 90 | — | — | 11 / 111 | 52 |
| S04 马虎 | 264 | 110 | 11 / 28(39%) | 1 / 4 | | |
| S10 规矩 | 305 | 122 | — | — | 6 / 39 | 75 |
| S10 马虎 | 305 | 142 | 5 / 20(25%) | — | | |
- 合计查标召回 24 / 64(38%)。只检索不调模型的基线召回 0:基线把检索到的第一句都当作已响应,说明现在查出的问题来自模型判断,不是检索。
- 按问题类型:漏响应(D01)9 / 14 较好;写明不支持(D05)5 / 20、负偏离未说明(D06)0 / 7、响应对象错位(D09)0 / 4 很差。
- 每次检查调用模型 31~39 次,输入约 9~11.5 万 token、输出约 1.5~1.7 万 token(约一两毛钱)。
### 1.2 问题出在哪里
1. **不需要投标人响应的条目也被报了「未找到响应」**:三份规矩的投标文件报了 316 个问题,166 个落在根本不需要响应的条目上(无效情形的列举、日期、采购人义务、别的采购包),这是误报的大头。→ C12 的响应方式抽取,加上「这条要不要投标人回答」的判断和按所投采购包过滤(第 2、3 步)。
2. **表格里的「暂不支持」「负偏离」看不见**:表格按单元格切句,检索命中的是投标人照抄要求原文的那一格,同一行里写着「暂不支持」「负偏离」的格子不在模型的输入里。→ 第 3 步把整行交给模型(偏离表直取也是这个思路)。
3. **证明材料和格式文件找不到**:规矩响应的要求上的误报,主要是证明材料(扫描件页没有文字层)和格式文件(声明函、承诺书)报了「未找到响应」,以及让投标人「见附件」的要求被报成「未给出具体值」。→ C10 扫描件兜底;文件要在(presence)的要求按文件在不在判断,不逐条找响应。
4. **级别口径**:S02 的 ★ 是「核心技术参数,负偏离扣分」,不是投标无效条款;标注集记成实质性条款,查标按致命报和真实评审不一致。→ 级别要跟着招标文件对 ★ 的定义走(读标口径 Q2 已有,查标沿用)。
### 1.3 评测集自身的问题(已修 / 待修)
| 问题 | 状态 |
|---|---|
| 分项报价平均摊,一项超过单价上限,规矩的投标文件自带致命问题(S10) | 已修:按分项上限比例分摊 |
| 偏离表有「设备名称」列,按固定列写会错位(S04) | 已修:按表头决定列 |
| 写死的落款日期晚于投标截止日(S04) | 已修:取截止日前 3 天 |
| 同一参数列在两张表里,只删一张表的行不算漏响应(S04) | 已修:漏响应在所有表里都删 |
| 符合性条件响应表的一格被算成 11 个问题 | 已修:按表级问题记一个 |
| 标注集同一要求有两个单元(两个类别、或表格一行拆成几条),一个问题算两次 | 已修:评分按句子重叠合并 |
| 「暂不支持」套在尺寸、费用、打印方式这类参数上,不像真实写法 | 待修:按参数类型选注入方式 |
| 默认接受的文件里注入漏响应(S02 另加了一张逐条的技术规格表,脚本推响应方式时被它带偏) | 待修:响应方式以档案的 modes 为准,不从表格推 |
| 「提供某文件」类要求被当成参数注入偏离 | 待修:推响应方式时识别 presence |
| 风格 B 的改写(截断、同义替换、只写值)还没做;所有数字都是风格 A 的 | 待做 |
## 2. 开发集第一版(2026-11-04)
**范围**:开发集 7 份招标文件各两份投标文件,共 14 份:S02、S05、S08、S10 是 A 规矩 + B 马虎,S01、S04、S07 是 B 规矩 + A 马虎(GUIDELINES §4)。生成器修了试跑的待修项(「暂不支持」不再套在数值参数上、表格响应方式以档案为准、「提供……文件」不注入参数类问题),风格 B 由模型改写、数字对不上的改写丢弃。查标代码仍是第 1 步的版本。
**复核**:注入的要求项问题两批共 222 个,Fable 复核通过 188 个。第二批 140 个里不通过 22 个,其中 14 个在 S07:S07 须知要求逐条标明,格式注又写未列入视作接受,且只有 ★ 项写明未响应无效,非 ★ 参数删一行算不算问题拿不准,金标准从严不算(查标侧仍按 C12 取更严的逐条)。用户抽查第一批 17 条全部同意;第二批抽查 28 条(含 Fable 判不通过的 4 条)也**全部同意**。两批合计抽查 45 条,和 Fable 的判断一致。
### 2.1 结果
| 指标 | 结果 |
|---|---|
| 查标召回(复核通过的问题) | 51 / 128(40%);风格 A 25 / 70(36%),风格 B 26 / 58(45%) |
| 端到端召回(含读标漏抽) | 51 / 132(39%) |
| 致命级召回 | 18 / 37(49%) |
| 不带标志词 / 带标志词 | 40 / 81(49%)/ 11 / 51(22%) |
| 连续一段 / 单点 | 7 / 12(58%)/ 44 / 120(37%) |
| 规矩的投标文件报出的问题 | 清单 2049 条,报出 699 个 |
| 其中规矩响应的要求上的误报 | 153 / 620(每 100 条 24.7) |
| 其中不需要响应的要求上报的 | 334 个(占 48%) |
| 干扰项误报 | 默认接受的要求没复述(T06)39,改写后的要求(T02)20,响应写在别的分册(T01)3 |
| 费用 | 14 次检查输入 150 万 token、输出 25 万 token |
按问题类型:资格证明缺失(D11)9 / 14、评分材料缺失(D13)9 / 12、漏响应(D01)15 / 23 较好;负偏离未说明(D06)0 / 20、响应对象错位(D09)1 / 14、证明材料缺失(D02)2 / 9、写明不支持(D05)11 / 31 很差。
### 2.2 读法
- **试跑的四个判断都成立,而且更清楚了**:「带标志词」的问题(写明不支持、负偏离)召回只有 22%,比不带的还低,正是表格里同一行的响应格、偏离格不在模型输入里;风格 B 反而比 A 高,因为 A 照抄要求原文,检索更容易命中照抄的那一格。规矩的投标文件上近一半的问题落在不需要响应的要求上,默认接受的要求被报了 39 次。
- **第 2、3 步按这个顺序改收益最大**:① 整行上下文 / 偏离表直取(直接对应 D05、D06、D09,51 个问题里的 45 个);② 响应方式和「这条要不要投标人回答」(C12,对应 334 + 39 个误报);③ 扫描件兜底和 presence 判断(证明材料、格式文件类误报)。
### 2.3 评测集还要改的
| 问题 | 状态 |
|---|---|
| 「暂不支持」「可选配置」仍会套在证书、供货清单、服务义务、文档交付这类非功能要求上(两批共 12 条被复核拒掉) | 待修:注入前按「功能类要求」过滤 |
| 同一要求被两个标注单元挂了同一个问题(实质性 + 技术参数、资格 + 符合性,第二批约 20 对);「第二十二条总述」这类伞状单元和具体条目不重叠 | 待修:注入前按 anchor 重叠合并标注单元;伞状单元不挂问题 |
| 条件性要求(「如依法免税则须提供」)被当作必须提供的材料挂 D11 | 待修:按投标人情况排除 |
| 低密度(每份 5~8 个问题)的投标文件还没做 | 待做:选 3 份 |
## 3. 第一轮改进:整行上下文(2026-11-04)
**改了什么**:候选句在表格里时,把同一行的单元格一起交给模型,也可以引用(`check/match.py`);提示词 `check.match` v2 说明怎么读一行(要求格 / 响应格 / 偏离格)。对应 §2.2 的第 ① 项。v2 的规则写法避开了开发集注入词库里的说法(重合的只有「负偏离」「完全响应」,是招标文件格式自带的用语)。
**评测集同时修了 §2.3 的前三项**(「暂不支持」只套功能类要求、伞状单元和条件性要求不挂问题),7 份马虎的投标文件用原来的种子重新生成,另加 3 份低密度的,共 17 份。新目标 55 个由 Fable 复核(通过 54 个),用户抽查 11 个全部同意(三批合计抽查 56 个,全部同意)。因为注入的问题换了一批(「负偏离未说明」多了、「暂不支持」少了),下表的「旧做法」要和同一批投标文件比,不能和 §2 的数字直接比。
| 指标(17 份,复核通过的问题) | 旧做法(v1、不带整行) | 新做法(v2 + 整行) |
|---|---|---|
| 查标召回 | 43 / 145(30%) | **73 / 145(50%)** |
| 致命级召回 | 9 / 31(29%) | **15 / 31(48%)** |
| 带标志词 | 1 / 65(2%) | **35 / 65(54%)** |
| 负偏离未说明 D06 / 写明不支持 D05 / 数值不满足 D03 | 0 / 58、1 / 7、3 / 7 | 28 / 58、7 / 7、6 / 7 |
| 漏响应 D01 / 响应对象错位 D09 | 18 / 27、3 / 14 | **14 / 27、0 / 14**(变差) |
| 规矩的投标文件报出的问题 | 699 | 639 |
| 规矩响应的要求上的误报 | 153 / 620(24.7 / 100) | 132 / 620(21.3 / 100) |
| 不需要响应的要求上报的问题 | 334 | 329 |
| 输入 / 输出 token | 189 万 / 32 万 | 273 万 / 28 万 |
**读法**:整行上下文解决了「看不见响应格、偏离格」的问题,带标志词的问题从几乎查不出到过半;误报略降。代价是输入 token 多 44%。**两处变差**:漏响应(D01)和响应对象错位(D09)。推测是同一行或相邻行的内容让模型更容易「找到点什么就判已响应」:漏掉的那一行不在了,检索命中的是相邻要求的行;错位的那一行响应格写的是另一条参数,模型没比对参数名。下一轮在提示词里要求先核对响应格说的是不是同一个参数 / 设备,并单独看 D01、D09 的漏报明细。不需要响应的要求上的误报(329)没有变化,要靠第 ② 项(C12)。
## 4. 第二轮:评测集纠错 + 响应方式和「要不要回答」(2026-11-04)
**评测集纠错**:§3 里漏响应、响应对象错位「变差」,逐条看漏报后发现多数是**生成器的错**,不是查标的错:读标清单有重复条目、S04 同一参数列在两张表里、技术方案散文也复述了要求,注入器只改了其中一处,别处还留着正确的响应,模型找到它是对的。改为同一要求的所有表格行一起改;在散文、附件、格式文件里也响应了的要求不挂漏响应 / 数值不满足 / 错位;错位响应必须是另一个参数的。马虎的投标文件按原种子重新生成,新目标 45 个由 Fable 复核(通过 40 个),用户抽查 9 个全部同意(四批合计抽查 65 个,全部同意)。
**查标改了什么**(第 2 步的 C12 部分):
- `checklist/response_mode.py`:按类别(技术 / 商务)识别招标文件的响应方式,规则优先;冲突按逐条,认不出按逐条并标明。开发集 7 份的识别结果和档案一致。(还没存进清单、没做清单页的显示和修改。)
- `check/applicability.py`:不需要投标人回答的条目不逐条找响应,写进覆盖范围(无效情形 —— 但 ★ / 斜体下划线标注的条款照常找、日期与递交、没要求附材料的评分标准、无需提供证明或由采购人查询的资格)。要求附证明材料按原文判断(读标的 evidence_required 常常是空的),中标后才交的不算。
- 默认接受的要求没找到响应:一般要求记「按招标文件默认接受」、不出问题;实质性要求出提示级「请人工核对」;要附证明材料的不免(迁移 0006 加了两种结论)。
| 指标(17 份,复核通过的问题) | v1 | v2 + 整行 | + 响应方式和「要不要回答」 |
|---|---|---|---|
| 查标召回 | 48 / 150(32%) | 102 / 150(68%) | 97 / 150(65%) |
| 致命级召回 | 9 / 32 | 20 / 32 | **21 / 32** |
| 规矩的投标文件报出的问题 | 699 | 639 | **229** |
| 规矩响应的要求上的误报 | 24.7 / 100 | 21.3 / 100 | **12.4 / 100** |
| 不需要响应的要求上报的问题 | 334 | 329 | **77** |
| 默认接受的要求被报(T06) | 39 | 35 | **1** |
| 输入 token | 189 万 | 273 万 | 224 万 |
按问题类型(最后一列):漏响应 19 / 24、写明不支持 13 / 14、数值不满足 4 / 4、负偏离未说明 39 / 59、评分材料缺失 9 / 14、错位 6 / 13;**证明材料缺失 1 / 17**。
**读法**:规矩的投标文件上的问题少了三分之二,召回只掉了 3 个点(掉的主要是只在默认接受的文件里、被正确判为「默认接受」的条目,以及证明材料)。证明材料缺失几乎查不出:资格证明、检测报告多是扫描件页,没有文字层,规矩的投标文件上同样报「未找到」——这是下一项(C10 扫描件兜底 + 文件要在的判断)。
**一处要说明的**:调响应方式识别时,打印结果里顺带出现了留出集 S03、S06、S09(招标文件本身,不是投标文件)的识别结果,其中 S03、S09 和规范 §2 的记录不一致。没有据此改任何规则,留到留出集正式评测时再看。
## 5. 第三轮:扫描件兜底、文件要在、响应方式存进清单(2026-11-04)
**改了什么**:
- **文件要在**(`check/presence.py`):「提供《授权委托书》」「须提交投标函」这类要求,从原文取出文件名,在投标文件里找写着它的标题;找到就算已响应(出处是那个标题),不调模型。
- **扫描件兜底**(C10):要附证明材料的要求没找到、而投标文件里有扫描件页时,不报「未找到响应」,改成提示级「可能在扫描件里,请人工核对」。没有 OCR 时,扫描件里有没有这份材料分不出来。
- **响应方式存进清单**(迁移 0008):解析时识别、带依据句子存下来;清单页显示,可以改(人工确认点,写审计,重新解析不覆盖人工改过的);查标用存下来的。
| 指标(17 份) | 上一轮(mode) | 本轮(scan) |
|---|---|---|
| 查标召回 | 98 / 150(65%) | 92 / 150(61%) |
| 致命级召回 | 22 / 32 | 22 / 32 |
| 规矩的投标文件报出的问题 | 229(致命 120、重要 58、提示 51) | 235(**致命 51**、重要 14、提示 170) |
| 规矩响应的要求上的误报 | 12.4 / 100 | 13.5 / 100 |
**读法**:问题的个数没变,变的是级别:规矩的投标文件上的致命问题少了一半多(120 → 51),多出来的是「请到扫描件里核对」的提示,致命级召回不变。总召回掉了 4 个点,主要是批次变化带来的波动(文件要在的条目不再进模型,同一批里的组合变了),以及几条漏响应被「标题里出现了文件名」误判为已响应——要盯着。评测集里证明材料全是扫描件页,所以证明材料缺失(D02)照旧查不出、扫描件兜底也分不出缺没缺;真正查出来要靠 OCR / 多模态(S5)。
## 6. 第四轮:规则检查(报价形式、形式要件)(2026-11-04)
**改了什么**(`check/rules.py`,规则集 `2026.11-r1`,不调模型):
- **投标侧结构**:按坐标把句子分成行、按表头归栏,认三种表——开标一览表(「投标总报价」那一栏)、分项报价表(数量 / 单价 / 合价三栏,可以跨页接续)、响应表(有「响应情况 / 偏离情况」一栏);再加落款日期、格式文件的填写位、页眉。
- **规则**:大写和小写金额(P04)、分项算术和合计(P05)、超最高限价(P01 / P02,按所投采购包取限价)、多个报价(P12)、页眉和填写位里的别的项目名称 / 编号 / 采购人(P06)、没填的地方(P07)、落款日期晚于截止或写错(P08)、投标有效期短于要求(P09)、附加条件(P10)、响应表里写「未完全响应 / 负偏离」或空着(D10)。响应匹配已经对同一句报了负偏离的,规则不重复报。
- 每条问题挂投标原文;有依据的挂招标侧出处(限价、截止时间、项目名称来自项目基本信息,有效期要求来自清单项)。缺项目侧的值(限价、截止时间、有效期要求)或认不出开标一览表时不报,写进覆盖范围。
- 说明只写读到的数字和依据,不写「应改为多少」(红线 6)。
**顾问审读后的修改**(请 Fable 从真实政府采购投标文件的角度审读,只给代码不给结论)。它指出的主要风险是真实版式上的致命误报,按「宁可让人核对,不让规矩的投标文件挂致命」改了:
- **表格分行改按单元格**:文字层原来就用 pdfplumber 找出了单元格框,但没存到句子上;现在句子带上单元格框(可选字段,旧的文字层没有就按句子重叠分行),同一行按单元格上沿分。垂直居中的短格、折成几句的长格、纵向合并的格不再串行或错位。
- **开标一览表认得更严**:总价那一栏下面恰好一个金额才算总价,分项表、多年度报价表的金额栏叫「投标报价」也不会被当成「多个总价」;不同位置写的总价不一致改为重要级的「投标总价前后写的不一致」,不再按致命的「两个报价」报。
- **另报价按把握分级**:「备选方案 / 方案二 / 第二套」致命;「优惠价 / 让利后」重要;金额等于投标总价(开标一览表或分项合计)的不报;否定看整个分句。
- **分项表**:「硬件小计」这类分组小计不进明细;同一栏读到两个数的行(无框线表格的合并格)不核对;下一页重复表头接着算同一张表;说明写「相差多少元」。
- **响应表**:一条记录从序号行到下一个序号行;「空着」一律按重要报(可能是版面没认准);认「否」(「是否响应 / 满足」栏)、「不响应(见说明)」这类写法。
- **投标有效期**:只在投标函、开标一览表那一页上的按致命报,别处按提示;复述的招标原文不算;「本报价有效期」(原厂报价单)不算;要求有几条时取最少的天数;认表格写法(「投标有效期」一格、旁边或下面一格写天数)。
- **别的项目名称**:页眉要像项目名称(以「项目 / 工程 / 采购 / 标段」结尾或带编号),章节页眉不算;招标文件里出现过的名称、编号(代理机构编号、简称)不算;「致:贵单位」不算。
- **附加条件**:复述判断先去掉编号、用前后 20 字;「不影响合同价款」这类不算;技术分册里的按提示报。
- **大写金额**:前后两句都找、只认写着「小写 / ¥ / 报价 / 合计」的句子,任何一处相等就不报。
- 落款日期认汉字数字(「二〇二六年十一月十七日」)和「日 期:」;说明里去掉「……的投标无效」这类绝对说法,改为「通常会导致投标无效,请核对」。复查对齐的目标改为原文前缀 + 摘要,前缀相同的两句不会互相吞掉。
- 每类都加了开发集以外的反例单元测试(`test_check_rules.py`)。
**评测集修正**:P07 的注入记录改成空白处前后的原文(原来记段落开头,空白在同段后一句时对不上);S07-A-sloppy 的 D10 注入在人员表里(不是响应表),剔除,生成器改为只往表头有「响应」的表里注入。score.py 加了规则问题的评分:规则编号对上、投标侧出处里有注入的原文才算查出。
| 指标(17 份) | 结果 |
|---|---|
| 规则问题查出 | 57 / 57(P04 8、P05 7、P06 7、P07 10、P08 7、P09 1、P10 7、P12 7、D10 3) |
| 规则问题误报 | 0(规矩的投标文件 7 份上 0) |
| 规则问题的级别 | 致命 8(备选方案类另报价 6、投标函上的有效期 1、符合性响应表 1)、重要 32、提示 17 |
| 响应匹配 | 不变(召回 92 / 150,致命级 22 / 32) |
**读法:这个满分基本说明不了泛化。** 规则是在知道生成器怎么注入、见过开发词库措辞的情况下写的,投标文件版式也都出自同一个生成器。附加条件(P10)和多个报价(P12)的开发词库措辞直接落在规则的写法里,最需要留出集检验。为此做了两件事:规则按招投标文件的通用写法归纳(单元测试和 `bid-mini-forms.pdf` 都用开发词库以外的措辞);评测集里没有的情形(超限价、跨页分项表、万元单位)只有单元测试。真实投标文件的合并单元格、扫描页、各地模板差异,要等国动真实投标文件来看。P01 / P02 / P03 / P11 / P13–P18 评测集里还没有注入。
## 7. 第 2 步收尾:需人工核对、分项上限(2026-11-04)
**需人工核对**(FR-M3-04、决策 P2,`check/manual.py`):份数、密封、递交、CA 签章加密、解密、保证金这些投标文件里看不出来的要求,从正式清单里挑出来(技术参数、评分项、资格条件不算;写明免收保证金的不列;点名要交的文件归「文件要在」),按主题合并成最多 5 条(开发集 7 份招标文件每份 3~4 条,原始条目 12~48 条),带招标原文出处;查标页勾选,按项目存、写勾选人和时间,复查后还在。接口 `GET / PUT /projects/{id}/manual-checks`,`check_key` 用 `rule:MANUAL.<主题>`,清单改了勾选也还在。不生成「问题」,覆盖范围里写明列了几条。
**分项 / 单价上限**(C7,`checklist/price.py` + 规则 `PRICE.OVER_CEILING_ITEM`):读标时从采购需求的清单表(表头有「名称」和「单价上限 / 分项限价」两栏,表前一句写采购包)逐行读出名称、数量、上限,作为「报价」类正式条目并入清单,出处是表格那一行,不调模型;采购包一级的表不算。查标时这类条目不交给模型,规则拿分项报价表里同名分项的单价比,超了报致命,挂招标侧出处和清单项。名称要求很严(完全相同、去掉括注后相同,或重合度 ≥ 0.8),对不上的写进覆盖范围。开发集只有 S10 有分项上限,5 条全部读对;散文写法(「单价不得超过 × 元」)还不认。
**评测集**:从规矩的投标文件派生 4 份只带报价问题的(`evals/check/price_defects.py`:只改一个分项单价,合价、合计、大小写跟着改,算术一致):S01-B-p01、S04-B-p01(超总价限价 1 元 / 1%)、S10-A-p02(超采购包限价 1%)、S10-A-p03(分项单价超上限 1%)。S10 的分项上限加起来就是采购包限价,总价超限必然有分项超上限,生成器把这条一并记进金标准。21 份上规则问题 62 / 62 查出、误报 0;同样只说明规则和生成器对得上。
## 8. 第五轮:偏离栏直读、响应错位、复查复用和预算(2026-11-04)
**改了什么**(`check/deviation.py`,都在模型判断之后、不调模型):
- **偏离栏直读**:模型判「已响应 / 只写满足」的条目,看它引的证据句所在的表格行;同一行有单元格整格写着「偏离 / 负偏离 / 部分满足 / 未完全响应」这类字样,改判负偏离,证据带上那一格。只写「偏离」两字的要是识别出单元格框的格(跨页无框的行抽字时「无偏离」会被拆成「无」「偏离」,规矩的 S07-B 上出过一次)。漏掉的 D06 多数就是这种:响应格照抄要求「满足,……」、偏离格写「偏离」,提示词已经要求读整行,模型还是漏。
- **响应错位**(D09):模型判「已响应」、引的证据全是照抄的要求格,同一行右边的响应格写的是别的内容(「显示器升降行程:150mm」「1位」),且和要求没有一个字对 / 数字字母串重合 → 改判「响应可能对不上」,说明里写出响应格原文。笼统的「支持 / 具备」、「详见……」、只写「满足」、要求太短(汉字少于 8 个)的不判。条件很严,宁可漏。
- **复查复用**(C2,`check/reuse.py`):每条清单项按「提示词版本 + 要求 + 候选句和同一行的单元格原文」算缓存键,和模型那一层的原始结论(后处理之前)一起存进 `check_item_result`;复查时键相同、证据句在本次投标文件里原样找得回来的,直接沿用,不调模型;响应方式、偏离栏直读这些后处理照常重算。覆盖范围写沿用了几条。
- **硬预算**(C2):每次检查送给模型的字数上限 120 万字(开发集平均约 5 倍),按批次顺序分配,超出的批次不调模型,条目记「超出预算」列为请人工核对,覆盖范围另写。
| 指标(17 份) | 第三轮(scan) | + 偏离栏直读 | + 响应错位(本轮) |
|---|---|---|---|
| 查标召回 | 92 / 150(61%) | 107(71%) | **109 / 150(73%)** |
| 致命级召回 | 22 / 32 | 24 / 32 | **24 / 32** |
| D06 负偏离未说明 | 38 / 59(64%) | 53 / 59(90%) | 53 / 59(90%) |
| D09 响应对象错位 | 5 / 13 | 5 / 13 | **7 / 13** |
| 规矩的投标文件报出的问题 | 235 | 236 | **235** |
| 模型调用 | — | 全部命中缓存 | 全部命中缓存 |
**读法**:两项都是确定的规则,没有改提示词、没有多花 token。响应错位报了 3 处(2 个单元),全对,规矩的投标文件上没有改判;第一版报错的 4 处(「13.56MHz」没算数字字母重合、「支持」当成别的内容、要求太短)修掉后才是这个数。偏离栏直读依赖投标文件的偏离栏写法,真实文件里偏离栏常写「无」「—」「见说明」,这些不会改判(不算不满足),只有明确写了不满足的字样才改。
## 9. 第六轮:同一行按单元格、业绩条款不再误跳、重复条目只合并显示(2026-11-04)
**改了什么**:
- **送给模型的「同一行」按单元格分行**(有框线的表格;正文句子照旧按上下重叠):垂直居中的格子、折行的长格不再串到上下行。要重跑模型(17 份约 225 万 token)。
- **「以合同签订时间为准」不再当作「签订合同时才交」**:`applicability` 判断证明材料是不是中标后才交的正则把它也认成了「签订合同时」,业绩评分项(「需提供合同复印件……以合同签订时间为准」)因此被当作「没要求附材料」跳过。评分项的业绩条款几乎都这样写。
- **重复条目**(决策 C5 修订):每条照常单独判断;问题接口给出同一次检查里问题类型相同、要求原文完全相同的其他问题(`duplicate_ids`),查标页同组只显示一条,注明「清单里出现 N 处」,处理和撤销对整组生效。
- **致命项重问(C3)先不做**:开发集 17 份上证据没通过校验的只有 5 条,致命级 0 条,C3 定的「不超过致命问题的 10%」已经满足,开发集上没有可测的对象;等真实投标文件上看到再做。
- **评测集裁定 1 条**(`adjudication/S10-B-sloppy.md`):t20 的漏响应不成立,技术方案正文完整响应了(生成器没认出风格 B 改写过的正文复述);问题总数 150 → 149。另核实了 3 条疑似错误、不改:S10-A-low t16 正文只响应了一半;S04-A s21 / s22 是同一条认证要求在几台设备下各写一遍、投标文件漏了其中一台——清单把几台设备的同一要求合成了一条,按条目查看不出哪一台漏了,这是现在的局限。
| 指标(17 份,按 149 个问题重算) | 第五轮(mismatch) | + 同一行按单元格(cellrow) | + 业绩条款(award,本轮) |
|---|---|---|---|
| 查标召回 | 109(73%) | 108(72%) | **111 / 149(74%)** |
| 致命级召回 | 24 / 32 | 25 / 32 | **25 / 32** |
| D06 / D09 / D13 | 53 / 7 / 9 | 53 / 5 / 9 | 54 / 5 / **10** |
| 规矩响应的要求上的误报 | 84 / 620 | 81 / 620 | 81 / 620 |
| 干扰项误报 | 30 | 27 | **24** |
| 规矩的投标文件报出的问题 | 235 | 236 | 239 |
| 输入 token(17 份) | 221 万 | 225 万 | 225 万 |
**读法**:按单元格分行和上一轮基本持平(召回 -1、致命 +1、误报略少),在一次模型重跑的波动范围内,结构上更对,保留。D09 少的 2 条是响应错位判断依赖模型引用「照抄的要求格」,模型换了引用就不触发——它是补漏,不是主力。业绩条款的修正带来 D13 +1、D01 +1;规矩的投标文件上多了 3 个提示(新查的业绩评分项)。
## 10. 第 3 步冻结(2026-11-04)
查标代码冻结在 `a60d16e`(规则集 `2026.11-r2`、提示词 `check.match` v2)。之后跑留出集(S03、S06、S09,风格 B 规矩 + 风格 C 马虎)和国动真实投标文件各一次,结果只记录,不据此调规则和提示词(GUIDELINES §8);留出集上发现的生成器错误按「响应清单错误」单独记数。
## 11. 留出集结果(冻结代码 a60d16e,跑一次,2026-11-04)
**留出集怎么做的**:S03、S06、S09 各一份风格 B 规矩 + 一份风格 C 马虎,共 6 份。招标档案由三个代理按 `prompts/profile.md` 写(S03、S06 各有 1100~1300 多条正式条目,是开发集最大的 2~3 倍);风格 B 改写由代理逐行手写(第一版用脚本批量生成,质量不够,返工);风格 C 由封存一侧的会话按封存说明在 build.py / mutate.py 里实现(开发集风格 A / B 输出逐字节不变),注入用留出集词库。96 个注入问题由 Fable 复核,通过 79、不通过 17(18%,开发集约 10%~15%:风格 C 生成器的「别处已响应就不注入」判断不够严,漏响应 6、可选加分当成缺证明 5、偏离行错位 3、删错材料 2、错位 1);用户抽查 19 条(含不通过 4 条)全部同意。
| 指标(只算复核通过的问题) | 开发集 17 份(调过) | 留出集 6 份(冻结代码原样) | S03 人工确认响应方式后 |
|---|---|---|---|
| 查标召回 | 111 / 149(74%) | **40 / 74(54%)** | 12 / 21(57%) |
| 致命级召回 | 25 / 32(78%) | **23 / 41(56%)** | 7 / 10 |
| D06 负偏离未说明 | 54 / 59(92%) | **5 / 20(25%)** | — |
| D01 / D03 / D05 / D09 | 74% / 100% / 86% / 38% | 58% / 90% / 100% / 80% | — |
| D02 / D11 证明材料、资格材料缺失 | 6% / 100% | 50% / 33% | — |
| 规矩的投标文件报出的问题 | 239(7 份) | **1134(3 份)**,其中 S03-B 1094 | S03-B 45 |
| 规则问题查出 | 57 / 57 | **13 / 20(65%)**:P10 0 / 3、P12 0 / 3 | — |
| 规则问题误报 | 0 | 3(都是「没填的地方」) | 0 |
**读法:开发集上的分数有相当一部分是调出来的。** 留出集召回比开发集低 20 个点,致命级低 22 个点,这更接近真实水平(留出集也仍是同一个生成器写的虚构文件,真实文件还要看国动)。具体失效在哪里:
1. **响应方式识别过度触发(影响最大)**:`response_mode` 的「逐条响应」写法里有「逐一」,S03 声明函的填写说明「逐一、准确填写产品名称……」被认成要求逐条响应,和「未列入偏离的视为接受」冲突,按 C12 冲突就按逐条处理,1028 条技术参数在规矩的投标文件上全部报了提示级「未找到响应」。产品里响应方式是人工确认点,清单页会显示识别出的方式和依据原文(这条依据一看就不对),人工改成「只列偏离」后 S03-B 降到 45 个提示。**要修:识别「逐条响应」要求限定在偏离表、响应表的说明里,声明函填写说明不算;冲突时在清单页上提醒确认。**
2. **偏离栏直读只认开发集的写法**:留出集偏离栏写「不完全一致 / 与要求有出入 / 接近要求」,不在「偏离 / 负偏离 / 部分满足」这组词里,模型也没读出来。D06 92% → 25%。**要修:偏离栏的取值按「是否完全一致」判断(「无偏离 / 完全响应 / 满足」以外的非空值都请人核对),不靠枚举不满足的说法。**
3. **附加条件、另报价的写法枚举**(事先就判断最需要留出集检验):「以双方另行签订的服务协议约定为准」「保留调整报价的权利」「备选配置总价」「如同意延长交货期,可将总报价调整为……」「愿以 × 元作为最终让利价格」(金额在关键词前面)都没认出。**要修:这两类改成「投标函、报价相关章节里出现第二个总价金额」「报价、付款、交货条款里出现保留 / 另行 / 调整」这种结构化判断,再配措辞;或者交给模型判断一次。**
4. **空白栏规则**:技术要求里的「精确到年月日」被当成没填的日期,误报 3 次。**要修:「年月日」前后是「精确到 / 格式 / 显示」这类说明性字样时不算。**
5. **PDF 字形**:渲染出的文字层把「户」读成「戶」(康熙部首 / 兼容字),回填位置时定位不到;真实文件里招标侧和投标侧字形不一致时检索和引文核对都会受影响。**要修:文字层做兼容字归一(读标的 `_char` 已经处理了一部分部首,要补全)。**
这些修完以后,留出集已经测不出修复效果(看过了),报告里的留出集数字以本节为准。国动真实投标文件仍然用冻结代码跑一次,之后再修。
## 12. 修留出集暴露的 5 处(2026-11-04,用户决定先修再跑真实文件,决策 E2)
规则集 `2026.11-r3`。
1. **响应方式识别**(`checklist/response_mode.py`):「逐条响应」那句话要说的是要求、条款、参数、偏离、采购需求,声明函填写说明里的「逐一、准确填写……」(10 份招标文件里 7 份都有这句套话)不算;「默认接受」的写法里,「视为」后面紧跟「无效 / 不 / 未 / 非」的(「否则视为无效响应」)是不响应的后果,不算。10 份招标文件的识别结果和档案一致(S03 → 默认接受,S09 → 逐条),S05 商务仍是「没写明」(原文确实没写)。
2. **偏离栏按「是不是明确满足」判断**(`check/deviation.py`):按表头认出偏离栏和响应情况栏,两栏合起来判断——任一栏明确写了不满足(短的一段里有「负偏离 / 部分响应 / 不满足」,长的一段开头就是这些)就是负偏离;偏离栏写的不是「无偏离 / 满足 / 符合」的其他短写法(「不完全一致 / 接近要求 / 基本一致」)请人核对,级别最高到重要;「响应情况」栏写的常是响应内容(「支持」「3 年」),只认明确的不满足。窄栏折成两段的「无偏」「离」合起来认。认不出表头时退回原来的整行明确写法。响应表规则用同一套判断,表体序号认「技-86」「C-3」「(一)」这类写法(原来只认纯数字,风格 B / C 的表整张被跳过)。
3. **另报价、附加条件按结构判断**(`check/rules.py`):另报价看「报价字样或备选 / 优惠 / 让利 / 如……调整为,加上一个和投标总价不同、不低于总价三成的金额」(金额在前在后都算),「备选 / 替代 / 方案二 / 第二套」致命,其余重要;业绩合同金额、限价、保证金不算。附加条件补「保留……权利」「另行签订」「以另行 / 实际 / 届时……为准」「调整报价」「价格波动」,商务语境补「交付 / 供货 / 服务期 / 售后 / 协议」,「不以任何理由……」算承诺。
4. **空白栏**:「精确到 / 格式为 / 显示」后面的「年月日」、后面跟「时」的不算没填。
5. **异体字**:比对时把「戶 / 戸 → 户」这类异体码位和 CJK 兼容汉字折叠(`text_layer.norm`、检索分词),原文照存。
每处都有开发集、留出集以外写法的单元测试;中间一版偏离栏判断把「投标响应」栏的响应内容和折行碎片当成了要核对的值,开发集上冒出几十个误报,改成两栏分开判断、按段判断后回落。
| 指标 | 开发集 修前 → 修后 | 留出集 冻结代码 → 修后(**已看过,仅参考**) |
|---|---|---|
| 查标召回 | 111 / 149(74%)→ 112(75%) | 40 / 74(54%)→ 51(69%) |
| 致命级召回 | 25 / 32 → 25 / 32 | 23 / 41 → 26 / 41 |
| D06 负偏离未说明 | 92% → 93% | 25% → 80% |
| 规矩的投标文件报出的问题 | 239 → 239 | 1134 → 82 |
| 规则问题查出 | 57 / 57 → 57 / 57 | 13 / 20 → 19 / 20 |
| 规则问题误报 | 0 → 4(规矩的投标文件上 1,都是「没读到响应情况」的重要级提示) | 3 → 0 |
留出集的正式成绩仍以 §11(冻结代码)为准:修的时候看过留出集的漏报,修后的数字有过拟合成分。
## 13. 资质匹配(查标第 4 步,2026-11-04)
规则集 `2026.11-r4`,方案 `qualification-plan.md`(决策 QL1–QL7)。只改规则,开发集上的模型结论全部走缓存。
**1. 开发集不退化**:17 份投标文件(空资质库)的全部指标和 §12 修后一致:召回 112 / 149(75%)、致命级 25 / 32、规矩的投标文件报出问题 239、规则问题 57 / 57。资质问题 0 个:开发集的证书全在扫描件页上,空资质库什么都不报(QL3)。
**2. 类别识别**(S01–S10 读标结果里资格条件、评分项中带「证书 / 资质 / 认证」的条目):认出类别 14 条,产品证书、人员证书排除 9 条(节能 / 环境标志产品认证、项目经理和技术人员资质),没认出 2 条(S03「认证证书(3 分)」、S05「数据库兼容证书」,都是产品类,正确地不判)。营业执照、法人证书、联合体规则不算资质要求。读标留出集的 S03、S06、S09 里没有可核对有效期和等级的企业资质。
**3. 模拟资质库的噪音**(开发集 17 份,`run_check.py --library`):
| 资质库 | 资质问题 | 说明 |
|---|---|---|
| 空 | 0 | |
| 每类一条、有效、等级最高 | 4 个提示 | 都是 S01 的「CCRC 服务类别」:要求写了「安全集成」,模拟记录的名称里没写 |
| 只有 ISO 9001 | 16 个重要 | S01 两份的资格条件和评分项「资质库里没有这类资质」,每份 8 个(含同一要求抽成两条的重复,一条评分项里两类证书各一个) |
| 每类一条、2020 年过期 | 29 个重要 | S01(每份 8)、S04(每份 3 类 ISO)、S07(每份 ITSS、CS 两类)「按资质库记录已过期」 |
真实用户的资质库介于「全有效」和「缺几类」之间:规矩的投标文件上,资质库录全了几乎不出问题;录了一部分时,没录的那几类在扫描件条目上会出「未覆盖」(重要)。
**4. 派生的资质情况表**(`evals/check/qual_defects.py`):在 S01-B、S04-B、S07-B 的资格证明一章开头插一张文字的「企业资质情况表」(虚构证书,写法故意和词表别名不完全一样),q00 全部有效、q01 注入 D12(有效期早于截止日)和 D18(等级低一级)。
| 指标 | 结果 |
|---|---|
| D12 有效期早于截止日 | 3 / 3(S01 资格条件致命,S04、S07 评分项重要) |
| D18 等级低于要求 | 2 / 2 |
| q00 规矩的资质表上的资质问题 | 0(空资质库、全有效资质库都是 0) |
调试中修的两处:一条评分项写了两类证书时合成一个问题,规则编号只能取一个(S07 的 CS2 被 ITSS 过期吞掉)→ 改为一类证书一个问题;证书编号「CS3-3201-…」排在等级栏前面,整行读成了 CS3 → 先看短的等级格。同一要求在清单里抽成两条时各报一个,评分按「同规则 + 同出处」算一个。
插表以后,响应匹配的候选句变了,13 + 8 + 12 次模型调用没有命中缓存:S01 有 5 条扫描件兜底的「请人工核对」因为文字表里能找到而消失(应当如此);S07 有 3 条条目的结论变了(1 条新的「请人工核对」、1 条从「请人工核对」变成「疑似不满足」、1 条新的「疑似不满足」),是模型看到资质表后的判断,没有逐条复核。
**局限**:资质情况表和词表是同一个人对着同一批样本写的,分数说明不了泛化;真实投标文件的资质表写法、扫描件比例都没测。国动真实投标文件到了以后,请用户按公司的真实证书录资质库再跑一次(`qualification-plan.md` §5)。
## 14. 国动真实投标文件(2026-11-05,跑一次)
用户提供的真实响应文件(南京长卿科技有限公司,183 页 PDF,WPS 导出;第 18–33 页里有 23 页是没有文字的扫描件,第 104–169 页是合同和劳动合同的扫描件,每页带一行文字页眉)。招标文件用当前代码重新读标(DOC 扩展名、实际是 DOCX,71 页,45 秒,299 条、待确认 3 条,响应方式技术 / 商务都认成「逐条响应」)。用户看过清单、没有改动,资质库空,直接查标。代码是 `5b77012`(规则集 `2026.11-r4`、提示词 `check.match` v2)。文件放在 `samples/tenders/GD/`,不进 git。
**结果**:26 秒,模型调用 31 次(输入 15.6 万 token、输出 2.1 万);249 条参与检查,203 条判「已响应」。报出 44 个问题:致命 1、重要 1、提示 42。没有金标准,下表是逐条对照投标文件原文的人工判断(Claude 判,用户未复核):
| 问题 | 个数 | 判断 |
|---|---|---|
| 响应错位(含唯一的致命) | 15 | **全部误报**。响应格写「完全满足,无偏离。」,`POSITIVE` 去掉「完全满足,」后剩下「无偏离。」,不在 `MARKER` 里,被当成「别的内容」。真实投标文件最常见的写法,开发集、留出集没有 |
| 附加条件(重要) | 1 | 误报。原句是「……均包含在合同价中,**不**以正常接口对接和适配工作**另行收费**」,是承诺不是附加条件 |
| 出处没能核对 | 4 | 误报。部署分区(第 72 页)、架构图(第 61–63 页)、人员经验和岗位(第 132–133 页)原文都有 |
| 未找到响应 | 10 | 5 条有用:平台费用三条(商务表里确实没有逐条回应)、没有承诺延长质保(评分项)、没有声明演示系统是自主研发;5 条误报:售后、实施、数据治理三条「方案应明确……」对应的章节都有,响应时限承诺第 100 页写了 15 分钟 / 1 小时,「合同签订后细化进度计划」不是投标时要回答的 |
| 疑似不满足 | 1 | 误报。团队配置第 132–133 页列了 3 名核心人员和分工,模型的说明「只写了满足」不对 |
| 扫描件兜底「请人工核对」 | 12 | 4 条有用:第二十二条的证明材料(确实在扫描页上),以及软件自主研发、团队专业能力、国产化适配三个评分项(投标文件里确实没有材料,人员证书一栏都写「无」,但提示说「可能在扫描件里」,说法不对);8 条是噪音:不接受联合体、无特定资格要求、须具备资格要求、联合体协议、特定资格证明,以及残疾人福利单位、监狱企业、本国产品三种价格扣除,都不需要这家投标人回答 |
合计有用 9 个、误报或噪音 35 个;**致命、重要级 2 个全是误报**。报价规则(大小写、分项合计 43.2 万、低于限价 45 万)、落款日期、有效期都没有报问题,原文核对也确实没有问题。
**产品没查、人工看到的真问题**:第 3 页「评分索引表」写的页码和实际页码对不上(表上写到第 209 页,文件只有 183 页,「履约能力」写的是第 124–194 页,实际是第 102–169 页),总目录也一样。评委按索引翻页会找不到。这属于「目录页码和实际页码一致」,覆盖范围里还没有这一项。
**暴露的问题**(按规矩只记录,修不修由用户定):
1. **响应错位的肯定写法**:「完全满足,无偏离」「满足,无偏离;」这类复合写法要整体当成肯定回答。影响最大,唯一的致命就是它。
2. **附加条件的否定句**:「不……另行收费」「不以……为由另行……」是承诺,不算附加条件。
3. **扫描件判断只看整页有没有字**:第 104–169 页的扫描件因为有一行页眉,不算扫描页,所以提示写「23 页扫描件」,实际有 80 多页。另外,评分项要的材料文字部分没有时,提示一律说「可能在扫描件里」,即使扫描页全是别的材料也这么说。
4. **「要不要投标人回答」漏判**:联合体、特定资格「无」、价格扣除这些条目(投标人没有声称适用的),以及合同签订后才做的事,不该出问题。
5. **评分细则的「方案应明确……」**:投标文件有整章对应时,模型在候选句里没找到。这类条目要整章去对,不是逐句去找。
6. **出处核对**:4 条模型找对了页,引文没能核对上,降成了「未找到」。
**读法**:真实文件上的问题数(44)不多,原因是这份投标文件很规矩:偏离表逐条照抄、全写「完全满足」。但最高两级全是误报,说明规则在没见过的写法上还很脆。召回测不出来,因为这份文件本身没有致命问题,第 1、2 条的误报正好落在最显眼的位置。
## 15. 修国动暴露的 6 处(2026-11-05,决策 E3)
规则集 `2026.11-r5`。每处都配了单元测试,例句不取自国动、开发集或留出集。
1. **响应错位的复合肯定写法**(`check/deviation.py`):响应格开头连着写的肯定回答(「完全满足,无偏离。」「符合,无负偏离;」)一起去掉,剩下有内容的才拿来和要求比;「完全满足,显示器升降行程:150mm」照报。
2. **附加条件的否定句**(`check/rules.py`):「不以……另行收费」「不以……为由另行计取」是承诺;「另行收取」「另行协商」照报。
3. **扫描件判断**(`text_layer` v5,`check/match.py`、`findings.py`):每页记图片占比。字数不到 20,或图片占两成以上且不到 60 字,或占四成半以上且不到 120 字,都算扫描页(带插图的正文页字多,不算)。提示写明扫描件在哪几页,例如「投标文件第 16–23、26–33、104–131 页是扫描件(共 82 页)……材料如果附在这些页上,请人工核对,没有附就是缺了」,不再说「材料可能在扫描件里」。投标分册下次检查时按 v5 重新抽文字层,句子编号不变。
4. **不需要这家投标人回答的条目**(`check/applicability.py`):写明没有的(「无特定资格要求」;有这条时,「特定资格要求的证明文件」也不查)、总括性的「须具备招标文件规定的资格要求」、标题以「合同签订后 / 中标后」开头的,都不找响应。条件性要求(标题里是联合体、残疾人福利性单位、监狱企业、本国产品的)要投标文件里出现了对应的文件才查。「不接受联合体」不算「没有要求」:投标人真是联合体时恰恰要报。这些都写进检查覆盖范围。
5. **提纲式要求**(`check/match.py`):「方案应明确 / 包括 A、B、C……」里列出的每一项(至少 3 项)再各检索 1 句,候选句最多 10 句,模型能看到整章的内容。
6. **同一批片段的出处核对**(`check/match.py`、`reuse.py`、`runner.py`):同一批送给模型的片段,任何一条都可以引用,引文照样要和原文核对;出处没过校验的结论不再沿用到下次检查(以前沿用回来会变成普通的「未找到」)。
**国动(看过结果才修的,只作参考)**:重新检查报出 11 个问题,全是提示级,用时 7 秒(大部分结论沿用上次,只调了 2 次模型)。§14 里判为有用的 9 个都还在,剩 2 个误报:团队配置的「只写了满足」、更优响应时限(这两条的结论是沿用上次的)。原来的 4 条出处核对失败、「方案应明确……」的 4 条都改判已响应,出处页码是对的(第 61–63、79–85、87、94–101、132 页)。
**开发集不退化**(17 份,运行 `r5` 对比 `r4`):
| 指标 | r4 | r5 |
|---|---|---|
| 查标召回 | 112 / 149(75%) | 111 / 149(74%) |
| 致命级召回 | 25 / 32 | 25 / 32 |
| 规矩的投标文件报出的问题 | 239 | 213 |
| 其中落在不需要响应的要求上 | 79 | 53 |
| 规则问题 | 57 / 57,误报 4 | 57 / 57,误报 4 |
召回少的 1 个是模型重跑的波动:跳过的条目变了以后,批次重新组合,大部分批次没命中缓存,模型重新判了一遍(各类此消彼长:D01 +1、D05 +1、D03 −1、D06 −2)。漏掉的 4 个,把第 6 处临时关掉(全部走缓存)结论一样,不是修复造成的。派生的报价、资质评测文件(8 份)的规则和资质问题和上次完全一样。
**留出集(看过,只作参考)**:召回 51 → 49 / 74,致命级 26 → 26 / 41,规矩的投标文件报出的问题 82 → 78,规则问题 19 / 20 不变。少的 2 个(S06-C 的 D09、S09-C 的 D01)关掉第 6 处后结论也一样,同样是重新组批后模型判法变了。
**局限**:这 6 处都是看着一份真实文件改的。第 4 处的「条件性要求」只认标题里的这几种主体;第 3 处的阈值是按国动这一份文件的扫描页定的,别的文件扫描页的版式(整页图片、多张小图)还没见过。
## 16. 国动真实投标文件开 OCR(2026-11-05,vision-plan 第 1 步)
新开发账号、新项目,重新读标(48 秒,292 条、待确认 2 条),资质库空,投标文件开 OCR 查标。代码见架构 §13 SK15,规则集 r5,提示词 `check.match` v2。看过 §14、§15 的结果,只作参考。
- **识别**:82 页扫描页、20 页文字页里的 25 张插图都识别了文字(OCR 约 65 秒,检查共 94 秒);覆盖范围写明「识别出来的文字参与检查,依据识别文字的问题降一级」。
- **新找到的证据**:7 条要求的投标侧出处落在识别文字上:营业执照(第 16 页)、部署架构图、数据流向图、功能架构图(第 62、63、67 页)、测试流程图(第 87 页)、演示截图(第 175 页)。以前这些地方没有文字。
- **第一次跑(规则也看识别文字)**:36 个问题,全是提示级,其中 21 个是规则在附件上的噪音:劳动合同模板没勾的期限条款(「可能有没填的地方」11 个)、历史合同的「另行协商」条款(「可能附加了条件」5 个)、历史合同的价款(「另一个价格」1 个)等。降级起了作用(没有致命、重要),但这些附件是别人的文件,规则本来就不该查。**改为**:规则检查不看扫描附件和插图上的识别文字;整份扫描的投标文件(文字页不到三成)照查。覆盖范围写明。
- **改后重跑**:16 个问题,全是提示级,规则噪音没有了。和 §15 修后的 11 个比,多出的主要是重新读标后清单不同、模型波动,没有逐条复核。
- **OCR 解决不了、要靠第 3 步页面分类的**:第二十二条的证明材料、纳税和社保证明,材料确实在扫描页上(第 16 页营业执照、第 22–23 页完税证明),识别文字也有,但响应匹配没把它们对上,仍是「请人工核对」或「未给出具体值」;软件自主研发、团队专业能力、国产化适配三条仍说「识别了文字但没认出这份材料」。
- **和 OCR 无关的**:「提供逻辑架构图、部署架构图和主要数据流向图……」「分别说明公众服务端和后台管理端的页面结构……」仍判「未找到」,图和页面原型都在(第 61–65 页),属于 §14 第 5 点「方案应明确……」要整章去对的问题。
## 17. 下一步
1. 分项报价表和采购需求清单逐项对照(P13)**暂缓**:在开发集 7 份招标文件上试读「名称 + 数量」的采购清单表,S02、S08(服务类)没有这种表;S04 把「是否进口」一栏读成了名称;S10 采购包 2 的表跨页、第二页没有表头,漏了大半;S01 9 行里能和投标文件的分项对上名称的只有 7 行。P13 是致命级,按这个抽取质量在真实文件上会大量误报「缺项」,评测集里也没有注入过,先不上,覆盖范围里照写「还没有上线」。散文写法的单价上限同样暂缓。
2. 第 3 步冻结前:确认各项改动的单元测试都有开发集以外的反例;冻结后跑留出集(S03、S06、S09,风格 B + C)和国动真实投标文件,各一次。
3. 第 3 步:D09(响应对象错位)、D06(负偏离未说明)的提示词 v3,token 预算、复查复用、重复条目分组。
3. 留出集(S03、S06、S09,风格 B + C,封存的词库)和真实国动投标文件,在第 3 步冻结代码后只跑一次。

View File

@@ -71,11 +71,11 @@
| identity | `tenant` `user_account` `login_identity` `membership` `user_session` | 人、登录方式、成员身份三层(AR11) |
| billing | `plan` `subscription` `activation_request` `quota_account` `quota_entry` `quota_alert` | 套餐带版本;开通申请注销后保留 |
| project | `project` `project_member` `lot` `project_field` | 项目字段的 AI 值和人工值分开存 |
| document | `source_document` `document_page` `parse_job` `citation` | 出处表归 document 模块,各模块共用 |
| document | `source_document` `document_page` `parse_job` `citation` | 出处表归 document 模块,各模块共用;`citation.ocr`(2026-11-05,迁移 0013)标原句是不是 OCR 识别出来的 |
| checklist | `checklist` `checklist_item` `checklist_item_citation` `checklist_version` `checklist_version_item` `marking_rule` | |
| check | `check_run` `check_run_document` `finding` `finding_citation` `manual_check` | |
| check | `check_run` `check_run_document` `check_item_result` `finding` `finding_citation` `manual_check` | `check_item_result` 2026-11-04 新增(C9) |
| report | `report` | |
| qualification | `qualification` `qualification_file` | |
| qualification | `qualification` `qualification_file` `qualification_scan` | 2026-11-05(迁移 0014):`qualification.holder`(证书主体)、`qualification.ai_values`(从识别结果保存时的识别值);`qualification_scan` 是一次证书识别,没保存的 24 小时后删除 |
| ai | `ai_call` | |
| audit | `audit_event` | |
| ops | `ops_admin` `ops_session` `feedback` | 反馈由用户写,在运营后台处理 |
@@ -228,6 +228,12 @@ erDiagram
- 沿用:上次也有,沿用上次的处理状态,上次是「已修改」的改回「待处理」;
- 本次已解决:上次有、这次没有的问题,在本批次里留一行,状态不再变。这样每个批次都能独立列出完整结果,报告也只需要读一个批次。
- **处理规则的数据库约束**:忽略必须写原因;致命问题选「确认无误」也必须写说明(U4)。这句说明同时写一条反馈(`feedback.source = fatal_confirm`)。
- **投标分册**(C8,2026-11-04):`source_document.volume_kind` 记分册类型(整本 / 资格证明 / 商务 / 技术 / 报价 / 其他),只用来排序检索候选和写「找过哪些分册」。
- **`check_item_result`**(C9,2026-11-04 新增):每次检查对每条参与检查的清单项留一行结论,「已响应」也留。
- 字段:结论(已响应 / 未找到 / 负偏离未说明 / 疑似不满足 / 未给出具体值 / 未判定 / 扫描件待人工核对)、走的哪一层(偏离表 / 规则比较 / 模型)、投标侧出处、校验状态、原因码、所属的重复组、token;
- 问题只为没响应好的条目生成,`finding` 通过清单项和它对应;
- 24 小时内复查时,按「清单项内容哈希 + 投标分册版本哈希 + 提示词版本」复用上一次的结论,不再调用模型(C2);
- 读标清单上的「已响应 · 第 N 页」、对已响应的「判断有误」反馈、报告的覆盖范围,都读这张表。
- **`manual_check`**:需人工核对的勾选按项目存(P2),以 `check_key` 为键,复查时直接读取,报告里写明勾选人和时间。
### 5.6 就绪标记

View File

@@ -50,7 +50,7 @@ Fable 用样本外的写法逐条验证过,标「已验证」的是复现过
| `INVALID` 新增「不得参加 / 被拒绝 / 拒绝接受」 | 「评标委员会成员不得参加评审」「不得参加后续培训」「甲方有权拒绝接受不合格货物」(已验证) | 一般要求冒充写明无效;只排除了合同章,而且前提是章识别成功 |
| 概括句过滤 `BLANKET` / leads | 「上述证书原件须在开标时携带,否则投标无效」本身是一条要求(已验证) | 被当成引导句丢掉,兜底也不补 |
| ★ 节 `star_sections` | 「★1 核心交换机」被当成节标题(已验证);后面没有同级编号时,范围最多延伸 800 句 | S10 一个标题覆盖 381 句、产出 225 条致命,没有人逐条核对 |
| ★ 降级 `star_scored`(Q2) | 后果写在不带 ★ 的句子里(「重要技术参数负偏离超过三项的,投标无效」)(已验证) | 真正的废标 ★ 被显示成「重要」 |
| ~~★ 降级 `star_scored`(Q2)~~ | 2026-11-05 Q2 修订后 ★ 一律按实质性,降级已删除,这条风险不再存在 | — |
| 评标方法 `EVAL_METHOD` | 招标投标体系的「综合评估法」「经评审的最低投标价法」 | 项目信息答不出 |
| 阈值 | 表后往回 40 句、行长 ≤ 60 字、★ 节范围 800 句、最后一章不超过一半、92% | 都是看样本定的,没有样本外依据 |
@@ -88,7 +88,7 @@ Fable 用样本外的写法逐条验证过,标「已验证」的是复现过
- 「拒绝接受」只认主语是采购人或代理机构、对象是投标文件的写法;
- 按法律体系补「作废标处理」「予以废标」(招标投标体系里等于否决投标;政府采购里「废标」多指整个采购终止,不能混用)。
4. **概括句过滤**:只在上下确实有编号列表时生效,句子本身写着具体要求的不过滤。
5. **★ 降级**:带后果的句子里没有 ★,但提到了 ★ 的定义词(「核心技术参数」「重要技术参数」),也不降级。
5. ~~**★ 降级**~~:不再需要(2026-11-05 Q2 修订:★ 一律按实质性)。
6. **章识别**:
- 支持「第1章」「第一部分」、章号和标题拆成两句;
- 认不出时计数 `chapters_unreliable`,写进解析结果;

View File

@@ -0,0 +1,270 @@
# 查标(M3)实现计划 v0.2(2026-11-04)
> 范围:FR-M3-01~09,加上查标依赖的 FR-M6-08 最小资质库。
> 依据:`docs/analysis/03-functional-requirements.md`、架构 §3.2 / §3.6~§3.9 / §3.12、`data-model.md` §4.3 / §5.5~§5.6、`api-design.md` §3~§4、原型 `bid-check.html` `results.html` `report.html` `qualifications.html`。
> v0.2:请 Fable 做顾问,深读参考项目的查标实现(`ref-bid-engine-analysis.md` §6),对照读标优化的教训(`extract-status.md`),重新评估了 v0.1 的待定事项。关键结论抽查过源码。
## 进度
| 步骤 | 状态 | 说明 |
|---|---|---|
| 第 0 步 评测集 | 试跑完成,继续扩充 | 规范 v0.2、留出集划分已定,留出集词库和风格 C 已封存;生成、注入、渲染、离线查标、评分工具已写;开发集 7 份招标文件、14 份投标文件第一版跑完,结果和发现见 `check-eval.md` §2 |
| 第 1 步 链路打通 | ✅ 2026-11-04 | 见下方「第 1 步落地记录」 |
| 第 2 步 规则 | 基本完成 | 响应方式(C12)、文件要在、扫描件兜底、规则检查 `rules.py`(报价形式、形式要件、响应表)、需人工核对(`manual.py`、`manual-checks` 接口)、「报价」类清单项和分项上限(C7)。还差分项和采购需求逐项对照(P13)、散文写法的单价上限。见 `check-eval.md` §4–§7 |
| 第 3 步 AI 响应匹配 | 进行中 | 整行上下文、提示词 v2、偏离栏直读、响应错位、复查复用、硬预算;送给模型的同一行按单元格、重复条目只合并显示(C5 修订);致命项重问按数据暂不做。见 `check-eval.md` §8–§9 |
| 第 4 步 资质库 | ✅ 2026-11-04 | 方案和落地记录见 `qualification-plan.md`(决策 QL1–QL7)。`qualification` 模块(增删改、附件按企业级密钥加密、`late_for` 到期提醒、审计)和前端资质库页;`check/qualification.py`:证书类别词表(16 类)、规则 `QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED`,投标文字层按清单项定级、只有资质库记录的降一级,规则集 2026.11-r4。评测见 `check-eval.md` §13 |
| 第 5 步 检查报告 | ✅ 2026-11-04 | `report` 模块:导出 → worker 用 Gotenberg(Chromium)把 HTML 转成 A4 PDF → 按项目数据密钥加密存对象存储 → `report`(不可改,存哈希);`report_job` 记生成过程(迁移 0009),完成推 `report.ready`、发通知。首页有项目信息、投标文件版本、检查依据、规则版本、导出人、检查覆盖范围、汇总、免责声明;问题明细带两侧出处和处理人;附录是需人工核对的勾选结果;页眉 logo、页脚「本报告为辅助检查结果,仅供投标前自查参考」+ 页码;测试检查模板文字不出现「通过 / 合格 / 保证」。前端「报告」标签页:选项、导出、内嵌预览、历次报告下载。「证据索引」页没有单独做(出处已在问题明细里逐条列出)。同时补了项目状态流转(递交 / 撤销递交 / 归档 / 撤销归档,FR-M6-02):致命问题未处理也能递交,确认框说明;递交后只读,撤销归档回到归档前的状态,都写审计 |
| 第 6 步 前端补齐 | ✅ 2026-11-05 | 查标页:对照布局、键盘(J / K / O / 1 / 2 / 3 / X / U / ?)、「提示」批量确认、判断有误反馈、从工作台带 `?f=` 定位到问题。读标页:每条写最近一次检查的结论,点投标侧出处看投标原文,「已响应」也能反馈判断有误(漏报);资格、评分里的证书要求写资质库的情况(`GET /projects/{id}/qualification-matches`,资质库空时不写)。概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡(招标方文件、投标分册最新版本,追加补遗挪到这里)。工作台:「致命」列、统计可点筛选、待处理的致命问题卡。日期输入加日历弹层(`DateInput`,Reka UI Calendar,周一开始、非本月留空)。反馈对话框抽成 `FeedbackDialog` 两页共用 |
**第 1 步落地记录**(和本文设计不一致、或留到后面的,记在这里):
- 后端 `check` 模块六个阶段都已接上,`rules` 阶段还是空的;问题由 `check_item_result` 生成,状态沿用、计费三种方式、健全性检查(投标文件读不出文字按 `input` 失败、不扣点)都有集成测试。
- 响应匹配目前只有第三层:检索用内存 BM25,分词先用汉字 2-gram + 「数字 + 单位」+ 英文型号,**jieba 留到第 3 步按评测决定**;批量先按 8 条清单项一批,**按投标章节打批、token 预算、复查复用、重复条目分组、致命项重问留到第 3 步**;`cache_key`、`group_key` 列已建好、还没写入。
- 提示词 `check.match` v1;假模型按字面重合判断,只用来测链路。
- 投标分册加了 `volume_kind` 列(迁移 0005),同一分册重新上传按 `lineage_id` 递增版本;电子投标平台加密格式先只认 `.kedt`(苏采云)和条目全部加密的压缩包,见过新的再补。
- 前端:上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、已处理和本次已解决),项目头抽成公共组件,加了「查标」标签页;键盘操作、批量处理、判断有误、需人工核对、导出报告留到第 6 步。
- 用真实模型(DeepSeek Flash)在虚构的 tender-mini + bid-mini 上走了一遍:漏响应的 ★ 条款查出为致命「未找到响应」,另两条判为已响应且出处核对通过;补上响应的 v2 复查后该问题「本次已解决」。一次检查一次模型调用(输入 1,552 token、输出 147 token)。这只说明链路通了,**不代表效果**,效果按第 0 步的评测集评。
## 0. 结论速览
- 表结构和接口契约大部分已有:`check_run` `finding` `finding_citation` `manual_check` `report` `qualification` 在基线迁移里,`openapi.yaml` 里查标相关的 20 多个操作都已定义。要做的是 `check`、`rules`、`report`、`qualification` 四个后端模块、投标文件的解析和结构识别,以及 4 个前端页面。
- 和 v0.1 相比,三处改动最大:
1. **先建评测集,再写第一条规则**。金标准是构造出来的:虚构投标文件时同时写一份「响应清单」,记下每条要求在哪里响应、故意埋了什么问题,按清单打分,不再用 AI 裁定。
2. **每条清单项都留一条带证据的结论,「已响应」也不例外**。新增 `check_item_result` 表,问题只为没响应好的条目生成。
3. **成本靠结构控制,不靠一个上限数字**:按投标文件的章节打批,同一段正文只送一次;每次检查有 token 预算,超出预算没查的条目列在报告的「检查覆盖范围」里;24 小时内复查时,没变的条目复用上次结果。
- 原则:**认不出、查不了、没查的,都写进「检查覆盖范围」(`coverage`),不静默跳过。** 读标里章识别静默失效的教训。
- 分 7 步交付,第 0 步(评测集)在第 2 步(规则)之前完成。
- §7 是 11 个待确认事项的最终建议(含 SK1、SK2)。
## 1. 现状盘点
| 项 | 已有 | 要做 |
|---|---|---|
| 表结构 | 基线迁移 `06_check.sql`、`07_…sql`;`project.latest_check_run_id` `open_fatal_count` `last_charged_check_at` | ORM 模型;新增 `check_item_result`(C9);清单快照里的重复组标记(C5) |
| 接口契约 | `check-preview`、`check-runs`、`findings` 的 resolve / reopen / bulk-resolve、`manual-checks`、`reports`、`qualifications`、`feedback` | 实现;补「每条清单项的检查结论」的读取接口。改了契约就 `pnpm gen:api` |
| 文件上传 | 分片上传、按文件头识别格式;`role = bid_volume` 能挂到项目上 | 分册类型和版本;**电子投标平台加密格式的识别还没做**(C8) |
| 文件解析 | 解析流水线和 `ParseJob` 绑在一起(`runner.py`),阶段函数基本是纯函数 | 拆出阶段函数给检查批次复用;新增投标侧结构识别 `bid_structure` |
| 清单 | 五类清单项,技术参数带 `op / value / unit`;确认时生成快照 | 检查前生成 `kind = check` 快照;**「报价」类清单项还没有抽取任务**(C7) |
| 额度 | 预扣 / 结算 / 退回、幂等键、余额行锁 | 检查的三种计费方式和免扣流水 |
| 模型调用 | 分块并发、截断检测(`near_limit`)、对半重跑、原因码 | **并发上限还是每个任务各算各的**(`extract.py:36` 的 TODO)。解析和检查同时跑会把供应商的每分钟请求数打满,查标上线前要做「供应商 × 租户」统一限流 |
| 事件流 | Valkey stream、`Last-Event-ID` 续读、`resync` | 加 `check_run`、`report_job` 的进度;发布用的客户端设短超时(SK1) |
| CI | PostgreSQL、Valkey 服务容器 | 加 Gotenberg(投标样例有 Word);数据库角色改为直接跑 `init-roles.sh`(SK2) |
| OCR | 未做(技术验证 S5) | 扫描件先按 C10 兜底;S5 排在 M3 之后 |
| 样本 | 10 份真实招标文件、S01–S10 标注集 | **没有投标文件**(C1) |
## 2. 检查流水线
`POST /projects/{id}/check-runs` 在同一个事务里:算计费方式 → 预扣(或记免扣)→ 生成清单快照 → 建 `check_run` → 入队。worker 按六个阶段跑,每个阶段写检查点、开始前清掉本阶段残留的产物,失败后从失败的阶段续跑(架构 §3.4)。
| 阶段 | 做什么 | 产出 |
|---|---|---|
| `prepare_bid` | 每个投标分册版本跑解析流水线的前五个阶段(格式核对、转换、文字层、OCR、结构识别),不抽取;同一版本只解析一次。然后做**投标侧结构识别**:偏离表、开标一览表、分项报价表、格式文件(投标函、授权书、声明函)、资格证明附件。表格沿用读标的按单元格切句(F8),偏离表直接从单元格里读。**健全性检查**:文字层为空或字数过少、全是图片页时,按 `input` 失败,不扣点、不生成问题 | 投标侧文字层;结构索引;每页有没有文字层 |
| `match` | 对每条参与检查的清单项找响应,见 §2.1 | `check_item_result` |
| `rules` | 确定性规则,见 §2.4 | 规则问题 |
| `verify` | 投标侧三道校验(编号、原文、依据)。**没有校验通过的证据,不能判为「已响应」** | 校验状态和原因码 |
| `carry_over` | 和上一次检查对齐,算出新增 / 沿用 / 本次已解决(FR-M3-08,对齐方式见 C5) | `presence` |
| `summarize` | 由 `check_item_result` 生成问题;写 `counts`、`coverage`;更新就绪状态和项目状态;汇总本次 `ai_call` 的 token 和费用;结算额度;发通知 | 检查完成 |
结构识别认不出(比如没找到偏离表)时,改用全文检索,并在 `coverage` 里写明「没有识别出偏离表」。
### 2.1 响应匹配
一份清单有 216~1348 条正式条目(参考项目每个项目只判 90 条左右,它的成本做法不能当依据)。分三层,能在前一层解决的就不进下一层:
1. **偏离表直取**。偏离表的行和清单项要先对齐:投标人抄过来的要求常被截断、改写、重新编号,所以用去掉编号后的文本相似度、参数名和顺序做确定性对齐,设置信阈值,对不上的行进第三层。MVP 以偏离表为准,「偏离表写无偏离,正文却相反」不查,写进 `coverage`。
2. **规则比较**。技术参数要求有 `op / value / unit` 的,用 Decimal 和单位换算比较读到的响应值,覆盖区间、枚举、支持 / 不支持、版本号。响应只写「满足」「响应」、没给具体值的,结论是「未给出具体值」(提示级),不是「疑似不满足」。模型只负责找出响应值在哪,满不满足由规则判断;模型给出的值必须出现在它引用的句子里。★ 参数判为「疑似不满足」时,问题里要同时列出两边的值、单位和比较依据,防止因为单位换算判错出致命误报。
3. **检索 + 模型判断**。见 §2.2。批量方式:
- **按投标文件的章节打批**:一段投标正文只送一次,带上候选句落在这段里的所有清单项。参考项目按清单项打批,同一页正文在一批里重复送好几遍,是反例。
- 每条清单项都必须有结论,漏答的记原因码 `missing_answer`,结论记「需人工核对」。
- 模型引用**句子编号**,不写页码和自由引文,这样三道校验能原样复用。
- 沿用读标的输出上限、`near_limit` 截断检测和对半重跑;每批之间检查取消标记;并发走统一限流。
- 部分批次因为模型的临时错误失败时,不拖垮整个批次:失败的条目记「未判定(模型错误)」,写进 `coverage`。
- 致命级条目的证据没通过校验时,允许重问一次:只把模型指的那一页作为候选,提示词不变。重问之后仍不通过,按 C3 处理。
- 提示词 `check.match` v1 的例子一律虚构,不取自评测集里的投标文件。
**token 预算**:每次检查有一个硬预算。超出后剩下的条目按 提示 → 重要 → 致命 的顺序放弃(先放弃提示级),没查的条目在 `coverage` 里逐条列出。预算按实测定(C2),正常情况下不应触发。
### 2.2 检索
用内存里的 BM25,保留架构 §6.2 的做法。`ref-bid-engine-analysis.md` §5.4 写的「用 PostgreSQL 的 pg_trgm / 全文检索」改掉,理由:
- 句子放对象存储、不进数据库(DM1)。用 PostgreSQL 检索就要为投标文字层另建一张句子表;
- 检索只在 worker 里、一次检查之内用。一个项目的投标文件最多几百页,在内存里建索引用不了几秒;
- 中文全文检索要装 zhparser 之类的第三方扩展,托管数据库和私有化部署都麻烦;pg_trgm 没有 IDF;
- 内存索引是纯函数,单元测试和离线评测都能直接用。
实现要点:
- 索引单位是带编号的句子和单元格,出处能回链;
- 分词用 jieba 加字 2-gram / 3-gram,「数字 + 单位」(≥32GB)和英文型号(10GE)保留成整词;
- 查询按类别构造:技术参数用参数名、值和单位,资格条件用材料名;
- 给模型的候选按句子窗口取上下文,不送整页;
- 排序带结构先验:技术参数先找技术偏离表,商务先找商务偏离表,资格先找资格证明分册。分册类型只用来排序,不限制检索范围(C8)。
### 2.3 问题级别
级别从清单项算出来,不让模型定(参考项目让模型定级别,模型可以把高危项降成低危):
| 清单项 | 级别 |
|---|---|
| `importance = substantive`(★ 条款、法规列明、原文写明无效),或类别含资格条件、无效 / 废标 | 致命 |
| ▲ 参数、评分项缺证明材料 | 重要 |
| 一般参数、其他 | 提示 |
问题上带 `importance_basis`,界面把「法规列明」和「文件写明」的无效分开显示(决策 Q4)。`explanation` 只写事实和出处,**不写整改建议**:参考项目让模型给每条结论写建议,用在报价类问题上就会写出报价数字(红线 6)。
### 2.4 规则(`rules` 模块)
每条规则都有编号,规则集有版本,写进 `check_run.rule_set_version`。每条规则都要有单元测试,至少配一个反例(不该报的写法)。
| 规则组 | 内容 |
|---|---|
| `PRICE.*` | 超出限价:总价、采购包、分项三层(分项见 C7);开标一览表和分项表的合计是否一致(苏采云范本写明「以开标一览表为准」,定为「重要」,说明里引用原文);大写和小写金额是否一致;是否只给了一个报价。金额只从投标文件读,`detail` 里存读到的金额、原文写法和出处 |
| `FORM.*` | 格式文件齐不齐;**格式文件里该填的地方没填**(C4);签章位置有没有图像(C4);落款日期是否合理;**出现了别的项目的名称、编号或采购人**:以本项目的名称、编号、采购人为准做比对,重点扫页眉页脚(照搬旧标书最常留在那里);泛泛匹配公司名只作弱证据;格式文件的固定文字有没有被改动:依赖招标侧识别出格式章,识别不出就不查,写进 `coverage` |
| `QUAL.*` | 证书在投标截止日是否已过期;等级能比较时看够不够;资格条件和评分项要求的材料有没有覆盖 |
| `REGIME.*` | 按法律体系切换术语(「投标无效」/「否决投标」)和无效情形清单;体系识别不出时用中性措辞。招标投标体系没有样本,`coverage` 里写「规则未经样本验证」 |
「需人工核对」的条目(份数、装订密封、递交方式、CA 加密)由规则按清单项生成 `manual_review`,勾选结果按项目存在 `manual_check`,复查时沿用(P2)。
### 2.5 检查覆盖范围(`coverage`)
第 1 步就定下字段,报告首页、查标页顶部都从这里读:
- 查了哪些分册和版本;哪些页没有文字层(扫描件);
- 哪些条目没查,以及原因:在待确认区、不在所投的包、超出预算、模型错误、扫描件;
- 结构识别结果:偏离表、开标一览表、格式文件,哪些认出来了;
- 法律体系,以及这套体系的规则是否经过样本验证;
- 清单是否已确认(G7);
- 规则集版本、提示词版本;本次 token 和费用;证据没通过校验的条目数(按原因码计)。
## 3. 计费
按 `data-model.md` §4.3 实现:
| 情况 | `check_run.billing` | 流水 |
|---|---|---|
| 专业版,24 小时窗口外 | `charged` | 在解析额度上预扣 −1;成功结算,失败、取消退回;成功后更新 `project.last_charged_check_at` |
| 专业版,24 小时窗口内 | `waived_recheck` | 免扣一行 0,`reason = recheck_24h`,关联本次检查 |
| 免费版 | `free_plan` | 不记流水 |
`check-preview` 和 `POST check-runs` 共用同一个计费函数;`expected_charge` 对不上时返回 409 `billing_changed`,不扣点。投标文件的解析算在本次检查里,不另外扣点。
**复查复用结果**:第三层(模型判断)的结论按「清单项内容哈希 + 投标分册版本哈希 + 提示词版本」缓存。没变的条目直接复用上次的 `check_item_result`,不再调用模型。否则一次扣点可能被拿来跑三五次全量。用户改了清单项,或者换了分册版本,相关条目就会重新判断。
## 4. 评测集(第 0 步)
没有任何真实投标文件,先自己造。读标的三个坑这样堵:
| 读标的坑 | 查标的做法 |
|---|---|
| AI 标注、AI 裁定,结果偏宽 | 投标文件是按「响应清单」写出来的,问题在构造时就已知,**响应清单就是金标准**,不需要 AI 裁定 |
| 评分脚本照搬抽取规则 | 评分只对照响应清单,不用检索和偏离表对齐的逻辑来判对错。准确率分两列报:对上响应清单的,和其他 |
| 规则照着样本写法调 | 至少两种写法风格,按招标文件整体留出,开工前选定 |
做法:
1. **问题分类表先定**:从 05 §5 的 R01–R20、07 F6 / F7 和 7 种问题类型派生,不少于 15 类,每类至少 3 种写法。例如:缺行、值不满足、单位不同、写在错的分册里、偏离表写「无偏离」正文却否定、页眉留着旧项目名、大小写金额不一致、证书过期、该填的地方没填。
2. **用招标文件自己的格式章写投标文件**:投标函、开标一览表、偏离表、声明函的模板,S01–S10 里都有。每份招标文件写两份投标文件,一份「规矩的」、一份「马虎的」;至少两种写法风格,其中一种由另一套提示词或另一个人来写。
3. **响应清单**:每条清单项记响应在哪(分册、页码 + 片段),以及怎么响应的(偏离表的行、正文句子、附件);没响应的记问题类型。
4. **留出集**:按招标文件整体划分,开工前选定,其中至少包括 S06(有分项限价)或 S10(多包、有单价上限)中的一份。
5. **裁定政策**:开发集上查出了响应清单里没有的问题,由人裁定一次,再改响应清单。投标文件里的问题是客观可查的,这是唯一允许「看过输出再改标注」的情况。留出集只记录,不改。
6. **人工复核**只看一件事:写出来的投标文件里,有没有无意中留下的问题(占位符没填、复制错位)。
7. 指标:致命级问题的召回率(漏报比误报严重);问题准确率;证据没通过校验的问题占致命问题的比例(目标 < 10%,否则会成为查标的「待确认区噪音」);每次检查的 token 和费用。
8. 按真实招标文件写出的投标文件,和招标文件一样不进 git。
## 5. 分步交付
每一步都要通过后端检查和前端检查(AGENTS.md「工程」一节);新接口都要有「A 企业的令牌访问 B 企业的资源,返回 404」的用例。
### 第 0 步:评测集
按 §4。问题分类表、响应清单格式、留出集划分,**在写第一条规则之前定稿**。投标文件可以和第 1 步并行写。
### 第 1 步:链路打通(假模型)
- 投标分册:类型和版本;加密格式识别(C8)。
- 拆 `ParseJob` 的阶段函数,`prepare_bid` 复用;健全性检查。
- `check` 模块:`check-preview`、发起 / 取消 / 查询检查、六个阶段的骨架、计费三个分支、状态沿用、`check_item_result`(C9)、`coverage` 字段(§2.5)。
- 处理问题:resolve(忽略要写原因;致命问题「确认无误」也要写说明,同时写一条 `feedback`)、reopen、只能批量处理「提示」级、只能处理最近一次检查的问题;同一个事务里更新 `open_fatal_count`。沿用「已确认无误」的致命问题时,如果证据所在的分册换了版本,标出「证据所在文件已更新」。
- SK1、SK2 的补丁:Valkey 发布客户端设短超时,SSE 连接池设上限;CI 加 Gotenberg、直接跑 `init-roles.sh`;架构 §8 改掉 testcontainers 的写法。
- 前端:上传投标文件和发起检查页、查标页的最小版本。
验收:在虚构样例上走完「上传投标文件 → 检查 → 处理问题 → 复查后状态沿用」;扣点、免扣、免费版、失败退回、409 `billing_changed`、健全性检查失败不扣点,都有测试。
### 第 2 步:投标侧结构识别和规则
- 读标新增「响应方式」抽取(C12):类别 × 方式 × 出处,规则优先;查标按响应方式决定「未找到响应」报不报、报什么级别。
- `bid_structure`:偏离表、开标一览表、分项报价表、格式文件、资格证明附件;认不出时走兜底,并写进 `coverage`。
- `rules` 模块:§2.4 的四组规则;需人工核对的条目和 `manual-checks` 接口。
- 「报价」类清单项(C7)。
- 只在开发集上调,每条结构规则配一个开发集以外的反例。
### 第 3 步:AI 响应匹配
- §2.1 三层、§2.2 检索、`check.match` 提示词、投标侧三道校验、致命项重问一次。
- 「供应商 × 租户」统一限流(解决 `extract.py:36` 的 TODO)。
- token 预算、复查复用结果、重复条目分组(C5)、扫描件兜底(C10)。
- 在开发集上调完后冻结代码,留出集跑一次,结果写进 `docs/architecture/check-eval.md`,按实测定 C2 的元数。
### 第 4 步:资质库和资质匹配
- `qualification` 模块:资质的增删改、附件(企业级数据密钥,DM4);前端资质库页。
- `QUAL.*`:过期、等级不足、未覆盖。资格证明是扫描件时,按名称回到资质库里匹配(C10)。资质数据只来自用户自己的资质库和投标文件(红线 5)。
### 第 5 步:检查报告
- HTML 模板 → Gotenberg 的 Chromium 转 PDF,嵌入思源宋体、思源黑体;后台生成,完成后发 `report.ready`。
- 内容按 FR-M3-09:首页(项目信息、投标文件版本、检查时间、规则版本、检查覆盖范围、免责声明「辅助检查结果,仅供投标前自查参考」)、汇总、问题明细、需人工核对附录。另加一页「证据索引」,每条证据的文件、页码、引文单独成行,方便逐条翻原文。
- 报告生成后不再改,存哈希;重复导出就生成新报告。CI 检查模板文案,不能出现「通过 / 合格 / 保证」;logo 不当作结论章使用。
### 第 6 步:前端补齐
- 查标页:对照布局(招标原文 / 投标原文)、键盘操作(含 U 键撤销)、批量处理、「反馈:判断有误」、需人工核对、`coverage` 摘要。
- 读标清单上显示每条清单项的检查结论(「已响应 · 第 N 页」),已响应的也能反馈判断有误。
- 报告页、项目概览的检查记录、工作台的就绪状态和致命问题数。致命问题没处理完时显示「未就绪」,但不限制导出和递交(红线 4)。
## 6. 红线对照
| 红线 | 落在哪里 |
|---|---|
| 1 辅助定位 | 每条清单项的检查结论都留痕,包括「已响应」;处理问题记录处理人和时间 |
| 2 可复核 | 招标侧出处必有;投标侧出处要过三道校验,没通过的不判「已响应」;「未找到」的写明找过哪些分册、哪些章节;规则问题没有清单项时挂规则编号 |
| 3 不用绝对化表述 | 只称「辅助检查 / 风险提示」;没查的写进覆盖范围;报告模板做 CI 文案检查 |
| 4 不阻断用户 | 就绪状态只是提示 |
| 5 不编造 | 资质只来自资质库和投标文件;不给整改建议 |
| 6 报价 | 只读金额、不生成金额,问题说明里不写建议 |
| 7 签章 | 只提示位置和有无,写明「不鉴定真伪;电子签章可能在投标平台加盖」 |
| 9 额度 | 失败、取消、健全性检查不过都不扣点;免扣也记一行;复查复用结果 |
## 7. 待确认事项:最终建议(2026-11-04 全部确认,见 decisions.md)
| # | 事项 | 建议 |
|---|---|---|
| C1 | 投标文件样本 | 按 §4 自己写:用招标文件自己的格式章,响应清单就是金标准,按招标文件整体留出,人工只裁定开发集上「响应清单里漏记的问题」。**用户有一份真实的国动小程序投标文件**,作为留出集里唯一的真实样本:第 3 步冻结代码后再要来跑一次,之前不看、不按它调 |
| C2 | 单次检查成本 | 不先定元数。设计时按 token 预算:模型输入总量不超过投标正文字数的 3 倍加上清单文字。另加三条:按投标文件章节打批、每次检查有硬预算且超出部分写进覆盖范围、复查复用结果。第 3 步在 S06 规模的样本上实测,再定元数(一起为 D2 提供依据) |
| C3 | 模型说已响应,但证据没通过校验 | 不判已响应。问题类型记「未找到响应」,级别不降,`detail` 记证据状态和原因码,说明里写「模型认为第 N 页有响应,引文未能核对」,界面可以按原因码筛。致命项允许重问一次(只给模型指的那一页)。评测单列这类问题的比例,目标是占致命问题的 10% 以内 |
| C4 | 签章 | 只提示位置和有无。主要检查是**格式文件里该填的地方有没有填**:投标人名称、法定代表人或授权代表姓名、落款日期(「年 月 日」)还空着。「(盖单位章)」「(签字)」这类括注在盖章后通常仍然留着,括注本身不算问题。图像检测只看签章位置附近有没有图像,结论只到「提示」。Word 稿也转成了规范化 PDF,两种稿走同一套检查 |
| C5 | 重复条目 | 检查时分组:只在同类别、同一个包里,按保守的文本相似度聚组(阈值记成规则,配反例)。问题挂在组里编号最小的清单项上,其他成员的出处作为附加的招标侧出处,`detail.group_members` 记全组;复查对齐时,组内任一成员对上就算同一个问题。分组结果记在检查快照里,界面显示「此要求出现在 3 处」。读标阶段合并以后再做 |
| C6 | 是否全查 | 正式清单全查。没查的写进覆盖范围:待确认区的条数、不在所投包的条目、超出预算的条目 |
| C7 | 分项限价 | 补 `extract.price`,规则优先(「限价 / 最高限价 / 单价不超过」+ 金额 + 所在的表行),模型只做结构化;用户也能在清单里手工新增「报价」类条目。10 份里只有 S06、S10 两份有分项限价或单价上限,不让它卡住总价和采购包两层 |
| C8 | 投标分册类型 | 固定选项:整本(默认)/ 资格证明 / 商务 / 技术 / 报价 / 其他,再加一个自填的分册名称。类型只用来排序检索候选和填写「找过哪些分册」,不限制检索范围。电子投标平台的加密格式按文件头和扩展名维护一张表,422 写明是哪个平台的格式 |
| C9 | **新增:每条清单项的检查结论** | 新增 `check_item_result` 表(检查批次 × 清单项:结论、走的哪一层、投标侧出处、校验状态、原因码、token)。「已响应」也是 AI 结论,按红线 1、2 要能复核、能反馈;复查复用结果、覆盖范围也靠它。问题仍然只为没响应好的条目生成。需要新迁移,并同步 `data-model.md` |
| C10 | **新增:扫描件投标文件** | 真实投标的资格证明、业绩合同多是盖章扫描件,OCR(S5)还没做,不处理的话会出一大片致命级「未找到」。MVP:候选分册里图片页占比超过阈值的条目,不出「未找到响应」,改出「该部分为扫描件,请人工核对」;资格类按名称回到资质库匹配;覆盖范围列出没有文字层的页码。S5 排在 M3 之后 |
| C11 | **新增:检索方案** | 内存 BM25 + jieba(§2.2),改掉 `ref-bid-engine-analysis.md` §5.4 的 pg_trgm 写法 |
| C12 | **每条要求怎么才算「响应了」**(2026-11-04 新增,已按建议确认,decisions.md C12) | 招标文件对响应方式的规定按类别不同(`evals/check/GUIDELINES.md` §2):逐条响应、默认接受(只列偏离)、要证明材料(分投标时和中标后)、文件要在、平台内应答,同一份文件里常有几种,S07 还自相矛盾。建议:① 读标新增「响应方式」抽取,粒度是「类别 × 方式 × 出处」,规则优先、模型兜底,条款原文写了「提供……证明材料」的按条覆盖并记投标时 / 中标后;在清单页显示、可改,作为人工确认点。② 两处写法冲突时按更严的(逐条)处理,问题说明里同时引两处原文。③ 默认接受的要求没找到响应:一般要求记「按招标文件默认接受」、不出问题,结论带出处;实质性 / ★ 要求出「提示」级「请人工核对」,不出致命;正文里找到了不满足的值照常报。④ 证明材料是中标后提供的,投标时没附不报。⑤ 识别不出响应方式时按逐条处理(宁可多报),这一组「未找到响应」标明「招标文件未写明是否要求逐条响应」,写进覆盖范围;项目上给一个「本项目只列偏离」的开关,打开后这一组降为提示 |
| SK1 | 进度推送用 Valkey stream | 确认。补两处:发布用的客户端设短超时(现在 `valkey()` 没设,Valkey 挂掉时会卡住 worker,而不是只记日志);SSE 连接池设上限 |
| SK2 | 集成测试连 Compose 的 PostgreSQL | 确认。补三处:CI 直接跑 `init-roles.sh`,不再单独写一份建角色的 SQL;CI 加 Gotenberg;架构 §8 改掉 testcontainers 的写法 |
## 8. 读标教训在本计划里的对应
| 读标的教训 | 本计划 |
|---|---|
| 评测集建得太晚,在同一批样本上既调又评 | 第 0 步在规则之前;按招标文件整体留出;冻结代码后留出集只跑一次 |
| AI 标注、AI 裁定偏宽 | 响应清单就是金标准 |
| 评分脚本照搬抽取规则 | 只对照响应清单打分,准确率分两列 |
| 提示词例子取自测试文件 | `check.match` 的例子一律虚构 |
| 输出临近上限时条目静默丢失 | 沿用截断检测和对半重跑;每条必答,漏答记原因码 |
| 待确认区噪音 | 证据没通过的问题记原因码、单列指标、可筛选 |
| 章识别静默失效 | 认不出就兜底,并写进覆盖范围 |
| 规则只修真正的缺陷 | 投标侧引文对不上不在附近找句子改挂;致命项重问是重试,不放宽校验 |

View File

@@ -0,0 +1,114 @@
# M6 剩下的 P0:额度流水、套餐开通、数据删除、运营后台(实现计划 v0.1)
依据:FR-M6-03 / 05 / 06 / 07 / 09(`docs/analysis/03-functional-requirements.md`),J4、J5(`02-user-journeys.md`),G6、G8、G10,决策 Q9、Q13、P1、DM4;表结构已在基线迁移里(`data-model.md` §4、§6),用户端接口已在 `openapi.yaml` 里定义;运营后台的接口和选型「做到时再定」(架构 §3.11、§4.3)。
## 0. 现状
| 方面 | 已有 | 还没有 |
|---|---|---|
| 额度 | 注册发放、预扣 / 结算 / 退回 / 免扣、`GET /quota`、顶栏额度(≥ 80% 赭色) | 流水接口和额度页、80% / 100% 提醒、人工调整、过期 |
| 套餐 | 免费版注册即开通、`GET /plans`、专业版套餐(解析 500,D2 待定) | 开通 / 续费申请、运营开通、到期处理 |
| 删除 | 资质删除(QL6) | 删除项目、注销账号 |
| 通知 | 解析、检查、报告完成写通知;事件流 | 前端通知入口、标为已读 |
| 运营后台 | 表:`ops_admin`、`ops_session`;角色 `bd_ops`(文档里有,部署脚本里还没建);反馈写入 | 进程、登录、接口、前端,全部没有 |
另外发现一处冲突:原型 `project.html` 的删除写「30 天内可以在回收站恢复」,而 G10、DM4(已确认)是删除时销毁项目数据密钥、彻底删除。按决策优先,见 M6-1。
## 1. 用户端
### 1.1 额度页(FR-M6-03,原型 `quota.html`)
- 侧栏账户区(头像、套餐标签)点开进「额度与套餐」页;顶栏额度改成链接,也进这个页。
- 页面:本期额度卡(剩余 / 已用 / 退回,期间起止)→ 扣点规则表(原型原样)→ 按功能分布(读标招标文件、补遗和澄清、查标、免扣次数)→ 本期流水。
- 流水:`GET /quota/entries`,按单位筛选、按时间倒序分页;每行写时间、类型(发放 / 预扣 / 结算 / 退回 / 免扣 / 过期 / 调整)、数量(+ / −,墨色,Q1)、余额、关联项目 · 任务(「招标文件」「第 2 次检查」;项目删了写「已删除的项目」和任务编号,G10)、说明(调整原因、失败原因、「距上次扣额度的检查不到 24 小时」)。导出 CSV 在前端生成。
- 提醒:预扣后余额跨过 80%、100%(按本期发放算)时写站内通知,`quota_alert` 保证每期每档只提醒一次。
### 1.2 开通与续费(FR-M6-05,G8)
- 免费版:额度页和顶栏「额度用完」提示里有「开通专业版」;专业版:到期前 30 天起显示「续费」。
- 申请对话框(原型):企业名称(注册时的,只读)、发票类型(普票 / 专票)、纳税人识别号、联系人、手机号;专票再加地址电话、开户行账号。`POST /activation-requests`,不扣额度、不用先付款。
- 提交后页面顶部显示申请单号、状态(等待对公转账 / 已到账 / 已开通 / 已退回)、收款信息(收款单位、开户行、账号,从配置读 `BD_PAYEE_*`)和「转账备注写申请单号」。
- 运营确认到账并开通:建专业版订阅、发放额度(流水「发放」,说明写申请单号),发站内通知;之后填发票号。
- 续费:当前专业版还没到期时,新一期从旧一期结束时开始;旧一期剩下的额度到期清零(「本期没用完的不结转」,原型)。
### 1.3 到期(FR-M6-05)
- worker 每天跑一次定时任务(Procrastinate 周期任务):到期的订阅改「已过期」、记「过期」流水把余额清零;排队的下一期到点发放。
- 提前 30 天、7 天、到期当天各发一次站内通知。
- 过期后:数据保留,只读(M6-5)。
### 1.4 删除项目(J4、G10、data-model §6.1)
- 入口:项目头「更多」菜单(和归档放一起),已递交、已归档的也能删。
- 确认框:写明删掉什么、保留什么(额度流水、不含业务内容的审计),输入项目名称确认(原型)。
- `DELETE /projects/{id}`:同一事务里写审计、抹掉这个项目审计的内容、删项目行(级联)、销毁数据密钥;提交后后台任务删对象存储前缀。正在解析 / 检查的项目先取消任务再删(预扣退回)。
### 1.5 账号页与注销(J4、data-model §6.2)
- 「额度与套餐」页旁边一个「账号」页:企业名称、手机号(脱敏)、显示名、套餐;最下面「注销账号」。
- 注销:确认框写明后果(全部项目、资质库删除;开通申请和发票记录按财务要求保留;剩余额度作废、不退款),输入企业名称 + 短信验证码。`POST /tenant/close`:立即吊销会话、企业标「已注销」,项目按 §1.4 由后台任务逐个删。
### 1.6 通知入口
- 顶栏加铃铛(原型有),有未读时带圆点;点开是浮层列表(最近 30 条:解析 / 检查 / 报告完成、额度提醒、开通结果、到期提醒),点一条跳到相关页面并标为已读,「全部标为已读」。`POST /notifications/read`。
## 2. 运营后台(FR-M6-09)
### 2.1 形态
- **后端**:同一个 Python 包的第三种进程 `bid-doctor ops`(和 api、worker 一样的镜像),单独端口、路径前缀 `/ops/v1`,用 `bd_ops` 数据库角色(绕过行级安全策略);用户端 api 进程里不挂任何运营接口。部署时运营端口只对内网 / 白名单开放。
- **登录**:用户名 + 密码 + TOTP 二次验证;IP 白名单(`BD_OPS_ALLOWED_IPS`,空着时拒绝启动于生产环境);会话 8 小时;账号用命令行建(`bid-doctor ops-admin create`),没有自助注册。
- **前端**:`apps/admin`,Vue 3 + Vite,复用 `tokens.css` 和组件样式,不引入带样式的框架(M6-3);单独构建、单独部署,私有化不交付。
- **审计**:每个运营操作(包括查询企业详情)都写审计事件,操作人记运营账号。
### 2.2 页面和接口(最小版)
| 页面 | 做什么 | 接口(`/ops/v1`) |
|---|---|---|
| 企业 | 按企业名称、手机号(用 HMAC 查)、申请单号搜;详情:成员(手机号脱敏)、套餐和到期日、各单位余额、项目数、最近任务;**不显示项目名称和任何业务内容** | `GET /tenants`、`GET /tenants/{id}` |
| 开通申请 | 待处理在前;标记「已到账」→「开通」(选套餐和周期,默认专业版 1 年),或「退回」写原因;开通后填发票号 | `GET /activation-requests`、`POST .../{id}/activate`、`/reject`、`/invoice` |
| 额度调整 | 在企业详情里:单位、±数量、原因(必填) | `POST /tenants/{id}/quota-adjustments` |
| 反馈 | 误报 / 漏报列表:类型、用户写的理由、问题标题和级别、规则编号、提示词版本、出处页码,**不显示招标 / 投标文件原文**(M6-7) | `GET /feedback` |
| 失败任务 | 解析、检查、报告失败的:错误类别、错误码、阶段、耗时、模型调用次数,不含文档内容 | `GET /failed-jobs` |
| 审计 | 按企业 / 对象 / 时间查审计事件(项目删除后只剩类型和时间) | `GET /audit-events` |
## 3. 步骤
1. 用户端后端:流水、提醒、开通申请、到期任务、删除项目、注销、通知已读,各配集成测试(额度对账:任何失败任务都没有净扣点;删除后对象不可解密)。
2. 用户端前端:额度与套餐页、账号页、通知入口、删除项目。
3. 运营后台后端:`ops` 进程、`bd_ops` 角色(部署脚本)、登录和 TOTP、上表接口、审计。
4. 运营后台前端:`apps/admin` 六个页面。
5. 浏览器走查:注册 → 用完免费额度 → 申请开通 → 运营开通 → 流水核对 → 到期(改时钟)→ 删除项目 → 注销。
## 4. 待确认(建议见每条)
| # | 问题 | 建议 |
|---|---|---|
| M6-1 | 删除项目要不要「30 天回收站」(原型这么写) | **不要,立即彻底删除**:按已确认的 G10、DM4 销毁数据密钥;回收站要软删除,和「彻底删除」冲突(同 QL6)。确认框要求输入项目名称,写明不能恢复。原型文案改掉 |
| M6-2 | 运营接口放在哪 | **单独的第三种进程 `bid-doctor ops`**(同一镜像、单独端口、`bd_ops` 角色);用户端 api 进程里没有运营接口,绕过行级安全策略的代码不会被公网请求触到 |
| M6-3 | 运营后台前端选型 | **Vue 3 + 复用 tokens.css / 组件样式,不引入带样式框架**:页面只有六个、以表格和表单为主;和用户端同一套组件,维护成本低。另一种做法是引入 Element Plus 之类加快开发,但多一套视觉和依赖 |
| M6-4 | 运营登录 | **用户名 + 密码 + TOTP + IP 白名单**,账号命令行创建(架构 §3.11 已写独立登录、白名单、二次验证) |
| M6-5 | 套餐到期后怎么办 | **不自动降为免费版**;项目只读(能看、能下载已导出的报告、能删除;不能新建项目、解析、检查、改清单);额度清零;提前 30 天、7 天、当天提醒。续费到账后恢复 |
| M6-6 | 开通申请页写不写价格、收款信息从哪来 | **页面不写价格**(D2 额度数字和定价都还没定),写「运营会联系你确认金额」;收款单位 / 开户行 / 账号从配置读,没配时显示「(待定)」,生产环境没配拒绝启动 |
| M6-7 | 运营看反馈时能不能看原文 | **不看原文**:只看标题、理由、规则编号、提示词版本、页码(红线 10:数据敏感、按租户隔离)。要复盘原文时请用户导出报告发过来,或以后做「用户授权查看」 |
| M6-8 | 注销账号的确认方式 | **输入企业名称 + 短信验证码**;有没到期的专业版时写明剩余额度作废、不退款 |
## 5. 落地记录(2026-11-05)
M6-1–M6-8 全部按建议确认后实现。和上文不一样、或计划里没写到的:
- **迁移 0010**:订阅加「排队中」(scheduled),续费到账时当前一期还没到期就排队,到点由定时任务生效发放;开通申请加申请单号 `request_no`(KT- 加 6 位);通知加「已到期」「申请被退回」;`billing_due(now)` 是 bd_auth 所有的 SECURITY DEFINER 函数,定时任务(worker 每小时 `billing_tick`)用它跨企业找到期、该生效、该提醒的订阅,只拿编号,处理时回到各企业自己的租户事务。到期时还有没了结的预扣就等下一次再清零,避免退回的 1 次漏在余额里。
- **迁移 0011**:`bd_ops` 的权限(跨企业只读、运营账号和会话表可写、审计可写),`bd_app` 收回运营账号表的权限。角色本身由部署脚本建(`init-roles.sh`,CI 同一个脚本)。
- **只读的落点**:到期后写操作在鉴权依赖里统一拦(`identity/deps.py` 的 `READ_ONLY_ALLOWED`),退出、续费申请、删除项目、注销、通知已读、反馈、管理登录设备照常放行;不用逐个接口去改。
- **额度单位**:只发放现在用得上的「解析」;专业版套餐里的 `generate`(M4 标书生成)等功能上线再发,免得顶栏出现用不了的额度。
- **删除项目**:正在解析、检查、生成报告时返回 409 `job_already_running`,先取消(预扣退回)再删;`data_key` 销毁以后不再建新密钥,还在跑的任务写不回文件。审计的 `before` / `after` 改为 Python 的 None 存成 SQL NULL(以前是 JSON 的 null)。
- **注销账号**:验证码改为登录后 `POST /me/sms-codes` 发到自己的号码(前端不知道完整号码);同步部分完成后手机号就能重新注册,删项目、资质库、抹审计内容、销毁密钥、清对象存储由后台任务 `close_tenant` 做,可以重复执行。
- **uuid7 单调**:同一进程内严格递增(RFC 9562 方法 2),额度流水按编号排序算逐条余额。
- **顺手修的**:取消解析、取消检查的接口在刷新以前转 API 会触发懒加载报错(MissingGreenlet),取消按钮会 500。
- **运营后台**:接口说明见 `ops-api.md`;前端 `apps/admin`(`pnpm dev:admin`,:5174)六个页面:登录、开通申请、企业(含调额度)、误报 / 漏报、失败任务、审计。
测试:后端 156 个(新增 `test_billing_flow.py` 4 个、`test_ops_console.py` 3 个,覆盖流水对账、提醒只发一次、开通 / 续费 / 到期只读、删除后不可解密、注销后可重新注册、运营登录和 IP 白名单、`bd_app` 读不到运营表、`bd_ops` 改不了业务表)。浏览器走查:注册 → 申请开通 → 运营标记到账、开通、登记发票 → 流水核对 → 调额度 → 到期只读 → 删除项目 → 注销账号。
没做的:运营后台没有「撤回申请」「延长订阅」这类操作(需要时用退回 + 重新申请、调额度代替);收款信息和发票是线下流程,系统只登记发票号。
**运营后台第二版(2026-11-05,决策 M6-9、M6-10)**:菜单改到左侧(墨色渐变底,标准横版 logo 反白,非生产环境挂赭色环境标签),分组可以展开收起、每项带图标和待办数(`GET /summary`),底部是当前运营人员(姓名、角色)和退出;登录页加 logo。运营人员的角色只留结构:迁移 0012 加 `ops_admin.role`,权限点和「角色 → 权限点」对应表、每个接口的检查点、按权限点过滤的菜单都已就位,现在只有管理员。见 `ops-api.md`。

View File

@@ -203,12 +203,40 @@ paths:
'204': { description: 已吊销 }
'404': { $ref: '#/components/responses/NotFound' }
/me/sms-codes:
post:
tags: [me]
operationId: sendMySmsCode
summary: 登录后发验证码到自己的手机号(注销账号的二次确认,前端不需要完整号码)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [purpose]
properties:
purpose: { type: string, enum: [close_tenant] }
responses:
'202':
description: 已发送
content:
application/json:
schema:
type: object
required: [resend_after_s]
properties:
resend_after_s: { type: integer }
'429': { $ref: '#/components/responses/RateLimited' }
/tenant/close:
post:
tags: [me]
operationId: closeTenant
summary: 注销账号(删除企业下全部业务数据,不可恢复)
description: data-model §6.2。需要短信验证码和企业名称两次确认。成功后当前会话失效。
description: |
data-model §6.2,决策 M6-8。需要短信验证码(`POST /me/sms-codes`)和企业名称两次确认;
企业名称不一致返回 422 `tenant_name_mismatch`。成功后所有会话失效,手机号可以重新注册;项目、资质库等由后台删除。
requestBody:
required: true
content:
@@ -573,6 +601,28 @@ paths:
items: { $ref: '#/components/schemas/Lot' }
'409': { $ref: '#/components/responses/ProjectReadOnly' }
/projects/{project_id}/lots/clear:
parameters:
- $ref: '#/components/parameters/ProjectId'
post:
tags: [projects]
operationId: clearLots
summary: 这个项目不分包(PI2)
description: 删掉所有包,清单项都归为各包通用,建回默认包。用于把分项误识别成包等情况。
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items: { $ref: '#/components/schemas/Lot' }
'409': { $ref: '#/components/responses/ProjectReadOnly' }
/lots/{lot_id}:
parameters:
- name: lot_id
@@ -599,12 +649,11 @@ paths:
delete:
tags: [projects]
operationId: deleteLot
summary: 删除分包(原属这个包的清单项变为各包通用)
summary: 删除分包(原属这个包的清单项变为各包通用,PI3)
description: 删掉最后一个包时项目回到「不分包」(自动建回默认包,PI2)。
responses:
'204': { description: 已删除 }
'409':
description: '`last_lot`:至少保留一个包;或 `project_read_only`'
content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
'409': { $ref: '#/components/responses/ProjectReadOnly' }
# ------------------------------------------------------------------ uploads
/uploads:
@@ -624,7 +673,7 @@ paths:
filename: { type: string, maxLength: 255 }
size_bytes: { type: integer, minimum: 1 }
sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: 选填;提供时完成上传时校验 }
purpose: { type: string, enum: [project_document, qualification_file] }
purpose: { type: string, enum: [project_document, qualification_file], description: '资质附件只收 PDF、JPG、PNG,单个不超过 20MB' }
responses:
'201':
description: OK
@@ -965,11 +1014,12 @@ paths:
application/json:
schema:
type: object
required: [items]
required: [items, hidden_count]
properties:
items:
type: array
items: { $ref: '#/components/schemas/ChecklistItem' }
hidden_count: { type: integer, description: '`lot_scope = bidding` 时不在所投包里、没列出的条数' }
post:
tags: [checklist]
operationId: createChecklistItem
@@ -1171,6 +1221,34 @@ paths:
description: '`checklist_empty` 或 `project_read_only`'
content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
/projects/{project_id}/checklist/response-modes/{category}:
parameters:
- $ref: '#/components/parameters/ProjectId'
- name: category
in: path
required: true
schema: { type: string, enum: [technical, commercial] }
put:
tags: [checklist]
operationId: setResponseMode
summary: 改招标文件某一类要求的响应方式(C12 的人工确认点)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [mode]
properties:
mode: { type: string, enum: [explicit, implicit] }
responses:
'200':
description: 返回清单概况
content:
application/json:
schema: { $ref: '#/components/schemas/Checklist' }
'409': { $ref: '#/components/responses/ProjectReadOnly' }
/projects/{project_id}/checklist/versions:
parameters:
- $ref: '#/components/parameters/ProjectId'
@@ -1207,6 +1285,39 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/CheckPreview' }
/projects/{project_id}/qualification-matches:
parameters:
- $ref: '#/components/parameters/ProjectId'
get:
tags: [checks]
operationId: listQualificationMatches
summary: 读标清单上的资质库情况(qualification-plan §4,第 6 步)
description: |
正式清单里资格条件、评分项中认出证书类别的条目,每类给出资质库里有效期最晚的一条,按投标截止日判断是否过期
(没有截止时间按今天)。认类别、排除产品和人员证书的口径和查标一样;资质库是空的时 items 为空(QL3)。
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
required: [as_of, by_deadline, items]
properties:
as_of: { type: string, format: date, description: 按哪天判断有效期 }
by_deadline: { type: boolean, description: as_of 是投标截止日(false 时是今天) }
items:
type: array
items:
type: object
required: [checklist_item_id, families]
properties:
checklist_item_id: { $ref: '#/components/schemas/Id' }
families:
type: array
items: { $ref: '#/components/schemas/QualificationMatch' }
'404': { $ref: '#/components/responses/NotFound' }
/projects/{project_id}/check-runs:
parameters:
- $ref: '#/components/parameters/ProjectId'
@@ -1244,7 +1355,7 @@ paths:
'409':
description: |
`job_already_running`(附 `job_id`)、`quota_exhausted`、`billing_changed`(附最新的 `preview`)、
`no_parsed_tender`、`no_bid_documents`、`project_read_only`
`no_parsed_tender`、`checklist_empty`、`no_bid_documents`、`project_read_only`
content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
/check-runs/{run_id}:
@@ -1309,6 +1420,30 @@ paths:
type: array
items: { $ref: '#/components/schemas/Finding' }
/check-runs/{run_id}/item-results:
parameters:
- $ref: '#/components/parameters/RunId'
get:
tags: [checks]
operationId: listCheckItemResults
summary: 每条清单项的检查结论(C9)
description: |
「已响应」也是 AI 结论,同样带投标侧出处、可以复核、可以反馈判断有误(红线 1、2)。
问题只为没响应好的条目生成;这里列出本批次参与检查的全部清单项。
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items: { $ref: '#/components/schemas/CheckItemResult' }
'404': { $ref: '#/components/responses/NotFound' }
/check-runs/{run_id}/findings/bulk-resolve:
parameters:
- $ref: '#/components/parameters/RunId'
@@ -1463,6 +1598,15 @@ paths:
operationId: generateReport
summary: 生成检查报告 PDF(后台生成,完成后推送事件)
description: 致命问题未处理也可以导出(红线 4)。每次生成都是一份新报告。
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
citations: { type: boolean, default: true, description: 问题明细带招标、投标两侧出处 }
manual_checks: { type: boolean, default: true, description: 附录「需人工核对」 }
responses:
'202':
description: 已排队
@@ -1532,10 +1676,15 @@ paths:
tags: [qualifications]
operationId: listQualifications
summary: 资质库
description: 按「有效期至」从近到远排,长期有效的在最后。
parameters:
- name: kind
in: query
schema: { $ref: '#/components/schemas/QualificationKind' }
- name: q
in: query
description: 按名称、证书编号、发证机构搜索
schema: { type: string, maxLength: 100 }
responses:
'200':
description: OK
@@ -1556,7 +1705,7 @@ paths:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/QualificationInput' }
schema: { $ref: '#/components/schemas/QualificationSave' }
responses:
'201':
description: OK
@@ -1576,7 +1725,7 @@ paths:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/QualificationInput' }
schema: { $ref: '#/components/schemas/QualificationSave' }
responses:
'200':
description: OK
@@ -1615,6 +1764,87 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Qualification' }
/qualifications/{qualification_id}/files/{file_id}/previews:
parameters:
- $ref: '#/components/parameters/QualificationId'
- name: file_id
in: path
required: true
schema: { $ref: '#/components/schemas/Id' }
get:
tags: [qualifications]
operationId: getQualificationFilePreviews
summary: 附件的预览图(图片一张,PDF 每页一张,最多 10 页),详情和大图浏览用
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
required: [file_id, filename, pages]
properties:
file_id: { $ref: '#/components/schemas/Id' }
filename: { type: string }
pages:
type: array
items:
type: object
required: [page, width, height, url]
properties:
page: { type: integer }
width: { type: integer }
height: { type: integer }
url: { type: string, description: 预览图(JPEG)的短时签名地址 }
/qualification-scans:
post:
tags: [qualifications]
operationId: createQualificationScan
summary: 上传证书扫描件识别(后台识别,轮询结果;不扣额度,每个企业每天限 100 次)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [upload_id]
properties:
upload_id: { $ref: '#/components/schemas/Id' }
responses:
'201':
description: 已排队
content:
application/json:
schema: { $ref: '#/components/schemas/QualificationScan' }
'429': { $ref: '#/components/responses/RateLimited' }
'503':
description: '`ocr_unavailable`:扫描件识别服务没有开或连不上,可以先手动填写'
content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
/qualification-scans/{scan_id}:
parameters:
- name: scan_id
in: path
required: true
schema: { $ref: '#/components/schemas/Id' }
get:
tags: [qualifications]
operationId: getQualificationScan
summary: 识别结果
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/QualificationScan' }
delete:
tags: [qualifications]
operationId: deleteQualificationScan
summary: 丢弃识别结果(连同原件)
responses:
'204': { description: 已删除 }
/qualifications/{qualification_id}/files/{file_id}:
parameters:
- $ref: '#/components/parameters/QualificationId'
@@ -1883,7 +2113,7 @@ components:
ProjectStatus: { type: string, enum: [tracking, drafting, checked, submitted, archived] }
LegalRegime: { type: string, enum: [gov_procurement, tendering, unknown] }
DocumentRole: { type: string, enum: [tender, addendum, clarification, bid_volume] }
DetectedFormat: { type: string, enum: [pdf, docx, doc, wps, ofd, encrypted_bid, unknown] }
DetectedFormat: { type: string, enum: [pdf, docx, doc, wps, ofd, encrypted_bid, jpeg, png, unknown], description: jpeg、png 只出现在资质附件(purpose = qualification_file)上 }
JobStatus: { type: string, enum: [queued, running, succeeded, failed, cancelled] }
Category: { type: string, enum: [qualification, invalidity, scoring, schedule, tech_param, format_doc, price] }
Importance:
@@ -1909,6 +2139,27 @@ components:
QuotaEntryKind: { type: string, enum: [grant, hold, settle, release, expire, adjust, waive] }
PlanCode: { type: string, enum: [free, pro, enterprise] }
QualificationKind: { type: string, enum: [certificate, system_cert, software_copyright, other] }
QualificationMatch:
type: object
description: 一类证书在资质库里的情况(读标页用)
required: [family, count, record, state]
properties:
family: { type: string, examples: [ISO 9001 质量管理体系认证] }
count: { type: integer, description: 资质库里这一类有几条 }
state: { type: string, enum: [valid, expired, missing], description: 按 as_of 判断;missing 是资质库里没有这一类 }
record:
description: 有效期最晚的一条;没有时为 null
oneOf:
- type: 'null'
- type: object
required: [id, name, kind, valid_to]
properties:
id: { $ref: '#/components/schemas/Id' }
name: { type: string }
kind: { $ref: '#/components/schemas/QualificationKind' }
level: { type: [string, 'null'] }
cert_no: { type: [string, 'null'] }
valid_to: { type: [string, 'null'], format: date, description: 'null 表示长期有效' }
ProjectFieldKey:
type: string
enum: [name, code, purchaser, agency, procurement_method, legal_regime, budget_total, ceiling_total,
@@ -2026,7 +2277,7 @@ components:
description: 给用户看的检查计费说明,如「检查从解析额度扣 1 次;同一项目 24 小时内复查不扣」
QuotaOverview:
type: object
required: [plan, accounts]
required: [plan, accounts, usage]
properties:
plan: { $ref: '#/components/schemas/PlanSummary' }
accounts:
@@ -2038,26 +2289,44 @@ components:
unit: { $ref: '#/components/schemas/QuotaUnit' }
balance: { type: integer, description: 剩余,含正在预扣的 }
held: { type: integer, description: 正在预扣、还没结算的 }
period_granted: { type: integer }
period_used: { type: integer }
period_granted: { type: integer, description: 本期发放(按当前这一期订阅) }
period_used: { type: integer, description: 本期净用量(预扣 − 退回) }
period_released: { type: integer, description: 本期退回的次数(失败、超时、取消) }
used_ratio: { type: number, description: '0~1;≥ 0.8 时界面变赭色(Q9)' }
by_feature:
type: array
description: 按功能分布,如解析、检查
items:
usage:
type: object
required: [feature, used]
description: 本期按功能分布(没被退回的预扣);deleted 是项目已删除、看不出用途的;waived 是 24 小时内复查的免扣次数
required: [tender, addendum, check, deleted, waived]
properties:
feature: { type: string, enum: [parse, check] }
used: { type: integer }
tender: { type: integer }
addendum: { type: integer, description: 补遗和澄清 }
check: { type: integer }
deleted: { type: integer }
waived: { type: integer }
renewal:
description: 已续费、排队等当前一期结束后生效的那一期
oneOf:
- type: 'null'
- type: object
required: [starts_at, ends_at]
properties:
starts_at: { $ref: '#/components/schemas/Timestamp' }
ends_at: { $ref: '#/components/schemas/Timestamp' }
pending_request:
description: 还在等转账或开通的申请
oneOf:
- type: 'null'
- $ref: '#/components/schemas/ActivationRequest'
QuotaEntry:
type: object
required: [id, unit, kind, amount, created_at]
required: [id, unit, kind, amount, balance_after, created_at]
properties:
id: { $ref: '#/components/schemas/Id' }
unit: { $ref: '#/components/schemas/QuotaUnit' }
kind: { $ref: '#/components/schemas/QuotaEntryKind' }
amount: { type: integer, description: 对余额的影响;结算、免扣为 0 }
balance_after: { type: integer, description: 记完这一条以后的账户余额 }
hold_entry_id: { oneOf: [{ $ref: '#/components/schemas/Id' }, { type: 'null' }], description: 结算、退回对应的预扣 }
reason: { type: string, description: '调整原因;免扣时为规则说明,如「24 小时内复查」' }
project:
type: [object, 'null']
@@ -2080,7 +2349,8 @@ components:
invoice_info: { $ref: '#/components/schemas/InvoiceInfo' }
InvoiceInfo:
type: object
required: [title, tax_no]
required: [title, tax_no, contact_name, contact_phone]
description: 专票(invoice_type = special)还要地址电话和开户行账号
properties:
title: { type: string, description: 发票抬头 }
tax_no: { type: string, description: 纳税人识别号 }
@@ -2091,14 +2361,27 @@ components:
contact_phone: { type: string }
ActivationRequest:
type: object
required: [id, plan_code, status, created_at]
required: [id, request_no, plan_code, status, payment, created_at]
properties:
id: { $ref: '#/components/schemas/Id' }
request_no: { type: string, examples: [KT-7Q3M2X], description: 申请单号,对公转账时写在备注里 }
plan_code: { $ref: '#/components/schemas/PlanCode' }
status: { type: string, enum: [submitted, paid, activated, rejected, cancelled] }
status:
type: string
enum: [submitted, paid, activated, rejected, cancelled]
description: 等待转账 / 已到账 / 已开通 / 已退回 / 已撤回
invoice_info: { $ref: '#/components/schemas/InvoiceInfo' }
payment_instructions: { type: string, description: 对公转账账户和备注要求 }
ops_note: { type: string }
payment:
type: object
description: 收款信息(配置项;没配时为「(待定)」,决策 M6-6)。页面不写价格,运营联系确认金额
required: [payee_name, payee_bank, payee_account]
properties:
payee_name: { type: string }
payee_bank: { type: string }
payee_account: { type: string }
ops_note: { type: [string, 'null'], description: 退回原因 }
invoice_no: { type: [string, 'null'] }
handled_at: { oneOf: [{ $ref: '#/components/schemas/Timestamp' }, { type: 'null' }] }
created_at: { $ref: '#/components/schemas/Timestamp' }
# ---------- projects
@@ -2137,6 +2420,11 @@ components:
progress: { type: integer }
label: { type: string }
next_action: { $ref: '#/components/schemas/NextAction' }
latest_check_run_id:
oneOf:
- $ref: '#/components/schemas/Id'
- type: 'null'
description: 最近一次完成的检查(「处理致命问题」直接打开它)
updated_at: { $ref: '#/components/schemas/Timestamp' }
ProjectDetail:
allOf:
@@ -2158,9 +2446,22 @@ components:
type: object
required: [item_id, label, at]
properties:
item_id: { $ref: '#/components/schemas/Id' }
item_id:
oneOf:
- $ref: '#/components/schemas/Id'
- type: 'null'
description: 来自哪条清单项;人工改过的投标截止时间没有对应条目,为 null
label: { type: string, examples: [投标截止] }
kind: { type: string, enum: [question_deadline, bid_deadline, opening, deposit_deadline, validity, other] }
at: { $ref: '#/components/schemas/Timestamp' }
edited: { type: boolean, description: 人工改过(投标截止时间按项目字段的人工值显示) }
replaces:
type: [object, 'null']
description: 已按补遗确认更新:原来的时间和带来新值的文件(「已按补遗 1 更新 · 原为……」)
required: [at, document_label]
properties:
at: { type: string }
document_label: { type: string }
superseded_pending: { type: boolean, description: 补遗带来了新值、等用户确认 }
latest_check_run_id:
oneOf:
@@ -2194,6 +2495,10 @@ components:
oneOf:
- $ref: '#/components/schemas/UserRef'
- type: 'null'
updated_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
pending:
type: [object, 'null']
description: 补遗带来的新值(FR-M2-10)
@@ -2235,6 +2540,9 @@ components:
edited_fields:
type: array
items: { type: string }
ai_values:
type: [object, 'null']
description: AI 识别的原值(包号、名称、预算、限价),人工修改不覆盖
# ---------- uploads / documents
Upload:
@@ -2263,13 +2571,20 @@ components:
page_count:
type: [integer, 'null']
description: PDF 能直接读出;Word 要转换后才知道,这里为 null
VolumeKind:
type: string
enum: [whole, qualification, commercial, technical, price, other]
description: 整本 / 资格证明 / 商务 / 技术 / 报价 / 其他(C8)
DocumentCreate:
type: object
required: [upload_id, role]
properties:
upload_id: { $ref: '#/components/schemas/Id' }
role: { $ref: '#/components/schemas/DocumentRole' }
volume_name: { type: string, maxLength: 40, description: '投标分册名:商务 / 技术 / 报价 / 资格证明' }
volume_name: { type: string, maxLength: 40, description: 用户自填的分册名称,不填时按分册类型显示 }
volume_kind:
$ref: '#/components/schemas/VolumeKind'
description: 投标分册类型,默认整本(C8)。只用来排序检索候选和填写「找过哪些分册」,不限制检索范围
replaces_document_id:
$ref: '#/components/schemas/Id'
description: 投标分册重新上传时填上一版的编号,生成新版本(FR-M3-01)
@@ -2282,6 +2597,10 @@ components:
label: { type: string, description: '如「招标文件」「补遗 1」「技术分册 v2」' }
seq_no: { type: integer }
volume_name: { type: [string, 'null'] }
volume_kind:
oneOf:
- $ref: '#/components/schemas/VolumeKind'
- type: 'null'
lineage_id: { $ref: '#/components/schemas/Id' }
is_latest_version: { type: boolean }
original_filename: { type: string }
@@ -2346,7 +2665,7 @@ components:
Citation:
type: object
description: 出处,不可变(data-model §5.1)
required: [id, document_id, document_label, page_no, sentence_keys, quote, quads, normalized_sha256, created_by_kind]
required: [id, document_id, document_label, page_no, sentence_keys, quote, quads, normalized_sha256, created_by_kind, ocr]
properties:
id: { $ref: '#/components/schemas/Id' }
document_id: { $ref: '#/components/schemas/Id' }
@@ -2363,6 +2682,7 @@ components:
type: array
items: { $ref: '#/components/schemas/Quad' }
created_by_kind: { $ref: '#/components/schemas/Origin' }
ocr: { type: boolean, description: 原句是扫描页或插图上 OCR 识别出来的,可能有错字(界面标「识别文字」) }
JobError:
type: object
description: 给用户看的失败原因;不含文档内容
@@ -2397,6 +2717,8 @@ components:
items_pending: { type: integer }
items_absent: { type: integer, description: 重新解析时没再出现的旧条目 }
lots_found: { type: integer }
dates_superseded: { type: integer, description: 补遗:更早文件里有几条日期条目被标「已被补遗 N 更新」(FR-M2-10) }
fields_pending: { type: integer, description: 补遗:项目字段里有几个新值等确认 }
verify_failures:
type: object
description: 没通过校验的原因码和条数(如 quote_too_short、value_unverified),评测和排查用
@@ -2429,6 +2751,10 @@ components:
type: object
required: [status, version_no, counts, marking_rules]
properties:
response_modes:
type: array
description: 招标文件对技术、商务要求的响应方式(C12)。识别出来的(origin = rule)可以改(origin = human,人工确认点);查标按它判没找到响应算不算问题
items: { $ref: '#/components/schemas/ResponseMode' }
status: { type: string, enum: [draft, confirmed] }
version_no: { type: integer, description: 最近一次确认的版本号;没确认过为 0 }
confirmed_by:
@@ -2570,6 +2896,13 @@ components:
lot_no: { type: [string, 'null'], description: 空表示各包通用 }
origin: { $ref: '#/components/schemas/Origin' }
origin_label: { type: string, description: '如「AI」「人工添加」「补遗 1」' }
origin_document:
type: [object, 'null']
description: 来自哪份招标侧文件(招标文件、补遗 N、澄清 N);人工添加的为 null
required: [id, label]
properties:
id: { $ref: '#/components/schemas/Id' }
label: { type: string }
zone: { $ref: '#/components/schemas/Zone' }
pending_reason:
oneOf:
@@ -2640,7 +2973,7 @@ components:
description: can_start 为 false 的原因
items:
type: string
enum: [no_parsed_tender, no_bid_documents, job_already_running, quota_exhausted, project_read_only]
enum: [no_parsed_tender, checklist_empty, no_bid_documents, job_already_running, quota_exhausted, project_read_only]
billing: { $ref: '#/components/schemas/BillingMode' }
charge: { type: integer, enum: [0, 1], description: 这次检查扣几次 }
charge_unit: { $ref: '#/components/schemas/QuotaUnit' }
@@ -2651,6 +2984,7 @@ components:
description: 24 小时免扣窗口的结束时间(从上一次扣点的检查算起,P1)
checklist_confirmed: { type: boolean, description: 为 false 时提示「清单未确认,检查结果以当前清单为准」 }
pending_item_count: { type: integer, description: 待确认区条目不参与检查 }
pending_updates: { type: integer, description: 补遗带来的新值、日期更新还没确认的处数;查标用旧值(AD4),只提示、不阻止 }
bid_documents:
type: array
description: 默认用每个分册的最新版本
@@ -2696,9 +3030,13 @@ components:
documents:
type: array
items: { $ref: '#/components/schemas/Document' }
basis_changed:
type: boolean
description: 检查之后改过项目依据(截止时间、限价、所投的包、法律体系),查标页提示「重新检查后生效」(PI5)
coverage:
type: [object, 'null']
description: 查了什么、没查什么(报告首页,FR-M3-09)
description: |
查了什么、没查什么(报告首页、查标页顶部,FR-M3-09、m3-plan §2.5)。认不出、查不了、没查的都写在这里,不静默跳过。
properties:
checked:
type: array
@@ -2706,6 +3044,53 @@ components:
not_checked:
type: array
items: { type: string }
documents:
type: array
description: 本次检查的投标分册;没有文字层(扫描件)的页码
items:
type: object
properties:
id: { $ref: '#/components/schemas/Id' }
label: { type: string }
pages: { type: integer }
pages_without_text:
type: array
items: { type: integer }
chars: { type: integer }
items:
type: object
description: 清单项条数:正式清单、本次查了的、待确认区、不在所投包、没完成判断、证据没通过校验
properties:
formal: { type: integer }
checked: { type: integer }
pending_zone: { type: integer }
other_lot: { type: integer }
undetermined: { type: integer }
evidence_unverified: { type: integer }
checklist_confirmed: { type: boolean }
legal_regime:
type: object
properties:
value: { $ref: '#/components/schemas/LegalRegime' }
rules_verified: { type: boolean, description: 这套法律体系的规则是否经过样本验证(招标投标体系暂无样本) }
rule_set_version: { type: string }
qualification:
type: object
description: 资质匹配的计数(qualification-plan §3.4):资质库条数、核对的资质要求、按资质库判断的、没认出类别的、排除的
basis:
type: object
description: 检查时用的项目依据(PI5):deadline_at、ceiling_total、legal_regime 各为 {value, source},lots 是所投各包 {lot_no, ceiling_amount, source}。报告按它写
prompt_versions: { type: object }
tokens:
type: object
properties:
in: { type: integer }
out: { type: integer }
model_calls: { type: integer }
verify_failures:
type: object
description: 证据没通过校验的条目数,按原因码计
additionalProperties: { type: integer }
counts:
type: [object, 'null']
properties:
@@ -2743,12 +3128,13 @@ components:
items: { $ref: '#/components/schemas/Category' }
rule:
type: [object, 'null']
description: 规则问题的规则编号和说明(detector = rule 时有);资质匹配的问题同时挂清单项
properties:
code: { type: string, examples: [PRICE.CAPITAL_MISMATCH] }
description: { type: string }
detail:
type: object
description: 按类型的补充信息:参数的要求值和响应值、资质预警种类(expired / insufficient_level / not_covered)、从投标文件读到的金额
description: 按类型的补充信息:参数的要求值和响应值、从投标文件读到的金额;资质问题(type = qualification)有 kind(expired / level_low / not_covered)、source(bid_text / library)、deadline,issues[] 里 source = library 的带 library(资质库记录的快照:id、name、kind、level、cert_no、valid_to),界面显示为「依据:资质库记录」
tender_citations:
type: array
items: { $ref: '#/components/schemas/Citation' }
@@ -2765,6 +3151,10 @@ components:
sections:
type: array
items: { type: string }
duplicate_ids:
type: array
description: 同一次检查里问题类型相同、要求原文完全相同的其他问题(清单里重复出现的要求,决策 C5 修订:各自判断,界面折叠显示)
items: { $ref: '#/components/schemas/Id' }
presence: { $ref: '#/components/schemas/Presence' }
carried_status:
oneOf:
@@ -2781,13 +3171,62 @@ components:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
ResponseMode:
type: object
required: [category, mode, status, origin, citations]
properties:
category: { type: string, enum: [technical, commercial] }
mode: { type: string, enum: [explicit, implicit], description: 逐条响应 / 默认接受(只列偏离) }
status: { type: string, enum: [found, conflict, unknown], description: 写明了 / 两处写法冲突(按逐条处理)/ 没写明(按逐条处理) }
origin: { type: string, enum: [rule, human] }
citations:
type: array
items: { $ref: '#/components/schemas/Citation' }
set_by:
oneOf:
- $ref: '#/components/schemas/UserRef'
- type: 'null'
set_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'
- type: 'null'
CheckItemResult:
type: object
required: [checklist_item_id, verdict, layer, evidence_status, bid_citations, detail, reused]
properties:
checklist_item_id: { $ref: '#/components/schemas/Id' }
title: { type: [string, 'null'] }
verdict:
type: string
enum: [responded, not_found, negative_deviation, suspected_unmet, no_value, undetermined, scanned, implicit_accepted, implicit_review]
description: |
已响应 / 未找到 / 负偏离未说明 / 疑似不满足 / 未给出具体值 / 未判定(模型漏答或暂时不可用)/ 扫描件请人工核对 /
按招标文件默认接受(未列入偏离表的视为接受,不出问题)/ 默认接受的实质性要求、请人工核对(C12)。
没有校验通过的投标侧证据,不会是 responded(架构 §3.6)
layer: { type: string, enum: [deviation_table, rule_compare, model, retrieval, presence], description: 结论来自哪一层(m3-plan §2.1;presence 是按文件在不在判断) }
evidence_status: { type: string, enum: [verified, unverified, none] }
reason_code: { type: [string, 'null'], description: 证据没通过、漏答、模型错误等的原因码 }
bid_citations:
type: array
items: { $ref: '#/components/schemas/Citation' }
detail: { type: object, description: 响应值、模型指的页码、找过哪些分册 }
reused: { type: boolean, description: 24 小时内复查时沿用了上一次的结论(C2) }
ManualCheck:
type: object
required: [check_key, label, checked]
properties:
check_key: { type: string }
label: { type: string, examples: [正副本份数与装订] }
requirement_text: { type: [string, 'null'] }
check_key: { type: string, description: "rule:MANUAL.<主题>:SUBMIT、SIGN_ENCRYPT、DECRYPT、PAPER、DEPOSIT" }
label: { type: string, examples: [纸质投标文件的份数、装订、密封] }
requirement_text: { type: [string, 'null'], description: 代表这一主题的清单条目标题 }
related_items:
type: array
description: 同一主题的清单条目(公告、须知、无效情形里常各写一遍,按主题合并成一条核对)
items:
type: object
required: [id, title]
properties:
id: { $ref: '#/components/schemas/Id' }
title: { type: string }
citation:
oneOf:
- $ref: '#/components/schemas/Citation'
@@ -2829,7 +3268,7 @@ components:
page_count: { type: integer }
sha256: { type: string }
template_version: { type: string }
open_fatal_at_export: { type: integer, description: 导出时还没处理的致命问题数 }
open_fatal_at_export: { type: integer, description: 生成时还没处理的致命问题数 }
generated_by: { $ref: '#/components/schemas/UserRef' }
created_at: { $ref: '#/components/schemas/Timestamp' }
@@ -2840,18 +3279,123 @@ components:
properties:
name: { type: string, minLength: 1, maxLength: 200 }
kind: { $ref: '#/components/schemas/QualificationKind' }
level: { type: [string, 'null'] }
cert_no: { type: [string, 'null'] }
issuer: { type: [string, 'null'] }
level: { type: [string, 'null'], maxLength: 40 }
cert_no: { type: [string, 'null'], maxLength: 80, description: 界面上必填(查标时用它核对附件),接口可空,以后批量导入时不一定读得全 }
issuer: { type: [string, 'null'], maxLength: 120 }
holder: { type: [string, 'null'], maxLength: 120, description: 证书主体(企业名、著作权人、持证人) }
valid_from: { type: [string, 'null'], format: date }
valid_to: { type: [string, 'null'], format: date, description: 空表示长期有效 }
valid_to: { type: [string, 'null'], format: date, description: 空表示长期有效;要晚于 valid_from }
QualificationSave:
description: 新增、修改资质。从识别结果保存时带上 scan:记下识别值,原件挂成附件(同一个原件不重复挂)
allOf:
- $ref: '#/components/schemas/QualificationInput'
- type: object
properties:
scan:
type: [object, 'null']
required: [scan_id, index]
properties:
scan_id: { $ref: '#/components/schemas/Id' }
index: { type: integer, minimum: 0, description: 识别结果里的第几张证书(从 0 数) }
QualificationScan:
type: object
description: 一次证书识别(vision-plan 第 2 步)。没保存的 24 小时后删除
required: [id, filename, status, error, pages, certificates, created_at]
properties:
id: { $ref: '#/components/schemas/Id' }
filename: { type: string }
status: { type: string, enum: [queued, running, succeeded, failed] }
error:
oneOf:
- type: 'null'
- $ref: '#/components/schemas/ScanError'
pages:
type: array
items:
type: object
required: [page, width, height, rotation, url]
properties:
page: { type: integer }
width: { type: integer, description: 识别用的页面图宽(像素),高亮框按它换算成比例 }
height: { type: integer }
rotation: { type: integer, description: OCR 转正用的角度(0 / 90 / 180 / 270) }
url: { type: string, description: 页面图的短时签名地址 }
certificates:
type: array
items: { $ref: '#/components/schemas/ScanCertificate' }
created_at: { $ref: '#/components/schemas/Timestamp' }
ScanError:
type: object
required: [code, message]
properties:
code: { type: string, examples: [ocr_unavailable, model_unavailable] }
message: { type: string }
ScanCertificate:
type: object
required: [page, category, kind, name, cert_no_field, fields, masked, suggest, notes, existing]
properties:
page: { type: integer }
category:
type: string
enum: [enterprise_qualification, system_cert, software_copyright, product, personnel, business_license, other]
kind: { $ref: '#/components/schemas/QualificationKind' }
name: { type: string, description: 建议的资质名称:证书标题,后面括注软件名或专业 }
cert_no_field: { type: string, enum: [cert_no, reg_no], description: 证书编号取哪个字段(软著取登记号) }
fields:
type: object
description: 字段名 → 识别结果。name、holder、level、specialty、software、cert_no、reg_no、issuer、issued_on、valid_from、valid_to、validity_text
additionalProperties: { $ref: '#/components/schemas/ScanField' }
masked:
type: array
description: 证书上有、但被打码或看不清的字段,要手动填写
items: { type: string }
suggest:
type: object
description: 推算的建议值,用户点了才填。valid_to:证书只写了有效期长度(如「五年」)时按发证日期推算
properties:
valid_to: { type: string, format: date }
notes:
type: array
items:
type: object
required: [code]
properties:
code: { type: string, enum: [personnel, product, business_license, holder_mismatch] }
holder: { type: string }
tenant: { type: string }
existing:
description: 资质库里证书编号相同的一条(换证时建议更新它)
oneOf:
- type: 'null'
- type: object
required: [id, name]
properties:
id: { $ref: '#/components/schemas/Id' }
name: { type: string }
ScanField:
type: object
required: [value, check, boxes]
properties:
value: { type: string, description: 证书上的原样文字 }
check:
type: string
enum: [ok, date_ok, low_conf, mismatch, bad_line, bad_format]
description: ok / date_ok 才自动填;low_conf(马赛克、模糊)、mismatch(识别结果和 OCR 对不上)等只作建议
day: { type: string, format: date, description: 日期字段换算成的日期 }
boxes:
type: array
description: 所引 OCR 行在页面图上的位置(像素)[x0, y0, x1, y1]
items:
type: array
items: { type: number }
Qualification:
allOf:
- $ref: '#/components/schemas/QualificationInput'
- type: object
required: [id, files, created_at, updated_at]
required: [id, files, recognized, late_for, created_at, updated_at]
properties:
id: { $ref: '#/components/schemas/Id' }
recognized: { type: boolean, description: 是从识别结果保存的(识别值留痕) }
files:
type: array
items:
@@ -2861,6 +3405,16 @@ components:
id: { $ref: '#/components/schemas/Id' }
filename: { type: string }
size_bytes: { type: integer }
late_for:
type: array
description: 进行中的项目(跟进中、编制中、已检查,截止日不早于今天)里,投标截止日晚于「有效期至」的项目。到期按每个项目的截止日判断,不按今天
items:
type: object
required: [project_id, name, deadline]
properties:
project_id: { $ref: '#/components/schemas/Id' }
name: { type: string }
deadline: { type: string, format: date }
created_at: { $ref: '#/components/schemas/Timestamp' }
updated_at: { $ref: '#/components/schemas/Timestamp' }
@@ -2872,14 +3426,15 @@ components:
id: { $ref: '#/components/schemas/Id' }
kind:
type: string
enum: [parse_succeeded, parse_failed, check_succeeded, check_failed, quota_80, quota_100,
subscription_activated, subscription_expiring]
enum: [parse_succeeded, parse_failed, check_succeeded, check_failed, report_succeeded, report_failed, quota_80, quota_100,
subscription_activated, subscription_expiring, subscription_expired, activation_rejected]
title: { type: string }
project_id:
oneOf:
- $ref: '#/components/schemas/Id'
- type: 'null'
link: { type: [string, 'null'], description: '前端路由,如 /projects/<id>/results' }
project_name: { type: [string, 'null'] }
link: { type: [string, 'null'], description: '前端路由,如 /projects/<id>/results、/quota' }
read_at:
oneOf:
- $ref: '#/components/schemas/Timestamp'

View File

@@ -0,0 +1,65 @@
# 运营后台接口(/ops/v1)
运营后台的接口不在用户端契约 `openapi.yaml` 里:它们只挂在 `bid-doctor ops` 进程上(决策 M6-2),前端 `apps/admin` 是唯一的调用方,类型在 `apps/admin/src/api.ts` 里手写。实现见 `server/src/bid_doctor/ops/`(`console_api.py` 路由、`console.py` 查询、`auth.py` 登录)。
## 安全
| 方面 | 做法 |
|---|---|
| 进程 | 和 api、worker 同一镜像的第三种进程,单独端口(开发 8019);部署时只对内网开放 |
| 来源地址 | `BD_OPS_ALLOWED_IPS`(CIDR 列表)以外的请求一律 403 `ops_ip_denied`;生产环境不配或放开全部地址拒绝启动 |
| 登录 | 用户名 + 密码(scrypt)+ TOTP 6 位动态码(身份验证器 App);同一用户名 15 分钟最多试 5 次;账号只能用 `bid-doctor ops-admin create` 建 |
| 会话 | cookie `bd_ops_session`(HttpOnly、SameSite=Strict,8 小时)+ 写操作带 `X-CSRF-Token`(等于 cookie `bd_ops_csrf`) |
| 数据库 | 跨企业查询用 `bd_ops`(绕过行级安全策略,业务表只读,运营账号和会话表可写,审计可写);对某个企业的修改走 `bd_app` + 那个企业的租户事务。`bd_app` 读不到运营账号表 |
| 留痕 | 每个操作都写审计(`actor_kind = ops`,记运营账号),查看企业详情也记一条 `ops.tenant.view` |
| 内容 | 不显示项目名称、招标 / 投标原文、审计的修改前后(决策 M6-7、红线 10) |
## 角色和权限点(只留结构,决策 M6-10)
以后会有管理员、运营、销售、运维等不同权限的运营人员。现在只建结构:
- `ops_admin.role`(迁移 0012,不加 CHECK);
- 权限点 `OpsPermission`(`ops/auth.py`):`tenant.view`、`quota.adjust`、`activation.manage`、`invoice.manage`、`feedback.view`、`jobs.view`、`audit.view`;
- 「角色 → 权限点」对应表 `ROLE_PERMISSIONS`:现在只有 `admin`(全部权限)。表里有、对应表里没有的角色什么权限都没有;
- 每个接口开头都调 `auth.require(op, 权限点)`,没有权限返回 403 `forbidden`;`/me` 返回角色和权限点,前端菜单按权限点过滤(`apps/admin/src/nav.ts`),一组都看不到时整组不显示。
加角色时只改 `ROLE_PERMISSIONS`、`ROLE_LABEL` 和 `ops-admin create --role` 的说明;要不要有「运营账号管理」页面、谁能建账号,到时再定。
## 菜单(决策 M6-9)
左侧菜单由 `apps/admin/src/nav.ts` 一处配置:分组(客户、质量、安全)→ 菜单项(图标、路由、要的权限点、待办数)。分组可以展开收起,记在本机;跳到收起分组里的页面时自动展开。待办数来自 `GET /summary`(待处理的开通申请、近 7 天失败任务、近 7 天反馈),做完操作和每分钟刷新一次。
## 接口
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/auth/login` | `{username, password, totp}`;失败 401 `ops_login_failed` |
| POST | `/auth/logout` | |
| GET | `/me` | 当前运营账号:角色、权限点、运行环境(非生产环境时菜单顶部提示) |
| GET | `/summary` | 菜单上的待办数;没有权限的那一项给 0 |
| GET | `/tenants?q=` | 企业名称(模糊)、手机号(精确,按 HMAC 查)、申请单号;不带条件列最近注册的 50 个 |
| GET | `/tenants/{id}` | 成员(手机号脱敏)、套餐历史、最近 30 条额度流水(不带项目名称)、任务按状态计数 |
| POST | `/tenants/{id}/quota-adjustments` | `{unit: parse, amount, reason}`;原因必填,不能扣成负数(G6) |
| GET | `/activation-requests?status=` | 待处理(等待转账、已到账)在前 |
| POST | `/activation-requests/{id}/paid` | 标记到账(只限等待转账) |
| POST | `/activation-requests/{id}/activate` | `{months = 12}`;免费版 / 已过期马上开始新一期,专业版没到期时新一期排队(续费) |
| POST | `/activation-requests/{id}/reject` | `{note}`,用户在额度页看得到原因 |
| POST | `/activation-requests/{id}/invoice` | `{invoice_no}`,开通以后才能登记 |
| GET | `/feedback?kind=` | 误报 / 漏报:标题、级别、理由、规则编号、规则集和提示词版本、页码 |
| GET | `/failed-jobs` | 解析、检查、报告失败的任务:错误类别、错误码、阶段、耗时、模型调用次数 |
| GET | `/audit-events?tenant_id=&action=` | 动作、对象、操作人、时间;`action` 按前缀筛选 |
## 本地跑起来
```
cd server && ../.venv/bin/bid-doctor ops # :8019
../.venv/bin/bid-doctor ops-admin create --username dev --display-name 开发运营 # 打印 otpauth:// 地址,用身份验证器 App 添加
pnpm dev:admin # :5174,/ops 代理到 :8019
```
已有的数据卷是在加 `bd_ops` 之前初始化的,要手工建一次角色(新数据卷由 `deploy/compose/postgres/init-roles.sh` 建):
```
docker exec -e PGPASSWORD=dev-owner bid-doctor-postgres-1 psql -U bd_owner -d bid_doctor \
-c "CREATE ROLE bd_ops LOGIN BYPASSRLS PASSWORD 'dev-ops'" -c "GRANT CONNECT ON DATABASE bid_doctor TO bd_ops"
```

View File

@@ -0,0 +1,128 @@
# 项目基本信息修改和分包选择实现方案 v0.1(2026-11-04)
> 范围:FR-M2-03 项目基本信息「用户可以修改,修改留痕」,FR-M2-04 分包识别与选择(G3)。
> 依据:`03-functional-requirements.md`、`02-user-journeys.md` J1 第 4 步、决策 G3 / DM7、`data-model.md` §5.3、`openapi.yaml`(`/projects/{id}/fields`、`/lots`、`/bidding-lots`)、原型 `project.html` `lots.html`。
> 状态:PI1–PI7 已全部按建议确认(2026-11-04,`docs/decisions.md`)。
## 0. 结论速览
- **为什么现在做**:查标的几条规则直接用项目信息。投标截止日决定证书过期(第 4 步刚做的资质匹配)和落款日期;所投的包决定查哪些清单项、和哪个限价比;法律体系决定用「投标无效」还是「否决投标」。这些都是模型抽的,现在抽错了用户改不了,查标结果就跟着错。国动是真实文件,不能指望抽得全对。
- **表和接口契约都已经有了**:`project_field` 分开存 AI 值和人工值,`lot` 有 `ai_values` 和 `edited_fields`;`openapi.yaml` 定义了字段修改、分包增删改和选包的接口。查标已经按「人工值优先」读字段、按 `is_bidding` 读所投的包。缺的是这些接口的实现和前端页面。
- **分包识别经常不对**(§1),所以「手工调整」和「这个项目不分包」都要一步就能做到,不能藏得太深。
- **检查要记下当时用的项目依据**:现在报告首页读的是导出时的项目信息,检查用的是检查时的值,中间改过就对不上(§5)。
## 1. 现状
**后端**:解析时 `project/extracted.py` 把 13 个字段和分包写进项目。用户改过的字段不覆盖,补遗的新值放进待确认;第一次识别到分包时替换掉建项目时的默认包。查标读字段时人工值优先,读所投的包用 `bidding_lot_ids`(不含默认包;没有分包时为空,表示整个项目)。清单项有 `lot_id`(空 = 各包通用),清单项修改接口已经能改 `lot_id`。
**没做的**:字段修改接口、分包增删改和选包接口、清单列表的 `lot_scope` 筛选;前端没有概览页、没有选包页,字段只在解析页显示了「识别到 N 个采购包」。
**分包识别的实际情况**(S01–S10 读标结果 `evals/out/runs/fix3/*/info.json`,对照 `07-sample-findings.md` 的人工档案):
| 招标文件 | 档案 | 识别结果 | 问题 |
|---|---|---|---|
| S10 | 3 个采购包,各有限价 | 3 个包,名称、限价都对 | — |
| S06 | 不分包(有分项限价) | 5 个「包」 | 把分项当成了包 |
| S01、S08 | 不分包 | 各 1 个包(「01」「1」),带限价 | 单包项目写了包号 |
| 其余 6 份 | 不分包 | 没有包 | — |
结论:只识别到 1 个包时按不分包处理(PI4);S06 这种错误要靠用户一键改成「不分包」,原属这些包的条目归为通用(PI2、PI3)。改进分包抽取属于读标专项优化(`extract-status.md`),这次不做,记进已知问题。
## 2. 基本信息修改(FR-M2-03)
### 2.1 接口
按契约实现 `PUT /projects/{id}/fields/{field_key}`,`value = null` 表示撤销人工值、恢复 AI 值。
- **校验按字段**:
| 字段 | 值 | 校验 |
|---|---|---|
| 名称、编号、采购人、代理机构、采购方式、评标方法 | 文本 | 去首尾空白;名称不能为空,其余空串等于撤销 |
| 法律体系 | `gov_procurement` / `tendering` / `unknown` | 枚举 |
| 预算、总价限价 | 金额字符串(元,两位小数) | 非负;界面可以写「286 万」「2,860,000」,提交前换成元(PI7) |
| 联合体、进口产品、专门面向中小企业 | 布尔 | 也可以撤销成「没写明」 |
| 投标截止时间 | 带时区的时间 | 界面按北京时间录入到分钟 |
- 写 `human_value / human_set_by / human_set_at`;`project` 表上冗余的几列(名称、编号、采购人、代理机构、采购方式、法律体系、截止时间)同步更新,撤销时回到 AI 值。
- 每次修改写审计 `project_field.set`(前后值),字段的修改记录从审计里读,和清单项同一做法。
- 项目已递交或已归档时返回 409 `project_read_only`。`If-Match` 选填,和清单项修改一致(MVP 只有一个人用,先不强制)。
- 响应里的 `updated_by` 填人工修改人。
- **关键日期跟着改**:概览的关键日期来自「日期与递交」类清单项;投标截止时间被人工改过时,时间线上的「投标截止」显示改后的值并标「已修改」,不然两处对不上。
### 2.2 不在这一步做的
补遗带来的待确认值(`POST .../fields/{key}/pending`)属于 FR-M2-10 补遗追加,下一步一起做;这次只在字段上显示「补遗 N 带来了新值」,不能确认。
## 3. 分包(FR-M2-04)
### 3.1 接口
按契约实现分包列表、新增、修改、删除和选包。补充几条规则:
- **默认包**:没分包的项目有一个默认包(`is_default`),界面上不显示,所有条目都归它(`lot_id` 为空)。手工新增第一个包时删掉默认包;删掉最后一个真实的包时自动建回默认包,即回到「不分包」,**不报 `last_lot`**(PI2,改契约)。
- **「这个项目不分包」**:新增 `POST /projects/{id}/lots/clear`,一次删掉所有包、建回默认包,条目都归为通用(PI2)。S06 那样识别错的项目一步改好。
- **删除一个包**:原属这个包的清单项归为各包通用(契约原文,外键 `ON DELETE SET NULL`);确认框写明「有 N 条清单项会归为通用」(PI3)。
- **修改**:包号(项目内不能重复)、名称、预算、限价;AI 识别的包改过的字段记进 `edited_fields`,AI 原值留在 `ai_values`。
- **选包**:`PUT /projects/{id}/bidding-lots`,至少选一个。没选过时所有包都算投(表的默认值),选包页会提示先选。
- 增删改、选包都写审计(`lot.create / update / delete / clear`、`project.bidding_lots`);只读项目返回 409。
### 3.2 清单按包筛选
- 清单列表加 `lot_scope`:`bidding`(默认,通用 + 所投的包)/ `all`。响应里带被隐藏的条数,清单页顶部写「通用 + 采购包 1 · 隐藏 23 条」,可以切到全部。
- 清单项对话框加「所属采购包」下拉(通用 / 各包),改归属走现有的清单项修改接口,留修改记录。
- 换了所投的包,清单的确认状态不变:清单内容没变,变的只是查哪些(PI6)。查标本来就按检查时的所投的包筛选,不在所投包里的条目写进覆盖范围。
## 4. 前端
- **概览标签页**(原型 `project.html`):这一步只做三张卡(PI1):
- **项目要点**:13 个字段,每个带出处(点开看原文);人工改过的标「已修改」,可以恢复 AI 值;点「修改」打开对话框,按字段类型给输入控件。
- **采购包**:包号、名称、预算、限价、是否投;「更改所投的包」去选包页。不分包的项目这张卡写「不分包」。
- **关键日期**:时间线和倒计时(FR-M2-09)。
- 原型里的检查记录、文件卡已在查标第 6 步做(2026-11-05)。AI 摘要还没做:原型「项目要点」里评分构成、保证金、递交方式这类概括,要另加抽取。
- **选包页**(原型 `lots.html`):
- 解析完成后,识别到 2 个及以上的包时,解析页的「下一步」进选包页,否则直接进清单(PI4)。
- 页面内容:每个包一张选择卡(包号、名称、预算、限价带出处、各包的条目数)、通用条目数、「识别得不对?手工调整」、「这个项目不分包」。
- 手工调整对话框:增删包、改包号、名称、预算、限价。
- **读标页**:顶部显示「通用 + 采购包 1」和隐藏条数,带「更改所投的包」链接;清单项对话框加所属包。
- **查标页**:检查预览里写明这次查哪几个包。检查结果页在项目信息于检查之后改过时提示「项目信息在本次检查后改过,重新检查后生效」(PI5)。
- 金额输入和显示:界面写元或万元都行,显示「286.00 万元」;限价旁边注明「招标文件原文」(红线 6:不是报价建议)。
## 5. 检查记下当时的项目依据(PI5)
`check_run` 已经存了 `lot_ids`。再在覆盖范围里存一份 `basis`:投标截止时间、总价限价、所投各包的包号和限价、法律体系,各带来源(AI / 人工)。
- **报告首页**改为读 `basis`。用户在检查后改了截止日再导出报告时,报告写的是检查时用的值,和问题明细一致。
- **查标结果页**比较 `basis` 和项目当前的值,不一样就出上面那条提示。
- 不加迁移,`coverage` 是 jsonb。
## 6. 测试
- 集成测试:字段修改、撤销、校验、只读、审计、冗余列同步;分包增删改、默认包的建回、「不分包」、删包时条目归通用、选包至少一个;清单 `lot_scope`;检查用了改后的截止日和所投的包,`basis` 记下的是检查时的值。
- 前端在浏览器里走一遍:概览改截止日 → 查标页提示重新检查;选包页手工调整、一键不分包、选包后清单隐藏条数。
- 不需要真实模型:分包用接口直接建,字段用测试样例解析出来的。
## 7. 工作量和顺序
1. 后端字段修改、分包接口、`lot_scope`、`basis`,以及测试。约半天。
2. 前端概览页(三张卡和修改对话框)。约半天。
3. 前端选包页、读标页和查标页的联动。约半天。
## 8. 待确认事项
| 编号 | 事项 | 建议 |
|---|---|---|
| PI1 | 字段在哪里改 | **做「概览」标签页**,这一步只做项目要点、采购包、关键日期三张卡;检查记录、文件、AI 摘要卡留到第 6 步。另一种做法是只在项目头放一个「项目信息」对话框,省事,但以后还得做概览页 |
| PI2 | 删掉最后一个包 | **回到「不分包」**(自动建回默认包),不报 `last_lot`;另给「这个项目不分包」一键操作。S06 那种把分项识别成 5 个包的,一步改好 |
| PI3 | 删包时原属这个包的条目 | **归为各包通用**(契约原文),确认框写明条数。被误识别的包里的条目多半本来就是通用的;归通用宁可多查,不会漏查 |
| PI4 | 只识别到 1 个包 | **按不分包处理**:跳过选包页,这个包的限价照用(作为采购包限价)。S01、S08 都是这种情况 |
| PI5 | 检查之后改了项目信息 | **检查记下当时的依据**,报告按检查时的值写;查标页提示「重新检查后生效」。不自动重新检查(重新检查可能扣点,红线 9 要求用户自己决定) |
| PI6 | 换了所投的包,清单还算不算已确认 | **算**:清单内容没变;查标按检查时的所投的包筛选,覆盖范围里写明不在所投包里的条数 |
| PI7 | 金额怎么录 | **按元存**,界面可以写「286 万」「2,860,000」,提交前换成元;显示用万元 |
## 9. 落地记录(2026-11-04)
- 后端:`project/edit.py`(字段修改、分包增删改、「不分包」、选包,都写审计);`project/service.basis` 是检查时的项目依据,查标存进覆盖范围、检查结果接口给 `basis_changed`,报告首页按它写截止时间;清单列表 `lot_scope` 默认只列通用 + 所投的包,响应带 `hidden_count`。
- 名称没有 AI 值时(建项目时手填的),撤销人工值保持现在的名称;界面只在有 AI 值时给「恢复 AI 值」。
- 前端:概览页三张卡(项目要点的值没有字段记录时取项目本身的值,比如建项目时填的名称)、字段修改对话框、选包页和手工调整对话框;解析完识别到 2 个及以上的包时「下一步」进选包页;读标页显示清单范围和隐藏条数,可以「显示全部」;清单项对话框加「所属采购包」;查标页写明本次查哪几个包;检查结果页在 `basis_changed` 时提示重新检查。
- 浏览器里走过:改截止时间(项目头、要点、关键日期一起变)、写「286万」的限价、手工加两个包并写限价、只投包 1、一键改回不分包。

View File

@@ -0,0 +1,189 @@
# 资质库和资质匹配实现方案 v0.1(2026-11-04)
> 范围:查标实现计划(`m3-plan.md`)第 4 步,即 FR-M6-08 最小资质库 + FR-M3-05 资质匹配检查。
> 依据:`03-functional-requirements.md`、`05-it-integration-domain.md` R07 / R08、决策 G4 / C10 / DM4、`data-model.md`(`qualification` `qualification_file`)、`openapi.yaml`(`/qualifications`)、原型 `qualifications.html` 和 `results.html` 的 f6、评测规范 `evals/check/GUIDELINES.md` D11 / D12 / D14 / D15。
> 状态:QL1–QL7 已全部按建议确认(2026-11-04,`docs/decisions.md`)。
## 0. 结论速览
- **表、接口、原型都已经有了**。基线迁移里已建 `qualification` 和 `qualification_file` 两张表;`openapi.yaml` 里已定义 7 个操作;原型资质库页的交互也已定稿。资质库本身是一个普通的增删改模块,工作量主要在前端页面和附件。
- **难点在匹配,不在资质库**。从样本看(§1),企业资质在政府采购项目里多数是**评分项**,不是资格条件;而且大量「证书」要求是**产品证书**(节能认证、CCC、商用密码)或**人员证书**,资质库管不了,必须先排除。
- **匹配不调模型**:按 IT 集成常见证书建一张类别词表,招标侧的要求、投标文字层、资质库三方都映射到类别再比较。认不出类别的,不做判断,写进检查覆盖范围。
- **证据有两个来源,可信度不同**:投标文字层(比如投标文件里的「资质情况表」)上读到的有效期和等级,有原文出处,按清单项定级;只有资质库记录、投标文件那边是扫描件或没写日期时,**降一级**,说明里写明「按资质库记录」。
- **资质库是空的就不做资质库核对**,只在覆盖范围里写一句。不能因为用户没录,就报一片「未覆盖」。
## 1. 样本里的资质要求长什么样
在 S01–S10 读标结果里筛出和证书有关的清单项(`evals/out/runs/fix3`),分成四种:
| 种类 | 例子 | 清单类别 | 资质库能不能管 |
|---|---|---|---|
| 企业资质(资格条件) | 电子与智能化工程专业承包资质、《安全生产许可证》(S01) | 资格条件 + 无效 | **能**,致命 |
| 企业资质(评分项) | ISO 27001 / 9001 / 20000(S09)、CCRC 信息安全服务资质一级、CCIA 一级(S01) | 评分 | **能**,重要 |
| 软件著作权(评分项) | 「虚拟化云平台软件计算机软件著作权登记证书(1 分)」、「软件著作权证书(3 分)」 | 评分 | 部分:软著长期有效、没有等级,只能看有没有 |
| 产品证书 | 节能产品认证、CCC、商用密码产品认证、IPv6 Ready、网络安全专用产品检测证书 | 技术参数 / 无效 | **不能**:证书是厂商的、跟着产品走 |
| 人员证书 | 「虚拟化维护人员具有虚拟化认证证书」 | 技术参数 | **不能**:人员库是 V1 |
结论:
1. 政府采购项目不能把和项目无关的资质设为资格条件,所以企业资质出现在资格条件里的少(10 份里只有 S01),多数在评分项里。这样一来,资质问题大多是「重要」,不是「致命」。
2. 产品证书和人员证书必须先排除,否则资质库会对它们报「未覆盖」,造成大量误报。
3. 真实投标文件里的证书基本都是盖章扫描件。不少 IT 投标文件另附一张文字的「企业资质情况表」,列出证书名称、编号和有效期,这是投标文字层上唯一能读到有效期的地方。
## 2. 资质库(FR-M6-08)
### 2.1 数据
用基线里已有的两张表,**不加列、不加迁移**:
- `qualification`:名称、类型(资质证书 / 体系认证 / 软件著作权 / 其他)、等级、证书编号、发证机构、有效期起止(「有效期至」为空表示长期有效)。
- `qualification_file`:附件。对象键 `t/<tenant>/q/<qualification>/<file>`,用**企业级数据密钥**加密(DM4)。
证书类别(§3.1)在用到时从名称和类型算出来,不存库。词表改了以后,旧记录不用迁移。
### 2.2 接口
按 `openapi.yaml` 现有的 7 个操作实现:列表(可按类型筛)、新增、修改、删除(连同附件)、挂附件(走现有的分片上传,`purpose = qualification_file`)、附件下载地址、删除附件。只加两处:
- `Qualification` 响应加一个字段 `late_for`:进行中的项目里,投标截止日晚于这张证书「有效期至」的项目(项目编号、名称、截止日)。原型里的「N 张证书会在进行中项目的投标截止前到期」提醒就靠这个字段。「进行中」指状态是跟进中、编制中、已检查,且截止日不早于今天。
- 列表接口加关键词参数 `q`,按名称、编号、发证机构搜索。
权限点用已有的 `qualification.edit`。增删改和附件操作都写审计:`qualification.create / update / delete`、`qualification_file.attach / delete`,修改记录前后的值。不扣额度。
### 2.3 校验
- 名称必填;「有效期至」必须晚于「有效期起」。
- 证书编号、「有效期至」:原型要求必填(长期有效的打开开关),**界面按原型必填,接口保持可空**,因为以后从投标文件批量导入时不一定读得全。
- 附件:PDF / JPG / PNG,单个不超过 20 MB,一条资质可以挂多个附件。
### 2.4 删除
删除是硬删:同时删行、删对象、写审计。原型在确认框之外还有「撤销」提示,**这一步不做撤销**(QL6):要撤销就得软删除,而软删除和「彻底删除」(G10)冲突;确认框已经够了。已导出的报告不受影响,因为报告里存的是生成时的快照。
企业注销时按 `data-model.md` §6.1 的顺序删资质库,企业级密钥一并销毁。
### 2.5 前端
照原型 `qualifications.html` 实现:侧栏「资质库」(图标 `award`)、类型分段筛选、关键词搜索、按「有效期至」排序(长期有效的排最后)、到期提醒条、新增 / 编辑对话框(日期选择器上标出进行中项目的截止日)、附件上传和预览、删除确认。页面底部注明「业绩、人员、历史标书等资产库在 V1 提供」。
## 3. 资质匹配(FR-M3-05)
### 3.1 证书类别词表
`check/qualification.py` 里放一张词表,规则集版本跟着升(2026.11-r4)。每个类别包括:别名(含国标等同写法)、等级写法,以及等级怎么比(数字越大越高,还是「一级」最高)。初版覆盖 IT 集成常见的企业资质:
| 类别 | 别名例子 | 等级 |
|---|---|---|
| ISO 9001 质量 | GB/T 19001 | 无 |
| ISO 14001 环境 | GB/T 24001 | 无 |
| ISO 45001 职业健康安全 | OHSAS 18001、GB/T 45001、GB/T 28001 | 无 |
| ISO 27001 信息安全 | GB/T 22080 | 无 |
| ISO 20000 IT 服务 | GB/T 24405 | 无 |
| ISO 22301 业务连续性 | GB/T 30146 | 无 |
| CMMI | 能力成熟度模型集成 | 1–5,大为高 |
| 信息系统建设和服务能力 | CS、CS1–CS5 | 1–5,大为高 |
| ITSS 运维 | 信息技术服务标准、运行维护 | 一级最高 |
| CCRC 信息安全服务资质 | 中国网络安全审查技术与认证中心;子类:安全集成、风险评估、应急处理、软件安全开发等 | 一级最高 |
| CCIA 信息系统业务安全服务资质 | 中国网络安全产业联盟 | 一级最高 |
| 电子与智能化工程专业承包 | 建筑业企业资质 | 一级 > 二级 |
| 建筑智能化系统设计专项 | 工程设计资质 | 甲 > 乙 |
| 涉密信息系统集成 | 涉密资质 | 甲 > 乙 |
| 安全生产许可证 | | 无 |
| 软件著作权 | 计算机软件著作权登记证书、软著 | 无,长期有效 |
**排除词**:要求里出现「产品」「设备」「所投」「厂商」「制造商」「节能」「环境标志」「CCC」「3C」「商用密码产品」「人员」「工程师」「项目经理」这类主体时,判定为产品证书或人员证书,不进资质匹配,仍按现在的流程处理(找响应、扫描件兜底)。
认不出类别、但清单类别是资格条件或评分项、文字里又有「证书 / 资质 / 认证」字样的,计入覆盖范围:「N 条资质要求没认出是哪类证书,有效期和等级没有核对」。
### 3.2 三方怎么对上
对每条进入资质匹配的清单项(类别是资格条件或评分项,且认出了证书类别):
1. **招标侧**:类别、要求的等级(「CS3 级及以上」「一级」)、子类(CCRC 的「安全集成」)。要求原文就是招标侧出处。
2. **投标文字层**:在响应匹配找到的证据句附近,以及投标文件里写着同一类别的表格行中,读证书编号、等级、有效期(「有效期至」「有效期:A 至 B」「截止日期」)。日期解析复用 `rules.py` 里的写法。只取**和这个类别同一行、或同一段落里只有这一张证书**的日期;附近出现两张以上证书、日期归属不清时不读。
3. **资质库**:同一类别的记录。同类有几条(旧证书和换发后的新证书)时取最有利的一条:有效期最晚、等级最高。
有效期比较用项目基本信息里的**投标截止日**(人工改过的为准),不用今天。「有效期至」当天仍算有效,即 `有效期至 < 截止日` 才算过期。项目没有截止日时,不比有效期,写进覆盖范围。
### 3.3 规则和定级
「按清单项定级」:资格条件 → 致命,评分项 → 重要(沿用 `findings.severity`)。
| 规则 | 什么时候报 | 证据来源 | 级别 |
|---|---|---|---|
| `QUAL.EXPIRED` | 证书在投标截止日已过期 | 投标文字层 | 按清单项 |
| | | 只有资质库 | 降一级(致命 → 重要,重要不变) |
| `QUAL.LEVEL_LOW` | 等级能比较,且低于要求 | 投标文字层 | 按清单项 |
| | | 只有资质库 | 降一级 |
| `QUAL.NOT_COVERED` | 投标文字层没找到(结论是「未找到」或「扫描件」),而且资质库**有记录但没有这一类** | 资质库 | 重要(不给致命:资质库可能没录全) |
| (不报问题) | 资质库里有、而且有效、等级够 | | 扫描件兜底那条「请人工核对」保留,说明里补一句:资质库里有《X》(编号…,有效期至…),请核对扫描件里附的是这一张 |
规定:
- **投标文字层和资质库冲突时,以投标文件为准**。投标文件里的日期已过期、资质库里有换发后的新证书时,仍按投标文字层报 `QUAL.EXPIRED`,说明里写「资质库里有有效期至 … 的新证书,投标文件附的可能是旧证书」。评审看的是投标文件。
- **一条清单项只出一个资质问题**。资质问题比扫描件兜底的「请人工核对」更具体,有资质问题时代替那一条;投标文件是文字版且确实没找到(「未找到响应」,致命)时,保留原问题,只在说明里补资质库的情况(「资质库里有这张证书,可能忘了附」)。
- **软件著作权**不查有效期和等级,只在扫描件兜底说明里补「资质库里有 N 件软件著作权」。要求里写了具体软件名的,也不按名称硬配:软件名的写法太散。
- **CCRC 子类**:要求写了子类(安全集成),资质库记录的名称里没有这个子类,按 `QUAL.NOT_COVERED` 报,说明写「资质库里的 CCRC 证书没写服务类别,请核对是不是『安全集成』」,级别是提示。
- **资质库是空的**:所有资质库规则都不跑,覆盖范围写「资质库还没有录入资质,有效期和等级没有按资质库核对」。查标页在覆盖范围旁边放一个去资质库的链接。
- 说明只写事实,不写整改建议。原型 f6 里「请上传换发后的证书」这一句去掉,改成「按资质库记录,有效期至 2026年11月15日,早于投标截止日 11月20日」。
### 3.4 问题的存法和出处
- `finding.type = qualification`,`detector = rule`,`rule_code` 是上面的编号,`checklist_item_id` 填对应清单项,`match_key = rule:QUAL.EXPIRED:<清单项>`,复查沿用按现有机制走。
- `detail` 里存:`kind`(过期 / 等级不足 / 未覆盖)、`source`(`bid_text` / `library`)、截止日、要求的等级,以及用到的资质库记录的**快照**(编号、名称、等级、有效期、记录 id)。资质库记录以后改了、删了,旧检查的问题和报告不变。
- 出处:招标侧照常挂清单项的原文出处;投标文字层来源的挂投标侧句子;资质库来源的**没有投标侧出处**,界面和报告显示「依据:资质库记录」块(名称、编号、有效期,能点开那条记录和附件)。这一点要你确认(QL2)。
- 覆盖范围加一节「资质核对」:资质库有几条、本次核对了几条资质要求、几条按资质库判断、几条没认出类别、产品证书和人员证书各排除了几条。
### 3.5 在流水线里的位置
放在 `runner.py` 的响应匹配和扫描件兜底之后、`rules.run` 之前,读一次本企业的资质库,不调模型、不计费。复查时重新算:资质库可能改过,结果不复用。
## 4. 不在这一步做的
| 不做 | 原因 |
|---|---|
| D14 证书主体和投标人不一致(子公司、曾用名) | 不在 FR-M3-05 的三种预警里;证书主体只在扫描件上;资质库挂在企业主体下,主体本来就一致。等 OCR(S5)后再看 |
| D15 业绩 / 人员证明的时间窗口 | 业绩库、人员库是 V1 |
| 产品证书核对 | 厂商的证书,不是用户的资质(红线 5:不从外部补数据) |
| 从投标文件或附件里自动识别、批量导入资质 | 要 OCR / 多模态;MVP 只手工录入(01-scope-roles:「手工录入 + 有效期」) |
| 读标清单上显示「资质库:ISO 9001 有效期至 …」 | 已在第 6 步做(2026-11-05):`GET /projects/{id}/qualification-matches`,按投标截止日判断,资质库空时不显示 |
| 软著按软件名称配对、按件数核对评分 | 写法太散,认不准的不报 |
## 5. 评测
- **类别识别**:把 S01–S10 读标结果里和证书有关的清单项全部人工标注(企业资质 / 软著 / 产品证书 / 人员证书 / 无关,再加类别和等级),约几十条,作为词表的单元测试。读标留出集的 S03、S06、S09 不在开发里看,词表写完后跑一次,只用来报数字。
- **规则**:每条规则配正例和反例单元测试:截止日当天到期不报;同类有新证书也有旧证书取新的;等级写法认不出时不比较;产品证书不进匹配;资质库空不报。
- **评测集**:生成器现在不生成证书文字。给风格 A / B 的规矩投标文件加一张「企业资质情况表」(虚构的证书、编号、有效期),注入 D12(有效期早于截止日;整张表的证书都过期)和「等级不足」两类问题,按 GUIDELINES 的流程请 Fable 复核、你抽查。资质库那一侧在评测里用虚构的资质库模拟,分「空 / 全 / 缺一类 / 有过期」四档。
- **局限**:评测集里的证书表是看着词表生成的,分数说明不了泛化。国动的真实投标文件到了以后,请你按自己公司的真实证书录资质库,再跑一次,这才是这一步真正的检验。
## 6. 工作量和顺序
1. 后端 `qualification` 模块:增删改、附件、`late_for`、审计,集成测试(含行级安全、企业级密钥)。约半天。
2. 前端资质库页。约半天。
3. 类别词表、标注样本、`check/qualification.py`、接入流水线、覆盖范围、问题渲染和报告。约 1 天。
4. 评测集加证书表和注入,复核。约半天。
## 7. 待确认事项
| 编号 | 事项 | 建议 |
|---|---|---|
| QL1 | 匹配方式 | **规则 + 证书类别词表,不调模型**。认不出的不判断、写进覆盖范围。理由:资质名称是有限集合,规则可解释,不花钱 |
| QL2 | 只有资质库记录、没有投标侧出处时能不能出问题 | **能出,降一级**,出处显示「依据:资质库记录」。资质库是用户自己录的数据,不是 AI 结论,和红线 2「AI 结论要能回溯原文」不冲突;如果不出,扫描件为主的真实投标文件就基本查不出过期 |
| QL3 | 「未覆盖」怎么报 | 资质库**有记录但没有这一类**时报「重要」;资质库是空的不报,只写覆盖范围 |
| QL4 | 投标文件和资质库冲突时 | **以投标文件为准**,说明里提一句资质库里有新证书 |
| QL5 | 免费版能不能用资质库 | **能用**。01-scope-roles 把资质库写在专业版下,但它不花额度、没有成本;免费版只有 1 个项目,用来试用完整的查标链路,缺了资质库体验就不完整 |
| QL6 | 删除要不要撤销 | **不要**,只留确认框;撤销需要软删除,和「彻底删除」冲突 |
| QL7 | D14(主体不一致)、D15(时间窗口) | **不在这一步做**,评测规范里这两类的实现阶段改为「OCR 之后 / V1」 |
## 8. 落地记录(2026-11-04)
和上文设计不一致、或实现时补的,记在这里:
- **一类证书一个问题**(改 §3.3「一条清单项只出一个资质问题」):一条评分项常写几类证书(S07「ITSS 三级得 1 分;CS3 及以上得 2 分」),一张过期、一张等级不够是两件事,合成一条时规则编号只能取一个。改为每条清单项里每类证书出一个问题,标题写证书名(「证书等级低于要求:信息系统建设和服务能力等级」),`match_key = rule:QUAL.*:<清单项>:<证书类别>`。
- **评分项不逐条找响应的也查**:applicability 把没写「提供……证书」的评分项跳过了(「评分标准本身」),而企业资质多数就在评分项里。资质匹配改为看所投包里的全部清单项;这类条目没有响应结论,投标文字层和资质库的过期、等级照常提示,「未覆盖」不报(不知道投标人报不报这一项分)。
- **等级按证书各读各的**:要求里属于这一类证书的那一段(从它的名称到下一类证书的名称)里读等级;投标侧先看短的格子(等级栏),证书编号里常带等级字样(「CS3-3201-…」);大写数字(「贰级」)也认。
- **响应错位规则收窄**(`check/deviation.py`):资质情况表的名称格(「安全生产许可证」,比要求短、以证书名结尾)不算「照抄的要求格」,右边的编号、有效期不算「响应对不上」。端到端测试里发现的。
- **营业执照、法人证书、联合体的资质认定规则**不算「没认出类别的资质要求」,不进覆盖范围的计数。
- **出处**:资质问题挂清单项自己的招标侧出处(`Hit.tender_refs`);问题接口的 `rule` 改为规则问题都带(原来挂了清单项就不带);报告模板 v2 在问题明细里写「依据:资质库记录《…》」。
- **前端**:资质库页和对话框(日期可直接键入,接受 2026/11/20、2026年11月20日等写法;日历弹层第 6 步已加);查标页资质问题显示「依据:资质库记录」块,墨色(用户录入的数据,不用 AI 出处的蓝铅笔色)。
- 评测见 `check-eval.md` §13。

View File

@@ -256,4 +256,46 @@
4. 识别占位符;
5. 提示词的禁止事项和服务端校验一一对应。
- **评测之后做**(涉及文字层数据结构):顶级章节识别并按章节类型分派任务;每页质量指标和扫描件判定;表格按行生成证据单元、带表头(解决两栏表、竖排文字串行)。
- **留到 M3 设计**:检查项来源分层、判定分层、检索不到不调模型、版式规则扫描;必须补投标侧引文逐字校验,pass 必须有校验通过的证据。检索用 PostgreSQL 的 pg_trgm / 全文检索,不自建倒排。
- **留到 M3 设计**:检查项来源分层、判定分层、检索不到不调模型、版式规则扫描;必须补投标侧引文逐字校验,pass 必须有校验通过的证据。~~检索用 PostgreSQL 的 pg_trgm / 全文检索,不自建倒排。~~(2026-11-04 改:句子不进库(DM1),中文全文检索要第三方扩展,改用内存 BM25,见 §6.4 和 `m3-plan.md` §2.2。)
## 6. 查标深读(2026-11-04 补)
> 为 M3 设计再读一次,聚焦 `handler/bidreview/`(18 个文件,约 7100 行)。由 Fable 做顾问走查,关键结论对照源码抽查过(预设清单项、按清单项重复送页面文本、投标侧证据不校验)。只读、未运行。路径相对 `bid-engine-backend/pkg/handler/bidreview/`。采纳情况见 `m3-plan.md`。
### 6.1 规模不同,成本做法不能照搬
它每个项目只有 14 条预设项、最多 24 条否决条款、若干资格字段和评分行、最多 18 条材料清单(`checklist.go:42-59, 133-135, 292-300`),大约 90 条。「技术方案对招标需求全覆盖」只算一条预设项(`checklist.go:57`),技术参数不逐条查。我们的正式清单有 216~1348 条。
### 6.2 值得学的做法
| 做法 | 位置 | 我们怎么用 |
|---|---|---|
| 检查项来源分层,每条有稳定的键;重建清单时只删非人工来源的检查项 | `checklist.go:97-124`;`verdict.go:147-150` | 对应 `match_key`。它的评分项键带序号(`checklist.go:214`),重抽后排序一变键就变,我们用清单项编号 |
| 召回为空就不调模型,直接「需人工确认」;批量结果里漏答的条目默认给 warning,不给 pass | `verdict.go:296-306, 375-394, 498-509` | 每条必答,漏答记 `missing_answer` |
| 判定阶段部分失败不拖垮整体,失败的条目可以单独复检 | `verdict.go:237-245`;`handlers_items.go:122-201` | 失败条目记「未判定(模型错误)」,写进覆盖范围 |
| 所有清单项都召回不到候选时,整个阶段失败并提示检查投标文件 | `pipeline.go:387-389` | `prepare_bid` 之后的健全性检查,按 `input` 失败、不扣点 |
| 版式扫描分强 / 弱证据;没有文字层的页面占比过高时,出「需人工核对」并列出页码 | `format_scan.go:100-176, 235-278` | 「别的项目名称」以本项目名称、编号、采购人为准做强证据;没有文字层的页码写进覆盖范围 |
| 页眉页脚单独落块,版式检查专门扫它 | `parse.go:194-211`;`format_scan.go:178-233` | 我们文字层已把页眉页脚单独成句(SK10),查别的项目名称时重点扫 |
| 报告附一张证据索引:每条证据的文件、页码、引文单独成行 | `excel.go:162-179` | 报告加「证据索引」页 |
| 每个功能的输出上限写死在代码里;模型并发用一个信号量,流水线和人工复检共用 | `llm.go:22-29, 68-91` | `check.match` 固定输出上限;解析和检查共用统一限流 |
### 6.3 需要避坑
| 问题 | 位置 | 对照 |
|---|---|---|
| 报价、签章、截止日都是「把要求写成文字,让模型读投标文件判断」:限价没有确定性的金额比对,签章没有任何图像层面的检查 | `checklist.go:46-50, 102-106`;`verdict.go:254` | 红线 6、7 |
| 投标侧证据不校验:模型给的页码和引文原样入库,页码对不上就挂到第一个召回文件;pass 没有证据也照样 pass;批量判定也一样 | `verdict.go:322-327, 397-402, 428-434, 465-486` | 红线 2 |
| 问题级别由模型定,模型可以把高危项降成低危 | `verdict.go:321, 396, 498-522` | 级别从清单项算 |
| 模型给每条结论写「整改建议」 | `verdict.go:72, 96, 113` | 报价类问题会写出数字(红线 6);我们不写建议 |
| 检索:每块只索引前 240 个词,后半块静默丢失;候选页截到 1400 字;一批里同一页正文给每条清单项各送一遍 | `parse.go:436-438`;`retrieval.go:75`;`verdict.go:345-353` | 召回静默损失,成本翻倍 |
| 投标文件的表格不做结构解析,偏离表、报价表被压成纯文本 | `pipeline.go:273`;`parse.go:113-116` | 偏离表直取靠单元格结构 |
| 解析不出页时把全文每 3000 字切成「伪页」;转 PDF 失败就直接解析原文件 | `parse.go:214-231` | 页码失真;我们按 `input` 失败、不扣点 |
| 输出截断不检测:JSON 修补失败就整批丢掉 | `llm.go:151-157` | 沿用 `near_limit` 和对半重跑 |
| 否决条款最多映射 24 条,超出的静默截掉 | `checklist.go:127-135` | 没查的都写进覆盖范围 |
| 报告用「通过 / 高风险 / 竞争力预估得分」 | `excel.go:18-24, 76`;`scoring.go:77-82` | 红线 3、5 |
| 复检后清单项上的人工确认状态不重置,用户确认的是旧结论(推测) | `verdict.go:411-448`;`handlers_items.go:96-101` | 证据所在分册换了版本时提示 |
| 企业规则的适用条件存了但从不求值,每条规则都进清单 | `checklist.go:237-251` | V1 做规则库时条件要真求值 |
### 6.4 检索方案
保留架构 §6.2 的内存 BM25 + jieba,不用 PostgreSQL 检索:句子放对象存储、不进库(DM1);检索只在 worker 里、一次检查内用,几百页建索引用不了几秒;中文全文检索要装 zhparser 之类的第三方扩展,pg_trgm 没有 IDF;内存索引是纯函数,便于测试和离线评测。它把 2-gram / 3-gram 倒排放进 MySQL 的代价(只索引前 240 词、没有 IDF)就是反例。

View File

@@ -1,4 +1,4 @@
# 技术验证 S1–S4 结论(2026-11-03)
# 技术验证结论(S1–S4 2026-11-03;S5、S6 初测 2026-11-05)
> 依据:架构文档 `architecture.md` §10。代码在 `spikes/`(一次性代码,复现步骤见 `spikes/README.md`),由样本派生的产物都在各子目录的 `out/` 里,不进 git。
> 环境:本机 Docker 29.8.2(Apple Silicon,**arm64**),Gotenberg 8.39.0;样本是 `samples/tenders/` 的 S01–S10。
@@ -12,208 +12,75 @@
| S2 | **可行**:在规范化 PDF 上识别「斜体且下划线」,和 DOCX 原文件逐字对得上 | 4 份文件按字符 100% 一致;片段级召回 0.993~1.0 | 不需要 Docling 来保格式 |
| S3 | **正文可行,表格要改进**:原句高亮的几何位置全部正确;切句在表格里会把相邻单元格拼在一起 | 几何回读 50/50、300/300;文本保真:正文 97.5%,表格内 78% | 切句要识别表格单元格和页眉页脚 |
| S4 | **可行**:两道证据校验 + 第三道「依据校验」后,抽取结果可用;★ 类条款建议用规则兜底保召回 | ★ 召回 93.5%(扣除银标准噪音后约 96.6%),斜体下划线 99.8%;采纳条目 95.2% 属实;每份文件约 0.15 元(高峰价) | §3.6 新增依据校验和规则兜底 |
| S5 | **可行(初测)**:RapidOCR(PP-OCRv6 small,CPU)识别字准、坐标准、够快;整页横放能自动转正;红章压字要局部按红色通道重读 | 国动投标文件 102 页(扫描 + 图表)62 秒;合成扫描件字符差 0.1%~1.1%;坐标回画全部对上 | §3.5 OCR 阶段可以照计划做,见 §7 |
| S5 视觉 | **可行(初测)**:DeepSeek Flash 收图片,页面分类、证书 / 合同字段、图表概述都能用;字段引用 OCR 行做校验能拦住两边读错的情况,但拦不住语义错和手写 | 102 页 23 秒,约 0.47 元(每页约 0.005 元);86 个字段 70 个和 OCR 行一致,16 个不一致里 10 个是跨行 | vision-plan §2 的交叉校验要支持多行、加字段格式校验 |
| S6 | **部分**:印章有无模型判断大体对,浅色骑缝章会漏;手写签名能认出 | 102 页里判「有章」44、「看不清」6;抽查 2 页漏 1 个边缘骑缝章 | 签章位检查要加红色像素初筛;准确率要等有标注的样本 |
## 1. S1 Word 转 PDF 的保真度
## 7. S5 / S6 扫描件识别初测(2026-11-05)
### 1.1 环境核实
> 计划见 `vision-plan.md`。代码在 `spikes/s5-ocr/`(独立虚拟环境,Python 3.12),产物在 `out/`,不进 git。
> 样本:国动真实投标文件(`samples/tenders/GD/响应文件-最新.pdf`,183 页)。本次没有扫描版招标文件样本(用户:招标文件通常是文字稿),证书扫描件用户随后提供。
- **Gotenberg 镜像**:`gotenberg/gotenberg:8`(8.39.0)有 **arm64 版本**,镜像约 2.5GB,基础系统是 Debian 13。
- **LibreOffice 版本**:26.8.1,比参考项目踩过坑的 7.3 / 24.2 都新。
- **自带中文字体**:只有 Noto Sans / Serif CJK。宋体、仿宋、黑体全部回落到西文的 Noto Sans,衬线和黑体都分不清。
- **样本用到的字体**:宋体(约 4.8 万处)、仿宋、黑体、楷体、方正系列、Times New Roman、Arial。都是商业字体,不能随镜像分发。
- **OLE 嵌入对象**:10 份样本里都没有,所以「内嵌 OLE 的 docx 会让旧版 LibreOffice 崩溃」这一条无法用本批样本验证,留到收集到这类样本时再测。
- **耗时与稳定性**:10 份文件 × 6 种配置,全部转换成功,没有崩溃;单份 0.4~2.0 秒。
### 7.1 OCR(RapidOCR 3.10,PP-OCRv6 small 检测 / 识别模型,onnxruntime CPU,Apple Silicon)
### 1.2 真值
- **挑页**:沿用文字层 v5 的扫描页规则,整页识别 80 页;图片占比 ≥ 0.15 的文字页只识别图片区域(跳过小于页面 5% 的小图),22 页。和文字层已有文字重叠的 OCR 行去掉。
- **速度**:200 dpi 渲染 + 识别,102 页 61 秒(约 0.6 秒 / 页,横放的报表页 1.5~3 秒)。按这个速度,300 页全扫描约 3 分钟,满足 10 分钟目标。
- **转正**:第 18、19 页的资产负债表是横放扫描,0° 识别出大量竖长框,试 90° / 270° 取「字数 × 平均置信度」最高者,判为 270°;框换算回原页坐标后画回去全部对上。
- **字准(合成扫描件)**:文字页渲染成图片,分三档退化(干净;灰度 + 倾斜 1° + 模糊 + 噪点 + JPEG 60;降采样 + 倾斜 2° + 更模糊 + JPEG 35),以文字层为真值:
| 来源 | 覆盖 | 说明 |
|---|---|---|
| 旧版 DOC 里 WPS 保存时记录的页数 | S07(79 页)、S09(73 页) | OLE SummaryInformation |
| DOCX 的 `docProps/app.xml` 页数 | **没有** | 8 份 DOCX 都是 ONLYOFFICE 7.2 生成的(苏采云平台导出),不写页数 |
| DOCX 目录里缓存的页码(PAGEREF) | S03、S04、S06 | 生成文档时排版写入;比较「各章标题之间隔了几页」,消除页码起始值的影响 |
| 文件 | 页 | 字数 | 字符差(不看顺序) | 按顺序的错误率 |
|---|---|---|---|---|
| 国动招标文件 | 5–24 | 12,806 | 0.31% / 0.41% / 0.32% | 1.2% / 1.4% / 1.3% |
| 国动投标文件 | 35–60 | 15,436 | 0.15% / 0.09% / 1.09% | 约 31%(表格多,阅读顺序和文字层不同,不是认错字) |
**发现**:平台导出的 DOCX 没有「Word 里显示的页数」可以对照。用户在 Word 或 WPS 里看到的分页,取决于他们自己装的软件和字体。所以「出处页码以规范化 PDF 为准」这条设计是必需的,不是权宜之计。
退化对字准影响很小。真实扫描件上没有真值,用视觉模型的交叉校验和人工抽查代替(§7.2)。
- **红章压字**:资产负债表「核算单位」被红章盖住,原图只认出「南京」「信息技术有限公司」两段。
- 按红色像素阈值抹掉印章:章下面的黑字也一起抹掉,更差。
- **只取红色通道当灰度图**(红章变浅、黑字不变):认出「核算单位:南京会信息技术有限公司」(错一字),肉眼可读出「南京长卿信息技术有限公司」。
- 但整页都用红色通道时,23 页有变化,有的变好(p21、p142、p162),有的变差(p16、p163)。**结论:只对红章附近的区域用红色通道重读,其余保留原图结果。**
- **图表**:技术方案里的 22 页架构图、流程图、界面截图,图里的文字全部读出(例如第 61 页系统逻辑架构图 26 行,四层的每个模块名都在)。这些文字以前不在文字层里,查标「出处没能核对」里架构图那几条(`check-eval.md` §14)就是因为这个。
### 1.3 严重问题:WPS 旧版 DOC 转出来正文是空白
### 7.2 视觉模型(DeepSeek Flash,`deepseek-chat` 不思考,`json_object`)
- **现象**:S07 默认转换后,正文渲染成空白,抽出的文字全是「第第第第」;S07 只有 61 页,真值是 79 页。
- **原因**:WPS 在字体表里把「宋体」的字符集标成了 `02`(SYMBOL,符号字符集)。LibreOffice 据此把正文当成符号,换成了 OpenSymbol 字体;转成 DOCX 后,这些字也变成了 `<w:sym>` 符号字符,不是普通文本。
- **修复**:先转 DOCX,把中文字体名被标成 SYMBOL 的字符集改成 GB2312(`86`),再转 PDF。修复后 S07 正文正常,**80 页**。
- **影响**:
- 转换前必须加这一步预处理。
- 用 DOCX 做交叉校验时,读文本要同时读 `<w:t>` 和 `<w:sym>`。
- 只要规范化 PDF 里出现大段 OpenSymbol 字体、或抽出的文字大量重复同一个字,就判定为「转换异常」:不扣点,提示用户。
- **做法**:每页图片(长边 1600 像素,JPEG 85)+ 带编号的 OCR 行一起发;输出页面类型、是否续页、标题、字段(必须引用 OCR 行号)、印章 / 签名有无、图表概述。字段值归一化后在所引行里找;只有数字一致算「近似」;对不上记「不一致」。
- **速度和成本**:102 页并发 6,23 秒;输入 199,863 token、输出 9,324 token,按 §4.3 的高峰价约 0.47 元,每页约 0.005 元。
- **页面分类**:劳动合同 35、业务合同 27、系统界面截图 9、信用查询截图 8、架构图 8、财务报表 4、流程图 4、纳税证明 2、其他图表 2、营业执照 1、授权书 1、其他 1。按「不是续页」的标记数,3 份业务合同(第 104、119、123 页起)、5 份劳动合同(第 135、142、149、156、163 页起,每份 7 页)的起始页都对,和页面下方的文字说明一致;没有逐页人工核对。第 21 页「登记通知书」归到「其他」,合理。
- **字段**:86 个,70 个和所引 OCR 行一致。16 个不一致:
### 1.4 字体配置对比(页数)
| 原因 | 个数 | 处理 |
|---|---|---|
| 值跨两行(住所、所属期间、合同期限、长合同名) | 10 | 允许引用多行,按拼接后的文字核对 |
| OCR 被红章或下划线切碎(「签订日期:20251」) | 4 | 红章附近用红色通道重读 |
| OCR 读错、模型读对(手写「2025」OCR 读成 2015) | 1 | 交叉校验按设计拦下,进待确认 |
| 模型读错(「长卿」读成「长顺」,还引错了行) | 1 | 交叉校验按设计拦下 |
| 配置 | 说明 |
|---|---|
| A-default | Gotenberg 默认,原文件直接转 |
| B-alias | 加 fontconfig 规则:宋体 / 仿宋 / 楷体 / 方正系列 → Noto Serif CJK SC,黑体 / 雅黑 → Noto Sans CJK SC |
| A-fixed / B-fixed | 同上,但先做 §1.3 的字符集修正 |
| **C10-fixed** | 字符集修正 + 用 fontTools 克隆 Noto CJK,**字形不变,只把行高改成 1.0em**(和宋体一致,上 0.86 / 下 0.14),改名 BD Song / BD Hei,替换规则同 B |
| C13-fixed | 同 C10,行高 1.3em |
**校验拦不住的**:
- 语义错:第 145 页「用人单位 = 甲方」「合同期限 = 1 种工时制度」,值确实在所引行里。要按字段类型加格式校验(单位名要像单位名,期限要有日期)。
- 手写:第 169 页乙方手写「2025 年 11 月 22 日」,OCR 和模型都读成「1 月」,两边一致但都错。手写区域即使两边一致也按低可信处理,不出致命级。
- 半截值:第 18 页「单位名称」取了 OCR 认出的半截「信息技术有限公司」,校验通过。被章挡住的值要标「可能不完整」。
- **印章 / 签名(S6 初测)**:判「有章」44 页、「看不清」6 页;手写签名 9 页。抽查:第 106 页右边缘的浅色骑缝章判成「无」(漏),第 131 页只露一截的骑缝章判「看不清」(合理)。签章检查只看指定签章位,骑缝章影响不大,但要加红色像素初筛补漏。没有标注,准确率没算。
- **图表概述**:架构图、流程图的概述准确(第 61 页把四层和每层的模块全部列对),适合在覆盖范围或出处卡片里作「图片说明(AI 生成,非原文)」。
| 样本 | 真值 | A-default | B-alias | A-fixed | B-fixed | **C10-fixed** | C13-fixed |
|---|---|---|---|---|---|---|---|
| S07(WPS) | 79 | 61(正文空白) | 61(正文空白) | 80 | 80 | **79** | 80 |
| S09(WPS) | 73 | 89 | 90 | 91 | 92 | **75** | 87 |
| S01 | — | 93 | 95 | 93 | 95 | 81 | 90 |
| S03 | — | 145 | 145 | 145 | 145 | 104 | 131 |
| S06 | — | 198 | 199 | 198 | 199 | 166 | 190 |
### 7.3 证书识别(资质库上传识别,vision-plan 第 2 步)
目录页码真值(标题之间的页数跨度误差,括号里是最大单点偏差):
> 样本:用户提供的 7 张网上公开证书图片(不进 git):人员证书 4 张(软考、职业技能等级 ×2、培训证书,其中 2 张姓名 / 编号打了马赛克)、软著 2 张(1 张低清、红章压日期)、一张照片里挂着两张软件产品证书(反光)。每张再转 90°、180°、270° 各做一份,共 28 次。标准答案由 Claude 对着图录(`spikes/s5-ocr/out/certs/gold.json`,不进 git,用户未复核)。代码 `spikes/s5-ocr/certs.py`。
| 样本 | 目录项 | A-fixed | C10-fixed | C13-fixed |
|---|---|---|---|---|
| S03 | 7 | +36.7%(29 页) | **-1.3%(1 页)** | +24.1%(19 页) |
| S04 | 8 | +32.3%(20 页) | **0.0%(1 页)** | +22.6%(14 页) |
| S06 | 6 | +17.3%(26 页) | **-2.2%(11 页)**¹ | +11.5%(26 页) |
- **流程**:图片按 EXIF 转正 → OCR 服务(转正、红章重读)→ 转正后的图 + 带编号的 OCR 行交给 DeepSeek Flash,按证书逐张输出类别和字段(每个字段引用 OCR 行)→ 校验。
- **转正**:90° / 270° 原来就能认;**180° 原来认不出**(逐行识别时 RapidOCR 自己翻正,字认得出,但整页图是倒的,模型把「评估机构」当成了「发证机构」、日期读错)。加了整页倒置检测:挑最多 30 个长行交给 RapidOCR 的方向分类模型,过半判 180° 就整页翻过来重识别。之后 16 种「图 × 角度」全部转对,倒置检测每页多 0.2~0.4 秒。
- **打码**:马赛克轻的地方 OCR 会「读」出字(培训证书的姓名、编号),模型照抄。两层处理:提示词要求被打码的字段不输出、写进 masked;服务端看所引 OCR 行的置信度,低于 0.9 标「识别不确定」,不自动填。透过马赛克读出的行置信度 0.69、0.88,正常行在 0.95 以上。
- **结果**(提示词改了两轮之后):200 个字段对 200,漏 0;打码字段 16 次全部没给值或标了识别不确定;一图两证两张都拆对。字段校验 192 个一致、8 个识别不确定(都是打码字段)、9 个不一致(值是对的,OCR 那行认错或值由两行拼成,会以「识别结果不一致,请核对」给用户)。
- **速度和成本**:每张 OCR 约 2 秒(含转正),模型约 1.5 秒;每张输入约 1,400 token、输出约 220 token,不到 0.005 元。
- **局限**:提示词是对着这 7 张改的(第一轮 50 个字段对 45、打码字段漏了 2 次),满分是调出来的,说明不了泛化。下一批证书不改提示词直接跑,才是正式成绩。样本里没有企业资质证书(CS、ITSS、ISO)和营业执照。
¹ S06 只匹配上 4 个目录项,最大偏差里有标题匹配不准的成分。
### 7.4 这份文件上能多查出什么
**结论**:
- 分页偏差的主因是**行高**,不是衬线还是黑体:Noto CJK 的行高约 1.45em,宋体是 1.0em,同样的「单倍行距」在 Noto 下每行高出 45%。
- 克隆字体的行高改成 1.0em 后,5 个有真值的数据点都落在 ±3% 以内,单点偏差大多在 1 页内。
- **字体许可**:Noto CJK 是 OFL 1.1,允许修改后再分发;改名后的字体仍按 OFL 分发,许可证文件要随镜像带上。
- **仍然存在的风险**:
- 这 5 个数据点都以宋体为主。仿宋、楷体为主的文件(S01、S02、S08)没有真值,偏差未知。
- 克隆字体只调了行高,西文字宽和宋体的半角宽度不同。表格密集、中英混排多的文件可能仍有偏差。
只是看结果做的推演,产品还没实现:
## 2. S2 格式识别(斜体 / 下划线 / 加粗)
- **主体名称不一致(D14)**:营业执照(第 16 页)是「南京长卿科技有限公司」,统一社会信用代码 91320114MA27H0P5X5;利润表、资产负债表、现金流量表(第 17、18、20 页)的单位名称,以及第三份业务合同(第 123–124 页)的乙方,都是「南京长卿信息技术有限公司」;资产负债表上的纳税人识别号和营业执照一致。多半是曾用名,但投标文件里没看到更名证明,值得提示用户核对。
- **证明材料**:第二十二条要的营业执照、财务报表、纳税、信用记录都能按页面类型定位到具体页,不用再出「请人工核对」。
- **评分项**:扫描页里只有营业执照、财务报表、纳税证明、信用报告、3 份业务合同、5 份劳动合同,没有软著、人员证书、国产化证明,「软件自主研发」「团队专业能力」「国产化适配」三条可以直接说「投标文件里没有找到这类材料」,不再说「可能在扫描件里」。
- **业绩**:3 份业务合同的金额(4 万、8 万、17 万)、甲乙方、签订日期都读得出,D15 的时间窗口核对有了数据。
### 2.1 方法
### 7.5 对计划的修改
- **银标准**:DOCX 原文件里「斜体且下划线」的文字。解析 XML 时完整处理样式继承:文档默认 → 段落样式链 → 字符样式链 → 直接格式。注意 python-docx 的 `paragraphs` 不包含表格里的段落,直接用它会漏掉大半。
- **PDF 侧**:
- 斜体:字体名含 Italic,或字形矩阵有倾斜(中文没有斜体字形,LibreOffice 合成斜体时倾斜量是 0.35);
- 下划线:字符底边附近有一条横线覆盖该字;
- 加粗:字体名含 Bold。
- 下划线的判定窗口要包含「字符框内侧」:克隆字体的下行部分变小后,下划线会落进字符框里。
### 2.2 结果(规范化 PDF = C10-fixed)
| 样本 | DOCX 斜体下划线字数 | PDF 识别字数 | 按字符一致 | 片段召回 | 片段精确率 |
|---|---|---|---|---|---|
| S06 | 14209 | 14209 | **100%** | 0.993 | 0.871² |
| S08 | 1088 | 1088 | **100%** | 1.000 | 1.000 |
| S09 | 70 | 70 | **100%** | 1.000 | 1.000 |
| S03 | 98 | 98 | **100%** | 1.000 | 1.000 |
² S06 的片段精确率偏低,是因为 PDF 按阅读顺序把相邻表格单元格的片段连在了一起,按片段匹配就对不上;按字符计数两边完全一致。默认字体配置(A-fixed)下结果相同,说明格式识别不依赖字体选择。
### 2.3 Docling 对比(S06)
| 输入 | 耗时 | 保留斜体下划线 | 页码和坐标 | 表格识别 |
|---|---|---|---|---|
| DOCX 原文件 | 2.1 秒 | **100%**(14209 字) | **没有** | 31 个 |
| 规范化 PDF | 96.2 秒(CPU) | **0** | 有 | 73 个 |
- `docling-serve-cpu` 有 arm64 镜像,约 7.2GB,本机拉取约 5 分钟。版本:docling-serve 1.38.2、docling 2.142.1。
- **结论**:Docling 做不到「格式 + 坐标」同时具备;我们自己从规范化 PDF 提取,两样都有,而且 4 份文件合计只要约 6 秒。Docling **不作为主解析器**。它的 PDF 表格识别可以作为候选,用来解决 S3 的表格切句问题,要再评估。
## 3. S3 原句级坐标与高亮
### 3.1 方法
- 从规范化 PDF 取逐字坐标,按行聚类;同一行里字间空隙大于 1.5 个字号时断开,得到「行片段」。
- 在 。!?; 处断句;段落末行或表格单元格结束时也断句。句子不跨页。
- 编号格式:`<文档>:p<页>:s<页内序号>`。规范化 PDF 不再重新生成,所以编号稳定。
- 每句生成 quads:按行合并成矩形。
- 校验分两种:
- **几何回读**:用 quads 裁剪原页重新取字,和句子比对;
- **文本保真**:句子能在 DOCX 原文里找到,不算 Word 自动编号和页码域。
### 3.2 结果
| 指标 | 50 句(种子 20261103) | 300 句(种子 7) |
|---|---|---|
| 10 份文件总句数 | 24020 | 24020 |
| 跨行句子占比 | 23 / 50 | 118 / 300 |
| **几何回读正确** | **50 / 50** | **300 / 300** |
| 文本保真 | 49 / 50(98%) | 269 / 300(89.7%) |
300 句按位置分类的文本保真率:
| 位置 | 文本保真 | 失败原因 |
|---|---|---|
| 正文 | **196 / 201 = 97.5%** | 个别行内拼接 |
| 表格内 | 71 / 91 = 78.0% | 相邻单元格(序号列、分值列)的文字被拼进同一句 |
| 页眉页脚带 | 2 / 8 = 25.0% | 页眉文字混进了正文句 |
- 高亮图在 `spikes/s3-citation/out/highlight-*.png`(8 张),样式是设计规范的蓝铅笔浅底加蓝铅笔描边。人工看过两张:跨 3 行的句子在句号处结束,同一行后面的下一句没有被带进来。
- **要改进的两点**:
1. 切句前先识别表格单元格,用 PDF 的表格线,或者参考 Docling 的表格结构,按单元格切;
2. 识别每页重复出现的页眉页脚带,单独处理,不并入正文句。
- 自动编号(「60.」「24.」)由 Word 渲染时生成,规范化 PDF 里有、DOCX 文字里没有。出处高亮以 PDF 为准,所以不影响高亮;只是做 DOCX 交叉校验时要容忍这一点。
## 4. S4 大模型抽取实质性条款
### 4.1 方法
- **输入**:S3 的句子,带格式标记(`<iu>` 斜体且下划线、`<u>` 下划线、`<i>` 斜体)。每个分块约 6000 字,10 份文件共 150 块。每个分块前附上从全文摘出的「标注规则说明」句。
- **银标准**(实质性条款句):
- ★ 句:排除说明句和「★号条款」这类引用句;
- 斜体下划线句:只在文件自己声明这种标注方式时才算(S06、S08、S09),否则它常常只是模板里的填空处。
- S03 正文的 ★ 句**正好 14 条**,和文件自述的「★ 项共 14 条」一致。
- **提示词**:`spikes/s4-llm/prompts/extract_substantive/`,有 v1、v2 两个版本。要求只能引用句子编号,引文必须逐字复制。
- **三道校验**:
1. 编号必须在本分块里;
2. 引文必须是原句的子串,并满足最小长度;
3. **依据校验**(v2 新增):模型声称的依据,用确定性信号核对。★ 看原句里有没有 ★,斜体下划线看有没有 `<iu>`,「写明无效」看原句和相邻句里有没有无效类字样。
- 任何一道没通过,条目都进**待确认区**,不丢弃。
### 4.2 结果(10 份文件全量)
| 指标 | v1(无依据校验) | **v2 + 依据校验** |
|---|---|---|
| ★ 召回(严格 / 含相邻句) | 91.3% / 92.4%(84、85 / 92) | **93.5% / 93.5%**(86 / 92) |
| 斜体下划线召回(严格 / 含相邻句) | 91.3% / 99.8% | 91.7% / **99.8%** |
| 采纳条目数 | 1761 | 651 |
| 采纳条目属实(命中银标准,或证据句写明无效) | 36.7% | **95.2%** |
| 进待确认区 | 66 | 283 |
| 证据两道校验通过率 | 95.1% | 93.6% |
| 输入 / 输出 token | 61.1 万 / 11.4 万 | 63.6 万 / 5.2 万 |
| 费用(高峰 / 空闲) | 1.93 / 0.96 元 | **1.49 / 0.74 元** |
- **漏掉的 ★ 句(v2)**:共 6 条,其中 3 条是银标准的噪音:「备注:加★为核心技术参数」「非标★、▲项为一般技术参数」「属于清单中打★品目的」。**真正漏掉的 3 条都是表格里的参数行**。扣除噪音后召回约 **96.6%**(86 / 89)。
- **v1 的主要问题是过度抽取**:合同履约、保密协议、付款等条款都被当成了「写明无效」。S09 有 208 条被标成「斜体下划线」,实际上全文只有 2 句。说明模型会**标错依据**,所以必须有第三道依据校验。
- **证据没通过的原因**:引文过短(少于 8 个字)、引文不是原句子串、编号不在本分块。都被校验拦下进了待确认区,没有一条错误出处进入正式清单。
- **耗时**:10 份文件 8 路并发,约 2 分钟;最大的 S06(199 页、28 块)单独串行累计 142 秒,并发后约 20 秒。
### 4.3 成本(供 D2 定价参考)
价格来源:DeepSeek 官方价目页(https://api-docs.deepseek.com/zh-cn/quick_start/pricing,2026-11-03 查询)。`deepseek-flash`(DeepSeek-V4.1-Flash-1017)每百万 tokens:输入缓存命中 0.04 元、未命中 2 元、输出 8 元(高峰);空闲时段半价。
| 口径 | 每份招标文件 |
|---|---|
| 本次实测(只做「实质性条款」一项抽取,v2) | 输入约 6.4 万 token、输出约 0.5 万 token;**约 0.15 元(高峰)/ 0.07 元(空闲)** |
| 推算:完整 M2 解读(资格、废标条款、评分、日期、参数、摘要等 5~6 项抽取) | 约 0.6~1.0 元(高峰),**推算值,未实测** |
- 换成深度思考模式或 `deepseek-v4-pro`,输入单价是 Flash 的 2.25~4.5 倍,还会多出思考 token。本次没有测,D2 定价前要补测「Pro 或思考模式能把召回提高多少」。
- 价格表里没有 `deepseek-chat` 这个名字,但接口仍然接受,实际由 Flash 提供服务。正式接入时应直接用 `deepseek-flash`,并把接口返回的 model 字段记进调用流水账(架构 §3.6)。
### 4.4 建议
1. **标注类条款用规则保召回**:★、*、文件声明了的斜体下划线,都能从规范化 PDF 上确定性地识别出来(S2)。所有这类句子都必须被某个条目覆盖;模型没覆盖的,系统自动生成一条「待确认」。模型负责归并、概括和判断依据,召回不再完全依赖模型。按 S2 的结果,这一类的召回可以做到接近 100%。
2. **「写明无效」类靠模型加依据校验**:这一类没有银标准,召回率还没测,需要人工标注 2~3 份文件。可以和样本缺口里的「人工标注 S03 / S06」一起做。
3. **数量核对**:文件写明了实质性条款数量的(S03「共 14 条」),和抽到的数量对比,不一致就提示(FR-M2-05 已有要求)。
4. 引文最小长度从 8 个字放宽到「不少于 6 个字,或整句」:表格单元格常常很短,「引文过短」是证据被拒的首要原因。
## 5. 对需求目标值的建议
| 需求 | 建议目标值 | 依据 |
|---|---|---|
| FR-M2-05 ★ / 标注类实质性条款召回 | **≥ 99%**(规则兜底 + 模型) | S2 格式识别 100%;S4 模型单独约 96.6% |
| FR-M2-05 「写明无效」类召回 | 先定 **≥ 90%**,人工标注后校准 | 尚无银标准 |
| FR-M2-05 采纳条目准确率(进正式清单的条目属实) | **≥ 95%** | S4 v2 实测 95.2% |
| FR-M2-06 出处页码正确率 | **100%** | 出处由句子编号决定,页码随编号而来;编号和引文都经过校验 |
| FR-M2-06 原句高亮准确率(抽查 50 条) | 正文 **≥ 98%**,表格 **≥ 95%** | 实测正文 97.5%、表格 78%,表格切句改进后再验收 |
| FR-M2-02 解析耗时(300 页) | 维持 10 分钟目标 | 转换 ≤ 2 秒,文字层约 2 秒 / 100 页,单项抽取并发后约 20 秒;完整解读按 5~6 项推算在 2~3 分钟内 |
## 6. 未覆盖的事项
- **S1**:内嵌 OLE 对象的 docx(本批样本里没有);以仿宋、楷体为主的文件的分页真值。
- **S3**:扫描件 OCR 页的坐标(S5 的范围)。
- **S4**:深度思考模式和 Pro 模型的对比;「写明无效」类的召回;资格、评分、日期等其他抽取项。
- S5(OCR)、S6(签章)、S7(任务队列)按 §10 的计划另做。
见 `vision-plan.md` v0.3:多行引用、字段格式校验、红章局部重读、手写低可信、半截值标记、签章位红色初筛;招标文件里的图片按重要程度分流(用户要求)。

View File

@@ -0,0 +1,243 @@
# 扫描件与图片识别计划 v0.3(OCR + 视觉模型)
> 2026-11-05。起因:国动真实投标文件查标时,扫描页上的内容一律成了「请人工核对」。本文盘点现状,回答「要不要视觉模型」,列出全系统里要识别图片的地方,给出分步计划。VS1–VS9 已确认(§7,`docs/decisions.md`);v0.2 按用户决定把视觉模型改为 DeepSeek Flash(VS2)。v0.3 按国动投标文件上的初测(`tech-validation.md` §7)补了校验规则,招标文件里的图片按重要程度分流(§3.1,用户要求)。
## 进度
| 步 | 状态 | 说明 |
|---|---|---|
| 0 验证 | 基本完成 | 国动投标文件的 OCR 和视觉初测(`tech-validation.md` §7.1、§7.2);用户的 7 张证书(§7.3,提示词对着这 7 张改过,满分说明不了泛化)。还差签章标注 |
| 1 OCR 进文字层 | **已完成**(2026-11-05) | 架构 §13 SK15;国动端到端见 `check-eval.md` §16。规则检查不看扫描附件和插图上的识别文字(第一次跑报出 21 个附件噪音后改的) |
| 2 资质库识别 | **已完成**(2026-11-05) | 架构 §13 SK16。按用户要求简化(VS11、VS12):一张图按一张证书处理;不单设识别入口,「添加资质」对话框最上面传证书照片,识别后填进下面的表单。产品接口上 7 张证书实测见下 |
| 3–5 | 未开始 | |
### 第 2 步落地记录(2026-11-05)
- **流程**:传照片(PDF / JPG / PNG / WEBP)→ `POST /qualification-scans` 排队 → worker 任务 `recognize_qualification`(`qualification/recognize.py`):每页图片按 EXIF 转正 → OCR 服务 → 转正后的图 + 带编号的 OCR 行交给 DeepSeek Flash(提示词 `vision.cert_fields` v1)→ 逐字段校验 → 前端轮询拿结果、填表。PDF 只识别前 10 页;不扣额度,每个企业每天 100 次;没开 OCR 时直接说明,可以手动填。
- **校验**:所引 OCR 行拼起来要包含字段值(日期按年月日比);所引行置信度低于 0.9 标「识别不确定」;对不上标「不一致」;「甲方」「本公司」这类不当证书主体。只有一致的才预填,其余只给建议、点「采用」才填。被打码的字段写「被遮挡,请手动填写」。
- **辅助**:名称写成「证书标题(软件名或专业)」;软著取登记号当证书编号、默认长期有效;只写了「有效期:五年」时给「按发证日期推算」;有效期起没写时给「发证日期 填入」;证书主体和企业名称不一致(曾用名、子公司)、人员证书、产品证书都提醒;编号和已有记录相同时可以「改为更新它」(换证)。
- **留痕**:保存时识别值记在 `qualification.ai_values`,识别用的照片挂成附件;没保存的识别 24 小时后删除(建下一次识别时顺手清)。迁移 0014。
- **产品接口实测**(真实 OCR + DeepSeek,7 张证书):每张 2~4 秒,全部识别成功,结果和 `tech-validation.md` §7.3 一致。**局限**:培训证书的编号中间几位打了码,OCR 这次跳过了打码的字、置信度不低,读成「FRX11032897」并预填了,两层防护都没拦住。所以界面要求逐项核对,识别出的值不能当确定值。
## 0. 结论
- **OCR 和视觉模型都还没有接。** 架构里写的是「PaddleOCR 独立服务 + 多模态大模型补充」(架构 §1、§3.5、AR1),技术验证 S5(OCR)、S6(签章检测)一直排在 M3 之后(C10),没有做。
- **要用视觉模型,但它不能代替 OCR。** OCR 给文字和坐标,保证原句高亮、出处可查(红线 2);视觉模型负责「理解」:这页扫描件是什么材料、证书上的字段、签章有没有、图里的表格。视觉模型的结论要能对上同一页的 OCR 文字,对不上的进待确认区。
- **视觉模型用 DeepSeek Flash,不另加供应商**(VS2)。v0.1 以为 DeepSeek 不收图片,实测是错的:`/models` 返回 `deepseek-flash`(DeepSeek-V4.1-Flash)的 `input_modalities` 是 `text`、`image`,`deepseek-chat` 直接收 OpenAI 格式的 `image_url`。见 §2.1。
- 除了读标、查标、资质库,还有一处 P0 缺口:**签章有无(FR-M3-04)完全没做**,`rules.py` 把它列在「还没查」里。它也要靠视觉。
## 1. 现状
| 地方 | 现在怎么做 | 代码 |
|---|---|---|
| 判断扫描页 | 每页记图片占比(文字层 v5),字少、图多的页算 `empty` | `document/pipeline/text_layer.py` `Page.text_source` |
| 流水线的 OCR 阶段 | 只有阶段名,不做事;招标文件超过一半是扫描页就报 `ocr_required` 失败 | `normalize.py`、`runner.py` 的 TODO(S5) |
| 整份扫描的招标文件、补遗 | 解析失败:「扫描件识别还没有上线」 | `core/errors.py` `ocr_required` |
| 整份扫描的投标文件 | 检查失败:`bid_text_missing` | `check/runner.py:148` |
| 投标文件里的扫描页 | C10 兜底:要附证明材料而文字部分没找到,出提示级「第 X–Y 页是扫描件……请人工核对」;覆盖范围列出扫描页 | `check/match.py`、`check/findings.py` |
| 资质匹配 | 证书多在扫描页上,只能靠资质库记录(QL2,降一级);主体不一致(D14)、业绩时间窗口(D15)留到「OCR 之后」 | `check/qualification.py` |
| 招标文件正文里的图片 | **没有处理,也没有告诉用户**:截图的评分表、参数表、资格要求不会进清单 | — |
| 签章有无 | 没做 | `check/rules.py` `RULES_NOT_YET` |
| 资质库 | 手工录入,扫描件只当附件存着 | `qualification/` |
| 模型调用 | 提示词可以声明 `capability: vision`,但 `openai_compat.py` 只发文字消息(模型本身收图片,§2.1) | `ai/providers/openai_compat.py` |
国动那份投标文件(`check-eval.md` §14、§15):183 页里约 82 页是扫描件(资格证明、合同、劳动合同),12 条「请人工核对」只有 4 条有用。
## 2. OCR 和视觉模型怎么分工
| | OCR(自部署) | 视觉模型(DeepSeek Flash,私有化可选) |
|---|---|---|
| 产出 | 每行文字 + 坐标框 + 置信度 | 结构化理解:页面是什么材料、证书字段、签章有无、图片类型 |
| 出处 | 能高亮原句,满足红线 2 | 给不出可靠坐标,**不能单独当出处** |
| 风险 | 错字(红章压字、手写、低清、旋转) | 会「顺手改正」或编出没写的值,日期、编号尤其危险 |
| 成本 | CPU 上跑,按算力算 | 按 token 计费,逐页调用较贵 |
| 私有化 | 镜像自带模型,离线可用 | 客户要有 GPU 部署多模态模型,没有就降级 |
**组合方式:先 OCR,再把「页面图片 + 带编号的 OCR 行」一起交给视觉模型。** 视觉模型输出的每个字段都要引用 OCR 行号,服务端沿用三道校验:
1. 编号校验:引用的行号在这一页上;
2. 原文校验:字段值(日期、编号按规则归一化后)出现在所引的 OCR 行里;
3. 不一致处理:视觉模型读出的值和 OCR 行对不上(例如 OCR 把 2028 认成 2026),**不采信任何一边**,标「识别结果不一致」,进待确认 / 要用户填写。
初测(`tech-validation.md` §7.2)后补的四条:
4. **可以引用多行**:住所、合同期限、长合同名常跨两行(初测 16 个不一致里占 10 个),按所引各行拼接后的文字核对。
5. **字段格式校验**:值在所引行里也可能是错的(「用人单位 = 甲方」「合同期限 = 1 种工时制度」)。按字段类型校验:单位名要以「公司 / 中心 / 局……」结尾,日期能解析,期限有起止日期,编号有字母数字。不合格的进待确认。
6. **半截值**:所引的行挨着印章、或者值明显不完整(单位名没有后缀),标「可能不完整」,不当作确定值比对(例如不拿半截单位名去判主体不一致)。
7. **手写区域低可信**:手写的签订日期,OCR 和模型可能一致但都错(第 169 页手写的「11 月」两边都读成「1 月」)。OCR 置信度低于阈值、或模型标了手写的值,最多出提示级。
这样出处始终落在 OCR 的坐标框上,视觉模型只负责「理解」和交叉校验。没有文字的判断(签章有没有、这是一张拓扑图)只能做提示,不当成原文证据。
### 2.1 DeepSeek Flash 的图片能力(2026-11-05 实测)
| 测试 | 结果 |
|---|---|
| `GET /models` | `deepseek-flash`(DeepSeek-V4.1-Flash):`input_modalities: ["text", "image"]`,上下文 1M |
| `deepseek-chat` / `deepseek-reasoner` 收 `image_url`(base64) | 都返回 200,实际模型都是 `deepseek-flash` |
| 虚构的中文证书图:倾斜 1.5°、模糊、椒盐噪声、JPEG 55 质量、红色圆章,要求输出 8 个字段的 JSON(`json_object` 模式) | 名称、编号、主体、等级、发证机构、发证日期、有效期至、有无印章 **8 / 8 全对**;输入 393 token、输出 72 token |
这只是一张合成图,说明不了真实扫描件上的准确率;坐标框也没测(不打算靠它出坐标,§2)。S5 在扫描评测集上正式测。
**没有理由否决 DeepSeek Flash**:能力够、一张证书不到 500 token,和抽取共用一家供应商,还省掉一套供应商接入、协议(红线 10)和路由。仍要留意的两点:① 合成图上的满分不代表真实扫描件,手写签名、红章压字、低清复印件要在 S5 / S6 里专门测;② 只有一家供应商,DeepSeek 限流或故障时抽取和视觉一起停,降级候选列表(架构 §3.6)以后再配。
## 3. 全系统里要识别图片的地方
按模块挖了一遍,分三档:**P0 缺口**(MVP 范围内,现在没做或做得不够)、**MVP 内的增强**、**以后版本**(按「以后的功能只留结构」的原则,只保证能复用同一层,不展开设计)。
### 3.1 读标(M2)
| # | 场景 | 现在 | 要做的 | 档 |
|---|---|---|---|---|
| R1 | 整份扫描的招标文件(盖章扫描版、纸质转电子) | 解析失败 | OCR 进文字层,抽取照常;扫描页上的斜体下划线标注认不出,覆盖范围写明「实质性条款可能漏,请复核」,★ 按 OCR 常见误认(`*`、`☆`)归一 | P0(FR-M2-01「PDF 扫描版」) |
| R2 | 补遗、更正公告是盖章扫描件 | 解析失败 | 同 R1 | P0(FR-M2-10) |
| R3 | 文字版招标文件里的部分扫描页(盖章页、附件) | 当空页跳过 | 同 R1,逐页补 | P0 |
| R4 | 正文里的截图:评分表、参数表、资格要求的平台截图 | 静默忽略 | 图片区域单独 OCR,按单元格切句进文字层,可引用、可高亮;重要的再交给视觉模型理解(见下面的分流) | P0(重要的一定做) |
| R5 | 正文里的图纸:拓扑图、平面图、效果图、二维码、logo | 静默忽略 | 装饰类(logo、二维码、印章、页眉图标)跳过;重要的图纸交给视觉模型,给出「图片说明(AI 生成,非原文)」,图里的文字(OCR)可引用;不重要的只在覆盖范围列出 | P0(重要的一定做) |
| R6 | 扫描件上的标注格式(下划线、加粗) | — | 视觉模型认标注,作为依据校验的信号 | 以后,跟读标专项(`extract-status.md`)一起做 |
**招标文件里的图片怎么分流**(用户要求:不是每张都做全套,重要的一定做)。OCR 便宜(CPU 约 0.6 秒 / 页),视觉模型按页计费,所以:
1. **都做 OCR**:扫描页整页识别;文字页里面积超过页面 5% 的图片区域都识别。小图(logo、图标)不识别。
2. **判断重不重要**,满足任一条就算重要,交给视觉模型:
- 图片在要求类章节里:资格条件、评分标准、采购需求 / 技术参数、商务要求、报价 / 清单、合同条款、投标文件格式;
- 图片的 OCR 文字像表格(多行多列、有数字、单位、「★」「≥」)或带要求类字眼(「应」「须」「不得」「无效」「分」);
- 整页扫描的页(补遗、盖章的更正页、扫描附件)。
3. **视觉模型做什么**:判断图片类型(表格截图、架构 / 拓扑图、平面图、效果图、装饰);表格截图按行列还原,交给后面的抽取;图纸给一段说明。
4. **不重要的**(章节外的效果图、宣传图)不调视觉模型,覆盖范围列出「第 X 页有 N 张图没有细读」。
### 3.2 查标(M3)
| # | 场景 | 现在 | 要做的 | 档 |
|---|---|---|---|---|
| C1 | 投标文件里的扫描页(资格证明、合同、证书) | C10 兜底「请人工核对」 | OCR 进文字层:文件要在(按标题找)、响应匹配都能用上扫描页上的文字;C10 只留给 OCR 也读不出的页 | P0 |
| C2 | 整份扫描的投标文件(纸质投标扫描、盖章后扫描) | 检查失败 | 同 C1;报价规则在 OCR 文字上跑,数字的处理见 §4.6 | P0 |
| C3 | 扫描页是什么材料 | 分不出 | **附件索引**:视觉模型给每页(或连续几页)分类——营业执照、资质证书、ISO 体系认证、软著、社保缴纳证明、纳税证明、审计报告、信用查询截图、合同、中标通知书、发票、身份证复印件、人员证书、授权书、其他;文件要在、证明材料缺失(D02)按索引判断,不再说「可能在扫描件里」 | P0(FR-M3-05「每条证明材料都要找到」) |
| C4 | 投标文件里的证书字段 | 只能靠资质库 | 证书类页面抽名称、编号、等级、发证机构、有效期、证书主体;资质匹配有了投标侧出处,不再降一级(QL2 只用于真没识别出的情况);主体和投标人不一致(D14)可以做 | P0 |
| C5 | 签章有无 | 没做 | 从文字层找签章位(「(盖章)」「法定代表人(签字或盖章)」「投标人(公章)」),截取附近区域:红色像素做初筛(不调模型),视觉模型判断有没有印章、手写签名、电子签章;只报「有 / 没看到 / 看不清」,界面写明不鉴定真伪(红线 7) | **P0 缺口**(FR-M3-04、S6、G12) |
| C6 | 业绩合同扫描件:合同金额、签订日期、甲方、项目名称 | — | 字段抽取同 C4;按评分项的时间窗口核对(D15) | 结构在 MVP 留好,规则按 QL7 放 V1 |
| C7 | 人员证书、社保证明、学历职称 | — | 页面分类先认出来(C3);人员匹配是 V1 | 以后 |
### 3.3 资质库(M6-08)及其他
| # | 场景 | 现在 | 要做的 | 档 |
|---|---|---|---|---|
| Q1 | 新增资质 | 手工填全部字段,扫描件只作附件 | **添加资质的对话框最上面传证书照片,识别后填进下面的表单**(只有这一个入口,VS12):名称、类型、等级、证书编号、发证机构、证书主体、有效期。识别出的字段标「识别」,鼠标移上去在缩略图上高亮出处;用户核对后保存才入库(人工确认) | MVP 增强(用户要求) |
| Q2 | 证书主体和本企业不一致 | 没有主体字段 | 识别出的主体和企业名称不一致时提示(子公司、曾用名),不阻断 | 随 Q1 |
| Q3 | ~~一份 PDF 里汇编了多张证书~~ | — | **不做**(VS11):一张图按一张证书处理,认出几张时只填第一张并提示 | — |
| Q4 | 换证:传了新证书 | — | 证书编号或名称 + 类别匹配到已有记录时,建议「更新这条」而不是新增,旧附件保留 | 随 Q1 |
| Q5 | 企业主体信息(营业执照:名称、统一社会信用代码) | 注册时手填企业名 | 传营业执照自动填;给 C4、Q2 的主体比对提供依据 | MVP 增强,可选 |
| L1 | M4 资产库:历史标书、业绩合同、人员证书入库 | V1 | 复用同一层 | 以后 |
| L2 | M5 查重:扫描版的历史标书 | V1/V2 | 复用 OCR 文字层 | 以后 |
| L3 | M1 标讯附件是扫描件 | P1/P2 | 复用 R1 | 以后 |
运营后台不看业务内容,不涉及。
## 4. 设计
### 4.1 流水线
现有的 OCR 阶段落实为(读标、查标共用 `normalize.pages`):
```
文字层(不变) → 逐页决定:
扫描页(text_source = empty) → 整页 OCR
文字页里有大图(按图片区域面积和位置) → 只 OCR 图片区域,和已有文字重叠的去掉
其他 → 跳过
→ 渲染(pypdfium2,约 200 dpi)
→ 转正:0° 识别出大量竖长框时试 90° / 270°,取「字数 × 平均置信度」最高者(初测横放报表判对)
→ 红章:先找红章位置,只对章附近的区域取红色通道当灰度图重读,和原图结果按行取优(整页用红色通道有的页变差,§7.1)
→ OCR 服务(独立容器 bid-doctor-ocr,HTTP 接口,worker 并发调用)
→ OCR 行换算回 PDF 坐标,按同样规则切句,并入文字层(text_layer v6)
→ [视觉] 页面分类、证书字段、图片类型(只对需要的页)
```
- **句子编号稳定**:页上原有文字句的编号不变,OCR 句接在后面编号(`p18s3` 之后是 OCR 的 `p18s4`…)。句子带 `source: "ocr"` 和置信度。
- `document_page.text_source` 增加 `ocr`、`mixed`,加 OCR 平均置信度;`empty` 只剩 OCR 也读不出的页。
- 阶段可续跑、幂等,和其他阶段一样;OCR 结果按页缓存(同一份规范化 PDF 不重复识别)。
- 招标文件不再因为扫描件失败;`ocr_required` 只在 OCR 服务不可用时报,按基础设施错误退回额度。
### 4.2 OCR 引擎
**初定 RapidOCR + PP-OCRv6 small 模型(onnxruntime,CPU)**:国动投标文件上 0.6 秒 / 页,合成扫描件字符差 0.1%~1.1%,坐标准(`tech-validation.md` §7.1)。不用装 PaddlePaddle,镜像小。许可证(RapidOCR Apache 2.0,模型随 PaddleOCR 发布)在做镜像时按架构 §6 的策略核实。表格截图(R4)要行列结构:先用 OCR 框的坐标聚成行列,不够再试 PP-StructureV3。
不用云 OCR 接口:私有化用不了,而且多一个处理方。
### 4.3 视觉模型接入
- **模型**:`vision` 能力用 DeepSeek Flash 不思考模式(`deepseek-chat`),和抽取同一个供应商、同一套配置(VS2、M1)。业务代码仍只声明 `capability: vision`,以后要换模型改 `BD_AI_MODELS` 就行,不改代码。
- **消息格式**:`complete_json` 加可选的图片入参(OpenAI 兼容的 `image_url`,用 base64 data URL,不给模型可访问的外链)。图片在 worker 内存里渲染,长边压到模型够用的分辨率,不另存。
- **假模型**:`fake` provider 加视觉任务的确定性输出,测试和 CI 照旧不调真实模型。
- **提示词**(带版本):`vision.page_classify`、`vision.cert_fields`、`vision.signature`、`vision.figure_kind`。输入都带 OCR 行编号,输出要引用行号(§2)。
- `ai_call` 照常记录;图片只记哈希。
### 4.4 资质库识别(Q1–Q4)
- 上传附件后发起识别任务(几秒;PDF 多页的按页分类后逐张抽)。结果存在附件上(`qualification_file.extracted`:识别值、引用的 OCR 行、模型和提示词版本),用户保存时记下最终值——识别值和人工值都留痕,同读标的 `ai_values`。
- `qualification` 加「证书主体」字段(迁移)。
- 前端:资质库新增改成「先传文件」,表单字段带识别标记,扫描件预览可高亮出处;识别不出的字段留空,不填猜测值(红线 5)。
### 4.5 查标的附件索引和签章(C3–C5)
- 附件索引:每个投标分册版本算一次,存进文字层旁边(同版本复用,和 `prepare_bid` 一样)。证明材料类清单项先查索引,找到了就有投标侧出处(页面分类引用的 OCR 标题行)。
- 证书字段(C4)复用资质库识别的提示词和校验,结果进资质匹配,出处是投标文件的页和 OCR 行。
- 签章(C5):签章位来自文字层;红色像素初筛先过一遍,只有初筛拿不准和要找手写签名的位置才调视觉模型。**电子投标的 CA 签章常在加密上传时才加**,投标人检查的那一版往往还没盖章——要用户说明「这一版盖过章了吗」,没盖过的不查签章,只进需人工核对(P2)。
### 4.6 定级和红线
- **OCR 文字当原文用,但界面标「识别文字」**:出处卡片显示 OCR 标记和置信度,高亮框画在扫描图上。
- **低置信度页上的结论降一级**(同 QL2 的思路);整页置信度太低的仍按 C10「请人工核对」。
- **OCR 读出的数字**:大小写金额、合计、超限价这几条规则在 OCR 文字上跑时,只有 OCR 和视觉模型读出的数字一致才保留原级别,否则降为提示「识别的金额可能有误,请核对」。报价仍然只读不生成(红线 6)。
- **不鉴定真伪**:证书、签章都只看「有没有、写了什么」,不去外部系统核验(红线 5、7)。
- **个人信息**:身份证复印件只分类,不抽号码;人员证书在 V1 之前只分类。
- **覆盖范围**:报告和查标页写明 OCR 了哪些页、哪些页置信度低、哪些图片没读、视觉模型没开时哪些检查没做。
### 4.7 计费
不单独计费,沿用现在的扣点(VS5):OCR 和视觉调用并入「解析 1 次 / 检查 N 点」,靠每个任务的页数上限和硬预算控制成本(同查标第五轮的硬预算);成本记进 `ai_call`,S5 实测后回填 D2 的单次成本。资质库识别不扣点,按企业限次(防刷)。额度页的扣点规则说明不用改。
### 4.8 私有化
OCR 容器随离线包交付(模型打进镜像)。客户没有 GPU 部署视觉模型时:OCR 照常,页面分类退化成按 OCR 标题关键词分类,证书字段只用 OCR + 规则抽(准确率低,全部进待确认),签章退回需人工核对。覆盖范围写明。
## 5. 分步计划
| 步 | 内容 | 验收 | 依赖 |
|---|---|---|---|
| **0 验证** | **初测已做**(2026-11-05,国动投标文件,`tech-validation.md` §7):OCR 速度、字准、转正、红章;DeepSeek Flash 页面分类、字段、交叉校验、图表概述、印章初判。**还差**:证书扫描件上的字段准确率(等用户的证书);签章有无的标注和准确率;扫描评测集(开发集投标文件做成「扫描版」,金标准沿用,留出一部分不看) | 选定 OCR 引擎(已初定);证书字段按字段统计准确率;签章按准确率定级 | 证书样本(§8) |
| **1 OCR 进文字层** | OCR 容器、`normalize` 的 OCR 阶段、text_layer v6、`document_page` 迁移、出处卡片的「识别文字」标记、覆盖范围;去掉 `ocr_required` / `bid_text_missing` 的「未上线」失败 | R1–R3、C1、C2 走通;开发集文字版不退化;扫描版开发集的查标召回和文字版差距可量化 | 0 |
| **2 资质库识别** | 模型调用的图片入参、`vision.cert_fields`、附件识别任务、证书主体字段、自动填表、多证拆分、换证合并 | Q1–Q4;字段级准确率(按字段统计,不合格的字段留空而不是填错) | 0(和 1 可以并行) |
| **3 附件索引 + 投标侧证书** | `vision.page_classify`、附件索引、证明材料按索引判断、证书字段进资质匹配、D14 主体不一致 | 国动重跑:12 条「请人工核对」里的噪音消失、有用的 4 条还在;扫描版开发集证明材料缺失(D02)召回 | 1、2 |
| **4 签章有无** | 签章位识别、红色初筛、`vision.signature`、「这一版盖过章了吗」、规则 `FORM.SIGNATURE_MISSING` | 按 S6 准确率定级(G12:不够就降为请核对) | 1、0 的 S6 |
| **5 读标里的图片** | R4 图片区域 OCR、§3.1 的重要程度分流、表格截图还原行列进抽取、R5 图纸说明和覆盖说明、扫描件上 ★ 的归一 | 构造含截图评分表、参数表的虚构招标文件,截图里的要求进清单、出处高亮在图上 | 1 |
| 以后 | R6、C6 的规则、C7、L1–L3 | 只保证复用 | — |
建议顺序 0 → 1 / 2 并行 → 3 → 4 → 5。资质库识别(2)独立、见效快,也是视觉链路的第一个真实用户场景,适合先上线。
## 6. 影响面
- 迁移:`document_page`(text_source 取值、置信度)、`qualification`(证书主体)、`qualification_file`(识别结果)、附件索引的存放(对象存储里和文字层放一起,库里只存键)。
- 接口:资质附件识别(发起 / 取结果)、文字层句子带 `source`、检查的「这一版盖过章了吗」选项;改 `openapi.yaml` 后重新生成客户端。
- 部署:Compose 加 `ocr` 服务;`.env.example` 加 `BD_OCR_URL`;视觉沿用现有的 `BD_AI_*` 配置,`BD_ENV=prod` 时的假模型检查已经覆盖。
- 文字层版本升级:已有的招标文件不自动重解析;投标分册下次检查时按 v6 重抽(和 v5 一样)。
## 7. 决定(2026-11-05 已确认)
VS2 按用户决定,VS5 用户重申不单独计费,其余按建议。
| # | 事项 | 决定 |
|---|---|---|
| VS1 | OCR 怎么部署 | 自部署 PaddleOCR 系(独立容器),SaaS 和私有化同一套;具体模型 S5 定。不用云 OCR 接口 |
| VS2 | 视觉模型 | **DeepSeek Flash**(`deepseek-chat` 不思考模式),和抽取同一家,不另加供应商(实测见 §2.1) |
| VS3 | 视觉模型的结论怎么用 | 字段必须引用同一页的 OCR 行并通过校验,对不上进待确认;没有文字依据的判断(签章、图片类型)只做提示或按 S6 定级 |
| VS4 | OCR 读出的报价数字 | OCR 和视觉读数一致才按原级别报,否则降为提示 |
| VS5 | 计费 | 不单独计费,沿用现在的扣点;按页数上限和硬预算控成本;资质库识别不扣点、按企业限次 |
| VS6 | 签章检查和 CA 签章 | 检查时让用户说明这一版是否已盖章;未盖章的不查签章,只进需人工核对 |
| VS7 | 私有化没有视觉模型 | 允许,降级运行(§4.8),覆盖范围写明 |
| VS8 | 个人信息 | 身份证复印件只分类不抽号码;人员证书 V1 前只分类 |
| VS9 | 顺序 | 0 → 1、2 并行 → 3 → 4 → 5 |
## 8. 需要准备的样本
- ~~真实扫描的招标文件~~:用户说明招标文件通常是文字稿,这次不提供;招标文件里的图片、扫描页照样按 §3.1 处理,评测用构造的虚构样例;
- 本企业的证书扫描件 10–20 张(资质库识别评测,不进 git):用户在找;
- 盖过章的投标文件 1 份(签章检测),最好有对应的未盖章版本。

View File

@@ -4,6 +4,150 @@
---
## VS1–VS13 · 2026-11-05 · 扫描件与图片识别:视觉模型用 DeepSeek Flash,其余按建议
依据 `docs/architecture/vision-plan.md` §7。
| # | 决策 | 理由 |
|---|---|---|
| VS1 | OCR 自部署 PaddleOCR 系(独立容器),SaaS 和私有化同一套,具体模型 S5 定;不用云 OCR 接口 | 私有化要离线可用,也少一个处理方 |
| VS2 | **视觉模型用 DeepSeek Flash**(`deepseek-chat` 不思考模式),和抽取同一家,不另加供应商 | 用户决定。v0.1 建议另加一家,前提是「DeepSeek 不收图片」,实测是错的:`deepseek-flash`(V4.1)收图片,虚构证书图 8 个字段全对,一张不到 500 token(计划 §2.1)。留意:合成图说明不了真实扫描件,S5 / S6 正式测;单一供应商,故障时抽取和视觉一起停 |
| VS3 | OCR 给文字和坐标、当出处;视觉模型的字段必须引用同一页的 OCR 行并通过校验,对不上进待确认;没有文字依据的判断(签章、图片类型)只做提示或按 S6 定级 | 红线 2:视觉模型给不出可靠坐标,还会「顺手改正」日期和编号 |
| VS4 | OCR 读出的报价数字,OCR 和视觉读数一致才按原级别报,否则降为提示 | 识别错一个数字就是误报的致命问题 |
| VS5 | 不单独计费,沿用现在的扣点;按页数上限和硬预算控成本;资质库识别不扣点、按企业限次 | 用户决定 |
| VS6 | 签章检查前让用户说明这一版是否已盖章;未盖章的不查签章,只进需人工核对 | 电子投标的 CA 签章常在加密上传时才加,检查的那一版往往还没盖 |
| VS7 | 私有化没有视觉模型时降级运行(OCR 照常,分类和字段退回规则、签章退回人工核对),覆盖范围写明 | 不强求客户有 GPU |
| VS8 | 身份证复印件只分类不抽号码;人员证书 V1 前只分类 | 红线 10,用不到的个人信息不抽 |
| VS9 | 顺序:0 验证 → 1 OCR 进文字层、2 资质库识别并行 → 3 附件索引和投标侧证书 → 4 签章有无 → 5 读标里的图片 | 资质库识别独立、见效快 |
| VS10 | 招标文件里的图片、扫描页、图表照样要识别理解,但按重要程度分流:都做 OCR,要求类章节里的、像表格或带要求字眼的、整页扫描的交给视觉模型,装饰图跳过,不重要的只列进覆盖范围(`vision-plan.md` §3.1)。这次不提供扫描版招标文件样本 | 用户要求(2026-11-05):招标文件通常是文字稿,但不能因为是图片就跳过,重要的一定要做 |
| VS11 | 资质库识别一张图按一张证书处理:认出几张时只填第一张并提示,不做多证拆分 | 用户决定(2026-11-05):一图多证极少,不专门兼容 |
| VS12 | 添加资质只有一个入口:对话框最上面传证书照片,识别后自动填进下面的表单;引导文字只写「上传证书照片,自动填写下面的内容」「标『识别』的请对照证书核对」 | 用户决定(2026-11-05):不区分「上传识别」和「手动添加」,文案简洁 |
| VS13 | 第 3 步做证书主体一致性(D14)时要认曾用名:名称对不上但是曾用名时,只提示附更名证明 | 用户确认国动财务报表上的「南京长卿信息技术有限公司」是曾用名,现名「南京长卿科技有限公司」 |
- **影响**:`ai` 模块的调用加图片入参,`vision` 能力不配置单独模型(`BD_AI_MODELS` 留空即用 `deepseek-chat`);架构 §1「OCR:PaddleOCR + 多模态大模型补充」的多模态模型落定为 DeepSeek Flash。
## M6-1–M6-8 · 2026-11-05 · M6 剩下的 P0:全部按建议
依据 `docs/architecture/m6-plan.md` §4。
| # | 决策 | 理由 |
|---|---|---|
| M6-1 | 删除项目立即彻底删除(销毁项目数据密钥),不做 30 天回收站;确认框要求输入项目名称;原型「30 天内可以在回收站恢复」的文案作废 | 按已确认的 G10、DM4;回收站要软删除,和「彻底删除」冲突 |
| M6-2 | 运营接口是同一代码的第三种进程 `bid-doctor ops`(单独端口、`/ops/v1`、`bd_ops` 角色),用户端 api 进程里没有运营接口 | 绕过行级安全策略的代码不会被公网请求触到 |
| M6-3 | 运营后台前端 `apps/admin`:Vue 3 + 复用 `tokens.css` 和组件样式,不引入带样式框架 | 页面少、以表格表单为主,和用户端一套组件 |
| M6-4 | 运营登录:用户名 + 密码 + TOTP + IP 白名单,账号命令行创建 | 架构 §3.11 |
| M6-5 | 套餐到期不自动降为免费版;项目只读(能看、能下载已导出的报告、能删除),额度清零;提前 30 天、7 天、当天站内提醒;续费到账后恢复 | FR-M6-05「数据保留,只读;额度清零」 |
| M6-6 | 开通申请页不写价格(D2 未定),写「运营会联系你确认金额」;收款信息从配置读,生产环境没配拒绝启动 | 定价没定;收款账户不进代码 |
| M6-7 | 运营后台看反馈不看招标 / 投标原文,只看标题、理由、规则编号、提示词版本、页码 | 红线 10 |
| M6-8 | 注销账号要输入企业名称 + 短信验证码;有没到期的专业版时写明剩余额度作废、不退款 | 不可恢复的操作,二次确认 |
| M6-9 | 运营后台菜单放左侧,分组像文件夹一样可以展开收起,每项配图标;左上角用标准横版 logo(反白) | 用户提出(2026-11-05);页面会越来越多 |
| M6-10 | 运营人员以后会分管理员、运营、销售、运维等角色:现在**只留结构**——`ops_admin.role`(不加 CHECK)、代码里的权限点和「角色 → 权限点」对应表、每个运营接口都过检查点、菜单按权限点过滤;现在只有「管理员」一种,拥有全部权限。具体有哪些角色、各自能做什么,加角色时再定 | 用户提出(2026-11-05);按「以后的功能只留结构」的做法(同 AR11、AR12) |
## E3 · 2026-11-05 · 国动真实投标文件暴露的 6 处全部修
- **决策**:国动真实投标文件跑完一次(`check-eval.md` §14)后,暴露的 6 处全部修:响应错位的复合肯定写法、附加条件的否定句、扫描件判断、不需要这家投标人回答的条目、提纲式的「方案应明确……」、同一批片段的出处核对。规则集 `2026.11-r5`。
- **理由**:用户决定。致命、重要两级报出来的都是误报,而且都是真实投标文件最常见的写法。
- **影响**:国动的正式成绩以 §14(修之前)为准,修后的数字只作参考(看过结果才修的)。真实文件仍然只有这一份,泛化要等更多真实投标文件。
## Q2(修订)· 2026-11-05 · ★ 一律按实质性条款处理
| # | 决策 | 理由 |
|---|---|---|
| Q2 | 修订原决策:文件把 ★ 定义成计分项(如 S02「加★为核心技术参数」、不满足只扣分)时,**也不再降为「重要」**,★ 一律按实质性要求;不再生成 meaning = important 的标注规则 | 用户在读标、查标走查后决定。走查时 S02 的 ★ 条款显示「重要」、负偏离只报「重要」,和用户对 ★ 的理解不一致。按实质性处理的代价是计分类 ★ 的负偏离会报成致命,用户可以「确认无误」并写说明(会作为误报反馈);降级的风险是漏掉真正导致无效的条款 |
- **影响**:`checklist/extract.py` 删掉 `star_scored` 和 `STAR_IMPORTANT`;已解析的项目要重新解析才会变。查标评测开发集里 S02 的级别分布会变(致命级多出 ★ 条款),下次跑评测时注意和旧数字不可直接比。
## AD1–AD6 · 2026-11-05 · 补遗追加:全部按建议
依据 `docs/architecture/addendum-plan.md` §7。
| # | 决策 | 理由 |
|---|---|---|
| AD1 | 补遗的新条目按招标文件同一套规则进正式清单(没出处、校验没过的进待确认区),标「补遗 N」;并入后清单回到草稿,要求重新确认 | 新旧条目都查,宁可多报,不漏报;全放待确认区的话,不逐条采纳就查不到补遗的新要求 |
| AD2 | 只对日期类条目自动标「已被补遗 N 更新」(同一种日期、时间不同);参数等条目的逐条对照是变更判断,P1 | 按 FR-M2-10;没有补遗样本,先看真实写法 |
| AD3 | 字段的新值在概览页逐个「采用 / 不采用」,日期条目在读标页逐条确认;采用投标截止时间时清单里的「投标截止」条目一起确认;采用时清掉人工值,以补遗为准 | 截止时间两处一次确认;补遗是更新的正式文件 |
| AD4 | 没确认之前查标用旧值,确认后生效;检查预览提示还有几处没确认,不阻止检查 | 数据模型原定「确认后生效」;不阻断用户(红线 4) |
| AD5 | 招标文件解析成功后才显示追加补遗的入口 | 没有招标文件时补遗的日期没有可关联的旧值 |
| AD6 | 用真实模型对虚构的更正公告样例跑一次解析,结果只记录,这次不调提示词 | 看抽取对「原为……现更正为……」写法的表现,花费几分钱 |
## PI1–PI7 · 2026-11-04 · 项目基本信息修改和分包选择:全部按建议
依据 `docs/architecture/project-info-plan.md` §8。
| # | 决策 | 理由 |
|---|---|---|
| PI1 | 做「概览」标签页,这一步只做项目要点、采购包、关键日期三张卡;检查记录、文件、AI 摘要卡留到第 6 步 | 原型就是在概览页改;只做对话框以后还得重做 |
| PI2 | 删掉最后一个包时回到「不分包」(自动建回默认包),不报 `last_lot`;另加「这个项目不分包」一键操作 | S06 把 5 个分项识别成 5 个包,要能一步改好 |
| PI3 | 删包时原属这个包的清单项归为各包通用,确认框写明条数 | 误识别的包里的条目多半本来就通用;宁可多查,不漏查 |
| PI4 | 只识别到 1 个包时按不分包处理:跳过选包页,这个包的限价照用 | S01、S08 是单包项目写了包号 |
| PI5 | 检查记下当时用的项目依据(截止时间、限价、所投的包、法律体系),报告按检查时的值写;之后改了信息,查标页提示「重新检查后生效」,不自动重新检查 | 报告首页和问题明细要一致;重新检查可能扣点,由用户决定(红线 9) |
| PI6 | 换了所投的包,清单的确认状态不变 | 清单内容没变,变的只是查哪些 |
| PI7 | 金额按元存,界面可以写「286 万」「2,860,000」,显示用万元 | 招标文件两种写法都常见 |
- **影响**:`openapi.yaml` 删包不再返回 `last_lot`,新增 `POST /projects/{id}/lots/clear`,清单列表加 `lot_scope`;检查覆盖范围加 `basis`;不加迁移。
## QL1–QL7 · 2026-11-04 · 资质库和资质匹配:全部按建议
依据 `docs/architecture/qualification-plan.md` §7。
| # | 决策 | 理由 |
|---|---|---|
| QL1 | 资质匹配用规则 + 证书类别词表,不调模型;认不出类别的不判断,写进检查覆盖范围 | 资质名称是有限集合,规则可解释、不花钱 |
| QL2 | 投标侧没有出处、只有资质库记录时也出问题,级别降一级,出处显示「依据:资质库记录」 | 资质库是用户自己录的数据,不是 AI 结论,不违反红线 2;真实投标的证书多是扫描件,不出就基本查不出过期 |
| QL3 | 「未覆盖」只在资质库有记录、但没有这一类时报,级别「重要」;资质库是空的不报,只写覆盖范围 | 用户没录不等于没有;空库报一片未覆盖是误报 |
| QL4 | 投标文字层和资质库冲突时以投标文件为准,说明里提资质库里有新证书 | 评审看的是投标文件 |
| QL5 | 免费版也能用资质库 | 不花额度、没有成本;免费版用来试用完整的查标链路 |
| QL6 | 删除资质不做撤销,只留确认框 | 撤销要软删除,和「彻底删除」(G10)冲突 |
| QL7 | D14(证书主体不一致)、D15(业绩 / 人员时间窗口)不在第 4 步做 | 不在 FR-M3-05 的三种预警里;证书主体只在扫描件上;业绩、人员库是 V1 |
- **影响**:资质库不加迁移;`Qualification` 响应加 `late_for`、列表加 `q`;规则集升到 2026.11-r4;评测规范 D14、D15 的实现阶段改为「OCR 之后 / V1」;原型 f6 的「请上传换发后的证书」不进产品(说明不写整改建议)。
## E2 · 2026-11-04 · 先修留出集暴露的 5 处,国动真实投标文件之后再跑
- **决策**:留出集跑完后(`check-eval.md` §11),先修暴露的 5 处(响应方式识别过度触发、偏离栏只认开发集写法、附加条件 / 另报价按措辞枚举、「精确到年月日」误报、PDF 异体字),国动真实投标文件用修好以后的代码跑,而不是冻结代码。
- **理由**:用户决定。真实文件晚一点提供;先修明确的缺陷,真实文件上测到的是改进后的水平。
- **影响**:国动真实投标文件不再是「冻结代码」的成绩;留出集的正式成绩以冻结代码那一次为准(修复后留出集上的数字只作参考,因为修的时候已经看过留出集的漏报)。
## C5(修订)· 2026-11-04 · 重复条目不合并判断,只合并显示
| # | 决策 | 理由 |
|---|---|---|
| C5 | 修订原决策:清单里的重复要求**每条照常单独判断**,不聚组共用一个结论;只在查标页把文字完全相同的问题折叠显示「此要求出现在 N 处」,处理一条时可以连同组一起处理 | 开发集上按相似度 0.9 估算,文字几乎相同的条目常是不同的要求:「缴纳税收材料」和「缴纳社会保障资金材料」;共用一段评分说明的几个评分项;两台服务器各写一遍的同一条参数(一台响应了不代表另一台响应了,正是「响应对象错位」要查的)。合并判断会把这些判错。代价是多花一点 token(每份约 5%~10%) |
- **影响**:`check_item_result.group_key` 不写入;问题接口按「问题类型 + 要求原文」给出同组信息,前端折叠显示。
## C12、E1 · 2026-11-04 · 响应方式按建议处理;评测集挂问题的要求由另一个模型复核、用户抽查 20%
| # | 决策 | 理由 |
|---|---|---|
| C12 | 按 `m3-plan.md` §7 C12 的建议:读标新增「响应方式」抽取(类别 × 方式 × 出处,规则优先、模型兜底,条款写了证明材料的按条覆盖并记投标时 / 中标后,清单页显示、可改);两处写法冲突按逐条处理并引两处原文;默认接受的一般要求没找到响应不出问题、结论注明「按招标文件默认接受」并带出处,实质性 / ★ 出提示级「请人工核对」;中标后才交的证明材料投标时不查;识别不出按逐条处理,标明「招标文件未写明是否要求逐条响应」,项目上给「本项目只列偏离」开关 | 10 份样本里响应方式按类别不同、同一份文件常有几种,S07 自相矛盾(`evals/check/GUIDELINES.md` §2);不区分会在默认接受的文件上大量误报致命问题 |
| E1 | 评测集里挂了问题的要求(核对要求本身和响应方式),由另一个模型(Fable)复核,用户抽查 20%;不由用户逐条复核 | 节省用户时间。代价:读标阶段已经证明 AI 复核偏宽,评测结论要注明「金标准未经全部人工复核」;复核模型和写投标文件的模型(Opus)、查标模型(DeepSeek)都不同 |
- **影响**:读标增加一项抽取(查标第 2 步做,和投标侧结构识别一起);`check_item_result.verdict` 要加「默认接受」;评测报告的头条数字都带「金标准由模型复核、用户抽查 20%」的说明。
## C1–C11、SK1–SK2 · 2026-11-04 · 查标(M3)实现计划的待定事项:全部按建议,C1 补充真实样本
依据 `docs/architecture/m3-plan.md` v0.2 §7。v0.2 是请 Fable 做顾问,深读参考项目的查标实现(`ref-bid-engine-analysis.md` §6),对照读标优化的教训重新评估后的结果。
| # | 决策 | 理由 |
|---|---|---|
| C1 | 评测集按 `m3-plan.md` §4 自己写:用招标文件自己的格式章写虚构投标文件,同时写「响应清单」作为金标准,按招标文件整体留出,人工只裁定开发集上响应清单漏记的问题。**补充**:用户有一份真实的国动小程序投标文件,作为留出集里唯一的真实样本,第 3 步冻结代码后再要来跑一次,之前不看、不按它调 | 读标的三个坑(AI 标注偏宽、评分照搬规则、规则照样本调)分别由响应清单、只对照响应清单打分、多写法 + 留出来堵。真实样本只有一份,用来检验泛化最有价值 |
| C2 | 不先定元数。按 token 设计预算;按投标文件章节打批,同一段正文只送一次;每次检查有硬预算,超出没查的条目写进覆盖范围;24 小时内复查时按「清单项内容哈希 + 分册版本哈希 + 提示词版本」复用结果。第 3 步实测后再定元数 | 参考项目每个项目只判约 90 条,我们 216~1348 条,量级不能照搬;可见的覆盖范围比一个上限数字更诚实 |
| C3 | 模型说已响应,但证据没通过校验:不判已响应,记「未找到响应」,级别不降,记原因码,界面可筛;致命项允许重问一次(只给模型指的那一页);这类问题占致命问题的比例单列指标,目标 < 10% | 红线 2;原因码和单列指标来自读标待确认区噪音的教训 |
| C4 | 签章只提示位置和有无。主要检查格式文件里该填的地方(投标人名称、签字人姓名、落款日期)有没有填;「(盖单位章)」这类括注不算问题;图像检测只看签章位置附近有没有图像,结论只到「提示」;Word 稿和 PDF 稿都走规范化 PDF,同一套检查 | 电子签章常在投标平台里加盖,稿子里本来没有章;括注盖章后通常仍保留 |
| C5 | 重复条目在检查时分组:同类别、同一个包、保守相似度;问题挂在组里编号最小的清单项上,其他成员的出处附上;复查对齐时组内任一成员对上就算同一个问题;分组记在检查快照里。读标阶段合并以后再做 | 读标同一要求常抽成三条,直接查会出三个一样的问题 |
| C6 | 正式清单全查;待确认区、不在所投包、超出预算的条目写进覆盖范围 | 「全部都查,或者明确说没查哪些」 |
| C7 | 补 `extract.price`,规则优先,模型只做结构化;允许手工新增「报价」类条目;不卡住总价和采购包两层 | 10 份里只有 S06、S10 两份有分项限价或单价上限 |
| C8 | 分册类型:整本(默认)/ 资格证明 / 商务 / 技术 / 报价 / 其他,加自填名称;类型只用来排序检索候选,不限制范围;电子投标平台加密格式维护一张识别表 | 很多投标人一个文件装全部;用户常标错类型 |
| C9 | 新增 `check_item_result`(检查批次 × 清单项的结论、所走的层、投标侧出处、校验状态、原因码、token);问题仍只为没响应好的条目生成 | 「已响应」也是 AI 结论,按红线 1、2 要能复核、能反馈;复用结果和覆盖范围也靠它 |
| C10 | 扫描件:候选分册里图片页占比超过阈值的条目不出「未找到响应」,改出「请人工核对」;资格类按名称回到资质库匹配;覆盖范围列出没有文字层的页码。OCR(S5)排在 M3 之后 | 真实投标的资格证明多是扫描件,不处理会出一大片致命级「未找到」 |
| C11 | 检索用内存 BM25 + jieba,不用 PostgreSQL 的 pg_trgm / 全文检索 | 句子不进库(DM1);中文全文检索要第三方扩展;内存索引便于测试和评测 |
| SK1 | 确认:进度推送用 Valkey stream。补:发布用的客户端设短超时,SSE 连接池设上限 | 现在 `valkey()` 没设超时,Valkey 挂掉时会卡住 worker |
| SK2 | 确认:集成测试连 Compose 起的 PostgreSQL。补:CI 直接跑 `init-roles.sh`、加 Gotenberg;架构 §8 同步 | CI 里的建角色 SQL 和部署脚本是两份,会漂移;投标样例有 Word |
- **影响**:`m3-plan.md` 按此执行;`data-model.md` 加 `check_item_result`(迁移在第 1 步);架构 §8、§13 同步;`ref-bid-engine-analysis.md` §5.4 的检索写法已改。
## Q1–Q4 · 2026-11-03 · 实质性条款的四个口径:全部按建议
依据 `docs/architecture/extract-eval.md` §5(S01–S10 标注时遇到、规范里没定的情形)。

View File

@@ -207,7 +207,7 @@
┌──────┬────────────────────────────────────────────────────┐
│ 侧栏 │ 顶栏(透明):公司 / 页面 搜索 │ 额度 · 通知 │
│ 96px ├────────────────────────────────────────────────────┤
│ logo │╭ 内容区(浅灰底,左上角 12px 圆角压在侧栏深色上) │
│ logo ││ 内容区(浅灰底,四角直角,直接贴着侧栏) │
│ 菜单 ││ │
│ … ││ 页面内容(三种主布局见下) │
│ 头像 ││ │
@@ -229,7 +229,7 @@
- 顶栏不用深色,避免和侧栏连成一个「门」字形边框。
**内容区**:
- 浅灰底 `--bg-page`,左上角 12px 圆角,压在侧栏的深色上。
- 浅灰底 `--bg-page`,四角都是直角,直接贴着侧栏(2026-11-05 改:原为左上角 12px 圆角,只有左上角圆、左下角是直角,看着不统一)。
- 页面左右内边距 32px,1400px 以下为 24px。
| 布局 | 页面 | 结构 |
@@ -517,6 +517,16 @@
- 日期输入框:可以直接键入,接受「2026/11/20 9:30」「2026.11.20 9:30」「2026年11月20日」等常见写法,离开输入框时整理成「2026-11-20 09:30」;下方小字显示界面写法「11月20日 09:30 · 剩 17 天」(v0.2)。
- 带时间的字段(如投标截止):日期选择器底部有时间输入框,24 小时制;输入框格式见 §9.3。
### 7.23 大图浏览(2026-11-05)
证书扫描件、附件图片在页面里看,**不开新标签页**。
- 深色蒙层(墨色 96%)铺满窗口,图片居中,打开时整张适应窗口;顶栏左边「文件名 · 第几张 / 共几张」,右边缩小、当前比例、放大、适应窗口、关闭。
- 滚轮按光标位置缩放,双击在适应窗口和 2 倍之间切换;放大后可以拖动;最多放大到原图的 6 倍。
- 多张图时两侧有上一张、下一张;PDF 附件按页出预览图(最多前 10 页),和图片一样浏览。
- 键盘:Esc 关闭(从对话框里打开时只关大图),+ / − 缩放,0 适应窗口,← / → 切换。
- 实现:`apps/web/src/components/ImageViewer.vue`(Reka UI 对话框打底:焦点锁定、焦点回到触发处)。
### 8.1 AI 产出 → 人工确认 → 留痕

View File

@@ -81,7 +81,7 @@ table.data.keys td:first-child{width:1%;padding-right:var(--sp-6)} /* 快捷
.ic.xl{width:32px;height:32px;stroke-width:1.25}
.vh{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
/* ---- B2 页面框架 v0.2(§5.3):96px 墨色渐变侧栏 + 透明顶栏 + 内容区左上角圆角 ----
/* ---- B2 页面框架 v0.2(§5.3):96px 墨色渐变侧栏 + 透明顶栏;内容区四角直角(2026-11-05 改) ----
* 结构由 demo.js 的 Demo.shell() 生成:body.app > aside.sidebar + main.main > header.top + 页面内容 */
body.app{display:grid;grid-template-columns:var(--rail-w) minmax(0,1fr);min-height:100vh;min-width:1280px;background:linear-gradient(180deg,var(--side-top) 0%,var(--side-bottom) 100%) fixed}
.sidebar{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;padding:0 0 var(--sp-4) 10px;color:var(--side-fg);min-width:0}
@@ -105,8 +105,8 @@ body.app{display:grid;grid-template-columns:var(--rail-w) minmax(0,1fr);min-heig
.plan[data-plan="pro"]{background:var(--plan-pro-bg);color:var(--plan-pro-fg);box-shadow:inset 0 0 0 1px var(--plan-pro-line)}
.plan[data-plan="ent"]{background:var(--plan-ent-bg);color:var(--c-ink)}
.main{min-width:0;display:flex;flex-direction:column;min-height:100vh;background:var(--bg-page);border-top-left-radius:var(--radius-card)}
.top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--sp-3);height:var(--topbar-h);padding:0 var(--pad-page);background:transparent;border-bottom:var(--line) solid transparent;border-top-left-radius:var(--radius-card);transition:background-color var(--dur-base) var(--ease),border-color var(--dur-base) var(--ease)}
.main{min-width:0;display:flex;flex-direction:column;min-height:100vh;background:var(--bg-page)}
.top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--sp-3);height:var(--topbar-h);padding:0 var(--pad-page);background:transparent;border-bottom:var(--line) solid transparent;transition:background-color var(--dur-base) var(--ease),border-color var(--dur-base) var(--ease)}
.top.scrolled{background:var(--topbar-glass);-webkit-backdrop-filter:blur(14px) saturate(1.2);backdrop-filter:blur(14px) saturate(1.2);border-bottom-color:var(--border-subtle)}
/* 左侧「公司 / 页面」 */
.crumbs{display:flex;align-items:center;gap:6px;min-width:0;color:var(--text-secondary);white-space:nowrap;overflow:hidden}

View File

@@ -9,7 +9,7 @@
* v0.1 的 216px 侧栏、校样纸底色、2px 圆角已删除。 */
/* =====================================================================
* 1. 页面框架 v0.2(§5.3):96px 墨色渐变侧栏 + 透明顶栏 + 内容区左上角圆角
* 1. 页面框架 v0.2(§5.3):96px 墨色渐变侧栏 + 透明顶栏;内容区四角直角(2026-11-05 改)
* 结构:body.app > aside.sidebar + main.main > header.top + [header.ph 项目头] + 页面内容
* ===================================================================== */
body.app{display:grid;grid-template-columns:var(--rail-w) minmax(0,1fr);min-height:100vh;min-width:1280px;background:linear-gradient(180deg,var(--side-top) 0%,var(--side-bottom) 100%) fixed}
@@ -34,8 +34,8 @@ body.app{display:grid;grid-template-columns:var(--rail-w) minmax(0,1fr);min-heig
.plan[data-plan="pro"]{background:var(--plan-pro-bg);color:var(--plan-pro-fg);box-shadow:inset 0 0 0 1px var(--plan-pro-line)}
.plan[data-plan="ent"]{background:var(--plan-ent-bg);color:var(--c-ink)}
.main{min-width:0;display:flex;flex-direction:column;min-height:100vh;background:var(--bg-page);border-top-left-radius:var(--radius-card)}
.top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--sp-3);height:var(--topbar-h);padding:0 var(--pad-page);background:transparent;border-bottom:var(--line) solid transparent;border-top-left-radius:var(--radius-card);transition:background-color var(--dur-base) var(--ease),border-color var(--dur-base) var(--ease)}
.main{min-width:0;display:flex;flex-direction:column;min-height:100vh;background:var(--bg-page)}
.top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--sp-3);height:var(--topbar-h);padding:0 var(--pad-page);background:transparent;border-bottom:var(--line) solid transparent;transition:background-color var(--dur-base) var(--ease),border-color var(--dur-base) var(--ease)}
.top.scrolled{background:var(--topbar-glass);-webkit-backdrop-filter:blur(14px) saturate(1.2);backdrop-filter:blur(14px) saturate(1.2);border-bottom-color:var(--border-subtle)}
/* 左侧「公司 / 页面」 */
.crumbs{display:flex;align-items:center;gap:6px;min-width:0;color:var(--text-secondary);white-space:nowrap;overflow:hidden}

View File

@@ -210,7 +210,7 @@ function act(k) {
else if (k === "unarchive") { setStatus(before === "已归档" ? "已检查" : before); toast("已撤销归档", { ok: false }); }
else if (k === "delete") dialog({
title: `删除项目「${esc(D.project.name)}」?`,
body: `<p>项目下的招标文件、投标文件、清单、检查结果和报告都会删除。删除后 30 天内可以在回收站恢复,之后彻底清除。</p>
body: `<p>项目下的招标文件、投标文件、清单、检查结果和报告都会彻底删除,不能恢复。需要留底的报告请先下载。</p>
<p class="cap muted">额度流水和一条不含业务内容的审计记录会保留。</p>
<div class="field" data-field><label for="dn">输入项目名称确认</label><input class="input" id="dn" placeholder="${esc(D.project.name)}" autocomplete="off"></div>`,
ok: "删除项目",
@@ -228,7 +228,7 @@ $("[data-state-acts]").addEventListener("click", (e) => {
if (b.matches("[data-more]")) {
e.stopPropagation();
const items = status === "已归档" ? [{ label: "删除项目", act: "delete" }]
: [{ label: "归档", sub: "不再出现在工作台的「进行中」里,可以撤销", act: "archive" }, "-", { label: "删除项目", sub: "30 天内可以在回收站恢复", act: "delete" }];
: [{ label: "归档", sub: "不再出现在工作台的「进行中」里,可以撤销", act: "archive" }, "-", { label: "删除项目", sub: "彻底删除,不能恢复(决策 M6-1)", act: "delete" }];
App.floatMenu(b, items, (k) => act(k));
}
});

View File

@@ -22,3 +22,11 @@ cd .. && evals/run_all.sh base && cd server
```
改了规则或提示词之后,换一个运行名重跑、打分,和上一次的 `summary.md` 对比。目前的基线是 `fix3`(`docs/architecture/extract-eval.md` §7;分数怎么读、为什么不能当达标证据,见 `extract-status.md` §2);`base` 是修复前的那一轮,用的是旧切句(s3-v3),不能拿现在的标注集重新打分。正式清单里多出来、对不上标注的条目要逐条裁定(GUIDELINES §4)。
## 查标评测集(2026-11-04 起)
规范见 `check/GUIDELINES.md`,留出集划分 `check/split.json`(开工前选定,不改)。
- `check/render.py <投标文件>`:把投标文件稿 `out/check/<投标文件>/spec.json` 渲染成 PDF,扫描件页换成图片,回填响应清单里的位置。
- `check/run_check.py <投标文件>`:拿读标评测的清单离线跑查标的响应匹配,模型输出缓存在 `out/cache/`。
- 生成器 `build.py`、注入 `mutate.py`、评分 `score.py` 待写。投标文件稿、PDF、响应清单都在 `out/check/`(不进 git);留出集专用的注入词库和 C 风格写作说明封存在 `out/check/_sealed/`。

247
evals/check/GUIDELINES.md Normal file
View File

@@ -0,0 +1,247 @@
# 查标评测集规范 v0.2(2026-11-04)
> 查标第 0 步(`docs/architecture/m3-plan.md` §4,决策 C1)。本文件在写第一条查标规则之前定稿:响应方式、问题分类表、投标文件怎么写、响应清单格式、留出集、评分和裁定规则。
> 读标的教训(`docs/architecture/extract-status.md`)在这里分别这样堵:AI 标注偏宽 → **响应清单在构造时就确定,挂问题的要求经人工复核**;评分照搬抽取规则 → **只对照响应清单打分,另报只检索基线**;规则照着样本调 → **按招标文件整体留出,留出一种写法和一套注入词库,开工时封存**。
> v0.2:请 Fable 审查 v0.1 后修改(响应方式按「文件 × 类别」记、规矩投标文件靠构造保证、注入分粒度、评分补三样口径、分类表补漏项),关键事实对照招标原文核对过。
## 1. 总体做法
1. 没有真实投标文件(真实的国动投标文件只有一份,留作最终检验,见 §4),所以按 S01–S10 的真实招标文件**自己造虚构的投标文件**。
2. **规矩的投标文件靠构造保证,不靠复核**:偏离表、响应表、报价表、资格和评分材料清单由脚本从清单生成,技术参数按 `op / value / unit` 算一个满足的值;LLM 只写散文(技术方案、投标函叙述)和按风格改写表格行(截断、同义替换、重新编号),逐行小提示,不整本生成。
3. 投标文件对招标文件的**全部要求**响应:「全集」用读标正式清单(`evals/out/runs/fix3/<ID>/items.json`),按「招标侧页码 + 片段」记,不只响应标注集的单元。
4. 再用确定性的脚本往里**注入问题**得到马虎的投标文件。注入了什么、注在哪里,脚本同时写进**响应清单**,它就是金标准。**问题只挂在复核过的标注单元上**(`evals/gold/<ID>.json`,优先实质性条款和资格条件;复核方式见决策 E1)。`optional` 和复核时补的单元也可以挂,按内容复核(四批复核和用户抽查都是这样做的);**上一次复核判为不成立的单元不再挂问题**。
5. 评分只对照响应清单,不用检索、偏离表对齐这些查标自己的逻辑来判断对错。
## 2. 每条要求怎么才算「响应了」:响应方式
招标文件对投标人怎么响应的规定**按类别不同**,同一份文件里常有几种(S01–S10 逐份核对):
| 方式 | 含义 | 招标原文的写法 |
|---|---|---|
| `explicit` 逐条响应 | 在响应表 / 偏离表里逐条写明满足与否 | 「逐条 / 逐项作出响应」「逐一逐条响应填写」 |
| `implicit` 默认接受 | 只列和要求不同的地方,没列出的视为接受 | 「否则将认为投标人接受」「未列入上表的视作完全接受」「除本表所列明的偏离外,均视作已理解和响应」「其余无偏离内容无需赘述」 |
| `evidence` 要证明材料 | 要附证明材料(检测报告、截图、承诺函、证书),有的还要在材料里标出参数 | 「提供……证明材料」;`when`:`bid`(投标文件中提供)或 `award`(中标后、签订合同时提供,投标时只要承诺或什么都不要) |
| `presence` 文件要在 | 不是逐条响应,而是某份文件必须有、必须填全 | 「必须提供,否则投标无效」的格式文件、资格证明文件、声明函 |
| `platform` 平台内应答 | 在电子投标客户端里应答,PDF 里看不到 | S04「在电子投标系统逐条应答」 |
**10 份文件的记录**(类别 × 方式;单条原文另写了证明材料的,按条覆盖):
| 文件 | 技术参数 | 实质性 / ★ | 商务 | 资格 | 评分项 | 备注 |
|---|---|---|---|---|---|---|
| S01 | explicit | explicit + evidence | explicit(商务要求均为实质性) | presence | evidence | 「中标后签订合同前提供」11 处(`award`);投标时要提供的有第三方检测报告 12 处、承诺函 5 处(`bid`)。须知(p13)又写证明材料「在投标文件中提供」,和逐条的写法冲突,按逐条的写法 |
| S02 | implicit | 符合性条件:explicit(符合性条件响应表);部分 ★:evidence | implicit | presence | evidence | 偏离表只列「除采购需求符合性条件外」的偏离 |
| S03 | implicit(以偏离表为准) | ★ 14 条:evidence(要标注参数)+ 实质性响应条款表 | implicit | presence | evidence | ▲ 31 条也要 evidence(级别重要) |
| S04 | explicit(全部逐一逐条) | ★:explicit(实质性响应偏离表)+ evidence | explicit | presence | evidence | 另有平台内逐条应答(platform) |
| S05 | implicit(只列正偏离) | 无标注;符合性证明文件第 1~5 项:presence | implicit | presence(资格证明文件第 1~6 项必须提供;专门面向中小企业,中小企业声明函也必须) | evidence | 偏离表几乎为空是正常的 |
| S06 | explicit | explicit(斜体下划线逐条) | explicit | presence | evidence | ▲ 要 evidence,未提供视为负偏离;格式写「(如有请提供)」和须知矛盾 |
| S07 | **冲突** | ★ 国产化承诺:evidence(承诺函) | **冲突** | evidence(逐一提供证明文件) | evidence | 须知「逐条标明满足与否」vs 格式注「未列入上表的视作完全接受」 |
| S08 | explicit(非实质性也逐条) | explicit + evidence | explicit | presence | ▲ 7 项:evidence | |
| S09 | explicit | explicit | explicit(实质性) | presence | evidence | 投标时要的证明材料很少 |
| S10 | explicit | explicit | explicit | presence | evidence(人员证书 + 社保) | 技术证明材料「签订合同时提供」(`award`) |
响应清单给每条要求记下方式和招标原文依据。**问题只在这种方式要求了、投标文件却没做到时才算**:`implicit` 的要求没被复述不是问题;`evidence` + `award` 的要求投标时没附材料不是问题。
> 查标怎么处理响应方式是待定事项 C12(`m3-plan.md` §7)。评测集按本节记录,C12 怎么定都能评。
## 3. 问题分类表
注入的问题分两类:**要求项问题**挂在某条要求上;**规则问题**不挂要求(或挂报价类要求)。另有**干扰项**:看起来像问题、其实不是,查标不应该报。
### 3.1 要求项问题
| 编号 | 问题 | 期望的结论 / 问题类型 | 期望级别 | 变体 | 实现阶段 |
|---|---|---|---|---|---|
| D01 | 漏响应(要求 explicit,整条没写) | `unresponded` | 按要求项 | 删一行;删连续一段;整节缺失 | 第 1、3 步 |
| D02 | 投标时要的证明材料缺失(evidence + bid) | `unresponded`(证明材料) | 按要求项 | 写「见附件 5」但附件不存在;根本没提。「附件在但没标出参数」单列,不进头条指标 | 第 3 步 |
| D03 | 数值不满足 | `suspected_unmet` | 按要求项 | 低于下限;高于上限;数量不够;单位不同换算后不满足(≥2TB 写 1024GB、≤50ms 写 0.1 秒、≥10Gbps 写 1000Mbps);偏离列写「正偏离」值却更差 | 第 3 步 |
| D05 | 功能写明不支持 | `negative_deviation` | 按要求项 | 「暂不支持」;「需二次开发后支持」;「可选配」(`negative_deviation` 或 `no_value` 都算对) | 第 3 步 |
| D06 | 负偏离没有说明 | `negative_deviation` | 按要求项 | 说明栏空;「部分满足」;只写「偏离」不写方向 | 第 3 步 |
| D07 | 偏离表写无偏离,正文的值不满足 | `suspected_unmet` | 按要求项 | 技术方案正文的值;配置清单的值;彩页摘录的值。MVP 可能不查(m3-plan §2.1),评分单列 | 第 3 步 |
| D08 | 只写「满足」,没给要的具体值或材料 | `no_value` | 提示 | 单条;整张表都写「满足」 | 第 3 步 |
| D09 | 响应对象错位 | `unresponded` | 按要求项 | 写的是另一台设备的同名参数;另一个采购包;整章是另一个型号 | 第 3 步 |
| D10 | 符合性条件响应表填「未完全响应」或空着 | `negative_deviation` | 致命 | 空着;「未完全响应」;「部分响应」 | 第 3 步 |
| D11 | 资格证明材料缺失 | `unresponded` | 致命 | 目录列了、正文没有;整份缺失;给的是别的证书 | 第 3、4 步 |
| D12 | 资格证书过期(按投标截止日) | `qualification`(过期) | 资格致命、评分重要 | 早于截止日;整本证书过期 | 第 4 步(`qual_defects.py` 派生,规则问题口径评分) |
| D13 | 评分项要的证明材料缺失 | `unresponded` | 重要 | 业绩合同缺;人员证书缺;社保缺 | 第 3 步 |
| D14 | 证书主体和投标人不一致 | `qualification` | 致命 | 子公司;分公司;曾用名 | OCR 之后(决策 QL7) |
| D15 | 业绩 / 人员证明的时间窗口不符 | `qualification` / `unresponded` | 资格致命、评分重要 | 业绩合同在近三年之外;社保月份不在要求区间 | V1(业绩、人员库,决策 QL7) |
| D16 | 授权代表的社保证明缺失(文件要求时) | `unresponded` | 致命 | 没附;附的是别人的;月份不对 | 第 3 步 |
| D18 | 资质证书等级低于要求 | `qualification`(等级不足) | 按要求项 | 低一级;证书编号里的等级和等级栏不一致 | 第 4 步 |
| D17 | 多包:采购包号空着或写错 | `formality` | 重要 | 偏离表包号空;授权书包号写错;分项表包号写错 | 第 2 步 |
(v0.1 的 D04「换算后不满足」并入 D03 当变体。)
### 3.2 规则问题
| 编号 | 问题 | 期望的规则编号 | 期望级别 | 变体 | 实现阶段 |
|---|---|---|---|---|---|
| P01 | 报价超过总价限价 | `PRICE.OVER_CEILING_TOTAL` | 致命 | 超 1 元;超 1%;万元 / 元的数量级错 | 第 2 步 |
| P02 | 报价超过采购包限价 | `PRICE.OVER_CEILING_LOT` | 致命 | 同上(S10) | 第 2 步 |
| P03 | 分项或单价超上限 | `PRICE.OVER_CEILING_ITEM` | 致命 | 单项超;单价 × 数量超 | 第 2 步(依赖 C7) |
| P04 | 大写和小写金额不一致 | `PRICE.CAPITAL_MISMATCH` | 重要 | 差一位;大写多一个「拾」;小数点错 | 第 2 步 |
| P05 | 开标一览表和分项合计不一致 | `PRICE.SUM_MISMATCH` | 重要 | 漏加一项;单价 × 数量算错;四舍五入差 | 第 2 步 |
| P06 | 出现别的项目名称、编号或采购人 | `FORM.FOREIGN_PROJECT` | 重要 | 页眉留旧项目名;授权书写旧项目编号;投标函写错采购人 | 第 2 步 |
| P07 | 该填的地方没填 | `FORM.BLANK_FIELD` | 提示 | 投标人名称空;落款日期「年 月 日」;授权书受托人空 | 第 2 步 |
| P08 | 落款日期晚于投标截止日 | `FORM.DATE_AFTER_DEADLINE` | 提示 | 晚一天;年份错;月份错 | 第 2 步 |
| P09 | 投标有效期短于要求 | `FORM.VALIDITY_SHORT` | 致命 | 90 天写 60 天;写「30 日」 | 第 2 步 |
| P10 | 附加条件 | `FORM.CONDITION_ADDED` | 重要 | 「报价不含税」;「付款方式另议」;「以实际到货为准」 | 第 2 步 |
| P11 | 缺必须提供的格式文件(presence) | `FORM.MISSING_DOC` | 致命 | 缺授权委托书;缺投标函;缺符合性条件响应表 | 第 2 步 |
| P12 | 给了两个报价 | `PRICE.MULTIPLE_QUOTES` | 致命 | 两行总价;「方案一 / 方案二」;「优惠价」 | 第 2 步 |
| P13 | 分项报价表缺项、漏项,和采购需求清单对不上 | `PRICE.ITEM_MISSING` | 致命 | 漏一项;名称对不上;数量对不上 | 第 2 步 |
| P14 | 格式文件的固定文字被改动 | `FORM.FIXED_TEXT_CHANGED` | 重要 | 删承诺句;改有效期措辞;改主体 | 第 2 步 |
| P15 | 授权委托书的受托人和投标函签字人不一致 | `FORM.SIGNER_MISMATCH` | 致命 | 姓名不同;职务不同;身份证号不同 | 第 2 步 |
| P16 | 声明函填报不全 | `FORM.DECLARATION_INCOMPLETE` | 致命(本国产品声明函三项缺一;专门面向中小企业时的中小企业声明函)/ 提示(其他) | 缺一项;未逐一填报;类型选错 | 第 2 步 |
| P17 | 签章位置没有图像 | `FORM.SEAL_ABSENT` | 提示 | 投标函;授权书;开标一览表 | 第 2 步 |
| P18 | 报价低于文件写明的阈值却没附说明 | `PRICE.LOW_NO_EXPLANATION` | 重要 | 低于阈值没说明;说明不在投标文件里 | 第 2 步 |
### 3.3 干扰项(不应报问题)
| 编号 | 情形 | 期望 |
|---|---|---|
| T01 | 响应写在别的分册 | 已响应 |
| T02 | 要求被截断、改写、重新编号后再响应 | 已响应 |
| T03 | 只写值不复述要求(「内存:64GB DDR4」) | 已响应 |
| T04 | 单位不同但满足(≥1024GB 写 2TB) | 已响应 |
| T05 | 正偏离 | 已响应 |
| T06 | `implicit` 的要求,投标文件没有复述 | 不报问题(按 C12 的结论) |
| T07 | 正文里出现**本项目**的名称、编号、采购人 | 不报 P06 |
| T08 | 「(盖单位章)」「(签字)」括注保留、名称和日期已填 | 不报 P07 |
| T09 | 条款写「中标后 / 签订合同时提供」,投标文件只写承诺或没写 | 不报 D02 |
| T10 | 声明函里「/」划除处未填(文件写明无需填写) | 不报 P07 |
| T11 | 评分项的可选加分没响应 | 不报致命,最多提示 |
| T12 | `implicit` 文件的偏离表只有一行「无偏离」 | 不报 D01、不报缺偏离表 |
| T13 | 正文写「见附件 N」且附件存在 | 不报 D02 |
### 3.4 注入的分布
真实投标人的错是成簇、相关、不声张的,均匀分布的单点问题会让分数虚高。所以:
- **三种粒度**:单点;连续块(相邻 5~20 行一起漏、一起错);全局模式(整张表写「满足」、整章换成另一个项目或型号、整本证书过期)。响应清单记 `granularity`。
- **有一部分不带标志词**:没有「暂不支持」这类字样,只是合理位置上的错数字、错单位、错型号。响应清单记 `worded: false`,按带不带标志词分别报召回。
- **两档密度**:多数马虎的投标文件注入 25~40 个(覆盖各类);另选 3 份只注入 5~8 个(接近真实密度),看低密度下误报会不会淹没真问题。
- 注入词库分**开发集用**和**留出集用**两份(§4)。
## 4. 样本和留出集(开工前选定,之后不改)
| 集合 | 招标文件 | 选它的理由 |
|---|---|---|
| 开发集 | S01、S02、S04、S05、S07、S08、S10 | 逐条响应(S01、S08、S10)、默认接受(S02、S05)、全逐条 + ★ 双表 + 证明材料(S04)、冲突(S07)、中标后提供(S01、S10)、多包和单价上限(S10)、磋商(S08)、WPS 旧版 DOC(S07) |
| 留出集 | S03、S06、S09 | S03:★ / ▲ 证明材料、一般参数默认接受;S06:分项最高限价、只用斜体下划线、清单最大;S09:磋商、★ 和斜体下划线双重标注、WPS 旧版 DOC。留出集里没有 S05 那种「只列正偏离」的写法,报告里写明 |
| 留出集(真实) | 国动小程序投标文件(用户提供) | 唯一的真实投标文件,第 3 步冻结代码后要来跑一次,之前不看、不按它调。它对应的招标文件不在 S01–S10 里,届时单独走一遍读标、人工看过清单再评 |
**写法风格**:
| 风格 | 写法 | 用在 |
|---|---|---|
| A 复述型 | 响应表、偏离表照抄要求原文,再写响应值和「无偏离」(苏采云范本的常见填法) | 开发集 |
| B 简写型 | 要求被截断、改写、重新编号;技术方案正文只写值不复述要求;部分响应写在别的分册 | 开发集、留出集 |
| C 叙述型 | 以叙述性技术方案为主,表格少;自己的条目编号;同义词替换 | **只用在留出集** |
**封存**:留出集专用的注入词库和 C 风格写作说明,在开工时由另一个会话写好,放进 `evals/out/check/_sealed/`,之后不打开;写它的会话不接触查标规则代码。写 C 风格的会话不看 A / B 的产出。`check.match` 提示词的例子和注入词库做文本比对,不允许出现同样的短语。写作说明里的例子不取自 S01–S10 的招标文件和开发集投标文件。
每份招标文件写两份投标文件:开发集是 A 规矩 + B 马虎(按文件轮换);留出集是 B 规矩 + C 马虎。一共 20 份。**先做 S02、S04、S10 这 6 份**,端到端跑通「生成 → 渲染 → 离线查标 → 评分」,修好生成器和评分脚本,再做其余开发集,留出集最后做。
## 5. 投标文件怎么生成
1. **格式用招标文件自己的**:投标函、开标一览表、分项报价表、偏离表、响应表、授权委托书、各种声明函,用这份招标文件格式章给的格式和表头。
2. **生成流程**:`build.py` 从读标正式清单和格式章生成规矩投标文件的结构化稿(`spec.json`)→ LLM 按风格改写表格行、补写散文 → `mutate.py` 注入问题、写响应清单 → `render.py` 渲染并回填位置。
3. **渲染**:多数走 Chromium(HTML → PDF);**至少 2~3 份走 DOCX → LibreOffice**(产品的真实路径),带合并单元格、多行单元格。资格证明、业绩合同中有一部分做成图片页,检验 C10。
4. **自检**:冻结前用另一个模型跑一遍「找无意的偏离」,脚本扫一遍占位符和空表格,再由人抽查。
5. **复核**(决策 E1):只复核**挂了问题的标注单元**(每份马虎的投标文件 25~40 个,20 份约 600~800 个)和各文件的响应方式记录,`ok` 的单元不复核。由另一个模型(Fable,和写投标文件的 Opus、查标的 DeepSeek 都不同)逐条复核,用户随机抽查 20%;抽查不一致的比例写进评测报告。所有评测结论都注明「金标准由模型复核、用户抽查 20%」。
## 6. 文件格式
### 6.1 投标文件稿 `spec.json`
```json
{
"bid": "S02-A",
"tender": "S02",
"style": "A",
"project_name": "……",
"bidder": {"name": "某某信息技术有限公司(虚构)", "credit_code": "91320000MA0000000X"},
"volumes": [
{
"id": "v1",
"kind": "whole",
"header": null,
"blocks": [
{"type": "heading", "text": "一、投标函"},
{"type": "para", "text": "……", "responds": []},
{"type": "table", "title": "偏离表", "header": ["序号", "招标文件的要求", "投标文件中的内容", "偏离", "说明"],
"rows": [{"cells": ["1", "……", "……", "无偏离", ""], "responds": ["s2"], "req": {"p": 53, "t": "支持硬件BFD"}}]},
{"type": "scan", "text": "营业执照(副本)扫描件", "lines": ["……"], "responds": ["q3"]}
]
}
],
"price": {"total": "1498000.00", "items": [{"name": "汇聚交换机", "qty": 3, "unit_price": "52000.00"}]}
}
```
`responds` 写标注单元编号(有的话);`req` 写招标侧「页码 + 片段」(全部响应块都写,不在标注集里的要求也有)。
### 6.2 响应清单 `manifest.json`(金标准)
```json
{
"bid": "S02-B-sloppy",
"tender": "S02",
"style": "B",
"split": "dev",
"kind": "sloppy",
"modes": [
{"category": "tech_param", "mode": "implicit", "basis": [{"p": 76, "t": "否则将认为投标人接受"}]},
{"category": "substantive", "mode": "explicit", "scope": "符合性条件", "basis": [{"p": 20, "t": "必须提供,否则投标无效"}]}
],
"units": {
"s2": {
"mode": "evidence", "when": "bid",
"reviewed": true,
"expect": "defect", "defect": "D02", "variant": "附件不存在",
"granularity": "single", "worded": false,
"where": [{"volume": "v1", "p": 31, "t": "支持硬件BFD,最小发送间隔3.3ms"}],
"expected_location": {"volume": "v1", "section": "技术证明材料"}
},
"s3": {"mode": "implicit", "expect": "ok", "trap": "T06", "where": []}
},
"rule_defects": [
{"id": "P04-1", "type": "P04", "variant": "大写多一个拾", "where": [{"volume": "v1", "p": 2, "t": "壹佰肆拾玖万捌仟元整"}]}
]
}
```
- `expect`:`ok`、`defect`、`na`(不要求投标人响应,或 `platform` 平台内应答)。
- `reviewed`:挂了问题的单元必须为 true(人工复核过要求本身和响应方式)。
- `where` 在渲染后由脚本回填;`expected_location` 写这条响应本该在哪个分册、哪一节(整节缺失时用来核对「找过哪些分册」)。
## 7. 评分
`score.py` 读一次离线检查的输出(`run_check.py` 的 `results.json`),对照响应清单:
1. **映射口径**:问题的任一招标侧出处和标注单元的任一 anchor 重叠即对上(复用读标评分的定位)。
2. **按单元去重**:同一要求在清单里有几条(公告、须知、审查表),只要一条报出就算召回,几条都报只算一个对的、不算误报;几条都没报算一个漏报。
3. **问题类型按类型族比**(`unresponded` 含证据没核对上的那种),级别单独比。
4. 主要指标:
- **致命级问题召回率**(按分类、粒度、带不带标志词分别报;漏报比误报严重);
- **端到端召回**(读标抽到 × 查标查出),致命级单独报:对用户来说读标漏抽也是漏报;「查标召回」只在读标抽到的要求上算;
- **问题准确率**:报出的问题里对上注入问题的比例,「对上注入的问题」和「其他」分两列;
- **规矩投标文件的误报数**(每 100 条要求)、**干扰项误报率**(按 T01–T13 分别报);
- **只检索基线**:不调模型,检索第一名命中就算已响应,按风格分别报;全系统和基线差不多,说明评的是检索不是判断;
- 证据没通过校验的问题占致命问题的比例(目标 < 10%,C3);每次检查的 token 和费用(C2)。
5. 对不上标注单元的问题单列「无法对照」,不算对也不算错。
6. 开发集、留出集分开报;所有数字带风格标签。**头条是留出集 C 风格和国动真实投标文件**,开发集 A 风格的数字只作调试用。
## 8. 裁定规则
- 开发集上查出了响应清单里没有的问题:由人裁定一次。投标文件里确实有这个问题(生成时无意留下的),就改响应清单并记录;没有就算误报。
- 留出集:允许改响应清单,但只限投标文件里客观可查的错;每一次记为「响应清单错误」,不算查标误报也不算查标对,单独报这个数(它大说明生成器差)。**不许据此调规则和提示词**。
- 裁定记录写在 `evals/out/check/adjudication/<bid>.md`。
## 9. 放在哪里
| 内容 | 位置 | 进不进 git |
|---|---|---|
| 本规范、留出集划分 `split.json`、开发集写作说明和注入词库、生成 / 渲染 / 评分脚本 | `evals/check/` | 进 |
| 留出集专用的注入词库和 C 风格写作说明 | `evals/out/check/_sealed/` | 不进(封存) |
| 投标文件稿、渲染出的 PDF、响应清单、检查输出、裁定记录 | `evals/out/check/` | 不进(照着真实招标文件写的) |
| 真实的国动投标文件 | 用户本地,第 3 步冻结后提供 | 不进 |

715
evals/check/build.py Normal file
View File

@@ -0,0 +1,715 @@
"""从「招标文件档案」和读标清单生成规矩投标文件的结构化稿 spec.json(evals/check/GUIDELINES.md §1、§5)。
cd server && ../.venv/bin/python ../evals/check/build.py <招标文件> <投标文件编号> --style A|B|C [--checklist fix3]
规矩靠构造保证:偏离表、响应表、报价表、证明材料由脚本生成;技术参数有 op / value / unit 的,按要求算一个满足的值。
LLM 只写散文(档案里的 prose)和 B / C 风格的改写(rewrite.json,可选)。
风格 C(叙述型,只用在留出集):技术要求不逐条列表,写在叙述性的技术方案里
(<投标文件>/narrative.json,见 narrative()),技术偏离表只留一行总述(注入时再往里加偏离行);
档案里的 prose 不用,由 narrative.json 的各节替代(prose_of 指明替代哪一节)。
## 招标文件档案 evals/out/check/tenders/<ID>.json(照着招标原文整理,不进 git)
{
"tender": "S02",
"project": {"name": "…", "code": "…", "purchaser": "…", "agency": "…", "deadline": "2026-12-01",
"validity_days": 90, "ceiling_total": "1514100.00", "lots": [{"no": "1", "ceiling": "…"}]},
"modes": [{"category": "tech_param", "scope": "一般参数", "mode": "implicit", "when": null,
"basis": [{"p": 76, "t": "否则将认为投标人接受"}]}],
"forms": [ // 按格式章的顺序
{"key": "bid_letter", "title": "投标函", "kind": "text", "paragraphs": ["致:{agency}", "…{project_name}…"]},
{"key": "price_summary", "title": "开标一览表", "kind": "price_summary", "header": ["项目名称", "投标总价(元)", "…"]},
{"key": "price_items", "title": "分项价格表", "kind": "price_items", "header": ["序号", "名称", "…"],
"items": [{"name": "汇聚交换机", "qty": 3, "unit": "台"}]},
{"key": "tech_dev", "title": "偏离表", "kind": "response_table", "route": "tech_row",
"header": ["序号", "招标文件的要求", "投标文件中的内容", "偏离", "说明"], "only_deviations": true},
{"key": "compliance", "title": "采购需求符合性条件响应表", "kind": "fixed_rows", "header": ["…"],
"rows": [["1", "“(一)数量要求”的响应情况", "见招标文件第六章《采购需求》中相关要求", "完全响应", ""]]},
{"key": "auth", "title": "授权委托书", "kind": "text", "paragraphs": ["…"]},
{"key": "evidence", "title": "资格证明文件", "kind": "attachments"}
],
"attachments": [{"key": "a1", "name": "营业执照(副本)", "scan": true, "lines": ["…"]}],
"routes": {"i12": "tech_row", "i40": "commercial_row", "i55": "attach:a1", "i60": "form:compliance", "i70": "na"},
"prose": [{"title": "技术方案", "paragraphs": ["…"]}]
}
routes 的取值可以用「+」连起来(tech_row+attach:a3),「prose」表示靠技术方案散文响应(prose:<节标题> 指定哪一节)。
占位符:{bidder} {credit_code} {legal_rep} {agent} {project_name} {project_code} {purchaser} {agency} {date}
{validity_days} {total} {total_cn}。`routes` 的键是读标清单里正式条目的序号(items.json 的下标,前缀 i)。
"""
from __future__ import annotations
import argparse
import datetime
import json
import pathlib
import re
import sys
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
EVALS = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(EVALS))
import gold # noqa: E402
OUT = EVALS / "out" / "check"
BIDDER = {
"bidder": "江苏启衡信息技术有限公司(虚构)",
"credit_code": "91320100MA1XXXXX0Q",
"legal_rep": "周明远",
"agent": "陈立新",
"date": "2026年11月20日",
}
NUMS = re.compile(r"\d+(?:\.\d+)?")
DIGITS = "零壹贰叁肆伍陆柒捌玖"
UNITS = ["", "拾", "佰", "仟"]
SECTIONS = ["", "万", "亿"]
def rmb_upper(amount: Decimal) -> str:
"""金额大写(元、角、分),用于开标一览表。"""
q = amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
yuan, cents = divmod(int(q * 100), 100)
if yuan == 0:
head = "零元"
else:
groups, out = [], ""
while yuan:
groups.append(yuan % 10000)
yuan //= 10000
for gi in range(len(groups) - 1, -1, -1):
g = groups[gi]
if g == 0:
if out and not out.endswith("零"):
out += "零"
continue
part, zero = "", False
for di in range(3, -1, -1):
d = (g // 10**di) % 10
if d == 0:
zero = bool(part)
continue
if zero:
part += "零"
zero = False
part += DIGITS[d] + UNITS[di]
if out and g < 1000 and not out.endswith("零"):
out += "零"
out += part + SECTIONS[gi]
head = out.rstrip("零") + "元"
jiao, fen = divmod(cents, 10)
if not cents:
return head + "整"
return head + (DIGITS[jiao] + "角" if jiao else "零") + (DIGITS[fen] + "分" if fen else "")
def fill(text: str, ctx: dict[str, str]) -> str:
return re.sub(r"\{(\w+)\}", lambda m: ctx.get(m.group(1), m.group(0)), text)
def comply(spec: dict[str, Any], positive: bool) -> tuple[str, str]:
"""按要求算一个满足的响应值。返回 (响应文字, 偏离)。positive 时给正偏离(T05)。"""
param, op, value, unit = (spec.get(k) or "" for k in ("param", "op", "value", "unit"))
try:
v = Decimal(str(value))
except Exception: # noqa: BLE001
v = None
if v is not None and op in (">=", "<="):
better = (v * 2 if op == ">=" else v / 2).normalize() if positive else v.normalize()
return f"{param}:{better:f}{unit}", "正偏离" if positive else "无偏离"
if v is not None and op == "=":
return f"{param}:{v.normalize():f}{unit}", "无偏离"
return f"支持{param}" if param else "满足", "无偏离"
def value_text(raw: dict[str, Any]) -> str | None:
"""数值要求的满足值(不带参数名),风格 C 的正文用它填 {v};和 comply() 的非正偏离取值相同。"""
if raw.get("op") not in (">=", "<=", "=") or not raw.get("value"):
return None
try:
v = Decimal(str(raw["value"]))
except Exception: # noqa: BLE001
return None
return f"{v.normalize():f}{raw.get('unit') or ''}"
CN_DIGITS = "一二三四五六七八九"
def cn(k: int) -> str:
"""1 → 一,12 → 十二,30 → 三十(风格 C 的条目编号)。"""
tens, ones = divmod(k, 10)
head = "" if tens == 0 else ("十" if tens == 1 else CN_DIGITS[tens - 1] + "十")
return head + (CN_DIGITS[ones - 1] if ones else "")
def narr_head(j: int, label: str) -> str:
return f"({cn(j)}){label} "
def narrative(
tender: str,
narr: dict[str, Any],
chapter: int,
items: dict[str, dict[str, Any]],
sent_units: dict[str, list[str]],
form_responds: dict[str, list[str]],
required: set[str],
extra_units: set[str],
) -> list[dict[str, Any]]:
"""风格 C 的技术服务方案:一节一个小标题,每段响应一条或几条要求(iids),或补写清单没抽到的标注单元(units)。
段落带 responds / req / tp / val / tmpl,注入时(mutate.py)按段改写;
required 里的条目和 extra_units 必须都写到。"""
gold_units, _ = gold.units(tender)
by_sid = {gold.sid(x): x for x in gold.sentences(tender)}
gold_by_id = {u.id: u for u in gold_units}
blocks: list[dict[str, Any]] = [
{"type": "heading", "text": f"{chapter}.{narr['chapter_title']}", "new_page": True}
]
seen_iids: list[str] = []
seen_units: set[str] = set()
for k, sec in enumerate(narr["sections"], start=1):
blocks.append({"type": "heading", "level": 3, "text": f"{chapter}.{k} {sec['title']}"})
prose_units = sorted({u for t in sec.get("prose_of") or [] for u in form_responds.get("prose:" + t, [])})
if sec.get("intro"):
blocks.append({"type": "para", "text": sec["intro"], "responds": prose_units, "form": "prose"})
elif prose_units:
raise SystemExit(f"{sec['title']}:替代了档案里的散文节,要写 intro 承接它的标注单元")
tab = sec.get("table")
if tab:
seen_iids += tab.get("iids") or []
blocks.append(
{
"type": "table",
"title": tab.get("title"),
"header": tab["header"],
"rows": [{"cells": r, "anchor": 1} for r in tab["rows"]],
}
)
for j, p in enumerate(sec["paras"], start=1):
iids = p.get("iids") or []
seen_iids += iids
units = sorted(
{u for i in iids for s in items[i].get("sentences") or [] for u in sent_units.get(s, [])}
| set(p.get("units") or [])
)
seen_units |= set(p.get("units") or [])
raw = ((items[iids[0]].get("raw") or {}).get("tech_param") or {}) if len(iids) == 1 else {}
val = value_text(raw)
tmpl = p["text"]
if (val is None) == ("{v}" in tmpl):
raise SystemExit(f"{sec['title']} / {p['label']}:{{v}} 只能也必须用在单条数值要求上({iids})")
body = tmpl.replace("{v}", val or "")
if iids:
req, req_text = items[iids[0]]["req"], "".join(items[i]["text"] or items[i]["title"] for i in iids)
elif not p.get("units"): # 只是叙述(设计思路、选型说明),不对应具体要求
req, req_text = None, ""
else:
sents = sorted(
(by_sid[x] for u in p["units"] for x in gold_by_id[u].sids if x in by_sid),
key=lambda x: (x.page_no, x.seq),
)
req = {"p": sents[0].page_no, "t": gold.norm(sents[0].text)[:24]}
req_text = "".join(x.text for x in sents)
head = narr_head(j, p["label"])
blocks.append(
{
"type": "para",
"text": head + body,
"form": "narr",
"sec": k,
"label": p["label"],
"head": head,
"body": body,
"tmpl": tmpl,
"val": val,
"tp": raw,
"iids": iids,
"responds": units,
"req": req,
"req_text": req_text,
}
)
dup = sorted({i for i in seen_iids if seen_iids.count(i) > 1})
missing = sorted(required - set(seen_iids), key=lambda x: int(x[1:]))
missing_units = sorted(extra_units - seen_units)
if dup or missing or missing_units:
raise SystemExit(f"narrative.json:重复 {dup},漏写条目 {missing},漏写单元 {missing_units}")
for b in blocks:
b["vol"] = "v1"
return blocks
def load_items(tender: str, run: str) -> dict[str, dict[str, Any]]:
rows = json.loads((EVALS / "out" / "runs" / run / tender / "items.json").read_text())
by_sid = {gold.sid(s): s for s in gold.sentences(tender)}
out = {}
for i, r in enumerate(rows):
if r.get("zone") != "formal":
continue
sents = [by_sid[k] for k in r.get("sentences") or [] if k in by_sid]
out[f"i{i}"] = {
**r,
"text": "".join(s.text for s in sents),
"req": {"p": sents[0].page_no, "t": gold.norm(sents[0].text)[:24]} if sents else None,
}
return out
def unit_of(tender: str) -> dict[str, str]:
"""清单条目 → 标注单元:读标清单条目的句子和标注单元的句子有重叠就对上(和读标评分同一口径)。"""
units, _ = gold.units(tender)
by_sent: dict[str, list[str]] = {}
for u in units:
for s in u.sids:
by_sent.setdefault(s, []).append(u.id)
return by_sent # 句子编号 → 单元编号
COLUMN_HINTS = [ # 表头文字 → 字段(按顺序匹配,先匹配到的为准)
("no", ("序号", "序")),
("deviation", ("偏离",)),
("note", ("说明", "备注")),
("answer", ("响应", "投标文件中的内容", "应答", "投标人", "实际")),
("requirement", ("要求", "条款", "参数", "指标")),
("device", ("名称", "设备", "货物", "服务")),
]
def columns_of(header: list[str]) -> list[str]:
"""按格式的表头决定每一列放什么(S04 的偏离表第 2 列是「项目名称」)。认不出的列留空。"""
out: list[str] = []
for h in header:
field = next((f for f, keys in COLUMN_HINTS if any(k in h for k in keys) and f not in out), "")
out.append(field)
if "answer" not in out and "" in out:
out[out.index("")] = "answer"
return out
def _row(k: int, r: dict[str, Any], columns: list[str]) -> dict[str, Any]:
value = {
"no": str(k),
"device": (r.get("tp") or {}).get("device") or "",
"requirement": r["requirement"],
"answer": r["answer"],
"deviation": r["deviation"],
"note": "",
}
return {
"cells": [value.get(c, "") for c in columns],
"anchor": columns.index("answer") if "answer" in columns else len(columns) - 1,
"responds": r["responds"],
"req": r["req"],
"iid": r["iid"],
"key": r.get("key"),
"tp": r["tp"],
}
# 复述要求时去掉开头的编号和标记(「1.」「(2)」「3、」「1.2.3」「★」);「2路服务器」开头的数字是内容,不去掉
def c_short(r: dict[str, Any]) -> dict[str, Any]:
"""风格 C 的偏离表行:要求写成「设备 + 参数」的简称,不复述原句;响应只写值或「具备……」。"""
tp = r.get("tp") or {}
label = (tp.get("device") or "") + (tp.get("param") or "")
numeric = tp.get("op") in (">=", "<=", "=") and tp.get("value")
return {
**r,
"requirement": label or LEADING.sub("", r["requirement"])[:16],
"answer": r["answer"] if numeric else (f"具备{tp['param']}" if tp.get("param") else "满足"),
}
LEADING = re.compile(
r"^[\s★▲**]*(?:(?:[((]\d{1,3}[))]|\d{1,3}[.、.))](?!\d)|\d+(?:\.\d+){2,}|\d+\.\d+(?=\s))\s*)?[\s★▲**]*"
)
def build(tender: str, bid: str, style: str, run: str) -> None:
profile = json.loads((OUT / "tenders" / f"{tender}.json").read_text())
items = load_items(tender, run)
sent_units = unit_of(tender)
rewrite_path = OUT / bid / "rewrite.json"
rewrite = json.loads(rewrite_path.read_text()) if rewrite_path.exists() else {}
narr = json.loads((OUT / bid / "narrative.json").read_text()) if style == "C" else {}
# 风格 C 只在技术偏离表逐条响应时把技术要求写进叙述正文;默认接受的(只列偏离)照旧只列偏离
tech_explicit = any(
f["kind"] == "response_table" and f.get("route", "tech_row") == "tech_row" and not f.get("only_deviations")
for f in profile["forms"]
)
proj = profile["project"]
routes: dict[str, str] = profile.get("routes", {})
rows_by_route: dict[str, list[dict[str, Any]]] = {}
attach_responds: dict[str, list[str]] = {}
form_responds: dict[str, list[str]] = {}
for iid, it in items.items():
units = sorted({u for s in it.get("sentences") or [] for u in sent_units.get(s, [])})
# 一条要求可以走几个去处,用「+」连起来(如 tech_row+attach:a3:表里一行,另附证明材料)
parts = routes.get(iid, "na").split("+")
for part in parts:
if part.startswith("attach:"):
attach_responds.setdefault(part[7:], []).extend(units)
elif part.startswith("form:"):
form_responds.setdefault(part[5:], []).extend(units)
elif part.startswith("prose"):
form_responds.setdefault("prose:" + part[6:], []).extend(units)
route = next((x for x in parts if x in ("tech_row", "commercial_row")), None)
if route is None:
continue
raw = (it.get("raw") or {}).get("tech_param") or {}
positive = style == "A" and len(rows_by_route.get(route, [])) % 9 == 4 # 每 9 行一个正偏离(T05)
req_text = it["text"] or it["title"]
numeric = raw.get("op") in (">=", "<=", "=") and raw.get("value")
if numeric:
answer, dev = comply(raw, positive)
else: # 功能类要求:复述型(风格 A)照常写「满足,……」
answer, dev = "满足," + LEADING.sub("", req_text)[:120], "无偏离"
rw = dict(rewrite.get(iid) or {})
if "answer" in rw and NUMS.findall(rw["answer"]) != NUMS.findall(answer) and (style != "C" or numeric):
rw.pop("answer") # 改写后的数字和规矩的响应对不上:丢弃改写,规矩靠构造保证
rows_by_route.setdefault(route, []).append(
{
"iid": iid,
"key": iid,
"requirement": rw.get("requirement", req_text),
"answer": rw.get("answer", answer),
"deviation": dev,
"responds": units,
"req": it["req"],
"tp": raw,
}
)
covered = {u for v in rows_by_route.values() for r in v for u in r["responds"]}
covered |= {u for v in attach_responds.values() for u in v} | {u for v in form_responds.values() for u in v}
na_units = sorted(
{
u
for iid, it in items.items()
if routes.get(iid, "na") == "na"
for s in it.get("sentences") or []
for u in sent_units.get(s, [])
}
- covered
)
# 读标清单漏抽、标注集里有的技术参数:也按规矩响应,免得规矩的投标文件里「无意漏响应」
gold_units, _ = gold.units(tender)
by_sid = {gold.sid(x): x for x in gold.sentences(tender)}
extra_units: set[str] = set() # 风格 C:这些单元由 narrative.json 的段落补写
for u in gold_units:
if u.cat != "tech_param" or u.id in covered or u.id in na_units or not u.sids:
continue
if style == "C" and tech_explicit:
extra_units.add(u.id)
continue
sents = sorted((by_sid[k] for k in u.sids if k in by_sid), key=lambda x: (x.page_no, x.seq))
if not sents:
continue
text = "".join(x.text for x in sents)
rw = rewrite.get(f"u:{u.id}") or {}
rows_by_route.setdefault("tech_row", []).append(
{
"iid": None,
"key": f"u:{u.id}",
"requirement": rw.get("requirement", text),
"answer": rw.get("answer", "满足:" + text),
"deviation": "无偏离",
"responds": [u.id],
"req": {"p": sents[0].page_no, "t": gold.norm(sents[0].text)[:24]},
"tp": {},
}
)
if proj.get("deadline"): # 落款日期取投标截止日前 3 天(写死的日期可能晚于截止日,规矩的投标文件不能带 P08)
dl = datetime.date.fromisoformat(str(proj["deadline"])[:10]) - datetime.timedelta(days=3)
bidder_date = f"{dl.year}年{dl.month}月{dl.day}日"
else:
bidder_date = BIDDER["date"]
total = Decimal(str(proj.get("ceiling_total") or "1000000")) * Decimal("0.93")
total = total.quantize(Decimal("1"))
ctx = {
**BIDDER,
"date": bidder_date,
"project_name": proj.get("name", ""),
"project_code": proj.get("code", ""),
"purchaser": proj.get("purchaser", ""),
"agency": proj.get("agency", ""),
"validity_days": str(proj.get("validity_days") or 90),
"total": f"{total:,.2f}",
"total_cn": rmb_upper(total),
}
blocks: list[dict[str, Any]] = [{"type": "title", "text": f"{proj.get('name', '')}投标文件"}]
price_items = next((f.get("items") for f in profile["forms"] if f["kind"] == "price_items"), []) or []
weights = [Decimal(str(x.get("qty") or 1)) for x in price_items] or [Decimal(1)]
# 各分项的合价:有分项 / 单价上限的按上限的比例分摊(总价是限价的 93%,所以每项都在上限以内);没有的按数量分摊
ceilings = [Decimal(str(x["ceiling"])) if x.get("ceiling") else None for x in price_items]
if price_items and all(c is not None for c in ceilings):
base = [
c * w if x.get("ceiling_is_unit") else c for c, w, x in zip(ceilings, weights, price_items, strict=False)
] # type: ignore[operator]
else:
base = list(weights)
lines = [(total * b / sum(base)).quantize(Decimal("0.01")) for b in base]
lines[-1] = total - sum(lines[:-1])
unit_prices = [(ln / w).quantize(Decimal("0.01")) for ln, w in zip(lines, weights, strict=False)]
moved: list[dict[str, Any]] = [] # 风格 B:一部分技术行写在商务资格册里(干扰项 T01)
moved_columns: list[str] = []
for n, f in enumerate(profile["forms"], start=1):
start = len(blocks)
head = {"type": "heading", "text": f"{n}.{f['title']}", "new_page": n > 1, "form": f["key"]}
kind = f["kind"]
if kind == "text":
blocks += [
head,
*(
{
"type": "para",
"text": fill(p, ctx),
"responds": form_responds.get(f["key"], []),
"form": f["key"],
}
for p in f["paragraphs"]
),
]
blocks.append({"type": "sign", "lines": [f"投标人:{ctx['bidder']}(电子签章)", f"日期:{ctx['date']}"]})
elif kind == "price_summary":
header = f.get("header") or ["项目名称", "投标总价(小写,元)", "投标总价(大写)"]
cells = [ctx["project_name"], ctx["total"], ctx["total_cn"]] + [""] * max(0, len(header) - 3)
blocks += [
head,
{
"type": "table",
"header": header,
"rows": [{"cells": cells[: len(header)], "anchor": 1}],
"price_summary": True,
},
]
elif kind == "price_items":
rows = []
for k, (x, up, ln) in enumerate(zip(price_items, unit_prices, lines, strict=False), start=1):
rows.append(
{
"cells": [
str(k),
x["name"],
f"{x.get('qty', 1)}",
x.get("unit", ""),
f"{up:,.2f}",
f"{ln:,.2f}",
],
"anchor": 1,
}
)
header = ["序号", "名称", "数量", "单位", "单价(元)", "总价(元)"]
blocks += [
head,
{"type": "table", "header": header, "rows": rows, "price_items": True},
{"type": "para", "text": f"合计:{ctx['total']}元({ctx['total_cn']})"},
]
elif (
kind == "response_table"
and style == "C"
and f.get("route", "tech_row") == "tech_row"
and not f.get("only_deviations")
):
# 风格 C:技术偏离表只留总述行(技术响应都在叙述性的技术方案里),注入的偏离行由 mutate.py 加
header = f.get("header") or ["序号", "招标文件的要求", "投标文件中的内容", "偏离", "说明"]
columns = columns_of(header)
rows = []
for k, r in enumerate(narr["tech_table_rows"], 1):
value = {"no": str(k), **r}
rows.append({"cells": [value.get(c, "") for c in columns], "anchor": columns.index("answer")})
blocks += [
head,
{
"type": "table",
"header": header,
"rows": rows,
"form": f["key"],
"route": "tech_row",
"only_deviations": False,
"mode": None,
"columns": columns,
"pool": [],
"summary": True,
},
]
elif kind == "response_table":
src = rows_by_route.get(f.get("route", "tech_row"), [])
header = f.get("header") or ["序号", "招标文件的要求", "投标文件中的内容", "偏离", "说明"]
columns = columns_of(header)
if f.get("only_deviations"):
devs = [r for r in src if r["deviation"] != "无偏离"]
rows = [_row(k, r, columns) for k, r in enumerate(devs, 1)]
empty = {
"no": "1",
"requirement": "—",
"answer": "—",
"deviation": "无偏离",
} # 苏采云范本:没有偏离时注明「无偏离」
rows = rows or [
{
"cells": [empty.get(c, "") for c in columns],
"anchor": columns.index("deviation") if "deviation" in columns else 0,
}
]
pool = [
_row(0, c_short(r) if style == "C" else r, columns) for r in src if r["deviation"] == "无偏离"
] # 没列出的(默认接受):注入时可以挑来写成偏离
if style == "C": # 风格 C 的行只写要求简称,原文留给注入时判断要求类型
texts = [r["requirement"] for r in src if r["deviation"] == "无偏离"]
for row, text in zip(pool, texts, strict=True):
row["req_text"] = text
else:
rows = [_row(k, r, columns) for k, r in enumerate(src, 1)]
pool = []
if style == "B" and f.get("route", "tech_row") == "tech_row" and not f.get("mode"):
keep = []
for k, r in enumerate(rows):
(moved if k % 12 == 11 else keep).append(r)
rows, moved_columns = keep, columns
if style == "B" and "no" in columns: # 风格 B 用自己的编号
prefix = "技" if f.get("route", "tech_row") == "tech_row" else "商"
for k, r in enumerate(rows, 1):
r["cells"][columns.index("no")] = f"{prefix}-{k:02d}"
if style == "C" and "no" in columns: # 风格 C 用自己的编号
for k, r in enumerate(rows, 1):
r["cells"][columns.index("no")] = f"({cn(k)})"
blocks += [
head,
{
"type": "table",
"header": header,
"rows": rows,
"form": f["key"],
"route": f.get("route", "tech_row"),
"only_deviations": bool(f.get("only_deviations")),
"mode": f.get("mode"),
"columns": columns,
"pool": pool,
},
]
elif kind == "fixed_rows":
rows = [
{"cells": [fill(c, ctx) for c in r], "responds": form_responds.get(f["key"], []), "anchor": len(r) - 2}
for r in f["rows"]
]
blocks += [head, {"type": "table", "header": f["header"], "rows": rows, "form": f["key"], "fixed": True}]
elif kind == "attachments":
blocks.append(head)
for a in profile.get("attachments", []):
b = {
"type": "scan" if a.get("scan") else "para",
"text": fill(a["name"], ctx),
"responds": sorted(set(attach_responds.get(a["key"], []))),
"attach": a["key"],
}
if a.get("scan"):
b["lines"] = [fill(x, ctx) for x in a.get("lines", [])]
else:
b["text"] = fill(a["name"] + ":" + ";".join(a.get("lines", [])), ctx)
blocks.append(b)
else:
raise ValueError(f"未知的表单类型:{kind}")
tech = kind == "response_table" and f.get("route", "tech_row") == "tech_row"
for b in blocks[start:]:
b["vol"] = "v2" if style == "B" and tech else "v1"
if moved:
for k, r in enumerate(moved, 1):
if "no" in moved_columns:
r["cells"][moved_columns.index("no")] = f"补-{k:02d}"
blocks += [
{"type": "heading", "text": "补充技术响应", "new_page": True, "vol": "v1"},
{
"type": "table",
"header": moved_columns,
"rows": moved,
"form": "moved",
"route": "tech_row",
"only_deviations": False,
"mode": None,
"columns": moved_columns,
"pool": [],
"vol": "v1",
},
]
if style == "C":
required = {iid for iid in items if "tech_row" in routes.get(iid, "na").split("+")} if tech_explicit else set()
blocks += narrative(
tender, narr, len(profile["forms"]) + 1, items, sent_units, form_responds, required, extra_units
)
for sec in profile.get("prose", []) if style != "C" else []:
blocks.append({"type": "heading", "text": sec["title"], "new_page": True})
units = sorted(set(form_responds.get("prose:" + sec["title"], []) + form_responds.get("prose:", [])))
blocks += [
{"type": "para", "text": fill(p, ctx), "responds": units if i == 0 else [], "form": "prose"}
for i, p in enumerate(sec["paragraphs"])
]
for b in blocks[-len(sec["paragraphs"]) - 1 :]:
b["vol"] = "v2" if style == "B" else "v1"
blocks[0]["vol"] = "v1"
if style == "B":
volumes = [
{"id": "v1", "kind": "commercial", "header": None, "blocks": [b for b in blocks if b.get("vol") == "v1"]},
{
"id": "v2",
"kind": "technical",
"header": None,
"blocks": [
{"type": "title", "text": f"{proj.get('name', '')}投标文件(技术册)"},
*[b for b in blocks if b.get("vol") == "v2"],
],
},
]
else:
volumes = [{"id": "v1", "kind": "whole", "header": None, "blocks": blocks}]
traps = {u: "T01" for r in moved for u in r.get("responds") or []}
for b in blocks: # 风格 C:叙述里换了说法、合并概括后再响应(T02)
if b.get("form") == "narr":
for u in b["responds"]:
traps.setdefault(u, "T02")
for v in rewrite: # 被改写的要求(截断、同义替换)是 T02;只写值不复述是 T03
for r in [x for xs in rows_by_route.values() for x in xs if x.get("key") == v]:
for u in r["responds"]:
traps.setdefault(u, "T03" if "answer" in rewrite[v] and "requirement" not in rewrite[v] else "T02")
spec = {
"bid": bid,
"tender": tender,
"style": style,
"project_name": proj.get("name", ""),
"bidder": BIDDER,
"volumes": volumes,
"traps": traps,
"price": {
"total": f"{total:.2f}",
"items": [
{"name": x["name"], "qty": x.get("qty", 1), "unit_price": f"{p:.2f}"}
for x, p in zip(price_items, unit_prices, strict=False)
],
},
"routes_used": {r: len(v) for r, v in rows_by_route.items()},
"na_units": na_units,
"deadline": proj.get("deadline"),
"ceiling_total": proj.get("ceiling_total"),
"validity_days": proj.get("validity_days"),
}
d = OUT / bid
d.mkdir(parents=True, exist_ok=True)
(d / "spec.json").write_text(json.dumps(spec, ensure_ascii=False, indent=1))
print(
f"{bid}:{sum(len(v) for v in rows_by_route.values())} 行响应,{len(profile.get('attachments', []))} 份附件,总价 {ctx['total']}"
)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("tender")
ap.add_argument("bid")
ap.add_argument("--style", default="A")
ap.add_argument("--checklist", default="fix3")
a = ap.parse_args()
build(a.tender, a.bid, a.style, a.checklist)

View File

@@ -0,0 +1,40 @@
"""导出风格 B 改写的输入:从风格 A 的投标文件稿里取出要改写的表格行(evals/check/prompts/style-B.md)。
cd server && ../.venv/bin/python ../evals/check/export_rewrite.py <招标文件> [...]
输出:evals/out/check/<招标文件>-B/rewrite-input.json(数组,字段 key、requirement、answer)。
没有风格 A 稿时(留出集)从还没改写的风格 B 稿取。
"""
from __future__ import annotations
import json
import pathlib
import sys
OUT = pathlib.Path(__file__).resolve().parents[1] / "out" / "check"
for tender in sys.argv[1:]:
# 留出集没有风格 A:从还没改写的风格 B 稿取(表格行的要求 / 响应两栏和 A 一样);B 已经改写过就不能再取
src = OUT / f"{tender}-A"
if not (src / "spec.json").exists():
src = OUT / f"{tender}-B"
if (src / "rewrite.json").exists():
sys.exit(f"{tender}:没有风格 A 稿,风格 B 稿已经按 rewrite.json 改写过,不能再导出改写输入")
spec = json.loads((src / "spec.json").read_text())
rows: dict[str, dict[str, str]] = {}
for v in spec["volumes"]:
for b in v["blocks"]:
cols = b.get("columns") or []
if b.get("type") != "table" or "requirement" not in cols or "answer" not in cols:
continue
for r in [*b["rows"], *(b.get("pool") or [])]:
if r.get("key") and r["key"] not in rows:
rows[r["key"]] = {
"key": r["key"],
"requirement": r["cells"][cols.index("requirement")],
"answer": r["cells"][cols.index("answer")],
}
d = OUT / f"{tender}-B"
d.mkdir(exist_ok=True)
(d / "rewrite-input.json").write_text(json.dumps(list(rows.values()), ensure_ascii=False, indent=1))
print(tender, len(rows))

736
evals/check/mutate.py Normal file
View File

@@ -0,0 +1,736 @@
"""往规矩的投标文件稿里注入问题,写出马虎的投标文件稿和响应清单(evals/check/GUIDELINES.md §3、§6.2)。
cd server && ../.venv/bin/python ../evals/check/mutate.py <规矩的投标文件> <输出的投标文件> --split dev [--seed 1] [--density high|low]
cd server && ../.venv/bin/python ../evals/check/mutate.py <规矩的投标文件> --clean --split dev # 只给规矩的投标文件写响应清单
- 词库:开发集用 evals/check/vocab-dev.json;留出集用封存的 evals/out/check/_sealed/vocab-holdout.json;
- 注入是确定性的(同一个 seed 结果相同),每个问题记 defect、variant、granularity、worded;
- 问题只挂在标注单元上;挂上的单元先记 reviewed: false,复核(决策 E1)后改成 true,评分只算复核过的;
- 响应方式按单元在投标文件里的响应位置推出来:逐条响应表 → explicit,只列偏离的表 → implicit,附件 → evidence,格式文件 → presence;
风格 C 的叙述段落(form: narr)→ explicit(招标文件要求逐项响应,只是写成了叙述)。
- 风格 C:技术响应在叙述段落里,按段注入(漏写一段 / 连续几段、数值改错、写明不支持、只说满足不给值、段落张冠李戴),
负偏离未说明加在技术偏离表的总述行后面;其余(附件、规则问题)和风格 A / B 相同。开发集(A / B)的注入路径不变。
"""
from __future__ import annotations
import argparse
import copy
import json
import pathlib
import random
import re
import sys
from decimal import Decimal
from typing import Any
EVALS = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(EVALS))
sys.path.insert(0, str(EVALS / "check"))
import gold # noqa: E402
from build import narr_head, rmb_upper # noqa: E402
OUT = EVALS / "out" / "check"
VOCAB = {"dev": EVALS / "check" / "vocab-dev.json", "holdout": OUT / "_sealed" / "vocab-holdout.json"}
# 「暂不支持」只对功能类要求说得通;证书、清单、服务义务、文档交付、改造范围这类不是功能(复核两批共拒掉 12 条)
FUNCTIONAL = re.compile(r"支持|功能|具备|实现|可对|能够|可以|兼容|提供.{0,6}接口")
NON_FUNCTIONAL = re.compile(r"证书|认证|清单|服务|培训|文档|资料|方案|范围|记录|报告|期限|质保|交付|人员|工期|成果")
# 条件性要求:只对特定投标人适用(「如依法免税则须提供……」),规矩的投标人不适用,不能当作必须提供的材料
CONDITIONAL = re.compile(r"(如|若|对于)[^,。;]{0,12}(免税|不需|无需|依法|属于|是)")
DOC_REQ = re.compile(r"提供.{0,12}(方案|证明|报告|文件|承诺|截图|复印件|证书)")
COUNT_UNIT = re.compile(r"^(个|款|套|台|名|人|次|项|种|份|块|盏|家|条|路|口|颗|张)$")
UNIT_DOWN = {"TB": ("GB", 1024), "GB": ("MB", 1024), "Gbps": ("Mbps", 1000), "s": ("ms", 1000), "秒": ("毫秒", 1000)}
class Mutator:
def __init__(self, spec: dict[str, Any], vocab: dict[str, Any], rng: random.Random) -> None:
self.spec, self.vocab, self.rng = spec, vocab, rng
self.vols = spec["volumes"]
self.unit_defects: dict[str, dict[str, Any]] = {}
self.rule_defects: list[dict[str, Any]] = []
self.umbrella: set[str] = set()
self.no_propagate = False
self.rejected: set[str] = set() # 以前复核判为不成立的单元(同一招标文件的任何投标文件里),不再挂问题
# 在表格以外(技术方案散文、附件、格式文件)也响应了的单元:删表格行、改响应值都不构成问题,不挂这几类
self.prose = [
{t[i : i + 2] for t in [re.sub(r"\W", "", b["text"])] for i in range(len(t) - 1)}
for b in self.blocks
if b.get("type") == "para" and b.get("form") == "prose"
]
self.elsewhere = {
u
for b in self.blocks
if b.get("type") != "table" and b.get("form") != "narr" # 风格 C 的叙述段落就是逐项响应本身
for u in b.get("responds") or []
}
@property
def blocks(self) -> list[dict[str, Any]]:
"""所有分册的块(只读视图);增删用 remove / insert_after。"""
return [b for v in self.vols for b in v["blocks"]]
def remove(self, b: dict[str, Any]) -> None:
for v in self.vols:
if b in v["blocks"]:
v["blocks"].remove(b)
def insert_after(self, anchor: dict[str, Any], new: dict[str, Any]) -> None:
for v in self.vols:
if anchor in v["blocks"]:
v["blocks"].insert(v["blocks"].index(anchor) + 1, new)
return
# ---------- 定位 ----------
def tables(self, *, explicit: bool | None = None, fixed: bool = False) -> list[dict[str, Any]]:
out = []
for b in self.blocks:
if b.get("type") != "table" or not b.get("form"):
continue
if fixed:
if b.get("fixed"):
out.append(b)
continue
if b.get("fixed") or "only_deviations" not in b:
continue
mode = table_mode(b)
if explicit is None or (explicit and mode == "explicit") or (explicit is False and mode == "implicit"):
out.append(b)
return out
def free(self, units: list[str]) -> bool:
return bool(units) and not any(u in self.unit_defects or u in self.rejected for u in units)
def mark(
self, units: list[str], defect: str, variant: str, granularity: str = "single", worded: bool = True
) -> None:
for u in units:
self.unit_defects[u] = {"defect": defect, "variant": variant, "granularity": granularity, "worded": worded}
def phrase(self, key: str, field: str) -> str:
return str(self.rng.choice(self.vocab[key][field]))
# ---------- 要求项问题 ----------
def d01_block(self) -> None:
"""连续漏掉 5~10 行(逐条响应表)。"""
for t in self.rng.sample(self.tables(explicit=True), k=len(self.tables(explicit=True))):
rows = t["rows"]
if len(rows) < 12:
continue
n = self.rng.randint(5, 10)
start = self.rng.randint(0, len(rows) - n)
chunk = rows[start : start + n]
if not all(self.free(r.get("responds") or []) for r in chunk if r.get("responds")):
continue
if any(set(r.get("responds") or []) & self.elsewhere for r in chunk):
continue
for r in chunk:
self.mark(r.get("responds") or [], "D01", "删连续一段", "block", worded=False)
self.drop_everywhere([u for r in chunk for u in r.get("responds") or []], [(t, r) for r in chunk])
return
def row_ops(self, n: int) -> None:
"""逐条响应表里的单点问题:漏行、数值不满足、写明不支持、负偏离未说明、只写满足、对象错位。"""
cands = [(t, r) for t in self.tables(explicit=True) for r in t["rows"] if self.free(r.get("responds") or [])]
self.rng.shuffle(cands)
ops = ["D01", "D03", "D03", "D05", "D06", "D08", "D09"]
done = 0
for t, r in cands:
if done >= n:
break
if r not in t["rows"] or not self.free(r.get("responds") or []):
continue
# 从轮到的类型开始,这一行做不了就换下一种(软件类项目几乎没有数值参数,D03 常常做不了)
for k in range(len(ops)):
if self._apply(ops[(done + k) % len(ops)], t, r, cands):
done += 1
break
def in_prose(self, text: str) -> bool:
"""技术方案散文里基本复述了这条要求(字 2-gram 有六成以上出现在同一段里):表格里删掉或改错,散文里还在响应。"""
t = re.sub(r"\W", "", text)
grams = {t[i : i + 2] for i in range(len(t) - 1)}
return len(grams) >= 6 and any(len(grams & g) / len(grams) >= 0.6 for g in self.prose)
def _req_text(self, t: dict[str, Any], r: dict[str, Any]) -> str:
at = self.col(t, "requirement")
return re.sub(r"\W", "", r["cells"][at]) if at is not None else ""
def siblings(self, t: dict[str, Any], r: dict[str, Any]) -> list[tuple[dict[str, Any], dict[str, Any]]]:
"""同一条要求在投标文件里的所有表格行:挂同一个标注单元,或者要求原文相同(读标清单常把同一要求抽成几条,
S04 的参数同时列在两张表里)。注入的问题要在这些行上一起做,否则别处还留着正确的响应,就不是问题了。"""
units, text = set(r.get("responds") or []), self._req_text(t, r)
out = []
for t2 in self.tables():
for r2 in t2["rows"]:
if r2 is r:
continue
if units & set(r2.get("responds") or []) or (len(text) >= 6 and self._req_text(t2, r2) == text):
out.append((t2, r2))
return out
def propagate(self, t: dict[str, Any], r: dict[str, Any]) -> None:
"""把这一行改过的响应、偏离、说明照样写到同一要求的其他行上。"""
if self.no_propagate: # 风格 C 的商务表:同一单元的几行是几款不同的条款,各写各的
return
for t2, r2 in self.siblings(t, r):
for field in ("answer", "deviation", "note"):
a, b = self.col(t, field), self.col(t2, field)
if a is not None and b is not None:
r2["cells"][b] = r["cells"][a]
def drop_everywhere(self, units: list[str], rows: list[tuple[dict[str, Any], dict[str, Any]]] = ()) -> None:
"""漏响应要在所有表里都漏:S04 的技术参数同时列在全量偏离表和 ★ 表里,只删一张表等于没漏;
要求原文相同的重复行也一起删。"""
drop = {id(r2) for t, r in rows for _, r2 in self.siblings(t, r)} | {id(r) for _, r in rows}
for t in self.tables():
t["rows"] = [r for r in t["rows"] if id(r) not in drop and not set(r.get("responds") or []) & set(units)]
@staticmethod
def col(t: dict[str, Any], field: str) -> int | None:
cols = t.get("columns") or ["no", "requirement", "answer", "deviation", "note"]
return cols.index(field) if field in cols else None
def _apply(self, op: str, t: dict[str, Any], r: dict[str, Any], cands: list[Any]) -> bool:
ans, dev, note, req = (self.col(t, f) for f in ("answer", "deviation", "note", "requirement"))
if ans is None:
return False
tp = r.get("tp") or {}
units = r.get("responds") or []
req_text = r["cells"][req] if req is not None else ""
if DOC_REQ.search(req_text): # 「提供……方案 / 证明 / 报告」是文件要在(presence),不注入参数类问题
return False
numeric = tp.get("op") in (">=", "<=", "=") and tp.get("value")
if op == "D05" and (numeric or not FUNCTIONAL.search(req_text) or NON_FUNCTIONAL.search(req_text)):
return False # 「暂不支持」只套在功能类要求上(数值参数、证书、清单、服务义务不会这么写)
if op in ("D01", "D03", "D08", "D09") and (set(units) & self.elsewhere or self.in_prose(req_text)):
return False
if op == "D01":
self.drop_everywhere(units, [(t, r)])
self.mark(units, "D01", "删一行", worded=False)
elif op == "D03":
if not self._unmet(r, tp, ans, dev):
return False
elif op == "D05":
param = tp.get("param") or (r["cells"][req][:12] if req is not None else "该功能")
r["cells"][ans] = self.phrase("D05", "phrases").format(param=param)
self.mark(units, "D05", r["cells"][ans])
elif op == "D06":
if dev is None:
return False
r["cells"][dev] = self.phrase("D06", "deviations")
if note is not None:
r["cells"][note] = ""
self.mark(units, "D06", r["cells"][dev])
elif op == "D08":
if not tp.get("value"):
return False
r["cells"][ans] = self.phrase("D08", "phrases")
self.mark(units, "D08", r["cells"][ans], worded=False)
elif op == "D09":
mine = str(tp.get("param") or "")
others = [
x
for _, x in cands
if x is not r
and (x.get("tp") or {}).get("param")
and x["tp"]["param"] != mine # 必须是另一个参数:同名参数(另一台设备的同一指标)的响应可能恰好满足
and (not mine or mine not in x["cells"][x.get("anchor", ans)])
and x["cells"][x.get("anchor", ans)] != r["cells"][ans]
]
if not others:
return False
o = self.rng.choice(others)
r["cells"][ans] = o["cells"][o.get("anchor", ans)]
self.mark(units, "D09", "另一条参数的响应", worded=False)
if op != "D01":
self.propagate(t, r)
return True
def _unmet(self, r: dict[str, Any], tp: dict[str, Any], ans: int, dev: int | None) -> bool:
"""把响应值改成不满足;一部分换成更小的单位(换算后不满足)。不带标志词。"""
op, unit = tp.get("op"), tp.get("unit") or ""
try:
v = Decimal(str(tp.get("value")))
except Exception: # noqa: BLE001
return False
if op not in (">=", "<=") or v == 0:
return False
bad = (v / 2) if op == ">=" else (v * Decimal("1.6"))
variant = "低于下限" if op == ">=" else "高于上限"
if unit in UNIT_DOWN and self.rng.random() < 0.4:
small, k = UNIT_DOWN[unit]
text, variant = f"{(bad * k).normalize():f}{small}", "换算后不满足"
else:
text = f"{bad.normalize():f}{unit}"
r["cells"][ans] = f"{tp.get('param') or ''}:{text}"
if dev is not None and self.rng.random() < 0.5:
r["cells"][dev] = "无偏离" # 一半在偏离栏仍写「无偏离」(另一半保持原样,也是无偏离或正偏离)
self.mark(r.get("responds") or [], "D03", variant, worded=False)
return True # 同一要求的其他行由调用方(_apply)同步
def implicit_deviation(self, n: int) -> None:
"""只列偏离的表:挑默认接受的要求写成偏离(D06 负偏离未说明 / D05 写明不支持)。"""
for t in self.tables(explicit=False):
req_col = self.col(t, "requirement")
pool = [
r
for r in t.get("pool") or []
if self.free(r.get("responds") or [])
and not (req_col is not None and DOC_REQ.search(r["cells"][req_col]))
]
self.rng.shuffle(pool)
ans, dev, note, req = (self.col(t, f) for f in ("answer", "deviation", "note", "requirement"))
if ans is None or dev is None:
continue
for k, r in enumerate(pool[:n]):
row = copy.deepcopy(r)
if k % 2 == 0:
row["cells"][dev] = self.phrase("D06", "deviations")
if note is not None:
row["cells"][note] = ""
self.mark(row["responds"], "D06", row["cells"][dev])
elif (tp := row.get("tp") or {}).get("op") in (">=", "<=") and (
(v := decimal_or_none(tp.get("value"))) is not None
):
# 数值参数不会写「暂不支持」:写一个不满足的值并标负偏离(值不是纯数字的按功能类处理)
bad = v / 2 if tp["op"] == ">=" else v * Decimal("1.6")
row["cells"][ans] = f"{tp.get('param') or ''}:{bad.normalize():f}{tp.get('unit') or ''}"
row["cells"][dev] = "负偏离"
self.mark(row["responds"], "D06", "数值不满足并写负偏离")
else:
# 风格 C 的偏离表只写要求简称,判断功能类用原文(req_text)
req_text = row.get("req_text") or (row["cells"][req] if req is not None else "")
if not FUNCTIONAL.search(req_text) or NON_FUNCTIONAL.search(req_text):
row["cells"][dev] = self.phrase("D06", "deviations") # 非功能要求:写负偏离而不是「不支持」
if note is not None:
row["cells"][note] = ""
self.mark(row["responds"], "D06", row["cells"][dev])
if t["rows"] and "—" in t["rows"][0]["cells"]:
t["rows"].clear()
if self.col(t, "no") is not None:
row["cells"][self.col(t, "no")] = str(len(t["rows"]) + 1)
t["rows"].append(row)
continue
param = tp.get("param") or (req_text[:12] or "该功能")
row["cells"][ans] = self.phrase("D05", "phrases").format(param=param)
row["cells"][dev] = "负偏离"
self.mark(row["responds"], "D05", row["cells"][ans])
if t["rows"] and "—" in t["rows"][0]["cells"]:
t["rows"].clear()
if self.col(t, "no") is not None:
row["cells"][self.col(t, "no")] = str(len(t["rows"]) + 1)
t["rows"].append(row)
def attachments(self, n: int, cats: dict[str, str], texts: dict[str, str]) -> None:
"""删掉附件:资格条件 → D11,评分项 → D13,其他 → D02。
伞状单元(「第二十二条规定的条件」这类总述,挂在 3 份以上附件上)不挂问题:查标报的是具体条目,和总述对不上;
条件性要求(「如依法免税则须提供」)规矩的投标人不适用,它的附件不删。"""
uses: dict[str, int] = {}
for b in self.blocks:
if b.get("attach"):
for u in b.get("responds") or []:
uses[u] = uses.get(u, 0) + 1
self.umbrella = {u for u, k in uses.items() if k >= 3}
cands = [
b
for b in self.blocks
if b.get("attach")
and self.free([u for u in b.get("responds") or [] if u not in self.umbrella])
and not any(CONDITIONAL.search(texts.get(u, "")) for u in b.get("responds") or [])
]
self.rng.shuffle(cands)
for b in cands[:n]:
units = [u for u in b.get("responds") or [] if u not in self.umbrella]
kinds = {cats.get(u) for u in units}
defect = "D11" if "qualification" in kinds else "D13" if "scoring" in kinds else "D02"
self.mark(units, defect, "附件缺失", worded=False)
self.remove(b)
def compliance(self) -> None:
for t in self.tables(fixed=True):
if not any("响应" in str(h) for h in t.get("header") or []):
continue # 只有响应表(表头有「响应情况」栏)才注入;人员表、业绩表不是
rows = [r for r in t["rows"] if self.free(r.get("responds") or [])]
if not rows:
continue
r = self.rng.choice(rows)
at = r.get("anchor", len(r["cells"]) - 2)
r["cells"][at] = self.phrase("D10", "cells")
# 符合性条件响应表的每一行没法对到单个标注单元(整张表共用一组),按表级问题记一个
self.rule("D10", r["cells"][at] or "空着", r["cells"][1] if len(r["cells"]) > 1 else "")
return
# ---------- 风格 C:叙述段落 ----------
def narr_paras(self) -> list[dict[str, Any]]:
return [b for b in self.blocks if b.get("type") == "para" and b.get("form") == "narr"]
def shared(self, b: dict[str, Any], skip: tuple[dict[str, Any], ...] = ()) -> bool:
"""b 的单元在 b 以外(skip 里的块除外)还有响应:叙述段落、表格行或其他块。"""
mine = set(b.get("responds") or [])
for x in self.blocks:
if x is b or any(x is y for y in skip):
continue
rows = x["rows"] if x.get("type") == "table" else [x]
if any(mine & set(r.get("responds") or []) for r in rows):
return True
return False
def narr_block(self) -> None:
"""同一节里连续漏掉 5~8 段(至少 3 段挂了标注单元)。"""
secs: dict[int, list[dict[str, Any]]] = {}
for b in self.narr_paras():
secs.setdefault(b["sec"], []).append(b)
for paras in self.rng.sample(list(secs.values()), k=len(secs)):
if len(paras) < 10:
continue
n = self.rng.randint(5, 8)
starts = list(range(len(paras) - n + 1))
self.rng.shuffle(starts)
for start in starts:
chunk = paras[start : start + n]
units = [u for b in chunk for u in b.get("responds") or []]
if sum(1 for b in chunk if b.get("responds")) < 3 or not self.free(units):
continue
if set(units) & self.elsewhere or any(self.in_prose(b["req_text"]) for b in chunk):
continue
if any(self.shared(b, tuple(chunk)) for b in chunk):
continue
for b in chunk:
self.mark(b.get("responds") or [], "D01", "删连续几段", "block", worded=False)
self.remove(b)
return
def narr_ops(self, n: int) -> int:
"""叙述段落里的单点问题,类型按 row_ops 的比例轮换;轮到的类型去找做得了的段落
(服务类项目的数值段落少,逐段试的话几乎全落到负偏离上)。一轮下来一个都做不了就停。"""
cands = [b for b in self.narr_paras() if self.free(b.get("responds") or [])]
self.rng.shuffle(cands)
ops = ["D01", "D03", "D03", "D05", "D06", "D08", "D09"]
done, k, idle = 0, 0, 0
while done < n and idle < len(ops):
op = ops[k % len(ops)]
k += 1
for b in cands:
if b in self.blocks and self.free(b.get("responds") or []) and self._apply_narr(op, b):
done += 1
idle = 0
break
else:
idle += 1
return done
def _apply_narr(self, op: str, b: dict[str, Any]) -> bool:
tp, units, req_text = b.get("tp") or {}, b.get("responds") or [], b.get("req_text") or ""
if DOC_REQ.search(req_text):
return False
numeric = tp.get("op") in (">=", "<=", "=") and tp.get("value")
if op == "D05" and (numeric or not FUNCTIONAL.search(req_text) or NON_FUNCTIONAL.search(req_text)):
return False
if op in ("D01", "D03", "D05", "D08", "D09") and self.shared(b):
return False # 同一单元在别的段落 / 表格行里也有响应:删改这一段不构成问题
if op in ("D01", "D03", "D08", "D09") and (set(units) & self.elsewhere or self.in_prose(req_text)):
return False
if op == "D01":
self.remove(b)
self.mark(units, "D01", "删一段", worded=False)
elif op == "D03":
if not b.get("val") or tp.get("op") not in (">=", "<="):
return False
try:
v = Decimal(str(tp["value"]))
except Exception: # noqa: BLE001
return False
if v == 0:
return False
unit = tp.get("unit") or ""
if v == v.to_integral_value() and COUNT_UNIT.search(unit): # 个数不会写成 0.5 款:差 1 个
if tp["op"] == ">=" and v < 2:
return False
bad = v - 1 if tp["op"] == ">=" else v + 1
else:
bad = (v / 2) if tp["op"] == ">=" else (v * Decimal("1.6"))
variant = "低于下限" if tp["op"] == ">=" else "高于上限"
if unit in UNIT_DOWN and self.rng.random() < 0.4:
small, k = UNIT_DOWN[unit]
text, variant = f"{(bad * k).normalize():f}{small}", "换算后不满足"
else:
text = f"{bad.normalize():f}{unit}"
b["body"] = b["tmpl"].replace("{v}", text)
self.mark(units, "D03", variant, worded=False)
elif op == "D05":
param = tp.get("param") or b["label"]
b["body"] = self.phrase("D05", "phrases").format(param=param)
self.mark(units, "D05", b["body"])
elif op == "D06":
t = next((x for x in self.blocks if x.get("summary") and x.get("route") == "tech_row"), None)
if t is None:
return False
ans, dev = self.col(t, "answer"), self.col(t, "deviation")
if ans is None or dev is None:
return False
value = {
"no": str(len(t["rows"]) + 1),
"requirement": b["label"],
"answer": b["body"],
"deviation": self.phrase("D06", "deviations"),
}
cols = t["columns"]
t["rows"].append({"cells": [value.get(c, "") for c in cols], "anchor": ans, "responds": units})
self.mark(units, "D06", value["deviation"])
elif op == "D08":
if not tp.get("value"):
return False
b["body"] = self.phrase("D08", "phrases")
self.mark(units, "D08", b["body"], worded=False)
elif op == "D09":
mine = str(tp.get("param") or "")
others = [
x
for x in self.narr_paras()
if x is not b
and (x.get("tp") or {}).get("param")
and x["tp"]["param"] != mine
and x["body"] != b["body"]
and (not mine or mine not in x["body"])
and x["body"] == x["tmpl"].replace("{v}", x.get("val") or "") # 只抄没改过的段落
and not set(x.get("responds") or []) & set(self.unit_defects)
]
if not mine or not others:
return False
b["body"] = self.rng.choice(others)["body"] # 复制错了别处的一段:原来的要求没有响应
self.mark(units, "D09", "另一条要求的响应", worded=False)
return True
def narr_finish(self) -> None:
"""叙述段落按改过的内容重写文字,并按节重新编号(漏掉的段落不留空号)。"""
k: dict[int, int] = {}
for b in self.narr_paras():
k[b["sec"]] = k.get(b["sec"], 0) + 1
b["head"] = narr_head(k[b["sec"]], b["label"])
b["text"] = b["head"] + b["body"]
# ---------- 规则问题 ----------
def rule(self, kind: str, variant: str, snippet: str) -> None:
self.rule_defects.append(
{"id": f"{kind}-{len(self.rule_defects) + 1}", "type": kind, "variant": variant, "t": snippet}
)
def price_table(self) -> dict[str, Any] | None:
return next((b for b in self.blocks if b.get("price_summary")), None)
def p04(self) -> None:
t = self.price_table()
if not t or len(t["rows"][0]["cells"]) < 3: # 开标一览表没有大写金额栏(S04):做不了
return
variant = self.phrase("P04", "variants")
total = Decimal(self.spec["price"]["total"])
wrong = {"drop_digit": total / 10, "extra_shi": total + 10, "decimal_shift": total / 100}.get(
variant, total + 1
)
t["rows"][0]["cells"][2] = rmb_upper(wrong.quantize(Decimal("0.01")))
self.rule("P04", variant, t["rows"][0]["cells"][2])
def p05(self) -> None:
t = next((b for b in self.blocks if b.get("price_items")), None)
if not t or not t["rows"]:
return
r = self.rng.choice(t["rows"])
r["cells"][-1] = f"{Decimal(r['cells'][-1].replace(',', '')) + 1000:,.2f}"
self.rule("P05", "分项总价多 1000 元", r["cells"][-1])
def p06(self) -> None:
fp = self.rng.choice(self.vocab["P06"]["foreign_projects"])
self.vols[-1]["header"] = fp["name"] # 技术分册照搬旧标书最常见
self.rule("P06", "页眉留旧项目名", fp["name"])
def text_paras(self, form_hint: str) -> list[dict[str, Any]]:
return [b for b in self.blocks if b.get("type") == "para" and form_hint in str(b.get("form") or "")]
def p07(self) -> None:
bidder = self.spec["bidder"]["bidder"]
paras = [b for b in self.blocks if b.get("type") == "para" and b.get("form") and bidder in b["text"]]
if paras:
b = self.rng.choice(paras)
blank = self.phrase("P07", "blanks")
b["text"] = b["text"].replace(bidder, blank, 1)
self.rule("P07", "投标人名称空", blank_context(b["text"], blank))
def p08(self) -> None:
signs = [b for b in self.blocks if b.get("type") == "sign"]
if signs:
date = self.phrase("P08", "dates")
b = self.rng.choice(signs)
b["lines"] = [re.sub(r"日期:.*", f"日期:{date}", x) for x in b["lines"]]
self.rule("P08", date, date)
def p09(self) -> None:
days = str(self.spec.get("validity_days") or 90)
for b in self.blocks:
if b.get("type") == "para" and f"{days}天" in b["text"].replace(" ", ""):
new = str(self.phrase("P09", "days"))
b["text"] = b["text"].replace(f"{days}天", f"{new}天").replace(f"{days} 天", f"{new}天")
self.rule("P09", f"{days} → {new} 天", f"{new}天")
return
def p10(self) -> None:
letter = next((i for i, b in enumerate(self.blocks) if b.get("type") == "para" and b.get("form")), None)
if letter is not None:
text = self.phrase("P10", "phrases")
self.insert_after(self.blocks[letter], {"type": "para", "text": text, "form": self.blocks[letter]["form"]})
self.rule("P10", text, text)
def p12(self) -> None:
t = self.price_table()
if t:
alt = f"{Decimal(self.spec['price']['total']) * Decimal('0.97'):,.2f}"
text = self.phrase("P12", "phrases").format(alt=alt)
self.insert_after(t, {"type": "para", "text": text})
self.rule("P12", text, text)
def decimal_or_none(value: Any) -> Decimal | None:
try:
return Decimal(str(value)) if value else None
except Exception: # noqa: BLE001
return None
def blank_context(text: str, blank: str) -> str:
"""空白处前后的原文(不跨句号),评分时拿它对照问题的投标侧出处。"""
i = text.index(blank)
start = max(i - 10, max(text.rfind(x, 0, i) for x in "。;") + 1)
return text[start : i + len(blank) + 10]
def table_mode(b: dict[str, Any]) -> str:
"""表格的响应方式:档案里表单写了 mode 的以它为准(如 S02 评分要的技术规格表是 presence,不是逐条响应的机制)。"""
return str(b.get("mode") or ("implicit" if b.get("only_deviations") else "explicit"))
def unit_modes(spec: dict[str, Any]) -> dict[str, str]:
"""单元的响应方式:逐条响应表里有 → explicit;只在只列偏离的表里 → implicit;附件 → evidence;格式文件 → presence。"""
modes: dict[str, str] = {}
rank = {"explicit": 0, "implicit": 1, "evidence": 2, "presence": 3}
blocks = [b for v in spec["volumes"] for b in v["blocks"]]
for b in blocks:
if b.get("type") == "table" and "only_deviations" in b:
mode = table_mode(b)
for r in [*b["rows"], *(b.get("pool") or [])]:
for u in r.get("responds") or []:
if u not in modes or rank.get(mode, 9) < rank.get(modes[u], 9):
modes[u] = mode
elif b.get("form") == "narr": # 风格 C 的叙述段落:逐项响应
for u in b.get("responds") or []:
if u not in modes or rank.get(modes[u], 9) > 0:
modes[u] = "explicit"
elif b.get("attach"):
for u in b.get("responds") or []:
modes.setdefault(u, "evidence")
elif b.get("form"):
for r in b.get("rows") or [b]:
for u in r.get("responds") or []:
modes.setdefault(u, "presence")
return modes
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("clean")
ap.add_argument("out", nargs="?")
ap.add_argument("--clean", dest="manifest_only", action="store_true", help="只给规矩的投标文件写响应清单")
ap.add_argument("--split", required=True, choices=["dev", "holdout"])
ap.add_argument("--seed", type=int, default=1)
ap.add_argument("--density", default="high", choices=["high", "low"])
a = ap.parse_args()
spec = json.loads((OUT / a.clean / "spec.json").read_text())
tender = spec["tender"]
units, _ = gold.units(tender)
cats = {u.id: u.cat for u in units}
texts = {
u.id: "".join(str(x.get("t", "")) for x in u.raw.get("anchors") or []) + str(u.raw.get("note") or "")
for u in units
}
optional = {u.id for u in units if u.optional}
modes = unit_modes(spec) # 注入前算:被删掉的行也要知道它本来该怎么响应
m = Mutator(spec, json.loads(VOCAB[a.split].read_text()), random.Random(a.seed)) # noqa: S311 评测数据,不用于安全
for f in OUT.glob(f"{tender}-*/manifest.json"):
for uid, e in json.loads(f.read_text())["units"].items():
if (e.get("review") or {}).get("ok") is False:
m.rejected.add(uid)
if not a.manifest_only and spec["style"] == "C":
if a.density == "high":
m.narr_block()
rest = 18 - m.narr_ops(18) # 默认接受的招标文件(技术要求不写进叙述):余下的配额给只列偏离的表和附件
m.no_propagate = True
m.row_ops(2) # 商务响应表仍是逐条表格
m.implicit_deviation(4 + rest * 2 // 3)
m.attachments(4 + rest // 3, cats, texts)
m.compliance()
for op in (m.p04, m.p05, m.p06, m.p07, m.p08, m.p09, m.p10, m.p12):
op()
else:
m.narr_ops(4)
m.attachments(1, cats, texts)
m.p04()
m.p07()
m.narr_finish()
elif not a.manifest_only:
if a.density == "high":
m.d01_block()
m.row_ops(18)
m.implicit_deviation(4)
m.attachments(4, cats, texts)
m.compliance()
for op in (m.p04, m.p05, m.p06, m.p07, m.p08, m.p09, m.p10, m.p12):
op()
else:
m.row_ops(4)
m.attachments(1, cats, texts)
m.p04()
m.p07()
bid = a.clean if a.manifest_only else a.out
assert bid
manifest_units: dict[str, Any] = {}
na = set(spec.get("na_units") or [])
for u in units:
mode = modes.get(u.id) or ("na" if u.id in na else "unknown")
entry: dict[str, Any] = {"cat": u.cat, "mode": mode, "optional": u.id in optional}
if u.id in m.umbrella and not a.manifest_only:
entry["expect"] = "unknown" # 伞状单元:附件删了一部分,算不算问题看具体条目,评分不算
manifest_units[u.id] = entry
continue
if u.id in m.unit_defects:
entry.update({"expect": "defect", "reviewed": False, **m.unit_defects[u.id]})
elif mode == "implicit":
entry.update({"expect": "ok", "trap": "T06"})
elif u.id in (spec.get("traps") or {}):
entry.update({"expect": "ok", "trap": spec["traps"][u.id]})
else:
entry["expect"] = {"na": "na", "unknown": "unknown"}.get(mode, "ok")
manifest_units[u.id] = entry
manifest = {
"bid": bid,
"tender": tender,
"style": spec["style"],
"split": a.split,
"kind": "clean" if a.manifest_only else "sloppy",
"seed": a.seed,
"density": None if a.manifest_only else a.density,
"units": manifest_units,
"rule_defects": m.rule_defects,
}
d = OUT / bid
d.mkdir(parents=True, exist_ok=True)
spec["bid"] = bid
(d / "spec.json").write_text(json.dumps(spec, ensure_ascii=False, indent=1))
(d / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
n_def = sum(1 for e in manifest_units.values() if e.get("expect") == "defect")
by: dict[str, int] = {}
for e in manifest_units.values():
if e.get("expect") == "defect":
by[e["defect"]] = by.get(e["defect"], 0) + 1
print(f"{bid}:要求项问题 {n_def} 个 {by},规则问题 {len(m.rule_defects)} 个")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,118 @@
"""从规矩的投标文件派生只带报价问题的投标文件(P01 超总价限价、P02 超采购包限价、P03 分项超单价上限)。
cd server && ../.venv/bin/python ../evals/check/price_defects.py <规矩的投标文件> <输出> <P01|P02|P03> [--variant ...]
然后 ../.venv/bin/python ../evals/check/render.py <输出>
- 只改一个分项的单价,再重算该行合价、合计、开标一览表的小写和大写,算术前后一致:只触发要测的那一条规则;
- 响应清单照抄规矩的那份(单元都是 expect=ok),rule_defects 记这一个问题;不挂标注单元,不需要复核(E1 只管单元);
- 限价、单价上限取招标档案(evals/out/check/tenders/<招标文件>.json),和读标抽出的值一致时才有意义,生成后自己核对一遍。
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
from decimal import ROUND_UP, Decimal
from typing import Any
ROOT = pathlib.Path(__file__).resolve().parent
OUT = ROOT.parent / "out" / "check"
sys.path.insert(0, str(ROOT))
from build import rmb_upper # noqa: E402
VARIANTS = {"P01": ["超 1 元", "超 1%"], "P02": ["超 1 元", "超 1%"], "P03": ["单价超 1%"]}
def fmt(v: Decimal) -> str:
return f"{v:,.2f}"
def blocks(spec: dict[str, Any]) -> list[dict[str, Any]]:
return [b for v in spec["volumes"] for b in v["blocks"]]
def reprice(spec: dict[str, Any], index: int, unit_price: Decimal) -> Decimal:
"""把第 index 个分项的单价改成 unit_price,重算合价、合计、开标一览表;正文里出现的旧总价一并替换。返回新总价。"""
items = spec["price"]["items"]
old_total = Decimal(spec["price"]["total"])
items[index]["unit_price"] = f"{unit_price:.2f}"
lines = [Decimal(str(i["qty"])) * Decimal(i["unit_price"]) for i in items]
total = sum(lines, Decimal(0)).quantize(Decimal("0.01"))
spec["price"]["total"] = f"{total:.2f}"
table = next(b for b in blocks(spec) if b.get("price_items"))
row = table["rows"][index]
row["cells"][-2], row["cells"][-1] = fmt(unit_price), fmt(lines[index].quantize(Decimal("0.01")))
pairs = [(fmt(old_total), fmt(total)), (rmb_upper(old_total), rmb_upper(total))]
for b in blocks(spec):
if b.get("type") == "table":
for r in b["rows"]:
r["cells"] = [_swap(c, pairs) for c in r["cells"]]
elif "text" in b:
b["text"] = _swap(b["text"], pairs)
if "lines" in b:
b["lines"] = [_swap(x, pairs) for x in b["lines"]]
return total
def _swap(text: str, pairs: list[tuple[str, str]]) -> str:
for a, b in pairs:
text = text.replace(a, b)
return text
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("clean")
ap.add_argument("out")
ap.add_argument("kind", choices=sorted(VARIANTS))
ap.add_argument("--variant")
a = ap.parse_args()
variant = a.variant or VARIANTS[a.kind][0]
src = OUT / a.clean
spec = json.loads((src / "spec.json").read_text())
manifest = json.loads((src / "manifest.json").read_text())
profile = json.loads((OUT / "tenders" / f"{spec['tender']}.json").read_text())
items = spec["price"]["items"]
total = Decimal(spec["price"]["total"])
forms = [f for f in profile["forms"] if f.get("kind") == "price_items"]
caps = {x["name"]: Decimal(str(x["ceiling"])) for x in (forms[0].get("items") or []) if x.get("ceiling")}
if a.kind in ("P01", "P02"):
ceiling = Decimal(str(spec["ceiling_total"]))
target = ceiling + 1 if variant == "超 1 元" else (ceiling * Decimal("1.01")).quantize(Decimal("0.01"))
i = next((n for n, x in enumerate(items) if Decimal(str(x["qty"])) == 1), 0)
qty = Decimal(str(items[i]["qty"]))
unit = Decimal(items[i]["unit_price"]) + ((target - total) / qty).quantize(Decimal("0.01"), rounding=ROUND_UP)
new_total = reprice(spec, i, unit)
assert new_total > ceiling, (new_total, ceiling)
snippet = fmt(new_total)
else:
i = next(n for n, x in enumerate(items) if x["name"] in caps)
unit = (caps[items[i]["name"]] * Decimal("1.01")).quantize(Decimal("0.01"))
new_total = reprice(spec, i, unit)
snippet = fmt(unit)
out = OUT / a.out
out.mkdir(parents=True, exist_ok=True)
spec["bid"] = a.out
manifest.update(
bid=a.out,
kind="sloppy",
density="price",
rule_defects=[
{"id": f"{a.kind}-1", "type": a.kind, "variant": variant, "t": snippet},
# 分项上限加起来常常就是限价:总价超限价时,被抬高的分项也超了上限,一并记
*(
[{"id": "P03-2", "type": "P03", "variant": "随总价超限", "t": fmt(Decimal(x["unit_price"]))}]
if a.kind != "P03" and (x := items[i])["name"] in caps and Decimal(x["unit_price"]) > caps[x["name"]]
else []
),
],
)
(out / "spec.json").write_text(json.dumps(spec, ensure_ascii=False, indent=1))
(out / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
print(f"{a.out}:{a.kind}({variant}),总价 {fmt(total)} → {fmt(new_total)},原文片段「{snippet}」")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,44 @@
# 写「招标文件档案」的说明
为查标评测集写一份招标文件档案(tender profile),供 `evals/check/build.py` 生成虚构的规矩投标文件。
## 读这些
- 档案格式:`evals/check/build.py` 文件头的 docstring,以及各 kind 的生成逻辑。
- 响应方式的定义:`evals/check/GUIDELINES.md` §2(含本文件那一行的记录,记录不准时以招标原文为准,并在回复里说明)。
- 招标文件全文(带句子编号 `[p页s句]`):`evals/out/docs/<ID>/sentences.txt`
- 读标正式清单(要分配 routes 的条目):`evals/out/check/tenders/<ID>.items.tsv`(列:iid、类别、重要性、页码、标题)
- 写好的档案可以参考格式(不要照抄内容):`evals/out/check/tenders/S02.json`、`S04.json`、`S10.json`。
**不要读** `evals/out/check/_sealed/`、`server/`、`prompts/`(仓库根目录那个)。
## 只写一个文件
`evals/out/check/tenders/<ID>.json`。不改仓库里的其他文件,不做 git 操作。
## 要求
1. `project`:项目名称、编号、采购人、代理机构、投标截止日期(`deadline`,YYYY-MM-DD)、投标有效期天数、最高限价或预算(元,纯数字字符串)、采购包(编号、名称、限价)。多包时只投一个包,写 `bidding_lot`,`ceiling_total` 填那个包的限价,只属于别的包的条目 routes 记 `na`。全部从招标原文取。
2. `modes`:按类别(tech_param、substantive、commercial、qualification、scoring,必要时加 scope)记响应方式(explicit / implicit / evidence / presence / platform),evidence 写 `when`(bid 或 award)。每条带 `basis`:`p` 页码,`t` 从 sentences.txt 逐字复制、不少于 8 个字的片段(去掉 `<u>` 这类标记)。同一份文件里两处写法冲突时都记上,并在回复里说明。
3. `forms`:按「投标文件格式」章的顺序列出投标人要交的全部格式文件。
- 叙述性的(投标函、授权委托书、声明函、承诺书)用 `kind: "text"`,`paragraphs` 照抄固定文字,填空处换成占位符(见 docstring),没有对应占位符的填空写成虚构的合理内容。声明函按规矩投标人的正确填法写;「如有 / 如是」而规矩投标人不适用的不列。
- 开标一览表 `price_summary`;分项报价表 `price_items`,`items` 写名称、数量、单位。**有分项最高限价或单价上限的,每项加 `ceiling`(元)**;上限是单价时再加 `ceiling_is_unit: true`。
- 偏离表 / 响应表 `response_table`:`route` 是 `tech_row` 或 `commercial_row`;implicit 的写 `only_deviations: true`,explicit 的写 false。表头照抄格式(脚本按表头文字决定每一列放什么,支持「序号 / 名称 / 要求 / 响应 / 偏离 / 说明」这些列)。**不是用来逐条响应、只是要求提交的技术文件**(例如评分要的「所投产品技术规格」表),加 `"mode": "presence"`。商务要求要逐项响应但格式章没给表的,可以自拟一张。
- 有固定行的表用 `fixed_rows`,响应栏按规矩填。
- 资格证明、评分证明材料放进**一个** `kind: "attachments"` 的表单(它会把全部附件渲染一遍)。
4. `attachments`:规矩投标人要附的每份证明材料(按本文件要求来;写明「投标文件中无需提供」的、中标后才交的不附)。证书类 `scan: true`,`lines` 写虚构内容(单位名 {bidder},信用代码 {credit_code},有效期覆盖截止日之后至少一年,社保月份按要求的区间)。全部虚构,不出现真实企业或真实人名。
5. `routes`:tsv 里**每一个** iid 都要分配:
- `tech_row`:技术参数、功能要求;`commercial_row`:商务要求(交货期、地点、质保、付款、售后响应等);
- `attach:<附件 key>`:靠附证明材料满足的;`form:<表单 key>`:靠某份格式文件满足的;
- `prose` 或 `prose:<节标题>`:靠技术方案散文响应的(实施方案、售后服务方案等评分项);
- 一条要求要走几处时用 `+` 连起来,例如 `tech_row+attach:a3`(表里一行,另附证明材料);
- `na`:不需要投标人响应的(无效情形的列举、评审程序、日期、采购人义务、合同条款、说明标注规则的句子、别的包的条目)。
6. `prose`:2~4 节虚构的技术方案叙述,每节 2~4 段,节标题要和 `prose:<节标题>` 对得上。**不写任何具体参数数值**,不出现别的项目名称。
## 自查(用 Python)
JSON 能解析;tsv 里的 iid 全部出现在 routes 里、没有多余的键;`form:` / `attach:` / `prose:` 都指向存在的表单、附件、节;modes 的 basis 片段都能在对应页逐字找到。可以把 build.py 的输出目录临时指到 scratchpad 试跑一次(不要写进仓库)。
## 最终回复
只写简短汇总:routes 各取值的条数、forms 列表(key + kind + 有无 mode)、modes 列表、拿不准的地方。

View File

@@ -0,0 +1,27 @@
# 风格 B「简写型」改写说明(开发集用)
你在为评测集改写虚构投标文件的技术响应表。输入是一组表格行,每行有编号 `key`、招标要求原文 `requirement`、规矩的响应 `answer`。按下面的风格改写,**只改写法,不改内容**:
## 改写 `requirement`(招标要求那一栏)
真实投标人常常不照抄要求原文。每行按下面几种做法之一改写(轮换使用,大约各占三分之一):
- **截断**:只留前半句或关键词,后面用「……」或直接截掉。例:「支持多种接入方式,包括有线、无线和蜂窝网络,并支持自动切换」→「支持多种接入方式……」
- **改写**:换一种说法概括。例:「最小报文发送间隔不大于 50 毫秒」→「报文间隔≤50ms」
- **去掉编号和标记**:删掉原文开头的序号、★、▲。
## 改写 `answer`(投标响应那一栏)
- 大约三分之一的行**只写值、不复述要求**:例「64GB DDR4」「支持」「3 年」。这些行只输出 `answer`,不输出 `requirement`。
- 其余行用简短的说法,例「满足,单机 64GB」「支持,含硬件 BFD」。
- **数值、单位、是否满足必须和原响应完全一致**:原响应写 64GB,改写后也必须是 64GB(不能写成 64G 或 65536MB);原响应是满足,改写后不能出现「不支持」「部分」「可选」这类字眼。脚本会核对数字,对不上的改写会被丢弃。
## 不要做的
- 不要编造原文没有的参数、型号、品牌。
- 不要改变响应的含义(满足的仍然满足)。
- 例子里的设备和参数是虚构的,不要照搬到输出里。
## 输出
一个 JSON 对象:`{"<key>": {"requirement": "…", "answer": "…"}, …}`,只写 `answer` 的行省略 `requirement`。每个输入行都要有一项。

View File

@@ -0,0 +1,97 @@
"""从规矩的投标文件派生带「企业资质情况表」的投标文件(qualification-plan §5):q00 证书都有效、等级够;
q01 注入 D12(有效期早于投标截止日)和 D18(等级低于要求)。
cd server && ../.venv/bin/python ../evals/check/qual_defects.py <规矩的投标文件> <输出> <q00|q01>
然后 ../.venv/bin/python ../evals/check/render.py <输出>
- 资质情况表是文字表格(证书原件在后面的扫描件页上,和规矩的那份一样),插在资格证明材料那一章的开头;
- 证书名称、编号、日期都是虚构的,写法故意和词表的别名不完全一样(国标号在前、括注英文缩写、日期几种写法);
- 响应清单照抄规矩的那份(单元都是 expect=ok),rule_defects 记注入的问题;不挂标注单元,不需要复核(E1 只管单元);
- 局限:表是对着 check/qualification.py 的词表写的,分数说明不了泛化(qualification-plan §5)。
"""
from __future__ import annotations
import argparse
import json
import pathlib
from typing import Any
ROOT = pathlib.Path(__file__).resolve().parent
OUT = ROOT.parent / "out" / "check"
HEADER = ["序号", "证书名称", "证书编号", "等级", "发证机构", "有效期"]
# 招标文件 → 资质情况表的行(名称、编号、等级、发证机构、有效期),全部虚构
CERTS: dict[str, list[list[str]]] = {
"S01": [
["建筑业企业资质证书(电子与智能化工程专业承包)", "D232000001", "贰级", "某省住房和城乡建设厅(虚构)", "2024年5月21日至2029年5月20日"],
["安全生产许可证", "(苏)JZ安许证字〔2023〕000016", "—", "某省住房和城乡建设厅(虚构)", "2023年11月16日至2027年11月15日"],
["信息系统业务安全服务资质证书(CCIA)", "CCIA-2025-000231", "一级", "某网络安全行业组织(虚构)", "2025-02-01至2028-01-31"],
["信息安全服务资质认证证书(安全集成)", "CCRC-2024-ISV-000188", "一级", "某认证中心(虚构)", "有效期至2027.06.30"],
],
"S04": [
["GB/T 19001-2016/ISO 9001:2015 质量管理体系认证证书", "00125Q30001R1M", "—", "某认证有限公司(虚构)", "2025年3月10日至2028年3月9日"],
["环境管理体系认证证书(ISO 14001)", "00125E30002R1M", "—", "某认证有限公司(虚构)", "2025年3月10日至2028年3月9日"],
["职业健康安全管理体系认证证书 GB/T 45001-2020", "00125S30003R1M", "—", "某认证有限公司(虚构)", "2025年3月10日至2028年3月9日"],
],
"S07": [
["信息技术服务标准(ITSS)运行维护符合性证书", "ITSS-YW-2-320020250001", "二级", "某行业协会(虚构)", "2025年6月1日至2028年5月31日"],
["信息系统建设和服务能力等级证书", "CS3-3201-2024000077", "CS3", "某行业联合会(虚构)", "2024-12-31至2028-12-30"],
],
}
# 招标文件 → 注入的问题:(行号, 列号, 新值, 问题编号, 写法)
DEFECTS: dict[str, list[tuple[int, int, str, str, str]]] = {
"S01": [
(1, 5, "2023年11月16日至2026年11月15日", "D12", "资格条件的证书早于截止日到期"),
(2, 3, "二级", "D18", "评分项要求一级,写的是二级"),
],
"S04": [(1, 5, "2023年3月10日至2026年3月9日", "D12", "评分项的证书已过期")],
"S07": [
(0, 5, "2023年6月1日至2026年5月31日", "D12", "评分项的证书已过期"),
(1, 3, "CS2", "D18", "评分项要求 CS3 及以上,写的是 CS2"),
],
}
def table(rows: list[list[str]], vol: str) -> list[dict[str, Any]]:
return [
{"type": "para", "text": "企业资质情况表(证书复印件附后)", "vol": vol},
{
"type": "table",
"header": HEADER,
"rows": [{"cells": [str(i + 1), *r]} for i, r in enumerate(rows)],
"vol": vol,
},
]
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("clean")
ap.add_argument("out")
ap.add_argument("kind", choices=["q00", "q01"])
a = ap.parse_args()
src = OUT / a.clean
spec = json.loads((src / "spec.json").read_text())
manifest = json.loads((src / "manifest.json").read_text())
tender = spec["tender"]
rows = [list(r) for r in CERTS[tender]]
defects = []
if a.kind == "q01":
for n, (row, col, value, code, variant) in enumerate(DEFECTS[tender], start=1):
rows[row][col - 1] = value
defects.append({"id": f"{code}-{n}", "type": code, "variant": variant, "t": value})
vol = spec["volumes"][0]
at = next(i for i, b in enumerate(vol["blocks"]) if b.get("type") == "heading" and b.get("form") == "evidence")
vol["blocks"][at + 1 : at + 1] = table(rows, vol["blocks"][at].get("vol") or "v1")
out = OUT / a.out
out.mkdir(parents=True, exist_ok=True)
spec["bid"] = a.out
manifest.update(bid=a.out, kind="clean" if a.kind == "q00" else "sloppy", density="qualification", rule_defects=defects)
(out / "spec.json").write_text(json.dumps(spec, ensure_ascii=False, indent=1))
(out / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
print(f"{a.out}:{a.kind},资质情况表 {len(rows)} 行,注入 {[d['id'] for d in defects]}")
if __name__ == "__main__":
main()

91
evals/check/regen.py Normal file
View File

@@ -0,0 +1,91 @@
"""按固定的种子重新生成开发集的马虎投标文件,沿用复核过的结论(同一招标文件里复核过的「单元 + 问题类型」),
列出没复核过的新目标(evals/out/check/review/pending-units.json)。
cd server && ../.venv/bin/python ../evals/check/regen.py
"""
from __future__ import annotations
import json
import pathlib
import subprocess
import sys
EVALS = pathlib.Path(__file__).resolve().parents[1]
OUT = EVALS / "out" / "check"
# (规矩的投标文件,马虎的投标文件,种子,密度):开发集的固定组合(GUIDELINES §4)
SLOPPY = [
("S02-B", "S02-B-sloppy", 11, "high"),
("S04-A", "S04-A-sloppy", 7, "high"),
("S10-B", "S10-B-sloppy", 13, "high"),
("S01-A", "S01-A-sloppy", 17, "high"),
("S05-B", "S05-B-sloppy", 23, "high"),
("S07-A", "S07-A-sloppy", 19, "high"),
("S08-B", "S08-B-sloppy", 29, "high"),
("S04-B", "S04-B-low", 31, "low"),
("S08-A", "S08-A-low", 37, "low"),
("S10-A", "S10-A-low", 41, "low"),
]
def reviews(tender: str) -> dict[tuple[str, str], dict]:
out = {}
for f in OUT.glob(f"{tender}-*/manifest.json"):
for uid, e in json.loads(f.read_text())["units"].items():
if e.get("review") and e.get("expect") == "defect":
out[(uid, e["defect"])] = {k: v for k, v in e["review"].items() if k != "user_spot_check"}
return out
def main() -> None:
prior = {t: reviews(t) for t in {c[:3] for c, *_ in SLOPPY}}
pending = []
for clean, sloppy, seed, density in SLOPPY:
tender = clean[:3]
cmd = [
sys.executable,
str(EVALS / "check" / "mutate.py"),
clean,
sloppy,
"--split",
"dev",
"--seed",
str(seed),
"--density",
density,
]
print(subprocess.run(cmd, capture_output=True, text=True, check=True).stdout.strip())
p = OUT / sloppy / "manifest.json"
m = json.loads(p.read_text())
gold = {x["id"]: x for x in json.loads((EVALS / "gold" / f"{tender}.json").read_text())["items"]}
for uid, e in m["units"].items():
if e.get("expect") != "defect":
continue
if e.get("defect") == "D02":
e["mode"] = "evidence"
r = prior[tender].get((uid, e["defect"]))
if r:
e["reviewed"], e["review"] = r["ok"], r
else:
x = gold[uid]
pending.append(
{
"bid": sloppy,
"tender": tender,
"unit": uid,
"cat": e["cat"],
"basis": x.get("basis"),
"anchors": x.get("anchors"),
"note": x.get("note"),
"mode": e["mode"],
"defect": e["defect"],
"variant": e.get("variant"),
}
)
p.write_text(json.dumps(m, ensure_ascii=False, indent=1))
(OUT / "review" / "pending-units.json").write_text(json.dumps(pending, ensure_ascii=False, indent=1))
print("待复核的新目标:", len(pending))
if __name__ == "__main__":
main()

183
evals/check/render.py Normal file
View File

@@ -0,0 +1,183 @@
"""把投标文件稿(spec.json)渲染成 PDF,并回填响应清单里的位置(evals/check/GUIDELINES.md §5、§6)。
cd server && ../.venv/bin/python ../evals/check/render.py <投标文件编号> [...] # 需要 Gotenberg(:3109)
- 每个分册一份 PDF:evals/out/check/<bid>/<分册编号>.pdf;
- `scan` 块单独成页,渲染后把那一页换成图片(没有文字层),用来检验扫描件的处理(C10);
- 页眉写 volume.header(默认是本项目名称;注入 P06 时改成别的项目名称);
- 渲染后用读标同一套切句找到每个响应块所在的页,写进 manifest.json 的 where(页码 + 片段)。
"""
from __future__ import annotations
import html
import io
import json
import pathlib
import sys
from typing import Any
import httpx
import pypdfium2 as pdfium
from bid_doctor.document.pipeline import text_layer
ROOT = pathlib.Path(__file__).resolve().parent
OUT = ROOT.parent / "out" / "check"
GOTENBERG = "http://127.0.0.1:3109/forms/chromium/convert/html"
SNIPPET = 24 # 回填位置时取块文字的前多少个字
CSS = """
@page { size: A4; margin: 22mm 18mm 20mm; }
body { font-family: "Noto Serif CJK SC", "Noto Sans CJK SC", serif; font-size: 11pt; line-height: 1.75; margin: 0; }
h1 { font-size: 17pt; text-align: center; margin: 0 0 12pt; }
h2 { font-size: 14pt; margin: 14pt 0 6pt; }
h3 { font-size: 12pt; margin: 10pt 0 4pt; }
p { margin: 0 0 6pt; text-align: justify; }
table { width: 100%; border-collapse: collapse; margin: 6pt 0 10pt; font-size: 10pt; }
th, td { border: 0.6pt solid #333; padding: 3pt 4pt; vertical-align: top; }
th { background: #f2f2f2; }
.page { page-break-before: always; }
.scan { page-break-before: always; page-break-after: always; padding: 24mm 10mm; border: 1pt solid #999; min-height: 150mm; }
.scan h3 { text-align: center; }
.sign { margin-top: 18pt; text-align: right; }
"""
def esc(s: Any) -> str:
return html.escape(str(s if s is not None else ""))
def block_html(b: dict[str, Any]) -> str:
t = b.get("type")
if t == "title":
return f"<h1>{esc(b['text'])}</h1>"
if t == "heading":
level = int(b.get("level", 2))
cls = ' class="page"' if b.get("new_page") else ""
return f"<h{level}{cls}>{esc(b['text'])}</h{level}>"
if t == "para":
return f"<p>{esc(b['text'])}</p>"
if t == "sign":
return "<div class='sign'>" + "".join(f"<p>{esc(x)}</p>" for x in b["lines"]) + "</div>"
if t == "table":
head = "".join(f"<th>{esc(h)}</th>" for h in b.get("header") or [])
rows = "".join("<tr>" + "".join(f"<td>{esc(c)}</td>" for c in r["cells"]) + "</tr>" for r in b["rows"])
title = f"<h3>{esc(b['title'])}</h3>" if b.get("title") else ""
return f"{title}<table><thead><tr>{head}</tr></thead><tbody>{rows}</tbody></table>"
if t == "scan":
lines = "".join(f"<p>{esc(x)}</p>" for x in b.get("lines") or [])
return f"<div class='scan'><h3>{esc(b['text'])}</h3>{lines}</div>"
raise ValueError(f"未知的块类型:{t}")
def volume_html(spec: dict[str, Any], vol: dict[str, Any]) -> tuple[str, str]:
body = "".join(block_html(b) for b in vol["blocks"])
header = esc(vol.get("header") or spec.get("project_name") or "")
# 页眉用 Gotenberg 的 header.html 传,每页都有;读标切句会把各页重复的行认成页眉页脚带,
# 查标的「别的项目名称」规则要专门扫这里(m3-plan §2.4)
return (
f"<!doctype html><html><head><meta charset='utf-8'><style>{CSS}</style></head><body>{body}</body></html>",
header,
)
def render_pdf(page_html: str, header: str) -> bytes:
head = (
"<html><head><style>body{font-family:'Noto Sans CJK SC',sans-serif;font-size:8pt;color:#555;"
"width:100%;margin:0 18mm;}</style></head><body>" + esc(header) + "</body></html>"
)
files = [
("files", ("index.html", page_html.encode(), "text/html")),
("files", ("header.html", head.encode(), "text/html")),
]
data = {"marginTop": "0.9", "marginBottom": "0.8", "marginLeft": "0.7", "marginRight": "0.7"}
r = httpx.post(GOTENBERG, files=files, data=data, timeout=120)
r.raise_for_status()
return r.content
def rasterize(pdf: bytes, pages: set[int]) -> bytes:
"""把指定页(从 1 起)换成图片页:没有文字层,模拟扫描件。"""
if not pages:
return pdf
src = pdfium.PdfDocument(pdf)
out = pdfium.PdfDocument.new()
for i in range(len(src)):
if i + 1 in pages:
img = src[i].render(scale=1.6).to_pil().convert("L")
buf = io.BytesIO()
img.save(buf, format="PDF", resolution=115)
out.import_pages(pdfium.PdfDocument(buf.getvalue()))
else:
out.import_pages(src, [i])
buf = io.BytesIO()
out.save(buf)
return buf.getvalue()
def page_texts(pdf: bytes) -> dict[int, str]:
pages = text_layer.extract("B", pdf)
return {p.page_no: text_layer.norm("".join(s.text for s in p.sentences)) for p in pages}
def block_text(b: dict[str, Any], row: dict[str, Any] | None = None) -> str:
if row is not None:
# 定位用投标人写的那一格(anchor 指定;默认第 3 格「投标文件中的内容」),不用照抄的要求原文
cells = [str(c) for c in row["cells"]]
at = row.get("anchor", 2 if len(cells) >= 3 else len(cells) - 1)
return cells[at]
if b.get("type") == "scan":
return str(b["text"])
return str(b.get("text") or " ".join(b.get("lines") or []))
def locate(texts: dict[int, str], snippet: str, last: bool = False) -> int | None:
"""last:取最后一次出现的页(附件在文件末尾,名称常和前面格式文件的标题相同)。"""
q = text_layer.norm(snippet)[:SNIPPET]
hits = [p for p, t in texts.items() if q and q in t]
return (hits[-1] if last else hits[0]) if hits else None
def render(bid: str) -> None:
d = OUT / bid
spec = json.loads((d / "spec.json").read_text())
manifest_path = d / "manifest.json"
manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else None
where: dict[str, list[dict[str, Any]]] = {}
problems: list[str] = []
for vol in spec["volumes"]:
page_html, header = volume_html(spec, vol)
pdf = render_pdf(page_html, header)
texts = page_texts(pdf)
scans = {p for b in vol["blocks"] if b.get("type") == "scan" and (p := locate(texts, b["text"], last=True))}
located: list[tuple[list[str], str]] = []
for b in vol["blocks"]:
if b.get("type") == "table":
located += [(r.get("responds") or [], block_text(b, r)) for r in b["rows"]]
else:
located.append((b.get("responds") or [], block_text(b)))
for units, snippet in located:
if not units:
continue
page = locate(texts, snippet)
if page is None:
problems.append(f"{vol['id']}:找不到「{snippet[:SNIPPET]}」")
continue
for u in units:
where.setdefault(u, []).append(
{"volume": vol["id"], "p": page, "t": text_layer.norm(snippet)[:SNIPPET]}
)
(d / f"{vol['id']}.pdf").write_bytes(rasterize(pdf, scans))
print(f"{bid}/{vol['id']}.pdf {len(texts)} 页,扫描页 {sorted(scans)}")
if manifest is not None:
for uid, entry in manifest["units"].items():
entry["where"] = where.get(uid, [])
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=1))
for p in problems:
print(" ! " + p)
if __name__ == "__main__":
for b in sys.argv[1:]:
render(b)

96
evals/check/report.py Normal file
View File

@@ -0,0 +1,96 @@
"""汇总一批投标文件的查标评分(evals/check/GUIDELINES.md §7)。先对每份跑过 score.py。
cd server && ../.venv/bin/python ../evals/check/report.py --run base <投标文件> [...]
"""
from __future__ import annotations
import argparse
import json
import pathlib
from collections import Counter
OUT = pathlib.Path(__file__).resolve().parents[1] / "out" / "check"
def pct(a: int, b: int) -> str:
return f"{a}/{b}({a / b:.0%})" if b else "—"
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("bids", nargs="+")
ap.add_argument("--run", default="base")
a = ap.parse_args()
rec: Counter[str] = Counter()
tot: Counter[str] = Counter()
by_style: Counter[str] = Counter()
by_style_tot: Counter[str] = Counter()
fp = ok = unmapped = other = 0
traps: Counter[str] = Counter()
tokens = [0, 0]
findings_clean = items_clean = 0
r_found: Counter[str] = Counter()
r_total: Counter[str] = Counter()
r_fp: Counter[str] = Counter()
r_fp_clean = r_clean = 0
for bid in a.bids:
d = OUT / bid
m = json.loads((d / "manifest.json").read_text())
s = json.loads((d / "runs" / a.run / "score.json").read_text())
r = json.loads((d / "runs" / a.run / "results.json").read_text())
tokens[0] += r["summary"]["usage"]["tokens_in"]
tokens[1] += r["summary"]["usage"]["tokens_out"]
for k, (hit, n) in s["recall"].items():
rec[k] += hit
tot[k] += n
if "查标" in s["recall"]:
by_style[m["style"]] += s["recall"]["查标"][0]
by_style_tot[m["style"]] += s["recall"]["查标"][1]
rules = s.get("rules") or {}
r_found.update(rules.get("found") or {})
r_total.update(rules.get("total") or {})
r_fp.update(rules.get("fp_by_code") or {})
if m["kind"] == "clean":
r_fp_clean += rules.get("fp", 0)
r_clean += 1
if m["kind"] == "clean":
fp += s["fp"]
ok += s["ok_units"]
other += s["other_findings"]
unmapped += s["unmapped"]
traps.update(s["fp_by_trap"])
findings_clean += sum(1 for it in r["items"] if it["finding"])
items_clean += len(r["items"])
lines = [
f"# 汇总:{len(a.bids)} 份投标文件 · 运行 {a.run}",
"",
f"- 查标召回:{pct(rec['查标'], tot['查标'])};端到端:{pct(rec['端到端'], tot['端到端'])};致命级:{pct(rec['致命级'], tot['致命级'])}",
"- 按风格:" + ";".join(f"{k} {pct(by_style[k], by_style_tot[k])}" for k in sorted(by_style_tot)),
"",
"| 分类 | 召回 |",
"|---|---|",
*[f"| {k} | {pct(rec[k], tot[k])} |" for k in sorted(tot) if k not in ("查标", "端到端", "致命级")],
f"| 召回里只是「扫描件待人工核对」的 | {rec['其中只是扫描件待人工核对']} |",
"",
f"- 规矩的投标文件:清单 {items_clean} 条,报出问题 {findings_clean} 个;其中规矩响应的要求上 {fp}/{ok}(每 100 条 {100 * fp / ok:.1f})"
if ok
else "",
f"- 规矩的投标文件里,不需要响应(na / unknown)的要求上报了问题 {other} 个,对不上标注单元的问题 {unmapped} 个",
f"- 干扰项误报:{dict(traps)}",
f"- token:输入 {tokens[0]:,},输出 {tokens[1]:,}({len(a.bids)} 次检查)",
"",
f"## 规则问题:查出 {pct(sum(r_found.values()), sum(r_total.values()))};误报 {sum(r_fp.values())} 个"
f"(其中规矩的投标文件 {r_clean} 份上 {r_fp_clean} 个)",
"",
"| 问题 | 查出 |",
"|---|---|",
*[f"| {k} | {pct(r_found[k], r_total[k])} |" for k in sorted(r_total)],
"",
f"- 误报按规则:{dict(r_fp)}",
]
print("\n".join(lines))
if __name__ == "__main__":
main()

357
evals/check/run_check.py Normal file
View File

@@ -0,0 +1,357 @@
"""离线查标评测:拿读标评测的清单(evals/out/runs/<运行>/<招标文件>/items.json)对一份虚构投标文件跑响应匹配。
用法(在 server/ 下,模型配置读根目录 .env):
../.venv/bin/python ../evals/check/run_check.py <投标文件编号> [--checklist fix3] [--name <运行名>]
输入:evals/out/check/<投标文件编号>/v*.pdf(render.py 渲染的)、manifest.json 里的 tender。
输出:evals/out/check/<投标文件编号>/runs/<运行名>/results.json(每条清单项的结论、投标侧证据、生成的问题)。
同一模型 + 同一提示词版本 + 同一输入只调用一次模型(和读标评测共用 evals/out/cache/),只改规则时重放不花钱。
"""
from __future__ import annotations
import argparse
import asyncio
import datetime as dt
import hashlib
import json
import pathlib
import re
import sys
from decimal import Decimal
from typing import Any
from uuid import UUID, uuid4
from bid_doctor.ai import prompts
from bid_doctor.ai import service as ai_service
from bid_doctor.ai.providers.base import near_limit
from bid_doctor.ai.service import OutputIncomplete, provider
from bid_doctor.check import applicability, deviation, findings, match, qualification, rules
from bid_doctor.checklist import response_mode
from bid_doctor.checklist import price
from bid_doctor.checklist.extract import amount_yuan
from bid_doctor.core.config import get_settings
from bid_doctor.core.db import TenantContext
from bid_doctor.document.pipeline import text_layer
from bid_doctor.qualification.service import Record
EVALS = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(EVALS))
import gold # noqa: E402
OUT = EVALS / "out" / "check"
CACHE = EVALS / "out" / "cache"
usage = {"calls": 0, "cached": 0, "tokens_in": 0, "tokens_out": 0}
async def cached_run_json(
ctx: Any, *, job_type: str, job_id: Any, task: str, prompt_id: str, version: int, user: str
) -> dict[str, Any]:
del ctx, job_type, job_id
p = prompts.get(prompt_id, version)
s = get_settings()
model = s.ai_models.get(p.capability) or s.ai_model
key = hashlib.sha256(f"{model}\n{prompt_id}\n{version}\n{p.body}\n{user}".encode()).hexdigest()[:32]
f = CACHE / f"{key}.json"
if f.exists():
data = json.loads(f.read_text())
usage["cached"] += 1
else:
r = await provider().complete_json(p.body, user, p.capability, task)
data = {
"data": r.data,
"model": r.model,
"tokens_in": r.tokens_in,
"tokens_out": r.tokens_out,
"incomplete": r.incomplete,
}
f.write_text(json.dumps(data, ensure_ascii=False))
usage["calls"] += 1
usage["tokens_in"] += data.get("tokens_in") or 0
usage["tokens_out"] += data.get("tokens_out") or 0
if data.get("incomplete") or near_limit(data.get("tokens_out")):
raise OutputIncomplete(task, data["data"])
return data["data"]
ITEM = re.compile(r"^\[(I\d+)\] ")
CANDS = re.compile(r"^候选句:(.*)$")
FRAG = re.compile(r"^\[(d\d+p\d+s\d+)\]([^)]*)(.*)$")
async def baseline_run_json(
ctx: Any, *, job_type: str, job_id: Any, task: str, prompt_id: str, version: int, user: str
) -> dict[str, Any]:
"""只检索基线(GUIDELINES §7):不调模型,检索第一名就当作已响应。"""
del ctx, job_type, job_id, task, prompt_id, version
frags = {m.group(1): m.group(2) for line in user.splitlines() if (m := FRAG.match(line.strip()))}
results, key = [], None
for line in user.splitlines():
if m := ITEM.match(line.strip()):
key = m.group(1)
elif (m := CANDS.match(line.strip())) and key:
first = m.group(1).split("、")[0]
results.append(
{"item": key, "verdict": "responded", "evidence": [{"sid": first, "quote": frags.get(first, "")}]}
)
return {"results": results}
def checklist(tender: str, run: str) -> dict[UUID, dict[str, Any]]:
"""读标评测输出的正式清单 → 查标的清单项(和检查快照的 payload 同样的字段)。"""
rows = json.loads((EVALS / "out" / "runs" / run / tender / "items.json").read_text())
by_sid = {gold.sid(s): s for s in gold.sentences(tender)}
out: dict[UUID, dict[str, Any]] = {}
for i, r in enumerate(rows):
if r.get("zone") != "formal":
continue
text = "".join(by_sid[k].text for k in r.get("sentences") or [] if k in by_sid)
raw = (r.get("raw") or {}).get("tech_param") or {}
spec = {"tech_param": {k: raw[k] for k in ("param", "op", "value", "unit") if raw.get(k)}} if raw else {}
sched = (r.get("raw") or {}).get("schedule") or {}
if sched:
spec["schedule"] = {k: sched[k] for k in ("date_kind", "at") if sched.get(k)}
out[UUID(int=i + 1)] = {
"title": r.get("title"),
"requirement_text": text or None,
"categories": r.get("categories") or [],
"importance": r.get("importance"),
"importance_basis": r.get("importance_basis"),
"spec": spec,
"sentences": r.get("sentences") or [],
}
return out
def facts(tender: str, run: str, items: dict[UUID, dict[str, Any]]) -> rules.Facts:
"""项目侧的值:读标评测抽出的项目基本信息(和产品里一样来自抽取);所投的包按招标档案(相当于用户选包)。"""
base = EVALS / "out" / "runs" / run / tender
info = json.loads((base / "info.json").read_text())
f = {k: v.get("value") for k, v in info["fields"].items()}
profile = json.loads((OUT / "tenders" / f"{tender}.json").read_text())["project"]
deadline = next(
(
dt.date.fromisoformat(s["at"][:10])
for r in json.loads((base / "items.json").read_text())
if r.get("zone") == "formal"
and (s := (r.get("raw") or {}).get("schedule") or {}).get("date_kind") == "bid_deadline"
and s.get("at")
),
None,
)
lots = info.get("lots") or []
ceiling, scope, note = None, "total", None
if lots:
lot = next(
(x for x in lots if str(x["lot_no"]).lstrip("0") == str(profile.get("bidding_lot", "")).lstrip("0")), None
)
if lot is None and len(lots) == 1:
lot = lots[0]
if lot is not None:
c = amount_yuan((lot.get("ceiling") or {}).get("value"), (lot.get("ceiling") or {}).get("unit"))
ceiling, scope = (Decimal(c) if c else None), "lot" if len(lots) > 1 else "total"
else:
note = "所投采购包不明确,报价没有和限价比对"
else:
c = amount_yuan(f.get("ceiling_total"), info["fields"].get("ceiling_total", {}).get("unit"))
ceiling = Decimal(c) if c else None
days, item = rules.required_validity(items)
lot_no = str(profile.get("bidding_lot") or "").lstrip("0")
caps = [
rules.ItemCap(c.name, c.cap, c.per)
for c in price.caps(gold.sentences(tender))
if not c.lot_no or not lot_no or c.lot_no.lstrip("0") == lot_no
]
return rules.Facts(
name=f.get("name"),
code=f.get("code"),
purchaser=f.get("purchaser"),
agency=f.get("agency"),
deadline=deadline,
ceiling=ceiling,
ceiling_scope=scope,
ceiling_note=note,
validity_days=days,
validity_item=item,
caps=caps,
)
def library(kind: str) -> list[Record]:
"""模拟的资质库(qualification-plan §5):empty 空库;valid 每类一条、有效、等级最高;partial 只有 ISO 9001;
expired 每类一条、2020 年就过期。软著不进模拟(不查有效期)。"""
best = {"asc": "5级", "cn": "一级", "jia": "甲级"}
fams = [f for f in qualification.FAMILIES if f.key != "softcopy"]
if kind == "partial":
fams = [qualification.BY_KEY["iso9001"]]
if kind == "empty":
return []
to = dt.date(2020, 1, 1) if kind == "expired" else dt.date(2030, 12, 31)
return [Record(uuid4(), f.label, "certificate", best.get(f.levels or ""), "SIM-001", to) for f in fams]
async def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("bid")
ap.add_argument("--checklist", default="fix3")
ap.add_argument("--name", default="base")
ap.add_argument("--baseline", action="store_true", help="只检索、不调模型的基线")
ap.add_argument("--v1", action="store_true", help="第 1 步的做法:提示词 v1、不带同一行的单元格(对比基线)")
ap.add_argument("--no-modes", action="store_true", help="不按响应方式和「要不要回答」筛选(C12 之前的做法)")
ap.add_argument("--rules-only", action="store_true", help="只重跑规则检查,写回已有运行的 results.json")
ap.add_argument("--no-direct", action="store_true", help="不做偏离栏直读(对比用)")
ap.add_argument("--no-mismatch", action="store_true", help="不做响应错位判断(对比用)")
ap.add_argument("--modes", help="人工确认后的响应方式,如 technical=implicit,commercial=implicit")
ap.add_argument("--library", default="empty", choices=["empty", "valid", "partial", "expired"], help="模拟的资质库")
args = ap.parse_args()
d = OUT / args.bid
manifest = json.loads((d / "manifest.json").read_text())
items = checklist(manifest["tender"], args.checklist)
docs = []
for n, pdf in enumerate(sorted(d.glob("v*.pdf")), start=1):
pages = text_layer.extract(f"{args.bid}-{pdf.stem}", pdf.read_bytes())
docs.append(
match.BidDoc(
uuid4(),
n,
pdf.stem,
text_layer.body_sentences(pages),
[p.page_no for p in pages if p.text_source == "empty"],
[s for p in pages for s in p.sentences if s.location == "header_footer"],
)
)
project_facts = facts(manifest["tender"], args.checklist, items)
hits, rules_cov = rules.run(project_facts, docs, gold.sentences(manifest["tender"]))
def rule_rows(covered: set[tuple[str, int, str]]) -> list[dict[str, Any]]:
return [
{
"code": h.code,
"type": h.type,
"severity": h.severity,
"title": h.title,
"explanation": h.explanation,
"evidence": [{"volume": doc.label, "p": s.page_no, "text": s.text} for doc, s in h.evidence],
}
for h in rules.drop_covered(hits, covered)
]
if args.rules_only:
prev = d / "runs" / args.name / "results.json"
data = json.loads(prev.read_text())
covered = {
(e["volume"], e["p"], "".join(e["text"].split()))
for it in data["items"]
if it["verdict"] == "negative_deviation"
for e in it["evidence"]
}
data["rules"], data["rules_coverage"] = rule_rows(covered), rules_cov
prev.write_text(json.dumps(data, ensure_ascii=False, indent=1))
print(f"{args.bid}:规则问题 {len(data['rules'])} 个")
return
ai_service.run_json = baseline_run_json if args.baseline else cached_run_json # type: ignore[assignment]
if args.v1:
match.PROMPT = ("check.match", 1)
match.ROW_MATES = 0
async def progress(_: float) -> None:
return None
ctx = TenantContext(tenant_id=UUID(int=0), user_id=UUID(int=0))
all_items = items
decisions: dict[UUID, applicability.Decision] = {}
if not args.no_modes:
modes = response_mode.detect(gold.sentences(manifest["tender"]))
for pair in filter(None, (args.modes or "").split(",")):
cat, mode = pair.split("=") # 模拟清单页上人工确认 / 改过响应方式(C12 的人工确认点)
m = modes[cat]
modes[cat] = response_mode.Mode(cat, mode, "found", m.explicit, m.implicit)
known = applicability.context(items.values(), (s.text for d in docs for s in d.sentences))
decisions = {i: applicability.decide(p, modes, known) for i, p in items.items()}
items = {i: p for i, p in items.items() if decisions[i].match}
results, stats = await match.run(ctx, uuid4(), items, docs, progress)
if not args.no_direct:
deviation.direct_read(results, docs)
if not args.no_mismatch:
deviation.mismatch_read(results, items, docs)
if decisions:
match.apply_modes(results, items, decisions)
match.scanned_fallback(results, items, decisions, docs)
qual_hits, qual_cov = qualification.run(all_items, results, docs, library(args.library), project_facts.deadline)
rows = []
for item_id, item in all_items.items():
if item_id not in items:
rows.append(
{
"title": item["title"],
"sentences": item["sentences"],
"categories": item["categories"],
"importance": item["importance"],
"verdict": f"skipped:{decisions[item_id].skip}",
"layer": None,
"evidence_status": "none",
"reason_code": None,
"evidence": [],
"detail": {},
"finding": None,
}
)
continue
res = results[item_id]
spec = findings.spec(res, item)
rows.append(
{
"title": item["title"],
"sentences": item["sentences"],
"categories": item["categories"],
"importance": item["importance"],
"verdict": res.verdict,
"layer": res.layer,
"evidence_status": res.evidence_status,
"reason_code": res.reason_code,
"evidence": [{"volume": doc.label, "p": s.page_no, "text": s.text} for doc, s in res.evidence],
"detail": res.detail,
"finding": {"type": spec.type, "severity": spec.severity, "title": spec.title} if spec else None,
}
)
out = d / "runs" / args.name
out.mkdir(parents=True, exist_ok=True)
summary = {"items": len(rows), "usage": usage, "verify_failures": stats.verify_failures, "splits": stats.splits}
covered = {
(doc.label, s.page_no, "".join(s.text.split()))
for res in results.values()
if res.verdict == "negative_deviation"
for doc, s in res.evidence
}
(out / "results.json").write_text(
json.dumps(
{
"summary": summary,
"items": rows,
"rules": rule_rows(covered),
"rules_coverage": rules_cov,
"qualification": [
{
"code": h.code,
"severity": h.severity,
"title": h.title,
"explanation": h.explanation,
"source": h.detail["source"],
"evidence": [{"volume": doc.label, "p": s.page_no, "text": s.text} for doc, s in h.evidence],
}
for h in qual_hits
],
"qualification_coverage": {"library": args.library, **qual_cov.to_api()},
},
ensure_ascii=False,
indent=1,
)
)
findings_n = sum(r["finding"] is not None for r in rows)
print(
f"{args.bid}:清单 {len(rows)} 条,问题 {findings_n} 个,模型调用 {usage['calls']} 次(缓存 {usage['cached']}),"
f"token {usage['tokens_in']} / {usage['tokens_out']}"
)
if __name__ == "__main__":
asyncio.run(main())

314
evals/check/score.py Normal file
View File

@@ -0,0 +1,314 @@
"""查标评分:对照响应清单给一次离线检查打分(evals/check/GUIDELINES.md §7)。
cd server && ../.venv/bin/python ../evals/check/score.py <投标文件> [--run base] [--include-unreviewed]
- 清单项 → 标注单元:清单项的招标侧句子和单元的句子有重叠就对上(和读标评分同一口径);
- 按单元去重:同一要求在清单里有几条,只要一条报出就算召回,几条都报不算误报;
- 问题类型按类型族比(D01 / D02 / D09 / D11 / D13 → unresponded,D03 / D07 → suspected_unmet,……);
证明材料类(D02 / D11 / D13)报成「可能在扫描件里,请人工核对」也算标出来了(C10),另外单独计数;
- 只算复核过的问题(决策 E1);--include-unreviewed 时也算没复核的,报告里标明;
- 读标没抽到的问题单列「读标漏抽」:不算查标漏报,但计入端到端召回;
- 对不上任何标注单元的问题单列「无法对照」。
输出:evals/out/check/<投标文件>/runs/<运行>/score.json、score.md。
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
from collections import Counter, defaultdict
from typing import Any
EVALS = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(EVALS))
import gold # noqa: E402
OUT = EVALS / "out" / "check"
FAMILY = {
"D01": {"unresponded"},
"D02": {"unresponded", "manual_review"},
"D09": {"unresponded"},
"D11": {"unresponded", "manual_review"},
"D13": {"unresponded", "manual_review"},
"D16": {"unresponded"},
"D03": {"suspected_unmet"},
"D07": {"suspected_unmet"},
"D08": {"suspected_unmet"},
"D05": {"negative_deviation", "suspected_unmet"},
"D06": {"negative_deviation"},
"D10": {"negative_deviation"},
"D12": {"qualification"},
"D14": {"qualification"},
"D15": {"qualification", "unresponded"},
}
FATAL_CATS = {"substantive", "qualification"}
RULE_CODES = {
"P01": "PRICE.OVER_CEILING_TOTAL",
"P02": "PRICE.OVER_CEILING_LOT",
"P03": "PRICE.OVER_CEILING_ITEM",
"P04": "PRICE.CAPITAL_MISMATCH",
"P05": "PRICE.SUM_MISMATCH",
"P06": "FORM.FOREIGN_PROJECT",
"P07": "FORM.BLANK_FIELD",
"P08": "FORM.DATE_AFTER_DEADLINE",
"P09": "FORM.VALIDITY_SHORT",
"P10": "FORM.CONDITION_ADDED",
"P11": "FORM.MISSING_DOC",
"P12": "PRICE.MULTIPLE_QUOTES",
"P13": "PRICE.ITEM_MISSING",
"D12": "QUAL.EXPIRED", # 资质匹配(qualification-plan §5):按资质情况表那一行的出处对照
"D18": "QUAL.LEVEL_LOW",
}
def squash(t: str) -> str:
return "".join(t.split())
def score_rules(manifest: dict[str, Any], res: dict[str, Any]) -> dict[str, Any]:
"""规则问题(P 类):规则编号对上、投标侧出处里有注入的那段原文才算查出;没对上任何注入问题的规则问题算误报。
D10(符合性条件响应表)按表级问题记在 rule_defects 里,它由响应匹配查(negative_deviation),这里一起对照。"""
hits = list(res.get("rules") or []) + list(res.get("qualification") or [])
used: set[int] = set()
found: Counter[str] = Counter()
total: Counter[str] = Counter()
missed: list[str] = []
for rd in manifest.get("rule_defects") or []:
kind, snip = rd["type"], squash(rd.get("t") or "")
total[kind] += 1
if kind == "D10":
pool = [
(-1, {"evidence": it["evidence"]})
for it in res["items"]
if it["finding"] and it["finding"]["type"] == "negative_deviation"
] + [(i, h) for i, h in enumerate(hits) if h["code"] == "FORM.RESPONSE_NEGATIVE"]
else:
pool = [(i, h) for i, h in enumerate(hits) if h["code"] == RULE_CODES.get(kind)]
key = snip[:12]
cell = squash(rd.get("variant") or "") if kind == "D10" else ""
ok = next(
(
i
for i, h in pool
if any(
(key and (key in squash(e["text"]) or squash(e["text"]) in snip))
or (cell and squash(e["text"]) == cell)
for e in h["evidence"]
)
),
None,
)
if ok is None:
missed.append(f"{rd['id']}({rd.get('variant', '')}):{rd.get('t', '')[:40]}")
continue
found[kind] += 1
if ok >= 0:
used.add(ok)
# 清单里同一要求抽成几条时,规则(资质匹配)对每条各报一个、出处相同:算同一个,不算误报(同响应问题的按单元去重)
same = (hits[ok]["code"], tuple(e["text"] for e in hits[ok]["evidence"]))
used |= {i for i, h in enumerate(hits) if (h["code"], tuple(e["text"] for e in h["evidence"])) == same}
# 响应表规则报的「负偏离」对上了响应问题(D05 / D06)的注入位置:算那个单元查出(rule_units),不算规则误报
rule_units: set[str] = set()
for i, h in enumerate(hits):
if h["code"] != "FORM.RESPONSE_NEGATIVE":
continue
texts = [squash(e["text"]) for e in h["evidence"]]
for uid, u in manifest["units"].items():
if u.get("expect") != "defect" or "negative_deviation" not in FAMILY.get(u.get("defect", ""), set()):
continue
if any(
(w := squash(x.get("t") or "")[:12]) and any(w in t or (t and t in w) for t in texts)
for x in u.get("where") or []
):
rule_units.add(uid)
used.add(i)
fp = [h for i, h in enumerate(hits) if i not in used]
return {"found": found, "total": total, "missed": missed, "fp": fp, "n": len(hits), "units": rule_units}
def pct(a: int, b: int) -> str:
return f"{a}/{b}({a / b:.0%})" if b else "—"
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("bid")
ap.add_argument("--run", default="base")
ap.add_argument("--include-unreviewed", action="store_true")
a = ap.parse_args()
d = OUT / a.bid
manifest = json.loads((d / "manifest.json").read_text())
res = json.loads((d / "runs" / a.run / "results.json").read_text())
units, _ = gold.units(manifest["tender"])
sid_units: dict[str, set[str]] = defaultdict(set)
for u in units:
for s in u.sids:
sid_units[s].add(u.id)
# 标注集里同一条要求有时记了两个单元(两个类别各一条,或表格一行拆成几条):句子有重叠的单元合成一组,按组计
parent = {u.id: u.id for u in units}
def find(x: str) -> str:
while parent[x] != x:
x = parent[x]
return x
for ids in sid_units.values():
first, *rest = sorted(ids)
for o in rest:
parent[find(o)] = find(first)
group = {u.id: find(u.id) for u in units}
rs = score_rules(manifest, res)
per_unit: dict[str, list[dict[str, Any]]] = defaultdict(list)
unmapped: list[dict[str, Any]] = []
for it in res["items"]:
hit = {u for s in it["sentences"] for u in sid_units.get(s, ())}
for u in hit:
per_unit[u].append(it)
if not hit and it["finding"]:
unmapped.append(it)
rec: Counter[str] = Counter()
rec_total: Counter[str] = Counter()
sev_ok = sev_n = 0
misses: list[str] = []
extraction_miss: list[str] = []
fp: list[str] = []
fp_by_trap: Counter[str] = Counter()
ok_units = trap_units = 0
other_findings = 0
seen_groups: set[str] = set()
for uid, e in sorted(manifest["units"].items(), key=lambda kv: kv[1].get("expect") != "defect"):
g = group.get(uid, uid)
if g in seen_groups:
continue # 同组的单元只算一次(挂了问题的那个优先)
seen_groups.add(g)
items = [it for x in manifest["units"] if group.get(x, x) == g for it in per_unit.get(x, [])]
found = [it for it in items if it["finding"]]
if e["expect"] == "defect":
if not e.get("reviewed") and not a.include_unreviewed:
continue
keys = [e["defect"], f"粒度:{e['granularity']}", "带标志词" if e.get("worded") else "不带标志词"]
fatal = e["cat"] in FATAL_CATS
if fatal:
keys.append("致命级")
for k in keys:
rec_total[k] += 1
rec_total["端到端"] += 1
if not items:
extraction_miss.append(f"{uid}({e['defect']},{e['cat']})")
continue
rec_total["查标"] += 1
good = [it for it in found if it["finding"]["type"] in FAMILY.get(e["defect"], set())]
if not good and any(x in rs["units"] for x in manifest["units"] if group.get(x, x) == g):
good = [{"finding": {"type": "negative_deviation", "severity": "important"}}]
rec["其中由响应表规则查出"] += 1
if good:
for k in [*keys, "端到端", "查标"]:
rec[k] += 1
if all(it["finding"]["type"] == "manual_review" for it in good):
rec["其中只是扫描件待人工核对"] += 1
if fatal:
sev_n += 1
sev_ok += any(it["finding"]["severity"] == "fatal" for it in good)
else:
got = ", ".join(sorted({it["verdict"] for it in items}))
misses.append(f"{uid}({e['defect']} {e.get('variant', '')})→ 查标结论:{got}")
elif e["expect"] == "ok":
ok_units += 1
if e.get("trap"):
trap_units += 1
if found:
fp.append(f"{uid}({e['cat']},{e['mode']}):{found[0]['finding']['title'][:40]}")
if e.get("trap"):
fp_by_trap[e["trap"]] += 1
elif found:
other_findings += 1
findings = [it for it in res["items"] if it["finding"]]
lines = [
f"# 查标评分:{a.bid} · 运行 {a.run}",
"",
f"- 投标文件:{manifest['kind']},风格 {manifest['style']},{manifest['split']};清单 {len(res['items'])} 条,报出问题 {len(findings)} 个",
f"- 金标准:{'含未复核的问题(试跑口径)' if a.include_unreviewed else '只算复核过的问题'};复核方式见决策 E1",
f"- 模型:{res['summary']['usage']}",
"",
"## 召回",
"",
f"- 查标召回(读标抽到的要求上):{pct(rec['查标'], rec_total['查标'])}",
f"- 端到端召回(含读标漏抽):{pct(rec['端到端'], rec_total['端到端'])}",
f"- 致命级:{pct(rec['致命级'], rec_total['致命级'])};其中级别也判成致命的 {pct(sev_ok, sev_n)}",
"",
"| 分类 | 召回 |",
"|---|---|",
*[f"| {k} | {pct(rec[k], rec_total[k])} |" for k in sorted(rec_total) if k not in ("端到端", "查标", "致命级")],
"",
"## 误报",
"",
f"- 规矩响应的要求 {ok_units} 条,报了问题的 {len(fp)} 条(每 100 条 {100 * len(fp) / ok_units:.1f})"
if ok_units
else "- 没有规矩响应的要求",
f"- 干扰项 {trap_units} 条,误报 {dict(fp_by_trap)}",
f"- 要求本来不需要响应或无法判断(na / unknown)却报了问题:{other_findings}",
f"- 无法对照(对不上任何标注单元)的问题:{len(unmapped)}",
"",
f"## 漏报({len(misses)})",
"",
*[f"- {x}" for x in misses],
"",
f"## 读标漏抽({len(extraction_miss)})",
"",
*[f"- {x}" for x in extraction_miss],
"",
f"## 误报明细({len(fp)})",
"",
*[f"- {x}" for x in fp[:80]],
*(["- ……"] if len(fp) > 80 else []),
"",
]
lines += [
f"## 规则问题:注入 {sum(rs['total'].values())} 个,查出 {sum(rs['found'].values())} 个;规则报出 {rs['n']} 个,误报 {len(rs['fp'])} 个",
"",
"| 问题 | 查出 |",
"|---|---|",
*[f"| {k} | {pct(rs['found'][k], rs['total'][k])} |" for k in sorted(rs["total"])],
"",
*[f"- 漏:{x}" for x in rs["missed"]],
*[
f"- 误报:{h['code']} {h['title']}:{h['evidence'][0]['text'][:50] if h['evidence'] else ''}"
for h in rs["fp"]
],
"",
"### 规则检查没覆盖的",
"",
*[f"- {x}" for x in (res.get("rules_coverage") or {}).get("not_checked", [])],
"",
]
out = d / "runs" / a.run
(out / "score.md").write_text("\n".join(lines))
summary = {
"recall": {k: [rec[k], rec_total[k]] for k in rec_total},
"fp": len(fp),
"ok_units": ok_units,
"fp_by_trap": dict(fp_by_trap),
"unmapped": len(unmapped),
"other_findings": other_findings,
"extraction_miss": len(extraction_miss),
"include_unreviewed": a.include_unreviewed,
"rules": {
"found": dict(rs["found"]),
"total": dict(rs["total"]),
"fp": len(rs["fp"]),
"fp_by_code": dict(Counter(h["code"] for h in rs["fp"])),
"n": rs["n"],
},
}
(out / "score.json").write_text(json.dumps(summary, ensure_ascii=False, indent=1))
print("\n".join(lines[:20]))
if __name__ == "__main__":
main()

5
evals/check/split.json Normal file
View File

@@ -0,0 +1,5 @@
{
"_comment": "查标评测集划分(GUIDELINES §3)。2026-11-04 开工前选定,之后不改。留出集只在第 3 步代码冻结后跑一次。",
"dev": {"tenders": ["S01", "S02", "S04", "S05", "S07", "S08", "S10"], "styles": ["A", "B"]},
"holdout": {"tenders": ["S03", "S06", "S09"], "styles": ["B", "C"], "real": ["国动小程序投标文件(用户提供)"]}
}

View File

@@ -0,0 +1,21 @@
{
"_comment": "开发集用的注入词库(GUIDELINES §3.4)。留出集用另一份,由另一个会话写好封存在 evals/out/check/_sealed/vocab-holdout.json,开发时不打开。check.match 提示词的例子不能和这里的短语相同。",
"D05": {"phrases": ["暂不支持{param}", "{param}需二次开发后支持", "{param}为可选配置"]},
"D06": {"deviations": ["负偏离", "部分满足", "偏离"]},
"D08": {"phrases": ["满足", "完全响应", "符合要求"]},
"D09": {"templates": ["{other}"]},
"D10": {"cells": ["未完全响应", "部分响应", ""]},
"P04": {"variants": ["drop_digit", "extra_shi", "decimal_shift"]},
"P06": {
"foreign_projects": [
{"name": "宿迁市人民医院智慧病房建设项目", "code": "JSZC-321300-HDZB-G2025-0318", "purchaser": "宿迁市人民医院"},
{"name": "盐城市大数据中心容灾备份系统采购项目", "code": "JSZC-320900-YCZB-G2025-0742", "purchaser": "盐城市大数据管理局"},
{"name": "泰州市第二中学智慧校园二期项目", "code": "JSZC-321200-TZJY-G2025-0096", "purchaser": "泰州市第二中学"}
]
},
"P07": {"blanks": ["__________", "年 月 日", "( )"]},
"P08": {"dates": ["2026年12月28日", "2027年11月20日", "2026年13月02日"]},
"P09": {"days": [60, 30, 45]},
"P10": {"phrases": ["本报价不含税,税费另行计算。", "付款方式双方另行协商。", "最终数量以实际到货为准。"]},
"P12": {"phrases": ["备选方案报价:{alt}元", "优惠报价:{alt}元", "方案二总价:{alt}元"]}
}

View File

@@ -2,11 +2,14 @@
"name": "bid-doctor",
"private": true,
"packageManager": "pnpm@10.34.5",
"engines": { "node": ">=22" },
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "pnpm --filter @bid-doctor/web dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"gen:api": "pnpm --filter @bid-doctor/api-client gen"
"gen:api": "pnpm --filter @bid-doctor/api-client gen",
"dev:admin": "pnpm --filter @bid-doctor/admin dev"
}
}

Some files were not shown because too many files have changed in this diff Show More