forked from hangshuo652/aurak
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user