demeter

Autonomous Hydroponic Intelligence
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:
Magent/Marl/bandit.py | 2+-
Magent/memory.py | 77+++++++++++++++++++++--------------------------------------------------------
Magent/sub_agents/Doctor.py | 59++++++++++++++++++-----------------------------------------
Magent/sub_agents/Supervisor.py | 439+++++++++++++++++++++++++++++++++++++++----------------------------------------
Magent/sub_agents/atmospheric_agent.py | 6++++--
Magent/sub_agents/judge_agent.py | 299+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Magent/sub_agents/water_agent.py | 6++++--
Magent/sub_agents/water_and_atmospheric_dependencies/retrieval.py | 43+++++++++++++++++++++++++++++++++++++++++--
Mbackend/server/functions.py | 410++++++++++++++++++++++++++++++-------------------------------------------------
Mbackend/server/main.py | 44++++++++++++--------------------------------
Mfrontend/src/pages/AgentControl.jsx | 193++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
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>