-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Add RefChecker logic for reference validation #15478
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
52be9eb
b475524
4c6ab30
ab3dedd
cb9d88c
f4eb226
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| import java.util.Collection; | ||
| import java.util.HashMap; | ||
| import java.util.HashSet; | ||
| import java.util.List; | ||
| import java.util.Locale; | ||
| import java.util.Map; | ||
| import java.util.Objects; | ||
|
|
@@ -16,13 +17,15 @@ | |
| import org.jabref.logic.util.strings.StringUtil; | ||
| import org.jabref.model.database.BibDatabase; | ||
| import org.jabref.model.database.BibDatabaseMode; | ||
| import org.jabref.model.entry.Author; | ||
| import org.jabref.model.entry.AuthorList; | ||
| import org.jabref.model.entry.BibEntry; | ||
| import org.jabref.model.entry.BibEntryType; | ||
| import org.jabref.model.entry.BibEntryTypesManager; | ||
| import org.jabref.model.entry.field.BibField; | ||
| import org.jabref.model.entry.field.Field; | ||
| import org.jabref.model.entry.field.FieldProperty; | ||
| import org.jabref.model.entry.field.InternalField; | ||
| import org.jabref.model.entry.field.OrFields; | ||
| import org.jabref.model.entry.field.StandardField; | ||
| import org.jabref.model.entry.identifier.ISBN; | ||
|
|
@@ -34,9 +37,20 @@ | |
|
|
||
| /// This class contains utility method for duplicate checking of entries. | ||
| public class DuplicateCheck { | ||
| public static final double COMPARE_ENTRIES_THRESHOLD = 0.8; // The threshold that determines if entries are likely to be of the same publication | ||
| private static final double DUPLICATE_THRESHOLD = 0.75; // The overall threshold to signal a duplicate pair | ||
|
|
||
| private static final Logger LOGGER = LoggerFactory.getLogger(DuplicateCheck.class); | ||
|
|
||
| private static final Map<Field, Double> COMPARE_ENTRIES_FIELD_WEIGHTS = Map.of( | ||
| StandardField.AUTHOR, 2.5, | ||
| StandardField.EDITOR, 2.5, | ||
| StandardField.TITLE, 3.0, | ||
| StandardField.JOURNAL, 2.0, | ||
| StandardField.NOTE, 0.1, | ||
| StandardField.COMMENT, 0.1 | ||
| ); | ||
|
|
||
| /* | ||
| * Integer values for indicating result of duplicate check (for entries): | ||
| */ | ||
|
|
@@ -340,4 +354,86 @@ public Optional<BibEntry> containsDuplicate(final BibDatabase database, | |
| final BibDatabaseMode bibDatabaseMode) { | ||
| return database.getEntries().stream().filter(other -> isDuplicate(entry, other, bibDatabaseMode)).findFirst(); | ||
| } | ||
|
|
||
| /// Computes a weighted similarity score between two entries for reference checking purposes. | ||
| /// | ||
| /// Only fields present in one are scored. Internal fields and identifier fields | ||
| /// such as DOI and EPRINT are excluded since they are used for lookup not comparison. | ||
| /// | ||
| /// If one contains a field that two does not similarity for that field is 0.0 | ||
| /// and its weight still counts toward the denominator. This conservatively lowers | ||
| /// the score rather than silently ignoring the discrepancy. | ||
| /// | ||
| /// Person name fields are compared author by author at matching positions. | ||
| /// The word "others" (e.g, author name1 , author name2, and others) is stripped before comparison | ||
| /// to handle abbreviated author lists. | ||
| /// Only authors listed in one are compared against two at the same position | ||
| /// so abbreviated local lists match complete fetched lists without penalty | ||
| /// while invented authors are still penalized. | ||
| /// | ||
| /// @param one the local entry to check (drives which fields are scored) | ||
| /// @param two the authoritative entry fetched from an online source | ||
| /// @return weighted similarity score in [0.0, 1.0] | ||
| public static double compareEntries(BibEntry one, BibEntry two) { | ||
| StringSimilarity stringSimilarity = new StringSimilarity(); | ||
|
|
||
| List<Field> localFields = one.getFields().stream() | ||
| .filter(field -> !(field instanceof InternalField)) | ||
| .filter(field -> !field.getProperties().contains(FieldProperty.IDENTIFIER)) | ||
| .toList(); | ||
|
|
||
| if (localFields.isEmpty()) { | ||
| return 0.0; | ||
| } | ||
|
|
||
| double totalWeight = 0.0; | ||
| double weightedSimilaritySum = 0.0; | ||
|
|
||
| for (Field field : localFields) { | ||
| String firstValue = one.getFieldLatexFree(field).orElse(""); | ||
| String secondValue = two.getFieldLatexFree(field).orElse(""); | ||
|
|
||
| double similarity; | ||
| if (field.getProperties().contains(FieldProperty.PERSON_NAMES)) { | ||
| List<Author> localAuthors = AuthorList.parse(firstValue).getAuthors().stream() | ||
| .filter(a -> !a.getFamilyGiven(false).equalsIgnoreCase("others")) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update: traced through source, Author.OTHERS.getFamilyGiven(false) returns "others" exactly, the filter works. |
||
| .toList(); | ||
| List<Author> authoritativeAuthors = AuthorList.parse(secondValue).getAuthors(); | ||
|
|
||
| if (localAuthors.isEmpty()) { | ||
| similarity = 0.0; | ||
| } else { | ||
| int count = Math.min(localAuthors.size(), authoritativeAuthors.size()); | ||
| double authorSimilaritySum = 0.0; | ||
| for (int i = 0; i < count; i++) { | ||
| Author localAuthor = localAuthors.get(i); | ||
| Author authAuthor = authoritativeAuthors.get(i); | ||
|
|
||
| String localFamily = localAuthor.getFamilyName().orElse("").toLowerCase(Locale.ROOT); | ||
| String authFamily = authAuthor.getFamilyName().orElse("").toLowerCase(Locale.ROOT); | ||
| double familySimilarity = stringSimilarity.similarity(localFamily, authFamily); | ||
|
|
||
| String localGiven = localAuthor.getGivenName().orElse("").toLowerCase(Locale.ROOT); | ||
| String authGiven = authAuthor.getGivenName().orElse("").toLowerCase(Locale.ROOT); | ||
| double givenSimilarity = (localGiven.isEmpty() || authGiven.isEmpty()) | ||
| ? 1.0 | ||
| : stringSimilarity.similarity(localGiven, authGiven); | ||
|
|
||
|
Comment on lines
+418
to
+421
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If local has "Smith" (no given) and authoritative has "Smith, John", given similarity = 1.0, this is reasonable for abbreviation, but the two branches (local-empty vs authoritative-empty vs both-empty) have different semantic meanings, i think it worth adding explicit tests for each branch so future tweaks don't shift behavior silently |
||
| authorSimilaritySum += (familySimilarity + givenSimilarity) / 2.0; | ||
| } | ||
| similarity = authorSimilaritySum / localAuthors.size(); | ||
| } | ||
| } else { | ||
| similarity = stringSimilarity.similarity( | ||
| firstValue.toLowerCase(Locale.ROOT), | ||
| secondValue.toLowerCase(Locale.ROOT)); | ||
| } | ||
|
|
||
| double weight = COMPARE_ENTRIES_FIELD_WEIGHTS.getOrDefault(field, 1.0); | ||
| weightedSimilaritySum += similarity * weight; | ||
| totalWeight += weight; | ||
| } | ||
|
|
||
| return totalWeight > 0 ? weightedSimilaritySum / totalWeight : 0.0; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package org.jabref.logic.refcheck; | ||
|
|
||
| import org.jabref.model.entry.BibEntry; | ||
|
|
||
| import org.jspecify.annotations.Nullable; | ||
|
|
||
| public record RefCheckResult(RefValidity validity, @Nullable BibEntry otherEntry, double similarityScore) { | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
compareEntries only iterates local fields, entry with just {TITLE} matching an authoritative entry -> score 1.0 -> REAL, regardless of mismatching author/year in the authoritative entry.
i think this means a hallucinated reference with just {title, year} faking a real DOI could land as REAL even if the authoritative entry has author/journal info that contradicts nothing (because those fields aren't compared)(right?)
try to consider requiring a minimum number of comparable fields or penalizing missing core fields