anhuiqiang 1 tydzień temu
rodzic
commit
57eed1b8e5
47 zmienionych plików z 3215 dodań i 232 usunięć
  1. 6 0
      .gitignore
  2. 17 0
      check_all_dbs.js
  3. 14 0
      check_schema.js
  4. 121 0
      docs/3.0/talent_assessment_workflow.md
  5. 4 3
      package-lock.json
  6. 11 0
      query_columns.js
  7. 16 0
      query_db.js
  8. BIN
      server/build_output.txt
  9. 2 1
      server/package.json
  10. 1 1
      server/src/api/api-v1.controller.ts
  11. 8 0
      server/src/app.module.ts
  12. 18 0
      server/src/assessment/assessment.controller.spec.ts
  13. 67 0
      server/src/assessment/assessment.controller.ts
  14. 31 0
      server/src/assessment/assessment.module.ts
  15. 18 0
      server/src/assessment/assessment.service.spec.ts
  16. 557 0
      server/src/assessment/assessment.service.ts
  17. 41 0
      server/src/assessment/entities/assessment-answer.entity.ts
  18. 43 0
      server/src/assessment/entities/assessment-question.entity.ts
  19. 92 0
      server/src/assessment/entities/assessment-session.entity.ts
  20. 57 0
      server/src/assessment/graph/builder.ts
  21. 61 0
      server/src/assessment/graph/nodes/analyzer.node.ts
  22. 70 0
      server/src/assessment/graph/nodes/generator.node.ts
  23. 106 0
      server/src/assessment/graph/nodes/grader.node.ts
  24. 72 0
      server/src/assessment/graph/nodes/interviewer.node.ts
  25. 96 0
      server/src/assessment/graph/state.ts
  26. 25 3
      server/src/auth/combined-auth.guard.ts
  27. 11 8
      server/src/auth/jwt.strategy.ts
  28. 12 0
      server/src/data-source.ts
  29. 18 0
      server/src/migrations/1773198650000-AddAssessmentTablesManual.ts
  30. 0 12
      server/src/model-config/model-config.controller.ts
  31. 0 14
      server/src/model-config/model-config.entity.ts
  32. 35 39
      server/src/model-config/model-config.service.ts
  33. 3 29
      server/src/rag/rag.service.ts
  34. 6 1
      server/src/user/user.entity.ts
  35. 0 0
      server/tsconfig.tsbuildinfo
  36. 28 61
      web/components/views/AgentsView.tsx
  37. 705 0
      web/components/views/AssessmentView.tsx
  38. 2 0
      web/index.tsx
  39. 84 30
      web/services/apiClient.ts
  40. 94 0
      web/services/assessmentService.ts
  41. 3 1
      web/src/components/layouts/WorkspaceLayout.tsx
  42. 26 0
      web/src/contexts/AuthContext.tsx
  43. 4 0
      web/src/pages/auth/Login.tsx
  44. 15 0
      web/src/pages/workspace/AssessmentPage.tsx
  45. 33 1
      web/types.ts
  46. 99 0
      web/utils/translations.ts
  47. 483 28
      yarn.lock

+ 6 - 0
.gitignore

@@ -58,3 +58,9 @@ all_used_keys.txt
 lint_output.txt
 log_dups.txt
 tmp_duplicates.txt
+server/build_output.txt
+server/migration_run_err.txt
+server/typeorm_err.txt
+web/build_full.txt
+web/build_output.txt
+web/tsc_errors.txt

+ 17 - 0
check_all_dbs.js

@@ -0,0 +1,17 @@
+const Database = require('better-sqlite3');
+
+function check(path) {
+    try {
+        const db = new Database(path);
+        const tableInfo = db.prepare('PRAGMA table_info(assessment_sessions)').all();
+        console.log(`PATH: ${path}`);
+        console.log(tableInfo.map(c => c.name).join(', '));
+        db.close();
+    } catch (e) {
+        console.log(`PATH: ${path} (ERROR: ${e.message})`);
+    }
+}
+
+check('d:/workspace/AuraK/server/metadata.db');
+check('d:/workspace/AuraK/data/metadata.db');
+check('d:/workspace/AuraK/server/data/metadata.db');

+ 14 - 0
check_schema.js

@@ -0,0 +1,14 @@
+const Database = require('better-sqlite3');
+const db = new Database('d:/workspace/AuraK/server/data/metadata.db');
+
+try {
+    const tableInfo = db.prepare('PRAGMA table_info(assessment_sessions)').all();
+    console.log('TABLE INFO:');
+    tableInfo.forEach(col => {
+        console.log(`- ${col.name} (${col.type})`);
+    });
+} catch (e) {
+    console.error(e);
+} finally {
+    db.close();
+}

+ 121 - 0
docs/3.0/talent_assessment_workflow.md

@@ -0,0 +1,121 @@
+# 人才评测智能体 (Talent Assessment Agent) 工作流程
+
+人才评测智能体(Talent Assessment Agent)是一个基于 LangGraph 的 AI 系统,旨在通过与员工的动态对话,评估其对特定知识领域的掌握程度。
+
+## 1. 核心流程概述
+
+评测过程分为四个主要阶段:
+1. **考纲拟定与生题 (Generation)**:基于知识库内容自动生成考题。
+2. **交互引导 (Interviewing)**:AI 作为考官,向用户抛出问题并引导回答。
+3. **智能阅卷与评分 (Grading)**:分析用户回答,给出分数及反馈,并决定是否追问。
+4. **综合学情报告 (Analysis)**:汇总所有表现,产出最终的能力定级和建议报告。
+
+---
+
+## 2. 后端架构 (LangGraph)
+
+系统使用 LangGraph 构建了一个有状态的任务流。
+
+### 2.1 状态定义 (State)
+所有节点共享 `EvaluationState`,其中包含:
+- `questions`: 生成的题目列表(包含知识点和难度)。
+- `currentQuestionIndex`: 当前题目索引。
+- `messages`: 完整的对话历史(用于上下文推理)。
+- `feedbackHistory`: 考官给出的实时反馈和得分记录。
+- `scores`: 每道题的量化得分。
+- `shouldFollowUp`: 是否需要针对当前题目进行补充追问。
+
+### 2.2 节点逻辑 (Nodes)
+
+- **`Generator` (出题节点)**:
+  - **内容提取机制**:
+    - 在评测开始前,`AssessmentService` 会根据用户选择的“知识库”或“知识分组”进行内容检索。
+    - **单文档提取**:直接从数据库中获取对应知识库记录的完整正文内容。
+    - **多文档分组提取**:如果是知识分组,则通过 `KnowledgeGroupService` 并行获取分组内所有已处理文件的内容,并按 `--- Document: [标题] ---` 格式进行拼接,形成聚合上下文。
+  - **题目生成**:
+    - 将提取到的文本注入 LangGraph 的 `configurable` 参数中。
+    - 节点调用 LLM,将该文本作为 **Ground Truth (事实基准)**,要求 AI 生成 3-5 道覆盖不同知识点和难度(Standard, Advanced, Specialist)的 JSON 格式题目。
+  - **初始化**:将生成的题目写入状态,并初始化评测进度。
+
+- **`Interviewer` (提问节点)**:
+  - 负责将题目或追问信息转化为自然语言对话。
+  - 如果 `shouldFollowUp` 为真,则根据上次的 `feedback` 生成引导性话术。
+  - 节点执行后进入 **中断挂起 (Interrupt)** 状态,等待用户在前端输入。
+
+- **`Grader` (评分节点)**:
+  - 用户提交回答后唤醒。
+  - 对比“标准知识点”与“用户回答”的匹配度(准确性、完整性、深度)。
+  - 给处 0-10 分,并生成建设性反馈。
+  - **追问判定**:如果得分较低且未达到最大追问次数,则设置 `shouldFollowUp = true`。
+
+- **`Analyzer` (报告节点)**:
+  - 评测结束(所有题目答完)时触发。
+  - 汇总所有得分和对话历史。
+  - 产出包含能力水平(Novice/Proficient/Advanced/Expert)、差距分析及学习建议的 Markdown 报告。
+
+### 2.3 路由逻辑 (Routing)
+- **`__start__`** -> `Generator` -> `Interviewer` -> **(等待用户回答)**
+- 用户回答后 -> `Grader`
+- `Grader` 判断:
+  - 需要追问? -> 回到 `Interviewer`
+  - 不需要追问且有后续题目? -> 索引自增,回到 `Interviewer`
+  - 题目全部完成? -> `Analyzer` -> **`__end__`**
+
+---
+
+## 3. 前端交互流
+
+### 3.1 准备阶段 (Setup)
+- 用户选择知识分组(Knowledge Group)。
+- 前端调用 `/assessment/start` 获取 `sessionId`。
+- 连接到 `/assessment/:id/start-stream` 开始流式生成题目。
+
+### 3.2 测评阶段 (Interactive)
+- **实时对话**:前端渲染 `Interviewer` 生成的问题。
+- **渐进反馈**:用户提交回答后,通过 `/answer-stream` 实时看到 `Grader` 给出的点评和得分(Live Feedback)。
+- **状态流转**:UI 会根据 `processStep` 提示当前 AI 正在进行的操作(如“正在阅卷”或“正在准备下一题”)。
+
+### 3.3 结果阶段 (Completion)
+- 测评完成后,展示总分、能力定级(Level)及详细分析。
+- 支持历史记录回顾,用户可以随时查看之前的测评报告。
+
+---
+
+## 4. 数据持久化
+
+- **线程持久化**:LangGraph 的 `MemorySaver` 确保了对话状态的连续性。
+- **业务存储**:`AssessmentSession` 表记录了每场测评的元数据(状态、总分、语言、生成的题目及最终报告)。
+- **断点恢复**:系统支持在服务器重启后,通过数据库中的历史消息自动重新激活 Graph 状态,实现“续考”。
+
+---
+
+## 5. 处理大规模知识库的“合理提取”策略
+
+当知识分组中包含大量文档(如超过 50 份文件或数百万字)时,全量拼接(Raw Concatenation)会超越 LLM 的上下文窗口(Context Window)并降低生成质量。以下是系统推荐的“合理提取”进阶方案:
+
+### 5.1 语义聚类与去重 (Semantic Clustering)
+- **原理**:不直接提取文件,而是利用系统中已有的 `ElasticsearchService`。
+- **操作**:对知识组内的所有分片(Chunks)进行语义聚类,从中提取 10-20 个最具代表性的核心知识点(Core Concepts),以此作为“出题大纲”,而不是直接喂入全文。
+
+### 5.2 动态 RAG 检索生成 (Dynamic RAG Generation)
+- **原理**:将“出题”过程拆分为两步。
+- **操作**:
+  1. **种子生成**:AI 先在没有背景的情况下,根据知识组的主题(Topic)生成几个潜在的问题方向。
+  2. **精准检索**:针对每个方向,调用 `RagService.searchKnowledge` 检索出最相关的 Top-K 个知识分片。
+  3. **基于片段出题**:将检索出的精准片段作为 Ground Truth 提供给 `Generator` 节点,确保题目既真实存在于库中,又不会导致上下文溢出。
+
+### 5.3 优先级与权重采样 (Weighted Sampling)
+- **原理**:根据文档的元数据进行筛选。
+- **操作**:
+  - **按更新时间**:优先选择最近更新的政策或文档进行评测。
+  - **按文档权重**:可以为知识库中的文档打标(如“核心规章” vs “参考资料”),`Generator` 优先从核心规章中提取内容。
+
+### 5.4 层次化提取 (Hierarchical Extraction)
+- **原理**:先总结,后出题。
+- **操作**:首先调用 AI 对整个知识分组进行一个“全景总结”(Summary),得到一份 2000 字以内的“知识图谱摘要”,再基于这份摘要进行出题编排。
+
+---
+
+## 6. 总结与建议选择
+
+将架构改为 LangGraph 后,状态管理的复杂性从微服务代码退化为了直观的、声明式的图流转。对于大规模知识库的处理,建议逐步从“全量提取”转向“动态 RAG 采样”,以保证评测的深度与广度。

+ 4 - 3
package-lock.json

@@ -16307,9 +16307,9 @@
       }
     },
     "node_modules/zod": {
-      "version": "4.1.13",
-      "resolved": "https://registry.npmmirror.com/zod/-/zod-4.1.13.tgz",
-      "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
+      "version": "4.3.6",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+      "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
       "license": "MIT",
       "peer": true,
       "funding": {
@@ -16332,6 +16332,7 @@
       "dependencies": {
         "@elastic/elasticsearch": "^9.2.0",
         "@langchain/core": "^1.1.5",
+        "@langchain/langgraph": "^1.0.4",
         "@langchain/openai": "^1.1.3",
         "@langchain/textsplitters": "^1.0.1",
         "@nestjs/common": "^11.0.1",

+ 11 - 0
query_columns.js

@@ -0,0 +1,11 @@
+const Database = require('better-sqlite3');
+const db = new Database('d:/workspace/AuraK/server/data/metadata.db');
+
+try {
+    const columns = db.prepare('PRAGMA table_info(assessment_sessions)').all();
+    console.log(columns.map(c => c.name));
+} catch (e) {
+    console.error(e);
+} finally {
+    db.close();
+}

+ 16 - 0
query_db.js

@@ -0,0 +1,16 @@
+const Database = require('better-sqlite3');
+const db = new Database('d:/workspace/AuraK/data/metadata.db');
+
+try {
+    const session = db.prepare('SELECT id, messages FROM assessment_sessions WHERE messages IS NOT NULL AND messages != "[]" ORDER BY created_at DESC LIMIT 1').get();
+    if (session) {
+        console.log('ID:', session.id);
+        console.log('MESSAGES:', session.messages);
+    } else {
+        console.log('No sessions with messages found.');
+    }
+} catch (e) {
+    console.error(e);
+} finally {
+    db.close();
+}

BIN
server/build_output.txt


+ 2 - 1
server/package.json

@@ -24,6 +24,7 @@
   "dependencies": {
     "@elastic/elasticsearch": "^9.2.0",
     "@langchain/core": "^1.1.5",
+    "@langchain/langgraph": "^1.0.4",
     "@langchain/openai": "^1.1.3",
     "@langchain/textsplitters": "^1.0.1",
     "@nestjs/common": "^11.0.1",
@@ -104,4 +105,4 @@
     "coverageDirectory": "../coverage",
     "testEnvironment": "node"
   }
-}
+}

+ 1 - 1
server/src/api/api-v1.controller.ts

@@ -59,7 +59,7 @@ export class ApiV1Controller {
         // Get user settings and model configuration
         const userSetting = await this.userSettingService.findOrCreate(user.id);
         const models = await this.modelConfigService.findAll(user.id, user.tenantId);
-        const llmModel = models.find((m) => m.id === userSetting?.selectedLLMId) ?? models.find((m) => m.type === 'llm' && m.isDefault);
+        const llmModel = models.find((m) => m.id === userSetting?.selectedLLMId) ?? models.find((m) => m.type === 'llm' && m.isEnabled);
 
         if (!llmModel) {
             return res.status(400).json({ error: 'No LLM model configured for this user' });

+ 8 - 0
server/src/app.module.ts

@@ -49,6 +49,10 @@ import { TenantMember } from './tenant/tenant-member.entity';
 import { TenantModule } from './tenant/tenant.module';
 import { SuperAdminModule } from './super-admin/super-admin.module';
 import { AdminModule } from './admin/admin.module';
+import { AssessmentModule } from './assessment/assessment.module';
+import { AssessmentSession } from './assessment/entities/assessment-session.entity';
+import { AssessmentQuestion } from './assessment/entities/assessment-question.entity';
+import { AssessmentAnswer } from './assessment/entities/assessment-answer.entity';
 
 @Module({
   imports: [
@@ -83,6 +87,9 @@ import { AdminModule } from './admin/admin.module';
           TenantSetting,
           TenantMember,
           ApiKey,
+          AssessmentSession,
+          AssessmentQuestion,
+          AssessmentAnswer,
         ],
         synchronize: true, // Auto-create database schema. Disable in production.
       }),
@@ -110,6 +117,7 @@ import { AdminModule } from './admin/admin.module';
     ImportTaskModule,
     SuperAdminModule,
     AdminModule,
+    AssessmentModule,
   ],
   controllers: [AppController],
   providers: [

+ 18 - 0
server/src/assessment/assessment.controller.spec.ts

@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { AssessmentController } from './assessment.controller';
+
+describe('AssessmentController', () => {
+  let controller: AssessmentController;
+
+  beforeEach(async () => {
+    const module: TestingModule = await Test.createTestingModule({
+      controllers: [AssessmentController],
+    }).compile();
+
+    controller = module.get<AssessmentController>(AssessmentController);
+  });
+
+  it('should be defined', () => {
+    expect(controller).toBeDefined();
+  });
+});

+ 67 - 0
server/src/assessment/assessment.controller.ts

@@ -0,0 +1,67 @@
+import { Controller, Post, Body, Get, Param, UseGuards, Request, Sse, MessageEvent, Query } from '@nestjs/common';
+import { map } from 'rxjs/operators';
+import { AssessmentService } from './assessment.service';
+import { CombinedAuthGuard } from '../auth/combined-auth.guard';
+import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
+
+@ApiTags('Assessment')
+@Controller('assessment')
+@UseGuards(CombinedAuthGuard)
+export class AssessmentController {
+    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 }) {
+        const { id: userId, tenantId } = req.user;
+        return this.assessmentService.startSession(userId, body.knowledgeBaseId, tenantId, 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;
+        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;
+        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;
+        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;
+        return this.assessmentService.getSessionState(sessionId, userId);
+    }
+
+    @Get()
+    @ApiOperation({ summary: 'Get assessment session history' })
+    async getHistory(@Request() req: any) {
+        const { id: userId, tenantId } = req.user;
+        return this.assessmentService.getHistory(userId, tenantId);
+    }
+}

+ 31 - 0
server/src/assessment/assessment.module.ts

@@ -0,0 +1,31 @@
+import { Module, forwardRef } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { AssessmentService } from './assessment.service';
+import { AssessmentController } from './assessment.controller';
+import { AssessmentSession } from './entities/assessment-session.entity';
+import { AssessmentQuestion } from './entities/assessment-question.entity';
+import { AssessmentAnswer } from './entities/assessment-answer.entity';
+import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module';
+import { KnowledgeGroupModule } from '../knowledge-group/knowledge-group.module';
+import { ModelConfigModule } from '../model-config/model-config.module';
+import { UserSettingModule } from '../user-setting/user-setting.module';
+import { ChatModule } from '../chat/chat.module';
+
+@Module({
+  imports: [
+    TypeOrmModule.forFeature([
+      AssessmentSession,
+      AssessmentQuestion,
+      AssessmentAnswer,
+    ]),
+    forwardRef(() => KnowledgeBaseModule),
+    forwardRef(() => KnowledgeGroupModule),
+    forwardRef(() => ModelConfigModule),
+    forwardRef(() => ChatModule),
+    UserSettingModule,
+  ],
+  controllers: [AssessmentController],
+  providers: [AssessmentService],
+  exports: [AssessmentService],
+})
+export class AssessmentModule { }

+ 18 - 0
server/src/assessment/assessment.service.spec.ts

@@ -0,0 +1,18 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { AssessmentService } from './assessment.service';
+
+describe('AssessmentService', () => {
+  let service: AssessmentService;
+
+  beforeEach(async () => {
+    const module: TestingModule = await Test.createTestingModule({
+      providers: [AssessmentService],
+    }).compile();
+
+    service = module.get<AssessmentService>(AssessmentService);
+  });
+
+  it('should be defined', () => {
+    expect(service).toBeDefined();
+  });
+});

+ 557 - 0
server/src/assessment/assessment.service.ts

@@ -0,0 +1,557 @@
+import { Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { ConfigService } from '@nestjs/config';
+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 { AssessmentQuestion } from './entities/assessment-question.entity';
+import { AssessmentAnswer } from './entities/assessment-answer.entity';
+import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
+import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
+import { ModelConfigService } from '../model-config/model-config.service';
+import { ModelType } from '../types';
+import { createEvaluationGraph } from './graph/builder';
+import { EvaluationState } from './graph/state';
+
+@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 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 }): Promise<string> {
+        const kbId = session.knowledgeBaseId || session.knowledgeGroupId;
+        if (!kbId) return '';
+
+        let content = '';
+
+        if (session.knowledgeBaseId) {
+            const kb = await (this.kbService as any).kbRepository.findOne({ where: { id: kbId, tenantId: session.tenantId } });
+            if (kb) content = kb.content || '';
+        } else {
+            try {
+                const groupFiles = await this.groupService.getGroupFiles(kbId, session.userId, session.tenantId);
+                content = groupFiles
+                    .filter(f => f.content)
+                    .map(f => `--- Document: ${f.title || f.originalName} ---\n${f.content}`)
+                    .join('\n\n');
+            } catch (err) {
+                this.logger.error(`Failed to get group files: ${err.message}`);
+            }
+        }
+
+        return content;
+    }
+
+    /**
+     * Starts a new assessment session.
+     * kbId can be a KnowledgeBase ID or a KnowledgeGroup ID.
+     */
+    async startSession(userId: string, kbId: string, tenantId: string, language: string = 'zh'): Promise<AssessmentSession> {
+        // Try to determine if it's a KB or Group
+        const isKb = await (this.kbService as any).kbRepository.count({ where: { id: kbId, tenantId } }) > 0;
+        
+        const tempSession = {
+            userId,
+            tenantId,
+            knowledgeBaseId: isKb ? kbId : undefined,
+            knowledgeGroupId: isKb ? undefined : kbId,
+        };
+
+        const content = await this.getSessionContent(tempSession);
+
+        if (!content || content.trim().length < 10) {
+            throw new Error('Selected knowledge source has no sufficient content for evaluation.');
+        }
+
+        const session = this.sessionRepository.create({
+            ...tempSession,
+            status: AssessmentStatus.IN_PROGRESS,
+            language,
+        });
+        const savedSession = await this.sessionRepository.save(session);
+
+        // Thread ID for LangGraph is the session ID
+        savedSession.threadId = savedSession.id;
+        await this.sessionRepository.save(savedSession);
+
+        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 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: [],
+                    };
+
+                    const stream = await this.graph.stream(
+                        { 
+                            ...initialState,
+                            messages: [new HumanMessage('Generate the assessment questions now.')] 
+                        },
+                        {
+                            configurable: {
+                                thread_id: sessionId,
+                                model,
+                                knowledgeBaseContent: content,
+                                language: session.language || 'zh',
+                            },
+                            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 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);
+                }
+            })();
+        });
+    }
+
+    /**
+     * Submits a user's answer and continues the assessment.
+     */
+    async submitAnswer(sessionId: string, userId: string, answer: string, language: string = 'zh'): 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)] },
+            {
+                configurable: {
+                    thread_id: sessionId,
+                    model,
+                    knowledgeBaseContent: content,
+                    language: session.language || language,
+                },
+                streamMode: ["values", "updates"]
+            }
+        );
+
+        for await (const [mode, data] of stream) {
+            if (mode === "values") {
+                finalResult = data;
+            }
+        }
+
+        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;
+                }
+            }
+            session.feedbackHistory = finalResult.feedbackHistory || [];
+            await this.sessionRepository.save(session);
+            finalResult.messages = this.mapMessages(finalResult.messages);
+            finalResult.feedbackHistory = this.mapMessages(finalResult.feedbackHistory || []);
+        }
+
+        return finalResult;
+    }
+
+    /**
+     * Streaming version of submitAnswer.
+     */
+    submitAnswerStream(sessionId: string, userId: string, answer: string, language: string = 'zh'): 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 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 };
+
+        if (values.messages) {
+            values.messages = this.mapMessages(values.messages);
+        }
+        if (values.feedbackHistory) {
+            values.feedbackHistory = this.mapMessages(values.feedbackHistory);
+        }
+
+        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;
+    }
+
+    /**
+     * 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 || 'zh',
+                    },
+                    "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 || 'zh',
+                    },
+                    streamMode: ["values", "updates"]
+                });
+
+                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(),
+            };
+        });
+    }
+}

+ 41 - 0
server/src/assessment/entities/assessment-answer.entity.ts

@@ -0,0 +1,41 @@
+import {
+    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;
+
+    @Column({ name: 'question_id' })
+    questionId: string;
+
+    @ManyToOne('AssessmentQuestion', (question: AssessmentQuestion) => question.answers, { onDelete: 'CASCADE' })
+    @JoinColumn({ name: 'question_id' })
+    question: AssessmentQuestion;
+
+    @Column({ type: 'text', name: 'user_answer' })
+    userAnswer: string;
+
+    @Column({ type: 'float', nullable: true })
+    score: number;
+
+    @Column({ type: 'text', nullable: true })
+    feedback: string;
+
+    @Column({ type: 'boolean', name: 'is_follow_up', default: false })
+    isFollowUp: boolean;
+
+    @CreateDateColumn({ name: 'created_at' })
+    createdAt: Date;
+
+    @UpdateDateColumn({ name: 'updated_at' })
+    updatedAt: Date;
+}

+ 43 - 0
server/src/assessment/entities/assessment-question.entity.ts

@@ -0,0 +1,43 @@
+import {
+    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;
+
+    @Column({ name: 'session_id' })
+    sessionId: string;
+
+    @ManyToOne('AssessmentSession', (session: AssessmentSession) => session.questions, { onDelete: 'CASCADE' })
+    @JoinColumn({ name: 'session_id' })
+    session: AssessmentSession;
+
+    @Column({ type: 'text', name: 'question_text' })
+    questionText: string;
+
+    @Column({ type: 'simple-json', name: 'key_points', nullable: true })
+    keyPoints: string[];
+
+    @Column({ type: 'varchar', nullable: true })
+    difficulty: string;
+
+    @OneToMany('AssessmentAnswer', (answer: AssessmentAnswer) => answer.question)
+    answers: AssessmentAnswer[];
+
+    @CreateDateColumn({ name: 'created_at' })
+    createdAt: Date;
+
+    @UpdateDateColumn({ name: 'updated_at' })
+    updatedAt: Date;
+}

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

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

+ 57 - 0
server/src/assessment/graph/builder.ts

@@ -0,0 +1,57 @@
+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";
+    }
+
+    if (state.currentQuestionIndex < state.questions.length) {
+        return "interviewer";
+    }
+
+    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)
+
+        // 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 grading, decide where to go
+        .addConditionalEdges("grader", routeAfterGrading, {
+            interviewer: "interviewer",
+            analyzer: "analyzer",
+        })
+
+        .addEdge("analyzer", "__end__");
+
+    // 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"],
+    });
+};

+ 61 - 0
server/src/assessment/graph/nodes/analyzer.node.ts

@@ -0,0 +1,61 @@
+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
+): 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]) => `Question ${qId}: Score ${score}/10`)
+        .join("\n");
+
+    const systemPrompt = `You are an objective and critical seniority education consultant.
+Review the following assessment results and provide a rigorous mastery report for the employee.
+
+IMPORTANT: You MUST generate the report in the following language: ${state.language || 'zh'}.
+
+CRITICAL INSTRUCTIONS:
+1. START the report with exactly this format: "LEVEL: [Novice/Proficient/Advanced/Expert]" on the first line.
+2. Be OBJECTIVE. If the user provided no valid answers or scores are 0, you MUST identify them as 'Novice' and explicitly state they have NOT demonstrated mastery.
+3. DO NOT invent or hallucinate strengths (like 'potential' or 'curiosity') if the user explicitly said "I don't know" or provided no content.
+4. Focus on what was PROVEN in the conversation logs.
+
+QUESTIONS AND SCORES:
+${scoreSummary}
+
+CONVERSATION LOGS:
+${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)
+2. Detailed Analysis of performance.
+3. Actual Gaps identified.
+4. Recommended learning path.`;
+
+    const response = await model.invoke([
+        new SystemMessage(systemPrompt),
+        new HumanMessage("Generate the final mastery report."),
+    ]);
+
+    console.log("[AnalyzerNode] Report generated successfully. Length:", response.content?.toString().length);
+    return {
+        report: response.content as string,
+    };
+};

+ 70 - 0
server/src/assessment/graph/nodes/generator.node.ts

@@ -0,0 +1,70 @@
+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
+): Promise<Partial<EvaluationState>> => {
+    const { model, knowledgeBaseContent } = (config?.configurable as any) || {};
+
+    console.log("[GeneratorNode] Starting generation...", {
+        language: state.language,
+        hasModel: !!model,
+        contentLength: knowledgeBaseContent?.length
+    });
+
+    if (!model || !knowledgeBaseContent) {
+        throw new Error("Missing model or knowledgeBaseContent in node configuration");
+    }
+
+    const systemPrompt = `You are a professional knowledge assessment expert. 
+Your task is to generate 3-5 high-quality assessment questions based on the provided knowledge base content.
+
+IMPORTANT: You MUST generate all questions and content in the following language: ${state.language || 'zh'}.
+
+For each question, you must provide:
+1. The question text.
+2. A list of 3-5 key points or concepts that the user should mention in their answer to demonstrate mastery.
+3. A difficulty level (Standard, Advanced, Specialist).
+
+Format your response as a JSON array of objects:
+[
+  {
+    "question_text": "...",
+    "key_points": ["...", "..."],
+    "difficulty": "..."
+  }
+]
+
+CONTENT:
+${knowledgeBaseContent}`;
+
+    const response = await model.invoke([
+        new SystemMessage(systemPrompt),
+        new HumanMessage("Generate the assessment questions now."),
+    ]);
+
+    try {
+        const questions = JSON.parse(response.content as string);
+        console.log("[GeneratorNode] Successfully generated questions:", questions.length);
+        return {
+            questions: questions.map((q: any) => ({
+                questionText: q.question_text,
+                keyPoints: q.key_points,
+                difficulty: q.difficulty,
+            })),
+            currentQuestionIndex: 0,
+        };
+    } catch (error) {
+        console.error("[GeneratorNode] Failed to parse questions from AI response:", error);
+        return { 
+            questions: [],
+            currentQuestionIndex: 0
+        };
+    }
+};

+ 106 - 0
server/src/assessment/graph/nodes/grader.node.ts

@@ -0,0 +1,106 @@
+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
+): 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 systemPrompt = `You are an expert examiner. 
+Grade the user's answer based on the following question and key points.
+IMPORTANT: You MUST provide the feedback in the following language: ${state.language || 'zh'}.
+
+QUESTION: ${currentQuestion.questionText}
+EXPECTED KEY POINTS: ${currentQuestion.keyPoints.join(", ")}
+
+Evaluate:
+1. Accuracy: Did they cover the key points correctly?
+2. Completeness: Did they miss anything important?
+3. Depth: Is the explanation sufficient?
+
+Provide:
+1. A score from 0 to 10.
+2. Constructive feedback.
+3. A boolean flag 'should_follow_up' if the answer is incomplete or unclear and needs further clarification.
+
+Format your response as JSON:
+{
+  "score": 8,
+  "feedback": "...",
+  "should_follow_up": false
+}`;
+
+    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,
+        };
+    }
+};

+ 72 - 0
server/src/assessment/graph/nodes/interviewer.node.ts

@@ -0,0 +1,72 @@
+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
+): Promise<Partial<EvaluationState>> => {
+    const { questions, currentQuestionIndex, shouldFollowUp, messages } = state;
+
+    console.log("[InterviewerNode] Entering node...", {
+        numQuestions: questions?.length,
+        currentIndex: currentQuestionIndex,
+        shouldFollowUp,
+        numMessages: messages?.length
+    });
+
+    if (!questions || questions.length === 0) {
+        return {
+            messages: [new AIMessage("I'm sorry, I couldn't generate any questions for this session.")],
+        };
+    }
+
+    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 };
+    }
+
+    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 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,
+    };
+};

+ 96 - 0
server/src/assessment/graph/state.ts

@@ -0,0 +1,96 @@
+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,
+    }),
+});
+
+export type EvaluationState = typeof EvaluationAnnotation.State;

+ 25 - 3
server/src/auth/combined-auth.guard.ts

@@ -38,11 +38,17 @@ export class CombinedAuthGuard implements CanActivate {
 
         const request = context.switchToHttp().getRequest<Request & { user?: any; tenantId?: string }>();
 
+        // DEBUG
+        const rawApiKey = request.headers['x-api-key'] as string;
+        // console.log(`[CombinedAuthGuard] Request: ${request.method} ${request.url}`);
+        // console.log(`[CombinedAuthGuard] Headers: x-api-key: ${rawApiKey ? rawApiKey.substring(0, 10) + '...' : 'false'}, x-tenant-id: ${request.headers['x-tenant-id']}`);
+
         // --- Try API Key first ---
         const apiKey = this.extractApiKey(request);
         if (apiKey) {
             const user = await this.userService.findByApiKey(apiKey);
             if (user) {
+                // --- Try API Key first ---
                 // If x-tenant-id is provided, verify membership
                 const requestedTenantId = request.headers['x-tenant-id'] as string;
                 let activeTenantId = user.tenantId;
@@ -75,11 +81,19 @@ export class CombinedAuthGuard implements CanActivate {
 
                 return true;
             }
-            throw new UnauthorizedException('Invalid API key');
+
+            // Only throw if it definitely looks like an API key. 
+            // If it doesn't have the 'kb_' prefix, it's likely a mis-stored JWT from the frontend,
+            // so we let it fall through to the JWT check.
+            if (apiKey.startsWith('kb_')) {
+                throw new UnauthorizedException('Invalid API key');
+            }
         }
 
         // --- Fall back to JWT ---
         try {
+            // console.log(`[CombinedAuthGuard] Attempting JWT Auth for ${request.method} ${request.url}`);
+            // console.log(`[CombinedAuthGuard] Authorization: ${request.headers.authorization?.substring(0, 20)}...`);
             const result = await (this.jwtGuard as any).canActivate(context);
             let hasJwtSession = false;
 
@@ -89,11 +103,17 @@ export class CombinedAuthGuard implements CanActivate {
                 hasJwtSession = result;
             }
 
+            // console.log(`[CombinedAuthGuard] JWT Auth result: ${hasJwtSession}`);
+
             if (hasJwtSession) {
                 const user = request.user;
-                if (!user) return false;
+                if (!user) {
+                    // console.log(`[CombinedAuthGuard] JWT Auth passed but request.user is missing!`);
+                    return false;
+                }
 
                 const requestedTenantId = request.headers['x-tenant-id'] as string;
+                // console.log(`[CombinedAuthGuard] User: ${user.username}, Tenant: ${user.tenantId}, Requested Tenant: ${requestedTenantId}`);
 
                 if (requestedTenantId && user.tenantId !== requestedTenantId) {
                     const memberships = await this.userService.getUserTenants(user.id);
@@ -102,6 +122,7 @@ export class CombinedAuthGuard implements CanActivate {
                     if (hasAccess || user.role === 'SUPER_ADMIN') {
                         user.tenantId = requestedTenantId;
                     } else {
+                        // console.log(`[CombinedAuthGuard] Access denied to tenant ${requestedTenantId}`);
                         throw new UnauthorizedException('User does not belong to the requested tenant');
                     }
                 }
@@ -117,9 +138,10 @@ export class CombinedAuthGuard implements CanActivate {
 
                 return true;
             }
+            // console.log(`[CombinedAuthGuard] JWT Auth failed (no session)`);
             return false;
         } catch (e) {
-            console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
+            // console.error(`[CombinedAuthGuard] JWT Auth Error:`, e);
             throw e instanceof UnauthorizedException ? e : new UnauthorizedException('Authentication required');
         }
     }

+ 11 - 8
server/src/auth/jwt.strategy.ts

@@ -25,16 +25,19 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
     role?: string;
     tenantId?: string;
   }): Promise<SafeUser | null> {
-    const user = await this.userService.findOneByUsername(payload.username);
-    if (user) {
-      const { password, ...result } = user;
+    // 1. ALWAYS lookup by ID (sub) for identity stability
+    const user = await this.userService.findOneById(payload.sub);
 
-      // 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.
+    if (user) {
+      // 2. ALWAYS prioritize database values for role/tenant to prevent stale token access
       return {
-        ...result,
-        role: payload.role || result.role,
-        tenantId: payload.tenantId || result.tenantId
+        id: user.id,
+        username: user.username,
+        role: user.role, // Use DB role
+        tenantId: user.tenantId, // Use DB tenantId
+        isAdmin: user.isAdmin,
+        createdAt: user.createdAt,
+        updatedAt: user.updatedAt,
       } as SafeUser;
     }
     return null;

+ 12 - 0
server/src/data-source.ts

@@ -7,10 +7,16 @@ import { KnowledgeGroup } from './knowledge-group/knowledge-group.entity';
 import { SearchHistory } from './search-history/search-history.entity';
 import { ChatMessage } from './search-history/chat-message.entity';
 import { Note } from './note/note.entity';
+import { NoteCategory } from './note/note-category.entity';
 import { PodcastEpisode } from './podcasts/entities/podcast-episode.entity';
 import { ImportTask } from './import-task/import-task.entity';
 import { Tenant } from './tenant/tenant.entity';
 import { TenantSetting } from './tenant/tenant-setting.entity';
+import { TenantMember } from './tenant/tenant-member.entity';
+import { ApiKey } from './auth/entities/api-key.entity';
+import { AssessmentSession } from './assessment/entities/assessment-session.entity';
+import { AssessmentQuestion } from './assessment/entities/assessment-question.entity';
+import { AssessmentAnswer } from './assessment/entities/assessment-answer.entity';
 
 export const AppDataSource = new DataSource({
     type: 'better-sqlite3',
@@ -26,10 +32,16 @@ export const AppDataSource = new DataSource({
         SearchHistory,
         ChatMessage,
         Note,
+        NoteCategory,
         PodcastEpisode,
         ImportTask,
         Tenant,
         TenantSetting,
+        TenantMember,
+        ApiKey,
+        AssessmentSession,
+        AssessmentQuestion,
+        AssessmentAnswer,
     ],
     migrations: ['src/migrations/**/*.ts'],
 });

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

@@ -0,0 +1,18 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddAssessmentTablesManual1773198650000 implements MigrationInterface {
+    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"`);
+    }
+
+}

+ 0 - 12
server/src/model-config/model-config.controller.ts

@@ -80,16 +80,4 @@ export class ModelConfigController {
   async remove(@Req() req, @Param('id') id: string): Promise<void> {
     await this.modelConfigService.remove(req.user.id, req.user.tenantId, id);
   }
-
-  @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
-  @Patch(':id/set-default')
-  async setDefault(
-    @Req() req,
-    @Param('id') id: string,
-  ): Promise<ModelConfigResponseDto> {
-    const userId = req.user.id;
-    const tenantId = req.user.tenantId;
-    const modelConfig = await this.modelConfigService.setDefault(userId, tenantId, id);
-    return plainToClass(ModelConfigResponseDto, modelConfig);
-  }
 }

+ 0 - 14
server/src/model-config/model-config.entity.ts

@@ -33,35 +33,21 @@ export class ModelConfig {
   @Column({ type: 'integer', nullable: true })
   dimensions?: number; 
 
-  
-  
-
-  
   @Column({ type: 'integer', nullable: true, default: 8191 })
   maxInputTokens?: number;
 
-  
   @Column({ type: 'integer', nullable: true, default: 2048 })
   maxBatchSize?: number;
 
-  
   @Column({ type: 'boolean', default: false })
   isVectorModel?: boolean;
 
-  
   @Column({ type: 'boolean', default: true })
   isEnabled?: boolean;
 
-  
-  @Column({ type: 'boolean', default: false })
-  isDefault?: boolean;
-
-  
   @Column({ type: 'text', nullable: true })
   providerName?: string;
 
-  
-
   @Column({ type: 'text', nullable: true })
   userId: string;
 

+ 35 - 39
server/src/model-config/model-config.service.ts

@@ -106,54 +106,50 @@ export class ModelConfigService {
   }
 
   
-  async setDefault(userId: string, tenantId: string, id: string): Promise<ModelConfig> {
-    const modelConfig = await this.findOne(id, userId, tenantId);
-
-    
-    
-    await this.modelConfigRepository
-      .createQueryBuilder()
-      .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
-      })
-      .execute();
-
-    modelConfig.isDefault = true;
-    return this.modelConfigRepository.save(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}`);
-    }
-
+    
     let modelId: string | undefined;
-    if (type === ModelType.LLM) {
-      modelId = settings.selectedLLMId;
-    } else if (type === ModelType.EMBEDDING) {
-      modelId = settings.selectedEmbeddingId;
-    } else if (type === ModelType.RERANK) {
-      modelId = settings.selectedRerankId;
+    if (settings) {
+      if (type === ModelType.LLM) {
+        modelId = settings.selectedLLMId;
+      } else if (type === ModelType.EMBEDDING) {
+        modelId = settings.selectedEmbeddingId;
+      } else if (type === ModelType.RERANK) {
+        modelId = settings.selectedRerankId;
+      }
     }
 
-    if (!modelId) {
-      throw new BadRequestException(`Model of type "${type}" is not configured in Index Chat Config for this organization.`);
+    // 1. If we have a specific model ID selected for this tenant, try it
+    if (modelId) {
+      const model = await this.modelConfigRepository.findOne({
+        where: { id: modelId, isEnabled: true }
+      });
+      if (model) return model;
     }
 
-    const model = await this.modelConfigRepository.findOne({
-      where: { id: modelId, isEnabled: true }
-    });
+    // 2. Fallback: Find any enabled model for this tenant or globally
+    // Prioritize tenant-specific models over global models
+    const availableModels = await this.modelConfigRepository.createQueryBuilder('model')
+      .where('model.type = :type AND model.isEnabled = :enabled', {
+        type,
+        enabled: true
+      })
+      .andWhere('(model.tenantId = :tenantId OR model.tenantId IS NULL OR model.tenantId = :globalTenantId)', {
+        tenantId,
+        globalTenantId: GLOBAL_TENANT_ID
+      })
+      .orderBy('model.tenantId', 'DESC') // Puts non-null tenantId (e.g. current tenant) before NULL/Global
+      .getMany();
 
-    if (!model) {
-      throw new BadRequestException(`The configured model for "${type}" (ID: ${modelId}) is either missing or disabled in model management.`);
+    if (availableModels.length > 0) {
+      return availableModels[0];
     }
 
-    return model;
+    throw new BadRequestException(
+      modelId 
+        ? `The configured model for "${type}" (ID: ${modelId}) is missing or disabled, and no valid fallback model of this type was found.`
+        : `Model of type "${type}" is not configured for this organization and no enabled global model is available.`
+    );
   }
 }

+ 3 - 29
server/src/rag/rag.service.ts

@@ -19,8 +19,6 @@ export interface RagSearchResult {
   metadata?: any;
 }
 
-
-
 @Injectable()
 export class RagService {
   private readonly logger = new Logger(RagService.name);
@@ -61,7 +59,6 @@ export class RagService {
     
     const globalSettings = await this.userSettingService.getGlobalSettings();
 
-    
     const effectiveTopK = topK || globalSettings.topK || 5;
     const effectiveVectorThreshold = vectorSimilarityThreshold !== undefined ? vectorSimilarityThreshold : (globalSettings.similarityThreshold || 0.3);
     const effectiveRerankThreshold = rerankSimilarityThreshold !== undefined ? rerankSimilarityThreshold : (globalSettings.rerankSimilarityThreshold || 0.5);
@@ -78,7 +75,6 @@ export class RagService {
     );
 
     try {
-      
       let queriesToSearch = [query];
 
       if (effectiveEnableHyDE) {
@@ -89,14 +85,11 @@ export class RagService {
         queriesToSearch = [...new Set([query, ...expanded])];
       }
 
-      
       if (!effectiveEmbeddingId) {
         throw new Error('Embedding model ID not provided');
       }
 
-      
       const searchTasks = queriesToSearch.map(async (searchQuery) => {
-        
         const queryEmbedding = await this.embeddingService.getEmbeddings(
           [searchQuery],
           userId,
@@ -104,7 +97,6 @@ export class RagService {
         );
         const queryVector = queryEmbedding[0];
 
-        
         let results;
         if (effectiveEnableFullText) {
           results = await this.elasticsearchService.hybridSearch(
@@ -136,19 +128,15 @@ export class RagService {
       const allResultsRaw = await Promise.all(searchTasks);
       let searchResults = this.deduplicateResults(allResultsRaw.flat());
 
-      
       const initialCount = searchResults.length;
 
-      
       searchResults.forEach((r, idx) => {
         this.logger.log(`Hit ${idx}: score=${r.score.toFixed(4)}, fileName=${r.fileName}`);
       });
 
-      
       searchResults = searchResults.filter(r => r.score >= effectiveVectorThreshold);
       this.logger.log(`Initial hits: ${initialCount} -> filtered by vectorThreshold: ${searchResults.length}`);
 
-      
       let finalResults = searchResults;
 
       if (effectiveEnableRerank && effectiveRerankId && searchResults.length > 0) {
@@ -171,21 +159,17 @@ export class RagService {
             };
           });
 
-          
           const beforeRerankFilter = finalResults.length;
           finalResults = finalResults.filter(r => r.score >= effectiveRerankThreshold);
           this.logger.log(`After rerank: ${beforeRerankFilter} -> filtered by rerankThreshold: ${finalResults.length}`);
 
         } catch (error) {
           this.logger.warn(`Rerank failed, falling back to filtered vector search: ${error.message}`);
-          
         }
       }
 
-      
       finalResults = finalResults.slice(0, effectiveTopK);
 
-      
       const ragResults: RagSearchResult[] = finalResults.map((result) => ({
         content: result.content,
         fileName: result.fileName,
@@ -209,13 +193,10 @@ export class RagService {
     language: string = 'ja',
   ): string {
     const lang = language || 'ja';
-
-    
     let context = '';
     if (searchResults.length === 0) {
       context = this.i18nService.getMessage('ragNoDocumentFound', lang);
     } else {
-      
       const fileGroups = new Map<string, RagSearchResult[]>();
       searchResults.forEach((result) => {
         if (!fileGroups.has(result.fileName)) {
@@ -224,7 +205,6 @@ export class RagService {
         fileGroups.get(result.fileName)!.push(result);
       });
 
-      
       const contextParts: string[] = [];
       fileGroups.forEach((chunks, fileName) => {
         contextParts.push(this.i18nService.formatMessage('ragSource', { fileName }, lang));
@@ -242,9 +222,7 @@ export class RagService {
       context = contextParts.join('\n');
     }
 
-    const langText =
-      lang === 'zh' ? 'Chinese' : lang === 'en' ? 'English' : 'Japanese';
-
+    const langText = lang === 'zh' ? 'Chinese' : lang === 'en' ? 'English' : 'Japanese';
     const systemPrompt = this.i18nService.getMessage('ragSystemPrompt', lang);
     const rules = this.i18nService.formatMessage('ragRules', { lang: langText }, lang);
     const docContentHeader = this.i18nService.getMessage('ragDocumentContent', lang);
@@ -272,7 +250,6 @@ ${answerHeader}`;
     return Array.from(uniqueFiles);
   }
 
-  
   private deduplicateResults(results: any[]): any[] {
     const unique = new Map<string, any>();
     results.forEach(r => {
@@ -284,7 +261,6 @@ ${answerHeader}`;
     return Array.from(unique.values()).sort((a, b) => b.score - a.score);
   }
 
-  
   async expandQuery(query: string, userId: string, tenantId?: string): Promise<string[]> {
     try {
       const llm = await this.getInternalLlm(userId, tenantId || 'default');
@@ -311,7 +287,6 @@ ${answerHeader}`;
     }
   }
 
-  
   async generateHyDE(query: string, userId: string, tenantId?: string): Promise<string> {
     try {
       const llm = await this.getInternalLlm(userId, tenantId || 'default');
@@ -332,14 +307,13 @@ ${answerHeader}`;
     }
   }
 
-  
   private async getInternalLlm(userId: string, tenantId: string): Promise<ChatOpenAI | null> {
     try {
       const models = await this.modelConfigService.findAll(userId, tenantId || 'default');
-      const defaultLlm = models.find(m => m.type === 'llm' && m.isDefault && m.isEnabled !== false);
+      const defaultLlm = models.find(m => m.type === 'llm' && m.isEnabled);
 
       if (!defaultLlm) {
-        this.logger.warn('No default LLM configured for internal tasks');
+        this.logger.warn('No enabled LLM configured for internal tasks');
         return null;
       }
 

+ 6 - 1
server/src/user/user.entity.ts

@@ -1,5 +1,6 @@
 import {
   BeforeInsert,
+  BeforeUpdate,
   Column,
   CreateDateColumn,
   Entity,
@@ -77,8 +78,12 @@ export class User {
   userSetting: UserSetting;
 
   @BeforeInsert()
+  @BeforeUpdate()
   async hashPassword() {
-    this.password = await bcrypt.hash(this.password, 10);
+    // Only hash if the password is not yet hashed (BCrypt hash length is 60)
+    if (this.password && this.password.length < 60) {
+      this.password = await bcrypt.hash(this.password, 10);
+    }
   }
 
   async validatePassword(password: string): Promise<boolean> {

Plik diff jest za duży
+ 0 - 0
server/tsconfig.tsbuildinfo


+ 28 - 61
web/components/views/AgentsView.tsx

@@ -1,7 +1,9 @@
 import React from 'react';
 import { useLanguage } from '../../contexts/LanguageContext';
+import { useNavigate } from 'react-router-dom';
 import { Search, Plus, MoreHorizontal, MessageSquare } from 'lucide-react';
 import { motion, AnimatePresence } from 'framer-motion';
+import { cn } from '../../src/utils/cn';
 
 // Mock data based on the provided design
 interface AgentMock {
@@ -12,76 +14,25 @@ interface AgentMock {
     updatedAt: string;
     iconEmoji: string;
     iconBgClass: string;
+    path?: string;
 }
 
 const mockAgents: AgentMock[] = [
     {
-        id: '1',
-        name: 'agent1Name',
-        description: 'agent1Desc',
+        id: 'assessment',
+        name: 'assessmentTitle',
+        description: 'assessmentDesc',
         status: 'running',
         updatedAt: 'agent1Time',
-        iconEmoji: '🧑‍💻',
-        iconBgClass: 'bg-indigo-50'
-    },
-    {
-        id: '2',
-        name: 'agent2Name',
-        description: 'agent2Desc',
-        status: 'running',
-        updatedAt: 'agent2Time',
-        iconEmoji: '💻',
-        iconBgClass: 'bg-green-50'
-    },
-    {
-        id: '3',
-        name: 'agent3Name',
-        description: 'agent3Desc',
-        status: 'running',
-        updatedAt: 'agent3Time',
-        iconEmoji: '📐',
-        iconBgClass: 'bg-blue-50'
-    },
-    {
-        id: '4',
-        name: 'agent4Name',
-        description: 'agent4Desc',
-        status: 'stopped',
-        updatedAt: 'agent4Time',
-        iconEmoji: '🧪',
-        iconBgClass: 'bg-slate-100'
-    },
-    {
-        id: '5',
-        name: 'agent5Name',
-        description: 'agent5Desc',
-        status: 'running',
-        updatedAt: 'agent5Time',
-        iconEmoji: '📊',
-        iconBgClass: 'bg-purple-50'
-    },
-    {
-        id: '6',
-        name: 'agent6Name',
-        description: 'agent6Desc',
-        status: 'running',
-        updatedAt: 'agent6Time',
-        iconEmoji: '⚙️',
-        iconBgClass: 'bg-orange-50'
-    },
-    {
-        id: '7',
-        name: 'agent7Name',
-        description: 'agent7Desc',
-        status: 'running',
-        updatedAt: 'agent7Time',
-        iconEmoji: '📈',
-        iconBgClass: 'bg-red-50'
+        iconEmoji: '📋',
+        iconBgClass: 'bg-blue-50',
+        path: '/assessment'
     }
 ];
 
 export const AgentsView: React.FC = () => {
     const { t } = useLanguage();
+    const navigate = useNavigate();
 
     return (
         <div className="flex flex-col h-full bg-[#f4f7fb] overflow-hidden">
@@ -119,7 +70,15 @@ export const AgentsView: React.FC = () => {
                                 layout
                                 initial={{ opacity: 0, y: 10 }}
                                 animate={{ opacity: 1, y: 0 }}
-                                className="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-all group flex flex-col h-[220px]"
+                                className={cn(
+                                    "bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover:shadow-md transition-all group flex flex-col h-[220px]",
+                                    agent.path && "cursor-pointer hover:border-blue-200"
+                                )}
+                                onClick={() => {
+                                    if (agent.path) {
+                                        navigate(agent.path);
+                                    }
+                                }}
                             >
                                 {/* Top layer */}
                                 <div className="flex items-center justify-between mb-4">
@@ -159,7 +118,15 @@ export const AgentsView: React.FC = () => {
                                     <span className="text-[12px] font-medium text-slate-400">
                                         {t('updatedAtPrefix')}{t(agent.updatedAt as any)}
                                     </span>
-                                    <button className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors">
+                                    <button 
+                                        onClick={(e) => {
+                                            e.stopPropagation();
+                                            if (agent.path) {
+                                                navigate(agent.path);
+                                            }
+                                        }}
+                                        className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
+                                    >
                                         <MessageSquare size={14} className="text-blue-500" />
                                         <span className="text-[13px] font-bold">{t('btnChat')}</span>
                                     </button>

+ 705 - 0
web/components/views/AssessmentView.tsx

@@ -0,0 +1,705 @@
+import React, { useState, useEffect, useRef } from 'react';
+import {
+    Brain,
+    Send,
+    Loader2,
+    CheckCircle,
+    AlertCircle,
+    ChevronRight,
+    History,
+    ClipboardCheck,
+    RefreshCcw,
+    FileText,
+    Star,
+    Award,
+    Trophy
+} from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { useLanguage } from '../../contexts/LanguageContext';
+import { assessmentService, AssessmentSession, AssessmentState } from '../../services/assessmentService';
+import { knowledgeGroupService } from '../../services/knowledgeGroupService';
+import { KnowledgeGroup } from '../../types';
+import { cn } from '../../src/utils/cn';
+
+interface AssessmentViewProps {
+    onLogout: () => void;
+    onNavigate: (path: string) => void;
+    isAdmin: boolean;
+}
+
+export const AssessmentView: React.FC<AssessmentViewProps> = ({
+    onLogout,
+    onNavigate,
+    isAdmin
+}) => {
+    const { language, t } = useLanguage();
+    const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
+    const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
+    const [session, setSession] = useState<AssessmentSession | null>(null);
+    const [state, setState] = useState<AssessmentState | null>(null);
+    const [inputValue, setInputValue] = useState('');
+    const [isLoading, setIsLoading] = useState(false);
+    const [processStep, setProcessStep] = useState<string>('');
+    const [error, setError] = useState<string | null>(null);
+    const [history, setHistory] = useState<AssessmentSession[]>([]);
+    const [loadingHistoryId, setLoadingHistoryId] = useState<string | null>(null);
+
+    const messagesEndRef = useRef<HTMLDivElement>(null);
+
+    useEffect(() => {
+        const fetchGroups = async () => {
+            try {
+                const data = await knowledgeGroupService.getGroups();
+                setGroups(data);
+            } catch (err) {
+                console.error('Failed to fetch groups:', err);
+            }
+        };
+        fetchGroups();
+    }, []);
+
+    useEffect(() => {
+        messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+    }, [state?.messages, isLoading]);
+
+    const fetchHistory = async () => {
+        try {
+            const data = await assessmentService.getHistory();
+            setHistory(data);
+        } catch (err) {
+            console.error('Failed to fetch history:', err);
+        }
+    };
+
+    useEffect(() => {
+        fetchHistory();
+    }, []);
+
+    const isZh = language === 'zh';
+    const isJa = language === 'ja';
+
+    const getStatusText = (node: string) => {
+        const mapping: Record<string, any> = {
+            generator: isZh ? '正在生成测评问题...' : isJa ? '問題を生成中...' : 'Generating questions...',
+            grader: isZh ? '正在评估您的回答...' : isJa ? '回答を評価中...' : 'Evaluating your answer...',
+            interviewer: isZh ? '正在准备下一个问题...' : isJa ? '次の質問を準備中...' : 'Preparing next question...',
+            analyzer: isZh ? '正在生成最终报告...' : isJa ? 'レポートを生成中...' : 'Generating final report...',
+        };
+        return mapping[node] || (isZh ? '正在处理...' : isJa ? '処理中...' : 'Processing...');
+    };
+
+    const handleSelectHistory = async (histSession: AssessmentSession) => {
+        if (isLoading) return;
+        
+        setLoadingHistoryId(histSession.id);
+        setIsLoading(true);
+        setError(null);
+        try {
+            const histState = await assessmentService.getSessionState(histSession.id);
+            setState(histState);
+            setSession(histSession);
+        } catch (err: any) {
+            setError(err.message || 'Failed to load historical assessment');
+        } finally {
+            setIsLoading(false);
+            setLoadingHistoryId(null);
+        }
+    };
+
+    const handleStartAssessment = async () => {
+        if (!selectedGroup) return;
+
+        setIsLoading(true);
+        setError(null);
+        setProcessStep(isZh ? '正在初始化...' : isJa ? '初期化中...' : 'Initializing...');
+        
+        try {
+            const newSession = await assessmentService.startSession(selectedGroup, language);
+            setSession(newSession);
+
+            for await (const event of assessmentService.startSessionStream(newSession.id)) {
+                if (event.type === 'node') {
+                    setProcessStep(getStatusText(event.node));
+                    if (event.data) {
+                        setState(prev => {
+                            if (!prev) return event.data;
+                            const prevMessages = prev.messages || [];
+                            return {
+                                ...prev,
+                                ...event.data,
+                                messages: event.data.messages 
+                                    ? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => pm.content === m.content && pm.role === m.role))]
+                                    : prevMessages,
+                                feedbackHistory: event.data.feedbackHistory 
+                                    ? [...(prev.feedbackHistory || []), ...event.data.feedbackHistory.filter((fh: any) => !(prev.feedbackHistory || []).some((pfh: any) => pfh.content === fh.content))]
+                                    : (prev.feedbackHistory || []),
+                                scores: { ...(prev.scores || {}), ...(event.data.scores || {}) }
+                            } as any;
+                        });
+                    }
+                } else if (event.type === 'final') {
+                    setState(event.data);
+                }
+            }
+        } catch (err: any) {
+            setError(err.message || 'Failed to start assessment');
+        } finally {
+            setIsLoading(false);
+            setProcessStep('');
+        }
+    };
+
+    const handleRetry = async () => {
+        if (!session) return;
+        setIsLoading(true);
+        setError(null);
+        setProcessStep(isZh ? '正在重新尝试生成...' : isJa ? '再生成中...' : 'Retrying generation...');
+        try {
+            for await (const event of assessmentService.startSessionStream(session.id)) {
+                if (event.type === 'node') {
+                    setProcessStep(getStatusText(event.node));
+                } else if (event.type === 'final') {
+                    setState(event.data);
+                }
+            }
+        } catch (err: any) {
+            setError(err.message || 'Retry failed');
+        } finally {
+            setIsLoading(false);
+            setProcessStep('');
+        }
+    };
+
+    const handleSubmitAnswer = async () => {
+        if (!session || !inputValue.trim() || isLoading) return;
+
+        const answer = inputValue.trim();
+        setInputValue('');
+        setIsLoading(true);
+        setError(null);
+        setProcessStep(isZh ? '正在准备发送...' : isJa ? '送信準備中...' : 'Preparing to send...');
+
+        try {
+            setState(prev => ({
+                ...prev!,
+                messages: [
+                    ...(prev?.messages || []),
+                    { role: 'user' as const, content: answer, timestamp: Date.now() }
+                ]
+            }));
+
+            for await (const event of assessmentService.submitAnswerStream(session.id, answer, language)) {
+                if (event.type === 'node') {
+                    setProcessStep(getStatusText(event.node));
+                    if (event.data) {
+                        setState(prev => {
+                            if (!prev) return event.data;
+                            const prevMessages = prev.messages || [];
+                            const mergedMessages = event.data.messages 
+                                ? [...prevMessages, ...event.data.messages.filter((m: any) => !prevMessages.some((pm: any) => pm.content === m.content && pm.role === m.role))]
+                                : prevMessages;
+                            
+                            return {
+                                ...prev,
+                                ...event.data,
+                                messages: mergedMessages,
+                                feedbackHistory: event.data.feedbackHistory 
+                                    ? [...(prev.feedbackHistory || []), ...event.data.feedbackHistory.filter((fh: any) => !(prev.feedbackHistory || []).some((pfh: any) => pfh.content === fh.content))]
+                                    : (prev.feedbackHistory || []),
+                                scores: { ...(prev.scores || {}), ...(event.data.scores || {}) }
+                            } as any;
+                        });
+                    }
+                } else if (event.type === 'final') {
+                    setState(event.data);
+                    if (event.data.status === 'COMPLETED') {
+                        setSession(prev => prev ? { ...prev, status: 'COMPLETED' } : null);
+                        fetchHistory();
+                    }
+                }
+            }
+        } catch (err: any) {
+            setError(err.message || 'Failed to submit answer');
+        } finally {
+            setIsLoading(false);
+            setProcessStep('');
+        }
+    };
+
+    const renderHeader = () => (
+        <div className="flex-none h-16 px-6 border-b border-slate-200/60 flex items-center justify-between bg-white/80 backdrop-blur-md relative z-40">
+            <div className="flex items-center gap-3">
+                <div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-200">
+                    <Brain size={22} className="text-white" />
+                </div>
+                <div>
+                    <h2 className="text-lg font-bold text-slate-900 leading-tight">{t('assessmentTitle')}</h2>
+                    <p className="text-xs text-slate-500 font-medium">{t('assessmentDesc')}</p>
+                </div>
+            </div>
+
+            <div className="flex items-center gap-3">
+                {session && (
+                    <div className="px-3 py-1.5 bg-slate-100 rounded-full flex items-center gap-2">
+                        <div className={cn(
+                            "w-2 h-2 rounded-full animate-pulse",
+                            session.status === 'IN_PROGRESS' ? "bg-green-500" : "bg-blue-500"
+                        )} />
+                        <span className="text-xs font-bold text-slate-600 uppercase tracking-wider">
+                            {session.status === 'IN_PROGRESS' ? t('inProgress') : t('statusReadyFragment')}
+                        </span>
+                    </div>
+                )}
+                <button
+                    onClick={() => {
+                        setSession(null);
+                        setState(null);
+                        setSelectedGroup(null);
+                    }}
+                    className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-all"
+                    title={t('newChat')}
+                >
+                    <RefreshCcw size={18} />
+                </button>
+            </div>
+        </div>
+    );
+
+    const renderSetup = () => (
+        <div className="flex-1 flex bg-[#F8FAFC] overflow-hidden">
+            {/* Main Setup Content */}
+            <div className="flex-1 overflow-y-auto p-8 flex flex-col items-center justify-center">
+                <motion.div
+                    initial={{ opacity: 0, y: 20 }}
+                    animate={{ opacity: 1, y: 0 }}
+                    className="max-w-xl w-full"
+                >
+                    <div className="bg-white rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 p-8">
+                        <div className="text-center mb-8">
+                            <div className="w-20 h-20 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-inner">
+                                <Brain size={40} />
+                            </div>
+                            <h3 className="text-2xl font-black text-slate-900 mb-2">{t('readyForAssessment')}</h3>
+                            <p className="text-slate-500 font-medium">{t('readyForAssessmentDesc')}</p>
+                        </div>
+
+                        <div className="space-y-6">
+                            <div>
+                                <label className="block text-sm font-bold text-slate-700 mb-2 ml-1">
+                                    {t('selectKnowledgeGroup')}
+                                </label>
+                                <div className="grid grid-cols-1 gap-3 max-h-60 overflow-y-auto pr-2 custom-scrollbar">
+                                    {groups.map(group => (
+                                        <button
+                                            key={group.id}
+                                            onClick={() => setSelectedGroup(group.id)}
+                                            className={cn(
+                                                "w-full text-left px-5 py-4 rounded-2xl border-2 transition-all flex items-center justify-between group",
+                                                selectedGroup === group.id
+                                                    ? "border-indigo-600 bg-indigo-50/50 text-indigo-700 shadow-md shadow-indigo-100"
+                                                    : "border-slate-100 hover:border-slate-200 text-slate-600 hover:bg-slate-50"
+                                            )}
+                                        >
+                                            <div className="flex flex-col">
+                                                <span className="text-sm font-bold mb-0.5">{group.name}</span>
+                                                <span className="text-[11px] opacity-60 font-medium">{group.fileCount} files available</span>
+                                            </div>
+                                            <ChevronRight size={18} className={cn("transition-transform", selectedGroup === group.id ? "translate-x-1" : "opacity-30")} />
+                                        </button>
+                                    ))}
+                                    {groups.length === 0 && (
+                                        <div className="px-5 py-8 text-center text-slate-400 bg-slate-50 rounded-2xl border-2 border-dashed border-slate-200">
+                                            <p className="text-sm">No knowledge groups found.</p>
+                                        </div>
+                                    )}
+                                </div>
+                            </div>
+
+                            <button
+                                onClick={handleStartAssessment}
+                                disabled={!selectedGroup || isLoading}
+                                className={cn(
+                                    "w-full py-4 rounded-2xl font-black text-white transition-all transform hover:scale-[1.02] active:scale-[0.98] shadow-lg flex items-center justify-center gap-3",
+                                    !selectedGroup || isLoading
+                                        ? "bg-slate-300 shadow-none cursor-not-allowed"
+                                        : "bg-indigo-600 hover:bg-indigo-700 shadow-indigo-200"
+                                )}
+                            >
+                                {isLoading ? (
+                                    <Loader2 size={20} className="animate-spin" />
+                                ) : (
+                                    <>
+                                        <ClipboardCheck size={20} />
+                                        <span>{t('startProfessionalEvaluation')}</span>
+                                    </>
+                                )}
+                            </button>
+
+                            {error && (
+                                <div className="mt-4 p-4 bg-rose-50 border border-rose-100 rounded-2xl flex items-start gap-3 animate-shake">
+                                    <AlertCircle size={20} className="text-rose-500 shrink-0 mt-0.5" />
+                                    <div className="flex-1">
+                                        <p className="text-sm font-bold text-rose-700">{error}</p>
+                                        <button 
+                                            onClick={handleRetry}
+                                            className="mt-1 text-xs font-bold text-rose-600 hover:text-rose-800 underline flex items-center gap-1"
+                                        >
+                                            <RefreshCcw size={12} />
+                                            {t('retry')}
+                                        </button>
+                                    </div>
+                                </div>
+                            )}
+                        </div>
+                    </div>
+
+                    <div className="mt-8 flex gap-4 justify-center">
+                        <div className="flex items-center gap-2 text-[13px] font-bold text-slate-400 uppercase tracking-widest">
+                            <CheckCircle size={14} className="text-emerald-500" />
+                            {t('aiPoweredAnalysis')}
+                        </div>
+                        <div className="flex items-center gap-2 text-[13px] font-bold text-slate-400 uppercase tracking-widest">
+                            <CheckCircle size={14} className="text-emerald-500" />
+                            {t('masteryScoring')}
+                        </div>
+                    </div>
+                </motion.div>
+            </div>
+
+            {/* Assessment History Sidebar */}
+            {history.length > 0 && (
+                <div className="w-80 flex-none bg-white p-6 overflow-y-auto hidden lg:flex flex-col border-l border-slate-200/60 shadow-[4px_0_24px_rgba(0,0,0,0.02)]">
+                    <h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
+                        <History size={18} className="text-indigo-600" />
+                        {t('recentAssessments')}
+                    </h3>
+                    <div className="space-y-3 custom-scrollbar">
+                        {history.map(hist => (
+                            <button
+                                key={hist.id}
+                                type="button"
+                                onClick={() => handleSelectHistory(hist)}
+                                disabled={isLoading}
+                                className={cn(
+                                    "w-full text-left p-4 rounded-2xl bg-slate-50 border border-slate-100 transition-all flex items-center justify-between group",
+                                    isLoading ? "opacity-50 cursor-not-allowed" : "hover:border-indigo-200 hover:bg-indigo-50/30 cursor-pointer"
+                                )}
+                            >
+                                <div className="flex flex-col">
+                                    <span className="text-sm font-bold text-slate-800 truncate max-w-[180px]">
+                                        {hist.knowledgeBase?.name || hist.knowledgeGroup?.name || t('assessmentTitle')}
+                                    </span>
+                                    <div className="flex items-center gap-2 mt-1">
+                                        <span className="text-[10px] font-black text-indigo-400 px-1.5 py-0.5 bg-indigo-50 rounded">
+                                            {hist.finalScore !== null && hist.finalScore !== undefined ? `${Math.round(hist.finalScore * 10) / 10}/10` : t('inProgress')}
+                                        </span>
+                                        <span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">
+                                            {new Date(hist.createdAt).toLocaleDateString()}
+                                        </span>
+                                    </div>
+                                </div>
+                                <div className="w-8 h-8 rounded-full bg-white border border-slate-100 flex items-center justify-center transition-all group-hover:bg-indigo-600 group-hover:text-white shrink-0">
+                                    {loadingHistoryId === hist.id ? (
+                                        <Loader2 size={14} className="animate-spin text-indigo-600 group-hover:text-white" />
+                                    ) : (
+                                        <FileText size={14} />
+                                    )}
+                                </div>
+                            </button>
+                        ))}
+                    </div>
+                </div>
+            )}
+        </div>
+    );
+
+    const renderAssessment = () => {
+        const currentQuestionNo = (state?.currentQuestionIndex || 0) + 1;
+        const totalQuestions = state?.questions?.length || 0;
+        const progressLabel = totalQuestions > 0 
+            ? t('questionProgress', currentQuestionNo, totalQuestions)
+            : t('initializingQuestion', currentQuestionNo);
+        
+        const messages = state?.messages || [];
+        const filteredMessages = messages.filter(m => 
+            m.role !== 'system' && 
+            !(m.role === 'assistant' && (m.content?.toString().startsWith('Score:') || m.content?.toString().startsWith('得分:')))
+        );
+        
+        const feedbackHistory = state?.feedbackHistory || [];
+        const lastFeedbackMessage = feedbackHistory[feedbackHistory.length - 1];
+        
+        const feedbackMatch = lastFeedbackMessage?.content?.toString().match(/(?:Score|得分): (\d+)\/10\n\n(?:Feedback|反馈): ([\s\S]*)/i);
+        const latestScore = feedbackMatch ? feedbackMatch[1] : null;
+        const latestFeedback = feedbackMatch ? feedbackMatch[2] : (lastFeedbackMessage?.content || null);
+
+        return (
+            <div className="flex-1 flex bg-[#F8FAFC] overflow-hidden">
+                {/* Left: Chat Area */}
+                <div className="flex-1 flex flex-col border-r border-slate-200/60 transition-all duration-500">
+                    <div className="flex-none px-6 py-3 bg-white/50 border-b border-slate-100 flex items-center justify-between">
+                        <div className="flex items-center gap-2">
+                            <span className="text-[10px] font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-full uppercase tracking-wider">
+                                {progressLabel}
+                            </span>
+                            {isLoading && (
+                                <span className="text-[10px] font-bold text-slate-400 animate-pulse flex items-center gap-1.5 uppercase tracking-widest">
+                                    <div className="w-1 h-1 bg-indigo-400 rounded-full animate-bounce" />
+                                    {processStep || t('aiIsProcessing')}
+                                </span>
+                            )}
+                        </div>
+                    </div>
+                    
+                    <div className="flex-1 overflow-y-auto px-6 py-8 custom-scrollbar">
+                        <div className="max-w-3xl mx-auto space-y-8">
+                            {filteredMessages.map((msg, idx) => (
+                                <motion.div
+                                    key={idx}
+                                    initial={{ opacity: 0, x: msg.role === 'user' ? 20 : -20 }}
+                                    animate={{ opacity: 1, x: 0 }}
+                                    className={cn(
+                                        "flex flex-col max-w-[85%]",
+                                        msg.role === 'user' ? "ml-auto items-end" : "mr-auto items-start"
+                                    )}
+                                >
+                                    <div className={cn(
+                                        "px-5 py-4 rounded-2xl shadow-sm text-[15px] leading-relaxed",
+                                        msg.role === 'user'
+                                            ? "bg-indigo-600 text-white rounded-tr-none"
+                                            : "bg-white text-slate-800 border border-slate-100 rounded-tl-none"
+                                    )}>
+                                        {msg.content}
+                                    </div>
+                                    <span className="mt-1.5 text-[10px] items-center uppercase tracking-widest font-bold text-slate-400">
+                                        {new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+                                    </span>
+                                </motion.div>
+                            ))}
+
+                            {isLoading && (
+                                <div className="flex items-start mr-auto max-w-[85%]">
+                                    <div className="px-5 py-4 bg-white border border-slate-100 rounded-2xl rounded-tl-none shadow-sm">
+                                        <div className="flex gap-1.5">
+                                            <div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]" />
+                                            <div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]" />
+                                            <div className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce" />
+                                        </div>
+                                    </div>
+                                </div>
+                            )}
+
+                            <div ref={messagesEndRef} />
+                        </div>
+                    </div>
+
+                    <div className="p-6 bg-white border-t border-slate-200/60 shadow-[0_-4px_20px_-10px_rgba(0,0,0,0.05)]">
+                        <div className="max-w-3xl mx-auto flex items-end gap-3">
+                            <textarea
+                                value={inputValue}
+                                onChange={(e) => setInputValue(e.target.value)}
+                                onKeyDown={(e) => {
+                                    if (e.key === 'Enter' && !e.shiftKey) {
+                                        e.preventDefault();
+                                        handleSubmitAnswer();
+                                    }
+                                }}
+                                placeholder={t('typeAnswerPlaceholder')}
+                                className="flex-1 max-h-32 p-4 bg-slate-50 border-none rounded-2xl focus:bg-white focus:ring-2 focus:ring-indigo-500/20 text-sm font-medium resize-none transition-all placeholder:text-slate-400 outline-none shadow-inner"
+                                rows={1}
+                            />
+                            <button
+                                onClick={handleSubmitAnswer}
+                                disabled={!inputValue.trim() || isLoading}
+                                className={cn(
+                                    "w-14 h-14 flex items-center justify-center rounded-2xl transition-all shadow-lg",
+                                    !inputValue.trim() || isLoading
+                                        ? "bg-slate-100 text-slate-400 shadow-none"
+                                        : "bg-indigo-600 text-white hover:bg-indigo-700 shadow-indigo-200 active:scale-95"
+                                )}
+                            >
+                                <Send size={22} className={isLoading ? "animate-pulse" : ""} />
+                            </button>
+                        </div>
+                    </div>
+                </div>
+
+                {/* Right: Feedback Panel */}
+                <div className="w-80 flex-none bg-white p-6 overflow-y-auto hidden lg:flex flex-col border-l border-slate-100">
+                    <h3 className="text-sm font-black text-slate-900 mb-6 flex items-center gap-2 uppercase tracking-widest">
+                        <ClipboardCheck size={18} className="text-indigo-600" />
+                        {t('liveFeedback')}
+                    </h3>
+                    
+                    {latestScore ? (
+                        <div className="space-y-6">
+                            <div className="bg-indigo-50/50 rounded-3xl p-6 text-center border border-indigo-100">
+                                <span className="text-[10px] font-black text-indigo-400 uppercase tracking-[0.2em] mb-2 block">{t('currentScore')}</span>
+                                <div className="text-5xl font-black text-indigo-600">{latestScore}<span className="text-xl opacity-40">/10</span></div>
+                            </div>
+                            
+                            <div className="space-y-4">
+                                <h4 className="text-[11px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
+                                    <History size={14} />
+                                    {t('aiExplanation')}
+                                </h4>
+                                <div className="text-sm text-slate-600 leading-relaxed font-medium bg-slate-50 rounded-2xl p-5 border border-slate-100">
+                                    {latestFeedback}
+                                </div>
+                            </div>
+
+                            <div className="bg-emerald-50 rounded-2xl p-4 border border-emerald-100 flex items-center gap-3">
+                                <div className="w-8 h-8 bg-emerald-500 text-white rounded-lg flex items-center justify-center shrink-0">
+                                    <Star size={18} />
+                                </div>
+                                <div>
+                                    <div className="text-xs font-black text-emerald-800">{t('masteryProgress')}</div>
+                                    <div className="text-[10px] text-emerald-600 font-bold opacity-80">{t('trackedInRealTime')}</div>
+                                </div>
+                            </div>
+                        </div>
+                    ) : (
+                        <div className="flex-1 flex flex-col items-center justify-center text-center opacity-40 space-y-4">
+                            <div className="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center">
+                                <History size={32} className="text-slate-400" />
+                            </div>
+                            <div className="text-xs font-bold text-slate-500">
+                                {t('submitAnswerToSeeFeedback')}
+                            </div>
+                        </div>
+                    )}
+
+                    <div className="mt-auto pt-6 border-t border-slate-100">
+                        <div className="bg-amber-50 rounded-2xl p-4 border border-amber-100 flex items-start gap-3">
+                            <AlertCircle size={16} className="text-amber-500 mt-0.5 shrink-0" />
+                            <div className="text-[11px] text-amber-800 font-medium leading-relaxed">
+                                <strong>{t('assessmentGuide')}</strong> {t('assessmentGuideDesc')}
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        );
+    };
+
+    const renderCompletion = () => (
+        <div className="flex-1 overflow-y-auto bg-[#F8FAFC] p-8 custom-scrollbar">
+            <motion.div
+                initial={{ opacity: 0, scale: 0.95 }}
+                animate={{ opacity: 1, scale: 1 }}
+                className="max-w-4xl mx-auto space-y-8"
+            >
+                <div className="bg-white rounded-[40px] shadow-2xl shadow-indigo-100/50 border border-slate-100 overflow-hidden">
+                    <div className="bg-indigo-600 p-10 text-white relative overflow-hidden">
+                        <div className="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -mr-32 -mt-32 blur-3xl" />
+                        <div className="relative z-10 flex flex-col items-center text-center">
+                            <div className="w-20 h-20 bg-white/20 backdrop-blur-md rounded-3xl flex items-center justify-center mb-6 border border-white/30 shadow-2xl">
+                                <Trophy size={40} className="text-yellow-300" />
+                            </div>
+                            <h3 className="text-4xl font-black mb-2 tracking-tight">{t('level')} {state?.report?.match(/LEVEL:\s*(\w+)/i)?.[1] || 'Pending'}</h3>
+                            <p className="text-indigo-100 font-bold uppercase tracking-[0.2em] text-sm opacity-80">{t('assessmentResultsAvailable')}</p>
+                        </div>
+                    </div>
+
+                    <div className="p-10">
+                        <div className="grid grid-cols-1 md:grid-cols-3 gap-6 -mt-20 relative z-20 mb-12">
+                            <div className="bg-white p-6 rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col items-center text-center group transition-all hover:-translate-y-1">
+                                <div className="w-12 h-12 bg-amber-50 text-amber-600 rounded-xl flex items-center justify-center mb-3">
+                                    <Star size={24} />
+                                </div>
+                                <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('knowledgeCoverage')}</span>
+                                <span className="text-2xl font-black text-slate-900">
+                                    {state?.questions && state.questions.length > 0 
+                                        ? `${Math.round((Object.keys(state.scores || {}).length / state.questions.length) * 100)}%`
+                                        : '0%'}
+                                </span>
+                            </div>
+                            <div className="bg-white p-6 rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col items-center text-center group transition-all hover:-translate-y-1">
+                                <div className="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center mb-3">
+                                    <Award size={24} />
+                                </div>
+                                <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('precisionScore')}</span>
+                                <span className="text-2xl font-black text-slate-900">
+                                    {state?.finalScore !== undefined ? (Math.round(state.finalScore * 10) / 10) : '0'}/10
+                                </span>
+                            </div>
+                            <div className="bg-white p-6 rounded-3xl shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col items-center text-center group transition-all hover:-translate-y-1">
+                                <div className="w-12 h-12 bg-emerald-50 text-emerald-600 rounded-xl flex items-center justify-center mb-3">
+                                    <CheckCircle size={24} />
+                                </div>
+                                <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{t('status')}</span>
+                                <span className={cn(
+                                    "text-2xl font-black uppercase tracking-tighter",
+                                    (state?.finalScore || 0) >= 6 ? "text-emerald-600" : "text-rose-600"
+                                )}>
+                                    {(state?.finalScore || 0) >= 6 ? t('verified') : t('fail')}
+                                </span>
+                            </div>
+                        </div>
+
+                        <div className="space-y-8">
+                            <div>
+                                <h4 className="flex items-center gap-2.5 text-lg font-black text-slate-900 mb-4">
+                                    <FileText size={20} className="text-indigo-600" />
+                                    {t('comprehensiveMasteryReport')}
+                                </h4>
+                                <div className="bg-slate-50 border border-slate-100 rounded-3xl p-8 text-slate-800 leading-relaxed font-medium assessment-report overflow-hidden whitespace-pre-wrap">
+                                    {state?.report}
+                                </div>
+                            </div>
+
+                            <div className="flex gap-4">
+                                <button
+                                    onClick={() => {
+                                        setSession(null);
+                                        setState(null);
+                                    }}
+                                    className="flex-1 py-4 bg-indigo-600 text-white rounded-2xl font-black shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all active:scale-[0.98]"
+                                >
+                                    {t('newAssessmentSession')}
+                                </button>
+                                <button
+                                    className="px-8 py-4 bg-white border-2 border-slate-100 text-slate-700 rounded-2xl font-bold hover:bg-slate-50 transition-all active:scale-[0.98]"
+                                >
+                                    {t('downloadPdfReport')}
+                                </button>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </motion.div>
+        </div>
+    );
+
+    return (
+        <div className="flex flex-col h-full bg-white animate-in flex-1">
+            {renderHeader()}
+
+            <AnimatePresence mode="wait">
+                {error && (
+                    <motion.div
+                        initial={{ opacity: 0, y: -20 }}
+                        animate={{ opacity: 1, y: 0 }}
+                        exit={{ opacity: 0, y: -20 }}
+                        className="absolute top-20 left-1/2 -translate-x-1/2 z-50 min-w-[320px] max-w-lg"
+                    >
+                        <div className="mx-6 p-4 bg-red-50 border border-red-100 rounded-2xl flex items-center gap-3 shadow-xl">
+                            <AlertCircle size={20} className="text-red-500 shrink-0" />
+                            <p className="text-sm font-bold text-red-800 pr-2">{error}</p>
+                            <button
+                                onClick={() => setError(null)}
+                                className="ml-auto p-1.5 text-red-400 hover:text-red-500 rounded-lg transition-colors"
+                            >
+                                <AlertCircle size={16} />
+                            </button>
+                        </div>
+                    </motion.div>
+                )}
+            </AnimatePresence>
+
+            {!session && renderSetup()}
+            {session && session.status === 'IN_PROGRESS' && renderAssessment()}
+            {session && session.status === 'COMPLETED' && renderCompletion()}
+        </div>
+    );
+};

+ 2 - 0
web/index.tsx

@@ -12,6 +12,7 @@ import WorkspaceLayout from './src/components/layouts/WorkspaceLayout';
 // Lazy-loaded page components
 const ChatPage = lazy(() => import('./src/pages/workspace/ChatPage'));
 const AgentsPage = lazy(() => import('./src/pages/workspace/AgentsPage'));
+const AssessmentPage = lazy(() => import('./src/pages/workspace/AssessmentPage'));
 const PluginsPage = lazy(() => import('./src/pages/workspace/PluginsPage'));
 const KnowledgePage = lazy(() => import('./src/pages/workspace/KnowledgePage'));
 const NotebooksPage = lazy(() => import('./src/pages/workspace/NotebooksPage'));
@@ -84,6 +85,7 @@ function App() {
                     <Route index element={<OverviewPage />} />
                     <Route path="chat" element={<ChatPage />} />
                     <Route path="agents" element={<AgentsPage />} />
+                    <Route path="assessment" element={<AssessmentPage />} />
                     <Route path="plugins" element={<PluginsPage />} />
                     <Route path="notebook" element={<MemosPage />} />
                     <Route path="knowledge/*" element={<KnowledgePage />} />

+ 84 - 30
web/services/apiClient.ts

@@ -13,19 +13,56 @@ class ApiClient {
   }
 
   private getAuthHeaders(): Record<string, string> {
-    // V2 API key auth (primary)
-    const apiKey = localStorage.getItem('kb_api_key');
+    const rawApiKey = localStorage.getItem('kb_api_key');
+    const rawToken = localStorage.getItem('authToken') || localStorage.getItem('token');
     const activeTenantId = localStorage.getItem('kb_active_tenant_id');
-    // Legacy JWT token (fallback, kept for compatibility during transition)
-    const token = localStorage.getItem('authToken') || localStorage.getItem('token');
     const language = localStorage.getItem('userLanguage') || 'ja';
-    return {
+
+    // Helper to filter out invalid values
+    const isValid = (val: string | null) => {
+        if (!val) return false;
+        const v = val.trim().toLowerCase();
+        return v !== '' && v !== 'undefined' && v !== 'null' && v !== '[object object]';
+    };
+
+    const apiKey = isValid(rawApiKey) ? rawApiKey : null;
+    const token = isValid(rawToken) ? rawToken : null;
+
+    const headers: Record<string, string> = {
       'Content-Type': 'application/json',
       'x-user-language': language,
-      ...(apiKey && { 'x-api-key': apiKey }),
-      ...(activeTenantId && { 'x-tenant-id': activeTenantId }),
-      ...(token && { Authorization: `Bearer ${token}` }),
     };
+
+    if (apiKey) {
+      if (apiKey.startsWith('kb_')) {
+        headers['x-api-key'] = apiKey;
+      } else {
+        headers['Authorization'] = `Bearer ${apiKey}`;
+      }
+    } else if (token) {
+      headers['Authorization'] = `Bearer ${token}`;
+    }
+
+    // Final fail-safe: Ensure no header is 'undefined' string
+    Object.keys(headers).forEach(key => {
+        if (headers[key]?.toLowerCase().includes('undefined')) {
+            delete headers[key];
+        }
+    });
+
+    if (activeTenantId && isValid(activeTenantId)) {
+      headers['x-tenant-id'] = activeTenantId;
+    }
+
+    // DEBUG: Only log first few chars
+    console.log('[ApiClient] Auth Headers:', {
+        hasApiKey: !!headers['x-api-key'],
+        hasAuth: !!headers['Authorization'],
+        authPreview: headers['Authorization']?.substring(0, 20),
+        tenantId: headers['x-tenant-id']
+    });
+
+    return headers;
   }
 
   // New API call method, returns { data, status }
@@ -35,12 +72,15 @@ class ApiClient {
       headers: this.getAuthHeaders(),
     });
 
+    const data = await response.json();
+    if (response.status === 401) {
+        this.handleUnauthorized();
+        throw new Error(data.message || 'Unauthorized');
+    }
     if (!response.ok) {
-      const errorData = await response.json();
-      throw new Error(errorData.message || 'Request failed');
+        throw new Error(data.message || 'Request failed');
     }
 
-    const data = await response.json();
     return { data, status: response.status };
   }
 
@@ -51,12 +91,15 @@ class ApiClient {
       body: body ? JSON.stringify(body) : undefined,
     });
 
+    const data = await response.json();
+    if (response.status === 401) {
+        this.handleUnauthorized();
+        throw new Error(data.message || 'Unauthorized');
+    }
     if (!response.ok) {
-      const errorData = await response.json();
-      throw new Error(errorData.message || 'Request failed');
+        throw new Error(data.message || 'Request failed');
     }
 
-    const data = await response.json();
     return { data, status: response.status };
   }
 
@@ -67,12 +110,15 @@ class ApiClient {
       body: body ? JSON.stringify(body) : undefined,
     });
 
+    const data = await response.json();
+    if (response.status === 401) {
+        this.handleUnauthorized();
+        throw new Error(data.message || 'Unauthorized');
+    }
     if (!response.ok) {
-      const errorData = await response.json();
-      throw new Error(errorData.message || 'Request failed');
+        throw new Error(data.message || 'Request failed');
     }
 
-    const data = await response.json();
     return { data, status: response.status };
   }
 
@@ -82,25 +128,23 @@ class ApiClient {
       headers: this.getAuthHeaders(),
     });
 
+    const data = await response.json();
+    if (response.status === 401) {
+        this.handleUnauthorized();
+        throw new Error(data.message || 'Unauthorized');
+    }
     if (!response.ok) {
-      const errorData = await response.json();
-      throw new Error(errorData.message || 'Request failed');
+        throw new Error(data.message || 'Request failed');
     }
 
-    const data = await response.json();
     return { data, status: response.status };
   }
 
   // Legacy compatibility method — returns raw Response for streaming and other special cases
   async request(path: string, options: RequestInit = {}): Promise<Response> {
-    const apiKey = localStorage.getItem('kb_api_key');
-    const activeTenantId = localStorage.getItem('kb_active_tenant_id');
-    const token = localStorage.getItem('authToken');
+    const authHeaders = this.getAuthHeaders();
     const headers = new Headers(options.headers);
-
-    if (apiKey) headers.set('x-api-key', apiKey);
-    if (activeTenantId) headers.set('x-tenant-id', activeTenantId);
-    if (token) headers.set('Authorization', `Bearer ${token}`);
+    Object.entries(authHeaders).forEach(([k, v]) => headers.set(k, v));
 
     const language = localStorage.getItem('userLanguage') || 'ja';
     headers.set('x-user-language', language);
@@ -117,14 +161,24 @@ class ApiClient {
     });
 
     if (response.status === 401) {
-      localStorage.removeItem('kb_api_key');
-      localStorage.removeItem('authToken');
-      window.location.href = '/login';
+      this.handleUnauthorized();
       throw new Error('Unauthorized');
     }
 
     return response;
   }
+
+  private handleUnauthorized() {
+    console.warn('[ApiClient] 401 Unauthorized detected. Cleaning up and redirecting to login...');
+    localStorage.removeItem('kb_api_key');
+    localStorage.removeItem('authToken');
+    localStorage.removeItem('token');
+    localStorage.removeItem('kb_active_tenant_id');
+    // Only redirect if we are not already on the login page
+    if (window.location.pathname !== '/login') {
+        window.location.href = '/login';
+    }
+  }
 }
 
 export const apiClient = new ApiClient(API_BASE_URL);

+ 94 - 0
web/services/assessmentService.ts

@@ -0,0 +1,94 @@
+import { apiClient } from './apiClient';
+
+export interface AssessmentSession {
+    id: string;
+    userId: string;
+    knowledgeBaseId: string;
+    threadId: string;
+    status: 'IN_PROGRESS' | 'COMPLETED';
+    finalScore?: number;
+    finalReport?: string;
+    createdAt: string;
+    updatedAt: string;
+    knowledgeBase?: { id: string; name: string };
+    knowledgeGroup?: { id: string; name: string };
+}
+
+export interface AssessmentState {
+    messages: any[];
+    assessmentSessionId: string;
+    knowledgeBaseId: string;
+    questions: any[];
+    currentQuestionIndex: number;
+    shouldFollowUp: boolean;
+    scores: Record<string, number>;
+    feedbackHistory?: any[];
+    status?: 'IN_PROGRESS' | 'COMPLETED';
+    report?: string;
+    finalScore?: number;
+}
+
+export class AssessmentService {
+    async startSession(knowledgeBaseId: string, language: string = 'zh'): Promise<AssessmentSession> {
+        const { data } = await apiClient.post<AssessmentSession>('/assessment/start', { knowledgeBaseId, language });
+        return data;
+    }
+    async submitAnswer(sessionId: string, answer: string, language: string = 'zh'): Promise<AssessmentState> {
+        const { data } = await apiClient.post<AssessmentState>(`/assessment/${sessionId}/answer`, { answer, language });
+        return data;
+    }
+    async getSessionState(sessionId: string): Promise<AssessmentState> {
+        const { data } = await apiClient.get<AssessmentState>(`/assessment/${sessionId}/state`);
+        return data;
+    }
+
+    async getHistory(): Promise<AssessmentSession[]> {
+        const { data } = await apiClient.get<AssessmentSession[]>('/assessment');
+        return data;
+    }
+
+    async *startSessionStream(sessionId: string): AsyncIterableIterator<any> {
+        const response = await apiClient.request(`/assessment/${sessionId}/start-stream`, {
+            method: 'GET',
+        });
+        yield* this.parseStream(response);
+    }
+
+    async *submitAnswerStream(sessionId: string, answer: string, language: string = 'zh'): AsyncIterableIterator<any> {
+        const query = new URLSearchParams({ answer, language }).toString();
+        const response = await apiClient.request(`/assessment/${sessionId}/answer-stream?${query}`, {
+            method: 'GET',
+        });
+        yield* this.parseStream(response);
+    }
+
+    private async *parseStream(response: Response): AsyncIterableIterator<any> {
+        const reader = response.body?.getReader();
+        if (!reader) return;
+
+        const decoder = new TextDecoder();
+        let buffer = '';
+
+        while (true) {
+            const { done, value } = await reader.read();
+            if (done) break;
+
+            buffer += decoder.decode(value, { stream: true });
+            const lines = buffer.split('\n');
+            buffer = lines.pop() || '';
+
+            for (const line of lines) {
+                if (line.startsWith('data: ')) {
+                    try {
+                        const data = JSON.parse(line.substring(6));
+                        yield data;
+                    } catch (e) {
+                        console.error('Failed to parse SSE data:', line);
+                    }
+                }
+            }
+        }
+    }
+}
+
+export const assessmentService = new AssessmentService();

+ 3 - 1
web/src/components/layouts/WorkspaceLayout.tsx

@@ -17,7 +17,8 @@ import {
     Building2,
     ChevronRight,
     Bot,
-    Blocks
+    Blocks,
+    ClipboardCheck
 } from 'lucide-react';
 import { motion, AnimatePresence } from 'framer-motion';
 import { useAuth } from '../../contexts/AuthContext';
@@ -112,6 +113,7 @@ const WorkspaceLayout: React.FC = () => {
                             isActive={location.pathname.startsWith('/plugins')}
                             onClick={handleNavClick}
                         />
+
                         <SidebarItem
                             icon={Database}
                             label={t('navKnowledge')}

+ 26 - 0
web/src/contexts/AuthContext.tsx

@@ -71,6 +71,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     // On mount, restore session
     useEffect(() => {
         const restoreSession = async () => {
+            // Nuclear cleanup for corrupted sessions
+            const authKeys = ['kb_api_key', 'kb_active_tenant_id', 'authToken', 'token'];
+            let corrupted = false;
+            authKeys.forEach(k => {
+                const val = localStorage.getItem(k);
+                // Check if the value is essentially 'empty' or the string 'undefined'/'null'
+                if (val === 'undefined' || val === 'null' || !val) {
+                    if (val !== null && val !== '') {
+                         localStorage.removeItem(k);
+                         corrupted = true;
+                    }
+                }
+            });
+            // ONLY reload if we actually FOUND and REMOVED corrupted 'undefined'/'null' strings
+            // AND the apiKey was one of them (to force a clean state)
+            if (corrupted) {
+                console.warn('[AuthContext] Corrupted auth storage detected and cleaned. Reloading...');
+                // Optimization: Don't reload if we just removed an empty optional key
+                window.location.reload();
+                return;
+            }
+
             if (!apiKey) {
                 setIsLoading(false);
                 return;
@@ -93,6 +115,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
                 } else {
                     localStorage.removeItem('kb_api_key');
                     localStorage.removeItem('kb_active_tenant_id');
+                    localStorage.removeItem('authToken');
+                    localStorage.removeItem('token');
                     setApiKey('');
                     setUser(null);
                 }
@@ -123,6 +147,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     const logout = () => {
         localStorage.removeItem('kb_api_key');
         localStorage.removeItem('kb_active_tenant_id');
+        localStorage.removeItem('authToken');
+        localStorage.removeItem('token');
         setApiKey('');
         setUser(null);
         setAvailableTenants([]);

+ 4 - 0
web/src/pages/auth/Login.tsx

@@ -28,6 +28,10 @@ export default function Login() {
         setIsLoading(true);
 
         try {
+            // Clear any stale tokens
+            localStorage.removeItem('authToken');
+            localStorage.removeItem('token');
+            localStorage.removeItem('kb_api_key');
             if (loginMode === 'apikey') {
                 if (!apiKeyInput.trim()) {
                     setError('API Key is required');

+ 15 - 0
web/src/pages/workspace/AssessmentPage.tsx

@@ -0,0 +1,15 @@
+import React from 'react';
+import { AssessmentView } from '../../../components/views/AssessmentView';
+import { useAuth } from '../../contexts/AuthContext';
+
+export default function AssessmentPage() {
+    const { apiKey, logout, user } = useAuth();
+
+    return (
+        <AssessmentView
+            onLogout={logout}
+            onNavigate={() => { }}
+            isAdmin={user?.role === 'TENANT_ADMIN' || user?.role === 'SUPER_ADMIN'}
+        />
+    );
+}

+ 33 - 1
web/types.ts

@@ -12,7 +12,7 @@ export interface VisionAnalysisResult {
   text: string;              // Extracted text content
   images: ImageDescription[]; // Image descriptions
   layout: string;            // Layout type
-  confidence: number;        
+  confidence: number;
   pageIndex?: number;        // Page number
 }
 
@@ -296,3 +296,35 @@ export const DEFAULT_SETTINGS: AppSettings = {
 };
 
 export const API_BASE_URL = '/api'
+
+// Assessment Types
+export interface AssessmentSession {
+  id: string;
+  status: 'STARTED' | 'COMPLETED' | 'FAILED';
+  masteryLevel?: string;
+  report?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface AssessmentQuestion {
+  id: string;
+  content: string;
+  keyPoints?: string[];
+  isAnswered: boolean;
+  score?: number;
+  feedback?: string;
+}
+
+export interface AssessmentState {
+  session: AssessmentSession;
+  questions: AssessmentQuestion[];
+  currentQuestion?: AssessmentQuestion;
+  isFinished: boolean;
+  messages: Array<{
+    role: 'user' | 'assistant';
+    content: string;
+    timestamp: number;
+  }>;
+}
+

+ 99 - 0
web/utils/translations.ts

@@ -20,6 +20,8 @@ export const translations = {
     confirm: "确认",
     cancel: "Cancel",
     confirmTitle: "确认操作",
+    defaultTenant: "默认租户",
+    selectOrganization: "选择组织",
     confirmDeleteGroup: "Confirm要删除分组 \"$1\" 吗?",
 
     sidebarTitle: "索引与聊天配置",
@@ -386,6 +388,7 @@ export const translations = {
     navKnowledgeGroups: "知识组",
     navNotebook: "笔记本",
     navAgent: "智能体",
+    navAssessment: "人才测评",
     navPlugin: "插件",
     notebookDesc: "记录您的个人想法和研究笔记。",
     newNote: "新建笔记",
@@ -701,6 +704,36 @@ export const translations = {
     "2d": "2d",
     Authorization: "Authorization",
     a: "a",
+    assessmentTitle: "人才测评",
+    assessmentDesc: "基于知识库对人员进行专业能力评估",
+    recentAssessments: "最近的测评",
+    inProgress: "进行中",
+    startProfessionalEvaluation: "开始专业评估",
+    aiPoweredAnalysis: "AI 驱动分析",
+    masteryScoring: "掌握度评分",
+    readyForAssessment: "准备好测评了吗?",
+    readyForAssessmentDesc: "选择一个知识库分组,开始您的评估之旅。",
+    liveFeedback: "实时反馈",
+    currentScore: "当前得分",
+    aiExplanation: "AI 解析",
+    masteryProgress: "掌握进度",
+    trackedInRealTime: "实时跟踪",
+    submitAnswerToSeeFeedback: "提交您的回答以查看实时评分和反馈。",
+    assessmentGuide: "测评指南",
+    assessmentGuideDesc: "重点在于简洁明了,同时涵盖所有技术要点。",
+    assessmentResultsAvailable: "评估结果已生成",
+    precisionScore: "精确度评分",
+    verified: "已验证",
+    fail: "未通过",
+    comprehensiveMasteryReport: "综合掌握度报告",
+    newAssessmentSession: "开始新测评",
+    downloadPdfReport: "下载 PDF 报告",
+    knowledgeCoverage: "知识覆盖率",
+    level: "等级",
+    questionProgress: "第 $1 题(共 $2 题)",
+    initializingQuestion: "正在准备第 $1 题...",
+    aiIsProcessing: "AI 正在处理...",
+    typeAnswerPlaceholder: "在此输入您的回答...",
     agentTitle: "智能体中心",
     agentDesc: "管理和运行您的 AI 助手,协助完成复杂任务。",
     createAgent: "创建智能体",
@@ -803,6 +836,8 @@ export const translations = {
     confirm: "Confirm",
     cancel: "Cancel",
     confirmTitle: "Confirm Action",
+    defaultTenant: "Default Tenant",
+    selectOrganization: "Select Organization",
     confirmDeleteGroup: "Are you sure you want to delete group \"$1\"?",
 
     systemConfiguration: "System Configuration",
@@ -1246,6 +1281,7 @@ export const translations = {
     navKnowledgeGroups: "Knowledge Groups",
     navNotebook: "Notebook",
     navAgent: "Agents",
+    navAssessment: "Assessment",
     navPlugin: "Plugins",
     notebookDesc: "Capture your personal thoughts and research notes.",
     newNote: "New Note",
@@ -1493,6 +1529,36 @@ export const translations = {
     "2d": "2d",
     Authorization: "Authorization",
     a: "a",
+    assessmentTitle: "Talent Assessment",
+    assessmentDesc: "AI-powered evaluation based on knowledge base",
+    recentAssessments: "Recent Assessments",
+    inProgress: "In Progress",
+    startProfessionalEvaluation: "Start Professional Evaluation",
+    aiPoweredAnalysis: "AI-Powered Analysis",
+    masteryScoring: "Mastery Scoring",
+    readyForAssessment: "Ready for Assessment?",
+    readyForAssessmentDesc: "Select a knowledge group and start your evaluation journey.",
+    liveFeedback: "Live Feedback",
+    currentScore: "Current Score",
+    aiExplanation: "AI Explanation",
+    masteryProgress: "Mastery Progress",
+    trackedInRealTime: "Tracked in real-time",
+    submitAnswerToSeeFeedback: "Submit your answer to see real-time scoring & feedback.",
+    assessmentGuide: "Assessment Guide",
+    assessmentGuideDesc: "Focus on being concise while covering all technical key points.",
+    assessmentResultsAvailable: "Assessment Results Available",
+    precisionScore: "Precision Score",
+    verified: "Verified",
+    fail: "Fail",
+    comprehensiveMasteryReport: "Comprehensive Mastery Report",
+    newAssessmentSession: "New Assessment Session",
+    downloadPdfReport: "Download PDF Report",
+    knowledgeCoverage: "Knowledge Coverage",
+    level: "Level",
+    questionProgress: "Question $1 of $2",
+    initializingQuestion: "Initializing Question $1...",
+    aiIsProcessing: "AI is processing...",
+    typeAnswerPlaceholder: "Type your answer here...",
     agentTitle: "Agent Center",
     agentDesc: "Manage and run your AI assistants to help with complex tasks.",
     createAgent: "Create Agent",
@@ -1605,6 +1671,8 @@ export const translations = {
     confirm: "確認",
     cancel: "キャンセル",
     confirmTitle: "操作の確認",
+    defaultTenant: "デフォルトテナント",
+    selectOrganization: "組織を選択",
     confirmDeleteGroup: "グループ \"$1\" を削除してもよろしいですか?",
 
     sidebarTitle: "索引とチャットの設定",
@@ -1998,6 +2066,37 @@ export const translations = {
     navKnowledgeGroups: "ナレッジグループ",
     navNotebook: "ノートブック",
     navAgent: "エージェント",
+    navAssessment: "アセスメント",
+    assessmentTitle: "人材アセスメント",
+    assessmentDesc: "ナレッジベースに基づくAI駆動型スキル評価",
+    recentAssessments: "最近のアセスメント",
+    inProgress: "進行中",
+    startProfessionalEvaluation: "プロフェッショナル評価を開始",
+    aiPoweredAnalysis: "AI駆動分析",
+    masteryScoring: "習熟度スコアリング",
+    readyForAssessment: "アセスメントの準備はできましたか?",
+    readyForAssessmentDesc: "ナレッジグループを選択して、評価の旅を始めましょう。",
+    liveFeedback: "リアルタイムフィードバック",
+    currentScore: "現在のスコア",
+    aiExplanation: "AI解説",
+    masteryProgress: "習熟度の進捗",
+    trackedInRealTime: "リアルタイムで追跡",
+    submitAnswerToSeeFeedback: "回答を送信して、リアルタイムのスコアリングとフィードバックを確認してください。",
+    assessmentGuide: "アセスメントガイド",
+    assessmentGuideDesc: "すべての技術的なキーポイントをカバーしながら、簡潔にまとめることに集中してください。",
+    assessmentResultsAvailable: "アセスメント結果が生成されました",
+    precisionScore: "精度スコア",
+    verified: "確認済み",
+    fail: "不合格",
+    comprehensiveMasteryReport: "総合習熟度レポート",
+    newAssessmentSession: "新しいアセスメントセッション",
+    downloadPdfReport: "PDFレポートをダウンロード",
+    knowledgeCoverage: "知識カバレッジ",
+    level: "レベル",
+    questionProgress: "問題 $1 / $2",
+    initializingQuestion: "問題 $1 を準備中...",
+    aiIsProcessing: "AIが処理中...",
+    typeAnswerPlaceholder: "ここに回答を入力してください...",
     navPlugin: "プラグイン",
     navCrawler: "リソース取得",
     expandMenu: "メニューを展開",

+ 483 - 28
yarn.lock

@@ -412,6 +412,22 @@
   resolved "https://registry.npmmirror.com/@colors/colors/-/colors-1.5.0.tgz"
   integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==
 
+"@cspotcode/source-map-support@^0.8.0":
+  version "0.8.1"
+  resolved "https://registry.npmmirror.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz"
+  integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==
+  dependencies:
+    "@jridgewell/trace-mapping" "0.3.9"
+
+"@elastic/elasticsearch@^9.2.0":
+  version "9.2.0"
+  resolved "https://registry.npmmirror.com/@elastic/elasticsearch/-/elasticsearch-9.2.0.tgz"
+  integrity sha512-M59qmMOZOk8pTcI9Ns2ow18PlyMbYrpcXqYwkChjiyXSmmqoCTvFXkC2bGQLxrrQkXaPbYR7aZqWD9b5F1405A==
+  dependencies:
+    "@elastic/transport" "^9.2.0"
+    apache-arrow "18.x - 21.x"
+    tslib "^2.4.0"
+
 "@elastic/transport@^9.2.0":
   version "9.2.3"
   resolved "https://registry.npmmirror.com/@elastic/transport/-/transport-9.2.3.tgz"
@@ -466,7 +482,7 @@
   dependencies:
     "@types/json-schema" "^7.0.15"
 
-"@eslint/eslintrc@^3.3.1":
+"@eslint/eslintrc@^3.2.0", "@eslint/eslintrc@^3.3.1":
   version "3.3.3"
   resolved "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz"
   integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==
@@ -481,7 +497,7 @@
     minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
-"@eslint/js@9.39.1":
+"@eslint/js@^9.18.0", "@eslint/js@9.39.1":
   version "9.39.1"
   resolved "https://registry.npmmirror.com/@eslint/js/-/js-9.39.1.tgz"
   integrity sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==
@@ -1107,7 +1123,7 @@
     "@jridgewell/resolve-uri" "^3.0.3"
     "@jridgewell/sourcemap-codec" "^1.4.10"
 
-"@langchain/core@^1.0.0", "@langchain/core@^1.0.1", "@langchain/core@1.1.4":
+"@langchain/core@^1.0.0", "@langchain/core@^1.0.1", "@langchain/core@^1.1.5", "@langchain/core@1.1.4":
   version "1.1.5"
   resolved "https://registry.npmjs.org/@langchain/core/-/core-1.1.5.tgz"
   integrity sha512-m+EhnHhaCnVPJt4HRmhElBN3ZBvQGfXL/hm80UV3EHNUPNUCHi6q6de7dqrw/l4oTvmX0nC08Fm2ta1U59o1bQ==
@@ -1139,7 +1155,7 @@
     p-retry "4"
     uuid "^9.0.0"
 
-"@langchain/langgraph@^1.0.0":
+"@langchain/langgraph@^1.0.0", "@langchain/langgraph@^1.0.4":
   version "1.0.4"
   resolved "https://registry.npmmirror.com/@langchain/langgraph/-/langgraph-1.0.4.tgz"
   integrity sha512-EYLyN/uv1ubMBd3RN/y+eAxY0FJWKrnzRw8HuDJdmDcyomgV9btyHK2zDN70sO3QDDuAU9voLNNUZeFBQkBYMQ==
@@ -1148,6 +1164,22 @@
     "@langchain/langgraph-sdk" "~1.2.0"
     uuid "^10.0.0"
 
+"@langchain/openai@^1.1.3":
+  version "1.1.3"
+  resolved "https://registry.npmmirror.com/@langchain/openai/-/openai-1.1.3.tgz"
+  integrity sha512-p+xR+4HRms5Ozjf5miC6U2AYRyNVSTdO7AMBkMYs1Tp6DWHBd+mQ72H8Ogd2dKrPuS5UDJ5dbpI1fS+OrTbgQQ==
+  dependencies:
+    js-tiktoken "^1.0.12"
+    openai "^6.9.0"
+    zod "^3.25.76 || ^4"
+
+"@langchain/textsplitters@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.npmmirror.com/@langchain/textsplitters/-/textsplitters-1.0.1.tgz"
+  integrity sha512-rheJlB01iVtrOUzttscutRgLybPH9qR79EyzBEbf1u97ljWyuxQfCwIWK+SjoQTM9O8M7GGLLRBSYE26Jmcoww==
+  dependencies:
+    js-tiktoken "^1.0.12"
+
 "@lukeed/csprng@^1.0.0":
   version "1.1.0"
   resolved "https://registry.npmmirror.com/@lukeed/csprng/-/csprng-1.1.0.tgz"
@@ -1187,6 +1219,30 @@
     "@napi-rs/canvas-win32-arm64-msvc" "0.1.88"
     "@napi-rs/canvas-win32-x64-msvc" "0.1.88"
 
+"@nestjs/cli@^11.0.0":
+  version "11.0.14"
+  resolved "https://registry.npmmirror.com/@nestjs/cli/-/cli-11.0.14.tgz"
+  integrity sha512-YwP03zb5VETTwelXU+AIzMVbEZKk/uxJL+z9pw0mdG9ogAtqZ6/mpmIM4nEq/NU8D0a7CBRLcMYUmWW/55pfqw==
+  dependencies:
+    "@angular-devkit/core" "19.2.19"
+    "@angular-devkit/schematics" "19.2.19"
+    "@angular-devkit/schematics-cli" "19.2.19"
+    "@inquirer/prompts" "7.10.1"
+    "@nestjs/schematics" "^11.0.1"
+    ansis "4.2.0"
+    chokidar "4.0.3"
+    cli-table3 "0.6.5"
+    commander "4.1.1"
+    fork-ts-checker-webpack-plugin "9.1.0"
+    glob "13.0.0"
+    node-emoji "1.11.0"
+    ora "5.4.1"
+    tsconfig-paths "4.2.0"
+    tsconfig-paths-webpack-plugin "4.2.0"
+    typescript "5.9.3"
+    webpack "5.103.0"
+    webpack-node-externals "3.0.0"
+
 "@nestjs/common@^10.0.0 || ^11.0.0", "@nestjs/common@^11.0.0", "@nestjs/common@^11.0.1", "@nestjs/common@^11.0.2", "@nestjs/common@^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0":
   version "11.1.9"
   resolved "https://registry.npmmirror.com/@nestjs/common/-/common-11.1.9.tgz"
@@ -1198,6 +1254,15 @@
     tslib "2.8.1"
     uid "2.0.2"
 
+"@nestjs/config@^4.0.2":
+  version "4.0.2"
+  resolved "https://registry.npmmirror.com/@nestjs/config/-/config-4.0.2.tgz"
+  integrity sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==
+  dependencies:
+    dotenv "16.4.7"
+    dotenv-expand "12.0.1"
+    lodash "4.17.21"
+
 "@nestjs/core@^10.0.0 || ^11.0.0", "@nestjs/core@^11.0.0", "@nestjs/core@^11.0.1", "@nestjs/core@^11.0.2":
   version "11.1.9"
   resolved "https://registry.npmmirror.com/@nestjs/core/-/core-11.1.9.tgz"
@@ -1210,12 +1275,25 @@
     tslib "2.8.1"
     uid "2.0.2"
 
-"@nestjs/mapped-types@2.1.0":
+"@nestjs/jwt@^11.0.2":
+  version "11.0.2"
+  resolved "https://registry.npmmirror.com/@nestjs/jwt/-/jwt-11.0.2.tgz"
+  integrity sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==
+  dependencies:
+    "@types/jsonwebtoken" "9.0.10"
+    jsonwebtoken "9.0.3"
+
+"@nestjs/mapped-types@^2.1.0", "@nestjs/mapped-types@2.1.0":
   version "2.1.0"
   resolved "https://registry.npmmirror.com/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz"
   integrity sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==
 
-"@nestjs/platform-express@^11.0.0":
+"@nestjs/passport@^11.0.5":
+  version "11.0.5"
+  resolved "https://registry.npmmirror.com/@nestjs/passport/-/passport-11.0.5.tgz"
+  integrity sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==
+
+"@nestjs/platform-express@^11.0.0", "@nestjs/platform-express@^11.0.1":
   version "11.1.9"
   resolved "https://registry.npmmirror.com/@nestjs/platform-express/-/platform-express-11.1.9.tgz"
   integrity sha512-GVd3+0lO0mJq2m1kl9hDDnVrX3Nd4oH3oDfklz0pZEVEVS0KVSp63ufHq2Lu9cyPdSBuelJr9iPm2QQ1yX+Kmw==
@@ -1226,7 +1304,14 @@
     path-to-regexp "8.3.0"
     tslib "2.8.1"
 
-"@nestjs/schematics@^11.0.1":
+"@nestjs/schedule@^6.1.0":
+  version "6.1.0"
+  resolved "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.0.tgz"
+  integrity sha512-W25Ydc933Gzb1/oo7+bWzzDiOissE+h/dhIAPugA39b9MuIzBbLybuXpc1AjoQLczO3v0ldmxaffVl87W0uqoQ==
+  dependencies:
+    cron "4.3.5"
+
+"@nestjs/schematics@^11.0.0", "@nestjs/schematics@^11.0.1":
   version "11.0.9"
   resolved "https://registry.npmmirror.com/@nestjs/schematics/-/schematics-11.0.9.tgz"
   integrity sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==
@@ -1237,6 +1322,37 @@
     jsonc-parser "3.3.1"
     pluralize "8.0.0"
 
+"@nestjs/serve-static@^5.0.4":
+  version "5.0.4"
+  resolved "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-5.0.4.tgz"
+  integrity sha512-3kO1M9D3vsPyWPFardxIjUYeuolS58PnhCoBTkS7t3BrdZFZCKHnBZ15js+UOzOR2Q6HmD7ssGjLd0DVYVdvOw==
+  dependencies:
+    path-to-regexp "8.3.0"
+
+"@nestjs/swagger@^11.2.6":
+  version "11.2.6"
+  resolved "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.6.tgz"
+  integrity sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==
+  dependencies:
+    "@microsoft/tsdoc" "0.16.0"
+    "@nestjs/mapped-types" "2.1.0"
+    js-yaml "4.1.1"
+    lodash "4.17.23"
+    path-to-regexp "8.3.0"
+    swagger-ui-dist "5.31.0"
+
+"@nestjs/testing@^11.0.1":
+  version "11.1.9"
+  resolved "https://registry.npmmirror.com/@nestjs/testing/-/testing-11.1.9.tgz"
+  integrity sha512-UFxerBDdb0RUNxQNj25pvkvNE7/vxKhXYWBt3QuwBFnYISzRIzhVlyIqLfoV5YI3zV0m0Nn4QAn1KM0zzwfEng==
+  dependencies:
+    tslib "2.8.1"
+
+"@nestjs/typeorm@^11.0.0":
+  version "11.0.0"
+  resolved "https://registry.npmmirror.com/@nestjs/typeorm/-/typeorm-11.0.0.tgz"
+  integrity sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==
+
 "@noble/hashes@^1.1.5":
   version "1.8.0"
   resolved "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz"
@@ -1410,6 +1526,26 @@
   resolved "https://registry.npmmirror.com/@tokenizer/token/-/token-0.3.0.tgz"
   integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==
 
+"@tsconfig/node10@^1.0.7":
+  version "1.0.12"
+  resolved "https://registry.npmmirror.com/@tsconfig/node10/-/node10-1.0.12.tgz"
+  integrity sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==
+
+"@tsconfig/node12@^1.0.7":
+  version "1.0.11"
+  resolved "https://registry.npmmirror.com/@tsconfig/node12/-/node12-1.0.11.tgz"
+  integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==
+
+"@tsconfig/node14@^1.0.0":
+  version "1.0.3"
+  resolved "https://registry.npmmirror.com/@tsconfig/node14/-/node14-1.0.3.tgz"
+  integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==
+
+"@tsconfig/node16@^1.0.2":
+  version "1.0.4"
+  resolved "https://registry.npmmirror.com/@tsconfig/node16/-/node16-1.0.4.tgz"
+  integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==
+
 "@types/babel__core@^7.20.5":
   version "7.20.5"
   resolved "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz"
@@ -1443,6 +1579,20 @@
   dependencies:
     "@babel/types" "^7.28.2"
 
+"@types/bcrypt@^6.0.0":
+  version "6.0.0"
+  resolved "https://registry.npmmirror.com/@types/bcrypt/-/bcrypt-6.0.0.tgz"
+  integrity sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/better-sqlite3@^7.6.13":
+  version "7.6.13"
+  resolved "https://registry.npmmirror.com/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz"
+  integrity sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==
+  dependencies:
+    "@types/node" "*"
+
 "@types/body-parser@*":
   version "1.19.6"
   resolved "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz"
@@ -1473,6 +1623,14 @@
   resolved "https://registry.npmmirror.com/@types/cookiejar/-/cookiejar-2.1.5.tgz"
   integrity sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==
 
+"@types/cron@^2.0.1":
+  version "2.0.1"
+  resolved "https://registry.npmjs.org/@types/cron/-/cron-2.0.1.tgz"
+  integrity sha512-WHa/1rtNtD2Q/H0+YTTZoty+/5rcE66iAFX2IY+JuUoOACsevYyFkSYu/2vdw+G5LrmO7Lxowrqm0av4k3qWNQ==
+  dependencies:
+    "@types/luxon" "*"
+    "@types/node" "*"
+
 "@types/d3-array@*":
   version "3.2.2"
   resolved "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz"
@@ -1728,7 +1886,7 @@
     "@types/range-parser" "*"
     "@types/send" "*"
 
-"@types/express@*":
+"@types/express@*", "@types/express@^5.0.0":
   version "5.0.6"
   resolved "https://registry.npmmirror.com/@types/express/-/express-5.0.6.tgz"
   integrity sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==
@@ -1773,6 +1931,14 @@
   dependencies:
     "@types/istanbul-lib-report" "*"
 
+"@types/jest@^30.0.0":
+  version "30.0.0"
+  resolved "https://registry.npmmirror.com/@types/jest/-/jest-30.0.0.tgz"
+  integrity sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==
+  dependencies:
+    expect "^30.0.0"
+    pretty-format "^30.0.0"
+
 "@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
   version "7.0.15"
   resolved "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz"
@@ -1813,6 +1979,13 @@
   resolved "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz"
   integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
 
+"@types/multer@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz"
+  integrity sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==
+  dependencies:
+    "@types/express" "*"
+
 "@types/node@*", "@types/node@^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node@>=18":
   version "25.0.0"
   resolved "https://registry.npmmirror.com/@types/node/-/node-25.0.0.tgz"
@@ -1820,6 +1993,13 @@
   dependencies:
     undici-types "~7.16.0"
 
+"@types/node@^22.10.7":
+  version "22.19.2"
+  resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.2.tgz"
+  integrity sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==
+  dependencies:
+    undici-types "~6.21.0"
+
 "@types/node@^22.14.0":
   version "22.19.2"
   resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.2.tgz"
@@ -1834,6 +2014,23 @@
   dependencies:
     undici-types "~7.16.0"
 
+"@types/passport-jwt@^4.0.1":
+  version "4.0.1"
+  resolved "https://registry.npmmirror.com/@types/passport-jwt/-/passport-jwt-4.0.1.tgz"
+  integrity sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==
+  dependencies:
+    "@types/jsonwebtoken" "*"
+    "@types/passport-strategy" "*"
+
+"@types/passport-local@^1.0.38":
+  version "1.0.38"
+  resolved "https://registry.npmmirror.com/@types/passport-local/-/passport-local-1.0.38.tgz"
+  integrity sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==
+  dependencies:
+    "@types/express" "*"
+    "@types/passport" "*"
+    "@types/passport-strategy" "*"
+
 "@types/passport-strategy@*":
   version "0.2.38"
   resolved "https://registry.npmmirror.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz"
@@ -1913,6 +2110,14 @@
     "@types/node" "*"
     form-data "^4.0.0"
 
+"@types/supertest@^6.0.2":
+  version "6.0.3"
+  resolved "https://registry.npmmirror.com/@types/supertest/-/supertest-6.0.3.tgz"
+  integrity sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==
+  dependencies:
+    "@types/methods" "^1.1.4"
+    "@types/superagent" "^8.1.0"
+
 "@types/trusted-types@^2.0.7":
   version "2.0.7"
   resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz"
@@ -2217,7 +2422,14 @@ acorn-jsx@^5.3.2:
   resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
-"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.11.0, acorn@^8.14.0, acorn@^8.15.0:
+acorn-walk@^8.1.1:
+  version "8.3.4"
+  resolved "https://registry.npmmirror.com/acorn-walk/-/acorn-walk-8.3.4.tgz"
+  integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==
+  dependencies:
+    acorn "^8.11.0"
+
+"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.11.0, acorn@^8.14.0, acorn@^8.15.0, acorn@^8.4.1:
   version "8.15.0"
   resolved "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz"
   integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
@@ -2370,6 +2582,11 @@ append-field@^1.0.0:
   resolved "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz"
   integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==
 
+arg@^4.1.0:
+  version "4.1.3"
+  resolved "https://registry.npmmirror.com/arg/-/arg-4.1.3.tgz"
+  integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
+
 argparse@^1.0.7:
   version "1.0.10"
   resolved "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz"
@@ -2409,6 +2626,15 @@ available-typed-arrays@^1.0.7:
   dependencies:
     possible-typed-array-names "^1.0.0"
 
+axios@^1.13.2:
+  version "1.13.2"
+  resolved "https://registry.npmmirror.com/axios/-/axios-1.13.2.tgz"
+  integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==
+  dependencies:
+    follow-redirects "^1.15.6"
+    form-data "^4.0.4"
+    proxy-from-env "^1.1.0"
+
 "babel-jest@^29.0.0 || ^30.0.0", babel-jest@30.2.0:
   version "30.2.0"
   resolved "https://registry.npmmirror.com/babel-jest/-/babel-jest-30.2.0.tgz"
@@ -2494,7 +2720,15 @@ baseline-browser-mapping@^2.9.0:
   resolved "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.6.tgz"
   integrity sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==
 
-"better-sqlite3@^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0":
+bcrypt@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.npmmirror.com/bcrypt/-/bcrypt-6.0.0.tgz"
+  integrity sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==
+  dependencies:
+    node-addon-api "^8.3.0"
+    node-gyp-build "^4.8.4"
+
+better-sqlite3@^12.5.0, "better-sqlite3@^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0":
   version "12.5.0"
   resolved "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.5.0.tgz"
   integrity sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==
@@ -2770,12 +3004,12 @@ cjs-module-lexer@^2.1.0:
   resolved "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz"
   integrity sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==
 
-class-transformer@*, "class-transformer@^0.4.0 || ^0.5.0", class-transformer@>=0.4.1:
+class-transformer@*, "class-transformer@^0.4.0 || ^0.5.0", class-transformer@^0.5.1, class-transformer@>=0.4.1:
   version "0.5.1"
   resolved "https://registry.npmmirror.com/class-transformer/-/class-transformer-0.5.1.tgz"
   integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==
 
-class-validator@*, "class-validator@^0.13.0 || ^0.14.0", class-validator@>=0.13.2:
+class-validator@*, "class-validator@^0.13.0 || ^0.14.0", class-validator@^0.14.3, class-validator@>=0.13.2:
   version "0.14.3"
   resolved "https://registry.npmmirror.com/class-validator/-/class-validator-0.14.3.tgz"
   integrity sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==
@@ -3036,6 +3270,11 @@ cosmiconfig@^8.2.0:
     parse-json "^5.2.0"
     path-type "^4.0.0"
 
+create-require@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.npmmirror.com/create-require/-/create-require-1.1.1.tgz"
+  integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
+
 cron@4.3.5:
   version "4.3.5"
   resolved "https://registry.npmjs.org/cron/-/cron-4.3.5.tgz"
@@ -3494,6 +3733,11 @@ dezalgo@^1.0.4:
     asap "^2.0.0"
     wrappy "1"
 
+diff@^4.0.1:
+  version "4.0.2"
+  resolved "https://registry.npmmirror.com/diff/-/diff-4.0.2.tgz"
+  integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
+
 dompurify@^3.2.5:
   version "3.3.1"
   resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz"
@@ -3513,6 +3757,11 @@ dotenv@^16.4.5, dotenv@^16.4.7:
   resolved "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz"
   integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==
 
+dotenv@^17.2.3:
+  version "17.2.3"
+  resolved "https://registry.npmmirror.com/dotenv/-/dotenv-17.2.3.tgz"
+  integrity sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==
+
 dotenv@16.4.7:
   version "16.4.7"
   resolved "https://registry.npmmirror.com/dotenv/-/dotenv-16.4.7.tgz"
@@ -3685,11 +3934,19 @@ escape-string-regexp@^5.0.0:
   resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz"
   integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
 
-"eslint-config-prettier@>= 7.0.0 <10.0.0 || >=10.1.0":
+eslint-config-prettier@^10.0.1, "eslint-config-prettier@>= 7.0.0 <10.0.0 || >=10.1.0":
   version "10.1.8"
   resolved "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz"
   integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
 
+eslint-plugin-prettier@^5.2.2:
+  version "5.5.4"
+  resolved "https://registry.npmmirror.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz"
+  integrity sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==
+  dependencies:
+    prettier-linter-helpers "^1.0.0"
+    synckit "^0.11.7"
+
 eslint-scope@^8.4.0:
   version "8.4.0"
   resolved "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz"
@@ -3716,7 +3973,7 @@ eslint-visitor-keys@^4.2.1:
   resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz"
   integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
 
-"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.57.0 || ^9.0.0", eslint@>=7.0.0, eslint@>=8.0.0:
+"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.57.0 || ^9.0.0", eslint@^9.18.0, eslint@>=7.0.0, eslint@>=8.0.0:
   version "9.39.1"
   resolved "https://registry.npmmirror.com/eslint/-/eslint-9.39.1.tgz"
   integrity sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==
@@ -4074,7 +4331,7 @@ fork-ts-checker-webpack-plugin@9.1.0:
     semver "^7.3.5"
     tapable "^2.2.1"
 
-form-data@^4.0.0, form-data@^4.0.4:
+form-data@^4.0.0, form-data@^4.0.4, form-data@^4.0.5:
   version "4.0.5"
   resolved "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz"
   integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==
@@ -4277,6 +4534,11 @@ globals@^14.0.0:
   resolved "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz"
   integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
 
+globals@^16.0.0:
+  version "16.5.0"
+  resolved "https://registry.npmmirror.com/globals/-/globals-16.5.0.tgz"
+  integrity sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==
+
 google-auth-library@^10.3.0:
   version "10.5.0"
   resolved "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-10.5.0.tgz"
@@ -5137,7 +5399,7 @@ jest-worker@30.2.0:
     merge-stream "^2.0.0"
     supports-color "^8.1.1"
 
-"jest@^29.0.0 || ^30.0.0":
+"jest@^29.0.0 || ^30.0.0", jest@^30.0.0:
   version "30.2.0"
   resolved "https://registry.npmmirror.com/jest/-/jest-30.2.0.tgz"
   integrity sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==
@@ -5292,6 +5554,17 @@ khroma@^2.1.0:
   resolved "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz"
   integrity sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==
 
+langchain@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.npmmirror.com/langchain/-/langchain-1.1.5.tgz"
+  integrity sha512-tmJHdCsi4AQLEWDeTm9QTWgdwYgIaA4kfp14KFw6e1sUPxjsoHqdFqdf1ZJZxhs1h/n+hpIr3NBfGNBQnWxWEQ==
+  dependencies:
+    "@langchain/langgraph" "^1.0.0"
+    "@langchain/langgraph-checkpoint" "^1.0.0"
+    langsmith "~0.3.74"
+    uuid "^10.0.0"
+    zod "^3.25.76 || ^4"
+
 langium@3.3.1:
   version "3.3.1"
   resolved "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz"
@@ -5528,7 +5801,7 @@ make-dir@^4.0.0:
   dependencies:
     semver "^7.5.3"
 
-make-error@^1.3.6:
+make-error@^1.1.1, make-error@^1.3.6:
   version "1.3.6"
   resolved "https://registry.npmmirror.com/make-error/-/make-error-1.3.6.tgz"
   integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
@@ -6306,6 +6579,15 @@ node-domexception@^1.0.0:
   resolved "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz"
   integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
 
+node-edge-tts@^1.2.8:
+  version "1.2.8"
+  resolved "https://registry.npmjs.org/node-edge-tts/-/node-edge-tts-1.2.8.tgz"
+  integrity sha512-Yj290EUQJeO/n/LVoaFF1cxULB+1sROLm6w84o8zkwK7cdYqsMhxmhpac/d88AdbrjHjR+2zxEijvhNTtluGxQ==
+  dependencies:
+    https-proxy-agent "^7.0.1"
+    ws "^8.13.0"
+    yargs "^17.7.2"
+
 node-emoji@1.11.0:
   version "1.11.0"
   resolved "https://registry.npmmirror.com/node-emoji/-/node-emoji-1.11.0.tgz"
@@ -6542,12 +6824,27 @@ parseurl@^1.3.3:
   resolved "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz"
   integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
 
+passport-jwt@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.npmmirror.com/passport-jwt/-/passport-jwt-4.0.1.tgz"
+  integrity sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==
+  dependencies:
+    jsonwebtoken "^9.0.0"
+    passport-strategy "^1.0.0"
+
+passport-local@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.npmmirror.com/passport-local/-/passport-local-1.0.0.tgz"
+  integrity sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==
+  dependencies:
+    passport-strategy "1.x.x"
+
 passport-strategy@^1.0.0, passport-strategy@1.x.x:
   version "1.0.0"
   resolved "https://registry.npmmirror.com/passport-strategy/-/passport-strategy-1.0.0.tgz"
   integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==
 
-"passport@^0.5.0 || ^0.6.0 || ^0.7.0":
+"passport@^0.5.0 || ^0.6.0 || ^0.7.0", passport@^0.7.0:
   version "0.7.0"
   resolved "https://registry.npmmirror.com/passport/-/passport-0.7.0.tgz"
   integrity sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==
@@ -6612,6 +6909,21 @@ pause@0.0.1:
   resolved "https://registry.npmmirror.com/pause/-/pause-0.0.1.tgz"
   integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==
 
+pdf-lib@^1.17.1:
+  version "1.17.1"
+  resolved "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz"
+  integrity sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==
+  dependencies:
+    "@pdf-lib/standard-fonts" "^1.0.0"
+    "@pdf-lib/upng" "^1.0.1"
+    pako "^1.0.11"
+    tslib "^1.11.1"
+
+pdf2image@^1.2.3:
+  version "1.2.3"
+  resolved "https://registry.npmmirror.com/pdf2image/-/pdf2image-1.2.3.tgz"
+  integrity sha512-jBSu3Vt2TZsI+fdGRDtkezduzyvuzayy2HPAYVf1vX6tHLAl/HBcXRo4/lD/NDMoa+XsMo5ZeUYF86Lkc+CtLQ==
+
 pdfjs-dist@^4.10.38:
   version "4.10.38"
   resolved "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz"
@@ -6740,7 +7052,7 @@ prettier-linter-helpers@^1.0.0:
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@>=3.0.0:
+prettier@^3.4.2, prettier@>=3.0.0:
   version "3.7.4"
   resolved "https://registry.npmmirror.com/prettier/-/prettier-3.7.4.tgz"
   integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==
@@ -6914,7 +7226,7 @@ readdirp@^4.0.1:
   resolved "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz"
   integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
 
-"reflect-metadata@^0.1.12 || ^0.2.0", "reflect-metadata@^0.1.13 || ^0.2.0", "reflect-metadata@^0.1.14 || ^0.2.0":
+"reflect-metadata@^0.1.12 || ^0.2.0", "reflect-metadata@^0.1.13 || ^0.2.0", "reflect-metadata@^0.1.14 || ^0.2.0", reflect-metadata@^0.2.2:
   version "0.2.2"
   resolved "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz"
   integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==
@@ -7206,7 +7518,42 @@ serve-static@^2.2.0:
     send "^1.2.0"
 
 "server@file:D:\\workspace\\AuraK\\server":
-  resolved "server"
+  version "0.0.1"
+  resolved "file:server"
+  dependencies:
+    "@elastic/elasticsearch" "^9.2.0"
+    "@langchain/core" "^1.1.5"
+    "@langchain/openai" "^1.1.3"
+    "@langchain/textsplitters" "^1.0.1"
+    "@nestjs/common" "^11.0.1"
+    "@nestjs/config" "^4.0.2"
+    "@nestjs/core" "^11.0.1"
+    "@nestjs/jwt" "^11.0.2"
+    "@nestjs/mapped-types" "^2.1.0"
+    "@nestjs/passport" "^11.0.5"
+    "@nestjs/platform-express" "^11.0.1"
+    "@nestjs/schedule" "^6.1.0"
+    "@nestjs/serve-static" "^5.0.4"
+    "@nestjs/swagger" "^11.2.6"
+    "@nestjs/typeorm" "^11.0.0"
+    "@types/cron" "^2.0.1"
+    axios "^1.13.2"
+    bcrypt "^6.0.0"
+    better-sqlite3 "^12.5.0"
+    class-transformer "^0.5.1"
+    class-validator "^0.14.3"
+    dotenv "^17.2.3"
+    form-data "^4.0.5"
+    langchain "^1.1.5"
+    node-edge-tts "^1.2.8"
+    passport "^0.7.0"
+    passport-jwt "^4.0.1"
+    pdf-lib "^1.17.1"
+    pdf2image "^1.2.3"
+    reflect-metadata "^0.2.2"
+    rxjs "^7.8.1"
+    tesseract.js "^7.0.0"
+    typeorm "0.3.26"
 
 set-cookie-parser@^2.6.0:
   version "2.7.2"
@@ -7340,7 +7687,7 @@ source-map-js@^1.2.1:
   resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz"
   integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
 
-source-map-support@~0.5.20:
+source-map-support@^0.5.21, source-map-support@~0.5.20:
   version "0.5.21"
   resolved "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz"
   integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
@@ -7545,6 +7892,14 @@ superagent@^10.2.3:
     mime "2.6.0"
     qs "^6.11.2"
 
+supertest@^7.0.0:
+  version "7.1.4"
+  resolved "https://registry.npmmirror.com/supertest/-/supertest-7.1.4.tgz"
+  integrity sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==
+  dependencies:
+    methods "^1.1.2"
+    superagent "^10.2.3"
+
 supports-color@^7.1.0:
   version "7.2.0"
   resolved "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz"
@@ -7653,6 +8008,21 @@ tesseract.js-core@^7.0.0:
   resolved "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz"
   integrity sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==
 
+tesseract.js@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz"
+  integrity sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==
+  dependencies:
+    bmp-js "^0.1.0"
+    idb-keyval "^6.2.0"
+    is-url "^1.2.4"
+    node-fetch "^2.6.9"
+    opencollective-postinstall "^2.0.3"
+    regenerator-runtime "^0.13.3"
+    tesseract.js-core "^7.0.0"
+    wasm-feature-detect "^1.8.0"
+    zlibjs "^0.3.1"
+
 test-exclude@^6.0.0:
   version "6.0.0"
   resolved "https://registry.npmmirror.com/test-exclude/-/test-exclude-6.0.0.tgz"
@@ -7747,6 +8117,51 @@ ts-dedent@^2.2.0:
   resolved "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz"
   integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==
 
+ts-jest@^29.2.5:
+  version "29.4.6"
+  resolved "https://registry.npmmirror.com/ts-jest/-/ts-jest-29.4.6.tgz"
+  integrity sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==
+  dependencies:
+    bs-logger "^0.2.6"
+    fast-json-stable-stringify "^2.1.0"
+    handlebars "^4.7.8"
+    json5 "^2.2.3"
+    lodash.memoize "^4.1.2"
+    make-error "^1.3.6"
+    semver "^7.7.3"
+    type-fest "^4.41.0"
+    yargs-parser "^21.1.1"
+
+ts-loader@^9.5.2:
+  version "9.5.4"
+  resolved "https://registry.npmmirror.com/ts-loader/-/ts-loader-9.5.4.tgz"
+  integrity sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==
+  dependencies:
+    chalk "^4.1.0"
+    enhanced-resolve "^5.0.0"
+    micromatch "^4.0.0"
+    semver "^7.3.4"
+    source-map "^0.7.4"
+
+ts-node@^10.7.0, ts-node@^10.9.2:
+  version "10.9.2"
+  resolved "https://registry.npmmirror.com/ts-node/-/ts-node-10.9.2.tgz"
+  integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==
+  dependencies:
+    "@cspotcode/source-map-support" "^0.8.0"
+    "@tsconfig/node10" "^1.0.7"
+    "@tsconfig/node12" "^1.0.7"
+    "@tsconfig/node14" "^1.0.0"
+    "@tsconfig/node16" "^1.0.2"
+    acorn "^8.4.1"
+    acorn-walk "^8.1.1"
+    arg "^4.1.0"
+    create-require "^1.1.0"
+    diff "^4.0.1"
+    make-error "^1.1.1"
+    v8-compile-cache-lib "^3.0.1"
+    yn "3.1.1"
+
 tsconfig-paths-webpack-plugin@4.2.0:
   version "4.2.0"
   resolved "https://registry.npmmirror.com/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz"
@@ -7757,7 +8172,7 @@ tsconfig-paths-webpack-plugin@4.2.0:
     tapable "^2.2.1"
     tsconfig-paths "^4.1.2"
 
-tsconfig-paths@^4.1.2, tsconfig-paths@4.2.0:
+tsconfig-paths@^4.1.2, tsconfig-paths@^4.2.0, tsconfig-paths@4.2.0:
   version "4.2.0"
   resolved "https://registry.npmmirror.com/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz"
   integrity sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==
@@ -7856,7 +8271,37 @@ typeorm@^0.3.0:
     uuid "^11.1.0"
     yargs "^17.7.2"
 
-typescript@*, "typescript@>=4.3 <6", typescript@>=4.8.2, typescript@>=4.8.4, "typescript@>=4.8.4 <6.0.0", typescript@>=4.9.5, typescript@>3.6.0, typescript@5.9.3:
+typeorm@0.3.26:
+  version "0.3.26"
+  resolved "https://registry.npmmirror.com/typeorm/-/typeorm-0.3.26.tgz"
+  integrity sha512-o2RrBNn3lczx1qv4j+JliVMmtkPSqEGpG0UuZkt9tCfWkoXKu8MZnjvp2GjWPll1SehwemQw6xrbVRhmOglj8Q==
+  dependencies:
+    "@sqltools/formatter" "^1.2.5"
+    ansis "^3.17.0"
+    app-root-path "^3.1.0"
+    buffer "^6.0.3"
+    dayjs "^1.11.13"
+    debug "^4.4.0"
+    dedent "^1.6.0"
+    dotenv "^16.4.7"
+    glob "^10.4.5"
+    sha.js "^2.4.11"
+    sql-highlight "^6.0.0"
+    tslib "^2.8.1"
+    uuid "^11.1.0"
+    yargs "^17.7.2"
+
+typescript-eslint@^8.20.0:
+  version "8.49.0"
+  resolved "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.49.0.tgz"
+  integrity sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==
+  dependencies:
+    "@typescript-eslint/eslint-plugin" "8.49.0"
+    "@typescript-eslint/parser" "8.49.0"
+    "@typescript-eslint/typescript-estree" "8.49.0"
+    "@typescript-eslint/utils" "8.49.0"
+
+typescript@*, typescript@^5.7.3, typescript@>=2.7, "typescript@>=4.3 <6", typescript@>=4.8.2, typescript@>=4.8.4, "typescript@>=4.8.4 <6.0.0", typescript@>=4.9.5, typescript@>3.6.0, typescript@5.9.3:
   version "5.9.3"
   resolved "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz"
   integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
@@ -8059,6 +8504,11 @@ uuid@^9.0.0:
   resolved "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz"
   integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
 
+v8-compile-cache-lib@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.npmmirror.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz"
+  integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
+
 v8-to-istanbul@^9.0.1:
   version "9.3.0"
   resolved "https://registry.npmmirror.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz"
@@ -8207,7 +8657,7 @@ web-streams-polyfill@^3.0.3:
   resolved "file:web"
   dependencies:
     "@google/genai" "^1.32.0"
-    "@tailwindcss/vite" "4.0.0"
+    "@tailwindcss/vite" "4.0.9"
     "@types/react-syntax-highlighter" "^15.5.13"
     clsx "^2.1.1"
     framer-motion "^12.34.3"
@@ -8402,6 +8852,11 @@ yargs@^17.7.2:
     y18n "^5.0.5"
     yargs-parser "^21.1.1"
 
+yn@3.1.1:
+  version "3.1.1"
+  resolved "https://registry.npmmirror.com/yn/-/yn-3.1.1.tgz"
+  integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
+
 yocto-queue@^0.1.0:
   version "0.1.0"
   resolved "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz"
@@ -8418,9 +8873,9 @@ zlibjs@^0.3.1:
   integrity sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==
 
 "zod@^3.25 || ^4.0", "zod@^3.25.32 || ^4.1.0", "zod@^3.25.76 || ^4":
-  version "4.1.13"
-  resolved "https://registry.npmmirror.com/zod/-/zod-4.1.13.tgz"
-  integrity sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==
+  version "4.3.6"
+  resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz"
+  integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==
 
 zwitch@^2.0.0:
   version "2.0.4"

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików