mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
208 lines
6.5 KiB
208 lines
6.5 KiB
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
|
import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator';
|
|
import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator';
|
|
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
|
|
import { CallerFacingError } from '@ghostfolio/api/errors/caller-facing.error';
|
|
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
|
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
|
import {
|
|
AI_ACTIVITIES_FILE_MAXIMUM_SIZE,
|
|
AI_ACTIVITIES_TIMEOUT,
|
|
AI_MAX_ACTIVITIES,
|
|
THROTTLE_AI_ACTIVITIES_LIMIT,
|
|
THROTTLE_AI_ACTIVITIES_TTL
|
|
} from '@ghostfolio/common/config';
|
|
import {
|
|
AiActivitiesResponse,
|
|
AiPromptResponse
|
|
} from '@ghostfolio/common/interfaces';
|
|
import { permissions } from '@ghostfolio/common/permissions';
|
|
import { scopes } from '@ghostfolio/common/scopes';
|
|
import type {
|
|
AiPromptMode,
|
|
ImpersonationContext,
|
|
RequestWithUser
|
|
} from '@ghostfolio/common/types';
|
|
|
|
import {
|
|
Controller,
|
|
Get,
|
|
HttpCode,
|
|
HttpException,
|
|
Inject,
|
|
Logger,
|
|
Param,
|
|
Post,
|
|
Query,
|
|
Res,
|
|
UploadedFile,
|
|
UseGuards,
|
|
UseInterceptors
|
|
} from '@nestjs/common';
|
|
import { REQUEST } from '@nestjs/core';
|
|
import { AuthGuard } from '@nestjs/passport';
|
|
import { FileInterceptor } from '@nestjs/platform-express';
|
|
import { Throttle } from '@nestjs/throttler';
|
|
import type { Response } from 'express';
|
|
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
|
|
|
|
import {
|
|
getDataRowCountOfCsvFile,
|
|
getMediaTypeOfActivityFile
|
|
} from './ai.helper';
|
|
import { AiService } from './ai.service';
|
|
|
|
@Controller('ai')
|
|
export class AiController {
|
|
private readonly logger = new Logger(AiController.name);
|
|
|
|
public constructor(
|
|
private readonly aiService: AiService,
|
|
private readonly apiService: ApiService,
|
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
) {}
|
|
|
|
/**
|
|
* Reads the activities of a CSV, JPEG, PDF or PNG file with Ghostfolio AI,
|
|
* without saving anything. The client imports the activities which the user
|
|
* selects with POST api/v1/import. The weekly limit applies to each user
|
|
* instead of each IP address. The models stop if the client closes the
|
|
* request before the response or at the timeout. An error whose message is
|
|
* written for the caller gives the status 400 with this message, the
|
|
* timeout gives the status 400 with a message for the caller as well.
|
|
*/
|
|
@HasPermission(permissions.enableGhostfolioAi)
|
|
@HttpCode(StatusCodes.OK)
|
|
@Post('activities')
|
|
@Throttle({
|
|
default: {
|
|
getTracker: ({ user }: RequestWithUser) => {
|
|
return user.id;
|
|
},
|
|
limit: THROTTLE_AI_ACTIVITIES_LIMIT,
|
|
ttl: THROTTLE_AI_ACTIVITIES_TTL
|
|
}
|
|
})
|
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard, CustomThrottlerGuard)
|
|
@UseInterceptors(
|
|
FileInterceptor('file', {
|
|
defParamCharset: 'utf8',
|
|
limits: { fileSize: AI_ACTIVITIES_FILE_MAXIMUM_SIZE }
|
|
})
|
|
)
|
|
public async getActivitiesFromFile(
|
|
@Res({ passthrough: true }) response: Response,
|
|
@UploadedFile() file?: { buffer: Buffer; originalname: string }
|
|
): Promise<AiActivitiesResponse> {
|
|
const mediaType = file
|
|
? getMediaTypeOfActivityFile({
|
|
buffer: file.buffer,
|
|
fileName: file.originalname
|
|
})
|
|
: undefined;
|
|
|
|
if (!mediaType) {
|
|
throw new HttpException(
|
|
{
|
|
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
|
|
message: [
|
|
'The file is not a CSV file in UTF-8, a JPEG, a PDF or a PNG file'
|
|
]
|
|
},
|
|
StatusCodes.BAD_REQUEST
|
|
);
|
|
}
|
|
|
|
if (mediaType === 'text/csv') {
|
|
const dataRowCount = getDataRowCountOfCsvFile(file.buffer);
|
|
|
|
if (dataRowCount > AI_MAX_ACTIVITIES) {
|
|
this.logger.warn(`Rejected a CSV file with ${dataRowCount} rows`);
|
|
|
|
throw new HttpException(
|
|
{
|
|
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
|
|
message: [
|
|
`The CSV file has more than ${AI_MAX_ACTIVITIES} rows of activities`
|
|
]
|
|
},
|
|
StatusCodes.BAD_REQUEST
|
|
);
|
|
}
|
|
}
|
|
|
|
const abortController = new AbortController();
|
|
const timeoutSignal = AbortSignal.timeout(AI_ACTIVITIES_TIMEOUT);
|
|
|
|
response.on('close', () => {
|
|
if (!response.writableFinished) {
|
|
abortController.abort();
|
|
}
|
|
});
|
|
|
|
try {
|
|
return await this.aiService.getActivitiesFromFile({
|
|
abortSignal: AbortSignal.any([abortController.signal, timeoutSignal]),
|
|
file: { mediaType, buffer: file.buffer, name: file.originalname },
|
|
user: this.request.user
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof CallerFacingError) {
|
|
throw new HttpException(
|
|
{
|
|
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
|
|
message: [error.message]
|
|
},
|
|
StatusCodes.BAD_REQUEST
|
|
);
|
|
} else if (timeoutSignal.aborted) {
|
|
this.logger.warn(
|
|
`Stopped reading a file of the type ${mediaType} at the timeout of ${AI_ACTIVITIES_TIMEOUT} ms`
|
|
);
|
|
|
|
throw new HttpException(
|
|
{
|
|
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
|
|
message: [
|
|
'Ghostfolio AI needs too much time for the file. Split it into smaller files.'
|
|
]
|
|
},
|
|
StatusCodes.BAD_REQUEST
|
|
);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@Get('prompt/:mode')
|
|
@HasPermission(permissions.readAiPrompt)
|
|
@RequiresScope(scopes.portfolioRead)
|
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
public async getPrompt(
|
|
@Impersonation() { userId, userSettings }: ImpersonationContext,
|
|
@Param('mode') mode: AiPromptMode,
|
|
@Query()
|
|
{ accounts, assetClasses, dataSource, symbol, tags }: FilterDto
|
|
): Promise<AiPromptResponse> {
|
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
|
filterByAccounts: accounts,
|
|
filterByAssetClasses: assetClasses,
|
|
filterByDataSource: dataSource,
|
|
filterBySymbol: symbol,
|
|
filterByTags: tags
|
|
});
|
|
|
|
const prompt = await this.aiService.getPrompt({
|
|
filters,
|
|
mode,
|
|
userId,
|
|
languageCode: this.request.user.settings.settings.language,
|
|
userCurrency: userSettings.baseCurrency
|
|
});
|
|
|
|
return { prompt };
|
|
}
|
|
}
|
|
|