""" app.py — Main Gradio application for the School Name Resolver. Run locally: python app.py HF Spaces: launched automatically by Dockerfile """ import os import gradio as gr from dotenv import load_dotenv from datetime import datetime load_dotenv() from hf_reader import search_udise, refresh_all_caches, get_config_status, get_state_hierarchy, search_by_name_fuzzy, get_udise_choices from alias_store import load_aliases, save_aliases, get_names_for_udise, delete_alias # ─── CSS ────────────────────────────────────────────────────────────────────── CSS = """ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); *, *::before, *::after { box-sizing: border-box; margin: 0; } body, .gradio-container { background: #f0f4ff !important; font-family: 'Inter', 'Segoe UI', sans-serif !important; color: #1e293b !important; } /* ── Header ── */ .app-header { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); border-radius: 18px; padding: 28px 36px; margin-bottom: 20px; text-align: center; box-shadow: 0 8px 32px rgba(79,70,229,0.25); } .app-header h1 { font-size: 1.9rem; font-weight: 800; color: #ffffff; letter-spacing: -0.5px; margin-bottom: 6px; } .app-header p { color: rgba(255,255,255,0.75); font-size: 0.95rem; } /* ── Step cards ── */ .step-box { background: #ffffff; border-radius: 14px; padding: 22px 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 16px; } .step-label { display: inline-block; background: #ede9fe; color: #6d28d9; font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; padding: 3px 10px; border-radius: 20px; margin-bottom: 10px; } /* ── Inputs ── */ label span { color: #475569 !important; font-size: 0.82rem !important; font-weight: 600 !important; text-transform: uppercase !important; letter-spacing: 0.5px !important; } input[type=text], textarea { background: #f8fafc !important; border: 1.5px solid #e2e8f0 !important; border-radius: 10px !important; color: #1e293b !important; font-size: 0.97rem !important; font-family: 'Inter', sans-serif !important; transition: border-color 0.2s, box-shadow 0.2s !important; } input[type=text]:focus, textarea:focus { border-color: #6d28d9 !important; box-shadow: 0 0 0 3px rgba(109,40,217,0.1) !important; outline: none !important; } /* ── Buttons ── */ .btn-delete button { background: #fee2e2 !important; color: #ef4444 !important; border: 1.5px solid #fca5a5 !important; height: 100% !important; min-height: 80px !important; border-radius: 11px !important; font-weight: 700 !important; font-size: 0.95rem !important; transition: all 0.2s !important; } .btn-delete button:hover { background: #fecaca !important; color: #b91c1c !important; } .btn-search button { background: linear-gradient(135deg, #4f46e5, #7c3aed) !important; color: white !important; font-weight: 700 !important; font-size: 1rem !important; border-radius: 12px !important; border: none !important; box-shadow: 0 4px 16px rgba(79,70,229,0.35) !important; transition: all 0.2s !important; padding: 12px !important; } .btn-search button:hover { transform: translateY(-1px) !important; box-shadow: 0 6px 22px rgba(79,70,229,0.45) !important; } .btn-map button { background: linear-gradient(135deg, #059669, #0891b2) !important; color: white !important; font-weight: 700 !important; font-size: 1rem !important; border-radius: 12px !important; border: none !important; box-shadow: 0 4px 16px rgba(5,150,105,0.3) !important; transition: all 0.2s !important; padding: 12px !important; } .btn-map button:hover { transform: translateY(-1px) !important; box-shadow: 0 6px 22px rgba(5,150,105,0.4) !important; } .btn-secondary button { background: #f1f5f9 !important; color: #64748b !important; font-weight: 600 !important; border-radius: 10px !important; border: 1.5px solid #e2e8f0 !important; transition: all 0.15s !important; } .btn-secondary button:hover { background: #e2e8f0 !important; color: #334155 !important; } /* ── Result cards ── */ .result-panel { background: #ffffff; border-radius: 14px; padding: 20px 22px; box-shadow: 0 2px 12px rgba(0,0,0,0.07); } .result-heading { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: #94a3b8; padding-bottom: 12px; border-bottom: 1.5px solid #f1f5f9; margin-bottom: 14px; } .marksheet-row { align-items: stretch !important; margin-bottom: 10px !important; gap: 12px !important; } .name-card { padding: 14px 16px; border-radius: 11px; border-left: 4px solid; height: 100%; } .card-marksheet { background: #fffbeb; border-color: #f59e0b; } .card-legacy { background: #eff6ff; border-color: #3b82f6; } .card-latest { background: #f0fdf4; border-color: #22c55e; } .card-old { background: #f8fafc; border-color: #cbd5e1; } .card-tag { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 4px; } .card-marksheet .card-tag { color: #d97706; } .card-legacy .card-tag { color: #2563eb; } .card-latest .card-tag { color: #16a34a; } .card-old .card-tag { color: #94a3b8; } .card-name { font-size: 1.05rem; font-weight: 700; color: #1e293b; line-height: 1.35; } .card-location { font-size: 0.78rem; color: #94a3b8; margin-top: 3px; } .match-badge { display: inline-block; background: #dcfce7; color: #16a34a; font-size: 0.65rem; font-weight: 700; padding: 2px 7px; border-radius: 8px; margin-left: 8px; vertical-align: middle; text-transform: uppercase; } /* ── Status text ── */ .map-status textarea, .map-status input { background: #f0fdf4 !important; border-color: #bbf7d0 !important; color: #15803d !important; font-weight: 600 !important; border-radius: 10px !important; } /* ── Empty / no result ── */ .empty-msg { text-align: center; padding: 40px 0; color: #cbd5e1; font-size: 0.95rem; } /* ── Accordion ── */ details { border-radius: 12px !important; } details summary { color: #64748b !important; font-weight: 600 !important; font-size: 0.88rem !important; padding: 10px 14px !important; background: #f8fafc !important; border-radius: 10px !important; } """ # ─── Data Initialization ────────────────────────────────────────────────────── # We now defer the heavy loading until the UI is booted so HuggingFace Spaces doesn't time out. STATE_HIER = {} STATE_CHOICES = [] # ─── HTML builders ──────────────────────────────────────────────────────────── def build_results_heading_html(results: list[dict], udise_code: str) -> str: if not results: return "" unique = len(set(r["name"].strip().upper() for r in results if r.get("name") and r["name"].lower() not in ("nan","none",""))) diff_note = f"{unique} different names found" if unique > 1 else "All sources agree on the name ✓" return f'
UDISE: {udise_code}  ·  {len(results)} source(s) checked  ·  {diff_note}
' def build_single_card_html(r: dict, ms_name: str, margin_bottom="10px") -> str: st = r.get("source_type", "") name = r.get("name", "").strip() src = r.get("source", "") ym = r.get("year_month", "") loc = " › ".join(p for p in [r.get("state",""), r.get("district",""), r.get("block",""), r.get("village","")] if p and p not in ("nan","none","")) css = {"marksheet": "card-marksheet", "legacy_db": "card-legacy", "latest_master": "card-latest", "old_master": "card-old"}.get(st, "card-old") if ym and "(" not in src: src = f"{src} ({ym})" icon = {"marksheet": "📄 Marksheet", "legacy_db": "📚 Old Master", "latest_master": "✅ Latest Master", "old_master": "🕐 Older Master"}.get(st, src) if ym: icon = f"{icon} ({ym})" badge = "" if ms_name and st != "marksheet" and name.upper() == ms_name.upper(): badge = '✓ Same Name' display = name if name and name.lower() not in ("nan","none","") else "Not found" loc_html = f'
📍 {loc}
' if loc else "" return f"""
{icon}
{display}{badge}
{loc_html}
""" def build_empty_html(): return """
🔍 Select a location, enter a school name or UDISE code, and click Find Schools
Matching schools and their historical names will appear here
""" # ─── Event handlers ─────────────────────────────────────────────────────────── def update_udise_dropdown(state, district, block, village): choices = get_udise_choices(state, district, block, village) return gr.update(choices=choices, value=None) def on_state_change(state): if not state: return gr.update(info="Select State"), gr.update(choices=[], value=None, interactive=False, info="Select State first") districts = sorted(STATE_HIER.get(state, {}).keys()) return gr.update(info=""), gr.update(choices=districts, value=None, interactive=True, info="Select District") def on_district_change(state, district): if not state: return gr.update(info="Select State first"), gr.update(choices=[], value=None, interactive=False, info="Select State first") if not district: return gr.update(info="Select District"), gr.update(choices=[], value=None, interactive=False, info="Select District first") blocks = sorted(STATE_HIER.get(state, {}).get(district, {}).keys()) return gr.update(info=""), gr.update(choices=blocks, value=None, interactive=True, info="Select Block") def on_block_change(state, district, block): if not state: return gr.update(info="Select State first"), gr.update(choices=[], value=None, interactive=False, info="Select State first") if not district: return gr.update(info="Select District first"), gr.update(choices=[], value=None, interactive=False, info="Select District first") if not block: return gr.update(info="Select Block"), gr.update(choices=[], value=None, interactive=False, info="Select Block first") villages = sorted(STATE_HIER.get(state, {}).get(district, {}).get(block, [])) return gr.update(info=""), gr.update(choices=villages, value=None, interactive=True, info="Select Village") def on_village_change(state, district, block, village): choices = get_udise_choices(state, district, block, village) if not state: return gr.update(info="Select State first"), gr.update(choices=choices, value=None) if not district: return gr.update(info="Select District first"), gr.update(choices=choices, value=None) if not block: return gr.update(info="Select Block first"), gr.update(choices=choices, value=None) if not village: return gr.update(info="Select Village"), gr.update(choices=choices, value=None) return gr.update(info=""), gr.update(choices=choices, value=None) def on_search(query_name, state, district, block, village, udise_val): if udise_val and str(udise_val).strip(): # User selected or typed a UDISE. Extract the code if they selected from dropdown (which is "UDISE - Name") u_code = str(udise_val).split(" - ")[0].strip() return [u_code] if not query_name: return [] matched_udises = search_by_name_fuzzy(query_name, state, district, block, village, max_results=5) return matched_udises def on_refresh_caches(): res = refresh_all_caches() global STATE_HIER, STATE_CHOICES STATE_HIER = get_state_hierarchy() STATE_CHOICES = sorted(STATE_HIER.keys()) return res, gr.update(choices=STATE_CHOICES) # ─── Theme ──────────────────────────────────────────────────────────────────── THEME = gr.themes.Base( primary_hue=gr.themes.colors.violet, secondary_hue=gr.themes.colors.blue, neutral_hue=gr.themes.colors.slate, ) # ─── UI ─────────────────────────────────────────────────────────────────────── with gr.Blocks(title="School Name Resolver") as app: search_state = gr.State([]) render_trigger = gr.State(0) gr.HTML("""

🏫 School Name Resolver

Cross-reference marksheet names against historical UDISE records to resolve discrepancies

""") gr.HTML('
Step 1 — Enter Location & Name
') with gr.Row(): state_dd = gr.Dropdown(label="State", choices=STATE_CHOICES, value=None, info="Select State") district_dd = gr.Dropdown(label="District", choices=[], value=None, interactive=False, info="Select State first") with gr.Row(): block_dd = gr.Dropdown(label="Block", choices=[], value=None, interactive=False, info="Select District first") village_dd = gr.Dropdown(label="Village", choices=[], value=None, interactive=False, info="Select Block first") with gr.Row(): school_input = gr.Textbox(label="School Name", placeholder="Enter school name from marksheet...", max_lines=1, scale=1) udise_input = gr.Dropdown(label="Or Search by UDISE Code", choices=[], allow_custom_value=True, scale=1) with gr.Row(): clear_btn = gr.Button("🗑️ Clear Form", elem_classes="btn-secondary", size="lg", scale=1) search_btn = gr.Button("🔍 Find Schools", elem_classes="btn-search", size="lg", scale=3) gr.HTML('
') gr.HTML('
Step 2 — Search Results & Mapping
') # ── Dynamic Results Rendering ── @gr.render(inputs=[search_state, render_trigger]) def render_results(udises, trigger): if not udises: gr.HTML(build_empty_html()) return for udise in udises: details = search_udise(udise, "") # Cross-reference with alias dictionary to pull in custom names mapped_names = get_names_for_udise(udise) detail_names_upper = {r.get("name","").upper() for r in details} for m_info in mapped_names: m_name = m_info.get("name", "").strip() if m_name and m_name.upper() not in detail_names_upper: details.append({ "source": m_info.get("source", "Saved Marksheet"), "source_type": "marksheet", "name": m_name, "year_month": m_info.get("year_month", "") }) with gr.Row(elem_classes="step-box"): with gr.Column(scale=3): with gr.Column(elem_classes="result-panel"): gr.HTML(build_results_heading_html(details, udise)) ms_name = next((r["name"] for r in details if r["source_type"] == "marksheet"), "") for r in details: if r.get("source_type") == "marksheet": with gr.Row(elem_classes="marksheet-row"): with gr.Column(scale=5, min_width=100): gr.HTML(build_single_card_html(r, ms_name, margin_bottom="0")) with gr.Column(scale=1, min_width=60): del_btn = gr.Button("🗑️", elem_classes="btn-delete") def make_on_delete(u_code=udise, n=r.get("name")): def on_delete(trigger_val): delete_alias(u_code, n) return trigger_val + 1 return on_delete del_btn.click(fn=make_on_delete(), inputs=[render_trigger], outputs=[render_trigger]) else: gr.HTML(build_single_card_html(r, ms_name)) with gr.Column(scale=1): # We removed the separate marksheet_input, we will just read directly from school_input! with gr.Row(): month_input = gr.Dropdown(label="Month", choices=["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], value=datetime.now().strftime("%b"), interactive=True, allow_custom_value=True) year_input = gr.Dropdown(label="Year", choices=[str(y) for y in range(2000, datetime.now().year + 2)], value=str(datetime.now().year), interactive=True, allow_custom_value=True) map_btn = gr.Button("Save Alias", elem_classes="btn-map") status_text = gr.Textbox(show_label=False, interactive=False, visible=False, elem_classes="map-status", max_lines=1) def on_map_inline(u, m, month_val, year_val, trigger_val, d=details): m = m.strip() if not m: return gr.update(visible=True, value="⚠️ Type a name in the search box above to save!"), trigger_val custom_ym = f"{year_val}-{month_val}" entries = [{"udise_code": u, "alias_name": m, "source_label": "Marksheet", "year_month": custom_ym}] seen = {m.upper()} for r in d: name = str(r.get("name","")).strip() if name and name.lower() not in ("nan","none","") and name.upper() not in seen: entries.append({ "udise_code": u, "alias_name": name, "source_label": r.get("source",""), "year_month": r.get("year_month", "") }) seen.add(name.upper()) msg = save_aliases(entries) return gr.update(visible=True, value=msg), trigger_val + 1 map_btn.click( fn=on_map_inline, inputs=[gr.State(udise), school_input, month_input, year_input, render_trigger], outputs=[status_text, render_trigger] ) # ── Download Master Sheet ── gr.HTML('
') download_aliases_btn = gr.DownloadButton("📥 Download Aliases Master Sheet (Excel)", elem_classes="btn-secondary", size="lg") def on_download_aliases(): from huggingface_hub import hf_hub_download import os import pandas as pd import tempfile token = os.getenv("HF_TOKEN") repo = os.getenv("HF_SCRAPER_REPO") if not token or not repo: return None try: path = hf_hub_download( repo_id=repo, filename="school_aliases.csv", repo_type="dataset", token=token, force_download=True, ) # Convert the CSV into a true Excel (.xlsx) file on the fly! df = pd.read_csv(path) import datetime date_str = datetime.datetime.now().strftime("%b_%Y") download_name = f"school_aliases_{date_str}.xlsx" temp_dir = tempfile.mkdtemp() out_path = os.path.join(temp_dir, download_name) df.to_excel(out_path, index=False) return out_path except Exception as e: print(f"Error downloading for UI: {e}") return None download_aliases_btn.click(fn=on_download_aliases, outputs=[download_aliases_btn]) # ── Settings ── with gr.Accordion("⚙️ Settings & Cache", open=False): refresh_cache_btn = gr.Button("🔄 Refresh Data Cache", elem_classes="btn-secondary") cache_status = gr.Textbox(show_label=False, interactive=False, max_lines=2) def on_clear_form(): return ( gr.update(value=None), # state gr.update(value=None), # school_input [] # search_state ) clear_btn.click( fn=on_clear_form, outputs=[state_dd, school_input, search_state] ) state_dd.change(fn=on_state_change, inputs=[state_dd], outputs=[state_dd, district_dd]) district_dd.change(fn=on_district_change, inputs=[state_dd, district_dd], outputs=[district_dd, block_dd]) block_dd.change(fn=on_block_change, inputs=[state_dd, district_dd, block_dd], outputs=[block_dd, village_dd]) village_dd.change(fn=on_village_change, inputs=[state_dd, district_dd, block_dd, village_dd], outputs=[village_dd, udise_input]) search_btn.click( fn=on_search, inputs=[school_input, state_dd, district_dd, block_dd, village_dd, udise_input], outputs=[search_state], ) school_input.submit( fn=on_search, inputs=[school_input, state_dd, district_dd, block_dd, village_dd, udise_input], outputs=[search_state], ) udise_input.change( fn=on_search, inputs=[school_input, state_dd, district_dd, block_dd, village_dd, udise_input], outputs=[search_state], ) refresh_cache_btn.click(fn=on_refresh_caches, outputs=[cache_status, state_dd]) def load_initial_data(): try: global STATE_HIER, STATE_CHOICES STATE_HIER = get_state_hierarchy() STATE_CHOICES = sorted(STATE_HIER.keys()) return gr.update(choices=STATE_CHOICES, interactive=True, info="Select State") except Exception as e: print(f"Failed to load data on boot: {e}") return gr.update(info="Failed to load data") app.load(fn=load_initial_data, outputs=[state_dd]) # ─── Launch ─────────────────────────────────────────────────────────────────── if __name__ == "__main__": port = int(os.getenv("PORT", 7860)) print(f"Starting Gradio app on port {port}...", flush=True) app.launch( server_name="0.0.0.0", server_port=port, theme=THEME, css=CSS, inbrowser=True )