demeter

Autonomous Hydroponic Intelligence
commit fe73a38ebc1de57ed58f11d8b5cd3849ad039592
parent d9c904259d1d8126b882c821916f24da8901fbf2
Author: maydayv7 <maydayv7@gmail.com>
Date:   Wed, 25 Mar 2026 16:21:31 +0530

Show explanation logs

Also begin work on Farm Intelligence refactor

Diffstat:
Mbackend/server/functions.py | 51++++++++++++++++++++++++++++++++-------------------
Mfrontend/src/api/agentApi.js | 8+++++++-
Mfrontend/src/components/Sidebar.jsx | 2+-
Mfrontend/src/data/mockData.js | 161++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mfrontend/src/pages/AddCrop.jsx | 2+-
Mfrontend/src/pages/CropDetails.jsx | 627++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mfrontend/src/pages/FarmIntelligence.jsx | 1550+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
7 files changed, 1943 insertions(+), 458 deletions(-)

diff --git a/backend/server/functions.py b/backend/server/functions.py @@ -397,26 +397,39 @@ async def process_cycle_stream(file: UploadFile, sensors_str: str, builder): def extract_json(text): """ - Robustly extracts the first valid JSON object from a text string. + Robustly extracts the first valid JSON object from text string. """ - try: - # 1. Try finding content inside ```json ... ``` - match = re.search(r"```json\s*(\{.*?\})\s*```", text, re.DOTALL) - if match: - return json.loads(match.group(1)) - - # 2. Try finding content inside plain ``` ... ``` - match = re.search(r"```\s*(\{.*?\})\s*```", text, re.DOTALL) - if match: - return json.loads(match.group(1)) - - # 3. Fallback: Find the first outermost { ... } - match = re.search(r"(\{.*\})", text, re.DOTALL) - if match: - return json.loads(match.group(1)) - - except Exception: - pass + # Strip <think>...</think> blocks FIRST — reasoning models emit these before the answer + cleaned = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip() + + for source in (cleaned, text): # fall back to raw text if stripping broke something + try: + # 1. Try direct parse (model returned only JSON) + return json.loads(source) + except Exception: + pass + + try: + # 2. Try finding content inside ```json ... ``` + match = re.search(r"```json\s*(\{.*?\})\s*```", source, re.DOTALL) + if match: + return json.loads(match.group(1)) + + # 3. Try finding content inside plain ``` ... ``` + match = re.search(r"```\s*(\{.*?\})\s*```", source, re.DOTALL) + if match: + return json.loads(match.group(1)) + + # 4. Fallback: Find the LAST outermost { ... } (avoids grabbing think-block JSON) + matches = list( + re.finditer(r"(\{[^{}]*(?:\{[^{}]*\}[^{}]*)?\})", source, re.DOTALL) + ) + if matches: + return json.loads(matches[-1].group(1)) + + except Exception: + pass + return {} diff --git a/frontend/src/api/agentApi.js b/frontend/src/api/agentApi.js @@ -77,6 +77,7 @@ export const agentService = { /** * Queries the RAG/Agent via Text + * Returns: { status, results, query_logic } */ async queryText(text) { if (USE_MOCK_DATA) { @@ -88,12 +89,17 @@ export const agentService = { d.payload.stage?.toLowerCase().includes(q) || d.payload.crop_id?.toLowerCase().includes(q), ); + const results = filtered.length ? filtered : MOCK_DASHBOARD; return { status: "success", - results: (filtered.length ? filtered : MOCK_DASHBOARD).map((d) => ({ + results: results.map((d) => ({ id: d.id, + score: Math.random() * 0.3 + 0.7, payload: d.payload, })), + query_logic: { + must: q ? [{ key: "crop", match: q }] : [], + }, }; } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx @@ -30,8 +30,8 @@ export default function Sidebar() { const NAV = [ { label: "Crops", icon: LayoutGrid, path: "/dashboard" }, - { label: "Analytics", icon: BarChart3, path: "/analytics" }, { label: "Alerts", icon: Bell, path: "/alerts", badge: alertCount || null }, + { label: "Analytics", icon: BarChart3, path: "/analytics" }, { label: "Intelligence", icon: Sparkles, path: "/intelligence" }, { label: "Settings", icon: Settings, path: "/settings" }, ]; diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js @@ -11,6 +11,75 @@ const ts = (daysAgo, hour = 10, min = 0) => { return d.toISOString(); }; +// Explanation log samples +const EXPLANATION_LOGS = { + lettuce: `1. **Observation**: Sensors show pH 6.1, EC 1.4 dS/m, Temp 23.5°C, Humidity 68%. + All parameters are within acceptable range for Vegetative Lettuce. + +2. **Precedent**: 3 similar past states found. In 2 of those cases, a slight EC boost + improved growth rate by +12%. No disease was detected in the last 5 cycles. + +3. **Logic**: EC at 1.4 is slightly below the 1.5–1.8 target for late vegetative. + A small nutrient dosage increase will push it into the optimal window. + Fan speed is adequate; no VPD concerns at current temp/humidity. + +4. **Conclusion**: Dosing 2.5ml nutrients is the safest, most targeted intervention. + No pH correction needed. Maintain current atmospheric settings.`, + + tomato: `1. **Observation**: pH 5.8, EC 2.1 dS/m, Temp 26°C, Humidity 58%. + EC is elevated for flowering stage — approaching upper safe limit of 2.2. + +2. **Precedent**: 2 similar flowering Tomato states found. In one prior case, + EC > 2.2 triggered blossom drop. Acid dosage was effective in 1 case. + +3. **Logic**: pH 5.8 is at the lower end for Tomato flowering (optimal 6.0–6.5). + A controlled acid dose of 1.5ml will bring pH down slightly to 5.9, + while nutrient top-up at 4ml maintains bloom support without pushing EC over limit. + +4. **Conclusion**: Targeted acid dosage + nutrient boost is the correct intervention. + Increased fan speed to 60% to manage VPD in warm conditions.`, + + basil: `1. **Observation**: CRITICAL — pH 7.8 (target 5.5–6.5), EC 0.6 (very low), Temp 29.5°C. + Multiple out-of-range parameters detected simultaneously. + +2. **Precedent**: 1 similar critical Basil state found. Previous corrective action + required 8ml acid dosage to restore pH. Recovery took 2 cycles. + +3. **Logic**: pH 7.8 indicates severe alkalinity — likely nutrient lockout. + At this pH, iron and manganese become unavailable causing yellowing. + EC 0.6 confirms nutrient starvation. Aggressive correction required. + High temp (29.5°C) increases risk of bolting. + +4. **Conclusion**: Emergency acid dosage (8ml) is necessary. Fan at 80% to cool. + Nutrient addition deferred until pH stabilizes to avoid compounding stress.`, + + spinach: `1. **Observation**: pH 6.3, EC 1.6 dS/m, Temp 21°C, Humidity 72%. + All parameters within optimal range for Spinach Vegetative stage. + +2. **Precedent**: 4 similar states found, all showing positive outcomes. + Gentle interventions in this range historically improve harvest weight by 8–15%. + +3. **Logic**: Conditions are near-ideal. pH 6.3 is perfect for Spinach (optimal 6.0–7.0). + Light nutrient boost (1ml) maintains EC momentum. Water refill supports + root zone hydration without diluting nutrients significantly. + +4. **Conclusion**: Minimal intervention strategy. 1ml nutrient + 2L water refill. + Fan at 35% maintains gentle airflow — excessive airflow stresses cool-season crops.`, + + cucumber: `1. **Observation**: pH 5.5 (slightly acidic), EC 2.8 (high for fruiting), Temp 27.5°C. + Fruiting stage with elevated EC — risk of nutrient burn increasing. + +2. **Precedent**: 3 fruiting Cucumber states found. EC > 2.5 in 2 cases led to + tip burn on fruit edges. Water flush was effective in reducing EC. + +3. **Logic**: EC 2.8 exceeds the 2.5 ceiling for fruiting Cucumbers. + Base dosage (2ml) will gently raise pH from 5.5 toward optimal 5.8–6.2. + 5L water refill will dilute EC concentration to ~2.4, within safe range. + +4. **Conclusion**: Base pH correction + aggressive water refill to manage EC. + High fan speed (70%) compensates for elevated temperature and humidity needs.`, +}; + // Dashboard snapshots (latest per crop) export const MOCK_DASHBOARD = [ { @@ -33,6 +102,7 @@ export const MOCK_DASHBOARD = [ strategic_intent: "MAINTAIN_CURRENT", bandit_action_id: 0, reward_score: 0.8, + explanation_log: EXPLANATION_LOGS.lettuce, }, }, { @@ -55,6 +125,7 @@ export const MOCK_DASHBOARD = [ strategic_intent: "INCREASE_EC_BLOOM", bandit_action_id: 6, reward_score: 0.4, + explanation_log: EXPLANATION_LOGS.tomato, }, }, { @@ -77,6 +148,7 @@ export const MOCK_DASHBOARD = [ strategic_intent: "AGGRESSIVE_PH_DOWN", bandit_action_id: 2, reward_score: -0.6, + explanation_log: EXPLANATION_LOGS.basil, }, }, { @@ -99,6 +171,7 @@ export const MOCK_DASHBOARD = [ strategic_intent: "GENTLE_PH_BALANCING", bandit_action_id: 4, reward_score: 0.7, + explanation_log: EXPLANATION_LOGS.spinach, }, }, { @@ -121,12 +194,13 @@ export const MOCK_DASHBOARD = [ strategic_intent: "LOWER_EC_FLUSH", bandit_action_id: 7, reward_score: 0.3, + explanation_log: EXPLANATION_LOGS.cucumber, }, }, ]; // Detailed history per crop (multiple snapshots) -function makeHistory(cropId, cropName, stage, n, baseVals) { +function makeHistory(cropId, cropName, stage, n, baseVals, explanationLog) { return Array.from({ length: n }, (_, i) => { const jitter = (range) => (Math.random() - 0.5) * range; return { @@ -164,42 +238,54 @@ function makeHistory(cropId, cropName, stage, n, baseVals) { "MAX_AIR_CIRCULATION", ][i % 5], reward_score: i % 5 === 0 ? -0.4 : i % 3 === 0 ? 0.3 : 0.75, + // Only the most recent few entries have explanation logs + explanation_log: i >= n - 3 ? explanationLog : "PENDING_ANALYSIS", }, }; }); } export const MOCK_HISTORY = [ - ...makeHistory("Batch_Lettuce_2025A", "Lettuce", "Vegetative", 14, { - ph: 6.1, - ec: 1.4, - temp: 23.5, - humidity: 68, - }), - ...makeHistory("Batch_Tomato_2025B", "Tomato", "Flowering", 22, { - ph: 5.9, - ec: 2.0, - temp: 26.0, - humidity: 58, - }), - ...makeHistory("Batch_Basil_2025C", "Basil", "Seedling", 5, { - ph: 7.2, - ec: 0.7, - temp: 29.0, - humidity: 80, - }), - ...makeHistory("Batch_Spinach_2025D", "Spinach", "Vegetative", 9, { - ph: 6.3, - ec: 1.6, - temp: 21.5, - humidity: 72, - }), - ...makeHistory("Batch_Cucumber_2025E", "Cucumber", "Fruiting", 31, { - ph: 5.6, - ec: 2.7, - temp: 27.0, - humidity: 56, - }), + ...makeHistory( + "Batch_Lettuce_2025A", + "Lettuce", + "Vegetative", + 14, + { ph: 6.1, ec: 1.4, temp: 23.5, humidity: 68 }, + EXPLANATION_LOGS.lettuce, + ), + ...makeHistory( + "Batch_Tomato_2025B", + "Tomato", + "Flowering", + 22, + { ph: 5.9, ec: 2.0, temp: 26.0, humidity: 58 }, + EXPLANATION_LOGS.tomato, + ), + ...makeHistory( + "Batch_Basil_2025C", + "Basil", + "Seedling", + 5, + { ph: 7.2, ec: 0.7, temp: 29.0, humidity: 80 }, + EXPLANATION_LOGS.basil, + ), + ...makeHistory( + "Batch_Spinach_2025D", + "Spinach", + "Vegetative", + 9, + { ph: 6.3, ec: 1.6, temp: 21.5, humidity: 72 }, + EXPLANATION_LOGS.spinach, + ), + ...makeHistory( + "Batch_Cucumber_2025E", + "Cucumber", + "Fruiting", + 31, + { ph: 5.6, ec: 2.7, temp: 27.0, humidity: 56 }, + EXPLANATION_LOGS.cucumber, + ), ]; // Mock search / agent response @@ -213,18 +299,7 @@ export const MOCK_SEARCH_RESULT = { fan_speed_pct: 55, water_refill_l: 1.5, }, - explanation: `1. **Observation**: Sensors show pH 6.2, EC 1.4 dS/m, Temp 23.5°C, Humidity 68%. - All parameters are within acceptable range for Vegetative Lettuce. - -2. **Precedent**: 3 similar past states found. In 2 of those cases, a slight EC boost - improved growth rate. No disease was detected in the last 5 cycles. - -3. **Logic**: EC at 1.4 is slightly below the 1.5-1.8 target for late vegetative. - A small nutrient dosage increase will push it into the optimal window. - Fan speed is adequate; no VPD concerns. - -4. **Conclusion**: Dosing 3.0ml nutrients is the safest, most targeted intervention. - No pH correction needed. Maintain current atmospheric settings.`, + explanation: EXPLANATION_LOGS.lettuce, search_results: MOCK_DASHBOARD.slice(0, 3).map((d, i) => ({ id: d.id, score: 0.95 - i * 0.08, diff --git a/frontend/src/pages/AddCrop.jsx b/frontend/src/pages/AddCrop.jsx @@ -818,7 +818,7 @@ export default function AddCrop() { </> ) : ( <> - <Play size={15} fill="currentColor" /> Start Agent Cycle + <Play size={15} fill="currentColor" /> Start Monitoring </> )} </button> diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx @@ -1,7 +1,17 @@ import React, { useEffect, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { fetchCropDetails } from "../api/farmApi"; -import { ArrowLeft, Thermometer, Droplet, Wind, Activity } from "lucide-react"; +import { + ArrowLeft, + Thermometer, + Droplet, + Wind, + Activity, + Brain, + ChevronDown, + ChevronUp, + Sparkles, +} from "lucide-react"; import { AreaChart, Area, @@ -103,6 +113,303 @@ function logDotColor(payload) { return "var(--green)"; } +// Explanation block +function ExplanationLogBlock({ log }) { + const [expanded, setExpanded] = useState(false); + + const isPending = + !log || log === "PENDING_ANALYSIS" || log.trim().length === 0; + + if (isPending) { + return ( + <div + style={{ + borderRadius: 14, + padding: "18px 20px", + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + gap: 10, + marginBottom: 12, + }} + > + <div + style={{ + width: 32, + height: 32, + borderRadius: 8, + background: "rgba(167,139,250,0.08)", + border: "1px solid rgba(167,139,250,0.15)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <Brain size={14} style={{ color: "#a78bfa" }} /> + </div> + <div className="section-label" style={{ marginBottom: 0 }}> + AI DECISION REASONING + </div> + <span + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + padding: "2px 8px", + borderRadius: 20, + background: "var(--bg-3)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + > + PENDING + </span> + </div> + <div + style={{ + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + fontStyle: "italic", + }} + > + Explanation will be generated after the agent completes its first + analysis cycle for this crop. + </div> + </div> + ); + } + + // Parse log to detect numbered steps for highlighting + const lines = log.split("\n").filter((l) => l.trim()); + const isStructured = lines.some((l) => /^\d+\./.test(l.trim())); + + // Preview: first 3 lines + const previewLines = lines.slice(0, 3); + const hasMore = lines.length > 3; + + return ( + <div + style={{ + borderRadius: 14, + background: "var(--surface)", + border: "1px solid rgba(167,139,250,0.2)", + overflow: "hidden", + flexShrink: 0, + }} + > + {/* Header */} + <div + style={{ + padding: "14px 20px", + borderBottom: "1px solid var(--border)", + background: "rgba(167,139,250,0.05)", + display: "flex", + alignItems: "center", + gap: 10, + }} + > + <div + style={{ + width: 28, + height: 28, + borderRadius: 7, + background: "rgba(167,139,250,0.12)", + border: "1px solid rgba(167,139,250,0.25)", + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }} + > + <Sparkles size={12} style={{ color: "#a78bfa" }} /> + </div> + <div style={{ flex: 1 }}> + <div className="section-label" style={{ marginBottom: 0 }}> + AI DECISION REASONING + </div> + <div + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginTop: 2, + }} + > + Chain-of-thought log generated by the Explainer agent + </div> + </div> + {hasMore && ( + <button + onClick={() => setExpanded((e) => !e)} + style={{ + display: "flex", + alignItems: "center", + gap: 5, + padding: "4px 10px", + borderRadius: 7, + fontSize: 10, + fontFamily: "DM Mono, monospace", + cursor: "pointer", + background: "var(--bg-3)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + > + {expanded ? ( + <> + <ChevronUp size={11} /> Collapse + </> + ) : ( + <> + <ChevronDown size={11} /> Expand + </> + )} + </button> + )} + </div> + + {/* Content */} + <div style={{ padding: "16px 20px" }}> + {isStructured ? ( + // Render numbered steps as styled blocks + <div style={{ display: "flex", flexDirection: "column", gap: 10 }}> + {(expanded ? lines : previewLines).map((line, i) => { + const stepMatch = line + .trim() + .match(/^(\d+)\.\s+\*\*(.+?)\*\*:?\s*(.*)/); + + if (stepMatch) { + const [, num, title, body] = stepMatch; + const stepColors = [ + "var(--blue)", + "#a78bfa", + "var(--amber)", + "var(--green)", + ]; + const col = stepColors[(parseInt(num) - 1) % stepColors.length]; + return ( + <div + key={i} + style={{ + display: "flex", + gap: 12, + padding: "10px 14px", + borderRadius: 10, + background: "var(--bg-3)", + border: `1px solid ${col}18`, + }} + > + <div + style={{ + width: 22, + height: 22, + borderRadius: "50%", + background: `${col}18`, + border: `1px solid ${col}40`, + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: col, + fontWeight: 700, + }} + > + {num} + </div> + <div> + <div + style={{ + fontSize: 12, + fontWeight: 700, + color: col, + fontFamily: "DM Mono, monospace", + marginBottom: 3, + }} + > + {title} + </div> + {body && ( + <div + style={{ + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--text-2)", + lineHeight: 1.7, + }} + > + {body} + </div> + )} + </div> + </div> + ); + } + + // Regular line + return ( + <div + key={i} + style={{ + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--text-2)", + lineHeight: 1.7, + padding: "2px 0", + }} + > + {line} + </div> + ); + })} + + {!expanded && hasMore && ( + <button + onClick={() => setExpanded(true)} + style={{ + alignSelf: "flex-start", + padding: "5px 12px", + borderRadius: 7, + fontSize: 11, + fontFamily: "DM Mono, monospace", + cursor: "pointer", + background: "rgba(167,139,250,0.08)", + border: "1px solid rgba(167,139,250,0.2)", + color: "#a78bfa", + }} + > + + {lines.length - previewLines.length} more lines + </button> + )} + </div> + ) : ( + // Plain text fallback + <pre + style={{ + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--text-2)", + lineHeight: 1.75, + whiteSpace: "pre-wrap", + margin: 0, + maxHeight: expanded ? "none" : 140, + overflow: expanded ? "visible" : "hidden", + }} + > + {log} + </pre> + )} + </div> + </div> + ); +} + const TABS = ["overview", "sensors", "log"]; export default function CropDetails() { @@ -115,6 +422,7 @@ export default function CropDetails() { const [latest, setLatest] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState("overview"); + const [showExp, setShowExp] = useState(false); useEffect(() => { fetchCropDetails(cropId).then((data) => { @@ -379,6 +687,9 @@ export default function CropDetails() { /> )} + {/* Explanation Log */} + <ExplanationLogBlock log={p.explanation_log} /> + {/* pH chart */} <div style={{ @@ -618,139 +929,221 @@ export default function CropDetails() { {[...history] .reverse() .slice(0, logLimit) - .map((h, i) => ( - <div - key={i} - style={{ - borderBottom: "1px solid var(--border)", - transition: "background 0.12s", - cursor: "default", - }} - onMouseEnter={(e) => - (e.currentTarget.style.background = - "rgba(74,222,128,0.04)") - } - onMouseLeave={(e) => - (e.currentTarget.style.background = "transparent") - } - > - {/* Row header */} + .map((h, i) => { + const hasExplanation = + h.payload?.explanation_log && + h.payload.explanation_log !== "PENDING_ANALYSIS"; + + return ( <div + key={i} style={{ - display: "flex", - alignItems: "center", - gap: 10, - padding: "10px 20px", + borderBottom: "1px solid var(--border)", + transition: "background 0.12s", + cursor: "default", }} + onMouseEnter={(e) => + (e.currentTarget.style.background = + "rgba(74,222,128,0.04)") + } + onMouseLeave={(e) => + (e.currentTarget.style.background = "transparent") + } > - <span - style={{ - width: 7, - height: 7, - borderRadius: "50%", - background: logDotColor(h.payload), - flexShrink: 0, - }} - /> - <span - style={{ - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - flexShrink: 0, - width: 52, - }} - > - {h.payload?.timestamp - ? new Date(h.payload.timestamp).toLocaleTimeString( - [], - { hour: "2-digit", minute: "2-digit" }, - ) - : "--"} - </span> - <span + {/* Row header */} + <div style={{ - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", - width: 36, - flexShrink: 0, + display: "flex", + alignItems: "center", + gap: 10, + padding: "10px 20px", }} > - #{h.payload?.sequence_number || i} - </span> + <span + style={{ + width: 7, + height: 7, + borderRadius: "50%", + background: logDotColor(h.payload), + flexShrink: 0, + }} + /> + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + flexShrink: 0, + width: 52, + }} + > + {h.payload?.timestamp + ? new Date( + h.payload.timestamp, + ).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : "--"} + </span> + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + width: 36, + flexShrink: 0, + }} + > + #{h.payload?.sequence_number || i} + </span> - {/* Sensor snapshot */} - <div - style={{ display: "flex", gap: 12, flexWrap: "wrap" }} - > - {[ - { - label: "pH", - value: h.cleanSensors?.ph, - color: "var(--green)", - }, - { - label: "EC", - value: h.cleanSensors?.ec, - color: "var(--amber)", - }, - { - label: "T", - value: h.cleanSensors?.temp + "°", - color: "var(--blue)", - }, - { - label: "H", - value: h.cleanSensors?.humidity + "%", - color: "#a78bfa", - }, - ].map(({ label, value, color }) => ( - <span - key={label} - style={{ - fontSize: 13, - fontFamily: "DM Mono, monospace", - display: "flex", - alignItems: "baseline", - gap: 3, - }} - > + {/* Sensor snapshot */} + <div + style={{ + display: "flex", + gap: 12, + flexWrap: "wrap", + }} + > + {[ + { + label: "pH", + value: h.cleanSensors?.ph, + color: "var(--green)", + }, + { + label: "EC", + value: h.cleanSensors?.ec, + color: "var(--amber)", + }, + { + label: "T", + value: h.cleanSensors?.temp + "°", + color: "var(--blue)", + }, + { + label: "H", + value: h.cleanSensors?.humidity + "%", + color: "#a78bfa", + }, + ].map(({ label, value, color }) => ( <span - style={{ color: "var(--text-3)", fontSize: 11 }} + key={label} + style={{ + fontSize: 13, + fontFamily: "DM Mono, monospace", + display: "flex", + alignItems: "baseline", + gap: 3, + }} > - {label} - </span> - <span style={{ color, fontWeight: 700 }}> - {formatNumber(value)} + <span + style={{ + color: "var(--text-3)", + fontSize: 11, + }} + > + {label} + </span> + <span style={{ color, fontWeight: 700 }}> + {formatNumber(value)} + </span> </span> + ))} + </div> + + {/* Outcome badge */} + {h.payload?.outcome && ( + <span style={{ marginLeft: "auto", flexShrink: 0 }}> + <AgentOutcomeWidget + outcome={h.payload.outcome} + rewardScore={h.payload.reward_score} + /> </span> - ))} + )} + + {/* Explanation toggle */} + {hasExplanation && ( + <button + onClick={() => setShowExp((v) => !v)} + style={{ + flexShrink: 0, + display: "flex", + alignItems: "center", + gap: 4, + padding: "3px 8px", + borderRadius: 6, + fontSize: 9, + fontFamily: "DM Mono, monospace", + cursor: "pointer", + background: showExp + ? "rgba(167,139,250,0.12)" + : "var(--bg-3)", + border: `1px solid ${showExp ? "rgba(167,139,250,0.3)" : "var(--border)"}`, + color: showExp ? "#a78bfa" : "var(--text-3)", + }} + > + <Brain size={9} /> + {showExp ? "Hide" : "Why?"} + </button> + )} </div> - {/* Outcome badge */} - {h.payload?.outcome && ( - <span style={{ marginLeft: "auto", flexShrink: 0 }}> - <AgentOutcomeWidget - outcome={h.payload.outcome} - rewardScore={h.payload.reward_score} - /> - </span> - )} - </div> + {/* Action row */} + {h.payload?.action_taken && + h.payload.action_taken !== "PENDING_ACTION" && ( + <div style={{ padding: "0 20px 10px 46px" }}> + <AgentActionWidget + actionTaken={h.payload.action_taken} + compact + /> + </div> + )} - {/* Action row*/} - {h.payload?.action_taken && - h.payload.action_taken !== "PENDING_ACTION" && ( - <div style={{ padding: "0 20px 10px 46px" }}> - <AgentActionWidget - actionTaken={h.payload.action_taken} - compact - /> + {/* Explanation log inline */} + {hasExplanation && showExp && ( + <div + className="animate-fade-in" + style={{ + margin: "0 20px 12px 46px", + padding: "12px 14px", + borderRadius: 10, + background: "rgba(167,139,250,0.05)", + border: "1px solid rgba(167,139,250,0.15)", + }} + > + <div + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: "#a78bfa", + marginBottom: 8, + }} + > + <Brain + size={9} + style={{ display: "inline", marginRight: 5 }} + /> + AI REASONING FOR THIS DECISION + </div> + <pre + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-2)", + lineHeight: 1.7, + whiteSpace: "pre-wrap", + margin: 0, + }} + > + {h.payload.explanation_log} + </pre> </div> )} - </div> - ))} + </div> + ); + })} </div> </div> )} diff --git a/frontend/src/pages/FarmIntelligence.jsx b/frontend/src/pages/FarmIntelligence.jsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useRef, useState, useMemo, useEffect } from "react"; import { Activity, Mic, @@ -6,7 +6,6 @@ import { Brain, Search, Sparkles, - ChevronRight, Database, TrendingUp, TrendingDown, @@ -15,6 +14,14 @@ import { Thermometer, Wind, BookOpen, + MessageSquare, + ChevronDown, + Leaf, + X, + Cpu, + GitBranch, + Zap, + BarChart2, } from "lucide-react"; import { agentService } from "../api/agentApi"; import { extractSensors } from "../utils/dataUtils"; @@ -26,17 +33,340 @@ import Sidebar from "../components/Sidebar"; import { useFarmData } from "../hooks/useFarmData"; import { deriveCropStatus } from "../utils/dataUtils"; -// Suggestion chips -const SUGGESTIONS = [ - "Show all Lettuce crops", - "Which crops are in flowering stage?", - "Find crops with negative outcomes", - "List recent critical failures", +// Suggestion banks +const GLOBAL_SUGGESTIONS = [ + "Show all crops", + "Which crops are critical?", + "Find crops in flowering stage", + "List recent negative outcomes", "Show Tomato batches", - "Find crops with high EC readings", + "Find crops with high EC", ]; -// INSIGHTS +const CROP_SUGGESTIONS = (crop, cropId) => [ + `Why did we take the last decision for ${crop}?`, + `Explain the current action for ${cropId}`, + `Is ${crop} performing well?`, + `What should I watch out for with ${crop}?`, + `How has ${crop} been trending lately?`, + `Compare ${crop} to similar crops`, +]; + +// Thinking block renderer +function ThinkingBlock({ text }) { + const [open, setOpen] = useState(false); + if (!text) return null; + return ( + <div + style={{ + borderRadius: 10, + border: "1px solid rgba(167,139,250,0.2)", + background: "rgba(167,139,250,0.04)", + overflow: "hidden", + marginBottom: 14, + }} + > + <button + onClick={() => setOpen((o) => !o)} + style={{ + width: "100%", + display: "flex", + alignItems: "center", + gap: 8, + padding: "9px 14px", + background: "transparent", + border: "none", + cursor: "pointer", + textAlign: "left", + }} + > + <Brain size={11} style={{ color: "#a78bfa", flexShrink: 0 }} /> + <span + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "#a78bfa", + flex: 1, + }} + > + THINKING PROCESS {open ? "▲" : "▼"} + </span> + <span + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {text.split("\n").filter(Boolean).length} steps + </span> + </button> + {open && ( + <div + style={{ + padding: "0 14px 12px", + borderTop: "1px solid rgba(167,139,250,0.15)", + }} + > + <pre + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + lineHeight: 1.75, + whiteSpace: "pre-wrap", + color: "var(--text-3)", + margin: "10px 0 0", + }} + > + {text} + </pre> + </div> + )} + </div> + ); +} + +// LLM Answer block +function LLMAnswerBlock({ answer, thinking, query, cropContext }) { + if (!answer) return null; + + return ( + <div + className="animate-fade-in" + style={{ + borderRadius: 14, + background: "var(--surface)", + border: "1px solid rgba(167,139,250,0.25)", + overflow: "hidden", + }} + > + {/* Header */} + <div + style={{ + padding: "12px 18px", + borderBottom: "1px solid var(--border)", + background: "rgba(167,139,250,0.06)", + display: "flex", + alignItems: "center", + gap: 10, + }} + > + <div + style={{ + width: 26, + height: 26, + borderRadius: 6, + background: "rgba(167,139,250,0.15)", + border: "1px solid rgba(167,139,250,0.3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }} + > + <Sparkles size={12} style={{ color: "#a78bfa" }} /> + </div> + <div style={{ flex: 1 }}> + <div + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "#a78bfa", + fontWeight: 600, + }} + > + DEMETER INTELLIGENCE + </div> + {cropContext && ( + <div + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginTop: 1, + }} + > + Context: {cropContext.crop} · {cropContext.cropId} + </div> + )} + </div> + <div + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + padding: "2px 8px", + borderRadius: 20, + background: "var(--bg-3)", + border: "1px solid var(--border)", + }} + > + {cropContext ? "CROP-AWARE" : "FLEET-WIDE"} + </div> + </div> + + <div style={{ padding: 18 }}> + {/* Thinking */} + <ThinkingBlock text={thinking} /> + + {/* Answer */} + <div + style={{ + fontSize: 13, + fontFamily: "DM Mono, monospace", + color: "var(--text-2)", + lineHeight: 1.8, + whiteSpace: "pre-wrap", + }} + > + {answer} + </div> + </div> + </div> + ); +} + +// Related crops card +function RelatedCropCard({ item, score }) { + const p = item.payload || {}; + const s = extractSensors(p); + const status = deriveCropStatus(p); + + const statusColor = + status === "Healthy" + ? "var(--green)" + : status === "Attention" + ? "var(--amber)" + : "var(--red)"; + + const scoreColor = + score > 0.8 + ? "var(--green)" + : score > 0.6 + ? "var(--amber)" + : "var(--text-3)"; + + return ( + <div + style={{ + borderRadius: 12, + padding: "14px 16px", + background: "var(--bg-3)", + border: "1px solid var(--border)", + display: "flex", + flexDirection: "column", + gap: 10, + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }} + > + <div> + <div style={{ fontWeight: 700, fontSize: 13, color: "var(--text)" }}> + {p.crop || "Unknown"} + </div> + <div + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginTop: 2, + }} + > + {p.crop_id || "—"} · Seq #{p.sequence_number || 1} + </div> + </div> + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "flex-end", + gap: 4, + }} + > + <span + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + padding: "2px 7px", + borderRadius: 20, + color: statusColor, + background: `${statusColor}18`, + border: `1px solid ${statusColor}30`, + }} + > + {status.toUpperCase()} + </span> + {score !== undefined && ( + <span + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: scoreColor, + }} + > + {(score * 100).toFixed(0)}% match + </span> + )} + </div> + </div> + + {/* Mini sensor row */} + <div style={{ display: "flex", gap: 8 }}> + {[ + { label: "pH", value: s.ph, color: "var(--green)" }, + { label: "EC", value: s.ec, color: "var(--amber)" }, + { label: "T", value: `${s.temp}°`, color: "var(--blue)" }, + { label: "H", value: `${s.humidity}%`, color: "#a78bfa" }, + ].map(({ label, value, color }) => ( + <div + key={label} + style={{ + flex: 1, + textAlign: "center", + padding: "5px 4px", + borderRadius: 6, + background: "var(--surface)", + border: "1px solid var(--border)", + }} + > + <div + style={{ + fontSize: 9, + color: "var(--text-3)", + fontFamily: "DM Mono, monospace", + }} + > + {label} + </div> + <div + style={{ + fontSize: 11, + fontWeight: 700, + color, + fontFamily: "DM Mono, monospace", + marginTop: 1, + }} + > + {value} + </div> + </div> + ))} + </div> + + {/* Outcome */} + {p.outcome && p.outcome !== "PENDING_OBSERVATION" && ( + <AgentOutcomeWidget outcome={p.outcome} rewardScore={p.reward_score} /> + )} + </div> + ); +} + +// Main insight card (search results) function InsightCard({ result, idx }) { const p = result.payload || {}; const s = extractSensors(p); @@ -215,7 +545,7 @@ function InsightCard({ result, idx }) { ); } -// FLEET SUMMARY +// Fleet stat pill function FleetStat({ label, value, color, icon: Icon }) { return ( <div @@ -266,23 +596,274 @@ function FleetStat({ label, value, color, icon: Icon }) { ); } +// Crop selector dropdown +function CropSelector({ crops, selectedCrop, onSelect, onClear }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + return ( + <div ref={ref} style={{ position: "relative" }}> + <button + onClick={() => setOpen((o) => !o)} + style={{ + display: "flex", + alignItems: "center", + gap: 7, + padding: "7px 12px", + borderRadius: 10, + fontSize: 12, + fontFamily: "DM Mono, monospace", + cursor: "pointer", + background: selectedCrop ? "rgba(74,222,128,0.1)" : "var(--surface)", + border: `1px solid ${selectedCrop ? "rgba(74,222,128,0.35)" : "var(--border)"}`, + color: selectedCrop ? "var(--green)" : "var(--text-2)", + flexShrink: 0, + minWidth: 160, + justifyContent: "space-between", + whiteSpace: "nowrap", + }} + > + <div style={{ display: "flex", alignItems: "center", gap: 6 }}> + <Leaf size={12} style={{ flexShrink: 0 }} /> + <span + style={{ + maxWidth: 110, + overflow: "hidden", + textOverflow: "ellipsis", + }} + > + {selectedCrop + ? `${selectedCrop.crop} · ${selectedCrop.cropId}` + : "All Crops"} + </span> + </div> + <div style={{ display: "flex", alignItems: "center", gap: 4 }}> + {selectedCrop && ( + <span + onClick={(e) => { + e.stopPropagation(); + onClear(); + }} + style={{ + display: "flex", + alignItems: "center", + color: "var(--text-3)", + cursor: "pointer", + }} + > + <X size={11} /> + </span> + )} + <ChevronDown size={11} style={{ opacity: 0.6 }} /> + </div> + </button> + + {open && ( + <div + className="animate-fade-in" + style={{ + position: "absolute", + top: "calc(100% + 6px)", + left: 0, + zIndex: 50, + minWidth: 240, + borderRadius: 12, + background: "var(--surface)", + border: "1px solid var(--border)", + boxShadow: "0 8px 32px rgba(0,0,0,0.3)", + overflow: "hidden", + }} + > + <div + style={{ + padding: "8px 12px 6px", + borderBottom: "1px solid var(--border)", + }} + > + <div + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + SELECT CROP — {crops.length} available + </div> + </div> + + <div style={{ maxHeight: 260, overflowY: "auto" }}> + {/* All option */} + <div + onClick={() => { + onClear(); + setOpen(false); + }} + style={{ + padding: "9px 14px", + cursor: "pointer", + display: "flex", + alignItems: "center", + gap: 10, + background: !selectedCrop + ? "rgba(74,222,128,0.07)" + : "transparent", + borderBottom: "1px solid var(--border)", + }} + onMouseEnter={(e) => + (e.currentTarget.style.background = "rgba(255,255,255,0.04)") + } + onMouseLeave={(e) => + (e.currentTarget.style.background = !selectedCrop + ? "rgba(74,222,128,0.07)" + : "transparent") + } + > + <Database size={12} style={{ color: "var(--text-3)" }} /> + <div> + <div + style={{ + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--text-2)", + }} + > + All Crops + </div> + <div style={{ fontSize: 10, color: "var(--text-3)" }}> + Fleet-wide query + </div> + </div> + </div> + + {crops.map((c) => { + const isSelected = selectedCrop?.cropId === c.cropId; + const statusColor = + c.status === "Healthy" + ? "var(--green)" + : c.status === "Attention" + ? "var(--amber)" + : "var(--red)"; + return ( + <div + key={c.cropId} + onClick={() => { + onSelect(c); + setOpen(false); + }} + style={{ + padding: "9px 14px", + cursor: "pointer", + display: "flex", + alignItems: "center", + gap: 10, + background: isSelected + ? "rgba(74,222,128,0.07)" + : "transparent", + borderBottom: "1px solid rgba(255,255,255,0.04)", + }} + onMouseEnter={(e) => + (e.currentTarget.style.background = + "rgba(255,255,255,0.04)") + } + onMouseLeave={(e) => + (e.currentTarget.style.background = isSelected + ? "rgba(74,222,128,0.07)" + : "transparent") + } + > + <div + style={{ + width: 8, + height: 8, + borderRadius: "50%", + background: statusColor, + flexShrink: 0, + }} + /> + <div style={{ flex: 1 }}> + <div + style={{ + fontSize: 12, + fontFamily: "DM Mono, monospace", + color: "var(--text)", + fontWeight: isSelected ? 700 : 400, + }} + > + {c.crop} + </div> + <div + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + marginTop: 1, + }} + > + {c.cropId} · {c.stage} + </div> + </div> + <span + style={{ + fontSize: 9, + fontFamily: "DM Mono, monospace", + color: statusColor, + }} + > + {c.status.toUpperCase()} + </span> + </div> + ); + })} + </div> + </div> + )} + </div> + ); +} + // MAIN export default function FarmIntelligence() { const [textQuery, setTextQuery] = useState(""); const [loading, setLoading] = useState(false); const [results, setResults] = useState([]); + const [relatedCrops, setRelatedCrops] = useState([]); const [transcription, setTranscription] = useState(""); const [hasQueried, setHasQueried] = useState(false); - const [explanation, setExplanation] = useState(""); - const [showExplain, setShowExplain] = useState(false); + const [llmAnswer, setLlmAnswer] = useState(""); + const [llmThinking, setLlmThinking] = useState(""); + const [queryLogic, setQueryLogic] = useState(""); + const [showQueryLogic, setShowQueryLogic] = useState(false); const [isRecording, setIsRecording] = useState(false); const [toast, setToast] = useState(null); + const [selectedCrop, setSelectedCrop] = useState(null); + const [mode, setMode] = useState("search"); // "search" | "ask" const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const { dashboard } = useFarmData(); + // Build crop list from dashboard + const cropList = useMemo(() => { + if (!dashboard?.length) return []; + return dashboard.map((d) => ({ + crop: d.payload?.crop || "Unknown", + cropId: d.payload?.crop_id || d.id, + stage: d.payload?.stage || "", + status: deriveCropStatus(d.payload), + payload: d.payload, + })); + }, [dashboard]); + const showToast = (msg, type = "success") => { setToast({ msg, type }); setTimeout(() => setToast(null), 3000); @@ -301,14 +882,160 @@ export default function FarmIntelligence() { ).length, }; - const handleQuery = async (q) => { + // Build rich context for LLM + const buildLLMContext = (cropCtx) => { + if (cropCtx) { + // Specific crop context + const p = cropCtx.payload || {}; + const sensors = extractSensors(p); + + // Find history from dashboard for same cropId + const cropDashboardItems = (dashboard || []).filter( + (d) => d.payload?.crop_id === cropCtx.cropId, + ); + + return ` +CROP CONTEXT: +- Crop: ${p.crop || cropCtx.crop} +- Batch ID: ${p.crop_id || cropCtx.cropId} +- Growth Stage: ${p.stage || "Unknown"} +- Sequence Number: ${p.sequence_number || "—"} +- Last Updated: ${p.timestamp ? new Date(p.timestamp).toLocaleString() : "Unknown"} + +LATEST SENSOR READINGS: +- pH: ${sensors.ph} +- EC: ${sensors.ec} dS/m +- Temperature: ${sensors.temp}°C +- Humidity: ${sensors.humidity}% + +LATEST AGENT DECISION: +- Action Taken: ${p.action_taken && p.action_taken !== "PENDING_ACTION" ? p.action_taken : "None recorded"} +- Outcome: ${p.outcome && p.outcome !== "PENDING_OBSERVATION" ? p.outcome : "Pending"} +- Reward Score: ${p.reward_score ?? "N/A"} +- Strategic Intent: ${p.strategic_intent || "N/A"} + +EXPLANATION LOG (AI Decision Reasoning): +${p.explanation_log && p.explanation_log !== "PENDING_ANALYSIS" ? p.explanation_log : "Not yet generated."} + +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: +- Total crops: ${fleetStats.total} +- Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical} + +CURRENT CROPS: +${cropSummaries || "No crops in database."} + +SYSTEM: Hydroponic multi-crop farm management system (Demeter). +`.trim(); + } + }; + + // Ask LLM + const handleAsk = async (q) => { + const query = q || textQuery; + if (!query.trim()) return; + setLoading(true); + setHasQueried(true); + setTextQuery(query); + setLlmAnswer(""); + setLlmThinking(""); + setResults([]); + setRelatedCrops([]); + + try { + const context = buildLLMContext(selectedCrop); + + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "claude-sonnet-4-20250514", + max_tokens: 1000, + thinking: { type: "enabled", budget_tokens: 5000 }, + system: `You are Demeter Intelligence, an expert AI agronomist and data analyst for a hydroponic farm management system. +You have access to live farm data and must answer questions about crop health, agent decisions, and farm performance. +Be specific, cite the actual numbers from the data, and be practical. Keep answers concise but thorough. +When explaining agent decisions, reference the explanation_log if available.`, + messages: [ + { + role: "user", + content: `FARM DATA:\n${context}\n\nQUESTION: ${query}`, + }, + ], + }), + }); + + const data = await response.json(); + + let thinking = ""; + let answer = ""; + + for (const block of data.content || []) { + if (block.type === "thinking") thinking = block.thinking; + if (block.type === "text") answer = block.text; + } + + setLlmThinking(thinking); + setLlmAnswer(answer || "No response generated."); + + // 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 + .filter((r) => r.payload?.crop_id !== selectedCrop.cropId) + .slice(0, 3) + .map((r) => ({ + id: r.id, + score: r.score || 0.85, + payload: r.payload, + })), + ); + } + } catch { + // Related crops are optional + } + } + } catch (e) { + console.error(e); + showToast("LLM query failed", "error"); + setLlmAnswer("Failed to get a response. Please check your connection."); + } finally { + setLoading(false); + } + }; + + // Search (Qdrant filter) + const handleSearch = async (q) => { const query = q || textQuery; if (!query.trim()) return; setLoading(true); setHasQueried(true); setTextQuery(query); setResults([]); - setExplanation(""); + setLlmAnswer(""); + setLlmThinking(""); + setRelatedCrops([]); + setQueryLogic(""); + try { const data = await agentService.queryText(query); if (data.results) { @@ -320,13 +1047,22 @@ export default function FarmIntelligence() { })), ); } + // Show query interpretation if available + if (data.query_logic) + setQueryLogic(JSON.stringify(data.query_logic, null, 2)); } catch { - showToast("Query failed", "error"); + showToast("Search failed", "error"); } finally { setLoading(false); } }; + const handleQuery = (q) => { + if (mode === "ask") return handleAsk(q); + return handleSearch(q); + }; + + // Voice const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); @@ -373,6 +1109,10 @@ export default function FarmIntelligence() { } }; + const suggestions = selectedCrop + ? CROP_SUGGESTIONS(selectedCrop.crop, selectedCrop.cropId) + : GLOBAL_SUGGESTIONS; + return ( <div style={{ @@ -446,9 +1186,54 @@ export default function FarmIntelligence() { <div> <h1 className="page-title">Farm Intelligence</h1> <p className="page-subtitle"> - Query your crops · Explore patterns · Ask anything + Query your crops · Ask Demeter anything · Explore patterns </p> </div> + + {/* Mode toggle */} + <div + style={{ + marginLeft: "auto", + display: "flex", + gap: 0, + borderRadius: 9, + background: "var(--bg-3)", + border: "1px solid var(--border)", + padding: 3, + }} + > + {[ + { key: "search", label: "Search", icon: Search }, + { key: "ask", label: "Ask AI", icon: MessageSquare }, + ].map(({ key, label, icon: Icon }) => ( + <button + key={key} + onClick={() => { + setMode(key); + setHasQueried(false); + setResults([]); + setLlmAnswer(""); + setLlmThinking(""); + }} + style={{ + display: "flex", + alignItems: "center", + gap: 5, + padding: "5px 14px", + borderRadius: 7, + fontSize: 12, + fontFamily: "DM Mono, monospace", + cursor: "pointer", + background: mode === key ? "var(--surface)" : "transparent", + border: `1px solid ${mode === key ? "var(--border-bright)" : "transparent"}`, + color: mode === key ? "var(--text)" : "var(--text-3)", + fontWeight: mode === key ? 600 : 400, + }} + > + <Icon size={11} /> {label} + </button> + ))} + </div> </header> <div @@ -461,7 +1246,7 @@ export default function FarmIntelligence() { gap: 20, }} > - {/* Fleet summary */} + {/* Fleet stats */} <div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}> <FleetStat label="Total Crops" @@ -489,77 +1274,172 @@ export default function FarmIntelligence() { /> </div> - {/* Search bar */} - <div - style={{ - display: "flex", - gap: 8, - padding: 8, - borderRadius: 14, - background: "var(--surface)", - border: "1px solid var(--border)", - }} - > - <button - onClick={isRecording ? stopRecording : startRecording} + {/* Search / Ask bar */} + <div> + {/* Crop selector row */} + <div style={{ - width: 38, - height: 38, - borderRadius: 8, - flexShrink: 0, - cursor: "pointer", - background: isRecording - ? "rgba(248,113,113,0.15)" - : "var(--bg-3)", - border: `1px solid ${isRecording ? "rgba(248,113,113,0.4)" : "var(--border)"}`, - color: isRecording ? "var(--red)" : "var(--text-3)", display: "flex", alignItems: "center", - justifyContent: "center", + gap: 8, + marginBottom: 8, }} > - {isRecording ? <Square size={14} /> : <Mic size={14} />} - </button> - <input - value={textQuery} - onChange={(e) => setTextQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleQuery()} - placeholder="Ask anything — 'Show all Tomato crops', 'Which batches are critical?', 'Find failed cycles'…" - style={{ - flex: 1, - background: "transparent", - border: "none", - outline: "none", - fontSize: 14, - fontFamily: "DM Mono, monospace", - color: "var(--text)", - caretColor: "#a78bfa", - }} - /> - <button - onClick={() => handleQuery()} - disabled={loading} + <div + style={{ + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + {mode === "ask" ? "ASK ABOUT:" : "FILTER BY:"} + </div> + <CropSelector + crops={cropList} + selectedCrop={selectedCrop} + onSelect={setSelectedCrop} + onClear={() => setSelectedCrop(null)} + /> + {selectedCrop && ( + <div + style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "4px 10px", + borderRadius: 20, + background: "rgba(74,222,128,0.08)", + border: "1px solid rgba(74,222,128,0.2)", + fontSize: 10, + fontFamily: "DM Mono, monospace", + color: "var(--green)", + }} + > + <Leaf size={10} /> + Context loaded — {selectedCrop.stage} ·{" "} + {selectedCrop.status === "Healthy" + ? "✓ Healthy" + : selectedCrop.status === "Attention" + ? "⚠ Attention" + : "✕ Critical"} + </div> + )} + </div> + + {/* Input bar */} + <div style={{ - padding: "8px 22px", - borderRadius: 10, - fontSize: 13, - fontWeight: 600, - background: loading ? "var(--bg-3)" : "#a78bfa", - color: loading ? "var(--text-3)" : "#1a0a2e", - border: "none", - cursor: loading ? "not-allowed" : "pointer", - flexShrink: 0, + display: "flex", + gap: 8, + padding: 8, + borderRadius: 14, + background: "var(--surface)", + border: "1px solid var(--border)", }} > - {loading ? ( - <Activity size={13} className="animate-spin" /> - ) : ( - "Search" + <button + onClick={isRecording ? stopRecording : startRecording} + style={{ + width: 38, + height: 38, + borderRadius: 8, + flexShrink: 0, + cursor: "pointer", + background: isRecording + ? "rgba(248,113,113,0.15)" + : "var(--bg-3)", + border: `1px solid ${isRecording ? "rgba(248,113,113,0.4)" : "var(--border)"}`, + color: isRecording ? "var(--red)" : "var(--text-3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + {isRecording ? <Square size={14} /> : <Mic size={14} />} + </button> + + <input + value={textQuery} + onChange={(e) => setTextQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleQuery()} + placeholder={ + mode === "ask" + ? selectedCrop + ? `Ask anything about ${selectedCrop.crop}…` + : "Ask anything about your farm — decisions, trends, comparisons…" + : "Search crops — 'Show all Tomato', 'Which are critical?', 'Find flowering stage'…" + } + style={{ + flex: 1, + background: "transparent", + border: "none", + outline: "none", + fontSize: 14, + fontFamily: "DM Mono, monospace", + color: "var(--text)", + caretColor: mode === "ask" ? "#a78bfa" : "var(--green)", + }} + /> + + {textQuery && ( + <button + onClick={() => setTextQuery("")} + style={{ + background: "none", + border: "none", + cursor: "pointer", + color: "var(--text-3)", + display: "flex", + alignItems: "center", + padding: "0 4px", + }} + > + <X size={13} /> + </button> )} - </button> + + <button + onClick={() => handleQuery()} + disabled={loading} + style={{ + padding: "8px 22px", + borderRadius: 10, + fontSize: 13, + fontWeight: 600, + background: loading + ? "var(--bg-3)" + : mode === "ask" + ? "#a78bfa" + : "var(--green)", + color: loading + ? "var(--text-3)" + : mode === "ask" + ? "#1a0a2e" + : "#0c1a0e", + border: "none", + cursor: loading ? "not-allowed" : "pointer", + flexShrink: 0, + display: "flex", + alignItems: "center", + gap: 6, + }} + > + {loading ? ( + <Activity size={13} className="animate-spin" /> + ) : mode === "ask" ? ( + <> + <Sparkles size={12} /> Ask + </> + ) : ( + <> + <Search size={12} /> Search + </> + )} + </button> + </div> </div> - {/* Transcription badge */} + {/* Transcription */} {transcription && ( <div className="animate-fade-in" @@ -580,12 +1460,19 @@ export default function FarmIntelligence() { {/* Suggestion chips */} {!hasQueried && ( <div> - <div className="section-label">QUICK QUERIES</div> + <div className="section-label"> + {selectedCrop + ? `SUGGESTED QUESTIONS FOR ${selectedCrop.crop.toUpperCase()}` + : "QUICK QUERIES"} + </div> <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}> - {SUGGESTIONS.map((s) => ( + {suggestions.map((s) => ( <button key={s} - onClick={() => handleQuery(s)} + onClick={() => { + setTextQuery(s); + handleQuery(s); + }} style={{ padding: "7px 14px", borderRadius: 20, @@ -602,15 +1489,23 @@ export default function FarmIntelligence() { }} onMouseEnter={(e) => { e.currentTarget.style.borderColor = - "rgba(167,139,250,0.4)"; - e.currentTarget.style.color = "#a78bfa"; + mode === "ask" + ? "rgba(167,139,250,0.4)" + : "rgba(74,222,128,0.4)"; + e.currentTarget.style.color = + mode === "ask" ? "#a78bfa" : "var(--green)"; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.color = "var(--text-2)"; }} > - <Search size={10} /> {s} + {mode === "ask" ? ( + <MessageSquare size={10} /> + ) : ( + <Search size={10} /> + )}{" "} + {s} </button> ))} </div> @@ -619,193 +1514,257 @@ export default function FarmIntelligence() { {/* Loading skeleton */} {loading && ( - <div - style={{ - display: "grid", - gridTemplateColumns: "repeat(auto-fill, minmax(320px,1fr))", - gap: 14, - }} - > - {[1, 2, 3].map((i) => ( - <div - key={i} - className="shimmer" - style={{ - height: 200, - borderRadius: 14, - border: "1px solid var(--border)", - }} - /> - ))} - </div> - )} - - {/* Results */} - {!loading && hasQueried && ( - <> - <div style={{ display: "flex", alignItems: "center", gap: 12 }}> - <div className="section-label" style={{ margin: 0 }}> - {results.length > 0 - ? `${results.length} RESULT${results.length !== 1 ? "S" : ""} FOUND` - : "NO RESULTS"} - </div> - {results.length > 0 && ( - <span + <div style={{ display: "flex", flexDirection: "column", gap: 12 }}> + {mode === "ask" ? ( + <> + <div + className="shimmer" style={{ - fontSize: 11, - fontFamily: "DM Mono, monospace", - color: "var(--text-3)", + height: 48, + borderRadius: 10, + border: "1px solid var(--border)", }} - > - for "{textQuery}" - </span> - )} - {results.length > 0 && ( - <button - onClick={() => setShowExplain(!showExplain)} + /> + <div + className="shimmer" style={{ - marginLeft: "auto", - display: "flex", - alignItems: "center", - gap: 5, - fontSize: 11, - fontFamily: "DM Mono, monospace", - padding: "4px 10px", - borderRadius: 7, - cursor: "pointer", - background: "var(--surface)", + height: 180, + borderRadius: 14, border: "1px solid var(--border)", - color: "var(--text-3)", }} - > - <BookOpen size={11} /> {showExplain ? "Hide" : "View"} query - logic - </button> - )} - </div> - - {/* Show query interpretation */} - {showExplain && explanation && ( + /> + </> + ) : ( <div - className="animate-fade-in" style={{ - padding: 16, - borderRadius: 12, - background: "var(--bg-3)", - border: "1px solid var(--border)", + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(320px,1fr))", + gap: 14, }} > - <div className="section-label"> - SUPERVISOR QUERY INTERPRETATION - </div> - <pre - style={{ - fontSize: 12, - fontFamily: "DM Mono, monospace", - lineHeight: 1.7, - whiteSpace: "pre-wrap", - color: "var(--text-2)", - margin: 0, - }} - > - {explanation} - </pre> + {[1, 2, 3].map((i) => ( + <div + key={i} + className="shimmer" + style={{ + height: 200, + borderRadius: 14, + border: "1px solid var(--border)", + }} + /> + ))} </div> )} + </div> + )} - {results.length === 0 ? ( - <div - style={{ - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - padding: 48, - gap: 16, - borderRadius: 14, - background: "var(--surface)", - border: "1px dashed var(--border)", - }} - > + {/* Results */} + {!loading && hasQueried && ( + <> + {/* ── Ask mode: LLM answer ── */} + {mode === "ask" && ( + <> + <LLMAnswerBlock + answer={llmAnswer} + thinking={llmThinking} + query={textQuery} + cropContext={selectedCrop} + /> + + {/* Related crops */} + {relatedCrops.length > 0 && ( + <div> + <div + className="section-label" + style={{ marginBottom: 10 }} + > + <GitBranch + size={10} + style={{ display: "inline", marginRight: 5 }} + /> + SIMILAR CROPS IN DATABASE + </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} + /> + ))} + </div> + </div> + )} + </> + )} + + {/* Search mode: crop cards */} + {mode === "search" && ( + <> <div - style={{ - width: 52, - height: 52, - borderRadius: 16, - background: "var(--bg-3)", - border: "1px solid var(--border)", - display: "flex", - alignItems: "center", - justifyContent: "center", - }} + style={{ display: "flex", alignItems: "center", gap: 12 }} > - <Brain size={22} style={{ color: "var(--text-3)" }} /> + <div className="section-label" style={{ margin: 0 }}> + {results.length > 0 + ? `${results.length} RESULT${results.length !== 1 ? "S" : ""} FOUND` + : "NO RESULTS"} + </div> + {results.length > 0 && ( + <span + style={{ + fontSize: 11, + fontFamily: "DM Mono, monospace", + color: "var(--text-3)", + }} + > + for "{textQuery}" + </span> + )} + {queryLogic && ( + <button + onClick={() => setShowQueryLogic(!showQueryLogic)} + style={{ + marginLeft: "auto", + display: "flex", + alignItems: "center", + gap: 5, + fontSize: 11, + fontFamily: "DM Mono, monospace", + padding: "4px 10px", + borderRadius: 7, + cursor: "pointer", + background: "var(--surface)", + border: "1px solid var(--border)", + color: "var(--text-3)", + }} + > + <BookOpen size={11} />{" "} + {showQueryLogic ? "Hide" : "View"} query logic + </button> + )} </div> - <div style={{ textAlign: "center" }}> + + {showQueryLogic && queryLogic && ( <div + className="animate-fade-in" style={{ - fontSize: 14, - fontWeight: 600, - color: "var(--text-2)", + padding: 16, + borderRadius: 12, + background: "var(--bg-3)", + border: "1px solid var(--border)", }} > - No crops matched + <div className="section-label">QDRANT FILTER</div> + <pre + style={{ + fontSize: 12, + fontFamily: "DM Mono, monospace", + lineHeight: 1.7, + whiteSpace: "pre-wrap", + color: "var(--text-2)", + margin: "8px 0 0", + }} + > + {queryLogic} + </pre> </div> + )} + + {results.length === 0 ? ( <div style={{ - fontSize: 12, - color: "var(--text-3)", - marginTop: 6, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + padding: 48, + gap: 16, + borderRadius: 14, + background: "var(--surface)", + border: "1px dashed var(--border)", }} > - Try a different query or add crops from the Dashboard. - </div> - </div> - <div - style={{ - display: "flex", - gap: 8, - flexWrap: "wrap", - justifyContent: "center", - }} - > - {SUGGESTIONS.slice(0, 3).map((s) => ( + <div + style={{ + width: 52, + height: 52, + borderRadius: 16, + background: "var(--bg-3)", + border: "1px solid var(--border)", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + <Brain size={22} style={{ color: "var(--text-3)" }} /> + </div> + <div style={{ textAlign: "center" }}> + <div + style={{ + fontSize: 14, + fontWeight: 600, + color: "var(--text-2)", + }} + > + No crops matched + </div> + <div + style={{ + fontSize: 12, + color: "var(--text-3)", + marginTop: 6, + }} + > + Try a different query or switch to Ask AI mode for + natural language questions. + </div> + </div> <button - key={s} - onClick={() => handleQuery(s)} + onClick={() => setMode("ask")} style={{ - padding: "6px 12px", + display: "flex", + alignItems: "center", + gap: 6, + padding: "8px 16px", borderRadius: 20, - fontSize: 11, + fontSize: 12, fontFamily: "DM Mono, monospace", cursor: "pointer", - background: "var(--bg-3)", - border: "1px solid var(--border)", - color: "var(--text-3)", + background: "rgba(167,139,250,0.08)", + border: "1px solid rgba(167,139,250,0.2)", + color: "#a78bfa", }} > - {s} + <Sparkles size={11} /> Try Ask AI instead </button> - ))} - </div> - </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} /> - ))} - </div> + </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} /> + ))} + </div> + )} + </> )} </> )} - {/* Empty state before first query */} + {/* Empty state */} {!hasQueried && !loading && ( <div style={{ @@ -816,7 +1775,6 @@ export default function FarmIntelligence() { justifyContent: "center", padding: 48, gap: 20, - minHeight: 200, borderRadius: 16, background: "var(--surface)", border: "1px dashed var(--border)", @@ -836,7 +1794,7 @@ export default function FarmIntelligence() { > <Sparkles size={28} style={{ color: "#a78bfa" }} /> </div> - <div style={{ textAlign: "center", maxWidth: 380 }}> + <div style={{ textAlign: "center", maxWidth: 420 }}> <div style={{ fontWeight: 700, @@ -844,7 +1802,9 @@ export default function FarmIntelligence() { color: "var(--text)", }} > - Ask Demeter anything about your farm + {mode === "ask" + ? "Ask Demeter anything about your farm" + : "Search your crop database"} </div> <div style={{ @@ -854,46 +1814,84 @@ export default function FarmIntelligence() { lineHeight: 1.6, }} > - Use natural language — English, Hindi, or Hinglish — to search - your crop database. The AI Supervisor translates your query - into precise filters. + {mode === "ask" + ? "Select a specific crop for targeted questions, or ask fleet-wide questions. The AI uses live sensor data, agent decisions, and explanation logs to answer." + : "Use natural language to filter crops by type, stage, status or outcome. The supervisor translates your query into precise database filters."} </div> </div> - <div - style={{ - display: "flex", - alignItems: "center", - gap: 8, - flexWrap: "wrap", - justifyContent: "center", - }} - > - {[ - "Show all crops", - "Find critical plants", - "Tomato flowering stage", - ].map((s) => ( - <button - key={s} - onClick={() => handleQuery(s)} - style={{ - padding: "7px 16px", - borderRadius: 20, - fontSize: 12, - fontFamily: "DM Mono, monospace", - cursor: "pointer", - background: "rgba(167,139,250,0.08)", - border: "1px solid rgba(167,139,250,0.2)", - color: "#a78bfa", - display: "flex", - alignItems: "center", - gap: 5, - }} - > - <ChevronRight size={10} /> {s} - </button> - ))} - </div> + + {mode === "ask" && ( + <div + style={{ + display: "flex", + flexDirection: "column", + gap: 10, + width: "100%", + maxWidth: 440, + }} + > + {[ + { + icon: Cpu, + label: "Decision Reasoning", + desc: "Why did the agent take this action?", + }, + { + icon: BarChart2, + label: "Performance Analysis", + desc: "How is my crop trending?", + }, + { + icon: GitBranch, + label: "Comparative Insights", + desc: "How does this compare to other crops?", + }, + { + icon: Zap, + label: "Actionable Advice", + desc: "What should I do next?", + }, + ].map(({ icon: Icon, label, desc }) => ( + <div + key={label} + style={{ + display: "flex", + alignItems: "center", + gap: 12, + padding: "10px 16px", + borderRadius: 10, + background: "var(--bg-3)", + border: "1px solid var(--border)", + }} + > + <Icon + size={14} + style={{ color: "#a78bfa", flexShrink: 0 }} + /> + <div> + <div + style={{ + fontSize: 12, + fontWeight: 600, + color: "var(--text-2)", + }} + > + {label} + </div> + <div + style={{ + fontSize: 11, + color: "var(--text-3)", + marginTop: 1, + }} + > + {desc} + </div> + </div> + </div> + ))} + </div> + )} </div> )} </div>