sharanyaswarup's picture
Add village to the location breadcrumbs on result cards
c7a1c65
Raw
History Blame Contribute Delete
24.5 kB
"""
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"<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} &nbsp;Β·&nbsp; {len(results)} source(s) checked &nbsp;Β·&nbsp; {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>"""
# ─── 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("""
<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>')
# ── 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('<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,
)
# 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
)