@mailwoman/corpus 7.2.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/package.json +59 -27
- package/src/adapter.ts +153 -0
- package/src/adapters/ban/adapter.ts +189 -0
- package/src/adapters/ban/street-decompose.ts +98 -0
- package/src/adapters/fcc-bdc/adapter.ts +188 -0
- package/src/adapters/geonames/adapter.ts +185 -0
- package/src/adapters/geonames-postal/adapter.ts +111 -0
- package/src/adapters/gnaf/adapter.ts +139 -0
- package/src/adapters/gnaf/assemble.ts +200 -0
- package/src/adapters/index.ts +135 -0
- package/src/adapters/openaddresses/adapter.ts +237 -0
- package/src/adapters/overture/adapter.ts +149 -0
- package/src/adapters/state-hi-schools/adapter.ts +171 -0
- package/src/adapters/state-ia-contractors/adapter.ts +148 -0
- package/src/adapters/state-ny-notaries/adapter.ts +169 -0
- package/src/adapters/state-tx-notaries/adapter.ts +160 -0
- package/src/adapters/synth-po-box/adapter.ts +197 -0
- package/src/adapters/tiger/adapter.ts +232 -0
- package/src/adapters/tiger/street-decompose.ts +120 -0
- package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
- package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
- package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
- package/src/adapters/usgov-nad/adapter.ts +338 -0
- package/src/adapters/usgov-nppes/adapter.ts +169 -0
- package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
- package/src/adapters/wof-admin-jp/adapter.ts +176 -0
- package/src/adapters/wof-admin-json/adapter.ts +280 -0
- package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
- package/src/align.ts +317 -0
- package/src/build.ts +348 -0
- package/src/codex/us-fips-state.ts +109 -0
- package/src/format.ts +14 -0
- package/src/golden.ts +147 -0
- package/src/index.ts +28 -0
- package/src/license.ts +48 -0
- package/src/parquet-wrapper/index.ts +9 -0
- package/src/parquet-wrapper/reader.ts +67 -0
- package/src/parquet-wrapper/schema.ts +80 -0
- package/src/parquet-wrapper/writer.ts +102 -0
- package/src/parquet.ts +360 -0
- package/src/runner.ts +283 -0
- package/src/shard-recipes/anchor-absorption.ts +86 -0
- package/src/shard-recipes/boundary-stress.ts +87 -0
- package/src/shard-recipes/country-balanced.ts +471 -0
- package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
- package/src/shard-recipes/fr-admin-split.ts +212 -0
- package/src/shard-recipes/fr-bare-street.ts +85 -0
- package/src/shard-recipes/fr-fragment.ts +304 -0
- package/src/shard-recipes/fr-order.ts +283 -0
- package/src/shard-recipes/german.ts +206 -0
- package/src/shard-recipes/house-venue.ts +78 -0
- package/src/shard-recipes/index.ts +76 -0
- package/src/shard-recipes/intersection.ts +617 -0
- package/src/shard-recipes/locale.ts +415 -0
- package/src/shard-recipes/nl-postcode.ts +105 -0
- package/src/shard-recipes/no-fragment.ts +206 -0
- package/src/shard-recipes/no-street-led.ts +130 -0
- package/src/shard-recipes/no-street.ts +78 -0
- package/src/shard-recipes/po-box-cedex.ts +853 -0
- package/src/shard-recipes/po-box.ts +111 -0
- package/src/shard-recipes/scaffold.ts +187 -0
- package/src/shard-recipes/si-bare-village.ts +88 -0
- package/src/shard-recipes/street-affix.ts +549 -0
- package/src/shard-recipes/street-bare.ts +90 -0
- package/src/shard-recipes/street.ts +74 -0
- package/src/shard-recipes/unit.ts +315 -0
- package/src/split.ts +251 -0
- package/src/synthesize-anchor-absorption.ts +263 -0
- package/src/synthesize-boundary-stress.ts +504 -0
- package/src/synthesize-german.ts +227 -0
- package/src/synthesize-house-venue.ts +192 -0
- package/src/synthesize-intersection.ts +205 -0
- package/src/synthesize-no-street.ts +341 -0
- package/src/synthesize-po-box.ts +307 -0
- package/src/synthesize-street.ts +264 -0
- package/src/synthesize.ts +872 -0
- package/src/tokenize.ts +68 -0
- package/src/tools/align-shard.ts +67 -0
- package/src/tools/audit.ts +352 -0
- package/src/tools/corpus-stats.ts +183 -0
- package/src/tools/fetch/ban.ts +288 -0
- package/src/tools/fetch/download.ts +119 -0
- package/src/tools/fetch/hrsa.ts +65 -0
- package/src/tools/fetch/imls-pls.ts +162 -0
- package/src/tools/fetch/index.ts +121 -0
- package/src/tools/fetch/nad.ts +304 -0
- package/src/tools/fetch/nppes.ts +177 -0
- package/src/tools/fetch/openaddresses.ts +380 -0
- package/src/tools/fetch/state-hi-schools.ts +217 -0
- package/src/tools/fetch/state-sources.ts +142 -0
- package/src/tools/fetch/tiger-full.ts +318 -0
- package/src/tools/golden-expand.ts +524 -0
- package/src/tools/golden-promote.ts +304 -0
- package/src/tools/index.ts +24 -0
- package/src/tools/ingest-csv.ts +420 -0
- package/src/tools/jsonl-to-parquet.ts +200 -0
- package/src/tools/lint-shard-vocab.ts +357 -0
- package/src/tools/lint-shard.ts +502 -0
- package/src/tools/overlay-manifest.ts +141 -0
- package/src/tools/shard-kryptonite.ts +131 -0
- package/src/tools/shard-translit.ts +244 -0
- package/src/types.ts +213 -0
- package/src/wof-json.ts +236 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/corpus",
|
|
3
|
-
"version": "7.2.
|
|
3
|
+
"version": "7.2.1",
|
|
4
4
|
"description": "Mailwoman corpus pipeline: BIO-labeled dataset builder for the neural classifier.",
|
|
5
5
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
6
6
|
"repository": {
|
|
@@ -12,55 +12,87 @@
|
|
|
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/src/index.js"
|
|
23
|
-
"types": "./out/src/index.d.ts"
|
|
29
|
+
"types": "./out/src/index.d.ts",
|
|
30
|
+
"default": "./out/src/index.js"
|
|
24
31
|
},
|
|
25
32
|
"./types": {
|
|
26
|
-
"
|
|
27
|
-
"default": "./out/src/types.js"
|
|
28
|
-
"types": "./out/src/types.d.ts"
|
|
33
|
+
"types": "./out/src/types.d.ts",
|
|
34
|
+
"default": "./out/src/types.js"
|
|
29
35
|
},
|
|
30
36
|
"./format": {
|
|
31
|
-
"
|
|
32
|
-
"default": "./out/src/format.js"
|
|
33
|
-
"types": "./out/src/format.d.ts"
|
|
37
|
+
"types": "./out/src/format.d.ts",
|
|
38
|
+
"default": "./out/src/format.js"
|
|
34
39
|
},
|
|
35
40
|
"./license": {
|
|
36
|
-
"
|
|
37
|
-
"default": "./out/src/license.js"
|
|
38
|
-
"types": "./out/src/license.d.ts"
|
|
41
|
+
"types": "./out/src/license.d.ts",
|
|
42
|
+
"default": "./out/src/license.js"
|
|
39
43
|
},
|
|
40
44
|
"./build": {
|
|
41
|
-
"
|
|
42
|
-
"default": "./out/src/build.js"
|
|
43
|
-
"types": "./out/src/build.d.ts"
|
|
45
|
+
"types": "./out/src/build.d.ts",
|
|
46
|
+
"default": "./out/src/build.js"
|
|
44
47
|
},
|
|
45
48
|
"./adapters": {
|
|
46
|
-
"
|
|
47
|
-
"default": "./out/src/adapters/index.js"
|
|
48
|
-
"types": "./out/src/adapters/index.d.ts"
|
|
49
|
+
"types": "./out/src/adapters/index.d.ts",
|
|
50
|
+
"default": "./out/src/adapters/index.js"
|
|
49
51
|
},
|
|
50
52
|
"./tools": {
|
|
51
|
-
"
|
|
52
|
-
"default": "./out/src/tools/index.js"
|
|
53
|
-
"types": "./out/src/tools/index.d.ts"
|
|
53
|
+
"types": "./out/src/tools/index.d.ts",
|
|
54
|
+
"default": "./out/src/tools/index.js"
|
|
54
55
|
}
|
|
55
56
|
},
|
|
56
57
|
"publishConfig": {
|
|
57
|
-
"access": "public"
|
|
58
|
+
"access": "public",
|
|
59
|
+
"exports": {
|
|
60
|
+
"./package.json": "./package.json",
|
|
61
|
+
".": {
|
|
62
|
+
"types": "./out/src/index.d.ts",
|
|
63
|
+
"default": "./out/src/index.js"
|
|
64
|
+
},
|
|
65
|
+
"./types": {
|
|
66
|
+
"types": "./out/src/types.d.ts",
|
|
67
|
+
"default": "./out/src/types.js"
|
|
68
|
+
},
|
|
69
|
+
"./format": {
|
|
70
|
+
"types": "./out/src/format.d.ts",
|
|
71
|
+
"default": "./out/src/format.js"
|
|
72
|
+
},
|
|
73
|
+
"./license": {
|
|
74
|
+
"types": "./out/src/license.d.ts",
|
|
75
|
+
"default": "./out/src/license.js"
|
|
76
|
+
},
|
|
77
|
+
"./build": {
|
|
78
|
+
"types": "./out/src/build.d.ts",
|
|
79
|
+
"default": "./out/src/build.js"
|
|
80
|
+
},
|
|
81
|
+
"./adapters": {
|
|
82
|
+
"types": "./out/src/adapters/index.d.ts",
|
|
83
|
+
"default": "./out/src/adapters/index.js"
|
|
84
|
+
},
|
|
85
|
+
"./tools": {
|
|
86
|
+
"types": "./out/src/tools/index.d.ts",
|
|
87
|
+
"default": "./out/src/tools/index.js"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
58
90
|
},
|
|
59
91
|
"dependencies": {
|
|
60
92
|
"@dsnp/parquetjs": "1.8.8",
|
|
61
|
-
"@mailwoman/codex": "7.2.
|
|
62
|
-
"@mailwoman/core": "7.2.
|
|
63
|
-
"@mailwoman/formatter": "7.2.
|
|
93
|
+
"@mailwoman/codex": "7.2.1",
|
|
94
|
+
"@mailwoman/core": "7.2.1",
|
|
95
|
+
"@mailwoman/formatter": "7.2.1",
|
|
64
96
|
"csv-parse": "^7.0.1",
|
|
65
97
|
"fast-glob": "^3.3.3",
|
|
66
98
|
"fastest-levenshtein": "^1.0.16",
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Adapter framework helpers — the pieces every corpus adapter and the runner share.
|
|
7
|
+
*
|
|
8
|
+
* This file does **not** define `CorpusAdapter` (that lives in `./types.ts`, which is the single
|
|
9
|
+
* canonical schema module). It exposes:
|
|
10
|
+
*
|
|
11
|
+
* - `AdapterRegistry`: a tiny lookup table the CLI + build pipeline use to find adapters by id.
|
|
12
|
+
* - `InMemoryAdapterRegistry`: the default implementation.
|
|
13
|
+
* - `stableSourceID(adapterID, components)`: deterministic content-addressed id for adapters whose
|
|
14
|
+
* source data has no native primary key (CSV, GeoJSON).
|
|
15
|
+
* - `canonicalDedupKey(row)`: normalized signature used to drop near-identical rows during a run.
|
|
16
|
+
* Adapter-internal dedup; cross-adapter dedup is the runner's job.
|
|
17
|
+
* - `streamingSha256()`: thin wrapper around `node:crypto` so the runner can hash JSONL output as it
|
|
18
|
+
* streams (avoids re-reading the shard for the manifest checksum).
|
|
19
|
+
*
|
|
20
|
+
* Everything here is pure (no I/O); side-effecting code goes in `./runner.ts`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash, type Hash } from "node:crypto"
|
|
24
|
+
|
|
25
|
+
import type { ComponentTag } from "@mailwoman/core/types"
|
|
26
|
+
|
|
27
|
+
import type { CanonicalRow, CorpusAdapter } from "./types.ts"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lookup table for corpus adapters.
|
|
31
|
+
*
|
|
32
|
+
* The CLI's `npx mailwoman corpus run <adapter-id>` resolves `<adapter-id>` against this registry; the same registry is
|
|
33
|
+
* iterated by the `corpus build` pipeline. Adapters do not self-register at module load — they're added explicitly so
|
|
34
|
+
* the dependency graph stays traceable.
|
|
35
|
+
*/
|
|
36
|
+
export interface AdapterRegistry {
|
|
37
|
+
/** Add an adapter. Throws if `adapter.id` is already registered. */
|
|
38
|
+
register(adapter: CorpusAdapter): void
|
|
39
|
+
|
|
40
|
+
/** Return the adapter for `id`, or `undefined`. */
|
|
41
|
+
get(id: string): CorpusAdapter | undefined
|
|
42
|
+
|
|
43
|
+
/** All registered adapters, in insertion order. */
|
|
44
|
+
list(): readonly CorpusAdapter[]
|
|
45
|
+
|
|
46
|
+
/** Convenience: ids only, in insertion order. */
|
|
47
|
+
ids(): readonly string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Default in-memory registry. The runner constructs one per invocation; the CLI re-uses a shared singleton
|
|
52
|
+
* (`defaultAdapterRegistry`) populated by `./adapters/index.ts` as adapters come online.
|
|
53
|
+
*/
|
|
54
|
+
export class InMemoryAdapterRegistry implements AdapterRegistry {
|
|
55
|
+
#byID = new Map<string, CorpusAdapter>()
|
|
56
|
+
|
|
57
|
+
register(adapter: CorpusAdapter): void {
|
|
58
|
+
if (this.#byID.has(adapter.id)) {
|
|
59
|
+
throw new Error(`AdapterRegistry: id ${JSON.stringify(adapter.id)} already registered`)
|
|
60
|
+
}
|
|
61
|
+
this.#byID.set(adapter.id, adapter)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get(id: string): CorpusAdapter | undefined {
|
|
65
|
+
return this.#byID.get(id)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
list(): readonly CorpusAdapter[] {
|
|
69
|
+
return Array.from(this.#byID.values())
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ids(): readonly string[] {
|
|
73
|
+
return Array.from(this.#byID.keys())
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Process-wide default registry. Populated by `./adapters/index.ts` as adapters are built; imported by the CLI. Tests
|
|
79
|
+
* should construct their own `InMemoryAdapterRegistry` to avoid cross-test pollution.
|
|
80
|
+
*/
|
|
81
|
+
export const defaultAdapterRegistry = new InMemoryAdapterRegistry()
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Deterministic content-addressed source id.
|
|
85
|
+
*
|
|
86
|
+
* For adapters whose upstream source has no native primary key (CSV rows, GeoJSON features), the runner expects a
|
|
87
|
+
* stable id so dedup, holdout manifests, and resumability work across reruns. This helper produces one by hashing the
|
|
88
|
+
* adapter id and a canonical serialization of the components dict (keys sorted, values verbatim).
|
|
89
|
+
*
|
|
90
|
+
* Output format: `<adapterID>-<first-12-hex-chars-of-sha256>`. 48 bits of entropy is enough for ~17M rows per adapter
|
|
91
|
+
* before the expected collision count exceeds 1 (birthday paradox); adapters with more rows should extend the prefix
|
|
92
|
+
* length.
|
|
93
|
+
*/
|
|
94
|
+
export function stableSourceID(adapterID: string, components: Partial<Record<ComponentTag, string>>): string {
|
|
95
|
+
const sortedKeys = Object.keys(components).sort() as ComponentTag[]
|
|
96
|
+
const payload = sortedKeys.map((k) => `${k}=${components[k] ?? ""}`).join("\x1f")
|
|
97
|
+
const digest = createHash("sha256").update(adapterID).update("\x1e").update(payload).digest("hex")
|
|
98
|
+
|
|
99
|
+
return `${adapterID}-${digest.slice(0, 12)}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Canonical dedup key for a row.
|
|
104
|
+
*
|
|
105
|
+
* Two rows that share this key are treated as duplicates and only the first wins. The key is built from `country`, the
|
|
106
|
+
* sorted `components` dict, and a normalized `raw` (lower-cased, whitespace collapsed). License and provenance fields
|
|
107
|
+
* are intentionally excluded so the same address from multiple adapters is recognized as a duplicate.
|
|
108
|
+
*
|
|
109
|
+
* Synthetic rows are never deduplicated against natural rows: `synth.method` is folded into the key when present,
|
|
110
|
+
* ensuring each augmentation variant survives.
|
|
111
|
+
*/
|
|
112
|
+
export function canonicalDedupKey(row: CanonicalRow): string {
|
|
113
|
+
const sortedKeys = Object.keys(row.components).sort() as ComponentTag[]
|
|
114
|
+
const compPart = sortedKeys.map((k) => `${k}=${row.components[k] ?? ""}`).join("\x1f")
|
|
115
|
+
const rawNorm = row.raw.toLowerCase().replace(/\s+/g, " ").trim()
|
|
116
|
+
const synthPart = row.synth ? `\x1e${row.synth.method}` : ""
|
|
117
|
+
|
|
118
|
+
return `${row.country}\x1e${rawNorm}\x1e${compPart}${synthPart}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Streaming SHA-256 hasher.
|
|
123
|
+
*
|
|
124
|
+
* The runner feeds every JSONL line into one of these so the per-shard checksum can be recorded in `MANIFEST.json`
|
|
125
|
+
* without a second pass over the shard. Implementation is a one-line wrapper, but giving it a name keeps the runner's
|
|
126
|
+
* hash-tracking intent obvious.
|
|
127
|
+
*/
|
|
128
|
+
export interface StreamingHasher {
|
|
129
|
+
update(chunk: string | Uint8Array): void
|
|
130
|
+
digest(): string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Default `StreamingHasher` (SHA-256, hex). */
|
|
134
|
+
export function streamingSha256(): StreamingHasher {
|
|
135
|
+
const h: Hash = createHash("sha256")
|
|
136
|
+
let finalized = false
|
|
137
|
+
let digestHex = ""
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
update(chunk) {
|
|
141
|
+
if (finalized) throw new Error("streamingSha256: update() called after digest()")
|
|
142
|
+
h.update(typeof chunk === "string" ? chunk : chunk)
|
|
143
|
+
},
|
|
144
|
+
digest() {
|
|
145
|
+
if (!finalized) {
|
|
146
|
+
digestHex = h.digest("hex")
|
|
147
|
+
finalized = true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return digestHex
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `ban`: Base Adresse Nationale CSV adapter (FR street-level).
|
|
7
|
+
*
|
|
8
|
+
* Input: a CSV dump from `adresse.data.gouv.fr` (semicolon-separated, ~25M rows nationally). The
|
|
9
|
+
* adapter only reads the small set of columns needed for the corpus:
|
|
10
|
+
*
|
|
11
|
+
* - `numero` → `house_number`
|
|
12
|
+
* - `rep` → repetition index ("bis", "ter") appended to house_number
|
|
13
|
+
* - `nom_voie` → `street` (full road name; includes the prefix "Rue", "Avenue", etc.)
|
|
14
|
+
* - `code_postal` → `postcode`
|
|
15
|
+
* - `nom_commune` → `locality`
|
|
16
|
+
*
|
|
17
|
+
* `region` and `country` are not in BAN. The adapter stamps `country: "FR"` on every row; region is
|
|
18
|
+
* left for the wof-postalcode + wof-admin cross-reference at corpus build time (a future pass;
|
|
19
|
+
* for Phase 1 the row's region is simply absent).
|
|
20
|
+
*
|
|
21
|
+
* License: the official BAN (adresse.data.gouv.fr) is DUAL-licensed — Licence Ouverte 2.0 (Etalab,
|
|
22
|
+
* attribution-only) OR ODbL (share-alike). We ELECT Licence Ouverte 2.0 (issue #26 Tier B:
|
|
23
|
+
* allowed for training with attribution; the ODbL option's share-alike obligation would defeat
|
|
24
|
+
* the proprietary-weights goal). Stamped onto every row as `Licence Ouverte 2.0` — NOT the older
|
|
25
|
+
* conservative `ODbL-1.0` label, which wrongly read as Tier-C-denied in the corpus license audit.
|
|
26
|
+
* The model card MUST carry the BAN attribution (Tier B obligation).
|
|
27
|
+
*
|
|
28
|
+
* The adapter is streaming-aware: it uses `csv-parse` in streaming mode so a 25M-row dump never
|
|
29
|
+
* sits in memory. Honors `opts.limit` for fixture / smoke runs, `opts.signal` for cancellation,
|
|
30
|
+
* and `opts.country` for a self-consistency check (errors if country !== FR).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { createReadStream } from "node:fs"
|
|
34
|
+
|
|
35
|
+
import { parse as csvParse } from "csv-parse"
|
|
36
|
+
|
|
37
|
+
import { stableSourceID } from "../../adapter.ts"
|
|
38
|
+
import { reconcileComponents } from "../../format.ts"
|
|
39
|
+
import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "../../types.ts"
|
|
40
|
+
import { decomposeFrStreet } from "./street-decompose.ts"
|
|
41
|
+
|
|
42
|
+
export const BAN_ADAPTER_ID = "ban"
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Subset of BAN CSV columns the adapter consults. Everything else is ignored; declaring the shape explicitly catches
|
|
46
|
+
* column-name drift early if BAN evolves its schema.
|
|
47
|
+
*/
|
|
48
|
+
interface BanRow {
|
|
49
|
+
id: string
|
|
50
|
+
numero: string
|
|
51
|
+
rep: string
|
|
52
|
+
nom_voie: string
|
|
53
|
+
code_postal: string
|
|
54
|
+
nom_commune: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compose `house_number` from `numero` + `rep`. BAN uses `rep` for repetition indices ("bis", "ter", "quater") that
|
|
59
|
+
* follow the house number. Result: `"10 bis"`, `"45"`, etc.
|
|
60
|
+
*/
|
|
61
|
+
function composeHouseNumber(numero: string, rep: string): string {
|
|
62
|
+
const n = numero.trim()
|
|
63
|
+
const r = rep.trim()
|
|
64
|
+
|
|
65
|
+
if (!n) return ""
|
|
66
|
+
|
|
67
|
+
return r ? `${n} ${r}` : n
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Compose the raw FR-style address line. Two common BAN-derived shapes:
|
|
72
|
+
*
|
|
73
|
+
* "10 bis Avenue des Champs-Élysées, 75008 Paris" "45 Cours Lafayette, 69003 Lyon"
|
|
74
|
+
*
|
|
75
|
+
* FR convention puts postcode on the same line as the locality, comma-separated from the street. The adapter renders
|
|
76
|
+
* that directly rather than relying on OpenCage's template — BAN already gives us the canonical FR strings; the
|
|
77
|
+
* template would round-trip identically.
|
|
78
|
+
*/
|
|
79
|
+
function composeRaw(house: string, street: string, postcode: string, locality: string): string {
|
|
80
|
+
const parts: string[] = []
|
|
81
|
+
const streetPart = [house, street].filter(Boolean).join(" ").trim()
|
|
82
|
+
|
|
83
|
+
if (streetPart) {
|
|
84
|
+
parts.push(streetPart)
|
|
85
|
+
}
|
|
86
|
+
const cityPart = [postcode, locality].filter(Boolean).join(" ").trim()
|
|
87
|
+
|
|
88
|
+
if (cityPart) {
|
|
89
|
+
parts.push(cityPart)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return parts.join(", ").replace(/\s+/g, " ").trim()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createBanAdapter(): CorpusAdapter {
|
|
96
|
+
return {
|
|
97
|
+
id: BAN_ADAPTER_ID,
|
|
98
|
+
defaultLicense: "Licence Ouverte 2.0",
|
|
99
|
+
description: "Base Adresse Nationale (FR): house-number-level street addresses (~25M rows).",
|
|
100
|
+
|
|
101
|
+
async *rows(opts: AdapterOptions): AsyncIterable<CanonicalRow> {
|
|
102
|
+
if (opts.country && opts.country !== "FR") {
|
|
103
|
+
throw new Error(`ban adapter: only FR supported, got country=${opts.country}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const stream = createReadStream(opts.inputPath, { encoding: "utf8" })
|
|
107
|
+
const parser = stream.pipe(
|
|
108
|
+
csvParse({
|
|
109
|
+
delimiter: ";",
|
|
110
|
+
columns: true,
|
|
111
|
+
skip_empty_lines: true,
|
|
112
|
+
relax_quotes: true,
|
|
113
|
+
relax_column_count: true,
|
|
114
|
+
})
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
let emitted = 0
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
for await (const record of parser as AsyncIterable<BanRow>) {
|
|
121
|
+
if (opts.signal?.aborted) break
|
|
122
|
+
|
|
123
|
+
if (opts.limit !== undefined && emitted >= opts.limit) break
|
|
124
|
+
|
|
125
|
+
const house = composeHouseNumber(record.numero ?? "", record.rep ?? "")
|
|
126
|
+
const street = (record.nom_voie ?? "").trim()
|
|
127
|
+
const postcode = (record.code_postal ?? "").trim()
|
|
128
|
+
const locality = (record.nom_commune ?? "").trim()
|
|
129
|
+
|
|
130
|
+
if (!street || !locality) continue
|
|
131
|
+
|
|
132
|
+
if (!house && !postcode) continue
|
|
133
|
+
|
|
134
|
+
const decomposed = decomposeFrStreet(street)
|
|
135
|
+
|
|
136
|
+
const components: CanonicalRow["components"] = {}
|
|
137
|
+
|
|
138
|
+
if (house) {
|
|
139
|
+
components.house_number = house
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (decomposed.prefix) {
|
|
143
|
+
components.street_prefix = decomposed.prefix
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (decomposed.street) {
|
|
147
|
+
components.street = decomposed.street
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (postcode) {
|
|
151
|
+
components.postcode = postcode
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (locality) {
|
|
155
|
+
components.locality = locality
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const raw = composeRaw(house, street, postcode, locality)
|
|
159
|
+
|
|
160
|
+
if (!raw) continue
|
|
161
|
+
|
|
162
|
+
const aligned = reconcileComponents(components, raw)
|
|
163
|
+
|
|
164
|
+
if (Object.keys(aligned).length === 0) continue
|
|
165
|
+
|
|
166
|
+
const sourceID = record.id?.trim()
|
|
167
|
+
? `${BAN_ADAPTER_ID}-${record.id.trim()}`
|
|
168
|
+
: stableSourceID(BAN_ADAPTER_ID, aligned)
|
|
169
|
+
|
|
170
|
+
yield {
|
|
171
|
+
raw,
|
|
172
|
+
components: aligned,
|
|
173
|
+
country: "FR",
|
|
174
|
+
locale: "fr-FR",
|
|
175
|
+
source: BAN_ADAPTER_ID,
|
|
176
|
+
source_id: sourceID,
|
|
177
|
+
corpus_version: "",
|
|
178
|
+
license: "Licence Ouverte 2.0",
|
|
179
|
+
}
|
|
180
|
+
emitted++
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
stream.destroy()
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const banAdapter = createBanAdapter()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Decompose a French street name into Stage 3 components. French convention puts the street type as
|
|
7
|
+
* a leading word: "Rue de Rivoli", "Avenue des Champs-Élysées", "Bd Voltaire".
|
|
8
|
+
*
|
|
9
|
+
* The street type becomes street_prefix in our schema. The remaining tokens form the street name.
|
|
10
|
+
*
|
|
11
|
+
* Examples: "Rue de Rivoli" → { prefix: "Rue", street: "de Rivoli" } "Avenue des Champs-Élysées" →
|
|
12
|
+
* { prefix: "Avenue", street: "des Champs-Élysées" } "Boulevard Voltaire" → { prefix:
|
|
13
|
+
* "Boulevard", street: "Voltaire" }
|
|
14
|
+
*
|
|
15
|
+
* Sources street types from `core/data/libpostal/dictionaries/fr/street_types.txt`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from "node:fs"
|
|
19
|
+
import { resolve } from "node:path"
|
|
20
|
+
|
|
21
|
+
import { resourceDictionaryPath } from "@mailwoman/core/utils"
|
|
22
|
+
|
|
23
|
+
function loadDictionary(filename: string): Set<string> {
|
|
24
|
+
const candidates = [
|
|
25
|
+
resourceDictionaryPath("libpostal", "fr", filename),
|
|
26
|
+
resourceDictionaryPath("libpostal", "fr", filename),
|
|
27
|
+
resolve(process.cwd(), "core/data/libpostal/dictionaries/fr", filename),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
for (const path of candidates) {
|
|
31
|
+
try {
|
|
32
|
+
const text = readFileSync(path, "utf8")
|
|
33
|
+
const set = new Set<string>()
|
|
34
|
+
|
|
35
|
+
for (const line of text.split("\n")) {
|
|
36
|
+
const trimmed = line.trim()
|
|
37
|
+
|
|
38
|
+
if (!trimmed || trimmed.startsWith("#")) continue
|
|
39
|
+
|
|
40
|
+
for (const form of trimmed.split("|")) {
|
|
41
|
+
const f = form.trim().toLowerCase()
|
|
42
|
+
|
|
43
|
+
if (f) {
|
|
44
|
+
set.add(f)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return set
|
|
50
|
+
} catch {
|
|
51
|
+
// try next
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Could not load FR libpostal dictionary: ${filename}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const STREET_TYPES_FR = loadDictionary("street_types.txt")
|
|
58
|
+
|
|
59
|
+
export interface DecomposedFrStreet {
|
|
60
|
+
prefix: string | null
|
|
61
|
+
street: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Decompose a French street name into prefix (leading type word) and street name.
|
|
66
|
+
*
|
|
67
|
+
* If the first 1-2 tokens match a known street type (allowing for multi-word like "ancien chemin"), they become the
|
|
68
|
+
* prefix. Returns `{ prefix: null, street: original }` if no match.
|
|
69
|
+
*/
|
|
70
|
+
export function decomposeFrStreet(fullname: string): DecomposedFrStreet {
|
|
71
|
+
const trimmed = fullname.trim()
|
|
72
|
+
|
|
73
|
+
if (!trimmed) return { prefix: null, street: "" }
|
|
74
|
+
|
|
75
|
+
const tokens = trimmed.split(/\s+/)
|
|
76
|
+
|
|
77
|
+
if (tokens.length < 2) return { prefix: null, street: trimmed }
|
|
78
|
+
|
|
79
|
+
const norm = (s: string) => s.toLowerCase().replace(/[.,;]$/, "")
|
|
80
|
+
|
|
81
|
+
// Try 2-word prefix first (e.g. "ancien chemin")
|
|
82
|
+
if (tokens.length >= 3) {
|
|
83
|
+
const twoWord = norm(tokens[0]!) + " " + norm(tokens[1]!)
|
|
84
|
+
|
|
85
|
+
if (STREET_TYPES_FR.has(twoWord)) {
|
|
86
|
+
return { prefix: tokens.slice(0, 2).join(" "), street: tokens.slice(2).join(" ") }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Then try 1-word prefix
|
|
91
|
+
const first = norm(tokens[0]!)
|
|
92
|
+
|
|
93
|
+
if (STREET_TYPES_FR.has(first)) {
|
|
94
|
+
return { prefix: tokens[0]!, street: tokens.slice(1).join(" ") }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { prefix: null, street: trimmed }
|
|
98
|
+
}
|