resolvekit 0.0.1__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.
Files changed (70) hide show
  1. resolvekit/README.md +134 -0
  2. resolvekit/__init__.py +67 -0
  3. resolvekit/api/README.md +165 -0
  4. resolvekit/api/__init__.py +10 -0
  5. resolvekit/api/convenience.py +53 -0
  6. resolvekit/api/resolver.py +457 -0
  7. resolvekit/builders/README.md +173 -0
  8. resolvekit/builders/__init__.py +0 -0
  9. resolvekit/calibration/README.md +351 -0
  10. resolvekit/calibration/__init__.py +12 -0
  11. resolvekit/calibration/calibrator.py +184 -0
  12. resolvekit/calibration/features.py +139 -0
  13. resolvekit/calibration/models.py +78 -0
  14. resolvekit/cli/README.md +215 -0
  15. resolvekit/cli/__init__.py +0 -0
  16. resolvekit/cli/main.py +18 -0
  17. resolvekit/config.py +128 -0
  18. resolvekit/constants.py +252 -0
  19. resolvekit/constraints/README.md +102 -0
  20. resolvekit/constraints/__init__.py +17 -0
  21. resolvekit/constraints/constraint_engine.py +111 -0
  22. resolvekit/constraints/hierarchy_validator.py +148 -0
  23. resolvekit/constraints/membership_validator.py +60 -0
  24. resolvekit/constraints/protocols.py +33 -0
  25. resolvekit/constraints/temporal_validator.py +43 -0
  26. resolvekit/constraints/type_validator.py +42 -0
  27. resolvekit/data/README.md +165 -0
  28. resolvekit/data/__init__.py +14 -0
  29. resolvekit/data/alias_repository.py +206 -0
  30. resolvekit/data/code_repository.py +85 -0
  31. resolvekit/data/context_filters.py +49 -0
  32. resolvekit/data/db_manager.py +196 -0
  33. resolvekit/data/entity_repository.py +466 -0
  34. resolvekit/data/membership_repository.py +107 -0
  35. resolvekit/data/query_builder.py +177 -0
  36. resolvekit/data/schema.py +122 -0
  37. resolvekit/disambiguation/README.md +72 -0
  38. resolvekit/disambiguation/__init__.py +0 -0
  39. resolvekit/extraction/README.md +204 -0
  40. resolvekit/extraction/__init__.py +0 -0
  41. resolvekit/matchers/README.md +77 -0
  42. resolvekit/matchers/__init__.py +65 -0
  43. resolvekit/matchers/alias_exact.py +65 -0
  44. resolvekit/matchers/canonical_name.py +62 -0
  45. resolvekit/matchers/cascade.py +127 -0
  46. resolvekit/matchers/code_validators.py +250 -0
  47. resolvekit/matchers/exact_code.py +177 -0
  48. resolvekit/matchers/fts_matcher.py +106 -0
  49. resolvekit/matchers/fuzzy_matcher.py +142 -0
  50. resolvekit/matchers/priorities.py +174 -0
  51. resolvekit/matchers/protocols.py +75 -0
  52. resolvekit/normalization/README.md +192 -0
  53. resolvekit/normalization/__init__.py +8 -0
  54. resolvekit/normalization/normalizer.py +164 -0
  55. resolvekit/overlays/README.md +226 -0
  56. resolvekit/overlays/__init__.py +0 -0
  57. resolvekit/types.py +534 -0
  58. resolvekit/utils/README.md +188 -0
  59. resolvekit/utils/__init__.py +48 -0
  60. resolvekit/utils/cache.py +109 -0
  61. resolvekit/utils/dates.py +339 -0
  62. resolvekit/utils/errors.py +145 -0
  63. resolvekit/utils/files.py +366 -0
  64. resolvekit/utils/logging.py +219 -0
  65. resolvekit/utils/text.py +475 -0
  66. resolvekit/utils/validation.py +301 -0
  67. resolvekit-0.0.1.dist-info/METADATA +36 -0
  68. resolvekit-0.0.1.dist-info/RECORD +70 -0
  69. resolvekit-0.0.1.dist-info/WHEEL +4 -0
  70. resolvekit-0.0.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,219 @@
1
+ """Logging utilities for resolvekit."""
2
+
3
+ import json
4
+ import logging
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from resolvekit.constants import LOG_DATE_FORMAT, LOG_FORMAT
10
+
11
+
12
+ class StructuredFormatter(logging.Formatter):
13
+ """JSON structured log formatter."""
14
+
15
+ def format(self, record: logging.LogRecord) -> str:
16
+ """Format log record as JSON."""
17
+ log_data = {
18
+ "timestamp": self.formatTime(record, self.datefmt),
19
+ "level": record.levelname,
20
+ "logger": record.name,
21
+ "message": record.getMessage(),
22
+ }
23
+
24
+ # Add extra fields
25
+ if hasattr(record, "extra"):
26
+ log_data.update(record.extra)
27
+
28
+ # Add exception info if present
29
+ if record.exc_info:
30
+ log_data["exception"] = self.formatException(record.exc_info)
31
+
32
+ return json.dumps(log_data)
33
+
34
+
35
+ class PrivacyFilter(logging.Filter):
36
+ """Filter to prevent logging sensitive query content by default."""
37
+
38
+ def __init__(self, redact_queries: bool = True):
39
+ """Initialize privacy filter."""
40
+ super().__init__()
41
+ self.redact_queries = redact_queries
42
+
43
+ def filter(self, record: logging.LogRecord) -> bool:
44
+ """Redact query content if privacy is enabled."""
45
+ if self.redact_queries and hasattr(record, "query"):
46
+ record.query = "[REDACTED]"
47
+ return True
48
+
49
+
50
+ def setup_logging(
51
+ level: str | int = logging.INFO,
52
+ format_type: str = "human",
53
+ log_file: Path | str | None = None,
54
+ redact_queries: bool = True,
55
+ ) -> None:
56
+ """
57
+ Set up logging configuration.
58
+
59
+ Args:
60
+ level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
61
+ format_type: "human" for human-readable, "json" for structured JSON
62
+ log_file: Optional file path for logging to file
63
+ redact_queries: Whether to redact query content (default: True for privacy)
64
+ """
65
+ # Convert string level to int
66
+ if isinstance(level, str):
67
+ level = getattr(logging, level.upper())
68
+
69
+ # Create handlers
70
+ handlers: list[logging.Handler] = []
71
+
72
+ # Console handler
73
+ console_handler = logging.StreamHandler(sys.stdout)
74
+ console_handler.setLevel(level)
75
+
76
+ if format_type == "json":
77
+ console_handler.setFormatter(StructuredFormatter(datefmt=LOG_DATE_FORMAT))
78
+ else:
79
+ console_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT))
80
+
81
+ handlers.append(console_handler)
82
+
83
+ # File handler if specified
84
+ if log_file:
85
+ file_handler = logging.FileHandler(log_file)
86
+ file_handler.setLevel(level)
87
+ if format_type == "json":
88
+ file_handler.setFormatter(StructuredFormatter(datefmt=LOG_DATE_FORMAT))
89
+ else:
90
+ file_handler.setFormatter(logging.Formatter(LOG_FORMAT, LOG_DATE_FORMAT))
91
+ handlers.append(file_handler)
92
+
93
+ # Configure root logger
94
+ root_logger = logging.getLogger()
95
+ root_logger.setLevel(level)
96
+
97
+ # Remove existing handlers
98
+ for handler in root_logger.handlers[:]:
99
+ root_logger.removeHandler(handler)
100
+
101
+ # Add new handlers
102
+ for handler in handlers:
103
+ root_logger.addHandler(handler)
104
+
105
+ # Add privacy filter
106
+ privacy_filter = PrivacyFilter(redact_queries=redact_queries)
107
+ for handler in handlers:
108
+ handler.addFilter(privacy_filter)
109
+
110
+
111
+ def get_logger(name: str) -> logging.Logger:
112
+ """
113
+ Get a logger instance.
114
+
115
+ Args:
116
+ name: Logger name (typically __name__)
117
+
118
+ Returns:
119
+ Logger instance
120
+ """
121
+ return logging.getLogger(name)
122
+
123
+
124
+ def log_performance(
125
+ logger: logging.Logger,
126
+ operation: str,
127
+ duration_ms: float,
128
+ extra: dict[str, Any] | None = None,
129
+ ) -> None:
130
+ """
131
+ Log performance metrics.
132
+
133
+ Args:
134
+ logger: Logger instance
135
+ operation: Operation name
136
+ duration_ms: Duration in milliseconds
137
+ extra: Additional context to log
138
+ """
139
+ log_data = {
140
+ "operation": operation,
141
+ "duration_ms": round(duration_ms, 2),
142
+ }
143
+ if extra:
144
+ log_data.update(extra)
145
+
146
+ logger.info(f"Performance: {operation} took {duration_ms:.2f}ms", extra=log_data)
147
+
148
+
149
+ def log_resolution(
150
+ logger: logging.Logger,
151
+ query: str,
152
+ result_dcid: str | None,
153
+ confidence: float | None,
154
+ num_candidates: int,
155
+ duration_ms: float,
156
+ redact_query: bool = True,
157
+ ) -> None:
158
+ """
159
+ Log resolution operation.
160
+
161
+ Args:
162
+ logger: Logger instance
163
+ query: Query string (will be redacted if redact_query=True)
164
+ result_dcid: Resolved entity DCID (None if no match)
165
+ confidence: Confidence score
166
+ num_candidates: Number of candidates found
167
+ duration_ms: Resolution duration in milliseconds
168
+ redact_query: Whether to redact query content
169
+ """
170
+ log_data = {
171
+ "query": "[REDACTED]" if redact_query else query,
172
+ "result_dcid": result_dcid,
173
+ "confidence": round(confidence, 3) if confidence else None,
174
+ "num_candidates": num_candidates,
175
+ "duration_ms": round(duration_ms, 2),
176
+ "success": result_dcid is not None,
177
+ }
178
+
179
+ if result_dcid:
180
+ # Format confidence if present, otherwise indicate it's missing
181
+ conf_str = f"{confidence:.3f}" if confidence is not None else "unknown"
182
+ logger.info(
183
+ f"Resolved query to {result_dcid} (confidence={conf_str})",
184
+ extra=log_data,
185
+ )
186
+ else:
187
+ logger.warning("No match found for query", extra=log_data)
188
+
189
+
190
+ def log_stage(
191
+ logger: logging.Logger,
192
+ stage: str,
193
+ candidates_before: int,
194
+ candidates_after: int,
195
+ duration_ms: float | None = None,
196
+ ) -> None:
197
+ """
198
+ Log resolution stage.
199
+
200
+ Args:
201
+ logger: Logger instance
202
+ stage: Stage name
203
+ candidates_before: Number of candidates before stage
204
+ candidates_after: Number of candidates after stage
205
+ duration_ms: Stage duration in milliseconds
206
+ """
207
+ log_data = {
208
+ "stage": stage,
209
+ "candidates_before": candidates_before,
210
+ "candidates_after": candidates_after,
211
+ "filtered": candidates_before - candidates_after,
212
+ }
213
+ if duration_ms is not None:
214
+ log_data["duration_ms"] = round(duration_ms, 2)
215
+
216
+ logger.debug(
217
+ f"Stage '{stage}': {candidates_before} → {candidates_after} candidates",
218
+ extra=log_data,
219
+ )
@@ -0,0 +1,475 @@
1
+ """Text processing utilities for resolvekit.
2
+
3
+ This module uses rapidfuzz for efficient fuzzy string matching.
4
+ We keep our normalization functions and wrap rapidfuzz for consistency.
5
+ """
6
+
7
+ import unicodedata
8
+ from collections.abc import Iterable
9
+
10
+ from rapidfuzz import distance, fuzz, process
11
+
12
+
13
+ def normalize_unicode(text: str, form: str = "NFKD") -> str:
14
+ """
15
+ Normalize Unicode text.
16
+
17
+ Args:
18
+ text: Input text
19
+ form: Normalization form (NFC, NFD, NFKC, NFKD)
20
+
21
+ Returns:
22
+ Normalized text
23
+
24
+ Examples:
25
+ >>> normalize_unicode("café")
26
+ 'café' # NFKD form
27
+ """
28
+ return unicodedata.normalize(form, text)
29
+
30
+
31
+ def remove_diacritics(text: str) -> str:
32
+ """
33
+ Remove diacritical marks from text.
34
+
35
+ Args:
36
+ text: Input text
37
+
38
+ Returns:
39
+ Text without diacritics
40
+
41
+ Examples:
42
+ >>> remove_diacritics("Côte d'Ivoire")
43
+ "Cote d'Ivoire"
44
+ >>> remove_diacritics("Türkiye")
45
+ "Turkiye"
46
+ """
47
+ # Normalize to NFD (decomposed form)
48
+ nfd = unicodedata.normalize("NFD", text)
49
+ # Filter out combining characters (diacritics)
50
+ return "".join(char for char in nfd if unicodedata.category(char) != "Mn")
51
+
52
+
53
+ def case_fold(text: str) -> str:
54
+ """
55
+ Case fold text for case-insensitive matching.
56
+
57
+ Args:
58
+ text: Input text
59
+
60
+ Returns:
61
+ Case-folded text (lowercase with special handling)
62
+
63
+ Examples:
64
+ >>> case_fold("FRANCE")
65
+ 'france'
66
+ >>> case_fold("Türkiye")
67
+ 'türkiye'
68
+ """
69
+ return text.casefold()
70
+
71
+
72
+ def normalize_whitespace(text: str) -> str:
73
+ """
74
+ Normalize whitespace in text.
75
+
76
+ Args:
77
+ text: Input text
78
+
79
+ Returns:
80
+ Text with normalized whitespace
81
+
82
+ Examples:
83
+ >>> normalize_whitespace(" hello world ")
84
+ 'hello world'
85
+ """
86
+ import re
87
+
88
+ # Replace multiple spaces with single space
89
+ text = re.sub(r"\s+", " ", text)
90
+ # Strip leading/trailing whitespace
91
+ return text.strip()
92
+
93
+
94
+ def edit_distance(s1: str, s2: str) -> int:
95
+ """
96
+ Compute Levenshtein edit distance between two strings.
97
+
98
+ Powered by rapidfuzz for performance.
99
+
100
+ Args:
101
+ s1: First string
102
+ s2: Second string
103
+
104
+ Returns:
105
+ Edit distance (number of insertions, deletions, substitutions)
106
+
107
+ Examples:
108
+ >>> edit_distance("kitten", "sitting")
109
+ 3
110
+ >>> edit_distance("france", "frence")
111
+ 1
112
+ """
113
+ return distance.Levenshtein.distance(s1, s2)
114
+
115
+
116
+ def normalized_edit_distance(s1: str, s2: str) -> float:
117
+ """
118
+ Compute normalized edit distance (0.0 = identical, 1.0 = completely different).
119
+
120
+ Powered by rapidfuzz for performance.
121
+
122
+ Args:
123
+ s1: First string
124
+ s2: Second string
125
+
126
+ Returns:
127
+ Normalized edit distance [0.0, 1.0]
128
+
129
+ Examples:
130
+ >>> normalized_edit_distance("france", "france")
131
+ 0.0
132
+ >>> normalized_edit_distance("abc", "xyz")
133
+ 1.0
134
+ """
135
+ return distance.Levenshtein.normalized_distance(s1, s2)
136
+
137
+
138
+ def string_similarity(s1: str, s2: str) -> float:
139
+ """
140
+ Compute overall string similarity score.
141
+
142
+ Uses rapidfuzz's ratio algorithm (similar to difflib.SequenceMatcher).
143
+
144
+ Args:
145
+ s1: First string
146
+ s2: Second string
147
+
148
+ Returns:
149
+ Similarity score [0.0, 1.0] (1.0 = identical)
150
+
151
+ Examples:
152
+ >>> string_similarity("france", "france")
153
+ 1.0
154
+ >>> string_similarity("france", "frence")
155
+ 0.83 # Approximately
156
+ """
157
+ return fuzz.ratio(s1, s2) / 100.0
158
+
159
+
160
+ def trigram_similarity(s1: str, s2: str) -> float:
161
+ """
162
+ Compute trigram similarity between two strings.
163
+
164
+ Uses rapidfuzz's token_set_ratio for better performance.
165
+
166
+ Args:
167
+ s1: First string
168
+ s2: Second string
169
+
170
+ Returns:
171
+ Similarity score [0.0, 1.0] (1.0 = identical)
172
+
173
+ Examples:
174
+ >>> trigram_similarity("france", "france")
175
+ 1.0
176
+ >>> trigram_similarity("germany", "germeny")
177
+ # High similarity but not identical
178
+ """
179
+ # Use token_set_ratio which is similar to trigram matching
180
+ return fuzz.token_set_ratio(s1, s2) / 100.0
181
+
182
+
183
+ def partial_similarity(s1: str, s2: str) -> float:
184
+ """
185
+ Compute partial string similarity (substring matching).
186
+
187
+ Useful when one string contains the other.
188
+
189
+ Args:
190
+ s1: First string
191
+ s2: Second string
192
+
193
+ Returns:
194
+ Similarity score [0.0, 1.0]
195
+
196
+ Examples:
197
+ >>> partial_similarity("france", "southern france")
198
+ 1.0
199
+ """
200
+ return fuzz.partial_ratio(s1, s2) / 100.0
201
+
202
+
203
+ def jaccard_similarity(set1: Iterable, set2: Iterable) -> float:
204
+ """
205
+ Compute Jaccard similarity between two sets.
206
+
207
+ Args:
208
+ set1: First set (or iterable)
209
+ set2: Second set (or iterable)
210
+
211
+ Returns:
212
+ Jaccard similarity [0.0, 1.0]
213
+
214
+ Examples:
215
+ >>> jaccard_similarity({1, 2, 3}, {2, 3, 4})
216
+ 0.5
217
+ """
218
+ s1 = set(set1)
219
+ s2 = set(set2)
220
+
221
+ if not s1 and not s2:
222
+ return 1.0
223
+
224
+ intersection = len(s1 & s2)
225
+ union = len(s1 | s2)
226
+
227
+ return intersection / union if union > 0 else 0.0
228
+
229
+
230
+ def token_jaccard_similarity(s1: str, s2: str) -> float:
231
+ """
232
+ Compute Jaccard similarity based on tokens (whitespace-separated words).
233
+
234
+ Args:
235
+ s1: First string
236
+ s2: Second string
237
+
238
+ Returns:
239
+ Token-based Jaccard similarity [0.0, 1.0]
240
+
241
+ Examples:
242
+ >>> token_jaccard_similarity("hello world", "world hello")
243
+ 1.0
244
+ >>> token_jaccard_similarity("democratic republic of congo", "republic of congo")
245
+ 0.75
246
+ """
247
+ tokens1 = set(s1.lower().split())
248
+ tokens2 = set(s2.lower().split())
249
+ return jaccard_similarity(tokens1, tokens2)
250
+
251
+
252
+ def find_best_match(
253
+ query: str, choices: list[str], limit: int = 1
254
+ ) -> list[tuple[str, float]]:
255
+ """
256
+ Find best matching string(s) from a list of choices.
257
+
258
+ Powered by rapidfuzz for efficient searching.
259
+
260
+ Args:
261
+ query: Query string
262
+ choices: List of candidate strings
263
+ limit: Number of results to return
264
+
265
+ Returns:
266
+ List of (match, score) tuples, sorted by score descending
267
+
268
+ Examples:
269
+ >>> find_best_match("frence", ["france", "germany", "italy"])
270
+ [('france', 0.83)]
271
+ """
272
+ if not choices:
273
+ return []
274
+
275
+ results = process.extract(query, choices, scorer=fuzz.ratio, limit=limit)
276
+ # Convert scores from 0-100 to 0-1
277
+ return [(match, score / 100.0) for match, score, _ in results]
278
+
279
+
280
+ def starts_with_prefix(text: str, prefix: str, case_sensitive: bool = False) -> bool:
281
+ """
282
+ Check if text starts with prefix.
283
+
284
+ Args:
285
+ text: Text to check
286
+ prefix: Prefix to look for
287
+ case_sensitive: Whether to use case-sensitive matching
288
+
289
+ Returns:
290
+ True if text starts with prefix
291
+ """
292
+ if not case_sensitive:
293
+ text = text.lower()
294
+ prefix = prefix.lower()
295
+
296
+ return text.startswith(prefix)
297
+
298
+
299
+ def contains_substring(text: str, substring: str, case_sensitive: bool = False) -> bool:
300
+ """
301
+ Check if text contains substring.
302
+
303
+ Args:
304
+ text: Text to search in
305
+ substring: Substring to look for
306
+ case_sensitive: Whether to use case-sensitive matching
307
+
308
+ Returns:
309
+ True if substring is found
310
+ """
311
+ if not case_sensitive:
312
+ text = text.lower()
313
+ substring = substring.lower()
314
+
315
+ return substring in text
316
+
317
+
318
+ def truncate(text: str, max_length: int, suffix: str = "...") -> str:
319
+ """
320
+ Truncate text to maximum length.
321
+
322
+ Args:
323
+ text: Text to truncate
324
+ max_length: Maximum length (including suffix)
325
+ suffix: Suffix to append when truncating
326
+
327
+ Returns:
328
+ Truncated text
329
+
330
+ Examples:
331
+ >>> truncate("hello world", 8)
332
+ 'hello...'
333
+ """
334
+ if len(text) <= max_length:
335
+ return text
336
+
337
+ return text[: max_length - len(suffix)] + suffix
338
+
339
+
340
+ def get_common_prefix(s1: str, s2: str) -> str:
341
+ """
342
+ Get common prefix of two strings.
343
+
344
+ Args:
345
+ s1: First string
346
+ s2: Second string
347
+
348
+ Returns:
349
+ Common prefix
350
+
351
+ Examples:
352
+ >>> get_common_prefix("france", "french")
353
+ 'fr'
354
+ """
355
+ prefix = []
356
+ for c1, c2 in zip(s1, s2, strict=False):
357
+ if c1 == c2:
358
+ prefix.append(c1)
359
+ else:
360
+ break
361
+ return "".join(prefix)
362
+
363
+
364
+ def get_common_suffix(s1: str, s2: str) -> str:
365
+ """
366
+ Get common suffix of two strings.
367
+
368
+ Args:
369
+ s1: First string
370
+ s2: Second string
371
+
372
+ Returns:
373
+ Common suffix
374
+
375
+ Examples:
376
+ >>> get_common_suffix("testing", "running")
377
+ 'ing'
378
+ """
379
+ return get_common_prefix(s1[::-1], s2[::-1])[::-1]
380
+
381
+
382
+ def is_acronym_of(short: str, long: str) -> bool:
383
+ """
384
+ Check if short string could be an acronym of long string.
385
+
386
+ Args:
387
+ short: Potential acronym
388
+ long: Full form
389
+
390
+ Returns:
391
+ True if short could be acronym of long
392
+
393
+ Examples:
394
+ >>> is_acronym_of("USA", "United States of America")
395
+ True
396
+ >>> is_acronym_of("UN", "United Nations")
397
+ True
398
+ >>> is_acronym_of("XYZ", "United Nations")
399
+ False
400
+ """
401
+ short = short.upper()
402
+ words = long.upper().split()
403
+
404
+ if len(short) > len(words):
405
+ return False
406
+
407
+ # Check if each letter in short matches first letter of words
408
+ if len(short) == len(words):
409
+ return all(s == w[0] for s, w in zip(short, words, strict=False))
410
+
411
+ # More complex matching (skip some words)
412
+ # Try to match acronym letters to word first letters
413
+ word_idx = 0
414
+ for char in short:
415
+ # Find next word starting with this character
416
+ while word_idx < len(words) and words[word_idx][0] != char:
417
+ word_idx += 1
418
+ if word_idx >= len(words):
419
+ return False
420
+ word_idx += 1
421
+
422
+ return True
423
+
424
+
425
+ def clean_text(text: str, remove_diacritics: bool = False) -> str:
426
+ """
427
+ Clean text for matching (normalize unicode, whitespace, optionally remove diacritics).
428
+
429
+ Args:
430
+ text: Input text
431
+ remove_diacritics: Whether to remove diacritical marks
432
+
433
+ Returns:
434
+ Cleaned text
435
+
436
+ Examples:
437
+ >>> clean_text(" Côte d'Ivoire ", remove_diacritics=True)
438
+ "cote d'ivoire"
439
+ """
440
+ # Normalize unicode
441
+ text = normalize_unicode(text)
442
+
443
+ # Optionally remove diacritics
444
+ if remove_diacritics:
445
+ # Call the remove_diacritics function
446
+ text = globals()["remove_diacritics"](text)
447
+
448
+ # Case fold
449
+ text = case_fold(text)
450
+
451
+ # Normalize whitespace
452
+ text = normalize_whitespace(text)
453
+
454
+ return text
455
+
456
+
457
+ # Keep get_trigrams for backward compatibility
458
+ def get_trigrams(text: str) -> set[str]:
459
+ """
460
+ Extract trigrams from text.
461
+
462
+ Args:
463
+ text: Input text
464
+
465
+ Returns:
466
+ Set of trigrams
467
+
468
+ Examples:
469
+ >>> get_trigrams("hello")
470
+ {'hel', 'ell', 'llo'}
471
+ """
472
+ if len(text) < 3:
473
+ return {text}
474
+
475
+ return {text[i : i + 3] for i in range(len(text) - 2)}