import { Body, Controller, Delete, Get, HttpStatus, Param, Patch, Post, Put, Query, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiQuery, ApiTags, } from '@nestjs/swagger'; import { AdminGuard } from 'src/auth/guards/admin.guard'; import { Permissions } from 'src/common/decorators/permission.decorator'; import { Permission } from 'src/common/types/permissions.catalog'; import { MAX_DICTIONARY_BYTES } from 'src/storage/storage.constants'; import { AiV2Client } from './ai-v2.client'; import { AiV2Exception } from './ai-v2.exception'; import { AiV2Service } from './ai-v2.service'; import { CursorPageQueryDto } from './dto/common-query.dto'; import { CreateDomainDto, ListDomainsQueryDto, UpdateDomainDto } from './dto/domains.dto'; import { ListFilesQueryDto } from './dto/files.dto'; import { CountPointsQueryDto, CreatePointDto, GetPointQueryDto, ListPointsQueryDto, PatchPointPayloadDto, ReplacePointDto, SearchPointsQueryDto, } from './dto/points.dto'; import { RetrievalQueryDto } from './dto/retrieval.dto'; @ApiTags('v2 AI') @ApiBearerAuth() @UseGuards(AdminGuard) @Permissions(Permission.DictionariesRead) @Controller('ai/v2') export class AiV2AdminController { constructor( private readonly client: AiV2Client, private readonly aiV2: AiV2Service, ) {} @Get('domains') @ApiOperation({ summary: 'List tenant domains (AI collections)' }) async listDomains(@Query() query: ListDomainsQueryDto) { return this.aiV2.ok(await this.client.listDomains(query.include_disabled)); } @Post('domains') @Permissions(Permission.DictionariesCreate) @ApiOperation({ summary: 'Create a tenant domain' }) @ApiBody({ type: CreateDomainDto }) async createDomain(@Body() body: CreateDomainDto) { return this.aiV2.created(await this.client.createDomain(body)); } @Patch('domains/:domain') @Permissions(Permission.DictionariesWrite) @ApiOperation({ summary: 'Update a tenant domain display name' }) @ApiParam({ name: 'domain' }) @ApiBody({ type: UpdateDomainDto }) async updateDomain( @Param('domain') domain: string, @Body() body: UpdateDomainDto, ) { return this.aiV2.ok(await this.client.updateDomain(domain, body)); } @Delete('domains/:domain') @Permissions(Permission.DictionariesWrite) @ApiOperation({ summary: 'Disable a tenant domain' }) @ApiParam({ name: 'domain' }) async disableDomain(@Param('domain') domain: string) { return this.aiV2.ok(await this.client.disableDomain(domain)); } @Post('domains/:domain/enable') @Permissions(Permission.DictionariesWrite) @ApiOperation({ summary: 'Enable a tenant domain' }) @ApiParam({ name: 'domain' }) async enableDomain(@Param('domain') domain: string) { return this.aiV2.ok(await this.client.enableDomain(domain)); } @Get('domains/:domain/points') @ApiOperation({ summary: 'List points in a domain' }) @ApiParam({ name: 'domain' }) async listDomainPoints( @Param('domain') domain: string, @Query() query: CursorPageQueryDto, ) { return this.aiV2.ok(await this.client.listDomainPoints(domain, query)); } @Post('files') @Permissions(Permission.DictionariesCreate) @ApiOperation({ summary: 'Upload a file into a domain for ingestion' }) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { type: 'object', required: ['file', 'domain'], properties: { file: { type: 'string', format: 'binary' }, domain: { type: 'string', example: 'faq' }, }, }, }) @UseInterceptors( FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: MAX_DICTIONARY_BYTES }, }), ) async uploadFile( @UploadedFile() file: Express.Multer.File, @Body('domain') domain: string, ) { if (!file) { throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'file_required'); } if (!domain?.trim()) { throw new AiV2Exception(HttpStatus.BAD_REQUEST, 'domain_required'); } return this.aiV2.created(await this.client.uploadFile(file, domain.trim())); } @Get('files') @ApiOperation({ summary: 'List uploaded files' }) async listFiles(@Query() query: ListFilesQueryDto) { return this.aiV2.ok(await this.client.listFiles(query)); } @Get('files/:fileId') @ApiOperation({ summary: 'Get file status / ingestion progress' }) @ApiParam({ name: 'fileId' }) async getFile(@Param('fileId') fileId: string) { return this.aiV2.ok(await this.client.getFile(fileId)); } @Delete('files/:fileId') @Permissions(Permission.DictionariesWrite) @ApiOperation({ summary: 'Delete a file (soft-deletes its points)' }) @ApiParam({ name: 'fileId' }) async deleteFile(@Param('fileId') fileId: string) { return this.aiV2.ok(await this.client.deleteFile(fileId)); } @Get('files/:fileId/points') @ApiOperation({ summary: 'List points for a file' }) @ApiParam({ name: 'fileId' }) async listFilePoints( @Param('fileId') fileId: string, @Query() query: CursorPageQueryDto, ) { return this.aiV2.ok(await this.client.listFilePoints(fileId, query)); } @Get('points/count') @ApiOperation({ summary: 'Count points, optionally filtered by domain and/or file' }) async countPoints(@Query() query: CountPointsQueryDto) { return this.aiV2.ok(await this.client.countPoints(query)); } @Get('points/search') @ApiOperation({ summary: 'Keyword search points (not semantic retrieval)' }) async searchPoints(@Query() query: SearchPointsQueryDto) { return this.aiV2.ok(await this.client.searchPoints(query)); } @Get('points') @ApiOperation({ summary: 'List points for a file_id' }) @ApiQuery({ name: 'file_id', required: true }) async listPoints(@Query() query: ListPointsQueryDto) { return this.aiV2.ok(await this.client.listPoints(query)); } @Post('points') @Permissions(Permission.DictionariesCreate) @ApiOperation({ summary: 'Create a point in a file' }) @ApiBody({ type: CreatePointDto }) async createPoint(@Body() body: CreatePointDto) { return this.aiV2.created(await this.client.createPoint(body)); } @Get('points/:pointId') @ApiOperation({ summary: 'Get one point' }) @ApiParam({ name: 'pointId' }) async getPoint( @Param('pointId') pointId: string, @Query() query: GetPointQueryDto, ) { return this.aiV2.ok(await this.client.getPoint(pointId, query.with_vectors)); } @Delete('points/:pointId') @Permissions(Permission.DictionariesWrite) @ApiOperation({ summary: 'Delete a point' }) @ApiParam({ name: 'pointId' }) async deletePoint(@Param('pointId') pointId: string) { return this.aiV2.ok(await this.client.deletePoint(pointId)); } @Put('points/:pointId') @Permissions(Permission.DictionariesWrite) @ApiOperation({ summary: 'Replace point content (version-guarded)' }) @ApiParam({ name: 'pointId' }) @ApiBody({ type: ReplacePointDto }) async replacePoint( @Param('pointId') pointId: string, @Body() body: ReplacePointDto, ) { return this.aiV2.ok(await this.client.replacePoint(pointId, body)); } @Patch('points/:pointId/payload') @Permissions(Permission.DictionariesWrite) @ApiOperation({ summary: 'Patch point payload (version-guarded)' }) @ApiParam({ name: 'pointId' }) @ApiBody({ type: PatchPointPayloadDto }) async patchPointPayload( @Param('pointId') pointId: string, @Body() body: PatchPointPayloadDto, ) { return this.aiV2.ok(await this.client.patchPointPayload(pointId, body)); } @Post('retrieval/query') @ApiOperation({ summary: 'Hybrid semantic retrieval (not the ask API)' }) @ApiBody({ type: RetrievalQueryDto }) async queryRetrieval(@Body() body: RetrievalQueryDto) { return this.aiV2.ok(await this.client.queryRetrieval(body)); } }