demeter

Autonomous Hydroponic Intelligence
commit b007a5c230fa5c4b3407d7e4a7e66888e76ce15e
parent 8c997d73a84e64b0eca59d0dfe66d68a7c5666e5
Author: Debarghya Das <debarghya1108@gmail.com>
Date:   Sat, 28 Feb 2026 14:45:44 +0000

Merge PR

Diffstat:
MQdrant/Client.py | 12+++++++-----
MQdrant/Setup.py | 6+++---
MQdrant/Store.py | 6+++---
MQdrant/__pycache__/Client.cpython-311.pyc | 0
MQdrant/__pycache__/Client.cpython-313.pyc | 0
MQdrant/__pycache__/Setup.cpython-313.pyc | 0
MQdrant/__pycache__/Store.cpython-311.pyc | 0
Mbackend/server/main.py | 62+++++++++++++++++++++++++++++++++++---------------------------
Arequirements.txt | 22++++++++++++++++++++++
Dweb/app/search/page.tsx | 4----
Mweb/app/upload/page.tsx | 257+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Aweb/models/index.ts | 38++++++++++++++++++++++++++++++++++++++
Aweb/services/api.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 325 insertions(+), 140 deletions(-)

diff --git a/Qdrant/Client.py b/Qdrant/Client.py @@ -1,7 +1,8 @@ from qdrant_client import QdrantClient -def get_client(): - return QdrantClient( - url="http://localhost:6333" # change if using cloud - # api_key="YOUR_API_KEY" - ) +client = QdrantClient( + url="https://2a9e6ab0-e572-4bfa-a50f-0a169f9753d3.europe-west3-0.gcp.cloud.qdrant.io:6333", + api_key="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0.RG2XaX6thvBqI6TCtUrFg8znHYbMuFGOvbxoxPgT020", +) + +# print(qdrant_client.get_collections()) +\ No newline at end of file diff --git a/Qdrant/Setup.py b/Qdrant/Setup.py @@ -1,10 +1,10 @@ from qdrant_client import models -from Qdrant.Client import QdrantClient # <--- FIXED IMPORT +from Qdrant.Client import client # <--- FIXED IMPORT VECTOR_SIZE = 516 -COLLECTION_NAME = "farm_memory" +COLLECTION_NAME = "Farm_Memory" -client = QdrantClient(url="http://localhost:6333") +# client = QdrantClient(url="http://localhost:6333") client.recreate_collection( collection_name=COLLECTION_NAME, diff --git a/Qdrant/Store.py b/Qdrant/Store.py @@ -1,9 +1,9 @@ -from Qdrant.Client import QdrantClient # <--- FIXED IMPORT +from Qdrant.Client import client # <--- FIXED IMPORT from qdrant_client.models import PointStruct -COLLECTION_NAME = "farm_memory" +COLLECTION_NAME = "Farm_Memory" -client = QdrantClient(url="http://localhost:6333") +# client = QdrantClient(url="http://localhost:6333") def store_fmu(fmu): point = PointStruct( diff --git a/Qdrant/__pycache__/Client.cpython-311.pyc b/Qdrant/__pycache__/Client.cpython-311.pyc Binary files differ. diff --git a/Qdrant/__pycache__/Client.cpython-313.pyc b/Qdrant/__pycache__/Client.cpython-313.pyc Binary files differ. diff --git a/Qdrant/__pycache__/Setup.cpython-313.pyc b/Qdrant/__pycache__/Setup.cpython-313.pyc Binary files differ. diff --git a/Qdrant/__pycache__/Store.cpython-311.pyc b/Qdrant/__pycache__/Store.cpython-311.pyc Binary files differ. diff --git a/backend/server/main.py b/backend/server/main.py @@ -2,7 +2,6 @@ import sys import os # --- PATH FIX: Add project root to system path --- -# This allows server.py to "see" the Qdrant and Sentinel folders two levels up. current_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.abspath(os.path.join(current_dir, '../../')) sys.path.append(project_root) @@ -14,8 +13,8 @@ from fastapi import FastAPI, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware from Sentinel.agent import FMUBuilder -from Qdrant.Store import store_fmu -from Qdrant.Client import QdrantClient +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 app = FastAPI() @@ -29,16 +28,10 @@ app.add_middleware( allow_headers=["*"], ) -# Initialize Agents +# Initialize the Builder print("🌱 Initializing Demeter Agents...") -try: - builder = FMUBuilder() - client = QdrantClient(url="http://localhost:6333") - vision = VisionEncoder() - print("✅ Agents Ready.") -except Exception as e: - print(f"⚠️ Initialization Warning: {e}") - print("Ensure Qdrant is running on port 6333") +builder = FMUBuilder() +print("✅ Agents Ready.") @app.post("/ingest") async def ingest_fmu( @@ -51,8 +44,6 @@ async def ingest_fmu( """ print(f"📡 Ingest Request Received: {file.filename}") - # 1. Save Image Temporarily - # We save it in the current directory (backend/server) temporarily temp_filename = f"temp_{file.filename}" with open(temp_filename, "wb") as buffer: shutil.copyfileobj(file.file, buffer) @@ -63,7 +54,6 @@ async def ingest_fmu( meta_data = json.loads(metadata) # 3. Create FMU (Uses Sentinel Logic) - # We pass the absolute path to ensure Sentinel can find the file abs_image_path = os.path.abspath(temp_filename) fmu = builder.create_fmu(abs_image_path, sensor_data, meta_data) @@ -82,30 +72,49 @@ async def ingest_fmu( os.remove(temp_filename) @app.post("/search") -async def search_image(file: UploadFile = File(...)): +async def search_fmu( + file: UploadFile = File(...), + sensors: str = Form(...) +): """ - Endpoint to find similar past FMUs based on visual similarity. + 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: - # 1. Encode image to vector + sensor_data = json.loads(sensors) abs_image_path = os.path.abspath(temp_filename) - vector = vision.encode(abs_image_path) + + # 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 - # 2. Search Qdrant - hits = client.search( - collection_name="farm_memory", - query_vector=vector.tolist(), - limit=5 + 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 }) @@ -113,7 +122,7 @@ async def search_image(file: UploadFile = File(...)): return {"results": results} except Exception as e: - print(f"❌ Search Error: {e}") + print(f"Search Error: {e}") return {"status": "error", "message": str(e)} finally: @@ -122,5 +131,4 @@ async def search_image(file: UploadFile = File(...)): if __name__ == "__main__": import uvicorn - # Host 0.0.0.0 allows external access, Port 8000 is standard for API uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt @@ -0,0 +1,21 @@ +# --- API Server --- +fastapi +uvicorn +python-multipart + +# --- Vector Database --- +# We force version 1.7.0+ to ensure .search() and .search_batch() exist +qdrant-client>=1.7.0 + +# --- AI & Image Processing --- +numpy +pillow +torch +torchvision +ftfy +regex +tqdm + +# --- OpenAI CLIP (Vision Encoder) --- +# This installs directly from GitHub because it's not on standard PyPI +git+https://github.com/openai/CLIP.git +\ No newline at end of file diff --git a/web/app/search/page.tsx b/web/app/search/page.tsx @@ -1,3 +0,0 @@ -export default function SearchPage() { - return <div className="p-8 text-white">Search Module Coming Soon...</div>; -} -\ No newline at end of file diff --git a/web/app/upload/page.tsx b/web/app/upload/page.tsx @@ -1,15 +1,22 @@ "use client"; import { useState } from "react"; -import { Upload, Save, Activity, Droplets, Thermometer, Wind } from "lucide-react"; +import { Upload, Save, Activity, Droplets, Thermometer, Wind, Search, Sprout, Calendar, BarChart3, ArrowRight } from "lucide-react"; +import { SensorData, SearchResult } from "@/models"; +import { IngestService } from "@/services/api"; // Ensure you have this service file created -export default function UploadPage() { +export default function UnifiedPage() { const [file, setFile] = useState<File | null>(null); const [preview, setPreview] = useState<string | null>(null); - const [loading, setLoading] = useState(false); + // Separate loading states to show which button is working + const [loadingIngest, setLoadingIngest] = useState(false); + const [loadingSearch, setLoadingSearch] = useState(false); + + const [searchResults, setSearchResults] = useState<SearchResult[]>([]); + // Sensor State - const [sensors, setSensors] = useState({ + const [sensors, setSensors] = useState<SensorData>({ pH: "6.0", EC: "1.2", temp: "24.0", @@ -18,11 +25,14 @@ export default function UploadPage() { stage: "Vegetative" }); + // --- Handlers --- + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files && e.target.files[0]) { const selected = e.target.files[0]; setFile(selected); setPreview(URL.createObjectURL(selected)); + setSearchResults([]); // Clear old results on new file } }; @@ -30,65 +40,60 @@ export default function UploadPage() { setSensors({ ...sensors, [e.target.name]: e.target.value }); }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!file) return alert("Please select an image"); - - setLoading(true); - const formData = new FormData(); - formData.append("file", file); - // Append sensor data as JSON string or individual fields - formData.append("sensors", JSON.stringify({ - pH: parseFloat(sensors.pH), - EC: parseFloat(sensors.EC), - temp: parseFloat(sensors.temp), - humidity: parseFloat(sensors.humidity) - })); - formData.append("metadata", JSON.stringify({ - crop: sensors.crop, - stage: sensors.stage - })); + const handleIngest = async () => { + if (!file) return alert("Please select an image first."); + setLoadingIngest(true); + + try { + await IngestService.uploadFMU(file, sensors); + alert("✅ FMU Created & Stored Successfully!"); + // Optional: Clear form after success? + // setFile(null); setPreview(null); setSearchResults([]); + } catch (error) { + console.log(error); + alert("❌ Ingest Failed. Is the backend running?"); + } finally { + setLoadingIngest(false); + } + }; + + const handleSearch = async () => { + if (!file) return alert("Please select an image to search with."); + setLoadingSearch(true); try { - const res = await fetch("http://localhost:8000/ingest", { - method: "POST", - body: formData, - }); - - if (res.ok) { - alert("FMU Created & Stored Successfully! 🌱"); - // Reset form - setFile(null); - setPreview(null); - } else { - alert("Error uploading data."); - } + const response = await IngestService.searchFMU(file, sensors); + setSearchResults(response.results || []); + if (response.results.length === 0) alert("No similar memories found."); } catch (error) { - console.error(error); - alert("Server connection failed."); + alert("❌ Search Failed. Is the backend running?"); } finally { - setLoading(false); + setLoadingSearch(false); } }; + // --- UI Render --- + return ( - <main className="min-h-screen bg-slate-950 text-slate-200 p-8 flex items-center justify-center"> - <div className="max-w-4xl w-full grid grid-cols-1 md:grid-cols-2 gap-12"> + <main className="min-h-screen bg-slate-950 text-slate-200 p-8 flex flex-col items-center"> + + {/* Top Section: Control Panel */} + <div className="max-w-6xl w-full grid grid-cols-1 lg:grid-cols-2 gap-12 mb-16"> - {/* --- Left Column: Image Upload --- */} + {/* Left: Image Input */} <div className="space-y-6"> - <div className="relative border-2 border-dashed border-slate-700 bg-slate-900/50 rounded-2xl h-96 flex flex-col items-center justify-center hover:border-emerald-500/50 transition-colors group"> + <div className="relative border-2 border-dashed border-slate-700 bg-slate-900/50 rounded-2xl h-96 flex flex-col items-center justify-center hover:border-emerald-500/50 transition-colors group overflow-hidden"> <input type="file" onChange={handleFileChange} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10" /> {preview ? ( - <img src={preview} alt="Preview" className="h-full w-full object-cover rounded-2xl opacity-80" /> + <img src={preview} alt="Preview" className="h-full w-full object-cover opacity-90" /> ) : ( <div className="text-center p-6"> <div className="w-16 h-16 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-emerald-500/20 transition-colors"> - <Upload className="w-8 h-8 text-slate-400 group-hover:text-emerald-400" /> + <Upload className="w-8 h-8 text-slate-400 group-hover:text-emerald-400" /> </div> <p className="text-slate-400 font-medium">Drop crop image here</p> <p className="text-xs text-slate-600 mt-2">JPG, PNG supported</p> @@ -97,63 +102,119 @@ export default function UploadPage() { </div> </div> - {/* --- Right Column: Sensor Data --- */} - <form onSubmit={handleSubmit} className="space-y-6"> - <div> - <h1 className="text-3xl font-bold text-white mb-2">New Entry</h1> - <p className="text-slate-500">Manual override for Sentinel Agent.</p> - </div> - - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <label className="text-xs font-mono text-emerald-400 uppercase">pH Level</label> - <div className="relative"> - <Droplets className="absolute left-3 top-3 w-4 h-4 text-slate-500" /> - <input name="pH" value={sensors.pH} onChange={handleInputChange} type="number" step="0.1" className="w-full bg-slate-900 border border-slate-800 rounded-xl py-2 pl-10 pr-4 focus:ring-2 focus:ring-emerald-500 outline-none transition-all" /> - </div> - </div> - <div className="space-y-2"> - <label className="text-xs font-mono text-yellow-400 uppercase">EC (mS/cm)</label> - <div className="relative"> - <Activity className="absolute left-3 top-3 w-4 h-4 text-slate-500" /> - <input name="EC" value={sensors.EC} onChange={handleInputChange} type="number" step="0.1" className="w-full bg-slate-900 border border-slate-800 rounded-xl py-2 pl-10 pr-4 focus:ring-2 focus:ring-yellow-500 outline-none transition-all" /> - </div> + {/* Right: Sensor Inputs & Actions */} + <div className="space-y-8 flex flex-col justify-center"> + <div> + <h1 className="text-3xl font-bold text-white mb-2">Demeter Control</h1> + <p className="text-slate-500">Ingest new data or query the Historian Agent.</p> + </div> + + {/* Sensor Grid */} + <div className="grid grid-cols-2 gap-4"> + {/* Helper function to render inputs cleanly */} + {[ + { label: "pH Level", name: "pH", icon: Droplets, color: "text-emerald-400" }, + { label: "EC (mS/cm)", name: "EC", icon: Activity, color: "text-yellow-400" }, + { label: "Temp (°C)", name: "temp", icon: Thermometer, color: "text-red-400" }, + { label: "Humidity (%)", name: "humidity", icon: Wind, color: "text-blue-400" } + ].map((field) => ( + <div key={field.name} className="space-y-2"> + <label className={`text-xs font-mono uppercase ${field.color}`}>{field.label}</label> + <div className="relative"> + <field.icon className="absolute left-3 top-3 w-4 h-4 text-slate-500" /> + <input + name={field.name} + // @ts-ignore + value={sensors[field.name as keyof SensorData]} + onChange={handleInputChange} + type="number" step="0.1" + className="w-full bg-slate-900 border border-slate-800 rounded-xl py-2 pl-10 pr-4 focus:ring-2 focus:ring-emerald-500 outline-none transition-all" + /> + </div> + </div> + ))} + </div> + + {/* Metadata Selects */} + <div className="grid grid-cols-2 gap-4"> + <select name="crop" value={sensors.crop} onChange={handleInputChange} className="bg-slate-900 border border-slate-800 text-slate-300 rounded-xl p-3 outline-none"> + <option>Lettuce</option><option>Basil</option><option>Tomato</option> + </select> + <select name="stage" value={sensors.stage} onChange={handleInputChange} className="bg-slate-900 border border-slate-800 text-slate-300 rounded-xl p-3 outline-none"> + <option>Seedling</option><option>Vegetative</option><option>Flowering</option><option>Harvest</option> + </select> + </div> + + {/* --- Action Buttons --- */} + <div className="grid grid-cols-2 gap-4 pt-4 border-t border-slate-800"> + <button + onClick={handleIngest} + disabled={loadingIngest || loadingSearch} + className="py-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-emerald-900/20 disabled:opacity-50 flex items-center justify-center space-x-2" + > + {loadingIngest ? <Activity className="animate-spin w-5 h-5" /> : <><Save className="w-5 h-5" /> <span>Store Memory</span></>} + </button> + + <button + onClick={handleSearch} + disabled={loadingIngest || loadingSearch} + className="py-4 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-blue-900/20 disabled:opacity-50 flex items-center justify-center space-x-2" + > + {loadingSearch ? <Activity className="animate-spin w-5 h-5" /> : <><Search className="w-5 h-5" /> <span>Search Archives</span></>} + </button> + </div> + </div> + </div> + + {/* Bottom Section: Search Results (Conditional Render) */} + {searchResults.length > 0 && ( + <div className="max-w-6xl w-full animate-in fade-in slide-in-from-bottom-10 duration-500"> + <div className="flex items-center justify-between border-b border-slate-800 pb-4 mb-8"> + <h2 className="text-2xl font-bold text-white flex items-center gap-2"> + <Search className="w-6 h-6 text-blue-500" /> + Retrieved Evidence + </h2> + <span className="text-xs font-mono text-blue-400 bg-blue-500/10 px-3 py-1 rounded-full border border-blue-500/20"> + {searchResults.length} SIMILAR CASES + </span> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> + {searchResults.map((res) => ( + <div key={res.id} className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl hover:border-blue-500/50 transition-all group relative overflow-hidden"> + <div className="absolute top-0 right-0 bg-blue-600 text-white text-[10px] font-bold px-3 py-1 rounded-bl-xl"> + {(res.score * 100).toFixed(1)}% MATCH </div> - <div className="space-y-2"> - <label className="text-xs font-mono text-red-400 uppercase">Temp (°C)</label> - <div className="relative"> - <Thermometer className="absolute left-3 top-3 w-4 h-4 text-slate-500" /> - <input name="temp" value={sensors.temp} onChange={handleInputChange} type="number" step="0.1" className="w-full bg-slate-900 border border-slate-800 rounded-xl py-2 pl-10 pr-4 focus:ring-2 focus:ring-red-500 outline-none transition-all" /> - </div> + + <div className="mb-4"> + <h3 className="text-xl font-bold text-white flex items-center gap-2"> + <Sprout className="w-5 h-5 text-emerald-400" /> + {res.payload.crop} + </h3> + <span className="text-xs text-slate-400 uppercase tracking-wider">{res.payload.stage}</span> </div> - <div className="space-y-2"> - <label className="text-xs font-mono text-blue-400 uppercase">Humidity (%)</label> - <div className="relative"> - <Wind className="absolute left-3 top-3 w-4 h-4 text-slate-500" /> - <input name="humidity" value={sensors.humidity} onChange={handleInputChange} type="number" step="1" className="w-full bg-slate-900 border border-slate-800 rounded-xl py-2 pl-10 pr-4 focus:ring-2 focus:ring-blue-500 outline-none transition-all" /> - </div> + + <div className="space-y-3 text-sm text-slate-400"> + <div className="flex items-center justify-between border-b border-slate-800 pb-2"> + <div className="flex items-center gap-2"><Calendar className="w-4 h-4" /> Date</div> + <span className="font-mono text-slate-300">{res.payload.timestamp ? new Date(res.payload.timestamp).toLocaleDateString() : 'N/A'}</span> + </div> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"><BarChart3 className="w-4 h-4" /> Sensors</div> + <span className="font-mono text-xs text-slate-300"> + pH: {res.payload.sensors?.pH} | EC: {res.payload.sensors?.EC} + </span> + </div> </div> - </div> - - <div className="grid grid-cols-2 gap-4 pt-4 border-t border-slate-800"> - <select name="crop" value={sensors.crop} onChange={handleInputChange} className="bg-slate-900 border border-slate-800 text-slate-300 rounded-xl p-3 outline-none"> - <option>Lettuce</option> - <option>Basil</option> - <option>Tomato</option> - </select> - <select name="stage" value={sensors.stage} onChange={handleInputChange} className="bg-slate-900 border border-slate-800 text-slate-300 rounded-xl p-3 outline-none"> - <option>Seedling</option> - <option>Vegetative</option> - <option>Flowering</option> - <option>Harvest</option> - </select> - </div> - - <button type="submit" disabled={loading} className="w-full py-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-emerald-900/20 disabled:opacity-50 flex items-center justify-center space-x-2"> - {loading ? <Activity className="animate-spin w-5 h-5" /> : <><Save className="w-5 h-5" /> <span>Store Memory Unit</span></>} - </button> - </form> - </div> + + <button className="w-full mt-6 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 text-sm rounded-lg transition-colors flex items-center justify-center gap-2 group-hover:text-blue-400"> + <span>Load Context</span> <ArrowRight className="w-4 h-4" /> + </button> + </div> + ))} + </div> + </div> + )} </main> ); } \ No newline at end of file diff --git a/web/models/index.ts b/web/models/index.ts @@ -0,0 +1,37 @@ +export interface SensorData { + pH: string; + EC: string; + temp: string; + humidity: string; + crop: string; + stage: string; +} + +export interface IngestResponse { + status: "success" | "error"; + fmu_id?: string; + message?: string; +} + +// New Types for Search +export interface SearchResult { + id: string; + score: number; + payload: { + crop: string; + stage: string; + timestamp: string; + sensors?: { + pH: number; + EC: number; + temp: number; + humidity: number; + } + }; +} + +export interface SearchResponse { + results: SearchResult[]; + status?: string; + message?: string; +} +\ No newline at end of file diff --git a/web/services/api.ts b/web/services/api.ts @@ -0,0 +1,57 @@ +import { SensorData, IngestResponse, SearchResponse } from "@/models"; + +const API_URL = "http://localhost:8000"; + +export const IngestService = { + // ... (keep uploadFMU as is) ... + + 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({ + pH: parseFloat(sensors.pH), + EC: parseFloat(sensors.EC), + temp: parseFloat(sensors.temp), + humidity: parseFloat(sensors.humidity) + })); + formData.append("metadata", JSON.stringify({ + crop: sensors.crop, + stage: sensors.stage + })); + + try { + const res = await fetch(`${API_URL}/ingest`, { method: "POST", body: formData }); + if (!res.ok) throw new Error(`Server Error: ${res.statusText}`); + return await res.json(); + } catch (error) { + console.error("Ingest Service Error:", error); + throw error; + } + }, + + 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) + })); + + try { + const res = await fetch(`${API_URL}/search`, { + method: "POST", + body: formData, + }); + if (!res.ok) throw new Error(`Server Error: ${res.statusText}`); + return await res.json(); + } catch (error) { + console.error("Search Service Error:", error); + throw error; + } + } +}; +\ No newline at end of file