"""AgroSense Streamlit chat UI. Run: streamlit run ui/app.py Talks to the FastAPI backend if it's reachable; otherwise falls back to running the RAG engine in-process so the UI works even without the API server. """ from __future__ import annotations import os import sys from pathlib import Path import requests import streamlit as st # Make the local package importable when run via `streamlit run ui/app.py`. sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) API_URL = os.getenv("AGROSENSE_API_URL", "http://127.0.0.1:8000") SAMPLE_QUERIES = [ "My soil is sandy, rainfall 900 mm, I plan to grow maize. What fertilizer and pest steps?", "Give me the fertilizer schedule for aromatic crops like mint.", "How do I prevent disease in paddy on loamy soil?", "What pest management is recommended for cotton?", ] st.set_page_config(page_title="AgroSense Advisor", page_icon="π±", layout="centered") # Increase the base font size by 3px (Streamlit's default root is 16px). Most text # (incl. the rem-based tickers) scales off the root, so this enlarges the whole app. st.markdown( "", unsafe_allow_html=True, ) def query_via_api(text: str, location: str | None, language: str, include_prices: bool) -> dict | None: try: payload: dict = {"query": text, "language": language, "include_prices": include_prices} if location: payload["location"] = location resp = requests.post(f"{API_URL}/query", json=payload, timeout=60) resp.raise_for_status() return resp.json() except Exception: return None @st.cache_resource(show_spinner="Loading AgroSense knowledge base...") def get_local_engine(): from agrosense import RAGEngine return RAGEngine() def query_local(text: str, location: str | None, language: str, include_prices: bool) -> dict: return get_local_engine().answer( text, location=location, language=language, include_prices=include_prices ).to_dict() @st.cache_data(show_spinner=False, ttl=1800) def get_satellite(location: str) -> dict | None: """Fetch satellite monitoring via the API, falling back to the local engine.""" try: resp = requests.get(f"{API_URL}/satellite", params={"location": location}, timeout=40) resp.raise_for_status() data = resp.json() return data if data.get("available") else None except Exception: pass try: report = get_local_engine().get_satellite(location=location) return report.to_dict() if report else None except Exception: return None @st.cache_data(show_spinner=False, ttl=1800) def get_environment(location: str) -> dict | None: """Fetch the location environment profile via the API, falling back to engine.""" try: resp = requests.get(f"{API_URL}/environment", params={"location": location}, timeout=40) resp.raise_for_status() data = resp.json() return data if data.get("available") else None except Exception: pass try: p = get_local_engine().get_environment(location=location) return p.to_dict() if p else None except Exception: return None def _m(value, suffix=""): return f"{value}{suffix}" if value is not None else "β" def render_environment(p: dict) -> None: sun, wind, aq = p.get("sunlight", {}), p.get("wind", {}), p.get("air_quality", {}) gw, pollen = p.get("groundwater", {}), p.get("pollen", {}) a, b, c = st.columns(3) a.metric("Latitude", _m(p.get("latitude"))) b.metric("Longitude", _m(p.get("longitude"))) c.metric("Altitude", _m(p.get("elevation_m"), " m")) # Wind: keep speed + bearing in the VALUE (not delta, which renders a β² arrow). wind_val = "β" if wind.get("speed_kmh") is not None: wind_val = f"{wind['speed_kmh']} km/h" if wind.get("direction_compass"): wind_val += f" {wind['direction_compass']}" d, e, f = st.columns(3) d.metric("Population", _m(p.get("population"))) e.metric("Humidity", _m(p.get("humidity_pct"), " %")) f.metric("Wind", wind_val) if wind.get("direction_deg") is not None: f.caption(f"from {wind['direction_deg']}Β°") g, h, i = st.columns(3) g.metric("Sunshine today", _m(sun.get("sunshine_hours"), " h")) h.metric("UV index (max)", _m(sun.get("uv_index_max"))) i.metric("Solar now", _m(sun.get("shortwave_wm2"), " W/mΒ²")) # AQI: category in the VALUE, not delta. aqi_val = "β" if aq.get("us_aqi") is not None: aqi_val = str(aq["us_aqi"]) if aq.get("category"): aqi_val += f" Β· {aq['category']}" j, k, l = st.columns(3) j.metric("Air quality (US AQI)", aqi_val) k.metric("PM2.5", _m(aq.get("pm2_5"), " Β΅g/mΒ³")) l.metric("PM10", _m(aq.get("pm10"), " Β΅g/mΒ³")) if pollen.get("available"): st.write("**Pollen** (grains/mΒ³): " + ", ".join(f"{k2}: {v}" for k2, v in pollen.get("values", {}).items())) else: st.caption("πΌ Pollen: " + pollen.get("note", "unavailable")) if gw.get("level_m") is not None: st.metric("Ground water table", f"{gw['level_m']} m below ground") else: st.metric("Ground water β soil moisture (3-9 cm)", _m(gw.get("soil_moisture_m3m3"), " mΒ³/mΒ³")) st.caption("π§ " + gw.get("note", "")) st.caption(f"Sources: {', '.join(p.get('sources', []))}") @st.cache_data(show_spinner=False, ttl=1800) def get_hazards(location: str) -> dict | None: """Natural-hazard events + active fires near the location.""" try: resp = requests.get(f"{API_URL}/hazards", params={"location": location}, timeout=40) resp.raise_for_status() data = resp.json() return data if data.get("available") else None except Exception: pass try: return get_local_engine().get_hazards(location=location) except Exception: return None def render_hazards(rep: dict) -> None: events = rep.get("events", []) if events: st.markdown(f"**{len(events)} natural-hazard event(s) within range** " f"(NASA EONET):") for e in events[:8]: dist = f" Β· {e['distance_km']} km away" if e.get("distance_km") is not None else "" st.warning(f"**{e['category']}** β {e['title']}{dist}") else: st.success("No active EONET hazard events within range.") fires = rep.get("fires") if fires is None: st.caption("π₯ Active fires: set AGROSENSE_FIRMS_MAP_KEY (free NASA FIRMS key) to enable.") elif not fires: st.caption("π₯ No active fire detections nearby (NASA FIRMS).") else: nearest = fires[0] st.error(f"π₯ {len(fires)} active fire detection(s) nearby (NASA FIRMS); " f"nearest {nearest.get('distance_km')} km away " f"(confidence {nearest.get('confidence')}, {nearest.get('acq_date')}).") def render_consult_session(c: dict) -> None: exp = c.get("expert") st.markdown(f"**Consultation {c['id']}** Β· status: **{c['status']}** Β· " f"channel: {c['channel']}") if exp: st.success(f"π¨ββοΈ Assigned: **{exp['name']}** β {exp['specialization']} \n" f"{exp['region']} Β· speaks {', '.join(exp['languages'])}") else: st.info("Queued β awaiting an available expert.") st.caption(f"Shared with expert: {c.get('summary', '')}") notes = c.get("notifications") or [] ok_channels = ", ".join(n["channel"] for n in notes if n.get("ok")) if ok_channels: st.caption(f"π Expert notified via: {ok_channels} " "(set AGROSENSE_NOTIFY_WEBHOOK / SMTP env for real delivery).") if c.get("room_url"): st.link_button("π₯ Join live video room", c["room_url"], width="stretch") st.caption("Public Jitsi room (may ask the first joiner to sign in as moderator).") st.markdown("**Conversation**") for m in c.get("messages", []): who = {"farmer": "π§βπΎ You", "expert": "π¨ββοΈ Expert", "system": "βΉοΈ System"}.get( m["sender"], m["sender"]) ts = f" Β· _{m['at']}_" if m.get("at") else "" st.markdown(f"- **{who}:** {m['text']}{ts}") def render_consultation(c: dict) -> None: hs = c.get("health_status", "Inconclusive") box = {"Likely healthy": st.success, "Needs attention": st.warning}.get(hs, st.info) box(f"**Diagnosis:** {c.get('diagnosis', 'β')} \n" f"**Health:** {hs} Β· **Severity:** {c.get('severity', 'n/a')} Β· " f"confidence {round(c.get('confidence', 0) * 100)}% (basis: {c.get('diagnosis_basis')})") if c.get("weather_note"): st.warning("π¦οΈ Timing: " + c["weather_note"]) st.markdown("**π Prescription**") for p in c.get("prescription", []): st.markdown(f"- **{p['category']}:** {p['instruction']}") if c.get("citations"): st.caption("Sources: " + ", ".join(c["citations"])) if c.get("follow_up"): st.markdown(f"**π Follow-up:** {c['follow_up']}") st.caption("βοΈ " + c.get("disclaimer", "")) def classify_image(image_bytes: bytes) -> dict: """Classify a plant/leaf image (disease + species), API then engine fallback.""" try: files = {"file": ("upload.jpg", image_bytes, "image/jpeg")} resp = requests.post(f"{API_URL}/vision/classify", files=files, params={"task": "all"}, timeout=60) resp.raise_for_status() return resp.json() except Exception: eng = get_local_engine() return {"disease": eng.predict_plant_disease(image_bytes), "plant": eng.predict_plant_species(image_bytes), "pest": eng.predict_pest(image_bytes)} @st.cache_data(show_spinner=False, ttl=1800) def get_advisories(location: str, crop: str | None, stage: str | None) -> dict | None: """Fetch fused decision advisories via the API, falling back to the engine.""" params = {"location": location} if crop: params["crop"] = crop if stage: params["stage"] = stage try: resp = requests.get(f"{API_URL}/advisories", params=params, timeout=60) resp.raise_for_status() data = resp.json() return data if data.get("available") else None except Exception: pass try: rep = get_local_engine().get_fusion_advisories(location=location, crop=crop, stage=stage) return rep.to_dict() if rep else None except Exception: return None @st.cache_data(show_spinner=False, ttl=900) def get_planetary(location: str) -> dict | None: """Fetch planetary positions via the API, falling back to the engine.""" try: resp = requests.get(f"{API_URL}/planetary", params={"location": location}, timeout=30) resp.raise_for_status() data = resp.json() return data if data.get("available") else None except Exception: pass try: rep = get_local_engine().get_planetary(location=location) return rep.to_dict() if rep else None except Exception: return None def render_planetary(rep: dict) -> None: mp = rep.get("moon_phase", {}) if mp: st.markdown(f"π **Moon phase:** {mp.get('name', 'β')} β " f"{round(mp.get('illumination', 0) * 100)}% illuminated") rows = [ {"Body": b["name"], "AltitudeΒ°": b["altitude_deg"], "AzimuthΒ°": b["azimuth_deg"], "Dir": b["azimuth_compass"], "Visible": "β " if b["above_horizon"] else "β"} for b in rep.get("bodies", []) ] if rows: st.dataframe(rows, hide_index=True) st.caption(f"{rep.get('utc_time')} Β· {rep.get('source')}") def render_advisories(report: dict) -> None: advs = report.get("advisories", []) if not advs: st.success("No urgent signals β conditions look unremarkable right now.") for a in advs: msg = f"**{a['title']}** β {a['action']} \n_Why: {a['rationale']}_" urgency = a.get("urgency") (st.error if urgency == "high" else st.warning if urgency == "medium" else st.info)(msg) img = report.get("imagery", {}) if img.get("ndvi"): st.image(img["ndvi"], caption="NDVI context (MODIS)", width="stretch") st.caption(f"Source: {report.get('source')}") def render_satellite(report: dict) -> None: st.markdown(f"**{report['location_name']}** Β· source: {report['source']}") c1, c2 = st.columns(2) with c1: st.image(report["imagery"]["true_color"], caption=f"True color Β· {report['truecolor_date']}", width="stretch") with c2: st.image(report["imagery"]["ndvi"], caption=f"NDVI (greener = denser vegetation) Β· {report['ndvi_date']}", width="stretch") ac = report.get("agroclimate") if ac: m1, m2, m3 = st.columns(3) m1.metric("Avg solar", f"{ac['avg_solar_mj']} MJ/mΒ²/d") m2.metric("Temp range", f"{ac['avg_tmin_c']}β{ac['avg_tmax_c']} Β°C") m3.metric(f"Rain ({ac['days']}d)", f"{ac['total_precip_mm']} mm") for note in ac.get("notes", []): st.info(note) nd = report.get("numeric_ndvi") if nd and nd.get("latest") is not None: st.markdown(f"**Field NDVI** Β· {nd['source']}") n1, n2, n3 = st.columns(3) n1.metric("Latest NDVI", nd["latest"], help=f"on {nd.get('latest_date')}") n2.metric("Mean NDVI", nd["mean"]) n3.metric("Trend", str(nd.get("trend"))) obs = nd.get("observations", []) if len(obs) >= 2: st.line_chart( {"date": [o["date"] for o in obs], "NDVI": [o["ndvi"] for o in obs]}, x="date", y="NDVI", ) for note in nd.get("notes", []): st.success(note) elif report.get("numeric_ndvi") is None: st.caption("Field-level NDVI not configured β set Earth Engine credentials to " "enable Sentinel-2 (~10 m) NDVI. Showing MODIS imagery + agroclimate.") if report["imagery"].get("worldview"): st.markdown(f"[π Open interactive view in NASA Worldview]({report['imagery']['worldview']})") def answer_query(text: str, location: str | None, language: str, include_prices: bool) -> tuple[dict, str]: result = query_via_api(text, location, language, include_prices) if result is not None: return result, "API" return query_local(text, location, language, include_prices), "in-process" def render_prices(prices: dict) -> None: s = prices.get("summary") or {} if s: c1, c2, c3 = st.columns(3) c1.metric("Modal min", f"βΉ{s['modal_min']}") c2.metric("Modal avg", f"βΉ{s['modal_avg']}") c3.metric("Modal max", f"βΉ{s['modal_max']}") rows = [ {"Market": r["market"], "State": r["state"], "Variety": r["variety"], "Min": r["min_price"], "Max": r["max_price"], "Modal": r["modal_price"], "Date": r["arrival_date"]} for r in prices.get("records", []) ] if rows: st.dataframe(rows, hide_index=True) for note in prices.get("notes", []): st.info(note) st.caption(f"Source: {prices.get('source')} (βΉ per quintal)") @st.cache_data(show_spinner=False, ttl=900) def get_news_items(region: str, query: str | None) -> list[dict]: """Latest headlines for a region/topic via the API, falling back to the engine.""" params: dict = {"limit": 15, "region": region} if query: params["query"] = query try: resp = requests.get(f"{API_URL}/news", params=params, timeout=20) resp.raise_for_status() return resp.json().get("items", []) except Exception: pass try: return [i.to_dict() for i in get_local_engine().get_news(query=query, region=region, limit=15)] except Exception: return [] @st.cache_data(show_spinner=False, ttl=900) def get_current_weather(location: str) -> dict | None: """Local weather for the top-bar strip, via API then engine fallback.""" try: r = requests.get(f"{API_URL}/weather", params={"location": location}, timeout=20) r.raise_for_status() data = r.json() return data if data.get("available") else None except Exception: pass try: wf = get_local_engine().get_weather(location=location) return wf.to_dict() if wf else None except Exception: return None def render_weather_strip(w: dict) -> None: daily = (w.get("daily") or [{}])[0] bits = [f"π€οΈ {w.get('location_name', '')}"] if w.get("current_temp_c") is not None: bits.append(f"{w['current_temp_c']}Β°C now") if w.get("current_humidity") is not None: bits.append(f"humidity {w['current_humidity']}%") if daily.get("tmin_c") is not None and daily.get("tmax_c") is not None: bits.append(f"today {daily['tmin_c']}β{daily['tmax_c']}Β°C") if daily.get("precip_mm") is not None: prob = daily.get("precip_prob") bits.append(f"rain {daily['precip_mm']}mm" + (f" ({prob}%)" if prob is not None else "")) st.markdown( "