]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tokenizer/token_analysis/generic.py
343534eec8a8778133a512982c38fd0360147959
[nominatim.git] / nominatim / tokenizer / token_analysis / generic.py
1 """
2 Generic processor for names that creates abbreviation variants.
3 """
4 from collections import defaultdict
5 import itertools
6 import re
7
8 from icu import Transliterator
9 import datrie
10
11 from nominatim.config import flatten_config_list
12 from nominatim.errors import UsageError
13 import nominatim.tokenizer.icu_variants as variants
14
15 ### Configuration section
16
17 def configure(rules, normalization_rules):
18     """ Extract and preprocess the configuration for this module.
19     """
20     rules = rules.get('variants')
21     immediate = defaultdict(list)
22     chars = set()
23
24     if rules:
25         vset = set()
26         rules = flatten_config_list(rules, 'variants')
27
28         vmaker = _VariantMaker(normalization_rules)
29
30         properties = []
31         for section in rules:
32             # Create the property field and deduplicate against existing
33             # instances.
34             props = variants.ICUVariantProperties.from_rules(section)
35             for existing in properties:
36                 if existing == props:
37                     props = existing
38                     break
39             else:
40                 properties.append(props)
41
42             for rule in (section.get('words') or []):
43                 vset.update(vmaker.compute(rule, props))
44
45         # Intermediate reorder by source. Also compute required character set.
46         for variant in vset:
47             if variant.source[-1] == ' ' and variant.replacement[-1] == ' ':
48                 replstr = variant.replacement[:-1]
49             else:
50                 replstr = variant.replacement
51             immediate[variant.source].append(replstr)
52             chars.update(variant.source)
53
54     return {'replacements': list(immediate.items()),
55             'chars': ''.join(chars)}
56
57
58 class _VariantMaker:
59     """ Generater for all necessary ICUVariants from a single variant rule.
60
61         All text in rules is normalized to make sure the variants match later.
62     """
63
64     def __init__(self, norm_rules):
65         self.norm = Transliterator.createFromRules("rule_loader_normalization",
66                                                    norm_rules)
67
68
69     def compute(self, rule, props):
70         """ Generator for all ICUVariant tuples from a single variant rule.
71         """
72         parts = re.split(r'(\|)?([=-])>', rule)
73         if len(parts) != 4:
74             raise UsageError("Syntax error in variant rule: " + rule)
75
76         decompose = parts[1] is None
77         src_terms = [self._parse_variant_word(t) for t in parts[0].split(',')]
78         repl_terms = (self.norm.transliterate(t.strip()) for t in parts[3].split(','))
79
80         # If the source should be kept, add a 1:1 replacement
81         if parts[2] == '-':
82             for src in src_terms:
83                 if src:
84                     for froms, tos in _create_variants(*src, src[0], decompose):
85                         yield variants.ICUVariant(froms, tos, props)
86
87         for src, repl in itertools.product(src_terms, repl_terms):
88             if src and repl:
89                 for froms, tos in _create_variants(*src, repl, decompose):
90                     yield variants.ICUVariant(froms, tos, props)
91
92
93     def _parse_variant_word(self, name):
94         name = name.strip()
95         match = re.fullmatch(r'([~^]?)([^~$^]*)([~$]?)', name)
96         if match is None or (match.group(1) == '~' and match.group(3) == '~'):
97             raise UsageError("Invalid variant word descriptor '{}'".format(name))
98         norm_name = self.norm.transliterate(match.group(2))
99         if not norm_name:
100             return None
101
102         return norm_name, match.group(1), match.group(3)
103
104
105 _FLAG_MATCH = {'^': '^ ',
106                '$': ' ^',
107                '': ' '}
108
109
110 def _create_variants(src, preflag, postflag, repl, decompose):
111     if preflag == '~':
112         postfix = _FLAG_MATCH[postflag]
113         # suffix decomposition
114         src = src + postfix
115         repl = repl + postfix
116
117         yield src, repl
118         yield ' ' + src, ' ' + repl
119
120         if decompose:
121             yield src, ' ' + repl
122             yield ' ' + src, repl
123     elif postflag == '~':
124         # prefix decomposition
125         prefix = _FLAG_MATCH[preflag]
126         src = prefix + src
127         repl = prefix + repl
128
129         yield src, repl
130         yield src + ' ', repl + ' '
131
132         if decompose:
133             yield src, repl + ' '
134             yield src + ' ', repl
135     else:
136         prefix = _FLAG_MATCH[preflag]
137         postfix = _FLAG_MATCH[postflag]
138
139         yield prefix + src + postfix, prefix + repl + postfix
140
141
142 ### Analysis section
143
144 def create(norm_rules, trans_rules, config):
145     """ Create a new token analysis instance for this module.
146     """
147     return GenericTokenAnalysis(norm_rules, trans_rules, config)
148
149
150 class GenericTokenAnalysis:
151     """ Collects the different transformation rules for normalisation of names
152         and provides the functions to apply the transformations.
153     """
154
155     def __init__(self, norm_rules, trans_rules, config):
156         self.normalizer = Transliterator.createFromRules("icu_normalization",
157                                                          norm_rules)
158         self.to_ascii = Transliterator.createFromRules("icu_to_ascii",
159                                                        trans_rules +
160                                                        ";[:Space:]+ > ' '")
161         self.search = Transliterator.createFromRules("icu_search",
162                                                      norm_rules + trans_rules)
163
164         # Set up datrie
165         self.replacements = datrie.Trie(config['chars'])
166         for src, repllist in config['replacements']:
167             self.replacements[src] = repllist
168
169
170     def get_normalized(self, name):
171         """ Normalize the given name, i.e. remove all elements not relevant
172             for search.
173         """
174         return self.normalizer.transliterate(name).strip()
175
176     def get_variants_ascii(self, norm_name):
177         """ Compute the spelling variants for the given normalized name
178             and transliterate the result.
179         """
180         baseform = '^ ' + norm_name + ' ^'
181         partials = ['']
182
183         startpos = 0
184         pos = 0
185         force_space = False
186         while pos < len(baseform):
187             full, repl = self.replacements.longest_prefix_item(baseform[pos:],
188                                                                (None, None))
189             if full is not None:
190                 done = baseform[startpos:pos]
191                 partials = [v + done + r
192                             for v, r in itertools.product(partials, repl)
193                             if not force_space or r.startswith(' ')]
194                 if len(partials) > 128:
195                     # If too many variants are produced, they are unlikely
196                     # to be helpful. Only use the original term.
197                     startpos = 0
198                     break
199                 startpos = pos + len(full)
200                 if full[-1] == ' ':
201                     startpos -= 1
202                     force_space = True
203                 pos = startpos
204             else:
205                 pos += 1
206                 force_space = False
207
208         # No variants detected? Fast return.
209         if startpos == 0:
210             trans_name = self.to_ascii.transliterate(norm_name).strip()
211             return [trans_name] if trans_name else []
212
213         return self._compute_result_set(partials, baseform[startpos:])
214
215
216     def _compute_result_set(self, partials, prefix):
217         results = set()
218
219         for variant in partials:
220             vname = variant + prefix
221             trans_name = self.to_ascii.transliterate(vname[1:-1]).strip()
222             if trans_name:
223                 results.add(trans_name)
224
225         return list(results)
226
227
228     def get_search_normalized(self, name):
229         """ Return the normalized version of the name (including transliteration)
230             to be applied at search time.
231         """
232         return self.search.transliterate(' ' + name + ' ').strip()