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,92 @@
|
||||
// server/src/model-config/dto/create-model-config.dto.ts
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Max,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ModelType } from '../../types';
|
||||
|
||||
export class CreateModelConfigDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
modelId: string;
|
||||
|
||||
@IsUrl({ require_tld: false }, { message: 'Base URL must be a valid URL' })
|
||||
@IsOptional()
|
||||
baseUrl?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
apiKey?: string; // API key is optional - allows local models
|
||||
|
||||
@IsEnum(ModelType)
|
||||
@IsNotEmpty()
|
||||
type: ModelType;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1, { message: 'Minimum vector dimension is 1' })
|
||||
@Max(4096, {
|
||||
message: 'Maximum vector dimension is 4096 (Elasticsearch limit)',
|
||||
})
|
||||
@IsOptional()
|
||||
dimensions?: number;
|
||||
|
||||
// ==================== Additional Fields ====================
|
||||
|
||||
/**
|
||||
* Model input token limit (only valid for embedding/rerank)
|
||||
*/
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100000)
|
||||
@IsOptional()
|
||||
maxInputTokens?: number;
|
||||
|
||||
/**
|
||||
* Batch processing limit (only valid for embedding/rerank)
|
||||
*/
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10000)
|
||||
@IsOptional()
|
||||
maxBatchSize?: number;
|
||||
|
||||
/**
|
||||
* Whether this is a vector model
|
||||
*/
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isVectorModel?: boolean;
|
||||
|
||||
/**
|
||||
* Model provider name
|
||||
*/
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
providerName?: string;
|
||||
|
||||
/**
|
||||
* Whether to enable this model
|
||||
*/
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isEnabled?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to use this model as default
|
||||
*/
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isDefault?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// server/src/model-config/dto/model-config-response.dto.ts
|
||||
import { Exclude, Expose, Transform } from 'class-transformer';
|
||||
import { ModelConfig } from '../model-config.entity';
|
||||
|
||||
export class ModelConfigResponseDto {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
baseUrl?: string;
|
||||
|
||||
@Transform(({ value }) => (value ? '********' : undefined))
|
||||
apiKey?: string;
|
||||
|
||||
type: string;
|
||||
isEnabled?: boolean;
|
||||
isDefault?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(partial: Partial<ModelConfig>) {
|
||||
Object.assign(this, partial);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// server/src/model-config/dto/update-model-config.dto.ts
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateModelConfigDto } from './create-model-config.dto';
|
||||
|
||||
export class UpdateModelConfigDto extends PartialType(CreateModelConfigDto) {}
|
||||
@@ -0,0 +1,80 @@
|
||||
// server/src/model-config/model-config.controller.ts
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ModelConfigService } from './model-config.service';
|
||||
import { CreateModelConfigDto } from './dto/create-model-config.dto';
|
||||
import { UpdateModelConfigDto } from './dto/update-model-config.dto';
|
||||
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 { ModelConfigResponseDto } from './dto/model-config-response.dto';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
@Controller('models') // Global prefix /api/models
|
||||
export class ModelConfigController {
|
||||
constructor(private readonly modelConfigService: ModelConfigService) {}
|
||||
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async create(
|
||||
@Body() createModelConfigDto: CreateModelConfigDto,
|
||||
): Promise<ModelConfigResponseDto> {
|
||||
const modelConfig =
|
||||
await this.modelConfigService.create(createModelConfigDto);
|
||||
return plainToClass(ModelConfigResponseDto, modelConfig);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(): Promise<ModelConfigResponseDto[]> {
|
||||
const modelConfigs = await this.modelConfigService.findAll();
|
||||
return modelConfigs.map((mc) => plainToClass(ModelConfigResponseDto, mc));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<ModelConfigResponseDto> {
|
||||
const modelConfig = await this.modelConfigService.findOne(id);
|
||||
return plainToClass(ModelConfigResponseDto, modelConfig);
|
||||
}
|
||||
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateModelConfigDto: UpdateModelConfigDto,
|
||||
): Promise<ModelConfigResponseDto> {
|
||||
const modelConfig = await this.modelConfigService.update(
|
||||
id,
|
||||
updateModelConfigDto,
|
||||
);
|
||||
return plainToClass(ModelConfigResponseDto, modelConfig);
|
||||
}
|
||||
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string): Promise<void> {
|
||||
await this.modelConfigService.remove(id);
|
||||
}
|
||||
|
||||
@Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
|
||||
@Patch(':id/set-default')
|
||||
async setDefault(@Param('id') id: string): Promise<ModelConfigResponseDto> {
|
||||
const modelConfig = await this.modelConfigService.setDefault(id);
|
||||
return plainToClass(ModelConfigResponseDto, modelConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// server/src/model-config/model-config.entity.ts
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('model_configs')
|
||||
export class ModelConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
modelId: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
baseUrl?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
apiKey?: string; // Should be encrypted in production
|
||||
|
||||
@Column({ type: 'text' })
|
||||
type: string; // ModelType enum values
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
dimensions?: number; // Embedding model dimensions, auto-detected and saved by system
|
||||
|
||||
// ==================== Additional Fields ====================
|
||||
// The following fields are only meaningful for embedding/rerank models
|
||||
|
||||
/**
|
||||
* Model input token limit
|
||||
* Example: OpenAI=8191, Gemini=2048
|
||||
*/
|
||||
@Column({ type: 'integer', nullable: true, default: 8191 })
|
||||
maxInputTokens?: number;
|
||||
|
||||
/**
|
||||
* Batch processing limit (max inputs per request)
|
||||
* Example: OpenAI=2048, Gemini=100
|
||||
*/
|
||||
@Column({ type: 'integer', nullable: true, default: 2048 })
|
||||
maxBatchSize?: number;
|
||||
|
||||
/**
|
||||
* Whether this is a vector model (for system identification)
|
||||
*/
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isVectorModel?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to enable this model
|
||||
* Users can disable models they don't use to prevent accidental selection
|
||||
*/
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isEnabled?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to use this model as default
|
||||
* Only one default allowed per type (llm, embedding, rerank)
|
||||
*/
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isDefault?: boolean;
|
||||
|
||||
/**
|
||||
* Model provider name (for display and identification)
|
||||
* Example: "OpenAI", "Google Gemini", "Custom"
|
||||
*/
|
||||
@Column({ type: 'text', nullable: true })
|
||||
providerName?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ModelConfig } from './model-config.entity';
|
||||
import { ModelConfigService } from './model-config.service';
|
||||
import { ModelConfigController } from './model-config.controller';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ModelConfig]),
|
||||
forwardRef(() => TenantModule),
|
||||
],
|
||||
providers: [ModelConfigService],
|
||||
controllers: [ModelConfigController],
|
||||
exports: [ModelConfigService],
|
||||
})
|
||||
export class ModelConfigModule {}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ModelConfig } from './model-config.entity';
|
||||
import { CreateModelConfigDto } from './dto/create-model-config.dto';
|
||||
import { UpdateModelConfigDto } from './dto/update-model-config.dto';
|
||||
import { GLOBAL_TENANT_ID } from '../common/constants';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { ModelType } from '../types';
|
||||
import { I18nService } from '../i18n/i18n.service';
|
||||
|
||||
@Injectable()
|
||||
export class ModelConfigService {
|
||||
constructor(
|
||||
@InjectRepository(ModelConfig)
|
||||
private modelConfigRepository: Repository<ModelConfig>,
|
||||
@Inject(forwardRef(() => TenantService))
|
||||
private readonly tenantService: TenantService,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
createModelConfigDto: CreateModelConfigDto,
|
||||
): Promise<ModelConfig> {
|
||||
const modelConfig = this.modelConfigRepository.create({
|
||||
...createModelConfigDto,
|
||||
});
|
||||
return this.modelConfigRepository.save(modelConfig);
|
||||
}
|
||||
|
||||
async findAll(): Promise<ModelConfig[]> {
|
||||
return this.modelConfigRepository.find();
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<ModelConfig> {
|
||||
const modelConfig = await this.modelConfigRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!modelConfig) {
|
||||
throw new NotFoundException(
|
||||
this.i18nService.formatMessage('modelConfigNotFound', { id }),
|
||||
);
|
||||
}
|
||||
return modelConfig;
|
||||
}
|
||||
|
||||
async findByType(type: string): Promise<ModelConfig[]> {
|
||||
return this.modelConfigRepository.find({ where: { type } });
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
updateModelConfigDto: UpdateModelConfigDto,
|
||||
): Promise<ModelConfig> {
|
||||
const modelConfig = await this.findOne(id);
|
||||
|
||||
// Update the model
|
||||
const updated = this.modelConfigRepository.merge(
|
||||
modelConfig,
|
||||
updateModelConfigDto,
|
||||
);
|
||||
return this.modelConfigRepository.save(updated);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const result = await this.modelConfigRepository.delete({ id });
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException(
|
||||
this.i18nService.formatMessage('modelConfigNotFound', { id }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the specified model as default
|
||||
*/
|
||||
async setDefault(id: string): Promise<ModelConfig> {
|
||||
const modelConfig = await this.findOne(id);
|
||||
|
||||
// Clear default flag for other models of the same type
|
||||
await this.modelConfigRepository
|
||||
.createQueryBuilder()
|
||||
.update(ModelConfig)
|
||||
.set({ isDefault: false })
|
||||
.where('type = :type', { type: modelConfig.type })
|
||||
.execute();
|
||||
|
||||
modelConfig.isDefault = true;
|
||||
return this.modelConfigRepository.save(modelConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model for specified type
|
||||
* Strict rule: Only return models specified in Index Chat Config, throw error if not found
|
||||
*/
|
||||
async findDefaultByType(
|
||||
tenantId: string,
|
||||
type: ModelType,
|
||||
): Promise<ModelConfig> {
|
||||
const settings = await this.tenantService.getSettings(tenantId);
|
||||
if (!settings) {
|
||||
throw new BadRequestException(
|
||||
`Organization settings not found for tenant: ${tenantId}`,
|
||||
);
|
||||
}
|
||||
|
||||
let modelId: string | undefined;
|
||||
if (type === ModelType.LLM) {
|
||||
modelId = settings.selectedLLMId;
|
||||
} else if (type === ModelType.EMBEDDING) {
|
||||
modelId = settings.selectedEmbeddingId;
|
||||
} else if (type === ModelType.RERANK) {
|
||||
modelId = settings.selectedRerankId;
|
||||
}
|
||||
|
||||
if (!modelId) {
|
||||
throw new BadRequestException(
|
||||
`Model of type "${type}" is not configured in Index Chat Config for this organization.`,
|
||||
);
|
||||
}
|
||||
|
||||
const model = await this.modelConfigRepository.findOne({
|
||||
where: { id: modelId, isEnabled: true },
|
||||
});
|
||||
|
||||
if (!model) {
|
||||
throw new BadRequestException(
|
||||
`The configured model for "${type}" (ID: ${modelId}) is either missing or disabled in model management.`,
|
||||
);
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user