Skills › Scientific Agent Skills › citation-management › validate_citations.py
validate_citations.py
713 lines · skills/citation-management/scripts/validate_citations.py at 5f2bc03
1#!/usr/bin/env python3
2"""
3Citation Validation Tool
4Validate BibTeX files for accuracy, completeness, and format compliance.
5"""
7import sys
8import re
9import requests
10import argparse
11import json
12import time
13from typing import Dict, List, Tuple, Optional
14from collections import defaultdict
15from urllib.parse import quote
17sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
19from _common import parse_bibtex_file as _parse_bibtex_file # noqa: E402
21# Typical reference-list lengths, as (minimum, typical maximum, display name).
22#
23# These are editorial rules of thumb drawn from what published papers at these
24# venues tend to carry -- not sourced submission requirements, most of which do
25# not cap references at all. They drive warnings only; a bibliography is never
26# wrong merely for being short.
27VENUE_STANDARDS = {
28 'nature': (35, 50, 'Nature'),
29 'science': (35, 50, 'Science'),
30 'cell': (35, 50, 'Cell'),
31 'multidisciplinary': (35, 50, 'High-impact Multidisciplinary Journal'),
32 'neurips': (30, 45, 'NeurIPS'),
33 'icml': (30, 45, 'ICML'),
34 'iclr': (30, 45, 'ICLR'),
35 'cvpr': (30, 45, 'CVPR'),
36 'acl': (30, 45, 'ACL'),
37 'ml_cs_conf': (30, 45, 'ML/CS Conference'),
38 'review': (40, 65, 'Comprehensive Literature Review'),
39 'market_research': (40, 65, 'Market Research Report'),
40 'literature_review': (40, 65, 'Literature Review / Market Research'),
41 'nejm': (30, 45, 'NEJM'),
42 'lancet': (30, 45, 'The Lancet'),
43 'jama': (30, 45, 'JAMA'),
44 'medical': (30, 45, 'Medical Journal'),
45}
48class CitationValidator:
49 """Validate BibTeX entries for errors and inconsistencies."""
51 def __init__(self):
52 self.session = requests.Session()
53 self.session.headers.update({
54 'User-Agent': 'CitationValidator/1.0 (Citation Management Tool)'
55 })
56 self.venue_standards = VENUE_STANDARDS
58 # Required fields by entry type
59 self.required_fields = {
60 'article': ['author', 'title', 'journal', 'year'],
61 'book': ['title', 'publisher', 'year'], # author OR editor
62 'inproceedings': ['author', 'title', 'booktitle', 'year'],
63 'incollection': ['author', 'title', 'booktitle', 'publisher', 'year'],
64 'phdthesis': ['author', 'title', 'school', 'year'],
65 'mastersthesis': ['author', 'title', 'school', 'year'],
66 'techreport': ['author', 'title', 'institution', 'year'],
67 'misc': ['title', 'year']
68 }
70 # Recommended fields
71 self.recommended_fields = {
72 'article': ['volume', 'pages', 'doi'],
73 'book': ['isbn'],
74 'inproceedings': ['pages'],
75 }
77 def parse_bibtex_file(self, filepath: str) -> List[Dict]:
78 """
79 Parse BibTeX file and extract entries.
81 Args:
82 filepath: Path to BibTeX file
84 Returns:
85 List of entry dictionaries
86 """
87 return _parse_bibtex_file(filepath)
89 def validate_entry(self, entry: Dict) -> Tuple[List[Dict], List[Dict]]:
90 """
91 Validate a single BibTeX entry.
93 Args:
94 entry: Entry dictionary
96 Returns:
97 Tuple of (errors, warnings)
98 """
99 errors = []
100 warnings = []
102 entry_type = entry['type']
103 key = entry['key']
104 fields = entry['fields']
106 # Check required fields
107 if entry_type in self.required_fields:
108 for req_field in self.required_fields[entry_type]:
109 if req_field not in fields or not fields[req_field]:
110 # Special case: book can have author OR editor
111 if entry_type == 'book' and req_field == 'author':
112 if 'editor' not in fields or not fields['editor']:
113 errors.append({
114 'type': 'missing_required_field',
115 'field': 'author or editor',
116 'severity': 'high',
117 'message': f'Entry {key}: Missing required field "author" or "editor"'
118 })
119 else:
120 errors.append({
121 'type': 'missing_required_field',
122 'field': req_field,
123 'severity': 'high',
124 'message': f'Entry {key}: Missing required field "{req_field}"'
125 })
127 # Check recommended fields
128 if entry_type in self.recommended_fields:
129 for rec_field in self.recommended_fields[entry_type]:
130 if rec_field not in fields or not fields[rec_field]:
131 warnings.append({
132 'type': 'missing_recommended_field',
133 'field': rec_field,
134 'severity': 'medium',
135 'message': f'Entry {key}: Missing recommended field "{rec_field}"'
136 })
138 # Validate year
139 if 'year' in fields:
140 year = fields['year']
141 if not re.match(r'^\d{4}$', year):
142 errors.append({
143 'type': 'invalid_year',
144 'field': 'year',
145 'value': year,
146 'severity': 'high',
147 'message': f'Entry {key}: Invalid year format "{year}" (should be 4 digits)'
148 })
149 elif int(year) < 1600 or int(year) > 2030:
150 warnings.append({
151 'type': 'suspicious_year',
152 'field': 'year',
153 'value': year,
154 'severity': 'medium',
155 'message': f'Entry {key}: Suspicious year "{year}" (outside reasonable range)'
156 })
158 # Validate DOI format
159 if 'doi' in fields:
160 doi = fields['doi']
161 if not re.match(r'^10\.\d{4,}/[^\s]+$', doi):
162 warnings.append({
163 'type': 'invalid_doi_format',
164 'field': 'doi',
165 'value': doi,
166 'severity': 'medium',
167 'message': f'Entry {key}: Invalid DOI format "{doi}"'
168 })
170 # Check for single hyphen in pages (should be --)
171 if 'pages' in fields:
172 pages = fields['pages']
173 if re.search(r'\d-\d', pages) and '--' not in pages:
174 warnings.append({
175 'type': 'page_range_format',
176 'field': 'pages',
177 'value': pages,
178 'severity': 'low',
179 'message': f'Entry {key}: Page range uses single hyphen, should use -- (en-dash)'
180 })
182 # Check author format
183 if 'author' in fields:
184 author = fields['author']
185 if ';' in author or '&' in author:
186 errors.append({
187 'type': 'invalid_author_format',
188 'field': 'author',
189 'severity': 'high',
190 'message': f'Entry {key}: Authors should be separated by " and ", not ";" or "&"'
191 })
193 return errors, warnings
195 def verify_doi(self, doi: str) -> Tuple[Optional[bool], Optional[Dict]]:
196 """
197 Verify DOI resolves correctly and get metadata.
199 Args:
200 doi: Digital Object Identifier
202 Returns:
203 (True, metadata) when registered, (False, None) on resolver 404,
204 or (None, None) when the lookup could not be completed.
205 """
206 # Ask the registration agency, not the publisher. A HEAD request to
207 # doi.org follows the redirect to the publisher, and several publishers
208 # answer HEAD with 403 or 405 behind a bot check -- which made a
209 # perfectly good DOI look unresolvable.
210 try:
211 time.sleep(0.21) # Stay below Crossref's public single-record limit.
212 crossref_url = f'https://api.crossref.org/works/{quote(doi, safe="")}'
213 metadata_response = self.session.get(crossref_url, timeout=10)
215 if metadata_response.status_code == 200:
216 data = metadata_response.json()
217 message = data.get('message', {})
218 if not isinstance(message, dict) or str(message.get('DOI', '')).lower() != doi.lower():
219 return None, None
221 # Extract key metadata
222 metadata = {
223 'title': (message.get('title') or [''])[0],
224 'year': self._extract_year_crossref(message),
225 'authors': self._format_authors_crossref(message.get('author', [])),
226 }
227 return True, metadata
229 if metadata_response.status_code == 404:
230 # Not a Crossref DOI. DataCite registers datasets and many
231 # preprints, so check there before calling it broken.
232 datacite_url = f'https://api.datacite.org/dois/{quote(doi, safe="")}'
233 datacite_response = self.session.get(datacite_url, timeout=10)
234 if datacite_response.status_code == 200:
235 record = datacite_response.json().get('data', {})
236 if str(record.get('id', '')).lower() == doi.lower():
237 return True, None
238 return None, None
239 if datacite_response.status_code != 404:
240 return None, None
241 # Other agencies mint DOIs too. Check the resolver itself,
242 # without following publishers' bot-blocked landing pages.
243 resolver = self.session.get(f'https://doi.org/{quote(doi, safe="/")}',
244 allow_redirects=False, timeout=10)
245 if resolver.status_code == 404:
246 return False, None
247 if resolver.status_code in (301, 302, 303, 307, 308) and resolver.headers.get('Location'):
248 return True, None
249 return None, None
251 # Any other status is a transport problem on our side, not
252 # evidence that the DOI is bad.
253 return None, None
255 except (requests.exceptions.RequestException, ValueError, TypeError, AttributeError):
256 return None, None
258 def detect_duplicates(self, entries: List[Dict]) -> List[Dict]:
259 """
260 Detect duplicate entries.
262 Args:
263 entries: List of entry dictionaries
265 Returns:
266 List of duplicate groups
267 """
268 duplicates = []
270 # Check for duplicate DOIs
271 doi_map = defaultdict(list)
272 for entry in entries:
273 doi = entry['fields'].get('doi', '').strip()
274 if doi:
275 doi_map[doi].append(entry['key'])
277 for doi, keys in doi_map.items():
278 if len(keys) > 1:
279 duplicates.append({
280 'type': 'duplicate_doi',
281 'doi': doi,
282 'entries': keys,
283 'severity': 'high',
284 'message': f'Duplicate DOI {doi} found in entries: {", ".join(keys)}'
285 })
287 # Check for duplicate citation keys
288 key_counts = defaultdict(int)
289 for entry in entries:
290 key_counts[entry['key']] += 1
292 for key, count in key_counts.items():
293 if count > 1:
294 duplicates.append({
295 'type': 'duplicate_key',
296 'key': key,
297 'count': count,
298 'severity': 'high',
299 'message': f'Citation key "{key}" appears {count} times'
300 })
302 # Check for similar titles (possible duplicates)
303 titles = {}
304 for entry in entries:
305 title = entry['fields'].get('title', '').lower()
306 title = re.sub(r'[^\w\s]', '', title) # Remove punctuation
307 title = ' '.join(title.split()) # Normalize whitespace
309 if title:
310 if title in titles:
311 duplicates.append({
312 'type': 'similar_title',
313 'entries': [titles[title], entry['key']],
314 'severity': 'medium',
315 'message': f'Possible duplicate: "{titles[title]}" and "{entry["key"]}" have identical titles'
316 })
317 else:
318 titles[title] = entry['key']
320 return duplicates
322 def parse_manuscript_citations(self, filepath: str) -> List[str]:
323 """
324 Parse a manuscript file (Markdown or LaTeX) and extract all cited keys.
326 Args:
327 filepath: Path to manuscript file
329 Returns:
330 List of cited citation keys
331 """
332 try:
333 with open(filepath, 'r', encoding='utf-8') as f:
334 content = f.read()
335 except Exception as e:
336 print(f'Error reading manuscript file {filepath}: {e}', file=sys.stderr)
337 return []
339 cited_keys = set()
341 # 1. LaTeX citations: \cite{key1, key2}, \citep{key}, \citet{key}, etc.
342 latex_matches = re.findall(r'\\cite[a-z]*\*?\{([^}]+)\}', content)
343 for match in latex_matches:
344 keys = [k.strip() for k in match.split(',')]
345 for key in keys:
346 if key:
347 cited_keys.add(key)
349 # 2. Markdown / Pandoc citations: @key or [@key1; @key2]
350 # Match @ followed by valid citation key chars (alphanumeric, -, _, :, .)
351 # Avoid email addresses, twitter handles, etc. by requiring that @ is not preceded by alphanumeric/dot/dash/underscore
352 md_matches = re.findall(r'(?<![a-zA-Z0-9_.-])@([a-zA-Z0-9_\-:]+)', content)
353 for key in md_matches:
354 # Exclude common false positives
355 if key and not key.isdigit():
356 cited_keys.add(key)
358 return list(cited_keys)
360 def validate_file(self, filepath: str, check_dois: bool = False, min_count: Optional[int] = None, venue: Optional[str] = None, manuscript_filepath: Optional[str] = None) -> Dict:
361 """
362 Validate entire BibTeX file.
364 Args:
365 filepath: Path to BibTeX file
366 check_dois: Whether to verify DOIs (slow)
367 min_count: Optional minimum citation count to enforce
368 venue: Optional venue type to check against standards
369 manuscript_filepath: Optional path to manuscript to cross-check citations
371 Returns:
372 Validation report dictionary
373 """
374 print(f'Parsing {filepath}...', file=sys.stderr)
375 entries = self.parse_bibtex_file(filepath)
377 if not entries:
378 return {
379 'filepath': filepath,
380 'total_entries': 0,
381 'valid_entries': 0,
382 'errors': [{
383 'type': 'empty_bibtex_file',
384 'severity': 'high',
385 'message': f'BibTeX file {filepath} has no valid entries.'
386 }],
387 'warnings': [],
388 'duplicates': [],
389 'venue_standard_checked': venue,
390 'min_count_checked': min_count,
391 'manuscript_results': {'checked': False}
392 }
394 print(f'Found {len(entries)} entries', file=sys.stderr)
396 all_errors = []
397 all_warnings = []
399 # Validate each entry
400 for i, entry in enumerate(entries):
401 print(f'Validating entry {i+1}/{len(entries)}: {entry["key"]}', file=sys.stderr)
402 errors, warnings = self.validate_entry(entry)
404 for error in errors:
405 error['entry'] = entry['key']
406 all_errors.append(error)
408 for warning in warnings:
409 warning['entry'] = entry['key']
410 all_warnings.append(warning)
412 # Check for duplicates
413 print('Checking for duplicates...', file=sys.stderr)
414 duplicates = self.detect_duplicates(entries)
416 # Verify DOIs if requested
417 doi_errors = []
418 if check_dois:
419 print('Verifying DOIs...', file=sys.stderr)
420 for i, entry in enumerate(entries):
421 doi = entry['fields'].get('doi', '')
422 if doi:
423 print(f'Verifying DOI {i+1}: {doi}', file=sys.stderr)
424 is_valid, metadata = self.verify_doi(doi)
426 if is_valid is None:
427 all_warnings.append({
428 'type': 'doi_unverified',
429 'entry': entry['key'],
430 'doi': doi,
431 'severity': 'medium',
432 'message': f'Entry {entry["key"]}: DOI lookup inconclusive; retry or verify manually: {doi}'
433 })
434 elif is_valid is False:
435 doi_errors.append({
436 'type': 'invalid_doi',
437 'entry': entry['key'],
438 'doi': doi,
439 'severity': 'high',
440 'message': f'Entry {entry["key"]}: DOI does not resolve: {doi}'
441 })
443 all_errors.extend(doi_errors)
445 # Count and Venue verification logic
446 count_errors = []
447 count_warnings = []
449 target_min = None
450 target_max = None
451 venue_name = None
453 if venue:
454 v_lower = venue.lower().strip()
455 if v_lower in self.venue_standards:
456 target_min, target_max, venue_name = self.venue_standards[v_lower]
457 else:
458 print(f"Warning: Unknown venue '{venue}'. Supported venues are: {', '.join(self.venue_standards.keys())}", file=sys.stderr)
460 if min_count is not None:
461 target_min = min_count
462 venue_name = f"Custom threshold (min: {min_count})"
463 target_max = None
465 # A reference list is never *wrong* for being short -- the right length
466 # is whatever the argument needs -- so a shortfall is reported as a
467 # warning. `--min-count` is the exception: an explicit floor the caller
468 # asked to have enforced, so falling under it is an error.
469 total_count = len(entries)
470 if target_min is not None:
471 if min_count is not None and total_count < min_count:
472 count_errors.append({
473 'type': 'below_requested_minimum',
474 'severity': 'high',
475 'message': f'Total citation entries ({total_count}) is below the requested minimum of {min_count}.'
476 })
477 elif total_count < target_min:
478 count_warnings.append({
479 'type': 'low_citation_count',
480 'severity': 'medium',
481 'message': f'Total citation entries ({total_count}) is below the {venue_name or "specified standard"} rule of thumb of {target_min}-{target_max or "+"}. This is a heuristic, not a submission requirement.'
482 })
484 # Manuscript checking logic
485 manuscript_results = {
486 'checked': False,
487 'manuscript_filepath': None,
488 'cited_keys': [],
489 'missing_keys': [],
490 'unused_keys': []
491 }
493 if manuscript_filepath:
494 manuscript_results['checked'] = True
495 manuscript_results['manuscript_filepath'] = manuscript_filepath
497 # Parse cited keys
498 cited_keys = self.parse_manuscript_citations(manuscript_filepath)
499 manuscript_results['cited_keys'] = cited_keys
501 bib_keys = {entry['key'] for entry in entries}
503 # Missing references: cited in manuscript but not defined in BibTeX
504 missing_keys = [key for key in cited_keys if key not in bib_keys]
505 manuscript_results['missing_keys'] = missing_keys
506 for key in missing_keys:
507 all_errors.append({
508 'type': 'unresolved_citation',
509 'entry': key,
510 'severity': 'high',
511 'message': f'Unresolved citation: Key "@{key}" is cited in manuscript "{manuscript_filepath}" but not defined in the BibTeX file.'
512 })
514 # Unused references: defined in BibTeX but not cited in manuscript
515 unused_keys = [key for key in bib_keys if key not in cited_keys]
516 manuscript_results['unused_keys'] = unused_keys
517 for key in unused_keys:
518 all_warnings.append({
519 'type': 'unused_citation',
520 'entry': key,
521 'severity': 'medium',
522 'message': f'Unused citation: Reference "{key}" is defined in the BibTeX file but not cited in the manuscript.'
523 })
525 # Check the count of ACTUALLY used citations
526 actual_count = len(cited_keys) - len(missing_keys)
527 if target_min is not None:
528 if min_count is not None and actual_count < min_count:
529 count_errors.append({
530 'type': 'manuscript_below_requested_minimum',
531 'severity': 'high',
532 'message': f'The manuscript cites {actual_count} references, below the requested minimum of {min_count}.'
533 })
534 elif actual_count < target_min:
535 count_warnings.append({
536 'type': 'low_manuscript_citation_count',
537 'severity': 'medium',
538 'message': f'The manuscript cites {actual_count} references, below the {venue_name or "specified standard"} rule of thumb of {target_min}-{target_max or "+"}. This is a heuristic, not a submission requirement.'
539 })
541 all_errors.extend(count_errors)
542 all_warnings.extend(count_warnings)
544 # Count entries carrying at least one high-severity error, rather than
545 # subtracting the error count -- several errors can land on one entry,
546 # which previously drove this figure negative.
547 entries_with_errors = {
548 error['entry'] for error in all_errors
549 if error.get('severity') == 'high' and error.get('entry')
550 }
551 bib_keys_present = {entry['key'] for entry in entries}
553 return {
554 'filepath': filepath,
555 'total_entries': len(entries),
556 'valid_entries': len(entries) - len(entries_with_errors & bib_keys_present),
557 'errors': all_errors,
558 'warnings': all_warnings,
559 'duplicates': duplicates,
560 'venue_standard_checked': venue,
561 'min_count_checked': min_count,
562 'manuscript_results': manuscript_results
563 }
565 def _extract_year_crossref(self, message: Dict) -> str:
566 """Extract year from CrossRef message."""
567 date_parts = message.get('published-print', {}).get('date-parts', [[]])
568 if not date_parts or not date_parts[0]:
569 date_parts = message.get('published-online', {}).get('date-parts', [[]])
571 if date_parts and date_parts[0]:
572 return str(date_parts[0][0])
573 return ''
575 def _format_authors_crossref(self, authors: List[Dict]) -> str:
576 """Format author list from CrossRef."""
577 if not authors:
578 return ''
580 formatted = []
581 for author in authors[:3]: # First 3 authors
582 given = author.get('given', '')
583 family = author.get('family', '')
584 if family:
585 formatted.append(f'{family}, {given}' if given else family)
587 if len(authors) > 3:
588 formatted.append('et al.')
590 return ', '.join(formatted)
593def main():
594 """Command-line interface."""
595 parser = argparse.ArgumentParser(
596 description='Validate BibTeX files for errors and inconsistencies',
597 epilog='Example: python validate_citations.py references.bib'
598 )
600 parser.add_argument(
601 'file',
602 help='BibTeX file to validate'
603 )
605 parser.add_argument(
606 '--check-dois',
607 action='store_true',
608 help='Verify DOIs resolve correctly (slow)'
609 )
611 parser.add_argument(
612 '--report',
613 help='Output file for the JSON validation report'
614 )
616 parser.add_argument(
617 '--verbose',
618 action='store_true',
619 help='Show detailed output'
620 )
622 parser.add_argument(
623 '--min-count',
624 type=int,
625 help='Enforce a minimum number of citation entries'
626 )
628 parser.add_argument(
629 '--venue',
630 help='Enforce citation standards for a specific venue (e.g. nature, neurips, review)'
631 )
633 parser.add_argument(
634 '--manuscript',
635 help='Path to manuscript file (Markdown or LaTeX) to check for unresolved or unused citations'
636 )
638 args = parser.parse_args()
640 # Validate file
641 validator = CitationValidator()
642 report = validator.validate_file(
643 args.file,
644 check_dois=args.check_dois,
645 min_count=args.min_count,
646 venue=args.venue,
647 manuscript_filepath=args.manuscript
648 )
650 # Print summary
651 print('\n' + '='*60)
652 print('CITATION VALIDATION REPORT')
653 print('='*60)
654 print(f'\nFile: {args.file}')
655 if report.get('venue_standard_checked'):
656 print(f'Venue Standard: {report["venue_standard_checked"]}')
657 if report.get('min_count_checked') is not None:
658 print(f'Required Min Count: {report["min_count_checked"]}')
659 print(f'Total entries in BibTeX: {report["total_entries"]}')
661 m_res = report.get('manuscript_results', {})
662 if m_res.get('checked'):
663 print(f'Manuscript checked: {m_res["manuscript_filepath"]}')
664 print(f' Actual unique citations in manuscript: {len(m_res["cited_keys"])}')
665 print(f' Missing/unresolved: {len(m_res["missing_keys"])}')
666 print(f' Unused in bib file: {len(m_res["unused_keys"])}')
668 print(f'Valid entries: {report["valid_entries"]}')
669 print(f'Errors: {len(report["errors"])}')
670 print(f'Warnings: {len(report["warnings"])}')
671 print(f'Duplicates: {len(report["duplicates"])}')
673 # Print errors
674 if report['errors']:
675 print('\n' + '-'*60)
676 print('ERRORS (must fix):')
677 print('-'*60)
678 for error in report['errors']:
679 print(f'\n{error["message"]}')
680 if args.verbose:
681 print(f' Type: {error["type"]}')
682 print(f' Severity: {error["severity"]}')
684 # Print warnings
685 if report['warnings'] and (args.verbose or report.get('venue_standard_checked') or report.get('min_count_checked') is not None or m_res.get('checked')):
686 print('\n' + '-'*60)
687 print('WARNINGS (should fix):')
688 print('-'*60)
689 for warning in report['warnings']:
690 print(f'\n{warning["message"]}')
692 # Print duplicates
693 if report['duplicates']:
694 print('\n' + '-'*60)
695 print('DUPLICATES:')
696 print('-'*60)
697 for dup in report['duplicates']:
698 print(f'\n{dup["message"]}')
700 # Save report
701 if args.report:
702 with open(args.report, 'w', encoding='utf-8') as f:
703 json.dump(report, f, indent=2)
704 print(f'\nDetailed report saved to: {args.report}')
706 # Exit with error code if there are high-severity errors
707 has_high_errors = any(e.get('severity') == 'high' for e in report['errors'])
708 if has_high_errors:
709 sys.exit(1)
712if __name__ == '__main__':
713 main()