nafnagreining 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.
- nafnagreining-0.1.0/.gitignore +7 -0
- nafnagreining-0.1.0/LICENSE +24 -0
- nafnagreining-0.1.0/PKG-INFO +91 -0
- nafnagreining-0.1.0/README.md +68 -0
- nafnagreining-0.1.0/pyproject.toml +38 -0
- nafnagreining-0.1.0/src/nafnagreining/__init__.py +13 -0
- nafnagreining-0.1.0/src/nafnagreining/data/LICENSE-wordlist.txt +17 -0
- nafnagreining-0.1.0/src/nafnagreining/data/is_words.txt.gz +0 -0
- nafnagreining-0.1.0/src/nafnagreining/entities.py +326 -0
- nafnagreining-0.1.0/src/nafnagreining/icelandic.py +63 -0
- nafnagreining-0.1.0/src/nafnagreining/py.typed +0 -0
- nafnagreining-0.1.0/tools/hunspell-is/README.md +10 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cylq-is
|
|
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.
|
|
22
|
+
|
|
23
|
+
The word list in src/nafnagreining/data/is_words.txt.gz is not covered by this
|
|
24
|
+
licence; see src/nafnagreining/data/LICENSE-wordlist.txt (CC BY-SA 3.0).
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nafnagreining
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Icelandic language detection and extraction of person names, places and locations
|
|
5
|
+
Project-URL: Homepage, https://github.com/cylq-is/nafnagreining
|
|
6
|
+
Project-URL: Issues, https://github.com/cylq-is/nafnagreining/issues
|
|
7
|
+
Author-email: cylq-is <cylq@proton.me>
|
|
8
|
+
License-Expression: MIT AND CC-BY-SA-3.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
License-File: src/nafnagreining/data/LICENSE-wordlist.txt
|
|
11
|
+
Keywords: icelandic,language-detection,named-entity-recognition,ner,nlp,íslenska
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Natural Language :: Icelandic
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: islenska<3,>=2.1
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# nafnagreining
|
|
25
|
+
|
|
26
|
+
Icelandic language detection and extraction of person names, places and locations.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
>>> from nafnagreining import isIcelandic, extractEntities
|
|
30
|
+
>>> text = "Í dag fór ég í Hólavallakirkjugarð með Önnu móður minni. Hún kom frá Breiðholti."
|
|
31
|
+
>>> isIcelandic(text)
|
|
32
|
+
True
|
|
33
|
+
>>> extractEntities(text)
|
|
34
|
+
{'names': ['Anna'], 'places': ['Breiðholt'], 'locations': ['Hólavallakirkjugarður']}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- `isIcelandic(s) -> bool` decides whether a text is Icelandic. On random
|
|
38
|
+
Wikipedia text in 13 languages it scores about 93% balanced accuracy. Faroese
|
|
39
|
+
is the language it most often confuses with Icelandic.
|
|
40
|
+
- `extractEntities(s) -> Entities` returns a dict with the keys `names`,
|
|
41
|
+
`places` and `locations`. Each key holds a list in the nominative
|
|
42
|
+
("Önnu" → "Anna", "Háskóla Íslands" → "Háskóli Íslands"), in order of first
|
|
43
|
+
appearance and without duplicates. The input must be Icelandic, so check it
|
|
44
|
+
with `isIcelandic` first.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
From PyPI (once published):
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
pip install nafnagreining
|
|
52
|
+
uv add nafnagreining
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Directly from git:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
pip install "nafnagreining @ git+https://github.com/cylq-is/nafnagreining.git"
|
|
59
|
+
uv add "nafnagreining @ git+https://github.com/cylq-is/nafnagreining.git"
|
|
60
|
+
# a specific tag or commit:
|
|
61
|
+
uv add "nafnagreining @ git+https://github.com/cylq-is/nafnagreining.git@v0.1.0"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Requires Python 3.10+. The `islenska` dependency ships prebuilt wheels for
|
|
65
|
+
Linux (glibc, x86_64 and ARM64) and macOS. Other platforms have to compile it
|
|
66
|
+
from source, which needs a C++17 compiler.
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
uv sync # create .venv with the package and dev tools
|
|
72
|
+
uv run pytest -s # run the tests and print accuracy reports
|
|
73
|
+
uv run python tests/test_icelandic.py --refresh # re-download the Wikipedia test text
|
|
74
|
+
uv run python tools/build_wordlist.py # rebuild the Icelandic word list
|
|
75
|
+
uv build # build sdist and wheel into dist/
|
|
76
|
+
uv publish # upload to PyPI (needs a PyPI token)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Data and licences
|
|
80
|
+
|
|
81
|
+
The code is MIT licensed (see `LICENSE`).
|
|
82
|
+
|
|
83
|
+
- **Icelandic word list** (`src/nafnagreining/data/is_words.txt.gz`):
|
|
84
|
+
generated from [hunspell-is](https://github.com/nifgraup/hunspell-is), which
|
|
85
|
+
is based on Icelandic Wiktionary. Licensed CC BY-SA 3.0; see
|
|
86
|
+
`src/nafnagreining/data/LICENSE-wordlist.txt`.
|
|
87
|
+
- **BÍN** (Beygingarlýsing íslensks nútímamáls), © Árni Magnússon Institute for
|
|
88
|
+
Icelandic Studies, CC BY-SA 4.0. It is used through Miðeind's
|
|
89
|
+
[islenska](https://pypi.org/project/islenska/) package and is not bundled here.
|
|
90
|
+
- **Test text** in `tests/data/wiki/` comes from Wikipedia (CC BY-SA 4.0) and
|
|
91
|
+
is not included in the built package.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# nafnagreining
|
|
2
|
+
|
|
3
|
+
Icelandic language detection and extraction of person names, places and locations.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
>>> from nafnagreining import isIcelandic, extractEntities
|
|
7
|
+
>>> text = "Í dag fór ég í Hólavallakirkjugarð með Önnu móður minni. Hún kom frá Breiðholti."
|
|
8
|
+
>>> isIcelandic(text)
|
|
9
|
+
True
|
|
10
|
+
>>> extractEntities(text)
|
|
11
|
+
{'names': ['Anna'], 'places': ['Breiðholt'], 'locations': ['Hólavallakirkjugarður']}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- `isIcelandic(s) -> bool` decides whether a text is Icelandic. On random
|
|
15
|
+
Wikipedia text in 13 languages it scores about 93% balanced accuracy. Faroese
|
|
16
|
+
is the language it most often confuses with Icelandic.
|
|
17
|
+
- `extractEntities(s) -> Entities` returns a dict with the keys `names`,
|
|
18
|
+
`places` and `locations`. Each key holds a list in the nominative
|
|
19
|
+
("Önnu" → "Anna", "Háskóla Íslands" → "Háskóli Íslands"), in order of first
|
|
20
|
+
appearance and without duplicates. The input must be Icelandic, so check it
|
|
21
|
+
with `isIcelandic` first.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
From PyPI (once published):
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pip install nafnagreining
|
|
29
|
+
uv add nafnagreining
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Directly from git:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pip install "nafnagreining @ git+https://github.com/cylq-is/nafnagreining.git"
|
|
36
|
+
uv add "nafnagreining @ git+https://github.com/cylq-is/nafnagreining.git"
|
|
37
|
+
# a specific tag or commit:
|
|
38
|
+
uv add "nafnagreining @ git+https://github.com/cylq-is/nafnagreining.git@v0.1.0"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Python 3.10+. The `islenska` dependency ships prebuilt wheels for
|
|
42
|
+
Linux (glibc, x86_64 and ARM64) and macOS. Other platforms have to compile it
|
|
43
|
+
from source, which needs a C++17 compiler.
|
|
44
|
+
|
|
45
|
+
## Development
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
uv sync # create .venv with the package and dev tools
|
|
49
|
+
uv run pytest -s # run the tests and print accuracy reports
|
|
50
|
+
uv run python tests/test_icelandic.py --refresh # re-download the Wikipedia test text
|
|
51
|
+
uv run python tools/build_wordlist.py # rebuild the Icelandic word list
|
|
52
|
+
uv build # build sdist and wheel into dist/
|
|
53
|
+
uv publish # upload to PyPI (needs a PyPI token)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Data and licences
|
|
57
|
+
|
|
58
|
+
The code is MIT licensed (see `LICENSE`).
|
|
59
|
+
|
|
60
|
+
- **Icelandic word list** (`src/nafnagreining/data/is_words.txt.gz`):
|
|
61
|
+
generated from [hunspell-is](https://github.com/nifgraup/hunspell-is), which
|
|
62
|
+
is based on Icelandic Wiktionary. Licensed CC BY-SA 3.0; see
|
|
63
|
+
`src/nafnagreining/data/LICENSE-wordlist.txt`.
|
|
64
|
+
- **BÍN** (Beygingarlýsing íslensks nútímamáls), © Árni Magnússon Institute for
|
|
65
|
+
Icelandic Studies, CC BY-SA 4.0. It is used through Miðeind's
|
|
66
|
+
[islenska](https://pypi.org/project/islenska/) package and is not bundled here.
|
|
67
|
+
- **Test text** in `tests/data/wiki/` comes from Wikipedia (CC BY-SA 4.0) and
|
|
68
|
+
is not included in the built package.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nafnagreining"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Icelandic language detection and extraction of person names, places and locations"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT AND CC-BY-SA-3.0"
|
|
8
|
+
license-files = ["LICENSE", "src/nafnagreining/data/LICENSE-wordlist.txt"]
|
|
9
|
+
authors = [{ name = "cylq-is", email = "cylq@proton.me" }]
|
|
10
|
+
keywords = ["icelandic", "íslenska", "ner", "named-entity-recognition", "language-detection", "nlp"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Natural Language :: Icelandic",
|
|
15
|
+
"Operating System :: MacOS",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Text Processing :: Linguistic",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["islenska>=2.1,<3"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/cylq-is/nafnagreining"
|
|
25
|
+
Issues = "https://github.com/cylq-is/nafnagreining/issues"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest>=8"]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling>=1.27"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
include = ["src", "README.md", "LICENSE", "pyproject.toml"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Icelandic language detection and extraction of names, places and locations."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .entities import Entities, extractEntities
|
|
6
|
+
from .icelandic import isIcelandic
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("nafnagreining")
|
|
10
|
+
except PackageNotFoundError: # running from a source checkout that isn't installed
|
|
11
|
+
__version__ = "0.0.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["Entities", "extractEntities", "isIcelandic", "__version__"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
is_words.txt.gz — Icelandic word forms
|
|
2
|
+
|
|
3
|
+
This file lists every word form generated from the Icelandic Hunspell dictionary
|
|
4
|
+
(is.dic and is.aff) of the hunspell-is project, by expanding its suffix rules
|
|
5
|
+
with tools/build_wordlist.py. The forms are unchanged; the file only lowercases
|
|
6
|
+
and sorts them.
|
|
7
|
+
|
|
8
|
+
Source: hunspell-is, https://github.com/nifgraup/hunspell-is
|
|
9
|
+
(as distributed in https://github.com/LibreOffice/dictionaries, folder "is")
|
|
10
|
+
Based on: Íslenska Wikiorðabókin (Icelandic Wiktionary), https://is.wiktionary.org,
|
|
11
|
+
and a public-domain word list by Orðabók Háskólans and Reiknistofnun
|
|
12
|
+
Háskóla Íslands
|
|
13
|
+
Licence: Creative Commons Attribution-ShareAlike 3.0 Unported (CC BY-SA 3.0)
|
|
14
|
+
https://creativecommons.org/licenses/by-sa/3.0/legalcode
|
|
15
|
+
|
|
16
|
+
If you redistribute this file or anything made from it, you must give the same
|
|
17
|
+
attribution and share it under CC BY-SA 3.0 or a compatible licence.
|
|
Binary file
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Extract person names, places and locations from Icelandic text.
|
|
2
|
+
|
|
3
|
+
Uses BÍN (Beygingarlýsing íslensks nútímamáls, CC BY-SA 4.0) through Miðeind's
|
|
4
|
+
`islenska` package. BÍN tags proper nouns with a domain (hluti), e.g. `ism` for
|
|
5
|
+
Icelandic first names and `örn` for place names, and lists every inflected form,
|
|
6
|
+
so "Önnu" maps straight to the first name "Anna".
|
|
7
|
+
|
|
8
|
+
The input is assumed to be Icelandic (see icelandic.isIcelandic).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from typing import NamedTuple, TypedDict
|
|
14
|
+
|
|
15
|
+
from islenska import Bin
|
|
16
|
+
|
|
17
|
+
PERSON, PLACE, LOCATION = "person", "place", "location"
|
|
18
|
+
|
|
19
|
+
# BÍN domains (hluti) mapped to our three categories.
|
|
20
|
+
DOMAINS = {
|
|
21
|
+
"ism": PERSON, # Icelandic first names
|
|
22
|
+
"föð": PERSON, # patronymics (-son, -dóttir)
|
|
23
|
+
"móð": PERSON, # matronymics
|
|
24
|
+
"gæl": PERSON, # nicknames
|
|
25
|
+
"ætt": PERSON, # family names
|
|
26
|
+
"örn": PLACE, # place names
|
|
27
|
+
"þor": PLACE, # towns
|
|
28
|
+
"bær": PLACE, # farms
|
|
29
|
+
"lönd": PLACE, # countries and continents
|
|
30
|
+
"erl": PLACE, # foreign place names
|
|
31
|
+
"svaedi": PLACE, # regions and districts
|
|
32
|
+
"heö": PLACE, # neighbourhoods
|
|
33
|
+
"göt": PLACE, # streets and squares
|
|
34
|
+
"fyr": LOCATION, # institutions and companies
|
|
35
|
+
"mvirk": LOCATION, # buildings and structures
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Head words of compounds that BÍN only knows as common nouns, e.g. "Fjölskyldugarðurinn".
|
|
39
|
+
LOCATION_HEADS = (
|
|
40
|
+
"safn", "kirkja", "höll", "skóli", "laug", "völlur", "spítali", "hús", "garður",
|
|
41
|
+
"hótel", "stöð", "búð", "verslun", "torg", "bíó", "leikhús", "kaffihús", "bókasafn",
|
|
42
|
+
"setur", "smiðja", "skáli", "stofa", "heimili",
|
|
43
|
+
)
|
|
44
|
+
PLACE_HEADS = (
|
|
45
|
+
"vogur", "fjörður", "dalur", "nes", "vík", "fjall", "jökull", "foss", "eyri", "hverfi",
|
|
46
|
+
"holt", "gata", "vegur", "braut", "hlíð", "heiði", "sveit", "eyja", "vatn", "fell",
|
|
47
|
+
"strönd", "sýsla", "hreppur", "byggð", "staðir",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Context words that point to a person or a place.
|
|
51
|
+
PLACE_PREPOSITIONS = {"í", "á", "úr", "frá", "til", "um", "við", "yfir", "undir", "að"}
|
|
52
|
+
STRONG_PLACE_PREPOSITIONS = {"í", "úr"} # "í Hörpu" is a venue, never a person
|
|
53
|
+
PERSON_CONTEXT = {
|
|
54
|
+
"með", "hjá", "herra", "frú", "séra",
|
|
55
|
+
*"móðir móður föður faðir systir systur bróðir bróður sonur son syni sonar dóttir dóttur "
|
|
56
|
+
"vinur vin vini vinar vinkona vinkonu vinkonur kona konu konan maður mann manni manns "
|
|
57
|
+
"frændi frænda frænka frænku amma ömmu afi afa".split(),
|
|
58
|
+
}
|
|
59
|
+
# Nouns that refer to a man or a woman and agree with a name next to them in gender
|
|
60
|
+
# and case: "Helga bróður hennar" is "Helgi", "Helga systir hennar" is "Helga".
|
|
61
|
+
KIN_LEMMAS = frozenset(
|
|
62
|
+
"móðir faðir systir bróðir sonur dóttir vinur vinkona frændi frænka amma afi "
|
|
63
|
+
"mamma pabbi eiginmaður eiginkona kærasti kærasta kona maður".split()
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Function words: a capitalised sentence-initial word with one of these readings is
|
|
67
|
+
# just a normal word ("Hún", "Í", "Þegar"), not a name. Verbs, adjectives and numerals
|
|
68
|
+
# are left out on purpose, since "Helga", "Harpa" and "Einar" also have such readings.
|
|
69
|
+
CLOSED_CLASSES = {"pfn", "gr", "fs", "st", "nhm", "uh", "ao", "abfn"}
|
|
70
|
+
# Generic words that start a multi-word location name, e.g. "Hótel Sögu".
|
|
71
|
+
LOCATION_PREFIXES = {"hótel", "kaffi", "café", "gistiheimilið", "veitingastaðurinn"}
|
|
72
|
+
|
|
73
|
+
CASES = ("NF", "ÞGF", "ÞF", "EF") # ÞGF before ÞF so prefix matching works
|
|
74
|
+
# Cases governed by common prepositions, used to pick the right reading of a name:
|
|
75
|
+
# "með Bjarna" is the dative of "Bjarni", not the nominative of the rare "Bjarna".
|
|
76
|
+
PREPOSITION_CASES = {
|
|
77
|
+
"með": ("ÞGF", "ÞF"), "frá": ("ÞGF",), "hjá": ("ÞGF",), "af": ("ÞGF",), "úr": ("ÞGF",),
|
|
78
|
+
"að": ("ÞGF",), "handa": ("ÞGF",), "gegn": ("ÞGF",), "um": ("ÞF",), "gegnum": ("ÞF",),
|
|
79
|
+
"kringum": ("ÞF",), "til": ("EF",), "án": ("EF",), "meðal": ("EF",), "vegna": ("EF",),
|
|
80
|
+
"milli": ("EF",), "við": ("ÞF", "ÞGF"), "eftir": ("ÞF", "ÞGF"), "fyrir": ("ÞF", "ÞGF"),
|
|
81
|
+
"á": ("ÞF", "ÞGF"), "í": ("ÞF", "ÞGF"), "undir": ("ÞF", "ÞGF"), "yfir": ("ÞF", "ÞGF"),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
TOKEN_RE = re.compile(r"[^\W\d_]+(?:-[^\W\d_]+)*")
|
|
85
|
+
SENTENCE_END_RE = re.compile(r"[.!?:;„“\"«»\n]\s*$")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@lru_cache(maxsize=1)
|
|
89
|
+
def _bin():
|
|
90
|
+
return Bin()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _head_category(lemma):
|
|
94
|
+
lemma = lemma.lower()
|
|
95
|
+
if lemma.endswith(LOCATION_HEADS):
|
|
96
|
+
return LOCATION
|
|
97
|
+
if lemma.endswith(PLACE_HEADS):
|
|
98
|
+
return PLACE
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _case(mark):
|
|
103
|
+
"""Grammatical case of a BÍN inflection tag, e.g. 'ÞGFET' -> 'ÞGF'."""
|
|
104
|
+
return next((c for c in CASES if mark.startswith(c)), None)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Reading(NamedTuple):
|
|
108
|
+
"""One BÍN interpretation of a word form, e.g. "Önnu" as the dative of "Anna"."""
|
|
109
|
+
|
|
110
|
+
lemma: str
|
|
111
|
+
gender: str
|
|
112
|
+
case: str
|
|
113
|
+
singular: bool
|
|
114
|
+
core: bool # BÍN core vocabulary ('K') rather than a rarer variant ('V')
|
|
115
|
+
definite: bool # has the suffixed article, e.g. "Húsdýragarðinn"
|
|
116
|
+
category: str
|
|
117
|
+
proper: bool # a proper noun in BÍN, not a common noun classified by its head word
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _reading(m, category, proper=True):
|
|
121
|
+
return Reading(
|
|
122
|
+
m.ord, m.ofl, _case(m.mark), "FT" not in m.mark, m.birting == "K", "gr" in m.mark,
|
|
123
|
+
category, proper,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _analyse(word, sentence_start):
|
|
128
|
+
"""Return (possible categories, is_genitive, readings) for a capitalised word."""
|
|
129
|
+
_, meanings = _bin().lookup_ksnid(word, at_sentence_start=sentence_start)
|
|
130
|
+
proper = [m for m in meanings if m.ord[:1].isupper()]
|
|
131
|
+
if sentence_start and any(m.ofl in CLOSED_CLASSES for m in meanings if not m.ord[:1].isupper()):
|
|
132
|
+
return set(), False, []
|
|
133
|
+
|
|
134
|
+
readings = []
|
|
135
|
+
for m in proper:
|
|
136
|
+
for h in m.hluti.split(","):
|
|
137
|
+
if h == "fyr" and _head_category(m.ord) == PLACE:
|
|
138
|
+
readings.append(_reading(m, PLACE)) # BÍN files some streets as companies, e.g. "Hringbraut"
|
|
139
|
+
elif h in DOMAINS:
|
|
140
|
+
readings.append(_reading(m, DOMAINS[h]))
|
|
141
|
+
categories = {r.category for r in readings}
|
|
142
|
+
if sentence_start and categories == {LOCATION} and any(
|
|
143
|
+
m.ofl in ("kk", "kvk", "hk") and not m.ord[:1].isupper() for m in meanings
|
|
144
|
+
):
|
|
145
|
+
return set(), False, [] # "Fjölskyldan fór ..." is the family, not the company
|
|
146
|
+
if not categories and not sentence_start:
|
|
147
|
+
# Capitalised mid-sentence but only a common noun in BÍN: classify by head word.
|
|
148
|
+
for m in meanings:
|
|
149
|
+
category = _head_category(m.ord) if m.ofl in ("kk", "kvk", "hk") else None
|
|
150
|
+
if category:
|
|
151
|
+
readings.append(_reading(m, category, proper=False))
|
|
152
|
+
categories = {r.category for r in readings}
|
|
153
|
+
is_genitive = any("EF" in m.mark for m in meanings)
|
|
154
|
+
return categories, is_genitive, readings
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _to_nominative(word, r):
|
|
158
|
+
"""Nominative of word under reading r, keeping number and article ("Elliðaánum" -> "Elliðaárnar")."""
|
|
159
|
+
# Match BÍN's capitalisation: "Borgarspítali" is capitalised in BÍN, "fjölskyldugarður" isn't.
|
|
160
|
+
source = word if r.lemma[:1].isupper() else word.lower()
|
|
161
|
+
for v in _bin().lookup_variants(source, r.gender, "NF", lemma=r.lemma):
|
|
162
|
+
if ("FT" not in v.mark) == r.singular and ("gr" in v.mark) == r.definite:
|
|
163
|
+
form = v.bmynd
|
|
164
|
+
break
|
|
165
|
+
else:
|
|
166
|
+
form = r.lemma
|
|
167
|
+
return form[:1].upper() + form[1:]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@lru_cache(maxsize=1024)
|
|
171
|
+
def _kin_agreement(word):
|
|
172
|
+
"""(gender, case) pairs of a family noun such as "bróður", or an empty set."""
|
|
173
|
+
if not word:
|
|
174
|
+
return frozenset()
|
|
175
|
+
_, meanings = _bin().lookup(word.lower())
|
|
176
|
+
return frozenset((m.ofl, _case(m.mark)) for m in meanings if m.ord in KIN_LEMMAS)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
POSSESSIVES = frozenset("minn þinn sinn vor".split())
|
|
180
|
+
GENITIVE_PRONOUNS = frozenset("hans hennar þess þeirra okkar ykkar".split())
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@lru_cache(maxsize=1024)
|
|
184
|
+
def _is_possessive(word):
|
|
185
|
+
"""True for possessives such as "minn", "mína", "hennar"."""
|
|
186
|
+
word = word.lower()
|
|
187
|
+
if word in GENITIVE_PRONOUNS:
|
|
188
|
+
return True
|
|
189
|
+
return any(m.ord in POSSESSIVES for m in _bin().lookup(word)[1])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _nominative(words, readings, previous, category, context=()):
|
|
193
|
+
"""Turn an inflected entity into the nominative: "Jóni Sigurðssyni" -> "Jón Sigurðsson".
|
|
194
|
+
|
|
195
|
+
In a person name every part inflects; in a place or location only the head does
|
|
196
|
+
("Háskóla Íslands" -> "Háskóli Íslands"), or the name after a prefix ("Hótel Sögu"
|
|
197
|
+
-> "Hótel Saga"). The inflecting parts share gender and case, so pick the
|
|
198
|
+
(gender, case) that most of them agree on, then the case the preceding preposition
|
|
199
|
+
governs, then BÍN's core vocabulary, then a form that already is nominative
|
|
200
|
+
("Helga" stays "Helga", not "Helgi"). Words BÍN doesn't know (e.g. "Laxness") are
|
|
201
|
+
kept as written. `context` holds (gender, case) sets of neighbouring words that
|
|
202
|
+
agree with the entity, such as "bróður" in "Helga bróður hennar"; each counts as
|
|
203
|
+
one more part agreeing.
|
|
204
|
+
"""
|
|
205
|
+
if category == PERSON:
|
|
206
|
+
inflecting = range(len(words))
|
|
207
|
+
elif words[0].lower() in LOCATION_PREFIXES:
|
|
208
|
+
inflecting = range(1, len(words))
|
|
209
|
+
else:
|
|
210
|
+
inflecting = range(1)
|
|
211
|
+
# Prefer readings in the entity's own category, e.g. "Esju" as the mountain, not the name.
|
|
212
|
+
candidates = {
|
|
213
|
+
i: [r for r in readings[i] if r.category == category] or readings[i] for i in inflecting
|
|
214
|
+
}
|
|
215
|
+
keys = {(r.gender, r.case) for rs in candidates.values() for r in rs}
|
|
216
|
+
if not keys:
|
|
217
|
+
return " ".join(words)
|
|
218
|
+
governed = PREPOSITION_CASES.get((previous or "").lower(), ())
|
|
219
|
+
|
|
220
|
+
def rank(key):
|
|
221
|
+
matching = [r for rs in candidates.values() for r in rs if (r.gender, r.case) == key]
|
|
222
|
+
support = sum(any((r.gender, r.case) == key for r in rs) for rs in candidates.values())
|
|
223
|
+
support += sum(key in keys_of_neighbour for keys_of_neighbour in context)
|
|
224
|
+
return (support, key[1] in governed, any(r.core for r in matching), key[1] == "NF")
|
|
225
|
+
|
|
226
|
+
best = max(sorted(keys), key=rank)
|
|
227
|
+
parts = list(words)
|
|
228
|
+
for i, rs in candidates.items():
|
|
229
|
+
options = [r for r in rs if (r.gender, r.case) == best]
|
|
230
|
+
if options:
|
|
231
|
+
# Prefer core vocabulary, then singular: "Katrínu" -> "Katrín", not "Katrína".
|
|
232
|
+
parts[i] = _to_nominative(words[i], max(options, key=lambda r: (r.core, r.singular)))
|
|
233
|
+
return " ".join(parts)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _tokens(s):
|
|
237
|
+
"""Yield (word, sentence_start, previous_word) for every word in s."""
|
|
238
|
+
previous, last_end = None, 0
|
|
239
|
+
for match in TOKEN_RE.finditer(s):
|
|
240
|
+
gap = s[last_end:match.start()]
|
|
241
|
+
sentence_start = previous is None or bool(SENTENCE_END_RE.search(gap))
|
|
242
|
+
yield match.group(), sentence_start, previous
|
|
243
|
+
previous, last_end = match.group(), match.end()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _resolve(categories, previous, following, neighbour_is_person):
|
|
247
|
+
"""Pick one category for a word that BÍN lists under several domains."""
|
|
248
|
+
prev = (previous or "").lower()
|
|
249
|
+
nxt = (following or "").lower()
|
|
250
|
+
if PERSON in categories and len(categories) == 1:
|
|
251
|
+
# "tónleikar í Hörpu": a name after í/úr is a venue that BÍN only knows as a name.
|
|
252
|
+
return LOCATION if prev in STRONG_PLACE_PREPOSITIONS else PERSON
|
|
253
|
+
if len(categories) == 1:
|
|
254
|
+
return next(iter(categories))
|
|
255
|
+
if PERSON in categories and (prev in PERSON_CONTEXT or nxt in PERSON_CONTEXT or neighbour_is_person):
|
|
256
|
+
return PERSON
|
|
257
|
+
if prev in PLACE_PREPOSITIONS:
|
|
258
|
+
return PLACE if PLACE in categories else LOCATION
|
|
259
|
+
for category in (PERSON, PLACE, LOCATION):
|
|
260
|
+
if category in categories:
|
|
261
|
+
return category
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class Entities(TypedDict):
|
|
265
|
+
names: list[str]
|
|
266
|
+
places: list[str]
|
|
267
|
+
locations: list[str]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def extractEntities(s) -> Entities:
|
|
271
|
+
"""Return the names, places and locations found in the Icelandic text s.
|
|
272
|
+
|
|
273
|
+
For example {"names": ["Anna"], "places": ["Breiðholt"], "locations": ["Harpa"]}.
|
|
274
|
+
Everything is given in the nominative ("Önnu" -> "Anna", "Breiðholti" ->
|
|
275
|
+
"Breiðholt", "Háskóla Íslands" -> "Háskóli Íslands"). Each list is in order
|
|
276
|
+
of first appearance, without duplicates. Multi-word names such as
|
|
277
|
+
"Jón Sigurðsson" and "Háskóli Íslands" are kept together.
|
|
278
|
+
"""
|
|
279
|
+
tokens = list(_tokens(s))
|
|
280
|
+
analysed = [] # (word, categories, is_genitive, sentence_start, previous, readings)
|
|
281
|
+
for word, sentence_start, previous in tokens:
|
|
282
|
+
if word[0].isupper():
|
|
283
|
+
analysis = _analyse(word, sentence_start)
|
|
284
|
+
else:
|
|
285
|
+
analysis = set(), False, []
|
|
286
|
+
analysed.append((word, analysis[0], analysis[1], sentence_start, previous, analysis[2]))
|
|
287
|
+
|
|
288
|
+
spans = [] # [category, [words], index of last word, index of first word]
|
|
289
|
+
for i, (word, categories, genitive, sentence_start, previous, _) in enumerate(analysed):
|
|
290
|
+
last = spans[-1] if spans else None
|
|
291
|
+
joins = last is not None and last[2] == i - 1 and not sentence_start and word[0].isupper()
|
|
292
|
+
if not categories and not (joins and last[0] == PERSON):
|
|
293
|
+
if word.lower() in LOCATION_PREFIXES and word[0].isupper():
|
|
294
|
+
spans.append([LOCATION, [word], i, i])
|
|
295
|
+
continue
|
|
296
|
+
following = analysed[i + 1][0] if i + 1 < len(analysed) else None
|
|
297
|
+
adjacent = [analysed[j][1] for j in (i - 1, i + 1) if 0 <= j < len(analysed)]
|
|
298
|
+
neighbour_is_person = any(c == {PERSON} for c in adjacent)
|
|
299
|
+
category = _resolve(categories, previous, following, neighbour_is_person) if categories else None
|
|
300
|
+
|
|
301
|
+
if joins and last[0] == PERSON:
|
|
302
|
+
last[1].append(word) # "Jóni" + "Sigurðssyni", "Halldór" + "Laxness"
|
|
303
|
+
elif joins and last[0] == LOCATION and last[1][-1].lower() in LOCATION_PREFIXES:
|
|
304
|
+
last[1].append(word) # "Hótel" + "Sögu"
|
|
305
|
+
elif joins and last[0] == LOCATION and genitive and category == PLACE:
|
|
306
|
+
last[1].append(word) # "Háskóla" + "Íslands"
|
|
307
|
+
else:
|
|
308
|
+
spans.append([category, [word], i, i])
|
|
309
|
+
continue
|
|
310
|
+
last[2] = i
|
|
311
|
+
|
|
312
|
+
result = {PERSON: [], PLACE: [], LOCATION: []}
|
|
313
|
+
for category, words, end, start in spans:
|
|
314
|
+
readings = [a[5] for a in analysed[start:end + 1]]
|
|
315
|
+
context = ()
|
|
316
|
+
if category == PERSON:
|
|
317
|
+
# "Helga bróður hennar", "systur minni Önnu"
|
|
318
|
+
before = [start - 1]
|
|
319
|
+
if start >= 2 and _is_possessive(analysed[start - 1][0]):
|
|
320
|
+
before.append(start - 2) # "bróður minn Helga"
|
|
321
|
+
neighbours = [analysed[j][0] for j in (*before, end + 1) if 0 <= j < len(analysed)]
|
|
322
|
+
context = [k for k in map(_kin_agreement, neighbours) if k]
|
|
323
|
+
text = _nominative(words, readings, analysed[start][4], category, context)
|
|
324
|
+
if text not in result[category]:
|
|
325
|
+
result[category].append(text)
|
|
326
|
+
return {"names": result[PERSON], "places": result[PLACE], "locations": result[LOCATION]}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Icelandic language detection based on the hunspell-is word list."""
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
|
|
9
|
+
WORDLIST = files(__package__) / "data" / "is_words.txt.gz"
|
|
10
|
+
|
|
11
|
+
TOKEN_RE = re.compile(r"[^\W\d_]+")
|
|
12
|
+
ICELANDIC_ONLY = set("þð")
|
|
13
|
+
ICELANDIC_ACCENTS = set("áéíóúýæö")
|
|
14
|
+
FOREIGN_LETTERS = set("øåäüßçñàèìòùâêîôûëïãõœšžčřłśźżőű")
|
|
15
|
+
RARE_LETTERS = set("cqwz")
|
|
16
|
+
# Common Faroese function words that are absent or archaic in modern Icelandic.
|
|
17
|
+
# Some are in the hunspell list anyway, and Faroese is the closest confusable language.
|
|
18
|
+
FAROESE_MARKERS = frozenset(
|
|
19
|
+
"tað ið ella millum hetta hesin hesi hesar hesum eisini vóru tann teir tey sum at eg".split()
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fold(word):
|
|
24
|
+
"""Strip diacritics so 'þetta' style text typed without accents can match."""
|
|
25
|
+
word = word.replace("þ", "th").replace("ð", "d").replace("æ", "ae")
|
|
26
|
+
return "".join(c for c in unicodedata.normalize("NFD", word) if not unicodedata.combining(c))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@lru_cache(maxsize=1)
|
|
30
|
+
def _words():
|
|
31
|
+
with WORDLIST.open("rb") as raw, gzip.open(raw, "rt", encoding="utf-8") as f:
|
|
32
|
+
words = frozenset(f.read().split("\n"))
|
|
33
|
+
return words, frozenset(_fold(w) for w in words)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def score(s):
|
|
37
|
+
"""Return a score in roughly [-1, 1.5]; higher means more likely Icelandic."""
|
|
38
|
+
tokens = TOKEN_RE.findall(s.lower())
|
|
39
|
+
if not tokens:
|
|
40
|
+
return float("-inf")
|
|
41
|
+
words, folded = _words()
|
|
42
|
+
letters = "".join(tokens)
|
|
43
|
+
|
|
44
|
+
hits = sum(t in words for t in tokens)
|
|
45
|
+
folded_hits = sum(t not in words and _fold(t) in folded for t in tokens)
|
|
46
|
+
result = (hits + 0.5 * folded_hits) / len(tokens)
|
|
47
|
+
|
|
48
|
+
if any(c in ICELANDIC_ONLY for c in letters):
|
|
49
|
+
result += 0.3
|
|
50
|
+
elif any(c in ICELANDIC_ACCENTS for c in letters):
|
|
51
|
+
result += 0.1
|
|
52
|
+
result -= 2.0 * sum(c in FOREIGN_LETTERS for c in letters) / len(tokens)
|
|
53
|
+
result -= 0.5 * sum(c in RARE_LETTERS for c in letters) / len(tokens)
|
|
54
|
+
result -= 1.0 * sum(t in FAROESE_MARKERS for t in tokens) / len(tokens)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
THRESHOLD = 0.75
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def isIcelandic(s):
|
|
62
|
+
"""Decide whether the text in s is Icelandic."""
|
|
63
|
+
return score(s) >= THRESHOLD
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Icelandic Hunspell dictionary
|
|
2
|
+
|
|
3
|
+
`is.dic` and `is.aff` are the Icelandic Hunspell dictionary from the
|
|
4
|
+
[hunspell-is](https://github.com/nifgraup/hunspell-is) project, copied unchanged from
|
|
5
|
+
[LibreOffice/dictionaries](https://github.com/LibreOffice/dictionaries/tree/master/is).
|
|
6
|
+
They are based on Icelandic Wiktionary (https://is.wiktionary.org).
|
|
7
|
+
|
|
8
|
+
Licence: [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/legalcode).
|
|
9
|
+
|
|
10
|
+
`tools/build_wordlist.py` expands them into `src/nafnagreining/data/is_words.txt.gz`.
|