fetchit-engine 0.4.0__tar.gz → 0.5.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.
- {fetchit_engine-0.4.0/fetchit_engine.egg-info → fetchit_engine-0.5.0}/PKG-INFO +1 -1
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0}/fetchit_engine/__init__.py +1 -1
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0}/fetchit_engine/core.py +134 -39
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0}/fetchit_engine/ruleset.json +1 -49
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0/fetchit_engine.egg-info}/PKG-INFO +1 -1
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0}/pyproject.toml +1 -1
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0}/LICENSE +0 -0
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0}/NOTICE +0 -0
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0}/README.md +0 -0
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0}/fetchit_engine.egg-info/SOURCES.txt +0 -0
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0}/fetchit_engine.egg-info/dependency_links.txt +0 -0
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0}/fetchit_engine.egg-info/top_level.txt +0 -0
- {fetchit_engine-0.4.0 → fetchit_engine-0.5.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""fetchit-engine: deterministic text cleanup and AI-writing heuristics.
|
|
2
2
|
|
|
3
|
-
Public API mirrors @
|
|
3
|
+
Public API mirrors @fetchitai/engine (JavaScript). See core.py for the
|
|
4
4
|
CleanResult contract; parity is enforced by packages/engine-core/vectors.json.
|
|
5
5
|
"""
|
|
6
6
|
from .core import (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Fetch It AI text engine (canonical core).
|
|
2
2
|
|
|
3
|
-
Emits the language-neutral CleanResult contract shared with @
|
|
3
|
+
Emits the language-neutral CleanResult contract shared with @fetchitai/engine
|
|
4
4
|
(JavaScript). Ported in lockstep; the parity vectors in
|
|
5
5
|
packages/engine-core/vectors.json are the contract both sides must satisfy.
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@ import json
|
|
|
15
15
|
import os
|
|
16
16
|
import re
|
|
17
17
|
|
|
18
|
-
ENGINE_VERSION = "0.
|
|
18
|
+
ENGINE_VERSION = "0.5.0"
|
|
19
19
|
|
|
20
20
|
# --- ruleset (single source of truth, shared with the JS package) -----------
|
|
21
21
|
_RULESET_PATH = os.path.join(
|
|
@@ -73,10 +73,50 @@ _LIST_MARKER = re.compile(r"^\s*(?:[-*•·]|\d+[.)])\s+", re.MULTILINE | re.ASC
|
|
|
73
73
|
# by 10 points, while _WORD split "don’t" into two words.
|
|
74
74
|
_CONTRACTION = re.compile(r"\b[A-Za-z]+['’](?:t|s|re|ve|ll|d|m)\b", re.IGNORECASE | re.ASCII)
|
|
75
75
|
_WORD = re.compile(r"[A-Za-z'’]+")
|
|
76
|
-
# Markdown that survived a paste out of a chat window into running prose
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
# Markdown that survived a paste out of a chat window into running prose: bold,
|
|
77
|
+
# underline-bold and headings. Markdown LINKS and images are deliberately NOT
|
|
78
|
+
# counted, because they are common in ordinary writing and scoring them as an AI
|
|
79
|
+
# tell is a false positive. Mirrors MARKDOWN_RE in the JS engine.
|
|
80
|
+
_MARKDOWN = re.compile(r"\*\*[^*\n]+\*\*|__[^_\n]+__|^#{1,6}[ \t]", re.MULTILINE)
|
|
81
|
+
|
|
82
|
+
# URLs and markdown link targets are literal, not prose: cleaning must leave them
|
|
83
|
+
# byte-identical. Scheme spelled out (no IGNORECASE) and whitespace an explicit
|
|
84
|
+
# class (no \S) so JS and Python match the same spans. Mirrors URL_RE.
|
|
85
|
+
_URL_RE = re.compile(r"(?:[Hh][Tt][Tt][Pp][Ss]?://|[Ww][Ww][Ww]\.)[^ \t\n\r\f\v)]+|\]\([^)\n]+\)")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Phrase matching is WHOLE-WORD, like the enumerators, so "landscape" no longer
|
|
89
|
+
# fires inside "landscapers". A \b is applied at an edge only when that edge is a
|
|
90
|
+
# word character: three shipped phrases end in a comma ("in conclusion,"), where a
|
|
91
|
+
# trailing \b would demand a letter after the comma and never match. All phrases
|
|
92
|
+
# start with a letter, so the leading \b is always valid. Built once; mirrors
|
|
93
|
+
# PHRASE_RES in the JS engine. re.ASCII pins \b to ASCII to match JS.
|
|
94
|
+
def _phrase_boundary(phrase):
|
|
95
|
+
start = r"\b" if re.match(r"[A-Za-z0-9]", phrase) else ""
|
|
96
|
+
end = r"\b" if re.search(r"[A-Za-z0-9]$", phrase) else ""
|
|
97
|
+
return start, end
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_PHRASE_RES = [
|
|
101
|
+
(p["id"], p["text"],
|
|
102
|
+
re.compile("".join((_phrase_boundary(p["text"])[0], re.escape(p["text"]), _phrase_boundary(p["text"])[1])), re.ASCII))
|
|
103
|
+
for p in _PHRASES
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# A ZERO WIDTH JOINER (U+200D) between two emoji fuses them into one glyph:
|
|
108
|
+
# family emoji, professions, the rainbow and other flags. Removing it as an
|
|
109
|
+
# invisible character shatters the emoji, so a ZWJ is kept when BOTH neighbours
|
|
110
|
+
# are emoji-ish. A ZWJ hidden inside Latin text has non-emoji neighbours and is
|
|
111
|
+
# still removed. Mirrors isEmojiContext in the JS engine.
|
|
112
|
+
_ZWJ_CP = 0x200D
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_emoji_context(cp):
|
|
116
|
+
return (0x1F000 <= cp <= 0x1FAFF # pictographs, emoticons, supplemental, regional indicators
|
|
117
|
+
or 0x2600 <= cp <= 0x27BF # misc symbols + dingbats
|
|
118
|
+
or 0x2B00 <= cp <= 0x2BFF # stars, arrows
|
|
119
|
+
or cp == 0xFE0F or cp == 0xFE0E) # emoji / text variation selectors
|
|
80
120
|
# Sentence split: same as the JS splitSentences(). ASCII whitespace only.
|
|
81
121
|
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])[ \t\n\r\f\v]+", re.ASCII)
|
|
82
122
|
# One shared whitespace set for trimming and word splitting, identical in both
|
|
@@ -145,13 +185,22 @@ def _dash_replacement(before_cp, after_cp):
|
|
|
145
185
|
blocked_after = after_cp < 0 or after_cp in _NO_COMMA_AFTER or after_cp in _DASH_CP
|
|
146
186
|
if blocked_before or blocked_after:
|
|
147
187
|
return " "
|
|
148
|
-
if 0x30 <= before_cp <= 0x39 and 0x30 <= after_cp <= 0x39:
|
|
149
|
-
return " "
|
|
150
188
|
return "," if after_cp in _WS else ", "
|
|
151
189
|
|
|
152
190
|
|
|
191
|
+
def _is_number_range_dash(before_cp, after_cp):
|
|
192
|
+
# A matched dash run directly between two digits (1914—1918, pages 12 — 14)
|
|
193
|
+
# is a numeric range, not a clause pause: leave the dash and its spacing as
|
|
194
|
+
# written. The pass returns None to skip it.
|
|
195
|
+
return 0x30 <= before_cp <= 0x39 and 0x30 <= after_cp <= 0x39
|
|
196
|
+
|
|
197
|
+
|
|
153
198
|
def _dash_match_replacement(m):
|
|
154
|
-
|
|
199
|
+
before = _cp_before(m.string, m.start())
|
|
200
|
+
after = _cp_after(m.string, m.end())
|
|
201
|
+
if _is_number_range_dash(before, after):
|
|
202
|
+
return None
|
|
203
|
+
return _dash_replacement(before, after)
|
|
155
204
|
|
|
156
205
|
# Priority when several passes touch the same characters and their cells merge.
|
|
157
206
|
# Higher wins the label. Dash beats space cleanup beats invisible.
|
|
@@ -165,18 +214,15 @@ _RULE_PRIORITY = {
|
|
|
165
214
|
}
|
|
166
215
|
|
|
167
216
|
|
|
217
|
+
_LOWER_TABLE = {c: c + 32 for c in range(65, 91)}
|
|
218
|
+
|
|
219
|
+
|
|
168
220
|
def _ascii_lower(text):
|
|
169
221
|
"""Lowercase only A-Z. Length- and position-preserving, so offsets computed
|
|
170
222
|
against the result map 1:1 back onto the input. Every rule phrase is ASCII,
|
|
171
|
-
so this never misses a match, and it is identical in JS.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
o = ord(ch)
|
|
175
|
-
if 65 <= o <= 90:
|
|
176
|
-
out.append(chr(o + 32))
|
|
177
|
-
else:
|
|
178
|
-
out.append(ch)
|
|
179
|
-
return "".join(out)
|
|
223
|
+
so this never misses a match, and it is identical in JS. str.translate is far
|
|
224
|
+
faster than a char loop on long text; mirrors asciiLower's regex replace."""
|
|
225
|
+
return text.translate(_LOWER_TABLE)
|
|
180
226
|
|
|
181
227
|
|
|
182
228
|
def _invisible_rule(codepoint):
|
|
@@ -200,14 +246,27 @@ class _Cell:
|
|
|
200
246
|
src0/src1 half-open code-point range in the INPUT this cell represents
|
|
201
247
|
rule (ruleId, category, message) if this cell is the product of an edit,
|
|
202
248
|
else None
|
|
249
|
+
protected inside a URL / link target: no pass may touch it
|
|
203
250
|
"""
|
|
204
|
-
__slots__ = ("text", "src0", "src1", "rule")
|
|
251
|
+
__slots__ = ("text", "src0", "src1", "rule", "protected")
|
|
205
252
|
|
|
206
|
-
def __init__(self, text, src0, src1, rule=None):
|
|
253
|
+
def __init__(self, text, src0, src1, rule=None, protected=False):
|
|
207
254
|
self.text = text
|
|
208
255
|
self.src0 = src0
|
|
209
256
|
self.src1 = src1
|
|
210
257
|
self.rule = rule
|
|
258
|
+
self.protected = protected
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _protected_flags(text):
|
|
262
|
+
"""A boolean per input code point: True where the code point lies inside a URL
|
|
263
|
+
or markdown link target and must be left byte-identical. Python str indices are
|
|
264
|
+
code points, so match offsets line up with cells. Mirrors protectedFlags."""
|
|
265
|
+
flags = [False] * len(text)
|
|
266
|
+
for m in _URL_RE.finditer(text):
|
|
267
|
+
for i in range(m.start(), m.end()):
|
|
268
|
+
flags[i] = True
|
|
269
|
+
return flags
|
|
211
270
|
|
|
212
271
|
|
|
213
272
|
def _regex_pass(cells, regex, replacement, rule):
|
|
@@ -244,15 +303,25 @@ def _regex_pass(cells, regex, replacement, rule):
|
|
|
244
303
|
a, b = m.start(), m.end()
|
|
245
304
|
ci, cj = cell_at(a), cell_at(b)
|
|
246
305
|
out.extend(cells[last:ci])
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
306
|
+
# A match that touches a protected (URL) cell is left alone, like a None
|
|
307
|
+
# replacement: copy its cells through untouched, so any sub-edits they
|
|
308
|
+
# carry (a normalized odd space beside a numeric-range dash) survive and
|
|
309
|
+
# the span produces no edit.
|
|
310
|
+
if any(cells[k].protected for k in range(ci, cj)):
|
|
311
|
+
rep = None
|
|
312
|
+
elif callable(replacement):
|
|
251
313
|
rep = replacement(m)
|
|
252
314
|
elif "\\" in replacement:
|
|
253
315
|
rep = m.expand(replacement)
|
|
254
316
|
else:
|
|
255
317
|
rep = replacement
|
|
318
|
+
if rep is None:
|
|
319
|
+
out.extend(cells[ci:cj])
|
|
320
|
+
last = cj
|
|
321
|
+
continue
|
|
322
|
+
merged = cells[ci:cj]
|
|
323
|
+
src0 = merged[0].src0
|
|
324
|
+
src1 = merged[-1].src1
|
|
256
325
|
# `rule` may depend on the replacement chosen (the dash pass labels
|
|
257
326
|
# comma and space outcomes differently while keeping one ruleId).
|
|
258
327
|
base = rule(rep) if callable(rule) else rule
|
|
@@ -270,15 +339,27 @@ def _regex_pass(cells, regex, replacement, rule):
|
|
|
270
339
|
return out, changed
|
|
271
340
|
|
|
272
341
|
|
|
273
|
-
def _build_cells(text, disabled=frozenset()):
|
|
342
|
+
def _build_cells(text, disabled=frozenset(), prot=None):
|
|
274
343
|
"""Pass A: invisible removal and look-alike-space normalization, cell-wise
|
|
275
|
-
by code point. A disabled rule leaves its characters untouched.
|
|
276
|
-
|
|
344
|
+
by code point. A disabled rule leaves its characters untouched. Code points
|
|
345
|
+
inside a URL / link target are left exactly as written. Returns the cell list
|
|
346
|
+
(input order preserved)."""
|
|
347
|
+
if prot is None:
|
|
348
|
+
prot = _protected_flags(text)
|
|
277
349
|
cells = []
|
|
350
|
+
n = len(text)
|
|
278
351
|
for i, ch in enumerate(text):
|
|
352
|
+
if prot[i]:
|
|
353
|
+
cells.append(_Cell(text[i], i, i + 1, None, True))
|
|
354
|
+
continue
|
|
279
355
|
cp = ord(ch)
|
|
280
356
|
inv = _invisible_rule(cp)
|
|
281
|
-
|
|
357
|
+
# A ZWJ flanked by emoji is joining them, not hiding in text: keep it so
|
|
358
|
+
# the emoji sequence survives (see _is_emoji_context).
|
|
359
|
+
keep_zwj = (cp == _ZWJ_CP
|
|
360
|
+
and _is_emoji_context(ord(text[i - 1]) if i > 0 else -1)
|
|
361
|
+
and _is_emoji_context(ord(text[i + 1]) if i + 1 < n else -1))
|
|
362
|
+
if inv and inv["id"] not in disabled and not keep_zwj:
|
|
282
363
|
cells.append(_Cell("", i, i + 1, (inv["id"], "invisible", "Removed " + inv["label"])))
|
|
283
364
|
elif _is_odd_space(cp) and _ODD_SPACE_RULE_ID not in disabled:
|
|
284
365
|
cells.append(_Cell(" ", i, i + 1, (_ODD_SPACE_RULE_ID, "space", "Normalized a " + _ODD_SPACE_LABEL)))
|
|
@@ -332,7 +413,9 @@ def _homoglyph_pass(cells, disabled=frozenset()):
|
|
|
332
413
|
Mirrors homoglyphPass in the JS engine."""
|
|
333
414
|
if _CONFUSABLE_RULE_ID in disabled:
|
|
334
415
|
return cells
|
|
335
|
-
|
|
416
|
+
# A protected (URL) cell is never wordish, so it breaks the run and its
|
|
417
|
+
# look-alike letters are never rewritten.
|
|
418
|
+
wordish = lambda c: (not c.protected) and (_is_latin_letter(c.text) or _is_confusable_text(c.text))
|
|
336
419
|
i = 0
|
|
337
420
|
while i < len(cells):
|
|
338
421
|
if not wordish(cells[i]):
|
|
@@ -407,7 +490,15 @@ def remove_em_dashes(text):
|
|
|
407
490
|
count = len(_EM_DASH.findall(text))
|
|
408
491
|
if not count:
|
|
409
492
|
return text, 0
|
|
410
|
-
|
|
493
|
+
|
|
494
|
+
def _repl(m):
|
|
495
|
+
before = _cp_before(m.string, m.start())
|
|
496
|
+
after = _cp_after(m.string, m.end())
|
|
497
|
+
if _is_number_range_dash(before, after):
|
|
498
|
+
return m.group(0) # leave a numeric range dash untouched
|
|
499
|
+
return _dash_replacement(before, after)
|
|
500
|
+
|
|
501
|
+
new = _EM_DASH.sub(_repl, text)
|
|
411
502
|
new = _MULTI_SPACE.sub(" ", new)
|
|
412
503
|
new = _SPACE_BEFORE_PUNCT.sub(r"\1", new)
|
|
413
504
|
return new, count
|
|
@@ -435,12 +526,9 @@ def find_ai_spans(text):
|
|
|
435
526
|
def _find_ai_flags(text):
|
|
436
527
|
lowered = _ascii_lower(text)
|
|
437
528
|
raw = []
|
|
438
|
-
for
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
while start != -1:
|
|
442
|
-
raw.append((start, start + len(phrase), p["id"], phrase))
|
|
443
|
-
start = lowered.find(phrase, start + len(phrase))
|
|
529
|
+
for pid, ptext, pre in _PHRASE_RES:
|
|
530
|
+
for m in pre.finditer(lowered):
|
|
531
|
+
raw.append((m.start(), m.end(), pid, ptext))
|
|
444
532
|
for w in _ENUMERATORS:
|
|
445
533
|
for m in re.finditer(r"\b" + re.escape(w["text"]) + r"\b", lowered, re.ASCII):
|
|
446
534
|
raw.append((m.start(), m.end(), w["id"], w["text"]))
|
|
@@ -483,10 +571,17 @@ def analyze_ai_signals(text):
|
|
|
483
571
|
signals.append({"id": "signal.dash-density", "points": 12,
|
|
484
572
|
"message": "Frequent em-dash use (%d dashes)" % dash_count})
|
|
485
573
|
|
|
486
|
-
# 2. Stock AI phrases.
|
|
487
|
-
|
|
574
|
+
# 2. Stock AI phrases. Whole-word, so "landscape" is not counted inside
|
|
575
|
+
# "landscapers"; the same matcher the flags use, so score and highlights agree.
|
|
576
|
+
# One scan per phrase (count once), not a filter pass plus a separate count.
|
|
577
|
+
found = []
|
|
578
|
+
occurrences = 0
|
|
579
|
+
for _pid, ptext, pre in _PHRASE_RES:
|
|
580
|
+
n = len(pre.findall(lowered))
|
|
581
|
+
if n > 0:
|
|
582
|
+
found.append(ptext)
|
|
583
|
+
occurrences += n
|
|
488
584
|
if found:
|
|
489
|
-
occurrences = sum(lowered.count(p) for p in found)
|
|
490
585
|
pts = min(30, 10 * len(found))
|
|
491
586
|
score += pts
|
|
492
587
|
shown = ", ".join('"%s"' % p for p in found[:4])
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"rulesetVersion": "2026-
|
|
2
|
+
"rulesetVersion": "2026-09-25",
|
|
3
3
|
"phrases": [
|
|
4
4
|
{
|
|
5
5
|
"id": "ai-wording.delve",
|
|
@@ -551,54 +551,6 @@
|
|
|
551
551
|
"confusableRuleId": "homoglyph.mixed-script",
|
|
552
552
|
"confusableLabel": "look-alike letter",
|
|
553
553
|
"typography": [
|
|
554
|
-
{
|
|
555
|
-
"cp": 8216,
|
|
556
|
-
"to": "'"
|
|
557
|
-
},
|
|
558
|
-
{
|
|
559
|
-
"cp": 8217,
|
|
560
|
-
"to": "'"
|
|
561
|
-
},
|
|
562
|
-
{
|
|
563
|
-
"cp": 8218,
|
|
564
|
-
"to": "'"
|
|
565
|
-
},
|
|
566
|
-
{
|
|
567
|
-
"cp": 8219,
|
|
568
|
-
"to": "'"
|
|
569
|
-
},
|
|
570
|
-
{
|
|
571
|
-
"cp": 8220,
|
|
572
|
-
"to": "\""
|
|
573
|
-
},
|
|
574
|
-
{
|
|
575
|
-
"cp": 8221,
|
|
576
|
-
"to": "\""
|
|
577
|
-
},
|
|
578
|
-
{
|
|
579
|
-
"cp": 8222,
|
|
580
|
-
"to": "\""
|
|
581
|
-
},
|
|
582
|
-
{
|
|
583
|
-
"cp": 8223,
|
|
584
|
-
"to": "\""
|
|
585
|
-
},
|
|
586
|
-
{
|
|
587
|
-
"cp": 8249,
|
|
588
|
-
"to": "'"
|
|
589
|
-
},
|
|
590
|
-
{
|
|
591
|
-
"cp": 8250,
|
|
592
|
-
"to": "'"
|
|
593
|
-
},
|
|
594
|
-
{
|
|
595
|
-
"cp": 8242,
|
|
596
|
-
"to": "'"
|
|
597
|
-
},
|
|
598
|
-
{
|
|
599
|
-
"cp": 8243,
|
|
600
|
-
"to": "\""
|
|
601
|
-
},
|
|
602
554
|
{
|
|
603
555
|
"cp": 8230,
|
|
604
556
|
"to": "..."
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fetchit-engine"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Deterministic text cleanup and AI-writing heuristics. Runs entirely in your process; text never leaves it."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|