Spaces:
Sleeping
Sleeping
| 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() | |