Jelajahi Sumber

飞书机器人对接,lint检查格式化

anhuiqiang 1 Minggu lalu
induk
melakukan
72ba7c21ab
100 mengubah file dengan 9233 tambahan dan 4160 penghapusan
  1. 130 0
      docs/QUICK-REFERENCE.md
  2. 297 0
      docs/feishu-assessment-implementation-summary.md
  3. 1341 0
      docs/feishu-assessment-integration-design.md
  4. 208 0
      docs/feishu-assessment-integration-summary.md
  5. 1 0
      schema.txt
  6. 67 49
      server/src/admin/admin.controller.ts
  7. 4 4
      server/src/admin/admin.module.ts
  8. 131 96
      server/src/admin/admin.service.ts
  9. 3 6
      server/src/ai/embedding.service.ts
  10. 260 234
      server/src/api/api-v1.controller.ts
  11. 8 4
      server/src/api/api.controller.ts
  12. 1 1
      server/src/api/api.module.ts
  13. 6 2
      server/src/api/api.service.ts
  14. 3 6
      server/src/app.module.ts
  15. 107 64
      server/src/assessment/assessment.controller.ts
  16. 1 1
      server/src/assessment/assessment.module.ts
  17. 959 655
      server/src/assessment/assessment.service.ts
  18. 43 34
      server/src/assessment/controllers/template.controller.ts
  19. 53 43
      server/src/assessment/dto/create-template.dto.ts
  20. 30 26
      server/src/assessment/entities/assessment-answer.entity.ts
  21. 31 27
      server/src/assessment/entities/assessment-question.entity.ts
  22. 69 66
      server/src/assessment/entities/assessment-session.entity.ts
  23. 54 50
      server/src/assessment/entities/assessment-template.entity.ts
  24. 56 44
      server/src/assessment/graph/builder.ts
  25. 69 46
      server/src/assessment/graph/nodes/analyzer.node.ts
  26. 131 172
      server/src/assessment/graph/nodes/generator.node.ts
  27. 146 85
      server/src/assessment/graph/nodes/grader.node.ts
  28. 82 61
      server/src/assessment/graph/nodes/interviewer.node.ts
  29. 116 116
      server/src/assessment/graph/state.ts
  30. 54 48
      server/src/assessment/services/content-filter.service.ts
  31. 73 39
      server/src/assessment/services/template.service.ts
  32. 1 1
      server/src/auth/admin.guard.ts
  33. 41 34
      server/src/auth/api-key.guard.ts
  34. 1 1
      server/src/auth/auth.controller.ts
  35. 4 4
      server/src/auth/auth.service.ts
  36. 136 117
      server/src/auth/combined-auth.guard.ts
  37. 17 17
      server/src/auth/entities/api-key.entity.ts
  38. 4 2
      server/src/auth/jwt.strategy.ts
  39. 4 2
      server/src/auth/local.strategy.ts
  40. 1 1
      server/src/auth/public.decorator.ts
  41. 16 16
      server/src/auth/roles.guard.ts
  42. 7 5
      server/src/auth/super-admin.guard.ts
  43. 10 10
      server/src/auth/tenant-admin.guard.ts
  44. 40 9
      server/src/chat/chat.controller.ts
  45. 1 1
      server/src/chat/chat.module.ts
  46. 289 82
      server/src/chat/chat.service.ts
  47. 3 6
      server/src/common/constants.ts
  48. 60 9
      server/src/common/file-support.constants.ts
  49. 19 19
      server/src/data-source.ts
  50. 44 31
      server/src/elasticsearch/elasticsearch.service.ts
  51. 36 0
      server/src/feishu/dto/assessment-command.dto.ts
  52. 6 6
      server/src/feishu/dto/bind-bot.dto.ts
  53. 26 18
      server/src/feishu/dto/create-bot.dto.ts
  54. 11 11
      server/src/feishu/dto/webhook.dto.ts
  55. 46 0
      server/src/feishu/entities/feishu-assessment-session.entity.ts
  56. 48 42
      server/src/feishu/entities/feishu-bot.entity.ts
  57. 26 7
      server/src/feishu/feishu-ws.manager.ts
  58. 237 224
      server/src/feishu/feishu.controller.ts
  59. 19 10
      server/src/feishu/feishu.module.ts
  60. 509 356
      server/src/feishu/feishu.service.ts
  61. 61 0
      server/src/feishu/services/assessment-command.parser.spec.ts
  62. 135 0
      server/src/feishu/services/assessment-command.parser.ts
  63. 616 0
      server/src/feishu/services/feishu-assessment.service.ts
  64. 16 16
      server/src/i18n/i18n.interceptor.ts
  65. 6 6
      server/src/i18n/i18n.middleware.ts
  66. 3 3
      server/src/i18n/i18n.module.ts
  67. 74 24
      server/src/i18n/i18n.service.ts
  68. 1 1
      server/src/i18n/i18n.store.ts
  69. 230 117
      server/src/i18n/messages.ts
  70. 44 34
      server/src/import-task/import-task.controller.ts
  71. 40 34
      server/src/import-task/import-task.entity.ts
  72. 8 8
      server/src/import-task/import-task.module.ts
  73. 380 293
      server/src/import-task/import-task.service.ts
  74. 71 37
      server/src/knowledge-base/chunk-config.service.ts
  75. 86 33
      server/src/knowledge-base/embedding.service.ts
  76. 79 32
      server/src/knowledge-base/knowledge-base.controller.ts
  77. 1 1
      server/src/knowledge-base/knowledge-base.entity.ts
  78. 1 1
      server/src/knowledge-base/knowledge-base.module.ts
  79. 386 134
      server/src/knowledge-base/knowledge-base.service.ts
  80. 35 13
      server/src/knowledge-base/memory-monitor.service.ts
  81. 23 6
      server/src/knowledge-group/knowledge-group.controller.ts
  82. 9 3
      server/src/knowledge-group/knowledge-group.entity.ts
  83. 3 1
      server/src/knowledge-group/knowledge-group.module.ts
  84. 172 39
      server/src/knowledge-group/knowledge-group.service.ts
  85. 42 24
      server/src/libreoffice/libreoffice.service.ts
  86. 4 2
      server/src/main.ts
  87. 13 5
      server/src/migrations/1737800000000-AddKnowledgeBaseEnhancements.ts
  88. 79 54
      server/src/migrations/1772329237979-AddDefaultTenant.ts
  89. 42 32
      server/src/migrations/1772334811108-AddTenantModule.ts
  90. 12 12
      server/src/migrations/1772340000000-AddParentIdToKnowledgeGroups.ts
  91. 18 13
      server/src/migrations/1773198650000-AddAssessmentTablesManual.ts
  92. 35 0
      server/src/migrations/1773200000000-AddFeishuBotKnowledgeFields.ts
  93. 32 0
      server/src/migrations/1773200000001-CreateFeishuAssessmentSessionTable.ts
  94. 3 1
      server/src/model-config/dto/create-model-config.dto.ts
  95. 15 4
      server/src/model-config/model-config.controller.ts
  96. 1 1
      server/src/model-config/model-config.module.ts
  97. 81 32
      server/src/model-config/model-config.service.ts
  98. 48 23
      server/src/note/note-category.controller.ts
  99. 39 37
      server/src/note/note-category.entity.ts
  100. 93 64
      server/src/note/note-category.service.ts

+ 130 - 0
docs/QUICK-REFERENCE.md

@@ -0,0 +1,130 @@
+# 飞书机器人快速参考
+
+## 一、当前状态
+
+### 飞书机器人知识库对接
+- **现状**:使用默认知识库(用户所有文件)
+- **原因**:`selectedFiles` 和 `selectedGroups` 参数为 `undefined`
+- **文件**:`server/src/feishu/feishu.service.ts` (line 311-331)
+
+### 人才测评模块
+- **位置**:`server/src/assessment/`
+- **功能**:基于知识库生成问题、评估答案、生成报告
+- **接口**:REST API + SSE 流式更新
+
+---
+
+## 二、快速命令
+
+### 飞书机器人命令
+```
+/assessment start [kbId|templateId]  # 开始测评
+/assessment answer [answer]          # 提交答案
+/assessment status                   # 查看状态
+/assessment result                   # 获取结果
+/assessment help                     # 帮助信息
+```
+
+### 直接回复(无需命令前缀)
+```
+# 直接回复答案,系统自动识别
+```
+
+---
+
+## 三、配置示例
+
+### 创建带知识库配置的机器人
+```bash
+POST /feishu/bots
+{
+    "appId": "cli_xxx",
+    "appSecret": "xxx",
+    "botName": "测评机器人",
+    "knowledgeBaseId": "kb_xxx",      # 特定知识库
+    "knowledgeGroupId": "group_xxx"   # 或知识组
+}
+```
+
+### 更新知识库配置
+```bash
+PATCH /feishu/bots/:id/knowledge
+{
+    "knowledgeBaseId": "kb_xxx",
+    "knowledgeGroupId": null
+}
+```
+
+---
+
+## 四、文件位置
+
+### 核心文件
+```
+server/src/feishu/
+├── feishu.service.ts              # 主服务
+├── feishu.controller.ts            # 控制器
+├── feishu-ws.manager.ts           # WebSocket 管理
+├── entities/
+│   ├── feishu-bot.entity.ts       # 机器人实体
+│   └── feishu-assessment-session.entity.ts  # 测评会话实体
+├── dto/
+│   ├── create-bot.dto.ts          # 创建机器人 DTO
+│   └── assessment-command.dto.ts  # 命令 DTO
+└── services/
+    ├── assessment-command.parser.ts  # 命令解析器
+    └── feishu-assessment.service.ts  # 测评服务
+```
+
+### 数据库迁移
+```
+server/src/migrations/
+├── XXXXXX-AddFeishuBotKnowledgeFields.ts      # 添加知识库字段
+└── XXXXXX-CreateFeishuAssessmentSessionTable.ts  # 创建测评会话表
+```
+
+---
+
+## 五、实施检查清单
+
+### 阶段 1:基础架构
+- [ ] 创建数据库迁移脚本
+- [ ] 更新 FeishuBot 实体
+- [ ] 更新 CreateFeishuBotDto
+- [ ] 修改 FeishuService.processChatMessage()
+
+### 阶段 2:测评集成
+- [ ] 创建 FeishuAssessmentSession 实体
+- [ ] 实现命令解析器
+- [ ] 实现 FeishuAssessmentService
+- [ ] 集成到 FeishuService
+- [ ] 设计飞书卡片模板
+
+### 阶段 3:测试
+- [ ] 单元测试
+- [ ] 集成测试
+- [ ] 文档更新
+
+---
+
+## 六、常见问题
+
+### Q1: 如何让飞书机器人只搜索特定知识库?
+**A**: 在创建机器人时设置 `knowledgeBaseId` 字段
+
+### Q2: 如何切换知识库?
+**A**: 使用 PATCH `/feishu/bots/:id/knowledge` 接口更新配置
+
+### Q3: 测评命令不生效怎么办?
+**A**: 检查命令格式是否正确,确保以 `/assessment` 或 `/测评` 开头
+
+### Q4: 如何查看测评进度?
+**A**: 发送 `/assessment status` 或 `/assessment result`
+
+---
+
+## 七、参考文档
+
+- 完整设计文档: `feishu-assessment-integration-design.md`
+- 设计摘要: `feishu-assessment-integration-summary.md`
+- 飞书开放平台: https://open.feishu.cn/document

+ 297 - 0
docs/feishu-assessment-implementation-summary.md

@@ -0,0 +1,297 @@
+# 飞书机器人与人才测评集成 - 实现总结
+
+> **文档版本**: v1.0  
+> **创建日期**: 2026-03-17  
+> **状态**: 实现完成
+
+---
+
+## 一、实现概述
+
+### 已完成的功能
+1. ✅ **知识库选择机制** - 飞书机器人支持配置特定知识库或知识组
+2. ✅ **命令解析器** - 支持多种语言的测评命令识别
+3. ✅ **测评会话管理** - 完整的会话生命周期管理
+4. ✅ **飞书卡片交互** - 友好的问题展示和结果报告
+
+### 架构图
+```
+用户消息
+    ↓
+FeishuController._handleMessage()
+    ↓
+[命令识别] → 是测评命令? → FeishuAssessmentService.handleCommand()
+    ↓                              ↓
+否                           命令解析器
+    ↓                              ↓
+FeishuService.processChatMessage()  执行对应操作
+    ↓                              ↓
+ChatService.streamChat()         [开始/回答/状态/结果]
+    ↓                              ↓
+RAG搜索 + LLM生成                AssessmentService
+    ↓                              ↓
+飞书消息回复                    飞书消息回复
+```
+
+---
+
+## 二、数据库变更
+
+### 1. FeishuBot 实体新增字段
+**文件**: `server/src/feishu/entities/feishu-bot.entity.ts`
+
+```typescript
+@Column({ name: 'knowledge_base_id', nullable: true, length: 36 })
+knowledgeBaseId: string;
+
+@Column({ name: 'knowledge_group_id', nullable: true, length: 36 })
+knowledgeGroupId: string;
+```
+
+### 2. 新增测评会话表
+**文件**: `server/src/feishu/entities/feishu-assessment-session.entity.ts`
+
+```sql
+CREATE TABLE feishu_assessment_sessions (
+    id VARCHAR(36) PRIMARY KEY,
+    bot_id VARCHAR(36) NOT NULL,
+    open_id VARCHAR(255) NOT NULL,
+    assessment_session_id VARCHAR(36) NOT NULL,
+    status ENUM('active', 'completed', 'cancelled') DEFAULT 'active',
+    current_question_index INT DEFAULT 0,
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_bot_open (bot_id, open_id),
+    INDEX idx_assessment_session (assessment_session_id),
+    CONSTRAINT fk_feishu_assessment_bot 
+        FOREIGN KEY (bot_id) 
+        REFERENCES feishu_bots(id) 
+        ON DELETE CASCADE
+);
+```
+
+### 3. 迁移脚本
+- `1773200000000-AddFeishuBotKnowledgeFields.ts` - 添加知识库字段
+- `1773200000001-CreateFeishuAssessmentSessionTable.ts` - 创建测评会话表
+
+---
+
+## 三、核心组件实现
+
+### 1. 命令解析器 (AssessmentCommandParser)
+**文件**: `server/src/feishu/services/assessment-command.parser.ts`
+
+**功能**:
+- 识别测评命令前缀:`/assessment`, `/测评`, `/eval`, `/测评评估`
+- 支持多语言命令:start/开始, answer/回答, status/状态, result/结果
+- 解析命令参数
+
+**示例**:
+```typescript
+const parser = new AssessmentCommandParser();
+const command = parser.parse('/assessment start kb_xxx');
+// 结果: { type: 'start', parameters: ['kb_xxx'], ... }
+```
+
+### 2. 测评服务 (FeishuAssessmentService)
+**文件**: `server/src/feishu/services/feishu-assessment.service.ts`
+
+**核心方法**:
+- `handleCommand()` - 处理测评命令
+- `startAssessment()` - 开始测评会话
+- `submitAnswer()` - 提交答案
+- `getStatus()` - 获取测评状态
+- `getResult()` - 获取测评结果
+- `cancelAssessment()` - 取消测评
+
+**会话流程**:
+1. 用户发送 `/assessment start`
+2. 系统创建测评会话,发送第一个问题卡片
+3. 用户回复答案(直接回复或 `/assessment answer`)
+4. 系统评估答案,发送下一个问题
+5. 重复步骤3-4直到完成
+6. 系统发送测评结果报告
+
+### 3. 集成到 FeishuService
+**文件**: `server/src/feishu/feishu.service.ts`
+
+**新增方法**:
+```typescript
+isAssessmentCommand(message: string): boolean {
+    const trimmed = message.trim().toLowerCase();
+    const commandPrefixes = ['/assessment', '/测评', '/eval', '/测评评估'];
+    return commandPrefixes.some(prefix => trimmed.startsWith(prefix.toLowerCase()));
+}
+```
+
+### 4. 集成到 FeishuController
+**文件**: `server/src/feishu/feishu.controller.ts`
+
+**修改 _handleMessage 方法**:
+```typescript
+if (this.feishuService.isAssessmentCommand(userText)) {
+    // 委托给测评服务
+    await this.feishuAssessmentService.handleCommand(bot, openId, userText);
+} else {
+    // 使用默认聊天处理
+    await this.feishuService.processChatMessage(bot, openId, messageId, userText);
+}
+```
+
+---
+
+## 四、API 接口更新
+
+### 1. 创建/更新飞书机器人
+**端点**: `POST /feishu/bots`
+
+**请求体**:
+```json
+{
+    "appId": "cli_xxx",
+    "appSecret": "xxx",
+    "botName": "测评机器人",
+    "knowledgeBaseId": "kb_xxx",      // 可选:特定知识库
+    "knowledgeGroupId": "group_xxx"   // 可选:知识组
+}
+```
+
+### 2. 更新知识库配置
+**端点**: `PATCH /feishu/bots/:id/knowledge`
+
+**请求体**:
+```json
+{
+    "knowledgeBaseId": "kb_xxx",
+    "knowledgeGroupId": null
+}
+```
+
+---
+
+## 五、命令参考
+
+### 支持的命令前缀
+- `/assessment`
+- `/测评`
+- `/eval`
+- `/测评评估`
+
+### 命令列表
+| 命令 | 参数 | 说明 |
+|------|------|------|
+| `start [kbId\|templateId]` | 可选 | 开始测评 |
+| `answer [answer]` | 必需 | 提交答案 |
+| `status` | - | 查看状态 |
+| `result` | - | 获取结果 |
+| `help` | - | 显示帮助 |
+| `cancel` | - | 取消测评 |
+
+### 使用示例
+```
+用户: /assessment start
+系统: [发送第一个问题卡片]
+
+用户: 这是我的答案
+系统: [评估答案并发送下一个问题]
+
+用户: /assessment status
+系统: 测评状态: 进度 3/10, 状态: 进行中
+
+用户: /assessment result
+系统: [发送测评结果报告]
+```
+
+---
+
+## 六、文件清单
+
+### 新增文件
+1. `server/src/feishu/dto/assessment-command.dto.ts` - 命令DTO
+2. `server/src/feishu/entities/feishu-assessment-session.entity.ts` - 测评会话实体
+3. `server/src/feishu/services/assessment-command.parser.ts` - 命令解析器
+4. `server/src/feishu/services/feishu-assessment.service.ts` - 测评服务
+5. `server/src/migrations/1773200000000-AddFeishuBotKnowledgeFields.ts` - 迁移脚本1
+6. `server/src/migrations/1773200000001-CreateFeishuAssessmentSessionTable.ts` - 迁移脚本2
+
+### 修改文件
+1. `server/src/feishu/entities/feishu-bot.entity.ts` - 添加知识库字段
+2. `server/src/feishu/dto/create-bot.dto.ts` - 添加知识库配置字段
+3. `server/src/feishu/feishu.service.ts` - 添加命令识别方法
+4. `server/src/feishu/feishu.controller.ts` - 集成测评服务
+5. `server/src/feishu/feishu.module.ts` - 注册新服务
+
+---
+
+## 七、实施步骤
+
+### 阶段 1: 基础架构 ✅
+- [x] 创建数据库迁移脚本
+- [x] 更新 FeishuBot 实体和 DTO
+- [x] 修改 FeishuService 支持知识库选择
+
+### 阶段 2: 测评集成 ✅
+- [x] 创建测评会话实体
+- [x] 实现命令解析器
+- [x] 实现测评服务
+- [x] 集成到 FeishuService 和 Controller
+
+### 阶段 3: 测试优化 ⏳
+- [ ] 运行数据库迁移
+- [ ] 测试命令解析功能
+- [ ] 测试完整测评流程
+- [ ] 性能测试和优化
+
+---
+
+## 八、安全考虑
+
+1. **多租户隔离**:所有查询包含 `userId` 和 `tenantId` 过滤
+2. **命令验证**:白名单命令验证,防止注入攻击
+3. **会话超时**:建议设置测评会话超时时间(如 24 小时)
+4. **数据隐私**:测评结果仅对授权用户可见
+
+---
+
+## 九、下一步工作
+
+1. **运行数据库迁移**
+   ```bash
+   yarn migration:run
+   ```
+
+2. **测试命令解析**
+   ```bash
+   node test-feishu-assessment.js
+   ```
+
+3. **集成测试**
+   - 创建飞书机器人
+   - 配置知识库
+   - 发送 `/assessment start` 命令
+   - 完成测评流程
+
+4. **文档完善**
+   - 更新用户使用文档
+   - 添加 API 文档
+   - 编写故障排除指南
+
+---
+
+## 十、总结
+
+本次实现完成了飞书机器人与人才测评的完整集成:
+
+1. **知识库选择**:飞书机器人现在可以配置特定知识库或知识组
+2. **命令解析**:支持多语言的测评命令识别
+3. **会话管理**:完整的测评会话生命周期管理
+4. **交互体验**:友好的飞书卡片交互
+
+系统架构清晰,代码结构良好,易于维护和扩展。
+
+---
+
+**相关文档**:
+- [完整设计文档](./feishu-assessment-integration-design.md)
+- [设计摘要](./feishu-assessment-integration-summary.md)
+- [快速参考](./QUICK-REFERENCE.md)

+ 1341 - 0
docs/feishu-assessment-integration-design.md

@@ -0,0 +1,1341 @@
+# 飞书机器人与人才测评集成设计文档
+
+> **文档版本**: v1.0  
+> **创建日期**: 2026-03-17  
+> **作者**: AI Assistant  
+> **状态**: Draft
+
+---
+
+## 目录
+
+1. [概述](#概述)
+2. [现状分析](#现状分析)
+3. [需求分析](#需求分析)
+4. [详细设计方案](#详细设计方案)
+5. [API 接口设计](#api-接口设计)
+6. [数据库设计](#数据库设计)
+7. [实施计划](#实施计划)
+8. [安全考虑](#安全考虑)
+9. [附录](#附录)
+
+---
+
+## 概述
+
+### 背景
+本项目是一个基于 RAG(检索增强生成)的问答系统,支持多知识库管理。飞书机器人作为外部接入点,目前与聊天系统集成,但知识库选择机制不明确。用户希望:
+1. 明确飞书机器人当前对接的知识库
+2. 将飞书机器人与人才测评模块集成
+
+### 设计目标
+- 明确飞书机器人的知识库选择机制
+- 实现飞书机器人与人才测评的完整集成
+- 保持多租户隔离和系统安全性
+- 提供友好的用户交互体验
+
+---
+
+## 现状分析
+
+### 1. 飞书机器人知识库对接现状
+
+#### 当前实现位置
+- **主服务**: `D:\aura\AuraK\server\src\feishu\feishu.service.ts`
+- **控制器**: `D:\aura\AuraK\server\src\feishu\feishu.controller.ts`
+- **WebSocket 管理**: `D:\aura\AuraK\server\src\feishu\feishu-ws.manager.ts`
+
+#### 集成方式
+飞书机器人通过以下两种方式接收消息:
+1. **Webhook**:飞书开放平台推送事件
+2. **WebSocket**:实时消息推送(推荐,性能更好)
+
+#### 知识库选择逻辑(关键代码)
+```typescript
+// feishu.service.ts (line 311-331)
+const stream = this.chatService.streamChat(
+    userMessage,
+    [],
+    userId,
+    llmModel as any,
+    language,
+    undefined,  // selectedEmbeddingId - 未指定
+    undefined,  // selectedGroups - 未指定
+    undefined,  // selectedFiles - 未指定 ← 关键点
+    undefined,  // historyId
+    false,      // enableRerank
+    // ... 其他参数
+    tenantId,
+);
+```
+
+**结论**:飞书机器人当前使用**默认知识库**(用户的所有文件),因为 `selectedFiles` 和 `selectedGroups` 都是 `undefined`。
+
+#### 数据库实体
+```typescript
+// feishu-bot.entity.ts
+@Entity('feishu_bots')
+export class FeishuBot {
+    id: string;
+    userId: string;
+    appId: string;
+    appSecret: string;
+    botName?: string;
+    enabled: boolean;
+    isDefault: boolean;
+    useWebSocket: boolean;
+    // ❌ 缺少知识库配置字段
+}
+```
+
+### 2. 人才测评模块现状
+
+#### 模块位置
+- **主服务**: `D:\aura\AuraK\server\src\assessment\assessment.service.ts`
+- **控制器**: `D:\aura\AuraK\server\src\assessment\assessment.controller.ts`
+- **实体**: `D:\aura\AuraK\server\src\assessment\entities\`
+
+#### 核心功能
+1. **会话管理**:创建、查询、删除测评会话
+2. **问题生成**:基于知识库内容生成测评问题
+3. **问答交互**:用户回答问题,系统评估并生成下一个问题
+4. **报告生成**:测评完成后生成详细报告和评分
+5. **流式支持**:实时更新测评进度
+
+#### 关键接口
+```typescript
+// assessment.controller.ts
+POST /assessment/start          // 开始测评会话
+POST /assessment/:id/answer     // 提交答案
+SSE  /assessment/:id/start-stream  // 流式获取初始问题
+SSE  /assessment/:id/answer-stream // 流式获取评估结果
+GET  /assessment/:id/state      // 获取会话状态
+GET  /assessment                // 获取历史记录
+```
+
+#### 集成点
+- 使用 `KnowledgeBaseService` 和 `KnowledgeGroupService` 获取内容
+- 使用 `RagService` 进行混合搜索
+- 使用 `ChatService` 进行 LLM 交互
+- 使用 LangGraph 构建评估图算法
+
+---
+
+## 需求分析
+
+### 用户需求
+1. **明确知识库选择机制**
+   - 飞书机器人当前对接哪个知识库?
+   - 如何配置飞书机器人使用特定知识库?
+
+2. **飞书机器人与人才测评集成**
+   - 通过飞书机器人启动测评
+   - 通过飞书机器人回答测评问题
+   - 通过飞书机器人获取测评结果
+
+### 功能需求
+1. **知识库配置功能**
+   - 支持为每个飞书机器人配置特定知识库或知识组
+   - 支持动态切换知识库
+
+2. **测评命令支持**
+   - `/assessment start [kbId|templateId]` - 开始测评
+   - `/assessment answer [answer]` - 回答问题
+   - `/assessment status` - 查看状态
+   - `/assessment result` - 获取结果
+
+3. **交互体验优化**
+   - 使用飞书卡片展示问题
+   - 实时更新测评进度
+   - 友好的错误提示
+
+### 非功能需求
+1. **安全性**:多租户隔离,防止越权访问
+2. **性能**:WebSocket 实时推送,避免超时
+3. **可扩展性**:支持未来新增测评类型
+4. **兼容性**:不影响现有聊天功能
+
+---
+
+## 详细设计方案
+
+### 方案 1:飞书机器人知识库选择机制
+
+#### 设计思路
+在 `FeishuBot` 实体中增加知识库配置字段,支持以下模式:
+1. **默认模式**:使用用户所有文件(当前行为)
+2. **特定知识库**:只搜索指定知识库的文件
+3. **知识组**:搜索知识组下的所有文件
+
+#### 数据库变更
+
+##### 1.1 新增字段到 FeishuBot 实体
+```typescript
+// D:\aura\AuraK\server\src\feishu\entities\feishu-bot.entity.ts
+
+@Entity('feishu_bots')
+export class FeishuBot {
+    // ... 现有字段保持不变
+    
+    @Column({ name: 'knowledge_base_id', nullable: true, length: 36 })
+    knowledgeBaseId: string;
+    
+    @Column({ name: 'knowledge_group_id', nullable: true, length: 36 })
+    knowledgeGroupId: string;
+    
+    @ManyToOne(() => KnowledgeBase, { onDelete: 'SET NULL' })
+    @JoinColumn({ name: 'knowledge_base_id' })
+    knowledgeBase?: KnowledgeBase;
+    
+    @ManyToOne(() => KnowledgeGroup, { onDelete: 'SET NULL' })
+    @JoinColumn({ name: 'knowledge_group_id' })
+    knowledgeGroup?: KnowledgeGroup;
+}
+```
+
+##### 1.2 创建数据库迁移
+```typescript
+// D:\aura\AuraK\server\src\migrations\XXXXXX-AddFeishuBotKnowledgeFields.ts
+
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddFeishuBotKnowledgeFieldsXXXXXX implements MigrationInterface {
+    name = 'AddFeishuBotKnowledgeFields';
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`
+            ALTER TABLE feishu_bots 
+            ADD COLUMN knowledge_base_id VARCHAR(36) NULL,
+            ADD COLUMN knowledge_group_id VARCHAR(36) NULL,
+            ADD CONSTRAINT fk_feishu_bot_knowledge_base 
+                FOREIGN KEY (knowledge_base_id) 
+                REFERENCES knowledge_bases(id) 
+                ON DELETE SET NULL,
+            ADD CONSTRAINT fk_feishu_bot_knowledge_group 
+                FOREIGN KEY (knowledge_group_id) 
+                REFERENCES knowledge_groups(id) 
+                ON DELETE SET NULL;
+        `);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`
+            ALTER TABLE feishu_bots 
+            DROP FOREIGN KEY fk_feishu_bot_knowledge_base,
+            DROP FOREIGN KEY fk_feishu_bot_knowledge_group,
+            DROP COLUMN knowledge_base_id,
+            DROP COLUMN knowledge_group_id;
+        `);
+    }
+}
+```
+
+#### 1.3 更新 DTO
+```typescript
+// D:\aura\AuraK\server\src\feishu\dto\create-bot.dto.ts
+
+export class CreateFeishuBotDto {
+    @IsString()
+    @IsNotEmpty()
+    appId: string;
+
+    @IsString()
+    @IsNotEmpty()
+    appSecret: string;
+
+    @IsString()
+    @IsOptional()
+    botName?: string;
+
+    // 新增知识库配置字段
+    @IsString()
+    @IsOptional()
+    knowledgeBaseId?: string;
+
+    @IsString()
+    @IsOptional()
+    knowledgeGroupId?: string;
+}
+```
+
+#### 1.4 修改 FeishuService
+
+##### 1.4.1 更新创建机器人方法
+```typescript
+// feishu.service.ts
+
+async createBot(userId: string, dto: CreateFeishuBotDto): Promise<FeishuBot> {
+    const existing = await this.botRepository.findOne({
+        where: { userId, appId: dto.appId },
+    });
+
+    if (existing) {
+        Object.assign(existing, dto);
+        return this.botRepository.save(existing);
+    }
+
+    const bot = this.botRepository.create({ userId, ...dto });
+    return this.botRepository.save(bot);
+}
+```
+
+##### 1.4.2 修改消息处理逻辑
+```typescript
+// feishu.service.ts
+
+async processChatMessage(
+    bot: FeishuBot,
+    openId: string,
+    messageId: string,
+    userMessage: string,
+): Promise<void> {
+    // ... 前面的代码保持不变
+    
+    // 确定搜索范围
+    let selectedFiles: string[] | undefined;
+    let selectedGroups: string[] | undefined;
+    
+    // 如果配置了特定知识库,获取该知识库的文件ID
+    if (bot.knowledgeBaseId) {
+        selectedFiles = await this.getFilesByKnowledgeBase(
+            bot.knowledgeBaseId,
+            userId,
+            tenantId
+        );
+    } 
+    // 如果配置了知识组,使用知识组
+    else if (bot.knowledgeGroupId) {
+        selectedGroups = [bot.knowledgeGroupId];
+    }
+    // 否则使用默认(所有文件)
+    
+    const stream = this.chatService.streamChat(
+        userMessage,
+        [],
+        userId,
+        llmModel as any,
+        language,
+        undefined,  // selectedEmbeddingId
+        selectedGroups,  // 改为使用配置的知识组
+        selectedFiles,   // 改为使用配置的知识库文件
+        undefined,  // historyId
+        false,      // enableRerank
+        undefined,  // selectedRerankId
+        undefined,  // temperature
+        undefined,  // maxTokens
+        10,         // topK
+        0.7,        // similarityThreshold
+        undefined,  // rerankSimilarityThreshold
+        undefined,  // enableQueryExpansion
+        undefined,  // enableHyDE
+        tenantId,
+    );
+    
+    // ... 后续处理保持不变
+}
+
+/**
+ * 获取知识库下的所有文件ID
+ */
+private async getFilesByKnowledgeBase(
+    knowledgeBaseId: string,
+    userId: string,
+    tenantId: string
+): Promise<string[]> {
+    try {
+        // 调用 KnowledgeBaseService 获取文件列表
+        const kb = await this.knowledgeBaseService.findOne(knowledgeBaseId, userId, tenantId);
+        if (!kb) {
+            this.logger.warn(`Knowledge base not found: ${knowledgeBaseId}`);
+            return [];
+        }
+        
+        // 假设 KnowledgeBase 有 files 字段或通过关联表获取
+        // 这里需要根据实际的 KnowledgeBase 实体结构调整
+        return kb.files?.map(f => f.id) || [];
+    } catch (error) {
+        this.logger.error(`Failed to get files from knowledge base: ${knowledgeBaseId}`, error);
+        return [];
+    }
+}
+```
+
+### 方案 2:飞书机器人与人才测评集成
+
+#### 设计思路
+通过自然语言命令触发测评功能,支持以下场景:
+1. 用户发送 `/assessment start` 启动测评
+2. 系统发送问题卡片
+3. 用户回复答案
+4. 系统评估并发送下一个问题
+5. 测评完成发送结果报告
+
+#### 2.1 命令解析机制
+
+##### 2.1.1 命令类型定义
+```typescript
+// D:\aura\AuraK\server\src\feishu\dto\assessment-command.dto.ts
+
+export enum AssessmentCommandType {
+    START = 'start',
+    ANSWER = 'answer',
+    STATUS = 'status',
+    RESULT = 'result',
+    HELP = 'help',
+}
+
+export interface AssessmentCommand {
+    type: AssessmentCommandType;
+    parameters: string[];
+    rawMessage: string;
+}
+```
+
+##### 2.1.2 命令解析器
+```typescript
+// D:\aura\AuraK\server\src\feishu\services\assessment-command.parser.ts
+
+@Injectable()
+export class AssessmentCommandParser {
+    private readonly commandPrefixes = ['/assessment', '/测评', '/eval'];
+
+    parse(message: string): AssessmentCommand | null {
+        const trimmed = message.trim();
+        
+        // 检查是否是测评命令
+        const isCommand = this.commandPrefixes.some(prefix => 
+            trimmed.toLowerCase().startsWith(prefix)
+        );
+        
+        if (!isCommand) {
+            return null;
+        }
+
+        // 解析命令
+        const parts = trimmed.split(/\s+/);
+        const commandType = parts[1]?.toLowerCase();
+        
+        switch (commandType) {
+            case 'start':
+                return {
+                    type: AssessmentCommandType.START,
+                    parameters: parts.slice(2),
+                    rawMessage: message,
+                };
+            case 'answer':
+                return {
+                    type: AssessmentCommandType.ANSWER,
+                    parameters: [parts.slice(2).join(' ')],
+                    rawMessage: message,
+                };
+            case 'status':
+                return {
+                    type: AssessmentCommandType.STATUS,
+                    parameters: [],
+                    rawMessage: message,
+                };
+            case 'result':
+                return {
+                    type: AssessmentCommandType.RESULT,
+                    parameters: [],
+                    rawMessage: message,
+                };
+            case 'help':
+            case '?':
+                return {
+                    type: AssessmentCommandType.HELP,
+                    parameters: [],
+                    rawMessage: message,
+                };
+            default:
+                return {
+                    type: AssessmentCommandType.HELP,
+                    parameters: [],
+                    rawMessage: message,
+                };
+        }
+    }
+}
+```
+
+#### 2.2 测评会话管理
+
+##### 2.2.1 数据库实体
+```typescript
+// D:\aura\AuraK\server\src\feishu\entities\feishu-assessment-session.entity.ts
+
+@Entity('feishu_assessment_sessions')
+export class FeishuAssessmentSession {
+    @PrimaryGeneratedColumn('uuid')
+    id: string;
+
+    @Column({ name: 'bot_id' })
+    botId: string;
+
+    @Column({ name: 'open_id' })
+    openId: string;
+
+    @Column({ name: 'assessment_session_id' })
+    assessmentSessionId: string;
+
+    @Column({ 
+        type: 'enum', 
+        enum: ['active', 'completed', 'cancelled'],
+        default: 'active'
+    })
+    status: 'active' | 'completed' | 'cancelled';
+
+    @CreateDateColumn({ name: 'created_at' })
+    createdAt: Date;
+
+    @UpdateDateColumn({ name: 'updated_at' })
+    updatedAt: Date;
+
+    // 关联关系
+    @ManyToOne(() => FeishuBot, { onDelete: 'CASCADE' })
+    @JoinColumn({ name: 'bot_id' })
+    bot: FeishuBot;
+}
+```
+
+##### 2.2.2 迁移脚本
+```typescript
+// D:\aura\AuraK\server\src\migrations\XXXXXX-CreateFeishuAssessmentSessionTable.ts
+
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class CreateFeishuAssessmentSessionTableXXXXXX implements MigrationInterface {
+    name = 'CreateFeishuAssessmentSessionTable';
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`
+            CREATE TABLE feishu_assessment_sessions (
+                id VARCHAR(36) PRIMARY KEY,
+                bot_id VARCHAR(36) NOT NULL,
+                open_id VARCHAR(255) NOT NULL,
+                assessment_session_id VARCHAR(36) NOT NULL,
+                status ENUM('active', 'completed', 'cancelled') DEFAULT 'active',
+                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                INDEX idx_bot_open (bot_id, open_id),
+                INDEX idx_assessment_session (assessment_session_id),
+                CONSTRAINT fk_feishu_assessment_bot 
+                    FOREIGN KEY (bot_id) 
+                    REFERENCES feishu_bots(id) 
+                    ON DELETE CASCADE
+            );
+        `);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`
+            DROP TABLE feishu_assessment_sessions;
+        `);
+    }
+}
+```
+
+#### 2.3 服务层实现
+
+##### 2.3.1 飞书测评服务
+```typescript
+// D:\aura\AuraK\server\src\feishu\services\feishu-assessment.service.ts
+
+@Injectable()
+export class FeishuAssessmentService {
+    private readonly logger = new Logger(FeishuAssessmentService.name);
+
+    constructor(
+        @InjectRepository(FeishuAssessmentSession)
+        private sessionRepository: Repository<FeishuAssessmentSession>,
+        private assessmentService: AssessmentService,
+        private feishuService: FeishuService,
+        private commandParser: AssessmentCommandParser,
+    ) {}
+
+    /**
+     * 处理测评命令
+     */
+    async handleCommand(
+        bot: FeishuBot,
+        openId: string,
+        message: string,
+    ): Promise<void> {
+        const command = this.commandParser.parse(message);
+        
+        if (!command) {
+            // 不是测评命令,使用默认聊天处理
+            await this.feishuService.processChatMessage(bot, openId, '', message);
+            return;
+        }
+
+        try {
+            switch (command.type) {
+                case AssessmentCommandType.START:
+                    await this.startAssessment(bot, openId, command.parameters);
+                    break;
+                case AssessmentCommandType.ANSWER:
+                    await this.submitAnswer(bot, openId, command.parameters[0]);
+                    break;
+                case AssessmentCommandType.STATUS:
+                    await this.getStatus(bot, openId);
+                    break;
+                case AssessmentCommandType.RESULT:
+                    await this.getResult(bot, openId);
+                    break;
+                case AssessmentCommandType.HELP:
+                    await this.sendHelp(bot, openId);
+                    break;
+            }
+        } catch (error) {
+            this.logger.error(`Failed to handle assessment command: ${error.message}`, error);
+            await this.feishuService.sendTextMessage(
+                bot,
+                'open_id',
+                openId,
+                `处理测评命令时出错: ${error.message}`
+            );
+        }
+    }
+
+    /**
+     * 开始测评
+     */
+    async startAssessment(
+        bot: FeishuBot,
+        openId: string,
+        parameters: string[],
+    ): Promise<void> {
+        // 检查是否已有进行中的测评
+        const existingSession = await this.getActiveSession(bot.id, openId);
+        if (existingSession) {
+            await this.feishuService.sendTextMessage(
+                bot,
+                'open_id',
+                openId,
+                '您已有进行中的测评会话,请先完成当前测评。'
+            );
+            return;
+        }
+
+        // 解析参数
+        const [kbIdOrTemplateId, secondParam] = parameters;
+        let knowledgeBaseId: string | undefined;
+        let templateId: string | undefined;
+
+        // 判断是知识库ID还是模板ID
+        if (kbIdOrTemplateId) {
+            // 这里可以根据实际需求判断参数类型
+            // 简单实现:如果参数是UUID格式,假设是模板ID
+            if (kbIdOrTemplateId.length === 36) {
+                templateId = kbIdOrTemplateId;
+            } else {
+                // 否则尝试作为知识库ID
+                knowledgeBaseId = kbIdOrTemplateId;
+            }
+        }
+
+        // 使用机器人配置的知识库(如果未指定)
+        if (!knowledgeBaseId && !templateId && bot.knowledgeBaseId) {
+            knowledgeBaseId = bot.knowledgeBaseId;
+        }
+
+        this.logger.log(`Starting assessment: bot=${bot.id}, openId=${openId}, kb=${knowledgeBaseId}, template=${templateId}`);
+
+        // 创建测评会话
+        const session = await this.assessmentService.startSession(
+            bot.userId,
+            knowledgeBaseId,
+            bot.user?.tenantId || 'default',
+            'zh',
+            templateId,
+        );
+
+        // 存储飞书会话关联
+        const feishuSession = this.sessionRepository.create({
+            botId: bot.id,
+            openId,
+            assessmentSessionId: session.id,
+            status: 'active',
+        });
+        await this.sessionRepository.save(feishuSession);
+
+        // 发送第一个问题
+        if (session.questions_json && session.questions_json.length > 0) {
+            const firstQuestion = session.questions_json[0];
+            const card = this.buildQuestionCard(firstQuestion, session.id, 1, session.questions_json.length);
+            await this.feishuService.sendCardMessage(bot, 'open_id', openId, card);
+        } else {
+            await this.feishuService.sendTextMessage(
+                bot,
+                'open_id',
+                openId,
+                '测评会话已创建,但未能生成问题。'
+            );
+        }
+    }
+
+    /**
+     * 提交答案
+     */
+    async submitAnswer(
+        bot: FeishuBot,
+        openId: string,
+        answer: string,
+    ): Promise<void> {
+        const session = await this.getActiveSession(bot.id, openId);
+        
+        if (!session) {
+            await this.feishuService.sendTextMessage(
+                bot,
+                'open_id',
+                openId,
+                '没有进行中的测评会话。请发送 /assessment start 开始测评。'
+            );
+            return;
+        }
+
+        this.logger.log(`Submitting answer for session ${session.assessmentSessionId}`);
+
+        // 提交答案到测评服务
+        const result = await this.assessmentService.submitAnswer(
+            session.assessmentSessionId,
+            bot.userId,
+            answer,
+            'zh',
+        );
+
+        // 更新飞书会话状态
+        if (result.report) {
+            session.status = 'completed';
+            await this.sessionRepository.save(session);
+            
+            // 发送测评结果
+            await this.sendAssessmentResult(bot, openId, result);
+        } else if (result.questions && result.questions.length > 0) {
+            // 发送下一个问题
+            const currentQuestionIndex = result.currentQuestionIndex || 0;
+            const nextQuestion = result.questions[currentQuestionIndex];
+            const totalQuestions = result.questions.length;
+            
+            const card = this.buildQuestionCard(
+                nextQuestion,
+                session.assessmentSessionId,
+                currentQuestionIndex + 1,
+                totalQuestions
+            );
+            await this.feishuService.sendCardMessage(bot, 'open_id', openId, card);
+        }
+    }
+
+    /**
+     * 获取测评状态
+     */
+    async getStatus(bot: FeishuBot, openId: string): Promise<void> {
+        const session = await this.getActiveSession(bot.id, openId);
+        
+        if (!session) {
+            await this.feishuService.sendTextMessage(
+                bot,
+                'open_id',
+                openId,
+                '没有进行中的测评会话。'
+            );
+            return;
+        }
+
+        const assessmentState = await this.assessmentService.getSessionState(
+            session.assessmentSessionId,
+            bot.userId
+        );
+
+        const currentQuestionIndex = assessmentState.currentQuestionIndex || 0;
+        const totalQuestions = assessmentState.questions?.length || 0;
+        
+        const message = `测评状态:\n` +
+            `- 进度: ${currentQuestionIndex + 1}/${totalQuestions}\n` +
+            `- 状态: ${session.status}\n` +
+            `- 开始时间: ${session.createdAt.toLocaleString('zh-CN')}`;
+
+        await this.feishuService.sendTextMessage(bot, 'open_id', openId, message);
+    }
+
+    /**
+     * 获取测评结果
+     */
+    async getResult(bot: FeishuBot, openId: string): Promise<void> {
+        const session = await this.getActiveSession(bot.id, openId);
+        
+        if (!session) {
+            await this.feishuService.sendTextMessage(
+                bot,
+                'open_id',
+                openId,
+                '没有进行中的测评会话。'
+            );
+            return;
+        }
+
+        if (session.status !== 'completed') {
+            await this.feishuService.sendTextMessage(
+                bot,
+                'open_id',
+                openId,
+                '测评尚未完成,请先完成所有问题。'
+            );
+            return;
+        }
+
+        const assessmentState = await this.assessmentService.getSessionState(
+            session.assessmentSessionId,
+            bot.userId
+        );
+
+        await this.sendAssessmentResult(bot, openId, assessmentState);
+    }
+
+    /**
+     * 发送帮助信息
+     */
+    async sendHelp(bot: FeishuBot, openId: string): Promise<void> {
+        const helpText = `
+**人才测评机器人帮助**
+
+命令格式:
+- `/assessment start [kbId|templateId]` - 开始测评
+- `/assessment answer [answer]` - 提交答案
+- `/assessment status` - 查看测评状态
+- `/assessment result` - 获取测评结果
+- `/assessment help` - 显示帮助
+
+说明:
+- 如果未指定知识库/模板,将使用机器人配置的默认知识库
+- 也可直接回复答案,无需命令前缀
+        `.trim();
+
+        await this.feishuService.sendTextMessage(bot, 'open_id', openId, helpText);
+    }
+
+    /**
+     * 获取活跃会话
+     */
+    private async getActiveSession(
+        botId: string,
+        openId: string,
+    ): Promise<FeishuAssessmentSession | null> {
+        return this.sessionRepository.findOne({
+            where: {
+                botId,
+                openId,
+                status: 'active',
+            },
+            order: { createdAt: 'DESC' },
+        });
+    }
+
+    /**
+     * 构建问题卡片
+     */
+    private buildQuestionCard(
+        question: any,
+        sessionId: string,
+        currentIndex: number,
+        totalQuestions: number,
+    ): any {
+        return {
+            config: { wide_screen_mode: true },
+            header: {
+                template: 'blue',
+                title: {
+                    content: `人才测评 (${currentIndex}/${totalQuestions})`,
+                    tag: 'plain_text',
+                },
+            },
+            elements: [
+                {
+                    tag: 'div',
+                    text: {
+                        content: `**问题 ${currentIndex}:** ${question.text || question.content}`,
+                        tag: 'lark_md',
+                    },
+                },
+                ...(question.options ? [
+                    {
+                        tag: 'div',
+                        text: {
+                            content: `选项:\n${question.options.map((opt: string, i: number) => 
+                                `${String.fromCharCode(65 + i)}. ${opt}`
+                            ).join('\n')}`,
+                            tag: 'lark_md',
+                        },
+                    }
+                ] : []),
+                {
+                    tag: 'div',
+                    text: {
+                        content: `难度: ${question.difficulty || '普通'} | 分值: ${question.score || 1}`,
+                        tag: 'lark_md',
+                    },
+                },
+                {
+                    tag: 'hr',
+                },
+                {
+                    tag: 'note',
+                    elements: [
+                        {
+                            content: `直接回复答案或使用 /assessment answer [你的答案]`,
+                            tag: 'plain_text',
+                        },
+                    ],
+                },
+            ],
+        };
+    }
+
+    /**
+     * 发送测评结果
+     */
+    private async sendAssessmentResult(
+        bot: FeishuBot,
+        openId: string,
+        result: any,
+    ): Promise<void> {
+        const report = result.report || result.finalReport;
+        const score = result.finalScore || result.score;
+        
+        const resultCard = {
+            config: { wide_screen_mode: true },
+            header: {
+                template: 'green',
+                title: {
+                    content: '测评完成',
+                    tag: 'plain_text',
+                },
+            },
+            elements: [
+                {
+                    tag: 'div',
+                    text: {
+                        content: `**测评结果**`,
+                        tag: 'lark_md',
+                    },
+                },
+                ...(score !== undefined ? [
+                    {
+                        tag: 'div',
+                        text: {
+                            content: `**总分**: ${score.toFixed(1)}`,
+                            tag: 'lark_md',
+                        },
+                    }
+                ] : []),
+                ...(report ? [
+                    {
+                        tag: 'div',
+                        text: {
+                            content: `**报告**:\n${report}`,
+                            tag: 'lark_md',
+                        },
+                    }
+                ] : []),
+                {
+                    tag: 'hr',
+                },
+                {
+                    tag: 'note',
+                    elements: [
+                        {
+                            content: `发送 /assessment start 开始新的测评`,
+                            tag: 'plain_text',
+                        },
+                    ],
+                },
+            ],
+        };
+
+        await this.feishuService.sendCardMessage(bot, 'open_id', openId, resultCard);
+    }
+}
+```
+
+#### 2.4 集成到 FeishuService
+
+##### 2.4.1 修改消息处理
+```typescript
+// feishu.service.ts
+
+// 新增字段
+private feishuAssessmentService: FeishuAssessmentService;
+
+// 在构造函数后初始化
+setFeishuAssessmentService(service: FeishuAssessmentService): void {
+    this.feishuAssessmentService = service;
+}
+
+// 修改 _handleMessage 方法
+private async _handleMessage(bot: any, event: any): Promise<void> {
+    const message = event?.message;
+    if (!message) return;
+
+    const messageId = message.message_id;
+    const openId = event?.sender?.sender_id?.open_id;
+
+    if (!openId) {
+        this.logger.warn('No sender open_id found in Feishu event');
+        return;
+    }
+
+    // 解析文本内容
+    let userText = '';
+    try {
+        const content = JSON.parse(message.content || '{}');
+        userText = content.text || '';
+    } catch {
+        this.logger.warn('Failed to parse Feishu message content');
+        return;
+    }
+
+    if (!userText.trim()) return;
+
+    try {
+        // 检查是否是测评命令
+        if (this.isAssessmentCommand(userText)) {
+            // 委托给测评服务处理
+            await this.feishuAssessmentService.handleCommand(bot, openId, userText);
+        } else {
+            // 默认使用知识库问答
+            await this.processChatMessage(bot, openId, messageId, userText);
+        }
+    } catch (error) {
+        this.logger.error('Message handling failed', error);
+        try {
+            await this.sendTextMessage(
+                bot,
+                'open_id',
+                openId,
+                '抱歉,处理您的消息时遇到了错误,请稍后重试。',
+            );
+        } catch (sendError) {
+            this.logger.error('Failed to send error message to Feishu', sendError);
+        }
+    }
+}
+
+private isAssessmentCommand(message: string): boolean {
+    const trimmed = message.trim().toLowerCase();
+    return trimmed.startsWith('/assessment') || 
+           trimmed.startsWith('/测评') || 
+           trimmed.startsWith('/eval');
+}
+```
+
+##### 2.4.2 模块初始化
+```typescript
+// D:\aura\AuraK\server\src\feishu\feishu.module.ts
+
+@Module({
+    imports: [
+        TypeOrmModule.forFeature([
+            FeishuBot,
+            FeishuAssessmentSession,
+        ]),
+        forwardRef(() => ChatModule),
+        forwardRef(() => AssessmentModule),
+        forwardRef(() => KnowledgeBaseModule),
+    ],
+    controllers: [FeishuController],
+    providers: [
+        FeishuService,
+        FeishuWsManager,
+        FeishuAssessmentService,
+        AssessmentCommandParser,
+    ],
+    exports: [FeishuService, FeishuAssessmentService],
+})
+export class FeishuModule {}
+```
+
+---
+
+## API 接口设计
+
+### 1. 飞书机器人管理接口
+
+#### 1.1 创建/更新飞书机器人
+```http
+POST /feishu/bots
+```
+
+**请求体**:
+```json
+{
+    "appId": "cli_xxx",
+    "appSecret": "xxx",
+    "botName": "测评机器人",
+    "knowledgeBaseId": "kb_xxx",      // 可选:特定知识库
+    "knowledgeGroupId": "group_xxx"   // 可选:知识组
+}
+```
+
+**响应**:
+```json
+{
+    "id": "bot_xxx",
+    "appId": "cli_xxx",
+    "botName": "测评机器人",
+    "webhookUrl": "/api/feishu/webhook/cli_xxx"
+}
+```
+
+#### 1.2 更新知识库配置
+```http
+PATCH /feishu/bots/:id/knowledge
+```
+
+**请求体**:
+```json
+{
+    "knowledgeBaseId": "kb_xxx",
+    "knowledgeGroupId": null
+}
+```
+
+#### 1.3 获取机器人列表
+```http
+GET /feishu/bots
+```
+
+**响应**:
+```json
+[
+    {
+        "id": "bot_xxx",
+        "appId": "cli_xxx",
+        "botName": "测评机器人",
+        "enabled": true,
+        "knowledgeBaseId": "kb_xxx",
+        "knowledgeGroupName": "产品文档"
+    }
+]
+```
+
+### 2. 测评会话接口(可选)
+
+#### 2.1 通过飞书启动测评
+```http
+POST /feishu/assessment/start
+```
+
+**请求体**:
+```json
+{
+    "botId": "bot_xxx",
+    "openId": "ou_xxx",
+    "knowledgeBaseId": "kb_xxx",
+    "templateId": "tmpl_xxx"
+}
+```
+
+**响应**:
+```json
+{
+    "sessionId": "sess_xxx",
+    "question": {
+        "id": "q_xxx",
+        "text": "问题内容",
+        "difficulty": "普通"
+    }
+}
+```
+
+#### 2.2 提交测评答案
+```http
+POST /feishu/assessment/answer
+```
+
+**请求体**:
+```json
+{
+    "botId": "bot_xxx",
+    "openId": "ou_xxx",
+    "answer": "用户答案"
+}
+```
+
+#### 2.3 获取测评状态
+```http
+GET /feishu/assessment/status/:botId/:openId
+```
+
+**响应**:
+```json
+{
+    "sessionId": "sess_xxx",
+    "status": "active",
+    "currentQuestion": 3,
+    "totalQuestions": 10,
+    "startTime": "2026-03-17T10:00:00Z"
+}
+```
+
+---
+
+## 数据库设计
+
+### 实体关系图
+
+```
+┌─────────────────┐
+│   FeishuBot     │
+│─────────────────│
+│ id              │◄──────┐
+│ userId          │       │
+│ appId           │       │ 1..*
+│ knowledgeBaseId │───────┼──────┐
+│ knowledgeGroupId│       │      │
+└─────────────────┘       │      │
+                          │      │
+                          │      │
+┌─────────────────────────┼──────┼─────────────────────┐
+│                         │      │                     │
+│ ┌─────────────────────┐ │      │ ┌─────────────────┐ │
+│ │ FeishuAssessment    │ │      │ │  KnowledgeBase  │ │
+│ │ Session             │ │      │ └─────────────────┘ │
+│ │─────────────────────│ │      │                     │
+│ │ id                  │ │      │ ┌─────────────────┐ │
+│ │ botId               │─┼──────┼─┤ KnowledgeGroup  │ │
+│ │ openId              │ │      │ └─────────────────┘ │
+│ │ assessmentSessionId │ │      │                     │
+│ │ status              │ │      │                     │
+│ └─────────────────────┘ │      │                     │
+└─────────────────────────┴──────┴─────────────────────┘
+                          │
+                          │ 1..*
+┌─────────────────────────┼─────────────────────────┐
+│                         │                         │
+│ ┌─────────────────────┐ │ ┌─────────────────────┐ │
+│ │   AssessmentSession │ │ │   AssessmentResult  │ │
+│ │─────────────────────│ │ │─────────────────────│ │
+│ │ id                  │ │ │ id                  │ │
+│ │ userId              │ │ │ sessionId           │ │
+│ │ knowledgeBaseId     │ │ │ report              │ │
+│ │ questions_json      │ │ │ score               │ │
+│ │ finalScore          │ │ │ ...                 │ │
+│ └─────────────────────┘ │ └─────────────────────┘ │
+└─────────────────────────┴─────────────────────────┘
+```
+
+### 数据表结构
+
+#### feishu_assessment_sessions
+```sql
+CREATE TABLE feishu_assessment_sessions (
+    id VARCHAR(36) PRIMARY KEY,
+    bot_id VARCHAR(36) NOT NULL,
+    open_id VARCHAR(255) NOT NULL,
+    assessment_session_id VARCHAR(36) NOT NULL,
+    status ENUM('active', 'completed', 'cancelled') DEFAULT 'active',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_bot_open (bot_id, open_id),
+    INDEX idx_assessment_session (assessment_session_id),
+    CONSTRAINT fk_feishu_assessment_bot 
+        FOREIGN KEY (bot_id) 
+        REFERENCES feishu_bots(id) 
+        ON DELETE CASCADE
+);
+```
+
+---
+
+## 实施计划
+
+### 阶段 1:基础架构(1-2 天)
+
+#### 任务清单
+- [ ] 1.1 创建数据库迁移脚本
+- [ ] 1.2 更新 FeishuBot 实体和 DTO
+- [ ] 1.3 修改 FeishuService 支持知识库选择
+- [ ] 1.4 更新飞书机器人创建/更新接口
+
+**交付物**:
+- 数据库迁移脚本
+- 更新后的实体和 DTO
+- 修改后的 FeishuService
+
+### 阶段 2:测评集成(2-3 天)
+
+#### 任务清单
+- [ ] 2.1 创建 FeishuAssessmentSession 实体和迁移
+- [ ] 2.2 实现命令解析器
+- [ ] 2.3 实现 FeishuAssessmentService
+- [ ] 2.4 集成到 FeishuService
+- [ ] 2.5 设计并实现飞书卡片模板
+
+**交付物**:
+- 测评会话实体和迁移
+- 命令解析器
+- 测评服务实现
+- 飞书卡片设计
+
+### 阶段 3:测试优化(1-2 天)
+
+#### 任务清单
+- [ ] 3.1 单元测试
+- [ ] 3.2 集成测试
+- [ ] 3.3 性能测试
+- [ ] 3.4 文档编写
+
+**交付物**:
+- 测试用例和测试报告
+- 性能测试结果
+- 用户使用文档
+
+---
+
+## 安全考虑
+
+### 1. 多租户隔离
+- **机制**:所有查询必须包含 `userId` 和 `tenantId` 过滤
+- **实现**:在 `FeishuBot` 实体中关联 `User` 实体,确保机器人只能访问所属用户的数据
+
+### 2. 命令验证
+- **机制**:白名单命令验证,防止恶意命令注入
+- **实现**:命令解析器只识别预定义的命令格式
+
+### 3. 会话超时
+- **机制**:测评会话设置超时时间(如 24 小时)
+- **实现**:定时清理过期会话
+
+### 4. 数据隐私
+- **机制**:测评结果仅对授权用户可见
+- **实现**:所有接口使用 JWT 认证,验证用户权限
+
+### 5. 敏感信息保护
+- **机制**:不存储明文的 App Secret
+- **实现**:加密存储 App Secret,使用时解密
+
+---
+
+## 附录
+
+### A. 参考资料
+- [飞书开放平台文档](https://open.feishu.cn/document)
+- [RAG 系统架构设计](./rag-architecture.md)
+- [人才测评模块文档](./assessment-module.md)
+
+### B. 术语表
+- **RAG**:检索增强生成 (Retrieval-Augmented Generation)
+- **FeishuBot**:飞书机器人实体
+- **KnowledgeBase**:知识库实体
+- **AssessmentSession**:测评会话实体
+
+### C. 变更记录
+| 版本 | 日期 | 修改内容 | 作者 |
+|------|------|----------|------|
+| v1.0 | 2026-03-17 | 初始版本 | AI Assistant |
+
+---
+
+**文档结束**

+ 208 - 0
docs/feishu-assessment-integration-summary.md

@@ -0,0 +1,208 @@
+# 飞书机器人与人才测评集成 - 设计摘要
+
+> **文档版本**: v1.0  
+> **创建日期**: 2026-03-17  
+> **完整文档**: [feishu-assessment-integration-design.md](./feishu-assessment-integration-design.md)
+
+---
+
+## 一、核心问题
+
+### 1. 飞书机器人当前对接哪个知识库?
+**答案**:当前使用**默认知识库**(用户的所有文件)
+
+**原因**:在 `feishu.service.ts` 中调用 `chatService.streamChat()` 时,`selectedFiles` 和 `selectedGroups` 参数均为 `undefined`,导致搜索用户所有文件。
+
+### 2. 如何与人才测评对接?
+**答案**:通过**自然语言命令**触发测评功能
+
+**命令格式**:
+- `/assessment start [kbId|templateId]` - 开始测评
+- `/assessment answer [answer]` - 提交答案
+- `/assessment status` - 查看状态
+- `/assessment result` - 获取结果
+
+---
+
+## 二、设计方案
+
+### 方案 1:知识库选择机制
+
+#### 数据库变更
+在 `FeishuBot` 实体中新增字段:
+```typescript
+knowledgeBaseId: string;      // 特定知识库ID
+knowledgeGroupId: string;     // 知识组ID
+```
+
+#### 选择逻辑
+1. **配置了 knowledgeBaseId** → 搜索该知识库的文件
+2. **配置了 knowledgeGroupId** → 搜索该知识组的文件
+3. **都未配置** → 搜索用户所有文件(默认行为)
+
+### 方案 2:测评集成
+
+#### 架构设计
+```
+用户消息 → 命令解析器 → 测评服务 → 人才测评模块
+                    ↓
+                聊天服务(非测评消息)
+```
+
+#### 核心组件
+1. **AssessmentCommandParser** - 命令解析器
+2. **FeishuAssessmentService** - 测评服务
+3. **FeishuAssessmentSession** - 会话实体
+
+#### 交互流程
+```
+1. 用户: /assessment start
+2. 系统: 创建测评会话,发送第一个问题卡片
+3. 用户: 回复答案
+4. 系统: 评估答案,发送下一个问题
+5. ... 循环直到测评完成
+6. 系统: 发送测评结果报告
+```
+
+---
+
+## 三、实施步骤
+
+### 阶段 1:基础架构(1-2 天)
+- [ ] 添加数据库字段和迁移脚本
+- [ ] 更新 FeishuBot 实体和 DTO
+- [ ] 修改 FeishuService 支持知识库选择
+
+### 阶段 2:测评集成(2-3 天)
+- [ ] 创建测评会话实体和迁移
+- [ ] 实现命令解析器
+- [ ] 实现测评服务
+- [ ] 设计飞书卡片模板
+
+### 阶段 3:测试优化(1-2 天)
+- [ ] 单元测试和集成测试
+- [ ] 性能测试
+- [ ] 文档编写
+
+---
+
+## 四、关键代码示例
+
+### 4.1 知识库选择逻辑
+```typescript
+// feishu.service.ts
+async processChatMessage(bot: FeishuBot, ...) {
+    let selectedFiles: string[] | undefined;
+    let selectedGroups: string[] | undefined;
+    
+    if (bot.knowledgeBaseId) {
+        selectedFiles = await this.getFilesByKnowledgeBase(bot.knowledgeBaseId, ...);
+    } else if (bot.knowledgeGroupId) {
+        selectedGroups = [bot.knowledgeGroupId];
+    }
+    
+    const stream = this.chatService.streamChat(
+        userMessage,
+        [],
+        userId,
+        llmModel,
+        language,
+        undefined,
+        selectedGroups,  // 使用配置的知识组
+        selectedFiles,   // 使用配置的知识库文件
+        // ...
+    );
+}
+```
+
+### 4.2 命令处理逻辑
+```typescript
+// feishu-assessment.service.ts
+async handleCommand(bot: FeishuBot, openId: string, message: string) {
+    const command = this.commandParser.parse(message);
+    
+    if (!command) {
+        // 非测评命令,使用默认聊天
+        await this.feishuService.processChatMessage(bot, openId, '', message);
+        return;
+    }
+    
+    switch (command.type) {
+        case AssessmentCommandType.START:
+            await this.startAssessment(bot, openId, command.parameters);
+            break;
+        case AssessmentCommandType.ANSWER:
+            await this.submitAnswer(bot, openId, command.parameters[0]);
+            break;
+        // ...
+    }
+}
+```
+
+---
+
+## 五、API 接口
+
+### 5.1 飞书机器人管理
+```http
+POST /feishu/bots
+{
+    "appId": "cli_xxx",
+    "appSecret": "xxx",
+    "knowledgeBaseId": "kb_xxx",      // 可选
+    "knowledgeGroupId": "group_xxx"   // 可选
+}
+```
+
+### 5.2 测评会话管理
+```http
+POST /feishu/assessment/start
+{
+    "botId": "bot_xxx",
+    "openId": "ou_xxx",
+    "knowledgeBaseId": "kb_xxx",
+    "templateId": "tmpl_xxx"
+}
+```
+
+---
+
+## 六、安全考虑
+
+1. **多租户隔离**:所有查询必须包含 `userId` 和 `tenantId`
+2. **命令验证**:白名单命令验证,防止注入
+3. **会话超时**:测评会话设置 24 小时超时
+4. **数据隐私**:测评结果仅对授权用户可见
+
+---
+
+## 七、文件清单
+
+### 需要创建的文件
+1. `server/src/feishu/entities/feishu-assessment-session.entity.ts`
+2. `server/src/feishu/dto/assessment-command.dto.ts`
+3. `server/src/feishu/services/assessment-command.parser.ts`
+4. `server/src/feishu/services/feishu-assessment.service.ts`
+5. `server/src/migrations/XXXXXX-AddFeishuBotKnowledgeFields.ts`
+6. `server/src/migrations/XXXXXX-CreateFeishuAssessmentSessionTable.ts`
+
+### 需要修改的文件
+1. `server/src/feishu/entities/feishu-bot.entity.ts`
+2. `server/src/feishu/dto/create-bot.dto.ts`
+3. `server/src/feishu/feishu.service.ts`
+4. `server/src/feishu/feishu.module.ts`
+
+---
+
+## 八、总结
+
+| 问题 | 答案 |
+|------|------|
+| 飞书机器人当前对接哪个知识库? | 默认知识库(用户所有文件) |
+| 如何配置特定知识库? | 在 FeishuBot 实体中设置 knowledgeBaseId 或 knowledgeGroupId |
+| 如何与人才测评对接? | 通过 `/assessment` 命令触发测评功能 |
+| 实施周期 | 5-7 天 |
+
+---
+
+**完整设计文档**: [feishu-assessment-integration-design.md](./feishu-assessment-integration-design.md)

+ 1 - 0
schema.txt

@@ -0,0 +1 @@
+CREATE TABLE IF NOT EXISTS "assessment_templates" ("id" varchar PRIMARY KEY NOT NULL, "tenant_id" varchar, "name" varchar NOT NULL, "description" text, "keywords" text, "question_count" integer NOT NULL DEFAULT (5), "difficulty_distribution" text, "style" varchar NOT NULL DEFAULT ('technical'), "knowledge_base_id" varchar, "knowledge_group_id" varchar, "is_active" boolean NOT NULL DEFAULT (1), "version" integer NOT NULL DEFAULT (1), "created_by" varchar, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "FK_cc65bf036f86297666e8c6d81da" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_82bfee03c2fcb339451261853a3" FOREIGN KEY ("knowledge_base_id") REFERENCES "knowledge_bases" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7ef697ba4f123c7aa55aad478f2" FOREIGN KEY ("knowledge_group_id") REFERENCES "knowledge_groups" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION);

+ 67 - 49
server/src/admin/admin.controller.ts

@@ -1,4 +1,16 @@
-import { Controller, Get, Post, Put, Body, UseGuards, Request, Query, UseInterceptors, UploadedFile, Res } from '@nestjs/common';
+import {
+  Controller,
+  Get,
+  Post,
+  Put,
+  Body,
+  UseGuards,
+  Request,
+  Query,
+  UseInterceptors,
+  UploadedFile,
+  Res,
+} from '@nestjs/common';
 import { FileInterceptor } from '@nestjs/platform-express';
 import { Response } from 'express';
 import { AdminService } from './admin.service';
@@ -10,58 +22,64 @@ import { UserRole } from '../user/user-role.enum';
 @Controller('v1/admin')
 @UseGuards(CombinedAuthGuard, RolesGuard)
 export class AdminController {
-    constructor(private readonly adminService: AdminService) { }
+  constructor(private readonly adminService: AdminService) {}
 
-    @Get('users')
-    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
-    async getUsers(
-        @Request() req: any,
-        @Query('page') page?: string,
-        @Query('limit') limit?: string,
-    ) {
-        const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
-        return this.adminService.getTenantUsers(
-            isSuperAdmin ? undefined : req.user.tenantId,
-            page ? parseInt(page) : undefined,
-            limit ? parseInt(limit) : undefined
-        );
-    }
+  @Get('users')
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+  async getUsers(
+    @Request() req: any,
+    @Query('page') page?: string,
+    @Query('limit') limit?: string,
+  ) {
+    const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
+    return this.adminService.getTenantUsers(
+      isSuperAdmin ? undefined : req.user.tenantId,
+      page ? parseInt(page) : undefined,
+      limit ? parseInt(limit) : undefined,
+    );
+  }
 
-    @Get('users/export')
-    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
-    async getUsersExport(@Request() req: any, @Res() res: Response) {
-        const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
-        const buffer = await this.adminService.exportUsers(isSuperAdmin ? undefined : req.user.tenantId);
-        res.set({
-            'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-            'Content-Disposition': 'attachment; filename="users_export.xlsx"',
-            'Content-Length': buffer.length,
-        });
-        res.end(buffer);
-    }
+  @Get('users/export')
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+  async getUsersExport(@Request() req: any, @Res() res: Response) {
+    const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
+    const buffer = await this.adminService.exportUsers(
+      isSuperAdmin ? undefined : req.user.tenantId,
+    );
+    res.set({
+      'Content-Type':
+        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+      'Content-Disposition': 'attachment; filename="users_export.xlsx"',
+      'Content-Length': buffer.length,
+    });
+    res.end(buffer);
+  }
 
-    @Post('users/import')
-    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
-    @UseInterceptors(FileInterceptor('file'))
-    async importUsers(@Request() req: any, @UploadedFile() file: any) {
-        const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
-        return this.adminService.importUsers(isSuperAdmin ? undefined : req.user.tenantId, file);
-    }
+  @Post('users/import')
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+  @UseInterceptors(FileInterceptor('file'))
+  async importUsers(@Request() req: any, @UploadedFile() file: any) {
+    const isSuperAdmin = req.user.role === UserRole.SUPER_ADMIN;
+    return this.adminService.importUsers(
+      isSuperAdmin ? undefined : req.user.tenantId,
+      file,
+    );
+  }
 
-    @Get('settings')
-    async getSettings(@Request() req: any) {
-        return this.adminService.getTenantSettings(req.user.tenantId);
-    }
+  @Get('settings')
+  async getSettings(@Request() req: any) {
+    return this.adminService.getTenantSettings(req.user.tenantId);
+  }
 
-    @Put('settings')
-    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
-    async updateSettings(@Request() req: any, @Body() body: any) {
-        return this.adminService.updateTenantSettings(req.user.tenantId, body);
-    }
+  @Put('settings')
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+  async updateSettings(@Request() req: any, @Body() body: any) {
+    return this.adminService.updateTenantSettings(req.user.tenantId, body);
+  }
 
-    @Get('pending-shares')
-    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
-    async getPendingShares(@Request() req: any) {
-        return this.adminService.getPendingShares(req.user.tenantId);
-    }
+  @Get('pending-shares')
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+  async getPendingShares(@Request() req: any) {
+    return this.adminService.getPendingShares(req.user.tenantId);
+  }
 }

+ 4 - 4
server/src/admin/admin.module.ts

@@ -5,8 +5,8 @@ import { UserModule } from '../user/user.module';
 import { TenantModule } from '../tenant/tenant.module';
 
 @Module({
-    imports: [UserModule, TenantModule],
-    controllers: [AdminController],
-    providers: [AdminService],
+  imports: [UserModule, TenantModule],
+  controllers: [AdminController],
+  providers: [AdminService],
 })
-export class AdminModule { }
+export class AdminModule {}

+ 131 - 96
server/src/admin/admin.service.ts

@@ -1,111 +1,146 @@
-import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
+import {
+  Injectable,
+  NotFoundException,
+  ForbiddenException,
+  BadRequestException,
+} from '@nestjs/common';
 import * as XLSX from 'xlsx';
 import { UserService } from '../user/user.service';
 import { TenantService } from '../tenant/tenant.service';
 import { I18nService } from '../i18n/i18n.service';
 
+interface UserImportRow {
+  Username?: string | number;
+  username?: string | number;
+  DisplayName?: string | number;
+  displayName?: string | number;
+  Name?: string | number;
+  name?: string | number;
+  Password?: string | number;
+  password?: string | number;
+  IsAdmin?: string | number | boolean;
+  isAdmin?: string | number | boolean;
+}
+
 @Injectable()
 export class AdminService {
-    constructor(
-        private readonly userService: UserService,
-        private readonly tenantService: TenantService,
-        private readonly i18nService: I18nService,
-    ) { }
-
-    async getTenantUsers(tenantId?: string, page?: number, limit?: number) {
-        if (!tenantId) {
-            return this.userService.findAll(page, limit);
-        }
-        return this.userService.findByTenantId(tenantId, page, limit);
+  constructor(
+    private readonly userService: UserService,
+    private readonly tenantService: TenantService,
+    private readonly i18nService: I18nService,
+  ) {}
+
+  async getTenantUsers(tenantId?: string, page?: number, limit?: number) {
+    if (!tenantId) {
+      return this.userService.findAll(page, limit);
     }
-
-    async exportUsers(tenantId?: string): Promise<Buffer> {
-        const { data: users } = tenantId 
-            ? await this.userService.findByTenantId(tenantId)
-            : await this.userService.findAll();
-        
-        const worksheet = XLSX.utils.json_to_sheet(users.map(u => ({
-            Username: u.username,
-            DisplayName: u.displayName || '',
-            IsAdmin: u.isAdmin ? 'Yes' : 'No',
-            CreatedAt: u.createdAt,
-            Password: '', // Placeholder for new users
-        })));
-
-        const workbook = XLSX.utils.book_new();
-        XLSX.utils.book_append_sheet(workbook, worksheet, 'Users');
-
-        return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
-    }
-
-    async importUsers(tenantId?: string, file?: any) {
-        if (!file) throw new BadRequestException(this.i18nService.getMessage('uploadNoFile'));
-
-        const workbook = XLSX.read(file.buffer, { type: 'buffer' });
-        const sheetName = workbook.SheetNames[0];
-        const worksheet = workbook.Sheets[sheetName];
-        const data = XLSX.utils.sheet_to_json(worksheet) as any[];
-
-        const results = {
-            success: 0,
-            failed: 0,
-            errors: [] as string[],
-        };
-
-        for (const row of data) {
-            try {
-                const username = (row.Username || row.username)?.toString();
-                const displayName = (row.DisplayName || row.displayName || row.Name || row.name)?.toString();
-                const password = (row.Password || row.password)?.toString();
-                const isAdminStr = (row.IsAdmin || row.isAdmin || 'No').toString();
-                const isAdmin = isAdminStr.toLowerCase() === 'yes' || isAdminStr === 'true' || isAdminStr === '1';
-
-                if (!username) {
-                    throw new Error(this.i18nService.getMessage('usernameRequired'));
-                }
-
-                const existingUser = await this.userService.findOneByUsername(username);
-
-                if (existingUser) {
-                    await this.userService.updateUser(existingUser.id, {
-                        displayName: displayName || existingUser.displayName,
-                        password: password || undefined,
-                        // We avoid changing isAdmin status via import for security unless explicitly required
-                    });
-                } else {
-                    if (!password) {
-                        throw new Error(this.i18nService.formatMessage('passwordRequiredForNewUser', { username }));
-                    }
-                    await this.userService.createUser(
-                        username,
-                        password,
-                        isAdmin,
-                        tenantId,
-                        displayName
-                    );
-                }
-                results.success++;
-            } catch (e: any) {
-                results.failed++;
-                results.errors.push(`${row.Username || 'Unknown'}: ${e.message}`);
-            }
+    return this.userService.findByTenantId(tenantId, page, limit);
+  }
+
+  async exportUsers(tenantId?: string): Promise<Buffer> {
+    const { data: users } = tenantId
+      ? await this.userService.findByTenantId(tenantId)
+      : await this.userService.findAll();
+
+    const worksheet = XLSX.utils.json_to_sheet(
+      users.map((u) => ({
+        Username: u.username,
+        DisplayName: u.displayName || '',
+        IsAdmin: u.isAdmin ? 'Yes' : 'No',
+        CreatedAt: u.createdAt,
+        Password: '', // Placeholder for new users
+      })),
+    );
+
+    const workbook = XLSX.utils.book_new();
+    XLSX.utils.book_append_sheet(workbook, worksheet, 'Users');
+
+    return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
+  }
+
+  async importUsers(tenantId?: string, file?: any) {
+    if (!file)
+      throw new BadRequestException(
+        this.i18nService.getMessage('uploadNoFile'),
+      );
+
+    const workbook = XLSX.read(file.buffer, { type: 'buffer' });
+    const sheetName = workbook.SheetNames[0];
+    const worksheet = workbook.Sheets[sheetName];
+    const data = XLSX.utils.sheet_to_json<UserImportRow>(worksheet);
+
+    const results = {
+      success: 0,
+      failed: 0,
+      errors: [] as string[],
+    };
+
+    for (const row of data) {
+      try {
+        const username = (row.Username || row.username)?.toString();
+        const displayName = (
+          row.DisplayName ||
+          row.displayName ||
+          row.Name ||
+          row.name
+        )?.toString();
+        const password = (row.Password || row.password)?.toString();
+        const isAdminStr = (row.IsAdmin || row.isAdmin || 'No').toString();
+        const isAdmin =
+          isAdminStr.toLowerCase() === 'yes' ||
+          isAdminStr === 'true' ||
+          isAdminStr === '1';
+
+        if (!username) {
+          throw new Error(this.i18nService.getMessage('usernameRequired'));
         }
 
-        return results;
+        const existingUser = await this.userService.findOneByUsername(username);
+
+        if (existingUser) {
+          await this.userService.updateUser(existingUser.id, {
+            displayName: displayName || existingUser.displayName,
+            password: password || undefined,
+            // We avoid changing isAdmin status via import for security unless explicitly required
+          });
+        } else {
+          if (!password) {
+            throw new Error(
+              this.i18nService.formatMessage('passwordRequiredForNewUser', {
+                username,
+              }),
+            );
+          }
+          await this.userService.createUser(
+            username,
+            password,
+            isAdmin,
+            tenantId,
+            displayName,
+          );
+        }
+        results.success++;
+      } catch (e: any) {
+        results.failed++;
+        results.errors.push(`${row.Username || 'Unknown'}: ${e.message}`);
+      }
     }
 
-    async getTenantSettings(tenantId: string) {
-        return this.tenantService.getSettings(tenantId);
-    }
+    return results;
+  }
 
-    async updateTenantSettings(tenantId: string, data: any) {
-        return this.tenantService.updateSettings(tenantId, data);
-    }
+  async getTenantSettings(tenantId: string) {
+    return this.tenantService.getSettings(tenantId);
+  }
 
-    // Notebook sharing approval and model assignments would go here
-    async getPendingShares(tenantId: string) {
-        // Mock implementation for pending shares to satisfy UI.
-        // Needs proper schema/entity support in the future.
-        return [];
-    }
+  async updateTenantSettings(tenantId: string, data: any) {
+    return this.tenantService.updateSettings(tenantId, data);
+  }
+
+  // Notebook sharing approval and model assignments would go here
+  async getPendingShares(tenantId: string) {
+    // Mock implementation for pending shares to satisfy UI.
+    // Needs proper schema/entity support in the future.
+    return [];
+  }
 }

+ 3 - 6
server/src/ai/embedding.service.ts

@@ -8,24 +8,21 @@ export class EmbeddingService {
 
   async getEmbeddings(text: string, config: ModelConfig): Promise<number[]> {
     try {
-      
       const embeddings = new OpenAIEmbeddings({
-        openAIApiKey: config.apiKey || 'ollama', 
+        openAIApiKey: config.apiKey || 'ollama',
         configuration: {
           baseURL: config.baseUrl,
         },
-        modelName: config.modelId, 
+        modelName: config.modelId,
       });
 
-      
-      
       const vector = await embeddings.embedQuery(text);
       return vector;
     } catch (error) {
       this.logger.error(
         `Failed to generate embeddings using model ${config.modelId}`,
         error,
-      ); 
+      );
       throw error;
     }
   }

+ 260 - 234
server/src/api/api-v1.controller.ts

@@ -1,15 +1,15 @@
 import {
-    Body,
-    Controller,
-    Delete,
-    Get,
-    Param,
-    Post,
-    Request,
-    Res,
-    UploadedFile,
-    UseGuards,
-    UseInterceptors,
+  Body,
+  Controller,
+  Delete,
+  Get,
+  Param,
+  Post,
+  Request,
+  Res,
+  UploadedFile,
+  UseGuards,
+  UseInterceptors,
 } from '@nestjs/common';
 import { FileInterceptor } from '@nestjs/platform-express';
 import { Response } from 'express';
@@ -25,250 +25,276 @@ import { I18nService } from '../i18n/i18n.service';
 @Controller('v1')
 @UseGuards(ApiKeyGuard)
 export class ApiV1Controller {
-    constructor(
-        private readonly ragService: RagService,
-        private readonly chatService: ChatService,
-        private readonly knowledgeBaseService: KnowledgeBaseService,
-        private readonly modelConfigService: ModelConfigService,
-        private readonly tenantService: TenantService,
-        private readonly userSettingService: UserSettingService,
-        private readonly i18nService: I18nService,
-    ) { }
+  constructor(
+    private readonly ragService: RagService,
+    private readonly chatService: ChatService,
+    private readonly knowledgeBaseService: KnowledgeBaseService,
+    private readonly modelConfigService: ModelConfigService,
+    private readonly tenantService: TenantService,
+    private readonly userSettingService: UserSettingService,
+    private readonly i18nService: I18nService,
+  ) {}
 
-    // ========== Chat / RAG ==========
-    /**
-     * POST /api/v1/chat
-     * Tenant-scoped RAG chat. Supports both streaming (SSE) and standard JSON responses.
-     * Body: { message, stream?, selectedGroups?, selectedFiles? }
-     */
-    @Post('chat')
-    async chat(
-        @Request() req,
-        @Body()
-        body: {
-            message: string;
-            stream?: boolean;
-            selectedGroups?: string[];
-            selectedFiles?: string[];
-        },
-        @Res() res: Response,
-    ) {
-        const { message, stream = false, selectedGroups, selectedFiles } = body;
-        const user = req.user;
+  // ========== Chat / RAG ==========
+  /**
+   * POST /api/v1/chat
+   * Tenant-scoped RAG chat. Supports both streaming (SSE) and standard JSON responses.
+   * Body: { message, stream?, selectedGroups?, selectedFiles? }
+   */
+  @Post('chat')
+  async chat(
+    @Request() req,
+    @Body()
+    body: {
+      message: string;
+      stream?: boolean;
+      selectedGroups?: string[];
+      selectedFiles?: string[];
+    },
+    @Res() res: Response,
+  ) {
+    const { message, stream = false, selectedGroups, selectedFiles } = body;
+    const user = req.user;
 
-        if (!message) {
-            return res.status(400).json({ error: 'message is required' });
-        }
+    if (!message) {
+      return res.status(400).json({ error: 'message is required' });
+    }
 
-        // Get organization settings and model configuration
-        const tenantSettings = await this.tenantService.getSettings(user.tenantId);
-        const userSetting = await this.userSettingService.getByUser(user.id);
-        const models = await this.modelConfigService.findAll(user.id, user.tenantId);
-        const llmModel = models.find((m) => m.id === tenantSettings?.selectedLLMId) ?? models.find((m) => m.type === 'llm' && m.isDefault);
+    // Get organization settings and model configuration
+    const tenantSettings = await this.tenantService.getSettings(user.tenantId);
+    const userSetting = await this.userSettingService.getByUser(user.id);
+    const models = await this.modelConfigService.findAll(
+      user.id,
+      user.tenantId,
+    );
+    const llmModel =
+      models.find((m) => m.id === tenantSettings?.selectedLLMId) ??
+      models.find((m) => m.type === 'llm' && m.isDefault);
 
-        if (!llmModel) {
-            return res.status(400).json({ error: 'No LLM model configured for this user' });
-        }
-
-        const modelConfig = llmModel as any;
+    if (!llmModel) {
+      return res
+        .status(400)
+        .json({ error: 'No LLM model configured for this user' });
+    }
 
-        if (stream) {
-            res.setHeader('Content-Type', 'text/event-stream');
-            res.setHeader('Cache-Control', 'no-cache');
-            res.setHeader('Connection', 'keep-alive');
+    const modelConfig = llmModel as any;
 
-            try {
-                const stream = this.chatService.streamChat(
-                    message,
-                    [],                                       // history
-                    user.id,
-                    modelConfig,
-                    userSetting?.language ?? 'zh',            // userLanguage
-                    tenantSettings?.selectedEmbeddingId,      // selectedEmbeddingId
-                    selectedGroups,                           // selectedGroups
-                    selectedFiles,                            // selectedFiles
-                    undefined,                                // historyId
-                    tenantSettings?.enableRerank ?? false,    // enableRerank
-                    tenantSettings?.selectedRerankId,         // selectedRerankId
-                    tenantSettings?.temperature,              // temperature
-                    tenantSettings?.maxTokens,                // maxTokens
-                    tenantSettings?.topK ?? 5,                // topK
-                    tenantSettings?.similarityThreshold ?? 0.3,   // similarityThreshold
-                    tenantSettings?.rerankSimilarityThreshold ?? 0.5, // rerankSimilarityThreshold
-                    tenantSettings?.enableQueryExpansion ?? false, // enableQueryExpansion
-                    tenantSettings?.enableHyDE ?? false,           // enableHyDE
-                    user.tenantId,                             // Passing tenantId correctly
-                );
+    if (stream) {
+      res.setHeader('Content-Type', 'text/event-stream');
+      res.setHeader('Cache-Control', 'no-cache');
+      res.setHeader('Connection', 'keep-alive');
 
-                for await (const chunk of stream) {
-                    res.write(`data: ${JSON.stringify(chunk)}\n\n`);
-                }
-                res.write('data: [DONE]\n\n');
-                res.end();
-            } catch (error) {
-                res.write(`data: ${JSON.stringify({ type: 'error', data: error.message })}\n\n`);
-                res.end();
-            }
-        } else {
-            // Non-streaming: collect all chunks and return as JSON
-            try {
-                let fullContent = '';
-                const sources: any[] = [];
-                let historyId: string | undefined;
+      try {
+        const stream = this.chatService.streamChat(
+          message,
+          [], // history
+          user.id,
+          modelConfig,
+          userSetting?.language ?? 'zh', // userLanguage
+          tenantSettings?.selectedEmbeddingId, // selectedEmbeddingId
+          selectedGroups, // selectedGroups
+          selectedFiles, // selectedFiles
+          undefined, // historyId
+          tenantSettings?.enableRerank ?? false, // enableRerank
+          tenantSettings?.selectedRerankId, // selectedRerankId
+          tenantSettings?.temperature, // temperature
+          tenantSettings?.maxTokens, // maxTokens
+          tenantSettings?.topK ?? 5, // topK
+          tenantSettings?.similarityThreshold ?? 0.3, // similarityThreshold
+          tenantSettings?.rerankSimilarityThreshold ?? 0.5, // rerankSimilarityThreshold
+          tenantSettings?.enableQueryExpansion ?? false, // enableQueryExpansion
+          tenantSettings?.enableHyDE ?? false, // enableHyDE
+          user.tenantId, // Passing tenantId correctly
+        );
 
-                const chatStream = this.chatService.streamChat(
-                    message,
-                    [],
-                    user.id,
-                    modelConfig,
-                    userSetting?.language ?? 'zh',
-                    tenantSettings?.selectedEmbeddingId,
-                    selectedGroups,
-                    selectedFiles,
-                    undefined,                                // historyId
-                    tenantSettings?.enableRerank ?? false,
-                    tenantSettings?.selectedRerankId,
-                    tenantSettings?.temperature,
-                    tenantSettings?.maxTokens,
-                    tenantSettings?.topK ?? 5,
-                    tenantSettings?.similarityThreshold ?? 0.3,
-                    tenantSettings?.rerankSimilarityThreshold ?? 0.5,
-                    tenantSettings?.enableQueryExpansion ?? false,
-                    tenantSettings?.enableHyDE ?? false,
-                    user.tenantId,                            // Passing tenantId correctly
-                );
+        for await (const chunk of stream) {
+          res.write(`data: ${JSON.stringify(chunk)}\n\n`);
+        }
+        res.write('data: [DONE]\n\n');
+        res.end();
+      } catch (error) {
+        res.write(
+          `data: ${JSON.stringify({ type: 'error', data: error.message })}\n\n`,
+        );
+        res.end();
+      }
+    } else {
+      // Non-streaming: collect all chunks and return as JSON
+      try {
+        let fullContent = '';
+        const sources: any[] = [];
+        let historyId: string | undefined;
 
-                for await (const chunk of chatStream) {
-                    if (chunk.type === 'content') fullContent += chunk.data;
-                    else if (chunk.type === 'sources') sources.push(...chunk.data);
-                    else if (chunk.type === 'historyId') historyId = chunk.data;
-                }
+        const chatStream = this.chatService.streamChat(
+          message,
+          [],
+          user.id,
+          modelConfig,
+          userSetting?.language ?? 'zh',
+          tenantSettings?.selectedEmbeddingId,
+          selectedGroups,
+          selectedFiles,
+          undefined, // historyId
+          tenantSettings?.enableRerank ?? false,
+          tenantSettings?.selectedRerankId,
+          tenantSettings?.temperature,
+          tenantSettings?.maxTokens,
+          tenantSettings?.topK ?? 5,
+          tenantSettings?.similarityThreshold ?? 0.3,
+          tenantSettings?.rerankSimilarityThreshold ?? 0.5,
+          tenantSettings?.enableQueryExpansion ?? false,
+          tenantSettings?.enableHyDE ?? false,
+          user.tenantId, // Passing tenantId correctly
+        );
 
-                return res.json({ content: fullContent, sources, historyId });
-            } catch (error) {
-                return res.status(500).json({ error: error.message });
-            }
+        for await (const chunk of chatStream) {
+          if (chunk.type === 'content') fullContent += chunk.data;
+          else if (chunk.type === 'sources') sources.push(...chunk.data);
+          else if (chunk.type === 'historyId') historyId = chunk.data;
         }
+
+        return res.json({ content: fullContent, sources, historyId });
+      } catch (error) {
+        return res.status(500).json({ error: error.message });
+      }
     }
+  }
 
-    // ========== Search ==========
-    /**
-     * POST /api/v1/search
-     * Tenant-scoped hybrid search across knowledge base.
-     * Body: { query, topK?, threshold?, selectedGroups?, selectedFiles? }
-     */
-    @Post('search')
-    async search(
-        @Request() req,
-        @Body()
-        body: {
-            query: string;
-            topK?: number;
-            threshold?: number;
-            selectedGroups?: string[];
-            selectedFiles?: string[];
-        },
-    ) {
-        const { query, topK = 5, threshold = 0.3, selectedGroups, selectedFiles } = body;
-        const user = req.user;
+  // ========== Search ==========
+  /**
+   * POST /api/v1/search
+   * Tenant-scoped hybrid search across knowledge base.
+   * Body: { query, topK?, threshold?, selectedGroups?, selectedFiles? }
+   */
+  @Post('search')
+  async search(
+    @Request() req,
+    @Body()
+    body: {
+      query: string;
+      topK?: number;
+      threshold?: number;
+      selectedGroups?: string[];
+      selectedFiles?: string[];
+    },
+  ) {
+    const {
+      query,
+      topK = 5,
+      threshold = 0.3,
+      selectedGroups,
+      selectedFiles,
+    } = body;
+    const user = req.user;
 
-        if (!query) return { error: 'query is required' };
+    if (!query) return { error: 'query is required' };
 
-        const userSetting = await this.tenantService.getSettings(user.tenantId);
+    const userSetting = await this.tenantService.getSettings(user.tenantId);
 
-        const results = await this.ragService.searchKnowledge(
-            query,
-            user.id,
-            topK,
-            threshold,
-            userSetting?.selectedEmbeddingId,
-            userSetting?.enableFullTextSearch ?? false,
-            userSetting?.enableRerank ?? false,
-            userSetting?.selectedRerankId,
-            selectedGroups,
-            selectedFiles,
-            userSetting?.rerankSimilarityThreshold ?? 0.5,
-            user.tenantId,
-            userSetting?.enableQueryExpansion ?? false,
-            userSetting?.enableHyDE ?? false,
-        );
+    const results = await this.ragService.searchKnowledge(
+      query,
+      user.id,
+      topK,
+      threshold,
+      userSetting?.selectedEmbeddingId,
+      userSetting?.enableFullTextSearch ?? false,
+      userSetting?.enableRerank ?? false,
+      userSetting?.selectedRerankId,
+      selectedGroups,
+      selectedFiles,
+      userSetting?.rerankSimilarityThreshold ?? 0.5,
+      user.tenantId,
+      userSetting?.enableQueryExpansion ?? false,
+      userSetting?.enableHyDE ?? false,
+    );
 
-        return { results, total: results.length };
-    }
+    return { results, total: results.length };
+  }
 
-    // ========== Knowledge Base ==========
-    /**
-     * GET /api/v1/knowledge-bases
-     * List all files belonging to the caller's tenant.
-     */
-    @Get('knowledge-bases')
-    async listFiles(@Request() req) {
-        const user = req.user;
-        const files = await this.knowledgeBaseService.findAll(user.id, user.tenantId);
-        return {
-            files: files.map((f) => ({
-                id: f.id,
-                name: f.originalName,
-                title: f.title,
-                status: f.status,
-                size: f.size,
-                mimetype: f.mimetype,
-                createdAt: f.createdAt,
-            })),
-            total: files.length,
-        };
-    }
+  // ========== Knowledge Base ==========
+  /**
+   * GET /api/v1/knowledge-bases
+   * List all files belonging to the caller's tenant.
+   */
+  @Get('knowledge-bases')
+  async listFiles(@Request() req) {
+    const user = req.user;
+    const files = await this.knowledgeBaseService.findAll(
+      user.id,
+      user.tenantId,
+    );
+    return {
+      files: files.map((f) => ({
+        id: f.id,
+        name: f.originalName,
+        title: f.title,
+        status: f.status,
+        size: f.size,
+        mimetype: f.mimetype,
+        createdAt: f.createdAt,
+      })),
+      total: files.length,
+    };
+  }
 
-    /**
-     * POST /api/v1/knowledge-bases/upload
-     * Upload and index a file into the caller's tenant knowledge base.
-     */
-    @Post('knowledge-bases/upload')
-    @UseInterceptors(FileInterceptor('file'))
-    async uploadFile(
-        @Request() req,
-        @UploadedFile() file: Express.Multer.File,
-        @Body() body: { mode?: 'fast' | 'precise'; chunkSize?: number; chunkOverlap?: number },
-    ) {
-        if (!file) return { error: 'file is required' };
-        const user = req.user;
+  /**
+   * POST /api/v1/knowledge-bases/upload
+   * Upload and index a file into the caller's tenant knowledge base.
+   */
+  @Post('knowledge-bases/upload')
+  @UseInterceptors(FileInterceptor('file'))
+  async uploadFile(
+    @Request() req,
+    @UploadedFile() file: Express.Multer.File,
+    @Body()
+    body: {
+      mode?: 'fast' | 'precise';
+      chunkSize?: number;
+      chunkOverlap?: number;
+    },
+  ) {
+    if (!file) return { error: 'file is required' };
+    const user = req.user;
 
-        const kb = await this.knowledgeBaseService.createAndIndex(
-            file,
-            user.id,
-            user.tenantId,
-            {
-                mode: body.mode ?? 'fast',
-                chunkSize: body.chunkSize ? Number(body.chunkSize) : 1000,
-                chunkOverlap: body.chunkOverlap ? Number(body.chunkOverlap) : 200,
-            },
-        );
+    const kb = await this.knowledgeBaseService.createAndIndex(
+      file,
+      user.id,
+      user.tenantId,
+      {
+        mode: body.mode ?? 'fast',
+        chunkSize: body.chunkSize ? Number(body.chunkSize) : 1000,
+        chunkOverlap: body.chunkOverlap ? Number(body.chunkOverlap) : 200,
+      },
+    );
 
-        return {
-            id: kb.id,
-            name: kb.originalName,
-            status: kb.status,
-            message: 'File uploaded and indexing started',
-        };
-    }
+    return {
+      id: kb.id,
+      name: kb.originalName,
+      status: kb.status,
+      message: 'File uploaded and indexing started',
+    };
+  }
 
-    /**
-     * DELETE /api/v1/knowledge-bases/:id
-     * Delete a specific file from the knowledge base.
-     */
-    @Delete('knowledge-bases/:id')
-    async deleteFile(@Request() req, @Param('id') id: string) {
-        const user = req.user;
-        await this.knowledgeBaseService.deleteFile(id, user.id, user.tenantId);
-        return { message: this.i18nService.getMessage('fileDeleted') };
-    }
+  /**
+   * DELETE /api/v1/knowledge-bases/:id
+   * Delete a specific file from the knowledge base.
+   */
+  @Delete('knowledge-bases/:id')
+  async deleteFile(@Request() req, @Param('id') id: string) {
+    const user = req.user;
+    await this.knowledgeBaseService.deleteFile(id, user.id, user.tenantId);
+    return { message: this.i18nService.getMessage('fileDeleted') };
+  }
 
-    @Get('knowledge-bases/:id')
-    async getFile(@Request() req, @Param('id') id: string) {
-        const user = req.user;
-        const files = await this.knowledgeBaseService.findAll(user.id, user.tenantId);
-        const file = files.find((f) => f.id === id);
-        if (!file) return { error: 'File not found' };
-        return file;
-    }
+  @Get('knowledge-bases/:id')
+  async getFile(@Request() req, @Param('id') id: string) {
+    const user = req.user;
+    const files = await this.knowledgeBaseService.findAll(
+      user.id,
+      user.tenantId,
+    );
+    const file = files.find((f) => f.id === id);
+    if (!file) return { error: 'File not found' };
+    return file;
+  }
 }

+ 8 - 4
server/src/api/api.controller.ts

@@ -23,7 +23,7 @@ export class ApiController {
     private readonly apiService: ApiService,
     private readonly modelConfigService: ModelConfigService,
     private readonly i18nService: I18nService,
-  ) { }
+  ) {}
 
   @Get('health')
   healthCheck() {
@@ -41,7 +41,10 @@ export class ApiController {
 
     try {
       // ユーザーの LLM モデル設定を取得
-      const models = await this.modelConfigService.findAll(req.user.id, req.user.tenantId);
+      const models = await this.modelConfigService.findAll(
+        req.user.id,
+        req.user.tenantId,
+      );
       const llmModel = models.find((m) => m.type === 'llm');
       if (!llmModel) {
         throw new Error(this.i18nService.getMessage('addLLMConfig'));
@@ -49,7 +52,6 @@ export class ApiController {
 
       // API key is optional - allows local models
 
-      
       const modelConfigForService = {
         id: llmModel.id,
         name: llmModel.name,
@@ -65,7 +67,9 @@ export class ApiController {
       );
       return { response };
     } catch (error) {
-      throw new Error(error.message || this.i18nService.getMessage('internalServerError'));
+      throw new Error(
+        error.message || this.i18nService.getMessage('internalServerError'),
+      );
     }
   }
 }

+ 1 - 1
server/src/api/api.module.ts

@@ -26,4 +26,4 @@ import { memoryStorage } from 'multer';
   controllers: [ApiController, ApiV1Controller],
   providers: [ApiService],
 })
-export class ApiModule { }
+export class ApiModule {}

+ 6 - 2
server/src/api/api.service.ts

@@ -5,7 +5,7 @@ import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class ApiService {
-  constructor(private i18nService: I18nService) { }
+  constructor(private i18nService: I18nService) {}
 
   // Simple health check method
   healthCheck() {
@@ -27,7 +27,11 @@ export class ApiService {
       if (error.message?.includes('401')) {
         throw new Error(this.i18nService.getMessage('invalidApiKey'));
       }
-      throw new Error(this.i18nService.formatMessage('apiCallFailed', { message: error.message }));
+      throw new Error(
+        this.i18nService.formatMessage('apiCallFailed', {
+          message: error.message,
+        }),
+      );
     }
   }
 

+ 3 - 6
server/src/app.module.ts

@@ -55,7 +55,7 @@ import { SuperAdminModule } from './super-admin/super-admin.module';
 import { AdminModule } from './admin/admin.module';
 import { FeishuModule } from './feishu/feishu.module';
 import { FeishuBot } from './feishu/entities/feishu-bot.entity';
-
+import { FeishuAssessmentSession } from './feishu/entities/feishu-assessment-session.entity';
 
 @Module({
   imports: [
@@ -95,7 +95,7 @@ import { FeishuBot } from './feishu/entities/feishu-bot.entity';
           TenantMember,
           ApiKey,
           FeishuBot,
-
+          FeishuAssessmentSession,
         ],
         synchronize: true, // Auto-create database schema. Disable in production.
       }),
@@ -124,7 +124,6 @@ import { FeishuBot } from './feishu/entities/feishu-bot.entity';
     SuperAdminModule,
     AdminModule,
     FeishuModule,
-
   ],
   controllers: [AppController],
   providers: [
@@ -137,9 +136,7 @@ import { FeishuBot } from './feishu/entities/feishu-bot.entity';
 })
 export class AppModule implements NestModule {
   configure(consumer: MiddlewareConsumer) {
-    consumer
-      .apply(I18nMiddleware, TenantMiddleware)
-      .forRoutes('*');
+    consumer.apply(I18nMiddleware, TenantMiddleware).forRoutes('*');
   }
 }
 // Trigger restart correct

+ 107 - 64
server/src/assessment/assessment.controller.ts

@@ -1,4 +1,16 @@
-import { Controller, Post, Body, Get, Param, UseGuards, Request, Sse, MessageEvent, Query, Delete } from '@nestjs/common';
+import {
+  Controller,
+  Post,
+  Body,
+  Get,
+  Param,
+  UseGuards,
+  Request,
+  Sse,
+  MessageEvent,
+  Query,
+  Delete,
+} from '@nestjs/common';
 import { map } from 'rxjs/operators';
 import { AssessmentService } from './assessment.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
@@ -8,74 +20,105 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
 @Controller('assessment')
 @UseGuards(CombinedAuthGuard)
 export class AssessmentController {
-    constructor(private readonly assessmentService: AssessmentService) { }
+  constructor(private readonly assessmentService: AssessmentService) {}
 
-    @Post('start')
-    @ApiOperation({ summary: 'Start a new assessment session' })
-    async startSession(@Request() req: any, @Body() body: { knowledgeBaseId?: string, language?: string, templateId?: string }) {
-        const { id: userId, tenantId } = req.user;
-        console.log(`[AssessmentController] startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`);
-        return this.assessmentService.startSession(userId, body.knowledgeBaseId, tenantId, body.language, body.templateId);
-    }
+  @Post('start')
+  @ApiOperation({ summary: 'Start a new assessment session' })
+  async startSession(
+    @Request() req: any,
+    @Body()
+    body: { knowledgeBaseId?: string; language?: string; templateId?: string },
+  ) {
+    const { id: userId, tenantId } = req.user;
+    console.log(
+      `[AssessmentController] startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`,
+    );
+    return this.assessmentService.startSession(
+      userId,
+      body.knowledgeBaseId,
+      tenantId,
+      body.language,
+      body.templateId,
+    );
+  }
 
-    @Post(':id/answer')
-    @ApiOperation({ summary: 'Submit an answer to the current question' })
-    async submitAnswer(
-        @Request() req: any,
-        @Param('id') sessionId: string,
-        @Body() body: { answer: string, language?: string }
-    ) {
-        const { id: userId } = req.user;
-        console.log(`[AssessmentController] submitAnswer: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`);
-        return this.assessmentService.submitAnswer(sessionId, userId, body.answer, body.language);
-    }
+  @Post(':id/answer')
+  @ApiOperation({ summary: 'Submit an answer to the current question' })
+  async submitAnswer(
+    @Request() req: any,
+    @Param('id') sessionId: string,
+    @Body() body: { answer: string; language?: string },
+  ) {
+    const { id: userId } = req.user;
+    console.log(
+      `[AssessmentController] submitAnswer: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`,
+    );
+    return this.assessmentService.submitAnswer(
+      sessionId,
+      userId,
+      body.answer,
+      body.language,
+    );
+  }
 
-    @Sse(':id/start-stream')
-    @ApiOperation({ summary: 'Stream initial session generation' })
-    startSessionStream(@Request() req: any, @Param('id') sessionId: string) {
-        const { id: userId } = req.user;
-        console.log(`[AssessmentController] startSessionStream: user=${userId}, session=${sessionId}`);
-        return this.assessmentService.startSessionStream(sessionId, userId).pipe(
-            map(data => ({ data } as MessageEvent))
-        );
-    }
+  @Sse(':id/start-stream')
+  @ApiOperation({ summary: 'Stream initial session generation' })
+  startSessionStream(@Request() req: any, @Param('id') sessionId: string) {
+    const { id: userId } = req.user;
+    console.log(
+      `[AssessmentController] startSessionStream: user=${userId}, session=${sessionId}`,
+    );
+    return this.assessmentService
+      .startSessionStream(sessionId, userId)
+      .pipe(map((data) => ({ data }) as MessageEvent));
+  }
 
-    @Sse(':id/answer-stream')
-    @ApiOperation({ summary: 'Stream answer evaluation and next question generation' })
-    submitAnswerStream(
-        @Request() req: any, 
-        @Param('id') sessionId: string, 
-        @Query('answer') answer: string,
-        @Query('language') language?: string
-    ) {
-        const { id: userId } = req.user;
-        console.log(`[AssessmentController] submitAnswerStream: user=${userId}, session=${sessionId}, lang=${language}`);
-        return this.assessmentService.submitAnswerStream(sessionId, userId, answer, language).pipe(
-            map(data => ({ data } as MessageEvent))
-        );
-    }
+  @Sse(':id/answer-stream')
+  @ApiOperation({
+    summary: 'Stream answer evaluation and next question generation',
+  })
+  submitAnswerStream(
+    @Request() req: any,
+    @Param('id') sessionId: string,
+    @Query('answer') answer: string,
+    @Query('language') language?: string,
+  ) {
+    const { id: userId } = req.user;
+    console.log(
+      `[AssessmentController] submitAnswerStream: user=${userId}, session=${sessionId}, lang=${language}`,
+    );
+    return this.assessmentService
+      .submitAnswerStream(sessionId, userId, answer, language)
+      .pipe(map((data) => ({ data }) as MessageEvent));
+  }
 
-    @Get(':id/state')
-    @ApiOperation({ summary: 'Get the current state of an assessment session' })
-    async getSessionState(@Request() req: any, @Param('id') sessionId: string) {
-        const { id: userId } = req.user;
-        console.log(`[AssessmentController] getSessionState: user=${userId}, session=${sessionId}`);
-        return this.assessmentService.getSessionState(sessionId, userId);
-    }
+  @Get(':id/state')
+  @ApiOperation({ summary: 'Get the current state of an assessment session' })
+  async getSessionState(@Request() req: any, @Param('id') sessionId: string) {
+    const { id: userId } = req.user;
+    console.log(
+      `[AssessmentController] getSessionState: user=${userId}, session=${sessionId}`,
+    );
+    return this.assessmentService.getSessionState(sessionId, userId);
+  }
 
-    @Get()
-    @ApiOperation({ summary: 'Get assessment session history' })
-    async getHistory(@Request() req: any) {
-        const { id: userId, tenantId } = req.user;
-        console.log(`[AssessmentController] getHistory: user=${userId}, tenant=${tenantId}`);
-        return this.assessmentService.getHistory(userId, tenantId);
-    }
+  @Get()
+  @ApiOperation({ summary: 'Get assessment session history' })
+  async getHistory(@Request() req: any) {
+    const { id: userId, tenantId } = req.user;
+    console.log(
+      `[AssessmentController] getHistory: user=${userId}, tenant=${tenantId}`,
+    );
+    return this.assessmentService.getHistory(userId, tenantId);
+  }
 
-    @Delete(':id')
-    @ApiOperation({ summary: 'Delete an assessment session' })
-    async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
-        const { id: userId } = req.user;
-        console.log(`[AssessmentController] deleteSession: user=${userId}, session=${sessionId}`);
-        return this.assessmentService.deleteSession(sessionId, userId);
-    }
+  @Delete(':id')
+  @ApiOperation({ summary: 'Delete an assessment session' })
+  async deleteSession(@Request() req: any, @Param('id') sessionId: string) {
+    const user = req.user;
+    console.log(
+      `[AssessmentController] deleteSession: user=${user.id}, role=${user.role}, session=${sessionId}`,
+    );
+    return this.assessmentService.deleteSession(sessionId, user);
+  }
 }

+ 1 - 1
server/src/assessment/assessment.module.ts

@@ -37,4 +37,4 @@ import { ContentFilterService } from './services/content-filter.service';
   providers: [AssessmentService, TemplateService, ContentFilterService],
   exports: [AssessmentService, TemplateService],
 })
-export class AssessmentModule { }
+export class AssessmentModule {}

+ 959 - 655
server/src/assessment/assessment.service.ts

@@ -1,11 +1,25 @@
-import { Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
+import {
+  Injectable,
+  Logger,
+  NotFoundException,
+  Inject,
+  forwardRef,
+} from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository, DeepPartial } from 'typeorm';
 import { ConfigService } from '@nestjs/config';
-import { ChatOpenAI } from "@langchain/openai";
-import { HumanMessage, BaseMessage, AIMessage, SystemMessage } from "@langchain/core/messages";
+import { ChatOpenAI } from '@langchain/openai';
+import {
+  HumanMessage,
+  BaseMessage,
+  AIMessage,
+  SystemMessage,
+} from '@langchain/core/messages';
 import { Observable, from, map, mergeMap, concatMap } from 'rxjs';
-import { AssessmentSession, AssessmentStatus } from './entities/assessment-session.entity';
+import {
+  AssessmentSession,
+  AssessmentStatus,
+} from './entities/assessment-session.entity';
 import { AssessmentQuestion } from './entities/assessment-question.entity';
 import { AssessmentAnswer } from './entities/assessment-answer.entity';
 import { AssessmentTemplate } from './entities/assessment-template.entity';
@@ -25,698 +39,988 @@ import { TenantService } from '../tenant/tenant.service';
 
 @Injectable()
 export class AssessmentService {
-    private readonly logger = new Logger(AssessmentService.name);
-    private readonly graph = createEvaluationGraph();
-
-    constructor(
-        @InjectRepository(AssessmentSession)
-        private sessionRepository: Repository<AssessmentSession>,
-        @InjectRepository(AssessmentQuestion)
-        private questionRepository: Repository<AssessmentQuestion>,
-        @InjectRepository(AssessmentAnswer)
-        private answerRepository: Repository<AssessmentAnswer>,
-        @Inject(forwardRef(() => KnowledgeBaseService))
-        private kbService: KnowledgeBaseService,
-        @Inject(forwardRef(() => KnowledgeGroupService))
-        private groupService: KnowledgeGroupService,
-        @Inject(forwardRef(() => ModelConfigService))
-        private modelConfigService: ModelConfigService,
-        private configService: ConfigService,
-        private templateService: TemplateService,
-        private contentFilterService: ContentFilterService,
-        private ragService: RagService,
-        @Inject(forwardRef(() => ChatService))
-        private chatService: ChatService,
-        private i18nService: I18nService,
-        private tenantService: TenantService,
-    ) { }
-
-    private async getModel(tenantId: string): Promise<ChatOpenAI> {
-        const config = await this.modelConfigService.findDefaultByType(tenantId, ModelType.LLM);
-        return new ChatOpenAI({
-            apiKey: config.apiKey || 'ollama',
-            modelName: config.modelId,
-            temperature: 0.7,
-            configuration: {
-                baseURL: config.baseUrl || 'http://localhost:11434/v1',
-            },
-        });
+  private readonly logger = new Logger(AssessmentService.name);
+  private readonly graph = createEvaluationGraph();
+
+  constructor(
+    @InjectRepository(AssessmentSession)
+    private sessionRepository: Repository<AssessmentSession>,
+    @InjectRepository(AssessmentQuestion)
+    private questionRepository: Repository<AssessmentQuestion>,
+    @InjectRepository(AssessmentAnswer)
+    private answerRepository: Repository<AssessmentAnswer>,
+    @Inject(forwardRef(() => KnowledgeBaseService))
+    private kbService: KnowledgeBaseService,
+    @Inject(forwardRef(() => KnowledgeGroupService))
+    private groupService: KnowledgeGroupService,
+    @Inject(forwardRef(() => ModelConfigService))
+    private modelConfigService: ModelConfigService,
+    private configService: ConfigService,
+    private templateService: TemplateService,
+    private contentFilterService: ContentFilterService,
+    private ragService: RagService,
+    @Inject(forwardRef(() => ChatService))
+    private chatService: ChatService,
+    private i18nService: I18nService,
+    private tenantService: TenantService,
+  ) {}
+
+  private async getModel(tenantId: string): Promise<ChatOpenAI> {
+    const config = await this.modelConfigService.findDefaultByType(
+      tenantId,
+      ModelType.LLM,
+    );
+    return new ChatOpenAI({
+      apiKey: config.apiKey || 'ollama',
+      modelName: config.modelId,
+      temperature: 0.7,
+      configuration: {
+        baseURL: config.baseUrl || 'http://localhost:11434/v1',
+      },
+    });
+  }
+
+  /**
+   * Starts a new assessment session.
+   */
+
+  private async getSessionContent(session: {
+    knowledgeBaseId?: string | null;
+    knowledgeGroupId?: string | null;
+    userId: string;
+    tenantId: string;
+    templateJson?: any;
+  }): Promise<string> {
+    const kbId = session.knowledgeBaseId || session.knowledgeGroupId;
+    this.logger.log(`[getSessionContent] Starting for KB/Group ID: ${kbId}`);
+    if (!kbId) {
+      this.logger.warn(`[getSessionContent] No KB/Group ID provided`);
+      return '';
     }
 
-    /**
-     * Starts a new assessment session.
-     */
-
-    private async getSessionContent(session: { knowledgeBaseId?: string | null, knowledgeGroupId?: string | null, userId: string, tenantId: string, templateJson?: any }): Promise<string> {
-        const kbId = session.knowledgeBaseId || session.knowledgeGroupId;
-        this.logger.log(`[getSessionContent] Starting for KB/Group ID: ${kbId}`);
-        if (!kbId) {
-            this.logger.warn(`[getSessionContent] No KB/Group ID provided`);
-            return '';
-        }
-
-        const keywords = session.templateJson?.keywords || [];
-        
-        // If keywords are provided, use RagService (Hybrid Search) to find relevant content
-        if (keywords.length > 0) {
-            this.logger.log(`[getSessionContent] Keywords detected, performing hybrid search via RagService: ${keywords.join(', ')}`);
-            
-            try {
-                // 1. Determine file IDs to include in search
-                let fileIds: string[] = [];
-                if (session.knowledgeBaseId) {
-                    fileIds = [session.knowledgeBaseId];
-                } else if (session.knowledgeGroupId) {
-                    fileIds = await this.groupService.getFileIdsByGroups([session.knowledgeGroupId], session.userId, session.tenantId);
-                }
-
-                if (fileIds.length > 0) {
-                    const query = keywords.join(' ');
-                    this.logger.log(`[getSessionContent] Performing high-fidelity grounded search (streamChat-style). Keywords: "${query}"`);
-                    
-                    // 1. Get default embedding model (strict logic from streamChat)
-                    const embeddingModel = await this.modelConfigService.findDefaultByType(session.tenantId || 'default', ModelType.EMBEDDING);
-                    
-                    // 2. Perform advanced RAG search
-                    const ragResults = await this.ragService.searchKnowledge(
-                        query,
-                        session.userId,
-                        20, // Increased topK to 20 for broader question coverage
-                        0.1, // Lenient similarityThreshold (Chat/Rag defaults are 0.3)
-                        embeddingModel?.id,
-                        true, // enableFullTextSearch
-                        true, // enableRerank
-                        undefined, // selectedRerankId
-                        undefined, // selectedGroups
-                        fileIds,
-                        0.3, // Lenient rerankSimilarityThreshold (Chat/Rag defaults are 0.5)
-                        session.tenantId
-                    );
-
-                    // 3. Format context using localized labels (equivalent to buildContext)
-                    const language = session.templateJson?.language || 'zh';
-                    const searchContent = ragResults
-                        .map((result, index) => {
-                            // this.logger.debug(`[getSessionContent] Found chunk [${index + 1}]: score=${result.score.toFixed(4)}, file=${result.fileName}, contentPreview=${result.content}...`);
-                            return `[${index + 1}] ${this.i18nService.getMessage('file', language)}:${result.fileName}\n${this.i18nService.getMessage('content', language)}:${result.content}\n`;
-                        })
-                        .join('\n');
-
-                    if (searchContent && searchContent.trim().length > 0) {
-                        this.logger.log(`[getSessionContent] SUCCESS: Found ${ragResults.length} relevant chunks. Total length: ${searchContent.length}`);
-                        // this.logger.log(`[getSessionContent] --- AI Context Start ---\n${searchContent}\n[getSessionContent] --- AI Context End ---`);
-                        return searchContent;
-                    } else {
-                        this.logger.warn(`[getSessionContent] High-fidelity search returned no results for query: "${query}".`);
-                    }
-                } else {
-                    this.logger.warn(`[getSessionContent] No files found for search scope (KB: ${session.knowledgeBaseId}, Group: ${session.knowledgeGroupId})`);
-                }
-            } catch (err) {
-                this.logger.error(`[getSessionContent] Grounded search failed unexpectedly: ${err.message}`, err.stack);
-            }
-            
-            this.logger.warn(`[getSessionContent] Grounded search failed or returned nothing. One common reason is that the keywords are not present in the indexed documents.`);
-        }
+    const keywords = session.templateJson?.keywords || [];
 
-        // Fallback or No Keywords: Original behavior (full content retrieval)
-        let content = '';
+    // If keywords are provided, use RagService (Hybrid Search) to find relevant content
+    if (keywords.length > 0) {
+      this.logger.log(
+        `[getSessionContent] Keywords detected, performing hybrid search via RagService: ${keywords.join(', ')}`,
+      );
 
+      try {
+        // 1. Determine file IDs to include in search
+        let fileIds: string[] = [];
         if (session.knowledgeBaseId) {
-            this.logger.debug(`[getSessionContent] Fetching content for KnowledgeBase: ${kbId}`);
-            const kb = await (this.kbService as any).kbRepository.findOne({ where: { id: kbId, tenantId: session.tenantId } });
-            if (kb) {
-                content = kb.content || '';
-                this.logger.debug(`[getSessionContent] Found KB content, length: ${content.length}`);
-            } else {
-                this.logger.warn(`[getSessionContent] KnowledgeBase not found: ${kbId}`);
-            }
-        } else {
-            try {
-                this.logger.debug(`[getSessionContent] Fetching content for KnowledgeGroup: ${kbId}`);
-                const groupFiles = await this.groupService.getGroupFiles(kbId, session.userId, session.tenantId);
-                this.logger.debug(`[getSessionContent] Found ${groupFiles.length} files in group`);
-                content = groupFiles
-                    .filter(f => f.content)
-                    .map(f => {
-                        this.logger.debug(`[getSessionContent] Including file: ${f.title || f.originalName}, content length: ${f.content?.length || 0}`);
-                        return `--- Document: ${f.title || f.originalName} ---\n${f.content}`;
-                    })
-                    .join('\n\n');
-                this.logger.debug(`[getSessionContent] Total group content length: ${content.length}`);
-            } catch (err) {
-                this.logger.error(`[getSessionContent] Failed to get group files: ${err.message}`);
-            }
+          fileIds = [session.knowledgeBaseId];
+        } else if (session.knowledgeGroupId) {
+          fileIds = await this.groupService.getFileIdsByGroups(
+            [session.knowledgeGroupId],
+            session.userId,
+            session.tenantId,
+          );
         }
 
-        // Apply keyword filter (regex based) as an extra layer if still using full content
-        if (content && keywords.length > 0) {
-            this.logger.debug(`[getSessionContent] Applying fallback keyword filters: ${keywords.join(', ')}`);
-            const prevLen = content.length;
-            content = this.contentFilterService.filterContent(content, keywords);
-            this.logger.debug(`[getSessionContent] After filtering, content length: ${content.length} (was ${prevLen})`);
+        if (fileIds.length > 0) {
+          const query = keywords.join(' ');
+          this.logger.log(
+            `[getSessionContent] Performing high-fidelity grounded search (streamChat-style). Keywords: "${query}"`,
+          );
+
+          // 1. Get default embedding model (strict logic from streamChat)
+          const embeddingModel =
+            await this.modelConfigService.findDefaultByType(
+              session.tenantId || 'default',
+              ModelType.EMBEDDING,
+            );
+
+          // 2. Perform advanced RAG search
+          const ragResults = await this.ragService.searchKnowledge(
+            query,
+            session.userId,
+            20, // Increased topK to 20 for broader question coverage
+            0.1, // Lenient similarityThreshold (Chat/Rag defaults are 0.3)
+            embeddingModel?.id,
+            true, // enableFullTextSearch
+            true, // enableRerank
+            undefined, // selectedRerankId
+            undefined, // selectedGroups
+            fileIds,
+            0.3, // Lenient rerankSimilarityThreshold (Chat/Rag defaults are 0.5)
+            session.tenantId,
+          );
+
+          // 3. Format context using localized labels (equivalent to buildContext)
+          const language = session.templateJson?.language || 'zh';
+          const searchContent = ragResults
+            .map((result, index) => {
+              // this.logger.debug(`[getSessionContent] Found chunk [${index + 1}]: score=${result.score.toFixed(4)}, file=${result.fileName}, contentPreview=${result.content}...`);
+              return `[${index + 1}] ${this.i18nService.getMessage('file', language)}:${result.fileName}\n${this.i18nService.getMessage('content', language)}:${result.content}\n`;
+            })
+            .join('\n');
+
+          if (searchContent && searchContent.trim().length > 0) {
+            this.logger.log(
+              `[getSessionContent] SUCCESS: Found ${ragResults.length} relevant chunks. Total length: ${searchContent.length}`,
+            );
+            // this.logger.log(`[getSessionContent] --- AI Context Start ---\n${searchContent}\n[getSessionContent] --- AI Context End ---`);
+            return searchContent;
+          } else {
+            this.logger.warn(
+              `[getSessionContent] High-fidelity search returned no results for query: "${query}".`,
+            );
+          }
+        } else {
+          this.logger.warn(
+            `[getSessionContent] No files found for search scope (KB: ${session.knowledgeBaseId}, Group: ${session.knowledgeGroupId})`,
+          );
         }
+      } catch (err) {
+        this.logger.error(
+          `[getSessionContent] Grounded search failed unexpectedly: ${err.message}`,
+          err.stack,
+        );
+      }
 
-        this.logger.log(`[getSessionContent] Final content for AI generation (Length: ${content.length})`);
-        this.logger.debug(`[getSessionContent] Content Preview: ${content.substring(0, 500)}...`);
-        
-        return content;
+      this.logger.warn(
+        `[getSessionContent] Grounded search failed or returned nothing. One common reason is that the keywords are not present in the indexed documents.`,
+      );
     }
 
-    /**
-     * Starts a new assessment session.
-     * kbId can be a KnowledgeBase ID or a KnowledgeGroup ID.
-     */
-    async startSession(userId: string, kbId: string | undefined, tenantId: string, language: string = 'en', templateId?: string): Promise<AssessmentSession> {
-        this.logger.log(`[startSession] Starting session for user ${userId}, templateId: ${templateId}, kbId: ${kbId}`);
-        let template: AssessmentTemplate | null = null;
-        if (templateId) {
-            template = await this.templateService.findOne(templateId, tenantId);
-            this.logger.debug(`[startSession] Found template: ${template?.name}, linked group: ${template?.knowledgeGroupId}`);
-        }
+    // Fallback or No Keywords: Original behavior (full content retrieval)
+    let content = '';
+
+    if (session.knowledgeBaseId) {
+      this.logger.debug(
+        `[getSessionContent] Fetching content for KnowledgeBase: ${kbId}`,
+      );
+      const kb = await (this.kbService as any).kbRepository.findOne({
+        where: { id: kbId, tenantId: session.tenantId },
+      });
+      if (kb) {
+        content = kb.content || '';
+        this.logger.debug(
+          `[getSessionContent] Found KB content, length: ${content.length}`,
+        );
+      } else {
+        this.logger.warn(
+          `[getSessionContent] KnowledgeBase not found: ${kbId}`,
+        );
+      }
+    } else {
+      try {
+        this.logger.debug(
+          `[getSessionContent] Fetching content for KnowledgeGroup: ${kbId}`,
+        );
+        const groupFiles = await this.groupService.getGroupFiles(
+          kbId,
+          session.userId,
+          session.tenantId,
+        );
+        this.logger.debug(
+          `[getSessionContent] Found ${groupFiles.length} files in group`,
+        );
+        content = groupFiles
+          .filter((f) => f.content)
+          .map((f) => {
+            this.logger.debug(
+              `[getSessionContent] Including file: ${f.title || f.originalName}, content length: ${f.content?.length || 0}`,
+            );
+            return `--- Document: ${f.title || f.originalName} ---\n${f.content}`;
+          })
+          .join('\n\n');
+        this.logger.debug(
+          `[getSessionContent] Total group content length: ${content.length}`,
+        );
+      } catch (err) {
+        this.logger.error(
+          `[getSessionContent] Failed to get group files: ${err.message}`,
+        );
+      }
+    }
 
-        // Use kbId if provided, otherwise fall back to template's group ID
-        const activeKbId = kbId || template?.knowledgeGroupId;
-        this.logger.log(`[startSession] activeKbId resolved to: ${activeKbId}`);
-        if (!activeKbId) {
-            this.logger.error(`[startSession] No knowledge source resolved`);
-            throw new Error('Knowledge source (ID or Template) must be provided.');
-        }
+    // Apply keyword filter (regex based) as an extra layer if still using full content
+    if (content && keywords.length > 0) {
+      this.logger.debug(
+        `[getSessionContent] Applying fallback keyword filters: ${keywords.join(', ')}`,
+      );
+      const prevLen = content.length;
+      content = this.contentFilterService.filterContent(content, keywords);
+      this.logger.debug(
+        `[getSessionContent] After filtering, content length: ${content.length} (was ${prevLen})`,
+      );
+    }
 
-        // Try to determine if it's a KB or Group
-        const isKb = await (this.kbService as any).kbRepository.count({ where: { id: activeKbId, tenantId } }) > 0;
-        this.logger.debug(`[startSession] isKb: ${isKb}`);
-        
-        const templateData = template ? {
-            name: template.name,
-            keywords: template.keywords,
-            questionCount: template.questionCount,
-            difficultyDistribution: template.difficultyDistribution,
-            style: template.style,
-        } : undefined;
-
-        const sessionData: any = {
-            userId,
-            tenantId,
-            knowledgeBaseId: isKb ? activeKbId : undefined,
-            knowledgeGroupId: isKb ? undefined : activeKbId,
-            templateId,
-            templateJson: templateData,
-            status: AssessmentStatus.IN_PROGRESS,
-            language,
-        };
+    this.logger.log(
+      `[getSessionContent] Final content for AI generation (Length: ${content.length})`,
+    );
+    this.logger.debug(
+      `[getSessionContent] Content Preview: ${content.substring(0, 500)}...`,
+    );
+
+    return content;
+  }
+
+  /**
+   * Starts a new assessment session.
+   * kbId can be a KnowledgeBase ID or a KnowledgeGroup ID.
+   */
+  async startSession(
+    userId: string,
+    kbId: string | undefined,
+    tenantId: string,
+    language: string = 'en',
+    templateId?: string,
+  ): Promise<AssessmentSession> {
+    this.logger.log(
+      `[startSession] Starting session for user ${userId}, templateId: ${templateId}, kbId: ${kbId}`,
+    );
+    let template: AssessmentTemplate | null = null;
+    if (templateId) {
+      template = await this.templateService.findOne(
+        templateId,
+        userId,
+        tenantId,
+      );
+      this.logger.debug(
+        `[startSession] Found template: ${template?.name}, linked group: ${template?.knowledgeGroupId}`,
+      );
+    }
 
-        const content = await this.getSessionContent(sessionData);
+    // Use kbId if provided, otherwise fall back to template's group ID
+    const activeKbId = kbId || template?.knowledgeGroupId;
+    this.logger.log(`[startSession] activeKbId resolved to: ${activeKbId}`);
+    if (!activeKbId) {
+      this.logger.error(`[startSession] No knowledge source resolved`);
+      throw new Error('Knowledge source (ID or Template) must be provided.');
+    }
 
-        if (!content || content.trim().length < 10) {
-            this.logger.error(`[startSession] Insufficient content length: ${content?.length || 0}`);
-            throw new Error('Selected knowledge source has no sufficient content for evaluation.');
+    // Try to determine if it's a KB or Group and check permissions
+    let isKb = false;
+    try {
+      await this.kbService.findOne(activeKbId, userId, tenantId);
+      isKb = true;
+    } catch (kbError) {
+      if (kbError instanceof NotFoundException) {
+        // Try finding it as a Group
+        try {
+          await this.groupService.findOne(activeKbId, userId, tenantId);
+        } catch (groupError) {
+          this.logger.error(
+            `[startSession] Knowledge source ${activeKbId} not found as KB or Group`,
+          );
+          throw new NotFoundException(
+            this.i18nService.getMessage('knowledgeSourceNotFound') ||
+              'Knowledge source not found',
+          );
         }
-
-        const session = this.sessionRepository.create(sessionData as DeepPartial<AssessmentSession>);
-        const savedSession = await this.sessionRepository.save(session as any) as AssessmentSession;
-
-        // Thread ID for LangGraph is the session ID
-        savedSession.threadId = savedSession.id;
-        await this.sessionRepository.save(savedSession);
-
-        this.logger.log(`[startSession] Session ${savedSession.id} created and saved`);
-        return savedSession;
+      } else {
+        throw kbError; // e.g. ForbiddenException
+      }
     }
-
-    /**
-     * Specialized streaming start for initial generation.
-     */
-    startSessionStream(sessionId: string, userId: string): Observable<any> {
-        return new Observable(observer => {
-            (async () => {
-                try {
-                    const session = await this.sessionRepository.findOne({ where: { id: sessionId, userId } });
-                    if (!session) {
-                        observer.error(new NotFoundException('Session not found'));
-                        return;
-                    }
-
-                    const model = await this.getModel(session.tenantId);
-                    const content = await this.getSessionContent(session);
-
-                    // Check if we already have state
-                    const existingState = await this.graph.getState({ configurable: { thread_id: sessionId } });
-                    if (existingState && existingState.values && (existingState.values as any).questions?.length > 0) {
-                        this.logger.log(`Session ${sessionId} already has state, skipping generation.`);
-                        const mappedData = { ...(existingState.values as any) };
-                        mappedData.messages = this.mapMessages(mappedData.messages || []);
-                        mappedData.feedbackHistory = this.mapMessages(mappedData.feedbackHistory || []);
-                        observer.next({ type: 'final', data: mappedData });
-                        observer.complete();
-                        return;
-                    }
-
-                    const initialState: Partial<EvaluationState> = {
-                        assessmentSessionId: sessionId,
-                        knowledgeBaseId: session.knowledgeBaseId || session.knowledgeGroupId || '',
-                        messages: [],
-                        questionCount: session.templateJson?.questionCount,
-                        difficultyDistribution: session.templateJson?.difficultyDistribution,
-                        style: session.templateJson?.style,
-                        keywords: session.templateJson?.keywords,
-                    };
-
-                    const isZh = (session.language || 'en') === 'zh';
-                    const isJa = session.language === 'ja';
-                    const initialMsg = isZh ? "现在生成评估问题。" : (isJa ? "今すぐアセスメント問題を生成してください。" : "Generate the assessment questions now.");
-
-                    this.logger.log(`[startSessionStream] Starting stream for session ${sessionId}`);
-                    const stream = await this.graph.stream(
-                        { 
-                            ...initialState,
-                            language: session.language || 'en', // Ensure language is passed in initial state
-                            messages: [new HumanMessage(initialMsg)] 
-                        },
-                        {
-                            configurable: {
-                                thread_id: sessionId,
-                                model,
-                                knowledgeBaseContent: content,
-                                language: session.language || 'en',
-                                questionCount: session.templateJson?.questionCount,
-                                difficultyDistribution: session.templateJson?.difficultyDistribution,
-                                style: session.templateJson?.style,
-                                keywords: session.templateJson?.keywords,
-                            },
-                            streamMode: ["values", "updates"]
-                        }
-                    );
-
-                    this.logger.debug(`[startSessionStream] Graph stream started`);
-
-                    for await (const [mode, data] of stream) {
-                        if (mode === "updates") {
-                            const node = Object.keys(data)[0];
-                            const updateData = { ...data[node] };
-                            if (updateData.messages) {
-                                updateData.messages = this.mapMessages(updateData.messages);
-                            }
-                            if (updateData.feedbackHistory) {
-                                updateData.feedbackHistory = this.mapMessages(updateData.feedbackHistory);
-                            }
-                            observer.next({ type: 'node', node, data: updateData });
-                        }
-                    }
-
-                    // After stream, get the latest authoritative state from checkpointer
-                    const fullState = await this.graph.getState({ configurable: { thread_id: sessionId } });
-                    const finalData = fullState.values as EvaluationState;
-
-                    if (finalData && finalData.messages) {
-                        console.log(`[AssessmentService] startSessionStream Final Authoritative State messages:`, finalData.messages.length);
-                        session.messages = finalData.messages;
-                        session.feedbackHistory = finalData.feedbackHistory || [];
-                        session.questions_json = finalData.questions;
-                        session.currentQuestionIndex = finalData.currentQuestionIndex;
-                        session.followUpCount = finalData.followUpCount || 0;
-                        
-                        if (finalData.report) {
-                            session.status = AssessmentStatus.COMPLETED;
-                            session.finalReport = finalData.report;
-                            const scores = finalData.scores as Record<string, number>;
-                            const questions = finalData.questions || [];
-                            
-                            if (questions.length > 0 && Object.keys(scores).length > 0) {
-                                let totalWeightedScore = 0;
-                                let totalWeight = 0;
-                                questions.forEach((q: any, idx: number) => {
-                                    const score = scores[q.id || idx.toString()];
-                                    if (score !== undefined) {
-                                        const weight = q.difficulty === 'Specialist' ? 2.0 : (q.difficulty === 'Advanced' ? 1.5 : 1.0);
-                                        totalWeightedScore += (score * weight);
-                                        totalWeight += weight;
-                                    }
-                                });
-                                session.finalScore = totalWeight > 0 ? totalWeightedScore / totalWeight : 0;
-                            }
-                        }
-                        await this.sessionRepository.save(session);
-                        
-                        const mappedData: any = { ...finalData };
-                        mappedData.messages = this.mapMessages(finalData.messages);
-                        mappedData.feedbackHistory = this.mapMessages(finalData.feedbackHistory || []);
-                        mappedData.status = session.status;
-                        mappedData.report = session.finalReport;
-                        mappedData.finalScore = session.finalScore;
-                        observer.next({ type: 'final', data: mappedData });
-                    }
-
-                    observer.complete();
-                } catch (err) {
-                    observer.error(err);
-                }
-            })();
-        });
+    this.logger.debug(`[startSession] isKb: ${isKb}`);
+
+    const templateData = template
+      ? {
+          name: template.name,
+          keywords: template.keywords,
+          questionCount: template.questionCount,
+          difficultyDistribution: template.difficultyDistribution,
+          style: template.style,
+        }
+      : undefined;
+
+    const sessionData: any = {
+      userId,
+      tenantId,
+      knowledgeBaseId: isKb ? activeKbId : undefined,
+      knowledgeGroupId: isKb ? undefined : activeKbId,
+      templateId,
+      templateJson: templateData,
+      status: AssessmentStatus.IN_PROGRESS,
+      language,
+    };
+
+    const content = await this.getSessionContent(sessionData);
+
+    if (!content || content.trim().length < 10) {
+      this.logger.error(
+        `[startSession] Insufficient content length: ${content?.length || 0}`,
+      );
+      throw new Error(
+        'Selected knowledge source has no sufficient content for evaluation.',
+      );
     }
 
-    /**
-     * Submits a user's answer and continues the assessment.
-     */
-    async submitAnswer(sessionId: string, userId: string, answer: string, language: string = 'en'): Promise<any> {
-        const session = await this.sessionRepository.findOne({ where: { id: sessionId, userId } });
-        if (!session) throw new NotFoundException('Session not found');
-
-        const model = await this.getModel(session.tenantId);
-        await this.ensureGraphState(sessionId, session);
-        const content = await this.getSessionContent(session);
-
-        let finalResult: any = null;
-        const stream = await this.graph.stream(
-            { messages: [new HumanMessage(answer)] },
+    const session = this.sessionRepository.create(
+      sessionData as DeepPartial<AssessmentSession>,
+    );
+    const savedSession = (await this.sessionRepository.save(
+      session as any,
+    )) as AssessmentSession;
+
+    // Thread ID for LangGraph is the session ID
+    savedSession.threadId = savedSession.id;
+    await this.sessionRepository.save(savedSession);
+
+    this.logger.log(
+      `[startSession] Session ${savedSession.id} created and saved`,
+    );
+    return savedSession;
+  }
+
+  /**
+   * Specialized streaming start for initial generation.
+   */
+  startSessionStream(sessionId: string, userId: string): Observable<any> {
+    return new Observable((observer) => {
+      (async () => {
+        try {
+          const session = await this.sessionRepository.findOne({
+            where: { id: sessionId, userId },
+          });
+          if (!session) {
+            observer.error(new NotFoundException('Session not found'));
+            return;
+          }
+
+          const model = await this.getModel(session.tenantId);
+          const content = await this.getSessionContent(session);
+
+          // Check if we already have state
+          const existingState = await this.graph.getState({
+            configurable: { thread_id: sessionId },
+          });
+          if (
+            existingState &&
+            existingState.values &&
+            existingState.values.questions?.length > 0
+          ) {
+            this.logger.log(
+              `Session ${sessionId} already has state, skipping generation.`,
+            );
+            const mappedData = { ...existingState.values };
+            mappedData.messages = this.mapMessages(mappedData.messages || []);
+            mappedData.feedbackHistory = this.mapMessages(
+              mappedData.feedbackHistory || [],
+            );
+            observer.next({ type: 'final', data: mappedData });
+            observer.complete();
+            return;
+          }
+
+          const initialState: Partial<EvaluationState> = {
+            assessmentSessionId: sessionId,
+            knowledgeBaseId:
+              session.knowledgeBaseId || session.knowledgeGroupId || '',
+            messages: [],
+            questionCount: session.templateJson?.questionCount,
+            difficultyDistribution:
+              session.templateJson?.difficultyDistribution,
+            style: session.templateJson?.style,
+            keywords: session.templateJson?.keywords,
+          };
+
+          const isZh = (session.language || 'en') === 'zh';
+          const isJa = session.language === 'ja';
+          const initialMsg = isZh
+            ? '现在生成评估问题。'
+            : isJa
+              ? '今すぐアセスメント問題を生成してください。'
+              : 'Generate the assessment questions now.';
+
+          this.logger.log(
+            `[startSessionStream] Starting stream for session ${sessionId}`,
+          );
+          const stream = await this.graph.stream(
             {
-                configurable: {
-                    thread_id: sessionId,
-                    model,
-                    knowledgeBaseContent: content,
-                    language: session.language || language,
-                },
-                streamMode: ["values", "updates"]
+              ...initialState,
+              language: session.language || 'en', // Ensure language is passed in initial state
+              messages: [new HumanMessage(initialMsg)],
+            },
+            {
+              configurable: {
+                thread_id: sessionId,
+                model,
+                knowledgeBaseContent: content,
+                language: session.language || 'en',
+                questionCount: session.templateJson?.questionCount,
+                difficultyDistribution:
+                  session.templateJson?.difficultyDistribution,
+                style: session.templateJson?.style,
+                keywords: session.templateJson?.keywords,
+              },
+              streamMode: ['values', 'updates'],
+            },
+          );
+
+          this.logger.debug(`[startSessionStream] Graph stream started`);
+
+          for await (const [mode, data] of stream) {
+            if (mode === 'updates') {
+              const node = Object.keys(data)[0];
+              const updateData = { ...data[node] };
+              if (updateData.messages) {
+                updateData.messages = this.mapMessages(updateData.messages);
+              }
+              if (updateData.feedbackHistory) {
+                updateData.feedbackHistory = this.mapMessages(
+                  updateData.feedbackHistory,
+                );
+              }
+              observer.next({ type: 'node', node, data: updateData });
             }
-        );
-
-        for await (const [mode, data] of stream) {
-            if (mode === "values") {
-                finalResult = data;
+          }
+
+          // After stream, get the latest authoritative state from checkpointer
+          const fullState = await this.graph.getState({
+            configurable: { thread_id: sessionId },
+          });
+          const finalData = fullState.values as EvaluationState;
+
+          if (finalData && finalData.messages) {
+            console.log(
+              `[AssessmentService] startSessionStream Final Authoritative State messages:`,
+              finalData.messages.length,
+            );
+            session.messages = finalData.messages;
+            session.feedbackHistory = finalData.feedbackHistory || [];
+            session.questions_json = finalData.questions;
+            session.currentQuestionIndex = finalData.currentQuestionIndex;
+            session.followUpCount = finalData.followUpCount || 0;
+
+            if (finalData.report) {
+              session.status = AssessmentStatus.COMPLETED;
+              session.finalReport = finalData.report;
+              const scores = finalData.scores;
+              const questions = finalData.questions || [];
+
+              if (questions.length > 0 && Object.keys(scores).length > 0) {
+                let totalWeightedScore = 0;
+                let totalWeight = 0;
+                questions.forEach((q: any, idx: number) => {
+                  const score = scores[q.id || idx.toString()];
+                  if (score !== undefined) {
+                    const weight =
+                      q.difficulty === 'Specialist'
+                        ? 2.0
+                        : q.difficulty === 'Advanced'
+                          ? 1.5
+                          : 1.0;
+                    totalWeightedScore += score * weight;
+                    totalWeight += weight;
+                  }
+                });
+                session.finalScore =
+                  totalWeight > 0 ? totalWeightedScore / totalWeight : 0;
+              }
             }
+            await this.sessionRepository.save(session);
+
+            const mappedData: any = { ...finalData };
+            mappedData.messages = this.mapMessages(finalData.messages);
+            mappedData.feedbackHistory = this.mapMessages(
+              finalData.feedbackHistory || [],
+            );
+            mappedData.status = session.status;
+            mappedData.report = session.finalReport;
+            mappedData.finalScore = session.finalScore;
+            observer.next({ type: 'final', data: mappedData });
+          }
+
+          observer.complete();
+        } catch (err) {
+          observer.error(err);
         }
+      })();
+    });
+  }
+
+  /**
+   * Submits a user's answer and continues the assessment.
+   */
+  async submitAnswer(
+    sessionId: string,
+    userId: string,
+    answer: string,
+    language: string = 'en',
+  ): Promise<any> {
+    const session = await this.sessionRepository.findOne({
+      where: { id: sessionId, userId },
+      relations: ['template'],
+    });
+    if (!session) throw new NotFoundException('Session not found');
+
+    const model = await this.getModel(session.tenantId);
+    await this.ensureGraphState(sessionId, session);
+    const content = await this.getSessionContent(session);
+
+    // Update state with human message first to ensure it's in history before resumption
+    await this.graph.updateState(
+      { configurable: { thread_id: sessionId } },
+      { messages: [new HumanMessage(answer)] },
+    );
+
+    this.logger.debug(`[submitAnswer] Resuming graph for session ${sessionId}`);
+
+    let finalResult: any = null;
+    // Resume from the last interrupt (typically after interviewer)
+    const stream = await this.graph.stream(null, {
+      configurable: {
+        thread_id: sessionId,
+        model,
+        knowledgeBaseContent: content,
+        language: session.language || language,
+        questionCount: session.templateJson?.questionCount,
+        difficultyDistribution: session.templateJson?.difficultyDistribution,
+        style: session.templateJson?.style,
+        keywords: session.templateJson?.keywords,
+      },
+      streamMode: ['values', 'updates'],
+    });
+
+    for await (const [mode, data] of stream) {
+      if (mode === 'values') {
+        // This might be the interrupt info if interrupted
+        finalResult = data;
+      } else if (mode === 'updates') {
+        const nodeName = Object.keys(data)[0];
+        this.logger.debug(`[submitAnswer] Node completed: ${nodeName}`);
+      }
+    }
 
-        if (finalResult.messages) {
-            session.messages = finalResult.messages;
-            session.questions_json = finalResult.questions;
-            session.currentQuestionIndex = finalResult.currentQuestionIndex;
-            
-            if (finalResult.report) {
-                session.status = AssessmentStatus.COMPLETED;
-                session.finalReport = finalResult.report;
-                const scores = finalResult.scores as Record<string, number>;
-                const questions = finalResult.questions || [];
-                
-                if (questions.length > 0 && Object.keys(scores).length > 0) {
-                    let totalWeightedScore = 0;
-                    let totalWeight = 0;
-                    questions.forEach((q: any, idx: number) => {
-                        const score = scores[q.id || idx.toString()];
-                        if (score !== undefined) {
-                            const weight = q.difficulty === 'Specialist' ? 2.0 : (q.difficulty === 'Advanced' ? 1.5 : 1.0);
-                            totalWeightedScore += (score * weight);
-                            totalWeight += weight;
-                        }
-                    });
-                    session.finalScore = totalWeight > 0 ? totalWeightedScore / totalWeight : 0;
-                }
+    // Always get the latest authoritative state from checkpointer after the stream
+    const fullState = await this.graph.getState({
+      configurable: { thread_id: sessionId },
+    });
+    finalResult = fullState.values as EvaluationState;
+
+    this.logger.log(
+      `[submitAnswer] Stream finished. State Index: ${finalResult.currentQuestionIndex}, Questions: ${finalResult.questions?.length}, HasReport: ${!!finalResult.report}`,
+    );
+
+    if (finalResult && (finalResult.messages || finalResult.questions)) {
+      session.messages = finalResult.messages;
+      session.questions_json = finalResult.questions;
+      session.currentQuestionIndex = finalResult.currentQuestionIndex;
+      session.followUpCount = finalResult.followUpCount || 0;
+
+      if (finalResult.report) {
+        session.status = AssessmentStatus.COMPLETED;
+        session.finalReport = finalResult.report;
+        const scores = finalResult.scores as Record<string, number>;
+        const questions = finalResult.questions || [];
+
+        if (questions.length > 0 && Object.keys(scores).length > 0) {
+          let totalWeightedScore = 0;
+          let totalWeight = 0;
+          questions.forEach((q: any, idx: number) => {
+            const score = scores[q.id || idx.toString()];
+            if (score !== undefined) {
+              const weight =
+                q.difficulty === 'Specialist'
+                  ? 2.0
+                  : q.difficulty === 'Advanced'
+                    ? 1.5
+                    : 1.0;
+              totalWeightedScore += score * weight;
+              totalWeight += weight;
             }
-            session.feedbackHistory = finalResult.feedbackHistory || [];
-            await this.sessionRepository.save(session);
-            finalResult.messages = this.mapMessages(finalResult.messages);
-            finalResult.feedbackHistory = this.mapMessages(finalResult.feedbackHistory || []);
+          });
+          session.finalScore =
+            totalWeight > 0 ? totalWeightedScore / totalWeight : 0;
         }
-
-        return finalResult;
+      }
+
+      session.feedbackHistory = finalResult.feedbackHistory || [];
+      await this.sessionRepository.save(session);
+
+      // Map result for return
+      finalResult.messages = this.mapMessages(finalResult.messages);
+      finalResult.feedbackHistory = this.mapMessages(
+        finalResult.feedbackHistory || [],
+      );
+      finalResult.report = session.finalReport;
+      finalResult.finalScore = session.finalScore;
+
+      this.logger.log(
+        `[submitAnswer] session saved. DB Status: ${session.status}, Index: ${session.currentQuestionIndex}`,
+      );
+      this.logger.log(
+        `[submitAnswer] finalResult check: hasQuestions=${!!finalResult.questions}, questionsLen=${finalResult.questions?.length}, hasReport=${!!finalResult.report}`,
+      );
+      this.logger.debug(
+        `[submitAnswer] finalResult keys: ${Object.keys(finalResult).join(', ')}`,
+      );
+      this.logger.log(
+        `[submitAnswer] session updated: status=${session.status}, index=${session.currentQuestionIndex}`,
+      );
+    } else {
+      this.logger.warn(
+        `[submitAnswer] finalResult has no usable data! Keys: ${Object.keys(finalResult || {}).join(', ')}`,
+      );
     }
 
-    /**
-     * Streaming version of submitAnswer.
-     */
-    submitAnswerStream(sessionId: string, userId: string, answer: string, language: string = 'en'): Observable<any> {
-        return new Observable(observer => {
-            (async () => {
-                try {
-                    const session = await this.sessionRepository.findOne({ where: { id: sessionId, userId } });
-                    if (!session) {
-                        observer.error(new NotFoundException('Session not found'));
-                        return;
-                    }
-
-                    const model = await this.getModel(session.tenantId);
-                    const content = await this.getSessionContent(session);
-                    await this.ensureGraphState(sessionId, session);
-                    const graphState = await this.graph.getState({ configurable: { thread_id: sessionId } });
-                    const hasState = graphState && graphState.values && Object.keys(graphState.values).length > 0;
-                    console.log(`[AssessmentService] submitAnswerStream: sessionId=${sessionId}, hasState=${hasState}, nextNodes=[${graphState.next || ''}]`);
-
-                    // Update state with human message first to ensure it's in history
-                    await this.graph.updateState(
-                        { configurable: { thread_id: sessionId } },
-                        { messages: [new HumanMessage(answer)] }
-                    );
-
-                    // Resume from the last interrupt
-                    const stream = await this.graph.stream(
-                        null,
-                        {
-                            configurable: {
-                                thread_id: sessionId,
-                                model,
-                                knowledgeBaseContent: content,
-                                language: session.language || language,
-                            },
-                            streamMode: ["values", "updates"]
-                        }
+    return finalResult;
+  }
+
+  /**
+   * Streaming version of submitAnswer.
+   */
+  submitAnswerStream(
+    sessionId: string,
+    userId: string,
+    answer: string,
+    language: string = 'en',
+  ): Observable<any> {
+    return new Observable((observer) => {
+      (async () => {
+        try {
+          const session = await this.sessionRepository.findOne({
+            where: { id: sessionId, userId },
+          });
+          if (!session) {
+            observer.error(new NotFoundException('Session not found'));
+            return;
+          }
+
+          const model = await this.getModel(session.tenantId);
+          const content = await this.getSessionContent(session);
+          await this.ensureGraphState(sessionId, session);
+          const graphState = await this.graph.getState({
+            configurable: { thread_id: sessionId },
+          });
+          const hasState =
+            graphState &&
+            graphState.values &&
+            Object.keys(graphState.values).length > 0;
+          console.log(
+            `[AssessmentService] submitAnswerStream: sessionId=${sessionId}, hasState=${hasState}, nextNodes=[${graphState.next || ''}]`,
+          );
+
+          // Update state with human message first to ensure it's in history
+          await this.graph.updateState(
+            { configurable: { thread_id: sessionId } },
+            { messages: [new HumanMessage(answer)] },
+          );
+
+          // Resume from the last interrupt
+          const stream = await this.graph.stream(null, {
+            configurable: {
+              thread_id: sessionId,
+              model,
+              knowledgeBaseContent: content,
+              language: session.language || language,
+            },
+            streamMode: ['values', 'updates'],
+          });
+
+          for await (const [mode, data] of stream) {
+            if (mode === 'updates') {
+              const node = Object.keys(data)[0];
+              const updateData = { ...data[node] };
+              if (updateData.messages) {
+                updateData.messages = this.mapMessages(updateData.messages);
+              }
+              if (updateData.feedbackHistory) {
+                updateData.feedbackHistory = this.mapMessages(
+                  updateData.feedbackHistory,
+                );
+              }
+              observer.next({ type: 'node', node, data: updateData });
+            }
+          }
+
+          // After stream, get authoritative state
+          const fullState = await this.graph.getState({
+            configurable: { thread_id: sessionId },
+          });
+          const finalData = fullState.values as EvaluationState;
+
+          if (finalData && finalData.messages) {
+            console.log(
+              `[AssessmentService] submitAnswerStream Final Authoritative State messages:`,
+              finalData.messages.length,
+            );
+            session.messages = finalData.messages;
+            session.feedbackHistory = finalData.feedbackHistory || [];
+            session.questions_json = finalData.questions;
+            session.currentQuestionIndex = finalData.currentQuestionIndex;
+            session.followUpCount = finalData.followUpCount || 0;
+
+            if (finalData.report) {
+              session.status = AssessmentStatus.COMPLETED;
+              session.finalReport = finalData.report;
+              const scores = finalData.scores;
+              const questions = finalData.questions || [];
+
+              if (questions.length > 0 && Object.keys(scores).length > 0) {
+                let totalWeightedScore = 0;
+                let totalWeight = 0;
+
+                questions.forEach((q: any, idx: number) => {
+                  const score = scores[q.id || idx.toString()];
+                  if (score !== undefined) {
+                    // Standard=1.0, Advanced=1.5, Specialist=2.0
+                    const weight =
+                      q.difficulty === 'Specialist'
+                        ? 2.0
+                        : q.difficulty === 'Advanced'
+                          ? 1.5
+                          : 1.0;
+                    totalWeightedScore += score * weight;
+                    totalWeight += weight;
+                    this.logger.debug(
+                      `[WeightedScoring] Q${idx}: Score=${score}, Difficulty=${q.difficulty}, Weight=${weight}`,
                     );
+                  }
+                });
 
-                    for await (const [mode, data] of stream) {
-                        if (mode === "updates") {
-                            const node = Object.keys(data)[0];
-                            const updateData = { ...data[node] };
-                            if (updateData.messages) {
-                                updateData.messages = this.mapMessages(updateData.messages);
-                            }
-                            if (updateData.feedbackHistory) {
-                                updateData.feedbackHistory = this.mapMessages(updateData.feedbackHistory);
-                            }
-                            observer.next({ type: 'node', node, data: updateData });
-                        }
-                    }
-
-                    // After stream, get authoritative state
-                    const fullState = await this.graph.getState({ configurable: { thread_id: sessionId } });
-                    const finalData = fullState.values as EvaluationState;
-
-                    if (finalData && finalData.messages) {
-                        console.log(`[AssessmentService] submitAnswerStream Final Authoritative State messages:`, finalData.messages.length);
-                        session.messages = finalData.messages;
-                        session.feedbackHistory = finalData.feedbackHistory || [];
-                        session.questions_json = finalData.questions;
-                        session.currentQuestionIndex = finalData.currentQuestionIndex;
-                        session.followUpCount = finalData.followUpCount || 0;
-                        
-                        if (finalData.report) {
-                            session.status = AssessmentStatus.COMPLETED;
-                            session.finalReport = finalData.report;
-                            const scores = finalData.scores as Record<string, number>;
-                            const questions = finalData.questions || [];
-                            
-                            if (questions.length > 0 && Object.keys(scores).length > 0) {
-                                let totalWeightedScore = 0;
-                                let totalWeight = 0;
-                                
-                                questions.forEach((q: any, idx: number) => {
-                                    const score = scores[q.id || idx.toString()];
-                                    if (score !== undefined) {
-                                        // Standard=1.0, Advanced=1.5, Specialist=2.0
-                                        const weight = q.difficulty === 'Specialist' ? 2.0 : (q.difficulty === 'Advanced' ? 1.5 : 1.0);
-                                        totalWeightedScore += (score * weight);
-                                        totalWeight += weight;
-                                        this.logger.debug(`[WeightedScoring] Q${idx}: Score=${score}, Difficulty=${q.difficulty}, Weight=${weight}`);
-                                    }
-                                });
-                                
-                                session.finalScore = totalWeight > 0 ? totalWeightedScore / totalWeight : 0;
-                                this.logger.log(`[WeightedScoring] Session ${sessionId} Final Score: ${session.finalScore} (Weighted Avg)`);
-                            }
-                        }
-                        await this.sessionRepository.save(session);
-                        
-                        const mappedData: any = { ...finalData };
-                        mappedData.messages = this.mapMessages(finalData.messages);
-                        mappedData.feedbackHistory = this.mapMessages(finalData.feedbackHistory || []);
-                        mappedData.status = session.status;
-                        mappedData.report = session.finalReport;
-                        observer.next({ type: 'final', data: mappedData });
-                    }
-
-                    observer.complete();
-                } catch (err) {
-                    observer.error(err);
-                }
-            })();
-        });
-    }
-
-    /**
-     * Retrieves the current state of a session.
-     */
-    async getSessionState(sessionId: string, userId: string): Promise<any> {
-        this.logger.log(`Retrieving state for session ${sessionId} for user ${userId}`);
-        const session = await this.sessionRepository.findOne({ where: { id: sessionId, userId } });
-        if (!session) throw new NotFoundException('Session not found');
-
-        // Ensure graph has state (lazy init or recovery)
-        await this.ensureGraphState(sessionId, session);
-
-        const state = await this.graph.getState({ configurable: { thread_id: sessionId } });
-        const values = { ...state.values };
+                session.finalScore =
+                  totalWeight > 0 ? totalWeightedScore / totalWeight : 0;
+                this.logger.log(
+                  `[WeightedScoring] Session ${sessionId} Final Score: ${session.finalScore} (Weighted Avg)`,
+                );
+              }
+            }
+            await this.sessionRepository.save(session);
 
-        if (values.messages) {
-            values.messages = this.mapMessages(values.messages);
+            const mappedData: any = { ...finalData };
+            mappedData.messages = this.mapMessages(finalData.messages);
+            mappedData.feedbackHistory = this.mapMessages(
+              finalData.feedbackHistory || [],
+            );
+            mappedData.status = session.status;
+            mappedData.report = session.finalReport;
+            observer.next({ type: 'final', data: mappedData });
+          }
+
+          observer.complete();
+        } catch (err) {
+          observer.error(err);
         }
-        if (values.feedbackHistory) {
-            values.feedbackHistory = this.mapMessages(values.feedbackHistory);
-        }
-
-        return values;
+      })();
+    });
+  }
+
+  /**
+   * Retrieves the current state of a session.
+   */
+  async getSessionState(sessionId: string, userId: string): Promise<any> {
+    this.logger.log(
+      `Retrieving state for session ${sessionId} for user ${userId}`,
+    );
+    const session = await this.sessionRepository.findOne({
+      where: { id: sessionId, userId },
+      relations: ['template'],
+    });
+    if (!session) throw new NotFoundException('Session not found');
+
+    // Ensure graph has state (lazy init or recovery)
+    await this.ensureGraphState(sessionId, session);
+
+    const state = await this.graph.getState({
+      configurable: { thread_id: sessionId },
+    });
+    const values = { ...state.values };
+
+    if (values.messages) {
+      values.messages = this.mapMessages(values.messages);
+    }
+    if (values.feedbackHistory) {
+      values.feedbackHistory = this.mapMessages(values.feedbackHistory);
     }
 
-    /**
-     * Retrieves assessment session history for a user.
-     */
-    async getHistory(userId: string, tenantId: string): Promise<AssessmentSession[]> {
-        const history = await this.sessionRepository.find({
-            where: { userId, tenantId },
-            order: { createdAt: 'DESC' },
-            relations: ['knowledgeBase', 'knowledgeGroup'],
-        });
-        
-        // Map questions_json to questions for frontend compatibility
-        const mappedHistory = history.map(session => ({
-            ...session,
-            questions: session.questions_json || [],
-        })) as any;
-
-        this.logger.log(`Found ${history.length} historical sessions`);
-        return mappedHistory;
+    return values;
+  }
+
+  /**
+   * Retrieves assessment session history for a user.
+   */
+  async getHistory(
+    userId: string,
+    tenantId: string,
+  ): Promise<AssessmentSession[]> {
+    const history = await this.sessionRepository.find({
+      where: { userId, tenantId },
+      order: { createdAt: 'DESC' },
+      relations: ['knowledgeBase', 'knowledgeGroup'],
+    });
+
+    // Map questions_json to questions for frontend compatibility
+    const mappedHistory = history.map((session) => ({
+      ...session,
+      questions: session.questions_json || [],
+    })) as any;
+
+    this.logger.log(`Found ${history.length} historical sessions`);
+    return mappedHistory;
+  }
+
+  /**
+   * Deletes an assessment session.
+   */
+  async deleteSession(sessionId: string, user: any): Promise<void> {
+    this.logger.log(
+      `Deleting session ${sessionId} for user ${user.id} (role: ${user.role})`,
+    );
+
+    const userId = user.id;
+    const isAdmin = user.role === 'super_admin' || user.role === 'admin';
+
+    const deleteCondition: any = { id: sessionId };
+    if (!isAdmin) {
+      deleteCondition.userId = userId;
     }
 
-    /**
-     * Deletes an assessment session.
-     */
-    async deleteSession(sessionId: string, userId: string): Promise<void> {
-        this.logger.log(`Deleting session ${sessionId} for user ${userId}`);
-        const result = await this.sessionRepository.delete({ id: sessionId, userId });
-        if (result.affected === 0) {
-            throw new NotFoundException('Session not found or you do not have permission to delete it');
-        }
+    const result = await this.sessionRepository.delete(deleteCondition);
+    if (result.affected === 0) {
+      throw new NotFoundException(
+        'Session not found or you do not have permission to delete it',
+      );
     }
+  }
+
+  /**
+   * Ensures the graph checkpointer has the state for the given session.
+   * Useful for lazy initialization and recovery after server restarts.
+   */
+  private async ensureGraphState(
+    sessionId: string,
+    session: AssessmentSession,
+  ): Promise<void> {
+    const state = await this.graph.getState({
+      configurable: { thread_id: sessionId },
+    });
+
+    if (
+      !state.values ||
+      Object.keys(state.values).length === 0 ||
+      !state.values.messages ||
+      state.values.messages.length === 0
+    ) {
+      const hasHistory = session.messages && session.messages.length > 0;
+
+      if (hasHistory) {
+        this.logger.log(
+          `[ensureGraphState] Recovering state from DB for session ${sessionId}`,
+        );
+        const historicalMessages = this.hydrateMessages(session.messages);
+        await this.graph.updateState(
+          { configurable: { thread_id: sessionId } },
+          {
+            assessmentSessionId: sessionId,
+            knowledgeBaseId:
+              session.knowledgeBaseId || session.knowledgeGroupId || '',
+            messages: historicalMessages,
+            feedbackHistory: this.hydrateMessages(
+              session.feedbackHistory || [],
+            ),
+            questions: session.questions_json || [],
+            currentQuestionIndex: session.currentQuestionIndex || 0,
+            followUpCount: session.followUpCount || 0,
+            questionCount: session.templateJson?.questionCount || 5,
+            difficultyDistribution:
+              session.templateJson?.difficultyDistribution,
+            style: session.templateJson?.style,
+            keywords: session.templateJson?.keywords,
+          },
+          'grader', // Recovering a session with messages should prep for grading the next input
+        );
+      } else {
+        this.logger.log(`Initializing new state for session ${sessionId}`);
+        const content = await this.getSessionContent(session);
+        const model = await this.getModel(session.tenantId);
 
-    /**
-     * Ensures the graph checkpointer has the state for the given session.
-     * Useful for lazy initialization and recovery after server restarts.
-     */
-    private async ensureGraphState(sessionId: string, session: AssessmentSession): Promise<void> {
-        let state = await this.graph.getState({ configurable: { thread_id: sessionId } });
-
-        if (!state.values || Object.keys(state.values).length === 0 || !state.values.messages || state.values.messages.length === 0) {
-            const hasHistory = session.messages && session.messages.length > 0;
-
-            if (hasHistory) {
-                this.logger.log(`Recovering historical state for session ${sessionId}`);
-                const historicalMessages = this.hydrateMessages(session.messages);
-                await this.graph.updateState(
-                    { configurable: { thread_id: sessionId } },
-                    {
-                        assessmentSessionId: sessionId,
-                        knowledgeBaseId: session.knowledgeBaseId || session.knowledgeGroupId || '',
-                        messages: historicalMessages,
-                        feedbackHistory: this.hydrateMessages(session.feedbackHistory || []),
-                        questions: session.questions_json || [],
-                        currentQuestionIndex: session.currentQuestionIndex || 0,
-                        followUpCount: session.followUpCount || 0,
-                        language: session.language || 'en',
-                        questionCount: session.templateJson?.questionCount,
-                        difficultyDistribution: session.templateJson?.difficultyDistribution,
-                        style: session.templateJson?.style,
-                        keywords: session.templateJson?.keywords,
-                    },
-                    "interviewer"
-                );
-            } else {
-                this.logger.log(`Initializing new state for session ${sessionId}`);
-                const content = await this.getSessionContent(session);
-                const model = await this.getModel(session.tenantId);
-
-                const initialState: Partial<EvaluationState> = {
-                    assessmentSessionId: sessionId,
-                    knowledgeBaseId: session.knowledgeBaseId || session.knowledgeGroupId || '',
-                    messages: [],
-                };
-
-                const resultStream = await this.graph.stream(initialState, {
-                    configurable: {
-                        thread_id: sessionId,
-                        model,
-                        knowledgeBaseContent: content,
-                        language: session.language || 'en',
-                        keywords: session.templateJson?.keywords,
-                    },
-                    streamMode: ["values", "updates"]
-                });
+        const initialState: Partial<EvaluationState> = {
+          assessmentSessionId: sessionId,
+          knowledgeBaseId:
+            session.knowledgeBaseId || session.knowledgeGroupId || '',
+          messages: [],
+          questionCount: session.templateJson?.questionCount,
+          difficultyDistribution: session.templateJson?.difficultyDistribution,
+          style: session.templateJson?.style,
+          keywords: session.templateJson?.keywords,
+          language: session.language || 'en',
+        };
 
-                let finalInvokeResult: any = null;
-                const nodes: string[] = [];
-                for await (const [mode, data] of resultStream) {
-                    if (mode === "values") finalInvokeResult = data;
-                    else if (mode === "updates") nodes.push(...Object.keys(data));
-                }
-
-                if (finalInvokeResult.messages) {
-                    session.messages = finalInvokeResult.messages;
-                    session.feedbackHistory = finalInvokeResult.feedbackHistory || [];
-                    session.questions_json = finalInvokeResult.questions;
-                    session.currentQuestionIndex = finalInvokeResult.currentQuestionIndex;
-                    session.followUpCount = finalInvokeResult.followUpCount || 0;
-                    await this.sessionRepository.save(session);
-                }
-            }
-        }
-    }
+        this.logger.log(
+          `[ensureGraphState] Initializing with questionCount=${initialState.questionCount}, keywords=${initialState.keywords?.join(',')}, style=${initialState.style}`,
+        );
 
-    /**
-     * Re-hydrates plain objects from DB into LangChain message instances.
-     */
-    private hydrateMessages(messages: any[]): BaseMessage[] {
-        if (!messages) return [];
-        return messages.map(m => {
-            if (m instanceof BaseMessage) return m;
-
-            const content = m.content || m.text || (typeof m === 'string' ? m : '');
-            const type = m.role || m.type || (m._getType?.()) || 'ai';
-
-            if (type === 'human' || type === 'user') {
-                return new HumanMessage(content);
-            } else if (type === 'ai' || type === 'assistant') {
-                return new AIMessage(content);
-            } else if (type === 'system') {
-                return new SystemMessage(content);
-            }
-            return new AIMessage(content);
+        const resultStream = await this.graph.stream(initialState, {
+          configurable: {
+            thread_id: sessionId,
+            model,
+            knowledgeBaseContent: content,
+            language: session.language || 'en',
+            keywords: session.templateJson?.keywords,
+            questionCount: session.templateJson?.questionCount,
+            difficultyDistribution:
+              session.templateJson?.difficultyDistribution,
+            style: session.templateJson?.style,
+          },
+          streamMode: ['values', 'updates'],
         });
-    }
 
-    /**
-     * Maps LangChain messages to a simple format for the frontend and storage.
-     */
-    private mapMessages(messages: BaseMessage[]): any[] {
-        if (!messages) return [];
-        return messages.map(msg => {
-            const type = msg._getType();
-            let role: 'user' | 'assistant' | 'system' = 'system';
-            
-            if (type === 'human') role = 'user';
-            else if (type === 'ai') role = 'assistant';
-            else if (type === 'system') role = 'system';
-
-            return {
-                role,
-                content: msg.content,
-                type, // Also store the LangChain type for easier hydration
-                timestamp: (msg as any).timestamp || Date.now(),
-            };
-        });
+        let finalInvokeResult: any = null;
+        const nodes: string[] = [];
+        for await (const [mode, data] of resultStream) {
+          if (mode === 'values') finalInvokeResult = data;
+          else if (mode === 'updates') nodes.push(...Object.keys(data));
+        }
+
+        if (finalInvokeResult.messages) {
+          session.messages = finalInvokeResult.messages;
+          session.feedbackHistory = finalInvokeResult.feedbackHistory || [];
+          session.questions_json = finalInvokeResult.questions;
+          session.currentQuestionIndex = finalInvokeResult.currentQuestionIndex;
+          session.followUpCount = finalInvokeResult.followUpCount || 0;
+          await this.sessionRepository.save(session);
+        }
+      }
     }
+  }
+
+  /**
+   * Re-hydrates plain objects from DB into LangChain message instances.
+   */
+  private hydrateMessages(messages: any[]): BaseMessage[] {
+    if (!messages) return [];
+    return messages.map((m) => {
+      if (m instanceof BaseMessage) return m;
+
+      const content = m.content || m.text || (typeof m === 'string' ? m : '');
+      const type = m.role || m.type || m._getType?.() || 'ai';
+
+      if (type === 'human' || type === 'user') {
+        return new HumanMessage(content);
+      } else if (type === 'ai' || type === 'assistant') {
+        return new AIMessage(content);
+      } else if (type === 'system') {
+        return new SystemMessage(content);
+      }
+      return new AIMessage(content);
+    });
+  }
+
+  /**
+   * Maps LangChain messages to a simple format for the frontend and storage.
+   */
+  private mapMessages(messages: BaseMessage[]): any[] {
+    if (!messages) return [];
+    return messages.map((msg) => {
+      const type = msg._getType();
+      let role: 'user' | 'assistant' | 'system' = 'system';
+
+      if (type === 'human') role = 'user';
+      else if (type === 'ai') role = 'assistant';
+      else if (type === 'system') role = 'system';
+
+      return {
+        role,
+        content: msg.content,
+        type, // Also store the LangChain type for easier hydration
+        timestamp: (msg as any).timestamp || Date.now(),
+      };
+    });
+  }
 }

+ 43 - 34
server/src/assessment/controllers/template.controller.ts

@@ -1,13 +1,13 @@
 import {
-    Controller,
-    Get,
-    Post,
-    Body,
-    Put,
-    Param,
-    Delete,
-    UseGuards,
-    Req,
+  Controller,
+  Get,
+  Post,
+  Body,
+  Put,
+  Param,
+  Delete,
+  UseGuards,
+  Req,
 } from '@nestjs/common';
 import { TemplateService } from '../services/template.service';
 import { CreateTemplateDto } from '../dto/create-template.dto';
@@ -17,34 +17,43 @@ import { CombinedAuthGuard } from '../../auth/combined-auth.guard';
 @Controller('assessment/templates')
 @UseGuards(CombinedAuthGuard)
 export class TemplateController {
-    constructor(private readonly templateService: TemplateService) {}
+  constructor(private readonly templateService: TemplateService) {}
 
-    @Post()
-    create(@Body() createDto: CreateTemplateDto, @Req() req: any) {
-        return this.templateService.create(createDto, req.user.id, req.user.tenantId);
-    }
+  @Post()
+  create(@Body() createDto: CreateTemplateDto, @Req() req: any) {
+    return this.templateService.create(
+      createDto,
+      req.user.id,
+      req.user.tenantId,
+    );
+  }
 
-    @Get()
-    findAll(@Req() req: any) {
-        return this.templateService.findAll(req.user.tenantId);
-    }
+  @Get()
+  findAll(@Req() req: any) {
+    return this.templateService.findAll(req.user.tenantId);
+  }
 
-    @Get(':id')
-    findOne(@Param('id') id: string, @Req() req: any) {
-        return this.templateService.findOne(id, req.user.tenantId);
-    }
+  @Get(':id')
+  async findOne(@Param('id') id: string, @Req() req: any) {
+    return this.templateService.findOne(id, req.user.id, req.user.tenantId);
+  }
 
-    @Put(':id')
-    update(
-        @Param('id') id: string,
-        @Body() updateDto: UpdateTemplateDto,
-        @Req() req: any,
-    ) {
-        return this.templateService.update(id, updateDto, req.user.tenantId);
-    }
+  @Put(':id')
+  async update(
+    @Param('id') id: string,
+    @Body() updateDto: UpdateTemplateDto,
+    @Req() req: any,
+  ) {
+    return this.templateService.update(
+      id,
+      updateDto,
+      req.user.id,
+      req.user.tenantId,
+    );
+  }
 
-    @Delete(':id')
-    remove(@Param('id') id: string, @Req() req: any) {
-        return this.templateService.remove(id, req.user.tenantId);
-    }
+  @Delete(':id')
+  async remove(@Param('id') id: string, @Req() req: any) {
+    return this.templateService.remove(id, req.user.id, req.user.tenantId);
+  }
 }

+ 53 - 43
server/src/assessment/dto/create-template.dto.ts

@@ -1,46 +1,56 @@
-import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt, Min, Max, IsObject, IsBoolean } from 'class-validator';
+import {
+  IsString,
+  IsNotEmpty,
+  IsOptional,
+  IsArray,
+  IsInt,
+  Min,
+  Max,
+  IsObject,
+  IsBoolean,
+} from 'class-validator';
 
 export class CreateTemplateDto {
-    @IsString()
-    @IsNotEmpty()
-    name: string;
-
-    @IsString()
-    @IsOptional()
-    description?: string;
-
-    @IsArray()
-    @IsString({ each: true })
-    @IsOptional()
-    keywords?: string[];
-
-    @IsInt()
-    @Min(1)
-    @Max(20)
-    @IsOptional()
-    questionCount?: number = 5;
-
-    @IsObject()
-    @IsOptional()
-    difficultyDistribution?: {
-        standard: number;
-        advanced: number;
-        specialist: number;
-    };
-
-    @IsString()
-    @IsOptional()
-    style?: string = 'technical';
-
-    @IsString()
-    @IsOptional()
-    knowledgeBaseId?: string;
-
-    @IsString()
-    @IsOptional()
-    knowledgeGroupId?: string;
-
-    @IsBoolean()
-    @IsOptional()
-    isActive?: boolean = true;
+  @IsString()
+  @IsNotEmpty()
+  name: string;
+
+  @IsString()
+  @IsOptional()
+  description?: string;
+
+  @IsArray()
+  @IsString({ each: true })
+  @IsOptional()
+  keywords?: string[];
+
+  @IsInt()
+  @Min(1)
+  @Max(20)
+  @IsOptional()
+  questionCount?: number = 5;
+
+  @IsObject()
+  @IsOptional()
+  difficultyDistribution?: {
+    standard: number;
+    advanced: number;
+    specialist: number;
+  };
+
+  @IsString()
+  @IsOptional()
+  style?: string = 'technical';
+
+  @IsString()
+  @IsOptional()
+  knowledgeBaseId?: string;
+
+  @IsString()
+  @IsOptional()
+  knowledgeGroupId?: string;
+
+  @IsBoolean()
+  @IsOptional()
+  isActive?: boolean = true;
 }

+ 30 - 26
server/src/assessment/entities/assessment-answer.entity.ts

@@ -1,41 +1,45 @@
 import {
-    Entity,
-    PrimaryGeneratedColumn,
-    Column,
-    CreateDateColumn,
-    UpdateDateColumn,
-    ManyToOne,
-    JoinColumn,
+  Entity,
+  PrimaryGeneratedColumn,
+  Column,
+  CreateDateColumn,
+  UpdateDateColumn,
+  ManyToOne,
+  JoinColumn,
 } from 'typeorm';
 import type { AssessmentQuestion } from './assessment-question.entity';
 
 @Entity('assessment_answers')
 export class AssessmentAnswer {
-    @PrimaryGeneratedColumn('uuid')
-    id: string;
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
 
-    @Column({ name: 'question_id' })
-    questionId: string;
+  @Column({ name: 'question_id' })
+  questionId: string;
 
-    @ManyToOne('AssessmentQuestion', (question: AssessmentQuestion) => question.answers, { onDelete: 'CASCADE' })
-    @JoinColumn({ name: 'question_id' })
-    question: AssessmentQuestion;
+  @ManyToOne(
+    'AssessmentQuestion',
+    (question: AssessmentQuestion) => question.answers,
+    { onDelete: 'CASCADE' },
+  )
+  @JoinColumn({ name: 'question_id' })
+  question: AssessmentQuestion;
 
-    @Column({ type: 'text', name: 'user_answer' })
-    userAnswer: string;
+  @Column({ type: 'text', name: 'user_answer' })
+  userAnswer: string;
 
-    @Column({ type: 'float', nullable: true })
-    score: number;
+  @Column({ type: 'float', nullable: true })
+  score: number;
 
-    @Column({ type: 'text', nullable: true })
-    feedback: string;
+  @Column({ type: 'text', nullable: true })
+  feedback: string;
 
-    @Column({ type: 'boolean', name: 'is_follow_up', default: false })
-    isFollowUp: boolean;
+  @Column({ type: 'boolean', name: 'is_follow_up', default: false })
+  isFollowUp: boolean;
 
-    @CreateDateColumn({ name: 'created_at' })
-    createdAt: Date;
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt: Date;
 
-    @UpdateDateColumn({ name: 'updated_at' })
-    updatedAt: Date;
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt: Date;
 }

+ 31 - 27
server/src/assessment/entities/assessment-question.entity.ts

@@ -1,43 +1,47 @@
 import {
-    Entity,
-    PrimaryGeneratedColumn,
-    Column,
-    CreateDateColumn,
-    UpdateDateColumn,
-    ManyToOne,
-    JoinColumn,
-    OneToMany,
+  Entity,
+  PrimaryGeneratedColumn,
+  Column,
+  CreateDateColumn,
+  UpdateDateColumn,
+  ManyToOne,
+  JoinColumn,
+  OneToMany,
 } from 'typeorm';
 import type { AssessmentSession } from './assessment-session.entity';
 import type { AssessmentAnswer } from './assessment-answer.entity';
 
 @Entity('assessment_questions')
 export class AssessmentQuestion {
-    @PrimaryGeneratedColumn('uuid')
-    id: string;
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
 
-    @Column({ name: 'session_id' })
-    sessionId: string;
+  @Column({ name: 'session_id' })
+  sessionId: string;
 
-    @ManyToOne('AssessmentSession', (session: AssessmentSession) => session.questions, { onDelete: 'CASCADE' })
-    @JoinColumn({ name: 'session_id' })
-    session: AssessmentSession;
+  @ManyToOne(
+    'AssessmentSession',
+    (session: AssessmentSession) => session.questions,
+    { onDelete: 'CASCADE' },
+  )
+  @JoinColumn({ name: 'session_id' })
+  session: AssessmentSession;
 
-    @Column({ type: 'text', name: 'question_text' })
-    questionText: string;
+  @Column({ type: 'text', name: 'question_text' })
+  questionText: string;
 
-    @Column({ type: 'simple-json', name: 'key_points', nullable: true })
-    keyPoints: string[];
+  @Column({ type: 'simple-json', name: 'key_points', nullable: true })
+  keyPoints: string[];
 
-    @Column({ type: 'varchar', nullable: true })
-    difficulty: string;
+  @Column({ type: 'varchar', nullable: true })
+  difficulty: string;
 
-    @OneToMany('AssessmentAnswer', (answer: AssessmentAnswer) => answer.question)
-    answers: AssessmentAnswer[];
+  @OneToMany('AssessmentAnswer', (answer: AssessmentAnswer) => answer.question)
+  answers: AssessmentAnswer[];
 
-    @CreateDateColumn({ name: 'created_at' })
-    createdAt: Date;
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt: Date;
 
-    @UpdateDateColumn({ name: 'updated_at' })
-    updatedAt: Date;
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt: Date;
 }

+ 69 - 66
server/src/assessment/entities/assessment-session.entity.ts

@@ -1,12 +1,12 @@
 import {
-    Entity,
-    PrimaryGeneratedColumn,
-    Column,
-    CreateDateColumn,
-    UpdateDateColumn,
-    ManyToOne,
-    JoinColumn,
-    OneToMany,
+  Entity,
+  PrimaryGeneratedColumn,
+  Column,
+  CreateDateColumn,
+  UpdateDateColumn,
+  ManyToOne,
+  JoinColumn,
+  OneToMany,
 } from 'typeorm';
 import { User } from '../../user/user.entity';
 import { KnowledgeBase } from '../../knowledge-base/knowledge-base.entity';
@@ -15,89 +15,92 @@ import type { AssessmentQuestion } from './assessment-question.entity';
 import { AssessmentTemplate } from './assessment-template.entity';
 
 export enum AssessmentStatus {
-    IN_PROGRESS = 'IN_PROGRESS',
-    COMPLETED = 'COMPLETED',
+  IN_PROGRESS = 'IN_PROGRESS',
+  COMPLETED = 'COMPLETED',
 }
 
 @Entity('assessment_sessions')
 export class AssessmentSession {
-    @PrimaryGeneratedColumn('uuid')
-    id: string;
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
 
-    @Column({ name: 'user_id' })
-    userId: string;
+  @Column({ name: 'user_id' })
+  userId: string;
 
-    @ManyToOne(() => User)
-    @JoinColumn({ name: 'user_id' })
-    user: User;
+  @ManyToOne(() => User)
+  @JoinColumn({ name: 'user_id' })
+  user: User;
 
-    @Column({ name: 'tenant_id', nullable: true })
-    tenantId: string;
+  @Column({ name: 'tenant_id', nullable: true })
+  tenantId: string;
 
-    @Column({ name: 'knowledge_base_id', nullable: true })
-    knowledgeBaseId: string | null;
+  @Column({ name: 'knowledge_base_id', nullable: true })
+  knowledgeBaseId: string | null;
 
-    @ManyToOne(() => KnowledgeBase, { nullable: true })
-    @JoinColumn({ name: 'knowledge_base_id' })
-    knowledgeBase: KnowledgeBase;
+  @ManyToOne(() => KnowledgeBase, { nullable: true })
+  @JoinColumn({ name: 'knowledge_base_id' })
+  knowledgeBase: KnowledgeBase;
 
-    @Column({ name: 'knowledge_group_id', nullable: true })
-    knowledgeGroupId: string | null;
+  @Column({ name: 'knowledge_group_id', nullable: true })
+  knowledgeGroupId: string | null;
 
-    @ManyToOne(() => KnowledgeGroup, { nullable: true })
-    @JoinColumn({ name: 'knowledge_group_id' })
-    knowledgeGroup: KnowledgeGroup;
+  @ManyToOne(() => KnowledgeGroup, { nullable: true })
+  @JoinColumn({ name: 'knowledge_group_id' })
+  knowledgeGroup: KnowledgeGroup;
 
-    @Column({ name: 'thread_id', nullable: true })
-    threadId: string;
+  @Column({ name: 'thread_id', nullable: true })
+  threadId: string;
 
-    @Column({
-        type: 'varchar',
-        enum: AssessmentStatus,
-        default: AssessmentStatus.IN_PROGRESS,
-    })
-    status: AssessmentStatus;
+  @Column({
+    type: 'varchar',
+    enum: AssessmentStatus,
+    default: AssessmentStatus.IN_PROGRESS,
+  })
+  status: AssessmentStatus;
 
-    @Column({ type: 'float', name: 'final_score', nullable: true })
-    finalScore: number;
+  @Column({ type: 'float', name: 'final_score', nullable: true })
+  finalScore: number;
 
-    @Column({ type: 'text', name: 'final_report', nullable: true })
-    finalReport: string;
+  @Column({ type: 'text', name: 'final_report', nullable: true })
+  finalReport: string;
 
-    @Column({ type: 'simple-json', nullable: true })
-    messages: any[];
+  @Column({ type: 'simple-json', nullable: true })
+  messages: any[];
 
-    @Column({ type: 'simple-json', name: 'feedback_history', nullable: true })
-    feedbackHistory: any[];
+  @Column({ type: 'simple-json', name: 'feedback_history', nullable: true })
+  feedbackHistory: any[];
 
-    @Column({ type: 'int', name: 'current_question_index', default: 0 })
-    currentQuestionIndex: number;
+  @Column({ type: 'int', name: 'current_question_index', default: 0 })
+  currentQuestionIndex: number;
 
-    @Column({ type: 'int', name: 'follow_up_count', default: 0 })
-    followUpCount: number;
+  @Column({ type: 'int', name: 'follow_up_count', default: 0 })
+  followUpCount: number;
 
-    @Column({ type: 'simple-json', nullable: true })
-    questions_json: any[];
+  @Column({ type: 'simple-json', nullable: true })
+  questions_json: any[];
 
-    @Column({ type: 'varchar', length: 10, default: 'zh' })
-    language: string;
+  @Column({ type: 'varchar', length: 10, default: 'zh' })
+  language: string;
 
-    @Column({ name: 'template_id', nullable: true })
-    templateId: string;
+  @Column({ name: 'template_id', nullable: true })
+  templateId: string;
 
-    @ManyToOne(() => AssessmentTemplate, { nullable: true })
-    @JoinColumn({ name: 'template_id' })
-    template: AssessmentTemplate;
+  @ManyToOne(() => AssessmentTemplate, { nullable: true })
+  @JoinColumn({ name: 'template_id' })
+  template: AssessmentTemplate;
 
-    @Column({ type: 'simple-json', name: 'template_json', nullable: true })
-    templateJson: any;
+  @Column({ type: 'simple-json', name: 'template_json', nullable: true })
+  templateJson: any;
 
-    @OneToMany('AssessmentQuestion', (question: AssessmentQuestion) => question.session)
-    questions: AssessmentQuestion[];
+  @OneToMany(
+    'AssessmentQuestion',
+    (question: AssessmentQuestion) => question.session,
+  )
+  questions: AssessmentQuestion[];
 
-    @CreateDateColumn({ name: 'created_at' })
-    createdAt: Date;
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt: Date;
 
-    @UpdateDateColumn({ name: 'updated_at' })
-    updatedAt: Date;
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt: Date;
 }

+ 54 - 50
server/src/assessment/entities/assessment-template.entity.ts

@@ -1,11 +1,11 @@
 import {
-    Entity,
-    PrimaryGeneratedColumn,
-    Column,
-    CreateDateColumn,
-    UpdateDateColumn,
-    ManyToOne,
-    JoinColumn,
+  Entity,
+  PrimaryGeneratedColumn,
+  Column,
+  CreateDateColumn,
+  UpdateDateColumn,
+  ManyToOne,
+  JoinColumn,
 } from 'typeorm';
 import { Tenant } from '../../tenant/tenant.entity';
 import { KnowledgeBase } from '../../knowledge-base/knowledge-base.entity';
@@ -13,64 +13,68 @@ import { KnowledgeGroup } from '../../knowledge-group/knowledge-group.entity';
 
 @Entity('assessment_templates')
 export class AssessmentTemplate {
-    @PrimaryGeneratedColumn('uuid')
-    id: string;
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
 
-    @Column({ name: 'tenant_id', nullable: true })
-    tenantId: string;
+  @Column({ name: 'tenant_id', nullable: true })
+  tenantId: string;
 
-    @ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
-    @JoinColumn({ name: 'tenant_id' })
-    tenant: Tenant;
+  @ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
+  @JoinColumn({ name: 'tenant_id' })
+  tenant: Tenant;
 
-    @Column()
-    name: string;
+  @Column()
+  name: string;
 
-    @Column({ type: 'text', nullable: true })
-    description: string;
+  @Column({ type: 'text', nullable: true })
+  description: string;
 
-    @Column({ type: 'simple-json', nullable: true })
-    keywords: string[];
+  @Column({ type: 'simple-json', nullable: true })
+  keywords: string[];
 
-    @Column({ type: 'int', name: 'question_count', default: 5 })
-    questionCount: number;
+  @Column({ type: 'int', name: 'question_count', default: 5 })
+  questionCount: number;
 
-    @Column({ type: 'simple-json', name: 'difficulty_distribution', nullable: true })
-    difficultyDistribution: {
-        standard: number;
-        advanced: number;
-        specialist: number;
-    };
+  @Column({
+    type: 'simple-json',
+    name: 'difficulty_distribution',
+    nullable: true,
+  })
+  difficultyDistribution: {
+    standard: number;
+    advanced: number;
+    specialist: number;
+  };
 
-    @Column({ type: 'varchar', default: 'technical' })
-    style: string;
+  @Column({ type: 'varchar', default: 'technical' })
+  style: string;
 
-    @Column({ name: 'knowledge_base_id', nullable: true })
-    knowledgeBaseId: string | null;
+  @Column({ name: 'knowledge_base_id', nullable: true })
+  knowledgeBaseId: string | null;
 
-    @ManyToOne(() => KnowledgeBase, { nullable: true })
-    @JoinColumn({ name: 'knowledge_base_id' })
-    knowledgeBase: KnowledgeBase;
+  @ManyToOne(() => KnowledgeBase, { nullable: true })
+  @JoinColumn({ name: 'knowledge_base_id' })
+  knowledgeBase: KnowledgeBase;
 
-    @Column({ name: 'knowledge_group_id', nullable: true })
-    knowledgeGroupId: string | null;
+  @Column({ name: 'knowledge_group_id', nullable: true })
+  knowledgeGroupId: string | null;
 
-    @ManyToOne(() => KnowledgeGroup, { nullable: true })
-    @JoinColumn({ name: 'knowledge_group_id' })
-    knowledgeGroup: KnowledgeGroup;
+  @ManyToOne(() => KnowledgeGroup, { nullable: true })
+  @JoinColumn({ name: 'knowledge_group_id' })
+  knowledgeGroup: KnowledgeGroup;
 
-    @Column({ type: 'boolean', name: 'is_active', default: true })
-    isActive: boolean;
+  @Column({ type: 'boolean', name: 'is_active', default: true })
+  isActive: boolean;
 
-    @Column({ type: 'int', default: 1 })
-    version: number;
+  @Column({ type: 'int', default: 1 })
+  version: number;
 
-    @Column({ name: 'created_by', nullable: true })
-    createdBy: string;
+  @Column({ name: 'created_by', nullable: true })
+  createdBy: string;
 
-    @CreateDateColumn({ name: 'created_at' })
-    createdAt: Date;
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt: Date;
 
-    @UpdateDateColumn({ name: 'updated_at' })
-    updatedAt: Date;
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt: Date;
 }

+ 56 - 44
server/src/assessment/graph/builder.ts

@@ -1,65 +1,77 @@
-import { StateGraph, MemorySaver } from "@langchain/langgraph";
-import { EvaluationAnnotation } from "./state";
-import { questionGeneratorNode } from "./nodes/generator.node";
-import { interviewerNode } from "./nodes/interviewer.node";
-import { graderNode } from "./nodes/grader.node";
-import { reportAnalyzerNode } from "./nodes/analyzer.node";
+import { StateGraph, MemorySaver } from '@langchain/langgraph';
+import { EvaluationAnnotation } from './state';
+import { questionGeneratorNode } from './nodes/generator.node';
+import { interviewerNode } from './nodes/interviewer.node';
+import { graderNode } from './nodes/grader.node';
+import { reportAnalyzerNode } from './nodes/analyzer.node';
 
 /**
  * Conditional routing logic for the Grader node.
  */
 const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => {
-    if (state.shouldFollowUp) {
-        return "interviewer";
-    }
+  const targetCount = state.questionCount || 5;
+  const questionsLen = state.questions?.length || 0;
+
+  console.log('[Router] Evaluation Result:', {
+    currentIndex: state.currentQuestionIndex,
+    shouldFollowUp: state.shouldFollowUp,
+    numQuestions: questionsLen,
+    targetCount,
+  });
+
+  if (state.shouldFollowUp) {
+    console.log('[Router] Routing to follow-up interviewer');
+    return 'interviewer';
+  }
 
-    const targetCount = state.questionCount || 3;
-    
-    if (state.currentQuestionIndex < targetCount) {
-        // If the next question isn't generated yet, go back to generator
-        if (state.currentQuestionIndex >= state.questions.length) {
-            return "generator";
-        }
-        // If it is generated (e.g. from a batch start or previous retry), go to interviewer
-        return "interviewer";
+  if (state.currentQuestionIndex < targetCount) {
+    // If the next question isn't generated yet, go back to generator
+    if (state.currentQuestionIndex >= questionsLen) {
+      console.log('[Router] Index >= Questions, routing to generator');
+      return 'generator';
     }
+    // If it is generated, go to interviewer
+    console.log('[Router] Index < Questions, routing to interviewer');
+    return 'interviewer';
+  }
 
-    return "analyzer";
+  console.log('[Router] Assessment complete, routing to analyzer');
+  return 'analyzer';
 };
 
 /**
  * Builds and compiles the Evaluation Graph.
  */
 export const createEvaluationGraph = () => {
-    const workflow = new StateGraph(EvaluationAnnotation)
-        .addNode("generator", questionGeneratorNode)
-        .addNode("interviewer", interviewerNode)
-        .addNode("grader", graderNode)
-        .addNode("analyzer", reportAnalyzerNode)
+  const workflow = new StateGraph(EvaluationAnnotation)
+    .addNode('generator', questionGeneratorNode)
+    .addNode('interviewer', interviewerNode)
+    .addNode('grader', graderNode)
+    .addNode('analyzer', reportAnalyzerNode)
 
-        // Flow definition
-        .addEdge("__start__", "generator")
-        .addEdge("generator", "interviewer")
+    // Flow definition
+    .addEdge('__start__', 'generator')
+    .addEdge('generator', 'interviewer')
 
-        // After interviewer, the graph will naturally pause for user input 
-        // if we use it in a thread-safe way with interrupts or simple invocation.
-        .addEdge("interviewer", "grader")
+    // After interviewer, the graph will naturally pause for user input
+    // if we use it in a thread-safe way with interrupts or simple invocation.
+    .addEdge('interviewer', 'grader')
 
-        // After grading, decide where to go
-        .addConditionalEdges("grader", routeAfterGrading, {
-            interviewer: "interviewer",
-            generator: "generator",
-            analyzer: "analyzer",
-        })
+    // After grading, decide where to go
+    .addConditionalEdges('grader', routeAfterGrading, {
+      interviewer: 'interviewer',
+      generator: 'generator',
+      analyzer: 'analyzer',
+    })
 
-        .addEdge("analyzer", "__end__");
+    .addEdge('analyzer', '__end__');
 
-    // Using MemorySaver for thread-based persistence
-    const checkpointer = new MemorySaver();
+  // Using MemorySaver for thread-based persistence
+  const checkpointer = new MemorySaver();
 
-    return workflow.compile({
-        checkpointer,
-        // We want the graph to stop after the interviewer presents the question
-        interruptAfter: ["interviewer"],
-    });
+  return workflow.compile({
+    checkpointer,
+    // We want the graph to stop after the interviewer presents the question
+    interruptAfter: ['interviewer'],
+  });
 };

+ 69 - 46
server/src/assessment/graph/nodes/analyzer.node.ts

@@ -1,38 +1,41 @@
-import { ChatOpenAI } from "@langchain/openai";
-import { SystemMessage, HumanMessage } from "@langchain/core/messages";
-import { RunnableConfig } from "@langchain/core/runnables";
-import { EvaluationState } from "../state";
+import { ChatOpenAI } from '@langchain/openai';
+import { SystemMessage, HumanMessage } from '@langchain/core/messages';
+import { RunnableConfig } from '@langchain/core/runnables';
+import { EvaluationState } from '../state';
 
 /**
  * Node responsible for generating the final mastery report at the end of the session.
  */
 export const reportAnalyzerNode = async (
-    state: EvaluationState,
-    config?: RunnableConfig
+  state: EvaluationState,
+  config?: RunnableConfig,
 ): Promise<Partial<EvaluationState>> => {
-    const { model } = (config?.configurable as any) || {};
-    const { scores, messages, questions } = state;
-
-    console.log("[AnalyzerNode] Entering node...", {
-        numScores: Object.keys(scores || {}).length,
-        numMessages: messages?.length
-    });
-
-    if (!model) {
-        throw new Error("Missing model in node configuration");
-    }
-
-    const scoreSummary = Object.entries(scores)
-        .map(([qId, score]) => {
-            const displayId = isNaN(parseInt(qId)) ? qId : (parseInt(qId) + 1).toString();
-            return `Question ${displayId}: Score ${score}/10`;
-        })
-        .join("\n");
-
-    const isZh = state.language === 'zh';
-    const isJa = state.language === 'ja';
-
-    const systemPromptZh = `你是一位客观且严谨的高级教育顾问。
+  const { model } = (config?.configurable as any) || {};
+  const { scores, messages, questions } = state;
+
+  console.log('[AnalyzerNode] Entering node...', {
+    numScores: Object.keys(scores || {}).length,
+    numMessages: messages?.length,
+    scores,
+  });
+
+  if (!model) {
+    throw new Error('Missing model in node configuration');
+  }
+
+  const scoreSummary = Object.entries(scores)
+    .map(([qId, score]) => {
+      const displayId = isNaN(parseInt(qId))
+        ? qId
+        : (parseInt(qId) + 1).toString();
+      return `Question ${displayId}: Score ${score}/10`;
+    })
+    .join('\n');
+
+  const isZh = state.language === 'zh';
+  const isJa = state.language === 'ja';
+
+  const systemPromptZh = `你是一位客观且严谨的高级教育顾问。
 请审查以下评估结果,并为员工提供一份严谨的掌握程度报告。
 
 重要提示:
@@ -46,7 +49,10 @@ export const reportAnalyzerNode = async (
 ${scoreSummary}
 
 对话记录:
-${messages.filter((m: any) => m._getType() !== "system").map((m: any) => `${m.role || m._getType()}: ${m.content}`).join("\n")}
+${messages
+  .filter((m: any) => m._getType() !== 'system')
+  .map((m: any) => `${m.role || m._getType()}: ${m.content}`)
+  .join('\n')}
 
 报告结构:
 1. 总体级别(已在顶部指定)
@@ -54,7 +60,7 @@ ${messages.filter((m: any) => m._getType() !== "system").map((m: any) => `${m.ro
 3. 确定的实际差距。
 4. 推荐的学习路径。`;
 
-    const systemPromptJa = `あなたは客観的で厳格なシニア教育コンサルタントです。
+  const systemPromptJa = `あなたは客観的で厳格なシニア教育コンサルタントです。
 以下の評価結果をレビューし、従業員に対して厳格な習熟度レポートを提供してください。
 
 重要事項:
@@ -68,7 +74,10 @@ ${messages.filter((m: any) => m._getType() !== "system").map((m: any) => `${m.ro
 ${scoreSummary}
 
 会話ログ:
-${messages.filter((m: any) => m._getType() !== "system").map((m: any) => `${m.role || m._getType()}: ${m.content}`).join("\n")}
+${messages
+  .filter((m: any) => m._getType() !== 'system')
+  .map((m: any) => `${m.role || m._getType()}: ${m.content}`)
+  .join('\n')}
 
 レポート構成:
 1. 総合レベル(一番上に指定済み)
@@ -76,7 +85,7 @@ ${messages.filter((m: any) => m._getType() !== "system").map((m: any) => `${m.ro
 3. 特技された実際のギャップ。
 4. 推奨される学習パス。`;
 
-    const systemPromptEn = `You are an objective and critical seniority education consultant.
+  const systemPromptEn = `You are an objective and critical seniority education consultant.
 Review the following assessment results and provide a rigorous mastery report for the employee.
 
 IMPORTANT: 
@@ -90,7 +99,10 @@ QUESTIONS AND SCORES:
 ${scoreSummary}
 
 CONVERSATION LOGS:
-${messages.filter((m: any) => m._getType() !== "system").map((m: any) => `${m.role || m._getType()}: ${m.content}`).join("\n")}
+${messages
+  .filter((m: any) => m._getType() !== 'system')
+  .map((m: any) => `${m.role || m._getType()}: ${m.content}`)
+  .join('\n')}
 
 REPORT STRUCTURE:
 1. Overall Level (Already specified at top)
@@ -98,16 +110,27 @@ REPORT STRUCTURE:
 3. Actual Gaps identified.
 4. Recommended learning path.`;
 
-    const systemPrompt = isZh ? systemPromptZh : (isJa ? systemPromptJa : systemPromptEn);
-    const humanMsg = isZh ? "生成最终掌握程度报告。" : (isJa ? "最終的な習熟度レポートを生成してください。" : "Generate the final mastery report.");
-
-    const response = await model.invoke([
-        new SystemMessage(systemPrompt),
-        new HumanMessage(humanMsg),
-    ]);
-
-    console.log("[AnalyzerNode] Report generated successfully. Length:", response.content?.toString().length);
-    return {
-        report: response.content as string,
-    };
+  const systemPrompt = isZh
+    ? systemPromptZh
+    : isJa
+      ? systemPromptJa
+      : systemPromptEn;
+  const humanMsg = isZh
+    ? '生成最终掌握程度报告。'
+    : isJa
+      ? '最終的な習熟度レポートを生成してください。'
+      : 'Generate the final mastery report.';
+
+  const response = await model.invoke([
+    new SystemMessage(systemPrompt),
+    new HumanMessage(humanMsg),
+  ]);
+
+  console.log(
+    '[AnalyzerNode] Report generated successfully. Length:',
+    response.content?.toString().length,
+  );
+  return {
+    report: response.content as string,
+  };
 };

+ 131 - 172
server/src/assessment/graph/nodes/generator.node.ts

@@ -1,200 +1,159 @@
-import { ChatOpenAI } from "@langchain/openai";
-import { SystemMessage, HumanMessage } from "@langchain/core/messages";
-import { RunnableConfig } from "@langchain/core/runnables";
-import { EvaluationState } from "../state";
+import { ChatOpenAI } from '@langchain/openai';
+import { SystemMessage, HumanMessage } from '@langchain/core/messages';
+import { RunnableConfig } from '@langchain/core/runnables';
+import { EvaluationState } from '../state';
 
 /**
  * Node responsible for generating assessment questions based on the knowledge base content.
  */
 export const questionGeneratorNode = async (
-    state: EvaluationState,
-    config?: RunnableConfig
+  state: EvaluationState,
+  config?: RunnableConfig,
 ): Promise<Partial<EvaluationState>> => {
-    const { model, knowledgeBaseContent } = (config?.configurable as any) || {};
-
-    console.log("[GeneratorNode] Starting generation...", {
-        language: state.language,
-        hasModel: !!model,
-        contentLength: knowledgeBaseContent?.length,
-        keywords: state.keywords || []
-    });
-
-    if (!model || !knowledgeBaseContent) {
-        console.error("[GeneratorNode] Missing model or knowledgeBaseContent");
-        throw new Error("Missing model or knowledgeBaseContent in node configuration");
-    }
-
-    console.log(`[GeneratorNode] Content provided to AI for generation (Length: ${knowledgeBaseContent?.length || 0})`);
-    // Optional: Log a snippet if needed, but not the whole thing
-    if (knowledgeBaseContent && knowledgeBaseContent.length > 500) {
-        console.log("[GeneratorNode] Content Snippet:", knowledgeBaseContent.substring(0, 500) + "...");
-    } else {
-        console.log("[GeneratorNode] Content:", knowledgeBaseContent);
-    }
+  const { model, knowledgeBaseContent } = (config?.configurable as any) || {};
+
+  console.log('[GeneratorNode] Starting generation...', {
+    language: state.language,
+    hasModel: !!model,
+    contentLength: knowledgeBaseContent?.length,
+    keywords: state.keywords || [],
+  });
+
+  if (!model || !knowledgeBaseContent) {
+    console.error('[GeneratorNode] Missing model or knowledgeBaseContent');
+    throw new Error(
+      'Missing model or knowledgeBaseContent in node configuration',
+    );
+  }
 
-    const isZh = state.language === 'zh';
-    const isJa = state.language === 'ja';
-
-    const questionCount = state.questionCount || 3;
-    const style = state.style || 'technical';
-    const difficultyText = state.difficultyDistribution ? JSON.stringify(state.difficultyDistribution) : '随机分布 (Random distribution)';
-    const keywords = state.keywords || [];
-    const keywordText = keywords.length > 0 ? keywords.join(', ') : '无 (None)';
-    
-    // For incremental generation, we check how many we already have
-    const existingQuestions = state.questions || [];
-    const existingQuestionsText = existingQuestions.map((q, i) => `Q${i+1}: ${q.questionText}`).join('\n');
-
-    const systemPromptZh = `你是一位专业的知识评估专家,擅长根据特定的知识库片段(Grounded Context)生成精准的测试题目。
-
-### 强制性出题规则(必须严格遵守,违者扣分):
-1. **关键词重心(最高优先级)**:如果提供了关键词 (${keywordText}),生成的问题**必须且只能**围绕这些关键词展开。关键词是出题的核心,禁止脱离关键词去考查片段中的其他细节。
-2. **绝对依据**:生成的问题和关键点必须且只能依据提供的文本。禁止使用你的通用知识或外部常识。
-3. **禁止重复**:
-   - **绝对禁止**生成与下方“禁止考查的问题列表”中相似的题目。
-   - 必须改变切入角度。如果之前的题目考查了定义,新题目应考查应用、对比或具体细节。
-4. **否定约束**:
-   - 禁止生成“什么是...”、“请简述...”这类过于宽泛的通识题。
-   - 禁止引用文本中未出现的任何文件名或外部链接。
-5. **质量控制**:
-   - 必须在 \`basis\` 中明确引用片段序号(例如:"[1] 原文:...")。
-   - 必须使用 **简体中文** 出题,即使原文是英文或日文,也必须翻译成准确的术语。
-
-### 任务数据:
-目标关键词:${keywordText}
-出题风格:${style}
-难度要求:${difficultyText}
-
-### 禁止考查的问题列表(之前已生成,严禁重复):
+  const isZh = state.language === 'zh';
+  const isJa = state.language === 'ja';
+
+  const style = state.style || 'technical';
+  const difficultyText = state.difficultyDistribution
+    ? JSON.stringify(state.difficultyDistribution)
+    : '随机分布 (Random distribution)';
+  const keywords = state.keywords || [];
+  const hasKeywords = keywords.length > 0;
+  const keywordText = hasKeywords ? keywords.join(', ') : '';
+
+  const rulesZh = [
+    `**禁止重复**:绝对禁止生成与下方“禁止重复列表”中相似的题目。`,
+    `**深度挖掘**:如果之前的题目考查了核心定义,新题目必须考查具体的应用案例、对比分析或隐藏的细节。`,
+    hasKeywords
+      ? `**关键词权重**:必须围绕关键词 (${keywordText}) 展开,但要从关键词的不同侧面(如流程、限制、优缺点、具体参数等)进行挖掘。`
+      : null,
+    `**随机扰动**:即使对于相同的主题或关键词,也要尝试从不同的逻辑链条(如“因为...所以...” vs “如果没有...会怎样”)出发。`,
+  ]
+    .filter(Boolean)
+    .map((r, i) => `${i + 1}. ${r}`)
+    .join('\n');
+
+  const rulesJa = [
+    `**重複禁止**:下記の「作成済み問題リスト」と類似した内容は絶対に避けてください。`,
+    `**多角的アプローチ**:前回が定義だった場合は、今回は応用方法、制限事項、具体的な数値などに焦点を当ててください。`,
+    hasKeywords
+      ? `**キーワードの深掘り**:キーワード (${keywordText}) の異なる側面から出題してください。`
+      : null,
+  ]
+    .filter(Boolean)
+    .map((r, i) => `${i + 1}. ${r}`)
+    .join('\n');
+
+  const rulesEn = [
+    `**NO REPETITION**: Strictly avoid any conceptual overlap with the "Previous Questions" list below.`,
+    `**New Facets**: If previous questions were about definitions, focus on applications, edge cases, or specific details.`,
+    hasKeywords
+      ? `**Keyword Variety**: Center on (${keywordText}), but explore different aspects (process, pros/cons, requirements).`
+      : null,
+  ]
+    .filter(Boolean)
+    .map((r, i) => `${i + 1}. ${r}`)
+    .join('\n');
+
+  const existingQuestions = state.questions || [];
+  const existingQuestionsText = existingQuestions
+    .map((q, i) => `Q${i + 1}: ${q.questionText}`)
+    .join('\n');
+
+  const systemPromptZh = `你是一位专业的知识评估专家。请根据提供的知识库片段生成 1 个唯一的测试题目。
+
+### 强制性多样性规则:
+${rulesZh}
+
+### 禁止重复列表(已出过):
 ${existingQuestionsText || '无'}
 
-### 输出指令:
-请生成 1 个高质量问题,确保它符合上述所有规则。如果你发现 context 中确实没有 keyword 的相关信息,请在 basis 中说明,并选择 context 中最重要的一个细节进行出题。
+### 任务:
+${hasKeywords ? `目标关键词:${keywordText}\n` : ''}出题风格:${style}
+难度:${difficultyText}
 
-请以 JSON 数组格式返回响应:
+请以 JSON 数组格式返回 1 个问题
 [
   {
     "question_text": "...",
     "key_points": ["点1", "点2"],
     "difficulty": "...",
-    "basis": "[n] 引用的原文片段..."
+    "basis": "[n] 引用原文..."
   }
 ]`;
 
-    const systemPromptJa = `あなたは専門的な知識アセスメントのエキスパートです。提供されたナレッジベースの断片(Grounded Context)のみに基づいて、正確なテスト問題を作成してください。
-
-### 必須ルール(厳守事項):
-1. **キーワードへの集中(最優先)**:キーワード (${keywordText}) が指定されている場合、生成される問題は**必ず**そのキーワードを軸にする必要があります。キーワードを無視して他の詳細を問うことは禁止されています。
-2. **絶対的な根拠**:生成される問題とキーポイントは、提供されたテキストのみに基づく必要があります。一般的な知識や外部の情報を使用しないでください。
-3. **重複の禁止**:
-   - 下記の「作成済み問題リスト」にある問題と類似した問題を作成することは**絶対に禁止**です。
-   - 出題の角度を変えてください。前回が定義だった場合は、今回は応用、比較、または別の詳細事項に焦点を当ててください。
-4. **否定的な制約**:
-   - 「...とは何ですか?」のような一般的すぎる問題は避けてください。
-   - テキスト内に未登場のファイル名や外部リンクを引用しないでください。
-5. **品質管理**:
-   - \`basis\` フィールドには、引用番号(例:"[1] 原文:...")を明記してください。
-   - **日本語**で出題してください。
-
-### タスクデータ:
-ターゲットキーワード:${keywordText}
-出題スタイル:${style}
-難易度:${difficultyText}
-
-### 作成済み問題リスト(重複厳禁):
-${existingQuestionsText || 'なし'}
+  const systemPromptJa = `あなたは専門的なアセスメントエキスパートです。提供されたナレッジベースに基づいて、ユニークな問題を 1 つ作成してください。
 
-### 出力指示
-1つの高品質な問題を生成し、上記のすべてのルールに適合していることを確認してください。context 内に keyword に関する情報がない場合は、basis にその旨を記載した上で、context 内の他の重要な詳細に基づいて作成してください。
+### 多様性ルール:
+${rulesJa}
 
-以下の JSON 配列形式で返してください:
-[
-  {
-    "question_text": "...",
-    "key_points": ["...", "..."],
-    "difficulty": "...",
-    "basis": "[n] 引用した原文..."
-  }
-]`;
+### 作成済み問題リスト:
+${existingQuestionsText || 'なし'}
 
-    const systemPromptEn = `You are a professional knowledge assessment expert specializing in generating precise questions based only on provided knowledge snippets (Grounded Context).
-
-### Mandatory Rules (Strict Adherence Required):
-1. **Keyword Focus (Highest Priority)**: If keywords (${keywordText}) are provided, the question **MUST** center exclusively around them. DO NOT deviate to other details in the snippets unless the keywords are missing.
-2. **Absolute Grounding**: Questions and key points MUST be derived exclusively from the provided text. DO NOT use general knowledge or external information.
-3. **No Repetition**:
-   - **Strictly PROHIBITED** from generating questions similar to those in the "Previous Questions" list below.
-   - Change the perspective. If a previous question asked for a definition, ask for an application, comparison, or specific detail.
-4. **Negative Constraints**:
-   - DO NOT generate generic definitions like "What is..." or "Describe the basics of...".
-   - DO NOT reference any external filenames or links not present in the snippets.
-5. **Quality Control**:
-   - You MUST specify the snippet index in the \`basis\` (e.g., "[1] Original text: ...").
-   - Use **English** for output.
-
-### Task Data:
-Target Keywords: ${keywordText}
-Style: ${style}
-Difficulty: ${difficultyText}
-
-### Previous Questions (STRICTLY PROHIBITED TO REPEAT):
-${existingQuestionsText || 'None'}
+JSON 形式で 1 つ返してください。`;
 
-### Output Instruction:
-Generate ONLY 1 high-quality question that adheres to all rules above. If the context lacks info on keywords, state this in \`basis\` and pick another important detail from the context.
+  const systemPromptEn = `You are an expert examiner. Generate 1 UNIQUE question based on the provided context.
 
-Return as a JSON array:
-[
-  {
-    "question_text": "...",
-    "key_points": ["...", "..."],
-    "difficulty": "...",
-    "basis": "[n] Quoted original text..."
-  }
-]`;
+### Diversity Rules:
+${rulesEn}
 
-    const systemPrompt = isZh ? systemPromptZh : (isJa ? systemPromptJa : systemPromptEn);
-    const humanMsg = isZh 
-        ? `强制要求考查的关键词:${keywordText}\n\n请基于以下知识库内容生成 1 个与其密切相关的评估问题:\n\n${knowledgeBaseContent}` 
-        : (isJa 
-            ? `強制対象キーワード:${keywordText}\n\n以下のナレッジベースの内容に基づいて、これに密接に関連する1つのアセスメント問題を生成してください:\n\n${knowledgeBaseContent}` 
-            : `Mandatory Keywords: ${keywordText}\n\nGenerate 1 assessment question strictly related to these keywords based on the following Knowledge Base content:\n\n${knowledgeBaseContent}`);
+### Previous Questions (DO NOT REPEAT):
+${existingQuestionsText || 'None'}
+
+Return 1 question as a JSON array.`;
+
+  const systemPrompt = isZh
+    ? systemPromptZh
+    : isJa
+      ? systemPromptJa
+      : systemPromptEn;
+  const humanMsg = isZh
+    ? `基于以下内容生成题目:\n\n${knowledgeBaseContent}`
+    : isJa
+      ? `以下の内容に基づいて作成してください:\n\n${knowledgeBaseContent}`
+      : `Generate question based on:\n\n${knowledgeBaseContent}`;
+
+  try {
+    const response = await model.invoke([
+      new SystemMessage(systemPrompt),
+      new HumanMessage(humanMsg),
+    ]);
 
-    console.log("[GeneratorNode] Invoking model...");
     try {
-        const response = await model.invoke([
-            new SystemMessage(systemPrompt),
-            new HumanMessage(humanMsg),
-        ]);
-        console.log("[GeneratorNode] AI response received, length:", (response.content as string).length);
-
-        try {
-            const newQuestions = JSON.parse(response.content as string);
-            console.log(`[GeneratorNode] Successfully generated ${newQuestions.length} questions (Expected 1)`);
-            
-            const mappedNewQuestions = newQuestions.map((q: any) => ({
-                id: (existingQuestions.length + 1).toString(), // Assign stable ID for incremental flow
-                questionText: q.question_text,
-                keyPoints: q.key_points,
-                difficulty: q.difficulty,
-                basis: q.basis,
-            }));
-
-            return {
-                questions: [...existingQuestions, ...mappedNewQuestions],
-                // We keep the current index if we just generated questions for the current session
-                // The grader or router will move the index forward.
-            };
-        } catch (error) {
-            console.error("[GeneratorNode] Failed to parse questions from AI response. Content was:", response.content);
-            console.error("[GeneratorNode] Parse error:", error);
-            return { 
-                questions: [],
-                currentQuestionIndex: 0
-            };
-        }
-    } catch (invokeError) {
-        console.error("[GeneratorNode] Model invocation failed:", invokeError);
-        throw invokeError;
+      const newQuestions = JSON.parse(response.content as string);
+      const mappedNewQuestions = newQuestions.map((q: any) => ({
+        id: (existingQuestions.length + 1).toString(),
+        questionText: q.question_text,
+        keyPoints: q.key_points,
+        difficulty: q.difficulty,
+        basis: q.basis,
+      }));
+
+      return {
+        questions: [...existingQuestions, ...mappedNewQuestions],
+      };
+    } catch (error) {
+      console.error('[GeneratorNode] Parse error:', error);
+      return { questions: existingQuestions };
     }
+  } catch (invokeError) {
+    console.error('[GeneratorNode] Invoke error:', invokeError);
+    throw invokeError;
+  }
 };

+ 146 - 85
server/src/assessment/graph/nodes/grader.node.ts

@@ -1,38 +1,72 @@
-import { ChatOpenAI } from "@langchain/openai";
-import { SystemMessage, HumanMessage, AIMessage } from "@langchain/core/messages";
-import { RunnableConfig } from "@langchain/core/runnables";
-import { EvaluationState } from "../state";
+import { ChatOpenAI } from '@langchain/openai';
+import {
+  SystemMessage,
+  HumanMessage,
+  AIMessage,
+} from '@langchain/core/messages';
+import { RunnableConfig } from '@langchain/core/runnables';
+import { EvaluationState } from '../state';
 
 /**
  * Node responsible for grading the user's answer and deciding if a follow-up is needed.
  */
 export const graderNode = async (
-    state: EvaluationState,
-    config?: RunnableConfig
+  state: EvaluationState,
+  config?: RunnableConfig,
 ): Promise<Partial<EvaluationState>> => {
-    const { model } = (config?.configurable as any) || {};
-    const { questions, currentQuestionIndex, messages } = state;
-
-    console.log("[GraderNode] Entering node...", {
-        currentIndex: currentQuestionIndex,
-        numMessages: messages?.length
-    });
-
-    if (!model) {
-        throw new Error("Missing model in node configuration");
-    }
-
-    const currentQuestion = questions[currentQuestionIndex];
-    const lastUserMessage = messages[messages.length - 1];
-
-    if (!(lastUserMessage instanceof HumanMessage)) {
-        return {};
-    }
-
-    const isZh = state.language === 'zh';
-    const isJa = state.language === 'ja';
-
-    const systemPromptZh = `你是一位专业的考官。
+  const { model } = (config?.configurable as any) || {};
+  const { questions, currentQuestionIndex, messages } = state;
+  const currentFollowUpCount = state.followUpCount || 0;
+
+  console.log('[GraderNode] Entering node...', {
+    currentIndex: currentQuestionIndex,
+    numMessages: messages?.length,
+    questionCount: state.questionCount,
+    hasQuestions: !!questions?.length,
+  });
+
+  if (!model) {
+    throw new Error('Missing model in node configuration');
+  }
+
+  const lastUserMessage = messages[messages.length - 1];
+
+  console.log('[GraderNode] Incoming Messages Count:', messages.length);
+  if (lastUserMessage) {
+    console.log(
+      '[GraderNode] Last Message Type:',
+      lastUserMessage.constructor.name,
+    );
+    // Safely extract content for logging
+    const logContent =
+      typeof lastUserMessage.content === 'string'
+        ? lastUserMessage.content
+        : JSON.stringify(lastUserMessage.content);
+    console.log(
+      '[GraderNode] Last Message Content:',
+      logContent.substring(0, 50),
+    );
+  }
+
+  if (!(lastUserMessage instanceof HumanMessage)) {
+    console.log(
+      '[GraderNode] Last message is not HumanMessage, skipping grading.',
+    );
+    return {};
+  }
+
+  const isZh = state.language === 'zh';
+  const isJa = state.language === 'ja';
+
+  const currentQuestion = questions[currentQuestionIndex];
+  if (!currentQuestion) {
+    console.error(
+      `[GraderNode] Question at index ${currentQuestionIndex} not found!`,
+    );
+    return { currentQuestionIndex: currentQuestionIndex + 1 };
+  }
+
+  const systemPromptZh = `你是一位专业的考官。
 请根据以下问题和关键点对用户的回答进行评分。
 
 重要提示:
@@ -40,7 +74,7 @@ export const graderNode = async (
 2. 如果用户的回答或知识库内容涉及其他语言,请确保你的反馈和解释依然使用中文。
 
 问题:${currentQuestion.questionText}
-预期的关键点:${currentQuestion.keyPoints.join(", ")}
+预期的关键点:${currentQuestion.keyPoints.join(', ')}
 
 评估标准:
 1. 准确性:他们是否正确覆盖了关键点?
@@ -59,7 +93,7 @@ export const graderNode = async (
   "should_follow_up": false
 }`;
 
-    const systemPromptJa = `あなたは専門的な試験官です。
+  const systemPromptJa = `あなたは専門的な試験官です。
 以下の質問とキーポイントに基づいて、ユーザーの回答を採点してください。
 
 重要事項:
@@ -67,7 +101,7 @@ export const graderNode = async (
 2. ユーザーの回答やナレッジベースの内容に他の言語が含まれている場合でも、フィードバックと説明は必ず日本語で行ってください。
 
 質問:${currentQuestion.questionText}
-期待されるキーポイント:${currentQuestion.keyPoints.join(", ")}
+期待されるキーポイント:${currentQuestion.keyPoints.join(', ')}
 
 評価基準:
 1. 正確性:キーポイントを正確に網羅していますか?
@@ -86,7 +120,7 @@ JSON 形式で回答してください:
   "should_follow_up": false
 }`;
 
-    const systemPromptEn = `You are an expert examiner. 
+  const systemPromptEn = `You are an expert examiner. 
 Grade the user's answer based on the following question and key points.
 
 IMPORTANT: 
@@ -94,7 +128,7 @@ IMPORTANT:
 2. If the user's answer or knowledge base content references other languages, ensure your feedback and explanation remain in English.
 
 QUESTION: ${currentQuestion.questionText}
-EXPECTED KEY POINTS: ${currentQuestion.keyPoints.join(", ")}
+EXPECTED KEY POINTS: ${currentQuestion.keyPoints.join(', ')}
 
 Evaluate:
 1. Accuracy: Did they cover the key points correctly?
@@ -113,56 +147,83 @@ Format your response as JSON:
   "should_follow_up": false
 }`;
 
-    const systemPrompt = isZh ? systemPromptZh : (isJa ? systemPromptJa : systemPromptEn);
-
-    const response = await model.invoke([
-        new SystemMessage(systemPrompt),
-        new HumanMessage((lastUserMessage as HumanMessage).content as string),
-    ]);
-
-    try {
-        const result = JSON.parse(response.content as string);
-        console.log("[GraderNode] AI Grade Result:", result);
-        const isZh = state.language === 'zh';
-        const isJa = state.language === 'ja';
-        const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
-        const feedbackLabel = isZh ? '反馈' : isJa ? 'フィードバック' : 'Feedback';
-
-        const feedbackMessage = new AIMessage(`${scoreLabel}: ${result.score}/10\n\n${feedbackLabel}: ${result.feedback}`);
-
-        const newScores = { ...state.scores, [currentQuestion.id || currentQuestionIndex]: result.score };
-        
-        let shouldFollowUp = result.should_follow_up;
-        const currentFollowUpCount = state.followUpCount || 0;
-
-        // Breakout logic: 
-        // 1. Max 1 follow-up per question
-        // 2. If score is decent (>= 8), don't follow up
-        // 3. If answer is short "don't know", don't follow up
-        const userContent = (lastUserMessage.content as string).trim().toLowerCase();
-        const saysIDontKnow = userContent.length < 10 && (
-            userContent.includes("不知道") || 
-            userContent.includes("不会") || 
-            userContent.includes("don't know") || 
-            userContent.includes("no idea")
-        );
-
-        if (currentFollowUpCount >= 1 || result.score >= 8 || saysIDontKnow) {
-            shouldFollowUp = false;
-        }
-
-        return {
-            feedbackHistory: [feedbackMessage],
-            scores: newScores,
-            shouldFollowUp: shouldFollowUp,
-            followUpCount: shouldFollowUp ? currentFollowUpCount + 1 : 0,
-            currentQuestionIndex: shouldFollowUp ? currentQuestionIndex : currentQuestionIndex + 1,
-        };
-    } catch (error) {
-        console.error("Failed to parse grade from AI response:", error);
-        return {
-            feedbackHistory: [new AIMessage("I had some trouble grading that, but let's move on.")],
-            currentQuestionIndex: currentQuestionIndex + 1,
-        };
+  const systemPrompt = isZh
+    ? systemPromptZh
+    : isJa
+      ? systemPromptJa
+      : systemPromptEn;
+
+  const userContentText =
+    typeof lastUserMessage.content === 'string'
+      ? lastUserMessage.content
+      : JSON.stringify(lastUserMessage.content);
+
+  const response = await model.invoke([
+    new SystemMessage(systemPrompt),
+    new HumanMessage(userContentText),
+  ]);
+
+  try {
+    const result = JSON.parse(response.content as string);
+    console.log('[GraderNode] AI Grade Result:', result);
+
+    const scoreLabel = isZh ? '得分' : isJa ? 'スコア' : 'Score';
+    const feedbackLabel = isZh ? '反馈' : isJa ? 'フィードバック' : 'Feedback';
+
+    const feedbackMessage = new AIMessage(
+      `${scoreLabel}: ${result.score}/10\n\n${feedbackLabel}: ${result.feedback}`,
+    );
+
+    const newScores = {
+      ...state.scores,
+      [currentQuestion.id || currentQuestionIndex.toString()]: result.score,
+    };
+
+    let shouldFollowUp = result.should_follow_up === true;
+
+    // Breakout logic:
+    // 1. Max 1 follow-up per question
+    // 2. If score is decent (>= 8), don't follow up
+    // 3. If answer is short "don't know", don't follow up
+    const normalizedContent = userContentText.trim().toLowerCase();
+    const saysIDontKnow =
+      normalizedContent.length < 10 &&
+      (normalizedContent.includes('不知道') ||
+        normalizedContent.includes('不会') ||
+        normalizedContent.includes("don't know") ||
+        normalizedContent.includes('no idea') ||
+        normalizedContent.includes('不知'));
+
+    if (currentFollowUpCount >= 1 || result.score >= 8 || saysIDontKnow) {
+      shouldFollowUp = false;
     }
+
+    console.log('[GraderNode] Final State decision:', {
+      shouldFollowUp,
+      nextIndex: shouldFollowUp
+        ? currentQuestionIndex
+        : currentQuestionIndex + 1,
+      score: result.score,
+      saysIDontKnow,
+    });
+
+    return {
+      feedbackHistory: [feedbackMessage],
+      scores: newScores,
+      shouldFollowUp: shouldFollowUp,
+      followUpCount: shouldFollowUp ? currentFollowUpCount + 1 : 0,
+      currentQuestionIndex: shouldFollowUp
+        ? currentQuestionIndex
+        : currentQuestionIndex + 1,
+    } as any;
+  } catch (error) {
+    console.error('Failed to parse grade from AI response:', error);
+    return {
+      feedbackHistory: [
+        new AIMessage("I had some trouble grading that, but let's move on."),
+      ],
+      currentQuestionIndex: currentQuestionIndex + 1,
+      shouldFollowUp: false,
+    } as any;
+  }
 };

+ 82 - 61
server/src/assessment/graph/nodes/interviewer.node.ts

@@ -1,77 +1,98 @@
-import { AIMessage } from "@langchain/core/messages";
-import { RunnableConfig } from "@langchain/core/runnables";
-import { EvaluationState } from "../state";
+import { AIMessage } from '@langchain/core/messages';
+import { RunnableConfig } from '@langchain/core/runnables';
+import { EvaluationState } from '../state';
 
 /**
  * Node responsible for presenting the current question or follow-up to the user.
  */
 export const interviewerNode = async (
-    state: EvaluationState,
-    config?: RunnableConfig
+  state: EvaluationState,
+  config?: RunnableConfig,
 ): Promise<Partial<EvaluationState>> => {
-    const { questions, currentQuestionIndex, shouldFollowUp, messages } = state;
+  const { questions, currentQuestionIndex, shouldFollowUp, messages } = state;
 
-    console.log("[InterviewerNode] Entering node...", {
-        numQuestions: questions?.length,
-        currentIndex: currentQuestionIndex,
-        shouldFollowUp,
-        numMessages: messages?.length
-    });
+  console.log('[InterviewerNode] Entering node...', {
+    numQuestions: questions?.length,
+    currentIndex: currentQuestionIndex,
+    shouldFollowUp,
+    numMessages: messages?.length,
+  });
 
-    if (!questions || questions.length === 0) {
-        const isZh = state.language === 'zh';
-        const isJa = state.language === 'ja';
-        const msg = isZh ? "很抱歉,我无法为此会话生成任何问题。" : 
-                    isJa ? "申し訳ありませんが、このセッションの問題を生成できませんでした。" : 
-                    "I'm sorry, I couldn't generate any questions for this session.";
-        return {
-            messages: [new AIMessage(msg)],
-        };
-    }
+  if (!questions || questions.length === 0) {
+    const isZh = state.language === 'zh';
+    const isJa = state.language === 'ja';
+    const msg = isZh
+      ? '很抱歉,我无法为此会话生成任何问题。'
+      : isJa
+        ? '申し訳ありませんが、このセッションの問題を生成できませんでした。'
+        : "I'm sorry, I couldn't generate any questions for this session.";
+    return {
+      messages: [new AIMessage(msg)],
+    };
+  }
 
-    const currentQuestion = questions[currentQuestionIndex];
+  const currentQuestion = questions[currentQuestionIndex];
 
-    // If it's a follow-up, we add a prefix to the label later.
-    // If we've run out of questions and no follow-up requested, we shouldn't be here, but let's be safe.
-    if (currentQuestionIndex >= questions.length) {
-        return { shouldFollowUp: false };
-    }
+  // If it's a follow-up, we add a prefix to the label later.
+  // If we've run out of questions and no follow-up requested, we shouldn't be here, but let's be safe.
+  if (currentQuestionIndex >= questions.length) {
+    return { shouldFollowUp: false };
+  }
 
-    const isZh = state.language === 'zh';
-    const isJa = state.language === 'ja';
-    
-    let prompt = '';
-    
-    if (shouldFollowUp && state.feedbackHistory && state.feedbackHistory.length > 0) {
-        // Construct a follow-up prompt based on last feedback
-        const lastFeedbackMsg = state.feedbackHistory[state.feedbackHistory.length - 1];
-        const feedbackText = lastFeedbackMsg.content.toString();
-        
-        // Extract the "Feedback: ..." part if possible, otherwise use whole text
-        const feedbackMatch = feedbackText.match(/(?:Feedback|反馈|フィードバック): ([\s\S]*)/i);
-        const specificFeedback = feedbackMatch ? feedbackMatch[1].trim() : feedbackText;
+  const isZh = state.language === 'zh';
+  const isJa = state.language === 'ja';
 
-        const followUpLabel = isZh ? "补充追问" : isJa ? "追加の質問" : "Follow-up Clarification";
-        const followUpInstruction = isZh ? "根据以上反馈,请补充更具体的信息:" : 
-                                    isJa ? "上記のフィードバックに基づき、より具体的な情報を追加してください:" : 
-                                    "Based on the feedback above, please provide more specific details:";
+  let prompt = '';
 
-        prompt = `${followUpLabel}\n\n${specificFeedback}\n\n${followUpInstruction}`;
-    } else {
-        // Standard question presentation
-        const label = isZh ? `问题 ${currentQuestionIndex + 1}` : 
-                      isJa ? `質問 ${currentQuestionIndex + 1}` : 
-                      `Question ${currentQuestionIndex + 1}`;
-        
-        const instruction = isZh ? "请提供您的回答。" : 
-                            isJa ? "回答を入力してください。" : 
-                            "Please provide your answer.";
+  if (
+    shouldFollowUp &&
+    state.feedbackHistory &&
+    state.feedbackHistory.length > 0
+  ) {
+    // Construct a follow-up prompt based on last feedback
+    const lastFeedbackMsg =
+      state.feedbackHistory[state.feedbackHistory.length - 1];
+    const feedbackText = lastFeedbackMsg.content.toString();
 
-        prompt = `${label}: ${currentQuestion.questionText}\n\n${instruction}`;
-    }
+    // Extract the "Feedback: ..." part if possible, otherwise use whole text
+    const feedbackMatch = feedbackText.match(
+      /(?:Feedback|反馈|フィードバック): ([\s\S]*)/i,
+    );
+    const specificFeedback = feedbackMatch
+      ? feedbackMatch[1].trim()
+      : feedbackText;
 
-    return {
-        messages: [new AIMessage(prompt)],
-        shouldFollowUp: false,
-    };
+    const followUpLabel = isZh
+      ? '补充追问'
+      : isJa
+        ? '追加の質問'
+        : 'Follow-up Clarification';
+    const followUpInstruction = isZh
+      ? '根据以上反馈,请补充更具体的信息:'
+      : isJa
+        ? '上記のフィードバックに基づき、より具体的な情報を追加してください:'
+        : 'Based on the feedback above, please provide more specific details:';
+
+    prompt = `${followUpLabel}\n\n${specificFeedback}\n\n${followUpInstruction}`;
+  } else {
+    // Standard question presentation
+    const label = isZh
+      ? `问题 ${currentQuestionIndex + 1}`
+      : isJa
+        ? `質問 ${currentQuestionIndex + 1}`
+        : `Question ${currentQuestionIndex + 1}`;
+
+    const instruction = isZh
+      ? '请提供您的回答。'
+      : isJa
+        ? '回答を入力してください。'
+        : 'Please provide your answer.';
+
+    prompt = `${label}: ${currentQuestion.questionText}\n\n${instruction}`;
+  }
+
+  return {
+    messages: [new AIMessage(prompt)],
+    shouldFollowUp: false,
+  };
 };

+ 116 - 116
server/src/assessment/graph/state.ts

@@ -1,124 +1,124 @@
-import { Annotation, MessagesAnnotation } from "@langchain/langgraph";
-import { BaseMessage } from "@langchain/core/messages";
+import { Annotation, MessagesAnnotation } from '@langchain/langgraph';
+import { BaseMessage } from '@langchain/core/messages';
 
 /**
  * State representing the evaluation session using LangGraph Annotation.
  */
 export const EvaluationAnnotation = Annotation.Root({
-    /**
-     * The message history of the conversation.
-     * Inherits from MessagesAnnotation to handle message merging.
-     */
-    ...MessagesAnnotation.spec,
-    
-    /**
-     * Historical evaluation feedback from the grader.
-     * Separated from main messages to keep conversation context clean.
-     */
-    feedbackHistory: Annotation<BaseMessage[]>({
-        reducer: (prev, next) => [...(prev || []), ...(next || [])],
-        default: () => [],
-    }),
-
-    /**
-     * The database ID of the current assessment session.
-     */
-    assessmentSessionId: Annotation<string>(),
-
-    /**
-     * The knowledge base ID used as ground truth for this evaluation.
-     */
-    knowledgeBaseId: Annotation<string>(),
-
-    /**
-     * List of questions generated for this session.
-     */
-    questions: Annotation<any[]>({
-        reducer: (prev, next) => next ?? prev,
-        default: () => [],
-    }),
-
-    /**
-     * Index of the current question being discussed.
-     */
-    currentQuestionIndex: Annotation<number>({
-        reducer: (prev, next) => next ?? prev,
-        default: () => 0,
-    }),
-
-    /**
-     * Flag indicating if the Grader believes a follow-up question is needed for clarity.
-     */
-    shouldFollowUp: Annotation<boolean>({
-        reducer: (prev, next) => next ?? prev,
-        default: () => false,
-    }),
-
-    /**
-     * Map of scores for each question.
-     */
-    scores: Annotation<Record<string, number>>({
-        reducer: (prev, next) => ({ ...prev, ...next }),
-        default: () => ({}),
-    }),
-
-    /**
-     * Final report generated by the ReportAnalyzer.
-     */
-    report: Annotation<string | undefined>({
-        reducer: (prev, next) => next ?? prev,
-    }),
-
-    /**
-     * Context chunks retrieved from the knowledge base for grounding.
-     */
-    context: Annotation<string[] | undefined>({
-        reducer: (prev, next) => next ?? prev,
-    }),
-
-    /**
-     * Preferred language for the assessment (zh, en, ja).
-     */
-    language: Annotation<string>({
-        reducer: (prev, next) => next ?? prev,
-        default: () => 'zh',
-    }),
-
-    /**
-     * Number of times we have followed up on the current question.
-     */
-    followUpCount: Annotation<number>({
-        reducer: (prev, next) => next ?? prev,
-        default: () => 0,
-    }),
-
-    /**
-     * Number of questions to generate.
-     */
-    questionCount: Annotation<number | undefined>({
-        reducer: (prev, next) => next ?? prev,
-    }),
-
-    /**
-     * Desired difficulty distribution.
-     */
-    difficultyDistribution: Annotation<any | undefined>({
-        reducer: (prev, next) => next ?? prev,
-    }),
-
-    /**
-     * Desired question style.
-     */
-    style: Annotation<string | undefined>({
-        reducer: (prev, next) => next ?? prev,
-    }),
-
-    /**
-     * Target keywords for question generation.
-     */
-    keywords: Annotation<string[] | undefined>({
-        reducer: (prev, next) => next ?? prev,
-    }),
+  /**
+   * The message history of the conversation.
+   * Inherits from MessagesAnnotation to handle message merging.
+   */
+  ...MessagesAnnotation.spec,
+
+  /**
+   * Historical evaluation feedback from the grader.
+   * Separated from main messages to keep conversation context clean.
+   */
+  feedbackHistory: Annotation<BaseMessage[]>({
+    reducer: (prev, next) => [...(prev || []), ...(next || [])],
+    default: () => [],
+  }),
+
+  /**
+   * The database ID of the current assessment session.
+   */
+  assessmentSessionId: Annotation<string>(),
+
+  /**
+   * The knowledge base ID used as ground truth for this evaluation.
+   */
+  knowledgeBaseId: Annotation<string>(),
+
+  /**
+   * List of questions generated for this session.
+   */
+  questions: Annotation<any[]>({
+    reducer: (prev, next) => next ?? prev,
+    default: () => [],
+  }),
+
+  /**
+   * Index of the current question being discussed.
+   */
+  currentQuestionIndex: Annotation<number>({
+    reducer: (prev, next) => next ?? prev,
+    default: () => 0,
+  }),
+
+  /**
+   * Flag indicating if the Grader believes a follow-up question is needed for clarity.
+   */
+  shouldFollowUp: Annotation<boolean>({
+    reducer: (prev, next) => next ?? prev,
+    default: () => false,
+  }),
+
+  /**
+   * Map of scores for each question.
+   */
+  scores: Annotation<Record<string, number>>({
+    reducer: (prev, next) => ({ ...prev, ...next }),
+    default: () => ({}),
+  }),
+
+  /**
+   * Final report generated by the ReportAnalyzer.
+   */
+  report: Annotation<string | undefined>({
+    reducer: (prev, next) => next ?? prev,
+  }),
+
+  /**
+   * Context chunks retrieved from the knowledge base for grounding.
+   */
+  context: Annotation<string[] | undefined>({
+    reducer: (prev, next) => next ?? prev,
+  }),
+
+  /**
+   * Preferred language for the assessment (zh, en, ja).
+   */
+  language: Annotation<string>({
+    reducer: (prev, next) => next ?? prev,
+    default: () => 'zh',
+  }),
+
+  /**
+   * Number of times we have followed up on the current question.
+   */
+  followUpCount: Annotation<number>({
+    reducer: (prev, next) => next ?? prev,
+    default: () => 0,
+  }),
+
+  /**
+   * Number of questions to generate.
+   */
+  questionCount: Annotation<number | undefined>({
+    reducer: (prev, next) => next ?? prev,
+  }),
+
+  /**
+   * Desired difficulty distribution.
+   */
+  difficultyDistribution: Annotation<any | undefined>({
+    reducer: (prev, next) => next ?? prev,
+  }),
+
+  /**
+   * Desired question style.
+   */
+  style: Annotation<string | undefined>({
+    reducer: (prev, next) => next ?? prev,
+  }),
+
+  /**
+   * Target keywords for question generation.
+   */
+  keywords: Annotation<string[] | undefined>({
+    reducer: (prev, next) => next ?? prev,
+  }),
 });
 
 export type EvaluationState = typeof EvaluationAnnotation.State;

+ 54 - 48
server/src/assessment/services/content-filter.service.ts

@@ -2,53 +2,59 @@ import { Injectable, Logger } from '@nestjs/common';
 
 @Injectable()
 export class ContentFilterService {
-    private readonly logger = new Logger(ContentFilterService.name);
-
-    /**
-     * Filters knowledge base content based on keywords.
-     * In a real implementation, this might use semantic search or simple keyword filtering.
-     * For now, we'll implement a simple relevance-based filtering.
-     */
-    filterContent(content: string, keywords: string[]): string {
-        if (!keywords || keywords.length === 0) {
-            return content;
-        }
-
-        this.logger.log(`Filtering content with ${keywords.length} keywords: ${keywords.join(', ')}`);
-
-        // Split content into paragraphs or sections
-        const sections = content.split(/\n\n+/);
-        
-        // Score each section based on keyword matches (case-insensitive)
-        const scoredSections = sections.map(section => {
-            let score = 0;
-            const lowerSection = section.toLowerCase();
-            
-            keywords.forEach(keyword => {
-                const lowerKeyword = keyword.toLowerCase();
-                const matches = lowerSection.split(lowerKeyword).length - 1;
-                score += matches;
-            });
-            
-            return { section, score };
-        });
-
-        // Sort sections by score and take the most relevant ones
-        // If content is huge, we might want to limit the total length
-        const relevantSections = scoredSections
-            .filter(s => s.score > 0)
-            .sort((a, b) => b.score - a.score)
-            .map(s => s.section);
-
-        // If no sections matched, return a sample or the original content
-        if (relevantSections.length === 0) {
-            this.logger.warn('No sections matched keywords, returning first 5000 characters');
-            return content.substring(0, 5000);
-        }
-
-        this.logger.log(`Found ${relevantSections.length} relevant sections out of ${sections.length}`);
-        
-        // Return combined relevant sections (up to a reasonable limit)
-        return relevantSections.join('\n\n').substring(0, 50000);
+  private readonly logger = new Logger(ContentFilterService.name);
+
+  /**
+   * Filters knowledge base content based on keywords.
+   * In a real implementation, this might use semantic search or simple keyword filtering.
+   * For now, we'll implement a simple relevance-based filtering.
+   */
+  filterContent(content: string, keywords: string[]): string {
+    if (!keywords || keywords.length === 0) {
+      return content;
     }
+
+    this.logger.log(
+      `Filtering content with ${keywords.length} keywords: ${keywords.join(', ')}`,
+    );
+
+    // Split content into paragraphs or sections
+    const sections = content.split(/\n\n+/);
+
+    // Score each section based on keyword matches (case-insensitive)
+    const scoredSections = sections.map((section) => {
+      let score = 0;
+      const lowerSection = section.toLowerCase();
+
+      keywords.forEach((keyword) => {
+        const lowerKeyword = keyword.toLowerCase();
+        const matches = lowerSection.split(lowerKeyword).length - 1;
+        score += matches;
+      });
+
+      return { section, score };
+    });
+
+    // Sort sections by score and take the most relevant ones
+    // If content is huge, we might want to limit the total length
+    const relevantSections = scoredSections
+      .filter((s) => s.score > 0)
+      .sort((a, b) => b.score - a.score)
+      .map((s) => s.section);
+
+    // If no sections matched, return a sample or the original content
+    if (relevantSections.length === 0) {
+      this.logger.warn(
+        'No sections matched keywords, returning first 5000 characters',
+      );
+      return content.substring(0, 5000);
+    }
+
+    this.logger.log(
+      `Found ${relevantSections.length} relevant sections out of ${sections.length}`,
+    );
+
+    // Return combined relevant sections (up to a reasonable limit)
+    return relevantSections.join('\n\n').substring(0, 50000);
+  }
 }

+ 73 - 39
server/src/assessment/services/template.service.ts

@@ -1,54 +1,88 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import {
+  Injectable,
+  NotFoundException,
+  ForbiddenException,
+} from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { AssessmentTemplate } from '../entities/assessment-template.entity';
 import { CreateTemplateDto } from '../dto/create-template.dto';
 import { UpdateTemplateDto } from '../dto/update-template.dto';
+import { TenantService } from '../../tenant/tenant.service';
 
 @Injectable()
 export class TemplateService {
-    constructor(
-        @InjectRepository(AssessmentTemplate)
-        private readonly templateRepository: Repository<AssessmentTemplate>,
-    ) {}
-
-    async create(createDto: CreateTemplateDto, userId: string, tenantId: string): Promise<AssessmentTemplate> {
-        const { ...data } = createDto;
-        const template = this.templateRepository.create({
-            ...data,
-            createdBy: userId,
-            tenantId,
-        });
-        return this.templateRepository.save(template);
-    }
+  constructor(
+    @InjectRepository(AssessmentTemplate)
+    private readonly templateRepository: Repository<AssessmentTemplate>,
+    private readonly tenantService: TenantService,
+  ) {}
 
-    async findAll(tenantId: string): Promise<AssessmentTemplate[]> {
-        return this.templateRepository.find({
-            where: { tenantId, isActive: true },
-            order: { createdAt: 'DESC' },
-        });
-    }
+  async create(
+    createDto: CreateTemplateDto,
+    userId: string,
+    tenantId: string,
+  ): Promise<AssessmentTemplate> {
+    const { ...data } = createDto;
+    const template = this.templateRepository.create({
+      ...data,
+      createdBy: userId,
+      tenantId,
+    });
+    return this.templateRepository.save(template);
+  }
 
-    async findOne(id: string, tenantId: string): Promise<AssessmentTemplate> {
-        const template = await this.templateRepository.findOne({
-            where: { id, tenantId },
-        });
-        if (!template) {
-            throw new NotFoundException(`Template with ID "${id}" not found`);
-        }
-        return template;
-    }
+  async findAll(tenantId: string): Promise<AssessmentTemplate[]> {
+    return this.templateRepository.find({
+      where: { tenantId, isActive: true },
+      order: { createdAt: 'DESC' },
+    });
+  }
+
+  async findOne(
+    id: string,
+    userId: string,
+    tenantId: string,
+  ): Promise<AssessmentTemplate> {
+    const template = await this.templateRepository.findOne({
+      where: { id },
+      relations: ['knowledgeGroup'],
+    });
 
-    async update(id: string, updateDto: UpdateTemplateDto, tenantId: string): Promise<AssessmentTemplate> {
-        const template = await this.findOne(id, tenantId);
-        Object.assign(template, updateDto);
-        return this.templateRepository.save(template);
+    if (!template) {
+      throw new NotFoundException(`Template with ID "${id}" not found`);
     }
 
-    async remove(id: string, tenantId: string): Promise<void> {
-        const template = await this.findOne(id, tenantId);
-        // Soft delete by setting isActive to false
-        template.isActive = false;
-        await this.templateRepository.save(template);
+    // Check permission using TenantService
+    const hasAccess = await this.tenantService.canAccessTenant(
+      userId,
+      template.tenantId,
+      tenantId,
+    );
+    if (!hasAccess) {
+      throw new ForbiddenException(
+        `You do not have permission to access this template`,
+      );
     }
+
+    return template;
+  }
+
+  async update(
+    id: string,
+    updateDto: UpdateTemplateDto,
+    userId: string,
+    tenantId: string,
+  ): Promise<AssessmentTemplate> {
+    const template = await this.findOne(id, userId, tenantId);
+    Object.assign(template, updateDto);
+    return this.templateRepository.save(template);
+  }
+
+  async remove(id: string, userId: string, tenantId: string): Promise<void> {
+    const template = await this.findOne(id, userId, tenantId);
+    // Soft delete by setting isActive to false
+    template.isActive = false;
+    await this.templateRepository.save(template);
+  }
 }

+ 1 - 1
server/src/auth/admin.guard.ts

@@ -15,4 +15,4 @@ export class AdminGuard implements CanActivate {
         user.isAdmin === true)
     );
   }
-}
+}

+ 41 - 34
server/src/auth/api-key.guard.ts

@@ -1,4 +1,9 @@
-import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
+import {
+  Injectable,
+  CanActivate,
+  ExecutionContext,
+  UnauthorizedException,
+} from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { UserService } from '../user/user.service';
 import { Request } from 'express';
@@ -6,44 +11,46 @@ import { IS_PUBLIC_KEY } from './public.decorator';
 
 @Injectable()
 export class ApiKeyGuard implements CanActivate {
-    constructor(
-        private reflector: Reflector,
-        private userService: UserService,
-    ) { }
+  constructor(
+    private reflector: Reflector,
+    private userService: UserService,
+  ) {}
 
-    async canActivate(context: ExecutionContext): Promise<boolean> {
-        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
-            context.getHandler(),
-            context.getClass(),
-        ]);
-        if (isPublic) {
-            return true;
-        }
-
-        const request = context.switchToHttp().getRequest<Request & { user?: any, tenantId?: string }>();
-        const apiKey = this.extractApiKeyFromHeader(request);
+  async canActivate(context: ExecutionContext): Promise<boolean> {
+    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
+      context.getHandler(),
+      context.getClass(),
+    ]);
+    if (isPublic) {
+      return true;
+    }
 
-        if (apiKey) {
-            const user = await this.userService.findByApiKey(apiKey);
-            if (user) {
-                request.user = user;
-                request.tenantId = user.tenantId;
-                return true;
-            }
-            throw new UnauthorizedException('Invalid API key');
-        }
+    const request = context
+      .switchToHttp()
+      .getRequest<Request & { user?: any; tenantId?: string }>();
+    const apiKey = this.extractApiKeyFromHeader(request);
 
-        throw new UnauthorizedException('Missing API key');
+    if (apiKey) {
+      const user = await this.userService.findByApiKey(apiKey);
+      if (user) {
+        request.user = user;
+        request.tenantId = user.tenantId;
+        return true;
+      }
+      throw new UnauthorizedException('Invalid API key');
     }
 
-    private extractApiKeyFromHeader(request: Request): string | undefined {
-        const authHeader = request.headers.authorization;
-        if (authHeader && authHeader.startsWith('Bearer kb_')) {
-            return authHeader.substring(7, authHeader.length);
-        }
-        const headerKey = request.headers['x-api-key'] as string;
-        if (headerKey) return headerKey;
+    throw new UnauthorizedException('Missing API key');
+  }
 
-        return undefined;
+  private extractApiKeyFromHeader(request: Request): string | undefined {
+    const authHeader = request.headers.authorization;
+    if (authHeader && authHeader.startsWith('Bearer kb_')) {
+      return authHeader.substring(7, authHeader.length);
     }
+    const headerKey = request.headers['x-api-key'] as string;
+    if (headerKey) return headerKey;
+
+    return undefined;
+  }
 }

+ 1 - 1
server/src/auth/auth.controller.ts

@@ -6,7 +6,7 @@ import { Public } from './public.decorator';
 
 @Controller('auth')
 export class AuthController {
-  constructor(private authService: AuthService) { }
+  constructor(private authService: AuthService) {}
 
   @Public()
   @UseGuards(LocalAuthGuard)

+ 4 - 4
server/src/auth/auth.service.ts

@@ -9,7 +9,7 @@ export class AuthService {
   constructor(
     private userService: UserService,
     private jwtService: JwtService,
-  ) { }
+  ) {}
 
   async validateUser(username: string, pass: string): Promise<User | null> {
     const user = await this.userService.findOneByUsername(username);
@@ -24,7 +24,7 @@ export class AuthService {
       username: user.username,
       sub: user.id,
       role: user.role,
-      tenantId: user.tenantId
+      tenantId: user.tenantId,
     };
     return {
       access_token: this.jwtService.sign(payload),
@@ -33,8 +33,8 @@ export class AuthService {
         username: user.username,
         role: user.role,
         tenantId: user.tenantId,
-        displayName: user.displayName
-      }
+        displayName: user.displayName,
+      },
     };
   }
 

+ 136 - 117
server/src/auth/combined-auth.guard.ts

@@ -1,4 +1,9 @@
-import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
+import {
+  Injectable,
+  CanActivate,
+  ExecutionContext,
+  UnauthorizedException,
+} from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { AuthGuard } from '@nestjs/passport';
 import { UserService } from '../user/user.service';
@@ -19,130 +24,144 @@ import * as path from 'path';
  */
 @Injectable()
 export class CombinedAuthGuard implements CanActivate {
-    // We extend AuthGuard('jwt') functionality by composition
-    private jwtGuard: ReturnType<typeof AuthGuard>;
-
-    constructor(
-        private reflector: Reflector,
-        private userService: UserService,
-    ) {
-        // Create a JWT guard instance
-        const JwtGuardClass = AuthGuard('jwt');
-        this.jwtGuard = new JwtGuardClass() as any;
+  // We extend AuthGuard('jwt') functionality by composition
+  private jwtGuard: ReturnType<typeof AuthGuard>;
+
+  constructor(
+    private reflector: Reflector,
+    private userService: UserService,
+  ) {
+    // Create a JWT guard instance
+    const JwtGuardClass = AuthGuard('jwt');
+    this.jwtGuard = new JwtGuardClass() as any;
+  }
+
+  async canActivate(context: ExecutionContext): Promise<boolean> {
+    // Allow @Public() decorated routes
+    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
+      context.getHandler(),
+      context.getClass(),
+    ]);
+
+    const request = context
+      .switchToHttp()
+      .getRequest<Request & { user?: any; tenantId?: string }>();
+    const logMsg = `\n[${new Date().toISOString()}] AuthGuard: ${request.method} ${request.url} (isPublic: ${isPublic})\n`;
+    fs.appendFileSync('auth_debug.log', logMsg);
+
+    if (isPublic) {
+      return true;
     }
 
-    async canActivate(context: ExecutionContext): Promise<boolean> {
-        // Allow @Public() decorated routes
-        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
-            context.getHandler(),
-            context.getClass(),
-        ]);
-        
-        const request = context.switchToHttp().getRequest<Request & { user?: any; tenantId?: string }>();
-        const logMsg = `\n[${new Date().toISOString()}] AuthGuard: ${request.method} ${request.url} (isPublic: ${isPublic})\n`;
-        fs.appendFileSync('auth_debug.log', logMsg);
-
-        if (isPublic) {
-            return true;
+    console.log(
+      `[CombinedAuthGuard] Checking auth for route: ${request.method} ${request.url}`,
+    );
+
+    // --- Try API Key first ---
+    const apiKey = this.extractApiKey(request);
+    if (apiKey) {
+      const user = await this.userService.findByApiKey(apiKey);
+      if (user) {
+        // If x-tenant-id is provided, verify membership
+        const requestedTenantId = request.headers['x-tenant-id'] as string;
+        let activeTenantId = user.tenantId;
+
+        if (requestedTenantId) {
+          const memberships = await this.userService.getUserTenants(user.id);
+          const hasAccess = memberships.some(
+            (m) => m.tenantId === requestedTenantId,
+          );
+
+          if (hasAccess || user.isAdmin) {
+            activeTenantId = requestedTenantId;
+          } else {
+            throw new UnauthorizedException(
+              'User does not belong to the requested tenant',
+            );
+          }
         }
 
-        console.log(`[CombinedAuthGuard] Checking auth for route: ${request.method} ${request.url}`);
-
-        // --- Try API Key first ---
-        const apiKey = this.extractApiKey(request);
-        if (apiKey) {
-            const user = await this.userService.findByApiKey(apiKey);
-            if (user) {
-                // If x-tenant-id is provided, verify membership
-                const requestedTenantId = request.headers['x-tenant-id'] as string;
-                let activeTenantId = user.tenantId;
-
-                if (requestedTenantId) {
-                    const memberships = await this.userService.getUserTenants(user.id);
-                    const hasAccess = memberships.some(m => m.tenantId === requestedTenantId);
-
-                    if (hasAccess || user.isAdmin) {
-                        activeTenantId = requestedTenantId;
-                    } else {
-                        throw new UnauthorizedException('User does not belong to the requested tenant');
-                    }
-                }
-
-                request.user = {
-                    id: user.id,
-                    username: user.username,
-                    role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER,
-                    tenantId: activeTenantId,
-                };
-                request.tenantId = activeTenantId;
-
-                // Update tenant context store
-                const store = tenantStore.getStore();
-                if (store) {
-                    store.tenantId = activeTenantId;
-                    store.userId = user.id;
-                }
-
-                return true;
-            }
-            throw new UnauthorizedException('Invalid API key');
+        request.user = {
+          id: user.id,
+          username: user.username,
+          role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER,
+          tenantId: activeTenantId,
+        };
+        request.tenantId = activeTenantId;
+
+        // Update tenant context store
+        const store = tenantStore.getStore();
+        if (store) {
+          store.tenantId = activeTenantId;
+          store.userId = user.id;
         }
 
-        // --- Fall back to JWT ---
-        try {
-            const result = await (this.jwtGuard as any).canActivate(context);
-            let hasJwtSession = false;
-
-            if (result instanceof Observable) {
-                hasJwtSession = await lastValueFrom(result);
-            } else {
-                hasJwtSession = result;
-            }
-
-            if (hasJwtSession) {
-                const user = request.user;
-                if (!user) return false;
-
-                const requestedTenantId = request.headers['x-tenant-id'] as string;
-
-                if (requestedTenantId && user.tenantId !== requestedTenantId) {
-                    const memberships = await this.userService.getUserTenants(user.id);
-                    const hasAccess = memberships.some(m => m.tenantId === requestedTenantId);
-
-                    if (hasAccess || user.isAdmin) {
-                        user.tenantId = requestedTenantId;
-                    } else {
-                        throw new UnauthorizedException('User does not belong to the requested tenant');
-                    }
-                }
-
-                request.tenantId = user.tenantId;
-
-                // Update tenant context store
-                const store = tenantStore.getStore();
-                if (store) {
-                    store.tenantId = user.tenantId;
-                    store.userId = user.id;
-                }
-
-                return true;
-            }
-            return false;
-        } catch (e) {
-            console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
-            throw e instanceof UnauthorizedException ? e : new UnauthorizedException('Authentication required');
-        }
+        return true;
+      }
+      throw new UnauthorizedException('Invalid API key');
     }
 
-    private extractApiKey(request: Request): string | undefined {
-        // Allow `Authorization: Bearer kb_...` form
-        const authHeader = request.headers.authorization;
-        if (authHeader?.startsWith('Bearer kb_')) {
-            return authHeader.substring(7);
+    // --- Fall back to JWT ---
+    try {
+      const result = await (this.jwtGuard as any).canActivate(context);
+      let hasJwtSession = false;
+
+      if (result instanceof Observable) {
+        hasJwtSession = await lastValueFrom(result);
+      } else {
+        hasJwtSession = result;
+      }
+
+      if (hasJwtSession) {
+        const user = request.user;
+        if (!user) return false;
+
+        const requestedTenantId = request.headers['x-tenant-id'] as string;
+
+        if (requestedTenantId && user.tenantId !== requestedTenantId) {
+          const memberships = await this.userService.getUserTenants(user.id);
+          const hasAccess = memberships.some(
+            (m) => m.tenantId === requestedTenantId,
+          );
+
+          if (hasAccess || user.isAdmin) {
+            user.tenantId = requestedTenantId;
+          } else {
+            throw new UnauthorizedException(
+              'User does not belong to the requested tenant',
+            );
+          }
+        }
+
+        request.tenantId = user.tenantId;
+
+        // Update tenant context store
+        const store = tenantStore.getStore();
+        if (store) {
+          store.tenantId = user.tenantId;
+          store.userId = user.id;
         }
-        // Or a plain `x-api-key` header
-        const headerKey = request.headers['x-api-key'] as string;
-        if (headerKey) return headerKey;
-        return undefined;
+
+        return true;
+      }
+      return false;
+    } catch (e) {
+      console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
+      throw e instanceof UnauthorizedException
+        ? e
+        : new UnauthorizedException('Authentication required');
+    }
+  }
+
+  private extractApiKey(request: Request): string | undefined {
+    // Allow `Authorization: Bearer kb_...` form
+    const authHeader = request.headers.authorization;
+    if (authHeader?.startsWith('Bearer kb_')) {
+      return authHeader.substring(7);
     }
+    // Or a plain `x-api-key` header
+    const headerKey = request.headers['x-api-key'] as string;
+    if (headerKey) return headerKey;
+    return undefined;
+  }
 }

+ 17 - 17
server/src/auth/entities/api-key.entity.ts

@@ -1,28 +1,28 @@
 import {
-    Column,
-    CreateDateColumn,
-    Entity,
-    JoinColumn,
-    ManyToOne,
-    PrimaryGeneratedColumn,
+  Column,
+  CreateDateColumn,
+  Entity,
+  JoinColumn,
+  ManyToOne,
+  PrimaryGeneratedColumn,
 } from 'typeorm';
 import { User } from '../../user/user.entity';
 
 @Entity('api_keys')
 export class ApiKey {
-    @PrimaryGeneratedColumn('uuid')
-    id: string;
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
 
-    @Column({ name: 'user_id', type: 'uuid' })
-    userId: string;
+  @Column({ name: 'user_id', type: 'uuid' })
+  userId: string;
 
-    @ManyToOne(() => User, (user) => user.apiKeys, { onDelete: 'CASCADE' })
-    @JoinColumn({ name: 'user_id' })
-    user: User;
+  @ManyToOne(() => User, (user) => user.apiKeys, { onDelete: 'CASCADE' })
+  @JoinColumn({ name: 'user_id' })
+  user: User;
 
-    @Column({ type: 'text', unique: true })
-    key: string;
+  @Column({ type: 'text', unique: true })
+  key: string;
 
-    @CreateDateColumn({ name: 'created_at' })
-    createdAt: Date;
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt: Date;
 }

+ 4 - 2
server/src/auth/jwt.strategy.ts

@@ -35,12 +35,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
       // In a multi-tenant setup, the tenantId in the payload is the "default" or "last active" one.
       // But it can be overridden by the x-tenant-id header in the guard.
       // Map the backend isAdmin flag to the global UserRole
-      const computedRole = result.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER;
+      const computedRole = result.isAdmin
+        ? UserRole.SUPER_ADMIN
+        : UserRole.USER;
 
       return {
         ...result,
         role: payload.role || computedRole,
-        tenantId: payload.tenantId || result.tenantId
+        tenantId: payload.tenantId || result.tenantId,
       } as SafeUser;
     }
     return null;

+ 4 - 2
server/src/auth/local.strategy.ts

@@ -18,12 +18,14 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
   async validate(username: string, password: string): Promise<SafeUser> {
     const user = await this.authService.validateUser(username, password);
     if (!user) {
-      throw new UnauthorizedException(this.i18nService.getMessage('incorrectCredentials'));
+      throw new UnauthorizedException(
+        this.i18nService.getMessage('incorrectCredentials'),
+      );
     }
     const { password: userPassword, ...result } = user; // Destructure to remove password
     return {
       ...result,
-      role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER
+      role: user.isAdmin ? UserRole.SUPER_ADMIN : UserRole.USER,
     } as SafeUser;
   }
 }

+ 1 - 1
server/src/auth/public.decorator.ts

@@ -1,4 +1,4 @@
 import { SetMetadata } from '@nestjs/common';
 
 export const IS_PUBLIC_KEY = 'isPublic';
-export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
+export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

+ 16 - 16
server/src/auth/roles.guard.ts

@@ -5,24 +5,24 @@ import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class RolesGuard implements CanActivate {
-    constructor(private reflector: Reflector) { }
+  constructor(private reflector: Reflector) {}
 
-    canActivate(context: ExecutionContext): boolean {
-        const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
-            context.getHandler(),
-            context.getClass(),
-        ]);
+  canActivate(context: ExecutionContext): boolean {
+    const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
+      ROLES_KEY,
+      [context.getHandler(), context.getClass()],
+    );
 
-        if (!requiredRoles) {
-            return true;
-        }
-
-        const { user } = context.switchToHttp().getRequest();
-        // User might not be injected yet if auth guard fails, but auth guard runs first usually.
-        if (!user) {
-            return false;
-        }
+    if (!requiredRoles) {
+      return true;
+    }
 
-        return requiredRoles.includes(user.role);
+    const { user } = context.switchToHttp().getRequest();
+    // User might not be injected yet if auth guard fails, but auth guard runs first usually.
+    if (!user) {
+      return false;
     }
+
+    return requiredRoles.includes(user.role);
+  }
 }

+ 7 - 5
server/src/auth/super-admin.guard.ts

@@ -3,9 +3,11 @@ import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class SuperAdminGuard implements CanActivate {
-    canActivate(context: ExecutionContext): boolean {
-        const request = context.switchToHttp().getRequest();
-        const user = request.user;
-        return user && (user.role === UserRole.SUPER_ADMIN || user.isAdmin === true);
-    }
+  canActivate(context: ExecutionContext): boolean {
+    const request = context.switchToHttp().getRequest();
+    const user = request.user;
+    return (
+      user && (user.role === UserRole.SUPER_ADMIN || user.isAdmin === true)
+    );
+  }
 }

+ 10 - 10
server/src/auth/tenant-admin.guard.ts

@@ -3,14 +3,14 @@ import { UserRole } from '../user/user-role.enum';
 
 @Injectable()
 export class TenantAdminGuard implements CanActivate {
-    canActivate(context: ExecutionContext): boolean {
-        const request = context.switchToHttp().getRequest();
-        const user = request.user;
-        return (
-            user &&
-            (user.role === UserRole.SUPER_ADMIN ||
-                user.role === UserRole.TENANT_ADMIN ||
-                user.isAdmin === true)
-        );
-    }
+  canActivate(context: ExecutionContext): boolean {
+    const request = context.switchToHttp().getRequest();
+    const user = request.user;
+    return (
+      user &&
+      (user.role === UserRole.SUPER_ADMIN ||
+        user.role === UserRole.TENANT_ADMIN ||
+        user.isAdmin === true)
+    );
+  }
 }

+ 40 - 9
server/src/chat/chat.controller.ts

@@ -40,7 +40,7 @@ export class ChatController {
     private chatService: ChatService,
     private modelConfigService: ModelConfigService,
     private tenantService: TenantService,
-  ) { }
+  ) {}
 
   @Post('stream')
   async streamChat(
@@ -50,7 +50,25 @@ export class ChatController {
   ) {
     try {
       console.log('Full Request Body:', JSON.stringify(body, null, 2));
-      const { message, history = [], userLanguage = 'zh', selectedEmbeddingId, selectedLLMId, selectedGroups, selectedFiles, historyId, enableRerank, selectedRerankId, temperature, maxTokens, topK, similarityThreshold, rerankSimilarityThreshold, enableQueryExpansion, enableHyDE } = body;
+      const {
+        message,
+        history = [],
+        userLanguage = 'zh',
+        selectedEmbeddingId,
+        selectedLLMId,
+        selectedGroups,
+        selectedFiles,
+        historyId,
+        enableRerank,
+        selectedRerankId,
+        temperature,
+        maxTokens,
+        topK,
+        similarityThreshold,
+        rerankSimilarityThreshold,
+        enableQueryExpansion,
+        enableHyDE,
+      } = body;
       const userId = req.user.id;
 
       console.log('=== Chat Debug Info ===');
@@ -80,18 +98,28 @@ export class ChatController {
         const tenantSettings = await this.tenantService.getSettings(tenantId);
         const enabledIds = tenantSettings?.enabledModelIds || [];
         // Only allow models that are enabled by the tenant admin
-        models = models.filter(m => enabledIds.includes(m.id));
+        models = models.filter((m) => enabledIds.includes(m.id));
       }
 
       let llmModel;
       if (selectedLLMId) {
         // Find specifically selected model
-        llmModel = await this.modelConfigService.findOne(selectedLLMId, userId, tenantId);
+        llmModel = await this.modelConfigService.findOne(
+          selectedLLMId,
+          userId,
+          tenantId,
+        );
         console.log('使用选中的LLM模型:', llmModel.name);
       } else {
         // Use organization's default LLM from Index Chat Config (strict)
-        llmModel = await this.modelConfigService.findDefaultByType(tenantId, ModelType.LLM);
-        console.log('最终使用的LLM模型 (默认):', llmModel ? llmModel.name : '无');
+        llmModel = await this.modelConfigService.findDefaultByType(
+          tenantId,
+          ModelType.LLM,
+        );
+        console.log(
+          '最终使用的LLM模型 (默认):',
+          llmModel ? llmModel.name : '无',
+        );
       }
 
       // 设置 SSE 响应头
@@ -113,7 +141,7 @@ export class ChatController {
         message,
         history,
         userId,
-        llmModel as any,
+        llmModel,
         userLanguage,
         selectedEmbeddingId,
         selectedGroups,
@@ -128,7 +156,7 @@ export class ChatController {
         rerankSimilarityThreshold, // 传递 rerankSimilarityThreshold 参数
         enableQueryExpansion, // 传递 enableQueryExpansion
         enableHyDE, // 传递 enableHyDE
-        req.user.tenantId // Pass tenant ID
+        req.user.tenantId, // Pass tenant ID
       );
 
       for await (const chunk of stream) {
@@ -164,7 +192,10 @@ export class ChatController {
       const role = req.user.role;
 
       // Use organization's default LLM from Index Chat Config (strict)
-      const llmModel = await this.modelConfigService.findDefaultByType(tenantId, ModelType.LLM);
+      const llmModel = await this.modelConfigService.findDefaultByType(
+        tenantId,
+        ModelType.LLM,
+      );
 
       res.setHeader('Content-Type', 'text/event-stream');
       res.setHeader('Cache-Control', 'no-cache');

+ 1 - 1
server/src/chat/chat.module.ts

@@ -25,4 +25,4 @@ import { UserModule } from '../user/user.module';
   providers: [ChatService],
   exports: [ChatService],
 })
-export class ChatModule { }
+export class ChatModule {}

+ 289 - 82
server/src/chat/chat.service.ts

@@ -10,7 +10,10 @@ import { SearchHistoryService } from '../search-history/search-history.service';
 import { ModelConfig, ModelType } from '../types';
 import { RagService } from '../rag/rag.service';
 
-import { DEFAULT_VECTOR_DIMENSIONS, DEFAULT_LANGUAGE } from '../common/constants';
+import {
+  DEFAULT_VECTOR_DIMENSIONS,
+  DEFAULT_LANGUAGE,
+} from '../common/constants';
 import { I18nService } from '../i18n/i18n.service';
 import { TenantService } from '../tenant/tenant.service';
 import { UserSettingService } from '../user/user-setting.service';
@@ -40,7 +43,10 @@ export class ChatService {
     private userSettingService: UserSettingService,
   ) {
     this.defaultDimensions = parseInt(
-      this.configService.get<string>('DEFAULT_VECTOR_DIMENSIONS', String(DEFAULT_VECTOR_DIMENSIONS)),
+      this.configService.get<string>(
+        'DEFAULT_VECTOR_DIMENSIONS',
+        String(DEFAULT_VECTOR_DIMENSIONS),
+      ),
     );
   }
 
@@ -63,7 +69,7 @@ export class ChatService {
     rerankSimilarityThreshold?: number, // New: rerankSimilarityThreshold parameter
     enableQueryExpansion?: boolean, // New
     enableHyDE?: boolean, // New
-    tenantId?: string // New: tenant isolation
+    tenantId?: string, // New: tenant isolation
   ): AsyncGenerator<{ type: 'content' | 'sources' | 'historyId'; data: any }> {
     console.log('=== ChatService.streamChat ===');
     console.log('User ID:', userId);
@@ -84,7 +90,10 @@ export class ChatService {
       modelId: modelConfig.modelId,
       baseUrl: modelConfig.baseUrl,
     });
-    console.log('API Key prefix:', modelConfig.apiKey?.substring(0, 10) + '...');
+    console.log(
+      'API Key prefix:',
+      modelConfig.apiKey?.substring(0, 10) + '...',
+    );
     console.log('API Key length:', modelConfig.apiKey?.length);
 
     // Get current language setting (keeping LANGUAGE_CONFIG for backward compatibility, now uses i18n service)
@@ -104,28 +113,61 @@ export class ChatService {
           selectedGroups,
         );
         currentHistoryId = searchHistory.id;
-        console.log(this.i18nService.getMessage('creatingHistory', effectiveUserLanguage) + currentHistoryId);
+        console.log(
+          this.i18nService.getMessage(
+            'creatingHistory',
+            effectiveUserLanguage,
+          ) + currentHistoryId,
+        );
         yield { type: 'historyId', data: currentHistoryId };
       }
 
       // Save user message
-      await this.searchHistoryService.addMessage(currentHistoryId, 'user', message);
+      await this.searchHistoryService.addMessage(
+        currentHistoryId,
+        'user',
+        message,
+      );
       // 1. Get user's embedding model settings
       let embeddingModel: any;
 
       if (selectedEmbeddingId) {
         // Find specifically selected model
-        embeddingModel = await this.modelConfigService.findOne(selectedEmbeddingId, userId, tenantId || 'default');
+        embeddingModel = await this.modelConfigService.findOne(
+          selectedEmbeddingId,
+          userId,
+          tenantId || 'default',
+        );
       } else {
         // Use organization's default from Index Chat Config (strict)
-        embeddingModel = await this.modelConfigService.findDefaultByType(tenantId || 'default', ModelType.EMBEDDING);
+        embeddingModel = await this.modelConfigService.findDefaultByType(
+          tenantId || 'default',
+          ModelType.EMBEDDING,
+        );
       }
 
-      console.log(this.i18nService.getMessage('usingEmbeddingModel', effectiveUserLanguage) + embeddingModel.name + ' ' + embeddingModel.modelId + ' ID:' + embeddingModel.id);
+      console.log(
+        this.i18nService.getMessage(
+          'usingEmbeddingModel',
+          effectiveUserLanguage,
+        ) +
+          embeddingModel.name +
+          ' ' +
+          embeddingModel.modelId +
+          ' ID:' +
+          embeddingModel.id,
+      );
 
       // 2. Search using user's query directly
-      console.log(this.i18nService.getMessage('startingSearch', effectiveUserLanguage));
-      yield { type: 'content', data: this.i18nService.getMessage('searching', effectiveUserLanguage) + '\n' };
+      console.log(
+        this.i18nService.getMessage('startingSearch', effectiveUserLanguage),
+      );
+      yield {
+        type: 'content',
+        data:
+          this.i18nService.getMessage('searching', effectiveUserLanguage) +
+          '\n',
+      };
 
       let searchResults: any[] = [];
       let context = '';
@@ -135,7 +177,12 @@ export class ChatService {
         let effectiveFileIds = selectedFiles; // Prioritize explicitly specified files
         if (!effectiveFileIds && selectedGroups && selectedGroups.length > 0) {
           // Get file IDs from knowledge groups
-          effectiveFileIds = await this.knowledgeGroupService.getFileIdsByGroups(selectedGroups, userId, tenantId as string);
+          effectiveFileIds =
+            await this.knowledgeGroupService.getFileIdsByGroups(
+              selectedGroups,
+              userId,
+              tenantId as string,
+            );
         }
 
         // 3. Use RagService for search (supports hybrid search + Rerank)
@@ -153,14 +200,19 @@ export class ChatService {
           rerankSimilarityThreshold,
           tenantId,
           enableQueryExpansion,
-          enableHyDE
+          enableHyDE,
         );
 
         // Convert RagSearchResult to format needed by ChatService (any[])
         // HybridSearch returns ES hit structure, but RagSearchResult is normalized
         // BuildContext expects {fileName, content}. RagSearchResult has these
         searchResults = ragResults;
-        console.log(this.i18nService.getMessage('searchResultsCount', effectiveUserLanguage) + searchResults.length);
+        console.log(
+          this.i18nService.getMessage(
+            'searchResultsCount',
+            effectiveUserLanguage,
+          ) + searchResults.length,
+        );
 
         // 4. Build context
         context = this.buildContext(searchResults, effectiveUserLanguage);
@@ -168,41 +220,90 @@ export class ChatService {
         if (searchResults.length === 0) {
           if (selectedGroups && selectedGroups.length > 0) {
             // User selected knowledge groups but no matches found
-            const noMatchMsg = this.i18nService.getMessage('noMatchInKnowledgeGroup', effectiveUserLanguage);
+            const noMatchMsg = this.i18nService.getMessage(
+              'noMatchInKnowledgeGroup',
+              effectiveUserLanguage,
+            );
             yield { type: 'content', data: `⚠️ ${noMatchMsg}\n\n` };
           } else {
-            yield { type: 'content', data: this.i18nService.getMessage('noResults', effectiveUserLanguage) + '\n\n' };
+            yield {
+              type: 'content',
+              data:
+                this.i18nService.getMessage(
+                  'noResults',
+                  effectiveUserLanguage,
+                ) + '\n\n',
+            };
           }
-          yield { type: 'content', data: `[Debug] ${this.i18nService.getMessage('searchScope', effectiveUserLanguage)}: ${selectedFiles ? selectedFiles.length + ' ' + this.i18nService.getMessage('files', effectiveUserLanguage) : selectedGroups ? selectedGroups.length + ' ' + this.i18nService.getMessage('notebooks', effectiveUserLanguage) : this.i18nService.getMessage('all', effectiveUserLanguage)}\n` };
-          yield { type: 'content', data: `[Debug] ${this.i18nService.getMessage('searchResults', effectiveUserLanguage)}: 0 ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n` };
+          yield {
+            type: 'content',
+            data: `[Debug] ${this.i18nService.getMessage('searchScope', effectiveUserLanguage)}: ${selectedFiles ? selectedFiles.length + ' ' + this.i18nService.getMessage('files', effectiveUserLanguage) : selectedGroups ? selectedGroups.length + ' ' + this.i18nService.getMessage('notebooks', effectiveUserLanguage) : this.i18nService.getMessage('all', effectiveUserLanguage)}\n`,
+          };
+          yield {
+            type: 'content',
+            data: `[Debug] ${this.i18nService.getMessage('searchResults', effectiveUserLanguage)}: 0 ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n`,
+          };
         } else {
           yield {
             type: 'content',
             data: `${searchResults.length} ${this.i18nService.getMessage('relevantInfoFound', effectiveUserLanguage)}。${this.i18nService.getMessage('generatingResponse', effectiveUserLanguage)}...\n\n`,
           };
           // Debug info
-          const scores = searchResults.map(r => {
-            if (r.originalScore !== undefined && r.originalScore !== r.score) {
-              return `${r.originalScore.toFixed(2)} → ${r.score.toFixed(2)}`;
-            }
-            return r.score.toFixed(2);
-          }).join(', ');
-          const files = [...new Set(searchResults.map(r => r.fileName))].join(', ');
-          yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('searchHits', effectiveUserLanguage)}: ${searchResults.length} ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n` };
-          yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('relevance', effectiveUserLanguage)}: ${scores}\n` };
-          yield { type: 'content', data: `> [Debug] ${this.i18nService.getMessage('sourceFiles', effectiveUserLanguage)}: ${files}\n\n---\n\n` };
+          const scores = searchResults
+            .map((r) => {
+              if (
+                r.originalScore !== undefined &&
+                r.originalScore !== r.score
+              ) {
+                return `${r.originalScore.toFixed(2)} → ${r.score.toFixed(2)}`;
+              }
+              return r.score.toFixed(2);
+            })
+            .join(', ');
+          const files = [...new Set(searchResults.map((r) => r.fileName))].join(
+            ', ',
+          );
+          yield {
+            type: 'content',
+            data: `> [Debug] ${this.i18nService.getMessage('searchHits', effectiveUserLanguage)}: ${searchResults.length} ${this.i18nService.getMessage('items', effectiveUserLanguage)}\n`,
+          };
+          yield {
+            type: 'content',
+            data: `> [Debug] ${this.i18nService.getMessage('relevance', effectiveUserLanguage)}: ${scores}\n`,
+          };
+          yield {
+            type: 'content',
+            data: `> [Debug] ${this.i18nService.getMessage('sourceFiles', effectiveUserLanguage)}: ${files}\n\n---\n\n`,
+          };
         }
       } catch (searchError) {
-        console.error(this.i18nService.getMessage('searchFailedLog', effectiveUserLanguage) + ':', searchError);
-        yield { type: 'content', data: this.i18nService.getMessage('searchFailed', effectiveUserLanguage) + '\n\n' };
+        console.error(
+          this.i18nService.getMessage(
+            'searchFailedLog',
+            effectiveUserLanguage,
+          ) + ':',
+          searchError,
+        );
+        yield {
+          type: 'content',
+          data:
+            this.i18nService.getMessage('searchFailed', effectiveUserLanguage) +
+            '\n\n',
+        };
       }
 
       // 5. Stream response generation
-      this.logger.log(this.i18nService.formatMessage('modelCall', {
-        type: 'LLM',
-        model: `${modelConfig.name} (${modelConfig.modelId})`,
-        user: userId
-      }, effectiveUserLanguage));
+      this.logger.log(
+        this.i18nService.formatMessage(
+          'modelCall',
+          {
+            type: 'LLM',
+            model: `${modelConfig.name} (${modelConfig.modelId})`,
+            user: userId,
+          },
+          effectiveUserLanguage,
+        ),
+      );
       const llm = new ChatOpenAI({
         apiKey: modelConfig.apiKey || 'ollama',
         streaming: true,
@@ -217,10 +318,10 @@ export class ChatService {
       const promptTemplate =
         context.length > 0
           ? this.i18nService.getPrompt(
-            effectiveUserLanguage,
-            'withContext',
-            selectedGroups && selectedGroups.length > 0
-          )
+              effectiveUserLanguage,
+              'withContext',
+              selectedGroups && selectedGroups.length > 0,
+            )
           : this.i18nService.getPrompt(effectiveUserLanguage, 'withoutContext');
 
       const prompt = PromptTemplate.fromTemplate(promptTemplate);
@@ -256,11 +357,20 @@ export class ChatService {
       );
 
       // 7. Auto-generate chat title (executed after first exchange)
-      const messagesInHistory = await this.searchHistoryService.findOne(currentHistoryId, userId, tenantId);
+      const messagesInHistory = await this.searchHistoryService.findOne(
+        currentHistoryId,
+        userId,
+        tenantId,
+      );
       if (messagesInHistory.messages.length === 2) {
-        this.generateChatTitle(currentHistoryId, userId, tenantId).catch((err) => {
-          this.logger.error(`Failed to generate chat title for ${currentHistoryId}`, err);
-        });
+        this.generateChatTitle(currentHistoryId, userId, tenantId).catch(
+          (err) => {
+            this.logger.error(
+              `Failed to generate chat title for ${currentHistoryId}`,
+              err,
+            );
+          },
+        );
       }
 
       // 6. Return sources
@@ -275,8 +385,14 @@ export class ChatService {
         })),
       };
     } catch (error) {
-      this.logger.error(this.i18nService.getMessage('chatStreamError', effectiveUserLanguage), error);
-      yield { type: 'content', data: `${this.i18nService.getMessage('error', effectiveUserLanguage)}: ${error.message}` };
+      this.logger.error(
+        this.i18nService.getMessage('chatStreamError', effectiveUserLanguage),
+        error,
+      );
+      yield {
+        type: 'content',
+        data: `${this.i18nService.getMessage('error', effectiveUserLanguage)}: ${error.message}`,
+      };
     }
   }
 
@@ -287,11 +403,17 @@ export class ChatService {
     userLanguage: string = DEFAULT_LANGUAGE,
   ): AsyncGenerator<{ type: 'content'; data: any }> {
     try {
-      this.logger.log(this.i18nService.formatMessage('modelCall', {
-        type: 'LLM (Assist)',
-        model: `${modelConfig.name} (${modelConfig.modelId})`,
-        user: 'N/A'
-      }, userLanguage));
+      this.logger.log(
+        this.i18nService.formatMessage(
+          'modelCall',
+          {
+            type: 'LLM (Assist)',
+            model: `${modelConfig.name} (${modelConfig.modelId})`,
+            user: 'N/A',
+          },
+          userLanguage,
+        ),
+      );
       const llm = new ChatOpenAI({
         apiKey: modelConfig.apiKey || 'ollama',
         streaming: true,
@@ -319,8 +441,14 @@ ${instruction}`;
         }
       }
     } catch (error) {
-      this.logger.error(this.i18nService.getMessage('assistStreamError', userLanguage), error);
-      yield { type: 'content', data: `${this.i18nService.getMessage('error', userLanguage)}: ${error.message}` };
+      this.logger.error(
+        this.i18nService.getMessage('assistStreamError', userLanguage),
+        error,
+      );
+      yield {
+        type: 'content',
+        data: `${this.i18nService.getMessage('error', userLanguage)}: ${error.message}`,
+      };
     }
   }
 
@@ -336,26 +464,43 @@ ${instruction}`;
     try {
       // Join keywords into search string
       const combinedQuery = keywords.join(' ');
-      console.log(this.i18nService.getMessage('searchString', userLanguage) + combinedQuery);
+      console.log(
+        this.i18nService.getMessage('searchString', userLanguage) +
+          combinedQuery,
+      );
 
       // Check if embedding model ID is provided
       if (!embeddingModelId) {
-        console.log(this.i18nService.getMessage('embeddingModelIdNotProvided', userLanguage));
+        console.log(
+          this.i18nService.getMessage(
+            'embeddingModelIdNotProvided',
+            userLanguage,
+          ),
+        );
         return [];
       }
 
       // Use actual embedding vector
-      console.log(this.i18nService.getMessage('generatingEmbeddings', userLanguage));
+      console.log(
+        this.i18nService.getMessage('generatingEmbeddings', userLanguage),
+      );
       const queryEmbedding = await this.embeddingService.getEmbeddings(
         [combinedQuery],
         userId,
         embeddingModelId,
       );
       const queryVector = queryEmbedding[0];
-      console.log(this.i18nService.getMessage('embeddingsGenerated', userLanguage) + this.i18nService.getMessage('dimensions', userLanguage) + ':', queryVector.length);
+      console.log(
+        this.i18nService.getMessage('embeddingsGenerated', userLanguage) +
+          this.i18nService.getMessage('dimensions', userLanguage) +
+          ':',
+        queryVector.length,
+      );
 
       // Hybrid search
-      console.log(this.i18nService.getMessage('performingHybridSearch', userLanguage));
+      console.log(
+        this.i18nService.getMessage('performingHybridSearch', userLanguage),
+      );
       const results = await this.elasticsearchService.hybridSearch(
         queryVector,
         combinedQuery,
@@ -366,16 +511,27 @@ ${instruction}`;
         explicitFileIds, // Pass explicit file IDs
         tenantId, // Pass tenant ID
       );
-      console.log(this.i18nService.getMessage('esSearchCompleted', userLanguage) + this.i18nService.getMessage('resultsCount', userLanguage) + ':', results.length);
+      console.log(
+        this.i18nService.getMessage('esSearchCompleted', userLanguage) +
+          this.i18nService.getMessage('resultsCount', userLanguage) +
+          ':',
+        results.length,
+      );
 
       return results.slice(0, 10);
     } catch (error) {
-      console.error(this.i18nService.getMessage('hybridSearchFailed', userLanguage) + ':', error);
+      console.error(
+        this.i18nService.getMessage('hybridSearchFailed', userLanguage) + ':',
+        error,
+      );
       return [];
     }
   }
 
-  private buildContext(results: any[], language: string = DEFAULT_LANGUAGE): string {
+  private buildContext(
+    results: any[],
+    language: string = DEFAULT_LANGUAGE,
+  ): string {
     return results
       .map(
         (result, index) =>
@@ -389,7 +545,10 @@ ${instruction}`;
     userLanguage: string = DEFAULT_LANGUAGE,
   ): string {
     const userLabel = this.i18nService.getMessage('userLabel', userLanguage);
-    const assistantLabel = this.i18nService.getMessage('assistantLabel', userLanguage);
+    const assistantLabel = this.i18nService.getMessage(
+      'assistantLabel',
+      userLanguage,
+    );
 
     return history
       .slice(-6)
@@ -399,10 +558,20 @@ ${instruction}`;
       )
       .join('\n');
   }
-  async getContextForTopic(topic: string, userId: string, tenantId?: string, groupId?: string, fileIds?: string[], userLanguage: string = DEFAULT_LANGUAGE): Promise<string> {
+  async getContextForTopic(
+    topic: string,
+    userId: string,
+    tenantId?: string,
+    groupId?: string,
+    fileIds?: string[],
+    userLanguage: string = DEFAULT_LANGUAGE,
+  ): Promise<string> {
     try {
       // Use organization's default embedding from Index Chat Config (strict)
-      const embeddingModel = await this.modelConfigService.findDefaultByType(tenantId || 'default', ModelType.EMBEDDING);
+      const embeddingModel = await this.modelConfigService.findDefaultByType(
+        tenantId || 'default',
+        ModelType.EMBEDDING,
+      );
 
       const results = await this.hybridSearch(
         [topic],
@@ -416,7 +585,9 @@ ${instruction}`;
 
       return this.buildContext(results);
     } catch (err) {
-      this.logger.error(`${this.i18nService.getMessage('getContextForTopicFailed', userLanguage)}: ${err.message}`);
+      this.logger.error(
+        `${this.i18nService.getMessage('getContextForTopicFailed', userLanguage)}: ${err.message}`,
+      );
       return '';
     }
   }
@@ -432,16 +603,27 @@ ${instruction}`;
       let config = modelConfig;
       if (!config) {
         // Use organization's default LLM from Index Chat Config (strict)
-        const found = await this.modelConfigService.findDefaultByType(tenantId || 'default', ModelType.LLM);
+        const found = await this.modelConfigService.findDefaultByType(
+          tenantId || 'default',
+          ModelType.LLM,
+        );
         config = found as unknown as ModelConfig;
       }
 
-      this.logger.log(this.i18nService.formatMessage('modelCall', {
-        type: 'LLM (Simple)',
-        model: `${config.name} (${config.modelId})`,
-        user: userId
-      }, DEFAULT_LANGUAGE));
-      const settings = await this.tenantService.getSettings(tenantId || 'default');
+      this.logger.log(
+        this.i18nService.formatMessage(
+          'modelCall',
+          {
+            type: 'LLM (Simple)',
+            model: `${config.name} (${config.modelId})`,
+            user: userId,
+          },
+          DEFAULT_LANGUAGE,
+        ),
+      );
+      const settings = await this.tenantService.getSettings(
+        tenantId || 'default',
+      );
       const llm = new ChatOpenAI({
         apiKey: config.apiKey || 'ollama',
         temperature: settings?.temperature ?? 0.7,
@@ -452,12 +634,15 @@ ${instruction}`;
       });
 
       const response = await llm.invoke(
-        messages.map(m => [m.role, m.content])
+        messages.map((m) => [m.role, m.content]),
       );
 
       return String(response.content);
     } catch (error) {
-      this.logger.error(this.i18nService.getMessage('simpleChatGenerationError', userLanguage), error);
+      this.logger.error(
+        this.i18nService.getMessage('simpleChatGenerationError', userLanguage),
+        error,
+      );
       throw error;
     }
   }
@@ -465,17 +650,27 @@ ${instruction}`;
   /**
    * Automatically generate chat title based on conversation content
    */
-  async generateChatTitle(historyId: string, userId: string, tenantId?: string): Promise<string | null> {
+  async generateChatTitle(
+    historyId: string,
+    userId: string,
+    tenantId?: string,
+  ): Promise<string | null> {
     this.logger.log(`Generating automatic title for chat session ${historyId}`);
 
     try {
-      const history = await this.searchHistoryService.findOne(historyId, userId, tenantId || 'default');
+      const history = await this.searchHistoryService.findOne(
+        historyId,
+        userId,
+        tenantId || 'default',
+      );
       if (!history || history.messages.length < 2) {
         return null;
       }
 
-      const userMessage = history.messages.find(m => m.role === 'user')?.content || '';
-      const aiResponse = history.messages.find(m => m.role === 'assistant')?.content || '';
+      const userMessage =
+        history.messages.find((m) => m.role === 'user')?.content || '';
+      const aiResponse =
+        history.messages.find((m) => m.role === 'assistant')?.content || '';
 
       if (!userMessage || !aiResponse) {
         return null;
@@ -486,24 +681,36 @@ ${instruction}`;
       const language = userSettings?.language || DEFAULT_LANGUAGE;
 
       // Build prompt
-      const prompt = this.i18nService.getChatTitlePrompt(language, userMessage, aiResponse);
+      const prompt = this.i18nService.getChatTitlePrompt(
+        language,
+        userMessage,
+        aiResponse,
+      );
 
       // Call LLM to generate title
       const generatedTitle = await this.generateSimpleChat(
         [{ role: 'user', content: prompt }],
         userId,
-        tenantId || 'default'
+        tenantId || 'default',
       );
 
       if (generatedTitle && generatedTitle.trim().length > 0) {
         // Remove extra quotes
-        const cleanedTitle = generatedTitle.trim().replace(/^["']|["']$/g, '').substring(0, 50);
+        const cleanedTitle = generatedTitle
+          .trim()
+          .replace(/^["']|["']$/g, '')
+          .substring(0, 50);
         await this.searchHistoryService.updateTitle(historyId, cleanedTitle);
-        this.logger.log(`Successfully generated title for chat ${historyId}: ${cleanedTitle}`);
+        this.logger.log(
+          `Successfully generated title for chat ${historyId}: ${cleanedTitle}`,
+        );
         return cleanedTitle;
       }
     } catch (error) {
-      this.logger.error(`Failed to generate chat title for ${historyId}`, error);
+      this.logger.error(
+        `Failed to generate chat title for ${historyId}`,
+        error,
+      );
     }
 
     return null;

+ 3 - 6
server/src/common/constants.ts

@@ -1,6 +1,3 @@
-
-
-
 export const DEFAULT_CHUNK_SIZE = 200;
 export const MIN_CHUNK_SIZE = 50;
 export const MAX_CHUNK_SIZE = 8191;
@@ -8,13 +5,11 @@ export const DEFAULT_CHUNK_OVERLAP = 40;
 export const MIN_CHUNK_OVERLAP = 25;
 export const DEFAULT_MAX_OVERLAP_RATIO = 0.5;
 
-
 export const DEFAULT_VECTOR_DIMENSIONS = 1536;
 
 // File size limit (バイト)
 export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
 
-
 export const DEFAULT_MAX_BATCH_SIZE = 2048;
 
 // Supported languages
@@ -30,7 +25,9 @@ function getDefaultLanguage(): (typeof SUPPORTED_LANGUAGES)[number] {
   // Validate: must be one of supported languages
   if (
     envValue &&
-    SUPPORTED_LANGUAGES.includes(envValue as (typeof SUPPORTED_LANGUAGES)[number])
+    SUPPORTED_LANGUAGES.includes(
+      envValue as (typeof SUPPORTED_LANGUAGES)[number],
+    )
   ) {
     return envValue as (typeof SUPPORTED_LANGUAGES)[number];
   }

+ 60 - 9
server/src/common/file-support.constants.ts

@@ -1,13 +1,64 @@
+export const DOC_EXTENSIONS = [
+  'pdf',
+  'doc',
+  'docx',
+  'xls',
+  'xlsx',
+  'ppt',
+  'pptx',
+  'rtf',
+  'csv',
+  'txt',
+  'md',
+  'html',
+  'json',
+  'xml',
+  'odt',
+  'ods',
+  'odp',
+];
+export const CODE_EXTENSIONS = [
+  'js',
+  'jsx',
+  'ts',
+  'tsx',
+  'css',
+  'py',
+  'java',
+  'sql',
+  'cpp',
+  'h',
+  'go',
+  'rs',
+  'php',
+  'rb',
+];
+export const IMAGE_EXTENSIONS = [
+  'jpg',
+  'jpeg',
+  'png',
+  'gif',
+  'bmp',
+  'webp',
+  'tiff',
+];
+export const IMAGE_MIME_TYPES = [
+  'image/jpeg',
+  'image/png',
+  'image/gif',
+  'image/bmp',
+  'image/webp',
+  'image/tiff',
+];
 
-export const DOC_EXTENSIONS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'csv', 'txt', 'md', 'html', 'json', 'xml', 'odt', 'ods', 'odp'];
-export const CODE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx', 'css', 'py', 'java', 'sql', 'cpp', 'h', 'go', 'rs', 'php', 'rb'];
-export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff'];
-export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp', 'image/tiff'];
-
-export const ALL_ALLOWED_EXTENSIONS = [...DOC_EXTENSIONS, ...CODE_EXTENSIONS, ...IMAGE_EXTENSIONS];
+export const ALL_ALLOWED_EXTENSIONS = [
+  ...DOC_EXTENSIONS,
+  ...CODE_EXTENSIONS,
+  ...IMAGE_EXTENSIONS,
+];
 
 export const isAllowedByExtension = (filename: string): boolean => {
-    const ext = filename.toLowerCase().split('.').pop();
-    if (!ext) return false;
-    return ALL_ALLOWED_EXTENSIONS.includes(ext);
+  const ext = filename.toLowerCase().split('.').pop();
+  if (!ext) return false;
+  return ALL_ALLOWED_EXTENSIONS.includes(ext);
 };

+ 19 - 19
server/src/data-source.ts

@@ -13,23 +13,23 @@ import { Tenant } from './tenant/tenant.entity';
 import { TenantSetting } from './tenant/tenant-setting.entity';
 
 export const AppDataSource = new DataSource({
-    type: 'better-sqlite3',
-    database: './data/knowledge-base.db',
-    synchronize: false,
-    logging: true,
-    entities: [
-        User,
-        // UserSetting,
-        ModelConfig,
-        KnowledgeBase,
-        KnowledgeGroup,
-        SearchHistory,
-        ChatMessage,
-        Note,
-        PodcastEpisode,
-        ImportTask,
-        Tenant,
-        TenantSetting,
-    ],
-    migrations: ['src/migrations/**/*.ts'],
+  type: 'better-sqlite3',
+  database: './data/knowledge-base.db',
+  synchronize: false,
+  logging: true,
+  entities: [
+    User,
+    // UserSetting,
+    ModelConfig,
+    KnowledgeBase,
+    KnowledgeGroup,
+    SearchHistory,
+    ChatMessage,
+    Note,
+    PodcastEpisode,
+    ImportTask,
+    Tenant,
+    TenantSetting,
+  ],
+  migrations: ['src/migrations/**/*.ts'],
 });

+ 44 - 31
server/src/elasticsearch/elasticsearch.service.ts

@@ -1,4 +1,3 @@
-
 import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { Client } from '@elastic/elasticsearch';
@@ -63,14 +62,18 @@ export class ElasticsearchService implements OnModuleInit {
         this.logger.warn(
           `Vector dimensions ${existingDims} of index ${this.indexName} do not match the current model dimensions ${vectorDimensions}.`,
         );
-        this.logger.warn(`Reason: The embedding model might have been changed to one with different dimensions. The system will automatically recreate the index.`);
+        this.logger.warn(
+          `Reason: The embedding model might have been changed to one with different dimensions. The system will automatically recreate the index.`,
+        );
 
         // Delete existing index and recreate
         await this.client.indices.delete({ index: this.indexName });
         this.logger.log(`Successfully deleted old index: ${this.indexName}`);
 
         await this.createIndex(vectorDimensions);
-        this.logger.log(`Recreated index: ${this.indexName} (Dimensions: ${vectorDimensions})`);
+        this.logger.log(
+          `Recreated index: ${this.indexName} (Dimensions: ${vectorDimensions})`,
+        );
       } else {
         this.logger.log(
           `Index ${this.indexName} already exists. Vector dimensions: ${existingDims || 'Unknown'}`,
@@ -167,7 +170,12 @@ export class ElasticsearchService implements OnModuleInit {
     });
   }
 
-  async searchSimilar(queryVector: number[], userId: string, topK: number = 5, tenantId?: string) {
+  async searchSimilar(
+    queryVector: number[],
+    userId: string,
+    topK: number = 5,
+    tenantId?: string,
+  ) {
     try {
       this.logger.log(
         `Vector search: userId=${userId}, vectorDim=${queryVector?.length}, topK=${topK}`,
@@ -222,7 +230,12 @@ export class ElasticsearchService implements OnModuleInit {
     }
   }
 
-  async searchFullText(query: string, userId: string, topK: number = 5, tenantId?: string) {
+  async searchFullText(
+    query: string,
+    userId: string,
+    topK: number = 5,
+    tenantId?: string,
+  ) {
     try {
       this.logger.log(
         `Full-text search: userId=${userId}, query="${query}", topK=${topK}`,
@@ -305,9 +318,14 @@ export class ElasticsearchService implements OnModuleInit {
       this.logger.log(`Final search target scope: ${fileIds.length} files`);
     }
 
-    
     const [vectorResults, textResults] = await Promise.all([
-      this.searchSimilarWithFileFilter(queryVector, userId, topK, fileIds, tenantId),
+      this.searchSimilarWithFileFilter(
+        queryVector,
+        userId,
+        topK,
+        fileIds,
+        tenantId,
+      ),
       this.searchFullTextWithFileFilter(query, userId, topK, fileIds, tenantId),
     ]);
 
@@ -343,20 +361,18 @@ export class ElasticsearchService implements OnModuleInit {
     });
 
     // 正規化forにすべての組み合わせスコアを取得
-    const allScores = Array.from(combinedResults.values()).map(r => r.combinedScore);
-    const maxScore = Math.max(...allScores, 1); 
+    const allScores = Array.from(combinedResults.values()).map(
+      (r) => r.combinedScore,
+    );
+    const maxScore = Math.max(...allScores, 1);
     const minScore = Math.min(...allScores);
 
-    
     return Array.from(combinedResults.values())
       .sort((a, b) => b.combinedScore - a.combinedScore)
       .slice(0, topK)
       .map((result) => {
-        
-        
         let finalScore = result.combinedScore;
 
-        
         finalScore = Math.max(0, Math.min(1.0, finalScore));
 
         return {
@@ -376,30 +392,27 @@ export class ElasticsearchService implements OnModuleInit {
   private async createIndex(vectorDimensions: number) {
     const mappings: any = {
       properties: {
-        
         content: {
           type: 'text',
           analyzer: 'standard',
         },
-        
+
         vector: {
           type: 'dense_vector',
           dims: vectorDimensions,
           index: true,
           similarity: 'cosine',
         },
-        
+
         fileId: { type: 'keyword' },
         fileName: { type: 'keyword' },
         title: { type: 'text' },
         fileMimeType: { type: 'keyword' },
 
-        
         chunkIndex: { type: 'integer' },
         startPosition: { type: 'integer' },
         endPosition: { type: 'integer' },
 
-        
         userId: { type: 'keyword' },
 
         // テナント情報(マルチテナント分離用)
@@ -420,16 +433,12 @@ export class ElasticsearchService implements OnModuleInit {
     );
   }
 
-  
   private normalizeScore(rawScore: number): number {
-    if (!rawScore || rawScore <= 0) return 0; 
+    if (!rawScore || rawScore <= 0) return 0;
 
-    
-    
     return Math.min(1.0, rawScore);
   }
 
-  
   private async searchSimilarWithFileFilter(
     queryVector: number[],
     userId: string,
@@ -448,7 +457,9 @@ export class ElasticsearchService implements OnModuleInit {
       }
 
       if (fileIds && fileIds.length === 0) {
-        this.logger.log('Filter resulted in 0 files, returning empty results for vector search');
+        this.logger.log(
+          'Filter resulted in 0 files, returning empty results for vector search',
+        );
         return [];
       }
 
@@ -464,9 +475,10 @@ export class ElasticsearchService implements OnModuleInit {
         filterClauses.push({ term: { userId } });
       }
 
-      const filter = filterClauses.length > 0
-        ? { bool: { must: filterClauses } }
-        : undefined;
+      const filter =
+        filterClauses.length > 0
+          ? { bool: { must: filterClauses } }
+          : undefined;
 
       const queryBody: any = {
         index: this.indexName,
@@ -510,7 +522,6 @@ export class ElasticsearchService implements OnModuleInit {
     }
   }
 
-  
   /**
    * Performs full-text search with file filtering.
    */
@@ -532,7 +543,9 @@ export class ElasticsearchService implements OnModuleInit {
       }
 
       if (fileIds && fileIds.length === 0) {
-        this.logger.log('Filter resulted in 0 files, returning empty results for full-text search');
+        this.logger.log(
+          'Filter resulted in 0 files, returning empty results for full-text search',
+        );
         return [];
       }
 
@@ -615,9 +628,9 @@ export class ElasticsearchService implements OnModuleInit {
           bool: { filter },
         },
         sort: [{ chunkIndex: 'asc' }],
-        size: 10000, 
+        size: 10000,
         _source: {
-          excludes: ['vector'], 
+          excludes: ['vector'],
         },
       });
 

+ 36 - 0
server/src/feishu/dto/assessment-command.dto.ts

@@ -0,0 +1,36 @@
+/**
+ * Assessment Command DTO
+ * 定义飞书机器人测评命令的类型和接口
+ */
+
+export enum AssessmentCommandType {
+  START = 'start',
+  ANSWER = 'answer',
+  STATUS = 'status',
+  RESULT = 'result',
+  HELP = 'help',
+  CANCEL = 'cancel',
+}
+
+export interface AssessmentCommand {
+  type: AssessmentCommandType;
+  parameters: string[];
+  rawMessage: string;
+  timestamp: Date;
+}
+
+export class AssessmentCommandDto {
+  type: AssessmentCommandType;
+  parameters: string[];
+  rawMessage: string;
+
+  constructor(
+    type: AssessmentCommandType,
+    parameters: string[],
+    rawMessage: string,
+  ) {
+    this.type = type;
+    this.parameters = parameters;
+    this.rawMessage = rawMessage;
+  }
+}

+ 6 - 6
server/src/feishu/dto/bind-bot.dto.ts

@@ -1,11 +1,11 @@
 import { IsString, IsNotEmpty, IsUUID } from 'class-validator';
 
 export class BindFeishuBotDto {
-    @IsUUID()
-    @IsNotEmpty()
-    botId: string;
+  @IsUUID()
+  @IsNotEmpty()
+  botId: string;
 
-    @IsString()
-    @IsNotEmpty()
-    verificationCode?: string; // Optional: used to validate the binding relationship
+  @IsString()
+  @IsNotEmpty()
+  verificationCode?: string; // Optional: used to validate the binding relationship
 }

+ 26 - 18
server/src/feishu/dto/create-bot.dto.ts

@@ -1,27 +1,35 @@
 import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator';
 
 export class CreateFeishuBotDto {
-    @IsString()
-    @IsNotEmpty()
-    appId: string;
+  @IsString()
+  @IsNotEmpty()
+  appId: string;
 
-    @IsString()
-    @IsNotEmpty()
-    appSecret: string;
+  @IsString()
+  @IsNotEmpty()
+  appSecret: string;
 
-    @IsString()
-    @IsOptional()
-    verificationToken?: string;
+  @IsString()
+  @IsOptional()
+  verificationToken?: string;
 
-    @IsString()
-    @IsOptional()
-    encryptKey?: string;
+  @IsString()
+  @IsOptional()
+  encryptKey?: string;
 
-    @IsString()
-    @IsOptional()
-    botName?: string;
+  @IsString()
+  @IsOptional()
+  botName?: string;
 
-    @IsBoolean()
-    @IsOptional()
-    enabled?: boolean;
+  @IsBoolean()
+  @IsOptional()
+  enabled?: boolean;
+
+  @IsString()
+  @IsOptional()
+  knowledgeBaseId?: string;
+
+  @IsString()
+  @IsOptional()
+  knowledgeGroupId?: string;
 }

+ 11 - 11
server/src/feishu/dto/webhook.dto.ts

@@ -1,20 +1,20 @@
 import { IsString, IsOptional } from 'class-validator';
 
 export class CreateSignatureDto {
-    @IsString()
-    @IsOptional()
-    timestamp?: string;
+  @IsString()
+  @IsOptional()
+  timestamp?: string;
 
-    @IsString()
-    @IsOptional()
-    nonce?: string;
+  @IsString()
+  @IsOptional()
+  nonce?: string;
 }
 
 export class VerifyWebhookDto {
-    @IsString()
-    token: string;
+  @IsString()
+  token: string;
 
-    @IsString()
-    @IsOptional()
-    challenge?: string;
+  @IsString()
+  @IsOptional()
+  challenge?: string;
 }

+ 46 - 0
server/src/feishu/entities/feishu-assessment-session.entity.ts

@@ -0,0 +1,46 @@
+import {
+  Entity,
+  PrimaryGeneratedColumn,
+  Column,
+  CreateDateColumn,
+  UpdateDateColumn,
+  ManyToOne,
+  JoinColumn,
+} from 'typeorm';
+import { FeishuBot } from './feishu-bot.entity';
+
+@Entity('feishu_assessment_sessions')
+export class FeishuAssessmentSession {
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
+
+  @Column({ name: 'bot_id' })
+  botId: string;
+
+  @Column({ name: 'open_id' })
+  openId: string;
+
+  @Column({ name: 'assessment_session_id' })
+  assessmentSessionId: string;
+
+  @Column({
+    type: 'varchar',
+    enum: ['active', 'completed', 'cancelled'],
+    default: 'active',
+  })
+  status: 'active' | 'completed' | 'cancelled';
+
+  @Column({ name: 'current_question_index', default: 0 })
+  currentQuestionIndex: number;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt: Date;
+
+  // 关联关系
+  @ManyToOne(() => FeishuBot, { onDelete: 'CASCADE' })
+  @JoinColumn({ name: 'bot_id' })
+  bot: FeishuBot;
+}

+ 48 - 42
server/src/feishu/entities/feishu-bot.entity.ts

@@ -1,65 +1,71 @@
 import {
-    Entity,
-    PrimaryGeneratedColumn,
-    Column,
-    CreateDateColumn,
-    UpdateDateColumn,
-    ManyToOne,
-    JoinColumn,
+  Entity,
+  PrimaryGeneratedColumn,
+  Column,
+  CreateDateColumn,
+  UpdateDateColumn,
+  ManyToOne,
+  JoinColumn,
 } from 'typeorm';
 import { User } from '../../user/user.entity';
 
 @Entity('feishu_bots')
 export class FeishuBot {
-    @PrimaryGeneratedColumn('uuid')
-    id: string;
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
 
-    @Column({ name: 'user_id' })
-    userId: string;
+  @Column({ name: 'user_id' })
+  userId: string;
 
-    @ManyToOne(() => User, { onDelete: 'CASCADE' })
-    @JoinColumn({ name: 'user_id' })
-    user: User;
+  @ManyToOne(() => User, { onDelete: 'CASCADE' })
+  @JoinColumn({ name: 'user_id' })
+  user: User;
 
-    @Column({ name: 'app_id', length: 64 })
-    appId: string;
+  @Column({ name: 'app_id', length: 64 })
+  appId: string;
 
-    @Column({ name: 'app_secret', length: 256 })
-    appSecret: string;
+  @Column({ name: 'app_secret', length: 256 })
+  appSecret: string;
 
-    @Column({ name: 'tenant_access_token', nullable: true, type: 'text' })
-    tenantAccessToken: string;
+  @Column({ name: 'tenant_access_token', nullable: true, type: 'text' })
+  tenantAccessToken: string;
 
-    @Column({ name: 'token_expires_at', nullable: true, type: 'datetime' })
-    tokenExpiresAt: Date;
+  @Column({ name: 'token_expires_at', nullable: true, type: 'datetime' })
+  tokenExpiresAt: Date;
 
-    @Column({ name: 'verification_token', nullable: true, length: 128 })
-    verificationToken: string;
+  @Column({ name: 'verification_token', nullable: true, length: 128 })
+  verificationToken: string;
 
-    @Column({ name: 'encrypt_key', nullable: true, length: 256 })
-    encryptKey: string;
+  @Column({ name: 'encrypt_key', nullable: true, length: 256 })
+  encryptKey: string;
 
-    @Column({ name: 'bot_name', nullable: true, length: 128 })
-    botName: string;
+  @Column({ name: 'bot_name', nullable: true, length: 128 })
+  botName: string;
 
-    @Column({ default: true })
-    enabled: boolean;
+  @Column({ default: true })
+  enabled: boolean;
 
-    @Column({ name: 'is_default', default: false })
-    isDefault: boolean;
+  @Column({ name: 'is_default', default: false })
+  isDefault: boolean;
 
-    @Column({ name: 'webhook_url', nullable: true, type: 'text' })
-    webhookUrl: string;
+  @Column({ name: 'webhook_url', nullable: true, type: 'text' })
+  webhookUrl: string;
 
-    @Column({ name: 'use_web_socket', default: false })
-    useWebSocket: boolean;
+  @Column({ name: 'use_web_socket', default: false })
+  useWebSocket: boolean;
 
-    @Column({ name: 'ws_connection_state', nullable: true, length: 32 })
-    wsConnectionState: string;
+  @Column({ name: 'ws_connection_state', nullable: true, length: 32 })
+  wsConnectionState: string;
 
-    @CreateDateColumn({ name: 'created_at' })
-    createdAt: Date;
+  @Column({ name: 'knowledge_base_id', nullable: true, length: 36 })
+  knowledgeBaseId: string;
 
-    @UpdateDateColumn({ name: 'updated_at' })
-    updatedAt: Date;
+  @Column({ name: 'knowledge_group_id', nullable: true, length: 36 })
+  knowledgeGroupId: string;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt: Date;
 }

+ 26 - 7
server/src/feishu/feishu-ws.manager.ts

@@ -1,5 +1,9 @@
 import { Injectable, Logger } from '@nestjs/common';
-import { WSClient, EventDispatcher, LoggerLevel } from '@larksuiteoapi/node-sdk';
+import {
+  WSClient,
+  EventDispatcher,
+  LoggerLevel,
+} from '@larksuiteoapi/node-sdk';
 import { FeishuBot } from './entities/feishu-bot.entity';
 import { ConnectionState, ConnectionStatus } from './dto/ws-status.dto';
 
@@ -31,7 +35,11 @@ export class FeishuWsManager {
 
     // Check if already connected or connecting
     const existing = this.connections.get(botId);
-    if (existing && (existing.status.state === ConnectionState.CONNECTED || existing.status.state === ConnectionState.CONNECTING)) {
+    if (
+      existing &&
+      (existing.status.state === ConnectionState.CONNECTED ||
+        existing.status.state === ConnectionState.CONNECTING)
+    ) {
       this.logger.warn(`Bot ${botId} is already connecting or connected`);
       return;
     }
@@ -39,7 +47,7 @@ export class FeishuWsManager {
     // Mark as connecting immediately to prevent race conditions
     this.connections.set(botId, {
       client: null as any,
-      status: { botId, state: ConnectionState.CONNECTING }
+      status: { botId, state: ConnectionState.CONNECTING },
     });
 
     try {
@@ -128,7 +136,9 @@ export class FeishuWsManager {
    * Returns true if the bot has an active connection
    */
   isConnected(botId: string): boolean {
-    return this.connections.get(botId)?.status.state === ConnectionState.CONNECTED;
+    return (
+      this.connections.get(botId)?.status.state === ConnectionState.CONNECTED
+    );
   }
 
   // ─── Private Helpers ──────────────────────────────────────────────────────
@@ -177,7 +187,12 @@ export class FeishuWsManager {
       if (!userText.trim()) return;
 
       if (this._feishuService) {
-        await this._feishuService.processChatMessage(bot, openId, messageId, userText);
+        await this._feishuService.handleIncomingMessage(
+          bot,
+          openId,
+          messageId,
+          userText,
+        );
       } else {
         this.logger.error('FeishuService not injected into FeishuWsManager');
       }
@@ -203,8 +218,12 @@ export class FeishuWsManager {
       return;
     }
 
-    const delay = this.RECONNECT_DELAYS[attempts] ?? this.RECONNECT_DELAYS[this.RECONNECT_DELAYS.length - 1];
-    this.logger.log(`Reconnecting bot ${botId} in ${delay}ms (attempt ${attempts + 1})`);
+    const delay =
+      this.RECONNECT_DELAYS[attempts] ??
+      this.RECONNECT_DELAYS[this.RECONNECT_DELAYS.length - 1];
+    this.logger.log(
+      `Reconnecting bot ${botId} in ${delay}ms (attempt ${attempts + 1})`,
+    );
     this.reconnectAttempts.set(botId, attempts + 1);
 
     setTimeout(async () => {

+ 237 - 224
server/src/feishu/feishu.controller.ts

@@ -1,19 +1,20 @@
 import {
-    Controller,
-    Post,
-    Get,
-    Delete,
-    Body,
-    Param,
-    Headers,
-    UseGuards,
-    Request,
-    Logger,
-    Patch,
-    Res,
+  Controller,
+  Post,
+  Get,
+  Delete,
+  Body,
+  Param,
+  Headers,
+  UseGuards,
+  Request,
+  Logger,
+  Patch,
+  Res,
 } from '@nestjs/common';
 import { Response } from 'express';
 import { FeishuService } from './feishu.service';
+import { FeishuAssessmentService } from './services/feishu-assessment.service';
 import { CreateFeishuBotDto } from './dto/create-bot.dto';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { Public } from '../auth/public.decorator';
@@ -22,250 +23,262 @@ import * as path from 'path';
 
 @Controller('feishu')
 export class FeishuController {
-    private readonly logger = new Logger(FeishuController.name);
+  private readonly logger = new Logger(FeishuController.name);
 
-    constructor(private readonly feishuService: FeishuService) {}
+  constructor(
+    private readonly feishuService: FeishuService,
+    private readonly feishuAssessmentService: FeishuAssessmentService,
+  ) {}
 
-    // ─── Bot Management Endpoints (JWT-protected) ─────────────────────────────
+  // ─── Bot Management Endpoints (JWT-protected) ─────────────────────────────
 
-    /** GET /feishu/bots - List user's bots, masking sensitive fields */
-    @Get('bots')
-    @UseGuards(CombinedAuthGuard)
-    async listBots(@Request() req) {
-        const bots = await this.feishuService.getUserBots(req.user.id);
-        return bots.map((bot) => ({
-            id: bot.id,
-            appId: bot.appId,
-            botName: bot.botName,
-            enabled: bot.enabled,
-            isDefault: bot.isDefault,
-            webhookUrl: `/api/feishu/webhook/${bot.appId}`,
-            createdAt: bot.createdAt,
-        }));
-    }
+  /** GET /feishu/bots - List user's bots, masking sensitive fields */
+  @Get('bots')
+  @UseGuards(CombinedAuthGuard)
+  async listBots(@Request() req) {
+    const bots = await this.feishuService.getUserBots(req.user.id);
+    return bots.map((bot) => ({
+      id: bot.id,
+      appId: bot.appId,
+      botName: bot.botName,
+      enabled: bot.enabled,
+      isDefault: bot.isDefault,
+      webhookUrl: `/api/feishu/webhook/${bot.appId}`,
+      createdAt: bot.createdAt,
+    }));
+  }
 
-    /** POST /feishu/bots - Create or update a bot */
-    @Post('bots')
-    @UseGuards(CombinedAuthGuard)
-    async createBot(@Request() req, @Body() dto: CreateFeishuBotDto) {
-        const bot = await this.feishuService.createBot(req.user.id, dto);
-        return {
-            id: bot.id,
-            appId: bot.appId,
-            botName: bot.botName,
-            enabled: bot.enabled,
-            webhookUrl: `/api/feishu/webhook/${bot.appId}`,
-        };
-    }
+  /** POST /feishu/bots - Create or update a bot */
+  @Post('bots')
+  @UseGuards(CombinedAuthGuard)
+  async createBot(@Request() req, @Body() dto: CreateFeishuBotDto) {
+    const bot = await this.feishuService.createBot(req.user.id, dto);
+    return {
+      id: bot.id,
+      appId: bot.appId,
+      botName: bot.botName,
+      enabled: bot.enabled,
+      webhookUrl: `/api/feishu/webhook/${bot.appId}`,
+    };
+  }
 
-    /** PATCH /feishu/bots/:id/toggle - Enable or disable a bot */
-    @Patch('bots/:id/toggle')
-    @UseGuards(CombinedAuthGuard)
-    async toggleBot(
-        @Request() req,
-        @Param('id') botId: string,
-        @Body() body: { enabled: boolean },
-    ) {
-        const bot = await this.feishuService.setBotEnabled(botId, body.enabled);
-        return { id: bot.id, enabled: bot.enabled };
-    }
+  /** PATCH /feishu/bots/:id/toggle - Enable or disable a bot */
+  @Patch('bots/:id/toggle')
+  @UseGuards(CombinedAuthGuard)
+  async toggleBot(
+    @Request() req,
+    @Param('id') botId: string,
+    @Body() body: { enabled: boolean },
+  ) {
+    const bot = await this.feishuService.setBotEnabled(botId, body.enabled);
+    return { id: bot.id, enabled: bot.enabled };
+  }
 
-    /** DELETE /feishu/bots/:id - Delete a bot */
-    @Delete('bots/:id')
-    @UseGuards(CombinedAuthGuard)
-    async deleteBot(@Request() req, @Param('id') botId: string) {
-        await this.feishuService.deleteBot(req.user.id, botId);
-        return { success: true };
-    }
+  /** DELETE /feishu/bots/:id - Delete a bot */
+  @Delete('bots/:id')
+  @UseGuards(CombinedAuthGuard)
+  async deleteBot(@Request() req, @Param('id') botId: string) {
+    await this.feishuService.deleteBot(req.user.id, botId);
+    return { success: true };
+  }
 
-    // ─── WebSocket Management Endpoints ────────────────────────────────────
+  // ─── WebSocket Management Endpoints ────────────────────────────────────
 
-    /** POST /feishu/bots/:id/ws/connect - Start WebSocket connection */
-    @Post('bots/:id/ws/connect')
-    @UseGuards(CombinedAuthGuard)
-    async connectWs(@Request() req, @Param('id') botId: string) {
-        const bot = await this.feishuService.getBotById(botId);
-        if (!bot || bot.userId !== req.user.id) {
-            return { success: false, error: 'Bot not found' };
-        }
-        try {
-            await this.feishuService.startWsConnection(botId);
-            return { success: true, botId, status: 'connecting' };
-        } catch (error: any) {
-            return { success: false, botId, error: error?.message || 'Failed to connect' };
-        }
+  /** POST /feishu/bots/:id/ws/connect - Start WebSocket connection */
+  @Post('bots/:id/ws/connect')
+  @UseGuards(CombinedAuthGuard)
+  async connectWs(@Request() req, @Param('id') botId: string) {
+    const bot = await this.feishuService.getBotById(botId);
+    if (!bot || bot.userId !== req.user.id) {
+      return { success: false, error: 'Bot not found' };
     }
-
-    /** POST /feishu/bots/:id/ws/disconnect - Stop WebSocket connection */
-    @Post('bots/:id/ws/disconnect')
-    @UseGuards(CombinedAuthGuard)
-    async disconnectWs(@Request() req, @Param('id') botId: string) {
-        const bot = await this.feishuService.getBotById(botId);
-        if (!bot || bot.userId !== req.user.id) {
-            return { success: false, error: 'Bot not found' };
-        }
-        try {
-            await this.feishuService.stopWsConnection(botId);
-            return { success: true, botId, status: 'disconnected' };
-        } catch (error: any) {
-            return { success: false, botId, error: error?.message || 'Failed to disconnect' };
-        }
+    try {
+      await this.feishuService.startWsConnection(botId);
+      return { success: true, botId, status: 'connecting' };
+    } catch (error: any) {
+      return {
+        success: false,
+        botId,
+        error: error?.message || 'Failed to connect',
+      };
     }
+  }
 
-    /** GET /feishu/bots/:id/ws/status - Get connection status */
-    @Get('bots/:id/ws/status')
-    @UseGuards(CombinedAuthGuard)
-    async getWsStatus(@Request() req, @Param('id') botId: string) {
-        const bot = await this.feishuService.getBotById(botId);
-        if (!bot || bot.userId !== req.user.id) {
-            return { success: false, error: 'Bot not found' };
-        }
-        const status = await this.feishuService.getWsStatus(botId);
-        if (!status) {
-            return { botId, state: 'disconnected' };
-        }
-        return {
-            botId: status.botId,
-            state: status.state,
-            connectedAt: status.connectedAt?.toISOString(),
-            lastHeartbeat: status.lastHeartbeat?.toISOString(),
-            error: status.error,
-        };
+  /** POST /feishu/bots/:id/ws/disconnect - Stop WebSocket connection */
+  @Post('bots/:id/ws/disconnect')
+  @UseGuards(CombinedAuthGuard)
+  async disconnectWs(@Request() req, @Param('id') botId: string) {
+    const bot = await this.feishuService.getBotById(botId);
+    if (!bot || bot.userId !== req.user.id) {
+      return { success: false, error: 'Bot not found' };
+    }
+    try {
+      await this.feishuService.stopWsConnection(botId);
+      return { success: true, botId, status: 'disconnected' };
+    } catch (error: any) {
+      return {
+        success: false,
+        botId,
+        error: error?.message || 'Failed to disconnect',
+      };
     }
+  }
 
-    /** GET /feishu/ws/status - Get all active WS connection statuses */
-    @Get('ws/status')
-    @UseGuards(CombinedAuthGuard)
-    async getAllWsStatus() {
-        const statuses = await this.feishuService.getAllWsStatuses();
-        return {
-            connections: statuses.map((s) => ({
-                botId: s.botId,
-                state: s.state,
-                connectedAt: s.connectedAt?.toISOString(),
-                lastHeartbeat: s.lastHeartbeat?.toISOString(),
-                error: s.error,
-            })),
-        };
+  /** GET /feishu/bots/:id/ws/status - Get connection status */
+  @Get('bots/:id/ws/status')
+  @UseGuards(CombinedAuthGuard)
+  async getWsStatus(@Request() req, @Param('id') botId: string) {
+    const bot = await this.feishuService.getBotById(botId);
+    if (!bot || bot.userId !== req.user.id) {
+      return { success: false, error: 'Bot not found' };
+    }
+    const status = await this.feishuService.getWsStatus(botId);
+    if (!status) {
+      return { botId, state: 'disconnected' };
     }
+    return {
+      botId: status.botId,
+      state: status.state,
+      connectedAt: status.connectedAt?.toISOString(),
+      lastHeartbeat: status.lastHeartbeat?.toISOString(),
+      error: status.error,
+    };
+  }
 
-    // ─── Feishu Webhook Endpoint (Public) ────────────────────────────────────
-    @Get('webhook/:appId')
-    @Post('webhook/:appId')
-    @Public()
-    async handleWebhook(
-        @Param('appId') appId: string,
-        @Body() body: any,
-        @Headers() headers: any,
-        @Request() req: any,
-        @Res() res: Response,
-    ) {
-        const logEntry = `\n[${new Date().toISOString()}] ${req.method} /api/feishu/webhook/${appId}\nHeaders: ${JSON.stringify(headers)}\nBody: ${JSON.stringify(body)}\n`;
-        fs.appendFileSync('feishu_webhook.log', logEntry);
+  /** GET /feishu/ws/status - Get all active WS connection statuses */
+  @Get('ws/status')
+  @UseGuards(CombinedAuthGuard)
+  async getAllWsStatus() {
+    const statuses = await this.feishuService.getAllWsStatuses();
+    return {
+      connections: statuses.map((s) => ({
+        botId: s.botId,
+        state: s.state,
+        connectedAt: s.connectedAt?.toISOString(),
+        lastHeartbeat: s.lastHeartbeat?.toISOString(),
+        error: s.error,
+      })),
+    };
+  }
 
-        this.logger.log(`Incoming Feishu webhook [${req.method}] for appId: ${appId}`);
-        
-        // GET request for simple connection test
-        if (req.method === 'GET') {
-            return res.status(200).json({ 
-                status: 'ok', 
-                message: 'AuraK Feishu Webhook is active.',
-                appId,
-                timestamp: new Date().toISOString()
-            });
-        }
+  // ─── Feishu Webhook Endpoint (Public) ────────────────────────────────────
+  @Get('webhook/:appId')
+  @Post('webhook/:appId')
+  @Public()
+  async handleWebhook(
+    @Param('appId') appId: string,
+    @Body() body: any,
+    @Headers() headers: any,
+    @Request() req: any,
+    @Res() res: Response,
+  ) {
+    const logEntry = `\n[${new Date().toISOString()}] ${req.method} /api/feishu/webhook/${appId}\nHeaders: ${JSON.stringify(headers)}\nBody: ${JSON.stringify(body)}\n`;
+    fs.appendFileSync('feishu_webhook.log', logEntry);
 
-        // Step 1: URL verification handshake
-        const challenge = body?.challenge || body?.event?.challenge;
-        if (body?.type === 'url_verification' || challenge) {
-            this.logger.log(`URL verification active for appId: ${appId}, challenge: ${challenge}`);
-            return res.status(200).json({ challenge });
-        }
+    this.logger.log(
+      `Incoming Feishu webhook [${req.method}] for appId: ${appId}`,
+    );
 
-        // Step 2: Return 200 immediately for all other events
-        res.status(200).json({ success: true });
+    // GET request for simple connection test
+    if (req.method === 'GET') {
+      return res.status(200).json({
+        status: 'ok',
+        message: 'AuraK Feishu Webhook is active.',
+        appId,
+        timestamp: new Date().toISOString(),
+      });
+    }
 
-        // Step 3: Process the event asynchronously
-        if (body?.type === 'event_callback' || body?.header?.event_type) {
-            setImmediate(() =>
-                this._processEvent(appId, body).catch((e) =>
-                    this.logger.error('Failed to process Feishu event async', e),
-                ),
-            );
-        }
+    // Step 1: URL verification handshake
+    const challenge = body?.challenge || body?.event?.challenge;
+    if (body?.type === 'url_verification' || challenge) {
+      this.logger.log(
+        `URL verification active for appId: ${appId}, challenge: ${challenge}`,
+      );
+      return res.status(200).json({ challenge });
     }
 
-    // ─── Private Event Processor ─────────────────────────────────────────────
+    // Step 2: Return 200 immediately for all other events
+    res.status(200).json({ success: true });
+
+    // Step 3: Process the event asynchronously
+    if (body?.type === 'event_callback' || body?.header?.event_type) {
+      setImmediate(() =>
+        this._processEvent(appId, body).catch((e) =>
+          this.logger.error('Failed to process Feishu event async', e),
+        ),
+      );
+    }
+  }
 
-    private async _processEvent(appId: string, body: any): Promise<void> {
-        const { type, event, header } = body;
+  // ─── Private Event Processor ─────────────────────────────────────────────
 
-        if (type !== 'event_callback') return;
+  private async _processEvent(appId: string, body: any): Promise<void> {
+    const { type, event, header } = body;
 
-        const eventType = header?.event_type || body.event_type;
-        const eventId = header?.event_id;
-        this.logger.log(`Processing Feishu event [${eventId}]: ${eventType} for appId: ${appId}`);
+    if (type !== 'event_callback') return;
 
-        const bot = await this.feishuService.getBotByAppId(appId);
-        if (!bot || !bot.enabled) {
-            this.logger.warn(`Bot not found or disabled for appId: ${appId}`);
-            return;
-        }
+    const eventType = header?.event_type || body.event_type;
+    const eventId = header?.event_id;
+    this.logger.log(
+      `Processing Feishu event [${eventId}]: ${eventType} for appId: ${appId}`,
+    );
 
-        switch (eventType) {
-            case 'im.message.receive_v1':
-            case 'im.message.p2p_msg_received':
-            case 'im.message.group_at_msg_received':
-                await this._handleMessage(bot, event);
-                break;
-            default:
-                this.logger.log(`Unhandled event type: ${eventType}`);
-        }
+    const bot = await this.feishuService.getBotByAppId(appId);
+    if (!bot || !bot.enabled) {
+      this.logger.warn(`Bot not found or disabled for appId: ${appId}`);
+      return;
     }
 
-    /**
-     * Parse incoming IM message and route to chatService via FeishuService.
-     * Implements Chunk 5 integration.
-     */
-    private async _handleMessage(bot: any, event: any): Promise<void> {
-        const message = event?.message;
-        if (!message) return;
+    switch (eventType) {
+      case 'im.message.receive_v1':
+      case 'im.message.p2p_msg_received':
+      case 'im.message.group_at_msg_received':
+        await this._handleMessage(bot, event);
+        break;
+      default:
+        this.logger.log(`Unhandled event type: ${eventType}`);
+    }
+  }
 
-        const messageId = message.message_id;
-        const openId = event?.sender?.sender_id?.open_id;
+  /**
+   * Parse incoming IM message and route to chatService via FeishuService.
+   * Implements Chunk 5 integration.
+   */
+  private async _handleMessage(bot: any, event: any): Promise<void> {
+    const message = event?.message;
+    if (!message) return;
 
-        if (!openId) {
-            this.logger.warn('No sender open_id found in Feishu event');
-            return;
-        }
+    const messageId = message.message_id;
+    const openId = event?.sender?.sender_id?.open_id;
 
-        // Parse text content
-        let userText = '';
-        try {
-            const content = JSON.parse(message.content || '{}');
-            userText = content.text || '';
-        } catch {
-            this.logger.warn('Failed to parse Feishu message content');
-            return;
-        }
+    if (!openId) {
+      this.logger.warn('No sender open_id found in Feishu event');
+      return;
+    }
+
+    // Parse text content
+    let userText = '';
+    try {
+      const content = JSON.parse(message.content || '{}');
+      userText = content.text || '';
+    } catch {
+      this.logger.warn('Failed to parse Feishu message content');
+      return;
+    }
 
-        if (!userText.trim()) return;
+    if (!userText.trim()) return;
 
-        try {
-            // Delegate to FeishuService which calls ChatService RAG pipeline (Chunk 5)
-            await this.feishuService.processChatMessage(bot, openId, messageId, userText);
-        } catch (error) {
-            this.logger.error('processChatMessage failed', error);
-            try {
-                await this.feishuService.sendTextMessage(
-                    bot,
-                    'open_id',
-                    openId,
-                    '抱歉,处理您的消息时遇到了错误,请稍后重试。',
-                );
-            } catch (sendError) {
-                this.logger.error('Failed to send error message to Feishu', sendError);
-            }
-        }
+    try {
+      // Centralized routing via FeishuService
+      await this.feishuService.handleIncomingMessage(
+        bot,
+        openId,
+        messageId,
+        userText,
+      );
+    } catch (error) {
+      this.logger.error('Message handling failed', error);
     }
+  }
 }

+ 19 - 10
server/src/feishu/feishu.module.ts

@@ -3,21 +3,30 @@ import { TypeOrmModule } from '@nestjs/typeorm';
 import { FeishuController } from './feishu.controller';
 import { FeishuService } from './feishu.service';
 import { FeishuBot } from './entities/feishu-bot.entity';
+import { FeishuAssessmentSession } from './entities/feishu-assessment-session.entity';
 import { FeishuWsManager } from './feishu-ws.manager';
+import { FeishuAssessmentService } from './services/feishu-assessment.service';
+import { AssessmentCommandParser } from './services/assessment-command.parser';
 import { ChatModule } from '../chat/chat.module';
 import { UserModule } from '../user/user.module';
 import { ModelConfigModule } from '../model-config/model-config.module';
+import { AssessmentModule } from '../assessment/assessment.module';
 
 @Module({
-    imports: [
-        TypeOrmModule.forFeature([FeishuBot]),
-        forwardRef(() => ChatModule),
-        forwardRef(() => UserModule),
-        forwardRef(() => ModelConfigModule),
-    ],
-    controllers: [FeishuController],
-    providers: [FeishuService, FeishuWsManager],
-    exports: [FeishuService, TypeOrmModule],
+  imports: [
+    TypeOrmModule.forFeature([FeishuBot, FeishuAssessmentSession]),
+    forwardRef(() => ChatModule),
+    forwardRef(() => UserModule),
+    forwardRef(() => ModelConfigModule),
+    forwardRef(() => AssessmentModule),
+  ],
+  controllers: [FeishuController],
+  providers: [
+    FeishuService,
+    FeishuWsManager,
+    FeishuAssessmentService,
+    AssessmentCommandParser,
+  ],
+  exports: [FeishuService, FeishuAssessmentService, TypeOrmModule],
 })
 export class FeishuModule {}
-

+ 509 - 356
server/src/feishu/feishu.service.ts

@@ -1,4 +1,10 @@
-import { Injectable, Logger, forwardRef, Inject, OnModuleInit } from '@nestjs/common';
+import {
+  Injectable,
+  Logger,
+  forwardRef,
+  Inject,
+  OnModuleInit,
+} from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import axios from 'axios';
@@ -10,392 +16,539 @@ import { UserService } from '../user/user.service';
 import { ModelType } from '../types';
 import { FeishuWsManager } from './feishu-ws.manager';
 import { ConnectionStatus } from './dto/ws-status.dto';
+import { FeishuAssessmentService } from './services/feishu-assessment.service';
 
 @Injectable()
 export class FeishuService implements OnModuleInit {
-    private readonly logger = new Logger(FeishuService.name);
-    private readonly feishuApiBase = 'https://open.feishu.cn/open-apis';
-
-    constructor(
-        @InjectRepository(FeishuBot)
-        private botRepository: Repository<FeishuBot>,
-        @Inject(forwardRef(() => ChatService))
-        private chatService: ChatService,
-        @Inject(forwardRef(() => ModelConfigService))
-        private modelConfigService: ModelConfigService,
-        @Inject(forwardRef(() => UserService))
-        private userService: UserService,
-        private wsManager: FeishuWsManager,
-    ) {}
-
-    onModuleInit(): void {
-        // Break circular dep: inject self into manager after module is ready
-        this.wsManager.setFeishuService(this);
+  private readonly logger = new Logger(FeishuService.name);
+  private readonly feishuApiBase = 'https://open.feishu.cn/open-apis';
+
+  constructor(
+    @InjectRepository(FeishuBot)
+    private botRepository: Repository<FeishuBot>,
+    @Inject(forwardRef(() => ChatService))
+    private chatService: ChatService,
+    @Inject(forwardRef(() => ModelConfigService))
+    private modelConfigService: ModelConfigService,
+    @Inject(forwardRef(() => UserService))
+    private userService: UserService,
+    private wsManager: FeishuWsManager,
+    @Inject(forwardRef(() => FeishuAssessmentService))
+    private feishuAssessmentService: FeishuAssessmentService,
+  ) {}
+
+  onModuleInit(): void {
+    // Break circular dep: inject self into manager after module is ready
+    this.wsManager.setFeishuService(this);
+  }
+
+  // ─── Bot CRUD ────────────────────────────────────────────────────────────────
+
+  async createBot(userId: string, dto: CreateFeishuBotDto): Promise<FeishuBot> {
+    const existing = await this.botRepository.findOne({
+      where: { userId, appId: dto.appId },
+    });
+
+    if (existing) {
+      Object.assign(existing, dto);
+      return this.botRepository.save(existing);
     }
 
-    // ─── Bot CRUD ────────────────────────────────────────────────────────────────
-
-    async createBot(userId: string, dto: CreateFeishuBotDto): Promise<FeishuBot> {
-        const existing = await this.botRepository.findOne({
-            where: { userId, appId: dto.appId },
-        });
-
-        if (existing) {
-            Object.assign(existing, dto);
-            return this.botRepository.save(existing);
-        }
-
-        const bot = this.botRepository.create({ userId, ...dto });
-        return this.botRepository.save(bot);
-    }
-
-    async getUserBots(userId: string): Promise<FeishuBot[]> {
-        return this.botRepository.find({ where: { userId } });
-    }
-
-    async getBotById(botId: string): Promise<FeishuBot | null> {
-        return this.botRepository.findOne({ where: { id: botId } });
+    const bot = this.botRepository.create({ userId, ...dto });
+    return this.botRepository.save(bot);
+  }
+
+  async getUserBots(userId: string): Promise<FeishuBot[]> {
+    return this.botRepository.find({
+      where: { userId },
+      relations: ['user'],
+    });
+  }
+
+  async getBotById(botId: string): Promise<FeishuBot | null> {
+    return this.botRepository.findOne({
+      where: { id: botId },
+      relations: ['user'],
+    });
+  }
+
+  async getBotByAppId(appId: string): Promise<FeishuBot | null> {
+    return this.botRepository.findOne({
+      where: { appId },
+      relations: ['user'],
+    });
+  }
+
+  async setBotEnabled(botId: string, enabled: boolean): Promise<FeishuBot> {
+    const bot = await this.botRepository.findOne({ where: { id: botId } });
+    if (!bot) throw new Error('Bot not found');
+    bot.enabled = enabled;
+    return this.botRepository.save(bot);
+  }
+
+  async deleteBot(userId: string, botId: string): Promise<void> {
+    await this.botRepository.delete({ id: botId, userId });
+  }
+
+  // ─── Feishu API Calls ─────────────────────────────────────────────────────────
+
+  /**
+   * Get or refresh tenant_access_token, cached per bot in DB
+   */
+  async getValidToken(bot: FeishuBot): Promise<string> {
+    if (
+      bot.tokenExpiresAt &&
+      bot.tenantAccessToken &&
+      new Date(bot.tokenExpiresAt) > new Date(Date.now() + 30 * 60 * 1000)
+    ) {
+      return bot.tenantAccessToken;
     }
 
-    async getBotByAppId(appId: string): Promise<FeishuBot | null> {
-        return this.botRepository.findOne({ where: { appId } });
+    this.logger.log(`Refreshing access token for bot: ${bot.appId}`);
+    const { data } = await axios.post<{
+      code: number;
+      msg: string;
+      tenant_access_token: string;
+      expire: number;
+    }>(`${this.feishuApiBase}/auth/v3/tenant_access_token/internal`, {
+      app_id: bot.appId,
+      app_secret: bot.appSecret,
+    });
+
+    if (data.code !== 0) {
+      throw new Error(`Failed to get Feishu token: ${data.msg}`);
     }
 
-    async setBotEnabled(botId: string, enabled: boolean): Promise<FeishuBot> {
-        const bot = await this.botRepository.findOne({ where: { id: botId } });
-        if (!bot) throw new Error('Bot not found');
-        bot.enabled = enabled;
-        return this.botRepository.save(bot);
+    bot.tenantAccessToken = data.tenant_access_token;
+    bot.tokenExpiresAt = new Date(Date.now() + data.expire * 1000);
+    await this.botRepository.save(bot);
+
+    return data.tenant_access_token;
+  }
+
+  /**
+   * Send a card message to a Feishu user
+   */
+  async sendCardMessage(
+    bot: FeishuBot,
+    receiveIdType: 'open_id' | 'user_id' | 'chat_id',
+    receiveId: string,
+    card: any,
+  ): Promise<string> {
+    const token = await this.getValidToken(bot);
+
+    const { data } = await axios.post<{
+      code: number;
+      msg: string;
+      data: { message_id: string };
+    }>(
+      `${this.feishuApiBase}/im/v1/messages?receive_id_type=${receiveIdType}`,
+      {
+        receive_id: receiveId,
+        msg_type: 'interactive',
+        content: JSON.stringify(card),
+      },
+      { headers: { Authorization: `Bearer ${token}` } },
+    );
+
+    if (data.code !== 0) {
+      throw new Error(`Failed to send Feishu card: ${data.msg}`);
     }
 
-    async deleteBot(userId: string, botId: string): Promise<void> {
-        await this.botRepository.delete({ id: botId, userId });
+    return data.data.message_id;
+  }
+
+  /**
+   * Send a simple text message to a Feishu user
+   */
+  async sendTextMessage(
+    bot: FeishuBot,
+    receiveIdType: 'open_id' | 'user_id' | 'chat_id',
+    receiveId: string,
+    text: string,
+  ): Promise<string> {
+    const token = await this.getValidToken(bot);
+
+    const { data } = await axios.post<{
+      code: number;
+      msg: string;
+      data: { message_id: string };
+    }>(
+      `${this.feishuApiBase}/im/v1/messages?receive_id_type=${receiveIdType}`,
+      {
+        receive_id: receiveId,
+        msg_type: 'text',
+        content: JSON.stringify({ text }),
+      },
+      { headers: { Authorization: `Bearer ${token}` } },
+    );
+
+    if (data.code !== 0) {
+      throw new Error(`Failed to send Feishu message: ${data.msg}`);
     }
 
-    // ─── Feishu API Calls ─────────────────────────────────────────────────────────
-
-    /**
-     * Get or refresh tenant_access_token, cached per bot in DB
-     */
-    async getValidToken(bot: FeishuBot): Promise<string> {
-        if (
-            bot.tokenExpiresAt &&
-            bot.tenantAccessToken &&
-            new Date(bot.tokenExpiresAt) > new Date(Date.now() + 30 * 60 * 1000)
-        ) {
-            return bot.tenantAccessToken;
-        }
-
-        this.logger.log(`Refreshing access token for bot: ${bot.appId}`);
-        const { data } = await axios.post<{
-            code: number;
-            msg: string;
-            tenant_access_token: string;
-            expire: number;
-        }>(`${this.feishuApiBase}/auth/v3/tenant_access_token/internal`, {
-            app_id: bot.appId,
-            app_secret: bot.appSecret,
-        });
-
-        if (data.code !== 0) {
-            throw new Error(`Failed to get Feishu token: ${data.msg}`);
-        }
-
-        bot.tenantAccessToken = data.tenant_access_token;
-        bot.tokenExpiresAt = new Date(Date.now() + data.expire * 1000);
-        await this.botRepository.save(bot);
-
-        return data.tenant_access_token;
+    return data.data.message_id;
+  }
+
+  /**
+   * Update an already-sent message (supports interactive cards)
+   */
+  async updateMessage(
+    bot: FeishuBot,
+    messageId: string,
+    content: any,
+    msgType: 'text' | 'interactive' = 'interactive',
+  ): Promise<void> {
+    const token = await this.getValidToken(bot);
+
+    const { data } = await axios.patch<{ code: number; msg: string }>(
+      `${this.feishuApiBase}/im/v1/messages/${messageId}`,
+      { msg_type: msgType, content: JSON.stringify(content) },
+      { headers: { Authorization: `Bearer ${token}` } },
+    );
+
+    if (data.code !== 0) {
+      this.logger.warn(`Failed to update Feishu message: ${data.msg}`);
     }
-
-    /**
-     * Send a card message to a Feishu user
-     */
-    async sendCardMessage(
-        bot: FeishuBot,
-        receiveIdType: 'open_id' | 'user_id' | 'chat_id',
-        receiveId: string,
-        card: any,
-    ): Promise<string> {
-        const token = await this.getValidToken(bot);
-
-        const { data } = await axios.post<{
-            code: number;
-            msg: string;
-            data: { message_id: string };
-        }>(
-            `${this.feishuApiBase}/im/v1/messages?receive_id_type=${receiveIdType}`,
+  }
+
+  /**
+   * Build a professional Feishu card
+   */
+  private buildFeishuCard(
+    content: string,
+    title = 'AuraK AI 助手',
+    isFinal = false,
+  ) {
+    return {
+      config: {
+        wide_screen_mode: true,
+      },
+      header: {
+        template: isFinal ? 'blue' : 'orange',
+        title: {
+          content: title + (isFinal ? '' : ' (正在生成...)'),
+          tag: 'plain_text',
+        },
+      },
+      elements: [
+        {
+          tag: 'div',
+          text: {
+            content: content || '...',
+            tag: 'lark_md',
+          },
+        },
+        {
+          tag: 'hr',
+        },
+        {
+          tag: 'note',
+          elements: [
             {
-                receive_id: receiveId,
-                msg_type: 'interactive',
-                content: JSON.stringify(card),
+              content: `由 AuraK 知识库驱动 · ${new Date().toLocaleTimeString()}`,
+              tag: 'plain_text',
             },
-            { headers: { Authorization: `Bearer ${token}` } },
-        );
-
-        if (data.code !== 0) {
-            throw new Error(`Failed to send Feishu card: ${data.msg}`);
-        }
-
-        return data.data.message_id;
+          ],
+        },
+      ],
+    };
+  }
+
+  // ─── Chunk 5: ChatService RAG Integration ─────────────────────────────────────
+
+  private processedMessages = new Map<
+    string,
+    { time: number; responseId?: string }
+  >();
+
+  /**
+   * Check if message is an assessment command
+   */
+  isAssessmentCommand(message: string): boolean {
+    const trimmed = message.trim().toLowerCase();
+    const commandPrefixes = ['/assessment', '/测评', '/eval', '/测评评估'];
+    return commandPrefixes.some((prefix) =>
+      trimmed.startsWith(prefix.toLowerCase()),
+    );
+  }
+
+  async handleIncomingMessage(
+    bot: FeishuBot,
+    openId: string,
+    messageId: string,
+    userText: string,
+  ): Promise<void> {
+    // Strip Feishu AT tags and trim
+    userText = userText.replace(/<at [^>]*><\/at>/g, '').trim();
+    if (!userText) return;
+
+    // 1. Deduplication: check if we are already processing this message
+    const now = Date.now();
+    const existing = this.processedMessages.get(messageId);
+    if (existing && now - existing.time < 1000 * 60 * 10) {
+      this.logger.warn(`Ignoring duplicate Feishu message: ${messageId}`);
+      return;
     }
 
-    /**
-     * Send a simple text message to a Feishu user
-     */
-    async sendTextMessage(
-        bot: FeishuBot,
-        receiveIdType: 'open_id' | 'user_id' | 'chat_id',
-        receiveId: string,
-        text: string,
-    ): Promise<string> {
-        const token = await this.getValidToken(bot);
-
-        const { data } = await axios.post<{
-            code: number;
-            msg: string;
-            data: { message_id: string };
-        }>(
-            `${this.feishuApiBase}/im/v1/messages?receive_id_type=${receiveIdType}`,
-            {
-                receive_id: receiveId,
-                msg_type: 'text',
-                content: JSON.stringify({ text }),
-            },
-            { headers: { Authorization: `Bearer ${token}` } },
-        );
-
-        if (data.code !== 0) {
-            throw new Error(`Failed to send Feishu message: ${data.msg}`);
-        }
+    // Mark as being processed
+    this.processedMessages.set(messageId, { time: now });
 
-        return data.data.message_id;
+    // Cleanup old cache (simple)
+    if (this.processedMessages.size > 1000) {
+      for (const [key, val] of this.processedMessages) {
+        if (now - val.time > 1000 * 60 * 30) this.processedMessages.delete(key);
+      }
     }
 
-    /**
-     * Update an already-sent message (supports interactive cards)
-     */
-    async updateMessage(bot: FeishuBot, messageId: string, content: any, msgType: 'text' | 'interactive' = 'interactive'): Promise<void> {
-        const token = await this.getValidToken(bot);
-
-        const { data } = await axios.patch<{ code: number; msg: string }>(
-            `${this.feishuApiBase}/im/v1/messages/${messageId}`,
-            { msg_type: msgType, content: JSON.stringify(content) },
-            { headers: { Authorization: `Bearer ${token}` } },
+    try {
+      // Check if message is an assessment command
+      if (this.isAssessmentCommand(userText)) {
+        this.logger.log(
+          `Routing assessment command [${messageId}] for bot ${bot.appId}`,
         );
-
-        if (data.code !== 0) {
-            this.logger.warn(`Failed to update Feishu message: ${data.msg}`);
-        }
-    }
-
-    /**
-     * Build a professional Feishu card
-     */
-    private buildFeishuCard(content: string, title = 'AuraK AI 助手', isFinal = false) {
-        return {
-            config: {
-                wide_screen_mode: true,
-            },
-            header: {
-                template: isFinal ? 'blue' : 'orange',
-                title: {
-                    content: title + (isFinal ? '' : ' (正在生成...)'),
-                    tag: 'plain_text',
-                },
-            },
-            elements: [
-                {
-                    tag: 'div',
-                    text: {
-                        content: content || '...',
-                        tag: 'lark_md',
-                    },
-                },
-                {
-                    tag: 'hr',
-                },
-                {
-                    tag: 'note',
-                    elements: [
-                        {
-                            content: `由 AuraK 知识库驱动 · ${new Date().toLocaleTimeString()}`,
-                            tag: 'plain_text',
-                        },
-                    ],
-                },
-            ],
-        };
+        // Delegate to assessment service
+        await this.feishuAssessmentService.handleCommand(bot, openId, userText);
+      } else {
+        // Delegate to standard RAG pipeline
+        await this.processChatMessage(bot, openId, messageId, userText, true);
+      }
+    } catch (error) {
+      this.logger.error(
+        `Message routing failed [${messageId}]: ${error.message}`,
+        error,
+      );
+      try {
+        await this.sendTextMessage(
+          bot,
+          'open_id',
+          openId,
+          '抱歉,处理您的消息时遇到了错误,请稍后重试。',
+        );
+      } catch (sendError) {
+        this.logger.error('Failed to send error message to Feishu', sendError);
+      }
     }
-
-    // ─── Chunk 5: ChatService RAG Integration ─────────────────────────────────────
-
-    private processedMessages = new Map<string, { time: number; responseId?: string }>();
-
-    /**
-     * Process a user message via the AuraK RAG pipeline and send the result back.
-     * This is the core of the Feishu integration.
-     */
-    async processChatMessage(
-        bot: FeishuBot,
-        openId: string,
-        messageId: string,
-        userMessage: string,
-    ): Promise<void> {
-        this.logger.log(`Processing Feishu message [${messageId}] for bot ${bot.appId}`);
-
-        // 1. Deduplication: check if we are already processing this message
-        const now = Date.now();
-        const existing = this.processedMessages.get(messageId);
-        if (existing && now - existing.time < 1000 * 60 * 10) {
-            this.logger.warn(`Ignoring duplicate Feishu message: ${messageId}`);
-            return;
-        }
-
-        // Mark as being processed
-        this.processedMessages.set(messageId, { time: now });
-
-        // Cleanup old cache (simple)
-        if (this.processedMessages.size > 1000) {
-            for (const [key, val] of this.processedMessages) {
-                if (now - val.time > 1000 * 60 * 30) this.processedMessages.delete(key);
-            }
-        }
-
-        // Get user from bot owner
-        const userId = bot.userId;
-        const user = await this.userService.findOneById(userId);
-        const tenantId = user?.tenantId || 'default';
-        const language = user?.userSetting?.language || 'zh';
-
-        // Get the user's default LLM model
-        const llmModel = await this.modelConfigService.findDefaultByType(tenantId, ModelType.LLM);
-
-        if (!llmModel) {
-            await this.sendTextMessage(bot, 'open_id', openId, '❌ 请先在 AuraK 中配置 LLM 模型才能使用机器人。');
-            return;
-        }
-
-        // Send initial "thinking" card
-        const cardTitle = 'AuraK 知识检索';
-        const initialCard = this.buildFeishuCard('⏳ 正在检索知识库,请稍候...', cardTitle, false);
-        const msgId = await this.sendCardMessage(bot, 'open_id', openId, initialCard);
-        
-        // Save the response message ID for potential future deduplication debugging
-        this.processedMessages.set(messageId, { time: now, responseId: msgId });
-
-        // Run the RAG pipeline in the background so we don't block the Feishu event handler
-        // This prevents Feishu from retrying the event if it takes > 3s.
-        this._runRagBackground(bot, msgId, userMessage, userId, llmModel, language, tenantId, cardTitle);
+  }
+
+  /**
+   * Process a user message via the AuraK RAG pipeline and send the result back.
+   * This is the core of the Feishu integration.
+   */
+  async processChatMessage(
+    bot: FeishuBot,
+    openId: string,
+    messageId: string,
+    userMessage: string,
+    alreadyDeduplicated = false,
+  ): Promise<void> {
+    this.logger.log(
+      `Processing Feishu message [${messageId}] for bot ${bot.appId}`,
+    );
+    const now = Date.now();
+
+    if (!alreadyDeduplicated) {
+      // 1. Deduplication: check if we are already processing this message
+      const now = Date.now();
+      const existing = this.processedMessages.get(messageId);
+      if (existing && now - existing.time < 1000 * 60 * 10) {
+        this.logger.warn(`Ignoring duplicate Feishu message: ${messageId}`);
+        return;
+      }
+      // Mark as being processed
+      this.processedMessages.set(messageId, { time: now });
     }
 
-    /**
-     * Internal background task for RAG processing
-     */
-    private async _runRagBackground(
-        bot: FeishuBot,
-        msgId: string,
-        userMessage: string,
-        userId: string,
-        llmModel: any,
-        language: string,
-        tenantId: string,
-        cardTitle: string,
-    ) {
-        let fullResponse = '';
-        let lastUpdateTime = Date.now();
-        const UPDATE_INTERVAL = 1500;
-
-        try {
-            // Stream from ChatService RAG pipeline
-            const stream = this.chatService.streamChat(
-                userMessage,
-                [],
-                userId,
-                llmModel as any,
-                language,
-                undefined,
-                undefined,
-                undefined,
-                undefined,
-                false,
-                undefined,
-                undefined,
-                undefined,
-                10,
-                0.7,
-                undefined,
-                undefined,
-                undefined,
-                tenantId,
-            );
-
-            for await (const chunk of stream) {
-                if (chunk.type === 'content') {
-                    fullResponse += chunk.data;
-
-                    const now = Date.now();
-                    if (now - lastUpdateTime > UPDATE_INTERVAL && fullResponse.length > 50) {
-                        const loadingCard = this.buildFeishuCard(fullResponse, cardTitle, false);
-                        await this.updateMessage(bot, msgId, loadingCard);
-                        lastUpdateTime = now;
-                    }
-                }
-            }
-        } catch (err) {
-            this.logger.error('RAG stream error for Feishu message', err);
-            fullResponse = `抱歉,处理您的问题时遇到了错误:${err?.message || '未知错误'}。`;
-        }
-
-        const MAX_LENGTH = 4500;
-        const finalContent = fullResponse.length > MAX_LENGTH
-            ? fullResponse.substring(0, MAX_LENGTH) + '\n\n...(内容过长,已截断)'
-            : fullResponse || '抱歉,未能生成有效回复,请稍后再试。';
-
-        const finalCard = this.buildFeishuCard(finalContent, cardTitle, true);
-        await this.updateMessage(bot, msgId, finalCard);
+    // Cleanup old cache (simple)
+    if (this.processedMessages.size > 1000) {
+      for (const [key, val] of this.processedMessages) {
+        if (now - val.time > 1000 * 60 * 30) this.processedMessages.delete(key);
+      }
     }
 
-    // ─── WebSocket Connection Management ─────────────────────────────────────────
-
-    /**
-     * Start WebSocket connection for a bot
-     */
-    async startWsConnection(botId: string): Promise<void> {
-        const bot = await this.getBotById(botId);
-        if (!bot) throw new Error('Bot not found');
-        if (!bot.enabled) throw new Error('Bot is disabled');
-
-        bot.useWebSocket = true;
-        await this.botRepository.save(bot);
-
-        await this.wsManager.connect(bot);
+    // Get user from bot owner
+    const userId = bot.userId;
+    const user = await this.userService.findOneById(userId);
+    const tenantId = user?.tenantId || 'default';
+    const language = user?.userSetting?.language || 'zh';
+
+    // Get the user's default LLM model
+    const llmModel = await this.modelConfigService.findDefaultByType(
+      tenantId,
+      ModelType.LLM,
+    );
+
+    if (!llmModel) {
+      await this.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        '❌ 请先在 AuraK 中配置 LLM 模型才能使用机器人。',
+      );
+      return;
     }
 
-    /**
-     * Stop WebSocket connection for a bot
-     */
-    async stopWsConnection(botId: string): Promise<void> {
-        const bot = await this.getBotById(botId);
-        if (!bot) throw new Error('Bot not found');
-
-        bot.useWebSocket = false;
-        await this.botRepository.save(bot);
-
-        await this.wsManager.disconnect(botId);
+    // Send initial "thinking" card
+    const cardTitle = 'AuraK 知识检索';
+    const initialCard = this.buildFeishuCard(
+      '⏳ 正在检索知识库,请稍候...',
+      cardTitle,
+      false,
+    );
+    const msgId = await this.sendCardMessage(
+      bot,
+      'open_id',
+      openId,
+      initialCard,
+    );
+
+    // Save the response message ID for potential future deduplication debugging
+    this.processedMessages.set(messageId, { time: now, responseId: msgId });
+
+    // Run the RAG pipeline in the background so we don't block the Feishu event handler
+    // This prevents Feishu from retrying the event if it takes > 3s.
+
+    // Handle knowledge source selection
+    let selectedFiles: string[] | undefined = undefined;
+    let selectedGroups: string[] | undefined = undefined;
+
+    if (bot.knowledgeBaseId) {
+      selectedFiles = [bot.knowledgeBaseId];
+    } else if (bot.knowledgeGroupId) {
+      selectedGroups = [bot.knowledgeGroupId];
     }
 
-    /**
-     * Get WebSocket connection status for a specific bot
-     */
-    async getWsStatus(botId: string): Promise<ConnectionStatus | null> {
-        return this.wsManager.getStatus(botId);
+    this._runRagBackground(
+      bot,
+      msgId,
+      userMessage,
+      userId,
+      llmModel,
+      language,
+      tenantId,
+      cardTitle,
+      selectedFiles,
+      selectedGroups,
+    );
+  }
+
+  /**
+   * Internal background task for RAG processing
+   */
+  private async _runRagBackground(
+    bot: FeishuBot,
+    msgId: string,
+    userMessage: string,
+    userId: string,
+    llmModel: any,
+    language: string,
+    tenantId: string,
+    cardTitle: string,
+    selectedFiles?: string[],
+    selectedGroups?: string[],
+  ) {
+    let fullResponse = '';
+    let lastUpdateTime = Date.now();
+    const UPDATE_INTERVAL = 1500;
+
+    try {
+      // Stream from ChatService RAG pipeline
+      const stream = this.chatService.streamChat(
+        userMessage,
+        [],
+        userId,
+        llmModel,
+        language,
+        undefined,
+        undefined,
+        undefined,
+        undefined,
+        false,
+        undefined,
+        undefined,
+        undefined,
+        10,
+        0.7,
+        undefined,
+        undefined,
+        undefined,
+        tenantId,
+      );
+
+      for await (const chunk of stream) {
+        if (chunk.type === 'content') {
+          fullResponse += chunk.data;
+
+          const now = Date.now();
+          if (
+            now - lastUpdateTime > UPDATE_INTERVAL &&
+            fullResponse.length > 50
+          ) {
+            const loadingCard = this.buildFeishuCard(
+              fullResponse,
+              cardTitle,
+              false,
+            );
+            await this.updateMessage(bot, msgId, loadingCard);
+            lastUpdateTime = now;
+          }
+        }
+      }
+    } catch (err) {
+      this.logger.error('RAG stream error for Feishu message', err);
+      fullResponse = `抱歉,处理您的问题时遇到了错误:${err?.message || '未知错误'}。`;
     }
 
-    /**
-     * Get all WebSocket connection statuses
-     */
-    async getAllWsStatuses(): Promise<ConnectionStatus[]> {
-        return this.wsManager.getAllStatuses();
-    }
+    const MAX_LENGTH = 4500;
+    const finalContent =
+      fullResponse.length > MAX_LENGTH
+        ? fullResponse.substring(0, MAX_LENGTH) + '\n\n...(内容过长,已截断)'
+        : fullResponse || '抱歉,未能生成有效回复,请稍后再试。';
+
+    const finalCard = this.buildFeishuCard(finalContent, cardTitle, true);
+    await this.updateMessage(bot, msgId, finalCard);
+  }
+
+  // ─── WebSocket Connection Management ─────────────────────────────────────────
+
+  /**
+   * Start WebSocket connection for a bot
+   */
+  async startWsConnection(botId: string): Promise<void> {
+    const bot = await this.getBotById(botId);
+    if (!bot) throw new Error('Bot not found');
+    if (!bot.enabled) throw new Error('Bot is disabled');
+
+    bot.useWebSocket = true;
+    await this.botRepository.save(bot);
+
+    await this.wsManager.connect(bot);
+  }
+
+  /**
+   * Stop WebSocket connection for a bot
+   */
+  async stopWsConnection(botId: string): Promise<void> {
+    const bot = await this.getBotById(botId);
+    if (!bot) throw new Error('Bot not found');
+
+    bot.useWebSocket = false;
+    await this.botRepository.save(bot);
+
+    await this.wsManager.disconnect(botId);
+  }
+
+  /**
+   * Get WebSocket connection status for a specific bot
+   */
+  async getWsStatus(botId: string): Promise<ConnectionStatus | null> {
+    return this.wsManager.getStatus(botId);
+  }
+
+  /**
+   * Get all WebSocket connection statuses
+   */
+  async getAllWsStatuses(): Promise<ConnectionStatus[]> {
+    return this.wsManager.getAllStatuses();
+  }
 }

+ 61 - 0
server/src/feishu/services/assessment-command.parser.spec.ts

@@ -0,0 +1,61 @@
+import { AssessmentCommandParser } from './assessment-command.parser';
+import { AssessmentCommandType } from '../dto/assessment-command.dto';
+
+describe('AssessmentCommandParser', () => {
+  let parser: AssessmentCommandParser;
+
+  beforeEach(() => {
+    parser = new AssessmentCommandParser();
+  });
+
+  describe('parse', () => {
+    it('should parse start command without parameters', () => {
+      const result = parser.parse('/assessment start');
+      expect(result).toMatchObject({
+        type: AssessmentCommandType.START,
+        parameters: [],
+      });
+    });
+
+    it('should parse start command with parameters', () => {
+      const result = parser.parse('/assessment start kb_123');
+      expect(result).toMatchObject({
+        type: AssessmentCommandType.START,
+        parameters: ['kb_123'],
+      });
+    });
+
+    it('should parse answer command', () => {
+      const result = parser.parse('/assessment answer my answer');
+      expect(result).toMatchObject({
+        type: AssessmentCommandType.ANSWER,
+        parameters: ['my', 'answer'],
+      });
+    });
+
+    it('should parse Chinese commands', () => {
+      const result = parser.parse('/测评 开始');
+      expect(result).toMatchObject({
+        type: AssessmentCommandType.START,
+        parameters: [],
+      });
+    });
+
+    it('should return null for non-assessment commands', () => {
+      const result = parser.parse('hello world');
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('isAssessmentCommand', () => {
+    it('should return true for valid commands', () => {
+      expect(parser.isAssessmentCommand('/assessment status')).toBe(true);
+      expect(parser.isAssessmentCommand('/测评 状态')).toBe(true);
+    });
+
+    it('should return false for invalid commands', () => {
+      expect(parser.isAssessmentCommand('status')).toBe(false);
+      expect(parser.isAssessmentCommand('/status')).toBe(false);
+    });
+  });
+});

+ 135 - 0
server/src/feishu/services/assessment-command.parser.ts

@@ -0,0 +1,135 @@
+import { Injectable, Logger } from '@nestjs/common';
+import {
+  AssessmentCommand,
+  AssessmentCommandType,
+  AssessmentCommandDto,
+} from '../dto/assessment-command.dto';
+
+@Injectable()
+export class AssessmentCommandParser {
+  private readonly logger = new Logger(AssessmentCommandParser.name);
+
+  // 支持的命令前缀
+  private readonly commandPrefixes = [
+    '/assessment',
+    '/测评',
+    '/eval',
+    '/测评评估',
+  ];
+
+  // 命令映射
+  private readonly commandMap: Record<string, AssessmentCommandType> = {
+    start: AssessmentCommandType.START,
+    开始: AssessmentCommandType.START,
+    answer: AssessmentCommandType.ANSWER,
+    回答: AssessmentCommandType.ANSWER,
+    status: AssessmentCommandType.STATUS,
+    状态: AssessmentCommandType.STATUS,
+    result: AssessmentCommandType.RESULT,
+    结果: AssessmentCommandType.RESULT,
+    help: AssessmentCommandType.HELP,
+    帮助: AssessmentCommandType.HELP,
+    cancel: AssessmentCommandType.CANCEL,
+    取消: AssessmentCommandType.CANCEL,
+  };
+
+  /**
+   * 解析消息是否为测评命令
+   */
+  parse(message: string): AssessmentCommand | null {
+    const trimmed = message.trim();
+
+    // 检查是否是测评命令
+    const isCommand = this.commandPrefixes.some((prefix) =>
+      trimmed.toLowerCase().startsWith(prefix.toLowerCase()),
+    );
+
+    if (!isCommand) {
+      return null;
+    }
+
+    try {
+      // 解析命令
+      const parts = trimmed.split(/\s+/);
+      const commandTypeStr = parts[1]?.toLowerCase();
+
+      // 查找命令类型
+      const commandType = this.commandMap[commandTypeStr];
+
+      if (!commandType) {
+        // 未知命令,返回帮助
+        return {
+          type: AssessmentCommandType.HELP,
+          parameters: [],
+          rawMessage: message,
+          timestamp: new Date(),
+        };
+      }
+
+      // 获取参数(跳过命令前缀和命令类型)
+      const parameters = parts.slice(2);
+
+      return {
+        type: commandType,
+        parameters,
+        rawMessage: message,
+        timestamp: new Date(),
+      };
+    } catch (error) {
+      this.logger.error(
+        `Failed to parse assessment command: ${error.message}`,
+        error,
+      );
+      return null;
+    }
+  }
+
+  /**
+   * 检查消息是否为测评命令
+   */
+  isAssessmentCommand(message: string): boolean {
+    const trimmed = message.trim().toLowerCase();
+    return this.commandPrefixes.some((prefix) =>
+      trimmed.startsWith(prefix.toLowerCase()),
+    );
+  }
+
+  /**
+   * 获取命令帮助文本
+   */
+  getHelpText(language: string = 'zh'): string {
+    if (language === 'zh') {
+      return `
+**人才测评机器人帮助**
+
+命令格式:
+- \`/assessment start [templateId]\` - 开始测评
+- \`/assessment answer [answer]\` - 提交答案
+- \`/assessment status\` - 查看测评状态
+- \`/assessment result\` - 获取测评结果
+- \`/assessment help\` - 显示帮助
+- \`/assessment cancel\` - 取消测评
+
+说明:
+- 如果未指定知识库/模板,将使用机器人配置的默认知识库
+- 也可直接回复答案,无需命令前缀
+            `.trim();
+    } else {
+      return `
+**Assessment Bot Help**
+
+Commands:
+- \`/assessment start [templateId]\` - Start assessment
+- \`/assessment answer [answer]\` - Submit answer
+- \`/assessment status\` - Check assessment status
+- \`/assessment result\` - Get assessment results
+- \`/assessment help\` - Show help
+- \`/assessment cancel\` - Cancel assessment
+
+Note:
+- If no knowledge base/template is specified, the bot's default knowledge base will be used
+- You can also reply directly with your answer without command prefix
+            `.trim();
+    }
+  }
+}

+ 616 - 0
server/src/feishu/services/feishu-assessment.service.ts

@@ -0,0 +1,616 @@
+import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { FeishuBot } from '../entities/feishu-bot.entity';
+import { FeishuAssessmentSession } from '../entities/feishu-assessment-session.entity';
+import { FeishuService } from '../feishu.service';
+import { AssessmentService } from '../../assessment/assessment.service';
+import { AssessmentCommandParser } from './assessment-command.parser';
+import { AssessmentCommandType } from '../dto/assessment-command.dto';
+
+@Injectable()
+export class FeishuAssessmentService {
+  private readonly logger = new Logger(FeishuAssessmentService.name);
+
+  constructor(
+    @InjectRepository(FeishuAssessmentSession)
+    private sessionRepository: Repository<FeishuAssessmentSession>,
+    @Inject(forwardRef(() => AssessmentService))
+    private assessmentService: AssessmentService,
+    @Inject(forwardRef(() => FeishuService))
+    private feishuService: FeishuService,
+    private commandParser: AssessmentCommandParser,
+  ) {}
+
+  /**
+   * 处理测评命令
+   */
+  async handleCommand(
+    bot: FeishuBot,
+    openId: string,
+    message: string,
+  ): Promise<void> {
+    // Ensure bot user relation is loaded (might be missing if fetched from an old cache or WS connection)
+    if (!bot.user) {
+      this.logger.log(`Reloading bot ${bot.id} to fetch user relation`);
+      const loadedBot = await this.feishuService.getBotById(bot.id);
+      if (loadedBot) {
+        bot = loadedBot;
+      }
+    }
+
+    const command = this.commandParser.parse(message);
+
+    if (!command) {
+      // 不是测评命令,使用默认聊天处理
+      await this.feishuService.processChatMessage(bot, openId, '', message);
+      return;
+    }
+
+    try {
+      switch (command.type) {
+        case AssessmentCommandType.START:
+          await this.startAssessment(bot, openId, command.parameters);
+          break;
+        case AssessmentCommandType.ANSWER:
+          await this.submitAnswer(bot, openId, command.parameters.join(' '));
+          break;
+        case AssessmentCommandType.STATUS:
+          await this.getStatus(bot, openId);
+          break;
+        case AssessmentCommandType.RESULT:
+          await this.getResult(bot, openId);
+          break;
+        case AssessmentCommandType.HELP:
+          await this.sendHelp(bot, openId);
+          break;
+        case AssessmentCommandType.CANCEL:
+          await this.cancelAssessment(bot, openId);
+          break;
+        default:
+          await this.sendHelp(bot, openId);
+      }
+    } catch (error) {
+      this.logger.error(
+        `Failed to handle assessment command: ${error.message}`,
+        error,
+      );
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        `处理测评命令时出错: ${error.message}`,
+      );
+    }
+  }
+
+  /**
+   * 开始测评
+   */
+  async startAssessment(
+    bot: FeishuBot,
+    openId: string,
+    parameters: string[],
+  ): Promise<void> {
+    // 检查是否已有进行中的测评
+    const existingSession = await this.getActiveSession(bot.id, openId);
+    if (existingSession) {
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        '您已有进行中的测评会话,请先完成当前测评或发送 /assessment cancel 取消。',
+      );
+      return;
+    }
+
+    // 解析参数
+    const [kbIdOrTemplateId] = parameters;
+    let knowledgeBaseId: string | undefined;
+    let templateId: string | undefined;
+
+    // 统一作为模板ID处理,因为模板包含了更完整的测评配置
+    // 且知识库ID也多为UUID,按长度判断不准确
+    if (kbIdOrTemplateId) {
+      templateId = kbIdOrTemplateId;
+    }
+
+    // 使用机器人配置的知识库或知识组(如果未指定)
+    if (!knowledgeBaseId && !templateId) {
+      if (bot.knowledgeBaseId) {
+        knowledgeBaseId = bot.knowledgeBaseId;
+      } else if (bot.knowledgeGroupId) {
+        knowledgeBaseId = bot.knowledgeGroupId;
+      }
+    }
+
+    this.logger.log(
+      `Starting assessment: bot=${bot.id}, openId=${openId}, kb=${knowledgeBaseId}, template=${templateId}`,
+    );
+
+    // 发送"正在创建"消息
+    await this.feishuService.sendTextMessage(
+      bot,
+      'open_id',
+      openId,
+      '⏳ 正在创建测评会话,请稍候...',
+    );
+
+    try {
+      // 创建测评会话
+      const session = await this.assessmentService.startSession(
+        bot.userId,
+        knowledgeBaseId,
+        bot.user?.tenantId || 'default',
+        'zh',
+        templateId,
+      );
+
+      // 触发问题生成(startSession 仅创建会话,getSessionState 会触发 agent 生成问题)
+      this.logger.log(
+        `Triggering question generation for session ${session.id}`,
+      );
+      const state = await this.assessmentService.getSessionState(
+        session.id,
+        bot.userId,
+      );
+      const questions = state.questions || [];
+
+      // 存储飞书会话关联
+      const feishuSession = this.sessionRepository.create({
+        botId: bot.id,
+        openId,
+        assessmentSessionId: session.id,
+        status: 'active',
+        currentQuestionIndex: 0,
+      });
+      await this.sessionRepository.save(feishuSession);
+
+      // 发送第一个问题
+      if (questions && questions.length > 0) {
+        const firstQuestion = questions[0];
+        const totalQuestions = state.questionCount || questions.length;
+        const card = this.buildQuestionCard(
+          firstQuestion,
+          session.id,
+          1,
+          totalQuestions,
+        );
+        await this.feishuService.sendCardMessage(bot, 'open_id', openId, card);
+      } else {
+        await this.feishuService.sendTextMessage(
+          bot,
+          'open_id',
+          openId,
+          '测评会话已创建,但未能生成问题。',
+        );
+      }
+    } catch (error) {
+      this.logger.error(`Failed to start assessment: ${error.message}`, error);
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        `创建测评会话失败: ${error.message}`,
+      );
+    }
+  }
+
+  /**
+   * 提交答案
+   */
+  async submitAnswer(
+    bot: FeishuBot,
+    openId: string,
+    answer: string,
+  ): Promise<void> {
+    const session = await this.getActiveSession(bot.id, openId);
+
+    if (!session) {
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        '没有进行中的测评会话。请发送 /assessment start 开始测评。',
+      );
+      return;
+    }
+
+    if (!answer || answer.trim() === '') {
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        '请提供答案。',
+      );
+      return;
+    }
+
+    this.logger.log(
+      `Submitting answer for session ${session.assessmentSessionId}`,
+    );
+
+    // 发送"正在评估"消息
+    await this.feishuService.sendTextMessage(
+      bot,
+      'open_id',
+      openId,
+      '⏳ 正在评估答案...',
+    );
+
+    try {
+      // 提交答案到测评服务
+      const result = await this.assessmentService.submitAnswer(
+        session.assessmentSessionId,
+        bot.userId,
+        answer,
+        'zh',
+      );
+
+      this.logger.log(
+        `Assessment result for session ${session.assessmentSessionId}: score=${result.finalScore}, hasReport=${!!result.report}, questionsLen=${result.questions?.length}, questionsJsonLen=${result.questions_json?.length}`,
+      );
+      this.logger.debug(`Result keys: ${Object.keys(result).join(', ')}`);
+      if (result.report) {
+        this.logger.debug(
+          `Result report snippet: ${result.report.substring(0, 100)}...`,
+        );
+      }
+
+      // 更新会话状态
+      session.currentQuestionIndex = result.currentQuestionIndex || 0;
+
+      // 检查是否完成
+      if (result.report) {
+        session.status = 'completed';
+        await this.sessionRepository.save(session);
+
+        // 发送测评结果
+        await this.sendAssessmentResult(bot, openId, result);
+      } else if (result.questions && result.questions.length > 0) {
+        // 更新并保存会话
+        await this.sessionRepository.save(session);
+
+        // 发送下一个问题
+        const currentQuestionIndex = result.currentQuestionIndex || 0;
+        const nextQuestion = result.questions[currentQuestionIndex];
+        const totalQuestions = result.questionCount || result.questions.length;
+
+        const card = this.buildQuestionCard(
+          nextQuestion,
+          session.assessmentSessionId,
+          currentQuestionIndex + 1,
+          totalQuestions,
+        );
+        await this.feishuService.sendCardMessage(bot, 'open_id', openId, card);
+      } else if (result.questions_json && result.questions_json.length > 0) {
+        // 有些版本返回 questions_json
+        await this.sessionRepository.save(session);
+        const currentQuestionIndex = result.currentQuestionIndex || 0;
+        const nextQuestion = result.questions_json[currentQuestionIndex];
+        const totalQuestions =
+          result.questionCount || result.questions_json.length;
+
+        const card = this.buildQuestionCard(
+          nextQuestion,
+          session.assessmentSessionId,
+          currentQuestionIndex + 1,
+          totalQuestions,
+        );
+        await this.feishuService.sendCardMessage(bot, 'open_id', openId, card);
+      } else {
+        // 没有更多问题,完成测评
+        session.status = 'completed';
+        await this.sessionRepository.save(session);
+        await this.sendAssessmentResult(bot, openId, result);
+      }
+    } catch (error) {
+      this.logger.error(`Failed to submit answer: ${error.message}`, error);
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        `提交答案失败: ${error.message}`,
+      );
+    }
+  }
+
+  /**
+   * 获取测评状态
+   */
+  async getStatus(bot: FeishuBot, openId: string): Promise<void> {
+    const session = await this.getActiveSession(bot.id, openId);
+
+    if (!session) {
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        '没有进行中的测评会话。',
+      );
+      return;
+    }
+
+    try {
+      const assessmentState = await this.assessmentService.getSessionState(
+        session.assessmentSessionId,
+        bot.userId,
+      );
+
+      const currentQuestionIndex = assessmentState.currentQuestionIndex || 0;
+      const totalQuestions = assessmentState.questions?.length || 0;
+
+      const message =
+        `测评状态:\n` +
+        `- 进度: ${currentQuestionIndex + 1}/${totalQuestions}\n` +
+        `- 状态: ${session.status === 'active' ? '进行中' : '已完成'}\n` +
+        `- 开始时间: ${session.createdAt.toLocaleString('zh-CN')}`;
+
+      await this.feishuService.sendTextMessage(bot, 'open_id', openId, message);
+    } catch (error) {
+      this.logger.error(`Failed to get status: ${error.message}`, error);
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        `获取状态失败: ${error.message}`,
+      );
+    }
+  }
+
+  /**
+   * 获取测评结果
+   */
+  async getResult(bot: FeishuBot, openId: string): Promise<void> {
+    const session = await this.getActiveSession(bot.id, openId);
+
+    if (!session) {
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        '没有进行中的测评会话。',
+      );
+      return;
+    }
+
+    if (session.status !== 'completed') {
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        '测评尚未完成,请先完成所有问题。',
+      );
+      return;
+    }
+
+    try {
+      const assessmentState = await this.assessmentService.getSessionState(
+        session.assessmentSessionId,
+        bot.userId,
+      );
+
+      await this.sendAssessmentResult(bot, openId, assessmentState);
+    } catch (error) {
+      this.logger.error(`Failed to get result: ${error.message}`, error);
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        `获取结果失败: ${error.message}`,
+      );
+    }
+  }
+
+  /**
+   * 取消测评
+   */
+  async cancelAssessment(bot: FeishuBot, openId: string): Promise<void> {
+    const session = await this.getActiveSession(bot.id, openId);
+
+    if (!session) {
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        '没有进行中的测评会话。',
+      );
+      return;
+    }
+
+    try {
+      session.status = 'cancelled';
+      await this.sessionRepository.save(session);
+
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        '测评会话已取消。发送 /assessment start 开始新的测评。',
+      );
+    } catch (error) {
+      this.logger.error(`Failed to cancel assessment: ${error.message}`, error);
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        `取消测评失败: ${error.message}`,
+      );
+    }
+  }
+
+  /**
+   * 发送帮助信息
+   */
+  async sendHelp(bot: FeishuBot, openId: string): Promise<void> {
+    const helpText = this.commandParser.getHelpText('zh');
+    await this.feishuService.sendTextMessage(bot, 'open_id', openId, helpText);
+  }
+
+  /**
+   * 获取活跃会话
+   */
+  private async getActiveSession(
+    botId: string,
+    openId: string,
+  ): Promise<FeishuAssessmentSession | null> {
+    return this.sessionRepository.findOne({
+      where: {
+        botId,
+        openId,
+        status: 'active',
+      },
+      order: { createdAt: 'DESC' },
+    });
+  }
+
+  /**
+   * 构建问题卡片
+   */
+  private buildQuestionCard(
+    question: any,
+    sessionId: string,
+    currentIndex: number,
+    totalQuestions: number,
+  ): any {
+    const difficultyColors: Record<string, string> = {
+      简单: 'green',
+      普通: 'blue',
+      困难: 'orange',
+      专家: 'red',
+      Easy: 'green',
+      Medium: 'blue',
+      Hard: 'orange',
+      Advanced: 'orange',
+      Expert: 'red',
+      Specialist: 'red',
+    };
+
+    const difficulty = question.difficulty || '普通';
+    const headerColor = difficultyColors[difficulty] || 'blue';
+
+    return {
+      config: { wide_screen_mode: true },
+      header: {
+        template: headerColor,
+        title: {
+          content: `人才测评 (${currentIndex}/${totalQuestions})`,
+          tag: 'plain_text',
+        },
+      },
+      elements: [
+        {
+          tag: 'div',
+          text: {
+            content: `**问题 ${currentIndex}:** ${question.questionText || question.text || question.content || '无问题内容'}`,
+            tag: 'lark_md',
+          },
+        },
+        ...(question.options
+          ? [
+              {
+                tag: 'div',
+                text: {
+                  content: `选项:\n${question.options
+                    .map(
+                      (opt: string, i: number) =>
+                        `${String.fromCharCode(65 + i)}. ${opt}`,
+                    )
+                    .join('\n')}`,
+                  tag: 'lark_md',
+                },
+              },
+            ]
+          : []),
+        {
+          tag: 'div',
+          text: {
+            content: `难度: ${difficulty} | 分值: ${question.score || 1}`,
+            tag: 'lark_md',
+          },
+        },
+        {
+          tag: 'hr',
+        },
+        {
+          tag: 'note',
+          elements: [
+            {
+              content: `直接回复答案或使用 /assessment answer [你的答案]`,
+              tag: 'plain_text',
+            },
+          ],
+        },
+      ],
+    };
+  }
+
+  /**
+   * 发送测评结果
+   */
+  private async sendAssessmentResult(
+    bot: FeishuBot,
+    openId: string,
+    result: any,
+  ): Promise<void> {
+    const report = result.report || result.finalReport;
+    const score = result.finalScore || result.score;
+
+    const resultCard = {
+      config: { wide_screen_mode: true },
+      header: {
+        template: 'green',
+        title: {
+          content: '测评完成',
+          tag: 'plain_text',
+        },
+      },
+      elements: [
+        {
+          tag: 'div',
+          text: {
+            content: `**测评结果**`,
+            tag: 'lark_md',
+          },
+        },
+        ...(score !== undefined
+          ? [
+              {
+                tag: 'div',
+                text: {
+                  content: `**总分**: ${score.toFixed(1)}`,
+                  tag: 'lark_md',
+                },
+              },
+            ]
+          : []),
+        {
+          tag: 'div',
+          text: {
+            content: `**报告**:\n${report && report.trim().length > 0 ? report : '未生成详细报告。'}`,
+            tag: 'lark_md',
+          },
+        },
+        {
+          tag: 'hr',
+        },
+        {
+          tag: 'note',
+          elements: [
+            {
+              content: `发送 /assessment start 开始新的测评`,
+              tag: 'plain_text',
+            },
+          ],
+        },
+      ],
+    };
+
+    await this.feishuService.sendCardMessage(
+      bot,
+      'open_id',
+      openId,
+      resultCard,
+    );
+  }
+}

+ 16 - 16
server/src/i18n/i18n.interceptor.ts

@@ -1,8 +1,8 @@
 import {
-    Injectable,
-    NestInterceptor,
-    ExecutionContext,
-    CallHandler,
+  Injectable,
+  NestInterceptor,
+  ExecutionContext,
+  CallHandler,
 } from '@nestjs/common';
 import { Observable } from 'rxjs';
 import { i18nStore } from './i18n.store';
@@ -10,18 +10,18 @@ import { DEFAULT_LANGUAGE } from '../common/constants';
 
 @Injectable()
 export class I18nInterceptor implements NestInterceptor {
-    intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
-        const request = context.switchToHttp().getRequest();
-        const language = request.headers['x-user-language'] || DEFAULT_LANGUAGE;
+  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
+    const request = context.switchToHttp().getRequest();
+    const language = request.headers['x-user-language'] || DEFAULT_LANGUAGE;
 
-        return new Observable((observer) => {
-            i18nStore.run({ language: String(language) }, () => {
-                next.handle().subscribe({
-                    next: (value) => observer.next(value),
-                    error: (err) => observer.error(err),
-                    complete: () => observer.complete(),
-                });
-            });
+    return new Observable((observer) => {
+      i18nStore.run({ language: String(language) }, () => {
+        next.handle().subscribe({
+          next: (value) => observer.next(value),
+          error: (err) => observer.error(err),
+          complete: () => observer.complete(),
         });
-    }
+      });
+    });
+  }
 }

+ 6 - 6
server/src/i18n/i18n.middleware.ts

@@ -5,10 +5,10 @@ import { DEFAULT_LANGUAGE } from '../common/constants';
 
 @Injectable()
 export class I18nMiddleware implements NestMiddleware {
-    use(req: Request, res: Response, next: NextFunction) {
-        const language = req.headers['x-user-language'] || DEFAULT_LANGUAGE;
-        i18nStore.run({ language: String(language) }, () => {
-            next();
-        });
-    }
+  use(req: Request, res: Response, next: NextFunction) {
+    const language = req.headers['x-user-language'] || DEFAULT_LANGUAGE;
+    i18nStore.run({ language: String(language) }, () => {
+      next();
+    });
+  }
 }

+ 3 - 3
server/src/i18n/i18n.module.ts

@@ -3,7 +3,7 @@ import { I18nService } from './i18n.service';
 
 @Global()
 @Module({
-    providers: [I18nService],
-    exports: [I18nService],
+  providers: [I18nService],
+  exports: [I18nService],
 })
-export class I18nModule { }
+export class I18nModule {}

+ 74 - 24
server/src/i18n/i18n.service.ts

@@ -23,37 +23,56 @@ export class I18nService {
 
   getErrorMessage(key: string, language?: string): string {
     const lang = this.normalizeLanguage(language);
-    return errorMessages[lang]?.[key] || errorMessages[this.defaultLanguage][key] || key;
+    return (
+      errorMessages[lang]?.[key] ||
+      errorMessages[this.defaultLanguage][key] ||
+      key
+    );
   }
 
   getLogMessage(key: string, language?: string): string {
     const lang = this.normalizeLanguage(language);
-    return logMessages[lang]?.[key] || logMessages[this.defaultLanguage][key] || key;
+    return (
+      logMessages[lang]?.[key] || logMessages[this.defaultLanguage][key] || key
+    );
   }
 
   getStatusMessage(key: string, language?: string): string {
     const lang = this.normalizeLanguage(language);
-    return statusMessages[lang]?.[key] || statusMessages[this.defaultLanguage][key] || key;
+    return (
+      statusMessages[lang]?.[key] ||
+      statusMessages[this.defaultLanguage][key] ||
+      key
+    );
   }
 
   // 汎用メッセージ取得メソッド、順次検索
   getMessage(key: string, language?: string): string {
     const lang = this.normalizeLanguage(language);
     // ステータスメッセージ、エラーメッセージ、ログメッセージの順に検索
-    return statusMessages[lang]?.[key] ||
+    return (
+      statusMessages[lang]?.[key] ||
       statusMessages[this.defaultLanguage][key] ||
       errorMessages[lang]?.[key] ||
       errorMessages[this.defaultLanguage][key] ||
       logMessages[lang]?.[key] ||
       logMessages[this.defaultLanguage][key] ||
-      key;
+      key
+    );
   }
 
   // メッセージの取得とフォーマット
-  formatMessage(key: string, args: Record<string, any>, language?: string): string {
+  formatMessage(
+    key: string,
+    args: Record<string, any>,
+    language?: string,
+  ): string {
     let message = this.getMessage(key, language);
     for (const [argKey, argValue] of Object.entries(args)) {
-      message = message.replace(new RegExp(`\\{${argKey}\\}`, 'g'), String(argValue));
+      message = message.replace(
+        new RegExp(`\\{${argKey}\\}`, 'g'),
+        String(argValue),
+      );
     }
     return message;
   }
@@ -69,16 +88,27 @@ export class I18nService {
   }
 
   // システムプロンプトを取得
-  getPrompt(lang: string = this.defaultLanguage, type: 'withContext' | 'withoutContext' = 'withContext', hasKnowledgeGroup: boolean = false): string {
+  getPrompt(
+    lang: string = this.defaultLanguage,
+    type: 'withContext' | 'withoutContext' = 'withContext',
+    hasKnowledgeGroup: boolean = false,
+  ): string {
     const language = this.normalizeLanguage(lang);
-    const noMatchMsg = statusMessages[language]?.noMatchInKnowledgeGroup || statusMessages[this.defaultLanguage].noMatchInKnowledgeGroup;
+    const noMatchMsg =
+      statusMessages[language]?.noMatchInKnowledgeGroup ||
+      statusMessages[this.defaultLanguage].noMatchInKnowledgeGroup;
 
     if (language === 'zh') {
-      return type === 'withContext' ? `
+      return type === 'withContext'
+        ? `
 基于以下知识库内容回答用户问题。
-${hasKnowledgeGroup ? `
+${
+  hasKnowledgeGroup
+    ? `
 **重要提示**: 用户已选择特定知识组,请严格基于以下知识库内容回答。如果知识库中没有相关信息,请明确告知用户:"${noMatchMsg}",然后再提供答案。
-` : ''}
+`
+    : ''
+}
 知识库内容:
 {context}
 
@@ -119,7 +149,8 @@ ${hasKnowledgeGroup ? `
    - 回答精炼准确
    - 多步骤操作使用有序列表
    - 对比类信息建议用表格展示(如果适用)
-` : `
+`
+        : `
 作为智能助手,请回答用户的问题。
 
 历史对话:
@@ -130,11 +161,16 @@ ${hasKnowledgeGroup ? `
 请用Chinese回答。
 `;
     } else if (language === 'ja') {
-      return type === 'withContext' ? `
+      return type === 'withContext'
+        ? `
 以下のナレッジベースの内容に基づいて、ユーザーの質問に答えてください。
-${hasKnowledgeGroup ? `
+${
+  hasKnowledgeGroup
+    ? `
 **重要**: ユーザーが特定のナレッジグループを選択しました。以下のナレッジベースの内容に厳密に基づいて回答してください。関連情報がナレッジベースに見つからない場合は、回答を提供する前に、ユーザーに明示的に「${noMatchMsg}」と伝えてください。
-` : ''}
+`
+    : ''
+}
 ナレッジベースの内容:
 {context}
 
@@ -175,7 +211,8 @@ ${hasKnowledgeGroup ? `
    - 回答は簡潔かつ明確にします
    - マルチステップ プロセスには番号付きリストを使用します
    - 比較情報には表を使用します(該当する場合)
-` : `
+`
+        : `
 インテリジェントなアシスタントとして、ユーザーの質問に答えてください。
 
 会話履歴:
@@ -187,11 +224,16 @@ ${hasKnowledgeGroup ? `
 `;
     } else {
       // Fallback to English for any other language
-      return type === 'withContext' ? `
+      return type === 'withContext'
+        ? `
 Answer the user's question based on the following knowledge base content.
-${hasKnowledgeGroup ? `
+${
+  hasKnowledgeGroup
+    ? `
 **IMPORTANT**: The user has selected a specific knowledge group. Please answer strictly based on the knowledge base content below. If the relevant information is not found in the knowledge base, explicitly tell the user: "${noMatchMsg}", before providing an answer.
-` : ''}
+`
+    : ''
+}
 Knowledge Base CONTENT:
 {context}
 
@@ -232,7 +274,8 @@ Please answer in English and strictly follow these Markdown formatting guideline
    - Keep answers concise and clear
    - Use numbered lists for multi-step processes
    - Use tables for comparison information (if applicable)
-` : `
+`
+        : `
 As an intelligent assistant, please answer the user's question.
 
 Conversation history:
@@ -246,7 +289,10 @@ Please answer in English.
   }
 
   // タイトル生成用のプロンプトを取得
-  getDocumentTitlePrompt(lang: string = this.defaultLanguage, contentSample: string): string {
+  getDocumentTitlePrompt(
+    lang: string = this.defaultLanguage,
+    contentSample: string,
+  ): string {
     const language = this.normalizeLanguage(lang);
     if (language === 'zh') {
       return `你是一个文档分析师。请阅读以下文本(文档开头部分),并生成一个简炼、专业的标题(不超过50个字符)。
@@ -269,7 +315,11 @@ ${contentSample}`;
     }
   }
 
-  getChatTitlePrompt(lang: string = this.defaultLanguage, userMessage: string, aiResponse: string): string {
+  getChatTitlePrompt(
+    lang: string = this.defaultLanguage,
+    userMessage: string,
+    aiResponse: string,
+  ): string {
     const language = this.normalizeLanguage(lang);
     if (language === 'zh') {
       return `根据以下对话片段,生成一个简短、描述性的标题(不超过50个字符),总结讨论的主题。
@@ -294,4 +344,4 @@ User: ${userMessage}
 Assistant: ${aiResponse}`;
     }
   }
-}
+}

+ 1 - 1
server/src/i18n/i18n.store.ts

@@ -1,7 +1,7 @@
 import { AsyncLocalStorage } from 'async_hooks';
 
 export interface I18nContext {
-    language: string;
+  language: string;
 }
 
 export const i18nStore = new AsyncLocalStorage<I18nContext>();

+ 230 - 117
server/src/i18n/messages.ts

@@ -16,8 +16,10 @@ export const errorMessages = {
     chunkUnderflow: '切片大小 {size} 小于最小值 {min}。已自动调整',
     overlapOverflow: '重叠大小 {size} 超过上限 {max}。已自动调整',
     overlapUnderflow: '重叠大小 {size} 小于最小值 {min}。已自动调整',
-    overlapRatioExceeded: '重叠大小 {size} 超过切片大小的50% ({max})。已自动调整',
-    batchOverflowWarning: '建议切片大小不超过 {safeSize} 以避免批量处理溢出 (当前: {size}, 模型限制的 {percent}%)',
+    overlapRatioExceeded:
+      '重叠大小 {size} 超过切片大小的50% ({max})。已自动调整',
+    batchOverflowWarning:
+      '建议切片大小不超过 {safeSize} 以避免批量处理溢出 (当前: {size}, 模型限制的 {percent}%)',
     estimatedChunkCountExcessive: '预计切片数量过多 ({count}),处理可能较慢',
     contentAndTitleRequired: '内容和标题为必填项',
     embeddingModelNotFound: '找不到嵌入模型 {id} 或类型不是 embedding',
@@ -55,7 +57,8 @@ export const errorMessages = {
     promptRequired: '提示词是必填项',
     addLLMConfig: '请在系统设置中添加 LLM 模型',
     visionAnalysisFailed: '视觉分析失败: {message}',
-    visionSystemPrompt: '您是专业的文档分析助手。请分析此文档图像,并按以下要求以 JSON 格式返回:\n\n1. 提取所有可读文本(按阅读顺序,保持段落和格式)\n2. 识别图像/图表/表格(描述内容、含义和作用)\n3. 分析页面布局(仅文本/文本和图像混合/表格/图表等)\n4. 评估分析质量 (0-1)\n\n响应格式:\n{\n  "text": "完整的文本内容",\n  "images": [\n    {"type": "图表类型", "description": "详细描述", "position": 1}\n  ],\n  "layout": "布局说明",\n  "confidence": 0.95\n}',
+    visionSystemPrompt:
+      '您是专业的文档分析助手。请分析此文档图像,并按以下要求以 JSON 格式返回:\n\n1. 提取所有可读文本(按阅读顺序,保持段落和格式)\n2. 识别图像/图表/表格(描述内容、含义和作用)\n3. 分析页面布局(仅文本/文本和图像混合/表格/图表等)\n4. 评估分析质量 (0-1)\n\n响应格式:\n{\n  "text": "完整的文本内容",\n  "images": [\n    {"type": "图表类型", "description": "详细描述", "position": 1}\n  ],\n  "layout": "布局说明",\n  "confidence": 0.95\n}',
     retryMechanismError: '重试机制异常',
     imageLoadError: '无法读取图像: {message}',
     groupNotFound: '分组不存在',
@@ -79,7 +82,8 @@ export const errorMessages = {
   },
   ja: {
     noEmbeddingModel: '先にシステム設定で埋め込みモデルを設定してください',
-    searchFailed: 'ナレッジベース検索に失敗しました。一般的な知識に基づいて回答します...',
+    searchFailed:
+      'ナレッジベース検索に失敗しました。一般的な知識に基づいて回答します...',
     invalidApiKey: 'APIキーが無効です',
     fileNotFound: 'ファイルが見つかりません',
     insufficientQuota: '利用枠が不足しています',
@@ -90,20 +94,29 @@ export const errorMessages = {
     uploadSizeExceeded: 'ファイルサイズが制限: {size}, 最大許容: {max}',
     uploadModelRequired: '埋め込みモデルを選択する必要があります',
     uploadTypeUnsupported: 'サポートされていないファイル形式です: {type}',
-    chunkOverflow: 'チャンクサイズ {size} が上限 {max} ({reason}) を超えています。自動調整されました',
-    chunkUnderflow: 'チャンクサイズ {size} が最小値 {min} 未満.自動調整されました',
-    overlapOverflow: '重なりサイズ {size} が上限 {max} を超えています。自動調整されました',
-    overlapUnderflow: '重なりサイズ {size} が最小値 {min} 未満.自動調整されました',
-    overlapRatioExceeded: '重なりサイズ {size} がチャンクサイズの50% ({max}) を超えています。自動調整されました',
-    batchOverflowWarning: 'バッチ処理のオーバーフローを避けるため、チャンクサイズを {safeSize} 以下にすることをお勧めします (現在: {size}, モデル制限の {percent}%)',
-    estimatedChunkCountExcessive: '推定チャンク数が多すぎます ({count})。処理に時間がかかる可能性があります',
+    chunkOverflow:
+      'チャンクサイズ {size} が上限 {max} ({reason}) を超えています。自動調整されました',
+    chunkUnderflow:
+      'チャンクサイズ {size} が最小値 {min} 未満.自動調整されました',
+    overlapOverflow:
+      '重なりサイズ {size} が上限 {max} を超えています。自動調整されました',
+    overlapUnderflow:
+      '重なりサイズ {size} が最小値 {min} 未満.自動調整されました',
+    overlapRatioExceeded:
+      '重なりサイズ {size} がチャンクサイズの50% ({max}) を超えています。自動調整されました',
+    batchOverflowWarning:
+      'バッチ処理のオーバーフローを避けるため、チャンクサイズを {safeSize} 以下にすることをお勧めします (現在: {size}, モデル制限の {percent}%)',
+    estimatedChunkCountExcessive:
+      '推定チャンク数が多すぎます ({count})。処理に時間がかかる可能性があります',
     contentAndTitleRequired: '内容とタイトルは必須です',
-    embeddingModelNotFound: '埋め込みモデル {id} が見つかりません、またはタイプが embedding ではありません',
+    embeddingModelNotFound:
+      '埋め込みモデル {id} が見つかりません、またはタイプが embedding ではありません',
     ocrFailed: 'テキストの抽出に失敗しました: {message}',
     noImageUploaded: '画像がアップロードされていません',
     adminOnlyViewList: '管理者のみがユーザーリストを表示できます',
     passwordsRequired: '現在のパスワードと新しいパスワードは必須です',
-    newPasswordMinLength: '新しいパスワードは少なくとも6文字以上である必要があります',
+    newPasswordMinLength:
+      '新しいパスワードは少なくとも6文字以上である必要があります',
     adminOnlyCreateUser: '管理者のみがユーザーを作成できます',
     usernamePasswordRequired: 'ユーザー名とパスワードは必須です',
     passwordMinLength: 'パスワードは少なくとも6文字以上である必要があります',
@@ -113,7 +126,8 @@ export const errorMessages = {
     adminOnlyDeleteUser: '管理者のみがユーザーを削除できます',
     cannotDeleteSelf: '自分自身のアカウントを削除できません',
     cannotDeleteBuiltinAdmin: 'ビルトイン管理者アカウントを削除できません',
-    invalidMemberRole: '無効な役割です。USER と TENANT_ADMIN のみ許可されています',
+    invalidMemberRole:
+      '無効な役割です。USER と TENANT_ADMIN のみ許可されています',
     incorrectCredentials: 'ユーザー名またはパスワードが間違っています',
     incorrectCurrentPassword: '現在のパスワードが間違っています',
     usernameExists: 'ユーザー名が既に存在します',
@@ -124,8 +138,10 @@ export const errorMessages = {
     pdfFileNotFound: 'PDF ファイルが見つかりません',
     pdfFileEmpty: 'PDF ファイルが空.変換に失敗した可能性があります',
     pdfConversionFailed: 'PDF ファイルが存在しないか、変換に失敗しました',
-    pdfConversionFailedDetail: 'PDF 変換に失敗しました(ファイル ID: {id})。後でもう一度お試しください',
-    pdfPreviewNotSupported: 'このファイル形式はプレビューをサポートしていません',
+    pdfConversionFailedDetail:
+      'PDF 変換に失敗しました(ファイル ID: {id})。後でもう一度お試しください',
+    pdfPreviewNotSupported:
+      'このファイル形式はプレビューをサポートしていません',
     pdfServiceUnavailable: 'PDF サービスを利用できません: {message}',
     pageImageNotFound: 'ページ画像が見つかりません',
     pdfPageImageFailed: 'PDF ページの画像を取得できませんでした',
@@ -133,7 +149,8 @@ export const errorMessages = {
     promptRequired: 'プロンプトは必須です',
     addLLMConfig: 'システム設定で LLM モデルを追加してください',
     visionAnalysisFailed: 'ビジョン分析に失敗しました: {message}',
-    visionSystemPrompt: 'あなたはプロフェッショナルなドキュメント分析アシスタントです。このドキュメント画像を分析し、以下の要求に従ってJSON形式で返してください:\n\n1. すべての可読テキストを抽出(読み順で、段落と書式を維持)\n2. 画像/グラフ/テーブルを識別(内容、意味、役割を説明)\n3. ページレイアウトを分析(テキストのみ/テキストと画像混在/テーブル/グラフなど)\n4. 分析品質を評価 (0-1)\n\nレスポンス形式:\n{\n  "text": "完全なテキスト内容",\n  "images": [\n    {"type": "グラフタイプ", "description": "詳細説明", "position": 1}\n  ],\n  "layout": "レイアウト説明",\n  "confidence": 0.95\n}',
+    visionSystemPrompt:
+      'あなたはプロフェッショナルなドキュメント分析アシスタントです。このドキュメント画像を分析し、以下の要求に従ってJSON形式で返してください:\n\n1. すべての可読テキストを抽出(読み順で、段落と書式を維持)\n2. 画像/グラフ/テーブルを識別(内容、意味、役割を説明)\n3. ページレイアウトを分析(テキストのみ/テキストと画像混在/テーブル/グラフなど)\n4. 分析品質を評価 (0-1)\n\nレスポンス形式:\n{\n  "text": "完全なテキスト内容",\n  "images": [\n    {"type": "グラフタイプ", "description": "詳細説明", "position": 1}\n  ],\n  "layout": "レイアウト説明",\n  "confidence": 0.95\n}',
     retryMechanismError: '再試行メカニズムの異常',
     imageLoadError: '画像を読み込めません: {message}',
     groupNotFound: 'グループが存在しません',
@@ -146,18 +163,22 @@ export const errorMessages = {
     jwtSecretRequired: 'JWT_SECRET 環境変数が設定されていません',
     tenantNotFound: 'テナントが見つかりません',
     usernameRequired: 'ユーザー名は必須です',
-    passwordRequiredForNewUser: '新しいユーザー {username} のパスワードが必要です',
+    passwordRequiredForNewUser:
+      '新しいユーザー {username} のパスワードが必要です',
     importTaskNotFound: 'インポートタスクが見つかりません',
     sourcePathNotFound: 'ソースパスが見つかりません: {path}',
     targetGroupRequired: 'ターゲットグループが指定されていません',
     modelConfigNotFound: 'モデル設定が見つかりません: {id}',
     cannotUpdateOtherTenantModel: '他のテナントのモデルは更新できません',
     cannotDeleteOtherTenantModel: '他のテナントのモデルは削除できません',
-    elasticsearchHostRequired: 'ELASTICSEARCH_HOST 環境変数が設定されていません',
+    elasticsearchHostRequired:
+      'ELASTICSEARCH_HOST 環境変数が設定されていません',
   },
   en: {
-    noEmbeddingModel: 'Please configure embedding model in system settings first',
-    searchFailed: 'Knowledge base search failed, will answer based on general knowledge...',
+    noEmbeddingModel:
+      'Please configure embedding model in system settings first',
+    searchFailed:
+      'Knowledge base search failed, will answer based on general knowledge...',
     invalidApiKey: 'Invalid API key',
     fileNotFound: 'File not found',
     insufficientQuota: 'Insufficient quota',
@@ -168,15 +189,21 @@ export const errorMessages = {
     uploadSizeExceeded: 'File size exceeds limit: {size}, Max allowed: {max}',
     uploadModelRequired: 'Embedding model must be selected',
     uploadTypeUnsupported: 'Unsupported file type: {type}',
-    chunkOverflow: 'Chunk size {size} exceeds limit {max} ({reason}). Auto-adjusted',
+    chunkOverflow:
+      'Chunk size {size} exceeds limit {max} ({reason}). Auto-adjusted',
     chunkUnderflow: 'Chunk size {size} is below minimum {min}. Auto-adjusted',
     overlapOverflow: 'Overlap size {size} exceeds limit {max}. Auto-adjusted',
-    overlapUnderflow: 'Overlap size {size} is below minimum {min}. Auto-adjusted',
-    overlapRatioExceeded: 'Overlap size {size} exceeds 50% of chunk size ({max}). Auto-adjusted',
-    batchOverflowWarning: 'Recommended chunk size below {safeSize} to avoid batch overflow (Current: {size}, {percent}% of model limit)',
-    estimatedChunkCountExcessive: 'Estimated chunk count is too high ({count}). Processing may be slow',
+    overlapUnderflow:
+      'Overlap size {size} is below minimum {min}. Auto-adjusted',
+    overlapRatioExceeded:
+      'Overlap size {size} exceeds 50% of chunk size ({max}). Auto-adjusted',
+    batchOverflowWarning:
+      'Recommended chunk size below {safeSize} to avoid batch overflow (Current: {size}, {percent}% of model limit)',
+    estimatedChunkCountExcessive:
+      'Estimated chunk count is too high ({count}). Processing may be slow',
     contentAndTitleRequired: 'Content and Title are required',
-    embeddingModelNotFound: 'Embedding model {id} not found or type is not embedding',
+    embeddingModelNotFound:
+      'Embedding model {id} not found or type is not embedding',
     ocrFailed: 'Failed to extract text: {message}',
     noImageUploaded: 'No image uploaded',
     adminOnlyViewList: 'Only admins can view the user list',
@@ -203,7 +230,8 @@ export const errorMessages = {
     pdfFileNotFound: 'PDF file not found',
     pdfFileEmpty: 'PDF file is empty. Conversion may have failed',
     pdfConversionFailed: 'PDF file does not exist or conversion failed',
-    pdfConversionFailedDetail: 'PDF conversion failed for file ID: {id}. Please try again later.',
+    pdfConversionFailedDetail:
+      'PDF conversion failed for file ID: {id}. Please try again later.',
     pdfPreviewNotSupported: 'Preview is not supported for this file format',
     pdfServiceUnavailable: 'PDF service unavailable: {message}',
     pageImageNotFound: 'Page image not found',
@@ -212,7 +240,8 @@ export const errorMessages = {
     promptRequired: 'Prompt is required',
     addLLMConfig: 'Please add LLM model in system settings',
     visionAnalysisFailed: 'Vision analysis failed: {message}',
-    visionSystemPrompt: 'You are a professional document analysis assistant. Please analyze this document image and return in JSON format according to the following requirements:\n\n1. Extract all readable text (in reading order, preserving paragraphs and formatting)\n2. Identify images/charts/tables (describe content, meaning, and purpose)\n3. Analyze page layout (text only/mixed text and images/tables/charts, etc.)\n4. Evaluate analysis quality (0-1)\n\nResponse format:\n{\n  "text": "complete text content",\n  "images": [\n    {"type": "chart type", "description": "detailed description", "position": 1}\n  ],\n  "layout": "layout description",\n  "confidence": 0.95\n}',
+    visionSystemPrompt:
+      'You are a professional document analysis assistant. Please analyze this document image and return in JSON format according to the following requirements:\n\n1. Extract all readable text (in reading order, preserving paragraphs and formatting)\n2. Identify images/charts/tables (describe content, meaning, and purpose)\n3. Analyze page layout (text only/mixed text and images/tables/charts, etc.)\n4. Evaluate analysis quality (0-1)\n\nResponse format:\n{\n  "text": "complete text content",\n  "images": [\n    {"type": "chart type", "description": "detailed description", "position": 1}\n  ],\n  "layout": "layout description",\n  "confidence": 0.95\n}',
     retryMechanismError: 'Retry mechanism error',
     imageLoadError: 'Cannot load image: {message}',
     groupNotFound: 'Group not found',
@@ -222,7 +251,8 @@ export const errorMessages = {
     groupSyncSuccess: 'Group sync completed successfully',
     groupDeleted: 'Group deleted successfully',
     searchHistoryDeleted: 'Search history deleted successfully',
-    jwtSecretRequired: 'JWT_SECRET environment variable is required but not set',
+    jwtSecretRequired:
+      'JWT_SECRET environment variable is required but not set',
     tenantNotFound: 'Tenant not found',
     importTaskNotFound: 'Import task not found',
     sourcePathNotFound: 'Source path not found: {path}',
@@ -230,9 +260,12 @@ export const errorMessages = {
     modelConfigNotFound: 'Model config not found: {id}',
     cannotUpdateOtherTenantModel: 'Cannot update models from another tenant',
     cannotDeleteOtherTenantModel: 'Cannot delete models from another tenant',
-    elasticsearchHostRequired: 'ELASTICSEARCH_HOST environment variable is not set',
-    libreofficeUrlRequired: 'LIBREOFFICE_URL environment variable is required but not set',
-    pdfToImageConversionFailed: 'PDF to image conversion failed. No images were generated.',
+    elasticsearchHostRequired:
+      'ELASTICSEARCH_HOST environment variable is not set',
+    libreofficeUrlRequired:
+      'LIBREOFFICE_URL environment variable is required but not set',
+    pdfToImageConversionFailed:
+      'PDF to image conversion failed. No images were generated.',
     pdfPageCountError: 'Could not get PDF page count',
     parentCategoryNotFound: 'Parent category not found',
     categoryNotFound: 'Category not found',
@@ -243,7 +276,7 @@ export const errorMessages = {
     vectorRequired: 'Vector is required for indexing',
     apiCallFailed: 'API call failed: {message}',
     tikaHostRequired: 'TIKA_HOST environment variable is required but not set',
-  }
+  },
 };
 
 export const logMessages = {
@@ -277,22 +310,33 @@ export const logMessages = {
     indexingComplete: 'インデックス完了: {id}',
     vectorizingFile: 'ファイルベクトル化中: ',
     searchQuery: '検索クエリ: ',
-    modelCall: '[モデル呼び出し] タイプ: {type}, モデル: {model}, ユーザー: {user}',
+    modelCall:
+      '[モデル呼び出し] タイプ: {type}, モデル: {model}, ユーザー: {user}',
     memoryStatus: 'メモリ状態: ',
-    uploadSuccess: 'ファイルが正常にアップロードされました。バックグラウンドでインデックス処理を実行中です',
-    overlapAdjusted: 'オーバーラップサイズがチャンクサイズの50%を超えています。自動的に {newSize} に調整されました',
+    uploadSuccess:
+      'ファイルが正常にアップロードされました。バックグラウンドでインデックス処理を実行中です',
+    overlapAdjusted:
+      'オーバーラップサイズがチャンクサイズの50%を超えています。自動的に {newSize} に調整されました',
     environmentLimit: '環境変数の制限',
     modelLimit: 'モデルの制限',
     configLoaded: 'データベースからモデル設定を読み込みました: {name} ({id})',
-    batchSizeAdjusted: 'バッチサイズを {old} から {new} に調整しました (モデル制限: {limit})',
-    dimensionMismatch: 'モデル {id} の次元が一致しません: 期待値 {expected}, 実際 {actual}',
-    searchMetadataFailed: 'ユーザー {userId} のナレッジベース検索に失敗しました',
+    batchSizeAdjusted:
+      'バッチサイズを {old} から {new} に調整しました (モデル制限: {limit})',
+    dimensionMismatch:
+      'モデル {id} の次元が一致しません: 期待値 {expected}, 実際 {actual}',
+    searchMetadataFailed:
+      'ユーザー {userId} のナレッジベース検索に失敗しました',
     extractedTextTooLarge: '抽出されたテキストが大きいです: {size}MB',
-    preciseModeUnsupported: 'ファイル形式 {ext} は精密モードをサポートしていません。高速モードにフォールバックします',
-    visionModelNotConfiguredFallback: 'ビジョンモデルが設定されていません。高速モードにフォールバックします',
-    visionModelInvalidFallback: 'ビジョンモデルの設定が無効です。高速モードにフォールバックします',
-    visionPipelineFailed: 'ビジョンパイプラインが失敗しました。高速モードにフォールバックします',
-    preciseModeComplete: '精密モード内容抽出完了: {pages}ページ, コスト: ${cost}',
+    preciseModeUnsupported:
+      'ファイル形式 {ext} は精密モードをサポートしていません。高速モードにフォールバックします',
+    visionModelNotConfiguredFallback:
+      'ビジョンモデルが設定されていません。高速モードにフォールバックします',
+    visionModelInvalidFallback:
+      'ビジョンモデルの設定が無効です。高速モードにフォールバックします',
+    visionPipelineFailed:
+      'ビジョンパイプラインが失敗しました。高速モードにフォールバックします',
+    preciseModeComplete:
+      '精密モード内容抽出完了: {pages}ページ, コスト: ${cost}',
     skippingEmptyVectorPage: '第 {page} ページの空ベクトルをスキップします',
     pdfPageImageError: 'PDF ページの画像取得に失敗しました: {message}',
     internalServerError: 'サーバー内部エラー',
@@ -305,23 +349,30 @@ export const logMessages = {
     modelCall: '[Model call] Type: {type}, Model: {model}, User: {user}',
     memoryStatus: 'Memory status: ',
     uploadSuccess: 'File uploaded successfully. Indexing in background',
-    overlapAdjusted: 'Overlap size exceeds 50% of chunk size. Auto-adjusted to {newSize}',
+    overlapAdjusted:
+      'Overlap size exceeds 50% of chunk size. Auto-adjusted to {newSize}',
     environmentLimit: 'Environment variable limit',
     modelLimit: 'Model limit',
     configLoaded: 'Model config loaded from DB: {name} ({id})',
-    batchSizeAdjusted: 'Batch size adjusted from {old} to {new} (Model limit: {limit})',
-    dimensionMismatch: 'Model {id} dimension mismatch: Expected {expected}, Actual {actual}',
+    batchSizeAdjusted:
+      'Batch size adjusted from {old} to {new} (Model limit: {limit})',
+    dimensionMismatch:
+      'Model {id} dimension mismatch: Expected {expected}, Actual {actual}',
     searchMetadataFailed: 'Failed to search knowledge base for user {userId}',
     extractedTextTooLarge: 'Extracted text is too large: {size}MB',
-    preciseModeUnsupported: 'Format {ext} not supported for precise mode. Falling back to fast mode',
-    visionModelNotConfiguredFallback: 'Vision model not configured. Falling back to fast mode',
-    visionModelInvalidFallback: 'Vision model config invalid. Falling back to fast mode',
+    preciseModeUnsupported:
+      'Format {ext} not supported for precise mode. Falling back to fast mode',
+    visionModelNotConfiguredFallback:
+      'Vision model not configured. Falling back to fast mode',
+    visionModelInvalidFallback:
+      'Vision model config invalid. Falling back to fast mode',
     visionPipelineFailed: 'Vision pipeline failed. Falling back to fast mode',
-    preciseModeComplete: 'Precise mode extraction complete: {pages} pages, cost: ${cost}',
+    preciseModeComplete:
+      'Precise mode extraction complete: {pages} pages, cost: ${cost}',
     skippingEmptyVectorPage: 'Skipping page {page} due to empty vector',
     pdfPageImageError: 'Failed to retrieve PDF page image: {message}',
     internalServerError: 'Internal server error',
-  }
+  },
 };
 
 export const statusMessages = {
@@ -357,7 +408,8 @@ export const statusMessages = {
     userLabel: '用户',
     assistantLabel: '助手',
     intelligentAssistant: '您是智能写作助手。',
-    assistSystemPrompt: '请根据用户的指示修正或改进提供的文本内容。不要包含问候语或结束语(如"明白了,这是..."等),直接输出修正后的内容。',
+    assistSystemPrompt:
+      '请根据用户的指示修正或改进提供的文本内容。不要包含问候语或结束语(如"明白了,这是..."等),直接输出修正后的内容。',
     contextLabel: '上下文(当前内容)',
     userInstructionLabel: '用户指示',
     searchString: '搜索字符串: ',
@@ -372,7 +424,8 @@ export const statusMessages = {
     getContextForTopicFailed: '获取主题上下文失败',
     noLLMConfigured: '用户未配置LLM模型',
     simpleChatGenerationError: '简单聊天生成错误',
-    noMatchInKnowledgeGroup: '所选知识组中未找到相关内容,以下是基于模型的一般性回答:',
+    noMatchInKnowledgeGroup:
+      '所选知识组中未找到相关内容,以下是基于模型的一般性回答:',
     uploadTextSuccess: '笔记内容已接收。正在后台索引',
     passwordChanged: '密码已成功修改',
     userCreated: '用户已成功创建',
@@ -387,44 +440,58 @@ export const statusMessages = {
     fileDeletedFromGroup: '文件已从分组中删除',
     chunkConfigCorrection: '切片配置已修正: {warnings}',
     noChunksGenerated: '文件 {id} 未生成任何切片',
-    chunkCountAnomaly: '实际切片数 {actual} 大幅超过预计值 {estimated},可能存在异常',
-    batchSizeExceeded: '批次 {index} 的大小 {actual} 超过推荐值 {limit},将拆分处理',
+    chunkCountAnomaly:
+      '实际切片数 {actual} 大幅超过预计值 {estimated},可能存在异常',
+    batchSizeExceeded:
+      '批次 {index} 的大小 {actual} 超过推荐值 {limit},将拆分处理',
     skippingEmptyVectorChunk: '跳过文本块 {index} (空向量)',
-    contextLengthErrorFallback: '批次处理发生上下文长度错误,降级到逐条处理模式',
-    chunkLimitExceededForceBatch: '切片数 {actual} 超过模型批次限制 {limit},强制进行批次处理',
+    contextLengthErrorFallback:
+      '批次处理发生上下文长度错误,降级到逐条处理模式',
+    chunkLimitExceededForceBatch:
+      '切片数 {actual} 超过模型批次限制 {limit},强制进行批次处理',
     noteContentRequired: '笔记内容是必填项',
     imageAnalysisStarted: '正在使用模型 {id} 分析图像...',
     batchAnalysisStarted: '正在分析 {count} 张图像...',
     pageAnalysisFailed: '第 {page} 页分析失败',
-    visionSystemPrompt: '您是专业的文档分析助手。请分析此文档图像,并按以下要求以 JSON 格式返回:\n\n1. 提取所有可读文本(按阅读顺序,保持段落和格式)\n2. 识别图像/图表/表格(描述内容、含义和作用)\n3. 分析页面布局(仅文本/文本和图像混合/表格/图表等)\n4. 评估分析质量 (0-1)\n\n响应格式:\n{\n  "text": "完整的文本内容",\n  "images": [\n    {"type": "图表类型", "description": "详细描述", "position": 1}\n  ],\n  "layout": "布局说明",\n  "confidence": 0.95\n}',
+    visionSystemPrompt:
+      '您是专业的文档分析助手。请分析此文档图像,并按以下要求以 JSON 格式返回:\n\n1. 提取所有可读文本(按阅读顺序,保持段落和格式)\n2. 识别图像/图表/表格(描述内容、含义和作用)\n3. 分析页面布局(仅文本/文本和图像混合/表格/图表等)\n4. 评估分析质量 (0-1)\n\n响应格式:\n{\n  "text": "完整的文本内容",\n  "images": [\n    {"type": "图表类型", "description": "详细描述", "position": 1}\n  ],\n  "layout": "布局说明",\n  "confidence": 0.95\n}',
     visionModelCall: '[模型调用] 类型: Vision, 模型: {model}, 页面: {page}',
-    visionAnalysisSuccess: '✅ 视觉分析完成: {path}{page}, 文本长度: {textLen}, 图像数: {imgCount}, 布局: {layout}, 置信度: {confidence}%',
+    visionAnalysisSuccess:
+      '✅ 视觉分析完成: {path}{page}, 文本长度: {textLen}, 图像数: {imgCount}, 布局: {layout}, 置信度: {confidence}%',
     conversationHistoryNotFound: '对话历史不存在',
-    batchContextLengthErrorFallback: '小文件批次处理发生上下文长度错误,降级到逐条处理模式',
+    batchContextLengthErrorFallback:
+      '小文件批次处理发生上下文长度错误,降级到逐条处理模式',
     chunkProcessingFailed: '处理文本块 {index} 失败,已跳过: {message}',
     singleTextProcessingComplete: '逐条文本处理完成: {count} 个切片',
-    fileVectorizationComplete: '文件 {id} 向量化完成。共处理 {count} 个文本块。最终内存: {memory}MB',
+    fileVectorizationComplete:
+      '文件 {id} 向量化完成。共处理 {count} 个文本块。最终内存: {memory}MB',
     fileVectorizationFailed: '文件 {id} 向量化失败',
     batchProcessingStarted: '开始批次处理: {count} 个项目',
     batchProcessingProgress: '正在处理批次 {index}/{total}: {count} 个项目',
     batchProcessingComplete: '批次处理完成: {count} 个项目,耗时 {duration}s',
     onlyFailedFilesRetryable: '仅允许重试失败的文件 (当前状态: {status})',
     emptyFileRetryFailed: '文件内容为空,无法重试。请重新上传文件。',
-    ragSystemPrompt: '您是专业的知识库助手。请根据以下提供的文档内容回答用户的问题。',
-    ragRules: '## 规则:\n1. 仅根据提供的文档内容进行回答,请勿编造信息。\n2. 如果文档中没有相关信息,请告知用户。\n3. 请在回答中注明信息来源。格式:[文件名.扩展子]\n4. 如果多个文档中的信息存在矛盾,请进行综合分析或解释不同的观点。\n5. 请使用{lang}进行回答。',
+    ragSystemPrompt:
+      '您是专业的知识库助手。请根据以下提供的文档内容回答用户的问题。',
+    ragRules:
+      '## 规则:\n1. 仅根据提供的文档内容进行回答,请勿编造信息。\n2. 如果文档中没有相关信息,请告知用户。\n3. 请在回答中注明信息来源。格式:[文件名.扩展子]\n4. 如果多个文档中的信息存在矛盾,请进行综合分析或解释不同的观点。\n5. 请使用{lang}进行回答。',
     ragDocumentContent: '## 文档内容:',
     ragUserQuestion: '## 用户问题:',
     ragAnswer: '## 回答:',
     ragSource: '### 来源:{fileName}',
     ragSegment: '片段 {index} (相似度: {score}):',
     ragNoDocumentFound: '未找到相关文档。',
-    queryExpansionPrompt: '您是一个搜索助手。请为以下用户查询生成3个不同的演变版本,以帮助在向量搜索中获得更好的结果。每个版本应包含不同的关键词或表达方式,但保持原始意思。直接输出3行查询,不要有数字或编号:\n\n查询:{query}',
-    hydePrompt: '请为以下用户问题写一段简短、事实性的假设回答(约100字)。不要包含任何引导性文字(如“基于我的分析...”),直接输出答案内容。\n\n问题:{query}',
+    queryExpansionPrompt:
+      '您是一个搜索助手。请为以下用户查询生成3个不同的演变版本,以帮助在向量搜索中获得更好的结果。每个版本应包含不同的关键词或表达方式,但保持原始意思。直接输出3行查询,不要有数字或编号:\n\n查询:{query}',
+    hydePrompt:
+      '请为以下用户问题写一段简短、事实性的假设回答(约100字)。不要包含任何引导性文字(如“基于我的分析...”),直接输出答案内容。\n\n问题:{query}',
   },
   ja: {
     searching: 'ナレッジベースを検索中...',
-    noResults: '関連する知識が見つかりませんでした。一般的な知識に基づいて回答します...',
-    searchFailed: 'ナレッジベース検索に失敗しました。一般的な知識に基づいて回答します...',
+    noResults:
+      '関連する知識が見つかりませんでした。一般的な知識に基づいて回答します...',
+    searchFailed:
+      'ナレッジベース検索に失敗しました。一般的な知識に基づいて回答します...',
     generatingResponse: '回答を生成中',
     files: '個のファイル',
     notebooks: '個のノートブック',
@@ -439,7 +506,8 @@ export const statusMessages = {
     error: 'エラー',
     creatingHistory: '新規対話履歴を作成: ',
     searchingModelById: 'selectedEmbeddingId に基づいてモデルを検索: ',
-    searchModelFallback: '指定された埋め込みモデルが見つかりません。最初に使用可能なモデルを使用します。',
+    searchModelFallback:
+      '指定された埋め込みモデルが見つかりません。最初に使用可能なモデルを使用します。',
     noEmbeddingModelFound: '埋め込みモデルの設定が見つかりません',
     usingEmbeddingModel: '使用する埋め込みモデル: ',
     startingSearch: 'ナレッジベースの検索を開始...',
@@ -452,7 +520,8 @@ export const statusMessages = {
     userLabel: 'ユーザー',
     assistantLabel: 'アシスタント',
     intelligentAssistant: 'あなたはインテリジェントな執筆アシスタントです。',
-    assistSystemPrompt: '提供されたテキスト内容を、ユーザーの指示に基づいて修正または改善してください。挨拶や結びの言葉(「わかりました、こちらが...」など)は含めず、修正後の内容のみを直接出力してください。',
+    assistSystemPrompt:
+      '提供されたテキスト内容を、ユーザーの指示に基づいて修正または改善してください。挨拶や結びの言葉(「わかりました、こちらが...」など)は含めず、修正後の内容のみを直接出力してください。',
     contextLabel: 'コンテキスト(現在の内容)',
     userInstructionLabel: 'ユーザーの指示',
     searchString: '検索文字列: ',
@@ -467,8 +536,10 @@ export const statusMessages = {
     getContextForTopicFailed: 'トピックのコンテキスト取得に失敗しました',
     noLLMConfigured: 'ユーザーにLLMモデルが設定されていません',
     simpleChatGenerationError: '簡易チャット生成エラー',
-    noMatchInKnowledgeGroup: '選択された知識グループに関連する内容が見つかりませんでした。以下はモデルに基づく一般的な回答です:',
-    uploadTextSuccess: 'ノート内容を受け取りました。バックグラウンドでインデックス処理を実行中です',
+    noMatchInKnowledgeGroup:
+      '選択された知識グループに関連する内容が見つかりませんでした。以下はモデルに基づく一般的な回答です:',
+    uploadTextSuccess:
+      'ノート内容を受け取りました。バックグラウンドでインデックス処理を実行中です',
     passwordChanged: 'パスワードが正常に変更されました',
     userCreated: 'ユーザーが正常に作成されました',
     userInfoUpdated: 'ユーザー情報が更新されました',
@@ -481,45 +552,67 @@ export const statusMessages = {
     groupSyncSuccess: 'ファイルグループが更新されました',
     fileDeletedFromGroup: 'ファイルがグループから削除されました',
     chunkConfigCorrection: 'チャンク設定の修正: {warnings}',
-    noChunksGenerated: 'ファイル {id} からテキストチャンクが生成されませんでした',
-    chunkCountAnomaly: '実際のチャンク数 {actual} が推定値 {estimated} を大幅に超えています。異常がある可能性があります',
-    batchSizeExceeded: 'バッチ {index} のサイズ {actual} が推奨値 {limit} を超えています。分割して処理します',
-    skippingEmptyVectorChunk: '空ベクトルのテキストブロック {index} をスキップします',
-    contextLengthErrorFallback: 'バッチ処理でコンテキスト長エラーが発生しました。単一テキスト処理モードにダウングレードします',
-    chunkLimitExceededForceBatch: 'チャンク数 {actual} がモデルのバッチ制限 {limit} を超えています。強制的にバッチ処理を行います',
+    noChunksGenerated:
+      'ファイル {id} からテキストチャンクが生成されませんでした',
+    chunkCountAnomaly:
+      '実際のチャンク数 {actual} が推定値 {estimated} を大幅に超えています。異常がある可能性があります',
+    batchSizeExceeded:
+      'バッチ {index} のサイズ {actual} が推奨値 {limit} を超えています。分割して処理します',
+    skippingEmptyVectorChunk:
+      '空ベクトルのテキストブロック {index} をスキップします',
+    contextLengthErrorFallback:
+      'バッチ処理でコンテキスト長エラーが発生しました。単一テキスト処理モードにダウングレードします',
+    chunkLimitExceededForceBatch:
+      'チャンク数 {actual} がモデルのバッチ制限 {limit} を超えています。強制的にバッチ処理を行います',
     noteContentRequired: 'ノート内容は必須です',
     imageAnalysisStarted: 'モデル {id} で画像を分析中...',
     batchAnalysisStarted: '{count} 枚の画像を分析中...',
     pageAnalysisFailed: '第 {page} ページの分析に失敗しました',
-    visionSystemPrompt: 'あなたは専門的なドキュメント分析アシスタントです。このドキュメント画像を分析し、以下の要求に従って JSON 形式で返してください:\n\n1. すべての読み取り可能なテキストを抽出(読み取り順序に従い、段落と形式を保持)\n2. 画像/グラフ/表の識別(内容、意味、役割を記述)\n3. ページレイアウトの分析(テキストのみ/テキストと画像の混合/表/グラフなど)\n4. 分析品質の評価(0-1)\n\nレスポンス形式:\n{\n  "text": "完全なテキスト内容",\n  "images": [\n    {"type": "グラフの種類", "description": "詳細な記述", "position": 1}\n  ],\n  "layout": "レイアウトの説明",\n  "confidence": 0.95\n}',
-    visionModelCall: '[モデル呼び出し] タイプ: Vision, モデル: {model}, ページ: {page}',
-    visionAnalysisSuccess: '✅ Vision 分析完了: {path}{page}, テキスト長: {textLen}文字, 画像数: {imgCount}, レイアウト: {layout}, 信頼度: {confidence}%',
+    visionSystemPrompt:
+      'あなたは専門的なドキュメント分析アシスタントです。このドキュメント画像を分析し、以下の要求に従って JSON 形式で返してください:\n\n1. すべての読み取り可能なテキストを抽出(読み取り順序に従い、段落と形式を保持)\n2. 画像/グラフ/表の識別(内容、意味、役割を記述)\n3. ページレイアウトの分析(テキストのみ/テキストと画像の混合/表/グラフなど)\n4. 分析品質の評価(0-1)\n\nレスポンス形式:\n{\n  "text": "完全なテキスト内容",\n  "images": [\n    {"type": "グラフの種類", "description": "詳細な記述", "position": 1}\n  ],\n  "layout": "レイアウトの説明",\n  "confidence": 0.95\n}',
+    visionModelCall:
+      '[モデル呼び出し] タイプ: Vision, モデル: {model}, ページ: {page}',
+    visionAnalysisSuccess:
+      '✅ Vision 分析完了: {path}{page}, テキスト長: {textLen}文字, 画像数: {imgCount}, レイアウト: {layout}, 信頼度: {confidence}%',
     conversationHistoryNotFound: '会話履歴が存在しません',
-    batchContextLengthErrorFallback: '小ファイルバッチ処理でコンテキスト長エラーが発生しました。単一テキスト処理モードにダウングレードします',
-    chunkProcessingFailed: 'テキストブロック {index} の処理に失敗しました。スキップします: {message}',
+    batchContextLengthErrorFallback:
+      '小ファイルバッチ処理でコンテキスト長エラーが発生しました。単一テキスト処理モードにダウングレードします',
+    chunkProcessingFailed:
+      'テキストブロック {index} の処理に失敗しました。スキップします: {message}',
     singleTextProcessingComplete: '単一テキスト処理完了: {count} チャンク',
-    fileVectorizationComplete: 'ファイル {id} ベクトル化完了。{count} 個のテキストブロックを処理しました。最終メモリ: {memory}MB',
+    fileVectorizationComplete:
+      'ファイル {id} ベクトル化完了。{count} 個のテキストブロックを処理しました。最終メモリ: {memory}MB',
     fileVectorizationFailed: 'ファイル {id} ベクトル化失敗',
     batchProcessingStarted: 'バッチ処理を開始します: {count} アイテム',
-    batchProcessingProgress: 'バッチ {index}/{total} を処理中: {count} 個のアイテム',
-    batchProcessingComplete: 'バッチ処理完了: {count} アイテム, 所要時間 {duration}s',
-    onlyFailedFilesRetryable: '失敗したファイルのみ再試行可能です (現在のステータス: {status})',
-    emptyFileRetryFailed: 'ファイル内容が空です。再試行できません。ファイルを再アップロードしてください。',
-    ragSystemPrompt: 'あなたは専門的なナレッジベースアシスタントです。以下の提供されたドキュメントの内容に基づいて、ユーザーの質問に答えてください。',
-    ragRules: '## ルール:\n1. 提供されたドキュメントの内容のみに基づいて回答し、情報を捏造しないでください。\n2. ドキュメントに関連情報がない場合は、その旨をユーザーに伝えてください。\n3. 回答には情報源を明記してください。形式:[ファイル名.拡張子]\n4. 複数のドキュメントで情報が矛盾している場合は、総合的に分析するか、異なる視点を説明してください。\n5. {lang}で回答してください。',
+    batchProcessingProgress:
+      'バッチ {index}/{total} を処理中: {count} 個のアイテム',
+    batchProcessingComplete:
+      'バッチ処理完了: {count} アイテム, 所要時間 {duration}s',
+    onlyFailedFilesRetryable:
+      '失敗したファイルのみ再試行可能です (現在のステータス: {status})',
+    emptyFileRetryFailed:
+      'ファイル内容が空です。再試行できません。ファイルを再アップロードしてください。',
+    ragSystemPrompt:
+      'あなたは専門的なナレッジベースアシスタントです。以下の提供されたドキュメントの内容に基づいて、ユーザーの質問に答えてください。',
+    ragRules:
+      '## ルール:\n1. 提供されたドキュメントの内容のみに基づいて回答し、情報を捏造しないでください。\n2. ドキュメントに関連情報がない場合は、その旨をユーザーに伝えてください。\n3. 回答には情報源を明記してください。形式:[ファイル名.拡張子]\n4. 複数のドキュメントで情報が矛盾している場合は、総合的に分析するか、異なる視点を説明してください。\n5. {lang}で回答してください。',
     ragDocumentContent: '## ドキュメント内容:',
     ragUserQuestion: '## ユーザーの質問:',
     ragAnswer: '## 回答:',
     ragSource: '### ソース:{fileName}',
     ragSegment: 'セグメント {index} (類似度: {score}):',
     ragNoDocumentFound: '関連するドキュメントが見つかりませんでした。',
-    queryExpansionPrompt: 'あなたは検索アシスタントです。以下のユーザーのクエリに対して、ベクトル検索でより良い結果を得るために、3つの異なるバリエーションを生成してください。各バリエーションは異なるキーワードや表現を使用しつつ、元の意味を維持する必要があります。数字やプレフィックスなしで、3行のクエリを直接出力してください:\n\nクエリ:{query}',
-    hydePrompt: '以下のユーザーの質問に対して、簡潔で事実に基づいた仮説的な回答(約200文字)を書いてください。「私の分析によると...」などの導入文は含めず、回答内容のみを直接出力してください。\n\n質問:{query}',
+    queryExpansionPrompt:
+      'あなたは検索アシスタントです。以下のユーザーのクエリに対して、ベクトル検索でより良い結果を得るために、3つの異なるバリエーションを生成してください。各バリエーションは異なるキーワードや表現を使用しつつ、元の意味を維持する必要があります。数字やプレフィックスなしで、3行のクエリを直接出力してください:\n\nクエリ:{query}',
+    hydePrompt:
+      '以下のユーザーの質問に対して、簡潔で事実に基づいた仮説的な回答(約200文字)を書いてください。「私の分析によると...」などの導入文は含めず、回答内容のみを直接出力してください。\n\n質問:{query}',
   },
   en: {
     searching: 'Searching knowledge base...',
-    noResults: 'No relevant knowledge found, will answer based on general knowledge...',
-    searchFailed: 'Knowledge base search failed, will answer based on general knowledge...',
+    noResults:
+      'No relevant knowledge found, will answer based on general knowledge...',
+    searchFailed:
+      'Knowledge base search failed, will answer based on general knowledge...',
     generatingResponse: 'Generating response',
     files: ' files',
     notebooks: ' notebooks',
@@ -534,7 +627,8 @@ export const statusMessages = {
     error: 'Error',
     creatingHistory: 'Creating new chat history: ',
     searchingModelById: 'Searching model by ID: ',
-    searchModelFallback: 'Specified embedding model not found. Using first available model.',
+    searchModelFallback:
+      'Specified embedding model not found. Using first available model.',
     noEmbeddingModelFound: 'No embedding model settings found',
     usingEmbeddingModel: 'Using embedding model: ',
     startingSearch: 'Starting knowledge base search...',
@@ -547,7 +641,8 @@ export const statusMessages = {
     userLabel: 'User',
     assistantLabel: 'Assistant',
     intelligentAssistant: 'You are an intelligent writing assistant.',
-    assistSystemPrompt: 'Please revise or improve the provided text content based on the user\'s instructions. Do not include greetings or closing phrases (such as "Understood, here is..." etc.), output only the revised content directly.',
+    assistSystemPrompt:
+      'Please revise or improve the provided text content based on the user\'s instructions. Do not include greetings or closing phrases (such as "Understood, here is..." etc.), output only the revised content directly.',
     contextLabel: 'Context (current content)',
     userInstructionLabel: 'User instructions',
     searchString: 'Search string: ',
@@ -562,7 +657,8 @@ export const statusMessages = {
     getContextForTopicFailed: 'getContextForTopic failed',
     noLLMConfigured: 'No LLM model configured for user',
     simpleChatGenerationError: 'Simple chat generation error',
-    noMatchInKnowledgeGroup: 'No relevant content found in the selected knowledge group. The following is a general answer based on the model:',
+    noMatchInKnowledgeGroup:
+      'No relevant content found in the selected knowledge group. The following is a general answer based on the model:',
     uploadTextSuccess: 'Note content received. Indexing in background',
     passwordChanged: 'Password changed successfully',
     userCreated: 'User created successfully',
@@ -577,38 +673,55 @@ export const statusMessages = {
     fileDeletedFromGroup: 'File removed from group',
     chunkConfigCorrection: 'Chunk config corrected: {warnings}',
     noChunksGenerated: 'No chunks generated for file {id}',
-    chunkCountAnomaly: 'Actual chunk count {actual} significantly exceeds estimate {estimated}. Possible anomaly.',
-    batchSizeExceeded: 'Batch {index} size {actual} exceeds recommended limit {limit}. Splitting for processing.',
+    chunkCountAnomaly:
+      'Actual chunk count {actual} significantly exceeds estimate {estimated}. Possible anomaly.',
+    batchSizeExceeded:
+      'Batch {index} size {actual} exceeds recommended limit {limit}. Splitting for processing.',
     skippingEmptyVectorChunk: 'Skipping text block {index} due to empty vector',
-    contextLengthErrorFallback: 'Context length error occurred during batch processing. Downgrading to single processing mode.',
-    chunkLimitExceededForceBatch: 'Chunk count {actual} exceeds model batch limit {limit}. Forcing batch processing.',
+    contextLengthErrorFallback:
+      'Context length error occurred during batch processing. Downgrading to single processing mode.',
+    chunkLimitExceededForceBatch:
+      'Chunk count {actual} exceeds model batch limit {limit}. Forcing batch processing.',
     noteContentRequired: 'Note content is required',
     imageAnalysisStarted: 'Analyzing image with model {id}...',
     batchAnalysisStarted: 'Batch analyzing {count} images...',
     pageAnalysisFailed: 'Failed to analyze page {page}',
-    visionSystemPrompt: 'You are a professional document analysis assistant. Analyze this document image and return in JSON format according to these requirements:\n\n1. Extract all readable text (follow reading order, maintain paragraphs and formatting)\n2. Identify images/graphs/tables (describe content, meaning, and role)\n3. Analyze page layout (text only/mixed/table/graph, etc.)\n4. Evaluate analysis quality (0-1)\n\nResponse format:\n{\n  "text": "full text content",\n  "images": [\n    {"type": "graph type", "description": "detailed description", "position": 1}\n  ],\n  "layout": "layout description",\n  "confidence": 0.95\n}',
+    visionSystemPrompt:
+      'You are a professional document analysis assistant. Analyze this document image and return in JSON format according to these requirements:\n\n1. Extract all readable text (follow reading order, maintain paragraphs and formatting)\n2. Identify images/graphs/tables (describe content, meaning, and role)\n3. Analyze page layout (text only/mixed/table/graph, etc.)\n4. Evaluate analysis quality (0-1)\n\nResponse format:\n{\n  "text": "full text content",\n  "images": [\n    {"type": "graph type", "description": "detailed description", "position": 1}\n  ],\n  "layout": "layout description",\n  "confidence": 0.95\n}',
     visionModelCall: '[Model Call] Type: Vision, Model: {model}, Page: {page}',
-    visionAnalysisSuccess: '✅ Vision analysis complete: {path}{page}, Text length: {textLen}, Images: {imgCount}, Layout: {layout}, Confidence: {confidence}%',
+    visionAnalysisSuccess:
+      '✅ Vision analysis complete: {path}{page}, Text length: {textLen}, Images: {imgCount}, Layout: {layout}, Confidence: {confidence}%',
     conversationHistoryNotFound: 'Conversation history not found',
-    batchContextLengthErrorFallback: 'Context length error occurred during small file batch processing. Downgrading to single processing mode.',
-    chunkProcessingFailed: 'Failed to process text block {index}. Skipping: {message}',
-    singleTextProcessingComplete: 'Single text processing complete: {count} chunks',
-    fileVectorizationComplete: 'File {id} vectorization complete. Processed {count} text blocks. Final memory: {memory}MB',
+    batchContextLengthErrorFallback:
+      'Context length error occurred during small file batch processing. Downgrading to single processing mode.',
+    chunkProcessingFailed:
+      'Failed to process text block {index}. Skipping: {message}',
+    singleTextProcessingComplete:
+      'Single text processing complete: {count} chunks',
+    fileVectorizationComplete:
+      'File {id} vectorization complete. Processed {count} text blocks. Final memory: {memory}MB',
     fileVectorizationFailed: 'File {id} vectorization failed',
     batchProcessingStarted: 'Batch processing started: {count} items',
     batchProcessingProgress: 'Processing batch {index}/{total}: {count} items',
-    batchProcessingComplete: 'Batch processing complete: {count} items in {duration}s',
-    onlyFailedFilesRetryable: 'Only failed files can be retried (current status: {status})',
-    emptyFileRetryFailed: 'File content is empty. Cannot retry. Please re-upload the file.',
-    ragSystemPrompt: 'You are a professional knowledge base assistant. Please answer the user\'s question based on the provided document content below.',
-    ragRules: '## Rules:\n1. Answer based only on the provided document content; do not fabricate information.\n2. If there is no relevant information in the documents, please inform the user.\n3. Clearly state the sources in your answer. Format: [filename.ext]\n4. If information in different documents is contradictory, analyze it comprehensively or explain the different perspectives.\n5. Please answer in {lang}.',
+    batchProcessingComplete:
+      'Batch processing complete: {count} items in {duration}s',
+    onlyFailedFilesRetryable:
+      'Only failed files can be retried (current status: {status})',
+    emptyFileRetryFailed:
+      'File content is empty. Cannot retry. Please re-upload the file.',
+    ragSystemPrompt:
+      "You are a professional knowledge base assistant. Please answer the user's question based on the provided document content below.",
+    ragRules:
+      '## Rules:\n1. Answer based only on the provided document content; do not fabricate information.\n2. If there is no relevant information in the documents, please inform the user.\n3. Clearly state the sources in your answer. Format: [filename.ext]\n4. If information in different documents is contradictory, analyze it comprehensively or explain the different perspectives.\n5. Please answer in {lang}.',
     ragDocumentContent: '## Document Content:',
     ragUserQuestion: '## User Question:',
     ragAnswer: '## Answer:',
     ragSource: '### Source: {fileName}',
     ragSegment: 'Segment {index} (Similarity: {score}):',
     ragNoDocumentFound: 'No relevant documents found.',
-    queryExpansionPrompt: 'You are a search assistant. Please generate 3 different variations of the following user query to help get better results in vector search. Each variation should use different keywords or phrasing while maintaining the original meaning. Output the 3 queries directly as 3 lines, without numbers or prefixes:\n\nQuery: {query}',
-    hydePrompt: 'Please write a brief, factual hypothetical answer (about 100 words) to the following user question. Do not include any introductory text (like "Based on my analysis..."), just output the answer content directly.\n\nQuestion: {query}',
-  }
-};
+    queryExpansionPrompt:
+      'You are a search assistant. Please generate 3 different variations of the following user query to help get better results in vector search. Each variation should use different keywords or phrasing while maintaining the original meaning. Output the 3 queries directly as 3 lines, without numbers or prefixes:\n\nQuery: {query}',
+    hydePrompt:
+      'Please write a brief, factual hypothetical answer (about 100 words) to the following user question. Do not include any introductory text (like "Based on my analysis..."), just output the answer content directly.\n\nQuestion: {query}',
+  },
+};

+ 44 - 34
server/src/import-task/import-task.controller.ts

@@ -1,4 +1,14 @@
-import { Controller, Post, Get, Delete, Param, Body, Request, UseGuards, Query } from '@nestjs/common';
+import {
+  Controller,
+  Post,
+  Get,
+  Delete,
+  Param,
+  Body,
+  Request,
+  UseGuards,
+  Query,
+} from '@nestjs/common';
 import { ImportTaskService } from './import-task.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
@@ -8,40 +18,40 @@ import { UserRole } from '../user/user-role.enum';
 @Controller('import-tasks')
 @UseGuards(CombinedAuthGuard, RolesGuard)
 export class ImportTaskController {
-    constructor(private readonly taskService: ImportTaskService) { }
+  constructor(private readonly taskService: ImportTaskService) {}
 
-    @Post()
-    @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
-    async create(@Request() req, @Body() body: any) {
-        return this.taskService.create({
-            sourcePath: body.sourcePath,
-            targetGroupId: body.targetGroupId,
-            targetGroupName: body.targetGroupName,
-            embeddingModelId: body.embeddingModelId,
-            scheduledAt: body.scheduledAt ? new Date(body.scheduledAt) : undefined,
-            chunkSize: body.chunkSize,
-            chunkOverlap: body.chunkOverlap,
-            mode: body.mode,
-            useHierarchy: body.useHierarchy ?? false,
-            userId: req.user.id,
-            tenantId: req.user.tenantId,
-        });
-    }
+  @Post()
+  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
+  async create(@Request() req, @Body() body: any) {
+    return this.taskService.create({
+      sourcePath: body.sourcePath,
+      targetGroupId: body.targetGroupId,
+      targetGroupName: body.targetGroupName,
+      embeddingModelId: body.embeddingModelId,
+      scheduledAt: body.scheduledAt ? new Date(body.scheduledAt) : undefined,
+      chunkSize: body.chunkSize,
+      chunkOverlap: body.chunkOverlap,
+      mode: body.mode,
+      useHierarchy: body.useHierarchy ?? false,
+      userId: req.user.id,
+      tenantId: req.user.tenantId,
+    });
+  }
 
-    @Get()
-    async findAll(
-        @Request() req,
-        @Query('page') page?: number,
-        @Query('limit') limit?: number,
-    ) {
-        return this.taskService.findAll(req.user.id, {
-            page: page ? Number(page) : undefined,
-            limit: limit ? Number(limit) : undefined,
-        });
-    }
+  @Get()
+  async findAll(
+    @Request() req,
+    @Query('page') page?: number,
+    @Query('limit') limit?: number,
+  ) {
+    return this.taskService.findAll(req.user.id, {
+      page: page ? Number(page) : undefined,
+      limit: limit ? Number(limit) : undefined,
+    });
+  }
 
-    @Delete(':id')
-    async delete(@Param('id') id: string, @Request() req) {
-        return this.taskService.delete(id, req.user.id);
-    }
+  @Delete(':id')
+  async delete(@Param('id') id: string, @Request() req) {
+    return this.taskService.delete(id, req.user.id);
+  }
 }

+ 40 - 34
server/src/import-task/import-task.entity.ts

@@ -1,53 +1,59 @@
-import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+import {
+  Entity,
+  Column,
+  PrimaryGeneratedColumn,
+  CreateDateColumn,
+  UpdateDateColumn,
+} from 'typeorm';
 
 @Entity()
 export class ImportTask {
-    @PrimaryGeneratedColumn('uuid')
-    id: string;
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
 
-    @Column()
-    sourcePath: string;
+  @Column()
+  sourcePath: string;
 
-    @Column({ nullable: true })
-    targetGroupId: string; // If null, creates new group
+  @Column({ nullable: true })
+  targetGroupId: string; // If null, creates new group
 
-    @Column({ nullable: true })
-    targetGroupName: string; // Used if creating new group
+  @Column({ nullable: true })
+  targetGroupName: string; // Used if creating new group
 
-    @Column()
-    userId: string;
+  @Column()
+  userId: string;
 
-    @Column({ nullable: true })
-    tenantId: string;
+  @Column({ nullable: true })
+  tenantId: string;
 
-    @Column({ nullable: true })
-    scheduledAt: Date;
+  @Column({ nullable: true })
+  scheduledAt: Date;
 
-    @Column({ default: 'PENDING' })
-    status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
+  @Column({ default: 'PENDING' })
+  status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
 
-    @Column({ type: 'text', nullable: true })
-    logs: string;
+  @Column({ type: 'text', nullable: true })
+  logs: string;
 
-    @Column({ nullable: true })
-    embeddingModelId: string;
+  @Column({ nullable: true })
+  embeddingModelId: string;
 
-    @Column({ nullable: true, default: 500 })
-    chunkSize: number;
+  @Column({ nullable: true, default: 500 })
+  chunkSize: number;
 
-    @Column({ nullable: true, default: 50 })
-    chunkOverlap: number;
+  @Column({ nullable: true, default: 50 })
+  chunkOverlap: number;
 
-    @Column({ nullable: true, default: 'fast' })
-    mode: string;
+  @Column({ nullable: true, default: 'fast' })
+  mode: string;
 
-    /** When true, sub-directories become sub-categories mirroring the folder hierarchy */
-    @Column({ default: false })
-    useHierarchy: boolean;
+  /** When true, sub-directories become sub-categories mirroring the folder hierarchy */
+  @Column({ default: false })
+  useHierarchy: boolean;
 
-    @CreateDateColumn()
-    createdAt: Date;
+  @CreateDateColumn()
+  createdAt: Date;
 
-    @UpdateDateColumn()
-    updatedAt: Date;
+  @UpdateDateColumn()
+  updatedAt: Date;
 }

+ 8 - 8
server/src/import-task/import-task.module.ts

@@ -7,12 +7,12 @@ import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module';
 import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module';
 
 @Module({
-    imports: [
-        TypeOrmModule.forFeature([ImportTask]),
-        KnowledgeBaseModule,
-        KnowledgeGroupModule,
-    ],
-    controllers: [ImportTaskController],
-    providers: [ImportTaskService],
+  imports: [
+    TypeOrmModule.forFeature([ImportTask]),
+    KnowledgeBaseModule,
+    KnowledgeGroupModule,
+  ],
+  controllers: [ImportTaskController],
+  providers: [ImportTaskService],
 })
-export class ImportTaskModule { }
+export class ImportTaskModule {}

+ 380 - 293
server/src/import-task/import-task.service.ts

@@ -11,325 +11,412 @@ import * as fs from 'fs';
 import * as path from 'path';
 
 export interface PaginatedImportTasks {
-    items: ImportTask[];
-    total: number;
-    page: number;
-    limit: number;
+  items: ImportTask[];
+  total: number;
+  page: number;
+  limit: number;
 }
 
 @Injectable()
 export class ImportTaskService {
-    private readonly logger = new Logger(ImportTaskService.name);
-
-    constructor(
-        @InjectRepository(ImportTask)
-        private taskRepository: Repository<ImportTask>,
-        private kbService: KnowledgeBaseService,
-        private groupService: KnowledgeGroupService,
-        private configService: ConfigService,
-        private i18nService: I18nService,
-    ) { }
-
-    async create(taskData: Partial<ImportTask>): Promise<ImportTask> {
-        const task = this.taskRepository.create(taskData);
-        const savedTask = await this.taskRepository.save(task);
-
-        // If no scheduled time or scheduled time is in the past, execute immediately (async)
-        if (!task.scheduledAt || task.scheduledAt <= new Date()) {
-            this.executeTask(savedTask.id).catch(err =>
-                this.logger.error(`Immediate execution failed to start for task ${savedTask.id}`, err)
-            );
-        }
-
-        return savedTask;
+  private readonly logger = new Logger(ImportTaskService.name);
+
+  constructor(
+    @InjectRepository(ImportTask)
+    private taskRepository: Repository<ImportTask>,
+    private kbService: KnowledgeBaseService,
+    private groupService: KnowledgeGroupService,
+    private configService: ConfigService,
+    private i18nService: I18nService,
+  ) {}
+
+  async create(taskData: Partial<ImportTask>): Promise<ImportTask> {
+    const task = this.taskRepository.create(taskData);
+    const savedTask = await this.taskRepository.save(task);
+
+    // If no scheduled time or scheduled time is in the past, execute immediately (async)
+    if (!task.scheduledAt || task.scheduledAt <= new Date()) {
+      this.executeTask(savedTask.id).catch((err) =>
+        this.logger.error(
+          `Immediate execution failed to start for task ${savedTask.id}`,
+          err,
+        ),
+      );
     }
 
-    async findAll(userId: string, options: { page?: number; limit?: number } = {}): Promise<PaginatedImportTasks> {
-        const { page = 1, limit = 12 } = options;
-        const skip = (page - 1) * limit;
-
-        const [items, total] = await this.taskRepository.findAndCount({
-            where: { userId },
-            order: { createdAt: 'DESC' },
-            skip,
-            take: limit,
-        });
-
-        return {
-            items,
-            total,
-            page,
-            limit,
-        };
+    return savedTask;
+  }
+
+  async findAll(
+    userId: string,
+    options: { page?: number; limit?: number } = {},
+  ): Promise<PaginatedImportTasks> {
+    const { page = 1, limit = 12 } = options;
+    const skip = (page - 1) * limit;
+
+    const [items, total] = await this.taskRepository.findAndCount({
+      where: { userId },
+      order: { createdAt: 'DESC' },
+      skip,
+      take: limit,
+    });
+
+    return {
+      items,
+      total,
+      page,
+      limit,
+    };
+  }
+
+  async delete(taskId: string, userId: string): Promise<void> {
+    const task = await this.taskRepository.findOne({
+      where: { id: taskId, userId },
+    });
+    if (!task) {
+      throw new Error(this.i18nService.getMessage('importTaskNotFound'));
     }
-
-    async delete(taskId: string, userId: string): Promise<void> {
-        const task = await this.taskRepository.findOne({ where: { id: taskId, userId } });
-        if (!task) {
-            throw new Error(this.i18nService.getMessage('importTaskNotFound'));
-        }
-        await this.taskRepository.remove(task);
+    await this.taskRepository.remove(task);
+  }
+
+  @Cron(CronExpression.EVERY_MINUTE)
+  async handleScheduledTasks() {
+    this.logger.debug('Checking for scheduled import tasks...');
+    const now = new Date();
+
+    const tasks = await this.taskRepository.find({
+      where: {
+        status: 'PENDING',
+        scheduledAt: LessThanOrEqual(now),
+      },
+    });
+
+    for (const task of tasks) {
+      this.logger.log(`Starting scheduled task ${task.id}`);
+      this.executeTask(task.id).catch((err) =>
+        this.logger.error(
+          `Scheduled execution failed to start for task ${task.id}`,
+          err,
+        ),
+      );
     }
-
-    @Cron(CronExpression.EVERY_MINUTE)
-    async handleScheduledTasks() {
-        this.logger.debug('Checking for scheduled import tasks...');
-        const now = new Date();
-
-        const tasks = await this.taskRepository.find({
-            where: {
-                status: 'PENDING',
-                scheduledAt: LessThanOrEqual(now),
-            },
-        });
-
-        for (const task of tasks) {
-            this.logger.log(`Starting scheduled task ${task.id}`);
-            this.executeTask(task.id).catch(err =>
-                this.logger.error(`Scheduled execution failed to start for task ${task.id}`, err)
-            );
-        }
+  }
+
+  private async executeTask(taskId: string) {
+    this.logger.debug(`Executing task ${taskId}`);
+    const task = await this.taskRepository.findOne({ where: { id: taskId } });
+    if (!task) {
+      this.logger.warn(`Task ${taskId} not found.`);
+      return;
     }
 
-    private async executeTask(taskId: string) {
-        this.logger.debug(`Executing task ${taskId}`);
-        const task = await this.taskRepository.findOne({ where: { id: taskId } });
-        if (!task) {
-            this.logger.warn(`Task ${taskId} not found.`);
-            return;
-        }
+    if (task.status === 'PROCESSING' || task.status === 'COMPLETED') {
+      this.logger.debug(
+        `Task ${taskId} is already ${task.status}, skipping execution.`,
+      );
+      return;
+    }
 
-        if (task.status === 'PROCESSING' || task.status === 'COMPLETED') {
-            this.logger.debug(`Task ${taskId} is already ${task.status}, skipping execution.`);
-            return;
+    await this.updateStatus(taskId, 'PROCESSING', 'Starting import...');
+
+    try {
+      if (!fs.existsSync(task.sourcePath)) {
+        throw new Error(
+          this.i18nService.formatMessage('sourcePathNotFound', {
+            path: task.sourcePath,
+          }),
+        );
+      }
+
+      const uploadPath = this.configService.get<string>(
+        'UPLOAD_FILE_PATH',
+        './uploads',
+      );
+      const importTargetDir = path.join(uploadPath, 'imported', taskId);
+
+      if (!fs.existsSync(importTargetDir)) {
+        fs.mkdirSync(importTargetDir, { recursive: true });
+      }
+
+      let successCount = 0;
+      let failCount = 0;
+
+      if (task.useHierarchy) {
+        // ---- Hierarchy mode: create sub-groups matching folder structure ----
+        await this.appendLog(
+          taskId,
+          `Scanning directory with hierarchy: ${task.sourcePath}`,
+        );
+
+        // Determine root group
+        let rootGroupId = task.targetGroupId;
+        if (!rootGroupId) {
+          const rootName =
+            task.targetGroupName || path.basename(task.sourcePath);
+          const rootGroup = await this.groupService.create(
+            task.userId,
+            task.tenantId || 'default',
+            {
+              name: rootName,
+              description: `Imported from ${task.sourcePath}`,
+              color: '#0078D4',
+            },
+          );
+          rootGroupId = rootGroup.id;
+          await this.appendLog(taskId, `Created root group: ${rootName}`);
         }
 
-        await this.updateStatus(taskId, 'PROCESSING', 'Starting import...');
-
-        try {
-            if (!fs.existsSync(task.sourcePath)) {
-                throw new Error(this.i18nService.formatMessage('sourcePathNotFound', { path: task.sourcePath }));
-            }
-
-            const uploadPath = this.configService.get<string>('UPLOAD_FILE_PATH', './uploads');
-            const importTargetDir = path.join(uploadPath, 'imported', taskId);
-
-            if (!fs.existsSync(importTargetDir)) {
-                fs.mkdirSync(importTargetDir, { recursive: true });
-            }
-
-            let successCount = 0;
-            let failCount = 0;
-
-            if (task.useHierarchy) {
-                // ---- Hierarchy mode: create sub-groups matching folder structure ----
-                await this.appendLog(taskId, `Scanning directory with hierarchy: ${task.sourcePath}`);
-
-                // Determine root group
-                let rootGroupId = task.targetGroupId;
-                if (!rootGroupId) {
-                    const rootName = task.targetGroupName || path.basename(task.sourcePath);
-                    const rootGroup = await this.groupService.create(task.userId, task.tenantId || 'default', {
-                        name: rootName,
-                        description: `Imported from ${task.sourcePath}`,
-                        color: '#0078D4',
-                    });
-                    rootGroupId = rootGroup.id;
-                    await this.appendLog(taskId, `Created root group: ${rootName}`);
-                }
-
-                // Map from relative dir path -> groupId
-                const dirToGroupId = new Map<string, string>();
-                dirToGroupId.set('.', rootGroupId);
-
-                // Collect all files first
-                const allFiles = this.scanDir(task.sourcePath);
-                await this.appendLog(taskId, `Found ${allFiles.length} files.`);
-
-                for (let i = 0; i < allFiles.length; i++) {
-                    const filePath = allFiles[i];
-                    const relativeDir = path.relative(task.sourcePath, path.dirname(filePath));
-                    const normalizedDir = relativeDir || '.';
-
-                    // Ensure group exists for this directory
-                    const groupId = await this.ensureHierarchyGroup(
-                        task.userId,
-                        task.tenantId || 'default',
-                        normalizedDir,
-                        dirToGroupId,
-                        task.sourcePath,
-                        taskId,
-                    );
-
-                    try {
-                        const kb = await this.importSingleFile(filePath, task, importTargetDir, i, allFiles.length);
-                        await this.groupService.addFilesToGroup(kb.id, [groupId], task.userId, task.tenantId || 'default');
-                        successCount++;
-                        if (successCount % 10 === 0) {
-                            await this.appendLog(taskId, `Imported ${successCount} files...`);
-                        }
-                    } catch (e) {
-                        failCount++;
-                        await this.appendLog(taskId, `Failed to import ${path.basename(filePath)}: ${e.message}`);
-                    }
-                }
-            } else {
-                // ---- Single-group mode (original behavior) ----
-                let groupId = task.targetGroupId;
-                if (!groupId && task.targetGroupName) {
-                    const group = await this.groupService.create(task.userId, task.tenantId || 'default', {
-                        name: task.targetGroupName,
-                        description: `Imported from ${task.sourcePath}`,
-                        color: '#0078D4',
-                    });
-                    groupId = group.id;
-                    await this.appendLog(taskId, `Created new group: ${task.targetGroupName}`);
-                } else if (!groupId) {
-                    throw new Error(this.i18nService.getMessage('targetGroupRequired'));
-                }
-
-                await this.appendLog(taskId, `Scanning directory: ${task.sourcePath}`);
-                const filesToImport = this.scanDir(task.sourcePath);
-                await this.appendLog(taskId, `Found ${filesToImport.length} files.`);
-
-                for (let i = 0; i < filesToImport.length; i++) {
-                    const filePath = filesToImport[i];
-                    try {
-                        const kb = await this.importSingleFile(filePath, task, importTargetDir, i, filesToImport.length);
-                        await this.groupService.addFilesToGroup(kb.id, [groupId], task.userId, task.tenantId || 'default');
-                        successCount++;
-                        if (successCount % 10 === 0) {
-                            await this.appendLog(taskId, `Imported ${successCount} files...`);
-                        }
-                    } catch (e) {
-                        failCount++;
-                        await this.appendLog(taskId, `Failed to import ${path.basename(filePath)}: ${e.message}`);
-                    }
-                }
+        // Map from relative dir path -> groupId
+        const dirToGroupId = new Map<string, string>();
+        dirToGroupId.set('.', rootGroupId);
+
+        // Collect all files first
+        const allFiles = this.scanDir(task.sourcePath);
+        await this.appendLog(taskId, `Found ${allFiles.length} files.`);
+
+        for (let i = 0; i < allFiles.length; i++) {
+          const filePath = allFiles[i];
+          const relativeDir = path.relative(
+            task.sourcePath,
+            path.dirname(filePath),
+          );
+          const normalizedDir = relativeDir || '.';
+
+          // Ensure group exists for this directory
+          const groupId = await this.ensureHierarchyGroup(
+            task.userId,
+            task.tenantId || 'default',
+            normalizedDir,
+            dirToGroupId,
+            task.sourcePath,
+            taskId,
+          );
+
+          try {
+            const kb = await this.importSingleFile(
+              filePath,
+              task,
+              importTargetDir,
+              i,
+              allFiles.length,
+            );
+            await this.groupService.addFilesToGroup(
+              kb.id,
+              [groupId],
+              task.userId,
+              task.tenantId || 'default',
+            );
+            successCount++;
+            if (successCount % 10 === 0) {
+              await this.appendLog(taskId, `Imported ${successCount} files...`);
             }
-
-            await this.updateStatus(taskId, 'COMPLETED', `Import finished. Success: ${successCount}, Failed: ${failCount}`);
-
-        } catch (error) {
-            await this.updateStatus(taskId, 'FAILED', `Fatal error: ${error.message}`);
+          } catch (e) {
+            failCount++;
+            await this.appendLog(
+              taskId,
+              `Failed to import ${path.basename(filePath)}: ${e.message}`,
+            );
+          }
         }
-    }
-
-    /**
-     * Ensure a KnowledgeGroup exists for each segment of the relative directory path.
-     * Returns the groupId for the leaf directory.
-     */
-    private async ensureHierarchyGroup(
-        userId: string,
-        tenantId: string,
-        relativeDir: string,
-        dirToGroupId: Map<string, string>,
-        _sourcePath: string,
-        taskId: string,
-    ): Promise<string> {
-        if (dirToGroupId.has(relativeDir)) {
-            return dirToGroupId.get(relativeDir)!;
+      } else {
+        // ---- Single-group mode (original behavior) ----
+        let groupId = task.targetGroupId;
+        if (!groupId && task.targetGroupName) {
+          const group = await this.groupService.create(
+            task.userId,
+            task.tenantId || 'default',
+            {
+              name: task.targetGroupName,
+              description: `Imported from ${task.sourcePath}`,
+              color: '#0078D4',
+            },
+          );
+          groupId = group.id;
+          await this.appendLog(
+            taskId,
+            `Created new group: ${task.targetGroupName}`,
+          );
+        } else if (!groupId) {
+          throw new Error(this.i18nService.getMessage('targetGroupRequired'));
         }
 
-        const segments = relativeDir.split(path.sep);
-        let currentPath = '';
-        let parentGroupId = dirToGroupId.get('.') ?? dirToGroupId.values().next().value;
-
-        for (const segment of segments) {
-            currentPath = currentPath ? path.join(currentPath, segment) : segment;
-            if (dirToGroupId.has(currentPath)) {
-                parentGroupId = dirToGroupId.get(currentPath)!;
-                continue;
+        await this.appendLog(taskId, `Scanning directory: ${task.sourcePath}`);
+        const filesToImport = this.scanDir(task.sourcePath);
+        await this.appendLog(taskId, `Found ${filesToImport.length} files.`);
+
+        for (let i = 0; i < filesToImport.length; i++) {
+          const filePath = filesToImport[i];
+          try {
+            const kb = await this.importSingleFile(
+              filePath,
+              task,
+              importTargetDir,
+              i,
+              filesToImport.length,
+            );
+            await this.groupService.addFilesToGroup(
+              kb.id,
+              [groupId],
+              task.userId,
+              task.tenantId || 'default',
+            );
+            successCount++;
+            if (successCount % 10 === 0) {
+              await this.appendLog(taskId, `Imported ${successCount} files...`);
             }
-
-            // Create a group for this directory segment
-            const group = await this.groupService.findOrCreate(
-                userId,
-                tenantId,
-                segment,
-                parentGroupId,
-                `Sub-folder: ${currentPath}`,
+          } catch (e) {
+            failCount++;
+            await this.appendLog(
+              taskId,
+              `Failed to import ${path.basename(filePath)}: ${e.message}`,
             );
-            dirToGroupId.set(currentPath, group.id);
-            await this.appendLog(taskId, `Created sub-group: ${segment}`);
-            parentGroupId = group.id;
+          }
         }
-
-        return parentGroupId;
+      }
+
+      await this.updateStatus(
+        taskId,
+        'COMPLETED',
+        `Import finished. Success: ${successCount}, Failed: ${failCount}`,
+      );
+    } catch (error) {
+      await this.updateStatus(
+        taskId,
+        'FAILED',
+        `Fatal error: ${error.message}`,
+      );
     }
-
-    /** Copy file to safe location and index it */
-    private async importSingleFile(
-        filePath: string,
-        task: ImportTask,
-        importTargetDir: string,
-        index: number,
-        total: number,
-    ) {
-        const filename = path.basename(filePath);
-        const storedFilename = `imported-${Date.now()}-${Math.random().toString(36).substr(2, 5)}-${filename}`;
-        const targetPath = path.join(importTargetDir, storedFilename);
-
-        fs.copyFileSync(filePath, targetPath);
-        const stats = fs.statSync(targetPath);
-
-        const fileInfo = {
-            filename: storedFilename,
-            originalname: filename,
-            path: targetPath,
-            mimetype: 'text/markdown',
-            size: stats.size,
-        };
-
-        const indexingConfig = {
-            chunkSize: task.chunkSize || 500,
-            chunkOverlap: task.chunkOverlap || 50,
-            embeddingModelId: task.embeddingModelId,
-            mode: (task.mode || 'fast') as 'fast' | 'precise',
-        };
-
-        this.logger.log(`Processing file ${index + 1}/${total}: ${filename}`);
-        const kb = await this.kbService.createAndIndex(fileInfo, task.userId, task.tenantId || 'default', {
-            ...indexingConfig,
-            waitForCompletion: true,
-        } as any);
-        this.logger.log(`File ${index + 1}/${total} processing completed: ${filename}`);
-        return kb;
+  }
+
+  /**
+   * Ensure a KnowledgeGroup exists for each segment of the relative directory path.
+   * Returns the groupId for the leaf directory.
+   */
+  private async ensureHierarchyGroup(
+    userId: string,
+    tenantId: string,
+    relativeDir: string,
+    dirToGroupId: Map<string, string>,
+    _sourcePath: string,
+    taskId: string,
+  ): Promise<string> {
+    if (dirToGroupId.has(relativeDir)) {
+      return dirToGroupId.get(relativeDir)!;
     }
 
-    private scanDir(directory: string): string[] {
-        let results: string[] = [];
-        const items = fs.readdirSync(directory);
-        for (const item of items) {
-            const fullPath = path.join(directory, item);
-            const stat = fs.statSync(fullPath);
-            if (stat.isDirectory()) {
-                results = results.concat(this.scanDir(fullPath));
-            } else {
-                if (item.match(/\.(md|txt|html|json|pdf|docx|xlsx|pptx|csv)$/i)) {
-                    results.push(fullPath);
-                }
-            }
-        }
-        return results;
+    const segments = relativeDir.split(path.sep);
+    let currentPath = '';
+    let parentGroupId =
+      dirToGroupId.get('.') ?? dirToGroupId.values().next().value;
+
+    for (const segment of segments) {
+      currentPath = currentPath ? path.join(currentPath, segment) : segment;
+      if (dirToGroupId.has(currentPath)) {
+        parentGroupId = dirToGroupId.get(currentPath)!;
+        continue;
+      }
+
+      // Create a group for this directory segment
+      const group = await this.groupService.findOrCreate(
+        userId,
+        tenantId,
+        segment,
+        parentGroupId,
+        `Sub-folder: ${currentPath}`,
+      );
+      dirToGroupId.set(currentPath, group.id);
+      await this.appendLog(taskId, `Created sub-group: ${segment}`);
+      parentGroupId = group.id;
     }
 
-    private async updateStatus(id: string, status: ImportTask['status'], logMessage?: string) {
-        const task = await this.taskRepository.findOne({ where: { id } });
-        if (task) {
-            task.status = status;
-            if (logMessage) {
-                task.logs = (task.logs || '') + `[${new Date().toISOString()}] ${logMessage}\n`;
-            }
-            await this.taskRepository.save(task);
+    return parentGroupId;
+  }
+
+  /** Copy file to safe location and index it */
+  private async importSingleFile(
+    filePath: string,
+    task: ImportTask,
+    importTargetDir: string,
+    index: number,
+    total: number,
+  ) {
+    const filename = path.basename(filePath);
+    const storedFilename = `imported-${Date.now()}-${Math.random().toString(36).substr(2, 5)}-${filename}`;
+    const targetPath = path.join(importTargetDir, storedFilename);
+
+    fs.copyFileSync(filePath, targetPath);
+    const stats = fs.statSync(targetPath);
+
+    const fileInfo = {
+      filename: storedFilename,
+      originalname: filename,
+      path: targetPath,
+      mimetype: 'text/markdown',
+      size: stats.size,
+    };
+
+    const indexingConfig = {
+      chunkSize: task.chunkSize || 500,
+      chunkOverlap: task.chunkOverlap || 50,
+      embeddingModelId: task.embeddingModelId,
+      mode: (task.mode || 'fast') as 'fast' | 'precise',
+    };
+
+    this.logger.log(`Processing file ${index + 1}/${total}: ${filename}`);
+    const kb = await this.kbService.createAndIndex(
+      fileInfo,
+      task.userId,
+      task.tenantId || 'default',
+      {
+        ...indexingConfig,
+        waitForCompletion: true,
+      } as any,
+    );
+    this.logger.log(
+      `File ${index + 1}/${total} processing completed: ${filename}`,
+    );
+    return kb;
+  }
+
+  private scanDir(directory: string): string[] {
+    let results: string[] = [];
+    const items = fs.readdirSync(directory);
+    for (const item of items) {
+      const fullPath = path.join(directory, item);
+      const stat = fs.statSync(fullPath);
+      if (stat.isDirectory()) {
+        results = results.concat(this.scanDir(fullPath));
+      } else {
+        if (item.match(/\.(md|txt|html|json|pdf|docx|xlsx|pptx|csv)$/i)) {
+          results.push(fullPath);
         }
+      }
     }
-
-    private async appendLog(id: string, message: string) {
-        const task = await this.taskRepository.findOne({ where: { id } });
-        if (task) {
-            task.logs = (task.logs || '') + `[${new Date().toISOString()}] ${message}\n`;
-            await this.taskRepository.save(task);
-        }
+    return results;
+  }
+
+  private async updateStatus(
+    id: string,
+    status: ImportTask['status'],
+    logMessage?: string,
+  ) {
+    const task = await this.taskRepository.findOne({ where: { id } });
+    if (task) {
+      task.status = status;
+      if (logMessage) {
+        task.logs =
+          (task.logs || '') + `[${new Date().toISOString()}] ${logMessage}\n`;
+      }
+      await this.taskRepository.save(task);
+    }
+  }
+
+  private async appendLog(id: string, message: string) {
+    const task = await this.taskRepository.findOne({ where: { id } });
+    if (task) {
+      task.logs =
+        (task.logs || '') + `[${new Date().toISOString()}] ${message}\n`;
+      await this.taskRepository.save(task);
     }
+  }
 }

+ 71 - 37
server/src/knowledge-base/chunk-config.service.ts

@@ -20,7 +20,7 @@ import {
   MIN_CHUNK_OVERLAP,
   DEFAULT_MAX_OVERLAP_RATIO,
   DEFAULT_MAX_BATCH_SIZE,
-  DEFAULT_VECTOR_DIMENSIONS
+  DEFAULT_VECTOR_DIMENSIONS,
 } from '../common/constants';
 import { I18nService } from '../i18n/i18n.service';
 
@@ -34,8 +34,8 @@ export class ChunkConfigService {
     chunkOverlap: DEFAULT_CHUNK_OVERLAP,
     minChunkSize: MIN_CHUNK_SIZE,
     minChunkOverlap: MIN_CHUNK_OVERLAP,
-    maxOverlapRatio: DEFAULT_MAX_OVERLAP_RATIO,  // Overlap up to 50% of chunk size
-    maxBatchSize: DEFAULT_MAX_BATCH_SIZE,    // Default batch limit
+    maxOverlapRatio: DEFAULT_MAX_OVERLAP_RATIO, // Overlap up to 50% of chunk size
+    maxBatchSize: DEFAULT_MAX_BATCH_SIZE, // Default batch limit
     expectedDimensions: DEFAULT_VECTOR_DIMENSIONS, // Default vector dimensions
   };
 
@@ -51,47 +51,70 @@ export class ChunkConfigService {
   ) {
     // Load global limit settings from environment variables
     this.envMaxChunkSize = parseInt(
-      this.configService.get<string>('MAX_CHUNK_SIZE', '8191')
+      this.configService.get<string>('MAX_CHUNK_SIZE', '8191'),
     );
     this.envMaxOverlapSize = parseInt(
-      this.configService.get<string>('MAX_OVERLAP_SIZE', '2000')
+      this.configService.get<string>('MAX_OVERLAP_SIZE', '2000'),
     );
 
     this.logger.log(
-      `Environment variable limits: MAX_CHUNK_SIZE=${this.envMaxChunkSize}, MAX_OVERLAP_SIZE=${this.envMaxOverlapSize}`
+      `Environment variable limits: MAX_CHUNK_SIZE=${this.envMaxChunkSize}, MAX_OVERLAP_SIZE=${this.envMaxOverlapSize}`,
     );
   }
 
   /**
    * Get model limit settings (read from database)
    */
-  async getModelLimits(modelId: string, userId: string, tenantId?: string): Promise<{
+  async getModelLimits(
+    modelId: string,
+    userId: string,
+    tenantId?: string,
+  ): Promise<{
     maxInputTokens: number;
     maxBatchSize: number;
     expectedDimensions: number;
     providerName: string;
     isVectorModel: boolean;
   }> {
-    const modelConfig = await this.modelConfigService.findOne(modelId, userId, tenantId || '');
+    const modelConfig = await this.modelConfigService.findOne(
+      modelId,
+      userId,
+      tenantId || '',
+    );
 
     if (!modelConfig || modelConfig.type !== 'embedding') {
-      throw new BadRequestException(this.i18nService.formatMessage('embeddingModelNotFound', { id: modelId }));
+      throw new BadRequestException(
+        this.i18nService.formatMessage('embeddingModelNotFound', {
+          id: modelId,
+        }),
+      );
     }
 
     // Get limits from database fields and fill with defaults
     const maxInputTokens = modelConfig.maxInputTokens || this.envMaxChunkSize;
     const maxBatchSize = modelConfig.maxBatchSize || this.DEFAULTS.maxBatchSize;
-    const expectedDimensions = modelConfig.dimensions || parseInt(this.configService.get('DEFAULT_VECTOR_DIMENSIONS', String(this.DEFAULTS.expectedDimensions)));
+    const expectedDimensions =
+      modelConfig.dimensions ||
+      parseInt(
+        this.configService.get(
+          'DEFAULT_VECTOR_DIMENSIONS',
+          String(this.DEFAULTS.expectedDimensions),
+        ),
+      );
     const providerName = modelConfig.providerName || 'unknown';
     const isVectorModel = modelConfig.isVectorModel || false;
 
     this.logger.log(
-      this.i18nService.formatMessage('configLoaded', { name: modelConfig.name, id: modelConfig.modelId }) + '\n' +
-      `  - Provider: ${providerName}\n` +
-      `  - Token limit: ${maxInputTokens}\n` +
-      `  - Batch limit: ${maxBatchSize}\n` +
-      `  - Vector dimensions: ${expectedDimensions}\n` +
-      `  - Is vector model: ${isVectorModel}`,
+      this.i18nService.formatMessage('configLoaded', {
+        name: modelConfig.name,
+        id: modelConfig.modelId,
+      }) +
+        '\n' +
+        `  - Provider: ${providerName}\n` +
+        `  - Token limit: ${maxInputTokens}\n` +
+        `  - Batch limit: ${maxBatchSize}\n` +
+        `  - Vector dimensions: ${expectedDimensions}\n` +
+        `  - Is vector model: ${isVectorModel}`,
     );
 
     return {
@@ -145,8 +168,8 @@ export class ChunkConfigService {
         this.i18nService.formatMessage('chunkOverflow', {
           size: chunkSize,
           max: effectiveMaxChunkSize,
-          reason
-        })
+          reason,
+        }),
       );
       chunkSize = effectiveMaxChunkSize;
     }
@@ -156,8 +179,8 @@ export class ChunkConfigService {
       warnings.push(
         this.i18nService.formatMessage('chunkUnderflow', {
           size: chunkSize,
-          min: this.DEFAULTS.minChunkSize
-        })
+          min: this.DEFAULTS.minChunkSize,
+        }),
       );
       chunkSize = this.DEFAULTS.minChunkSize;
     }
@@ -167,8 +190,8 @@ export class ChunkConfigService {
       warnings.push(
         this.i18nService.formatMessage('overlapOverflow', {
           size: chunkOverlap,
-          max: effectiveMaxOverlapSize
-        })
+          max: effectiveMaxOverlapSize,
+        }),
       );
       chunkOverlap = effectiveMaxOverlapSize;
     }
@@ -181,8 +204,8 @@ export class ChunkConfigService {
       warnings.push(
         this.i18nService.formatMessage('overlapRatioExceeded', {
           size: chunkOverlap,
-          max: maxOverlapByRatio
-        })
+          max: maxOverlapByRatio,
+        }),
       );
       chunkOverlap = maxOverlapByRatio;
     }
@@ -191,8 +214,8 @@ export class ChunkConfigService {
       warnings.push(
         this.i18nService.formatMessage('overlapUnderflow', {
           size: chunkOverlap,
-          min: this.DEFAULTS.minChunkOverlap
-        })
+          min: this.DEFAULTS.minChunkOverlap,
+        }),
       );
       chunkOverlap = this.DEFAULTS.minChunkOverlap;
     }
@@ -207,8 +230,8 @@ export class ChunkConfigService {
         this.i18nService.formatMessage('batchOverflowWarning', {
           safeSize: safeChunkSize,
           size: chunkSize,
-          percent: Math.round(safetyMargin * 100)
-        })
+          percent: Math.round(safetyMargin * 100),
+        }),
       );
     }
 
@@ -220,7 +243,9 @@ export class ChunkConfigService {
 
     if (estimatedChunkCount > 50000) {
       warnings.push(
-        this.i18nService.formatMessage('estimatedChunkCountExcessive', { count: estimatedChunkCount })
+        this.i18nService.formatMessage('estimatedChunkCountExcessive', {
+          count: estimatedChunkCount,
+        }),
       );
     }
 
@@ -256,8 +281,8 @@ export class ChunkConfigService {
         this.i18nService.formatMessage('batchSizeAdjusted', {
           old: currentBatchSize,
           new: recommended,
-          limit: limits.maxBatchSize
-        })
+          limit: limits.maxBatchSize,
+        }),
       );
     }
 
@@ -288,8 +313,8 @@ export class ChunkConfigService {
         this.i18nService.formatMessage('dimensionMismatch', {
           id: modelId,
           expected: limits.expectedDimensions,
-          actual: actualDimensions
-        })
+          actual: actualDimensions,
+        }),
       );
       return false;
     }
@@ -349,7 +374,11 @@ export class ChunkConfigService {
     );
 
     // Get model config name
-    const modelConfig = await this.modelConfigService.findOne(modelId, userId, tenantId || '');
+    const modelConfig = await this.modelConfigService.findOne(
+      modelId,
+      userId,
+      tenantId || '',
+    );
     const modelName = modelConfig?.name || 'Unknown';
 
     // Get defaults from tenant or user settings
@@ -358,8 +387,10 @@ export class ChunkConfigService {
 
     if (tenantId) {
       const tenantSettings = await this.tenantService.getSettings(tenantId);
-      if (tenantSettings?.chunkSize) defaultChunkSize = tenantSettings.chunkSize;
-      if (tenantSettings?.chunkOverlap) defaultOverlapSize = tenantSettings.chunkOverlap;
+      if (tenantSettings?.chunkSize)
+        defaultChunkSize = tenantSettings.chunkSize;
+      if (tenantSettings?.chunkOverlap)
+        defaultOverlapSize = tenantSettings.chunkOverlap;
     }
 
     return {
@@ -367,7 +398,10 @@ export class ChunkConfigService {
       maxOverlapSize,
       minOverlapSize: this.DEFAULTS.minChunkOverlap,
       defaultChunkSize: Math.min(defaultChunkSize, maxChunkSize),
-      defaultOverlapSize: Math.max(this.DEFAULTS.minChunkOverlap, Math.min(defaultOverlapSize, maxOverlapSize)),
+      defaultOverlapSize: Math.max(
+        this.DEFAULTS.minChunkOverlap,
+        Math.min(defaultOverlapSize, maxOverlapSize),
+      ),
       modelInfo: {
         name: modelName,
         maxInputTokens: limits.maxInputTokens,

+ 86 - 33
server/src/knowledge-base/embedding.service.ts

@@ -28,7 +28,9 @@ export class EmbeddingService {
     this.defaultDimensions = parseInt(
       this.configService.get<string>('DEFAULT_VECTOR_DIMENSIONS', '2560'),
     );
-    this.logger.log(`Default vector dimensions set to ${this.defaultDimensions}`);
+    this.logger.log(
+      `Default vector dimensions set to ${this.defaultDimensions}`,
+    );
   }
 
   async getEmbeddings(
@@ -45,26 +47,37 @@ export class EmbeddingService {
       tenantId || 'default',
     );
     if (!modelConfig || modelConfig.type !== 'embedding') {
-      throw new Error(this.i18nService.formatMessage('embeddingModelNotFound', { id: embeddingModelConfigId }));
+      throw new Error(
+        this.i18nService.formatMessage('embeddingModelNotFound', {
+          id: embeddingModelConfigId,
+        }),
+      );
     }
 
     if (modelConfig.isEnabled === false) {
-      throw new Error(`Model ${modelConfig.name} is disabled and cannot generate embeddings`);
+      throw new Error(
+        `Model ${modelConfig.name} is disabled and cannot generate embeddings`,
+      );
     }
 
     // API key is optional - allows local models
 
     if (!modelConfig.baseUrl) {
-      throw new Error(`Model ${modelConfig.name} does not have baseUrl configured`);
+      throw new Error(
+        `Model ${modelConfig.name} does not have baseUrl configured`,
+      );
     }
 
     // Determine max batch size based on model name
-    const maxBatchSize = this.getMaxBatchSizeForModel(modelConfig.modelId, modelConfig.maxBatchSize);
+    const maxBatchSize = this.getMaxBatchSizeForModel(
+      modelConfig.modelId,
+      modelConfig.maxBatchSize,
+    );
 
     // Split processing if batch size exceeds limit
     if (texts.length > maxBatchSize) {
       this.logger.log(
-        `Splitting ${texts.length} texts into batches (model batch limit: ${maxBatchSize})`
+        `Splitting ${texts.length} texts into batches (model batch limit: ${maxBatchSize})`,
       );
 
       const allEmbeddings: number[][] = [];
@@ -75,14 +88,14 @@ export class EmbeddingService {
           batch,
           userId,
           modelConfig,
-          maxBatchSize
+          maxBatchSize,
         );
 
         allEmbeddings.push(...batchEmbeddings);
 
         // Wait briefly to avoid API rate limiting
         if (i + maxBatchSize < texts.length) {
-          await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms
+          await new Promise((resolve) => setTimeout(resolve, 100)); // Wait 100ms
         }
       }
 
@@ -93,7 +106,7 @@ export class EmbeddingService {
         texts,
         userId,
         modelConfig,
-        maxBatchSize
+        maxBatchSize,
       );
     }
   }
@@ -101,12 +114,21 @@ export class EmbeddingService {
   /**
    * Determine max batch size based on model ID
    */
-  private getMaxBatchSizeForModel(modelId: string, configuredMaxBatchSize?: number): number {
+  private getMaxBatchSizeForModel(
+    modelId: string,
+    configuredMaxBatchSize?: number,
+  ): number {
     // Model-specific batch size limits
-    if (modelId.includes('text-embedding-004') || modelId.includes('text-embedding-v4') ||
-      modelId.includes('text-embedding-ada-002')) {
+    if (
+      modelId.includes('text-embedding-004') ||
+      modelId.includes('text-embedding-v4') ||
+      modelId.includes('text-embedding-ada-002')
+    ) {
       return Math.min(10, configuredMaxBatchSize || 100); // Google limit: 10
-    } else if (modelId.includes('text-embedding-3') || modelId.includes('text-embedding-003')) {
+    } else if (
+      modelId.includes('text-embedding-3') ||
+      modelId.includes('text-embedding-003')
+    ) {
       return Math.min(2048, configuredMaxBatchSize || 2048); // OpenAI v3 limit: 2048
     } else {
       // Default: smaller of configured max or 100
@@ -138,8 +160,12 @@ export class EmbeddingService {
           this.logger.error(`Embedding API timeout after 60s: ${apiUrl}`);
         }, 60000); // 60s timeout
 
-        this.logger.log(`[Model call] Type: Embedding, Model: ${modelConfig.name} (${modelConfig.modelId}), User: ${userId}, Text count: ${texts.length}`);
-        this.logger.log(`Calling embedding API (attempt ${attempt}/${MAX_RETRIES}): ${apiUrl}`);
+        this.logger.log(
+          `[Model call] Type: Embedding, Model: ${modelConfig.name} (${modelConfig.modelId}), User: ${userId}, Text count: ${texts.length}`,
+        );
+        this.logger.log(
+          `Calling embedding API (attempt ${attempt}/${MAX_RETRIES}): ${apiUrl}`,
+        );
 
         let response;
         try {
@@ -164,10 +190,14 @@ export class EmbeddingService {
           const errorText = await response.text();
 
           // Detect batch size limit error
-          if (errorText.includes('batch size is invalid') || errorText.includes('batch_size') ||
-            errorText.includes('invalid') || errorText.includes('larger than')) {
+          if (
+            errorText.includes('batch size is invalid') ||
+            errorText.includes('batch_size') ||
+            errorText.includes('invalid') ||
+            errorText.includes('larger than')
+          ) {
             this.logger.warn(
-              `Batch size limit error detected. Splitting batch in half and retrying: ${maxBatchSize} -> ${Math.floor(maxBatchSize / 2)}`
+              `Batch size limit error detected. Splitting batch in half and retrying: ${maxBatchSize} -> ${Math.floor(maxBatchSize / 2)}`,
             );
 
             // Split batch into smaller units and retry
@@ -176,46 +206,67 @@ export class EmbeddingService {
               const firstHalf = texts.slice(0, midPoint);
               const secondHalf = texts.slice(midPoint);
 
-              const firstResult = await this.getEmbeddingsForBatch(firstHalf, userId, modelConfig, Math.floor(maxBatchSize / 2));
-              const secondResult = await this.getEmbeddingsForBatch(secondHalf, userId, modelConfig, Math.floor(maxBatchSize / 2));
+              const firstResult = await this.getEmbeddingsForBatch(
+                firstHalf,
+                userId,
+                modelConfig,
+                Math.floor(maxBatchSize / 2),
+              );
+              const secondResult = await this.getEmbeddingsForBatch(
+                secondHalf,
+                userId,
+                modelConfig,
+                Math.floor(maxBatchSize / 2),
+              );
 
               return [...firstResult, ...secondResult];
             }
           }
 
           // Detect context length excess error
-          if (errorText.includes('context length') || errorText.includes('exceeds')) {
-            const avgLength = texts.reduce((s, t) => s + t.length, 0) / texts.length;
+          if (
+            errorText.includes('context length') ||
+            errorText.includes('exceeds')
+          ) {
+            const avgLength =
+              texts.reduce((s, t) => s + t.length, 0) / texts.length;
             const totalLength = texts.reduce((s, t) => s + t.length, 0);
             this.logger.error(
               `Text length exceeds limit: ${texts.length} texts, ` +
-              `total ${totalLength} characters, average ${Math.round(avgLength)} characters, ` +
-              `model limit: ${modelConfig.maxInputTokens || 8192} tokens`
+                `total ${totalLength} characters, average ${Math.round(avgLength)} characters, ` +
+                `model limit: ${modelConfig.maxInputTokens || 8192} tokens`,
             );
             throw new Error(
               `Text length exceeds model limit. ` +
-              `Current: ${texts.length} texts with total ${totalLength} characters, ` +
-              `model limit: ${modelConfig.maxInputTokens || 8192} tokens. ` +
-              `Advice: Reduce chunk size or batch size`
+                `Current: ${texts.length} texts with total ${totalLength} characters, ` +
+                `model limit: ${modelConfig.maxInputTokens || 8192} tokens. ` +
+                `Advice: Reduce chunk size or batch size`,
             );
           }
 
           // Retry on 429 (Too Many Requests) or 5xx (Server Error)
           if (response.status === 429 || response.status >= 500) {
-            this.logger.warn(`Temporary error from embedding API (${response.status}): ${errorText}`);
+            this.logger.warn(
+              `Temporary error from embedding API (${response.status}): ${errorText}`,
+            );
             throw new Error(`API Error ${response.status}: ${errorText}`);
           }
 
           this.logger.error(`Embedding API error details: ${errorText}`);
-          this.logger.error(`Request parameters: model=${modelConfig.modelId}, inputLength=${texts[0]?.length}`);
-          throw new Error(`Embedding API call failed: ${response.statusText} - ${errorText}`);
+          this.logger.error(
+            `Request parameters: model=${modelConfig.modelId}, inputLength=${texts[0]?.length}`,
+          );
+          throw new Error(
+            `Embedding API call failed: ${response.statusText} - ${errorText}`,
+          );
         }
 
         const data: EmbeddingResponse = await response.json();
         const embeddings = data.data.map((item) => item.embedding);
 
         // Get dimensions from actual response
-        const actualDimensions = embeddings[0]?.length || this.defaultDimensions;
+        const actualDimensions =
+          embeddings[0]?.length || this.defaultDimensions;
         this.logger.log(
           `Got ${embeddings.length} embedding vectors from ${modelConfig.name}. Dimensions: ${actualDimensions}`,
         );
@@ -227,8 +278,10 @@ export class EmbeddingService {
         // If not the last attempt and error appears temporary (or for robustness on all), retry after waiting
         if (attempt < MAX_RETRIES) {
           const delay = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
-          this.logger.warn(`Embedding request failed. Retrying after ${delay}ms: ${error.message}`);
-          await new Promise(resolve => setTimeout(resolve, delay));
+          this.logger.warn(
+            `Embedding request failed. Retrying after ${delay}ms: ${error.message}`,
+          );
+          await new Promise((resolve) => setTimeout(resolve, delay));
           continue;
         }
       }

+ 79 - 32
server/src/knowledge-base/knowledge-base.controller.ts

@@ -36,7 +36,7 @@ export class KnowledgeBaseController {
     private readonly chunkConfigService: ChunkConfigService,
     private readonly knowledgeGroupService: KnowledgeGroupService,
     private readonly i18nService: I18nService,
-  ) { }
+  ) {}
 
   @Get()
   @UseGuards(CombinedAuthGuard)
@@ -46,7 +46,9 @@ export class KnowledgeBaseController {
 
   @Get('stats')
   @UseGuards(CombinedAuthGuard)
-  async getStats(@Request() req): Promise<{ total: number, uncategorized: number }> {
+  async getStats(
+    @Request() req,
+  ): Promise<{ total: number; uncategorized: number }> {
     return this.knowledgeBaseService.getStats(req.user.id, req.user.tenantId);
   }
 
@@ -86,7 +88,11 @@ export class KnowledgeBaseController {
     @Request() req,
     @Param('id') fileId: string,
   ): Promise<{ message: string }> {
-    await this.knowledgeBaseService.deleteFile(fileId, req.user.id, req.user.tenantId);
+    await this.knowledgeBaseService.deleteFile(
+      fileId,
+      req.user.id,
+      req.user.tenantId,
+    );
     return { message: this.i18nService.getMessage('fileDeleted') };
   }
 
@@ -96,18 +102,22 @@ export class KnowledgeBaseController {
     @Request() req,
     @Param('id') fileId: string,
   ): Promise<KnowledgeBase> {
-    return this.knowledgeBaseService.retryFailedFile(fileId, req.user.id, req.user.tenantId);
+    return this.knowledgeBaseService.retryFailedFile(
+      fileId,
+      req.user.id,
+      req.user.tenantId,
+    );
   }
 
   @Get(':id/chunks')
-  async getFileChunks(
-    @Request() req,
-    @Param('id') fileId: string,
-  ) {
-    return this.knowledgeBaseService.getFileChunks(fileId, req.user.id, req.user.tenantId);
+  async getFileChunks(@Request() req, @Param('id') fileId: string) {
+    return this.knowledgeBaseService.getFileChunks(
+      fileId,
+      req.user.id,
+      req.user.tenantId,
+    );
   }
 
-
   /**
    * Get chunk config limits (for frontend slider settings)
    * Query parameter: embeddingModelId - embedding model ID
@@ -128,7 +138,9 @@ export class KnowledgeBaseController {
           name: this.i18nService.getMessage('modelNotConfigured'),
           maxInputTokens: parseInt(process.env.MAX_CHUNK_SIZE || '8191'),
           maxBatchSize: 2048,
-          expectedDimensions: parseInt(process.env.DEFAULT_VECTOR_DIMENSIONS || '2560'),
+          expectedDimensions: parseInt(
+            process.env.DEFAULT_VECTOR_DIMENSIONS || '2560',
+          ),
         },
       };
     }
@@ -183,24 +195,32 @@ export class KnowledgeBaseController {
   ) {
     try {
       if (!token) {
-        throw new NotFoundException(this.i18nService.getMessage('accessDeniedNoToken'));
+        throw new NotFoundException(
+          this.i18nService.getMessage('accessDeniedNoToken'),
+        );
       }
 
       const jwt = await import('jsonwebtoken');
       const secret = process.env.JWT_SECRET;
       if (!secret) {
-        throw new InternalServerErrorException(this.i18nService.getMessage('jwtSecretRequired'));
+        throw new InternalServerErrorException(
+          this.i18nService.getMessage('jwtSecretRequired'),
+        );
       }
 
       let decoded;
       try {
         decoded = jwt.verify(token, secret) as any;
       } catch {
-        throw new NotFoundException(this.i18nService.getMessage('invalidToken'));
+        throw new NotFoundException(
+          this.i18nService.getMessage('invalidToken'),
+        );
       }
 
       if (decoded.type !== 'pdf-access' || decoded.fileId !== fileId) {
-        throw new NotFoundException(this.i18nService.getMessage('invalidToken'));
+        throw new NotFoundException(
+          this.i18nService.getMessage('invalidToken'),
+        );
       }
 
       const pdfPath = await this.knowledgeBaseService.ensurePDFExists(
@@ -213,7 +233,9 @@ export class KnowledgeBaseController {
       const path = await import('path');
 
       if (!fs.existsSync(pdfPath)) {
-        throw new NotFoundException(this.i18nService.getMessage('pdfFileNotFound'));
+        throw new NotFoundException(
+          this.i18nService.getMessage('pdfFileNotFound'),
+        );
       }
 
       const stat = fs.statSync(pdfPath);
@@ -223,8 +245,10 @@ export class KnowledgeBaseController {
         this.logger.warn(`PDF file is empty: ${pdfPath}`);
         try {
           fs.unlinkSync(pdfPath); // Delete empty file
-        } catch (e) { }
-        throw new NotFoundException(this.i18nService.getMessage('pdfFileEmpty'));
+        } catch (e) {}
+        throw new NotFoundException(
+          this.i18nService.getMessage('pdfFileEmpty'),
+        );
       }
 
       res.setHeader('Content-Type', 'application/pdf');
@@ -237,7 +261,9 @@ export class KnowledgeBaseController {
         throw error;
       }
       this.logger.error(`PDF preview error: ${error.message}`);
-      throw new NotFoundException(this.i18nService.getMessage('pdfConversionFailed'));
+      throw new NotFoundException(
+        this.i18nService.getMessage('pdfConversionFailed'),
+      );
     }
   }
 
@@ -250,39 +276,56 @@ export class KnowledgeBaseController {
   ) {
     try {
       // Trigger PDF conversion
-      await this.knowledgeBaseService.ensurePDFExists(fileId, req.user.id, req.user.tenantId, force === 'true');
+      await this.knowledgeBaseService.ensurePDFExists(
+        fileId,
+        req.user.id,
+        req.user.tenantId,
+        force === 'true',
+      );
 
       // Generate temporary access token
       const jwt = await import('jsonwebtoken');
 
       const secret = process.env.JWT_SECRET;
       if (!secret) {
-        throw new InternalServerErrorException(this.i18nService.getMessage('jwtSecretRequired'));
+        throw new InternalServerErrorException(
+          this.i18nService.getMessage('jwtSecretRequired'),
+        );
       }
 
       const token = jwt.sign(
-        { fileId, userId: req.user.id, tenantId: req.user.tenantId, type: 'pdf-access' },
+        {
+          fileId,
+          userId: req.user.id,
+          tenantId: req.user.tenantId,
+          type: 'pdf-access',
+        },
         secret,
-        { expiresIn: '1h' }
+        { expiresIn: '1h' },
       );
 
       return {
-        url: `/api/knowledge-bases/${fileId}/pdf?token=${token}`
+        url: `/api/knowledge-bases/${fileId}/pdf?token=${token}`,
       };
     } catch (error) {
       if (error.message.includes('LibreOffice')) {
-        throw new InternalServerErrorException(this.i18nService.formatMessage('pdfServiceUnavailable', { message: error.message }));
+        throw new InternalServerErrorException(
+          this.i18nService.formatMessage('pdfServiceUnavailable', {
+            message: error.message,
+          }),
+        );
       }
       throw new InternalServerErrorException(error.message);
     }
   }
 
   @Get(':id/pdf-status')
-  async getPDFStatus(
-    @Param('id') fileId: string,
-    @Request() req,
-  ) {
-    return await this.knowledgeBaseService.getPDFStatus(fileId, req.user.id, req.user.tenantId);
+  async getPDFStatus(@Param('id') fileId: string, @Request() req) {
+    return await this.knowledgeBaseService.getPDFStatus(
+      fileId,
+      req.user.id,
+      req.user.tenantId,
+    );
   }
 
   // Get specific page of PDF as image
@@ -303,13 +346,17 @@ export class KnowledgeBaseController {
 
       const fs = await import('fs');
       if (!fs.existsSync(imagePath)) {
-        throw new NotFoundException(this.i18nService.getMessage('pageImageNotFound'));
+        throw new NotFoundException(
+          this.i18nService.getMessage('pageImageNotFound'),
+        );
       }
 
       res.sendFile(path.resolve(imagePath));
     } catch (error) {
       this.logger.error(`Failed to get PDF page image: ${error.message}`);
-      throw new NotFoundException(this.i18nService.getMessage('pdfPageImageFailed'));
+      throw new NotFoundException(
+        this.i18nService.getMessage('pdfPageImageFailed'),
+      );
     }
   }
 }

+ 1 - 1
server/src/knowledge-base/knowledge-base.entity.ts

@@ -20,7 +20,7 @@ export enum FileStatus {
 }
 
 export enum ProcessingMode {
-  FAST = 'fast',      // Fast mode - use Tika
+  FAST = 'fast', // Fast mode - use Tika
   PRECISE = 'precise', // Precise mode - use Vision Pipeline
 }
 

+ 1 - 1
server/src/knowledge-base/knowledge-base.module.ts

@@ -49,4 +49,4 @@ import { CombinedAuthGuard } from '../auth/combined-auth.guard';
   ],
   exports: [KnowledgeBaseService, EmbeddingService],
 })
-export class KnowledgeBaseModule { }
+export class KnowledgeBaseModule {}

File diff ditekan karena terlalu besar
+ 386 - 134
server/src/knowledge-base/knowledge-base.service.ts


+ 35 - 13
server/src/knowledge-base/memory-monitor.service.ts

@@ -1,10 +1,10 @@
 import { Injectable, Logger } from '@nestjs/common';
 
 export interface MemoryStats {
-  heapUsed: number;    // Used heap memory (MB)
-  heapTotal: number;   // Total heap memory (MB)
-  external: number;    // External memory (MB)
-  rss: number;         // RSS (Resident Set Size) (MB)
+  heapUsed: number; // Used heap memory (MB)
+  heapTotal: number; // Total heap memory (MB)
+  external: number; // External memory (MB)
+  rss: number; // RSS (Resident Set Size) (MB)
   timestamp: Date;
 }
 
@@ -21,7 +21,9 @@ export class MemoryMonitorService {
     this.BATCH_SIZE = parseInt(process.env.CHUNK_BATCH_SIZE || '100'); // 100 chunks per batch
     this.GC_THRESHOLD_MB = parseInt(process.env.GC_THRESHOLD_MB || '800'); // Trigger GC at 800MB
 
-    this.logger.log(`Memory monitor initialized: limit=${this.MAX_MEMORY_MB}MB, batchSize=${this.BATCH_SIZE}, GCThreshold=${this.GC_THRESHOLD_MB}MB`);
+    this.logger.log(
+      `Memory monitor initialized: limit=${this.MAX_MEMORY_MB}MB, batchSize=${this.BATCH_SIZE}, GCThreshold=${this.GC_THRESHOLD_MB}MB`,
+    );
   }
 
   /**
@@ -54,7 +56,9 @@ export class MemoryMonitorService {
 
     while (this.isMemoryHigh()) {
       if (Date.now() - startTime > timeoutMs) {
-        throw new Error(`Memory wait timeout: current ${this.getMemoryUsage().heapUsed}MB > ${this.MAX_MEMORY_MB * 0.85}MB`);
+        throw new Error(
+          `Memory wait timeout: current ${this.getMemoryUsage().heapUsed}MB > ${this.MAX_MEMORY_MB * 0.85}MB`,
+        );
       }
 
       this.logger.warn(
@@ -119,7 +123,11 @@ export class MemoryMonitorService {
     items: T[],
     processor: (batch: T[], batchIndex: number) => Promise<R[]>,
     options?: {
-      onBatchComplete?: (batchIndex: number, totalBatches: number, results: R[]) => Promise<void> | void;
+      onBatchComplete?: (
+        batchIndex: number,
+        totalBatches: number,
+        results: R[],
+      ) => Promise<void> | void;
       maxConcurrency?: number;
     },
   ): Promise<R[]> {
@@ -132,7 +140,7 @@ export class MemoryMonitorService {
     const allResults: R[] = [];
     let processedCount = 0;
 
-    for (let i = 0; i < totalItems;) {
+    for (let i = 0; i < totalItems; ) {
       // Check memory state and wait
       await this.waitForMemoryAvailable();
 
@@ -182,7 +190,11 @@ export class MemoryMonitorService {
   /**
    * Estimate memory required for processing
    */
-  estimateMemoryUsage(itemCount: number, itemSizeBytes: number, vectorDim: number): number {
+  estimateMemoryUsage(
+    itemCount: number,
+    itemSizeBytes: number,
+    vectorDim: number,
+  ): number {
     // Text content memory
     const textMemory = itemCount * itemSizeBytes;
 
@@ -192,7 +204,9 @@ export class MemoryMonitorService {
     // Object overhead (~100 bytes per object)
     const overhead = itemCount * 100;
 
-    const totalMB = Math.round((textMemory + vectorMemory + overhead) / 1024 / 1024);
+    const totalMB = Math.round(
+      (textMemory + vectorMemory + overhead) / 1024 / 1024,
+    );
 
     return totalMB;
   }
@@ -200,8 +214,16 @@ export class MemoryMonitorService {
   /**
    * Check if batching should be used
    */
-  shouldUseBatching(itemCount: number, itemSizeBytes: number, vectorDim: number): boolean {
-    const estimatedMB = this.estimateMemoryUsage(itemCount, itemSizeBytes, vectorDim);
+  shouldUseBatching(
+    itemCount: number,
+    itemSizeBytes: number,
+    vectorDim: number,
+  ): boolean {
+    const estimatedMB = this.estimateMemoryUsage(
+      itemCount,
+      itemSizeBytes,
+      vectorDim,
+    );
     const threshold = this.MAX_MEMORY_MB * 0.7; // 70% threshold
 
     if (estimatedMB > threshold) {
@@ -223,7 +245,7 @@ export class MemoryMonitorService {
     const targetMemoryBytes = targetMemoryMB * 1024 * 1024;
 
     // Memory per item = text + vector + overhead
-    const singleItemMemory = itemSizeBytes + (vectorDim * 4) + 100;
+    const singleItemMemory = itemSizeBytes + vectorDim * 4 + 100;
 
     const batchSize = Math.floor(targetMemoryBytes / singleItemMemory);
 

+ 23 - 6
server/src/knowledge-group/knowledge-group.controller.ts

@@ -14,7 +14,11 @@ import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 import { RolesGuard } from '../auth/roles.guard';
 import { Roles } from '../auth/roles.decorator';
 import { UserRole } from '../user/user-role.enum';
-import { KnowledgeGroupService, CreateGroupDto, UpdateGroupDto } from './knowledge-group.service';
+import {
+  KnowledgeGroupService,
+  CreateGroupDto,
+  UpdateGroupDto,
+} from './knowledge-group.service';
 import { I18nService } from '../i18n/i18n.service';
 
 @Controller('knowledge-groups')
@@ -23,7 +27,7 @@ export class KnowledgeGroupController {
   constructor(
     private readonly groupService: KnowledgeGroupService,
     private readonly i18nService: I18nService,
-  ) { }
+  ) {}
 
   @Get()
   async findAll(@Request() req) {
@@ -39,7 +43,11 @@ export class KnowledgeGroupController {
   @Post()
   @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   async create(@Body() createGroupDto: CreateGroupDto, @Request() req) {
-    return await this.groupService.create(req.user.id, req.user.tenantId, createGroupDto);
+    return await this.groupService.create(
+      req.user.id,
+      req.user.tenantId,
+      createGroupDto,
+    );
   }
 
   @Put(':id')
@@ -49,7 +57,12 @@ export class KnowledgeGroupController {
     @Body() updateGroupDto: UpdateGroupDto,
     @Request() req,
   ) {
-    return await this.groupService.update(id, req.user.id, req.user.tenantId, updateGroupDto);
+    return await this.groupService.update(
+      id,
+      req.user.id,
+      req.user.tenantId,
+      updateGroupDto,
+    );
   }
 
   @Delete(':id')
@@ -61,7 +74,11 @@ export class KnowledgeGroupController {
 
   @Get(':id/files')
   async getGroupFiles(@Param('id') id: string, @Request() req) {
-    const files = await this.groupService.getGroupFiles(id, req.user.id, req.user.tenantId);
+    const files = await this.groupService.getGroupFiles(
+      id,
+      req.user.id,
+      req.user.tenantId,
+    );
     return { files };
   }
-}
+}

+ 9 - 3
server/src/knowledge-group/knowledge-group.entity.ts

@@ -40,7 +40,10 @@ export class KnowledgeGroup {
   @Column({ name: 'parent_id', nullable: true, type: 'text' })
   parentId: string | null;
 
-  @ManyToOne(() => KnowledgeGroup, (group) => group.children, { nullable: true, onDelete: 'SET NULL' })
+  @ManyToOne(() => KnowledgeGroup, (group) => group.children, {
+    nullable: true,
+    onDelete: 'SET NULL',
+  })
   @JoinColumn({ name: 'parent_id' })
   parent: KnowledgeGroup;
 
@@ -57,7 +60,10 @@ export class KnowledgeGroup {
   @JoinTable({
     name: 'knowledge_base_groups',
     joinColumn: { name: 'group_id', referencedColumnName: 'id' },
-    inverseJoinColumn: { name: 'knowledge_base_id', referencedColumnName: 'id' },
+    inverseJoinColumn: {
+      name: 'knowledge_base_id',
+      referencedColumnName: 'id',
+    },
   })
   knowledgeBases: KnowledgeBase[];
-}
+}

+ 3 - 1
server/src/knowledge-group/knowledge-group.module.ts

@@ -8,6 +8,7 @@ import { KnowledgeGroupController } from './knowledge-group.controller';
 import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
 import { I18nModule } from '../i18n/i18n.module';
 import { UserModule } from '../user/user.module';
+import { TenantModule } from '../tenant/tenant.module';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 
 @Module({
@@ -17,9 +18,10 @@ import { CombinedAuthGuard } from '../auth/combined-auth.guard';
     forwardRef(() => KnowledgeBaseModule),
     I18nModule,
     UserModule,
+    TenantModule,
   ],
   controllers: [KnowledgeGroupController],
   providers: [KnowledgeGroupService, CombinedAuthGuard],
   exports: [KnowledgeGroupService],
 })
-export class KnowledgeGroupModule { }
+export class KnowledgeGroupModule {}

+ 172 - 39
server/src/knowledge-group/knowledge-group.service.ts

@@ -1,10 +1,17 @@
-import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
+import {
+  Injectable,
+  NotFoundException,
+  ForbiddenException,
+  Inject,
+  forwardRef,
+} from '@nestjs/common';
 import { I18nService } from '../i18n/i18n.service';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository, In } from 'typeorm';
 import { KnowledgeGroup } from './knowledge-group.entity';
 import { KnowledgeBase } from '../knowledge-base/knowledge-base.entity';
 import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
+import { TenantService } from '../tenant/tenant.service';
 
 export interface CreateGroupDto {
   name: string;
@@ -47,10 +54,14 @@ export class KnowledgeGroupService {
     private knowledgeBaseRepository: Repository<KnowledgeBase>,
     @Inject(forwardRef(() => KnowledgeBaseService))
     private knowledgeBaseService: KnowledgeBaseService,
+    private tenantService: TenantService,
     private i18nService: I18nService,
-  ) { }
+  ) {}
 
-  async findAll(userId: string, tenantId: string): Promise<GroupWithFileCount[]> {
+  async findAll(
+    userId: string,
+    tenantId: string,
+  ): Promise<GroupWithFileCount[]> {
     // Return all groups for the tenant with file counts
     const groups = await this.groupRepository
       .createQueryBuilder('group')
@@ -61,16 +72,18 @@ export class KnowledgeGroupService {
       .orderBy('group.createdAt', 'ASC')
       .getRawAndEntities();
 
-    const flatList: GroupWithFileCount[] = groups.entities.map((group, index) => ({
-      id: group.id,
-      name: group.name,
-      description: group.description,
-      color: group.color,
-      parentId: group.parentId ?? null,
-      fileCount: parseInt(groups.raw[index].fileCount) || 0,
-      createdAt: group.createdAt,
-      children: [],
-    }));
+    const flatList: GroupWithFileCount[] = groups.entities.map(
+      (group, index) => ({
+        id: group.id,
+        name: group.name,
+        description: group.description,
+        color: group.color,
+        parentId: group.parentId ?? null,
+        fileCount: parseInt(groups.raw[index].fileCount) || 0,
+        createdAt: group.createdAt,
+        children: [],
+      }),
+    );
 
     // Build tree structure
     return this.buildTree(flatList);
@@ -79,10 +92,10 @@ export class KnowledgeGroupService {
   /** Build a nested tree from a flat list */
   private buildTree(items: GroupWithFileCount[]): GroupWithFileCount[] {
     const map = new Map<string, GroupWithFileCount>();
-    items.forEach(item => map.set(item.id, { ...item, children: [] }));
+    items.forEach((item) => map.set(item.id, { ...item, children: [] }));
 
     const roots: GroupWithFileCount[] = [];
-    map.forEach(item => {
+    map.forEach((item) => {
       if (item.parentId && map.has(item.parentId)) {
         map.get(item.parentId)!.children!.push(item);
       } else {
@@ -92,10 +105,13 @@ export class KnowledgeGroupService {
     return roots;
   }
 
-  async findOne(id: string, userId: string, tenantId: string): Promise<KnowledgeGroup> {
-    // Restrict group to tenant
+  async findOne(
+    id: string,
+    userId: string,
+    tenantId: string,
+  ): Promise<KnowledgeGroup> {
     const group = await this.groupRepository.findOne({
-      where: { id, tenantId },
+      where: { id },
       relations: ['knowledgeBases'],
     });
 
@@ -103,10 +119,26 @@ export class KnowledgeGroupService {
       throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
     }
 
+    // Check permission using TenantService
+    const hasAccess = await this.tenantService.canAccessTenant(
+      userId,
+      group.tenantId,
+      tenantId,
+    );
+    if (!hasAccess) {
+      throw new ForbiddenException(
+        `You do not have permission to access this knowledge group`,
+      );
+    }
+
     return group;
   }
 
-  async create(userId: string, tenantId: string, createGroupDto: CreateGroupDto): Promise<KnowledgeGroup> {
+  async create(
+    userId: string,
+    tenantId: string,
+    createGroupDto: CreateGroupDto,
+  ): Promise<KnowledgeGroup> {
     const group = this.groupRepository.create({
       ...createGroupDto,
       parentId: createGroupDto.parentId ?? null,
@@ -116,7 +148,12 @@ export class KnowledgeGroupService {
     return await this.groupRepository.save(group);
   }
 
-  async update(id: string, userId: string, tenantId: string, updateGroupDto: UpdateGroupDto): Promise<KnowledgeGroup> {
+  async update(
+    id: string,
+    userId: string,
+    tenantId: string,
+    updateGroupDto: UpdateGroupDto,
+  ): Promise<KnowledgeGroup> {
     // Update group within the tenant
     const group = await this.groupRepository.findOne({
       where: { id, tenantId },
@@ -144,9 +181,15 @@ export class KnowledgeGroupService {
   }
 
   /** Recursively delete a group, all its children, and all associated files */
-  private async removeGroupRecursive(id: string, userId: string, tenantId: string): Promise<void> {
+  private async removeGroupRecursive(
+    id: string,
+    userId: string,
+    tenantId: string,
+  ): Promise<void> {
     // 1. Find all direct children of this group
-    const children = await this.groupRepository.find({ where: { parentId: id, tenantId } });
+    const children = await this.groupRepository.find({
+      where: { parentId: id, tenantId },
+    });
 
     // 2. Recurse into each child first (depth-first)
     for (const child of children) {
@@ -168,10 +211,17 @@ export class KnowledgeGroupService {
           select: ['id', 'userId', 'tenantId'],
         });
         if (fullFile) {
-          await this.knowledgeBaseService.deleteFile(fullFile.id, fullFile.userId, fullFile.tenantId as string);
+          await this.knowledgeBaseService.deleteFile(
+            fullFile.id,
+            fullFile.userId,
+            fullFile.tenantId,
+          );
         }
       } catch (error) {
-        console.error(`Failed to delete file ${file.id} when deleting group ${id}`, error);
+        console.error(
+          `Failed to delete file ${file.id} when deleting group ${id}`,
+          error,
+        );
       }
     }
 
@@ -182,9 +232,13 @@ export class KnowledgeGroupService {
     }
   }
 
-  async getGroupFiles(groupId: string, userId: string, tenantId: string): Promise<KnowledgeBase[]> {
+  async getGroupFiles(
+    groupId: string,
+    userId: string,
+    tenantId: string,
+  ): Promise<KnowledgeBase[]> {
     const group = await this.groupRepository.findOne({
-      where: { id: groupId, tenantId },
+      where: { id: groupId },
       relations: ['knowledgeBases'],
     });
 
@@ -192,12 +246,29 @@ export class KnowledgeGroupService {
       throw new NotFoundException(this.i18nService.getMessage('groupNotFound'));
     }
 
+    // Check permission using TenantService
+    const hasAccess = await this.tenantService.canAccessTenant(
+      userId,
+      group.tenantId,
+      tenantId,
+    );
+    if (!hasAccess) {
+      throw new ForbiddenException(
+        `You do not have permission to access this knowledge group`,
+      );
+    }
+
     return group.knowledgeBases;
   }
 
-  async addFilesToGroup(fileId: string, groupIds: string[], userId: string, tenantId: string): Promise<void> {
+  async addFilesToGroup(
+    fileId: string,
+    groupIds: string[],
+    userId: string,
+    tenantId: string,
+  ): Promise<void> {
     const file = await this.knowledgeBaseRepository.findOne({
-      where: { id: fileId, userId, tenantId },
+      where: { id: fileId },
       relations: ['groups'],
     });
 
@@ -205,21 +276,52 @@ export class KnowledgeGroupService {
       throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
     }
 
+    // Check permission for file
+    const fileAccess = await this.tenantService.canAccessTenant(
+      userId,
+      file.tenantId,
+      tenantId,
+    );
+    if (!fileAccess) {
+      throw new ForbiddenException(
+        `You do not have permission to modify this knowledge base`,
+      );
+    }
+
     // Load all groups by ID without user restriction
     const groups = await this.groupRepository.findBy({ id: In(groupIds) });
-    const validGroups = groups.filter(g => g.tenantId === tenantId);
+    // Verify each group access
+    for (const g of groups) {
+      const gAccess = await this.tenantService.canAccessTenant(
+        userId,
+        g.tenantId,
+        tenantId,
+      );
+      if (!gAccess) {
+        throw new ForbiddenException(
+          `You do not have permission to access group ${g.name}`,
+        );
+      }
+    }
 
-    if (groupIds.length > 0 && validGroups.length !== groupIds.length) {
-      throw new NotFoundException(this.i18nService.getMessage('someGroupsNotFound'));
+    if (groupIds.length > 0 && groups.length !== groupIds.length) {
+      throw new NotFoundException(
+        this.i18nService.getMessage('someGroupsNotFound'),
+      );
     }
 
-    file.groups = validGroups;
+    file.groups = groups;
     await this.knowledgeBaseRepository.save(file);
   }
 
-  async removeFileFromGroup(fileId: string, groupId: string, userId: string, tenantId: string): Promise<void> {
+  async removeFileFromGroup(
+    fileId: string,
+    groupId: string,
+    userId: string,
+    tenantId: string,
+  ): Promise<void> {
     const file = await this.knowledgeBaseRepository.findOne({
-      where: { id: fileId, userId, tenantId },
+      where: { id: fileId },
       relations: ['groups'],
     });
 
@@ -227,24 +329,55 @@ export class KnowledgeGroupService {
       throw new NotFoundException(this.i18nService.getMessage('fileNotFound'));
     }
 
-    file.groups = file.groups.filter(group => group.id !== groupId);
+    // Check permission for file
+    const fileAccess = await this.tenantService.canAccessTenant(
+      userId,
+      file.tenantId,
+      tenantId,
+    );
+    if (!fileAccess) {
+      throw new ForbiddenException(
+        `You do not have permission to modify this knowledge base`,
+      );
+    }
+
+    file.groups = file.groups.filter((group) => group.id !== groupId);
     await this.knowledgeBaseRepository.save(file);
   }
 
-  async getFileIdsByGroups(groupIds: string[], userId: string, tenantId: string): Promise<string[]> {
+  async getFileIdsByGroups(
+    groupIds: string[],
+    userId: string,
+    tenantId: string,
+  ): Promise<string[]> {
     if (!groupIds || groupIds.length === 0) {
       return [];
     }
 
+    // Security check: Verify user has access to these groups
+    const groups = await this.groupRepository.findBy({ id: In(groupIds) });
+    for (const g of groups) {
+      const hasAccess = await this.tenantService.canAccessTenant(
+        userId,
+        g.tenantId,
+        tenantId,
+      );
+      if (!hasAccess) {
+        throw new ForbiddenException(
+          `You do not have permission to access group ${g.name}`,
+        );
+      }
+    }
+
     const result = await this.knowledgeBaseRepository
       .createQueryBuilder('kb')
       .innerJoin('kb.groups', 'group')
       .where('group.id IN (:...groupIds)', { groupIds })
-      .andWhere('kb.tenantId = :tenantId', { tenantId })
+      // No extra tenantId check here because we verified the groups above
       .select('DISTINCT kb.id', 'id')
       .getRawMany();
 
-    return result.map(row => row.id);
+    return result.map((row) => row.id);
   }
 
   /**
@@ -265,4 +398,4 @@ export class KnowledgeGroupService {
 
     return this.create(userId, tenantId, { name, description, parentId });
   }
-}
+}

+ 42 - 24
server/src/libreoffice/libreoffice.service.ts

@@ -4,7 +4,10 @@ import * as fs from 'fs/promises';
 import * as path from 'path';
 import axios from 'axios';
 import FormData from 'form-data';
-import { LibreOfficeConvertResponse, LibreOfficeHealthResponse } from './libreoffice.interface';
+import {
+  LibreOfficeConvertResponse,
+  LibreOfficeHealthResponse,
+} from './libreoffice.interface';
 import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
@@ -15,7 +18,7 @@ export class LibreOfficeService implements OnModuleInit {
   constructor(
     private configService: ConfigService,
     private i18nService: I18nService,
-  ) { }
+  ) {}
 
   onModuleInit() {
     const libreofficeUrl = this.configService.get<string>('LIBREOFFICE_URL');
@@ -23,7 +26,9 @@ export class LibreOfficeService implements OnModuleInit {
       throw new Error(this.i18nService.getMessage('libreofficeUrlRequired'));
     }
     this.baseUrl = libreofficeUrl;
-    this.logger.log(`LibreOffice service initialized with base URL: ${this.baseUrl}`);
+    this.logger.log(
+      `LibreOffice service initialized with base URL: ${this.baseUrl}`,
+    );
   }
 
   /**
@@ -33,7 +38,7 @@ export class LibreOfficeService implements OnModuleInit {
     try {
       const response = await axios.get<LibreOfficeHealthResponse>(
         `${this.baseUrl}/health`,
-        { timeout: 5000 }
+        { timeout: 5000 },
       );
       return response.data.status === 'healthy';
     } catch (error) {
@@ -94,16 +99,12 @@ export class LibreOfficeService implements OnModuleInit {
     for (let attempt = 1; attempt <= maxRetries; attempt++) {
       try {
         // Call LibreOffice service
-        const response = await axios.post(
-          `${this.baseUrl}/convert`,
-          formData,
-          {
-            headers: formData.getHeaders(),
-            timeout: 300000, // 5 minute timeout
-            responseType: 'stream', // Receive file stream
-            maxRedirects: 5, // Max redirects
-          }
-        );
+        const response = await axios.post(`${this.baseUrl}/convert`, formData, {
+          headers: formData.getHeaders(),
+          timeout: 300000, // 5 minute timeout
+          responseType: 'stream', // Receive file stream
+          maxRedirects: 5, // Max redirects
+        });
 
         // Write stream to output file
         const writer = (await import('fs')).createWriteStream(targetPdfPath);
@@ -111,7 +112,9 @@ export class LibreOfficeService implements OnModuleInit {
 
         return new Promise((resolve, reject) => {
           writer.on('finish', () => {
-            this.logger.log(`Conversion completed: ${fileName} -> ${targetPdfPath}`);
+            this.logger.log(
+              `Conversion completed: ${fileName} -> ${targetPdfPath}`,
+            );
             resolve(targetPdfPath);
           });
           writer.on('error', (err) => {
@@ -119,17 +122,23 @@ export class LibreOfficeService implements OnModuleInit {
             reject(err);
           });
         });
-
       } catch (error) {
-        this.logger.error(`Attempt ${attempt} failed for ${fileName}: ${error.message}`);
+        this.logger.error(
+          `Attempt ${attempt} failed for ${fileName}: ${error.message}`,
+        );
         lastError = error;
 
         // Wait and retry on socket hang up or connection error
-        if (error.code === 'ECONNRESET' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.message.includes('socket hang up')) {
+        if (
+          error.code === 'ECONNRESET' ||
+          error.code === 'ECONNREFUSED' ||
+          error.code === 'ETIMEDOUT' ||
+          error.message.includes('socket hang up')
+        ) {
           if (attempt < maxRetries) {
             const delay = 2000 * attempt; // Increasing delay
             this.logger.log(`Waiting ${delay}ms before retry...`);
-            await new Promise(resolve => setTimeout(resolve, delay));
+            await new Promise((resolve) => setTimeout(resolve, delay));
           }
         } else {
           // Do not retry other errors
@@ -161,19 +170,28 @@ export class LibreOfficeService implements OnModuleInit {
           throw new Error('Conversion timed out. The file may be too large.');
         }
         throw new Error(`Conversion failed: ${detail}`);
-
       } catch (streamError) {
         this.logger.error('Error reading error stream:', streamError);
         throw new Error(`Conversion failed: ${lastError.message}`);
       }
     }
 
-    this.logger.error(`Conversion failed for ${fileName} after ${maxRetries} attempts:`, lastError.message);
+    this.logger.error(
+      `Conversion failed for ${fileName} after ${maxRetries} attempts:`,
+      lastError.message,
+    );
     if (lastError.code === 'ECONNREFUSED') {
-      throw new Error('LibreOffice service is not running. Please check the service status.');
+      throw new Error(
+        'LibreOffice service is not running. Please check the service status.',
+      );
     }
-    if (lastError.code === 'ECONNRESET' || lastError.message.includes('socket hang up')) {
-      throw new Error('Connection to LibreOffice service was reset. The service may be unstable.');
+    if (
+      lastError.code === 'ECONNRESET' ||
+      lastError.message.includes('socket hang up')
+    ) {
+      throw new Error(
+        'Connection to LibreOffice service was reset. The service may be unstable.',
+      );
     }
     throw new Error(`Conversion failed: ${lastError.message}`);
   }

+ 4 - 2
server/src/main.ts

@@ -7,7 +7,7 @@ async function bootstrap() {
   const app = await NestFactory.create(AppModule);
   app.useGlobalPipes(new ValidationPipe());
   app.enableCors({
-    origin: true, // Allow all origins 
+    origin: true, // Allow all origins
     methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
     credentials: true,
   });
@@ -16,7 +16,9 @@ async function bootstrap() {
   // Swagger / OpenAPI documentation
   const config = new DocumentBuilder()
     .setTitle('AuraK API')
-    .setDescription('External API for accessing AuraK functionalities via API Key')
+    .setDescription(
+      'External API for accessing AuraK functionalities via API Key',
+    )
     .setVersion('1.0')
     .addApiKey({ type: 'apiKey', name: 'x-api-key', in: 'header' }, 'x-api-key')
     .build();

+ 13 - 5
server/src/migrations/1737800000000-AddKnowledgeBaseEnhancements.ts

@@ -60,9 +60,15 @@ export class AddKnowledgeBaseEnhancements1737800000000 implements MigrationInter
     `);
 
     // Create index
-    await queryRunner.query(`CREATE INDEX "IDX_knowledge_groups_user_id" ON "knowledge_groups" ("user_id")`);
-    await queryRunner.query(`CREATE INDEX "IDX_search_history_user_id" ON "search_history" ("user_id")`);
-    await queryRunner.query(`CREATE INDEX "IDX_chat_messages_search_history_id" ON "chat_messages" ("search_history_id")`);
+    await queryRunner.query(
+      `CREATE INDEX "IDX_knowledge_groups_user_id" ON "knowledge_groups" ("user_id")`,
+    );
+    await queryRunner.query(
+      `CREATE INDEX "IDX_search_history_user_id" ON "search_history" ("user_id")`,
+    );
+    await queryRunner.query(
+      `CREATE INDEX "IDX_chat_messages_search_history_id" ON "chat_messages" ("search_history_id")`,
+    );
   }
 
   public async down(queryRunner: QueryRunner): Promise<void> {
@@ -72,7 +78,9 @@ export class AddKnowledgeBaseEnhancements1737800000000 implements MigrationInter
     await queryRunner.query(`DROP INDEX "IDX_knowledge_groups_user_id"`);
 
     // Delete pdf_path field
-    await queryRunner.query(`ALTER TABLE "knowledge_base" DROP COLUMN "pdf_path"`);
+    await queryRunner.query(
+      `ALTER TABLE "knowledge_base" DROP COLUMN "pdf_path"`,
+    );
 
     // Delete table
     await queryRunner.query(`DROP TABLE "chat_messages"`);
@@ -80,4 +88,4 @@ export class AddKnowledgeBaseEnhancements1737800000000 implements MigrationInter
     await queryRunner.query(`DROP TABLE "knowledge_base_groups"`);
     await queryRunner.query(`DROP TABLE "knowledge_groups"`);
   }
-}
+}

+ 79 - 54
server/src/migrations/1772329237979-AddDefaultTenant.ts

@@ -1,66 +1,91 @@
-import { MigrationInterface, QueryRunner } from "typeorm";
+import { MigrationInterface, QueryRunner } from 'typeorm';
 
 export class AddDefaultTenant1772329237979 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    // 1. Insert "Default" tenant if it doesn't exist.
+    // We use a predefined UUID or let the DB generate it.
+    // Assuming Postgres/MySQL compatible uuid generation isn't strictly standard here,
+    // we'll insert and get the ID back, or use a workaround for SQLite if it's SQLite.
+    // Actually, since this is a TypeORM setup, we can use standard SQL.
 
-    public async up(queryRunner: QueryRunner): Promise<void> {
-        // 1. Insert "Default" tenant if it doesn't exist.
-        // We use a predefined UUID or let the DB generate it.
-        // Assuming Postgres/MySQL compatible uuid generation isn't strictly standard here,
-        // we'll insert and get the ID back, or use a workaround for SQLite if it's SQLite.
-        // Actually, since this is a TypeORM setup, we can use standard SQL.
+    // This is a bit tricky to write purely in SQL that works across all DBs (SQLite/Postgres/MySQL)
+    // without knowing the exact DB.  AuraK seems to use SQLite locally by default.
+    // First, check if there's any record in the tenant table.
+    const tenants = await queryRunner.query(
+      `SELECT id FROM "tenant" WHERE "name" = 'Default'`,
+    );
+    let defaultTenantId;
 
-        // This is a bit tricky to write purely in SQL that works across all DBs (SQLite/Postgres/MySQL)
-        // without knowing the exact DB.  AuraK seems to use SQLite locally by default.
-        // First, check if there's any record in the tenant table.
-        const tenants = await queryRunner.query(`SELECT id FROM "tenant" WHERE "name" = 'Default'`);
-        let defaultTenantId;
+    if (tenants && tenants.length > 0) {
+      defaultTenantId = tenants[0].id;
+    } else {
+      // Create it with a JS generated UUID to be database agnostic
+      const crypto = require('crypto');
+      defaultTenantId = crypto.randomUUID();
+      const now = new Date().toISOString();
+      await queryRunner.query(
+        `INSERT INTO "tenant" (id, name, description, "createdAt", "updatedAt") VALUES (?, ?, ?, ?, ?)`,
+        [
+          defaultTenantId,
+          'Default',
+          'Default tenant created by migration',
+          now,
+          now,
+        ],
+      );
 
-        if (tenants && tenants.length > 0) {
-            defaultTenantId = tenants[0].id;
-        } else {
-            // Create it with a JS generated UUID to be database agnostic
-            const crypto = require('crypto');
-            defaultTenantId = crypto.randomUUID();
-            const now = new Date().toISOString();
-            await queryRunner.query(
-                `INSERT INTO "tenant" (id, name, description, "createdAt", "updatedAt") VALUES (?, ?, ?, ?, ?)`,
-                [defaultTenantId, 'Default', 'Default tenant created by migration', now, now]
-            );
-
-            // Create tenant settings
-            const settingsId = crypto.randomUUID();
-            await queryRunner.query(
-                `INSERT INTO "tenant_setting" (id, "tenantId", "createdAt", "updatedAt") VALUES (?, ?, ?, ?)`,
-                [settingsId, defaultTenantId, now, now]
-            );
-        }
+      // Create tenant settings
+      const settingsId = crypto.randomUUID();
+      await queryRunner.query(
+        `INSERT INTO "tenant_setting" (id, "tenantId", "createdAt", "updatedAt") VALUES (?, ?, ?, ?)`,
+        [settingsId, defaultTenantId, now, now],
+      );
+    }
 
-        // 2. Assign the Default tenant to all relevant existing records that have no tenantId
-        const tablesToUpdate = ['user', 'knowledge_base', 'knowledge_group', 'search_history', 'note', 'model_config'];
+    // 2. Assign the Default tenant to all relevant existing records that have no tenantId
+    const tablesToUpdate = [
+      'user',
+      'knowledge_base',
+      'knowledge_group',
+      'search_history',
+      'note',
+      'model_config',
+    ];
 
-        for (const table of tablesToUpdate) {
-            // Check if table exists first (some might be missing if DB is fresh)
-            try {
-                await queryRunner.query(`UPDATE "${table}" SET "tenantId" = ? WHERE "tenantId" IS NULL`, [defaultTenantId]);
-            } catch (e) {
-                console.warn(`Could not update table ${table}, it might not exist or the tenantId column might not exist yet.`);
-            }
-        }
+    for (const table of tablesToUpdate) {
+      // Check if table exists first (some might be missing if DB is fresh)
+      try {
+        await queryRunner.query(
+          `UPDATE "${table}" SET "tenantId" = ? WHERE "tenantId" IS NULL`,
+          [defaultTenantId],
+        );
+      } catch (e) {
+        console.warn(
+          `Could not update table ${table}, it might not exist or the tenantId column might not exist yet.`,
+        );
+      }
     }
+  }
 
-    public async down(queryRunner: QueryRunner): Promise<void> {
-        // We don't necessarily want to delete the tenant data in a down migration
-        // as it would orphan all the records or require setting them to NULL.
-        // But for completeness, we can set them back to NULL.
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    // We don't necessarily want to delete the tenant data in a down migration
+    // as it would orphan all the records or require setting them to NULL.
+    // But for completeness, we can set them back to NULL.
 
-        const tablesToUpdate = ['user', 'knowledge_base', 'knowledge_group', 'search_history', 'note', 'model_config'];
-        for (const table of tablesToUpdate) {
-            try {
-                await queryRunner.query(`UPDATE "${table}" SET "tenantId" = NULL`);
-            } catch (e) {
-                // Ignore
-            }
-        }
+    const tablesToUpdate = [
+      'user',
+      'knowledge_base',
+      'knowledge_group',
+      'search_history',
+      'note',
+      'model_config',
+    ];
+    for (const table of tablesToUpdate) {
+      try {
+        await queryRunner.query(`UPDATE "${table}" SET "tenantId" = NULL`);
+      } catch (e) {
+        // Ignore
+      }
     }
-
+  }
 }

+ 42 - 32
server/src/migrations/1772334811108-AddTenantModule.ts

@@ -1,47 +1,57 @@
-import { MigrationInterface, QueryRunner } from "typeorm";
+import { MigrationInterface, QueryRunner } from 'typeorm';
 
 export class AddTenantModule1772334811108 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    // 1. Ensure 'tenants' table exists before we insert into it.
+    // This assumes the schema definition migrations have run or TypeORM synchronize handled the tables.
+    // We will insert a system default tenant.
 
-    public async up(queryRunner: QueryRunner): Promise<void> {
-        // 1. Ensure 'tenants' table exists before we insert into it. 
-        // This assumes the schema definition migrations have run or TypeORM synchronize handled the tables.
-        // We will insert a system default tenant.
-
-        await queryRunner.query(`
+    await queryRunner.query(`
             INSERT INTO "tenants" ("id", "name", "description", "isActive", "created_at", "updated_at")
             SELECT '00000000-0000-0000-0000-000000000000', 'Default Tenant', 'System Default Organization', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
             WHERE NOT EXISTS (SELECT 1 FROM "tenants" WHERE "id" = '00000000-0000-0000-0000-000000000000');
         `);
 
-        // 2. Link existing users to the default tenant
-        await queryRunner.query(`UPDATE "users" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`);
-
-        // 3. Link existing knowledge bases to the default tenant
-        await queryRunner.query(`UPDATE "knowledge_bases" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`);
-
-        // 4. Link existing knowledge groups to the default tenant
-        await queryRunner.query(`UPDATE "knowledge_groups" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`);
-
-        // 5. Link existing search histories to the default tenant
-        await queryRunner.query(`UPDATE "search_history" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`);
-
-        // 6. Link existing notes to the default tenant
-        await queryRunner.query(`UPDATE "notes" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`);
-
-        // 7. Make the existing admin users SUPER_ADMIN
-        await queryRunner.query(`UPDATE "users" SET "role" = 'SUPER_ADMIN' WHERE "isAdmin" = 1;`);
-    }
-
-    public async down(queryRunner: QueryRunner): Promise<void> {
-        // Reverse operations if needed. Note: We do not delete the Default tenant here because it might be in active use.
-        // But we could nullify the links if we were strictly rolling back to a state without tenant links.
-        /*
+    // 2. Link existing users to the default tenant
+    await queryRunner.query(
+      `UPDATE "users" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`,
+    );
+
+    // 3. Link existing knowledge bases to the default tenant
+    await queryRunner.query(
+      `UPDATE "knowledge_bases" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`,
+    );
+
+    // 4. Link existing knowledge groups to the default tenant
+    await queryRunner.query(
+      `UPDATE "knowledge_groups" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`,
+    );
+
+    // 5. Link existing search histories to the default tenant
+    await queryRunner.query(
+      `UPDATE "search_history" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`,
+    );
+
+    // 6. Link existing notes to the default tenant
+    await queryRunner.query(
+      `UPDATE "notes" SET "tenant_id" = '00000000-0000-0000-0000-000000000000' WHERE "tenant_id" IS NULL;`,
+    );
+
+    // 7. Make the existing admin users SUPER_ADMIN
+    await queryRunner.query(
+      `UPDATE "users" SET "role" = 'SUPER_ADMIN' WHERE "isAdmin" = 1;`,
+    );
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    // Reverse operations if needed. Note: We do not delete the Default tenant here because it might be in active use.
+    // But we could nullify the links if we were strictly rolling back to a state without tenant links.
+    /*
         await queryRunner.query(`UPDATE "users" SET "tenant_id" = NULL WHERE "tenant_id" = '00000000-0000-0000-0000-000000000000';`);
         await queryRunner.query(`UPDATE "knowledge_bases" SET "tenant_id" = NULL WHERE "tenant_id" = '00000000-0000-0000-0000-000000000000';`);
         await queryRunner.query(`UPDATE "knowledge_groups" SET "tenant_id" = NULL WHERE "tenant_id" = '00000000-0000-0000-0000-000000000000';`);
         await queryRunner.query(`UPDATE "search_history" SET "tenant_id" = NULL WHERE "tenant_id" = '00000000-0000-0000-0000-000000000000';`);
         await queryRunner.query(`UPDATE "notes" SET "tenant_id" = NULL WHERE "tenant_id" = '00000000-0000-0000-0000-000000000000';`);
         */
-    }
-
+  }
 }

+ 12 - 12
server/src/migrations/1772340000000-AddParentIdToKnowledgeGroups.ts

@@ -1,18 +1,18 @@
 import { MigrationInterface, QueryRunner } from 'typeorm';
 
 export class AddParentIdToKnowledgeGroups1772340000000 implements MigrationInterface {
-    name = 'AddParentIdToKnowledgeGroups1772340000000';
+  name = 'AddParentIdToKnowledgeGroups1772340000000';
 
-    public async up(queryRunner: QueryRunner): Promise<void> {
-        // Add parent_id column to knowledge_groups table
-        await queryRunner.query(
-            `ALTER TABLE "knowledge_groups" ADD COLUMN "parent_id" text REFERENCES "knowledge_groups"("id") ON DELETE SET NULL`
-        );
-    }
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    // Add parent_id column to knowledge_groups table
+    await queryRunner.query(
+      `ALTER TABLE "knowledge_groups" ADD COLUMN "parent_id" text REFERENCES "knowledge_groups"("id") ON DELETE SET NULL`,
+    );
+  }
 
-    public async down(queryRunner: QueryRunner): Promise<void> {
-        await queryRunner.query(
-            `ALTER TABLE "knowledge_groups" DROP COLUMN "parent_id"`
-        );
-    }
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(
+      `ALTER TABLE "knowledge_groups" DROP COLUMN "parent_id"`,
+    );
+  }
 }

+ 18 - 13
server/src/migrations/1773198650000-AddAssessmentTablesManual.ts

@@ -1,18 +1,23 @@
-import { MigrationInterface, QueryRunner } from "typeorm";
+import { MigrationInterface, QueryRunner } from 'typeorm';
 
 export class AddAssessmentTablesManual1773198650000 implements MigrationInterface {
-    name = 'AddAssessmentTablesManual1773198650000'
+  name = 'AddAssessmentTablesManual1773198650000';
 
-    public async up(queryRunner: QueryRunner): Promise<void> {
-        await queryRunner.query(`CREATE TABLE IF NOT EXISTS "assessment_sessions" ("id" varchar PRIMARY KEY NOT NULL, "user_id" varchar NOT NULL, "knowledge_base_id" varchar NOT NULL, "thread_id" varchar, "status" varchar CHECK( "status" IN ('IN_PROGRESS','COMPLETED') ) NOT NULL DEFAULT ('IN_PROGRESS'), "final_score" float, "final_report" text, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`);
-        await queryRunner.query(`CREATE TABLE IF NOT EXISTS "assessment_questions" ("id" varchar PRIMARY KEY NOT NULL, "session_id" varchar NOT NULL, "question_text" text NOT NULL, "key_points" text, "difficulty" varchar, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`);
-        await queryRunner.query(`CREATE TABLE IF NOT EXISTS "assessment_answers" ("id" varchar PRIMARY KEY NOT NULL, "question_id" varchar NOT NULL, "user_answer" text NOT NULL, "score" float, "feedback" text, "is_follow_up" boolean NOT NULL DEFAULT (0), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`);
-    }
-
-    public async down(queryRunner: QueryRunner): Promise<void> {
-        await queryRunner.query(`DROP TABLE "assessment_answers"`);
-        await queryRunner.query(`DROP TABLE "assessment_questions"`);
-        await queryRunner.query(`DROP TABLE "assessment_sessions"`);
-    }
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(
+      `CREATE TABLE IF NOT EXISTS "assessment_sessions" ("id" varchar PRIMARY KEY NOT NULL, "user_id" varchar NOT NULL, "knowledge_base_id" varchar NOT NULL, "thread_id" varchar, "status" varchar CHECK( "status" IN ('IN_PROGRESS','COMPLETED') ) NOT NULL DEFAULT ('IN_PROGRESS'), "final_score" float, "final_report" text, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`,
+    );
+    await queryRunner.query(
+      `CREATE TABLE IF NOT EXISTS "assessment_questions" ("id" varchar PRIMARY KEY NOT NULL, "session_id" varchar NOT NULL, "question_text" text NOT NULL, "key_points" text, "difficulty" varchar, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`,
+    );
+    await queryRunner.query(
+      `CREATE TABLE IF NOT EXISTS "assessment_answers" ("id" varchar PRIMARY KEY NOT NULL, "question_id" varchar NOT NULL, "user_answer" text NOT NULL, "score" float, "feedback" text, "is_follow_up" boolean NOT NULL DEFAULT (0), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`,
+    );
+  }
 
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`DROP TABLE "assessment_answers"`);
+    await queryRunner.query(`DROP TABLE "assessment_questions"`);
+    await queryRunner.query(`DROP TABLE "assessment_sessions"`);
+  }
 }

+ 35 - 0
server/src/migrations/1773200000000-AddFeishuBotKnowledgeFields.ts

@@ -0,0 +1,35 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddFeishuBotKnowledgeFields1773200000000 implements MigrationInterface {
+  name = 'AddFeishuBotKnowledgeFields1773200000000';
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    // 添加知识库ID字段
+    await queryRunner.query(`
+            ALTER TABLE feishu_bots 
+            ADD COLUMN knowledge_base_id VARCHAR(36) NULL;
+        `);
+
+    // 添加知识组ID字段
+    await queryRunner.query(`
+            ALTER TABLE feishu_bots 
+            ADD COLUMN knowledge_group_id VARCHAR(36) NULL;
+        `);
+
+    // 添加外键约束(可选,如果需要引用完整性)
+    // 注意:这里假设 knowledge_bases 和 knowledge_groups 表存在
+    // 如果表不存在,可以先创建或移除外键约束
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
+            ALTER TABLE feishu_bots 
+            DROP COLUMN knowledge_base_id;
+        `);
+
+    await queryRunner.query(`
+            ALTER TABLE feishu_bots 
+            DROP COLUMN knowledge_group_id;
+        `);
+  }
+}

+ 32 - 0
server/src/migrations/1773200000001-CreateFeishuAssessmentSessionTable.ts

@@ -0,0 +1,32 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class CreateFeishuAssessmentSessionTable1773200000001 implements MigrationInterface {
+  name = 'CreateFeishuAssessmentSessionTable1773200000001';
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
+            CREATE TABLE feishu_assessment_sessions (
+                id VARCHAR(36) PRIMARY KEY,
+                bot_id VARCHAR(36) NOT NULL,
+                open_id VARCHAR(255) NOT NULL,
+                assessment_session_id VARCHAR(36) NOT NULL,
+                status ENUM('active', 'completed', 'cancelled') DEFAULT 'active',
+                current_question_index INT DEFAULT 0,
+                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                INDEX idx_bot_open (bot_id, open_id),
+                INDEX idx_assessment_session (assessment_session_id),
+                CONSTRAINT fk_feishu_assessment_bot 
+                    FOREIGN KEY (bot_id) 
+                    REFERENCES feishu_bots(id) 
+                    ON DELETE CASCADE
+            );
+        `);
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`
+            DROP TABLE feishu_assessment_sessions;
+        `);
+  }
+}

+ 3 - 1
server/src/model-config/dto/create-model-config.dto.ts

@@ -36,7 +36,9 @@ export class CreateModelConfigDto {
 
   @IsNumber()
   @Min(1, { message: 'Minimum vector dimension is 1' })
-  @Max(4096, { message: 'Maximum vector dimension is 4096 (Elasticsearch limit)' })
+  @Max(4096, {
+    message: 'Maximum vector dimension is 4096 (Elasticsearch limit)',
+  })
   @IsOptional()
   dimensions?: number;
 

+ 15 - 4
server/src/model-config/model-config.controller.ts

@@ -26,7 +26,7 @@ import { plainToClass } from 'class-transformer';
 @UseGuards(CombinedAuthGuard)
 @Controller('models') // Global prefix /api/models
 export class ModelConfigController {
-  constructor(private readonly modelConfigService: ModelConfigService) { }
+  constructor(private readonly modelConfigService: ModelConfigService) {}
 
   @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
   @Post()
@@ -45,7 +45,10 @@ export class ModelConfigController {
 
   @Get()
   async findAll(@Req() req): Promise<ModelConfigResponseDto[]> {
-    const modelConfigs = await this.modelConfigService.findAll(req.user.id, req.user.tenantId);
+    const modelConfigs = await this.modelConfigService.findAll(
+      req.user.id,
+      req.user.tenantId,
+    );
     return modelConfigs.map((mc) => plainToClass(ModelConfigResponseDto, mc));
   }
 
@@ -54,7 +57,11 @@ export class ModelConfigController {
     @Req() req,
     @Param('id') id: string,
   ): Promise<ModelConfigResponseDto> {
-    const modelConfig = await this.modelConfigService.findOne(id, req.user.id, req.user.tenantId);
+    const modelConfig = await this.modelConfigService.findOne(
+      id,
+      req.user.id,
+      req.user.tenantId,
+    );
     return plainToClass(ModelConfigResponseDto, modelConfig);
   }
 
@@ -89,7 +96,11 @@ export class ModelConfigController {
   ): Promise<ModelConfigResponseDto> {
     const userId = req.user.id;
     const tenantId = req.user.tenantId;
-    const modelConfig = await this.modelConfigService.setDefault(userId, tenantId, id);
+    const modelConfig = await this.modelConfigService.setDefault(
+      userId,
+      tenantId,
+      id,
+    );
     return plainToClass(ModelConfigResponseDto, modelConfig);
   }
 }

+ 1 - 1
server/src/model-config/model-config.module.ts

@@ -14,4 +14,4 @@ import { TenantModule } from '../tenant/tenant.module';
   controllers: [ModelConfigController],
   exports: [ModelConfigService],
 })
-export class ModelConfigModule { }
+export class ModelConfigModule {}

+ 81 - 32
server/src/model-config/model-config.service.ts

@@ -1,4 +1,11 @@
-import { Injectable, NotFoundException, ForbiddenException, BadRequestException, forwardRef, Inject } from '@nestjs/common';
+import {
+  Injectable,
+  NotFoundException,
+  ForbiddenException,
+  BadRequestException,
+  forwardRef,
+  Inject,
+} from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm';
 import { ModelConfig } from './model-config.entity';
@@ -17,7 +24,7 @@ export class ModelConfigService {
     @Inject(forwardRef(() => TenantService))
     private readonly tenantService: TenantService,
     private i18nService: I18nService,
-  ) { }
+  ) {}
 
   async create(
     userId: string,
@@ -33,21 +40,33 @@ export class ModelConfigService {
   }
 
   async findAll(userId: string, tenantId: string): Promise<ModelConfig[]> {
-    return this.modelConfigRepository.createQueryBuilder('model')
-      .where('model.tenantId = :tenantId OR model.tenantId IS NULL OR model.tenantId = :globalTenantId', {
-        tenantId,
-        globalTenantId: GLOBAL_TENANT_ID
-      })
+    return this.modelConfigRepository
+      .createQueryBuilder('model')
+      .where(
+        'model.tenantId = :tenantId OR model.tenantId IS NULL OR model.tenantId = :globalTenantId',
+        {
+          tenantId,
+          globalTenantId: GLOBAL_TENANT_ID,
+        },
+      )
       .getMany();
   }
 
-  async findOne(id: string, userId: string, tenantId: string): Promise<ModelConfig> {
-    const modelConfig = await this.modelConfigRepository.createQueryBuilder('model')
+  async findOne(
+    id: string,
+    userId: string,
+    tenantId: string,
+  ): Promise<ModelConfig> {
+    const modelConfig = await this.modelConfigRepository
+      .createQueryBuilder('model')
       .where('model.id = :id', { id })
-      .andWhere('(model.tenantId = :tenantId OR model.tenantId IS NULL OR model.tenantId = :globalTenantId)', {
-        tenantId,
-        globalTenantId: GLOBAL_TENANT_ID
-      })
+      .andWhere(
+        '(model.tenantId = :tenantId OR model.tenantId IS NULL OR model.tenantId = :globalTenantId)',
+        {
+          tenantId,
+          globalTenantId: GLOBAL_TENANT_ID,
+        },
+      )
       .getOne();
 
     if (!modelConfig) {
@@ -58,13 +77,21 @@ export class ModelConfigService {
     return modelConfig;
   }
 
-  async findByType(userId: string, tenantId: string, type: string): Promise<ModelConfig[]> {
-    return this.modelConfigRepository.createQueryBuilder('model')
+  async findByType(
+    userId: string,
+    tenantId: string,
+    type: string,
+  ): Promise<ModelConfig[]> {
+    return this.modelConfigRepository
+      .createQueryBuilder('model')
       .where('model.type = :type', { type })
-      .andWhere('(model.tenantId = :tenantId OR model.tenantId IS NULL OR model.tenantId = :globalTenantId)', {
-        tenantId,
-        globalTenantId: GLOBAL_TENANT_ID
-      })
+      .andWhere(
+        '(model.tenantId = :tenantId OR model.tenantId IS NULL OR model.tenantId = :globalTenantId)',
+        {
+          tenantId,
+          globalTenantId: GLOBAL_TENANT_ID,
+        },
+      )
       .getMany();
   }
 
@@ -84,7 +111,9 @@ export class ModelConfigService {
 
     // Only allow updating if it belongs to the tenant, or if it's a global admin (not fully implemented, so we check tenantId)
     if (modelConfig.tenantId && modelConfig.tenantId !== tenantId) {
-      throw new ForbiddenException(this.i18nService.getMessage('cannotUpdateOtherTenantModel'));
+      throw new ForbiddenException(
+        this.i18nService.getMessage('cannotUpdateOtherTenantModel'),
+      );
     }
 
     // Update the model
@@ -99,18 +128,26 @@ export class ModelConfigService {
     // Only allow removing if it exists and accessible in current tenant context
     const model = await this.findOne(id, userId, tenantId);
     if (model.tenantId && model.tenantId !== tenantId) {
-      throw new ForbiddenException(this.i18nService.getMessage('cannotDeleteOtherTenantModel'));
+      throw new ForbiddenException(
+        this.i18nService.getMessage('cannotDeleteOtherTenantModel'),
+      );
     }
     const result = await this.modelConfigRepository.delete({ id });
     if (result.affected === 0) {
-      throw new NotFoundException(this.i18nService.formatMessage('modelConfigNotFound', { id }));
+      throw new NotFoundException(
+        this.i18nService.formatMessage('modelConfigNotFound', { id }),
+      );
     }
   }
 
   /**
    * Set the specified model as default
    */
-  async setDefault(userId: string, tenantId: string, id: string): Promise<ModelConfig> {
+  async setDefault(
+    userId: string,
+    tenantId: string,
+    id: string,
+  ): Promise<ModelConfig> {
     const modelConfig = await this.findOne(id, userId, tenantId);
 
     // Clear default flag for other models of the same type (within current tenant or global)
@@ -119,10 +156,13 @@ export class ModelConfigService {
       .update(ModelConfig)
       .set({ isDefault: false })
       .where('type = :type', { type: modelConfig.type })
-      .andWhere('(tenantId = :tenantId OR tenantId IS NULL OR tenantId = :globalTenantId)', {
-        tenantId,
-        globalTenantId: GLOBAL_TENANT_ID
-      })
+      .andWhere(
+        '(tenantId = :tenantId OR tenantId IS NULL OR tenantId = :globalTenantId)',
+        {
+          tenantId,
+          globalTenantId: GLOBAL_TENANT_ID,
+        },
+      )
       .execute();
 
     modelConfig.isDefault = true;
@@ -133,10 +173,15 @@ export class ModelConfigService {
    * Get default model for specified type
    * Strict rule: Only return models specified in Index Chat Config, throw error if not found
    */
-  async findDefaultByType(tenantId: string, type: ModelType): Promise<ModelConfig> {
+  async findDefaultByType(
+    tenantId: string,
+    type: ModelType,
+  ): Promise<ModelConfig> {
     const settings = await this.tenantService.getSettings(tenantId);
     if (!settings) {
-      throw new BadRequestException(`Organization settings not found for tenant: ${tenantId}`);
+      throw new BadRequestException(
+        `Organization settings not found for tenant: ${tenantId}`,
+      );
     }
 
     let modelId: string | undefined;
@@ -149,15 +194,19 @@ export class ModelConfigService {
     }
 
     if (!modelId) {
-      throw new BadRequestException(`Model of type "${type}" is not configured in Index Chat Config for this organization.`);
+      throw new BadRequestException(
+        `Model of type "${type}" is not configured in Index Chat Config for this organization.`,
+      );
     }
 
     const model = await this.modelConfigRepository.findOne({
-      where: { id: modelId, isEnabled: true }
+      where: { id: modelId, isEnabled: true },
     });
 
     if (!model) {
-      throw new BadRequestException(`The configured model for "${type}" (ID: ${modelId}) is either missing or disabled in model management.`);
+      throw new BadRequestException(
+        `The configured model for "${type}" (ID: ${modelId}) is either missing or disabled in model management.`,
+      );
     }
 
     return model;

+ 48 - 23
server/src/note/note-category.controller.ts

@@ -1,34 +1,59 @@
-import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
+import {
+  Controller,
+  Get,
+  Post,
+  Put,
+  Delete,
+  Body,
+  Param,
+  UseGuards,
+  Request,
+} from '@nestjs/common';
 import { NoteCategoryService } from './note-category.service';
 import { CombinedAuthGuard } from '../auth/combined-auth.guard';
 
 @Controller('v1/note-categories')
 @UseGuards(CombinedAuthGuard)
 export class NoteCategoryController {
-    constructor(private readonly categoryService: NoteCategoryService) { }
+  constructor(private readonly categoryService: NoteCategoryService) {}
 
-    @Get()
-    async findAll(@Request() req: any) {
-        return this.categoryService.findAll(req.user.id, req.user.tenantId);
-    }
+  @Get()
+  async findAll(@Request() req: any) {
+    return this.categoryService.findAll(req.user.id, req.user.tenantId);
+  }
 
-    @Post()
-    async create(@Request() req: any, @Body('name') name: string, @Body('parentId') parentId?: string) {
-        return this.categoryService.create(req.user.id, req.user.tenantId, name, parentId);
-    }
+  @Post()
+  async create(
+    @Request() req: any,
+    @Body('name') name: string,
+    @Body('parentId') parentId?: string,
+  ) {
+    return this.categoryService.create(
+      req.user.id,
+      req.user.tenantId,
+      name,
+      parentId,
+    );
+  }
 
-    @Put(':id')
-    async update(
-        @Request() req: any,
-        @Param('id') id: string,
-        @Body('name') name?: string,
-        @Body('parentId') parentId?: string,
-    ) {
-        return this.categoryService.update(req.user.id, req.user.tenantId, id, name, parentId);
-    }
+  @Put(':id')
+  async update(
+    @Request() req: any,
+    @Param('id') id: string,
+    @Body('name') name?: string,
+    @Body('parentId') parentId?: string,
+  ) {
+    return this.categoryService.update(
+      req.user.id,
+      req.user.tenantId,
+      id,
+      name,
+      parentId,
+    );
+  }
 
-    @Delete(':id')
-    async remove(@Request() req: any, @Param('id') id: string) {
-        return this.categoryService.remove(req.user.id, req.user.tenantId, id);
-    }
+  @Delete(':id')
+  async remove(@Request() req: any, @Param('id') id: string) {
+    return this.categoryService.remove(req.user.id, req.user.tenantId, id);
+  }
 }

+ 39 - 37
server/src/note/note-category.entity.ts

@@ -1,12 +1,12 @@
 import {
-    Entity,
-    PrimaryGeneratedColumn,
-    Column,
-    CreateDateColumn,
-    UpdateDateColumn,
-    ManyToOne,
-    JoinColumn,
-    OneToMany,
+  Entity,
+  PrimaryGeneratedColumn,
+  Column,
+  CreateDateColumn,
+  UpdateDateColumn,
+  ManyToOne,
+  JoinColumn,
+  OneToMany,
 } from 'typeorm';
 import { User } from '../user/user.entity';
 import { Tenant } from '../tenant/tenant.entity';
@@ -14,45 +14,47 @@ import { Note } from './note.entity';
 
 @Entity('note_categories')
 export class NoteCategory {
-    @PrimaryGeneratedColumn('uuid')
-    id: string;
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
 
-    @Column()
-    name: string;
+  @Column()
+  name: string;
 
-    @Column({ name: 'user_id' })
-    userId: string;
+  @Column({ name: 'user_id' })
+  userId: string;
 
-    @Column({ name: 'tenant_id', nullable: true, type: 'text' })
-    tenantId: string;
+  @Column({ name: 'tenant_id', nullable: true, type: 'text' })
+  tenantId: string;
 
-    @Column({ name: 'parent_id', nullable: true, type: 'text' })
-    parentId: string;
+  @Column({ name: 'parent_id', nullable: true, type: 'text' })
+  parentId: string;
 
-    @Column({ default: 1 })
-    level: number;
+  @Column({ default: 1 })
+  level: number;
 
-    @ManyToOne(() => NoteCategory, (category) => category.children, { onDelete: 'CASCADE' })
-    @JoinColumn({ name: 'parent_id' })
-    parent: NoteCategory;
+  @ManyToOne(() => NoteCategory, (category) => category.children, {
+    onDelete: 'CASCADE',
+  })
+  @JoinColumn({ name: 'parent_id' })
+  parent: NoteCategory;
 
-    @OneToMany(() => NoteCategory, (category) => category.parent)
-    children: NoteCategory[];
+  @OneToMany(() => NoteCategory, (category) => category.parent)
+  children: NoteCategory[];
 
-    @ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
-    @JoinColumn({ name: 'tenant_id' })
-    tenant: Tenant;
+  @ManyToOne(() => Tenant, { nullable: true, onDelete: 'CASCADE' })
+  @JoinColumn({ name: 'tenant_id' })
+  tenant: Tenant;
 
-    @ManyToOne(() => User)
-    @JoinColumn({ name: 'user_id' })
-    user: User;
+  @ManyToOne(() => User)
+  @JoinColumn({ name: 'user_id' })
+  user: User;
 
-    @OneToMany(() => Note, (note) => note.category)
-    notes: Note[];
+  @OneToMany(() => Note, (note) => note.category)
+  notes: Note[];
 
-    @CreateDateColumn({ name: 'created_at' })
-    createdAt: Date;
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt: Date;
 
-    @UpdateDateColumn({ name: 'updated_at' })
-    updatedAt: Date;
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt: Date;
 }

+ 93 - 64
server/src/note/note-category.service.ts

@@ -6,79 +6,108 @@ import { I18nService } from '../i18n/i18n.service';
 
 @Injectable()
 export class NoteCategoryService {
-    constructor(
-        @InjectRepository(NoteCategory)
-        private readonly categoryRepository: Repository<NoteCategory>,
-        private readonly i18nService: I18nService,
-    ) { }
+  constructor(
+    @InjectRepository(NoteCategory)
+    private readonly categoryRepository: Repository<NoteCategory>,
+    private readonly i18nService: I18nService,
+  ) {}
 
-    async findAll(userId: string, tenantId: string): Promise<NoteCategory[]> {
-        return this.categoryRepository.find({
-            where: { userId, tenantId },
-            order: { level: 'ASC', name: 'ASC' },
-        });
-    }
-
-    async create(userId: string, tenantId: string, name: string, parentId?: string): Promise<NoteCategory> {
-        let level = 1;
-        if (parentId) {
-            const parent = await this.categoryRepository.findOne({
-                where: { id: parentId, userId, tenantId }
-            });
-            if (!parent) {
-                throw new NotFoundException(this.i18nService.getMessage('parentCategoryNotFound'));
-            }
-            if (parent.level >= 3) {
-                throw new Error(this.i18nService.getMessage('maxCategoryDepthExceeded'));
-            }
-            level = parent.level + 1;
-        }
+  async findAll(userId: string, tenantId: string): Promise<NoteCategory[]> {
+    return this.categoryRepository.find({
+      where: { userId, tenantId },
+      order: { level: 'ASC', name: 'ASC' },
+    });
+  }
 
-        const category = this.categoryRepository.create({
-            name,
-            userId,
-            tenantId,
-            parentId,
-            level,
-        });
-        return this.categoryRepository.save(category);
+  async create(
+    userId: string,
+    tenantId: string,
+    name: string,
+    parentId?: string,
+  ): Promise<NoteCategory> {
+    let level = 1;
+    if (parentId) {
+      const parent = await this.categoryRepository.findOne({
+        where: { id: parentId, userId, tenantId },
+      });
+      if (!parent) {
+        throw new NotFoundException(
+          this.i18nService.getMessage('parentCategoryNotFound'),
+        );
+      }
+      if (parent.level >= 3) {
+        throw new Error(
+          this.i18nService.getMessage('maxCategoryDepthExceeded'),
+        );
+      }
+      level = parent.level + 1;
     }
 
-    async update(userId: string, tenantId: string, id: string, name?: string, parentId?: string): Promise<NoteCategory> {
-        const category = await this.categoryRepository.findOne({
-            where: { id, userId, tenantId },
-        });
-        if (!category) {
-            throw new NotFoundException(this.i18nService.getMessage('categoryNotFound'));
-        }
+    const category = this.categoryRepository.create({
+      name,
+      userId,
+      tenantId,
+      parentId,
+      level,
+    });
+    return this.categoryRepository.save(category);
+  }
 
-        if (name !== undefined) {
-            category.name = name;
-        }
+  async update(
+    userId: string,
+    tenantId: string,
+    id: string,
+    name?: string,
+    parentId?: string,
+  ): Promise<NoteCategory> {
+    const category = await this.categoryRepository.findOne({
+      where: { id, userId, tenantId },
+    });
+    if (!category) {
+      throw new NotFoundException(
+        this.i18nService.getMessage('categoryNotFound'),
+      );
+    }
 
-        if (parentId !== undefined && parentId !== category.parentId) {
-            if (parentId === null) {
-                category.parentId = null as any;
-                category.level = 1;
-            } else {
-                const parent = await this.categoryRepository.findOne({
-                    where: { id: parentId, userId, tenantId }
-                });
-                if (!parent) throw new NotFoundException(this.i18nService.getMessage('parentCategoryNotFound'));
-                if (parent.level >= 3) throw new Error(this.i18nService.getMessage('maxCategoryDepthExceeded'));
+    if (name !== undefined) {
+      category.name = name;
+    }
 
-                category.parentId = parentId;
-                category.level = parent.level + 1;
-            }
-        }
+    if (parentId !== undefined && parentId !== category.parentId) {
+      if (parentId === null) {
+        category.parentId = null as any;
+        category.level = 1;
+      } else {
+        const parent = await this.categoryRepository.findOne({
+          where: { id: parentId, userId, tenantId },
+        });
+        if (!parent)
+          throw new NotFoundException(
+            this.i18nService.getMessage('parentCategoryNotFound'),
+          );
+        if (parent.level >= 3)
+          throw new Error(
+            this.i18nService.getMessage('maxCategoryDepthExceeded'),
+          );
 
-        return this.categoryRepository.save(category);
+        category.parentId = parentId;
+        category.level = parent.level + 1;
+      }
     }
 
-    async remove(userId: string, tenantId: string, id: string): Promise<void> {
-        const result = await this.categoryRepository.delete({ id, userId, tenantId });
-        if (result.affected === 0) {
-            throw new NotFoundException(this.i18nService.getMessage('categoryNotFound'));
-        }
+    return this.categoryRepository.save(category);
+  }
+
+  async remove(userId: string, tenantId: string, id: string): Promise<void> {
+    const result = await this.categoryRepository.delete({
+      id,
+      userId,
+      tenantId,
+    });
+    if (result.affected === 0) {
+      throw new NotFoundException(
+        this.i18nService.getMessage('categoryNotFound'),
+      );
     }
+  }
 }

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini