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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fetchit-engine
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Deterministic text cleanup and AI-writing heuristics. Runs entirely in your process; text never leaves it.
5
5
  Author: Outta Work Solutions
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  """fetchit-engine: deterministic text cleanup and AI-writing heuristics.
2
2
 
3
- Public API mirrors @fetchit/engine (JavaScript). See core.py for the
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 @fetchit/engine
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.4.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
- _MARKDOWN = re.compile(
78
- r"\*\*[^*\n]+\*\*|__[^_\n]+__|^#{1,6}[ \t]|\[[^\]\n]+\]\([^)\n]+\)", re.MULTILINE
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
- return _dash_replacement(_cp_before(m.string, m.start()), _cp_after(m.string, m.end()))
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
- out = []
173
- for ch in text:
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
- merged = cells[ci:cj]
248
- src0 = merged[0].src0
249
- src1 = merged[-1].src1
250
- if callable(replacement):
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. Returns the
276
- cell list (input order preserved)."""
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
- if inv and inv["id"] not in disabled:
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
- wordish = lambda c: _is_latin_letter(c.text) or _is_confusable_text(c.text)
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
- new = _EM_DASH.sub(_dash_match_replacement, text)
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 p in _PHRASES:
439
- phrase = p["text"]
440
- start = lowered.find(phrase)
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
- found = [p["text"] for p in _PHRASES if p["text"] in lowered]
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-08-29",
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": "..."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fetchit-engine
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Deterministic text cleanup and AI-writing heuristics. Runs entirely in your process; text never leaves it.
5
5
  Author: Outta Work Solutions
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fetchit-engine"
7
- version = "0.4.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