# Research 模块 Phase 1 改造实施计划

## Context

当前 Research 模块是单页工作台（ResearchView.vue），状态存 localStorage。本次改造拆分为三页架构（列表/新建/详情），后端新增研究记录持久化，执行由 Celery worker 独立运行。

**用户决策：** Phase 1 一次全做 | 直接替换旧页面 | ensure_table 建表 | 不做迁移 | 不含 cancel | Celery 后台任务

**架构模型：** POST /run → 创建 run + 投递 Celery task → 立即返回 run_id | Worker 执行 engine + 写 Redis Stream | SSE 只订阅 Stream | SSE 断开不影响执行

---

## Batch 1: 基础设施与数据层

### 1.1 mysql_client.py — 新增 fetch_all + 三张表 ensure 方法

**research_records 表：** id, user_id, session_id(NULL), title, mode, status, output_template, summary, archived, task_json, plan_json, references_json, imported_items_json, notes, parent_record_id(NULL), root_record_id(NULL), version_no, last_error, created_at, updated_at, completed_at(NULL)

**research_record_reports 表：** record_id(PK), report_json, created_at, updated_at

**research_record_runs 表：** run_id(PK), record_id, **user_id**, run_type('full'/'section_rerun'), status, section_title(NULL), started_at(NULL), completed_at(NULL), error(NULL), created_at

### 1.2 main.py lifespan — 注册三张表建表

### 1.3 research_record_store.py（新文件）

**ResearchRecordStore：** create_record, get_record, list_records→(items,total), update_record, **touch_record**(仅更新 updated_at), archive_record, delete_record(仅 draft/planned/failed), get_report, save_report

**ResearchRecordRunStore：** create_run, get_run(run_id, user_id), get_latest_run(record_id, user_id), update_run — 所有读取带 user_id 校验

### 1.4 schemas/research.py — 扩展

output_template 新增 briefing_outline, speech_draft。新增模型：ResearchRecordCreateRequest(**task 必填**), ResearchRecordSummary, ResearchRecordListResponse, **ResearchRecordDetail(含 latest_run_id/latest_run_status/latest_run_type)**, ResearchRecordReportResponse, ResearchRecordPlanRequest, ResearchRecordSectionRerunRequest, ResearchRecordRunResponse

### 1.5 redis_client.py — 补 Stream 薄封装

xadd, xread, expire。注意：当前 decode_responses=True，Stream 字段用字符串 key `fields["data"]`。

---

## Batch 2: 后端 API + Celery 任务

### 2.1 research_record_service.py（新文件）

- `create_record(...)` | `_extract_seed_doc_ids(record)` — item_type: document/snippet→doc_id, matter→governing_doc_ids, answer→references[].doc_id，合并 task_json.required_doc_ids 去重，**三条链路统一调用**
- `generate_plan(record_id, user_id)` — build_plan(不传 perm) → 回写 plan_json + status=planned
- `start_run(record_id, user_id, acl_tokens)` → run_id — 创建 run + 投递 Celery task（传 user_id + acl_tokens，不传 perm 对象）
- `start_section_rerun(record_id, user_id, acl_tokens, ...)` → run_id — 同上
- `subscribe_run(record_id, run_id, user_id)` → AsyncIterator[ResearchChunk] — 校验 run_id→record_id→user_id 归属后从 Redis Stream 读取

### 2.2 celery_app.py — 注册新任务模块

在 imports 列表（第 43 行）中新增 `"app.tasks.research_task"`。

### 2.3 tasks/research_task.py（新文件）

**工程模式：** 严格遵循 ingest_task.py 的模式（见 ingest_task.py:28-37, 78-119）：
- `_run_async(coro)` 创建临时事件循环执行异步逻辑
- task 内部的 `async def _run()` 中自建所有客户端（ES、Redis、MySQL、Neo4j、Embedding、LLM）
- **不能**依赖 FastAPI 的 `app.state`、`Depends`、或 API 层 helper
- **不传 perm 对象**。只传 `user_id` + `acl_tokens: list[str]`（纯 JSON 可序列化数据），worker 内部重建 `PermissionContext`（见 permission.py:81 的构造逻辑）

Celery task，Worker 中执行：
1. 更新 run=running + record=running
2. 调 engine.run_deep_research()，每条 chunk 写 Redis Stream + 聚合 report/references
3. references 按 `guide:{profile_id or doc_id}` / `doc:{doc_id}` 去重
4. **had_error 保护**：error→done 不得覆盖 failed
5. full run 成功：save_report + update_record(status=completed, summary, references_json, **completed_at**) + update_run(completed)
6. full run 失败：update_record(status=failed, last_error) + update_run(failed, error)
7. section_rerun 成功：读现有 report → 替换同标题章节(title/summary/content/source_doc_ids) → save_report + **touch_record** + update_run
8. section_rerun 不改 records.completed_at
9. Stream 设置 TTL 1h

### 2.4 research_records.py（新文件）— API Router

`prefix="/research/records"`

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | / | 列表(q,status,archived,limit,offset) → {records,total} |
| POST | / | 创建记录(task 必填) |
| GET | /{id} | 详情(不含 report，含 latest_run 元数据) |
| GET | /{id}/report | 完整报告 |
| POST | /{id}/plan | 生成计划 |
| POST | /{id}/run | 启动 Celery 任务 → 返回 run_id |
| POST | /{id}/sections/rerun | 启动章节重跑 → 返回 run_id |
| POST | /{id}/runs/{run_id}/events | SSE 订阅(POST 与 useSSE 兼容) |
| POST | /{id}/archive | 归档 |
| DELETE | /{id} | 删除(仅 draft/planned/failed) |

run 接口校验：POST run 只需校验 record_id 属于当前 user_id；POST events 和所有 run 读取/订阅操作必须校验 run_id 属于 record_id 且 record_id 属于 user_id。MySQL 不可用→503。

### 2.5 router.py — 注册新 router

---

## Batch 3: 后端模板扩展

research_formatter.py — _default_sections_for_template 和 _default_deliverables 新增两模板
research_prompts.py — TEMPLATE_CONSTRAINTS + get_template_constraint()
research_formatter.py — _build_task_brief() 末尾注入约束

---

## Batch 4: 前端类型与 API

### types/research.ts
ResearchOutputTemplate 新增 | ResearchRecordStatus | ResearchRecordSummary | ResearchRecordDetail(含 latestRunId/latestRunStatus/latestRunType) | RESEARCH_TEMPLATE_OPTIONS 新增两项

### api/researchRecords.ts（新文件）
fetchRecordList, fetchRecordDetail, fetchRecordReport, createRecord, generateRecordPlan, startRecordRun→run_id, startRecordSectionRerun→run_id, buildRunEventsUrl, archiveRecord, deleteRecord

---

## Batch 5: 前端 Store

### stores/researchRecordList.ts — 列表 Store
records[], total, loading, searchQuery, statusFilter, showArchived, page, pageSize | loadRecords, archiveRecord, deleteRecord

### stores/researchRecordWorkbench.ts — 工作台 Store
recordId, record, isPlanning, isRunning, progress[], report, references[] | createNewRecord, loadRecord(**不含 report**), loadReport(**独立**), generatePlan, handleSSEEvent, **resetStreamState(clearReport: boolean)**(true=清 report+累积态，false=只清 progress/references), reset(全量清空)

**Store 不持有 SSE 连接。** DetailView 持有 useSSE，store 只暴露 handleSSEEvent。

---

## Batch 6: 前端页面

### router/index.ts — 三条路由（/research/new 在 /:id 前）

### ResearchListView.vue — 表格+搜索+筛选+归档+删除

### ResearchCreateView.vue — TaskEditor + PlanViewer，**不发 SSE**
流程：填表 → createRecord + generatePlan → 跳转 `/research/:id?autoRun=true`

### ResearchDetailView.vue — 核心页面
mount 时：
1. loadRecord(id)
2. autoRun=true 且 planned → startRecordRun → 拿 run_id → **立即 router.replace 清 query** → 订阅 events
3. status=running 且有 latestRunId → 重新订阅该 run 的 events
4. status=completed → loadReport
5. status=failed → 展示 lastError

章节重跑：ReportViewer emit → startSectionRerun → 订阅 events → 完成后 reload
**SSE 结束后必须 loadRecord + loadReport**（不能只 loadReport），确保 record 状态和 run 状态同步更新。

```typescript
// DetailView.vue
const { connect } = useSSE()

// 首次启动（从新建页跳转 autoRun=true）
async function startRun() {
  store.isRunning = true
  const { run_id } = await startRecordRun(store.recordId!)
  router.replace({ query: {} })
  await subscribeRun(run_id)
}

// 重新订阅（刷新恢复场景，record.latestRunId 存在且 status=running）
async function resumeRun(runId: string, runType: string) {
  store.isRunning = true
  // C18：full run 清 report+累积态；section_rerun 只清 progress/references
  store.resetStreamState(runType === 'full')
  await subscribeRun(runId)
}

async function subscribeRun(runId: string) {
  await connect({
    url: buildRunEventsUrl(store.recordId!, runId),
    body: {},
    onMessage: (data) => store.handleSSEEvent(data),
    onDone: async () => {  // C14：AbortError 不触发此回调
      store.isRunning = false
      await store.loadRecord(store.recordId!)
      if (store.record?.status === 'completed') await store.loadReport(store.recordId!)
    },
    onError: () => { store.isRunning = false },
  })
}
```

---

## Batch 7: 导入链路与清理

### 7.1 原子改动（三文件同步）
research-import.d.ts — target 简化为 basket/new | ImportToResearchDialog.vue — 移除旧 store 依赖 | useResearchImport.ts — new 走 sessionStorage + /research/new

### 7.2 ResearchBasketDrawer.vue — 跳转改 /research/new

### 7.3 删除旧文件（严格顺序）
先改完所有 useResearchStore 引用 → TS 编译通过 → 删 ResearchView.vue + SessionSidebar.vue + research.ts → 再次编译确认

---

## 硬约束（C1-C10）

| # | 约束 |
|---|------|
| C1 | error→done 不覆盖 failed（had_error 保护） |
| C2 | imported_items→seed_doc_ids 三链路统一，认 item_type |
| C3 | records 用独立 schema，禁复用旧 task+plan 请求 |
| C4 | 列表固定返回 {records, total} |
| C5 | 创建记录 task 必填 |
| C6 | run 层 user_id 隔离（run_id→record_id→user_id） |
| C7 | 详情恢复依赖 latest_run 元数据 |
| C8 | full run→completed_at；rerun 不改 completed_at |
| C9 | rerun updated_at 用 touch_record |
| C10 | Redis Stream 用字符串 key |
| C11 | Celery task 不传 perm 对象，只传 user_id+acl_tokens，worker 内重建 PermissionContext |
| C12 | celery_app.py imports 必须注册 app.tasks.research_task |
| C13 | research_task.py 采用 ingest_task.py 的 _run_async + worker 自建客户端模式 |
| C14 | useSSE 在 AbortError/组件卸载时不触发 onDone。修改 useSSE.ts:115-123 的 finally 块：AbortError 场景不调 finalize()，onDone 仅表示流自然结束。这是最小改动方案，不改 SSEOptions 类型 |
| C15 | generateRecordPlan 前端统一发送空对象 `{}`，后端从 record.task_json 读取 task |
| C16 | worker 时间戳规则：开始→run.started_at=now，成功→run.completed_at=now，失败→run.completed_at=now。full run 成功→records.completed_at=now。section rerun 不改 records.completed_at |
| C19 | section_rerun 状态回写：开始时置 record.status=running；**成功时恢复 record.status=completed**；**失败时也恢复 record.status=completed**（不能卡在 running），同时写 run.failed + run.error + record.last_error |
| C20 | section_rerun 成功后必须同步更新 record.references_json：收集本次 rerun 产生的 reference chunk，与现有 references_json 按去重 key 合并，回写主表。否则刷新后证据区和导出会回退到旧引用 |
| C17 | Celery 入队失败补偿：full run 入队失败→run=failed + record.status=failed + record.last_error + 503。section rerun 入队失败→run=failed + run.error + record.last_error，**不改 record.status**（避免 completed 被降级）。两种都写 run.completed_at |
| C18 | 重订阅幂等：store 新增 `resetStreamState()` 只清 progress/references/SSE 累积态，不清 recordId/record/plan。**full run 重订阅时同时清 report；section_rerun 重订阅时不清 report（rerun 流只发目标章节，无法恢复完整报告）**。Phase 2 可升级为游标续传 |

---

## 关键复用

| 模块 | 复用方式 |
|------|---------|
| ResearchEngine.build_plan/run_deep_research/rerun_section | Service/Celery task 内部调用（**不改旧 research.py 接口行为**） |
| Celery 基础设施(celery_app.py, tasks/) | 新增 research_task.py |
| normalizeResearchPlan, mapTaskToApi (api/research.ts) | researchRecords.ts 复用 |
| TaskEditor/PlanViewer/ReportViewer/ResearchTimeline/EvidencePanel | 新页面直接 import |
| researchExport.ts | 详情页导出 |
| useSSE.ts | DetailView 持有，POST 模式兼容 |

---

## 验证

**后端：** 启动 app+worker → 三表建成 → curl 创建/列表/详情/plan/run→返回 run_id/events 订阅/report/archive/delete → 验证旧接口不退化 → 新模板输出正确

**前端：** /research 列表 → /research/new 填表+plan+跳转 → 详情页 autoRun+SSE → 完成后 report 显示 → 刷新恢复 → 章节重跑+刷新验证 → 导入链路 → 导出 → 无悬空引用
