1use std::collections::BTreeMap;
23
24use serde_json::Value;
25
26use crate::source::{FetchContext, Source};
27use crate::store::Metadata;
28use crate::{CapabilityProfile, Ref};
29
30pub const REPLACEMENT_CHAR: char = '\u{FFFD}';
32
33const CHECKED_FIELDS: &[&str] = &["title", "authors", "venue", "publisher", "abstract"];
35
36#[must_use]
39pub fn replacement_char_fields(m: &Metadata) -> Vec<&'static str> {
40 CHECKED_FIELDS
41 .iter()
42 .copied()
43 .filter(|f| match *f {
44 "title" => has_replacement_char(&m.title),
45 "authors" => m.authors.iter().any(|a| has_replacement_char(a)),
46 "venue" => m.venue.as_deref().is_some_and(has_replacement_char),
47 "publisher" => m.publisher.as_deref().is_some_and(has_replacement_char),
48 "abstract" => m.abstract_.as_deref().is_some_and(has_replacement_char),
49 _ => false,
50 })
51 .collect()
52}
53
54#[must_use]
56pub fn has_replacement_char(s: &str) -> bool {
57 s.contains(REPLACEMENT_CHAR)
58}
59
60#[must_use]
70pub fn restores(damaged: &str, candidate: &str) -> bool {
71 if has_replacement_char(candidate) || !has_replacement_char(damaged) {
72 return false;
73 }
74 let c: Vec<char> = candidate.chars().collect();
75 let d_len = damaged.chars().count();
76 let lost = damaged.chars().filter(|&ch| ch == REPLACEMENT_CHAR).count();
77 if d_len > RESTORE_MAX_CHARS || c.len() > RESTORE_MAX_CHARS {
78 return false;
79 }
80 if c.len() < d_len || c.len() > d_len + lost {
82 return false;
83 }
84 let mut reach = vec![false; c.len() + 1];
86 let mut next = vec![false; c.len() + 1];
87 reach[0] = true;
88 for dc in damaged.chars() {
89 next.fill(false);
90 for j in (0..=c.len()).filter(|&j| reach[j]) {
91 if dc == REPLACEMENT_CHAR {
92 for step in 1..=2 {
93 if j + step <= c.len() {
94 next[j + step] = true;
95 }
96 }
97 } else if c.get(j) == Some(&dc) {
98 next[j + 1] = true;
99 }
100 }
101 if !next.contains(&true) {
102 return false;
103 }
104 std::mem::swap(&mut reach, &mut next);
105 }
106 reach[c.len()]
107}
108
109pub const RESTORE_MAX_CHARS: usize = 2_000;
112
113#[derive(Debug, Clone, Default, PartialEq, Eq)]
115pub struct QualityReport {
116 pub repaired: BTreeMap<String, String>,
118 pub remaining: Vec<&'static str>,
120}
121
122impl QualityReport {
123 #[must_use]
127 pub fn flags(&self) -> Vec<String> {
128 self.remaining
129 .iter()
130 .map(|f| format!("replacement_char:{f}"))
131 .collect()
132 }
133}
134
135pub async fn repair_with(
144 m: &mut Metadata,
145 sources: &[&dyn Source],
146 profile: &CapabilityProfile,
147 ctx: &FetchContext,
148) -> QualityReport {
149 let mut report = QualityReport::default();
150 let repairable: Vec<&'static str> = replacement_char_fields(m)
151 .into_iter()
152 .filter(|f| matches!(*f, "title" | "venue"))
153 .collect();
154 if let (false, Some(doi)) = (repairable.is_empty(), m.doi.clone()) {
155 let ref_ = Ref::Doi(doi);
156 for src in sources {
157 if repairable.iter().all(|f| report.repaired.contains_key(*f)) {
158 break;
159 }
160 if !src.can_serve(profile, &ref_) {
161 continue;
162 }
163 let work = match src.fetch(&ref_, profile, ctx).await {
164 Ok(r) => r.metadata_json,
165 Err(e) => {
166 tracing::warn!(
167 source = src.name(),
168 error = %e,
169 "could not ask this source to repair a U+FFFD in the metadata"
170 );
171 continue;
172 }
173 };
174 let Some(work) = work else { continue };
175 for field in &repairable {
176 if report.repaired.contains_key(*field) {
177 continue;
178 }
179 let Some(found) = candidate(src.name(), field, &work) else {
180 continue;
181 };
182 let slot = match *field {
183 "title" => &mut m.title,
184 _ => match m.venue.as_mut() {
185 Some(v) => v,
186 None => continue,
187 },
188 };
189 if restores(slot, &found) {
190 *slot = found;
191 report
192 .repaired
193 .insert((*field).to_string(), src.name().to_string());
194 }
195 }
196 }
197 }
198 report.remaining = replacement_char_fields(m);
199 if let Some(d) = m.doiget.as_mut() {
200 d.repaired_fields.extend(report.repaired.clone());
201 }
202 report
203}
204
205pub async fn repair(
211 m: &mut Metadata,
212 profile: &CapabilityProfile,
213 ctx: &FetchContext,
214) -> QualityReport {
215 if replacement_char_fields(m).is_empty() {
216 return QualityReport::default();
217 }
218 #[cfg(not(feature = "metadata"))]
219 {
220 let _ = (profile, ctx);
221 QualityReport {
222 remaining: replacement_char_fields(m),
223 ..QualityReport::default()
224 }
225 }
226 #[cfg(feature = "metadata")]
227 {
228 let s2 = crate::sources::s2::S2Source::new(
229 std::env::var("DOIGET_S2_API_KEY")
230 .ok()
231 .filter(|k| !k.is_empty()),
232 );
233 let openalex = crate::sources::openalex::OpenalexSource::new(
234 crate::orchestrator::resolve_contact_email(),
235 );
236 repair_with(m, &[&s2, &openalex], profile, ctx).await
237 }
238}
239
240fn candidate(source: &str, field: &str, work: &Value) -> Option<String> {
242 let s = match (source, field) {
243 ("semantic_scholar", "title") => work.get("title")?.as_str()?,
244 ("semantic_scholar", "venue") => work.get("venue")?.as_str()?,
245 ("openalex", "title") => work
246 .get("title")
247 .or_else(|| work.get("display_name"))?
248 .as_str()?,
249 ("openalex", "venue") => work
250 .get("primary_location")?
251 .get("source")?
252 .get("display_name")?
253 .as_str()?,
254 _ => return None,
255 };
256 let s = s.trim();
257 (!s.is_empty()).then(|| s.to_string())
258}
259
260#[cfg(test)]
261#[allow(clippy::expect_used, clippy::unwrap_used)]
262mod tests {
263 use super::*;
264
265 const F: char = REPLACEMENT_CHAR;
266
267 #[test]
268 fn a_candidate_restores_only_the_characters_that_were_lost() {
269 let z = format!("Zeitschrift f{F}r Physik");
270 assert!(restores(&z, "Zeitschrift für Physik"));
271 assert!(!restores(&z, "The European Physical Journal A"));
273 assert!(!restores(&z, "Zeitschrift für physik"));
275 let n = format!("N{F}herungsmethode zur L{F}sung");
276 assert!(restores(&n, "Näherungsmethode zur Lösung"));
277 assert!(restores(&n, "Na\u{308}herungsmethode zur Lösung"));
279 assert!(!restores(&n, "Nxyzherungsmethode zur Lösung"));
281 assert!(!restores(&n, "Nherungsmethode zur Lösung"));
282 }
283
284 #[test]
285 fn an_oversized_answer_is_never_aligned() {
286 let long = format!("{}{F}", "a".repeat(RESTORE_MAX_CHARS));
287 assert!(!restores(
288 &long,
289 &format!("{}b", "a".repeat(RESTORE_MAX_CHARS))
290 ));
291 let short = format!("x{F}y");
292 assert!(restores(&short, "xüy"));
293 assert!(!restores(&short, &"x".repeat(50_000)));
294 }
295
296 #[test]
297 fn a_candidate_carrying_the_same_loss_is_not_a_repair() {
298 let z = format!("Zeitschrift f{F}r Physik");
299 assert!(!restores(&z, &z));
300 assert!(!restores(
301 "Zeitschrift für Physik",
302 "Zeitschrift für Physik"
303 ));
304 }
305
306 #[test]
307 fn every_checked_field_is_detected() {
308 let m = Metadata {
309 title: format!("Wechselwirkung neutraler Atome und {F} Bindung"),
310 authors: vec!["London, F.".into(), format!("M{F}ller, A.")],
311 venue: Some(format!("Zeitschrift f{F}r Physik")),
312 publisher: Some("Springer".into()),
313 ..Metadata::default()
314 };
315 assert_eq!(
316 replacement_char_fields(&m),
317 vec!["title", "authors", "venue"]
318 );
319 let report = QualityReport {
320 remaining: vec!["venue"],
321 ..QualityReport::default()
322 };
323 assert_eq!(report.flags(), vec!["replacement_char:venue".to_string()]);
324 }
325
326 #[cfg(feature = "metadata")]
330 #[tokio::test]
331 async fn repair_takes_a_matching_title_records_it_and_leaves_the_rest_flagged() {
332 use std::sync::Arc;
333
334 use camino::Utf8PathBuf;
335 use wiremock::matchers::{method, path};
336 use wiremock::{Mock, MockServer, ResponseTemplate};
337
338 use crate::http::HttpClient;
339 use crate::provenance::ProvenanceLog;
340 use crate::rate_limiter::RateLimiter;
341 use crate::sources::s2::S2Source;
342 use crate::store::DoigetExtension;
343 use crate::{Doi, RateLimits};
344
345 let server = MockServer::start().await;
346 Mock::given(method("GET"))
347 .and(path("/graph/v1/paper/DOI:10.1007/BF01340294"))
348 .respond_with(ResponseTemplate::new(200).set_body_string(
349 r#"{"paperId": "bf0e", "title": "Näherungsmethode zur Lösung", "venue": ""}"#,
350 ))
351 .expect(1)
352 .mount(&server)
353 .await;
354 let td = tempfile::TempDir::new().expect("tempdir");
355 let log_path = Utf8PathBuf::try_from(td.path().join("t.jsonl")).expect("utf-8");
356 let session_id = "01J0000000000000000000TEST".to_string();
357 let ctx = FetchContext {
358 http: Arc::new(HttpClient::new_for_tests_allow_http(
359 "semantic_scholar",
360 &server.uri(),
361 )),
362 rate_limiter: Arc::new(RateLimiter::new(RateLimits::HARD_CODED)),
363 log: Arc::new(ProvenanceLog::open(log_path, session_id.clone()).expect("log")),
364 session_id,
365 cache_root: None,
366 };
367 let s2 = S2Source::with_base(url::Url::parse(&server.uri()).expect("uri"), None);
368
369 let mut m = Metadata {
370 title: format!("N{F}herungsmethode zur L{F}sung"),
371 venue: Some(format!("Zeitschrift f{F}r Physik")),
372 doi: Some(Doi::parse("10.1007/BF01340294").expect("doi")),
373 doiget: Some(DoigetExtension {
374 fetched_at: chrono::Utc::now(),
375 source: "crossref".into(),
376 license: "unknown".into(),
377 oa_status: None,
378 size_bytes: 0,
379 mcp_call_id: None,
380 tags: Vec::new(),
381 collections: Vec::new(),
382 annotation: None,
383 repaired_fields: BTreeMap::new(),
384 short_venue: None,
385 origin: None,
386 }),
387 ..Metadata::default()
388 };
389
390 let off = CapabilityProfile::for_tests();
392 let report = repair_with(&mut m.clone(), &[&s2], &off, &ctx).await;
393 assert!(report.repaired.is_empty());
394 assert_eq!(report.remaining, vec!["title", "venue"]);
395
396 let mut on = CapabilityProfile::for_tests();
397 on.metadata.semantic_scholar = true;
398 let report = repair_with(&mut m, &[&s2], &on, &ctx).await;
399 assert_eq!(m.title, "Näherungsmethode zur Lösung");
400 assert_eq!(
401 m.venue.as_deref(),
402 Some(format!("Zeitschrift f{F}r Physik").as_str())
403 );
404 assert_eq!(
405 report.repaired.get("title").map(String::as_str),
406 Some("semantic_scholar")
407 );
408 assert_eq!(report.remaining, vec!["venue"]);
409 assert_eq!(
410 m.doiget.as_ref().map(|d| d.repaired_fields.clone()),
411 Some(report.repaired.clone())
412 );
413 }
414
415 #[test]
416 fn candidates_are_read_from_each_source_shape() {
417 let s2 = serde_json::json!({"paperId": "x", "title": " Näherungsmethode ", "venue": ""});
418 assert_eq!(
419 candidate("semantic_scholar", "title", &s2).as_deref(),
420 Some("Näherungsmethode")
421 );
422 assert_eq!(candidate("semantic_scholar", "venue", &s2), None);
423 let oa = serde_json::json!({
424 "display_name": "Näherungsmethode",
425 "primary_location": {"source": {"display_name": "Zeitschrift für Physik"}}
426 });
427 assert_eq!(
428 candidate("openalex", "title", &oa).as_deref(),
429 Some("Näherungsmethode")
430 );
431 assert_eq!(
432 candidate("openalex", "venue", &oa).as_deref(),
433 Some("Zeitschrift für Physik")
434 );
435 }
436}