@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.
Files changed (103) hide show
  1. package/package.json +59 -27
  2. package/src/adapter.ts +153 -0
  3. package/src/adapters/ban/adapter.ts +189 -0
  4. package/src/adapters/ban/street-decompose.ts +98 -0
  5. package/src/adapters/fcc-bdc/adapter.ts +188 -0
  6. package/src/adapters/geonames/adapter.ts +185 -0
  7. package/src/adapters/geonames-postal/adapter.ts +111 -0
  8. package/src/adapters/gnaf/adapter.ts +139 -0
  9. package/src/adapters/gnaf/assemble.ts +200 -0
  10. package/src/adapters/index.ts +135 -0
  11. package/src/adapters/openaddresses/adapter.ts +237 -0
  12. package/src/adapters/overture/adapter.ts +149 -0
  13. package/src/adapters/state-hi-schools/adapter.ts +171 -0
  14. package/src/adapters/state-ia-contractors/adapter.ts +148 -0
  15. package/src/adapters/state-ny-notaries/adapter.ts +169 -0
  16. package/src/adapters/state-tx-notaries/adapter.ts +160 -0
  17. package/src/adapters/synth-po-box/adapter.ts +197 -0
  18. package/src/adapters/tiger/adapter.ts +232 -0
  19. package/src/adapters/tiger/street-decompose.ts +120 -0
  20. package/src/adapters/usgov-hrsa-fqhc/adapter.ts +189 -0
  21. package/src/adapters/usgov-imls-pls/adapter.ts +152 -0
  22. package/src/adapters/usgov-irs-bmf/adapter.ts +155 -0
  23. package/src/adapters/usgov-nad/adapter.ts +338 -0
  24. package/src/adapters/usgov-nppes/adapter.ts +169 -0
  25. package/src/adapters/usgov-samhsa-treatment-locator/adapter.ts +211 -0
  26. package/src/adapters/wof-admin-jp/adapter.ts +176 -0
  27. package/src/adapters/wof-admin-json/adapter.ts +280 -0
  28. package/src/adapters/wof-postalcode-json/adapter.ts +209 -0
  29. package/src/align.ts +317 -0
  30. package/src/build.ts +348 -0
  31. package/src/codex/us-fips-state.ts +109 -0
  32. package/src/format.ts +14 -0
  33. package/src/golden.ts +147 -0
  34. package/src/index.ts +28 -0
  35. package/src/license.ts +48 -0
  36. package/src/parquet-wrapper/index.ts +9 -0
  37. package/src/parquet-wrapper/reader.ts +67 -0
  38. package/src/parquet-wrapper/schema.ts +80 -0
  39. package/src/parquet-wrapper/writer.ts +102 -0
  40. package/src/parquet.ts +360 -0
  41. package/src/runner.ts +283 -0
  42. package/src/shard-recipes/anchor-absorption.ts +86 -0
  43. package/src/shard-recipes/boundary-stress.ts +87 -0
  44. package/src/shard-recipes/country-balanced.ts +471 -0
  45. package/src/shard-recipes/cz-pcfirst-preposition.ts +83 -0
  46. package/src/shard-recipes/fr-admin-split.ts +212 -0
  47. package/src/shard-recipes/fr-bare-street.ts +85 -0
  48. package/src/shard-recipes/fr-fragment.ts +304 -0
  49. package/src/shard-recipes/fr-order.ts +283 -0
  50. package/src/shard-recipes/german.ts +206 -0
  51. package/src/shard-recipes/house-venue.ts +78 -0
  52. package/src/shard-recipes/index.ts +76 -0
  53. package/src/shard-recipes/intersection.ts +617 -0
  54. package/src/shard-recipes/locale.ts +415 -0
  55. package/src/shard-recipes/nl-postcode.ts +105 -0
  56. package/src/shard-recipes/no-fragment.ts +206 -0
  57. package/src/shard-recipes/no-street-led.ts +130 -0
  58. package/src/shard-recipes/no-street.ts +78 -0
  59. package/src/shard-recipes/po-box-cedex.ts +853 -0
  60. package/src/shard-recipes/po-box.ts +111 -0
  61. package/src/shard-recipes/scaffold.ts +187 -0
  62. package/src/shard-recipes/si-bare-village.ts +88 -0
  63. package/src/shard-recipes/street-affix.ts +549 -0
  64. package/src/shard-recipes/street-bare.ts +90 -0
  65. package/src/shard-recipes/street.ts +74 -0
  66. package/src/shard-recipes/unit.ts +315 -0
  67. package/src/split.ts +251 -0
  68. package/src/synthesize-anchor-absorption.ts +263 -0
  69. package/src/synthesize-boundary-stress.ts +504 -0
  70. package/src/synthesize-german.ts +227 -0
  71. package/src/synthesize-house-venue.ts +192 -0
  72. package/src/synthesize-intersection.ts +205 -0
  73. package/src/synthesize-no-street.ts +341 -0
  74. package/src/synthesize-po-box.ts +307 -0
  75. package/src/synthesize-street.ts +264 -0
  76. package/src/synthesize.ts +872 -0
  77. package/src/tokenize.ts +68 -0
  78. package/src/tools/align-shard.ts +67 -0
  79. package/src/tools/audit.ts +352 -0
  80. package/src/tools/corpus-stats.ts +183 -0
  81. package/src/tools/fetch/ban.ts +288 -0
  82. package/src/tools/fetch/download.ts +119 -0
  83. package/src/tools/fetch/hrsa.ts +65 -0
  84. package/src/tools/fetch/imls-pls.ts +162 -0
  85. package/src/tools/fetch/index.ts +121 -0
  86. package/src/tools/fetch/nad.ts +304 -0
  87. package/src/tools/fetch/nppes.ts +177 -0
  88. package/src/tools/fetch/openaddresses.ts +380 -0
  89. package/src/tools/fetch/state-hi-schools.ts +217 -0
  90. package/src/tools/fetch/state-sources.ts +142 -0
  91. package/src/tools/fetch/tiger-full.ts +318 -0
  92. package/src/tools/golden-expand.ts +524 -0
  93. package/src/tools/golden-promote.ts +304 -0
  94. package/src/tools/index.ts +24 -0
  95. package/src/tools/ingest-csv.ts +420 -0
  96. package/src/tools/jsonl-to-parquet.ts +200 -0
  97. package/src/tools/lint-shard-vocab.ts +357 -0
  98. package/src/tools/lint-shard.ts +502 -0
  99. package/src/tools/overlay-manifest.ts +141 -0
  100. package/src/tools/shard-kryptonite.ts +131 -0
  101. package/src/tools/shard-translit.ts +244 -0
  102. package/src/types.ts +213 -0
  103. 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.0",
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
- "node": "./src/index.ts",
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
- "node": "./src/types.ts",
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
- "node": "./src/format.ts",
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
- "node": "./src/license.ts",
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
- "node": "./src/build.ts",
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
- "node": "./src/adapters/index.ts",
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
- "node": "./src/tools/index.ts",
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.0",
62
- "@mailwoman/core": "7.2.0",
63
- "@mailwoman/formatter": "7.2.0",
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
+ }