api-server/docs/chat-scope-design.md

636 lines
19 KiB
Markdown
Raw Normal View History

# 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 里程碑的唯一权威参考。如有冲突,以本文档为准。**