commit 724604d76ee3783f075310c7aeced5c2b093d51d
parent 3ff200b9313f5bac43318b8b2432e2af25ebeb09
Author: maydayv7 <maydayv7@gmail.com>
Date: Thu, 26 Mar 2026 02:41:11 +0530
Farm Intelligence
Give related crops context (cosine similarity)
Diffstat:
4 files changed, 592 insertions(+), 177 deletions(-)
diff --git a/backend/server/functions.py b/backend/server/functions.py
@@ -395,11 +395,13 @@ def extract_json(text):
return None
-async def process_text_query(text: str):
+async def process_text_query(text: str, crop_id: str = None):
"""
HYBRID FILTER ENGINE:
- Uses LangChain to extract both Exact/Range limits (for Qdrant)
- AND substring matches (for Python Post-Filtering of Agent Logic).
+ Uses LLM to extract Exact/Range/Text filters
+
+ When crop_id is provided the caller has selected a specific crop, so we
+ inject a should-match for that crop_id to bias results toward it.
"""
system_prompt = """
You are a Database Translator for an AI Hydroponic Farm.
@@ -436,6 +438,7 @@ async def process_text_query(text: str):
2. Use "text" for partial/substring matches (CRITICAL for 'action_taken' since it contains stringified JSON records).
3. Use "gt", "lt", "gte", "lte" for numeric sensor comparisons.
4. Translate queries into English (e.g., "Tamatar" -> "Tomato", "Kharab" -> "Negative").
+ 5. For queries about "similar crops" or "crops like X", extract the crop name as an exact filter on 'crop'.
EXAMPLE: "Find tomato crops in vegetative stage with pH over 6.0 where the agent flushed the tank"
{
@@ -477,24 +480,21 @@ async def process_text_query(text: str):
if not field or val is None:
continue
- # Store text substring matches for Python Post-Filtering
+ # Text substring matches (Python post-filter)
if op == "text":
post_filters.append((field, str(val).lower()))
continue
- # Handle numeric sensors (nested routing)
+ # Numeric sensor fields (nested routing)
if field.lower() in ["ph", "ec", "temp", "humidity"]:
- if field.lower() == "ph":
- field = "pH"
- if field.lower() == "ec":
- field = "EC"
- if field.lower() == "temp":
- field = "temp"
- if field.lower() == "humidity":
- field = "humidity"
-
- path1 = f"sensors.{field}"
- path2 = f"sensor_data.{field}"
+ field_norm = {
+ "ph": "pH",
+ "ec": "EC",
+ "temp": "temp",
+ "humidity": "humidity",
+ }.get(field.lower(), field)
+ path1 = f"sensors.{field_norm}"
+ path2 = f"sensor_data.{field_norm}"
if op == "exact":
qdrant_conditions.append(
@@ -537,43 +537,43 @@ async def process_text_query(text: str):
)
)
- # 1. Hardware Search (Qdrant)
+ # Build Qdrant filter
scroll_filter = (
models.Filter(must=qdrant_conditions) if qdrant_conditions else None
)
- results, next_offset = client.scroll(
+ results, _ = client.scroll(
collection_name=COLLECTION_NAME,
scroll_filter=scroll_filter,
- limit=100, # Pull a larger batch to account for post-filtering
+ limit=100,
with_payload=True,
+ with_vectors=False,
)
- # 2. Logic Search (Python Post-Filtering)
- # We do this because 'action_taken' is a complex JSON string.
- # Checking substring via Python ensures we never crash Qdrant over indexing issues.
+ # Python post-filter (for text/substring fields like action_taken)
filtered_results = []
for res in results:
payload = res.payload or {}
passed = True
-
for pf_field, pf_val in post_filters:
- payload_val = str(payload.get(pf_field, "")).lower()
- if pf_val not in payload_val:
+ if pf_val not in str(payload.get(pf_field, "")).lower():
passed = False
break
-
if passed:
filtered_results.append(res)
-
- # Stop once we have top 10 matches
if len(filtered_results) >= 10:
break
+ # If a specific crop was selected, sort its results to the top
+ if crop_id:
+ filtered_results.sort(
+ key=lambda p: 0 if p.payload.get("crop_id") == crop_id else 1
+ )
+
return {
"status": "success",
"results": [
- {"id": p.id, "score": 1.0, "payload": p.payload}
+ {"id": str(p.id), "score": 1.0, "payload": p.payload}
for p in filtered_results
],
"query_logic": filter_logic,
@@ -581,6 +581,9 @@ async def process_text_query(text: str):
except Exception as e:
print(f"❌ Text Search Error: {e}")
+ import traceback
+
+ traceback.print_exc()
return {"status": "error", "message": str(e)}
@@ -616,7 +619,9 @@ async def process_audio_search(file: UploadFile):
async def process_ask_query(query: str, context: str, language: str):
"""
- Directly answers specific user questions from the frontend.
+ Directly answers user questions using farm data context.
+ The context string already contains similar-crop data pre-built by the
+ frontend; this function just passes it through to the LLM.
"""
try:
lang_instr = (
@@ -626,11 +631,16 @@ async def process_ask_query(query: str, context: str, language: str):
)
system_prompt = f"""
You are Demeter Intelligence, an expert AI agronomist for a hydroponic farm.
- Use this FARM DATA to answer the user's question:
+ Use the FARM DATA below to answer the user's question accurately and concisely.
+
{context}
-
- Wrap your reasoning in <thinking>...</thinking> tags.
- CRITICAL: {lang_instr}
+
+ Instructions:
+ - Wrap your internal reasoning in <thinking>...</thinking> tags.
+ - After </thinking>, give a clear direct answer.
+ - When referencing specific crops, mention their crop_id in parentheses.
+ - If the question requires comparing multiple crops, address each one.
+ - CRITICAL: {lang_instr}
"""
response = supervisor.model.invoke(
@@ -650,4 +660,120 @@ async def process_ask_query(query: str, context: str, language: str):
return {"status": "success", "thinking": thinking, "answer": answer}
except Exception as e:
+ import traceback
+
+ traceback.print_exc()
return {"status": "error", "message": str(e)}
+
+
+async def process_similar_crops(crop_id: str, crop_name: str, payload_json: str):
+ """
+ Find cosine-similar crops using the ACTUAL stored vector for crop_id
+ """
+ import json as _json
+ import numpy as np
+
+ try:
+ # Find the latest point for this crop
+ filter_latest = models.Filter(
+ must=[
+ models.FieldCondition(
+ key="crop_id",
+ match=models.MatchValue(value=crop_id),
+ )
+ ]
+ )
+
+ points, _ = client.scroll(
+ collection_name=COLLECTION_NAME,
+ scroll_filter=filter_latest,
+ limit=100,
+ with_payload=True,
+ with_vectors=True, # <-- we need the actual stored vector
+ )
+
+ query_vector = None
+
+ if points:
+ # Pick the point with the highest sequence_number
+ best = max(points, key=lambda p: p.payload.get("sequence_number", 0))
+ v = best.vector
+ if v is not None:
+ # v may be a list or a dict (named vectors); handle both
+ if isinstance(v, dict):
+ # Named vector collections — grab the default/first key
+ v = next(iter(v.values()))
+ query_vector = list(v)
+
+ # Fallback: build sensor vector from payload JSON
+ if query_vector is None:
+ print(
+ f"[SimilarCrops] No stored vector for {crop_id}, falling back to sensor encoding"
+ )
+ try:
+ from Sentinel.Encoders.TimeSeries import SensorEncoder
+
+ payload = _json.loads(payload_json) if payload_json else {}
+ raw_sensors = payload.get("sensor_data") or payload.get("sensors") or {}
+ # Keep only the four numeric sensors
+ clean = {}
+ for k, v in raw_sensors.items():
+ if k in {"pH", "EC", "temp", "humidity"}:
+ try:
+ clean[k] = float(v)
+ except (TypeError, ValueError):
+ pass
+
+ if clean:
+ encoder = SensorEncoder()
+ sensor_vec = encoder.encode(clean) # shape: (4,) or (12,)
+ # Pad to COLLECTION vector size (516) with zeros
+ full_vec = np.zeros(516, dtype=np.float32)
+ full_vec[-len(sensor_vec) :] = sensor_vec
+ query_vector = full_vec.tolist()
+ except Exception as enc_err:
+ print(f"[SimilarCrops] Sensor encoding fallback failed: {enc_err}")
+
+ if query_vector is None:
+ return {
+ "status": "error",
+ "message": f"Could not build a query vector for crop_id={crop_id}",
+ "results": [],
+ }
+
+ # Cector search, excluding this crop
+ exclude_filter = models.Filter(
+ must_not=[
+ models.FieldCondition(
+ key="crop_id",
+ match=models.MatchValue(value=crop_id),
+ )
+ ]
+ )
+
+ search_results = client.search(
+ collection_name=COLLECTION_NAME,
+ query_vector=query_vector,
+ query_filter=exclude_filter,
+ limit=6,
+ with_payload=True,
+ with_vectors=False,
+ )
+
+ return {
+ "status": "success",
+ "results": [
+ {
+ "id": str(r.id),
+ "score": float(r.score),
+ "payload": r.payload,
+ }
+ for r in search_results
+ ],
+ }
+
+ except Exception as e:
+ import traceback
+
+ traceback.print_exc()
+ return {"status": "error", "message": str(e), "results": []}
diff --git a/backend/server/main.py b/backend/server/main.py
@@ -31,6 +31,7 @@ from backend.server.functions import (
process_audio_search,
process_cycle_stream,
process_ask_query,
+ process_similar_crops,
)
app = FastAPI()
@@ -73,8 +74,20 @@ async def run_cycle_stream_endpoint(
@app.post("/query-text")
-async def text_query_endpoint(query: str = Form(...)):
- return await process_text_query(query)
+async def text_query_endpoint(
+ query: str = Form(...),
+ crop_id: str = Form(None), # optional
+):
+ return await process_text_query(query, crop_id)
+
+
+@app.post("/query-similar")
+async def similar_crops_endpoint(
+ crop_id: str = Form(...),
+ crop_name: str = Form(...),
+ payload: str = Form(...),
+):
+ return await process_similar_crops(crop_id, crop_name, payload)
@app.post("/query-audio")
diff --git a/frontend/src/api/agentApi.js b/frontend/src/api/agentApi.js
@@ -80,7 +80,7 @@ export const agentService = {
/**
* Translates a natural language query into a database filter using LLM
*/
- async queryText(text) {
+ async queryText(text, cropId = null) {
if (USE_MOCK_DATA) {
await new Promise((r) => setTimeout(r, 600));
return {
@@ -101,6 +101,8 @@ export const agentService = {
const formData = new FormData();
formData.append("query", text);
+ if (cropId) formData.append("crop_id", cropId);
+
const res = await fetch(`${API_URL}/query-text`, {
method: "POST",
body: formData,
@@ -110,6 +112,35 @@ export const agentService = {
},
/**
+ * Finds cosine-similar crops via Qdrant vector search
+ */
+ async querySimilarCrops(cropId, cropName, payload) {
+ if (USE_MOCK_DATA) {
+ await new Promise((r) => setTimeout(r, 400));
+ return {
+ status: "success",
+ results: MOCK_DASHBOARD.slice(1, 4).map((d, i) => ({
+ id: d.id,
+ score: 0.91 - i * 0.07,
+ payload: d.payload,
+ })),
+ };
+ }
+
+ const formData = new FormData();
+ formData.append("crop_id", cropId);
+ formData.append("crop_name", cropName || "");
+ formData.append("payload", JSON.stringify(payload || {}));
+
+ const res = await fetch(`${API_URL}/query-similar`, {
+ method: "POST",
+ body: formData,
+ });
+ if (!res.ok) throw new Error(res.statusText);
+ return res.json();
+ },
+
+ /**
* Processes voice input
*/
async queryAudio(audioBlob) {
diff --git a/frontend/src/pages/FarmIntelligence.jsx b/frontend/src/pages/FarmIntelligence.jsx
@@ -1,4 +1,4 @@
-import { useRef, useState, useMemo, useEffect } from "react";
+import { useRef, useState, useMemo, useEffect, useCallback } from "react";
import { useT } from "../hooks/useTranslation";
import {
Activity,
@@ -20,6 +20,7 @@ import {
Leaf,
X,
GitBranch,
+ ExternalLink,
} from "lucide-react";
import { agentService } from "../api/agentApi";
import { extractSensors, deriveCropStatus } from "../utils/dataUtils";
@@ -123,7 +124,14 @@ function ThinkingBlock({ text, t }) {
}
// LLM Answer block
-function LLMAnswerBlock({ answer, thinking, query, cropContext, t, td }) {
+function LLMAnswerBlock({
+ answer,
+ thinking,
+ cropContext,
+ referencedCrops,
+ t,
+ td,
+}) {
if (!answer) return null;
return (
@@ -134,6 +142,7 @@ function LLMAnswerBlock({ answer, thinking, query, cropContext, t, td }) {
background: "var(--surface)",
border: "1px solid rgba(167,139,250,0.25)",
overflow: "hidden",
+ flexShrink: 0,
}}
>
{/* Header */}
@@ -215,6 +224,53 @@ function LLMAnswerBlock({ answer, thinking, query, cropContext, t, td }) {
>
{answer}
</div>
+
+ {/* Referenced crops inline mention */}
+ {referencedCrops && referencedCrops.length > 0 && (
+ <div
+ style={{
+ marginTop: 14,
+ padding: "8px 12px",
+ borderRadius: 8,
+ background: "rgba(167,139,250,0.06)",
+ border: "1px solid rgba(167,139,250,0.15)",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ flexWrap: "wrap",
+ }}
+ >
+ <span
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ color: "var(--text-3)",
+ }}
+ >
+ <ExternalLink
+ size={9}
+ style={{ display: "inline", marginRight: 4 }}
+ />
+ {t("intel_data_from")}:
+ </span>
+ {referencedCrops.map((c, i) => (
+ <span
+ key={i}
+ style={{
+ fontSize: 10,
+ fontFamily: "DM Mono, monospace",
+ padding: "2px 8px",
+ borderRadius: 12,
+ background: "rgba(167,139,250,0.12)",
+ color: "#a78bfa",
+ border: "1px solid rgba(167,139,250,0.25)",
+ }}
+ >
+ {td(c.crop)} · {c.cropId}
+ </span>
+ ))}
+ </div>
+ )}
</div>
</div>
);
@@ -456,6 +512,22 @@ function InsightCard({ result, idx, t, td }) {
{td(p.stage)}
</span>
)}
+ {result.score !== undefined && result.score < 1 && (
+ <span
+ style={{
+ fontSize: 9,
+ fontFamily: "DM Mono, monospace",
+ color:
+ result.score > 0.8
+ ? "var(--green)"
+ : result.score > 0.6
+ ? "var(--amber)"
+ : "var(--text-3)",
+ }}
+ >
+ {t("intel_match", { n: (result.score * 100).toFixed(0) })}
+ </span>
+ )}
</div>
</div>
@@ -831,6 +903,7 @@ export default function FarmIntelligence() {
const [loading, setLoading] = useState(false);
const [results, setResults] = useState([]);
const [relatedCrops, setRelatedCrops] = useState([]);
+ const [referencedCrops, setReferencedCrops] = useState([]);
const [transcription, setTranscription] = useState("");
const [hasQueried, setHasQueried] = useState(false);
const [llmAnswer, setLlmAnswer] = useState("");
@@ -859,31 +932,50 @@ export default function FarmIntelligence() {
}));
}, [dashboard, t]);
- const showToast = (msg, type = "success") => {
+ const showToast = useCallback((msg, type = "success") => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
- };
+ }, []);
- const fleetStats = {
- total: dashboard?.length || 0,
- healthy: (dashboard || []).filter(
- (d) => deriveCropStatus(d.payload) === "Healthy",
- ).length,
- attention: (dashboard || []).filter(
- (d) => deriveCropStatus(d.payload) === "Attention",
- ).length,
- critical: (dashboard || []).filter(
- (d) => deriveCropStatus(d.payload) === "Critical",
- ).length,
- };
+ const fleetStats = useMemo(
+ () => ({
+ total: dashboard?.length || 0,
+ healthy: (dashboard || []).filter(
+ (d) => deriveCropStatus(d.payload) === "Healthy",
+ ).length,
+ attention: (dashboard || []).filter(
+ (d) => deriveCropStatus(d.payload) === "Attention",
+ ).length,
+ critical: (dashboard || []).filter(
+ (d) => deriveCropStatus(d.payload) === "Critical",
+ ).length,
+ }),
+ [dashboard],
+ );
// Build rich context for LLM
- const buildLLMContext = (cropCtx) => {
- if (cropCtx) {
- // Specific crop context
- const p = cropCtx.payload || {};
- const sensors = extractSensors(p);
- return `CROP CONTEXT:
+ const buildLLMContext = useCallback(
+ (cropCtx, similarCrops = []) => {
+ const allCrops = dashboard || [];
+
+ if (cropCtx) {
+ const p = cropCtx.payload || {};
+ const sensors = extractSensors(p);
+
+ // Build similar crops context section
+ const similarSection =
+ similarCrops.length > 0
+ ? `\nSIMILAR CROPS (cosine similarity via vector search):\n` +
+ similarCrops
+ .map((sc) => {
+ const sp = sc.payload || {};
+ const ss = extractSensors(sp);
+ return ` - ${sp.crop || "?"} (${sp.crop_id || sc.id}): pH=${ss.ph}, EC=${ss.ec}, T=${ss.temp}°, H=${ss.humidity}%, Stage=${sp.stage}, Status=${deriveCropStatus(sp)}, Outcome=${sp.outcome || "Pending"}, Score=${(sc.score * 100).toFixed(0)}%`;
+ })
+ .join("\n")
+ : "";
+
+ return `CROP CONTEXT:
- Crop: ${p.crop || cropCtx.crop}
- Batch ID: ${p.crop_id || cropCtx.cropId}
- Growth Stage: ${p.stage || "Unknown"}
@@ -900,116 +992,177 @@ LATEST AGENT DECISION:
- Reward Score: ${p.reward_score ?? "N/A"}
- Strategic Intent: ${p.strategic_intent || "N/A"}
EXPLANATION LOG:
-${p.explanation_log && p.explanation_log !== "PENDING_ANALYSIS" ? p.explanation_log : "Not yet generated."}
+${p.explanation_log && p.explanation_log !== "PENDING_ANALYSIS" ? p.explanation_log : "Not yet generated."}${similarSection}
FLEET OVERVIEW (for comparison):
- Total crops: ${fleetStats.total}
- Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical}`.trim();
- } else {
- // Fleet-wide context
- const cropSummaries = (dashboard || [])
- .slice(0, 10)
- .map((d) => {
- const p = d.payload || {};
- const s = extractSensors(p);
- return ` - ${p.crop || "?"} (${p.crop_id || d.id}): Stage=${p.stage}, pH=${s.ph}, EC=${s.ec}, Status=${deriveCropStatus(p)}, Outcome=${p.outcome || "Pending"}`;
- })
- .join("\n");
- return `FLEET OVERVIEW:
+ } else {
+ // Fleet-wide: pass ALL crops (capped at 20 for prompt size)
+ const cropSummaries = allCrops
+ .slice(0, 20)
+ .map((d) => {
+ const p = d.payload || {};
+ const s = extractSensors(p);
+ return ` - ${p.crop || "?"} (${p.crop_id || d.id}): Stage=${p.stage}, pH=${s.ph}, EC=${s.ec}, T=${s.temp}°, H=${s.humidity}%, Status=${deriveCropStatus(p)}, Outcome=${p.outcome || "Pending"}, Action=${p.action_taken || "Pending"}`;
+ })
+ .join("\n");
+ return `FLEET OVERVIEW:
- Total crops: ${fleetStats.total}
- Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical}
-CURRENT CROPS:
+ALL CROPS (${allCrops.length} total):
${cropSummaries || "No crops in database."}
SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
- }
- };
+ }
+ },
+ [dashboard, fleetStats],
+ );
// Ask LLM
- const handleAsk = async (q) => {
- const query = q || textQuery;
- if (!query.trim()) return;
- setLoading(true);
- setHasQueried(true);
- setTextQuery(query);
- setLlmAnswer("");
- setLlmThinking("");
- setResults([]);
- setRelatedCrops([]);
+ const handleAsk = useCallback(
+ async (q) => {
+ const query = q || textQuery;
+ if (!query.trim()) return;
+ setLoading(true);
+ setHasQueried(true);
+ setTextQuery(query);
+ setLlmAnswer("");
+ setLlmThinking("");
+ setResults([]);
+ setRelatedCrops([]);
+ setReferencedCrops([]);
+ setTranscription("");
- try {
- const context = buildLLMContext(selectedCrop);
- const data = await agentService.askDemeter(query, context, lang);
-
- setLlmThinking(data.thinking || "");
- setLlmAnswer(data.answer || t("intel_no_response"));
+ try {
+ let similarCrops = [];
- // Also run a search to show related crops
- if (selectedCrop) {
- // For specific crop, show similar crops via vector search
- try {
- const searchData = await agentService.queryText(selectedCrop.crop);
- if (searchData.results) {
- setRelatedCrops(
- searchData.results
+ if (selectedCrop) {
+ // Use Qdrant vector search to find cosine-similar crops
+ try {
+ const searchData = await agentService.querySimilarCrops(
+ selectedCrop.cropId,
+ selectedCrop.crop,
+ selectedCrop.payload,
+ );
+ if (searchData.results) {
+ similarCrops = searchData.results
.filter((r) => r.payload?.crop_id !== selectedCrop.cropId)
- .slice(0, 3)
+ .slice(0, 5)
.map((r) => ({
id: r.id,
- score: r.score || 0.85,
+ score: r.score || 0,
payload: r.payload,
- })),
- );
+ }));
+ setRelatedCrops(similarCrops.slice(0, 3));
+ }
+ } catch (e) {
+ console.warn("Similar crop search failed:", e);
+ }
+ }
+
+ const context = buildLLMContext(selectedCrop, similarCrops);
+ const data = await agentService.askDemeter(query, context, lang);
+
+ setLlmThinking(data.thinking || "");
+ setLlmAnswer(data.answer || t("intel_no_response"));
+
+ // Parse which crops the answer references (by crop_id mentioned in answer)
+ if (!selectedCrop && data.answer) {
+ const mentioned = cropList.filter(
+ (c) =>
+ data.answer.toLowerCase().includes(c.crop.toLowerCase()) ||
+ data.answer.includes(c.cropId),
+ );
+ if (mentioned.length > 0) {
+ setReferencedCrops(mentioned.slice(0, 6));
}
- } catch {}
+ }
+ } catch (e) {
+ console.error(e);
+ showToast(t("intel_llm_fail_toast"), "error");
+ setLlmAnswer(t("intel_llm_fail"));
+ } finally {
+ setLoading(false);
}
- } catch (e) {
- console.error(e);
- showToast(t("intel_llm_fail_toast"), "error");
- setLlmAnswer(t("intel_llm_fail"));
- } finally {
- setLoading(false);
- }
- };
+ },
+ [textQuery, selectedCrop, cropList, buildLLMContext, lang, showToast, t],
+ );
- // Search (Qdrant filter)
- const handleSearch = async (q) => {
- const query = q || textQuery;
- if (!query.trim()) return;
- setLoading(true);
- setHasQueried(true);
- setTextQuery(query);
- setResults([]);
- setLlmAnswer("");
- setLlmThinking("");
- setRelatedCrops([]);
- setQueryLogic("");
+ // Search
+ const handleSearch = useCallback(
+ async (q) => {
+ const query = q || textQuery;
+ if (!query.trim()) return;
+ setLoading(true);
+ setHasQueried(true);
+ setTextQuery(query);
+ setResults([]);
+ setLlmAnswer("");
+ setLlmThinking("");
+ setRelatedCrops([]);
+ setReferencedCrops([]);
+ setQueryLogic("");
+ setTranscription("");
- try {
- const data = await agentService.queryText(query);
- if (data.results) {
- setResults(
- data.results.map((r) => ({
- id: r.id,
- score: r.score || 1,
- payload: r.payload,
- })),
- );
+ try {
+ // Build the search payload — include selectedCrop context so backend
+ // can bias Qdrant filter results toward that crop's embedding
+ const data = await agentService.queryText(query, selectedCrop?.cropId);
+
+ if (data.results) {
+ setResults(
+ data.results.map((r) => ({
+ id: r.id,
+ score: r.score || 1,
+ payload: r.payload,
+ })),
+ );
+ }
+
+ if (data.query_logic)
+ setQueryLogic(JSON.stringify(data.query_logic, null, 2));
+
+ // If a crop is selected, also show cosine-similar crops in a sidebar section
+ if (selectedCrop) {
+ try {
+ const simData = await agentService.querySimilarCrops(
+ selectedCrop.cropId,
+ selectedCrop.crop,
+ selectedCrop.payload,
+ );
+ if (simData.results) {
+ setRelatedCrops(
+ simData.results
+ .filter((r) => r.payload?.crop_id !== selectedCrop.cropId)
+ .slice(0, 3)
+ .map((r) => ({
+ id: r.id,
+ score: r.score || 0,
+ payload: r.payload,
+ })),
+ );
+ }
+ } catch (e) {
+ console.warn("Similar crop search failed:", e);
+ }
+ }
+ } catch {
+ showToast(t("intel_search_fail_toast"), "error");
+ } finally {
+ setLoading(false);
}
- // Show query interpretation if available
- if (data.query_logic)
- setQueryLogic(JSON.stringify(data.query_logic, null, 2));
- } catch {
- showToast(t("intel_search_fail_toast"), "error");
- } finally {
- setLoading(false);
- }
- };
+ },
+ [textQuery, selectedCrop, showToast, t],
+ );
- const handleQuery = (q) => {
- if (mode === "ask") return handleAsk(q);
- return handleSearch(q);
- };
+ const handleQuery = useCallback(
+ (q) => {
+ if (mode === "ask") return handleAsk(q);
+ return handleSearch(q);
+ },
+ [mode, handleAsk, handleSearch],
+ );
- // Voice
+ // Voice recording
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
@@ -1027,10 +1180,12 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
setTranscription(data.transcription);
setTextQuery(data.transcription);
+ // In ask mode: hand off transcription to ask handler
if (mode === "ask") {
await handleAsk(data.transcription);
} else {
- if (data.results) {
+ // In search mode: if backend returned results use them, else re-query
+ if (data.results && data.results.length > 0) {
setResults(
data.results.map((r) => ({
id: r.id,
@@ -1038,14 +1193,19 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
payload: r.payload,
})),
);
+ if (data.query_logic)
+ setQueryLogic(JSON.stringify(data.query_logic, null, 2));
setHasQueried(true);
+ } else {
+ // Fallback: run text search with the transcription
+ await handleSearch(data.transcription);
}
}
}
} finally {
setLoading(false);
}
- stream.getTracks().forEach((t) => t.stop());
+ stream.getTracks().forEach((trk) => trk.stop());
};
mediaRecorderRef.current.start();
setIsRecording(true);
@@ -1061,6 +1221,12 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
}
};
+ // Clear transcription banner when user manually types
+ const handleInputChange = (e) => {
+ setTextQuery(e.target.value);
+ if (transcription) setTranscription("");
+ };
+
const suggestions = selectedCrop
? getCropSuggestions(td(selectedCrop.crop), t)
: getGlobalSuggestions(t);
@@ -1164,6 +1330,9 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
setResults([]);
setLlmAnswer("");
setLlmThinking("");
+ setRelatedCrops([]);
+ setReferencedCrops([]);
+ setQueryLogic("");
}}
style={{
display: "flex",
@@ -1247,8 +1416,23 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
<CropSelector
crops={cropList}
selectedCrop={selectedCrop}
- onSelect={setSelectedCrop}
- onClear={() => setSelectedCrop(null)}
+ onSelect={(c) => {
+ setSelectedCrop(c);
+ // Reset results so user re-queries with crop context
+ setHasQueried(false);
+ setResults([]);
+ setLlmAnswer("");
+ setRelatedCrops([]);
+ setReferencedCrops([]);
+ }}
+ onClear={() => {
+ setSelectedCrop(null);
+ setHasQueried(false);
+ setResults([]);
+ setLlmAnswer("");
+ setRelatedCrops([]);
+ setReferencedCrops([]);
+ }}
t={t}
td={td}
/>
@@ -1288,7 +1472,7 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
<input
value={textQuery}
- onChange={(e) => setTextQuery(e.target.value)}
+ onChange={handleInputChange}
onKeyDown={(e) => e.key === "Enter" && handleQuery()}
placeholder={
mode === "ask"
@@ -1297,7 +1481,11 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
crop: td(selectedCrop.crop),
})
: t("intel_ask_placeholder_fleet")
- : t("intel_search_placeholder")
+ : selectedCrop
+ ? t("intel_search_placeholder_crop", {
+ crop: td(selectedCrop.crop),
+ }) || t("intel_search_placeholder")
+ : t("intel_search_placeholder")
}
style={{
flex: 1,
@@ -1313,7 +1501,10 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
{textQuery && (
<button
- onClick={() => setTextQuery("")}
+ onClick={() => {
+ setTextQuery("");
+ setTranscription("");
+ }}
style={{
background: "none",
border: "none",
@@ -1369,8 +1560,8 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
</div>
</div>
- {/* Transcription */}
- {transcription && (
+ {/* Transcription banner */}
+ {transcription && !loading && (
<div
className="animate-fade-in"
style={{
@@ -1381,9 +1572,26 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
fontSize: 12,
fontFamily: "DM Mono, monospace",
color: "#a78bfa",
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
}}
>
- 🎙 Heard: "{transcription}"
+ <span style={{ flex: 1 }}>🎙 Heard: "{transcription}"</span>
+ <button
+ onClick={() => setTranscription("")}
+ style={{
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ color: "rgba(167,139,250,0.6)",
+ display: "flex",
+ alignItems: "center",
+ padding: 0,
+ }}
+ >
+ <X size={11} />
+ </button>
</div>
)}
@@ -1499,13 +1707,13 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
<LLMAnswerBlock
answer={llmAnswer}
thinking={llmThinking}
- query={textQuery}
cropContext={selectedCrop}
+ referencedCrops={referencedCrops}
t={t}
td={td}
/>
- {/* Related crops */}
+ {/* Similar crops */}
{relatedCrops.length > 0 && (
<div>
<div
@@ -1604,6 +1812,7 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
</pre>
</div>
)}
+
{results.length === 0 ? (
<div
style={{
@@ -1638,24 +1847,60 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim();
</button>
</div>
) : (
- <div
- style={{
- display: "grid",
- gridTemplateColumns:
- "repeat(auto-fill, minmax(320px,1fr))",
- gap: 14,
- }}
- >
- {results.map((r, i) => (
- <InsightCard
- key={r.id}
- result={r}
- idx={i}
- t={t}
- td={td}
- />
- ))}
- </div>
+ <>
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns:
+ "repeat(auto-fill, minmax(320px,1fr))",
+ gap: 14,
+ }}
+ >
+ {results.map((r, i) => (
+ <InsightCard
+ key={r.id}
+ result={r}
+ idx={i}
+ t={t}
+ td={td}
+ />
+ ))}
+ </div>
+
+ {/* Similar crops section below search results when crop selected */}
+ {relatedCrops.length > 0 && selectedCrop && (
+ <div>
+ <div
+ className="section-label"
+ style={{ marginBottom: 10 }}
+ >
+ <GitBranch
+ size={10}
+ style={{ display: "inline", marginRight: 5 }}
+ />
+ {t("intel_similar_crops")} · {td(selectedCrop.crop)}
+ </div>
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns:
+ "repeat(auto-fill, minmax(260px,1fr))",
+ gap: 12,
+ }}
+ >
+ {relatedCrops.map((r, i) => (
+ <RelatedCropCard
+ key={r.id || i}
+ item={r}
+ score={r.score}
+ t={t}
+ td={td}
+ />
+ ))}
+ </div>
+ </div>
+ )}
+ </>
)}
</>
)}