@whiteslove/parsing-lexicon 0.9.5 → 0.9.7
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.
- package/README.md +2 -0
- package/package.json +2 -1
- package/src/alias-prefilter.js +136 -0
- package/src/housing-intent.js +21 -9
- package/src/location-merge.js +13 -1
package/README.md
CHANGED
|
@@ -52,6 +52,8 @@ Lexical aliases belong here. Consumers keep source adapters, persistence, rankin
|
|
|
52
52
|
|
|
53
53
|
The package must remain dependency-light and must never require a runtime lexicon HTTP service, Redis or a message broker.
|
|
54
54
|
|
|
55
|
+
Location entries expose their alias-matching regex through a lazy `re` getter, compiled on first match rather than at merge/import time. Merge helpers must copy entry fields without using object spread on a full entry (`{ ...entry }`), since spread invokes getters and would compile every alias regex eagerly during module load. See `AUDIT.md` for the history of this constraint.
|
|
56
|
+
|
|
55
57
|
## Usage
|
|
56
58
|
|
|
57
59
|
```js
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whiteslove/parsing-lexicon",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"./lexicon-core": "./src/lexicon-core.js",
|
|
18
18
|
"./normalization": "./src/normalization.js",
|
|
19
|
+
"./alias-prefilter": "./src/alias-prefilter.js",
|
|
19
20
|
"./parser-core": {
|
|
20
21
|
"types": "./src/parser-core.d.ts",
|
|
21
22
|
"import": "./src/parser-core.js"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Candidate prefilter for alias-backed location/entity lists.
|
|
2
|
+
//
|
|
3
|
+
// A country dictionary holds tens of thousands of entries, each carrying a
|
|
4
|
+
// lazily compiled `re` built by aliasesToRegex(). Scanning a list with
|
|
5
|
+
// `entries.find((entry) => entry.re.test(text))` therefore runs one large
|
|
6
|
+
// alternation regex per entry — ~14.5k of them for a single Uzbek listing,
|
|
7
|
+
// which costs seconds per call and dwarfs every other parsing step.
|
|
8
|
+
//
|
|
9
|
+
// Almost all of that work is provably wasted. aliasPattern() only relaxes
|
|
10
|
+
// *separators* between alias words; the letters and digits of an alias are
|
|
11
|
+
// matched literally (modulo the Karakalpak Latin equivalence classes and
|
|
12
|
+
// case). So the longest run of letters/digits inside an alias must appear as a
|
|
13
|
+
// contiguous substring of the text for that alias to match at all. Indexing
|
|
14
|
+
// entries by the first few characters of that run lets a text's own character
|
|
15
|
+
// n-grams select the handful of entries worth testing.
|
|
16
|
+
//
|
|
17
|
+
// The index is a filter, never a verdict: every surviving candidate is still
|
|
18
|
+
// matched with its real `re`, in the list's original order, so results are
|
|
19
|
+
// identical to a full scan. Folding is deliberately more aggressive than the
|
|
20
|
+
// pattern's own equivalences (Cyrillic ё/ў/қ/ғ… are collapsed): over-merging
|
|
21
|
+
// can only admit extra candidates, never discard a real match.
|
|
22
|
+
|
|
23
|
+
import { normalizeUnicode } from './normalization.js';
|
|
24
|
+
|
|
25
|
+
const GRAM = 4;
|
|
26
|
+
|
|
27
|
+
const PREFILTER_FOLD = Object.freeze({
|
|
28
|
+
// Karakalpak Latin equivalences aliasPattern() encodes as character classes.
|
|
29
|
+
á: 'a', ǵ: 'g', ı: 'i', ń: 'n', ó: 'o', ú: 'u',
|
|
30
|
+
// Safe over-merging: these only widen the candidate set.
|
|
31
|
+
ё: 'е', ў: 'у', қ: 'к', ғ: 'г', ҳ: 'х', ә: 'а', і: 'и',
|
|
32
|
+
ң: 'н', ө: 'о', ұ: 'у', ү: 'у', һ: 'х', є: 'е', ї: 'и', ґ: 'г',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const NON_ALNUM_RE = /[^\p{L}\p{N}]+/u;
|
|
36
|
+
|
|
37
|
+
function fold(value) {
|
|
38
|
+
let out = '';
|
|
39
|
+
for (const char of normalizeUnicode(value).toLocaleLowerCase()) {
|
|
40
|
+
out += PREFILTER_FOLD[char] ?? char;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The first GRAM characters of an alias's longest literal run, or null. */
|
|
46
|
+
function aliasGram(alias) {
|
|
47
|
+
let longest = '';
|
|
48
|
+
for (const run of fold(alias).split(NON_ALNUM_RE)) {
|
|
49
|
+
if (run.length > longest.length) longest = run;
|
|
50
|
+
}
|
|
51
|
+
return longest.length >= GRAM ? longest.slice(0, GRAM) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every GRAM-length window of the folded text. */
|
|
55
|
+
function textGrams(text) {
|
|
56
|
+
const folded = fold(text);
|
|
57
|
+
const grams = new Set();
|
|
58
|
+
for (let i = 0; i + GRAM <= folded.length; i += 1) {
|
|
59
|
+
grams.add(folded.slice(i, i + GRAM));
|
|
60
|
+
}
|
|
61
|
+
return grams;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildIndex(entries) {
|
|
65
|
+
const byGram = new Map();
|
|
66
|
+
// Entries whose every alias is shorter than GRAM cannot be indexed, so they
|
|
67
|
+
// are always tested. In practice this is a tiny tail.
|
|
68
|
+
const always = [];
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
71
|
+
const entry = entries[i];
|
|
72
|
+
const aliases = entry?.aliases?.length ? entry.aliases : [entry?.name].filter(Boolean);
|
|
73
|
+
const grams = [];
|
|
74
|
+
let indexable = aliases.length > 0;
|
|
75
|
+
|
|
76
|
+
for (const alias of aliases) {
|
|
77
|
+
const gram = aliasGram(alias);
|
|
78
|
+
if (!gram) {
|
|
79
|
+
indexable = false;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
grams.push(gram);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!indexable) {
|
|
86
|
+
always.push(i);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
for (const gram of new Set(grams)) {
|
|
90
|
+
const bucket = byGram.get(gram);
|
|
91
|
+
if (bucket) bucket.push(i);
|
|
92
|
+
else byGram.set(gram, [i]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { byGram, always };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const INDEX_CACHE = new WeakMap();
|
|
100
|
+
|
|
101
|
+
function indexFor(entries) {
|
|
102
|
+
let index = INDEX_CACHE.get(entries);
|
|
103
|
+
if (!index) {
|
|
104
|
+
index = buildIndex(entries);
|
|
105
|
+
INDEX_CACHE.set(entries, index);
|
|
106
|
+
}
|
|
107
|
+
return index;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* First entry in `entries` whose alias regex matches `text`.
|
|
112
|
+
*
|
|
113
|
+
* Equivalent to `entries.find((entry) => entry.re.test(text))`, including the
|
|
114
|
+
* "first in list order wins" tie-break, but only compiles and runs the regexes
|
|
115
|
+
* of entries the text could plausibly contain.
|
|
116
|
+
*/
|
|
117
|
+
export function matchFirstEntry(entries, text) {
|
|
118
|
+
if (!Array.isArray(entries) || !entries.length) return undefined;
|
|
119
|
+
const value = String(text || '');
|
|
120
|
+
if (!value) return undefined;
|
|
121
|
+
|
|
122
|
+
const { byGram, always } = indexFor(entries);
|
|
123
|
+
const candidates = new Set(always);
|
|
124
|
+
for (const gram of textGrams(value)) {
|
|
125
|
+
const bucket = byGram.get(gram);
|
|
126
|
+
if (!bucket) continue;
|
|
127
|
+
for (const i of bucket) candidates.add(i);
|
|
128
|
+
}
|
|
129
|
+
if (!candidates.size) return undefined;
|
|
130
|
+
|
|
131
|
+
for (const i of [...candidates].sort((a, b) => a - b)) {
|
|
132
|
+
const entry = entries[i];
|
|
133
|
+
if (entry?.re?.test(value)) return entry;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
package/src/housing-intent.js
CHANGED
|
@@ -9,6 +9,15 @@ const UZ_CONTEXTUAL_RENT_OUT_RE = /(?:^|[^\p{L}\p{N}_])(?:ijaraga|ижарага
|
|
|
9
9
|
const UZ_PER_DAY_RE = /(?:^|[^\p{L}\p{N}_])(?:kuniga|кунига)(?=$|[^\p{L}\p{N}_])/iu;
|
|
10
10
|
const UZ_DAILY_RENT_PRICE_RE = /(?:narx|нарх|ijara|ижара|to['’`]?lov|т[ўу]лов|оплата)[^.!?\r\n]{0,48}(?:kuniga|кунига)|(?:kuniga|кунига)[^.!?\r\n]{0,48}(?:narx|нарх|ijara|ижара|to['’`]?lov|т[ўу]лов|оплата)/iu;
|
|
11
11
|
|
|
12
|
+
// Short-stay wording is heavily inflected in RU/UK ("подобова оренда",
|
|
13
|
+
// "посуточной аренды") and routinely abbreviated on catalogue cards
|
|
14
|
+
// ("посут/почас", "подобово-погодинно"). HOUSING_DEAL_TYPES keeps the canonical
|
|
15
|
+
// dictionary forms; enumerating every inflection there would be brittle, so
|
|
16
|
+
// stems and established abbreviations are matched by pattern instead. A bare
|
|
17
|
+
// day-rate mention ("сутки/суток") counts too: it outranks a source's generic
|
|
18
|
+
// long-rent default even when nothing else resolves to shortRent.
|
|
19
|
+
const EXPLICIT_SHORT_STAY_RE = /(?:^|[^\p{L}\p{N}_])(?:сут(?:ки|ок)|посуточн\p{L}*|почасов\p{L}*|подобов\p{L}*|погодинн\p{L}*|подобу|посут|почас)(?=$|[^\p{L}\p{N}_])/iu;
|
|
20
|
+
|
|
12
21
|
// "ищу квартиру" / "шукаю квартиру" style aliases only match a literal,
|
|
13
22
|
// adjacent phrase. Real posts routinely insert a room count or adjective
|
|
14
23
|
// between the search verb and the housing noun ("Ищу 2-комнатную квартиру"),
|
|
@@ -115,7 +124,7 @@ export const HOUSING_DEAL_TYPES = Object.freeze([
|
|
|
115
124
|
group('shortRent', {
|
|
116
125
|
ru: ['посуточно', 'посуточная аренда', 'на сутки', 'на час', 'почасово', 'краткосрочно'],
|
|
117
126
|
en: ['daily rent', 'short term', 'short-term rent', 'per day', 'hourly'],
|
|
118
|
-
uk: ['подобово', 'погодинно', 'на добу', 'на годину', 'короткострокова оренда'],
|
|
127
|
+
uk: ['подобово', 'подобова оренда', 'погодинно', 'на добу', 'на годину', 'короткострокова оренда'],
|
|
119
128
|
ro: ['regim hotelier', 'pe zi', 'zilnic', 'pe noapte', 'închiriere pe termen scurt', 'inchiriere pe termen scurt'],
|
|
120
129
|
uzLatn: ['kunlik', 'sutkaga', 'sutkalik', 'soatlik'],
|
|
121
130
|
uzCyrl: ['кунлик', 'суткага', 'суткалик', 'соатлик'],
|
|
@@ -141,11 +150,19 @@ export function resolveHousingIntent(value) {
|
|
|
141
150
|
const durationDeal = findCanonical(text, HOUSING_DEAL_TYPES, { partial: true });
|
|
142
151
|
const hasContextualUzPerDay = UZ_PER_DAY_RE.test(text)
|
|
143
152
|
&& ((action === 'rentOut' || action === 'rentIn') || UZ_DAILY_RENT_PRICE_RE.test(text));
|
|
153
|
+
// An inflected/abbreviated short-stay stem is as authoritative as a
|
|
154
|
+
// dictionary alias, and outranks a longRent alias in the same text: a card
|
|
155
|
+
// reading "подобова оренда" is a day rental that happens to use the generic
|
|
156
|
+
// word for renting, not a long-term lease.
|
|
157
|
+
const hasShortStayStem = EXPLICIT_SHORT_STAY_RE.test(text);
|
|
158
|
+
const shortStay = durationDeal?.canonical === 'shortRent'
|
|
159
|
+
|| hasContextualUzPerDay
|
|
160
|
+
|| hasShortStayStem;
|
|
144
161
|
|
|
145
162
|
if (action) {
|
|
146
163
|
const base = HOUSING_ACTION_MAP[action];
|
|
147
164
|
let dealType = base.dealType;
|
|
148
|
-
if (
|
|
165
|
+
if (shortStay && (action === 'rentOut' || action === 'rentIn')) {
|
|
149
166
|
dealType = 'shortRent';
|
|
150
167
|
}
|
|
151
168
|
return Object.freeze({
|
|
@@ -155,11 +172,11 @@ export function resolveHousingIntent(value) {
|
|
|
155
172
|
});
|
|
156
173
|
}
|
|
157
174
|
|
|
158
|
-
if (!durationDeal && !
|
|
175
|
+
if (!durationDeal && !shortStay) return null;
|
|
159
176
|
return Object.freeze({
|
|
160
177
|
action: null,
|
|
161
178
|
listingKind: null,
|
|
162
|
-
dealType:
|
|
179
|
+
dealType: shortStay ? 'shortRent' : durationDeal.canonical,
|
|
163
180
|
});
|
|
164
181
|
}
|
|
165
182
|
|
|
@@ -178,11 +195,6 @@ export function classifyHousingDealType(value) {
|
|
|
178
195
|
return null;
|
|
179
196
|
}
|
|
180
197
|
|
|
181
|
-
// A bare "сутки/суток" (day-rate) mention outranks a source's generic
|
|
182
|
-
// long-rent default, even when the rest of the text does not otherwise
|
|
183
|
-
// resolve to shortRent.
|
|
184
|
-
const EXPLICIT_SHORT_STAY_RE = /(?:^|[^\p{L}\p{N}_])сут(?:ки|ок)(?=$|[^\p{L}\p{N}_])/iu;
|
|
185
|
-
|
|
186
198
|
export function looksExplicitDailyRentalMention(value) {
|
|
187
199
|
return EXPLICIT_SHORT_STAY_RE.test(String(value || ''));
|
|
188
200
|
}
|
package/src/location-merge.js
CHANGED
|
@@ -49,6 +49,18 @@ export function locationEntries(rows = []) {
|
|
|
49
49
|
return Object.freeze(rows.map(([name, ...aliases]) => locationEntry(name, ...aliases)));
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Copy an entry's data fields without touching its lazy `re` getter. Object
|
|
53
|
+
// spread invokes getters, which would compile every alias regex eagerly at
|
|
54
|
+
// merge time (tens of seconds at import) instead of on first match.
|
|
55
|
+
function entryData(entry) {
|
|
56
|
+
const data = {};
|
|
57
|
+
if (!entry) return data;
|
|
58
|
+
for (const key of Object.keys(entry)) {
|
|
59
|
+
if (key !== 're') data[key] = entry[key];
|
|
60
|
+
}
|
|
61
|
+
return data;
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
function mergeEntry(existing, incoming) {
|
|
53
65
|
const aliases = [...new Set([
|
|
54
66
|
...(existing?.aliases || []),
|
|
@@ -56,7 +68,7 @@ function mergeEntry(existing, incoming) {
|
|
|
56
68
|
existing?.name,
|
|
57
69
|
incoming?.name,
|
|
58
70
|
].filter(Boolean))];
|
|
59
|
-
const base = { ...(existing
|
|
71
|
+
const base = { ...entryData(existing), ...entryData(incoming) };
|
|
60
72
|
// A map-data entry may enrich an existing reviewed owner with additional
|
|
61
73
|
// aliases. It remains fallback-only only when every merged source is map
|
|
62
74
|
// data; otherwise the reviewed owner must retain its matching precedence.
|