فهرست منبع

enhanced schema for remarks

Dr-Swopt 5 روز پیش
والد
کامیت
34c14f02d2

+ 3 - 1
.gitignore

@@ -58,4 +58,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
 
 
 # Certs
-certs
+certs
+# LangGraph API
+.langgraph_api

+ 7 - 0
langgraph.json

@@ -0,0 +1,7 @@
+{
+  "node_version": "20",
+  "graphs": {
+    "ffb_agent": "./src/FFB/studio-entry.ts:graph"
+  },
+  "env": ".env"
+}

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

@@ -12,13 +12,14 @@ export class FFBProductionController {
   ) { }
 
   @Post('chat')
-  async query(@Body('message') message: string, @Body('sessionId') sessionId?: string) {
-    throw new BadRequestException('HTTP Chat endpoint is deprecated. Please use WebSocket connection on namespace /ffb with event "chat".');
+  async query(@Body('message') message: string, @Body('provider') provider?: 'openai' | 'gemini') {
+    if (!message) {
+      throw new BadRequestException('Message is required');
+    }
+    const response = await this.ffbLangChainService.chatStateless(message, provider);
+    return { response };
   }
 
-
-
-
   /** Vector search endpoint */
   @Get('search')
   async search(@Query('q') q: string, @Query('k') k?: string) {
@@ -38,7 +39,17 @@ export class FFBProductionController {
   @Get()
   async findAll(@Query() query: any) {
     console.log('GET /ffb-production', query);
-    return this.ffbService.findAll(query);
+
+    // Extract pagination parameters
+    const page = query.page ? parseInt(query.page, 10) : 1;
+    const limit = query.limit ? parseInt(query.limit, 10) : 10;
+
+    // Clean query object (remove page and limit from filters)
+    const filter = { ...query };
+    delete filter.page;
+    delete filter.limit;
+
+    return this.ffbService.findAll(filter, { page, limit });
   }
 
   /** Find a record by ID */
@@ -55,4 +66,12 @@ export class FFBProductionController {
     return this.ffbService.delete(id);
   }
 
+  /** Generate simple remark string (stateless) */
+  @Post('generate-remark')
+  async generateRemarks(@Body() body: any) {
+    console.log(`POST /ffb-production/generate-remark`);
+    const remark = await this.ffbService.generateRemarks(body);
+    return { remark };
+  }
+
 }

+ 1 - 0
src/FFB/ffb-production.schema.ts

@@ -8,6 +8,7 @@ export interface FFBProduction {
   weightUom: string;
   quantity: number;
   quantityUom: string;
+  remarks?: string;
 }
 
 export interface ThoughtPayload {

+ 1 - 0
src/FFB/ffb.gateway.ts

@@ -70,6 +70,7 @@ export class FFBGateway
     }
 
     emitThought(socketId: string, data: any) {
+        if (!socketId || socketId === 'stateless') return;
         this.server.to(socketId).emit('agent_thought', data);
     }
 }

+ 20 - 3
src/FFB/mongo-ffb-production.repository.ts

@@ -26,12 +26,21 @@ export class FFBProductionRepository {
     return { ...ffb, _id: result.insertedId.toString() };
   }
 
-  async findAll(filter: Record<string, any> = {}): Promise<(FFBProduction & { vector?: number[] })[]> {
-    const results = await this.collection.find(filter).toArray();
-    return results.map((r: WithId<FFBProduction & { vector?: number[] }>) => ({
+  async findAll(filter: Record<string, any> = {}, options: { page?: number, limit?: number } = {}): Promise<{ data: (FFBProduction & { vector?: number[] })[], total: number }> {
+    const { page = 1, limit = 10 } = options;
+    const skip = (page - 1) * limit;
+
+    const [results, total] = await Promise.all([
+      this.collection.find(filter).skip(skip).limit(limit).toArray(),
+      this.collection.countDocuments(filter)
+    ]);
+
+    const data = results.map((r: WithId<FFBProduction & { vector?: number[] }>) => ({
       ...r,
       _id: r._id?.toString(),
     }));
+
+    return { data, total };
   }
 
   async findById(id: string): Promise<(FFBProduction & { vector?: number[] }) | null> {
@@ -43,6 +52,13 @@ export class FFBProductionRepository {
     return this.collection.deleteOne({ _id: new ObjectId(id) as any });
   }
 
+  async update(id: string, update: Partial<FFBProduction>): Promise<void> {
+    await this.collection.updateOne(
+      { _id: new ObjectId(id) as any },
+      { $set: update }
+    );
+  }
+
   async findOne(filter: Record<string, any> = {}): Promise<FFBProduction | null> {
     const result = await this.collection.findOne(filter);
     return result ? { ...result, _id: result._id.toString() } : null;
@@ -77,6 +93,7 @@ export class FFBProductionRepository {
             quantityUom: 1,
             weight: 1,
             weightUom: 1,
+            remarks: 1,
             score: { "$meta": "vectorSearchScore" }  // correctly get the score
           }
         }

+ 10 - 8
src/FFB/services/config/langchain-config.ts

@@ -2,7 +2,7 @@ import { z } from "zod";
 
 export const SCHEMAS = {
     ENTRY: z.object({
-        category: z.enum(['InScope-Actionable', 'InScope-NeedsGuidance', 'InScope-Meta', 'OutOfScope']),
+        category: z.enum(['InScope-Actionable', 'InScope-NeedsGuidance', 'InScope-Meta', 'InScope-Generation', 'OutOfScope']),
         language: z.string().describe("The language of the user input (e.g., English, Spanish, Indonesian)"),
         reasoning: z.string()
     }),
@@ -29,23 +29,25 @@ export const SCHEMAS = {
 export const PROMPTS = {
     ENTRY: (lastMessage: string) => `
 You are the Entry Node for a strictly defined FFB Production Data Agent.
-Your job is to CLASSIFY the user's message into one of four categories AND DETECT the language.
+Your job is to CLASSIFY the user's message into one of five categories AND DETECT the language.
 
 IDENTITY:
-- You retrieve, summarize, and aggregate records from a vector database.
-- You DO NOT answer general knowledge, creative, or unrelated questions.
+- You retrieve, summarize, aggregate, and GENERATE field reports or remarks from/for FFB records.
+- You DO NOT answer general knowledge, creative fiction (unrelated to plantation), or unrelated questions.
 - You NEVER behave like a general-purpose chatbot.
 
 CATEGORIES:
 1. InScope-Actionable: A valid, clear request for querying or aggregating data.
-2. InScope-NeedsGuidance: On-topic but vague, incomplete, or malformed request (e.g. mentions a site but no question).
+2. InScope-NeedsGuidance: On-topic but vague, incomplete, or malformed request.
 3. InScope-Meta: User asks about capabilities, "what can you do", "what did I ask before", or how to use the agent.
-4. OutOfScope: Any request unrelated to FFB data querying or aggregation (e.g. "write a poem", "who is the president", "general chat").
+4. InScope-Generation: Request to generate a remark, report, or summary based on provided or mock plantation data.
+5. OutOfScope: Any request unrelated to FFB data/production (e.g. "who is the president", "general chat").
 
 LOGIC:
-- IF questions about capabilities OR memory ("what did I ask") -> InScope-Meta
-- IF unrelated to data/production -> OutOfScope
+- IF questions about capabilities OR memory -> InScope-Meta
+- IF request to "generate", "create a remark", or "write a report" about plantation activities -> InScope-Generation
 - IF mentions domain entities (site, date) BUT lacks clear intent -> InScope-NeedsGuidance
+- IF unrelated to data/production -> OutOfScope
 - ELSE -> InScope-Actionable
 
 User Message: "${lastMessage}"

+ 40 - 6
src/FFB/services/ffb-langchain.service.ts

@@ -20,6 +20,7 @@ import { refusalNode } from './nodes/refusal.node';
 import { vectorSearchNode } from './nodes/vector-search.node';
 import { aggregationNode } from './nodes/aggregation.node';
 import { synthesisNode } from './nodes/synthesis.node';
+import { generationNode } from './nodes/generation.node';
 
 @Injectable()
 export class FFBLangChainService {
@@ -49,32 +50,38 @@ export class FFBLangChainService {
         this.initGraph();
     }
 
+    private getProvider(socketId: string): 'openai' | 'gemini' {
+        if (socketId?.startsWith('stateless:')) {
+            return (socketId.split(':')[1] as 'openai' | 'gemini') || 'openai';
+        }
+        return this.sessionManager.getModelProvider(socketId);
+    }
+
     private getModel(socketId: string): BaseChatModel {
-        const provider = this.sessionManager.getModelProvider(socketId);
+        const provider = this.getProvider(socketId);
         return provider === 'gemini' ? this.geminiModel : this.openaiModel;
     }
 
     switchModel(socketId: string, provider: 'openai' | 'gemini') {
+        if (socketId?.startsWith('stateless:')) return;
         this.sessionManager.setModelProvider(socketId, provider);
     }
 
     getCurrentModel(socketId: string) {
-        const provider = this.sessionManager.getModelProvider(socketId);
+        const provider = this.getProvider(socketId);
         return {
             provider: provider,
             modelName: provider === 'gemini' ? 'gemini-2.5-flash' : 'gpt-4o-mini'
         };
     }
 
-
-
     private initGraph() {
         const graph = new StateGraph(AgentState)
             .addNode("entry_node", (state) => entryNode(state, this.getModel(state.socketId), this.gateway))
             .addNode("guidance_node", (state) => guidanceNode(state))
             .addNode("meta_node", (state) => {
                 const socketId = state.socketId;
-                const provider = this.sessionManager.getModelProvider(socketId);
+                const provider = this.getProvider(socketId);
                 const providerName = provider === 'gemini' ? 'Google Gemini' : 'OpenAI';
                 return metaNode(state, this.getModel(socketId), providerName, this.vectorService);
             })
@@ -82,7 +89,8 @@ export class FFBLangChainService {
             .addNode("router_node", (state) => routerNode(state, this.getModel(state.socketId), this.gateway))
             .addNode("vector_search_node", (state) => vectorSearchNode(state, this.vectorService, this.gateway))
             .addNode("aggregation_node", (state) => aggregationNode(state, this.getModel(state.socketId), this.vectorService, this.gateway))
-            .addNode("synthesis_node", (state) => synthesisNode(state, this.getModel(state.socketId), this.gateway));
+            .addNode("synthesis_node", (state) => synthesisNode(state, this.getModel(state.socketId), this.gateway))
+            .addNode("generation_node", (state) => generationNode(state, this.getModel(state.socketId), this.gateway));
 
         // Add Edges
         graph.addEdge(START, "entry_node");
@@ -94,6 +102,7 @@ export class FFBLangChainService {
                 "InScope-Actionable": "router_node",
                 "InScope-NeedsGuidance": "guidance_node",
                 "InScope-Meta": "meta_node",
+                "InScope-Generation": "generation_node",
                 "OutOfScope": "refusal_node"
             }
         );
@@ -109,6 +118,7 @@ export class FFBLangChainService {
 
         graph.addEdge("guidance_node", END);
         graph.addEdge("meta_node", END);
+        graph.addEdge("generation_node", END);
         graph.addEdge("refusal_node", END);
         graph.addEdge("vector_search_node", "synthesis_node");
         graph.addEdge("aggregation_node", "synthesis_node");
@@ -166,4 +176,28 @@ export class FFBLangChainService {
             throw error;
         }
     }
+
+    async chatStateless(message: string, provider: 'openai' | 'gemini' = 'openai'): Promise<string> {
+        try {
+            const userMsg = new HumanMessage(message);
+            const socketId = `stateless:${provider}`;
+
+            const inputs = {
+                messages: [userMsg],
+                entityStore: {},
+                socketId: socketId
+            };
+
+            const result = await this.graph.invoke(inputs);
+
+            const allMessages = result.messages as BaseMessage[];
+            const agentMessages = allMessages.filter((m: BaseMessage) => m._getType() === 'ai');
+            const lastResponse = agentMessages[agentMessages.length - 1];
+
+            return lastResponse?.content as string || "I'm sorry, I encountered an error.";
+        } catch (error) {
+            console.error('Error calling LangGraph (stateless):', error);
+            throw error;
+        }
+    }
 }

+ 54 - 2
src/FFB/services/ffb-production.service.ts

@@ -3,6 +3,7 @@ import { MongoCoreService } from '../../mongo/mongo-core.service';
 import { FFBProduction } from '../ffb-production.schema';
 import { FFBProductionRepository } from 'src/FFB/mongo-ffb-production.repository';
 import { FFBVectorService } from './ffb-vector.service';
+import { FFBLangChainService } from './ffb-langchain.service';
 
 @Injectable()
 export class FFBProductionService {
@@ -11,6 +12,7 @@ export class FFBProductionService {
   constructor(
     private readonly mongoCore: MongoCoreService,
     private readonly vectorService: FFBVectorService, // Inject vector service
+    private readonly ffbLangChainService: FFBLangChainService,
   ) { }
 
   /** Lazily get or create the repository */
@@ -30,9 +32,9 @@ export class FFBProductionService {
   }
 
   /** Find all records (no embedding required) */
-  async findAll(filter: Record<string, any> = {}) {
+  async findAll(filter: Record<string, any> = {}, options: { page?: number, limit?: number } = {}) {
     const repo = await this.getRepository();
-    return repo.findAll(filter);
+    return repo.findAll(filter, options);
   }
 
   /** Find a record by ID (no embedding required) */
@@ -51,4 +53,54 @@ export class FFBProductionService {
   async search(query: string, k = 5) {
     return this.vectorService.vectorSearch(query, k);
   }
+
+  /** Generate LLM remarks for provided data or random context */
+  async generateRemarks(data?: any) {
+    let contextInfo = '';
+
+    if (data && Object.keys(data).length > 0) {
+      contextInfo = `Data: ${JSON.stringify(data)}`;
+    } else {
+      contextInfo =
+        'Data: Random FFB production context (e.g. wet weather affecting harvest, high yield block)';
+    }
+
+    const prompt = `Write an original, realistic field note or supervisor's remark (2–3 sentences) from an oil palm plantation regarding FFB production.
+
+Choose 2-3 specific aspects to focus on. Do not cover everything. Keep it grounded, practical, and observational.
+
+You may choose from (but are not limited to) the following aspects:
+- Harvest conditions (e.g., slippery laterite paths, loose fronds, uneven ground)
+- Crop quality or ripeness (e.g., mixed ripeness, loose fruit levels, overripe bunches)
+- Weather or micro-climate effects (e.g., morning mist, short showers, heat buildup)
+- Equipment or tools (e.g., tractor performance, bin condition, cutter sharpness)
+- Manpower or team dynamics (e.g., fast harvesters, fatigue setting in, new worker adapting)
+- Field layout or block characteristics (e.g., long walking distance, soft patches, slope)
+- Collection point or evacuation flow (e.g., bin queue forming, delayed pickup, smooth turnaround)
+- Timing or pacing (e.g., late start, catching up by midday, slowing toward afternoon)
+- Small operational adjustments (e.g., rerouting harvest path, spacing out bin movement)
+- Minor issues or near-misses (e.g., short hold-up, brief stoppage, quick fix on site)
+- Sensory details (e.g., smell of fresh fruit, sound of rustling leaves, sight of sun glare)
+- Human factors (e.g., harvester morale, supervisor vigilance, teamwork spirit)
+- Environmental observations (e.g., presence of wildlife, condition of surrounding vegetation)
+- Any other specific, tangible detail relevant to FFB production
+
+Do not mention any of the above aspects explicitly in the remark. Instead, weave them naturally into the observation.
+
+Guidelines:
+- Sound like a real note written by someone on the ground.
+- Include at least one concrete, physical detail.
+- Avoid generic phrases like “overall performance was good” or “operations ran smoothly”.
+- Do not explain or summarize the entire day.
+
+${contextInfo ? `Context Reference (use lightly, do not restate verbatim): ${contextInfo}` : ''}
+
+Remark:`;
+
+    const remark = await this.ffbLangChainService.chatStateless(prompt, 'gemini');
+
+    // Clean up remark
+    return remark.replace(/^Remark:\s*/i, '').replace(/^"|"$/g, '').trim();
+  }
+
 }

+ 29 - 0
src/FFB/services/nodes/generation.node.ts

@@ -0,0 +1,29 @@
+import { AgentState } from "../config/agent-state";
+import { BaseChatModel } from "@langchain/core/language_models/chat_models";
+import { FFBGateway } from "../../ffb.gateway";
+
+export const generationNode = async (
+    state: typeof AgentState.State,
+    model: BaseChatModel,
+    gateway: FFBGateway
+): Promise<Partial<typeof AgentState.State>> => {
+    const lastMessage = state.messages[state.messages.length - 1].content as string;
+
+    gateway.emitThought(state.socketId, {
+        node: 'generation_node',
+        status: 'processing',
+        message: 'Generating requested content...',
+    });
+
+    const response = await model.invoke(lastMessage);
+
+    gateway.emitThought(state.socketId, {
+        node: 'generation_node',
+        status: 'completed',
+        message: 'Content generated successfully.'
+    });
+
+    return {
+        messages: [response]
+    };
+};

+ 24 - 0
src/FFB/studio-entry.ts

@@ -0,0 +1,24 @@
+import { FFBLangChainService } from './services/ffb-langchain.service';
+import { FFBVectorService } from './services/ffb-vector.service';
+
+// 1. Mock the Gateway (it just needs to not crash when emitThought is called)
+const mockGateway = {
+  emitThought: (socketId: string, payload: any) => {
+    console.log(`[Studio Thought - ${socketId}]:`, payload.message);
+  }
+} as any;
+
+// 2. Mock the Vector Service 
+// Note: For real data in Studio, you'd connect to your dev Mongo here
+const mockVectorService = {
+  vectorSearch: async () => [],
+  aggregate: async () => [{ totalValue: 0 }],
+  getDistinct: async () => [],
+  getSchemaContext: async () => "Fields: site, weight, quantity, block, phase"
+} as any;
+
+// 3. Initialize the service
+const langChainService = new FFBLangChainService(mockVectorService, mockGateway);
+
+// 4. Export the compiled graph (Crucial for Studio)
+export const graph = langChainService['graph'];