voidful commited on
Commit
a2ec1b5
·
verified ·
1 Parent(s): 740846d

Add manual review controls

Browse files
Files changed (2) hide show
  1. app.py +226 -3
  2. src/space_service.py +163 -13
app.py CHANGED
@@ -6,7 +6,13 @@ from typing import Any
6
 
7
  import gradio as gr
8
 
9
- from src.space_service import RefCheckOptions, run_refcheck_file
 
 
 
 
 
 
10
 
11
 
12
  def _uploaded_path(uploaded: Any) -> str | None:
@@ -44,8 +50,179 @@ def process_bib(
44
  return f"## RefCheck Report\n\nProcessing failed: `{exc}`", None, None
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  with gr.Blocks(title="RefCheck") as demo:
48
  gr.Markdown("# RefCheck")
 
49
 
50
  with gr.Row():
51
  with gr.Column(scale=1):
@@ -55,8 +232,8 @@ with gr.Blocks(title="RefCheck") as demo:
55
  type="filepath",
56
  )
57
  remove_unverified = gr.Checkbox(
58
- label="Remove unverifiable entries",
59
- value=True,
60
  )
61
  enable_google_scholar = gr.Checkbox(
62
  label="Google Scholar fallback",
@@ -70,18 +247,64 @@ with gr.Blocks(title="RefCheck") as demo:
70
  value=4,
71
  )
72
  run_button = gr.Button("Run RefCheck", variant="primary")
 
73
 
74
  with gr.Column(scale=2):
75
  report = gr.Markdown(label="Report")
76
  fixed_bib = gr.File(label="Fixed BibTeX")
77
  report_file = gr.File(label="Markdown report")
 
 
 
 
 
 
 
78
 
79
  run_button.click(
 
 
 
 
 
 
80
  fn=process_bib,
81
  inputs=[bib_file, remove_unverified, enable_google_scholar, max_workers],
82
  outputs=[report, fixed_bib, report_file],
83
  api_name="refcheck",
84
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
 
87
  def launch() -> None:
 
6
 
7
  import gradio as gr
8
 
9
+ from src.space_service import (
10
+ RefCheckOptions,
11
+ RefCheckResult,
12
+ apply_review_action,
13
+ preview_review_action,
14
+ run_refcheck_file,
15
+ )
16
 
17
 
18
  def _uploaded_path(uploaded: Any) -> str | None:
 
50
  return f"## RefCheck Report\n\nProcessing failed: `{exc}`", None, None
51
 
52
 
53
+ def process_bib_for_ui(
54
+ uploaded: Any,
55
+ remove_unverified: bool,
56
+ enable_google_scholar: bool,
57
+ max_workers: int,
58
+ ):
59
+ file_path = _uploaded_path(uploaded)
60
+ if not file_path:
61
+ report = "## RefCheck Report\n\nNo BibTeX file was uploaded."
62
+ return report, None, None, None, gr.update(choices=[], value=None), gr.update(choices=[], value=None), ""
63
+
64
+ try:
65
+ options = _options(remove_unverified, enable_google_scholar, max_workers)
66
+ result = run_refcheck_file(Path(file_path), options)
67
+ return _ui_outputs(result)
68
+ except Exception as exc:
69
+ report = f"## RefCheck Report\n\nProcessing failed: `{exc}`"
70
+ return report, None, None, None, gr.update(choices=[], value=None), gr.update(choices=[], value=None), ""
71
+
72
+
73
+ def update_review_controls(review_label: str | None, result: RefCheckResult | None):
74
+ action_choices = _action_choices(result, review_label)
75
+ action_value = action_choices[0] if action_choices else None
76
+ preview = _review_summary(result, review_label)
77
+ return gr.update(choices=action_choices, value=action_value), preview
78
+
79
+
80
+ def preview_selected_review(
81
+ review_label: str | None,
82
+ action_label: str | None,
83
+ result: RefCheckResult | None,
84
+ remove_unverified: bool,
85
+ enable_google_scholar: bool,
86
+ max_workers: int,
87
+ ) -> str:
88
+ review_index = _parse_review_index(review_label)
89
+ action, candidate_index = _parse_action(action_label)
90
+ options = _options(remove_unverified, enable_google_scholar, max_workers)
91
+ return preview_review_action(result, review_index, action, candidate_index, options)
92
+
93
+
94
+ def apply_selected_review(
95
+ review_label: str | None,
96
+ action_label: str | None,
97
+ result: RefCheckResult | None,
98
+ remove_unverified: bool,
99
+ enable_google_scholar: bool,
100
+ max_workers: int,
101
+ ):
102
+ review_index = _parse_review_index(review_label)
103
+ action, candidate_index = _parse_action(action_label)
104
+ options = _options(remove_unverified, enable_google_scholar, max_workers)
105
+ try:
106
+ updated = apply_review_action(result, review_index, action, candidate_index, options)
107
+ except Exception as exc:
108
+ preview = f"### Manual review\n\n{exc}"
109
+ return (
110
+ result.report_markdown if result else "",
111
+ result.fixed_bib_path if result else None,
112
+ result.report_path if result else None,
113
+ result,
114
+ gr.update(),
115
+ gr.update(),
116
+ preview,
117
+ )
118
+ return _ui_outputs(updated)
119
+
120
+
121
+ def _options(remove_unverified: bool, enable_google_scholar: bool, max_workers: int) -> RefCheckOptions:
122
+ return RefCheckOptions(
123
+ remove_unverified=remove_unverified,
124
+ enable_google_scholar=enable_google_scholar,
125
+ max_workers=int(max_workers),
126
+ )
127
+
128
+
129
+ def _ui_outputs(result: RefCheckResult):
130
+ review_choices = _review_choices(result)
131
+ review_value = review_choices[0] if review_choices else None
132
+ action_choices = _action_choices(result, review_value)
133
+ action_value = action_choices[0] if action_choices else None
134
+ preview = _review_summary(result, review_value)
135
+ return (
136
+ result.report_markdown,
137
+ result.fixed_bib_path,
138
+ result.report_path,
139
+ result,
140
+ gr.update(choices=review_choices, value=review_value),
141
+ gr.update(choices=action_choices, value=action_value),
142
+ preview,
143
+ )
144
+
145
+
146
+ def _review_choices(result: RefCheckResult | None) -> list[str]:
147
+ if not result:
148
+ return []
149
+ choices = []
150
+ for idx, item in enumerate(result.review_items, 1):
151
+ entry = item["entry"]
152
+ title = entry.title or "[missing title]"
153
+ if len(title) > 90:
154
+ title = title[:87] + "..."
155
+ choices.append(f"{idx}. {entry.key}: {title}")
156
+ return choices
157
+
158
+
159
+ def _action_choices(result: RefCheckResult | None, review_label: str | None) -> list[str]:
160
+ review_index = _parse_review_index(review_label)
161
+ if not result or review_index < 0 or review_index >= len(result.review_items):
162
+ return []
163
+
164
+ item = result.review_items[review_index]
165
+ choices = []
166
+ for idx, candidate in enumerate(item.get("candidates", []), 1):
167
+ fetched = candidate.fetched_data
168
+ title = getattr(fetched, "title", "") or "[missing title]"
169
+ if len(title) > 72:
170
+ title = title[:69] + "..."
171
+ choices.append(f"Candidate {idx}: {candidate.source} ({candidate.confidence:.2f}) {title}")
172
+ choices.extend(["Keep original", "Remove entry"])
173
+ return choices
174
+
175
+
176
+ def _review_summary(result: RefCheckResult | None, review_label: str | None) -> str:
177
+ review_index = _parse_review_index(review_label)
178
+ if not result or not result.review_items:
179
+ return "### Manual review\n\nNo unresolved entries."
180
+ if review_index < 0 or review_index >= len(result.review_items):
181
+ return "### Manual review\n\nSelect an unresolved entry."
182
+
183
+ item = result.review_items[review_index]
184
+ entry = item["entry"]
185
+ best = item.get("best_result")
186
+ reason = "; ".join(best.issues) if best and best.issues else "Ambiguous match"
187
+ return (
188
+ "### Manual review\n\n"
189
+ f"**Key:** `{entry.key}`\n\n"
190
+ f"**Title:** {entry.title or '[missing]'}\n\n"
191
+ f"**Authors:** {entry.author or '[missing]'}\n\n"
192
+ f"**Year:** {entry.year or '[missing]'}\n\n"
193
+ f"**Reason:** {reason}\n\n"
194
+ f"**Candidates:** {len(item.get('candidates', []))}"
195
+ )
196
+
197
+
198
+ def _parse_review_index(review_label: str | None) -> int:
199
+ if not review_label:
200
+ return -1
201
+ try:
202
+ return int(review_label.split(".", 1)[0]) - 1
203
+ except Exception:
204
+ return -1
205
+
206
+
207
+ def _parse_action(action_label: str | None) -> tuple[str, int | None]:
208
+ if not action_label:
209
+ return "", None
210
+ if action_label.startswith("Candidate "):
211
+ try:
212
+ number = int(action_label.split(":", 1)[0].replace("Candidate", "").strip())
213
+ except Exception:
214
+ return "", None
215
+ return "candidate", number - 1
216
+ if action_label == "Keep original":
217
+ return "keep", None
218
+ if action_label == "Remove entry":
219
+ return "remove", None
220
+ return "", None
221
+
222
+
223
  with gr.Blocks(title="RefCheck") as demo:
224
  gr.Markdown("# RefCheck")
225
+ session_state = gr.State(None)
226
 
227
  with gr.Row():
228
  with gr.Column(scale=1):
 
232
  type="filepath",
233
  )
234
  remove_unverified = gr.Checkbox(
235
+ label="Remove entries with no candidates",
236
+ value=False,
237
  )
238
  enable_google_scholar = gr.Checkbox(
239
  label="Google Scholar fallback",
 
247
  value=4,
248
  )
249
  run_button = gr.Button("Run RefCheck", variant="primary")
250
+ api_button = gr.Button(visible=False)
251
 
252
  with gr.Column(scale=2):
253
  report = gr.Markdown(label="Report")
254
  fixed_bib = gr.File(label="Fixed BibTeX")
255
  report_file = gr.File(label="Markdown report")
256
+ with gr.Accordion("Manual review", open=True):
257
+ review_entry = gr.Dropdown(label="Unresolved entry", choices=[])
258
+ review_action = gr.Radio(label="Candidate/action", choices=[])
259
+ with gr.Row():
260
+ test_button = gr.Button("Test selected")
261
+ apply_button = gr.Button("Apply selected", variant="primary")
262
+ review_preview = gr.Markdown()
263
 
264
  run_button.click(
265
+ fn=process_bib_for_ui,
266
+ inputs=[bib_file, remove_unverified, enable_google_scholar, max_workers],
267
+ outputs=[report, fixed_bib, report_file, session_state, review_entry, review_action, review_preview],
268
+ api_visibility="private",
269
+ )
270
+ api_button.click(
271
  fn=process_bib,
272
  inputs=[bib_file, remove_unverified, enable_google_scholar, max_workers],
273
  outputs=[report, fixed_bib, report_file],
274
  api_name="refcheck",
275
  )
276
+ review_entry.change(
277
+ fn=update_review_controls,
278
+ inputs=[review_entry, session_state],
279
+ outputs=[review_action, review_preview],
280
+ api_visibility="private",
281
+ )
282
+ test_button.click(
283
+ fn=preview_selected_review,
284
+ inputs=[
285
+ review_entry,
286
+ review_action,
287
+ session_state,
288
+ remove_unverified,
289
+ enable_google_scholar,
290
+ max_workers,
291
+ ],
292
+ outputs=review_preview,
293
+ api_visibility="private",
294
+ )
295
+ apply_button.click(
296
+ fn=apply_selected_review,
297
+ inputs=[
298
+ review_entry,
299
+ review_action,
300
+ session_state,
301
+ remove_unverified,
302
+ enable_google_scholar,
303
+ max_workers,
304
+ ],
305
+ outputs=[report, fixed_bib, report_file, session_state, review_entry, review_action, review_preview],
306
+ api_visibility="private",
307
+ )
308
 
309
 
310
  def launch() -> None:
src/space_service.py CHANGED
@@ -3,6 +3,7 @@ Non-interactive RefCheck workflow for Hugging Face Spaces.
3
  """
4
  from __future__ import annotations
5
 
 
6
  import tempfile
7
  from dataclasses import dataclass, field
8
  from functools import lru_cache
@@ -43,11 +44,14 @@ class RefCheckOptions:
43
  class RefCheckResult:
44
  """Artifacts and summary produced by a Space run."""
45
 
 
46
  total_input: int = 0
47
  total_output: int = 0
48
  verified: int = 0
49
  issues: int = 0
50
  not_found: int = 0
 
 
51
  fixed_details: dict[str, list[str]] = field(default_factory=dict)
52
  removed_details: list[tuple[str, str, str]] = field(default_factory=list)
53
  review_details: list[dict[str, Any]] = field(default_factory=list)
@@ -66,12 +70,12 @@ def run_refcheck_file(file_path: str | Path, options: RefCheckOptions | None = N
66
  source_path = Path(file_path)
67
  parser = BibParser()
68
  entries = parser.parse_file(str(source_path))
69
- result = RefCheckResult(total_input=len(entries))
70
 
71
  if not entries:
72
  result.report_markdown = "## RefCheck Report\n\nNo BibTeX entries were found."
73
  result.report_path = _write_report(result.report_markdown)
74
- result.fixed_bib_path = _write_bib(parser, [], source_path.stem)
75
  return result
76
 
77
  sanitizer = BibSanitizer()
@@ -117,26 +121,35 @@ def run_refcheck_file(file_path: str | Path, options: RefCheckOptions | None = N
117
  result.fixed_details.setdefault(entry.key, []).extend(changes)
118
  updated_entries.append(entry)
119
  elif action == "review":
120
- result.review_details.append(_review_payload(entry, best_result, candidates))
121
  updated_entries.append(entry)
122
  elif action == "remove":
123
  if options.remove_unverified:
124
  result.removed_details.append((entry.key, entry.title, "No matching metadata found in any source"))
125
  else:
126
- result.review_details.append(
127
- {
128
- "key": entry.key,
129
- "title": entry.title,
130
- "reason": "No matching metadata found in any source",
131
- "candidates": [],
132
- }
133
- )
134
  updated_entries.append(entry)
135
  else:
136
  updated_entries.append(entry)
137
 
138
- result.total_output = len(updated_entries)
139
- fixed_path = _write_bib(parser, updated_entries, source_path.stem)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  result.fixed_bib_path = fixed_path
141
 
142
  verified_entries = parser.parse_file(fixed_path)
@@ -160,6 +173,125 @@ def run_refcheck_file(file_path: str | Path, options: RefCheckOptions | None = N
160
  return result
161
 
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def _build_fetchers() -> dict[str, Any]:
164
  return {
165
  "arxiv": ArxivFetcher(),
@@ -251,6 +383,24 @@ def _load_local_db() -> LocalConferenceDB:
251
  return local_db
252
 
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  def _review_payload(entry: BibEntry, best_result: Any, candidates: list[Any]) -> dict[str, Any]:
255
  return {
256
  "key": entry.key,
 
3
  """
4
  from __future__ import annotations
5
 
6
+ import copy
7
  import tempfile
8
  from dataclasses import dataclass, field
9
  from functools import lru_cache
 
44
  class RefCheckResult:
45
  """Artifacts and summary produced by a Space run."""
46
 
47
+ source_stem: str = "references"
48
  total_input: int = 0
49
  total_output: int = 0
50
  verified: int = 0
51
  issues: int = 0
52
  not_found: int = 0
53
+ entries: list[BibEntry] = field(default_factory=list)
54
+ review_items: list[dict[str, Any]] = field(default_factory=list)
55
  fixed_details: dict[str, list[str]] = field(default_factory=dict)
56
  removed_details: list[tuple[str, str, str]] = field(default_factory=list)
57
  review_details: list[dict[str, Any]] = field(default_factory=list)
 
70
  source_path = Path(file_path)
71
  parser = BibParser()
72
  entries = parser.parse_file(str(source_path))
73
+ result = RefCheckResult(source_stem=source_path.stem or "references", total_input=len(entries))
74
 
75
  if not entries:
76
  result.report_markdown = "## RefCheck Report\n\nNo BibTeX entries were found."
77
  result.report_path = _write_report(result.report_markdown)
78
+ result.fixed_bib_path = _write_bib(parser, [], result.source_stem)
79
  return result
80
 
81
  sanitizer = BibSanitizer()
 
121
  result.fixed_details.setdefault(entry.key, []).extend(changes)
122
  updated_entries.append(entry)
123
  elif action == "review":
124
+ result.review_items.append(_review_item(entry, best_result, candidates))
125
  updated_entries.append(entry)
126
  elif action == "remove":
127
  if options.remove_unverified:
128
  result.removed_details.append((entry.key, entry.title, "No matching metadata found in any source"))
129
  else:
130
+ result.review_items.append(_review_item(entry, best_result, candidates))
 
 
 
 
 
 
 
131
  updated_entries.append(entry)
132
  else:
133
  updated_entries.append(entry)
134
 
135
+ result.entries = updated_entries
136
+ return finalize_result(result, options)
137
+
138
+
139
+ def finalize_result(result: RefCheckResult, options: RefCheckOptions | None = None) -> RefCheckResult:
140
+ """Write current entries, re-verify them, and refresh downloadable artifacts."""
141
+ options = options or RefCheckOptions()
142
+ parser = BibParser()
143
+ fetchers = _build_fetchers()
144
+ workflow = get_default_workflow()
145
+ for step in workflow.steps:
146
+ if step.name == "google_scholar":
147
+ step.enabled = options.enable_google_scholar
148
+
149
+ comparator = MetadataComparator()
150
+ result.review_details = [_review_payload_from_item(item) for item in result.review_items]
151
+ result.total_output = len(result.entries)
152
+ fixed_path = _write_bib(parser, result.entries, result.source_stem)
153
  result.fixed_bib_path = fixed_path
154
 
155
  verified_entries = parser.parse_file(fixed_path)
 
173
  return result
174
 
175
 
176
+ def preview_review_action(
177
+ result: RefCheckResult | None,
178
+ review_index: int,
179
+ action: str,
180
+ candidate_index: int | None = None,
181
+ options: RefCheckOptions | None = None,
182
+ ) -> str:
183
+ """Preview and test a manual review action without mutating the session."""
184
+ if not result or not result.review_items:
185
+ return "No unresolved entries are available."
186
+ if review_index < 0 or review_index >= len(result.review_items):
187
+ return "Select an unresolved entry first."
188
+
189
+ options = options or RefCheckOptions()
190
+ item = result.review_items[review_index]
191
+ entry = _find_entry(result.entries, item["entry_key"])
192
+ if not entry:
193
+ return "The selected entry is no longer in the working bibliography."
194
+
195
+ if action == "keep":
196
+ return _entry_preview_markdown(entry, "Keep original entry", ["No metadata changes will be applied."])
197
+ if action == "remove":
198
+ return _entry_preview_markdown(entry, "Remove entry", ["This entry will be removed from the exported BibTeX."])
199
+ if action != "candidate":
200
+ return "Select a candidate, keep, or remove action."
201
+
202
+ candidates = item.get("candidates", [])
203
+ if candidate_index is None or candidate_index < 0 or candidate_index >= len(candidates):
204
+ return "Select a candidate first."
205
+
206
+ candidate = candidates[candidate_index]
207
+ temp_entry = copy.deepcopy(entry)
208
+ changes = apply_fix(temp_entry, candidate.fetched_data)
209
+ if not changes:
210
+ changes = ["No field-level changes are needed for this candidate."]
211
+
212
+ fetchers = _build_fetchers()
213
+ workflow = get_default_workflow()
214
+ for step in workflow.steps:
215
+ if step.name == "google_scholar":
216
+ step.enabled = options.enable_google_scholar
217
+ comparator = MetadataComparator()
218
+ best_result, _ = validate_entry(temp_entry, workflow, fetchers, comparator)
219
+ test_lines = [
220
+ f"Candidate source: {candidate.source}",
221
+ f"Candidate confidence before apply: {candidate.confidence:.2f}",
222
+ ]
223
+ if best_result:
224
+ test_lines.extend(
225
+ [
226
+ f"Verification source after apply: {best_result.source}",
227
+ f"Verification confidence after apply: {best_result.confidence:.2f}",
228
+ f"Verified after apply: {'yes' if best_result.is_match else 'no'}",
229
+ ]
230
+ )
231
+ if best_result.issues:
232
+ test_lines.append(f"Remaining issues: {'; '.join(best_result.issues)}")
233
+
234
+ return _entry_preview_markdown(temp_entry, "Candidate test", changes + test_lines)
235
+
236
+
237
+ def apply_review_action(
238
+ result: RefCheckResult | None,
239
+ review_index: int,
240
+ action: str,
241
+ candidate_index: int | None = None,
242
+ options: RefCheckOptions | None = None,
243
+ ) -> RefCheckResult:
244
+ """Apply a manual review action to the working bibliography."""
245
+ if not result or not result.review_items:
246
+ raise ValueError("No unresolved entries are available.")
247
+ if review_index < 0 or review_index >= len(result.review_items):
248
+ raise ValueError("Select an unresolved entry first.")
249
+
250
+ options = options or RefCheckOptions()
251
+ item = result.review_items[review_index]
252
+ entry = _find_entry(result.entries, item["entry_key"])
253
+ if not entry:
254
+ raise ValueError("The selected entry is no longer in the working bibliography.")
255
+
256
+ if action == "candidate":
257
+ candidates = item.get("candidates", [])
258
+ if candidate_index is None or candidate_index < 0 or candidate_index >= len(candidates):
259
+ raise ValueError("Select a candidate first.")
260
+ candidate = candidates[candidate_index]
261
+ changes = apply_fix(entry, candidate.fetched_data)
262
+ changes.append(f"Resolved manually with candidate from {candidate.source}.")
263
+ result.fixed_details.setdefault(entry.key, []).extend(changes)
264
+ elif action == "remove":
265
+ result.entries = [existing for existing in result.entries if existing.key != entry.key]
266
+ result.removed_details.append((entry.key, entry.title, "Removed during manual review"))
267
+ elif action == "keep":
268
+ result.fixed_details.setdefault(entry.key, []).append("Marked as manually reviewed; kept original entry.")
269
+ else:
270
+ raise ValueError("Select a candidate, keep, or remove action.")
271
+
272
+ del result.review_items[review_index]
273
+ return finalize_result(result, options)
274
+
275
+
276
+ def _find_entry(entries: list[BibEntry], key: str) -> BibEntry | None:
277
+ for entry in entries:
278
+ if entry.key == key:
279
+ return entry
280
+ return None
281
+
282
+
283
+ def _entry_preview_markdown(entry: BibEntry, title: str, lines: list[str]) -> str:
284
+ body = "\n".join(f"- {line}" for line in lines)
285
+ return (
286
+ f"### {title}\n\n"
287
+ f"**Key:** `{entry.key}`\n\n"
288
+ f"**Title:** {entry.title or '[missing]'}\n\n"
289
+ f"**Authors:** {entry.author or '[missing]'}\n\n"
290
+ f"**Year:** {entry.year or '[missing]'}\n\n"
291
+ f"{body}"
292
+ )
293
+
294
+
295
  def _build_fetchers() -> dict[str, Any]:
296
  return {
297
  "arxiv": ArxivFetcher(),
 
383
  return local_db
384
 
385
 
386
+ def _review_item(entry: BibEntry, best_result: Any, candidates: list[Any]) -> dict[str, Any]:
387
+ sorted_candidates = sorted(candidates, key=lambda item: item.confidence, reverse=True)
388
+ return {
389
+ "entry_key": entry.key,
390
+ "entry": entry,
391
+ "best_result": best_result,
392
+ "candidates": sorted_candidates,
393
+ }
394
+
395
+
396
+ def _review_payload_from_item(item: dict[str, Any]) -> dict[str, Any]:
397
+ return _review_payload(
398
+ item["entry"],
399
+ item.get("best_result"),
400
+ item.get("candidates", []),
401
+ )
402
+
403
+
404
  def _review_payload(entry: BibEntry, best_result: Any, candidates: list[Any]) -> dict[str, Any]:
405
  return {
406
  "key": entry.key,