demeter

Autonomous Hydroponic Intelligence
commit a301b5fbe31e3215dee2e5f4dab6a2b544d880b7
parent a40228adf42a56246cd5cd12d61df20f2b02f37d
Author: Debarghya Das <debarghya1108@gmail.com>
Date:   Sun,  1 Mar 2026 16:06:08 +0000

Merge PR

Diffstat:
M.gitignore | 2++
MQdrant/Client.py | 1+
MQdrant/__pycache__/Client.cpython-311.pyc | 0
Aagent/sub_agents/Researcher.py | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aagent/sub_agents/Supervisor.py | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dagent/sub_agents/action_orchestrator.py | 0
Dagent/sub_agents/researcher_agent.py | 0
Aagent/sub_agents/test_pipeline.py | 44++++++++++++++++++++++++++++++++++++++++++++
Mbackend/server/functions.py | 184++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mbackend/server/main.py | 21++++++---------------
Abackend/server/rag_brain.py | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mrequirements.txt | 4++++
Mweb/app/upload/page.tsx | 120++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mweb/models/index.ts | 36++++++++++++++++++++++++------------
14 files changed, 597 insertions(+), 114 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -3,3 +3,4 @@ node_modules/ __pycache__/ /web/node_modules +Knowledge_Base +\ No newline at end of file diff --git a/Qdrant/Client.py b/Qdrant/Client.py @@ -3,6 +3,7 @@ from qdrant_client import QdrantClient client = QdrantClient( url="https://2a9e6ab0-e572-4bfa-a50f-0a169f9753d3.europe-west3-0.gcp.cloud.qdrant.io:6333", api_key="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0.RG2XaX6thvBqI6TCtUrFg8znHYbMuFGOvbxoxPgT020", + timeout=120 ) # print(qdrant_client.get_collections()) \ No newline at end of file diff --git a/Qdrant/__pycache__/Client.cpython-311.pyc b/Qdrant/__pycache__/Client.cpython-311.pyc Binary files differ. diff --git a/agent/sub_agents/Researcher.py b/agent/sub_agents/Researcher.py @@ -0,0 +1,66 @@ +import uuid +from qdrant_client import models +from fastembed import TextEmbedding +from Qdrant.Client import client # Import your existing cloud connection + +class ResearcherAgent: + def __init__(self): + self.client = client + self.collection = "Knowledge_Base" + # FastEmbed is lightweight and runs locally on CPU + self.encoder = TextEmbedding(model_name="BAAI/bge-small-en-v1.5") + self._ensure_collection() + + def _ensure_collection(self): + """Creates the text collection if it doesn't exist.""" + if not self.client.collection_exists(self.collection): + self.client.create_collection( + collection_name=self.collection, + vectors_config=models.VectorParams( + size=384, # bge-small uses 384 dimensions + distance=models.Distance.COSINE + ) + ) + print(f"šŸ“š Created knowledge base: {self.collection}") + + def ingest_text(self, text: str, source: str = "Manual"): + """Saves a chunk of text (e.g., from a PDF) into memory.""" + # Embed the text + embedding = list(self.encoder.embed([text]))[0] + + # Upload to Qdrant + self.client.upsert( + collection_name=self.collection, + points=[ + models.PointStruct( + id=str(uuid.uuid4()), + vector=embedding.tolist(), + payload={"text": text, "source": source} + ) + ] + ) + + def search(self, query: str, limit: int = 3) -> str: + """Retrieves relevant textbook pages and formats them as a string.""" + # 1. Convert query to vector + query_vec = list(self.encoder.embed([query]))[0] + + # 2. Search Qdrant + # CRITICAL FIX: Added 'with_payload=True' so we actually get the text back + response = self.client.query_points( + collection_name=self.collection, + query=query_vec, + limit=limit, + with_payload=True + ) + + hits = response.points + + # 3. Format as a single string (better for LLM Context) + if not hits: + return "No specific scientific manuals found for this issue." + + return "\n\n".join([ + f"[Source: {hit.payload.get('source', 'Unknown')}]\n{hit.payload.get('text', '')}" + for hit in hits + ]) +\ No newline at end of file diff --git a/agent/sub_agents/Supervisor.py b/agent/sub_agents/Supervisor.py @@ -0,0 +1,98 @@ +import json +import os +from dotenv import load_dotenv +from openai import OpenAI + +# Load .env file relative to this script +current_dir = os.path.dirname(os.path.abspath(__file__)) +env_path = os.path.join(current_dir, '../../.env') +load_dotenv(env_path) + +class SupervisorAgent: + def __init__(self, researcher_agent): + self.researcher = researcher_agent + + # ⚔ CONNECT TO GROQ CLOUD + # CHECK: Ensure your .env file has 'GROK_API_KEY' or 'GROQ_API_KEY' + # We use 'GROQ_API_KEY' here based on your previous messages + api_key = os.getenv("GROQ_API_KEY") + + if not api_key: + print("āš ļø WARNING: API Key not found. Supervisor may fail.") + + self.llm = OpenAI( + base_url="https://api.groq.com/openai/v1", + api_key=api_key + ) + + def reason(self, current_fmu, similar_fmus, sub_agent_outputs): + """ + The Core Reasoning Loop: + 1. Contextualize -> 2. Research -> 3. Synthesize -> 4. Decide + """ + + # --- STEP 1: Formulate the Research Question --- + crop = current_fmu['metadata'].get('crop', 'Unknown Crop') + stage = current_fmu['metadata'].get('stage', 'Unknown Stage') + + # E.g., "Lettuce Vegetative Low pH issues" + research_query = f"{crop} {stage} {sub_agent_outputs.get('nutrient_analysis', '')} issues" + + print(f"šŸ¤” Supervisor is asking Researcher: '{research_query}'") + + # --- STEP 2: The Researcher Fetches Evidence (RAG) --- + # This now returns a clean STRING, not a list + scientific_context = self.researcher.search(research_query) + + # --- STEP 3: Synthesize History (Memory) --- + history_context = "\n".join([ + f"- Previous Case (Score {f['score']:.2f}): {f['payload'].get('outcome', 'No outcome recorded')}" + for f in similar_fmus + ]) + + # --- STEP 4: The Final Prompt --- + system_prompt = """ + You are the Chief Supervisor AI of a Hydroponic Facility. + Your goal: Synthesize conflicting data to recommend the OPTIMAL action. + + PRINCIPLES: + 1. Plant Health is Priority #1. + 2. Verify Sub-Agent claims against the SCIENTIFIC KNOWLEDGE provided. + 3. If History contradicts Science, prefer Science (Manuals), but note the anomaly. + """ + + user_message = f""" + ### SITUATION REPORT + Target: {crop} ({stage}) + Sensors: {current_fmu['payload']['sensors']} + + ### SUB-AGENT ALERTS + {json.dumps(sub_agent_outputs, indent=2)} + + ### SCIENTIFIC KNOWLEDGE (Verified Manuals) + {scientific_context} + + ### HISTORICAL MEMORY (Similar Past Events) + {history_context} + + ### COMMAND + Analyze the situation. Resolve conflicts between agents using the Manuals. + Output JSON: {{ "reasoning": "...", "action": "...", "confidence": 0.0-1.0 }} + """ + + # --- STEP 5: Execute Reasoning on Groq --- + try: + response = self.llm.chat.completions.create( + # We use Llama-3.1-8b because it is fast and smart enough for this logic + model="llama-3.1-8b-instant", + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message} + ], + temperature=0.1, # Low temp for strict logic + response_format={"type": "json_object"} # Force valid JSON + ) + return json.loads(response.choices[0].message.content) + + except Exception as e: + return {"error": str(e), "reasoning": "Groq Connection Failed"} +\ No newline at end of file diff --git a/agent/sub_agents/action_orchestrator.py b/agent/sub_agents/action_orchestrator.py diff --git a/agent/sub_agents/researcher_agent.py b/agent/sub_agents/researcher_agent.py diff --git a/agent/sub_agents/test_pipeline.py b/agent/sub_agents/test_pipeline.py @@ -0,0 +1,43 @@ +import sys +import os +# --- PATH FIX: Add project root to system path --- +# This ensures Python can see 'agent', 'Qdrant', 'Sentinel' from anywhere +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 agent.sub_agents.Researcher import ResearcherAgent +from agent.sub_agents.Supervisor import SupervisorAgent + +# 1. Setup Agents +print("🌱 Waking up Agents...") +researcher = ResearcherAgent() +supervisor = SupervisorAgent(researcher) + +# 2. Seed some dummy knowledge (Run this once) +print("šŸ“š Ingesting Knowledge...") +researcher.ingest_text( + "Lettuce in vegetative stage requires pH between 5.5 and 6.5. " + "Yellowing leaves often indicate Nitrogen deficiency or low pH lockout." +) + +# 3. Mock Data (Simulate what the Backend would send) +mock_fmu = { + "metadata": {"crop": "Lettuce", "stage": "Vegetative"}, + "payload": {"sensors": {"pH": 4.2, "EC": 1.2}} +} +mock_similar_fmus = [ + {"score": 0.88, "payload": {"outcome": "Recovered after adding pH Up"}} +] +mock_sub_agents = { + "nutrient_analysis": "Critical Low pH detected.", + "resource_status": "Water tank at 15%." +} + +# 4. Run Reasoning +print("🧠 Supervisor is thinking...") +decision = supervisor.reason(mock_fmu, mock_similar_fmus, mock_sub_agents) + +print("\n=== FINAL DECISION ===") +print(decision) +\ No newline at end of file diff --git a/backend/server/functions.py b/backend/server/functions.py @@ -1,58 +1,101 @@ +import os +import shutil import json +import traceback +from fastapi import UploadFile from qdrant_client.http import models + +# --- AGENT IMPORTS --- +from agent.sub_agents.Researcher import ResearcherAgent +from agent.sub_agents.Supervisor import SupervisorAgent from Qdrant.Store import store_fmu, COLLECTION_NAME from Qdrant.Client import client -async def process_ingest(image_base64: str, sensors_str: str, metadata_str: str, builder): +# Initialize Agents ONCE (Global Scope) to save memory +print("🌱 Initializing Cognitive Stack...") +researcher = ResearcherAgent() +supervisor = SupervisorAgent(researcher) +print("āœ… Agents Ready.") + +# --- HELPER: SIMULATE MINI-AGENTS --- +# In production, these would be your actual imported classes from agent/sub_agents/ +def simulate_sub_agents(sensors): + """ + Generates 'Expert Opinions' based on raw sensor data. + """ + reports = {} + + # 1. Nutrient Agent Logic + ph = sensors.get("pH", 6.0) + ec = sensors.get("EC", 1.5) + if ph < 5.5: + reports["nutrient"] = f"CRITICAL: pH is {ph} (Too Acidic). Risk of Nutrient Lockout." + elif ph > 6.5: + reports["nutrient"] = f"WARNING: pH is {ph} (Too Alkaline). Efficiency dropping." + else: + reports["nutrient"] = f"Optimal pH ({ph}). EC is {ec}." + + # 2. Atmosphere Agent Logic + temp = sensors.get("temp", 25) + humid = sensors.get("humidity", 60) + if temp > 28: + reports["atmosphere"] = f"Heat Stress Warning: {temp}°C is too high." + elif humid > 80: + reports["atmosphere"] = f"High Humidity ({humid}%). Vapor Pressure Deficit (VPD) is low." + else: + reports["atmosphere"] = "Climate is within nominal range." + + # 3. Resource Agent Logic + reports["resources"] = "Water levels stable. Power grid nominal." + + return reports + +async def process_ingest(file: UploadFile, sensors_str: str, metadata_str: str, builder): """ - Handles FMU creation and storage logic using base64 image. - No more temporary files! + Handles file saving, FMU creation, and storage logic. """ + temp_filename = f"temp_{file.filename}" + with open(temp_filename, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + try: - # 1. Parse Data sensor_data = json.loads(sensors_str) meta_data = json.loads(metadata_str) - - # 2. Create FMU directly from base64 - print(f"šŸ“” Creating FMU from base64 image...") - fmu = builder.create_fmu(image_base64, sensor_data, meta_data) - - # 3. Store in Cloud + abs_image_path = os.path.abspath(temp_filename) + + fmu = builder.create_fmu(abs_image_path, sensor_data, meta_data) store_fmu(fmu) - print(f"āœ… FMU stored successfully: {fmu.id}") return {"status": "success", "fmu_id": fmu.id} - except Exception as e: - print(f"āŒ Ingest processing error: {e}") - raise + finally: + if os.path.exists(temp_filename): + os.remove(temp_filename) -async def process_search(image_base64: str, sensors_str: str, builder): +async def process_search(file: UploadFile, sensors_str: str, builder): """ - Handles image processing, context extraction, and filtered Qdrant search. - Uses base64 image instead of temporary files. + 1. Search Similar FMUs (Memory) + 2. Consult Researcher (Knowledge) + 3. Run Supervisor (Reasoning) """ + 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") + target_crop = sensor_data.get("crop", "Unknown") + target_stage = sensor_data.get("stage", "Unknown") + print(f"šŸ”Ž Pipeline triggered for: {target_crop} ({target_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") - } + # --- 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} - # --- STEP 3: Create Filter --- + # Create Filter context_filter = models.Filter( must=[ models.FieldCondition(key="crop", match=models.MatchValue(value=target_crop)), @@ -60,45 +103,62 @@ async def process_search(image_base64: str, sensors_str: str, builder): ] ) - # --- STEP 4: Generate Vector from base64 --- - print(f"🧠 Generating query vector from base64 image...") - query_fmu = builder.create_fmu(image_base64, numeric_sensors, metadata=metadata) + # 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 - # --- STEP 5: Search --- try: - print(f"šŸ” Searching Qdrant with filters...") response = client.query_points( collection_name=COLLECTION_NAME, query=query_vector, query_filter=context_filter, - limit=5, + limit=3, # Get top 3 similar cases with_payload=True ) hits = response.points - print(f"āœ… Found {len(hits)} matches") - except Exception as filter_error: - # Fallback for missing indexes - if "Index required" in str(filter_error): - print("āš ļø Payload indexes missing. Falling back to unfiltered search.") - print("šŸ’” Run 'python create_indexes.py' to enable filtered searches.") - response = client.search( - collection_name=COLLECTION_NAME, - query_vector=query_vector, - limit=5, - with_payload=True - ) - hits = response.points - else: - raise filter_error - - # Format Results - results = [ - {"id": hit.id, "score": hit.score, "payload": hit.payload} - for hit in hits + 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) + + # Format Memory for the Supervisor + similar_fmus_formatted = [ + {"score": hit.score, "payload": hit.payload} for hit in hits ] - return {"results": results} + + # --- 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 --- + 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! + } except Exception as e: - print(f"āŒ Search processing error: {e}") - raise -\ No newline at end of file + print(f"āŒ Pipeline Error: {e}") + traceback.print_exc() + return {"status": "error", "message": str(e)} + + 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 @@ -12,7 +12,7 @@ from fastapi import FastAPI, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware from Sentinel.agent import FMUBuilder -# Import the new logic functions +# Import the logic functions from backend.server.functions import process_ingest, process_search app = FastAPI() @@ -30,12 +30,7 @@ print("🌱 Initializing Demeter Agents...") builder = FMUBuilder() print("āœ… Agents Ready.") -async def file_to_base64(file: UploadFile) -> str: - """Convert uploaded file to base64 string""" - contents = await file.read() - base64_string = base64.b64encode(contents).decode('utf-8') - await file.seek(0) # Reset file pointer in case it's needed again - return base64_string +# (Helper function removed as it is no longer needed for these endpoints) @app.post("/ingest") async def ingest_endpoint( @@ -44,10 +39,8 @@ async def ingest_endpoint( metadata: str = Form(...) ): try: - # Convert file to base64 - image_base64 = await file_to_base64(file) - # Pass the base64 string to the process function - return await process_ingest(image_base64, sensors, metadata, builder) + # FIX: Pass the 'file' object directly. Do NOT convert to base64 string. + return await process_ingest(file, sensors, metadata, builder) except Exception as e: print(f"āŒ Ingest Error: {e}") import traceback @@ -60,10 +53,8 @@ async def search_endpoint( sensors: str = Form(...) ): try: - # Convert file to base64 - image_base64 = await file_to_base64(file) - # Pass the base64 string to the process function - return await process_search(image_base64, sensors, builder) + # FIX: Pass the 'file' object directly. + return await process_search(file, sensors, builder) except Exception as e: print(f"āŒ Search Error: {e}") import traceback diff --git a/backend/server/rag_brain.py b/backend/server/rag_brain.py @@ -0,0 +1,132 @@ +import sys +import os +import uuid +import pypdf +from qdrant_client import models +from fastembed import TextEmbedding + +# --- PATH FIX: Add project root to system path --- +# This ensures we can import your 'Qdrant.Client' connection +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 # Uses your existing Cloud connection + +# --- CONFIGURATION --- +COLLECTION_NAME = "Knowledge_Base" +VECTOR_SIZE = 384 # Standard size for 'bge-small-en-v1.5' +DOCS_FOLDER = os.path.join(project_root, "Knowledge_Base") # <--- Folder Name + +def init_collection(): + """ + Creates the collection if it doesn't exist. + """ + if client.collection_exists(COLLECTION_NAME): + print(f"ā„¹ļø Collection '{COLLECTION_NAME}' already exists. Appending data...") + else: + print(f"šŸ”Ø Creating new collection: {COLLECTION_NAME}") + client.create_collection( + collection_name=COLLECTION_NAME, + vectors_config=models.VectorParams( + size=VECTOR_SIZE, + distance=models.Distance.COSINE + ) + ) + print("āœ… Collection created.") + +def extract_text_from_pdf(pdf_path): + """ + Reads a PDF file page by page and returns the full text. + """ + text = "" + try: + reader = pypdf.PdfReader(pdf_path) + for page in reader.pages: + page_text = page.extract_text() + if page_text: + text += page_text + "\n" + except Exception as e: + print(f"āŒ Error reading PDF {pdf_path}: {e}") + return text + +def chunk_text(text, chunk_size=500, overlap=50): + """ + Splits long text into smaller overlapping pieces. + Overlap helps preserve context between chunks. + """ + if not text: + return [] + return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size - overlap)] + +def ingest_docs(): + # 1. Setup Collection & Model + init_collection() + + print("🧠 Loading Embedding Model (bge-small-en)...") + # This runs locally on your CPU (Fast & Free) + model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5") + + # 2. Check if folder exists + if not os.path.exists(DOCS_FOLDER): + os.makedirs(DOCS_FOLDER) + print(f"āš ļø Created folder '{DOCS_FOLDER}'. Please put your PDFs there and run this script again!") + return + + # 3. Scan for files + files = [f for f in os.listdir(DOCS_FOLDER) if f.endswith(('.pdf', '.txt'))] + if not files: + print(f"šŸ“­ No files found in '{DOCS_FOLDER}'. Add some PDFs!") + return + + print(f"šŸ“š Found {len(files)} documents. Starting ingestion...") + total_chunks = 0 + + for file_name in files: + file_path = os.path.join(DOCS_FOLDER, file_name) + print(f" šŸ“„ Processing: {file_name}") + + # A. Extract Text + content = "" + if file_name.endswith('.pdf'): + content = extract_text_from_pdf(file_path) + else: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + if not content.strip(): + print(f" āš ļø Skipping empty file.") + continue + + # B. Chunk Text + chunks = chunk_text(content) + if not chunks: + continue + + # C. Convert to Vectors (Embed) + # FastEmbed handles the list of strings automatically + embeddings = list(model.embed(chunks)) + + # D. Prepare Points for Qdrant + points = [] + for i, (text_chunk, vector) in enumerate(zip(chunks, embeddings)): + points.append(models.PointStruct( + id=str(uuid.uuid4()), # Generate a random ID for this chunk + vector=vector.tolist(), + payload={ + "text": text_chunk, + "source": file_name, + "chunk_id": i + } + )) + + # E. Upload Batch + client.upsert(collection_name=COLLECTION_NAME, points=points) + total_chunks += len(points) + print(f" āœ… Uploaded {len(points)} chunks.") + + print(f"\nšŸŽ‰ Success! Knowledge Base now contains {total_chunks} searchable segments.") + +if __name__ == "__main__": + ingest_docs() +\ No newline at end of file diff --git a/requirements.txt b/requirements.txt @@ -15,6 +15,10 @@ torchvision ftfy regex tqdm +fastembed +openai +pypdf + # --- OpenAI CLIP (Vision Encoder) --- # This installs directly from GitHub because it's not on standard PyPI diff --git a/web/app/upload/page.tsx b/web/app/upload/page.tsx @@ -1,21 +1,26 @@ "use client"; import { useState } from "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 +import { + Upload, Save, Activity, Droplets, Thermometer, Wind, Search, + Sprout, Calendar, BarChart3, ArrowRight, Brain, ShieldCheck, + CheckCircle, AlertTriangle +} from "lucide-react"; +import { SensorData, SearchResult, AgentDecision } from "@/models"; // Ensure AgentDecision is exported in models +import { IngestService } from "@/services/api"; export default function UnifiedPage() { const [file, setFile] = useState<File | null>(null); const [preview, setPreview] = useState<string | null>(null); - // Separate loading states to show which button is working const [loadingIngest, setLoadingIngest] = useState(false); const [loadingSearch, setLoadingSearch] = useState(false); const [searchResults, setSearchResults] = useState<SearchResult[]>([]); + + // 🧠 State for the Supervisor's Output + const [decision, setDecision] = useState<AgentDecision | null>(null); - // Sensor State const [sensors, setSensors] = useState<SensorData>({ pH: "6.0", EC: "1.2", @@ -32,7 +37,8 @@ export default function UnifiedPage() { const selected = e.target.files[0]; setFile(selected); setPreview(URL.createObjectURL(selected)); - setSearchResults([]); // Clear old results on new file + setSearchResults([]); + setDecision(null); // Clear old reasoning when new image is picked } }; @@ -43,15 +49,12 @@ export default function UnifiedPage() { 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?"); + console.error(error); + alert("āŒ Ingest Failed. Check console."); } finally { setLoadingIngest(false); } @@ -60,13 +63,24 @@ export default function UnifiedPage() { const handleSearch = async () => { if (!file) return alert("Please select an image to search with."); setLoadingSearch(true); + setDecision(null); // Clear previous decision while thinking... try { const response = await IngestService.searchFMU(file, sensors); - setSearchResults(response.results || []); - if (response.results.length === 0) alert("No similar memories found."); + + 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."); + } } catch (error) { - alert("āŒ Search Failed. Is the backend running?"); + console.error(error); + alert("āŒ Search Failed. Check console."); } finally { setLoadingSearch(false); } @@ -78,7 +92,7 @@ export default function UnifiedPage() { <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"> + <div className="max-w-6xl w-full grid grid-cols-1 lg:grid-cols-2 gap-12 mb-12"> {/* Left: Image Input */} <div className="space-y-6"> @@ -102,16 +116,14 @@ export default function UnifiedPage() { </div> </div> - {/* Right: Sensor Inputs & Actions */} + {/* Right: 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" }, @@ -135,7 +147,6 @@ export default function UnifiedPage() { ))} </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> @@ -145,7 +156,6 @@ export default function UnifiedPage() { </select> </div> - {/* --- Action Buttons --- */} <div className="grid grid-cols-2 gap-4 pt-4 border-t border-slate-800"> <button onClick={handleIngest} @@ -160,22 +170,82 @@ export default function UnifiedPage() { 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></>} + {loadingSearch ? <Activity className="animate-spin w-5 h-5" /> : <><Search className="w-5 h-5" /> <span>Search & Reason</span></>} </button> </div> </div> </div> - {/* Bottom Section: Search Results (Conditional Render) */} + {/* 🧠 SECTION: SUPERVISOR REASONING OUTPUT */} + {decision && ( + <div className="max-w-6xl w-full mb-12 animate-in fade-in slide-in-from-top-10 duration-700"> + <div className="bg-gradient-to-r from-indigo-900/40 to-slate-900/40 border border-indigo-500/30 p-8 rounded-3xl relative overflow-hidden"> + {/* Glowing Top Border */} + <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div> + + <div className="flex flex-col md:flex-row gap-8"> + {/* Icon Column */} + <div className="flex-shrink-0 flex flex-col items-center justify-center md:items-start space-y-2"> + <div className="w-16 h-16 bg-indigo-500/20 rounded-2xl flex items-center justify-center border border-indigo-500/30 shadow-[0_0_30px_rgba(99,102,241,0.2)]"> + <Brain className="w-8 h-8 text-indigo-300" /> + </div> + <span className="text-xs font-mono text-indigo-400 tracking-widest uppercase">Supervisor</span> + </div> + + {/* Content Column */} + <div className="flex-1 space-y-6"> + {/* Reasoning Text */} + <div className="space-y-2"> + <h3 className="text-xl font-bold text-white flex items-center gap-2"> + Analysis & Reasoning + </h3> + <p className="text-slate-300 leading-relaxed text-lg border-l-2 border-indigo-500/50 pl-4"> + {decision.reasoning} + </p> + </div> + + {/* Action & Confidence Row */} + <div className="flex flex-col md:flex-row gap-4"> + {/* Recommended Action */} + <div className="flex-1 bg-emerald-950/30 border border-emerald-500/30 p-4 rounded-xl flex items-center gap-4"> + <div className="p-2 bg-emerald-500/20 rounded-lg"> + <CheckCircle className="w-6 h-6 text-emerald-400" /> + </div> + <div> + <span className="text-xs text-emerald-500 uppercase font-bold tracking-wider">Recommended Action</span> + <p className="text-lg font-bold text-white">{decision.action}</p> + </div> + </div> + + {/* Confidence Score */} + <div className="bg-slate-900/50 border border-slate-700 p-4 rounded-xl flex items-center gap-4 min-w-[200px]"> + <div className="p-2 bg-slate-700/50 rounded-lg"> + <ShieldCheck className="w-6 h-6 text-blue-400" /> + </div> + <div> + <span className="text-xs text-slate-400 uppercase font-bold tracking-wider">Confidence</span> + <p className="text-lg font-bold text-white">{(decision.confidence * 100).toFixed(0)}%</p> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + )} + {/* 🧠 END REASONING SECTION */} + + + {/* Bottom Section: Search Results */} {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 + Retrieved Memory (Similar Cases) </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 + {searchResults.length} RECORDS </span> </div> @@ -202,7 +272,7 @@ export default function UnifiedPage() { <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} + pH: {res.payload.sensors?.pH} | EC: {res.payload.sensors?.EC} </span> </div> </div> diff --git a/web/models/index.ts b/web/models/index.ts @@ -1,3 +1,6 @@ +// src/models/index.ts (or wherever your types are) + +// 1. Keep SensorData and others as they are... export interface SensorData { pH: string; EC: string; @@ -7,31 +10,40 @@ export interface SensorData { stage: string; } -export interface IngestResponse { - status: "success" | "error"; - fmu_id?: string; - message?: string; +// 2. Add the new Decision Type +export interface AgentDecision { + reasoning: string; + action: string; + confidence: number; +} + +// 3. Update SearchResponse to match the new Backend output +export interface SearchResponse { + status: string; + // The backend now returns "search_results" instead of just "results" + search_results: SearchResult[]; + // The new AI Brain output + agent_decision?: AgentDecision; } -// New Types for Search +// 4. Ensure SearchResult matches what Qdrant sends export interface SearchResult { id: string; score: number; payload: { crop: string; stage: string; - timestamp: string; + timestamp?: string; sensors?: { pH: number; EC: number; - temp: number; - humidity: number; - } + }; + [key: string]: any; // Allow for other flexible fields }; } -export interface SearchResponse { - results: SearchResult[]; - status?: string; +export interface IngestResponse { + status: "success" | "error"; + fmu_id?: string message?: string; } \ No newline at end of file