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:
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
+}