llmclean 0.1.0__py3-none-any.whl

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.
llmclean/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """
2
+ llmclean — utilities for cleaning and normalizing raw LLM output.
3
+
4
+ Quick start::
5
+
6
+ from llmclean import strip_fences, enforce_json, trim_repetition
7
+
8
+ clean = strip_fences(raw_output)
9
+ data = enforce_json(raw_output)
10
+ text = trim_repetition(raw_output)
11
+ """
12
+
13
+ from .fences import strip_fences
14
+ from .json_utils import enforce_json
15
+ from .repetition import trim_repetition
16
+
17
+ __all__ = ["strip_fences", "enforce_json", "trim_repetition"]
18
+ __version__ = "0.1.0"
llmclean/fences.py ADDED
@@ -0,0 +1,155 @@
1
+ """
2
+ fences.py — strip markdown code fences from LLM output.
3
+
4
+ LLMs frequently wrap their output in fences like:
5
+ ```json
6
+ { "key": "value" }
7
+ ```
8
+
9
+ This module handles:
10
+ - Named fences: ```json ... ```
11
+ - Anonymous fences: ``` ... ```
12
+ - Tilde fences: ~~~ ... ~~~
13
+ - Indented fences: leading whitespace before the backticks
14
+ - Multiple fences in one string (strips all of them)
15
+ - Nested / back-to-back fences
16
+ - Fences with no closing marker (strips opening line, returns rest)
17
+ - Stray language tags left on their own line after stripping
18
+ """
19
+
20
+ import re
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Patterns
24
+ # ---------------------------------------------------------------------------
25
+
26
+ # Matches an opening fence line, e.g. ```json or ``` or ~~~python
27
+ # Group 1: the fence characters (``` or ~~~)
28
+ # Group 2: optional language identifier (may be empty)
29
+ _OPEN_FENCE_RE = re.compile(
30
+ r"^[ \t]*(?P<fence>`{3,}|~{3,})[ \t]*(?P<lang>[a-zA-Z0-9_+\-.]*)[ \t]*$",
31
+ re.MULTILINE,
32
+ )
33
+
34
+ # Matches a closing fence: same or more fence characters on its own line
35
+ _CLOSE_FENCE_RE = re.compile(
36
+ r"^[ \t]*(?P<fence>`{3,}|~{3,})[ \t]*$",
37
+ re.MULTILINE,
38
+ )
39
+
40
+ # After all fences are stripped, clean up lone language-tag lines that were
41
+ # sitting right above the content, e.g. a line that is just "json" or "python"
42
+ # This is intentionally narrow to avoid removing real content.
43
+ _LONE_LANG_TAG_RE = re.compile(
44
+ r"^[ \t]*(?:json|python|javascript|js|typescript|ts|bash|sh|shell|"
45
+ r"html|xml|css|yaml|yml|toml|ini|markdown|md|text|txt|plaintext|"
46
+ r"sql|graphql|rust|go|java|c|cpp|c\+\+|csharp|cs|ruby|rb|php|"
47
+ r"swift|kotlin|scala|r|lua|perl|haskell|elixir|erlang|clojure|"
48
+ r"dart|objc|output|console|log)[ \t]*$",
49
+ re.MULTILINE | re.IGNORECASE,
50
+ )
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Public API
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def strip_fences(text: str) -> str:
58
+ """Remove all markdown/tilde code fences from *text*.
59
+
60
+ Strips every fenced block found, replacing each fence with the raw
61
+ content that was inside it. If no fences are present the original
62
+ text is returned unchanged. The function never raises.
63
+
64
+ Parameters
65
+ ----------
66
+ text:
67
+ Raw LLM output that may contain one or more fenced code blocks.
68
+
69
+ Returns
70
+ -------
71
+ str
72
+ The cleaned text with fence markers removed and inner content
73
+ preserved. Leading/trailing whitespace that was *outside* all
74
+ fences is also stripped.
75
+ """
76
+ if not isinstance(text, str):
77
+ return text # defensive: wrong type → return as-is
78
+
79
+ original = text
80
+
81
+ try:
82
+ result = _strip_all_fences(text)
83
+ # Collapse any excessive blank lines introduced by fence removal
84
+ result = _normalize_blank_lines(result)
85
+ return result
86
+ except Exception:
87
+ # Safety net: never crash the caller
88
+ return original
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Internal helpers
93
+ # ---------------------------------------------------------------------------
94
+
95
+ def _strip_all_fences(text: str) -> str:
96
+ """Iteratively strip fences until no more remain."""
97
+ # We loop because fences can be back-to-back; each pass removes one layer.
98
+ for _ in range(50): # guard against pathological inputs
99
+ new_text = _strip_one_pass(text)
100
+ if new_text == text:
101
+ break
102
+ text = new_text
103
+
104
+ # Clean up orphaned language-tag lines (e.g. a bare "json" line)
105
+ text = _LONE_LANG_TAG_RE.sub("", text)
106
+ return text.strip()
107
+
108
+
109
+ def _strip_one_pass(text: str) -> str:
110
+ """Remove every complete (or unclosed) fence block found in *text*."""
111
+ result_parts: list[str] = []
112
+ cursor = 0
113
+
114
+ for open_match in _OPEN_FENCE_RE.finditer(text):
115
+ open_start = open_match.start()
116
+ open_end = open_match.end()
117
+ fence_chars = open_match.group("fence")
118
+ fence_type = fence_chars[0] # ` or ~
119
+
120
+ # Append everything before this opening fence
121
+ result_parts.append(text[cursor:open_start])
122
+
123
+ # Look for a matching closing fence (same fence character, >= same length)
124
+ close_match = _find_closing_fence(text, open_end, fence_type, len(fence_chars))
125
+
126
+ if close_match:
127
+ # Content between open and close fences
128
+ inner = text[open_end:close_match.start()]
129
+ result_parts.append(inner)
130
+ cursor = close_match.end()
131
+ else:
132
+ # Unclosed fence: drop the opening line, keep the rest of the text
133
+ inner = text[open_end:]
134
+ result_parts.append(inner)
135
+ cursor = len(text)
136
+ break # nothing left to process
137
+
138
+ # Append any trailing text after the last fence
139
+ result_parts.append(text[cursor:])
140
+
141
+ return "".join(result_parts)
142
+
143
+
144
+ def _find_closing_fence(text: str, search_from: int, fence_type: str, min_len: int):
145
+ """Return the first closing fence match at or after *search_from*."""
146
+ for m in _CLOSE_FENCE_RE.finditer(text, search_from):
147
+ fc = m.group("fence")
148
+ if fc[0] == fence_type and len(fc) >= min_len:
149
+ return m
150
+ return None
151
+
152
+
153
+ def _normalize_blank_lines(text: str) -> str:
154
+ """Collapse 3+ consecutive blank lines down to 2."""
155
+ return re.sub(r"\n{3,}", "\n\n", text)
llmclean/json_utils.py ADDED
@@ -0,0 +1,354 @@
1
+ """
2
+ json_utils.py — extract and repair valid JSON from messy LLM output.
3
+
4
+ LLMs routinely return JSON wrapped in prose, fences, or with small syntax
5
+ errors. This module tries a pipeline of increasingly aggressive strategies
6
+ to get back a valid, parse-able JSON string.
7
+
8
+ Strategy pipeline (stops at first success):
9
+ 1. Parse as-is (already valid JSON)
10
+ 2. Strip fences then parse
11
+ 3. Strip leading/trailing prose, leaving only the JSON substring
12
+ (handles "Sure! Here is your JSON: {...} Hope that helps!")
13
+ 4. Remove trailing commas before ] or }
14
+ 5. Attempt to fix unquoted keys (moderate repair)
15
+ 6. Attempt to close unclosed brackets/braces
16
+ 7. Combination of fixes 4+5+6
17
+
18
+ If every strategy fails the original text is returned unchanged so the
19
+ caller can decide what to do.
20
+ """
21
+
22
+ import json
23
+ import re
24
+ from .fences import strip_fences
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Public API
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def enforce_json(text: str) -> str:
31
+ """Attempt to extract and return valid JSON from *text*.
32
+
33
+ Applies a pipeline of cleaning strategies. The first strategy that
34
+ produces parse-able JSON wins; its output (the cleaned JSON string) is
35
+ returned. If nothing works the original text is returned unchanged.
36
+
37
+ The returned string, when a strategy succeeds, is re-serialized with
38
+ ``json.dumps`` so it is always consistently formatted.
39
+
40
+ Parameters
41
+ ----------
42
+ text:
43
+ Raw LLM output that should contain JSON somewhere.
44
+
45
+ Returns
46
+ -------
47
+ str
48
+ A valid JSON string, or the original *text* if extraction failed.
49
+ """
50
+ if not isinstance(text, str):
51
+ return text
52
+
53
+ original = text
54
+
55
+ try:
56
+ return _run_pipeline(text.strip())
57
+ except Exception:
58
+ return original
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Pipeline
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def _run_pipeline(text: str) -> str:
66
+ strategies = [
67
+ _try_parse_direct,
68
+ _try_strip_fences,
69
+ _try_extract_json_substring,
70
+ _try_fix_trailing_commas,
71
+ _try_fix_python_literals,
72
+ _try_fix_unquoted_keys,
73
+ _try_close_open_brackets,
74
+ _try_combined_fixes,
75
+ ]
76
+ for strategy in strategies:
77
+ result = strategy(text)
78
+ if result is not None:
79
+ return result
80
+ # Nothing worked — return original
81
+ return text
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Strategies (each returns a clean JSON *string* or None)
86
+ # ---------------------------------------------------------------------------
87
+
88
+ def _try_parse_direct(text: str):
89
+ """Strategy 1: already valid JSON."""
90
+ return _parse_and_serialize(text)
91
+
92
+
93
+ def _try_strip_fences(text: str):
94
+ """Strategy 2: strip code fences then parse."""
95
+ stripped = strip_fences(text)
96
+ if stripped == text:
97
+ return None # no change, skip
98
+ return _parse_and_serialize(stripped)
99
+
100
+
101
+ def _try_extract_json_substring(text: str):
102
+ """Strategy 3: find the first JSON object or array in the text.
103
+
104
+ Scans for the first '{' or '[' and tries progressively larger substrings
105
+ until one parses. Also tries from the *last* '}' or ']' backwards.
106
+ This handles patterns like:
107
+ 'Sure, here is the data: {"key": "value"} Let me know if...'
108
+ """
109
+ # Try object extraction
110
+ result = _extract_by_brackets(text, "{", "}")
111
+ if result is not None:
112
+ return result
113
+ # Try array extraction
114
+ return _extract_by_brackets(text, "[", "]")
115
+
116
+
117
+ def _extract_by_brackets(text: str, open_char: str, close_char: str):
118
+ """Find the outermost balanced bracket pair and try to parse it."""
119
+ start = text.find(open_char)
120
+ if start == -1:
121
+ return None
122
+
123
+ end = text.rfind(close_char)
124
+ if end == -1 or end <= start:
125
+ return None
126
+
127
+ # Try from outermost to innermost close bracket
128
+ candidate = text[start:end + 1]
129
+ result = _parse_and_serialize(candidate)
130
+ if result is not None:
131
+ return result
132
+
133
+ # Walk inward if the outer attempt fails (handles trailing junk)
134
+ for i in range(end - 1, start, -1):
135
+ if text[i] == close_char:
136
+ result = _parse_and_serialize(text[start:i + 1])
137
+ if result is not None:
138
+ return result
139
+ return None
140
+
141
+
142
+ def _try_fix_trailing_commas(text: str):
143
+ """Strategy 4: remove trailing commas before closing brackets."""
144
+ cleaned = _remove_trailing_commas(text)
145
+ if cleaned == text:
146
+ return None
147
+ return _parse_and_serialize(cleaned)
148
+
149
+
150
+ def _try_fix_python_literals(text: str):
151
+ """Strategy 5: replace Python literals that LLMs emit instead of JSON.
152
+
153
+ LLMs frequently output Python-style values inside otherwise valid JSON:
154
+ True / False / None -> true / false / null
155
+ Single-quoted strings: {'key': 'val'} -> {"key": "val"}
156
+
157
+ Applied word-boundary-aware so legitimate content is not corrupted.
158
+ """
159
+ cleaned = _replace_python_literals(text)
160
+ if cleaned == text:
161
+ return None
162
+ return _parse_and_serialize(cleaned)
163
+
164
+
165
+ def _try_fix_unquoted_keys(text: str):
166
+ """Strategy 6: quote bare word keys like {key: value} -> {"key": value}."""
167
+ cleaned = _quote_unquoted_keys(text)
168
+ if cleaned == text:
169
+ return None
170
+ return _parse_and_serialize(cleaned)
171
+
172
+
173
+ def _try_close_open_brackets(text: str):
174
+ """Strategy 7: append missing closing brackets/braces."""
175
+ cleaned = _close_open_structures(text)
176
+ if cleaned == text:
177
+ return None
178
+ return _parse_and_serialize(cleaned)
179
+
180
+
181
+ def _try_combined_fixes(text: str):
182
+ """Strategy 8: apply all fixers in sequence."""
183
+ cleaned = _replace_python_literals(text)
184
+ cleaned = _remove_trailing_commas(cleaned)
185
+ cleaned = _quote_unquoted_keys(cleaned)
186
+ cleaned = _close_open_structures(cleaned)
187
+ if cleaned == text:
188
+ return None
189
+ # Also try substring extraction on the combined fix
190
+ result = _parse_and_serialize(cleaned)
191
+ if result is not None:
192
+ return result
193
+ return _try_extract_json_substring(cleaned)
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Fixers
198
+ # ---------------------------------------------------------------------------
199
+
200
+ # Trailing comma before } or ] (also handles whitespace/newlines between)
201
+ _TRAILING_COMMA_RE = re.compile(r",\s*([}\]])")
202
+
203
+ def _remove_trailing_commas(text: str) -> str:
204
+ return _TRAILING_COMMA_RE.sub(r"\1", text)
205
+
206
+
207
+ # Bare (unquoted) object keys: { key: ... } → { "key": ... }
208
+ # Only matches word-characters; won't disturb already-quoted keys.
209
+ _UNQUOTED_KEY_RE = re.compile(r'(?<!["\w])(\b[a-zA-Z_][a-zA-Z0-9_]*\b)\s*(?=:)')
210
+
211
+ def _quote_unquoted_keys(text: str) -> str:
212
+ # Only operate inside what looks like a JSON object
213
+ start = text.find("{")
214
+ if start == -1:
215
+ return text
216
+ prefix = text[:start]
217
+ body = text[start:]
218
+ fixed = _UNQUOTED_KEY_RE.sub(r'"\1"', body)
219
+ return prefix + fixed
220
+
221
+
222
+ def _close_open_structures(text: str) -> str:
223
+ """Append any missing closing } or ] characters."""
224
+ stack = []
225
+ in_string = False
226
+ escape_next = False
227
+ pairs = {"{": "}", "[": "]"}
228
+ closers = set(pairs.values())
229
+
230
+ for ch in text:
231
+ if escape_next:
232
+ escape_next = False
233
+ continue
234
+ if ch == "\\" and in_string:
235
+ escape_next = True
236
+ continue
237
+ if ch == '"':
238
+ in_string = not in_string
239
+ continue
240
+ if in_string:
241
+ continue
242
+ if ch in pairs:
243
+ stack.append(pairs[ch])
244
+ elif ch in closers:
245
+ if stack and stack[-1] == ch:
246
+ stack.pop()
247
+
248
+ # Append missing closers in reverse order
249
+ return text + "".join(reversed(stack))
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Utility
254
+ # ---------------------------------------------------------------------------
255
+
256
+ def _parse_and_serialize(text: str):
257
+ """Try to parse *text* as JSON; return re-serialized string or None."""
258
+ try:
259
+ parsed = json.loads(text.strip())
260
+ return json.dumps(parsed, ensure_ascii=False, indent=2)
261
+ except (json.JSONDecodeError, ValueError):
262
+ return None
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # Python-literal fixer (added separately for clarity)
267
+ # ---------------------------------------------------------------------------
268
+
269
+ # Word-boundary replacements for Python boolean/None literals
270
+ _PYTHON_LITERAL_RE = re.compile(r'\bTrue\b|\bFalse\b|\bNone\b')
271
+ _PYTHON_LITERAL_MAP = {"True": "true", "False": "false", "None": "null"}
272
+
273
+ def _replace_python_literals(text: str) -> str:
274
+ """Replace Python True/False/None with JSON true/false/null.
275
+
276
+ Also converts single-quoted strings to double-quoted where safe.
277
+ Only operates outside of already-valid JSON strings to avoid
278
+ corrupting content that legitimately contains these words.
279
+ """
280
+ # Step 1: True / False / None (simple word-boundary swap)
281
+ result = _PYTHON_LITERAL_RE.sub(lambda m: _PYTHON_LITERAL_MAP[m.group()], text)
282
+
283
+ # Step 2: single-quoted strings -> double-quoted
284
+ # Strategy: only replace 'value' patterns that look like JSON string values
285
+ # or keys (preceded by { , or : and optional whitespace).
286
+ # This is intentionally conservative to avoid mangling prose.
287
+ result = _single_to_double_quotes(result)
288
+ return result
289
+
290
+
291
+ def _single_to_double_quotes(text: str) -> str:
292
+ """Convert single-quoted JSON-ish strings to double-quoted JSON."""
293
+ result = []
294
+ i = 0
295
+ in_single = False
296
+ in_double = False
297
+ sq = chr(39) # '
298
+ dq = chr(34) # "
299
+ bs = chr(92) # \
300
+ while i < len(text):
301
+ ch = text[i]
302
+ if ch == bs and (in_single or in_double):
303
+ result.append(ch)
304
+ i += 1
305
+ if i < len(text):
306
+ result.append(text[i])
307
+ i += 1
308
+ continue
309
+ if ch == sq and not in_double:
310
+ in_single = not in_single
311
+ result.append(dq)
312
+ elif ch == dq and not in_single:
313
+ in_double = not in_double
314
+ result.append(ch)
315
+ elif ch == dq and in_single:
316
+ result.append(bs + dq)
317
+ else:
318
+ result.append(ch)
319
+ i += 1
320
+ return "".join(result)
321
+
322
+ def _parse_and_serialize(text: str):
323
+ """Try to parse *text* as JSON; return re-serialized string or None."""
324
+ try:
325
+ parsed = json.loads(text.strip())
326
+ return json.dumps(parsed, ensure_ascii=False, indent=2)
327
+ except (json.JSONDecodeError, ValueError):
328
+ return None
329
+
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # Python-literal fixer (added separately for clarity)
333
+ # ---------------------------------------------------------------------------
334
+
335
+ # Word-boundary replacements for Python boolean/None literals
336
+ _PYTHON_LITERAL_RE = re.compile(r'\bTrue\b|\bFalse\b|\bNone\b')
337
+ _PYTHON_LITERAL_MAP = {"True": "true", "False": "false", "None": "null"}
338
+
339
+ def _replace_python_literals(text: str) -> str:
340
+ """Replace Python True/False/None with JSON true/false/null.
341
+
342
+ Also converts single-quoted strings to double-quoted where safe.
343
+ Only operates outside of already-valid JSON strings to avoid
344
+ corrupting content that legitimately contains these words.
345
+ """
346
+ # Step 1: True / False / None (simple word-boundary swap)
347
+ result = _PYTHON_LITERAL_RE.sub(lambda m: _PYTHON_LITERAL_MAP[m.group()], text)
348
+
349
+ # Step 2: single-quoted strings -> double-quoted
350
+ # Strategy: only replace 'value' patterns that look like JSON string values
351
+ # or keys (preceded by { , or : and optional whitespace).
352
+ # This is intentionally conservative to avoid mangling prose.
353
+ result = _single_to_double_quotes(result)
354
+ return result
llmclean/repetition.py ADDED
@@ -0,0 +1,257 @@
1
+ """
2
+ repetition.py — detect and trim repetitive content at the end of LLM output.
3
+
4
+ LLMs sometimes loop: they repeat the same sentence, phrase, or paragraph
5
+ multiple times — especially near the end of a generation. This module
6
+ trims that tail while preserving the legitimate content before it.
7
+
8
+ Detection strategies (applied in order, first match wins):
9
+ 1. Exact sentence repetition — the same sentence appears 2+ times in a row
10
+ at the tail of the text.
11
+ 2. Near-duplicate sentences — sentences that are ≥ N% similar (Jaccard on
12
+ word tokens) appear 2+ times in a row.
13
+ 3. N-gram phrase repetition — a multi-word phrase of 4+ tokens repeats
14
+ 3+ times anywhere in the last quarter of the text.
15
+ 4. Paragraph-level repetition — same paragraph (≥ 20 chars) repeated 2+
16
+ times at the end.
17
+
18
+ The function is conservative by design: it only trims from the *end* of the
19
+ text and requires repetition to be clearly back-to-back or densely clustered.
20
+ """
21
+
22
+ import re
23
+ from typing import Optional
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Tunables (all have sensible defaults)
27
+ # ---------------------------------------------------------------------------
28
+
29
+ # Minimum sentence length (chars) before we consider it for dedup
30
+ _MIN_SENTENCE_LEN = 12
31
+
32
+ # Jaccard similarity threshold to call two sentences "near-duplicates"
33
+ _JACCARD_THRESHOLD = 0.82
34
+
35
+ # Minimum n-gram size for phrase repetition detection
36
+ _NGRAM_SIZE = 5
37
+
38
+ # How many times an n-gram must repeat before we act
39
+ _NGRAM_MIN_REPEAT = 3
40
+
41
+ # Minimum paragraph length (chars) for paragraph-level dedup
42
+ _MIN_PARA_LEN = 20
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Public API
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def trim_repetition(text: str, *, similarity_threshold: float = _JACCARD_THRESHOLD) -> str:
50
+ """Detect and remove repetitive content from the tail of *text*.
51
+
52
+ Only removes content from the *end* of the text. If no repetition is
53
+ detected the original text is returned unchanged. Never raises.
54
+
55
+ Parameters
56
+ ----------
57
+ text:
58
+ Raw LLM output to clean.
59
+ similarity_threshold:
60
+ Jaccard similarity (0–1) above which two sentences are considered
61
+ duplicates. Default 0.82. Lower = more aggressive trimming.
62
+
63
+ Returns
64
+ -------
65
+ str
66
+ Cleaned text with repetitive tail removed, or original if no
67
+ repetition is found.
68
+ """
69
+ if not isinstance(text, str) or not text.strip():
70
+ return text
71
+
72
+ original = text
73
+ try:
74
+ result = _trim(text, similarity_threshold)
75
+ return result if result.strip() else original
76
+ except Exception:
77
+ return original
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Core trimmer
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def _trim(text: str, sim_threshold: float) -> str:
85
+ strategies = [
86
+ lambda t: _trim_exact_sentence_repeat(t),
87
+ lambda t: _trim_near_duplicate_sentences(t, sim_threshold),
88
+ lambda t: _trim_ngram_repeat(t),
89
+ lambda t: _trim_paragraph_repeat(t),
90
+ ]
91
+ for strategy in strategies:
92
+ cleaned = strategy(text)
93
+ if cleaned != text:
94
+ # Apply recursively — sometimes repetition is layered
95
+ return _trim(cleaned, sim_threshold)
96
+ return text
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Strategy 1: exact sentence repetition
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def _trim_exact_sentence_repeat(text: str) -> str:
104
+ """Remove consecutive duplicate sentences from the tail."""
105
+ sentences = _split_sentences(text)
106
+ if len(sentences) < 2:
107
+ return text
108
+
109
+ # Find the last run of 2+ identical sentences
110
+ cut_idx = _find_repeat_run(sentences, key=lambda s: s.strip().lower())
111
+ if cut_idx is None:
112
+ return text
113
+
114
+ return _rejoin_sentences(sentences[:cut_idx]).rstrip()
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Strategy 2: near-duplicate sentence detection
119
+ # ---------------------------------------------------------------------------
120
+
121
+ def _trim_near_duplicate_sentences(text: str, threshold: float) -> str:
122
+ """Remove near-duplicate consecutive sentences from the tail."""
123
+ sentences = _split_sentences(text)
124
+ if len(sentences) < 2:
125
+ return text
126
+
127
+ cut_idx = _find_repeat_run(
128
+ sentences,
129
+ key=lambda s: frozenset(_tokenize(s)),
130
+ similarity=lambda a, b: _jaccard(a, b) >= threshold,
131
+ )
132
+ if cut_idx is None:
133
+ return text
134
+
135
+ return _rejoin_sentences(sentences[:cut_idx]).rstrip()
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Strategy 3: n-gram phrase repetition
140
+ # ---------------------------------------------------------------------------
141
+
142
+ def _trim_ngram_repeat(text: str) -> str:
143
+ """Detect a repeated multi-word phrase and trim from its second occurrence."""
144
+ words = _tokenize(text)
145
+ if len(words) < _NGRAM_SIZE * _NGRAM_MIN_REPEAT:
146
+ return text
147
+
148
+ # Only look in the last 75% of the word list to stay conservative
149
+ search_start = len(words) // 4
150
+
151
+ ngram_positions: dict[tuple, list[int]] = {}
152
+ for i in range(search_start, len(words) - _NGRAM_SIZE + 1):
153
+ ngram = tuple(words[i:i + _NGRAM_SIZE])
154
+ ngram_positions.setdefault(ngram, []).append(i)
155
+
156
+ # Find the n-gram with most repeats; trim before its second occurrence
157
+ best: Optional[tuple] = None
158
+ best_positions: list[int] = []
159
+ for ngram, positions in ngram_positions.items():
160
+ if len(positions) >= _NGRAM_MIN_REPEAT:
161
+ if not best or positions[0] < best_positions[0]:
162
+ best = ngram
163
+ best_positions = positions
164
+
165
+ if best is None:
166
+ return text
167
+
168
+ # Reconstruct text up to (but not including) the second occurrence
169
+ cut_word_idx = best_positions[1]
170
+ return _words_to_text_approx(text, words, cut_word_idx)
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Strategy 4: paragraph-level repetition
175
+ # ---------------------------------------------------------------------------
176
+
177
+ def _trim_paragraph_repeat(text: str) -> str:
178
+ """Remove repeated paragraphs at the tail."""
179
+ paras = [p.strip() for p in re.split(r"\n{2,}", text)]
180
+ paras = [p for p in paras if len(p) >= _MIN_PARA_LEN]
181
+ if len(paras) < 2:
182
+ return text
183
+
184
+ cut_idx = _find_repeat_run(paras, key=lambda p: p.lower())
185
+ if cut_idx is None:
186
+ return text
187
+
188
+ # Rejoin paragraphs up to the cut point
189
+ rejoined = "\n\n".join(paras[:cut_idx])
190
+ return rejoined.rstrip()
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Helpers
195
+ # ---------------------------------------------------------------------------
196
+
197
+ def _split_sentences(text: str) -> list[str]:
198
+ """Split text into sentences, keeping the delimiter."""
199
+ parts = re.split(r"(?<=[.!?])\s+", text)
200
+ return [p for p in parts if len(p.strip()) >= _MIN_SENTENCE_LEN]
201
+
202
+
203
+ def _rejoin_sentences(sentences: list[str]) -> str:
204
+ return " ".join(sentences)
205
+
206
+
207
+ def _tokenize(text: str) -> list[str]:
208
+ """Lowercase word tokens, no punctuation."""
209
+ return re.findall(r"\b[a-z0-9]+\b", text.lower())
210
+
211
+
212
+ def _jaccard(a: frozenset, b: frozenset) -> float:
213
+ if not a and not b:
214
+ return 1.0
215
+ intersection = len(a & b)
216
+ union = len(a | b)
217
+ return intersection / union if union else 0.0
218
+
219
+
220
+ def _find_repeat_run(items: list, *, key=None, similarity=None) -> Optional[int]:
221
+ """Return the cut index: keep items[:cut], which preserves ONE copy of the
222
+ repeated item and drops all subsequent duplicates.
223
+
224
+ Scans from the end. Returns None if no such run exists.
225
+ ``key`` maps an item to a comparable value.
226
+ ``similarity`` is an optional callable(key(a), key(b)) -> bool; if
227
+ omitted, equality is used.
228
+ """
229
+ if not items:
230
+ return None
231
+
232
+ keys = [key(item) if key else item for item in items]
233
+ eq = similarity if similarity else (lambda a, b: a == b)
234
+
235
+ n = len(keys)
236
+ # Walk backwards looking for a run of duplicates
237
+ i = n - 1
238
+ while i > 0:
239
+ if eq(keys[i], keys[i - 1]):
240
+ # Found a duplicate pair — find the start of this run
241
+ run_start = i - 1
242
+ while run_start > 0 and eq(keys[run_start], keys[run_start - 1]):
243
+ run_start -= 1
244
+ # Keep one copy: cut point is run_start + 1
245
+ return run_start + 1
246
+ i -= 1
247
+ return None
248
+
249
+
250
+ def _words_to_text_approx(original: str, words: list[str], cut_word_idx: int) -> str:
251
+ """Return original text truncated approximately at the *cut_word_idx*-th word."""
252
+ # Find the character offset of the cut_word_idx-th word in the original
253
+ pattern = re.compile(r"\b[a-z0-9]+\b", re.IGNORECASE)
254
+ for i, m in enumerate(pattern.finditer(original)):
255
+ if i == cut_word_idx:
256
+ return original[:m.start()].rstrip()
257
+ return original
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: llmclean
3
+ Version: 0.1.0
4
+ Summary: Utilities for cleaning and normalizing raw LLM output
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/Tushar-9802/llmclean
7
+ Keywords: llm,cleaning,json,text,ai,output,normalization
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: Text Processing
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0; extra == "dev"
22
+
23
+ # llmclean
24
+
25
+ **A zero-dependency Python library for cleaning and normalizing raw LLM output.**
26
+
27
+ LLMs are inconsistent: they wrap JSON in markdown fences, add prose around code, repeat themselves, and produce subtly broken JSON. `llmclean` handles all of that with three focused utilities.
28
+
29
+ ---
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install llmclean
35
+ ```
36
+
37
+ ---
38
+
39
+ ## Quick start
40
+
41
+ ```python
42
+ from llmclean import strip_fences, enforce_json, trim_repetition
43
+
44
+ # Remove ```json ... ``` wrappers
45
+ strip_fences('```json\n{"name": "Alice"}\n```')
46
+ # → '{"name": "Alice"}'
47
+
48
+ # Extract valid JSON from messy output
49
+ enforce_json('Here you go: {"ok": True, "items": [1,2,3,]}')
50
+ # → '{\n "ok": true,\n "items": [1, 2, 3]\n}'
51
+
52
+ # Remove repeated sentences/paragraphs at the end
53
+ trim_repetition("The answer is 42. This is final. This is final.")
54
+ # → 'The answer is 42. This is final.'
55
+ ```
56
+
57
+ For full examples and edge cases see **[USAGE.md](USAGE.md)**.
58
+
59
+ ---
60
+
61
+ ## Functions
62
+
63
+ | Function | What it fixes |
64
+ |---|---|
65
+ | `strip_fences(text)` | Removes ` ```lang ` / ` ``` ` / `~~~` code fences |
66
+ | `enforce_json(text)` | Extracts valid JSON from fences, prose, trailing commas, Python literals, unquoted keys, unclosed brackets |
67
+ | `trim_repetition(text)` | Removes repeated sentences, near-duplicates, and repeated paragraphs from the tail |
68
+
69
+ ---
70
+
71
+ ## Design principles
72
+
73
+ - **Zero dependencies** — pure Python standard library
74
+ - **Never throws** — every function returns the original input if cleaning fails
75
+ - **Non-destructive** — unchanged input when nothing needs cleaning
76
+ - **Composable** — chain freely
77
+
78
+ ```python
79
+ # Full pipeline
80
+ data = enforce_json(trim_repetition(strip_fences(raw_output)))
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Running tests
86
+
87
+ ```bash
88
+ # With pytest
89
+ pip install "llmclean[dev]"
90
+ pytest -v
91
+
92
+ # Without pytest
93
+ python run_tests.py
94
+ ```
95
+
96
+ ---
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1,8 @@
1
+ llmclean/__init__.py,sha256=roZ27a2NbVhf8ZkocUvsarpO1zFH1Lq3Z5jsbT-ADAs,488
2
+ llmclean/fences.py,sha256=af619jjPrKC_zeM898TlXJxfVSBB0caRECWCYVUmT5s,5585
3
+ llmclean/json_utils.py,sha256=5Vs1M14hC9SGtU3lNWChc8eVuCo_LGNkKQbqfMUHG8Q,12293
4
+ llmclean/repetition.py,sha256=A8hUe9gmGS6WZYvP51uFTlISu-1li9tyxqdlLdV-Glo,9385
5
+ llmclean-0.1.0.dist-info/METADATA,sha256=CDFa9SO5uEUuECO_DP3iIgMEANwxad_cytPDh4U8wks,2795
6
+ llmclean-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
7
+ llmclean-0.1.0.dist-info/top_level.txt,sha256=JCCk4TFXSl0znMtj0lvPjfnRj04Qd7OAYJg681Svl40,9
8
+ llmclean-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ llmclean