scholarr/app/services/openalex/matching.py
Justin Visser 3866c6d6f0 ci: add CodeQL security scanning and Dependabot
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:05:17 +01:00

106 lines
3.4 KiB
Python

import logging
import re
from rapidfuzz import fuzz
from app.services.domains.openalex.types import OpenAlexWork
logger = logging.getLogger(__name__)
# A minimum similarity score out of 100 for a title to be considered a match candidate.
TITLE_MATCH_THRESHOLD = 90.0
# The margin within the top score where a secondary tiebreaker (author/year) is necessary.
TIEBREAKER_MARGIN = 5.0
def _clean_string(s: str | None) -> str:
if not s:
return ""
# Strip non-alphanumeric (keep spaces), lowercase, and collapse whitespace
cleaned = re.sub(r"[^a-z0-9\s]", " ", s.lower())
return " ".join(cleaned.split())
def _author_overlap_score(target_authors: str | None, candidate_authors: list[str]) -> bool:
if not target_authors or not candidate_authors:
return False
target_clean = _clean_string(target_authors)
if not target_clean:
return False
for candidate in candidate_authors:
cand_clean = _clean_string(candidate)
if cand_clean and (cand_clean in target_clean or target_clean in cand_clean):
return True
# Alternatively check rapidfuzz token_set_ratio
if cand_clean and fuzz.token_set_ratio(target_clean, cand_clean) > 80:
return True
return False
def find_best_match(
target_title: str,
target_year: int | None,
target_authors: str | None,
candidates: list[OpenAlexWork],
) -> OpenAlexWork | None:
"""
Finds the best matching OpenAlexWork from a list of candidates, prioritizing title similarity (>90%)
with year and author overlap as tiebreakers for close candidates.
"""
if not target_title or not candidates:
return None
clean_target = _clean_string(target_title)
if not clean_target:
return None
scored_candidates: list[tuple[float, OpenAlexWork]] = []
for cand in candidates:
if not cand.title:
continue
clean_cand = _clean_string(cand.title)
# Primary sort: string similarity ratio
score = fuzz.ratio(clean_target, clean_cand)
if score >= TITLE_MATCH_THRESHOLD:
scored_candidates.append((score, cand))
if not scored_candidates:
return None
# Sort descending by score
scored_candidates.sort(key=lambda x: x[0], reverse=True)
best_score = scored_candidates[0][0]
# Extract all candidates within the tiebreaker margin
top_scored_candidates = [
(score, cand) for score, cand in scored_candidates if best_score - score <= TIEBREAKER_MARGIN
]
if len(top_scored_candidates) == 1:
return top_scored_candidates[0][1]
# We have a tie or near-tie. Use year and author overlap to break the tie.
# Score candidates: +1 for year match (within 1 year), +1 for author overlap
tiebreaker_scores: list[tuple[int, float, OpenAlexWork]] = []
for original_score, cand in top_scored_candidates:
tb_score = 0
if target_year is not None and cand.publication_year is not None and abs(target_year - cand.publication_year) <= 1:
tb_score += 1
candidate_author_names = [a.display_name for a in cand.authors if a.display_name]
if _author_overlap_score(target_authors, candidate_author_names):
tb_score += 1
tiebreaker_scores.append((tb_score, original_score, cand))
tiebreaker_scores.sort(key=lambda x: (x[0], x[1]), reverse=True)
return tiebreaker_scores[0][2]