commit 15201611237d6da7fd58a87e7015080821dc9727
parent 756e9917c24a850a845d0d097742187736f0bc28
Author: Debarghya Das <debarghya1108@gmail.com>
Date: Mon, 2 Mar 2026 00:32:56 +0000
Merge PR
Diffstat:
7 files changed, 371 insertions(+), 57 deletions(-)
diff --git a/agent/Sentinel/agent.py b/agent/Sentinel/agent.py
@@ -42,13 +42,30 @@ class FMUBuilder:
# Combine vectors
fmu_vector = np.concatenate([img_vec, sensor_vec]).tolist()
+ # --- UPDATE START ---
+ # 1. Ensure metadata is a dictionary
+ if metadata is None:
+ metadata = {}
+
+ # 2. Construct the full payload for Qdrant
+ # We merge sensor data + metadata + new schema fields
+ final_payload = {
+ "timestamp": datetime.utcnow().isoformat(),
+ "sensors": sensor_data, # Critical: Store raw values for Frontend display
+ **metadata, # Unpack crop, stage, etc.
+ "crop_id": metadata.get("crop_id", "UNKNOWN_CROP"),
+ "sequence_number": metadata.get("sequence_number", 1),
+
+ # š NEW SCHEMA PARAMETERS (Initialized with Placeholders)
+ "action_taken": metadata.get("action_taken", "PENDING_ACTION"),
+ "outcome": metadata.get("outcome", "PENDING_OBSERVATION")
+ }
+ # --- UPDATE END ---
+
return FMU(
id=str(uuid.uuid4()),
vector=fmu_vector,
- metadata={
- **(metadata or {}),
- "timestamp": datetime.utcnow().isoformat(),
- }
+ metadata=final_payload # This becomes the Qdrant Payload
)
def _is_base64(self, s):
@@ -100,8 +117,6 @@ class FMUBuilder:
image_stream = io.BytesIO(image_bytes)
# Encode using VisionEncoder
- # If VisionEncoder only accepts paths, you may need to update it
- # to also accept BytesIO objects or PIL Images
return self.vision.encode(image_stream)
@@ -125,7 +140,9 @@ if __name__ == "__main__":
print("ā
FMU ID:", fmu.id)
print("ā
Vector length:", len(fmu.vector))
- print("ā
Metadata:", fmu.metadata)
-
- # Test with file path
- # fmu2 = builder.create_fmu("path/to/image.png", sensors, {"crop": "basil"})
-\ No newline at end of file
+
+ # Check for the new fields in the output
+ print("\nš Checking Schema:")
+ print(f" - Action: {fmu.metadata.get('action_taken')}")
+ print(f" - Outcome: {fmu.metadata.get('outcome')}")
+ print(f" - Sensors Saved: {'sensors' in fmu.metadata}")
+\ No newline at end of file
diff --git a/backend/server/fix.py b/backend/server/fix.py
@@ -0,0 +1,53 @@
+import sys
+import os
+from dotenv import load_dotenv # Ensure you have python-dotenv installed
+
+# 1. Get the folder where this script lives
+current_dir = os.path.dirname(os.path.abspath(__file__))
+
+# 2. Go up two levels to find the Project Root (Code/)
+project_root = os.path.abspath(os.path.join(current_dir, '../../'))
+
+# 3. Add Project Root to Python Path
+sys.path.append(project_root)
+
+# 4. š„ FORCE LOAD THE .ENV FILE š„
+# This must happen BEFORE importing Qdrant.Client
+env_path = os.path.join(project_root, '.env')
+if os.path.exists(env_path):
+ print(f"ā
Loading environment from: {env_path}")
+ load_dotenv(env_path)
+else:
+ print("ā ļø WARNING: .env file not found at project root!")
+
+# 5. NOW Import Client (It will see the loaded variables)
+from Qdrant.Client import client
+from qdrant_client.http import models
+
+def create_indexes():
+ print(f"š§ Optimizing collection Farm_Memory...")
+
+ # 1. Create Index for crop_id
+ try:
+ client.create_payload_index(
+ collection_name="Farm_Memory",
+ field_name="crop_id",
+ field_schema=models.PayloadSchemaType.KEYWORD
+ )
+ print("ā
Index created for 'crop_id'")
+ except Exception as e:
+ print(f"ā¹ļø Note on crop_id: {e}")
+
+ # 2. Create Index for outcome
+ try:
+ client.create_payload_index(
+ collection_name="Farm_Memory",
+ field_name="outcome",
+ field_schema=models.PayloadSchemaType.KEYWORD
+ )
+ print("ā
Index created for 'outcome'")
+ except Exception as e:
+ print(f"ā¹ļø Note on outcome: {e}")
+
+if __name__ == "__main__":
+ create_indexes()
+\ No newline at end of file
diff --git a/backend/server/functions.py b/backend/server/functions.py
@@ -4,6 +4,7 @@ import json
import traceback
from fastapi import UploadFile
from qdrant_client.http import models
+from datetime import datetime
# --- AGENT IMPORTS ---
from agent.sub_agents.Researcher import ResearcherAgent
@@ -19,6 +20,28 @@ print("ā
Agents Ready.")
# --- HELPER: SIMULATE MINI-AGENTS ---
# In production, these would be your actual imported classes from agent/sub_agents/
+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)
+ )
+ ]
+ )
+ )
+ return count_result.count + 1
+ except Exception as e:
+ print(f"ā ļø Could not calculate sequence: {e}")
+ return 1
+
def simulate_sub_agents(sensors):
"""
Generates 'Expert Opinions' based on raw sensor data.
@@ -73,9 +96,9 @@ async def process_ingest(file: UploadFile, sensors_str: str, metadata_str: str,
async def process_search(file: UploadFile, sensors_str: str, builder):
"""
- 1. Search Similar FMUs (Memory)
- 2. Consult Researcher (Knowledge)
- 3. Run Supervisor (Reasoning)
+ 1. Create & Save FMU (Placeholder State)
+ 2. Search Memory
+ 3. Run Supervisor
"""
temp_filename = f"temp_search_{file.filename}"
with open(temp_filename, "wb") as buffer:
@@ -85,17 +108,38 @@ async def process_search(file: UploadFile, sensors_str: str, builder):
sensor_data = json.loads(sensors_str)
abs_image_path = os.path.abspath(temp_filename)
- # --- STEP 1: Context Extraction ---
+ 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")
- print(f"š Pipeline triggered for: {target_crop} ({target_stage})")
- # --- 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}
+
+ # š 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')}")
+
+ # š NEW: Calculate Sequence
+ seq_num = get_next_sequence_number(target_crop_id)
+ print(f"š¢ Processing {target_crop_id} | Snapshot #{seq_num}")
- # Create Filter
+ metadata = {
+ "crop": target_crop,
+ "stage": sensor_data.get("stage", "Unknown"),
+ "crop_id": target_crop_id, # <--- Added
+ "sequence_number": seq_num, # <--- Added
+ "action_taken": "PENDING_USER_ACTION",
+ "outcome": "PENDING_OBSERVATION"
+ }
+
+ # Create & Store FMU
+ query_fmu = builder.create_fmu(abs_image_path, numeric_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)),
@@ -103,55 +147,48 @@ async def process_search(file: UploadFile, sensors_str: str, builder):
]
)
- # 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
-
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=3, # Get top 3 similar cases
+ 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]
+
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)
+ 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]
- # Format Memory for the Supervisor
- similar_fmus_formatted = [
- {"score": hit.score, "payload": hit.payload} for hit in hits
- ]
+ similar_fmus_formatted = [{"score": hit.score, "payload": hit.payload} for hit in hits]
# --- 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 ---
+ # --- STEP 4: Return Result + The New ID ---
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!
+ "new_fmu_id": query_fmu.id, # <--- Frontend needs this for the Feedback Loop
+ "search_results": [{"id": h.id, "score": h.score, "payload": h.payload} for h in hits],
+ "agent_decision": decision_json
}
except Exception as e:
@@ -161,4 +198,95 @@ async def process_search(file: UploadFile, sensors_str: str, builder):
finally:
if os.path.exists(temp_filename):
- os.remove(temp_filename)
-\ No newline at end of file
+ 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 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. If user says "poor health", "bad", "failed" or similar negative words, map to outcome="Negative".
+ 2. If user says "good", "healthy" or other positive words, map to outcome="Positive".
+ 3. Output strictly JSON matching this structure:
+ {
+ "must": [
+ {"key": "field_name", "match": "value"}
+ ]
+ }
+ 4. 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"])
+ )
+ )
+
+ # 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,
+ scroll_filter=scroll_filter,
+ limit=10,
+ with_payload=True
+ )
+ else:
+ # No filters found, return latest
+ results = client.scroll(
+ collection_name=COLLECTION_NAME,
+ limit=10,
+ with_payload=True
+ )
+
+ points = results[0] # Scroll returns (points, offset)
+
+ return {
+ "status": "success",
+ "results": [{"id": p.id, "payload": p.payload} for p in points]
+ }
+
+ except Exception as e:
+ return {"status": "error", "message": str(e)}
+\ No newline at end of file
diff --git a/backend/server/main.py b/backend/server/main.py
@@ -13,8 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
from Sentinel.agent import FMUBuilder
# Import the logic functions
-from backend.server.functions import process_ingest, process_search
-
+from backend.server.functions import process_ingest, process_search, process_text_query
app = FastAPI()
app.add_middleware(
@@ -61,6 +60,10 @@ async def search_endpoint(
traceback.print_exc()
return {"status": "error", "message": str(e)}
+@app.post("/query-text")
+async def text_query_endpoint(query: str = Form(...)):
+ return await process_text_query(query)
+
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
\ No newline at end of file
diff --git a/web/app/upload/page.tsx b/web/app/upload/page.tsx
@@ -17,6 +17,7 @@ export default function UnifiedPage() {
const [loadingSearch, setLoadingSearch] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
+ const [textQuery, setTextQuery] = useState("");
// š§ State for the Supervisor's Output
const [decision, setDecision] = useState<AgentDecision | null>(null);
@@ -60,27 +61,67 @@ export default function UnifiedPage() {
}
};
+ // Add state to store the ID of the current query
+ const [currentQueryId, setCurrentQueryId] = useState<string | null>(null);
+
const handleSearch = async () => {
if (!file) return alert("Please select an image to search with.");
setLoadingSearch(true);
- setDecision(null); // Clear previous decision while thinking...
+ setDecision(null);
try {
const response = await IngestService.searchFMU(file, sensors);
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.");
+
+ // š Capture the ID of the newly created FMU
+ if (response.new_fmu_id) {
+ setCurrentQueryId(response.new_fmu_id);
+ console.log("Query Logged with ID:", response.new_fmu_id);
}
+
} catch (error) {
console.error(error);
- alert("ā Search Failed. Check console.");
+ alert("ā Search Failed.");
+ } finally {
+ setLoadingSearch(false);
+ }
+ };
+
+ const handleTextQuery = async () => {
+ if (!textQuery) return;
+ setLoadingSearch(true);
+ setSearchResults([]); // Clear old results
+
+ try {
+ const formData = new FormData();
+ formData.append("query", textQuery);
+
+ const res = await fetch("http://localhost:8000/query-text", {
+ method: "POST",
+ body: formData
+ });
+ const data = await res.json();
+
+ if (data.results) {
+ // Map the "scroll" results to your "search result" format
+ // @ts-ignore
+ const mappedResults = data.results.map(r => ({
+ id: r.id,
+ score: 1.0, // Text search matches are exact, so 100% score
+ payload: r.payload
+ }));
+ setSearchResults(mappedResults);
+
+ if (mappedResults.length === 0) alert("No records found matching that description.");
+ }
+ } catch (e) {
+ console.error(e);
+ alert("Text Query Failed");
} finally {
setLoadingSearch(false);
}
@@ -116,6 +157,25 @@ export default function UnifiedPage() {
</div>
</div>
+ {/* š TEXT SEARCH BAR */}
+<div className="max-w-6xl w-full mb-8">
+ <div className="bg-slate-900/50 p-4 rounded-xl border border-slate-700 flex gap-4">
+ <input
+ type="text"
+ value={textQuery}
+ onChange={(e) => setTextQuery(e.target.value)}
+ placeholder="Ask Demeter: 'Show me all failed Lettuce crops' or 'Show me Batch-001'"
+ className="flex-1 bg-transparent border-none outline-none text-white placeholder-slate-500"
+ />
+ <button
+ onClick={handleTextQuery}
+ className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-lg font-bold transition-colors"
+ >
+ Ask
+ </button>
+ </div>
+</div>
+
{/* Right: Inputs & Actions */}
<div className="space-y-8 flex flex-col justify-center">
<div>
@@ -234,6 +294,25 @@ export default function UnifiedPage() {
</div>
)}
{/* š§ END REASONING SECTION */}
+ {/* {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>
+ <div className="flex gap-2">
+ <button
+ onClick={() => submitFeedback(currentQueryId, decision.action, "Effective")}
+ className="px-4 py-2 bg-green-600 rounded text-sm hover:bg-green-500"
+ >
+ It Worked!
+ </button>
+ <button
+ onClick={() => submitFeedback(currentQueryId, decision.action, "Ineffective")}
+ className="px-4 py-2 bg-red-600 rounded text-sm hover:bg-red-500"
+ >
+ Failed
+ </button>
+ </div>
+ </div>
+)} */}
{/* Bottom Section: Search Results */}
diff --git a/web/models/index.ts b/web/models/index.ts
@@ -8,6 +8,7 @@ export interface SensorData {
humidity: string;
crop: string;
stage: string;
+ crop_id?: string;
}
// 2. Add the new Decision Type
@@ -24,6 +25,8 @@ export interface SearchResponse {
search_results: SearchResult[];
// The new AI Brain output
agent_decision?: AgentDecision;
+
+ new_fmu_id?: string;
}
// 4. Ensure SearchResult matches what Qdrant sends
@@ -37,7 +40,14 @@ export interface SearchResult {
sensors?: {
pH: number;
EC: number;
+ temp: number;
+ humidity: number;
};
+
+ // š ADD THE NEW SCHEMA FIELDS HERE
+ action_taken?: string;
+ outcome?: string;
+
[key: string]: any; // Allow for other flexible fields
};
}
diff --git a/web/services/api.ts b/web/services/api.ts
@@ -2,11 +2,17 @@ import { SensorData, IngestResponse, SearchResponse } from "@/models";
const API_URL = "http://localhost:8000";
-export const IngestService = {
- // ... (keep uploadFMU as is) ...
+// 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> {
- // ... (your existing upload code) ...
const formData = new FormData();
formData.append("file", file);
formData.append("sensors", JSON.stringify({
@@ -30,18 +36,19 @@ export const IngestService = {
}
},
+ // 2. Search Function (Existing)
async searchFMU(file: File, sensors: SensorData): Promise<SearchResponse> {
const formData = new FormData();
formData.append("file", file);
- // We send sensor data because it's part of the vector math!
formData.append("sensors", JSON.stringify({
pH: parseFloat(sensors.pH),
EC: parseFloat(sensors.EC),
temp: parseFloat(sensors.temp),
humidity: parseFloat(sensors.humidity),
- crop: sensors.crop, // <--- Must include this
+ crop: sensors.crop,
stage: sensors.stage,
+ crop_id: sensors.crop_id, // <--- Add this
}));
try {
@@ -55,5 +62,21 @@ export const IngestService = {
console.error("Search Service Error:", error);
throw error;
}
- }
+ },
+
+ // 3. š ADD THIS MISSING FUNCTION
+ // async sendFeedback(data: FeedbackPayload) {
+ // try {
+ // const res = await fetch(`${API_URL}/feedback`, {
+ // method: "POST",
+ // headers: { "Content-Type": "application/json" },
+ // body: JSON.stringify(data),
+ // });
+ // if (!res.ok) throw new Error(`Server Error: ${res.statusText}`);
+ // return await res.json();
+ // } catch (error) {
+ // console.error("Feedback Error:", error);
+ // throw error;
+ // }
+ // }
};
\ No newline at end of file