Skip to content

Commit 588d1b9

Browse files
IMNMVclaude
andcommitted
Researcher toolkit: write-back, citations, notebooks, codebooks
ClaudeR 0.5.0 / clauder-mcp 0.8.0 (32 tools). Reviewer Zero write-back (R/writeback.R): - annotate_manuscript() injects native Word comments into a .docx at the paragraphs containing flagged text via xml2+zip OOXML surgery; comments part, content type, and relationship registered idempotently; writes <name>_annotated.docx, never the original - reviewer_zero_prompt(writeback = TRUE) appends the protocol step that turns registry findings into anchored comments Citation upgrades (R/citations.R + verify_references): - retraction/correction/concern flags from Crossref update notices (verified against the Wakefield 1998 retraction) - arXiv ID resolution with published-version lookup - bibliographic matching for DOI-less reference entries - new tools: search_citations (OpenAlex) and get_bibtex (doi.org content negotiation) Lab notebooks (R/notebook.R): export_log_as_notebook() turns a session log into a Quarto notebook -- runnable chunks with timestamps and agent attribution, errored blocks preserved as non-eval chunks, narration TODO markers; generate_notebook MCP tool briefs the agent to narrate. Codebooks (R/codebook.R): generate_codebook() scans scripts for packages/reads/writes, reads each data file, and emits the OSF-style codebook (variable, class, n, missingness, summary) plus package, script, and output inventories; generate_codebook MCP tool. CI: notebook, codebook, and write-back functional tests (hand-rolled minimal docx fixture; xml2+zip added to check deps). R CMD check: Status OK. pytest 30/30, handshake 32 tools. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 028168e commit 588d1b9

19 files changed

Lines changed: 1423 additions & 18 deletions

.github/scripts/checks.R

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ for (f in list.files("R", full.names = TRUE, pattern = "[.][rR]$")) {
1616
env <- new.env()
1717
sys.source("R/ui.R", envir = env)
1818
sys.source("R/checkpoints.R", envir = env)
19+
sys.source("R/notebook.R", envir = env)
20+
sys.source("R/codebook.R", envir = env)
21+
sys.source("R/writeback.R", envir = env)
22+
sys.source("R/citations.R", envir = env)
1923

2024
# --- 2. Lab-mode assembly gates ---
2125
lab <- tempfile("labtest"); dir.create(lab)
@@ -106,5 +110,85 @@ r <- tryCatch({
106110
}, error = function(e) conditionMessage(e))
107111
if (isTRUE(r)) pass("restore backs up current state first") else fail("pre_restore backup:", r)
108112

113+
# --- 6. lab-notebook generator ---
114+
r <- tryCatch({
115+
log <- tempfile(fileext = ".R")
116+
writeLines(c(
117+
"# --- [2026-08-01 12:01:00] ---", "# Code executed by agent-a:",
118+
"x <- 1:10", "mean(x)", "",
119+
"# --- [2026-08-01 12:02:00] ---", "# Code executed by agent-a (ERROR):",
120+
"stop_here()", "# Error: could not find function", "",
121+
"# --- [2026-08-01 12:03:00] ---", "# Code executed by agent-b:",
122+
"plot(x)", ""), log)
123+
out <- suppressMessages(env$export_log_as_notebook(log, title = "T"))
124+
qmd <- readLines(out)
125+
sum(grepl("^## Step", qmd)) == 3 &&
126+
sum(grepl("```{r}", qmd, fixed = TRUE)) == 3 &&
127+
sum(grepl("eval: false", qmd, fixed = TRUE)) == 1 &&
128+
sum(grepl("TODO: narration", qmd, fixed = TRUE)) == 5
129+
}, error = function(e) conditionMessage(e))
130+
if (isTRUE(r)) pass("notebook generator") else fail("notebook:", r)
131+
132+
# --- 7. codebook generator ---
133+
r <- tryCatch({
134+
proj <- tempfile("proj"); dir.create(proj)
135+
utils::write.csv(data.frame(id = 1:20, v = c(rnorm(18), NA, NA)),
136+
file.path(proj, "d.csv"), row.names = FALSE)
137+
writeLines(c("library(jsonlite)",
138+
paste0("d <- read.csv(\"", file.path(proj, "d.csv"), "\")"),
139+
"saveRDS(d, \"out.rds\")"), file.path(proj, "a.R"))
140+
out <- suppressMessages(env$generate_codebook(proj))
141+
md <- readLines(out)
142+
any(grepl("| v |", md, fixed = TRUE)) &&
143+
any(grepl("2 (10.0%)", md, fixed = TRUE)) &&
144+
any(grepl("out.rds", md, fixed = TRUE)) &&
145+
any(grepl("jsonlite (", md, fixed = TRUE))
146+
}, error = function(e) conditionMessage(e))
147+
if (isTRUE(r)) pass("codebook generator") else fail("codebook:", r)
148+
149+
# --- 8. manuscript write-back (needs xml2 + zip) ---
150+
if (requireNamespace("xml2", quietly = TRUE) && requireNamespace("zip", quietly = TRUE)) {
151+
r <- tryCatch({
152+
W <- "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
153+
fx <- tempfile("fixdocx"); dir.create(file.path(fx, "word", "_rels"), recursive = TRUE)
154+
dir.create(file.path(fx, "_rels"), recursive = TRUE)
155+
writeLines(paste0('<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
156+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>',
157+
'<Default Extension="xml" ContentType="application/xml"/>',
158+
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'),
159+
file.path(fx, "[Content_Types].xml"))
160+
writeLines(paste0('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
161+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>'),
162+
file.path(fx, "_rels", ".rels"))
163+
writeLines(paste0('<?xml version="1.0"?><w:document xmlns:w="', W, '"><w:body>',
164+
'<w:p><w:r><w:t>Results: t(38) = 2.12, p = .041, d = 0.34.</w:t></w:r></w:p>',
165+
'<w:p><w:r><w:t>We excluded 12 participants.</w:t></w:r></w:p>',
166+
'</w:body></w:document>'),
167+
file.path(fx, "word", "document.xml"))
168+
writeLines(paste0('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>'),
169+
file.path(fx, "word", "_rels", "document.xml.rels"))
170+
fixture <- tempfile(fileext = ".docx")
171+
zip::zip(fixture, files = list.files(fx, recursive = TRUE, all.files = TRUE),
172+
root = fx, mode = "mirror")
173+
174+
res <- suppressMessages(env$annotate_manuscript(
175+
fixture,
176+
data.frame(anchor = c("t(38) = 2.12", "no such text"),
177+
comment = c("recomputed p = .058", "x"),
178+
stringsAsFactors = FALSE)))
179+
td <- tempfile(); dir.create(td); utils::unzip(res$output_path, exdir = td)
180+
cm <- xml2::read_xml(file.path(td, "word", "comments.xml"))
181+
dx <- xml2::read_xml(file.path(td, "word", "document.xml"))
182+
rl <- readLines(file.path(td, "word", "_rels", "document.xml.rels"), warn = FALSE)
183+
length(res$matched) == 1 && length(res$unmatched) == 1 &&
184+
length(xml2::xml_find_all(cm, "//w:comment", xml2::xml_ns(cm))) == 1 &&
185+
length(xml2::xml_find_all(dx, "//w:commentRangeStart", xml2::xml_ns(dx))) == 1 &&
186+
any(grepl("relationships/comments", rl))
187+
}, error = function(e) conditionMessage(e))
188+
if (isTRUE(r)) pass("manuscript write-back") else fail("write-back:", r)
189+
} else {
190+
cat("skip: write-back test (xml2/zip not installed)\n")
191+
}
192+
109193
if (!ok) quit(status = 1)
110194
cat("\nAll checks passed.\n")

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ jobs:
3434
- uses: r-lib/actions/setup-r@v2
3535
with:
3636
use-public-rspm: true
37-
- name: Install jsonlite
38-
run: Rscript -e 'install.packages("jsonlite")'
37+
- name: Install check dependencies
38+
run: Rscript -e 'install.packages(c("jsonlite", "xml2", "zip"))'
3939
- name: Run checks
4040
run: Rscript .github/scripts/checks.R
4141

DESCRIPTION

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Package: ClaudeR
22
Title: R Integration for Claude AI
3-
Version: 0.4.0
3+
Version: 0.5.0
44
Authors@R: person("Nykko", "Vitali", email = "nykvt@icloud.com", role = c("aut", "cre"))
55
Description: Connects RStudio with Claude AI to enable interactive coding sessions.
66
License: MIT + file LICENSE
@@ -22,5 +22,7 @@ Imports:
2222
Suggests:
2323
devtools,
2424
officer,
25-
pdftools
25+
pdftools,
26+
xml2,
27+
zip
2628
RoxygenNote: 7.3.3

NAMESPACE

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
# Generated by roxygen2: do not edit by hand
22

3+
export(annotate_manuscript)
34
export(checkpoint_session)
45
export(claudeAddin)
56
export(claude_rstudio_addin)
67
export(clean_clauder_log)
78
export(data_annotation_prompt)
89
export(execute_code_in_session)
10+
export(export_log_as_notebook)
911
export(export_log_as_script)
1012
export(extract_manuscript_text)
1113
export(finalize_lab_session)
14+
export(generate_codebook)
1215
export(install_clauder)
1316
export(install_cli)
1417
export(lab_mode_prompt)

R/citations.R

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# --- Citation tools ---
2+
# Network helpers behind the search_citations / get_bibtex MCP tools and the
3+
# retraction / arXiv / no-DOI extensions to verify_references. All requests
4+
# are bounded by options(timeout = 10) at the call site and wrapped in
5+
# tryCatch so one dead API never kills a whole report.
6+
7+
# Query OpenAlex for works matching a free-text query. Returns a formatted
8+
# candidate list the agent can pick a citation from (instead of hallucinating
9+
# one). OpenAlex needs no API key and tolerates polite anonymous use.
10+
search_citations_impl <- function(query, max_results = 5L) {
11+
if (!nzchar(trimws(query))) return("Error: empty query.")
12+
old <- options(timeout = 10)
13+
on.exit(options(old), add = TRUE)
14+
15+
url <- paste0(
16+
"https://api.openalex.org/works?search=",
17+
utils::URLencode(query, reserved = TRUE),
18+
"&per-page=", as.integer(max_results),
19+
"&select=title,authorships,publication_year,primary_location,doi,cited_by_count,type"
20+
)
21+
res <- tryCatch(jsonlite::fromJSON(url, simplifyVector = FALSE),
22+
error = function(e) NULL)
23+
if (is.null(res) || length(res$results) == 0) {
24+
return(paste0("No OpenAlex results for: ", query))
25+
}
26+
27+
entries <- vapply(res$results, function(w) {
28+
authors <- vapply(w$authorships, function(a) {
29+
an <- a$author$display_name
30+
if (is.null(an)) "?" else an
31+
}, character(1))
32+
if (length(authors) > 4) authors <- c(authors[1:3], "et al.")
33+
venue <- tryCatch(w$primary_location$source$display_name,
34+
error = function(e) NULL)
35+
doi <- if (!is.null(w$doi)) sub("^https://doi.org/", "", w$doi) else "no DOI"
36+
paste0(
37+
"- ", if (!is.null(w$title)) w$title else "(untitled)", "\n",
38+
" ", paste(authors, collapse = ", "),
39+
" (", if (!is.null(w$publication_year)) w$publication_year else "?", "). ",
40+
if (!is.null(venue)) venue else "unknown venue", ".\n",
41+
" DOI: ", doi,
42+
" | type: ", if (!is.null(w$type)) w$type else "?",
43+
" | cited by: ", if (!is.null(w$cited_by_count)) w$cited_by_count else "?"
44+
)
45+
}, character(1))
46+
47+
paste0(
48+
"OpenAlex results for '", query, "' (", length(entries), "):\n\n",
49+
paste(entries, collapse = "\n\n"),
50+
"\n\nUse get_bibtex with a DOI to fetch a citation entry."
51+
)
52+
}
53+
54+
# Fetch a BibTeX entry for a DOI via doi.org content negotiation. This is the
55+
# canonical registered metadata, not a reconstruction.
56+
get_bibtex_impl <- function(doi) {
57+
doi <- sub("^https?://doi.org/", "", trimws(doi))
58+
if (!grepl("^10\\.\\d{4,9}/", doi)) {
59+
return(paste0("Error: '", doi, "' does not look like a DOI (expected 10.XXXX/...)."))
60+
}
61+
old <- options(timeout = 10)
62+
on.exit(options(old), add = TRUE)
63+
64+
bib <- tryCatch({
65+
con <- url(paste0("https://doi.org/", doi),
66+
headers = c(Accept = "application/x-bibtex"))
67+
on.exit(try(close(con), silent = TRUE), add = TRUE)
68+
paste(readLines(con, warn = FALSE), collapse = "\n")
69+
}, error = function(e) NULL)
70+
71+
if (is.null(bib) || !nzchar(bib)) {
72+
return(paste0("Could not resolve BibTeX for DOI ", doi,
73+
". The DOI may be invalid or the resolver unreachable."))
74+
}
75+
bib
76+
}
77+
78+
# Check whether anything in Crossref updates this DOI (retractions,
79+
# expressions of concern, major corrections). Returns NULL when clean,
80+
# otherwise a short human-readable flag string.
81+
check_retraction_impl <- function(doi) {
82+
res <- tryCatch(jsonlite::fromJSON(
83+
paste0("https://api.crossref.org/works?filter=updates:",
84+
utils::URLencode(doi, reserved = TRUE), "&rows=5"),
85+
simplifyVector = FALSE
86+
), error = function(e) NULL)
87+
if (is.null(res)) return(NULL)
88+
items <- res$message$items
89+
if (length(items) == 0) return(NULL)
90+
91+
flags <- character(0)
92+
for (it in items) {
93+
for (upd in it$`update-to`) {
94+
type <- tolower(if (!is.null(upd$type)) upd$type else "")
95+
lab <- if (!is.null(upd$label)) upd$label else upd$type
96+
if (identical(upd$DOI, doi) || grepl("retract|concern|correct", type)) {
97+
notice_doi <- if (!is.null(it$DOI)) it$DOI else "?"
98+
flags <- c(flags, sprintf("%s (notice DOI: %s)", lab, notice_doi))
99+
}
100+
}
101+
}
102+
if (length(flags) == 0) return(NULL)
103+
paste0("!!! UPDATE NOTICE: ", paste(unique(flags), collapse = "; "),
104+
" -- verify before citing (possible retraction/correction/concern)")
105+
}
106+
107+
# Best-effort bibliographic match for a reference string with no DOI.
108+
# Returns a short candidate line, or NULL if nothing plausible came back.
109+
match_reference_impl <- function(ref_text) {
110+
ref_text <- trimws(gsub("\\s+", " ", ref_text))
111+
if (nchar(ref_text) < 40) return(NULL)
112+
res <- tryCatch(jsonlite::fromJSON(
113+
paste0("https://api.crossref.org/works?rows=1&query.bibliographic=",
114+
utils::URLencode(substr(ref_text, 1, 300), reserved = TRUE)),
115+
simplifyVector = FALSE
116+
), error = function(e) NULL)
117+
items <- tryCatch(res$message$items, error = function(e) NULL)
118+
if (is.null(items) || length(items) == 0) return(NULL)
119+
it <- items[[1]]
120+
title <- tryCatch(paste(unlist(it$title), collapse = " "), error = function(e) "?")
121+
year <- tryCatch(it$issued$`date-parts`[[1]][[1]], error = function(e) "?")
122+
score <- if (!is.null(it$score)) round(as.numeric(it$score), 1) else NA
123+
sprintf("Best Crossref match: \"%s\" (%s), DOI: %s [score %s -- verify title/authors match before accepting]",
124+
title, year, if (!is.null(it$DOI)) it$DOI else "?", score)
125+
}
126+
127+
# Look up an arXiv ID via the arXiv Atom API, and check Crossref for a
128+
# published (journal/proceedings) version so preprint citations of published
129+
# work get flagged. Atom parsed with regex to avoid an xml2 hard dependency.
130+
check_arxiv_impl <- function(arxiv_id) {
131+
res <- tryCatch({
132+
con <- url(paste0("https://export.arxiv.org/api/query?id_list=", arxiv_id))
133+
on.exit(try(close(con), silent = TRUE), add = TRUE)
134+
paste(readLines(con, warn = FALSE), collapse = "\n")
135+
}, error = function(e) NULL)
136+
if (is.null(res)) return(NULL)
137+
138+
title <- regmatches(res, regexpr("<title>[^<]+</title>", res))
139+
# First <title> is the feed's own; the entry title is the second match
140+
titles <- regmatches(res, gregexpr("<title>[^<]+</title>", res))[[1]]
141+
entry_title <- if (length(titles) >= 2) {
142+
trimws(gsub("</?title>|\\s+", " ", titles[2]))
143+
} else NULL
144+
if (is.null(entry_title) || !nzchar(entry_title)) {
145+
return(sprintf("arXiv:%s -- not found on arXiv.", arxiv_id))
146+
}
147+
148+
out <- sprintf("arXiv:%s resolves to: \"%s\"", arxiv_id, entry_title)
149+
published <- match_reference_impl(entry_title)
150+
if (!is.null(published)) {
151+
out <- paste0(out, "\n Possible published version -- ", published,
152+
"\n If this matches, cite the published version rather than the preprint.")
153+
}
154+
out
155+
}

0 commit comments

Comments
 (0)