commit b0654e0c4a6c6568403ccdee0ffc6fe873153ca4
parent 1b89d08df121148fe294fde8e149e25ab1e9d2d5
Author: Abhinav Rai <69450646+AbhinavRai01@users.noreply.github.com>
Date: Thu, 5 Mar 2026 07:23:04 +0000
Merge PR
Diffstat:
11 files changed, 815 insertions(+), 763 deletions(-)
diff --git a/agent/Marl/bandit.py b/agent/Marl/bandit.py
@@ -5,7 +5,7 @@ import pickle
import os
class ContextualBandit:
- def __init__(self, n_actions=15, feature_dim=515):
+ def __init__(self, n_actions=15, feature_dim=519):
"""
LinGreedy Implementation (Pure Exploitation).
We removed 'alpha' because we do not want to explore.
diff --git a/agent/memory.py b/agent/memory.py
@@ -51,7 +51,7 @@ class FarmMemory:
try:
# Check if collection exists
client.get_collection(collection_name)
- print(f"ā
Collection '{collection_name}' already exists")
+ # print(f"ā
Collection '{collection_name}' already exists")
except Exception:
# Create collection with 384 dimensions
print(f"š Creating collection '{collection_name}' with 384 dimensions...")
@@ -66,71 +66,36 @@ class FarmMemory:
def get_plant_history(self, crop_id):
"""Retrieve the complete biographical history of a plant"""
- history = self.memory.search(
- query=f"What is the health history and past treatments for {crop_id}?",
- user_id=crop_id
- )
-
- # Debug: Print the structure to see what we got
- print(f"š Debug - History type: {type(history)}")
- # print(f"š Debug - History content: {history}")
-
- if not history:
- return "No prior biographical records for this plant."
-
- # Handle different possible response structures
try:
- # If history is a dict with 'results' key
+ history = self.memory.search(
+ query=f"What is the health history and past treatments for {crop_id}?",
+ user_id=crop_id
+ )
+
+ if not history:
+ return "No prior biographical records for this plant."
+
+ # Handle different possible response structures from mem0
if isinstance(history, dict) and 'results' in history:
results = history['results']
- formatted_history = "\n".join([f"- {item['memory']}" for item in results])
- # If history is already a list
+ return "\n".join([f"- {item['memory']}" for item in results])
elif isinstance(history, list):
- # Each item might be a dict or a string
formatted_lines = []
for item in history:
- if isinstance(item, dict):
- # Try different possible keys
- text = item.get('memory') or item.get('text') or item.get('content') or str(item)
- else:
- text = str(item)
+ text = item.get('memory', str(item)) if isinstance(item, dict) else str(item)
formatted_lines.append(f"- {text}")
- formatted_history = "\n".join(formatted_lines)
- # If it's a string (single result)
- elif isinstance(history, str):
- formatted_history = f"- {history}"
- else:
- formatted_history = str(history)
+ return "\n".join(formatted_lines)
- return formatted_history
+ return str(history)
except Exception as e:
print(f"ā ļø Error formatting history: {e}")
- return f"Error retrieving history: {str(e)}\nRaw data: {history}"
+ return f"Error retrieving history: {str(e)}"
def log_event(self, crop_id, event_text):
"""Log a new event in the plant's biography"""
- result = self.memory.add(event_text, user_id=crop_id)
- # print(f"š§ Biography Updated for {crop_id}")
- # print(f"š Add result: {result}")
-
-if __name__ == "__main__":
- print("š Running Quick Memory Check...")
-
- # 1. Initialize
- mem = FarmMemory()
- test_id = "Debug_Plant_001"
-
- # 2. Write
- print(f"\nš Writing memory for {test_id}...")
- mem.log_event(test_id, "DIAGNOSIS: Plant shows signs of severe Nitrogen deficiency. Leaves are yellowing at the bottom.")
-
- # 3. Read
- print(f"\nš Reading back memory...")
- history = mem.get_plant_history(test_id)
-
- print("\n" + "="*50)
- print("--- PLANT BIOGRAPHY ---")
- print("="*50)
- print(history)
- print("="*50 + "\n")
-\ No newline at end of file
+ try:
+ self.memory.add(event_text, user_id=crop_id)
+ print(f"š§ Biography Updated for {crop_id}")
+ except Exception as e:
+ print(f"ā Memory Write Error: {e}")
+\ No newline at end of file
diff --git a/agent/sub_agents/Doctor.py b/agent/sub_agents/Doctor.py
@@ -3,6 +3,7 @@ import cv2
import json
import os
import logging
+import numpy as np
# Setup basic logging
logging.basicConfig(level=logging.INFO)
@@ -12,37 +13,28 @@ class VisionAgent:
def __init__(self, model_path=None):
logger.info("šļø Initializing Vision Agent (Doctor)...")
- # 1. Find the project root (directory containing "agent" folder)
+ # 1. Find the project root
if model_path:
default_model = model_path
else:
- # Get the directory where THIS file (Doctor.py) is located
current_file = os.path.abspath(__file__)
- # Navigate up to agent/sub_agents/Doctor.py -> agent/
- agent_dir = os.path.dirname(os.path.dirname(current_file))
- # Now go to agent/model/plant_disease_model.pt
+ agent_dir = os.path.dirname(os.path.dirname(current_file)) # agent/
default_model = os.path.join(agent_dir, "model", "plant_disease_model.pt")
self.model_name = default_model
# 2. Load Model with Fallback
try:
- # Check if file exists
if os.path.exists(self.model_name):
logger.info(f"ā
Found plant disease model at: {self.model_name}")
self.model = YOLO(self.model_name)
- logger.info(f"ā
Loaded Custom Plant Doctor")
else:
- # Debug info
- logger.warning(f"ā ļø Model not found at: {self.model_name}")
- logger.warning(f" Looking in: {os.path.dirname(self.model_name)}")
- logger.info("š„ Using generic YOLOv8n instead...")
+ logger.warning(f"ā ļø Custom model not found. Using generic YOLOv8n.")
self.model = YOLO("yolov8n.pt")
self.model_name = "yolov8n.pt"
- # CPU Optimization for Laptop
+ # Optimization
self.model.to('cpu')
- logger.info("ā
Vision Agent ready")
except Exception as e:
logger.error(f"ā Critical Error loading model: {e}")
@@ -66,7 +58,6 @@ class VisionAgent:
detections = []
summary_counts = {}
- # 4. Process Detections
for box in result.boxes:
class_id = int(box.cls[0])
label = self.model.names[class_id]
@@ -77,24 +68,30 @@ class VisionAgent:
"confidence": round(confidence, 2),
"box": [round(x, 2) for x in box.xywhn[0].tolist()]
})
-
summary_counts[label] = summary_counts.get(label, 0) + 1
- # 5. Smart Health Logic
+ # 4. Health Logic
health_status = "HEALTHY"
visual_alert = False
if not detections:
- health_status = "NO_PLANTS_DETECTED"
+ # If generic model, it might just see nothing.
+ # If disease model, empty usually means healthy.
+ if "yolov8n" in self.model_name:
+ health_status = "NO_OBJECTS_DETECTED"
+ else:
+ health_status = "HEALTHY"
else:
for label in summary_counts:
label_lower = label.lower()
- if "healthy" not in label_lower and any(x in label_lower for x in ['spot', 'rot', 'blight', 'mildew', 'rust', 'virus', 'miner', 'mite']):
+ # Keywords that imply sickness
+ sick_keywords = ['spot', 'rot', 'blight', 'mildew', 'rust', 'virus', 'miner', 'mite', 'wilt']
+ if any(x in label_lower for x in sick_keywords) and "healthy" not in label_lower:
health_status = "DISEASE_DETECTED"
visual_alert = True
break
- report = {
+ return {
"status": "Success",
"model_used": self.model_name,
"health_assessment": health_status,
@@ -103,26 +100,6 @@ class VisionAgent:
"detailed_detections": detections
}
- return report
-
except Exception as e:
logger.error(f"Error during analysis: {e}")
- return {"error": str(e)}
-
-# --- Quick Test Block ---
-if __name__ == "__main__":
- agent = VisionAgent()
-
- test_path = "test_plant.jpg"
-
- if not os.path.exists(test_path):
- import numpy as np
- print("ā ļø Creating dummy test image...")
- dummy_img = np.zeros((640, 640, 3), dtype=np.uint8)
- dummy_img[:] = (0, 255, 0)
- cv2.rectangle(dummy_img, (100, 100), (200, 200), (0, 0, 255), -1)
- cv2.imwrite(test_path, dummy_img)
-
- print("\n--- ANALYSIS REPORT ---")
- report = agent.analyze_frame(test_path)
- print(json.dumps(report, indent=2))
-\ No newline at end of file
+ return {"error": str(e)}
+\ No newline at end of file
diff --git a/agent/sub_agents/Supervisor.py b/agent/sub_agents/Supervisor.py
@@ -1,243 +1,237 @@
+import os
import json
import numpy as np
-import os
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from langgraph.graph import StateGraph, END
+from agent.tools.actuation import convert_targets_to_actions
-# 1. Internal Engines
from agent.Marl.bandit import ContextualBandit
from agent.Marl.strategies import STRATEGIES, NUM_ACTIONS
-from agent.memory import FarmMemory
-
-# š¢ NEW: Import the Doctor
-from agent.sub_agents.Doctor import VisionAgent
+from agent.Qdrant.Store import store_fmu
+from agent.sub_agents.water_and_atmospheric_dependencies.physics_engine import predict_outcome
+
+# --- NEW TOOLS DEFINITION ---
+def check_cross_domain_conflicts(atmos, water):
+ conflicts = []
+
+ # 1. Thermal Shock Check
+ air_t = atmos.get('air_temp', 25)
+ water_t = water.get('water_temp', 20)
+ if abs(air_t - water_t) > 10:
+ conflicts.append(f"CRITICAL: Thermal Shock Risk. Air ({air_t}C) and Water ({water_t}C) delta > 10C.")
+
+ # 2. Transpiration vs Uptake Check
+ # High VPD (Dry) + High EC (Salty) = Burn Risk
+ rh = atmos.get('humidity', 60)
+ ec = water.get('ec', 1.0)
+ if rh < 50 and ec > 2.0:
+ conflicts.append(f"STRESS: Low Humidity ({rh}%) + High EC ({ec}) will cause Tip Burn.")
+
+ return conflicts
+
+def validate_hard_limits(plan):
+ violations = []
+ # Hard limits for Lettuce/General Hydroponics
+ if plan.get('ph', 6.0) < 5.0: violations.append("pH < 5.0 is toxic.")
+ if plan.get('ph', 6.0) > 7.5: violations.append("pH > 7.5 causes lockout.")
+ if plan.get('ec', 1.0) > 3.0: violations.append("EC > 3.0 is too high for lettuce.")
+ if plan.get('humidity', 60) > 85: violations.append("Humidity > 85% guarantees mold.")
+
+ return violations
+
+# --- STATE DEFINITION ---
+from typing import TypedDict, Optional, Dict, Any, List
+
+class SupervisorState(TypedDict):
+ # Inputs
+ atmos_plan: Dict[str, Any]
+ water_plan: Dict[str, Any]
+ strategy_advice: str # Kept as advice, not law
+
+ # Processing
+ merged_plan: Dict[str, Any]
+ review_notes: List[str]
+ simulation_health: float
+
+ # Output
+ final_decision: str # "APPROVE" or "REJECT"
+ critique: str # Feedback for sub-agents if Rejected
+
+API_KEY = os.environ.get("GROQ_API_KEY")
class SupervisorAgent:
- def __init__(self, llm_client):
- self.llm = llm_client
+ 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)
- # Initialize the Team
- self.bandit = ContextualBandit(n_actions=NUM_ACTIONS, feature_dim=515)
- self.bio_memory = FarmMemory()
+ if API_KEY:
+ self.model = ChatOpenAI(
+ base_url="https://api.groq.com/openai/v1",
+ api_key=API_KEY,
+ model="llama-3.3-70b-versatile",
+ temperature=0.0 # Zero temp for strict judging
+ )
- # š¢ NEW: Initialize the Doctor (Eyes)
- self.doctor = VisionAgent()
+ self.app = self._build_graph()
- # Load saved bandit brain if it exists
- self.model_path = os.path.join(os.path.dirname(__file__), '../Marl/saved_bandit_state.pkl')
- # self.bandit.load(self.model_path)
+ def _build_graph(self):
+ workflow = StateGraph(SupervisorState)
- def _report_to_vector(self, doctor_report):
- """
- š¢ NEW: Converts the Doctor's JSON report into the 512-dim vector.
- Uses semantic hashing to map specific diseases to specific neurons.
- """
- vis_vec = np.zeros(512)
-
- if "detailed_detections" in doctor_report:
- for detection in doctor_report["detailed_detections"]:
- label = detection["object"]
- confidence = detection["confidence"]
-
- # Hash the label name to an index between 0-511
- idx = hash(label) % 512
- vis_vec[idx] += confidence
-
- return np.clip(vis_vec, 0, 1.0)
-
- def _build_context(self, visual_vector, sensors):
- """
- š¢ UPDATED: Fuses Vision (512) + 3
- """
- # Raw Sensor Values
- ph = sensors.get('pH', 6.0)
- ec = sensors.get('EC', 1.0)
- temp = sensors.get('temp', 25.0)
-
- # 1. Normalize Raw (Direction)
- raw_ph = (ph - 6.0) / 2.0
- raw_ec = (ec - 1.0) / 3.0
- raw_temp = (temp - 25.0) / 40.0
-
- sensor_features = np.array([
- raw_ph, raw_ec, raw_temp,
- ])
+ # 1. Merge: Combine the two JSONs
+ workflow.add_node("merge", self.node_merge)
- return np.concatenate([visual_vector, sensor_features])
-
- def _get_strategy_instruction(self, strategy_name):
- """Translates Math Strategy -> Natural Language Orders"""
- instructions = {
- "MAINTAIN_CURRENT": "Do NOT recommend changes. System is stable.",
- "CALIBRATE_SENSORS": "Sensor readings are anomalous. Recommend hardware calibration.",
- "AGGRESSIVE_PH_DOWN": "Priority: LOWER pH rapidly. Recommend strong acid buffers.",
- "AGGRESSIVE_PH_UP": "Priority: RAISE pH rapidly. Recommend strong base buffers.",
- "GENTLE_PH_BALANCING": "pH is drifting. Recommend gentle adjustments only.",
- "INCREASE_EC_VEG": "Plant needs NITROGEN for vegetative growth.",
- "INCREASE_EC_BLOOM": "Plant needs PHOSPHORUS/POTASSIUM for flowering.",
- "LOWER_EC_FLUSH": "Nutrient burn detected. Recommend flushing reservoir.",
- "CALMAG_BOOST": "Calcium/Magnesium deficiency detected. Recommend CalMag supplement.",
- "RAISE_TEMP_HUMIDITY": "Environment too cold/dry. Recommend heating/humidifying.",
- "LOWER_TEMP_HUMIDITY": "Mold risk high. Recommend fans and dehumidifiers.",
- "MAX_AIR_CIRCULATION": "Stagnant air. Recommend max fan speed.",
- "FUNGAL_TREATMENT": "Fungal risk. Recommend fungicide and lower humidity.",
- "PEST_ISOLATION": "Pests detected. Recommend isolation and organic pesticide.",
- "PRUNE_NECROTIC_LEAVES": "Necrosis detected. Recommend pruning dead matter."
- }
- return instructions.get(strategy_name, "Follow standard procedures.")
+ # 2. Review: Run the 3 Tools (Conflicts, Limits, Physics)
+ workflow.add_node("review", self.node_review)
+
+ # 3. Judge: LLM decides if the issues are fatal
+ workflow.add_node("judge", self.node_judge)
+
+ # Flow
+ workflow.set_entry_point("merge")
+ workflow.add_edge("merge", "review")
+ workflow.add_edge("review", "judge")
+ workflow.add_edge("judge", END)
+
+ return workflow.compile()
- def reason(self, current_fmu, similar_fmus, sub_agent_outputs):
+ # --- NODE FUNCTIONS ---
+
+ def node_merge(self, state):
+ print(" š Supervisor Merging Plans...")
+ # Simple dictionary merge
+ merged = {**state['atmos_plan'], **state['water_plan']}
+ return {"merged_plan": merged}
+
+ def node_review(self, state):
+ print(" š Supervisor Running Unit Tests...")
+ plan = state['merged_plan']
+ notes = []
+
+ # Tool 1: Conflict Check
+ conflicts = check_cross_domain_conflicts(state['atmos_plan'], state['water_plan'])
+ if conflicts:
+ notes.extend(conflicts)
+
+ # Tool 2: Limit Check
+ limits = validate_hard_limits(plan)
+ if limits:
+ 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)
+
+ if health < 90:
+ notes.append(f"SIMULATION FAIL: Predicted health drops to {health}%. Risk: {sim_result.get('risk_warning')}")
+
+ return {"review_notes": notes, "simulation_health": health}
+
+ def node_judge(self, state):
"""
- The Core Logic: Synthesizes Bandit (Math), Specialists (Science),
- Qdrant (History), mem0 (Biography), AND Doctor (Vision).
+ The LLM looks at the automated test results and makes the final call.
"""
- payload = current_fmu['payload']
- sensors = payload['sensors']
-
- # š¢ NEW: Extract Image Path
- image_path = payload.get('image_path', None)
- crop_id = payload.get('crop_id', 'General_Zone_1')
-
- # ---------------------------------------------------------
- # 0. š¢ THE DOCTOR (Vision Analysis)
- # ---------------------------------------------------------
- visual_report = {"scan_summary": "No Image Provided", "detailed_detections": []}
-
- if image_path and os.path.exists(image_path):
- print(f"š Doctor Analyzing: {image_path}")
- visual_report = self.doctor.analyze_frame(image_path)
- print(f"š Visual Report: {visual_report.get('scan_summary')}")
-
- # Convert report to vector for the Bandit
- visual_vector = self._report_to_vector(visual_report)
-
- # ---------------------------------------------------------
- # 1. š¢ THE GENERAL (Bandit RL)
- # ---------------------------------------------------------
- # Build 515-dim context (Vision + Advanced Sensors)
- context_vector = self._build_context(visual_vector, sensors)
-
- action_idx, debug_info = self.bandit.select_action(context_vector)
- strategic_intent = STRATEGIES[action_idx]
- specific_order = self._get_strategy_instruction(strategic_intent)
-
- print(f"š° Bandit Order: {strategic_intent} (Score: {debug_info['scores'][action_idx]:.2f})")
-
- # ---------------------------------------------------------
- # 2. š¢ THE EXPERTS (Mini-Agents)
- # ---------------------------------------------------------
- if "NUTRIENT" in strategic_intent or "PH" in strategic_intent or "EC" in strategic_intent:
- highlighted_report = sub_agent_outputs.get("nutrient_report", "No Report")
- focus_area = "NUTRIENT SPECIALIST"
- elif "TEMP" in strategic_intent or "HUMIDITY" in strategic_intent:
- highlighted_report = sub_agent_outputs.get("atmosphere_report", "No Report")
- focus_area = "ATMOSPHERE SPECIALIST"
- elif "PEST" in strategic_intent or "FUNGAL" in strategic_intent or "PRUNE" in strategic_intent:
- # š¢ UPDATED: Use the Doctor's report for bio-threats
- highlighted_report = f"Visual Diagnosis: {visual_report.get('scan_summary', 'None')}"
- focus_area = "PLANT DOCTOR"
- else:
- highlighted_report = "Standard operational check."
- focus_area = "ALL SECTORS"
-
- # ---------------------------------------------------------
- # 3. š¢ THE HISTORIAN (Qdrant / RAG)
- # ---------------------------------------------------------
- history_context = "No relevant global precedents found."
- if similar_fmus and len(similar_fmus) > 0:
- history_lines = []
- for i, fmu in enumerate(similar_fmus):
- past_action = fmu['payload'].get('action_taken', 'Unknown')
- past_outcome = fmu['payload'].get('outcome', 'Unknown')
- score = fmu.get('score', 0.0)
- history_lines.append(f"- Global Case #{i+1} ({score:.0%} Match): Action '{past_action}' -> Result '{past_outcome}'")
- history_context = "\n".join(history_lines)
-
- # ---------------------------------------------------------
- # 4. š¢ THE BIOGRAPHER (mem0 / Entity Memory)
- # ---------------------------------------------------------
- plant_biography = self.bio_memory.get_plant_history(crop_id)
-
- # ---------------------------------------------------------
- # 5. š¢ THE COMMANDER (Supervisor LLM)
- # ---------------------------------------------------------
- system_prompt = f"""
- You are the Supervisor of a Hydroponic Farm.
-
- --- šØ INPUTS FROM YOUR TEAM šØ ---
-
- [1] INTELLIGENCE REPORT (From {focus_area}):
- "{highlighted_report}"
- *Use these facts to justify the decision.*
-
- [2] VISUAL DIAGNOSIS (From The Doctor):
- Summary: {json.dumps(visual_report.get('scan_summary'))}
- Detections: {json.dumps(visual_report.get('detailed_detections'))}
-
- [3] LIVE SENSORS:
- {json.dumps(sensors)}
-
- [4] GLOBAL PRECEDENT (Similar Past Situations):
- {history_context}
-
- [5] FULL CONTEXT:
- All Specialist Reports: {json.dumps(sub_agent_outputs)}
-
- [6] PATIENT BIOGRAPHY (Specific to {crop_id}):
- {plant_biography}
- *CRITICAL: If this specific plant has a history of sensitivity, adjust the plan.*
-
- [7] STRATEGIC ORDER (From RL):
- "{strategic_intent}" -> "{specific_order}"
- *This is your just one metric*
-
- --- šØ HIERARCHY OF TRUTH (CRITICAL) šØ ---
- 1. **LIVE SENSORS**: Absolute truth.
- 2. **VISUAL EVIDENCE**: Strong truth (The Doctor sees the plant and checks for sickness).
- 3. **BIOGRAPHY**: History (Past truth).
-
- --- YOUR TASK ---
- Generate a detailed action plan. Synthesize all the inputs.
-
- --- āļø STYLE GUIDELINES ---
- - **Plain English Only.**
- - **Tone:** Professional, decisive, and clear.
-
- RESPONSE FORMAT (JSON):
- {{
- "decision": "Brief, actionable summary",
- "reasoning": "Detailed explanation synthesizing Strategy + Visuals + History...",
- "visual_alert": true/false,
- "risk_matrix": {{ "nutrients": 0-10, "climate": 0-10, "visuals": 0-10, "history": 0-10 }}
- }}
+ print(" āļø Supervisor Judging...")
+
+ if not state['review_notes']:
+ # No issues found by tools
+ return {"final_decision": "APPROVE", "critique": "Plan looks solid."}
+
+ # If issues exist, ask LLM if they are fatal or acceptable trade-offs
+ prompt = f"""
+ You are the Quality Assurance Supervisor.
+
+ PROPOSED PLAN: {state['merged_plan']}
+
+ AUTOMATED TEST FAILURES:
+ {json.dumps(state['review_notes'], indent=2)}
+
+ ADVISORY STRATEGY: {state['strategy_advice']}
+
+ TASK:
+ 1. If the failures are dangerous (Toxic pH, Thermal Shock, Low Health), REJECT the plan.
+ 2. If the failures are minor or necessary for the Strategy (e.g., Low Humidity required for 'Fungal Treatment'), APPROVE it.
+
+ OUTPUT JSON: {{ "verdict": "APPROVE" or "REJECT", "critique": "Explanation..." }}
"""
try:
- response = self.llm.chat.completions.create(
- model="llama-3.1-8b-instant",
- messages=[
- {"role": "system", "content": system_prompt},
- {"role": "user", "content": f"Current Sensors: {json.dumps(sensors)}"}
- ],
- response_format={"type": "json_object"}
- )
- decision_json = json.loads(response.choices[0].message.content)
-
- # ---------------------------------------------------------
- # 6. š¢ CLOSE THE LOOP (Log to mem0)
- # ---------------------------------------------------------
- log_entry = f"Condition: {strategic_intent}. Visuals: {visual_report.get('scan_summary')}. Action: {decision_json['decision']}."
- self.bio_memory.log_event(crop_id, log_entry)
-
- # Attach Metadata for RL Training later
- decision_json["strategic_intent"] = strategic_intent
- decision_json["bandit_action_idx"] = int(action_idx)
- decision_json["visual_report"] = visual_report
+ response = self.model.invoke([HumanMessage(content=prompt)])
+ content = response.content.replace("```json", "").replace("```", "").strip()
+ result = json.loads(content)
- return decision_json
-
- except Exception as e:
return {
- "decision": f"Execute Standard Protocol: {strategic_intent}",
- "reasoning": f"LLM Generation Failed ({str(e)}). Defaulting to Bandit Strategy.",
- "strategic_intent": strategic_intent,
- "bandit_action_idx": int(action_idx)
+ "final_decision": result.get("verdict", "REJECT"),
+ "critique": result.get("critique", "Automated tests failed.")
}
+ except:
+ # Default to reject if unsafe
+ return {"final_decision": "REJECT", "critique": "Plan failed automated safety checks."}
+
+ # --- ENTRY POINT ---
+
+ def synthesize_plan(self, atmos_plan, water_plan, fmu, history, strategy_info):
+ strategy_name, _, action_idx = strategy_info
+
+ initial_state = {
+ "atmos_plan": atmos_plan,
+ "water_plan": water_plan,
+ "strategy_advice": strategy_name,
+ "merged_plan": {},
+ "review_notes": [],
+ "simulation_health": 0.0,
+ "final_decision": "",
+ "critique": ""
+ }
+
+ 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...")
+
+ # Calculate physical actions
+ physical_action_obj = convert_targets_to_actions(current_sensors, final_targets)
+
+ # Convert Pydantic model to Dict for JSON serialization
+ final_payload = physical_action_obj.dict()
+
+ # Log it
+ print(f"[{self.name}] š Activating Hardware: {final_payload}")
+
+ # Store in FMU
+ fmu.metadata["action_taken"] = str(final_payload)
+ fmu.metadata["bandit_action_id"] = action_idx
+ fmu.metadata["strategic_intent"] = strategy_name
+
+ if "image_b64" in fmu.metadata: del fmu.metadata["image_b64"]
+ store_fmu(fmu)
+
+ return final_payload
+
+ # --- ADVISORY ONLY (Not Enforced) ---
+ def get_strategic_goal(self, fmu):
+ # (Same as before, but treated as advice now)
+ sensors = fmu.metadata.get('sensor_data', {})
+ fmu_vector = fmu.vector
+ vis_vec1 = np.array(fmu_vector) if isinstance(fmu_vector, list) else fmu_vector
+ vis_vec = vis_vec1[:512] if len(vis_vec1) >= 512 else None
+ if vis_vec is None or len(vis_vec) == 0: vis_vec = np.zeros(516)
+
+ s_vec = np.array([
+ (float(sensors.get('pH', 6.0)) - 6.0) / 2.0,
+ float(sensors.get('EC', 1.0)) / 3.0,
+ float(sensors.get('temp', 25.0)) / 40.0
+ ])
+ context_vector = np.concatenate([vis_vec, s_vec])
+
+ print("Context Vector for Bandit:", context_vector.shape)
+
+ action_idx, _ = self.bandit.select_action(context_vector)
+ strategy_name = STRATEGIES[action_idx]
+
+ return strategy_name, "Advisory Only", int(action_idx)
+\ No newline at end of file
diff --git a/agent/sub_agents/atmospheric_agent.py b/agent/sub_agents/atmospheric_agent.py
@@ -7,7 +7,7 @@ from agent.sub_agents.water_and_atmospheric_dependencies.state import AgentState
from agent.sub_agents.water_and_atmospheric_dependencies.nodes import decide_node, simulate_node, finalize_node, execute_tools_node
# Tools
-from agent.sub_agents.water_and_atmospheric_dependencies.retrieval import ask_historian, ask_rag
+from agent.sub_agents.water_and_atmospheric_dependencies.retrieval import ask_historian, ask_rag, diagnose_plant, ask_memory
from agent.sub_agents.water_and_atmospheric_dependencies.tools import calculate_vpd, web_search
# Configuration
@@ -56,7 +56,9 @@ class AtmosphericAgent:
ask_historian,
ask_rag,
web_search,
- calculate_vpd
+ calculate_vpd,
+ diagnose_plant,
+ ask_memory
])
# 3. Build the Graph (The "Brain")
diff --git a/agent/sub_agents/judge_agent.py b/agent/sub_agents/judge_agent.py
@@ -1,99 +1,262 @@
import os
import json
+import base64
+import tempfile
+from typing import TypedDict, Dict, Any, Optional
+
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from langgraph.graph import StateGraph, END
from qdrant_client import models
+
from Sentinel.fmu import FMU
-from sub_agents.base_agent import BaseReasoningAgent
+from agent.sub_agents.base_agent import BaseReasoningAgent
from Qdrant.Client import client
-from Qdrant.Store import store_fmu
+from Qdrant.Store import COLLECTION_NAME
-COLLECTION_NAME = "Farm_Memory"
+# š¢ 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)
+
+ # Internal Context
+ prev_point: Any # The 'Before' State (Sequence N-1)
+ crop_id: str
+
+ # Forensic Evidence
+ visual_report: Dict # Output from diagnose_plant
+ biography: Any # Output from ask_memory
+
+ # Verdict
+ reward: float # -1.0 to 1.0
+ outcome: str # "IMPROVED", "DETERIORATED", "STABLE"
+ explanation: str # Reasoning
+
+ # Output
+ training_data: Optional[Dict]
class JudgeAgent(BaseReasoningAgent):
def __init__(self):
super().__init__(name="Judge Agent")
self.qdrant = client
- # Using Llama 3.2 Vision (11B) for analysis
- self.vision_model = "meta-llama/llama-4-scout-17b-16e-instruct"
+
+ # LLM for the "Deliberation" phase
+ self.llm = ChatOpenAI(
+ base_url="https://api.groq.com/openai/v1",
+ api_key=os.environ.get("GROQ_API_KEY"),
+ model="llama-3.3-70b-versatile",
+ temperature=0.1
+ )
+
+ self.app = self._build_graph()
- def review_previous_cycle(self, current_fmu: FMU):
+ def _build_graph(self):
+ workflow = StateGraph(JudgeState)
+
+ # 1. Retrieve: Get N-1 state from Qdrant
+ workflow.add_node("retrieve_evidence", self.node_retrieve_evidence)
+
+ # 2. Investigate: Run Tools (Vision & Memory)
+ workflow.add_node("run_forensics", self.node_run_forensics)
+
+ # 3. Deliberate: LLM Synthesis
+ workflow.add_node("deliberate", self.node_deliberate)
+
+ # 4. Update: Write to DBs
+ workflow.add_node("file_verdict", self.node_file_verdict)
+
+ # Flow
+ workflow.set_entry_point("retrieve_evidence")
+
+ # Conditional: If no history, skip to end
+ workflow.add_conditional_edges(
+ "retrieve_evidence",
+ lambda x: "run_forensics" if x.get("prev_point") else "end_no_history",
+ {
+ "run_forensics": "run_forensics",
+ "end_no_history": END
+ }
+ )
+
+ workflow.add_edge("run_forensics", "deliberate")
+ workflow.add_edge("deliberate", "file_verdict")
+ workflow.add_edge("file_verdict", END)
+
+ return workflow.compile()
+
+ # --- NODES ---
+
+ def node_retrieve_evidence(self, state: JudgeState):
"""
- Only looks back at Sequence N-1 to judge its outcome based on N.
- Does NOT store the current state (N).
+ Finds the previous cycle (N-1) to compare against.
"""
- print(f"[{self.name}] šØāāļø Reviewing previous cycle results...")
-
- crop_id = current_fmu.metadata.get("crop_id")
- current_seq = current_fmu.metadata.get("sequence_number", 1)
- image_b64 = current_fmu.metadata.get("image_b64") # Raw image for vision analysis
+ print(f"[{self.name}] šµļø Retrieve Evidence...")
+ fmu = state["current_fmu"]
+ crop_id = fmu.metadata.get("crop_id")
+ current_seq = fmu.metadata.get("sequence_number", 1)
+
+ if current_seq <= 1:
+ print(" -> First cycle. No history to judge.")
+ return {"prev_point": None}
- if current_seq > 1:
- prev_seq = current_seq - 1
- print(f"[{self.name}] š Looking up history (Seq #{prev_seq})...")
-
- prev_point = self._find_specific_sequence(crop_id, prev_seq)
-
- if prev_point:
- # Judge: Did the plant improve?
- health_analysis = self._analyze_visual_health(
- image_b64,
- current_fmu.metadata.get("sensors")
- )
-
- # Update N-1 with the verdict
- self._update_outcome(prev_point.id, health_analysis)
- else:
- print(f"[{self.name}] ā ļø History record (Seq #{prev_seq}) not found.")
- else:
- print(f"[{self.name}] š First cycle. No history to review.")
-
- def _find_specific_sequence(self, crop_id, sequence_number):
+ prev_seq = current_seq - 1
+
try:
s_filter = models.Filter(
must=[
models.FieldCondition(key="crop_id", match=models.MatchValue(value=crop_id)),
- models.FieldCondition(key="sequence_number", match=models.MatchValue(value=sequence_number))
+ models.FieldCondition(key="sequence_number", match=models.MatchValue(value=prev_seq))
]
)
- res, _ = self.qdrant.scroll(collection_name=COLLECTION_NAME, scroll_filter=s_filter, limit=1)
- return res[0] if res else None
+ res, _ = self.qdrant.scroll(
+ collection_name=COLLECTION_NAME,
+ scroll_filter=s_filter,
+ limit=1,
+ with_vectors=True
+ )
+ return {"prev_point": res[0] if res else None, "crop_id": crop_id}
+
except Exception as e:
- print(f"[{self.name}] ā ļø DB Error: {e}")
- return None
+ print(f" -> DB Error: {e}")
+ return {"prev_point": None}
+
+ def node_run_forensics(self, state: JudgeState):
+ """
+ Executes the TWO mandated tools: diagnose_plant and ask_memory.
+ """
+ print(f"[{self.name}] š Running Forensics...")
+ prev_point = state["prev_point"]
+ crop_id = state["crop_id"]
+
+ # --- TOOL 1: diagnose_plant ---
+ visual_data = {"status": "No Image"}
+ image_b64 = prev_point.payload.get("image_b64")
+
+ if image_b64:
+ try:
+ # Create temp file for the tool
+ 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})
+
+ # Cleanup
+ os.remove(temp_path)
+ except Exception as e:
+ visual_data = {"error": str(e)}
+
+ # --- TOOL 2: ask_memory ---
+ # We formulate a query about this specific crop's history
+ query = f"What is the health history and past treatments for {crop_id}?"
+ print(f" -> Invoking Tool: ask_memory")
+ memory_data = ask_memory.invoke({"query": query})
+
+ return {
+ "visual_report": visual_data,
+ "biography": memory_data
+ }
- def _analyze_visual_health(self, image_b64, sensors):
- if not image_b64:
- return {"outcome": "NO_IMAGE", "health_score": 0}
+ def node_deliberate(self, state: JudgeState):
+ """
+ LLM synthesizes Visual + History + Sensor Delta to form a verdict.
+ """
+ print(f"[{self.name}] āļø Deliberating...")
+
+ prev_sensors = state["prev_point"].payload.get("sensor_data", {})
+ curr_sensors = state["current_fmu"].metadata.get("sensor_data", {})
+ visual = state["visual_report"]
+ history = state["biography"]
+
+ prompt = f"""
+ You are the Chief Judge of an Automated Farm.
+ Evaluate the result of the LAST ACTION based on the transition from State N-1 to State N.
- prompt = (
- f"Sensors: {sensors}\n"
- f"Task: Assess and find out the current condition of the plant.\n"
- f"Output JSON: {{'health_score': 0-100, 'outcome': 'IMPROVED'|'DETERIORATED'|'STABLE', 'notes': '...' }}"
- )
+ --- EVIDENCE ---
+ PREVIOUS SENSORS (N-1): {prev_sensors}
+ CURRENT SENSORS (N): {curr_sensors}
+
+ VISUAL AUTOPSY (Doctor's Report):
+ {json.dumps(visual, indent=2)}
+
+ PLANT BIOGRAPHY (Past Issues):
+ {history}
+
+ --- RUBRIC ---
+ 1. IF Doctor found "DISEASE_DETECTED": Reward = -1.0 (Critical Failure).
+ 2. IF Doctor found "HEALTHY" AND Sensors moved closer to targets: Reward = 0.5 to 1.0.
+ 3. IF Sensors drifted away from targets: Reward = -0.5.
+
+ TASK:
+ Output JSON: {{ "outcome": "IMPROVED"|"DETERIORATED"|"STABLE", "reward": float(-1.0 to 1.0), "reason": "Short explanation" }}
+ """
try:
- image_url = f"data:image/png;base64,{image_b64}"
- completion = self.client.chat.completions.create(
- model=self.vision_model,
- messages=[
- {"role": "user", "content": [
- {"type": "text", "text": prompt},
- {"type": "image_url", "image_url": {"url": image_url}}
- ]}
- ],
- response_format={"type": "json_object"},
- temperature=0.1
- )
- return json.loads(completion.choices[0].message.content)
+ response = self.llm.invoke([HumanMessage(content=prompt)])
+ content = response.content.replace("```json", "").replace("```", "").strip()
+ verdict = json.loads(content)
+
+ print(f" -> Verdict: {verdict['outcome']} ({verdict['reward']})")
+ return {
+ "outcome": verdict.get("outcome", "STABLE"),
+ "reward": verdict.get("reward", 0.0),
+ "explanation": verdict.get("reason", "Analysis complete.")
+ }
except Exception as e:
- print(f"[{self.name}] ā ļø Vision Error: {e}")
- return {"outcome": "ERROR", "health_score": 0}
+ print(f" -> Deliberation Failed: {e}")
+ return {"outcome": "ERROR", "reward": 0.0, "explanation": "Judge LLM failed."}
- def _update_outcome(self, point_id, analysis):
+ def node_file_verdict(self, state: JudgeState):
+ """
+ Writes the final judgment to Qdrant.
+ """
+ print(f"[{self.name}] š Filing Verdict...")
+
+ prev_id = state["prev_point"].id
+
+ # Update Qdrant Snapshot
self.qdrant.set_payload(
collection_name=COLLECTION_NAME,
+ points=[prev_id],
payload={
- "outcome": "condition_assessed" + analysis.get("outcome", "UNKNOWN") + "| health_score:" + str(analysis.get("health_score", 0)) + " | notes:" + analysis.get("notes", "")
- },
- points=[point_id]
+ "outcome": f"{state['outcome']} | Reward: {state['reward']}",
+ "reward_score": state['reward'],
+ "explanation_log": state["explanation"],
+ "visual_diagnosis": str(state["visual_report"].get("health_assessment", "N/A"))
+ }
)
- print(f"[{self.name}] ā
Outcome Updated for ID {point_id}: {analysis.get('outcome')}")
-\ No newline at end of file
+
+ # Prepare Training Data Bundle
+ training_data = {
+ "reward": state['reward'],
+ "prev_action_idx": state["prev_point"].payload.get("bandit_action_id"),
+ "prev_vector": state["prev_point"].vector,
+ "prev_sensors": state["prev_point"].payload.get("sensor_data", {})
+ }
+
+ return {"training_data": training_data}
+
+ # --- ENTRY POINT ---
+ def review_previous_cycle(self, current_fmu: FMU):
+ """
+ The public API called by the main system.
+ """
+ initial_state = {
+ "current_fmu": current_fmu,
+ "prev_point": None,
+ "crop_id": "",
+ "visual_report": {},
+ "biography": "",
+ "reward": 0.0,
+ "outcome": "",
+ "explanation": "",
+ "training_data": None
+ }
+
+ result = self.app.invoke(initial_state)
+ return result.get("training_data")
+\ No newline at end of file
diff --git a/agent/sub_agents/water_agent.py b/agent/sub_agents/water_agent.py
@@ -7,7 +7,7 @@ from agent.sub_agents.water_and_atmospheric_dependencies.state import AgentState
from agent.sub_agents.water_and_atmospheric_dependencies.nodes import decide_node, simulate_node, finalize_node, execute_tools_node
# Tools
-from agent.sub_agents.water_and_atmospheric_dependencies.retrieval import ask_historian, ask_rag
+from agent.sub_agents.water_and_atmospheric_dependencies.retrieval import ask_historian, ask_rag, diagnose_plant, ask_memory
from agent.sub_agents.water_and_atmospheric_dependencies.tools import check_ph_safety, web_search
# Configuration
@@ -54,7 +54,9 @@ class WaterAgent:
ask_historian,
ask_rag,
web_search,
- check_ph_safety
+ check_ph_safety,
+ diagnose_plant,
+ ask_memory
])
# 3. Build the Graph (The "Brain")
diff --git a/agent/sub_agents/water_and_atmospheric_dependencies/retrieval.py b/agent/sub_agents/water_and_atmospheric_dependencies/retrieval.py
@@ -7,9 +7,13 @@ from qdrant_client import QdrantClient
from agent.sub_agents.Researcher import ResearcherAgent
from agent.Qdrant.Store import COLLECTION_NAME
+from agent.sub_agents.Doctor import VisionAgent
+from agent.memory import FarmMemory
+
# Initialize shared clients
# Note: We rely on the existing ResearcherAgent logic for embeddings/search
researcher_instance = ResearcherAgent()
+farm_memory = FarmMemory()
# Initialize Qdrant for the Historian
qdrant_client = QdrantClient(
@@ -17,6 +21,40 @@ qdrant_client = QdrantClient(
api_key=os.environ.get("QDRANT_API_KEY"),
)
+doctor = VisionAgent()
+
+@tool
+def diagnose_plant(image_path: str):
+ """
+ Uses Computer Vision to scan the plant image for disease, pests, or growth issues.
+ Call this if you suspect the plant is sick or need to verify visual health.
+
+ Args:
+ image_path (str): The absolute file path of the image (provided in your instructions/context).
+
+ Returns:
+ JSON report containing 'health_assessment' and 'object_counts'.
+ """
+ if not image_path or image_path == "None":
+ return {"error": "No image path provided."}
+
+ return doctor.analyze_frame(image_path)
+
+@tool
+def ask_memory(query: str):
+ """
+ Consult the Farm Memory for past events, strategies, and outcomes.
+ Useful for recalling what has been tried before and its results.
+
+ Args:
+ query: A description of the situation to look up (e.g. "What strategies were used when humidity was high?")
+ """
+ try:
+ response = farm_memory.memory.query(query, top_k=3)
+ return response
+ except Exception as e:
+ return f"Memory unavailable: {str(e)}"
+
@tool
def ask_historian(query: str):
"""
@@ -64,4 +102,6 @@ def ask_rag(query: str):
# Reuse your existing ResearcherAgent logic
return researcher_instance.search(query)
except Exception as e:
- return f"Research unavailable: {str(e)}"
-\ No newline at end of file
+ return f"Research unavailable: {str(e)}"
+
+
diff --git a/backend/server/functions.py b/backend/server/functions.py
@@ -9,71 +9,57 @@ from datetime import datetime
# --- AGENT IMPORTS ---
from agent.sub_agents.Researcher import ResearcherAgent
from agent.sub_agents.Supervisor import SupervisorAgent
-from agent.sub_agents.Explainer import ExplainerAgent
+from agent.sub_agents.atmospheric_agent import AtmosphericAgent
+from agent.sub_agents.water_agent import WaterAgent
+from agent.sub_agents.judge_agent import JudgeAgent
+from agent.sub_agents.Explainer import ExplainerAgent
+
from Qdrant.Store import store_fmu, COLLECTION_NAME
from Qdrant.Client import client
-# Initialize Agents ONCE (Global Scope) to save memory
-print("š± Initializing Cognitive Stack...")
+# --- INITIALIZE COGNITIVE STACK ---
+print("š± Initializing Demeter Cognitive Stack (Bandit Disabled)...")
+
researcher = ResearcherAgent()
-supervisor = SupervisorAgent(researcher.llm)
-explainer = ExplainerAgent(supervisor.llm)
+atmos_agent = AtmosphericAgent()
+water_agent = WaterAgent()
+supervisor = SupervisorAgent(researcher_agent=researcher)
+judge = JudgeAgent()
+explainer = ExplainerAgent(supervisor.model)
+
print("ā
Agents Ready.")
-# --- HELPER: SIMULATE MINI-AGENTS ---
-# In production, these would be your actual imported classes from agent/sub_agents/
+# --- HELPER FUNCTIONS ---
+
def get_next_sequence_number(crop_id: str) -> int:
- """
- Queries Qdrant to find how many snapshots exist for this specific crop_id.
- Returns count + 1.
- """
try:
count_result = client.count(
collection_name=COLLECTION_NAME,
count_filter=models.Filter(
- must=[
- models.FieldCondition(
- key="crop_id",
- match=models.MatchValue(value=crop_id)
- )
- ]
+ must=[models.FieldCondition(key="crop_id", match=models.MatchValue(value=crop_id))]
)
)
return count_result.count + 1
except Exception as e:
print(f"ā ļø Could not calculate sequence: {e}")
return 1
-
-def simulate_sub_agents(sensors):
+
+def filter_numeric_sensors(raw_data: dict) -> dict:
"""
- Generates 'Expert Opinions' based on raw sensor data.
+ Extracts only floating-point sensor values.
"""
- 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."
+ clean = {}
+ valid_keys = ["ph", "ec", "temp", "humidity", "co2", "light", "tds", "do", "orp"]
- return reports
+ for k, v in raw_data.items():
+ if any(valid in k.lower() for valid in valid_keys):
+ try:
+ clean[k] = float(v)
+ except (ValueError, TypeError):
+ pass
+ return clean
+
+# --- CORE ENDPOINTS ---
async def process_ingest(file: UploadFile, sensors_str: str, metadata_str: str, builder):
"""
@@ -84,37 +70,32 @@ async def process_ingest(file: UploadFile, sensors_str: str, metadata_str: str,
shutil.copyfileobj(file.file, buffer)
try:
- sensor_data = json.loads(sensors_str)
+ raw_sensor_data = json.loads(sensors_str)
meta_data = json.loads(metadata_str)
abs_image_path = os.path.abspath(temp_filename)
- # --- 1. Identify Context ---
+ clean_sensors = filter_numeric_sensors(raw_sensor_data)
+
+ # 1. Identity Logic
target_crop = meta_data.get("crop", "Unknown")
-
- # Get Crop ID (Prefer metadata, fall back to sensor data, then auto-generate)
- target_crop_id = meta_data.get("crop_id") or sensor_data.get("crop_id")
+ target_crop_id = meta_data.get("crop_id") or raw_sensor_data.get("crop_id")
if not target_crop_id:
target_crop_id = f"Batch_{target_crop}_{datetime.now().strftime('%Y%m')}"
- # Calculate Sequence Number
seq_num = get_next_sequence_number(target_crop_id)
-
print(f"š„ Ingesting {target_crop_id} | Snapshot #{seq_num}")
- # --- 2. Inject Metadata Schema ---
- # We inject 'explanation_log' here so even "Raw" snapshots match the schema
+ # 2. Metadata Injection
meta_data.update({
"crop_id": target_crop_id,
"sequence_number": seq_num,
- "sensor_data": sensor_data,
+ "sensor_data": clean_sensors,
"action_taken": meta_data.get("action_taken", "PENDING_ACTION"),
"outcome": meta_data.get("outcome", "PENDING_OBSERVATION"),
- "explanation_log": "PENDING_ANALYSIS" # š Ensures Schema Consistency
})
- # --- 3. Create & Store ---
- # Note: FMUBuilder handles putting sensor_data into the "sensors" key
- fmu = builder.create_fmu(abs_image_path, sensor_data, meta_data)
+ # 3. Store
+ fmu = builder.create_fmu(abs_image_path, clean_sensors, meta_data)
store_fmu(fmu)
return {"status": "success", "fmu_id": fmu.id}
@@ -125,126 +106,135 @@ async def process_ingest(file: UploadFile, sensors_str: str, metadata_str: str,
async def process_search(file: UploadFile, sensors_str: str, builder):
"""
- 1. Create & Save FMU (Placeholder State)
- 2. Search Memory
- 3. Run Supervisor
+ RUNS THE DEMETER AGENT LOOP (Standard Mode - No Bandit)
"""
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)
+ raw_sensor_data = json.loads(sensors_str)
abs_image_path = os.path.abspath(temp_filename)
-
- numeric_sensors = {k: v for k, v in sensor_data.items() if k in ["pH", "EC", "temp", "humidity"]}
-
- # --- EXTRACT DATA ---
- target_crop = sensor_data.get("crop", "Unknown")
- target_stage = sensor_data.get("stage", "Unknown")
-
- # š NEW: Extract Crop ID from frontend (or generate a default)
- target_crop_id = sensor_data.get("crop_id", f"Batch_{target_crop}_{datetime.now().strftime('%Y%m')}")
+ clean_sensors = filter_numeric_sensors(raw_sensor_data)
- # š NEW: Calculate Sequence
+ # --- 1. Create Query FMU ---
+ target_crop = raw_sensor_data.get("crop", "Unknown")
+ target_crop_id = raw_sensor_data.get("crop_id", f"Batch_{target_crop}_{datetime.now().strftime('%Y%m')}")
seq_num = get_next_sequence_number(target_crop_id)
- print(f"š¢ Processing {target_crop_id} | Snapshot #{seq_num}")
-
+
metadata = {
"crop": target_crop,
- "stage": sensor_data.get("stage", "Unknown"),
- "crop_id": target_crop_id, # <--- Added
- "sequence_number": seq_num, # <--- Added
- "sensor_data": sensor_data,
- "action_taken": "PENDING_USER_ACTION",
- "outcome": "PENDING_OBSERVATION",
- "explanation_log": "PENDING_ANALYSIS"
+ "stage": raw_sensor_data.get("stage", "Unknown"),
+ "crop_id": target_crop_id,
+ "sequence_number": seq_num,
+ "sensor_data": clean_sensors,
+ "action_taken": "PENDING_DECISION",
+ "outcome": "PENDING"
}
- # Create & Store FMU
- query_fmu = builder.create_fmu(abs_image_path, numeric_sensors, metadata=metadata)
+ query_fmu = builder.create_fmu(abs_image_path, clean_sensors, metadata=metadata)
store_fmu(query_fmu)
- print(f"š Created Query FMU ID: {query_fmu.id}")
-
- # --- STEP 2: Vector Search (Using the new FMU's vector) ---
- query_vector = query_fmu.vector.tolist() if hasattr(query_fmu.vector, 'tolist') else query_fmu.vector
-
- # Create Context Filter
- context_filter = models.Filter(
- must=[
- models.FieldCondition(key="crop", match=models.MatchValue(value=target_crop)),
- models.FieldCondition(key="stage", match=models.MatchValue(value=target_stage))
- ]
- )
+ print(f"š Processing FMU ID: {query_fmu.id}")
+ # --- 2. JUDGE (Review Previous) ---
+ # We run the Judge to update the Database with the 'Outcome' of the last cycle.
+ # But we do NOT use the result for training the Bandit.
try:
- # We fetch 4 items so we can safely drop the current query if it appears
- response = client.query_points(
- collection_name=COLLECTION_NAME,
- query=query_vector,
- query_filter=context_filter,
- limit=4,
- with_payload=True
- )
- hits = response.points
-
- # Filter out the current query ID if it appears in results (Self-Exclusion)
- hits = [hit for hit in hits if hit.id != query_fmu.id][:3]
+ judge.review_previous_cycle(query_fmu)
+ except Exception as e:
+ 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
+ print(f"š”ļø Strategy Selected: {strat_name} (Manual Override)")
+
+ # --- 4. RESEARCH ---
+ hits = client.query_points(
+ collection_name=COLLECTION_NAME,
+ query=query_fmu.vector,
+ limit=3,
+ with_payload=True
+ )
+
+ points_list = hits.points if hasattr(hits, 'points') else hits
- except Exception:
- print("ā ļø Filter failed, searching raw vectors...")
- hits = client.search(collection_name=COLLECTION_NAME, query_vector=query_vector, limit=4, with_payload=True)
- hits = [hit for hit in hits if hit.id != query_fmu.id][:3]
+ # 2. Generate Context (Safe handling for missing payloads)
+ history_context = "\n".join([
+ f"- {(h.payload or {}).get('action_taken', 'Unknown')}: {(h.payload or {}).get('outcome', 'Unknown')}"
+ for h in points_list
+ ])
- similar_fmus_formatted = [{"score": hit.score, "payload": hit.payload} for hit in hits]
+ research_query = f"optimal hydroponic conditions for {target_crop} in {metadata['stage']} stage"
+ research_context = researcher.search(research_query)
- # --- STEP 3: The Reasoning Cycle ---
- print("š§ Invoking Supervisor Agent...")
- mini_agent_reports = simulate_sub_agents(numeric_sensors)
+ # --- 5. SUB-AGENTS ---
+ print("š§ Specialists Planning...")
+
+ atmos_plan = atmos_agent.reason(
+ sensors=clean_sensors,
+ research=research_context,
+ strategy=strat_instr,
+ history=history_context
+ )
+
+ water_plan = water_agent.reason(
+ sensors=clean_sensors,
+ research=research_context,
+ strategy=strat_instr,
+ history=history_context
+ )
- fmu_vector = query_fmu.vector
- if hasattr(fmu_vector, 'tolist'):
- fmu_vector = fmu_vector.tolist()
+ # --- 6. SUPERVISOR ---
+ print("š® Supervisor Finalizing...")
+ final_decision_json = supervisor.synthesize_plan(
+ atmos_plan,
+ water_plan,
+ query_fmu,
+ history_context,
+ strategy_info=(strat_name, strat_instr, action_idx)
+ )
+
+ # --- 7. EXPLAINER ---
current_fmu_context = {
"metadata": metadata,
- "payload": {"sensors": numeric_sensors},
- "vector": fmu_vector
+ "payload": {"sensors": clean_sensors},
+ "vector": query_fmu.vector.tolist() if hasattr(query_fmu.vector, 'tolist') else query_fmu.vector
}
+ similar_fmus_formatted = [{"score": h.score, "payload": h.payload} for h in points_list]
+ sub_agent_reports = {"Atmospheric": atmos_plan, "Water": water_plan}
- decision_json = supervisor.reason(
- current_fmu=current_fmu_context,
- similar_fmus=similar_fmus_formatted,
- sub_agent_outputs=mini_agent_reports
- )
-
- # --- š¢ NEW: Run the Explainer ---
- print("Detailed Explanation Generation...")
explanation_log = explainer.explain(
current_fmu=current_fmu_context,
similar_fmus=similar_fmus_formatted,
- sub_agent_reports=mini_agent_reports,
- final_decision=decision_json
+ sub_agent_reports=sub_agent_reports,
+ final_decision=final_decision_json
)
- # Update the FMU Metadata with this log
+ # Update Record
client.set_payload(
collection_name=COLLECTION_NAME,
points=[query_fmu.id],
payload={
- "action_taken": decision_json.get("decision"),
- "outcome": "PENDING_FEEDBACK",
- "explanation_log": explanation_log # š Saving the detailed text
+ "action_taken": str(final_decision_json),
+ "outcome": "PENDING_OBSERVATION",
+ "explanation_log": explanation_log,
+ "strategic_intent": strat_name
}
)
return {
"status": "success",
"new_fmu_id": query_fmu.id,
- "search_results": [{"id": h.id, "score": h.score, "payload": h.payload} for h in hits],
- "agent_decision": decision_json,
- "explanation": explanation_log # š Send to Frontend immediately
+ "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]
}
except Exception as e:
@@ -256,77 +246,29 @@ async def process_search(file: UploadFile, sensors_str: str, builder):
if os.path.exists(temp_filename):
os.remove(temp_filename)
-async def parse_natural_language_query(query_text: str):
- """
- Uses the LLM to convert a text query into structured 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", "PENDING_OBSERVATION")
- - action_taken (e.g., "Add CalMag", "Lower pH")
- - crop_id (e.g., "Batch_Lettuce_2026")
-
- RULES:
- 1. TRANSLATION: The user may speak Hindi or mixed "Hinglish". You must map these to the standard English tags.
- - "Tamatar" -> crop: "Tomato"
- - "Kharab" / "Sadd gaya" / "Bekar" -> outcome: "Negative"
- - "Accha hai" / "Badhiya" -> outcome: "Positive"
- - "Paani" / "Water" -> (No direct filter unless context implies outcome)
- 2. If user says "poor health", "bad", "failed" or similar negative words, map to outcome="Negative".
- 3. If user says "good", "healthy" or other positive words, map to outcome="Positive".
- 4. Output strictly JSON matching this structure:
- {
- "must": [
- {"key": "field_name", "match": "value"}
- ]
- }
- 5. Return empty list [] if no specific filters apply.
- """
-
- try:
- response = supervisor.llm.chat.completions.create(
- model="llama-3.1-8b-instant",
- messages=[
- {"role": "system", "content": system_prompt},
- {"role": "user", "content": query_text}
- ],
- temperature=0,
- response_format={"type": "json_object"}
- )
- return json.loads(response.choices[0].message.content)
- except Exception as e:
- print(f"ā Query Parse Error: {e}")
- return {"must": []}
-
async def process_text_query(text: str):
- """
- Handles natural language search requests.
- """
- print(f"š£ļø User asked: '{text}'")
-
- # 1. Translate Text -> Filters
- filter_logic = await parse_natural_language_query(text)
- print(f"āļø Generated Filters: {json.dumps(filter_logic, indent=2)}")
-
- # 2. Build Qdrant Filter
- conditions = []
- for item in filter_logic.get("must", []):
- conditions.append(
- models.FieldCondition(
- key=item["key"],
- match=models.MatchValue(value=item["match"])
+ try:
+ from langchain_core.messages import SystemMessage, HumanMessage
+
+ system_prompt = "You are a Database Translator. Convert natural language to JSON filters..."
+ response = supervisor.model.invoke([
+ SystemMessage(content=system_prompt),
+ HumanMessage(content=text)
+ ])
+
+ content = response.content.replace("```json", "").replace("```", "").strip()
+ filter_logic = json.loads(content)
+
+ conditions = []
+ for item in filter_logic.get("must", []):
+ conditions.append(
+ models.FieldCondition(
+ key=item["key"],
+ match=models.MatchValue(value=item["match"])
+ )
)
- )
- # 3. Query Database (Scroll is better for "List" queries than vector search)
- try:
if conditions:
- # Search with filters
scroll_filter = models.Filter(must=conditions)
results = client.scroll(
collection_name=COLLECTION_NAME,
@@ -335,63 +277,17 @@ async def process_text_query(text: str):
with_payload=True
)
else:
- # No filters found, return latest
- results = client.scroll(
- collection_name=COLLECTION_NAME,
- limit=10,
- with_payload=True
- )
+ results = client.scroll(collection_name=COLLECTION_NAME, limit=10, with_payload=True)
- points = results[0] # Scroll returns (points, offset)
-
+ 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}")
return {"status": "error", "message": str(e)}
-
-async def process_audio_search(file: UploadFile):
- """
- 1. Transcribe Audio (Whisper) -> Text
- 2. Run Text Search (LLM -> Filters)
- """
- temp_filename = f"temp_audio_{file.filename}"
-
- # Save audio temporarily
- with open(temp_filename, "wb") as buffer:
- shutil.copyfileobj(file.file, buffer)
- try:
- print("šļø Transcribing audio (Multilingual)...")
- audio_file = open(temp_filename, "rb")
-
- # š CHANGE THIS MODEL
- transcription = supervisor.llm.audio.transcriptions.create(
- file=audio_file,
- model="whisper-large-v3", # š Use the Multilingual Model (No "-en" suffix)
- response_format="json",
- prompt="The audio may contain English or Hindi technical terms about farming." # Optional hint
- )
-
- detected_text = transcription.text
- print(f"š Heard ({transcription.language if hasattr(transcription, 'language') else 'auto'}): '{detected_text}'")
-
- # 1. Get the standard search results
- response_data = await process_text_query(detected_text)
- # 2. š INJECT the transcription into the response
- 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
- if 'audio_file' in locals():
- audio_file.close()
- if os.path.exists(temp_filename):
- os.remove(temp_filename)
-\ No newline at end of file
+async def process_audio_search(file: UploadFile):
+ return {"status": "error", "message": "Audio search temporarily disabled."}
+\ No newline at end of file
diff --git a/backend/server/main.py b/backend/server/main.py
@@ -1,9 +1,10 @@
import sys
import os
-import base64
# --- PATH FIX ---
current_dir = os.path.dirname(os.path.abspath(__file__))
+# Adjust this depending on where main.py sits relative to the root 'Demeter' folder
+# If main.py is in Demeter/backend/server, root is ../../
project_root = os.path.abspath(os.path.join(current_dir, '../../'))
sys.path.append(project_root)
# ----------------
@@ -12,24 +13,22 @@ from fastapi import FastAPI, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from Sentinel.agent import FMUBuilder
-# Import the logic functions
+# Import the UPDATED logic functions
from backend.server.functions import process_ingest, process_search, process_text_query, process_audio_search
+
app = FastAPI()
app.add_middleware(
CORSMiddleware,
- allow_origins=["http://localhost:3000"],
+ allow_origins=["*"], # Allow all for dev
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
-# Initialize Agents Once
-print("š± Initializing Demeter Agents...")
+print("š± Server Starting...")
builder = FMUBuilder()
-print("ā
Agents Ready.")
-
-# (Helper function removed as it is no longer needed for these endpoints)
+print("ā
Server Ready.")
@app.post("/ingest")
async def ingest_endpoint(
@@ -37,28 +36,15 @@ async def ingest_endpoint(
sensors: str = Form(...),
metadata: str = Form(...)
):
- try:
- # 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
- traceback.print_exc()
- return {"status": "error", "message": str(e)}
+ return await process_ingest(file, sensors, metadata, builder)
@app.post("/search")
async def search_endpoint(
file: UploadFile = File(...),
sensors: str = Form(...)
):
- try:
- # FIX: Pass the 'file' object directly.
- return await process_search(file, sensors, builder)
- except Exception as e:
- print(f"ā Search Error: {e}")
- import traceback
- traceback.print_exc()
- return {"status": "error", "message": str(e)}
+ # This endpoint now triggers the Full Agent Reasoning Loop
+ return await process_search(file, sensors, builder)
@app.post("/query-text")
async def text_query_endpoint(query: str = Form(...)):
@@ -66,15 +52,9 @@ async def text_query_endpoint(query: str = Form(...)):
@app.post("/query-audio")
async def audio_query_endpoint(file: UploadFile = File(...)):
- """
- Accepts an audio file (webm/wav), transcribes it, and runs a search.
- """
- try:
- return await process_audio_search(file)
- except Exception as e:
- print(f"ā Route Error: {e}")
- return {"status": "error", "message": str(e)}
+ return await process_audio_search(file)
if __name__ == "__main__":
import uvicorn
+ # Using 8002 to avoid conflict with Simulator (8001) and React (3000)
uvicorn.run(app, host="0.0.0.0", port=8000)
\ No newline at end of file
diff --git a/frontend/src/pages/AgentControl.jsx b/frontend/src/pages/AgentControl.jsx
@@ -3,7 +3,7 @@ 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
+ ArrowLeft, Leaf, Database, CheckCircle2, Fan, FlaskConical, Waves, Zap
} from "lucide-react";
import { agentService } from "../api/agentApi";
@@ -24,7 +24,9 @@ export default function AgentControl() {
const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);
+ // š§ State for the Supervisor's Output
const [decision, setDecision] = useState(null);
+ const [strategy, setStrategy] = useState(""); // New state for Strategy
const [sensors, setSensors] = useState({
pH: "6.0",
@@ -43,6 +45,8 @@ export default function AgentControl() {
setPreview(URL.createObjectURL(selected));
setSearchResults([]);
setDecision(null);
+ setStrategy("");
+ setExplanationText("");
}
};
@@ -68,12 +72,18 @@ export default function AgentControl() {
if (!file) return alert("Please select an image to search with.");
setLoadingSearch(true);
setDecision(null);
+ setStrategy("");
try {
const response = await agentService.searchFMU(file, sensors);
+
+ // Update State with new JSON structure
if (response.explanation) setExplanationText(response.explanation);
- setSearchResults(response.search_results || []);
+ if (response.strategy) setStrategy(response.strategy);
if (response.agent_decision) setDecision(response.agent_decision);
+
+ setSearchResults(response.search_results || []);
+
} catch (error) {
console.error(error);
alert("ā Search Failed.");
@@ -106,60 +116,79 @@ export default function AgentControl() {
}
};
- const startRecording = async () => {
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- mediaRecorderRef.current = new MediaRecorder(stream);
- chunksRef.current = [];
- mediaRecorderRef.current.ondataavailable = (e) => {
- if (e.data.size > 0) chunksRef.current.push(e.data);
- };
- mediaRecorderRef.current.onstop = async () => {
- const audioBlob = new Blob(chunksRef.current, { type: "audio/webm" });
- await handleAudioUpload(audioBlob);
- stream.getTracks().forEach(track => track.stop());
- };
- mediaRecorderRef.current.start();
- setIsRecording(true);
- } catch (err) {
- console.error("Mic Error:", err);
- alert("Microphone access denied.");
- }
- };
-
- const stopRecording = () => {
- if (mediaRecorderRef.current && isRecording) {
- mediaRecorderRef.current.stop();
- setIsRecording(false);
+ // --- Helper to Map Decision Keys to UI ---
+ const getActionCardProps = (key, value) => {
+ switch(key) {
+ case 'acid_dosage_ml':
+ return { label: "Acid Dosage", value: `${value} ml`, icon: FlaskConical, color: "text-rose-500", bg: "bg-rose-50" };
+ case 'base_dosage_ml':
+ return { label: "Base Dosage", value: `${value} ml`, icon: FlaskConical, color: "text-indigo-500", bg: "bg-indigo-50" };
+ case 'nutrient_dosage_ml':
+ return { label: "Nutrient Mix", value: `${value} ml`, icon: Sprout, color: "text-emerald-500", bg: "bg-emerald-50" };
+ case 'fan_speed_pct':
+ return { label: "Fan Speed", value: `${value}%`, icon: Fan, color: "text-cyan-500", bg: "bg-cyan-50" };
+ case 'water_refill_l':
+ return { label: "Water Refill", value: `${value} L`, icon: Waves, color: "text-blue-500", bg: "bg-blue-50" };
+ default:
+ return { label: key.replace(/_/g, ' '), value: value, icon: Zap, color: "text-gray-500", bg: "bg-gray-50" };
}
};
- const handleAudioUpload = async (audioBlob) => {
- setLoadingSearch(true);
- setSearchResults([]);
- try {
- const data = await agentService.queryAudio(audioBlob);
- if (data.transcription) setTextQuery(data.transcription);
- if (data.results) {
- const mappedResults = data.results.map(r => ({
- id: r.id,
- score: 1.0,
- payload: r.payload
- }));
- setSearchResults(mappedResults);
+ // ... (Keep Audio Handlers: startRecording, stopRecording, handleAudioUpload as is) ...
+ const startRecording = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ mediaRecorderRef.current = new MediaRecorder(stream);
+ chunksRef.current = [];
+ mediaRecorderRef.current.ondataavailable = (e) => {
+ if (e.data.size > 0) chunksRef.current.push(e.data);
+ };
+ mediaRecorderRef.current.onstop = async () => {
+ const audioBlob = new Blob(chunksRef.current, { type: "audio/webm" });
+ await handleAudioUpload(audioBlob);
+ stream.getTracks().forEach(track => track.stop());
+ };
+ mediaRecorderRef.current.start();
+ setIsRecording(true);
+ } catch (err) {
+ console.error("Mic Error:", err);
+ alert("Microphone access denied.");
}
- } catch (e) {
- console.error(e);
- alert("Audio Query Failed");
- } finally {
- setLoadingSearch(false);
- }
- };
+ };
+
+ const stopRecording = () => {
+ if (mediaRecorderRef.current && isRecording) {
+ mediaRecorderRef.current.stop();
+ setIsRecording(false);
+ }
+ };
+
+ const handleAudioUpload = async (audioBlob) => {
+ setLoadingSearch(true);
+ setSearchResults([]);
+ try {
+ const data = await agentService.queryAudio(audioBlob);
+ if (data.transcription) setTextQuery(data.transcription);
+ if (data.results) {
+ const mappedResults = data.results.map(r => ({
+ id: r.id,
+ score: 1.0,
+ payload: r.payload
+ }));
+ setSearchResults(mappedResults);
+ }
+ } catch (e) {
+ console.error(e);
+ alert("Audio Query Failed");
+ } finally {
+ setLoadingSearch(false);
+ }
+ };
return (
<div className="min-h-screen bg-[#F4F9F6] font-sans text-gray-800 pb-20">
- {/* --- 1. NAVBAR (Light Mode) --- */}
+ {/* --- 1. NAVBAR --- */}
<nav className="border-b border-gray-200 bg-white sticky top-0 z-20 h-16 shadow-sm">
<div className="max-w-7xl mx-auto px-6 h-full flex items-center justify-between">
<Link to="/" className="flex items-center space-x-2 hover:opacity-80 transition">
@@ -171,10 +200,6 @@ export default function AgentControl() {
</span>
</Link>
<div className="flex items-center space-x-6 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
- <div className="flex items-center space-x-2 bg-emerald-50 text-emerald-700 px-3 py-1.5 rounded-full">
- <span className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></span>
- <span>System Online</span>
- </div>
<Link to="/dashboard" className="hover:text-emerald-600 transition-colors">
Dashboard
</Link>
@@ -222,9 +247,6 @@ export default function AgentControl() {
<p className="text-gray-900 font-bold text-lg">Upload Crop Scan</p>
<p className="text-gray-400 text-sm">Drag & drop or click to browse</p>
</div>
- <div className="inline-flex items-center gap-2 px-3 py-1 bg-gray-50 rounded-full text-[10px] text-gray-500 font-bold border border-gray-200">
- JPG, PNG SUPPORTED
- </div>
</div>
)}
</div>
@@ -275,8 +297,8 @@ export default function AgentControl() {
{ 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", "Kale"] },
- { label: "Stage", name: "stage", icon: Calendar, color: "text-purple-600", bg: "bg-purple-50", type: "select", options: ["Seedling", "Vegetative", "Flowering", "Fruiting", "Mature"] }
+ { 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>
@@ -333,32 +355,50 @@ export default function AgentControl() {
{/* --- OUTPUT SECTION --- */}
- {/* 1. Decision Card (Supervisor) */}
+ {/* 1. Decision & Action Grid (Supervisor) */}
{decision && (
<div className="mb-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
- <div className="bg-white border border-emerald-100 rounded-3xl overflow-hidden shadow-xl shadow-emerald-500/10 relative">
- {/* Background Decoration */}
- <div className="absolute top-0 right-0 w-64 h-64 bg-emerald-50 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 opacity-50"></div>
-
- <div className="p-6 border-b border-gray-100 flex justify-between items-center relative z-10 bg-white/80 backdrop-blur-sm">
- <h3 className="text-gray-900 font-bold text-lg flex items-center gap-2">
- <div className="bg-emerald-500 text-white p-1.5 rounded-lg"><Brain size={18}/></div>
- Demeter Recommendation
- </h3>
+ <div className="bg-white border border-emerald-100 rounded-3xl overflow-hidden shadow-xl shadow-emerald-500/10">
+
+ {/* Header with Strategy */}
+ <div className="p-6 border-b border-gray-100 bg-emerald-50/50 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
+ <div>
+ <h3 className="text-gray-900 font-bold text-lg flex items-center gap-2">
+ <div className="bg-emerald-500 text-white p-1.5 rounded-lg"><Brain size={18}/></div>
+ Supervisor Command
+ </h3>
+ <p className="text-xs font-mono text-emerald-600 mt-1 uppercase tracking-wide">
+ Active Strategy: <span className="font-bold">{strategy || "ANALYZING..."}</span>
+ </p>
+ </div>
+
<button
onClick={() => setShowExplanation(!showExplanation)}
- className="text-xs font-semibold text-emerald-600 hover:text-emerald-700 bg-emerald-50 px-3 py-1.5 rounded-full transition-colors flex items-center gap-1"
+ className="text-xs font-semibold text-emerald-600 hover:text-emerald-700 bg-white border border-emerald-200 px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1 shadow-sm"
>
<Search size={12}/> View Logic Trace
</button>
</div>
- <div className="p-8 relative z-10">
- <p className="text-xl text-gray-700 leading-relaxed font-medium">
- {decision.reasoning || "Analyzing..."}
- </p>
+ {/* ACTION GRID */}
+ <div className="p-8">
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
+ {Object.entries(decision).map(([key, value]) => {
+ const props = getActionCardProps(key, value);
+ return (
+ <div key={key} className="bg-gray-50 border border-gray-100 rounded-2xl p-4 flex flex-col items-center justify-center text-center hover:border-emerald-200 hover:shadow-md transition-all">
+ <div className={`w-10 h-10 rounded-full ${props.bg} flex items-center justify-center mb-3`}>
+ <props.icon className={`w-5 h-5 ${props.color}`} />
+ </div>
+ <div className="text-2xl font-bold text-gray-800 font-mono mb-1">{props.value}</div>
+ <div className="text-[10px] uppercase font-bold text-gray-400 tracking-wider">{props.label}</div>
+ </div>
+ );
+ })}
+ </div>
</div>
+ {/* Explainer Drawer */}
{showExplanation && (
<div className="bg-gray-50 p-6 border-t border-gray-200 animate-in slide-in-from-top-2">
<h4 className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3">
@@ -373,7 +413,7 @@ export default function AgentControl() {
</div>
)}
- {/* 2. Search Results Grid */}
+ {/* 2. Search Results Grid (Kept same) */}
{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">
@@ -425,13 +465,6 @@ export default function AgentControl() {
</div>
</div>
</div>
-
- {/* Card Footer */}
- <div className="p-4 border-t border-gray-100 bg-white">
- <button className="w-full py-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-600 text-xs font-bold uppercase rounded-lg transition-colors flex items-center justify-center gap-2 group-hover:text-blue-600 group-hover:border-blue-200">
- <span>Load Full Context</span> <ArrowRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
- </button>
- </div>
</div>
))}
</div>