Text Generation
Transformers
Safetensors
English
gemma4_text
transcript-cleanup
meetings
asr-post-processing
disfluency-removal
conversational
Instructions to use xav-ie/scribe-cleanup with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use xav-ie/scribe-cleanup with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="xav-ie/scribe-cleanup") messages = [ {"role": "user", "content": "Who are you?"}, ] pipe(messages)# pip install -U transformers accelerate # Load model directly from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer = AutoTokenizer.from_pretrained("xav-ie/scribe-cleanup") model = AutoModelForCausalLM.from_pretrained("xav-ie/scribe-cleanup", device_map="auto") messages = [ {"role": "user", "content": "Who are you?"}, ] inputs = tokenizer.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=256) print(tokenizer.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use xav-ie/scribe-cleanup with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "xav-ie/scribe-cleanup" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "xav-ie/scribe-cleanup", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/xav-ie/scribe-cleanup
- SGLang
How to use xav-ie/scribe-cleanup with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "xav-ie/scribe-cleanup" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "xav-ie/scribe-cleanup", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "xav-ie/scribe-cleanup" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "xav-ie/scribe-cleanup", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }' - Docker Model Runner
How to use xav-ie/scribe-cleanup with Docker Model Runner:
docker model run hf.co/xav-ie/scribe-cleanup
Download guard.py from xav-ie/scribe-cleanup: direct link, hf CLI and curl.
- Browser
- Download file 17.4 kB
-
https://huggingface.co/xav-ie/scribe-cleanup/resolve/main/guard.py
- Command line
-
hf download hf://xav-ie/scribe-cleanup/guard.py
-
curl -L -o guard.py https://huggingface.co/xav-ie/scribe-cleanup/resolve/main/guard.py
17.4 kB
| #!/usr/bin/env python3 | |
| """Line guards for the scribe cleanup model: keep the raw line wherever the cleaned one is unsafe. | |
| The cleanup model is trained to keep every word and number, but no model is perfect. Run its output through this | |
| check and show the raw text for any line where: | |
| - the output doesn't have the same lines, timestamps and speakers (then the whole window stays raw) | |
| - a number's value was lost, changed or added ("twenty five" and "25" count as equal) | |
| - an ambiguous spoken number ("twelve fifty") was turned into digits without context that settles it | |
| - a negation (not, no, never, n't, ...) was lost from a word that is still there | |
| An explicit self-correction is allowed: "ten no wait four" -> "4" loses neither a number nor a negation. | |
| python3 guard.py raw.txt cleaned.txt # both in "[m:ss] Speaker: text" lines; prints the guarded lines | |
| python3 guard.py --selftest | |
| Generated by build_guard.py in the training repository from the code its evaluation uses. Standard library | |
| only. Apache-2.0. | |
| """ | |
| import difflib, re, sys | |
| from collections import Counter | |
| FILLERS = {"um", "uh", "umm", "uhm", "mm", "mmm", "hmm", "hm", "mm-hmm", "mhm", "uh-huh", "er", "erm", "ah", "eh", "mkay", "mm'kay"} | |
| FUNCTION = set("a an the i to and of in on it is we you he she they my your our this but so for with at as or".split()) | |
| ONES = "zero one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen".split() | |
| TENS_WORDS = "_ _ twenty thirty forty fifty sixty seventy eighty ninety".split() | |
| ORD = {1: "first", 2: "second", 3: "third", 4: "fourth", 5: "fifth", 6: "sixth", 7: "seventh", 8: "eighth", 9: "ninth", | |
| 10: "tenth", 11: "eleventh", 12: "twelfth", 13: "thirteenth", 14: "fourteenth", 15: "fifteenth", 16: "sixteenth", | |
| 17: "seventeenth", 18: "eighteenth", 19: "nineteenth", 20: "twentieth", 30: "thirtieth"} | |
| def say(n): | |
| """Spoken English for 0 <= n < 1,000,000 as ASR writes it ("three hundred twenty", no "and").""" | |
| if n < 20: | |
| return ONES[n] | |
| if n < 100: | |
| return TENS_WORDS[n // 10] + ("" if n % 10 == 0 else " " + ONES[n % 10]) | |
| if n < 1000: | |
| return ONES[n // 100] + " hundred" + ("" if n % 100 == 0 else " " + say(n % 100)) | |
| return say(n // 1000) + " thousand" + ("" if n % 1000 == 0 else " " + say(n % 1000)) | |
| def say_ord(n): | |
| return ORD.get(n) or TENS_WORDS[n // 10] + " " + ORD[n % 10] | |
| def say_minutes(m): | |
| return "oh " + ONES[m] if 0 < m < 10 else say(m) | |
| UNITS = {w: i for i, w in enumerate(ONES)} | |
| TENS = {w: 10 * i for i, w in enumerate(TENS_WORDS) if w != "_"} | |
| ORDINALS = {w: n for n, w in ORD.items()} | |
| ORDINALS.update({w + "s": v for w, v in list(ORDINALS.items()) if w not in ("first", "second")}) | |
| SHORT_WORDS = set("a i o am an as at be by do go he if in is it me my no of oh ok on or pm so to up us we".split()) | |
| CUES = ["no wait", "no no", "actually no", "no actually", "or rather", "i mean", "sorry", "wait no"] | |
| CUE_RE = re.compile(r"\b(" + "|".join(c.replace(" ", r"\s+") for c in CUES) + r")\b", re.I) | |
| SCALES = {"hundred": 100, "thousand": 1000, "million": 10**6, "billion": 10**9} | |
| def number_word(w): | |
| return w in UNITS or w in TENS or w in ORDINALS or w in SCALES or w == "point" | |
| def number_spans(text, context=False): | |
| """Number mentions in order. Each span is a list of groups: a group is one value. "twenty five" -> [25]; | |
| "three hundred and twenty" -> [320]; "two point five" -> [2.5]; "fifty million" / "50 million" -> [5e7]; | |
| adjacent values that do not combine form an ambiguous span: "twelve fifty" -> [12, 50], "12 50" -> [12, 50]. | |
| A digit token with ':' or '/' (12:50, 08/01) is one group holding its parts, as a tuple.""" | |
| spans, groups = [], [] | |
| state = None # last word kind in the current group: unit, teen, tens, hundred, big, point, digit | |
| total = current = 0 | |
| decimals = None | |
| def value(): | |
| v = total + current | |
| return v + float("0." + decimals) if decimals else v | |
| def close_group(): | |
| nonlocal total, current, decimals, state | |
| if state is not None: | |
| groups.append(value()) | |
| total = current = 0 | |
| decimals, state = None, None | |
| def close_span(): | |
| close_group() | |
| if groups: | |
| # context: the sentence so far (a date word anywhere in it counts), the next few tokens (a correction | |
| # cue may follow a unit or a clock phrase), and where the span starts | |
| spans.append((list(groups), tokens[sentence_start:span_start], tokens[i:i + 8] if i is not None else [], span_start)) | |
| groups.clear() | |
| i, span_start, sentence_start = None, 0, 0 | |
| tokens = raw_tokens = re.findall(r"\$?\d+(?:,\d{3})*(?:\.\d+)?(?:[:/]\d+)*%?(?:st|nd|rd|th)?|[a-z]+|[.,;?!]", | |
| text.lower().replace("-", " ")) | |
| for i, t in enumerate(raw_tokens): | |
| if not groups and state is None: | |
| span_start = i | |
| if t in ".,;?!": | |
| close_span() | |
| if t in ".?!": | |
| sentence_start = i + 1 | |
| continue | |
| if t in FILLERS or (t == "oh" and state is not None): # "twelve uh fifty", "five oh five": one mention | |
| continue | |
| if state is not None and t.isalpha() and len(t) <= 2 and t not in SHORT_WORDS and not number_word(t): | |
| continue # a fragment inside a number: "three f three five" | |
| if t[0].isdigit() or t[0] == "$": | |
| d = re.sub(r"[$,%]|(st|nd|rd|th)$", "", t) | |
| close_group() | |
| if ":" in d or "/" in d: | |
| groups.append(tuple(int(x) for x in re.split(r"[:/]", d))) | |
| else: | |
| total, current, state = 0, float(d), "digit" | |
| continue | |
| if t == "and" and state in ("hundred", "big"): | |
| continue | |
| if t == "point" and state in ("unit", "teen", "tens", "digit", "hundred", "big") and decimals is None: | |
| decimals, state = "", "point"; continue | |
| if state == "point" or (decimals is not None and t in UNITS and UNITS[t] < 10): | |
| if t in UNITS and UNITS[t] < 10: | |
| decimals += str(UNITS[t]); continue | |
| if t in SCALES and state is not None: | |
| if SCALES[t] == 100: | |
| current = (current or 1) * 100; state = "hundred" | |
| else: | |
| total += (current or 1) * SCALES[t]; current = 0; state = "big" | |
| continue | |
| v = UNITS.get(t, TENS.get(t, ORDINALS.get(t))) | |
| if v is None: | |
| close_span(); continue | |
| kind = "tens" if t in TENS or (t in ORDINALS and v >= 20) else ("unit" if v < 10 else "teen") | |
| combines = (state == "tens" and kind == "unit") or state in ("hundred", "big") | |
| if not combines: | |
| close_group() | |
| current += v | |
| state = kind | |
| i = len(raw_tokens) | |
| close_span() | |
| return spans if context else [g for g, _, _, _ in spans] | |
| CURRENCY = {"dollars": "$", "dollar": "$", "euros": "€", "euro": "€", "pounds": "£", "pound": "£"} | |
| def written(value, unit): | |
| """How an unambiguous amount is written: (30000, "dollars") -> "$30,000", (2.5, "percent") -> "2.5%".""" | |
| if unit == "percent": | |
| return f"{value:g}%" | |
| return CURRENCY[unit] + (f"{value:,.2f}" if value != int(value) else f"{int(value):,}") | |
| SPELLING = {"programme": "program", "favourite": "favorite", "colour": "color", "centre": "center", "grey": "gray", | |
| "realise": "realize", "organise": "organize", "behaviour": "behavior", "neighbourhoods": "neighborhoods", | |
| "reprogramme": "reprogram", "okay": "ok"} | |
| NEG = re.compile(r"\b(not|no|never|nothing|none|nobody|neither|nor|cannot)\b|n't\b", re.I) | |
| LINE = re.compile(r"^\[(\d+:\d\d)\] ([^:\n]+):[ \t]*(.*)$") | |
| def words(s): | |
| s = re.sub(r"\b([a-z])\.([a-z])\.", r"\1\2", s.lower()) # p.m. -> pm, e.g. -> eg | |
| return [SPELLING.get(w, w) for w in re.findall(r"[a-z0-9']+", s.replace("-", " "))] | |
| DATE_CUES = {"date", "deadline", "due", "dated"} | |
| def justified(before, after): | |
| """Context that makes a two-part spoken number safe to normalize (SPEC.md): am/pm right after (a time), or | |
| a month name or a date cue just before (a date). A currency does not: "twelve fifty euro" is still 12.50 | |
| or 1,250.""" | |
| return bool(set(after[:2]) & {"am", "pm"} or set(before) & DATE_CUES | |
| or set(before[-1:]) & {m for m in "january february march april may june july august september october november december".split()}) | |
| def readings(group_pair): | |
| """Digit forms a two-group ambiguous span could be resolved into: 12 50 -> 12.5, 1250, (12, 50).""" | |
| a, b = group_pair | |
| if not (float(a).is_integer() and float(b).is_integer()): | |
| return set() | |
| a, b = int(a), int(b) | |
| return {float(f"{a}.{b:02d}") if b < 100 else None, float(f"{a}{b:02d}") if b < 100 else None, float(f"{a}{b}"), | |
| (a, b)} - {None} | |
| def corrected(after): | |
| """True when a self-correction cue starts within the next three words and another number follows it: | |
| "ten [no wait four]", "ten am [no wait four pm]", "twenty five to eight [no wait eight o'clock]".""" | |
| w = [t for t in after if t not in ".,;?!"] | |
| for k in range(min(3, len(w))): | |
| for cue in CUES: | |
| c = cue.split() | |
| if w[k:k + len(c)] == c and any(number_word(t) or t[0].isdigit() or t in ("quarter", "half") | |
| for t in w[k + len(c):k + len(c) + 3]): | |
| return True | |
| return False | |
| def restarted(tokens, i): | |
| """True when token i is in the first copy of an immediately repeated phrase of up to four words, perhaps with a | |
| cut-off word between ("one of the f one of the", "I don't I don't"): dropping that copy loses nothing. A phrase of | |
| numbers alone ("two two", "one five five") is never a restart: dropping a digit there changes a value.""" | |
| w = [t for t in tokens if t not in ".,;?!"] | |
| j = sum(1 for t in tokens[:i] if t not in ".,;?!") # i among the words | |
| for n in range(1, 5): | |
| for a in range(max(0, j - n + 1), j + 1): | |
| for gap in (0, 1): | |
| b = a + n + gap | |
| if (b + n <= len(w) and w[a:a + n] == w[b:b + n] | |
| and not all(number_word(t) or t[:1].isdigit() for t in w[a:a + n]) # "one five five" is a code | |
| and (gap == 0 or (len(w[a + n]) <= 3 and not number_word(w[a + n]) and w[a + n] not in ("oh", "o")))): | |
| return True | |
| return False | |
| def negations(s): | |
| """Negations, not counting the "no" inside correction cues ("ten no wait four").""" | |
| return len(NEG.findall(CUE_RE.sub(" ", re.sub(r"[^\w\s']", " ", s)))) | |
| def is_neg(w): | |
| return bool(NEG.fullmatch(w)) or w.endswith("n't") | |
| def neg_lost(src, hyp): | |
| """A negation of src is lost when it has no counterpart in hyp but the word it negated does ("should not | |
| exceed" -> "should exceed"), by word alignment, or when hyp has no negation left at all. Dropping a false | |
| start that held a repeat of it is not a loss ("you can't make it you can't make a mistake").""" | |
| plain = lambda t: words(CUE_RE.sub(" ", " ".join(words(t)))) # punctuation-blind, cues removed | |
| s, h = plain(src), plain(hyp) | |
| to_h = {blk.a + k: blk.b + k for blk in difflib.SequenceMatcher(None, s, h, autojunk=False).get_matching_blocks() | |
| for k in range(blk.size)} # aligned word positions | |
| if any(is_neg(w) and i not in to_h and not restarted(s, i) and to_h.get(i + 1) is not None and not is_neg(s[i + 1]) | |
| and not (to_h[i + 1] > 0 and is_neg(h[to_h[i + 1] - 1])) # the kept word is still negated | |
| for i, w in enumerate(s)): | |
| return True | |
| return any(map(is_neg, s)) and not any(map(is_neg, h)) | |
| def num_check(raw, hyp, ref=None): | |
| """(lost, invented, resolved) number mentions of hyp against raw. A raw span matches a hyp span with the | |
| same groups (form ignored). An ambiguous two-group raw span matched by one digit token built from its | |
| parts counts as resolved. Lost and invented mentions are excused when ref agrees with hyp.""" | |
| rs = number_spans(raw, context=True) | |
| hs = number_spans(hyp) | |
| if ref is not None and hs == number_spans(ref): | |
| return 0, 0, 0 # exactly the reference's numbers | |
| ref_vals = {g for s in number_spans(ref) for g in s} if ref is not None else set() | |
| used, lost, resolved, lost_vals = set(), 0, 0, Counter() | |
| raw_tokens = re.findall(r"\$?\d+(?:,\d{3})*(?:\.\d+)?(?:[:/]\d+)*%?(?:st|nd|rd|th)?|[a-z]+|[.,;?!]", raw.lower().replace("-", " ")) | |
| optional = Counter() # values the output may drop or keep: a corrected one, or the first copy of a restart | |
| for s0, before, after, start in rs: | |
| if corrected(after) or restarted(raw_tokens, start): | |
| optional.update(s0) # "ten no wait four", "one of the one of the": never claims the output's values | |
| continue | |
| # A doubled number is a stutter and may become one ("one one little question"); a longer run is a code or a | |
| # page number and keeps every digit ("page eight eight eight", "channel six six one eight"). | |
| s = s0 | |
| stutter = len(s) == 2 and s[0] == s[1] | |
| hit = next((k for k, h in enumerate(hs) if k not in used and (h == s or (stutter and h == s[:1]) | |
| or (len(s) == 1 and after[:1] == ["o"] and h == [(int(s[0]), 0)]))), None) # "three o'clock" == 3:00 | |
| if hit is None and len(s) == 2: | |
| hit = next((k for k, h in enumerate(hs) if k not in used and len(h) == 1 and h[0] in readings(s)), None) | |
| resolved += hit is not None and not justified(before, after) | |
| if hit is None and len(s) > 1: # parts kept but split differently ("twelve fifty" -> "12 50") | |
| hit = next((k for k, h in enumerate(hs) if k not in used and sorted(map(str, h)) == sorted(map(str, s))), None) | |
| if hit is None: | |
| if not (ref is not None and not set(s) & ref_vals): | |
| lost += 1 | |
| lost_vals.update(s) | |
| else: | |
| used.add(hit) | |
| unmatched = [] | |
| for k, h in enumerate(hs): | |
| if k not in used: | |
| if Counter(h) <= optional: | |
| optional.subtract(h) # an optional value kept: fine | |
| else: | |
| unmatched.append(h) | |
| extra = [h for h in unmatched if not (ref is not None and set(h) <= ref_vals)] | |
| if lost and Counter(v for h in unmatched for v in h) == lost_vals: | |
| return 0, 0, resolved # same values, split differently ("one two three" -> "One, two, three") | |
| if Counter(v for sp, *_ in rs for v in sp) == Counter(v for h in hs for v in h): | |
| return 0, 0, resolved # every value as often as before: "two two" -> "Two. Two." | |
| return lost, len(extra), resolved | |
| def unsafe(raw, hyp): | |
| """A number lost, invented or ambiguously rewritten ("twelve fifty" -> 12.50 without context), or a negation | |
| lost. Self-corrections are allowed: "ten no wait four" -> "4" loses neither a number nor a negation.""" | |
| return bool(sum(num_check(raw, hyp))) or neg_lost(raw, hyp) | |
| def guard_lines(raw_lines, cleaned_lines): | |
| """raw_lines, cleaned_lines: lists of "[m:ss] Speaker: text" strings. Returns (lines to show, reverted count).""" | |
| raw = [LINE.match(l.strip()) for l in raw_lines if l.strip()] | |
| out = [LINE.match(l.strip()) for l in cleaned_lines if l.strip()] | |
| if len(raw) != len(out) or not all(raw) or not all(out) or any( | |
| (a.group(1), a.group(2).strip()) != (b.group(1), b.group(2).strip()) for a, b in zip(raw, out)): | |
| return [m.group(0) for m in raw if m], len(raw) # line format broken: the whole window stays raw | |
| bad = [unsafe(a.group(3), b.group(3)) for a, b in zip(raw, out)] | |
| return [a.group(0) if x else b.group(0) for a, b, x in zip(raw, out, bad)], sum(bad) | |
| def selftest(): | |
| ok = lambda raw, out: guard_lines([f"[0:01] A B: {raw}"], [f"[0:01] A B: {out}"])[1] == 0 | |
| assert ok("um so the the cost is fine", "So the cost is fine.") | |
| assert ok("twenty five thousand dollars", "$25,000") | |
| assert ok("let's meet at three thirty pm", "Let's meet at 3:30 PM.") | |
| assert ok("the deadline is eight one", "The deadline is 08/01.") | |
| assert ok("stay below twelve uh fifty", "Stay below twelve fifty.") | |
| assert ok("the review moved to ten no wait four pm", "The review moved to 4 PM.") | |
| assert ok("I have one one little question", "I have one little question.") | |
| assert not ok("it's on channel six one one one one oh", "It's on channel six one one one oh.") | |
| assert ok("so you can't make it you can't make a mistake", "So you can't make a mistake.") | |
| assert not ok("less than twelve fifty euro", "less than 125,000 Euro") | |
| assert not ok("less than twelve fifty", "less than 12.50") # ambiguous, no context | |
| assert not ok("so that's two off the price", "So that's 20 off the price.") | |
| assert not ok("it should not exceed it", "It should exceed it.") | |
| assert guard_lines(["[0:01] A B: hi"], ["[0:02] A B: Hi."])[1] == 1 # timestamp changed: window stays raw | |
| print("ok") | |
| if __name__ == "__main__": | |
| if sys.argv[1:2] == ["--selftest"]: | |
| selftest() | |
| else: | |
| shown, reverted = guard_lines(open(sys.argv[1]).read().splitlines(), open(sys.argv[2]).read().splitlines()) | |
| print("\n".join(shown)) | |
| print(f"{reverted} line(s) kept raw", file=sys.stderr) | |