demeter

Autonomous Hydroponic Intelligence
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:
Mbackend/server/functions.py | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mbackend/server/main.py | 17+++++++++++++++--
Mfrontend/src/api/agentApi.js | 33++++++++++++++++++++++++++++++++-
Mfrontend/src/pages/FarmIntelligence.jsx | 525++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
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> + )} + </> )} </> )}