demeter

Autonomous Hydroponic Intelligence
commit 30f0e834ff7c95d89f335b3ab21436f2bba13c9d
parent b007a5c230fa5c4b3407d7e4a7e66888e76ce15e
Author: Debarghya Das <debarghya1108@gmail.com>
Date:   Sat, 28 Feb 2026 20:23:36 +0000

Merge PR

Diffstat:
Abackend/server/create-index.py | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Abackend/server/functions.py | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbackend/server/main.py | 104++++++++++++-------------------------------------------------------------------
Mweb/services/api.ts | 4+++-
4 files changed, 178 insertions(+), 90 deletions(-)

diff --git a/backend/server/create-index.py b/backend/server/create-index.py @@ -0,0 +1,48 @@ +""" +Script to create payload indexes for filtering in Qdrant. +Run this ONCE to enable filtering by 'crop' and 'stage'. +""" + +import sys +import os + +# Add project root to path +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.abspath(os.path.join(current_dir, '../../')) +sys.path.append(project_root) + +from Qdrant.Client import client +from Qdrant.Store import COLLECTION_NAME +from qdrant_client.http import models + +def create_indexes(): + """Create keyword indexes for crop and stage fields""" + + print(f"šŸ“‘ Creating payload indexes for collection: {COLLECTION_NAME}") + + try: + # Create index for 'crop' field + client.create_payload_index( + collection_name=COLLECTION_NAME, + field_name="crop", + field_schema=models.PayloadSchemaType.KEYWORD + ) + print("āœ… Index created for 'crop' field") + + # Create index for 'stage' field + client.create_payload_index( + collection_name=COLLECTION_NAME, + field_name="stage", + field_schema=models.PayloadSchemaType.KEYWORD + ) + print("āœ… Index created for 'stage' field") + + print("\nšŸŽ‰ All indexes created successfully!") + print("You can now use filtered searches in your /search endpoint.") + + except Exception as e: + print(f"āŒ Error creating indexes: {e}") + print("\nNote: If indexes already exist, this is normal.") + +if __name__ == "__main__": + create_indexes() +\ No newline at end of file diff --git a/backend/server/functions.py b/backend/server/functions.py @@ -0,0 +1,110 @@ +import os +import shutil +import json +from fastapi import UploadFile +from Qdrant.Store import store_fmu, COLLECTION_NAME +from Qdrant.Client import client +from qdrant_client.http import models + +async def process_ingest(file: UploadFile, sensors_str: str, metadata_str: str, builder): + """ + Handles file saving, FMU creation, and storage logic. + """ + # 1. Save Image Temporarily + temp_filename = f"temp_{file.filename}" + with open(temp_filename, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + try: + # 2. Parse Data + sensor_data = json.loads(sensors_str) + meta_data = json.loads(metadata_str) + + # 3. Create FMU + abs_image_path = os.path.abspath(temp_filename) + fmu = builder.create_fmu(abs_image_path, sensor_data, meta_data) + + # 4. Store in Cloud + store_fmu(fmu) + return {"status": "success", "fmu_id": fmu.id} + + finally: + if os.path.exists(temp_filename): + os.remove(temp_filename) + +async def process_search(file: UploadFile, sensors_str: str, builder): + """ + Handles image processing, context extraction, and filtered Qdrant search. + """ + temp_filename = f"temp_search_{file.filename}" + with open(temp_filename, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + try: + sensor_data = json.loads(sensors_str) + abs_image_path = os.path.abspath(temp_filename) + + # --- STEP 1: Context Extraction --- + target_crop = sensor_data.get("crop") + target_stage = sensor_data.get("stage") + + if not target_crop or not target_stage: + return {"status": "error", "message": f"Missing crop/stage in: {sensor_data}"} + + print(f"šŸ”Ž Context: Searching for {target_crop} ({target_stage})...") + + # --- STEP 2: Separate Numeric Data vs Metadata --- + numeric_sensors = { + "pH": sensor_data.get("pH"), + "EC": sensor_data.get("EC"), + "temp": sensor_data.get("temp"), + "humidity": sensor_data.get("humidity") + } + metadata = {"crop": target_crop, "stage": target_stage} + + # --- STEP 3: Create Filter --- + context_filter = models.Filter( + must=[ + models.FieldCondition(key="crop", match=models.MatchValue(value=target_crop)), + models.FieldCondition(key="stage", match=models.MatchValue(value=target_stage)) + ] + ) + + # --- STEP 4: Generate Vector --- + 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 + + # --- STEP 5: Search --- + try: + response = client.query_points( + collection_name=COLLECTION_NAME, + query=query_vector, + query_filter=context_filter, + limit=5, + with_payload=True + ) + hits = response.points + except Exception as filter_error: + # Fallback for missing indexes + if "Index required" in str(filter_error): + print("āš ļø Index missing. Falling back to unfiltered search.") + response = client.search( + collection_name=COLLECTION_NAME, + query_vector=query_vector, + limit=5, + with_payload=True + ) + hits = response + else: + raise filter_error + + # Format Results + results = [ + {"id": hit.id, "score": hit.score, "payload": hit.payload} + for hit in hits + ] + return {"results": results} + + finally: + if os.path.exists(temp_filename): + os.remove(temp_filename) +\ No newline at end of file diff --git a/backend/server/main.py b/backend/server/main.py @@ -1,25 +1,21 @@ import sys import os -# --- PATH FIX: Add project root to system path --- +# --- PATH FIX --- current_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.abspath(os.path.join(current_dir, '../../')) sys.path.append(project_root) -# ------------------------------------------------- +# ---------------- -import shutil -import json from fastapi import FastAPI, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware - from Sentinel.agent import FMUBuilder -from Qdrant.Store import store_fmu, COLLECTION_NAME # Import name to avoid typos -from Qdrant.Client import client # This imports your CLOUD connection -from Sentinel.Encoders.Vision import VisionEncoder + +# Import the new logic functions +from backend.server.functions import process_ingest, process_search app = FastAPI() -# Allow connection from Next.js (port 3000) app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000"], @@ -28,107 +24,37 @@ app.add_middleware( allow_headers=["*"], ) -# Initialize the Builder +# Initialize Agents Once print("🌱 Initializing Demeter Agents...") builder = FMUBuilder() print("āœ… Agents Ready.") @app.post("/ingest") -async def ingest_fmu( +async def ingest_endpoint( file: UploadFile = File(...), sensors: str = Form(...), metadata: str = Form(...) ): - """ - Endpoint to receive raw data, convert to FMU, and store in Qdrant. - """ - print(f"šŸ“” Ingest Request Received: {file.filename}") - - temp_filename = f"temp_{file.filename}" - with open(temp_filename, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - try: - # 2. Parse JSON data from frontend - sensor_data = json.loads(sensors) - meta_data = json.loads(metadata) - - # 3. Create FMU (Uses Sentinel Logic) - abs_image_path = os.path.abspath(temp_filename) - fmu = builder.create_fmu(abs_image_path, sensor_data, meta_data) - - # 4. Store in Qdrant (Uses Memory Logic) - store_fmu(fmu) - - return {"status": "success", "fmu_id": fmu.id} - + # Pass the builder instance to the route handler + return await process_ingest(file, sensors, metadata, builder) except Exception as e: - print(f"āŒ Error: {e}") + print(f"āŒ Ingest Error: {e}") return {"status": "error", "message": str(e)} - finally: - # 5. Cleanup - if os.path.exists(temp_filename): - os.remove(temp_filename) - @app.post("/search") -async def search_fmu( +async def search_endpoint( file: UploadFile = File(...), sensors: str = Form(...) ): - """ - Semantic Search: - 1. Receives Image + Sensor Data - 2. Uses FMUBuilder to create a 'Transient FMU' (Query Object) - 3. Uses that FMU's vector to find neighbors in Qdrant - """ - temp_filename = f"temp_search_{file.filename}" - with open(temp_filename, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - try: - sensor_data = json.loads(sensors) - abs_image_path = os.path.abspath(temp_filename) - - # 1. Create a "Query FMU" - # We don't care about metadata for the query, just the vector - query_fmu = builder.create_fmu(abs_image_path, sensor_data, metadata={}) - - # 2. Search Qdrant using the FMU's vector - print(f"šŸ”Ž Searching Cloud for matches...") - - # Convert numpy array to list if needed - query_vector = query_fmu.vector.tolist() if hasattr(query_fmu.vector, 'tolist') else query_fmu.vector - - response = client.query_points( - collection_name="Farm_Memory", - query=query_vector, - limit=5, - with_payload=True - ) - - # Extract the points list from QueryResponse object - hits = response.points - - # 3. Format results - results = [] - for hit in hits: - results.append({ - "id": hit.id, - "score": hit.score, - "payload": hit.payload - }) - - return {"results": results} - + return await process_search(file, sensors, builder) except Exception as e: - print(f"Search Error: {e}") + print(f"āŒ Search Error: {e}") + import traceback + traceback.print_exc() return {"status": "error", "message": str(e)} - finally: - if os.path.exists(temp_filename): - os.remove(temp_filename) - 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/services/api.ts b/web/services/api.ts @@ -39,7 +39,9 @@ export const IngestService = { pH: parseFloat(sensors.pH), EC: parseFloat(sensors.EC), temp: parseFloat(sensors.temp), - humidity: parseFloat(sensors.humidity) + humidity: parseFloat(sensors.humidity), + crop: sensors.crop, // <--- Must include this + stage: sensors.stage, })); try {