Run Exam Paper Desk from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads the brief and works out the blueprint for free - each topic's target marks, the weights scaled to the total and rounded by largest remainder so they add up exactly, and the pace in marks a minute - and checks a pasted draft paper the way the source skill asks: marks that add up at every level and to the total, every question tagged to a topic in the coverage map, no topic left without a question or drifting from its target, one assessable outcome per call, no bare "Discuss", sound multiple choice. That is epscan.js, and every finding carries its rule id, line and fix. The run writes the paper and its mark scheme ("write") or reviews the draft ("review"); the page then holds the reply to its own reading (epreply.js): every rule answered, every mark accounted for, every topic on its target.
A drafting aid. A written paper is a draft for the teacher: check every question, key and marking point against what the class was taught before it is sat.
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"exam-paper-desk"}), so no slug header is needed afterwards. Send it as Authorization: Bearer .... The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a field the run needs (task, brief, facts) is missing or not a string. Send every field as a string; focus may be "". |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes - "write" (the paper written from the brief to the page's blueprint: an instructions block, sections of questions with their topic, outcome, style, marks, calls and answer space, and a separate scoring outline with model answers and marking points; a draft under Paper: is rewritten, keeping what works) and "review" (the draft under Paper: judged against the brief: a verdict, findings with fixes, a call on every page finding, and keep, revise with a rewrite, or cut for every question). The EP- rules belong to both. A missing or unknown task is answered as write, with a note saying so. Both lanes send the same fields.
The input
Every field is a string.
| Field | Required | What |
|---|---|---|
task | yes | "write" or "review". The model answers that task and only that task. |
brief | yes | the exam brief as pasted, in the brief format. The page sends up to 18,000 characters - the syllabus summary, not the readings - and needs a Total: and at least one topic under Topics:; "review" also needs a draft under Paper:. |
focus | no | what the teacher wants weighed: the class, accessibility, plain language for EAL students - or a review to apply in a write (see review, then write). Up to 1,500 characters on the page. May be ""; when given, the summary says how it changed the answer. |
facts | yes | the page's own reading of the same brief: the total, the time and the pace, the target marks per topic, every draft question with its topic, marks, parts and options, and every finding as rule | severity | line | where | text. See the facts format. |
retry_note | no | present only on a reformat retry: the previous reply could not be read as one JSON object, and this says how to answer again. Leave it out otherwise. |
The brief format
brief is plain text in three parts, in this order:
- Header lines - the exam lock, one
Key: valueper line:Course,Assessment,Time(50 minutes,1 hour 30 minutes),Total(40 marks),Rules(open or closed book, calculators, formula sheets) and optionallyPast papers(the structure to follow - never the wording). - The coverage map - a
Topics:line, then one bullet per topic with its weight at the end: a percentage (- Microscopy: 20%), marks (- Momentum: 10 marks) or teaching time (- Osmosis: 4 lessons, hours, periods or weeks). Use one kind of weight throughout; with no weights, every topic gets an equal share. An optionalOutcomes:line follows with one bullet per learning outcome. - A draft paper (optional for
"write", required for"review") - aPaper:line, then questions numbered1.(orQ1.), each with its topic in square brackets and its marks in round brackets:3. [Microscopy] (8 marks). Sub-parts starta)with their own marks ((4)), and multiple-choice options startA..
Course: Year 9 Biology
Assessment: End-of-unit test - Cells and organisation
Time: 40 minutes
Total: 40 marks
Rules: closed book; no calculators; answer in the spaces provided
Topics:
- Cell structure: 30%
- Microscopy: 20%
- Diffusion and osmosis: 25%
- Levels of organisation: 25%
Paper:
1. [Cell structure] Name the part of a plant cell that controls what enters and leaves the cell. (1 mark)
3. [Microscopy] (8 marks)
a) A cell measures 4 mm in a drawing magnified 400 times. Calculate its real size in micrometres. (4)
4. [Diffusion and osmosis] Which statement defines diffusion? (1 mark)
A. Movement of particles from low to high concentration
B. Movement of particles from high to low concentration
A header the brief does not give is simply left out; the page reports what that costs as a finding.
The free blueprint
The page turns the coverage map into target marks in the browser, for free:
- Each topic's share is its weight over the sum of the weights - percentages, marks or teaching time alike, so weights that do not add to 100 are scaled to fit (and reported as
EP-WEIGHTSUM). With no weights, or a mix of kinds, every topic gets an equal share. - Largest remainder: each topic gets
floor(total x share), and the marks still missing go one each to the topics with the largest fractional remainders (ties to the earlier topic). The targets therefore always add up to the total exactly. For 40 marks at 30/20/25/25%, that is 12, 8, 10 and 10. - The pace is the total over the minutes: about one mark a minute is usual, and above 1.5 or below 0.4 the page reports
EP-PACE. - With a draft, each topic's marks in the draft are set against its target; a topic more than
max(2, 5% of the total)marks off isEP-DRIFT, and a topic with no question isEP-ORPHAN.
The targets go into facts as the coverage-map lines, and the model is told to follow the facts' targets: each topic's marks in a written paper follow its target, and a deliberate departure is stated in notes. So an API client must compute the targets itself and send its own target-marks lines in facts - with epscan.js under node (Build the input yourself), or with your own largest-remainder rounding written in the same form:
Read by the page: total <T> marks, <M> minutes, <pace> marks a minute.
Coverage map (target marks, weights <in %|in marks|in teaching time|not given - equal shares>, scaled to the total and rounded so they add up):
- <topic>: <target> marks (weight <w>)[; the draft gives it <n>]
Spell the topics exactly as the coverage map does: the model uses those names in topic and coverage, and the page matches them back.
The facts format
facts is the page's own reading, written as plain text. The page builds it from the brief with epscan.js, and the model answers against it. A client calling the API should send the same format (from epscan.js run under node) or at least its own summary - the total and the time, your target-marks lines, the draft questions as you read them, and what you already know is wrong. The check's format gets the most out of a run: the model answers every error and warn rule id it finds there with one prescan entry per id. The format, line by line:
<Course|Level|Assessment|Time|Total|Rules|Reading time|Past papers>: <as written - only the lines the brief gives>
Read by the page: total <T|not stated> marks, <M minutes|time not stated>[, <pace> marks a minute].
Coverage map (target marks, ...): (or: Coverage map: none.)
- <topic>: <target> marks (weight <w>)[; the draft gives it <n>]
Learning outcomes: <a | b | c> (when given)
Draft paper: <n> questions, <m> marks: (or: Draft paper: none pasted - write it from the brief.)
- <Q1> [<topic|untagged>] <n marks|no marks>[, parts a (3) b (2)][, 4 options] | <stem>
Prescan verdict: <ready|revise|rework> (<n> errors, <n> warnings, <n> notes).
Findings (rule | severity | line | where | text):
- <rule id> | <error|warn|info> | line <n> | <Q3b|-> | <title>[ (note)][ | the brief text]
A brief with no findings gets the single line - none. The prescan verdict is rework at two or more errors, or with errors or warnings in three or more areas (exam lock, coverage, marks, calls); revise on any error or warning; ready otherwise. A real example - the facts the page sends for the biology paper used in the worked example below:
Course: Year 9 Biology
Assessment: End-of-unit test - Cells and organisation
Time: 40 minutes
Total: 40 marks
Rules: closed book; no calculators; answer in the spaces provided
Read by the page: total 40 marks, 40 minutes, 1 marks a minute.
Coverage map (target marks, weights in %, scaled to the total and rounded so they add up):
- Cell structure: 12 marks (weight 30%); the draft gives it 12
- Microscopy: 8 marks (weight 20%); the draft gives it 8
- Diffusion and osmosis: 10 marks (weight 25%); the draft gives it 10
- Levels of organisation: 10 marks (weight 25%); the draft gives it 10
Draft paper: 6 questions, 40 marks:
- Q1 [Cell structure] 1 mark | Name the part of a plant cell that controls what enters and leaves the cell.
- Q2 [Cell structure] 11 marks, parts a (3) b (2) c (6) |
- Q3 [Microscopy] 8 marks, parts a (4) b (2) c (2) |
- Q4 [Diffusion and osmosis] 1 mark, 4 options | Which statement defines diffusion?
- Q5 [Diffusion and osmosis] 9 marks | In about 100 words, explain what happens to a red blood cell placed in pure water. Use the word osmosis.
- Q6 [Levels of organisation] 10 marks, parts a (2) b (4) c (4) |
Prescan verdict: ready (0 errors, 0 warnings, 0 notes).
Findings (rule | severity | line | where | text):
- none
Rule ids the check uses, with their severity and area:
| Rule | Severity | Area | Title |
|---|---|---|---|
EP-NOTOTAL | error | lock | No total marks |
EP-NOTIME | warn | lock | No sitting length |
EP-NORULES | info | lock | Materials and rules not stated |
EP-NOTOPICS | error | coverage | No coverage map |
EP-WEIGHTSUM | warn | coverage | Topic weights do not add up |
EP-PACE | warn | lock | Pace out of range |
EP-SUM | error | marks | Paper marks do not add up to the total |
EP-PARTSUM | error | marks | Sub-part marks do not add up to the question |
EP-NOMARKS | error | marks | Question without marks |
EP-UNTAGGED | warn | coverage | Question not tagged to a topic |
EP-OFFSYLLABUS | error | coverage | Question on a topic outside the coverage map |
EP-DRIFT | warn | coverage | Topic weighting drifts from the map |
EP-ORPHAN | warn | coverage | Topic in the map with no question |
EP-COMPOUND | warn | calls | Compound call |
EP-VAGUE | warn | calls | Open call without expectations |
EP-MCQ | warn | calls | Weak multiple-choice options |
EP-DUP | warn | calls | Two questions with the same stem |
EP-REPEAT | info | calls | Instruction repeated in questions |
A finding the check cannot see (an item that tests the wrong outcome, a give-away cue, an answer that depends on an unstated fact or a missing diagram, a wrong or ambiguous key, marks a call cannot earn) comes back with the rule NEW.
The standard the model applies
The source skill - @suzheng/k2f-exam-paper (suzheng/k2f, Apache-2.0) - is the only standard:
- The syllabus is the ceiling: only the topics in the coverage map (and the outcomes, when given); every question carries its topic.
- Points sum at every level: sub-parts to their question, questions to the total; each topic's marks follow the page's target, and a deliberate departure is stated.
- One assessable outcome per call: "describe and explain" is split into sub-parts with their own marks. A precise command word, and for open responses the length, form and evidence expected - never a bare "Discuss".
- Order within a section: warm-up, anchor items, then discriminating items; the style (recall, application, analysis, quantitative, multiple choice, extended) fits the outcome. Multiple choice has one correct key, the same number of plausible options throughout, and no "all of the above" or "none of the above".
- Instructions once, at the top; a formal, neutral tone; about one mark a minute, with enough answer lines for every constructed response.
- Original items only: past papers inform the structure, never the wording. The scoring outline is a separate artifact from the student paper.
House rules
- Everything is grounded in the brief: no invented topics, facts the syllabus does not cover or source texts the brief does not name. A gap goes in
out_of_scope(write) orquestions(review). - Every
errorandwarnfinding in the facts is answered by its rule id inprescan; a finding the model thinks is wrong is disputed with a reason, never silently dropped. - Topic names are spelled as in the coverage map; question ids are
Q1,Q2... with part labelsa,b,c. severitymapserror,warnandinfotohigh,mediumandlow;wherenames a question (Q3,Q3b) orBrief.- The reply is ONE JSON object and nothing else - no prose around it, no Markdown fences.
The replies
Both lanes carry task, summary, findings (rule - an EP- id from the facts or NEW - severity high / medium / low, title, where, why, fix; most severe first) and prescan (rule, call agree or disagree, note). The rest differs by lane.
write
The paper, written from the brief to the page's blueprint; a draft under Paper: is rewritten, keeping what works and fixing what the facts flag.
summary- two sentences: what the paper assesses and how it is built.instructions- the single instructions block for the cover: time, materials, how to answer.sections- each withtitleanditems. Each item:id,topic(spelled as in the map),outcome,style(recall,application,analysis,quantitative,mcqorextended),marks,stem, and eitherparts(eachlabel,marks,call,response-lines,calculation,diagramortable- andlines, the answer lines it needs) or, for a single call,responseandlines; amcqitem hasoptions(the texts, in order) andkey(the letter).scoring- one entry per item:id,answer(the model answer or the key) andpoints(eachpart,marks,criteria- the marking point and any partial credit), adding up to the item's marks.coverage- one entry per topic:topicandmarks, what the paper gives it.out_of_scope(what the brief asks for that the syllabus does not support) andnotes(departures from the map, choices made).
{
"task": "write",
"summary": "",
"instructions": "",
"sections": [{"title": "Section A", "items": [{"id": "Q1", "topic": "", "outcome": "", "style": "recall", "marks": 2, "stem": "", "parts": [{"label": "a", "marks": 2, "call": "", "response": "lines", "lines": 2}]}]}],
"scoring": [{"id": "Q1", "answer": "", "points": [{"part": "a", "marks": 2, "criteria": ""}]}],
"coverage": [{"topic": "", "marks": 0}],
"findings": [],
"prescan": [],
"out_of_scope": [""],
"notes": [""]
}
review
verdict on the draft: ready (fit to sit as is), revise (fixable problems) or rework (marks that do not add up, off-syllabus or missing topics, or calls students cannot answer as written). A page error the reply agrees with rules out ready.
summary- two or three sentences: what the paper assesses, the verdict, the first thing to fix.findingsandprescan.items- one entry per draft question:id,call(keep,reviseorcut),note, and forrevisearewriteof the question with its marks.coverage- one or two sentences (a string) on how the paper's marks sit against the map, using the page's numbers.questions(what the teacher must decide) andcaveats.
{
"task": "review",
"verdict": "revise",
"summary": "",
"findings": [{"rule": "EP-COMPOUND", "severity": "medium", "title": "", "where": "Q1", "why": "", "fix": ""}],
"prescan": [{"rule": "EP-COMPOUND", "call": "agree", "note": ""}],
"items": [{"id": "Q1", "call": "revise", "note": "", "rewrite": ""}],
"coverage": "",
"questions": [""],
"caveats": [""]
}
The page holds either reply to its own reading: every error and warning answered by rule id, and no rule id the page did not report other than NEW. For write it also wants an instructions block, unique question ids, marks on every question, sub-parts that add up to their question and questions that add up to the total, every item on a coverage-map topic, each topic within max(2, 5% of the total) marks of its target (or the departure stated in notes or out_of_scope), the coverage table equal to what the paper really gives, a scoring entry for every item whose points add up to its marks, the same number of options in every multiple-choice item and no "all/none of the above", and no compound or bare open calls. For review it wants every draft question judged (and no question the draft does not have), a rewrite for every revise, a word on the coverage, and no ready over an undisputed error. Each miss is shown next to the reply as a note.
Reading the reply
The job's output.output is text that should be exactly one JSON object. Parse it; if a model ever wraps it in prose or a fence, take the text from the first { to the last } and parse that (the page does the same in epreply.js). If it still does not parse, send the same input again with a retry_note such as "Your previous reply was not usable. Reply again with ONLY the JSON object for task 'review' - no prose, no code fences." and a new attempt number in the key (see step 5). A "write" reply is two documents: its instructions and sections with their items print as the student paper (the page adds a name line and answer lines from each call's lines), and its scoring prints as a separate mark scheme, not for students. Step 5 prints both. Check every question, key and marking point before the paper is sat.
Review, then write
A review can be handed to a "write" run as its focus, so the paper is rewritten with it. The page builds that focus from the review reply, one line each, clipped to 1,500 characters: Apply this review (Exam Paper Desk, verdict revise): <summary>, then - <where> <title>: <fix> per finding and - <id> <call>: <note> per question that is not kept. The same brief and facts go along.
Worked example - reviewing the biology paper
A clean-looking draft: a Year 9 end-of-unit test on cells and organisation, 40 marks in 40 minutes, four topics weighted 30/20/25/25%. The page's blueprint is 12, 8, 10 and 10 marks, the draft gives exactly that, the pace is one mark a minute, and the check finds nothing wrong. The input below is exactly what the page sends for the "review" lane; for the "write" lane only task changes to "write", and the same facts go along. Input:
{
"task": "review",
"brief": "Course: Year 9 Biology\nAssessment: End-of-unit test - Cells and organisation\nTime: 40 minutes\nTotal: 40 marks\nRules: closed book; no calculators; answer in the spaces provided\nTopics:\n- Cell structure: 30%\n- Microscopy: 20%\n- Diffusion and osmosis: 25%\n- Levels of organisation: 25%\nPaper:\n1. [Cell structure] Name the part of a plant cell that controls what enters and leaves the cell. (1 mark)\n2. [Cell structure] (11 marks)\na) Label the nucleus, cell membrane and cytoplasm on the animal cell diagram. (3)\nb) Give two structures found in plant cells but not in animal cells. (2)\nc) Explain how the structure of a root hair cell helps it absorb water, using two features. (6)\n3. [Microscopy] (8 marks)\na) A cell measures 4 mm in a drawing magnified 400 times. Calculate its real size in micrometres. (4)\nb) State one advantage of an electron microscope over a light microscope. (2)\nc) Describe how to focus a light microscope on a slide, in three steps. (2)\n4. [Diffusion and osmosis] Which statement defines diffusion? (1 mark)\nA. Movement of particles from low to high concentration\nB. Movement of particles from high to low concentration\nC. Movement of water through a partially permeable membrane only\nD. Movement that always needs energy from respiration\n5. [Diffusion and osmosis] In about 100 words, explain what happens to a red blood cell placed in pure water. Use the word osmosis. (9 marks)\n6. [Levels of organisation] (10 marks)\na) Put these in order from smallest to largest: organ, cell, organ system, tissue. (2)\nb) Explain why the stomach is an organ, using two tissues in your answer. (4)\nc) Describe the job of the digestive system as an organ system, in two points. (4)",
"focus": "",
"facts": "Course: Year 9 Biology\nAssessment: End-of-unit test - Cells and organisation\nTime: 40 minutes\nTotal: 40 marks\nRules: closed book; no calculators; answer in the spaces provided\nRead by the page: total 40 marks, 40 minutes, 1 marks a minute.\nCoverage map (target marks, weights in %, scaled to the total and rounded so they add up):\n- Cell structure: 12 marks (weight 30%); the draft gives it 12\n- Microscopy: 8 marks (weight 20%); the draft gives it 8\n- Diffusion and osmosis: 10 marks (weight 25%); the draft gives it 10\n- Levels of organisation: 10 marks (weight 25%); the draft gives it 10\nDraft paper: 6 questions, 40 marks:\n- Q1 [Cell structure] 1 mark | Name the part of a plant cell that controls what enters and leaves the cell.\n- Q2 [Cell structure] 11 marks, parts a (3) b (2) c (6) | \n- Q3 [Microscopy] 8 marks, parts a (4) b (2) c (2) | \n- Q4 [Diffusion and osmosis] 1 mark, 4 options | Which statement defines diffusion?\n- Q5 [Diffusion and osmosis] 9 marks | In about 100 words, explain what happens to a red blood cell placed in pure water. Use the word osmosis.\n- Q6 [Levels of organisation] 10 marks, parts a (2) b (4) c (4) | \nPrescan verdict: ready (0 errors, 0 warnings, 0 notes).\nFindings (rule | severity | line | where | text):\n- none"
}
Reply - the real saved reply the page shows for this sample, abbreviated: long strings are cut and end in ..., findings keeps only its first two entries, items its first two, questions its first and caveats its last (the full reply has 8 findings, 6 items, 3 questions and 3 caveats). prescan is empty because the page reported no findings to answer:
{
"task": "review",
"verdict": "revise",
"summary": "A 40-mark, 40-minute Year 9 test on cells and organisation whose topic totals match the coverage map exactly (12/8/10/10), but several parts carry marks their calls cannot earn and Q2a depends on a diagram the paper does not contain. Verdict: revise, not ...",
"findings": [
{
"rule": "NEW",
"severity": "high",
"title": "Q2a needs a diagram that is not in the paper",
"where": "Q2a",
"why": "The call says \"on the animal cell diagram\", but the draft supplies no diagram and no figure is named in the brief. As written, 3 marks cannot be ...",
"fix": "Insert a clear unlabelled animal cell diagram with three label lines, or use the text version in the rewrite (name the structure described by each of ..."
},
{
"rule": "NEW",
"severity": "medium",
"title": "Q5 puts 9 marks on one short explanation",
"where": "Q5",
"why": "A red blood cell in pure water has about four creditable points (water concentration higher outside, water enters by osmosis, through the partially ...",
"fix": "Keep the 9 marks for the topic but split them: three factors that increase the rate of diffusion (3), two observed changes in the cell (2), and the ..."
}
],
"prescan": [],
"items": [
{
"id": "Q1",
"call": "keep",
"note": "A fair 1-mark warm-up that tests the membrane/wall misconception. Keep the wording; the scheme must reject \"cell wall\"."
},
{
"id": "Q2",
"call": "revise",
"note": "Q2a cannot be answered without the missing diagram, and Q2c offers 6 marks for two features. Q2b is fine as written. The question stays at 11 marks.",
"rewrite": "2. [Cell structure] (11 marks)\na) Label the nucleus, cell membrane and cytoplasm on the animal cell diagram. (3) [Insert the diagram: an unlabelled ..."
}
],
"coverage": "The page's targets are Cell structure 12, Microscopy 8, Diffusion and osmosis 10 and Levels of organisation 10 (40 marks), and the draft gives exactly 12/8/10/10. Every rewrite moves marks within its own question, so the totals stay at 12/8/10/10 and the ...",
"questions": [
"Q2a: can you supply the animal cell diagram? If not, use the text version, which swaps the cell membrane for mitochondria (Q1 already tests the ..."
],
"caveats": [
"The diagram for Q2a was not seen. If it exists and is clear, Q2a can stay as written."
]
}
Every finding is NEW: the totals add up, so the page could not see that Q2a needs a diagram the paper does not contain, or that several parts carry marks their calls cannot earn. Sent to "write", the same brief comes back as a six-question paper with a mark scheme entry for every question and coverage of exactly 12, 8, 10 and 10 marks.
Build the input yourself
The checker and the reply checker are plain scripts that run under node (global.window = global; require("./epscan.js"); require("./epreply.js")), then:
const S = EpScan, R = EpReply;
const st = { brief: fs.readFileSync("brief.txt", "utf8"), focus: "" };
const scan = S.scan(st); // findings, counts, total, time, pace, targets, items, verdict
console.log(scan.targets.rows.map(r => r.name + ": " + r.target)); // the largest-remainder targets
const b = S.buildInput(st, "review"); // or "write"; b.ok is false (with b.note) when the brief cannot run
const wire = S.toWire(b.input); // task, brief, focus, facts - all strings; POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b) // {notes, warn}: anything the reply missed or got wrong
// R.paperText(reply, b), R.schemeText(reply, b) // a write reply as the student paper and the mark scheme
// R.handoff(reply) // a review reply as the focus of a write run
Get a token for the steps below from /tokens.html.
Step 1 - a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://exam-paper-desk.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://exam-paper-desk.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// exam-paper-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://exam-paper-desk.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://exam-paper-desk.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// ExamPaperDesk.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class ExamPaperDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://exam-paper-desk.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = +"YOUR_TOKEN" # from https://exam-paper-desk.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, 443, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://exam-paper-desk.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://exam-paper-desk.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 - a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"exam-paper-desk"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "exam-paper-desk"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "exam-paper-desk" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "exam-paper-desk"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "exam-paper-desk"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "exam-paper-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "exam-paper-desk"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "exam-paper-desk" }))!["token"]!.GetValue<string>();
Step 3 - GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 - POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra. Save the worked-example input above as input.json (with task set to "write" or "review") to follow along.
# input.json: the run input object - {"task": "review", "brief": "Course: ...\nTotal: 40 marks\nTopics:\n- ...\nPaper:\n1. ...", "focus": "", "facts": "Course: ...\nCoverage map ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "review", "brief": "Course: ...", "focus": "", "facts": "Course: ...\nCoverage map ..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "write" or "review", brief, focus, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 - POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses exam-paper-desk:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a review and a write of the same brief are two runs - and its one automatic reformat retry (sent with a retry_note, which the hash leaves out) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. The code below parses the JSON object out of the output; for "write" it prints the summary, the student paper (instructions, sections, questions with their options and parts) and then the mark scheme, and for "review" the verdict, the summary, the call on each question, the coverage and the questions for the teacher.
KEY="exam-paper-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "write" then
.summary, "", "INSTRUCTIONS", .instructions,
(.sections[]? | "", (.title | ascii_upcase), (.items[]? | "\(.id) [\(.topic)] (\(.marks) marks) \(.stem)",
(.options // [] | to_entries[] | " \([65 + .key] | implode). \(.value)"),
(.parts[]? | " (\(.label)) \(.call) [\(.marks)]"))),
"", "MARK SCHEME",
(.scoring[]? | "\(.id): \(.answer)", (.points[]? | " (\(.part)) [\(.marks)] \(.criteria)"))
else
"verdict: \(.verdict)", .summary,
(.items[]? | "\(.id): \(.call) - \(.note)"),
"Coverage: \(.coverage)",
(.questions[]? | "? \(.)")
end' reply.json # write: the student paper, then the mark scheme
import hashlib, time
key = "exam-paper-desk:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
out = j["output"]["output"]
reply = json.loads(out[out.index("{"):out.rindex("}") + 1]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"))
if reply.get("task") == "write":
print(reply.get("summary"))
print("\nINSTRUCTIONS\n" + reply.get("instructions", ""))
for s in reply.get("sections", []): # the student paper
print("\n" + s["title"].upper())
for it in s.get("items", []):
print(f"{it['id']} [{it.get('topic')}] ({it.get('marks')} marks) {it.get('stem', '')}")
for i, o in enumerate(it.get("options", [])):
print(f" {chr(65 + i)}. {o}")
for p in it.get("parts", []):
print(f" ({p['label']}) {p['call']} [{p['marks']}]")
print("\nMARK SCHEME") # a separate document: keep it away from students
for s in reply.get("scoring", []):
print(f"{s['id']}: {s.get('answer', '')}")
for p in s.get("points", []):
print(f" ({p.get('part', '')}) [{p['marks']}] {p['criteria']}")
else: # review
print("verdict:", reply.get("verdict"))
print(reply.get("summary"))
for x in reply.get("items", []):
print(f"{x['id']}: {x['call']} - {x.get('note', '')}")
print("Coverage:", reply.get("coverage"))
for q in reply.get("questions", []):
print("?", q)
import { createHash } from "node:crypto";
const key = `exam-paper-desk:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const out = j.output.output;
const reply = JSON.parse(out.slice(out.indexOf("{"), out.lastIndexOf("}") + 1));
console.log(j.charged_credits, j.truncated);
if (reply.task === "write") {
console.log(reply.summary);
console.log("\nINSTRUCTIONS\n" + reply.instructions);
for (const s of reply.sections || []) { // the student paper
console.log("\n" + s.title.toUpperCase());
for (const it of s.items || []) {
console.log(`${it.id} [${it.topic}] (${it.marks} marks) ${it.stem || ""}`);
(it.options || []).forEach((o, i) => console.log(` ${String.fromCharCode(65 + i)}. ${o}`));
for (const p of it.parts || []) console.log(` (${p.label}) ${p.call} [${p.marks}]`);
}
}
console.log("\nMARK SCHEME"); // a separate document: keep it away from students
for (const s of reply.scoring || []) {
console.log(`${s.id}: ${s.answer || ""}`);
for (const p of s.points || []) console.log(` (${p.part || ""}) [${p.marks}] ${p.criteria}`);
}
} else { // review
console.log("verdict:", reply.verdict);
console.log(reply.summary);
for (const x of reply.items || []) console.log(`${x.id}: ${x.call} - ${x.note || ""}`);
console.log("Coverage:", reply.coverage);
for (const q of reply.questions || []) console.log("?", q);
}
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "strings", "time" to the imports
key := "exam-paper-desk:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
out := j["output"].(map[string]any)["output"].(string)
var reply map[string]any
json.Unmarshal([]byte(out[strings.Index(out, "{"):strings.LastIndex(out, "}")+1]), &reply)
fmt.Println(j["charged_credits"], j["truncated"])
list := func(v any) []any { a, _ := v.([]any); return a }
obj := func(v any) map[string]any { o, _ := v.(map[string]any); return o }
if reply["task"] == "write" {
fmt.Println(reply["summary"])
fmt.Printf("\nINSTRUCTIONS\n%v\n", reply["instructions"])
for _, sx := range list(reply["sections"]) { // the student paper
s := obj(sx)
fmt.Println("\n" + strings.ToUpper(fmt.Sprint(s["title"])))
for _, ix := range list(s["items"]) {
it := obj(ix)
fmt.Printf("%v [%v] (%v marks) %v\n", it["id"], it["topic"], it["marks"], it["stem"])
for i, o := range list(it["options"]) { fmt.Printf(" %c. %v\n", 'A'+i, o) }
for _, px := range list(it["parts"]) { p := obj(px); fmt.Printf(" (%v) %v [%v]\n", p["label"], p["call"], p["marks"]) }
}
}
fmt.Println("\nMARK SCHEME") // a separate document: keep it away from students
for _, sx := range list(reply["scoring"]) {
s := obj(sx)
fmt.Printf("%v: %v\n", s["id"], s["answer"])
for _, px := range list(s["points"]) { p := obj(px); fmt.Printf(" (%v) [%v] %v\n", p["part"], p["marks"], p["criteria"]) }
}
} else { // review
fmt.Println("verdict:", reply["verdict"])
fmt.Println(reply["summary"])
for _, x := range list(reply["items"]) { it := obj(x); fmt.Printf("%v: %v - %v\n", it["id"], it["call"], it["note"]) }
fmt.Println("Coverage:", reply["coverage"])
for _, q := range list(reply["questions"]) { fmt.Println("?", q) }
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "exam-paper-desk:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
String out = j.get("output").get("output").asText();
JsonNode reply = M.readTree(out.substring(out.indexOf('{'), out.lastIndexOf('}') + 1));
System.out.println(j.get("charged_credits") + " " + j.get("truncated"));
if ("write".equals(reply.path("task").asText())) {
System.out.println(reply.path("summary").asText());
System.out.println("\nINSTRUCTIONS\n" + reply.path("instructions").asText());
for (JsonNode s : reply.path("sections")) { // the student paper
System.out.println("\n" + s.path("title").asText().toUpperCase());
for (JsonNode it : s.path("items")) {
System.out.println(it.path("id").asText() + " [" + it.path("topic").asText() + "] (" + it.path("marks") + " marks) " + it.path("stem").asText());
int i = 0;
for (JsonNode o : it.path("options")) System.out.println(" " + (char) ('A' + i++) + ". " + o.asText());
for (JsonNode p : it.path("parts")) System.out.println(" (" + p.path("label").asText() + ") " + p.path("call").asText() + " [" + p.path("marks") + "]");
}
}
System.out.println("\nMARK SCHEME"); // a separate document: keep it away from students
for (JsonNode s : reply.path("scoring")) {
System.out.println(s.path("id").asText() + ": " + s.path("answer").asText());
for (JsonNode p : s.path("points")) System.out.println(" (" + p.path("part").asText() + ") [" + p.path("marks") + "] " + p.path("criteria").asText());
}
} else { // review
System.out.println("verdict: " + reply.path("verdict").asText());
System.out.println(reply.path("summary").asText());
for (JsonNode x : reply.path("items")) System.out.println(x.path("id").asText() + ": " + x.path("call").asText() + " - " + x.path("note").asText());
System.out.println("Coverage: " + reply.path("coverage").asText());
for (JsonNode q : reply.path("questions")) System.out.println("? " + q.asText());
}
require "digest"
key = "exam-paper-desk:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
out = j["output"]["output"]
reply = JSON.parse(out[out.index("{")..out.rindex("}")])
puts [j["charged_credits"], j["truncated"]].inspect
if reply["task"] == "write"
puts reply["summary"], "", "INSTRUCTIONS", reply["instructions"]
(reply["sections"] || []).each do |s| # the student paper
puts "", s["title"].upcase
(s["items"] || []).each do |it|
puts "#{it["id"]} [#{it["topic"]}] (#{it["marks"]} marks) #{it["stem"]}"
(it["options"] || []).each_with_index { |o, i| puts " #{(65 + i).chr}. #{o}" }
(it["parts"] || []).each { |p| puts " (#{p["label"]}) #{p["call"]} [#{p["marks"]}]" }
end
end
puts "", "MARK SCHEME" # a separate document: keep it away from students
(reply["scoring"] || []).each do |s|
puts "#{s["id"]}: #{s["answer"]}"
(s["points"] || []).each { |p| puts " (#{p["part"]}) [#{p["marks"]}] #{p["criteria"]}" }
end
else # review
puts "verdict: #{reply["verdict"]}", reply["summary"]
(reply["items"] || []).each { |x| puts "#{x["id"]}: #{x["call"]} - #{x["note"]}" }
puts "Coverage: #{reply["coverage"]}"
(reply["questions"] || []).each { |q| puts "? #{q}" }
end
$key = "exam-paper-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$out = $j["output"]["output"];
$from = strpos($out, "{");
$reply = json_decode(substr($out, $from, strrpos($out, "}") - $from + 1), true);
echo $j["charged_credits"], "\n";
if (($reply["task"] ?? "") === "write") {
echo $reply["summary"] ?? "", "\n\nINSTRUCTIONS\n", $reply["instructions"] ?? "", "\n";
foreach ($reply["sections"] ?? [] as $s) { // the student paper
echo "\n", strtoupper($s["title"]), "\n";
foreach ($s["items"] ?? [] as $it) {
echo $it["id"], " [", $it["topic"] ?? "", "] (", $it["marks"] ?? "", " marks) ", $it["stem"] ?? "", "\n";
foreach ($it["options"] ?? [] as $i => $o) echo " ", chr(65 + $i), ". ", $o, "\n";
foreach ($it["parts"] ?? [] as $p) echo " (", $p["label"], ") ", $p["call"], " [", $p["marks"], "]\n";
}
}
echo "\nMARK SCHEME\n"; // a separate document: keep it away from students
foreach ($reply["scoring"] ?? [] as $s) {
echo $s["id"], ": ", $s["answer"] ?? "", "\n";
foreach ($s["points"] ?? [] as $p) echo " (", $p["part"] ?? "", ") [", $p["marks"], "] ", $p["criteria"], "\n";
}
} else { // review
echo "verdict: ", $reply["verdict"] ?? "", "\n", $reply["summary"] ?? "", "\n";
foreach ($reply["items"] ?? [] as $x) echo $x["id"], ": ", $x["call"], " - ", $x["note"] ?? "", "\n";
echo "Coverage: ", $reply["coverage"] ?? "", "\n";
foreach ($reply["questions"] ?? [] as $q) echo "? ", $q, "\n";
}
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"exam-paper-desk:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var output = j["output"]!["output"]!.GetValue<string>();
var from = output.IndexOf('{');
var reply = JsonNode.Parse(output.Substring(from, output.LastIndexOf('}') - from + 1))!;
Console.WriteLine($"{j["charged_credits"]} {j["truncated"]}");
JsonArray Arr(JsonNode? n) => n as JsonArray ?? new JsonArray();
if ((string?)reply["task"] == "write") {
Console.WriteLine(reply["summary"]);
Console.WriteLine($"\nINSTRUCTIONS\n{reply["instructions"]}");
foreach (var s in Arr(reply["sections"])) { // the student paper
Console.WriteLine("\n" + ((string?)s!["title"] ?? "").ToUpperInvariant());
foreach (var it in Arr(s["items"])) {
Console.WriteLine($"{it!["id"]} [{it["topic"]}] ({it["marks"]} marks) {it["stem"]}");
var i = 0;
foreach (var o in Arr(it["options"])) Console.WriteLine($" {(char)('A' + i++)}. {o}");
foreach (var p in Arr(it["parts"])) Console.WriteLine($" ({p!["label"]}) {p["call"]} [{p["marks"]}]");
}
}
Console.WriteLine("\nMARK SCHEME"); // a separate document: keep it away from students
foreach (var s in Arr(reply["scoring"])) {
Console.WriteLine($"{s!["id"]}: {s["answer"]}");
foreach (var p in Arr(s["points"])) Console.WriteLine($" ({p!["part"]}) [{p["marks"]}] {p["criteria"]}");
}
} else { // review
Console.WriteLine($"verdict: {reply["verdict"]}");
Console.WriteLine(reply["summary"]);
foreach (var x in Arr(reply["items"])) Console.WriteLine($"{x!["id"]}: {x["call"]} - {x["note"]}");
Console.WriteLine($"Coverage: {reply["coverage"]}");
foreach (var q in Arr(reply["questions"])) Console.WriteLine($"? {q}");
}
Step 6 - POST /run-stream
The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.
# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: exam-paper-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json (see the input table)
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "exam-paper-desk:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `exam-paper-desk:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "exam-paper-desk:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<24) // the final message carries the whole reply
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", "exam-paper-desk:" + M.readTree(rev).path("task").asText() + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "exam-paper-desk:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, 443, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$task = json_decode($rev, true)["task"];
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: exam-paper-desk:$task:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"exam-paper-desk:{JsonNode.Parse(rev)!["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);