Merge branch 'codex/personal-assistant-phase2' into codex/multi-tenant-knowledge-platform

# Conflicts:
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java
#	backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrModelSeedServiceTest.java
#	backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java
#	docs/API_INTEGRATION.md
#	docs/个人AI助理阶段二专项TechSpec.md
#	docs/个人AI助理阶段二开发推进计划.md
#	frontend/src/views/knowledge/processing.vue
#	mobile-uni/src/pages/user/sop/index.vue
#	mobile-uni/src/services/api.ts
#	mobile-uni/src/services/personal-assistant.ts
#	mobile-uni/tests/personal-assistant.test.mjs
#	scripts/demo-check.sh
#	scripts/personal-assistant-smoke.sh
This commit is contained in:
2026-07-22 08:54:04 +08:00
72 changed files with 13883 additions and 61 deletions
@@ -0,0 +1,406 @@
# Personal Scanned PDF OCR Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make image-only PDFs in the personal assistant asynchronously OCR every page in bounded batches and become searchable without truncating pages.
**Architecture:** Keep Tika as the fast path. When a PDF has no text layer, create owner-scoped OCR job/page rows and let the existing scheduled ingestion worker process at most 20 pages per claim. A focused renderer converts PDF pages to bounded JPEG images; a focused vision gateway reuses the enabled OpenAI-compatible vision/chat model. Final fragments are published only after the job reaches a terminal result.
**Tech Stack:** Java 17, Spring Boot 3.5, JdbcTemplate, PDFBox (already transitively available through Tika; declare explicitly), JUnit 5/Mockito, MySQL 8, uni-app Vue 3/TypeScript.
---
## File map
- Create `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPdfPageRenderer.java`: PDF page counting and bounded JPEG rendering only.
- Create `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVisionOcrService.java`: resolve enabled vision/chat runtime and call OpenAI-compatible image OCR.
- Create `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPdfOcrService.java`: owner-scoped job/page lifecycle, 20-page claims, retry, aggregation.
- Modify `PersonalIngestionWorker.java`: retain Tika fast path; hand empty PDFs to `PersonalPdfOcrService`.
- Modify `PersonalAssistantDto.java`, `PersonalSpaceService.java`, `PersonalAssistantController.java`: expose progress and retry-failed-pages contract.
- Modify `PersonalCleanupService.java`: remove OCR page/job rows when deleting a personal item.
- Modify `backend/script/sql/aihr_personal_knowledge_mysql8.sql`: add OCR job/page tables.
- Modify `mobile-uni/src/services/personal-assistant.ts` and `mobile-uni/src/pages/user/assistant/item.vue`: show progress and retry failed pages.
- Add focused tests beside existing personal assistant tests.
### Task 1: Schema and API contract
**Files:**
- Modify: `backend/script/sql/aihr_personal_knowledge_mysql8.sql`
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java`
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java`
- [ ] **Step 1: Write the failing schema test**
Add assertions that the SQL contains both OCR tables, the owner isolation keys, page uniqueness, and the 200-page progress columns:
```java
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS `aihr_personal_ocr_job`"));
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS `aihr_personal_ocr_page`"));
assertTrue(sql.contains("UNIQUE KEY `uk_personal_ocr_job_item` (`tenant_id`, `owner_user_id`, `item_id`)"));
assertTrue(sql.contains("UNIQUE KEY `uk_personal_ocr_page_number` (`tenant_id`, `owner_user_id`, `item_id`, `page_number`)"));
assertTrue(sql.contains("`processed_pages` int NOT NULL DEFAULT 0"));
```
- [ ] **Step 2: Run the schema test and verify RED**
Run:
```bash
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalSchemaContractTest test
```
Expected: FAIL because the OCR table strings do not exist.
- [ ] **Step 3: Add the two tables and progress DTO**
Add `aihr_personal_ocr_job` with job status/counters/lease fields and `aihr_personal_ocr_page` with page status/text/attempt fields. Extend `ItemResponse` with an optional nested record:
```java
public record OcrProgressResponse(boolean required, String status, int totalPages,
int processedPages, int successPages, int failedPages,
List<Integer> failedPageNumbers) {}
```
Append `OcrProgressResponse ocr` to `ItemResponse` so absence remains `null` for non-OCR items.
- [ ] **Step 4: Run the schema test and verify GREEN**
Run the same Maven command. Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/script/sql/aihr_personal_knowledge_mysql8.sql \
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/domain/PersonalAssistantDto.java \
backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java
git commit -m "feat(personal): add scanned PDF OCR schema"
```
### Task 2: Bounded PDF page renderer
**Files:**
- Modify: `backend/ruoyi-modules/ruoyi-aihr/pom.xml`
- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPdfPageRenderer.java`
- Create: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPdfPageRendererTest.java`
- [ ] **Step 1: Write failing renderer tests**
Create in-memory PDFs with PDFBox and assert:
```java
assertEquals(8, renderer.pageCount(eightPagePdf));
assertEquals(List.of(0, 1), renderer.render(eightPagePdf, 0, 2).stream().map(RenderedPage::pageIndex).toList());
assertThrows(PdfPageLimitException.class, () -> renderer.requireSupportedPageCount(201));
```
Also assert every rendered image is `image/jpeg`, non-empty, and below the renderer byte limit.
- [ ] **Step 2: Run renderer tests and verify RED**
```bash
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalPdfPageRendererTest test
```
Expected: test compilation fails because `PersonalPdfPageRenderer` is absent.
- [ ] **Step 3: Implement the renderer**
Declare `org.apache.pdfbox:pdfbox` explicitly at the version resolved by Tika. Implement constants `MAX_PAGES=200`, `BATCH_SIZE=20`, render at bounded DPI, scale oversized pages down, JPEG encode with a fixed quality, and return:
```java
public record RenderedPage(int pageIndex, byte[] bytes, String mimeType) {}
```
Reject malformed/encrypted PDFs using controlled `PdfRenderException` codes; never log PDF bytes or extracted content.
- [ ] **Step 4: Run renderer tests and module tests**
Expected: renderer tests PASS; existing parser tests remain PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/ruoyi-modules/ruoyi-aihr/pom.xml \
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPdfPageRenderer.java \
backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPdfPageRendererTest.java
git commit -m "feat(personal): render bounded PDF OCR pages"
```
### Task 3: Shared vision OCR boundary
**Files:**
- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVisionOcrService.java`
- Create: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalVisionOcrServiceTest.java`
- [ ] **Step 1: Write failing gateway tests**
Test runtime resolution order (`vision` before `chat`), disabled cost guard, missing runtime, HTTP failure, and normalized OCR text. Use an injected HTTP caller instead of a real provider.
```java
assertEquals("第一条\n第二条", service.recognize(jpeg, "image/jpeg", 3));
assertThrows(OcrUnavailableException.class, () -> disabledService.recognize(jpeg, "image/jpeg", 3));
```
- [ ] **Step 2: Run and verify RED**
```bash
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest=PersonalVisionOcrServiceTest test
```
Expected: compilation failure because the service is absent.
- [ ] **Step 3: Implement the gateway**
Query enabled model configuration with category order `vision`, then `chat`. Build the same OpenAI-compatible multimodal request used by the existing knowledge OCR, with temperature 0 and the exact extraction prompt:
```text
忠实提取本页全部可见文字,保留标题、段落和表格行顺序;不要总结、解释或补写。无可识别文字时返回空字符串。
```
Honor `AIHR_AI_RUNTIME_ENABLED` and `AIHR_AI_CHAT_ENABLED`; bound connect/request timeouts and response bytes.
- [ ] **Step 4: Run and verify GREEN**
Run the focused test. Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalVisionOcrService.java \
backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalVisionOcrServiceTest.java
git commit -m "feat(personal): add vision OCR gateway"
```
### Task 4: OCR job orchestration and publication
**Files:**
- Create: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPdfOcrService.java`
- Create: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPdfOcrServiceTest.java`
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java`
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionWorkerTest.java`
- [ ] **Step 1: Write failing orchestration tests**
Cover these exact behaviors with owner-scoped SQL verification:
```java
// Empty PDF text creates a job instead of PERSONAL_PARSE_EMPTY.
assertTrue(worker.processNext());
verify(ocr).enqueue(eq(item), any(byte[].class));
// One claim never exceeds 20 pages.
assertEquals(20, service.claimBatch(jobId).pageNumbers().size());
// Partial success publishes only after terminal aggregation.
assertEquals("READY", terminalItemStatus);
assertEquals(List.of(4, 7), failedPageNumbers);
```
Add tests for 201 pages, all pages failing, process interruption, idempotent page upsert, and max three attempts.
- [ ] **Step 2: Run tests and verify RED**
Run both focused test classes. Expected: failures because the OCR orchestration API does not exist and the worker still emits `PERSONAL_PARSE_EMPTY`.
- [ ] **Step 3: Implement job lifecycle**
Implement owner-scoped methods:
```java
void enqueue(Item item, byte[] pdfBytes);
boolean processNextBatch();
OcrProgressResponse progress(PersonalOwner owner, long itemId);
OcrProgressResponse retryFailedPages(PersonalOwner owner, long itemId);
```
Use conditional SQL updates to claim one job. Render/recognize at most 20 pages, upsert each page result, recompute counters, and release the job to `PENDING` when pages remain. On terminal completion aggregate successful page text in page order, call the existing fragment publication path, and set `PERSONAL_OCR_PARTIAL` only when failed pages remain.
- [ ] **Step 4: Integrate with the worker**
Change only the empty-PDF branch:
```java
if (chunks.isEmpty() && isPdf(item)) {
pdfOcrService.enqueue(item, stored.bytes());
return true;
}
```
Schedule `processNextBatch()` on the existing personal ingestion scheduler. Ordinary PDFs and all non-PDF formats keep the current path.
- [ ] **Step 5: Run focused and full personal tests**
```bash
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest='Personal*Test' test
```
Expected: all personal tests PASS.
- [ ] **Step 6: Commit**
```bash
git add backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPdfOcrService.java \
backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalIngestionWorker.java \
backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPdfOcrServiceTest.java \
backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalIngestionWorkerTest.java
git commit -m "feat(personal): process scanned PDFs in OCR batches"
```
### Task 5: Progress, retry and deletion contracts
**Files:**
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalSpaceService.java`
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java`
- Modify: `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalCleanupService.java`
- Modify tests: `PersonalSpaceServiceTest.java`, `PersonalAssistantControllerTest.java`, `PersonalCleanupServiceTest.java`
- [ ] **Step 1: Write failing contract tests**
Assert item detail includes OCR progress, `POST /items/{id}/ocr/retry-failed` is owner-scoped, a non-OCR item rejects OCR retry, and cleanup deletes page rows before job rows.
- [ ] **Step 2: Run focused tests and verify RED**
Expected: DTO/controller/cleanup assertions fail.
- [ ] **Step 3: Implement progress/retry/cleanup**
Join OCR progress into item detail without multiplying list rows. Add:
```java
@PostMapping("/items/{id}/ocr/retry-failed")
public R<OcrProgressResponse> retryFailedOcrPages(@PathVariable long id) {
return R.ok(pdfOcrService.retryFailedPages(owner(), id));
}
```
Delete `aihr_personal_ocr_page` then `aihr_personal_ocr_job` in the existing cleanup transaction.
- [ ] **Step 4: Run focused tests and verify GREEN**
Expected: all three focused test classes PASS.
- [ ] **Step 5: Commit**
Commit backend contract and cleanup files with message `feat(personal): expose OCR progress and retry`.
### Task 6: Mobile progress UI
**Files:**
- Modify: `mobile-uni/src/services/personal-assistant.ts`
- Modify: `mobile-uni/src/pages/user/assistant/item.vue`
- Modify/Create matching Vitest tests under `mobile-uni/src/**/*.spec.ts`
- [ ] **Step 1: Write failing TypeScript tests**
Assert `ocrProgressText()` returns:
```text
正在识别扫描 PDF:20/86 页
已收录,2 页识别失败
文件超过 200 页,请拆分后重新上传
```
and that failed-page retry calls `/items/{id}/ocr/retry-failed`.
- [ ] **Step 2: Run and verify RED**
```bash
npm --prefix mobile-uni run test:unit
```
Expected: tests fail because OCR fields/helpers are absent.
- [ ] **Step 3: Implement minimal UI**
Extend `PersonalItem` with optional OCR progress, show a progress bar/copy in `item.vue`, poll only while item/OCR status is active, and show “重试失败页” only when `failedPages > 0`.
- [ ] **Step 4: Run tests, typecheck and H5 build**
```bash
npm --prefix mobile-uni run test:unit
npm --prefix mobile-uni run typecheck
npm --prefix mobile-uni run build:h5
```
Expected: all commands PASS.
- [ ] **Step 5: Commit**
Commit the service, page and test files with message `feat(mobile): show scanned PDF OCR progress`.
### Task 7: Migration, real PDF smoke and documentation
**Files:**
- Modify: `scripts/personal-assistant-smoke.sh`
- Modify: `docs/个人AI助理阶段二开发推进计划.md`
- Modify: `docs/个人AI助理阶段二专项TechSpec.md`
- [ ] **Step 1: Add smoke assertions before production verification**
Extend the smoke script to assert OCR tables exist and, when `AIHR_PERSONAL_SCANNED_PDF` is set, upload that file, wait for OCR terminal state, require `READY`, run a personal-domain search against extracted text, then delete and verify OCR/OSS cleanup.
- [ ] **Step 2: Import the migration without resetting other data**
```bash
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \
< backend/script/sql/aihr_personal_knowledge_mysql8.sql
```
- [ ] **Step 3: Run backend and ordinary smoke regression**
```bash
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -Dtest='Personal*Test' test
AIHR_PERSONAL_API_URL=https://personal-assistant-phase2.wygj-api.localhost \
./scripts/personal-assistant-smoke.sh
```
Expected: all personal tests and existing TEXT/PDF/URL smoke pass.
- [ ] **Step 4: Run the real scanned PDF gate**
```bash
AIHR_PERSONAL_API_URL=https://personal-assistant-phase2.wygj-api.localhost \
AIHR_PERSONAL_SCANNED_PDF='/Users/yuanjiantsui/workspace/项目-物业AI/补充资料/关于修订证书管理办法的通知.pdf' \
./scripts/personal-assistant-smoke.sh
```
Expected: 8 pages processed, item reaches `READY`, a query hits the item, and all temporary DB/vector/OSS rows are cleaned.
- [ ] **Step 5: Browser verification**
Upload the same PDF from `/h5/#/pages/user/assistant/capture`, verify progress on item detail, final `READY`, searchable citation, then delete it and verify it disappears immediately.
- [ ] **Step 6: Update docs and commit**
Document the 20-page batch, 200-page maximum, progress states, partial success and failed-page retry. Run `git diff --check`, then commit with message `docs(personal): document scanned PDF OCR`.
### Task 8: Final verification
**Files:** none beyond prior tasks.
- [ ] **Step 1: Run complete backend module tests**
```bash
mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -am test
```
- [ ] **Step 2: Run mobile checks**
```bash
npm --prefix mobile-uni run test:unit
npm --prefix mobile-uni run typecheck
npm --prefix mobile-uni run build:h5
```
- [ ] **Step 3: Verify repository hygiene**
```bash
git diff --check
git status --short
```
Expected: no whitespace errors; only intentional uncommitted files, preferably none.
- [ ] **Step 4: Record remaining external-model boundary**
If no enabled vision/chat model is configured locally, record the real OCR gate as unverified and retain the explicit `PERSONAL_OCR_MODEL_UNAVAILABLE` behavior. Do not substitute fake OCR text.
@@ -0,0 +1,162 @@
# 个人 AI 助理扫描 PDF OCR 设计
## 1. 背景与目标
个人 AI 助理已经支持 PDF 收藏、私有 OSS、异步解析和个人知识检索,但当前解析器只提取 PDF 文本层。扫描件没有文本层时,资料会进入 `FAILED / PERSONAL_PARSE_EMPTY`。
本次目标是在不改变普通 PDF 快速路径、不引入 Tesseract 等新 OCR 服务的前提下,复用现有视觉模型,为扫描 PDF 提供异步、分批、可观测、可重试的完整 OCR。
## 2. 范围
### 包含
- 仅在 PDF 文本提取结果为空时触发视觉 OCR。
- 按每批 20 页处理,持续处理到整份文件完成。
- 单文件最多 200 页;超过时明确失败并提示拆分文件。
- 记录总页数、已处理页数、成功页数、失败页数和失败页码。
- 全部批次完成后再将资料置为 `READY` 并写入检索片段。
- 单页失败不终止整份文件;完成后允许只重试失败页。
- 删除资料时清理 OCR 中间结果、最终片段和私有 OSS 对象。
### 不包含
- 不对已有文本层的 PDF 再做 OCR。
- 不新增第三方 OCR 供应商或 Tesseract 依赖。
- 不在同步上传请求中执行 PDF 渲染或视觉模型调用。
- 不允许部分 OCR 内容在资料 `READY` 前进入检索。
## 3. 方案选择
采用“现有异步解析 Worker + PDF OCR 批次状态表”的方案。
未采用的方案:
- 单次 Worker 内完整 OCR:实现简单,但长文件会长期占用 worker,进程重启后难以从页级进度恢复。
- 只识别前 20 页:成本可控,但会永久丢失用户资料内容,不符合个人知识库完整性要求。
- 本地 Tesseract:增加部署依赖,且与项目现有视觉 OCR 技术边界不一致。
## 4. 架构与组件
### 4.1 `PersonalIngestionWorker`
保持普通文档现有行为。PDF 文本层解析为空时:
1. 获取 PDF 页数。
2. 页数超过 200 时,将资料置为 `FAILED / PERSONAL_PDF_PAGE_LIMIT`。
3. 创建或恢复 OCR 任务,将资料保持为 `PARSING`。
4. 每次 worker 调度领取一个最多 20 页的批次。
5. 批次完成后继续排队下一批,直到所有页处理结束。
6. 聚合成功页面文本,统一切片、写入 `aihr_personal_fragment`,最后置为 `READY`。
### 4.2 PDF 页面渲染器
新增单一职责组件 `PersonalPdfPageRenderer`:
- 输入 PDF 字节和页码范围。
- 使用项目现有 PDFBox 依赖将页面渲染成受限分辨率 JPEG/PNG。
- 对单页像素尺寸和输出字节设置上限,防止压缩炸弹和内存失控。
- 不负责模型调用、数据库写入或状态流转。
### 4.3 视觉 OCR 适配器
新增 `PersonalVisionOcr` 接口,生产实现复用现有 `category=vision`,缺失时按项目规则回退启用的 `category=chat` OpenAI-compatible 模型。
- 输入:渲染后的单页图片、页码。
- 输出:规范化文本;空文本视为该页失败。
- Prompt 只要求忠实提取文字、保留标题与段落,不总结、不补写。
- 模型未配置时,资料置为 `FAILED / PERSONAL_OCR_MODEL_UNAVAILABLE`,不返回假内容。
### 4.4 OCR 状态表
新增 `aihr_personal_ocr_job`,按 `tenant_id + owner_user_id + item_id` 隔离:
- `total_pages`
- `processed_pages`
- `success_pages`
- `failed_pages`
- `failed_page_numbers_json`
- `next_page`
- `status`: `PENDING/RUNNING/RETRY/COMPLETED/FAILED`
- `attempt_count`
- `last_error_code`
- 时间字段
新增 `aihr_personal_ocr_page` 保存页级中间结果:
- 所属 item、页码、状态、OCR 文本、尝试次数、脱敏错误码。
- 唯一键为 `tenant_id + owner_user_id + item_id + page_number`。
- 中间文本只用于最终聚合,不进入检索接口。
## 5. 数据流
1. H5 上传 PDF,接口仍立即返回 `QUEUED`。
2. Worker 下载私有 OSS 对象并执行普通 PDF 文本解析。
3. 有文本:沿用当前解析、切片和 `READY` 流程。
4. 无文本:创建 OCR job,资料进入扫描 PDF OCR 流程。
5. Worker 领取 20 页批次,逐页渲染、调用视觉模型、保存页级结果并更新进度。
6. 批次结束后释放 worker;后续调度继续领取下一批。
7. 全部页面处理结束:
- 至少一页成功:按页码聚合文本,写最终片段;若有失败页,资料仍为 `READY`,同时保留 `PERSONAL_OCR_PARTIAL` 提示信息。
- 全部页面失败:资料置为 `FAILED / PERSONAL_OCR_EMPTY`。
8. 用户在资料详情查看进度和失败页,可触发“重试失败页”。
## 6. 状态与前端表现
资料详情响应增加可选 OCR 字段,旧客户端可忽略:
- `ocrRequired`
- `ocrTotalPages`
- `ocrProcessedPages`
- `ocrSuccessPages`
- `ocrFailedPages`
- `ocrFailedPageNumbers`
- `ocrStatus`
H5 展示:
- `PARSING`:`正在识别扫描 PDF:20/86 页`。
- 部分成功:`已收录,2 页识别失败`,提供“重试失败页”。
- 超过 200 页:`文件超过 200 页,请拆分后重新上传`。
- 无视觉模型:`扫描 PDF 识别服务未配置`。
## 7. 错误与恢复
- 单页模型超时或空结果:记录页级失败,继续下一页。
- 批次进程中断:通过 job 的 `next_page` 和页级唯一键幂等恢复。
- 重复调度:领取 job 时使用状态条件更新,避免两个 worker 同时处理同一批次。
- 重试只处理失败页,不重复调用已成功页面。
- 错误信息只保存受控错误码,不落模型原始响应、密钥或完整堆栈。
- 删除资料后,未开始的 worker 通过 owner/item/status 条件失去领取资格;清理任务删除 OCR job/page。
## 8. 成本与资源边界
- 每批 20 页。
- 单文件最多 200 页。
- 同一资料同一页默认最多 3 次 OCR 尝试。
- 页面渲染分辨率和图片字节设置固定上限。
- 继续遵守 `AIHR_AI_RUNTIME_ENABLED` 与 `AIHR_AI_CHAT_ENABLED` 成本闸门;关闭时不外发 OCR 请求。
## 9. 测试策略
按 TDD 实现:
1. 普通文本 PDF 不调用 OCR。
2. 扫描 PDF 文本为空时创建 OCR job。
3. 201 页 PDF 返回明确页数上限错误。
4. 每次只领取最多 20 页。
5. 批次中断后从未完成页继续,成功页不重复调用。
6. 单页失败不阻断其他页;最终状态与成功/失败计数正确。
7. 全部失败时资料为 `FAILED`,部分成功时资料为 `READY` 并带失败页提示。
8. 重试只处理失败页。
9. 删除资料清理 OCR 中间结果。
10. 双用户、双租户不能读取或重试对方 OCR 任务。
11. 用 `关于修订证书管理办法的通知.pdf` 做本地真实烟测:8 页全部处理,最终 `READY`,可检索并带个人引用。
## 10. 验收标准
- 现有普通 PDF、文本、网页收藏回归不受影响。
- 8 页扫描 PDF 可以异步进入 `READY`,页面显示真实进度。
- 21 页以上文件能跨批次继续,不截断剩余页面。
- 超过 200 页明确拒绝,不静默截断。
- OCR 中间文本在完成前不可检索。
- 失败页可单独重试,删除后无 DB、向量或 OSS 残留。