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.
- resolvekit/README.md +134 -0
- resolvekit/__init__.py +67 -0
- resolvekit/api/README.md +165 -0
- resolvekit/api/__init__.py +10 -0
- resolvekit/api/convenience.py +53 -0
- resolvekit/api/resolver.py +457 -0
- resolvekit/builders/README.md +173 -0
- resolvekit/builders/__init__.py +0 -0
- resolvekit/calibration/README.md +351 -0
- resolvekit/calibration/__init__.py +12 -0
- resolvekit/calibration/calibrator.py +184 -0
- resolvekit/calibration/features.py +139 -0
- resolvekit/calibration/models.py +78 -0
- resolvekit/cli/README.md +215 -0
- resolvekit/cli/__init__.py +0 -0
- resolvekit/cli/main.py +18 -0
- resolvekit/config.py +128 -0
- resolvekit/constants.py +252 -0
- resolvekit/constraints/README.md +102 -0
- resolvekit/constraints/__init__.py +17 -0
- resolvekit/constraints/constraint_engine.py +111 -0
- resolvekit/constraints/hierarchy_validator.py +148 -0
- resolvekit/constraints/membership_validator.py +60 -0
- resolvekit/constraints/protocols.py +33 -0
- resolvekit/constraints/temporal_validator.py +43 -0
- resolvekit/constraints/type_validator.py +42 -0
- resolvekit/data/README.md +165 -0
- resolvekit/data/__init__.py +14 -0
- resolvekit/data/alias_repository.py +206 -0
- resolvekit/data/code_repository.py +85 -0
- resolvekit/data/context_filters.py +49 -0
- resolvekit/data/db_manager.py +196 -0
- resolvekit/data/entity_repository.py +466 -0
- resolvekit/data/membership_repository.py +107 -0
- resolvekit/data/query_builder.py +177 -0
- resolvekit/data/schema.py +122 -0
- resolvekit/disambiguation/README.md +72 -0
- resolvekit/disambiguation/__init__.py +0 -0
- resolvekit/extraction/README.md +204 -0
- resolvekit/extraction/__init__.py +0 -0
- resolvekit/matchers/README.md +77 -0
- resolvekit/matchers/__init__.py +65 -0
- resolvekit/matchers/alias_exact.py +65 -0
- resolvekit/matchers/canonical_name.py +62 -0
- resolvekit/matchers/cascade.py +127 -0
- resolvekit/matchers/code_validators.py +250 -0
- resolvekit/matchers/exact_code.py +177 -0
- resolvekit/matchers/fts_matcher.py +106 -0
- resolvekit/matchers/fuzzy_matcher.py +142 -0
- resolvekit/matchers/priorities.py +174 -0
- resolvekit/matchers/protocols.py +75 -0
- resolvekit/normalization/README.md +192 -0
- resolvekit/normalization/__init__.py +8 -0
- resolvekit/normalization/normalizer.py +164 -0
- resolvekit/overlays/README.md +226 -0
- resolvekit/overlays/__init__.py +0 -0
- resolvekit/types.py +534 -0
- resolvekit/utils/README.md +188 -0
- resolvekit/utils/__init__.py +48 -0
- resolvekit/utils/cache.py +109 -0
- resolvekit/utils/dates.py +339 -0
- resolvekit/utils/errors.py +145 -0
- resolvekit/utils/files.py +366 -0
- resolvekit/utils/logging.py +219 -0
- resolvekit/utils/text.py +475 -0
- resolvekit/utils/validation.py +301 -0
- resolvekit-0.0.1.dist-info/METADATA +36 -0
- resolvekit-0.0.1.dist-info/RECORD +70 -0
- resolvekit-0.0.1.dist-info/WHEEL +4 -0
- 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
|
+
)
|
resolvekit/utils/text.py
ADDED
|
@@ -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)}
|