# 文档图片质量校验模块第3版接口与落地清单

## 1. 目标边界

本模块用于对案卷文档中的拍照图片、扫描图片、附件图片做“图片清晰度预警检测”。

本模块的边界固定如下：

- 与现有 OCR 抽取、评查主流程完全独立。
- 即使检测出图片模糊，也不能阻断上传、不能阻断 OCR、不能阻断评查。
- 结果只做预警展示和留痕，分三档：
  - `pass`：通过
  - `review`：疑似模糊待人工确认
  - `reject`：不通过需重拍
- 第一版优先支持：
  - 图片文件
  - PDF / 扫描 PDF
  - 图片附件
  - PDF 附件
- `doc/docx/wps` 第一版允许降级为“正文第 N 张图”定位，不强承诺精确页码。

## 2. 建议代码落点

### 2.1 后端目录

建议新增如下目录与文件：

- `fastapi_modules/fastapi_leaudit/domian/vo/imageQualityVo.py`
- `fastapi_modules/fastapi_leaudit/services/imageQualityService.py`
- `fastapi_modules/fastapi_leaudit/services/impl/imageQualityServiceImpl.py`
- `fastapi_modules/fastapi_leaudit/controllers/imageQualityController.py`
- `fastapi_modules/fastapi_leaudit/image_quality/tasks.py`
- `fastapi_modules/fastapi_leaudit/image_quality/runner.py`
- `fastapi_modules/fastapi_leaudit/image_quality/storage_adapter.py`
- `fastapi_modules/fastapi_leaudit/image_quality/input_resolver.py`
- `fastapi_modules/fastapi_leaudit/image_quality/extractors.py`
- `fastapi_modules/fastapi_leaudit/image_quality/detector.py`
- `fastapi_modules/fastapi_leaudit/image_quality/config_resolver.py`

### 2.2 现有文件改动点

- `fastapi_modules/fastapi_leaudit/controllers/documentController.py`
- `fastapi_modules/fastapi_leaudit/services/documentService.py`
- `fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py`
- `fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py`
- `fastapi_admin/config/_settings.py`
- `fastapi_admin/config/__init__.pyi`

## 3. 新增 VO 建议

按当前仓库 `documentVo.py / auditVo.py / reviewPointVo.py` 风格，建议单独新增 `imageQualityVo.py`。

### 3.1 配置相关 VO

```python
from pydantic import BaseModel, Field


class ImageQualityConfigItemVO(BaseModel):
    """图片质量校验配置项。"""

    id: int = Field(..., description="配置ID")
    scopeType: str = Field(..., description="作用域类型：global/doc_type/region/doc_type_region")
    documentTypeId: int | None = Field(None, description="文档类型ID")
    region: str | None = Field(None, description="地区")
    enabled: bool = Field(..., description="是否启用")
    warnThreshold: float | None = Field(None, description="疑似模糊阈值")
    rejectThreshold: float | None = Field(None, description="不通过阈值")
    maxImagesPerDoc: int | None = Field(None, description="单文档最大检测图片数")
    maxConcurrency: int | None = Field(None, description="单任务并发数")
    updatedAt: str | None = Field(None, description="更新时间")


class ImageQualityConfigUpsertDTO(BaseModel):
    """图片质量校验配置新增/更新请求。"""

    scopeType: str = Field(..., description="作用域类型")
    documentTypeId: int | None = Field(None, description="文档类型ID")
    region: str | None = Field(None, description="地区")
    enabled: bool = Field(..., description="是否启用")
    warnThreshold: float | None = Field(None, description="疑似模糊阈值")
    rejectThreshold: float | None = Field(None, description="不通过阈值")
    maxImagesPerDoc: int | None = Field(None, description="单文档最大检测图片数")
    maxConcurrency: int | None = Field(None, description="单任务并发数")
```

### 3.2 明细与摘要相关 VO

```python
class ImageQualityItemVO(BaseModel):
    """单张图片质检明细。"""

    itemId: int = Field(..., description="明细ID")
    runId: int = Field(..., description="质检运行ID")
    documentId: int = Field(..., description="文档ID")
    documentFileId: int | None = Field(None, description="文档文件ID")
    sourceKind: str = Field(..., description="来源类型")
    sourceFileName: str | None = Field(None, description="来源文件名")
    sourcePageNum: int | None = Field(None, description="来源页码")
    imageIndexInPage: int | None = Field(None, description="页内图片序号")
    imageIndexInFile: int | None = Field(None, description="文件内图片序号")
    bbox: dict | list | None = Field(None, description="图片定位框")
    qualityStatus: str = Field(..., description="pass/review/reject")
    qualityScore: float | None = Field(None, description="清晰度分值")
    reasonCode: str | None = Field(None, description="原因编码")
    reasonText: str | None = Field(None, description="原因说明")
    cropOssUrl: str | None = Field(None, description="裁剪图OSS地址")
    displayText: str | None = Field(None, description="展示文案，例如第12页第1张图片模糊")


class ImageQualitySummaryVO(BaseModel):
    """文档图片质检摘要。"""

    runId: int | None = Field(None, description="最新运行ID")
    runStatus: str | None = Field(None, description="queued/running/completed/failed/skipped")
    summaryStatus: str | None = Field(None, description="pass/review/reject")
    skipReason: str | None = Field(None, description="跳过原因")
    totalImages: int = Field(0, description="总图片数")
    passCount: int = Field(0, description="通过数")
    reviewCount: int = Field(0, description="待人工确认数")
    rejectCount: int = Field(0, description="需重拍数")
    warningText: str | None = Field(None, description="摘要提示文案")
    finishedAt: str | None = Field(None, description="完成时间")


class ImageQualityDetailVO(BaseModel):
    """文档图片质检详情。"""

    summary: ImageQualitySummaryVO = Field(..., description="质检摘要")
    items: list[ImageQualityItemVO] = Field(default_factory=list, description="问题图片列表")
```

### 3.3 批量状态与重检相关 VO

```python
class ImageQualityStatusItemVO(BaseModel):
    """文档图片质检状态项。"""

    documentId: int = Field(..., description="文档ID")
    runId: int | None = Field(None, description="最新运行ID")
    runStatus: str | None = Field(None, description="运行状态")
    summaryStatus: str | None = Field(None, description="摘要状态")
    rejectCount: int = Field(0, description="需重拍数")
    reviewCount: int = Field(0, description="待确认数")
    updatedAt: str | None = Field(None, description="更新时间")


class ImageQualityRecheckVO(BaseModel):
    """手工重检响应。"""

    runId: int = Field(..., description="新质检运行ID")
    documentId: int = Field(..., description="文档ID")
    status: str = Field(..., description="queued")
    message: str = Field("", description="提示信息")
```

## 4. 对现有 Document VO 的扩展建议

建议直接扩充 `documentVo.py`，这样上传页、列表页、详情页可以少开新接口或少拼数据。

### 4.1 `DocumentUploadVO` 增加字段

```python
imageQualityEnabled: bool = Field(False, description="当前文档是否启用图片质量校验")
imageQualityRunId: int | None = Field(None, description="图片质量校验运行ID")
imageQualityRunStatus: str | None = Field(None, description="图片质量校验运行状态")
imageQualitySummaryStatus: str | None = Field(None, description="图片质量校验摘要状态")
```

### 4.2 `DocumentStatusItemVO` 增加字段

```python
imageQualityRunId: int | None = Field(None, description="图片质量校验运行ID")
imageQualityRunStatus: str | None = Field(None, description="图片质量校验运行状态")
imageQualitySummaryStatus: str | None = Field(None, description="图片质量校验摘要状态")
imageQualityRejectCount: int = Field(0, description="需重拍图片数")
imageQualityReviewCount: int = Field(0, description="待人工确认图片数")
```

### 4.3 `DocumentListItemVO` 增加字段

```python
imageQualityRunId: int | None = Field(None, description="图片质量校验运行ID")
imageQualityRunStatus: str | None = Field(None, description="图片质量校验运行状态")
imageQualitySummaryStatus: str | None = Field(None, description="图片质量校验摘要状态")
imageQualityIssueCount: int = Field(0, description="问题图片数")
imageQualityWarningText: str | None = Field(None, description="图片质量提示文案")
```

### 4.4 `DocumentDetailVO` 增加字段

```python
imageQualitySummary: ImageQualitySummaryVO | None = Field(None, description="图片质量摘要")
```

## 5. 新增 Service 接口签名

建议新建 `services/imageQualityService.py`，不要污染 `IDocumentService` 主职责。

```python
from abc import ABC, abstractmethod

from fastapi_modules.fastapi_leaudit.domian.vo.imageQualityVo import (
    ImageQualityConfigItemVO,
    ImageQualityConfigUpsertDTO,
    ImageQualityDetailVO,
    ImageQualityRecheckVO,
    ImageQualityStatusItemVO,
    ImageQualitySummaryVO,
)


class IImageQualityService(ABC):
    """图片质量校验服务接口。"""

    @abstractmethod
    async def DispatchForDocument(
        self,
        DocumentId: int,
        TriggerUserId: int | None = None,
        Force: bool = False,
        Speed: str = "normal",
    ) -> ImageQualityRecheckVO | None:
        """按文档触发图片质量校验任务。"""
        ...

    @abstractmethod
    async def GetDocumentSummary(
        self,
        CurrentUserId: int,
        DocumentId: int,
    ) -> ImageQualitySummaryVO:
        """获取文档图片质检摘要。"""
        ...

    @abstractmethod
    async def GetDocumentDetail(
        self,
        CurrentUserId: int,
        DocumentId: int,
    ) -> ImageQualityDetailVO:
        """获取文档图片质检详情。"""
        ...

    @abstractmethod
    async def GetDocumentsStatus(
        self,
        CurrentUserId: int,
        Ids: list[int],
    ) -> list[ImageQualityStatusItemVO]:
        """批量获取文档图片质检状态。"""
        ...

    @abstractmethod
    async def RecheckDocument(
        self,
        CurrentUserId: int,
        DocumentId: int,
        Speed: str = "normal",
    ) -> ImageQualityRecheckVO:
        """手工重跑文档图片质量校验。"""
        ...

    @abstractmethod
    async def ListConfigs(self) -> list[ImageQualityConfigItemVO]:
        """获取图片质量校验配置列表。"""
        ...

    @abstractmethod
    async def UpsertConfig(self, Body: ImageQualityConfigUpsertDTO) -> ImageQualityConfigItemVO:
        """新增或更新图片质量校验配置。"""
        ...

    @abstractmethod
    async def DeleteConfig(self, Id: int) -> None:
        """删除图片质量校验配置。"""
        ...
```

## 6. 对现有 DocumentService 的接入点建议

建议只在现有 `IDocumentService / DocumentServiceImpl` 中补“触发点”，不要把查询逻辑也塞进去。

### 6.1 `DocumentServiceImpl.Upload`

在上传主文件和附件落库完成后，补一段：

```python
await self.ImageQualityService.DispatchForDocument(
    DocumentId=document.Id,
    TriggerUserId=CreatedBy,
    Force=False,
    Speed=Speed,
)
```

要求：

- try/except 包裹
- 失败只记 warning log
- 不影响现有上传成功返回

### 6.2 `DocumentServiceImpl.AppendAttachments`

在追加附件成功后，补一段：

```python
await self.ImageQualityService.DispatchForDocument(
    DocumentId=Id,
    TriggerUserId=CurrentUserId,
    Force=True,
    Speed="normal",
)
```

原因：

- 附件变化后，图片质检结果已过期
- 需要按最新附件重新跑

## 7. Controller 路由建议

建议新建 `controllers/imageQualityController.py`，风格保持与 `documentController.py / auditController.py` 一致。

### 7.1 文档级路由

```python
@self.router.get("/documents/{DocumentId}/image-quality/summary", response_model=Result[ImageQualitySummaryVO])
async def GetDocumentImageQualitySummary(
    DocumentId: int,
    payload: dict[str, Any] = Depends(verify_access_token),
):
    """获取单个文档图片质量校验摘要。"""


@self.router.get("/documents/{DocumentId}/image-quality", response_model=Result[ImageQualityDetailVO])
async def GetDocumentImageQualityDetail(
    DocumentId: int,
    payload: dict[str, Any] = Depends(verify_access_token),
):
    """获取单个文档图片质量校验详情。"""


@self.router.post("/documents/{DocumentId}/image-quality/recheck", response_model=Result[ImageQualityRecheckVO])
async def RecheckDocumentImageQuality(
    DocumentId: int,
    speed: str = Form("normal", description="执行速度档位：urgent/normal"),
    payload: dict[str, Any] = Depends(verify_access_token),
):
    """手工重跑单个文档图片质量校验。"""
```

### 7.2 批量状态路由

```python
@self.router.get("/documents/image-quality/status", response_model=Result[list[ImageQualityStatusItemVO]])
async def GetDocumentsImageQualityStatus(
    ids: str = Query(..., description="逗号分隔的文档ID列表"),
    payload: dict[str, Any] = Depends(verify_access_token),
):
    """批量获取文档图片质量校验状态。"""
```

### 7.3 配置管理路由

如果第一期不做后台配置页，可以先只保留 service，不先开放 controller。

如果要开接口，建议：

```python
@self.router.get("/v3/image-quality/configs", response_model=Result[list[ImageQualityConfigItemVO]])
async def ListImageQualityConfigs():
    """获取图片质量校验配置。"""


@self.router.post("/v3/image-quality/configs", response_model=Result[ImageQualityConfigItemVO])
async def UpsertImageQualityConfig(Body: ImageQualityConfigUpsertDTO):
    """新增或更新图片质量校验配置。"""


@self.router.delete("/v3/image-quality/configs/{ConfigId}", response_model=Result[None])
async def DeleteImageQualityConfig(ConfigId: int):
    """删除图片质量校验配置。"""
```

## 8. Celery 任务与函数签名建议

新增文件：`fastapi_modules/fastapi_leaudit/image_quality/tasks.py`

```python
def resolve_image_quality_queue(speed: str = "normal") -> str:
    """根据优先级返回图片质量校验队列名。"""


def dispatch_image_quality_task(
    run_id: int,
    *,
    speed: str = "normal",
    trigger_user_id: int | None = None,
) -> Any:
    """投递图片质量校验任务。"""


@celery_app.task(
    bind=True,
    name="leaudit.image_quality.process_document",
    acks_late=True,
)
def image_quality_process_document_task(
    self,
    run_id: int,
    trigger_user_id: int | None = None,
) -> dict[str, Any]:
    """Celery worker 入口。"""
```

新增文件：`fastapi_modules/fastapi_leaudit/image_quality/runner.py`

```python
class ImageQualityRunner:
    """图片质量校验执行器。"""

    async def Execute(
        self,
        RunId: int,
        TriggerUserId: int | None = None,
    ) -> dict[str, Any]:
        """执行一次完整的图片质量校验。"""
```

## 9. SQL 草案文件命名建议

按你当前仓库习惯，建议直接放到：

- `scripts/创建sql/`

建议拆 3 个 SQL 草案文件。

### 9.1 表结构 SQL

文件名：

- `scripts/创建sql/schema_add_image_quality_module.sql`

内容范围：

- `leaudit_image_quality_configs`
- `leaudit_image_quality_runs`
- `leaudit_image_quality_items`
- 索引
- 唯一约束
- 软删除字段

### 9.2 权限点 SQL

文件名：

- `scripts/创建sql/seed_image_quality_permissions.sql`

内容范围：

- 图片质量校验查询权限
- 图片质量校验重检权限
- 图片质量配置管理权限

### 9.3 路由/入口 SQL

文件名：

- `scripts/创建sql/seed_image_quality_routes.sql`

内容范围：

- 新增接口路由资源
- 如需配置后台菜单，再补菜单项

## 10. 建议表结构草案

### 10.1 `leaudit_image_quality_configs`

建议字段：

- `id BIGSERIAL PRIMARY KEY`
- `scope_type VARCHAR(32) NOT NULL`
- `document_type_id BIGINT NULL`
- `region VARCHAR(64) NULL`
- `enabled BOOLEAN NOT NULL DEFAULT TRUE`
- `warn_threshold NUMERIC(10,4) NULL`
- `reject_threshold NUMERIC(10,4) NULL`
- `max_images_per_doc INTEGER NULL`
- `max_concurrency INTEGER NULL`
- `created_at TIMESTAMP NOT NULL DEFAULT NOW()`
- `updated_at TIMESTAMP NOT NULL DEFAULT NOW()`
- `deleted_at TIMESTAMP NULL`

建议索引：

- `idx_leaudit_image_quality_configs_scope`
- `idx_leaudit_image_quality_configs_doc_type`
- `idx_leaudit_image_quality_configs_region`

### 10.2 `leaudit_image_quality_runs`

建议字段：

- `id BIGSERIAL PRIMARY KEY`
- `document_id BIGINT NOT NULL`
- `document_file_id BIGINT NULL`
- `status VARCHAR(32) NOT NULL DEFAULT 'queued'`
- `skip_reason VARCHAR(64) NULL`
- `summary_status VARCHAR(32) NULL`
- `total_images INTEGER NOT NULL DEFAULT 0`
- `pass_count INTEGER NOT NULL DEFAULT 0`
- `review_count INTEGER NOT NULL DEFAULT 0`
- `reject_count INTEGER NOT NULL DEFAULT 0`
- `task_id VARCHAR(255) NULL`
- `error_message TEXT NULL`
- `started_at TIMESTAMP NULL`
- `finished_at TIMESTAMP NULL`
- `created_by BIGINT NULL`
- `created_at TIMESTAMP NOT NULL DEFAULT NOW()`
- `updated_at TIMESTAMP NOT NULL DEFAULT NOW()`
- `deleted_at TIMESTAMP NULL`

建议索引：

- `idx_leaudit_image_quality_runs_document_id`
- `idx_leaudit_image_quality_runs_status`
- `idx_leaudit_image_quality_runs_document_created_at`

### 10.3 `leaudit_image_quality_items`

建议字段：

- `id BIGSERIAL PRIMARY KEY`
- `run_id BIGINT NOT NULL`
- `document_id BIGINT NOT NULL`
- `document_file_id BIGINT NULL`
- `source_kind VARCHAR(64) NOT NULL`
- `source_file_name VARCHAR(255) NULL`
- `source_page_num INTEGER NULL`
- `image_index_in_page INTEGER NULL`
- `image_index_in_file INTEGER NULL`
- `bbox_json JSONB NULL`
- `image_key VARCHAR(255) NULL`
- `parent_image_key VARCHAR(255) NULL`
- `crop_oss_url TEXT NULL`
- `quality_status VARCHAR(32) NOT NULL`
- `quality_score NUMERIC(10,4) NULL`
- `reason_code VARCHAR(64) NULL`
- `reason_text TEXT NULL`
- `extra_json JSONB NULL`
- `created_at TIMESTAMP NOT NULL DEFAULT NOW()`
- `updated_at TIMESTAMP NOT NULL DEFAULT NOW()`
- `deleted_at TIMESTAMP NULL`

建议索引：

- `idx_leaudit_image_quality_items_run_id`
- `idx_leaudit_image_quality_items_document_id`
- `idx_leaudit_image_quality_items_quality_status`
- `idx_leaudit_image_quality_items_source_page_num`

## 11. 配置项建议

在 `fastapi_admin/config/_settings.py` 的 `LeauditSettings` 增加：

```python
LEAUDIT_IMAGE_QUALITY_ENABLED: bool = False
LEAUDIT_IMAGE_QUALITY_WARN_THRESHOLD: float = 0.45
LEAUDIT_IMAGE_QUALITY_REJECT_THRESHOLD: float = 0.30
LEAUDIT_IMAGE_QUALITY_MAX_IMAGES_PER_DOC: int = 80
LEAUDIT_IMAGE_QUALITY_MAX_CONCURRENCY: int = 4
LEAUDIT_IMAGE_QUALITY_QUEUE_NORMAL: str = "leaudit.image_quality.normal"
LEAUDIT_IMAGE_QUALITY_QUEUE_URGENT: str = "leaudit.image_quality.urgent"
LEAUDIT_IMAGE_QUALITY_TIMEOUT: int = 120
```

并同步补到 `fastapi_admin/config/__init__.pyi`。

## 12. 前端接口命名建议

建议前端 API 文件新增：

- `legal-platform-frontend/lib/api/legacy/files/image-quality.ts`

建议函数：

```ts
export async function getDocumentImageQualitySummary(documentId: number)
export async function getDocumentImageQualityDetail(documentId: number)
export async function getDocumentsImageQualityStatus(ids: number[])
export async function recheckDocumentImageQuality(documentId: number, speed?: string)
```

## 13. 第一版实施顺序

建议按下面顺序做：

1. 先补 SQL 与后端表模型
2. 再补 `imageQualityVo.py`
3. 再补 `IImageQualityService / ImageQualityServiceImpl`
4. 再补 `tasks.py / runner.py`
5. 再在 `DocumentServiceImpl.Upload / AppendAttachments` 挂触发
6. 再补 `imageQualityController.py`
7. 最后接前端上传页、列表页、详情页

## 14. 结论

按当前仓库风格，这个模块最稳妥的落地方式是：

- 新开独立 `VO + Service + Controller + Celery task + SQL`
- 只在 `DocumentServiceImpl` 做触发，不侵入现有 `AuditServiceImpl`
- 对外暴露文档级摘要、明细、批量状态、手工重检四类接口
- SQL 先拆成结构、权限、路由三个脚本，方便分阶段上线

这个拆法能保证图片质量校验模块后续可单独演进，不会把现有 OCR / 评查主链路拖乱。

## 15. 页码定位策略修正版

这一节用于修正第一版里对 `doc/docx/wps` 页码定位的表述，避免研发误以为可以直接靠当前 `python-docx` 兜底逻辑拿到真实页码。

### 15.1 现有 OCR / 评查链路里的页码定位是怎么来的

当前系统里，评查详情页能展示字段页码、部分情况下还能做定位高亮，主来源并不是“原始 Word 文档页码”，而是下面两层能力：

- 第一层：OCR/归一化结果自带页结构
  - `ocr_result.pages[].page_num`
  - OCR chunk 的 `bbox`
  - `field_positions` 里的 `pageNum / bbox / matchPosition`
- 第二层：详情页兜底文本匹配
  - 对 PDF 重新逐页抽文本，再把字段文本匹配回页码
  - 这是兜底逻辑，不是主定位来源

也就是说，当前评查模块“真正稳定可复用”的页码语义，主要来自 OCR / normalized document，而不是详情页自己的回推逻辑。

### 15.2 当前项目里 `docx` 的兜底页码能力并不是真分页

当前 `documentServiceImpl.py` 里的 `_extract_page_texts_from_docx()` 实现，实际是：

- 读取段落和表格文本
- 拼成整篇文档文本
- 最终只返回 `[(1, text)]`

这意味着：

- 当前详情页兜底逻辑对 `docx` 并没有真实分页能力
- 它只是把整篇文档退化成“第 1 页”
- 因此不能把这条逻辑当成 `docx` 图片定位的基础

结论要固定为：

- 当前系统里 `docx` 不是完全不能定位
- 但不能依赖现有 `python-docx` 兜底逻辑做精确页码
- 如果要把 `docx` 图片定位做准，必须优先复用 OCR / 归一化后的页结构

### 15.3 图片质量模块对 `docx` 的正确做法

图片质量模块在处理 `doc/docx/wps` 时，页码定位策略建议按优先级分层：

1. 优先复用 OCR 结果中的页结构和视觉对象定位
2. 其次复用 `visual_manifest` 中已有的视觉对象元数据
3. 如果 OCR 已能给出该图片或父图像所在页，则直接采用该页码
4. 如果拿不到稳定页码，则降级为“正文第 N 张图”或“附件第 N 张图”

推荐优先使用的字段包括：

- `ocr_result.pages[].page_num`
- `ocr_result.visual_manifest`
- `visual_manifest` 中对象的 `page_num`
- `visual_manifest` 中对象的 `bbox`
- `image_key / parent_image_key`

这样做的本质是：

- 不自己重新发明 Word 分页
- 而是复用现有 OCR pipeline 已经归一化出来的页概念

### 15.4 为什么图片质量模块不能直接复用详情页 `docx` 兜底逻辑

因为那条逻辑只适合“文本字段没有页码时，尽量补一个最小可用页码”，不适合图片质量场景。

图片质量场景要解决的是：

- 哪一张图片模糊
- 它在第几页
- 它在页内第几张
- 最好还能给出 bbox 或裁剪图

而当前 `docx` 兜底逻辑没有：

- 真分页
- 图片级索引
- 页内图片序号
- 图片 bbox

所以如果直接复用它，只会得到一个看似有页码、其实不可用的假定位。

### 15.5 图片质量模块的页码定位分级策略

建议在方案里明确写死：

- `PDF / 扫描PDF / 图片文件 / 图片附件 / PDF附件`
  - 第一优先级支持精确定位
  - 目标输出：`source_page_num + image_index_in_page + bbox`
- `doc/docx/wps`
  - 第一优先级复用 OCR / visual_manifest 的页码与 bbox
  - 第二优先级降级为 `image_index_in_file`
  - 第一版不承诺所有 Office 文档都能稳定精确到预览页码

也就是说，`docx` 可以做定位，但实现前提不是当前 `python-docx` 的退化逻辑，而是 OCR 归一化之后的页结构。

### 15.6 对图片质量模块的实现修正

基于上面的分析，图片质量模块实现时建议补一个约束：

- 对 `doc/docx/wps`，图片抽取和质量判断可以独立做
- 但页码回指优先放在“抽图后结合 OCR visual metadata 回填”这一层完成
- 不允许直接调用当前 `_extract_page_texts_from_docx()` 来生成图片页码

更具体地说：

- `extractors.py` 负责抽图和生成图片级唯一索引
- `detector.py` 负责清晰度三档判断
- `runner.py` 在汇总结果前，优先尝试将图片索引和 OCR/visual_manifest 做关联
- 关联成功则写入 `source_page_num / bbox`
- 关联失败则写入 `image_index_in_file`，前端展示“正文第 N 张图”

### 15.7 最终结论

本项目当前“评查页码定位”对 `docx` 的可用性，主要来自 OCR 归一化页结构，而不是 `python-docx` 的原生分页能力。

因此文档图片质量校验模块如果要把 `docx` 也做准，正确方向是：

- 参考现有 OCR / 评查流程的页结构设计
- 复用 `ocr_result.pages / visual_manifest / bbox / image_key`
- 避免把当前 `docx` 文本兜底逻辑误当成图片级定位能力

这一点在后续实施时应作为明确技术约束，不建议再走“直接解析 Word 文本推页码”的路线。

## 16. 图片索引表与 OCR visual_manifest 关联策略

这一节用于把图片质量模块里最关键的“抽图记录如何回绑到 OCR 页码与 bbox”讲清楚，尤其是 `doc/docx/wps` 场景。

### 16.1 为什么必须做关联层

图片质量模块自己的 `extractors.py` 会先把图片从原始文档里抽出来，并落到 `leaudit_image_quality_items`。

但抽图本身只能保证下面这些信息：

- 这张图片来自哪个文档文件
- 它是主文件还是附件
- 它是文件内第几张图
- 它在抽图阶段的原始宽高、字节、hash

仅靠这些信息，还不够支持前端稳定回指到：

- 第几页
- 页内第几张
- 具体 bbox

所以必须增加一层“图片索引表与 OCR visual metadata 的关联层”，把抽图记录和 OCR/visual_manifest 里的页码、坐标、父图像标识打通。

### 16.2 建议增加的落库字段

在 `leaudit_image_quality_items` 里，建议补充或明确以下字段：

- `image_sha256`
  - 抽图后二进制 hash
- `image_width`
  - 抽图宽度
- `image_height`
  - 抽图高度
- `source_ext`
  - 来源文件扩展名
- `ocr_page_num`
  - 关联到 OCR 后得到的页码
- `ocr_bbox_json`
  - 关联到 OCR 后得到的 bbox
- `ocr_image_key`
  - 关联到 OCR visual object 的 image_key
- `ocr_parent_image_key`
  - 关联到 OCR visual object 的 parent_image_key
- `ocr_match_mode`
  - 关联命中方式
- `ocr_match_score`
  - 关联命中置信度

说明：

- `source_page_num` 表示模块自己理解的来源页码
- `ocr_page_num` 表示从 OCR / visual_manifest 里回填的页码
- 第一版前端展示时，优先取 `ocr_page_num`，取不到再回退到 `source_page_num`

### 16.3 关联优先级建议

建议在 `runner.py` 里做一段统一的关联流程，优先级从高到低如下：

1. `image_key / parent_image_key` 直接命中
2. 图片二进制 hash 命中
3. 图片尺寸 + 裁剪相似度命中
4. 页级候选范围内按 bbox / area 比例匹配
5. 全部失败则降级为文件内序号定位

这套顺序的原因是：

- `image_key / parent_image_key` 一旦能命中，最稳定
- hash 命中次稳，但前提是 OCR side 和抽图 side 用的是同一图像字节
- 尺寸和相似度可以做弱匹配
- bbox/area 更适合已经知道页候选时做局部定位

### 16.4 各类文件的推荐关联策略

#### 16.4.1 PDF / 扫描PDF / PDF附件

推荐策略：

- 抽图阶段直接记录 `source_page_num`
- OCR 若返回同页 visual object，则用页内 bbox 做二次确认
- 最终定位以 `source_page_num + image_index_in_page + bbox` 为准

这一类文件通常不需要过度依赖 `visual_manifest`，因为自身抽图时就有稳定页概念。

#### 16.4.2 图片文件 / 图片附件

推荐策略：

- `source_page_num` 固定记为 `1`
- `image_index_in_page = 1`
- 若 OCR 输出 visual object，则仅作为 bbox 或 `image_key` 补充

这类文件定位最简单，本身就是单页单图。

#### 16.4.3 `doc/docx/wps`

推荐策略：

- 抽图阶段只保证：
  - `image_index_in_file`
  - `image_sha256`
  - `image_width / image_height`
  - `source_file_name`
- 页码不要在抽图阶段硬算
- 等 OCR/visual_manifest 结果出来后，再做回填

这一类文件的关联优先级建议是：

1. `parent_image_key` 命中
2. `image_key` 命中
3. hash 命中
4. 尺寸与内容相似度命中
5. 命不中则只展示“正文第 N 张图”

### 16.5 建议的 `ocr_match_mode` 枚举

建议在 `ocr_match_mode` 里固定以下值，方便后续排查：

- `parent_image_key_exact`
- `image_key_exact`
- `image_sha256_exact`
- `size_similarity_match`
- `bbox_overlap_match`
- `page_candidate_match`
- `file_index_fallback`
- `no_match`

这样后面如果业务反馈“页码不准”，可以直接按命中模式排查是哪一层出了问题。

### 16.6 `visual_manifest` 关联的实现建议

建议把这块逻辑放在 `image_quality/runner.py` 或单独的 `image_quality/locator.py` 中，职责清晰一点：

- `extractors.py`
  - 只负责抽图
  - 不负责猜页码
- `detector.py`
  - 只负责判断图片清晰度
- `locator.py` 或 `runner.py`
  - 负责把图片索引记录与 OCR `visual_manifest` 做关联
  - 回填 `ocr_page_num / ocr_bbox_json / ocr_match_mode`

也就是说，页码定位不是抽图逻辑的一部分，而是“抽图后 + OCR结果可用后”的补全逻辑。

### 16.7 为什么建议先落图片索引，再做关联

因为如果把“抽图、关联、检测”混在一起，会有两个问题：

- 当 OCR 结果未就绪时，图片质量模块会被迫等待主流程
- 当 OCR side 结构调整时，图片质量模块会被连带影响

正确拆法是：

1. 先独立抽图并持久化图片索引
2. 独立跑清晰度检测
3. 如果 OCR 结果已就绪，则补做 visual_manifest 关联
4. 如果 OCR 结果尚未就绪，则先以弱定位展示，后续可异步补齐

这样既保持了两条 pipeline 独立，又能最大化复用 OCR 页结构。

### 16.8 页面展示的取值优先级

前端展示“第几页/第几张图”时，建议按下面顺序取值：

1. `ocr_page_num + image_index_in_page`
2. `source_page_num + image_index_in_page`
3. `image_index_in_file`
4. 仅展示来源文件名

示例文案：

- `主文件第 12 页第 1 张图片模糊`
- `附件《现场照片2.pdf》第 3 页第 2 张图片疑似模糊`
- `正文第 5 张内嵌图片模糊`
- `附件《取证照片.jpg》图片模糊`

### 16.9 对第一版实施的建议收口

为了控制复杂度，第一版建议这样收：

- `PDF / 扫描PDF / 图片文件 / 图片附件 / PDF附件`
  - 做强定位
- `doc/docx/wps`
  - 先把 OCR `visual_manifest` 关联能力做出来
  - 关联成功就展示页码
  - 关联失败就降级到“正文第 N 张图”

不要第一版就要求所有 Word/WPS 内嵌图都 100% 精确到预览页码，否则开发成本和误判风险都会明显上升。

### 16.10 最终结论

图片质量模块能不能把 `docx` 场景做准，关键不在“能不能把图抽出来”，而在“抽出来以后，能不能与 OCR visual metadata 建立稳定关联”。

所以第一版技术路线应明确为：

- 先做图片索引表
- 再做 visual_manifest 关联层
- 最后才是页码/坐标展示

这比直接依赖 `python-docx` 或手工猜 Word 分页要稳得多，也更贴合当前项目已有的 OCR / 评查体系。

## 17. `extractors.py` 职责拆分与 `runner.py` 执行时序

这一节用于把模块内部分层再落细一点，方便研发直接按文件拆任务。

### 17.1 模块职责分层建议

建议图片质量模块按下面几层拆，不要把所有逻辑堆在一个 service 或一个 task 文件里。

#### 17.1.1 `input_resolver.py`

职责：

- 读取文档主文件与附件
- 统一返回原始文件输入列表
- 不做抽图
- 不做检测
- 不做 OCR 关联

建议输出结构：

- `document_id`
- `document_file_id`
- `file_role`
- `file_name`
- `file_ext`
- `mime_type`
- `source_type`
- `source_path`
- `file_bytes`

#### 17.1.2 `extractors.py`

职责：

- 针对不同文件类型抽图
- 生成图片级索引记录
- 记录图片最基础元数据
- 不负责模糊判断
- 不负责最终页码回指策略

建议内部再拆为：

- `extract_images_from_pdf()`
- `extract_images_from_image_file()`
- `extract_images_from_docx()`
- `extract_images_from_attachment()`
- `persist_image_index_items()`

建议统一输出的数据结构：

- `documentId`
- `documentFileId`
- `sourceKind`
- `sourceFileName`
- `sourcePageNum`
- `imageIndexInPage`
- `imageIndexInFile`
- `imageSha256`
- `imageWidth`
- `imageHeight`
- `imageBytes`
- `extraJson`

其中：

- `PDF / 扫描PDF / 图片文件 / 图片附件` 可以在这里直接给出 `sourcePageNum`
- `doc/docx/wps` 这里不强行给精确页码，允许先留空

#### 17.1.3 `detector.py`

职责：

- 对抽出的图片执行清晰度检测
- 返回三档结果
- 输出原因码和说明
- 不负责页码定位

建议统一返回：

- `qualityStatus`
- `qualityScore`
- `reasonCode`
- `reasonText`

建议原因码先收敛为有限集合：

- `blur_detected`
- `low_resolution`
- `over_exposure`
- `under_exposure`
- `motion_blur`
- `text_unreadable`
- `detector_timeout`
- `detector_failed`

#### 17.1.4 `locator.py`

职责：

- 读取 OCR / visual_manifest
- 把图片索引记录与 visual object 做关联
- 回填 `ocr_page_num / ocr_bbox_json / ocr_match_mode`
- 不负责清晰度打分

如果第一期不想单独开 `locator.py`，也可以先内聚到 `runner.py`，但职责概念上仍然建议独立。

#### 17.1.5 `storage_adapter.py`

职责：

- 创建 run
- 批量写入 image items
- 更新图片检测结果
- 更新关联结果
- 汇总 run 主状态

不要把业务判断写进这里，这里只做存取。

### 17.2 `extractors.py` 建议的实现规则

#### 17.2.1 PDF 抽图规则

建议：

- 按页遍历
- 同页内按出现顺序编号 `image_index_in_page`
- 全文件维度编号 `image_index_in_file`
- 能拿到 bbox 就记录 bbox
- 原始图片字节计算 `sha256`

最终目标：

- PDF 在抽图阶段就拿到强定位能力

#### 17.2.2 图片文件抽图规则

建议：

- 单文件直接视为 1 页 1 图
- `sourcePageNum = 1`
- `imageIndexInPage = 1`
- `imageIndexInFile = 1`

#### 17.2.3 `doc/docx/wps` 抽图规则

建议：

- 只抽出内嵌图片
- 保证文件内顺序稳定
- 第一版不在这里做页码推断
- 重点保留：
  - `image_sha256`
  - `image_width`
  - `image_height`
  - `image_index_in_file`

原因：

- 这类文件的难点不在抽图，而在“抽图后如何挂回 OCR 页结构”

#### 17.2.4 附件抽图规则

建议：

- 每个附件独立视为一个来源文件
- 不要沿用主文件页码空间
- `source_file_name` 必填
- 前端展示时允许出现：
  - `附件《xxx.pdf》第 2 页第 1 张图片模糊`
  - `附件《xxx.jpg》图片模糊`

### 17.3 `runner.py` 建议执行时序

建议整条图片质量任务按下面时序执行：

1. 创建或确认 `image_quality_run`
2. 更新 run 状态为 `running`
3. 调用 `input_resolver.py` 读取主文件与附件
4. 调用 `extractors.py` 抽图
5. 将抽图结果落表 `leaudit_image_quality_items`
6. 判断是否无图
7. 若无图则 run 标记 `skipped/no_images`
8. 若有图则进入并发检测
9. 调用 `detector.py` 批量检测，回写每条 item 的质量结果
10. 尝试读取 OCR / visual_manifest
11. 若可读，则做 `locator` 关联并回填页码/bbox
12. 汇总 `pass/review/reject` 计数
13. 更新 run 的 `summary_status`
14. 标记 run `completed` 或 `partial_failed`

### 17.4 推荐时序图口径

可以把执行理解成下面这条线：

1. 上传成功
2. 文档主流程正常投递 OCR/评查
3. 图片质量任务独立投递
4. 图片质量任务先抽图、先检测
5. OCR 若先完成，则图片质量任务顺手做 visual_manifest 关联
6. OCR 若未完成，则图片质量任务先给弱定位结果
7. 后续可通过补偿任务把弱定位升级成强定位

这意味着：

- 图片质量任务不依赖 OCR 主任务完成后才能启动
- 但它可以在 OCR 结果可用时变得更准

### 17.5 是否需要补偿任务

建议保留“可选补偿任务”设计，但第一版不强求一定实现。

补偿任务适合处理：

- 图片质检先完成，但 OCR 尚未完成
- 首次只拿到了 `image_index_in_file`
- OCR 完成后再补齐 `ocr_page_num / bbox`

如果要做，建议命名：

- `leaudit.image_quality.relocate_by_ocr`

但第一版也可以先不单独开 task，而是在详情查询时做一次懒补偿，或者在 OCR 主流程结束后顺手触发一次回填。

### 17.6 `runner.py` 的失败处理原则

建议明确：

- 抽图失败：
  - 当前 run 标记 `failed`
  - 不影响文档主流程
- 单张图片检测失败：
  - 当前 item 标记失败原因
  - run 最终允许为 `partial_failed`
- OCR 关联失败：
  - 不算质检失败
  - 只算定位降级
- 全部图片检测完成但都无法定位页码：
  - run 仍可 `completed`
  - 只是展示层回退到“第 N 张图”

也就是说，质量检测失败和定位失败要分开统计，不能混成一类。

### 17.7 推荐的汇总规则

建议 `runner.py` 最终汇总时按下面规则判断：

- 只要存在至少 1 张 `reject`，`summary_status = reject`
- 否则只要存在至少 1 张 `review`，`summary_status = review`
- 否则若全部 `pass`，`summary_status = pass`
- 若任务本身没跑起来，则 `status = failed`
- 若任务跑完但部分图片检测失败，则 `status = partial_failed`
- 若命中无图、纯文本、开关关闭，则 `status = skipped`

### 17.8 第一版最小可交付实现

为了避免第一版过重，建议把可交付标准压到下面这一级：

- `extractors.py`
  - 先支持 PDF、图片、docx 内嵌图、附件
- `detector.py`
  - 先给出稳定三档判断
- `runner.py`
  - 先能完成抽图、检测、汇总
- `locator`
  - 先支持 OCR `visual_manifest` 存在时的强关联
  - 不存在时允许降级

只要能做到：

- 不影响主流程
- 能把问题图片列出来
- 大多数 PDF/图片类文档能精确到页
- `docx` 至少能稳定定位到“正文第 N 张图”

第一版就已经是可上线状态。

### 17.9 最终建议

研发拆任务时，建议直接按下面方式分：

- A 同学：`SQL + VO + Controller + Service`
- B 同学：`input_resolver + extractors`
- C 同学：`detector + runner`
- D 同学：`locator / OCR visual_manifest 关联`
- 前端同学：`上传页 + 列表页 + 详情页展示`

这样拆分写入面冲突最小，也最贴合当前仓库结构。
