feat: implement QuestionBank CRUD with pagination and template query

- Add pagination support to findAll (page, limit query params)
- Add findByTemplateId method to service
- Add GET /by-template/:templateId endpoint to controller
- Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
+297
View File
@@ -0,0 +1,297 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Request,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { ApiKeyGuard } from '../auth/api-key.guard';
import { RagService } from '../rag/rag.service';
import { ChatService } from '../chat/chat.service';
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
import { ModelConfigService } from '../model-config/model-config.service';
import { TenantService } from '../tenant/tenant.service';
import { UserSettingService } from '../user/user-setting.service';
import { I18nService } from '../i18n/i18n.service';
@Controller('v1')
@UseGuards(ApiKeyGuard)
export class ApiV1Controller {
constructor(
private readonly ragService: RagService,
private readonly chatService: ChatService,
private readonly knowledgeBaseService: KnowledgeBaseService,
private readonly modelConfigService: ModelConfigService,
private readonly tenantService: TenantService,
private readonly userSettingService: UserSettingService,
private readonly i18nService: I18nService,
) {}
// ========== Chat / RAG ==========
/**
* POST /api/v1/chat
* Tenant-scoped RAG chat. Supports both streaming (SSE) and standard JSON responses.
* Body: { message, stream?, selectedGroups?, selectedFiles? }
*/
@Post('chat')
async chat(
@Request() req,
@Body()
body: {
message: string;
stream?: boolean;
selectedGroups?: string[];
selectedFiles?: string[];
},
@Res() res: Response,
) {
const { message, stream = false, selectedGroups, selectedFiles } = body;
const user = req.user;
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
// Get organization settings and model configuration
const tenantSettings = await this.tenantService.getSettings(user.tenantId);
const userSetting = await this.userSettingService.getByUser(user.id);
const models = await this.modelConfigService.findAll();
const llmModel =
models.find((m) => m.id === tenantSettings?.selectedLLMId) ??
models.find((m) => m.type === 'llm' && m.isDefault);
if (!llmModel) {
return res
.status(400)
.json({ error: 'No LLM model configured for this user' });
}
const modelConfig = llmModel as any;
if (stream) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
const stream = this.chatService.streamChat(
message,
[], // history
user.id,
modelConfig,
userSetting?.language ?? 'zh', // userLanguage
tenantSettings?.selectedEmbeddingId, // selectedEmbeddingId
selectedGroups, // selectedGroups
selectedFiles, // selectedFiles
undefined, // historyId
tenantSettings?.enableRerank ?? false, // enableRerank
tenantSettings?.selectedRerankId, // selectedRerankId
tenantSettings?.temperature, // temperature
tenantSettings?.maxTokens, // maxTokens
tenantSettings?.topK ?? 5, // topK
tenantSettings?.similarityThreshold ?? 0.3, // similarityThreshold
tenantSettings?.rerankSimilarityThreshold ?? 0.5, // rerankSimilarityThreshold
tenantSettings?.enableQueryExpansion ?? false, // enableQueryExpansion
tenantSettings?.enableHyDE ?? false, // enableHyDE
user.tenantId, // Passing tenantId correctly
);
for await (const chunk of stream) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
}
res.write('data: [DONE]\n\n');
res.end();
} catch (error) {
res.write(
`data: ${JSON.stringify({ type: 'error', data: error.message })}\n\n`,
);
res.end();
}
} else {
// Non-streaming: collect all chunks and return as JSON
try {
let fullContent = '';
const sources: any[] = [];
let historyId: string | undefined;
const chatStream = this.chatService.streamChat(
message,
[],
user.id,
modelConfig,
userSetting?.language ?? 'zh',
tenantSettings?.selectedEmbeddingId,
selectedGroups,
selectedFiles,
undefined, // historyId
tenantSettings?.enableRerank ?? false,
tenantSettings?.selectedRerankId,
tenantSettings?.temperature,
tenantSettings?.maxTokens,
tenantSettings?.topK ?? 5,
tenantSettings?.similarityThreshold ?? 0.3,
tenantSettings?.rerankSimilarityThreshold ?? 0.5,
tenantSettings?.enableQueryExpansion ?? false,
tenantSettings?.enableHyDE ?? false,
user.tenantId, // Passing tenantId correctly
);
for await (const chunk of chatStream) {
if (chunk.type === 'content') fullContent += chunk.data;
else if (chunk.type === 'sources') sources.push(...chunk.data);
else if (chunk.type === 'historyId') historyId = chunk.data;
}
return res.json({ content: fullContent, sources, historyId });
} catch (error) {
return res.status(500).json({ error: error.message });
}
}
}
// ========== Search ==========
/**
* POST /api/v1/search
* Tenant-scoped hybrid search across knowledge base.
* Body: { query, topK?, threshold?, selectedGroups?, selectedFiles? }
*/
@Post('search')
async search(
@Request() req,
@Body()
body: {
query: string;
topK?: number;
threshold?: number;
selectedGroups?: string[];
selectedFiles?: string[];
},
) {
const {
query,
topK = 5,
threshold = 0.3,
selectedGroups,
selectedFiles,
} = body;
const user = req.user;
if (!query) return { error: 'query is required' };
const userSetting = await this.tenantService.getSettings(user.tenantId);
const results = await this.ragService.searchKnowledge(
query,
user.id,
topK,
threshold,
userSetting?.selectedEmbeddingId,
userSetting?.enableFullTextSearch ?? false,
userSetting?.enableRerank ?? false,
userSetting?.selectedRerankId,
selectedGroups,
selectedFiles,
userSetting?.rerankSimilarityThreshold ?? 0.5,
user.tenantId,
userSetting?.enableQueryExpansion ?? false,
userSetting?.enableHyDE ?? false,
);
return { results, total: results.length };
}
// ========== Knowledge Base ==========
/**
* GET /api/v1/knowledge-bases
* List all files belonging to the caller's tenant.
*/
@Get('knowledge-bases')
async listFiles(@Request() req) {
const user = req.user;
const files = await this.knowledgeBaseService.findAll(
user.id,
user.tenantId,
);
return {
files: files.map((f) => ({
id: f.id,
name: f.originalName,
title: f.title,
status: f.status,
size: f.size,
mimetype: f.mimetype,
createdAt: f.createdAt,
})),
total: files.length,
};
}
/**
* POST /api/v1/knowledge-bases/upload
* Upload and index a file into the caller's tenant knowledge base.
*/
@Post('knowledge-bases/upload')
@UseInterceptors(FileInterceptor('file'))
async uploadFile(
@Request() req,
@UploadedFile() file: Express.Multer.File,
@Body()
body: {
mode?: 'fast' | 'precise';
chunkSize?: number;
chunkOverlap?: number;
},
) {
if (!file) return { error: 'file is required' };
const user = req.user;
const kb = await this.knowledgeBaseService.createAndIndex(
file,
user.id,
user.tenantId,
{
mode: body.mode ?? 'fast',
chunkSize: body.chunkSize ? Number(body.chunkSize) : 1000,
chunkOverlap: body.chunkOverlap ? Number(body.chunkOverlap) : 200,
},
);
return {
id: kb.id,
name: kb.originalName,
status: kb.status,
message: 'File uploaded and indexing started',
};
}
/**
* DELETE /api/v1/knowledge-bases/:id
* Delete a specific file from the knowledge base.
*/
@Delete('knowledge-bases/:id')
async deleteFile(@Request() req, @Param('id') id: string) {
const user = req.user;
await this.knowledgeBaseService.deleteFile(id, user.id, user.tenantId);
return { message: this.i18nService.getMessage('fileDeleted') };
}
@Get('knowledge-bases/:id')
async getFile(@Request() req, @Param('id') id: string) {
const user = req.user;
const files = await this.knowledgeBaseService.findAll(
user.id,
user.tenantId,
);
const file = files.find((f) => f.id === id);
if (!file) return { error: 'File not found' };
return file;
}
}
+72
View File
@@ -0,0 +1,72 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import { ApiService } from './api.service';
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
import { ModelConfigService } from '../model-config/model-config.service';
import { I18nService } from '../i18n/i18n.service';
class ChatDto {
prompt: string;
}
@Controller()
export class ApiController {
constructor(
private readonly apiService: ApiService,
private readonly modelConfigService: ModelConfigService,
private readonly i18nService: I18nService,
) {}
@Get('health')
healthCheck() {
return this.apiService.healthCheck();
}
@Post('chat')
@UseGuards(CombinedAuthGuard)
@HttpCode(HttpStatus.OK)
async chat(@Request() req, @Body() chatDto: ChatDto) {
const { prompt } = chatDto;
if (!prompt) {
throw new Error(this.i18nService.getMessage('promptRequired'));
}
try {
// ユーザーの LLM モデル設定を取得
const models = await this.modelConfigService.findAll();
const llmModel = models.find((m) => m.type === 'llm');
if (!llmModel) {
throw new Error(this.i18nService.getMessage('addLLMConfig'));
}
// API key is optional - allows local models
const modelConfigForService = {
id: llmModel.id,
name: llmModel.name,
modelId: llmModel.modelId,
baseUrl: llmModel.baseUrl,
apiKey: llmModel.apiKey,
type: llmModel.type as any,
};
const response = await this.apiService.getChatCompletion(
prompt,
modelConfigForService,
);
return { response };
} catch (error) {
throw new Error(
error.message || this.i18nService.getMessage('internalServerError'),
);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { ApiController } from './api.controller';
import { ApiService } from './api.service';
import { ApiV1Controller } from './api-v1.controller';
import { KnowledgeBaseModule } from '../knowledge-base/knowledge-base.module';
import { AuthModule } from '../auth/auth.module';
import { TenantModule } from '../tenant/tenant.module';
import { ModelConfigModule } from '../model-config/model-config.module';
import { RagModule } from '../rag/rag.module';
import { ChatModule } from '../chat/chat.module';
import { UserModule } from '../user/user.module';
import { MulterModule } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
@Module({
imports: [
KnowledgeBaseModule,
AuthModule,
ModelConfigModule,
RagModule,
ChatModule,
UserModule,
TenantModule,
MulterModule.register({ storage: memoryStorage() }),
],
controllers: [ApiController, ApiV1Controller],
providers: [ApiService],
})
export class ApiModule {}
+48
View File
@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai';
import { ModelConfig } from '../types';
import { I18nService } from '../i18n/i18n.service';
@Injectable()
export class ApiService {
constructor(private i18nService: I18nService) {}
// Simple health check method
healthCheck() {
return { status: 'ok', message: 'API is healthy' };
}
async getChatCompletion(
prompt: string,
modelConfig: ModelConfig,
): Promise<string> {
// API key is optional - allows local models
try {
const llm = this.createLLM(modelConfig);
const response = await llm.invoke(prompt);
return response.content.toString();
} catch (error) {
console.error('LangChain call failed:', error);
if (error.message?.includes('401')) {
throw new Error(this.i18nService.getMessage('invalidApiKey'));
}
throw new Error(
this.i18nService.formatMessage('apiCallFailed', {
message: error.message,
}),
);
}
}
private createLLM(modelConfig: ModelConfig): ChatOpenAI {
return new ChatOpenAI({
temperature: 0.7,
apiKey: modelConfig.apiKey,
modelName: modelConfig.modelId,
configuration: {
baseURL: modelConfig.baseUrl || 'http://localhost:11434/v1',
},
});
}
}