@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/classify.ts
CHANGED
|
@@ -5,15 +5,20 @@
|
|
|
5
5
|
*
|
|
6
6
|
* `classifyKind` — entry point for Stage 2.5 (kind classification).
|
|
7
7
|
*
|
|
8
|
-
* Composes the per-kind rules from `rules.ts` and picks the winner. Returns
|
|
9
|
-
* confidence so the coordinator can offer fallback paths when the top kind
|
|
8
|
+
* Composes the per-kind rules from `rules.ts` and `intent-rules.ts` and picks the winner. Returns
|
|
9
|
+
* alternatives sorted by confidence so the coordinator can offer fallback paths when the top kind
|
|
10
|
+
* isn't actionable.
|
|
10
11
|
*
|
|
11
12
|
* Per the project's "possibilities not constraints" principle, every kind that fires above 0
|
|
12
13
|
* surfaces in `alternatives` — the caller decides whether to act on the top kind only or consider
|
|
13
|
-
* runner-ups.
|
|
14
|
+
* runner-ups. The ROAD_TO_V9 §4 intent vocabulary leans on that: `bare_toponym` and `route_pair`
|
|
15
|
+
* are scored below their structural incumbent precisely so they land in `alternatives`, where they
|
|
16
|
+
* inform the markers without moving the routing decision.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
|
-
import {
|
|
19
|
+
import { deriveIntentMarkers } from "./intent-markers.ts"
|
|
20
|
+
import { scoreBareToponym, scoreNearMe, scoreRoutePair } from "./intent-rules.ts"
|
|
21
|
+
import { createScorePOICategory, createScorePOIQuery, type POIPhraseLookup } from "./poi.ts"
|
|
17
22
|
import {
|
|
18
23
|
scoreIntersection,
|
|
19
24
|
scoreLandmark,
|
|
@@ -24,7 +29,14 @@ import {
|
|
|
24
29
|
scoreVague,
|
|
25
30
|
scoreVenueLandmark,
|
|
26
31
|
} from "./rules.ts"
|
|
27
|
-
import type {
|
|
32
|
+
import type {
|
|
33
|
+
LocaleHint,
|
|
34
|
+
NormalizedInputLite,
|
|
35
|
+
QueryIntentMarker,
|
|
36
|
+
QueryKind,
|
|
37
|
+
QueryKindResult,
|
|
38
|
+
QueryShapeLike,
|
|
39
|
+
} from "./types.ts"
|
|
28
40
|
|
|
29
41
|
interface KindScorer {
|
|
30
42
|
kind: QueryKind
|
|
@@ -38,30 +50,70 @@ const SCORERS: ReadonlyArray<KindScorer> = [
|
|
|
38
50
|
{ kind: "postcode_only", score: scorePostcodeOnly },
|
|
39
51
|
{ kind: "locality_only", score: scoreLocalityOnly },
|
|
40
52
|
{ kind: "structured_address", score: scoreStructuredAddress },
|
|
53
|
+
// ROAD_TO_V9 §4. Ordinary members of the same list — intent is vocabulary, not a stage. `bare_toponym` and
|
|
54
|
+
// `route_pair` are scored under `locality_only` on purpose (see `intent-rules.ts`), so their position here is
|
|
55
|
+
// cosmetic; the sort below is what decides.
|
|
56
|
+
{ kind: "bare_toponym", score: scoreBareToponym },
|
|
57
|
+
{ kind: "route_pair", score: scoreRoutePair },
|
|
58
|
+
{ kind: "near_me", score: scoreNearMe },
|
|
41
59
|
{ kind: "vague", score: scoreVague },
|
|
42
60
|
]
|
|
43
61
|
|
|
44
62
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
63
|
+
* Rank a scored list and shape it into a verdict. Shared by the lexicon-free and lexicon-wired paths so the two cannot
|
|
64
|
+
* drift in how they break ties or build `alternatives`.
|
|
47
65
|
*/
|
|
48
|
-
|
|
49
|
-
const scored = SCORERS.map((s) => ({ kind: s.kind, confidence: s.score(input, shape) })).filter(
|
|
50
|
-
(s) => s.confidence > 0
|
|
51
|
-
)
|
|
52
|
-
|
|
66
|
+
function rank(scored: Array<{ kind: QueryKind; confidence: number }>): QueryKindResult {
|
|
53
67
|
scored.sort((a, b) => b.confidence - a.confidence)
|
|
54
68
|
|
|
55
69
|
const top = scored[0] ?? { kind: "vague" as QueryKind, confidence: 0.3 }
|
|
56
|
-
const alternatives = scored.slice(1).map((s) => ({ kind: s.kind, confidence: s.confidence }))
|
|
57
70
|
|
|
58
71
|
return {
|
|
59
72
|
kind: top.kind,
|
|
60
73
|
confidence: top.confidence,
|
|
61
|
-
alternatives,
|
|
74
|
+
alternatives: scored.slice(1).map((s) => ({ kind: s.kind, confidence: s.confidence })),
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Every kind whose verdict carries `intentMarkers`. Checked before the marker builder runs so the hot path — a
|
|
80
|
+
* structured address, where none of these fire — pays one set membership test per kind and nothing else.
|
|
81
|
+
*/
|
|
82
|
+
const MARKER_BEARING_KINDS: ReadonlySet<QueryKind> = new Set<QueryKind>(["route_pair", "near_me", "poi_category"])
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Attach markers to a verdict, or return it untouched. Separate from {@link rank} because the lexicon-wired path needs
|
|
86
|
+
* to merge `poi_query`/`poi_category` in first.
|
|
87
|
+
*/
|
|
88
|
+
function withIntentMarkers(
|
|
89
|
+
verdict: QueryKindResult,
|
|
90
|
+
input: NormalizedInputLite,
|
|
91
|
+
poiLexicon?: POIPhraseLookup,
|
|
92
|
+
locale?: string
|
|
93
|
+
): QueryKindResult {
|
|
94
|
+
const kinds = [{ kind: verdict.kind, confidence: verdict.confidence }, ...verdict.alternatives]
|
|
95
|
+
|
|
96
|
+
if (!kinds.some((k) => MARKER_BEARING_KINDS.has(k.kind))) return verdict
|
|
97
|
+
|
|
98
|
+
const intentMarkers: QueryIntentMarker[] = deriveIntentMarkers(kinds, { input, poiLexicon, locale })
|
|
99
|
+
|
|
100
|
+
if (!intentMarkers.length) return verdict
|
|
101
|
+
|
|
102
|
+
return { ...verdict, intentMarkers }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Classify the query shape into a `QueryKind`. Synchronous + pure — produces the same result for the same `(input,
|
|
107
|
+
* shape)` pair.
|
|
108
|
+
*/
|
|
109
|
+
export function classifyKindSync(input: NormalizedInputLite, shape: QueryShapeLike): QueryKindResult {
|
|
110
|
+
const scored = SCORERS.map((s) => ({ kind: s.kind, confidence: s.score(input, shape) })).filter(
|
|
111
|
+
(s) => s.confidence > 0
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return withIntentMarkers(rank(scored), input)
|
|
115
|
+
}
|
|
116
|
+
|
|
65
117
|
/**
|
|
66
118
|
* Async variant matching the runtime-pipeline's `classifyKind` contract.
|
|
67
119
|
*
|
|
@@ -80,16 +132,16 @@ export async function classifyKind(
|
|
|
80
132
|
*/
|
|
81
133
|
export interface KindClassifierOpts {
|
|
82
134
|
/**
|
|
83
|
-
* POI phrase lexicon (spec §3.1). When present,
|
|
84
|
-
* so this package stays dictionary-free. Absent → the returned classifier is behaviorally identical
|
|
85
|
-
* {@link classifyKind}.
|
|
135
|
+
* POI phrase lexicon (spec §3.1). When present, `poi_query` and `poi_category` scorers join the rule set — injected,
|
|
136
|
+
* never imported, so this package stays dictionary-free. Absent → the returned classifier is behaviorally identical
|
|
137
|
+
* to {@link classifyKind}.
|
|
86
138
|
*/
|
|
87
139
|
poiLexicon?: POIPhraseLookup
|
|
88
140
|
}
|
|
89
141
|
|
|
90
142
|
/**
|
|
91
143
|
* Build a kind classifier. Without opts this is exactly the default {@link classifyKind}; with a `poiLexicon` it
|
|
92
|
-
* additionally scores `poi_query` and merges
|
|
144
|
+
* additionally scores `poi_query` + `poi_category` (ROAD_TO_V9 §4.4) and merges them into the ranked result.
|
|
93
145
|
*/
|
|
94
146
|
export function createKindClassifier(
|
|
95
147
|
opts: KindClassifierOpts = {}
|
|
@@ -99,25 +151,29 @@ export function createKindClassifier(
|
|
|
99
151
|
if (!poiLexicon) return classifyKind
|
|
100
152
|
|
|
101
153
|
return async (input, shape, locale): Promise<QueryKindResult> => {
|
|
154
|
+
const localeTag = locale?.locale
|
|
102
155
|
const base = classifyKindSync(input, shape)
|
|
103
|
-
const poiConfidence = createScorePOIQuery(poiLexicon,
|
|
156
|
+
const poiConfidence = createScorePOIQuery(poiLexicon, localeTag)(input, shape)
|
|
157
|
+
const categoryConfidence = createScorePOICategory(poiLexicon, localeTag)(input, shape)
|
|
104
158
|
|
|
105
|
-
if (poiConfidence <= 0) return base
|
|
159
|
+
if (poiConfidence <= 0 && categoryConfidence <= 0) return base
|
|
106
160
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
161
|
+
// Re-rank over the union rather than special-casing "did POI beat the base?". The base verdict's own
|
|
162
|
+
// alternatives are preserved, which is what keeps `bare_toponym` / `route_pair` visible to the marker builder
|
|
163
|
+
// even when a POI kind takes the top slot.
|
|
164
|
+
const merged: Array<{ kind: QueryKind; confidence: number }> = [
|
|
165
|
+
{ kind: base.kind, confidence: base.confidence },
|
|
166
|
+
...base.alternatives,
|
|
167
|
+
]
|
|
114
168
|
|
|
115
|
-
|
|
116
|
-
|
|
169
|
+
if (poiConfidence > 0) {
|
|
170
|
+
merged.push({ kind: "poi_query", confidence: poiConfidence })
|
|
171
|
+
}
|
|
117
172
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
alternatives: [...base.alternatives, poiAlternative].toSorted((a, b) => b.confidence - a.confidence),
|
|
173
|
+
if (categoryConfidence > 0) {
|
|
174
|
+
merged.push({ kind: "poi_category", confidence: categoryConfidence })
|
|
121
175
|
}
|
|
176
|
+
|
|
177
|
+
return withIntentMarkers(rank(merged), input, poiLexicon, localeTag)
|
|
122
178
|
}
|
|
123
179
|
}
|
package/index.ts
CHANGED
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
export { classifyKind, classifyKindSync, createKindClassifier } from "./classify.ts"
|
|
17
17
|
export type { KindClassifierOpts } from "./classify.ts"
|
|
18
|
-
export {
|
|
19
|
-
export type {
|
|
18
|
+
export { deriveIntentMarkers } from "./intent-markers.ts"
|
|
19
|
+
export type { IntentMarkerContext } from "./intent-markers.ts"
|
|
20
|
+
export { nearMeSubject, scoreBareToponym, scoreNearMe, scoreRoutePair } from "./intent-rules.ts"
|
|
21
|
+
export { matchPOICategory, matchPOISubject } from "./poi.ts"
|
|
22
|
+
export type { POIPhraseMatch, POIPhraseLookup, POIQuerySpan, POISpatialRelation, POISubjectMatch } from "./poi.ts"
|
|
20
23
|
|
|
21
24
|
export {
|
|
22
25
|
scoreIntersection,
|
|
@@ -26,6 +29,16 @@ export {
|
|
|
26
29
|
scorePostcodeOnly,
|
|
27
30
|
scoreStructuredAddress,
|
|
28
31
|
scoreVague,
|
|
32
|
+
scoreVenueLandmark,
|
|
29
33
|
} from "./rules.ts"
|
|
30
34
|
|
|
31
|
-
export
|
|
35
|
+
export { QueryIntentCode } from "./types.ts"
|
|
36
|
+
|
|
37
|
+
export type {
|
|
38
|
+
LocaleHint,
|
|
39
|
+
NormalizedInputLite,
|
|
40
|
+
QueryIntentMarker,
|
|
41
|
+
QueryKind,
|
|
42
|
+
QueryKindResult,
|
|
43
|
+
QueryShapeLike,
|
|
44
|
+
} from "./types.ts"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Marker derivation for the ROAD_TO_V9 §4 intent vocabulary. Pure, synchronous, and the ONLY place
|
|
7
|
+
* the classifier turns a fired rule into something a caller reads.
|
|
8
|
+
*
|
|
9
|
+
* Three of the four intent kinds can raise their marker here, from the string alone. The fourth —
|
|
10
|
+
* `bare_toponym`'s `declared_ambiguity` — cannot: its trigger is the dominance margin of the
|
|
11
|
+
* RESOLVED candidate list, which does not exist yet at Stage 2.5. That one is raised by
|
|
12
|
+
* `mailwoman/query-intent.ts` after the resolve, against the measured 0.5-log10 cut. The split is
|
|
13
|
+
* deliberate and it is why this module never emits `declared_ambiguity`: a marker that asserted
|
|
14
|
+
* ambiguity from the string alone would be declaring that every bare city name is ambiguous, which
|
|
15
|
+
* is false 89.1% of the time (the measured table behind `DECISIVE_MARGIN_LOG10`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { nearMeSubject } from "./intent-rules.ts"
|
|
19
|
+
import { matchPOICategory, type POIPhraseLookup } from "./poi.ts"
|
|
20
|
+
import type { NormalizedInputLite, QueryIntentMarker, QueryKind } from "./types.ts"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Context the marker builder needs beyond the scored kinds.
|
|
24
|
+
*/
|
|
25
|
+
export interface IntentMarkerContext {
|
|
26
|
+
input: NormalizedInputLite
|
|
27
|
+
/**
|
|
28
|
+
* The injected POI lexicon, when one was wired. Absent → no `poi_category` marker can be built, which is consistent
|
|
29
|
+
* because the kind cannot fire without it either.
|
|
30
|
+
*/
|
|
31
|
+
poiLexicon?: POIPhraseLookup
|
|
32
|
+
locale?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the advisories for one classified query.
|
|
37
|
+
*
|
|
38
|
+
* `kinds` is the FULL verdict — top plus alternatives — because two of the four intent kinds live in `alternatives` by
|
|
39
|
+
* design (see `intent-rules.ts`). Reading only the top kind would make them invisible, which is the mistake this
|
|
40
|
+
* signature exists to prevent.
|
|
41
|
+
*
|
|
42
|
+
* Returns `[]` when no intent kind fired. Callers surface that empty array rather than dropping the field: an empty
|
|
43
|
+
* array is the classifier stating it looked.
|
|
44
|
+
*/
|
|
45
|
+
export function deriveIntentMarkers(
|
|
46
|
+
kinds: ReadonlyArray<{ kind: QueryKind; confidence: number }>,
|
|
47
|
+
ctx: IntentMarkerContext
|
|
48
|
+
): QueryIntentMarker[] {
|
|
49
|
+
const fired = new Set<QueryKind>(kinds.map((k) => k.kind))
|
|
50
|
+
const markers: QueryIntentMarker[] = []
|
|
51
|
+
|
|
52
|
+
if (fired.has("route_pair")) {
|
|
53
|
+
const tokens = ctx.input.normalized.trim().split(/\s+/)
|
|
54
|
+
|
|
55
|
+
markers.push({
|
|
56
|
+
kind: "route_pair",
|
|
57
|
+
code: "declared_fork",
|
|
58
|
+
mechanism: "kind:route_pair",
|
|
59
|
+
message: `"${tokens.join(" ")}" reads two ways and the pipeline is not choosing between them: two distinct places, or one place with its admin context.`,
|
|
60
|
+
evidence: {
|
|
61
|
+
tokens,
|
|
62
|
+
/**
|
|
63
|
+
* Both readings, named. The order is stable (pair first, then the admin reading) so a consumer can index it; it
|
|
64
|
+
* is NOT a ranking, and nothing downstream reads it as one.
|
|
65
|
+
*/
|
|
66
|
+
interpretations: ["two_toponyms", "locality_with_admin_context"],
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (fired.has("near_me")) {
|
|
72
|
+
const subject = nearMeSubject(ctx.input)
|
|
73
|
+
|
|
74
|
+
markers.push({
|
|
75
|
+
kind: "near_me",
|
|
76
|
+
code: "focus_point_required",
|
|
77
|
+
mechanism: "kind:near_me",
|
|
78
|
+
message: `"${subject}" was asked for relative to the asker, and no focus point was supplied.`,
|
|
79
|
+
evidence: {
|
|
80
|
+
subject,
|
|
81
|
+
/**
|
|
82
|
+
* The SEAM, named but not wired (ROAD_TO_V9 §4.4 scopes v9 to classification). Photon's `/api` already accepts
|
|
83
|
+
* `lat`/`lon` location-bias params — `photon/` is the eventual consumer of this marker, and this string is the
|
|
84
|
+
* note that says where it plugs in.
|
|
85
|
+
*/
|
|
86
|
+
focusParameter: "photon:lat/lon",
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (fired.has("poi_category") && ctx.poiLexicon) {
|
|
92
|
+
const match = matchPOICategory(ctx.input.normalized, ctx.locale ?? ctx.input.appliedLocale, ctx.poiLexicon)
|
|
93
|
+
|
|
94
|
+
if (match) {
|
|
95
|
+
markers.push({
|
|
96
|
+
kind: "poi_category",
|
|
97
|
+
code: "poi_category",
|
|
98
|
+
mechanism: "poi-taxonomy:synonym",
|
|
99
|
+
message: `"${match.matchedPhrase}" is a POI category with no place to search; resolution against poi.db is out of scope.`,
|
|
100
|
+
evidence: {
|
|
101
|
+
categoryID: match.categoryID,
|
|
102
|
+
matchedPhrase: match.matchedPhrase,
|
|
103
|
+
...(match.wikidata ? { wikidata: match.wikidata } : {}),
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return markers
|
|
110
|
+
}
|
package/intent-rules.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* ROAD_TO_V9 §4 — the query-INTENT rules. Same contract as `rules.ts` (a `(input, shape) => number`
|
|
7
|
+
* in [0, 1], 0 when the rule does not fire) and the same bitter-lesson invariant: universal
|
|
8
|
+
* structural patterns and bounded linguistic categories only, never a place-name dictionary. The
|
|
9
|
+
* one lexicon these kinds consult — the POI synonym table — is INJECTED, exactly as `poi.ts`
|
|
10
|
+
* already does it.
|
|
11
|
+
*
|
|
12
|
+
* ## Why two of these three deliberately lose
|
|
13
|
+
*
|
|
14
|
+
* `bare_toponym` and `route_pair` are scored BELOW the structural kind that already owns their
|
|
15
|
+
* population (`locality_only`, 0.85). They therefore surface in `QueryKindResult.alternatives` and
|
|
16
|
+
* never as the top kind. That is not timidity — it is the D-rule discharge. The top kind is the
|
|
17
|
+
* only thing the coordinator routes on (`deriveInputMode`, `canShortCircuit`, the POI branch), so
|
|
18
|
+
* pinning it is what makes these additions provably answer-neutral on the bare-city-name register,
|
|
19
|
+
* which is the single largest population in map search. The intent they carry travels on the
|
|
20
|
+
* marker instead, where it is advisory by construction.
|
|
21
|
+
*
|
|
22
|
+
* `near_me` DOES win its top slot (0.91), because there is no incumbent worth preserving: a query
|
|
23
|
+
* ending "near me" is not a locality and answering it as one is the bug.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { MAX_LOCALITY_ONLY_LENGTH, STREET_SUFFIXES } from "./rules.ts"
|
|
27
|
+
import type { NormalizedInputLite, QueryShapeLike } from "./types.ts"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `locality_only` scores 0.85. Both refinement kinds sit under it by a whole confidence step so no float-comparison
|
|
31
|
+
* accident can flip the top slot, and so the gap reads as deliberate to the next person.
|
|
32
|
+
*/
|
|
33
|
+
const BARE_TOPONYM_CONFIDENCE = 0.84
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Lower still, and for a second reason on top of the ranking discipline: a route pair is a HYPOTHESIS about a query
|
|
37
|
+
* whose competing reading (locality + region) is more common in this corpus. The number states that.
|
|
38
|
+
*/
|
|
39
|
+
const ROUTE_PAIR_CONFIDENCE = 0.55
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Above `landmark`'s venue ceiling (0.88) and above `poi_query`'s anchored band (0.90), because a deictic tail is a
|
|
43
|
+
* stronger signal than either shape heuristic: nothing else in the vocabulary explains why "me" is at the end of the
|
|
44
|
+
* string.
|
|
45
|
+
*/
|
|
46
|
+
const NEAR_ME_CONFIDENCE = 0.91
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Word ceiling for a single bare toponym. Four covers the long tail that actually exists as one place name ("Newcastle
|
|
50
|
+
* upon Tyne", "Sault Sainte Marie", "Las Palmas de Gran Canaria"); past it the input is carrying more than a name.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_BARE_TOPONYM_WORDS = 4
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Toponymic HEAD particles — the bounded linguistic category that makes a multi-token string ONE place name.
|
|
56
|
+
*
|
|
57
|
+
* Same justification, and the same boundary, as `@mailwoman/phrase-grouper`'s `PLACE_NAME_PARTICLES` (which covers the
|
|
58
|
+
* INFIX glue: `de`, `am`, `aan den`). This set covers the PREFIX heads, and it exists for exactly one job: keeping
|
|
59
|
+
* `route_pair` off "New York", "San Francisco", "Fort Worth" and their kin. It is a closed morphological class, not a
|
|
60
|
+
* gazetteer — growing it with actual place names is the wrong move, and the pressure for that belongs on the resolver.
|
|
61
|
+
*
|
|
62
|
+
* Case-folded on read, because lowercase is the primary user register and "new york" is the same query.
|
|
63
|
+
*/
|
|
64
|
+
const TOPONYM_HEAD_PARTICLES: ReadonlySet<string> = new Set([
|
|
65
|
+
// English
|
|
66
|
+
"new",
|
|
67
|
+
"old",
|
|
68
|
+
"fort",
|
|
69
|
+
"ft",
|
|
70
|
+
"port",
|
|
71
|
+
"lake",
|
|
72
|
+
"mount",
|
|
73
|
+
"mt",
|
|
74
|
+
"north",
|
|
75
|
+
"south",
|
|
76
|
+
"east",
|
|
77
|
+
"west",
|
|
78
|
+
"upper",
|
|
79
|
+
"lower",
|
|
80
|
+
"great",
|
|
81
|
+
"little",
|
|
82
|
+
"saint",
|
|
83
|
+
"st",
|
|
84
|
+
"st.",
|
|
85
|
+
// Romance
|
|
86
|
+
"san",
|
|
87
|
+
"santa",
|
|
88
|
+
"santo",
|
|
89
|
+
"são",
|
|
90
|
+
"sao",
|
|
91
|
+
"los",
|
|
92
|
+
"las",
|
|
93
|
+
"el",
|
|
94
|
+
"la",
|
|
95
|
+
"le",
|
|
96
|
+
"les",
|
|
97
|
+
"villa",
|
|
98
|
+
"rio",
|
|
99
|
+
"nueva",
|
|
100
|
+
"nuevo",
|
|
101
|
+
"puerto",
|
|
102
|
+
"ciudad",
|
|
103
|
+
"campo",
|
|
104
|
+
"monte",
|
|
105
|
+
"castel",
|
|
106
|
+
"borgo",
|
|
107
|
+
// Germanic / Nordic
|
|
108
|
+
"bad",
|
|
109
|
+
"sankt",
|
|
110
|
+
"neu",
|
|
111
|
+
"alt",
|
|
112
|
+
"groß",
|
|
113
|
+
"gross",
|
|
114
|
+
"klein",
|
|
115
|
+
"ober",
|
|
116
|
+
"unter",
|
|
117
|
+
"nieuw",
|
|
118
|
+
"oud",
|
|
119
|
+
"ny",
|
|
120
|
+
"stor",
|
|
121
|
+
"lille",
|
|
122
|
+
"sint",
|
|
123
|
+
// Definite article as a head — "The Valley" (Anguilla), "The Hague", "The Bottom".
|
|
124
|
+
"the",
|
|
125
|
+
// Generic toponymic heads outside the Latin/Germanic families, added because the 306-case corpus MEASURED them
|
|
126
|
+
// (see `mailwoman/test/kind-intent-invariance.test.ts`): each is a common noun in its own language — Semitic "tel"
|
|
127
|
+
// (mound), Malay "kuala" (confluence), Khmer "phnom" (hill) — that heads a place name the way "mount" does.
|
|
128
|
+
"tel",
|
|
129
|
+
"kuala",
|
|
130
|
+
"phnom",
|
|
131
|
+
"cape",
|
|
132
|
+
"isle",
|
|
133
|
+
"isla",
|
|
134
|
+
"ilha",
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Generic toponymic TAIL nouns — the other half of the same bounded morphological class. "Belize City", "George Town",
|
|
139
|
+
* "Cape Town", "Palm Springs": a place name whose last token is a settlement/landform generic is ONE name, not two.
|
|
140
|
+
*
|
|
141
|
+
* Measured additions, same as the heads above: `city`, `town` and `valley` each came off a real corpus row that was
|
|
142
|
+
* forking wrongly.
|
|
143
|
+
*/
|
|
144
|
+
const TOPONYM_TAIL_NOUNS: ReadonlySet<string> = new Set([
|
|
145
|
+
"city",
|
|
146
|
+
"town",
|
|
147
|
+
"ville",
|
|
148
|
+
"village",
|
|
149
|
+
"borough",
|
|
150
|
+
"springs",
|
|
151
|
+
"falls",
|
|
152
|
+
"beach",
|
|
153
|
+
"heights",
|
|
154
|
+
"valley",
|
|
155
|
+
"island",
|
|
156
|
+
"islands",
|
|
157
|
+
"bay",
|
|
158
|
+
"harbour",
|
|
159
|
+
"harbor",
|
|
160
|
+
"park",
|
|
161
|
+
"hills",
|
|
162
|
+
"river",
|
|
163
|
+
"creek",
|
|
164
|
+
"point",
|
|
165
|
+
"stadt",
|
|
166
|
+
"burg",
|
|
167
|
+
])
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Deictic locator tails — "near me", "nearby", "around here", "in my area".
|
|
171
|
+
*
|
|
172
|
+
* The class is `preposition + a reference to the ASKER`, which is why it is bounded and why it is safe: `me`, `here`,
|
|
173
|
+
* `my <noun>` are function words, not places. Anchored to the END of the string (`$`) on purpose — the whole point of
|
|
174
|
+
* the kind is that the query names no anchor, so anything AFTER the locator is an anchor and disqualifies it.
|
|
175
|
+
*
|
|
176
|
+
* Linear by construction: every alternative begins with a required literal, and the only quantifiers are bounded `\s+`
|
|
177
|
+
* runs BETWEEN two required literals or trailing before `$`. No unbounded-whitespace-then-literal prefix, which is the
|
|
178
|
+
* `js/polynomial-redos` shape (see the `ANCHOR_SEPARATOR` docstring in `poi.ts` for the same analysis).
|
|
179
|
+
*/
|
|
180
|
+
const DEICTIC_LOCATOR_TAIL =
|
|
181
|
+
/\b(?:near|close\s+to|next\s+to|around|by|closest\s+to|nearest\s+to)\s+(?:me|us|here|my\s+(?:location|position|area|place|house|home))\s*$/
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The adverbial half of the same class — no preposition, the deixis is baked into the word.
|
|
185
|
+
*/
|
|
186
|
+
const DEICTIC_ADVERB_TAIL =
|
|
187
|
+
/\b(?:nearby|near\s?by|close\s+by|around\s+here|in\s+my\s+(?:area|neighborhood|neighbourhood))\s*$/
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Split on whitespace + commas, the way the other rules in this package do.
|
|
191
|
+
*/
|
|
192
|
+
function wordsOf(text: string): string[] {
|
|
193
|
+
return text.split(/[\s,]+/).filter(Boolean)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* True when the input carries a deictic locator tail in EITHER form.
|
|
198
|
+
*/
|
|
199
|
+
function hasDeicticTail(lowercased: string): boolean {
|
|
200
|
+
return DEICTIC_LOCATOR_TAIL.test(lowercased) || DEICTIC_ADVERB_TAIL.test(lowercased)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The gates `bare_toponym` and `route_pair` share: no address grammar of any kind, one segment, alpha throughout.
|
|
205
|
+
*
|
|
206
|
+
* Returns the word list when the input clears them, `null` when it does not. Deliberately a SUPERSET of
|
|
207
|
+
* `scoreLocalityOnly`'s gates (which admits two segments), so `bare_toponym` is a strict refinement of `locality_only`
|
|
208
|
+
* and can never fire where `locality_only` did not — the property `intent-rules.test.ts` asserts and the reason the
|
|
209
|
+
* ranking discipline above is enough to keep the top kind pinned.
|
|
210
|
+
*/
|
|
211
|
+
function bareNameWords(input: NormalizedInputLite, shape: QueryShapeLike): string[] | null {
|
|
212
|
+
const text = input.normalized.trim()
|
|
213
|
+
|
|
214
|
+
if (!text || text.length > MAX_LOCALITY_ONLY_LENGTH) return null
|
|
215
|
+
|
|
216
|
+
// A recognized postcode/known format IS address grammar. Nothing bare survives this.
|
|
217
|
+
if (shape.knownFormats.length) return null
|
|
218
|
+
|
|
219
|
+
// `alpha` excludes every house number and every postcode by construction — the cheapest available statement of
|
|
220
|
+
// "no address grammar", and it costs no lexicon.
|
|
221
|
+
if (shape.characterClass !== "alpha") return null
|
|
222
|
+
|
|
223
|
+
// A comma is the admin-context marker ("Paris, FR"). One segment, or the name is not bare.
|
|
224
|
+
if ((shape.segments?.length ?? 1) !== 1) return null
|
|
225
|
+
|
|
226
|
+
const lowercased = text.toLowerCase()
|
|
227
|
+
|
|
228
|
+
if (hasDeicticTail(lowercased)) return null
|
|
229
|
+
|
|
230
|
+
const words = wordsOf(text)
|
|
231
|
+
|
|
232
|
+
if (!words.length || words.length > MAX_BARE_TOPONYM_WORDS) return null
|
|
233
|
+
|
|
234
|
+
for (const word of words) {
|
|
235
|
+
if (STREET_SUFFIXES.has(word.toLowerCase())) return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return words
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `bare_toponym` rule: a single coherent place-name carrying no address grammar.
|
|
243
|
+
*
|
|
244
|
+
* Feeds the declared-ambiguity path. The rule itself asserts nothing about WHICH place — that is the resolver's
|
|
245
|
+
* question, and `mailwoman/query-intent.ts` is where the answer's dominance margin decides whether the ambiguity gets
|
|
246
|
+
* declared.
|
|
247
|
+
*/
|
|
248
|
+
export function scoreBareToponym(input: NormalizedInputLite, shape: QueryShapeLike): number {
|
|
249
|
+
return bareNameWords(input, shape) ? BARE_TOPONYM_CONFIDENCE : 0
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `route_pair` rule: exactly two toponym-shaped tokens with nothing between them.
|
|
254
|
+
*
|
|
255
|
+
* **The known confound is structural and unfixable here.** "Paris London" and "Moscow Idaho" are the same string shape
|
|
256
|
+
* — two bare capitalized words — and separating them needs to know that Idaho is a region, which is a gazetteer fact,
|
|
257
|
+
* not a structural one. The hard-slice board's 18 `comma_free` rows are that population, and they fire this rule. That
|
|
258
|
+
* is the reason ROAD_TO_V9 §4.3 specifies **classification + a declared fork, never a router**: both readings are named
|
|
259
|
+
* in the marker, neither wins, and the resolver keeps answering exactly as it did.
|
|
260
|
+
*
|
|
261
|
+
* The one class that IS separable structurally is the two-token SINGLE name — "New York", "Fort Worth", "San Francisco"
|
|
262
|
+
* — because those carry a toponymic head particle. That guard is what keeps the fork off the common case.
|
|
263
|
+
*/
|
|
264
|
+
export function scoreRoutePair(input: NormalizedInputLite, shape: QueryShapeLike): number {
|
|
265
|
+
const words = bareNameWords(input, shape)
|
|
266
|
+
|
|
267
|
+
if (!words || words.length !== 2) return 0
|
|
268
|
+
|
|
269
|
+
const [first, second] = [words[0]!.toLowerCase(), words[1]!.toLowerCase()]
|
|
270
|
+
|
|
271
|
+
// Reduplication — "Pago Pago", "Baden-Baden", "Walla Walla", "Bora Bora". Nobody travels from a place to itself,
|
|
272
|
+
// so a repeated token is a universal single-name signal and needs no lexicon at all.
|
|
273
|
+
if (first === second) return 0
|
|
274
|
+
|
|
275
|
+
if (TOPONYM_HEAD_PARTICLES.has(first) || TOPONYM_HEAD_PARTICLES.has(second)) return 0
|
|
276
|
+
|
|
277
|
+
if (TOPONYM_TAIL_NOUNS.has(second)) return 0
|
|
278
|
+
|
|
279
|
+
return ROUTE_PAIR_CONFIDENCE
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* `near_me` rule: a subject plus a deictic locator, with no anchor.
|
|
284
|
+
*
|
|
285
|
+
* Requires a non-empty subject before the locator, so a bare "near me" stays with the `landmark` leaders rule rather
|
|
286
|
+
* than claiming to be a category search with a missing focus point.
|
|
287
|
+
*/
|
|
288
|
+
export function scoreNearMe(input: NormalizedInputLite, _shape: QueryShapeLike): number {
|
|
289
|
+
const lowercased = input.normalized.trim().toLowerCase()
|
|
290
|
+
|
|
291
|
+
if (!hasDeicticTail(lowercased)) return 0
|
|
292
|
+
|
|
293
|
+
// The subject is everything before the locator. `hasDeicticTail` already anchored the match to the end, so the
|
|
294
|
+
// first match index is where the subject stops.
|
|
295
|
+
const match = DEICTIC_LOCATOR_TAIL.exec(lowercased) ?? DEICTIC_ADVERB_TAIL.exec(lowercased)
|
|
296
|
+
|
|
297
|
+
if (!match) return 0
|
|
298
|
+
|
|
299
|
+
return lowercased.slice(0, match.index).trim() ? NEAR_ME_CONFIDENCE : 0
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The subject of a `near_me` query — the category or thing the asker wants, with the locator stripped. Empty string
|
|
304
|
+
* when the rule would not have fired. Used to build the marker's evidence, never to route.
|
|
305
|
+
*/
|
|
306
|
+
export function nearMeSubject(input: NormalizedInputLite): string {
|
|
307
|
+
const trimmed = input.normalized.trim()
|
|
308
|
+
const lowercased = trimmed.toLowerCase()
|
|
309
|
+
const match = DEICTIC_LOCATOR_TAIL.exec(lowercased) ?? DEICTIC_ADVERB_TAIL.exec(lowercased)
|
|
310
|
+
|
|
311
|
+
if (!match) return ""
|
|
312
|
+
|
|
313
|
+
return trimmed.slice(0, match.index).trim()
|
|
314
|
+
}
|
package/out/classify.d.ts
CHANGED
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
*
|
|
6
6
|
* `classifyKind` — entry point for Stage 2.5 (kind classification).
|
|
7
7
|
*
|
|
8
|
-
* Composes the per-kind rules from `rules.ts` and picks the winner. Returns
|
|
9
|
-
* confidence so the coordinator can offer fallback paths when the top kind
|
|
8
|
+
* Composes the per-kind rules from `rules.ts` and `intent-rules.ts` and picks the winner. Returns
|
|
9
|
+
* alternatives sorted by confidence so the coordinator can offer fallback paths when the top kind
|
|
10
|
+
* isn't actionable.
|
|
10
11
|
*
|
|
11
12
|
* Per the project's "possibilities not constraints" principle, every kind that fires above 0
|
|
12
13
|
* surfaces in `alternatives` — the caller decides whether to act on the top kind only or consider
|
|
13
|
-
* runner-ups.
|
|
14
|
+
* runner-ups. The ROAD_TO_V9 §4 intent vocabulary leans on that: `bare_toponym` and `route_pair`
|
|
15
|
+
* are scored below their structural incumbent precisely so they land in `alternatives`, where they
|
|
16
|
+
* inform the markers without moving the routing decision.
|
|
14
17
|
*/
|
|
15
18
|
import { type POIPhraseLookup } from "./poi.ts";
|
|
16
19
|
import type { LocaleHint, NormalizedInputLite, QueryKindResult, QueryShapeLike } from "./types.ts";
|
|
@@ -30,15 +33,15 @@ export declare function classifyKind(input: NormalizedInputLite, shape: QuerySha
|
|
|
30
33
|
*/
|
|
31
34
|
export interface KindClassifierOpts {
|
|
32
35
|
/**
|
|
33
|
-
* POI phrase lexicon (spec §3.1). When present,
|
|
34
|
-
* so this package stays dictionary-free. Absent → the returned classifier is behaviorally identical
|
|
35
|
-
* {@link classifyKind}.
|
|
36
|
+
* POI phrase lexicon (spec §3.1). When present, `poi_query` and `poi_category` scorers join the rule set — injected,
|
|
37
|
+
* never imported, so this package stays dictionary-free. Absent → the returned classifier is behaviorally identical
|
|
38
|
+
* to {@link classifyKind}.
|
|
36
39
|
*/
|
|
37
40
|
poiLexicon?: POIPhraseLookup;
|
|
38
41
|
}
|
|
39
42
|
/**
|
|
40
43
|
* Build a kind classifier. Without opts this is exactly the default {@link classifyKind}; with a `poiLexicon` it
|
|
41
|
-
* additionally scores `poi_query` and merges
|
|
44
|
+
* additionally scores `poi_query` + `poi_category` (ROAD_TO_V9 §4.4) and merges them into the ranked result.
|
|
42
45
|
*/
|
|
43
46
|
export declare function createKindClassifier(opts?: KindClassifierOpts): (input: NormalizedInputLite, shape: QueryShapeLike, locale?: LocaleHint) => Promise<QueryKindResult>;
|
|
44
47
|
//# sourceMappingURL=classify.d.ts.map
|