소스 검색

人才测评智能体

anhuiqiang 1 주 전
부모
커밋
182502f907

+ 945 - 0
docs/superpowers/plans/2026-03-16-feishu-bot-integration.md

@@ -0,0 +1,945 @@
+# Feishu Bot Integration Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Enable users to bind their Feishu (飞书) bots to their AuraK accounts and interact with the RAG system and talent assessment features through Feishu messages.
+
+**Architecture:** Create a new `feishu` module that acts as a bridge between Feishu Open Platform and existing ChatService/AssessmentService. Users create Feishu custom apps, bind them in AuraK, and receive/push messages through Feishu's webhook-based event subscription.
+
+**Tech Stack:** 
+- NestJS (existing)
+- Feishu Open Platform APIs (im/v1/messages, auth/v3/tenant_access_token)
+- Event subscription (Webhooks)
+- Existing: ChatService, AssessmentService, JWT Auth
+
+---
+
+## Architecture Overview
+
+```
+┌─────────────┐      ┌──────────────┐      ┌─────────────┐
+│  Feishu User│─────▶│ Feishu Server│─────▶│  AuraK API  │
+│  (User A)   │◀─────│   Webhook    │◀─────│  /feishu/*  │
+└─────────────┘      └──────────────┘      └─────────────┘
+                                                      │
+                                                      ▼
+                                              ┌─────────────┐
+                                              │ FeishuModule│
+                                              │ - Bot Entity│
+                                              │ - Message   │
+                                              │ - Events    │
+                                              └─────────────┘
+                                                      │
+                              ┌───────────────────────┼───────────────────────┐
+                              ▼                       ▼                       ▼
+                      ┌─────────────┐        ┌─────────────┐        ┌─────────────┐
+                      │ ChatService │        │AssessmentSvc│        │  UserService│
+                      │  (RAG Q&A)  │        │ (评测对话)   │        │ (绑定管理)  │
+                      └─────────────┘        └─────────────┘        └─────────────┘
+```
+
+---
+
+## File Structure
+
+### New Files to Create
+
+```
+server/src/
+├── feishu/
+│   ├── feishu.module.ts              # Module definition
+│   ├── feishu.controller.ts          # Webhook endpoints
+│   ├── feishu.service.ts             # Business logic
+│   ├── feishu.gateway.ts             # Event handling (optional)
+│   ├── entities/
+│   │   └── feishu-bot.entity.ts      # Bot configuration entity
+│   ├── dto/
+│   │   ├── create-bot.dto.ts         # Create bot DTO
+│   │   ├── bind-bot.dto.ts           # Bind bot DTO
+│   │   └── feishu-webhook.dto.ts    # Webhook event DTO
+│   └── interfaces/
+│       └── feishu.interface.ts       # TypeScript interfaces
+```
+
+### Existing Files to Modify
+
+```
+server/src/
+├── app.module.ts                      # Import FeishuModule
+├── user/
+│   └── user.entity.ts                # Add one-to-many relation to FeishuBot
+├── user/user.service.ts               # Add methods to get/set Feishu binding
+```
+
+---
+
+## Chunk 1: Entity and DTO Definitions
+
+### Task 1.1: Create FeishuBot Entity
+
+**Files:**
+- Create: `server/src/feishu/entities/feishu-bot.entity.ts`
+
+- [ ] **Step 1: Create the FeishuBot entity file**
+
+```typescript
+import {
+  Entity,
+  PrimaryGeneratedColumn,
+  Column,
+  CreateDateColumn,
+  UpdateDateColumn,
+  ManyToOne,
+  JoinColumn,
+} from 'typeorm';
+import { User } from '../../user/user.entity';
+
+@Entity('feishu_bots')
+export class FeishuBot {
+  @PrimaryGeneratedColumn('uuid')
+  id: string;
+
+  @Column({ name: 'user_id' })
+  userId: string;
+
+  @ManyToOne(() => User)
+  @JoinColumn({ name: 'user_id' })
+  user: User;
+
+  @Column({ name: 'app_id', length: 64 })
+  appId: string;  // 飞书应用 App ID (cli_xxx)
+
+  @Column({ name: 'app_secret', length: 128 })
+  appSecret: string;  // 飞书应用 App Secret (加密存储)
+
+  @Column({ name: 'tenant_access_token', nullable: true })
+  tenantAccessToken: string;
+
+  @Column({ name: 'token_expires_at', nullable: true })
+  tokenExpiresAt: Date;
+
+  @Column({ name: 'verification_token', nullable: true })
+  verificationToken: string;  // 用于验证回调请求
+
+  @Column({ name: 'encrypt_key', nullable: true })
+  encryptKey: string;  // 飞书加密 key (可选)
+
+  @Column({ name: 'bot_name', nullable: true })
+  botName: string;  // 机器人名称显示
+
+  @Column({ default: true })
+  enabled: boolean;
+
+  @Column({ name: 'is_default', default: false })
+  isDefault: boolean;  // 用户是否有多个机器人
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt: Date;
+}
+```
+
+- [ ] **Step 2: Add FeishuBot relation to User entity**
+
+Modify: `server/src/user/user.entity.ts:73-74`
+
+Add after userSetting relation:
+```typescript
+  @OneToMany(() => FeishuBot, (bot) => bot.user)
+  feishuBots: FeishuBot[];
+```
+
+- [ ] **Step 3: Create DTOs**
+
+Create: `server/src/feishu/dto/create-bot.dto.ts`
+```typescript
+import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator';
+
+export class CreateFeishuBotDto {
+  @IsString()
+  @IsNotEmpty()
+  appId: string;
+
+  @IsString()
+  @IsNotEmpty()
+  appSecret: string;
+
+  @IsString()
+  @IsOptional()
+  verificationToken?: string;
+
+  @IsString()
+  @IsOptional()
+  encryptKey?: string;
+
+  @IsString()
+  @IsOptional()
+  botName?: string;
+
+  @IsBoolean()
+  @IsOptional()
+  enabled?: boolean;
+}
+```
+
+Create: `server/src/feishu/dto/bind-bot.dto.ts`
+```typescript
+import { IsString, IsNotEmpty, IsUUID } from 'class-validator';
+
+export class BindFeishuBotDto {
+  @IsUUID()
+  @IsNotEmpty()
+  botId: string;
+
+  @IsString()
+  @IsNotEmpty()
+  verificationCode?: string;  // Optional: 用于验证绑定关系
+}
+```
+
+---
+
+## Chunk 2: Core Service Implementation
+
+### Task 2.1: Create Feishu Service
+
+**Files:**
+- Create: `server/src/feishu/feishu.service.ts`
+
+- [ ] **Step 1: Implement FeishuService with token management and message sending**
+
+```typescript
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { ConfigService } from '@nestjs/config';
+import { HttpService } from '@nestjs/axios';
+import { FeishuBot } from './entities/feishu-bot.entity';
+import { CreateFeishuBotDto } from './dto/create-bot.dto';
+
+@Injectable()
+export class FeishuService {
+  private readonly logger = new Logger(FeishuService.name);
+  private readonly feishuApiBase = 'https://open.feishu.cn/open-apis';
+
+  constructor(
+    @InjectRepository(FeishuBot)
+    private botRepository: Repository<FeishuBot>,
+    private httpService: HttpService,
+    private configService: ConfigService,
+  ) {}
+
+  /**
+   * Create or update a Feishu bot for a user
+   */
+  async createBot(userId: string, dto: CreateFeishuBotDto): Promise<FeishuBot> {
+    // Check if bot already exists for this user with same appId
+    const existing = await this.botRepository.findOne({
+      where: { userId, appId: dto.appId },
+    });
+
+    if (existing) {
+      // Update existing bot
+      Object.assign(existing, dto);
+      return this.botRepository.save(existing);
+    }
+
+    // Create new bot
+    const bot = this.botRepository.create({
+      userId,
+      ...dto,
+    });
+    return this.botRepository.save(bot);
+  }
+
+  /**
+   * Get all bots for a user
+   */
+  async getUserBots(userId: string): Promise<FeishuBot[]> {
+    return this.botRepository.find({ where: { userId } });
+  }
+
+  /**
+   * Get bot by ID
+   */
+  async getBotById(botId: string): Promise<FeishuBot | null> {
+    return this.botRepository.findOne({ where: { id: botId } });
+  }
+
+  /**
+   * Get bot by appId
+   */
+  async getBotByAppId(appId: string): Promise<FeishuBot | null> {
+    return this.botRepository.findOne({ where: { appId } });
+  }
+
+  /**
+   * Get or refresh tenant_access_token
+   */
+  async getValidToken(bot: FeishuBot): Promise<string> {
+    // Check if token is still valid (expire in 2 hours, refresh at 1.5 hours)
+    if (
+      bot.tokenExpiresAt &&
+      bot.tenantAccessToken &&
+      new Date(bot.tokenExpiresAt) > new Date(Date.now() + 30 * 60 * 1000)
+    ) {
+      return bot.tenantAccessToken;
+    }
+
+    // Refresh token
+    const response = await this.httpService
+      .post(`${this.feishuApiBase}/auth/v3/tenant_access_token/internal`, {
+        app_id: bot.appId,
+        app_secret: bot.appSecret,
+      })
+      .toPromise();
+
+    if (response.data.code !== 0) {
+      throw new Error(`Failed to get token: ${response.data.msg}`);
+    }
+
+    const { tenant_access_token, expire } = response.data;
+
+    // Update bot with new token
+    bot.tenantAccessToken = tenant_access_token;
+    bot.tokenExpiresAt = new Date(Date.now() + expire * 1000);
+    await this.botRepository.save(bot);
+
+    return tenant_access_token;
+  }
+
+  /**
+   * Send message to Feishu user
+   */
+  async sendMessage(
+    bot: FeishuBot,
+    receiveIdType: 'open_id' | 'user_id' | 'union_id' | 'chat_id',
+    receiveId: string,
+    messageType: 'text' | 'interactive' | 'post',
+    content: any,
+  ): Promise<string> {
+    const token = await this.getValidToken(bot);
+
+    const response = await this.httpService
+      .post(
+        `${this.feishuApiBase}/im/v1/messages?receive_id_type=${receiveIdType}`,
+        {
+          receive_id: receiveId,
+          msg_type: messageType,
+          content: JSON.stringify(content),
+        },
+        {
+          headers: {
+            Authorization: `Bearer ${token}`,
+          },
+        },
+      )
+      .toPromise();
+
+    if (response.data.code !== 0) {
+      throw new Error(`Failed to send message: ${response.data.msg}`);
+    }
+
+    return response.data.data.message_id;
+  }
+
+  /**
+   * Send text message (convenience method)
+   */
+  async sendTextMessage(
+    bot: FeishuBot,
+    receiveIdType: 'open_id' | 'user_id' | 'chat_id',
+    receiveId: string,
+    text: string,
+  ): Promise<string> {
+    return this.sendMessage(bot, receiveIdType, receiveId, 'text', { text });
+  }
+
+  /**
+   * Reply to a message
+   */
+  async replyMessage(
+    bot: FeishuBot,
+    messageId: string,
+    messageType: 'text' | 'interactive' | 'post',
+    content: any,
+  ): Promise<string> {
+    const token = await this.getValidToken(bot);
+
+    const response = await this.httpService
+      .post(
+        `${this.feishuApiBase}/im/v1/messages/${messageId}/reply`,
+        {
+          msg_type: messageType,
+          content: JSON.stringify(content),
+        },
+        {
+          headers: {
+            Authorization: `Bearer ${token}`,
+          },
+        },
+      )
+      .toPromise();
+
+    if (response.data.code !== 0) {
+      throw new Error(`Failed to reply message: ${response.data.msg}`);
+    }
+
+    return response.data.data.message_id;
+  }
+
+  /**
+   * Upload image for sending
+   */
+  async uploadImage(
+    bot: FeishuBot,
+    imageType: 'message' | 'avatar',
+    image: Buffer,
+    imageName: string,
+  ): Promise<string> {
+    const token = await this.getValidToken(bot);
+
+    const FormData = require('form-data');
+    const form = new FormData();
+    form.append('image_type', imageType);
+    form.append('image', image, { filename: imageName });
+
+    const response = await this.httpService
+      .post(`${this.feishuApiBase}/im/v1/images`, form, {
+        headers: {
+          Authorization: `Bearer ${token}`,
+          ...form.getHeaders(),
+        },
+      })
+      .toPromise();
+
+    if (response.data.code !== 0) {
+      throw new Error(`Failed to upload image: ${response.data.msg}`);
+    }
+
+    return response.data.data.image_key;
+  }
+
+  /**
+   * Delete bot
+   */
+  async deleteBot(botId: string): Promise<void> {
+    await this.botRepository.delete(botId);
+  }
+
+  /**
+   * Enable/disable bot
+   */
+  async setBotEnabled(botId: string, enabled: boolean): Promise<FeishuBot> {
+    const bot = await this.botRepository.findOne({ where: { id: botId } });
+    if (!bot) {
+      throw new Error('Bot not found');
+    }
+    bot.enabled = enabled;
+    return this.botRepository.save(bot);
+  }
+}
+```
+
+---
+
+## Chunk 3: Controller and Webhook Endpoints
+
+### Task 3.1: Create Feishu Controller
+
+**Files:**
+- Create: `server/src/feishu/feishu.controller.ts`
+
+- [ ] **Step 1: Implement webhook endpoints**
+
+```typescript
+import {
+  Controller,
+  Post,
+  Get,
+  Delete,
+  Body,
+  Param,
+  Query,
+  Headers,
+  UseGuards,
+  Request,
+  RawBodyRequest,
+  Req,
+} from '@nestjs/common';
+import { Request as ExpressRequest } from 'express';
+import { FeishuService } from './feishu.service';
+import { CreateFeishuBotDto } from './dto/create-bot.dto';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CreateSignatureDto, VerifyWebhookDto } from './dto/webhook.dto';
+
+@Controller('feishu')
+export class FeishuController {
+  constructor(private readonly feishuService: FeishuService) {}
+
+  /**
+   * GET /feishu/bots - List user's bots
+   * Requires JWT auth
+   */
+  @Get('bots')
+  @UseGuards(JwtAuthGuard)
+  async listBots(@Request() req) {
+    const bots = await this.feishuService.getUserBots(req.user.id);
+    // Mask sensitive data
+    return bots.map((bot) => ({
+      id: bot.id,
+      appId: bot.appId,
+      botName: bot.botName,
+      enabled: bot.enabled,
+      isDefault: bot.isDefault,
+      createdAt: bot.createdAt,
+    }));
+  }
+
+  /**
+   * POST /feishu/bots - Create a new bot
+   * Requires JWT auth
+   */
+  @Post('bots')
+  @UseGuards(JwtAuthGuard)
+  async createBot(@Request() req, @Body() dto: CreateFeishuBotDto) {
+    const bot = await this.feishuService.createBot(req.user.id, dto);
+    return {
+      id: bot.id,
+      appId: bot.appId,
+      botName: bot.botName,
+      enabled: bot.enabled,
+    };
+  }
+
+  /**
+   * DELETE /feishu/bots/:id - Delete a bot
+   * Requires JWT auth
+   */
+  @Delete('bots/:id')
+  @UseGuards(JwtAuthGuard)
+  async deleteBot(@Request() req, @Param('id') botId: string) {
+    await this.feishuService.deleteBot(botId);
+    return { success: true };
+  }
+
+  /**
+   * POST /feishu/webhook - Feishu webhook endpoint
+   * Public endpoint - no auth required (uses verification token)
+   */
+  @Post('webhook')
+  async handleWebhook(
+    @Body() body: any,
+    @Headers('x-xsign') xSign?: string,
+    @Headers('x-timESTAMP') xTimestamp?: string,
+  ) {
+    this.logger.log(`Received webhook: ${JSON.stringify(body)}`);
+
+    const { type, schema, event } = body;
+
+    // Handle URL verification (飞书首次配置时验证)
+    if (type === 'url_verification') {
+      return {
+        challenge: body.challenge,
+      };
+    }
+
+    // Handle event callback
+    if (type === 'event_callback') {
+      const { event_type, token } = body;
+
+      // Verify token
+      if (token !== body?.verify_token) {
+        this.logger.warn('Webhook token verification failed');
+        return { success: false };
+      }
+
+      // Handle different event types
+      switch (event_type) {
+        case 'im.message.p2p_msg_received':
+          // Handle private message
+          await this.handleP2PMsg(event);
+          break;
+
+        case 'im.message.group_at_msg_received':
+          // Handle group @message
+          await this.handleGroupMsg(event);
+          break;
+
+        default:
+          this.logger.log(`Unhandled event type: ${event_type}`);
+      }
+    }
+
+    return { success: true };
+  }
+
+  /**
+   * Handle private message
+   */
+  private async handleP2PMsg(event: any) {
+    const { message } = event;
+    const { message_id, chat_id, sender, message_type, body } = message;
+
+    // Get message content
+    const content = JSON.parse(message.content || '{}');
+    const text = content.text || '';
+
+    // Find bot by app_id (from chat or event)
+    const openId = sender?.id?.open_id;
+    if (!openId) {
+      this.logger.warn('No sender open_id found');
+      return;
+    }
+
+    // Find user's bot
+    const bot = await this.feishuService.getBotByAppId(sender?.sender_id?.app_id);
+    if (!bot || !bot.enabled) {
+      this.logger.warn('Bot not found or disabled');
+      return;
+    }
+
+    // Process message through RAG/Chat service
+    await this.processMessage(bot, openId, message_id, text);
+  }
+
+  /**
+   * Handle group message (@bot)
+   */
+  private async handleGroupMsg(event: any) {
+    const { message } = event;
+    const { message_id, chat_id, sender, message_type, content } = message;
+
+    // Check if bot was mentioned
+    const msgContent = JSON.parse(content || '{}');
+    // Group messages often require specific handling
+
+    // Similar to P2P but with chat_id context
+    await this.handleP2PMsg(event);
+  }
+
+  /**
+   * Process message through ChatService
+   */
+  private async processMessage(
+    bot: any,
+    openId: string,
+    messageId: string,
+    text: string,
+  ) {
+    // TODO: Integrate with ChatService
+    // This will be implemented in Chunk 5
+
+    // For now, echo back (placeholder)
+    try {
+      await this.feishuService.sendTextMessage(
+        bot,
+        'open_id',
+        openId,
+        `Received: ${text}`,
+      );
+    } catch (error) {
+      this.logger.error('Failed to send response', error);
+    }
+  }
+}
+```
+
+- [ ] **Step 2: Create webhook DTOs**
+
+Create: `server/src/feishu/dto/webhook.dto.ts`
+```typescript
+import { IsString, IsOptional } from 'class-validator';
+
+export class CreateSignatureDto {
+  @IsString()
+  @IsOptional()
+  timestamp?: string;
+
+  @IsString()
+  @IsOptional()
+  nonce?: string;
+}
+
+export class VerifyWebhookDto {
+  @IsString()
+  token: string;
+
+  @IsString()
+  @IsOptional()
+  challenge?: string;
+}
+```
+
+---
+
+## Chunk 4: Module Registration
+
+### Task 4.1: Register FeishuModule
+
+**Files:**
+- Create: `server/src/feishu/feishu.module.ts`
+- Modify: `server/src/app.module.ts`
+
+- [ ] **Step 1: Create FeishuModule**
+
+```typescript
+import { Module, forwardRef } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { HttpModule } from '@nestjs/axios';
+import { FeishuController } from './feishu.controller';
+import { FeishuService } from './feishu.service';
+import { FeishuBot } from './entities/feishu-bot.entity';
+import { ChatModule } from '../chat/chat.module';
+import { UserModule } from '../user/user.module';
+
+@Module({
+  imports: [
+    TypeOrmModule.forFeature([FeishuBot]),
+    HttpModule,
+    forwardRef(() => ChatModule),
+    forwardRef(() => UserModule),
+  ],
+  controllers: [FeishuController],
+  providers: [FeishuService],
+  exports: [FeishuService, TypeOrmModule],
+})
+export class FeishuModule {}
+```
+
+- [ ] **Step 2: Add FeishuModule to AppModule**
+
+Modify: `server/src/app.module.ts`
+
+Add import:
+```typescript
+import { FeishuModule } from './feishu/feishu.module';
+```
+
+Add to imports array:
+```typescript
+FeishuModule,
+```
+
+---
+
+## Chunk 5: Integration with ChatService
+
+### Task 5.1: Connect Feishu messages to ChatService
+
+**Files:**
+- Modify: `server/src/feishu/feishu.controller.ts`
+- Modify: `server/src/feishu/feishu.service.ts`
+
+- [ ] **Step 1: Extend FeishuService to handle chat integration**
+
+Add to FeishuService:
+```typescript
+  // Import these
+  import { ChatService } from '../chat/chat.service';
+  import { ModelConfigService } from '../model-config/model-config.service';
+  import { TenantService } from '../tenant/tenant.service';
+  import { UserService } from '../user/user.service';
+  import { ModelType } from '../types';
+
+  // Add to constructor
+  constructor(
+    // ... existing
+    private chatService: ChatService,
+    private modelConfigService: ModelConfigService,
+    private tenantService: TenantService,
+    private userService: UserService,
+  ) {}
+
+  /**
+   * Process chat message through RAG
+   */
+  async processChatMessage(
+    bot: FeishuBot,
+    openId: string,
+    messageId: string,
+    userMessage: string,
+  ): Promise<void> {
+    // Get user by Feishu open_id mapping (future: map table)
+    // For now, use userId from bot
+    const userId = bot.userId;
+    
+    // Get user's tenant
+    const user = await this.userService.findById(userId);
+    const tenantId = user?.tenantId || 'default';
+
+    // Get user's LLM config
+    const llmModel = await this.modelConfigService.findDefaultByType(
+      tenantId,
+      ModelType.LLM,
+    );
+
+    if (!llmModel) {
+      await this.sendTextMessage(bot, 'open_id', openId, '请先配置 LLM 模型');
+      return;
+    }
+
+    // Send "thinking" message
+    await this.sendTextMessage(bot, 'open_id', openId, '正在思考...');
+
+    // Stream chat response
+    const stream = this.chatService.streamChat(
+      userMessage,
+      [],  // No history for now (future: persist per openId)
+      userId,
+      llmModel as any,
+      user?.userSetting?.language || 'zh',
+      undefined, // selectedEmbeddingId
+      undefined, // selectedGroups
+      undefined, // selectedFiles
+      undefined, // historyId
+      false, // enableRerank
+      undefined, // selectedRerankId
+      undefined, // temperature
+      undefined, // maxTokens
+      10, // topK
+      0.7, // similarityThreshold
+      undefined, // rerankSimilarityThreshold
+      undefined, // enableQueryExpansion
+      undefined, // enableHyDE
+      tenantId,
+    );
+
+    let fullResponse = '';
+    for await (const chunk of stream) {
+      if (chunk.type === 'content') {
+        fullResponse += chunk.data;
+        // Could send incrementally, but Feishu prefers complete messages
+      }
+    }
+
+    // Send final response
+    // Truncate if too long (Feishu has limits)
+    const maxLength = 5000;
+    const finalMessage = fullResponse.length > maxLength
+      ? fullResponse.substring(0, maxLength) + '...(内容过长)'
+      : fullResponse;
+
+    await this.sendTextMessage(bot, 'open_id', openId, finalMessage);
+  }
+```
+
+- [ ] **Step 2: Update controller to use chat integration**
+
+Modify the `processMessage` method in FeishuController to call `feishuService.processChatMessage()`:
+
+```typescript
+  private async processMessage(
+    bot: any,
+    openId: string,
+    messageId: string,
+    text: string,
+  ) {
+    try {
+      await this.feishuService.processChatMessage(bot, openId, messageId, text);
+    } catch (error) {
+      this.logger.error('Failed to process message', error);
+      try {
+        await this.feishuService.sendTextMessage(
+          bot,
+          'open_id',
+          openId,
+          '抱歉,处理消息时发生错误,请稍后重试。',
+        );
+      } catch (sendError) {
+        this.logger.error('Failed to send error message', sendError);
+      }
+    }
+  }
+```
+
+---
+
+## Chunk 6: Frontend Integration (Optional)
+
+### Task 6.1: Add Feishu settings UI
+
+**Files:**
+- Create: `web/src/pages/Settings/FeishuSettings.tsx` (or similar)
+- Modify: `web/src/services/api.ts`
+
+- [ ] **Step 1: Add API endpoints to frontend**
+
+Modify: `web/src/services/api.ts`
+```typescript
+// Add Feishu API calls
+export const feishuApi = {
+  listBots: () => api.get('/feishu/bots'),
+  createBot: (data: CreateBotDto) => api.post('/feishu/bots', data),
+  deleteBot: (botId: string) => api.delete(`/feishu/bots/${botId}`),
+};
+```
+
+- [ ] **Step 2: Create settings component**
+
+Create a simple settings page where users can:
+1. View their bound Feishu bots
+2. Add new bot (paste App ID, App Secret)
+3. Delete bots
+4. See setup instructions
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+- `feishu.service.spec.ts` - Test token refresh, message sending
+- `feishu.controller.spec.ts` - Test webhook endpoints
+
+### Integration Tests
+- Test full flow: Feishu message → Webhook → ChatService → Response
+
+### Manual Testing
+1. Create Feishu app in 开放平台
+2. Configure webhook URL (use ngrok for local)
+3. Subscribe to message events
+4. Send message to bot
+5. Verify RAG response
+
+---
+
+## Security Considerations
+
+1. **Token Storage**: Encrypt `app_secret` before storing in DB
+2. **Webhook Verification**: Always verify `verification_token` from Feishu
+3. **Rate Limiting**: Implement queue for sending messages (Feishu limits)
+4. **User Mapping**: Consider secure mapping between Feishu open_id and AuraK user
+
+---
+
+## Future Enhancements
+
+1. **Assessment Integration**: Bind assessment sessions to Feishu conversations
+2. **Rich Responses**: Use Feishu interactive cards for better UI
+3. **Multi-bot Support**: Users can have multiple bots for different purposes
+4. **Group Chats**: Support bot in group chats
+5. **Voice Messages**: Handle voice message transcription
+
+---
+
+## Dependencies
+
+- `@nestjs/axios` - For HTTP requests to Feishu API
+- `form-data` - For file/image uploads
+- Optional: `crypto` (built-in) - For signature verification
+
+---
+
+## Reference Links
+
+- [飞书开放平台 - 机器人文档](https://open.feishu.cn/document/faq/bot)
+- [飞书事件订阅](https://open.feishu.cn/document/server-docs/event-subscription-guide/overview)
+- [消息发送 API](https://open.feishu.cn/document/server-docs/im-v1/message-content-description)
+- [获取 tenant_access_token](https://open.feishu.cn/document/server-docs/authentication-management/access-token)
+
+---
+
+> **Plan created:** 2026-03-16
+> **Based on:** Feishu integration analysis

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

@@ -45,6 +45,7 @@ import { ImportTask } from './import-task/import-task.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';
+import { AssessmentTemplate } from './assessment/entities/assessment-template.entity';
 import { Tenant } from './tenant/tenant.entity';
 import { TenantSetting } from './tenant/tenant-setting.entity';
 import { ApiKey } from './auth/entities/api-key.entity';
@@ -85,6 +86,7 @@ import { AdminModule } from './admin/admin.module';
           AssessmentSession,
           AssessmentQuestion,
           AssessmentAnswer,
+          AssessmentTemplate,
           Tenant,
           TenantSetting,
           TenantMember,

+ 8 - 2
server/src/assessment/assessment.controller.ts

@@ -12,9 +12,10 @@ export class AssessmentController {
 
     @Post('start')
     @ApiOperation({ summary: 'Start a new assessment session' })
-    async startSession(@Request() req: any, @Body() body: { knowledgeBaseId: string, language?: string }) {
+    async startSession(@Request() req: any, @Body() body: { knowledgeBaseId?: string, language?: string, templateId?: string }) {
         const { id: userId, tenantId } = req.user;
-        return this.assessmentService.startSession(userId, body.knowledgeBaseId, tenantId, body.language);
+        console.log(`[AssessmentController] startSession: user=${userId}, tenant=${tenantId}, templateId=${body.templateId}, kbId=${body.knowledgeBaseId}`);
+        return this.assessmentService.startSession(userId, body.knowledgeBaseId, tenantId, body.language, body.templateId);
     }
 
     @Post(':id/answer')
@@ -25,6 +26,7 @@ export class AssessmentController {
         @Body() body: { answer: string, language?: string }
     ) {
         const { id: userId } = req.user;
+        console.log(`[AssessmentController] submitAnswer: user=${userId}, session=${sessionId}, answerLen=${body.answer?.length}`);
         return this.assessmentService.submitAnswer(sessionId, userId, body.answer, body.language);
     }
 
@@ -32,6 +34,7 @@ export class AssessmentController {
     @ApiOperation({ summary: 'Stream initial session generation' })
     startSessionStream(@Request() req: any, @Param('id') sessionId: string) {
         const { id: userId } = req.user;
+        console.log(`[AssessmentController] startSessionStream: user=${userId}, session=${sessionId}`);
         return this.assessmentService.startSessionStream(sessionId, userId).pipe(
             map(data => ({ data } as MessageEvent))
         );
@@ -46,6 +49,7 @@ export class AssessmentController {
         @Query('language') language?: string
     ) {
         const { id: userId } = req.user;
+        console.log(`[AssessmentController] submitAnswerStream: user=${userId}, session=${sessionId}, lang=${language}`);
         return this.assessmentService.submitAnswerStream(sessionId, userId, answer, language).pipe(
             map(data => ({ data } as MessageEvent))
         );
@@ -55,6 +59,7 @@ export class AssessmentController {
     @ApiOperation({ summary: 'Get the current state of an assessment session' })
     async getSessionState(@Request() req: any, @Param('id') sessionId: string) {
         const { id: userId } = req.user;
+        console.log(`[AssessmentController] getSessionState: user=${userId}, session=${sessionId}`);
         return this.assessmentService.getSessionState(sessionId, userId);
     }
 
@@ -62,6 +67,7 @@ export class AssessmentController {
     @ApiOperation({ summary: 'Get assessment session history' })
     async getHistory(@Request() req: any) {
         const { id: userId, tenantId } = req.user;
+        console.log(`[AssessmentController] getHistory: user=${userId}, tenant=${tenantId}`);
         return this.assessmentService.getHistory(userId, tenantId);
     }
 }

+ 14 - 3
server/src/assessment/assessment.module.ts

@@ -5,10 +5,17 @@ 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 { AssessmentTemplate } from './entities/assessment-template.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 { ChatModule } from '../chat/chat.module';
+import { ElasticsearchModule } from '../elasticsearch/elasticsearch.module';
+import { RagModule } from '../rag/rag.module';
+import { TenantModule } from '../tenant/tenant.module';
+import { TemplateService } from './services/template.service';
+import { TemplateController } from './controllers/template.controller';
+import { ContentFilterService } from './services/content-filter.service';
 
 @Module({
   imports: [
@@ -16,14 +23,18 @@ import { ChatModule } from '../chat/chat.module';
       AssessmentSession,
       AssessmentQuestion,
       AssessmentAnswer,
+      AssessmentTemplate,
     ]),
     forwardRef(() => KnowledgeBaseModule),
     forwardRef(() => KnowledgeGroupModule),
     forwardRef(() => ModelConfigModule),
     forwardRef(() => ChatModule),
+    ElasticsearchModule,
+    RagModule,
+    TenantModule,
   ],
-  controllers: [AssessmentController],
-  providers: [AssessmentService],
-  exports: [AssessmentService],
+  controllers: [AssessmentController, TemplateController],
+  providers: [AssessmentService, TemplateService, ContentFilterService],
+  exports: [AssessmentService, TemplateService],
 })
 export class AssessmentModule { }

+ 167 - 18
server/src/assessment/assessment.service.ts

@@ -1,6 +1,6 @@
 import { Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
+import { Repository, DeepPartial } from 'typeorm';
 import { ConfigService } from '@nestjs/config';
 import { ChatOpenAI } from "@langchain/openai";
 import { HumanMessage, BaseMessage, AIMessage, SystemMessage } from "@langchain/core/messages";
@@ -8,12 +8,20 @@ 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 { AssessmentTemplate } from './entities/assessment-template.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 { ElasticsearchService } from '../elasticsearch/elasticsearch.service';
+import { RagService } from '../rag/rag.service';
+import { ChatService } from '../chat/chat.service';
 import { createEvaluationGraph } from './graph/builder';
 import { EvaluationState } from './graph/state';
+import { TemplateService } from './services/template.service';
+import { ContentFilterService } from './services/content-filter.service';
+import { I18nService } from '../i18n/i18n.service';
+import { TenantService } from '../tenant/tenant.service';
 
 @Injectable()
 export class AssessmentService {
@@ -34,6 +42,13 @@ export class AssessmentService {
         @Inject(forwardRef(() => ModelConfigService))
         private modelConfigService: ModelConfigService,
         private configService: ConfigService,
+        private templateService: TemplateService,
+        private contentFilterService: ContentFilterService,
+        private ragService: RagService,
+        @Inject(forwardRef(() => ChatService))
+        private chatService: ChatService,
+        private i18nService: I18nService,
+        private tenantService: TenantService,
     ) { }
 
     private async getModel(tenantId: string): Promise<ChatOpenAI> {
@@ -52,27 +67,119 @@ export class AssessmentService {
      * Starts a new assessment session.
      */
 
-    private async getSessionContent(session: { knowledgeBaseId?: string | null, knowledgeGroupId?: string | null, userId: string, tenantId: string }): Promise<string> {
+    private async getSessionContent(session: { knowledgeBaseId?: string | null, knowledgeGroupId?: string | null, userId: string, tenantId: string, templateJson?: any }): Promise<string> {
         const kbId = session.knowledgeBaseId || session.knowledgeGroupId;
-        if (!kbId) return '';
+        this.logger.log(`[getSessionContent] Starting for KB/Group ID: ${kbId}`);
+        if (!kbId) {
+            this.logger.warn(`[getSessionContent] No KB/Group ID provided`);
+            return '';
+        }
+
+        const keywords = session.templateJson?.keywords || [];
+        
+        // If keywords are provided, use RagService (Hybrid Search) to find relevant content
+        if (keywords.length > 0) {
+            this.logger.log(`[getSessionContent] Keywords detected, performing hybrid search via RagService: ${keywords.join(', ')}`);
+            
+            try {
+                // 1. Determine file IDs to include in search
+                let fileIds: string[] = [];
+                if (session.knowledgeBaseId) {
+                    fileIds = [session.knowledgeBaseId];
+                } else if (session.knowledgeGroupId) {
+                    fileIds = await this.groupService.getFileIdsByGroups([session.knowledgeGroupId], session.userId, session.tenantId);
+                }
+
+                if (fileIds.length > 0) {
+                    const query = keywords.join(' ');
+                    this.logger.log(`[getSessionContent] Performing high-fidelity grounded search (streamChat-style). Keywords: "${query}"`);
+                    
+                    // 1. Get default embedding model (strict logic from streamChat)
+                    const embeddingModel = await this.modelConfigService.findDefaultByType(session.tenantId || 'default', ModelType.EMBEDDING);
+                    
+                    // 2. Perform advanced RAG search
+                    const ragResults = await this.ragService.searchKnowledge(
+                        query,
+                        session.userId,
+                        20, // Increased topK to 20 for broader question coverage
+                        0.1, // Lenient similarityThreshold (Chat/Rag defaults are 0.3)
+                        embeddingModel?.id,
+                        true, // enableFullTextSearch
+                        true, // enableRerank
+                        undefined, // selectedRerankId
+                        undefined, // selectedGroups
+                        fileIds,
+                        0.3, // Lenient rerankSimilarityThreshold (Chat/Rag defaults are 0.5)
+                        session.tenantId
+                    );
+
+                    // 3. Format context using localized labels (equivalent to buildContext)
+                    const language = session.templateJson?.language || 'zh';
+                    const searchContent = ragResults
+                        .map((result, index) => {
+                            // this.logger.debug(`[getSessionContent] Found chunk [${index + 1}]: score=${result.score.toFixed(4)}, file=${result.fileName}, contentPreview=${result.content}...`);
+                            return `[${index + 1}] ${this.i18nService.getMessage('file', language)}:${result.fileName}\n${this.i18nService.getMessage('content', language)}:${result.content}\n`;
+                        })
+                        .join('\n');
+
+                    if (searchContent && searchContent.trim().length > 0) {
+                        this.logger.log(`[getSessionContent] SUCCESS: Found ${ragResults.length} relevant chunks. Total length: ${searchContent.length}`);
+                        // this.logger.log(`[getSessionContent] --- AI Context Start ---\n${searchContent}\n[getSessionContent] --- AI Context End ---`);
+                        return searchContent;
+                    } else {
+                        this.logger.warn(`[getSessionContent] High-fidelity search returned no results for query: "${query}".`);
+                    }
+                } else {
+                    this.logger.warn(`[getSessionContent] No files found for search scope (KB: ${session.knowledgeBaseId}, Group: ${session.knowledgeGroupId})`);
+                }
+            } catch (err) {
+                this.logger.error(`[getSessionContent] Grounded search failed unexpectedly: ${err.message}`, err.stack);
+            }
+            
+            this.logger.warn(`[getSessionContent] Grounded search failed or returned nothing. One common reason is that the keywords are not present in the indexed documents.`);
+        }
 
+        // Fallback or No Keywords: Original behavior (full content retrieval)
         let content = '';
 
         if (session.knowledgeBaseId) {
+            this.logger.debug(`[getSessionContent] Fetching content for KnowledgeBase: ${kbId}`);
             const kb = await (this.kbService as any).kbRepository.findOne({ where: { id: kbId, tenantId: session.tenantId } });
-            if (kb) content = kb.content || '';
+            if (kb) {
+                content = kb.content || '';
+                this.logger.debug(`[getSessionContent] Found KB content, length: ${content.length}`);
+            } else {
+                this.logger.warn(`[getSessionContent] KnowledgeBase not found: ${kbId}`);
+            }
         } else {
             try {
+                this.logger.debug(`[getSessionContent] Fetching content for KnowledgeGroup: ${kbId}`);
                 const groupFiles = await this.groupService.getGroupFiles(kbId, session.userId, session.tenantId);
+                this.logger.debug(`[getSessionContent] Found ${groupFiles.length} files in group`);
                 content = groupFiles
                     .filter(f => f.content)
-                    .map(f => `--- Document: ${f.title || f.originalName} ---\n${f.content}`)
+                    .map(f => {
+                        this.logger.debug(`[getSessionContent] Including file: ${f.title || f.originalName}, content length: ${f.content?.length || 0}`);
+                        return `--- Document: ${f.title || f.originalName} ---\n${f.content}`;
+                    })
                     .join('\n\n');
+                this.logger.debug(`[getSessionContent] Total group content length: ${content.length}`);
             } catch (err) {
-                this.logger.error(`Failed to get group files: ${err.message}`);
+                this.logger.error(`[getSessionContent] Failed to get group files: ${err.message}`);
             }
         }
 
+        // Apply keyword filter (regex based) as an extra layer if still using full content
+        if (content && keywords.length > 0) {
+            this.logger.debug(`[getSessionContent] Applying fallback keyword filters: ${keywords.join(', ')}`);
+            const prevLen = content.length;
+            content = this.contentFilterService.filterContent(content, keywords);
+            this.logger.debug(`[getSessionContent] After filtering, content length: ${content.length} (was ${prevLen})`);
+        }
+
+        this.logger.log(`[getSessionContent] Final content for AI generation (Length: ${content.length})`);
+        this.logger.debug(`[getSessionContent] Content Preview: ${content.substring(0, 500)}...`);
+        
         return content;
     }
 
@@ -80,34 +187,60 @@ export class AssessmentService {
      * 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 = 'en'): Promise<AssessmentSession> {
+    async startSession(userId: string, kbId: string | undefined, tenantId: string, language: string = 'en', templateId?: string): Promise<AssessmentSession> {
+        this.logger.log(`[startSession] Starting session for user ${userId}, templateId: ${templateId}, kbId: ${kbId}`);
+        let template: AssessmentTemplate | null = null;
+        if (templateId) {
+            template = await this.templateService.findOne(templateId, tenantId);
+            this.logger.debug(`[startSession] Found template: ${template?.name}, linked group: ${template?.knowledgeGroupId}`);
+        }
+
+        // Use kbId if provided, otherwise fall back to template's group ID
+        const activeKbId = kbId || template?.knowledgeGroupId;
+        this.logger.log(`[startSession] activeKbId resolved to: ${activeKbId}`);
+        if (!activeKbId) {
+            this.logger.error(`[startSession] No knowledge source resolved`);
+            throw new Error('Knowledge source (ID or Template) must be provided.');
+        }
+
         // 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 isKb = await (this.kbService as any).kbRepository.count({ where: { id: activeKbId, tenantId } }) > 0;
+        this.logger.debug(`[startSession] isKb: ${isKb}`);
         
-        const tempSession = {
+        const templateData = template ? {
+            name: template.name,
+            keywords: template.keywords,
+            questionCount: template.questionCount,
+            difficultyDistribution: template.difficultyDistribution,
+            style: template.style,
+        } : undefined;
+
+        const sessionData: any = {
             userId,
             tenantId,
-            knowledgeBaseId: isKb ? kbId : undefined,
-            knowledgeGroupId: isKb ? undefined : kbId,
+            knowledgeBaseId: isKb ? activeKbId : undefined,
+            knowledgeGroupId: isKb ? undefined : activeKbId,
+            templateId,
+            templateJson: templateData,
+            status: AssessmentStatus.IN_PROGRESS,
+            language,
         };
 
-        const content = await this.getSessionContent(tempSession);
+        const content = await this.getSessionContent(sessionData);
 
         if (!content || content.trim().length < 10) {
+            this.logger.error(`[startSession] Insufficient content length: ${content?.length || 0}`);
             throw new Error('Selected knowledge source has no sufficient content for evaluation.');
         }
 
-        const session = this.sessionRepository.create({
-            ...tempSession,
-            status: AssessmentStatus.IN_PROGRESS,
-            language,
-        });
-        const savedSession = await this.sessionRepository.save(session);
+        const session = this.sessionRepository.create(sessionData as DeepPartial<AssessmentSession>);
+        const savedSession = await this.sessionRepository.save(session as any) as AssessmentSession;
 
         // Thread ID for LangGraph is the session ID
         savedSession.threadId = savedSession.id;
         await this.sessionRepository.save(savedSession);
 
+        this.logger.log(`[startSession] Session ${savedSession.id} created and saved`);
         return savedSession;
     }
 
@@ -143,12 +276,17 @@ export class AssessmentService {
                         assessmentSessionId: sessionId,
                         knowledgeBaseId: session.knowledgeBaseId || session.knowledgeGroupId || '',
                         messages: [],
+                        questionCount: session.templateJson?.questionCount,
+                        difficultyDistribution: session.templateJson?.difficultyDistribution,
+                        style: session.templateJson?.style,
+                        keywords: session.templateJson?.keywords,
                     };
 
                     const isZh = (session.language || 'en') === 'zh';
                     const isJa = session.language === 'ja';
                     const initialMsg = isZh ? "现在生成评估问题。" : (isJa ? "今すぐアセスメント問題を生成してください。" : "Generate the assessment questions now.");
 
+                    this.logger.log(`[startSessionStream] Starting stream for session ${sessionId}`);
                     const stream = await this.graph.stream(
                         { 
                             ...initialState,
@@ -161,11 +299,17 @@ export class AssessmentService {
                                 model,
                                 knowledgeBaseContent: content,
                                 language: session.language || 'en',
+                                questionCount: session.templateJson?.questionCount,
+                                difficultyDistribution: session.templateJson?.difficultyDistribution,
+                                style: session.templateJson?.style,
+                                keywords: session.templateJson?.keywords,
                             },
                             streamMode: ["values", "updates"]
                         }
                     );
 
+                    this.logger.debug(`[startSessionStream] Graph stream started`);
+
                     for await (const [mode, data] of stream) {
                         if (mode === "updates") {
                             const node = Object.keys(data)[0];
@@ -473,6 +617,10 @@ export class AssessmentService {
                         currentQuestionIndex: session.currentQuestionIndex || 0,
                         followUpCount: session.followUpCount || 0,
                         language: session.language || 'en',
+                        questionCount: session.templateJson?.questionCount,
+                        difficultyDistribution: session.templateJson?.difficultyDistribution,
+                        style: session.templateJson?.style,
+                        keywords: session.templateJson?.keywords,
                     },
                     "interviewer"
                 );
@@ -493,6 +641,7 @@ export class AssessmentService {
                         model,
                         knowledgeBaseContent: content,
                         language: session.language || 'en',
+                        keywords: session.templateJson?.keywords,
                     },
                     streamMode: ["values", "updates"]
                 });

+ 50 - 0
server/src/assessment/controllers/template.controller.ts

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

+ 46 - 0
server/src/assessment/dto/create-template.dto.ts

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

+ 4 - 0
server/src/assessment/dto/update-template.dto.ts

@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/mapped-types';
+import { CreateTemplateDto } from './create-template.dto';
+
+export class UpdateTemplateDto extends PartialType(CreateTemplateDto) {}

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

@@ -12,6 +12,7 @@ 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';
+import { AssessmentTemplate } from './assessment-template.entity';
 
 export enum AssessmentStatus {
     IN_PROGRESS = 'IN_PROGRESS',
@@ -81,6 +82,16 @@ export class AssessmentSession {
     @Column({ type: 'varchar', length: 10, default: 'zh' })
     language: string;
 
+    @Column({ name: 'template_id', nullable: true })
+    templateId: string;
+
+    @ManyToOne(() => AssessmentTemplate, { nullable: true })
+    @JoinColumn({ name: 'template_id' })
+    template: AssessmentTemplate;
+
+    @Column({ type: 'simple-json', name: 'template_json', nullable: true })
+    templateJson: any;
+
     @OneToMany('AssessmentQuestion', (question: AssessmentQuestion) => question.session)
     questions: AssessmentQuestion[];
 

+ 76 - 0
server/src/assessment/entities/assessment-template.entity.ts

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

+ 9 - 1
server/src/assessment/graph/builder.ts

@@ -13,7 +13,14 @@ const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => {
         return "interviewer";
     }
 
-    if (state.currentQuestionIndex < state.questions.length) {
+    const targetCount = state.questionCount || 3;
+    
+    if (state.currentQuestionIndex < targetCount) {
+        // If the next question isn't generated yet, go back to generator
+        if (state.currentQuestionIndex >= state.questions.length) {
+            return "generator";
+        }
+        // If it is generated (e.g. from a batch start or previous retry), go to interviewer
         return "interviewer";
     }
 
@@ -41,6 +48,7 @@ export const createEvaluationGraph = () => {
         // After grading, decide where to go
         .addConditionalEdges("grader", routeAfterGrading, {
             interviewer: "interviewer",
+            generator: "generator",
             analyzer: "analyzer",
         })
 

+ 140 - 67
server/src/assessment/graph/nodes/generator.node.ts

@@ -15,113 +15,186 @@ export const questionGeneratorNode = async (
     console.log("[GeneratorNode] Starting generation...", {
         language: state.language,
         hasModel: !!model,
-        contentLength: knowledgeBaseContent?.length
+        contentLength: knowledgeBaseContent?.length,
+        keywords: state.keywords || []
     });
 
     if (!model || !knowledgeBaseContent) {
+        console.error("[GeneratorNode] Missing model or knowledgeBaseContent");
         throw new Error("Missing model or knowledgeBaseContent in node configuration");
     }
 
+    console.log(`[GeneratorNode] Content provided to AI for generation (Length: ${knowledgeBaseContent?.length || 0})`);
+    // Optional: Log a snippet if needed, but not the whole thing
+    if (knowledgeBaseContent && knowledgeBaseContent.length > 500) {
+        console.log("[GeneratorNode] Content Snippet:", knowledgeBaseContent.substring(0, 500) + "...");
+    } else {
+        console.log("[GeneratorNode] Content:", knowledgeBaseContent);
+    }
+
     const isZh = state.language === 'zh';
     const isJa = state.language === 'ja';
 
-    const systemPromptZh = `你是一位专业的知识评估专家。
-你的任务是根据提供的知识库内容生成 3-5 个高质量的评估问题。
-
-重要提示:
-1. 你必须使用以下语言生成所有问题和内容:中文 (Simplified Chinese)。
-2. 如果提供的知识库内容包含其他语言(如日语或英语),你必须在生成问题和关键点时将其相关术语翻译成中文。
-3. 问题应考察用户对核心概念的理解和应用。
-
-对于每个问题,你必须提供:
-1. 问题文本。
-2. 3-5 个关键点或概念,用户在回答中应当提到这些点以证明其掌握程度。
-3. 难度级别(标准、进阶、专家)。
-4. 出题依据(从知识库中提取的、支持此问题的相关文本片段或事实摘要)。
+    const questionCount = state.questionCount || 3;
+    const style = state.style || 'technical';
+    const difficultyText = state.difficultyDistribution ? JSON.stringify(state.difficultyDistribution) : '随机分布 (Random distribution)';
+    const keywords = state.keywords || [];
+    const keywordText = keywords.length > 0 ? keywords.join(', ') : '无 (None)';
+    
+    // For incremental generation, we check how many we already have
+    const existingQuestions = state.questions || [];
+    const existingQuestionsText = existingQuestions.map((q, i) => `Q${i+1}: ${q.questionText}`).join('\n');
+
+    const systemPromptZh = `你是一位专业的知识评估专家,擅长根据特定的知识库片段(Grounded Context)生成精准的测试题目。
+
+### 强制性出题规则(必须严格遵守,违者扣分):
+1. **关键词重心(最高优先级)**:如果提供了关键词 (${keywordText}),生成的问题**必须且只能**围绕这些关键词展开。关键词是出题的核心,禁止脱离关键词去考查片段中的其他细节。
+2. **绝对依据**:生成的问题和关键点必须且只能依据提供的文本。禁止使用你的通用知识或外部常识。
+3. **禁止重复**:
+   - **绝对禁止**生成与下方“禁止考查的问题列表”中相似的题目。
+   - 必须改变切入角度。如果之前的题目考查了定义,新题目应考查应用、对比或具体细节。
+4. **否定约束**:
+   - 禁止生成“什么是...”、“请简述...”这类过于宽泛的通识题。
+   - 禁止引用文本中未出现的任何文件名或外部链接。
+5. **质量控制**:
+   - 必须在 \`basis\` 中明确引用片段序号(例如:"[1] 原文:...")。
+   - 必须使用 **简体中文** 出题,即使原文是英文或日文,也必须翻译成准确的术语。
+
+### 任务数据:
+目标关键词:${keywordText}
+出题风格:${style}
+难度要求:${difficultyText}
+
+### 禁止考查的问题列表(之前已生成,严禁重复):
+${existingQuestionsText || '无'}
+
+### 输出指令:
+请生成 1 个高质量问题,确保它符合上述所有规则。如果你发现 context 中确实没有 keyword 的相关信息,请在 basis 中说明,并选择 context 中最重要的一个细节进行出题。
 
 请以 JSON 数组格式返回响应:
 [
   {
     "question_text": "...",
-    "key_points": ["...", "..."],
+    "key_points": ["点1", "点2"],
     "difficulty": "...",
-    "basis": "..."
+    "basis": "[n] 引用的原文片段..."
   }
 ]`;
 
-    const systemPromptJa = `あなたは専門的な知識アセスメントのエキスパートです。
-提供されたナレッジベースの内容に基づいて、3〜5 個の高品質なアセスメント問題を作成してください。
-
-重要事項:
-1. すべての問題と内容は、次の言語で生成してください:日本語。
-2. 提供されたナレッジベースの内容に他の言語(英語など)が含まれている場合、問題やキーワードを生成する際に、それらの用語を日本語に翻訳してください。
-3. 問題は、ユーザーがコア概念を理解し、応用できるかを確認するものである必要があります。
-
-各問題について、以下を提供してください:
-1. 問題文。
-2. ユーザーが習熟度を証明するために回答内で言及すべき 3〜5 個のキーポイントまたは概念。
-3. 難易度(標準、上級、スペシャリスト)。
-4. 出題の根拠(この問題を裏付けるナレッジベースから抽出された関連するテキストスニペットまたは事実の要約)。
-
-レスポンスは以下の JSON 配列形式でフォーマットしてください:
+    const systemPromptJa = `あなたは専門的な知識アセスメントのエキスパートです。提供されたナレッジベースの断片(Grounded Context)のみに基づいて、正確なテスト問題を作成してください。
+
+### 必須ルール(厳守事項):
+1. **キーワードへの集中(最優先)**:キーワード (${keywordText}) が指定されている場合、生成される問題は**必ず**そのキーワードを軸にする必要があります。キーワードを無視して他の詳細を問うことは禁止されています。
+2. **絶対的な根拠**:生成される問題とキーポイントは、提供されたテキストのみに基づく必要があります。一般的な知識や外部の情報を使用しないでください。
+3. **重複の禁止**:
+   - 下記の「作成済み問題リスト」にある問題と類似した問題を作成することは**絶対に禁止**です。
+   - 出題の角度を変えてください。前回が定義だった場合は、今回は応用、比較、または別の詳細事項に焦点を当ててください。
+4. **否定的な制約**:
+   - 「...とは何ですか?」のような一般的すぎる問題は避けてください。
+   - テキスト内に未登場のファイル名や外部リンクを引用しないでください。
+5. **品質管理**:
+   - \`basis\` フィールドには、引用番号(例:"[1] 原文:...")を明記してください。
+   - **日本語**で出題してください。
+
+### タスクデータ:
+ターゲットキーワード:${keywordText}
+出題スタイル:${style}
+難易度:${difficultyText}
+
+### 作成済み問題リスト(重複厳禁):
+${existingQuestionsText || 'なし'}
+
+### 出力指示:
+1つの高品質な問題を生成し、上記のすべてのルールに適合していることを確認してください。context 内に keyword に関する情報がない場合は、basis にその旨を記載した上で、context 内の他の重要な詳細に基づいて作成してください。
+
+以下の JSON 配列形式で返してください:
 [
   {
     "question_text": "...",
     "key_points": ["...", "..."],
     "difficulty": "...",
-    "basis": "..."
+    "basis": "[n] 引用した原文..."
   }
 ]`;
 
-    const systemPromptEn = `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: 
-1. You MUST generate all questions and content in English.
-2. If the provided knowledge base content contains other languages (e.g., Japanese or Chinese), you MUST translate the relevant terms into English when generating questions and key points.
-3. Questions should test the user's understanding and application of core concepts.
-
-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).
-4. A basis for the question (the relevant text snippet or fact summary extracted from the knowledge base that supports this question).
-
-Format your response as a JSON array of objects:
+    const systemPromptEn = `You are a professional knowledge assessment expert specializing in generating precise questions based only on provided knowledge snippets (Grounded Context).
+
+### Mandatory Rules (Strict Adherence Required):
+1. **Keyword Focus (Highest Priority)**: If keywords (${keywordText}) are provided, the question **MUST** center exclusively around them. DO NOT deviate to other details in the snippets unless the keywords are missing.
+2. **Absolute Grounding**: Questions and key points MUST be derived exclusively from the provided text. DO NOT use general knowledge or external information.
+3. **No Repetition**:
+   - **Strictly PROHIBITED** from generating questions similar to those in the "Previous Questions" list below.
+   - Change the perspective. If a previous question asked for a definition, ask for an application, comparison, or specific detail.
+4. **Negative Constraints**:
+   - DO NOT generate generic definitions like "What is..." or "Describe the basics of...".
+   - DO NOT reference any external filenames or links not present in the snippets.
+5. **Quality Control**:
+   - You MUST specify the snippet index in the \`basis\` (e.g., "[1] Original text: ...").
+   - Use **English** for output.
+
+### Task Data:
+Target Keywords: ${keywordText}
+Style: ${style}
+Difficulty: ${difficultyText}
+
+### Previous Questions (STRICTLY PROHIBITED TO REPEAT):
+${existingQuestionsText || 'None'}
+
+### Output Instruction:
+Generate ONLY 1 high-quality question that adheres to all rules above. If the context lacks info on keywords, state this in \`basis\` and pick another important detail from the context.
+
+Return as a JSON array:
 [
   {
     "question_text": "...",
     "key_points": ["...", "..."],
     "difficulty": "...",
-    "basis": "..."
+    "basis": "[n] Quoted original text..."
   }
 ]`;
 
     const systemPrompt = isZh ? systemPromptZh : (isJa ? systemPromptJa : systemPromptEn);
-    const humanMsg = isZh ? "现在生成评估问题。" : (isJa ? "今すぐアセスメント問題を生成してください。" : "Generate the assessment questions now.");
-
-    const response = await model.invoke([
-        new SystemMessage(systemPrompt),
-        new HumanMessage(humanMsg),
-    ]);
+    const humanMsg = isZh 
+        ? `强制要求考查的关键词:${keywordText}\n\n请基于以下知识库内容生成 1 个与其密切相关的评估问题:\n\n${knowledgeBaseContent}` 
+        : (isJa 
+            ? `強制対象キーワード:${keywordText}\n\n以下のナレッジベースの内容に基づいて、これに密接に関連する1つのアセスメント問題を生成してください:\n\n${knowledgeBaseContent}` 
+            : `Mandatory Keywords: ${keywordText}\n\nGenerate 1 assessment question strictly related to these keywords based on the following Knowledge Base content:\n\n${knowledgeBaseContent}`);
 
+    console.log("[GeneratorNode] Invoking model...");
     try {
-        const questions = JSON.parse(response.content as string);
-        console.log("[GeneratorNode] Successfully generated questions:", questions.length);
-        return {
-            questions: questions.map((q: any) => ({
+        const response = await model.invoke([
+            new SystemMessage(systemPrompt),
+            new HumanMessage(humanMsg),
+        ]);
+        console.log("[GeneratorNode] AI response received, length:", (response.content as string).length);
+
+        try {
+            const newQuestions = JSON.parse(response.content as string);
+            console.log(`[GeneratorNode] Successfully generated ${newQuestions.length} questions (Expected 1)`);
+            
+            const mappedNewQuestions = newQuestions.map((q: any) => ({
+                id: (existingQuestions.length + 1).toString(), // Assign stable ID for incremental flow
                 questionText: q.question_text,
                 keyPoints: q.key_points,
                 difficulty: q.difficulty,
                 basis: q.basis,
-            })),
-            currentQuestionIndex: 0,
-        };
-    } catch (error) {
-        console.error("[GeneratorNode] Failed to parse questions from AI response:", error);
-        return { 
-            questions: [],
-            currentQuestionIndex: 0
-        };
+            }));
+
+            return {
+                questions: [...existingQuestions, ...mappedNewQuestions],
+                // We keep the current index if we just generated questions for the current session
+                // The grader or router will move the index forward.
+            };
+        } catch (error) {
+            console.error("[GeneratorNode] Failed to parse questions from AI response. Content was:", response.content);
+            console.error("[GeneratorNode] Parse error:", error);
+            return { 
+                questions: [],
+                currentQuestionIndex: 0
+            };
+        }
+    } catch (invokeError) {
+        console.error("[GeneratorNode] Model invocation failed:", invokeError);
+        throw invokeError;
     }
 };

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

@@ -91,6 +91,34 @@ export const EvaluationAnnotation = Annotation.Root({
         reducer: (prev, next) => next ?? prev,
         default: () => 0,
     }),
+
+    /**
+     * Number of questions to generate.
+     */
+    questionCount: Annotation<number | undefined>({
+        reducer: (prev, next) => next ?? prev,
+    }),
+
+    /**
+     * Desired difficulty distribution.
+     */
+    difficultyDistribution: Annotation<any | undefined>({
+        reducer: (prev, next) => next ?? prev,
+    }),
+
+    /**
+     * Desired question style.
+     */
+    style: Annotation<string | undefined>({
+        reducer: (prev, next) => next ?? prev,
+    }),
+
+    /**
+     * Target keywords for question generation.
+     */
+    keywords: Annotation<string[] | undefined>({
+        reducer: (prev, next) => next ?? prev,
+    }),
 });
 
 export type EvaluationState = typeof EvaluationAnnotation.State;

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

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

+ 54 - 0
server/src/assessment/services/template.service.ts

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

+ 4 - 1
server/src/elasticsearch/elasticsearch.service.ts

@@ -511,7 +511,10 @@ export class ElasticsearchService implements OnModuleInit {
   }
 
   
-  private async searchFullTextWithFileFilter(
+  /**
+   * Performs full-text search with file filtering.
+   */
+  public async searchFullTextWithFileFilter(
     query: string,
     userId: string,
     topK: number = 5,

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
server/tsconfig.build.tsbuildinfo


+ 388 - 0
web/components/views/AssessmentTemplateManager.tsx

@@ -0,0 +1,388 @@
+import React, { useState, useEffect } from 'react';
+import { createPortal } from 'react-dom';
+import { Plus, Edit2, Trash2, FileText, Loader2, X, Sparkles, Sliders, Hash, Type, Brain } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { useLanguage } from '../../contexts/LanguageContext';
+import { useToast } from '../../contexts/ToastContext';
+import { useConfirm } from '../../contexts/ConfirmContext';
+import { templateService } from '../../services/templateService';
+import { knowledgeGroupService } from '../../services/knowledgeGroupService';
+import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup } from '../../types';
+
+export const AssessmentTemplateManager: React.FC = () => {
+    const { t } = useLanguage();
+    const { showSuccess, showError } = useToast();
+    const { confirm } = useConfirm();
+    const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
+    const [groups, setGroups] = useState<KnowledgeGroup[]>([]);
+    const [isLoading, setIsLoading] = useState(false);
+    const [isSaving, setIsSaving] = useState(false);
+    const [showModal, setShowModal] = useState(false);
+    const [editingTemplate, setEditingTemplate] = useState<AssessmentTemplate | null>(null);
+    
+    // UI state uses strings for easy input
+    const [formData, setFormData] = useState({
+        name: '',
+        description: '',
+        keywords: '',
+        questionCount: 5,
+        difficultyDistribution: 'Basic: 30%, Intermediate: 40%, Advanced: 30%',
+        style: 'Professional',
+        knowledgeGroupId: '',
+    });
+
+    const fetchTemplates = async () => {
+        setIsLoading(true);
+        try {
+            const data = await templateService.getAll();
+            setTemplates(data);
+        } catch (error) {
+            console.error('Failed to fetch templates:', error);
+            showError(t('actionFailed'));
+        } finally {
+            setIsLoading(false);
+        }
+    };
+
+    const fetchGroups = async () => {
+        try {
+            const data = await knowledgeGroupService.getGroups();
+            setGroups(data);
+        } catch (error) {
+            console.error('Failed to fetch groups:', error);
+        }
+    };
+
+    useEffect(() => {
+        fetchTemplates();
+        fetchGroups();
+    }, []);
+
+    const handleOpenModal = (template?: AssessmentTemplate) => {
+        if (template) {
+            setEditingTemplate(template);
+            setFormData({
+                name: template.name,
+                description: template.description || '',
+                keywords: Array.isArray(template.keywords) ? template.keywords.join(', ') : '',
+                questionCount: template.questionCount,
+                difficultyDistribution: typeof template.difficultyDistribution === 'object' 
+                    ? JSON.stringify(template.difficultyDistribution)
+                    : (template.difficultyDistribution || ''),
+                style: template.style || 'Professional',
+                knowledgeGroupId: template.knowledgeGroupId || '',
+            });
+        } else {
+            setEditingTemplate(null);
+            setFormData({
+                name: '',
+                description: '',
+                keywords: '',
+                questionCount: 5,
+                difficultyDistribution: '{"Basic": 3, "Intermediate": 4, "Advanced": 3}',
+                style: 'Professional',
+                knowledgeGroupId: '',
+            });
+        }
+        setShowModal(true);
+    };
+
+    const handleSave = async (e: React.FormEvent) => {
+        e.preventDefault();
+        setIsSaving(true);
+        try {
+            // Convert UI strings back to required types
+            const keywordsArray = formData.keywords.split(',').map(k => k.trim()).filter(k => k !== '');
+            let diffDist: any = formData.difficultyDistribution;
+            try {
+                if (formData.difficultyDistribution.startsWith('{')) {
+                    diffDist = JSON.parse(formData.difficultyDistribution);
+                }
+            } catch (e) {
+                // Keep as string if parsing fails
+            }
+
+            const payload: CreateTemplateData = {
+                name: formData.name,
+                description: formData.description,
+                keywords: keywordsArray,
+                questionCount: formData.questionCount,
+                difficultyDistribution: diffDist,
+                style: formData.style,
+                knowledgeGroupId: formData.knowledgeGroupId || undefined,
+            };
+
+            if (editingTemplate) {
+                await templateService.update(editingTemplate.id, payload as UpdateTemplateData);
+                showSuccess(t('featureUpdated'));
+            } else {
+                await templateService.create(payload);
+                showSuccess(t('confirm'));
+            }
+            setShowModal(false);
+            fetchTemplates();
+        } catch (error) {
+            console.error('Save failed:', error);
+            showError(t('actionFailed'));
+        } finally {
+            setIsSaving(false);
+        }
+    };
+
+    const handleDelete = async (id: string) => {
+        if (!(await confirm(t('confirmTitle')))) return;
+        try {
+            await templateService.delete(id);
+            showSuccess(t('confirm'));
+            fetchTemplates();
+        } catch (error) {
+            showError(t('actionFailed'));
+        }
+    };
+
+    const renderDifficulty = (dist: any) => {
+        if (typeof dist === 'string') return dist;
+        if (typeof dist === 'object' && dist !== null) {
+            return Object.entries(dist).map(([k, v]) => `${k}: ${v}`).join(', ');
+        }
+        return '';
+    };
+
+    return (
+        <div className="space-y-6">
+            <div className="flex items-center justify-between mb-2">
+                <div className="flex items-center gap-3">
+                    <div className="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center">
+                        <FileText size={22} />
+                    </div>
+                    <div>
+                        <h3 className="text-lg font-bold text-slate-900">{t('assessmentTemplates')}</h3>
+                        <p className="text-xs text-slate-500">{t('assessmentTemplatesSubtitle')}</p>
+                    </div>
+                </div>
+                <button
+                    onClick={() => handleOpenModal()}
+                    className="px-4 py-2.5 bg-indigo-600 text-white rounded-xl text-sm font-black flex items-center gap-2 shadow-lg shadow-indigo-100 hover:bg-indigo-700 transition-all active:scale-95"
+                >
+                    <Plus size={18} />
+                    {t('createTemplate')}
+                </button>
+            </div>
+
+            {isLoading ? (
+                <div className="flex items-center justify-center py-20">
+                    <Loader2 className="w-8 h-8 animate-spin text-indigo-600 opacity-20" />
+                </div>
+            ) : templates.length === 0 ? (
+                <div className="bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-16 text-center">
+                    <FileText className="w-12 h-12 text-slate-200 mx-auto mb-4" />
+                    <p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
+                </div>
+            ) : (
+                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+                    {templates.map((template) => (
+                        <motion.div
+                            key={template.id}
+                            initial={{ opacity: 0, scale: 0.95 }}
+                            animate={{ opacity: 1, scale: 1 }}
+                            className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden"
+                        >
+                            <div className="absolute top-0 right-0 w-24 h-24 bg-indigo-500/5 rounded-full blur-3xl -mr-12 -mt-12" />
+                            
+                            <div className="flex justify-between items-start mb-4 relative z-10">
+                                <h4 className="text-base font-black text-slate-900 truncate pr-8">{template.name}</h4>
+                                <div className="flex gap-1">
+                                    <button
+                                        onClick={() => handleOpenModal(template)}
+                                        className="p-1.5 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all"
+                                    >
+                                        <Edit2 size={14} />
+                                    </button>
+                                    <button
+                                        onClick={() => handleDelete(template.id)}
+                                        className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all"
+                                    >
+                                        <Trash2 size={14} />
+                                    </button>
+                                </div>
+                            </div>
+
+                            <p className="text-xs text-slate-500 mb-4 line-clamp-2 h-8">{template.description || t('noDescription')}</p>
+
+                            <div className="grid grid-cols-2 gap-2 mb-2">
+                                <div className="bg-slate-50 rounded-xl p-2 border border-slate-100">
+                                    <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('questionCount')}</span>
+                                    <span className="text-xs font-bold text-slate-700">{template.questionCount}</span>
+                                </div>
+                                <div className="bg-slate-50 rounded-xl p-2 border border-slate-100 overflow-hidden">
+                                    <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('difficultyDistribution')}</span>
+                                    <span className="text-xs font-bold text-slate-700 truncate block">
+                                        {renderDifficulty(template.difficultyDistribution)}
+                                    </span>
+                                </div>
+                            </div>
+
+                            <div className="bg-indigo-50/30 rounded-xl p-2 border border-indigo-100/50 mb-4 flex items-center gap-2">
+                                <Brain size={12} className="text-indigo-500" />
+                                <span className="text-[10px] font-bold text-indigo-700 truncate">
+                                    {template.knowledgeGroup?.name || t('selectKnowledgeGroup')}
+                                </span>
+                            </div>
+
+                            <div className="flex flex-wrap gap-1.5 pt-4 border-t border-slate-50">
+                                {Array.isArray(template.keywords) && template.keywords.map((kw, i) => (
+                                    <span key={i} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[10px] font-bold rounded-full border border-indigo-100/50">
+                                        {kw}
+                                    </span>
+                                ))}
+                                {(!template.keywords || template.keywords.length === 0) && <span className="text-[10px] text-slate-400 italic">No keywords</span>}
+                            </div>
+                        </motion.div>
+                    ))}
+                </div>
+            )}
+
+            {createPortal(
+                <AnimatePresence>
+                    {showModal && (
+                        <div key="assessment-template-modal" className="fixed inset-0 z-[1000] flex items-center justify-center p-4">
+                            <motion.div
+                                initial={{ opacity: 0 }}
+                                animate={{ opacity: 1 }}
+                                exit={{ opacity: 0 }}
+                                onClick={() => setShowModal(false)}
+                                className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
+                            />
+                            <motion.div
+                                initial={{ opacity: 0, scale: 0.9, y: 20 }}
+                                animate={{ opacity: 1, scale: 1, y: 0 }}
+                                exit={{ opacity: 0, scale: 0.9, y: 20 }}
+                                className="w-full max-w-xl bg-white rounded-[2.5rem] shadow-2xl relative z-10 overflow-hidden"
+                            >
+                                <div className="p-8 pb-4 flex items-center justify-between border-b border-slate-100">
+                                    <div className="flex items-center gap-3">
+                                        <div className="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center">
+                                            {editingTemplate ? <Edit2 size={24} /> : <Plus size={24} />}
+                                        </div>
+                                        <h3 className="text-xl font-black text-slate-900">
+                                            {editingTemplate ? t('editTemplate') : t('createTemplate')}
+                                        </h3>
+                                    </div>
+                                    <button onClick={() => setShowModal(false)} className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-xl transition-all">
+                                        <X size={20} />
+                                    </button>
+                                </div>
+
+                                <form onSubmit={handleSave} className="p-8 space-y-5">
+                                    <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
+                                        <div className="space-y-1.5 md:col-span-2">
+                                            <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
+                                                <Type size={12} className="text-indigo-500" />
+                                                {t('templateName')} *
+                                            </label>
+                                            <input
+                                                required
+                                                className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
+                                                value={formData.name}
+                                                onChange={e => setFormData({ ...formData, name: e.target.value })}
+                                                placeholder="e.g. Senior Frontend Engineer Technical Interview"
+                                            />
+                                        </div>
+
+                                        <div className="space-y-1.5 md:col-span-2">
+                                            <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
+                                                <Sparkles size={12} className="text-indigo-500" />
+                                                {t('keywords')} ({t('keywordsHint')})
+                                            </label>
+                                            <input
+                                                className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
+                                                value={formData.keywords}
+                                                onChange={e => setFormData({ ...formData, keywords: e.target.value })}
+                                                placeholder={t('keywordsHint')}
+                                            />
+                                        </div>
+
+                                        <div className="space-y-1.5">
+                                            <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
+                                                <Hash size={12} className="text-indigo-500" />
+                                                {t('questionCount')}
+                                            </label>
+                                            <input
+                                                type="number"
+                                                className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
+                                                value={formData.questionCount}
+                                                onChange={e => setFormData({ ...formData, questionCount: parseInt(e.target.value) })}
+                                            />
+                                        </div>
+
+                                        <div className="space-y-1.5">
+                                            <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
+                                                <Sliders size={12} className="text-indigo-500" />
+                                                {t('difficultyDistribution')}
+                                            </label>
+                                            <input
+                                                className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
+                                                value={formData.difficultyDistribution}
+                                                onChange={e => setFormData({ ...formData, difficultyDistribution: e.target.value })}
+                                                placeholder='{"Basic": 3, "Inter": 4, "Adv": 3}'
+                                            />
+                                        </div>
+
+                                        <div className="space-y-1.5 md:col-span-2">
+                                            <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
+                                                <Sliders size={12} className="text-indigo-500" />
+                                                {t('selectKnowledgeGroup')} *
+                                            </label>
+                                            <select
+                                                required
+                                                className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all appearance-none cursor-pointer"
+                                                value={formData.knowledgeGroupId}
+                                                onChange={e => setFormData({ ...formData, knowledgeGroupId: e.target.value })}
+                                            >
+                                                <option value="" disabled>{t('selectKnowledgeGroup')}</option>
+                                                {groups.map(group => (
+                                                    <option key={group.id} value={group.id}>{group.name}</option>
+                                                ))}
+                                            </select>
+                                        </div>
+
+                                        <div className="space-y-1.5 md:col-span-2">
+                                            <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
+                                                <Sliders size={12} className="text-indigo-500" />
+                                                {t('style')}
+                                            </label>
+                                            <input
+                                                className="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-[1.25rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
+                                                value={formData.style}
+                                                onChange={e => setFormData({ ...formData, style: e.target.value })}
+                                            />
+                                        </div>
+                                    </div>
+
+                                    <div className="flex justify-end gap-3 pt-4">
+                                        <button
+                                            type="button"
+                                            onClick={() => setShowModal(false)}
+                                            className="px-6 py-4 text-sm font-black text-slate-500 hover:text-slate-700 transition-colors"
+                                        >
+                                            {t('mmCancel')}
+                                        </button>
+                                        <button
+                                            type="submit"
+                                            disabled={isSaving}
+                                            className="px-10 py-4 bg-indigo-600 text-white rounded-[1.25rem] font-black uppercase tracking-widest text-xs shadow-xl shadow-indigo-100 hover:bg-indigo-700 transition-all active:scale-95 flex items-center gap-2"
+                                        >
+                                            {isSaving && <Loader2 size={16} className="animate-spin" />}
+                                            {t('save')}
+                                        </button>
+                                    </div>
+                                </form>
+                            </motion.div>
+                        </div>
+                    )}
+                </AnimatePresence>,
+                document.body
+            )}
+        </div>
+    );
+};

+ 37 - 22
web/components/views/AssessmentView.tsx

@@ -18,7 +18,8 @@ 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 { templateService } from '../../services/templateService';
+import { KnowledgeGroup, AssessmentTemplate } from '../../types';
 import { cn } from '../../src/utils/cn';
 
 interface AssessmentViewProps {
@@ -44,6 +45,8 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
     const [history, setHistory] = useState<AssessmentSession[]>([]);
     const [loadingHistoryId, setLoadingHistoryId] = useState<string | null>(null);
     const [showBasis, setShowBasis] = useState(false);
+    const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
+    const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
 
     const messagesEndRef = useRef<HTMLDivElement>(null);
 
@@ -56,7 +59,16 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
                 console.error('Failed to fetch groups:', err);
             }
         };
+        const fetchTemplates = async () => {
+            try {
+                const data = await templateService.getAll();
+                setTemplates(data);
+            } catch (err) {
+                console.error('Failed to fetch templates:', err);
+            }
+        };
         fetchGroups();
+        fetchTemplates();
     }, []);
 
     useEffect(() => {
@@ -108,14 +120,14 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
     };
 
     const handleStartAssessment = async () => {
-        if (!selectedGroup) return;
+        if (!selectedTemplate) return;
 
         setIsLoading(true);
         setError(null);
         setProcessStep(isZh ? '正在初始化...' : isJa ? '初期化中...' : 'Initializing...');
         
         try {
-            const newSession = await assessmentService.startSession(selectedGroup, language);
+            const newSession = await assessmentService.startSession(selectedGroup || undefined, language, selectedTemplate || undefined);
             setSession(newSession);
 
             for await (const event of assessmentService.startSessionStream(newSession.id)) {
@@ -287,41 +299,44 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
                         <div className="space-y-6">
                             <div>
                                 <label className="block text-sm font-bold text-slate-700 mb-2 ml-1">
-                                    {t('selectKnowledgeGroup')}
+                                    {t('assessmentTemplates')}
                                 </label>
-                                <div className="grid grid-cols-1 gap-3 max-h-60 overflow-y-auto pr-2 custom-scrollbar">
-                                    {groups.map(group => (
+                                <div className="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto pr-2 custom-scrollbar">
+                                    {templates.map(template => (
                                         <button
-                                            key={group.id}
-                                            onClick={() => setSelectedGroup(group.id)}
+                                            key={template.id}
+                                            onClick={() => setSelectedTemplate(template.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"
+                                                "w-full text-left px-4 py-3 rounded-xl border-2 transition-all flex items-center justify-between",
+                                                selectedTemplate === template.id
+                                                    ? "border-indigo-600 bg-indigo-50/50 text-indigo-700 shadow-sm"
+                                                    : "border-slate-100 hover:border-slate-200 text-slate-500 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">{t('filesAvailable', group.fileCount)}</span>
+                                                <span className="text-sm font-bold truncate max-w-[240px]">{template.name}</span>
+                                                <span className="text-[10px] opacity-60 font-medium">
+                                                    {template.questionCount} {t('questionsCountLabel')} • {
+                                                        template.difficultyDistribution
+                                                            ? (typeof template.difficultyDistribution === 'object' 
+                                                                ? Object.entries(template.difficultyDistribution).map(([k, v]) => `${k}:${v}`).join(', ')
+                                                                : String(template.difficultyDistribution))
+                                                            : ''
+                                                    }
+                                                </span>
                                             </div>
-                                            <ChevronRight size={18} className={cn("transition-transform", selectedGroup === group.id ? "translate-x-1" : "opacity-30")} />
+                                            {selectedTemplate === template.id && <div className="w-1.5 h-1.5 bg-indigo-600 rounded-full" />}
                                         </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}
+                                disabled={!selectedTemplate || 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
+                                    !selectedTemplate || isLoading
                                         ? "bg-slate-300 shadow-none cursor-not-allowed"
                                         : "bg-indigo-600 hover:bg-indigo-700 shadow-indigo-200"
                                 )}

+ 120 - 90
web/components/views/SettingsView.tsx

@@ -1,4 +1,5 @@
 import React, { useState, useEffect } from 'react';
+import { createPortal } from 'react-dom';
 import { ModelConfig, ModelType, AppSettings, KnowledgeGroup, Tenant, TenantMember, DEFAULT_SETTINGS } from '../../types';
 import { useLanguage } from '../../contexts/LanguageContext';
 import {
@@ -42,6 +43,7 @@ import {
   Sparkles,
   ToggleRight,
   ToggleLeft,
+  FileText,
 } from "lucide-react";
 import { motion, AnimatePresence } from 'framer-motion';
 import { userService } from '../../services/userService';
@@ -49,6 +51,7 @@ import { settingsService } from '../../services/settingsService';
 import { userSettingService } from '../../services/userSettingService';
 import { knowledgeGroupService } from '../../services/knowledgeGroupService';
 import { apiClient } from '../../services/apiClient';
+import { AssessmentTemplateManager } from './AssessmentTemplateManager';
 
 import { useConfirm } from '../../contexts/ConfirmContext';
 import { useToast } from '../../contexts/ToastContext';
@@ -63,7 +66,7 @@ interface SettingsViewProps {
     initialTab?: TabType;
 }
 
-type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks';
+type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'assessment_templates';
 
 const buildTenantTree = (tenants: Tenant[]): Tenant[] => {
     const map = new Map<string, Tenant>();
@@ -934,101 +937,113 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                 </motion.form>
             )}
 
-            {passwordChangeUserData && (
-                <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center z-[100] p-6">
-                    <motion.div
-                        initial={{ scale: 0.95, opacity: 0 }}
-                        animate={{ scale: 1, opacity: 1 }}
-                        className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
-                    >
-                        <div className="flex items-center justify-between mb-8">
-                            <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('changeUserPassword')}</h3>
-                            <button onClick={() => setPasswordChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
-                                <X size={20} className="text-slate-400" />
-                            </button>
-                        </div>
+            {createPortal(
+                <AnimatePresence>
+                    {passwordChangeUserData && (
+                        <div key="password-change-modal" className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center z-[1000] p-6">
+                            <motion.div
+                                initial={{ scale: 0.95, opacity: 0 }}
+                                animate={{ scale: 1, opacity: 1 }}
+                                exit={{ scale: 0.95, opacity: 0 }}
+                                className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
+                            >
+                                <div className="flex items-center justify-between mb-8">
+                                    <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('changeUserPassword')}</h3>
+                                    <button onClick={() => setPasswordChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
+                                        <X size={20} className="text-slate-400" />
+                                    </button>
+                                </div>
 
-                        <form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-6">
-                            <div>
-                                <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
-                                    {t('newPassword')}
-                                </label>
-                                <input
-                                    type="password"
-                                    value={passwordChangeUserData.newPassword}
-                                    onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
-                                    className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
-                                    placeholder={t('enterNewPassword')}
-                                    required
-                                />
-                            </div>
+                                <form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-6">
+                                    <div>
+                                        <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
+                                            {t('newPassword')}
+                                        </label>
+                                        <input
+                                            type="password"
+                                            value={passwordChangeUserData.newPassword}
+                                            onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
+                                            className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
+                                            placeholder={t('enterNewPassword')}
+                                            required
+                                        />
+                                    </div>
 
-                            <div className="flex gap-4 pt-4">
-                                <button type="button" onClick={() => setPasswordChangeUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
-                                <button type="submit" className="flex-1 py-3.5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('confirmChange')}</button>
-                            </div>
-                        </form>
-                    </motion.div>
-                </div>
+                                    <div className="flex gap-4 pt-4">
+                                        <button type="button" onClick={() => setPasswordChangeUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
+                                        <button type="submit" className="flex-1 py-3.5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('confirmChange')}</button>
+                                    </div>
+                                </form>
+                            </motion.div>
+                        </div>
+                    )}
+                </AnimatePresence>,
+                document.body
             )}
 
             {/* Edit User Modal */}
-            {editUserData && (
-                <div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
-                    <motion.div
-                        initial={{ scale: 0.95, opacity: 0 }}
-                        animate={{ scale: 1, opacity: 1 }}
-                        className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
-                    >
-                        <div className="flex items-center justify-between mb-8">
-                            <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('editUser')}</h3>
-                            <button onClick={() => setEditUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
-                                <X size={20} className="text-slate-400" />
-                            </button>
-                        </div>
+            {createPortal(
+                <AnimatePresence>
+                    {editUserData && (
+                        <div key="edit-user-modal" className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
+                            <motion.div
+                                initial={{ scale: 0.95, opacity: 0 }}
+                                animate={{ scale: 1, opacity: 1 }}
+                                exit={{ scale: 0.95, opacity: 0 }}
+                                className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
+                            >
+                                <div className="flex items-center justify-between mb-8">
+                                    <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('editUser')}</h3>
+                                    <button onClick={() => setEditUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
+                                        <X size={20} className="text-slate-400" />
+                                    </button>
+                                </div>
 
-                        <form onSubmit={(e) => { e.preventDefault(); handleUpdateUser(); }} className="space-y-6">
-                            <div>
-                                <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
-                                    {t('username')}
-                                </label>
-                                <input
-                                    type="text"
-                                    value={editUserData.username}
-                                    onChange={(e) => setEditUserData({ ...editUserData, username: e.target.value })}
-                                    className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
-                                    placeholder={t('usernamePlaceholder')}
-                                    required
-                                />
-                            </div>
+                                <form onSubmit={(e) => { e.preventDefault(); handleUpdateUser(); }} className="space-y-6">
+                                    <div>
+                                        <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
+                                            {t('username')}
+                                        </label>
+                                        <input
+                                            type="text"
+                                            value={editUserData.username}
+                                            onChange={(e) => setEditUserData({ ...editUserData, username: e.target.value })}
+                                            className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
+                                            placeholder={t('usernamePlaceholder')}
+                                            required
+                                        />
+                                    </div>
 
-                            <div>
-                                <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
-                                    {t('displayName') || t('name')}
-                                </label>
-                                <input
-                                    type="text"
-                                    value={editUserData.displayName}
-                                    onChange={(e) => setEditUserData({ ...editUserData, displayName: e.target.value })}
-                                    className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
-                                    placeholder={t('displayNamePlaceholder') || t('namePlaceholder')}
-                                    required
-                                />
-                            </div>
-                            <div className="py-2.5 px-4 bg-indigo-50 rounded-2xl border border-indigo-100/50">
-                                <p className="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-1">{t('globalUserNote') || "Note"}</p>
-                                <p className="text-[11px] text-indigo-700/70 leading-relaxed font-medium">
-                                    {t('roleManagedInOrg') || "Roles are managed within organizations."}
-                                </p>
-                            </div>
+                                    <div>
+                                        <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
+                                            {t('displayName') || t('name')}
+                                        </label>
+                                        <input
+                                            type="text"
+                                            value={editUserData.displayName}
+                                            onChange={(e) => setEditUserData({ ...editUserData, displayName: e.target.value })}
+                                            className="w-full px-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-[14px] font-medium transition-all focus:outline-none focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50"
+                                            placeholder={t('displayNamePlaceholder') || t('namePlaceholder')}
+                                            required
+                                        />
+                                    </div>
+                                    <div className="py-2.5 px-4 bg-indigo-50 rounded-2xl border border-indigo-100/50">
+                                        <p className="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-1">{t('globalUserNote') || "Note"}</p>
+                                        <p className="text-[11px] text-indigo-700/70 leading-relaxed font-medium">
+                                            {t('roleManagedInOrg') || "Roles are managed within organizations."}
+                                        </p>
+                                    </div>
 
-                            <div className="flex gap-4 pt-4">
-                                <button type="button" onClick={() => setEditUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
-                                <button type="submit" className="flex-1 py-3.5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('saveChanges')}</button>
-                            </div>
-                        </form>
-                    </motion.div>
-                </div>
+                                    <div className="flex gap-4 pt-4">
+                                        <button type="button" onClick={() => setEditUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
+                                        <button type="submit" className="flex-1 py-3.5 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600 shadow-xl shadow-slate-100 transition-all">{t('saveChanges')}</button>
+                                    </div>
+                                </form>
+                            </motion.div>
+                        </div>
+                    )}
+                </AnimatePresence>,
+                document.body
             )}
 
             <div className="w-full bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-2xl overflow-hidden shadow-sm">
@@ -2106,6 +2121,16 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                             {t('navTenants')}
                         </button>
                     )}
+                    {isAdmin && (
+                        <button
+                            onClick={() => setActiveTab('assessment_templates')}
+                            className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'assessment_templates' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
+                                }`}
+                        >
+                            <FileText size={18} />
+                            {t('assessmentTemplates')}
+                        </button>
+                    )}
                 </div>
             </div>
 
@@ -2114,10 +2139,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                 <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
                     <div>
                         <h1 className="text-2xl font-bold text-slate-900 leading-tight">
-                            {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : t('navTenants')}
+                            {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : activeTab === 'tenants' ? t('navTenants') : t('assessmentTemplates')}
                         </h1>
                         <p className="text-[15px] text-slate-500 mt-1">
-                            {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : t('tenantsSubtitle')}
+                            {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : activeTab === 'tenants' ? t('tenantsSubtitle') : t('assessmentTemplatesSubtitle')}
                         </p>
                     </div>
                 </div>
@@ -2153,6 +2178,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
                                 {activeTab === 'model' && isAdmin && renderModelTab()}
                                 {activeTab === 'knowledge_base' && isAdmin && renderKnowledgeBaseTab()}
                                 {activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN' && renderTenantsTab()}
+                                {activeTab === 'assessment_templates' && isAdmin && (
+                                    <div className="bg-white rounded-3xl border border-slate-200/60 p-8 shadow-sm">
+                                        <AssessmentTemplateManager />
+                                    </div>
+                                )}
                             </motion.div>
                         </AnimatePresence>
                     </div>

+ 7 - 6
web/services/assessmentService.ts

@@ -29,8 +29,8 @@ export interface AssessmentState {
 }
 
 export class AssessmentService {
-    async startSession(knowledgeBaseId: string, language: string = 'zh'): Promise<AssessmentSession> {
-        const { data } = await apiClient.post<AssessmentSession>('/assessment/start', { knowledgeBaseId, language });
+    async startSession(knowledgeBaseId: string, language: string = 'zh', templateId?: string): Promise<AssessmentSession> {
+        const { data } = await apiClient.post<AssessmentSession>('/assessment/start', { knowledgeBaseId, language, templateId });
         return data;
     }
     async submitAnswer(sessionId: string, answer: string, language: string = 'zh'): Promise<AssessmentState> {
@@ -47,15 +47,16 @@ export class AssessmentService {
         return data;
     }
 
-    async *startSessionStream(sessionId: string): AsyncIterableIterator<any> {
-        const response = await apiClient.request(`/assessment/${sessionId}/start-stream`, {
+    async *startSessionStream(sessionId: string, templateId?: string): AsyncIterableIterator<any> {
+        const query = templateId ? `?templateId=${templateId}` : '';
+        const response = await apiClient.request(`/assessment/${sessionId}/start-stream${query}`, {
             method: 'GET',
         });
         yield* this.parseStream(response);
     }
 
-    async *submitAnswerStream(sessionId: string, answer: string, language: string = 'zh'): AsyncIterableIterator<any> {
-        const query = new URLSearchParams({ answer, language }).toString();
+    async *submitAnswerStream(sessionId: string, answer: string, language: string = 'zh', templateId?: string): AsyncIterableIterator<any> {
+        const query = new URLSearchParams({ answer, language, ...(templateId && { templateId }) }).toString();
         const response = await apiClient.request(`/assessment/${sessionId}/answer-stream?${query}`, {
             method: 'GET',
         });

+ 28 - 0
web/services/templateService.ts

@@ -0,0 +1,28 @@
+import { apiClient } from './apiClient';
+import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData } from '../types';
+
+export const templateService = {
+  async getAll(): Promise<AssessmentTemplate[]> {
+    const response = await apiClient.get<AssessmentTemplate[]>('/assessment/templates');
+    return response.data;
+  },
+
+  async getById(id: string): Promise<AssessmentTemplate> {
+    const response = await apiClient.get<AssessmentTemplate>(`/assessment/templates/${id}`);
+    return response.data;
+  },
+
+  async create(data: CreateTemplateData): Promise<AssessmentTemplate> {
+    const response = await apiClient.post<AssessmentTemplate>('/assessment/templates', data);
+    return response.data;
+  },
+
+  async update(id: string, data: UpdateTemplateData): Promise<AssessmentTemplate> {
+    const response = await apiClient.put<AssessmentTemplate>(`/assessment/templates/${id}`, data);
+    return response.data;
+  },
+
+  async delete(id: string): Promise<void> {
+    await apiClient.delete(`/assessment/templates/${id}`);
+  },
+};

+ 2 - 2
web/components/views/AgentsView.tsx → web/src/components/views/AgentsView.tsx

@@ -1,9 +1,9 @@
 import React from 'react';
-import { useLanguage } from '../../contexts/LanguageContext';
+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';
+import { cn } from '../../utils/cn';
 
 // Mock data based on the provided design
 interface AgentMock {

+ 1 - 1
web/src/pages/workspace/AgentsPage.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
-import { AgentsView } from '../../../components/views/AgentsView';
+import { AgentsView } from '../../components/views/AgentsView';
 
 const AgentsPage: React.FC = () => {
     return (

+ 34 - 0
web/types.ts

@@ -328,3 +328,37 @@ export interface TenantMember {
   createdAt: string;
   updatedAt: string;
 }
+
+// Assessment Template Types
+export interface AssessmentTemplate {
+  id: string;
+  name: string;
+  description?: string;
+  keywords?: string[];
+  questionCount: number;
+  difficultyDistribution?: Record<string, number>;
+  style?: string;
+  knowledgeBaseId?: string;
+  knowledgeGroupId?: string;
+  knowledgeGroup?: KnowledgeGroup;
+  isActive: boolean;
+  version: number;
+  creatorId: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface CreateTemplateData {
+  name: string;
+  description?: string;
+  keywords?: string[];
+  questionCount?: number;
+  difficultyDistribution?: Record<string, number>;
+  style?: string;
+  knowledgeBaseId?: string;
+  knowledgeGroupId?: string;
+}
+
+export interface UpdateTemplateData extends Partial<CreateTemplateData> {
+  isActive?: boolean;
+}

+ 39 - 3
web/utils/translations.ts

@@ -6,6 +6,7 @@ export const translations = {
     appTitle: "简易知识库",
     loginTitle: "系统登录",
     loginDesc: "请输入访问密钥以进入知识库系统",
+    optional: "可选",
     loginButton: "进入系统",
     usernamePlaceholder: "用户名",
     passwordPlaceholder: "密码",
@@ -624,6 +625,17 @@ export const translations = {
     modelManagementSubtitle: "配置全局 AI 模型。",
     kbSettingsSubtitle: "索引和聊天参数的技术配置。",
     tenantsSubtitle: "管理组织架构、成员及其权限。",
+    assessmentTemplates: "测评模板",
+    assessmentTemplatesSubtitle: "管理和配置测评生成模板。",
+    templateName: "模板名称",
+    keywords: "关键词",
+    keywordsHint: "请输入关键词,用逗号分隔",
+    questionCount: "题目数量",
+    questionsCountLabel: "道题目",
+    difficultyDistribution: "难度分布",
+    style: "风格要求",
+    createTemplate: "创建模板",
+    editTemplate: "编辑模板",
 
     allNotes: "所有笔记",
     filterNotesPlaceholder: "筛选笔记...",
@@ -874,6 +886,7 @@ export const translations = {
     appTitle: "Gemini Knowledge Base",
     loginTitle: "System Login",
     loginDesc: "Enter access key to enter the system",
+    optional: "Optional",
     loginButton: "Enter System",
     loginError: "Key cannot be empty",
     unknown: "Unknown",
@@ -1489,13 +1502,24 @@ export const translations = {
     noHistory: "No chat history",
     noHistoryDesc: "Start a conversation to create history",
     loadMore: "Load More",
-    loadingHistoriesFailed: "Failed to load search history",
+    loadingHistoriesFailed: "Failed to load chat history",
     supportedFormatsInfo: "Supports documents, images and code formats",
     generalSettingsSubtitle: "Manage your application preferences.",
     userManagementSubtitle: "View and manage system user information.",
     modelManagementSubtitle: "Configure global AI models.",
     kbSettingsSubtitle: "Technical configuration for indexing and chat parameters.",
-    tenantsSubtitle: "Manage organization structure and members.",
+    tenantsSubtitle: "Manage organizational structure, members and their permissions.",
+    assessmentTemplates: "Assessment Templates",
+    assessmentTemplatesSubtitle: "Manage and configure assessment generation templates.",
+    templateName: "Template Name",
+    keywords: "Keywords",
+    keywordsHint: "Enter keywords, separated by commas",
+    questionCount: "Question Count",
+    questionsCountLabel: "Questions",
+    difficultyDistribution: "Difficulty Distribution",
+    style: "Style Requirements",
+    createTemplate: "Create Template",
+    editTemplate: "Edit Template",
 
     allNotes: "All Notes",
     filterNotesPlaceholder: "Filter notes...",
@@ -1753,6 +1777,7 @@ export const translations = {
     appTitle: "Gemini ナレッジベース",
     loginTitle: "ログイン",
     loginDesc: "システムに入るためのキーを入力してください",
+    optional: "任意",
     loginButton: "ログイン",
     loginError: "キーは必須です",
     unknown: "不明",
@@ -2459,7 +2484,18 @@ export const translations = {
     userManagementSubtitle: "システムユーザー情報の表示と管理。",
     modelManagementSubtitle: "グローバルなAIモデルを設定します。",
     kbSettingsSubtitle: "インデックス作成とチャットパラメータの技術設定。",
-    tenantsSubtitle: "組織構造、メンバー、およびその権限の管理。",
+    tenantsSubtitle: "組織、メンバー、およびその権限を管理します。",
+    assessmentTemplates: "評価テンプレート",
+    assessmentTemplatesSubtitle: "評価生成テンプレートを管理および設定します。",
+    templateName: "テンプレート名",
+    keywords: "キーワード",
+    keywordsHint: "キーワードを入力してください(カンマ区切り)",
+    questionCount: "問題数",
+    questionsCountLabel: "問",
+    difficultyDistribution: "難易度分布",
+    style: "スタイル要件",
+    createTemplate: "テンプレートを作成",
+    editTemplate: "テンプレートを編集",
     allNotes: "すべてのノート",
     filterNotesPlaceholder: "ノートをフィルタリング...",
     startWritingPlaceholder: "書き始める...",

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.