demeter

Autonomous Hydroponic Intelligence
commit c25277957511740301fc711a3822277fc8bc82b1
parent faf1b2947d637aafd441920e8ea8395f6681e742
Author: Abhinav Rai <69450646+AbhinavRai01@users.noreply.github.com>
Date:   Thu,  5 Mar 2026 15:49:52 +0000

Merge PR

Diffstat:
Magent/sub_agents/Researcher.py | 5++---
Magent/sub_agents/Supervisor.py | 5+----
Magent/sub_agents/atmospheric_agent.py | 25+++++--------------------
Magent/sub_agents/base_agent.py | 10++++------
Magent/sub_agents/fetching_agent.py | 2--
Dagent/sub_agents/historian_agent.py | 0
Magent/sub_agents/judge_agent.py | 18++++++++----------
Magent/sub_agents/water_agent.py | 4++--
Magent/sub_agents/water_and_atmospheric_dependencies/physics_engine.py | 2+-
Magent/sub_agents/water_and_atmospheric_dependencies/retrieval.py | 2+-
Massets/images/Fetcher.png | 0
Mbackend/server/functions.py | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mbackend/server/rag_brain.py | 12++++--------
Mfrontend/src/pages/AgentControl.jsx | 208+++++++++++++++++++++++++++++++++++++++++++------------------------------------
14 files changed, 288 insertions(+), 162 deletions(-)

diff --git a/agent/sub_agents/Researcher.py b/agent/sub_agents/Researcher.py @@ -1,7 +1,7 @@ import uuid from qdrant_client import models from fastembed import TextEmbedding -from Qdrant.Client import client # Import your existing cloud connection +from Qdrant.Client import client from groq import Groq import os from dotenv import load_dotenv @@ -23,7 +23,7 @@ class ResearcherAgent: self.client.create_collection( collection_name=self.collection, vectors_config=models.VectorParams( - size=384, # bge-small uses 384 dimensions + size=384, distance=models.Distance.COSINE ) ) @@ -52,7 +52,6 @@ class ResearcherAgent: 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, diff --git a/agent/sub_agents/Supervisor.py b/agent/sub_agents/Supervisor.py @@ -63,7 +63,6 @@ API_KEY = os.environ.get("GROQ_API_KEY") class SupervisorAgent: def __init__(self, researcher_agent=None): self.name = "Supervisor" - # Bandit is now just an 'Advisor', not an enforcer self.bandit = ContextualBandit(n_actions=NUM_ACTIONS, feature_dim=519) if API_KEY: @@ -120,7 +119,6 @@ class SupervisorAgent: notes.extend(limits) # Tool 3: Physics Simulator - # (We reuse your existing prediction engine) sim_result = predict_outcome(plan, plan) # Comparing plan vs itself as a snapshot for now health = sim_result.get('predicted_health', 100) @@ -188,8 +186,7 @@ class SupervisorAgent: result = self.app.invoke(initial_state) final_targets = result.get("merged_plan", {}) - - # 🟢 NEW STEP: CONVERT TARGETS TO PHYSICAL ACTIONS + current_sensors = fmu.metadata.get('sensor_data', {}) print(f"[{self.name}] ⚙️ Converting Targets to Actuator Commands...") diff --git a/agent/sub_agents/atmospheric_agent.py b/agent/sub_agents/atmospheric_agent.py @@ -38,7 +38,6 @@ class AtmosphericAgent: def __init__(self): self.name = "Atmospheric Agent" - # 1. Initialize Model if not API_KEY: print(f"[{self.name}] ⚠️ No API Key found.") self.model = None @@ -49,9 +48,7 @@ class AtmosphericAgent: model="llama-3.3-70b-versatile", temperature=0.2 ) - - # 2. 🟢 BIND TOOLS (The "Arms") - # We bind the general research tools AND the specific math tool (VPD) + self.model_with_tools = llm.bind_tools([ ask_historian, ask_rag, @@ -60,35 +57,25 @@ class AtmosphericAgent: diagnose_plant, ask_memory ]) - - # 3. Build the Graph (The "Brain") + self.app = self._build_graph() def _build_graph(self): workflow = StateGraph(AgentState) - # --- A. ADD NODES --- - # 1. Decide: Uses the LLM with Tools bound to it workflow.add_node("decide", lambda state: decide_node(state, self.model_with_tools, ATMOS_PROMPT)) - - # 2. Tools: Executes the function if the LLM calls one + workflow.add_node("tools", execute_tools_node) - - # 3. Simulate: Checks physics/safety + workflow.add_node("simulate", simulate_node) - - # 4. Finalize: Formatting + workflow.add_node("finalize", finalize_node) - # --- B. DEFINE FLOW --- workflow.set_entry_point("decide") - # Logic 1: Decide -> (Tools OR Simulate) def check_decision_output(state): - # If the LLM decided to call a tool, go to tool execution if state.get("next_step") == "tools": return "tools" - # Otherwise, it wrote a plan, so go verify it return "simulate" workflow.add_conditional_edges( @@ -97,10 +84,8 @@ class AtmosphericAgent: {"tools": "tools", "simulate": "simulate"} ) - # Logic 2: Tools -> Back to Decide (ReAct Loop) workflow.add_edge("tools", "decide") - # Logic 3: Simulate -> (Finalize OR Retry) def check_simulation_result(state): if state["simulation_result"]["passed"]: return "finalize" diff --git a/agent/sub_agents/base_agent.py b/agent/sub_agents/base_agent.py @@ -1,20 +1,18 @@ import os from openai import OpenAI -from dotenv import load_dotenv # 🆕 Import this +from dotenv import load_dotenv -# 🆕 Load environment variables from .env file load_dotenv() # --- GROQ CONFIGURATION --- # Common Groq Models: "llama3-70b-8192", "mixtral-8x7b-32768" -MODEL_ID = "openai/gpt-oss-120b" +MODEL_ID = "llama-3.3-70b-versatile" API_KEY = os.environ.get("GROQ_API_KEY") class BaseReasoningAgent: def __init__(self, name): self.name = name - - # ⚡ Connect to Groq via OpenAI Client + if not API_KEY: print(f"[{self.name}] ⚠️ WARNING: GROQ_API_KEY not found in environment.") self.client = None @@ -43,7 +41,7 @@ class BaseReasoningAgent: {"role": "system", "content": f"You are the {self.name} Agent for a high-tech hydroponic farm."}, {"role": "user", "content": prompt} ], - temperature=0.6, # Slightly lower temp for more stable control decisions + temperature=0.6, max_tokens=1024 ) return response.choices[0].message.content diff --git a/agent/sub_agents/fetching_agent.py b/agent/sub_agents/fetching_agent.py @@ -3,11 +3,9 @@ import os import requests from pathlib import Path -# --- PATH FIX --- current_file = Path(__file__).resolve() project_root = current_file.parent.parent.parent sys.path.append(str(project_root)) -# ---------------- from qdrant_client import models from Sentinel.agent import FMUBuilder diff --git a/agent/sub_agents/historian_agent.py b/agent/sub_agents/historian_agent.py diff --git a/agent/sub_agents/judge_agent.py b/agent/sub_agents/judge_agent.py @@ -14,26 +14,25 @@ from agent.sub_agents.base_agent import BaseReasoningAgent from Qdrant.Client import client from Qdrant.Store import COLLECTION_NAME -# 🟢 IMPORT ONLY THE REQUESTED TOOLS from agent.sub_agents.water_and_atmospheric_dependencies.retrieval import diagnose_plant, ask_memory # --- STATE DEFINITION --- class JudgeState(TypedDict): # Inputs - current_fmu: Any # The 'After' State (Sequence N) + current_fmu: Any # Internal Context - prev_point: Any # The 'Before' State (Sequence N-1) + prev_point: Any crop_id: str # Forensic Evidence - visual_report: Dict # Output from diagnose_plant - biography: Any # Output from ask_memory + visual_report: Dict + biography: Any # Verdict - reward: float # -1.0 to 1.0 - outcome: str # "IMPROVED", "DETERIORATED", "STABLE" - explanation: str # Reasoning + reward: float + outcome: str + explanation: str # Output training_data: Optional[Dict] @@ -141,8 +140,7 @@ class JudgeAgent(BaseReasoningAgent): with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as temp: temp.write(base64.b64decode(image_b64)) temp_path = temp.name - - # 🟢 Invoke diagnose_plant + print(f" -> Invoking Tool: diagnose_plant") visual_data = diagnose_plant.invoke({"image_path": temp_path}) diff --git a/agent/sub_agents/water_agent.py b/agent/sub_agents/water_agent.py @@ -48,11 +48,11 @@ class WaterAgent: temperature=0.2 ) - # 2. 🟢 BIND TOOLS (The "Arms") + # 2. BIND TOOLS (The "Arms") # We bind the general research tools AND the specific math tool (pH Safety) self.model_with_tools = llm.bind_tools([ ask_historian, - ask_rag, + # ask_rag, web_search, check_ph_safety, diagnose_plant, diff --git a/agent/sub_agents/water_and_atmospheric_dependencies/physics_engine.py b/agent/sub_agents/water_and_atmospheric_dependencies/physics_engine.py @@ -4,7 +4,7 @@ from langchain_openai import ChatOpenAI from langchain_core.messages import SystemMessage, HumanMessage # Configuration -API_KEY = os.environ.get("GROQ_API_KEY") +API_KEY = os.environ.get("GROQ_API_KEY1") MODEL_ID = "llama-3.3-70b-versatile" # Using the latest supported Groq model def predict_outcome(current_state: dict, proposed_action: dict) -> dict: diff --git a/agent/sub_agents/water_and_atmospheric_dependencies/retrieval.py b/agent/sub_agents/water_and_atmospheric_dependencies/retrieval.py @@ -18,7 +18,7 @@ farm_memory = FarmMemory() # Initialize Qdrant for the Historian qdrant_client = QdrantClient( url=os.environ.get("QDRANT_URL", "http://localhost:6333"), - api_key=os.environ.get("QDRANT_API_KEY"), + api_key=os.environ.get("QDRANT_API_KEY1"), ) doctor = VisionAgent() diff --git a/assets/images/Fetcher.png b/assets/images/Fetcher.png Binary files differ. diff --git a/backend/server/functions.py b/backend/server/functions.py @@ -3,8 +3,11 @@ import shutil import json import traceback from fastapi import UploadFile +from groq import Groq from qdrant_client.http import models from datetime import datetime +import re +from langchain_core.messages import SystemMessage, HumanMessage # --- AGENT IMPORTS --- from agent.sub_agents.Researcher import ResearcherAgent @@ -146,7 +149,6 @@ async def process_search(file: UploadFile, sensors_str: str, builder): print(f"⚠️ Judge Error (Non-Critical): {e}") # --- 3. STRATEGY (STATIC) --- - # 🔴 CHANGED: Hardcoded Standard Strategy instead of Bandit strat_name = "STANDARD_MAINTENANCE" strat_instr = "Maintain optimal crop-specific parameters. Ensure homeostasis." action_idx = 0 # Dummy ID @@ -233,7 +235,6 @@ async def process_search(file: UploadFile, sensors_str: str, builder): "strategy": strat_name, "agent_decision": final_decision_json, "explanation": explanation_log, - # 🟢 FIX: Include 'payload' here so the frontend can read 'crop' "search_results": [{"id": h.id, "score": h.score, "payload": h.payload} for h in points_list] } @@ -246,19 +247,72 @@ async def process_search(file: UploadFile, sensors_str: str, builder): if os.path.exists(temp_filename): os.remove(temp_filename) +def extract_json(text): + """ + Robustly extracts the first valid JSON object from a text string, + ignoring conversational fluff or markdown blocks. + """ + try: + # 1. Try finding content inside ```json ... ``` + match = re.search(r"```json\s*(\{.*?\})\s*```", text, re.DOTALL) + if match: + return json.loads(match.group(1)) + + # 2. Try finding content inside plain ``` ... ``` + match = re.search(r"```\s*(\{.*?\})\s*```", text, re.DOTALL) + if match: + return json.loads(match.group(1)) + + # 3. Fallback: Find the first outermost { ... } + match = re.search(r"(\{.*\})", text, re.DOTALL) + if match: + return json.loads(match.group(1)) + + except Exception: + pass + return {} + async def process_text_query(text: str): + try: - from langchain_core.messages import SystemMessage, HumanMessage + # 🟢 1. STRICT SYSTEM PROMPT + # We explicitly tell the LLM the EXACT schema we need ("must": [{"key": "...", "match": "..."}]) + system_prompt = """ + You are a Database Translator. Convert the user's natural language query into a strict JSON filter for Qdrant. + + TARGET SCHEMA: + { + "must": [ + { "key": "crop", "match": "lettuce" }, + { "key": "stage", "match": "vegetative" } + ] + } + + RULES: + 1. Output ONLY valid JSON. No conversational text. + 2. Use the key "must" for the list of conditions. + 3. Field names in payload are usually: "crop", "crop_id", "stage", "outcome". + 4. If the user asks for everything, return { "must": [] }. + """ + + print(f"🗣️ User Query: {text}") - system_prompt = "You are a Database Translator. Convert natural language to JSON filters..." response = supervisor.model.invoke([ SystemMessage(content=system_prompt), HumanMessage(content=text) ]) + + # 🟢 2. ROBUST PARSING + # Instead of simple replace(), we use the regex extractor + filter_logic = extract_json(response.content) - content = response.content.replace("```json", "").replace("```", "").strip() - filter_logic = json.loads(content) - + if not filter_logic: + print(f"⚠️ Failed to parse JSON from: {response.content}") + return {"status": "error", "message": "Could not understand query structure."} + + print(f"⚙️ Parsed Logic: {filter_logic}") + + # 🟢 3. CONSTRUCT QDRANT FILTER conditions = [] for item in filter_logic.get("must", []): conditions.append( @@ -268,6 +322,7 @@ async def process_text_query(text: str): ) ) + # 🟢 4. EXECUTE SEARCH if conditions: scroll_filter = models.Filter(must=conditions) results = client.scroll( @@ -277,17 +332,98 @@ async def process_text_query(text: str): with_payload=True ) else: - results = client.scroll(collection_name=COLLECTION_NAME, limit=10, with_payload=True) + # If no conditions, return the latest 10 items + results = client.scroll( + collection_name=COLLECTION_NAME, + limit=10, + with_payload=True + ) points = results[0] + return { "status": "success", "results": [{"id": p.id, "payload": p.payload} for p in points] } except Exception as e: - print(f"Query Parse Error: {e}") + print(f"❌ Query Error: {e}") return {"status": "error", "message": str(e)} +groq_client = Groq(api_key=os.environ.get("GROQ_API_KEY")) + async def process_audio_search(file: UploadFile): - return {"status": "error", "message": "Audio search temporarily disabled."} -\ No newline at end of file + """ + 1. Transcribe Audio (Whisper-Large-V3) -> Text + 2. Run Text Search (via existing process_text_query) + """ + temp_filename = f"temp_audio_{file.filename}" + + # Save audio temporarily + try: + with open(temp_filename, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + print("🎙️ Transcribing audio (Multilingual)...") + + # Open file in binary read mode + with open(temp_filename, "rb") as audio_file: + transcription = groq_client.audio.transcriptions.create( + file=audio_file, + model="whisper-large-v3", # Multilingual model + response_format="json", + prompt="The audio may contain English or Hindi technical terms about farming." + ) + + detected_text = transcription.text + print(f"📝 Heard: '{detected_text}'") + + # This ensures we get the same RAG/Qdrant logic as text queries + response_data = await process_text_query(detected_text) + + # Inject the transcription so the UI can show what was heard + response_data["transcription"] = detected_text + + return response_data + + except Exception as e: + print(f"❌ Audio Search Error: {e}") + return {"status": "error", "message": str(e)} + + finally: + # Cleanup temp file + if os.path.exists(temp_filename): + os.remove(temp_filename) + + +async def parse_natural_language_query(query_text: str): + """ + Uses the Supervisor (LangChain) to convert text into Qdrant filters. + """ + system_prompt = """ + You are a Database Translator. + Your goal: Convert natural language queries (English, Hindi, Hinglish, etc.) 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") + + RULES: + 1. TRANSLATION: Map "Tamatar" -> "Tomato", "Kharab" -> "Negative", "Badhiya" -> "Positive". + 2. OUTPUT SCHEMA: { "must": [ {"key": "field", "match": "value"} ] } + 3. If no filters apply, return { "must": [] }. + """ + + try: + # instead of raw .chat.completions.create + response = supervisor.model.invoke([ + SystemMessage(content=system_prompt), + HumanMessage(content=query_text) + ]) + + return extract_json(response.content) + + except Exception as e: + print(f"❌ Query Parse Error: {e}") + return {"must": []} diff --git a/backend/server/rag_brain.py b/backend/server/rag_brain.py @@ -5,19 +5,16 @@ 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 +from Qdrant.Client import client # --- 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 +VECTOR_SIZE = 384 +DOCS_FOLDER = os.path.join(project_root, "Knowledge_Base") def init_collection(): """ @@ -65,7 +62,6 @@ def ingest_docs(): 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 @@ -112,7 +108,7 @@ def ingest_docs(): 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 + id=str(uuid.uuid4()), vector=vector.tolist(), payload={ "text": text_chunk, diff --git a/frontend/src/pages/AgentControl.jsx b/frontend/src/pages/AgentControl.jsx @@ -2,8 +2,7 @@ import React, { useRef, useState } from "react"; import { Link } from "react-router-dom"; import { Upload, Save, Activity, Droplets, Thermometer, Wind, Search, - Sprout, Calendar, BarChart3, ArrowRight, Brain, Mic, Square, - ArrowLeft, Leaf, Database, CheckCircle2, Fan, FlaskConical, Waves, Zap + Sprout, Calendar, ArrowLeft, Leaf, Database, Mic, Square, Zap, Fan, FlaskConical, Waves, Brain } from "lucide-react"; import { agentService } from "../api/agentApi"; @@ -26,7 +25,7 @@ export default function AgentControl() { // 🧠 State for the Supervisor's Output const [decision, setDecision] = useState(null); - const [strategy, setStrategy] = useState(""); // New state for Strategy + const [strategy, setStrategy] = useState(""); const [sensors, setSensors] = useState({ pH: "6.0", @@ -77,7 +76,6 @@ export default function AgentControl() { try { const response = await agentService.searchFMU(file, sensors); - // Update State with new JSON structure if (response.explanation) setExplanationText(response.explanation); if (response.strategy) setStrategy(response.strategy); if (response.agent_decision) setDecision(response.agent_decision); @@ -100,9 +98,10 @@ export default function AgentControl() { try { const data = await agentService.queryText(textQuery); if (data.results) { + // Map backend format to frontend expectation const mappedResults = data.results.map(r => ({ id: r.id, - score: 1.0, + score: r.score || 1.0, payload: r.payload })); setSearchResults(mappedResults); @@ -134,7 +133,6 @@ export default function AgentControl() { } }; - // ... (Keep Audio Handlers: startRecording, stopRecording, handleAudioUpload as is) ... const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); @@ -172,7 +170,7 @@ export default function AgentControl() { if (data.results) { const mappedResults = data.results.map(r => ({ id: r.id, - score: 1.0, + score: r.score || 1.0, payload: r.payload })); setSearchResults(mappedResults); @@ -185,6 +183,13 @@ export default function AgentControl() { } }; + // 🟢 HELPER: Safe Number Formatting + const formatMetric = (val) => { + if (val === undefined || val === null) return "-"; + const num = parseFloat(val); + return isNaN(num) ? val : num.toFixed(2); + }; + return ( <div className="min-h-screen bg-[#F4F9F6] font-sans text-gray-800 pb-20"> @@ -254,9 +259,9 @@ export default function AgentControl() { {/* RIGHT: Controls (Span 7) */} <div className="lg:col-span-7 space-y-6"> - - {/* Search Bar */} - <div className="bg-white p-2 rounded-2xl border border-gray-200 flex gap-2 shadow-sm focus-within:shadow-md transition-shadow"> + + {/* Search Bar */} + <div className="bg-white p-2 rounded-2xl border border-gray-200 flex gap-2 shadow-sm focus-within:shadow-md transition-shadow"> <button onClick={isRecording ? stopRecording : startRecording} className={`p-3 rounded-xl transition-all flex items-center justify-center ${ @@ -281,32 +286,32 @@ export default function AgentControl() { > Ask Agent </button> - </div> + </div> - {/* Sensor Inputs Panel */} - <div className="bg-white border border-gray-200 rounded-3xl p-8 space-y-6 shadow-sm"> - <div className="flex items-center justify-between"> + {/* Sensor Inputs Panel */} + <div className="bg-white border border-gray-200 rounded-3xl p-8 space-y-6 shadow-sm"> + <div className="flex items-center justify-between"> <h3 className="text-gray-900 font-bold flex items-center gap-2 text-lg"> <Activity className="w-5 h-5 text-emerald-500"/> Manual Parameters </h3> - </div> - - <div className="grid grid-cols-2 gap-5"> - {[ - { label: "pH Level", name: "pH", icon: Droplets, color: "text-emerald-600", bg: "bg-emerald-50", type: "number" }, - { label: "EC (mS/cm)", name: "EC", icon: Activity, color: "text-yellow-600", bg: "bg-yellow-50", type: "number" }, - { label: "Temp (°C)", name: "temp", icon: Thermometer, color: "text-red-600", bg: "bg-red-50", type: "number" }, - { label: "Humidity (%)", name: "humidity", icon: Wind, color: "text-blue-600", bg: "bg-blue-50", type: "number" }, - { label: "Crop", name: "crop", icon: Sprout, color: "text-green-600", bg: "bg-green-50", type: "select", options: ["Lettuce", "Tomato", "Cucumber", "Basil", "Spinach"] }, - { label: "Stage", name: "stage", icon: Calendar, color: "text-purple-600", bg: "bg-purple-50", type: "select", options: ["Seedling", "Vegetative", "Flowering", "Fruiting"] } - ].map((field) => ( - <div key={field.name} className="space-y-2 group"> - <label className={`text-[11px] font-bold uppercase tracking-wider ${field.color} ml-1`}>{field.label}</label> - <div className="relative"> - <div className={`absolute left-3 top-2.5 w-8 h-8 rounded-lg ${field.bg} flex items-center justify-center z-10`}> - <field.icon className={`w-4 h-4 ${field.color}`} /> - </div> - {field.type === "select" ? ( + </div> + + <div className="grid grid-cols-2 gap-5"> + {[ + { label: "pH Level", name: "pH", icon: Droplets, color: "text-emerald-600", bg: "bg-emerald-50", type: "number" }, + { label: "EC (mS/cm)", name: "EC", icon: Activity, color: "text-yellow-600", bg: "bg-yellow-50", type: "number" }, + { label: "Temp (°C)", name: "temp", icon: Thermometer, color: "text-red-600", bg: "bg-red-50", type: "number" }, + { label: "Humidity (%)", name: "humidity", icon: Wind, color: "text-blue-600", bg: "bg-blue-50", type: "number" }, + { label: "Crop", name: "crop", icon: Sprout, color: "text-green-600", bg: "bg-green-50", type: "select", options: ["Lettuce", "Tomato", "Cucumber", "Basil", "Spinach"] }, + { label: "Stage", name: "stage", icon: Calendar, color: "text-purple-600", bg: "bg-purple-50", type: "select", options: ["Seedling", "Vegetative", "Flowering", "Fruiting"] } + ].map((field) => ( + <div key={field.name} className="space-y-2 group"> + <label className={`text-[11px] font-bold uppercase tracking-wider ${field.color} ml-1`}>{field.label}</label> + <div className="relative"> + <div className={`absolute left-3 top-2.5 w-8 h-8 rounded-lg ${field.bg} flex items-center justify-center z-10`}> + <field.icon className={`w-4 h-4 ${field.color}`} /> + </div> + {field.type === "select" ? ( <select name={field.name} value={sensors[field.name]} @@ -317,7 +322,7 @@ export default function AgentControl() { <option key={opt} value={opt}>{opt}</option> ))} </select> - ) : ( + ) : ( <input name={field.name} value={sensors[field.name]} @@ -325,31 +330,31 @@ export default function AgentControl() { type="number" step="0.1" className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 pl-14 pr-4 focus:ring-2 focus:ring-emerald-500 focus:bg-white outline-none transition-all text-gray-800 font-bold" /> - )} - </div> - </div> - ))} - </div> + )} + </div> + </div> + ))} + </div> - {/* Action Buttons */} - <div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-100"> - <button - onClick={handleIngest} - disabled={loadingIngest || loadingSearch} - className="py-3.5 bg-gray-100 hover:bg-emerald-50 text-gray-600 hover:text-emerald-700 font-bold rounded-xl transition-all disabled:opacity-50 flex items-center justify-center space-x-2 group" - > - {loadingIngest ? <Activity className="animate-spin w-5 h-5" /> : <><Save className="w-5 h-5 text-gray-400 group-hover:text-emerald-500 transition-colors" /> <span>Store Memory</span></>} - </button> + {/* Action Buttons */} + <div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-100"> + <button + onClick={handleIngest} + disabled={loadingIngest || loadingSearch} + className="py-3.5 bg-gray-100 hover:bg-emerald-50 text-gray-600 hover:text-emerald-700 font-bold rounded-xl transition-all disabled:opacity-50 flex items-center justify-center space-x-2 group" + > + {loadingIngest ? <Activity className="animate-spin w-5 h-5" /> : <><Save className="w-5 h-5 text-gray-400 group-hover:text-emerald-500 transition-colors" /> <span>Store Memory</span></>} + </button> - <button - onClick={handleSearch} - disabled={loadingIngest || loadingSearch} - className="py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-all shadow-lg shadow-blue-500/20 disabled:opacity-50 flex items-center justify-center space-x-2 group" - > - {loadingSearch ? <Activity className="animate-spin w-5 h-5" /> : <><Search className="w-5 h-5" /> <span>Reason & Solve</span></>} - </button> - </div> - </div> + <button + onClick={handleSearch} + disabled={loadingIngest || loadingSearch} + className="py-3.5 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-all shadow-lg shadow-blue-500/20 disabled:opacity-50 flex items-center justify-center space-x-2 group" + > + {loadingSearch ? <Activity className="animate-spin w-5 h-5" /> : <><Search className="w-5 h-5" /> <span>Reason & Solve</span></>} + </button> + </div> + </div> </div> </div> @@ -413,7 +418,7 @@ export default function AgentControl() { </div> )} - {/* 2. Search Results Grid (Kept same) */} + {/* 2. Search Results Grid (Fixed for Sensors) */} {searchResults.length > 0 && ( <div className="animate-in fade-in slide-in-from-bottom-8 duration-700"> <div className="flex items-center justify-between mb-8"> @@ -427,50 +432,66 @@ export default function AgentControl() { </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-white border border-gray-200 rounded-2xl hover:border-blue-300 hover:shadow-xl hover:shadow-blue-500/10 transition-all group relative overflow-hidden flex flex-col"> - {/* Confidence Badge */} - <div className="absolute top-0 right-0 bg-blue-600 text-white text-[10px] font-bold px-3 py-1 rounded-bl-xl shadow-lg z-10"> - {(res.score * 100).toFixed(1)}% MATCH - </div> - - {/* Card Header */} - <div className="p-5 border-b border-gray-100 bg-gray-50/50"> - <h3 className="text-lg font-bold text-gray-900 flex items-center gap-2"> - <Sprout className="w-5 h-5 text-emerald-500" /> - {res.payload.crop} - </h3> - <span className="text-xs text-gray-400 font-bold uppercase tracking-wider mt-1 block"> - {res.payload.stage} Phase - </span> - </div> - - {/* Card Body */} - <div className="p-5 space-y-4 flex-1"> - <div className="flex items-center justify-between text-sm text-gray-500"> - <div className="flex items-center gap-2 font-medium"><Calendar className="w-4 h-4 text-gray-400" /> Date</div> - <span className="font-mono text-gray-700 bg-gray-100 px-2 py-0.5 rounded text-xs"> - {res.payload.timestamp ? new Date(res.payload.timestamp).toLocaleDateString() : 'N/A'} + {searchResults.map((res) => { + // 🟢 FIX: Handle both new 'sensors' key and legacy 'sensor_data' key + const sensors = res.payload.sensors || res.payload.sensor_data || {}; + + return ( + <div key={res.id} className="bg-white border border-gray-200 rounded-2xl hover:border-blue-300 hover:shadow-xl hover:shadow-blue-500/10 transition-all group relative overflow-hidden flex flex-col"> + {/* Confidence Badge */} + <div className="absolute top-0 right-0 bg-blue-600 text-white text-[10px] font-bold px-3 py-1 rounded-bl-xl shadow-lg z-10"> + {(res.score * 100).toFixed(1)}% MATCH + </div> + + {/* Card Header */} + <div className="p-5 border-b border-gray-100 bg-gray-50/50"> + <h3 className="text-lg font-bold text-gray-900 flex items-center gap-2"> + <Sprout className="w-5 h-5 text-emerald-500" /> + {res.payload.crop} + </h3> + <span className="text-xs text-gray-400 font-bold uppercase tracking-wider mt-1 block"> + {res.payload.stage} Phase </span> </div> - <div className="grid grid-cols-2 gap-2 mt-2"> - <div className="text-center p-2 rounded-lg bg-emerald-50 border border-emerald-100"> - <div className="text-[10px] text-emerald-600 font-bold uppercase">pH Level</div> - <div className="text-emerald-800 font-mono font-bold text-lg">{res.payload.sensor_data?.pH || '-'}</div> - </div> - <div className="text-center p-2 rounded-lg bg-yellow-50 border border-yellow-100"> - <div className="text-[10px] text-yellow-600 font-bold uppercase">EC Level</div> - <div className="text-yellow-800 font-mono font-bold text-lg">{res.payload.sensor_data?.EC || '-'}</div> - </div> + {/* Card Body */} + <div className="p-5 space-y-4 flex-1"> + <div className="flex items-center justify-between text-sm text-gray-500"> + <div className="flex items-center gap-2 font-medium"><Calendar className="w-4 h-4 text-gray-400" /> Date</div> + <span className="font-mono text-gray-700 bg-gray-100 px-2 py-0.5 rounded text-xs"> + {res.payload.timestamp ? new Date(res.payload.timestamp).toLocaleDateString() : 'N/A'} + </span> + </div> + + <div className="grid grid-cols-2 gap-2 mt-2"> + <div className="text-center p-2 rounded-lg bg-emerald-50 border border-emerald-100"> + <div className="text-[10px] text-emerald-600 font-bold uppercase">pH Level</div> + {/* 🟢 FIX: Use sensors.pH and format number */} + <div className="text-emerald-800 font-mono font-bold text-lg">{formatMetric(sensors.pH)}</div> + </div> + <div className="text-center p-2 rounded-lg bg-yellow-50 border border-yellow-100"> + <div className="text-[10px] text-yellow-600 font-bold uppercase">EC Level</div> + {/* 🟢 FIX: Use sensors.EC and format number */} + <div className="text-yellow-800 font-mono font-bold text-lg">{formatMetric(sensors.EC)}</div> + </div> + </div> + + {/* Optional Outcome Section */} + {res.payload.outcome && ( + <div className="mt-2 text-xs bg-gray-50 p-2 rounded border border-gray-100 text-gray-600 line-clamp-2"> + <span className="font-bold text-gray-400 uppercase text-[10px] block mb-1">Outcome Note:</span> + {/* Simple cleanup of outcome text */} + {res.payload.outcome.replace("condition_assessed", "").replace("|", " • ")} + </div> + )} </div> </div> - </div> - ))} + ); + })} </div> </div> )} </div> </div> ); -} -\ No newline at end of file +}