ng-address-resolver 0.1.0__tar.gz
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.
- ng_address_resolver-0.1.0/.gitignore +8 -0
- ng_address_resolver-0.1.0/LICENSE +21 -0
- ng_address_resolver-0.1.0/PKG-INFO +80 -0
- ng_address_resolver-0.1.0/README.md +56 -0
- ng_address_resolver-0.1.0/pyproject.toml +60 -0
- ng_address_resolver-0.1.0/src/ng_address/__init__.py +20 -0
- ng_address_resolver-0.1.0/src/ng_address/cli.py +103 -0
- ng_address_resolver-0.1.0/src/ng_address/core.py +179 -0
- ng_address_resolver-0.1.0/src/ng_address/geocode.py +90 -0
- ng_address_resolver-0.1.0/src/ng_address/models.py +73 -0
- ng_address_resolver-0.1.0/src/ng_address/parse.py +70 -0
- ng_address_resolver-0.1.0/src/ng_address/py.typed +0 -0
- ng_address_resolver-0.1.0/src/ng_address/resolve.py +136 -0
- ng_address_resolver-0.1.0/tests/test_core.py +183 -0
- ng_address_resolver-0.1.0/tests/test_geocode.py +84 -0
- ng_address_resolver-0.1.0/tests/test_parse.py +138 -0
- ng_address_resolver-0.1.0/tests/test_resolve.py +176 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kayode Adeniyi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ng-address-resolver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Resolve free-text Nigerian addresses to NIPOST digital postcodes (NDAPS), only as precisely as the evidence allows.
|
|
5
|
+
Project-URL: Repository, https://github.com/Adeniyikayodee/ng-postcode
|
|
6
|
+
Author: Kayode Adeniyi
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: address,address-parser,ai-agents,claude,geocoding,ndaps,nigeria,nipost,postcode
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: ng-postcode[client]<0.2,>=0.1
|
|
20
|
+
Requires-Dist: pydantic>=2.7
|
|
21
|
+
Provides-Extra: claude
|
|
22
|
+
Requires-Dist: anthropic<2,>=1.11; extra == 'claude'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# ng-address-resolver
|
|
26
|
+
|
|
27
|
+
Resolve free-text Nigerian addresses, such as "back of Fabian Hotel, off NTA Road, Ado Ekiti", to NIPOST digital postcodes (NDAPS). It answers only as precisely as the evidence allows, and asks a question when it cannot.
|
|
28
|
+
|
|
29
|
+
**Status: pre-release.** The workflow is tested against mocked Claude, NIPOST and geocoder APIs. It has not yet been run end to end with live NIPOST and Claude credentials, and its accuracy on real addresses is unmeasured.
|
|
30
|
+
|
|
31
|
+
## How it decides
|
|
32
|
+
|
|
33
|
+
1. **A postcode written in the text** is extracted (never auto-corrected) and confirmed with NIPOST.
|
|
34
|
+
2. **A location pin** is reverse-geocoded by NIPOST. This is the only route to a high-confidence building code.
|
|
35
|
+
3. **Text only** is read by Claude into street, landmarks and their relation ("behind", "opposite", "at"), area, LGA and state, then placed with a geocoder and reverse-geocoded by NIPOST.
|
|
36
|
+
|
|
37
|
+
| Evidence | Answer |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| Typed postcode NIPOST confirms, or a pin within 10 m of a building | Building code, high confidence |
|
|
40
|
+
| A pin within 25 m, or the landmark the address *is* | Building code, medium confidence |
|
|
41
|
+
| A building near a landmark ("behind", "opposite") | Area code, e.g. `EK-01-A03-FK` |
|
|
42
|
+
| A street only | District code, low confidence |
|
|
43
|
+
| A town only, or nothing found | No code, plus a question for the user |
|
|
44
|
+
|
|
45
|
+
Text alone rarely identifies a building: a landmark's own building is not the one behind it, and a road's map point is not any house on it. Ask users for a location pin when you need the exact building.
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
ng-address "back of Fabian Hotel, off NTA Road, Ado Ekiti"
|
|
51
|
+
ng-address "my house" --lat 7.6211 --lng 5.2214
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The result is JSON: `status` (`resolved`, `partial`, `unresolved`), `code`, `level`, `confidence`, `method`, `question` and the `evidence` behind it.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from ng_address import Resolver
|
|
58
|
+
|
|
59
|
+
result = await Resolver(nipost=..., parser=..., geocoder=...).resolve("...")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The core needs no model. Install `ng-address-resolver[claude]` to let the CLI and `ng_address.parse.ClaudeParser` read addresses with Claude. A caller that has already read the address, such as the host model of an MCP server, passes its own `ParsedAddress` as `resolve(..., parsed=...)` instead.
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
| Variable | Purpose |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| Anthropic credentials | Needs the `claude` extra. Read by the Anthropic SDK (`ANTHROPIC_API_KEY` or an `ant auth login` profile). Without them the raw text is searched instead. |
|
|
69
|
+
| `NG_POSTCODE_API_KEY` | NIPOST API key. Without it no postcode can be returned. |
|
|
70
|
+
| `NG_GEOCODER_URL` | A Nominatim server, ideally your own. |
|
|
71
|
+
| `NG_GEOCODER_CONTACT` | A URL or email sent in the User-Agent to identify you. Required for the public Nominatim. |
|
|
72
|
+
| `NG_ADDRESS_MODEL` | Claude model. Defaults to `claude-opus-5-5`. |
|
|
73
|
+
|
|
74
|
+
The public Nominatim at `https://nominatim.openstreetmap.org` allows light personal use only. A service whose main job is geocoding must run its own instance or use a commercial geocoder. Map data © OpenStreetMap contributors.
|
|
75
|
+
|
|
76
|
+
Each address uses at most one Claude call at low effort, up to three geocoder searches, and one or two NIPOST calls.
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# ng-address-resolver
|
|
2
|
+
|
|
3
|
+
Resolve free-text Nigerian addresses, such as "back of Fabian Hotel, off NTA Road, Ado Ekiti", to NIPOST digital postcodes (NDAPS). It answers only as precisely as the evidence allows, and asks a question when it cannot.
|
|
4
|
+
|
|
5
|
+
**Status: pre-release.** The workflow is tested against mocked Claude, NIPOST and geocoder APIs. It has not yet been run end to end with live NIPOST and Claude credentials, and its accuracy on real addresses is unmeasured.
|
|
6
|
+
|
|
7
|
+
## How it decides
|
|
8
|
+
|
|
9
|
+
1. **A postcode written in the text** is extracted (never auto-corrected) and confirmed with NIPOST.
|
|
10
|
+
2. **A location pin** is reverse-geocoded by NIPOST. This is the only route to a high-confidence building code.
|
|
11
|
+
3. **Text only** is read by Claude into street, landmarks and their relation ("behind", "opposite", "at"), area, LGA and state, then placed with a geocoder and reverse-geocoded by NIPOST.
|
|
12
|
+
|
|
13
|
+
| Evidence | Answer |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| Typed postcode NIPOST confirms, or a pin within 10 m of a building | Building code, high confidence |
|
|
16
|
+
| A pin within 25 m, or the landmark the address *is* | Building code, medium confidence |
|
|
17
|
+
| A building near a landmark ("behind", "opposite") | Area code, e.g. `EK-01-A03-FK` |
|
|
18
|
+
| A street only | District code, low confidence |
|
|
19
|
+
| A town only, or nothing found | No code, plus a question for the user |
|
|
20
|
+
|
|
21
|
+
Text alone rarely identifies a building: a landmark's own building is not the one behind it, and a road's map point is not any house on it. Ask users for a location pin when you need the exact building.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
ng-address "back of Fabian Hotel, off NTA Road, Ado Ekiti"
|
|
27
|
+
ng-address "my house" --lat 7.6211 --lng 5.2214
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The result is JSON: `status` (`resolved`, `partial`, `unresolved`), `code`, `level`, `confidence`, `method`, `question` and the `evidence` behind it.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from ng_address import Resolver
|
|
34
|
+
|
|
35
|
+
result = await Resolver(nipost=..., parser=..., geocoder=...).resolve("...")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The core needs no model. Install `ng-address-resolver[claude]` to let the CLI and `ng_address.parse.ClaudeParser` read addresses with Claude. A caller that has already read the address, such as the host model of an MCP server, passes its own `ParsedAddress` as `resolve(..., parsed=...)` instead.
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
| Variable | Purpose |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| Anthropic credentials | Needs the `claude` extra. Read by the Anthropic SDK (`ANTHROPIC_API_KEY` or an `ant auth login` profile). Without them the raw text is searched instead. |
|
|
45
|
+
| `NG_POSTCODE_API_KEY` | NIPOST API key. Without it no postcode can be returned. |
|
|
46
|
+
| `NG_GEOCODER_URL` | A Nominatim server, ideally your own. |
|
|
47
|
+
| `NG_GEOCODER_CONTACT` | A URL or email sent in the User-Agent to identify you. Required for the public Nominatim. |
|
|
48
|
+
| `NG_ADDRESS_MODEL` | Claude model. Defaults to `claude-opus-5-5`. |
|
|
49
|
+
|
|
50
|
+
The public Nominatim at `https://nominatim.openstreetmap.org` allows light personal use only. A service whose main job is geocoding must run its own instance or use a commercial geocoder. Map data © OpenStreetMap contributors.
|
|
51
|
+
|
|
52
|
+
Each address uses at most one Claude call at low effort, up to three geocoder searches, and one or two NIPOST calls.
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ng-address-resolver"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Resolve free-text Nigerian addresses to NIPOST digital postcodes (NDAPS), only as precisely as the evidence allows."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Kayode Adeniyi" }]
|
|
14
|
+
keywords = ["nigeria", "address", "postcode", "nipost", "ndaps", "geocoding", "address-parser", "claude", "ai-agents"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["httpx>=0.27", "ng-postcode[client]>=0.1,<0.2", "pydantic>=2.7"]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
claude = ["anthropic>=1.11,<2"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
ng-address = "ng_address.cli:main"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Repository = "https://github.com/Adeniyikayodee/ng-postcode"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = ["anthropic>=1.11,<2", "mypy>=1.13", "pytest>=8", "ruff>=0.8"]
|
|
37
|
+
|
|
38
|
+
# Inside the repository, build against the local library so the two never drift.
|
|
39
|
+
[tool.uv.sources]
|
|
40
|
+
ng-postcode = { path = "../python", editable = true }
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/ng_address"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
only-include = ["src", "tests", "README.md", "LICENSE"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 100
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "PT"]
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
strict = true
|
|
57
|
+
files = ["src", "tests"]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Resolve free-text Nigerian addresses to NIPOST digital postcodes, only as
|
|
2
|
+
precisely as the evidence allows.
|
|
3
|
+
|
|
4
|
+
The core needs no model. `ng_address.parse.ClaudeParser` reads addresses with
|
|
5
|
+
Claude and needs the `claude` extra."""
|
|
6
|
+
|
|
7
|
+
from .geocode import GeocodeFailure, Nominatim
|
|
8
|
+
from .models import Geocoded, Landmark, ParsedAddress, ParseFailure, Resolution
|
|
9
|
+
from .resolve import Resolver
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"GeocodeFailure",
|
|
13
|
+
"Geocoded",
|
|
14
|
+
"Landmark",
|
|
15
|
+
"Nominatim",
|
|
16
|
+
"ParseFailure",
|
|
17
|
+
"ParsedAddress",
|
|
18
|
+
"Resolution",
|
|
19
|
+
"Resolver",
|
|
20
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""`ng-address "back of Fabian Hotel, NTA Road, Ado Ekiti"`: prints the Resolution as JSON.
|
|
2
|
+
|
|
3
|
+
Configuration comes from the environment. Anthropic credentials are read by
|
|
4
|
+
the SDK as usual; NG_POSTCODE_API_KEY, NG_GEOCODER_URL, NG_GEOCODER_CONTACT
|
|
5
|
+
and NG_ADDRESS_MODEL are read here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from importlib.metadata import version
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from ng_postcode.api import Coordinate
|
|
20
|
+
from ng_postcode.client import AsyncClient
|
|
21
|
+
|
|
22
|
+
from .geocode import PUBLIC_NOMINATIM, Nominatim
|
|
23
|
+
from .models import Resolution
|
|
24
|
+
from .resolve import Parser, Resolver
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
import anthropic
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv: list[str] | None = None) -> None:
|
|
31
|
+
cli = argparse.ArgumentParser(prog="ng-address", description=__doc__)
|
|
32
|
+
cli.add_argument("address", help="Free-text address, in quotes.")
|
|
33
|
+
cli.add_argument("--lat", type=float, help="Latitude of a location pin.")
|
|
34
|
+
cli.add_argument("--lng", type=float, help="Longitude of a location pin.")
|
|
35
|
+
args = cli.parse_args(argv)
|
|
36
|
+
if (args.lat is None) != (args.lng is None):
|
|
37
|
+
cli.error("give both --lat and --lng, or neither")
|
|
38
|
+
location = None if args.lat is None else Coordinate(lat=args.lat, lng=args.lng)
|
|
39
|
+
problem = config_problem(os.environ)
|
|
40
|
+
if problem:
|
|
41
|
+
sys.exit(f"ng-address: {problem}")
|
|
42
|
+
result = asyncio.run(run(args.address, location, os.environ))
|
|
43
|
+
print(json.dumps(result.model_dump(), indent=2))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def config_problem(env: Mapping[str, str]) -> str | None:
|
|
47
|
+
url = env.get("NG_GEOCODER_URL", "").rstrip("/")
|
|
48
|
+
if url == PUBLIC_NOMINATIM and not env.get("NG_GEOCODER_CONTACT"):
|
|
49
|
+
return "the public Nominatim requires NG_GEOCODER_CONTACT, a URL or email identifying you"
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def run(text: str, location: Coordinate | None, env: Mapping[str, str]) -> Resolution:
|
|
54
|
+
key = env.get("NG_POSTCODE_API_KEY", "").strip()
|
|
55
|
+
nipost = AsyncClient(key) if key else None
|
|
56
|
+
geocoder = geocoder_from(env)
|
|
57
|
+
claude = claude_from()
|
|
58
|
+
parser = parser_from(claude, env.get("NG_ADDRESS_MODEL"))
|
|
59
|
+
try:
|
|
60
|
+
return await Resolver(nipost=nipost, parser=parser, geocoder=geocoder).resolve(
|
|
61
|
+
text, location
|
|
62
|
+
)
|
|
63
|
+
finally:
|
|
64
|
+
if nipost:
|
|
65
|
+
await nipost.aclose()
|
|
66
|
+
if geocoder:
|
|
67
|
+
await geocoder.aclose()
|
|
68
|
+
if claude:
|
|
69
|
+
await claude.close()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def geocoder_from(env: Mapping[str, str]) -> Nominatim | None:
|
|
73
|
+
url = env.get("NG_GEOCODER_URL", "").rstrip("/")
|
|
74
|
+
if not url:
|
|
75
|
+
return None
|
|
76
|
+
contact = env.get("NG_GEOCODER_CONTACT", "unknown")
|
|
77
|
+
if url == PUBLIC_NOMINATIM:
|
|
78
|
+
print(
|
|
79
|
+
"ng-address: using the public Nominatim, for light personal use only. "
|
|
80
|
+
"Map data (c) OpenStreetMap contributors.",
|
|
81
|
+
file=sys.stderr,
|
|
82
|
+
)
|
|
83
|
+
return Nominatim(url, f"ng-address-resolver/{version('ng-address-resolver')} ({contact})")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def claude_from() -> anthropic.AsyncAnthropic | None:
|
|
87
|
+
"""A Claude client, or None without the `claude` extra or usable credentials."""
|
|
88
|
+
try:
|
|
89
|
+
import anthropic
|
|
90
|
+
except ImportError:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
return anthropic.AsyncAnthropic()
|
|
94
|
+
except anthropic.AnthropicError:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parser_from(claude: anthropic.AsyncAnthropic | None, model: str | None) -> Parser | None:
|
|
99
|
+
if claude is None:
|
|
100
|
+
return None
|
|
101
|
+
from .parse import MODEL, ClaudeParser
|
|
102
|
+
|
|
103
|
+
return ClaudeParser(claude, model or MODEL)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Pure decisions: no I/O, and the same evidence always gives the same answer.
|
|
2
|
+
|
|
3
|
+
The rule throughout is that an answer is never more precise than its evidence.
|
|
4
|
+
A landmark's own building is not the building "behind" it, and the middle of a
|
|
5
|
+
road is not any house on it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from ng_postcode import Postcode, Segment, parse
|
|
13
|
+
from ng_postcode.api import Reverse
|
|
14
|
+
|
|
15
|
+
from .models import Geocoded, Landmark, Level, Method, ParsedAddress, Precision, Resolution
|
|
16
|
+
|
|
17
|
+
BUILDING_RADIUS_M = 25.0
|
|
18
|
+
"""A building this close to the point is taken to be the place itself."""
|
|
19
|
+
|
|
20
|
+
SEARCH_RADIUS_M: dict[Precision, float | None] = {
|
|
21
|
+
"building": 50.0,
|
|
22
|
+
"street": 250.0,
|
|
23
|
+
"locality": None,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
DEFAULT_QUESTION = "Can you share a location pin, or the street and a nearby landmark?"
|
|
27
|
+
|
|
28
|
+
_LEVEL_SEGMENT = {
|
|
29
|
+
"building": Segment.UNIT,
|
|
30
|
+
"area": Segment.AREA,
|
|
31
|
+
"district": Segment.DISTRICT,
|
|
32
|
+
"lga": Segment.LGA,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_CANDIDATE = re.compile(
|
|
36
|
+
r"(?<![A-Za-z0-9])"
|
|
37
|
+
r"[A-Za-z]{2}[ -]?\d{2}[ -]?[A-Za-z0-9]{3}[ -]?[A-Za-z]{2}[ -]?\d{2}"
|
|
38
|
+
r"(?![A-Za-z0-9])"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_typed_postcode(text: str) -> Postcode | None:
|
|
43
|
+
"""The first well-formed postcode written in the text. Look-alikes are not corrected."""
|
|
44
|
+
codes = (parse(match.group()) for match in _CANDIDATE.finditer(text))
|
|
45
|
+
return next((code for code in codes if isinstance(code, Postcode)), None)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def precision_of(place_rank: int) -> Precision:
|
|
49
|
+
"""Nominatim ranks buildings and named places 30, roads 26 to 27, and areas lower."""
|
|
50
|
+
if place_rank >= 28:
|
|
51
|
+
return "building"
|
|
52
|
+
return "street" if place_rank >= 26 else "locality"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def landmark_for(query: str, parsed: ParsedAddress | None) -> Landmark | None:
|
|
56
|
+
"""The landmark a successful map query was about, if any."""
|
|
57
|
+
if parsed is None:
|
|
58
|
+
return None
|
|
59
|
+
text = query.casefold()
|
|
60
|
+
return next((lm for lm in parsed.landmarks if lm.name.casefold() in text), None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def code_at(postcode: str | None, level: Level) -> str | None:
|
|
64
|
+
"""The code of the enclosing `level` for a full postcode."""
|
|
65
|
+
code = parse(postcode or "")
|
|
66
|
+
return code.prefix(_LEVEL_SEGMENT[level]) if isinstance(code, Postcode) else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def from_typed(code: Postcode, assigned: bool | None, note: str) -> Resolution:
|
|
70
|
+
if assigned is False:
|
|
71
|
+
return Resolution(
|
|
72
|
+
status="unresolved",
|
|
73
|
+
code=None,
|
|
74
|
+
level=None,
|
|
75
|
+
confidence=None,
|
|
76
|
+
method="typed",
|
|
77
|
+
question=f"NIPOST says {code} is not assigned to a building. Can you check it?",
|
|
78
|
+
evidence=[f"Postcode {code} is written in the address.", note],
|
|
79
|
+
)
|
|
80
|
+
return Resolution(
|
|
81
|
+
status="resolved",
|
|
82
|
+
code=str(code),
|
|
83
|
+
level="building",
|
|
84
|
+
confidence="high" if assigned else "medium",
|
|
85
|
+
method="typed",
|
|
86
|
+
question=None,
|
|
87
|
+
evidence=[f"Postcode {code} is written in the address.", note],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def from_location(found: Reverse) -> Resolution:
|
|
92
|
+
unit = found.unit
|
|
93
|
+
if not found.found or unit is None:
|
|
94
|
+
return unresolved(
|
|
95
|
+
"location",
|
|
96
|
+
found.message or "NIPOST found no building within the search radius.",
|
|
97
|
+
"No building was found at that location. Is the pin on the building itself?",
|
|
98
|
+
)
|
|
99
|
+
if unit.distance_m is not None and unit.distance_m <= BUILDING_RADIUS_M:
|
|
100
|
+
return Resolution(
|
|
101
|
+
status="resolved",
|
|
102
|
+
code=unit.postcode,
|
|
103
|
+
level="building",
|
|
104
|
+
confidence="high" if unit.distance_m <= 10 else "medium",
|
|
105
|
+
method="location",
|
|
106
|
+
question=None,
|
|
107
|
+
evidence=[
|
|
108
|
+
f"Nearest building to the pin is {unit.postcode}, {unit.distance_m:.0f} m away."
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
return Resolution(
|
|
112
|
+
status="partial",
|
|
113
|
+
code=code_at(unit.postcode, "area"),
|
|
114
|
+
level="area",
|
|
115
|
+
confidence="medium",
|
|
116
|
+
method="location",
|
|
117
|
+
question="The pin is not on a building. Can you move it onto the building itself?",
|
|
118
|
+
evidence=[
|
|
119
|
+
f"Nearest building is {unit.postcode}, {unit.distance_m or 0:.0f} m from the pin."
|
|
120
|
+
],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def from_geocoded(found: Reverse | None, place: Geocoded, landmark: Landmark | None) -> Resolution:
|
|
125
|
+
if place.precision == "locality" or found is None:
|
|
126
|
+
return unresolved(
|
|
127
|
+
"geocoded", f"Only placed as far as {place.label}, too broad for a postcode."
|
|
128
|
+
)
|
|
129
|
+
unit = found.unit
|
|
130
|
+
if not found.found or unit is None:
|
|
131
|
+
return unresolved("geocoded", f"NIPOST found no building near {place.label}.")
|
|
132
|
+
near = (
|
|
133
|
+
f"Nearest building to {place.label} is {unit.postcode}, {unit.distance_m or 0:.0f} m away."
|
|
134
|
+
)
|
|
135
|
+
if place.precision == "street":
|
|
136
|
+
return Resolution(
|
|
137
|
+
status="partial",
|
|
138
|
+
code=code_at(unit.postcode, "district"),
|
|
139
|
+
level="district",
|
|
140
|
+
confidence="low",
|
|
141
|
+
method="geocoded",
|
|
142
|
+
question="What is the house number, or a landmark on that street? A location pin is "
|
|
143
|
+
"most reliable.",
|
|
144
|
+
evidence=["The map only placed the street, not a building.", near],
|
|
145
|
+
)
|
|
146
|
+
is_the_place = landmark is None or landmark.relation == "at"
|
|
147
|
+
close = unit.distance_m is not None and unit.distance_m <= BUILDING_RADIUS_M
|
|
148
|
+
if is_the_place and close:
|
|
149
|
+
return Resolution(
|
|
150
|
+
status="resolved",
|
|
151
|
+
code=unit.postcode,
|
|
152
|
+
level="building",
|
|
153
|
+
confidence="medium",
|
|
154
|
+
method="geocoded",
|
|
155
|
+
question=None,
|
|
156
|
+
evidence=[near],
|
|
157
|
+
)
|
|
158
|
+
where = f"{landmark.relation} {landmark.name}" if landmark else f"near {place.label}"
|
|
159
|
+
return Resolution(
|
|
160
|
+
status="partial",
|
|
161
|
+
code=code_at(unit.postcode, "area"),
|
|
162
|
+
level="area",
|
|
163
|
+
confidence="medium",
|
|
164
|
+
method="geocoded",
|
|
165
|
+
question=f"Which building {where} is it? A location pin or house number would pin it down.",
|
|
166
|
+
evidence=[near, f"The address is {where}, not the place the map found."],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def unresolved(method: Method | None, reason: str, question: str = DEFAULT_QUESTION) -> Resolution:
|
|
171
|
+
return Resolution(
|
|
172
|
+
status="unresolved",
|
|
173
|
+
code=None,
|
|
174
|
+
level=None,
|
|
175
|
+
confidence=None,
|
|
176
|
+
method=method,
|
|
177
|
+
question=question,
|
|
178
|
+
evidence=[reason],
|
|
179
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Nominatim (OpenStreetMap) search, limited to Nigeria.
|
|
2
|
+
|
|
3
|
+
The public instance at nominatim.openstreetmap.org allows light use only and
|
|
4
|
+
requires an identifying User-Agent, at most one request per second and cached
|
|
5
|
+
results. A service whose main job is geocoding must run its own instance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .core import precision_of
|
|
18
|
+
from .models import Geocoded
|
|
19
|
+
|
|
20
|
+
PUBLIC_NOMINATIM = "https://nominatim.openstreetmap.org"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class GeocodeFailure:
|
|
25
|
+
reason: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Nominatim:
|
|
29
|
+
"""Caches every answer and spaces requests at least `min_interval_s` apart."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
base_url: str,
|
|
34
|
+
user_agent: str,
|
|
35
|
+
*,
|
|
36
|
+
http: httpx.AsyncClient | None = None,
|
|
37
|
+
min_interval_s: float = 1.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._http = http if http is not None else httpx.AsyncClient(timeout=10.0)
|
|
40
|
+
self._owns_http = http is None
|
|
41
|
+
self._url = base_url.rstrip("/") + "/search"
|
|
42
|
+
self._headers = {"User-Agent": user_agent}
|
|
43
|
+
self._interval = min_interval_s
|
|
44
|
+
self._lock = asyncio.Lock()
|
|
45
|
+
self._last = float("-inf")
|
|
46
|
+
self._cache: dict[str, Geocoded | None] = {}
|
|
47
|
+
|
|
48
|
+
async def __call__(self, query: str) -> Geocoded | GeocodeFailure | None:
|
|
49
|
+
key = " ".join(query.casefold().split())
|
|
50
|
+
async with self._lock:
|
|
51
|
+
if key in self._cache:
|
|
52
|
+
return self._cache[key]
|
|
53
|
+
await asyncio.sleep(max(0.0, self._last + self._interval - time.monotonic()))
|
|
54
|
+
try:
|
|
55
|
+
response = await self._http.get(
|
|
56
|
+
self._url, params=_params(query), headers=self._headers
|
|
57
|
+
)
|
|
58
|
+
except httpx.HTTPError as error:
|
|
59
|
+
return GeocodeFailure(f"geocoder unreachable: {error or type(error).__name__}")
|
|
60
|
+
finally:
|
|
61
|
+
self._last = time.monotonic()
|
|
62
|
+
if response.status_code != 200:
|
|
63
|
+
return GeocodeFailure(f"geocoder answered HTTP {response.status_code}")
|
|
64
|
+
try:
|
|
65
|
+
place = first_place(query, response.json())
|
|
66
|
+
except ValueError:
|
|
67
|
+
return GeocodeFailure("geocoder answered with something other than JSON")
|
|
68
|
+
self._cache[key] = place
|
|
69
|
+
return place
|
|
70
|
+
|
|
71
|
+
async def aclose(self) -> None:
|
|
72
|
+
if self._owns_http:
|
|
73
|
+
await self._http.aclose()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _params(query: str) -> dict[str, str]:
|
|
77
|
+
return {"q": query, "format": "jsonv2", "countrycodes": "ng", "limit": "1"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def first_place(query: str, data: Any) -> Geocoded | None:
|
|
81
|
+
"""The best match from a Nominatim jsonv2 search response, if it is usable."""
|
|
82
|
+
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
|
83
|
+
return None
|
|
84
|
+
hit = data[0]
|
|
85
|
+
try:
|
|
86
|
+
lat, lng, rank = float(hit["lat"]), float(hit["lon"]), int(hit.get("place_rank", 0))
|
|
87
|
+
except (KeyError, TypeError, ValueError):
|
|
88
|
+
return None
|
|
89
|
+
label = str(hit.get("display_name") or query)
|
|
90
|
+
return Geocoded(query=query, lat=lat, lng=lng, precision=precision_of(rank), label=label)
|