kalle07 commited on
Commit
5dac36c
·
verified ·
1 Parent(s): 8aee810

Upload 3 files

Browse files
.gitattributes CHANGED
@@ -34,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  snippet_extracor_v03alpha.exe filter=lfs diff=lfs merge=lfs -text
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  snippet_extracor_v03alpha.exe filter=lfs diff=lfs merge=lfs -text
37
+ snippet_extractor_v03alpha.exe filter=lfs diff=lfs merge=lfs -text
build_snippet.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import subprocess
3
+ import os
4
+ from pathlib import Path
5
+
6
+ # Define the entry point (your main script)
7
+ entry_point = "snippet_extractor_v03alpha.py"
8
+
9
+ # Build command with PyInstaller arguments
10
+ cmd = [
11
+ sys.executable,
12
+ "-m", "PyInstaller",
13
+ "--onefile",
14
+ "--noconfirm",
15
+ "--clean",
16
+ # "--console", # No console window (important for GUI applications)
17
+ "--hidden-import", "wx",
18
+ "--hidden-import", "numpy",
19
+ "--hidden-import", "rapidfuzz",
20
+ "--hidden-import", "typing_extensions",
21
+ ]
22
+
23
+ # Add the entry point
24
+ cmd.append(entry_point)
25
+
26
+ # Execute the build command
27
+ try:
28
+ result = subprocess.run(cmd, check=True, capture_output=True, text=True)
29
+ print("Compilation completed successfully.")
30
+
31
+ # Show any warnings or info from PyInstaller
32
+ if result.stderr:
33
+ for line in result.stderr.split('\n'):
34
+ if line.strip() and not line.startswith('['):
35
+ print(f"PyInstaller: {line}")
36
+
37
+ except subprocess.CalledProcessError as e:
38
+ print(f"Error during compilation: {e}")
39
+ print("Stderr:", e.stderr)
40
+
41
+ except FileNotFoundError:
42
+ print("PyInstaller not found. Please install it with 'pip install pyinstaller'")
snippet_extractor_v03alpha.exe ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f188a294fb960fb87f437e01e597943bd55baa76c777a7b6d8a7f8e6a3440448
3
+ size 26206525
snippet_extractor_v03alpha.py ADDED
@@ -0,0 +1,956 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import wx
2
+ import os
3
+ import re
4
+ import json
5
+ import threading
6
+ from pathlib import Path
7
+ from rapidfuzz import fuzz, process
8
+ from typing import List, Tuple, Any, Optional
9
+
10
+
11
+ # --- Engine ---
12
+
13
+ class TextProcessor:
14
+ def __init__(self, file_path: str):
15
+ self.file_path = Path(file_path)
16
+ self.text_bytes = None
17
+ self.decoded_text = None
18
+ self.char_to_byte = None
19
+ self.load_and_process_file()
20
+
21
+ def load_and_process_file(self):
22
+ try:
23
+ self.text_bytes = self.file_path.read_bytes()
24
+ self.decoded_text = self.text_bytes.decode("utf-8", errors="surrogateescape")
25
+ self._build_char_to_byte_mapping()
26
+ except Exception as e:
27
+ raise RuntimeError(f"Failed to read file {self.file_path}: {str(e)}")
28
+
29
+ def _build_char_to_byte_mapping(self):
30
+ self.char_to_byte = [0]
31
+ for ch in self.decoded_text:
32
+ self.char_to_byte.append(self.char_to_byte[-1] + len(ch.encode("utf-8", errors="surrogateescape")))
33
+
34
+
35
+ class Match:
36
+ def __init__(self, pattern, text, start_char, end_char):
37
+ self.pattern = pattern
38
+ self.text = text
39
+ self.start_char = start_char
40
+ self.end_char = end_char
41
+ self.byte_start = None
42
+ self.byte_end = None
43
+
44
+ def set_byte_positions(self, char_to_byte_map):
45
+ self.byte_start = char_to_byte_map[self.start_char]
46
+ self.byte_end = char_to_byte_map[self.end_char]
47
+
48
+
49
+ class SnippetExtractor:
50
+ # Pre-compiled regex patterns for performance
51
+ _regex_cache = {}
52
+
53
+ # -------------
54
+ # wildcard part
55
+ # -------------
56
+ @staticmethod
57
+ def wildcards_to_regex(pattern: str) -> str:
58
+ """
59
+ Convert wildcard pattern to regex with caching.
60
+ - '?' → matches exactly one character of any type
61
+ - '*' → matches zero or more non-whitespace chars
62
+ """
63
+ try:
64
+ # Use cache for better performance
65
+ if pattern in SnippetExtractor._regex_cache:
66
+ return SnippetExtractor._regex_cache[pattern]
67
+
68
+ regex_parts = []
69
+ i = 0
70
+ while i < len(pattern):
71
+ ch = pattern[i]
72
+ if ch == '?':
73
+ regex_parts.append('.')
74
+ i += 1
75
+ elif ch == '*':
76
+ regex_parts.append(r'(?:\S*)')
77
+ i += 1
78
+ else:
79
+ regex_parts.append(re.escape(ch))
80
+ i += 1
81
+
82
+ result = "".join(regex_parts)
83
+ SnippetExtractor._regex_cache[pattern] = result
84
+ return result
85
+ except Exception as e:
86
+ raise RuntimeError(f"Failed to convert wildcard pattern '{pattern}' to regex: {str(e)}")
87
+
88
+
89
+ @staticmethod
90
+ # hanlde wildcard pattern '?' and '*'
91
+ def expand_to_word_boundaries(text: str, start_char: int, end_char: int, pattern: str):
92
+ """
93
+ Expand match boundaries depending on '*' position.
94
+ """
95
+ try:
96
+ # Exact match for '?' only patterns
97
+ if '?' in pattern and '*' not in pattern:
98
+ return text[start_char:end_char], start_char, end_char
99
+
100
+ expanded_start = start_char
101
+ expanded_end = end_char
102
+
103
+ if '*' in pattern:
104
+ if pattern.startswith('*') and not pattern.endswith('*'):
105
+ # expand LEFT until whitespace - optimized with backward search
106
+ while expanded_start > 0 and not text[expanded_start - 1].isspace():
107
+ expanded_start -= 1
108
+
109
+ elif pattern.endswith('*') and not pattern.startswith('*'):
110
+ # expand RIGHT until whitespace - optimized forward search
111
+ while expanded_end < len(text) and not text[expanded_end].isspace():
112
+ expanded_end += 1
113
+
114
+ else:
115
+ # '*' is inside → expand both sides until visible character
116
+ if expanded_start > 0:
117
+ expanded_start -= 1
118
+ if expanded_end < len(text):
119
+ expanded_end += 1
120
+
121
+ return text[expanded_start:expanded_end], expanded_start, expanded_end
122
+
123
+ except Exception as e:
124
+ raise RuntimeError(f"Failed to expand word boundaries for pattern '{pattern}': {str(e)}")
125
+
126
+
127
+ # find wildcard matches
128
+ @staticmethod
129
+ def find_matches(patterns, decoded_text: str, char_to_byte_map):
130
+ """
131
+ Find all matches. These are also passed on to fuzzy match.
132
+ """
133
+ try:
134
+ matches = []
135
+
136
+ # Pre-compile all patterns once - cached version
137
+ compiled_patterns = {}
138
+ for pattern in patterns:
139
+ if not pattern:
140
+ continue
141
+ if '*' in pattern or '?' in pattern:
142
+ regex_pattern = SnippetExtractor.wildcards_to_regex(pattern)
143
+ compiled_patterns[pattern] = re.compile(regex_pattern, re.IGNORECASE | re.DOTALL)
144
+ else:
145
+ escaped_pattern = re.escape(pattern)
146
+ regex_pattern = r'\b' + escaped_pattern + r'\b'
147
+ compiled_patterns[pattern] = re.compile(regex_pattern, re.IGNORECASE)
148
+
149
+ for pattern, compiled_pattern in compiled_patterns.items():
150
+ try:
151
+ # Check stop event before each iteration
152
+ for match in compiled_pattern.finditer(decoded_text):
153
+ start_pos, end_pos = match.start(), match.end()
154
+ match_text = decoded_text[start_pos:end_pos]
155
+
156
+ if '*' in pattern or '?' in pattern:
157
+ expanded_match_text, expanded_start, expanded_end = SnippetExtractor.expand_to_word_boundaries(
158
+ decoded_text, start_pos, end_pos, pattern
159
+ )
160
+ match_text = expanded_match_text
161
+ start_pos = expanded_start
162
+ end_pos = expanded_end
163
+
164
+ match_obj = Match(pattern, match_text, start_pos, end_pos)
165
+ match_obj.set_byte_positions(char_to_byte_map)
166
+ matches.append(match_obj)
167
+ except re.error as e:
168
+ raise RuntimeError(f"Regex compilation error for pattern '{pattern}': {str(e)}")
169
+
170
+ return matches
171
+ except Exception as e:
172
+ raise RuntimeError(f"Failed to find matches: {str(e)}")
173
+
174
+
175
+ # distance check of all found matches
176
+ @staticmethod
177
+ def filter_by_distance(matches, distance: int, buzzwords):
178
+ """
179
+ filter matches by distance limit given by user input.
180
+ """
181
+ try:
182
+ if not matches:
183
+ return []
184
+
185
+ # Use sets for faster membership checks and avoid redundant lookups
186
+ pattern_positions = {word: set() for word in buzzwords}
187
+ for m in matches:
188
+ if m.pattern in pattern_positions:
189
+ pattern_positions[m.pattern].add((m.start_char, m.end_char))
190
+
191
+ if any(not pos_set for pos_set in pattern_positions.values()):
192
+ return []
193
+
194
+ combined_spans = []
195
+ first_word = list(buzzwords)[0]
196
+
197
+ for start1, end1 in pattern_positions[first_word]:
198
+ span_candidates = [(start1, end1)]
199
+ for other_word in buzzwords:
200
+ if other_word == first_word:
201
+ continue
202
+ best_match = None
203
+ min_distance = float('inf')
204
+
205
+ # Direct set iteration - much faster than list lookup
206
+ for start2, end2 in pattern_positions[other_word]:
207
+ dist = abs(start1 - start2)
208
+ if dist <= distance and dist < min_distance:
209
+ min_distance = dist
210
+ best_match = (start2, end2)
211
+
212
+ if best_match:
213
+ span_candidates.append(best_match)
214
+
215
+ if len(span_candidates) == len(buzzwords):
216
+ min_pos = min(s for s, _ in span_candidates)
217
+ max_pos = max(e for _, e in span_candidates)
218
+ combined_spans.append((min_pos, max_pos))
219
+
220
+ return combined_spans
221
+ except Exception as e:
222
+ raise RuntimeError(f"Failed to filter by distance: {str(e)}")
223
+
224
+ # snippet extraction, pre_ratio and post_ratio given from user
225
+ @staticmethod
226
+ def extract_snippets(matches, snippet_size, pre_ratio, post_ratio, decoded_text):
227
+ try:
228
+ snippets = []
229
+ for start, end in matches:
230
+ pre_chars = int(snippet_size * pre_ratio)
231
+ post_chars = int(snippet_size * post_ratio)
232
+ snippet_start = max(0, start - pre_chars)
233
+ snippet_end = min(len(decoded_text), end + post_chars)
234
+ snippets.append((snippet_start, snippet_end))
235
+ return snippets
236
+ except Exception as e:
237
+ raise RuntimeError(f"Failed to extract snippets: {str(e)}")
238
+
239
+ # merge snippet if overlapping
240
+ @staticmethod
241
+ def merge_snippets(snippets):
242
+ try:
243
+ if not snippets:
244
+ return [], 0
245
+
246
+ total_snippets = len(snippets)
247
+
248
+ # Sort once instead of repeatedly during merging
249
+ sorted_snippets = sorted(snippets, key=lambda x: x[0])
250
+ merged = [sorted_snippets[0]]
251
+
252
+ for current in sorted_snippets[1:]:
253
+ last_end = merged[-1][1]
254
+ if current[0] <= last_end:
255
+ # Fast merge - no need to check all previous ones
256
+ merged[-1] = (merged[-1][0], max(last_end, current[1]))
257
+ else:
258
+ merged.append(current)
259
+
260
+ return merged, total_snippets
261
+ except Exception as e:
262
+ raise RuntimeError(f"Failed to merge snippets: {str(e)}")
263
+
264
+
265
+ # ----------
266
+ # Fuzzy part
267
+ # ----------
268
+ # use results of wildcard find_matches for fuzzy search
269
+ @staticmethod
270
+ def find_fuzzy_matches(decoded_text: str, wildcard_matches: List[Match], threshold: float, stop_event=None):
271
+ """
272
+ Search the entire text using matches from wildcard search as fuzzily searched words.
273
+ Returns list of tuples (match_start, match_end, score, original_word) where score >= threshold.
274
+ """
275
+ try:
276
+ fuzzy_results = []
277
+
278
+ # Get all unique texts from wildcard matches to use as buzzwords
279
+ buzzwords = [match.text for match in wildcard_matches if match.text.strip()]
280
+
281
+ if not buzzwords:
282
+ return fuzzy_results
283
+
284
+ # Use rapidfuzz.process.extract for efficient fuzzy matching
285
+ # Process each word in the text against our buzzwords
286
+ words = decoded_text.split()
287
+ processed_words = []
288
+
289
+ # Create a list of (word, start_pos, end_pos) tuples to track positions
290
+ current_pos = 0
291
+ for word in words:
292
+ if stop_event and stop_event.is_set():
293
+ raise RuntimeError("Fuzzy search was aborted")
294
+
295
+ # Find exact position of this word in original text
296
+ try:
297
+ pos = decoded_text.index(word, current_pos)
298
+ processed_words.append((word, pos, pos + len(word)))
299
+ current_pos = pos + len(word)
300
+ except ValueError:
301
+ # Word not found - skip it
302
+ continue
303
+
304
+ # For each word in the document, check fuzzy matches against our buzzwords
305
+ for word, start_pos, end_pos in processed_words:
306
+ if stop_event and stop_event.is_set():
307
+ raise RuntimeError("Fuzzy search was aborted")
308
+
309
+ # Find best match among buzzwords using rapidfuzz
310
+ try:
311
+ # Get top match with score >= threshold
312
+ matches = process.extract(
313
+ word,
314
+ buzzwords,
315
+ limit=1,
316
+ scorer=fuzz.ratio,
317
+ score_cutoff=threshold
318
+ )
319
+
320
+ if matches and len(matches) > 0:
321
+ best_match_text, score, _ = matches[0]
322
+ # Add the position of this match in original text + the actual word that was matched
323
+ fuzzy_results.append((start_pos, end_pos, score, word))
324
+
325
+ except Exception as e:
326
+ # Continue with other words if one fails
327
+ continue
328
+
329
+ return fuzzy_results
330
+
331
+ except Exception as e:
332
+ raise RuntimeError(f"Failed to find fuzzy matches: {str(e)}")
333
+
334
+
335
+
336
+ # filter by distance for fuzzy matches if "AND
337
+ # filter by distance for fuzzy matches - NEW IMPLEMENTATION
338
+ @staticmethod
339
+ def filter_by_distance_fuzzy(fuzzy_matches, distance_threshold):
340
+ """
341
+ Filter fuzzy matches requiring all buzzwords within distance threshold.
342
+ Groups matching words together and only keeps groups where all required
343
+ buzzwords appear within the specified distance.
344
+
345
+ Args:
346
+ fuzzy_matches: List of tuples (start_pos, end_pos, score, original_word)
347
+ distance_threshold: Maximum character distance between matches
348
+
349
+ Returns:
350
+ List of filtered fuzzy match tuples
351
+ """
352
+ try:
353
+ if not fuzzy_matches:
354
+ return []
355
+
356
+ # Group matches by their original word (buzzword)
357
+ word_groups = {}
358
+ for start, end, score, word in fuzzy_matches:
359
+ if word not in word_groups:
360
+ word_groups[word] = []
361
+ word_groups[word].append((start, end, score))
362
+
363
+ # Debugging output
364
+ print(f"DEBUG: Processing {len(word_groups)} unique words from fuzzy matches")
365
+ for word, positions in word_groups.items():
366
+ print(f" Word '{word}': {len(positions)} matches at positions {[pos[0] for pos in positions]}")
367
+
368
+ # Get all buzzwords that were actually found
369
+ found_buzzwords = list(word_groups.keys())
370
+
371
+ if len(found_buzzwords) < 2:
372
+ print("DEBUG: Only one unique word found - returning all matches")
373
+ return fuzzy_matches
374
+
375
+ # For multiple words, create sliding windows to find valid groups
376
+ # This approach checks each possible combination of positions for different words
377
+ results = []
378
+
379
+ # Sort all positions by start position to make grouping easier
380
+ all_positions = []
381
+ for word, pos_list in word_groups.items():
382
+ for start, end, score in pos_list:
383
+ all_positions.append((start, end, score, word))
384
+
385
+ all_positions.sort(key=lambda x: x[0]) # Sort by start position
386
+
387
+ print(f"DEBUG: Total positions to process: {len(all_positions)}")
388
+
389
+ # Try to find groups where multiple buzzwords appear within distance
390
+ i = 0
391
+ while i < len(all_positions):
392
+ current_start = all_positions[i][0]
393
+ current_end = all_positions[i][1]
394
+
395
+ # Create a window around this position
396
+ window_end = current_start + distance_threshold
397
+
398
+ # Collect all words in this window
399
+ window_words = {}
400
+ j = i
401
+ while j < len(all_positions) and all_positions[j][0] <= window_end:
402
+ pos_start, pos_end, score, word = all_positions[j]
403
+ if word not in window_words:
404
+ window_words[word] = []
405
+ window_words[word].append((pos_start, pos_end, score))
406
+ j += 1
407
+
408
+ # Check if we have matches for ALL required buzzwords
409
+ if len(window_words) >= 2: # At least two different words found together
410
+ # For now, just return all the original matches from this window
411
+ # This is a simpler approach - you could get more sophisticated later
412
+ print(f"DEBUG: Found group with {len(window_words)} words in range [{current_start}, {window_end}]")
413
+ for word, positions in window_words.items():
414
+ print(f" Word '{word}': {[pos[0] for pos in positions]}")
415
+
416
+ # Add all matches from this valid window
417
+ for word, positions in window_words.items():
418
+ for start, end, score in positions:
419
+ results.append((start, end, score, word))
420
+ else:
421
+ print(f"DEBUG: Window [{current_start}, {window_end}] only had {len(window_words)} unique words")
422
+
423
+ i = j
424
+
425
+ # Remove duplicates while preserving order
426
+ seen = set()
427
+ final_results = []
428
+ for item in results:
429
+ if item not in seen:
430
+ seen.add(item)
431
+ final_results.append(item)
432
+
433
+ print(f"DEBUG: Final filtered results count: {len(final_results)}")
434
+ return final_results
435
+
436
+ except Exception as e:
437
+ raise RuntimeError(f"Failed to filter fuzzy matches by distance: {str(e)}")
438
+
439
+
440
+
441
+
442
+
443
+ # extract fuzzy snippets
444
+ @staticmethod
445
+ def extract_snippets_fuzzy(matches, snippet_size, pre_ratio, post_ratio, decoded_text):
446
+ """
447
+ Extract snippets from fuzzy matches.
448
+ """
449
+ try:
450
+ snippets = []
451
+ for start, end, score, original_word in matches:
452
+ # Apply ratio-based padding to include more context
453
+ pre_chars = int(snippet_size * pre_ratio)
454
+ post_chars = int(snippet_size * post_ratio)
455
+ snippet_start = max(0, start - pre_chars)
456
+ snippet_end = min(len(decoded_text), end + post_chars)
457
+
458
+ snippets.append((snippet_start, snippet_end, score, original_word))
459
+ return snippets
460
+ except Exception as e:
461
+ raise RuntimeError(f"Failed to extract fuzzy snippets: {str(e)}")
462
+
463
+
464
+
465
+ # merge fuzzy snippets
466
+ @staticmethod
467
+ def merge_snippets_fuzzy(snippets):
468
+ """
469
+ Merge overlapping or adjacent fuzzy snippets.
470
+ """
471
+ try:
472
+ if not snippets:
473
+ return [], 0
474
+
475
+ total_snippets = len(snippets)
476
+
477
+ # Sort by start position
478
+ sorted_snippets = sorted(snippets, key=lambda x: x[0])
479
+ merged = [sorted_snippets[0]]
480
+
481
+ for current in sorted_snippets[1:]:
482
+ last_end = merged[-1][1]
483
+
484
+ if current[0] <= last_end:
485
+ # Merge overlapping or adjacent snippets
486
+ new_start = merged[-1][0]
487
+ new_end = max(last_end, current[1])
488
+
489
+ # Update the score to be average of both scores (or keep highest)
490
+ avg_score = (merged[-1][2] + current[2]) / 2.0
491
+
492
+ merged[-1] = (new_start, new_end, avg_score, merged[-1][3]) # Keep original word from first
493
+ else:
494
+ merged.append(current)
495
+
496
+ return merged, total_snippets
497
+ except Exception as e:
498
+ raise RuntimeError(f"Failed to merge fuzzy snippets: {str(e)}")
499
+
500
+
501
+
502
+
503
+ # --- Main search function ---
504
+
505
+ def run_search_for_file(file_path: str, config: dict, stop_event: threading.Event):
506
+ """
507
+ Run search for a single file. Writes output_snippets.txt and output_fuzzy_snippets.txt.
508
+ Returns (wildcard_text, fuzzy_text) strings for UI display.
509
+ Optimized version with faster operations.
510
+ """
511
+ try:
512
+ processor = TextProcessor(file_path)
513
+ buzzwords = [bw for bw in config.get("buzzwords", []) if bw.strip()]
514
+
515
+ # Use set for filter_by_distance membership but keep list for order preservation
516
+ buzzwords_set = list(dict.fromkeys(buzzwords)) # unique preserving order
517
+
518
+ # wildcard-part - optimized
519
+ all_matches = SnippetExtractor.find_matches(
520
+ buzzwords_set,
521
+ processor.decoded_text,
522
+ processor.char_to_byte
523
+ )
524
+
525
+ if config.get("search_type", "AND") == "AND":
526
+ final_matches = SnippetExtractor.filter_by_distance(
527
+ all_matches,
528
+ config.get("distance_match", 100),
529
+ buzzwords_set
530
+ )
531
+ else:
532
+ final_matches = [(m.start_char, m.end_char) for m in all_matches]
533
+
534
+ snippets = SnippetExtractor.extract_snippets(
535
+ final_matches,
536
+ config.get("snippet_size", 2000),
537
+ config.get("pre_ratio", 0.3),
538
+ config.get("post_ratio", 0.7),
539
+ processor.decoded_text
540
+ )
541
+
542
+ merged_snippets, total_snippets = SnippetExtractor.merge_snippets(snippets)
543
+
544
+ # Build wildcard textual output - optimized with pre-calculated values
545
+ wildcard_blocks = []
546
+ for idx, (start, end) in enumerate(merged_snippets):
547
+ if stop_event.is_set():
548
+ raise RuntimeError("Search was aborted")
549
+
550
+ s_b = processor.char_to_byte[start]
551
+ e_b = processor.char_to_byte[end]
552
+ snippet_bytes = processor.text_bytes[s_b:e_b]
553
+ snippet_text = snippet_bytes.decode("utf-8", errors="surrogateescape")
554
+ cleaned = re.sub(r'\s+', ' ', snippet_text) # without \n and \r
555
+
556
+ # Find first match
557
+ match_text = None
558
+ byte_start = None
559
+ for m in all_matches:
560
+ if start <= m.start_char and end >= m.end_char:
561
+ match_text = m.text
562
+ byte_start = m.byte_start
563
+ break
564
+
565
+ block = [
566
+ {"Excerpt": idx + 1},
567
+ {"Match Text": match_text},
568
+ {"Start position, match_text": byte_start},
569
+ {"Content": cleaned},
570
+ ]
571
+ wildcard_blocks.append(json.dumps(block, ensure_ascii=False, indent=1))
572
+
573
+ wildcard_text = "\n\n".join(wildcard_blocks)
574
+
575
+ # fuzzy part, similar approach like wildcard
576
+ ft = config.get("fuzzy_threshold", 96)
577
+ if not isinstance(ft, (int, float)) or not (0 <= ft <= 100):
578
+ ft = 96.0 # default threshold
579
+
580
+ # Use all wildcard matches as input for fuzzy search
581
+ fuzzy_matches = SnippetExtractor.find_fuzzy_matches(
582
+ processor.decoded_text,
583
+ all_matches,
584
+ ft
585
+ )
586
+
587
+ if config.get("search_type", "AND") == "AND":
588
+ if len(buzzwords) > 1:
589
+ filtered_fuzzy_matches = SnippetExtractor.filter_by_distance_fuzzy(
590
+ fuzzy_matches,
591
+ config.get("distance_match", 100)
592
+ )
593
+ else:
594
+ # fallback to OR behavior when only one buzzword
595
+ filtered_fuzzy_matches = fuzzy_matches
596
+ else:
597
+ filtered_fuzzy_matches = fuzzy_matches
598
+
599
+ # Extract snippets for fuzzy matches
600
+ fuzzy_snippets = SnippetExtractor.extract_snippets_fuzzy(
601
+ filtered_fuzzy_matches,
602
+ config.get("snippet_size", 2000),
603
+ config.get("pre_ratio", 0.3),
604
+ config.get("post_ratio", 0.7),
605
+ processor.decoded_text
606
+ )
607
+
608
+ # Merge fuzzy snippets
609
+ merged_fuzzy_snippets, total_fuzzy_snippets = SnippetExtractor.merge_snippets_fuzzy(fuzzy_snippets)
610
+
611
+ # Build fuzzy textual output - now with actual matched text and byte positions
612
+ fuzzy_blocks = []
613
+ for idx, (start, end, score, original_word) in enumerate(merged_fuzzy_snippets):
614
+ if stop_event.is_set():
615
+ raise RuntimeError("Search was aborted")
616
+
617
+ s_b = processor.char_to_byte[start]
618
+ e_b = processor.char_to_byte[end]
619
+ snippet_bytes = processor.text_bytes[s_b:e_b]
620
+ snippet_text = snippet_bytes.decode("utf-8", errors="surrogateescape")
621
+ cleaned_snippet = re.sub(r'\s+', ' ', snippet_text) # without \n and \r
622
+
623
+ # Get the actual byte start position of the matched word in the original file
624
+ match_byte_start = None
625
+ for fm in fuzzy_matches: # Use original fuzzy_matches, not filtered_fuzzy_matches
626
+ if fm[3] == original_word and fm[0] >= start and fm[1] <= end:
627
+ # Found the exact fuzzy match that corresponds to this merged snippet
628
+ match_byte_start = processor.char_to_byte[fm[0]]
629
+ break
630
+
631
+ block = [
632
+ {"Excerpt": idx + 1},
633
+ {"Match Text": original_word}, # Show the actual word that was matched
634
+ {"Score": score},
635
+ {"Start Byte Position": match_byte_start}, # Add byte position to JSON output
636
+ {"Content": cleaned_snippet},
637
+ ]
638
+ fuzzy_blocks.append(json.dumps(block, ensure_ascii=False, indent=1))
639
+
640
+
641
+ fuzzy_text = "\n\n".join(fuzzy_blocks)
642
+
643
+ return wildcard_text, fuzzy_text
644
+ except Exception as e:
645
+ raise RuntimeError(f"Search failed for file {file_path}: {str(e)}")
646
+
647
+ # ---
648
+ # GUI
649
+ # ---
650
+
651
+ class SearchThread(threading.Thread):
652
+ def __init__(self, paths, config, stop_event, on_complete):
653
+ super().__init__()
654
+ self.paths = paths
655
+ self.config = config
656
+ self.stop_event = stop_event
657
+ self.on_complete = on_complete # callback(wildcard_text, fuzzy_text, finished_ok)
658
+
659
+ def run(self):
660
+ try:
661
+ agg_wild = []
662
+ agg_fuzzy = []
663
+ for p in self.paths:
664
+ if self.stop_event.is_set():
665
+ self.on_complete("", "", False)
666
+ return
667
+ try:
668
+ w, f = run_search_for_file(p, self.config, self.stop_event)
669
+ agg_wild.append(w)
670
+ agg_fuzzy.append(f)
671
+ except Exception as e:
672
+ # If one file fails, continue with others but report the error
673
+ if not self.stop_event.is_set(): # Only show error if not aborted
674
+ self.on_complete(f"ERROR processing {p}: {str(e)}", f"ERROR processing {p}: {str(e)}", False)
675
+ return
676
+ wildcard_text = "\n\n--- FILE BOUNDARY ---\n\n".join(agg_wild)
677
+ fuzzy_text = "\n\n--- FILE BOUNDARY ---\n\n".join(agg_fuzzy)
678
+ self.on_complete(wildcard_text, fuzzy_text, True)
679
+ except Exception as e:
680
+ # Handle exceptions in the thread itself
681
+ self.on_complete(f"THREAD ERROR: {str(e)}", f"THREAD ERROR: {str(e)}", False)
682
+
683
+ class MainFrame(wx.Frame):
684
+ def __init__(self):
685
+ super().__init__(None, title="Text Search by Sevenof9 (v3_alpha)", size=(1200, 1000))
686
+ panel = wx.Panel(self)
687
+
688
+ # Top: file / dir pickers and right-side label for chosen path
689
+ top_sizer = wx.BoxSizer(wx.HORIZONTAL)
690
+ self.file_picker = wx.FilePickerCtrl(panel, style=wx.FLP_OPEN | wx.FLP_FILE_MUST_EXIST)
691
+ self.dir_picker = wx.DirPickerCtrl(panel)
692
+ self.path_label = wx.StaticText(panel, label="No file/folder selected")
693
+
694
+ top_sizer.Add(self.file_picker, 0, wx.ALL | wx.ALIGN_LEFT, 4)
695
+ top_sizer.Add(self.dir_picker, 0, wx.ALL | wx.ALIGN_LEFT, 4)
696
+ top_sizer.Add(self.path_label, 0, wx.ALL | wx.ALIGN_LEFT, 6)
697
+
698
+ # Middle: left = buzzwords (4 fields with AND/OR buttons between), right = controls/config
699
+ middle_sizer = wx.BoxSizer(wx.HORIZONTAL)
700
+
701
+ # Left: buzzwords area
702
+ buzz_sizer = wx.BoxSizer(wx.VERTICAL)
703
+ self.buzz_inputs = []
704
+ self.toggle_buttons = []
705
+ for i in range(4):
706
+ txt = wx.TextCtrl(panel, size=(250, -1))
707
+ self.buzz_inputs.append(txt)
708
+ buzz_sizer.Add(txt, 0, wx.ALL | wx.ALIGN_LEFT, 2)
709
+ if i < 3:
710
+ btn = wx.Button(panel, label="AND", size=(80, 24))
711
+ btn.Bind(wx.EVT_BUTTON, self.on_toggle)
712
+ self.toggle_buttons.append(btn)
713
+ buzz_sizer.Add(btn, 0, wx.ALL | wx.ALIGN_LEFT, 2)
714
+
715
+ middle_sizer.Add(buzz_sizer, 0, wx.ALL | wx.ALIGN_LEFT, 6)
716
+
717
+ # Right: controls and config
718
+ ctrl_sizer = wx.BoxSizer(wx.VERTICAL)
719
+
720
+ # Start / Abort
721
+ self.start_button = wx.Button(panel, label="Start Search")
722
+ self.abort_button = wx.Button(panel, label="Abort")
723
+ self.abort_button.Disable()
724
+ self.start_button.Bind(wx.EVT_BUTTON, self.on_start)
725
+ self.abort_button.Bind(wx.EVT_BUTTON, self.on_abort)
726
+ ctrl_sizer.Add(self.start_button, 0, wx.ALL | wx.ALIGN_LEFT, 4)
727
+ ctrl_sizer.Add(self.abort_button, 0, wx.ALL | wx.ALIGN_LEFT, 4)
728
+
729
+ # Config fields
730
+ self.cfg_fields = {}
731
+ defaults = [("snippet_size", "2000"),
732
+ ("pre_ratio", "0.3"),
733
+ ("post_ratio", "0.7"),
734
+ ("distance_match", "300"),
735
+ ("fuzzy_threshold", "96")]
736
+ for label, val in defaults:
737
+ row = wx.BoxSizer(wx.HORIZONTAL)
738
+ lbl = wx.StaticText(panel, label=label + ":")
739
+ fld = wx.TextCtrl(panel, value=val, size=(50, -1))
740
+ # Bind focus event for validation
741
+ fld.Bind(wx.EVT_KILL_FOCUS, self.on_field_focus_lost)
742
+ row.Add(lbl, 0, wx.ALL | wx.ALIGN_LEFT, 2)
743
+ row.Add(fld, 0, wx.ALL | wx.ALIGN_LEFT, 2)
744
+ ctrl_sizer.Add(row, 0, wx.ALL | wx.ALIGN_LEFT, 2)
745
+ self.cfg_fields[label] = fld
746
+
747
+ middle_sizer.Add(ctrl_sizer, 0, wx.ALL | wx.ALIGN_LEFT, 6)
748
+
749
+ # Bottom: results (wildcard and fuzzy) across full width
750
+ result_sizer = wx.BoxSizer(wx.VERTICAL)
751
+ result_sizer.Add(wx.StaticText(panel, label="Wildcard Results (output_snippets.txt):"), 0, wx.ALL | wx.ALIGN_LEFT, 2)
752
+ self.wildcard_box = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY, size=(-1, 220))
753
+ result_sizer.Add(self.wildcard_box, 1, wx.EXPAND | wx.ALL, 4)
754
+ result_sizer.Add(wx.StaticText(panel, label="Fuzzy Results (output_fuzzy_snippets.txt):"), 0, wx.ALL | wx.ALIGN_LEFT, 2)
755
+ self.fuzzy_box = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY, size=(-1, 220))
756
+ result_sizer.Add(self.fuzzy_box, 1, wx.EXPAND | wx.ALL, 4)
757
+
758
+ # Main vertical layout using only horizontal alignment flags where appropriate
759
+ main_sizer = wx.BoxSizer(wx.VERTICAL)
760
+ main_sizer.Add(top_sizer, 0, wx.ALL | wx.ALIGN_LEFT, 6)
761
+ main_sizer.Add(middle_sizer, 0, wx.ALL | wx.ALIGN_LEFT, 6)
762
+ main_sizer.Add(result_sizer, 1, wx.EXPAND | wx.ALL, 6)
763
+
764
+ panel.SetSizer(main_sizer)
765
+
766
+ # Events
767
+ self.file_picker.Bind(wx.EVT_FILEPICKER_CHANGED, self.on_path_change)
768
+ self.dir_picker.Bind(wx.EVT_DIRPICKER_CHANGED, self.on_path_change)
769
+
770
+ # Thread controls
771
+ self.worker = None
772
+ self.stop_event = threading.Event()
773
+
774
+ def on_field_focus_lost(self, evt):
775
+ """Validate all fields when any field loses focus"""
776
+ self.validate_all_fields()
777
+ evt.Skip() # Allow normal processing to continue
778
+
779
+ def validate_all_fields(self):
780
+ """Validate all configuration fields and enforce dependencies"""
781
+ try:
782
+ # Get current values
783
+ snippet_size_val = self.cfg_fields["snippet_size"].GetValue().strip()
784
+ pre_ratio_val = self.cfg_fields["pre_ratio"].GetValue().strip()
785
+ post_ratio_val = self.cfg_fields["post_ratio"].GetValue().strip()
786
+ distance_match_val = self.cfg_fields["distance_match"].GetValue().strip()
787
+ fuzzy_threshold_val = self.cfg_fields["fuzzy_threshold"].GetValue().strip()
788
+
789
+ # Default values if empty
790
+ snippet_size_val = snippet_size_val if snippet_size_val else "2000"
791
+ pre_ratio_val = pre_ratio_val if pre_ratio_val else "0.3"
792
+ post_ratio_val = post_ratio_val if post_ratio_val else "0.7"
793
+ distance_match_val = distance_match_val if distance_match_val else "300"
794
+ fuzzy_threshold_val = fuzzy_threshold_val if fuzzy_threshold_val else "96"
795
+
796
+ # Validate and process each field
797
+ # snippet_size: min=0, max=999999, round to integer
798
+ snippet_size = int(float(snippet_size_val)) if snippet_size_val else 2000
799
+ snippet_size = max(0, min(999999, snippet_size))
800
+
801
+ # pre_ratio: min=0.1, max=0.9, 1 decimal place
802
+ pre_ratio = round(float(pre_ratio_val), 1) if pre_ratio_val else 0.3
803
+ pre_ratio = max(0.1, min(0.9, pre_ratio))
804
+
805
+ # post_ratio: min=0.1, max=0.9, 1 decimal place
806
+ post_ratio = round(float(post_ratio_val), 1) if post_ratio_val else 0.7
807
+ post_ratio = max(0.1, min(0.9, post_ratio))
808
+
809
+ # Ensure pre + post = 1 (adjust one to maintain sum)
810
+ total = pre_ratio + post_ratio
811
+ if abs(total - 1.0) > 0.001: # Allow small floating point differences
812
+ # Adjust post_ratio to make the sum equal to 1.0
813
+ post_ratio = round(1.0 - pre_ratio, 1)
814
+ self.cfg_fields["post_ratio"].SetValue(str(post_ratio))
815
+
816
+ # distance_match: min=0, max=snippet_size, round to integer
817
+ distance_match = int(float(distance_match_val)) if distance_match_val else 300
818
+ distance_match = max(0, min(snippet_size, distance_match))
819
+
820
+ # If snippet_size < distance_match, adjust snippet_size to match
821
+ if snippet_size < distance_match:
822
+ snippet_size = distance_match
823
+ self.cfg_fields["snippet_size"].SetValue(str(snippet_size))
824
+
825
+ # fuzzy_threshold: min=1, max=100, round to integer
826
+ fuzzy_threshold = int(float(fuzzy_threshold_val)) if fuzzy_threshold_val else 96
827
+ fuzzy_threshold = max(1, min(100, fuzzy_threshold))
828
+
829
+ # Apply validated values back to fields
830
+ self.cfg_fields["snippet_size"].SetValue(str(snippet_size))
831
+ self.cfg_fields["pre_ratio"].SetValue(str(pre_ratio))
832
+ self.cfg_fields["post_ratio"].SetValue(str(post_ratio))
833
+ self.cfg_fields["distance_match"].SetValue(str(distance_match))
834
+ self.cfg_fields["fuzzy_threshold"].SetValue(str(fuzzy_threshold))
835
+
836
+ except Exception as e:
837
+ # If validation fails, show error but don't block the user
838
+ wx.MessageBox(f"Validation Error: {str(e)}", "Error")
839
+
840
+ def on_path_change(self, evt):
841
+ path = evt.GetPath()
842
+ self.path_label.SetLabel(path)
843
+
844
+ def on_toggle(self, evt):
845
+ btn = evt.GetEventObject()
846
+ label = btn.GetLabel()
847
+ if label == "AND":
848
+ btn.SetLabel("OR")
849
+ else:
850
+ btn.SetLabel("AND")
851
+
852
+ def on_abort(self, evt):
853
+ """Abort button now properly stops all processes"""
854
+ if self.worker and self.worker.is_alive():
855
+ # Set the stop event to signal all running operations to abort
856
+ self.stop_event.set()
857
+
858
+ # Disable buttons immediately
859
+ self.abort_button.Disable()
860
+ self.start_button.Enable()
861
+
862
+ # Clear any text that might have been set during processing
863
+ wx.CallAfter(self.wildcard_box.SetValue, "Aborting...")
864
+ wx.CallAfter(self.fuzzy_box.SetValue, "Aborting...")
865
+
866
+ def on_start(self, evt):
867
+ # get path
868
+ path = self.path_label.GetLabel()
869
+ if not path or path == "No file/folder selected":
870
+ wx.MessageBox("Please select a file or folder first.", "Error")
871
+ return
872
+
873
+ try:
874
+ if os.path.isdir(path):
875
+ txts = [str(Path(path) / f) for f in sorted(os.listdir(path))
876
+ if f.lower().endswith(".txt") and os.path.isfile(os.path.join(path, f))]
877
+ if not txts:
878
+ wx.MessageBox("Selected folder contains no .txt files.", "Error")
879
+ return
880
+ paths = txts
881
+ else:
882
+ if not os.path.isfile(path):
883
+ wx.MessageBox("Selected path is not a file.", "Error")
884
+ return
885
+ # Only allow .txt files - this validation was missing before
886
+ if not path.lower().endswith(".txt"):
887
+ wx.MessageBox("Please select a .txt file.", "Error")
888
+ return
889
+ paths = [path]
890
+ except Exception as e:
891
+ wx.MessageBox(f"Failed to access path: {str(e)}", "Error")
892
+ return
893
+
894
+ # prepare config
895
+ try:
896
+ cfg = {
897
+ "snippet_size": int(self.cfg_fields["snippet_size"].GetValue().strip()),
898
+ "pre_ratio": float(self.cfg_fields["pre_ratio"].GetValue().strip()),
899
+ "post_ratio": float(self.cfg_fields["post_ratio"].GetValue().strip()),
900
+ "distance_match": int(self.cfg_fields["distance_match"].GetValue().strip()),
901
+ "fuzzy_threshold": float(self.cfg_fields["fuzzy_threshold"].GetValue().strip()),
902
+ }
903
+ except Exception:
904
+ wx.MessageBox("Please check numeric configuration values.", "Error")
905
+ return
906
+
907
+ buzzwords = [t.GetValue().strip() for t in self.buzz_inputs]
908
+ cfg["buzzwords"] = buzzwords
909
+ cfg["search_type"] = "AND" if self.toggle_buttons[0].GetLabel() == "AND" else "OR"
910
+
911
+ # UI state
912
+ self.start_button.Disable()
913
+ self.abort_button.Enable()
914
+ self.wildcard_box.SetValue("Running...")
915
+ self.fuzzy_box.SetValue("Running...")
916
+
917
+ # reset stop_event and start thread
918
+ self.stop_event.clear()
919
+
920
+ # Overwrite output files at the beginning of each new search
921
+ Path("output_snippets.txt").write_text("", encoding="utf-8", errors="surrogateescape")
922
+ Path("output_fuzzy_snippets.txt").write_text("", encoding="utf-8", errors="surrogateescape")
923
+
924
+ self.worker = SearchThread(paths, cfg, self.stop_event, self.on_search_complete)
925
+ self.worker.start()
926
+
927
+ def on_search_complete(self, wildcard_text, fuzzy_text, finished_ok):
928
+ # This callback runs in worker thread; must marshal to main GUI thread
929
+ def _update():
930
+ if finished_ok:
931
+ self.wildcard_box.SetValue(wildcard_text)
932
+ self.fuzzy_box.SetValue(fuzzy_text)
933
+
934
+ # Append results to output files for each processed file
935
+ Path("output_snippets.txt").write_text(wildcard_text, encoding="utf-8", errors="surrogateescape")
936
+ Path("output_fuzzy_snippets.txt").write_text(fuzzy_text, encoding="utf-8", errors="surrogateescape")
937
+
938
+ else:
939
+ # signals either error or aborted
940
+ self.wildcard_box.SetValue(wildcard_text or "Aborted / Error")
941
+ self.fuzzy_box.SetValue(fuzzy_text or "Aborted / Error")
942
+ self.stop_event.clear()
943
+ self.start_button.Enable()
944
+ self.abort_button.Disable()
945
+
946
+ wx.CallAfter(_update)
947
+
948
+ if __name__ == "__main__":
949
+ app = wx.App(False)
950
+ frame = MainFrame()
951
+ frame.Show()
952
+ app.MainLoop()
953
+
954
+
955
+
956
+