Zeqh Claude Opus 4.8 commited on
Commit
57f6176
Β·
1 Parent(s): b091c09

Add Review & Correct page: human-in-the-loop entity editing

Browse files

Click 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>

Files changed (5) hide show
  1. app.py +3 -0
  2. lib/review.py +106 -0
  3. lib/viz.py +62 -0
  4. pages/1_Live_Parser.py +4 -0
  5. 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()