""" APEX-VW Propagation Demo -- Gradio app ====================================== Interactive demonstration of *correction propagation* for English->Spanish post-editing, built around the APEX-VW corpus workflow. Flow ---- 1. Load the built-in NHS virtual-ward sample, or paste your own English text. 2. The app segments it and machine-translates one paragraph at a time (source / MT / post-edit, mirroring apex_vw_src_mt_pe.csv). 3. You post-edit the current paragraph. The app diffs raw MT against your edit and mines phrase-substitution rules (the edit-memory engine). 4. On every later paragraph, rules that match its MT appear as checkboxes. Tick = accept, untick = reject. Accepted rules are applied on top of the raw MT before you finalise that paragraph. MT backends ----------- - "Demo (offline)" -> no download; canned / rule-based pseudo-MT. - "Helsinki-NLP/opus-mt-en-es" -> open-source MarianMT (recommended). - "facebook/nllb-200-distilled-600M" -> open-source multilingual NLLB. Run: python app.py (local) Deploy: Hugging Face Spaces, SDK = Gradio, app_file = app.py """ from __future__ import annotations import difflib import html import re from dataclasses import dataclass import gradio as gr import spaces # ZeroGPU: enables @spaces.GPU; a no-op off ZeroGPU # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- MODELS = { "Demo (offline, no download)": {"kind": "mock"}, "Helsinki-NLP/opus-mt-en-es": {"kind": "marian", "name": "Helsinki-NLP/opus-mt-en-es"}, "facebook/nllb-200-distilled-600M": { "kind": "nllb", "name": "facebook/nllb-200-distilled-600M", "src": "eng_Latn", "tgt": "spa_Latn", }, } _NO_SPACE_BEFORE = set(".,;:!?%)]}\u00bb") _NO_SPACE_AFTER = set("([{\u00bf\u00a1\u00ab") MAX_RULE_SPAN = 6 # --------------------------------------------------------------------------- # Built-in sample (source + canned "MT" so the offline demo is realistic) # --------------------------------------------------------------------------- SAMPLE_SOURCE = """A virtual ward allows patients to receive hospital-level care in their own home. Each virtual ward is led by a consultant and supported by a multidisciplinary team. Referral to a virtual ward should follow the agreed clinical criteria. Before admission to the virtual ward, the team confirms that remote monitoring is appropriate. Staff on the virtual ward review patient observations at least once a day. If a patient deteriorates, the virtual ward escalates care according to the local pathway. Technology-enabled virtual wards use apps and wearable devices to monitor patients. A technology-enabled virtual ward can safely support a larger number of patients. Discharge from the virtual ward is agreed jointly by the clinical team and the patient. After discharge, the virtual ward shares a summary with the patient's GP.""" SAMPLE_MT = { "A virtual ward allows patients to receive hospital-level care in their own home. Each virtual ward is led by a consultant and supported by a multidisciplinary team.": "Una sala virtual permite a los pacientes recibir atenci\u00f3n de nivel hospitalario en su propio domicilio. Cada sala virtual est\u00e1 dirigida por un m\u00e9dico consultor y cuenta con el apoyo de un equipo multidisciplinar.", "Referral to a virtual ward should follow the agreed clinical criteria. Before admission to the virtual ward, the team confirms that remote monitoring is appropriate.": "La derivaci\u00f3n a una sala virtual debe seguir los criterios cl\u00ednicos acordados. Antes del ingreso en la sala virtual, el equipo confirma que la monitorizaci\u00f3n remota es apropiada.", "Staff on the virtual ward review patient observations at least once a day. If a patient deteriorates, the virtual ward escalates care according to the local pathway.": "El personal de la sala virtual revisa las observaciones del paciente al menos una vez al d\u00eda. Si un paciente se deteriora, la sala virtual escala la atenci\u00f3n seg\u00fan la v\u00eda local.", "Technology-enabled virtual wards use apps and wearable devices to monitor patients. A technology-enabled virtual ward can safely support a larger number of patients.": "Las salas virtuales habilitadas por tecnolog\u00eda utilizan aplicaciones y dispositivos ponibles para monitorizar a los pacientes. Una sala virtual habilitada por tecnolog\u00eda puede atender de forma segura a un mayor n\u00famero de pacientes.", "Discharge from the virtual ward is agreed jointly by the clinical team and the patient. After discharge, the virtual ward shares a summary with the patient's GP.": "El alta de la sala virtual se acuerda de forma conjunta entre el equipo cl\u00ednico y el paciente. Tras el alta, la sala virtual comparte un resumen con el m\u00e9dico de cabecera del paciente.", } MOCK_PHRASES = [ ("virtual wards", "salas virtuales"), ("virtual ward", "sala virtual"), ("hospital-level care", "atenci\u00f3n de nivel hospitalario"), ("multidisciplinary team", "equipo multidisciplinar"), ("remote monitoring", "monitorizaci\u00f3n remota"), ("technology-enabled", "habilitada por tecnolog\u00eda"), ("clinical team", "equipo cl\u00ednico"), ] MOCK_WORDS = { "a": "una", "an": "una", "the": "el", "patients": "pacientes", "patient": "paciente", "care": "atenci\u00f3n", "home": "domicilio", "team": "equipo", "staff": "personal", "referral": "derivaci\u00f3n", "admission": "ingreso", "discharge": "alta", "consultant": "m\u00e9dico consultor", "apps": "aplicaciones", "devices": "dispositivos", "monitor": "monitorizar", "summary": "resumen", "day": "d\u00eda", "pathway": "v\u00eda", "and": "y", "in": "en", "to": "a", "of": "de", "with": "con", "is": "es", } # --------------------------------------------------------------------------- # Pure helpers (tokenisation, rule mining, rule application) # --------------------------------------------------------------------------- def tokenize(s: str) -> list[str]: return re.findall(r"\w+|[^\w\s]", s, re.UNICODE) def detok(tokens: list[str]) -> str: out = "" for i, tok in enumerate(tokens): if i == 0: out = tok continue prev = tokens[i - 1] if tok and tok[0] in _NO_SPACE_BEFORE: out += tok elif prev and prev[-1] in _NO_SPACE_AFTER: out += tok else: out += " " + tok return out def _join_pairs_html(pairs: list[tuple[str, bool]]) -> str: out = "" for i, (tok, hl) in enumerate(pairs): piece = html.escape(tok) if hl: piece = f'{piece}' if i == 0: out = piece continue prev = pairs[i - 1][0] if tok and tok[0] in _NO_SPACE_BEFORE: out += piece elif prev and prev[-1] in _NO_SPACE_AFTER: out += piece else: out += " " + piece return out def mine_rules(mt: str, pe: str, max_span: int = MAX_RULE_SPAN) -> list[tuple[str, str]]: a, b = tokenize(mt), tokenize(pe) sm = difflib.SequenceMatcher(a=a, b=b, autojunk=False) rules: list[tuple[str, str]] = [] for tag, i1, i2, j1, j2 in sm.get_opcodes(): if tag != "replace": continue if (i2 - i1) > max_span or (j2 - j1) > max_span: continue src, tgt = detok(a[i1:i2]), detok(b[j1:j2]) if not src.strip() or not tgt.strip() or src == tgt: continue if re.fullmatch(r"[\W_]+", src): continue rules.append((src, tgt)) return rules def rule_applies(src: str, mt_tokens: list[str]) -> bool: st_toks = tokenize(src) L = len(st_toks) if L == 0: return False return any(mt_tokens[i:i + L] == st_toks for i in range(len(mt_tokens) - L + 1)) def apply_rules(mt: str, accepted: list[tuple[str, str]]): tokens = tokenize(mt) compiled = sorted( ((tokenize(s), tokenize(t), s, t) for s, t in accepted), key=lambda r: len(r[0]), reverse=True, ) out_pairs: list[tuple[str, bool]] = [] applied: dict[tuple[str, str], int] = {} i, n = 0, len(tokens) while i < n: matched = False for src_toks, tgt_toks, s, t in compiled: L = len(src_toks) if L and tokens[i:i + L] == src_toks: out_pairs.extend((tok, True) for tok in tgt_toks) applied[(s, t)] = applied.get((s, t), 0) + 1 i += L matched = True break if not matched: out_pairs.append((tokens[i], False)) i += 1 plain = detok([tok for tok, _ in out_pairs]) html_text = _join_pairs_html(out_pairs) return plain, html_text, [(s, t, c) for (s, t), c in applied.items()] def segment_document(text: str, mode: str = "paragraph") -> list[str]: text = text.strip() if not text: return [] if mode == "paragraph": segs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] if len(segs) > 1: return segs sents = re.split(r"(?<=[.!?])\s+", text.replace("\n", " ")) return [s.strip() for s in sents if s.strip()] def mock_translate(text: str) -> str: if text in SAMPLE_MT: return SAMPLE_MT[text] out = text for en, es in MOCK_PHRASES: out = re.sub(re.escape(en), es, out, flags=re.IGNORECASE) return detok([MOCK_WORDS.get(t.lower(), t) for t in tokenize(out)]) @dataclass(frozen=True) class Rule: src: str tgt: str learned_from: int # --------------------------------------------------------------------------- # MT backends # --------------------------------------------------------------------------- _PIPELINES: dict[str, object] = {} def _get_pipeline(cfg): key = cfg["name"] if key not in _PIPELINES: from transformers import pipeline try: import torch device = 0 if torch.cuda.is_available() else -1 except Exception: device = -1 if cfg["kind"] == "nllb": _PIPELINES[key] = pipeline("translation", model=key, src_lang=cfg["src"], tgt_lang=cfg["tgt"], device=device) else: _PIPELINES[key] = pipeline("translation", model=key, device=device) return _PIPELINES[key] @spaces.GPU(duration=30) def _gpu_translate(model_key: str, sentences: list[str]) -> list[str]: """Real-model inference. On ZeroGPU this runs inside an allocated GPU context (where torch.cuda becomes available); off ZeroGPU it runs on CPU. Its presence is also what satisfies the ZeroGPU startup check.""" pipe = _get_pipeline(MODELS[model_key]) return [pipe(s, max_length=512)[0]["translation_text"].strip() for s in sentences] def translate_segment(text: str, model_key: str) -> str: cfg = MODELS[model_key] if cfg["kind"] == "mock": return mock_translate(text) sentences = [s for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()] try: pieces = _gpu_translate(model_key, sentences) except Exception as exc: gr.Warning(f"Could not load {model_key} ({exc}). Using offline demo MT.") return mock_translate(text) return " ".join(pieces) # --------------------------------------------------------------------------- # State + derived data # --------------------------------------------------------------------------- def default_state() -> dict: return {"segments": [], "idx": 0, "committed": {}, "mt_cache": {}, "model_key": list(MODELS)[0], "applic": []} def current_mt(state: dict, idx: int) -> str: key = (state["model_key"], idx) if key not in state["mt_cache"]: state["mt_cache"][key] = translate_segment(state["segments"][idx], state["model_key"]) return state["mt_cache"][key] def learned_rules(state: dict, exclude_idx: int | None = None) -> list[Rule]: seen: set[tuple[str, str]] = set() rules: list[Rule] = [] for i in sorted(state["committed"]): if i == exclude_idx: continue for s, t in mine_rules(current_mt(state, i), state["committed"][i]): if (s, t) not in seen: seen.add((s, t)) rules.append(Rule(s, t, i)) return rules def compute_applicable(state: dict, idx: int) -> list[tuple[str, str, str]]: """Return (label, src, tgt) for rules from other paragraphs that match this MT.""" mt_toks = tokenize(current_mt(state, idx)) out = [] for r in learned_rules(state, exclude_idx=idx): if rule_applies(r.src, mt_toks): label = f"{r.src} \u2192 {r.tgt}" out.append((label, r.src, r.tgt)) return out def wrap_html(inner: str, applied: list[tuple[str, str, int]]) -> str: note = "" if applied: note = ('
Applied: ' + "; ".join(f"{s} \u2192 {t} (\u00d7{c})" for s, t, c in applied) + "
") return f'
{inner or " "}
{note}' # --------------------------------------------------------------------------- # Render + event handlers # --------------------------------------------------------------------------- def render(state: dict): if not state["segments"]: return ( "### Load a document to begin \u2192", "", "", gr.update(choices=[], value=[]), wrap_html("", []), gr.update(value=""), [], [], ) idx, segs = state["idx"], state["segments"] n = len(segs) mt = current_mt(state, idx) applic = compute_applicable(state, idx) state["applic"] = applic labels = [a[0] for a in applic] accepted_pairs = [(s, t) for (_, s, t) in applic] _, html_s, applied = apply_rules(mt, accepted_pairs) flag = " \u00b7 committed \u2713" if idx in state["committed"] else "" progress = (f"### Paragraph {idx + 1} of {n}{flag}\n" f"{len(state['committed'])}/{n} paragraphs post-edited") editor_val = state["committed"].get(idx, mt) learned = [[r.src, r.tgt, r.learned_from + 1] for r in learned_rules(state)] committed = [[i + 1, segs[i], current_mt(state, i), state["committed"][i]] for i in sorted(state["committed"])] return (progress, segs[idx], mt, gr.update(choices=labels, value=labels), wrap_html(html_s, applied), gr.update(value=editor_val), learned, committed) def on_load_sample(state): new = default_state() new["model_key"] = state["model_key"] new["segments"] = segment_document(SAMPLE_SOURCE, "paragraph") return (new, *render(new)) def on_load_doc(state, text, mode): if not (text or "").strip(): gr.Warning("Paste some English text first, or load the sample.") return (state, *render(state)) new = default_state() new["model_key"] = state["model_key"] new["segments"] = segment_document(text, mode) return (new, *render(new)) def on_model_change(state, model_key): state["model_key"] = model_key return (state, *render(state)) def on_rules_change(state, accepted_labels): if not state["segments"]: return wrap_html("", []) mt = current_mt(state, state["idx"]) pairs = [(s, t) for (lab, s, t) in state.get("applic", []) if lab in accepted_labels] _, html_s, applied = apply_rules(mt, pairs) return wrap_html(html_s, applied) def on_use_suggestion(state, accepted_labels): if not state["segments"]: return gr.update() mt = current_mt(state, state["idx"]) pairs = [(s, t) for (lab, s, t) in state.get("applic", []) if lab in accepted_labels] plain, _, _ = apply_rules(mt, pairs) return gr.update(value=plain) def on_reset_editor(state): if not state["segments"]: return gr.update() return gr.update(value=current_mt(state, state["idx"])) def on_commit(state, editor_text): if state["segments"]: idx = state["idx"] state["committed"][idx] = editor_text if idx + 1 < len(state["segments"]): state["idx"] = idx + 1 return (state, *render(state)) def on_nav(state, delta): if state["segments"]: state["idx"] = max(0, min(len(state["segments"]) - 1, state["idx"] + delta)) return (state, *render(state)) # --------------------------------------------------------------------------- # UI # --------------------------------------------------------------------------- CSS = """ .mtbox { background:#f6f8fa; border:1px solid #e1e4e8; border-radius:6px; padding:10px 12px; line-height:1.6; } .prop-hit { background:#c8f7c5; border-radius:3px; padding:0 2px; } .applied-note { color:#6a737d; font-size:0.85rem; margin-top:6px; } .srcbox textarea { background:#fffdf5 !important; } """ def build_demo(): with gr.Blocks() as demo: gr.HTML(f"") state = gr.State(default_state()) gr.Markdown( "# APEX-VW \u00b7 Correction Propagation Demo\n" "Post-edit EN\u2192ES MT one paragraph at a time. Your edits become rules " "(edit-memory) that are proposed on later paragraphs for you to **accept or reject**." ) with gr.Row(): # ---- Left: controls + source + MT -------------------------------- with gr.Column(scale=3): with gr.Row(): model_dd = gr.Dropdown(list(MODELS), value=list(MODELS)[0], label="MT model", scale=3) seg_mode = gr.Radio(["paragraph", "sentence"], value="paragraph", label="Segment by", scale=2) with gr.Accordion("Input a document", open=True): doc_in = gr.Textbox(lines=4, label="Paste English text", placeholder="Paste a document, or use the sample \u2192") with gr.Row(): load_doc_btn = gr.Button("Load document") sample_btn = gr.Button("Load NHS sample", variant="secondary") progress_md = gr.Markdown("### Load a document to begin \u2192") with gr.Row(): prev_btn = gr.Button("\u25c0 Prev") next_btn = gr.Button("Next \u25b6") source_box = gr.Textbox(label="Source (English)", interactive=False, lines=3, elem_classes="srcbox") mt_box = gr.Textbox(label="Raw MT (Spanish)", interactive=False, lines=3) # ---- Right: rules panel + suggestion + editor -------------------- with gr.Column(scale=2): gr.Markdown("### Propagation rules") gr.Markdown("Corrections mined " "from your earlier edits. Tick the ones to propagate onto this " "paragraph.") rules_group = gr.CheckboxGroup(choices=[], value=[], label="Rules (tick to propagate)") gr.Markdown("**Suggested translation** (accepted rules applied)") suggested_html = gr.HTML(wrap_html("", [])) with gr.Row(): use_btn = gr.Button("\u2b07 Use suggestion", variant="primary") reset_btn = gr.Button("\u21ba Reset to raw MT") editor = gr.Textbox(label="Your post-edit (final Spanish)", lines=4) commit_btn = gr.Button("Commit & continue \u25b6", variant="primary") with gr.Accordion("Learned rules (edit-memory)", open=False): learned_df = gr.Dataframe(headers=["MT phrase", "Correction", "From \u00b6"], interactive=False, wrap=True) with gr.Accordion("Post-edited segments (source / targetmt / targetpe)", open=False): committed_df = gr.Dataframe(headers=["#", "source", "targetmt", "targetpe"], interactive=False, wrap=True) # ---- Wiring ------------------------------------------------------------ seg_out = [progress_md, source_box, mt_box, rules_group, suggested_html, editor, learned_df, committed_df] sample_btn.click(on_load_sample, [state], [state, *seg_out]) load_doc_btn.click(on_load_doc, [state, doc_in, seg_mode], [state, *seg_out]) model_dd.change(on_model_change, [state, model_dd], [state, *seg_out]) rules_group.change(on_rules_change, [state, rules_group], suggested_html) use_btn.click(on_use_suggestion, [state, rules_group], editor) reset_btn.click(on_reset_editor, [state], editor) commit_btn.click(on_commit, [state, editor], [state, *seg_out]) prev_btn.click(lambda s: on_nav(s, -1), [state], [state, *seg_out]) next_btn.click(lambda s: on_nav(s, +1), [state], [state, *seg_out]) return demo demo = build_demo() if __name__ == "__main__": demo.launch()