Zeqhx commited on
Commit
c59578d
Β·
verified Β·
1 Parent(s): af8d788

Deploy CV parser dashboard with dataset 2 model

Browse files
Files changed (14) hide show
  1. .gitignore +16 -0
  2. Dockerfile +17 -0
  3. README.md +66 -4
  4. app.py +65 -0
  5. config.py +109 -0
  6. lib/__init__.py +0 -0
  7. lib/extract.py +86 -0
  8. lib/model.py +198 -0
  9. lib/ui.py +60 -0
  10. lib/viz.py +102 -0
  11. pages/1_Live_Parser.py +80 -0
  12. pages/2_Analytics.py +112 -0
  13. pages/3_Manage_Model.py +135 -0
  14. 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: purple
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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