@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/out/types.js CHANGED
@@ -3,5 +3,5 @@
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
5
  */
6
- export {};
6
+ export { QueryIntentCode } from "@mailwoman/core/pipeline";
7
7
  //# sourceMappingURL=types.js.map
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.0.0",
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.0.0"
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. For
26
- * `kind: "brand"`, the brand's canonical display name (NOT a taxonomy id) — `matchPOISubject` never reads this field
27
- * itself, so the caller (`mailwoman`'s `poi-intent.ts`) is the one that interprets it per `kind`.
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 hits
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(?:near|in|at|around)\s+/gi
101
+ const ANCHOR_SEPARATOR = /,\s*|\s(near|in|at|around|to)\s+/gi
82
102
 
83
103
  /**
84
- * Longest subject we accept, in tokens. Lexicon phrases are short; 4 covers the table.
104
+ * Longest subject we accept, in tokens. Eight covers compound taxonomy phrases while bounding lexicon probes.
85
105
  */
86
- const MAX_SUBJECT_TOKENS = 4
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
- * 4 tokens). Scans separator occurrences left-to-right — a lexicon phrase may itself contain a bare separator word
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 { match: whole[0]!, subject: trimmed, remainder: "" }
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
- return { match: hits[0]!, subject, remainder }
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.