diff options
| author | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
|---|---|---|
| committer | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
| commit | 898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch) | |
| tree | 85c6ee5ad58b860144551184d4cf86b560c62b91 /.agents/skills/zotero-obsidian-bridge/scripts | |
| download | www-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.tar.gz www-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.zip | |
Diffstat (limited to '')
| -rwxr-xr-x | .agents/skills/zotero-obsidian-bridge/scripts/verify_paper_notes.py | 212 |
1 files changed, 212 insertions, 0 deletions
diff --git a/.agents/skills/zotero-obsidian-bridge/scripts/verify_paper_notes.py b/.agents/skills/zotero-obsidian-bridge/scripts/verify_paper_notes.py new file mode 100755 index 0000000..d60e020 --- /dev/null +++ b/.agents/skills/zotero-obsidian-bridge/scripts/verify_paper_notes.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Verify Obsidian paper-note schema, Zotero-key coverage, and optional inventory consistency.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REQUIRED_HEADINGS = ( + "## Claim", + "## Method", + "## Evidence", + "## Limitation", + "## Direct relevance to repo", + "## Relation to other papers", +) + +REQUIRED_FRONTMATTER_FIELDS = ( + "type", + "project", + "title", + "zotero_key", + "linked_knowledge", + "paper_relationships", + "updated", +) + +ZOTERO_KEY_RE = re.compile(r'^zotero_key:\s*"?([A-Z0-9]+)"?', re.MULTILINE) +FRONTMATTER_RE = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL) +FIELD_RE_TEMPLATE = r'^{}:\s*' +INVENTORY_ROW_RE = re.compile(r'^\|\s*([A-Z0-9]+)\s*\|\s*(.*?)\|\s*(.*?)\|\s*(.*?)\|\s*$') + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Verify paper-note schema and optional Zotero-key / inventory coverage." + ) + parser.add_argument("--papers-dir", required=True, help="Directory containing canonical paper notes.") + parser.add_argument( + "--expected-zotero-keys", + default="", + help="Comma-separated Zotero keys expected to be covered.", + ) + parser.add_argument( + "--inventory-note", + default="", + help="Optional collection inventory note to cross-check against canonical paper notes.", + ) + parser.add_argument( + "--strict-missing-zotero-key", + action="store_true", + help="Treat notes without zotero_key as errors. Default behavior skips them.", + ) + return parser.parse_args() + + +def load_expected_keys(raw_keys: str) -> list[str]: + if not raw_keys.strip(): + return [] + return [key.strip() for key in raw_keys.split(",") if key.strip()] + + +def extract_frontmatter(text: str) -> str: + match = FRONTMATTER_RE.match(text) + return match.group(1) if match else "" + + +def missing_frontmatter_fields(frontmatter: str) -> list[str]: + missing: list[str] = [] + for field in REQUIRED_FRONTMATTER_FIELDS: + if not re.search(FIELD_RE_TEMPLATE.format(re.escape(field)), frontmatter, re.MULTILINE): + missing.append(field) + return missing + + +def collect_note_status( + papers_dir: Path, + strict_missing_zotero_key: bool, +) -> tuple[dict[str, str], list[str], list[str]]: + key_to_file: dict[str, str] = {} + issues: list[str] = [] + skipped_without_key: list[str] = [] + + for path in sorted(papers_dir.glob("*.md")): + text = path.read_text(encoding="utf-8") + frontmatter = extract_frontmatter(text) + match = ZOTERO_KEY_RE.search(text) + if not match: + skipped_without_key.append(path.name) + if strict_missing_zotero_key: + issues.append(f"{path.name}: missing zotero_key") + continue + + zotero_key = match.group(1) + if zotero_key in key_to_file: + issues.append( + f"{path.name}: duplicate zotero_key {zotero_key} also used by {key_to_file[zotero_key]}" + ) + key_to_file[zotero_key] = path.name + + missing_headings = [heading for heading in REQUIRED_HEADINGS if heading not in text] + if missing_headings: + issues.append(f"{path.name}: missing headings -> {', '.join(missing_headings)}") + + missing_fields = missing_frontmatter_fields(frontmatter) + if missing_fields: + issues.append(f"{path.name}: missing frontmatter fields -> {', '.join(missing_fields)}") + + return key_to_file, issues, skipped_without_key + + +def parse_inventory_note(inventory_path: Path) -> tuple[dict[str, str], list[str]]: + text = inventory_path.read_text(encoding="utf-8") + mapping: dict[str, str] = {} + issues: list[str] = [] + in_table = False + + for line in text.splitlines(): + if line.strip() == "## Item Mapping": + in_table = True + continue + if in_table and line.startswith("## "): + break + if not in_table or not line.startswith("|"): + continue + if "---" in line: + continue + match = INVENTORY_ROW_RE.match(line) + if not match: + continue + key, _title, note_path, _status = [part.strip() for part in match.groups()] + if key in mapping: + issues.append(f"inventory: duplicate zotero key {key}") + mapping[key] = note_path + + if not mapping: + issues.append("inventory: no item mapping rows found") + return mapping, issues + + +def main() -> int: + args = parse_args() + papers_dir = Path(args.papers_dir).expanduser() + expected_keys = load_expected_keys(args.expected_zotero_keys) + + if not papers_dir.exists(): + print(f"ERROR: papers dir not found: {papers_dir}") + return 1 + + note_files = list(papers_dir.glob("*.md")) + key_to_file, issues, skipped_without_key = collect_note_status( + papers_dir, args.strict_missing_zotero_key + ) + + print(f"Papers dir: {papers_dir}") + print(f"Paper notes scanned: {len(note_files)}") + print(f"Notes with zotero_key: {len(key_to_file)}") + if skipped_without_key: + print(f"Notes skipped without zotero_key: {len(skipped_without_key)}") + + if expected_keys: + missing = [key for key in expected_keys if key not in key_to_file] + extras = sorted(set(key_to_file) - set(expected_keys)) + print(f"Expected Zotero keys: {len(expected_keys)}") + print(f"Covered Zotero keys: {len(expected_keys) - len(missing)} / {len(expected_keys)}") + if missing: + issues.append(f"Missing expected keys: {', '.join(missing)}") + if extras: + print(f"Extra zotero_key notes: {', '.join(extras)}") + + if args.inventory_note: + inventory_path = Path(args.inventory_note).expanduser() + if not inventory_path.exists(): + issues.append(f"inventory note not found: {inventory_path}") + else: + inventory_mapping, inventory_issues = parse_inventory_note(inventory_path) + issues.extend(inventory_issues) + if inventory_mapping: + for key, note_path in inventory_mapping.items(): + expected_name = key_to_file.get(key) + if expected_name is None: + issues.append(f"inventory: key {key} not found in paper notes") + continue + if Path(note_path).name != expected_name: + issues.append( + f"inventory: key {key} points to {note_path}, expected file ending {expected_name}" + ) + missing_in_inventory = sorted(set(key_to_file) - set(inventory_mapping)) + if missing_in_inventory: + issues.append( + f"inventory: missing keys present in paper notes -> {', '.join(missing_in_inventory)}" + ) + + if skipped_without_key: + print("Skipped note files without zotero_key:") + for name in skipped_without_key: + print(f"- {name}") + + if issues: + print("\nISSUES:") + for issue in issues: + print(f"- {issue}") + return 1 + + print("\nOK: schema and coverage checks passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |
