diff --git a/.eslintrc.js b/.eslintrc.js index 2141291..03d55f2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,6 +21,15 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', + "@typescript-eslint/ban-types": [ + "error", + { + "types": { + "Function": false, + }, + "extendDefaults": true, + }, + ], }, }; \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 00de7c1..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,15 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - -## [0.0.1] - 2024-02-28 - -### Added - -- Initial release with 5 annotations : `@AIValidate`, `@AISuggest`, `@AIDocumentExtract`, `@AIDocumentSummarize`, `@AIDocumentTag` - -[0.0.1]: https://github.com/sipios/nestjs-generative-ai/releases/tag/v0.0.1 \ No newline at end of file diff --git a/package.json b/package.json index d009d8b..d03224b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "scripts": { "prepare": "husky install", "build": "tsc", + "build:watch": "tsc -w", "release": "semantic-release", "lint": "eslint \"src/**/!(*.d).ts\" --fix", "format": "prettier \"**/*.ts\" --ignore-path ./.prettierignore --write" @@ -38,6 +39,7 @@ "@nestjs/testing": "10.3.0", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^9.2.6", + "@semantic-release/changelog": "^6.0.3", "@tsconfig/recommended": "^1.0.2", "@types/jest": "28.1.6", "@types/multer": "^1.4.11", @@ -75,6 +77,12 @@ "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", + [ + "@semantic-release/changelog", + { + "changelogFile": "CHANGELOG.md" + } + ], "@semantic-release/npm", "@semantic-release/git" ] diff --git a/src/decorators/generative-ai-feedback.decorator.ts b/src/decorators/generative-ai-feedback.decorator.ts new file mode 100644 index 0000000..05190e6 --- /dev/null +++ b/src/decorators/generative-ai-feedback.decorator.ts @@ -0,0 +1,8 @@ +import { Type, UsePipes } from '@nestjs/common'; +import { AICheckPipe } from '../pipes'; + +export function AIFeedback(dtoType: Type): MethodDecorator { + return function (target: any, key: any, descriptor: PropertyDescriptor) { + UsePipes(AICheckPipe(dtoType))(target, key, descriptor); + }; +} diff --git a/src/decorators/generative-ai-check.decorator.ts b/src/decorators/generative-ai-specifications.decorator.ts similarity index 67% rename from src/decorators/generative-ai-check.decorator.ts rename to src/decorators/generative-ai-specifications.decorator.ts index 8e1564d..f340a54 100644 --- a/src/decorators/generative-ai-check.decorator.ts +++ b/src/decorators/generative-ai-specifications.decorator.ts @@ -1,20 +1,14 @@ import { ValidationArguments, registerDecorator } from 'class-validator'; -import { Inject } from '@nestjs/common'; -import { AICheckParams } from '../interfaces/generative-ai.interface'; -import { AIFeedbackEngine } from '../generative-ai.service'; +import { AISpecificationsParams } from '../interfaces/generative-ai.interface'; +import { AIService } from '../generative-ai.service'; import { FieldsSpecificationsStore, ValidationMessageStore } from '../utils'; -export const AICheck = ( +export const AISpecifications = ( specifications: string[], - checkParams?: AICheckParams, + specificationsParams?: AISpecificationsParams, ) => { - const injectFeedbackEngine = Inject(AIFeedbackEngine); - return (target: any, propertyKey: string) => { - injectFeedbackEngine(target, 'feedbackEngine'); - const feedbackEngine: AIFeedbackEngine = target.feedbackEngine; - - if (checkParams === undefined || !checkParams.validate) { + if (specificationsParams === undefined || !specificationsParams.validate) { FieldsSpecificationsStore.setClassFieldsSpecifications( target.constructor.name, { @@ -22,17 +16,16 @@ export const AICheck = ( specifications, }, ); - return; } const validate = async (value, args) => { + const aiService = AIService.getInstance(); const [guidelines] = args.constraints; - const feedback = - await feedbackEngine.generateFeedbackOnInputWithGuidelines( - value, - guidelines, - ); + const feedback = await aiService.generateFeedbackOnInputWithGuidelines( + value, + guidelines, + ); const isValid = feedback.includes("It's good"); @@ -58,7 +51,7 @@ export const AICheck = ( }; registerDecorator({ - name: 'aICheck', + name: 'aISpecifications', target: target.constructor, propertyName: propertyKey, constraints: [specifications], diff --git a/src/decorators/index.ts b/src/decorators/index.ts index 01e8e31..6872f86 100644 --- a/src/decorators/index.ts +++ b/src/decorators/index.ts @@ -1 +1,2 @@ -export * from './generative-ai-check.decorator'; +export * from './generative-ai-specifications.decorator'; +export * from './generative-ai-feedback.decorator'; diff --git a/src/generative-ai.module.ts b/src/generative-ai.module.ts index 79dc638..f1ec2f3 100644 --- a/src/generative-ai.module.ts +++ b/src/generative-ai.module.ts @@ -1,7 +1,5 @@ import { DynamicModule, Global, Module, Provider } from '@nestjs/common'; -import { APP_PIPE } from '@nestjs/core'; -import { AIFeedbackEngine } from './generative-ai.service'; -import { AICheckPipe, AISummarizeDocumentPipe } from './pipes'; +import { AIService } from './generative-ai.service'; import { GenerativeAIModuleAsyncOptions, GenerativeAIModuleOptions, @@ -19,17 +17,9 @@ export class GenerativeAIModule { provide: GENERATIVE_AI_MODULE_OPTIONS, useValue: options, }, - AIFeedbackEngine, - { - provide: APP_PIPE, - useClass: AICheckPipe, - }, - { - provide: APP_PIPE, - useClass: AISummarizeDocumentPipe, - }, + AIService, ], - exports: [AIFeedbackEngine], + exports: [AIService], }; } @@ -37,19 +27,8 @@ export class GenerativeAIModule { return { module: GenerativeAIModule, imports: options.imports || [], - providers: [ - this.createAsyncOptionsProvider(options), - AIFeedbackEngine, - { - provide: APP_PIPE, - useClass: AICheckPipe, - }, - { - provide: APP_PIPE, - useClass: AISummarizeDocumentPipe, - }, - ], - exports: [AIFeedbackEngine], + providers: [this.createAsyncOptionsProvider(options), AIService], + exports: [AIService], }; } diff --git a/src/generative-ai.service.ts b/src/generative-ai.service.ts index 7ce3c41..3ebf2c4 100644 --- a/src/generative-ai.service.ts +++ b/src/generative-ai.service.ts @@ -1,22 +1,31 @@ -import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { StringOutputParser } from '@langchain/core/output_parsers'; import { ChatOpenAI } from '@langchain/openai'; import { GenerativeAIModuleOptions } from './interfaces'; import { GENERATIVE_AI_MODULE_OPTIONS } from './constants'; +import { loadSummarizationChain } from 'langchain/chains'; +import { Document } from '@langchain/core/documents'; @Injectable() -export class AIFeedbackEngine implements OnModuleInit { +export class AIService { private chatModel!: ChatOpenAI; + private static instance: AIService; constructor( @Inject(GENERATIVE_AI_MODULE_OPTIONS) protected readonly options: GenerativeAIModuleOptions, - ) {} - - async onModuleInit() { + ) { this.chatModel = new ChatOpenAI({ openAIApiKey: this.options.modelApiKey, }); + AIService.instance = this; + } + + public static getInstance(): AIService { + if (!AIService.instance) { + throw new Error('AIService instance has not been initialized'); + } + return AIService.instance; } async generateFeedbackOnInputWithGuidelines( @@ -46,4 +55,14 @@ export class AIFeedbackEngine implements OnModuleInit { return result; } + + async summarizeDocuments(docs: Document[]): Promise { + const summarizeChain = loadSummarizationChain(this.chatModel, { + type: 'stuff', + }); + + const result = await summarizeChain.invoke({ input_documents: docs }); + + return result.text; + } } diff --git a/src/interfaces/generative-ai.interface.ts b/src/interfaces/generative-ai.interface.ts index 9427a5a..b1377cc 100644 --- a/src/interfaces/generative-ai.interface.ts +++ b/src/interfaces/generative-ai.interface.ts @@ -8,7 +8,7 @@ export class SummaryEnriched { public summary!: string; } -export interface AICheckParams { +export interface AISpecificationsParams { validate: boolean; } @@ -17,7 +17,7 @@ export interface FieldSpecifications { specifications: string[]; } -export class AIFile { +export class SummaryEnrichedFile { file!: Express.Multer.File; summary!: string; } diff --git a/src/pipes/generative-ai-check.pipe.ts b/src/pipes/generative-ai-check.pipe.ts index 4821880..bb4db01 100644 --- a/src/pipes/generative-ai-check.pipe.ts +++ b/src/pipes/generative-ai-check.pipe.ts @@ -1,41 +1,54 @@ -import { Injectable, PipeTransform, Type } from '@nestjs/common'; +import { Inject, PipeTransform, Type, mixin } from '@nestjs/common'; import { FeedbackEnriched } from '../interfaces/generative-ai.interface'; -import { AIFeedbackEngine } from '../generative-ai.service'; -import { FieldsSpecificationsStore } from '../utils'; - -@Injectable() -export class AICheckPipe - implements PipeTransform>> -{ - constructor( - private expectedType: Type, - private readonly feedbackEngine: AIFeedbackEngine, - ) {} - async transform(value: T): Promise> { - const fieldsSpecifications = - FieldsSpecificationsStore.getClassFieldsSpecifications( - this.expectedType.name, - ); - - if (fieldsSpecifications !== undefined) { - const input = value[fieldsSpecifications.fieldName]; - const specifications = fieldsSpecifications.specifications; - - const feedback = - await this.feedbackEngine.generateFeedbackOnInputWithGuidelines( - input, - specifications, +import { AIService } from '../generative-ai.service'; +import { FieldsSpecificationsStore, memoize } from '../utils'; + +export type IAICheckPipe = { + transform(value: T): Promise>; +}; + +export const AICheckPipe: (expectedType: Type) => Type = + memoize(createAICheckPipe); + +function createAICheckPipe(expectedType: Type): Type { + class MixinAICheckPipe + implements PipeTransform>> + { + protected aiService: AIService; + constructor(@Inject(AIService) feedbackEngine: AIService) { + this.aiService = feedbackEngine; + } + + async transform(value: T): Promise> { + const fieldsSpecifications = + FieldsSpecificationsStore.getClassFieldsSpecifications( + expectedType.name, ); + if (fieldsSpecifications !== undefined) { + const input = value[fieldsSpecifications.fieldName]; + const specifications = fieldsSpecifications.specifications; + + const feedback = + await this.aiService.generateFeedbackOnInputWithGuidelines( + input, + specifications, + ); + + return { + data: value, + feedback, + }; + } + return { data: value, - feedback, + feedback: undefined, }; } - - return { - data: value, - feedback: undefined, - }; } + + const pipe = mixin(MixinAICheckPipe); + + return pipe as Type; } diff --git a/src/pipes/generative-ai-summarize-document.pipe.ts b/src/pipes/generative-ai-summarize-document.pipe.ts index 45c3a19..864ae2c 100644 --- a/src/pipes/generative-ai-summarize-document.pipe.ts +++ b/src/pipes/generative-ai-summarize-document.pipe.ts @@ -3,15 +3,16 @@ import { join } from 'path'; import { tmpdir } from 'os'; import { promises as fs } from 'fs'; import { PDFLoader } from 'langchain/document_loaders/fs/pdf'; -import { ChatOpenAI } from '@langchain/openai'; -import { loadSummarizationChain } from 'langchain/chains'; +import { SummaryEnrichedFile } from '../interfaces'; +import { AIService } from '../generative-ai.service'; @Injectable() export class AISummarizeDocumentPipe implements PipeTransform { + constructor(private readonly aiService: AIService) {} async transform(value: Express.Multer.File, metadata: ArgumentMetadata) { if ( metadata.metatype === undefined || - metadata.metatype.name !== 'AIFile' + metadata.metatype.name !== SummaryEnrichedFile.name ) { return value; } @@ -25,17 +26,11 @@ export class AISummarizeDocumentPipe implements PipeTransform { try { const docs = await loader.loadAndSplit(); - const llm = new ChatOpenAI(); - - const summarizeChain = loadSummarizationChain(llm, { - type: 'stuff', - }); - - const summary = await summarizeChain.invoke({ input_documents: docs }); + const summary = await this.aiService.summarizeDocuments(docs); const result = { file: value, - summary: summary.text, + summary, }; return result; diff --git a/src/utils/index.ts b/src/utils/index.ts index 52d664b..13c1f89 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ +export * from './memoize'; export * from './stores'; diff --git a/src/utils/memoize.ts b/src/utils/memoize.ts new file mode 100644 index 0000000..4fb8eba --- /dev/null +++ b/src/utils/memoize.ts @@ -0,0 +1,15 @@ +const defaultKey = 'default'; + +export function memoize(fn: Function) { + const cache = {}; + return (...args) => { + const n = args[0] || defaultKey; + if (n in cache) { + return cache[n]; + } else { + const result = fn(n === defaultKey ? undefined : n); + cache[n] = result; + return result; + } + }; +}