sharanyaswarup commited on
Commit
e37cbeb
·
1 Parent(s): 3e2f38d

new features

Browse files
Files changed (8) hide show
  1. .env.example +0 -1
  2. README.md +59 -119
  3. admin_patterns.py +205 -0
  4. alias_store.py +82 -16
  5. app.py +237 -262
  6. hf_reader.py +234 -29
  7. patterns.json +83 -0
  8. requirements.txt +2 -0
.env.example CHANGED
@@ -1,3 +1,2 @@
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
 
1
  HF_TOKEN=hf_your_token_here
2
  HF_SCRAPER_REPO=Apf-AI4Good/kys-school-data
 
README.md CHANGED
@@ -1,140 +1,80 @@
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. |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # 🏫 School Name Resolver
2
 
3
+ The **School Name Resolver** is an internal tool built for the Scholarship Operations Team. It is designed to quickly cross-reference, verify, and resolve discrepancies between the school names written on student marksheets and the official government UDISE records.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
+ By allowing the operations team to save custom "Marksheet Aliases," the system learns different variations of the same school name, completely eliminating the need to manually verify the same mismatched school names year after year!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  ---
8
 
9
+ ## 🏗️ Architecture & Data Sources
 
 
10
 
11
+ The application is entirely stateless. It reads from and writes directly to a centralized, cloud-hosted dataset repository on HuggingFace: **`Apf-AI4Good/kys-school-data`**.
 
 
 
 
12
 
13
+ This central repository houses two critical types of data:
 
14
 
15
+ 1. **Master Records (`.parquet` files)**:
16
+ - **✅ Latest Master**: The most recently scraped government UDISE data, representing the absolute source of truth for the current year. (e.g., `scraped_data/mapped/mapped_master_2026_jul_01_10_19_pm.parquet`)
17
+ - **📚 Old Master**: The historical baseline dataset used in previous scholarship cycles. It serves as a fallback to ensure continuity for students who applied in previous years. (`mapping_rules/baseline_master.parquet`)
 
18
 
19
+ 2. **Alias Dictionary (`school_aliases.json`)**:
20
+ - A dynamic JSON file that acts as the memory for the application. Whenever an operations team member encounters a marksheet name that doesn't perfectly match the official masters, they map it in the UI. The app immediately updates this JSON file and pushes it back to HuggingFace.
 
 
 
 
 
 
21
 
22
  ---
23
 
24
+ ## 🖥️ Using the App
 
 
25
 
26
+ The app is divided into two distinct steps for the user:
 
 
 
27
 
28
+ ### Step 1 Enter Location & Name
29
+ You can locate a school in two ways:
30
+ - **Location Search**: Use the cascading dropdowns to select the `State` ➔ `District` ➔ `Block` ➔ `Village`. The app will fuzzy-match the school name you enter against all schools in that specific village. (Note: The dropdowns strictly enforce hierarchy to prevent invalid selections).
31
+ - **UDISE Search**: If you already have the 11-digit UDISE code, you can select it directly from the dropdown to instantly pull up the school's records.
32
 
33
+ ### Step 2 Search Results & Mapping
34
+ The app will display all known names for the selected school(s) as distinct color-coded cards:
35
+ - <strong style="color: #16a34a;">Green Cards (✅ Latest Master)</strong>: The official, current name from the government registry.
36
+ - <strong style="color: #94a3b8;">Grey Cards (🕐 Older Master)</strong>: The official name from the historical baseline dataset.
37
+ - <strong style="color: #d97706;">Yellow Cards (📄 Marksheet)</strong>: Custom aliases that were previously saved by the operations team.
38
 
39
+ **Saving an Alias**:
40
+ If the student's marksheet says something different (e.g., "NC High School" instead of "Nandeswar Chakravarty High School"), simply type "NC High School" into the input box on the right and click **Save Alias**. The app will push this mapping to the cloud, and future searches for this school will instantly recognize "NC High School" as a valid alias!
 
 
41
 
42
+ **Deleting an Alias**:
43
+ If an incorrect alias was saved by mistake, every Yellow Marksheet card features a red **🗑️ Delete** button on the right. Clicking it will instantly purge that specific alias from the cloud dictionary.
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  ---
46
 
47
+ ## Setup & Deployment
48
+
49
+ ### Local Development
50
+ To run this application on your local machine for development:
51
+
52
+ 1. **Clone the repository**:
53
+ ```bash
54
+ git clone <your-repo-url>
55
+ cd school_name_resolver
56
+ ```
57
+
58
+ 2. **Install dependencies**:
59
+ ```bash
60
+ pip install -r requirements.txt
61
+ ```
62
+
63
+ 3. **Configure Environment Variables**:
64
+ Create a `.env` file in the root directory (you can copy `.env.example`). You only need two variables:
65
+ ```env
66
+ HF_TOKEN=your_huggingface_write_token
67
+ HF_SCRAPER_REPO=Apf-AI4Good/kys-school-data
68
+ ```
69
+
70
+ 4. **Run the App**:
71
+ ```bash
72
+ python app.py
73
+ ```
74
+ The app will start a local server, usually at `http://localhost:7862`.
75
+
76
+ ### Production Deployment
77
+ This application is designed to be hosted directly as a **HuggingFace Space**.
78
+ - **Docker**: The repository includes a `Dockerfile` that HuggingFace automatically uses to build and launch the environment.
79
+ - **Secrets**: In your HuggingFace Space Settings, simply add your `HF_TOKEN` as a Secret, and `HF_SCRAPER_REPO` as a public Variable.
80
+ - **Updates**: Any code pushed to the `main` branch of this repository will trigger HuggingFace to automatically rebuild and redeploy the live application!
admin_patterns.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # admin_patterns.py
2
+ import os
3
+ import json
4
+ import re
5
+ import shutil
6
+ import pandas as pd
7
+ from datetime import datetime
8
+
9
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
10
+ PATTERN_FILE = os.path.join(BASE_DIR, "patterns.json")
11
+ pattern_config = None
12
+ DEFAULT_PATTERN_CONFIG = {"global": [], "states": {}}
13
+
14
+
15
+ def _make_backup_of_patterns():
16
+ """
17
+ If PATTERN_FILE exists, copy it to patterns_backup_{timestamp}.json
18
+ Returns backup path or None on failure/if not exists.
19
+ """
20
+ try:
21
+ if os.path.exists(PATTERN_FILE):
22
+ ts = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
23
+ backup_name = f"patterns_backup_{ts}.json"
24
+ backup_path = os.path.join(BASE_DIR, backup_name)
25
+ shutil.copyfile(PATTERN_FILE, backup_path)
26
+ return backup_path
27
+ except Exception as e:
28
+ print("ERROR creating patterns.json backup:", e)
29
+ return None
30
+
31
+
32
+ def load_pattern_config(debug: bool = False):
33
+ """
34
+ Simplified config loader for Hugging Face Spaces:
35
+ - Checks common locations in order
36
+ - Returns first valid config found
37
+ - Falls back to default if none found
38
+ """
39
+ global pattern_config
40
+
41
+ # Search paths in priority order
42
+ search_paths = [
43
+ PATTERN_FILE, # Same dir as module
44
+ "/app/patterns.json", # HF Spaces mount point
45
+ os.path.join(os.getcwd(), "patterns.json") # Current directory
46
+ ]
47
+
48
+ # Try each path
49
+ for path in search_paths:
50
+ if not os.path.exists(path):
51
+ continue
52
+
53
+ try:
54
+ with open(path, "r", encoding="utf-8") as f:
55
+ cfg = json.load(f)
56
+
57
+ # Validate it has expected structure
58
+ if isinstance(cfg, dict) and ("global" in cfg or "states" in cfg):
59
+ pattern_config = cfg
60
+ if debug:
61
+ print(f"Loaded config from: {path}")
62
+ return cfg
63
+
64
+ except (json.JSONDecodeError, IOError) as e:
65
+ if debug:
66
+ print(f"Failed to load {path}: {e}")
67
+ continue
68
+
69
+ # No valid config found - use default
70
+ pattern_config = DEFAULT_PATTERN_CONFIG.copy()
71
+ if debug:
72
+ print("No valid config found, using defaults")
73
+
74
+ return pattern_config
75
+
76
+
77
+ def save_pattern_config(cfg: dict):
78
+ """
79
+ Save the given config to PATTERN_FILE with a timestamped backup of the previous file.
80
+ """
81
+ global pattern_config
82
+ # create backup first
83
+ backup_path = _make_backup_of_patterns()
84
+ if backup_path:
85
+ print(f"INFO: patterns.json backed up to {backup_path}")
86
+ # write new file
87
+ with open(PATTERN_FILE, "w", encoding="utf-8") as f:
88
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
89
+ pattern_config = cfg.copy()
90
+ return True
91
+
92
+
93
+ def build_patterns_from_config(cfg: dict, state_key: str | None):
94
+ global_list = [(p["pattern"], p["replacement"]) for p in cfg.get("global", [])]
95
+ state_list = []
96
+ if state_key:
97
+ state_key_up = state_key.upper().strip()
98
+ state_patterns = cfg.get("states", {}).get(state_key_up, [])
99
+ state_list = [(p["pattern"], p["replacement"]) for p in state_patterns]
100
+ return global_list, state_list
101
+
102
+
103
+ def normalize_with_patterns_dynamic(s: str, state_key: str | None):
104
+ global pattern_config
105
+ if not isinstance(s, str):
106
+ return ""
107
+ s = s.upper()
108
+ if pattern_config is None:
109
+ load_pattern_config(debug=False)
110
+ cfg = pattern_config or DEFAULT_PATTERN_CONFIG
111
+ global_patterns, state_patterns = build_patterns_from_config(cfg, state_key)
112
+ for pat, repl in global_patterns:
113
+ try:
114
+ s = re.sub(pat, repl, s)
115
+ except re.error:
116
+ continue
117
+ for pat, repl in state_patterns:
118
+ try:
119
+ s = re.sub(pat, repl, s)
120
+ except re.error:
121
+ continue
122
+ s = re.sub(r"[^A-Z0-9]+", " ", s)
123
+ s = re.sub(r"\s+", " ", s).strip()
124
+ return s
125
+
126
+
127
+ # Admin helpers for the UI
128
+ def load_global_patterns_for_editor():
129
+ cfg = load_pattern_config(debug=False)
130
+ return pd.DataFrame(cfg.get("global", []))
131
+
132
+
133
+ def load_state_patterns_for_editor(selected_state: str | None, new_state_name: str | None):
134
+ cfg = load_pattern_config(debug=False)
135
+ key = None
136
+ if new_state_name and new_state_name.strip():
137
+ key = new_state_name.strip().upper()
138
+ elif selected_state:
139
+ key = selected_state.strip().upper()
140
+ if not key:
141
+ return pd.DataFrame(columns=["pattern", "replacement"])
142
+ state_patterns = cfg.get("states", {}).get(key, [])
143
+ return pd.DataFrame(state_patterns)
144
+
145
+
146
+ def save_global_patterns_from_editor(df: pd.DataFrame, admin_password: str, expected_password: str):
147
+ if expected_password is None:
148
+ return "❌ ADMIN password not configured in environment."
149
+ if admin_password != expected_password:
150
+ return "❌ Invalid admin password. Global patterns NOT saved."
151
+ cfg = load_pattern_config(debug=False)
152
+ cfg["global"] = df.fillna("").to_dict(orient="records")
153
+ save_pattern_config(cfg)
154
+ gcount = len(cfg.get("global", []))
155
+ skeys = sorted(list(cfg.get("states", {}).keys()))
156
+ return f"✅ Global patterns saved — global={gcount}, state_keys={skeys}"
157
+
158
+
159
+ def save_state_patterns_from_editor(selected_state: str | None, new_state_name: str | None, df: pd.DataFrame, admin_password: str, expected_password: str):
160
+ if expected_password is None:
161
+ return "❌ ADMIN password not configured in environment."
162
+ if admin_password != expected_password:
163
+ return "❌ Invalid admin password. State patterns NOT saved."
164
+ key = None
165
+ if new_state_name and new_state_name.strip():
166
+ key = new_state_name.strip().upper()
167
+ elif selected_state:
168
+ key = selected_state.strip().upper()
169
+ if not key:
170
+ return "⚠ Please select a state or type a new state key."
171
+ cfg = load_pattern_config(debug=False)
172
+ cfg.setdefault("states", {})[key] = df.fillna("").to_dict(orient="records")
173
+ save_pattern_config(cfg)
174
+ gcount = len(cfg.get("global", []))
175
+ skeys = sorted(list(cfg.get("states", {}).keys()))
176
+ return f"✅ Patterns for {key} saved — global={gcount}, state_keys={skeys}"
177
+
178
+
179
+ def refresh_pattern_config():
180
+ cfg = load_pattern_config(debug=True)
181
+ gcount = len(cfg.get("global", []))
182
+ skeys = sorted(list(cfg.get("states", {}).keys()))
183
+ return f"Refreshed patterns.json — global={gcount}, state_keys={skeys}"
184
+
185
+
186
+ def show_patterns_file_info():
187
+ info_lines = []
188
+ info_lines.append(f"PATTERN_FILE: {PATTERN_FILE}")
189
+ info_lines.append(f"Exists: {os.path.exists(PATTERN_FILE)}")
190
+ if os.path.exists(PATTERN_FILE):
191
+ try:
192
+ size = os.path.getsize(PATTERN_FILE)
193
+ info_lines.append(f"Size (bytes): {size}")
194
+ with open(PATTERN_FILE, "r", encoding="utf-8") as f:
195
+ txt = f.read(1000)
196
+ info_lines.append("Preview (first 1000 chars):")
197
+ info_lines.append("```json\n" + txt + ("\n... (truncated)" if len(txt) >= 1000 else "") + "\n```")
198
+ except Exception as e:
199
+ info_lines.append("ERROR reading file: " + str(e))
200
+ try:
201
+ listing = os.listdir(BASE_DIR)
202
+ info_lines.append("Files in BASE_DIR: " + ", ".join(listing))
203
+ except Exception as e:
204
+ info_lines.append("ERROR listing BASE_DIR: " + str(e))
205
+ return "\n\n".join(info_lines)
alias_store.py CHANGED
@@ -4,11 +4,9 @@ alias_store.py — Manages the school_aliases.json file stored on HuggingFace.
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
  }
@@ -21,27 +19,42 @@ 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 {}
@@ -55,8 +68,8 @@ def save_aliases(new_entries: list[dict]) -> str:
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
 
@@ -69,19 +82,26 @@ def save_aliases(new_entries: list[dict]) -> str:
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
 
@@ -99,7 +119,7 @@ def save_aliases(new_entries: list[dict]) -> str:
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))",
@@ -114,7 +134,53 @@ def save_aliases(new_entries: list[dict]) -> str:
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", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  Schema:
5
  {
6
  "18050406004": {
7
+ "names": [
8
+ {"name": "SARUPETA GIRLS HE SCHOOL", "year_month": "2025-07", "source": "Old Master"}
9
+ ],
 
 
10
  "last_updated": "2026-07-07"
11
  }
12
  }
 
19
  from huggingface_hub import HfApi, hf_hub_download
20
 
21
  HF_TOKEN = os.getenv("HF_TOKEN", "")
22
+ HF_SCRAPER_REPO = os.getenv("HF_SCRAPER_REPO", "")
23
  ALIAS_FILE = "school_aliases.json"
24
 
25
 
26
+ def _migrate_names(names: list) -> list[dict]:
27
+ migrated = []
28
+ for item in names:
29
+ if isinstance(item, str):
30
+ migrated.append({"name": item, "year_month": "", "source": "Unknown (Migrated)"})
31
+ elif isinstance(item, dict):
32
+ migrated.append(item)
33
+ return migrated
34
+
35
+
36
  def load_aliases() -> dict:
37
  """
38
  Download and return the alias dictionary from HF as a Python dict.
39
  Returns an empty dict {} if the file doesn't exist yet.
40
  """
41
+ if not HF_SCRAPER_REPO:
42
  return {}
43
  try:
44
  path = hf_hub_download(
45
+ repo_id=HF_SCRAPER_REPO,
46
  filename=ALIAS_FILE,
47
  repo_type="dataset",
48
  token=HF_TOKEN or None,
49
  force_download=True,
50
  )
51
  with open(path, "r", encoding="utf-8") as f:
52
+ data = json.load(f)
53
+ # Migrate old string arrays to object arrays on load
54
+ for udise, info in data.items():
55
+ if "names" in info:
56
+ info["names"] = _migrate_names(info["names"])
57
+ return data
58
  except Exception as e:
59
  if "404" in str(e) or "not found" in str(e).lower() or "Entry Not Found" in str(e):
60
  return {}
 
68
 
69
  Each entry should have: udise_code, alias_name, source_label
70
  """
71
+ if not HF_SCRAPER_REPO:
72
+ return "⚠️ HF_SCRAPER_REPO is not configured — cannot save aliases."
73
  if not new_entries:
74
  return "⚠️ No entries provided."
75
 
 
82
  for entry in new_entries:
83
  code = str(entry.get("udise_code", "")).strip()
84
  name = str(entry.get("alias_name", "")).strip()
85
+ source = str(entry.get("source_label", "")).strip()
86
+ year_month = str(entry.get("year_month", "")).strip()
87
+
88
  if not code or not name:
89
  continue
90
 
91
  if code not in data:
92
  data[code] = {"names": [], "last_updated": today}
93
 
94
+ existing_upper = {n["name"].upper() for n in data[code]["names"]}
95
 
96
  if name.upper() in existing_upper:
97
  skipped_count += 1
98
  continue
99
 
100
+ data[code]["names"].append({
101
+ "name": name,
102
+ "source": source,
103
+ "year_month": year_month
104
+ })
105
  data[code]["last_updated"] = today
106
  added_count += 1
107
 
 
119
  api.upload_file(
120
  path_or_fileobj=tmp_path,
121
  path_in_repo=ALIAS_FILE,
122
+ repo_id=HF_SCRAPER_REPO,
123
  repo_type="dataset",
124
  token=HF_TOKEN or None,
125
  commit_message=f"Add aliases for UDISE {udise_sample} ({added_count} new name(s))",
 
134
  return f"❌ Failed to save aliases: {e}"
135
 
136
 
137
+ def get_names_for_udise(udise_code: str) -> list[dict]:
138
+ """Return all known names for a specific UDISE code as a list of dicts."""
139
  data = load_aliases()
140
  return data.get(str(udise_code).strip(), {}).get("names", [])
141
+
142
+ def delete_alias(udise_code: str, alias_name: str) -> str:
143
+ """Delete a specific alias name for a UDISE code from the cloud JSON."""
144
+ if not HF_SCRAPER_REPO:
145
+ return "⚠️ HF_SCRAPER_REPO is not configured — cannot delete aliases."
146
+
147
+ code = str(udise_code).strip()
148
+ name_to_delete = str(alias_name).strip().upper()
149
+
150
+ if not code or not name_to_delete:
151
+ return "⚠️ Invalid UDISE code or name."
152
+
153
+ data = load_aliases()
154
+ if code not in data:
155
+ return "⚠️ UDISE code not found in saved aliases."
156
+
157
+ original_count = len(data[code].get("names", []))
158
+ data[code]["names"] = [
159
+ n for n in data[code].get("names", [])
160
+ if n.get("name", "").strip().upper() != name_to_delete
161
+ ]
162
+
163
+ if len(data[code]["names"]) == original_count:
164
+ return f"⚠️ Alias '{alias_name}' not found for UDISE {code}."
165
+
166
+ data[code]["last_updated"] = datetime.now(timezone.utc).strftime("%Y-%m-%d")
167
+
168
+ # Upload to HF
169
+ try:
170
+ api = HfApi()
171
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False, encoding="utf-8") as f:
172
+ json.dump(data, f, indent=2, ensure_ascii=False)
173
+ tmp_path = f.name
174
+
175
+ api.upload_file(
176
+ path_or_fileobj=tmp_path,
177
+ path_in_repo=ALIAS_FILE,
178
+ repo_id=HF_SCRAPER_REPO,
179
+ repo_type="dataset",
180
+ token=HF_TOKEN or None,
181
+ commit_message=f"Delete alias '{alias_name}' for UDISE {code}",
182
+ )
183
+
184
+ return f"🗑️ Deleted '{alias_name}' successfully!"
185
+ except Exception as e:
186
+ return f"❌ Failed to delete alias: {e}"
app.py CHANGED
@@ -7,13 +7,13 @@ HF Spaces: launched automatically by Dockerfile
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 = """
@@ -93,6 +93,21 @@ input[type=text]:focus, textarea:focus {
93
  }
94
 
95
  /* ── Buttons ── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  .btn-search button {
97
  background: linear-gradient(135deg, #4f46e5, #7c3aed) !important;
98
  color: white !important;
@@ -155,11 +170,12 @@ input[type=text]:focus, textarea:focus {
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; }
@@ -211,26 +227,6 @@ input[type=text]:focus, textarea:focus {
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 {
@@ -252,184 +248,110 @@ details summary {
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(
@@ -443,74 +365,117 @@ THEME = gr.themes.Base(
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:**
@@ -518,35 +483,45 @@ with gr.Blocks(title="School Name Resolver") as app:
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__":
 
7
 
8
  import os
9
  import gradio as gr
 
10
  from dotenv import load_dotenv
11
+ from datetime import datetime
12
 
13
  load_dotenv()
14
 
15
+ from hf_reader import search_udise, refresh_all_caches, get_config_status, get_state_hierarchy, search_by_name_fuzzy, get_udise_choices
16
+ from alias_store import load_aliases, save_aliases, get_names_for_udise, delete_alias
17
 
18
  # ─── CSS ──────────────────────────────────────────────────────────────────────
19
  CSS = """
 
93
  }
94
 
95
  /* ── Buttons ── */
96
+ .btn-delete button {
97
+ background: #fee2e2 !important;
98
+ color: #ef4444 !important;
99
+ border: 1.5px solid #fca5a5 !important;
100
+ height: 100% !important;
101
+ min-height: 80px !important;
102
+ border-radius: 11px !important;
103
+ font-weight: 700 !important;
104
+ font-size: 0.95rem !important;
105
+ transition: all 0.2s !important;
106
+ }
107
+ .btn-delete button:hover {
108
+ background: #fecaca !important;
109
+ color: #b91c1c !important;
110
+ }
111
  .btn-search button {
112
  background: linear-gradient(135deg, #4f46e5, #7c3aed) !important;
113
  color: white !important;
 
170
  border-bottom: 1.5px solid #f1f5f9;
171
  margin-bottom: 14px;
172
  }
173
+ .marksheet-row { align-items: stretch !important; margin-bottom: 10px !important; gap: 12px !important; }
174
  .name-card {
175
  padding: 14px 16px;
176
  border-radius: 11px;
 
177
  border-left: 4px solid;
178
+ height: 100%;
179
  }
180
  .card-marksheet { background: #fffbeb; border-color: #f59e0b; }
181
  .card-legacy { background: #eff6ff; border-color: #3b82f6; }
 
227
  border-radius: 10px !important;
228
  }
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
  /* ── Empty / no result ── */
232
  .empty-msg {
 
248
  }
249
  """
250
 
251
+ # ─── Data Initialization ──────────────────────────────────────────────────────
252
+ try:
253
+ STATE_HIER = get_state_hierarchy()
254
+ STATE_CHOICES = sorted(STATE_HIER.keys())
255
+ except Exception as e:
256
+ print(f"Warning: Could not load state hierarchy on startup: {e}")
257
+ STATE_HIER = {}
258
+ STATE_CHOICES = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
 
260
+ # ─── HTML builders ────────────────────────────────────────────────────────────
261
+ def build_results_heading_html(results: list[dict], udise_code: str) -> str:
262
+ if not results: return ""
263
  unique = len(set(r["name"].strip().upper() for r in results if r.get("name") and r["name"].lower() not in ("nan","none","")))
264
  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>"
265
+ 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>'
266
+
267
+ def build_single_card_html(r: dict, ms_name: str, margin_bottom="10px") -> str:
268
+ st = r.get("source_type", "")
269
+ name = r.get("name", "").strip()
270
+ src = r.get("source", "")
271
+ ym = r.get("year_month", "")
272
+ loc = " › ".join(p for p in [r.get("state",""), r.get("district",""), r.get("block","")] if p and p not in ("nan","none",""))
273
+ css = {"marksheet": "card-marksheet", "legacy_db": "card-legacy", "latest_master": "card-latest", "old_master": "card-old"}.get(st, "card-old")
274
+ if ym and "(" not in src: src = f"{src} ({ym})"
275
+ icon = {"marksheet": "📄 Marksheet", "legacy_db": "📚 Old Master", "latest_master": "✅ Latest Master", "old_master": "🕐 Older Master"}.get(st, src)
276
+ if ym: icon = f"{icon} ({ym})"
277
+ badge = ""
278
+ if ms_name and st != "marksheet" and name.upper() == ms_name.upper(): badge = '<span class="match-badge">✓ Same Name</span>'
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
  return f"""
282
+ <div class="name-card {css}" style="margin-bottom:{margin_bottom}">
283
+ <div class="card-tag">{icon}</div>
284
+ <div class="card-name">{display}{badge}</div>
285
+ {loc_html}
 
286
  </div>"""
287
 
 
288
  def build_empty_html():
289
  return """<div class="result-panel">
290
  <div class="empty-msg">
291
+ 🔍 Select a location, enter a school name or UDISE code, and click <strong>Find Schools</strong><br>
292
+ <span style="font-size:0.82rem;color:#e2e8f0">Matching schools and their historical names will appear here</span>
293
  </div>
294
  </div>"""
295
 
296
 
297
  # ─── Event handlers ───────────────────────────────────────────────────────────
298
+ def update_udise_dropdown(state, district, block, village):
299
+ choices = get_udise_choices(state, district, block, village)
300
+ return gr.update(choices=choices, value=None)
301
+
302
+ def on_state_change(state):
303
+ if not state:
304
+ return gr.update(info="Select State"), gr.update(choices=[], value=None, interactive=False, info="Select State first")
305
+ districts = sorted(STATE_HIER.get(state, {}).keys())
306
+ return gr.update(info=""), gr.update(choices=districts, value=None, interactive=True, info="Select District")
307
+
308
+ def on_district_change(state, district):
309
+ if not state:
310
+ return gr.update(info="Select State first"), gr.update(choices=[], value=None, interactive=False, info="Select State first")
311
+ if not district:
312
+ return gr.update(info="Select District"), gr.update(choices=[], value=None, interactive=False, info="Select District first")
313
+ blocks = sorted(STATE_HIER.get(state, {}).get(district, {}).keys())
314
+ return gr.update(info=""), gr.update(choices=blocks, value=None, interactive=True, info="Select Block")
315
+
316
+ def on_block_change(state, district, block):
317
+ if not state:
318
+ return gr.update(info="Select State first"), gr.update(choices=[], value=None, interactive=False, info="Select State first")
319
+ if not district:
320
+ return gr.update(info="Select District first"), gr.update(choices=[], value=None, interactive=False, info="Select District first")
321
+ if not block:
322
+ return gr.update(info="Select Block"), gr.update(choices=[], value=None, interactive=False, info="Select Block first")
323
+ villages = sorted(STATE_HIER.get(state, {}).get(district, {}).get(block, []))
324
+ return gr.update(info=""), gr.update(choices=villages, value=None, interactive=True, info="Select Village")
325
+
326
+ def on_village_change(state, district, block, village):
327
+ choices = get_udise_choices(state, district, block, village)
328
+ if not state:
329
+ return gr.update(info="Select State first"), gr.update(choices=choices, value=None)
330
+ if not district:
331
+ return gr.update(info="Select District first"), gr.update(choices=choices, value=None)
332
+ if not block:
333
+ return gr.update(info="Select Block first"), gr.update(choices=choices, value=None)
334
+ if not village:
335
+ return gr.update(info="Select Village"), gr.update(choices=choices, value=None)
336
+ return gr.update(info=""), gr.update(choices=choices, value=None)
337
+
338
+ def on_search(query_name, state, district, block, village, udise_val):
339
+ if udise_val and str(udise_val).strip():
340
+ # User selected or typed a UDISE. Extract the code if they selected from dropdown (which is "UDISE - Name")
341
+ u_code = str(udise_val).split(" - ")[0].strip()
342
+ return [u_code]
343
+
344
+ if not query_name:
345
+ return []
346
+ matched_udises = search_by_name_fuzzy(query_name, state, district, block, village, max_results=5)
347
+ return matched_udises
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
 
349
  def on_refresh_caches():
350
+ res = refresh_all_caches()
351
+ global STATE_HIER, STATE_CHOICES
352
+ STATE_HIER = get_state_hierarchy()
353
+ STATE_CHOICES = sorted(STATE_HIER.keys())
354
+ return res, gr.update(choices=STATE_CHOICES)
 
355
 
356
  # ─── Theme ────────────────────────────────────────────────────────────────────
357
  THEME = gr.themes.Base(
 
365
  with gr.Blocks(title="School Name Resolver") as app:
366
 
367
  search_state = gr.State([])
368
+ render_trigger = gr.State(0)
369
 
 
370
  gr.HTML("""
371
  <div class="app-header">
372
  <h1>🏫 School Name Resolver</h1>
373
+ <p>Cross-reference marksheet names against historical UDISE records to resolve discrepancies</p>
374
  </div>
375
  """)
376
 
377
+ 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>')
378
+
 
379
  with gr.Row():
380
+ state_dd = gr.Dropdown(label="State", choices=STATE_CHOICES, value=None, info="Select State")
381
+ district_dd = gr.Dropdown(label="District", choices=[], value=None, interactive=False, info="Select State first")
382
+ with gr.Row():
383
+ block_dd = gr.Dropdown(label="Block", choices=[], value=None, interactive=False, info="Select District first")
384
+ village_dd = gr.Dropdown(label="Village", choices=[], value=None, interactive=False, info="Select Block first")
385
+
386
+ with gr.Row():
387
+ school_input = gr.Textbox(label="School Name", placeholder="Enter school name from marksheet...", max_lines=1, scale=1)
388
+ udise_input = gr.Dropdown(label="Or Search by UDISE Code", choices=[], allow_custom_value=True, scale=1)
389
+
390
+ with gr.Row():
391
+ clear_btn = gr.Button("🗑️ Clear Form", elem_classes="btn-secondary", size="lg", scale=1)
392
+ search_btn = gr.Button("🔍 Find Schools", elem_classes="btn-search", size="lg", scale=3)
 
393
  gr.HTML('</div>')
394
 
395
+ gr.HTML('<div style="margin-bottom:4px"><span class="step-label" style="background:#dbeafe;color:#1d4ed8">Step 2 — Search Results & Mapping</span></div>')
396
+
397
+ # ── Dynamic Results Rendering ──
398
+ @gr.render(inputs=[search_state, render_trigger])
399
+ def render_results(udises, trigger):
400
+ if not udises:
401
+ gr.HTML(build_empty_html())
402
+ return
403
+
404
+ for udise in udises:
405
+ details = search_udise(udise, "")
406
+
407
+ # Cross-reference with alias dictionary to pull in custom names
408
+ mapped_names = get_names_for_udise(udise)
409
+ detail_names_upper = {r.get("name","").upper() for r in details}
410
+
411
+ for m_info in mapped_names:
412
+ m_name = m_info.get("name", "").strip()
413
+ if m_name and m_name.upper() not in detail_names_upper:
414
+ details.append({
415
+ "source": m_info.get("source", "Saved Marksheet"),
416
+ "source_type": "marksheet",
417
+ "name": m_name,
418
+ "year_month": m_info.get("year_month", "")
419
+ })
420
+
421
+ with gr.Row(elem_classes="step-box"):
422
+ with gr.Column(scale=3):
423
+ with gr.Column(elem_classes="result-panel"):
424
+ gr.HTML(build_results_heading_html(details, udise))
425
+ ms_name = next((r["name"] for r in details if r["source_type"] == "marksheet"), "")
426
+
427
+ for r in details:
428
+ if r.get("source_type") == "marksheet":
429
+ with gr.Row(elem_classes="marksheet-row"):
430
+ with gr.Column(scale=5, min_width=100):
431
+ gr.HTML(build_single_card_html(r, ms_name, margin_bottom="0"))
432
+ with gr.Column(scale=1, min_width=60):
433
+ del_btn = gr.Button("🗑️", elem_classes="btn-delete")
434
+
435
+ def make_on_delete(u_code=udise, n=r.get("name")):
436
+ def on_delete(trigger_val):
437
+ delete_alias(u_code, n)
438
+ return trigger_val + 1
439
+ return on_delete
440
+
441
+ del_btn.click(fn=make_on_delete(), inputs=[render_trigger], outputs=[render_trigger])
442
+ else:
443
+ gr.HTML(build_single_card_html(r, ms_name))
444
+
445
+ with gr.Column(scale=1):
446
+ marksheet_input = gr.Textbox(label="Marksheet Name", placeholder="Enter exact name to map...", max_lines=1)
447
+ map_btn = gr.Button("Save Alias", elem_classes="btn-map")
448
+ status_text = gr.Textbox(show_label=False, interactive=False, visible=False, elem_classes="map-status", max_lines=1)
449
+
450
+ def on_map_inline(u, m, trigger_val, d=details):
451
+ m = m.strip()
452
+ if not m: return gr.update(visible=True, value="⚠️ Enter a name."), trigger_val
453
+
454
+ current_ym = datetime.now().strftime("%Y-%b").capitalize()
455
+ entries = [{"udise_code": u, "alias_name": m, "source_label": "Marksheet", "year_month": current_ym}]
456
+
457
+ seen = {m.upper()}
458
+ for r in d:
459
+ name = str(r.get("name","")).strip()
460
+ if name and name.lower() not in ("nan","none","") and name.upper() not in seen:
461
+ entries.append({
462
+ "udise_code": u,
463
+ "alias_name": name,
464
+ "source_label": r.get("source",""),
465
+ "year_month": r.get("year_month", "")
466
+ })
467
+ seen.add(name.upper())
468
+
469
+ msg = save_aliases(entries)
470
+ return gr.update(visible=True, value=msg), trigger_val + 1
471
+
472
+ map_btn.click(
473
+ fn=on_map_inline,
474
+ inputs=[gr.State(udise), marksheet_input, render_trigger],
475
+ outputs=[status_text, render_trigger]
476
+ )
477
+
478
+ # ── Settings ──
479
  with gr.Accordion("⚙️ Settings & Cache", open=False):
480
  gr.Markdown("""
481
  **Environment Variables required:**
 
483
  | Variable | Purpose |
484
  |---|---|
485
  | `HF_TOKEN` | HuggingFace token (read + write) |
486
+ | `HF_SCRAPER_REPO` | Existing KYS scraper dataset (has scraped masters and will store aliases) |
 
 
 
487
  """)
488
  refresh_cache_btn = gr.Button("🔄 Refresh Data Cache", elem_classes="btn-secondary")
489
  cache_status = gr.Textbox(show_label=False, interactive=False, max_lines=2)
490
 
491
+ def on_clear_form():
492
+ return (
493
+ gr.update(value=None), # state
494
+ gr.update(value=None), # school_input
495
+ [] # search_state
496
+ )
497
+
498
+ clear_btn.click(
499
+ fn=on_clear_form,
500
+ outputs=[state_dd, school_input, search_state]
501
+ )
502
+
503
+ state_dd.change(fn=on_state_change, inputs=[state_dd], outputs=[state_dd, district_dd])
504
+ district_dd.change(fn=on_district_change, inputs=[state_dd, district_dd], outputs=[district_dd, block_dd])
505
+ block_dd.change(fn=on_block_change, inputs=[state_dd, district_dd, block_dd], outputs=[block_dd, village_dd])
506
+ village_dd.change(fn=on_village_change, inputs=[state_dd, district_dd, block_dd, village_dd], outputs=[village_dd, udise_input])
507
+
508
  search_btn.click(
509
  fn=on_search,
510
+ inputs=[school_input, state_dd, district_dd, block_dd, village_dd, udise_input],
511
+ outputs=[search_state],
512
  )
513
+ school_input.submit(
514
  fn=on_search,
515
+ inputs=[school_input, state_dd, district_dd, block_dd, village_dd, udise_input],
516
+ outputs=[search_state],
517
  )
518
+ udise_input.change(
519
+ fn=on_search,
520
+ inputs=[school_input, state_dd, district_dd, block_dd, village_dd, udise_input],
521
+ outputs=[search_state],
522
  )
 
 
 
 
523
 
524
+ refresh_cache_btn.click(fn=on_refresh_caches, outputs=[cache_status, state_dd])
525
 
526
  # ─── Launch ───────────────────────────────────────────────────────────────────
527
  if __name__ == "__main__":
hf_reader.py CHANGED
@@ -13,10 +13,14 @@ 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
@@ -27,24 +31,25 @@ _cache_loaded: bool = False
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
 
@@ -102,6 +107,147 @@ def _ensure_loaded():
102
  _cache_loaded = True
103
 
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  # ─── Public API ───────────────────────────────────────────────────────────────
106
 
107
  def refresh_all_caches() -> str:
@@ -119,32 +265,34 @@ def refresh_all_caches() -> str:
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] = []
@@ -157,6 +305,7 @@ def search_udise(udise_code: str, marksheet_name: str = "") -> list[dict]:
157
  "state": "",
158
  "district": "",
159
  "block": "",
 
160
  })
161
 
162
  if _old_master_df is not None and not _old_master_df.empty:
@@ -164,12 +313,13 @@ def search_udise(udise_code: str, marksheet_name: str = "") -> list[dict]:
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()):
@@ -177,7 +327,7 @@ def search_udise(udise_code: str, marksheet_name: str = "") -> list[dict]:
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({
@@ -187,17 +337,72 @@ def search_udise(udise_code: str, marksheet_name: str = "") -> list[dict]:
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),
 
13
  import pandas as pd
14
  from huggingface_hub import HfApi, hf_hub_download
15
  from datetime import datetime
16
+ from rapidfuzz import process, fuzz
17
+ try:
18
+ from admin_patterns import normalize_with_patterns_dynamic
19
+ except ImportError:
20
+ normalize_with_patterns_dynamic = lambda s, st: s
21
 
22
  HF_TOKEN = os.getenv("HF_TOKEN", "")
23
  HF_SCRAPER_REPO = os.getenv("HF_SCRAPER_REPO", "")
 
24
 
25
  # ─── Module-level caches ───────────────────────────────────────────────────────
26
  _old_master_df: pd.DataFrame | None = None
 
31
  # ─── Internal helpers ──────────────────────────────────────────────────────────
32
 
33
  def _load_old_master() -> pd.DataFrame:
34
+ """Download and return baseline_master.parquet from HF_SCRAPER_REPO."""
35
+ if not HF_SCRAPER_REPO:
36
+ print("[hf_reader] HF_SCRAPER_REPO not set — skipping Old Master load.")
37
  return pd.DataFrame()
38
  try:
39
  path = hf_hub_download(
40
+ repo_id=HF_SCRAPER_REPO,
41
+ filename="mapping_rules/baseline_master.parquet",
42
  repo_type="dataset",
43
  token=HF_TOKEN or None,
44
  force_download=True,
45
  )
46
+ df = pd.read_parquet(path)
47
+ if "School_Udise_Code__c" in df.columns:
48
+ df["School_Udise_Code__c"] = df["School_Udise_Code__c"].astype(str).str.strip()
49
  print(f"[hf_reader] Old Master loaded: {len(df):,} rows.")
50
  return df
51
  except Exception as e:
52
+ print(f"[hf_reader] Could not load baseline_master.parquet: {e}")
53
  return pd.DataFrame()
54
 
55
 
 
107
  _cache_loaded = True
108
 
109
 
110
+ def get_state_hierarchy() -> dict:
111
+ """
112
+ Returns a nested dictionary of the geographic hierarchy:
113
+ { state: { district: { block: [villages] } } }
114
+ Built by combining all loaded data sources.
115
+ """
116
+ _ensure_loaded()
117
+ hier = {}
118
+
119
+ dfs_to_process = []
120
+ if _old_master_df is not None and not _old_master_df.empty:
121
+ dfs_to_process.append(_old_master_df)
122
+ dfs_to_process.extend(_mapped_masters.values())
123
+
124
+ for df in dfs_to_process:
125
+ if df.empty: continue
126
+
127
+ s_col = "School_State__c"
128
+ d_col = "School_District__c"
129
+ b_col = "School_Block__c"
130
+ v_col = "School_Village__c"
131
+
132
+ if s_col not in df.columns: continue
133
+
134
+ df_tmp = df.copy()
135
+ for col in [s_col, d_col, b_col, v_col]:
136
+ if col not in df_tmp.columns:
137
+ df_tmp[col] = ""
138
+ else:
139
+ df_tmp[col] = df_tmp[col].fillna("").astype(str).str.strip().str.upper()
140
+
141
+ for _, r in df_tmp.iterrows():
142
+ st = r[s_col]
143
+ di = r[d_col]
144
+ bl = r[b_col]
145
+ vi = r[v_col]
146
+
147
+ if not st: continue
148
+ if st not in hier: hier[st] = {}
149
+ if not di: continue
150
+ if di not in hier[st]: hier[st][di] = {}
151
+ if not bl: continue
152
+ if bl not in hier[st][di]: hier[st][di][bl] = set()
153
+ if vi:
154
+ hier[st][di][bl].add(vi)
155
+
156
+ for s in hier:
157
+ for d in hier[s]:
158
+ for b in hier[s][d]:
159
+ hier[s][d][b] = sorted(list(hier[s][d][b]))
160
+
161
+ return hier
162
+
163
+
164
+ def search_by_name_fuzzy(query_name: str, state: str, district: str, block: str, village: str = None, max_results=10) -> list[str]:
165
+ """
166
+ Fuzzy searches the combined data sources for a school name.
167
+ Returns a list of unique matched UDISE codes.
168
+ """
169
+ _ensure_loaded()
170
+ if not query_name:
171
+ return []
172
+
173
+ dfs_to_process = []
174
+ if _old_master_df is not None and not _old_master_df.empty:
175
+ dfs_to_process.append(_old_master_df)
176
+ dfs_to_process.extend(_mapped_masters.values())
177
+
178
+ if not dfs_to_process:
179
+ return []
180
+
181
+ combined_rows = []
182
+
183
+ for df in dfs_to_process:
184
+ if df.empty: continue
185
+ if "School_Name__c" not in df.columns or "School_Udise_Code__c" not in df.columns: continue
186
+
187
+ mask = pd.Series(True, index=df.index)
188
+
189
+ if state:
190
+ if "School_State__c" in df.columns:
191
+ mask = mask & (df["School_State__c"].astype(str).str.strip().str.upper() == state.upper())
192
+ else:
193
+ continue
194
+
195
+ if district:
196
+ if "School_District__c" in df.columns:
197
+ mask = mask & (df["School_District__c"].astype(str).str.strip().str.upper() == district.upper())
198
+ else:
199
+ continue
200
+
201
+ if block:
202
+ if "School_Block__c" in df.columns:
203
+ mask = mask & (df["School_Block__c"].astype(str).str.strip().str.upper() == block.upper())
204
+ else:
205
+ continue
206
+
207
+ if village:
208
+ if "School_Village__c" in df.columns:
209
+ mask = mask & (df["School_Village__c"].astype(str).str.strip().str.upper() == village.upper())
210
+ else:
211
+ continue
212
+
213
+ filtered = df[mask]
214
+ if not filtered.empty:
215
+ filtered_sub = filtered[["School_Udise_Code__c", "School_Name__c"]].copy()
216
+ combined_rows.append(filtered_sub)
217
+
218
+ if not combined_rows:
219
+ return []
220
+
221
+ combined_df = pd.concat(combined_rows, ignore_index=True).drop_duplicates()
222
+ combined_df["School_Name__c"] = combined_df["School_Name__c"].astype(str).str.strip()
223
+ combined_df = combined_df[combined_df["School_Name__c"] != ""]
224
+
225
+ if combined_df.empty:
226
+ return []
227
+
228
+ choices = combined_df["School_Name__c"].tolist()
229
+ state_for_patterns = (state or "ARUNACHAL PRADESH").upper()
230
+
231
+ candidates_raw = process.extract(
232
+ query_name,
233
+ choices,
234
+ scorer=fuzz.token_set_ratio,
235
+ processor=lambda s: normalize_with_patterns_dynamic(s, state_for_patterns),
236
+ limit=max_results,
237
+ )
238
+
239
+ matched_udises = []
240
+ seen_udises = set()
241
+ for choice, score, idx in candidates_raw:
242
+ udise = combined_df.iloc[idx]["School_Udise_Code__c"]
243
+ udise = str(udise).strip()
244
+ if udise and udise not in seen_udises:
245
+ seen_udises.add(udise)
246
+ matched_udises.append(udise)
247
+
248
+ return matched_udises
249
+
250
+
251
  # ─── Public API ───────────────────────────────────────────────────────────────
252
 
253
  def refresh_all_caches() -> str:
 
265
  )
266
 
267
 
268
+ def _pretty_label(stem: str, idx: int) -> tuple[str, str]:
269
+ """Turn a filename stem like 'mapped_master_2026_jul_01_10_19_pm' into a readable label and year_month."""
270
+ match_str = re.search(r"(\d{4})_([a-zA-Z]{3})_(\d{2})", stem)
271
+ if match_str:
272
+ y, m_str, d = match_str.groups()
273
+ date_str = f"{d}-{m_str.capitalize()}-{y}"
274
+ ym = f"{y}-{m_str.capitalize()}"
275
  else:
276
+ match_num = re.search(r"(\d{4})(\d{2})(\d{2})", stem)
277
+ if match_num:
278
+ y, m, d = match_num.groups()
279
+ date_str = f"{d}/{m}/{y}"
280
+ month_str = datetime.strptime(m, "%m").strftime("%b").capitalize()
281
+ ym = f"{y}-{month_str}"
282
+ else:
283
+ date_str = stem
284
+ ym = ""
285
 
286
  if idx == 0:
287
+ return f"Latest Scraped Master ({date_str})", ym
288
  else:
289
+ return f"Older Scraped Master ({date_str})", ym
290
 
291
 
292
  def search_udise(udise_code: str, marksheet_name: str = "") -> list[dict]:
293
  """
294
  Search for a UDISE code across all data sources.
295
+ Returns a list of dicts.
 
 
 
 
 
 
 
296
  """
297
  _ensure_loaded()
298
  results: list[dict] = []
 
305
  "state": "",
306
  "district": "",
307
  "block": "",
308
+ "year_month": datetime.now().strftime("%Y-%b").capitalize(),
309
  })
310
 
311
  if _old_master_df is not None and not _old_master_df.empty:
 
313
  matches = _old_master_df[mask]
314
  for _, row in matches.iterrows():
315
  results.append({
316
+ "source": "Old Master (baseline_master.parquet)",
317
  "source_type": "legacy_db",
318
  "name": str(row.get("School_Name__c", "")).strip(),
319
  "state": str(row.get("School_State__c", "")).strip(),
320
  "district": str(row.get("School_District__c", "")).strip(),
321
  "block": str(row.get("School_Block__c", "")).strip(),
322
+ "year_month": "2025",
323
  })
324
 
325
  for idx, (stem, df) in enumerate(_mapped_masters.items()):
 
327
  continue
328
  mask = df["School_Udise_Code__c"] == str(udise_code).strip()
329
  matches = df[mask]
330
+ label, ym = _pretty_label(stem, idx)
331
  source_type = "latest_master" if idx == 0 else "old_master"
332
  for _, row in matches.iterrows():
333
  results.append({
 
337
  "state": str(row.get("School_State__c", "")).strip(),
338
  "district": str(row.get("School_District__c", "")).strip(),
339
  "block": str(row.get("School_Block__c", "")).strip(),
340
+ "year_month": ym,
341
  })
342
 
343
  return results
344
 
345
 
346
+ def get_udise_choices(state: str, district: str, block: str, village: str = None) -> list[str]:
347
+ """Returns a list of 'UDISE - School Name' for the given location filter."""
348
+ _ensure_loaded()
349
+
350
+ dfs_to_process = []
351
+ if _old_master_df is not None and not _old_master_df.empty:
352
+ dfs_to_process.append(_old_master_df)
353
+ dfs_to_process.extend(_mapped_masters.values())
354
+
355
+ if not dfs_to_process:
356
+ return []
357
+
358
+ combined_rows = []
359
+ for df in dfs_to_process:
360
+ if df.empty: continue
361
+ if "School_Name__c" not in df.columns or "School_Udise_Code__c" not in df.columns: continue
362
+
363
+ mask = pd.Series(True, index=df.index)
364
+ if state:
365
+ if "School_State__c" in df.columns:
366
+ mask &= (df["School_State__c"].astype(str).str.strip().str.upper() == state.upper())
367
+ else: continue
368
+ if district:
369
+ if "School_District__c" in df.columns:
370
+ mask &= (df["School_District__c"].astype(str).str.strip().str.upper() == district.upper())
371
+ else: continue
372
+ if block:
373
+ if "School_Block__c" in df.columns:
374
+ mask &= (df["School_Block__c"].astype(str).str.strip().str.upper() == block.upper())
375
+ else: continue
376
+ if village:
377
+ if "School_Village__c" in df.columns:
378
+ mask &= (df["School_Village__c"].astype(str).str.strip().str.upper() == village.upper())
379
+ else: continue
380
+
381
+ filtered = df[mask]
382
+ if not filtered.empty:
383
+ filtered_sub = filtered[["School_Udise_Code__c", "School_Name__c"]].copy()
384
+ combined_rows.append(filtered_sub)
385
+
386
+ if not combined_rows:
387
+ return []
388
+
389
+ combined_df = pd.concat(combined_rows, ignore_index=True).drop_duplicates()
390
+ combined_df["School_Udise_Code__c"] = combined_df["School_Udise_Code__c"].astype(str).str.strip()
391
+ combined_df["School_Name__c"] = combined_df["School_Name__c"].astype(str).str.strip()
392
+ combined_df = combined_df[(combined_df["School_Udise_Code__c"] != "") & (combined_df["School_Name__c"] != "")]
393
+
394
+ if combined_df.empty:
395
+ return []
396
+
397
+ choices = (combined_df["School_Udise_Code__c"] + " - " + combined_df["School_Name__c"]).unique().tolist()
398
+ return sorted(choices)
399
+
400
+
401
  def get_config_status() -> dict:
402
  """Return a dict describing the current configuration state."""
403
  _ensure_loaded()
404
  return {
405
  "scraper_repo": HF_SCRAPER_REPO or "⚠️ Not set (HF_SCRAPER_REPO)",
 
406
  "sf_baseline_rows": len(_old_master_df) if _old_master_df is not None else 0,
407
  "mapped_masters_count": len(_mapped_masters),
408
  "token_set": bool(HF_TOKEN),
patterns.json ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "global": [
3
+ {"pattern": "\\bH\\.?\\s*/?\\s*S\\.?\\b", "replacement": " HIGH SCHOOL "},
4
+ {"pattern": "\\bH\\.?\\s*SC(H)?\\.?\\b", "replacement": " HIGH SCHOOL "},
5
+ {"pattern": "\\bHS\\b", "replacement": " HIGH SCHOOL "},
6
+ {"pattern": "\\bH\\.?\\s*SEC\\.?\\b", "replacement": " HIGHER SECONDARY "},
7
+ {"pattern": "\\bHR\\.?\\s*SEC\\.?\\b", "replacement": " HIGHER SECONDARY "},
8
+ {"pattern": "\\bHSS\\b", "replacement": " HIGHER SECONDARY SCHOOL "},
9
+ {"pattern": "\\bH\\.?S\\.?S\\.?\\b", "replacement": " HIGHER SECONDARY SCHOOL "},
10
+ {"pattern": "\\bGHS\\b", "replacement": " GOVERNMENT HIGH SCHOOL "},
11
+ {"pattern": "\\bGHSS\\b", "replacement": " GOVERNMENT HIGHER SECONDARY SCHOOL "},
12
+ {"pattern": "\\bIC\\b", "replacement": " INTER COLLEGE "},
13
+ {"pattern": "\\bINTER\\s+COLLEGE\\b", "replacement": " INTER COLLEGE "},
14
+ {"pattern": "\\b\\+2\\b", "replacement": " PLUS TWO "},
15
+
16
+ {"pattern": "\\bVIDYALAYA\\b", "replacement": " SCHOOL "},
17
+ {"pattern": "\\bVIDYA(LAY|LAYA)\\b", "replacement": " SCHOOL "},
18
+ {"pattern": "\\bUCHCH\\b", "replacement": " HIGHER "},
19
+ {"pattern": "\\bUCHCHA\\b", "replacement": " HIGHER "},
20
+ {"pattern": "\\bUCHCHATAR(A)?\\b", "replacement": " HIGHER "},
21
+ {"pattern": "\\bMADHYAMIK\\b", "replacement": " SECONDARY "},
22
+ {"pattern": "\\bUCHHATARA\\s+MADHYAMIK\\b", "replacement": " HIGHER SECONDARY "},
23
+ {"pattern": "\\bHIGHER\\s+SCHOOL\\b", "replacement": " HIGH SCHOOL "},
24
+ {"pattern": "\\bUCHCH\\s+VIDYALAYA\\b", "replacement": " HIGH SCHOOL "},
25
+ {"pattern": "\\bUCHCHA\\s+VIDYALAYA\\b", "replacement": " HIGH SCHOOL "},
26
+
27
+ {"pattern": "\\bBALIKA\\b", "replacement": " GIRLS "},
28
+ {"pattern": "\\bBALIKA\\s+VIDYALAYA\\b", "replacement": " GIRLS SCHOOL "},
29
+ {"pattern": "\\bBAL\\b", "replacement": " GIRLS "},
30
+ {"pattern": "\\bGIRL['’]S\\b", "replacement": " GIRLS "},
31
+ {"pattern": "\\bBOY['’]S\\b", "replacement": " BOYS "},
32
+
33
+ {"pattern": "\\bGOVT\\.?\\b", "replacement": " GOVT "},
34
+ {"pattern": "\\bGOVERNMENT\\b", "replacement": " GOVT "},
35
+ {"pattern": "\\bRAJKIYA\\b", "replacement": " GOVT "},
36
+ {"pattern": "\\bRAJKEEYA\\b", "replacement": " GOVT "},
37
+
38
+ {"pattern": "\\bST\\.?\\b", "replacement": " SAINT "},
39
+ {"pattern": "\\bSRI\\b", "replacement": " SHRI "},
40
+ {"pattern": "\\bSHREE\\b", "replacement": " SHRI "},
41
+
42
+ {"pattern": "\\bKV\\b", "replacement": " KENDRIYA VIDYALAYA "},
43
+ {"pattern": "\\bK\\.?\\s*V\\.?\\b", "replacement": " KENDRIYA VIDYALAYA "},
44
+ {"pattern": "\\bKENDRIYA\\s+VIDYALAYA\\b", "replacement": " KENDRIYA VIDYALAYA "},
45
+ {"pattern": "\\bNVS\\b", "replacement": " JAWAHAR NAVODAYA VIDYALAYA "},
46
+ {"pattern": "\\bJNV\\b", "replacement": " JAWAHAR NAVODAYA VIDYALAYA "},
47
+ {"pattern": "\\bNAVODAYA\\s+VIDYALAYA\\b", "replacement": " JAWAHAR NAVODAYA VIDYALAYA "},
48
+
49
+ {"pattern": "\\bPROJ\\.?\\b", "replacement": " PROJECT "},
50
+ {"pattern": "\\bUPG\\.?\\b", "replacement": " UPGRADED "},
51
+ {"pattern": "\\bUPGRADED\\b", "replacement": " UPGRADED "},
52
+ {"pattern": "\\bTECH\\.?\\b", "replacement": " TECHNICAL "},
53
+ {"pattern": "\\bJR\\.?\\b", "replacement": " JUNIOR "},
54
+ {"pattern": "\\bSR\\.?\\b", "replacement": " SENIOR "},
55
+ {"pattern": "\\bPRASTABIT\\b", "replacement": " PRASTAWIT "},
56
+ {"pattern": "\\bPRASTAWIT\\b", "replacement": " PRASTAWIT "},
57
+ {"pattern": "\\bPUB\\.?\\s*SCH\\.?\\b", "replacement": " PUBLIC SCHOOL "},
58
+ {"pattern": "\\bCONVENT\\b", "replacement": " CONVENT "},
59
+ {"pattern": "\\bMISSION\\b", "replacement": " MISSION "}
60
+ ],
61
+
62
+ "states": {
63
+ "KARNATAKA": [
64
+ {"pattern": "\\bHR\\.?\\s*SEC\\.?\\b", "replacement": " HIGHER SECONDARY "},
65
+ {"pattern": "\\bHR\\.?\\s*SEC\\.?\\s*SCHOOL\\b", "replacement": " HIGHER SECONDARY SCHOOL "},
66
+ {"pattern": "\\bMAT(H)?\\b", "replacement": " MATRICULATION "},
67
+
68
+ {"pattern": "\\bP\\.?\\s*U\\.?\\b", "replacement": " PRE UNIVERSITY "},
69
+ {"pattern": "\\bPU\\s+COLLEGE\\b", "replacement": " PRE UNIVERSITY COLLEGE "},
70
+ {"pattern": "\\bPRE\\s*UNIVERSITY\\s*COLLEGE\\b", "replacement": " PRE UNIVERSITY COLLEGE "},
71
+ {"pattern": "\\bPRE\\s*UNIVERSITY\\b", "replacement": " PRE UNIVERSITY "},
72
+ {"pattern": "\\bGOVT[-\\s]*PU\\s+COLLEGE\\b", "replacement": " GOVT PRE UNIVERSITY COLLEGE "},
73
+ {"pattern": "\\bGOVT[-\\s]*P\\s*U\\s+COLLEGE\\b", "replacement": " GOVT PRE UNIVERSITY COLLEGE "},
74
+ {"pattern": "\\bGOVT[-\\s]*JUNIOR\\s+COLLEGE\\b", "replacement": " GOVT JUNIOR COLLEGE "},
75
+ {"pattern": "\\bGJC\\b", "replacement": " GOVT JUNIOR COLLEGE "},
76
+ {"pattern": "\\bGPUC\\b", "replacement": " GOVT PRE UNIVERSITY COLLEGE "},
77
+ {"pattern": "\\bKARNATAKA\\s+PUBLIC\\s+SCHOOLS\\b", "replacement": " KARNATAKA PUBLIC SCHOOL "},
78
+ {"pattern": "\\bHIGH\\s+SCHOOL\\s+SECTION\\b", "replacement": " HIGH SCHOOL SECTION "},
79
+
80
+ {"pattern": "\\bJUNIOR\\s+COLLEGE\\b", "replacement": " PRE UNIVERSITY COLLEGE "}
81
+ ]
82
+ }
83
+ }
requirements.txt CHANGED
@@ -3,3 +3,5 @@ huggingface-hub>=0.24.0
3
  pandas>=2.0.0
4
  pyarrow>=14.0.0
5
  python-dotenv>=1.0.0
 
 
 
3
  pandas>=2.0.0
4
  pyarrow>=14.0.0
5
  python-dotenv>=1.0.0
6
+ rapidfuzz>=3.0.0
7
+