166 lines
6.6 KiB
Python
166 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Archive a finished literature-search-verify session into a permanent,
|
|
project-level folder instead of leaving results sitting in the skill's
|
|
own scratch output/ directory (which is easy to lose track of across
|
|
sessions and isn't meant to be a durable deliverable location).
|
|
|
|
Bundles the verified BibTeX file -- and, if given, any downloaded PDFs --
|
|
into <project-root>/references/<topic-slug>/, and writes a README.md
|
|
index (entry list, suspect/unverified entries flagged separately, free-
|
|
text coverage notes) so a future session or a human can find and trust
|
|
what's there without re-reading the conversation that produced it.
|
|
|
|
No third-party dependencies; uses only the standard library.
|
|
|
|
CLI usage:
|
|
python3 archive_references.py "UAV aeromagnetic compensation" \\
|
|
--bib output/uav_aeromagnetic_compensation_final.bib \\
|
|
--project-root . \\
|
|
--pdfs-dir output/pdfs \\
|
|
--suspect "Some fabricated-looking title|DOI resolves but venue is topically unrelated" \\
|
|
--notes "Kalman-filter and GA/PSO angles searched, no on-topic hits found."
|
|
|
|
Output: prints the path of the archive directory that was created/updated.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
from datetime import date
|
|
|
|
|
|
def slugify(text):
|
|
text = text.strip().lower()
|
|
text = re.sub(r"[^a-z0-9]+", "_", text)
|
|
return text.strip("_")[:60] or "references"
|
|
|
|
|
|
def parse_bib_entries(bib_path):
|
|
"""Minimal BibTeX parser -- just enough to pull key/title/year/venue/doi/note
|
|
(plus the raw entry text, for reordering) for the README index. Not a
|
|
general-purpose BibTeX parser."""
|
|
with open(bib_path, encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
entries = []
|
|
for m in re.finditer(r"@(\w+)\{([^,\n]+),(.*?)\n\}", content, re.S):
|
|
entry_type, key, body = m.groups()
|
|
fields = {}
|
|
for fm in re.finditer(r"(\w+)\s*=\s*\{(.*?)\}\s*,?\s*(?=\n\s*\w+\s*=|\n\Z|\Z)", body, re.S):
|
|
fields[fm.group(1).lower()] = re.sub(r"\s+", " ", fm.group(2)).strip()
|
|
entries.append({"type": entry_type, "key": key.strip(), "raw": m.group(0).strip(), **fields})
|
|
return entries
|
|
|
|
|
|
def year_sort_key(entry):
|
|
"""Chronological order, oldest first; entries with no parseable year sort last."""
|
|
year_str = re.sub(r"[^0-9]", "", entry.get("year", "") or "")
|
|
year = int(year_str) if year_str else 9999
|
|
return (year, entry.get("key", ""))
|
|
|
|
|
|
def build_readme(topic, entries, pdf_count, suspect, notes):
|
|
lines = []
|
|
lines.append(f"# {topic} — literature archive")
|
|
lines.append("")
|
|
lines.append(f"Archived: {date.today().isoformat()}")
|
|
lines.append(f"Verified entries: {len(entries)}")
|
|
lines.append(f"PDFs bundled: {pdf_count}")
|
|
lines.append("")
|
|
lines.append(
|
|
"Every entry in `references.bib` passed independent verification "
|
|
"(arXiv ID / DOI resolution and/or cross-source title match, "
|
|
"similarity >= 0.9) via the literature-search-verify skill before "
|
|
"being archived here. Citation keys follow the surname+year "
|
|
"convention and are stable -- the paper-writing-grounded skill's "
|
|
"`\\cite{}` calls should match these keys directly."
|
|
)
|
|
lines.append("")
|
|
lines.append("## Entries (chronological, oldest first)")
|
|
lines.append("")
|
|
for e in entries:
|
|
title = e.get("title", "?")
|
|
year = e.get("year", "?")
|
|
venue = e.get("journal") or e.get("booktitle") or e.get("school") or ""
|
|
doi = e.get("doi", "")
|
|
note = e.get("note", "")
|
|
line = f"- **{e['key']}** ({year}) — {title}"
|
|
if venue:
|
|
line += f". *{venue}*"
|
|
if doi:
|
|
line += f". DOI: {doi}"
|
|
lines.append(line)
|
|
if note:
|
|
lines.append(f" - Note: {note}")
|
|
|
|
if suspect:
|
|
lines.append("")
|
|
lines.append("## Flagged during search — NOT included above, do not cite")
|
|
lines.append("")
|
|
for s in suspect:
|
|
parts = s.split("|", 1)
|
|
title = parts[0].strip()
|
|
reason = parts[1].strip() if len(parts) > 1 else ""
|
|
lines.append(f"- {title}" + (f" — {reason}" if reason else ""))
|
|
|
|
if notes:
|
|
lines.append("")
|
|
lines.append("## Search coverage notes")
|
|
lines.append("")
|
|
lines.append(notes)
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("topic", help="Human-readable topic name, e.g. \"UAV aeromagnetic compensation\"")
|
|
ap.add_argument("--bib", required=True, help="path to the curated/verified .bib file to archive")
|
|
ap.add_argument("--project-root", default=".", help="project root; archive is written under <root>/references/<slug>/")
|
|
ap.add_argument("--pdfs-dir", default=None, help="optional folder of open-access PDFs to copy alongside the bib")
|
|
ap.add_argument("--suspect", action="append", default=[], help="title|reason of a suspect/unverified entry to log; repeatable")
|
|
ap.add_argument("--notes", default=None, help="free-text notes on search coverage/gaps for the README")
|
|
args = ap.parse_args()
|
|
|
|
if not os.path.isfile(args.bib):
|
|
print(f"error: bib file not found: {args.bib}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
slug = slugify(args.topic)
|
|
archive_dir = os.path.join(args.project_root, "references", slug)
|
|
os.makedirs(archive_dir, exist_ok=True)
|
|
|
|
bib_dest = os.path.join(archive_dir, "references.bib")
|
|
shutil.copyfile(args.bib, bib_dest)
|
|
entries = parse_bib_entries(bib_dest)
|
|
entries.sort(key=year_sort_key)
|
|
|
|
# Rewrite the archived .bib in chronological order (oldest first) so the
|
|
# file itself, not just the README, reads as a timeline.
|
|
header = f"% {args.topic} -- verified references, chronological order\n% Archived {date.today().isoformat()}\n\n"
|
|
with open(bib_dest, "w", encoding="utf-8") as f:
|
|
f.write(header)
|
|
f.write("\n\n".join(e["raw"] for e in entries))
|
|
f.write("\n")
|
|
|
|
pdf_count = 0
|
|
if args.pdfs_dir and os.path.isdir(args.pdfs_dir):
|
|
pdf_dest_dir = os.path.join(archive_dir, "pdfs")
|
|
os.makedirs(pdf_dest_dir, exist_ok=True)
|
|
for fn in sorted(os.listdir(args.pdfs_dir)):
|
|
if fn.lower().endswith(".pdf"):
|
|
shutil.copyfile(os.path.join(args.pdfs_dir, fn), os.path.join(pdf_dest_dir, fn))
|
|
pdf_count += 1
|
|
|
|
readme = build_readme(args.topic, entries, pdf_count, args.suspect, args.notes)
|
|
with open(os.path.join(archive_dir, "README.md"), "w", encoding="utf-8") as f:
|
|
f.write(readme)
|
|
|
|
print(archive_dir)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|