Spaces:
Sleeping
Sleeping
Commit ·
e37cbeb
1
Parent(s): 3e2f38d
new features
Browse files- .env.example +0 -1
- README.md +59 -119
- admin_patterns.py +205 -0
- alias_store.py +82 -16
- app.py +237 -262
- hf_reader.py +234 -29
- patterns.json +83 -0
- 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 |
-
|
| 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 |
-
##
|
| 65 |
-
|
| 66 |
-
This app is designed to be hosted on **HuggingFace Spaces** using Docker.
|
| 67 |
|
| 68 |
-
|
| 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 |
-
|
| 75 |
-
When creating the HuggingFace Space for this application, configure the following in the **Settings → Variables and Secrets** tab:
|
| 76 |
|
| 77 |
-
**
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
| `HF_TOKEN` | A HuggingFace Access Token with **Write** permissions. Used to read the baselines and write the alias JSON. |
|
| 81 |
|
| 82 |
-
**
|
| 83 |
-
|
| 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 |
-
##
|
| 94 |
-
|
| 95 |
-
If you need to test the application locally on a Windows machine:
|
| 96 |
|
| 97 |
-
|
| 98 |
-
# 1. Create and activate a virtual environment
|
| 99 |
-
python -m venv venv
|
| 100 |
-
.\venv\Scripts\activate
|
| 101 |
|
| 102 |
-
#
|
| 103 |
-
|
|
|
|
|
|
|
| 104 |
|
| 105 |
-
#
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 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 |
-
##
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 8 |
-
|
| 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 |
-
|
| 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
|
| 34 |
return {}
|
| 35 |
try:
|
| 36 |
path = hf_hub_download(
|
| 37 |
-
repo_id=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 59 |
-
return "⚠️
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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[
|
| 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 |
-
# ───
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 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="
|
| 294 |
-
<div class="
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
{"".join(cards)}
|
| 298 |
</div>"""
|
| 299 |
|
| 300 |
-
|
| 301 |
def build_empty_html():
|
| 302 |
return """<div class="result-panel">
|
| 303 |
<div class="empty-msg">
|
| 304 |
-
🔍
|
| 305 |
-
<span style="font-size:0.82rem;color:#e2e8f0">
|
| 306 |
</div>
|
| 307 |
</div>"""
|
| 308 |
|
| 309 |
|
| 310 |
# ─── Event handlers ───────────────────────────────────────────────────────────
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
return gr.update(
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
if not
|
| 358 |
-
return
|
| 359 |
-
|
| 360 |
-
|
| 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 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
return
|
| 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>
|
| 452 |
</div>
|
| 453 |
""")
|
| 454 |
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
with gr.Row():
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
)
|
| 471 |
-
|
| 472 |
-
search_btn = gr.Button("🔍 Search All Sources", elem_classes="btn-search", size="lg")
|
| 473 |
gr.HTML('</div>')
|
| 474 |
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
search_btn.click(
|
| 531 |
fn=on_search,
|
| 532 |
-
inputs=[
|
| 533 |
-
outputs=[
|
| 534 |
)
|
| 535 |
-
|
| 536 |
fn=on_search,
|
| 537 |
-
inputs=[
|
| 538 |
-
outputs=[
|
| 539 |
)
|
| 540 |
-
|
| 541 |
-
fn=
|
| 542 |
-
inputs=[
|
| 543 |
-
outputs=[
|
| 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} · {len(results)} source(s) checked · {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
|
| 31 |
-
if not
|
| 32 |
-
print("[hf_reader]
|
| 33 |
return pd.DataFrame()
|
| 34 |
try:
|
| 35 |
path = hf_hub_download(
|
| 36 |
-
repo_id=
|
| 37 |
-
filename="
|
| 38 |
repo_type="dataset",
|
| 39 |
token=HF_TOKEN or None,
|
| 40 |
force_download=True,
|
| 41 |
)
|
| 42 |
-
df = pd.
|
| 43 |
-
|
|
|
|
| 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
|
| 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 '
|
| 124 |
-
|
| 125 |
-
if
|
| 126 |
-
y,
|
| 127 |
-
date_str = f"{d}
|
|
|
|
| 128 |
else:
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 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 |
+
|