Spaces:
Running
Running
unknown
Claude Opus 5.5
Theses over 40,000 words reach 0%: the fast engine's limits grow with the length (time cap 30 s per 1,000 words, at least 10 min; a round waits 0.08 s per flagged sentence, at least 90 s; 2 extra rounds per 10,000 words); a wallet run's deadline follows the same cap. A flat 10-minute cap and 90-second rounds dropped most flagged sentences of a long thesis each round
c7ef1c5 Download fast.py from Rasheed/Test_AI_writing: direct link, hf CLI and curl.
- Browser
- Download file 39.2 kB
-
https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/fast.py
- Command line
-
hf download hf://spaces/Rasheed/Test_AI_writing/fast.py
-
curl -L -o fast.py https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/fast.py
39.2 kB
| """ | |
| Fast engine: a Word document of any length humanized in about a minute. | |
| The Smart engine rewrites sentence by sentence: Parrot versions, about ten checks per version, several passes one | |
| after another; its time grows with the length of the document. This engine works on the whole document at once: | |
| 1. The AI detector reads the whole document once (GPU, batched). | |
| 2. Every flagged sentence goes to SEVERAL language models in parallel batches (FAST_GENERATORS; FAST_PARALLEL | |
| requests at once per model), with the author's sentence as the meaning reference; each model writes several | |
| human-style versions. Measured on 30 AI-written sentences (devtools/bench_generators.py), versions the detector | |
| reads as human (< 0.5): gpt-oss-120b 2/30, Qwen 3.8 9/30, Gemini 3.5 Flash-Lite 14/30, the two pooled 16/30. | |
| 3. Each version must keep every term, number, citation and symbol (textparse.guard) and the meaning (sentence | |
| embeddings, GPU). The detector reads the surviving versions IN CONTEXT (with their neighbours) and the most | |
| human one is used, but only if it reads as more human than what the sentence has now. | |
| 4. The document is scored again. Sentences that are still flagged get another round with a stronger rewrite | |
| (more new wording), until the score is 0 or the time budget (FAST_BUDGET seconds) is used up. | |
| The version with the lowest score is kept. Only the text of each sentence is replaced, so formatting, citation | |
| fields, equations, tables and figures stay as they were. | |
| Space variables: FAST_BUDGET (50 s), FAST_PARALLEL (16 requests at once per model), FAST_ROUNDS (4), | |
| FAST_VERSIONS (2 per model), FAST_GENERATORS ("provider:model,..."; Groq only by default; other providers only if | |
| named here). | |
| """ | |
| import io | |
| import os | |
| import time | |
| from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor, wait | |
| import requests | |
| import detector as D | |
| import docx_report as DX | |
| import fluency as FL | |
| import groqai as G | |
| import humanizer as H | |
| import meaning as MEANING | |
| import pubrules as PR | |
| import smart as S | |
| import textparse as TP | |
| # 0% first: rounds continue until the document is at 0% on our detector (each round rewriting more strongly), until | |
| # STALL rounds in a row bring no improvement, or until FAST_BUDGET seconds (a safety cap; about a minute for 2,000 | |
| # words, a few minutes for a thesis on a 250k-tokens-a-minute Groq key). Requests still running at the cap are dropped. | |
| BUDGET = float(os.getenv("FAST_BUDGET", "600")) | |
| ROUND_LM_MAX = float(os.getenv("FAST_ROUND_LM_MAX", "90")) # seconds one round may wait for the language models | |
| STALL = int(os.getenv("FAST_STALL", "3")) | |
| CAP_PER_1K = float(os.getenv("FAST_CAP_PER_1K_WORDS", "0.60")) # US$ per 1,000 words a job may spend at most | |
| PARALLEL = int(os.getenv("FAST_PARALLEL", "16")) | |
| # sentences per request: the instructions (~1,500 tokens) go with every request, so larger batches cost far fewer | |
| # tokens per sentence (they were most of the tokens sent) | |
| BATCH = int(os.getenv("FAST_BATCH", "24")) | |
| ALWAYS_ROUNDS = int(os.getenv("FAST_ALWAYS_ROUNDS", "1")) # rounds in which every flagged sentence is replaced | |
| ROUNDS = int(os.getenv("FAST_ROUNDS", "10")) | |
| VERSIONS = int(os.getenv("FAST_VERSIONS", "2")) # per model (4 models: 8 versions per sentence) | |
| # round -> (versions per sentence, share of new wording asked for (%), sentences above this score are rewritten) | |
| # FAST_CHANGES: the new-wording share asked for in each round (measured on 4 AI paragraphs with the 3-model detector: | |
| # "0,40,60,70" needed 4 rounds for 0%; the first, gentle round only took the score from 100% to 79%) | |
| _CHG = [int(x) for x in os.getenv("FAST_CHANGES", "50,60,70,75").split(",") if x.strip()] | |
| PLAN = [(VERSIONS + min(k, 2), c, thr) for k, (c, thr) in enumerate(zip(_CHG, (0.50, 0.40, 0.30, 0.25)))] | |
| # rounds after the plan: the sentences left are the stubborn ones, so more versions and the strongest rewrite | |
| PLAN += [(VERSIONS + 3, max(_CHG[-1], 80), 0.25)] * max(0, ROUNDS - len(PLAN)) | |
| # Long documents (a 40,000+ word thesis) get limits that grow with their length, so they too reach 0%: a flat | |
| # 10-minute cap and 90-second rounds dropped most of a thesis's flagged sentences in each round and stopped it early. | |
| BUDGET_PER_1K = float(os.getenv("FAST_BUDGET_PER_1K_WORDS", "30")) # seconds per 1,000 words (22,000 words: ~4 min) | |
| ROUND_LM_PER_SENT = float(os.getenv("FAST_ROUND_LM_PER_SENTENCE", "0.08")) # a round's wait per flagged sentence | |
| ROUNDS_PER_10K = int(os.getenv("FAST_ROUNDS_PER_10K_WORDS", "2")) # extra rounds per 10,000 words | |
| def budget_for(words): | |
| """The job's time cap: FAST_BUDGET, or more for a long document.""" | |
| return max(BUDGET, BUDGET_PER_1K * max(0, int(words or 0)) / 1000) | |
| def rounds_for(words): | |
| return ROUNDS + ROUNDS_PER_10K * (max(0, int(words or 0)) // 10000) | |
| def _plan(rounds): | |
| return PLAN[:rounds] + [PLAN[-1]] * max(0, rounds - len(PLAN)) | |
| def _stock(s): | |
| """The sentence uses a stock AI word or phrase ("plays a crucial role", "Furthermore", "It is important to note"). | |
| Such a sentence is rewritten even when the detector no longer flags it: once its neighbours were rewritten the | |
| detector passed it, but a reader (or another checker) still sees it.""" | |
| return bool(PR._AI_WORD_RE.search(s or "") or any(r.search(s or "") for r in PR._AI_PHRASE_RE)) | |
| def _jobs(blocks, thr, min_words=5, also=None, around=None): | |
| """(block, sub-span, current sentence text) for every sentence at or above `thr` (or with a stock AI phrase, or | |
| picked by `also`, e.g. the sentences an uploaded checker report highlights) that can be edited safely.""" | |
| jobs, locked = [], 0 | |
| for b in blocks: | |
| if b.kind != "para" or b.el is None: | |
| continue | |
| for sp, s, p in zip(b.cspans, b.sents, b.scores): | |
| picked = bool(also and also(s)) # asked for every sentence (the caller records the matches) | |
| if not sp or (p < thr and not _stock(s) and not picked) or len(s.split()) < min_words: | |
| continue | |
| sub = S.safe_subspan(b.el, *sp) | |
| # only the whole sentence, or its beginning (a sentence ending in a citation field or superscript): | |
| # a rewrite spliced into the middle of a sentence read "It is worth noting that Performance drops" | |
| if sub and b.raw[sp[0]:sub[0]].strip() == "" and sub == tuple(sp): | |
| jobs.append((b, sub, b.raw[sub[0]:sub[1]])) | |
| continue | |
| segs = _segments(b, *sp) if (AROUND and around is not None) else None | |
| if segs: # fields inside: rewrite around them (markers) | |
| text, refs = _tokenized(b, segs) | |
| around[(id(b), sp[0])] = (segs, refs) | |
| jobs.append((b, tuple(sp), text)) | |
| elif sub and b.raw[sp[0]:sub[0]].strip() == "": | |
| jobs.append((b, sub, b.raw[sub[0]:sub[1]])) | |
| else: | |
| locked += 1 | |
| return jobs, locked | |
| # ---------------------------------------------------------------------------------------------------- around fields | |
| # Sentences with a citation field (Zotero / Mendeley / EndNote), a link or a superscript citation in the middle used to | |
| # be left as written ("locked"): in a thesis that was a third of the flagged sentences, and a checker kept flagging | |
| # them. Now the locked parts become markers ([REF1], [REF2] ...) for the language model; a version is used only if it | |
| # keeps every marker once, in the same order, and puts words only where the sentence has plain text to hold them. Its | |
| # words go into the plain text around the untouched fields, so the citations stay live (and are never retyped). | |
| AROUND = os.getenv("FAST_AROUND_FIELDS", "1") != "0" | |
| _TOKEN = "[REF{}]" | |
| _TOKEN_RE = __import__("re").compile(r"\[REF(\d+)\]") | |
| def _segments(b, a, e): | |
| """The sentence at [a, e) of block b as [("text", s, t) | ("lock", s, t)] (paragraph offsets), or None when it holds | |
| an equation or has too little plain text to rewrite.""" | |
| items = S._run_map(b.el) | |
| if any(r is None and a <= s < e for r, s, _, _ in items): | |
| return None | |
| segs = [] | |
| for r, s, t, safe in items: | |
| if r is None or t <= a or s >= e or t <= s: | |
| continue | |
| kind = "text" if safe else "lock" | |
| s, t = max(a, s), min(e, t) | |
| if segs and segs[-1][0] == kind and segs[-1][2] == s: | |
| segs[-1] = (kind, segs[-1][1], t) | |
| else: | |
| segs.append((kind, s, t)) | |
| if not any(k == "lock" for k, _, _ in segs): | |
| return None | |
| words = sum(len(b.raw[s:t].split()) for k, s, t in segs if k == "text") | |
| return segs if words >= 5 else None | |
| def _tokenized(b, segs): | |
| """The sentence with each locked part replaced by its marker, and the markers' real text.""" | |
| out, refs = [], [] | |
| for k, s, t in segs: | |
| if k == "lock": | |
| refs.append(b.raw[s:t]) | |
| out.append(_TOKEN.format(len(refs))) | |
| else: | |
| out.append(b.raw[s:t]) | |
| return "".join(out), refs | |
| def _fits(segs, version): | |
| """Words of a version for each plain-text part of the sentence (None: the version cannot be laid out around the | |
| fields: a marker missing, repeated or moved, or words where the sentence has no plain text).""" | |
| nums = [int(m) for m in _TOKEN_RE.findall(version)] | |
| locks = sum(1 for k, _, _ in segs if k == "lock") | |
| if nums != list(range(1, locks + 1)): | |
| return None | |
| pieces = _TOKEN_RE.split(version)[::2] # text before the first marker, between markers, after the last | |
| texts, i = [], 0 | |
| for k, s, t in segs: # each lock sits between two plain-text slots (maybe empty) | |
| if k == "lock": | |
| i += 1 | |
| continue | |
| texts.append((i, s, t)) | |
| out = [] | |
| for slot, piece in enumerate(pieces): | |
| span = next(((s, t) for i_, s, t in texts if i_ == slot), None) | |
| if span is None: | |
| if piece.strip(): | |
| return None # words where the sentence has no plain text to hold them | |
| continue | |
| out.append((span, piece)) | |
| return out | |
| def _replace_around(b, segs, version): | |
| """Write a version into the plain text around the fields (right to left, so offsets stay valid).""" | |
| parts = _fits(segs, version) | |
| if parts is None: | |
| return False | |
| for (s, t), piece in sorted(parts, key=lambda x: -x[0][0]): | |
| if not S.replace_span(b.el, s, t, piece): | |
| return False | |
| return True | |
| def _tok(text, refs): | |
| """A sentence with its citations' real text -> with markers (the author's sentence for the language model).""" | |
| for i, r in enumerate(refs, 1): | |
| text = text.replace(r, _TOKEN.format(i), 1) if r else text | |
| return text | |
| def _detok(text, refs): | |
| """A sentence with markers -> with the citations' real text.""" | |
| return _TOKEN_RE.sub(lambda m: refs[int(m.group(1)) - 1] if int(m.group(1)) <= len(refs) else m.group(0), text) | |
| def _context(blocks): | |
| ctx = {} | |
| for b in blocks: | |
| if b.kind == "para": | |
| for i, t in enumerate(b.sents): | |
| ctx[t.strip()] = (b.sents[i - 1] if i else "", b.sents[i + 1] if i + 1 < len(b.sents) else "") | |
| return ctx | |
| def _recase(anchor, v, lower): | |
| """The word that opened the author's sentence keeps its capital only at the start: "relies heavily on Data | |
| protection legislation" -> "data protection legislation". Only for words the document also writes in lower case | |
| (`lower`), so names ("India", "Kumar") keep theirs.""" | |
| import re | |
| m = re.match(r"\s*([A-Z][a-z]+)\b", anchor or "") | |
| if not v or not m or m.group(1).lower() not in lower: | |
| return v | |
| w = m.group(1) | |
| return v[:1] + re.sub(rf"(?<=[a-z,;:] ){w}\b", w.lower(), v[1:]) | |
| def _fit(cur, v): | |
| """A rewrite of a sentence's beginning (the rest is a citation field etc.) must not end the sentence early.""" | |
| if v and cur and cur.rstrip()[-1:] not in ".!?" and v.rstrip()[-1:] in ".!?": | |
| return v.rstrip()[:-1] | |
| return v | |
| _URLS = {"groq": ("https://api.groq.com/openai/v1/chat/completions", "GROQ_API_KEY"), | |
| "gemini": ("https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", "GEMINI_API_KEY"), | |
| "anthropic": ("https://api.anthropic.com/v1/chat/completions", "ANTHROPIC_API_KEY")} | |
| GENERATORS = [g.strip() for g in os.getenv( | |
| "FAST_GENERATORS", | |
| # Groq only: four models, each with its own limit (250,000 tokens a minute per model on the paid key), so about | |
| # a million tokens a minute together. (With two models a 14,600-word document had only 11 of 688 sentences | |
| # rewritten within the minute.) | |
| "groq:qwen/qwen3.8-27b,groq:qwen/qwen3.6-27b,groq:openai/gpt-oss-20b,groq:openai/gpt-oss-120b").split(",") | |
| if g.strip()] | |
| FALLBACK = os.getenv("FAST_FALLBACK", "groq:openai/gpt-oss-20b") | |
| # sentences per request by provider: Gemini keys have a low requests-per-minute limit (about 20 on this key), so it | |
| # gets fewer, larger requests; a batch it refuses goes to the fallback model | |
| BATCH_BY = {"gemini": int(os.getenv("FAST_BATCH_GEMINI", "40"))} | |
| def _size(gen): | |
| return BATCH_BY.get(gen.split(":", 1)[0], BATCH) | |
| def _key(gen): | |
| prov = gen.split(":", 1)[0] | |
| if prov == "groq": | |
| return G.KEY | |
| return os.getenv(_URLS.get(prov, ("", ""))[1], "").strip() | |
| def _live_generators(): | |
| gens = [g for g in GENERATORS if g.split(":", 1)[0] in _URLS and _key(g)] | |
| return gens or ([FALLBACK] if _key(FALLBACK) else []) | |
| def _system(n): | |
| tok = G.FAITHFUL.set(True) | |
| try: | |
| return G._variants_system(n) | |
| finally: | |
| G.FAITHFUL.reset(tok) | |
| def _request(gen, system, user, max_tokens, deadline=None): | |
| """One chat request to an OpenAI-compatible endpoint; two retries on rate limits / server errors.""" | |
| prov, model = gen.split(":", 1) | |
| url = _URLS[prov][0] | |
| body = {"model": model, "max_tokens": max_tokens, | |
| "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}]} | |
| if prov != "anthropic": | |
| body["temperature"] = 0.7 | |
| if "gpt-oss" in model: | |
| body["reasoning_effort"] = "low" | |
| elif "qwen" in model: | |
| body["reasoning_effort"] = "none" | |
| for attempt in range(3): | |
| try: | |
| left = 60 if deadline is None else deadline - time.time() | |
| if left < 2: | |
| raise RuntimeError("deadline reached") | |
| r = requests.post(url, headers={"Authorization": f"Bearer {_key(gen)}", "Content-Type": "application/json"}, | |
| json=body, timeout=min(60, left)) | |
| except requests.RequestException as err: | |
| last = str(err) | |
| else: | |
| if r.ok: | |
| d = r.json() | |
| u = d.get("usage") or {} | |
| G._account("variants", model, {"prompt_tokens": u.get("prompt_tokens", 0), | |
| "completion_tokens": u.get("completion_tokens", 0)}) | |
| return d["choices"][0]["message"]["content"] | |
| last = f"{r.status_code} {r.text[:160]}" | |
| if r.status_code not in (429, 500, 502, 503, 504): | |
| break | |
| if deadline is not None and time.time() + 1.5 * (attempt + 1) + 3 > deadline: | |
| break | |
| time.sleep(1.5 * (attempt + 1)) | |
| raise RuntimeError(f"{gen}: {last}") | |
| def _batch(gen, items, glossary, n, min_change, lower=frozenset(), deadline=None): | |
| """Versions of a batch of (current sentence, author's sentence) from one model, each already checked for terms, | |
| numbers, citations and symbols (the check runs here, in the worker thread, while other requests are waiting).""" | |
| lines = [f"{i}. ORIGINAL: {a}\n SENTENCE: {t}" if a and a != t else f"{i}. SENTENCE: {t}" | |
| for i, (t, a) in enumerate(items, 1)] | |
| terms = G._surface_terms(" ".join(t for t, _ in items), glossary) | |
| user = "PROTECTED TERMS: " + ("; ".join(terms) if terms else "(none)") + "\n\n" + "\n".join(lines) | |
| if min_change: | |
| user += (f"\n\nVOCABULARY CHANGE: in every version, at least {int(min_change)}% of the words must differ " | |
| "from the sentence: use different (correct, common) vocabulary and change the clause order or " | |
| "voice where natural. Protected terms, numbers and citations stay exactly as written, and every " | |
| "rule above still holds.") | |
| max_tokens = min(8000, G._out_budget(sum(len(t) for t, _ in items) * n, len(items) * n, 1.1) + 800) | |
| data = G._load_json(_request(gen, _system(n), user, max_tokens, deadline)) | |
| out = [[] for _ in items] | |
| for k, it in enumerate(data.get("items") or []): | |
| if not isinstance(it, dict): | |
| continue | |
| try: | |
| i = int(it.get("id", k + 1)) - 1 | |
| except (TypeError, ValueError): | |
| continue | |
| if not 0 <= i < len(items): | |
| continue | |
| cur, anchor = items[i][0], items[i][1] or items[i][0] | |
| for v in (it.get("versions") or [])[:n]: | |
| if isinstance(v, str) and v.strip(): | |
| ok = _fit(cur, _recase(anchor, TP.guard(anchor, TP.restore_terms(anchor, v.strip(), glossary), | |
| glossary), lower)) | |
| if ok and ok.strip() != cur.strip(): | |
| out[i].append(ok) | |
| return out | |
| def _versions(texts, anchors, glossary, n, min_change, lower=frozenset(), deadline=None): | |
| """Checked versions of every sentence from every model, all batches of all models in parallel. A batch a model | |
| could not do goes to the fallback model.""" | |
| gens = _live_generators() | |
| if not gens: | |
| raise G.Unavailable("no language model key is set") | |
| items = [(t, a) for t, a in zip(texts, anchors)] | |
| jobs = [(g, i, min(len(items), i + _size(g))) for g in gens for i in range(0, len(items), _size(g))] | |
| out = [[] for _ in items] | |
| failed = [] | |
| acct = G.ACCT.get() # the job's cost book (not visible in worker threads) | |
| def run(gen, a, b): | |
| tok = G.ACCT.set(acct) | |
| try: | |
| return gen, a, _batch(gen, items[a:b], glossary, n, min_change, lower, deadline) | |
| except Exception as err: # this model, this batch: the others still count | |
| return gen, a, err | |
| finally: | |
| G.ACCT.reset(tok) | |
| pool = ThreadPoolExecutor(max_workers=PARALLEL * len(gens)) | |
| res, late = [], 0 | |
| def collect(futs): | |
| nonlocal late | |
| left = None if deadline is None else max(0.5, deadline - time.time()) | |
| done, rest = wait(list(futs), timeout=left) | |
| late += len(rest) # still running at the deadline: dropped | |
| return [f.result() for f in done] | |
| try: | |
| futs = {pool.submit(run, *j): j for j in jobs} | |
| got = collect(futs) | |
| res += got | |
| bad = [(g, a) for g, a, r in got if isinstance(r, Exception)] | |
| failed += [f"{g}: {str(r)[:120]}" for g, _, r in got if isinstance(r, Exception)] | |
| enough = deadline is None or deadline - time.time() > 6 | |
| if bad and _key(FALLBACK) and enough: # refused batches (rate limit): the fallback model does them | |
| size = {(g, a): b for g, a, b in jobs} | |
| res += collect({pool.submit(run, FALLBACK, a, size[(g, a)]): (g, a) for g, a in bad}) | |
| finally: | |
| pool.shutdown(wait=False, cancel_futures=True) | |
| if late: | |
| print(f"[fast] {late} request(s) still running at the deadline were dropped", flush=True) | |
| for gen, a, r in res: | |
| if isinstance(r, Exception): | |
| continue | |
| for k, vs in enumerate(r): | |
| out[a + k] += [v for v in vs if v not in out[a + k]] | |
| if failed: | |
| print(f"[fast] {len(failed)} request(s) failed: " + " | ".join(sorted(set(failed))[:3]), flush=True) | |
| if not any(out) and failed: | |
| raise G.Unavailable(failed[0]) | |
| return out | |
| def _verify(pairs): | |
| """Faithfulness verdicts (True = says the same) for [(author's sentence, version)], all batches in parallel.""" | |
| if not pairs: | |
| return [] | |
| old = G.PARALLEL | |
| G.PARALLEL = max(old, PARALLEL) | |
| try: | |
| return G.verify(pairs) | |
| except G.Unavailable as err: | |
| print(f"[fast] faithfulness check unavailable: {err}", flush=True) | |
| return [None] * len(pairs) | |
| finally: | |
| G.PARALLEL = old | |
| TOP = 2 # versions per sentence sent to the faithfulness check | |
| # CPU only (no GPU): every version is first ranked by the two small detector models, and only the best PREFILTER per | |
| # sentence go to the full detector (its large model was about 2/3 of the time). FAST_PREFILTER=0 = off. | |
| PREFILTER = int(os.getenv("FAST_PREFILTER", "2")) | |
| PREFILTER_GPU_MIN = int(os.getenv("FAST_PREFILTER_GPU_MIN", "400")) | |
| CPU_VERSIONS = int(os.getenv("FAST_CPU_VERSIONS", "0")) # CPU only: cap on versions per model (0 = no cap; 2 stalled at 23%) | |
| TIMES = {} # time of each step of the last choice (log) | |
| # A run with an uploaded checker report: the sentences it highlights keep being rewritten (each round asking for more | |
| # new wording) until little of their original wording is left, because the checker matches reworded text too and the | |
| # report estimate counts a sentence as cleared only then. FAST_REPORT_OVERLAP: share of the original word sequences a | |
| # highlighted sentence may keep; FAST_REPORT_CHANGE: the least new wording asked for in those rounds (%). | |
| REPORT_OVERLAP = float(os.getenv("FAST_REPORT_OVERLAP", "0.15")) | |
| REPORT_CHANGE = int(os.getenv("FAST_REPORT_CHANGE", "75")) | |
| REPORT_MIN_CLEARED = int(os.getenv("FAST_REPORT_MIN_CLEARED", "3")) # per round at 0%, else the run stops | |
| def _overlap(a, b): | |
| return H.similarity_to_source(a, b) / 100.0 | |
| def _choose(jobs, anchors, outs, glossary, ctx, always=False, fresh=()): | |
| """Per job: the most human version (read in context) that keeps terms, numbers, citations, the meaning | |
| (embeddings + an independent reading against the author's sentence) and reads as fluent English, if it reads as | |
| more human than the sentence does now; else None. -> (choices, versions rejected as unfaithful)""" | |
| # (versions arrive already checked for terms, numbers, citations and symbols; see _batch) | |
| cands = list(dict.fromkeys((j, v) for j, vs in enumerate(outs) for v in vs or ())) | |
| if not cands: | |
| return [None] * len(jobs), 0 | |
| t_ = time.time() | |
| sims = MEANING.similarities([(anchors[j], v) for j, v in cands]) | |
| lim = MEANING.min_sim() | |
| cands = [c for c, s_ in zip(cands, sims) if s_ >= lim] | |
| TIMES["meaning"] = time.time() - t_ | |
| # CPU: always; GPU: when the candidates are many (a long document), so choosing stays within the deadline | |
| cpu = PREFILTER and (D.on_cpu() or len(cands) > PREFILTER_GPU_MIN) | |
| if cpu and cands: | |
| # CPU: rank every version with the two small models, on its own (3x less text than in context), and keep | |
| # one more than PREFILTER per sentence for the fluency check below | |
| t_ = time.time() | |
| light = D.score_light([v for _, v in cands]) | |
| keep, per = [], {} | |
| for c, s_ in sorted(zip(cands, light), key=lambda x: x[1]): | |
| if per.get(c[0], 0) < PREFILTER + 1: | |
| per[c[0]] = per.get(c[0], 0) + 1 | |
| keep.append(c) | |
| cands = keep | |
| TIMES["prefilter"] = time.time() - t_ | |
| t_ = time.time() | |
| flu = FL.acceptable([(anchors[j], v) for j, v in cands]) | |
| cands = [c for c, f_ in zip(cands, flu) if f_] | |
| TIMES["fluency"] = time.time() - t_ | |
| if cpu and cands: # the PREFILTER best fluent versions go to the full detector | |
| per, keep = {}, [] | |
| for c in cands: | |
| if per.get(c[0], 0) < PREFILTER: | |
| per[c[0]] = per.get(c[0], 0) + 1 | |
| keep.append(c) | |
| cands = keep | |
| # Each version is scored on its own. (Scored inside its window, a version could not win while its neighbours were | |
| # still the AI-written ones: on long documents only a quarter of the flagged sentences were replaced. Now every | |
| # flagged sentence is replaced at once, so whole windows change together.) | |
| t_ = time.time() | |
| if always: | |
| sc = H._detector_scores([v for _, v in cands]) or [0.0] * len(cands) | |
| now = H._detector_scores([j[2].strip() for j in jobs]) or [1.0] * len(jobs) | |
| else: | |
| # later rounds: most neighbours are rewritten already, so a version read in its context (next to the | |
| # neighbours as they are NOW) says how the detector will read it in the document | |
| cur = {t.strip(): t.strip() for t in ctx} | |
| sc = H._ctx_scores([(jobs[j][2].strip(), v) for j, v in cands], ctx, cur) or [0.0] * len(cands) | |
| now = H._ctx_scores([(j[2].strip(), j[2].strip()) for j in jobs], ctx, cur) or [1.0] * len(jobs) | |
| TIMES["detector"] = time.time() - t_ | |
| ranked = {} | |
| for (j, v), s_ in sorted(zip(cands, sc), key=lambda x: x[1]): | |
| if j in fresh: | |
| continue | |
| if (always or s_ < now[j] - 0.02) and len(ranked.setdefault(j, [])) < TOP: | |
| ranked[j].append(v) | |
| # report sentences ("fresh"): fewest of the author's word sequences first, provided the version does not read | |
| # clearly more AI-like than the sentence does now; a later round takes it when it is clearly more reworded | |
| for j in fresh: | |
| mine = [(v, s_) for (jj, v), s_ in zip(cands, sc) if jj == j] | |
| if not mine: | |
| continue | |
| cur = _overlap(anchors[j], jobs[j][2].strip()) | |
| for v, s_ in sorted(mine, key=lambda x: (_overlap(anchors[j], x[0]), x[1])): | |
| if s_ > now[j] + 0.10 and not always: | |
| continue | |
| if (always or _overlap(anchors[j], v) < cur - 0.05) and len(ranked.setdefault(j, [])) < TOP: | |
| ranked[j].append(v) | |
| pairs = [(j, v) for j, vs in ranked.items() for v in vs] | |
| t_ = time.time() | |
| verdict = _verify([(anchors[j], v) for j, v in pairs]) | |
| TIMES["verify"] = time.time() - t_ | |
| # used only if the faithfulness check SAID it keeps the meaning: when the check could not run (service busy, | |
| # spending cap), the sentence keeps its wording rather than taking an unchecked rewrite | |
| ok = {p for p, r in zip(pairs, verdict) if r is True} | |
| best, bad = [None] * len(jobs), 0 | |
| for j, vs in ranked.items(): | |
| good = [v for v in vs if (j, v) in ok] | |
| bad += len(vs) - len(good) | |
| best[j] = good[0] if good else None | |
| return best, bad | |
| def humanize_docx(path, hi=0.80, mod=0.50, rep=None, budget=None, also=None, also_overlap=None): | |
| """-> dict like smart.smart_docx (before, after, log, note, blocks, path, report, orig, new, final_of). | |
| also(sentence) -> True: rewrite that sentence in round 1 even if our detector does not flag it (the sentences an | |
| uploaded checker report highlights); later rounds take what our detector still flags, until 0%.""" | |
| rep = rep or (lambda *a, **k: None) | |
| t0 = time.time() | |
| G.start_account() | |
| rep(0.03, "Reading your document") | |
| doc = DX.open_clean(path) | |
| orig_text = "\n".join(b.text for b in D.docx_blocks(doc) if b.kind == "para") | |
| n_words = len(orig_text.split()) | |
| budget = budget_for(n_words) if budget is None else budget | |
| glossary = TP.build_glossary([orig_text]) | TP.GLOSSARY.get() | |
| import re as _re | |
| lower = frozenset(_re.findall(r"\b[a-z][a-z]+\b", orig_text)) # words the document writes in lower case | |
| TP.GLOSSARY.set(glossary) | |
| TP.set_document([orig_text]) | |
| # spending cap scaled to the length (the flat $1 stopped the faithfulness check mid-way on a 5,500-word paper) | |
| G.set_cap(max(G.JOB_BUDGET, CAP_PER_1K * len(orig_text.split()) / 1000)) | |
| rep(0.08, "Checking the whole document for AI-written passages") | |
| blocks = S._score_doc(doc) | |
| first = stats = D.summarise(blocks, hi, mod) | |
| best = (stats["pct"], S._snapshot(doc), blocks) | |
| anchor = {} # current sentence -> the author's sentence | |
| # the author's sentence -> its latest wording, recorded when it is replaced (re-splitting the finished text | |
| # cannot find it when a rewrite changed the sentence boundaries); kept with the best version of the document | |
| replaced, best_replaced = {}, {} | |
| log, note = [], "" | |
| end = min(t0 + budget, H.JOB_DEADLINE.get() or float("inf")) # the job's safety cap (wallet: its deadline) | |
| def anchor_of(t): | |
| return anchor.get(t.strip(), t.strip()) | |
| def needs_rewording(t): | |
| """A highlighted sentence (asked about by its ORIGINAL wording) that still keeps too much of that wording.""" | |
| a = anchor_of(t) | |
| return bool(also and also(a)) and _overlap(a, t.strip()) > (REPORT_OVERLAP if also_overlap is None | |
| else also_overlap) | |
| def report_left(blocks_): | |
| return sum(1 for b_ in blocks_ if b_.kind == "para" for t in b_.sents if needs_rewording(t)) if also else 0 | |
| rleft = report_left(blocks) | |
| best_key = (stats["pct"], rleft) | |
| last_lm = last_after = None | |
| stalled = zero_rounds = 0 | |
| for rnd, (n, chg, thr) in enumerate(_plan(rounds_for(n_words)), 1): | |
| if CPU_VERSIONS and D.on_cpu(): | |
| n = min(n, CPU_VERSIONS) | |
| if stats["pct"] == 0 and not rleft and (zero_rounds >= 1 or | |
| not any(_stock(t) for b in blocks if b.kind == "para" for t in b.sents)): | |
| break # at 0%: one more round at most, for leftover stock phrases | |
| zero_rounds += stats["pct"] == 0 | |
| around = {} | |
| jobs, locked = _jobs(blocks, thr if rnd == 1 else min(thr, mod), also=needs_rewording if also else None, | |
| around=around) | |
| if not jobs: | |
| break | |
| # time for choosing + re-scoring after the language model (measured on the T4: about 0.03 s per sentence) | |
| after_lm = 4 + 0.035 * len(jobs) if last_after is None else last_after * 1.15 | |
| left = min(max(ROUND_LM_MAX, ROUND_LM_PER_SENT * len(jobs)), end - time.time() - after_lm) | |
| if rnd > 1 and left < 8.0: # the safety cap is reached | |
| log.append(f"Round {rnd}: stopped, time limit reached") | |
| break | |
| # sentences with fields: the language model sees markers ([REF1] ...); everything else (anchors, choosing, | |
| # the faithfulness check, the report) reads the real text with the citations in place | |
| refs_of = [around[(id(b_), sp_[0])][1] if (id(b_), sp_[0]) in around else [] for b_, sp_, _ in jobs] | |
| llm_texts = [j[2].strip() for j in jobs] | |
| texts = [_detok(t, r) for t, r in zip(llm_texts, refs_of)] | |
| anchors = [anchor.get(t, t) for t in texts] | |
| llm_anchors = [_tok(a, r) for a, r in zip(anchors, refs_of)] | |
| fresh = {i for i, t in enumerate(texts) if needs_rewording(t)} if also else set() | |
| if fresh: # report sentences: ask for more new wording | |
| chg = max(chg, REPORT_CHANGE + 5 * (rnd - 1)) | |
| frac = 0.12 + 0.83 * (1 - 0.6 ** (rnd - 1)) # moves on every round without promising an end | |
| rep(frac, f"Round {rnd}: AI score {stats['pct']}%, rewriting {len(jobs)} sentence(s) at once") | |
| tg = time.time() | |
| try: | |
| outs = _versions(llm_texts, llm_anchors, glossary, n, chg, lower, deadline=time.time() + max(8.0, left)) | |
| except G.Unavailable as err: | |
| note += " (The rewriting service is busy right now: fewer sentences could be rewritten.)" | |
| print(f"[fast] round {rnd}: rewriting unavailable: {err}", flush=True) | |
| break | |
| tg = time.time() - tg | |
| marked = {} # (job, real version) -> its version with markers | |
| for i_, (b_, sp_, _) in enumerate(jobs): # around fields: only versions that fit around them | |
| key_ = (id(b_), sp_[0]) | |
| if key_ in around and outs[i_]: | |
| vs_ = [v for v in outs[i_] if _fits(around[key_][0], v) is not None] | |
| outs[i_] = [_detok(v, refs_of[i_]) for v in vs_] | |
| marked.update({(i_, r_): v for r_, v in zip(outs[i_], vs_)}) | |
| real_jobs = [(b_, sp_, t) for (b_, sp_, _), t in zip(jobs, texts)] | |
| rep(frac + 0.83 * 0.6 ** (rnd - 1) * 0.2, f"Round {rnd}: choosing the most natural version of each sentence") | |
| tc = time.time() | |
| # round 1: every flagged sentence takes its best valid version, even one that scores no lower on its own: a | |
| # window is only read as human when (nearly) all its sentences changed; on a 5,500-word document the "must | |
| # score lower" rule left 4 in 10 sentences as they were and the score stayed at 95% | |
| chosen, unfaithful = _choose(real_jobs, anchors, outs, glossary, _context(blocks), always=rnd <= ALWAYS_ROUNDS, | |
| fresh=fresh) | |
| tc = time.time() - tc | |
| done = 0 | |
| for i_, (b, (a, e), t), new, anc in sorted(zip(range(len(jobs)), jobs, chosen, anchors), | |
| key=lambda x: (id(x[1][0]), -x[1][1][0])): | |
| info = around.get((id(b), a)) | |
| if new and info: | |
| ok = _replace_around(b, info[0], marked.get((i_, new), new)) | |
| else: | |
| ok = bool(new) and S.replace_span(b.el, a, e, new) | |
| if ok: | |
| anchor[new.strip()] = anc | |
| replaced[anc.strip()] = new.strip() | |
| done += 1 | |
| ts = time.time() | |
| blocks = S._score_doc(doc) | |
| prev, stats = stats["pct"], D.summarise(blocks, hi, mod) | |
| rprev, rleft = rleft, report_left(blocks) | |
| ts = time.time() - ts | |
| last_lm, last_after = tg, tc + ts | |
| log.append(f"Round {rnd}: AI score {prev}% → {stats['pct']}% (rewrote {done} of {len(jobs)} sentences" | |
| + (f", {locked} locked" if locked else "") | |
| + (f"; {rleft} highlighted sentence(s) still close to the original" if also else "") + ")") | |
| print(f"[fast] round {rnd}: {len(jobs)} sentences, {done} rewritten, {unfaithful} version(s) rejected as " | |
| f"unfaithful, AI {prev}% -> {stats['pct']}% | " | |
| f"language model {tg:.1f}s, choosing {tc:.1f}s (" | |
| + ", ".join(f"{k} {v:.1f}s" for k, v in TIMES.items()) + f"), scoring {ts:.1f}s", flush=True) | |
| key = (stats["pct"], rleft) # lower AI score first, then fewer report sentences left | |
| if key < best_key: | |
| best, best_key = (stats["pct"], S._snapshot(doc), blocks), key | |
| best_replaced = dict(replaced) | |
| stalled = 0 | |
| else: | |
| stalled += 1 | |
| # at 0% only the report's sentences are being reworded: one round without progress is enough | |
| if stalled >= STALL or (stats["pct"] == 0 and stalled >= 1): | |
| log.append(f"Round {rnd}: stopped, {STALL} rounds without further improvement") | |
| break | |
| # at 0% with only the report's sentences left: a round that clears very few of them is not worth another | |
| # minute (a thesis part spent 5 of its 12 minutes taking 80 such sentences to 72) | |
| if also and prev == 0 and stats["pct"] == 0 and rprev - rleft < max(REPORT_MIN_CLEARED, 0.05 * rprev): | |
| log.append(f"Round {rnd}: stopped, few highlighted sentences left to reword further") | |
| break | |
| if stats["pct"] > best[0]: # this round made it worse: the next one starts from the best | |
| from docx import Document as _Doc | |
| doc = _Doc(io.BytesIO(best[1])) | |
| replaced = dict(best_replaced) | |
| blocks = S._score_doc(doc) | |
| stats = D.summarise(blocks, hi, mod) | |
| rleft = report_left(blocks) | |
| if not done: | |
| break | |
| from docx import Document | |
| doc = Document(io.BytesIO(best[1])) | |
| blocks = best[2] | |
| after = D.summarise(blocks, hi, mod) | |
| new_text = "\n".join(b.text for b in blocks if b.kind == "para") | |
| out = H._save_output(doc, S._clean_stem(path), "_humanized") | |
| secs = time.time() - t0 | |
| print(f"[fast] {first['pct']}% -> {after['pct']}% in {secs:.1f}s ({first['analysed']} words) | " | |
| f"{G.usage_line()}", flush=True) | |
| note = "⚡ Fast engine: every flagged sentence rewritten at once by the language model." + note | |
| left_ = [s_ for b_ in blocks if b_.kind == "para" for s_, p_ in zip(b_.sents, b_.scores) if p_ >= mod] | |
| final_of = dict(best_replaced) # the author's sentence -> its wording in the delivered file | |
| return dict(before=first, after=after, log=log, note=note, blocks=blocks, path=out, report=None, | |
| orig=orig_text, new=new_text, seconds=secs, zero_goal=True, left=len(left_), final_of=final_of) | |
| def humanize_text(text, hi=0.80, mod=0.50, rep=None, budget=None): | |
| """Pasted text: written to a Word file (one paragraph per paragraph), humanized, read back.""" | |
| import tempfile | |
| from docx import Document | |
| d = Document() | |
| for p in [p.strip() for p in (text or "").split("\n") if p.strip()]: | |
| d.add_paragraph(p) | |
| path = os.path.join(tempfile.mkdtemp(prefix="cs_fast_"), "Pasted_text.docx") | |
| d.save(path) | |
| res = humanize_docx(path, hi, mod, rep, budget) | |
| res["text"] = "\n\n".join(p.text for p in Document(res["path"]).paragraphs if p.text.strip()) | |
| return res | |
| def selftest(sizes=(1, 3, 8)): | |
| """FAST_SELFTEST=1 (Space variable): after start-up, time the engine on test documents of about 1,800 x size | |
| words (the subject samples; every copy gets different numbers, so no cache helps). Results go to the log.""" | |
| import random | |
| import re | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| from docx import Document | |
| sys.path.insert(0, str(Path(__file__).resolve().parent / "devtools")) | |
| from samples_domains import DOMAINS | |
| time.sleep(float(os.getenv("FAST_SELFTEST_DELAY", "60"))) # let start-up finish | |
| for size in sizes: | |
| rnd = random.Random(size) | |
| d = Document() | |
| d.add_heading(f"Self-test document x{size}", 1) | |
| for c in range(size): | |
| for k, v in DOMAINS.items(): | |
| d.add_heading(f"{k.title()} ({c + 1})", 2) | |
| d.add_paragraph(v if c == 0 else re.sub(r"\d+", lambda m: str(int(m.group()) + rnd.randint(1, 9)), v)) | |
| path = os.path.join(tempfile.mkdtemp(prefix="cs_selftest_"), f"selftest_x{size}.docx") | |
| d.save(path) | |
| try: | |
| r = humanize_docx(path, budget=float(os.getenv("FAST_SELFTEST_BUDGET", str(BUDGET)))) | |
| print(f"[selftest] x{size}: {r['before']['analysed']} words, AI {r['before']['pct']}% -> " | |
| f"{r['after']['pct']}% in {r['seconds']:.1f}s | " + " | ".join(r["log"]), flush=True) | |
| except Exception as err: | |
| print(f"[selftest] x{size}: failed: {type(err).__name__}: {err}", flush=True) | |