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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
@@ -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.
@@ -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`.