Spaces:
Running
Match every name a place has, without case or accents
Browse filesThree of the misses in #12 were in the index all along, under another
spelling: Chittagong is filed as Chattogram District, Da Nang as Đà Nẵng,
Bogota as Bogotá, D.c. Search compared the query with the primary and
English names only, and Jaro-Winkler rewards a shared prefix, so a
near-homograph on another continent won.
A record is now matched under every name Overture holds for it: its own,
its translations (names.common) and its alternate, short and official
names (names.rules), each compared after lowercasing and stripping
accents. matched_name reports the spelling that hit. Natural Earth, which
has no alternates, is matched on its own and English names as before,
folded the same way.
Building and folding those names on every query would make search about
twice as slow as main, so normalize_geodata stores them in a
search_names column, and search reads it where every file has one. A raw
Overture download still works; it builds the names on the fly. On the
2026-08-19.0 release a fuzzy ids_only search now takes 0.19s against
main's 0.82s, and a full-geometry one 0.74s. The column adds 0.16 GB to
divisions_area and 0.22 GB to localities.
The natural-language pipeline sees better candidates, but still of the
subtypes its model was trained on; the name it is shown is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- dataset/scripts/normalize_geodata.py +8 -3
- src/gazet/search.py +113 -49
- tests/conftest.py +33 -4
- tests/test_search.py +41 -0
|
@@ -18,6 +18,10 @@ Cities and towns (Overture ``locality`` and ``localadmin``) go to their own
|
|
| 18 |
file. Fuzzy search reads it; the training pipeline and the natural-language
|
| 19 |
search read only ``divisions_area``, because the model was not trained on
|
| 20 |
those subtypes.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
"""
|
| 22 |
|
| 23 |
from pathlib import Path
|
|
@@ -25,6 +29,7 @@ from pathlib import Path
|
|
| 25 |
import duckdb
|
| 26 |
|
| 27 |
from gazet.config import _DATA_DIR
|
|
|
|
| 28 |
|
| 29 |
#: The subtypes the natural-language model was trained on.
|
| 30 |
TRAINED_SUBTYPES = ("country", "region", "county")
|
|
@@ -63,9 +68,9 @@ def normalize_geodata(output_root: Path | None = None) -> dict[str, str]:
|
|
| 63 |
con.execute(
|
| 64 |
f"""
|
| 65 |
COPY (
|
| 66 |
-
SELECT
|
| 67 |
-
ST_GeomFromWKB(ST_AsWKB(geometry)) AS geometry
|
| 68 |
-
|
| 69 |
FROM read_parquet('{root / "overture/divisions_area/*.parquet"}')
|
| 70 |
WHERE geometry IS NOT NULL
|
| 71 |
AND subtype IN {subtypes}
|
|
|
|
| 18 |
file. Fuzzy search reads it; the training pipeline and the natural-language
|
| 19 |
search read only ``divisions_area``, because the model was not trained on
|
| 20 |
those subtypes.
|
| 21 |
+
|
| 22 |
+
Both Overture files also store ``search_names``: every name a record
|
| 23 |
+
answers to, folded for comparison, so fuzzy search does not rebuild and
|
| 24 |
+
fold them on every query.
|
| 25 |
"""
|
| 26 |
|
| 27 |
from pathlib import Path
|
|
|
|
| 29 |
import duckdb
|
| 30 |
|
| 31 |
from gazet.config import _DATA_DIR
|
| 32 |
+
from gazet.search import DIVISIONS_AREA_SEARCH_NAMES, SEARCH_NAMES_COLUMN
|
| 33 |
|
| 34 |
#: The subtypes the natural-language model was trained on.
|
| 35 |
TRAINED_SUBTYPES = ("country", "region", "county")
|
|
|
|
| 68 |
con.execute(
|
| 69 |
f"""
|
| 70 |
COPY (
|
| 71 |
+
SELECT
|
| 72 |
+
* REPLACE (ST_GeomFromWKB(ST_AsWKB(geometry)) AS geometry),
|
| 73 |
+
{DIVISIONS_AREA_SEARCH_NAMES} AS {SEARCH_NAMES_COLUMN}
|
| 74 |
FROM read_parquet('{root / "overture/divisions_area/*.parquet"}')
|
| 75 |
WHERE geometry IS NOT NULL
|
| 76 |
AND subtype IN {subtypes}
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import logging
|
| 2 |
-
from functools import partial
|
| 3 |
|
| 4 |
import duckdb
|
| 5 |
import pandas as pd
|
|
@@ -30,6 +30,67 @@ def _readable(expr: str) -> str:
|
|
| 30 |
return f"NULLIF(trim({expr}), '')"
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def _divisions_area_paths(include_localities: bool) -> str | list[str]:
|
| 34 |
"""The divisions_area file, and the localities beside it where asked for.
|
| 35 |
|
|
@@ -46,6 +107,7 @@ def simple_fuzzy_search(
|
|
| 46 |
path: str | list[str],
|
| 47 |
source: str,
|
| 48 |
place: Place,
|
|
|
|
| 49 |
name_expr: str = PRIMARY_NAME,
|
| 50 |
english_name_expr: str | None = None,
|
| 51 |
extra_select: str = "",
|
|
@@ -56,12 +118,14 @@ def simple_fuzzy_search(
|
|
| 56 |
) -> pd.DataFrame:
|
| 57 |
"""Jaro-Winkler similarity search using only the place name.
|
| 58 |
|
| 59 |
-
Each record is matched under
|
| 60 |
-
``
|
| 61 |
-
|
| 62 |
-
English exonym ("Copenhagen") as well as by its local name
|
| 63 |
-
Kommune")
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
|
| 66 |
``name`` is the English name where there is one and the record's own name
|
| 67 |
otherwise — the expression :func:`fetch_by_id` uses too, so a candidate
|
|
@@ -80,17 +144,13 @@ def simple_fuzzy_search(
|
|
| 80 |
GeoJSON serialization), for lightweight context (e.g. ``ids_only``
|
| 81 |
responses).
|
| 82 |
"""
|
| 83 |
-
params = [path, place.place, place.place, place.place, place.place, limit]
|
| 84 |
-
|
| 85 |
english_expr = english_name_expr or "CAST(NULL AS VARCHAR)"
|
| 86 |
extra_clause = f", {extra_select}" if extra_select else ""
|
| 87 |
-
geometry_clause = ", ST_AsGeoJSON(geometry) AS geometry" if include_geometry else ""
|
| 88 |
bbox_clause = f", {bbox_expr} AS bbox" if include_bbox else ""
|
| 89 |
-
# Geometry
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
)
|
| 94 |
rel = con.execute(
|
| 95 |
f"""
|
| 96 |
WITH named AS (
|
|
@@ -102,26 +162,35 @@ def simple_fuzzy_search(
|
|
| 102 |
region,
|
| 103 |
admin_level,
|
| 104 |
is_land,
|
| 105 |
-
is_territorial{extra_clause}{
|
| 106 |
{_readable(name_expr)} AS primary_name,
|
| 107 |
-
{_readable(english_expr)} AS english_name
|
| 108 |
-
|
|
|
|
| 109 |
),
|
| 110 |
scored AS (
|
| 111 |
SELECT
|
| 112 |
*,
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
| 123 |
FROM named
|
| 124 |
-
WHERE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
)
|
| 126 |
SELECT
|
| 127 |
id,
|
|
@@ -132,22 +201,14 @@ def simple_fuzzy_search(
|
|
| 132 |
region,
|
| 133 |
admin_level,
|
| 134 |
is_land,
|
| 135 |
-
is_territorial{extra_clause}{
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
> (primary_substring, primary_similarity)
|
| 141 |
-
THEN english_name
|
| 142 |
-
ELSE COALESCE(primary_name, english_name)
|
| 143 |
-
END AS matched_name,
|
| 144 |
-
GREATEST(primary_similarity, english_similarity) AS similarity,
|
| 145 |
-
primary_substring OR english_substring AS is_substring_match
|
| 146 |
-
FROM scored
|
| 147 |
ORDER BY is_substring_match DESC, similarity DESC, admin_level ASC
|
| 148 |
-
LIMIT ?
|
| 149 |
""",
|
| 150 |
-
|
| 151 |
)
|
| 152 |
df = rel.fetchdf()
|
| 153 |
df.insert(0, "source", source)
|
|
@@ -168,20 +229,22 @@ def search_divisions_area(
|
|
| 168 |
) -> pd.DataFrame:
|
| 169 |
"""Fuzzy-match a place against divisions_area (Overture admin boundaries).
|
| 170 |
|
| 171 |
-
Matched on ``names.primary``
|
| 172 |
-
divisions_area rows have no English common name and
|
| 173 |
-
every search if English were the only match field,
|
| 174 |
-
on its own leaves a place unreachable by the
|
| 175 |
-
asked for.
|
| 176 |
|
| 177 |
``include_localities`` searches cities and towns as well. Only the fuzzy
|
| 178 |
endpoint passes it; see :func:`_divisions_area_paths`.
|
| 179 |
"""
|
|
|
|
| 180 |
return simple_fuzzy_search(
|
| 181 |
con,
|
| 182 |
-
|
| 183 |
"divisions_area",
|
| 184 |
place,
|
|
|
|
| 185 |
english_name_expr=DIVISIONS_AREA_ENGLISH_NAME,
|
| 186 |
extra_select="division_id",
|
| 187 |
limit=limit,
|
|
@@ -204,6 +267,7 @@ def search_natural_earth(
|
|
| 204 |
NATURAL_EARTH_PATH,
|
| 205 |
"natural_earth",
|
| 206 |
place,
|
|
|
|
| 207 |
english_name_expr=NATURAL_EARTH_ENGLISH_NAME,
|
| 208 |
limit=limit,
|
| 209 |
include_geometry=include_geometry,
|
|
|
|
| 1 |
import logging
|
| 2 |
+
from functools import cache, partial
|
| 3 |
|
| 4 |
import duckdb
|
| 5 |
import pandas as pd
|
|
|
|
| 30 |
return f"NULLIF(trim({expr}), '')"
|
| 31 |
|
| 32 |
|
| 33 |
+
def _folded(expr: str) -> str:
|
| 34 |
+
"""SQL that compares a name without case or accents: "Bogota" is "Bogotá"."""
|
| 35 |
+
return f"lower(strip_accents({expr}))"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def search_names_sql(
|
| 39 |
+
name_expr: str, english_name_expr: str, alternate_names_expr: str | None = None
|
| 40 |
+
) -> str:
|
| 41 |
+
"""SQL for every name a record answers to, each beside its folded form.
|
| 42 |
+
|
| 43 |
+
The record's own name comes first and its English one second, so they
|
| 44 |
+
win a tie; the alternates follow with exact repeats dropped. Blank names
|
| 45 |
+
are left out.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
def entry(name: str) -> str:
|
| 49 |
+
return f"{{'name': trim({name}), 'folded': {_folded(f'trim({name})')}}}"
|
| 50 |
+
|
| 51 |
+
alternates = (
|
| 52 |
+
f"list_distinct(list_transform({alternate_names_expr}, n -> {entry('n')}))"
|
| 53 |
+
if alternate_names_expr
|
| 54 |
+
else "[]"
|
| 55 |
+
)
|
| 56 |
+
return (
|
| 57 |
+
f"list_filter(list_concat([{entry(name_expr)}, {entry(english_name_expr)}], "
|
| 58 |
+
f"{alternates}), s -> NULLIF(s.name, '') IS NOT NULL)"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
#: The column normalize_geodata stores search_names_sql's result in, so a
|
| 63 |
+
#: search reads it instead of rebuilding and folding every name per query.
|
| 64 |
+
SEARCH_NAMES_COLUMN = "search_names"
|
| 65 |
+
#: Overture's translations (``names.common``) and its alternate, short and
|
| 66 |
+
#: official names (``names.rules``).
|
| 67 |
+
DIVISIONS_AREA_SEARCH_NAMES = search_names_sql(
|
| 68 |
+
PRIMARY_NAME,
|
| 69 |
+
DIVISIONS_AREA_ENGLISH_NAME,
|
| 70 |
+
"list_concat(COALESCE(map_values(names.common), []), "
|
| 71 |
+
"COALESCE(list_transform(names.rules, r -> r.value), []))",
|
| 72 |
+
)
|
| 73 |
+
NATURAL_EARTH_SEARCH_NAMES = search_names_sql(PRIMARY_NAME, NATURAL_EARTH_ENGLISH_NAME)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@cache
|
| 77 |
+
def _stores_search_names(path: str) -> bool:
|
| 78 |
+
"""Whether a parquet file (or glob) carries the stored search names."""
|
| 79 |
+
columns = duckdb.connect().execute("DESCRIBE SELECT * FROM read_parquet(?)", [path])
|
| 80 |
+
return SEARCH_NAMES_COLUMN in {row[0] for row in columns.fetchall()}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _search_names(path: str | list[str], computed: str) -> str:
|
| 84 |
+
"""The stored search names where every file has them, else ``computed``.
|
| 85 |
+
|
| 86 |
+
Raw Overture downloads and the test fixtures have no stored column.
|
| 87 |
+
"""
|
| 88 |
+
paths = [path] if isinstance(path, str) else path
|
| 89 |
+
if all(_stores_search_names(p) for p in paths):
|
| 90 |
+
return SEARCH_NAMES_COLUMN
|
| 91 |
+
return computed
|
| 92 |
+
|
| 93 |
+
|
| 94 |
def _divisions_area_paths(include_localities: bool) -> str | list[str]:
|
| 95 |
"""The divisions_area file, and the localities beside it where asked for.
|
| 96 |
|
|
|
|
| 107 |
path: str | list[str],
|
| 108 |
source: str,
|
| 109 |
place: Place,
|
| 110 |
+
search_names_expr: str,
|
| 111 |
name_expr: str = PRIMARY_NAME,
|
| 112 |
english_name_expr: str | None = None,
|
| 113 |
extra_select: str = "",
|
|
|
|
| 118 |
) -> pd.DataFrame:
|
| 119 |
"""Jaro-Winkler similarity search using only the place name.
|
| 120 |
|
| 121 |
+
Each record is matched under every name in ``search_names_expr`` (see
|
| 122 |
+
:func:`search_names_sql`): its own, its English one, and any others the
|
| 123 |
+
source holds. A record scores its best name, so a place is reachable by
|
| 124 |
+
its English exonym ("Copenhagen") as well as by its local name
|
| 125 |
+
("Københavns Kommune") or an alternate one ("Chittagong" for "Chattogram
|
| 126 |
+
District"). Names are compared without case or accents, so "Bogota"
|
| 127 |
+
finds "Bogotá". ``matched_name`` is whichever name produced the score;
|
| 128 |
+
on a tie, the record's own name.
|
| 129 |
|
| 130 |
``name`` is the English name where there is one and the record's own name
|
| 131 |
otherwise — the expression :func:`fetch_by_id` uses too, so a candidate
|
|
|
|
| 144 |
GeoJSON serialization), for lightweight context (e.g. ``ids_only``
|
| 145 |
responses).
|
| 146 |
"""
|
|
|
|
|
|
|
| 147 |
english_expr = english_name_expr or "CAST(NULL AS VARCHAR)"
|
| 148 |
extra_clause = f", {extra_select}" if extra_select else ""
|
|
|
|
| 149 |
bbox_clause = f", {bbox_expr} AS bbox" if include_bbox else ""
|
| 150 |
+
# Geometry is carried raw and only serialised for the rows returned.
|
| 151 |
+
carried_geometry = ", geometry" if include_geometry else ""
|
| 152 |
+
geometry_clause = ", ST_AsGeoJSON(geometry) AS geometry" if include_geometry else ""
|
| 153 |
+
query = _folded("$1")
|
|
|
|
| 154 |
rel = con.execute(
|
| 155 |
f"""
|
| 156 |
WITH named AS (
|
|
|
|
| 162 |
region,
|
| 163 |
admin_level,
|
| 164 |
is_land,
|
| 165 |
+
is_territorial{extra_clause}{bbox_clause}{carried_geometry},
|
| 166 |
{_readable(name_expr)} AS primary_name,
|
| 167 |
+
{_readable(english_expr)} AS english_name,
|
| 168 |
+
{search_names_expr} AS search_names
|
| 169 |
+
FROM read_parquet($2)
|
| 170 |
),
|
| 171 |
scored AS (
|
| 172 |
SELECT
|
| 173 |
*,
|
| 174 |
+
-- Struct order is the ranking: a substring hit beats a
|
| 175 |
+
-- higher raw score, and an earlier name breaks a tie.
|
| 176 |
+
list_max(
|
| 177 |
+
list_transform(
|
| 178 |
+
search_names,
|
| 179 |
+
(s, i) -> {{
|
| 180 |
+
'substring': contains(s.folded, {query}),
|
| 181 |
+
'similarity': jaro_winkler_similarity(s.folded, {query}),
|
| 182 |
+
'rank': -i,
|
| 183 |
+
'name': s.name
|
| 184 |
+
}}
|
| 185 |
+
)
|
| 186 |
+
) AS best
|
| 187 |
FROM named
|
| 188 |
+
WHERE len(search_names) > 0
|
| 189 |
+
),
|
| 190 |
+
ranked AS (
|
| 191 |
+
SELECT * FROM scored
|
| 192 |
+
ORDER BY best.substring DESC, best.similarity DESC, admin_level ASC
|
| 193 |
+
LIMIT $3
|
| 194 |
)
|
| 195 |
SELECT
|
| 196 |
id,
|
|
|
|
| 201 |
region,
|
| 202 |
admin_level,
|
| 203 |
is_land,
|
| 204 |
+
is_territorial{extra_clause}{", bbox" if include_bbox else ""}{geometry_clause},
|
| 205 |
+
best.name AS matched_name,
|
| 206 |
+
best.similarity AS similarity,
|
| 207 |
+
best.substring AS is_substring_match
|
| 208 |
+
FROM ranked
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
ORDER BY is_substring_match DESC, similarity DESC, admin_level ASC
|
|
|
|
| 210 |
""",
|
| 211 |
+
[place.place, path, limit],
|
| 212 |
)
|
| 213 |
df = rel.fetchdf()
|
| 214 |
df.insert(0, "source", source)
|
|
|
|
| 229 |
) -> pd.DataFrame:
|
| 230 |
"""Fuzzy-match a place against divisions_area (Overture admin boundaries).
|
| 231 |
|
| 232 |
+
Matched on ``names.primary``, ``names.common`` and ``names.rules``
|
| 233 |
+
together: 77% of divisions_area rows have no English common name and
|
| 234 |
+
would drop out of every search if English were the only match field,
|
| 235 |
+
while ``names.primary`` on its own leaves a place unreachable by the
|
| 236 |
+
English name it is usually asked for.
|
| 237 |
|
| 238 |
``include_localities`` searches cities and towns as well. Only the fuzzy
|
| 239 |
endpoint passes it; see :func:`_divisions_area_paths`.
|
| 240 |
"""
|
| 241 |
+
path = _divisions_area_paths(include_localities)
|
| 242 |
return simple_fuzzy_search(
|
| 243 |
con,
|
| 244 |
+
path,
|
| 245 |
"divisions_area",
|
| 246 |
place,
|
| 247 |
+
_search_names(path, DIVISIONS_AREA_SEARCH_NAMES),
|
| 248 |
english_name_expr=DIVISIONS_AREA_ENGLISH_NAME,
|
| 249 |
extra_select="division_id",
|
| 250 |
limit=limit,
|
|
|
|
| 267 |
NATURAL_EARTH_PATH,
|
| 268 |
"natural_earth",
|
| 269 |
place,
|
| 270 |
+
NATURAL_EARTH_SEARCH_NAMES,
|
| 271 |
english_name_expr=NATURAL_EARTH_ENGLISH_NAME,
|
| 272 |
limit=limit,
|
| 273 |
include_geometry=include_geometry,
|
|
@@ -6,6 +6,8 @@ from pathlib import Path
|
|
| 6 |
import duckdb
|
| 7 |
import pytest
|
| 8 |
|
|
|
|
|
|
|
| 9 |
# Force data dir to project root for tests
|
| 10 |
os.environ["GAZET_DATA_DIR"] = str(Path(__file__).resolve().parent.parent / "data")
|
| 11 |
# Prefer original (non-normalized) paths — test suite ships without normalized copies
|
|
@@ -66,8 +68,17 @@ _EXONYM_ROWS = [
|
|
| 66 |
("ratnapura", None, "Ratnapura District", "LK", "county", 2, 80.4, 6.7),
|
| 67 |
("gva", "Genève", "", "CH", "county", 2, 6.1, 46.2),
|
| 68 |
("manas", "Manas", None, "KG", "county", 2, 72.9, 42.5),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
]
|
| 70 |
|
|
|
|
|
|
|
|
|
|
| 71 |
#: Cities, shaped like the localities file: never trained on, so only fuzzy
|
| 72 |
#: search may return them.
|
| 73 |
_LOCALITY_ROWS = [
|
|
@@ -85,13 +96,26 @@ def _name_map(english):
|
|
| 85 |
return "MAP([], [])" if english is None else f"MAP(['en'], ['{english}'])"
|
| 86 |
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
def _write_divisions(path, rows):
|
| 89 |
-
"""Write rows to a parquet with the divisions_area schema
|
|
|
|
| 90 |
c = duckdb.connect()
|
| 91 |
c.execute("INSTALL spatial")
|
| 92 |
c.execute("LOAD spatial")
|
| 93 |
values = ",\n".join(
|
| 94 |
-
f"({_literal(id)}, {_literal(primary)}, {_name_map(english)}, "
|
| 95 |
f"{_literal(country)}, {_literal(subtype)}, "
|
| 96 |
f"{'NULL' if admin_level is None else admin_level}, {lon}, {lat})"
|
| 97 |
for id, primary, english, country, subtype, admin_level, lon, lat in rows
|
|
@@ -99,6 +123,8 @@ def _write_divisions(path, rows):
|
|
| 99 |
c.execute(
|
| 100 |
f"""
|
| 101 |
COPY (
|
|
|
|
|
|
|
| 102 |
SELECT
|
| 103 |
id,
|
| 104 |
ST_AsWKB(
|
|
@@ -114,7 +140,7 @@ def _write_divisions(path, rows):
|
|
| 114 |
country,
|
| 115 |
subtype,
|
| 116 |
'land' AS class,
|
| 117 |
-
{{'primary': primary_name, 'common': common}} AS names,
|
| 118 |
NULL AS region,
|
| 119 |
CAST(admin_level AS INTEGER) AS admin_level,
|
| 120 |
true AS is_land,
|
|
@@ -122,7 +148,10 @@ def _write_divisions(path, rows):
|
|
| 122 |
id AS division_id
|
| 123 |
FROM (VALUES
|
| 124 |
{values}
|
| 125 |
-
) AS t(
|
|
|
|
|
|
|
|
|
|
| 126 |
) TO '{path}' (FORMAT PARQUET)
|
| 127 |
"""
|
| 128 |
)
|
|
|
|
| 6 |
import duckdb
|
| 7 |
import pytest
|
| 8 |
|
| 9 |
+
from gazet.search import DIVISIONS_AREA_SEARCH_NAMES, SEARCH_NAMES_COLUMN
|
| 10 |
+
|
| 11 |
# Force data dir to project root for tests
|
| 12 |
os.environ["GAZET_DATA_DIR"] = str(Path(__file__).resolve().parent.parent / "data")
|
| 13 |
# Prefer original (non-normalized) paths — test suite ships without normalized copies
|
|
|
|
| 68 |
("ratnapura", None, "Ratnapura District", "LK", "county", 2, 80.4, 6.7),
|
| 69 |
("gva", "Genève", "", "CH", "county", 2, 6.1, 46.2),
|
| 70 |
("manas", "Manas", None, "KG", "county", 2, 72.9, 42.5),
|
| 71 |
+
# Reachable only by an alternate name, or only once accents are folded;
|
| 72 |
+
# the near-homographs beside them are what those queries used to hit.
|
| 73 |
+
("ctg", "Chattogram District", None, "BD", "county", 2, 91.8, 22.3),
|
| 74 |
+
("chitato", "Chitato", None, "AO", "county", 2, 20.8, -7.4),
|
| 75 |
+
("bog", "Bogotá, D.c.", None, "CO", "county", 2, -74.1, 4.6),
|
| 76 |
+
("bobota", "Bobota", None, "RO", "county", 2, 22.8, 47.4),
|
| 77 |
]
|
| 78 |
|
| 79 |
+
#: ``names.rules`` values: alternate, short and official names.
|
| 80 |
+
_EXONYM_RULES = {"ctg": ["Chittagong"]}
|
| 81 |
+
|
| 82 |
#: Cities, shaped like the localities file: never trained on, so only fuzzy
|
| 83 |
#: search may return them.
|
| 84 |
_LOCALITY_ROWS = [
|
|
|
|
| 96 |
return "MAP([], [])" if english is None else f"MAP(['en'], ['{english}'])"
|
| 97 |
|
| 98 |
|
| 99 |
+
def _rules(id):
|
| 100 |
+
"""``names.rules``: the record's alternate names, as Overture shapes them."""
|
| 101 |
+
values = _EXONYM_RULES.get(id, [])
|
| 102 |
+
if not values:
|
| 103 |
+
return "CAST([] AS STRUCT(variant VARCHAR, language VARCHAR, value VARCHAR)[])"
|
| 104 |
+
items = ", ".join(
|
| 105 |
+
f"{{'variant': 'alternate', 'language': NULL, 'value': '{value}'}}"
|
| 106 |
+
for value in values
|
| 107 |
+
)
|
| 108 |
+
return f"[{items}]"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
def _write_divisions(path, rows):
|
| 112 |
+
"""Write rows to a parquet with the normalised divisions_area schema,
|
| 113 |
+
search names stored as normalize_geodata stores them."""
|
| 114 |
c = duckdb.connect()
|
| 115 |
c.execute("INSTALL spatial")
|
| 116 |
c.execute("LOAD spatial")
|
| 117 |
values = ",\n".join(
|
| 118 |
+
f"({_literal(id)}, {_literal(primary)}, {_name_map(english)}, {_rules(id)}, "
|
| 119 |
f"{_literal(country)}, {_literal(subtype)}, "
|
| 120 |
f"{'NULL' if admin_level is None else admin_level}, {lon}, {lat})"
|
| 121 |
for id, primary, english, country, subtype, admin_level, lon, lat in rows
|
|
|
|
| 123 |
c.execute(
|
| 124 |
f"""
|
| 125 |
COPY (
|
| 126 |
+
SELECT *, {DIVISIONS_AREA_SEARCH_NAMES} AS {SEARCH_NAMES_COLUMN}
|
| 127 |
+
FROM (
|
| 128 |
SELECT
|
| 129 |
id,
|
| 130 |
ST_AsWKB(
|
|
|
|
| 140 |
country,
|
| 141 |
subtype,
|
| 142 |
'land' AS class,
|
| 143 |
+
{{'primary': primary_name, 'common': common, 'rules': rules}} AS names,
|
| 144 |
NULL AS region,
|
| 145 |
CAST(admin_level AS INTEGER) AS admin_level,
|
| 146 |
true AS is_land,
|
|
|
|
| 148 |
id AS division_id
|
| 149 |
FROM (VALUES
|
| 150 |
{values}
|
| 151 |
+
) AS t(
|
| 152 |
+
id, primary_name, common, rules, country, subtype, admin_level, lon, lat
|
| 153 |
+
)
|
| 154 |
+
)
|
| 155 |
) TO '{path}' (FORMAT PARQUET)
|
| 156 |
"""
|
| 157 |
)
|
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import pandas as pd
|
| 4 |
|
|
|
|
| 5 |
from gazet.schemas import Place
|
| 6 |
from gazet.search import (
|
| 7 |
get_by_id,
|
|
@@ -266,6 +267,27 @@ class TestEnglishNames:
|
|
| 266 |
assert bool(df.iloc[0]["is_substring_match"]) is False
|
| 267 |
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
class TestLocalities:
|
| 270 |
"""Cities are searchable by fuzzy name, and kept from the
|
| 271 |
natural-language pipeline, whose model was never trained on them."""
|
|
@@ -292,3 +314,22 @@ class TestLocalities:
|
|
| 292 |
def test_a_locality_id_resolves(self, con, localities_source):
|
| 293 |
df = get_by_id(con, "manaus", include_geometry=False)
|
| 294 |
assert df.iloc[0]["name"] == "Manaus"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import pandas as pd
|
| 4 |
|
| 5 |
+
from gazet import search
|
| 6 |
from gazet.schemas import Place
|
| 7 |
from gazet.search import (
|
| 8 |
get_by_id,
|
|
|
|
| 267 |
assert bool(df.iloc[0]["is_substring_match"]) is False
|
| 268 |
|
| 269 |
|
| 270 |
+
class TestOtherNames:
|
| 271 |
+
"""A place is reachable by any name Overture holds for it, with or
|
| 272 |
+
without accents: "Chittagong" is an alternate name of Chattogram
|
| 273 |
+
District, and "Bogota" is how "Bogotá" is typed without the accent."""
|
| 274 |
+
|
| 275 |
+
def test_alternate_name_finds_the_place(self, con, exonym_source):
|
| 276 |
+
df = search_divisions_area(con, Place(place="Chittagong"), limit=3)
|
| 277 |
+
assert df.iloc[0]["id"] == "ctg"
|
| 278 |
+
assert df.iloc[0]["matched_name"] == "Chittagong"
|
| 279 |
+
assert df.iloc[0]["name"] == "Chattogram District"
|
| 280 |
+
|
| 281 |
+
def test_unaccented_query_finds_the_accented_name(self, con, exonym_source):
|
| 282 |
+
df = search_divisions_area(con, Place(place="Bogota"), limit=3)
|
| 283 |
+
assert df.iloc[0]["id"] == "bog"
|
| 284 |
+
assert df.iloc[0]["matched_name"] == "Bogotá, D.c."
|
| 285 |
+
|
| 286 |
+
def test_accented_query_finds_the_unaccented_name(self, con, exonym_source):
|
| 287 |
+
df = search_divisions_area(con, Place(place="Chattógram"), limit=1)
|
| 288 |
+
assert df.iloc[0]["id"] == "ctg"
|
| 289 |
+
|
| 290 |
+
|
| 291 |
class TestLocalities:
|
| 292 |
"""Cities are searchable by fuzzy name, and kept from the
|
| 293 |
natural-language pipeline, whose model was never trained on them."""
|
|
|
|
| 314 |
def test_a_locality_id_resolves(self, con, localities_source):
|
| 315 |
df = get_by_id(con, "manaus", include_geometry=False)
|
| 316 |
assert df.iloc[0]["name"] == "Manaus"
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
class TestStoredSearchNames:
|
| 320 |
+
"""normalize_geodata stores each record's folded names; a file without
|
| 321 |
+
them, such as a raw Overture download, is searched all the same."""
|
| 322 |
+
|
| 323 |
+
def test_the_stored_names_are_read(self, exonym_source):
|
| 324 |
+
assert search._search_names(exonym_source, "computed") == "search_names"
|
| 325 |
+
|
| 326 |
+
def test_a_raw_file_falls_back_to_computing_them(self):
|
| 327 |
+
assert (
|
| 328 |
+
search._search_names(search.DIVISIONS_AREA_PATH, "computed") == "computed"
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
def test_both_find_the_same_record(self, con, exonym_source, monkeypatch):
|
| 332 |
+
stored = search_divisions_area(con, Place(place="Chittagong"), limit=1)
|
| 333 |
+
monkeypatch.setattr(search, "_search_names", lambda path, computed: computed)
|
| 334 |
+
computed = search_divisions_area(con, Place(place="Chittagong"), limit=1)
|
| 335 |
+
assert stored.iloc[0]["id"] == computed.iloc[0]["id"] == "ctg"
|