demeter

Autonomous Hydroponic Intelligence
commit 15201611237d6da7fd58a87e7015080821dc9727
parent 756e9917c24a850a845d0d097742187736f0bc28
Author: Debarghya Das <debarghya1108@gmail.com>
Date:   Mon,  2 Mar 2026 00:32:56 +0000

Merge PR

Diffstat:
Magent/Sentinel/agent.py | 39++++++++++++++++++++++++++++-----------
Abackend/server/fix.py | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbackend/server/functions.py | 192++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mbackend/server/main.py | 7+++++--
Mweb/app/upload/page.tsx | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mweb/models/index.ts | 10++++++++++
Mweb/services/api.ts | 35+++++++++++++++++++++++++++++------
7 files changed, 371 insertions(+), 57 deletions(-)

diff --git a/agent/Sentinel/agent.py b/agent/Sentinel/agent.py @@ -42,13 +42,30 @@ class FMUBuilder: # Combine vectors fmu_vector = np.concatenate([img_vec, sensor_vec]).tolist() + # --- UPDATE START --- + # 1. Ensure metadata is a dictionary + if metadata is None: + metadata = {} + + # 2. Construct the full payload for Qdrant + # We merge sensor data + metadata + new schema fields + final_payload = { + "timestamp": datetime.utcnow().isoformat(), + "sensors": sensor_data, # Critical: Store raw values for Frontend display + **metadata, # Unpack crop, stage, etc. + "crop_id": metadata.get("crop_id", "UNKNOWN_CROP"), + "sequence_number": metadata.get("sequence_number", 1), + + # šŸ‘‡ NEW SCHEMA PARAMETERS (Initialized with Placeholders) + "action_taken": metadata.get("action_taken", "PENDING_ACTION"), + "outcome": metadata.get("outcome", "PENDING_OBSERVATION") + } + # --- UPDATE END --- + return FMU( id=str(uuid.uuid4()), vector=fmu_vector, - metadata={ - **(metadata or {}), - "timestamp": datetime.utcnow().isoformat(), - } + metadata=final_payload # This becomes the Qdrant Payload ) def _is_base64(self, s): @@ -100,8 +117,6 @@ class FMUBuilder: image_stream = io.BytesIO(image_bytes) # Encode using VisionEncoder - # If VisionEncoder only accepts paths, you may need to update it - # to also accept BytesIO objects or PIL Images return self.vision.encode(image_stream) @@ -125,7 +140,9 @@ if __name__ == "__main__": print("āœ… FMU ID:", fmu.id) print("āœ… Vector length:", len(fmu.vector)) - print("āœ… Metadata:", fmu.metadata) - - # Test with file path - # fmu2 = builder.create_fmu("path/to/image.png", sensors, {"crop": "basil"}) -\ No newline at end of file + + # Check for the new fields in the output + print("\nšŸ” Checking Schema:") + print(f" - Action: {fmu.metadata.get('action_taken')}") + print(f" - Outcome: {fmu.metadata.get('outcome')}") + print(f" - Sensors Saved: {'sensors' in fmu.metadata}") +\ No newline at end of file diff --git a/backend/server/fix.py b/backend/server/fix.py @@ -0,0 +1,53 @@ +import sys +import os +from dotenv import load_dotenv # Ensure you have python-dotenv installed + +# 1. Get the folder where this script lives +current_dir = os.path.dirname(os.path.abspath(__file__)) + +# 2. Go up two levels to find the Project Root (Code/) +project_root = os.path.abspath(os.path.join(current_dir, '../../')) + +# 3. Add Project Root to Python Path +sys.path.append(project_root) + +# 4. šŸ”„ FORCE LOAD THE .ENV FILE šŸ”„ +# This must happen BEFORE importing Qdrant.Client +env_path = os.path.join(project_root, '.env') +if os.path.exists(env_path): + print(f"āœ… Loading environment from: {env_path}") + load_dotenv(env_path) +else: + print("āš ļø WARNING: .env file not found at project root!") + +# 5. NOW Import Client (It will see the loaded variables) +from Qdrant.Client import client +from qdrant_client.http import models + +def create_indexes(): + print(f"šŸ”§ Optimizing collection Farm_Memory...") + + # 1. Create Index for crop_id + try: + client.create_payload_index( + collection_name="Farm_Memory", + field_name="crop_id", + field_schema=models.PayloadSchemaType.KEYWORD + ) + print("āœ… Index created for 'crop_id'") + except Exception as e: + print(f"ā„¹ļø Note on crop_id: {e}") + + # 2. Create Index for outcome + try: + client.create_payload_index( + collection_name="Farm_Memory", + field_name="outcome", + field_schema=models.PayloadSchemaType.KEYWORD + ) + print("āœ… Index created for 'outcome'") + except Exception as e: + print(f"ā„¹ļø Note on outcome: {e}") + +if __name__ == "__main__": + create_indexes() +\ No newline at end of file diff --git a/backend/server/functions.py b/backend/server/functions.py @@ -4,6 +4,7 @@ import json import traceback from fastapi import UploadFile from qdrant_client.http import models +from datetime import datetime # --- AGENT IMPORTS --- from agent.sub_agents.Researcher import ResearcherAgent @@ -19,6 +20,28 @@ print("āœ… Agents Ready.") # --- HELPER: SIMULATE MINI-AGENTS --- # In production, these would be your actual imported classes from agent/sub_agents/ +def get_next_sequence_number(crop_id: str) -> int: + """ + Queries Qdrant to find how many snapshots exist for this specific crop_id. + Returns count + 1. + """ + try: + count_result = client.count( + collection_name=COLLECTION_NAME, + count_filter=models.Filter( + must=[ + models.FieldCondition( + key="crop_id", + match=models.MatchValue(value=crop_id) + ) + ] + ) + ) + return count_result.count + 1 + except Exception as e: + print(f"āš ļø Could not calculate sequence: {e}") + return 1 + def simulate_sub_agents(sensors): """ Generates 'Expert Opinions' based on raw sensor data. @@ -73,9 +96,9 @@ async def process_ingest(file: UploadFile, sensors_str: str, metadata_str: str, async def process_search(file: UploadFile, sensors_str: str, builder): """ - 1. Search Similar FMUs (Memory) - 2. Consult Researcher (Knowledge) - 3. Run Supervisor (Reasoning) + 1. Create & Save FMU (Placeholder State) + 2. Search Memory + 3. Run Supervisor """ temp_filename = f"temp_search_{file.filename}" with open(temp_filename, "wb") as buffer: @@ -85,17 +108,38 @@ async def process_search(file: UploadFile, sensors_str: str, builder): sensor_data = json.loads(sensors_str) abs_image_path = os.path.abspath(temp_filename) - # --- STEP 1: Context Extraction --- + numeric_sensors = {k: v for k, v in sensor_data.items() if k in ["pH", "EC", "temp", "humidity"]} + + # --- EXTRACT DATA --- target_crop = sensor_data.get("crop", "Unknown") target_stage = sensor_data.get("stage", "Unknown") - print(f"šŸ”Ž Pipeline triggered for: {target_crop} ({target_stage})") - # --- STEP 2: Vector Search (Memory) --- - # Separate numeric data for vector construction - numeric_sensors = {k: v for k, v in sensor_data.items() if k in ["pH", "EC", "temp", "humidity"]} - metadata = {"crop": target_crop, "stage": target_stage} + + # šŸ‘‡ NEW: Extract Crop ID from frontend (or generate a default) + target_crop_id = sensor_data.get("crop_id", f"Batch_{target_crop}_{datetime.now().strftime('%Y%m')}") + + # šŸ‘‡ NEW: Calculate Sequence + seq_num = get_next_sequence_number(target_crop_id) + print(f"šŸ”¢ Processing {target_crop_id} | Snapshot #{seq_num}") - # Create Filter + metadata = { + "crop": target_crop, + "stage": sensor_data.get("stage", "Unknown"), + "crop_id": target_crop_id, # <--- Added + "sequence_number": seq_num, # <--- Added + "action_taken": "PENDING_USER_ACTION", + "outcome": "PENDING_OBSERVATION" + } + + # Create & Store FMU + query_fmu = builder.create_fmu(abs_image_path, numeric_sensors, metadata=metadata) + store_fmu(query_fmu) + print(f"šŸ“ Created Query FMU ID: {query_fmu.id}") + + # --- STEP 2: Vector Search (Using the new FMU's vector) --- + query_vector = query_fmu.vector.tolist() if hasattr(query_fmu.vector, 'tolist') else query_fmu.vector + + # Create Context Filter context_filter = models.Filter( must=[ models.FieldCondition(key="crop", match=models.MatchValue(value=target_crop)), @@ -103,55 +147,48 @@ async def process_search(file: UploadFile, sensors_str: str, builder): ] ) - # Create Vector & Search - query_fmu = builder.create_fmu(abs_image_path, numeric_sensors, metadata=metadata) - query_vector = query_fmu.vector.tolist() if hasattr(query_fmu.vector, 'tolist') else query_fmu.vector - try: + # We fetch 4 items so we can safely drop the current query if it appears response = client.query_points( collection_name=COLLECTION_NAME, query=query_vector, query_filter=context_filter, - limit=3, # Get top 3 similar cases + limit=4, with_payload=True ) hits = response.points + + # Filter out the current query ID if it appears in results (Self-Exclusion) + hits = [hit for hit in hits if hit.id != query_fmu.id][:3] + except Exception: - # Fallback to unfiltered if index missing print("āš ļø Filter failed, searching raw vectors...") - hits = client.search(collection_name=COLLECTION_NAME, query_vector=query_vector, limit=3, with_payload=True) + hits = client.search(collection_name=COLLECTION_NAME, query_vector=query_vector, limit=4, with_payload=True) + hits = [hit for hit in hits if hit.id != query_fmu.id][:3] - # Format Memory for the Supervisor - similar_fmus_formatted = [ - {"score": hit.score, "payload": hit.payload} for hit in hits - ] + similar_fmus_formatted = [{"score": hit.score, "payload": hit.payload} for hit in hits] # --- STEP 3: The Reasoning Cycle --- print("🧠 Invoking Supervisor Agent...") - - # A. Get Expert Opinions mini_agent_reports = simulate_sub_agents(numeric_sensors) - # B. Construct the Current FMU object for the Supervisor current_fmu_context = { "metadata": metadata, "payload": {"sensors": numeric_sensors} } - # C. Run the Supervisor Logic (RAG + Groq) decision_json = supervisor.reason( current_fmu=current_fmu_context, similar_fmus=similar_fmus_formatted, sub_agent_outputs=mini_agent_reports ) - # --- STEP 4: Return Combined Result --- + # --- STEP 4: Return Result + The New ID --- return { "status": "success", - "search_results": [ - {"id": hit.id, "score": hit.score, "payload": hit.payload} for hit in hits - ], - "agent_decision": decision_json # <--- The frontend will render this! + "new_fmu_id": query_fmu.id, # <--- Frontend needs this for the Feedback Loop + "search_results": [{"id": h.id, "score": h.score, "payload": h.payload} for h in hits], + "agent_decision": decision_json } except Exception as e: @@ -161,4 +198,95 @@ async def process_search(file: UploadFile, sensors_str: str, builder): finally: if os.path.exists(temp_filename): - os.remove(temp_filename) -\ No newline at end of file + os.remove(temp_filename) + +async def parse_natural_language_query(query_text: str): + """ + Uses the LLM to convert a text query into structured Qdrant filters. + """ + system_prompt = """ + You are a Database Translator. + Your goal: Convert natural language queries into a JSON filter object for a Hydroponic Database. + + AVAILABLE FIELDS: + - crop (e.g., Lettuce, Basil, Tomato) + - stage (e.g., Seedling, Vegetative, Flowering) + - outcome (Values: "Positive", "Negative", "Neutral", "PENDING_OBSERVATION") + - action_taken (e.g., "Add CalMag", "Lower pH") + - crop_id (e.g., "Batch_Lettuce_2026") + + RULES: + 1. If user says "poor health", "bad", "failed" or similar negative words, map to outcome="Negative". + 2. If user says "good", "healthy" or other positive words, map to outcome="Positive". + 3. Output strictly JSON matching this structure: + { + "must": [ + {"key": "field_name", "match": "value"} + ] + } + 4. Return empty list [] if no specific filters apply. + """ + + try: + response = supervisor.llm.chat.completions.create( + model="llama-3.1-8b-instant", + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": query_text} + ], + temperature=0, + response_format={"type": "json_object"} + ) + return json.loads(response.choices[0].message.content) + except Exception as e: + print(f"āŒ Query Parse Error: {e}") + return {"must": []} + +async def process_text_query(text: str): + """ + Handles natural language search requests. + """ + print(f"šŸ—£ļø User asked: '{text}'") + + # 1. Translate Text -> Filters + filter_logic = await parse_natural_language_query(text) + print(f"āš™ļø Generated Filters: {json.dumps(filter_logic, indent=2)}") + + # 2. Build Qdrant Filter + conditions = [] + for item in filter_logic.get("must", []): + conditions.append( + models.FieldCondition( + key=item["key"], + match=models.MatchValue(value=item["match"]) + ) + ) + + # 3. Query Database (Scroll is better for "List" queries than vector search) + try: + if conditions: + # Search with filters + scroll_filter = models.Filter(must=conditions) + results = client.scroll( + collection_name=COLLECTION_NAME, + scroll_filter=scroll_filter, + limit=10, + with_payload=True + ) + else: + # No filters found, return latest + results = client.scroll( + collection_name=COLLECTION_NAME, + limit=10, + with_payload=True + ) + + points = results[0] # Scroll returns (points, offset) + + return { + "status": "success", + "results": [{"id": p.id, "payload": p.payload} for p in points] + } + + except Exception as e: + return {"status": "error", "message": str(e)} +\ No newline at end of file diff --git a/backend/server/main.py b/backend/server/main.py @@ -13,8 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware from Sentinel.agent import FMUBuilder # Import the logic functions -from backend.server.functions import process_ingest, process_search - +from backend.server.functions import process_ingest, process_search, process_text_query app = FastAPI() app.add_middleware( @@ -61,6 +60,10 @@ async def search_endpoint( traceback.print_exc() return {"status": "error", "message": str(e)} +@app.post("/query-text") +async def text_query_endpoint(query: str = Form(...)): + return await process_text_query(query) + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/web/app/upload/page.tsx b/web/app/upload/page.tsx @@ -17,6 +17,7 @@ export default function UnifiedPage() { const [loadingSearch, setLoadingSearch] = useState(false); const [searchResults, setSearchResults] = useState<SearchResult[]>([]); + const [textQuery, setTextQuery] = useState(""); // 🧠 State for the Supervisor's Output const [decision, setDecision] = useState<AgentDecision | null>(null); @@ -60,27 +61,67 @@ export default function UnifiedPage() { } }; + // Add state to store the ID of the current query + const [currentQueryId, setCurrentQueryId] = useState<string | null>(null); + const handleSearch = async () => { if (!file) return alert("Please select an image to search with."); setLoadingSearch(true); - setDecision(null); // Clear previous decision while thinking... + setDecision(null); try { const response = await IngestService.searchFMU(file, sensors); setSearchResults(response.search_results || []); - // 🧠 Capture the Agent Decision from Backend if (response.agent_decision) { setDecision(response.agent_decision); } - - if ((response.search_results || []).length === 0 && !response.agent_decision) { - alert("No similar memories or insights found."); + + // šŸ‘‡ Capture the ID of the newly created FMU + if (response.new_fmu_id) { + setCurrentQueryId(response.new_fmu_id); + console.log("Query Logged with ID:", response.new_fmu_id); } + } catch (error) { console.error(error); - alert("āŒ Search Failed. Check console."); + alert("āŒ Search Failed."); + } finally { + setLoadingSearch(false); + } + }; + + const handleTextQuery = async () => { + if (!textQuery) return; + setLoadingSearch(true); + setSearchResults([]); // Clear old results + + try { + const formData = new FormData(); + formData.append("query", textQuery); + + const res = await fetch("http://localhost:8000/query-text", { + method: "POST", + body: formData + }); + const data = await res.json(); + + if (data.results) { + // Map the "scroll" results to your "search result" format + // @ts-ignore + const mappedResults = data.results.map(r => ({ + id: r.id, + score: 1.0, // Text search matches are exact, so 100% score + payload: r.payload + })); + setSearchResults(mappedResults); + + if (mappedResults.length === 0) alert("No records found matching that description."); + } + } catch (e) { + console.error(e); + alert("Text Query Failed"); } finally { setLoadingSearch(false); } @@ -116,6 +157,25 @@ export default function UnifiedPage() { </div> </div> + {/* šŸ” TEXT SEARCH BAR */} +<div className="max-w-6xl w-full mb-8"> + <div className="bg-slate-900/50 p-4 rounded-xl border border-slate-700 flex gap-4"> + <input + type="text" + value={textQuery} + onChange={(e) => setTextQuery(e.target.value)} + placeholder="Ask Demeter: 'Show me all failed Lettuce crops' or 'Show me Batch-001'" + className="flex-1 bg-transparent border-none outline-none text-white placeholder-slate-500" + /> + <button + onClick={handleTextQuery} + className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-lg font-bold transition-colors" + > + Ask + </button> + </div> +</div> + {/* Right: Inputs & Actions */} <div className="space-y-8 flex flex-col justify-center"> <div> @@ -234,6 +294,25 @@ export default function UnifiedPage() { </div> )} {/* 🧠 END REASONING SECTION */} + {/* {decision && currentQueryId && ( + <div className="mt-4 bg-slate-900 p-4 rounded-xl border border-slate-700"> + <h4 className="text-white font-bold mb-2">Report Outcome</h4> + <div className="flex gap-2"> + <button + onClick={() => submitFeedback(currentQueryId, decision.action, "Effective")} + className="px-4 py-2 bg-green-600 rounded text-sm hover:bg-green-500" + > + It Worked! + </button> + <button + onClick={() => submitFeedback(currentQueryId, decision.action, "Ineffective")} + className="px-4 py-2 bg-red-600 rounded text-sm hover:bg-red-500" + > + Failed + </button> + </div> + </div> +)} */} {/* Bottom Section: Search Results */} diff --git a/web/models/index.ts b/web/models/index.ts @@ -8,6 +8,7 @@ export interface SensorData { humidity: string; crop: string; stage: string; + crop_id?: string; } // 2. Add the new Decision Type @@ -24,6 +25,8 @@ export interface SearchResponse { search_results: SearchResult[]; // The new AI Brain output agent_decision?: AgentDecision; + + new_fmu_id?: string; } // 4. Ensure SearchResult matches what Qdrant sends @@ -37,7 +40,14 @@ export interface SearchResult { sensors?: { pH: number; EC: number; + temp: number; + humidity: number; }; + + // šŸ‘‡ ADD THE NEW SCHEMA FIELDS HERE + action_taken?: string; + outcome?: string; + [key: string]: any; // Allow for other flexible fields }; } diff --git a/web/services/api.ts b/web/services/api.ts @@ -2,11 +2,17 @@ import { SensorData, IngestResponse, SearchResponse } from "@/models"; const API_URL = "http://localhost:8000"; -export const IngestService = { - // ... (keep uploadFMU as is) ... +// Define the payload type here or import it from models +interface FeedbackPayload { + fmu_id: string; + action: string; + outcome: string; +} +export const IngestService = { + + // 1. Upload Function (Existing) async uploadFMU(file: File, sensors: SensorData): Promise<IngestResponse> { - // ... (your existing upload code) ... const formData = new FormData(); formData.append("file", file); formData.append("sensors", JSON.stringify({ @@ -30,18 +36,19 @@ export const IngestService = { } }, + // 2. Search Function (Existing) async searchFMU(file: File, sensors: SensorData): Promise<SearchResponse> { const formData = new FormData(); formData.append("file", file); - // We send sensor data because it's part of the vector math! formData.append("sensors", JSON.stringify({ pH: parseFloat(sensors.pH), EC: parseFloat(sensors.EC), temp: parseFloat(sensors.temp), humidity: parseFloat(sensors.humidity), - crop: sensors.crop, // <--- Must include this + crop: sensors.crop, stage: sensors.stage, + crop_id: sensors.crop_id, // <--- Add this })); try { @@ -55,5 +62,21 @@ export const IngestService = { console.error("Search Service Error:", error); throw error; } - } + }, + + // 3. šŸ‘‡ ADD THIS MISSING FUNCTION + // async sendFeedback(data: FeedbackPayload) { + // try { + // const res = await fetch(`${API_URL}/feedback`, { + // method: "POST", + // headers: { "Content-Type": "application/json" }, + // body: JSON.stringify(data), + // }); + // if (!res.ok) throw new Error(`Server Error: ${res.statusText}`); + // return await res.json(); + // } catch (error) { + // console.error("Feedback Error:", error); + // throw error; + // } + // } }; \ No newline at end of file