Coverage for src/local_deep_research/journal_quality/scoring.py: 100%

56 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1"""Pure-function quality scoring helpers. 

2 

3These are stateless and called by both the build phase 

4(`db.build_db()` populating the `quality` column) and the runtime 

5filter (when scoring an institution-only fallback). 

6 

7Kept in a small module of their own so the build phase doesn't have 

8to import the read-only DB accessor just to get at the score 

9thresholds. 

10""" 

11 

12from __future__ import annotations 

13 

14import unicodedata 

15from typing import Optional 

16 

17from ..constants import ( 

18 CONFERENCE_QUALITY_DEFAULT, 

19 DOAJ_QUALITY_LISTED, 

20 INSTITUTION_HINDEX_HIGH, 

21 INSTITUTION_HINDEX_TOP, 

22 INSTITUTION_QUALITY_DEFAULT, 

23 INSTITUTION_QUALITY_HIGH, 

24 INSTITUTION_QUALITY_TOP, 

25 JOURNAL_HINDEX_ACCEPTABLE, 

26 JOURNAL_HINDEX_ELITE, 

27 JOURNAL_HINDEX_GOOD, 

28 JOURNAL_HINDEX_STRONG, 

29 JOURNAL_HINDEX_VERY_GOOD, 

30 JOURNAL_QUALITY_ACCEPTABLE, 

31 JOURNAL_QUALITY_DEFAULT, 

32 JOURNAL_QUALITY_ELITE, 

33 JOURNAL_QUALITY_GOOD, 

34 JOURNAL_QUALITY_PREDATORY, 

35 JOURNAL_QUALITY_STRONG, 

36 REPOSITORY_QUALITY_DEFAULT, 

37 JOURNAL_QUALITY_VERY_GOOD, 

38) 

39 

40 

41def normalize_name(name: str) -> str: 

42 """NFKC + lowercase + strip — used for consistent name matching. 

43 

44 Mirrors the previous `_normalize` helper that lived in both 

45 `journal_reference_db.py` and `journal_data_manager.py`. Single 

46 home now so the build phase and the runtime accessor agree. 

47 """ 

48 return unicodedata.normalize("NFKC", name).lower().strip() 

49 

50 

51def derive_quality_score( 

52 *, 

53 h_index: Optional[int] = None, 

54 quartile: Optional[str] = None, 

55 is_in_doaj: bool = False, 

56 is_predatory: bool = False, 

57 source_type: Optional[str] = None, 

58) -> Optional[int]: 

59 """Derive a 1–10 quality score from bibliometric data. 

60 

61 Inputs (in order of preference): 

62 - ``quartile``: SJR-style Q1/Q2/Q3/Q4. Strongest single signal — this 

63 is what librarians and reviewers use when evaluating journals, so 

64 we honour it directly and only use h-index as a tiebreaker. 

65 - ``h_index``: used standalone when no quartile is available. 

66 - ``is_in_doaj``: weakest fall-through signal. 

67 

68 There is deliberately no DOAJ Seal input anymore: DOAJ retired the 

69 Seal in April 2025 and removed it from their metadata, so the old 

70 ``has_doaj_seal`` tier (score 8) could never be earned again and 

71 only ever fired on stale pre-2025 data. 

72 

73 H-index thresholds calibrated from real data: 

74 - Nature h-index: 1,442 

75 - PLOS ONE h-index: 467 

76 - Only 3 journals globally have h-index > 1,000 

77 

78 Note: h-index has field-dependent bias (mathematics journals have 

79 naturally lower h-index than biomedical journals). These thresholds 

80 are general-purpose; field-specific normalization is not yet 

81 implemented. 

82 

83 Returns: 

84 Score 1–10, or `None` if there is not enough signal. 

85 """ 

86 if is_predatory and not is_in_doaj: 

87 return JOURNAL_QUALITY_PREDATORY # Auto-remove threshold 

88 

89 # Preprint repositories (arXiv, bioRxiv, SSRN, PsyArXiv, ...) are 

90 # not peer-reviewed. Their h-index reflects citation accumulation 

91 # across the thousands of papers they aggregate — not venue rigor. 

92 # Cap them at the ACCEPTABLE tier so Q-tier semantics remain 

93 # meaningful. The filter's Tier 3.5 institution-salvage path can 

94 # lift this via author affiliations when appropriate. 

95 # 

96 # NOTE: only ``"repository"`` is capped here. ``"conference"`` gets 

97 # its own flat score below via the ``source_type == "conference"`` 

98 # branch. Other OpenAlex source types — ``"book series"`` (Springer 

99 # Lecture Notes etc.) and ``"ebook platform"`` (Elsevier 

100 # ScienceDirect, Springer Link) — CAN be peer-reviewed, so we 

101 # intentionally let h-index scoring apply for them. Reviewed in 

102 # the PR #3081 audit; not a gap. 

103 if source_type == "repository": 

104 return REPOSITORY_QUALITY_DEFAULT 

105 

106 # Quartile takes precedence — it is the canonical librarian signal. 

107 # We still let a high h-index bump a Q1 to "elite" so Nature stays 

108 # distinguishable from a typical Q1. DOAJ listing applies 

109 # orthogonally via max() so it cannot be silently discarded. 

110 if quartile: 

111 q = quartile.upper().strip() 

112 q_score: Optional[int] = None 

113 if q == "Q1": 

114 if h_index and h_index > JOURNAL_HINDEX_ELITE: 

115 q_score = JOURNAL_QUALITY_ELITE 

116 else: 

117 q_score = JOURNAL_QUALITY_STRONG 

118 elif q == "Q2": 

119 q_score = JOURNAL_QUALITY_VERY_GOOD 

120 elif q == "Q3": 

121 q_score = JOURNAL_QUALITY_GOOD 

122 elif q == "Q4": 

123 q_score = JOURNAL_QUALITY_ACCEPTABLE 

124 

125 if q_score is not None: 

126 if is_in_doaj: 

127 return max(q_score, DOAJ_QUALITY_LISTED) 

128 return q_score 

129 

130 # h_index=0 means newly indexed, not meaningful. Negative values 

131 # would be a data error — treat as no signal rather than returning 

132 # DEFAULT which is ambiguous. 

133 if h_index and h_index > 0: 

134 if h_index > JOURNAL_HINDEX_ELITE: 

135 h_score = JOURNAL_QUALITY_ELITE # Nature/Science/NEJM 

136 elif h_index > JOURNAL_HINDEX_STRONG: 

137 h_score = JOURNAL_QUALITY_STRONG 

138 elif h_index > JOURNAL_HINDEX_VERY_GOOD: 

139 h_score = JOURNAL_QUALITY_VERY_GOOD 

140 elif h_index > JOURNAL_HINDEX_GOOD: 

141 h_score = JOURNAL_QUALITY_GOOD 

142 elif h_index > JOURNAL_HINDEX_ACCEPTABLE: 

143 h_score = JOURNAL_QUALITY_ACCEPTABLE 

144 else: 

145 h_score = JOURNAL_QUALITY_DEFAULT 

146 

147 # DOAJ listing is an orthogonal quality signal (verified open 

148 # access). Use max() so the signals reinforce rather than 

149 # conflict. 

150 if is_in_doaj: 

151 return max(h_score, DOAJ_QUALITY_LISTED) 

152 return h_score 

153 

154 if is_in_doaj: 

155 return DOAJ_QUALITY_LISTED 

156 

157 if source_type == "conference": 

158 return CONFERENCE_QUALITY_DEFAULT # Neutral — in CS, top conferences are Q1-equivalent 

159 

160 return None # Insufficient data 

161 

162 

163def institution_score_from_h_index(h_index: Optional[int]) -> Optional[int]: 

164 """Derive a quality score from an institution's h-index. 

165 

166 Capped at 6 — institution alone never beats a real venue match. 

167 Used by the Tier 3.5 affiliation salvage path in the filter. 

168 """ 

169 if h_index is None: 

170 return None 

171 if h_index > INSTITUTION_HINDEX_TOP: 

172 return INSTITUTION_QUALITY_TOP # Top-tier research universities 

173 if h_index > INSTITUTION_HINDEX_HIGH: 

174 return INSTITUTION_QUALITY_HIGH 

175 return INSTITUTION_QUALITY_DEFAULT