add common response and prisma service and icd vector service and icd api
This commit is contained in:
43
package-lock.json
generated
43
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@langchain/community": "^0.3.53",
|
"@langchain/community": "^0.3.53",
|
||||||
"@langchain/openai": "^0.6.9",
|
"@langchain/openai": "^0.6.9",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/swagger": "^11.2.0",
|
"@nestjs/swagger": "^11.2.0",
|
||||||
@@ -3168,6 +3169,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/config": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "16.4.7",
|
||||||
|
"dotenv-expand": "12.0.1",
|
||||||
|
"lodash": "4.17.21"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
|
"rxjs": "^7.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nestjs/config/node_modules/dotenv": {
|
||||||
|
"version": "16.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||||
|
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/core": {
|
"node_modules/@nestjs/core": {
|
||||||
"version": "11.1.6",
|
"version": "11.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.6.tgz",
|
||||||
@@ -6159,6 +6187,21 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv-expand": {
|
||||||
|
"version": "12.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.1.tgz",
|
||||||
|
"integrity": "sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@langchain/community": "^0.3.53",
|
"@langchain/community": "^0.3.53",
|
||||||
"@langchain/openai": "^0.6.9",
|
"@langchain/openai": "^0.6.9",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/swagger": "^11.2.0",
|
"@nestjs/swagger": "^11.2.0",
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { AppController } from './app.controller';
|
|
||||||
import { AppService } from './app.service';
|
|
||||||
|
|
||||||
describe('AppController', () => {
|
|
||||||
let appController: AppController;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const app: TestingModule = await Test.createTestingModule({
|
|
||||||
controllers: [AppController],
|
|
||||||
providers: [AppService],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
appController = app.get<AppController>(AppController);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('root', () => {
|
|
||||||
it('should return "Hello World!"', () => {
|
|
||||||
expect(appController.getHello()).toBe('Hello World!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
|
||||||
import { AppService } from './app.service';
|
|
||||||
|
|
||||||
@Controller()
|
|
||||||
export class AppController {
|
|
||||||
constructor(private readonly appService: AppService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
getHello(): string {
|
|
||||||
return this.appService.getHello();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
|
||||||
import { AppService } from './app.service';
|
|
||||||
import { IcdModule } from './icd/icd.module';
|
import { IcdModule } from './icd/icd.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { ResponseModule } from './common/response/response.module';
|
||||||
|
import { PrismaModule } from './common/prisma/prisma.module';
|
||||||
|
import { IcdCodeModule } from './icd-code/icd-code.module';
|
||||||
|
import { IcdCodeVectorModule } from './icd-code-vector/icd-code-vector.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IcdModule, HealthModule],
|
imports: [IcdModule, HealthModule, ResponseModule, PrismaModule, IcdCodeModule, IcdCodeVectorModule],
|
||||||
controllers: [AppController],
|
controllers: [],
|
||||||
providers: [AppService],
|
providers: [],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AppService {
|
|
||||||
getHello(): string {
|
|
||||||
return 'Hello World!';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
src/common/prisma/prisma.module.ts
Normal file
8
src/common/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma/prisma.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
18
src/common/prisma/prisma/prisma.service.spec.ts
Normal file
18
src/common/prisma/prisma/prisma.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
describe('PrismaService', () => {
|
||||||
|
let service: PrismaService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [PrismaService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<PrismaService>(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
16
src/common/prisma/prisma/prisma.service.ts
Normal file
16
src/common/prisma/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/common/response/response.module.ts
Normal file
8
src/common/response/response.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ResponseService } from './response/response.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [ResponseService],
|
||||||
|
exports: [ResponseService],
|
||||||
|
})
|
||||||
|
export class ResponseModule {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ResponseInterceptor } from './response.interceptor';
|
||||||
|
|
||||||
|
describe('ResponseInterceptor', () => {
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(new ResponseInterceptor()).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
26
src/common/response/response/response.interceptor.ts
Normal file
26
src/common/response/response/response.interceptor.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable, map } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResponseInterceptor<T> implements NestInterceptor<T, any> {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data) => {
|
||||||
|
if (data?.success !== undefined) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: true,
|
||||||
|
data,
|
||||||
|
message: 'Success',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/common/response/response/response.service.spec.ts
Normal file
18
src/common/response/response/response.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ResponseService } from './response.service';
|
||||||
|
|
||||||
|
describe('ResponseService', () => {
|
||||||
|
let service: ResponseService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [ResponseService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<ResponseService>(ResponseService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
38
src/common/response/response/response.service.ts
Normal file
38
src/common/response/response/response.service.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResponseService {
|
||||||
|
success<T>(data: T, message: string = 'Success') {
|
||||||
|
return {
|
||||||
|
status: true,
|
||||||
|
data,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
paginate<T>(data: T[], total: number, page: number, pageSize: number) {
|
||||||
|
return {
|
||||||
|
status: true,
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ids(ids: (string | number)[], message: string = 'Success') {
|
||||||
|
return {
|
||||||
|
status: true,
|
||||||
|
data: ids,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string = 'Error', statusCode: number = 400) {
|
||||||
|
return {
|
||||||
|
status: false,
|
||||||
|
message,
|
||||||
|
statusCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/icd-code-vector/icd-code-vector.module.ts
Normal file
10
src/icd-code-vector/icd-code-vector.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { IcdCodeVectorService } from './icd-code-vector/icd-code-vector.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [IcdCodeVectorService],
|
||||||
|
exports: [IcdCodeVectorService],
|
||||||
|
})
|
||||||
|
export class IcdCodeVectorModule {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { IcdCodeVectorService } from './icd-code-vector.service';
|
||||||
|
|
||||||
|
describe('IcdCodeVectorService', () => {
|
||||||
|
let service: IcdCodeVectorService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [IcdCodeVectorService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<IcdCodeVectorService>(IcdCodeVectorService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
424
src/icd-code-vector/icd-code-vector/icd-code-vector.service.ts
Normal file
424
src/icd-code-vector/icd-code-vector/icd-code-vector.service.ts
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { OpenAIEmbeddings } from '@langchain/openai';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
export interface VectorSearchResult {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
display: string;
|
||||||
|
version: string;
|
||||||
|
category: string;
|
||||||
|
similarity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmbeddingGenerationResult {
|
||||||
|
processed: number;
|
||||||
|
errors: number;
|
||||||
|
totalSample: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IcdCodeVectorService {
|
||||||
|
private readonly logger = new Logger(IcdCodeVectorService.name);
|
||||||
|
private readonly pool: Pool;
|
||||||
|
private embeddings: OpenAIEmbeddings | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
// Initialize PostgreSQL connection pool
|
||||||
|
this.pool = new Pool({
|
||||||
|
connectionString: this.configService.get<string>('DATABASE_URL'),
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.initializeEmbeddings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize OpenAI embeddings
|
||||||
|
*/
|
||||||
|
private async initializeEmbeddings() {
|
||||||
|
try {
|
||||||
|
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
|
||||||
|
if (!apiKey) {
|
||||||
|
this.logger.error(
|
||||||
|
'OPENAI_API_KEY not found. Vector operations require OpenAI API key.',
|
||||||
|
);
|
||||||
|
throw new Error('OPENAI_API_KEY is required for vector operations');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiModel = this.configService.get<string>('OPENAI_API_MODEL');
|
||||||
|
const modelName = apiModel || 'text-embedding-ada-002';
|
||||||
|
|
||||||
|
this.embeddings = new OpenAIEmbeddings({
|
||||||
|
openAIApiKey: apiKey,
|
||||||
|
modelName: modelName,
|
||||||
|
maxConcurrency: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`OpenAI embeddings initialized successfully with model: ${modelName}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to initialize OpenAI embeddings:', error);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to initialize OpenAI embeddings: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding untuk text menggunakan OpenAI
|
||||||
|
*/
|
||||||
|
async generateEmbedding(text: string): Promise<number[]> {
|
||||||
|
try {
|
||||||
|
this.logger.log(
|
||||||
|
`Generating embedding for text: ${text.substring(0, 100)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.embeddings) {
|
||||||
|
throw new Error(
|
||||||
|
'OpenAI embeddings not initialized. Please check your API configuration.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use OpenAI embeddings
|
||||||
|
const embedding = await this.embeddings.embedQuery(text);
|
||||||
|
this.logger.log(
|
||||||
|
`Generated OpenAI embedding with ${embedding.length} dimensions`,
|
||||||
|
);
|
||||||
|
return embedding;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error generating embedding:', error);
|
||||||
|
throw new Error(`Failed to generate embedding: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate dan simpan embeddings untuk ICD codes (default: 100)
|
||||||
|
*/
|
||||||
|
async generateAndStoreEmbeddings(
|
||||||
|
limit: number = 100,
|
||||||
|
): Promise<EmbeddingGenerationResult> {
|
||||||
|
try {
|
||||||
|
this.logger.log(
|
||||||
|
`Starting batch embedding generation and storage for ${limit} ICD codes...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get ICD codes without embeddings using raw SQL
|
||||||
|
const codesWithoutEmbedding = await this.pool.query(
|
||||||
|
'SELECT id, code, display, version, category FROM icd_codes WHERE embedding IS NULL LIMIT $1',
|
||||||
|
[limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (codesWithoutEmbedding.rows.length === 0) {
|
||||||
|
this.logger.log('All ICD codes already have embeddings');
|
||||||
|
return { processed: 0, errors: 0, totalSample: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Found ${codesWithoutEmbedding.rows.length} codes without embeddings (limited to ${limit})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
// Process each code
|
||||||
|
for (let i = 0; i < codesWithoutEmbedding.rows.length; i++) {
|
||||||
|
const code = codesWithoutEmbedding.rows[i];
|
||||||
|
try {
|
||||||
|
// Create text representation for embedding
|
||||||
|
const text = `${code.code} - ${code.display}`;
|
||||||
|
|
||||||
|
// Generate embedding
|
||||||
|
const embedding = await this.generateEmbedding(text);
|
||||||
|
|
||||||
|
// Convert embedding array to proper vector format for pgvector
|
||||||
|
const vectorString = `[${embedding.join(',')}]`;
|
||||||
|
|
||||||
|
// Update database with embedding, metadata, and content using raw SQL
|
||||||
|
await this.pool.query(
|
||||||
|
`UPDATE icd_codes
|
||||||
|
SET embedding = $1::vector,
|
||||||
|
metadata = $2::jsonb,
|
||||||
|
content = $3,
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[
|
||||||
|
vectorString,
|
||||||
|
JSON.stringify({
|
||||||
|
id: code.id,
|
||||||
|
code: code.code,
|
||||||
|
display: code.display,
|
||||||
|
version: code.version,
|
||||||
|
category: code.category,
|
||||||
|
}),
|
||||||
|
text,
|
||||||
|
code.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
|
||||||
|
if (processed % 10 === 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Processed ${processed}/${codesWithoutEmbedding.rows.length} embeddings`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error processing code ${code.code}:`, error);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Embedding generation and storage completed. Processed: ${processed}, Errors: ${errors}, Total: ${codesWithoutEmbedding.rows.length}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
processed,
|
||||||
|
errors,
|
||||||
|
totalSample: codesWithoutEmbedding.rows.length,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error in generateAndStoreEmbeddings:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate dan simpan embeddings untuk ICD codes dengan kategori tertentu
|
||||||
|
*/
|
||||||
|
async generateAndStoreEmbeddingsByCategory(
|
||||||
|
category: string,
|
||||||
|
limit: number = 100,
|
||||||
|
): Promise<EmbeddingGenerationResult & { category: string }> {
|
||||||
|
try {
|
||||||
|
this.logger.log(
|
||||||
|
`Starting batch embedding generation for ${limit} ICD codes in category: ${category}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get ICD codes by category without embeddings using raw SQL
|
||||||
|
const codesWithoutEmbedding = await this.pool.query(
|
||||||
|
'SELECT id, code, display, version, category FROM icd_codes WHERE embedding IS NULL AND category = $1 LIMIT $2',
|
||||||
|
[category, limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (codesWithoutEmbedding.rows.length === 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`No ICD codes found in category '${category}' without embeddings`,
|
||||||
|
);
|
||||||
|
return { processed: 0, errors: 0, totalSample: 0, category };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Found ${codesWithoutEmbedding.rows.length} codes in category '${category}' without embeddings (limited to ${limit})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
// Process each code
|
||||||
|
for (let i = 0; i < codesWithoutEmbedding.rows.length; i++) {
|
||||||
|
const code = codesWithoutEmbedding.rows[i];
|
||||||
|
try {
|
||||||
|
// Create text representation for embedding
|
||||||
|
const text = `${code.code} - ${code.display}`;
|
||||||
|
|
||||||
|
// Generate embedding
|
||||||
|
const embedding = await this.generateEmbedding(text);
|
||||||
|
|
||||||
|
// Convert embedding array to proper vector format for pgvector
|
||||||
|
const vectorString = `[${embedding.join(',')}]`;
|
||||||
|
|
||||||
|
// Update database with embedding, metadata, and content using raw SQL
|
||||||
|
await this.pool.query(
|
||||||
|
`UPDATE icd_codes
|
||||||
|
SET embedding = $1::vector,
|
||||||
|
metadata = $2::jsonb,
|
||||||
|
content = $3,
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[
|
||||||
|
vectorString,
|
||||||
|
JSON.stringify({
|
||||||
|
id: code.id,
|
||||||
|
code: code.code,
|
||||||
|
display: code.display,
|
||||||
|
version: code.version,
|
||||||
|
category: code.category,
|
||||||
|
}),
|
||||||
|
text,
|
||||||
|
code.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
|
||||||
|
if (processed % 10 === 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Processed ${processed}/${codesWithoutEmbedding.rows.length} embeddings in category '${category}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error processing code ${code.code}:`, error);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Embedding generation completed for category '${category}'. Processed: ${processed}, Errors: ${errors}, Total: ${codesWithoutEmbedding.rows.length}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
processed,
|
||||||
|
errors,
|
||||||
|
totalSample: codesWithoutEmbedding.rows.length,
|
||||||
|
category,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error in generateAndStoreEmbeddingsByCategory for category '${category}':`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vector similarity search menggunakan pgvector dengan threshold dari config
|
||||||
|
*/
|
||||||
|
async search(
|
||||||
|
query: string,
|
||||||
|
category?: string,
|
||||||
|
limit: number = 10,
|
||||||
|
): Promise<VectorSearchResult[]> {
|
||||||
|
try {
|
||||||
|
// Get threshold from config service
|
||||||
|
const threshold = this.configService.get<number>('THRESHOLD', 0.85);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Performing vector search for: "${query}" with threshold: ${threshold}${category ? `, category: ${category}` : ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.embeddings) {
|
||||||
|
throw new Error('OpenAI embeddings not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate embedding for query
|
||||||
|
const queryEmbedding = await this.generateEmbedding(query);
|
||||||
|
|
||||||
|
// Convert embedding array to proper vector format for pgvector
|
||||||
|
const vectorString = `[${queryEmbedding.join(',')}]`;
|
||||||
|
|
||||||
|
// Build SQL query for vector similarity search
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
id, code, display, version, category,
|
||||||
|
(1 - (embedding <=> $1::vector)) as similarity
|
||||||
|
FROM icd_codes
|
||||||
|
WHERE embedding IS NOT NULL
|
||||||
|
AND (1 - (embedding <=> $1::vector)) >= $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [vectorString, threshold];
|
||||||
|
let paramIndex = 3;
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
sql += ` AND category = $${paramIndex}`;
|
||||||
|
params.push(category);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order by similarity descending and limit results
|
||||||
|
sql += ` ORDER BY similarity DESC LIMIT $${paramIndex}`;
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
// Execute raw SQL query
|
||||||
|
const result = await this.pool.query(sql, params);
|
||||||
|
|
||||||
|
// Transform results
|
||||||
|
const searchResults: VectorSearchResult[] = result.rows.map(
|
||||||
|
(row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
display: row.display,
|
||||||
|
version: row.version,
|
||||||
|
category: row.category,
|
||||||
|
similarity: parseFloat(row.similarity),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Vector search returned ${searchResults.length} results for query: "${query}" with threshold: ${threshold}`,
|
||||||
|
);
|
||||||
|
return searchResults;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error in vector search:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search dengan kategori spesifik (ICD-9 atau ICD-10)
|
||||||
|
*/
|
||||||
|
async searchByCategory(
|
||||||
|
query: string,
|
||||||
|
category: 'ICD9' | 'ICD10',
|
||||||
|
limit: number = 10,
|
||||||
|
): Promise<VectorSearchResult[]> {
|
||||||
|
return this.search(query, category, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get embedding statistics
|
||||||
|
*/
|
||||||
|
async getEmbeddingStats(): Promise<{
|
||||||
|
total: number;
|
||||||
|
withEmbeddings: number;
|
||||||
|
withoutEmbeddings: number;
|
||||||
|
percentage: number;
|
||||||
|
threshold: number;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// Use raw SQL to get embedding statistics
|
||||||
|
const [totalResult, withEmbeddingsResult] = await Promise.all([
|
||||||
|
this.pool.query('SELECT COUNT(*) as count FROM icd_codes'),
|
||||||
|
this.pool.query(
|
||||||
|
'SELECT COUNT(*) as count FROM icd_codes WHERE embedding IS NOT NULL',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(totalResult.rows[0].count);
|
||||||
|
const withEmbeddings = parseInt(withEmbeddingsResult.rows[0].count);
|
||||||
|
const withoutEmbeddings = total - withEmbeddings;
|
||||||
|
const percentage = total > 0 ? (withEmbeddings / total) * 100 : 0;
|
||||||
|
const threshold = this.configService.get<number>('THRESHOLD', 0.85);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
withEmbeddings,
|
||||||
|
withoutEmbeddings,
|
||||||
|
percentage: Math.round(percentage * 100) / 100,
|
||||||
|
threshold,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error getting embedding stats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current threshold configuration
|
||||||
|
*/
|
||||||
|
getThreshold(): number {
|
||||||
|
return this.configService.get<number>('THRESHOLD', 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup resources
|
||||||
|
*/
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/icd-code/icd-code.module.ts
Normal file
13
src/icd-code/icd-code.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IcdCodeService } from './icd-code/icd-code.service';
|
||||||
|
import { IcdCodeController } from './icd-code/icd-code.controller';
|
||||||
|
import { PrismaModule } from 'src/common/prisma/prisma.module';
|
||||||
|
import { ResponseModule } from 'src/common/response/response.module';
|
||||||
|
import { IcdCodeVectorModule } from '../icd-code-vector/icd-code-vector.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, ResponseModule, IcdCodeVectorModule],
|
||||||
|
providers: [IcdCodeService],
|
||||||
|
controllers: [IcdCodeController],
|
||||||
|
})
|
||||||
|
export class IcdCodeModule {}
|
||||||
18
src/icd-code/icd-code/icd-code.controller.spec.ts
Normal file
18
src/icd-code/icd-code/icd-code.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { IcdCodeController } from './icd-code.controller';
|
||||||
|
|
||||||
|
describe('IcdCodeController', () => {
|
||||||
|
let controller: IcdCodeController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [IcdCodeController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<IcdCodeController>(IcdCodeController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
419
src/icd-code/icd-code/icd-code.controller.ts
Normal file
419
src/icd-code/icd-code/icd-code.controller.ts
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Body,
|
||||||
|
HttpStatus,
|
||||||
|
HttpCode,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { IcdCodeService } from './icd-code.service';
|
||||||
|
import { IcdCodeVectorService } from '../../icd-code-vector/icd-code-vector/icd-code-vector.service';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiBody,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBadRequestResponse,
|
||||||
|
ApiInternalServerErrorResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { ResponseService } from 'src/common/response/response/response.service';
|
||||||
|
|
||||||
|
export class GenerateEmbeddingsRequestDto {
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerateEmbeddingsByCategoryRequestDto {
|
||||||
|
category: 'ICD9' | 'ICD10';
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VectorSearchRequestDto {
|
||||||
|
query: string;
|
||||||
|
category?: 'ICD9' | 'ICD10';
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerateEmbeddingsResponseDto {
|
||||||
|
message: string;
|
||||||
|
processed: number;
|
||||||
|
errors: number;
|
||||||
|
totalSample: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VectorSearchResponseDto {
|
||||||
|
results: any[];
|
||||||
|
total: number;
|
||||||
|
query: string;
|
||||||
|
category?: string;
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('ICD Code')
|
||||||
|
@Controller('icd-code')
|
||||||
|
export class IcdCodeController {
|
||||||
|
constructor(
|
||||||
|
private readonly icdCodeService: IcdCodeService,
|
||||||
|
private readonly responseService: ResponseService,
|
||||||
|
private readonly icdCodeVectorService: IcdCodeVectorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Search ICD codes with filters and pagination',
|
||||||
|
description:
|
||||||
|
'Search for ICD codes using various filters like category, search term, with pagination support. Returns a paginated list of matching ICD codes.',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'search',
|
||||||
|
required: false,
|
||||||
|
description: 'Search term for ICD code or description',
|
||||||
|
example: 'diabetes',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'page',
|
||||||
|
required: false,
|
||||||
|
description: 'Page number for pagination',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'limit',
|
||||||
|
required: false,
|
||||||
|
description: 'Number of items per page',
|
||||||
|
example: 10,
|
||||||
|
})
|
||||||
|
async findIcdCodes(
|
||||||
|
@Query('search') search: string,
|
||||||
|
@Query('page') page: string,
|
||||||
|
@Query('limit') limit: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await this.icdCodeService.findIcdCodes(
|
||||||
|
search,
|
||||||
|
Number(page),
|
||||||
|
Number(limit),
|
||||||
|
);
|
||||||
|
return this.responseService.paginate(
|
||||||
|
result.data,
|
||||||
|
result.page,
|
||||||
|
result.limit,
|
||||||
|
result.total,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return this.responseService.error('Internal server error during search');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('generate-embeddings')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Generate and store embeddings for ICD codes',
|
||||||
|
description:
|
||||||
|
'Batch generate embeddings for ICD codes and store them in the database with pgvector. This process may take some time depending on the number of codes.',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: GenerateEmbeddingsRequestDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'Embedding generation and storage results summary',
|
||||||
|
type: GenerateEmbeddingsResponseDto,
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({ description: 'Invalid request parameters' })
|
||||||
|
@ApiInternalServerErrorResponse({
|
||||||
|
description: 'Internal server error during embedding generation',
|
||||||
|
})
|
||||||
|
async generateAndStoreEmbeddings(
|
||||||
|
@Body() body: GenerateEmbeddingsRequestDto,
|
||||||
|
): Promise<GenerateEmbeddingsResponseDto> {
|
||||||
|
try {
|
||||||
|
const result = await this.icdCodeVectorService.generateAndStoreEmbeddings(
|
||||||
|
body.limit,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
message: `Processed ${result.processed} embeddings with ${result.errors} errors`,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to generate embeddings: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('generate-embeddings-by-category')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Generate and store embeddings for ICD codes by category',
|
||||||
|
description:
|
||||||
|
'Batch generate embeddings for ICD codes in a specific category (ICD9 or ICD10) and store them in the database.',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: GenerateEmbeddingsByCategoryRequestDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'Embedding generation and storage results summary by category',
|
||||||
|
type: GenerateEmbeddingsResponseDto,
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({ description: 'Invalid request parameters' })
|
||||||
|
@ApiInternalServerErrorResponse({
|
||||||
|
description: 'Internal server error during embedding generation',
|
||||||
|
})
|
||||||
|
async generateAndStoreEmbeddingsByCategory(
|
||||||
|
@Body() body: GenerateEmbeddingsByCategoryRequestDto,
|
||||||
|
): Promise<GenerateEmbeddingsResponseDto & { category: string }> {
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
await this.icdCodeVectorService.generateAndStoreEmbeddingsByCategory(
|
||||||
|
body.category,
|
||||||
|
body.limit,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
message: `Processed ${result.processed} embeddings for category ${result.category} with ${result.errors} errors`,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to generate embeddings for category ${body.category}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('vector-search')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Search ICD codes using vector similarity',
|
||||||
|
description:
|
||||||
|
'Search for ICD codes using vector similarity with configurable threshold and optional category filtering.',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: VectorSearchRequestDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'Search results with vector similarity scores',
|
||||||
|
type: VectorSearchResponseDto,
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({ description: 'Invalid search parameters' })
|
||||||
|
@ApiInternalServerErrorResponse({
|
||||||
|
description: 'Internal server error during search',
|
||||||
|
})
|
||||||
|
async vectorSearch(
|
||||||
|
@Body() body: VectorSearchRequestDto,
|
||||||
|
): Promise<VectorSearchResponseDto> {
|
||||||
|
try {
|
||||||
|
const results = await this.icdCodeVectorService.search(
|
||||||
|
body.query,
|
||||||
|
body.category,
|
||||||
|
body.limit,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
total: results.length,
|
||||||
|
query: body.query,
|
||||||
|
category: body.category,
|
||||||
|
threshold: this.icdCodeVectorService.getThreshold(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Vector search failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('vector-search')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Search ICD codes using vector similarity (GET method)',
|
||||||
|
description:
|
||||||
|
'Search for ICD codes using vector similarity with query parameters for easier testing.',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'query',
|
||||||
|
description: 'Text query to search for',
|
||||||
|
example: 'diabetes',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'category',
|
||||||
|
description: 'ICD category filter',
|
||||||
|
required: false,
|
||||||
|
enum: ['ICD9', 'ICD10'],
|
||||||
|
example: 'ICD10',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'limit',
|
||||||
|
description: 'Maximum number of results',
|
||||||
|
required: false,
|
||||||
|
type: Number,
|
||||||
|
example: 10,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'Search results with vector similarity scores',
|
||||||
|
type: VectorSearchResponseDto,
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({ description: 'Invalid search parameters' })
|
||||||
|
@ApiInternalServerErrorResponse({
|
||||||
|
description: 'Internal server error during search',
|
||||||
|
})
|
||||||
|
async vectorSearchGet(
|
||||||
|
@Query('query') query: string,
|
||||||
|
@Query('category') category?: 'ICD9' | 'ICD10',
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
): Promise<VectorSearchResponseDto> {
|
||||||
|
try {
|
||||||
|
const limitNumber = limit ? parseInt(limit) : 10;
|
||||||
|
const results = await this.icdCodeVectorService.search(
|
||||||
|
query,
|
||||||
|
category,
|
||||||
|
limitNumber,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
total: results.length,
|
||||||
|
query,
|
||||||
|
category,
|
||||||
|
threshold: this.icdCodeVectorService.getThreshold(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Vector search failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('vector-search/icd9')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Search ICD-9 codes using vector similarity',
|
||||||
|
description:
|
||||||
|
'Search for ICD-9 codes using vector similarity with configurable threshold.',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'query',
|
||||||
|
description: 'Text query to search for',
|
||||||
|
example: 'cardiac procedure',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'limit',
|
||||||
|
description: 'Maximum number of results',
|
||||||
|
required: false,
|
||||||
|
type: Number,
|
||||||
|
example: 10,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'ICD-9 search results with vector similarity scores',
|
||||||
|
type: VectorSearchResponseDto,
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({ description: 'Invalid search parameters' })
|
||||||
|
@ApiInternalServerErrorResponse({
|
||||||
|
description: 'Internal server error during search',
|
||||||
|
})
|
||||||
|
async vectorSearchICD9(
|
||||||
|
@Query('query') query: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
): Promise<VectorSearchResponseDto> {
|
||||||
|
try {
|
||||||
|
const limitNumber = limit ? parseInt(limit) : 10;
|
||||||
|
const results = await this.icdCodeVectorService.searchByCategory(
|
||||||
|
query,
|
||||||
|
'ICD9',
|
||||||
|
limitNumber,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
total: results.length,
|
||||||
|
query,
|
||||||
|
category: 'ICD9',
|
||||||
|
threshold: this.icdCodeVectorService.getThreshold(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`ICD-9 vector search failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('vector-search/icd10')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Search ICD-10 codes using vector similarity',
|
||||||
|
description:
|
||||||
|
'Search for ICD-10 codes using vector similarity with configurable threshold.',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'query',
|
||||||
|
description: 'Text query to search for',
|
||||||
|
example: 'diabetes mellitus',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'limit',
|
||||||
|
description: 'Maximum number of results',
|
||||||
|
required: false,
|
||||||
|
type: Number,
|
||||||
|
example: 10,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'ICD-10 search results with vector similarity scores',
|
||||||
|
type: VectorSearchResponseDto,
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({ description: 'Invalid search parameters' })
|
||||||
|
@ApiInternalServerErrorResponse({
|
||||||
|
description: 'Internal server error during search',
|
||||||
|
})
|
||||||
|
async vectorSearchICD10(
|
||||||
|
@Query('query') query: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
): Promise<VectorSearchResponseDto> {
|
||||||
|
try {
|
||||||
|
const limitNumber = limit ? parseInt(limit) : 10;
|
||||||
|
const results = await this.icdCodeVectorService.searchByCategory(
|
||||||
|
query,
|
||||||
|
'ICD10',
|
||||||
|
limitNumber,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
total: results.length,
|
||||||
|
query,
|
||||||
|
category: 'ICD10',
|
||||||
|
threshold: this.icdCodeVectorService.getThreshold(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`ICD-10 vector search failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('embedding-stats')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get embedding statistics',
|
||||||
|
description: 'Get statistics about ICD codes and their embedding status.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'Embedding statistics and current threshold',
|
||||||
|
})
|
||||||
|
@ApiInternalServerErrorResponse({
|
||||||
|
description: 'Internal server error getting statistics',
|
||||||
|
})
|
||||||
|
async getEmbeddingStats() {
|
||||||
|
try {
|
||||||
|
return await this.icdCodeVectorService.getEmbeddingStats();
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to get embedding stats: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('threshold')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get current similarity threshold',
|
||||||
|
description: 'Get the current similarity threshold used for vector search.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'Current similarity threshold value',
|
||||||
|
})
|
||||||
|
async getThreshold() {
|
||||||
|
try {
|
||||||
|
return { threshold: this.icdCodeVectorService.getThreshold() };
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to get threshold: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/icd-code/icd-code/icd-code.service.spec.ts
Normal file
18
src/icd-code/icd-code/icd-code.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { IcdCodeService } from './icd-code.service';
|
||||||
|
|
||||||
|
describe('IcdCodeService', () => {
|
||||||
|
let service: IcdCodeService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [IcdCodeService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<IcdCodeService>(IcdCodeService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/icd-code/icd-code/icd-code.service.ts
Normal file
37
src/icd-code/icd-code/icd-code.service.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from 'src/common/prisma/prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IcdCodeService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findIcdCodes(search: string, page: number, limit: number) {
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ code: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ display: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
this.prisma.icdCode.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.prisma.icdCode.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,13 +13,17 @@ import {
|
|||||||
IcdStatisticsResponseDto,
|
IcdStatisticsResponseDto,
|
||||||
ErrorResponseDto,
|
ErrorResponseDto,
|
||||||
} from './dto/icd-response.dto';
|
} from './dto/icd-response.dto';
|
||||||
|
import { ResponseService } from 'src/common/response/response/response.service';
|
||||||
|
|
||||||
@ApiTags('ICD')
|
@ApiTags('ICD')
|
||||||
@Controller('icd')
|
@Controller('icd')
|
||||||
export class IcdController {
|
export class IcdController {
|
||||||
private readonly logger = new Logger(IcdController.name);
|
private readonly logger = new Logger(IcdController.name);
|
||||||
|
|
||||||
constructor(private readonly icdService: IcdService) {}
|
constructor(
|
||||||
|
private readonly icdService: IcdService,
|
||||||
|
private readonly responseService: ResponseService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('search')
|
@Get('search')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -72,7 +76,7 @@ export class IcdController {
|
|||||||
@Query('search') search?: string,
|
@Query('search') search?: string,
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('limit') limit?: string,
|
@Query('limit') limit?: string,
|
||||||
): Promise<IcdSearchResponseDto> {
|
) {
|
||||||
try {
|
try {
|
||||||
const pageNum = page ? parseInt(page, 10) : 1;
|
const pageNum = page ? parseInt(page, 10) : 1;
|
||||||
const limitNum = limit ? parseInt(limit, 10) : 10;
|
const limitNum = limit ? parseInt(limit, 10) : 10;
|
||||||
@@ -84,21 +88,15 @@ export class IcdController {
|
|||||||
limitNum,
|
limitNum,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return this.responseService.paginate(
|
||||||
success: true,
|
result.data,
|
||||||
data: result.data,
|
result.page,
|
||||||
pagination: {
|
result.limit,
|
||||||
currentPage: result.page,
|
result.total,
|
||||||
totalPages: result.totalPages,
|
);
|
||||||
totalItems: result.total,
|
|
||||||
itemsPerPage: result.limit,
|
|
||||||
hasNextPage: result.page < result.totalPages,
|
|
||||||
hasPreviousPage: result.page > 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Error searching ICD codes:', error);
|
this.logger.error('Error searching ICD codes:', error);
|
||||||
throw error;
|
return this.responseService.error('Internal server error during search');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,16 +115,16 @@ export class IcdController {
|
|||||||
description: 'Internal server error while fetching statistics',
|
description: 'Internal server error while fetching statistics',
|
||||||
type: ErrorResponseDto,
|
type: ErrorResponseDto,
|
||||||
})
|
})
|
||||||
async getStatistics(): Promise<IcdStatisticsResponseDto> {
|
async getStatistics() {
|
||||||
try {
|
try {
|
||||||
const stats = await this.icdService.getStatistics();
|
const stats = await this.icdService.getStatistics();
|
||||||
return {
|
|
||||||
success: true,
|
return this.responseService.success(stats);
|
||||||
data: stats,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Error getting statistics:', error);
|
this.logger.error('Error getting statistics:', error);
|
||||||
throw error;
|
return this.responseService.error(
|
||||||
|
'Internal server error while fetching statistics',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { IcdController } from './icd.controller';
|
import { IcdController } from './icd.controller';
|
||||||
import { IcdService } from './icd.service';
|
import { IcdService } from './icd.service';
|
||||||
import { PgVectorModule } from './pgvector.module';
|
import { PgVectorModule } from './pgvector.module';
|
||||||
|
import { PrismaModule } from 'src/common/prisma/prisma.module';
|
||||||
|
import { ResponseModule } from 'src/common/response/response.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [IcdController],
|
controllers: [IcdController],
|
||||||
providers: [IcdService],
|
providers: [IcdService],
|
||||||
imports: [PgVectorModule],
|
imports: [PgVectorModule, PrismaModule, ResponseModule],
|
||||||
exports: [IcdService, PgVectorModule],
|
exports: [IcdService, PgVectorModule],
|
||||||
})
|
})
|
||||||
export class IcdModule {}
|
export class IcdModule {}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaService } from 'src/common/prisma/prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IcdService {
|
export class IcdService {
|
||||||
private readonly logger = new Logger(IcdService.name);
|
private readonly logger = new Logger(IcdService.name);
|
||||||
private readonly prisma = new PrismaClient();
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async findIcdCodes(
|
async findIcdCodes(
|
||||||
category?: string,
|
category?: string,
|
||||||
@@ -79,8 +79,4 @@ export class IcdService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
|
||||||
await this.prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user