@mailwoman/phrase-grouper 7.2.0 → 7.3.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/group.ts +114 -0
- package/index.ts +43 -0
- package/package.json +21 -7
- package/rules.ts +815 -0
- package/types.ts +44 -0
package/group.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `groupPhrases` — Stage 2.7 entry point.
|
|
7
|
+
*
|
|
8
|
+
* Composes per-kind rules over the normalized input + QueryShape and emits one `PhraseProposal` per
|
|
9
|
+
* fired rule. Overlapping proposals are expected — the reconciler (Stage 5) picks the best
|
|
10
|
+
* non-overlapping subset.
|
|
11
|
+
*
|
|
12
|
+
* See `docs/articles/concepts/the-knowledge-ladder.md` § Phrase grouper for the design rationale,
|
|
13
|
+
* and `phrase-grouper/rules.ts` for per-rule documentation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
scoreHyphenatedCompound,
|
|
18
|
+
scoreLocalityPhrase,
|
|
19
|
+
scoreNumeric,
|
|
20
|
+
scorePostcode,
|
|
21
|
+
scoreRegionAbbreviation,
|
|
22
|
+
scoreStreetPhrase,
|
|
23
|
+
scoreVenuePhrase,
|
|
24
|
+
tokenizeSegment,
|
|
25
|
+
type SegmentToken,
|
|
26
|
+
} from "./rules.ts"
|
|
27
|
+
import type { GroupPhrasesOpts, LocaleHint, NormalizedInputLite, PhraseProposal, QueryShapeLike } from "./types.ts"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Walk every QueryShape segment and emit one `tokens-by-segment` list. Falls back to treating the whole input as a
|
|
31
|
+
* single segment when QueryShape didn't supply segmentation (e.g. callers wiring the grouper into a path that bypasses
|
|
32
|
+
* QueryShape).
|
|
33
|
+
*/
|
|
34
|
+
function tokensPerSegment(
|
|
35
|
+
text: string,
|
|
36
|
+
shape: QueryShapeLike
|
|
37
|
+
): Array<{ tokens: SegmentToken[]; isFirst: boolean; isLast: boolean }> {
|
|
38
|
+
const segs = shape.segments
|
|
39
|
+
|
|
40
|
+
if (segs && segs.length > 0) {
|
|
41
|
+
return segs.map((s, idx) => {
|
|
42
|
+
const start = s.span?.start ?? 0
|
|
43
|
+
const end = s.span?.end ?? text.length
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
tokens: tokenizeSegment(text.slice(start, end), start),
|
|
47
|
+
isFirst: idx === 0,
|
|
48
|
+
isLast: idx === segs.length - 1,
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return [{ tokens: tokenizeSegment(text, 0), isFirst: true, isLast: true }]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Synchronous, pure rule-based implementation. The async wrapper matches the pipeline contract.
|
|
58
|
+
*
|
|
59
|
+
* Emits overlapping proposals freely — the consumer (Stage 5 reconcile) picks the best non-overlapping subset under
|
|
60
|
+
* semantic+hierarchical constraints. Confidence is a [0,1] score per proposal; relative ordering is what matters more
|
|
61
|
+
* than absolute calibration at v0.5.0.
|
|
62
|
+
*
|
|
63
|
+
* The `_locale` parameter is reserved for future locale-aware rule packs (Japanese postcode/honorific patterns, French
|
|
64
|
+
* preposition-bound localities) — currently unused.
|
|
65
|
+
*/
|
|
66
|
+
export function groupPhrasesSync(
|
|
67
|
+
input: NormalizedInputLite,
|
|
68
|
+
shape: QueryShapeLike,
|
|
69
|
+
_locale?: LocaleHint,
|
|
70
|
+
_opts: GroupPhrasesOpts = {}
|
|
71
|
+
): PhraseProposal[] {
|
|
72
|
+
const text = input.normalized
|
|
73
|
+
|
|
74
|
+
if (text.length === 0) return []
|
|
75
|
+
|
|
76
|
+
const proposals: PhraseProposal[] = []
|
|
77
|
+
|
|
78
|
+
// Postcode rule consumes QueryShape directly (segment-agnostic).
|
|
79
|
+
proposals.push(...scorePostcode(shape, text))
|
|
80
|
+
|
|
81
|
+
// Per-segment rules.
|
|
82
|
+
for (const { tokens, isFirst, isLast } of tokensPerSegment(text, shape)) {
|
|
83
|
+
if (tokens.length === 0) continue
|
|
84
|
+
proposals.push(...scoreNumeric(tokens, text))
|
|
85
|
+
proposals.push(...scoreRegionAbbreviation(tokens, text, isLast))
|
|
86
|
+
proposals.push(...scoreHyphenatedCompound(tokens, text))
|
|
87
|
+
proposals.push(...scoreStreetPhrase(tokens, text))
|
|
88
|
+
proposals.push(...scoreLocalityPhrase(tokens, text, isLast))
|
|
89
|
+
proposals.push(...scoreVenuePhrase(tokens, text, isFirst))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Sort: descending confidence, ties broken by span start (left-to-right). Downstream Stage 5
|
|
93
|
+
// can rely on this ordering for top-k selection without re-sorting.
|
|
94
|
+
proposals.sort((a, b) => {
|
|
95
|
+
if (a.confidence !== b.confidence) return b.confidence - a.confidence
|
|
96
|
+
|
|
97
|
+
return a.span.start - b.span.start
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return proposals
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Async variant matching `RuntimePipelineStages.groupPhrases`. Wraps the sync impl so the pipeline coordinator can use
|
|
105
|
+
* it as-is.
|
|
106
|
+
*/
|
|
107
|
+
export async function groupPhrases(
|
|
108
|
+
input: NormalizedInputLite,
|
|
109
|
+
shape: QueryShapeLike,
|
|
110
|
+
locale?: LocaleHint,
|
|
111
|
+
opts?: GroupPhrasesOpts
|
|
112
|
+
): Promise<PhraseProposal[]> {
|
|
113
|
+
return groupPhrasesSync(input, shape, locale, opts)
|
|
114
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `@mailwoman/phrase-grouper` — Stage 2.7 of the runtime pipeline.
|
|
7
|
+
*
|
|
8
|
+
* Proposes coherent input units (boundary discovery) with a structural kind hypothesis +
|
|
9
|
+
* confidence. Decouples boundary discovery from type classification: Stage 3 conditions on these
|
|
10
|
+
* proposals so it answers the simpler "what type is this proposed span?" rather than jointly
|
|
11
|
+
* discovering boundaries and types. Stage 5 consumes the proposals as boundary candidates for
|
|
12
|
+
* joint decoding.
|
|
13
|
+
*
|
|
14
|
+
* Bitter-lesson-safe: only universal structural cues (proximity, punctuation, capitalization,
|
|
15
|
+
* hyphenation, format-shape repetition) — never place-name dictionaries. v0.5.0 ships the
|
|
16
|
+
* rule-based v1; learned 1-2M-param span proposer reserved for v0.5.1.
|
|
17
|
+
*
|
|
18
|
+
* See `docs/articles/concepts/the-knowledge-ladder.md` § Phrase grouper for the design rationale
|
|
19
|
+
* and `docs/articles/plan/phases/PHASE_8_v0_5_0_fresh_slate.md` § E for the v0.5.0 thread.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export { groupPhrases, groupPhrasesSync } from "./group.ts"
|
|
23
|
+
export {
|
|
24
|
+
scoreHyphenatedCompound,
|
|
25
|
+
scoreLocalityPhrase,
|
|
26
|
+
scoreNumeric,
|
|
27
|
+
scorePostcode,
|
|
28
|
+
scoreRegionAbbreviation,
|
|
29
|
+
scoreStreetPhrase,
|
|
30
|
+
scoreVenuePhrase,
|
|
31
|
+
tokenizeSegment,
|
|
32
|
+
} from "./rules.ts"
|
|
33
|
+
export type { SegmentToken } from "./rules.ts"
|
|
34
|
+
export type {
|
|
35
|
+
GroupPhrasesOpts,
|
|
36
|
+
LocaleHint,
|
|
37
|
+
NormalizedInputLite,
|
|
38
|
+
PhraseGrouper,
|
|
39
|
+
PhraseKind,
|
|
40
|
+
PhraseProposal,
|
|
41
|
+
QueryShapeLike,
|
|
42
|
+
Section,
|
|
43
|
+
} from "./types.ts"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/phrase-grouper",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.0",
|
|
4
4
|
"description": "Stage 2.7 of the runtime pipeline — propose coherent input units (boundary discovery) with a structural kind hypothesis + confidence. Rule-based v1 (port of v1 section/sub-section logic); learned 1-2M-param span proposer reserved for v0.5.1.",
|
|
5
5
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
6
6
|
"repository": {
|
|
@@ -12,21 +12,35 @@
|
|
|
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": {
|
|
19
27
|
"./package.json": "./package.json",
|
|
20
28
|
".": {
|
|
21
|
-
"
|
|
22
|
-
"default": "./out/index.js"
|
|
23
|
-
"types": "./out/index.d.ts"
|
|
29
|
+
"types": "./out/index.d.ts",
|
|
30
|
+
"default": "./out/index.js"
|
|
24
31
|
}
|
|
25
32
|
},
|
|
26
33
|
"publishConfig": {
|
|
27
|
-
"access": "public"
|
|
34
|
+
"access": "public",
|
|
35
|
+
"exports": {
|
|
36
|
+
"./package.json": "./package.json",
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./out/index.d.ts",
|
|
39
|
+
"default": "./out/index.js"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
28
42
|
},
|
|
29
43
|
"dependencies": {
|
|
30
|
-
"@mailwoman/core": "7.
|
|
44
|
+
"@mailwoman/core": "7.3.0"
|
|
31
45
|
}
|
|
32
46
|
}
|
package/rules.ts
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Rule-based scorers for Stage 2.7 phrase grouping. Each rule inspects the tokenized segment +
|
|
7
|
+
* QueryShape priors and emits zero or more `PhraseProposal`s with a confidence in [0, 1].
|
|
8
|
+
*
|
|
9
|
+
* Bitter-lesson-safe: only universal structural cues (proximity, punctuation, capitalization,
|
|
10
|
+
* hyphenation, format-shape repetition). No place-name dictionaries — a `LOCALITY_PHRASE`
|
|
11
|
+
* proposal means "this looks shaped like a multi-word capitalized run that COULD be a city name",
|
|
12
|
+
* not "this IS a city name". Typing the span is the classifier's job; this layer only answers "do
|
|
13
|
+
* these tokens belong together?".
|
|
14
|
+
*
|
|
15
|
+
* Per "possibilities not constraints", rules emit overlapping proposals freely. The reconciler
|
|
16
|
+
* (Stage 5) picks the best non-overlapping subset.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Span } from "@mailwoman/core/tokenization"
|
|
20
|
+
|
|
21
|
+
import type { PhraseProposal, QueryShapeLike } from "./types.ts"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One token within a segment — absolute offsets into the normalized input. Built by `tokenizeSegment` from a
|
|
25
|
+
* (segment-text, segment-start) pair.
|
|
26
|
+
*/
|
|
27
|
+
export interface SegmentToken {
|
|
28
|
+
body: string
|
|
29
|
+
start: number
|
|
30
|
+
end: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const WHITESPACE = /\s+/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Neutral baseline confidence for phrase proposals when no structural cue (position, length, known
|
|
37
|
+
* suffix/prefix/marker, format-hit) lifts or penalizes the score. Each rule adds bonuses on top of this base (e.g.
|
|
38
|
+
* +0.15 for 2-token locality runs, +0.1 for tail-of-last-segment) and subtracts penalties (e.g. −0.2 for a known US
|
|
39
|
+
* region name that isn't at segment-tail).
|
|
40
|
+
*/
|
|
41
|
+
const NEUTRAL_PROPOSAL_CONFIDENCE = 0.55
|
|
42
|
+
|
|
43
|
+
const US_REGION_NAMES: ReadonlySet<string> = new Set([
|
|
44
|
+
"alabama",
|
|
45
|
+
"alaska",
|
|
46
|
+
"arizona",
|
|
47
|
+
"arkansas",
|
|
48
|
+
"california",
|
|
49
|
+
"colorado",
|
|
50
|
+
"connecticut",
|
|
51
|
+
"delaware",
|
|
52
|
+
"florida",
|
|
53
|
+
"georgia",
|
|
54
|
+
"hawaii",
|
|
55
|
+
"idaho",
|
|
56
|
+
"illinois",
|
|
57
|
+
"indiana",
|
|
58
|
+
"iowa",
|
|
59
|
+
"kansas",
|
|
60
|
+
"kentucky",
|
|
61
|
+
"louisiana",
|
|
62
|
+
"maine",
|
|
63
|
+
"maryland",
|
|
64
|
+
"massachusetts",
|
|
65
|
+
"michigan",
|
|
66
|
+
"minnesota",
|
|
67
|
+
"mississippi",
|
|
68
|
+
"missouri",
|
|
69
|
+
"montana",
|
|
70
|
+
"nebraska",
|
|
71
|
+
"nevada",
|
|
72
|
+
"ohio",
|
|
73
|
+
"oklahoma",
|
|
74
|
+
"oregon",
|
|
75
|
+
"pennsylvania",
|
|
76
|
+
"tennessee",
|
|
77
|
+
"texas",
|
|
78
|
+
"utah",
|
|
79
|
+
"vermont",
|
|
80
|
+
"virginia",
|
|
81
|
+
"washington",
|
|
82
|
+
"wisconsin",
|
|
83
|
+
"wyoming",
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Split a segment body into whitespace-separated tokens. Offsets are absolute into the original input (caller supplies
|
|
88
|
+
* the segment's `start` offset).
|
|
89
|
+
*/
|
|
90
|
+
export function tokenizeSegment(segmentBody: string, segmentStart: number): SegmentToken[] {
|
|
91
|
+
const tokens: SegmentToken[] = []
|
|
92
|
+
let i = 0
|
|
93
|
+
|
|
94
|
+
while (i < segmentBody.length) {
|
|
95
|
+
while (i < segmentBody.length && WHITESPACE.test(segmentBody[i]!)) {
|
|
96
|
+
i++
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (i >= segmentBody.length) break
|
|
100
|
+
const start = i
|
|
101
|
+
|
|
102
|
+
while (i < segmentBody.length && !WHITESPACE.test(segmentBody[i]!)) {
|
|
103
|
+
i++
|
|
104
|
+
}
|
|
105
|
+
tokens.push({
|
|
106
|
+
body: segmentBody.slice(start, i),
|
|
107
|
+
start: segmentStart + start,
|
|
108
|
+
end: segmentStart + i,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return tokens
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Build a `Section` (Span instance) from absolute offsets into the original text. */
|
|
116
|
+
function makeSection(text: string, start: number, end: number): Span {
|
|
117
|
+
return Span.from(text.slice(start, end), { start })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True when token body is non-empty digits only. */
|
|
121
|
+
function isAllDigit(s: string): boolean {
|
|
122
|
+
return s.length > 0 && /^[0-9]+$/.test(s)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** True when token body is 2-3 uppercase Latin letters (US state, Canadian province abbreviation). */
|
|
126
|
+
function isRegionAbbreviation(s: string): boolean {
|
|
127
|
+
return /^[A-Z]{2,3}$/.test(s)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* True when token starts with an uppercase letter — the common Western proper-noun shape. Unicode-aware (`\p{Lu}`) so
|
|
132
|
+
* accented Latin capitals (`Évellys`, `Étagnac`, `Ñuñoa`, `Ávila`) count as proper nouns too; an ASCII-only `[A-Z]`
|
|
133
|
+
* silently dropped those localities from the grouper (#425 residual).
|
|
134
|
+
*/
|
|
135
|
+
function startsCapitalized(s: string): boolean {
|
|
136
|
+
return /^\p{Lu}/u.test(s)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Common street-type suffixes (en-US + en-GB + abbreviated forms). Match case-insensitively against the raw token body.
|
|
141
|
+
* The set is intentionally short — coverage extension belongs in a future per-locale rule pack, not as a 500-entry
|
|
142
|
+
* dictionary in this rule.
|
|
143
|
+
*/
|
|
144
|
+
const STREET_SUFFIXES: ReadonlySet<string> = new Set([
|
|
145
|
+
"st",
|
|
146
|
+
"st.",
|
|
147
|
+
"street",
|
|
148
|
+
"ave",
|
|
149
|
+
"ave.",
|
|
150
|
+
"avenue",
|
|
151
|
+
"blvd",
|
|
152
|
+
"blvd.",
|
|
153
|
+
"boulevard",
|
|
154
|
+
"rd",
|
|
155
|
+
"rd.",
|
|
156
|
+
"road",
|
|
157
|
+
"ln",
|
|
158
|
+
"ln.",
|
|
159
|
+
"lane",
|
|
160
|
+
"dr",
|
|
161
|
+
"dr.",
|
|
162
|
+
"drive",
|
|
163
|
+
"way",
|
|
164
|
+
"pl",
|
|
165
|
+
"pl.",
|
|
166
|
+
"place",
|
|
167
|
+
"ct",
|
|
168
|
+
"ct.",
|
|
169
|
+
"court",
|
|
170
|
+
"pkwy",
|
|
171
|
+
"parkway",
|
|
172
|
+
"hwy",
|
|
173
|
+
"highway",
|
|
174
|
+
"ter",
|
|
175
|
+
"terrace",
|
|
176
|
+
"cir",
|
|
177
|
+
"circle",
|
|
178
|
+
"sq",
|
|
179
|
+
"square",
|
|
180
|
+
"trl",
|
|
181
|
+
"trail",
|
|
182
|
+
])
|
|
183
|
+
|
|
184
|
+
function isStreetSuffix(token: string): boolean {
|
|
185
|
+
return STREET_SUFFIXES.has(token.toLowerCase())
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Romance/Latin street-TYPE words that LEAD the street ("Via Trento", "Calle Mayor", "Corso Italia"). English puts the
|
|
190
|
+
* type last (a suffix — see STREET_SUFFIXES); Romance languages put it first. Without this, a leading "Via"/"Calle" is
|
|
191
|
+
* capitalized first-segment text the locality rule happily proposes, and on OOD intl input the model can't type it
|
|
192
|
+
* either — so the grouper-audit promotes it to a spurious `locality`, burying the real city (#425 re-gate).
|
|
193
|
+
*
|
|
194
|
+
* Street-TYPES only — deliberately NOT the ambiguous area/development words ("Polígono", "Urbanización", "Lugar",
|
|
195
|
+
* "Partida", "Borgo") that legitimately serve AS localities. This stays a bounded linguistic category; per-locale
|
|
196
|
+
* breadth belongs in a future rule pack, not an exception pile.
|
|
197
|
+
*/
|
|
198
|
+
const STREET_PREFIXES: ReadonlySet<string> = new Set([
|
|
199
|
+
// Italian
|
|
200
|
+
"via",
|
|
201
|
+
"viale",
|
|
202
|
+
"corso",
|
|
203
|
+
"largo",
|
|
204
|
+
"vicolo",
|
|
205
|
+
"strada",
|
|
206
|
+
"piazza",
|
|
207
|
+
"piazzale",
|
|
208
|
+
"contrada",
|
|
209
|
+
"traversa",
|
|
210
|
+
"lungomare",
|
|
211
|
+
// Spanish / Catalan
|
|
212
|
+
"calle",
|
|
213
|
+
"avenida",
|
|
214
|
+
"avinguda",
|
|
215
|
+
"carrer",
|
|
216
|
+
"plaza",
|
|
217
|
+
"plaça",
|
|
218
|
+
"paseo",
|
|
219
|
+
"passeig",
|
|
220
|
+
"camino",
|
|
221
|
+
"carretera",
|
|
222
|
+
"ronda",
|
|
223
|
+
"travesía",
|
|
224
|
+
// Portuguese
|
|
225
|
+
"rua",
|
|
226
|
+
"travessa",
|
|
227
|
+
"praça",
|
|
228
|
+
// French
|
|
229
|
+
"rue",
|
|
230
|
+
"avenue",
|
|
231
|
+
"boulevard",
|
|
232
|
+
"chemin",
|
|
233
|
+
"impasse",
|
|
234
|
+
"allée",
|
|
235
|
+
"quai",
|
|
236
|
+
])
|
|
237
|
+
|
|
238
|
+
function isStreetPrefix(token: string): boolean {
|
|
239
|
+
return STREET_PREFIXES.has(token.toLowerCase())
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Lowercase connective particles that live INSIDE multi-word place names — the Romance/Germanic glue that bridges two
|
|
244
|
+
* capitalized content words: "Las Palmas **de** Gran Canaria", "San Pietro **in** Casale", "Alphen **aan den** Rijn",
|
|
245
|
+
* "Frankfurt **am** Main", "Rothenburg **ob der** Tauber". This is a BOUNDED linguistic category (place-name
|
|
246
|
+
* connectives), not a gazetteer or a stopword dump — and it only ever fires when bracketed by capitalized content on
|
|
247
|
+
* BOTH sides (see `scoreLocalityPhrase`), so a stray "and"/"the" in a street phrase can't smuggle a particle through.
|
|
248
|
+
* Keep coverage to the connectives that actually bridge place-name tokens; growing it into a per-locale stopword list
|
|
249
|
+
* is the wrong move — that pressure belongs on the gazetteer/reconciler, not here.
|
|
250
|
+
*/
|
|
251
|
+
const PLACE_NAME_PARTICLES: ReadonlySet<string> = new Set([
|
|
252
|
+
// Spanish / Catalan / Portuguese
|
|
253
|
+
"de",
|
|
254
|
+
"del",
|
|
255
|
+
"la",
|
|
256
|
+
"las",
|
|
257
|
+
"los",
|
|
258
|
+
"el",
|
|
259
|
+
"i",
|
|
260
|
+
// Italian
|
|
261
|
+
"di",
|
|
262
|
+
"della",
|
|
263
|
+
"dei",
|
|
264
|
+
"degli",
|
|
265
|
+
"delle",
|
|
266
|
+
"in",
|
|
267
|
+
"a",
|
|
268
|
+
"sul",
|
|
269
|
+
"sulla",
|
|
270
|
+
// French
|
|
271
|
+
"du",
|
|
272
|
+
"des",
|
|
273
|
+
"le",
|
|
274
|
+
"les",
|
|
275
|
+
"sur",
|
|
276
|
+
"sous",
|
|
277
|
+
"en",
|
|
278
|
+
"lès",
|
|
279
|
+
// Dutch / Flemish
|
|
280
|
+
"aan",
|
|
281
|
+
"op",
|
|
282
|
+
"den",
|
|
283
|
+
"ter",
|
|
284
|
+
"ten",
|
|
285
|
+
// German
|
|
286
|
+
"am",
|
|
287
|
+
"an",
|
|
288
|
+
"auf",
|
|
289
|
+
"ob",
|
|
290
|
+
"im",
|
|
291
|
+
"vor",
|
|
292
|
+
"bei",
|
|
293
|
+
"der",
|
|
294
|
+
])
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A short lowercase particle fused via apostrophe to a capitalized name — the Italian/French elision that the tokenizer
|
|
298
|
+
* keeps as ONE token: `nell'Emilia`, `dell'Adda`, `l'Aquila`. Treated as place-name CONTENT (it carries the proper
|
|
299
|
+
* noun), so it can both start and continue a locality run.
|
|
300
|
+
*/
|
|
301
|
+
function isFusedParticleName(s: string): boolean {
|
|
302
|
+
return /^\p{Ll}{1,6}['’]\p{Lu}/u.test(s)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Place-name content token: a capitalized word OR an apostrophe-fused particle name (`nell'Emilia`).
|
|
307
|
+
*/
|
|
308
|
+
function isPlaceNameContent(s: string): boolean {
|
|
309
|
+
return startsCapitalized(s) || isFusedParticleName(s)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** True when the token is a known lowercase place-name connective (`de`, `in`, `aan`, `am`, …). */
|
|
313
|
+
function isPlaceNameParticle(s: string): boolean {
|
|
314
|
+
return PLACE_NAME_PARTICLES.has(s.toLowerCase())
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Venue-marker nouns with per-term confidence weights. Same caveat as STREET_SUFFIXES — universal structural markers,
|
|
319
|
+
* not a places dictionary. Higher weight = stronger venue signal.
|
|
320
|
+
*/
|
|
321
|
+
const VENUE_MARKERS: ReadonlyMap<string, number> = new Map([
|
|
322
|
+
// Dining (0.90 — unambiguous venue markers)
|
|
323
|
+
["steakhouse", 0.9],
|
|
324
|
+
["restaurant", 0.9],
|
|
325
|
+
["bistro", 0.9],
|
|
326
|
+
["diner", 0.85],
|
|
327
|
+
["cafe", 0.85],
|
|
328
|
+
["café", 0.85],
|
|
329
|
+
["grill", 0.8],
|
|
330
|
+
["pizzeria", 0.9],
|
|
331
|
+
["bakery", 0.85],
|
|
332
|
+
["brewery", 0.85],
|
|
333
|
+
["winery", 0.85],
|
|
334
|
+
["tavern", 0.8],
|
|
335
|
+
["pub", 0.75],
|
|
336
|
+
["bar", 0.7],
|
|
337
|
+
// Lodging
|
|
338
|
+
["hotel", 0.9],
|
|
339
|
+
["motel", 0.9],
|
|
340
|
+
["inn", 0.75],
|
|
341
|
+
["resort", 0.85],
|
|
342
|
+
["lodge", 0.75],
|
|
343
|
+
["hostel", 0.85],
|
|
344
|
+
// Entertainment / culture
|
|
345
|
+
["theater", 0.85],
|
|
346
|
+
["theatre", 0.85],
|
|
347
|
+
["cinema", 0.85],
|
|
348
|
+
["stadium", 0.9],
|
|
349
|
+
["arena", 0.85],
|
|
350
|
+
["museum", 0.85],
|
|
351
|
+
["gallery", 0.75],
|
|
352
|
+
["casino", 0.85],
|
|
353
|
+
["lounge", 0.7],
|
|
354
|
+
// Retail / commercial
|
|
355
|
+
["market", 0.7],
|
|
356
|
+
["mall", 0.8],
|
|
357
|
+
["plaza", 0.7],
|
|
358
|
+
["tower", 0.65],
|
|
359
|
+
["center", 0.6],
|
|
360
|
+
["centre", 0.6],
|
|
361
|
+
// Medical / institutional
|
|
362
|
+
["hospital", 0.9],
|
|
363
|
+
["clinic", 0.85],
|
|
364
|
+
["pharmacy", 0.85],
|
|
365
|
+
// Education
|
|
366
|
+
["university", 0.9],
|
|
367
|
+
["college", 0.85],
|
|
368
|
+
["school", 0.8],
|
|
369
|
+
["academy", 0.8],
|
|
370
|
+
// Civic / religious
|
|
371
|
+
["church", 0.8],
|
|
372
|
+
["temple", 0.8],
|
|
373
|
+
["mosque", 0.8],
|
|
374
|
+
["synagogue", 0.85],
|
|
375
|
+
["cathedral", 0.85],
|
|
376
|
+
["chapel", 0.75],
|
|
377
|
+
["library", 0.85],
|
|
378
|
+
// Outdoor
|
|
379
|
+
["park", 0.6],
|
|
380
|
+
["gardens", 0.65],
|
|
381
|
+
["ranch", 0.7],
|
|
382
|
+
["farm", 0.65],
|
|
383
|
+
])
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Unit-designator tokens that gate the venue-by-exclusion heuristic. When any token in a segment matches one of these,
|
|
387
|
+
* the segment is likely a unit/suite line, not a venue name.
|
|
388
|
+
*/
|
|
389
|
+
const UNIT_MARKERS: ReadonlySet<string> = new Set([
|
|
390
|
+
"apt",
|
|
391
|
+
"apt.",
|
|
392
|
+
"apartment",
|
|
393
|
+
"unit",
|
|
394
|
+
"ste",
|
|
395
|
+
"ste.",
|
|
396
|
+
"suite",
|
|
397
|
+
"room",
|
|
398
|
+
"rm",
|
|
399
|
+
"rm.",
|
|
400
|
+
"floor",
|
|
401
|
+
"fl",
|
|
402
|
+
"fl.",
|
|
403
|
+
"bldg",
|
|
404
|
+
"bldg.",
|
|
405
|
+
"building",
|
|
406
|
+
"dept",
|
|
407
|
+
"dept.",
|
|
408
|
+
"department",
|
|
409
|
+
"#",
|
|
410
|
+
])
|
|
411
|
+
|
|
412
|
+
function venueMarkerWeight(tokens: ReadonlyArray<SegmentToken>): number {
|
|
413
|
+
let maxWeight = 0
|
|
414
|
+
|
|
415
|
+
for (const t of tokens) {
|
|
416
|
+
const w = VENUE_MARKERS.get(t.body.toLowerCase())
|
|
417
|
+
|
|
418
|
+
if (w !== undefined && w > maxWeight) {
|
|
419
|
+
maxWeight = w
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return maxWeight
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function hasUnitMarker(tokens: ReadonlyArray<SegmentToken>): boolean {
|
|
427
|
+
return tokens.some((t) => UNIT_MARKERS.has(t.body.toLowerCase()))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* `NUMERIC` rule: emit one proposal per all-digit token. House numbers, postcodes (when no format hit), unit numbers
|
|
432
|
+
* all surface here as a base hypothesis.
|
|
433
|
+
*
|
|
434
|
+
* Confidence drops for very long runs (5+ digits) where POSTCODE will typically win; the reconciler does the final
|
|
435
|
+
* pick.
|
|
436
|
+
*/
|
|
437
|
+
export function scoreNumeric(tokens: ReadonlyArray<SegmentToken>, text: string): PhraseProposal[] {
|
|
438
|
+
const out: PhraseProposal[] = []
|
|
439
|
+
|
|
440
|
+
for (const t of tokens) {
|
|
441
|
+
if (!isAllDigit(t.body)) continue
|
|
442
|
+
const len = t.body.length
|
|
443
|
+
// 1-4 digit pure-numerics are clearly NUMERIC (house number). 5+ are ambiguous with POSTCODE
|
|
444
|
+
// — emit anyway at lower confidence so the reconciler sees both options.
|
|
445
|
+
const confidence = len <= 4 ? 0.95 : NEUTRAL_PROPOSAL_CONFIDENCE
|
|
446
|
+
out.push({
|
|
447
|
+
span: makeSection(text, t.start, t.end),
|
|
448
|
+
kindHypothesis: "NUMERIC",
|
|
449
|
+
confidence,
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return out
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* `POSTCODE` rule: lift each `QueryShape.knownFormats` postcode hit directly. The QueryShape stage already did the
|
|
458
|
+
* format-shape recognition — Stage 2.7's job is just to publish the spans as phrase proposals so the reconciler can use
|
|
459
|
+
* them.
|
|
460
|
+
*/
|
|
461
|
+
export function scorePostcode(shape: QueryShapeLike, text: string): PhraseProposal[] {
|
|
462
|
+
const out: PhraseProposal[] = []
|
|
463
|
+
|
|
464
|
+
for (const hit of shape.knownFormats) {
|
|
465
|
+
// `po_box` is not a postcode; the kind classifier owns that signal. Skip non-postcode
|
|
466
|
+
// formats here so we don't pollute POSTCODE proposals.
|
|
467
|
+
if (hit.format === "po_box") continue
|
|
468
|
+
out.push({
|
|
469
|
+
span: makeSection(text, hit.span.start, hit.span.end),
|
|
470
|
+
kindHypothesis: "POSTCODE",
|
|
471
|
+
// Lift the format-hit confidence directly — Stage 5 can weight it against alternatives.
|
|
472
|
+
confidence: hit.confidence,
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return out
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* `REGION_ABBREVIATION` rule: 2-3 uppercase Latin letters. Tail-of-segment position boosts confidence because that's
|
|
481
|
+
* the canonical "City, ST ZIP" shape.
|
|
482
|
+
*/
|
|
483
|
+
export function scoreRegionAbbreviation(
|
|
484
|
+
tokens: ReadonlyArray<SegmentToken>,
|
|
485
|
+
text: string,
|
|
486
|
+
segmentIsLast: boolean
|
|
487
|
+
): PhraseProposal[] {
|
|
488
|
+
const out: PhraseProposal[] = []
|
|
489
|
+
|
|
490
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
491
|
+
const t = tokens[i]!
|
|
492
|
+
|
|
493
|
+
if (!isRegionAbbreviation(t.body)) continue
|
|
494
|
+
// A region code is canonically standalone — the tail of "City, ST ZIP", never immediately
|
|
495
|
+
// followed by another place-name word. When the next token IS place-name content (and not
|
|
496
|
+
// itself a region abbreviation or a street suffix), this token is the HEAD of a multi-word
|
|
497
|
+
// place name ("SAN" NAZARIO, "DI" CASTELLO — common in all-caps intl data where every short
|
|
498
|
+
// word matches the 2-3-uppercase shape), not a region. Suppressing the region proposal here
|
|
499
|
+
// keeps it from out-deduping the same span's LOCALITY_PHRASE in the reconciler (#425).
|
|
500
|
+
const after = tokens[i + 1]
|
|
501
|
+
|
|
502
|
+
if (after && isPlaceNameContent(after.body) && !isRegionAbbreviation(after.body) && !isStreetSuffix(after.body)) {
|
|
503
|
+
continue
|
|
504
|
+
}
|
|
505
|
+
// Position cue: last token in a segment (canonical region slot) → high confidence. Anywhere
|
|
506
|
+
// else, moderate. Anywhere in the LAST segment → slightly elevated (region is canonically the
|
|
507
|
+
// final non-postcode component).
|
|
508
|
+
const atTail = i === tokens.length - 1
|
|
509
|
+
const confidence = atTail ? 0.85 : segmentIsLast ? 0.7 : NEUTRAL_PROPOSAL_CONFIDENCE
|
|
510
|
+
out.push({
|
|
511
|
+
span: makeSection(text, t.start, t.end),
|
|
512
|
+
kindHypothesis: "REGION_ABBREVIATION",
|
|
513
|
+
confidence,
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return out
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* `HYPHENATED_COMPOUND` rule: tokens containing an internal hyphen. Captures `NY-NY` (venue disambiguation case),
|
|
522
|
+
* `Saint-Denis` (French locality compound), `10118-1234` (ZIP+4 written as a single token).
|
|
523
|
+
*
|
|
524
|
+
* Internal hyphen is the cue; the rule doesn't pre-judge what the compound MEANS — that's typing (classifier) or
|
|
525
|
+
* reconcile work. A high confidence here just says "this is one unit, not two".
|
|
526
|
+
*/
|
|
527
|
+
export function scoreHyphenatedCompound(tokens: ReadonlyArray<SegmentToken>, text: string): PhraseProposal[] {
|
|
528
|
+
const out: PhraseProposal[] = []
|
|
529
|
+
|
|
530
|
+
for (const t of tokens) {
|
|
531
|
+
if (!t.body.includes("-")) continue
|
|
532
|
+
|
|
533
|
+
// Skip leading/trailing hyphens (likely punctuation drift) — require an interior hyphen
|
|
534
|
+
// surrounded by non-hyphen characters.
|
|
535
|
+
if (!/[^-]-[^-]/.test(t.body)) continue
|
|
536
|
+
out.push({
|
|
537
|
+
span: makeSection(text, t.start, t.end),
|
|
538
|
+
kindHypothesis: "HYPHENATED_COMPOUND",
|
|
539
|
+
confidence: 0.88,
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return out
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* `STREET_PHRASE` rule: a token run that contains a street-type suffix. The span covers a leading numeric (house
|
|
548
|
+
* number) when present, through the suffix token.
|
|
549
|
+
*
|
|
550
|
+
* Confidence reflects how canonical the run looks: NUMERIC + 1-3 capitalized words + SUFFIX scores highest; suffix-only
|
|
551
|
+
* or non-leading-numeric variants score lower but still emit.
|
|
552
|
+
*/
|
|
553
|
+
export function scoreStreetPhrase(tokens: ReadonlyArray<SegmentToken>, text: string): PhraseProposal[] {
|
|
554
|
+
const out: PhraseProposal[] = []
|
|
555
|
+
|
|
556
|
+
for (let suffixIdx = 0; suffixIdx < tokens.length; suffixIdx++) {
|
|
557
|
+
if (!isStreetSuffix(tokens[suffixIdx]!.body)) continue
|
|
558
|
+
// Walk left from the suffix gathering capitalized/numeric/ordinal tokens. Stop when we hit
|
|
559
|
+
// something un-street-y (lowercase non-suffix, another suffix, etc.).
|
|
560
|
+
let start = suffixIdx
|
|
561
|
+
|
|
562
|
+
for (let i = suffixIdx - 1; i >= 0; i--) {
|
|
563
|
+
const body = tokens[i]!.body
|
|
564
|
+
|
|
565
|
+
if (isAllDigit(body) || /^\d+(st|nd|rd|th)$/i.test(body) || startsCapitalized(body)) {
|
|
566
|
+
start = i
|
|
567
|
+
} else {
|
|
568
|
+
break
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Need at least one preceding token (or a numeric house number) for STREET_PHRASE — a
|
|
573
|
+
// suffix-only token "Street" alone isn't a street phrase.
|
|
574
|
+
if (start === suffixIdx) continue
|
|
575
|
+
// #565: a leading ALL-DIGIT token is a house NUMBER, not part of the street name. Exclude it from
|
|
576
|
+
// the STREET_PHRASE span — the NUMERIC rule already proposes the house number separately — so the
|
|
577
|
+
// joint reconciler types the house number and the street as DISTINCT nodes instead of fusing the
|
|
578
|
+
// whole run ("3075 Hill Street") into one (the regression behind #566). Ordinals ("5th Ave") are
|
|
579
|
+
// part of the name and stay.
|
|
580
|
+
let hadHouseNumber = false
|
|
581
|
+
|
|
582
|
+
if (isAllDigit(tokens[start]!.body)) {
|
|
583
|
+
start += 1
|
|
584
|
+
hadHouseNumber = true
|
|
585
|
+
|
|
586
|
+
if (start === suffixIdx) continue // only "<number> <suffix>" remained — no street name to phrase
|
|
587
|
+
}
|
|
588
|
+
const startTok = tokens[start]!
|
|
589
|
+
const endTok = tokens[suffixIdx]!
|
|
590
|
+
// A preceding (now-excluded) house number is still strong evidence this run is a street → high
|
|
591
|
+
// confidence. A capitalized-run + suffix with no number scores slightly lower (could be a venue).
|
|
592
|
+
const confidence = hadHouseNumber ? 0.9 : 0.75
|
|
593
|
+
out.push({
|
|
594
|
+
span: makeSection(text, startTok.start, endTok.end),
|
|
595
|
+
kindHypothesis: "STREET_PHRASE",
|
|
596
|
+
confidence,
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Romance street pattern: the street TYPE LEADS ("Via Trento", "Calle Mayor", "Largo Millefiori").
|
|
601
|
+
// Walk RIGHT from a street-prefix token gathering capitalized place-name words (bridging particles),
|
|
602
|
+
// stopping at a digit house-number or any non-place token.
|
|
603
|
+
for (let prefixIdx = 0; prefixIdx < tokens.length; prefixIdx++) {
|
|
604
|
+
if (!isStreetPrefix(tokens[prefixIdx]!.body)) continue
|
|
605
|
+
let end = prefixIdx
|
|
606
|
+
|
|
607
|
+
for (let i = prefixIdx + 1; i < tokens.length; i++) {
|
|
608
|
+
const body = tokens[i]!.body
|
|
609
|
+
|
|
610
|
+
if (isStreetPrefix(body)) break
|
|
611
|
+
|
|
612
|
+
if (isPlaceNameContent(body) || isPlaceNameParticle(body)) {
|
|
613
|
+
end = i
|
|
614
|
+
} else {
|
|
615
|
+
break
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Don't end on a trailing connective particle ("Calle de" is not a street name).
|
|
620
|
+
while (end > prefixIdx && isPlaceNameParticle(tokens[end]!.body)) {
|
|
621
|
+
end--
|
|
622
|
+
}
|
|
623
|
+
const startTok = tokens[prefixIdx]!
|
|
624
|
+
const endTok = tokens[end]!
|
|
625
|
+
// Prefix + name scores moderately; a bare prefix still emits a low-confidence marker so the
|
|
626
|
+
// audit types the leftover span `street`, never `locality`.
|
|
627
|
+
out.push({
|
|
628
|
+
span: makeSection(text, startTok.start, endTok.end),
|
|
629
|
+
kindHypothesis: "STREET_PHRASE",
|
|
630
|
+
confidence: end > prefixIdx ? 0.72 : 0.5,
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return out
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* `LOCALITY_PHRASE` rule: runs of contiguous place-name tokens (1-6 long). Emits multiple overlapping proposals so the
|
|
639
|
+
* reconciler can choose between e.g. `Saint Petersburg` as one phrase vs `Saint` + `Petersburg` as two.
|
|
640
|
+
*
|
|
641
|
+
* The run bridges lowercase place-name PARTICLES (`de`, `in`, `aan den`, `am`, …) and apostrophe-fused names
|
|
642
|
+
* (`nell'Emilia`) when they sit between capitalized content — without that, the walk used to stop dead at the first
|
|
643
|
+
* lowercase token, so it never proposed "Reggio nell'Emilia", "Las Palmas de Gran Canaria", or "San Pietro in Casale"
|
|
644
|
+
* as single spans. Those gaps are exactly the native-order multi-word localities the joint-decode A/B fragmented (Route
|
|
645
|
+
* A Phase I, #425).
|
|
646
|
+
*
|
|
647
|
+
* Confidence scales with: run length (2-5 are good place-name lengths), tail-of-segment position, and whether the span
|
|
648
|
+
* sits at a segment boundary.
|
|
649
|
+
*/
|
|
650
|
+
export function scoreLocalityPhrase(
|
|
651
|
+
tokens: ReadonlyArray<SegmentToken>,
|
|
652
|
+
text: string,
|
|
653
|
+
segmentIsLast: boolean
|
|
654
|
+
): PhraseProposal[] {
|
|
655
|
+
const out: PhraseProposal[] = []
|
|
656
|
+
|
|
657
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
658
|
+
if (!isPlaceNameContent(tokens[i]!.body)) continue
|
|
659
|
+
|
|
660
|
+
// A leading street-type word ("Via", "Calle", "Corso") heads a STREET, not a locality — let
|
|
661
|
+
// scoreStreetPhrase own it so the audit never promotes it to a spurious locality.
|
|
662
|
+
if (isStreetPrefix(tokens[i]!.body)) continue
|
|
663
|
+
|
|
664
|
+
// A region-abbreviation-SHAPED head (2-3 uppercase letters) starts a LOCALITY_PHRASE only when
|
|
665
|
+
// place-name content follows it. This keeps a standalone trailing "NY"/"TX" owned by
|
|
666
|
+
// REGION_ABBREVIATION, while still forming "SAN NAZARIO" / "CITTÀ DI CASTELLO" — in all-caps
|
|
667
|
+
// intl data (OpenAddresses), the head/connector of a place name ("SAN", "DI", "DEL") matches
|
|
668
|
+
// the abbreviation shape, so a hard skip here dropped the multi-word locality entirely (#425).
|
|
669
|
+
if (isRegionAbbreviation(tokens[i]!.body)) {
|
|
670
|
+
const after = tokens[i + 1]
|
|
671
|
+
|
|
672
|
+
if (!after || !(isPlaceNameContent(after.body) || isPlaceNameParticle(after.body))) continue
|
|
673
|
+
}
|
|
674
|
+
// Walk forward grabbing place-name content. Bridge connective particles (lowercase "de"/"in" or
|
|
675
|
+
// all-caps "DI"/"DEL") ONLY when a content token follows within a short run (≤2 consecutive
|
|
676
|
+
// particles: "aan den Rijn"), so a dangling "Palmas de" at end-of-segment doesn't extend the
|
|
677
|
+
// run. Stop on digits, street suffixes, and NON-particle region abbreviations ("Springfield IL"
|
|
678
|
+
// must not absorb "IL").
|
|
679
|
+
let j = i
|
|
680
|
+
|
|
681
|
+
for (;;) {
|
|
682
|
+
const next = tokens[j + 1]
|
|
683
|
+
|
|
684
|
+
if (!next) break
|
|
685
|
+
const b = next.body
|
|
686
|
+
|
|
687
|
+
if (isAllDigit(b) || isStreetSuffix(b) || isStreetPrefix(b)) break
|
|
688
|
+
|
|
689
|
+
if (isRegionAbbreviation(b) && !isPlaceNameParticle(b)) break
|
|
690
|
+
|
|
691
|
+
if (isPlaceNameContent(b) && !isPlaceNameParticle(b)) {
|
|
692
|
+
j++
|
|
693
|
+
continue
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (isPlaceNameParticle(b)) {
|
|
697
|
+
// Look past a short run of consecutive particles for the next content token.
|
|
698
|
+
let k = j + 2
|
|
699
|
+
|
|
700
|
+
while (tokens[k] && isPlaceNameParticle(tokens[k]!.body)) {
|
|
701
|
+
k++
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (tokens[k] && k - (j + 1) <= 2 && isPlaceNameContent(tokens[k]!.body)) {
|
|
705
|
+
j = k // jump onto the content token; the bridged particles stay inside the span
|
|
706
|
+
continue
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
break
|
|
710
|
+
}
|
|
711
|
+
// Emit proposals for every prefix-length of the run starting at i, capped at 6 tokens (covers
|
|
712
|
+
// "Las Palmas de Gran Canaria" = 5). Each starting i contributes ≤6 proposals → O(n) per segment.
|
|
713
|
+
const maxLen = Math.min(j - i + 1, 6)
|
|
714
|
+
|
|
715
|
+
for (let len = 1; len <= maxLen; len++) {
|
|
716
|
+
const startTok = tokens[i]!
|
|
717
|
+
const endTok = tokens[i + len - 1]!
|
|
718
|
+
|
|
719
|
+
// Never end a proposal ON a connective particle ("Las Palmas de" / "CITTÀ DI" is not a place).
|
|
720
|
+
if (isPlaceNameParticle(endTok.body)) continue
|
|
721
|
+
const spanText = text.slice(startTok.start, endTok.end)
|
|
722
|
+
const isRegionName = len === 1 && US_REGION_NAMES.has(spanText.toLowerCase())
|
|
723
|
+
const atTail = i + len - 1 === tokens.length - 1
|
|
724
|
+
const lenBonus = len === 2 ? 0.15 : len === 3 ? 0.12 : len >= 4 ? 0.08 : 0
|
|
725
|
+
let confidence = NEUTRAL_PROPOSAL_CONFIDENCE + lenBonus
|
|
726
|
+
|
|
727
|
+
if (isRegionName && !atTail) {
|
|
728
|
+
confidence -= 0.2
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (atTail && segmentIsLast) {
|
|
732
|
+
confidence += 0.1
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (atTail) {
|
|
736
|
+
confidence += 0.05
|
|
737
|
+
}
|
|
738
|
+
out.push({
|
|
739
|
+
span: makeSection(text, startTok.start, endTok.end),
|
|
740
|
+
kindHypothesis: "LOCALITY_PHRASE",
|
|
741
|
+
confidence: Math.min(0.95, confidence),
|
|
742
|
+
})
|
|
743
|
+
}
|
|
744
|
+
// Do NOT skip past the run — let i++ advance normally so every capitalized token gets a
|
|
745
|
+
// chance to emit single-token proposals from its own starting position. (Saint Petersburg
|
|
746
|
+
// needs `Saint`, `Petersburg`, AND `Saint Petersburg`; a run-skip would lose `Petersburg`.)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return out
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* `VENUE_PHRASE` rule: capitalized run containing a venue-marker noun (Steakhouse, Hotel, etc.) OR containing a
|
|
754
|
+
* hyphenated compound + ≥1 capitalized word.
|
|
755
|
+
*
|
|
756
|
+
* The shape "NY-NY Steakhouse" — the kryptonite case the reconciler eventually needs to lift the NY tokens off REGION —
|
|
757
|
+
* surfaces here as a `VENUE_PHRASE` proposal at moderate-high confidence.
|
|
758
|
+
*
|
|
759
|
+
* Also includes a venue-by-exclusion positional prior: multi-word capitalized run in the first segment with no street
|
|
760
|
+
* suffix, no house number, and no unit marker → weak VENUE_PHRASE at 0.50-0.55. The idea: if we can't identify what
|
|
761
|
+
* something IS, but it's in the venue slot (first segment) and doesn't look like any other component, it might be a
|
|
762
|
+
* venue name.
|
|
763
|
+
*/
|
|
764
|
+
export function scoreVenuePhrase(
|
|
765
|
+
tokens: ReadonlyArray<SegmentToken>,
|
|
766
|
+
text: string,
|
|
767
|
+
segmentIsFirst?: boolean
|
|
768
|
+
): PhraseProposal[] {
|
|
769
|
+
const out: PhraseProposal[] = []
|
|
770
|
+
let i = 0
|
|
771
|
+
|
|
772
|
+
while (i < tokens.length) {
|
|
773
|
+
if (!startsCapitalized(tokens[i]!.body)) {
|
|
774
|
+
i++
|
|
775
|
+
continue
|
|
776
|
+
}
|
|
777
|
+
let j = i
|
|
778
|
+
|
|
779
|
+
while (j + 1 < tokens.length && (startsCapitalized(tokens[j + 1]!.body) || tokens[j + 1]!.body.includes("-"))) {
|
|
780
|
+
j++
|
|
781
|
+
}
|
|
782
|
+
const run = tokens.slice(i, j + 1)
|
|
783
|
+
const markerWeight = venueMarkerWeight(run)
|
|
784
|
+
const hasHyphenCompound = run.some((t) => /[^-]-[^-]/.test(t.body))
|
|
785
|
+
|
|
786
|
+
if (markerWeight > 0 || (hasHyphenCompound && run.length >= 2)) {
|
|
787
|
+
const startTok = run[0]!
|
|
788
|
+
const endTok = run[run.length - 1]!
|
|
789
|
+
const confidence = markerWeight > 0 ? markerWeight : 0.65
|
|
790
|
+
out.push({
|
|
791
|
+
span: makeSection(text, startTok.start, endTok.end),
|
|
792
|
+
kindHypothesis: "VENUE_PHRASE",
|
|
793
|
+
confidence,
|
|
794
|
+
})
|
|
795
|
+
} else if (segmentIsFirst && run.length >= 2) {
|
|
796
|
+
const hasStreet = run.some((t) => isStreetSuffix(t.body))
|
|
797
|
+
const hasLeadingNum = isAllDigit(run[0]!.body)
|
|
798
|
+
const hasUnit = hasUnitMarker(run)
|
|
799
|
+
|
|
800
|
+
if (!hasStreet && !hasLeadingNum && !hasUnit) {
|
|
801
|
+
const startTok = run[0]!
|
|
802
|
+
const endTok = run[run.length - 1]!
|
|
803
|
+
out.push({
|
|
804
|
+
span: makeSection(text, startTok.start, endTok.end),
|
|
805
|
+
kindHypothesis: "VENUE_PHRASE",
|
|
806
|
+
confidence: run.length >= 3 ? NEUTRAL_PROPOSAL_CONFIDENCE : 0.5,
|
|
807
|
+
})
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
i = j + 1
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return out
|
|
815
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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, PhraseGrouper, PhraseKind, PhraseProposal } from "@mailwoman/core/pipeline"
|
|
9
|
+
|
|
10
|
+
/** Re-export of the canonical `Section` type from `@mailwoman/core/types`. `Section = Span`. */
|
|
11
|
+
export type { Section } from "@mailwoman/core/types"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Minimal `NormalizedInput` shape consumed by `groupPhrases`. Compatible with `@mailwoman/normalize`'s output.
|
|
15
|
+
*/
|
|
16
|
+
export interface NormalizedInputLite {
|
|
17
|
+
raw: string
|
|
18
|
+
normalized: string
|
|
19
|
+
appliedLocale?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Minimal `QueryShape` shape consumed by `groupPhrases`. Compatible with `@mailwoman/query-shape`'s output.
|
|
24
|
+
*/
|
|
25
|
+
export interface QueryShapeLike {
|
|
26
|
+
knownFormats: ReadonlyArray<{
|
|
27
|
+
format: string
|
|
28
|
+
span: { start: number; end: number }
|
|
29
|
+
confidence: number
|
|
30
|
+
}>
|
|
31
|
+
segments?: ReadonlyArray<{ body: string; index: number; span?: { start: number; end: number } }>
|
|
32
|
+
tokenClasses?: ReadonlyArray<{
|
|
33
|
+
span: { start: number; end: number; body: string }
|
|
34
|
+
class: string
|
|
35
|
+
length: number
|
|
36
|
+
}>
|
|
37
|
+
characterClass?: string
|
|
38
|
+
totalLength?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GroupPhrasesOpts {
|
|
42
|
+
/** Reserved for future tunables (e.g. confidence floor, per-kind biasing). Currently unused. */
|
|
43
|
+
confidenceFloor?: number
|
|
44
|
+
}
|