api-server/docs/chat-scope-design.md
wangdl fe44dec567
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 46s
feat: M-CHAT ChatScope 会话系统完整实现
## 数据模型
- ChatSession +13 字段 (scopeType/scopeId/parentKnowledgeBaseId/createdFrom/isPinned/isArchived/isDeleted/modelMode/modelId/lastMessageAt)
- ChatMessage +scopeSnapshot (消息级 scope 快照)
- ChatCitation +lineStart/lineEnd +sourceId 索引
- 5 个新查询索引

## 核心能力
- open-or-create: 同 scope 继续会话 (200) / 新建 (201)
- scope 级检索: global/knowledge_base/material/knowledge_item/folder
- listSessions: scope 过滤 + isDeleted 排除 + isPinned 排序 + 分页元数据
- 自动标题: 首条消息截取 + 词边界处理
- 软删除 + 置顶/归档
- scope 字段创建后不可修改
- 全部端点 userId 鉴权

## 文档
- docs/chat-scope-design.md (设计文档 + 决策表)
- docs/chat-scope-api-contract.md (API 契约)
- docs/chat-scope-test-plan.md (33 条测试用例)
- prisma/migrations/backfill_chat_scope.sql (旧数据回填)

## Bug 修复
- #104: KnowledgeItem.sourceRef 填充 (material scope 检索修复)
- #102: sendMessageStream aiGateway null 保护
- listSessions isDeleted/isArchived 过滤 + 分页

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 17:27:40 +08:00

636 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ChatScope 会话系统设计文档
> CHAT-001 | 版本 v1.0 | 2026-06-06
>
> 本文档是 M-CHAT 里程碑的**权威参考**。所有后端和 iOS 的 issue 均以本文档冻结的规则为准。
---
## 1. 概述
### 1.1 问题
当前 AI Chat 只有 `POST /rag-chat/sessions` 接受 `knowledgeBaseId`,无法区分用户是从知识库详情、资料详情还是知识点详情进入的。导致:
- 同一知识库的不同学习对象混在同一个会话里
- 无法精准限定检索范围(只检索当前资料 vs 整个知识库)
- 切换资料后仍在旧上下文中继续,回答不相关
### 1.2 目标
**所有 AI 会话都绑定明确的 ChatScope。同一 scope 继续会话,不同 scope 打开或创建对应会话。前端所有入口必须显式传 scopeAI 页面不再猜测上下文。**
---
## 2. 核心规则
```
规则 1. 会话必须绑定 scopeType + scopeId。
规则 2. userId + scopeType + scopeId 完全一致,才允许继续上次会话。
规则 3. scope 变化 → 打开或创建另一个 session不修改当前 session。
规则 4. 从知识点详情进入 → 必须 knowledge_item 会话,不能打开知识库会话。
规则 5. 从资料详情/阅读页进入 → 必须 material 会话。
规则 6. 从知识库详情进入 → 必须 knowledge_base 会话。
规则 7. 手动"新对话" → 在当前 scope 下新建会话。
规则 8. 切换模型 / 深度思考 / 联网搜索 → 不改变 scope继续当前会话。
```
---
## 3. ChatScope 类型定义
```typescript
type ChatScopeType =
| "knowledge_base" // 整个知识库 — scopeId = kb.id
| "folder" // 某个分类/目录 — scopeId = folder.id (KnowledgeItem with itemType=folder)
| "material" // 某份资料 — scopeId = knowledgeSource.id
| "knowledge_item" // 某个知识点 — scopeId = knowledgeItem.id
| "global" // 不绑定知识库的普通对话
// 未来扩展(本期不做):
// | "multi_source" // 多个来源组合
// | "temporary_file" // 临时上传文件
```
### 3.1 scope 字段映射
| scopeType | scopeId | parentKnowledgeBaseId | 含义 |
|-----------|---------|----------------------|------|
| `knowledge_base` | kb.id | = scopeId | 问整个知识库《计算机网络》 |
| `folder` | folder.id | kb.id | 问分类《TCP/IP 协议》里的内容 |
| `material` | source.id | kb.id | 问资料《数据库事务.pdf》 |
| `knowledge_item` | item.id | kb.id | 问知识点"TCP 三次握手" |
| `global` | null | null | 普通对话,不检索知识库 |
---
## 4. 完整决策表
| # | 用户行为 | 当前会话 scope | 目标 scope | 结果 |
|---|---------|-------------|----------|------|
| 1 | 从知识库详情点"AI 对话" | 无 | knowledge_base(KA) | 打开/创建 KA 的 kb 会话 |
| 2 | 从资料详情点"AI 对话" | 无 | material(MA) | 打开/创建 MA 的 material 会话 |
| 3 | 从资料阅读页点"AI 对话" | 无 | material(MA) | 打开/创建 MA 的 material 会话(附加阅读位置) |
| 4 | 从知识点详情点"AI 对话" | 无 | knowledge_item(IA) | 打开/创建 IA 的 item 会话 |
| 5 | 从分类页点"AI 对话" | 无 | folder(FA) | 打开/创建 FA 的 folder 会话 |
| 6 | 在知识库 KA 会话中切知识库 KB | kb(KA) | kb(KB) | 打开/创建 kb(KB) 会话 |
| 7 | 在 material(MA) 会话中切 material(MB) | material(MA) | material(MB) | 打开/创建 material(MB) 会话 |
| 8 | 在 knowledge_item(IA) 会话切知识库 | item(IA) | kb(KA) | 打开/创建 kb(KA) 会话 |
| 9 | 在 material(MA) 会话切知识库 | material(MA) | kb(KA) | 打开/创建 kb(KA) 会话 |
| 10 | 手动点"新对话" | 当前 scope X | scope X | 在 scope X 下新建会话(不继续旧会话) |
| 11 | 切换模型模式 | 当前 scope X | scope X | 继续当前会话 |
| 12 | 开启深度思考 | 当前 scope X | scope X | 继续当前会话 |
| 13 | 切换联网搜索 | 当前 scope X | scope X | 继续当前会话 |
| 14 | 删除会话 | scope X | 无 | 软删除当前会话isDeleted=true |
| 15 | 删除知识点 IA | item(IA) 会话存在 | IA 消失 | 会话标记来源已删除 |
| 16 | 删除资料 MA | material(MA) 会话存在 | MA 消失 | 会话标记来源已删除 |
| 17 | 删除知识库 KA | KA 相关会话存在 | KA 消失 | 知识库级会话也标记来源已删除 |
---
## 5. 数据模型
### 5.1 ChatSession目标 Schema
```prisma
model ChatSession {
id String @id @default(cuid())
userId String
// === 核心 scope 字段 ===
scopeType String @default("knowledge_base") @db.VarChar(32)
scopeId String?
parentKnowledgeBaseId String?
// === 元数据 ===
title String @default("新对话") @db.VarChar(200)
createdFrom String @default("global_ai_entry") @db.VarChar(32)
// ^ "knowledge_base_detail" | "material_detail" | "material_reader"
// | "knowledge_item_detail" | "folder_detail" | "global_ai_entry"
isPinned Boolean @default(false)
isArchived Boolean @default(false)
isDeleted Boolean @default(false)
// === 模型设置 ===
modelMode String @default("normal") @db.VarChar(16)
// ^ "normal" | "deep_think" | "web_search"
modelId String? @db.VarChar(64)
// === 时间 ===
lastMessageAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages ChatMessage[]
@@index([userId])
@@index([userId, scopeType, scopeId]) // open-or-create 核心查询
@@index([userId, parentKnowledgeBaseId]) // 知识库内会话列表
@@index([userId, isDeleted]) // 排除已删除会话
}
```
### 5.2 ChatMessage新增字段
```prisma
model ChatMessage {
id String @id @default(cuid())
sessionId String
role String @db.VarChar(16) // "user" | "assistant"
content String @db.LongText
tokens Int @default(0)
// === scope 快照(消息级别不可变记录) ===
scopeSnapshot Json? // { scopeType, scopeId, parentKnowledgeBaseId }
createdAt DateTime @default(now())
session ChatSession @relation(fields: [sessionId], references: [id])
citations ChatCitation[]
@@index([sessionId])
}
```
### 5.3 ChatCitation完善字段
```prisma
model ChatCitation {
id String @id @default(cuid())
messageId String
chunkId String?
sourceId String?
sourceTitle String? @db.VarChar(255)
excerptText String? @db.VarChar(2000)
// === 精确定位 ===
pageNumber Int?
lineStart Int?
lineEnd Int?
createdAt DateTime @default(now())
message ChatMessage @relation(fields: [messageId], references: [id])
@@index([messageId])
@@index([sourceId])
@@index([createdAt])
}
```
### 5.4 字段变更汇总
| 模型 | 变更 | 字段 | 类型 | Issue |
|------|------|------|------|-------|
| ChatSession | **新增** | scopeType | String @default("knowledge_base") | #79 |
| ChatSession | **新增** | scopeId | String? | #79 |
| ChatSession | **新增** | parentKnowledgeBaseId | String? | #79 |
| ChatSession | **新增** | createdFrom | String @default("global_ai_entry") | #92 |
| ChatSession | **新增** | isPinned | Boolean @default(false) | #93 |
| ChatSession | **新增** | isArchived | Boolean @default(false) | #93 |
| ChatSession | **新增** | isDeleted | Boolean @default(false) | #92 |
| ChatSession | **新增** | modelMode | String @default("normal") | (附带) |
| ChatSession | **新增** | modelId | String? | (附带) |
| ChatSession | **新增** | lastMessageAt | DateTime? | (附带) |
| ChatSession | **移除** | knowledgeBaseId | — | #79 |
| ChatSession | **移除** | knowledgeItemIds | — | #79 |
| ChatMessage | **新增** | scopeSnapshot | Json? | #73 |
| ChatCitation | **新增** | lineStart | Int? | #72 |
| ChatCitation | **新增** | lineEnd | Int? | #72 |
| ChatCitation | **新增** | @@index([sourceId]) | — | #72 |
---
## 6. API 契约
### 6.1 创建会话 — open-or-create
```
POST /rag-chat/sessions
```
**Request Body:**
```json
{
"scopeType": "material",
"scopeId": "clx7abc123",
"parentKnowledgeBaseId": "clx7kb456",
"createdFrom": "material_detail",
"title": "新对话"
}
```
**Behavior (open-or-create):**
```
1. 查询现有会话:
SELECT * FROM ChatSession
WHERE userId = ? AND scopeType = ? AND scopeId = ?
AND isDeleted = false
ORDER BY updatedAt DESC LIMIT 1
2. 如果有 → 返回该会话(继续上次)
3. 如果没有 → 创建新会话并返回
4. scope 不可变规则:
- scopeType + scopeId 一旦在创建时设定PATCH 不可修改
- parentKnowledgeBaseId 由后端从 scope 推导,不可由前端覆盖
```
**Response:** `ChatSession`
### 6.2 会话列表
```
GET /rag-chat/sessions
```
**Query Parameters:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| scopeType | string | 否 | 过滤 scope 类型 |
| scopeId | string | 否 | 过滤 scope ID |
| parentKnowledgeBaseId | string | 否 | 查询知识库下所有会话 |
| isArchived | boolean | 否 | 默认 false不返回归档 |
| page | number | 否 | 分页,默认 1 |
| limit | number | 否 | 分页,默认 20 |
**示例:**
```bash
# 获取某个知识库下的所有会话(不分 scope
GET /rag-chat/sessions?parentKnowledgeBaseId=clx7kb456&page=1&limit=20
# 获取当前 material 的历史会话
GET /rag-chat/sessions?scopeType=material&scopeId=clx7abc123
# 获取全局历史
GET /rag-chat/sessions?page=1&limit=50
```
### 6.3 发消息
```
POST /rag-chat/sessions/:id/messages # 同步
POST /rag-chat/sessions/:id/stream # SSE 流式
```
**Request Body不变:**
```json
{ "content": "TCP 三次握手的过程是什么?" }
```
注意:发消息只需要 `sessionId` + `content`。scope 信息已在创建会话时绑定。
### 6.4 会话操作
```
DELETE /rag-chat/sessions/:id # 软删除 (isDeleted = true)
PATCH /rag-chat/sessions/:id # 更新 title / isPinned / isArchived / modelMode
```
**PATCH Body:**
```json
{
"title": "三次握手讨论",
"isPinned": true,
"isArchived": false,
"modelMode": "deep_think"
}
```
scopeType / scopeId 不可通过 PATCH 修改。
---
## 7. open-or-create 算法
```typescript
async openOrCreateSession(
userId: string,
scope: ChatScope,
createdFrom: string,
): Promise<ChatSession> {
// 1. 查找匹配的活跃会话
const existing = await prisma.chatSession.findFirst({
where: {
userId,
scopeType: scope.scopeType,
scopeId: scope.scopeId ?? null,
isDeleted: false,
},
orderBy: { updatedAt: 'desc' },
});
if (existing) {
return existing;
}
// 2. 创建新会话
return prisma.chatSession.create({
data: {
userId,
scopeType: scope.scopeType,
scopeId: scope.scopeId ?? null,
parentKnowledgeBaseId: deriveParentKbId(scope),
createdFrom,
title: '新对话',
},
});
}
function deriveParentKbId(scope: ChatScope): string | null {
switch (scope.scopeType) {
case 'knowledge_base':
return scope.scopeId;
case 'material':
case 'folder':
case 'knowledge_item':
return scope.parentKnowledgeBaseId ?? null;
case 'global':
return null;
}
}
```
---
## 8. 上下文检索策略 (loadContext)
### 8.1 按 scope 决定检索范围
| scopeType | 检索范围 | 实现方式 |
|-----------|---------|---------|
| `knowledge_base` | 整个知识库的所有 chunk | `WHERE kbId = parentKnowledgeBaseId` |
| `folder` | 该 folder 及子节点的所有 chunk | 先查 folder 下所有 item.id`WHERE knowledgeItemId IN (...)` |
| `material` | 只检索该资料的 chunk | `WHERE sourceId = scopeId` |
| `knowledge_item` | 只检索该知识点的 chunk | `WHERE knowledgeItemId = scopeId` |
| `global` | 无知识库上下文 | 不检索,纯模型回答 |
### 8.2 检索实现伪代码
```typescript
async loadContext(session: ChatSession): Promise<SearchResult[]> {
switch (session.scopeType) {
case 'knowledge_base':
// 检索该知识库下的所有 chunk
return this.vectorSearch.search({
filter: { knowledgeBaseId: session.parentKnowledgeBaseId },
topK: 10,
});
case 'material':
// 只检索该资料的 chunk
return this.vectorSearch.search({
filter: { sourceId: session.scopeId },
topK: 10,
});
case 'knowledge_item':
// 只检索该知识点的 chunk
return this.vectorSearch.search({
filter: { knowledgeItemId: session.scopeId },
topK: 5,
});
case 'folder':
// 检索该 folder 下所有 item 的 chunk
const itemIds = await this.getFolderItemIds(session.scopeId!);
return this.vectorSearch.search({
filter: { knowledgeItemId: { in: itemIds } },
topK: 10,
});
case 'global':
return []; // 不检索
}
}
```
---
## 9. iOS 集成
### 9.1 ChatEntryContext 模型
```swift
struct ChatEntryContext {
let scopeType: ChatScopeType
let scopeId: String?
let scopeName: String // 显示名,如"计算机网络" 或 "TCP 三次握手.md"
let parentKnowledgeBaseId: String?
let createdFrom: String // "knowledge_base_detail" | "material_detail" | ...
}
```
### 9.2 6 个入口
| # | 入口页面 | scopeType | scopeId = | parentKB | createdFrom |
|---|---------|-----------|-----------|----------|-------------|
| 1 | 知识库详情 → "AI 对话" | knowledge_base | kb.id | kb.id | knowledge_base_detail |
| 2 | 资料详情 → "AI 对话" | material | source.id | kb.id | material_detail |
| 3 | 资料阅读页 → "AI 对话" | material | source.id | kb.id | material_reader |
| 4 | 知识点详情 → "AI 对话" | knowledge_item | item.id | kb.id | knowledge_item_detail |
| 5 | 分类页 → "AI 对话" | folder | folder.id | kb.id | folder_detail |
| 6 | Tab/Main → "AI 对话" | global | null | null | global_ai_entry |
### 9.3 Route 参数
```swift
// Route.swift
case aiChat(context: ChatEntryContext)
// 调用示例
Route.aiChat(context: ChatEntryContext(
scopeType: .material,
scopeId: source.id,
scopeName: source.title ?? "资料",
parentKnowledgeBaseId: kb.id,
createdFrom: "material_detail"
))
```
### 9.4 AI Chat View 集成
AIChatPage 接受 `ChatEntryContext`,在 `.task` 中调用 `open-or-create`:
```swift
// AIChatViewModel
func load(entry: ChatEntryContext) async {
let session = try await RagChatService.shared.openOrCreateSession(entry: entry)
self.sessionId = session.id
await loadMessages(session.id)
}
```
---
## 10. 迁移计划
### 10.1 现有数据
当前 `ChatSession` 表约 23 个会话,字段为:
```prisma
model ChatSession {
id String
userId String
knowledgeBaseId String
title String
knowledgeItemIds Json?
}
```
### 10.2 迁移步骤
1. **Prisma migration** — 添加新字段(`scopeType`, `scopeId`, `parentKnowledgeBaseId`, `createdFrom`, `isPinned`, `isArchived`, `isDeleted`, `modelMode`, `modelId`, `lastMessageAt`),所有新增字段有默认值,不破坏现有数据
2. **数据回填脚本** — 对每个旧会话,设置:
- `scopeType = "knowledge_base"`
- `scopeId = knowledgeBaseId`
- `parentKnowledgeBaseId = knowledgeBaseId`
- `createdFrom = "legacy_migration"`
3. **迁移后** — 移除旧的 `knowledgeBaseId``knowledgeItemIds` 列(可选,可保留)
### 10.3 回填 SQL
```sql
UPDATE "ChatSession"
SET
"scopeType" = 'knowledge_base',
"scopeId" = "knowledgeBaseId",
"parentKnowledgeBaseId" = "knowledgeBaseId",
"createdFrom" = 'legacy_migration',
"lastMessageAt" = "updatedAt"
WHERE "scopeType" = 'knowledge_base' -- 默认值
AND "scopeId" IS NULL;
```
---
## 11. 边界情况处理
| 情况 | 处理方式 |
|------|---------|
| 来源被删除(资料/知识点已删除) | 会话保留但标记 `isArchived = true`,前端显示"来源已删除" |
| 知识库被删除 | 其下所有会话自动 `isArchived = true` |
| scopeId 为空字符串 | 视为 null等同于 global |
| 同一 scope 多次点"新对话" | 每次都创建新会话,不做去重 |
| 网络离线 | iOS 本地缓存会话列表,恢复后同步 |
| 并发创建 | 数据库唯一索引 `[userId, scopeType, scopeId, isDeleted]` 不做唯一约束;同一 scope 可以有多个会话(用户手动新对话) |
| 切换 scope 但保留消息 | 不迁移消息scope 变化 = 新会话 |
---
## 12. 依赖关系
```
#82 CHAT-001 (本文档)
├── #83 CHAT-002 (API Contract — 独立文档)
├── #79 M7-01 (Prisma scope 字段)
│ ├── #92 M7-02a (createdFrom + isDeleted)
│ └── #93 M7-02b (isArchived + isPinned)
├── #81 M7-05 (createSession 接受 scope)
│ ├── #76 M7-08 (open-or-create)
│ │ └── #85 CHAT-204 (scope 不可变)
│ ├── #75 M7-07 (listSessions 过滤)
│ └── #84 CHAT-203 (自动标题)
├── #74 M7-06 (loadContext 按 scope)
│ ├── #86 CHAT-302 (item 检索)
│ ├── #87 CHAT-303 (material 检索)
│ └── #88 CHAT-304 (kb 检索)
├── #72 M7-03 (ChatCitation)
├── #73 M7-04 (ChatMessage scopeSnapshot)
├── #94-96 (索引)
├── #78 M7-10 (迁移)
└── #89-#91 (测试)
```
---
## 附录 A: 完整的 ChatScope 类型定义 (TypeScript)
```typescript
// === ChatScope 类型 ===
export const CHAT_SCOPE_TYPES = [
'knowledge_base',
'folder',
'material',
'knowledge_item',
'global',
] as const;
export type ChatScopeType = (typeof CHAT_SCOPE_TYPES)[number];
export interface ChatScope {
scopeType: ChatScopeType;
scopeId: string | null;
parentKnowledgeBaseId: string | null;
}
// === 从 scope 推导 parentKnowledgeBaseId ===
export function deriveParentKbId(scope: ChatScope): string | null {
switch (scope.scopeType) {
case 'knowledge_base':
return scope.scopeId;
case 'material':
case 'folder':
case 'knowledge_item':
return scope.parentKnowledgeBaseId ?? null;
case 'global':
return null;
}
}
// === createdFrom 枚举 ===
export const CREATED_FROM_VALUES = [
'knowledge_base_detail',
'material_detail',
'material_reader',
'knowledge_item_detail',
'folder_detail',
'global_ai_entry',
'legacy_migration',
] as const;
export type CreatedFrom = (typeof CREATED_FROM_VALUES)[number];
```
## 附录 B: 完整的 iOS 类型定义 (Swift)
```swift
// MARK: - ChatScope
enum ChatScopeType: String, Codable {
case knowledgeBase = "knowledge_base"
case folder = "folder"
case material = "material"
case knowledgeItem = "knowledge_item"
case global = "global"
}
struct ChatEntryContext {
let scopeType: ChatScopeType
let scopeId: String?
let scopeName: String
let parentKnowledgeBaseId: String?
let createdFrom: String
}
// API Request
struct CreateChatSessionRequest: Codable {
let scopeType: String
let scopeId: String?
let parentKnowledgeBaseId: String?
let createdFrom: String
let title: String?
}
```
---
> **本文档是 M-CHAT 里程碑的唯一权威参考。如有冲突,以本文档为准。**