diff --git a/.env.example b/.env.example
index 97a17b8..b8554ab 100644
--- a/.env.example
+++ b/.env.example
@@ -21,6 +21,11 @@
# 按能力选模型(选填,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=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index db57168..989bde2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -51,6 +51,8 @@ jobs:
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
diff --git a/.gitignore b/.gitignore
index ca5a3c2..79298e4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,6 +35,7 @@ opensource-ref/
# 技术验证的产物(由样本派生,样本不转载)
spikes/**/out/
spikes/.venv/
+spikes/**/.venv/
spikes/**/.env
# 工程骨架:本地环境与产物
diff --git a/AGENTS.md b/AGENTS.md
index e6bbd22..890c1cb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -88,6 +88,7 @@ server/ 后端:api、worker、ops 是同一个包、三种进
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 生成的类型 + 客户端(不手写接口类型)
@@ -100,7 +101,7 @@ 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` |
@@ -241,4 +242,10 @@ samples/
- 顺手修了取消解析 / 检查时接口报 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(协作权限、资产库)。
diff --git a/apps/web/src/components/CitationBlock.vue b/apps/web/src/components/CitationBlock.vue
index e207230..69ef575 100644
--- a/apps/web/src/components/CitationBlock.vue
+++ b/apps/web/src/components/CitationBlock.vue
@@ -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: [] }>();
@@ -19,7 +20,7 @@ const emit = defineEmits<{ open: [] }>();
{{ citation.document_label }} · 第 {{ citation.page_no }}–{{ lastPage }} 页
- {{ quote }}
+ OCR{{ quote }}
@@ -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;
diff --git a/apps/web/src/components/ImageViewer.vue b/apps/web/src/components/ImageViewer.vue
new file mode 100644
index 0000000..59ecc79
--- /dev/null
+++ b/apps/web/src/components/ImageViewer.vue
@@ -0,0 +1,298 @@
+
+
+
+
+
+
+
+
+
+ {{ current?.title }} · {{ index + 1 }} / {{ images.length }}
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/components/QualificationDetail.vue b/apps/web/src/components/QualificationDetail.vue
new file mode 100644
index 0000000..477b916
--- /dev/null
+++ b/apps/web/src/components/QualificationDetail.vue
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
+ {{ item.name }}
+
+
+ - 类型
+ - {{ QUALIFICATION_KIND[item.kind] }}
+ - 等级
+ - {{ item.level }}—
+ - 证书编号
+ - {{ item.cert_no }}—
+ - 发证机构
+ - {{ item.issuer }}—
+ - 证书主体
+ - {{ item.holder }}—
+ - 有效期
+ -
+ {{ validity }}
+
+ 早于「{{ item.late_for[0]!.name }}」的投标截止 {{ fmtMonthDay(item.late_for[0]!.deadline) }} 等 {{ item.late_for.length }} 个项目
+
+
+
+ - 录入方式
+ - 从证书照片识别,经人工核对
+
+
+
+ 附件
+ 还没有附件,可以在编辑里补录。
+
+ !{{ err }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/components/QualificationDialog.vue b/apps/web/src/components/QualificationDialog.vue
index e93a609..13f60e9 100644
--- a/apps/web/src/components/QualificationDialog.vue
+++ b/apps/web/src/components/QualificationDialog.vue
@@ -1,19 +1,23 @@