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"
{name}" 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, 20, 1, key=f"{prefix}_rain") spi = st.slider(text["spi"], -3.0, 2.0, -1.0, 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, 20, 1, key=f"{prefix}_rain") spi = st.slider(text["spi"], -3.0, 2.0, -1.0, 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) -> List[str]: fallback = ( ["ЁЯМ▒ Fasal Badlein", "ЁЯТз Sinchai Sudharen", "ЁЯЫбя╕П Bima Karwain"] if language == "Hindi" else ["ЁЯМ▒ Shift Crop Mix", "ЁЯТз Improve Irrigation", "ЁЯЫбя╕П Take Insurance"] ) if not text: return fallback lines = [ln.strip(" -тАв\t") for ln in text.splitlines() if ln.strip()] picks = [ ln for ln in lines if any(k in ln.lower() for k in ["insurance", "crop", "irrig", "рдмреАрдорд╛", "рдлрд╕рд▓", "рд╕рд┐рдВрдЪ"]) ] return (picks[:3] if len(picks) >= 3 else fallback)[: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) 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): 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 = np.random.choice(crops, p=crop_probs) portfolio.append( { "age": int(np.random.randint(28, 62)), "land_size_acres": round(np.random.uniform(1, 10), 2), "annual_income_lakhs": round(np.random.uniform(1.5, 10), 2), "loan_amount_lakhs": round(max(0.5, np.random.normal(avg_loan, 0.6)), 2), "previous_defaults": int(np.random.choice([0, 1], p=[0.82, 0.18])), "crop_type": crop, "state": str(np.random.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) stress = run_stress_test(st.session_state.model, st.session_state.scaler, portfolio) rr = recovery_rate / 100 for row in stress: row["total_loss_lakhs"] = round(row["avg_default_pct"] / 100 * avg_loan * (1 - rr) * n_loans, 2) st.session_state.last_stress_result = {"stress": stress, "narratives": {}, "narrative_sources": {}} 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 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_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(worst_loss - normal_loss, 2) buf = int(gap // 100 * 100) cap_lines = ( f"Capital buffer indicator

" f"Baseline (normal monsoon) loss estimate: тВ╣{normal_loss} lakhs
" f"Incremental stress to worst scenario shown: тВ╣{gap} lakhs
" f"Rule-of-thumb buffer to discuss with risk: тВ╣{buf} lakhs+" ) 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"
{html.escape(text['hero_title'])}
" f"
{html.escape(text['hero_sub'])}
", 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()