demeter

Autonomous Hydroponic Intelligence
commit f734d5ffd2193be4befaa6781481afe53d03ece4
parent 7bea89e6e7e2ce3bdaf54b1901c217d81b776431
Author: Debarghya Das <debarghya1108@gmail.com>
Date:   Mon,  2 Mar 2026 20:15:28 +0000

Merge PR

Diffstat:
Magent/Sentinel/agent.py | 3++-
Aagent/sub_agents/Explainer.py | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mbackend/server/functions.py | 48+++++++++++++++++++++++++++++++++++++-----------
Mweb/app/upload/page.tsx | 102++++++++++++++++++++++++++++++++++++-------------------------------------------
Mweb/models/index.ts | 1+
Mweb/services/api.ts | 6------
6 files changed, 135 insertions(+), 74 deletions(-)

diff --git a/agent/Sentinel/agent.py b/agent/Sentinel/agent.py @@ -60,7 +60,8 @@ class FMUBuilder: "crop_id": metadata.get("crop_id", "UNKNOWN_CROP"), "sequence_number": metadata.get("sequence_number", 1), "action_taken": metadata.get("action_taken", "PENDING_ACTION"), - "outcome": metadata.get("outcome", "PENDING_OBSERVATION") + "outcome": metadata.get("outcome", "PENDING_OBSERVATION"), + "explanation_log": metadata.get("explanation_log", "PENDING_ANALYSIS") } return FMU( diff --git a/agent/sub_agents/Explainer.py b/agent/sub_agents/Explainer.py @@ -0,0 +1,48 @@ +import json + +class ExplainerAgent: + def __init__(self, llm_client): + self.llm = llm_client + + def explain(self, current_fmu, similar_fmus, sub_agent_reports, final_decision): + """ + Generates a detailed, human-readable log of the decision process. + """ + + # Construct the context for the LLM + context = f""" + CONTEXT DATA: + - Current Sensors: {json.dumps(current_fmu['payload']['sensors'])} + - Visual Context: {current_fmu['metadata'].get('stage')} {current_fmu['metadata'].get('crop')} + - Expert Reports: {json.dumps(sub_agent_reports)} + - Historical Precedents: Found {len(similar_fmus)} similar past cases. + + FINAL DECISION TAKEN: + {json.dumps(final_decision)} + """ + + system_prompt = """ + You are the "Explainer" for an AI Hydroponic System. + Your goal is to write a "Chain of Thought" log that explains WHY a specific decision was made. + + STRUCTURE YOUR RESPONSE AS A CLEAN LIST OF STEPS: + 1. **Observation**: What did the sensors and vision see? (Cite specific numbers). + 2. **Precedent**: Did we see this before? (Reference the similar cases). + 3. **Logic**: Connect the dots. (e.g., "High pH + Yellow Leaves usually means X"). + 4. **Conclusion**: Why is the recommended action the safest bet? + + Keep the tone professional, transparent, and educational. + """ + + try: + response = self.llm.chat.completions.create( + model="llama-3.1-8b-instant", + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": context} + ], + temperature=0.3 # Keep it factual + ) + return response.choices[0].message.content + except Exception as e: + return f"Explanation unavailable: {str(e)}" +\ No newline at end of file diff --git a/backend/server/functions.py b/backend/server/functions.py @@ -9,6 +9,7 @@ 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 Qdrant.Store import store_fmu, COLLECTION_NAME from Qdrant.Client import client @@ -16,6 +17,7 @@ from Qdrant.Client import client print("🌱 Initializing Cognitive Stack...") researcher = ResearcherAgent() supervisor = SupervisorAgent(researcher) +explainer = ExplainerAgent(supervisor.llm) print("✅ Agents Ready.") # --- HELPER: SIMULATE MINI-AGENTS --- @@ -86,30 +88,32 @@ async def process_ingest(file: UploadFile, sensors_str: str, metadata_str: str, meta_data = json.loads(metadata_str) abs_image_path = os.path.abspath(temp_filename) - # --- 🟢 NEW: Add Sequence & ID Logic (Same as process_search) --- + # --- 1. Identify Context --- target_crop = meta_data.get("crop", "Unknown") - # 1. Get Crop ID (Prefer metadata, fall back to sensor data, then auto-generate) + # 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") if not target_crop_id: target_crop_id = f"Batch_{target_crop}_{datetime.now().strftime('%Y%m')}" - # 2. Calculate Sequence Number automatically + # Calculate Sequence Number seq_num = get_next_sequence_number(target_crop_id) print(f"📥 Ingesting {target_crop_id} | Snapshot #{seq_num}") - # 3. Inject into Metadata BEFORE creating FMU + # --- 2. Inject Metadata Schema --- + # We inject 'explanation_log' here so even "Raw" snapshots match the schema meta_data.update({ "crop_id": target_crop_id, "sequence_number": seq_num, "sensor_data": sensor_data, - # Ensure placeholders exist if not provided "action_taken": meta_data.get("action_taken", "PENDING_ACTION"), - "outcome": meta_data.get("outcome", "PENDING_OBSERVATION") + "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) store_fmu(fmu) @@ -152,8 +156,10 @@ async def process_search(file: UploadFile, sensors_str: str, builder): "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" + "outcome": "PENDING_OBSERVATION", + "explanation_log": "PENDING_ANALYSIS" } # Create & Store FMU @@ -208,12 +214,32 @@ async def process_search(file: UploadFile, sensors_str: str, builder): sub_agent_outputs=mini_agent_reports ) - # --- STEP 4: Return Result + The New ID --- + # --- 🟢 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 + ) + + # Update the FMU Metadata with this log + 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 + } + ) + return { "status": "success", - "new_fmu_id": query_fmu.id, # <--- Frontend needs this for the Feedback Loop + "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 + "agent_decision": decision_json, + "explanation": explanation_log # 👈 Send to Frontend immediately } except Exception as e: diff --git a/web/app/upload/page.tsx b/web/app/upload/page.tsx @@ -4,7 +4,7 @@ import { useRef, useState } from "react"; import { Upload, Save, Activity, Droplets, Thermometer, Wind, Search, Sprout, Calendar, BarChart3, ArrowRight, Brain, ShieldCheck, - CheckCircle, AlertTriangle, Mic, Square + CheckCircle, Mic, Square } from "lucide-react"; import { SensorData, SearchResult, AgentDecision } from "@/models"; // Ensure AgentDecision is exported in models import { IngestService } from "@/services/api"; @@ -19,6 +19,9 @@ export default function UnifiedPage() { const [searchResults, setSearchResults] = useState<SearchResult[]>([]); const [textQuery, setTextQuery] = useState(""); + const [showExplanation, setShowExplanation] = useState(false); + const [explanationText, setExplanationText] = useState(""); + const [isRecording, setIsRecording] = useState(false); const mediaRecorderRef = useRef<MediaRecorder | null>(null); const chunksRef = useRef<Blob[]>([]); @@ -75,6 +78,9 @@ export default function UnifiedPage() { try { const response = await IngestService.searchFMU(file, sensors); + if (response.explanation) { + setExplanationText(response.explanation); + } setSearchResults(response.search_results || []); @@ -320,64 +326,48 @@ export default function UnifiedPage() { </div> </div> - {/* 🧠 SECTION: SUPERVISOR REASONING OUTPUT */} {decision && ( - <div className="max-w-6xl w-full mb-12 animate-in fade-in slide-in-from-top-10 duration-700"> - <div className="bg-gradient-to-r from-indigo-900/40 to-slate-900/40 border border-indigo-500/30 p-8 rounded-3xl relative overflow-hidden"> - {/* Glowing Top Border */} - <div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div> - - <div className="flex flex-col md:flex-row gap-8"> - {/* Icon Column */} - <div className="flex-shrink-0 flex flex-col items-center justify-center md:items-start space-y-2"> - <div className="w-16 h-16 bg-indigo-500/20 rounded-2xl flex items-center justify-center border border-indigo-500/30 shadow-[0_0_30px_rgba(99,102,241,0.2)]"> - <Brain className="w-8 h-8 text-indigo-300" /> - </div> - <span className="text-xs font-mono text-indigo-400 tracking-widest uppercase">Supervisor</span> - </div> - - {/* Content Column */} - <div className="flex-1 space-y-6"> - {/* Reasoning Text */} - <div className="space-y-2"> - <h3 className="text-xl font-bold text-white flex items-center gap-2"> - Analysis & Reasoning - </h3> - <p className="text-slate-300 leading-relaxed text-lg border-l-2 border-indigo-500/50 pl-4"> - {decision.reasoning} - </p> - </div> - - {/* Action & Confidence Row */} - <div className="flex flex-col md:flex-row gap-4"> - {/* Recommended Action */} - <div className="flex-1 bg-emerald-950/30 border border-emerald-500/30 p-4 rounded-xl flex items-center gap-4"> - <div className="p-2 bg-emerald-500/20 rounded-lg"> - <CheckCircle className="w-6 h-6 text-emerald-400" /> - </div> - <div> - <span className="text-xs text-emerald-500 uppercase font-bold tracking-wider">Recommended Action</span> - <p className="text-lg font-bold text-white">{decision.action}</p> - </div> - </div> - - {/* Confidence Score */} - <div className="bg-slate-900/50 border border-slate-700 p-4 rounded-xl flex items-center gap-4 min-w-[200px]"> - <div className="p-2 bg-slate-700/50 rounded-lg"> - <ShieldCheck className="w-6 h-6 text-blue-400" /> - </div> - <div> - <span className="text-xs text-slate-400 uppercase font-bold tracking-wider">Confidence</span> - <p className="text-lg font-bold text-white">{(decision.confidence * 100).toFixed(0)}%</p> - </div> - </div> - </div> - </div> - </div> - </div> + <div className="max-w-3xl w-full mb-12 bg-slate-900 border border-emerald-500/30 rounded-2xl overflow-hidden shadow-2xl shadow-emerald-900/20"> + + {/* Header */} + <div className="bg-emerald-900/20 p-4 border-b border-emerald-500/20 flex justify-between items-center"> + <h3 className="text-emerald-400 font-bold text-lg flex items-center gap-2"> + 🌱 Demeter Recommendation + </h3> + + {/* The "Why?" Button */} + <button + onClick={() => setShowExplanation(!showExplanation)} + className="text-xs text-slate-400 hover:text-white underline transition-colors flex items-center gap-1" + > + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg> + Why this output? + </button> + </div> + + {/* Main Decision Text */} + <div className="p-6"> + <p className="text-2xl text-white font-light leading-relaxed"> + {decision.reasoning || "Analyzing..."} + </p> + </div> + + {/* The Explainer Dropdown (Hidden by default) */} + {showExplanation && ( + <div className="bg-slate-950/50 p-6 border-t border-slate-800 animate-in slide-in-from-top-2"> + <h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3"> + Reasoning Log + </h4> + <div className="text-slate-300 text-sm whitespace-pre-wrap font-mono leading-relaxed opacity-90"> + {explanationText ? explanationText : ( + <span className="animate-pulse text-slate-500">Generating logic trace...</span> + )} + </div> + </div> + )} </div> )} - {/* 🧠 END REASONING SECTION */} + {/* 👆 END OF NEW COMPONENT 👆 */} {/* {decision && currentQueryId && ( <div className="mt-4 bg-slate-900 p-4 rounded-xl border border-slate-700"> <h4 className="text-white font-bold mb-2">Report Outcome</h4> diff --git a/web/models/index.ts b/web/models/index.ts @@ -27,6 +27,7 @@ export interface SearchResponse { agent_decision?: AgentDecision; new_fmu_id?: string; + explanation?: string; } // 4. Ensure SearchResult matches what Qdrant sends diff --git a/web/services/api.ts b/web/services/api.ts @@ -3,14 +3,8 @@ import { SensorData, IngestResponse, SearchResponse } from "@/models"; const API_URL = "http://localhost:8000"; // Define the payload type here or import it from models -interface FeedbackPayload { - fmu_id: string; - action: string; - outcome: string; -} export const IngestService = { - // 1. Upload Function (Existing) async uploadFMU(file: File, sensors: SensorData): Promise<IngestResponse> { const formData = new FormData();