Преглед изворни кода

pre- setup for the gemini agent

Dr-Swopt пре 2 недеља
родитељ
комит
a83a695849

+ 79 - 0
AgentQueryPlan.json

@@ -0,0 +1,79 @@
+{
+  "description": "MongoDB Query Planner for FFB Production",
+  "instructions": "You are an intelligent MongoDB query planner for FFBProduction.\n\nYour job is to:\n1. Understand the user's question and extract intent (AGGREGATE or SEARCH).\n2. Generate a minimal preFilter ($match) for efficiency.\n3. Decide if a vector search is needed; if yes, set vectorQuery and vectorOptions.\n4. Build postPipeline for aggregation or projection; include only fields necessary for computation.\n5. Parse natural language dates into ISO format (YYYY-MM-DD).\n6. Map user terms like \"Total output\" to actual fields (weight, quantity).\n7. Use only allowed fields: [\n        \"site\",\n        \"phase\",\n        \"block\",\n        \"productionDate\",\n        \"weight\",\n        \"quantity\"\n    ].\n8. Use only allowed operators: [\n        \"$eq\",\n        \"$in\",\n        \"$gte\",\n        \"$lte\"\n    ].\n9. Output **valid JSON only**, no extra text.",
+  "examples": [
+    {
+      "question": "Total output in Site A for Nov-Dec",
+      "plan": {
+        "intent": "AGGREGATE",
+        "preFilter": {
+          "site": "Site A",
+          "productionDate": {
+            "$gte": "2025-11-01",
+            "$lte": "2025-12-31"
+          }
+        },
+        "vectorQuery": null,
+        "vectorOptions": {
+          "limit": 5,
+          "numCandidates": 50
+        },
+        "postPipeline": [
+          {
+            "$group": {
+              "_id": "$site",
+              "totalWeight": {
+                "$sum": "$weight"
+              }
+            }
+          },
+          {
+            "$project": {
+              "site": "$_id",
+              "totalWeight": 1,
+              "_id": 0
+            }
+          }
+        ],
+        "fields": [
+          "site",
+          "weight",
+          "productionDate"
+        ]
+      }
+    },
+    {
+      "question": "Top 5 most similar records to 'highest producing block in Site B'",
+      "plan": {
+        "intent": "SEARCH",
+        "preFilter": {
+          "site": "Site B"
+        },
+        "vectorQuery": "highest producing block in Site B",
+        "vectorOptions": {
+          "limit": 5,
+          "numCandidates": 50
+        },
+        "postPipeline": [
+          {
+            "$project": {
+              "site": 1,
+              "phase": 1,
+              "block": 1,
+              "weight": 1,
+              "quantity": 1,
+              "_id": 0
+            }
+          }
+        ],
+        "fields": [
+          "site",
+          "phase",
+          "block",
+          "weight",
+          "quantity"
+        ]
+      }
+    }
+  ]
+}

+ 61 - 0
AgentResultCompiler.json

@@ -0,0 +1,61 @@
+{
+  "description": "Result Compiler for FFB Production Queries",
+  "instructions": "You are a result compiler for FFBProduction data.\n\nYour job is to:\n1. Take the raw results from MongoDB aggregation or vector search (JSON array).\n2. Take the original AgentQueryPlan for context.\n3. Perform any arithmetic/aggregation needed (if not already done in postPipeline).\n4. Produce a clear, concise natural language answer for the user.\n5. Do not invent data; only use the provided results.\n6. Include units (e.g., kg for weight) when summarizing production.\n7. If results are empty, politely indicate that no data matches the query.\n8. Return ONLY the natural language answer (no JSON).",
+  "examples": [
+    {
+      "plan": {
+        "intent": "AGGREGATE",
+        "preFilter": {
+          "site": "Site A",
+          "productionDate": {
+            "$gte": "2025-11-01",
+            "$lte": "2025-12-31"
+          }
+        },
+        "postPipeline": [
+          {
+            "$group": {
+              "_id": "$site",
+              "totalWeight": {
+                "$sum": "$weight"
+              }
+            }
+          },
+          {
+            "$project": {
+              "site": "$_id",
+              "totalWeight": 1,
+              "_id": 0
+            }
+          }
+        ]
+      },
+      "results": [
+        {
+          "site": "Site A",
+          "totalWeight": 2000
+        }
+      ],
+      "answer": "Site A produced 2,000 kg of FFB between November and December 2025."
+    },
+    {
+      "plan": {
+        "intent": "SEARCH",
+        "preFilter": {
+          "site": "Site B"
+        },
+        "vectorQuery": "highest producing block in Site B"
+      },
+      "results": [
+        {
+          "site": "Site B",
+          "phase": "Phase 1",
+          "block": "Block 3",
+          "weight": 500,
+          "quantity": 100
+        }
+      ],
+      "answer": "The highest producing block in Site B is Block 3 (Phase 1) with 500 kg of FFB across 100 units."
+    }
+  ]
+}

+ 23 - 0
src/FFB/ffb-agent.types.ts

@@ -0,0 +1,23 @@
+export type AgentIntent = 'SEARCH' | 'AGGREGATE';
+
+export interface AgentQueryPlan {
+    intent: AgentIntent;
+
+    /** Used as $vectorSearch.filter OR $match (pre-filter documents for efficiency) */
+    preFilter?: Record<string, any>;
+
+    /** Text used to generate vector embedding, if vector search is needed */
+    vectorQuery?: string | null;
+
+    /** Vector search tuning options */
+    vectorOptions?: {
+        limit?: number;        // How many top documents to retrieve
+        numCandidates?: number; // How many candidates to consider in search
+    };
+
+    /** Aggregation stages AFTER preFilter / vectorSearch */
+    postPipeline?: any[];   // Should include only fields necessary for computation
+
+    /** Hint for which fields to retrieve for computation */
+    fields?: string[];
+}

+ 25 - 3
src/FFB/ffb-production.controller.ts

@@ -1,11 +1,33 @@
 import { Controller, Get, Post, Delete, Body, Param, Query } from '@nestjs/common';
 import { FFBProduction } from './ffb-production.schema';
 import { FFBProductionService } from './ffb-production.service';
+import { FFBQueryPlannerService } from './ffb-query-planner.service';
+import { FFBQueryExecutorService } from './ffb-query-executor.service';
+import { FFBResultCompilerService } from './ffb-result-compiler.service';
 
 @Controller('ffb-production')
 export class FFBProductionController {
-  constructor(private readonly ffbService: FFBProductionService) {}
-  
+  constructor(
+    private readonly ffbService: FFBProductionService,
+    private readonly planner: FFBQueryPlannerService,
+    private readonly executor: FFBQueryExecutorService,
+    private readonly compiler: FFBResultCompilerService, // Add compiler
+  ) { }
+
+  @Post('query')
+  async query(@Body('message') message: string) {
+    // 1. Planner generates AgentQueryPlan
+    const plan = await this.planner.plan(message);
+
+    // 2. Executor runs query against MongoDB / vector store
+    const rawResults = await this.executor.execute(plan);
+
+    // 3. Compiler formats results into natural language
+    const answer = await this.compiler.compile(plan, rawResults);
+
+    return { answer }; // Return final human-readable response
+  }
+
   /** Vector search endpoint */
   @Get('search')
   async search(@Query('q') q: string, @Query('k') k?: string) {
@@ -13,7 +35,7 @@ export class FFBProductionController {
     const topK = k ? parseInt(k, 10) : 5;
     return this.ffbService.search(q, topK);
   }
-  
+
   /** Create a new FFB production record (with embedding) */
   @Post()
   async create(@Body() body: FFBProduction) {

+ 4 - 1
src/FFB/ffb-production.module.ts

@@ -3,12 +3,15 @@ import { FFBProductionController } from './ffb-production.controller';
 import { FFBProductionService } from './ffb-production.service';
 import { MongoModule } from 'src/mongo/mongo.module';
 import { FFBVectorService } from './ffb-vector.service';
+import { FFBQueryExecutorService } from './ffb-query-executor.service';
+import { FFBQueryPlannerService } from './ffb-query-planner.service';
+import { FFBResultCompilerService } from './ffb-result-compiler.service';
 
 @Module({
   imports: [
     MongoModule
   ],
   controllers: [FFBProductionController],
-  providers: [FFBProductionService, FFBVectorService],
+  providers: [FFBProductionService, FFBVectorService, FFBQueryExecutorService, FFBQueryPlannerService, FFBResultCompilerService],
 })
 export class FFBProductionModule {}

+ 59 - 0
src/FFB/ffb-query-executor.service.ts

@@ -0,0 +1,59 @@
+import { Injectable } from '@nestjs/common';
+import { AgentQueryPlan } from './ffb-agent.types';
+import { FFBVectorService } from './ffb-vector.service';
+import { MongoCoreService } from 'src/mongo/mongo-core.service';
+import { FFBProductionRepository } from 'src/mongo/mongo-ffb-production.repository';
+
+@Injectable()
+export class FFBQueryExecutorService {
+    private repo: FFBProductionRepository;
+
+    constructor(
+        private readonly mongoCore: MongoCoreService,
+        private readonly vectorService: FFBVectorService,
+    ) { }
+
+    private async getRepo() {
+        if (!this.repo) {
+            const db = await this.mongoCore.getDb();
+            this.repo = new FFBProductionRepository(db);
+            await this.repo.init();
+        }
+        return this.repo;
+    }
+
+    async execute(plan: AgentQueryPlan) {
+        const repo = await this.getRepo();
+
+        // Build $project if fields are specified
+        let postPipeline = plan.postPipeline ?? [];
+        if (plan.fields && plan.fields.length > 0) {
+            postPipeline = [{ $project: plan.fields.reduce((acc, f) => ({ ...acc, [f]: 1 }), { _id: 0 }) }, ...postPipeline];
+        }
+
+        // CASE 1: Vector search
+        if (plan.vectorQuery) {
+            const vector = await this.vectorService['embedText'](plan.vectorQuery);
+
+            return repo.aggregate([
+                {
+                    $vectorSearch: {
+                        index: 'vector_index',
+                        path: 'vector',
+                        queryVector: vector,
+                        filter: plan.preFilter,
+                        limit: plan.vectorOptions?.limit ?? 5,
+                        numCandidates: plan.vectorOptions?.numCandidates ?? 50,
+                    },
+                },
+                ...postPipeline,
+            ]);
+        }
+
+        // CASE 2: Pure aggregation (no vectors)
+        return repo.aggregate([
+            ...(plan.preFilter ? [{ $match: plan.preFilter }] : []),
+            ...postPipeline,
+        ]);
+    }
+}

+ 91 - 0
src/FFB/ffb-query-planner.service.ts

@@ -0,0 +1,91 @@
+import { Injectable, OnModuleInit } from '@nestjs/common';
+import * as fs from 'fs';
+import * as path from 'path';
+import axios from 'axios';
+import { AgentQueryPlan } from './ffb-agent.types';
+
+@Injectable()
+export class FFBQueryPlannerService implements OnModuleInit {
+  private systemPrompt: any;
+
+  async onModuleInit() {
+    const filePath = path.join(process.cwd(), 'AgentQueryPlan.json'); // updated file
+    const data = fs.readFileSync(filePath, 'utf-8');
+    this.systemPrompt = JSON.parse(data);
+  }
+
+  private buildPrompt(userMessage: string): string {
+    const examplesText = (this.systemPrompt.examples || [])
+      .map(
+        (ex: any) =>
+          `Q: "${ex.question}"\nA: ${JSON.stringify(ex.plan, null, 2)}`
+      )
+      .join('\n\n');
+
+    return `
+${this.systemPrompt.instructions}
+
+Always include the minimal "fields" needed for computation to reduce bandwidth.
+
+${examplesText}
+
+Now, given the following user question, output the JSON only:
+
+Q: "${userMessage}"
+`;
+  }
+
+  async plan(userMessage: string): Promise<AgentQueryPlan> {
+    const promptText = this.buildPrompt(userMessage);
+    const responseText = await this.callGemini(promptText);
+    const sanitized = this.sanitizeLLMOutput(responseText);
+
+    try {
+      return JSON.parse(sanitized);
+    } catch (err) {
+      console.error('Failed to parse Gemini output:', sanitized);
+      throw new Error('LLM returned invalid JSON');
+    }
+  }
+
+  private async callGemini(prompt: string): Promise<string> {
+    const apiKey = process.env.GOOGLE_API_KEY;
+    if (!apiKey) throw new Error('Missing GOOGLE_API_KEY');
+
+    const url =
+      'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent';
+
+    const body = {
+      contents: [{ role: 'user', parts: [{ text: prompt }] }],
+    };
+
+    try {
+      const response = await axios.post(url, body, {
+        headers: {
+          'Content-Type': 'application/json',
+          'x-goog-api-key': apiKey,
+        },
+      });
+
+      const text =
+        response.data?.candidates?.[0]?.content?.parts
+          ?.map((p: any) => p.text)
+          .join(' ') ?? '';
+
+      if (!text) throw new Error('No text generated by Gemini');
+      return text;
+    } catch (err: any) {
+      console.error('Failed to call Gemini:', err.response?.data || err.message);
+      throw err;
+    }
+  }
+
+  private sanitizeLLMOutput(text: string): string {
+    return text
+      .trim()
+      .replace(/^```json\s*/, '') // remove opening ```json
+      .replace(/^```\s*/, '')     // remove opening ```
+      .replace(/```$/, '')        // remove closing ```
+      .trim();
+  }
+}

+ 72 - 0
src/FFB/ffb-result-compiler.service.ts

@@ -0,0 +1,72 @@
+import { Injectable, OnModuleInit } from '@nestjs/common';
+import * as fs from 'fs';
+import * as path from 'path';
+import axios from 'axios';
+import { AgentQueryPlan } from './ffb-agent.types';
+
+@Injectable()
+export class FFBResultCompilerService implements OnModuleInit {
+  private systemPrompt: any;
+
+  async onModuleInit() {
+    const filePath = path.join(process.cwd(), 'AgentResultCompiler.json');
+    const data = fs.readFileSync(filePath, 'utf-8');
+    this.systemPrompt = JSON.parse(data);
+  }
+
+  async compile(plan: AgentQueryPlan, rawResults: any[]): Promise<string> {
+    const promptText = this.buildPrompt(plan, rawResults);
+    const response = await this.callGemini(promptText);
+    return response.trim();
+  }
+
+  private buildPrompt(plan: AgentQueryPlan, results: any[]): string {
+    // Build example text for LLM
+    const examplesText = (this.systemPrompt.examples || [])
+      .map(
+        (ex: any) =>
+          `Plan: ${JSON.stringify(ex.plan, null, 2)}\nResults: ${JSON.stringify(ex.results, null, 2)}\nAnswer: ${ex.answer}`
+      )
+      .join('\n\n');
+
+    return `
+${this.systemPrompt.instructions}
+
+${examplesText}
+
+Here is the actual AgentQueryPlan: ${JSON.stringify(plan, null, 2)}
+Here are the raw results from MongoDB: ${JSON.stringify(results, null, 2)}
+`;
+  }
+
+  private async callGemini(prompt: string): Promise<string> {
+    const apiKey = process.env.GOOGLE_API_KEY;
+    if (!apiKey) throw new Error("Missing GOOGLE_API_KEY");
+
+    const url =
+      "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";
+
+    const body = {
+      contents: [{ role: 'user', parts: [{ text: prompt }] }],
+    };
+
+    try {
+      const response = await axios.post(url, body, {
+        headers: {
+          'Content-Type': 'application/json',
+          'x-goog-api-key': apiKey,
+        },
+      });
+
+      const text = response.data?.candidates?.[0]?.content?.parts
+        ?.map((p: any) => p.text)
+        .join(' ') ?? '';
+
+      if (!text) throw new Error("No text generated by Gemini");
+      return text;
+    } catch (err: any) {
+      console.error("Failed to call Gemini:", err.response?.data || err.message);
+      throw err;
+    }
+  }
+}

+ 0 - 92
src/FFB/ffb-vector.service.openai.txt

@@ -1,92 +0,0 @@
-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,
-        }));
-    }
-}

+ 15 - 0
src/mongo/mongo-ffb-production.repository.ts

@@ -73,4 +73,19 @@ export class FFBProductionRepository {
       ])
       .toArray();
   }
+
+  async aggregate(pipeline: any[]): Promise<any[]> {
+    // Optional: log the pipeline for debugging
+    // console.log('Executing aggregation pipeline:', JSON.stringify(pipeline, null, 2));
+
+    // Execute aggregation
+    const results = await this.collection.aggregate(pipeline).toArray();
+
+    // Optional: strip out any internal vector fields if accidentally included
+    return results.map(r => {
+      const { vector, ...rest } = r;
+      return rest;
+    });
+  }
+
 }