fetchit-engine 0.1.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,43 +1,43 @@
1
- Metadata-Version: 2.4
2
- Name: fetchit-engine
3
- Version: 0.1.2
4
- Summary: Deterministic text cleanup and AI-writing heuristics. Runs entirely in your process; text never leaves it.
5
- Author: Outta Work Solutions
6
- License-Expression: Apache-2.0
7
- Project-URL: Homepage, https://fetchitai.com/developers
8
- Project-URL: Repository, https://github.com/OuttaWorkSolutions/fetchit-engine
9
- Project-URL: Changelog, https://github.com/OuttaWorkSolutions/fetchit-engine/blob/main/CHANGELOG.md
10
- Project-URL: Issues, https://github.com/OuttaWorkSolutions/fetchit-engine/issues
11
- Keywords: ai text,cleanup,invisible characters,em dash,text hygiene
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Operating System :: OS Independent
14
- Classifier: Topic :: Text Processing :: Linguistic
15
- Requires-Python: >=3.8
16
- Description-Content-Type: text/markdown
17
- License-File: LICENSE
18
- License-File: NOTICE
19
- Provides-Extra: spell
20
- Requires-Dist: pyspellchecker>=0.7; extra == "spell"
21
- Dynamic: license-file
22
-
23
- # fetchit-engine
24
-
25
- Deterministic text cleanup and AI-writing heuristics that run entirely in your
26
- process. Your users text never leaves it. Pure standard library, no dependencies.
27
-
28
- ```python
29
- from fetchit_engine import clean, apply_edits
30
-
31
- r = clean(ai_draft, {"mode": "auto"}) # apply only auto-safe edits unattended
32
- publish(r["cleaned"]["text"])
33
- if r["aiReport"].get("level") == "high":
34
- review_queue.put(ai_draft, r) # hand a human the full result
35
- ```
36
-
37
- Offsets are code points. See the [repository README](https://github.com/OuttaWorkSolutions/fetchit-engine#the-design-in-four-claims)
38
- for the full CleanResult contract, shared 1:1 with @fetchitai/engine (JavaScript).
39
-
40
-
41
- ## License
42
-
43
- Apache-2.0. Free for everyone, including commercial use.
1
+ Metadata-Version: 2.4
2
+ Name: fetchit-engine
3
+ Version: 0.3.0
4
+ Summary: Deterministic text cleanup and AI-writing heuristics. Runs entirely in your process; text never leaves it.
5
+ Author: Outta Work Solutions
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://fetchitai.com/developers
8
+ Project-URL: Repository, https://github.com/OuttaWorkSolutions/fetchit-engine
9
+ Project-URL: Changelog, https://github.com/OuttaWorkSolutions/fetchit-engine/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/OuttaWorkSolutions/fetchit-engine/issues
11
+ Keywords: ai text,cleanup,invisible characters,em dash,text hygiene
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Text Processing :: Linguistic
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ License-File: NOTICE
19
+ Provides-Extra: spell
20
+ Requires-Dist: pyspellchecker>=0.7; extra == "spell"
21
+ Dynamic: license-file
22
+
23
+ # fetchit-engine
24
+
25
+ Deterministic text cleanup and AI-writing heuristics that run entirely in your
26
+ process. Your users text never leaves it. Pure standard library, no dependencies.
27
+
28
+ ```python
29
+ from fetchit_engine import clean, apply_edits
30
+
31
+ r = clean(ai_draft, {"mode": "auto"}) # apply only auto-safe edits unattended
32
+ publish(r["cleaned"]["text"])
33
+ if r["aiReport"].get("level") == "high":
34
+ review_queue.put(ai_draft, r) # hand a human the full result
35
+ ```
36
+
37
+ Offsets are code points. See the [repository README](https://github.com/OuttaWorkSolutions/fetchit-engine#the-design-in-four-claims)
38
+ for the full CleanResult contract, shared 1:1 with @fetchitai/engine (JavaScript).
39
+
40
+
41
+ ## License
42
+
43
+ Apache-2.0. Free for everyone, including commercial use.
@@ -15,7 +15,7 @@ import json
15
15
  import os
16
16
  import re
17
17
 
18
- ENGINE_VERSION = "0.1.2"
18
+ ENGINE_VERSION = "0.3.0"
19
19
 
20
20
  # --- ruleset (single source of truth, shared with the JS package) -----------
21
21
  _RULESET_PATH = os.path.join(
@@ -36,6 +36,14 @@ _INVISIBLE_RANGES = RULESET["invisibleRanges"]
36
36
  _ODD_SPACE_RANGES = RULESET["oddSpaceRanges"]
37
37
  _ODD_SPACE_RULE_ID = RULESET["oddSpaceRuleId"]
38
38
  _ODD_SPACE_LABEL = RULESET["oddSpaceLabel"]
39
+ _CONFUSABLE_RULE_ID = RULESET["confusableRuleId"]
40
+ _CONFUSABLE_LABEL = RULESET["confusableLabel"]
41
+ _TYPOGRAPHY_RULE_ID = RULESET["typographyRuleId"]
42
+ _TYPOGRAPHY_LABEL = RULESET["typographyLabel"]
43
+ # code point -> replacement, built once from the shared tables so both
44
+ # languages derive the same lookup from the same data.
45
+ _CONFUSABLES = {c["cp"]: c["to"] for c in RULESET["confusables"]}
46
+ _TYPOGRAPHY = {c["cp"]: c["to"] for c in RULESET["typography"]}
39
47
  _T = RULESET["thresholds"]
40
48
  MIN_CHARS = _T["MIN_CHARS"]
41
49
  MIN_WORDS = _T["MIN_WORDS"]
@@ -43,10 +51,12 @@ _LEVEL_MODERATE = _T["levelModerate"]
43
51
  _LEVEL_HIGH = _T["levelHigh"]
44
52
 
45
53
  # --- regexes that are logic, not data (kept in code; may contain dashes) -----
46
- # Em dash (U+2014) / horizontal bar (U+2015) in any spacing; en dash (U+2013)
47
- # only when spaced on both sides. Only spaces/tabs are consumed so line breaks
48
- # survive. Mirrors _EM_DASH in text_tools.py.
49
- _EM_DASH = re.compile(r"[ \t]*[—―][ \t]*|[ \t]+–[ \t]+")
54
+ # A RUN of em dashes (U+2014) / horizontal bars (U+2015), optionally
55
+ # space-separated, is one match, so "a——b" produces one comma, not two edits;
56
+ # an en dash (U+2013) matches only when spaced on both sides, so numeric
57
+ # ranges like 3–5 survive. Only spaces/tabs are consumed so line breaks
58
+ # survive. Mirrors EM_DASH_RE in the JS engine.
59
+ _EM_DASH = re.compile(r"[ \t]*[—―](?:[ \t]*[—―])*[ \t]*|[ \t]+–[ \t]+")
50
60
  _MULTI_SPACE = re.compile(r"[ \t]{2,}")
51
61
  _SPACE_BEFORE_PUNCT = re.compile(r"[ \t]+([,.;:!?])")
52
62
 
@@ -57,8 +67,16 @@ _RULE_OF_THREE = re.compile(
57
67
  r"\b[A-Za-z]+,\s+[A-Za-z]+,\s+and\s+[A-Za-z]+\b", re.IGNORECASE | re.ASCII
58
68
  )
59
69
  _LIST_MARKER = re.compile(r"^\s*(?:[-*•·]|\d+[.)])\s+", re.MULTILINE | re.ASCII)
60
- _CONTRACTION = re.compile(r"\b[A-Za-z]+'(?:t|s|re|ve|ll|d|m)\b", re.IGNORECASE | re.ASCII)
61
- _WORD = re.compile(r"[A-Za-z']+")
70
+ # U+2019 is included deliberately. Word processors and AI assistants emit the
71
+ # curly apostrophe, and matching only the straight one made this signal report
72
+ # "almost no contractions" on prose that was full of them, inflating the score
73
+ # by 10 points, while _WORD split "don’t" into two words.
74
+ _CONTRACTION = re.compile(r"\b[A-Za-z]+['’](?:t|s|re|ve|ll|d|m)\b", re.IGNORECASE | re.ASCII)
75
+ _WORD = re.compile(r"[A-Za-z'’]+")
76
+ # Markdown that survived a paste out of a chat window into running prose.
77
+ _MARKDOWN = re.compile(
78
+ r"\*\*[^*\n]+\*\*|__[^_\n]+__|^#{1,6}[ \t]|\[[^\]\n]+\]\([^)\n]+\)", re.MULTILINE
79
+ )
62
80
  # Sentence split: same as the JS splitSentences(). ASCII whitespace only.
63
81
  _SENTENCE_SPLIT = re.compile(r"(?<=[.!?])[ \t\n\r\f\v]+", re.ASCII)
64
82
  # One shared whitespace set for trimming and word splitting, identical in both
@@ -85,14 +103,62 @@ def _split_ws(s):
85
103
  return [w for w in re.split(r"[ \t\n\r\f\v]+", s) if w]
86
104
 
87
105
  # Rule metadata for edits produced by the dash/space normalization passes.
88
- _DASH_RULE = ("dash.spaced", "dash", "Replaced a spaced dash with a space")
106
+ _DASH_RULE_ID = "dash.spaced"
107
+ _DASH_COMMA_RULE = (_DASH_RULE_ID, "dash", "Replaced a dash with a comma")
108
+ _DASH_SPACE_RULE = (_DASH_RULE_ID, "dash", "Replaced a spaced dash with a space")
109
+
110
+
111
+ def _dash_rule_for(rep):
112
+ return _DASH_COMMA_RULE if rep[:1] == "," else _DASH_SPACE_RULE
113
+
114
+
89
115
  _COLLAPSE_RULE = ("space.collapse", "space", "Collapsed repeated spaces")
116
+ _CONFUSABLE_RULE = (_CONFUSABLE_RULE_ID, "homoglyph", "Replaced a " + _CONFUSABLE_LABEL)
117
+ _TYPOGRAPHY_RULE = (_TYPOGRAPHY_RULE_ID, "typography", "Normalized a " + _TYPOGRAPHY_LABEL)
90
118
  _SPACE_BEFORE_RULE = ("punct.space-before", "space", "Removed a space before punctuation")
91
119
 
120
+ # What a matched dash run becomes. A clause dash reads as a pause, so the
121
+ # default replacement is a comma. The comma is withheld (single space instead,
122
+ # the old behavior, tidied by the collapse and space-before-punct passes) when
123
+ # a comma cannot sit there: at a text or line boundary, next to punctuation or
124
+ # a bracket it would double up against, next to a dash the match could not
125
+ # consume, or between digits, where the dash is a range rather than a pause.
126
+ # When the next character is whitespace the comma takes no trailing space, so
127
+ # it hugs the word before a line break. Mirrors dashReplacement in the JS
128
+ # engine.
129
+ _DASH_CP = frozenset((0x2014, 0x2015, 0x2013))
130
+ _NO_COMMA_BEFORE = frozenset(ord(c) for c in ",.;:!?([{")
131
+ _NO_COMMA_AFTER = frozenset(ord(c) for c in ",.;:!?)]}")
132
+
133
+
134
+ def _cp_before(s, i):
135
+ return ord(s[i - 1]) if i > 0 else -1
136
+
137
+
138
+ def _cp_after(s, i):
139
+ return ord(s[i]) if i < len(s) else -1
140
+
141
+
142
+ def _dash_replacement(before_cp, after_cp):
143
+ blocked_before = (before_cp < 0 or before_cp in _WS
144
+ or before_cp in _NO_COMMA_BEFORE or before_cp in _DASH_CP)
145
+ blocked_after = after_cp < 0 or after_cp in _NO_COMMA_AFTER or after_cp in _DASH_CP
146
+ if blocked_before or blocked_after:
147
+ return " "
148
+ if 0x30 <= before_cp <= 0x39 and 0x30 <= after_cp <= 0x39:
149
+ return " "
150
+ return "," if after_cp in _WS else ", "
151
+
152
+
153
+ def _dash_match_replacement(m):
154
+ return _dash_replacement(_cp_before(m.string, m.start()), _cp_after(m.string, m.end()))
155
+
92
156
  # Priority when several passes touch the same characters and their cells merge.
93
157
  # Higher wins the label. Dash beats space cleanup beats invisible.
94
158
  _RULE_PRIORITY = {
159
+ "homoglyph.mixed-script": 45,
95
160
  "dash.spaced": 40,
161
+ "typography.smart": 35,
96
162
  "space.lookalike": 30,
97
163
  "space.collapse": 20,
98
164
  "punct.space-before": 15,
@@ -181,14 +247,22 @@ def _regex_pass(cells, regex, replacement, rule):
181
247
  merged = cells[ci:cj]
182
248
  src0 = merged[0].src0
183
249
  src1 = merged[-1].src1
250
+ if callable(replacement):
251
+ rep = replacement(m)
252
+ elif "\\" in replacement:
253
+ rep = m.expand(replacement)
254
+ else:
255
+ rep = replacement
256
+ # `rule` may depend on the replacement chosen (the dash pass labels
257
+ # comma and space outcomes differently while keeping one ruleId).
258
+ base = rule(rep) if callable(rule) else rule
184
259
  # if any merged cell already carried a higher-priority rule, keep it
185
- best = rule
186
- best_pri = _RULE_PRIORITY.get(rule[0], 0)
260
+ best = base
261
+ best_pri = _RULE_PRIORITY.get(base[0], 0)
187
262
  for c in merged:
188
263
  if c.rule and _RULE_PRIORITY.get(c.rule[0], 0) > best_pri:
189
264
  best = c.rule
190
265
  best_pri = _RULE_PRIORITY.get(c.rule[0], 0)
191
- rep = m.expand(replacement) if "\\" in replacement else replacement
192
266
  out.append(_Cell(rep, src0, src1, best))
193
267
  last = cj
194
268
  changed = True
@@ -208,6 +282,8 @@ def _build_cells(text, disabled=frozenset()):
208
282
  cells.append(_Cell("", i, i + 1, (inv["id"], "invisible", "Removed " + inv["label"])))
209
283
  elif _is_odd_space(cp) and _ODD_SPACE_RULE_ID not in disabled:
210
284
  cells.append(_Cell(" ", i, i + 1, (_ODD_SPACE_RULE_ID, "space", "Normalized a " + _ODD_SPACE_LABEL)))
285
+ elif cp in _TYPOGRAPHY and _TYPOGRAPHY_RULE_ID not in disabled:
286
+ cells.append(_Cell(_TYPOGRAPHY[cp], i, i + 1, _TYPOGRAPHY_RULE))
211
287
  else:
212
288
  cells.append(_Cell(ch, i, i + 1, None))
213
289
  return cells
@@ -239,25 +315,70 @@ def _cells_to_edits(cells, text):
239
315
  return edits
240
316
 
241
317
 
318
+ def _is_latin_letter(t):
319
+ return len(t) == 1 and (65 <= ord(t) <= 90 or 97 <= ord(t) <= 122)
320
+
321
+
322
+ def _is_confusable_text(t):
323
+ return len(t) == 1 and ord(t) in _CONFUSABLES
324
+
325
+
326
+ def _homoglyph_pass(cells, disabled=frozenset()):
327
+ """A confusable is only a problem when it hides inside a word that is
328
+ otherwise Latin. Replacing them wholesale would destroy genuine Cyrillic or
329
+ Greek text, so a run is rewritten only when it MIXES scripts. Cells whose
330
+ text is empty (an invisible character already removed) are transparent, so
331
+ "a<ZWSP>pple" with a Cyrillic a is still seen as one word.
332
+ Mirrors homoglyphPass in the JS engine."""
333
+ if _CONFUSABLE_RULE_ID in disabled:
334
+ return cells
335
+ wordish = lambda c: _is_latin_letter(c.text) or _is_confusable_text(c.text)
336
+ i = 0
337
+ while i < len(cells):
338
+ if not wordish(cells[i]):
339
+ i += 1
340
+ continue
341
+ j = i
342
+ last_word = i
343
+ has_latin = False
344
+ has_confusable = False
345
+ while j < len(cells) and (wordish(cells[j]) or cells[j].text == ""):
346
+ if _is_latin_letter(cells[j].text):
347
+ has_latin = True
348
+ last_word = j
349
+ elif _is_confusable_text(cells[j].text):
350
+ has_confusable = True
351
+ last_word = j
352
+ j += 1
353
+ if has_latin and has_confusable:
354
+ for k in range(i, last_word + 1):
355
+ if not _is_confusable_text(cells[k].text):
356
+ continue
357
+ cells[k] = _Cell(_CONFUSABLES[ord(cells[k].text)],
358
+ cells[k].src0, cells[k].src1, _CONFUSABLE_RULE)
359
+ i = max(j, i + 1)
360
+ return cells
361
+
362
+
242
363
  def _clean_cells(text, disabled=frozenset()):
243
364
  """Full clean pipeline over cells. Mirrors clean_text() in text_tools.py:
244
365
  rebuild (invisible + odd space), then, only if a dash was present, the em
245
366
  dash pass plus multi-space collapse and space-before-punct tidy. A disabled
246
367
  rule id skips its pass entirely, so cleaned text and the edit list agree."""
247
- cells = _build_cells(text, disabled)
368
+ cells = _homoglyph_pass(_build_cells(text, disabled), disabled)
248
369
  current = "".join(c.text for c in cells)
249
370
  # Run the dash/space passes to a FIXED POINT, not once. A single pass is
250
- # not idempotent: removing an em dash can manufacture the spacing that arms
251
- # the spaced-en-dash rule ("X—– Y" -> "X – Y", and only a second clean
252
- # reached "X Y"), which broke the documented clean(clean(x)) == clean(x)
253
- # contract. Each enabled dash pass strictly reduces the dash count, so this
254
- # terminates; the equality check breaks when the dash rule is disabled.
255
- # Mirrors cleanCells() in the JS engine exactly.
371
+ # not idempotent: replacing an em dash can manufacture the spacing that
372
+ # arms the spaced-en-dash rule ("X—– Y" -> "X – Y", and only the next
373
+ # iteration reaches "X, Y"), which would break the documented
374
+ # clean(clean(x)) == clean(x) contract. Each enabled dash pass strictly
375
+ # reduces the dash count, so this terminates; the equality check breaks
376
+ # when the dash rule is disabled. Mirrors cleanCells() in the JS engine.
256
377
  for _guard in range(8):
257
378
  if not _EM_DASH.search(current):
258
379
  break
259
- if _DASH_RULE[0] not in disabled:
260
- cells, _ = _regex_pass(cells, _EM_DASH, " ", _DASH_RULE)
380
+ if _DASH_RULE_ID not in disabled:
381
+ cells, _ = _regex_pass(cells, _EM_DASH, _dash_match_replacement, _dash_rule_for)
261
382
  if _COLLAPSE_RULE[0] not in disabled:
262
383
  cells, _ = _regex_pass(cells, _MULTI_SPACE, " ", _COLLAPSE_RULE)
263
384
  if _SPACE_BEFORE_RULE[0] not in disabled:
@@ -281,12 +402,12 @@ def rebuild_text(text):
281
402
 
282
403
 
283
404
  def remove_em_dashes(text):
284
- """Replace spaced dashes with a single space. Returns (new_text, count).
285
- Back-compatible with text_tools.remove_em_dashes."""
405
+ """Replace clause dashes with a comma where one fits, else a space.
406
+ Returns (new_text, count); count is the number of matched dash groups."""
286
407
  count = len(_EM_DASH.findall(text))
287
408
  if not count:
288
409
  return text, 0
289
- new = _EM_DASH.sub(" ", text)
410
+ new = _EM_DASH.sub(_dash_match_replacement, text)
290
411
  new = _MULTI_SPACE.sub(" ", new)
291
412
  new = _SPACE_BEFORE_PUNCT.sub(r"\1", new)
292
413
  return new, count
@@ -404,6 +525,16 @@ def analyze_ai_signals(text):
404
525
  "message": "Some rule-of-three phrasing"})
405
526
 
406
527
  # 6. List structure.
528
+ markdown_hits = len(_MARKDOWN.findall(text))
529
+ if markdown_hits >= 2:
530
+ score += 10
531
+ signals.append({"id": "signal.markdown-artifacts", "points": 10,
532
+ "message": "Markdown left in the text (%d marks), a sign of a paste from a chat window" % markdown_hits})
533
+ elif markdown_hits == 1:
534
+ score += 5
535
+ signals.append({"id": "signal.markdown-artifacts", "points": 5,
536
+ "message": "A markdown mark left in the text"})
537
+
407
538
  enum_hits = sum(len(re.findall(r"\b" + re.escape(w["text"]) + r"\b", lowered, re.ASCII))
408
539
  for w in _ENUMERATORS)
409
540
  marker_hits = len(_LIST_MARKER.findall(text))
@@ -500,6 +631,10 @@ def clean(text, options=None):
500
631
  # Dashes count by consumed CHARACTER too: with the fixed-point dash pass, a
501
632
  # chain like "—– " merges into one edit that removed two dashes.
502
633
  dashes_n = _count_consumed(lambda cp: cp in (0x2014, 0x2015, 0x2013))
634
+ # Same measured-by-consumption rule: a confusable left inside a genuinely
635
+ # Cyrillic word is never consumed, so it is never counted.
636
+ homoglyphs_n = _count_consumed(lambda cp: cp in _CONFUSABLES)
637
+ typography_n = _count_consumed(lambda cp: cp in _TYPOGRAPHY)
503
638
 
504
639
  return {
505
640
  "engineVersion": ENGINE_VERSION,
@@ -514,6 +649,8 @@ def clean(text, options=None):
514
649
  "invisible": invisible_n,
515
650
  "oddSpaces": oddspace_n,
516
651
  "dashes": dashes_n,
652
+ "homoglyphs": homoglyphs_n,
653
+ "typography": typography_n,
517
654
  "hidden": invisible_n + oddspace_n,
518
655
  "flagged": len(flags),
519
656
  },