From f1c89ea9d1807b1d52bf56b99dc1d42551b7136b Mon Sep 17 00:00:00 2001 From: Aditya Date: Thu, 20 Aug 2026 22:45:13 +0530 Subject: [PATCH] feat: add superdocsr R extension --- .../Aditya2600/superdocsr/.Rbuildignore | 6 + extensions/Aditya2600/superdocsr/DESCRIPTION | 33 ++ extensions/Aditya2600/superdocsr/LICENSE | 2 + extensions/Aditya2600/superdocsr/NAMESPACE | 29 ++ extensions/Aditya2600/superdocsr/NEWS.md | 28 ++ extensions/Aditya2600/superdocsr/R/approve.R | 108 ++++++ extensions/Aditya2600/superdocsr/R/budget.R | 170 ++++++++ extensions/Aditya2600/superdocsr/R/changes.R | 242 ++++++++++++ extensions/Aditya2600/superdocsr/R/client.R | 270 +++++++++++++ extensions/Aditya2600/superdocsr/R/edit.R | 230 +++++++++++ extensions/Aditya2600/superdocsr/R/export.R | 137 +++++++ extensions/Aditya2600/superdocsr/R/knit.R | 244 ++++++++++++ .../superdocsr/R/superdocsr-package.R | 25 ++ extensions/Aditya2600/superdocsr/R/upload.R | 205 ++++++++++ extensions/Aditya2600/superdocsr/R/wait.R | 206 ++++++++++ extensions/Aditya2600/superdocsr/README.md | 190 +++++++++ .../Aditya2600/superdocsr/man/sd_approve.Rd | 49 +++ .../superdocsr/man/sd_awaiting_kind.Rd | 25 ++ .../Aditya2600/superdocsr/man/sd_budget.Rd | 67 ++++ .../Aditya2600/superdocsr/man/sd_cancel.Rd | 23 ++ .../Aditya2600/superdocsr/man/sd_changes.Rd | 54 +++ .../Aditya2600/superdocsr/man/sd_client.Rd | 65 ++++ .../Aditya2600/superdocsr/man/sd_continue.Rd | 36 ++ .../Aditya2600/superdocsr/man/sd_deny.Rd | 31 ++ .../Aditya2600/superdocsr/man/sd_document.Rd | 46 +++ .../Aditya2600/superdocsr/man/sd_edit.Rd | 76 ++++ .../Aditya2600/superdocsr/man/sd_export.Rd | 51 +++ .../Aditya2600/superdocsr/man/sd_job.Rd | 52 +++ .../Aditya2600/superdocsr/man/sd_knit.Rd | 92 +++++ .../Aditya2600/superdocsr/man/sd_ops_used.Rd | 26 ++ .../superdocsr/man/sd_review_console.Rd | 34 ++ .../Aditya2600/superdocsr/man/sd_upload.Rd | 47 +++ .../superdocsr/man/sd_verify_key.Rd | 25 ++ .../Aditya2600/superdocsr/man/sd_wait.Rd | 55 +++ .../superdocsr/man/superdocsr-package.Rd | 51 +++ .../Aditya2600/superdocsr/tests/testthat.R | 4 + .../tests/testthat/helper-fake-api.R | 200 ++++++++++ .../superdocsr/tests/testthat/test-approve.R | 111 ++++++ .../superdocsr/tests/testthat/test-budget.R | 91 +++++ .../superdocsr/tests/testthat/test-changes.R | 158 ++++++++ .../superdocsr/tests/testthat/test-client.R | 84 ++++ .../superdocsr/tests/testthat/test-edit.R | 122 ++++++ .../superdocsr/tests/testthat/test-export.R | 120 ++++++ .../superdocsr/tests/testthat/test-knit.R | 213 ++++++++++ .../superdocsr/tests/testthat/test-upload.R | 78 ++++ .../superdocsr/tests/testthat/test-wait.R | 140 +++++++ .../vignettes/research-paper-workflow.Rmd | 366 ++++++++++++++++++ 47 files changed, 4717 insertions(+) create mode 100644 extensions/Aditya2600/superdocsr/.Rbuildignore create mode 100644 extensions/Aditya2600/superdocsr/DESCRIPTION create mode 100644 extensions/Aditya2600/superdocsr/LICENSE create mode 100644 extensions/Aditya2600/superdocsr/NAMESPACE create mode 100644 extensions/Aditya2600/superdocsr/NEWS.md create mode 100644 extensions/Aditya2600/superdocsr/R/approve.R create mode 100644 extensions/Aditya2600/superdocsr/R/budget.R create mode 100644 extensions/Aditya2600/superdocsr/R/changes.R create mode 100644 extensions/Aditya2600/superdocsr/R/client.R create mode 100644 extensions/Aditya2600/superdocsr/R/edit.R create mode 100644 extensions/Aditya2600/superdocsr/R/export.R create mode 100644 extensions/Aditya2600/superdocsr/R/knit.R create mode 100644 extensions/Aditya2600/superdocsr/R/superdocsr-package.R create mode 100644 extensions/Aditya2600/superdocsr/R/upload.R create mode 100644 extensions/Aditya2600/superdocsr/R/wait.R create mode 100644 extensions/Aditya2600/superdocsr/README.md create mode 100644 extensions/Aditya2600/superdocsr/man/sd_approve.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_awaiting_kind.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_budget.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_cancel.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_changes.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_client.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_continue.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_deny.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_document.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_edit.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_export.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_job.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_knit.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_ops_used.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_review_console.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_upload.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_verify_key.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/sd_wait.Rd create mode 100644 extensions/Aditya2600/superdocsr/man/superdocsr-package.Rd create mode 100644 extensions/Aditya2600/superdocsr/tests/testthat.R create mode 100644 extensions/Aditya2600/superdocsr/tests/testthat/helper-fake-api.R create mode 100644 extensions/Aditya2600/superdocsr/tests/testthat/test-approve.R create mode 100644 extensions/Aditya2600/superdocsr/tests/testthat/test-budget.R create mode 100644 extensions/Aditya2600/superdocsr/tests/testthat/test-changes.R create mode 100644 extensions/Aditya2600/superdocsr/tests/testthat/test-client.R create mode 100644 extensions/Aditya2600/superdocsr/tests/testthat/test-edit.R create mode 100644 extensions/Aditya2600/superdocsr/tests/testthat/test-export.R create mode 100644 extensions/Aditya2600/superdocsr/tests/testthat/test-knit.R create mode 100644 extensions/Aditya2600/superdocsr/tests/testthat/test-upload.R create mode 100644 extensions/Aditya2600/superdocsr/tests/testthat/test-wait.R create mode 100644 extensions/Aditya2600/superdocsr/vignettes/research-paper-workflow.Rmd diff --git a/extensions/Aditya2600/superdocsr/.Rbuildignore b/extensions/Aditya2600/superdocsr/.Rbuildignore new file mode 100644 index 00000000..b757cd61 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/.Rbuildignore @@ -0,0 +1,6 @@ +^.*\.Rproj$ +^\.Rproj\.user$ +^\.github$ +^LICENSE\.md$ +^cran-comments\.md$ +^\.DS_Store$ diff --git a/extensions/Aditya2600/superdocsr/DESCRIPTION b/extensions/Aditya2600/superdocsr/DESCRIPTION new file mode 100644 index 00000000..3b519723 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/DESCRIPTION @@ -0,0 +1,33 @@ +Package: superdocsr +Title: Review-Gated AI Document Editing for Research Papers via SuperDocs +Version: 0.1.0 +Authors@R: + person("Aditya", "Meshram", email = "adime2500@gmail.com", role = c("aut", "cre")) +Description: A researcher-facing client for the SuperDocs document editing + API. Renders an R Markdown paper with 'rmarkdown', uploads it, asks the + SuperDocs agent for a targeted edit, and returns the proposed changes for + inspection. Nothing is written into the document until the changes are + approved explicitly, so an AI edit can never land unreviewed. Long edits + are polled with exponential backoff under a wall-clock timeout, and a + per-client operation budget stops runaway loops before they spend a quota. +License: MIT + file LICENSE +Encoding: UTF-8 +URL: https://github.com/superdocsapp/superdocs-builds +BugReports: https://github.com/superdocsapp/superdocs-builds/issues +Depends: + R (>= 4.1) +Imports: + curl, + httr2 (>= 1.0.0), + jsonlite (>= 1.8.0), + tools, + utils +Suggests: + knitr, + rmarkdown, + testthat (>= 3.0.0), + withr +VignetteBuilder: knitr +Config/testthat/edition: 3 +Roxygen: list(markdown = TRUE) +Config/roxygen2/version: 8.1.0 diff --git a/extensions/Aditya2600/superdocsr/LICENSE b/extensions/Aditya2600/superdocsr/LICENSE new file mode 100644 index 00000000..48e6822f --- /dev/null +++ b/extensions/Aditya2600/superdocsr/LICENSE @@ -0,0 +1,2 @@ +YEAR: 2026 +COPYRIGHT HOLDER: Aditya Meshram diff --git a/extensions/Aditya2600/superdocsr/NAMESPACE b/extensions/Aditya2600/superdocsr/NAMESPACE new file mode 100644 index 00000000..f804ff04 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/NAMESPACE @@ -0,0 +1,29 @@ +# Generated by roxygen2: do not edit by hand + +S3method("[",sd_changes) +S3method(print,sd_budget) +S3method(print,sd_changes) +S3method(print,sd_client) +S3method(print,sd_document) +S3method(print,sd_job) +S3method(print,sd_knit_result) +export(sd_approve) +export(sd_awaiting_kind) +export(sd_budget) +export(sd_cancel) +export(sd_changes) +export(sd_client) +export(sd_continue) +export(sd_deny) +export(sd_document) +export(sd_edit) +export(sd_export) +export(sd_job) +export(sd_knit) +export(sd_ops_remaining) +export(sd_ops_used) +export(sd_review_console) +export(sd_review_none) +export(sd_upload) +export(sd_verify_key) +export(sd_wait) diff --git a/extensions/Aditya2600/superdocsr/NEWS.md b/extensions/Aditya2600/superdocsr/NEWS.md new file mode 100644 index 00000000..8968e029 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/NEWS.md @@ -0,0 +1,28 @@ +# superdocsr 0.1.0 + +First release. + +* `sd_client()` builds an authenticated SuperDocs client. The HTTP transport is + a plain function stored on the client, so tests and vignettes can swap in a + fake one and run with no network and no API key. +* `sd_upload()` loads a `.docx`/`.pdf`/`.html`/`.md`/`.txt`/`.rtf` file as the + active document of a session, streaming the file from disk rather than + holding it in memory. +* `sd_edit()` starts a review-gated AI edit. `approval_mode` defaults to + `"ask_every_time"`; auto-apply has to be asked for by name. +* `sd_wait()` polls `GET /v1/jobs/{job_id}` with exponential backoff and a + wall-clock timeout, and stops on every terminal or paused state instead of + spinning. +* `sd_changes()` returns the proposed changes as a data frame. Batches that + arrive as a JSON-encoded string in `content` are parsed a second time with + `jsonlite::fromJSON()`, which is what keeps every field from reading as `NA`. +* `sd_approve()` approves or denies changes by id, always sending the + top-level `approved` field the endpoint requires. +* `sd_continue()` answers the large-edit continue prompt, so a paused job is + resumed or stopped deliberately rather than waited out. +* `sd_export()` writes the approved document to disk as `docx`, `pdf`, `html`, + `markdown`, or `txt`, and surfaces the `X-Export-Warnings` header. +* `sd_knit()` renders an `.Rmd` with `rmarkdown::render()` and runs the same + upload, edit, review, export workflow over the result. +* `sd_budget()` caps billable operations per client and refuses documents above + an estimated page count, so a loop cannot quietly drain a quota. diff --git a/extensions/Aditya2600/superdocsr/R/approve.R b/extensions/Aditya2600/superdocsr/R/approve.R new file mode 100644 index 00000000..bc82cdfb --- /dev/null +++ b/extensions/Aditya2600/superdocsr/R/approve.R @@ -0,0 +1,108 @@ +# The gate. Nothing in this package writes to a document except through here. + +#' Approve or deny proposed changes +#' +#' Sends a decision for the change ids you name to +#' `POST /v1/chat/{session_id}/approve`. Approved changes are applied +#' atomically; denied ones are discarded, and any `feedback` goes back to the +#' AI, which may then propose a revision. After the call the job resumes, so +#' poll it again with [sd_wait()] -- a review can go several rounds. +#' +#' `change_ids` has no default on purpose. There is no "approve everything" +#' switch in this package, because an unreviewed AI edit to a paper you are +#' about to submit is exactly the failure this workflow exists to prevent. To +#' accept a whole batch, say so explicitly: +#' `sd_approve(job, sd_changes(job)$change_id)`. +#' +#' @param job An [sd_job()] paused on a change review. Check with +#' [sd_awaiting_kind()] first; a continue prompt is answered by +#' [sd_continue()] and rejects an approval with a 409. +#' @param change_ids Character vector of `change_id` values from [sd_changes()]. +#' @param approved `TRUE` to apply them, `FALSE` to discard them. +#' @param feedback Optional note to the AI, most useful alongside +#' `approved = FALSE`: say what to do instead and the next proposal reflects +#' it. +#' +#' @return The refreshed [sd_job()], invisibly. +#' @seealso [sd_deny()] for the denying half. +#' @export +#' @examples +#' \dontrun{ +#' changes <- sd_changes(job) +#' keep <- changes$change_id[changes$operation == "edit"] +#' job <- sd_approve(job, keep) +#' job <- sd_wait(job) +#' } +sd_approve <- function(job, change_ids, approved = TRUE, feedback = NULL) { + if (!inherits(job, "sd_job")) { + stop("`job` must come from sd_edit() or sd_job().", call. = FALSE) + } + if (missing(change_ids)) { + stop( + "Name the changes you are deciding on.\n", + " Cause: superdocsr has no approve-everything default; an unreviewed edit ", + "is the thing this workflow exists to prevent.\n", + " Fix: sd_approve(job, sd_changes(job)$change_id) to accept the whole batch, ", + "or pass the subset you agree with.", + call. = FALSE + ) + } + change_ids <- unique(as.character(change_ids)) + change_ids <- change_ids[!is.na(change_ids) & nzchar(change_ids)] + if (length(change_ids) == 0L) { + stop("`change_ids` is empty; there is nothing to decide.", call. = FALSE) + } + if (!is.logical(approved) || length(approved) != 1L || is.na(approved)) { + stop("`approved` must be TRUE or FALSE.", call. = FALSE) + } + if (is.na(job$job_id)) { + stop( + "This job has no job_id, so there is nothing to approve.\n", + " Cause: a synchronous sd_edit(async = FALSE) turn applies its changes and returns; ", + "there is no review stage to gate.\n", + " Fix: use async = TRUE with approval_mode = \"ask_every_time\" to review edits.", + call. = FALSE + ) + } + if (identical(sd_awaiting_kind(job), "continue_prompt")) { + stop( + "This job is paused on a continue prompt, not a change review.\n", + " Fix: answer it with sd_continue(job, proceed = TRUE/FALSE). ", + "Approving a continue prompt is rejected with a 409.", + call. = FALSE + ) + } + + # `approved` is required at the top level even when every entry in `changes` + # carries its own. Omitting it is a documented 422. + body <- sd_drop_null(list( + job_id = job$job_id, + approved = approved, + feedback = feedback, + changes = lapply(change_ids, function(id) list(change_id = id, approved = approved)) + )) + + req <- httr2::req_body_json( + sd_req(job$client, paste0("/v1/chat/", job$session_id, "/approve"), method = "POST"), + body, + auto_unbox = TRUE + ) + sd_perform(job$client, req) + invisible(sd_job_refresh(job)) +} + +#' Deny proposed changes +#' +#' Shorthand for `sd_approve(approved = FALSE)`. Denying with feedback is the +#' useful shape: the AI reads it and may propose a revision, so keep polling. +#' +#' @inheritParams sd_approve +#' @return The refreshed [sd_job()], invisibly. +#' @export +#' @examples +#' \dontrun{ +#' job <- sd_deny(job, "ch_3", feedback = "Keep the hedge; the effect is not significant.") +#' } +sd_deny <- function(job, change_ids, feedback = NULL) { + sd_approve(job, change_ids, approved = FALSE, feedback = feedback) +} diff --git a/extensions/Aditya2600/superdocsr/R/budget.R b/extensions/Aditya2600/superdocsr/R/budget.R new file mode 100644 index 00000000..0a5b8957 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/R/budget.R @@ -0,0 +1,170 @@ +# Spending controls. Everything that can cost money or spin in a loop passes +# through here first. + +#' Cap what a client may spend +#' +#' A SuperDocs operation is billable; uploads, exports and job polls are not. +#' An edit loop that goes wrong is therefore the only thing that can quietly +#' drain a quota, and this is the object that stops it. +#' +#' Three controls, all enforced before a request leaves your machine: +#' +#' * `max_operations` -- the hard stop. Each [sd_edit()] spends one operation +#' from the client's allowance. When the allowance reaches zero the next edit +#' raises an error instead of sending the request. +#' * `max_pages` -- the size gate. A document above this estimated page count +#' is refused before any edit runs. Uploads are free, so this check costs +#' nothing and happens right after the upload, in [sd_upload()]. +#' * `small_sample` -- the cheap preset for a first run: one operation, three +#' pages, and the fastest model tier. +#' +#' @section How pages are estimated: +#' The API reports `chunks_count` -- the number of addressable blocks +#' (paragraphs, headings, table rows and cells) it parsed out of the file. It +#' does not report a page count, and page count is not knowable from a `.docx` +#' without rendering it. `max_pages` therefore compares against +#' `ceiling(chunks_count / chunks_per_page)`, an estimate. The default of 12 +#' chunks per page suits a double-spaced manuscript; a dense two-column paper +#' runs higher. Tune `chunks_per_page`, or set `max_pages = NULL` to switch the +#' gate off and rely on `max_operations` alone. +#' +#' @param max_operations Maximum billable operations this client may spend. +#' `Inf` removes the cap. +#' @param max_pages Maximum estimated pages a document may have before an edit +#' is allowed. `NULL` disables the gate. +#' @param chunks_per_page Chunks-per-page divisor used for the estimate above. +#' @param small_sample If `TRUE`, apply the cheap preset: at most one +#' operation, at most `small_sample_pages` pages, and `model_tier = "turbo"` +#' unless [sd_edit()] is told otherwise. +#' @param small_sample_pages Page cap used when `small_sample = TRUE`. +#' +#' @return An object of class `sd_budget`. +#' @export +#' @examples +#' sd_budget() +#' sd_budget(small_sample = TRUE) +#' sd_budget(max_operations = 3, max_pages = 40) +sd_budget <- function(max_operations = 25, + max_pages = NULL, + chunks_per_page = 12, + small_sample = FALSE, + small_sample_pages = 3) { + if (!is.numeric(max_operations) || length(max_operations) != 1L || max_operations < 0) { + stop("`max_operations` must be a single non-negative number.", call. = FALSE) + } + if (!is.null(max_pages) && (!is.numeric(max_pages) || length(max_pages) != 1L || max_pages < 1)) { + stop("`max_pages` must be NULL or a single number >= 1.", call. = FALSE) + } + if (!is.numeric(chunks_per_page) || length(chunks_per_page) != 1L || chunks_per_page < 1) { + stop("`chunks_per_page` must be a single number >= 1.", call. = FALSE) + } + + if (isTRUE(small_sample)) { + max_operations <- min(max_operations, 1) + max_pages <- min(max_pages %||% small_sample_pages, small_sample_pages) + } + + state <- new.env(parent = emptyenv()) + state$used <- 0L + + structure( + list( + max_operations = max_operations, + max_pages = max_pages, + chunks_per_page = chunks_per_page, + small_sample = isTRUE(small_sample), + state = state + ), + class = "sd_budget" + ) +} + +#' @export +print.sd_budget <- function(x, ...) { + cat(" budget: ", x$state$used, " of ", + if (is.finite(x$max_operations)) x$max_operations else "unlimited", + " operations used", + if (x$small_sample) " (small sample)" else "", + "\n", + sep = "" + ) + if (!is.null(x$max_pages)) { + cat(" max ", x$max_pages, " estimated pages per document\n", sep = "") + } + invisible(x) +} + +#' Operations spent and left on a client +#' +#' @param client An [sd_client()]. +#' @return A single number: operations spent so far, or operations still +#' available (possibly `Inf`). +#' @export +#' @examples +#' client <- sd_client(api_key = "sk_x", transport = function(req) req) +#' sd_ops_used(client) +#' sd_ops_remaining(client) +sd_ops_used <- function(client) { + sd_stopifnot_client(client) + client$budget$state$used +} + +#' @rdname sd_ops_used +#' @export +sd_ops_remaining <- function(client) { + sd_stopifnot_client(client) + client$budget$max_operations - client$budget$state$used +} + +# Spend n operations or refuse. Called before the request, so a refusal costs +# nothing at all. +sd_spend <- function(client, n = 1L, what = "edit") { + budget <- client$budget + if (budget$state$used + n > budget$max_operations) { + stop( + structure( + class = c("sd_budget_error", "error", "condition"), + list( + message = paste0( + "Operation budget exhausted: this client has spent ", + budget$state$used, " of ", budget$max_operations, + " operations and the ", what, " needs ", n, " more.\n", + " Fix: raise it with sd_client(budget = sd_budget(max_operations = N)), ", + "or start a new client. The cap exists so a loop cannot drain your quota." + ), + call = NULL + ) + ) + ) + } + budget$state$used <- budget$state$used + n + invisible(client) +} + +# Refuse an oversized document. Uploads are free, so this runs after the upload +# and before anything billable. +sd_check_pages <- function(client, chunks_count, filename = "the document") { + budget <- client$budget + if (is.null(budget$max_pages) || is.null(chunks_count) || is.na(chunks_count)) { + return(invisible(NULL)) + } + pages <- ceiling(chunks_count / budget$chunks_per_page) + if (pages > budget$max_pages) { + stop( + structure( + class = c("sd_budget_error", "error", "condition"), + list( + message = paste0( + filename, " parsed into ", chunks_count, " chunks, about ", pages, + " pages at ", budget$chunks_per_page, " chunks per page. ", + "The budget allows ", budget$max_pages, ".\n", + " Fix: raise max_pages in sd_budget(), tune chunks_per_page for this ", + "document's density, or edit a shorter excerpt first." + ), + call = NULL + ) + ) + ) + } + invisible(pages) +} diff --git a/extensions/Aditya2600/superdocsr/R/changes.R b/extensions/Aditya2600/superdocsr/R/changes.R new file mode 100644 index 00000000..3093e47c --- /dev/null +++ b/extensions/Aditya2600/superdocsr/R/changes.R @@ -0,0 +1,242 @@ +# Reading what the AI proposes -- including the second JSON parse that makes +# the fields readable instead of empty. + +#' Read the changes a job proposes +#' +#' Returns one row per proposed change, so you can look at what the AI wants to +#' do before any of it touches the document. +#' +#' @section The second parse: +#' Proposed changes reach a client by two routes, and they are not shaped the +#' same. `metadata$pending_changes` arrives as ordinary JSON objects. The +#' batched form, in `metadata$intermediate_responses`, arrives with its payload +#' in a `content` field that is *itself a JSON-encoded string* -- the same +#' double encoding the SSE `proposed_change_batch` event uses. Parsing the +#' response once leaves `content` as a character scalar, and every field read +#' off it comes back `NULL`. This function calls [jsonlite::fromJSON()] a +#' second time on that string, which is the difference between a populated +#' table and a table of `NA`s. It is the single most common thing integrators +#' miss, so it is handled here rather than left to the caller. +#' +#' Both routes are read and merged, deduplicated by `change_id`. Changes +#' already decided before a reconnect (reported in +#' `metadata$pending_batch_decisions`) are marked in the `decided` column so a +#' review UI can skip them. +#' +#' @param job An [sd_job()]. +#' @param refresh Re-poll the job first. `TRUE` by default, so the table is +#' never stale. +#' +#' @return A data frame of class `sd_changes`, one row per proposed change, +#' with columns `change_id`, `operation` (`edit`, `create` or `delete`), +#' `chunk_id`, `document_id`, `ai_explanation`, `old_html`, `new_html`, +#' `insert_after_chunk_id`, `batch_id`, `batch_total` and `decided`. Zero +#' rows means the AI proposed nothing -- an honest answer, not a failure. +#' @seealso [sd_approve()] to act on the result. +#' @export +#' @examples +#' \dontrun{ +#' changes <- sd_changes(job) +#' changes[, c("change_id", "operation", "ai_explanation")] +#' sd_approve(job, changes$change_id[changes$operation != "delete"]) +#' } +sd_changes <- function(job, refresh = TRUE) { + if (!inherits(job, "sd_job")) { + stop("`job` must come from sd_edit() or sd_job().", call. = FALSE) + } + if (isTRUE(refresh) && !is.na(job$job_id)) { + job <- sd_job_refresh(job) + } + + raw <- sd_raw_pending_changes(job) + decided <- names(job$metadata$pending_batch_decisions %||% list()) + + df <- data.frame( + change_id = sd_field(raw, "change_id"), + operation = sd_field(raw, "operation"), + chunk_id = sd_field(raw, "chunk_id"), + document_id = sd_field(raw, "document_id"), + ai_explanation = sd_field(raw, "ai_explanation"), + old_html = sd_field(raw, "old_html"), + new_html = sd_field(raw, "new_html"), + insert_after_chunk_id = sd_field(raw, "insert_after_chunk_id"), + batch_id = sd_field(raw, "batch_id"), + batch_total = sd_field(raw, "batch_total", as = as.integer), + stringsAsFactors = FALSE + ) + df$decided <- df$change_id %in% decided + + attr(df, "job") <- job + class(df) <- c("sd_changes", "data.frame") + df +} + +SD_CHANGE_COLS <- c( + "change_id", "operation", "chunk_id", "document_id", "ai_explanation", + "old_html", "new_html", "insert_after_chunk_id", "batch_id", + "batch_total", "decided" +) + +# Filtering rows keeps the review printout; selecting columns gives back a +# plain data frame, because a two-column slice is a table, not a review. +#' @export +`[.sd_changes` <- function(x, ...) { + job <- attr(x, "job") + out <- NextMethod() + if (!is.data.frame(out)) { + return(out) + } + if (all(SD_CHANGE_COLS %in% names(out))) { + class(out) <- c("sd_changes", "data.frame") + attr(out, "job") <- job + } else { + class(out) <- "data.frame" + attr(out, "job") <- NULL + } + out +} + +#' @export +print.sd_changes <- function(x, ..., max_rows = 20L, width = 60L) { + job <- attr(x, "job") + if (!all(SD_CHANGE_COLS %in% names(x))) { + return(print.data.frame(x, ...)) + } + if (nrow(x) == 0L) { + cat(" no changes proposed\n") + if (!is.null(job) && identical(sd_awaiting_kind(job), "continue_prompt")) { + cat(" The job is paused on a continue prompt, not a review.\n") + cat(" ", job$metadata$continue_prompt$message %||% "", "\n", sep = "") + cat(" Answer it with sd_continue(job, proceed = TRUE/FALSE).\n") + } + return(invisible(x)) + } + + cat(" ", nrow(x), " proposed change(s)", + if (!is.null(job)) paste0(" on session ", job$session_id) else "", "\n", + sep = "" + ) + shown <- min(nrow(x), max_rows) + for (i in seq_len(shown)) { + cat("\n", i, ". [", x$operation[i], "] ", x$change_id[i], + if (isTRUE(x$decided[i])) " (already decided)" else "", "\n", + sep = "" + ) + if (!is.na(x$ai_explanation[i])) { + cat(" why: ", x$ai_explanation[i], "\n", sep = "") + } + if (!is.na(x$old_html[i])) { + cat(" - ", sd_text_preview(x$old_html[i], width), "\n", sep = "") + } + if (!is.na(x$new_html[i])) { + cat(" + ", sd_text_preview(x$new_html[i], width), "\n", sep = "") + } + } + if (nrow(x) > shown) { + cat("\n... ", nrow(x) - shown, " more. Print with max_rows = Inf.\n", sep = "") + } + cat("\nNothing is applied until sd_approve() names the ids you accept.\n") + invisible(x) +} + +# ---- internals -------------------------------------------------------------- + +# Collect proposed changes from both places a polled job carries them. +sd_raw_pending_changes <- function(job) { + meta <- job$metadata + if (is.null(meta)) { + return(list()) + } + + out <- sd_as_change_list(meta$pending_changes) + + for (event in meta$intermediate_responses %||% list()) { + if (!identical(event$type, "proposed_change_batch")) { + next + } + batch <- sd_parse_content(event$content) + changes <- sd_as_change_list(batch$changes) + # The batch id and total live on the envelope, not on each change. + changes <- lapply(changes, function(change) { + change$batch_id <- change$batch_id %||% batch$batch_id + change$batch_total <- change$batch_total %||% batch$batch_total + change + }) + out <- c(out, changes) + } + + ids <- vapply(out, function(change) as.character(change$change_id %||% NA_character_), character(1)) + out[!duplicated(ids) | is.na(ids)] +} + +# The second jsonlite::fromJSON() call. `content` is a JSON-encoded string +# nested inside JSON that was already parsed once; without this the payload +# stays a character scalar and every field below it reads as NULL. +sd_parse_content <- function(content) { + if (is.null(content)) { + return(list()) + } + if (is.character(content) && length(content) == 1L) { + parsed <- tryCatch( + jsonlite::fromJSON(content, simplifyVector = FALSE), + error = function(e) NULL + ) + if (is.null(parsed)) { + warning( + "A proposed_change_batch carried a `content` field that is not valid JSON; ", + "skipping that batch.", + call. = FALSE + ) + return(list()) + } + return(parsed) + } + if (is.list(content)) { + return(content) + } + list() +} + +# Accept a list of changes, a single change, or either of those still wrapped +# in a JSON string. +sd_as_change_list <- function(x) { + if (is.null(x)) { + return(list()) + } + if (is.character(x) && length(x) == 1L) { + return(sd_as_change_list(sd_parse_content(x))) + } + if (!is.list(x)) { + return(list()) + } + if (!is.null(x$change_id) || !is.null(x$operation)) { + return(list(x)) + } + if (!is.null(x$changes)) { + return(sd_as_change_list(x$changes)) + } + Filter(is.list, unname(x)) +} + +sd_field <- function(records, name, as = as.character) { + if (length(records) == 0L) { + return(as(character(0))) + } + vapply( + records, + function(record) { + value <- record[[name]] + if (is.null(value) || length(value) == 0L) as(NA) else as(value[[1]]) + }, + as(NA) + ) +} + +sd_text_preview <- function(html, width = 60L) { + if (is.na(html)) { + return("") + } + text <- gsub("<[^>]*>", " ", html) + text <- trimws(gsub("[[:space:]]+", " ", text)) + if (nchar(text) > width) paste0(substr(text, 1, width - 3), "...") else text +} diff --git a/extensions/Aditya2600/superdocsr/R/client.R b/extensions/Aditya2600/superdocsr/R/client.R new file mode 100644 index 00000000..c48d4008 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/R/client.R @@ -0,0 +1,270 @@ +# Client construction, request building, and the single place every HTTP call +# in this package goes through. + +#' Create a SuperDocs client +#' +#' Builds the object every other `sd_*()` function takes. It holds the API key, +#' the base URL, the spending [sd_budget()], and the HTTP transport. +#' +#' The transport is a function of one argument -- an `httr2` request -- that +#' returns an `httr2` response. It defaults to [httr2::req_perform()]. Supplying +#' your own is how you run this package with no network and no API key; the +#' test suite and the package vignette both do exactly that. +#' +#' @param api_key SuperDocs API key (the `sk_` key from Settings > API Keys). +#' Read from the `SUPERDOCS_API_KEY` environment variable by default. Never +#' type it into a script that goes into version control. +#' @param base_url Base URL of the API. Defaults to `https://api.superdocs.app`. +#' @param budget An [sd_budget()] controlling how much this client may spend. +#' @param transport `NULL` (perform real requests) or a function taking an +#' `httr2` request and returning an `httr2` response. +#' @param timeout Per-request timeout in seconds. This is the transport +#' timeout, not the wall-clock budget for an AI edit -- that one belongs to +#' [sd_wait()]. +#' @param max_tries Number of attempts for transient failures (429 and 5xx). +#' `Retry-After` is honoured when the server sends it. +#' +#' @return An object of class `sd_client`. +#' @export +#' @examples +#' # Offline client: a transport that answers every request the same way. +#' fake <- function(req) { +#' httr2::response( +#' status_code = 200, +#' headers = list(`Content-Type` = "application/json"), +#' body = charToRaw("[]") +#' ) +#' } +#' client <- sd_client(api_key = "sk_not_a_real_key", transport = fake) +#' client +#' +#' \dontrun{ +#' # Real client, key from the environment. +#' client <- sd_client() +#' sd_verify_key(client) +#' } +sd_client <- function(api_key = Sys.getenv("SUPERDOCS_API_KEY"), + base_url = "https://api.superdocs.app", + budget = sd_budget(), + transport = NULL, + timeout = 60, + max_tries = 3) { + if (!is.character(api_key) || length(api_key) != 1L || is.na(api_key)) { + stop("`api_key` must be a single string.", call. = FALSE) + } + if (!nzchar(api_key) && is.null(transport)) { + stop( + "No API key found.\n", + " Cause: `api_key` is empty and no `SUPERDOCS_API_KEY` is set.\n", + " Fix: Sys.setenv(SUPERDOCS_API_KEY = \"sk_...\") in ~/.Renviron, ", + "or pass `transport =` to run offline.", + call. = FALSE + ) + } + if (!inherits(budget, "sd_budget")) { + stop("`budget` must come from sd_budget().", call. = FALSE) + } + if (!is.null(transport) && !is.function(transport)) { + stop("`transport` must be NULL or a function of one argument.", call. = FALSE) + } + + structure( + list( + api_key = api_key, + base_url = sub("/+$", "", base_url), + budget = budget, + transport = transport %||% httr2::req_perform, + timeout = timeout, + max_tries = max_tries, + user_agent = sprintf( + "superdocsr/%s (R %s.%s)", + utils::packageVersion("superdocsr"), + R.version$major, R.version$minor + ) + ), + class = "sd_client" + ) +} + +#' @export +print.sd_client <- function(x, ...) { + cat("\n") + cat(" base url: ", x$base_url, "\n", sep = "") + cat(" api key: ", sd_mask_key(x$api_key), "\n", sep = "") + cat(" transport: ", if (identical(x$transport, httr2::req_perform)) { + "httr2::req_perform (live)" + } else { + "custom (offline)" + }, "\n", sep = "") + print(x$budget) + invisible(x) +} + +#' Confirm an API key works +#' +#' Calls `GET /v1/sessions`, which the SuperDocs documentation names as the +#' cheapest way to check an `sk_` key. It is not billable and it does not +#' create anything. `GET /v1/users/me` is deliberately not used here: it is +#' web-app-only and returns 401 even for a valid API key. +#' +#' @param client An [sd_client()]. +#' @return `TRUE`, invisibly, if the key is accepted. Errors otherwise. +#' @export +#' @examples +#' \dontrun{ +#' sd_verify_key(sd_client()) +#' } +sd_verify_key <- function(client) { + sd_stopifnot_client(client) + sd_perform(client, sd_req(client, "/v1/sessions")) + invisible(TRUE) +} + +# ---- internals -------------------------------------------------------------- + +`%||%` <- function(x, y) if (is.null(x)) y else x + +sd_mask_key <- function(key) { + if (!nzchar(key)) { + return("") + } + if (nchar(key) <= 8) { + return(strrep("*", nchar(key))) + } + paste0(substr(key, 1, 5), strrep("*", nchar(key) - 8), substr(key, nchar(key) - 2, nchar(key))) +} + +sd_stopifnot_client <- function(client) { + if (!inherits(client, "sd_client")) { + stop("`client` must come from sd_client().", call. = FALSE) + } + invisible(TRUE) +} + +sd_is_transient <- function(resp) { + httr2::resp_status(resp) %in% c(429L, 500L, 502L, 503L, 504L) +} + +# Build a request. Errors are never raised by httr2 itself: sd_perform() maps +# them so every failure in this package speaks with one voice. +sd_req <- function(client, path, method = "GET") { + httr2::request(client$base_url) |> + httr2::req_url_path_append(path) |> + httr2::req_method(method) |> + httr2::req_auth_bearer_token(client$api_key) |> + httr2::req_user_agent(client$user_agent) |> + httr2::req_timeout(client$timeout) |> + httr2::req_retry( + max_tries = client$max_tries, + is_transient = sd_is_transient + ) |> + httr2::req_error(is_error = function(resp) FALSE) +} + +sd_perform <- function(client, req) { + resp <- client$transport(req) + if (!inherits(resp, "httr2_response")) { + stop( + "The transport returned a ", class(resp)[1], ", not an httr2 response.\n", + " Fix: a transport must return httr2::response(...).", + call. = FALSE + ) + } + sd_check_status(resp, req) + resp +} + +# Parse a JSON body without simplification. Keeping lists as lists is what makes +# the second parse in sd_changes() predictable: no silent matrix coercion, no +# fields quietly dropped because one element of a batch lacked them. +sd_json <- function(resp) { + txt <- httr2::resp_body_string(resp) + if (!nzchar(txt)) { + return(list()) + } + jsonlite::fromJSON(txt, simplifyVector = FALSE) +} + +sd_check_status <- function(resp, req = NULL) { + status <- httr2::resp_status(resp) + if (status < 400L) { + return(invisible(resp)) + } + + detail <- sd_error_detail(resp) + fix <- sd_error_fix(status) + msg <- paste0( + "SuperDocs API error ", status, ".\n", + " Cause: ", detail, "\n", + " Fix: ", fix + ) + + cond <- structure( + class = c(paste0("sd_http_", status), "sd_api_error", "error", "condition"), + list( + message = msg, + call = NULL, + status = status, + detail = detail, + url = if (is.null(req)) NA_character_ else req$url + ) + ) + stop(cond) +} + +# The documented error envelope is {"detail": ...}, but `detail` is a string on +# most endpoints, an object on 413, and an array of validation records on 422. +# All three shapes end up as one readable line. +sd_error_detail <- function(resp) { + parsed <- tryCatch(sd_json(resp), error = function(e) NULL) + if (is.null(parsed)) { + body <- tryCatch(httr2::resp_body_string(resp), error = function(e) "") + if (!nzchar(body)) { + return("no response body (the gateway may have rejected the request)") + } + return(substr(gsub("\\s+", " ", body), 1, 300)) + } + detail <- parsed$detail %||% parsed$message_user %||% parsed + sd_flatten_detail(detail) +} + +sd_flatten_detail <- function(detail) { + if (is.character(detail) && length(detail) == 1L) { + return(detail) + } + if (is.list(detail) && !is.null(detail$message_user)) { + return(as.character(detail$message_user)) + } + if (is.list(detail) && !is.null(detail$msg)) { + return(as.character(detail$msg)) + } + if (is.list(detail)) { + parts <- vapply(detail, function(entry) { + if (is.list(entry) && !is.null(entry$msg)) { + loc <- paste(unlist(entry$loc %||% list()), collapse = ".") + if (nzchar(loc)) paste0(loc, ": ", entry$msg) else as.character(entry$msg) + } else { + paste(utils::capture.output(utils::str(entry)), collapse = " ") + } + }, character(1)) + return(paste(parts, collapse = "; ")) + } + paste(as.character(detail), collapse = "; ") +} + +sd_error_fix <- function(status) { + switch(as.character(status), + "400" = "Check the request fields; an empty instruction is the usual cause.", + "401" = "The key was rejected. Check SUPERDOCS_API_KEY, and note that a key revoked in Settings stays revoked.", + "403" = "The account does not own this session or document.", + "404" = "Jobs are deleted one hour after they are created. Re-run the edit rather than polling a stale job_id.", + "409" = "Another job is running on this session, or a review is still pending. Resolve it, then retry.", + "413" = "The payload is over the size cap. Split the document, or export via the pre-signed upload path.", + "415" = "Unsupported file type. Use .pdf, .docx, .txt, .rtf, .md, .html or .htm; convert legacy .doc to .docx first.", + "422" = "The request body did not validate. Session ids may only contain letters, digits, '_', '-' and '.'.", + "429" = "The monthly operation quota is exhausted, or you are being throttled. Wait for Retry-After, or upgrade the plan.", + "500" = "Server error. Retry; if it persists, report it to hello@superdocs.app with the request time.", + "504" = "The edit ran past the 30 minute server cap. Split it into smaller instructions, one section at a time.", + "Check the SuperDocs API documentation at https://docs.superdocs.app." + ) +} diff --git a/extensions/Aditya2600/superdocsr/R/edit.R b/extensions/Aditya2600/superdocsr/R/edit.R new file mode 100644 index 00000000..65a541c3 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/R/edit.R @@ -0,0 +1,230 @@ +# Asking for an edit, and the job object that comes back. + +#' Ask SuperDocs to edit a document +#' +#' Starts an AI edit against the document's session. By default the job pauses +#' before anything is written: `approval_mode = "ask_every_time"` means the AI +#' proposes changes and waits for [sd_approve()]. Auto-apply exists, but you +#' have to ask for it by name. +#' +#' This is the one billable call in the package, so it is the one that spends +#' from the client's [sd_budget()]. The spend happens before the request is +#' sent; if the budget refuses, nothing is sent and nothing is charged. +#' +#' @param document An [sd_document()] from [sd_upload()], or an [sd_job()] / +#' session id if you are continuing earlier work. +#' @param instruction What to change, in plain language. Targeted instructions +#' land better than sweeping ones: "tighten the discussion section" beats +#' "improve the paper". +#' @param async `TRUE` (default) uses `POST /v1/chat/async` and returns +#' immediately with a job to poll. `FALSE` uses the synchronous +#' `POST /v1/chat`, which cannot pause for review and therefore requires +#' `approval_mode = "approve_all"`. +#' @param approval_mode `"ask_every_time"` (default) proposes changes and +#' waits. `"approve_all"` applies them as they are made -- appropriate for +#' throwaway drafts, not for a paper you are about to submit. +#' @param model_tier One of `"core"`, `"turbo"`, `"pro"`, `"max"`, or `NULL` +#' for the server default. `small_sample` budgets default to `"turbo"`. +#' @param thinking_depth One of `"fast"`, `"balanced"`, `"deep"`, or `NULL`. +#' @param response_mode `"compact"` keeps the full document HTML out of every +#' poll and surfaces per-section diffs instead -- worth setting for long +#' manuscripts. `NULL` uses the server default. +#' @param document_html Send document HTML explicitly. Only needed to load or +#' replace the session's document; the server keeps it between turns +#' otherwise. Send it verbatim if you send it at all -- stripping +#' `data-chunk-id` attributes is what breaks targeted editing. +#' @param client An [sd_client()]. Taken from `document` when it has one. +#' +#' @return An object of class `sd_job`. +#' @seealso [sd_wait()] to poll it, [sd_changes()] to read what it proposes. +#' @export +#' @examples +#' \dontrun{ +#' doc <- sd_upload("manuscript.docx") +#' job <- sd_edit(doc, "Tighten the abstract to 150 words without losing the result.") +#' job <- sd_wait(job) +#' sd_changes(job) +#' } +sd_edit <- function(document, + instruction, + async = TRUE, + approval_mode = c("ask_every_time", "approve_all"), + model_tier = NULL, + thinking_depth = NULL, + response_mode = NULL, + document_html = NULL, + client = NULL) { + approval_mode <- match.arg(approval_mode) + client <- sd_client_of(document, client) + session_id <- sd_session_of(document) + + if (!is.character(instruction) || length(instruction) != 1L || !nzchar(trimws(instruction))) { + stop( + "`instruction` must be a single non-empty string.\n", + " Fix: say what should change, e.g. \"rewrite the limitations paragraph in the past tense\".", + call. = FALSE + ) + } + if (!isTRUE(async) && identical(approval_mode, "ask_every_time")) { + stop( + "A synchronous edit cannot pause for review.\n", + " Cause: `async = FALSE` uses POST /v1/chat, which applies changes and returns.\n", + " Fix: keep `async = TRUE` to review changes, or pass ", + "`approval_mode = \"approve_all\"` to accept that the edit lands unreviewed.", + call. = FALSE + ) + } + + model_tier <- model_tier %||% if (client$budget$small_sample) "turbo" else NULL + model_tier <- sd_match_or_null(model_tier, c("core", "turbo", "pro", "max"), "model_tier") + thinking_depth <- sd_match_or_null(thinking_depth, c("fast", "balanced", "deep"), "thinking_depth") + response_mode <- sd_match_or_null(response_mode, c("compact", "full"), "response_mode") + + body <- sd_drop_null(list( + message = instruction, + session_id = session_id, + document_html = document_html, + model_tier = model_tier, + thinking_depth = thinking_depth, + response_mode = response_mode, + approval_mode = if (isTRUE(async)) approval_mode else NULL + )) + + sd_spend(client, 1L, what = "edit") + + path <- if (isTRUE(async)) "/v1/chat/async" else "/v1/chat" + req <- httr2::req_body_json(sd_req(client, path, method = "POST"), body) + parsed <- sd_json(sd_perform(client, req)) + + if (isTRUE(async)) { + sd_job( + client = client, + job_id = parsed$job_id, + session_id = parsed$session_id %||% session_id, + status = parsed$status %||% "pending" + ) + } else { + # One return type for both paths: a synchronous turn is a job that was + # already finished when it arrived. + sd_job( + client = client, + job_id = NA_character_, + session_id = parsed$session_id %||% session_id, + status = "completed", + result = parsed + ) + } +} + +#' A SuperDocs async job +#' +#' Constructor for the object [sd_edit()] returns. Build one yourself to resume +#' polling a `job_id` you persisted -- after an R session restart, for +#' instance. Jobs are deleted one hour after they are created, so a job id +#' older than that is gone rather than merely slow. +#' +#' @param client An [sd_client()]. +#' @param job_id Job identifier. Opaque; treat it as a string. +#' @param session_id Session the job belongs to. +#' @param status One of `pending`, `in_progress`, `awaiting_approval`, +#' `completed`, `failed`, `cancelled`. +#' @param progress Progress percentage reported by the API. +#' @param result Result list, present once the job completes. +#' @param error Error string, present when the job failed. +#' @param metadata Metadata list, carrying `pending_changes`, `awaiting_kind`, +#' `continue_prompt` and `intermediate_responses`. +#' @param job_type Job type reported by the API. +#' +#' @return An object of class `sd_job`. +#' @export +#' @examples +#' client <- sd_client(api_key = "sk_x", transport = function(req) req) +#' sd_job(client, job_id = "550e8400", session_id = "paper-2026") +sd_job <- function(client, + job_id, + session_id, + status = "pending", + progress = NA_real_, + result = NULL, + error = NULL, + metadata = NULL, + job_type = NA_character_) { + sd_stopifnot_client(client) + structure( + list( + client = client, + job_id = if (is.null(job_id)) NA_character_ else as.character(job_id), + session_id = sd_check_session_id(session_id), + status = status, + progress = progress, + result = result, + error = error, + metadata = metadata, + job_type = job_type + ), + class = "sd_job" + ) +} + +#' @export +print.sd_job <- function(x, ...) { + cat("\n") + cat(" job: ", x$job_id, "\n", sep = "") + cat(" session: ", x$session_id, "\n", sep = "") + cat(" status: ", x$status, sep = "") + kind <- sd_awaiting_kind(x) + if (!is.na(kind)) { + cat(" (", kind, ")", sep = "") + } + cat("\n") + if (!is.na(x$progress)) { + cat(" progress: ", x$progress, "%\n", sep = "") + } + if (!is.null(x$error)) { + cat(" error: ", sd_flatten_detail(x$error), "\n", sep = "") + } + n <- length(sd_raw_pending_changes(x)) + if (n > 0L) { + cat(" proposed: ", n, " change(s) -- inspect with sd_changes()\n", sep = "") + } + invisible(x) +} + +#' What a paused job is waiting for +#' +#' `awaiting_approval` covers two different pauses. A change review wants +#' [sd_approve()]; a large-edit continue prompt wants [sd_continue()]. Sending +#' the wrong one is rejected with a 409, so branch on this first. +#' +#' @param job An [sd_job()]. +#' @return `"continue_prompt"`, `"change_review"`, or `NA` when the job is not +#' paused. +#' @export +#' @examples +#' client <- sd_client(api_key = "sk_x", transport = function(req) req) +#' job <- sd_job(client, "j1", "paper-2026", status = "in_progress") +#' sd_awaiting_kind(job) +sd_awaiting_kind <- function(job) { + if (!identical(job$status, "awaiting_approval")) { + return(NA_character_) + } + kind <- job$metadata$awaiting_kind + if (identical(kind, "continue_prompt")) "continue_prompt" else "change_review" +} + +# ---- internals -------------------------------------------------------------- + +sd_drop_null <- function(x) x[!vapply(x, is.null, logical(1))] + +sd_match_or_null <- function(value, choices, arg) { + if (is.null(value)) { + return(NULL) + } + if (!is.character(value) || length(value) != 1L || !value %in% choices) { + stop("`", arg, "` must be one of ", paste0("\"", choices, "\"", collapse = ", "), + ", or NULL.", + call. = FALSE + ) + } + value +} diff --git a/extensions/Aditya2600/superdocsr/R/export.R b/extensions/Aditya2600/superdocsr/R/export.R new file mode 100644 index 00000000..ad54f60a --- /dev/null +++ b/extensions/Aditya2600/superdocsr/R/export.R @@ -0,0 +1,137 @@ +# Getting the approved document back out as a file. + +SD_EXPORT_FORMATS <- c("docx", "pdf", "html", "markdown", "txt") + +SD_EXPORT_EXT <- c( + docx = "docx", pdf = "pdf", html = "html", + markdown = "md", txt = "txt" +) + +#' Export a document to a file +#' +#' Renders the session's current document through `POST /v1/documents/export` +#' and writes the bytes to disk. Exports are not billable, so exporting often +#' is free -- do it after every approved round rather than at the end. +#' +#' What gets exported is what is *in* the document. Changes still awaiting +#' approval are not in it, and this function says so rather than letting a +#' successful-looking export imply otherwise. +#' +#' @param document An [sd_document()], an [sd_job()], or a session id. +#' @param format One of `"docx"` (default), `"pdf"`, `"html"`, `"markdown"`, +#' `"txt"`. RTF is an upload format only; it is not an export target. +#' @param path Where to write the file. Defaults to the document's name with +#' the format's extension, in the working directory. +#' @param options Named list of export options passed through to the API: +#' `paper_size`, `orientation`, `margins`, `custom_margins_inches`, +#' `filename`, `embed_images`, `watermark_text`, `watermark_opacity`. +#' @param client An [sd_client()]. Taken from `document` when it has one. +#' +#' @return The path written, invisibly. Raises an error if the response was +#' empty, so a returned path always means a real file. +#' @export +#' @examples +#' \dontrun{ +#' sd_export(doc, "docx", "manuscript-revised.docx") +#' sd_export(doc, "pdf", "manuscript-revised.pdf", +#' options = list(paper_size = "A4", margins = "narrow") +#' ) +#' } +sd_export <- function(document, + format = "docx", + path = NULL, + options = list(), + client = NULL) { + client <- sd_client_of(document, client) + session_id <- sd_session_of(document) + + if (!is.character(format) || length(format) != 1L || !format %in% SD_EXPORT_FORMATS) { + stop( + "`format` must be one of ", paste0("\"", SD_EXPORT_FORMATS, "\"", collapse = ", "), ".\n", + " Note: RTF is accepted as an upload but is not an export target.", + call. = FALSE + ) + } + if (!is.list(options)) { + stop("`options` must be a named list.", call. = FALSE) + } + + if (inherits(document, "sd_job") && identical(document$status, "awaiting_approval")) { + warning( + "This job is still awaiting approval. Pending changes are not in the document, ", + "so the exported file will not contain them.", + call. = FALSE + ) + } + + path <- path %||% sd_default_export_path(document, format) + + body <- sd_drop_null(list( + session_id = session_id, + format = format, + options = if (length(options)) options else NULL + )) + req <- httr2::req_body_json( + sd_req(client, "/v1/documents/export", method = "POST"), body + ) + resp <- sd_perform(client, req) + + bytes <- httr2::resp_body_raw(resp) + if (length(bytes) == 0L) { + stop( + "The export returned an empty body, so no file was written.\n", + " Fix: check the session still holds a document (sd_upload() it again if not).", + call. = FALSE + ) + } + + dir <- dirname(path) + if (!dir.exists(dir)) { + dir.create(dir, recursive = TRUE) + } + writeBin(bytes, path) + + written <- file.size(path) + if (is.na(written) || written == 0L) { + stop("Wrote '", path, "' but it is empty. The disk write failed.", call. = FALSE) + } + + sd_report_export_warnings(resp) + invisible(path) +} + +# ---- internals -------------------------------------------------------------- + +sd_default_export_path <- function(document, format) { + base <- if (inherits(document, "sd_document") && !is.na(document$filename)) { + tools::file_path_sans_ext(document$filename) + } else { + sd_session_of(document) + } + paste0(base, ".", SD_EXPORT_EXT[[format]]) +} + +# Non-fatal render problems ride back in a base64-encoded JSON header. A silent +# export that dropped an image is exactly the kind of quiet lie worth surfacing. +sd_report_export_warnings <- function(resp) { + header <- httr2::resp_header(resp, "X-Export-Warnings") + if (is.null(header) || !nzchar(header)) { + return(invisible(NULL)) + } + parsed <- tryCatch( + jsonlite::fromJSON(rawToChar(jsonlite::base64_dec(header)), simplifyVector = FALSE), + error = function(e) NULL + ) + if (is.null(parsed) || length(parsed) == 0L) { + return(invisible(NULL)) + } + lines <- vapply(parsed, function(w) { + paste0(" - ", w$code %||% "warning", ": ", w$message %||% "") + }, character(1)) + warning( + "The export completed with non-fatal warnings:\n", + paste(lines, collapse = "\n"), + call. = FALSE + ) + invisible(parsed) +} diff --git a/extensions/Aditya2600/superdocsr/R/knit.R b/extensions/Aditya2600/superdocsr/R/knit.R new file mode 100644 index 00000000..b93e8cc7 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/R/knit.R @@ -0,0 +1,244 @@ +# The knitr entry point: render a paper, then run the whole workflow over it. + +#' Render an R Markdown paper and edit it through SuperDocs +#' +#' Calls [rmarkdown::render()] on `input_rmd`, uploads the rendered file, asks +#' for `instruction`, waits for the proposal, hands it to `review`, applies +#' exactly the decisions `review` returns, and optionally exports the result. +#' +#' The review step is a function you supply, and there is no default that +#' approves anything. `sd_review_console()`, the default, asks a human in an +#' interactive session and refuses to guess in a script. Every change gets an +#' explicit decision -- ids you name are approved, the rest are denied -- so +#' the job never sits blocking the session waiting on a review nobody is going +#' to give. +#' +#' @section Stopping rules: +#' Two loops live here, and both are bounded. `max_rounds` caps how many times +#' a denied-with-feedback round trip may repeat, because the AI is entitled to +#' keep proposing revisions and each one costs. `continue` decides what happens +#' when a large edit pauses to ask whether to keep going; it defaults to +#' `FALSE`, which keeps the work already applied and stops, rather than +#' spending more without being asked. On top of both, the client's +#' [sd_budget()] is the hard ceiling. +#' +#' @param input_rmd Path to the `.Rmd` file. +#' @param instruction What to change, in plain language. +#' @param output_format Passed to [rmarkdown::render()]. `"word_document"` by +#' default, which gives SuperDocs the `.docx` it round-trips with the highest +#' fidelity. +#' @param client An [sd_client()]. +#' @param review Function taking the [sd_changes()] data frame and returning +#' the `change_id` values to approve. Return `character(0)` to deny +#' everything, or `NULL` to stop and leave the review open for a human. +#' @param export_path Where to write the approved document. `NULL` skips the +#' export; nothing is exported when nothing was approved. +#' @param export_format Format for the export. Inferred from `export_path`'s +#' extension when possible, otherwise `"docx"`. +#' @param timeout Seconds to wait on each poll, passed to [sd_wait()]. +#' @param max_rounds Maximum review rounds before giving up and leaving the job +#' for a human. +#' @param continue Answer given to a large-edit continue prompt: `FALSE` +#' (default) stops and keeps what is applied, `TRUE` finishes the edit. +#' @param render_args Extra arguments for [rmarkdown::render()]. +#' @param ... Passed to [sd_edit()], e.g. `model_tier = "max"`. +#' +#' @return An object of class `sd_knit_result`: a list with `rendered`, +#' `document`, `job`, `changes`, `approved`, `denied` and `export_path`. +#' @export +#' @examples +#' \dontrun{ +#' result <- sd_knit( +#' "paper.Rmd", +#' "Tighten the discussion section; keep every citation exactly as written.", +#' export_path = "paper-revised.docx" +#' ) +#' result$changes +#' } +sd_knit <- function(input_rmd, + instruction, + output_format = "word_document", + client = sd_client(), + review = sd_review_console, + export_path = NULL, + export_format = NULL, + timeout = 300, + max_rounds = 3L, + continue = FALSE, + render_args = list(), + ...) { + if (!requireNamespace("rmarkdown", quietly = TRUE)) { + stop( + "sd_knit() needs the rmarkdown package.\n Fix: install.packages(\"rmarkdown\").", + call. = FALSE + ) + } + if (!file.exists(input_rmd)) { + stop("No file at '", input_rmd, "'.", call. = FALSE) + } + if (!is.function(review)) { + stop("`review` must be a function of one argument (the sd_changes table).", call. = FALSE) + } + + rendered <- do.call( + rmarkdown::render, + c( + list(input = input_rmd, output_format = output_format, quiet = TRUE, envir = new.env()), + render_args + ) + ) + + document <- sd_upload(rendered, client) + job <- sd_wait(sd_edit(document, instruction, client = client, ...), timeout = timeout) + + approved <- character(0) + denied <- character(0) + changes <- sd_changes(job, refresh = FALSE) + + for (round in seq_len(max_rounds)) { + if (!identical(job$status, "awaiting_approval")) { + break + } + + if (identical(sd_awaiting_kind(job), "continue_prompt")) { + prompt <- job$metadata$continue_prompt + message( + "Large edit paused: ", prompt$message %||% "the edit did not finish in one turn.", + "\n Answering continue = ", continue, "." + ) + job <- sd_wait(sd_continue(job, proceed = continue), timeout = timeout) + next + } + + changes <- sd_changes(job, refresh = FALSE) + if (nrow(changes) == 0L) { + break + } + + decision <- review(changes) + if (is.null(decision)) { + message( + "Review left open after round ", round, + ". The job stays in awaiting_approval; decide with sd_approve() / sd_deny()." + ) + break + } + + yes <- intersect(as.character(decision), changes$change_id) + no <- setdiff(changes$change_id, yes) + + if (length(yes)) { + job <- sd_approve(job, yes) + approved <- c(approved, yes) + } + if (length(no)) { + job <- sd_deny(job, no) + denied <- c(denied, no) + } + job <- sd_wait(job, timeout = timeout) + } + + if (identical(job$status, "awaiting_approval") && length(approved) + length(denied) > 0L) { + message( + "Still awaiting approval after ", max_rounds, + " round(s). Left for you rather than looping further." + ) + } + + written <- NULL + if (!is.null(export_path)) { + if (length(approved) == 0L) { + message("Nothing was approved, so nothing was exported.") + } else { + export_format <- export_format %||% sd_format_from_path(export_path) + written <- sd_export(document, format = export_format, path = export_path) + } + } + + structure( + list( + rendered = rendered, + document = document, + job = job, + changes = changes, + approved = approved, + denied = denied, + export_path = written + ), + class = "sd_knit_result" + ) +} + +#' @export +print.sd_knit_result <- function(x, ...) { + cat("\n") + cat(" rendered: ", x$rendered, "\n", sep = "") + cat(" session: ", x$document$session_id, "\n", sep = "") + cat(" status: ", x$job$status, "\n", sep = "") + cat(" proposed: ", nrow(x$changes), "\n", sep = "") + cat(" approved: ", length(x$approved), "\n", sep = "") + cat(" denied: ", length(x$denied), "\n", sep = "") + if (is.null(x$export_path)) { + cat(" export: none\n") + } else { + cat(" export: ", x$export_path, "\n", sep = "") + } + if (identical(x$job$status, "awaiting_approval")) { + cat("\n Review still open. Next: sd_changes(result$job), then sd_approve()/sd_deny().\n") + } + invisible(x) +} + +#' Review helpers for sd_knit() +#' +#' `sd_review_console()` prints each proposed change and asks a human which to +#' approve. In a non-interactive session it refuses to decide and raises an +#' error, because the alternative -- guessing -- is the behaviour this package +#' exists to avoid. +#' +#' `sd_review_none()` returns `NULL`, which runs the workflow up to the review +#' and stops there, leaving the job open for a person. It is the right choice +#' for a scheduled script that should prepare work rather than commit it. +#' +#' @param changes The [sd_changes()] data frame. +#' @return A character vector of `change_id` values to approve, +#' `character(0)` to deny everything, or `NULL` to leave the review open. +#' @export +#' @examples +#' client <- sd_client(api_key = "sk_x", transport = function(req) req) +#' job <- sd_job(client, "j1", "paper-2026") +#' sd_review_none(sd_changes(job, refresh = FALSE)) +sd_review_console <- function(changes) { + if (!interactive()) { + stop( + "sd_review_console() will not approve changes in a non-interactive session.\n", + " Fix: pass review = sd_review_none to stop at the review, or supply your own ", + "function, e.g. review = function(ch) ch$change_id[ch$operation == \"edit\"].", + call. = FALSE + ) + } + print(changes) + cat("\nEnter the numbers to approve (e.g. 1 3), 'all', or blank to deny all: ") + answer <- trimws(readline()) + if (!nzchar(answer)) { + return(character(0)) + } + if (identical(tolower(answer), "all")) { + return(changes$change_id) + } + picks <- suppressWarnings(as.integer(strsplit(answer, "[^0-9]+")[[1]])) + picks <- picks[!is.na(picks) & picks >= 1L & picks <= nrow(changes)] + changes$change_id[picks] +} + +#' @rdname sd_review_console +#' @export +sd_review_none <- function(changes) NULL + +# ---- internals -------------------------------------------------------------- + +sd_format_from_path <- function(path) { + ext <- tolower(tools::file_ext(path)) + hit <- names(SD_EXPORT_EXT)[match(ext, SD_EXPORT_EXT)] + if (is.na(hit)) "docx" else hit +} diff --git a/extensions/Aditya2600/superdocsr/R/superdocsr-package.R b/extensions/Aditya2600/superdocsr/R/superdocsr-package.R new file mode 100644 index 00000000..4534ac19 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/R/superdocsr-package.R @@ -0,0 +1,25 @@ +#' superdocsr: review-gated AI document editing for research papers +#' +#' A researcher-facing client for the SuperDocs document editing API. The +#' package covers one workflow end to end: render a paper, upload it, ask for +#' a targeted AI edit, inspect what the AI proposes, approve the parts you +#' agree with, and export the approved document. +#' +#' The whole point of the package is the gate in the middle. `sd_edit()` +#' defaults to `approval_mode = "ask_every_time"`, so the job pauses and waits; +#' nothing reaches the document until `sd_approve()` names the change ids you +#' accept. There is no function that approves everything for you. +#' +#' @section Cost controls: +#' Every client carries an [sd_budget()]. It caps how many billable operations +#' the client may spend and refuses documents above an estimated page count, so +#' a loop that goes wrong stops instead of draining a quota. `small_sample = +#' TRUE` is the cheap preset for a first run. +#' +#' @section Testing without a key: +#' The HTTP transport is a plain function stored on the client. Pass your own +#' to `sd_client(transport = )` and the package never touches the network -- +#' that is how this package's own test suite and vignette run. +#' +#' @keywords internal +"_PACKAGE" diff --git a/extensions/Aditya2600/superdocsr/R/upload.R b/extensions/Aditya2600/superdocsr/R/upload.R new file mode 100644 index 00000000..81e836a4 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/R/upload.R @@ -0,0 +1,205 @@ +# Getting a rendered paper into a SuperDocs session. + +# The formats the API accepts as an editable document. Anything else comes back +# as a 415, so it is cheaper to say so here. +SD_UPLOAD_EXTS <- c("pdf", "docx", "txt", "rtf", "md", "html", "htm") + +#' Upload a document into a SuperDocs session +#' +#' Sends a file to `POST /v1/documents/upload` and loads it as the session's +#' active editable document. The file is streamed from disk by `curl`, not read +#' into R first, so a large manuscript does not have to fit in memory twice. +#' +#' Uploading is not a billable operation. The page gate from [sd_budget()] is +#' applied here, right after the parse, because that is the first moment the +#' real chunk count is known and it is still before anything costs money. +#' +#' @param path Path to the file. One of `.pdf`, `.docx`, `.txt`, `.rtf`, `.md`, +#' `.html`, `.htm`. Legacy binary `.doc` is not accepted -- convert it to +#' `.docx` first. +#' @param client An [sd_client()]. +#' @param session_id Session to load the document into. Generated if omitted. +#' May contain only letters, digits, `_`, `-` and `.`, up to 256 characters. +#' @param open_mode `"replace"` (the default) swaps the session's focused +#' document. `"new_focused"` opens the file as an extra document and focuses +#' it; `"background"` opens it without stealing focus. +#' +#' @return An object of class `sd_document`. +#' @export +#' @examples +#' \dontrun{ +#' client <- sd_client() +#' doc <- sd_upload("manuscript.docx", client) +#' doc +#' } +sd_upload <- function(path, + client = sd_client(), + session_id = NULL, + open_mode = c("replace", "new_focused", "background")) { + sd_stopifnot_client(client) + open_mode <- match.arg(open_mode) + + if (!is.character(path) || length(path) != 1L || is.na(path)) { + stop("`path` must be a single file path.", call. = FALSE) + } + if (!file.exists(path)) { + stop("No file at '", path, "'.\n Fix: check the path, or render the source first with sd_knit().", + call. = FALSE + ) + } + ext <- tolower(tools::file_ext(path)) + if (!ext %in% SD_UPLOAD_EXTS) { + stop( + "SuperDocs does not accept '.", ext, "' as an editable document.\n", + " Fix: use one of ", paste0(".", SD_UPLOAD_EXTS, collapse = ", "), + if (identical(ext, "doc")) ". Convert legacy .doc to .docx first." else ".", + call. = FALSE + ) + } + + session_id <- sd_session_id_or_new(session_id) + + body <- list( + file = curl::form_file(path), + session_id = session_id + ) + if (!identical(open_mode, "replace")) { + body$open_mode <- open_mode + } + + req <- sd_req(client, "/v1/documents/upload", method = "POST") + req <- do.call(httr2::req_body_multipart, c(list(req), body)) + parsed <- sd_json(sd_perform(client, req)) + + doc <- sd_document( + client = client, + session_id = parsed$session_id %||% session_id, + filename = parsed$filename %||% basename(path), + chunks_count = sd_as_int(parsed$chunks_count), + version_id = parsed$version_id %||% NA_character_, + html = parsed$html %||% NA_character_, + page_setup = parsed$page_setup, + source_path = path + ) + + sd_check_pages(client, doc$chunks_count, filename = doc$filename) + doc +} + +#' A document open in a SuperDocs session +#' +#' Constructor for the object [sd_upload()] returns. You rarely call this +#' directly; use it when you already hold a session id -- to resume work after +#' a crash, say -- and want the other verbs to accept it. +#' +#' @param client An [sd_client()]. +#' @param session_id Session holding the document. +#' @param filename Name reported by the API. +#' @param chunks_count Number of addressable blocks the parser found. +#' @param version_id Version identifier reported by the API. +#' @param html Parsed document HTML, when the API returned it. +#' @param page_setup Page geometry list, or `NULL` for formats that carry none. +#' @param source_path Local file the document came from, if any. +#' +#' @return An object of class `sd_document`. +#' @export +#' @examples +#' client <- sd_client(api_key = "sk_x", transport = function(req) req) +#' sd_document(client, session_id = "paper-2026") +sd_document <- function(client, + session_id, + filename = NA_character_, + chunks_count = NA_integer_, + version_id = NA_character_, + html = NA_character_, + page_setup = NULL, + source_path = NA_character_) { + sd_stopifnot_client(client) + structure( + list( + client = client, + session_id = sd_check_session_id(session_id), + filename = filename, + chunks_count = chunks_count, + version_id = version_id, + html = html, + page_setup = page_setup, + source_path = source_path + ), + class = "sd_document" + ) +} + +#' @export +print.sd_document <- function(x, ...) { + cat("\n") + cat(" session: ", x$session_id, "\n", sep = "") + cat(" file: ", x$filename, "\n", sep = "") + if (!is.na(x$chunks_count)) { + cat(" chunks: ", x$chunks_count, "\n", sep = "") + } + if (!is.null(x$page_setup$orientation)) { + cat(" page: ", x$page_setup$orientation, " ", + x$page_setup$width_in %||% "?", "in x ", + x$page_setup$height_in %||% "?", "in\n", + sep = "" + ) + } + invisible(x) +} + +# ---- internals -------------------------------------------------------------- + +sd_check_session_id <- function(session_id) { + if (!is.character(session_id) || length(session_id) != 1L || is.na(session_id)) { + stop("`session_id` must be a single string.", call. = FALSE) + } + # Checked in two parts: TRE caps a {n,m} bound at 255, and the limit is 256. + if (!grepl("^[A-Za-z0-9_.-]+$", session_id) || nchar(session_id) > 256L) { + stop( + "'", session_id, "' is not a valid session id.\n", + " Fix: use only letters, digits, '_', '-' and '.', up to 256 characters. ", + "Spaces and slashes are rejected by the API with a 422.", + call. = FALSE + ) + } + session_id +} + +sd_session_id_or_new <- function(session_id) { + if (is.null(session_id)) { + return(sprintf( + "superdocsr-%s-%04d", + format(Sys.time(), "%Y%m%d-%H%M%S"), + sample.int(9999, 1) + )) + } + sd_check_session_id(session_id) +} + +sd_as_int <- function(x) { + if (is.null(x)) NA_integer_ else as.integer(x) +} + +# Every verb downstream of upload accepts a document, a job, or a bare session +# id. One accessor, so none of them has to care. +sd_session_of <- function(x) { + if (inherits(x, c("sd_document", "sd_job"))) { + return(x$session_id) + } + if (is.character(x) && length(x) == 1L) { + return(sd_check_session_id(x)) + } + stop("Expected an sd_document, an sd_job, or a session id string.", call. = FALSE) +} + +sd_client_of <- function(x, client = NULL) { + if (!is.null(client)) { + sd_stopifnot_client(client) + return(client) + } + if (inherits(x, c("sd_document", "sd_job"))) { + return(x$client) + } + stop("No client available.\n Fix: pass `client = sd_client()`.", call. = FALSE) +} diff --git a/extensions/Aditya2600/superdocsr/R/wait.R b/extensions/Aditya2600/superdocsr/R/wait.R new file mode 100644 index 00000000..0f22353f --- /dev/null +++ b/extensions/Aditya2600/superdocsr/R/wait.R @@ -0,0 +1,206 @@ +# Polling a job to a stopping point. + +#' Wait for a job to reach a stopping point +#' +#' Polls `GET /v1/jobs/{job_id}` until the job stops needing to be polled, and +#' returns it. Four things count as a stopping point: `completed`, `failed`, +#' `cancelled`, and `awaiting_approval` -- the last one being the review gate, +#' which is a result, not a delay. +#' +#' An edit on a real paper takes anywhere from ten seconds to several minutes +#' with nothing visible happening in between. That is the API working, not a +#' crash. The default `timeout` of 300 seconds suits a multi-section edit; a +#' full-document restructure of a long manuscript wants more. The server caps +#' any single turn at 30 minutes, and jobs are deleted an hour after creation, +#' so there is no point waiting longer than that. +#' +#' @param job An [sd_job()]. +#' @param timeout Wall-clock seconds to wait before giving up. +#' @param backoff If `TRUE` (default), the gap between polls grows by half each +#' time, up to `max_interval`. If `FALSE`, poll every `interval` seconds. +#' @param interval First gap between polls, in seconds. +#' @param max_interval Longest gap between polls, in seconds. +#' @param verbose Report progress while waiting. Defaults to on in an +#' interactive session, off in a script. +#' +#' @return The updated [sd_job()]. Raises `sd_job_failed` if the job failed or +#' was cancelled, and `sd_timeout_error` if `timeout` runs out first -- the +#' job id stays valid in that case, so you can call `sd_wait()` again. +#' @export +#' @examples +#' \dontrun{ +#' job <- sd_wait(sd_edit(doc, "Rewrite the conclusion."), timeout = 600) +#' sd_awaiting_kind(job) +#' } +sd_wait <- function(job, + timeout = 300, + backoff = TRUE, + interval = 2, + max_interval = 30, + verbose = interactive()) { + if (!inherits(job, "sd_job")) { + stop("`job` must come from sd_edit() or sd_job().", call. = FALSE) + } + if (!is.numeric(timeout) || length(timeout) != 1L || timeout <= 0) { + stop("`timeout` must be a single positive number of seconds.", call. = FALSE) + } + + started <- Sys.time() + wait <- interval + announced <- 0 + + repeat { + if (sd_is_settled(job)) { + return(sd_stop_if_failed(job)) + } + if (is.na(job$job_id)) { + stop( + "This job has no job_id, so there is nothing to poll.\n", + " Cause: it came from a synchronous sd_edit(async = FALSE) call.\n", + " Fix: read its result directly; it finished before it was returned.", + call. = FALSE + ) + } + + elapsed <- as.numeric(difftime(Sys.time(), started, units = "secs")) + if (elapsed > timeout) { + stop( + structure( + class = c("sd_timeout_error", "error", "condition"), + list( + message = paste0( + "Job ", job$job_id, " was still '", job$status, "' after ", + round(elapsed), "s (timeout ", timeout, "s).\n", + " Note: this is a client-side give-up, not a server failure. The job ", + "keeps running.\n", + " Fix: call sd_wait(job, timeout = ", max(600, timeout * 2), + ") to keep waiting, or split the instruction into smaller edits. ", + "Jobs are deleted one hour after they are created." + ), + call = NULL, + job = job + ) + ) + ) + } + + if (isTRUE(verbose) && elapsed - announced >= 30) { + message(" still ", job$status, "... (", round(elapsed), "s elapsed)") + announced <- elapsed + } + + Sys.sleep(min(wait, max(0, timeout - elapsed))) + if (isTRUE(backoff)) { + wait <- min(wait * 1.5, max_interval) + } + job <- sd_job_refresh(job) + } +} + +#' Answer a large-edit continue prompt +#' +#' A very large edit applies what it can, keeps that work, and pauses to ask +#' whether to carry on. That pause reports `status = "awaiting_approval"` with +#' [sd_awaiting_kind()] of `"continue_prompt"`, and it is answered here rather +#' than with [sd_approve()] -- sending an approval to a continue prompt is +#' rejected with a 409. +#' +#' Continuing costs more operations, which is exactly why this is a call you +#' make rather than something [sd_wait()] does for you. +#' +#' @param job An [sd_job()] paused on a continue prompt. +#' @param proceed `TRUE` to finish the rest of the edit, `FALSE` to stop and +#' keep everything applied so far. +#' +#' @return The refreshed [sd_job()]. +#' @export +#' @examples +#' \dontrun{ +#' if (identical(sd_awaiting_kind(job), "continue_prompt")) { +#' message(job$metadata$continue_prompt$message) +#' job <- sd_continue(job, proceed = TRUE) +#' } +#' } +sd_continue <- function(job, proceed = TRUE) { + if (!inherits(job, "sd_job")) { + stop("`job` must come from sd_edit() or sd_job().", call. = FALSE) + } + if (!identical(sd_awaiting_kind(job), "continue_prompt")) { + stop( + "This job is not paused on a continue prompt (status '", job$status, "').\n", + " Fix: branch on sd_awaiting_kind(job); a 'change_review' pause is answered ", + "with sd_approve().", + call. = FALSE + ) + } + + body <- list(job_id = job$job_id, `continue` = isTRUE(proceed)) + req <- httr2::req_body_json( + sd_req(job$client, paste0("/v1/chat/", job$session_id, "/continue"), method = "POST"), + body + ) + sd_perform(job$client, req) + sd_job_refresh(job) +} + +#' Cancel a running job +#' +#' Stops the AI mid-edit. Changes already applied stay in the document; pending +#' ones are discarded. Only `pending` and `in_progress` jobs can be cancelled. +#' +#' @param job An [sd_job()]. +#' @return The refreshed [sd_job()]. +#' @export +#' @examples +#' \dontrun{ +#' sd_cancel(job) +#' } +sd_cancel <- function(job) { + if (!inherits(job, "sd_job")) { + stop("`job` must come from sd_edit() or sd_job().", call. = FALSE) + } + req <- sd_req(job$client, paste0("/v1/jobs/", job$job_id, "/cancel"), method = "POST") + sd_perform(job$client, req) + sd_job_refresh(job) +} + +# ---- internals -------------------------------------------------------------- + +SD_SETTLED <- c("completed", "failed", "cancelled", "awaiting_approval") + +sd_is_settled <- function(job) job$status %in% SD_SETTLED + +sd_stop_if_failed <- function(job) { + if (job$status %in% c("failed", "cancelled")) { + stop( + structure( + class = c("sd_job_failed", "error", "condition"), + list( + message = paste0( + "Job ", job$job_id, " ", job$status, ".\n", + " Cause: ", if (is.null(job$error)) "the API reported no error text" else sd_flatten_detail(job$error), "\n", + " Fix: nothing was approved, so the document is untouched. Re-run the ", + "edit, or split the instruction if it timed out server-side." + ), + call = NULL, + job = job + ) + ) + ) + } + job +} + +sd_job_refresh <- function(job) { + parsed <- sd_json(sd_perform( + job$client, + sd_req(job$client, paste0("/v1/jobs/", job$job_id)) + )) + job$status <- parsed$status %||% job$status + job$progress <- if (is.null(parsed$progress)) NA_real_ else as.numeric(parsed$progress) + job$result <- parsed$result + job$error <- parsed$error + job$metadata <- parsed$metadata + job$job_type <- parsed$job_type %||% job$job_type + job +} diff --git a/extensions/Aditya2600/superdocsr/README.md b/extensions/Aditya2600/superdocsr/README.md new file mode 100644 index 00000000..70f215aa --- /dev/null +++ b/extensions/Aditya2600/superdocsr/README.md @@ -0,0 +1,190 @@ +# superdocsr + +Review-gated AI editing for research papers, from R. + +Render your paper, ask [SuperDocs](https://use.superdocs.app) for a targeted +edit, look at exactly what it proposes, approve the parts you agree with, and +export the result. Nothing reaches the document until you name the changes you +accept. + +Built for the SuperDocs engineer task by Aditya Meshram. + +```r +library(superdocsr) + +doc <- sd_upload("manuscript.docx") +job <- sd_wait(sd_edit(doc, "Replace vague intensifiers with the reported statistics.")) + +changes <- sd_changes(job) +changes +#> 2 proposed change(s) on session paper-2026 +#> +#> 1. [edit] ch_1 +#> why: Replaced an intensifier with the reported statistic +#> - The effect was very significant. +#> + The effect was significant (p = 0.03). +#> +#> 2. [delete] ch_2 +#> why: Removed a causal claim the data does not support +#> - This clearly proves the mechanism. +#> +#> Nothing is applied until sd_approve() names the ids you accept. + +job <- sd_approve(job, "ch_1") +job <- sd_deny(job, "ch_2", feedback = "Keep the sentence; soften it instead of cutting it.") +sd_export(sd_wait(job), "docx", "manuscript-revised.docx") +``` + +Or the whole thing in one call, from the `.Rmd`: + +```r +sd_knit( + "paper.Rmd", + "Tighten the discussion; keep every citation and number exactly as written.", + export_path = "paper-revised.docx" +) +``` + +## Install + +```r +# install.packages("remotes") +remotes::install_github("superdocsapp/superdocs-builds", subdir = "extensions/superdocsr") +``` + +Then put your key somewhere that is not your code. `usethis::edit_r_environ()` +opens the right file: + +``` +SUPERDOCS_API_KEY=sk_your_key_here +``` + +Get a key from Settings > API Keys at [use.superdocs.app](https://use.superdocs.app). + +## Why the gate + +An AI that edits your manuscript is useful right up to the moment it changes +something you did not want changed. In a paper that is not cosmetic: a +rewritten hedge becomes an overclaim, a "tightened" result becomes a different +result, and neither is obvious three drafts later. + +So `sd_edit()` defaults to `approval_mode = "ask_every_time"`, and +`sd_approve()` has no default for `change_ids`. There is no +approve-everything switch. To accept a whole batch you write it out: + +```r +sd_approve(job, sd_changes(job)$change_id) +``` + +`sd_edit(async = FALSE)` cannot pause for review, so it refuses to run unless +you also pass `approval_mode = "approve_all"` -- the unreviewed path exists, +but you have to ask for it twice. + +## The API surface + +| Function | What it does | Endpoint | +| --- | --- | --- | +| `sd_client()` | Key, base URL, budget, transport | -- | +| `sd_verify_key()` | Confirm an `sk_` key works | `GET /v1/sessions` | +| `sd_upload()` | Load a file as the session's document | `POST /v1/documents/upload` | +| `sd_edit()` | Start a review-gated AI edit | `POST /v1/chat/async` | +| `sd_wait()` | Poll with backoff to a stopping point | `GET /v1/jobs/{job_id}` | +| `sd_changes()` | Read the proposed changes | (from the job payload) | +| `sd_approve()` / `sd_deny()` | Decide, change by change | `POST /v1/chat/{session_id}/approve` | +| `sd_continue()` | Answer a large-edit continue prompt | `POST /v1/chat/{session_id}/continue` | +| `sd_cancel()` | Stop a running job | `POST /v1/jobs/{job_id}/cancel` | +| `sd_export()` | Write the document to disk | `POST /v1/documents/export` | +| `sd_knit()` | Render an `.Rmd`, then all of the above | -- | + +Endpoints and payload shapes follow the published OpenAPI spec at +`https://docs.superdocs.app/openapi.json` and the guides at +[docs.superdocs.app](https://docs.superdocs.app). Nothing here is guessed. + +## Three things that bite integrators + +**Proposed changes need a second JSON parse.** Batched changes arrive inside +`metadata.intermediate_responses` with the payload in a `content` field that is +itself a JSON-encoded string. Parse once and `content` is a character scalar; +every field you read off it is empty. `sd_changes()` makes the second +`jsonlite::fromJSON()` call, which is the difference between a populated table +and a table of `NA`s. There is a regression test for exactly this. + +**The approve endpoint needs a top-level `approved` field**, even when every +entry in `changes` carries its own. Omitting it is a 422 with an unhelpful +message. `sd_approve()` always sends it. + +**Silence is not failure.** An edit can run for minutes with no visible +progress. `sd_wait()` backs off rather than hammering, and its timeout is a +client-side give-up: the job keeps running server-side and `sd_wait(job)` +resumes it. Jobs are deleted an hour after creation, which is the real +deadline. + +## Not spending money by accident + +Every client carries a budget, enforced before requests leave your machine. + +```r +client <- sd_client(budget = sd_budget(max_operations = 5, max_pages = 40)) + +# The cheap first run: one operation, three pages, fastest model tier. +client <- sd_client(budget = sd_budget(small_sample = TRUE)) +``` + +* `max_operations` is the hard stop. Only `sd_edit()` is billable; uploads, + polls and exports are free. +* `max_pages` refuses an oversized document after the free upload and before + any billable edit. The API reports chunks rather than pages, so the gate + compares against `ceiling(chunks_count / chunks_per_page)` -- an estimate, + default 12 chunks per page, tunable per document. It is an estimate on + purpose and the error message says so. +* `sd_knit()` bounds its own review loop with `max_rounds`, and answers a + large-edit continue prompt with `continue = FALSE` by default: it keeps the + work already applied rather than spending more unasked. + +## Tests, with no key and no network + +The HTTP transport is a plain function on the client, so the whole suite runs +against recorded payloads: + +```r +fake <- function(req) httr2::response(200L, body = charToRaw('{"status":"ok"}')) +client <- sd_client(api_key = "sk_x", transport = fake) +``` + +``` +$ R CMD check --as-cran superdocsr_0.1.0.tar.gz +Status: OK +``` + +The vignette runs the same way -- every output in +`vignette("research-paper-workflow")` is real, produced offline. + +The tests cover the behaviours worth doubting rather than the mocks: that a +budget refusal never reaches the network, that the double-parsed batch +populates every field, that a top-level `approved` is always sent, that +backoff genuinely lengthens the gap between polls, that a timeout is reported +as a client give-up, that a review returning nothing leaves the job open +instead of approving, and that an export writes real bytes before it claims +success. + +## Known limits + +* Documents over ~20 MB need the pre-signed upload path + (`POST /v1/uploads`), which this package does not wrap yet. `sd_upload()` + streams from disk and handles everything below that ceiling; above it the + API returns a 413 and the error message points at the fix. +* SSE streaming is not wrapped. `sd_wait()` polls, which is the documented + alternative and is enough for a script. +* Multi-document sessions are not modelled; one document per session. +* `max_pages` is an estimate derived from chunk count, not a rendered page + count. Nothing client-side can know the true page count of a `.docx` + without rendering it, and the package says estimate rather than pretending + otherwise. + +## License + +MIT. See `LICENSE`. + +--- + +Built by Aditya Meshram for the SuperDocs task. diff --git a/extensions/Aditya2600/superdocsr/man/sd_approve.Rd b/extensions/Aditya2600/superdocsr/man/sd_approve.Rd new file mode 100644 index 00000000..d4b068d0 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_approve.Rd @@ -0,0 +1,49 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/approve.R +\name{sd_approve} +\alias{sd_approve} +\title{Approve or deny proposed changes} +\usage{ +sd_approve(job, change_ids, approved = TRUE, feedback = NULL) +} +\arguments{ +\item{job}{An \code{\link[=sd_job]{sd_job()}} paused on a change review. Check with +\code{\link[=sd_awaiting_kind]{sd_awaiting_kind()}} first; a continue prompt is answered by +\code{\link[=sd_continue]{sd_continue()}} and rejects an approval with a 409.} + +\item{change_ids}{Character vector of \code{change_id} values from \code{\link[=sd_changes]{sd_changes()}}.} + +\item{approved}{\code{TRUE} to apply them, \code{FALSE} to discard them.} + +\item{feedback}{Optional note to the AI, most useful alongside +\code{approved = FALSE}: say what to do instead and the next proposal reflects +it.} +} +\value{ +The refreshed \code{\link[=sd_job]{sd_job()}}, invisibly. +} +\description{ +Sends a decision for the change ids you name to +\code{POST /v1/chat/{session_id}/approve}. Approved changes are applied +atomically; denied ones are discarded, and any \code{feedback} goes back to the +AI, which may then propose a revision. After the call the job resumes, so +poll it again with \code{\link[=sd_wait]{sd_wait()}} -- a review can go several rounds. +} +\details{ +\code{change_ids} has no default on purpose. There is no "approve everything" +switch in this package, because an unreviewed AI edit to a paper you are +about to submit is exactly the failure this workflow exists to prevent. To +accept a whole batch, say so explicitly: +\code{sd_approve(job, sd_changes(job)$change_id)}. +} +\examples{ +\dontrun{ +changes <- sd_changes(job) +keep <- changes$change_id[changes$operation == "edit"] +job <- sd_approve(job, keep) +job <- sd_wait(job) +} +} +\seealso{ +\code{\link[=sd_deny]{sd_deny()}} for the denying half. +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_awaiting_kind.Rd b/extensions/Aditya2600/superdocsr/man/sd_awaiting_kind.Rd new file mode 100644 index 00000000..ca984a18 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_awaiting_kind.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/edit.R +\name{sd_awaiting_kind} +\alias{sd_awaiting_kind} +\title{What a paused job is waiting for} +\usage{ +sd_awaiting_kind(job) +} +\arguments{ +\item{job}{An \code{\link[=sd_job]{sd_job()}}.} +} +\value{ +\code{"continue_prompt"}, \code{"change_review"}, or \code{NA} when the job is not +paused. +} +\description{ +\code{awaiting_approval} covers two different pauses. A change review wants +\code{\link[=sd_approve]{sd_approve()}}; a large-edit continue prompt wants \code{\link[=sd_continue]{sd_continue()}}. Sending +the wrong one is rejected with a 409, so branch on this first. +} +\examples{ +client <- sd_client(api_key = "sk_x", transport = function(req) req) +job <- sd_job(client, "j1", "paper-2026", status = "in_progress") +sd_awaiting_kind(job) +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_budget.Rd b/extensions/Aditya2600/superdocsr/man/sd_budget.Rd new file mode 100644 index 00000000..3eca3f2f --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_budget.Rd @@ -0,0 +1,67 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/budget.R +\name{sd_budget} +\alias{sd_budget} +\title{Cap what a client may spend} +\usage{ +sd_budget( + max_operations = 25, + max_pages = NULL, + chunks_per_page = 12, + small_sample = FALSE, + small_sample_pages = 3 +) +} +\arguments{ +\item{max_operations}{Maximum billable operations this client may spend. +\code{Inf} removes the cap.} + +\item{max_pages}{Maximum estimated pages a document may have before an edit +is allowed. \code{NULL} disables the gate.} + +\item{chunks_per_page}{Chunks-per-page divisor used for the estimate above.} + +\item{small_sample}{If \code{TRUE}, apply the cheap preset: at most one +operation, at most \code{small_sample_pages} pages, and \code{model_tier = "turbo"} +unless \code{\link[=sd_edit]{sd_edit()}} is told otherwise.} + +\item{small_sample_pages}{Page cap used when \code{small_sample = TRUE}.} +} +\value{ +An object of class \code{sd_budget}. +} +\description{ +A SuperDocs operation is billable; uploads, exports and job polls are not. +An edit loop that goes wrong is therefore the only thing that can quietly +drain a quota, and this is the object that stops it. +} +\details{ +Three controls, all enforced before a request leaves your machine: +\itemize{ +\item \code{max_operations} -- the hard stop. Each \code{\link[=sd_edit]{sd_edit()}} spends one operation +from the client's allowance. When the allowance reaches zero the next edit +raises an error instead of sending the request. +\item \code{max_pages} -- the size gate. A document above this estimated page count +is refused before any edit runs. Uploads are free, so this check costs +nothing and happens right after the upload, in \code{\link[=sd_upload]{sd_upload()}}. +\item \code{small_sample} -- the cheap preset for a first run: one operation, three +pages, and the fastest model tier. +} +} +\section{How pages are estimated}{ + +The API reports \code{chunks_count} -- the number of addressable blocks +(paragraphs, headings, table rows and cells) it parsed out of the file. It +does not report a page count, and page count is not knowable from a \code{.docx} +without rendering it. \code{max_pages} therefore compares against +\code{ceiling(chunks_count / chunks_per_page)}, an estimate. The default of 12 +chunks per page suits a double-spaced manuscript; a dense two-column paper +runs higher. Tune \code{chunks_per_page}, or set \code{max_pages = NULL} to switch the +gate off and rely on \code{max_operations} alone. +} + +\examples{ +sd_budget() +sd_budget(small_sample = TRUE) +sd_budget(max_operations = 3, max_pages = 40) +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_cancel.Rd b/extensions/Aditya2600/superdocsr/man/sd_cancel.Rd new file mode 100644 index 00000000..53b0ad7b --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_cancel.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/wait.R +\name{sd_cancel} +\alias{sd_cancel} +\title{Cancel a running job} +\usage{ +sd_cancel(job) +} +\arguments{ +\item{job}{An \code{\link[=sd_job]{sd_job()}}.} +} +\value{ +The refreshed \code{\link[=sd_job]{sd_job()}}. +} +\description{ +Stops the AI mid-edit. Changes already applied stay in the document; pending +ones are discarded. Only \code{pending} and \code{in_progress} jobs can be cancelled. +} +\examples{ +\dontrun{ +sd_cancel(job) +} +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_changes.Rd b/extensions/Aditya2600/superdocsr/man/sd_changes.Rd new file mode 100644 index 00000000..a303074c --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_changes.Rd @@ -0,0 +1,54 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/changes.R +\name{sd_changes} +\alias{sd_changes} +\title{Read the changes a job proposes} +\usage{ +sd_changes(job, refresh = TRUE) +} +\arguments{ +\item{job}{An \code{\link[=sd_job]{sd_job()}}.} + +\item{refresh}{Re-poll the job first. \code{TRUE} by default, so the table is +never stale.} +} +\value{ +A data frame of class \code{sd_changes}, one row per proposed change, +with columns \code{change_id}, \code{operation} (\code{edit}, \code{create} or \code{delete}), +\code{chunk_id}, \code{document_id}, \code{ai_explanation}, \code{old_html}, \code{new_html}, +\code{insert_after_chunk_id}, \code{batch_id}, \code{batch_total} and \code{decided}. Zero +rows means the AI proposed nothing -- an honest answer, not a failure. +} +\description{ +Returns one row per proposed change, so you can look at what the AI wants to +do before any of it touches the document. +} +\section{The second parse}{ + +Proposed changes reach a client by two routes, and they are not shaped the +same. \code{metadata$pending_changes} arrives as ordinary JSON objects. The +batched form, in \code{metadata$intermediate_responses}, arrives with its payload +in a \code{content} field that is \emph{itself a JSON-encoded string} -- the same +double encoding the SSE \code{proposed_change_batch} event uses. Parsing the +response once leaves \code{content} as a character scalar, and every field read +off it comes back \code{NULL}. This function calls \code{\link[jsonlite:fromJSON]{jsonlite::fromJSON()}} a +second time on that string, which is the difference between a populated +table and a table of \code{NA}s. It is the single most common thing integrators +miss, so it is handled here rather than left to the caller. + +Both routes are read and merged, deduplicated by \code{change_id}. Changes +already decided before a reconnect (reported in +\code{metadata$pending_batch_decisions}) are marked in the \code{decided} column so a +review UI can skip them. +} + +\examples{ +\dontrun{ +changes <- sd_changes(job) +changes[, c("change_id", "operation", "ai_explanation")] +sd_approve(job, changes$change_id[changes$operation != "delete"]) +} +} +\seealso{ +\code{\link[=sd_approve]{sd_approve()}} to act on the result. +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_client.Rd b/extensions/Aditya2600/superdocsr/man/sd_client.Rd new file mode 100644 index 00000000..89f3c43b --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_client.Rd @@ -0,0 +1,65 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/client.R +\name{sd_client} +\alias{sd_client} +\title{Create a SuperDocs client} +\usage{ +sd_client( + api_key = Sys.getenv("SUPERDOCS_API_KEY"), + base_url = "https://api.superdocs.app", + budget = sd_budget(), + transport = NULL, + timeout = 60, + max_tries = 3 +) +} +\arguments{ +\item{api_key}{SuperDocs API key (the \code{sk_} key from Settings > API Keys). +Read from the \code{SUPERDOCS_API_KEY} environment variable by default. Never +type it into a script that goes into version control.} + +\item{base_url}{Base URL of the API. Defaults to \verb{https://api.superdocs.app}.} + +\item{budget}{An \code{\link[=sd_budget]{sd_budget()}} controlling how much this client may spend.} + +\item{transport}{\code{NULL} (perform real requests) or a function taking an +\code{httr2} request and returning an \code{httr2} response.} + +\item{timeout}{Per-request timeout in seconds. This is the transport +timeout, not the wall-clock budget for an AI edit -- that one belongs to +\code{\link[=sd_wait]{sd_wait()}}.} + +\item{max_tries}{Number of attempts for transient failures (429 and 5xx). +\code{Retry-After} is honoured when the server sends it.} +} +\value{ +An object of class \code{sd_client}. +} +\description{ +Builds the object every other \verb{sd_*()} function takes. It holds the API key, +the base URL, the spending \code{\link[=sd_budget]{sd_budget()}}, and the HTTP transport. +} +\details{ +The transport is a function of one argument -- an \code{httr2} request -- that +returns an \code{httr2} response. It defaults to \code{\link[httr2:req_perform]{httr2::req_perform()}}. Supplying +your own is how you run this package with no network and no API key; the +test suite and the package vignette both do exactly that. +} +\examples{ +# Offline client: a transport that answers every request the same way. +fake <- function(req) { + httr2::response( + status_code = 200, + headers = list(`Content-Type` = "application/json"), + body = charToRaw("[]") + ) +} +client <- sd_client(api_key = "sk_not_a_real_key", transport = fake) +client + +\dontrun{ +# Real client, key from the environment. +client <- sd_client() +sd_verify_key(client) +} +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_continue.Rd b/extensions/Aditya2600/superdocsr/man/sd_continue.Rd new file mode 100644 index 00000000..287ff927 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_continue.Rd @@ -0,0 +1,36 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/wait.R +\name{sd_continue} +\alias{sd_continue} +\title{Answer a large-edit continue prompt} +\usage{ +sd_continue(job, proceed = TRUE) +} +\arguments{ +\item{job}{An \code{\link[=sd_job]{sd_job()}} paused on a continue prompt.} + +\item{proceed}{\code{TRUE} to finish the rest of the edit, \code{FALSE} to stop and +keep everything applied so far.} +} +\value{ +The refreshed \code{\link[=sd_job]{sd_job()}}. +} +\description{ +A very large edit applies what it can, keeps that work, and pauses to ask +whether to carry on. That pause reports \code{status = "awaiting_approval"} with +\code{\link[=sd_awaiting_kind]{sd_awaiting_kind()}} of \code{"continue_prompt"}, and it is answered here rather +than with \code{\link[=sd_approve]{sd_approve()}} -- sending an approval to a continue prompt is +rejected with a 409. +} +\details{ +Continuing costs more operations, which is exactly why this is a call you +make rather than something \code{\link[=sd_wait]{sd_wait()}} does for you. +} +\examples{ +\dontrun{ +if (identical(sd_awaiting_kind(job), "continue_prompt")) { + message(job$metadata$continue_prompt$message) + job <- sd_continue(job, proceed = TRUE) +} +} +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_deny.Rd b/extensions/Aditya2600/superdocsr/man/sd_deny.Rd new file mode 100644 index 00000000..b7eb3494 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_deny.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/approve.R +\name{sd_deny} +\alias{sd_deny} +\title{Deny proposed changes} +\usage{ +sd_deny(job, change_ids, feedback = NULL) +} +\arguments{ +\item{job}{An \code{\link[=sd_job]{sd_job()}} paused on a change review. Check with +\code{\link[=sd_awaiting_kind]{sd_awaiting_kind()}} first; a continue prompt is answered by +\code{\link[=sd_continue]{sd_continue()}} and rejects an approval with a 409.} + +\item{change_ids}{Character vector of \code{change_id} values from \code{\link[=sd_changes]{sd_changes()}}.} + +\item{feedback}{Optional note to the AI, most useful alongside +\code{approved = FALSE}: say what to do instead and the next proposal reflects +it.} +} +\value{ +The refreshed \code{\link[=sd_job]{sd_job()}}, invisibly. +} +\description{ +Shorthand for \code{sd_approve(approved = FALSE)}. Denying with feedback is the +useful shape: the AI reads it and may propose a revision, so keep polling. +} +\examples{ +\dontrun{ +job <- sd_deny(job, "ch_3", feedback = "Keep the hedge; the effect is not significant.") +} +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_document.Rd b/extensions/Aditya2600/superdocsr/man/sd_document.Rd new file mode 100644 index 00000000..0987d35c --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_document.Rd @@ -0,0 +1,46 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/upload.R +\name{sd_document} +\alias{sd_document} +\title{A document open in a SuperDocs session} +\usage{ +sd_document( + client, + session_id, + filename = NA_character_, + chunks_count = NA_integer_, + version_id = NA_character_, + html = NA_character_, + page_setup = NULL, + source_path = NA_character_ +) +} +\arguments{ +\item{client}{An \code{\link[=sd_client]{sd_client()}}.} + +\item{session_id}{Session holding the document.} + +\item{filename}{Name reported by the API.} + +\item{chunks_count}{Number of addressable blocks the parser found.} + +\item{version_id}{Version identifier reported by the API.} + +\item{html}{Parsed document HTML, when the API returned it.} + +\item{page_setup}{Page geometry list, or \code{NULL} for formats that carry none.} + +\item{source_path}{Local file the document came from, if any.} +} +\value{ +An object of class \code{sd_document}. +} +\description{ +Constructor for the object \code{\link[=sd_upload]{sd_upload()}} returns. You rarely call this +directly; use it when you already hold a session id -- to resume work after +a crash, say -- and want the other verbs to accept it. +} +\examples{ +client <- sd_client(api_key = "sk_x", transport = function(req) req) +sd_document(client, session_id = "paper-2026") +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_edit.Rd b/extensions/Aditya2600/superdocsr/man/sd_edit.Rd new file mode 100644 index 00000000..31bc84f5 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_edit.Rd @@ -0,0 +1,76 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/edit.R +\name{sd_edit} +\alias{sd_edit} +\title{Ask SuperDocs to edit a document} +\usage{ +sd_edit( + document, + instruction, + async = TRUE, + approval_mode = c("ask_every_time", "approve_all"), + model_tier = NULL, + thinking_depth = NULL, + response_mode = NULL, + document_html = NULL, + client = NULL +) +} +\arguments{ +\item{document}{An \code{\link[=sd_document]{sd_document()}} from \code{\link[=sd_upload]{sd_upload()}}, or an \code{\link[=sd_job]{sd_job()}} / +session id if you are continuing earlier work.} + +\item{instruction}{What to change, in plain language. Targeted instructions +land better than sweeping ones: "tighten the discussion section" beats +"improve the paper".} + +\item{async}{\code{TRUE} (default) uses \code{POST /v1/chat/async} and returns +immediately with a job to poll. \code{FALSE} uses the synchronous +\code{POST /v1/chat}, which cannot pause for review and therefore requires +\code{approval_mode = "approve_all"}.} + +\item{approval_mode}{\code{"ask_every_time"} (default) proposes changes and +waits. \code{"approve_all"} applies them as they are made -- appropriate for +throwaway drafts, not for a paper you are about to submit.} + +\item{model_tier}{One of \code{"core"}, \code{"turbo"}, \code{"pro"}, \code{"max"}, or \code{NULL} +for the server default. \code{small_sample} budgets default to \code{"turbo"}.} + +\item{thinking_depth}{One of \code{"fast"}, \code{"balanced"}, \code{"deep"}, or \code{NULL}.} + +\item{response_mode}{\code{"compact"} keeps the full document HTML out of every +poll and surfaces per-section diffs instead -- worth setting for long +manuscripts. \code{NULL} uses the server default.} + +\item{document_html}{Send document HTML explicitly. Only needed to load or +replace the session's document; the server keeps it between turns +otherwise. Send it verbatim if you send it at all -- stripping +\code{data-chunk-id} attributes is what breaks targeted editing.} + +\item{client}{An \code{\link[=sd_client]{sd_client()}}. Taken from \code{document} when it has one.} +} +\value{ +An object of class \code{sd_job}. +} +\description{ +Starts an AI edit against the document's session. By default the job pauses +before anything is written: \code{approval_mode = "ask_every_time"} means the AI +proposes changes and waits for \code{\link[=sd_approve]{sd_approve()}}. Auto-apply exists, but you +have to ask for it by name. +} +\details{ +This is the one billable call in the package, so it is the one that spends +from the client's \code{\link[=sd_budget]{sd_budget()}}. The spend happens before the request is +sent; if the budget refuses, nothing is sent and nothing is charged. +} +\examples{ +\dontrun{ +doc <- sd_upload("manuscript.docx") +job <- sd_edit(doc, "Tighten the abstract to 150 words without losing the result.") +job <- sd_wait(job) +sd_changes(job) +} +} +\seealso{ +\code{\link[=sd_wait]{sd_wait()}} to poll it, \code{\link[=sd_changes]{sd_changes()}} to read what it proposes. +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_export.Rd b/extensions/Aditya2600/superdocsr/man/sd_export.Rd new file mode 100644 index 00000000..fde6142b --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_export.Rd @@ -0,0 +1,51 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/export.R +\name{sd_export} +\alias{sd_export} +\title{Export a document to a file} +\usage{ +sd_export( + document, + format = "docx", + path = NULL, + options = list(), + client = NULL +) +} +\arguments{ +\item{document}{An \code{\link[=sd_document]{sd_document()}}, an \code{\link[=sd_job]{sd_job()}}, or a session id.} + +\item{format}{One of \code{"docx"} (default), \code{"pdf"}, \code{"html"}, \code{"markdown"}, +\code{"txt"}. RTF is an upload format only; it is not an export target.} + +\item{path}{Where to write the file. Defaults to the document's name with +the format's extension, in the working directory.} + +\item{options}{Named list of export options passed through to the API: +\code{paper_size}, \code{orientation}, \code{margins}, \code{custom_margins_inches}, +\code{filename}, \code{embed_images}, \code{watermark_text}, \code{watermark_opacity}.} + +\item{client}{An \code{\link[=sd_client]{sd_client()}}. Taken from \code{document} when it has one.} +} +\value{ +The path written, invisibly. Raises an error if the response was +empty, so a returned path always means a real file. +} +\description{ +Renders the session's current document through \code{POST /v1/documents/export} +and writes the bytes to disk. Exports are not billable, so exporting often +is free -- do it after every approved round rather than at the end. +} +\details{ +What gets exported is what is \emph{in} the document. Changes still awaiting +approval are not in it, and this function says so rather than letting a +successful-looking export imply otherwise. +} +\examples{ +\dontrun{ +sd_export(doc, "docx", "manuscript-revised.docx") +sd_export(doc, "pdf", "manuscript-revised.pdf", + options = list(paper_size = "A4", margins = "narrow") +) +} +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_job.Rd b/extensions/Aditya2600/superdocsr/man/sd_job.Rd new file mode 100644 index 00000000..e0711f88 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_job.Rd @@ -0,0 +1,52 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/edit.R +\name{sd_job} +\alias{sd_job} +\title{A SuperDocs async job} +\usage{ +sd_job( + client, + job_id, + session_id, + status = "pending", + progress = NA_real_, + result = NULL, + error = NULL, + metadata = NULL, + job_type = NA_character_ +) +} +\arguments{ +\item{client}{An \code{\link[=sd_client]{sd_client()}}.} + +\item{job_id}{Job identifier. Opaque; treat it as a string.} + +\item{session_id}{Session the job belongs to.} + +\item{status}{One of \code{pending}, \code{in_progress}, \code{awaiting_approval}, +\code{completed}, \code{failed}, \code{cancelled}.} + +\item{progress}{Progress percentage reported by the API.} + +\item{result}{Result list, present once the job completes.} + +\item{error}{Error string, present when the job failed.} + +\item{metadata}{Metadata list, carrying \code{pending_changes}, \code{awaiting_kind}, +\code{continue_prompt} and \code{intermediate_responses}.} + +\item{job_type}{Job type reported by the API.} +} +\value{ +An object of class \code{sd_job}. +} +\description{ +Constructor for the object \code{\link[=sd_edit]{sd_edit()}} returns. Build one yourself to resume +polling a \code{job_id} you persisted -- after an R session restart, for +instance. Jobs are deleted one hour after they are created, so a job id +older than that is gone rather than merely slow. +} +\examples{ +client <- sd_client(api_key = "sk_x", transport = function(req) req) +sd_job(client, job_id = "550e8400", session_id = "paper-2026") +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_knit.Rd b/extensions/Aditya2600/superdocsr/man/sd_knit.Rd new file mode 100644 index 00000000..8a4c96ab --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_knit.Rd @@ -0,0 +1,92 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/knit.R +\name{sd_knit} +\alias{sd_knit} +\title{Render an R Markdown paper and edit it through SuperDocs} +\usage{ +sd_knit( + input_rmd, + instruction, + output_format = "word_document", + client = sd_client(), + review = sd_review_console, + export_path = NULL, + export_format = NULL, + timeout = 300, + max_rounds = 3L, + continue = FALSE, + render_args = list(), + ... +) +} +\arguments{ +\item{input_rmd}{Path to the \code{.Rmd} file.} + +\item{instruction}{What to change, in plain language.} + +\item{output_format}{Passed to \code{\link[rmarkdown:render]{rmarkdown::render()}}. \code{"word_document"} by +default, which gives SuperDocs the \code{.docx} it round-trips with the highest +fidelity.} + +\item{client}{An \code{\link[=sd_client]{sd_client()}}.} + +\item{review}{Function taking the \code{\link[=sd_changes]{sd_changes()}} data frame and returning +the \code{change_id} values to approve. Return \code{character(0)} to deny +everything, or \code{NULL} to stop and leave the review open for a human.} + +\item{export_path}{Where to write the approved document. \code{NULL} skips the +export; nothing is exported when nothing was approved.} + +\item{export_format}{Format for the export. Inferred from \code{export_path}'s +extension when possible, otherwise \code{"docx"}.} + +\item{timeout}{Seconds to wait on each poll, passed to \code{\link[=sd_wait]{sd_wait()}}.} + +\item{max_rounds}{Maximum review rounds before giving up and leaving the job +for a human.} + +\item{continue}{Answer given to a large-edit continue prompt: \code{FALSE} +(default) stops and keeps what is applied, \code{TRUE} finishes the edit.} + +\item{render_args}{Extra arguments for \code{\link[rmarkdown:render]{rmarkdown::render()}}.} + +\item{...}{Passed to \code{\link[=sd_edit]{sd_edit()}}, e.g. \code{model_tier = "max"}.} +} +\value{ +An object of class \code{sd_knit_result}: a list with \code{rendered}, +\code{document}, \code{job}, \code{changes}, \code{approved}, \code{denied} and \code{export_path}. +} +\description{ +Calls \code{\link[rmarkdown:render]{rmarkdown::render()}} on \code{input_rmd}, uploads the rendered file, asks +for \code{instruction}, waits for the proposal, hands it to \code{review}, applies +exactly the decisions \code{review} returns, and optionally exports the result. +} +\details{ +The review step is a function you supply, and there is no default that +approves anything. \code{sd_review_console()}, the default, asks a human in an +interactive session and refuses to guess in a script. Every change gets an +explicit decision -- ids you name are approved, the rest are denied -- so +the job never sits blocking the session waiting on a review nobody is going +to give. +} +\section{Stopping rules}{ + +Two loops live here, and both are bounded. \code{max_rounds} caps how many times +a denied-with-feedback round trip may repeat, because the AI is entitled to +keep proposing revisions and each one costs. \code{continue} decides what happens +when a large edit pauses to ask whether to keep going; it defaults to +\code{FALSE}, which keeps the work already applied and stops, rather than +spending more without being asked. On top of both, the client's +\code{\link[=sd_budget]{sd_budget()}} is the hard ceiling. +} + +\examples{ +\dontrun{ +result <- sd_knit( + "paper.Rmd", + "Tighten the discussion section; keep every citation exactly as written.", + export_path = "paper-revised.docx" +) +result$changes +} +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_ops_used.Rd b/extensions/Aditya2600/superdocsr/man/sd_ops_used.Rd new file mode 100644 index 00000000..cf2a746b --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_ops_used.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/budget.R +\name{sd_ops_used} +\alias{sd_ops_used} +\alias{sd_ops_remaining} +\title{Operations spent and left on a client} +\usage{ +sd_ops_used(client) + +sd_ops_remaining(client) +} +\arguments{ +\item{client}{An \code{\link[=sd_client]{sd_client()}}.} +} +\value{ +A single number: operations spent so far, or operations still +available (possibly \code{Inf}). +} +\description{ +Operations spent and left on a client +} +\examples{ +client <- sd_client(api_key = "sk_x", transport = function(req) req) +sd_ops_used(client) +sd_ops_remaining(client) +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_review_console.Rd b/extensions/Aditya2600/superdocsr/man/sd_review_console.Rd new file mode 100644 index 00000000..d652a35f --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_review_console.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/knit.R +\name{sd_review_console} +\alias{sd_review_console} +\alias{sd_review_none} +\title{Review helpers for sd_knit()} +\usage{ +sd_review_console(changes) + +sd_review_none(changes) +} +\arguments{ +\item{changes}{The \code{\link[=sd_changes]{sd_changes()}} data frame.} +} +\value{ +A character vector of \code{change_id} values to approve, +\code{character(0)} to deny everything, or \code{NULL} to leave the review open. +} +\description{ +\code{sd_review_console()} prints each proposed change and asks a human which to +approve. In a non-interactive session it refuses to decide and raises an +error, because the alternative -- guessing -- is the behaviour this package +exists to avoid. +} +\details{ +\code{sd_review_none()} returns \code{NULL}, which runs the workflow up to the review +and stops there, leaving the job open for a person. It is the right choice +for a scheduled script that should prepare work rather than commit it. +} +\examples{ +client <- sd_client(api_key = "sk_x", transport = function(req) req) +job <- sd_job(client, "j1", "paper-2026") +sd_review_none(sd_changes(job, refresh = FALSE)) +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_upload.Rd b/extensions/Aditya2600/superdocsr/man/sd_upload.Rd new file mode 100644 index 00000000..1daf8137 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_upload.Rd @@ -0,0 +1,47 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/upload.R +\name{sd_upload} +\alias{sd_upload} +\title{Upload a document into a SuperDocs session} +\usage{ +sd_upload( + path, + client = sd_client(), + session_id = NULL, + open_mode = c("replace", "new_focused", "background") +) +} +\arguments{ +\item{path}{Path to the file. One of \code{.pdf}, \code{.docx}, \code{.txt}, \code{.rtf}, \code{.md}, +\code{.html}, \code{.htm}. Legacy binary \code{.doc} is not accepted -- convert it to +\code{.docx} first.} + +\item{client}{An \code{\link[=sd_client]{sd_client()}}.} + +\item{session_id}{Session to load the document into. Generated if omitted. +May contain only letters, digits, \verb{_}, \code{-} and \code{.}, up to 256 characters.} + +\item{open_mode}{\code{"replace"} (the default) swaps the session's focused +document. \code{"new_focused"} opens the file as an extra document and focuses +it; \code{"background"} opens it without stealing focus.} +} +\value{ +An object of class \code{sd_document}. +} +\description{ +Sends a file to \code{POST /v1/documents/upload} and loads it as the session's +active editable document. The file is streamed from disk by \code{curl}, not read +into R first, so a large manuscript does not have to fit in memory twice. +} +\details{ +Uploading is not a billable operation. The page gate from \code{\link[=sd_budget]{sd_budget()}} is +applied here, right after the parse, because that is the first moment the +real chunk count is known and it is still before anything costs money. +} +\examples{ +\dontrun{ +client <- sd_client() +doc <- sd_upload("manuscript.docx", client) +doc +} +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_verify_key.Rd b/extensions/Aditya2600/superdocsr/man/sd_verify_key.Rd new file mode 100644 index 00000000..b949e558 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_verify_key.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/client.R +\name{sd_verify_key} +\alias{sd_verify_key} +\title{Confirm an API key works} +\usage{ +sd_verify_key(client) +} +\arguments{ +\item{client}{An \code{\link[=sd_client]{sd_client()}}.} +} +\value{ +\code{TRUE}, invisibly, if the key is accepted. Errors otherwise. +} +\description{ +Calls \code{GET /v1/sessions}, which the SuperDocs documentation names as the +cheapest way to check an \code{sk_} key. It is not billable and it does not +create anything. \code{GET /v1/users/me} is deliberately not used here: it is +web-app-only and returns 401 even for a valid API key. +} +\examples{ +\dontrun{ +sd_verify_key(sd_client()) +} +} diff --git a/extensions/Aditya2600/superdocsr/man/sd_wait.Rd b/extensions/Aditya2600/superdocsr/man/sd_wait.Rd new file mode 100644 index 00000000..837eecaf --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/sd_wait.Rd @@ -0,0 +1,55 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/wait.R +\name{sd_wait} +\alias{sd_wait} +\title{Wait for a job to reach a stopping point} +\usage{ +sd_wait( + job, + timeout = 300, + backoff = TRUE, + interval = 2, + max_interval = 30, + verbose = interactive() +) +} +\arguments{ +\item{job}{An \code{\link[=sd_job]{sd_job()}}.} + +\item{timeout}{Wall-clock seconds to wait before giving up.} + +\item{backoff}{If \code{TRUE} (default), the gap between polls grows by half each +time, up to \code{max_interval}. If \code{FALSE}, poll every \code{interval} seconds.} + +\item{interval}{First gap between polls, in seconds.} + +\item{max_interval}{Longest gap between polls, in seconds.} + +\item{verbose}{Report progress while waiting. Defaults to on in an +interactive session, off in a script.} +} +\value{ +The updated \code{\link[=sd_job]{sd_job()}}. Raises \code{sd_job_failed} if the job failed or +was cancelled, and \code{sd_timeout_error} if \code{timeout} runs out first -- the +job id stays valid in that case, so you can call \code{sd_wait()} again. +} +\description{ +Polls \code{GET /v1/jobs/{job_id}} until the job stops needing to be polled, and +returns it. Four things count as a stopping point: \code{completed}, \code{failed}, +\code{cancelled}, and \code{awaiting_approval} -- the last one being the review gate, +which is a result, not a delay. +} +\details{ +An edit on a real paper takes anywhere from ten seconds to several minutes +with nothing visible happening in between. That is the API working, not a +crash. The default \code{timeout} of 300 seconds suits a multi-section edit; a +full-document restructure of a long manuscript wants more. The server caps +any single turn at 30 minutes, and jobs are deleted an hour after creation, +so there is no point waiting longer than that. +} +\examples{ +\dontrun{ +job <- sd_wait(sd_edit(doc, "Rewrite the conclusion."), timeout = 600) +sd_awaiting_kind(job) +} +} diff --git a/extensions/Aditya2600/superdocsr/man/superdocsr-package.Rd b/extensions/Aditya2600/superdocsr/man/superdocsr-package.Rd new file mode 100644 index 00000000..abc02881 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/man/superdocsr-package.Rd @@ -0,0 +1,51 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/superdocsr-package.R +\docType{package} +\name{superdocsr-package} +\alias{superdocsr} +\alias{superdocsr-package} +\title{superdocsr: review-gated AI document editing for research papers} +\description{ +A researcher-facing client for the SuperDocs document editing API. The +package covers one workflow end to end: render a paper, upload it, ask for +a targeted AI edit, inspect what the AI proposes, approve the parts you +agree with, and export the approved document. +} +\details{ +The whole point of the package is the gate in the middle. \code{sd_edit()} +defaults to \code{approval_mode = "ask_every_time"}, so the job pauses and waits; +nothing reaches the document until \code{sd_approve()} names the change ids you +accept. There is no function that approves everything for you. +} +\section{Cost controls}{ + +Every client carries an \code{\link[=sd_budget]{sd_budget()}}. It caps how many billable operations +the client may spend and refuses documents above an estimated page count, so +a loop that goes wrong stops instead of draining a quota. \code{small_sample = TRUE} is the cheap preset for a first run. +} + +\section{Testing without a key}{ + +The HTTP transport is a plain function stored on the client. Pass your own +to \code{sd_client(transport = )} and the package never touches the network -- +that is how this package's own test suite and vignette run. +} + +\seealso{ +Useful links: +\itemize{ + \item \url{https://github.com/superdocsapp/superdocs-builds} + \item Report bugs at \url{https://github.com/superdocsapp/superdocs-builds/issues} +} + +} +\author{ +\strong{Maintainer}: Aditya Meshram \email{adime2500@gmail.com} + +Authors: +\itemize{ + \item Aditya Meshram \email{adime2500@gmail.com} +} + +} +\keyword{internal} diff --git a/extensions/Aditya2600/superdocsr/tests/testthat.R b/extensions/Aditya2600/superdocsr/tests/testthat.R new file mode 100644 index 00000000..62266ea8 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/tests/testthat.R @@ -0,0 +1,4 @@ +library(testthat) +library(superdocsr) + +test_check("superdocsr") diff --git a/extensions/Aditya2600/superdocsr/tests/testthat/helper-fake-api.R b/extensions/Aditya2600/superdocsr/tests/testthat/helper-fake-api.R new file mode 100644 index 00000000..c8c835b4 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/tests/testthat/helper-fake-api.R @@ -0,0 +1,200 @@ +# A fake SuperDocs API. Every test in this suite runs against it, so the suite +# needs no API key, spends no operations, and touches no network. +# +# Routes are named by a regular expression matched against "METHOD /path". +# A route's value is either a literal httr2 response, a list (encoded as a 200 +# JSON body), or a function(n, req) where `n` is how many times that route has +# been called -- which is how a job is made to report in_progress before it +# reports completed. + +json_response <- function(body = list(), status = 200L, headers = list()) { + headers <- utils::modifyList(list(`Content-Type` = "application/json"), headers) + httr2::response( + status_code = status, + headers = headers, + body = charToRaw(jsonlite::toJSON(body, auto_unbox = TRUE, null = "null")) + ) +} + +raw_response <- function(bytes, status = 200L, headers = list()) { + httr2::response(status_code = status, headers = headers, body = bytes) +} + +fake_api <- function(...) { + routes <- list(...) + state <- new.env(parent = emptyenv()) + state$log <- list() + state$counts <- list() + + transport <- function(req) { + method <- if (is.null(req$method)) "GET" else req$method + path <- httr2::url_parse(req$url)$path + key <- paste(method, path) + + n <- (state$counts[[key]] %||% 0L) + 1L + state$counts[[key]] <- n + state$log[[length(state$log) + 1L]] <- list( + key = key, method = method, path = path, + request = req, body = req$body$data + ) + + hit <- Find(function(pattern) grepl(pattern, key), names(routes)) + if (is.null(hit)) { + stop("fake_api: no route matches '", key, "'", call. = FALSE) + } + handler <- routes[[hit]] + resp <- if (is.function(handler)) handler(n, req) else handler + if (inherits(resp, "httr2_response")) resp else json_response(resp) + } + + list( + transport = transport, + calls = function() state$log, + n_calls = function(pattern = NULL) { + if (is.null(pattern)) { + return(length(state$log)) + } + sum(vapply(state$log, function(call) grepl(pattern, call$key), logical(1))) + }, + last = function(pattern = NULL) { + calls <- state$log + if (!is.null(pattern)) { + calls <- Filter(function(call) grepl(pattern, call$key), calls) + } + if (length(calls) == 0L) NULL else calls[[length(calls)]] + } + ) +} + +# A client wired to a fake API, with a budget generous enough not to be the +# thing under test unless a test says so. +fake_client <- function(api, budget = sd_budget(max_operations = 100)) { + sd_client( + api_key = "sk_test_key_not_real", + base_url = "https://api.superdocs.test", + budget = budget, + transport = api$transport, + max_tries = 1 + ) +} + +# A file on disk with an accepted extension. The bytes do not matter: the fake +# API never parses them, and sd_upload() streams the file rather than reading +# it. +test_docx <- function(env = parent.frame()) { + path <- withr::local_tempfile(fileext = ".docx", .local_envir = env) + writeBin(charToRaw("PK not really a docx"), path) + path +} + +# --- fixtures, shaped exactly like the documented payloads -------------------- + +upload_payload <- function(session_id = "paper-2026", + filename = "manuscript.docx", + chunks_count = 12L) { + list( + html = "

Title

", + session_id = session_id, + filename = filename, + chunks_count = chunks_count, + version_id = "v_1", + page_setup = list( + width_in = 8.27, height_in = 11.69, + margin_in = list(top = 1, right = 1, bottom = 1, left = 1), + orientation = "portrait", source = "docx" + ) + ) +} + +a_change <- function(id = "ch_1", operation = "edit") { + list( + change_id = id, + operation = operation, + chunk_id = "550e8400-e29b-41d4-a716-446655440000", + old_html = "

The effect was very significant.

", + new_html = "

The effect was significant (p = 0.03).

", + ai_explanation = "Replaced an intensifier with the reported statistic", + insert_after_chunk_id = NULL, + document_id = "doc_1" + ) +} + +# A job paused on a HITL review, with the changes delivered as plain objects. +job_awaiting_objects <- function(changes = list(a_change())) { + list( + job_id = "job_1", session_id = "paper-2026", job_type = "chat", + status = "awaiting_approval", progress = 50, + created_at = "2026-08-10T10:00:00Z", updated_at = "2026-08-10T10:00:30Z", + metadata = list(pending_changes = changes) + ) +} + +# The same job, but with the batch delivered the way the API actually delivers +# it: `content` is a JSON-encoded string that has to be parsed a second time. +job_awaiting_batch_string <- function(changes = list(a_change("ch_1"), a_change("ch_2", "create")), + batch_total = 2L) { + content <- jsonlite::toJSON( + list( + type = "batch_approval", + batch_id = "ch_1", + batch_total = batch_total, + changes = changes + ), + auto_unbox = TRUE, null = "null" + ) + list( + job_id = "job_1", session_id = "paper-2026", job_type = "chat", + status = "awaiting_approval", progress = 50, + created_at = "2026-08-10T10:00:00Z", updated_at = "2026-08-10T10:00:30Z", + metadata = list( + intermediate_responses = list( + list(type = "intermediate", content = "Reading section 3...", sequence = 1), + list(type = "proposed_change_batch", content = unclass(content), sequence = 2) + ) + ) + ) +} + +job_completed <- function(job_id = "job_1") { + list( + job_id = job_id, session_id = "paper-2026", job_type = "chat", + status = "completed", progress = 100, + created_at = "2026-08-10T10:00:00Z", updated_at = "2026-08-10T10:02:00Z", + result = list( + response = "Done.", + session_id = "paper-2026", + document_changes = list( + updated_html = "
edited
", + version_id = "v_2", + changes_summary = "Document updated by AI" + ), + usage = list( + monthly_used = 44, monthly_limit = 500, monthly_remaining = 456, + was_billable = TRUE, subscription_tier = "free" + ) + ) + ) +} + +job_in_progress <- function() { + list( + job_id = "job_1", session_id = "paper-2026", job_type = "chat", + status = "in_progress", progress = 20, + created_at = "2026-08-10T10:00:00Z", updated_at = "2026-08-10T10:00:10Z" + ) +} + +job_continue_prompt <- function() { + list( + job_id = "job_1", session_id = "paper-2026", job_type = "chat", + status = "awaiting_approval", progress = 60, + created_at = "2026-08-10T10:00:00Z", updated_at = "2026-08-10T10:01:00Z", + metadata = list( + awaiting_kind = "continue_prompt", + continue_prompt = list( + message = "I've updated 500 of 864 sections so far. 364 remain. Want me to continue?", + done = 500, total = 864, remaining = 364 + ) + ) + ) +} diff --git a/extensions/Aditya2600/superdocsr/tests/testthat/test-approve.R b/extensions/Aditya2600/superdocsr/tests/testthat/test-approve.R new file mode 100644 index 00000000..0051b473 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/tests/testthat/test-approve.R @@ -0,0 +1,111 @@ +approving_api <- function() { + fake_api( + "GET /v1/jobs/job_1" = function(n, req) { + if (n == 1) job_awaiting_objects() else job_completed() + }, + "POST /v1/chat/paper-2026/approve" = list(status = "ok") + ) +} + +test_that("there is no approve-everything default", { + client <- fake_client(approving_api()) + job <- sd_job(client, "job_1", "paper-2026", status = "awaiting_approval") + + expect_error(sd_approve(job), "Name the changes") + expect_error(sd_approve(job), "no approve-everything default") +}) + +test_that("the request always carries a top-level approved field", { + # Omitting it is a documented 422 even when every entry in `changes` has one. + api <- approving_api() + client <- fake_client(api) + job <- sd_job(client, "job_1", "paper-2026", status = "awaiting_approval") + + sd_approve(job, c("ch_1", "ch_2")) + body <- api$last("approve")$body + + expect_true("approved" %in% names(body)) + expect_true(body$approved) + expect_equal(body$job_id, "job_1") + expect_length(body$changes, 2) + expect_equal(body$changes[[1]]$change_id, "ch_1") + expect_true(body$changes[[2]]$approved) +}) + +test_that("the approval goes to the session's approve endpoint", { + api <- approving_api() + client <- fake_client(api) + sd_approve(sd_job(client, "job_1", "paper-2026", status = "awaiting_approval"), "ch_1") + + expect_equal(api$last("approve")$path, "/v1/chat/paper-2026/approve") + expect_equal(api$last("approve")$method, "POST") +}) + +test_that("approving refreshes the job so the caller sees what happened", { + api <- fake_api( + "GET /v1/jobs/job_1" = job_completed(), + "POST /v1/chat/paper-2026/approve" = list(status = "ok") + ) + client <- fake_client(api) + job <- sd_job(client, "job_1", "paper-2026", status = "awaiting_approval") + + updated <- sd_approve(job, "ch_1") + + expect_equal(updated$status, "completed") + expect_equal(api$n_calls("GET /v1/jobs/job_1"), 1) +}) + +test_that("denying sends approved = FALSE and the feedback", { + api <- approving_api() + client <- fake_client(api) + job <- sd_job(client, "job_1", "paper-2026", status = "awaiting_approval") + + sd_deny(job, "ch_1", feedback = "Keep the hedge; the effect is not significant.") + body <- api$last("approve")$body + + expect_false(body$approved) + expect_false(body$changes[[1]]$approved) + expect_equal(body$feedback, "Keep the hedge; the effect is not significant.") +}) + +test_that("duplicate and empty ids are cleaned up before sending", { + api <- approving_api() + client <- fake_client(api) + job <- sd_job(client, "job_1", "paper-2026", status = "awaiting_approval") + + sd_approve(job, c("ch_1", "ch_1", NA, "")) + expect_length(api$last("approve")$body$changes, 1) + + expect_error(sd_approve(job, character(0)), "nothing to decide") + expect_error(sd_approve(job, c(NA_character_, "")), "nothing to decide") +}) + +test_that("approving a continue prompt is refused locally, not by a 409", { + api <- fake_api( + "GET /v1/jobs/job_1" = job_continue_prompt(), + "POST /v1/chat/paper-2026/approve" = list(status = "ok") + ) + client <- fake_client(api) + job <- sd_wait(sd_job(client, "job_1", "paper-2026"), interval = 0.01, verbose = FALSE) + + expect_error(sd_approve(job, "ch_1"), "paused on a continue prompt") + expect_equal(api$n_calls("approve"), 0) +}) + +test_that("approved must be a single TRUE or FALSE", { + client <- fake_client(approving_api()) + job <- sd_job(client, "job_1", "paper-2026", status = "awaiting_approval") + + expect_error(sd_approve(job, "ch_1", approved = NA), "TRUE or FALSE") + expect_error(sd_approve(job, "ch_1", approved = c(TRUE, FALSE)), "TRUE or FALSE") +}) + +test_that("a whole batch can be approved, but only by saying so", { + api <- approving_api() + client <- fake_client(api) + job <- sd_wait(sd_job(client, "job_1", "paper-2026"), interval = 0.01, verbose = FALSE) + changes <- sd_changes(job, refresh = FALSE) + + sd_approve(job, changes$change_id) + expect_length(api$last("approve")$body$changes, nrow(changes)) +}) diff --git a/extensions/Aditya2600/superdocsr/tests/testthat/test-budget.R b/extensions/Aditya2600/superdocsr/tests/testthat/test-budget.R new file mode 100644 index 00000000..4b541ffe --- /dev/null +++ b/extensions/Aditya2600/superdocsr/tests/testthat/test-budget.R @@ -0,0 +1,91 @@ +test_that("small_sample is a genuinely cheap preset", { + budget <- sd_budget(small_sample = TRUE) + + expect_equal(budget$max_operations, 1) + expect_equal(budget$max_pages, 3) + expect_true(budget$small_sample) +}) + +test_that("small_sample never loosens a stricter budget", { + budget <- sd_budget(max_operations = 0, max_pages = 1, small_sample = TRUE) + + expect_equal(budget$max_operations, 0) + expect_equal(budget$max_pages, 1) +}) + +test_that("an exhausted budget stops the request before it is sent", { + api <- fake_api("POST /v1/chat/async" = job_completed()) + client <- fake_client(api, sd_budget(max_operations = 1)) + doc <- sd_document(client, session_id = "paper-2026") + + sd_edit(doc, "First edit.") + expect_error(sd_edit(doc, "Second edit."), class = "sd_budget_error") + + # The refused edit must not have reached the network. + expect_equal(api$n_calls("POST /v1/chat/async"), 1) +}) + +test_that("the budget error says how to raise the cap", { + client <- fake_client(fake_api(), sd_budget(max_operations = 0)) + doc <- sd_document(client, session_id = "paper-2026") + + expect_error(sd_edit(doc, "Edit."), "sd_budget\\(max_operations = N\\)") +}) + +test_that("operation counters track spending", { + api <- fake_api("POST /v1/chat/async" = job_completed()) + client <- fake_client(api, sd_budget(max_operations = 5)) + doc <- sd_document(client, session_id = "paper-2026") + + expect_equal(sd_ops_used(client), 0) + expect_equal(sd_ops_remaining(client), 5) + + sd_edit(doc, "Edit.") + + expect_equal(sd_ops_used(client), 1) + expect_equal(sd_ops_remaining(client), 4) +}) + +test_that("uploads and exports are free", { + api <- fake_api( + "POST /v1/documents/upload" = upload_payload(), + "POST /v1/documents/export" = raw_response(charToRaw("PK bytes")) + ) + client <- fake_client(api, sd_budget(max_operations = 0)) + path <- withr::local_tempfile(fileext = ".docx") + + doc <- sd_upload(test_docx(), client) + sd_export(doc, "docx", path) + + expect_equal(sd_ops_used(client), 0) +}) + +test_that("an oversized document is refused before any edit runs", { + api <- fake_api("POST /v1/documents/upload" = upload_payload(chunks_count = 600L)) + client <- fake_client(api, sd_budget(max_pages = 5, chunks_per_page = 12)) + + expect_error(sd_upload(test_docx(), client), class = "sd_budget_error") + expect_error(sd_upload(test_docx(), client), "about 50 pages") +}) + +test_that("the page gate states its method and can be tuned", { + api <- fake_api("POST /v1/documents/upload" = upload_payload(chunks_count = 600L)) + + # 600 chunks at 12/page is 50 pages: refused at max_pages = 5. + expect_error( + sd_upload(test_docx(), fake_client(api, sd_budget(max_pages = 5))), + "12 chunks per page" + ) + + # The same document at a denser 200 chunks/page is 3 pages: allowed. + expect_s3_class( + sd_upload(test_docx(), fake_client(api, sd_budget(max_pages = 5, chunks_per_page = 200))), + "sd_document" + ) +}) + +test_that("sd_budget validates its arguments", { + expect_error(sd_budget(max_operations = -1), "non-negative") + expect_error(sd_budget(max_pages = 0), "NULL or a single number") + expect_error(sd_budget(chunks_per_page = 0), "number >= 1") +}) diff --git a/extensions/Aditya2600/superdocsr/tests/testthat/test-changes.R b/extensions/Aditya2600/superdocsr/tests/testthat/test-changes.R new file mode 100644 index 00000000..400d9c7b --- /dev/null +++ b/extensions/Aditya2600/superdocsr/tests/testthat/test-changes.R @@ -0,0 +1,158 @@ +test_that("changes delivered as plain objects are read", { + api <- fake_api("GET /v1/jobs/job_1" = job_awaiting_objects()) + client <- fake_client(api) + + changes <- sd_changes(sd_job(client, "job_1", "paper-2026")) + + expect_s3_class(changes, "sd_changes") + expect_equal(nrow(changes), 1) + expect_equal(changes$change_id, "ch_1") + expect_equal(changes$operation, "edit") + expect_equal(changes$ai_explanation, "Replaced an intensifier with the reported statistic") +}) + +test_that("a batch whose content is a JSON string is parsed a second time", { + # This is the documented trap: `content` is JSON inside JSON. One parse leaves + # it a character scalar and every field below reads as NA. If this test fails + # with a table of NAs, the second fromJSON() call has gone missing. + api <- fake_api("GET /v1/jobs/job_1" = job_awaiting_batch_string()) + client <- fake_client(api) + + changes <- sd_changes(sd_job(client, "job_1", "paper-2026")) + + expect_equal(nrow(changes), 2) + expect_equal(changes$change_id, c("ch_1", "ch_2")) + expect_equal(changes$operation, c("edit", "create")) + expect_false(any(is.na(changes$new_html))) + expect_false(any(is.na(changes$ai_explanation))) + expect_equal(changes$chunk_id[1], "550e8400-e29b-41d4-a716-446655440000") +}) + +test_that("the raw job body really does carry content as a string", { + # Guards the fixture itself: if this stopped being a string, the test above + # would pass for the wrong reason. + body <- job_awaiting_batch_string() + content <- body$metadata$intermediate_responses[[2]]$content + + expect_type(content, "character") + expect_length(content, 1) + expect_type(jsonlite::fromJSON(content, simplifyVector = FALSE)$changes, "list") +}) + +test_that("batch id and total come off the envelope onto every change", { + api <- fake_api("GET /v1/jobs/job_1" = job_awaiting_batch_string(batch_total = 32L)) + client <- fake_client(api) + + changes <- sd_changes(sd_job(client, "job_1", "paper-2026")) + + expect_equal(unique(changes$batch_id), "ch_1") + expect_equal(unique(changes$batch_total), 32L) +}) + +test_that("the same change arriving by both routes is counted once", { + body <- job_awaiting_batch_string(changes = list(a_change("ch_1"))) + body$metadata$pending_changes <- list(a_change("ch_1"), a_change("ch_9")) + api <- fake_api("GET /v1/jobs/job_1" = body) + + changes <- sd_changes(sd_job(fake_client(api), "job_1", "paper-2026")) + + expect_equal(sort(changes$change_id), c("ch_1", "ch_9")) +}) + +test_that("non-change intermediate events are ignored", { + body <- job_awaiting_batch_string() + body$metadata$intermediate_responses <- c( + body$metadata$intermediate_responses, + list( + list(type = "model_fallback", content = "Falling back to core.", sequence = 3), + list(type = "documents_changed", content = "{\"documents\": []}", sequence = 4) + ) + ) + api <- fake_api("GET /v1/jobs/job_1" = body) + + changes <- sd_changes(sd_job(fake_client(api), "job_1", "paper-2026")) + expect_equal(nrow(changes), 2) +}) + +test_that("a batch with unparseable content warns and is skipped, not silently dropped", { + body <- job_awaiting_objects() + body$metadata$intermediate_responses <- list( + list(type = "proposed_change_batch", content = "{not json", sequence = 2) + ) + api <- fake_api("GET /v1/jobs/job_1" = body) + + expect_warning( + changes <- sd_changes(sd_job(fake_client(api), "job_1", "paper-2026")), + "not valid JSON" + ) + # The well-formed change from pending_changes still comes through. + expect_equal(changes$change_id, "ch_1") +}) + +test_that("no proposed changes is an honest empty table, not an error", { + api <- fake_api("GET /v1/jobs/job_1" = job_completed()) + changes <- sd_changes(sd_job(fake_client(api), "job_1", "paper-2026")) + + expect_equal(nrow(changes), 0) + expect_named( + changes, + c( + "change_id", "operation", "chunk_id", "document_id", "ai_explanation", + "old_html", "new_html", "insert_after_chunk_id", "batch_id", + "batch_total", "decided" + ) + ) + expect_output(print(changes), "no changes proposed") +}) + +test_that("changes decided before a reconnect are marked", { + body <- job_awaiting_batch_string() + body$metadata$pending_batch_decisions <- list(ch_1 = list(approved = TRUE, feedback = NULL)) + api <- fake_api("GET /v1/jobs/job_1" = body) + + changes <- sd_changes(sd_job(fake_client(api), "job_1", "paper-2026")) + + expect_equal(changes$decided, c(TRUE, FALSE)) +}) + +test_that("missing optional fields become NA rather than shifting the table", { + bare <- list(change_id = "ch_7", operation = "delete", chunk_id = "chunk-7") + api <- fake_api("GET /v1/jobs/job_1" = job_awaiting_objects(changes = list(bare))) + + changes <- sd_changes(sd_job(fake_client(api), "job_1", "paper-2026")) + + expect_equal(changes$change_id, "ch_7") + expect_true(is.na(changes$new_html)) + expect_true(is.na(changes$ai_explanation)) + expect_type(changes$batch_total, "integer") +}) + +test_that("printing a review never dumps raw HTML at the user", { + api <- fake_api("GET /v1/jobs/job_1" = job_awaiting_objects()) + changes <- sd_changes(sd_job(fake_client(api), "job_1", "paper-2026")) + printed <- paste(capture.output(print(changes)), collapse = "\n") + + expect_false(grepl("

", printed, fixed = TRUE)) + expect_match(printed, "The effect was significant") + expect_match(printed, "Nothing is applied until sd_approve") +}) + +test_that("a continue prompt is reported as such instead of as an empty review", { + api <- fake_api("GET /v1/jobs/job_1" = job_continue_prompt()) + changes <- sd_changes(sd_job(fake_client(api), "job_1", "paper-2026")) + + expect_equal(nrow(changes), 0) + expect_output(print(changes), "sd_continue") +}) + +test_that("refresh = FALSE reads the job in hand without another call", { + api <- fake_api("GET /v1/jobs/job_1" = job_awaiting_objects()) + client <- fake_client(api) + job <- sd_wait(sd_job(client, "job_1", "paper-2026"), interval = 0.01, verbose = FALSE) + + before <- api$n_calls() + changes <- sd_changes(job, refresh = FALSE) + + expect_equal(api$n_calls(), before) + expect_equal(nrow(changes), 1) +}) diff --git a/extensions/Aditya2600/superdocsr/tests/testthat/test-client.R b/extensions/Aditya2600/superdocsr/tests/testthat/test-client.R new file mode 100644 index 00000000..51437002 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/tests/testthat/test-client.R @@ -0,0 +1,84 @@ +test_that("a client refuses to exist without a key unless it is offline", { + withr::local_envvar(SUPERDOCS_API_KEY = "") + expect_error(sd_client(), "No API key found") + + # A supplied transport is the offline seam, so no key is needed. + expect_s3_class(sd_client(transport = function(req) req), "sd_client") +}) + +test_that("the key is never printed in full", { + client <- sd_client(api_key = "sk_live_abcdefghijklmnop", transport = function(req) req) + printed <- paste(capture.output(print(client)), collapse = " ") + + expect_false(grepl("abcdefghijklmnop", printed, fixed = TRUE)) + expect_match(printed, "sk_li\\*+nop") +}) + +test_that("sd_verify_key uses the endpoint that works for sk_ keys", { + api <- fake_api("GET /v1/sessions" = list()) + expect_true(sd_verify_key(fake_client(api))) + expect_equal(api$n_calls("GET /v1/sessions"), 1) + + # /v1/users/me is web-app only and would 401 on a valid key; it must not be + # what we probe with. + expect_equal(api$n_calls("/v1/users/me"), 0) +}) + +test_that("requests carry bearer auth and a package user agent", { + api <- fake_api("GET /v1/sessions" = list()) + sd_verify_key(fake_client(api)) + headers <- httr2::req_get_headers(api$last()$request, redact = "reveal") + + expect_equal(headers$Authorization, "Bearer sk_test_key_not_real") + expect_match(api$last()$request$options$useragent, "^superdocsr/") +}) + +test_that("errors name a cause and a fix", { + api <- fake_api( + "GET /v1/sessions" = json_response(list(detail = "Invalid API key"), status = 401) + ) + expect_error(sd_verify_key(fake_client(api)), "Cause: Invalid API key") + expect_error(sd_verify_key(fake_client(api)), "Fix:.*SUPERDOCS_API_KEY") +}) + +test_that("errors are classed by status so callers can branch", { + api <- fake_api("GET /v1/sessions" = json_response(list(detail = "nope"), status = 429)) + expect_error(sd_verify_key(fake_client(api)), class = "sd_http_429") + expect_error(sd_verify_key(fake_client(api)), class = "sd_api_error") +}) + +test_that("the three shapes of `detail` all flatten to one readable line", { + # 422 sends an array of validation records. + api422 <- fake_api("GET /v1/sessions" = json_response( + list(detail = list(list( + type = "string_pattern_mismatch", + loc = list("body", "session_id"), + msg = "String should match the expected pattern" + ))), + status = 422 + )) + expect_error(sd_verify_key(fake_client(api422)), "body.session_id: String should match") + + # 413 sends an object. + api413 <- fake_api("GET /v1/sessions" = json_response( + list(detail = list( + error_code = "document_too_large", + message_user = "This document is 142.3 MB, over the 100 MB export limit." + )), + status = 413 + )) + expect_error(sd_verify_key(fake_client(api413)), "over the 100 MB export limit") + + # The gateway can reject a body with an HTML page rather than JSON. + apihtml <- fake_api("GET /v1/sessions" = httr2::response( + status_code = 413, + headers = list(`Content-Type` = "text/html"), + body = charToRaw("413 Request Entity Too Large") + )) + expect_error(sd_verify_key(fake_client(apihtml)), "413 Request Entity Too Large") +}) + +test_that("a transport that does not return a response says so plainly", { + client <- sd_client(api_key = "sk_x", transport = function(req) "not a response") + expect_error(sd_verify_key(client), "transport returned a character") +}) diff --git a/extensions/Aditya2600/superdocsr/tests/testthat/test-edit.R b/extensions/Aditya2600/superdocsr/tests/testthat/test-edit.R new file mode 100644 index 00000000..232f6b6e --- /dev/null +++ b/extensions/Aditya2600/superdocsr/tests/testthat/test-edit.R @@ -0,0 +1,122 @@ +test_that("review is the default: every async edit asks before it applies", { + api <- fake_api("POST /v1/chat/async" = list( + job_id = "job_1", session_id = "paper-2026", status = "pending" + )) + client <- fake_client(api) + + job <- sd_edit(sd_document(client, "paper-2026"), "Tighten the abstract.") + + expect_equal(api$last()$body$approval_mode, "ask_every_time") + expect_s3_class(job, "sd_job") + expect_equal(job$job_id, "job_1") + expect_equal(job$status, "pending") +}) + +test_that("auto-apply has to be asked for by name", { + api <- fake_api("POST /v1/chat/async" = list( + job_id = "job_1", session_id = "paper-2026", status = "pending" + )) + client <- fake_client(api) + + sd_edit(sd_document(client, "paper-2026"), "Tighten it.", approval_mode = "approve_all") + expect_equal(api$last()$body$approval_mode, "approve_all") +}) + +test_that("a synchronous edit cannot pretend to offer a review", { + client <- fake_client(fake_api()) + + expect_error( + sd_edit(sd_document(client, "paper-2026"), "Tighten it.", async = FALSE), + "cannot pause for review" + ) + expect_error( + sd_edit(sd_document(client, "paper-2026"), "Tighten it.", async = FALSE), + "approval_mode = \"approve_all\"" + ) +}) + +test_that("a synchronous edit returns a job that is already finished", { + api <- fake_api("POST /v1/chat" = list( + response = "Done.", + session_id = "paper-2026", + document_changes = list(updated_html = "

edited

") + )) + client <- fake_client(api) + + job <- sd_edit( + sd_document(client, "paper-2026"), "Tighten it.", + async = FALSE, approval_mode = "approve_all" + ) + + expect_equal(job$status, "completed") + expect_true(is.na(job$job_id)) + expect_equal(job$result$response, "Done.") + expect_null(api$last()$body$approval_mode) +}) + +test_that("a finished synchronous job needs no polling and cannot be approved", { + api <- fake_api("POST /v1/chat" = list(response = "Done.", session_id = "paper-2026")) + client <- fake_client(api) + job <- sd_edit( + sd_document(client, "paper-2026"), "Tighten it.", + async = FALSE, approval_mode = "approve_all" + ) + + # Already settled, so sd_wait() hands it straight back without a poll. + expect_equal(sd_wait(job, verbose = FALSE)$status, "completed") + expect_equal(api$n_calls("GET"), 0) + + expect_error(sd_approve(job, "ch_1"), "nothing to approve") + expect_error(sd_approve(job, "ch_1"), "no review stage to gate") +}) + +test_that("an unsettled job with no id says why it cannot be polled", { + client <- fake_client(fake_api()) + job <- sd_job(client, job_id = NULL, session_id = "paper-2026", status = "in_progress") + + expect_error(sd_wait(job, verbose = FALSE), "no job_id") +}) + +test_that("an empty instruction is refused locally", { + api <- fake_api("POST /v1/chat/async" = list(job_id = "j", session_id = "s", status = "pending")) + client <- fake_client(api) + + expect_error(sd_edit(sd_document(client, "paper-2026"), " "), "non-empty string") + expect_equal(api$n_calls(), 0) +}) + +test_that("model options are validated locally rather than by a 422", { + client <- fake_client(fake_api()) + doc <- sd_document(client, "paper-2026") + + expect_error(sd_edit(doc, "Edit.", model_tier = "ultra"), "model_tier") + expect_error(sd_edit(doc, "Edit.", thinking_depth = "medium"), "thinking_depth") + expect_error(sd_edit(doc, "Edit.", response_mode = "tiny"), "response_mode") +}) + +test_that("a small_sample budget picks the cheapest model tier by default", { + api <- fake_api("POST /v1/chat/async" = list(job_id = "j", session_id = "s", status = "pending")) + client <- fake_client(api, sd_budget(small_sample = TRUE)) + + sd_edit(sd_document(client, "paper-2026"), "Edit.") + expect_equal(api$last()$body$model_tier, "turbo") +}) + +test_that("an explicit model tier still wins under small_sample", { + api <- fake_api("POST /v1/chat/async" = list(job_id = "j", session_id = "s", status = "pending")) + client <- fake_client(api, sd_budget(small_sample = TRUE)) + + sd_edit(sd_document(client, "paper-2026"), "Edit.", model_tier = "max") + expect_equal(api$last()$body$model_tier, "max") +}) + +test_that("document_html is omitted unless it is given", { + api <- fake_api("POST /v1/chat/async" = list(job_id = "j", session_id = "s", status = "pending")) + client <- fake_client(api) + + sd_edit(sd_document(client, "paper-2026"), "Edit.") + expect_false("document_html" %in% names(api$last()$body)) + + sd_edit(sd_document(client, "paper-2026"), "Edit.", document_html = "

x

") + expect_equal(api$last()$body$document_html, "

x

") +}) diff --git a/extensions/Aditya2600/superdocsr/tests/testthat/test-export.R b/extensions/Aditya2600/superdocsr/tests/testthat/test-export.R new file mode 100644 index 00000000..1e7ebd92 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/tests/testthat/test-export.R @@ -0,0 +1,120 @@ +export_bytes <- function() as.raw(c(0x50, 0x4b, 0x03, 0x04, 0x14, 0x00)) + +test_that("an export writes real bytes and returns the path", { + api <- fake_api("POST /v1/documents/export" = raw_response(export_bytes())) + client <- fake_client(api) + path <- withr::local_tempfile(fileext = ".docx") + + written <- sd_export(sd_document(client, "paper-2026"), "docx", path) + + expect_equal(written, path) + expect_true(file.exists(path)) + expect_equal(readBin(path, "raw", 6), export_bytes()) +}) + +test_that("the request names the session and the format", { + api <- fake_api("POST /v1/documents/export" = raw_response(export_bytes())) + client <- fake_client(api) + + sd_export(sd_document(client, "paper-2026"), "pdf", + withr::local_tempfile(fileext = ".pdf"), + options = list(paper_size = "A4", watermark_text = "DRAFT") + ) + body <- api$last()$body + + expect_equal(body$session_id, "paper-2026") + expect_equal(body$format, "pdf") + expect_equal(body$options$paper_size, "A4") +}) + +test_that("options are omitted when empty rather than sent as an empty object", { + api <- fake_api("POST /v1/documents/export" = raw_response(export_bytes())) + client <- fake_client(api) + + sd_export(sd_document(client, "paper-2026"), "docx", withr::local_tempfile(fileext = ".docx")) + expect_false("options" %in% names(api$last()$body)) +}) + +test_that("the default path follows the document name and the format", { + api <- fake_api("POST /v1/documents/export" = raw_response(export_bytes())) + client <- fake_client(api) + withr::local_dir(withr::local_tempdir()) + + doc <- sd_document(client, "paper-2026", filename = "manuscript.docx") + + expect_equal(basename(sd_export(doc, "markdown")), "manuscript.md") + expect_equal(basename(sd_export(doc, "docx")), "manuscript.docx") +}) + +test_that("only real export formats are accepted", { + client <- fake_client(fake_api()) + doc <- sd_document(client, "paper-2026") + + expect_error(sd_export(doc, "rtf"), "not an export target") + expect_error(sd_export(doc, "epub"), "must be one of") +}) + +test_that("an empty body is an error, not a zero-byte success", { + api <- fake_api("POST /v1/documents/export" = raw_response(raw())) + client <- fake_client(api) + + expect_error( + sd_export(sd_document(client, "paper-2026"), "docx", withr::local_tempfile(fileext = ".docx")), + "empty body" + ) +}) + +test_that("non-fatal render warnings are surfaced", { + warnings <- jsonlite::toJSON( + list(list( + code = "image_download_failed", + message = "figure-2.png could not be fetched", + detail = list(src = "https://example.test/figure-2.png") + )), + auto_unbox = TRUE + ) + api <- fake_api("POST /v1/documents/export" = raw_response( + export_bytes(), + headers = list(`X-Export-Warnings` = jsonlite::base64_enc(charToRaw(warnings))) + )) + client <- fake_client(api) + path <- withr::local_tempfile(fileext = ".docx") + + expect_warning( + sd_export(sd_document(client, "paper-2026"), "docx", path), + "image_download_failed" + ) + # The file is still written; the warning is about fidelity, not failure. + expect_true(file.exists(path)) +}) + +test_that("exporting mid-review says the pending changes are not in the file", { + api <- fake_api("POST /v1/documents/export" = raw_response(export_bytes())) + client <- fake_client(api) + job <- sd_job(client, "job_1", "paper-2026", status = "awaiting_approval") + + expect_warning( + sd_export(job, "docx", withr::local_tempfile(fileext = ".docx")), + "still awaiting approval" + ) +}) + +test_that("missing directories are created", { + api <- fake_api("POST /v1/documents/export" = raw_response(export_bytes())) + client <- fake_client(api) + path <- file.path(withr::local_tempdir(), "nested", "out", "paper.docx") + + expect_true(file.exists(sd_export(sd_document(client, "paper-2026"), "docx", path))) +}) + +test_that("a bare session id is enough to export", { + api <- fake_api("POST /v1/documents/export" = raw_response(export_bytes())) + client <- fake_client(api) + + path <- sd_export("paper-2026", + "docx", + withr::local_tempfile(fileext = ".docx"), + client = client + ) + expect_true(file.exists(path)) +}) diff --git a/extensions/Aditya2600/superdocsr/tests/testthat/test-knit.R b/extensions/Aditya2600/superdocsr/tests/testthat/test-knit.R new file mode 100644 index 00000000..f3f6d60a --- /dev/null +++ b/extensions/Aditya2600/superdocsr/tests/testthat/test-knit.R @@ -0,0 +1,213 @@ +# sd_knit() renders for real, then runs the workflow against the fake API. +# Rendering needs pandoc, so these skip where pandoc is absent rather than +# failing for a reason that has nothing to do with this package. + +skip_without_pandoc <- function() { + skip_if_not_installed("rmarkdown") + skip_if_not(rmarkdown::pandoc_available(), "pandoc is not available") +} + +a_paper <- function(env = parent.frame()) { + dir <- withr::local_tempdir(.local_envir = env) + path <- file.path(dir, "paper.Rmd") + writeLines( + c( + "---", + "title: A Small Paper", + "---", + "", + "# Results", + "", + "The effect was very significant." + ), + path + ) + path +} + +knit_api <- function() { + fake_api( + "POST /v1/documents/upload" = upload_payload(filename = "paper.md", chunks_count = 6L), + "POST /v1/chat/async" = list(job_id = "job_1", session_id = "paper-2026", status = "pending"), + "GET /v1/jobs/job_1" = function(n, req) { + if (n == 1) job_awaiting_batch_string() else job_completed() + }, + "POST /v1/chat/paper-2026/approve" = list(status = "ok"), + "POST /v1/documents/export" = raw_response(as.raw(c(0x50, 0x4b))) + ) +} + +test_that("sd_knit renders, edits, reviews, approves and exports", { + skip_without_pandoc() + api <- knit_api() + client <- fake_client(api) + out <- file.path(withr::local_tempdir(), "paper-revised.docx") + + result <- sd_knit( + a_paper(), + "Replace the intensifier with the reported statistic.", + output_format = "md_document", + client = client, + review = function(changes) changes$change_id[changes$operation == "edit"], + export_path = out, + timeout = 5 + ) + + expect_s3_class(result, "sd_knit_result") + expect_true(file.exists(result$rendered)) + expect_equal(result$approved, "ch_1") + expect_equal(result$denied, "ch_2") + expect_equal(result$export_path, out) + expect_true(file.exists(out)) +}) + +test_that("every proposed change gets a decision, so nothing is left blocking", { + skip_without_pandoc() + api <- knit_api() + + result <- sd_knit(a_paper(), "Tighten it.", + output_format = "md_document", + client = fake_client(api), + review = function(changes) changes$change_id[1], + timeout = 5 + ) + + decided <- c(result$approved, result$denied) + expect_setequal(decided, result$changes$change_id) +}) + +test_that("a review that returns NULL leaves the job open and approves nothing", { + skip_without_pandoc() + api <- knit_api() + + result <- NULL + expect_message( + result <- sd_knit(a_paper(), "Tighten it.", + output_format = "md_document", + client = fake_client(api), + review = sd_review_none, + export_path = file.path(withr::local_tempdir(), "out.docx"), + timeout = 5 + ), + "Review left open" + ) + + expect_length(result$approved, 0) + expect_equal(api$n_calls("approve"), 0) + expect_equal(api$n_calls("export"), 0) + expect_null(result$export_path) + expect_equal(result$job$status, "awaiting_approval") +}) + +test_that("a review that returns character(0) denies everything", { + skip_without_pandoc() + api <- knit_api() + + result <- sd_knit(a_paper(), "Tighten it.", + output_format = "md_document", + client = fake_client(api), + review = function(changes) character(0), + timeout = 5 + ) + + expect_length(result$approved, 0) + expect_setequal(result$denied, c("ch_1", "ch_2")) + expect_false(api$last("approve")$body$approved) +}) + +test_that("nothing approved means nothing exported", { + skip_without_pandoc() + api <- knit_api() + + expect_message( + sd_knit(a_paper(), "Tighten it.", + output_format = "md_document", + client = fake_client(api), + review = function(changes) character(0), + export_path = file.path(withr::local_tempdir(), "out.docx"), + timeout = 5 + ), + "nothing was exported" + ) + expect_equal(api$n_calls("export"), 0) +}) + +test_that("the export format follows the export path's extension", { + skip_without_pandoc() + api <- knit_api() + + sd_knit(a_paper(), "Tighten it.", + output_format = "md_document", + client = fake_client(api), + review = function(changes) changes$change_id, + export_path = file.path(withr::local_tempdir(), "out.pdf"), + timeout = 5 + ) + + expect_equal(api$last("export")$body$format, "pdf") +}) + +test_that("a large-edit continue prompt is answered, not waited out", { + skip_without_pandoc() + api <- fake_api( + "POST /v1/documents/upload" = upload_payload(filename = "paper.md", chunks_count = 6L), + "POST /v1/chat/async" = list(job_id = "job_1", session_id = "paper-2026", status = "pending"), + "GET /v1/jobs/job_1" = function(n, req) { + if (n == 1) job_continue_prompt() else job_completed() + }, + "POST /v1/chat/paper-2026/continue" = list(status = "resumed") + ) + + expect_message( + sd_knit(a_paper(), "Restructure every section.", + output_format = "md_document", + client = fake_client(api), + review = sd_review_none, + timeout = 5 + ), + "Large edit paused" + ) + + # The default is to stop and keep the applied work rather than spend more. + expect_false(api$last("continue")$body[["continue"]]) +}) + +test_that("the budget still governs a knit", { + skip_without_pandoc() + api <- knit_api() + client <- fake_client(api, sd_budget(max_operations = 0)) + + expect_error( + sd_knit(a_paper(), "Tighten it.", + output_format = "md_document", client = client, + review = sd_review_none, timeout = 5 + ), + class = "sd_budget_error" + ) + expect_equal(api$n_calls("chat"), 0) +}) + +test_that("sd_knit checks its inputs before rendering anything", { + client <- fake_client(fake_api()) + + expect_error(sd_knit("no-such-paper.Rmd", "Edit.", client = client), "No file at") + skip_without_pandoc() + expect_error( + sd_knit(a_paper(), "Edit.", client = client, review = "not a function"), + "`review` must be a function" + ) +}) + +test_that("sd_review_console refuses to decide in a script", { + changes <- sd_changes( + sd_job(fake_client(fake_api("GET /v1/jobs/job_1" = job_awaiting_objects())), "job_1", "paper-2026") + ) + + skip_if(interactive(), "needs a non-interactive session") + expect_error(sd_review_console(changes), "will not approve changes in a non-interactive session") + expect_error(sd_review_console(changes), "review = sd_review_none") +}) + +test_that("sd_review_none approves nothing at all", { + expect_null(sd_review_none(NULL)) +}) diff --git a/extensions/Aditya2600/superdocsr/tests/testthat/test-upload.R b/extensions/Aditya2600/superdocsr/tests/testthat/test-upload.R new file mode 100644 index 00000000..14c71236 --- /dev/null +++ b/extensions/Aditya2600/superdocsr/tests/testthat/test-upload.R @@ -0,0 +1,78 @@ +test_that("a successful upload returns the parsed document", { + api <- fake_api("POST /v1/documents/upload" = upload_payload()) + doc <- sd_upload(test_docx(), fake_client(api), session_id = "paper-2026") + + expect_s3_class(doc, "sd_document") + expect_equal(doc$session_id, "paper-2026") + expect_equal(doc$filename, "manuscript.docx") + expect_equal(doc$chunks_count, 12L) + expect_equal(doc$page_setup$orientation, "portrait") +}) + +test_that("the upload is multipart and carries the session", { + api <- fake_api("POST /v1/documents/upload" = upload_payload()) + sd_upload(test_docx(), fake_client(api), session_id = "paper-2026") + call <- api$last() + + expect_equal(call$path, "/v1/documents/upload") + expect_equal(call$method, "POST") + expect_equal(call$body$session_id, "paper-2026") + # The file rides as a curl form_file, i.e. streamed from disk, not read in. + expect_s3_class(call$body$file, "form_file") +}) + +test_that("open_mode is sent only when it is not the default", { + api <- fake_api("POST /v1/documents/upload" = upload_payload()) + client <- fake_client(api) + + sd_upload(test_docx(), client, session_id = "s1") + expect_null(api$last()$body$open_mode) + + sd_upload(test_docx(), client, session_id = "s1", open_mode = "background") + expect_equal(api$last()$body$open_mode, "background") +}) + +test_that("a missing file is caught before a request is sent", { + api <- fake_api("POST /v1/documents/upload" = upload_payload()) + expect_error(sd_upload("no-such-file.docx", fake_client(api)), "No file at") + expect_equal(api$n_calls(), 0) +}) + +test_that("unsupported extensions are caught locally, with the .doc case named", { + api <- fake_api("POST /v1/documents/upload" = upload_payload()) + client <- fake_client(api) + + csv <- withr::local_tempfile(fileext = ".csv") + writeBin(charToRaw("a,b"), csv) + expect_error(sd_upload(csv, client), "does not accept '\\.csv'") + + doc <- withr::local_tempfile(fileext = ".doc") + writeBin(charToRaw("legacy"), doc) + expect_error(sd_upload(doc, client), "Convert legacy \\.doc to \\.docx") + + expect_equal(api$n_calls(), 0) +}) + +test_that("session ids are validated against the documented pattern", { + api <- fake_api("POST /v1/documents/upload" = upload_payload()) + client <- fake_client(api) + + expect_error(sd_upload(test_docx(), client, session_id = "has spaces"), "not a valid session id") + expect_error(sd_upload(test_docx(), client, session_id = "has/slash"), "422") + expect_equal(api$n_calls(), 0) +}) + +test_that("a generated session id satisfies the same pattern", { + api <- fake_api("POST /v1/documents/upload" = upload_payload()) + sd_upload(test_docx(), fake_client(api)) + + expect_match(api$last()$body$session_id, "^[A-Za-z0-9_.-]+$") +}) + +test_that("sd_document accepts a session id for resuming work", { + client <- fake_client(fake_api()) + doc <- sd_document(client, session_id = "paper-2026") + + expect_equal(doc$session_id, "paper-2026") + expect_output(print(doc), "paper-2026") +}) diff --git a/extensions/Aditya2600/superdocsr/tests/testthat/test-wait.R b/extensions/Aditya2600/superdocsr/tests/testthat/test-wait.R new file mode 100644 index 00000000..d1f2916f --- /dev/null +++ b/extensions/Aditya2600/superdocsr/tests/testthat/test-wait.R @@ -0,0 +1,140 @@ +test_that("polling continues until the job completes", { + api <- fake_api("GET /v1/jobs/job_1" = function(n, req) { + if (n < 3) job_in_progress() else job_completed() + }) + client <- fake_client(api) + job <- sd_job(client, "job_1", "paper-2026", status = "pending") + + done <- sd_wait(job, interval = 0.01, max_interval = 0.01, verbose = FALSE) + + expect_equal(done$status, "completed") + expect_equal(done$result$response, "Done.") + expect_equal(api$n_calls("GET /v1/jobs/job_1"), 3) +}) + +test_that("an already settled job is returned without a single poll", { + api <- fake_api("GET /v1/jobs/job_1" = job_completed()) + client <- fake_client(api) + job <- sd_job(client, "job_1", "paper-2026", status = "completed") + + expect_equal(sd_wait(job, verbose = FALSE)$status, "completed") + expect_equal(api$n_calls(), 0) +}) + +test_that("awaiting_approval is a result, not something to wait out", { + api <- fake_api("GET /v1/jobs/job_1" = job_awaiting_objects()) + client <- fake_client(api) + + job <- sd_wait(sd_job(client, "job_1", "paper-2026"), interval = 0.01, verbose = FALSE) + + expect_equal(job$status, "awaiting_approval") + expect_equal(sd_awaiting_kind(job), "change_review") + expect_equal(api$n_calls("GET /v1/jobs/job_1"), 1) +}) + +test_that("the two flavours of awaiting_approval are told apart", { + api <- fake_api("GET /v1/jobs/job_1" = job_continue_prompt()) + client <- fake_client(api) + + job <- sd_wait(sd_job(client, "job_1", "paper-2026"), interval = 0.01, verbose = FALSE) + expect_equal(sd_awaiting_kind(job), "continue_prompt") +}) + +test_that("backoff lengthens the gap between polls", { + slow <- function(n, req) if (n < 4) job_in_progress() else job_completed() + + fixed_api <- fake_api("GET /v1/jobs/job_1" = slow) + fixed_elapsed <- system.time( + sd_wait(sd_job(fake_client(fixed_api), "job_1", "paper-2026"), + interval = 0.02, backoff = FALSE, verbose = FALSE + ) + )[["elapsed"]] + + backoff_api <- fake_api("GET /v1/jobs/job_1" = slow) + backoff_elapsed <- system.time( + sd_wait(sd_job(fake_client(backoff_api), "job_1", "paper-2026"), + interval = 0.02, backoff = TRUE, max_interval = 10, verbose = FALSE + ) + )[["elapsed"]] + + expect_equal(fixed_api$n_calls("GET /v1/jobs"), backoff_api$n_calls("GET /v1/jobs")) + expect_gt(backoff_elapsed, fixed_elapsed) +}) + +test_that("a timeout is reported as a client give-up, not a server failure", { + api <- fake_api("GET /v1/jobs/job_1" = job_in_progress()) + client <- fake_client(api) + + expect_error( + sd_wait(sd_job(client, "job_1", "paper-2026"), + timeout = 0.05, interval = 0.02, verbose = FALSE + ), + class = "sd_timeout_error" + ) + expect_error( + sd_wait(sd_job(client, "job_1", "paper-2026"), + timeout = 0.05, interval = 0.02, verbose = FALSE + ), + "keeps running" + ) +}) + +test_that("a failed job raises rather than returning quietly", { + api <- fake_api("GET /v1/jobs/job_1" = list( + job_id = "job_1", session_id = "paper-2026", job_type = "chat", + status = "failed", progress = 0, + created_at = "t", updated_at = "t", + error = "The model returned no usable edit." + )) + client <- fake_client(api) + + expect_error( + sd_wait(sd_job(client, "job_1", "paper-2026"), interval = 0.01, verbose = FALSE), + class = "sd_job_failed" + ) + expect_error( + sd_wait(sd_job(client, "job_1", "paper-2026"), interval = 0.01, verbose = FALSE), + "no usable edit" + ) +}) + +test_that("sd_continue answers only a continue prompt", { + api <- fake_api( + "GET /v1/jobs/job_1" = function(n, req) if (n == 1) job_continue_prompt() else job_completed(), + "POST /v1/chat/paper-2026/continue" = list(status = "resumed") + ) + client <- fake_client(api) + job <- sd_wait(sd_job(client, "job_1", "paper-2026"), interval = 0.01, verbose = FALSE) + + resumed <- sd_continue(job, proceed = TRUE) + + body <- api$last("continue")$body + expect_equal(body$job_id, "job_1") + expect_true(body[["continue"]]) + expect_equal(resumed$status, "completed") +}) + +test_that("sd_continue refuses a change review", { + api <- fake_api("GET /v1/jobs/job_1" = job_awaiting_objects()) + client <- fake_client(api) + job <- sd_wait(sd_job(client, "job_1", "paper-2026"), interval = 0.01, verbose = FALSE) + + expect_error(sd_continue(job), "not paused on a continue prompt") + expect_error(sd_continue(job), "sd_approve") +}) + +test_that("sd_cancel posts to the cancel endpoint", { + api <- fake_api( + "POST /v1/jobs/job_1/cancel" = list(status = "cancelling"), + "GET /v1/jobs/job_1" = list( + job_id = "job_1", session_id = "paper-2026", job_type = "chat", + status = "cancelled", progress = 0, created_at = "t", updated_at = "t" + ) + ) + client <- fake_client(api) + + cancelled <- sd_cancel(sd_job(client, "job_1", "paper-2026")) + + expect_equal(api$n_calls("POST /v1/jobs/job_1/cancel"), 1) + expect_equal(cancelled$status, "cancelled") +}) diff --git a/extensions/Aditya2600/superdocsr/vignettes/research-paper-workflow.Rmd b/extensions/Aditya2600/superdocsr/vignettes/research-paper-workflow.Rmd new file mode 100644 index 00000000..38ce679f --- /dev/null +++ b/extensions/Aditya2600/superdocsr/vignettes/research-paper-workflow.Rmd @@ -0,0 +1,366 @@ +--- +title: "Editing a research paper with a review gate" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Editing a research paper with a review gate} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r setup, include = FALSE} +knitr::opts_chunk$set(collapse = TRUE, comment = "#>") +library(superdocsr) +``` + +## The problem this solves + +An AI that edits your manuscript is useful right up to the moment it changes +something you did not want changed. In a paper, that is not a cosmetic +problem: a rewritten hedge becomes an overclaim, a "tightened" result becomes +a different result, and neither is obvious three drafts later. + +So this package does not apply edits. It proposes them, shows you what they +are, and applies exactly the ones you name. The workflow is five calls: + + sd_upload() -> sd_edit() -> sd_wait() -> sd_changes() -> sd_approve() -> sd_export() + +and the gate sits in the middle of it, not as an option you can switch on. + +## Running this vignette + +Everything below actually runs, with no API key and no network. The client's +HTTP transport is a plain function, so this vignette supplies one that returns +recorded SuperDocs payloads. That is the same seam the package's test suite +uses, and it is available to you: if you are building on top of `superdocsr`, +you can test your own code the same way. + +```{r fake-api} +# Recorded payloads, shaped exactly like the documented API responses. +payloads <- list( + upload = list( + html = "

Effects of X on Y

", + session_id = "paper-2026", filename = "manuscript.docx", + chunks_count = 96, version_id = "v_1", + page_setup = list(width_in = 8.27, height_in = 11.69, orientation = "portrait") + ), + started = list(job_id = "job_7f3a", session_id = "paper-2026", status = "pending"), + review = list( + job_id = "job_7f3a", session_id = "paper-2026", job_type = "chat", + status = "awaiting_approval", progress = 60, + created_at = "2026-08-10T10:00:00Z", updated_at = "2026-08-10T10:01:12Z", + metadata = list(intermediate_responses = list( + list(type = "intermediate", content = "Reading the discussion...", sequence = 1), + # Note the shape: `content` is a JSON string inside JSON. + list(type = "proposed_change_batch", sequence = 2, content = jsonlite::toJSON(list( + type = "batch_approval", batch_id = "ch_1", batch_total = 2, + changes = list( + list( + change_id = "ch_1", operation = "edit", chunk_id = "c41", + old_html = "

The effect was very significant.

", + new_html = "

The effect was significant (p = 0.03).

", + ai_explanation = "Replaced an intensifier with the reported statistic" + ), + list( + change_id = "ch_2", operation = "delete", chunk_id = "c58", + old_html = "

This clearly proves the mechanism.

", + new_html = NULL, + ai_explanation = "Removed a causal claim the data does not support" + ) + ) + ), auto_unbox = TRUE, null = "null")) + )) + ), + done = list( + job_id = "job_7f3a", session_id = "paper-2026", job_type = "chat", + status = "completed", progress = 100, + created_at = "2026-08-10T10:00:00Z", updated_at = "2026-08-10T10:02:40Z", + result = list(response = "Applied 1 change.", session_id = "paper-2026") + ) +) + +json <- function(x) { + httr2::response( + status_code = 200, headers = list(`Content-Type` = "application/json"), + body = charToRaw(jsonlite::toJSON(x, auto_unbox = TRUE, null = "null")) + ) +} + +approved_yet <- FALSE +transport <- function(req) { + path <- httr2::url_parse(req$url)$path + if (grepl("documents/upload", path)) return(json(payloads$upload)) + if (grepl("chat/async", path)) return(json(payloads$started)) + if (grepl("approve", path)) { + approved_yet <<- TRUE + return(json(list(status = "ok"))) + } + if (grepl("^/v1/jobs/", path)) { + return(json(if (approved_yet) payloads$done else payloads$review)) + } + if (grepl("documents/export", path)) { + return(httr2::response(200L, body = charToRaw("PK\003\004 ...docx bytes..."))) + } + stop("unrecorded call: ", path) +} +``` + +## The client + +In real use you write this and nothing else: + +```{r real-client, eval = FALSE} +client <- sd_client() # reads SUPERDOCS_API_KEY +sd_verify_key(client) # cheap check that the key works +``` + +Keep the key in `~/.Renviron`, never in the script. Here we point at the +recorded transport instead, and cap the client at three operations so a mistake +in this vignette could not cost anything even if it were live: + +```{r client} +client <- sd_client( + api_key = "sk_not_a_real_key", + budget = sd_budget(max_operations = 3, max_pages = 40), + transport = transport +) +client +``` + +## Upload the rendered paper + +`sd_upload()` streams the file from disk rather than reading it into R, so a +long manuscript with figures does not have to fit in memory twice. Uploading +is not billable. + +```{r upload} +manuscript <- tempfile(fileext = ".docx") +writeBin(charToRaw("PK... a rendered manuscript ..."), manuscript) + +doc <- sd_upload(manuscript, client, session_id = "paper-2026") +doc +``` + +The session id is yours to choose and worth choosing deliberately: it is how +you come back to this document tomorrow, or after R crashes. + +## Ask for a targeted edit + +Targeted instructions land better than sweeping ones. "Improve the paper" +gives the model licence to rewrite your argument; the instruction below does +not. + +```{r edit} +job <- sd_edit( + doc, + paste( + "In the discussion, replace vague intensifiers with the reported statistics,", + "and remove any causal claim the results section does not support.", + "Do not touch the methods, the citations, or the numbers." + ) +) +job +``` + +`approval_mode` defaults to `"ask_every_time"`. Nothing has changed in the +document yet. + +## Wait for it + +An edit on a real paper takes anywhere from ten seconds to several minutes, +with nothing visible in between. That is the API working. `sd_wait()` polls +with exponential backoff so a long job is not hammered, and gives up on your +schedule rather than the server's: + +```{r wait} +job <- sd_wait(job, timeout = 300, verbose = FALSE) +job$status +sd_awaiting_kind(job) +``` + +Two different pauses report `awaiting_approval`, which is why +`sd_awaiting_kind()` exists. `"change_review"` is answered by `sd_approve()`; +`"continue_prompt"` -- a large edit asking whether to keep going -- is answered +by `sd_continue()`. Sending the wrong one is rejected with a 409. + +If `sd_wait()` times out, nothing is lost: the job keeps running server-side, +and `sd_wait(job)` picks it back up. Jobs are deleted an hour after they are +created, so that is the real deadline. + +## Read what it proposes + +```{r changes} +changes <- sd_changes(job, refresh = FALSE) +changes +``` + +It is an ordinary data frame, so you can filter it like one: + +```{r changes-df} +changes[, c("change_id", "operation", "ai_explanation")] +``` + +### The second parse, and why you did not have to think about it + +Proposed changes reach a client in two shapes. Some arrive as ordinary JSON +objects in `metadata$pending_changes`. The batched ones arrive inside +`metadata$intermediate_responses`, where the payload sits in a `content` field +that is *itself a JSON-encoded string* -- the same double encoding the SSE +`proposed_change_batch` event uses. + +Parse the response once and `content` is still a character scalar; every field +you read off it comes back empty. That is the single most common reason an +integration renders a review UI where every row is blank. `sd_changes()` makes +the second `jsonlite::fromJSON()` call for you. Here is the raw payload it +started from: + +```{r double-parse} +raw_event <- payloads$review$metadata$intermediate_responses[[2]] +substr(raw_event$content, 1, 60) + +# One parse gets you a string. Two gets you the changes. +class(raw_event$content) +length(jsonlite::fromJSON(raw_event$content, simplifyVector = FALSE)$changes) +``` + +## Approve the part you agree with + +This is the gate. `change_ids` has no default, and there is no +approve-everything switch, because an unreviewed edit to a paper you are about +to submit is the whole thing this package exists to prevent. + +Here we take the statistic correction and reject the deletion, telling the +model why: + +```{r approve} +keep <- changes$change_id[changes$operation == "edit"] +drop <- changes$change_id[changes$operation == "delete"] + +job <- sd_approve(job, keep) +job <- sd_deny(job, drop, feedback = "Keep the sentence; soften it instead of cutting it.") +job <- sd_wait(job, verbose = FALSE) +job$status +``` + +Denying with feedback can produce a revised proposal, so a review can go +several rounds. Keep polling until the status is `completed`. + +## Export + +Exports are free, so export often rather than at the end. + +```{r export} +out <- file.path(tempdir(), "manuscript-revised.docx") +sd_export(doc, format = "docx", path = out) +file.size(out) > 0 +``` + +`docx`, `pdf`, `html`, `markdown` and `txt` are the available formats. If the +render hit something non-fatal -- an image that would not download, a diagram +that timed out -- `sd_export()` raises an R warning carrying the codes rather +than handing you a file that quietly lost a figure. + +## The knitr shortcut + +`sd_knit()` is the whole thing in one call: it renders the `.Rmd` with +`rmarkdown::render()` and then runs upload, edit, review, approve, export over +the result. + +```{r knit, eval = FALSE} +result <- sd_knit( + "paper.Rmd", + "Tighten the discussion; keep every citation and number exactly as written.", + output_format = "word_document", + export_path = "paper-revised.docx" +) +result$changes +``` + +The review step is a function you supply. The default, +`sd_review_console()`, asks you in an interactive session and refuses to guess +in a script -- which means a scheduled job cannot approve anything by +accident. Two other useful shapes: + +```{r review-fns, eval = FALSE} +# Prepare the work, decide later. +sd_knit("paper.Rmd", "...", review = sd_review_none) + +# A rule you can defend: accept wording edits, never accept deletions. +sd_knit("paper.Rmd", "...", review = function(changes) { + changes$change_id[changes$operation == "edit"] +}) +``` + +Whatever the function returns, every proposed change gets an explicit decision +-- the ids you name are approved and the rest are denied -- so a job never +sits blocking your session waiting for a review nobody is going to give. + +## Not spending more than you meant to + +`sd_budget()` is attached to the client and enforced before requests leave your +machine. + +```{r budget} +sd_ops_used(client) +sd_ops_remaining(client) + +tiny <- sd_client(api_key = "sk_x", budget = sd_budget(max_operations = 0), transport = transport) +try(sd_edit(sd_document(tiny, "paper-2026"), "Rewrite everything.")) +``` + +`max_pages` refuses an oversized document after the free upload and before any +billable edit. The API reports chunks, not pages, so the gate compares against +`ceiling(chunks_count / chunks_per_page)` -- an estimate, with a default of 12 +chunks per page that suits a double-spaced manuscript. Tune it for your +documents: + +```{r pages} +strict <- sd_client( + api_key = "sk_x", + budget = sd_budget(max_pages = 5, chunks_per_page = 12), + transport = transport +) +try(sd_upload(manuscript, strict, session_id = "paper-2026")) +``` + +And `small_sample = TRUE` is the cheap first run: one operation, three pages, +fastest model tier. + +## Picking up after a crash + +Nothing about the workflow lives only in your R session. The session id and +the job id are enough to resume: + +```{r resume} +doc <- sd_document(client, session_id = "paper-2026") +job <- sd_job(client, job_id = "job_7f3a", session_id = "paper-2026") + +# Poll it again to find out where it got to. Here it has already finished. +sd_wait(job, verbose = FALSE)$status +``` + +Save both to disk as soon as you have them. A job id older than an hour is +gone rather than slow -- re-run the edit in that case. + +## When something goes wrong + +Errors name the cause and the fix, and carry a class so you can branch: + +```{r errors, error = TRUE} +broken <- sd_client( + api_key = "sk_x", + transport = function(req) { + httr2::response( + status_code = 415, + headers = list(`Content-Type` = "application/json"), + body = charToRaw('{"detail": "Unsupported file type: .doc"}') + ) + } +) +tex <- tempfile(fileext = ".doc") +writeBin(charToRaw("x"), tex) +sd_upload(tex, broken) +``` + +That one never reached the network: the extension check runs locally. The +classes are `sd_api_error` (plus `sd_http_`), `sd_budget_error`, +`sd_timeout_error` and `sd_job_failed`.