Spaces:
Sleeping
Sleeping
Deploy CV parser dashboard with dataset 2 model
Browse files- .gitignore +16 -0
- Dockerfile +17 -0
- README.md +66 -4
- app.py +65 -0
- config.py +109 -0
- lib/__init__.py +0 -0
- lib/extract.py +86 -0
- lib/model.py +198 -0
- lib/ui.py +60 -0
- lib/viz.py +102 -0
- pages/1_Live_Parser.py +80 -0
- pages/2_Analytics.py +112 -0
- pages/3_Manage_Model.py +135 -0
- requirements.txt +10 -0
.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
|
| 7 |
+
# Streamlit local secrets (NEVER commit β use the Space's secret settings)
|
| 8 |
+
.streamlit/secrets.toml
|
| 9 |
+
|
| 10 |
+
# Local model weights live on dev machines / the Hub, not in this repo
|
| 11 |
+
exported_models/
|
| 12 |
+
|
| 13 |
+
# Misc
|
| 14 |
+
.DS_Store
|
| 15 |
+
logs/
|
| 16 |
+
*.log
|
Dockerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PYTHONUNBUFFERED=1 \
|
| 7 |
+
STREAMLIT_SERVER_HEADLESS=true \
|
| 8 |
+
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
|
| 9 |
+
|
| 10 |
+
COPY requirements.txt .
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
EXPOSE 7860
|
| 16 |
+
|
| 17 |
+
CMD ["streamlit", "run", "app.py", "--server.address=0.0.0.0", "--server.port=7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,72 @@
|
|
| 1 |
---
|
| 2 |
title: Automated CV Parser
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Automated CV Parser
|
| 3 |
+
emoji: π§©
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
short_description: Resume NER β extracts Job Titles, Skills & Education
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# CV Parser Dashboard
|
| 13 |
+
|
| 14 |
+
A Streamlit app on top of the WQF7007 resume-NER model. Deployed as a Hugging
|
| 15 |
+
Face Space; the model is loaded from the Hub so it can be updated without a
|
| 16 |
+
redeploy.
|
| 17 |
+
|
| 18 |
+
- **π Live Parser** β paste/upload one CV and watch it get tokenized and
|
| 19 |
+
classified: sub-word token chips coloured by predicted label, the original text
|
| 20 |
+
with highlighted entities, and a structured summary.
|
| 21 |
+
- **π Analytics** β batch-upload CVs (PDF / DOCX / TXT) for a skills word cloud
|
| 22 |
+
and top-entity charts across the set.
|
| 23 |
+
- **π Manage Model** β password-gated; teammates upload a new exported model and
|
| 24 |
+
it's pushed to the Hub repo the app reads from.
|
| 25 |
+
|
| 26 |
+
## Models β how swapping works
|
| 27 |
+
|
| 28 |
+
The app loads its model **from a Hugging Face Hub repo** (`PRIMARY_MODEL_ID` in
|
| 29 |
+
`config.py`, default `Zeqhx/cv-parser-ner`). A Space's own disk is wiped on
|
| 30 |
+
restart, so the Hub repo is the durable store. To update the live model:
|
| 31 |
+
|
| 32 |
+
- **Easiest:** open **Manage Model**, enter the page password, upload a `.zip` of
|
| 33 |
+
your exported model folder (the `exported_models/β¦` folder the training notebooks
|
| 34 |
+
produce). It's validated against the 7-tag scheme and pushed to the Hub repo.
|
| 35 |
+
- **Or:** push files straight to the Hub model repo (web UI / CLI).
|
| 36 |
+
- Then click **π Reload model** in the sidebar to pick up the new weights.
|
| 37 |
+
|
| 38 |
+
The sidebar picker also has a **Custom HF model ID** box to load any repo live.
|
| 39 |
+
|
| 40 |
+
## Secrets (Space β Settings β Variables and secrets)
|
| 41 |
+
|
| 42 |
+
| Name | Purpose |
|
| 43 |
+
|------|---------|
|
| 44 |
+
| `HF_TOKEN` | A Hugging Face **write** token, so Manage Model can push to the Hub. |
|
| 45 |
+
| `MANAGE_PASSWORD` | Password protecting the Manage Model page (page is disabled if unset). |
|
| 46 |
+
| `DASHBOARD_MODEL_ID` | *(optional)* Override the model repo the app loads. |
|
| 47 |
+
|
| 48 |
+
## Run locally
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
pip install -r requirements.txt
|
| 52 |
+
streamlit run app.py
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
Local exported models in `../exported_models/β¦` are auto-offered in the picker
|
| 56 |
+
when present (see `config.MODEL_REGISTRY`).
|
| 57 |
+
|
| 58 |
+
## Layout
|
| 59 |
+
|
| 60 |
+
```
|
| 61 |
+
app.py # landing page + model-status banner
|
| 62 |
+
config.py # model registry/resolution, labels, colours, sample CV
|
| 63 |
+
pages/
|
| 64 |
+
1_Live_Parser.py
|
| 65 |
+
2_Analytics.py
|
| 66 |
+
3_Manage_Model.py # password-gated model uploader -> Hub
|
| 67 |
+
lib/
|
| 68 |
+
model.py # load (local/Hub/fallback) + sliding-window inference
|
| 69 |
+
extract.py # PDF / DOCX / TXT -> text (adaptive spacing fix)
|
| 70 |
+
ui.py # shared sidebar model picker
|
| 71 |
+
viz.py # entity HTML, token chips, word cloud, bar charts
|
| 72 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CV Parser dashboard β entry page.
|
| 2 |
+
|
| 3 |
+
Run from the project root:
|
| 4 |
+
streamlit run dashboard/app.py
|
| 5 |
+
"""
|
| 6 |
+
import streamlit as st
|
| 7 |
+
|
| 8 |
+
import config
|
| 9 |
+
from lib.ui import model_selector
|
| 10 |
+
|
| 11 |
+
st.set_page_config(page_title="CV Parser Dashboard", page_icon="π§©", layout="wide")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def model_status_banner(lm):
|
| 15 |
+
if lm.is_fallback:
|
| 16 |
+
st.warning(
|
| 17 |
+
f"**Demo mode β no fine-tuned model loaded.** Source: `{lm.source}`. "
|
| 18 |
+
"The classification head is untrained, so entity predictions are "
|
| 19 |
+
"**not meaningful** yet. Publish a model from the **Manage Model** page "
|
| 20 |
+
f"to `{config.PRIMARY_MODEL_ID}`, or pick one in the sidebar.",
|
| 21 |
+
icon="β οΈ",
|
| 22 |
+
)
|
| 23 |
+
else:
|
| 24 |
+
st.success(f"Model loaded β {lm.source}", icon="β
")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
st.title("π§© Automated CV Parser")
|
| 28 |
+
st.caption("WQF7007 NLP Β· Resume NER Β· extracts Job Titles, Skills & Education")
|
| 29 |
+
|
| 30 |
+
lm = model_selector()
|
| 31 |
+
model_status_banner(lm)
|
| 32 |
+
|
| 33 |
+
st.markdown(
|
| 34 |
+
"""
|
| 35 |
+
### What's inside
|
| 36 |
+
|
| 37 |
+
- **π Live Parser** β paste or upload a single CV and watch it get **tokenized and
|
| 38 |
+
classified** in real time: sub-word token chips coloured by predicted label, the
|
| 39 |
+
original text with highlighted entities, and a clean structured summary.
|
| 40 |
+
- **π Analytics** β upload a batch of CVs (PDF / DOCX / TXT) and the page builds a
|
| 41 |
+
**skills word cloud** plus top Job Titles / Skills / Education charts across the set.
|
| 42 |
+
|
| 43 |
+
Use the sidebar to switch pages.
|
| 44 |
+
"""
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
with st.expander("βΉοΈ How the model is resolved"):
|
| 48 |
+
st.markdown(
|
| 49 |
+
f"""
|
| 50 |
+
The sidebar **Model** picker selects which weights run. Options:
|
| 51 |
+
|
| 52 |
+
1. **β Best model (Hub)** β `{config.PRIMARY_MODEL_ID}`. The team's current
|
| 53 |
+
best model; what the deployed app loads by default.
|
| 54 |
+
2. **Custom HF model ID** β type any Hub repo id to load it live.
|
| 55 |
+
3. **Local export** β `exported_models/β¦` folders (only on dev machines).
|
| 56 |
+
4. **Demo fallback** β `{config.FALLBACK_MODEL}` with a random head
|
| 57 |
+
(UI works, predictions don't).
|
| 58 |
+
|
| 59 |
+
Teammates update the live model from the **π Manage Model** page (uploads
|
| 60 |
+
an exported model and pushes it to the Hub repo) β no redeploy needed.
|
| 61 |
+
After updating, click **π Reload model** in the sidebar.
|
| 62 |
+
|
| 63 |
+
Label scheme: `{', '.join(config.LABELS)}`
|
| 64 |
+
"""
|
| 65 |
+
)
|
config.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central config for the CV Parser dashboard.
|
| 2 |
+
|
| 3 |
+
A model "ref" is resolved by lib/model.py as:
|
| 4 |
+
- a local directory (e.g. exported_models/roberta-base-ner) -> load from disk
|
| 5 |
+
- any other string with a "/" -> a Hugging Face Hub repo id
|
| 6 |
+
- None -> FALLBACK_MODEL (roberta-base + random head; flagged as demo in the UI)
|
| 7 |
+
|
| 8 |
+
On the deployed HF Space there are no local folders, so the app loads the team's
|
| 9 |
+
"best model" repo (PRIMARY_MODEL_ID) from the Hub. Teammates update that repo via
|
| 10 |
+
the password-gated Manage Model page (or by pushing to it directly), and the app
|
| 11 |
+
picks up the new weights β no redeploy needed.
|
| 12 |
+
"""
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
# ---- Model resolution -------------------------------------------------------
|
| 16 |
+
# Hugging Face owner + the canonical "best model" repo the app loads by default
|
| 17 |
+
# and that the Manage Model page overwrites.
|
| 18 |
+
HF_OWNER = os.environ.get("HF_OWNER", "Zeqhx")
|
| 19 |
+
PRIMARY_MODEL_ID = os.environ.get("DASHBOARD_MODEL_ID", f"{HF_OWNER}/cv-parser-ner")
|
| 20 |
+
|
| 21 |
+
# Back-compat single-ref overrides (used by load_model()'s config path).
|
| 22 |
+
MODEL_PATH = os.environ.get("DASHBOARD_MODEL_PATH", "")
|
| 23 |
+
MODEL_ID = PRIMARY_MODEL_ID
|
| 24 |
+
FALLBACK_MODEL = "roberta-base"
|
| 25 |
+
|
| 26 |
+
DEMO_LABEL = "Demo β untrained roberta-base"
|
| 27 |
+
|
| 28 |
+
# Toggle registry. Each entry: (label, kind, ref). "local" entries are only
|
| 29 |
+
# offered when the folder exists (dev machines); "hub" entries are always offered.
|
| 30 |
+
MODEL_REGISTRY = [
|
| 31 |
+
(f"β Best model (Hub: {PRIMARY_MODEL_ID})", "hub", PRIMARY_MODEL_ID),
|
| 32 |
+
("RoBERTa β local export", "local", "exported_models/roberta-base-ner"),
|
| 33 |
+
("BERT β local export", "local", "exported_models/bert-base-uncased-ner"),
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def available_models():
|
| 38 |
+
"""Ordered {label: ref} of selectable models, plus the demo fallback.
|
| 39 |
+
|
| 40 |
+
``ref=None`` marks the demo/fallback. Local entries appear only when present
|
| 41 |
+
on disk; Hub entries always appear (a missing/private repo degrades to demo,
|
| 42 |
+
which the UI flags).
|
| 43 |
+
"""
|
| 44 |
+
found = {}
|
| 45 |
+
local_found = {}
|
| 46 |
+
hub_found = {}
|
| 47 |
+
for label, kind, ref in MODEL_REGISTRY:
|
| 48 |
+
if kind == "local" and not os.path.isdir(ref):
|
| 49 |
+
continue
|
| 50 |
+
if kind == "local":
|
| 51 |
+
local_found[label] = ref
|
| 52 |
+
else:
|
| 53 |
+
hub_found[label] = ref
|
| 54 |
+
found.update(local_found)
|
| 55 |
+
found.update(hub_found)
|
| 56 |
+
found[DEMO_LABEL] = None
|
| 57 |
+
return found
|
| 58 |
+
|
| 59 |
+
# ---- Inference --------------------------------------------------------------
|
| 60 |
+
MAX_LENGTH = 512
|
| 61 |
+
STRIDE = 128 # matches the project's sliding-window preprocessing
|
| 62 |
+
|
| 63 |
+
# ---- Label scheme (must match training; see project README) -----------------
|
| 64 |
+
LABELS = [
|
| 65 |
+
"O",
|
| 66 |
+
"B-JOB_TITLE", "I-JOB_TITLE",
|
| 67 |
+
"B-SKILL", "I-SKILL",
|
| 68 |
+
"B-EDUCATION", "I-EDUCATION",
|
| 69 |
+
]
|
| 70 |
+
ID2LABEL = {i: l for i, l in enumerate(LABELS)}
|
| 71 |
+
LABEL2ID = {l: i for i, l in enumerate(LABELS)}
|
| 72 |
+
|
| 73 |
+
# Entity types (BIO prefix stripped) + display colours
|
| 74 |
+
ENTITY_TYPES = ["JOB_TITLE", "SKILL", "EDUCATION"]
|
| 75 |
+
ENTITY_COLORS = {
|
| 76 |
+
"JOB_TITLE": "#ffb703", # amber
|
| 77 |
+
"SKILL": "#2a9d8f", # teal
|
| 78 |
+
"EDUCATION": "#4361ee", # blue
|
| 79 |
+
}
|
| 80 |
+
ENTITY_LABELS = {
|
| 81 |
+
"JOB_TITLE": "Job Title",
|
| 82 |
+
"SKILL": "Skill",
|
| 83 |
+
"EDUCATION": "Education",
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
SUPPORTED_EXTS = [".pdf", ".docx", ".txt"]
|
| 87 |
+
|
| 88 |
+
SAMPLE_RESUME = """John Carter
|
| 89 |
+
Senior Software Engineer
|
| 90 |
+
|
| 91 |
+
Summary
|
| 92 |
+
Experienced Software Engineer and Team Lead with 8 years building scalable
|
| 93 |
+
backend systems. Skilled in Python, Java, Kubernetes, and distributed systems.
|
| 94 |
+
|
| 95 |
+
Experience
|
| 96 |
+
Senior Software Engineer, Acme Corp (2019 - present)
|
| 97 |
+
- Designed microservices using Python, FastAPI and PostgreSQL.
|
| 98 |
+
- Led a team of 5 engineers and introduced CI/CD with Docker and Jenkins.
|
| 99 |
+
|
| 100 |
+
Data Scientist, Globex (2016 - 2019)
|
| 101 |
+
- Built machine learning models with TensorFlow and scikit-learn.
|
| 102 |
+
|
| 103 |
+
Education
|
| 104 |
+
Master of Science in Computer Science, Stanford University (2016)
|
| 105 |
+
Bachelor of Engineering in Software Engineering, MIT (2014)
|
| 106 |
+
|
| 107 |
+
Skills
|
| 108 |
+
Python, Java, SQL, Machine Learning, Kubernetes, Docker, AWS, Leadership
|
| 109 |
+
"""
|
lib/__init__.py
ADDED
|
File without changes
|
lib/extract.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extract plain text from uploaded CV files (PDF / DOCX / TXT).
|
| 2 |
+
|
| 3 |
+
Extraction libs are imported lazily so the app still loads if one is missing;
|
| 4 |
+
the caller gets a clear error string instead of a crash.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _clean(text: str) -> str:
|
| 13 |
+
"""Light normalisation mirroring the project's preprocessing."""
|
| 14 |
+
text = text.replace("\x00", " ")
|
| 15 |
+
text = re.sub(r"\(cid:\d+\)", " ", text) # unmapped PDF glyphs (icons/ligatures)
|
| 16 |
+
text = re.sub(r"[ \t]+", " ", text)
|
| 17 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 18 |
+
return "\n".join(line.strip() for line in text.splitlines()).strip()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _space_ratio(text: str) -> float:
|
| 22 |
+
"""Fraction of characters that are spaces. Normal prose ~0.12-0.18;
|
| 23 |
+
PDFs with glued words ('UniversityofMalaya') drop near ~0.0."""
|
| 24 |
+
t = text.strip()
|
| 25 |
+
return (t.count(" ") / len(t)) if t else 0.0
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _from_pdf(file) -> str:
|
| 29 |
+
import pdfplumber
|
| 30 |
+
|
| 31 |
+
def extract(pages, **kw):
|
| 32 |
+
return "\n".join((p.extract_text(**kw) or "") for p in pages)
|
| 33 |
+
|
| 34 |
+
with pdfplumber.open(file) as pdf:
|
| 35 |
+
pages = pdf.pages
|
| 36 |
+
text = extract(pages)
|
| 37 |
+
# Some PDFs encode inter-word spaces as gaps smaller than pdfplumber's
|
| 38 |
+
# default x_tolerance (3), so words come out glued together. Detect that
|
| 39 |
+
# via a very low space ratio and re-extract with a tighter tolerance,
|
| 40 |
+
# keeping it only if it genuinely adds spaces.
|
| 41 |
+
if _space_ratio(text) < 0.08:
|
| 42 |
+
tight = extract(pages, x_tolerance=1)
|
| 43 |
+
if _space_ratio(tight) > _space_ratio(text):
|
| 44 |
+
text = tight
|
| 45 |
+
return text
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _from_docx(file) -> str:
|
| 49 |
+
import docx
|
| 50 |
+
document = docx.Document(file)
|
| 51 |
+
return "\n".join(p.text for p in document.paragraphs)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _from_txt(file) -> str:
|
| 55 |
+
raw = file.read()
|
| 56 |
+
if isinstance(raw, bytes):
|
| 57 |
+
return raw.decode("utf-8", errors="ignore")
|
| 58 |
+
return raw
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def extract_text(file, filename: str | None = None):
|
| 62 |
+
"""Return (text, error). Exactly one is non-empty.
|
| 63 |
+
|
| 64 |
+
`file` is a file-like object (e.g. a Streamlit UploadedFile).
|
| 65 |
+
"""
|
| 66 |
+
name = filename or getattr(file, "name", "") or ""
|
| 67 |
+
ext = os.path.splitext(name)[1].lower()
|
| 68 |
+
try:
|
| 69 |
+
if ext == ".pdf":
|
| 70 |
+
text = _from_pdf(file)
|
| 71 |
+
elif ext == ".docx":
|
| 72 |
+
text = _from_docx(file)
|
| 73 |
+
elif ext == ".txt":
|
| 74 |
+
text = _from_txt(file)
|
| 75 |
+
else:
|
| 76 |
+
return "", f"Unsupported file type: {ext or '(none)'}"
|
| 77 |
+
except ModuleNotFoundError as e:
|
| 78 |
+
return "", (f"Missing library for {ext} files ({e.name}). "
|
| 79 |
+
f"Install dashboard/requirements.txt.")
|
| 80 |
+
except Exception as e: # noqa: BLE001 - surface any parse error to the UI
|
| 81 |
+
return "", f"Could not read {name}: {e}"
|
| 82 |
+
|
| 83 |
+
text = _clean(text)
|
| 84 |
+
if not text:
|
| 85 |
+
return "", f"No extractable text in {name} (scanned/image PDF?)."
|
| 86 |
+
return text, ""
|
lib/model.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model loading + sliding-window NER inference.
|
| 2 |
+
|
| 3 |
+
Kept free of any Streamlit imports so it can be unit-tested / reused.
|
| 4 |
+
The Streamlit pages wrap `load_model()` in `st.cache_resource`.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
from transformers import AutoTokenizer, AutoModelForTokenClassification
|
| 13 |
+
|
| 14 |
+
import config
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class LoadedModel:
|
| 19 |
+
tokenizer: object
|
| 20 |
+
model: object
|
| 21 |
+
id2label: dict
|
| 22 |
+
source: str # human-readable description of where weights came from
|
| 23 |
+
is_fallback: bool # True => random head, predictions are meaningless
|
| 24 |
+
device: str = field(default="cpu")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _ensure_label_scheme(model):
|
| 28 |
+
"""If the loaded model lacks our entity labels, overwrite its id2label."""
|
| 29 |
+
cfg_labels = set(getattr(model.config, "id2label", {}).values())
|
| 30 |
+
if "B-SKILL" not in cfg_labels:
|
| 31 |
+
model.config.id2label = dict(config.ID2LABEL)
|
| 32 |
+
model.config.label2id = dict(config.LABEL2ID)
|
| 33 |
+
return {int(k): v for k, v in model.config.id2label.items()}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
_USE_CONFIG = "__use_config__"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _load_local(path: str, device: str) -> LoadedModel:
|
| 40 |
+
tok = AutoTokenizer.from_pretrained(path)
|
| 41 |
+
model = AutoModelForTokenClassification.from_pretrained(path)
|
| 42 |
+
id2label = _ensure_label_scheme(model)
|
| 43 |
+
model.to(device).eval()
|
| 44 |
+
return LoadedModel(tok, model, id2label,
|
| 45 |
+
source=f"Local folder: {path}",
|
| 46 |
+
is_fallback=False, device=device)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _load_hub(model_id: str, device: str) -> LoadedModel:
|
| 50 |
+
tok = AutoTokenizer.from_pretrained(model_id)
|
| 51 |
+
model = AutoModelForTokenClassification.from_pretrained(model_id)
|
| 52 |
+
id2label = _ensure_label_scheme(model)
|
| 53 |
+
model.to(device).eval()
|
| 54 |
+
return LoadedModel(tok, model, id2label,
|
| 55 |
+
source=f"Hugging Face Hub: {model_id}",
|
| 56 |
+
is_fallback=False, device=device)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _load_fallback(device: str) -> LoadedModel:
|
| 60 |
+
tok = AutoTokenizer.from_pretrained(config.FALLBACK_MODEL, add_prefix_space=True)
|
| 61 |
+
model = AutoModelForTokenClassification.from_pretrained(
|
| 62 |
+
config.FALLBACK_MODEL,
|
| 63 |
+
num_labels=len(config.LABELS),
|
| 64 |
+
id2label=dict(config.ID2LABEL),
|
| 65 |
+
label2id=dict(config.LABEL2ID),
|
| 66 |
+
)
|
| 67 |
+
model.to(device).eval()
|
| 68 |
+
return LoadedModel(tok, model, dict(config.ID2LABEL),
|
| 69 |
+
source=f"Fallback base model: {config.FALLBACK_MODEL} (untrained head)",
|
| 70 |
+
is_fallback=True, device=device)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def load_model(ref: str | None = _USE_CONFIG) -> LoadedModel:
|
| 74 |
+
"""Load a NER model from a ref.
|
| 75 |
+
|
| 76 |
+
- ``ref=_USE_CONFIG`` (default): resolve per config.py priority
|
| 77 |
+
(local MODEL_PATH -> MODEL_ID -> fallback). Keeps old callers working.
|
| 78 |
+
- ``ref`` is a local directory: load that exported folder.
|
| 79 |
+
- ``ref`` is any other non-empty string: treat as a Hugging Face Hub repo id.
|
| 80 |
+
- ``ref=None``: go straight to the demo fallback model.
|
| 81 |
+
|
| 82 |
+
A local folder that's missing, or a Hub repo that can't be loaded
|
| 83 |
+
(404 / private / offline), degrades gracefully to the demo fallback.
|
| 84 |
+
"""
|
| 85 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 86 |
+
|
| 87 |
+
if ref == _USE_CONFIG:
|
| 88 |
+
if config.MODEL_PATH and os.path.isdir(config.MODEL_PATH):
|
| 89 |
+
return _load_local(config.MODEL_PATH, device)
|
| 90 |
+
if config.MODEL_ID:
|
| 91 |
+
ref = config.MODEL_ID
|
| 92 |
+
else:
|
| 93 |
+
return _load_fallback(device)
|
| 94 |
+
|
| 95 |
+
if not ref:
|
| 96 |
+
return _load_fallback(device)
|
| 97 |
+
if os.path.isdir(ref):
|
| 98 |
+
return _load_local(ref, device)
|
| 99 |
+
try:
|
| 100 |
+
return _load_hub(ref, device)
|
| 101 |
+
except Exception: # noqa: BLE001 - missing/private/offline repo -> demo
|
| 102 |
+
return _load_fallback(device)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@torch.no_grad()
|
| 106 |
+
def predict(text: str, lm: LoadedModel):
|
| 107 |
+
"""Run sliding-window token classification over `text`.
|
| 108 |
+
|
| 109 |
+
Returns (tokens, entities):
|
| 110 |
+
tokens = [{"text", "label", "type", "start", "end"}] one per sub-word
|
| 111 |
+
entities = [{"text", "type", "start", "end"}] merged BIO spans
|
| 112 |
+
"""
|
| 113 |
+
text = text or ""
|
| 114 |
+
if not text.strip():
|
| 115 |
+
return [], []
|
| 116 |
+
|
| 117 |
+
enc = lm.tokenizer(
|
| 118 |
+
text,
|
| 119 |
+
max_length=config.MAX_LENGTH,
|
| 120 |
+
truncation=True,
|
| 121 |
+
stride=config.STRIDE,
|
| 122 |
+
return_overflowing_tokens=True,
|
| 123 |
+
return_offsets_mapping=True,
|
| 124 |
+
padding=True,
|
| 125 |
+
return_tensors="pt",
|
| 126 |
+
)
|
| 127 |
+
offsets = enc["offset_mapping"]
|
| 128 |
+
attn = enc["attention_mask"]
|
| 129 |
+
input_ids = enc["input_ids"].to(lm.device)
|
| 130 |
+
attn_dev = attn.to(lm.device)
|
| 131 |
+
|
| 132 |
+
logits = lm.model(input_ids=input_ids, attention_mask=attn_dev).logits
|
| 133 |
+
preds = logits.argmax(-1).cpu()
|
| 134 |
+
|
| 135 |
+
# Deduplicate overlapping sliding-window tokens by their global char offset.
|
| 136 |
+
seen: dict[int, tuple] = {}
|
| 137 |
+
n_windows, seq_len = preds.shape
|
| 138 |
+
for w in range(n_windows):
|
| 139 |
+
for i in range(seq_len):
|
| 140 |
+
s, e = offsets[w][i].tolist()
|
| 141 |
+
if (s == 0 and e == 0) or attn[w][i] == 0:
|
| 142 |
+
continue # special token or padding
|
| 143 |
+
if s in seen:
|
| 144 |
+
continue
|
| 145 |
+
seen[s] = (s, e, int(preds[w][i]))
|
| 146 |
+
|
| 147 |
+
tokens = []
|
| 148 |
+
for s in sorted(seen):
|
| 149 |
+
_, e, pid = seen[s]
|
| 150 |
+
label = lm.id2label.get(pid, "O")
|
| 151 |
+
etype = label.split("-", 1)[1] if "-" in label else None
|
| 152 |
+
tokens.append({"text": text[s:e], "label": label, "type": etype,
|
| 153 |
+
"start": s, "end": e})
|
| 154 |
+
|
| 155 |
+
entities = _merge_bio(tokens, text)
|
| 156 |
+
return tokens, entities
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _merge_bio(tokens, text):
|
| 160 |
+
"""Merge consecutive B-/I- tokens of the same type into entity spans."""
|
| 161 |
+
entities = []
|
| 162 |
+
cur = None
|
| 163 |
+
for t in tokens:
|
| 164 |
+
label = t["label"]
|
| 165 |
+
if "-" not in label: # "O"
|
| 166 |
+
if cur:
|
| 167 |
+
entities.append(cur)
|
| 168 |
+
cur = None
|
| 169 |
+
continue
|
| 170 |
+
prefix, etype = label.split("-", 1)
|
| 171 |
+
if prefix == "B" or cur is None or cur["type"] != etype:
|
| 172 |
+
if cur:
|
| 173 |
+
entities.append(cur)
|
| 174 |
+
cur = {"type": etype, "start": t["start"], "end": t["end"]}
|
| 175 |
+
else: # I- continuing the same type
|
| 176 |
+
cur["end"] = t["end"]
|
| 177 |
+
if cur:
|
| 178 |
+
entities.append(cur)
|
| 179 |
+
|
| 180 |
+
for e in entities:
|
| 181 |
+
e["text"] = text[e["start"]:e["end"]].strip()
|
| 182 |
+
return [e for e in entities if e["text"]]
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def group_entities(entities):
|
| 186 |
+
"""Group merged entities by type, de-duplicating case-insensitively."""
|
| 187 |
+
grouped = {t: [] for t in config.ENTITY_TYPES}
|
| 188 |
+
seen = {t: set() for t in config.ENTITY_TYPES}
|
| 189 |
+
for e in entities:
|
| 190 |
+
t = e["type"]
|
| 191 |
+
if t not in grouped:
|
| 192 |
+
continue
|
| 193 |
+
key = e["text"].lower()
|
| 194 |
+
if key in seen[t]:
|
| 195 |
+
continue
|
| 196 |
+
seen[t].add(key)
|
| 197 |
+
grouped[t].append(e["text"])
|
| 198 |
+
return grouped
|
lib/ui.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared Streamlit UI helpers β the cross-page model toggle.
|
| 2 |
+
|
| 3 |
+
Keeping this in one place means every page shows the same picker and shares the
|
| 4 |
+
same selection (via st.session_state) and the same per-ref cache.
|
| 5 |
+
"""
|
| 6 |
+
import streamlit as st
|
| 7 |
+
|
| 8 |
+
import config
|
| 9 |
+
from lib.model import load_model
|
| 10 |
+
|
| 11 |
+
_CUSTOM_LABEL = "βοΈ Custom HF model IDβ¦"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@st.cache_resource(show_spinner="Loading NER modelβ¦")
|
| 15 |
+
def _load_cached(ref):
|
| 16 |
+
"""Cache one LoadedModel per distinct ref (None = demo fallback)."""
|
| 17 |
+
return load_model(ref=ref)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def model_selector():
|
| 21 |
+
"""Render the sidebar model picker and return the selected LoadedModel.
|
| 22 |
+
|
| 23 |
+
The choice persists across pages through st.session_state["model_label"].
|
| 24 |
+
A "Custom HF model ID" option lets anyone load any Hub repo live, and the
|
| 25 |
+
refresh button clears the cache to pick up a freshly-uploaded model.
|
| 26 |
+
"""
|
| 27 |
+
options = config.available_models() # {label: ref}
|
| 28 |
+
labels = list(options.keys()) + [_CUSTOM_LABEL]
|
| 29 |
+
|
| 30 |
+
st.sidebar.subheader("Model")
|
| 31 |
+
current = st.session_state.get("model_label", labels[0])
|
| 32 |
+
index = labels.index(current) if current in labels else 0
|
| 33 |
+
choice = st.sidebar.selectbox(
|
| 34 |
+
"Active NER model", labels, index=index, key="model_label",
|
| 35 |
+
label_visibility="collapsed",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
if choice == _CUSTOM_LABEL:
|
| 39 |
+
ref = st.sidebar.text_input(
|
| 40 |
+
"HF model repo id", placeholder="e.g. Zeqhx/cv-parser-ner",
|
| 41 |
+
key="custom_model_id",
|
| 42 |
+
).strip() or None
|
| 43 |
+
else:
|
| 44 |
+
ref = options[choice]
|
| 45 |
+
|
| 46 |
+
if st.sidebar.button("π Reload model", use_container_width=True,
|
| 47 |
+
help="Clear the cache and re-pull (use after updating a model)."):
|
| 48 |
+
_load_cached.clear()
|
| 49 |
+
st.rerun()
|
| 50 |
+
|
| 51 |
+
lm = _load_cached(ref)
|
| 52 |
+
|
| 53 |
+
if lm.is_fallback:
|
| 54 |
+
st.sidebar.warning("Demo mode β untrained head; predictions are not meaningful.",
|
| 55 |
+
icon="β οΈ")
|
| 56 |
+
else:
|
| 57 |
+
st.sidebar.success("Model loaded", icon="β
")
|
| 58 |
+
st.sidebar.caption(lm.source)
|
| 59 |
+
|
| 60 |
+
return lm
|
lib/viz.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendering helpers: entity-highlighted text, token chips, word cloud, charts."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import html
|
| 5 |
+
|
| 6 |
+
import config
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _legend() -> str:
|
| 10 |
+
items = []
|
| 11 |
+
for t in config.ENTITY_TYPES:
|
| 12 |
+
items.append(
|
| 13 |
+
f'<span style="background:{config.ENTITY_COLORS[t]};color:#fff;'
|
| 14 |
+
f'padding:2px 8px;border-radius:4px;margin-right:8px;font-size:0.8rem">'
|
| 15 |
+
f'{config.ENTITY_LABELS[t]}</span>'
|
| 16 |
+
)
|
| 17 |
+
return '<div style="margin-bottom:10px">' + "".join(items) + "</div>"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def render_entities_html(text: str, entities: list[dict]) -> str:
|
| 21 |
+
"""Original text with entity spans wrapped in coloured marks."""
|
| 22 |
+
ents = sorted((e for e in entities if e["type"] in config.ENTITY_COLORS),
|
| 23 |
+
key=lambda e: e["start"])
|
| 24 |
+
out, cursor = [], 0
|
| 25 |
+
for e in ents:
|
| 26 |
+
if e["start"] < cursor: # skip any overlap defensively
|
| 27 |
+
continue
|
| 28 |
+
out.append(html.escape(text[cursor:e["start"]]))
|
| 29 |
+
color = config.ENTITY_COLORS[e["type"]]
|
| 30 |
+
label = config.ENTITY_LABELS[e["type"]]
|
| 31 |
+
out.append(
|
| 32 |
+
f'<mark style="background:{color};color:#fff;padding:1px 4px;'
|
| 33 |
+
f'border-radius:4px" title="{label}">'
|
| 34 |
+
f'{html.escape(text[e["start"]:e["end"]])}'
|
| 35 |
+
f'<sub style="font-size:0.6em;opacity:.85"> {label}</sub></mark>'
|
| 36 |
+
)
|
| 37 |
+
cursor = e["end"]
|
| 38 |
+
out.append(html.escape(text[cursor:]))
|
| 39 |
+
body = "".join(out).replace("\n", "<br>")
|
| 40 |
+
return (_legend() +
|
| 41 |
+
f'<div style="line-height:2.1;font-family:system-ui;font-size:0.95rem;'
|
| 42 |
+
f'border:1px solid #ddd;border-radius:8px;padding:16px;'
|
| 43 |
+
f'max-height:520px;overflow:auto">{body}</div>')
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def render_tokens_html(tokens: list[dict], limit: int = 400) -> str:
|
| 47 |
+
"""Sub-word token chips, coloured by predicted label β the 'tokenization view'."""
|
| 48 |
+
chips = []
|
| 49 |
+
for t in tokens[:limit]:
|
| 50 |
+
txt = html.escape(t["text"]) or "Β·"
|
| 51 |
+
if t["type"] in config.ENTITY_COLORS:
|
| 52 |
+
color = config.ENTITY_COLORS[t["type"]]
|
| 53 |
+
style = f"background:{color};color:#fff"
|
| 54 |
+
else:
|
| 55 |
+
style = "background:#eee;color:#555"
|
| 56 |
+
chips.append(
|
| 57 |
+
f'<span style="{style};padding:2px 6px;border-radius:4px;margin:2px;'
|
| 58 |
+
f'display:inline-block;font-family:monospace;font-size:0.8rem">{txt}</span>'
|
| 59 |
+
)
|
| 60 |
+
more = "" if len(tokens) <= limit else f'<div style="color:#888;margin-top:8px">β¦ +{len(tokens)-limit} more tokens</div>'
|
| 61 |
+
return (_legend() +
|
| 62 |
+
f'<div style="border:1px solid #ddd;border-radius:8px;padding:12px;'
|
| 63 |
+
f'max-height:420px;overflow:auto">{"".join(chips)}{more}</div>')
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def wordcloud_figure(freq: dict, title: str = ""):
|
| 67 |
+
"""Return a matplotlib Figure for a frequency dict, or None if unavailable."""
|
| 68 |
+
if not freq:
|
| 69 |
+
return None
|
| 70 |
+
try:
|
| 71 |
+
from wordcloud import WordCloud
|
| 72 |
+
import matplotlib.pyplot as plt
|
| 73 |
+
except ModuleNotFoundError:
|
| 74 |
+
return None
|
| 75 |
+
wc = WordCloud(width=900, height=400, background_color="white",
|
| 76 |
+
colormap="viridis", prefer_horizontal=0.9)
|
| 77 |
+
wc.generate_from_frequencies(freq)
|
| 78 |
+
fig, ax = plt.subplots(figsize=(9, 4))
|
| 79 |
+
ax.imshow(wc, interpolation="bilinear")
|
| 80 |
+
ax.axis("off")
|
| 81 |
+
if title:
|
| 82 |
+
ax.set_title(title)
|
| 83 |
+
fig.tight_layout()
|
| 84 |
+
return fig
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def top_bar_figure(counter, title: str, color: str, top_n: int = 15):
|
| 88 |
+
"""Horizontal bar chart of the most common items. Returns a Plotly fig or None."""
|
| 89 |
+
if not counter:
|
| 90 |
+
return None
|
| 91 |
+
try:
|
| 92 |
+
import plotly.graph_objects as go
|
| 93 |
+
except ModuleNotFoundError:
|
| 94 |
+
return None
|
| 95 |
+
items = counter.most_common(top_n)[::-1]
|
| 96 |
+
labels = [k for k, _ in items]
|
| 97 |
+
values = [v for _, v in items]
|
| 98 |
+
fig = go.Figure(go.Bar(x=values, y=labels, orientation="h",
|
| 99 |
+
marker_color=color))
|
| 100 |
+
fig.update_layout(title=title, height=max(300, 28 * len(items) + 80),
|
| 101 |
+
margin=dict(l=10, r=10, t=40, b=10))
|
| 102 |
+
return fig
|
pages/1_Live_Parser.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live Parser page β tokenize + classify a single CV, visually."""
|
| 2 |
+
import streamlit as st
|
| 3 |
+
|
| 4 |
+
import config
|
| 5 |
+
from lib.model import predict, group_entities
|
| 6 |
+
from lib.extract import extract_text
|
| 7 |
+
from lib.ui import model_selector
|
| 8 |
+
from lib import viz
|
| 9 |
+
|
| 10 |
+
st.set_page_config(page_title="Live Parser", page_icon="π", layout="wide")
|
| 11 |
+
|
| 12 |
+
lm = model_selector()
|
| 13 |
+
|
| 14 |
+
st.title("π Live CV Parser")
|
| 15 |
+
if lm.is_fallback:
|
| 16 |
+
st.warning("Demo mode: predictions come from an untrained head and are not meaningful.",
|
| 17 |
+
icon="β οΈ")
|
| 18 |
+
|
| 19 |
+
# ---- Input ------------------------------------------------------------------
|
| 20 |
+
src = st.radio("Input", ["Paste text", "Upload file", "Use sample CV"],
|
| 21 |
+
horizontal=True, label_visibility="collapsed")
|
| 22 |
+
|
| 23 |
+
text = ""
|
| 24 |
+
if src == "Paste text":
|
| 25 |
+
text = st.text_area("Paste CV text", height=260, placeholder="Paste resume text hereβ¦")
|
| 26 |
+
elif src == "Upload file":
|
| 27 |
+
up = st.file_uploader("Upload a CV", type=["pdf", "docx", "txt"])
|
| 28 |
+
if up is not None:
|
| 29 |
+
text, err = extract_text(up)
|
| 30 |
+
if err:
|
| 31 |
+
st.error(err)
|
| 32 |
+
else:
|
| 33 |
+
text = config.SAMPLE_RESUME
|
| 34 |
+
st.info("Using a built-in sample CV.")
|
| 35 |
+
|
| 36 |
+
run = st.button("Parse CV", type="primary", disabled=not text.strip())
|
| 37 |
+
|
| 38 |
+
# ---- Output -----------------------------------------------------------------
|
| 39 |
+
if run and text.strip():
|
| 40 |
+
with st.spinner("Tokenizing and classifyingβ¦"):
|
| 41 |
+
tokens, entities = predict(text, lm)
|
| 42 |
+
|
| 43 |
+
grouped = group_entities(entities)
|
| 44 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 45 |
+
c1.metric("Sub-word tokens", len(tokens))
|
| 46 |
+
c2.metric("Job Titles", len(grouped["JOB_TITLE"]))
|
| 47 |
+
c3.metric("Skills", len(grouped["SKILL"]))
|
| 48 |
+
c4.metric("Education", len(grouped["EDUCATION"]))
|
| 49 |
+
|
| 50 |
+
tab_ent, tab_tok, tab_card = st.tabs(
|
| 51 |
+
["π·οΈ Highlighted entities", "π’ Tokenization view", "ποΈ Structured summary"])
|
| 52 |
+
|
| 53 |
+
with tab_ent:
|
| 54 |
+
st.markdown(viz.render_entities_html(text, entities), unsafe_allow_html=True)
|
| 55 |
+
|
| 56 |
+
with tab_tok:
|
| 57 |
+
st.caption("Each chip is one sub-word token produced by the tokenizer, "
|
| 58 |
+
"coloured by its predicted label.")
|
| 59 |
+
st.markdown(viz.render_tokens_html(tokens), unsafe_allow_html=True)
|
| 60 |
+
|
| 61 |
+
with tab_card:
|
| 62 |
+
cols = st.columns(3)
|
| 63 |
+
for col, etype in zip(cols, config.ENTITY_TYPES):
|
| 64 |
+
with col:
|
| 65 |
+
st.subheader(config.ENTITY_LABELS[etype])
|
| 66 |
+
vals = grouped[etype]
|
| 67 |
+
if vals:
|
| 68 |
+
for v in vals:
|
| 69 |
+
st.markdown(f"- {v}")
|
| 70 |
+
else:
|
| 71 |
+
st.caption("β none β")
|
| 72 |
+
|
| 73 |
+
import json
|
| 74 |
+
st.download_button(
|
| 75 |
+
"β¬οΈ Download as JSON",
|
| 76 |
+
data=json.dumps({config.ENTITY_LABELS[t]: grouped[t]
|
| 77 |
+
for t in config.ENTITY_TYPES}, indent=2),
|
| 78 |
+
file_name="parsed_cv.json",
|
| 79 |
+
mime="application/json",
|
| 80 |
+
)
|
pages/2_Analytics.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Analytics page β batch-upload CVs and aggregate extracted entities."""
|
| 2 |
+
from collections import Counter
|
| 3 |
+
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
import config
|
| 8 |
+
from lib.model import predict, group_entities
|
| 9 |
+
from lib.extract import extract_text
|
| 10 |
+
from lib.ui import model_selector
|
| 11 |
+
from lib import viz
|
| 12 |
+
|
| 13 |
+
st.set_page_config(page_title="Analytics", page_icon="π", layout="wide")
|
| 14 |
+
|
| 15 |
+
lm = model_selector()
|
| 16 |
+
|
| 17 |
+
st.title("π CV Corpus Analytics")
|
| 18 |
+
st.caption("Upload a batch of CVs β select every file in a folder β to see aggregate "
|
| 19 |
+
"skills and roles across the set.")
|
| 20 |
+
if lm.is_fallback:
|
| 21 |
+
st.warning("Demo mode: predictions come from an untrained head and are not meaningful.",
|
| 22 |
+
icon="β οΈ")
|
| 23 |
+
|
| 24 |
+
uploads = st.file_uploader(
|
| 25 |
+
"Upload CVs (PDF / DOCX / TXT). Tip: open a folder and Ctrl/Cmd-A to select all.",
|
| 26 |
+
type=["pdf", "docx", "txt"], accept_multiple_files=True,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
if not uploads:
|
| 30 |
+
st.info("Upload one or more CVs to build the analytics.")
|
| 31 |
+
st.stop()
|
| 32 |
+
|
| 33 |
+
if st.button(f"Analyze {len(uploads)} file(s)", type="primary"):
|
| 34 |
+
counters = {t: Counter() for t in config.ENTITY_TYPES}
|
| 35 |
+
rows = []
|
| 36 |
+
failures = []
|
| 37 |
+
progress = st.progress(0.0, text="Processingβ¦")
|
| 38 |
+
|
| 39 |
+
for i, up in enumerate(uploads, start=1):
|
| 40 |
+
text, err = extract_text(up)
|
| 41 |
+
if err:
|
| 42 |
+
failures.append((up.name, err))
|
| 43 |
+
else:
|
| 44 |
+
_, entities = predict(text, lm)
|
| 45 |
+
grouped = group_entities(entities) # de-duped per CV
|
| 46 |
+
for etype in config.ENTITY_TYPES:
|
| 47 |
+
for val in grouped[etype]:
|
| 48 |
+
counters[etype][val] += 1
|
| 49 |
+
rows.append({
|
| 50 |
+
"file": up.name,
|
| 51 |
+
"job_titles": len(grouped["JOB_TITLE"]),
|
| 52 |
+
"skills": len(grouped["SKILL"]),
|
| 53 |
+
"education": len(grouped["EDUCATION"]),
|
| 54 |
+
})
|
| 55 |
+
progress.progress(i / len(uploads), text=f"Processed {i}/{len(uploads)}")
|
| 56 |
+
progress.empty()
|
| 57 |
+
|
| 58 |
+
st.session_state["analytics"] = {"counters": counters, "rows": rows,
|
| 59 |
+
"failures": failures, "n": len(uploads)}
|
| 60 |
+
|
| 61 |
+
# ---- Render (persists across reruns) ----------------------------------------
|
| 62 |
+
data = st.session_state.get("analytics")
|
| 63 |
+
if data:
|
| 64 |
+
counters, rows, failures = data["counters"], data["rows"], data["failures"]
|
| 65 |
+
|
| 66 |
+
a, b, c, d = st.columns(4)
|
| 67 |
+
a.metric("CVs processed", len(rows))
|
| 68 |
+
b.metric("Unique job titles", len(counters["JOB_TITLE"]))
|
| 69 |
+
c.metric("Unique skills", len(counters["SKILL"]))
|
| 70 |
+
d.metric("Unique education", len(counters["EDUCATION"]))
|
| 71 |
+
if failures:
|
| 72 |
+
with st.expander(f"β οΈ {len(failures)} file(s) could not be read"):
|
| 73 |
+
for name, err in failures:
|
| 74 |
+
st.write(f"- **{name}** β {err}")
|
| 75 |
+
|
| 76 |
+
st.subheader("βοΈ Skills word cloud")
|
| 77 |
+
fig = viz.wordcloud_figure(dict(counters["SKILL"]))
|
| 78 |
+
if fig is not None:
|
| 79 |
+
st.pyplot(fig)
|
| 80 |
+
elif counters["SKILL"]:
|
| 81 |
+
st.info("Install `wordcloud` + `matplotlib` to see the cloud. Showing top skills below instead.")
|
| 82 |
+
else:
|
| 83 |
+
st.caption("No skills extracted.")
|
| 84 |
+
|
| 85 |
+
st.subheader("π Most common entities")
|
| 86 |
+
cols = st.columns(3)
|
| 87 |
+
for col, etype in zip(cols, config.ENTITY_TYPES):
|
| 88 |
+
with col:
|
| 89 |
+
bar = viz.top_bar_figure(counters[etype], config.ENTITY_LABELS[etype],
|
| 90 |
+
config.ENTITY_COLORS[etype])
|
| 91 |
+
if bar is not None:
|
| 92 |
+
st.plotly_chart(bar, use_container_width=True)
|
| 93 |
+
else:
|
| 94 |
+
top = counters[etype].most_common(15)
|
| 95 |
+
st.write(f"**{config.ENTITY_LABELS[etype]}**")
|
| 96 |
+
st.table(pd.DataFrame(top, columns=["entity", "count"]) if top
|
| 97 |
+
else pd.DataFrame(columns=["entity", "count"]))
|
| 98 |
+
|
| 99 |
+
st.subheader("π Per-file breakdown")
|
| 100 |
+
df = pd.DataFrame(rows)
|
| 101 |
+
st.dataframe(df, use_container_width=True)
|
| 102 |
+
|
| 103 |
+
# Full long-form export of every (file-agnostic) entity count
|
| 104 |
+
export = pd.concat([
|
| 105 |
+
pd.DataFrame({"type": config.ENTITY_LABELS[t],
|
| 106 |
+
"entity": list(counters[t].keys()),
|
| 107 |
+
"count": list(counters[t].values())})
|
| 108 |
+
for t in config.ENTITY_TYPES
|
| 109 |
+
], ignore_index=True)
|
| 110 |
+
st.download_button("β¬οΈ Download entity counts (CSV)",
|
| 111 |
+
data=export.to_csv(index=False),
|
| 112 |
+
file_name="cv_entity_counts.csv", mime="text/csv")
|
pages/3_Manage_Model.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manage Model β password-gated page for teammates to update the live model.
|
| 2 |
+
|
| 3 |
+
Upload an exported model (a .zip of the folder produced by the training
|
| 4 |
+
notebooks, i.e. config.json + model.safetensors + tokenizer files). It is
|
| 5 |
+
validated against the project's 7-label scheme and pushed to the Hugging Face
|
| 6 |
+
Hub repo the app loads from (config.PRIMARY_MODEL_ID). The Hub repo is the
|
| 7 |
+
durable store β a Space's own disk is wiped on restart, so we never rely on it.
|
| 8 |
+
|
| 9 |
+
Secrets (set in the Space settings, never in git):
|
| 10 |
+
HF_TOKEN a Hugging Face *write* token
|
| 11 |
+
MANAGE_PASSWORD password protecting this page
|
| 12 |
+
"""
|
| 13 |
+
import io
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import tempfile
|
| 17 |
+
import zipfile
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import streamlit as st
|
| 21 |
+
|
| 22 |
+
import config
|
| 23 |
+
|
| 24 |
+
st.set_page_config(page_title="Manage Model", page_icon="π", layout="centered")
|
| 25 |
+
st.title("π Manage Model")
|
| 26 |
+
st.caption("Update the live NER model. Restricted to the team.")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _secret(name, default=""):
|
| 30 |
+
# st.secrets raises if no secrets file exists; fall back to env.
|
| 31 |
+
try:
|
| 32 |
+
if name in st.secrets:
|
| 33 |
+
return st.secrets[name]
|
| 34 |
+
except Exception: # noqa: BLE001
|
| 35 |
+
pass
|
| 36 |
+
return os.environ.get(name, default)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---- Password gate ----------------------------------------------------------
|
| 40 |
+
PASSWORD = _secret("MANAGE_PASSWORD")
|
| 41 |
+
if not PASSWORD:
|
| 42 |
+
st.error("This page is disabled: no `MANAGE_PASSWORD` secret is configured.")
|
| 43 |
+
st.stop()
|
| 44 |
+
|
| 45 |
+
if not st.session_state.get("manage_authed"):
|
| 46 |
+
pw = st.text_input("Password", type="password")
|
| 47 |
+
if st.button("Unlock"):
|
| 48 |
+
if pw == PASSWORD:
|
| 49 |
+
st.session_state["manage_authed"] = True
|
| 50 |
+
st.rerun()
|
| 51 |
+
else:
|
| 52 |
+
st.error("Wrong password.")
|
| 53 |
+
st.stop()
|
| 54 |
+
|
| 55 |
+
# ---- Authed --------------------------------------------------------------
|
| 56 |
+
TARGET_REPO = st.text_input("Target Hub model repo", value=config.PRIMARY_MODEL_ID,
|
| 57 |
+
help="The repo the app loads. Overwriting it updates the live model.")
|
| 58 |
+
private = st.checkbox("Keep repo private", value=True)
|
| 59 |
+
|
| 60 |
+
st.markdown(
|
| 61 |
+
"Upload a **.zip of your exported model folder** "
|
| 62 |
+
"(`config.json`, `model.safetensors`, tokenizer files, ideally `label_config.json`). "
|
| 63 |
+
"This is the folder the training notebooks write to `exported_models/β¦`."
|
| 64 |
+
)
|
| 65 |
+
up = st.file_uploader("Model .zip", type=["zip"])
|
| 66 |
+
|
| 67 |
+
REQUIRED = ["config.json"]
|
| 68 |
+
WEIGHTS = ["model.safetensors", "pytorch_model.bin"]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _find_model_dir(root: Path):
|
| 72 |
+
"""Locate the directory holding config.json (zip may have a wrapper folder)."""
|
| 73 |
+
for cfg in root.rglob("config.json"):
|
| 74 |
+
return cfg.parent
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _validate(model_dir: Path):
|
| 79 |
+
files = {p.name for p in model_dir.iterdir()}
|
| 80 |
+
if not any(w in files for w in WEIGHTS):
|
| 81 |
+
return f"No weights file found ({' or '.join(WEIGHTS)})."
|
| 82 |
+
# Label-scheme check: prefer label_config.json, else config.json id2label.
|
| 83 |
+
labels = None
|
| 84 |
+
if (model_dir / "label_config.json").exists():
|
| 85 |
+
labels = set(json.loads((model_dir / "label_config.json").read_text())
|
| 86 |
+
.get("id2label", {}).values())
|
| 87 |
+
else:
|
| 88 |
+
cfg = json.loads((model_dir / "config.json").read_text())
|
| 89 |
+
labels = set(cfg.get("id2label", {}).values())
|
| 90 |
+
if "B-SKILL" not in labels:
|
| 91 |
+
return ("Label scheme mismatch β model does not use the project's BIO tags "
|
| 92 |
+
f"(expected B-SKILL/JOB_TITLE/EDUCATION, got: {sorted(labels) or 'none'}).")
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if up is not None and st.button("Validate & publish", type="primary"):
|
| 97 |
+
token = _secret("HF_TOKEN")
|
| 98 |
+
if not token:
|
| 99 |
+
st.error("No `HF_TOKEN` secret configured β cannot push to the Hub.")
|
| 100 |
+
st.stop()
|
| 101 |
+
try:
|
| 102 |
+
from huggingface_hub import HfApi, create_repo
|
| 103 |
+
except ModuleNotFoundError:
|
| 104 |
+
st.error("`huggingface_hub` is not installed (add it to requirements.txt).")
|
| 105 |
+
st.stop()
|
| 106 |
+
|
| 107 |
+
with tempfile.TemporaryDirectory() as td:
|
| 108 |
+
tdp = Path(td)
|
| 109 |
+
try:
|
| 110 |
+
with zipfile.ZipFile(io.BytesIO(up.getvalue())) as zf:
|
| 111 |
+
zf.extractall(tdp)
|
| 112 |
+
except zipfile.BadZipFile:
|
| 113 |
+
st.error("That file is not a valid .zip.")
|
| 114 |
+
st.stop()
|
| 115 |
+
|
| 116 |
+
model_dir = _find_model_dir(tdp)
|
| 117 |
+
if model_dir is None:
|
| 118 |
+
st.error("No `config.json` found anywhere in the zip.")
|
| 119 |
+
st.stop()
|
| 120 |
+
|
| 121 |
+
err = _validate(model_dir)
|
| 122 |
+
if err:
|
| 123 |
+
st.error(err)
|
| 124 |
+
st.stop()
|
| 125 |
+
|
| 126 |
+
st.success(f"Validated: {sorted(p.name for p in model_dir.iterdir())}")
|
| 127 |
+
with st.spinner(f"Publishing to {TARGET_REPO}β¦"):
|
| 128 |
+
create_repo(TARGET_REPO, repo_type="model", private=private,
|
| 129 |
+
exist_ok=True, token=token)
|
| 130 |
+
HfApi(token=token).upload_folder(
|
| 131 |
+
folder_path=str(model_dir), repo_id=TARGET_REPO, repo_type="model",
|
| 132 |
+
commit_message="Update model via Manage Model page",
|
| 133 |
+
)
|
| 134 |
+
st.success(f"β
Published to https://huggingface.co/{TARGET_REPO}")
|
| 135 |
+
st.info("Go to any page and click **π Reload model** in the sidebar to use it.")
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.32
|
| 2 |
+
transformers>=4.38
|
| 3 |
+
torch>=2.0
|
| 4 |
+
huggingface_hub>=0.23
|
| 5 |
+
pdfplumber>=0.10
|
| 6 |
+
python-docx>=1.1
|
| 7 |
+
wordcloud>=1.9
|
| 8 |
+
matplotlib>=3.7
|
| 9 |
+
plotly>=5.18
|
| 10 |
+
pandas>=2.0
|