Skill Tracer
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"""
6
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).
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'),
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()