commit a301b5fbe31e3215dee2e5f4dab6a2b544d880b7
parent a40228adf42a56246cd5cd12d61df20f2b02f37d
Author: Debarghya Das <debarghya1108@gmail.com>
Date: Sun, 1 Mar 2026 16:06:08 +0000
Merge PR
Diffstat:
14 files changed, 597 insertions(+), 114 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -3,3 +3,4 @@
node_modules/
__pycache__/
/web/node_modules
+Knowledge_Base
+\ No newline at end of file
diff --git a/Qdrant/Client.py b/Qdrant/Client.py
@@ -3,6 +3,7 @@ from qdrant_client import QdrantClient
client = QdrantClient(
url="https://2a9e6ab0-e572-4bfa-a50f-0a169f9753d3.europe-west3-0.gcp.cloud.qdrant.io:6333",
api_key="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0.RG2XaX6thvBqI6TCtUrFg8znHYbMuFGOvbxoxPgT020",
+ timeout=120
)
# print(qdrant_client.get_collections())
\ No newline at end of file
diff --git a/Qdrant/__pycache__/Client.cpython-311.pyc b/Qdrant/__pycache__/Client.cpython-311.pyc
Binary files differ.
diff --git a/agent/sub_agents/Researcher.py b/agent/sub_agents/Researcher.py
@@ -0,0 +1,66 @@
+import uuid
+from qdrant_client import models
+from fastembed import TextEmbedding
+from Qdrant.Client import client # Import your existing cloud connection
+
+class ResearcherAgent:
+ def __init__(self):
+ self.client = client
+ self.collection = "Knowledge_Base"
+ # FastEmbed is lightweight and runs locally on CPU
+ self.encoder = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")
+ self._ensure_collection()
+
+ def _ensure_collection(self):
+ """Creates the text collection if it doesn't exist."""
+ if not self.client.collection_exists(self.collection):
+ self.client.create_collection(
+ collection_name=self.collection,
+ vectors_config=models.VectorParams(
+ size=384, # bge-small uses 384 dimensions
+ distance=models.Distance.COSINE
+ )
+ )
+ print(f"š Created knowledge base: {self.collection}")
+
+ def ingest_text(self, text: str, source: str = "Manual"):
+ """Saves a chunk of text (e.g., from a PDF) into memory."""
+ # Embed the text
+ embedding = list(self.encoder.embed([text]))[0]
+
+ # Upload to Qdrant
+ self.client.upsert(
+ collection_name=self.collection,
+ points=[
+ models.PointStruct(
+ id=str(uuid.uuid4()),
+ vector=embedding.tolist(),
+ payload={"text": text, "source": source}
+ )
+ ]
+ )
+
+ def search(self, query: str, limit: int = 3) -> str:
+ """Retrieves relevant textbook pages and formats them as a string."""
+ # 1. Convert query to vector
+ query_vec = list(self.encoder.embed([query]))[0]
+
+ # 2. Search Qdrant
+ # CRITICAL FIX: Added 'with_payload=True' so we actually get the text back
+ response = self.client.query_points(
+ collection_name=self.collection,
+ query=query_vec,
+ limit=limit,
+ with_payload=True
+ )
+
+ hits = response.points
+
+ # 3. Format as a single string (better for LLM Context)
+ if not hits:
+ return "No specific scientific manuals found for this issue."
+
+ return "\n\n".join([
+ f"[Source: {hit.payload.get('source', 'Unknown')}]\n{hit.payload.get('text', '')}"
+ for hit in hits
+ ])
+\ No newline at end of file
diff --git a/agent/sub_agents/Supervisor.py b/agent/sub_agents/Supervisor.py
@@ -0,0 +1,98 @@
+import json
+import os
+from dotenv import load_dotenv
+from openai import OpenAI
+
+# Load .env file relative to this script
+current_dir = os.path.dirname(os.path.abspath(__file__))
+env_path = os.path.join(current_dir, '../../.env')
+load_dotenv(env_path)
+
+class SupervisorAgent:
+ def __init__(self, researcher_agent):
+ self.researcher = researcher_agent
+
+ # ā” CONNECT TO GROQ CLOUD
+ # CHECK: Ensure your .env file has 'GROK_API_KEY' or 'GROQ_API_KEY'
+ # We use 'GROQ_API_KEY' here based on your previous messages
+ api_key = os.getenv("GROQ_API_KEY")
+
+ if not api_key:
+ print("ā ļø WARNING: API Key not found. Supervisor may fail.")
+
+ self.llm = OpenAI(
+ base_url="https://api.groq.com/openai/v1",
+ api_key=api_key
+ )
+
+ def reason(self, current_fmu, similar_fmus, sub_agent_outputs):
+ """
+ The Core Reasoning Loop:
+ 1. Contextualize -> 2. Research -> 3. Synthesize -> 4. Decide
+ """
+
+ # --- STEP 1: Formulate the Research Question ---
+ crop = current_fmu['metadata'].get('crop', 'Unknown Crop')
+ stage = current_fmu['metadata'].get('stage', 'Unknown Stage')
+
+ # E.g., "Lettuce Vegetative Low pH issues"
+ research_query = f"{crop} {stage} {sub_agent_outputs.get('nutrient_analysis', '')} issues"
+
+ print(f"š¤ Supervisor is asking Researcher: '{research_query}'")
+
+ # --- STEP 2: The Researcher Fetches Evidence (RAG) ---
+ # This now returns a clean STRING, not a list
+ scientific_context = self.researcher.search(research_query)
+
+ # --- STEP 3: Synthesize History (Memory) ---
+ history_context = "\n".join([
+ f"- Previous Case (Score {f['score']:.2f}): {f['payload'].get('outcome', 'No outcome recorded')}"
+ for f in similar_fmus
+ ])
+
+ # --- STEP 4: The Final Prompt ---
+ system_prompt = """
+ You are the Chief Supervisor AI of a Hydroponic Facility.
+ Your goal: Synthesize conflicting data to recommend the OPTIMAL action.
+
+ PRINCIPLES:
+ 1. Plant Health is Priority #1.
+ 2. Verify Sub-Agent claims against the SCIENTIFIC KNOWLEDGE provided.
+ 3. If History contradicts Science, prefer Science (Manuals), but note the anomaly.
+ """
+
+ user_message = f"""
+ ### SITUATION REPORT
+ Target: {crop} ({stage})
+ Sensors: {current_fmu['payload']['sensors']}
+
+ ### SUB-AGENT ALERTS
+ {json.dumps(sub_agent_outputs, indent=2)}
+
+ ### SCIENTIFIC KNOWLEDGE (Verified Manuals)
+ {scientific_context}
+
+ ### HISTORICAL MEMORY (Similar Past Events)
+ {history_context}
+
+ ### COMMAND
+ Analyze the situation. Resolve conflicts between agents using the Manuals.
+ Output JSON: {{ "reasoning": "...", "action": "...", "confidence": 0.0-1.0 }}
+ """
+
+ # --- STEP 5: Execute Reasoning on Groq ---
+ try:
+ response = self.llm.chat.completions.create(
+ # We use Llama-3.1-8b because it is fast and smart enough for this logic
+ model="llama-3.1-8b-instant",
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_message}
+ ],
+ temperature=0.1, # Low temp for strict logic
+ response_format={"type": "json_object"} # Force valid JSON
+ )
+ return json.loads(response.choices[0].message.content)
+
+ except Exception as e:
+ return {"error": str(e), "reasoning": "Groq Connection Failed"}
+\ No newline at end of file
diff --git a/agent/sub_agents/action_orchestrator.py b/agent/sub_agents/action_orchestrator.py
diff --git a/agent/sub_agents/researcher_agent.py b/agent/sub_agents/researcher_agent.py
diff --git a/agent/sub_agents/test_pipeline.py b/agent/sub_agents/test_pipeline.py
@@ -0,0 +1,43 @@
+import sys
+import os
+# --- PATH FIX: Add project root to system path ---
+# This ensures Python can see 'agent', 'Qdrant', 'Sentinel' from anywhere
+current_dir = os.path.dirname(os.path.abspath(__file__))
+project_root = os.path.abspath(os.path.join(current_dir, '../../'))
+sys.path.append(project_root)
+# -------------------------------------------------
+
+from agent.sub_agents.Researcher import ResearcherAgent
+from agent.sub_agents.Supervisor import SupervisorAgent
+
+# 1. Setup Agents
+print("š± Waking up Agents...")
+researcher = ResearcherAgent()
+supervisor = SupervisorAgent(researcher)
+
+# 2. Seed some dummy knowledge (Run this once)
+print("š Ingesting Knowledge...")
+researcher.ingest_text(
+ "Lettuce in vegetative stage requires pH between 5.5 and 6.5. "
+ "Yellowing leaves often indicate Nitrogen deficiency or low pH lockout."
+)
+
+# 3. Mock Data (Simulate what the Backend would send)
+mock_fmu = {
+ "metadata": {"crop": "Lettuce", "stage": "Vegetative"},
+ "payload": {"sensors": {"pH": 4.2, "EC": 1.2}}
+}
+mock_similar_fmus = [
+ {"score": 0.88, "payload": {"outcome": "Recovered after adding pH Up"}}
+]
+mock_sub_agents = {
+ "nutrient_analysis": "Critical Low pH detected.",
+ "resource_status": "Water tank at 15%."
+}
+
+# 4. Run Reasoning
+print("š§ Supervisor is thinking...")
+decision = supervisor.reason(mock_fmu, mock_similar_fmus, mock_sub_agents)
+
+print("\n=== FINAL DECISION ===")
+print(decision)
+\ No newline at end of file
diff --git a/backend/server/functions.py b/backend/server/functions.py
@@ -1,58 +1,101 @@
+import os
+import shutil
import json
+import traceback
+from fastapi import UploadFile
from qdrant_client.http import models
+
+# --- AGENT IMPORTS ---
+from agent.sub_agents.Researcher import ResearcherAgent
+from agent.sub_agents.Supervisor import SupervisorAgent
from Qdrant.Store import store_fmu, COLLECTION_NAME
from Qdrant.Client import client
-async def process_ingest(image_base64: str, sensors_str: str, metadata_str: str, builder):
+# Initialize Agents ONCE (Global Scope) to save memory
+print("š± Initializing Cognitive Stack...")
+researcher = ResearcherAgent()
+supervisor = SupervisorAgent(researcher)
+print("ā
Agents Ready.")
+
+# --- HELPER: SIMULATE MINI-AGENTS ---
+# In production, these would be your actual imported classes from agent/sub_agents/
+def simulate_sub_agents(sensors):
+ """
+ Generates 'Expert Opinions' based on raw sensor data.
+ """
+ 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."
+
+ return reports
+
+async def process_ingest(file: UploadFile, sensors_str: str, metadata_str: str, builder):
"""
- Handles FMU creation and storage logic using base64 image.
- No more temporary files!
+ Handles file saving, FMU creation, and storage logic.
"""
+ temp_filename = f"temp_{file.filename}"
+ with open(temp_filename, "wb") as buffer:
+ shutil.copyfileobj(file.file, buffer)
+
try:
- # 1. Parse Data
sensor_data = json.loads(sensors_str)
meta_data = json.loads(metadata_str)
-
- # 2. Create FMU directly from base64
- print(f"š” Creating FMU from base64 image...")
- fmu = builder.create_fmu(image_base64, sensor_data, meta_data)
-
- # 3. Store in Cloud
+ abs_image_path = os.path.abspath(temp_filename)
+
+ fmu = builder.create_fmu(abs_image_path, sensor_data, meta_data)
store_fmu(fmu)
- print(f"ā
FMU stored successfully: {fmu.id}")
return {"status": "success", "fmu_id": fmu.id}
- except Exception as e:
- print(f"ā Ingest processing error: {e}")
- raise
+ finally:
+ if os.path.exists(temp_filename):
+ os.remove(temp_filename)
-async def process_search(image_base64: str, sensors_str: str, builder):
+async def process_search(file: UploadFile, sensors_str: str, builder):
"""
- Handles image processing, context extraction, and filtered Qdrant search.
- Uses base64 image instead of temporary files.
+ 1. Search Similar FMUs (Memory)
+ 2. Consult Researcher (Knowledge)
+ 3. Run Supervisor (Reasoning)
"""
+ 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)
+ abs_image_path = os.path.abspath(temp_filename)
# --- STEP 1: Context Extraction ---
- target_crop = sensor_data.get("crop")
- target_stage = sensor_data.get("stage")
+ target_crop = sensor_data.get("crop", "Unknown")
+ target_stage = sensor_data.get("stage", "Unknown")
+ print(f"š Pipeline triggered for: {target_crop} ({target_stage})")
- if not target_crop or not target_stage:
- return {"status": "error", "message": f"Missing crop/stage in: {sensor_data}"}
-
- print(f"š Context: Searching for {target_crop} ({target_stage})...")
-
- # --- STEP 2: Separate Numeric Data vs Metadata ---
- numeric_sensors = {
- "pH": sensor_data.get("pH"),
- "EC": sensor_data.get("EC"),
- "temp": sensor_data.get("temp"),
- "humidity": sensor_data.get("humidity")
- }
+ # --- STEP 2: Vector Search (Memory) ---
+ # Separate numeric data for vector construction
+ numeric_sensors = {k: v for k, v in sensor_data.items() if k in ["pH", "EC", "temp", "humidity"]}
metadata = {"crop": target_crop, "stage": target_stage}
- # --- STEP 3: Create Filter ---
+ # Create Filter
context_filter = models.Filter(
must=[
models.FieldCondition(key="crop", match=models.MatchValue(value=target_crop)),
@@ -60,45 +103,62 @@ async def process_search(image_base64: str, sensors_str: str, builder):
]
)
- # --- STEP 4: Generate Vector from base64 ---
- print(f"š§ Generating query vector from base64 image...")
- query_fmu = builder.create_fmu(image_base64, numeric_sensors, metadata=metadata)
+ # Create Vector & Search
+ query_fmu = builder.create_fmu(abs_image_path, numeric_sensors, metadata=metadata)
query_vector = query_fmu.vector.tolist() if hasattr(query_fmu.vector, 'tolist') else query_fmu.vector
- # --- STEP 5: Search ---
try:
- print(f"š Searching Qdrant with filters...")
response = client.query_points(
collection_name=COLLECTION_NAME,
query=query_vector,
query_filter=context_filter,
- limit=5,
+ limit=3, # Get top 3 similar cases
with_payload=True
)
hits = response.points
- print(f"ā
Found {len(hits)} matches")
- except Exception as filter_error:
- # Fallback for missing indexes
- if "Index required" in str(filter_error):
- print("ā ļø Payload indexes missing. Falling back to unfiltered search.")
- print("š” Run 'python create_indexes.py' to enable filtered searches.")
- response = client.search(
- collection_name=COLLECTION_NAME,
- query_vector=query_vector,
- limit=5,
- with_payload=True
- )
- hits = response.points
- else:
- raise filter_error
-
- # Format Results
- results = [
- {"id": hit.id, "score": hit.score, "payload": hit.payload}
- for hit in hits
+ except Exception:
+ # Fallback to unfiltered if index missing
+ print("ā ļø Filter failed, searching raw vectors...")
+ hits = client.search(collection_name=COLLECTION_NAME, query_vector=query_vector, limit=3, with_payload=True)
+
+ # Format Memory for the Supervisor
+ similar_fmus_formatted = [
+ {"score": hit.score, "payload": hit.payload} for hit in hits
]
- return {"results": results}
+
+ # --- STEP 3: The Reasoning Cycle ---
+ print("š§ Invoking Supervisor Agent...")
+
+ # A. Get Expert Opinions
+ mini_agent_reports = simulate_sub_agents(numeric_sensors)
+
+ # B. Construct the Current FMU object for the Supervisor
+ current_fmu_context = {
+ "metadata": metadata,
+ "payload": {"sensors": numeric_sensors}
+ }
+
+ # C. Run the Supervisor Logic (RAG + Groq)
+ decision_json = supervisor.reason(
+ current_fmu=current_fmu_context,
+ similar_fmus=similar_fmus_formatted,
+ sub_agent_outputs=mini_agent_reports
+ )
+
+ # --- STEP 4: Return Combined Result ---
+ return {
+ "status": "success",
+ "search_results": [
+ {"id": hit.id, "score": hit.score, "payload": hit.payload} for hit in hits
+ ],
+ "agent_decision": decision_json # <--- The frontend will render this!
+ }
except Exception as e:
- print(f"ā Search processing error: {e}")
- raise
-\ No newline at end of file
+ print(f"ā Pipeline Error: {e}")
+ traceback.print_exc()
+ return {"status": "error", "message": str(e)}
+
+ finally:
+ if os.path.exists(temp_filename):
+ os.remove(temp_filename)
+\ No newline at end of file
diff --git a/backend/server/main.py b/backend/server/main.py
@@ -12,7 +12,7 @@ from fastapi import FastAPI, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from Sentinel.agent import FMUBuilder
-# Import the new logic functions
+# Import the logic functions
from backend.server.functions import process_ingest, process_search
app = FastAPI()
@@ -30,12 +30,7 @@ print("š± Initializing Demeter Agents...")
builder = FMUBuilder()
print("ā
Agents Ready.")
-async def file_to_base64(file: UploadFile) -> str:
- """Convert uploaded file to base64 string"""
- contents = await file.read()
- base64_string = base64.b64encode(contents).decode('utf-8')
- await file.seek(0) # Reset file pointer in case it's needed again
- return base64_string
+# (Helper function removed as it is no longer needed for these endpoints)
@app.post("/ingest")
async def ingest_endpoint(
@@ -44,10 +39,8 @@ async def ingest_endpoint(
metadata: str = Form(...)
):
try:
- # Convert file to base64
- image_base64 = await file_to_base64(file)
- # Pass the base64 string to the process function
- return await process_ingest(image_base64, sensors, metadata, builder)
+ # 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
@@ -60,10 +53,8 @@ async def search_endpoint(
sensors: str = Form(...)
):
try:
- # Convert file to base64
- image_base64 = await file_to_base64(file)
- # Pass the base64 string to the process function
- return await process_search(image_base64, sensors, builder)
+ # FIX: Pass the 'file' object directly.
+ return await process_search(file, sensors, builder)
except Exception as e:
print(f"ā Search Error: {e}")
import traceback
diff --git a/backend/server/rag_brain.py b/backend/server/rag_brain.py
@@ -0,0 +1,132 @@
+import sys
+import os
+import uuid
+import pypdf
+from qdrant_client import models
+from fastembed import TextEmbedding
+
+# --- PATH FIX: Add project root to system path ---
+# This ensures we can import your 'Qdrant.Client' connection
+current_dir = os.path.dirname(os.path.abspath(__file__))
+project_root = os.path.abspath(os.path.join(current_dir, '../../'))
+sys.path.append(project_root)
+# -------------------------------------------------
+
+from Qdrant.Client import client # Uses your existing Cloud connection
+
+# --- CONFIGURATION ---
+COLLECTION_NAME = "Knowledge_Base"
+VECTOR_SIZE = 384 # Standard size for 'bge-small-en-v1.5'
+DOCS_FOLDER = os.path.join(project_root, "Knowledge_Base") # <--- Folder Name
+
+def init_collection():
+ """
+ Creates the collection if it doesn't exist.
+ """
+ if client.collection_exists(COLLECTION_NAME):
+ print(f"ā¹ļø Collection '{COLLECTION_NAME}' already exists. Appending data...")
+ else:
+ print(f"šØ Creating new collection: {COLLECTION_NAME}")
+ client.create_collection(
+ collection_name=COLLECTION_NAME,
+ vectors_config=models.VectorParams(
+ size=VECTOR_SIZE,
+ distance=models.Distance.COSINE
+ )
+ )
+ print("ā
Collection created.")
+
+def extract_text_from_pdf(pdf_path):
+ """
+ Reads a PDF file page by page and returns the full text.
+ """
+ text = ""
+ try:
+ reader = pypdf.PdfReader(pdf_path)
+ for page in reader.pages:
+ page_text = page.extract_text()
+ if page_text:
+ text += page_text + "\n"
+ except Exception as e:
+ print(f"ā Error reading PDF {pdf_path}: {e}")
+ return text
+
+def chunk_text(text, chunk_size=500, overlap=50):
+ """
+ Splits long text into smaller overlapping pieces.
+ Overlap helps preserve context between chunks.
+ """
+ if not text:
+ return []
+ return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size - overlap)]
+
+def ingest_docs():
+ # 1. Setup Collection & Model
+ init_collection()
+
+ print("š§ Loading Embedding Model (bge-small-en)...")
+ # This runs locally on your CPU (Fast & Free)
+ model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")
+
+ # 2. Check if folder exists
+ if not os.path.exists(DOCS_FOLDER):
+ os.makedirs(DOCS_FOLDER)
+ print(f"ā ļø Created folder '{DOCS_FOLDER}'. Please put your PDFs there and run this script again!")
+ return
+
+ # 3. Scan for files
+ files = [f for f in os.listdir(DOCS_FOLDER) if f.endswith(('.pdf', '.txt'))]
+ if not files:
+ print(f"š No files found in '{DOCS_FOLDER}'. Add some PDFs!")
+ return
+
+ print(f"š Found {len(files)} documents. Starting ingestion...")
+ total_chunks = 0
+
+ for file_name in files:
+ file_path = os.path.join(DOCS_FOLDER, file_name)
+ print(f" š Processing: {file_name}")
+
+ # A. Extract Text
+ content = ""
+ if file_name.endswith('.pdf'):
+ content = extract_text_from_pdf(file_path)
+ else:
+ with open(file_path, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ if not content.strip():
+ print(f" ā ļø Skipping empty file.")
+ continue
+
+ # B. Chunk Text
+ chunks = chunk_text(content)
+ if not chunks:
+ continue
+
+ # C. Convert to Vectors (Embed)
+ # FastEmbed handles the list of strings automatically
+ embeddings = list(model.embed(chunks))
+
+ # D. Prepare Points for Qdrant
+ points = []
+ for i, (text_chunk, vector) in enumerate(zip(chunks, embeddings)):
+ points.append(models.PointStruct(
+ id=str(uuid.uuid4()), # Generate a random ID for this chunk
+ vector=vector.tolist(),
+ payload={
+ "text": text_chunk,
+ "source": file_name,
+ "chunk_id": i
+ }
+ ))
+
+ # E. Upload Batch
+ client.upsert(collection_name=COLLECTION_NAME, points=points)
+ total_chunks += len(points)
+ print(f" ā
Uploaded {len(points)} chunks.")
+
+ print(f"\nš Success! Knowledge Base now contains {total_chunks} searchable segments.")
+
+if __name__ == "__main__":
+ ingest_docs()
+\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
@@ -15,6 +15,10 @@ torchvision
ftfy
regex
tqdm
+fastembed
+openai
+pypdf
+
# --- OpenAI CLIP (Vision Encoder) ---
# This installs directly from GitHub because it's not on standard PyPI
diff --git a/web/app/upload/page.tsx b/web/app/upload/page.tsx
@@ -1,21 +1,26 @@
"use client";
import { useState } from "react";
-import { Upload, Save, Activity, Droplets, Thermometer, Wind, Search, Sprout, Calendar, BarChart3, ArrowRight } from "lucide-react";
-import { SensorData, SearchResult } from "@/models";
-import { IngestService } from "@/services/api"; // Ensure you have this service file created
+import {
+ Upload, Save, Activity, Droplets, Thermometer, Wind, Search,
+ Sprout, Calendar, BarChart3, ArrowRight, Brain, ShieldCheck,
+ CheckCircle, AlertTriangle
+} from "lucide-react";
+import { SensorData, SearchResult, AgentDecision } from "@/models"; // Ensure AgentDecision is exported in models
+import { IngestService } from "@/services/api";
export default function UnifiedPage() {
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
- // Separate loading states to show which button is working
const [loadingIngest, setLoadingIngest] = useState(false);
const [loadingSearch, setLoadingSearch] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
+
+ // š§ State for the Supervisor's Output
+ const [decision, setDecision] = useState<AgentDecision | null>(null);
- // Sensor State
const [sensors, setSensors] = useState<SensorData>({
pH: "6.0",
EC: "1.2",
@@ -32,7 +37,8 @@ export default function UnifiedPage() {
const selected = e.target.files[0];
setFile(selected);
setPreview(URL.createObjectURL(selected));
- setSearchResults([]); // Clear old results on new file
+ setSearchResults([]);
+ setDecision(null); // Clear old reasoning when new image is picked
}
};
@@ -43,15 +49,12 @@ export default function UnifiedPage() {
const handleIngest = async () => {
if (!file) return alert("Please select an image first.");
setLoadingIngest(true);
-
try {
await IngestService.uploadFMU(file, sensors);
alert("ā
FMU Created & Stored Successfully!");
- // Optional: Clear form after success?
- // setFile(null); setPreview(null); setSearchResults([]);
} catch (error) {
- console.log(error);
- alert("ā Ingest Failed. Is the backend running?");
+ console.error(error);
+ alert("ā Ingest Failed. Check console.");
} finally {
setLoadingIngest(false);
}
@@ -60,13 +63,24 @@ export default function UnifiedPage() {
const handleSearch = async () => {
if (!file) return alert("Please select an image to search with.");
setLoadingSearch(true);
+ setDecision(null); // Clear previous decision while thinking...
try {
const response = await IngestService.searchFMU(file, sensors);
- setSearchResults(response.results || []);
- if (response.results.length === 0) alert("No similar memories found.");
+
+ setSearchResults(response.search_results || []);
+
+ // š§ Capture the Agent Decision from Backend
+ if (response.agent_decision) {
+ setDecision(response.agent_decision);
+ }
+
+ if ((response.search_results || []).length === 0 && !response.agent_decision) {
+ alert("No similar memories or insights found.");
+ }
} catch (error) {
- alert("ā Search Failed. Is the backend running?");
+ console.error(error);
+ alert("ā Search Failed. Check console.");
} finally {
setLoadingSearch(false);
}
@@ -78,7 +92,7 @@ export default function UnifiedPage() {
<main className="min-h-screen bg-slate-950 text-slate-200 p-8 flex flex-col items-center">
{/* Top Section: Control Panel */}
- <div className="max-w-6xl w-full grid grid-cols-1 lg:grid-cols-2 gap-12 mb-16">
+ <div className="max-w-6xl w-full grid grid-cols-1 lg:grid-cols-2 gap-12 mb-12">
{/* Left: Image Input */}
<div className="space-y-6">
@@ -102,16 +116,14 @@ export default function UnifiedPage() {
</div>
</div>
- {/* Right: Sensor Inputs & Actions */}
+ {/* Right: Inputs & Actions */}
<div className="space-y-8 flex flex-col justify-center">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Demeter Control</h1>
<p className="text-slate-500">Ingest new data or query the Historian Agent.</p>
</div>
- {/* Sensor Grid */}
<div className="grid grid-cols-2 gap-4">
- {/* Helper function to render inputs cleanly */}
{[
{ label: "pH Level", name: "pH", icon: Droplets, color: "text-emerald-400" },
{ label: "EC (mS/cm)", name: "EC", icon: Activity, color: "text-yellow-400" },
@@ -135,7 +147,6 @@ export default function UnifiedPage() {
))}
</div>
- {/* Metadata Selects */}
<div className="grid grid-cols-2 gap-4">
<select name="crop" value={sensors.crop} onChange={handleInputChange} className="bg-slate-900 border border-slate-800 text-slate-300 rounded-xl p-3 outline-none">
<option>Lettuce</option><option>Basil</option><option>Tomato</option>
@@ -145,7 +156,6 @@ export default function UnifiedPage() {
</select>
</div>
- {/* --- Action Buttons --- */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-slate-800">
<button
onClick={handleIngest}
@@ -160,22 +170,82 @@ export default function UnifiedPage() {
disabled={loadingIngest || loadingSearch}
className="py-4 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-blue-900/20 disabled:opacity-50 flex items-center justify-center space-x-2"
>
- {loadingSearch ? <Activity className="animate-spin w-5 h-5" /> : <><Search className="w-5 h-5" /> <span>Search Archives</span></>}
+ {loadingSearch ? <Activity className="animate-spin w-5 h-5" /> : <><Search className="w-5 h-5" /> <span>Search & Reason</span></>}
</button>
</div>
</div>
</div>
- {/* Bottom Section: Search Results (Conditional Render) */}
+ {/* š§ 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>
+ )}
+ {/* š§ END REASONING SECTION */}
+
+
+ {/* Bottom Section: Search Results */}
{searchResults.length > 0 && (
<div className="max-w-6xl w-full animate-in fade-in slide-in-from-bottom-10 duration-500">
<div className="flex items-center justify-between border-b border-slate-800 pb-4 mb-8">
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Search className="w-6 h-6 text-blue-500" />
- Retrieved Evidence
+ Retrieved Memory (Similar Cases)
</h2>
<span className="text-xs font-mono text-blue-400 bg-blue-500/10 px-3 py-1 rounded-full border border-blue-500/20">
- {searchResults.length} SIMILAR CASES
+ {searchResults.length} RECORDS
</span>
</div>
@@ -202,7 +272,7 @@ export default function UnifiedPage() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2"><BarChart3 className="w-4 h-4" /> Sensors</div>
<span className="font-mono text-xs text-slate-300">
- pH: {res.payload.sensors?.pH} | EC: {res.payload.sensors?.EC}
+ pH: {res.payload.sensors?.pH} | EC: {res.payload.sensors?.EC}
</span>
</div>
</div>
diff --git a/web/models/index.ts b/web/models/index.ts
@@ -1,3 +1,6 @@
+// src/models/index.ts (or wherever your types are)
+
+// 1. Keep SensorData and others as they are...
export interface SensorData {
pH: string;
EC: string;
@@ -7,31 +10,40 @@ export interface SensorData {
stage: string;
}
-export interface IngestResponse {
- status: "success" | "error";
- fmu_id?: string;
- message?: string;
+// 2. Add the new Decision Type
+export interface AgentDecision {
+ reasoning: string;
+ action: string;
+ confidence: number;
+}
+
+// 3. Update SearchResponse to match the new Backend output
+export interface SearchResponse {
+ status: string;
+ // The backend now returns "search_results" instead of just "results"
+ search_results: SearchResult[];
+ // The new AI Brain output
+ agent_decision?: AgentDecision;
}
-// New Types for Search
+// 4. Ensure SearchResult matches what Qdrant sends
export interface SearchResult {
id: string;
score: number;
payload: {
crop: string;
stage: string;
- timestamp: string;
+ timestamp?: string;
sensors?: {
pH: number;
EC: number;
- temp: number;
- humidity: number;
- }
+ };
+ [key: string]: any; // Allow for other flexible fields
};
}
-export interface SearchResponse {
- results: SearchResult[];
- status?: string;
+export interface IngestResponse {
+ status: "success" | "error";
+ fmu_id?: string
message?: string;
}
\ No newline at end of file