commit b007a5c230fa5c4b3407d7e4a7e66888e76ce15e
parent 8c997d73a84e64b0eca59d0dfe66d68a7c5666e5
Author: Debarghya Das <debarghya1108@gmail.com>
Date: Sat, 28 Feb 2026 14:45:44 +0000
Merge PR
Diffstat:
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