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"
{html.escape(body)}
"
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"{html.escape(head)}
")
if body:
parts.append(f"{html.escape(body)}
")
if parts:
st.markdown(
"" + "".join(parts) + "
",
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"",
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"""
""",
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"{html.escape(text['section_profile'])}
", 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"{html.escape(text['section_climate'])}
", 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"{html.escape(u['lbl_score'])}
"
f"
{result['credit_score']} / 850
",
unsafe_allow_html=True,
)
with c2:
st.markdown(
f"{html.escape(u['lbl_risk'])}
"
f"
{html.escape(str(result['risk_band']))}
",
unsafe_allow_html=True,
)
with c3:
st.markdown(
f"{html.escape(u['lbl_default'])}
"
f"
{result['default_probability']}%
",
unsafe_allow_html=True,
)
def tab_kisan(client):
lang = st.session_state.language
text = LANG_TEXT[lang]
st.markdown(f"{html.escape(text['tab_kisan_hint'])}
", unsafe_allow_html=True)
left, right = st.columns([1.05, 1], gap="large")
with left:
st.markdown(f"{html.escape(text['your_info'])}
", 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"{html.escape(text['score_title'])}
", 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""
f"{html.escape(str(result['risk_band']))}
"
f"{html.escape(text['prob'])}: {result['default_probability']}%
",
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"{html.escape(item)}
",
unsafe_allow_html=True,
)
def tab_officer(client):
u = OFFICER_UI
st.markdown(f"{html.escape(u['form_title'])}
", unsafe_allow_html=True)
st.markdown(f"{html.escape(u['form_help'])}
", 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"{html.escape(u['scorecard'])}
", unsafe_allow_html=True)
render_score_tiles(result)
rec = html.escape(str(result["recommended_action"]))
note = html.escape(u["moratorium_note"])
st.markdown(
f"{html.escape(u['rec_action'])}: {rec}
{note}
",
unsafe_allow_html=True,
)
st.markdown(f"{html.escape(u['risk_drivers'])}
", 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"{html.escape(u['ai_report'])}
", 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"{html.escape(text['tab_portfolio_hint'])}
", unsafe_allow_html=True)
st.markdown("Portfolio configuration
", 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("Estimated loss by scenario (тВ╣ lakhs)
", 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("Average default rate by scenario
", 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("Portfolio risk narrative
", 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"Capital buffer indicator
"
f"Baseline (normal monsoon) loss estimate: тВ╣{round(normal_loss, 2)} lakhs
"
f"Worst scenario loss in this run: тВ╣{round(worst_loss, 2)} lakhs
"
f"Incremental climate tail (worst minus baseline): тВ╣{gap} lakhs тАФ use this gap when discussing extra provisioning vs normal weather."
)
st.markdown(f"{cap_lines}
", 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"{html.escape(text['app_subtitle'])}
",
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"",
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()