Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion builder/additional_methods/Person.py.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,73 @@
return person

def _find_matching_instances(self, client, in_spaces=None, existence_match="equals"):
# with existence_match="normalized", look for the same ORCID before looking for the same name
# with existence_match="normalized", look for the same ORCID before looking for the same name,
# and allow for middle names and initials in the given names
if existence_match == "normalized":
instances = self._find_by_identifiers(client, ("ORCID",), in_spaces=in_spaces)
if instances:
return instances
if (
isinstance(self.given_name, str)
and split_given_names(self.given_name)
and isinstance(self.family_name, str)
and self.family_name.strip()
):
return self._find_by_compatible_names(client, in_spaces=in_spaces)
return super()._find_matching_instances(client, in_spaces=in_spaces, existence_match=existence_match)

def _find_by_compatible_names(self, client, in_spaces=None):
"""
Look for people with an equivalent family name (see `names_equivalent()`) and compatible given names
(see `given_names_compatible()`), so that "James Maxwell", "James C. Maxwell" and "J. Clerk Maxwell"
are all found from "James Clerk Maxwell".

If exactly one of them has the same given names (ignoring case), only that one is returned;
otherwise, if exactly one has equivalent given names, only that one; otherwise all of them.
"""
query_filter = {
"given_name": Regex(build_given_name_regex(self.given_name)),
"family_name": Regex(build_normalized_regex(self.family_name)),
}
compatible = []
equivalent = []
exact = []
for instance in self._run_minimal_query(client, query_filter, in_spaces=in_spaces):
data = client.instance_from_full_uri(instance["@id"], use_cache=True, release_status="any")
if data is None:
continue
stored_values = self.__class__._deserialize_data(data)
stored_given_name = stored_values.get("given_name", None)
stored_family_name = stored_values.get("family_name", None)
if not (isinstance(stored_given_name, str) and isinstance(stored_family_name, str)):
continue
if not (
names_equivalent(stored_family_name, self.family_name)
and given_names_compatible(stored_given_name, self.given_name)
):
continue
compatible.append(instance)
if names_equivalent(stored_given_name, self.given_name):
equivalent.append(instance)
if (
stored_given_name.lower() == self.given_name.lower()
and stored_family_name.lower() == self.family_name.lower()
):
exact.append(instance)
for candidates in (exact, equivalent):
if len(candidates) == 1:
return candidates
return compatible

def _adopt_matched_values(self, data, existence_match):
super()._adopt_matched_values(data, existence_match)
# with existence_match="normalized", the given names may differ by more than names_equivalent() allows
if existence_match == "normalized":
stored_given_name = self.__class__._deserialize_data(data).get("given_name", None)
if (
isinstance(stored_given_name, str)
and isinstance(self.given_name, str)
and stored_given_name != self.given_name
and given_names_compatible(stored_given_name, self.given_name)
):
self.given_name = stored_given_name
8 changes: 8 additions & 0 deletions builder/update_openminds.py
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,14 @@ def get_controlled_terms_table(type_):
"CommonCoordinateSpaceVersion": preamble_for_download,
"CommonCoordinateFrameworkVersion": preamble_for_download,
"AnatomicalAtlasVersion": preamble_for_download,
"Person": """from fairgraph.queries import Regex
from fairgraph.utility.name_matching import (
build_given_name_regex,
build_normalized_regex,
given_names_compatible,
names_equivalent,
split_given_names,
)""",
"ScholarlyArticle": """from fairgraph.utility import as_list
from .publication_issue import PublicationIssue
from .periodical import Periodical""",
Expand Down
9 changes: 9 additions & 0 deletions doc/creatingupdating.rst
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ an ORCID or a ROR ID, respectively, in :attr:`digital_identifiers` are first loo
and by name only if no node has it. A node found by its identifier is updated with the local names
if they differ.

When looking for a :class:`Person` by name with ``existence_match="normalized"``, the given names may
also differ by middle names that appear in only one of them, and by initials in place of names,
so "James Maxwell", "James C. Maxwell" and "J. Clerk Maxwell" all find an existing "James Clerk Maxwell".
The first given names must correspond, however: "Clerk Maxwell" does not. Family names must still match
as described above. If several nodes match, one whose given names match exactly (ignoring case) is preferred,
then one whose given names differ only in case, accents or whitespace. As before, the object takes the
names stored in the Knowledge Graph, so saving "James Maxwell" does not remove the middle name of an existing
"James Clerk Maxwell".


Saving child nodes
==================
Expand Down
2 changes: 2 additions & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Matching names when saving
"Émilie du Châtelet" rather than creating a duplicate. If several instances match, one that matches
exactly (ignoring case) is preferred. In this mode, :class:`~fairgraph.openminds.core.Person` and
:class:`~fairgraph.openminds.core.Organization` are first looked for by ORCID or ROR ID, respectively.
A :class:`~fairgraph.openminds.core.Person` is then looked for by name allowing for middle names and initials,
so "James Maxwell" and "J. Clerk Maxwell" both find an existing "James Clerk Maxwell".
The default, ``existence_match="equals"``, is unchanged
(`#184 <https://github.com/HumanBrainProject/fairgraph/issues/184>`_).

Expand Down
75 changes: 74 additions & 1 deletion fairgraph/openminds/v4/core/actors/person.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
from openminds.v4.core import Person as OMPerson
from fairgraph import KGObject

from fairgraph.queries import Regex
from fairgraph.utility.name_matching import (
build_given_name_regex,
build_normalized_regex,
given_names_compatible,
names_equivalent,
split_given_names,
)


class Person(KGObject, OMPerson):
"""
Expand Down Expand Up @@ -237,9 +246,73 @@ def me(cls, client, allow_multiple=False, follow_links=None):
return person

def _find_matching_instances(self, client, in_spaces=None, existence_match="equals"):
# with existence_match="normalized", look for the same ORCID before looking for the same name
# with existence_match="normalized", look for the same ORCID before looking for the same name,
# and allow for middle names and initials in the given names
if existence_match == "normalized":
instances = self._find_by_identifiers(client, ("ORCID",), in_spaces=in_spaces)
if instances:
return instances
if (
isinstance(self.given_name, str)
and split_given_names(self.given_name)
and isinstance(self.family_name, str)
and self.family_name.strip()
):
return self._find_by_compatible_names(client, in_spaces=in_spaces)
return super()._find_matching_instances(client, in_spaces=in_spaces, existence_match=existence_match)

def _find_by_compatible_names(self, client, in_spaces=None):
"""
Look for people with an equivalent family name (see `names_equivalent()`) and compatible given names
(see `given_names_compatible()`), so that "James Maxwell", "James C. Maxwell" and "J. Clerk Maxwell"
are all found from "James Clerk Maxwell".

If exactly one of them has the same given names (ignoring case), only that one is returned;
otherwise, if exactly one has equivalent given names, only that one; otherwise all of them.
"""
query_filter = {
"given_name": Regex(build_given_name_regex(self.given_name)),
"family_name": Regex(build_normalized_regex(self.family_name)),
}
compatible = []
equivalent = []
exact = []
for instance in self._run_minimal_query(client, query_filter, in_spaces=in_spaces):
data = client.instance_from_full_uri(instance["@id"], use_cache=True, release_status="any")
if data is None:
continue
stored_values = self.__class__._deserialize_data(data)
stored_given_name = stored_values.get("given_name", None)
stored_family_name = stored_values.get("family_name", None)
if not (isinstance(stored_given_name, str) and isinstance(stored_family_name, str)):
continue
if not (
names_equivalent(stored_family_name, self.family_name)
and given_names_compatible(stored_given_name, self.given_name)
):
continue
compatible.append(instance)
if names_equivalent(stored_given_name, self.given_name):
equivalent.append(instance)
if (
stored_given_name.lower() == self.given_name.lower()
and stored_family_name.lower() == self.family_name.lower()
):
exact.append(instance)
for candidates in (exact, equivalent):
if len(candidates) == 1:
return candidates
return compatible

def _adopt_matched_values(self, data, existence_match):
super()._adopt_matched_values(data, existence_match)
# with existence_match="normalized", the given names may differ by more than names_equivalent() allows
if existence_match == "normalized":
stored_given_name = self.__class__._deserialize_data(data).get("given_name", None)
if (
isinstance(stored_given_name, str)
and isinstance(self.given_name, str)
and stored_given_name != self.given_name
and given_names_compatible(stored_given_name, self.given_name)
):
self.given_name = stored_given_name
75 changes: 74 additions & 1 deletion fairgraph/openminds/v5/core/actors/person.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
from openminds.v5.core import Person as OMPerson
from fairgraph import KGObject

from fairgraph.queries import Regex
from fairgraph.utility.name_matching import (
build_given_name_regex,
build_normalized_regex,
given_names_compatible,
names_equivalent,
split_given_names,
)


class Person(KGObject, OMPerson):
"""
Expand Down Expand Up @@ -195,9 +204,73 @@ def me(cls, client, allow_multiple=False, follow_links=None):
return person

def _find_matching_instances(self, client, in_spaces=None, existence_match="equals"):
# with existence_match="normalized", look for the same ORCID before looking for the same name
# with existence_match="normalized", look for the same ORCID before looking for the same name,
# and allow for middle names and initials in the given names
if existence_match == "normalized":
instances = self._find_by_identifiers(client, ("ORCID",), in_spaces=in_spaces)
if instances:
return instances
if (
isinstance(self.given_name, str)
and split_given_names(self.given_name)
and isinstance(self.family_name, str)
and self.family_name.strip()
):
return self._find_by_compatible_names(client, in_spaces=in_spaces)
return super()._find_matching_instances(client, in_spaces=in_spaces, existence_match=existence_match)

def _find_by_compatible_names(self, client, in_spaces=None):
"""
Look for people with an equivalent family name (see `names_equivalent()`) and compatible given names
(see `given_names_compatible()`), so that "James Maxwell", "James C. Maxwell" and "J. Clerk Maxwell"
are all found from "James Clerk Maxwell".

If exactly one of them has the same given names (ignoring case), only that one is returned;
otherwise, if exactly one has equivalent given names, only that one; otherwise all of them.
"""
query_filter = {
"given_name": Regex(build_given_name_regex(self.given_name)),
"family_name": Regex(build_normalized_regex(self.family_name)),
}
compatible = []
equivalent = []
exact = []
for instance in self._run_minimal_query(client, query_filter, in_spaces=in_spaces):
data = client.instance_from_full_uri(instance["@id"], use_cache=True, release_status="any")
if data is None:
continue
stored_values = self.__class__._deserialize_data(data)
stored_given_name = stored_values.get("given_name", None)
stored_family_name = stored_values.get("family_name", None)
if not (isinstance(stored_given_name, str) and isinstance(stored_family_name, str)):
continue
if not (
names_equivalent(stored_family_name, self.family_name)
and given_names_compatible(stored_given_name, self.given_name)
):
continue
compatible.append(instance)
if names_equivalent(stored_given_name, self.given_name):
equivalent.append(instance)
if (
stored_given_name.lower() == self.given_name.lower()
and stored_family_name.lower() == self.family_name.lower()
):
exact.append(instance)
for candidates in (exact, equivalent):
if len(candidates) == 1:
return candidates
return compatible

def _adopt_matched_values(self, data, existence_match):
super()._adopt_matched_values(data, existence_match)
# with existence_match="normalized", the given names may differ by more than names_equivalent() allows
if existence_match == "normalized":
stored_given_name = self.__class__._deserialize_data(data).get("given_name", None)
if (
isinstance(stored_given_name, str)
and isinstance(self.given_name, str)
and stored_given_name != self.given_name
and given_names_compatible(stored_given_name, self.given_name)
):
self.given_name = stored_given_name
81 changes: 81 additions & 0 deletions fairgraph/utility/name_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,84 @@ def names_equivalent(stored: str, local: str) -> bool:
return matches_name(
collapse_whitespace(stored), collapse_whitespace(local), "equals", case_sensitive=False, ignore_accents=True
)


def split_given_names(value: str) -> List[str]:
"""
Split a string containing one or more given names into its parts, in the form in which they are compared
by `given_names_compatible()`: without case, accents or full stops.

Names are separated by whitespace, by hyphens, or by the full stop after an initial,
so "J.-P." and "Jean-Pierre" both have two parts, and so do "J.E." and "J. E.".
"""
tokens = re.split(r"[\s\-‐‑–]+|(?<=\.)", value)
parts = []
for token in tokens:
token = remove_accents(token.casefold()).strip(".")
if token:
parts.append(token)
return parts


def _given_name_parts_compatible(a: str, b: str) -> bool:
"""Whether two parts of a given name may be the same name: they are equal, or one is the initial of the other."""
if a == b:
return True
if len(a) == 1:
return b.startswith(a)
if len(b) == 1:
return a.startswith(b)
return False


def _is_ordered_subset(shorter: List[str], longer: List[str]) -> bool:
"""Whether each of `shorter` corresponds to a different item of `longer`, in the same order."""
if not shorter:
return True
for index, part in enumerate(longer):
if _given_name_parts_compatible(shorter[0], part) and _is_ordered_subset(shorter[1:], longer[index + 1 :]):
return True
return False


def given_names_compatible(stored: str, local: str) -> bool:
"""
Whether two strings of given names may belong to the same person, allowing for middle names that appear
in only one of them, and for initials in place of names.

The first names must correspond: they must be the same (ignoring case, accents and full stops),
or one must be the initial of the other. Each of the remaining names in the string with fewer of them
must correspond in the same way to one of the remaining names in the other, in the same order.
So "James" and "J. Clerk" are both compatible with "James Clerk", but "Clerk" is not,
and neither is "James K.".
"""
stored_parts = split_given_names(stored)
local_parts = split_given_names(local)
if not (stored_parts and local_parts):
return False
if not _given_name_parts_compatible(stored_parts[0], local_parts[0]):
return False
shorter, longer = sorted((stored_parts[1:], local_parts[1:]), key=len)
return _is_ordered_subset(shorter, longer)


def build_given_name_regex(value: str) -> str:
"""
Build a regular expression for use as a KG `REGEX` filter, which selects the stored given names that
`given_names_compatible()` might consider compatible with `value`: those starting with the same letter,
ignoring case and accents. It is a pre-filter, whose results are checked with `given_names_compatible()`.
"""
parts = split_given_names(value)
if not parts:
raise ValueError(f"No given name found in {value!r}")
single, multi = _fold_maps()
# both cases are listed, in case the KG ignores case only for unaccented letters
initials = {parts[0][0], parts[0][0].upper()}
alternatives = set(initials)
for initial in initials:
alternatives |= single.get(initial, set())
# a stored name may begin with a special letter that folds onto two letters, e.g. "Æ" -> "AE"
alternatives |= {
character for folded, characters in multi.items() if folded[0] in initials for character in characters
}
return r"^\s*" + "[" + "".join(re.escape(alternative) for alternative in sorted(alternatives)) + "]"
Loading
Loading