Spaces:
Sleeping
Sleeping
Add Review & Correct page: human-in-the-loop entity editing
Browse filesClick a predicted entity to relabel/delete it, or click any word to tag a
missed entity, then save a corrected version with human-override stats.
Edit state is keyed by a URL id in a process-global store so it survives the
full reload that an in-text <a> click triggers in Streamlit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- app.py +3 -0
- lib/review.py +106 -0
- lib/viz.py +62 -0
- pages/1_Live_Parser.py +4 -0
- pages/4_Review_&_Correct.py +233 -0
app.py
CHANGED
|
@@ -37,6 +37,9 @@ st.markdown(
|
|
| 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 |
|
|
|
|
| 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 |
+
- **βοΈ Review & Correct** β *human in the loop*: click the model's predictions to
|
| 41 |
+
**relabel or delete** them, click any word to **tag a missed entity**, then save a
|
| 42 |
+
corrected version with stats on how much the human overrode the model.
|
| 43 |
- **π Analytics** β upload a batch of CVs (PDF / DOCX / TXT) and the page builds a
|
| 44 |
**skills word cloud** plus top Job Titles / Skills / Education charts across the set.
|
| 45 |
|
lib/review.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Human-in-the-loop review logic for the Live Parser output.
|
| 2 |
+
|
| 3 |
+
Pure functions over the entity list (no Streamlit imports) so the corrected
|
| 4 |
+
highlighted text, the structured summary, the JSON export and the override
|
| 5 |
+
stats all derive from one editable source of truth.
|
| 6 |
+
|
| 7 |
+
A "work" item mirrors a model entity plus review bookkeeping:
|
| 8 |
+
{"_id", "type", "start", "end", "text", "conf",
|
| 9 |
+
"origin": "model"|"added", "orig_type": <type or None>}
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def seed_review(entities):
|
| 17 |
+
"""Copy model entities into an editable working list with stable ids."""
|
| 18 |
+
work = []
|
| 19 |
+
for i, e in enumerate(entities):
|
| 20 |
+
work.append({
|
| 21 |
+
"_id": i,
|
| 22 |
+
"type": e["type"],
|
| 23 |
+
"start": e["start"],
|
| 24 |
+
"end": e["end"],
|
| 25 |
+
"text": e["text"],
|
| 26 |
+
"conf": e.get("conf", 1.0),
|
| 27 |
+
"origin": "model",
|
| 28 |
+
"orig_type": e["type"], # frozen original label, for the relabel diff
|
| 29 |
+
})
|
| 30 |
+
return work
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _next_id(work):
|
| 34 |
+
return max((e["_id"] for e in work), default=-1) + 1
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def relabel(work, _id, new_type):
|
| 38 |
+
"""Change the label of the entity with id ``_id`` (in place)."""
|
| 39 |
+
for e in work:
|
| 40 |
+
if e["_id"] == _id:
|
| 41 |
+
e["type"] = new_type
|
| 42 |
+
return work
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def delete(work, _id):
|
| 46 |
+
"""Remove the entity with id ``_id``."""
|
| 47 |
+
return [e for e in work if e["_id"] != _id]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def add_entity(work, phrase, full_text, etype, hint=0):
|
| 51 |
+
"""Tag ``phrase`` as a new entity, locating it in ``full_text``.
|
| 52 |
+
|
| 53 |
+
Returns (work, error). ``error`` is None on success. The phrase is located
|
| 54 |
+
near ``hint`` (the clicked char offset) first, then anywhere, so repeated
|
| 55 |
+
words resolve to the one the reviewer clicked.
|
| 56 |
+
"""
|
| 57 |
+
phrase = (phrase or "").strip()
|
| 58 |
+
if not phrase:
|
| 59 |
+
return work, "Nothing to add β the selection was empty."
|
| 60 |
+
idx = full_text.find(phrase, max(0, hint - len(phrase)))
|
| 61 |
+
if idx < 0:
|
| 62 |
+
idx = full_text.find(phrase)
|
| 63 |
+
if idx < 0:
|
| 64 |
+
return work, f"Couldn't find β{phrase}β in the CV text."
|
| 65 |
+
start, end = idx, idx + len(phrase)
|
| 66 |
+
for e in work:
|
| 67 |
+
if e["start"] == start and e["end"] == end:
|
| 68 |
+
return work, "That exact span is already tagged."
|
| 69 |
+
work = work + [{
|
| 70 |
+
"_id": _next_id(work),
|
| 71 |
+
"type": etype, "start": start, "end": end, "text": phrase,
|
| 72 |
+
"conf": 1.0, "origin": "added", "orig_type": None,
|
| 73 |
+
}]
|
| 74 |
+
return work, None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def diff_stats(original, corrected):
|
| 78 |
+
"""Compare model output (``original``) with human-corrected output.
|
| 79 |
+
|
| 80 |
+
``original`` is the seeded snapshot; ``corrected`` is the edited work list.
|
| 81 |
+
"""
|
| 82 |
+
corr_spans = {(e["start"], e["end"]) for e in corrected}
|
| 83 |
+
|
| 84 |
+
relabeled, added = [], []
|
| 85 |
+
for e in corrected:
|
| 86 |
+
if e.get("origin") == "added":
|
| 87 |
+
added.append(e)
|
| 88 |
+
elif e.get("orig_type") and e["type"] != e["orig_type"]:
|
| 89 |
+
relabeled.append(e)
|
| 90 |
+
|
| 91 |
+
deleted = [e for e in original if (e["start"], e["end"]) not in corr_spans]
|
| 92 |
+
|
| 93 |
+
n_model = len(original)
|
| 94 |
+
n_touched = len(relabeled) + len(deleted) # model entities the human changed
|
| 95 |
+
return {
|
| 96 |
+
"n_model": n_model,
|
| 97 |
+
"n_corrected": len(corrected),
|
| 98 |
+
"relabeled": relabeled,
|
| 99 |
+
"deleted": deleted,
|
| 100 |
+
"added": added,
|
| 101 |
+
"n_relabeled": len(relabeled),
|
| 102 |
+
"n_deleted": len(deleted),
|
| 103 |
+
"n_added": len(added),
|
| 104 |
+
"n_touched": n_touched,
|
| 105 |
+
"override_rate": (n_touched / n_model) if n_model else 0.0,
|
| 106 |
+
}
|
lib/viz.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import html
|
|
|
|
| 5 |
|
| 6 |
import config
|
| 7 |
|
|
@@ -101,6 +102,67 @@ def render_entities_html(text: str, entities: list[dict],
|
|
| 101 |
f'max-height:520px;overflow:auto">{body}</div>')
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def render_tokens_html(tokens: list[dict], limit: int = 400,
|
| 105 |
shade_by_conf: bool = False) -> str:
|
| 106 |
"""Sub-word token chips, coloured by predicted label β the 'tokenization view'.
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import html
|
| 5 |
+
import re
|
| 6 |
|
| 7 |
import config
|
| 8 |
|
|
|
|
| 102 |
f'max-height:520px;overflow:auto">{body}</div>')
|
| 103 |
|
| 104 |
|
| 105 |
+
def _clickable_gap(segment: str, base: int, rid: str) -> str:
|
| 106 |
+
"""Render non-entity text where every word links to its 'add' selection.
|
| 107 |
+
|
| 108 |
+
``base`` is the char offset of ``segment`` within the full CV text, so each
|
| 109 |
+
word's link carries its absolute ``start``/``end`` for the add-entity panel.
|
| 110 |
+
Links carry ``rid`` because a click is a full reload β the review state is
|
| 111 |
+
recovered from the process store via this id, not from session_state.
|
| 112 |
+
"""
|
| 113 |
+
parts = []
|
| 114 |
+
for m in re.finditer(r"\S+|\s+", segment):
|
| 115 |
+
chunk = m.group(0)
|
| 116 |
+
if chunk.isspace():
|
| 117 |
+
parts.append(html.escape(chunk).replace("\n", "<br>"))
|
| 118 |
+
continue
|
| 119 |
+
s, e = base + m.start(), base + m.end()
|
| 120 |
+
parts.append(
|
| 121 |
+
f'<a href="?rid={rid}&sel=add-{s}-{e}" target="_self" '
|
| 122 |
+
f'style="color:inherit;text-decoration:none;border-bottom:1px dotted #c9c9c9;'
|
| 123 |
+
f'cursor:pointer" title="Tag β{html.escape(chunk)}β as a missed entity">'
|
| 124 |
+
f'{html.escape(chunk)}</a>'
|
| 125 |
+
)
|
| 126 |
+
return "".join(parts)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def render_review_html(text: str, entities: list[dict], rid: str) -> str:
|
| 130 |
+
"""Editable variant of the highlighted text for the review page.
|
| 131 |
+
|
| 132 |
+
Entities link to ``?rid=β¦&sel=e-<id>`` (relabel/delete); every other word
|
| 133 |
+
links to ``?rid=β¦&sel=add-<start>-<end>`` (tag a missed entity). A click is a
|
| 134 |
+
full page reload, so the page restores state from the process store by ``rid``.
|
| 135 |
+
"""
|
| 136 |
+
ents = sorted((e for e in entities if e["type"] in config.ENTITY_COLORS),
|
| 137 |
+
key=lambda e: e["start"])
|
| 138 |
+
out, cursor = [], 0
|
| 139 |
+
for e in ents:
|
| 140 |
+
if e["start"] < cursor: # skip any overlap defensively
|
| 141 |
+
continue
|
| 142 |
+
out.append(_clickable_gap(text[cursor:e["start"]], cursor, rid))
|
| 143 |
+
color = config.ENTITY_COLORS[e["type"]]
|
| 144 |
+
label = config.ENTITY_LABELS[e["type"]]
|
| 145 |
+
ring = "box-shadow:0 0 0 2px #fff,0 0 0 3px #555;" if e.get("origin") == "added" else ""
|
| 146 |
+
out.append(
|
| 147 |
+
f'<a href="?rid={rid}&sel=e-{e["_id"]}" target="_self" '
|
| 148 |
+
f'style="background:{color};color:#fff;padding:1px 4px;border-radius:4px;'
|
| 149 |
+
f'text-decoration:none;cursor:pointer;{ring}" '
|
| 150 |
+
f'title="Click to relabel or delete">'
|
| 151 |
+
f'{html.escape(text[e["start"]:e["end"]])}'
|
| 152 |
+
f'<sub style="font-size:0.6em;opacity:.85"> {label}</sub></a>'
|
| 153 |
+
)
|
| 154 |
+
cursor = e["end"]
|
| 155 |
+
out.append(_clickable_gap(text[cursor:], cursor, rid))
|
| 156 |
+
body = "".join(out)
|
| 157 |
+
hint = ('<div style="margin-bottom:8px;font-size:0.8rem;color:#666">'
|
| 158 |
+
'π Click a coloured entity to <b>relabel</b> or <b>delete</b> it, '
|
| 159 |
+
'or click any plain word to <b>tag a missed entity</b>.</div>')
|
| 160 |
+
return (_legend() + hint +
|
| 161 |
+
f'<div style="line-height:2.3;font-family:system-ui;font-size:0.95rem;'
|
| 162 |
+
f'border:1px solid #ddd;border-radius:8px;padding:16px;'
|
| 163 |
+
f'max-height:520px;overflow:auto">{body}</div>')
|
| 164 |
+
|
| 165 |
+
|
| 166 |
def render_tokens_html(tokens: list[dict], limit: int = 400,
|
| 167 |
shade_by_conf: bool = False) -> str:
|
| 168 |
"""Sub-word token chips, coloured by predicted label β the 'tokenization view'.
|
pages/1_Live_Parser.py
CHANGED
|
@@ -51,6 +51,10 @@ if parse:
|
|
| 51 |
c3.metric("Skills", len(grouped["SKILL"]))
|
| 52 |
c4.metric("Education", len(grouped["EDUCATION"]))
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
shade = st.toggle(
|
| 55 |
"π‘οΈ Shade by confidence",
|
| 56 |
value=False,
|
|
|
|
| 51 |
c3.metric("Skills", len(grouped["SKILL"]))
|
| 52 |
c4.metric("Education", len(grouped["EDUCATION"]))
|
| 53 |
|
| 54 |
+
st.page_link("pages/4_Review_&_Correct.py",
|
| 55 |
+
label="βοΈ Review & correct these predictions (human in the loop)",
|
| 56 |
+
icon="β‘οΈ")
|
| 57 |
+
|
| 58 |
shade = st.toggle(
|
| 59 |
"π‘οΈ Shade by confidence",
|
| 60 |
value=False,
|
pages/4_Review_&_Correct.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Review & Correct β human-in-the-loop editing of the model's predictions.
|
| 2 |
+
|
| 3 |
+
Takes the most recent Live Parser output (st.session_state["parse"]) and lets a
|
| 4 |
+
reviewer click entities to relabel/delete them and click plain words to tag
|
| 5 |
+
missed entities. Saving snapshots the corrected output and shows a side-by-side
|
| 6 |
+
of what the human overrode, with stats.
|
| 7 |
+
|
| 8 |
+
State note: a click on a highlighted span is a plain ``<a href>``, which is a
|
| 9 |
+
*full page reload* in Streamlit and wipes st.session_state. So the editable
|
| 10 |
+
state lives in a process-global store keyed by a short id (``rid``) carried in
|
| 11 |
+
the URL, which survives the reload. Nothing is written to disk.
|
| 12 |
+
"""
|
| 13 |
+
import json
|
| 14 |
+
import uuid
|
| 15 |
+
|
| 16 |
+
import streamlit as st
|
| 17 |
+
|
| 18 |
+
import config
|
| 19 |
+
from lib import review, viz
|
| 20 |
+
from lib.model import group_entities
|
| 21 |
+
from lib.ui import model_selector
|
| 22 |
+
|
| 23 |
+
st.set_page_config(page_title="Review & Correct", page_icon="βοΈ", layout="wide")
|
| 24 |
+
|
| 25 |
+
model_selector() # keep the shared sidebar picker consistent across pages
|
| 26 |
+
|
| 27 |
+
st.title("βοΈ Review & Correct")
|
| 28 |
+
st.caption("Human in the loop β fix the model's predictions, then save a corrected version.")
|
| 29 |
+
|
| 30 |
+
ENTITY_TYPES = config.ENTITY_TYPES
|
| 31 |
+
TYPE_LABELS = [config.ENTITY_LABELS[t] for t in ENTITY_TYPES]
|
| 32 |
+
_LABEL2TYPE = {config.ENTITY_LABELS[t]: t for t in ENTITY_TYPES}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@st.cache_resource
|
| 36 |
+
def _review_store():
|
| 37 |
+
"""Process-global {rid: state} dict; survives the per-click full reload.
|
| 38 |
+
|
| 39 |
+
Distinct rids keep concurrent sessions/users isolated within the process.
|
| 40 |
+
"""
|
| 41 |
+
return {}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _clear_selection():
|
| 45 |
+
# Drop only 'sel' β keep 'rid' so state is still recoverable after reload.
|
| 46 |
+
if "sel" in st.query_params:
|
| 47 |
+
del st.query_params["sel"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
store = _review_store()
|
| 51 |
+
parse = st.session_state.get("parse")
|
| 52 |
+
rid = st.query_params.get("rid")
|
| 53 |
+
state = store.get(rid) if rid else None
|
| 54 |
+
|
| 55 |
+
# (Re)seed when arriving from the Live Parser with a new/different CV. On a
|
| 56 |
+
# post-click reload there's no session parse, so we keep the stored state.
|
| 57 |
+
if parse is not None and (state is None or state["text"] != parse["text"]):
|
| 58 |
+
rid = uuid.uuid4().hex[:8]
|
| 59 |
+
state = {
|
| 60 |
+
"text": parse["text"],
|
| 61 |
+
"original": review.seed_review(parse["entities"]),
|
| 62 |
+
"work": review.seed_review(parse["entities"]),
|
| 63 |
+
"phase": "edit",
|
| 64 |
+
}
|
| 65 |
+
store[rid] = state
|
| 66 |
+
st.query_params["rid"] = rid
|
| 67 |
+
|
| 68 |
+
if state is None:
|
| 69 |
+
st.info("No parsed CV yet. Open **π Live CV Parser**, parse a CV, then come back here.",
|
| 70 |
+
icon="βΉοΈ")
|
| 71 |
+
st.stop()
|
| 72 |
+
|
| 73 |
+
text = state["text"]
|
| 74 |
+
work = state["work"]
|
| 75 |
+
original = state["original"]
|
| 76 |
+
phase = state["phase"]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ===========================================================================
|
| 80 |
+
# SAVED PHASE β corrected output + human-override stats
|
| 81 |
+
# ===========================================================================
|
| 82 |
+
if phase == "saved":
|
| 83 |
+
stats = review.diff_stats(original, work)
|
| 84 |
+
|
| 85 |
+
if st.button("βοΈ Back to editing"):
|
| 86 |
+
state["phase"] = "edit"
|
| 87 |
+
st.rerun()
|
| 88 |
+
|
| 89 |
+
st.subheader("π Human override")
|
| 90 |
+
m = st.columns(5)
|
| 91 |
+
m[0].metric("Model entities", stats["n_model"])
|
| 92 |
+
m[1].metric("Relabeled", stats["n_relabeled"])
|
| 93 |
+
m[2].metric("Deleted", stats["n_deleted"])
|
| 94 |
+
m[3].metric("Added", stats["n_added"])
|
| 95 |
+
m[4].metric("Override rate", f"{stats['override_rate']:.0%}",
|
| 96 |
+
help="Share of the model's entities the human relabeled or deleted.")
|
| 97 |
+
|
| 98 |
+
grouped = group_entities(work)
|
| 99 |
+
st.subheader("β
Corrected output")
|
| 100 |
+
st.markdown(viz.render_entities_html(text, work, shade_by_conf=False),
|
| 101 |
+
unsafe_allow_html=True)
|
| 102 |
+
|
| 103 |
+
cols = st.columns(3)
|
| 104 |
+
for col, etype in zip(cols, ENTITY_TYPES):
|
| 105 |
+
with col:
|
| 106 |
+
st.markdown(f"**{config.ENTITY_LABELS[etype]}**")
|
| 107 |
+
vals = grouped[etype]
|
| 108 |
+
if vals:
|
| 109 |
+
for v in vals:
|
| 110 |
+
st.markdown(f"- {v}")
|
| 111 |
+
else:
|
| 112 |
+
st.caption("β none β")
|
| 113 |
+
|
| 114 |
+
st.download_button(
|
| 115 |
+
"β¬οΈ Download corrected JSON",
|
| 116 |
+
data=json.dumps({config.ENTITY_LABELS[t]: grouped[t] for t in ENTITY_TYPES},
|
| 117 |
+
indent=2),
|
| 118 |
+
file_name="parsed_cv_corrected.json",
|
| 119 |
+
mime="application/json",
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
with st.expander("π What the human changed", expanded=True):
|
| 123 |
+
if stats["relabeled"]:
|
| 124 |
+
st.markdown("**Relabeled**")
|
| 125 |
+
st.table([
|
| 126 |
+
{"text": e["text"],
|
| 127 |
+
"model said": config.ENTITY_LABELS[e["orig_type"]],
|
| 128 |
+
"human says": config.ENTITY_LABELS[e["type"]]}
|
| 129 |
+
for e in stats["relabeled"]
|
| 130 |
+
])
|
| 131 |
+
if stats["deleted"]:
|
| 132 |
+
st.markdown("**Deleted (false positives)**")
|
| 133 |
+
st.table([{"text": e["text"], "model label": config.ENTITY_LABELS[e["type"]]}
|
| 134 |
+
for e in stats["deleted"]])
|
| 135 |
+
if stats["added"]:
|
| 136 |
+
st.markdown("**Added (missed by the model)**")
|
| 137 |
+
st.table([{"text": e["text"], "human label": config.ENTITY_LABELS[e["type"]]}
|
| 138 |
+
for e in stats["added"]])
|
| 139 |
+
if not (stats["relabeled"] or stats["deleted"] or stats["added"]):
|
| 140 |
+
st.caption("No changes β the human accepted the model's output as-is.")
|
| 141 |
+
st.stop()
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ===========================================================================
|
| 145 |
+
# EDIT PHASE β click entities/words to correct them
|
| 146 |
+
# ===========================================================================
|
| 147 |
+
sel = st.query_params.get("sel")
|
| 148 |
+
|
| 149 |
+
# ---- Selection editor panel (driven by the ?sel= query param) --------------
|
| 150 |
+
if sel and sel.startswith("e-"):
|
| 151 |
+
try:
|
| 152 |
+
sid = int(sel[2:])
|
| 153 |
+
except ValueError:
|
| 154 |
+
sid = None
|
| 155 |
+
ent = next((e for e in work if e["_id"] == sid), None)
|
| 156 |
+
if ent is None:
|
| 157 |
+
st.info("That entity was removed. Click another to edit.")
|
| 158 |
+
else:
|
| 159 |
+
with st.container(border=True):
|
| 160 |
+
st.markdown(f"**Editing:** β{ent['text']}β "
|
| 161 |
+
f"Β· model said *{config.ENTITY_LABELS[ent['orig_type']]}*"
|
| 162 |
+
if ent.get("orig_type")
|
| 163 |
+
else f"**Editing:** β{ent['text']}β Β· *(human-added)*")
|
| 164 |
+
c1, c2, c3 = st.columns([2, 1, 1])
|
| 165 |
+
with c1:
|
| 166 |
+
new_label = st.selectbox(
|
| 167 |
+
"Label", TYPE_LABELS,
|
| 168 |
+
index=ENTITY_TYPES.index(ent["type"]), key=f"relabel_{sid}")
|
| 169 |
+
with c2:
|
| 170 |
+
st.write("")
|
| 171 |
+
if st.button("β
Apply", use_container_width=True, key=f"apply_{sid}"):
|
| 172 |
+
review.relabel(state["work"], sid, _LABEL2TYPE[new_label])
|
| 173 |
+
_clear_selection()
|
| 174 |
+
st.rerun()
|
| 175 |
+
with c3:
|
| 176 |
+
st.write("")
|
| 177 |
+
if st.button("ποΈ Delete", use_container_width=True, key=f"del_{sid}"):
|
| 178 |
+
state["work"] = review.delete(state["work"], sid)
|
| 179 |
+
_clear_selection()
|
| 180 |
+
st.rerun()
|
| 181 |
+
|
| 182 |
+
elif sel and sel.startswith("add-"):
|
| 183 |
+
try:
|
| 184 |
+
_, s, e = sel.split("-")
|
| 185 |
+
s, e = int(s), int(e)
|
| 186 |
+
except ValueError:
|
| 187 |
+
s = e = None
|
| 188 |
+
if s is None:
|
| 189 |
+
st.info("Couldn't read that selection. Click a word to try again.")
|
| 190 |
+
else:
|
| 191 |
+
with st.container(border=True):
|
| 192 |
+
st.markdown("**Tag a missed entity** β edit the phrase to extend it across words.")
|
| 193 |
+
c1, c2, c3 = st.columns([2, 1, 1])
|
| 194 |
+
with c1:
|
| 195 |
+
phrase = st.text_input("Phrase", value=text[s:e], key=f"add_phrase_{s}")
|
| 196 |
+
with c2:
|
| 197 |
+
add_label = st.selectbox("Label", TYPE_LABELS, key=f"add_label_{s}")
|
| 198 |
+
with c3:
|
| 199 |
+
st.write("")
|
| 200 |
+
if st.button("β Add", use_container_width=True, key=f"add_{s}"):
|
| 201 |
+
new_work, err = review.add_entity(
|
| 202 |
+
state["work"], phrase, text, _LABEL2TYPE[add_label], hint=s)
|
| 203 |
+
if err:
|
| 204 |
+
st.error(err)
|
| 205 |
+
else:
|
| 206 |
+
state["work"] = new_work
|
| 207 |
+
_clear_selection()
|
| 208 |
+
st.rerun()
|
| 209 |
+
|
| 210 |
+
# ---- Live counts -----------------------------------------------------------
|
| 211 |
+
grouped = group_entities(work)
|
| 212 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 213 |
+
c1.metric("Entities", len(work))
|
| 214 |
+
c2.metric("Job Titles", len(grouped["JOB_TITLE"]))
|
| 215 |
+
c3.metric("Skills", len(grouped["SKILL"]))
|
| 216 |
+
c4.metric("Education", len(grouped["EDUCATION"]))
|
| 217 |
+
|
| 218 |
+
# ---- The clickable CV ------------------------------------------------------
|
| 219 |
+
st.markdown(viz.render_review_html(text, work, rid), unsafe_allow_html=True)
|
| 220 |
+
|
| 221 |
+
# ---- Actions ---------------------------------------------------------------
|
| 222 |
+
a1, a2, _ = st.columns([1, 1, 3])
|
| 223 |
+
with a1:
|
| 224 |
+
if st.button("β
Save corrections", type="primary", use_container_width=True):
|
| 225 |
+
state["phase"] = "saved"
|
| 226 |
+
_clear_selection()
|
| 227 |
+
st.rerun()
|
| 228 |
+
with a2:
|
| 229 |
+
if st.button("β©οΈ Reset to model output", use_container_width=True):
|
| 230 |
+
# Restore from the frozen model snapshot (no session parse after reloads).
|
| 231 |
+
state["work"] = [dict(e) for e in original]
|
| 232 |
+
_clear_selection()
|
| 233 |
+
st.rerun()
|