@mailwoman/kind-classifier 7.1.0 → 7.2.1
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 +120 -0
- package/index.ts +29 -0
- package/out/classify.d.ts +15 -0
- package/out/classify.d.ts.map +1 -1
- package/out/classify.js +29 -0
- package/out/classify.js.map +1 -1
- package/out/index.d.ts +5 -2
- package/out/index.d.ts.map +1 -1
- package/out/index.js +3 -2
- package/out/index.js.map +1 -1
- package/out/poi.d.ts +41 -0
- package/out/poi.d.ts.map +1 -0
- package/out/poi.js +69 -0
- package/out/poi.js.map +1 -0
- package/package.json +13 -5
- package/poi.ts +108 -0
- package/rules.ts +262 -0
- package/types.ts +31 -0
package/classify.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `classifyKind` — entry point for Stage 2.5 (kind classification).
|
|
7
|
+
*
|
|
8
|
+
* Composes the per-kind rules from `rules.ts` and picks the winner. Returns alternatives sorted by
|
|
9
|
+
* confidence so the coordinator can offer fallback paths when the top kind isn't actionable.
|
|
10
|
+
*
|
|
11
|
+
* Per the project's "possibilities not constraints" principle, every kind that fires above 0
|
|
12
|
+
* surfaces in `alternatives` — the caller decides whether to act on the top kind only or consider
|
|
13
|
+
* runner-ups.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createScorePOIQuery, type POIPhraseLookup } from "./poi.ts"
|
|
17
|
+
import {
|
|
18
|
+
scoreIntersection,
|
|
19
|
+
scoreLandmark,
|
|
20
|
+
scoreLocalityOnly,
|
|
21
|
+
scorePoBox,
|
|
22
|
+
scorePostcodeOnly,
|
|
23
|
+
scoreStructuredAddress,
|
|
24
|
+
scoreVague,
|
|
25
|
+
scoreVenueLandmark,
|
|
26
|
+
} from "./rules.ts"
|
|
27
|
+
import type { LocaleHint, NormalizedInputLite, QueryKind, QueryKindResult, QueryShapeLike } from "./types.ts"
|
|
28
|
+
|
|
29
|
+
interface KindScorer {
|
|
30
|
+
kind: QueryKind
|
|
31
|
+
score: (input: NormalizedInputLite, shape: QueryShapeLike) => number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SCORERS: ReadonlyArray<KindScorer> = [
|
|
35
|
+
{ kind: "po_box", score: scorePoBox },
|
|
36
|
+
{ kind: "landmark", score: (i, s) => Math.max(scoreLandmark(i, s), scoreVenueLandmark(i, s)) },
|
|
37
|
+
{ kind: "intersection", score: scoreIntersection },
|
|
38
|
+
{ kind: "postcode_only", score: scorePostcodeOnly },
|
|
39
|
+
{ kind: "locality_only", score: scoreLocalityOnly },
|
|
40
|
+
{ kind: "structured_address", score: scoreStructuredAddress },
|
|
41
|
+
{ kind: "vague", score: scoreVague },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Classify the query shape into a `QueryKind`. Synchronous + pure — produces the same result for the same `(input,
|
|
46
|
+
* shape)` pair.
|
|
47
|
+
*/
|
|
48
|
+
export function classifyKindSync(input: NormalizedInputLite, shape: QueryShapeLike): QueryKindResult {
|
|
49
|
+
const scored = SCORERS.map((s) => ({ kind: s.kind, confidence: s.score(input, shape) })).filter(
|
|
50
|
+
(s) => s.confidence > 0
|
|
51
|
+
)
|
|
52
|
+
scored.sort((a, b) => b.confidence - a.confidence)
|
|
53
|
+
|
|
54
|
+
const top = scored[0] ?? { kind: "vague" as QueryKind, confidence: 0.3 }
|
|
55
|
+
const alternatives = scored.slice(1).map((s) => ({ kind: s.kind, confidence: s.confidence }))
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
kind: top.kind,
|
|
59
|
+
confidence: top.confidence,
|
|
60
|
+
alternatives,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Async variant matching the runtime-pipeline's `classifyKind` contract.
|
|
66
|
+
*
|
|
67
|
+
* The locale parameter is accepted for future locale-aware rules (Japanese honorifics, etc.) but not currently used.
|
|
68
|
+
*/
|
|
69
|
+
export async function classifyKind(
|
|
70
|
+
input: NormalizedInputLite,
|
|
71
|
+
shape: QueryShapeLike,
|
|
72
|
+
_locale?: LocaleHint
|
|
73
|
+
): Promise<QueryKindResult> {
|
|
74
|
+
return classifyKindSync(input, shape)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Options for {@link createKindClassifier}. */
|
|
78
|
+
export interface KindClassifierOpts {
|
|
79
|
+
/**
|
|
80
|
+
* POI phrase lexicon (spec §3.1). When present, a `poi_query` scorer joins the rule set — injected, never imported,
|
|
81
|
+
* so this package stays dictionary-free. Absent → the returned classifier is behaviorally identical to
|
|
82
|
+
* {@link classifyKind}.
|
|
83
|
+
*/
|
|
84
|
+
poiLexicon?: POIPhraseLookup
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a kind classifier. Without opts this is exactly the default {@link classifyKind}; with a `poiLexicon` it
|
|
89
|
+
* additionally scores `poi_query` and merges it into the ranked result.
|
|
90
|
+
*/
|
|
91
|
+
export function createKindClassifier(
|
|
92
|
+
opts: KindClassifierOpts = {}
|
|
93
|
+
): (input: NormalizedInputLite, shape: QueryShapeLike, locale?: LocaleHint) => Promise<QueryKindResult> {
|
|
94
|
+
const { poiLexicon } = opts
|
|
95
|
+
|
|
96
|
+
if (!poiLexicon) return classifyKind
|
|
97
|
+
|
|
98
|
+
return async (input, shape, locale): Promise<QueryKindResult> => {
|
|
99
|
+
const base = classifyKindSync(input, shape)
|
|
100
|
+
const poiConfidence = createScorePOIQuery(poiLexicon, locale?.locale)(input, shape)
|
|
101
|
+
|
|
102
|
+
if (poiConfidence <= 0) return base
|
|
103
|
+
|
|
104
|
+
if (poiConfidence > base.confidence) {
|
|
105
|
+
return {
|
|
106
|
+
kind: "poi_query",
|
|
107
|
+
confidence: poiConfidence,
|
|
108
|
+
alternatives: [{ kind: base.kind, confidence: base.confidence }, ...base.alternatives],
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The literal needs the contextual element type — a bare array literal widens `kind` to string.
|
|
113
|
+
const poiAlternative: { kind: QueryKind; confidence: number } = { kind: "poi_query", confidence: poiConfidence }
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
...base,
|
|
117
|
+
alternatives: [...base.alternatives, poiAlternative].sort((a, b) => b.confidence - a.confidence),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `@mailwoman/kind-classifier` — Stage 2.5 of the runtime pipeline.
|
|
7
|
+
*
|
|
8
|
+
* Categorize inputs into one of eight `QueryKind`s by composing rule-based scorers over the
|
|
9
|
+
* QueryShape sub-system's output. Pure functions, no ML, no place-name dictionaries. Returns
|
|
10
|
+
* possibilities (alternatives) alongside the top pick so the coordinator can fall back when the
|
|
11
|
+
* winning kind isn't actionable.
|
|
12
|
+
*
|
|
13
|
+
* See `docs/articles/plan/reference/STAGES.md` § Stage 2.5 for the contract.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export { classifyKind, classifyKindSync, createKindClassifier } from "./classify.ts"
|
|
17
|
+
export type { KindClassifierOpts } from "./classify.ts"
|
|
18
|
+
export { matchPOISubject } from "./poi.ts"
|
|
19
|
+
export type { POIPhraseMatch, POIPhraseLookup, POISubjectMatch } from "./poi.ts"
|
|
20
|
+
export {
|
|
21
|
+
scoreIntersection,
|
|
22
|
+
scoreLandmark,
|
|
23
|
+
scoreLocalityOnly,
|
|
24
|
+
scorePoBox,
|
|
25
|
+
scorePostcodeOnly,
|
|
26
|
+
scoreStructuredAddress,
|
|
27
|
+
scoreVague,
|
|
28
|
+
} from "./rules.ts"
|
|
29
|
+
export type { LocaleHint, NormalizedInputLite, QueryKind, QueryKindResult, QueryShapeLike } from "./types.ts"
|
package/out/classify.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* surfaces in `alternatives` — the caller decides whether to act on the top kind only or consider
|
|
13
13
|
* runner-ups.
|
|
14
14
|
*/
|
|
15
|
+
import { type POIPhraseLookup } from "./poi.ts";
|
|
15
16
|
import type { LocaleHint, NormalizedInputLite, QueryKindResult, QueryShapeLike } from "./types.ts";
|
|
16
17
|
/**
|
|
17
18
|
* Classify the query shape into a `QueryKind`. Synchronous + pure — produces the same result for the same `(input,
|
|
@@ -24,4 +25,18 @@ export declare function classifyKindSync(input: NormalizedInputLite, shape: Quer
|
|
|
24
25
|
* The locale parameter is accepted for future locale-aware rules (Japanese honorifics, etc.) but not currently used.
|
|
25
26
|
*/
|
|
26
27
|
export declare function classifyKind(input: NormalizedInputLite, shape: QueryShapeLike, _locale?: LocaleHint): Promise<QueryKindResult>;
|
|
28
|
+
/** Options for {@link createKindClassifier}. */
|
|
29
|
+
export interface KindClassifierOpts {
|
|
30
|
+
/**
|
|
31
|
+
* POI phrase lexicon (spec §3.1). When present, a `poi_query` scorer joins the rule set — injected, never imported,
|
|
32
|
+
* so this package stays dictionary-free. Absent → the returned classifier is behaviorally identical to
|
|
33
|
+
* {@link classifyKind}.
|
|
34
|
+
*/
|
|
35
|
+
poiLexicon?: POIPhraseLookup;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build a kind classifier. Without opts this is exactly the default {@link classifyKind}; with a `poiLexicon` it
|
|
39
|
+
* additionally scores `poi_query` and merges it into the ranked result.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createKindClassifier(opts?: KindClassifierOpts): (input: NormalizedInputLite, shape: QueryShapeLike, locale?: LocaleHint) => Promise<QueryKindResult>;
|
|
27
42
|
//# sourceMappingURL=classify.d.ts.map
|
package/out/classify.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"classify.d.ts","sourceRoot":"","sources":["../classify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;
|
|
1
|
+
{"version":3,"file":"classify.d.ts","sourceRoot":"","sources":["../classify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAuB,KAAK,eAAe,EAAE,MAAM,UAAU,CAAA;AAWpE,OAAO,KAAK,EAAE,UAAU,EAAE,mBAAmB,EAAa,eAAe,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAiB7G;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,mBAAmB,EAAE,KAAK,EAAE,cAAc,GAAG,eAAe,CAcnG;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CACjC,KAAK,EAAE,mBAAmB,EAC1B,KAAK,EAAE,cAAc,EACrB,OAAO,CAAC,EAAE,UAAU,GAClB,OAAO,CAAC,eAAe,CAAC,CAE1B;AAED,gDAAgD;AAChD,MAAM,WAAW,kBAAkB;IAClC;;;;OAIG;IACH,UAAU,CAAC,EAAE,eAAe,CAAA;CAC5B;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CACnC,IAAI,GAAE,kBAAuB,GAC3B,CAAC,KAAK,EAAE,mBAAmB,EAAE,KAAK,EAAE,cAAc,EAAE,MAAM,CAAC,EAAE,UAAU,KAAK,OAAO,CAAC,eAAe,CAAC,CA2BtG"}
|
package/out/classify.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* surfaces in `alternatives` — the caller decides whether to act on the top kind only or consider
|
|
13
13
|
* runner-ups.
|
|
14
14
|
*/
|
|
15
|
+
import { createScorePOIQuery } from "./poi.js";
|
|
15
16
|
import { scoreIntersection, scoreLandmark, scoreLocalityOnly, scorePoBox, scorePostcodeOnly, scoreStructuredAddress, scoreVague, scoreVenueLandmark, } from "./rules.js";
|
|
16
17
|
const SCORERS = [
|
|
17
18
|
{ kind: "po_box", score: scorePoBox },
|
|
@@ -45,4 +46,32 @@ export function classifyKindSync(input, shape) {
|
|
|
45
46
|
export async function classifyKind(input, shape, _locale) {
|
|
46
47
|
return classifyKindSync(input, shape);
|
|
47
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Build a kind classifier. Without opts this is exactly the default {@link classifyKind}; with a `poiLexicon` it
|
|
51
|
+
* additionally scores `poi_query` and merges it into the ranked result.
|
|
52
|
+
*/
|
|
53
|
+
export function createKindClassifier(opts = {}) {
|
|
54
|
+
const { poiLexicon } = opts;
|
|
55
|
+
if (!poiLexicon)
|
|
56
|
+
return classifyKind;
|
|
57
|
+
return async (input, shape, locale) => {
|
|
58
|
+
const base = classifyKindSync(input, shape);
|
|
59
|
+
const poiConfidence = createScorePOIQuery(poiLexicon, locale?.locale)(input, shape);
|
|
60
|
+
if (poiConfidence <= 0)
|
|
61
|
+
return base;
|
|
62
|
+
if (poiConfidence > base.confidence) {
|
|
63
|
+
return {
|
|
64
|
+
kind: "poi_query",
|
|
65
|
+
confidence: poiConfidence,
|
|
66
|
+
alternatives: [{ kind: base.kind, confidence: base.confidence }, ...base.alternatives],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// The literal needs the contextual element type — a bare array literal widens `kind` to string.
|
|
70
|
+
const poiAlternative = { kind: "poi_query", confidence: poiConfidence };
|
|
71
|
+
return {
|
|
72
|
+
...base,
|
|
73
|
+
alternatives: [...base.alternatives, poiAlternative].sort((a, b) => b.confidence - a.confidence),
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}
|
|
48
77
|
//# sourceMappingURL=classify.js.map
|
package/out/classify.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"classify.js","sourceRoot":"","sources":["../classify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACN,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EACjB,UAAU,EACV,iBAAiB,EACjB,sBAAsB,EACtB,UAAU,EACV,kBAAkB,GAClB,MAAM,YAAY,CAAA;AAQnB,MAAM,OAAO,GAA8B;IAC1C,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,UAAU,EAAE;IACrC,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,kBAAkB,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE;IAC9F,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,iBAAiB,EAAE;IAClD,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,iBAAiB,EAAE;IACnD,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,iBAAiB,EAAE;IACnD,EAAE,IAAI,EAAE,oBAAoB,EAAE,KAAK,EAAE,sBAAsB,EAAE;IAC7D,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE;CACpC,CAAA;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAA0B,EAAE,KAAqB;IACjF,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAC9F,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,GAAG,CAAC,CACvB,CAAA;IACD,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,UAAU,CAAC,CAAA;IAElD,MAAM,GAAG,GAAG,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,OAAoB,EAAE,UAAU,EAAE,GAAG,EAAE,CAAA;IACxE,MAAM,YAAY,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC,UAAU,EAAE,CAAC,CAAC,CAAA;IAE7F,OAAO;QACN,IAAI,EAAE,GAAG,CAAC,IAAI;QACd,UAAU,EAAE,GAAG,CAAC,UAAU;QAC1B,YAAY;KACZ,CAAA;AACF,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CACjC,KAA0B,EAC1B,KAAqB,EACrB,OAAoB;IAEpB,OAAO,gBAAgB,CAAC,KAAK,EAAE,KAAK,CAAC,CAAA;AACtC,CAAC"}
|
|
1
|
+
{"version":3,"file":"classify.js","sourceRoot":"","sources":["../classify.ts"],"names":[],"mappings":"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"}
|
package/out/index.d.ts
CHANGED
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
*
|
|
6
6
|
* `@mailwoman/kind-classifier` — Stage 2.5 of the runtime pipeline.
|
|
7
7
|
*
|
|
8
|
-
* Categorize inputs into one of
|
|
8
|
+
* Categorize inputs into one of eight `QueryKind`s by composing rule-based scorers over the
|
|
9
9
|
* QueryShape sub-system's output. Pure functions, no ML, no place-name dictionaries. Returns
|
|
10
10
|
* possibilities (alternatives) alongside the top pick so the coordinator can fall back when the
|
|
11
11
|
* winning kind isn't actionable.
|
|
12
12
|
*
|
|
13
13
|
* See `docs/articles/plan/reference/STAGES.md` § Stage 2.5 for the contract.
|
|
14
14
|
*/
|
|
15
|
-
export { classifyKind, classifyKindSync } from "./classify.ts";
|
|
15
|
+
export { classifyKind, classifyKindSync, createKindClassifier } from "./classify.ts";
|
|
16
|
+
export type { KindClassifierOpts } from "./classify.ts";
|
|
17
|
+
export { matchPOISubject } from "./poi.ts";
|
|
18
|
+
export type { POIPhraseMatch, POIPhraseLookup, POISubjectMatch } from "./poi.ts";
|
|
16
19
|
export { scoreIntersection, scoreLandmark, scoreLocalityOnly, scorePoBox, scorePostcodeOnly, scoreStructuredAddress, scoreVague, } from "./rules.ts";
|
|
17
20
|
export type { LocaleHint, NormalizedInputLite, QueryKind, QueryKindResult, QueryShapeLike } from "./types.ts";
|
|
18
21
|
//# sourceMappingURL=index.d.ts.map
|
package/out/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA;AACpF,YAAY,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AACvD,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAC1C,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAChF,OAAO,EACN,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EACjB,UAAU,EACV,iBAAiB,EACjB,sBAAsB,EACtB,UAAU,GACV,MAAM,YAAY,CAAA;AACnB,YAAY,EAAE,UAAU,EAAE,mBAAmB,EAAE,SAAS,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA"}
|
package/out/index.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
*
|
|
6
6
|
* `@mailwoman/kind-classifier` — Stage 2.5 of the runtime pipeline.
|
|
7
7
|
*
|
|
8
|
-
* Categorize inputs into one of
|
|
8
|
+
* Categorize inputs into one of eight `QueryKind`s by composing rule-based scorers over the
|
|
9
9
|
* QueryShape sub-system's output. Pure functions, no ML, no place-name dictionaries. Returns
|
|
10
10
|
* possibilities (alternatives) alongside the top pick so the coordinator can fall back when the
|
|
11
11
|
* winning kind isn't actionable.
|
|
12
12
|
*
|
|
13
13
|
* See `docs/articles/plan/reference/STAGES.md` § Stage 2.5 for the contract.
|
|
14
14
|
*/
|
|
15
|
-
export { classifyKind, classifyKindSync } from "./classify.js";
|
|
15
|
+
export { classifyKind, classifyKindSync, createKindClassifier } from "./classify.js";
|
|
16
|
+
export { matchPOISubject } from "./poi.js";
|
|
16
17
|
export { scoreIntersection, scoreLandmark, scoreLocalityOnly, scorePoBox, scorePostcodeOnly, scoreStructuredAddress, scoreVague, } from "./rules.js";
|
|
17
18
|
//# sourceMappingURL=index.js.map
|
package/out/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA;AAEpF,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AAE1C,OAAO,EACN,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EACjB,UAAU,EACV,iBAAiB,EACjB,sBAAsB,EACtB,UAAU,GACV,MAAM,YAAY,CAAA"}
|
package/out/poi.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* POI subject detection for the `poi_query` kind. The lexicon is INJECTED (`POIPhraseLookup`) —
|
|
7
|
+
* this package keeps its bitter-lesson invariant (no dictionaries in-tree); the phrase table
|
|
8
|
+
* lives in `@mailwoman/poi-taxonomy` and is wired in by `createRuntimePipeline` behind the
|
|
9
|
+
* default-OFF `poiQueryKind` flag. Spec §3.1.
|
|
10
|
+
*/
|
|
11
|
+
import type { NormalizedInputLite, QueryShapeLike } from "./types.ts";
|
|
12
|
+
/** One lexicon hit for a candidate subject phrase. */
|
|
13
|
+
export interface POIPhraseMatch {
|
|
14
|
+
categoryID: string;
|
|
15
|
+
matchedPhrase: string;
|
|
16
|
+
confidence: number;
|
|
17
|
+
}
|
|
18
|
+
/** Injected phrase→category lookup. Exact-phrase, locale-aware; returns [] on miss. */
|
|
19
|
+
export type POIPhraseLookup = (phrase: string, locale?: string) => ReadonlyArray<POIPhraseMatch>;
|
|
20
|
+
export interface POISubjectMatch {
|
|
21
|
+
match: POIPhraseMatch;
|
|
22
|
+
/** The matched subject text as it appeared in the query. */
|
|
23
|
+
subject: string;
|
|
24
|
+
/** The anchor remainder after the separator; `""` when the whole input matched. */
|
|
25
|
+
remainder: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Match a POI subject: the whole input, or the text before the FIRST anchor separator WHOSE PREFIX HITS THE LEXICON (≤
|
|
29
|
+
* 4 tokens). Scans separator occurrences left-to-right — a lexicon phrase may itself contain a bare separator word
|
|
30
|
+
* (e.g. "walk in clinic"), so the first separator isn't necessarily the right split point. Returns null when the
|
|
31
|
+
* lexicon never fires — including comma-ridden full addresses whose leading segment isn't a lexicon phrase.
|
|
32
|
+
*/
|
|
33
|
+
export declare function matchPOISubject(text: string, locale: string | undefined, lookup: POIPhraseLookup): POISubjectMatch | null;
|
|
34
|
+
/**
|
|
35
|
+
* `poi_query` scorer over an injected lexicon. Confidence bands: whole-input lexicon hit 0.92 (above venue-landmark's
|
|
36
|
+
* 0.88 ceiling — an exact lexicon phrase beats a shape heuristic); subject + anchor 0.9. Guards below keep venue-led
|
|
37
|
+
* FULL addresses (class 2) on the structured-address path: a remainder that leads with a house number, or a 4+-segment
|
|
38
|
+
* input, scores 0 here.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createScorePOIQuery(lookup: POIPhraseLookup, locale?: string): (input: NormalizedInputLite, shape: QueryShapeLike) => number;
|
|
41
|
+
//# sourceMappingURL=poi.d.ts.map
|
package/out/poi.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"poi.d.ts","sourceRoot":"","sources":["../poi.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAErE,sDAAsD;AACtD,MAAM,WAAW,cAAc;IAC9B,UAAU,EAAE,MAAM,CAAA;IAClB,aAAa,EAAE,MAAM,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;CAClB;AAED,uFAAuF;AACvF,MAAM,MAAM,eAAe,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,aAAa,CAAC,cAAc,CAAC,CAAA;AAEhG,MAAM,WAAW,eAAe;IAC/B,KAAK,EAAE,cAAc,CAAA;IACrB,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAA;IACf,mFAAmF;IACnF,SAAS,EAAE,MAAM,CAAA;CACjB;AAWD;;;;;GAKG;AACH,wBAAgB,eAAe,CAC9B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,MAAM,EAAE,eAAe,GACrB,eAAe,GAAG,IAAI,CA6BxB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAClC,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,MAAM,GACb,CAAC,KAAK,EAAE,mBAAmB,EAAE,KAAK,EAAE,cAAc,KAAK,MAAM,CAiB/D"}
|
package/out/poi.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* POI subject detection for the `poi_query` kind. The lexicon is INJECTED (`POIPhraseLookup`) —
|
|
7
|
+
* this package keeps its bitter-lesson invariant (no dictionaries in-tree); the phrase table
|
|
8
|
+
* lives in `@mailwoman/poi-taxonomy` and is wired in by `createRuntimePipeline` behind the
|
|
9
|
+
* default-OFF `poiQueryKind` flag. Spec §3.1.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Anchor separator between subject and place: comma, or near/in/at/around — scanned left-to-right until a prefix hits
|
|
13
|
+
* the lexicon.
|
|
14
|
+
*/
|
|
15
|
+
const ANCHOR_SEPARATOR = /\s*,\s*|\s+(?:near|in|at|around)\s+/gi;
|
|
16
|
+
/** Longest subject we accept, in tokens. Lexicon phrases are short; 4 covers the table. */
|
|
17
|
+
const MAX_SUBJECT_TOKENS = 4;
|
|
18
|
+
/**
|
|
19
|
+
* Match a POI subject: the whole input, or the text before the FIRST anchor separator WHOSE PREFIX HITS THE LEXICON (≤
|
|
20
|
+
* 4 tokens). Scans separator occurrences left-to-right — a lexicon phrase may itself contain a bare separator word
|
|
21
|
+
* (e.g. "walk in clinic"), so the first separator isn't necessarily the right split point. Returns null when the
|
|
22
|
+
* lexicon never fires — including comma-ridden full addresses whose leading segment isn't a lexicon phrase.
|
|
23
|
+
*/
|
|
24
|
+
export function matchPOISubject(text, locale, lookup) {
|
|
25
|
+
const trimmed = text.trim();
|
|
26
|
+
if (!trimmed)
|
|
27
|
+
return null;
|
|
28
|
+
const whole = lookup(trimmed, locale);
|
|
29
|
+
if (whole.length > 0) {
|
|
30
|
+
return { match: whole[0], subject: trimmed, remainder: "" };
|
|
31
|
+
}
|
|
32
|
+
for (const separator of trimmed.matchAll(ANCHOR_SEPARATOR)) {
|
|
33
|
+
if (separator.index === 0)
|
|
34
|
+
continue;
|
|
35
|
+
const subject = trimmed.slice(0, separator.index).trim();
|
|
36
|
+
// Subjects only grow as the scan moves right — once over budget, later splits are too.
|
|
37
|
+
if (subject.split(/\s+/).length > MAX_SUBJECT_TOKENS)
|
|
38
|
+
break;
|
|
39
|
+
const hits = lookup(subject, locale);
|
|
40
|
+
if (hits.length === 0)
|
|
41
|
+
continue;
|
|
42
|
+
const remainder = trimmed.slice(separator.index + separator[0].length).trim();
|
|
43
|
+
return { match: hits[0], subject, remainder };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `poi_query` scorer over an injected lexicon. Confidence bands: whole-input lexicon hit 0.92 (above venue-landmark's
|
|
49
|
+
* 0.88 ceiling — an exact lexicon phrase beats a shape heuristic); subject + anchor 0.9. Guards below keep venue-led
|
|
50
|
+
* FULL addresses (class 2) on the structured-address path: a remainder that leads with a house number, or a 4+-segment
|
|
51
|
+
* input, scores 0 here.
|
|
52
|
+
*/
|
|
53
|
+
export function createScorePOIQuery(lookup, locale) {
|
|
54
|
+
return (input, shape) => {
|
|
55
|
+
const matched = matchPOISubject(input.normalized, locale ?? input.appliedLocale, lookup);
|
|
56
|
+
if (!matched)
|
|
57
|
+
return 0;
|
|
58
|
+
if (matched.remainder === "")
|
|
59
|
+
return 0.92 * matched.match.confidence;
|
|
60
|
+
// Venue-led full address: "X, 350 5th Ave, …" stays a structured_address parse.
|
|
61
|
+
if (/^\d+\s/.test(matched.remainder))
|
|
62
|
+
return 0;
|
|
63
|
+
const segCount = shape.segments?.length ?? 1;
|
|
64
|
+
if (segCount > 3)
|
|
65
|
+
return 0;
|
|
66
|
+
return 0.9 * matched.match.confidence;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=poi.js.map
|
package/out/poi.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"poi.js","sourceRoot":"","sources":["../poi.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAsBH;;;GAGG;AACH,MAAM,gBAAgB,GAAG,uCAAuC,CAAA;AAEhE,2FAA2F;AAC3F,MAAM,kBAAkB,GAAG,CAAC,CAAA;AAE5B;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAC9B,IAAY,EACZ,MAA0B,EAC1B,MAAuB;IAEvB,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAA;IAE3B,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAA;IAEzB,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAAA;IAErC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,CAAE,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE,EAAE,CAAA;IAC7D,CAAC;IAED,KAAK,MAAM,SAAS,IAAI,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAC;QAC5D,IAAI,SAAS,CAAC,KAAK,KAAK,CAAC;YAAE,SAAQ;QAEnC,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAA;QAExD,uFAAuF;QACvF,IAAI,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,kBAAkB;YAAE,MAAK;QAE3D,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,CAAA;QAEpC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,SAAQ;QAE/B,MAAM,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC,SAAS,CAAC,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAA;QAE7E,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAE,EAAE,OAAO,EAAE,SAAS,EAAE,CAAA;IAC/C,CAAC;IAED,OAAO,IAAI,CAAA;AACZ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAClC,MAAuB,EACvB,MAAe;IAEf,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;QACvB,MAAM,OAAO,GAAG,eAAe,CAAC,KAAK,CAAC,UAAU,EAAE,MAAM,IAAI,KAAK,CAAC,aAAa,EAAE,MAAM,CAAC,CAAA;QAExF,IAAI,CAAC,OAAO;YAAE,OAAO,CAAC,CAAA;QAEtB,IAAI,OAAO,CAAC,SAAS,KAAK,EAAE;YAAE,OAAO,IAAI,GAAG,OAAO,CAAC,KAAK,CAAC,UAAU,CAAA;QAEpE,gFAAgF;QAChF,IAAI,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC;YAAE,OAAO,CAAC,CAAA;QAE9C,MAAM,QAAQ,GAAG,KAAK,CAAC,QAAQ,EAAE,MAAM,IAAI,CAAC,CAAA;QAE5C,IAAI,QAAQ,GAAG,CAAC;YAAE,OAAO,CAAC,CAAA;QAE1B,OAAO,GAAG,GAAG,OAAO,CAAC,KAAK,CAAC,UAAU,CAAA;IACtC,CAAC,CAAA;AACF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/kind-classifier",
|
|
3
|
-
"version": "7.1
|
|
3
|
+
"version": "7.2.1",
|
|
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": {
|
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
"out/**/*.js",
|
|
13
13
|
"out/**/*.js.map",
|
|
14
14
|
"out/**/*.d.ts",
|
|
15
|
-
"out/**/*.d.ts.map"
|
|
15
|
+
"out/**/*.d.ts.map",
|
|
16
|
+
"*.ts",
|
|
17
|
+
"*.tsx",
|
|
18
|
+
"**/*.ts",
|
|
19
|
+
"**/*.tsx",
|
|
20
|
+
"!*.test.ts",
|
|
21
|
+
"!*.test.tsx",
|
|
22
|
+
"!**/*.test.ts",
|
|
23
|
+
"!**/*.test.tsx"
|
|
16
24
|
],
|
|
17
25
|
"type": "module",
|
|
18
26
|
"exports": {
|
|
@@ -23,16 +31,16 @@
|
|
|
23
31
|
}
|
|
24
32
|
},
|
|
25
33
|
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
26
35
|
"exports": {
|
|
27
36
|
"./package.json": "./package.json",
|
|
28
37
|
".": {
|
|
29
38
|
"types": "./out/index.d.ts",
|
|
30
39
|
"default": "./out/index.js"
|
|
31
40
|
}
|
|
32
|
-
}
|
|
33
|
-
"access": "public"
|
|
41
|
+
}
|
|
34
42
|
},
|
|
35
43
|
"dependencies": {
|
|
36
|
-
"@mailwoman/core": "7.1
|
|
44
|
+
"@mailwoman/core": "7.2.1"
|
|
37
45
|
}
|
|
38
46
|
}
|
package/poi.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* POI subject detection for the `poi_query` kind. The lexicon is INJECTED (`POIPhraseLookup`) —
|
|
7
|
+
* this package keeps its bitter-lesson invariant (no dictionaries in-tree); the phrase table
|
|
8
|
+
* lives in `@mailwoman/poi-taxonomy` and is wired in by `createRuntimePipeline` behind the
|
|
9
|
+
* default-OFF `poiQueryKind` flag. Spec §3.1.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { NormalizedInputLite, QueryShapeLike } from "./types.ts"
|
|
13
|
+
|
|
14
|
+
/** One lexicon hit for a candidate subject phrase. */
|
|
15
|
+
export interface POIPhraseMatch {
|
|
16
|
+
categoryID: string
|
|
17
|
+
matchedPhrase: string
|
|
18
|
+
confidence: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Injected phrase→category lookup. Exact-phrase, locale-aware; returns [] on miss. */
|
|
22
|
+
export type POIPhraseLookup = (phrase: string, locale?: string) => ReadonlyArray<POIPhraseMatch>
|
|
23
|
+
|
|
24
|
+
export interface POISubjectMatch {
|
|
25
|
+
match: POIPhraseMatch
|
|
26
|
+
/** The matched subject text as it appeared in the query. */
|
|
27
|
+
subject: string
|
|
28
|
+
/** The anchor remainder after the separator; `""` when the whole input matched. */
|
|
29
|
+
remainder: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Anchor separator between subject and place: comma, or near/in/at/around — scanned left-to-right until a prefix hits
|
|
34
|
+
* the lexicon.
|
|
35
|
+
*/
|
|
36
|
+
const ANCHOR_SEPARATOR = /\s*,\s*|\s+(?:near|in|at|around)\s+/gi
|
|
37
|
+
|
|
38
|
+
/** Longest subject we accept, in tokens. Lexicon phrases are short; 4 covers the table. */
|
|
39
|
+
const MAX_SUBJECT_TOKENS = 4
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Match a POI subject: the whole input, or the text before the FIRST anchor separator WHOSE PREFIX HITS THE LEXICON (≤
|
|
43
|
+
* 4 tokens). Scans separator occurrences left-to-right — a lexicon phrase may itself contain a bare separator word
|
|
44
|
+
* (e.g. "walk in clinic"), so the first separator isn't necessarily the right split point. Returns null when the
|
|
45
|
+
* lexicon never fires — including comma-ridden full addresses whose leading segment isn't a lexicon phrase.
|
|
46
|
+
*/
|
|
47
|
+
export function matchPOISubject(
|
|
48
|
+
text: string,
|
|
49
|
+
locale: string | undefined,
|
|
50
|
+
lookup: POIPhraseLookup
|
|
51
|
+
): POISubjectMatch | null {
|
|
52
|
+
const trimmed = text.trim()
|
|
53
|
+
|
|
54
|
+
if (!trimmed) return null
|
|
55
|
+
|
|
56
|
+
const whole = lookup(trimmed, locale)
|
|
57
|
+
|
|
58
|
+
if (whole.length > 0) {
|
|
59
|
+
return { match: whole[0]!, subject: trimmed, remainder: "" }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const separator of trimmed.matchAll(ANCHOR_SEPARATOR)) {
|
|
63
|
+
if (separator.index === 0) continue
|
|
64
|
+
|
|
65
|
+
const subject = trimmed.slice(0, separator.index).trim()
|
|
66
|
+
|
|
67
|
+
// Subjects only grow as the scan moves right — once over budget, later splits are too.
|
|
68
|
+
if (subject.split(/\s+/).length > MAX_SUBJECT_TOKENS) break
|
|
69
|
+
|
|
70
|
+
const hits = lookup(subject, locale)
|
|
71
|
+
|
|
72
|
+
if (hits.length === 0) continue
|
|
73
|
+
|
|
74
|
+
const remainder = trimmed.slice(separator.index + separator[0].length).trim()
|
|
75
|
+
|
|
76
|
+
return { match: hits[0]!, subject, remainder }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `poi_query` scorer over an injected lexicon. Confidence bands: whole-input lexicon hit 0.92 (above venue-landmark's
|
|
84
|
+
* 0.88 ceiling — an exact lexicon phrase beats a shape heuristic); subject + anchor 0.9. Guards below keep venue-led
|
|
85
|
+
* FULL addresses (class 2) on the structured-address path: a remainder that leads with a house number, or a 4+-segment
|
|
86
|
+
* input, scores 0 here.
|
|
87
|
+
*/
|
|
88
|
+
export function createScorePOIQuery(
|
|
89
|
+
lookup: POIPhraseLookup,
|
|
90
|
+
locale?: string
|
|
91
|
+
): (input: NormalizedInputLite, shape: QueryShapeLike) => number {
|
|
92
|
+
return (input, shape) => {
|
|
93
|
+
const matched = matchPOISubject(input.normalized, locale ?? input.appliedLocale, lookup)
|
|
94
|
+
|
|
95
|
+
if (!matched) return 0
|
|
96
|
+
|
|
97
|
+
if (matched.remainder === "") return 0.92 * matched.match.confidence
|
|
98
|
+
|
|
99
|
+
// Venue-led full address: "X, 350 5th Ave, …" stays a structured_address parse.
|
|
100
|
+
if (/^\d+\s/.test(matched.remainder)) return 0
|
|
101
|
+
|
|
102
|
+
const segCount = shape.segments?.length ?? 1
|
|
103
|
+
|
|
104
|
+
if (segCount > 3) return 0
|
|
105
|
+
|
|
106
|
+
return 0.9 * matched.match.confidence
|
|
107
|
+
}
|
|
108
|
+
}
|
package/rules.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Rule-based classifiers for each `QueryKind`. Each rule inspects the normalized input + QueryShape
|
|
7
|
+
* and returns a confidence score in [0, 1], or 0 if the rule doesn't fire.
|
|
8
|
+
*
|
|
9
|
+
* Bitter-lesson-safe: only universal structural patterns — no place-name dictionaries. ~1 small
|
|
10
|
+
* regex set per new locale, not 50K dictionary entries.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { NormalizedInputLite, QueryShapeLike } from "./types.ts"
|
|
14
|
+
|
|
15
|
+
/** Landmark vocabulary — phrases that suggest a vague-location description rather than an address. */
|
|
16
|
+
const LANDMARK_LEADERS = [
|
|
17
|
+
"behind",
|
|
18
|
+
"near",
|
|
19
|
+
"across from",
|
|
20
|
+
"opposite",
|
|
21
|
+
"next to",
|
|
22
|
+
"by the",
|
|
23
|
+
"in front of",
|
|
24
|
+
"close to",
|
|
25
|
+
"beside",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** Intersection vocabulary — words that signal "where two streets cross" rather than an address. */
|
|
29
|
+
const INTERSECTION_PATTERNS = [
|
|
30
|
+
/\bcorner of\b/i,
|
|
31
|
+
/\bintersection of\b/i,
|
|
32
|
+
/\bat the corner of\b/i,
|
|
33
|
+
// "5th and Main", "Broadway & 42nd"
|
|
34
|
+
/\b\w+(?:st|nd|rd|th|street|ave|avenue|blvd|boulevard|road|rd|lane|ln)?\s+(?:and|&|@)\s+\w+/i,
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `po_box` rule: high-confidence iff QueryShape detected a po_box format hit. Confidence comes directly from the hit;
|
|
39
|
+
* covers all locale variants (US "PO Box 123", FR "BP 42", etc.).
|
|
40
|
+
*/
|
|
41
|
+
export function scorePoBox(_input: NormalizedInputLite, shape: QueryShapeLike): number {
|
|
42
|
+
const hit = shape.knownFormats.find((f) => f.format === "po_box")
|
|
43
|
+
|
|
44
|
+
if (!hit) return 0
|
|
45
|
+
|
|
46
|
+
// Boost slightly above the raw hit confidence so po_box wins ties with structured_address when
|
|
47
|
+
// both rules fire on the same input.
|
|
48
|
+
return Math.min(1, hit.confidence + 0.1)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `intersection` rule: text matches one of the conventional intersection phrasings.
|
|
53
|
+
*/
|
|
54
|
+
export function scoreIntersection(input: NormalizedInputLite, _shape: QueryShapeLike): number {
|
|
55
|
+
const text = input.normalized
|
|
56
|
+
|
|
57
|
+
for (const pattern of INTERSECTION_PATTERNS) {
|
|
58
|
+
if (pattern.test(text)) return 0.85
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `landmark` rule: text begins with a landmark-leader phrase. These inputs are not addresses proper — they describe a
|
|
66
|
+
* location relative to another place.
|
|
67
|
+
*/
|
|
68
|
+
export function scoreLandmark(input: NormalizedInputLite, _shape: QueryShapeLike): number {
|
|
69
|
+
const lc = input.normalized.toLowerCase().trim()
|
|
70
|
+
|
|
71
|
+
for (const leader of LANDMARK_LEADERS) {
|
|
72
|
+
if (lc.startsWith(leader + " ") || lc === leader) return 0.9
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return 0
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Street-suffix tokens that indicate an address, not a venue name. */
|
|
79
|
+
const STREET_SUFFIXES = new Set([
|
|
80
|
+
"st",
|
|
81
|
+
"street",
|
|
82
|
+
"ave",
|
|
83
|
+
"avenue",
|
|
84
|
+
"blvd",
|
|
85
|
+
"boulevard",
|
|
86
|
+
"rd",
|
|
87
|
+
"road",
|
|
88
|
+
"dr",
|
|
89
|
+
"drive",
|
|
90
|
+
"ln",
|
|
91
|
+
"lane",
|
|
92
|
+
"ct",
|
|
93
|
+
"court",
|
|
94
|
+
"pl",
|
|
95
|
+
"place",
|
|
96
|
+
"way",
|
|
97
|
+
"pkwy",
|
|
98
|
+
"parkway",
|
|
99
|
+
"hwy",
|
|
100
|
+
"highway",
|
|
101
|
+
"cir",
|
|
102
|
+
"circle",
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `landmark` rule (venue/named-place variant): short capitalized input with no street suffixes, no postcode hits, and
|
|
107
|
+
* no region abbreviations. Captures "Pier 39", "Empire State Building", "Wrigley Field", "Grand Central Terminal".
|
|
108
|
+
*
|
|
109
|
+
* Fires at moderate confidence (0.65) — below structured_address (0.9) so addresses always win, but above vague (0.3)
|
|
110
|
+
* so the pipeline can route landmark queries to the venue resolver.
|
|
111
|
+
*/
|
|
112
|
+
export function scoreVenueLandmark(input: NormalizedInputLite, shape: QueryShapeLike): number {
|
|
113
|
+
const text = input.normalized.trim()
|
|
114
|
+
const len = text.length
|
|
115
|
+
|
|
116
|
+
if (len === 0 || len > 50) return 0
|
|
117
|
+
|
|
118
|
+
// Must have at least one capitalized word.
|
|
119
|
+
if (!/[A-Z]/.test(text)) return 0
|
|
120
|
+
|
|
121
|
+
// Reject if any known postcode format hit exists.
|
|
122
|
+
if (shape.knownFormats.length > 0) return 0
|
|
123
|
+
|
|
124
|
+
// Reject if it looks like a multi-segment structured address (City, ST ZIP).
|
|
125
|
+
const segCount = shape.segments?.length ?? 1
|
|
126
|
+
|
|
127
|
+
if (segCount > 2) return 0
|
|
128
|
+
|
|
129
|
+
// Reject if any word is a street suffix.
|
|
130
|
+
const words = text.split(/[\s,]+/)
|
|
131
|
+
|
|
132
|
+
for (const w of words) {
|
|
133
|
+
if (STREET_SUFFIXES.has(w.toLowerCase())) return 0
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Reject if the first token is a pure number (house-number-leading pattern).
|
|
137
|
+
if (/^\d+\s/.test(text)) return 0
|
|
138
|
+
|
|
139
|
+
// Boost if the input has a number NOT at the start (venue-style: "Pier 39", "Terminal 5").
|
|
140
|
+
const hasInternalNumber = /\s\d+/.test(text) && !/^\d/.test(text)
|
|
141
|
+
|
|
142
|
+
// Check if every word starts with uppercase (proper-noun pattern).
|
|
143
|
+
const allProperCase = words.length > 1 && words.every((w) => /^[A-Z]/.test(w))
|
|
144
|
+
|
|
145
|
+
// Boost for short single-segment capitalized phrases (2-4 words).
|
|
146
|
+
const wordCount = words.length
|
|
147
|
+
|
|
148
|
+
if (wordCount >= 2 && wordCount <= 4 && segCount === 1) {
|
|
149
|
+
if (hasInternalNumber) return 0.88
|
|
150
|
+
|
|
151
|
+
if (allProperCase) return 0.88
|
|
152
|
+
|
|
153
|
+
return 0.65
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Longer single-segment capitalized phrases get moderate confidence.
|
|
157
|
+
if (wordCount <= 6 && segCount === 1 && allProperCase) {
|
|
158
|
+
return 0.75
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return 0
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Known QueryShape format strings that indicate "this token is a postcode". */
|
|
165
|
+
const POSTCODE_FORMATS: ReadonlySet<string> = new Set([
|
|
166
|
+
"us_zip",
|
|
167
|
+
"us_zip4",
|
|
168
|
+
"uk_postcode",
|
|
169
|
+
"fr_postcode",
|
|
170
|
+
"de_postcode",
|
|
171
|
+
"ca_postcode",
|
|
172
|
+
"jp_postcode",
|
|
173
|
+
"nl_postcode",
|
|
174
|
+
])
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Test whether a format string is a postcode variant. Use the set rather than ad-hoc string-matching to avoid the
|
|
178
|
+
* `us_zip4.endsWith("_zip")` false-negative trap.
|
|
179
|
+
*/
|
|
180
|
+
export function isPostcodeFormat(format: string): boolean {
|
|
181
|
+
return POSTCODE_FORMATS.has(format)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `postcode_only` rule: input is short AND has a postcode format hit covering most of it.
|
|
186
|
+
*
|
|
187
|
+
* The "covering most of it" check is what distinguishes `"10118"` (postcode-only) from `"350 5th Ave 10118"`
|
|
188
|
+
* (structured-address with a postcode in it).
|
|
189
|
+
*/
|
|
190
|
+
export function scorePostcodeOnly(input: NormalizedInputLite, shape: QueryShapeLike): number {
|
|
191
|
+
const len = input.normalized.length
|
|
192
|
+
|
|
193
|
+
if (len === 0 || len > 16) return 0
|
|
194
|
+
const postcodeHit = shape.knownFormats.find((f) => isPostcodeFormat(f.format))
|
|
195
|
+
|
|
196
|
+
if (!postcodeHit) return 0
|
|
197
|
+
const hitLen = postcodeHit.span.end - postcodeHit.span.start
|
|
198
|
+
|
|
199
|
+
// At least 70% of the input must be the postcode for the rule to fire confidently.
|
|
200
|
+
if (hitLen / len < 0.7) return 0
|
|
201
|
+
|
|
202
|
+
// Confidence scales with how much of the input is the postcode and how confident the format hit was.
|
|
203
|
+
return Math.min(1, postcodeHit.confidence * (hitLen / len) + 0.1)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `locality_only` rule: short input, alpha-class, single segment, no format hits.
|
|
208
|
+
*
|
|
209
|
+
* Examples: `"Paris"`, `"NYC NY"`, `"Tokyo"`. Distinguishes from `structured_address` (multiple segments) and `vague`
|
|
210
|
+
* (long or mixed-class).
|
|
211
|
+
*/
|
|
212
|
+
export function scoreLocalityOnly(input: NormalizedInputLite, shape: QueryShapeLike): number {
|
|
213
|
+
const len = input.normalized.length
|
|
214
|
+
|
|
215
|
+
if (len === 0 || len > 30) return 0
|
|
216
|
+
|
|
217
|
+
if (shape.characterClass !== "alpha") return 0
|
|
218
|
+
|
|
219
|
+
if (shape.knownFormats.length > 0) return 0
|
|
220
|
+
// Locality-only inputs typically have 1-3 segments (e.g. "New York" is 1 segment, "Paris, FR" is 2).
|
|
221
|
+
// We allow up to 2 segments before deciding it's structured.
|
|
222
|
+
const segCount = shape.segments?.length ?? 1
|
|
223
|
+
|
|
224
|
+
if (segCount > 2) return 0
|
|
225
|
+
|
|
226
|
+
return 0.85
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `structured_address` rule: looks like a real multi-component address. Either has multiple segments or is long and
|
|
231
|
+
* mixed-class.
|
|
232
|
+
*/
|
|
233
|
+
export function scoreStructuredAddress(input: NormalizedInputLite, shape: QueryShapeLike): number {
|
|
234
|
+
const len = input.normalized.length
|
|
235
|
+
|
|
236
|
+
if (len === 0) return 0
|
|
237
|
+
const segCount = shape.segments?.length ?? 1
|
|
238
|
+
|
|
239
|
+
// Multi-segment input with mixed character class = high confidence structured.
|
|
240
|
+
if (segCount >= 2 && shape.characterClass === "alphanumeric") return 0.9
|
|
241
|
+
|
|
242
|
+
// Single-segment but reasonably long and alphanumeric = moderate confidence.
|
|
243
|
+
if (len >= 15 && shape.characterClass === "alphanumeric") return 0.75
|
|
244
|
+
|
|
245
|
+
// Multi-segment but pure-alpha = moderate (could be a multi-word locality).
|
|
246
|
+
if (segCount >= 2) return 0.6
|
|
247
|
+
|
|
248
|
+
// Single-segment, short, alphanumeric (e.g. "10118-1234" with no other content) — weak.
|
|
249
|
+
if (len < 15 && shape.characterClass === "alphanumeric") return 0.4
|
|
250
|
+
|
|
251
|
+
return 0
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* `vague` rule: nothing else fired with high confidence — input is ambiguous.
|
|
256
|
+
*
|
|
257
|
+
* Returns a moderate baseline so `vague` always shows up as an alternative, even when other rules dominate. The
|
|
258
|
+
* coordinator decides whether to trust vague as the primary kind.
|
|
259
|
+
*/
|
|
260
|
+
export function scoreVague(_input: NormalizedInputLite, _shape: QueryShapeLike): number {
|
|
261
|
+
return 0.3
|
|
262
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Re-exports of the canonical types from `@mailwoman/core/pipeline`. */
|
|
8
|
+
export type { LocaleHint, QueryKind, QueryKindResult } from "@mailwoman/core/pipeline"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Minimal `NormalizedInput` shape consumed by `classifyKind`. Compatible with `@mailwoman/normalize`'s output.
|
|
12
|
+
*/
|
|
13
|
+
export interface NormalizedInputLite {
|
|
14
|
+
raw: string
|
|
15
|
+
normalized: string
|
|
16
|
+
appliedLocale?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Minimal `QueryShape` shape consumed by `classifyKind`. Compatible with `@mailwoman/query-shape`'s output.
|
|
21
|
+
*/
|
|
22
|
+
export interface QueryShapeLike {
|
|
23
|
+
knownFormats: ReadonlyArray<{
|
|
24
|
+
format: string
|
|
25
|
+
span: { start: number; end: number }
|
|
26
|
+
confidence: number
|
|
27
|
+
}>
|
|
28
|
+
segments?: ReadonlyArray<{ body: string; index: number }>
|
|
29
|
+
characterClass?: string
|
|
30
|
+
totalLength?: number
|
|
31
|
+
}
|