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

19 KiB
Raw Permalink Blame 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 类型定义

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

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新增字段

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完善字段

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:

{
  "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

示例:

# 获取某个知识库下的所有会话(不分 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不变:

{ "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:

{
  "title": "三次握手讨论",
  "isPinned": true,
  "isArchived": false,
  "modelMode": "deep_think"
}

scopeType / scopeId 不可通过 PATCH 修改。


7. open-or-create 算法

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.idWHERE knowledgeItemId IN (...)
material 只检索该资料的 chunk WHERE sourceId = scopeId
knowledge_item 只检索该知识点的 chunk WHERE knowledgeItemId = scopeId
global 无知识库上下文 不检索,纯模型回答

8.2 检索实现伪代码

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 模型

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 参数

// 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:

// 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 个会话,字段为:

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. 迁移后 — 移除旧的 knowledgeBaseIdknowledgeItemIds 列(可选,可保留)

10.3 回填 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)

// === 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)

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