Automated-CV-Parser / lib /review.py
Zeqh's picture
Add Review & Correct page: human-in-the-loop entity editing
57f6176
Raw
History Blame Contribute Delete
3.49 kB
"""Human-in-the-loop review logic for the Live Parser output.
Pure functions over the entity list (no Streamlit imports) so the corrected
highlighted text, the structured summary, the JSON export and the override
stats all derive from one editable source of truth.
A "work" item mirrors a model entity plus review bookkeeping:
{"_id", "type", "start", "end", "text", "conf",
"origin": "model"|"added", "orig_type": <type or None>}
"""
from __future__ import annotations
import re
def seed_review(entities):
"""Copy model entities into an editable working list with stable ids."""
work = []
for i, e in enumerate(entities):
work.append({
"_id": i,
"type": e["type"],
"start": e["start"],
"end": e["end"],
"text": e["text"],
"conf": e.get("conf", 1.0),
"origin": "model",
"orig_type": e["type"], # frozen original label, for the relabel diff
})
return work
def _next_id(work):
return max((e["_id"] for e in work), default=-1) + 1
def relabel(work, _id, new_type):
"""Change the label of the entity with id ``_id`` (in place)."""
for e in work:
if e["_id"] == _id:
e["type"] = new_type
return work
def delete(work, _id):
"""Remove the entity with id ``_id``."""
return [e for e in work if e["_id"] != _id]
def add_entity(work, phrase, full_text, etype, hint=0):
"""Tag ``phrase`` as a new entity, locating it in ``full_text``.
Returns (work, error). ``error`` is None on success. The phrase is located
near ``hint`` (the clicked char offset) first, then anywhere, so repeated
words resolve to the one the reviewer clicked.
"""
phrase = (phrase or "").strip()
if not phrase:
return work, "Nothing to add — the selection was empty."
idx = full_text.find(phrase, max(0, hint - len(phrase)))
if idx < 0:
idx = full_text.find(phrase)
if idx < 0:
return work, f"Couldn't find “{phrase}” in the CV text."
start, end = idx, idx + len(phrase)
for e in work:
if e["start"] == start and e["end"] == end:
return work, "That exact span is already tagged."
work = work + [{
"_id": _next_id(work),
"type": etype, "start": start, "end": end, "text": phrase,
"conf": 1.0, "origin": "added", "orig_type": None,
}]
return work, None
def diff_stats(original, corrected):
"""Compare model output (``original``) with human-corrected output.
``original`` is the seeded snapshot; ``corrected`` is the edited work list.
"""
corr_spans = {(e["start"], e["end"]) for e in corrected}
relabeled, added = [], []
for e in corrected:
if e.get("origin") == "added":
added.append(e)
elif e.get("orig_type") and e["type"] != e["orig_type"]:
relabeled.append(e)
deleted = [e for e in original if (e["start"], e["end"]) not in corr_spans]
n_model = len(original)
n_touched = len(relabeled) + len(deleted) # model entities the human changed
return {
"n_model": n_model,
"n_corrected": len(corrected),
"relabeled": relabeled,
"deleted": deleted,
"added": added,
"n_relabeled": len(relabeled),
"n_deleted": len(deleted),
"n_added": len(added),
"n_touched": n_touched,
"override_rate": (n_touched / n_model) if n_model else 0.0,
}