|
|
@@ -0,0 +1,92 @@
|
|
|
+import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
|
+import axios from 'axios';
|
|
|
+import { MongoCoreService } from 'src/mongo/mongo-core.service';
|
|
|
+import { FFBProductionRepository } from 'src/mongo/mongo-ffb-production.repository';
|
|
|
+import { FFBProduction } from './ffb-production.schema';
|
|
|
+
|
|
|
+@Injectable()
|
|
|
+export class FFBVectorService implements OnModuleInit {
|
|
|
+ private repo: FFBProductionRepository;
|
|
|
+ private readonly VECTOR_DIM = parseInt(process.env.VECTOR_DIM || '1536'); // OpenAI default
|
|
|
+ private accessToken: string;
|
|
|
+
|
|
|
+ constructor(private readonly mongoCore: MongoCoreService) { }
|
|
|
+
|
|
|
+ async onModuleInit() {
|
|
|
+ // Initialize Mongo repository
|
|
|
+ const db = await this.mongoCore.getDb();
|
|
|
+ this.repo = new FFBProductionRepository(db);
|
|
|
+ await this.repo.init();
|
|
|
+
|
|
|
+ // Load OpenAI API key
|
|
|
+ this.accessToken = process.env.OPENAI_API_KEY as unknown as string;
|
|
|
+ if (!this.accessToken) {
|
|
|
+ throw new Error('❌ Missing OPENAI_API_KEY in environment.');
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('✅ OpenAI embedding service ready. Repository initialized.');
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Convert a record to a text string suitable for embedding */
|
|
|
+ private recordToText(record: FFBProduction): string {
|
|
|
+ return `Production on ${new Date(record.productionDate).toISOString()} at ${record.site} in ${record.phase} ${record.block} produced ${record.quantity} ${record.quantityUom} with a total weight of ${record.weight} ${record.weightUom}.`;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Generate embedding via OpenAI */
|
|
|
+ private async embedText(text: string): Promise<number[]> {
|
|
|
+ const model = process.env.EMBEDDING_MODEL || 'text-embedding-3-small';
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await axios.post(
|
|
|
+ 'https://api.openai.com/v1/embeddings',
|
|
|
+ { model, input: text },
|
|
|
+ {
|
|
|
+ headers: {
|
|
|
+ Authorization: `Bearer ${this.accessToken}`,
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ },
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ const embedding = response.data?.data?.[0]?.embedding;
|
|
|
+
|
|
|
+ if (!embedding || !Array.isArray(embedding)) {
|
|
|
+ throw new Error(`Invalid embedding returned: ${JSON.stringify(response.data)}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (embedding.length !== this.VECTOR_DIM) {
|
|
|
+ console.warn(
|
|
|
+ `⚠️ Warning: embedding dimension mismatch. Expected ${this.VECTOR_DIM}, got ${embedding.length}`
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return embedding;
|
|
|
+ } catch (err: any) {
|
|
|
+ console.error('❌ Failed to generate OpenAI embedding:', err.response?.data || err.message);
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Insert a single record with embedding vector */
|
|
|
+ async insertWithVector(record: FFBProduction) {
|
|
|
+ const text = this.recordToText(record);
|
|
|
+ const vector = await this.embedText(text);
|
|
|
+
|
|
|
+ const data: FFBProduction & { vector: number[] } = { ...record, vector };
|
|
|
+ return this.repo.create(data);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Search for top-k similar records using a text query */
|
|
|
+ async vectorSearch(query: string, k = 5) {
|
|
|
+ if (!query) throw new Error('Query string cannot be empty');
|
|
|
+
|
|
|
+ const vector = await this.embedText(query);
|
|
|
+ const results = await this.repo.vectorSearch(vector, k, 50);
|
|
|
+
|
|
|
+ return results.map((r) => ({
|
|
|
+ ...r,
|
|
|
+ _id: r._id.toString(),
|
|
|
+ score: r.score,
|
|
|
+ }));
|
|
|
+ }
|
|
|
+}
|