Rasheed commited on
Commit
aa68510
·
verified ·
1 Parent(s): 796d3f7

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +27 -10
  2. report_flags.py +175 -0
  3. smart.py +29 -2
app.py CHANGED
@@ -26,6 +26,7 @@ import dashboard as DB
26
  import plans as P
27
  import time
28
  import site_pages as SP
 
29
  from humanizer import BRAND, RAINBOW, LOGO_SRC, FAVICON_SRC, FAVICON_PATH, MODES, OUT_DIR
30
 
31
  APP_NAME = os.getenv("APP_NAME", "CS AI Writing Suite")
@@ -258,8 +259,19 @@ def _smart_result(res, hi, mod, text_mode, rep=None, name=""):
258
  return tuple(out)
259
 
260
 
261
- def _smart_text_core(text, target, passes, temp, mode, hi, mod, full_ai, vocab, rep):
262
- H.FULL_AI.set(bool(full_ai)); H.VOCAB_TARGET.set(float(vocab or 0))
 
 
 
 
 
 
 
 
 
 
 
263
  text = (text or "").strip()
264
  if not text:
265
  raise gr.Error("Please paste some text.")
@@ -267,23 +279,23 @@ def _smart_text_core(text, target, passes, temp, mode, hi, mod, full_ai, vocab,
267
  return _smart_result(res, hi, mod, True, rep, "Pasted text")
268
 
269
 
270
- def _smart_docx_core(path, target, passes, temp, mode, hi, mod, full_ai, vocab, rep):
271
- H.FULL_AI.set(bool(full_ai)); H.VOCAB_TARGET.set(float(vocab or 0))
272
  if not path or not str(path).lower().endswith(".docx"):
273
  raise gr.Error("Please upload a Word (.docx) file.")
274
  res = S.smart_docx(path, target, passes, temp, mode, hi, mod, rep)
275
  return _smart_result(res, hi, mod, False, rep, os.path.basename(str(path)))
276
 
277
 
278
- def smart_text(text, target, passes, temp, mode, hi, mod, full_ai, vocab, user, request: gr.Request = None):
279
  passes = P.max_passes(user, passes)
280
- yield from _stream(_smart_text_core, (text, target, passes, temp, mode, hi, mod, bool(full_ai), vocab), 6, 0, user, request=request,
281
  tool="Smart Humanize", paste=text)
282
 
283
 
284
- def smart_docx(path, target, passes, temp, mode, hi, mod, full_ai, vocab, user, request: gr.Request = None):
285
  passes = P.max_passes(user, passes)
286
- yield from _stream(_smart_docx_core, (path, target, passes, temp, mode, hi, mod, bool(full_ai), vocab), 5, 0, user, True, request,
287
  tool="Smart Humanize", words=P.doc_words(path) if path else None)
288
 
289
 
@@ -1222,6 +1234,11 @@ with gr.Blocks(title=f"{APP_NAME} · CS Software Solutions", theme=theme, css=CS
1222
  "and checked by Groq. Slower. "
1223
  "Leave it off for your own writing or lightly edited AI text.",
1224
  elem_classes="cs-fullai")
 
 
 
 
 
1225
  s_hi, s_mod = thresholds()
1226
  with gr.Tabs(elem_classes="sub-tabs"):
1227
  with gr.Tab("✍️ Paste text · free"):
@@ -1685,9 +1702,9 @@ with gr.Blocks(title=f"{APP_NAME} · CS Software Solutions", theme=theme, css=CS
1685
  for _cb, _sl in ((s_full, s_vocab), (hp_full, hp_vocab), (hd_full, hd_vocab)):
1686
  _cb.change(lambda v: gr.update(value=70 if v else 0), _cb, _sl, queue=False, **HID)
1687
  sp_in.change(count_words_for, [sp_in, user_state], sp_count, queue=False, **HID)
1688
- sp_run = sp_btn.click(lambda: WAIT, None, sp_status, queue=False, **HID).then(smart_text, [sp_in, s_target, s_passes, s_temp, s_mode, s_hi, s_mod, s_full, s_vocab, user_state],
1689
  [sp_status, sp_out, sp_prev, sp_scores, sp_file, sp_dl], **JOB(sp_status))
1690
- sd_run = sd_btn.click(lambda: WAIT, None, sd_status, queue=False, **HID).then(smart_docx, [sd_in, s_target, s_passes, s_temp, s_mode, s_hi, s_mod, s_full, s_vocab, user_state],
1691
  [sd_status, sd_prev, sd_scores, sd_file, sd_dl], **JOB(sd_status))
1692
 
1693
  # detector
 
26
  import plans as P
27
  import time
28
  import site_pages as SP
29
+ import report_flags as RF
30
  from humanizer import BRAND, RAINBOW, LOGO_SRC, FAVICON_SRC, FAVICON_PATH, MODES, OUT_DIR
31
 
32
  APP_NAME = os.getenv("APP_NAME", "CS AI Writing Suite")
 
259
  return tuple(out)
260
 
261
 
262
+ def _use_report(report):
263
+ """Uploaded AI / similarity report -> only the sentences it flags are rewritten (None = normal run)."""
264
+ if not report:
265
+ RF.ACTIVE.set(None)
266
+ return
267
+ try:
268
+ RF.ACTIVE.set(RF.load(report))
269
+ except RF.ReportError as err:
270
+ raise gr.Error(str(err))
271
+
272
+
273
+ def _smart_text_core(text, target, passes, temp, mode, hi, mod, full_ai, vocab, report, rep):
274
+ H.FULL_AI.set(bool(full_ai)); H.VOCAB_TARGET.set(float(vocab or 0)); _use_report(report)
275
  text = (text or "").strip()
276
  if not text:
277
  raise gr.Error("Please paste some text.")
 
279
  return _smart_result(res, hi, mod, True, rep, "Pasted text")
280
 
281
 
282
+ def _smart_docx_core(path, target, passes, temp, mode, hi, mod, full_ai, vocab, report, rep):
283
+ H.FULL_AI.set(bool(full_ai)); H.VOCAB_TARGET.set(float(vocab or 0)); _use_report(report)
284
  if not path or not str(path).lower().endswith(".docx"):
285
  raise gr.Error("Please upload a Word (.docx) file.")
286
  res = S.smart_docx(path, target, passes, temp, mode, hi, mod, rep)
287
  return _smart_result(res, hi, mod, False, rep, os.path.basename(str(path)))
288
 
289
 
290
+ def smart_text(text, target, passes, temp, mode, hi, mod, full_ai, vocab, report, user, request: gr.Request = None):
291
  passes = P.max_passes(user, passes)
292
+ yield from _stream(_smart_text_core, (text, target, passes, temp, mode, hi, mod, bool(full_ai), vocab, report), 6, 0, user, request=request,
293
  tool="Smart Humanize", paste=text)
294
 
295
 
296
+ def smart_docx(path, target, passes, temp, mode, hi, mod, full_ai, vocab, report, user, request: gr.Request = None):
297
  passes = P.max_passes(user, passes)
298
+ yield from _stream(_smart_docx_core, (path, target, passes, temp, mode, hi, mod, bool(full_ai), vocab, report), 5, 0, user, True, request,
299
  tool="Smart Humanize", words=P.doc_words(path) if path else None)
300
 
301
 
 
1234
  "and checked by Groq. Slower. "
1235
  "Leave it off for your own writing or lightly edited AI text.",
1236
  elem_classes="cs-fullai")
1237
+ s_report = gr.File(label="📑 Have an AI or similarity report? Upload it (optional)", file_types=[".pdf", ".docx"],
1238
+ type="filepath", elem_classes="cs-report")
1239
+ gr.HTML('<div class="cs-gauge-cap" style="text-align:left;margin:-4px 2px 4px">Turnitin, iThenticate, GPTZero, '
1240
+ 'ZeroGPT, Copyleaks or any other checker: upload the report PDF as downloaded. Only the sentences '
1241
+ 'the report highlights are rewritten; everything else stays exactly as it is.</div>')
1242
  s_hi, s_mod = thresholds()
1243
  with gr.Tabs(elem_classes="sub-tabs"):
1244
  with gr.Tab("✍️ Paste text · free"):
 
1702
  for _cb, _sl in ((s_full, s_vocab), (hp_full, hp_vocab), (hd_full, hd_vocab)):
1703
  _cb.change(lambda v: gr.update(value=70 if v else 0), _cb, _sl, queue=False, **HID)
1704
  sp_in.change(count_words_for, [sp_in, user_state], sp_count, queue=False, **HID)
1705
+ sp_run = sp_btn.click(lambda: WAIT, None, sp_status, queue=False, **HID).then(smart_text, [sp_in, s_target, s_passes, s_temp, s_mode, s_hi, s_mod, s_full, s_vocab, s_report, user_state],
1706
  [sp_status, sp_out, sp_prev, sp_scores, sp_file, sp_dl], **JOB(sp_status))
1707
+ sd_run = sd_btn.click(lambda: WAIT, None, sd_status, queue=False, **HID).then(smart_docx, [sd_in, s_target, s_passes, s_temp, s_mode, s_hi, s_mod, s_full, s_vocab, s_report, user_state],
1708
  [sd_status, sd_prev, sd_scores, sd_file, sd_dl], **JOB(sd_status))
1709
 
1710
  # detector
report_flags.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Report-guided rewriting: read an AI-detection or similarity report (Turnitin, iThenticate, GPTZero, ZeroGPT,
3
+ Copyleaks, our own CS report, ...) and find the passages it FLAGS, whatever the tool. A passage counts as flagged
4
+ when the report marks it in any of the usual ways:
5
+
6
+ * highlight / underline / squiggly annotations (PDF)
7
+ * coloured text (similarity reports colour matched text by source)
8
+ * coloured shading drawn behind the text (AI-writing reports shade flagged sentences)
9
+ * Word reports (.docx): highlighted, shaded or coloured runs
10
+
11
+ The flagged words are then matched to the sentences of the customer's document by word overlap (line breaks,
12
+ hyphenation and page furniture in the report do not matter). Smart Humanize then rewrites exactly those
13
+ sentences and nothing else.
14
+ """
15
+ import contextvars
16
+ import re
17
+
18
+ MIN_SHARE = 0.5 # a sentence is flagged when at least half of its word trigrams were marked in the report
19
+ _WORD = re.compile(r"[A-Za-z0-9]+(?:['’][A-Za-z]+)?")
20
+
21
+ # set per job by the app when a report is uploaded (None = no report: normal Smart Humanize)
22
+ ACTIVE = contextvars.ContextVar("report_flags", default=None)
23
+
24
+
25
+ class ReportError(Exception):
26
+ """The report could not be used (no flagged passages found, scanned image, wrong file)."""
27
+
28
+
29
+ def _norm_words(text):
30
+ text = re.sub(r"(\w)-\s*\n\s*(\w)", r"\1\2", text or "") # de-hyphenate line breaks
31
+ return [w.lower().replace("’", "'") for w in _WORD.findall(text)]
32
+
33
+
34
+ def _trigrams(words):
35
+ return {tuple(words[i:i + 3]) for i in range(len(words) - 2)}
36
+
37
+
38
+ def _colourful(rgb, min_sat=0.12):
39
+ """True for a real colour (not black, white or grey)."""
40
+ if not rgb:
41
+ return False
42
+ r, g, b = rgb[:3]
43
+ return max(r, g, b) - min(r, g, b) >= min_sat
44
+
45
+
46
+ def _int_rgb(c):
47
+ return ((c >> 16) & 255) / 255, ((c >> 8) & 255) / 255, (c & 255) / 255
48
+
49
+
50
+ def _pdf_runs(path):
51
+ """Runs of flagged words from a PDF report (each run = consecutive marked words, in reading order)."""
52
+ import fitz # PyMuPDF
53
+ try:
54
+ pdf = fitz.open(path)
55
+ except Exception as err:
56
+ raise ReportError(f"Could not open the report: {err}")
57
+ runs, n_words = [], 0
58
+ for page in pdf:
59
+ words = page.get_text("words") # x0, y0, x1, y1, text, block, line, word_no
60
+ n_words += len(words)
61
+ if not words:
62
+ continue
63
+ area = page.rect.width * page.rect.height
64
+ marks = [] # rectangles that mark text
65
+ for a in page.annots() or []:
66
+ if a.type[0] in (8, 9, 10): # highlight, underline, squiggly
67
+ v = a.vertices or []
68
+ if len(v) >= 4:
69
+ for i in range(0, len(v) - 3, 4):
70
+ marks.append(fitz.Quad(v[i:i + 4]).rect)
71
+ else:
72
+ marks.append(a.rect)
73
+ for d in page.get_drawings(): # shading drawn behind flagged text
74
+ fill = d.get("fill")
75
+ r = d.get("rect")
76
+ if fill and r is not None and _colourful(fill, 0.08) and r.width * r.height < 0.5 * area \
77
+ and r.height < 60:
78
+ marks.append(r)
79
+ coloured = set() # coloured text spans (similarity reports)
80
+ for blk in page.get_text("dict").get("blocks", []):
81
+ for line in blk.get("lines", []):
82
+ for sp in line.get("spans", []):
83
+ if sp.get("text", "").strip() and _colourful(_int_rgb(sp.get("color", 0))):
84
+ coloured.add(tuple(round(x, 1) for x in sp["bbox"]))
85
+ col_rects = [fitz.Rect(b) for b in coloured]
86
+
87
+ def marked(w):
88
+ cx, cy = (w[0] + w[2]) / 2, (w[1] + w[3]) / 2
89
+ p = fitz.Point(cx, cy)
90
+ return any(r.contains(p) for r in marks) or any(r.contains(p) for r in col_rects)
91
+
92
+ cur = []
93
+ for w in sorted(words, key=lambda w: (w[5], w[6], w[7])):
94
+ if marked(w):
95
+ cur.append(w[4])
96
+ elif cur:
97
+ runs.append(" ".join(cur))
98
+ cur = []
99
+ if cur:
100
+ runs.append(" ".join(cur))
101
+ if n_words == 0:
102
+ raise ReportError("This report has no readable text (it looks like a scan or image). Download the report "
103
+ "as a PDF from the checker's website and upload that file.")
104
+ return runs
105
+
106
+
107
+ def _docx_runs(path):
108
+ """Runs of flagged text from a Word report: highlighted, shaded or coloured runs."""
109
+ from docx import Document
110
+ from docx.oxml.ns import qn
111
+ runs = []
112
+ for p in Document(path).paragraphs:
113
+ cur = []
114
+ for r in p.runs:
115
+ rpr = r._r.find(qn("w:rPr"))
116
+ flag = False
117
+ if rpr is not None:
118
+ hl = rpr.find(qn("w:highlight"))
119
+ shd = rpr.find(qn("w:shd"))
120
+ col = rpr.find(qn("w:color"))
121
+ flag = (hl is not None and hl.get(qn("w:val")) not in (None, "none")) or \
122
+ (shd is not None and (shd.get(qn("w:fill")) or "auto").lower() not in ("auto", "ffffff")) or \
123
+ (col is not None and _colourful(_hex_rgb(col.get(qn("w:val")))))
124
+ if flag and r.text.strip():
125
+ cur.append(r.text)
126
+ elif cur:
127
+ runs.append(" ".join(cur))
128
+ cur = []
129
+ if cur:
130
+ runs.append(" ".join(cur))
131
+ return runs
132
+
133
+
134
+ def _hex_rgb(h):
135
+ try:
136
+ h = (h or "").strip()
137
+ return int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255
138
+ except Exception:
139
+ return None
140
+
141
+
142
+ class Flags:
143
+ """The flagged passages of one report; .match(sentence) says whether a document sentence was flagged."""
144
+
145
+ def __init__(self, runs):
146
+ self.runs = [r for r in runs if len(_norm_words(r)) >= 3]
147
+ self.tri = set()
148
+ for r in self.runs:
149
+ self.tri |= _trigrams(_norm_words(r))
150
+ self.matched = 0
151
+
152
+ def match(self, sentence):
153
+ t = _trigrams(_norm_words(sentence))
154
+ if not t:
155
+ return False
156
+ hit = sum(1 for x in t if x in self.tri) / len(t) >= MIN_SHARE
157
+ self.matched += hit
158
+ return hit
159
+
160
+
161
+ def load(path):
162
+ """Read a report file -> Flags. Raises ReportError with a customer-friendly message."""
163
+ p = str(path or "")
164
+ low = p.lower()
165
+ if low.endswith(".pdf"):
166
+ runs = _pdf_runs(p)
167
+ elif low.endswith(".docx"):
168
+ runs = _docx_runs(p)
169
+ else:
170
+ raise ReportError("Please upload the report as a PDF (or a Word .docx with the flagged text highlighted).")
171
+ flags = Flags(runs)
172
+ if not flags.tri:
173
+ raise ReportError("No highlighted or flagged passages were found in this report. Upload the full report PDF "
174
+ "as downloaded from the checker (the version with the coloured / highlighted text).")
175
+ return flags
smart.py CHANGED
@@ -19,6 +19,7 @@ import detector as D
19
  import textparse as TP
20
  import docx_report as DX
21
  import humanizer as H
 
22
 
23
  W_T = qn("w:t")
24
  _XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space"
@@ -191,6 +192,12 @@ def _target_change():
191
 
192
 
193
  def _done_early(k, pct, target, chg):
 
 
 
 
 
 
194
  """Normal mode: stop when the AI score is 0 or (from pass 2) at/below the target.
195
  100% AI mode: at least one rewrite, and the new-wording target must also be reached."""
196
  if not _all_sentences():
@@ -204,6 +211,8 @@ def _better(cand, best, k):
204
  then the lowest AI score; below the target, the version with the most new wording wins."""
205
  if best is None:
206
  return True
 
 
207
  if not _all_sentences():
208
  return cand[0] < best[0] or (k > 1 and cand[0] == best[0])
209
  if best[1] is None: # best is still the untouched original
@@ -275,6 +284,9 @@ def smart_docx(path, target, passes, temperature, mode, hi, mod, rep):
275
  log, first, note = [], None, ""
276
  orig_text = "\n".join(b.text for b in D.docx_blocks(doc) if b.kind == "para")
277
  TP.GLOSSARY.set(TP.build_glossary([orig_text])) # the document's key terms stay untouched
 
 
 
278
  best = None # (pct, docx bytes, scored blocks)
279
  step = 0.8 / max(1, passes)
280
  for k in range(1, int(passes) + 2): # last iteration = final check only
@@ -299,12 +311,18 @@ def smart_docx(path, target, passes, temperature, mode, hi, mod, rep):
299
  if b.kind != "para" or b.el is None:
300
  continue
301
  for sp, s, p in zip(b.cspans, b.sents, b.scores):
302
- if sp and (full or p >= mod) and len(s.split()) >= 5:
 
303
  sub = safe_subspan(b.el, *sp)
304
  if sub:
305
  jobs.append((b, sub, b.raw[sub[0]:sub[1]]))
306
  else:
307
  locked += 1
 
 
 
 
 
308
  if not jobs:
309
  log.append(_log_line(k, stats["pct"]) + f" — no rewritable sentences left ({locked} locked)")
310
  break
@@ -359,6 +377,9 @@ def smart_text(text, target, passes, temperature, mode, hi, mod, rep):
359
  paras = [p for p, _ in parts]
360
  orig = TP.join_paste(parts)
361
  log, first, note, best = [], None, "", None # best = (pct, paras, blocks)
 
 
 
362
  step = 0.8 / max(1, passes)
363
  for k in range(1, int(passes) + 2):
364
  base = 0.05 + step * (k - 1)
@@ -377,7 +398,13 @@ def smart_text(text, target, passes, temperature, mode, hi, mod, rep):
377
  break
378
  full = _all_sentences() # 100% AI box / vocabulary level: every sentence
379
  jobs = [(bi, si, s) for bi, b in enumerate(blocks) if b.kind == "para"
380
- for si, (s, p) in enumerate(zip(b.sents, b.scores)) if (full or p >= mod) and len(s.split()) >= 5]
 
 
 
 
 
 
381
  if not jobs:
382
  log.append(_log_line(k, stats["pct"]) + " — no rewritable sentences left")
383
  break
 
19
  import textparse as TP
20
  import docx_report as DX
21
  import humanizer as H
22
+ import report_flags as R
23
 
24
  W_T = qn("w:t")
25
  _XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space"
 
192
 
193
 
194
  def _done_early(k, pct, target, chg):
195
+ if R.ACTIVE.get() is not None: # report-guided: always rewrite what the report flagged
196
+ return False
197
+ return _done_early_inner(k, pct, target, chg)
198
+
199
+
200
+ def _done_early_inner(k, pct, target, chg):
201
  """Normal mode: stop when the AI score is 0 or (from pass 2) at/below the target.
202
  100% AI mode: at least one rewrite, and the new-wording target must also be reached."""
203
  if not _all_sentences():
 
211
  then the lowest AI score; below the target, the version with the most new wording wins."""
212
  if best is None:
213
  return True
214
+ if R.ACTIVE.get() is not None: # report-guided: the rewritten version is the result
215
+ return k > 1
216
  if not _all_sentences():
217
  return cand[0] < best[0] or (k > 1 and cand[0] == best[0])
218
  if best[1] is None: # best is still the untouched original
 
284
  log, first, note = [], None, ""
285
  orig_text = "\n".join(b.text for b in D.docx_blocks(doc) if b.kind == "para")
286
  TP.GLOSSARY.set(TP.build_glossary([orig_text])) # the document's key terms stay untouched
287
+ rpt = R.ACTIVE.get() # uploaded AI / similarity report: rewrite only what it flags
288
+ if rpt is not None:
289
+ passes = 1
290
  best = None # (pct, docx bytes, scored blocks)
291
  step = 0.8 / max(1, passes)
292
  for k in range(1, int(passes) + 2): # last iteration = final check only
 
311
  if b.kind != "para" or b.el is None:
312
  continue
313
  for sp, s, p in zip(b.cspans, b.sents, b.scores):
314
+ pick = rpt.match(s) if rpt is not None else (full or p >= mod)
315
+ if sp and pick and len(s.split()) >= 5:
316
  sub = safe_subspan(b.el, *sp)
317
  if sub:
318
  jobs.append((b, sub, b.raw[sub[0]:sub[1]]))
319
  else:
320
  locked += 1
321
+ if not jobs and rpt is not None:
322
+ raise gr.Error("None of the sentences in your document match the passages flagged in the report. "
323
+ "Check that the report belongs to this document.")
324
+ if rpt is not None:
325
+ log.append(f"Report: {len(jobs) + locked} of your sentences match passages the report flagged")
326
  if not jobs:
327
  log.append(_log_line(k, stats["pct"]) + f" — no rewritable sentences left ({locked} locked)")
328
  break
 
377
  paras = [p for p, _ in parts]
378
  orig = TP.join_paste(parts)
379
  log, first, note, best = [], None, "", None # best = (pct, paras, blocks)
380
+ rpt = R.ACTIVE.get() # uploaded AI / similarity report: rewrite only what it flags
381
+ if rpt is not None:
382
+ passes = 1
383
  step = 0.8 / max(1, passes)
384
  for k in range(1, int(passes) + 2):
385
  base = 0.05 + step * (k - 1)
 
398
  break
399
  full = _all_sentences() # 100% AI box / vocabulary level: every sentence
400
  jobs = [(bi, si, s) for bi, b in enumerate(blocks) if b.kind == "para"
401
+ for si, (s, p) in enumerate(zip(b.sents, b.scores))
402
+ if (rpt.match(s) if rpt is not None else (full or p >= mod)) and len(s.split()) >= 5]
403
+ if rpt is not None:
404
+ if not jobs:
405
+ raise gr.Error("None of your sentences match the passages flagged in the report. "
406
+ "Check that the report belongs to this text.")
407
+ log.append(f"Report: {len(jobs)} of your sentences match passages the report flagged")
408
  if not jobs:
409
  log.append(_log_line(k, stats["pct"]) + " — no rewritable sentences left")
410
  break