fetchit-engine 0.2.0__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.2.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.
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.2.0"
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"]
@@ -59,8 +67,16 @@ _RULE_OF_THREE = re.compile(
59
67
  r"\b[A-Za-z]+,\s+[A-Za-z]+,\s+and\s+[A-Za-z]+\b", re.IGNORECASE | re.ASCII
60
68
  )
61
69
  _LIST_MARKER = re.compile(r"^\s*(?:[-*•·]|\d+[.)])\s+", re.MULTILINE | re.ASCII)
62
- _CONTRACTION = re.compile(r"\b[A-Za-z]+'(?:t|s|re|ve|ll|d|m)\b", re.IGNORECASE | re.ASCII)
63
- _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
+ )
64
80
  # Sentence split: same as the JS splitSentences(). ASCII whitespace only.
65
81
  _SENTENCE_SPLIT = re.compile(r"(?<=[.!?])[ \t\n\r\f\v]+", re.ASCII)
66
82
  # One shared whitespace set for trimming and word splitting, identical in both
@@ -97,6 +113,8 @@ def _dash_rule_for(rep):
97
113
 
98
114
 
99
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)
100
118
  _SPACE_BEFORE_RULE = ("punct.space-before", "space", "Removed a space before punctuation")
101
119
 
102
120
  # What a matched dash run becomes. A clause dash reads as a pause, so the
@@ -138,7 +156,9 @@ def _dash_match_replacement(m):
138
156
  # Priority when several passes touch the same characters and their cells merge.
139
157
  # Higher wins the label. Dash beats space cleanup beats invisible.
140
158
  _RULE_PRIORITY = {
159
+ "homoglyph.mixed-script": 45,
141
160
  "dash.spaced": 40,
161
+ "typography.smart": 35,
142
162
  "space.lookalike": 30,
143
163
  "space.collapse": 20,
144
164
  "punct.space-before": 15,
@@ -262,6 +282,8 @@ def _build_cells(text, disabled=frozenset()):
262
282
  cells.append(_Cell("", i, i + 1, (inv["id"], "invisible", "Removed " + inv["label"])))
263
283
  elif _is_odd_space(cp) and _ODD_SPACE_RULE_ID not in disabled:
264
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))
265
287
  else:
266
288
  cells.append(_Cell(ch, i, i + 1, None))
267
289
  return cells
@@ -293,12 +315,57 @@ def _cells_to_edits(cells, text):
293
315
  return edits
294
316
 
295
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
+
296
363
  def _clean_cells(text, disabled=frozenset()):
297
364
  """Full clean pipeline over cells. Mirrors clean_text() in text_tools.py:
298
365
  rebuild (invisible + odd space), then, only if a dash was present, the em
299
366
  dash pass plus multi-space collapse and space-before-punct tidy. A disabled
300
367
  rule id skips its pass entirely, so cleaned text and the edit list agree."""
301
- cells = _build_cells(text, disabled)
368
+ cells = _homoglyph_pass(_build_cells(text, disabled), disabled)
302
369
  current = "".join(c.text for c in cells)
303
370
  # Run the dash/space passes to a FIXED POINT, not once. A single pass is
304
371
  # not idempotent: replacing an em dash can manufacture the spacing that
@@ -458,6 +525,16 @@ def analyze_ai_signals(text):
458
525
  "message": "Some rule-of-three phrasing"})
459
526
 
460
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
+
461
538
  enum_hits = sum(len(re.findall(r"\b" + re.escape(w["text"]) + r"\b", lowered, re.ASCII))
462
539
  for w in _ENUMERATORS)
463
540
  marker_hits = len(_LIST_MARKER.findall(text))
@@ -554,6 +631,10 @@ def clean(text, options=None):
554
631
  # Dashes count by consumed CHARACTER too: with the fixed-point dash pass, a
555
632
  # chain like "—– " merges into one edit that removed two dashes.
556
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)
557
638
 
558
639
  return {
559
640
  "engineVersion": ENGINE_VERSION,
@@ -568,6 +649,8 @@ def clean(text, options=None):
568
649
  "invisible": invisible_n,
569
650
  "oddSpaces": oddspace_n,
570
651
  "dashes": dashes_n,
652
+ "homoglyphs": homoglyphs_n,
653
+ "typography": typography_n,
571
654
  "hidden": invisible_n + oddspace_n,
572
655
  "flagged": len(flags),
573
656
  },