5c974c50de
- 🔴 searchKnowledge: 移除随机mock向量,使用真实embedding - 🔴 userId: 改为NOT NULL,清理遗留调试注释 - 🟡 文件移动事务安全:先移文件再创DB记录 - 🟡 Ollama嵌入并行化:串行→Promise.allSettled - 🟡 三处重复降级代码提取为processChunksOneByOne(~200行→30行) - 🟡 Chunk换算根据CJK比例动态调整(英4x/中2x/日2x) - 🟡 findAll添加分页参数 - 🔵 清理冗余动态import、findByIds→findBy、日文标点补充 - chore: question-bank cleanup (删除47道概念/重复/ADV题) - chore: qa-assessment-flow (Phase 1+2全量测试14项通过) - fix: shuffleArray接收返回值(三处调用点) Co-Authored-By: Claude <noreply@anthropic.com>
372 lines
9.8 KiB
TypeScript
372 lines
9.8 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Param,
|
|
Post,
|
|
Query,
|
|
Request,
|
|
UseGuards,
|
|
Res,
|
|
NotFoundException,
|
|
InternalServerErrorException,
|
|
} from '@nestjs/common';
|
|
import { Response } from 'express';
|
|
import * as path from 'path';
|
|
import { Logger } from '@nestjs/common';
|
|
import { KnowledgeBaseService } from './knowledge-base.service';
|
|
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
|
import { RolesGuard } from '../auth/roles.guard';
|
|
import { Roles } from '../auth/roles.decorator';
|
|
import { UserRole } from '../user/user-role.enum';
|
|
import { Public } from '../auth/public.decorator';
|
|
import { KnowledgeBase } from './knowledge-base.entity';
|
|
import { ChunkConfigService } from './chunk-config.service';
|
|
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
|
|
import { I18nService } from '../i18n/i18n.service';
|
|
|
|
@Controller('knowledge-bases')
|
|
@UseGuards(CombinedAuthGuard, RolesGuard)
|
|
export class KnowledgeBaseController {
|
|
private readonly logger = new Logger(KnowledgeBaseController.name);
|
|
|
|
constructor(
|
|
private readonly knowledgeBaseService: KnowledgeBaseService,
|
|
private readonly chunkConfigService: ChunkConfigService,
|
|
private readonly knowledgeGroupService: KnowledgeGroupService,
|
|
private readonly i18nService: I18nService,
|
|
) {}
|
|
|
|
@Get()
|
|
@UseGuards(CombinedAuthGuard)
|
|
async findAll(
|
|
@Request() req,
|
|
@Query('page') page?: number,
|
|
@Query('limit') limit?: number,
|
|
) {
|
|
return this.knowledgeBaseService.findAll(
|
|
req.user.id,
|
|
req.user.tenantId,
|
|
page ? Number(page) : undefined,
|
|
limit ? Number(limit) : undefined,
|
|
);
|
|
}
|
|
|
|
@Get('stats')
|
|
@UseGuards(CombinedAuthGuard)
|
|
async getStats(
|
|
@Request() req,
|
|
): Promise<{ total: number; uncategorized: number }> {
|
|
return this.knowledgeBaseService.getStats(req.user.id, req.user.tenantId);
|
|
}
|
|
|
|
@Delete('clear')
|
|
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
|
async clearAll(@Request() req): Promise<{ message: string }> {
|
|
await this.knowledgeBaseService.clearAll(req.user.id, req.user.tenantId);
|
|
return { message: this.i18nService.getMessage('kbCleared') };
|
|
}
|
|
|
|
@Post('search')
|
|
async search(@Request() req, @Body() body: { query: string; topK?: number }) {
|
|
return this.knowledgeBaseService.searchKnowledge(
|
|
req.user.id,
|
|
req.user.tenantId, // New
|
|
body.query,
|
|
body.topK || 5,
|
|
);
|
|
}
|
|
|
|
@Post('rag-search')
|
|
async ragSearch(
|
|
@Request() req,
|
|
@Body() body: { query: string; settings: any },
|
|
) {
|
|
return this.knowledgeBaseService.ragSearch(
|
|
req.user.id,
|
|
req.user.tenantId, // New
|
|
body.query,
|
|
body.settings,
|
|
);
|
|
}
|
|
|
|
@Delete(':id')
|
|
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
|
async deleteFile(
|
|
@Request() req,
|
|
@Param('id') fileId: string,
|
|
): Promise<{ message: string }> {
|
|
await this.knowledgeBaseService.deleteFile(
|
|
fileId,
|
|
req.user.id,
|
|
req.user.tenantId,
|
|
);
|
|
return { message: this.i18nService.getMessage('fileDeleted') };
|
|
}
|
|
|
|
@Post(':id/retry')
|
|
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
|
async retryFile(
|
|
@Request() req,
|
|
@Param('id') fileId: string,
|
|
): Promise<KnowledgeBase> {
|
|
return this.knowledgeBaseService.retryFailedFile(
|
|
fileId,
|
|
req.user.id,
|
|
req.user.tenantId,
|
|
);
|
|
}
|
|
|
|
@Get(':id/chunks')
|
|
async getFileChunks(@Request() req, @Param('id') fileId: string) {
|
|
return this.knowledgeBaseService.getFileChunks(
|
|
fileId,
|
|
req.user.id,
|
|
req.user.tenantId,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get chunk config limits (for frontend slider settings)
|
|
* Query parameter: embeddingModelId - embedding model ID
|
|
*/
|
|
@Get('chunk-config/limits')
|
|
async getChunkConfigLimits(
|
|
@Request() req,
|
|
@Query('embeddingModelId') embeddingModelId: string,
|
|
) {
|
|
if (!embeddingModelId) {
|
|
return {
|
|
maxChunkSize: parseInt(process.env.MAX_CHUNK_SIZE || '8191'),
|
|
maxOverlapSize: parseInt(process.env.MAX_OVERLAP_SIZE || '2000'),
|
|
minOverlapSize: 25,
|
|
defaultChunkSize: 200,
|
|
defaultOverlapSize: 40,
|
|
modelInfo: {
|
|
name: this.i18nService.getMessage('modelNotConfigured'),
|
|
maxInputTokens: parseInt(process.env.MAX_CHUNK_SIZE || '8191'),
|
|
maxBatchSize: 2048,
|
|
expectedDimensions: parseInt(
|
|
process.env.DEFAULT_VECTOR_DIMENSIONS || '2560',
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
return await this.chunkConfigService.getFrontendLimits(
|
|
embeddingModelId,
|
|
req.user.id,
|
|
req.user.tenantId,
|
|
);
|
|
}
|
|
|
|
// File group management - requires admin permission
|
|
@Post(':id/groups')
|
|
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
|
async addFileToGroups(
|
|
@Param('id') fileId: string,
|
|
@Body() body: { groupIds: string[] },
|
|
@Request() req,
|
|
) {
|
|
await this.knowledgeGroupService.addFilesToGroup(
|
|
fileId,
|
|
body.groupIds,
|
|
req.user.id,
|
|
req.user.tenantId,
|
|
);
|
|
return { message: this.i18nService.getMessage('groupSyncSuccess') };
|
|
}
|
|
|
|
@Delete(':id/groups/:groupId')
|
|
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
|
async removeFileFromGroup(
|
|
@Param('id') fileId: string,
|
|
@Param('groupId') groupId: string,
|
|
@Request() req,
|
|
) {
|
|
await this.knowledgeGroupService.removeFileFromGroup(
|
|
fileId,
|
|
groupId,
|
|
req.user.id,
|
|
req.user.tenantId,
|
|
);
|
|
return { message: this.i18nService.getMessage('fileDeletedFromGroup') };
|
|
}
|
|
|
|
// PDF preview - public access
|
|
@Public()
|
|
@Get(':id/pdf')
|
|
async getPDFPreview(
|
|
@Param('id') fileId: string,
|
|
@Query('token') token: string,
|
|
@Res() res: Response,
|
|
) {
|
|
try {
|
|
if (!token) {
|
|
throw new NotFoundException(
|
|
this.i18nService.getMessage('accessDeniedNoToken'),
|
|
);
|
|
}
|
|
|
|
const jwt = await import('jsonwebtoken');
|
|
const secret = process.env.JWT_SECRET;
|
|
if (!secret) {
|
|
throw new InternalServerErrorException(
|
|
this.i18nService.getMessage('jwtSecretRequired'),
|
|
);
|
|
}
|
|
|
|
let decoded;
|
|
try {
|
|
decoded = jwt.verify(token, secret) as any;
|
|
} catch {
|
|
throw new NotFoundException(
|
|
this.i18nService.getMessage('invalidToken'),
|
|
);
|
|
}
|
|
|
|
if (decoded.type !== 'pdf-access' || decoded.fileId !== fileId) {
|
|
throw new NotFoundException(
|
|
this.i18nService.getMessage('invalidToken'),
|
|
);
|
|
}
|
|
|
|
const pdfPath = await this.knowledgeBaseService.ensurePDFExists(
|
|
fileId,
|
|
decoded.userId,
|
|
decoded.tenantId, // New
|
|
);
|
|
|
|
const fs = await import('fs');
|
|
const path = await import('path');
|
|
|
|
if (!fs.existsSync(pdfPath)) {
|
|
throw new NotFoundException(
|
|
this.i18nService.getMessage('pdfFileNotFound'),
|
|
);
|
|
}
|
|
|
|
const stat = fs.statSync(pdfPath);
|
|
const fileName = path.basename(pdfPath);
|
|
|
|
if (stat.size === 0) {
|
|
this.logger.warn(`PDF file is empty: ${pdfPath}`);
|
|
try {
|
|
fs.unlinkSync(pdfPath); // Delete empty file
|
|
} catch (e) {}
|
|
throw new NotFoundException(
|
|
this.i18nService.getMessage('pdfFileEmpty'),
|
|
);
|
|
}
|
|
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Length', stat.size);
|
|
|
|
const stream = fs.createReadStream(pdfPath);
|
|
stream.pipe(res);
|
|
} catch (error) {
|
|
if (error instanceof NotFoundException) {
|
|
throw error;
|
|
}
|
|
this.logger.error(`PDF preview error: ${error.message}`);
|
|
throw new NotFoundException(
|
|
this.i18nService.getMessage('pdfConversionFailed'),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Get PDF preview URL
|
|
@Get(':id/pdf-url')
|
|
async getPDFUrl(
|
|
@Param('id') fileId: string,
|
|
@Query('force') force: string,
|
|
@Request() req,
|
|
) {
|
|
try {
|
|
// Trigger PDF conversion
|
|
await this.knowledgeBaseService.ensurePDFExists(
|
|
fileId,
|
|
req.user.id,
|
|
req.user.tenantId,
|
|
force === 'true',
|
|
);
|
|
|
|
// Generate temporary access token
|
|
const jwt = await import('jsonwebtoken');
|
|
|
|
const secret = process.env.JWT_SECRET;
|
|
if (!secret) {
|
|
throw new InternalServerErrorException(
|
|
this.i18nService.getMessage('jwtSecretRequired'),
|
|
);
|
|
}
|
|
|
|
const token = jwt.sign(
|
|
{
|
|
fileId,
|
|
userId: req.user.id,
|
|
tenantId: req.user.tenantId,
|
|
type: 'pdf-access',
|
|
},
|
|
secret,
|
|
{ expiresIn: '1h' },
|
|
);
|
|
|
|
return {
|
|
url: `/api/knowledge-bases/${fileId}/pdf?token=${token}`,
|
|
};
|
|
} catch (error) {
|
|
if (error.message.includes('LibreOffice')) {
|
|
throw new InternalServerErrorException(
|
|
this.i18nService.formatMessage('pdfServiceUnavailable', {
|
|
message: error.message,
|
|
}),
|
|
);
|
|
}
|
|
throw new InternalServerErrorException(error.message);
|
|
}
|
|
}
|
|
|
|
@Get(':id/pdf-status')
|
|
async getPDFStatus(@Param('id') fileId: string, @Request() req) {
|
|
return await this.knowledgeBaseService.getPDFStatus(
|
|
fileId,
|
|
req.user.id,
|
|
req.user.tenantId,
|
|
);
|
|
}
|
|
|
|
// Get specific page of PDF as image
|
|
@Get(':id/page/:index')
|
|
async getPageImage(
|
|
@Param('id') fileId: string,
|
|
@Param('index') index: number,
|
|
@Request() req,
|
|
@Res() res: Response,
|
|
) {
|
|
try {
|
|
const imagePath = await this.knowledgeBaseService.getPageAsImage(
|
|
fileId,
|
|
Number(index),
|
|
req.user.id,
|
|
req.user.tenantId,
|
|
);
|
|
|
|
const fs = await import('fs');
|
|
if (!fs.existsSync(imagePath)) {
|
|
throw new NotFoundException(
|
|
this.i18nService.getMessage('pageImageNotFound'),
|
|
);
|
|
}
|
|
|
|
res.sendFile(path.resolve(imagePath));
|
|
} catch (error) {
|
|
this.logger.error(`Failed to get PDF page image: ${error.message}`);
|
|
throw new NotFoundException(
|
|
this.i18nService.getMessage('pdfPageImageFailed'),
|
|
);
|
|
}
|
|
}
|
|
}
|