@mailwoman/kind-classifier 9.0.0 → 9.2.0
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/classify.ts +88 -32
- package/index.ts +16 -3
- package/intent-markers.ts +110 -0
- package/intent-rules.ts +314 -0
- package/out/classify.d.ts +10 -7
- package/out/classify.d.ts.map +1 -1
- package/out/classify.js +63 -25
- package/out/classify.js.map +1 -1
- package/out/index.d.ts +8 -4
- package/out/index.d.ts.map +1 -1
- package/out/index.js +5 -2
- package/out/index.js.map +1 -1
- package/out/intent-markers.d.ts +45 -0
- package/out/intent-markers.d.ts.map +1 -0
- package/out/intent-markers.js +85 -0
- package/out/intent-markers.js.map +1 -0
- package/out/intent-rules.d.ts +59 -0
- package/out/intent-rules.d.ts.map +1 -0
- package/out/intent-rules.js +288 -0
- package/out/intent-rules.js.map +1 -0
- package/out/poi.d.ts +38 -5
- package/out/poi.d.ts.map +1 -1
- package/out/poi.js +78 -8
- package/out/poi.js.map +1 -1
- package/out/rules.d.ts +13 -0
- package/out/rules.d.ts.map +1 -1
- package/out/rules.js +12 -3
- package/out/rules.js.map +1 -1
- package/out/types.d.ts +2 -1
- package/out/types.d.ts.map +1 -1
- package/out/types.js +1 -1
- package/out/types.js.map +1 -1
- package/package.json +48 -4
- package/poi.ts +115 -12
- package/rules.ts +12 -3
- package/types.ts +2 -1
package/out/types.js
CHANGED
package/out/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../types.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/kind-classifier",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.2.0",
|
|
4
4
|
"description": "Stage 2.5 of the runtime pipeline — categorize inputs by query shape (postcode_only / locality_only / structured_address / intersection / po_box / landmark / vague). Rule-based for v1.",
|
|
5
5
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "https://github.com/sister-software/mailwoman.git",
|
|
9
|
-
"directory": "kind-classifier"
|
|
9
|
+
"directory": "packages/kind-classifier"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"out/**/*.js",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"!*.test.ts",
|
|
21
21
|
"!*.test.tsx",
|
|
22
22
|
"!**/*.test.ts",
|
|
23
|
-
"!**/*.test.tsx"
|
|
23
|
+
"!**/*.test.tsx",
|
|
24
|
+
"!test/**"
|
|
24
25
|
],
|
|
25
26
|
"type": "module",
|
|
26
27
|
"exports": {
|
|
@@ -28,6 +29,26 @@
|
|
|
28
29
|
".": {
|
|
29
30
|
"types": "./out/index.d.ts",
|
|
30
31
|
"default": "./out/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./classify": {
|
|
34
|
+
"types": "./out/classify.d.ts",
|
|
35
|
+
"default": "./out/classify.js"
|
|
36
|
+
},
|
|
37
|
+
"./types": {
|
|
38
|
+
"types": "./out/types.d.ts",
|
|
39
|
+
"default": "./out/types.js"
|
|
40
|
+
},
|
|
41
|
+
"./intent-rules": {
|
|
42
|
+
"types": "./out/intent-rules.d.ts",
|
|
43
|
+
"default": "./out/intent-rules.js"
|
|
44
|
+
},
|
|
45
|
+
"./rules": {
|
|
46
|
+
"types": "./out/rules.d.ts",
|
|
47
|
+
"default": "./out/rules.js"
|
|
48
|
+
},
|
|
49
|
+
"./poi": {
|
|
50
|
+
"types": "./out/poi.d.ts",
|
|
51
|
+
"default": "./out/poi.js"
|
|
31
52
|
}
|
|
32
53
|
},
|
|
33
54
|
"publishConfig": {
|
|
@@ -37,10 +58,33 @@
|
|
|
37
58
|
".": {
|
|
38
59
|
"types": "./out/index.d.ts",
|
|
39
60
|
"default": "./out/index.js"
|
|
61
|
+
},
|
|
62
|
+
"./classify": {
|
|
63
|
+
"types": "./out/classify.d.ts",
|
|
64
|
+
"default": "./out/classify.js"
|
|
65
|
+
},
|
|
66
|
+
"./types": {
|
|
67
|
+
"types": "./out/types.d.ts",
|
|
68
|
+
"default": "./out/types.js"
|
|
69
|
+
},
|
|
70
|
+
"./intent-rules": {
|
|
71
|
+
"types": "./out/intent-rules.d.ts",
|
|
72
|
+
"default": "./out/intent-rules.js"
|
|
73
|
+
},
|
|
74
|
+
"./rules": {
|
|
75
|
+
"types": "./out/rules.d.ts",
|
|
76
|
+
"default": "./out/rules.js"
|
|
77
|
+
},
|
|
78
|
+
"./poi": {
|
|
79
|
+
"types": "./out/poi.d.ts",
|
|
80
|
+
"default": "./out/poi.js"
|
|
40
81
|
}
|
|
41
82
|
}
|
|
42
83
|
},
|
|
43
84
|
"dependencies": {
|
|
44
|
-
"@mailwoman/core": "9.
|
|
85
|
+
"@mailwoman/core": "9.2.0"
|
|
86
|
+
},
|
|
87
|
+
"devDependencies": {
|
|
88
|
+
"@mailwoman/query-shape": "9.2.0"
|
|
45
89
|
}
|
|
46
90
|
}
|
package/poi.ts
CHANGED
|
@@ -22,18 +22,20 @@ const MAX_POI_SEGMENTS = 3
|
|
|
22
22
|
*/
|
|
23
23
|
export interface POIPhraseMatch {
|
|
24
24
|
/**
|
|
25
|
-
* The matched subject's identifier string. For `kind: "category"`, a `@mailwoman/poi-taxonomy` category id
|
|
26
|
-
* `kind: "brand"`, the
|
|
27
|
-
*
|
|
25
|
+
* The matched subject's identifier string. For `kind: "category"`, a `@mailwoman/poi-taxonomy` category id; for
|
|
26
|
+
* `kind: "brand"` or `kind: "name"`, the canonical display name. `matchPOISubject` treats it opaquely; the caller
|
|
27
|
+
* (`mailwoman`'s `poi-intent.ts`) interprets it per `kind`.
|
|
28
28
|
*/
|
|
29
29
|
categoryID: string
|
|
30
30
|
matchedPhrase: string
|
|
31
31
|
confidence: number
|
|
32
|
+
mechanism?: "exact" | "locale_normalized" | "typo"
|
|
33
|
+
inputPhrase?: string
|
|
32
34
|
/**
|
|
33
35
|
* Absent = "category" (the pre-brand shape) — optional so pre-7.3 POIPhraseLookup implementors stay
|
|
34
36
|
* source-compatible.
|
|
35
37
|
*/
|
|
36
|
-
kind?: "category" | "brand"
|
|
38
|
+
kind?: "category" | "brand" | "name"
|
|
37
39
|
/**
|
|
38
40
|
* Wikidata QID, when known. `kind: "brand"` only — absent when a brand resolved by name alone (no QID match).
|
|
39
41
|
*/
|
|
@@ -45,6 +47,17 @@ export interface POIPhraseMatch {
|
|
|
45
47
|
*/
|
|
46
48
|
export type POIPhraseLookup = (phrase: string, locale?: string) => ReadonlyArray<POIPhraseMatch>
|
|
47
49
|
|
|
50
|
+
export type POISpatialRelation = "comma" | "near" | "in" | "at" | "around" | "to"
|
|
51
|
+
|
|
52
|
+
export interface POIQuerySpan {
|
|
53
|
+
text: string
|
|
54
|
+
/**
|
|
55
|
+
* Half-open character offsets into the normalized input.
|
|
56
|
+
*/
|
|
57
|
+
start: number
|
|
58
|
+
end: number
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
/**
|
|
49
62
|
* Which lexicon this hit came from. Existing category lookups set `"category"` (backward-compatible default).
|
|
50
63
|
*/
|
|
@@ -54,15 +67,22 @@ export interface POISubjectMatch {
|
|
|
54
67
|
* The matched subject text as it appeared in the query.
|
|
55
68
|
*/
|
|
56
69
|
subject: string
|
|
70
|
+
subjectSpan: POIQuerySpan
|
|
71
|
+
/**
|
|
72
|
+
* The relation crossing from the subject span to the anchor span.
|
|
73
|
+
*/
|
|
74
|
+
relation?: POISpatialRelation
|
|
75
|
+
relationSpan?: POIQuerySpan
|
|
57
76
|
/**
|
|
58
77
|
* The anchor remainder after the separator; `""` when the whole input matched.
|
|
59
78
|
*/
|
|
60
79
|
remainder: string
|
|
80
|
+
anchorSpan?: POIQuerySpan
|
|
61
81
|
}
|
|
62
82
|
|
|
63
83
|
/**
|
|
64
|
-
* Anchor separator between subject and place: comma, or near/in/at/around — scanned left-to-right until a prefix
|
|
65
|
-
* the lexicon.
|
|
84
|
+
* Anchor separator between subject and place: comma, or near/in/at/around/to — scanned left-to-right until a prefix
|
|
85
|
+
* hits the lexicon.
|
|
66
86
|
*
|
|
67
87
|
* Linear by construction (no polynomial ReDoS): neither alternative places an unbounded whitespace quantifier _before_
|
|
68
88
|
* its required literal — the classic `\s*`/`\s+`-then-literal backtracking shape that CodeQL's `js/polynomial-redos`
|
|
@@ -78,16 +98,16 @@ export interface POISubjectMatch {
|
|
|
78
98
|
* lastIndex) identical, preserving the exact subsequent-match sequence. Verified: 0 divergences across 22.7k inputs
|
|
79
99
|
* (systematic + fuzzed adversarial whitespace + shared-whitespace anchor/comma chains).
|
|
80
100
|
*/
|
|
81
|
-
const ANCHOR_SEPARATOR = /,\s*|\s(
|
|
101
|
+
const ANCHOR_SEPARATOR = /,\s*|\s(near|in|at|around|to)\s+/gi
|
|
82
102
|
|
|
83
103
|
/**
|
|
84
|
-
* Longest subject we accept, in tokens.
|
|
104
|
+
* Longest subject we accept, in tokens. Eight covers compound taxonomy phrases while bounding lexicon probes.
|
|
85
105
|
*/
|
|
86
|
-
const MAX_SUBJECT_TOKENS =
|
|
106
|
+
const MAX_SUBJECT_TOKENS = 8
|
|
87
107
|
|
|
88
108
|
/**
|
|
89
109
|
* Match a POI subject: the whole input, or the text before the FIRST anchor separator WHOSE PREFIX HITS THE LEXICON (≤
|
|
90
|
-
*
|
|
110
|
+
* 8 tokens). Scans separator occurrences left-to-right — a lexicon phrase may itself contain a bare separator word
|
|
91
111
|
* (e.g. "walk in clinic"), so the first separator isn't necessarily the right split point. Returns null when the
|
|
92
112
|
* lexicon never fires — including comma-ridden full addresses whose leading segment isn't a lexicon phrase.
|
|
93
113
|
*/
|
|
@@ -97,13 +117,19 @@ export function matchPOISubject(
|
|
|
97
117
|
lookup: POIPhraseLookup
|
|
98
118
|
): POISubjectMatch | null {
|
|
99
119
|
const trimmed = text.trim()
|
|
120
|
+
const inputStart = text.indexOf(trimmed)
|
|
100
121
|
|
|
101
122
|
if (!trimmed) return null
|
|
102
123
|
|
|
103
124
|
const whole = lookup(trimmed, locale)
|
|
104
125
|
|
|
105
126
|
if (whole.length) {
|
|
106
|
-
return {
|
|
127
|
+
return {
|
|
128
|
+
match: whole[0]!,
|
|
129
|
+
subject: trimmed,
|
|
130
|
+
subjectSpan: { text: trimmed, start: inputStart, end: inputStart + trimmed.length },
|
|
131
|
+
remainder: "",
|
|
132
|
+
}
|
|
107
133
|
}
|
|
108
134
|
|
|
109
135
|
for (const separator of trimmed.matchAll(ANCHOR_SEPARATOR)) {
|
|
@@ -120,7 +146,34 @@ export function matchPOISubject(
|
|
|
120
146
|
|
|
121
147
|
const remainder = trimmed.slice(separator.index + separator[0].length).trim()
|
|
122
148
|
|
|
123
|
-
|
|
149
|
+
if (!remainder) continue
|
|
150
|
+
|
|
151
|
+
const subjectOffset = trimmed.slice(0, separator.index).indexOf(subject)
|
|
152
|
+
const anchorOffset = trimmed.indexOf(remainder, separator.index + separator[0].length)
|
|
153
|
+
const relationText = separator[1] ?? ","
|
|
154
|
+
const relationOffset = separator[1] ? separator.index + separator[0].indexOf(separator[1]) : separator.index
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
match: hits[0]!,
|
|
158
|
+
subject,
|
|
159
|
+
subjectSpan: {
|
|
160
|
+
text: subject,
|
|
161
|
+
start: inputStart + subjectOffset,
|
|
162
|
+
end: inputStart + subjectOffset + subject.length,
|
|
163
|
+
},
|
|
164
|
+
relation: relationText === "," ? "comma" : (relationText.toLowerCase() as POISpatialRelation),
|
|
165
|
+
relationSpan: {
|
|
166
|
+
text: relationText,
|
|
167
|
+
start: inputStart + relationOffset,
|
|
168
|
+
end: inputStart + relationOffset + relationText.length,
|
|
169
|
+
},
|
|
170
|
+
remainder,
|
|
171
|
+
anchorSpan: {
|
|
172
|
+
text: remainder,
|
|
173
|
+
start: inputStart + anchorOffset,
|
|
174
|
+
end: inputStart + anchorOffset + remainder.length,
|
|
175
|
+
},
|
|
176
|
+
}
|
|
124
177
|
}
|
|
125
178
|
|
|
126
179
|
return null
|
|
@@ -153,3 +206,53 @@ export function createScorePOIQuery(
|
|
|
153
206
|
return 0.9 * matched.match.confidence
|
|
154
207
|
}
|
|
155
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Confidence band for a bare category. One notch above `poi_query`'s whole-input band (0.92) so the anchorless subset
|
|
212
|
+
* takes the top slot from it, and only from it — every anchored POI query keeps scoring `poi_query` exactly as before.
|
|
213
|
+
* The coordinator's POI branch accepts both kinds, so the routing is identical either way; the split exists so the
|
|
214
|
+
* marker can say "you named a category and no place", which is a different thing to tell a caller.
|
|
215
|
+
*/
|
|
216
|
+
const POI_CATEGORY_CONFIDENCE = 0.93
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `poi_category` scorer (ROAD_TO_V9 §4.4) — a bare taxonomy category with nowhere to search: "tacos", "grocery store",
|
|
220
|
+
* "drinking fountain".
|
|
221
|
+
*
|
|
222
|
+
* Fires ONLY on a whole-input lexicon hit (`remainder === ""`) whose subject is a CATEGORY. A brand (`kind: "brand"`)
|
|
223
|
+
* is excluded: a bare "Starbucks" is a name lookup, not a category, and the taxonomy id a category marker promises to
|
|
224
|
+
* carry does not exist for it — `POIPhraseMatch.categoryID` holds the brand's display name in that case, which would
|
|
225
|
+
* make the marker's `categoryID` evidence a lie.
|
|
226
|
+
*/
|
|
227
|
+
export function createScorePOICategory(
|
|
228
|
+
lookup: POIPhraseLookup,
|
|
229
|
+
locale?: string
|
|
230
|
+
): (input: NormalizedInputLite, shape: QueryShapeLike) => number {
|
|
231
|
+
return (input, _shape) => {
|
|
232
|
+
const matched = matchPOISubject(input.normalized, locale ?? input.appliedLocale, lookup)
|
|
233
|
+
|
|
234
|
+
if (!matched || matched.remainder !== "") return 0
|
|
235
|
+
|
|
236
|
+
if ((matched.match.kind ?? "category") !== "category") return 0
|
|
237
|
+
|
|
238
|
+
return POI_CATEGORY_CONFIDENCE * matched.match.confidence
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The whole-input category hit behind a `poi_category` verdict, for the marker's evidence. `null` when the input is not
|
|
244
|
+
* a bare category — same gates as {@link createScorePOICategory}, so the two cannot disagree.
|
|
245
|
+
*/
|
|
246
|
+
export function matchPOICategory(
|
|
247
|
+
text: string,
|
|
248
|
+
locale: string | undefined,
|
|
249
|
+
lookup: POIPhraseLookup
|
|
250
|
+
): POIPhraseMatch | null {
|
|
251
|
+
const matched = matchPOISubject(text, locale, lookup)
|
|
252
|
+
|
|
253
|
+
if (!matched || matched.remainder !== "") return null
|
|
254
|
+
|
|
255
|
+
if ((matched.match.kind ?? "category") !== "category") return null
|
|
256
|
+
|
|
257
|
+
return matched.match
|
|
258
|
+
}
|
package/rules.ts
CHANGED
|
@@ -31,8 +31,12 @@ const MIN_POSTCODE_COVERAGE = 0.7
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Longest input still plausible as a bare locality name, including a trailing region code.
|
|
34
|
+
*
|
|
35
|
+
* Exported so `intent-rules.ts`'s `bare_toponym` can share the exact same ceiling. Sharing it is what makes
|
|
36
|
+
* "bare_toponym is a strict refinement of locality_only" a structural property rather than two numbers that happen to
|
|
37
|
+
* agree today.
|
|
34
38
|
*/
|
|
35
|
-
const MAX_LOCALITY_ONLY_LENGTH = 30
|
|
39
|
+
export const MAX_LOCALITY_ONLY_LENGTH = 30
|
|
36
40
|
|
|
37
41
|
/**
|
|
38
42
|
* Word count of a short capitalized phrase — the shape of a venue name like `Empire State Building`. Wider than this
|
|
@@ -124,9 +128,10 @@ export function scoreLandmark(input: NormalizedInputLite, _shape: QueryShapeLike
|
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
/**
|
|
127
|
-
* Street-suffix tokens that indicate an address, not a venue name.
|
|
131
|
+
* Street-suffix tokens that indicate an address, not a venue name. Shared with `intent-rules.ts` — the intent kinds
|
|
132
|
+
* disqualify on the same vocabulary, and a second copy would drift.
|
|
128
133
|
*/
|
|
129
|
-
const STREET_SUFFIXES = new Set([
|
|
134
|
+
export const STREET_SUFFIXES: ReadonlySet<string> = new Set([
|
|
130
135
|
"st",
|
|
131
136
|
"street",
|
|
132
137
|
"ave",
|
|
@@ -223,6 +228,10 @@ const POSTCODE_FORMATS: ReadonlySet<string> = new Set([
|
|
|
223
228
|
"ca_postcode",
|
|
224
229
|
"jp_postcode",
|
|
225
230
|
"nl_postcode",
|
|
231
|
+
"cz_postcode",
|
|
232
|
+
"sk_postcode",
|
|
233
|
+
"se_postcode",
|
|
234
|
+
"gr_postcode",
|
|
226
235
|
])
|
|
227
236
|
|
|
228
237
|
/**
|
package/types.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
/**
|
|
8
8
|
* Re-exports of the canonical types from `@mailwoman/core/pipeline`.
|
|
9
9
|
*/
|
|
10
|
-
export type { LocaleHint, QueryKind, QueryKindResult } from "@mailwoman/core/pipeline"
|
|
10
|
+
export type { LocaleHint, QueryIntentMarker, QueryKind, QueryKindResult } from "@mailwoman/core/pipeline"
|
|
11
|
+
export { QueryIntentCode } from "@mailwoman/core/pipeline"
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Minimal `NormalizedInput` shape consumed by `classifyKind`. Compatible with `@mailwoman/normalize`'s output.
|