commit f734d5ffd2193be4befaa6781481afe53d03ece4
parent 7bea89e6e7e2ce3bdaf54b1901c217d81b776431
Author: Debarghya Das <debarghya1108@gmail.com>
Date: Mon, 2 Mar 2026 20:15:28 +0000
Merge PR
Diffstat:
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();