| """ |
| 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 = """ |
| @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; |
| } |
| """ |
|
|
| |
| |
| STATE_HIER = {} |
| STATE_CHOICES = [] |
|
|
| |
| 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"<strong style='color:#e11d48'>{unique} different names found</strong>" if unique > 1 else "<strong style='color:#16a34a'>All sources agree on the name β</strong>" |
| return f'<div class="result-heading" style="margin-bottom: 10px;">UDISE: {udise_code} Β· {len(results)} source(s) checked Β· {diff_note}</div>' |
|
|
| 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 = '<span class="match-badge">β Same Name</span>' |
| display = name if name and name.lower() not in ("nan","none","") else "<em style='color:#cbd5e1'>Not found</em>" |
| loc_html = f'<div class="card-location">π {loc}</div>' if loc else "" |
| return f""" |
| <div class="name-card {css}" style="margin-bottom:{margin_bottom}"> |
| <div class="card-tag">{icon}</div> |
| <div class="card-name">{display}{badge}</div> |
| {loc_html} |
| </div>""" |
|
|
| def build_empty_html(): |
| return """<div class="result-panel"> |
| <div class="empty-msg"> |
| π Select a location, enter a school name or UDISE code, and click <strong>Find Schools</strong><br> |
| <span style="font-size:0.82rem;color:#e2e8f0">Matching schools and their historical names will appear here</span> |
| </div> |
| </div>""" |
|
|
|
|
| |
| 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(): |
| |
| 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 = gr.themes.Base( |
| primary_hue=gr.themes.colors.violet, |
| secondary_hue=gr.themes.colors.blue, |
| neutral_hue=gr.themes.colors.slate, |
| ) |
|
|
| |
| with gr.Blocks(title="School Name Resolver") as app: |
|
|
| search_state = gr.State([]) |
| render_trigger = gr.State(0) |
|
|
| gr.HTML(""" |
| <div class="app-header"> |
| <h1>π« School Name Resolver</h1> |
| <p>Cross-reference marksheet names against historical UDISE records to resolve discrepancies</p> |
| </div> |
| """) |
|
|
| gr.HTML('<div class="step-box"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;"><span class="step-label" style="margin-bottom:0">Step 1 β Enter Location & Name</span><button id="clear-form-btn" style="display:none"></button></div>') |
| |
| 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('</div>') |
|
|
| gr.HTML('<div style="margin-bottom:4px"><span class="step-label" style="background:#dbeafe;color:#1d4ed8">Step 2 β Search Results & Mapping</span></div>') |
| |
| |
| @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, "") |
| |
| |
| 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): |
| |
| 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] |
| ) |
|
|
| |
| gr.HTML('<div style="margin-top: 20px;"></div>') |
| 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, |
| ) |
| |
| |
| 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]) |
|
|
| |
| 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), |
| gr.update(value=None), |
| [] |
| ) |
|
|
| 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]) |
|
|
| |
| 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 |
| ) |
|
|