# zm-rag 需求变更记录

> 本文档记录历次开发会话中实现的功能变更，供新会话快速了解项目现状、继续开发。
>
> **项目路径**：`D:\work\zm-rag`
> **技术栈**：FastAPI + Celery + OpenSearch 2.19 + Neo4j + Redis（后端）；Vue 3 + Ant Design Vue（前端）

---

## 项目结构速览

```
zm-rag/
├── backend/
│   └── app/
│       ├── api/v1/          # FastAPI 路由层
│       │   ├── admin.py     # 管理后台接口
│       │   ├── document.py  # 文档详情接口
│       │   ├── graph.py     # 知识图谱接口
│       │   ├── ingest.py    # 入库触发接口
│       │   ├── mock.py      # Mock OA 接口
│       │   ├── research.py  # 研究问答接口
│       │   └── search.py    # 搜索接口
│       ├── core/            # 业务逻辑层
│       │   ├── chunker.py
│       │   ├── document_processor.py
│       │   ├── embedding.py
│       │   ├── graph_builder.py
│       │   ├── graph_query_service.py
│       │   ├── ingest_pipeline.py   ← 入库主流程
│       │   ├── metadata_extractor.py
│       │   ├── research_engine.py
│       │   ├── search_engine.py
│       │   └── summary_generator.py ← 新增
│       ├── infrastructure/  # 基础设施客户端
│       │   ├── es_client.py
│       │   ├── llm_client.py
│       │   ├── neo4j_client.py
│       │   └── redis_client.py
│       ├── prompts/         # LLM 提示词
│       │   ├── entity_extraction.py
│       │   ├── metadata_extraction.py
│       │   ├── research_prompts.py
│       │   └── summary_generation.py ← 新增
│       ├── tasks/
│       │   └── ingest_task.py
│       └── config.py
└── frontend/
    └── src/
        ├── api/             # Axios 封装
        ├── components/
        ├── composables/
        ├── stores/
        ├── types/
        ├── utils/
        │   └── graphLabels.ts ← 新增（图谱标签中文映射）
        └── views/
            ├── admin/
            │   └── Dashboard.vue ← 管理后台
            ├── DocDetailView.vue ← 文档详情（含知识图谱 Tab）
            ├── GraphExplorer.vue ← 全局图谱探索
            └── MockOA.vue        ← Mock OA 测试台
```

---

## Session 2026-03-13 变更记录

### 1. Research 对齐 Deep Research 工作流

本轮将原有偏聊天式 Research 重构为计划驱动的深度研究工作台。

**后端新增能力**：
- `POST /api/v1/research/plan`：根据 `ResearchTask` 生成结构化研究计划
- `POST /api/v1/research/run`：按确认计划执行深度研究并返回结构化 SSE 事件
- 兼容保留 `POST /api/v1/research`
- SSE 事件扩展为 `plan / progress / summary / finding / conflict / section / open_question / source_group / follow_up`

**相关文件**：
- `backend/app/api/schemas/research.py`
- `backend/app/prompts/research_prompts.py`
- `backend/app/core/research_engine.py`
- `backend/app/api/v1/research.py`

### 2. Research 前端重构为结构化研究工作台

**前端新增能力**：
- 结构化会话模型：`task / plan / report / progress / references / notes`
- 计划确认区、进度时间线、证据工作区、章节化报告展示
- Search / QA / 文档详情 / 事项详情继续共享 `ImportToResearchDialog` + `useResearchImport`
- `useSSE` 修复 `onDone` 重复触发问题

**相关文件**：
- `frontend/src/types/research.ts`
- `frontend/src/api/research.ts`
- `frontend/src/stores/research.ts`
- `frontend/src/views/ResearchView.vue`
- `frontend/src/composables/useSSE.ts`

### 3. 新增章节局部重跑与导出能力

**章节局部重跑**：
- 新增 `POST /api/v1/research/sections/rerun`
- 前端章节卡支持“重跑本节”与“转成新任务”两条路径

**导出能力**：
- Markdown 报告导出
- 完整报告 Word 兼容 `.doc` 导出
- 正式汇报版 `.doc` 导出

### 4. 本轮验证结果

- 后端验证：`python -m pytest tests/test_research_engine_unit.py tests/test_api_research.py -q` → `16 passed`
- 前端验证：`npx vite build` 通过
- 导入链路静态联调：Search、QA、DocDetail、MatterDetail 仍共享 `useResearchImport` 进入 Research 会话

### 5. 审查后稳定性修正与语义收敛

在上述功能完成后，又根据代码审查补做了一轮稳定性修正和实现收敛：

- 修复 `ResearchView` 状态标签 fallback，避免异常 `status` 值导致标签渲染异常。
- 修复 `researchStore` 的 hydration watch guard，避免恢复会话时产生多余的同步和持久化写入。
- 修复右侧证据工作区滚动链路，长证据列表与导入资料列表现在可稳定滚动。
- 为研究执行中但尚未返回首批章节内容的阶段增加显式 loading 提示，避免用户看到空白报告区。
- 将 Word 导出文案明确为 Word 兼容 `.doc`，与当前 HTML-based 导出实现保持一致。
- 补充后端单测，对 `conflict` / `open_question` 结构化 SSE 事件做显式断言。
- 收敛 Research 请求语义：`plan` 阶段继续使用 `seed_doc_ids`，`run` / `rerun` 阶段以 `plan.included_doc_ids` 作为显式文档范围主来源，不再在前端请求体中重复携带同一批导入文档。
- 前端 Markdown 渲染基础配置已抽到共享工具，页面渲染和导出 HTML 不再各自维护独立 MarkdownIt 基础选项。

---

## Session 1 变更记录

### 1. 知识图谱页面 `/graph` 渲染修复

**问题**：`/graph` 页面只显示散点，边不渲染。

**根因**：G6 v5（5.0.51）内部保留 `type` 字段用于指定边的渲染形状类型。传入 `type: 'TAGGED'`、`type: 'ISSUED'` 等业务数据，G6 找不到对应渲染器，静默失败，所有边均不绘制。

**修复**（`GraphExplorer.vue`）：
- `renderGraph()` 中边数据映射：`type` → `relType`
- `edge.style` 从对象字面量改为**函数形式**，`labelText: getRelTypeZh(d.relType || '')` 放入函数内部

---

### 2. `/document/{id}/graph` 接口返回空数组修复

**问题**：`GET /ai/v1/document/2/graph` 始终返回空数组。

**根因**：
1. Cypher 语句中使用 `[r*1..$max_depth]`，Neo4j 不允许在变长路径中使用参数，执行报 SyntaxError，被 `except` 静默捕获，返回空数组。
2. 异步驱动返回的 Node/Relationship 对象不能直接访问 `.start_node.element_id` 等属性，需用 Cypher 函数返回标量值。

**修复**（`backend/app/infrastructure/neo4j_client.py`）：
- 深度值直接嵌入 Cypher 字面量：`f"[*1..{depth_safe}]"`
- 通过 Cypher 函数返回标量：`elementId()`、`labels()`、`properties()`、`type()`
- Python 层对节点/边去重

---

### 3. 文档详情页 `/doc/{id}` 知识图谱 Tab 实现

**问题**：`DocDetailView.vue` 中知识图谱 Tab 有容器 div 但无任何渲染代码。

**修复**（`DocDetailView.vue`）：
- 添加 `graphContainerRef` ref、`graphInstance` shallowRef
- 实现 `initAndRender()` + `renderData()` 函数（与 GraphExplorer 同样的 G6 初始化逻辑）
- `watch(activeTab, maybeRender)` + `watch(graphData, maybeRender)` + `nextTick()` 确保 Tab 可见后再初始化 G6
- 图谱工具栏：节点/边数量统计 + "适应画布"按钮
- 同样修复 `relType` 字段命名问题

---

### 4. 力导向布局参数修正（节点重叠问题）

**问题**：图谱很多节点重叠在一起。

**根因**：`@antv/layout` 的自定义 `force` 布局中：
- `nodeStrength` 是**正整数**表示节点排斥力（默认 1000），之前错误地设置为 `-300`（负值变成了引力，节点相互吸引）
- `edgeStrength` 默认 50，之前设置为 `0.8`（过小，边约束几乎无效）

**修复参数**：

| 参数 | GraphExplorer | DocDetailView |
|------|-------------|---------------|
| `gravity` | 2 | 5 |
| `nodeStrength` | 3000 | 1800 |
| `edgeStrength` | 15 | 20 |
| `linkDistance` | 300 | 200 |
| `nodeSize` | 60 | 60 |
| `nodeSpacing` | 20 | 15 |
| `maxIteration` | 600 | （默认）|

---

### 5. 知识图谱关系类型中文显示

**需求**：将 TAGGED、RECEIVED_BY、ISSUED 等英文关系类型在画布上显示为中文。

**实现**（新建 `frontend/src/utils/graphLabels.ts`）：

```typescript
export const NODE_TYPE_COLORS: Record<string, string> = {
  Document: '#4e79a7',
  Organization: '#f28e2b',
  Person: '#e15759',
  Region: '#76b7b2',
  Topic: '#59a14f',
  DocType: '#edc948',
  Keyword: '#b07aa1',
}

export const REL_TYPE_ZH: Record<string, string> = {
  ISSUED: '发文', RECEIVED_BY: '收文', SIGNED: '签发',
  HANDLED: '承办', INVOLVES: '涉及', COVERS_REGION: '涉及地区',
  ABOUT: '主题', CATEGORIZED_AS: '文种', TAGGED: '主题词',
  REFERENCES: '引用', RELATED_TO: '关联',
}

export function getRelTypeZh(relType: string): string {
  return REL_TYPE_ZH[relType] || relType
}

export function getNodeColor(nodeType: string): string {
  return NODE_TYPE_COLORS[nodeType] || '#8c8c8c'
}
```

`GraphExplorer.vue` 和 `DocDetailView.vue` 均改用此共享工具，删除本地重复颜色常量。

---

### 6. 上传文档时 AI 自动生成摘要

**需求**：上传文档时，AI 自动提取同时**生成摘要**，保存到文档元数据中（`summary_enabled` 默认开启）。

**实现**：

**新建 `backend/app/prompts/summary_generation.py`**
- `SYSTEM_PROMPT`：政务公文摘要专家角色
- `USER_PROMPT`：包含 `{title}`、`{doc_type}`、`{issuing_org}`、`{max_chars}`、`{content}` 占位符
- 摘要要求：150～250 字，涵盖主要目的、核心政策措施、适用范围、时间节点

**新建 `backend/app/core/summary_generator.py`**
- `SummaryGenerator` 类，依赖 `LLMClient`
- 使用 `LLMClient.complete()` 返回纯文本（无需 JSON 解析）
- 失败安全：任何异常返回空字符串，从不抛出
- `max_content_chars` 默认从 `settings.summary_max_content_chars` 读取

**修改 `backend/app/config.py`**
```python
summary_enabled: bool = True
summary_max_content_chars: int = 8_000
```

**修改 `backend/app/core/ingest_pipeline.py`**
- `__init__` 添加 `summary_generator: SummaryGenerator | None`
- `ingest_document()` 新增 Step 1.6：在元数据提取之后、分块之前生成摘要
- `_write_doc_meta()` 添加 `summary: str = ""` 参数，写入 `"summary": summary or None`
- `create_pipeline()` 实例化 `SummaryGenerator`，与 `MetadataExtractor` 共享同一 `LLMClient`

---

### 7. 入库日志显示所有状态（pending / processing / failed / completed）

**问题**：【管理后台】→【近期入库日志】只显示已完成的文档，进行中和失败的文档不出现。

**根因**：`_write_doc_meta()` 只在成功末尾调用，失败/pending 文档从未写入 ES，`match_all` 查询自然查不到。

**解决方案**：分三个阶段写 ES，实现完整状态链：

```
上传 → [pending] → Celery 开始 → [processing] → 成功 → [completed]
                                               → 失败 → [failed] + error 信息
```

**修改 `backend/app/api/v1/ingest.py`**
- 新增 `_write_pending_meta()` 辅助函数：任务进队列后立即向 ES 写入 `status: "pending"` 最小记录
- `trigger_ingest` 和 `webhook_receive_document` 均添加 `request: Request` 参数并调用该函数
- pending 记录包含：`doc_id`、`title`、`doc_number`、`issuing_org`、`doc_type`、`pdf_path`、`task_id`、`created_at`
- 写入失败只记录 warning，不影响 API 响应

**修改 `backend/app/core/ingest_pipeline.py`**
- 新增 `_update_meta_status()` 私有方法，使用 ES **scripted upsert**：
  - 文档存在时：只更新 `status`、`updated_at`、`error` 字段，保留 `created_at` 等字段
  - 文档不存在时（兜底）：创建包含 `created_at` 的最小 stub
- `ingest_document()` 开始时调用 `await self._update_meta_status(doc_id, "processing")`
- `except` 块中调用 `await self._update_meta_status(doc_id, "failed", error=error_msg)`

**修改 `backend/app/api/v1/admin.py`**
- `ingest-logs` 接口 `_source` 增加 `error`、`task_id` 字段
- 响应 records 增加 `error`、`task_id` 两个字段

**修改 `frontend/src/views/admin/Dashboard.vue`**
- 新增 `statusLabel()` 函数，状态显示为中文：`completed→已完成`、`processing→处理中`、`failed→失败`、`pending→等待中`
- 状态列：对有 `error` 字段的记录，status tag 外包 `<a-tooltip>`，鼠标悬停显示错误详情（`cursor: help`）

---

## 现有 API 接口一览

| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/ai/v1/ingest/trigger` | 触发文档入库（需 JWT） |
| POST | `/api/ai/v1/ingest/webhook/document` | OA Webhook 接收文档（无鉴权） |
| POST | `/api/ai/v1/ingest/webhook/permission` | OA Webhook 权限更新 |
| GET  | `/api/ai/v1/ingest/status/{task_id}` | 查询 Celery 任务状态 |
| GET  | `/api/ai/v1/admin/stats` | 系统统计（ES/Neo4j/Redis 健康状态） |
| GET  | `/api/ai/v1/admin/ingest-logs` | 入库日志（分页，所有状态） |
| DELETE | `/api/ai/v1/admin/document/{doc_id}` | 删除文档（ES + Neo4j） |
| GET  | `/api/ai/v1/document/{doc_id}` | 文档详情（含摘要） |
| GET  | `/api/ai/v1/document/{doc_id}/graph` | 文档知识图谱 |
| GET  | `/api/ai/v1/graph/overview` | 全局图谱概览 |
| POST | `/api/ai/v1/search` | 混合搜索（BM25 + kNN） |
| POST | `/api/ai/v1/research/query` | 研究问答（SSE 流式输出） |
| POST | `/api/ai/v1/mock/token` | 生成测试 JWT |

---

## ES 索引结构

### `gov_doc_meta`（文档元数据）

| 字段 | 类型 | 说明 |
|------|------|------|
| `doc_id` | keyword | 文档唯一 ID |
| `title` | text+keyword | 文档标题 |
| `doc_number` | keyword | 文号 |
| `issuing_org` | keyword | 发文机关 |
| `doc_type` | keyword | 文种 |
| `subject_words` | keyword[] | 主题词 |
| `signer` | keyword | 签发人 |
| `publish_date` | date | 发文日期 |
| `summary` | text | AI 生成摘要 ← 新增 |
| `chunk_count` | integer | 分块数量 |
| `page_count` | integer | 页数 |
| `file_path` | keyword (不索引) | 内容寻址文件路径 ({hash}.{ext}) |
| `file_type` | keyword | 源文件类型 |
| `acl_dept_ids` | keyword[] | 部门权限列表 |
| `acl_user_ids` | keyword[] | 用户权限列表 |
| `status` | keyword（动态） | pending/processing/failed/completed ← 新增 |
| `error` | text（动态） | 失败原因 ← 新增 |
| `task_id` | keyword（动态） | Celery 任务 ID ← 新增 |
| `created_at` | date | 记录创建时间 |
| `updated_at` | date | 记录更新时间 |

### `gov_doc_chunks`（文档分块）

| 字段 | 类型 | 说明 |
|------|------|------|
| `chunk_id` | keyword | 分块唯一 ID |
| `doc_id` | keyword | 所属文档 ID |
| `chunk_index` | integer | 分块序号 |
| `content` | text（IK 分词） | 分块正文 |
| `content_vector` | knn_vector(1024) | 嵌入向量 |
| `title`/`doc_number`/`issuing_org`/... | | 冗余元数据（用于过滤） |
| `acl_dept_ids`/`acl_user_ids` | keyword[] | 权限控制 |

---

## Neo4j 图谱节点类型 & 关系类型

### 节点类型
| 标签 | 颜色 | 说明 |
|------|------|------|
| `Document` | #4e79a7 蓝 | 政务公文 |
| `Organization` | #f28e2b 橙 | 机构/部门 |
| `Person` | #e15759 红 | 人员（签发人等） |
| `Region` | #76b7b2 青 | 地区 |
| `Topic` | #59a14f 绿 | 主题 |
| `DocType` | #edc948 黄 | 文种 |
| `Keyword` | #b07aa1 紫 | 主题词 |

### 关系类型（英文→中文）
| 英文 | 中文 | 含义 |
|------|------|------|
| `ISSUED` | 发文 | 机构发布该文件 |
| `RECEIVED_BY` | 收文 | 文件送达机构 |
| `SIGNED` | 签发 | 人员签发 |
| `HANDLED` | 承办 | 机构承办 |
| `INVOLVES` | 涉及 | 文件涉及对象 |
| `COVERS_REGION` | 涉及地区 | 适用地区 |
| `ABOUT` | 主题 | 关联主题 |
| `CATEGORIZED_AS` | 文种 | 文件归类 |
| `TAGGED` | 主题词 | 关键词标注 |
| `REFERENCES` | 引用 | 引用其他文件 |
| `RELATED_TO` | 关联 | 通用关联 |

---

## 配置项说明（`config.py` 关键字段）

```python
# LLM（OpenAI 兼容接口）
llm_base_url: str = "http://localhost:11434/v1"   # Ollama 本地地址
llm_model: str = "qwen2.5:14b"
llm_enable_thinking: bool = True   # False 时禁用 Chain-of-Thought

# 嵌入模型
embedding_model: str = "bge-m3:latest"
embedding_dimensions: int = 1024
embedding_batch_size: int = 6      # DashScope 上限 10，保守设 6

# 知识图谱构建
graph_build_enabled: bool = True
graph_max_content_chars: int = 12_000   # 送入 LLM 的最大字符数

# 摘要生成
summary_enabled: bool = True
summary_max_content_chars: int = 8_000

# 文件存储
file_storage_path: Path = Path("/data/files")
```

---

## 注意事项 / 开发规范

1. **G6 v5 保留字段**：边数据中不能使用 `type` 字段（G6 内部用于指定边渲染器），业务侧一律用 `relType`。`edge.style` 必须是**函数形式**才能读取数据。

2. **Neo4j 变长路径参数**：Cypher `[r*1..$max_depth]` 中不能使用参数，必须嵌入字面量；异步驱动返回的 Node/Relationship 对象需用 Cypher 函数取标量值。

3. **`@antv/layout` force 参数**：`nodeStrength` 是**正整数**表示排斥力（默认 1000），与 D3-force 的负值语义相反。

4. **LLM enable_thinking**：调用 LLM 时对元数据提取和摘要生成均传 `"enable_thinking": False`（速度优先），研究问答不传（推理质量优先）。

5. **Celery worker 重启**：后端代码变更后必须重启 Celery worker 才能生效：
   ```bash
   # 在 backend/ 目录下
   celery -A app.tasks.celery_app worker -l info -P solo
   ```

6. **ES 动态字段**：`status`、`error`、`task_id` 未在 `GOV_DOC_META_MAPPING` 中显式声明，依赖 ES 动态映射（默认 `dynamic: true`）。如需精确类型控制，应通过 mapping 迁移添加。

7. **权限控制**：所有搜索/研究接口均需 JWT，携带 `user_id` 和 `dept_ids`；查询时取用户所在部门 + 个人 ID 联合过滤 ACL。

---

---

## Session 3 变更记录

### 8. Elasticsearch → OpenSearch 迁移

**需求**：ES 8.x 的 RRF (Reciprocal Rank Fusion) 混合检索需要 Platinum 许可证（trial 30 天过期），patched x-pack-core.jar 方式会导致 RRF 功能完全失效。OpenSearch 2.19+ 的 RRF 完全免费（Apache 2.0），迁移可彻底解决许可证问题。

**详细方案**：参见 `docs/opensearch-migration.md`

#### 8.1 Python 依赖替换

- `pyproject.toml`：`elasticsearch[async]>=8.15.0` → `opensearch-py[async]>=2.4.0`

#### 8.2 ES 客户端核心改造 (`es_client.py`)

| 变更项 | ES 8.x | OpenSearch 2.x |
|--------|--------|----------------|
| Client 类 | `AsyncElasticsearch` | `AsyncOpenSearch` |
| helpers 模块 | `elasticsearch.helpers` | `opensearchpy.helpers` |
| 认证参数 | `basic_auth` | `http_auth` |
| 超时参数 | `request_timeout` | `timeout` |
| 向量字段 | `dense_vector` + `dims` + `similarity` | `knn_vector` + `dimension` + `method.space_type` |
| 索引设置 | 无需额外设置 | `"index.knn": True` |
| 同义词 | `"updateable": True` 支持 | 不支持，已移除 |
| RRF 管道 | 无需（retriever API 内置） | 需创建 `search_pipeline`（`normalization-processor` + `rrf`） |
| 响应解析 | `resp.body if hasattr(resp, "body")` | `resp if isinstance(resp, dict) else resp.body` |

新增 `HYBRID_RRF_PIPELINE` 常量和 `_RRF_PIPELINE_BODY` 管道定义，`create_indices()` 末尾自动创建。

#### 8.3 RRF 查询重写

**`search_engine.py`** + **`research_engine.py`**：

ES 8.14+ retriever API：
```python
"retriever": {
    "rrf": {
        "retrievers": [
            {"standard": {"query": bm25_query}},
            {"knn": {"field": "content_vector", ...}},
        ],
        "rank_window_size": 200,
        "rank_constant": 60,
    },
}
```

OpenSearch hybrid query + search pipeline：
```python
"query": {
    "hybrid": {
        "queries": [
            bm25_query,
            {"knn": {"content_vector": {"vector": [...], "k": 200, "filter": ...}}},
        ],
    },
}
# 搜索时添加 params={"search_pipeline": "hybrid_rrf_pipeline"}
```

- 降级检测从 `"non-compliant" / "license"` 改为 `"hybrid" / "pipeline" / "illegal"`
- 管道方式：通过 `params={"search_pipeline": HYBRID_RRF_PIPELINE}` 传入

#### 8.4 Celery 任务和脚本替换

以下 4 个文件中的 `AsyncElasticsearch` → `AsyncOpenSearch`，`basic_auth` → `http_auth`：

- `backend/app/core/ingest_pipeline.py`（`create_pipeline()` 工厂函数）
- `backend/app/tasks/ingest_task.py`（`update_permissions_task`）
- `backend/app/tasks/graph_task.py`（3 处 ES 客户端创建）
- `backend/scripts/bulk_build_graph.py`（`main()` 函数）

#### 8.5 API 层响应解析

以下文件中的 `resp.body if hasattr(resp, "body") else resp` 统一替换为 `resp if isinstance(resp, dict) else resp.body`：

- `backend/app/api/v1/document.py`（3 处）
- `backend/app/api/v1/admin.py`（4 处）
- `backend/app/api/v1/admin_graph.py`（2 处）
- `backend/app/api/v1/mock.py`（3 处）
- `backend/app/api/v1/search.py`（1 处）

#### 8.6 Docker 配置

- **新建** `docker/opensearch/Dockerfile`：基于 `opensearchproject/opensearch:2.19.0` + IK 分词插件
- **新建** `docker/opensearch/opensearch.yml`：含 `plugins.security.disabled: true` + `knn.plugin.enabled: true`
- **复制** `analysis/gov_synonyms.txt` 到 `docker/opensearch/analysis/`
- **重写** `docker/docker-compose.yml`：`elasticsearch` → `opensearch`，删除 `es-init`，`kibana` → `opensearch-dashboards`
- **重写** `docker/docker-compose-bind.yml`：同步更新

---

## Session 4 变更记录

### 9. OpenSearch 升级 + 自动化测试

**需求**：将 OpenSearch 从 2.19.1 升级到 2.19.4，创建完整的自动化测试用例（接口粒度 + 业务流程粒度），并记录到文档。

#### 9.1 OpenSearch 2.19.4 升级

- `docker/opensearch/Dockerfile`：基础镜像 → `opensearchproject/opensearch:2.19.4`，IK 插件 → `2.19.4.zip`
- `docker/docker-compose.yml`：dashboards → `opensearchproject/opensearch-dashboards:2.19.4`
- `docker/docker-compose-bind.yml`：同步更新
- 验证：`curl localhost:9200/` 确认 `"number": "2.19.4"`, IK + kNN 插件已加载

#### 9.2 测试框架搭建

- 依赖：`pytest 8.4.2`, `pytest-asyncio 1.2.0`, `opensearch-py 3.0.0`, `asgi-lifespan 2.1.0`
- conftest.py 重写：
  - 使用 `asgi-lifespan.LifespanManager` 确保 ASGI 模式下 lifespan 正确初始化
  - 双模式：ASGI (默认) / HTTP (`--base-url` 参数)
  - JWT fixture: `auth_headers` (admin), `user_headers` (zhang_san)
  - PDF fixture: `example_pdf_path` (最小文件), `all_example_pdfs`

#### 9.3 接口粒度测试 (8 文件, 53 用例)

| 文件 | 用例数 | 覆盖端点 |
|------|--------|---------|
| `test_api_health.py` | 2 | /health, 404 |
| `test_api_mock.py` | 9 | /mock/token, /mock/login, /mock/users |
| `test_api_search.py` | 8 | /search, /search/suggest |
| `test_api_ingest.py` | 11 | /ingest/trigger, /ingest/webhook/*, /ingest/status |
| `test_api_document.py` | 6 | /document/{id}, /document/{id}/graph |
| `test_api_admin.py` | 8 | /admin/stats, /admin/ingest-logs, /admin/document/{id} |
| `test_api_research.py` | 3 | /research (SSE) |
| `test_api_graph.py` | 6 | /graph/search, /graph/entity, /graph/related-docs, /graph/overview |

#### 9.4 业务流程粒度测试 (4 文件, 7 用例)

| 文件 | 用例数 | 流程 |
|------|--------|------|
| `test_flow_ingest_search.py` | 2 | 导入→搜索→详情→图谱→删除全生命周期 |
| `test_flow_permission.py` | 1 | ACL 权限可见性 (D_05 可见, D_08 不可见) |
| `test_flow_research.py` | 3 | SSE 流解析 + 多轮对话 + 空问题拒绝 |
| `test_flow_graph.py` | 1 | 导入→图谱构建→实体搜索→清理 |

#### 9.5 测试执行结果

```
ASGI 模式: 53 passed, 1 skipped, 0 failed (26.48s)
环境: OpenSearch 2.19.4, Python 3.11, pytest 8.4.2
```

#### 9.6 测试文档

- **新建** `docs/testing.md`：完整测试文档（环境准备 + 运行方式 + 用例清单 + 架构说明 + CI/CD 建议）

#### 9.7 Bug 修复

- `test_api_mock.py`：LoginResponse 字段在顶层而非 `user` 嵌套 (`body["user"]["user_id"]` → `body["user_id"]`)
- `test_api_admin.py`：admin 端点需要鉴权，补充 auth_headers
- `test_api_graph.py`：EntitySearchRequest 字段为 `name` (非 `keyword`)，RelatedDocsRequest 需要 `entity_name` + `entity_label`
- 所有流程测试：admin/document 删除需要 auth_headers

---

*Session 4 完成，OpenSearch 2.19.4 升级 + 60 个自动化测试用例*

---

## Session 5~6 变更记录

### 10. 基于 Docling 的多格式文档支持

**需求**：系统从仅支持 PDF 扩展为支持 18 种文档格式（PDF、DOC、DOCX、XLS、XLSX、PPT、PPTX、WPS、ET、OFD、PNG、JPG、JPEG、TIFF、BMP、TXT、MD、Markdown），使用 IBM Docling 作为统一解析引擎，并新增 Java 文档转换服务处理 Docling 不支持的旧格式。

#### 10.1 整体架构变更

```
文件上传 → 文件类型检测(magic bytes) → MD5 去重检查
    ├── 已存在 → 快速路径（写 meta + 重计算 ACL）
    └── 新内容 → 判断格式
        ├── 需要转换(doc/xls/ppt/wps/et/ofd) → Java 转换服务 → 转换后的文件
        ├── 纯文本(txt/md) → 直接读取 + 分块
        └── Docling 支持(pdf/docx/pptx/xlsx/images) ↓
            Docling 解析 → HybridChunker 结构化分块
                → 每个 chunk 保留 page_number、heading_hierarchy、element_type
                → 嵌入 → OpenSearch 索引 → 图谱构建
```

#### 10.2 新增 Java 文档转换服务 (`converter/`)

**技术栈**：Spring Boot 2.7.18 + Java 8 + Spire.Office (本地 jar) + MySQL

**项目结构**：
```
converter/
├── pom.xml                              # Maven 配置，本地 Spire jar (system scope)
├── lib/                                 # Spire.Doc.jar, Spire.Xls.jar, Spire.Presentation.jar, Spire.Pdf.jar
├── Dockerfile                           # 多阶段构建 (maven:3.8-openjdk-8 → openjdk:8-jre-slim)
├── src/main/resources/
│   ├── application.yml                  # 端口 8080, MySQL zm_tag, JPA ddl-auto=update
│   └── schema.sql                       # convert_log DDL (参考用)
└── src/main/java/com/zmrag/converter/
    ├── ConverterApplication.java        # Spring Boot 启动类
    ├── entity/ConvertLog.java           # JPA 实体 (convert_log 表)
    ├── repository/ConvertLogRepository.java  # 分页 + 过滤查询
    ├── util/FileFormatDetector.java     # 自主 magic bytes 格式检测 (无 Tika)
    ├── model/
    │   ├── ConvertResult.java           # 转换结果 DTO
    │   └── PageResult.java              # 分页响应 DTO
    ├── service/DocumentConvertService.java  # 核心转换逻辑 + DB 日志
    └── controller/ConvertController.java    # REST API
```

**API 接口**：
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/convert` | 文件转换 (multipart + targetFormat) |
| GET  | `/api/convert/logs` | 分页查询转换记录 (status/startDate/endDate) |

**转换能力**：
| 源格式 | 目标格式 | 引擎 |
|--------|----------|------|
| DOC/WPS | DOCX | Spire.Doc |
| XLS/ET | XLSX | Spire.Xls |
| PPT | PPTX | Spire.Presentation |
| OFD | PDF | Spire.Doc |

**持久化**：每次转换记录 (成功/失败) 写入 MySQL `zm_tag.convert_log` 表，包含：原始文件名、原始格式、目标格式、文件大小(输入/输出)、耗时、状态、错误信息、时间戳。

**依赖说明**：Spire.Office jar 文件从 `D:\work\ofd-box\ofd-box\web\WEB-INF\lib` 复制到 `converter/lib/`，**不使用在线 e-iceblue Maven 仓库**，通过 `<scope>system</scope>` + `<systemPath>` 引用。

#### 10.3 Python 后端变更

**新增文件**：
- `app/utils/file_type.py` — 自主实现 magic bytes 文件类型检测（无外部依赖），与 Java 端逻辑一致
- `app/core/docling_processor.py` — Docling 文档处理器，懒加载 `DocumentConverter`，RapidOCR 支持扫描件
- `scripts/reset_and_migrate.py` — 清空旧 OpenSearch 索引 + Neo4j 图谱，重建新 mapping

**修改文件**：

| 文件 | 变更要点 |
|------|----------|
| `requirements.txt` | 移除 `PyMuPDF`，新增 `docling>=2.78.0`, `charset-normalizer>=3.3.0` |
| `config.py` | `pdf_storage_path` → `file_storage_path`，新增 `supported_file_types`、`docling_*`、`converter_*`、`formats_need_conversion` |
| `es_client.py` | chunks mapping 新增 `page_number`、`page_numbers`、`heading_hierarchy`、`element_type`、`file_type`；meta mapping 新增 `file_path`、`file_type` |
| `chunker.py` | `Chunk` dataclass 新增结构化字段，新增 `chunks_from_docling()` 工厂函数 |
| `ingest_pipeline.py` | 完全重写：多格式分支处理、Java 转换服务调用 (httpx)、Docling/纯文本/分块路径、UTF-8/charset-normalizer/GBK 编码检测 |
| `ingest_task.py` | `pdf_path` → `file_path` 参数（普通位置参数，非 keyword-only） |
| `ingest.py` | 完全重写：多格式上传验证、文件类型/大小白名单、`_write_pending_meta` 含 `file_type` |
| `document.py` | 新增 `GET /file/{doc_id}` 多格式下载端点，旧 `/pdf/{doc_id}` 保留为别名；删除时按 `file_type` 清理文件 |

#### 10.4 OpenSearch 索引变更

**`gov_doc_chunks` 新增字段**：
| 字段 | 类型 | 说明 |
|------|------|------|
| `page_number` | integer | chunk 所在主页码 |
| `page_numbers` | integer | chunk 跨越的所有页码 |
| `heading_hierarchy` | keyword | 标题层级 (如 ["第一章", "第一节"]) |
| `element_type` | keyword | 元素类型 (text/table/figure 等) |
| `file_type` | keyword | 源文件类型 |

**`gov_doc_meta` 新增字段**：
| 字段 | 类型 | 说明 |
|------|------|------|
| `file_path` | keyword (不索引) | 内容寻址文件路径 ({hash}.{ext}) |
| `file_type` | keyword | 源文件类型 |

> ⚠️ 不需向后兼容旧数据，运行 `scripts/reset_and_migrate.py` 清空后重建。

#### 10.5 Docker Compose 变更

- **新增 `doc-converter` 服务**：端口映射 `18800:18800`，MySQL 连接 `host.docker.internal:3306/zm_tag`
- **celery-worker** 新增 `doc-converter` 依赖 (`condition: service_healthy`)
- **`.env.docker`**：`PDF_STORAGE_PATH` → `FILE_STORAGE_PATH`，新增 `CONVERTER_BASE_URL=http://doc-converter:8080`

#### 10.6 前端变更

**新增文件**：
- `views/admin/ConvertLogs.vue` — 转换记录查看页面（筛选、统计、分页表格）

**修改文件**：
| 文件 | 变更要点 |
|------|----------|
| `router/index.ts` | 新增 `/admin/convert-logs` 路由 |
| `App.vue` | 侧边栏菜单新增「转换记录」(SwapOutlined 图标) |
| `vite.config.ts` | 新增 `/api/converter` 代理 → `localhost:18800` |

---

### 11. 端口清单（不可变更）

| 服务 | 端口 | 说明 |
|------|------|------|
| Python FastAPI 后端 | 8900 | 本地开发 |
| Java 转换服务 | 18800 (host) → 18800 (container) | Spire.Office |
| Neo4j HTTP | 8474 → 7474 | Browser |
| Neo4j Bolt | 8687 → 7687 | 驱动连接 |
| Redis | 16379 → 6379 | 缓存/队列 |
| OpenSearch | 9200 | 搜索引擎 |
| Dashboards | 5601 | 管理界面 |
| Frontend | 3000 | Vue 开发服务器 |
| MySQL | 3306 | 转换日志存储 |

---

### 12. 新增 API 接口

| 方法 | 路径 | 服务 | 说明 |
|------|------|------|------|
| POST | `/api/convert` | Java 转换服务 (8901) | 文档格式转换 |
| GET  | `/api/convert/logs` | Java 转换服务 (8901) | 转换记录分页查询 |
| GET  | `/api/v1/document/file/{doc_id}` | Python 后端 (8900) | 多格式文件下载 |

---

*Session 5~6 完成，Docling 多格式文档支持 + Java 转换服务 + 前端转换记录页面*

---

## Session 7 变更记录

### 13. 全局 `pdf_path` 清除 + `.pdf` 硬编码修复

**需求**：系统已支持多格式文档，但大量代码中仍硬编码 `.pdf` 后缀或 `pdf_path` 字段。清理所有遗留引用，不需要向后兼容旧数据。

#### 13.1 `pdf_path` 字段完全移除

以下文件中的 `pdf_path` 字段全部删除，改用 `file_path` + `file_type`：

| 文件 | 变更 |
|------|------|
| `backend/app/api/schemas/ingest.py` | 删除 `pdf_path` 字段 |
| `backend/app/api/schemas/document.py` | 删除 `pdf_path` 字段，保留 `file_path` + `file_type` |
| `backend/app/api/v1/ingest.py` | `_write_pending_meta` 移除 `pdf_path`；`trigger_ingest` 不再回退 `body.pdf_path` |
| `backend/app/api/v1/document.py` | 返回 `file_path`/`file_type`，移除 `pdf_path` |
| `backend/app/api/v1/admin.py` | `_source` 查询和响应中移除 `pdf_path`，改用 `file_path`/`file_type` |
| `backend/app/core/ingest_pipeline.py` | `_write_doc_meta()` 移除 `"pdf_path": file_path`，仅保留 `"file_path"` |
| `backend/app/infrastructure/es_client.py` | `GOV_DOC_META_MAPPING` 移除 `pdf_path` 字段定义；`find_by_content_hash` 的 `_source` 改用 `file_path`/`file_type` |
| `backend/app/core/document_processor.py` | 所有 `pdf_path` → `file_path`，`pdf_metadata` → `doc_metadata` |
| `backend/scripts/bulk_ingest.py` | `--pdf-dir` → `--file-dir`，`pdf_filename` → `filename` |
| `backend/scripts/bulk_build_graph.py` | `pdf_path` → `file_path` |
| `frontend/src/types/document.d.ts` | 移除 `pdf_path`，保留 `file_path` + `file_type` |
| `frontend/src/views/admin/Dashboard.vue` | 使用 `file_path` 替代 `pdf_path` |

#### 13.2 前端下载功能多格式适配

**`frontend/src/api/document.ts`**：
- `downloadDocumentPdf()` → `downloadDocumentFile()`，使用响应 `content-type` 确定 MIME
- 旧函数名保留为 `@deprecated` 别名

**`frontend/src/views/DocDetailView.vue`**：
- 下载按钮根据文档的 `file_type` 字段确定文件扩展名（不再硬编码 `.pdf`）

---

### 14. Magic Bytes 文件类型检测（Java + Python 双端实现）

**需求**：
1. Java 转换服务原使用 Apache Tika 检测格式，但 Tika 对 OLE2 复合文档返回通用 MIME `application/x-tika-msoffice`，无法区分 DOC/XLS/PPT
2. Python 后端原使用 `filetype` 库，功能有限
3. 需在上传后、converter/docling 处理前准确检测文件真实格式

#### 14.1 Java `FileFormatDetector` 重写

**`converter/src/main/java/com/zmrag/converter/util/FileFormatDetector.java`**：
- 移除 Apache Tika 依赖，改用 `RandomAccessFile` 读取 64KB 文件头
- 使用 `StandardCharsets.ISO_8859_1` 做 1:1 字节映射
- 检测逻辑：

| Magic Bytes | 格式 | 检测方式 |
|-------------|------|----------|
| `%PDF` (25 50 44 46) | PDF | 直接匹配 |
| `PK..` (50 4B 03 04) | ZIP 系 | 内容扫描：`word/document.xml`→DOCX, `xl/workbook.xml`→XLSX, `ppt/presentation.xml`→PPTX, `OFD.xml`→OFD |
| `D0 CF 11 E0` | OLE2 系 | UTF-16LE 流名称扫描：`WordDocument`→DOC, `Workbook`/`Book`→XLS, `PowerPoint`/`Current User`→PPT, 扩展名回退→WPS/ET |
| `89 50 4E 47` | PNG | 直接匹配 |
| `FF D8 FF` | JPEG | 前 3 字节匹配 |
| `42 4D` | BMP | 前 2 字节匹配 |
| `49 49 2A 00` / `4D 4D 00 2A` | TIFF | LE/BE 两种字节序 |

**`converter/pom.xml`**：移除 `tika-core` 依赖。

#### 14.2 Python `file_type.py` 重写

**`backend/app/utils/file_type.py`**：
- 移除 `filetype` 库依赖，完全自主实现 magic bytes 检测
- 与 Java 端完全一致的检测逻辑和优先级
- 文本文件（txt/md/csv）按扩展名判断（无 magic bytes 可用）
- 64KB 头部读取，`latin-1` 解码做内容扫描

**`backend/requirements.txt`**：移除 `filetype>=1.2.0`。

---

### 15. Celery Task 参数修复

**问题**：上传 `.doc` 文件时，文件到达 Java 转换服务时扩展名变成 `.pdf`。

**根因**：`ingest_task.py` 中 `file_path` 是 keyword-only 参数（在 `*` 之后）：
```python
# 有问题的写法
def ingest_document_task(self, doc_id: str, *, file_path: str = "", ...)
```
Celery 的 JSON 序列化可能无法正确处理 keyword-only 参数，导致 `file_path` 丢失，回退到默认空值或旧的 `pdf_path` 逻辑。

**修复**：将 `file_path` 改为普通位置参数：
```python
# 修复后
def ingest_document_task(self, doc_id: str, file_path: str = "", metadata: dict | None = None)
```

---

### 16. 端口更新

Java 转换服务端口从 `8901:8080` 改为 `18800:18800`：

| 服务 | 端口 | 说明 |
|------|------|------|
| Java 转换服务 | 18800 (host) → 18800 (container) | Spire.Office |

---

### 17. ES 索引结构更新

#### `gov_doc_meta`（文档元数据）—— 当前字段

| 字段 | 类型 | 说明 |
|------|------|------|
| `doc_id` | keyword | 文档唯一 ID |
| `content_hash` | keyword | 内容 MD5 哈希 |
| `title` | text+keyword | 文档标题 |
| `doc_number` | keyword | 文号 |
| `issuing_org` | keyword | 发文机关 |
| `doc_type` | keyword | 文种 |
| `subject_words` | keyword[] | 主题词 |
| `signer` | keyword | 签发人 |
| `publish_date` | date | 发文日期 |
| `summary` | text | AI 生成摘要 |
| `chunk_count` | integer | 分块数量 |
| `page_count` | integer | 页数 |
| `file_path` | keyword (不索引) | 内容寻址文件路径 ({hash}.{ext}) |
| `file_type` | keyword | 源文件类型 |
| `acl_dept_ids` | keyword[] | 部门权限列表 |
| `acl_user_ids` | keyword[] | 用户权限列表 |
| `status` | keyword（动态） | pending/processing/failed/completed |
| `error` | text（动态） | 失败原因 |
| `task_id` | keyword（动态） | Celery 任务 ID |
| `created_at` | date | 记录创建时间 |
| `updated_at` | date | 记录更新时间 |

> ⚠️ `pdf_path` 字段已完全移除，不再存在于 mapping 中。

---

### 18. 项目结构更新

```
zm-rag/
├── backend/
│   └── app/
│       ├── api/v1/
│       ├── core/
│       │   ├── chunker.py
│       │   ├── docling_processor.py     ← Docling 解析引擎
│       │   ├── document_processor.py    ← PyMuPDF (仅保留兼容)
│       │   ├── ingest_pipeline.py       ← 入库主流程 (多格式)
│       │   └── ...
│       ├── infrastructure/
│       ├── utils/
│       │   ├── file_type.py             ← Magic bytes 文件类型检测
│       │   └── logger.py
│       └── config.py
├── converter/                            ← Java 转换服务
│   ├── lib/                              ← Spire.Office 本地 jar
│   ├── src/main/java/com/zmrag/converter/
│   │   └── util/FileFormatDetector.java  ← Magic bytes 格式检测 (无 Tika)
│   └── pom.xml                           ← 无 tika-core 依赖
├── frontend/
│   └── src/
│       ├── api/document.ts               ← downloadDocumentFile()
│       └── views/
│           ├── DocDetailView.vue         ← 按 file_type 下载
│           └── admin/Dashboard.vue       ← 使用 file_path
└── docker/
    └── docker-compose.yml                ← doc-converter 端口 18800
```

---

### 19. 配置项更新 (`config.py`)

```python
# 文件存储（已无 pdf_storage_path）
file_storage_path: Path = Path("/data/files")

# 文件格式支持
supported_file_types: list[str] = [
    "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
    "wps", "et", "ofd", "png", "jpg", "jpeg", "tiff", "bmp",
    "txt", "md", "markdown",
]

# 需要 Java 转换服务处理的格式
formats_need_conversion: list[str] = ["doc", "xls", "ppt", "wps", "et", "ofd"]

# Java 转换服务
converter_base_url: str = "http://localhost:18800"
converter_timeout: int = 120
```

---

### 20. 依赖变更 (`requirements.txt`)

| 操作 | 包 | 说明 |
|------|---|------|
| 移除 | `filetype>=1.2.0` | 用自主 magic bytes 检测替代 |
| 保留 | `docling>=2.78.0` | IBM Docling 文档解析 |
| 保留 | `charset-normalizer>=3.3.0` | 文本编码检测 (UTF-8/GBK) |

Java 侧：
| 操作 | 包 | 说明 |
|------|---|------|
| 移除 | `tika-core 1.28.5` | 用自主 magic bytes 检测替代 |

---

### 21. 端口清单（更新后）

| 服务 | 端口 | 说明 |
|------|------|------|
| Python FastAPI 后端 | 8900 | 本地开发 |
| Java 转换服务 | 18800 (host) → 18800 (container) | Spire.Office |
| Neo4j HTTP | 8474 → 7474 | Browser |
| Neo4j Bolt | 8687 → 7687 | 驱动连接 |
| Redis | 16379 → 6379 | 缓存/队列 |
| OpenSearch | 9200 | 搜索引擎 |
| Dashboards | 5601 | 管理界面 |
| Frontend | 3000 | Vue 开发服务器 |
| MySQL | 3306 | 转换日志存储 |

---

### 注意事项补充

8. **Magic Bytes 检测一致性**：Java (`FileFormatDetector.java`) 和 Python (`file_type.py`) 两端使用完全相同的检测逻辑和优先级。OLE2 复合文档通过扫描 UTF-16LE 编码的目录流名称来区分 DOC/XLS/PPT；ZIP 系格式通过扫描内部路径来区分 DOCX/XLSX/PPTX/OFD。两端均使用 ISO-8859-1 / latin-1 编码做 1:1 字节映射。

9. **Celery 参数序列化**：Celery task 的参数不要使用 keyword-only（`*` 之后的参数），否则 JSON 序列化可能丢失参数值。应使用普通位置参数或带默认值的参数。

10. **Git Bash MSYS 路径转换**：在 Windows Git Bash 中执行 `docker cp` 或 `docker exec` 时，以 `/` 开头的路径会被 MSYS 自动转换（如 `/app/` → `C:/Program Files/Git/app/`）。使用 `MSYS_NO_PATHCONV=1` 前缀可禁用此行为。

---

## Session 8：Pipeline 流程文档 + Bug 修复

### 22. 文档入库完整处理流程

```
文件上传 (webhook / trigger)
    │
    Step 0: 文件类型检测 (magic bytes) + MD5 去重
    │   ├── 已存在相同 content_hash → 快速路径（复制 meta + ACL，跳过内容处理）
    │   └── 新内容 ↓
    │
    Step 2: 需要转换？(doc/xls/ppt/wps/et/ofd)
    │   ├── 是 → 调用 Java 转换服务 (POST /api/convert) → 得到 docx/xlsx/pptx/pdf
    │   └── 否 → 使用原文件
    │
    Step 3: 解析 + 分块
    │   ├── TXT/MD → 直接读取文本 → DocumentChunker.chunk_document()
    │   └── 其他格式 → DoclingProcessor.process()
    │       ├── Docling DocumentConverter.convert() — 含 OCR (RapidOCR) + 版面分析
    │       └── HybridChunker.chunk() → 语义分块（保留 page_number、heading_hierarchy）
    │
    Step 3.5: LLM 元数据提取 (extract_metadata_llm)
    │   └── 使用 full_text 前 2000 字 → 提取 title、doc_number、issuing_org 等
    │       └── title 为空时 fallback: 使用 original_filename 的文件名部分（去扩展名）
    │
    Step 3.6: LLM 摘要生成 (generate_summary_llm)
    │   └── 使用 full_text 前 3000 字 → 生成 200 字以内摘要
    │
    Step 4: 嵌入向量生成
    │   └── 批量调用 embedding API → 每个 chunk 生成 embedding 向量
    │
    Step 5: 写入 OpenSearch (gov_doc_chunks)
    │   └── bulk index：chunk_text + embedding + page_number + heading_hierarchy + ...
    │
    Step 6: 写入 OpenSearch (gov_doc_meta)
    │   └── 文档元信息：title、original_filename、doc_number、summary、chunk_count、...
    │
    Step 7: 构建知识图谱 (Neo4j)
        └── LLM 提取实体关系 → 写入 Neo4j 图数据库
```

**关键点：**
- LLM 元数据提取发生在**转换和 Docling 解析之后**，使用解析后的 `full_text`
- MD5 去重在 Step 0 完成，已存在的文档不会重复解析
- `original_filename` 在 webhook 接收时保存到 metadata，贯穿整个管道
- Docling 在 CPU 模式下首次加载模型较慢（版面分析 + TableFormer + OCR 模型约需 3-5 分钟）

---

### 23. Bug 修复：DocumentDetail 验证错误

**问题**：查看正在处理中（status="processing"）的文档详情时返回 500 错误。

**原因**：`DocumentDetail` schema 中 `chunk_count` 和 `page_count` 定义为 `int`，但处理中的文档在 ES 中这两个字段为 `None`，Pydantic 严格模式拒绝 `None → int` 转换。

**修复** (`app/api/schemas/document.py`)：
```python
# 修改前
chunk_count: int | None = 0
page_count: int | None = 0
# 现在接受 None 值，不会触发验证错误
```

---

### 24. Bug 修复：转换文档原始文件名丢失

**问题**：通过 webhook 上传需要转换的文档（doc/xls/ppt 等），在 admin ingest-logs 中 `original_filename` 和 `title` 显示为空。

**原因**：
1. webhook 端点捕获了 `file.filename`（如"关于XX的通知.doc"），但仅用于提取扩展名，未保存到 metadata
2. 当 `auto_extract=true` 时，前端不传 title 等字段，依赖 LLM 提取
3. LLM 提取对短文档或格式不佳的文本可能返回空结果
4. `original_filename` 在整个管道中从未被存储

**修复**：
- `ingest.py` webhook：`meta_dict["original_filename"] = original_name`
- `_write_pending_meta()`：在 ES body 中写入 `original_filename` 字段
- `ingest_pipeline.py` `_write_doc_meta()`：存储 `original_filename`，并在 LLM 提取 title 为空时使用文件名（去扩展名）作为 fallback
- `admin.py`：ingest-logs 查询和响应中包含 `original_filename`

---

*最后更新：Session 8 完成 — Pipeline 流程文档、DocumentDetail 验证修复、原始文件名保留修复*
