⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
autonomous-research
An 18-agent pipeline that turns one topic line into a drafted
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
Not yet listed on ClawHub or SkillsMP
// README
OpenDraft: open-source research paper and thesis writer with verified citations
The open-source agent system that writes research papers with verified citations.
OpenDraft drafts research papers, literature reviews and thesis chapters from one topic, and keeps a citation only if at least two of Crossref, OpenAlex and Semantic Scholar hold its DOI. An open-source alternative to Jenni and ThesisAI: MIT licensed, runs on your own model key.
Website: opendraft.xyz · Docs · Sample PDF · Hosted version (OpenPaper)
Use OpenDraft as a skill in your own agent (Claude, ChatGPT), free → getedge.cc/skills/autonomous-research
Cited in research
- Talukder et al. (2026), Discourse2Draft: an AI-powered structured document writing tool, Frontiers in Artificial Intelligence. Peer-reviewed NIH paper, next to Stanford's STORM (comparison).
- Kong et al. (2026), AI for Auto-Research: Roadmap & User Guide, arXiv:2605.18661. NUS survey of research agents.
24k+ downloads: 20,289 on PyPI (pepy.tech) and 4,200 on npm, as of November 2026.
Quickstart
Requires Python 3.10+ and a Gemini API key (Claude and OpenAI also work).
pip install opendraft
opendraft setup # enter your Gemini API key
opendraft "Remote work and team productivity"
| Command | What it does |
|---|---|
opendraft | Interactive mode |
opendraft "Topic" --level phd --lang de | Draft at a level (research_paper, bachelor, master, phd) and language |
opendraft "Topic" --expose | Outline and sources only, faster |
opendraft revise ./output "make the intro longer" | Revise a draft |
opendraft tldr paper.pdf | 5-bullet summary of any paper |
Use it as a skill
In Claude Code, Codex or any agent that reads skills:
npx skills add federicodeponte/opendraft --skill autonomous-research
Then ask: Write a paper on <topic>. The skill lives in skills/autonomous-research/; it runs inside your agent and does not call this Python engine.
Run the engine yourself
git clone https://github.com/federicodeponte/opendraft.git
cd opendraft
pip install -e ./engine
cp .env.example engine/.env # add your key
opendraft "Your topic"
| Variable | Required | Purpose |
|---|---|---|
GOOGLE_API_KEY | One key required | Gemini (default provider) |
ANTHROPIC_API_KEY / OPENAI_API_KEY | Optional | Claude or OpenAI, with AI_PROVIDER=claude or openai |
ENABLE_SEMANTIC_SCHOLAR | Optional | Set false if Semantic Scholar rate-limits you |
All settings are listed in .env.example.
How it works
19 agents work in six phases: research, structure, compose, validate, refine, enhance. A source stays only if at least two of Crossref, OpenAlex and Semantic Scholar hold its DOI. That proves the work exists, not that every claim about it is right, so a second check judges whether each source fits its claim. Details: citation verification · pipeline · methodology.
FAQ
What is OpenDraft? An open-source agent system that researches and writes papers with verified citations. MIT licensed.
Is OpenDraft free? Yes. You pay only your model provider, about $0.35 to $3 per draft.
How are citations verified? A source stays only if at least two of Crossref, OpenAlex and Semantic Scholar hold its DOI. That proves it exists, not that every claim about it is right.
How long does a draft take, and in what formats? Usually 10 to 20 minutes. Export to PDF, Word and LaTeX.
Who has cited OpenDraft? A peer-reviewed NIH paper (Talukder et al., 2026), next to Stanford's STORM, and the NUS survey AI for Auto-Research (Kong et al., 2026).
Are the drafts ready to submit? No. They are starting points: review and edit before use, and follow your institution's policy on disclosure.
Contributing
Issues and pull requests are welcome. See CONTRIBUTING.md, ask in Discussions, and read the changelog.
License
MIT, see LICENSE. To cite OpenDraft, use CITATION.cff ("Cite this repository" on GitHub).
// HOW IT'S BUILT
// FILES
60 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
skills/autonomous-research/SKILL.md32 KBe3167622ee2fupstream ↗
--- name: autonomous-research description: An 18-agent pipeline that turns one topic line into a drafted research paper, literature review, or thesis chapter. Finds real sources at Crossref and OpenAlex, drafts each section against them, attacks its own draft for weak claims, then compiles citations deterministically so in-text markers map one to one onto the bibliography and every printed DOI resolved at Crossref or DataCite. Every agent is a markdown file you can open and edit. Pure stdlib, no API key, no account. Use when someone asks to write a research paper, do a literature review, draft a thesis chapter, find sources on a topic and write them up, or names opendraft or openpaper. --- # Autonomous Research Turns one topic line into a finished paper with a real literature base. Ported from OpenDraft (github.com/federicodeponte/opendraft, MIT), which runs this as a hosted engine. Here the engine is you: eighteen stages and one evidence stage, each a prompt in `agents/`, plus the scripts that do the parts a language model must not do by hand. **You are the model.** No key, no service, no account. The only network calls are Crossref, OpenAlex and DataCite, all open endpoints. ## The one command ``` Write a paper on <topic> ``` Everything below follows from that. Do not ask the user to run the stages themselves; run them. ## Ask four things first Before stage 5, if the user has not already said, ask for: 1. **Target venue**, or "none, general academic". 2. **Total word limit.** 3. **Citation style**, one of the six in `references/citation-styles.md`. 4. **Document type**, one of the types in `references/paper-types.md`. These are four questions in one message, not an interview, and they are the only questions the pipeline asks. Everything else it decides. Ask because the cost of guessing lands at the end and cannot be paid there. A venue with a hard 150-word abstract cap is enforced at stage 6 or nowhere: by the time stage 17 writes the abstract, the word budget every section was drafted against is already wrong. A citation style chosen at the gate rather than at stage 6 means the whole draft was written against the wrong marker density. If the user declines to answer, or says "you pick", proceed on the defaults in `references/paper-types.md`, write them into the venue format block at stage 6, and say in one line which defaults you used. Never proceed on an unstated assumption you did not show them. ## Write down the brief's own numbers, then check against them Most briefs arrive with numbers already in them, in ordinary words: "about 3,000 words", "1200-1500 words", "a couple of dozen sources at least", "caps the main text at 6,000 words and the abstract at 200", "thirty or more works", "cover the hospital series, the exposure problem and the policy evaluations". Each of those is a requirement, and each of them is a requirement this pipeline loses if nobody writes it down, because every stage after the first works from an outline rather than from the request. So write them down. At stage 6, alongside the venue format block, record what the user asked for in `research/brief.json`: ```json { "word_range": [2700, 3300], "min_references": 24, "abstract_max_words": 250, "required_sections": ["Introduction", "Methodology", "Discussion", "Conclusion", "References"] } ``` Four rules about that file: - **Every value in it traces to a sentence in the brief**, or to the document type's own skeleton in `references/paper-types.md` where the user left the choice open. A number nobody asked for does not belong in it. - **"About N" is a range, not a point.** "About 3,000 words" is a request to land near three thousand, so record a band around it rather than the single number; ten percent either side is the same tolerance `integrity.py` has always used for `--target`. A stated cap ("no more than 6,000") is a ceiling and is recorded as one. - **Stated minimums are floors, and the only way past one is more sources.** "A couple of dozen at least" records as 24 and stays 24. See "Keep searching until the count is met" below. - **Omit what the brief does not state.** Nothing in the file has a default, and an absent key is simply not checked. An invented cap is as wrong as a missed one. `integrity.py` then reads that file at the gate and checks the draft against it, so the numbers the user stated are tested against the delivered document rather than remembered. ## Four things the scripts own, and you do not A language model is good at judgement and bad at bookkeeping. The split is deliberate, and crossing it is how this output breaks. Each of these is enforced by a script that exits nonzero, so none of them is a matter of opinion at the gate. **Never write a rendered citation marker by hand.** Not `[3]`, not `(Smith, 2020)`. While drafting you write `{cite_<doi>}` inline at the exact point of the claim. `scripts/citations.py compile` turns those into markers and builds the bibliography by dictionary lookup. It is deterministic, so the mapping between prose and bibliography is mechanical rather than remembered. **Never invent a source for a claim you cannot support.** If a claim is real and you cannot find a source for it, write `{cite_MISSING: short description of the claim}` and keep going. `compile` refuses to render it, names it, and exits nonzero, and `integrity.py` counts it as critical, so it cannot survive to a finished paper. That is the point: it is not a way to ship an unsourced claim, it is a way to be honest while drafting instead of quietly deleting the claim or attaching it to a DOI you made up. Clear each one before the gate by finding a real source with `sources.py find`, or by cutting the claim on purpose. **Never merge the sections into the draft by hand.** `scripts/assemble.py` does it, in numeric order, and refuses to splice anything that is not a numbered section into the paper. A hand merge that drops one section is the failure nobody notices until a reader does. **Never decide by eye whether the paper is internally consistent.** `scripts/integrity.py` checks it and exits nonzero. Run it and read the exit code. ## Setup ```bash mkdir -p research/texts sections review ``` No API key, no account, no install step: the scripts are Python standard library only. One optional environment variable exists, and it is the only one anything here reads. Set `OPENDRAFT_CONTACT_EMAIL` to your own address and `sources.py` appends it to its User-Agent, which moves Crossref requests into the polite pool and its better rate limits. Leave it unset and every stage still runs; no address is baked in, because a shipped default would pool every installer into one identity and route their rate-limit problems to a stranger's inbox. The pipeline writes to fixed paths, and each stage reads what earlier stages wrote. The paths are the contract between stages: ``` research/sources.md research/sources.json research/summaries.md research/gaps.md research/citations.json research/citation-notes.md research/brief.json research/abstracts.json research/evidence.json research/index.json research/texts/*.txt research/figures/* outline.md outline_formatted.md sections/*.md full_draft.md final.md review/thread.md review/narrator.md review/skeptic.md review/verifier.md review/referee.md review/voice.md review/entropy.md review/polish.md review/evidence.md ``` Four rules about those paths, and each of them has been broken before: - **`sections/` holds paper sections and nothing else.** Every file in it is spliced into the finished paper by `scripts/assemble.py`. A stage report parked there ends up inside somebody's thesis. Reports go in `review/`. - **`research/sources.json` is machine input, `research/sources.md` is human reading.** Stage 1 writes both. `citations.py build` reads the JSON one. - **Nothing hand-written ever goes into `research/citations.json`.** It is built by `citations.py build` and only ever changed by re-running `build`. The judgement calls that no script can make, sources with no DOI, entries needing review, mentions that could not be turned into a placeholder, go in `research/citation-notes.md`, which is prose and is never read by a script. A hand-edited database is how a `verified` flag gets flipped to get past the gate, which is how an unresolved DOI reaches the bibliography looking checked. - **After stage 9.5, `full_draft.md` is the only draft.** Nothing downstream reads `sections/*.md` again, so editing a section file after assembly changes nothing and quietly loses the edit. ## The pipeline Run in order. Every stage but one is a file in `agents/`: read that file, do what it says, write the output it names, then move on. The exception is stage 9.5, which is a script you run. Do not skip a stage because the topic looks easy. | # | Stage | Agent file or script | Writes | | --- | --- | --- | --- | | 1 | Find sources | `agents/01-scout.md` | `research/sources.md`, `research/sources.json` | | 2 | Read and summarise them | `agents/02-scribe.md` | `research/summaries.md` | | 3 | Find the gap worth writing into | `agents/03-signal.md` | `research/gaps.md` | | 4 | Build the citation database | `agents/04-citation-manager.md` | `research/citations.json`, `research/citation-notes.md` | | 4.5 | Extract the evidence (reviews) | `agents/04.5-evidence.md` | `research/abstracts.json`, `research/texts/*.txt`, `research/evidence.json`, `research/index.json`, `research/figures/*`, `review/evidence.md` | | 5 | Outline the argument | `agents/05-architect.md` | `outline.md` | | 6 | Apply venue format and word budgets | `agents/06-formatter.md` | `outline_formatted.md`, `research/brief.json` | | 7 | Write each section | `agents/07-crafter.md` | `sections/*.md`, appends to `research/gaps.md` | | 8 | Check cross-section consistency | `agents/08-thread.md` | fixes in `sections/*.md`, `review/thread.md` | | 9 | Unify voice | `agents/09-narrator.md` | fixes in `sections/*.md`, `review/narrator.md` | | 9.5 | Assemble the sections into one draft | `scripts/assemble.py` | `full_draft.md` | | 10 | Attack the argument | `agents/10-skeptic.md` | `review/skeptic.md`, then fixes | | 11 | Check claims against sources | `agents/11-verifier.md` | `review/verifier.md`, then fixes | | 12 | Simulate peer review | `agents/12-referee.md` | `review/referee.md`, then fixes | | 13 | Match the author's voice (optional) | `agents/13-voice.md` | fixes in `full_draft.md`, `review/voice.md` | | 14 | Vary the prose rhythm | `agents/14-entropy.md` | fixes in `full_draft.md`, `review/entropy.md` | | 15 | Grammar and final polish | `agents/15-polish.md` | `full_draft.md`, `review/polish.md` | | 16 | Add apparatus (optional) | `agents/16-enhancer.md` | `full_draft.md` | | 17 | Write the abstract | `agents/17-abstract.md` | prepended to `full_draft.md` | | 18 | Write the title | `agents/18-titlemaker.md` | prepended to `full_draft.md` | Stage 4.5 runs for literature reviews, scoping reviews and any paper that compares what several studies found about the same outcomes, and is skipped otherwise. It turns findings into `research/evidence.json`, one verbatim quote per finding, and the scripts then compute the evidence index and draw every evidence figure and table from that file. Its number is a half step for the same reason 9.5's is. Stage 7 runs once per section, not once per paper. Stages 10 to 12 produce issue lists; an issue list nobody applies is a no-op, so apply the fixes and re-run the stage until no critical issue remains. ## Keep searching until the count is met Stages 1 and 4 are a loop, not a pass. Run them, then count what survived: ```bash python3 scripts/citations.py verify -d research/citations.json ``` It prints `resolved=N` and that N is the real number: it is what the paper can cite, after the DOIs that turned out to be absent, invalid or unreachable have come out. Compare N against the floor, which is whichever is higher of the `min_references` the brief stated and the per-type floor in `references/paper-types.md`. If N is below the floor, go back to stage 1 and search again. Not the same queries: different ones. The pool ran short because the queries ran out of angles, so widen along the ones the brief itself names, the adjacent literature, the sub-questions the topic decomposes into, the review articles that would cite this work, the outcome measures by name. Then merge into `research/sources.json`, re-run `citations.py build` and `verify`, and count again. Keep going until N clears the floor, or until further queries stop returning anything new. This is where verification discipline turns into its own failure mode. The right instinct, refusing to cite what did not verify, has a wrong ending: a review that drops half its sources at the verification step and then ships the half that survived has not been careful, it has been short. Fifty found and ten verified is not a ten-source review, it is a search that has to continue. The pool is a floor on what you go and find, and the only two honest ways to reach the end of this loop are to meet it or to tell the user plainly, in the delivered document, how many sources you verified and why the literature would not yield more. Never close the gap the other way. Do not lower the floor to what you have, do not cite a DOI whose state is not `resolved`, do not pad the list with sources the paper never cites, and do not count one work twice under two identifiers. Stage 12 has a number, not a feeling. Stop when a fresh run reports zero critical issues and an overall average of at least 3.0 out of 5, with no single dimension below 3 unless you record why in `review/referee.md`. "Good enough to send" is not a stopping condition, because a model asked to judge its own draft will always find it good enough on the third pass. Stage 13 is the only stage with an input the pipeline never produces. It matches the draft to the author's own prose, and it reads that prose from a `samples/` directory in the working directory: two or three prior papers, chapters or long-form posts, as `.md` or `.txt`. Nothing creates that directory and nothing asks you for it, so a run that never makes one skips stage 13 cleanly, which is the normal outcome rather than a failure. Put your own writing there before the run if you want the paper to sound like you wrote it. Stage 9.5 is numbered as a half step because it is a script rather than an agent prompt, and because the eighteen agent stages keep the numbers they already had. It is not optional. Stages 1 to 9 work on `sections/*.md`; stages 10 to 18 work on `full_draft.md`; nothing produces that file except this command: ```bash python3 scripts/assemble.py sections -o full_draft.md ``` It merges the section files in numeric order, refuses to include any file that is not a numbered section, and exits nonzero on a numbering gap, a duplicate number or an empty directory. `--check` reports what it would do without writing. It also skips any numbered file whose name contains `report`, `review`, `notes`, `checklist` or `log`, which is the mechanism that stops a stale stage report ending up inside a thesis. That is a filename heuristic, so it has one sharp edge: a real section called "Review of the literature" would be skipped, and you would see it only as a "Skipped" line on stdout rather than as an error. Name that section `related-work` or `literature` instead. Read the skip lines; a section that vanishes here vanishes silently. Re-running it after `full_draft.md` exists needs `--force`, and `--force` throws away every edit stages 10 and later made to the draft, so re-assemble only when you mean to restart from the sections. ## Scale Match the pipeline to what was asked. The stages are the same; the depth is not. - **A short piece, 1,500 to 3,000 words.** Stages 1 to 7, then 9.5, 10, 11, 15, 17. Ten to fifteen sources, or the brief's own minimum wherever it asks for more. - **A full paper, the default.** All eighteen, plus 9.5, and 4.5 for a review. Twenty-five to fifty sources, or fifty and up when the paper is a literature review, whose own floor governs wherever it is higher (`references/paper-types.md`). - **A thesis chapter or long review.** All eighteen, plus 9.5, and 4.5 for a review, sources in the fifties or more, and stage 7 once per subsection rather than per section. A long review runs 8,000 to 12,000 words on sixty sources or more; its length comes from covering more of the literature and reporting more of it in the evidence tables, never from restating the same findings at greater length. Stage 9.5 is in every one of those lists. There is no scale at which a paper assembles itself. Stage 17 is in every one of those lists too. Every section skeleton in `references/paper-types.md` opens with an abstract, so a tier that skipped the stage that writes one produced a paper missing its first section, and the word it goes under is the only thing scale changes: a journal article has an abstract, a committee paper or an evidence brief has a summary, and both are the same section doing the same job. Write it in the document's own register and keep it inside whatever cap the brief states. Where the brief states none, `agents/17-abstract.md` carries the fallback range. ## The two reference files - **`references/paper-types.md`** carries the section skeleton, word budget and source count for each document type. Read it at stage 5. - **`references/citation-styles.md`** shows the real compiled output of all six styles, in-text marker and reference entry, so you can choose one for the venue and know what it will look like. Read it at stage 6, and again before the gate if a citation renders in a way you did not expect. It also states what the compiler does not carry: no volume, issue or page numbers, no journal abbreviation. If a supervisor requires those, this is the place that says so honestly rather than the place you find out afterwards. Neither file is optional reading dressed up as a reference. A style chosen without reading the second one is a style chosen from memory, and the compiler does not implement your memory of APA. ## The scripts ```bash python3 scripts/sources.py find "<query>" --n 15 # Crossref plus OpenAlex python3 scripts/sources.py find "<query>" --json # machine-readable python3 scripts/sources.py verify <doi> <doi> ... # Crossref plus DataCite python3 scripts/citations.py build research/sources.json -o research/citations.json python3 scripts/citations.py verify -d research/citations.json python3 scripts/citations.py compile full_draft.md -d research/citations.json --style apa -o final.md python3 scripts/citations.py bibtex -d research/citations.json -o refs.bib python3 scripts/assemble.py sections -o full_draft.md python3 scripts/assemble.py sections -o full_draft.md --check python3 scripts/integrity.py final.md -d research/citations.json --target 8000 python3 scripts/integrity.py final.md -b research/brief.json # the brief's own numbers python3 scripts/integrity.py final.md --stats # count, do not check python3 scripts/integrity.py final.md -c research/summaries.md # advisory number check python3 scripts/export.py final.md --format docx -o final.docx python3 scripts/export.py final.md --format pdf -o final.pdf --template journal --kind "Narrative review" python3 scripts/evidence.py abstracts research/sources.json -o research/abstracts.json python3 scripts/evidence.py check research/evidence.json --abstracts research/abstracts.json --texts research/texts -d research/citations.json python3 scripts/evidence.py index research/evidence.json -o research/index.json python3 scripts/evidence.py figures research -o research/figures ``` Every one of them exits nonzero on failure. That exit code is the signal; read it rather than skimming the output. `sources.py find` exits 1 when no API could be reached at all, which is different from reaching them and getting no hits. `agents/01-scout.md` is the authority on how to read that exit code and on the source floor below which stage 1 stops rather than proceeding; read it there rather than trusting an empty result. `citations.py build` takes the JSON array stage 1 wrote to `research/sources.json`, not the markdown in `research/sources.md`. Feeding it the markdown file is an error, and it will say so. Search two or three narrower sub-queries as well if the first pass is thin. A paper with five sources reads like one with five sources. ## Length is a number, not an impression Count the draft before delivering it, and count it again after every trim: ```bash python3 scripts/integrity.py full_draft.md --stats ``` That prints the main text's word count, the abstract's, the number of reference entries and the headings. Main text means the body: the reference list and the abstract are counted separately, because a brief that caps the main text and the abstract as two numbers is treating them as two numbers. Do this at stage 15, before the gate, and act on what it says. Over the range, cut, and cut the paragraphs that repeat an argument already made rather than shaving a word from every sentence. Under it, go back to the sources and write what the pool actually supports; if the pool does not support more, that is a finding about the literature, and it goes in the paper as one instead of being padded over. The estimate is the thing to distrust here. A draft that feels like five thousand words can be nine, and a paper that comes in half again over a stated cap is not a long paper, it is one the venue will not take and the reader will not finish. Neither the model that wrote the prose nor the person reading it back can tell six thousand words from nine and a half by eye; the command above can, and it takes a second. ## The docx a reader opens `scripts/export.py` sets the body font, the body size and the page margins on the exported docx, through the document's Normal style rather than stamped onto each paragraph, so a reader who restyles Normal restyles the paper. The defaults are Times New Roman, twelve point, one inch margins, which is the ordinary manuscript setting; `--font`, `--font-size` and `--margin-inches` change them where a venue's house style asks for something else. This is set rather than left alone because pandoc's own default names no body font at all, which means the file opens in whatever the reader's word processor calls Normal, and on a current Word that is Calibri. A literature review for a supervisor, a brief for a committee and a manuscript for a journal are all documents whose typography somebody is expected to have decided. Arriving in the word processor's default is the one outcome that says nobody did. ## The journal PDF `scripts/export.py --template journal` typesets `final.md` as a two-column journal article through `scripts/journal.py`: a masthead, a serif title with the subtitle split off at the first colon, a two-column abstract, numbered sections, merged runs of adjacent author-year citations, and, when stage 4.5 ran, an at-a-glance strip, the evidence figures, the two evidence tables and the index equations at the draft's placeholder lines. The fonts, STIX Two Text and Source Sans 3, ship in `assets/fonts/` under the SIL Open Font License. It needs pandoc and, for the PDF, weasyprint. It invents nothing a journal page usually carries. There is no journal name, volume, issue, received date, affiliation or DOI unless you pass it: `--brand` sets the masthead name (default OpenDraft), `--kind` the article type line, `--byline` and `--masthead-note` the two lines of small print, and each takes only what is true of this paper. A placeholder whose figure was never drawn stops the export with the command that draws it, rather than printing the braces or leaving a hole. The docx, latex and plain html exports cannot place those figures, so they drop the placeholder lines and name each one on stderr. ## Verification, and what it does and does not prove `citations.py verify` puts every DOI into one of four states, and `unknown` is never quietly turned into `absent`: - **resolved.** Crossref or DataCite returned the record. Cite it. - **absent.** Both returned 404. Drop it. Do not repair it, do not guess a replacement DOI, go back to stage 1 for a real source. - **unknown.** A network or rate-limit failure. Retry once, then name it in the paper's limitations rather than pretending it resolved. - **invalid.** Malformed or empty. arXiv preprints resolve at DataCite and 404 at Crossref, which is why the check runs both. A Crossref-only check silently deletes every preprint. `citations.py compile` refuses to render any citation whose state is not `resolved`, and names every offender. That is what makes the DOI claim in this skill's own description true rather than aspirational: a DOI that never resolved cannot reach the printed bibliography, because the compiler will not print it. The fix for a refusal is a real source, never a softer claim. This makes `verify` mandatory rather than advisory, and the order is not cosmetic. `build` writes `verified: "unknown"` for every record it creates, so a `compile` run before a `verify` run refuses every citation in the paper and exits 1. If that happens, you have not found a bug; you have skipped a step. A source with no listed authors is citable. It renders as `Anon.` in the marker and at the head of its bibliography entry, in all six styles. Do not discard such a source, and never supply an author name it does not have. A resolved DOI proves the work exists. It does not prove the work supports the sentence citing it. That second question is stage 11, and it is the one that matters most. ## The gate, non-negotiable Before showing anyone the paper: ```bash python3 scripts/citations.py verify -d research/citations.json python3 scripts/citations.py compile full_draft.md -d research/citations.json --style <style> -o final.md python3 scripts/integrity.py final.md -d research/citations.json -b research/brief.json -c research/summaries.md ``` When stage 4.5 ran, two more commands belong to the gate, because the figures are only as current as the file they were drawn from: ```bash python3 scripts/evidence.py check research/evidence.json --abstracts research/abstracts.json --texts research/texts -d research/citations.json python3 scripts/evidence.py index research/evidence.json -o research/index.json python3 scripts/evidence.py figures research -o research/figures ``` `check` must exit 0: every quote found verbatim in its source text, every study's DOI in the citation database. Then compare every *S*, *M* and leave-one-out number in `final.md` against what `index` just printed. A number that differs is a stale number, and the draft is corrected to the file, never the other way round. `verify` runs first so that every record carries a current resolution state before `compile` decides what it is allowed to print. `compile` then refuses any citation that is not `resolved`. `integrity.py` checks that no `{cite_` placeholder survived, that every bibliography entry is pointed at by a marker, that every marker resolves to an entry, that numeric bibliographies carry their numbers, that no stranded punctuation was left where a marker moved, that the word count is on target, and that no template text is left behind. With `-b` it also checks the five things the user asked for, and this half of the gate is the one that answers for the finished document rather than for its citations: - the main text lands inside the word range they stated, - the abstract or summary is there and inside its cap, - the reference list meets the minimum they stated, - every section they named by name exists, - and, as it always has, every in-text citation resolves to an entry in the reference list and every entry is cited. That last one is the one nobody checks by eye and nobody gets right by eye. A reference list is long, the markers are scattered through twenty pages, and a single entry that lost its marker in a late cut looks exactly like twenty-nine that did not. It is checked here or it is not checked. Run this against the file you actually hand over. Exporting `final.md` to docx or pdf does not change its words, so checking `final.md` checks the export; but a paragraph edited straight into the delivered markdown after the gate ran was never checked at all, and the whole gate has to run again. There is no version of this where the thing delivered and the thing checked are different files. If one of these truly cannot be satisfied, say which one, in one plain sentence to the user and in the document's own limitations section: the reference list came to nineteen and not to twenty-four, and here is what the searching turned up. An unmet requirement that is named is a fact the reader can act on. An unmet requirement that is silent is one they find out about from the person they sent the paper to. `-c research/summaries.md` adds one advisory report on top of that: every number in the draft that appears nowhere in the research corpus. It never changes the exit code, and it is not a list of errors. It exists because the checks above have one blind spot they cannot close. A bracketed slot like `[figure from summaries.md]` left unfilled is caught; the same slot replaced with a plausible invented number is caught by nothing, because the DOI still resolves, the marker still maps, and the word count still lands. Read the advisory list and account for each number: derived from the corpus, missing from a summary that should carry it, or invented. Only the last one is a defect, and it is the one nothing else in this pipeline can see. Any failure means fix it, not ship it with a caveat. Numeric prose citing `[1]` to `[27]` against an unnumbered alphabetical list is the most common way this output becomes unusable, and it is invisible until you read the rendered document rather than the markdown. ## After the gate passes Once all three commands above have exited zero, and only then, print exactly this to the user, as the last thing you say in the conversation: ``` Built with opendraft (MIT). If it saved you time, a star helps: github.com/federicodeponte/opendraft ``` Two words on where that goes. It is a message to the user, in the terminal or the chat. It never goes into `final.md`, `full_draft.md`, any section file, or any exported document. A line like that inside the paper is a watermark in somebody's thesis, and it is exactly the class of unauthored boilerplate that the integrity gate's leftover-template-text check exists to catch. On a failed run, do not print it. Fix the failure and re-run the gate. ## State the limits in the paper One short section, plainly worded: - Sources without a DOI are printed unchecked. - A resolved DOI proves the work exists, not that it supports the sentence. - Name any source that came back `unknown`, and any claim stage 11 marked unverifiable. ## Disclosure This draft was produced with AI assistance, and most journals, conferences and universities now have a written policy on saying so. Some require a disclosure statement, some restrict which stages may be automated, and a few prohibit it outright for student work. Tell the user this once, at the end of the run, in one sentence: the draft was AI-assisted, and their venue or institution likely has a disclosure policy worth checking before submission. Do not draft the disclosure statement for them and do not guess what their policy says. The wording is specific to the venue and getting it wrong is worse than leaving it to them. This is separate from the limitations section above, which is about the sources. This one is about the authorship. ## What this does not do It writes a first draft with a real literature base. It does not do your fieldwork, replace peer review, or make you the author of something you have not read. Read the sources before you put your name on it. ## Claims you may make about the output Permitted, because they are enforced above: markers map one-to-one to the bibliography; every printed DOI resolved at Crossref or DataCite; each cited claim was checked against its source and the unverifiable ones are disclosed; sources without a DOI are printed unchecked. Forbidden: "every citation is real", "no hallucinations", "verified citations", zero errors, exhaustive research, any accuracy guarantee.skills/autonomous-research/scripts/assemble.py6.6 KB56ed59939b89upstream ↗
#!/usr/bin/env python3 """Merge the numbered section files of a draft into one document. No API key, standard library only, no model involved: this is a file concatenation with the ordering and completeness checks that a model doing the same job by hand keeps getting wrong. Sections are ordered by their leading number, not lexically, so 10 follows 9 instead of 1. assemble.py sections -o full_draft.md assemble.py sections -o full_draft.md --check assemble.py sections -o full_draft.md --force A file is part of the body when its name starts with a number: 01_intro.md, 02-methods.md, 03.md. Everything else in the directory is left alone, and so is a numbered file whose name marks it as a report rather than prose, because a review report that lands in sections/ used to be spliced into the paper. Refuses to run on a gap or a duplicate in the numbering rather than quietly producing a draft with a missing section, and refuses to overwrite an existing output without --force. """ import argparse import re import sys from pathlib import Path # 01_intro.md, 02-methods.md, 3.md -- the number is what orders the document. SECTION_NAME = re.compile(r"^(\d+)(?:[-_. ]+(?P<slug>.*))?$") # A review report is not prose. Reports belong in review/<stage>.md, but a # stale one sitting in sections/ used to be concatenated into the paper as if # it were a chapter, and nothing downstream could tell it apart from one. REPORT_TOKENS = ("report", "review", "notes", "checklist", "log") class AssembleError(Exception): """A problem the user can fix, reported without a traceback.""" def _classify(path): """(number, is_report) for a body-eligible file, or (None, False).""" m = SECTION_NAME.match(path.stem) if not m: return None, False slug = (m.group("slug") or "").lower() tokens = re.split(r"[^a-z0-9]+", slug) return int(m.group(1)), any(t in REPORT_TOKENS for t in tokens) def plan(source_dir): """Decide what would be merged, in order, and what would be skipped. Returns (sections, skipped): `sections` is a list of (number, Path) sorted by number, `skipped` a sorted list of Paths left out of the body.""" source = Path(source_dir) if not source.exists(): raise AssembleError(f"section directory not found: {source}") if not source.is_dir(): raise AssembleError(f"not a directory: {source}") sections, skipped, by_number = [], [], {} for path in sorted(source.iterdir()): if not path.is_file() or path.suffix.lower() != ".md": continue number, is_report = _classify(path) if number is None or is_report: skipped.append(path) continue if number in by_number: first = by_number[number].name raise AssembleError( f"two files share section number {number:02d}: {first} and " f"{path.name}. Renumber one of them; assemble.py will not " f"guess which comes first.") by_number[number] = path sections.append((number, path)) if not sections: raise AssembleError( f"no numbered section files in {source}. Section files are named " f"with a leading number, for example 01_introduction.md.") numbers = [n for n, _ in sections] missing = [n for n in range(min(numbers), max(numbers) + 1) if n not in by_number] if missing: listed = ", ".join(f"{n:02d}" for n in missing) raise AssembleError( f"gap in the section numbering: {listed} missing between " f"{min(numbers):02d} and {max(numbers):02d}. A missing section is " f"a missing part of the paper, so nothing was written.") return sections, sorted(skipped) def merge(sections): """Concatenate section bodies, one blank line between them.""" bodies = [] for _, path in sections: text = path.read_text(encoding="utf-8").strip() if not text: raise AssembleError( f"{path.name} is empty. An empty section merged silently is a " f"heading with nothing under it in the finished paper.") bodies.append(text) return "\n\n".join(bodies) + "\n" def _build_parser(): parser = argparse.ArgumentParser( prog="assemble.py", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "source", help="Directory holding the numbered section files, e.g. sections") parser.add_argument( "-o", "--output", required=True, help="File to write the merged draft to, e.g. full_draft.md") parser.add_argument( "--check", action="store_true", help="Report what would be merged and what would be skipped, and write " "nothing. Still fails on a gap or a duplicate.") parser.add_argument( "--force", action="store_true", help="Overwrite an existing output file.") return parser def main(argv=None): args = _build_parser().parse_args(argv) out = Path(args.output) try: sections, skipped = plan(args.source) except AssembleError as e: print(f"Error: {e}", file=sys.stderr) return 1 if args.check: print(f"Would merge {len(sections)} section(s) into {out}:") for number, path in sections: print(f" {number:02d} {path.name}") if skipped: print("Not part of the body:") for path in skipped: print(f" {path.name}") return 0 if out.exists() and not args.force: # Assembly happens once, at stage 9.5. Everything after it edits # full_draft.md in place, so a second unforced run would throw that # work away, and the person running it has no other copy. print( f"Error: {out} already exists. Re-assembling replaces it with the " f"current contents of {args.source}/, which discards every edit " f"made to {out} after it was assembled: the stage 10 to 15 " f"revisions, the citation compile, and any hand edit since. " f"Pass --force if that is what you want.", file=sys.stderr) return 1 try: text = merge(sections) except (AssembleError, OSError) as e: print(f"Error: {e}", file=sys.stderr) return 1 out.parent.mkdir(parents=True, exist_ok=True) out.write_text(text, encoding="utf-8") print(f"Assembled {len(sections)} section(s) from {args.source} -> {out}") if skipped: print(f"Skipped (not body sections): " f"{', '.join(p.name for p in skipped)}") return 0 if __name__ == "__main__": sys.exit(main())skills/autonomous-research/scripts/citations.py31 KB0aacf8470f35upstream ↗
#!/usr/bin/env python3 """Citation database and deterministic compile step for the opendraft skill. No API key, standard library only. `verify` reuses the exact Crossref/DataCite resolution in sources.py; `compile` and `bibtex` are pure, deterministic dictionary lookups with no model involved. `compile` refuses to render a citation whose `verified` field is not `resolved`, which is what makes "every printed DOI resolved at Crossref or DataCite" a fact about the output rather than a hope about the process. citations.py build <sources.json> -o research/citations.json citations.py verify -d research/citations.json citations.py compile <draft.md> -d research/citations.json --style apa -o out.md citations.py bibtex -d research/citations.json -o refs.bib Placeholders in a draft look like {cite_<doi>}, e.g. {cite_10.1038/s41586-021-03819-2}. Styles: apa, mla, chicago, harvard (author-year, alphabetical bibliography); ieee, vancouver (numeric, numbered by first appearance in the text). """ import argparse import json import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import sources # noqa: E402 (needs the sys.path insert above) AUTHOR_YEAR_STYLES = {"apa", "mla", "chicago", "harvard"} NUMERIC_STYLES = {"ieee", "vancouver"} ALL_STYLES = AUTHOR_YEAR_STYLES | NUMERIC_STYLES DB_VERSION = 1 CITE_PATTERN = re.compile(r"\{cite_([^}]+)\}") # `{cite_MISSING: <description>}` is not a DOI placeholder. It is the drafter # saying, in the draft, that a claim has no source yet. CITE_PATTERN matches it # too -- group(1) would be the literal string "MISSING: <description>" -- so # every consumer of CITE_PATTERN filters it out and the specific error wins over # a misleading "not found in the database". MISSING_CITE = re.compile(r"\{cite_MISSING\s*:?\s*([^}]*)\}") def _cite_matches(text): """CITE_PATTERN matches that are real DOI placeholders.""" return [m for m in CITE_PATTERN.finditer(text) if not MISSING_CITE.fullmatch(m.group(0))] def _cited_dois(text): return {sources._norm_doi(m.group(1)) for m in _cite_matches(text)} # Author token used for a record with no named author, in the in-text marker and # at the head of the bibliography entry alike. One token, both places, so the # integrity gate can match them; the alternative was a truncated title in the # marker that nothing could ever resolve. ANON_AUTHOR = "Anon." # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- def _normalize_record(raw): """Normalize one scripts/sources.py find --json record into a DB entry.""" doi = sources._norm_doi(raw.get("doi")) authors = [] for a in raw.get("authors") or []: if isinstance(a, dict): family = (a.get("family") or "").strip() given = (a.get("given") or "").strip() else: # Backward-compatible with a plain family-name string. family, given = str(a).strip(), "" if family or given: authors.append({"family": family, "given": given}) return { "doi": doi, "title": (raw.get("title") or "").strip(), "authors": authors, "year": raw.get("year"), "venue": (raw.get("venue") or "").strip(), "publisher": (raw.get("publisher") or "").strip(), "type": raw.get("type") or "misc", "url": raw.get("url") or (f"https://doi.org/{doi}" if doi else ""), "verified": raw.get("verified", "unknown"), } class DatabaseError(Exception): """A citation database that cannot be read, or is not shaped like one. Named on purpose: a wrong-shaped database used to surface downstream as "placeholder not found in the database" for a DOI that is plainly sitting in the file, which sends a model hunting for a missing source that was never missing, and from there to inventing a replacement.""" _SCHEMA_HINT = ("Rebuild it with: python3 scripts/citations.py build " "research/sources.json -o research/citations.json") def _validate_database(data, path): """Fail on a wrong-shaped database here, where the error still names the real problem.""" if not isinstance(data, dict): raise DatabaseError( f"{path} is a {type(data).__name__}, not a citation database object. " f"Expected an object with a 'citations' member keyed by DOI. {_SCHEMA_HINT}") citations = data.get("citations", {}) if not isinstance(citations, dict): raise DatabaseError( f"{path}: 'citations' is a {type(citations).__name__}, but it must be " f"an object keyed by DOI, not a list. {_SCHEMA_HINT}") for doi, record in citations.items(): if not isinstance(record, dict): raise DatabaseError( f"{path}: citation {doi!r} is a {type(record).__name__}, " f"but every citation must be an object. {_SCHEMA_HINT}") authors = record.get("authors", []) if not isinstance(authors, list): raise DatabaseError( f"{path}: citation {doi!r} has a non-list 'authors' field. " f"Expected a list of {{\"family\", \"given\"}} objects. {_SCHEMA_HINT}") for author in authors: if not isinstance(author, dict): raise DatabaseError( f"{path}: citation {doi!r} has author {author!r} as a bare " f"{type(author).__name__}. Every author must be an object with " f"\"family\" and \"given\" keys. {_SCHEMA_HINT}") if "family" not in author and "given" not in author: raise DatabaseError( f"{path}: citation {doi!r} has an author object with neither " f"\"family\" nor \"given\". {_SCHEMA_HINT}") return data def load_database(path): p = Path(path) if not p.exists(): return {"version": DB_VERSION, "citations": {}} try: with open(p, "r", encoding="utf-8") as f: data = json.load(f) except json.JSONDecodeError as e: raise DatabaseError(f"{path} is not valid JSON: {e}") from e _validate_database(data, path) data.setdefault("version", DB_VERSION) data.setdefault("citations", {}) return data def save_database(path, data): p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) with open(p, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False, sort_keys=True) f.write("\n") def build_database(sources_json_path, out_path): with open(sources_json_path, "r", encoding="utf-8") as f: raw_records = json.load(f) if not isinstance(raw_records, list): raise ValueError( f"{sources_json_path} does not look like sources.py find --json output " "(expected a JSON array)" ) db = load_database(out_path) added, updated = 0, 0 for raw in raw_records: record = _normalize_record(raw) if not record["doi"]: print(f"# skipping record with no DOI: {record.get('title', '')[:60]!r}", file=sys.stderr) continue existing = db["citations"].get(record["doi"]) if existing: # Preserve a prior verification result; refresh the bibliographic # fields in case the source was re-fetched with better metadata. record["verified"] = existing.get("verified", "unknown") updated += 1 else: added += 1 db["citations"][record["doi"]] = record save_database(out_path, db) return {"added": added, "updated": updated, "total": len(db["citations"])} def verify_database(db_path): db = load_database(db_path) counts = {"resolved": 0, "absent": 0, "unknown": 0, "invalid": 0} failures = [] for doi, record in sorted(db["citations"].items()): result = sources.verify(doi) record["verified"] = result["status"] counts[result["status"]] = counts.get(result["status"], 0) + 1 if result["status"] in ("absent", "invalid"): failures.append((doi, result["status"], result.get("error", ""))) save_database(db_path, db) return counts, failures # --------------------------------------------------------------------------- # Formatting helpers # --------------------------------------------------------------------------- def _family_names(citation): return [a["family"] for a in citation["authors"] if a.get("family")] def _full_name(a): return f"{a.get('given', '')} {a.get('family', '')}".strip() def _initials(given): return " ".join(f"{part[0]}." for part in given.split() if part) def _inverted_name(family, given): """'Family, Given', with no stranded comma when either half is missing. Crossref routinely records a single name part, and sources.py maps that to family with an empty given, so f"{family}, {given}" produced "Chen, ." on real data. A trailing .strip() cannot remove an interior ", ".""" parts = [p for p in ((family or "").strip(), (given or "").strip()) if p] return ", ".join(parts) def _initial_name(a): """Family, G. I. -- the initials-first form used by APA/IEEE/Vancouver.""" return _inverted_name(a.get("family"), _initials(a.get("given") or "")) def _sort_key(citation): families = _family_names(citation) # Sorted under the token the entry actually prints under, not the title. head = families[0].lower() if families else ANON_AUTHOR.lower() # A surname alone is not unique. Two entries by different Chens used to # land in whatever order the dict happened to iterate, so the same database # could compile to two different bibliographies on two runs. Year, then # title, then DOI makes the order total and reproducible. return (head, str(citation.get("year") or ""), (citation.get("title") or "").lower(), citation.get("doi") or "") def _italic_md(text): return f"*{text}*" if text else "" # Styles print a press for book-like sources and a venue for articles. Crossref # reports a `publisher` for journal articles too (the imprint that owns the # journal, for example "Springer Nature"), which no style prints in a reference # list, so `publisher` is carried in every record but only rendered for the # types where a press actually belongs. _PRESS_TYPES = {"book", "book-chapter", "book-section", "book-part", "monograph", "edited-book", "reference-book", "report", "report-component", "thesis", "dissertation"} def _publisher_of(citation): """The press this record should print, or "" when the style would omit it.""" publisher = (citation.get("publisher") or "").strip() if not publisher: return "" if (citation.get("type") or "").strip().lower() not in _PRESS_TYPES: return "" if publisher.lower() == (citation.get("venue") or "").strip().lower(): return "" return publisher def _doi_url(citation): if citation.get("url"): return citation["url"] if citation.get("doi"): return f"https://doi.org/{citation['doi']}" return "" # --- in-text markers -------------------------------------------------------- def format_in_text(style, citation, number=None): if style in NUMERIC_STYLES: return f"[{number}]" families = _family_names(citation) year = str(citation.get("year") or "n.d.") if not families: # A truncated title made an in-text marker no reader and no checker # could match: "(Alpha Study of Carbon Pricing, 2023)" is not a # surname, and the bibliography entry it points at never claimed to be # one. ANON_AUTHOR is used in the marker AND at the head of the # bibliography entry, so the two agree and the gate can join them. author_part = ANON_AUTHOR elif len(families) == 1: author_part = families[0] elif len(families) == 2: sep = "&" if style == "apa" else "and" author_part = f"{families[0]} {sep} {families[1]}" else: author_part = f"{families[0]} et al." if style == "mla": return f"({author_part})" return f"({author_part}, {year})" # --- bibliography entries --------------------------------------------------- def _end_period(s): """Append a period unless the string already ends with one (avoids 'R..' when the last token is already an initial like 'R.').""" return s if s.endswith(".") else s + "." def _format_authors_apa(citation): authors = citation["authors"] if not authors: return ANON_AUTHOR names = [_initial_name(a) for a in authors] if len(names) == 1: return _end_period(names[0]) if len(names) == 2: return f"{names[0]}, & {_end_period(names[1])}" if len(names) <= 7: return ", ".join(names[:-1]) + f", & {_end_period(names[-1])}" return ", ".join(names[:6]) + f", ... & {_end_period(names[-1])}" def _format_reference_apa(citation): author_str = _format_authors_apa(citation) year = str(citation.get("year") or "n.d.") title = citation.get("title") or "" venue = citation.get("venue") or "" url = _doi_url(citation) ref = f"{author_str} ({year}). {title}." if venue: ref += f" {_italic_md(venue)}." publisher = _publisher_of(citation) if publisher: ref += f" {_end_period(publisher)}" if url: ref += f" {url}" return ref.strip() def _format_authors_mla(citation): authors = citation["authors"] if not authors: return ANON_AUTHOR if len(authors) == 1: return _end_period(_inverted_name(authors[0].get("family"), authors[0].get("given"))) first_name = _inverted_name(authors[0].get("family"), authors[0].get("given")) if len(authors) == 2: return _end_period(f"{first_name}, and {_full_name(authors[1])}") return f"{first_name}, et al." def _format_reference_mla(citation): author_str = _format_authors_mla(citation) title = citation.get("title") or "" venue = citation.get("venue") or "" year = str(citation.get("year") or "n.d.") url = _doi_url(citation) ref = f'{author_str} "{title}."' if venue: ref += f" {_italic_md(venue)}," publisher = _publisher_of(citation) if publisher: ref += f" {publisher}," # _end_period, not f"{year}.", or a record with no year renders "n.d..". ref += f" {_end_period(year)}" if url: ref += f" {url}." return ref.strip() def _format_authors_chicago(citation): authors = citation["authors"] if not authors: return ANON_AUTHOR if len(authors) == 1: return _end_period(_inverted_name(authors[0].get("family"), authors[0].get("given"))) first_name = _inverted_name(authors[0].get("family"), authors[0].get("given")) rest = [_full_name(a) for a in authors[1:]] if len(rest) == 1: return _end_period(f"{first_name}, and {rest[0]}") return _end_period(f"{first_name}, " + ", ".join(rest[:-1]) + f", and {rest[-1]}") def _format_reference_chicago(citation): author_str = _format_authors_chicago(citation) year = str(citation.get("year") or "n.d.") title = citation.get("title") or "" venue = citation.get("venue") or "" url = _doi_url(citation) # _end_period on the year, or a record with no year renders "n.d.." here. ref = f'{author_str} {_end_period(year)} "{title}."' if venue: ref += f" {_italic_md(venue)}." publisher = _publisher_of(citation) if publisher: ref += f" {_end_period(publisher)}" if url: ref += f" {url}." return ref.strip() def _format_authors_harvard(citation): authors = citation["authors"] if not authors: return ANON_AUTHOR names = [_initial_name(a) for a in authors] if len(names) == 1: return names[0] if len(names) <= 3: return ", ".join(names[:-1]) + f" and {names[-1]}" return f"{names[0]} et al." def _format_reference_harvard(citation): author_str = _format_authors_harvard(citation) year = str(citation.get("year") or "n.d.") title = citation.get("title") or "" venue = citation.get("venue") or "" url = _doi_url(citation) ref = f"{author_str} ({year}) '{title}'." if venue: ref += f" {_italic_md(venue)}." publisher = _publisher_of(citation) if publisher: ref += f" {_end_period(publisher)}" if url: ref += f" Available at: {url}." return ref.strip() def _format_authors_numeric(citation): authors = citation["authors"] if not authors: return ANON_AUTHOR names = [_initial_name(a) for a in authors] if len(names) <= 3: return ", ".join(names) return f"{names[0]}, et al." def _format_reference_ieee(citation, number): author_str = _format_authors_numeric(citation) title = citation.get("title") or "" venue = citation.get("venue") or "" year = str(citation.get("year") or "n.d.") url = _doi_url(citation) ref = f'[{number}] {author_str}, "{title},"' if venue: ref += f" {_italic_md(venue)}," publisher = _publisher_of(citation) if publisher: ref += f" {publisher}," # _end_period, not f"{year}.", or a record with no year renders "n.d..". ref += f" {_end_period(year)}" if url: ref += f" {url}" return ref.strip() def _format_reference_vancouver(citation, number): author_str = _format_authors_numeric(citation) title = citation.get("title") or "" venue = citation.get("venue") or "" year = str(citation.get("year") or "n.d.") url = _doi_url(citation) ref = f"{number}. {_end_period(author_str)} {title}." if venue: ref += f" {venue}." publisher = _publisher_of(citation) if publisher: ref += f" {_end_period(publisher)}" # _end_period, not f"{year}.", or a record with no year renders "n.d..". ref += f" {_end_period(year)}" if url: ref += f" {url}" return ref.strip() def format_reference(style, citation, number=None): if style == "apa": return _format_reference_apa(citation) if style == "mla": return _format_reference_mla(citation) if style == "chicago": return _format_reference_chicago(citation) if style == "harvard": return _format_reference_harvard(citation) if style == "ieee": return _format_reference_ieee(citation, number) if style == "vancouver": return _format_reference_vancouver(citation, number) raise ValueError(f"Unsupported style: {style}") # --------------------------------------------------------------------------- # Compile # --------------------------------------------------------------------------- class CompileError(Exception): def __init__(self, missing): self.missing = missing super().__init__( "Citation placeholder(s) not found in the database: " + ", ".join(f"{{cite_{d}}}" for d in missing) ) class UnverifiedCitationError(Exception): """A cited DOI is not in state `resolved`. The user-facing promise is that every printed DOI resolved at Crossref or DataCite. `build` writes `verified: "unknown"` for every record, so without this gate the promise was decoration. Refusing to render is what makes it survive someone checking it.""" def __init__(self, offenders): self.offenders = offenders listed = ", ".join(f"{doi} ({status})" for doi, status in offenders) super().__init__( "Refusing to render citation(s) whose DOI is not resolved at " f"Crossref or DataCite: {listed}. " "Run: python3 scripts/citations.py verify -d <database> " "and remove or replace any source that does not resolve." ) class MissingSourceError(Exception): """The draft still carries `{cite_MISSING: ...}`. That marker is a drafter admitting a claim has no source. It is the honest thing to write and it is a hard stop: compiling it away would turn an admission into a shipped unsourced claim. Every occurrence is named at once, with the drafter's own description, so one pass clears them all.""" def __init__(self, offenders): self.offenders = offenders # list of (lineno, description) listed = "; ".join( f"line {lineno}: {description}" if description else f"line {lineno}" for lineno, description in offenders ) super().__init__( f"Refusing to compile {len(offenders)} unsourced claim(s) marked " f"{{cite_MISSING: ...}} by the drafter: {listed}. " "For each one: find a source with " "python3 scripts/sources.py find \"<topic>\" --json > new.json, " "merge it into the database with " "python3 scripts/citations.py build new.json -o research/citations.json, " "and replace the marker with {cite_<doi>} -- or cut the claim. " "It cannot ship marked." ) def find_missing_source_markers(text): """Every `{cite_MISSING: ...}` as (line number, description).""" offenders = [] for lineno, line in enumerate(text.splitlines(), start=1): for match in MISSING_CITE.finditer(line): offenders.append((lineno, (match.group(1) or "").strip())) return offenders def compile_text(text, db, style): """Deterministic placeholder replacement. Raises MissingSourceError naming every claim the drafter marked unsourced, CompileError naming every placeholder whose DOI is not in the database, and UnverifiedCitationError naming every cited DOI that is not `resolved` -- never a silent drop, and never an unverified DOI printed as though it had been checked.""" style = style.lower() if style not in ALL_STYLES: raise ValueError(f"Unsupported style '{style}'. Choose from: {', '.join(sorted(ALL_STYLES))}") # First, because {cite_MISSING: ...} also matches CITE_PATTERN and would # otherwise be reported as a DOI that is not in the database. marked = find_missing_source_markers(text) if marked: raise MissingSourceError(marked) citations = db["citations"] missing = [] for match in _cite_matches(text): doi = sources._norm_doi(match.group(1)) if doi not in citations: missing.append(match.group(1)) if missing: # Report every offending placeholder, not just the first. raise CompileError(missing) # Only the citations this draft actually prints are gated. An unverified # record sitting unused in the database is not a false claim about anything. unverified = [] for doi in sorted(_cited_dois(text)): status = citations[doi].get("verified", "unknown") if status != "resolved": unverified.append((doi, status)) if unverified: raise UnverifiedCitationError(unverified) numeric = style in NUMERIC_STYLES order = [] # DOIs in first-appearance order (numeric styles) number_of = {} def replace(match): raw = match.group(1) doi = sources._norm_doi(raw) if numeric: if doi not in number_of: number_of[doi] = len(order) + 1 order.append(doi) return format_in_text(style, citations[doi], number=number_of[doi]) return format_in_text(style, citations[doi]) compiled = CITE_PATTERN.sub(replace, text) if numeric: cited_dois = order else: cited_dois = sorted( _cited_dois(text), key=lambda d: _sort_key(citations[d]), ) bib_lines = ["## References", ""] if not cited_dois: bib_lines.append("(No citations found)") elif numeric: for doi in cited_dois: bib_lines.append(format_reference(style, citations[doi], number=number_of[doi])) bib_lines.append("") else: for doi in cited_dois: bib_lines.append(format_reference(style, citations[doi])) bib_lines.append("") compiled = compiled.rstrip() + "\n\n" + "\n".join(bib_lines).rstrip() + "\n" return compiled # --------------------------------------------------------------------------- # BibTeX # --------------------------------------------------------------------------- _TYPE_TO_BIBTEX = { "journal-article": "article", "proceedings-article": "inproceedings", "book-chapter": "incollection", "monograph": "book", "book": "book", "report": "techreport", "posted-content": "misc", } def _bibtex_key(citation, used_keys): families = _family_names(citation) base_author = re.sub(r"[^a-z0-9]", "", (families[0] if families else "anon").lower()) or "anon" year = str(citation.get("year") or "nd") title_words = re.findall(r"[A-Za-z0-9]+", citation.get("title") or "") slug = title_words[0].lower() if title_words else "" base = f"{base_author}{year}{slug}" key = base suffix = ord("a") while key in used_keys: key = f"{base}{chr(suffix)}" suffix += 1 used_keys.add(key) return key def _bibtex_escape(text): return (text or "").replace("{", "\\{").replace("}", "\\}") def to_bibtex(db): used_keys = set() entries = [] for doi in sorted(db["citations"]): c = db["citations"][doi] entry_type = _TYPE_TO_BIBTEX.get(c.get("type"), "misc") key = _bibtex_key(c, used_keys) fields = [] authors = c["authors"] if authors: author_field = " and ".join( _inverted_name(a.get("family"), a.get("given")) for a in authors ) fields.append(("author", author_field)) if c.get("title"): fields.append(("title", c["title"])) if c.get("year"): fields.append(("year", str(c["year"]))) venue = c.get("venue") or "" if venue: if entry_type == "article": fields.append(("journal", venue)) elif entry_type in ("inproceedings",): fields.append(("booktitle", venue)) elif entry_type == "incollection": fields.append(("booktitle", venue)) else: fields.append(("note", venue)) if (c.get("publisher") or "").strip(): fields.append(("publisher", c["publisher"].strip())) if c.get("doi"): fields.append(("doi", c["doi"])) if c.get("url"): fields.append(("url", c["url"])) body = ",\n".join(f" {name} = {{{_bibtex_escape(value)}}}" for name, value in fields) entries.append(f"@{entry_type}{{{key},\n{body}\n}}") return "\n\n".join(entries) + ("\n" if entries else "") # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def _build_parser(): parser = argparse.ArgumentParser( prog="citations.py", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) sub = parser.add_subparsers(dest="cmd") p_build = sub.add_parser("build", help="Build/merge a citation database from sources.py find --json output.") p_build.add_argument("sources_json", help="Path to a JSON array from `sources.py find --json`") p_build.add_argument("-o", "--output", required=True, help="Path to write/merge research/citations.json") p_verify = sub.add_parser("verify", help="Re-verify every DOI in the database via Crossref/DataCite.") p_verify.add_argument("-d", "--database", required=True, help="Path to citations.json") p_compile = sub.add_parser("compile", help="Replace {cite_<doi>} placeholders and append a bibliography.") p_compile.add_argument("draft", help="Path to the draft markdown file") p_compile.add_argument("-d", "--database", required=True, help="Path to citations.json") p_compile.add_argument("--style", required=True, choices=sorted(ALL_STYLES), help="Citation style: apa, mla, chicago, harvard (author-year) " "or ieee, vancouver (numeric)") p_compile.add_argument("-o", "--output", required=True, help="Path to write the compiled markdown") p_bibtex = sub.add_parser("bibtex", help="Emit a BibTeX file for every citation in the database.") p_bibtex.add_argument("-d", "--database", required=True, help="Path to citations.json") p_bibtex.add_argument("-o", "--output", required=True, help="Path to write the .bib file") return parser def main(argv=None): parser = _build_parser() args = parser.parse_args(argv) if args.cmd == "build": try: stats = build_database(args.sources_json, args.output) except (OSError, json.JSONDecodeError, ValueError, DatabaseError) as e: print(f"Error: {e}", file=sys.stderr) return 1 print(f"Added {stats['added']}, updated {stats['updated']}, " f"total {stats['total']} citations in {args.output}") return 0 if args.cmd == "verify": if not Path(args.database).exists(): print(f"Error: database not found: {args.database}", file=sys.stderr) return 1 try: counts, failures = verify_database(args.database) except (OSError, DatabaseError) as e: print(f"Error: {e}", file=sys.stderr) return 1 print(f"resolved={counts.get('resolved', 0)} " f"absent={counts.get('absent', 0)} " f"unknown={counts.get('unknown', 0)} " f"invalid={counts.get('invalid', 0)}") if failures: print("Failing DOIs:", file=sys.stderr) for doi, status, error in failures: print(f" {doi}: {status} {error}".rstrip(), file=sys.stderr) return 1 return 0 if args.cmd == "compile": if not Path(args.database).exists(): print(f"Error: database not found: {args.database}", file=sys.stderr) return 1 if not Path(args.draft).exists(): print(f"Error: draft not found: {args.draft}", file=sys.stderr) return 1 try: db = load_database(args.database) text = Path(args.draft).read_text(encoding="utf-8") compiled = compile_text(text, db, args.style) except (MissingSourceError, CompileError, UnverifiedCitationError, DatabaseError, ValueError, OSError) as e: print(f"Error: {e}", file=sys.stderr) return 1 Path(args.output).parent.mkdir(parents=True, exist_ok=True) Path(args.output).write_text(compiled, encoding="utf-8") print(f"Compiled {args.draft} -> {args.output} ({args.style})") return 0 if args.cmd == "bibtex": if not Path(args.database).exists(): print(f"Error: database not found: {args.database}", file=sys.stderr) return 1 try: db = load_database(args.database) except (OSError, DatabaseError) as e: print(f"Error: {e}", file=sys.stderr) return 1 bib = to_bibtex(db) Path(args.output).parent.mkdir(parents=True, exist_ok=True) Path(args.output).write_text(bib, encoding="utf-8") print(f"Wrote {len(db['citations'])} entries to {args.output}") return 0 parser.print_help() return 2 if __name__ == "__main__": sys.exit(main())skills/autonomous-research/scripts/evidence.py34 KB207dcd846093upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/scripts/export.py26 KB558c18b1988bupstream ↗
#!/usr/bin/env python3 """Export a compiled markdown draft to docx, pdf, latex or html. Standard library only (shells out to pandoc/weasyprint if installed; installs nothing itself). Detects available conversion engines at runtime and fails with the exact install command for this OS when none is available, rather than a Python traceback. export.py <draft.md> --format docx -o out.docx export.py <draft.md> --format pdf -o out.pdf [--bibliography refs.bib --csl apa.csl] export.py <draft.md> --format latex -o out.tex export.py <draft.md> --format html -o out.html export.py <draft.md> --format pdf -o out.pdf --template journal [--brand NAME] export.py <draft.md> --format html -o out.html --template journal export.py --check # report what this machine can produce Journal template: `--template journal` typesets the paper as a two-column journal article (scripts/journal.py): masthead, serif title, two-column abstract, numbered sections, and the evidence figures, tables and equations that `scripts/evidence.py figures` drew, placed at the draft's {figure:...}, {table:...} and {equations:...} placeholder lines. It needs pandoc, and weasyprint for the PDF. The default template cannot place those figures, so it removes the placeholder lines and names each one on stderr. PDF strategy: pandoc with a LaTeX engine (xelatex/pdflatex) if one is installed, otherwise pandoc with --pdf-engine=weasyprint (needs the `weasyprint` Python package on PATH), otherwise fail with install commands for both pandoc and a PDF engine. DOCX typography: pandoc's own default reference document sets no body font, no body size and no page margins, so the finished paper opens in whatever the reader's Word calls Normal, which is Calibri 11 on a current install. A manuscript going to a supervisor, a committee or a journal is read as a formal document, and a formal document's body type is a decision somebody made rather than the default of the program that produced it. So the docx path patches a copy of that reference document to name a body font, a body size and page margins, and hands it to pandoc as --reference-doc. The defaults below are the ordinary academic manuscript setting; --font, --font-size and --margin-inches change them when a venue's house style asks for something else, and --plain-docx skips the whole step. The patch is applied to the Normal style and to the document defaults, which is what makes it one setting the whole document inherits rather than a value stamped onto every paragraph: a reader who changes Normal in Word then changes the whole paper, which is the behaviour anyone editing the file afterwards expects. """ import argparse import platform import re import shutil import subprocess import sys import tempfile import zipfile from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import journal # noqa: E402 FORMATS = ("docx", "pdf", "latex", "html") TEMPLATES = ("default", "journal") # The ordinary academic manuscript setting, and the one the venue can override. DEFAULT_DOCX_FONT = "Times New Roman" DEFAULT_DOCX_FONT_SIZE = 12.0 DEFAULT_DOCX_MARGIN_INCHES = 1.0 PANDOC_INSTALL = { "Darwin": "brew install pandoc", "Linux": "sudo apt-get update && sudo apt-get install -y pandoc", } LATEX_INSTALL = { "Darwin": "brew install --cask mactex-no-gui (or: brew install --cask basictex)", "Linux": "sudo apt-get install -y texlive-xetex", } WEASYPRINT_INSTALL = { "Darwin": "pip install weasyprint (also needs: brew install pango)", "Linux": "pip install weasyprint (also needs: sudo apt-get install -y libpango-1.0-0 libpangocairo-1.0-0)", } def _os_name(): return platform.system() # "Darwin", "Linux", "Windows" def _install_hint(table): os_name = _os_name() if os_name in table: return table[os_name] return " / ".join(f"{k}: {v}" for k, v in table.items()) def detect_engines(): """Return a dict describing what this machine can produce.""" pandoc = shutil.which("pandoc") xelatex = shutil.which("xelatex") pdflatex = shutil.which("pdflatex") weasyprint = shutil.which("weasyprint") pdf_engine = None if pandoc and xelatex: pdf_engine = "xelatex" elif pandoc and pdflatex: pdf_engine = "pdflatex" elif pandoc and weasyprint: pdf_engine = "weasyprint" return { "pandoc": bool(pandoc), "pandoc_path": pandoc, "xelatex": bool(xelatex), "pdflatex": bool(pdflatex), "weasyprint": bool(weasyprint), "can_docx": bool(pandoc), "can_latex": bool(pandoc), "can_html": bool(pandoc), "can_pdf": bool(pdf_engine), "pdf_engine": pdf_engine, } def _run(cmd, timeout=120): try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) except FileNotFoundError as e: return 127, "", str(e) except subprocess.TimeoutExpired as e: return 124, "", f"Timed out after {timeout}s: {e}" return result.returncode, result.stdout, result.stderr class ExportError(Exception): pass # --- docx typography -------------------------------------------------------- def _twips(inches): """Inches to twentieths of a point, the unit w:pgMar is written in.""" return int(round(inches * 1440)) def _half_points(points): """Points to half-points, the unit w:sz is written in.""" return int(round(points * 2)) def _xml_escape(value): return (value.replace("&", "&").replace("<", "<") .replace(">", ">").replace('"', """)) _RPR_DEFAULT = re.compile(r"(<w:rPrDefault>\s*<w:rPr>)(.*?)(</w:rPr>)", re.S) _NORMAL_STYLE = re.compile( r'(<w:style\b[^>]*w:styleId="Normal"[^>]*>)(.*?)(</w:style>)', re.S) _RFONTS = re.compile(r"<w:rFonts\b[^>]*/>") _SZ = re.compile(r"<w:sz\b[^>]*/>") _SZCS = re.compile(r"<w:szCs\b[^>]*/>") _SECTPR = re.compile(r"(<w:sectPr\b[^>]*>)(.*?)(</w:sectPr>)", re.S) # pandoc 3.1.x ships its reference document with an empty, self-closing # section block. Newer pandoc writes the paired form. Both must be patchable, # or margins silently never land on the older host. _SECTPR_EMPTY = re.compile(r"<w:sectPr\b([^>]*?)\s*/>") _PGMAR = re.compile(r"<w:pgMar\b[^>]*/>") def _run_properties(font, size_pt): """The <w:rFonts>/<w:sz>/<w:szCs> triple naming a body font and size.""" name = _xml_escape(font) half = _half_points(size_pt) return (f'<w:rFonts w:ascii="{name}" w:hAnsi="{name}" w:cs="{name}" ' f'w:eastAsia="{name}"/>' f'<w:sz w:val="{half}"/><w:szCs w:val="{half}"/>') def _apply_run_properties(inner, font, size_pt): """`inner`, an <w:rPr> body, with the font and size elements set. Each of the three elements is replaced where it already exists and prepended where it does not, because pandoc's shipped reference document carries an rFonts pointing at the theme (which is where the Calibri comes from) and no rFonts at all on the Normal style itself. """ name = _xml_escape(font) half = _half_points(size_pt) replacements = [ (_RFONTS, f'<w:rFonts w:ascii="{name}" w:hAnsi="{name}" w:cs="{name}" ' f'w:eastAsia="{name}"/>'), (_SZ, f'<w:sz w:val="{half}"/>'), (_SZCS, f'<w:szCs w:val="{half}"/>'), ] missing = "" for pattern, element in replacements: if pattern.search(inner): inner = pattern.sub(element, inner, count=1) else: missing += element return missing + inner def patch_styles_xml(xml, font, size_pt): """word/styles.xml with the body font and size set on Normal and on the defaults. Both places, because they answer two different questions. The document defaults are what every style without its own font inherits, so patching them is what actually changes the rendered body. The Normal style is what a reader sees when they open the style pane and what they edit to restyle the paper, so a Normal that still says "theme font" while the page renders in Times is a document that lies about itself. """ def default_sub(m): return m.group(1) + _apply_run_properties(m.group(2), font, size_pt) + m.group(3) def normal_sub(m): head, inner, tail = m.groups() rpr = re.search(r"(<w:rPr>)(.*?)(</w:rPr>)", inner, re.S) if rpr: inner = (inner[:rpr.start()] + rpr.group(1) + _apply_run_properties(rpr.group(2), font, size_pt) + rpr.group(3) + inner[rpr.end():]) else: inner = inner + f"<w:rPr>{_run_properties(font, size_pt)}</w:rPr>" return head + inner + tail patched, defaults_done = _RPR_DEFAULT.subn(default_sub, xml, count=1) patched, normal_done = _NORMAL_STYLE.subn(normal_sub, patched, count=1) if not (defaults_done or normal_done): raise ExportError( "pandoc's reference document has neither a document-defaults run " "block nor a Normal style to set the body font on") return patched def patch_document_xml(xml, margin_inches): """word/document.xml with page margins set on the section properties. pandoc's reference document ships a sectPr with no w:pgMar, which leaves the margins to whatever the reading application defaults to. """ twips = _twips(margin_inches) element = (f'<w:pgMar w:top="{twips}" w:right="{twips}" w:bottom="{twips}" ' f'w:left="{twips}" w:header="720" w:footer="720" w:gutter="0"/>') def sub(m): head, inner, tail = m.groups() if _PGMAR.search(inner): inner = _PGMAR.sub(element, inner, count=1) else: # w:pgMar follows w:footnotePr, w:endnotePr, w:type and w:pgSz in # the schema's element order, and precedes nothing this document # carries, so the end of the block is the correct position. inner = inner + element return head + inner + tail patched, done = _SECTPR.subn(sub, xml, count=1) if not done: patched, done = _SECTPR_EMPTY.subn( lambda m: f"<w:sectPr{m.group(1)}>{element}</w:sectPr>", xml, count=1) if not done: raise ExportError("pandoc's reference document has no section " "properties to set page margins on") return patched def build_reference_docx(dest, font, size_pt, margin_inches): """pandoc's own reference.docx, copied out and given a body font and margins.""" result = subprocess.run( ["pandoc", "--print-default-data-file", "reference.docx"], capture_output=True, timeout=60) if result.returncode != 0 or not result.stdout: raise ExportError( "pandoc could not produce its default reference document " f"(exit {result.returncode}): " f"{result.stderr.decode('utf-8', 'replace').strip()}") source = Path(dest).with_suffix(".source.docx") source.write_bytes(result.stdout) patchers = { "word/styles.xml": lambda xml: patch_styles_xml(xml, font, size_pt), "word/document.xml": lambda xml: patch_document_xml(xml, margin_inches), } failures = [] with zipfile.ZipFile(source) as src, \ zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as out: for item in src.infolist(): data = src.read(item.filename) patch = patchers.get(item.filename) if patch is not None: # Each part is patched independently. A margin patch that # fails must not throw away a font patch that succeeded, # which is how a host with an older pandoc shipped Calibri. try: data = patch(data.decode("utf-8")).encode("utf-8") except ExportError as e: failures.append(f"{item.filename}: {e}") out.writestr(item, data) source.unlink() if len(failures) == len(patchers): raise ExportError("; ".join(failures)) for failure in failures: print(f"Warning: docx typography partly applied, {failure}", file=sys.stderr) return dest def _reference_doc_args(stack, typography): """['--reference-doc=...'] for a docx export, or [] when it is turned off. A failure to build the reference document is reported and then stepped over: a paper that exports in the reader's default font is worse than one that exports in Times, and better than one that does not export at all. """ if typography is None: return [] font, size_pt, margin_inches = typography path = Path(stack) / "reference.docx" try: build_reference_docx(path, font, size_pt, margin_inches) except (ExportError, OSError, zipfile.BadZipFile, subprocess.SubprocessError) as e: print(f"Warning: falling back to pandoc's default docx typography " f"({font} {size_pt:g}pt was not applied): {e}", file=sys.stderr) return [] return [f"--reference-doc={path}"] def export_docx(src, out, engines, typography=None): if not engines["pandoc"]: raise ExportError(_missing_pandoc_message()) with tempfile.TemporaryDirectory() as stack: cmd = (["pandoc", str(src)] + _reference_doc_args(stack, typography) + ["-o", str(out)]) code, _, err = _run(cmd) if code != 0: raise ExportError(f"pandoc failed (exit {code}):\n{err.strip()}") def export_latex(src, out, engines): if not engines["pandoc"]: raise ExportError(_missing_pandoc_message()) code, _, err = _run(["pandoc", str(src), "-o", str(out)]) if code != 0: raise ExportError(f"pandoc failed (exit {code}):\n{err.strip()}") def _document_title(src): """The paper's own title: its first level-1 heading, outside code fences. `pandoc -s` with no title metadata falls back to the source filename, so a finished paper exported to HTML arrived titled "final" in the browser tab and in every search result and link preview that reads that element. The title is the one piece of metadata the document already carries, and nothing else in the pipeline records it. """ try: with open(src, encoding="utf-8") as handle: in_fence = False for line in handle: if line.lstrip().startswith(("```", "~~~")): in_fence = not in_fence continue if in_fence: continue stripped = line.strip() if stripped.startswith("# "): return stripped[2:].strip() or None except OSError: return None return None def _title_metadata(src): title = _document_title(src) return ["--metadata", f"title={title}"] if title else [] def export_html(src, out, engines): if not engines["pandoc"]: raise ExportError(_missing_pandoc_message()) cmd = ["pandoc", str(src), "-s"] + _title_metadata(src) + ["-o", str(out)] code, _, err = _run(cmd) if code != 0: raise ExportError(f"pandoc failed (exit {code}):\n{err.strip()}") def export_pdf(src, out, engines): if not engines["pandoc"]: raise ExportError(_missing_pandoc_message()) if not engines["pdf_engine"]: raise ExportError( "No PDF engine available. pandoc is installed but needs either a " "LaTeX engine or weasyprint to produce a PDF.\n" f"Install a LaTeX engine:\n {_install_hint(LATEX_INSTALL)}\n" f"or install weasyprint:\n {_install_hint(WEASYPRINT_INSTALL)}" ) cmd = ["pandoc", str(src), f"--pdf-engine={engines['pdf_engine']}", "-o", str(out)] code, _, err = _run(cmd, timeout=180) if code != 0: raise ExportError(f"pandoc --pdf-engine={engines['pdf_engine']} failed (exit {code}):\n{err.strip()}") def _missing_pandoc_message(): return ( "pandoc is not installed. It is required for every export format.\n" f"Install it with:\n {_install_hint(PANDOC_INSTALL)}" ) EXPORTERS = { "docx": export_docx, "latex": export_latex, "html": export_html, "pdf": export_pdf, } def export_journal(src, out, fmt, engines, options=None): """The journal template: HTML from scripts/journal.py, PDF via weasyprint.""" options = dict(options or {}) if fmt not in ("pdf", "html"): raise ExportError("--template journal produces pdf or html only") if not engines["pandoc"]: raise ExportError(_missing_pandoc_message()) base = Path(src).resolve().parent options.setdefault("figures_dir", base / "research" / "figures") options.setdefault("research_dir", base / "research") try: page, warnings = journal.build_html(src, **options) except journal.JournalError as e: raise ExportError(str(e)) for w in warnings: print(f"Warning: {w}", file=sys.stderr) if fmt == "html": Path(out).write_text(page, encoding="utf-8") return if not engines["weasyprint"]: raise ExportError("The journal PDF needs weasyprint.\n" f"Install it with:\n {_install_hint(WEASYPRINT_INSTALL)}") with tempfile.TemporaryDirectory() as tmp: page_path = Path(tmp) / "journal.html" page_path.write_text(page, encoding="utf-8") code, _, err = _run(["weasyprint", str(page_path), str(out)], timeout=300) if code != 0: raise ExportError(f"weasyprint failed (exit {code}):\n{err.strip()}") def export(src_path, out_path, fmt, bibliography=None, csl=None, typography=None, template="default", journal_options=None): engines = detect_engines() if fmt not in EXPORTERS: raise ExportError(f"Unsupported format '{fmt}'. Choose from: {', '.join(FORMATS)}") if template == "journal": if bibliography or csl: raise ExportError("--template journal typesets the compiled references; " "drop --bibliography/--csl") export_journal(src_path, out_path, fmt, engines, journal_options) return text = Path(src_path).read_text(encoding="utf-8") stripped, removed = journal.strip_placeholders(text) if removed: for line in removed: print(f"Warning: {line} dropped; only --template journal can place it", file=sys.stderr) with tempfile.TemporaryDirectory() as tmp: clean = Path(tmp) / Path(src_path).name clean.write_text(stripped, encoding="utf-8") export(clean, out_path, fmt, bibliography, csl, typography) return extra = [] if bibliography: if not Path(bibliography).exists(): raise ExportError(f"Bibliography file not found: {bibliography}") extra += [f"--bibliography={bibliography}", "--citeproc"] if csl: if not Path(csl).exists(): raise ExportError(f"CSL style file not found: {csl}") extra += [f"--csl={csl}"] if extra: _export_with_extra_args(src_path, out_path, fmt, engines, extra, typography) elif fmt == "docx": export_docx(src_path, out_path, engines, typography) else: EXPORTERS[fmt](src_path, out_path, engines) def _export_with_extra_args(src, out, fmt, engines, extra, typography=None): if not engines["pandoc"]: raise ExportError(_missing_pandoc_message()) cmd = ["pandoc", str(src)] + extra if fmt == "docx": with tempfile.TemporaryDirectory() as stack: cmd += _reference_doc_args(stack, typography) code, _, err = _run(cmd + ["-o", str(out)], timeout=180) if code != 0: raise ExportError(f"pandoc failed (exit {code}):\n{err.strip()}") return if fmt == "pdf": if not engines["pdf_engine"]: raise ExportError( "No PDF engine available. pandoc is installed but needs either a " "LaTeX engine or weasyprint to produce a PDF.\n" f"Install a LaTeX engine:\n {_install_hint(LATEX_INSTALL)}\n" f"or install weasyprint:\n {_install_hint(WEASYPRINT_INSTALL)}" ) cmd.append(f"--pdf-engine={engines['pdf_engine']}") elif fmt == "html": cmd.append("-s") cmd += _title_metadata(src) cmd += ["-o", str(out)] code, _, err = _run(cmd, timeout=180) if code != 0: raise ExportError(f"pandoc failed (exit {code}):\n{err.strip()}") def _print_check(engines): print(f"OS: {_os_name()}") print(f"pandoc: {'yes (' + engines['pandoc_path'] + ')' if engines['pandoc'] else 'NOT FOUND'}") print(f"xelatex: {'yes' if engines['xelatex'] else 'no'}") print(f"pdflatex: {'yes' if engines['pdflatex'] else 'no'}") print(f"weasyprint: {'yes' if engines['weasyprint'] else 'no'}") print() print(f"docx: {'OK' if engines['can_docx'] else 'unavailable, ' + PANDOC_INSTALL.get(_os_name(), _install_hint(PANDOC_INSTALL))}") print(f"latex: {'OK' if engines['can_latex'] else 'unavailable, ' + PANDOC_INSTALL.get(_os_name(), _install_hint(PANDOC_INSTALL))}") print(f"html: {'OK' if engines['can_html'] else 'unavailable, ' + PANDOC_INSTALL.get(_os_name(), _install_hint(PANDOC_INSTALL))}") if engines["can_pdf"]: print(f"pdf: OK (via pandoc --pdf-engine={engines['pdf_engine']})") else: print("pdf: unavailable") if not engines["pandoc"]: print(f" install pandoc: {_install_hint(PANDOC_INSTALL)}") else: print(f" install a LaTeX engine: {_install_hint(LATEX_INSTALL)}") print(f" or install weasyprint: {_install_hint(WEASYPRINT_INSTALL)}") def _build_parser(): parser = argparse.ArgumentParser( prog="export.py", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("draft", nargs="?", help="Path to the compiled draft markdown file") parser.add_argument("--format", choices=FORMATS, help="Output format") parser.add_argument("-o", "--output", help="Output file path") parser.add_argument("--bibliography", help="BibTeX file for pandoc --citeproc rendering") parser.add_argument("--csl", help="CSL style file for pandoc citation rendering") parser.add_argument("--font", default=DEFAULT_DOCX_FONT, help="docx body font, set on the Normal style rather than stamped on " f"every paragraph (default: {DEFAULT_DOCX_FONT})") parser.add_argument("--font-size", type=float, default=DEFAULT_DOCX_FONT_SIZE, metavar="PT", help=f"docx body size in points (default: {DEFAULT_DOCX_FONT_SIZE:g})") parser.add_argument("--margin-inches", type=float, default=DEFAULT_DOCX_MARGIN_INCHES, metavar="N", help="docx page margin on all four sides, in inches " f"(default: {DEFAULT_DOCX_MARGIN_INCHES:g})") parser.add_argument("--plain-docx", action="store_true", help="Skip the typography step and let pandoc's own reference document " "decide the docx body font, size and margins. That default sets no " "body font at all, so the paper renders in whatever the reader's " "Word calls Normal.") parser.add_argument("--template", choices=TEMPLATES, default="default", help="journal: two-column journal article with the evidence figures " "(pdf or html only)") parser.add_argument("--figures", metavar="DIR", help="journal: directory evidence.py figures wrote " "(default: research/figures next to the draft)") parser.add_argument("--research", metavar="DIR", help="journal: research/ directory for the at-a-glance numbers " "(default: research next to the draft)") parser.add_argument("--brand", default="OpenDraft", help="journal: masthead name (default: OpenDraft)") parser.add_argument("--kind", help='journal: article type line, e.g. "Narrative review"') parser.add_argument("--byline", help="journal: byline under the title") parser.add_argument("--masthead-note", help="journal: right-hand masthead text") parser.add_argument("--lang", default="en", help="journal: document language, for hyphenation") parser.add_argument("--no-glance", action="store_true", help="journal: leave out the at-a-glance number strip") parser.add_argument("--check", action="store_true", help="Report what this machine can produce and exit (no conversion)") return parser def main(argv=None): parser = _build_parser() args = parser.parse_args(argv) if args.check: _print_check(detect_engines()) return 0 if not args.draft or not args.format or not args.output: parser.print_help() return 2 draft_path = Path(args.draft) if not draft_path.exists(): print(f"Error: draft not found: {args.draft}", file=sys.stderr) return 1 typography = (None if args.plain_docx else (args.font, args.font_size, args.margin_inches)) try: options = {"brand": args.brand, "kind": args.kind, "byline": args.byline, "masthead_note": args.masthead_note, "lang": args.lang, "glance": not args.no_glance} if args.figures: options["figures_dir"] = Path(args.figures) if args.research: options["research_dir"] = Path(args.research) export(draft_path, Path(args.output), args.format, bibliography=args.bibliography, csl=args.csl, typography=typography, template=args.template, journal_options=options) except ExportError as e: print(f"Error: {e}", file=sys.stderr) return 1 print(f"Exported {args.draft} -> {args.output} ({args.format})") return 0 if __name__ == "__main__": sys.exit(main())skills/autonomous-research/scripts/integrity.py62 KB4fb0e127f94fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/scripts/journal.py18 KBbde3312cf5bbupstream ↗
#!/usr/bin/env python3 """The journal template: a compiled draft typeset as a two-column journal article. Standard library only; shells out to pandoc for markdown and to weasyprint for the PDF, exactly as export.py does. Called by `export.py --template journal`, normally run through export.py. Run directly it writes the HTML page only: journal.py final.md -o page.html [--figures research/figures] [--research research] What it adds over pandoc's default page: - a masthead with a brand line (default "OpenDraft") and nothing invented: no journal name, volume, issue, received date or affiliation the draft does not state; - a serif title with the subtitle split off at the first ": "; - the abstract in two columns, and an optional at-a-glance strip whose every number is read from research/index.json and research/sources.json; - numbered sections, a two-column body, merged adjacent author-year citations; - the evidence figures, tables and equations, placed where the draft has a placeholder line for them: {figure:evidence-profile} {figure:evidence-map} {figure:source-flow} {table:studies} {table:ledger} {equations:evidence-index} A placeholder whose file `evidence.py figures` did not produce is an error, never a silent gap. A `{figure:evidence-profile}` placed before the first section heading becomes the page-one hero figure under the abstract. """ import argparse import html import json import re import subprocess import sys from pathlib import Path HERE = Path(__file__).resolve().parent ASSETS = HERE.parent / "assets" FONT_DIR = ASSETS / "fonts" EQUATION_FILES = ("equation-1.svg", "equation-2.svg", "equation-3.svg") PLACEHOLDER = re.compile(r"^\{(figure|table|equations):([a-z0-9-]+)\}\s*$") SLOTS = { ("figure", "evidence-profile"): ("evidence-profile.svg", "evidence-profile", "figure"), ("figure", "evidence-map"): ("evidence-map.svg", "evidence-map", "figure"), ("figure", "source-flow"): ("source-flow.svg", "source-flow", "figure"), ("table", "studies"): ("table-studies.html", "studies", "table"), ("table", "ledger"): ("table-ledger.html", "ledger", "table"), ("equations", "evidence-index"): (None, None, "equations"), } ABSTRACT_NAMES = ("abstract", "zusammenfassung", "resumen", "résumé", "resume", "summary") REFERENCE_NAMES = ("references", "bibliography", "literaturverzeichnis", "literatur", "referencias", "bibliografía", "références", "bibliographie") ADJACENT_CITES = re.compile(r"(\d{4}[a-z]?|n\.d\.)\) \((?=[A-ZÀ-ÖØ-Ý])") FONTS = [ ("STIX Two Text", "STIX-400.ttf", 400, "normal"), ("STIX Two Text", "STIX-600.ttf", 600, "normal"), ("STIX Two Text", "STIX-700.ttf", 700, "normal"), ("STIX Two Text", "STIXi-400.ttf", 400, "italic"), ("STIX Two Text", "STIXi-600.ttf", 600, "italic"), ("STIXGeneral", "STIX-400.ttf", 400, "normal"), ("STIXGeneral", "STIXi-400.ttf", 400, "italic"), ("Source Sans 3", "SS-400.ttf", 400, "normal"), ("Source Sans 3", "SS-600.ttf", 600, "normal"), ("Source Sans 3", "SS-700.ttf", 700, "normal"), ("Source Sans 3", "SSi-400.ttf", 400, "italic"), ] class JournalError(Exception): pass def font_faces(font_dir=FONT_DIR): """@font-face rules for every bundled font file that is present, and the list of files that are not. A missing file degrades to the fallback stack named in the CSS; it is reported, not fatal.""" rules, missing = [], [] for family, name, weight, style in FONTS: path = Path(font_dir) / name if path.exists(): rules.append(f'@font-face{{font-family:"{family}";src:url("{path.as_uri()}");' f"font-weight:{weight};font-style:{style}}}") elif name not in missing: missing.append(name) return "\n".join(rules), missing CSS = """ @page{size:A4;margin:17mm 16mm 18mm; @top-left{content:string(runhead);font:italic 8pt var(--serif);color:#59626b} @top-right{content:string(brand);font:600 7.5pt var(--sans);color:#59626b} @bottom-center{content:counter(page);font:7.5pt var(--sans);color:#59626b}} @page:first{@top-left{content:none}@top-right{content:none}} :root{--serif:"STIX Two Text",STIXGeneral,Charter,Georgia,"Times New Roman",serif; --sans:"Source Sans 3","Source Sans Pro","Helvetica Neue",Arial,sans-serif;--accent:#1d6b60} html{font-family:var(--serif);font-size:9.3pt;line-height:1.38;color:#181c20;hyphens:auto; font-variant-numeric:lining-nums} body{margin:0} .mast{display:flex;justify-content:space-between;align-items:baseline; border-bottom:.6pt solid #181c20;padding-bottom:3pt} .mast .brand{font:600 12.5pt var(--serif);letter-spacing:.01em;string-set:brand content()} .mast .meta{font:7.6pt var(--sans);color:#59626b} .kind{font:600 8pt var(--sans);color:var(--accent);margin:14pt 0 5pt} .spacer{height:14pt} h1{string-set:runhead content();font:600 22pt/1.1 var(--serif);letter-spacing:-.005em;margin:0 0 5pt;color:#101316} .subtitle{font:italic 13pt/1.25 var(--serif);color:#3d464f;margin:0 0 10pt} .byline{font:8.3pt/1.4 var(--sans);color:#3d464f;margin:0 0 12pt} .abstract{border-top:.6pt solid #181c20;border-bottom:.6pt solid #b9c0c6;padding:8pt 0 6pt; column-count:2;column-gap:6.5mm} .abstract .ab-h{column-span:all;font:700 8.4pt var(--sans);margin:0 0 4pt} .abstract p{font-size:9.2pt;line-height:1.38;text-align:justify;margin:0 0 5pt} .abstract strong{font:600 8.4pt var(--sans);color:var(--accent);letter-spacing:.01em} .glance{display:flex;border-bottom:.6pt solid #181c20;margin:0 0 12pt} .glance div{flex:1;padding:7pt 10pt 7pt 0;margin-right:10pt;border-right:.4pt solid #cfd5da} .glance div:last-child{border-right:0;margin-right:0} .glance b{display:block;font:600 19pt/1 var(--serif);color:var(--accent);margin-bottom:3pt} .glance span{display:block;font:7.4pt/1.3 var(--sans);color:#3d464f} .cols{column-count:2;column-gap:6.5mm} h2{font:700 9.6pt var(--sans);margin:11pt 0 3pt;break-after:avoid;color:#101316} h2 .num{color:var(--accent);margin-right:5pt} h3{font:italic 600 9.4pt var(--serif);margin:8pt 0 2pt;break-after:avoid} p{margin:0;text-align:justify;orphans:2;widows:2} .cols p+p{text-indent:1.1em} h2+p,h3+p,figure+p,.eqs+p{text-indent:0} a{color:var(--accent);text-decoration:none} figure{margin:9pt 0 11pt;break-inside:avoid} figure.wide{column-span:all} figure.tbl{break-inside:auto;column-span:none} figure.hero{margin:12pt 0 4pt} figure svg{width:100%;height:auto} figure.map svg{width:78%;margin:0 11%} figcaption{font:7.7pt/1.35 var(--sans);color:#2f373f;margin-top:5pt;text-align:left} figcaption b{font-weight:700;color:#101316} .tcap{margin:0 0 5pt} table{width:100%;border-collapse:collapse;font:7.5pt/1.3 var(--sans);font-variant-numeric:tabular-nums} thead th{text-align:left;border-top:.8pt solid #181c20;border-bottom:.5pt solid #181c20; padding:3pt 5pt 3pt 0;font-weight:600} tbody td{padding:2.4pt 5pt 2.4pt 0;border-bottom:.3pt solid #e1e5e8;vertical-align:top} tbody tr:last-child td{border-bottom:.8pt solid #181c20} .r{text-align:right}.c{text-align:center} .dp{color:#1d6b60}.dm{color:#c9851c}.dz{color:#8e98a3}.dn{color:#b83b35} table.ledger{font-size:7.1pt} table.ledger td.q{font-family:var(--serif);font-style:italic;font-size:8pt;color:#181c20;width:52%} table.ledger td.doi{color:#59626b;font-size:6.6pt;word-break:break-all;width:17%} .eqs{margin:7pt 0 8pt} .eq{display:flex;align-items:center;justify-content:space-between;margin:3pt 0} .eq svg{height:auto;max-width:88%} .eq span{font:8pt var(--sans);color:#59626b} h2.refs{margin-top:12pt} div.refs p{font:7.3pt/1.32 var(--sans);text-align:left;padding-left:9pt;text-indent:-9pt; margin-bottom:2.6pt;color:#2f373f} div.refs p+p{text-indent:-9pt} """ def _heading(line, level): m = re.match(r"^(#{1,6})\s+(.*\S)\s*$", line) return m.group(2).strip() if m and len(m.group(1)) == level else None def split_draft(text): """(title, abstract_md, body_md, refs_md) from a compiled draft.""" lines = text.splitlines() title = None i = 0 while i < len(lines) and not lines[i].strip(): i += 1 if i < len(lines) and _heading(lines[i], 1): title = _heading(lines[i], 1) lines = lines[i + 1:] abstract, body, refs, lead = [], [], [], [] target = body for line in lines: h2 = _heading(line, 2) or _heading(line, 1) if h2: name = h2.lower().strip(" :") if name in ABSTRACT_NAMES and not abstract: target = abstract continue if name in REFERENCE_NAMES: target = refs continue if target is abstract: target = body if target is abstract and PLACEHOLDER.match(line.strip()): # A figure placed after the abstract belongs to the page, not to # the abstract text: it opens the body, where it becomes the hero. lead.append(line) continue target.append(line) body = lead + ([""] if lead else []) + body return title, "\n".join(abstract).strip(), "\n".join(body).strip(), "\n".join(refs).strip() def _svg_inline(path): text = Path(path).read_text(encoding="utf-8") text = re.sub(r"<\?xml[^>]*\?>", "", text) text = re.sub(r"<!DOCTYPE[^>]*>", "", text, flags=re.S) text = re.sub(r"<metadata>.*?</metadata>", "", text, flags=re.S) return text.strip() def _pandoc(md): try: r = subprocess.run(["pandoc", "-f", "markdown", "-t", "html5", "--wrap=none"], input=md, capture_output=True, text=True, timeout=120) except FileNotFoundError: raise JournalError("pandoc is not installed; the journal template needs it") if r.returncode != 0: raise JournalError(f"pandoc failed (exit {r.returncode}): {r.stderr.strip()}") return r.stdout def find_placeholders(body_md): """[(line_index, kind, name)]; unknown names are an error, not a skip.""" found, bad = [], [] for i, line in enumerate(body_md.splitlines()): m = PLACEHOLDER.match(line.strip()) if not m: if re.match(r"^\s*\{(figure|table|equations):", line): bad.append(line.strip()) continue if (m.group(1), m.group(2)) not in SLOTS: bad.append(line.strip()) continue found.append((i, m.group(1), m.group(2))) if bad: known = ", ".join("{%s:%s}" % k for k in SLOTS) raise JournalError("unknown placeholder line(s): " + "; ".join(bad) + f". Known: {known}") return found def _glance(research): if not research: return "" research = Path(research) index_path = research / "index.json" if not index_path.exists(): return "" index = json.loads(index_path.read_text(encoding="utf-8")) items = [(index.get("studies"), "studies with outcome data"), (index.get("reports"), "outcome reports, each quoted verbatim")] tier3 = sum(v.get("k_tier3", 0) for v in index.get("outcomes", {}).values()) items.append((tier3, "reports from randomized or controlled designs")) src = research / "sources.json" if src.exists(): records = json.loads(src.read_text(encoding="utf-8")) items.append((sum(1 for r in records if r.get("doi")), "sources with a resolved DOI")) cells = "".join(f"<div><b>{n:,}</b><span>{html.escape(t)}</span></div>" for n, t in items if isinstance(n, int)) return f"<div class=glance>{cells}</div>" if cells else "" def build_html(draft_path, figures_dir=None, research_dir=None, brand="OpenDraft", kind=None, byline=None, masthead_note=None, lang="en", glance=True, font_dir=FONT_DIR): """(html, warnings) for the journal page.""" text = Path(draft_path).read_text(encoding="utf-8") title, abstract_md, body_md, refs_md = split_draft(text) warnings = [] figures_dir = Path(figures_dir) if figures_dir else None captions = {} if figures_dir and (figures_dir / "captions.json").exists(): captions = json.loads((figures_dir / "captions.json").read_text(encoding="utf-8")) placeholders = find_placeholders(body_md) lines = body_md.splitlines() first_h2 = next((i for i, l in enumerate(lines) if _heading(l, 2)), len(lines)) hero_html = "" rendered = {} counters = {"figure": 0, "table": 0} def render(kind_, name, hero=False): fname, cap_key, role = SLOTS[(kind_, name)] if role == "equations": eqs = [] for k, f in enumerate(EQUATION_FILES, 1): p = ASSETS / f if not p.exists(): raise JournalError(f"{{equations:{name}}} needs {p}, which is missing from the bundle") eqs.append(f"<div class=eq>{_svg_inline(p)}<span>({k})</span></div>") return f"<div class=eqs>{''.join(eqs)}</div>" if not figures_dir or not (figures_dir / fname).exists(): raise JournalError( f"{{{kind_}:{name}}} is in the draft but {(figures_dir or Path('research/figures')) / fname} " "does not exist. Run: python3 scripts/evidence.py figures research -o research/figures") counters[role] += 1 label, caption = captions.get(cap_key, ("", "")) head = f"<b>{'Fig.' if role == 'figure' else 'Table'} {counters[role]} | {html.escape(label)}</b> " cap = f"<figcaption{' class=tcap' if role == 'table' else ''}>{head}{caption}</figcaption>" content = (figures_dir / fname).read_text(encoding="utf-8") if role == "table": return f"</main><figure class=\"wide tbl\">{cap}{content}</figure><main class=cols>" cls = "hero" if hero else ("col" if name == "source-flow" else "wide") if name == "evidence-map": cls += " map" return f"<figure class=\"{cls}\">{_svg_inline(figures_dir / fname)}{cap}</figure>" for idx, kind_, name in placeholders: if kind_ == "figure" and name == "evidence-profile" and idx < first_h2 and not hero_html: hero_html = render(kind_, name, hero=True) lines[idx] = "" else: token = f"JOURNALSLOT{len(rendered)}" rendered[token] = (kind_, name) lines[idx] = f"\n{token}\n" body_md = ADJACENT_CITES.sub(r"\1; ", "\n".join(lines)) body = _pandoc(body_md) for token, (kind_, name) in rendered.items(): block = render(kind_, name) if f"<p>{token}</p>" not in body: raise JournalError(f"placeholder {{{kind_}:{name}}} did not survive pandoc as its own paragraph") body = body.replace(f"<p>{token}</p>", block) n = [0] def number(m): n[0] += 1 return f"<h2><span class=num>{n[0]}</span>{m.group(2)}</h2>" body = re.sub(r"<h2( id=\"[^\"]*\")?>(.*?)</h2>", number, body) refs_html = "" if refs_md: refs_html = (f"<h2 class=refs><span class=num>{n[0] + 1}</span>References</h2>" f"<div class=refs>{_pandoc(refs_md)}</div>") abstract_html = "" if abstract_md: abstract_html = f"<section class=abstract><p class=ab-h>Abstract</p>{_pandoc(abstract_md)}</section>" main_t, sub_t = (title or "").split(": ", 1) if title and ": " in title else (title or "", "") faces, missing = font_faces(font_dir) if missing: warnings.append("bundled fonts missing, falling back to system fonts: " + ", ".join(missing)) meta = f"<span class=meta>{html.escape(masthead_note)}</span>" if masthead_note else "<span class=meta></span>" parts = [ f"<!doctype html><html lang=\"{html.escape(lang)}\"><head><meta charset=utf-8>", f"<title>{html.escape(title or 'Draft')}</title><style>{faces}\n{CSS}</style></head><body>", f"<header class=mast><span class=brand>{html.escape(brand)}</span>{meta}</header>", f"<p class=kind>{html.escape(kind)}</p>" if kind else "<div class=spacer></div>", f"<h1>{html.escape(main_t)}</h1>" if main_t else "", f"<p class=subtitle>{html.escape(sub_t)}</p>" if sub_t else "", f"<p class=byline>{html.escape(byline)}</p>" if byline else "", abstract_html, _glance(research_dir) if glance else "", hero_html, f"<main class=cols>{body}{refs_html}</main></body></html>", ] return "\n".join(p for p in parts if p), warnings def strip_placeholders(text): """(text, [placeholder]) for the non-journal exports, which cannot place the figures: the lines are removed and named, never passed through as literal braces in somebody's docx.""" kept, removed = [], [] for line in text.splitlines(): if PLACEHOLDER.match(line.strip()): removed.append(line.strip()) else: kept.append(line) return "\n".join(kept) + ("\n" if text.endswith("\n") else ""), removed def _build_parser(): p = argparse.ArgumentParser(prog="journal.py", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("draft", help="The compiled draft, final.md") p.add_argument("-o", "--output", required=True, help="HTML file to write") p.add_argument("--figures", help="Directory evidence.py figures wrote") p.add_argument("--research", help="research/ directory for the at-a-glance numbers") p.add_argument("--brand", default="OpenDraft", help="Masthead name") return p def main(argv=None): args = _build_parser().parse_args(argv) try: page, warnings = build_html(args.draft, figures_dir=args.figures, research_dir=args.research, brand=args.brand) except (JournalError, OSError) as e: print(f"Error: {e}", file=sys.stderr) return 1 for w in warnings: print(f"Warning: {w}", file=sys.stderr) Path(args.output).write_text(page, encoding="utf-8") print(f"Wrote {args.output}") return 0 if __name__ == "__main__": sys.exit(main())skills/autonomous-research/scripts/sources.py19 KB607fa3727a34upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/references/citation-styles.md19 KB7bf929a6b407upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/references/paper-types.md14 KBb6eeaa9c9ae1upstream ↗
# Paper types and length This pipeline varies a paper along two independent axes: **paper type**, which determines section order and what each section has to argue, and **scale**, which determines how long the paper and its source list should be. They combine freely: a literature review can be short or thesis-length, exactly as an empirical study can. Read this file at stage 5, alongside `agents/05-architect.md`, and again at stage 6 when the word basis and venue format are settled. Cross-references in this file name a file and a section heading, never a line number. Line numbers in a prompt bundle go stale the first time anyone edits the file above them, and a stale pointer into a neighbouring agent's instructions is worse than no pointer, because it reads as precise. The one exception is the historical note at the end, which cites line numbers in a different project's source repository; those are a record of where something was, not an instruction to go and read it. ## Paper type: section order Four structural templates, named in `agents/05-architect.md` under "Paper types": | Paper type | Section order | |---|---| | Literature review | Introduction, methodology, themes, discussion, conclusion | | Empirical study (IMRaD) | Introduction, methods, results, discussion | | Theoretical paper | Introduction, background, framework, implications, conclusion | | Mixed methods | Introduction, literature review, methods, results, discussion, conclusion | None of these four carries its own separate word-count table. The word budgets below apply across all four; what changes between them is section naming and which sections exist at all (an empirical study has no separate "literature review" heading, a theoretical paper has no "results"). ## Review type: three names, two supported Within "literature review," this pipeline distinguishes three review types, stated explicitly in `agents/05-architect.md` under "Review type: say which one this is": - **Narrative review.** Curated exploration of the literature. The default. - **Scoping review.** Systematic mapping without formal quality assessment. Supported. - **Systematic review.** Not supported. It requires a PRISMA protocol with formal screening and quality scoring, and this pipeline has no mechanism for that. If a systematic review is requested, say so plainly, offer "comprehensive literature review" or "scoping review" as the alternative, and record in `outline.md` that the approach taken is narrative. In the methodology, write "search strategy" and "source selection," never "systematic search protocol" or "PRISMA screening": those words promise a rigor this pipeline cannot deliver. The prohibition is not confined to the methodology section, and the places it is most often breached are the places most people read. Nothing this pipeline produces may be called a systematic review anywhere: not in the title stage 18 writes, not in the abstract stage 17 writes, not in a section heading, not in a caption, and not in what any stage reports back to the user. Two stages have this written into their own instructions, `agents/17-abstract.md` under "What not to do" and `agents/18-titlemaker.md` under "Conventions by paper type," because a single sentence in an abstract is enough to make the whole paper overclaim. ## Word budget: two real tables, and how they relate Two word-count tables exist in this pipeline's own agent files, produced at two different stages, and they are not the same numbers. Both are real; neither is wrong; they serve different purposes. **The planning range**, from `agents/05-architect.md` under "Structure" (used when first structuring the outline, and when no specific total length was requested): | Section | Words | |---|---| | Abstract | 250-300 | | Introduction | 800-1,200 | | Literature review | 1,500-2,500 | | Methodology | 1,000-1,500 | | Results / analysis | 1,500-2,000 | | Discussion | 1,500-2,000 | | Conclusion | 500-700 | | **Total (sum of the ranges above, not stated as a single figure in the source)** | **~7,050-10,200** | **The proportional model**, from `agents/06-formatter.md` under "Length targets" (used once a format and venue are chosen and a specific total is known, and explicitly meant to be scaled): | Section | Words | Share | |---|---|---| | Abstract | 250 | 1% | | Introduction | 2,500 | 12% | | Literature review | 6,000 | 28% | | Methodology | 2,500 | 12% | | Results | 6,000 | 28% | | Discussion | 3,000 | 14% | | Conclusion | 1,000 | 5% | | Total | 21,000 | 100% | (The listed subtotals sum to 21,250, about 250 words over the stated 21,000 total; the discrepancy is small and the formatter table itself frames these as a proportional model to scale from, not an exact accounting, so treat the 21,000 as approximate rather than reconciling it to the last word. The share column does reconcile exactly: each share is its row against the 21,250 actually listed, and the seven sum to 100. That is why the two largest sections read 28 percent and not 29.) The planning range's Introduction row is itself divided into five finer parts in `agents/05-architect.md` under "Structure" (hook and context, problem statement, research question, contribution, organization, roughly 200/200/150/250/100 words), which sum to roughly 900 words inside the 800-1,200 total above. Treat those five as the centres of their own small bands, not as five independent floors stacked on top of the section total. **How to use the two together.** The formatter's own instruction, verbatim from `agents/06-formatter.md` under "Length targets," is: "Scale proportionally for a shorter target length; keep the literature review and results as the two largest sections regardless of overall scale." Read the 21,000-word table as the proportions (roughly 1/12/28/12/28/14/5 percent), not as a fixed target. For a specific target length, multiply those shares by the actual word count wanted. The architect's smaller range is a reasonable default for a standard paper when no other length has been specified; the formatter's table is what to reach for once a specific target (a venue's word limit, a thesis chapter's length requirement) is known and the sections need to be scaled to fit it. **Pick one basis, and say which.** These are two bases for the same decision, not two opinions to average. A paper uses the architect's default ranges or the formatter's scaled shares, never some sections from each. `agents/06-formatter.md` records the choice in the `Word basis` key of the venue format block it writes at the top of `outline_formatted.md`, and the per-section numbers in its own "Formatted structure" list are the default-scale reading of that same decision, not a competing third table. **Every number here is a band, never a floor.** `scripts/integrity.py` checks word count as the target plus or minus ten percent, so a section written to exceed its number fails the same check a short section fails. `agents/07-crafter.md` states this in its own terms under "Word count": a section that comes in short is reporting thin evidence, and the response is to record the gap rather than to pad toward the number. In both tables, literature review and results are consistently the two largest sections (28% each in the formatter table; the two widest ranges in the architect table). That balance holds across scale: a paper padded in the introduction or discussion instead reads as opinion-driven rather than evidence-driven, which is exactly what the formatter's note is warning against. ## Scale: matching pipeline depth and source count to what was asked Three tiers, stated in `SKILL.md` under "Scale": | Tier | Word count | Stages | Sources | |---|---|---|---| | Short piece | 1,500-3,000 words | 1-7, then 9.5, 10, 11, 15, 17 (skips 8, 9, 12-14, 16, 18) | 10-15 | | Full paper (default) | Formatter's scaled table, ~21,000 words at full scale | All 18, plus 9.5, and 4.5 for a review | 25-50 | | Thesis chapter or long review | Formatter's scaled table, scaled up; a long review runs 8,000-12,000 words | All 18, plus 9.5, and 4.5 for a review, with stage 7 (drafting) run once per subsection rather than once per section | 60+ for a long review, 50+ otherwise | Stage 9.5 appears in every tier above and is never one of the skipped stages. It is `scripts/assemble.py`, and stages 10, 11 and 15 all read `full_draft.md`, which does not exist until assembly has run. A tier list that drops it is not a shorter pipeline, it is one that stops at stage 10 with nothing to read. Stage 17 appears in every tier for the same kind of reason. Both word-count tables above open with an abstract row, so every paper this pipeline produces has an abstract as its first section, and the tier that skipped stage 17 was not producing a shorter paper, it was producing one whose opening section nobody wrote. Scale changes its length and the document type changes its name (an article's abstract, a committee paper's summary, an evidence brief's key findings), never whether it is there. Where the brief states a cap on it, that cap governs; `agents/17-abstract.md` carries the fallback range for when none was stated. `agents/06-formatter.md`, under "Reference URLs: cite the actual source, not the tool you found it with," gives the corresponding citation-count floor by paper type rather than by scale tier: roughly 20 references for an empirical paper, 50 or more for a literature review, drawn from `research/citations.json`. The temporal shape of that pool is stage 1's to set, not the formatter's: `agents/01-scout.md` fixes it under "Quality filtering," in that section's "Balance temporally" step, relative to the current year rather than to a fixed window, and `agents/06-formatter.md` records what stage 1 achieved instead of specifying a second split against it. When the two disagree, the paper type's floor governs. A literature review written at the full-paper tier gathers fifty or more sources, not the 25-50 its tier row suggests: the tier band describes the usual case for a paper of that length and is not a cap, while the per-type floor is the point below which a review has not surveyed enough of the literature to be one. This is worth stating rather than leaving to judgement, because a narrative review at the default scale is the commonest thing this pipeline is asked for, so the two numbers collide in the default case and not in some edge case a reader can be trusted to notice. That floor is a floor on sources gathered, which is the one number in this file that genuinely is one, and it is not a licence to cite a source the drafting stage has not read. `research/summaries.md` marks each source as fully read or metadata-only, and a metadata-only source can be cited for its existence and its topic but never for a finding, a method or a number. A reference list that clears 50 while most of its entries are metadata-only is a list of things that exist, not a body of evidence, and the honest place to say so is the paper's limitations. ## "Thesis chapter" is a scale, not a paper type There is no fifth structural template for a thesis chapter. A thesis chapter is the "thesis chapter or long review" scale tier above, applied to whichever of the four paper types actually fits the chapter's content (a literature-review chapter uses the literature-review structure; a methods-and-results chapter uses the empirical-study structure). The only structural difference at this scale is that stage 7 drafts one subsection at a time instead of one section at a time, so a 6,000-word literature-review section, say, might be drafted as four or five separately-written 1,200-1,500-word subsections rather than one pass. ## Where OpenDraft's original axis went The source project this pipeline is ported from (OpenDraft) used a different, more granular axis: `academic_level` (`research_paper`, `bachelor`, `master`, `phd`), each with its own word-count table enforced in code at `engine/draft_generator.py:312-380` of the source repository, plus a separate coarser floor-check in `engine/utils/quality_gate.py:81-87`. That axis is not carried into this port: `agents/05-architect.md` and `agents/06-formatter.md` use the single scalable model documented above instead, combined with the three-tier "scale" system in `SKILL.md`. The source numbers are recorded here only as historical context, not as anything this pipeline enforces: | academic_level (source project only, not this pipeline) | Total words | Min. citations | |---|---|---| | research_paper | 3,000-5,000 | 10 | | bachelor | 10,000-15,000 | 15 | | master | 25,000-30,000 | 25 | | phd | 50,000-80,000 | 50 | If a user asks for output shaped like one of these four tiers specifically (for example, "write this at bachelor's-thesis length"), treat it as a request for a target word count and citation floor to plug into the scale system above, not as a fifth paper type or a mode this pipeline has separate code for. ## Checklist for stage 5 - Name the paper type (one of the four above) and, if it's a review, the review type (narrative or scoping, never systematic). - State the paper's main claim and instantiate the argument flow's X, Y, Z and W in the paper's own terms, per `agents/05-architect.md` under "Main claim and argument flow"; a structure with sections but no stated claim gives the drafting and review stages nothing to argue toward. - Pick a scale tier, or a specific target word count if the user gave one. - Derive each section's word budget either from the architect's default range (no target length given) or by scaling the formatter's percentage table to the target (a specific length was given). - Set the citation-count floor from the per-paper-type guidance in `agents/06-formatter.md` under "Reference URLs: cite the actual source, not the tool you found it with," adjusted upward if the scale tier is "thesis chapter or long review." - Check the planned results table against `research/summaries.md` before writing it into the outline: every metric column has to be one that at least two sources' entries actually report, and a source marked metadata-only there fills no metric row at all. If the pool reports no common metric, plan the comparison the pool supports and say so, rather than planning the table the template suggests. - Write no example statistic into `outline.md`. Where a value is needed to show a claim's shape, leave a bracketed slot; a number invented to illustrate an outline is inherited by the drafting stage as though it had a source. - Record all of the above explicitly in `outline.md`, per `agents/05-architect.md`'s own "Done when" list.skills/autonomous-research/assets/equation-1.svg3.5 KB7b83f75e3fc1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/equation-2.svg4.8 KB84bd0b12faa2upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/equation-3.svg5.7 KBb2d7181b3681upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/fonts/OFL-Source-Sans-3.txt4.4 KB22c601b82f29upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/fonts/OFL-STIX-Two.txt4.4 KB11b7b8e8892aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/fonts/SS-400.ttf371 KB9bfd4b577a6aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/fonts/SS-600.ttf371 KB5e37e593d097upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/fonts/SS-700.ttf371 KB8809d57bf287upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/fonts/SSi-400.ttf235 KB3707c1940456upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/fonts/STIX-400.ttf293 KB8ae8b10aad5dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/fonts/STIX-600.ttf293 KB7663a3cb5a25upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/fonts/STIX-700.ttf293 KB67ca2f36cf04upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/fonts/STIXi-400.ttf319 KB2f7edd8c838eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/assets/fonts/STIXi-600.ttf319 KBf31e1c5f9706upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/.gitignore34 B75c61823b871upstream ↗
__pycache__/ *.pyc .pytest_cache/skills/autonomous-research/agents/01-scout.md20 KB58c4b938c5d0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/02-scribe.md8.5 KB44a8f2d1a25aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/03-signal.md8.3 KBf6fc2ee525b0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/04-citation-manager.md21 KBd3f568235a96upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/04.5-evidence.md6.4 KBd43eda959e41upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/05-architect.md17 KB5787fcc56d09upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/06-formatter.md21 KBb7893fcd4f77upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/07-crafter.md35 KB6fc8bc400a5dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/08-thread.md15 KBad880e78589bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/09-narrator.md7.6 KB6db76751e2e1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/10-skeptic.md11 KB91a8d1cceb61upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/11-verifier.md13 KB9029b5a6590dupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/12-referee.md8.2 KB19eddf7e9103upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/13-voice.md13 KBbc142f16ae66upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/14-entropy.md13 KB59d30d615c58upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/15-polish.md13 KBc35d6de5fc9eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/16-enhancer.md22 KB1e161efbcf8aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/17-abstract.md14 KB3c53bab4083eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/agents/18-titlemaker.md15 KB6b706b7b1427upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/DERIVATION.json23 KB6ccd8e7389d9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/EVALS.md29 KBd104fac3bd3fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/LICENSE11 KB052d7d992447upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/tests/test_agent_contract.py16 KB3fac2c31c4eaupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/tests/test_assemble.py11 KB8940d5f3a443upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/tests/test_citations.py25 KB058a5ba1914aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/tests/test_docs_consistency.py30 KB3b4527141b4cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/tests/test_documented_commands.py8.8 KB1b612c459a86upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/tests/test_evidence.py13 KB5a4e0d8715f6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/tests/test_export.py11 KBfd4e8ceb50aaupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/tests/test_integrity.py68 KB61eb368e68f5upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/tests/test_journal.py7.3 KB7a632e5f3a0cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/tests/test_roundtrip.py6.2 KB219db7dc3f66upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/tests/test_sources.py22 KB0b0263fbd784upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
skills/autonomous-research/THIRD_PARTY_NOTICES.md2.4 KBfff19692f915upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
README.mdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/federicodeponte-opendraft-skills-autonomous-research.md