climaiq-kisan / app.py
krishy
Refine scoring calibration, Kisan recommendations, and stress-test edge cases
2d723e9
Raw
History Blame Contribute Delete
58.3 kB
import html
import io
import os
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from climaiq_engine import CROP_WATER_MAP, load_model, predict_single, run_stress_test
from climaiq_gemma import (
explain_for_farmer,
explain_for_officer,
explain_portfolio_stress,
get_client,
ollama_verify_connection,
)
from climaiq_report_pdf import build_officer_assessment_pdf, split_report_sections
st.set_page_config(page_title="ClimaIQ Kisan", page_icon="🌾", layout="wide")
COLORS = {
"primary": "#2E7D32",
"accent": "#FF8F00",
"danger": "#C62828",
"safe": "#1B5E20",
"bg": "#F9F6F0",
"ink": "#1f2a20",
}
LANG_TEXT = {
"English": {
"app_subtitle": "Climate intelligence for agricultural lending",
"hero_title": "ClimaIQ Kisan",
"hero_sub": "One climate-adjusted credit model with three views: farmer, loan officer, and portfolio stress.",
"tab_kisan_hint": "Enter your details and climate signals, then get a plain-language score and guidance.",
"tab_officer_hint": "Compact borrower inputs, scorecard, risk drivers, and an AI report for credit decisions.",
"tab_portfolio_hint": "Simulate a book of loans and compare losses under four drought scenarios.",
"empty_kisan": "Fill the form on the left, then tap “Know Your Score”. Your gauge, default likelihood, and Gemma explanation appear on the right.",
"empty_officer": "Open the assessment panel, adjust fields, then run “Run ClimaIQ assessment” to see the scorecard and downloadable report.",
"empty_portfolio": "Set portfolio parameters, then run the stress test to see scenario losses and default rates.",
"your_info": "Your information",
"crop": "Crop Type",
"state": "State",
"land": "Land (acres)",
"income": "Annual Income (₹ Lakhs)",
"loan": "Loan Amount (₹ Lakhs)",
"default_hist": "Previous Default?",
"weather": "Current Climate Condition",
"rain_def": "Rainfall Deficit (%)",
"spi": "Drought Severity (SPI)",
"drought_years": "Consecutive Drought Years",
"cta": "Know Your Score",
"score_title": "Your ClimaIQ Score",
"prob": "Default Probability",
"actions": "What You Can Do",
"farmer_expl": "Plain-language summary (Gemma 4)",
"section_profile": "Profile & loan",
"section_climate": "This season’s climate",
"narrative_source_cloud": "This explanation came from Google Gemma in the cloud.",
"narrative_source_ollama": "This explanation came from Gemma on your computer (Ollama).",
"ollama_panel_title": "##### Local inference with Ollama",
"ollama_panel_intro": "Enter the address and model name that your Ollama install uses.",
"ollama_base_label": "Base URL",
"ollama_model_label": "Model name",
"ollama_model_help": "Must match a name from ollama list (e.g. gemma3:4b).",
"ollama_test_btn": "Test connection",
"ollama_setup_md": (
"**Setup**\n\n"
"1. Keep Ollama running (for example `brew services start ollama` on a Mac, or the Ollama app).\n\n"
"2. Pull a model, for example **`ollama pull gemma3:4b`**, then type the same name under **Model name**. "
"For a smaller, faster model, run **`ollama pull gemma2:2b`** and use **`gemma2:2b`**.\n\n"
"3. Run **`ollama list`** in Terminal. Your model must appear before narratives will work.\n\n"
"4. Tap **Test connection** below. You should see a green confirmation when Ollama and the model are ready.\n\n"
"**Demo:** Turn Wi‑Fi on and generate an officer report, then turn Wi‑Fi off. Responses still run on your machine."
),
"sidebar_mode_cloud": "Cloud (Google AI Studio)",
"sidebar_mode_ollama": "Local (Ollama, offline)",
"sidebar_gemma_caption": (
"Cloud suits a Hugging Face Spaces demo well. For Ollama, run ollama serve on your machine, "
"then you can turn Wi‑Fi off and narratives still run. When Local is on, use the bordered panel under the title for URL and model."
),
"sidebar_gemma_cloud_ok": "Gemma: cloud (Google AI Studio), API key present.",
"sidebar_gemma_cloud_missing": (
"Gemma: cloud unavailable (no GEMMA_API_KEY). Use Local (Ollama) for offline narratives, or add a Space secret."
),
"sidebar_gemma_local_verified": "Locally verified. Gemma on Ollama: {model} at {base}.",
"sidebar_gemma_local_unverified": "Gemma on Ollama: {model} at {base}.",
"hide_sidebar_label": "Hide side panel",
"hide_sidebar_help": "Gives charts and reports more horizontal space. Uncheck to open language and Gemma settings again.",
},
"Hindi": {
"app_subtitle": "कृषि ऋण के लिए जलवायु आधारित समझ",
"hero_title": "ClimaIQ Kisan",
"hero_sub": "एक ही मॉडल, तीन दृष्टि: किसान, ऋण अधिकारी, और पोर्टफोलियो तनाव परीक्षण।",
"tab_kisan_hint": "अपनी जानकारी और मौसम भरें, फिर स्कोर और सरल सलाह देखें।",
"tab_officer_hint": "संक्षिप्त फॉर्म, स्कोरकार्ड, जोखिम कारक और AI रिपोर्ट।",
"tab_portfolio_hint": "ऋणों की संख्या सेट करें और चार सूखा परिदृश्यों में नुकसान की तुलना करें।",
"empty_kisan": "बाईं ओर फॉर्म भरें, फिर “अपना स्कोर जानें” दबाएँ। दाईं ओर गेज, संभावना और सरल व्याख्या दिखेगी।",
"empty_officer": "पैनल खोलें, फील्ड बदलें, फिर मूल्यांकन बटन चलाएँ; स्कोरकार्ड और रिपोर्ट नीचे दिखेगी।",
"empty_portfolio": "पोर्टफोलियो सेट करके स्ट्रेस टेस्ट चलाएँ; परिदृश्य और चार्ट नीचे दिखेंगे।",
"your_info": "आपकी जानकारी",
"crop": "फसल",
"state": "राज्य",
"land": "ज़मीन (एकड़)",
"income": "सालाना आय (₹ लाख)",
"loan": "ऋण राशि (₹ लाख)",
"default_hist": "पहले डिफ़ॉल्ट?",
"weather": "इस मौसम का हाल",
"rain_def": "बारिश की कमी (%)",
"spi": "सूखा गंभीरता (SPI)",
"drought_years": "लगातार सूखा साल",
"cta": "अपना स्कोर जानें",
"score_title": "आपका ClimaIQ स्कोर",
"prob": "डिफ़ॉल्ट संभावना",
"actions": "आप क्या कर सकते हैं",
"farmer_expl": "सरल भाषा में सारांश (Gemma 4)",
"section_profile": "प्रोफाइल और ऋण",
"section_climate": "इस मौसम की स्थिति",
"narrative_source_cloud": "यह सारांश Google Gemma (क्लाउड) से आया है।",
"narrative_source_ollama": "यह सारांश आपके कंप्यूटर पर Gemma (Ollama) से आया है।",
"ollama_panel_title": "##### स्थानीय अनुमान: Ollama कनेक्शन",
"ollama_panel_intro": "वह पता और मॉडल का नाम भरें जो आपके Ollama में सेट है।",
"ollama_base_label": "आधार URL",
"ollama_model_label": "मॉडल का नाम",
"ollama_model_help": "`ollama list` में दिखा नाम यहाँ लिखें (उदा. gemma3:4b)।",
"ollama_test_btn": "कनेक्शन जाँचें",
"ollama_setup_md": (
"**सेटअप**\n\n"
"1. Ollama चालू रखें (Mac पर उदा. `brew services start ollama`, या Ollama ऐप)।\n\n"
"2. मॉडल खींचें, उदा. **`ollama pull gemma3:4b`**, फिर वही नाम **मॉडल का नाम** में लिखें। "
"छोटा/तेज़ विकल्प: **`ollama pull gemma2:2b`** और फिर **`gemma2:2b`** इस्तेमाल करें।\n\n"
"3. टर्मिनल में **`ollama list`** चलाएँ; बयान (नैरेटिव) से पहले आपका मॉडल सूची में होना चाहिए।\n\n"
"4. नीचे **कनेक्शन जाँचें** दबाएँ। Ollama और मॉडल तैयार हों तो हरी पुष्टि दिखेगी।\n\n"
"**डेमो:** Wi‑Fi चालू कर ऋण अधिकारी रिपोर्ट बनाएँ, फिर Wi‑Fi बंद करें; जवाब फिर भी आपके कंप्यूटर पर चलेगा।"
),
"sidebar_mode_cloud": "क्लाउड (Google AI Studio)",
"sidebar_mode_ollama": "स्थानीय (Ollama, ऑफ़लाइन)",
"sidebar_gemma_caption": (
"क्लाउड Hugging Face Spaces डेमो के लिए अच्छा है। Ollama के लिए अपने कंप्यूटर पर ollama serve चलाएँ, "
"फिर Wi‑Fi बंद करने पर भी बयान चल सकते हैं। स्थानीय मोड में URL और मॉडल शीर्षक के नीचे वाले बॉक्स में भरें।"
),
"sidebar_gemma_cloud_ok": "Gemma: क्लाउड (Google AI Studio), API कुंजी मिली।",
"sidebar_gemma_cloud_missing": (
"Gemma: क्लाउड उपलब्ध नहीं (GEMMA_API_KEY नहीं)। ऑफ़लाइन बयान के लिए स्थानीय (Ollama) चुनें, या Space में रहस्य जोड़ें।"
),
"sidebar_gemma_local_verified": "स्थानीय जाँच सफल। Ollama पर Gemma: {model}, पता {base}।",
"sidebar_gemma_local_unverified": "Ollama पर Gemma: {model}, पता {base}।",
"hide_sidebar_label": "साइड पैनल छुपाएँ",
"hide_sidebar_help": "चार्ट और रिपोर्ट को चौड़ाई मिलेगी। भाषा व Gemma सेटिंग वापस लाने के लिए अनचेक करें।",
},
}
SPI_LABELS = {
"English": {0: "Severe Drought", 1: "Moderate Drought", 2: "Normal"},
"Hindi": {0: "गंभीर सूखा", 1: "मध्यम सूखा", 2: "सामान्य"},
}
DROUGHT_OPTIONS = [0, 1, 2, 3]
DROUGHT_LABEL = {0: "0", 1: "1", 2: "2", 3: "3+"}
def format_gemma_inference_mode(mode: str) -> str:
"""Stable format_func for sidebar mode radio (lambdas here cause extra reruns / double clicks)."""
lang = st.session_state.get("language", "English")
if mode == "cloud":
return LANG_TEXT[lang]["sidebar_mode_cloud"]
return LANG_TEXT[lang]["sidebar_mode_ollama"]
def format_drought_years(value: int) -> str:
return DROUGHT_LABEL[value]
OFFICER_UI = {
"form_title": "Borrower assessment",
"form_help": "Same ClimaIQ engine as the farmer view. Field labels stay in English for branch and audit use.",
"expander": "Assessment inputs",
"run": "Run ClimaIQ assessment",
"empty": "Adjust inputs in the panel above, then run the assessment. Results persist while you explore other tabs.",
"scorecard": "Scorecard",
"lbl_score": "Credit score",
"lbl_risk": "Risk band",
"lbl_default": "Default likelihood",
"risk_drivers": "Top risk drivers",
"risk_drivers_caption": "Relative contribution to model risk for this profile (longer bar = stronger upward pressure on default likelihood).",
"ai_report": "AI assessment report",
"report_lang": "Report language",
"download": "Download PDF report",
"download_txt_fallback": "Download narrative (.txt)",
"micro_caption": "Driver strength at a glance (normalized to the strongest driver in the top three).",
"pdf_hint": "The PDF includes the scorecard table plus this narrative, ready for credit file notes.",
"moratorium_note": "If approved, consider a moratorium clause if SPI falls below −1.5 during the loan term.",
"rec_action": "Recommended action",
}
def narr_html(body: str) -> str:
"""Escape user/model text for safe insertion inside HTML blocks."""
return f"<div class='soft-note narr-body'>{html.escape(body)}</div>"
def render_officer_report_ui(report_text: str) -> None:
"""Render Gemma officer narrative with clear section headings (1. … 2. …)."""
for head, body in split_report_sections(report_text):
parts = []
if head:
parts.append(f"<div class='report-head'>{html.escape(head)}</div>")
if body:
parts.append(f"<div class='report-body'>{html.escape(body)}</div>")
if parts:
st.markdown(
"<div class='report-block'>" + "".join(parts) + "</div>",
unsafe_allow_html=True,
)
def render_driver_micro_bars(result: Dict) -> None:
drivers = result.get("top_risk_drivers") or []
if not drivers:
return
dlist = drivers[:3]
mx = max(abs(float(d["contribution"])) for d in dlist) or 1e-9
st.caption(OFFICER_UI["micro_caption"])
for d in dlist:
pct = min(100, int(100 * abs(float(d["contribution"])) / mx))
name = html.escape(str(d.get("display_name", "")))
st.markdown(
f"<div class='microbar-wrap'><span class='microbar-label'>{name}</span>"
f"<div class='microbar-track'><div class='microbar-fill' style='width:{pct}%'></div></div></div>",
unsafe_allow_html=True,
)
def inject_styles(hide_sidebar: bool = False) -> None:
sidebar_hide_css = ""
if hide_sidebar:
sidebar_hide_css = """
section[data-testid="stSidebar"] {
display: none !important;
}
[data-testid="stSidebarCollapsedControl"] {
display: none !important;
}
.main .block-container {
max-width: min(1420px, 98vw) !important;
padding-left: 1.75rem !important;
padding-right: 1.75rem !important;
}
"""
st.markdown(
f"""
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap');
html, body, [class*="css"] {{
font-family: "DM Sans", "Segoe UI", system-ui, sans-serif;
}}
.stApp {{
background: linear-gradient(180deg, #f9f6f0 0%, #f3efe6 100%);
color: {COLORS["ink"]};
}}
.main .block-container {{
padding-top: 1.25rem;
padding-bottom: 2.25rem;
max-width: 1180px;
}}
[data-testid="stSidebar"] {{
background: #123019;
border-right: 1px solid #204d2b;
color: #f6fff5;
}}
/* Do NOT use sidebar * {{ color }} — it breaks light widgets (white text on white inputs). */
[data-testid="stSidebar"] h1,
[data-testid="stSidebar"] h2,
[data-testid="stSidebar"] h3,
[data-testid="stSidebar"] p,
[data-testid="stSidebar"] .stMarkdown,
[data-testid="stSidebar"] [data-testid="stMarkdownContainer"] p {{
color: #f6fff5;
}}
[data-testid="stSidebar"] [data-testid="stHeading"] {{
color: #f6fff5;
}}
[data-testid="stSidebar"] [data-testid="stCaption"] {{
color: #c8e6c9 !important;
}}
[data-testid="stSidebar"] .stRadio [role="radiogroup"] {{
gap: 0.35rem;
align-items: center;
}}
[data-testid="stSidebar"] .stRadio label {{
background: rgba(255,255,255,0.06);
border-radius: 8px;
padding: 0.28rem 0.55rem;
color: #f6fff5 !important;
cursor: pointer;
user-select: none;
}}
[data-testid="stSidebar"] .stRadio input {{
cursor: pointer;
}}
.main .stRadio [role="radiogroup"] label {{
cursor: pointer;
user-select: none;
}}
[data-testid="stSidebar"] hr {{
border-color: rgba(255, 255, 255, 0.18) !important;
}}
/* Do not style sidebar text_input / expander via CSS — Streamlit Base Web uses
scoped rules that beat generic overrides. Ollama fields render in main instead. */
[data-testid="stSidebar"] code,
[data-testid="stSidebar"] pre {{
background: rgba(255, 255, 255, 0.12) !important;
color: #f4fff6 !important;
border: 1px solid rgba(255, 255, 255, 0.22);
padding: 0.12rem 0.4rem;
border-radius: 6px;
font-size: 0.88em;
}}
.header-wrap {{
background: linear-gradient(135deg, #ffffff 0%, #f5f8f3 100%);
border: 1px solid #e1eadf;
border-radius: 16px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}}
.header-title {{
font-size: 1.45rem;
font-weight: 700;
color: #142818;
letter-spacing: -0.02em;
margin-bottom: 0.2rem;
}}
.header-sub {{
color: #4a5c50;
font-size: 0.98rem;
line-height: 1.5;
max-width: 52rem;
}}
.tab-hint {{
color: #5a6d61;
font-size: 0.9rem;
line-height: 1.45;
margin: -0.2rem 0 1rem 0;
}}
.panel-title {{
font-weight: 600;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #3d5244;
margin-bottom: 0.5rem;
}}
.section-label {{
font-size: 0.8rem;
font-weight: 600;
color: {COLORS["primary"]};
margin: 0.35rem 0 0.5rem 0;
}}
.hero-metric {{
text-align: center;
padding: 0.85rem 0.6rem;
border-radius: 12px;
background: #ffffff;
border: 1px solid #e5e8df;
margin-top: 0.2rem;
margin-bottom: 0.5rem;
}}
.risk-pill {{
display: inline-block;
padding: 0.2rem 0.65rem;
border-radius: 999px;
font-weight: 700;
font-size: 0.86rem;
}}
.chip-high {{ background: #fde8e8; color: {COLORS["danger"]}; }}
.chip-medium {{ background: #fff4de; color: {COLORS["accent"]}; }}
.chip-low {{ background: #e8f5e9; color: {COLORS["safe"]}; }}
.action-chip {{
border: 1px solid #e5e8df;
border-radius: 12px;
padding: 0.7rem 0.6rem;
background: #fff;
min-height: 74px;
text-align: center;
font-weight: 600;
font-size: 0.9rem;
}}
.soft-note {{
background: #ffffff;
border: 1px solid #e5e8df;
border-left: 4px solid {COLORS["primary"]};
border-radius: 12px;
padding: 0.75rem 1rem;
margin-bottom: 0.65rem;
}}
.narr-body {{
line-height: 1.58;
font-size: 0.95rem;
color: #2a382e;
white-space: pre-line;
}}
.score-tile {{
background: #fff;
border: 1px solid #e3e7de;
border-radius: 12px;
padding: 0.65rem;
text-align: center;
}}
.score-k {{
font-size: 0.78rem;
letter-spacing: 0.04em;
color: #5e7566;
margin-bottom: 0.15rem;
}}
.score-v {{
font-size: 1.25rem;
font-weight: 700;
color: #203429;
}}
.small-muted {{
color: #6f6f6f;
font-size: 0.9rem;
}}
.stButton > button {{
border-radius: 10px;
border: 0;
font-weight: 600;
box-shadow: 0 1px 0 rgba(0,0,0,.06);
}}
[data-baseweb="tab-list"] button {{
font-weight: 600;
font-size: 0.95rem;
}}
div[data-testid="stExpander"] summary {{
font-weight: 600;
}}
.report-block {{
background: #fff;
border: 1px solid #e3e8e0;
border-radius: 12px;
padding: 0.85rem 1rem 0.95rem 1rem;
margin-bottom: 0.75rem;
border-left: 4px solid {COLORS["primary"]};
}}
.report-head {{
font-weight: 700;
font-size: 1rem;
color: {COLORS["safe"]};
margin-bottom: 0.45rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid #c8e6c9;
}}
.report-body {{
line-height: 1.62;
font-size: 0.95rem;
color: #2a382e;
white-space: pre-line;
}}
.microbar-wrap {{ margin-bottom: 0.5rem; }}
.microbar-label {{
font-size: 0.8rem;
color: #3d5244;
display: block;
margin-bottom: 3px;
}}
.microbar-track {{
height: 9px;
background: #e8ebe4;
border-radius: 99px;
overflow: hidden;
}}
.microbar-fill {{
height: 100%;
border-radius: 99px;
background: linear-gradient(90deg, {COLORS["primary"]}, {COLORS["danger"]});
}}
{sidebar_hide_css}
</style>
""",
unsafe_allow_html=True,
)
def init_state():
if "model" not in st.session_state or "scaler" not in st.session_state:
model, scaler = load_model()
st.session_state.model = model
st.session_state.scaler = scaler
if "language" not in st.session_state:
st.session_state.language = "Hindi"
if "last_result" not in st.session_state:
st.session_state.last_result = None
if "last_officer_result" not in st.session_state:
st.session_state.last_officer_result = None
if "last_stress_result" not in st.session_state:
st.session_state.last_stress_result = None
if "gemma_inference_mode" not in st.session_state:
st.session_state.gemma_inference_mode = os.environ.get("GEMMA_INFERENCE_MODE", "cloud")
if "ollama_base_url" not in st.session_state:
st.session_state.ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
if "ollama_model" not in st.session_state:
st.session_state.ollama_model = os.environ.get("OLLAMA_MODEL", "gemma3:4b")
if "ollama_last_verify_ok" not in st.session_state:
st.session_state.ollama_last_verify_ok = False
if "ollama_last_verify_msg" not in st.session_state:
st.session_state.ollama_last_verify_msg = None
if "ui_hide_sidebar" not in st.session_state:
st.session_state.ui_hide_sidebar = False
def _invalidate_gemma_outputs() -> None:
"""Clear cached Gemma strings when switching cloud ↔ Ollama."""
lr = st.session_state.get("last_result")
if lr and isinstance(lr, dict) and "explanations" in lr:
lr["explanations"] = {}
lr["explanation_sources"] = {}
lo = st.session_state.get("last_officer_result")
if lo and isinstance(lo, dict) and "reports" in lo:
lo["reports"] = {}
lo["report_sources"] = {}
ls = st.session_state.get("last_stress_result")
if ls and isinstance(ls, dict) and "narratives" in ls:
ls["narratives"] = {}
ls["narrative_sources"] = {}
st.session_state.ollama_last_verify_ok = False
st.session_state.ollama_last_verify_msg = None
def narrative_source_caption(mode: str, label_lang: str) -> str:
"""Short attribution for which Gemma backend produced the visible narrative."""
if mode == "ollama":
return LANG_TEXT[label_lang]["narrative_source_ollama"]
return LANG_TEXT[label_lang]["narrative_source_cloud"]
def get_inference_config() -> Tuple[str, str, str]:
mode = st.session_state.get("gemma_inference_mode", "cloud")
base = (st.session_state.get("ollama_base_url") or "http://127.0.0.1:11434").strip().rstrip("/")
model = (st.session_state.get("ollama_model") or "gemma3:4b").strip()
return mode, base, model
def render_ollama_connection_main() -> None:
"""
Ollama URL + model must live in the MAIN area, not the sidebar.
Streamlit’s Base Web inputs in the dark sidebar inherit unreadable contrast
no matter how much global CSS we add; scoped widget styles win.
"""
if st.session_state.get("gemma_inference_mode") != "ollama":
return
ui = LANG_TEXT[st.session_state.language]
with st.container(border=True):
st.markdown(ui["ollama_panel_title"])
st.caption(ui["ollama_panel_intro"])
st.markdown(ui["ollama_setup_md"])
c1, c2 = st.columns(2)
with c1:
st.text_input(ui["ollama_base_label"], key="ollama_base_url")
with c2:
st.text_input(
ui["ollama_model_label"],
key="ollama_model",
help=ui["ollama_model_help"],
)
btn_col, _ = st.columns([1, 3])
with btn_col:
if st.button(ui["ollama_test_btn"], type="secondary", use_container_width=True, key="ollama_test_btn"):
base = (st.session_state.get("ollama_base_url") or "").strip().rstrip("/")
model = (st.session_state.get("ollama_model") or "").strip()
ok, msg = ollama_verify_connection(
base_url=base or None,
model=model or None,
)
st.session_state.ollama_last_verify_ok = ok
st.session_state.ollama_last_verify_msg = msg
msg = st.session_state.get("ollama_last_verify_msg")
if msg:
if st.session_state.get("ollama_last_verify_ok"):
st.success(msg)
else:
st.error(msg)
def plotly_theme(fig):
fig.update_layout(
plot_bgcolor="rgba(0,0,0,0)",
paper_bgcolor="rgba(0,0,0,0)",
font=dict(family="Inter, Segoe UI, sans-serif", color="#24352a"),
margin=dict(l=12, r=12, t=20, b=20),
)
return fig
def get_gemma_client_safe():
key = os.environ.get("GEMMA_API_KEY")
if not key:
return None
try:
return get_client(key)
except Exception:
return None
def build_farmer_input(prefix: str, compact: bool = False, label_lang: Optional[str] = None) -> Dict:
lang = label_lang if label_lang is not None else st.session_state.language
text = LANG_TEXT[lang]
crops = list(CROP_WATER_MAP.keys())
states = ["Maharashtra", "Punjab"]
if compact:
c1, c2 = st.columns(2)
with c1:
crop = st.selectbox(text["crop"], crops, key=f"{prefix}_crop")
state = st.selectbox(text["state"], states, key=f"{prefix}_state")
land = st.slider(text["land"], 0.5, 20.0, 3.5, 0.5, key=f"{prefix}_land")
income = st.slider(text["income"], 1.0, 15.0, 3.0, 0.5, key=f"{prefix}_income")
with c2:
loan = st.slider(text["loan"], 0.5, 8.0, 2.0, 0.1, key=f"{prefix}_loan")
prev_default = st.radio(
text["default_hist"], ["No", "Yes"], horizontal=True, key=f"{prefix}_default"
)
rain_def = st.slider(text["rain_def"], 0, 60, 0, 1, key=f"{prefix}_rain")
spi = st.slider(text["spi"], -3.0, 2.0, 0.3, 0.1, key=f"{prefix}_spi")
drought = st.selectbox(
text["drought_years"],
DROUGHT_OPTIONS,
format_func=format_drought_years,
key=f"{prefix}_drought",
)
else:
st.markdown(f"<div class='section-label'>{html.escape(text['section_profile'])}</div>", unsafe_allow_html=True)
crop = st.selectbox(text["crop"], crops, key=f"{prefix}_crop")
state = st.selectbox(text["state"], states, key=f"{prefix}_state")
land = st.slider(text["land"], 0.5, 20.0, 3.5, 0.5, key=f"{prefix}_land")
income = st.slider(text["income"], 1.0, 15.0, 3.0, 0.5, key=f"{prefix}_income")
loan = st.slider(text["loan"], 0.5, 8.0, 2.0, 0.1, key=f"{prefix}_loan")
prev_default = st.radio(
text["default_hist"], ["No", "Yes"], horizontal=True, key=f"{prefix}_default"
)
st.markdown("---")
st.markdown(f"<div class='section-label'>{html.escape(text['section_climate'])}</div>", unsafe_allow_html=True)
rain_def = st.slider(text["rain_def"], 0, 60, 0, 1, key=f"{prefix}_rain")
spi = st.slider(text["spi"], -3.0, 2.0, 0.3, 0.1, key=f"{prefix}_spi")
spi_label_idx = 0 if spi <= -1.5 else 1 if spi <= -0.5 else 2
st.caption(SPI_LABELS[lang][spi_label_idx])
drought = st.selectbox(
text["drought_years"],
DROUGHT_OPTIONS,
format_func=format_drought_years,
key=f"{prefix}_drought",
)
return {
"age": 42,
"land_size_acres": land,
"annual_income_lakhs": income,
"loan_amount_lakhs": loan,
"previous_defaults": 1 if prev_default == "Yes" else 0,
"crop_type": crop,
"state": state,
"rainfall_deficit_pct": -float(rain_def),
"spi": float(spi),
"consecutive_drought_years": int(drought),
}
def risk_chip_class(risk_band: str) -> str:
if "High" in risk_band:
return "chip-high"
if "Medium" in risk_band:
return "chip-medium"
return "chip-low"
def risk_color_band(risk_band: str) -> str:
if "High" in risk_band:
return COLORS["danger"]
if "Medium" in risk_band:
return COLORS["accent"]
return COLORS["safe"]
def build_score_gauge(score: int, risk_band: str):
color = risk_color_band(risk_band)
fig = go.Figure(
go.Indicator(
mode="gauge+number",
value=score,
number={"font": {"size": 44, "color": color}},
title={"text": "ClimaIQ Score (300-850)", "font": {"size": 15}},
gauge={
"axis": {"range": [300, 850], "tickwidth": 1},
"bar": {"color": color, "thickness": 0.34},
"steps": [
{"range": [300, 600], "color": "#fde8e8"},
{"range": [600, 650], "color": "#fff4de"},
{"range": [650, 850], "color": "#e7f7ea"},
],
"borderwidth": 0,
},
)
)
fig.update_layout(height=275)
return plotly_theme(fig)
def extract_action_cards(text: str, language: str, farmer_input: Dict, result: Dict) -> List[str]:
fallback = (
["🌱 फसल विविधीकरण करें", "💧 सिंचाई दक्षता बढ़ाएँ", "🛡️ फसल बीमा लें"]
if language == "Hindi"
else ["🌱 Diversify crop mix", "💧 Improve irrigation efficiency", "🛡️ Take crop insurance"]
)
def _uniq(items: List[str]) -> List[str]:
out: List[str] = []
for it in items:
clean = it.strip()
if clean and clean not in out:
out.append(clean)
return out
picked_from_text: List[str] = []
if text:
lines = [ln.strip(" -•\t") for ln in text.splitlines() if ln.strip()]
picked_from_text = [
ln
for ln in lines
if any(k in ln.lower() for k in ["insurance", "crop", "irrig", "बीमा", "फसल", "सिंच"])
][:3]
rf = float(farmer_input.get("rainfall_deficit_pct", 0.0))
spi = float(farmer_input.get("spi", 0.0))
dyears = int(farmer_input.get("consecutive_drought_years", 0))
prev = int(farmer_input.get("previous_defaults", 0))
dti = float(farmer_input.get("loan_amount_lakhs", 0.0)) / max(float(farmer_input.get("annual_income_lakhs", 1.0)), 0.1)
crop = str(farmer_input.get("crop_type", ""))
risk_band = str(result.get("risk_band", ""))
dynamic: List[str] = []
if language == "Hindi":
if rf <= -20 or spi <= -1.2 or dyears >= 1:
dynamic.append("💧 तुरंत जल-संरक्षण योजना अपनाएँ (ड्रिप/मल्च/लाइन सिंचाई)")
if crop in {"Rice", "Sugarcane"} and rf <= -15:
dynamic.append("🌱 कम पानी वाली फसल/मिश्रित खेती पर विचार करें")
if dti > 0.5:
dynamic.append("📉 ऋण राशि या किश्त संरचना को आय के अनुसार पुनर्संतुलित करें")
if prev == 1:
dynamic.append("🧾 पिछले बकाये के लिए समयबद्ध चुकौती योजना बनाएं")
if "High" in risk_band or "Medium" in risk_band:
dynamic.append("🛡️ मौसम-आधारित फसल बीमा और नुकसान कवर सक्रिय करें")
else:
if rf <= -20 or spi <= -1.2 or dyears >= 1:
dynamic.append("💧 Start an immediate water-conservation plan (drip/mulch/scheduling)")
if crop in {"Rice", "Sugarcane"} and rf <= -15:
dynamic.append("🌱 Consider a lower water-intensity crop mix for the next cycle")
if dti > 0.5:
dynamic.append("📉 Rebalance loan size/instalments to better match farm income")
if prev == 1:
dynamic.append("🧾 Create a time-bound plan to clear past overdues")
if "High" in risk_band or "Medium" in risk_band:
dynamic.append("🛡️ Activate weather-index crop insurance and loss cover")
cards = _uniq(picked_from_text + dynamic + fallback)
return cards[:3]
def try_farmer_explanation(client, farmer_input, result, language: str) -> str:
mode, ollama_base, ollama_model = get_inference_config()
if mode == "ollama":
try:
return explain_for_farmer(
farmer_input,
result,
language=language,
client=None,
inference_mode="ollama",
ollama_base=ollama_base,
ollama_model=ollama_model,
)
except Exception as ex:
return (
f"Ollama error: {ex}\n\n"
"Check that `ollama serve` is running and the model is pulled "
f"(e.g. `ollama pull {ollama_model}`)."
)
if client is None:
return (
"Cloud mode needs GEMMA_API_KEY (Google AI Studio), or switch sidebar to "
"Local (Ollama) for offline narratives."
if language == "English"
else "क्लाउड मोड के लिए GEMMA_API_KEY चाहिए, या साइडबार में Local (Ollama) चुनें।"
)
try:
return explain_for_farmer(
farmer_input,
result,
language=language,
client=client,
inference_mode="cloud",
)
except Exception as ex:
return f"Gemma response unavailable: {ex}"
def render_score_tiles(result):
u = OFFICER_UI
c1, c2, c3 = st.columns(3)
with c1:
st.markdown(
f"<div class='score-tile'><div class='score-k'>{html.escape(u['lbl_score'])}</div>"
f"<div class='score-v'>{result['credit_score']} <span style='font-size:0.75rem;font-weight:500;color:#5e7566'>/ 850</span></div></div>",
unsafe_allow_html=True,
)
with c2:
st.markdown(
f"<div class='score-tile'><div class='score-k'>{html.escape(u['lbl_risk'])}</div>"
f"<div class='score-v'>{html.escape(str(result['risk_band']))}</div></div>",
unsafe_allow_html=True,
)
with c3:
st.markdown(
f"<div class='score-tile'><div class='score-k'>{html.escape(u['lbl_default'])}</div>"
f"<div class='score-v'>{result['default_probability']}%</div></div>",
unsafe_allow_html=True,
)
def tab_kisan(client):
lang = st.session_state.language
text = LANG_TEXT[lang]
st.markdown(f"<div class='tab-hint'>{html.escape(text['tab_kisan_hint'])}</div>", unsafe_allow_html=True)
left, right = st.columns([1.05, 1], gap="large")
with left:
st.markdown(f"<div class='panel-title'>{html.escape(text['your_info'])}</div>", unsafe_allow_html=True)
with st.container(border=True):
farmer_input = build_farmer_input("kisan", compact=False)
run = st.button(text["cta"], type="primary", use_container_width=True)
if run:
with st.spinner("Computing your ClimaIQ score…"):
result = predict_single(farmer_input, st.session_state.model, st.session_state.scaler)
explanation = try_farmer_explanation(client, farmer_input, result, lang)
mode_run, _, _ = get_inference_config()
st.session_state.last_result = {
"input": farmer_input,
"result": result,
"explanations": {lang: explanation},
"explanation_sources": {lang: mode_run},
}
st.success("Done. Your results are on the right.")
with right:
st.markdown(f"<div class='panel-title'>{html.escape(text['score_title'])}</div>", unsafe_allow_html=True)
if not st.session_state.last_result:
st.markdown(narr_html(text["empty_kisan"]), unsafe_allow_html=True)
return
stored = st.session_state.last_result
result = stored["result"]
if lang not in stored["explanations"]:
with st.spinner("Updating explanation for the selected language…"):
stored["explanations"][lang] = try_farmer_explanation(client, stored["input"], result, lang)
stored.setdefault("explanation_sources", {})[lang] = get_inference_config()[0]
explanation = stored["explanations"][lang]
expl_mode = stored.get("explanation_sources", {}).get(lang, get_inference_config()[0])
cards = extract_action_cards(explanation, lang, stored["input"], result)
st.plotly_chart(build_score_gauge(result["credit_score"], result["risk_band"]), use_container_width=True)
st.markdown(
f"<div class='hero-metric'><span class='risk-pill {risk_chip_class(result['risk_band'])}'>"
f"{html.escape(str(result['risk_band']))}</span><br>"
f"<b>{html.escape(text['prob'])}: {result['default_probability']}%</b></div>",
unsafe_allow_html=True,
)
st.markdown(f"**{text['farmer_expl']}**")
st.caption(narrative_source_caption(expl_mode, lang))
st.markdown(narr_html(explanation), unsafe_allow_html=True)
st.markdown(f"**{text['actions']}**")
a1, a2, a3 = st.columns(3)
for col, item in zip([a1, a2, a3], cards):
with col:
st.markdown(
f"<div class='action-chip'>{html.escape(item)}</div>",
unsafe_allow_html=True,
)
def tab_officer(client):
u = OFFICER_UI
st.markdown(f"<div class='panel-title'>{html.escape(u['form_title'])}</div>", unsafe_allow_html=True)
st.markdown(f"<div class='tab-hint'>{html.escape(u['form_help'])}</div>", unsafe_allow_html=True)
with st.expander(u["expander"], expanded=True):
borrower_input = build_farmer_input("officer", compact=True, label_lang="English")
run = st.button(u["run"], type="primary", use_container_width=True)
if run:
with st.spinner("Running ClimaIQ assessment…"):
result = predict_single(borrower_input, st.session_state.model, st.session_state.scaler)
st.session_state.last_officer_result = {
"input": borrower_input,
"result": result,
"reports": {},
"report_sources": {},
}
st.success("Assessment complete. Scorecard is below.")
if not st.session_state.last_officer_result:
st.markdown(narr_html(u["empty"]), unsafe_allow_html=True)
return
stored = st.session_state.last_officer_result
result = stored["result"]
st.markdown(f"<div class='panel-title'>{html.escape(u['scorecard'])}</div>", unsafe_allow_html=True)
render_score_tiles(result)
rec = html.escape(str(result["recommended_action"]))
note = html.escape(u["moratorium_note"])
st.markdown(
f"<div class='soft-note narr-body'><b>{html.escape(u['rec_action'])}:</b> {rec}<br><br>{note}</div>",
unsafe_allow_html=True,
)
st.markdown(f"<div class='panel-title'>{html.escape(u['risk_drivers'])}</div>", unsafe_allow_html=True)
st.caption(u["risk_drivers_caption"])
df = pd.DataFrame(result["top_risk_drivers"])
df["impact"] = df["contribution"].abs()
df = df.sort_values("impact", ascending=True)
bar = px.bar(
df,
x="impact",
y="display_name",
orientation="h",
color="impact",
color_continuous_scale=["#2E7D32", "#FF8F00", "#C62828"],
)
bar.update_layout(height=295, coloraxis_showscale=False, xaxis_title="Relative impact", yaxis_title="")
st.plotly_chart(plotly_theme(bar), use_container_width=True)
st.markdown(f"<div class='panel-title'>{html.escape(u['ai_report'])}</div>", unsafe_allow_html=True)
rep_lang = st.radio(u["report_lang"], ["English", "Hindi"], horizontal=True, key="officer_rep_lang")
if rep_lang not in stored["reports"]:
mode, ollama_base, ollama_model = get_inference_config()
if mode == "ollama":
try:
with st.spinner("Generating assessment report (Ollama)…"):
stored["reports"][rep_lang] = explain_for_officer(
stored["input"],
result,
language=rep_lang,
client=None,
inference_mode="ollama",
ollama_base=ollama_base,
ollama_model=ollama_model,
)
except Exception as ex:
stored["reports"][rep_lang] = (
f"Ollama error: {ex}\n\nEnsure `ollama serve` is running and "
f"`ollama pull {ollama_model}` has been run on this machine."
)
elif client is None:
stored["reports"][rep_lang] = (
"Cloud mode needs a `GEMMA_API_KEY` secret on this Space, or switch the sidebar to "
"Local (Ollama) for offline AI reports. Scorecard and charts above still work."
)
else:
try:
with st.spinner("Generating assessment report…"):
stored["reports"][rep_lang] = explain_for_officer(
stored["input"],
result,
language=rep_lang,
client=client,
inference_mode="cloud",
)
except Exception as ex:
stored["reports"][rep_lang] = f"Gemma response unavailable: {ex}"
stored.setdefault("report_sources", {})[rep_lang] = get_inference_config()[0]
report_text = stored["reports"][rep_lang]
rep_mode = stored.get("report_sources", {}).get(rep_lang, get_inference_config()[0])
st.caption(narrative_source_caption(rep_mode, rep_lang))
render_driver_micro_bars(result)
render_officer_report_ui(report_text)
st.caption(u["pdf_hint"])
try:
pdf_bytes = build_officer_assessment_pdf(report_text, stored["input"], result, rep_lang)
st.download_button(
u["download"],
data=pdf_bytes,
file_name=f"climaiq_officer_report_{rep_lang.lower()}.pdf",
mime="application/pdf",
use_container_width=True,
)
except Exception as ex:
st.warning(
f"PDF build failed ({ex}). First run may need network access to cache fonts; try again, or use the text fallback."
)
st.download_button(
u["download_txt_fallback"],
data=io.BytesIO(report_text.encode("utf-8")),
file_name=f"climaiq_officer_report_{rep_lang.lower()}.txt",
mime="text/plain",
use_container_width=True,
)
def build_portfolio(n_loans: int, avg_loan: float, dominant_crop: str):
"""
Synthetic portfolio draws. Uses an isolated RNG so each Run produces a fresh book even if
global np.random was seeded elsewhere (e.g. training helpers).
"""
rng = np.random.default_rng()
crops = list(CROP_WATER_MAP.keys())
probs_map = {
"Cotton": [0.5 if c == "Cotton" else 0.5 / (len(crops) - 1) for c in crops],
"Rice": [0.5 if c == "Rice" else 0.5 / (len(crops) - 1) for c in crops],
"Wheat": [0.5 if c == "Wheat" else 0.5 / (len(crops) - 1) for c in crops],
"Mixed": [1 / len(crops)] * len(crops),
}
crop_probs = probs_map[dominant_crop]
portfolio = []
for _ in range(n_loans):
crop = str(rng.choice(crops, p=crop_probs))
portfolio.append(
{
"age": int(rng.integers(28, 62)),
"land_size_acres": round(float(rng.uniform(1, 10)), 2),
"annual_income_lakhs": round(float(rng.uniform(1.5, 10)), 2),
"loan_amount_lakhs": round(max(0.5, float(rng.normal(avg_loan, 0.6))), 2),
"previous_defaults": int(rng.choice([0, 1], p=[0.82, 0.18])),
"crop_type": crop,
"state": str(rng.choice(["Maharashtra", "Punjab"], p=[0.6, 0.4])),
"rainfall_deficit_pct": 0.0,
"spi": 0.0,
"consecutive_drought_years": 0,
}
)
return portfolio
def tab_portfolio(client):
lang = st.session_state.language
text = LANG_TEXT[lang]
st.markdown(f"<div class='tab-hint'>{html.escape(text['tab_portfolio_hint'])}</div>", unsafe_allow_html=True)
st.markdown("<div class='panel-title'>Portfolio configuration</div>", unsafe_allow_html=True)
with st.container(border=True):
c1, c2, c3 = st.columns(3)
with c1:
n_loans = st.number_input("Number of loans to simulate", min_value=20, max_value=2000, value=100)
with c2:
avg_loan = st.number_input("Average loan size (₹ lakhs)", min_value=0.5, max_value=8.0, value=3.0, step=0.1)
with c3:
recovery_rate = st.slider("Recovery rate (%)", 0, 80, 30, 1)
dominant_crop = st.radio(
"Dominant crop in portfolio",
["Cotton", "Rice", "Wheat", "Mixed"],
horizontal=True,
key="portfolio_dominant_crop",
)
run = st.button("Run stress test", type="primary", use_container_width=True)
if run:
with st.spinner("Simulating portfolio across climate scenarios…"):
portfolio = build_portfolio(int(n_loans), float(avg_loan), dominant_crop)
rr = recovery_rate / 100
stress = run_stress_test(
st.session_state.model,
st.session_state.scaler,
portfolio,
avg_loan_lakhs=float(avg_loan),
recovery_rate=float(rr),
)
st.session_state.last_stress_result = {
"stress": stress,
"narratives": {},
"narrative_sources": {},
"portfolio_meta": {
"n_loans": int(n_loans),
"avg_loan": float(avg_loan),
"recovery_pct": int(recovery_rate),
"dominant_crop": dominant_crop,
},
}
st.success("Stress test complete. Charts are below.")
if not st.session_state.last_stress_result:
st.markdown(narr_html(text["empty_portfolio"]), unsafe_allow_html=True)
return
meta = st.session_state.last_stress_result.get("portfolio_meta")
if meta:
st.caption(
f"Charts reflect your last Run: {meta['n_loans']} loans, avg ticket ₹{meta['avg_loan']} lakhs, "
f"{meta['recovery_pct']}% recovery, dominant crop mix {meta['dominant_crop']}. "
"Change inputs and tap Run stress test again for a new simulated book."
)
stress = st.session_state.last_stress_result["stress"]
df = pd.DataFrame(stress)
order = ["Normal Monsoon", "Moderate Drought", "Severe Drought", "Back-to-Back Drought"]
df["scenario"] = pd.Categorical(df["scenario"], order, ordered=True)
df = df.sort_values("scenario")
st.markdown("<div class='panel-title'>Estimated loss by scenario (₹ lakhs)</div>", unsafe_allow_html=True)
st.caption(
"Illustrative portfolio simulation: loss scales with count of loans, average ticket, recovery rate, and modelled default rates."
)
bar = px.bar(
df,
x="total_loss_lakhs",
y="scenario",
orientation="h",
text="total_loss_lakhs",
color="scenario",
color_discrete_map={
"Normal Monsoon": "#2E7D32",
"Moderate Drought": "#FFB300",
"Severe Drought": "#E53935",
"Back-to-Back Drought": "#7F0000",
},
)
bar.update_traces(texttemplate="%{x:.2f}", textposition="outside", cliponaxis=False)
bar.update_layout(showlegend=False, yaxis_title="", xaxis_title="Estimated loss (₹ lakhs)", height=330)
st.plotly_chart(plotly_theme(bar), use_container_width=True)
st.markdown("<div class='panel-title'>Average default rate by scenario</div>", unsafe_allow_html=True)
st.caption("The step up from “normal” to stressed monsoons shows how climate shocks can move the book non-linearly.")
line = px.line(df, x="scenario", y="avg_default_pct", markers=True, color_discrete_sequence=[COLORS["danger"]])
line.update_layout(yaxis_title="Portfolio average default (%)", xaxis_title="", height=300)
st.plotly_chart(plotly_theme(line), use_container_width=True)
st.markdown("<div class='panel-title'>Portfolio risk narrative</div>", unsafe_allow_html=True)
rep_lang = st.radio("Narrative language", ["English", "Hindi"], horizontal=True, key="port_lang")
narratives = st.session_state.last_stress_result["narratives"]
if rep_lang not in narratives:
mode, ollama_base, ollama_model = get_inference_config()
if mode == "ollama":
try:
with st.spinner("Drafting portfolio narrative (Ollama)…"):
narratives[rep_lang] = explain_portfolio_stress(
stress,
language=rep_lang,
client=None,
inference_mode="ollama",
ollama_base=ollama_base,
ollama_model=ollama_model,
)
except Exception as ex:
narratives[rep_lang] = f"Ollama error: {ex}"
elif client is None:
narratives[rep_lang] = (
"Set `GEMMA_API_KEY` for cloud narratives, or use Local (Ollama) in the sidebar. "
"Figures above are already computed from your ClimaIQ engine."
)
else:
try:
with st.spinner("Drafting portfolio narrative…"):
narratives[rep_lang] = explain_portfolio_stress(
stress, language=rep_lang, client=client, inference_mode="cloud"
)
except Exception as ex:
narratives[rep_lang] = f"Gemma response unavailable: {ex}"
st.session_state.last_stress_result.setdefault("narrative_sources", {})[rep_lang] = get_inference_config()[0]
narr_mode = st.session_state.last_stress_result.get("narrative_sources", {}).get(
rep_lang, get_inference_config()[0]
)
st.caption(narrative_source_caption(narr_mode, rep_lang))
st.markdown(narr_html(narratives[rep_lang]), unsafe_allow_html=True)
normal_loss = float(df[df["scenario"] == "Normal Monsoon"]["total_loss_lakhs"].iloc[0])
worst_loss = float(df["total_loss_lakhs"].max())
gap = round(max(worst_loss - normal_loss, 0), 2)
cap_lines = (
f"<b>Capital buffer indicator</b><br><br>"
f"Baseline (normal monsoon) loss estimate: ₹{round(normal_loss, 2)} lakhs<br>"
f"Worst scenario loss in this run: ₹{round(worst_loss, 2)} lakhs<br>"
f"<b>Incremental climate tail</b> (worst minus baseline): ₹{gap} lakhs — use this gap when discussing extra provisioning vs normal weather."
)
st.markdown(f"<div class='soft-note narr-body'>{cap_lines}</div>", unsafe_allow_html=True)
def main():
init_state()
inject_styles(hide_sidebar=bool(st.session_state.get("ui_hide_sidebar", False)))
client = get_gemma_client_safe()
text = LANG_TEXT[st.session_state.language]
_, layout_toggle = st.columns([5.5, 1.5])
with layout_toggle:
st.checkbox(
text["hide_sidebar_label"],
key="ui_hide_sidebar",
help=text["hide_sidebar_help"],
)
with st.sidebar:
st.markdown("## ClimaIQ Kisan")
st.markdown(
f"<div class='small-muted' style='color:#c8e6c9;line-height:1.45'>{html.escape(text['app_subtitle'])}</div>",
unsafe_allow_html=True,
)
st.divider()
st.markdown("### Language / भाषा")
st.radio(
"Display language",
["English", "Hindi"],
horizontal=True,
label_visibility="collapsed",
key="language",
)
text = LANG_TEXT[st.session_state.language]
st.caption("Applies to farmer-facing labels and Gemma prompts tied to that view.")
st.divider()
st.markdown("### Gemma inference")
st.radio(
"Mode",
["cloud", "ollama"],
format_func=format_gemma_inference_mode,
key="gemma_inference_mode",
on_change=_invalidate_gemma_outputs,
)
st.caption(text["sidebar_gemma_caption"])
st.divider()
mode_now = st.session_state.get("gemma_inference_mode", "cloud")
if mode_now == "ollama":
ok_local = st.session_state.get("ollama_last_verify_ok")
model = st.session_state.get("ollama_model", "")
base = st.session_state.get("ollama_base_url", "")
if ok_local:
st.caption(text["sidebar_gemma_local_verified"].format(model=model, base=base))
else:
st.caption(text["sidebar_gemma_local_unverified"].format(model=model, base=base))
elif client is not None:
st.caption(text["sidebar_gemma_cloud_ok"])
else:
st.caption(text["sidebar_gemma_cloud_missing"])
st.divider()
st.markdown("### About")
st.caption("ClimaIQ engine + Gemma 4 explanations.")
st.caption("Model AUC (hold-out): 0.804")
text = LANG_TEXT[st.session_state.language]
st.markdown(
f"<div class='header-wrap'><div class='header-title'>{html.escape(text['hero_title'])}</div>"
f"<div class='header-sub'>{html.escape(text['hero_sub'])}</div></div>",
unsafe_allow_html=True,
)
render_ollama_connection_main()
tab1, tab2, tab3 = st.tabs(["Kisan view", "Loan officer view", "Portfolio stress test"])
with tab1:
tab_kisan(client)
with tab2:
tab_officer(client)
with tab3:
tab_portfolio(client)
if __name__ == "__main__":
main()