sharanyaswarup commited on
Commit
3e2f38d
Β·
0 Parent(s):

Initial Commit

Browse files
Files changed (9) hide show
  1. .dockerignore +29 -0
  2. .env.example +3 -0
  3. .gitignore +24 -0
  4. Dockerfile +17 -0
  5. README.md +140 -0
  6. alias_store.py +120 -0
  7. app.py +560 -0
  8. hf_reader.py +204 -0
  9. requirements.txt +5 -0
.dockerignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables
2
+ .env
3
+
4
+ # Virtual environment (Docker will install its own packages)
5
+ venv/
6
+ env/
7
+ .venv/
8
+
9
+ # Python cache (prevent copying compiled files into the container)
10
+ __pycache__/
11
+ *.py[cod]
12
+ *$py.class
13
+ *.so
14
+
15
+ # Data files (App downloads these from HF Dataset at runtime, don't build them into the image)
16
+ *.csv
17
+ *.xlsx
18
+ *.parquet
19
+
20
+ # Git files
21
+ .git/
22
+ .gitignore
23
+ .dockerignore
24
+
25
+ # IDE and OS files
26
+ .vscode/
27
+ .idea/
28
+ .DS_Store
29
+ Thumbs.db
.env.example ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ HF_TOKEN=hf_your_token_here
2
+ HF_SCRAPER_REPO=Apf-AI4Good/kys-school-data
3
+ HF_RESOLVER_REPO=Apf-AI4Good/school-name-resolver-data
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables (Never push secrets!)
2
+ .env
3
+
4
+ # Virtual environment
5
+ venv/
6
+ env/
7
+ .venv/
8
+
9
+ # Python cache
10
+ __pycache__/
11
+ *.py[cod]
12
+ *$py.class
13
+ *.so
14
+
15
+ # Data files (These belong in the HF Dataset, NOT the HF Space code)
16
+ *.csv
17
+ *.xlsx
18
+ *.parquet
19
+
20
+ # IDE and OS files
21
+ .vscode/
22
+ .idea/
23
+ .DS_Store
24
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy application
10
+ COPY . .
11
+
12
+ # HuggingFace Spaces exposes port 7860
13
+ EXPOSE 7860
14
+
15
+ ENV PORT=7860
16
+
17
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: School Name Resolver
3
+ emoji: 🏫
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # 🏫 School Name Resolver
11
+
12
+ A comprehensive, Gradio-based web application built for the **Scholarship Operations Team** to efficiently resolve discrepancies in school names when validating applicant marksheets.
13
+
14
+ ---
15
+
16
+ ## πŸ“– The Problem Context
17
+
18
+ In the Indian education system, a school's **11-digit UDISE Code** acts as a permanent, immutable identifier. However, the **name of the school** can (and often does) change over time due to upgrades (e.g., from a Junior College to a Senior Secondary School), government renamings, or simply varying data entry practices.
19
+
20
+ When an Operations Team member reviews a student's printed marksheet, they might see a name like `"N. C. HIGH SCHOOL"`. When they look up that student's UDISE code in the internal Scholarship System (the "Old Master"), it might show the older or newer name `"NANDESWAR CHAKRAVARTY HIGH SCHOOL"`. This discrepancy causes confusion and delays in verification.
21
+
22
+ ## πŸ’‘ The Solution
23
+
24
+ The **School Name Resolver** bridges this gap by acting as a unified search engine and mapping utility. It allows the Ops Team to:
25
+ 1. **Search** a UDISE code and instantly pull every known name for that school across historical datasets.
26
+ 2. **Compare** the name written on the physical marksheet against historical names.
27
+ 3. **Map** all these names together into a persistent, cloud-hosted **Alias Dictionary**.
28
+
29
+ ---
30
+
31
+ ## πŸ—οΈ Architecture & Data Flow
32
+
33
+ This application is built with a serverless data architecture using **HuggingFace Datasets** as the primary storage layer. It does not require a traditional SQL database.
34
+
35
+ ### 1. Data Sources Searched
36
+ When a UDISE code is searched, the application concurrently queries:
37
+ - **πŸ“„ The Marksheet Name**: Entered manually by the Ops user.
38
+ - **πŸ“š Old Master (`master_all_states.csv`)**: The current database used by the Scholarship Application system. This file contains over 200,000 baseline school records.
39
+ - **βœ… Latest Scraped Master**: The most recent Parquet file generated by the upstream KYS (Know Your School) Scraper app.
40
+ - **πŸ• Older Scraped Masters**: Historical Parquet snapshots to catch historical name changes.
41
+
42
+ ### 2. The Alias Dictionary (`school_aliases.json`)
43
+ When the user clicks "Map All Names Together", the application collects all unique names found during the search and upserts them into a persistent dictionary.
44
+ - **Storage Strategy**: The dictionary is stored as a standard JSON file (`school_aliases.json`) directly on a private HuggingFace dataset.
45
+ - **Schema**: It uses a clean JSON dictionary format where keys are UDISE codes.
46
+ ```json
47
+ {
48
+ "18050406004": {
49
+ "names": ["SARUPETA GIRLS HE SCHOOL", "VIDYAMANDIR HIGH SCHOOL,SARUPETA"],
50
+ "last_updated": "2026-07-07"
51
+ }
52
+ }
53
+ ```
54
+ - **Deduplication**: Mapping is strictly case-insensitive. If an Ops user attempts to map a name that already exists in the `names` array, the application will silently skip the duplicate to maintain a clean dataset.
55
+
56
+ ### 3. In-Memory Caching (`hf_reader.py`)
57
+ To ensure high performance without straining HuggingFace bandwidth, the application uses module-level caching:
58
+ - The 45MB `master_all_states.csv` and all mapped `.parquet` files are downloaded and loaded into Pandas DataFrames on the *first* search request.
59
+ - Subsequent searches pull directly from RAM, making them virtually instantaneous.
60
+ - A "Refresh Data Cache" button in the UI allows users to force-reload the cache if the upstream KYS scraper pushes new master files.
61
+
62
+ ---
63
+
64
+ ## πŸš€ Setup & Deployment Guide
65
+
66
+ This app is designed to be hosted on **HuggingFace Spaces** using Docker.
67
+
68
+ ### Step 1: Create the Resolver Dataset
69
+ The app requires its own dedicated dataset repository to store the baseline and alias dictionary.
70
+ 1. Go to HuggingFace and create a new **Private Dataset** (e.g., `Apf-AI4Good/school-name-resolver-data`).
71
+ 2. Upload your existing `master_all_states.csv` (the Old Master) to the root of this dataset.
72
+ *(Note: You do not need to create `school_aliases.json`. The app will automatically create it the first time an ops user maps a school.)*
73
+
74
+ ### Step 2: Configure Space Secrets & Variables
75
+ When creating the HuggingFace Space for this application, configure the following in the **Settings β†’ Variables and Secrets** tab:
76
+
77
+ **πŸ”’ Secrets:**
78
+ | Name | Description |
79
+ |---|---|
80
+ | `HF_TOKEN` | A HuggingFace Access Token with **Write** permissions. Used to read the baselines and write the alias JSON. |
81
+
82
+ **βš™οΈ Variables:**
83
+ | Name | Description |
84
+ |---|---|
85
+ | `HF_SCRAPER_REPO` | The Repository ID of your upstream KYS Scraper dataset (e.g., `Apf-AI4Good/kys-school-data`). |
86
+ | `HF_RESOLVER_REPO` | The Repository ID of the new dataset created in Step 1 (e.g., `Apf-AI4Good/school-name-resolver-data`). |
87
+
88
+ ### Step 3: Deploy
89
+ Push all files in this directory (including the provided `Dockerfile` and `requirements.txt`) to the HuggingFace Space. The Docker environment will automatically build and launch the Gradio server on port `7860`.
90
+
91
+ ---
92
+
93
+ ## πŸ’» Local Development
94
+
95
+ If you need to test the application locally on a Windows machine:
96
+
97
+ ```powershell
98
+ # 1. Create and activate a virtual environment
99
+ python -m venv venv
100
+ .\venv\Scripts\activate
101
+
102
+ # 2. Install lightweight dependencies
103
+ pip install -r requirements.txt
104
+
105
+ # 3. Configure environment variables
106
+ # Copy the example env file and fill in your HuggingFace details
107
+ copy .env.example .env
108
+
109
+ # 4. Run the application
110
+ python app.py
111
+ ```
112
+ *Note: The local app is configured with `inbrowser=True`, meaning it will automatically open a new tab in your default web browser (usually at `http://127.0.0.1:7862`) once the server starts.*
113
+
114
+ ---
115
+
116
+ ## 🎨 UI Layout & User Flow
117
+
118
+ The User Interface was completely custom-designed using Gradio Blocks with injected CSS for a modern, premium experience.
119
+
120
+ 1. **Step 1 β€” Enter Details**: A clean input form requiring only the UDISE code and an optional physical marksheet name.
121
+ 2. **Step 2 β€” Review Names**: Results are rendered as visual, color-coded HTML cards rather than a raw data table.
122
+ - 🟑 **Amber**: Marksheet Name
123
+ - πŸ”΅ **Blue**: Old Master Name
124
+ - 🟒 **Green**: Latest Scraped Master
125
+ - ⚫ **Grey**: Older Scraped Masters
126
+ 3. **Step 3 β€” Save Aliases**: A one-click mapping button that executes the upsert logic and commits to HuggingFace.
127
+ 4. **Alias Dictionary View**: A live, searchable HTML render of the `school_aliases.json` file, visually grouping all known aliases into interactive accordion dropdowns under distinct UDISE code blocks for easy reading.
128
+
129
+ ---
130
+
131
+ ## πŸ› οΈ File Structure
132
+
133
+ | File | Purpose |
134
+ |---|---|
135
+ | `app.py` | The main Gradio interface, routing, custom CSS, and HTML component builders. |
136
+ | `hf_reader.py` | The data layer responsible for fetching, caching, and searching CSV/Parquet files from HuggingFace. |
137
+ | `alias_store.py` | The CRUD operations and deduplication logic for managing the `school_aliases.json` dictionary. |
138
+ | `requirements.txt` | Python dependencies (`gradio`, `huggingface-hub`, `pandas`, `pyarrow`, `python-dotenv`). |
139
+ | `Dockerfile` | Python 3.11-slim container configuration for HF Spaces deployment. |
140
+ | `.env.example` | Template for local environment variable setup. |
alias_store.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ alias_store.py β€” Manages the school_aliases.json file stored on HuggingFace.
3
+
4
+ Schema:
5
+ {
6
+ "18050406004": {
7
+ "names": ["SARUPETA GIRLS HE SCHOOL", "VIDYAMANDIR HIGH SCHOOL,SARUPETA"],
8
+ "last_updated": "2026-07-07"
9
+ },
10
+ "18150113702": {
11
+ "names": ["N. C. HIGH SCHOOL", "NANDESWAR CHAKRAVARTY HIGH SCHOOL"],
12
+ "last_updated": "2026-07-07"
13
+ }
14
+ }
15
+ """
16
+
17
+ import os
18
+ import json
19
+ import tempfile
20
+ from datetime import datetime, timezone
21
+ from huggingface_hub import HfApi, hf_hub_download
22
+
23
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
24
+ HF_RESOLVER_REPO = os.getenv("HF_RESOLVER_REPO", "")
25
+ ALIAS_FILE = "school_aliases.json"
26
+
27
+
28
+ def load_aliases() -> dict:
29
+ """
30
+ Download and return the alias dictionary from HF as a Python dict.
31
+ Returns an empty dict {} if the file doesn't exist yet.
32
+ """
33
+ if not HF_RESOLVER_REPO:
34
+ return {}
35
+ try:
36
+ path = hf_hub_download(
37
+ repo_id=HF_RESOLVER_REPO,
38
+ filename=ALIAS_FILE,
39
+ repo_type="dataset",
40
+ token=HF_TOKEN or None,
41
+ force_download=True,
42
+ )
43
+ with open(path, "r", encoding="utf-8") as f:
44
+ return json.load(f)
45
+ except Exception as e:
46
+ if "404" in str(e) or "not found" in str(e).lower() or "Entry Not Found" in str(e):
47
+ return {}
48
+ print(f"[alias_store] Error loading aliases: {e}")
49
+ return {}
50
+
51
+
52
+ def save_aliases(new_entries: list[dict]) -> str:
53
+ """
54
+ Upsert new alias entries into the cloud JSON.
55
+
56
+ Each entry should have: udise_code, alias_name, source_label
57
+ """
58
+ if not HF_RESOLVER_REPO:
59
+ return "⚠️ HF_RESOLVER_REPO is not configured β€” cannot save aliases."
60
+ if not new_entries:
61
+ return "⚠️ No entries provided."
62
+
63
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
64
+ data = load_aliases()
65
+
66
+ added_count = 0
67
+ skipped_count = 0
68
+
69
+ for entry in new_entries:
70
+ code = str(entry.get("udise_code", "")).strip()
71
+ name = str(entry.get("alias_name", "")).strip()
72
+ if not code or not name:
73
+ continue
74
+
75
+ if code not in data:
76
+ data[code] = {"names": [], "last_updated": today}
77
+
78
+ existing_upper = {n.upper() for n in data[code]["names"]}
79
+
80
+ if name.upper() in existing_upper:
81
+ skipped_count += 1
82
+ continue
83
+
84
+ data[code]["names"].append(name)
85
+ data[code]["last_updated"] = today
86
+ added_count += 1
87
+
88
+ if added_count == 0:
89
+ return f"ℹ️ All {skipped_count} name(s) already in dictionary β€” nothing new to save."
90
+
91
+ # Upload to HF
92
+ try:
93
+ api = HfApi()
94
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8") as f:
95
+ json.dump(data, f, indent=2, ensure_ascii=False)
96
+ tmp_path = f.name
97
+
98
+ udise_sample = new_entries[0].get("udise_code", "unknown")
99
+ api.upload_file(
100
+ path_or_fileobj=tmp_path,
101
+ path_in_repo=ALIAS_FILE,
102
+ repo_id=HF_RESOLVER_REPO,
103
+ repo_type="dataset",
104
+ token=HF_TOKEN or None,
105
+ commit_message=f"Add aliases for UDISE {udise_sample} ({added_count} new name(s))",
106
+ )
107
+
108
+ msg = f"βœ… Saved! {added_count} new name(s) added."
109
+ if skipped_count:
110
+ msg += f" ({skipped_count} duplicate(s) skipped.)"
111
+ return msg
112
+
113
+ except Exception as e:
114
+ return f"❌ Failed to save aliases: {e}"
115
+
116
+
117
+ def get_names_for_udise(udise_code: str) -> list[str]:
118
+ """Return all known names for a specific UDISE code as a list."""
119
+ data = load_aliases()
120
+ return data.get(str(udise_code).strip(), {}).get("names", [])
app.py ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py β€” Main Gradio application for the School Name Resolver.
3
+
4
+ Run locally: python app.py
5
+ HF Spaces: launched automatically by Dockerfile
6
+ """
7
+
8
+ import os
9
+ import gradio as gr
10
+ import pandas as pd
11
+ from dotenv import load_dotenv
12
+
13
+ load_dotenv()
14
+
15
+ from hf_reader import search_udise, refresh_all_caches, get_config_status
16
+ from alias_store import load_aliases, save_aliases
17
+
18
+ # ─── CSS ──────────────────────────────────────────────────────────────────────
19
+ CSS = """
20
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
21
+
22
+ *, *::before, *::after { box-sizing: border-box; margin: 0; }
23
+
24
+ body, .gradio-container {
25
+ background: #f0f4ff !important;
26
+ font-family: 'Inter', 'Segoe UI', sans-serif !important;
27
+ color: #1e293b !important;
28
+ }
29
+
30
+ /* ── Header ── */
31
+ .app-header {
32
+ background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
33
+ border-radius: 18px;
34
+ padding: 28px 36px;
35
+ margin-bottom: 20px;
36
+ text-align: center;
37
+ box-shadow: 0 8px 32px rgba(79,70,229,0.25);
38
+ }
39
+ .app-header h1 {
40
+ font-size: 1.9rem;
41
+ font-weight: 800;
42
+ color: #ffffff;
43
+ letter-spacing: -0.5px;
44
+ margin-bottom: 6px;
45
+ }
46
+ .app-header p {
47
+ color: rgba(255,255,255,0.75);
48
+ font-size: 0.95rem;
49
+ }
50
+
51
+ /* ── Step cards ── */
52
+ .step-box {
53
+ background: #ffffff;
54
+ border-radius: 14px;
55
+ padding: 22px 24px;
56
+ box-shadow: 0 2px 12px rgba(0,0,0,0.07);
57
+ margin-bottom: 16px;
58
+ }
59
+ .step-label {
60
+ display: inline-block;
61
+ background: #ede9fe;
62
+ color: #6d28d9;
63
+ font-size: 0.72rem;
64
+ font-weight: 700;
65
+ text-transform: uppercase;
66
+ letter-spacing: 1px;
67
+ padding: 3px 10px;
68
+ border-radius: 20px;
69
+ margin-bottom: 10px;
70
+ }
71
+
72
+ /* ── Inputs ── */
73
+ label span {
74
+ color: #475569 !important;
75
+ font-size: 0.82rem !important;
76
+ font-weight: 600 !important;
77
+ text-transform: uppercase !important;
78
+ letter-spacing: 0.5px !important;
79
+ }
80
+ input[type=text], textarea {
81
+ background: #f8fafc !important;
82
+ border: 1.5px solid #e2e8f0 !important;
83
+ border-radius: 10px !important;
84
+ color: #1e293b !important;
85
+ font-size: 0.97rem !important;
86
+ font-family: 'Inter', sans-serif !important;
87
+ transition: border-color 0.2s, box-shadow 0.2s !important;
88
+ }
89
+ input[type=text]:focus, textarea:focus {
90
+ border-color: #6d28d9 !important;
91
+ box-shadow: 0 0 0 3px rgba(109,40,217,0.1) !important;
92
+ outline: none !important;
93
+ }
94
+
95
+ /* ── Buttons ── */
96
+ .btn-search button {
97
+ background: linear-gradient(135deg, #4f46e5, #7c3aed) !important;
98
+ color: white !important;
99
+ font-weight: 700 !important;
100
+ font-size: 1rem !important;
101
+ border-radius: 12px !important;
102
+ border: none !important;
103
+ box-shadow: 0 4px 16px rgba(79,70,229,0.35) !important;
104
+ transition: all 0.2s !important;
105
+ padding: 12px !important;
106
+ }
107
+ .btn-search button:hover {
108
+ transform: translateY(-1px) !important;
109
+ box-shadow: 0 6px 22px rgba(79,70,229,0.45) !important;
110
+ }
111
+
112
+ .btn-map button {
113
+ background: linear-gradient(135deg, #059669, #0891b2) !important;
114
+ color: white !important;
115
+ font-weight: 700 !important;
116
+ font-size: 1rem !important;
117
+ border-radius: 12px !important;
118
+ border: none !important;
119
+ box-shadow: 0 4px 16px rgba(5,150,105,0.3) !important;
120
+ transition: all 0.2s !important;
121
+ padding: 12px !important;
122
+ }
123
+ .btn-map button:hover {
124
+ transform: translateY(-1px) !important;
125
+ box-shadow: 0 6px 22px rgba(5,150,105,0.4) !important;
126
+ }
127
+
128
+ .btn-secondary button {
129
+ background: #f1f5f9 !important;
130
+ color: #64748b !important;
131
+ font-weight: 600 !important;
132
+ border-radius: 10px !important;
133
+ border: 1.5px solid #e2e8f0 !important;
134
+ transition: all 0.15s !important;
135
+ }
136
+ .btn-secondary button:hover {
137
+ background: #e2e8f0 !important;
138
+ color: #334155 !important;
139
+ }
140
+
141
+ /* ── Result cards ── */
142
+ .result-panel {
143
+ background: #ffffff;
144
+ border-radius: 14px;
145
+ padding: 20px 22px;
146
+ box-shadow: 0 2px 12px rgba(0,0,0,0.07);
147
+ }
148
+ .result-heading {
149
+ font-size: 0.75rem;
150
+ font-weight: 700;
151
+ text-transform: uppercase;
152
+ letter-spacing: 0.8px;
153
+ color: #94a3b8;
154
+ padding-bottom: 12px;
155
+ border-bottom: 1.5px solid #f1f5f9;
156
+ margin-bottom: 14px;
157
+ }
158
+ .name-card {
159
+ padding: 14px 16px;
160
+ border-radius: 11px;
161
+ margin-bottom: 10px;
162
+ border-left: 4px solid;
163
+ }
164
+ .card-marksheet { background: #fffbeb; border-color: #f59e0b; }
165
+ .card-legacy { background: #eff6ff; border-color: #3b82f6; }
166
+ .card-latest { background: #f0fdf4; border-color: #22c55e; }
167
+ .card-old { background: #f8fafc; border-color: #cbd5e1; }
168
+
169
+ .card-tag {
170
+ font-size: 0.68rem;
171
+ font-weight: 700;
172
+ text-transform: uppercase;
173
+ letter-spacing: 0.6px;
174
+ margin-bottom: 4px;
175
+ }
176
+ .card-marksheet .card-tag { color: #d97706; }
177
+ .card-legacy .card-tag { color: #2563eb; }
178
+ .card-latest .card-tag { color: #16a34a; }
179
+ .card-old .card-tag { color: #94a3b8; }
180
+
181
+ .card-name {
182
+ font-size: 1.05rem;
183
+ font-weight: 700;
184
+ color: #1e293b;
185
+ line-height: 1.35;
186
+ }
187
+ .card-location {
188
+ font-size: 0.78rem;
189
+ color: #94a3b8;
190
+ margin-top: 3px;
191
+ }
192
+ .match-badge {
193
+ display: inline-block;
194
+ background: #dcfce7;
195
+ color: #16a34a;
196
+ font-size: 0.65rem;
197
+ font-weight: 700;
198
+ padding: 2px 7px;
199
+ border-radius: 8px;
200
+ margin-left: 8px;
201
+ vertical-align: middle;
202
+ text-transform: uppercase;
203
+ }
204
+
205
+ /* ── Status text ── */
206
+ .map-status textarea, .map-status input {
207
+ background: #f0fdf4 !important;
208
+ border-color: #bbf7d0 !important;
209
+ color: #15803d !important;
210
+ font-weight: 600 !important;
211
+ border-radius: 10px !important;
212
+ }
213
+
214
+ /* ── Alias table ── */
215
+ .alias-section {
216
+ background: #ffffff;
217
+ border-radius: 14px;
218
+ padding: 22px 24px;
219
+ box-shadow: 0 2px 12px rgba(0,0,0,0.07);
220
+ margin-top: 6px;
221
+ }
222
+ .section-heading {
223
+ font-size: 1rem;
224
+ font-weight: 700;
225
+ color: #1e293b;
226
+ margin-bottom: 14px;
227
+ }
228
+ .section-sub {
229
+ font-size: 0.8rem;
230
+ color: #94a3b8;
231
+ margin-bottom: 14px;
232
+ font-weight: 400;
233
+ }
234
+
235
+ /* ── Empty / no result ── */
236
+ .empty-msg {
237
+ text-align: center;
238
+ padding: 40px 0;
239
+ color: #cbd5e1;
240
+ font-size: 0.95rem;
241
+ }
242
+
243
+ /* ── Accordion ── */
244
+ details { border-radius: 12px !important; }
245
+ details summary {
246
+ color: #64748b !important;
247
+ font-weight: 600 !important;
248
+ font-size: 0.88rem !important;
249
+ padding: 10px 14px !important;
250
+ background: #f8fafc !important;
251
+ border-radius: 10px !important;
252
+ }
253
+ """
254
+
255
+ # ─── HTML builders ────────────────────────────────────────────────────────────
256
+
257
+ def build_results_html(results: list[dict], udise_code: str) -> str:
258
+ if not results:
259
+ return ""
260
+
261
+ ms_name = next((r["name"] for r in results if r["source_type"] == "marksheet"), "")
262
+
263
+ cards = []
264
+ for r in results:
265
+ st = r.get("source_type", "")
266
+ name = r.get("name", "").strip()
267
+ src = r.get("source", "")
268
+ loc = " β€Ί ".join(p for p in [r.get("state",""), r.get("district",""), r.get("block","")] if p and p not in ("nan","none",""))
269
+
270
+ css = {"marksheet": "card-marksheet", "legacy_db": "card-legacy",
271
+ "latest_master": "card-latest", "old_master": "card-old"}.get(st, "card-old")
272
+ icon = {"marksheet": "πŸ“„ Marksheet", "legacy_db": "πŸ“š Old Master",
273
+ "latest_master": "βœ… Latest Master", "old_master": "πŸ• Older Master"}.get(st, src)
274
+
275
+ badge = ""
276
+ if ms_name and st != "marksheet" and name.upper() == ms_name.upper():
277
+ badge = '<span class="match-badge">βœ“ Same Name</span>'
278
+
279
+ display = name if name and name.lower() not in ("nan","none","") else "<em style='color:#cbd5e1'>Not found</em>"
280
+ loc_html = f'<div class="card-location">πŸ“ {loc}</div>' if loc else ""
281
+
282
+ cards.append(f"""
283
+ <div class="name-card {css}">
284
+ <div class="card-tag">{icon}</div>
285
+ <div class="card-name">{display}{badge}</div>
286
+ {loc_html}
287
+ </div>""")
288
+
289
+ unique = len(set(r["name"].strip().upper() for r in results if r.get("name") and r["name"].lower() not in ("nan","none","")))
290
+ 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>"
291
+
292
+ return f"""
293
+ <div class="result-panel">
294
+ <div class="result-heading">
295
+ UDISE: {udise_code} &nbsp;Β·&nbsp; {len(results)} source(s) checked &nbsp;Β·&nbsp; {diff_note}
296
+ </div>
297
+ {"".join(cards)}
298
+ </div>"""
299
+
300
+
301
+ def build_empty_html():
302
+ return """<div class="result-panel">
303
+ <div class="empty-msg">
304
+ πŸ” Enter a UDISE code above and click <strong>Search</strong><br>
305
+ <span style="font-size:0.82rem;color:#e2e8f0">All known names for that school will appear here</span>
306
+ </div>
307
+ </div>"""
308
+
309
+
310
+ # ─── Event handlers ───────────────────────────────────────────────────────────
311
+
312
+ def on_search(udise_code: str, marksheet_name: str):
313
+ udise_code = str(udise_code).strip()
314
+ if not udise_code:
315
+ return gr.update(value=build_empty_html()), gr.update(visible=False), gr.update(visible=False, value=""), []
316
+
317
+ results = search_udise(udise_code, marksheet_name.strip() if marksheet_name else "")
318
+
319
+ if not results:
320
+ html = f"""<div class="result-panel">
321
+ <div class="empty-msg">❌ No schools found for UDISE <strong>{udise_code}</strong> in any data source.</div>
322
+ </div>"""
323
+ return gr.update(value=html), gr.update(visible=False), gr.update(visible=False, value=""), []
324
+
325
+ return (
326
+ gr.update(value=build_results_html(results, udise_code)),
327
+ gr.update(visible=True),
328
+ gr.update(visible=False, value=""),
329
+ results,
330
+ )
331
+
332
+
333
+ def on_map(udise_code: str, marksheet_name: str, search_results: list):
334
+ udise_code = str(udise_code).strip()
335
+ if not udise_code or not search_results:
336
+ return gr.update(visible=True, value="⚠️ Search for a UDISE code first."), build_alias_html()
337
+
338
+ entries, seen = [], set()
339
+ if marksheet_name and marksheet_name.strip():
340
+ ms = marksheet_name.strip()
341
+ entries.append({"udise_code": udise_code, "alias_name": ms, "source_label": "Marksheet"})
342
+ seen.add(ms.upper())
343
+
344
+ for r in search_results:
345
+ name = str(r.get("name","")).strip()
346
+ if name and name.lower() not in ("nan","none","") and name.upper() not in seen:
347
+ entries.append({"udise_code": udise_code, "alias_name": name, "source_label": r.get("source","")})
348
+ seen.add(name.upper())
349
+
350
+ msg = save_aliases(entries)
351
+ return gr.update(visible=True, value=msg), build_alias_html()
352
+
353
+
354
+ def build_alias_html(query: str = "") -> str:
355
+ """Render the alias dictionary as accordion cards (one per UDISE code)."""
356
+ data = load_aliases()
357
+ if not data:
358
+ return '<div style="text-align:center;padding:40px;color:#94a3b8;font-size:0.95rem">πŸ“­ No aliases saved yet. Search for a school and click <strong>Map All Names Together</strong>.</div>'
359
+
360
+ filtered = {}
361
+ if query and query.strip():
362
+ q = query.strip().upper()
363
+ for udise, info in data.items():
364
+ if q in udise.upper() or any(q in n.upper() for n in info.get("names", [])):
365
+ filtered[udise] = info
366
+ else:
367
+ filtered = data
368
+
369
+ if not filtered:
370
+ return f'<div style="text-align:center;padding:30px;color:#94a3b8">No results for <strong>"{query}"</strong></div>'
371
+
372
+ cards = []
373
+ for udise in sorted(filtered.keys()):
374
+ info = filtered[udise]
375
+ names = info.get("names", [])
376
+ updated = info.get("last_updated", "")[:10]
377
+
378
+ pills = ""
379
+ for i, name in enumerate(names):
380
+ if i == 0:
381
+ bg, dot = "#fffbeb", "🟑"
382
+ elif i == len(names) - 1:
383
+ bg, dot = "#f0fdf4", "🟒"
384
+ else:
385
+ bg, dot = "#eff6ff", "πŸ”΅"
386
+
387
+ pills += f"""
388
+ <div style="display:flex;align-items:center;gap:10px;padding:9px 12px;
389
+ background:{bg};border-radius:9px;margin-bottom:6px;">
390
+ <span>{dot}</span>
391
+ <span style="font-size:0.97rem;font-weight:700;color:#1e293b">{name}</span>
392
+ </div>"""
393
+
394
+ open_attr = "open" if query and query.strip() else ""
395
+
396
+ cards.append(f"""
397
+ <details {open_attr} style="background:#fff;border-radius:14px;padding:12px 20px;
398
+ box-shadow:0 2px 10px rgba(0,0,0,0.07);margin-bottom:14px;cursor:pointer;">
399
+ <summary style="display:flex;align-items:center;justify-content:space-between;list-style:none;">
400
+ <div style="display:flex;align-items:center;">
401
+ <span style="font-size:0.65rem;font-weight:700;text-transform:uppercase;
402
+ letter-spacing:1px;color:#6d28d9;background:#ede9fe;
403
+ padding:3px 9px;border-radius:20px">UDISE</span>
404
+ <span style="font-size:1.05rem;font-weight:800;color:#1e293b;margin-left:12px">{udise}</span>
405
+ </div>
406
+ <span style="font-size:0.75rem;color:#94a3b8">{len(names)} name(s) Β· β–Ό</span>
407
+ </summary>
408
+ <div style="margin-top:16px;">
409
+ {pills}
410
+ </div>
411
+ </details>""")
412
+
413
+ return f"""
414
+ <div style="font-size:0.78rem;color:#94a3b8;font-weight:600;text-transform:uppercase;
415
+ letter-spacing:0.6px;margin-bottom:12px">{len(filtered)} school(s) in dictionary</div>
416
+ {"".join(cards)}
417
+ <style>details > summary::-webkit-details-marker {{ display: none; }}</style>
418
+ """
419
+
420
+
421
+
422
+ def on_alias_search(query: str) -> str:
423
+ return build_alias_html(query)
424
+
425
+
426
+ def on_refresh_caches():
427
+ return refresh_all_caches()
428
+
429
+
430
+ def _alias_df() -> str:
431
+ return build_alias_html()
432
+
433
+
434
+ # ─── Theme ────────────────────────────────────────────────────────────────────
435
+ THEME = gr.themes.Base(
436
+ primary_hue=gr.themes.colors.violet,
437
+ secondary_hue=gr.themes.colors.blue,
438
+ neutral_hue=gr.themes.colors.slate,
439
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"],
440
+ )
441
+
442
+ # ─── UI ───────────────────────────────────────────────────────��───────────────
443
+ with gr.Blocks(title="School Name Resolver") as app:
444
+
445
+ search_state = gr.State([])
446
+
447
+ # ── Header ──────────────────────────────────────────────────────────────
448
+ gr.HTML("""
449
+ <div class="app-header">
450
+ <h1>🏫 School Name Resolver</h1>
451
+ <p>Look up all known names for a school using its UDISE code</p>
452
+ </div>
453
+ """)
454
+
455
+ # ── Step 1 + 2: Inputs ──────────────────────────────────────────────────
456
+ gr.HTML('<div class="step-box"><span class="step-label">Step 1 β€” Enter Details</span>')
457
+
458
+ with gr.Row():
459
+ udise_input = gr.Textbox(
460
+ label="UDISE Code",
461
+ placeholder="e.g. 18150113702",
462
+ max_lines=1,
463
+ scale=1,
464
+ )
465
+ marksheet_input = gr.Textbox(
466
+ label="School Name on Marksheet (optional)",
467
+ placeholder="Paste the name exactly as written on the student's marksheet…",
468
+ max_lines=1,
469
+ scale=2,
470
+ )
471
+
472
+ search_btn = gr.Button("πŸ” Search All Sources", elem_classes="btn-search", size="lg")
473
+ gr.HTML('</div>')
474
+
475
+ # ── Step 2: Results ──────────────────────────────────────────────────────
476
+ gr.HTML('<div style="margin-bottom:4px"><span class="step-label" style="background:#dbeafe;color:#1d4ed8">Step 2 β€” Review Names</span></div>')
477
+ results_html = gr.HTML(value=build_empty_html())
478
+
479
+ # ── Step 3: Map ──────────────────────────────────────────────────────────
480
+ gr.HTML('<div style="margin:12px 0 4px"><span class="step-label" style="background:#dcfce7;color:#15803d">Step 3 β€” Save Aliases</span></div>')
481
+ map_btn = gr.Button(
482
+ "πŸ—ΊοΈ Map All Names Together",
483
+ elem_classes="btn-map",
484
+ size="lg",
485
+ visible=False,
486
+ )
487
+ map_status = gr.Textbox(
488
+ show_label=False,
489
+ interactive=False,
490
+ visible=False,
491
+ placeholder="",
492
+ elem_classes="map-status",
493
+ max_lines=1,
494
+ )
495
+
496
+ gr.HTML("<hr style='border:none;border-top:1.5px solid #e2e8f0;margin:24px 0'>")
497
+
498
+ # ── Alias Dictionary ─────────────────────────────────────────────────────
499
+ with gr.Accordion("πŸ“– Alias Dictionary", open=False, elem_classes="alias-section"):
500
+ gr.HTML('<div class="section-sub" style="margin-bottom:15px">All school names that have been mapped together so far</div>')
501
+
502
+ with gr.Row():
503
+ alias_search_box = gr.Textbox(
504
+ show_label=False,
505
+ placeholder="πŸ”Ž Search by UDISE code or school name…",
506
+ max_lines=1,
507
+ scale=4,
508
+ )
509
+ refresh_alias_btn = gr.Button("πŸ”„ Refresh", scale=1, elem_classes="btn-secondary")
510
+
511
+ alias_html = gr.HTML(value="")
512
+
513
+ # ── Settings accordion ───────────────────────────────────────────────────
514
+ with gr.Accordion("βš™οΈ Settings & Cache", open=False):
515
+ gr.Markdown("""
516
+ **Environment Variables required:**
517
+
518
+ | Variable | Purpose |
519
+ |---|---|
520
+ | `HF_TOKEN` | HuggingFace token (read + write) |
521
+ | `HF_SCRAPER_REPO` | Existing KYS scraper dataset (has scraped masters) |
522
+ | `HF_RESOLVER_REPO` | New resolver dataset (has master_all_states.csv) |
523
+
524
+ Use **Refresh Cache** after building a new master sheet in the scraper app.
525
+ """)
526
+ refresh_cache_btn = gr.Button("πŸ”„ Refresh Data Cache", elem_classes="btn-secondary")
527
+ cache_status = gr.Textbox(show_label=False, interactive=False, max_lines=2)
528
+
529
+ # ── Events ───────────────────────────────────────────────────────────────
530
+ search_btn.click(
531
+ fn=on_search,
532
+ inputs=[udise_input, marksheet_input],
533
+ outputs=[results_html, map_btn, map_status, search_state],
534
+ )
535
+ udise_input.submit(
536
+ fn=on_search,
537
+ inputs=[udise_input, marksheet_input],
538
+ outputs=[results_html, map_btn, map_status, search_state],
539
+ )
540
+ map_btn.click(
541
+ fn=on_map,
542
+ inputs=[udise_input, marksheet_input, search_state],
543
+ outputs=[map_status, alias_html],
544
+ )
545
+ alias_search_box.change(fn=on_alias_search, inputs=[alias_search_box], outputs=[alias_html])
546
+ refresh_alias_btn.click(fn=lambda: build_alias_html(), outputs=[alias_html])
547
+ refresh_cache_btn.click(fn=on_refresh_caches, outputs=[cache_status])
548
+ app.load(fn=build_alias_html, outputs=[alias_html])
549
+
550
+
551
+ # ─── Launch ───────────────────────────────────────────────────────────────────
552
+ if __name__ == "__main__":
553
+ port = int(os.getenv("PORT", 7862))
554
+ app.launch(
555
+ server_name="0.0.0.0",
556
+ server_port=port,
557
+ theme=THEME,
558
+ css=CSS,
559
+ inbrowser=True,
560
+ )
hf_reader.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ hf_reader.py β€” HuggingFace data layer for the School Name Resolver.
3
+
4
+ Reads:
5
+ 1. master_all_states.csv (Old Master) from HF_RESOLVER_REPO
6
+ 2. scraped_data/mapped/*.parquet (new masters) from HF_SCRAPER_REPO
7
+
8
+ All data is cached in memory after first load. Call refresh_all_caches() to force reload.
9
+ """
10
+
11
+ import os
12
+ import re
13
+ import pandas as pd
14
+ from huggingface_hub import HfApi, hf_hub_download
15
+ from datetime import datetime
16
+
17
+ HF_TOKEN = os.getenv("HF_TOKEN", "")
18
+ HF_SCRAPER_REPO = os.getenv("HF_SCRAPER_REPO", "")
19
+ HF_RESOLVER_REPO = os.getenv("HF_RESOLVER_REPO", "")
20
+
21
+ # ─── Module-level caches ───────────────────────────────────────────────────────
22
+ _old_master_df: pd.DataFrame | None = None
23
+ _mapped_masters: dict[str, pd.DataFrame] = {}
24
+ _cache_loaded: bool = False
25
+
26
+
27
+ # ─── Internal helpers ──────────────────────────────────────────────────────────
28
+
29
+ def _load_old_master() -> pd.DataFrame:
30
+ """Download and return master_all_states.csv from HF_RESOLVER_REPO."""
31
+ if not HF_RESOLVER_REPO:
32
+ print("[hf_reader] HF_RESOLVER_REPO not set β€” skipping Old Master load.")
33
+ return pd.DataFrame()
34
+ try:
35
+ path = hf_hub_download(
36
+ repo_id=HF_RESOLVER_REPO,
37
+ filename="master_all_states.csv",
38
+ repo_type="dataset",
39
+ token=HF_TOKEN or None,
40
+ force_download=True,
41
+ )
42
+ df = pd.read_csv(path, dtype=str, low_memory=False)
43
+ df["School_Udise_Code__c"] = df["School_Udise_Code__c"].astype(str).str.strip()
44
+ print(f"[hf_reader] Old Master loaded: {len(df):,} rows.")
45
+ return df
46
+ except Exception as e:
47
+ print(f"[hf_reader] Could not load master_all_states.csv: {e}")
48
+ return pd.DataFrame()
49
+
50
+
51
+ def _load_mapped_masters() -> dict[str, pd.DataFrame]:
52
+ """Download all parquet files from scraped_data/mapped/ in HF_SCRAPER_REPO."""
53
+ if not HF_SCRAPER_REPO:
54
+ print("[hf_reader] HF_SCRAPER_REPO not set β€” skipping mapped masters load.")
55
+ return {}
56
+
57
+ api = HfApi()
58
+ results: dict[str, pd.DataFrame] = {}
59
+
60
+ try:
61
+ all_files = list(api.list_repo_files(
62
+ repo_id=HF_SCRAPER_REPO,
63
+ repo_type="dataset",
64
+ token=HF_TOKEN or None,
65
+ ))
66
+ mapped_files = sorted(
67
+ [f for f in all_files if f.startswith("scraped_data/mapped/") and f.endswith(".parquet")],
68
+ reverse=True,
69
+ )
70
+ print(f"[hf_reader] Found {len(mapped_files)} mapped master(s) in scraper repo.")
71
+
72
+ for file_path in mapped_files:
73
+ try:
74
+ local_path = hf_hub_download(
75
+ repo_id=HF_SCRAPER_REPO,
76
+ filename=file_path,
77
+ repo_type="dataset",
78
+ token=HF_TOKEN or None,
79
+ force_download=True,
80
+ )
81
+ df = pd.read_parquet(local_path)
82
+ if "School_Udise_Code__c" in df.columns:
83
+ df["School_Udise_Code__c"] = df["School_Udise_Code__c"].astype(str).str.strip()
84
+ stem = file_path.split("/")[-1].replace(".parquet", "")
85
+ results[stem] = df
86
+ print(f"[hf_reader] Loaded: {stem} ({len(df):,} rows)")
87
+ except Exception as e:
88
+ print(f"[hf_reader] Failed to load {file_path}: {e}")
89
+
90
+ except Exception as e:
91
+ print(f"[hf_reader] Could not list scraper repo files: {e}")
92
+
93
+ return results
94
+
95
+
96
+ def _ensure_loaded():
97
+ """Load all caches if they haven't been loaded yet."""
98
+ global _old_master_df, _mapped_masters, _cache_loaded
99
+ if not _cache_loaded:
100
+ _old_master_df = _load_old_master()
101
+ _mapped_masters = _load_mapped_masters()
102
+ _cache_loaded = True
103
+
104
+
105
+ # ─── Public API ───────────────────────────────────────────────────────────────
106
+
107
+ def refresh_all_caches() -> str:
108
+ """Force-reload all data from HuggingFace. Returns a status message."""
109
+ global _old_master_df, _mapped_masters, _cache_loaded
110
+ _cache_loaded = False
111
+ _old_master_df = None
112
+ _mapped_masters = {}
113
+ _ensure_loaded()
114
+ n_masters = len(_mapped_masters)
115
+ sf_rows = len(_old_master_df) if _old_master_df is not None else 0
116
+ return (
117
+ f"βœ… Caches refreshed β€” Old Master: {sf_rows:,} rows, "
118
+ f"Mapped Masters: {n_masters} file(s) loaded."
119
+ )
120
+
121
+
122
+ def _pretty_label(stem: str, idx: int) -> str:
123
+ """Turn a filename stem like 'mapped_master_20250707_123456' into a readable label."""
124
+ match = re.search(r"(\d{4})(\d{2})(\d{2})", stem)
125
+ if match:
126
+ y, m, d = match.group(1), match.group(2), match.group(3)
127
+ date_str = f"{d}/{m}/{y}"
128
+ else:
129
+ date_str = stem
130
+
131
+ if idx == 0:
132
+ return f"Latest Scraped Master ({date_str})"
133
+ else:
134
+ return f"Older Scraped Master ({date_str})"
135
+
136
+
137
+ def search_udise(udise_code: str, marksheet_name: str = "") -> list[dict]:
138
+ """
139
+ Search for a UDISE code across all data sources.
140
+
141
+ Returns a list of dicts, each with:
142
+ source str β€” human-readable source label
143
+ source_type str β€” 'marksheet' | 'legacy_db' | 'latest_master' | 'old_master'
144
+ name str β€” school name found
145
+ state str β€” state (if available)
146
+ district str β€” district (if available)
147
+ block str β€” block (if available)
148
+ """
149
+ _ensure_loaded()
150
+ results: list[dict] = []
151
+
152
+ if marksheet_name and marksheet_name.strip():
153
+ results.append({
154
+ "source": "Marksheet (entered by you)",
155
+ "source_type": "marksheet",
156
+ "name": marksheet_name.strip(),
157
+ "state": "",
158
+ "district": "",
159
+ "block": "",
160
+ })
161
+
162
+ if _old_master_df is not None and not _old_master_df.empty:
163
+ mask = _old_master_df["School_Udise_Code__c"] == str(udise_code).strip()
164
+ matches = _old_master_df[mask]
165
+ for _, row in matches.iterrows():
166
+ results.append({
167
+ "source": "Old Master (master_all_states.csv)",
168
+ "source_type": "legacy_db",
169
+ "name": str(row.get("School_Name__c", "")).strip(),
170
+ "state": str(row.get("School_State__c", "")).strip(),
171
+ "district": str(row.get("School_District__c", "")).strip(),
172
+ "block": str(row.get("School_Block__c", "")).strip(),
173
+ })
174
+
175
+ for idx, (stem, df) in enumerate(_mapped_masters.items()):
176
+ if "School_Udise_Code__c" not in df.columns:
177
+ continue
178
+ mask = df["School_Udise_Code__c"] == str(udise_code).strip()
179
+ matches = df[mask]
180
+ label = _pretty_label(stem, idx)
181
+ source_type = "latest_master" if idx == 0 else "old_master"
182
+ for _, row in matches.iterrows():
183
+ results.append({
184
+ "source": label,
185
+ "source_type": source_type,
186
+ "name": str(row.get("School_Name__c", "")).strip(),
187
+ "state": str(row.get("School_State__c", "")).strip(),
188
+ "district": str(row.get("School_District__c", "")).strip(),
189
+ "block": str(row.get("School_Block__c", "")).strip(),
190
+ })
191
+
192
+ return results
193
+
194
+
195
+ def get_config_status() -> dict:
196
+ """Return a dict describing the current configuration state."""
197
+ _ensure_loaded()
198
+ return {
199
+ "scraper_repo": HF_SCRAPER_REPO or "⚠️ Not set (HF_SCRAPER_REPO)",
200
+ "resolver_repo": HF_RESOLVER_REPO or "⚠️ Not set (HF_RESOLVER_REPO)",
201
+ "sf_baseline_rows": len(_old_master_df) if _old_master_df is not None else 0,
202
+ "mapped_masters_count": len(_mapped_masters),
203
+ "token_set": bool(HF_TOKEN),
204
+ }
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=4.44.0
2
+ huggingface-hub>=0.24.0
3
+ pandas>=2.0.0
4
+ pyarrow>=14.0.0
5
+ python-dotenv>=1.0.0