Mark a recitation from your own code
The 3D atlas, the organ reference data and the client-side term prescan are free and need no account. The Study Desk — where a learner's written recall is graded against a fixed six-criterion anatomy rubric — runs through the SkillSafe App API: plain JSON over HTTPS, with optional streaming. Every step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#.
Basics
Base URL: https://api.skillsafe.ai/v1/app-api. Every request sends
Authorization: Bearer <token>, and JSON bodies carry
Content-Type: application/json. Responses are always wrapped in an envelope:
{"data": …} on success, {"error": {"code", "message"}} on failure.
A grading run bills SkillSafe credits to the calling token — a worst-case hold goes up front
and the actual cost settles when the job finishes. The app itself has
price_credits 0, so what you pay is model cost plus the app's
markup_bps, nothing more.
| Status | Code | What it means here |
|---|---|---|
400 | VALIDATION_ERROR |
The input JSON is malformed or a field is the wrong type — usually a missing
recitation, a missing organ_brief, or a mode
/ level outside the allowed sets. |
401 | UNAUTHORIZED |
Missing, malformed or expired token. Mint a fresh one (step 1) and retry. |
402 | PAYMENT_REQUIRED |
Balance is below the hold this run needs. Call /estimate first and top up
at skillsafe.ai/account/billing. Nothing is charged when a run is refused. |
404 | NOT_FOUND |
Unknown job_id, or a data key that was never written.
A missing history key is normal on a first run — treat it as an empty list. |
409 | CONFLICT |
An Idempotency-Key you already used is being replayed with a
different body. Same key + same body returns the original job instead of
charging twice; change the key when the recitation changes. |
429 | RATE_LIMITED |
Too many calls too quickly. Back off exponentially; do not tight-loop the job poll. |
500 | INTERNAL |
Transient platform error. Retry with backoff, reusing the same
Idempotency-Key so a run that actually started is not duplicated. |
Browsers enforce CORS on this API, so run these examples from a terminal, script or server — not from another website's frontend. Treat the token like a password: it can spend your credits.
Step 1 — Get a token
Open the token page and press
Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard,
which is exactly what the examples below read. Sign in there first if you want the run billed to
your own credits and its history saved to your account. For a fully headless script,
POST /guest mints a guest token with no browser involved; a guest can check its
balance and estimate freely, and can run if it has credits.
export API="https://api.skillsafe.ai/v1/app-api"
# either paste the export from /tokens.html:
export SKILLSAFE_TOKEN="YOUR_TOKEN"
# …or mint a guest token headlessly:
export SKILLSAFE_TOKEN=$(curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"anatomy-atelier"}' | jq -r '.data.token')
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://anatomy-atelier.skillsafe.ai/tokens.html
# headless alternative — a guest token, no browser:
import requests
TOKEN = requests.post(API + "/guest", json={"slug": "anatomy-atelier"}) \
.json()["data"]["token"]
const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://anatomy-atelier.skillsafe.ai/tokens.html
// headless alternative — a guest token, no browser:
const res = await fetch(API + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "anatomy-atelier" }),
});
TOKEN = (await res.json()).data.token;
const API = "https://api.skillsafe.ai/v1/app-api"
// Read the token minted at /tokens.html from the environment:
var token = os.Getenv("SKILLSAFE_TOKEN")
// …or mint a guest token headlessly (uses call() from step 2):
var guest struct{ Token string `json:"token"` }
if token == "" {
if err := call("POST", "/guest", map[string]string{"slug": "anatomy-atelier"}, &guest); err != nil {
log.Fatal(err)
}
token = guest.Token
}
static final String API = "https://api.skillsafe.ai/v1/app-api";
// Token copied from /tokens.html, read from the environment:
static String TOKEN = System.getenv("SKILLSAFE_TOKEN");
// …or mint a guest token headlessly (uses api() from step 2):
// String envelope = api("POST", "/guest", "{\"slug\":\"anatomy-atelier\"}");
// the token is at data.token in the returned JSON
API = "https://api.skillsafe.ai/v1/app-api"
# Token copied from /tokens.html:
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
# …or mint a guest token headlessly (uses api() from step 2):
# TOKEN = api("POST", "/guest", { slug: "anatomy-atelier" })["token"]
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
// Token copied from /tokens.html:
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
// …or mint a guest token headlessly (uses api() from step 2):
// $TOKEN = api("POST", "/guest", ["slug" => "anatomy-atelier"])["token"];
const string Api = "https://api.skillsafe.ai/v1/app-api";
// Token copied from /tokens.html:
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
// …or mint a guest token headlessly (uses SkillSafe.ApiAsync from step 2):
// var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
// new { slug = "anatomy-atelier" });
// token = guest.GetProperty("token").GetString();
No DevTools required — /tokens.html shows your session state, the masked token, reveal / copy / copy-shell-export buttons, and a force-new-guest button.
Step 2 — A tiny client
Every call is one HTTP request, so start with a small helper that adds the auth header, sends
JSON and unwraps the data envelope. Steps 3 to 7 reuse it.
# every call looks like:
# curl -s "$API/…" -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-d '{json}']
# jq pulls fields out of the {"data": …} envelope:
# … | jq '.data'
# add -H "Content-Type: application/json" on any POST with a body.
import json, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # see step 1
def api(method, path, body=None, **headers):
res = requests.request(method, API + path, json=body,
headers={"Authorization": f"Bearer {TOKEN}", **headers})
payload = res.json()
if not res.ok:
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code', res.status_code)}: {err.get('message', res.reason)}")
return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your own config in real code
async function api(method, path, body, extraHeaders = {}) {
const res = await fetch(API + path, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...extraHeaders,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error?.code ?? res.status}: ${json.error?.message ?? res.statusText}`);
return json.data;
}
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1
func call(method, path string, body, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, API+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 {
return fmt.Errorf("api %s %s: %s: %s", method, path, env.Error.Code, env.Error.Message)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class AnatomyAtelier {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
static final HttpClient HTTP = HttpClient.newHttpClient();
static String api(String method, String path, String jsonBody) throws Exception {
var req = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body(); // envelope: {"data": …}
}
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1
def api(method, path, body = nil, extra = {})
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
extra.each { |k, v| req[k] = v }
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
unless res.is_a?(Net::HTTPSuccess)
raise "#{payload.dig("error", "code")}: #{payload.dig("error", "message") || res.message}"
end
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1
function api(string $method, string $path, ?array $body = null, array $extra = []): mixed {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array_merge([
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
], $extra),
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception(($payload["error"]["code"] ?? "HTTP $status")
. ": " . ($payload["error"]["message"] ?? ""));
}
return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;
static class SkillSafe
{
const string Api = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new();
static SkillSafe() =>
Http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1
public static async Task<JsonElement> ApiAsync(
HttpMethod method, string path, object? body = null,
(string Name, string Value)[]? extraHeaders = null)
{
var req = new HttpRequestMessage(method, Api + path);
if (body != null) req.Content = JsonContent.Create(body);
if (extraHeaders != null)
foreach (var h in extraHeaders) req.Headers.Add(h.Name, h.Value);
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
return json.GetProperty("data");
}
}
Step 3 — Check the session and your balance
Returns subject_type ("user" or "guest"),
subject_id and the credits balance for the calling token. Call it
first: it tells you whether the token you pasted is your personal session or a stale guest,
and whether there is enough balance for the hold in step 4.
curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
# {"subject_type":"user","subject_id":"usr_…","credits":41250}
me = api("GET", "/me")
print(me["subject_type"], me["subject_id"], me["credits"], "credits")
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits, "credits");
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int64 `json:"credits"`
}
if err := call("GET", "/me", nil, &me); err != nil {
log.Fatal(err)
}
fmt.Println(me.SubjectType, me.Credits)
String envelope = api("GET", "/me", null);
// data.subject_type ("user" | "guest"), data.subject_id, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["subject_id"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']} {$me['subject_id']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");
Step 4 — Estimate the cost (free)
Send the exact body you would send to /run. Nothing is charged, no job is
created, and no model is called. The response tells you which model will run and what the
worst case costs — assert on it in your scripts so a silent model swap or a pricing change
cannot surprise you:
| Response field | Expected for this app | Notes |
|---|---|---|
model_alias | "gpt-terra" | The alias the app pins. Assert on this, not on model. |
model | the concrete model the alias currently resolves to | May change as the platform moves the alias forward; log it, don't hard-code it. |
markup_bps | 1000 | Basis points added on top of model cost (10%). |
hold_credits | integer | Worst-case cost — this is what /run holds. Compare it against /me's credits and refuse the run yourself rather than eating a 402. |
min_credits | integer | Floor for the run, when reported. |
The price_credits for this app is 0: there is no per-run app fee on
top of model cost and markup.
# input.json is the full run body — see step 5 for every field.
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-d @input.json | jq '.data | {model, model_alias, markup_bps, hold_credits, min_credits}'
# assert before spending:
jq -e '.data.model_alias == "gpt-terra" and .data.markup_bps == 1000' <<<"$EST" >/dev/null \
|| echo "unexpected pricing shape — check before running"
est = api("POST", "/estimate", payload) # payload built in step 5
assert est["model_alias"] == "gpt-terra", est["model_alias"]
assert est["markup_bps"] == 1000, est["markup_bps"]
hold = est["hold_credits"]
print(f"{est['model']} (alias {est['model_alias']}), worst case {hold} credits")
if hold > api("GET", "/me")["credits"]:
raise SystemExit("not enough credits — top up before running")
const est = await api("POST", "/estimate", payload); // payload built in step 5
if (est.model_alias !== "gpt-terra") throw new Error(`unexpected alias ${est.model_alias}`);
if (est.markup_bps !== 1000) throw new Error(`unexpected markup ${est.markup_bps}`);
console.log(`${est.model} (alias ${est.model_alias}), worst case ${est.hold_credits} credits`);
const { credits } = await api("GET", "/me");
if (est.hold_credits > credits) throw new Error("not enough credits — top up before running");
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int64 `json:"markup_bps"`
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
}
if err := call("POST", "/estimate", payload, &est); err != nil { // payload from step 5
log.Fatal(err)
}
if est.ModelAlias != "gpt-terra" || est.MarkupBps != 1000 {
log.Fatalf("unexpected pricing: %s / %d bps", est.ModelAlias, est.MarkupBps)
}
fmt.Printf("%s, worst case %d credits\n", est.Model, est.HoldCredits)
String envelope = api("POST", "/estimate", payloadJson); // payloadJson from step 5
// Read these out with your JSON library and assert before spending:
// data.model_alias == "gpt-terra"
// data.markup_bps == 1000
// data.hold_credits <= the credits reported by GET /me
// data.model, data.min_credits are informational — log them.
est = api("POST", "/estimate", payload) # payload from step 5
raise "unexpected alias #{est["model_alias"]}" unless est["model_alias"] == "gpt-terra"
raise "unexpected markup #{est["markup_bps"]}" unless est["markup_bps"] == 1000
puts "#{est["model"]}, worst case #{est["hold_credits"]} credits"
abort "not enough credits" if est["hold_credits"] > api("GET", "/me")["credits"]
$est = api("POST", "/estimate", $payload); // $payload from step 5
if (($est["model_alias"] ?? "") !== "gpt-terra") {
throw new Exception("unexpected alias " . ($est["model_alias"] ?? "?"));
}
if (($est["markup_bps"] ?? 0) !== 1000) {
throw new Exception("unexpected markup " . ($est["markup_bps"] ?? "?"));
}
echo "{$est['model']}, worst case {$est['hold_credits']} credits\n";
if ($est["hold_credits"] > api("GET", "/me")["credits"]) {
throw new Exception("not enough credits — top up before running");
}
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload); // step 5
var alias = est.GetProperty("model_alias").GetString();
var markup = est.GetProperty("markup_bps").GetInt32();
if (alias != "gpt-terra" || markup != 1000)
throw new Exception($"unexpected pricing: {alias} / {markup} bps");
var hold = est.GetProperty("hold_credits").GetInt32();
Console.WriteLine($"{est.GetProperty("model")}, worst case {hold} credits");
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
if (hold > me.GetProperty("credits").GetInt32())
throw new Exception("not enough credits — top up before running");
Step 5 — Grade a recitation and wait for the result
/run places the credit hold and returns a job_id. Poll
/jobs/{job_id} every 1–2 seconds until status is
succeeded or failed. Always send an Idempotency-Key
header: a network retry with the same key and the same body returns the original job instead
of starting a second, double-charged run (a different body under the same key is a
409 CONFLICT).
Input body
| Field | Type | Notes |
|---|---|---|
organ | string, required | The atlas organ id, e.g. "heart". |
organ_brief | object, required | The atlas's reference data for that organ — the model's ground truth. Keys: name, scientific_name, system, size, weight, location, function, blood_supply, tissue, daily_fact, medical, and conditions (string array). Corrections are grounded in this and nothing else. |
recitation | string, required | The learner's own words — what is actually being graded. |
mode | "recitation" | "notes" | "exam" | recitation grades understanding and forgives loose wording; notes grades coverage over phrasing; exam is graded strictly and gets comments on structure. |
level | "beginner" | "student" | "clinical" | Sets the bar and the pitch of corrections and flashcards. A beginner is not penalised for omitting vessel names; a clinical learner is. |
prescan | object | What the app's free client-side term scan found first: word_count (number), terms_total (number), terms_hit / terms_missed (string arrays), and criteria_touched (a subset of the six criterion ids). It is a hint, not an instruction — a concept explained in plain words without the technical term still earns credit. |
clipped | boolean | True when you shortened a long input before sending. Then a criterion is marked partial rather than missing on absence alone. |
clip_note | string | What was cut, e.g. "trailing 1,400 words dropped". Empty string when clipped is false. |
retry_note | string, optional | Only on the reformat retry lane: describe how the previous reply was malformed and it will be obeyed exactly. |
{
"organ": "heart",
"organ_brief": {
"name": "Heart",
"scientific_name": "Cor",
"system": "Cardiovascular",
"size": "About the size of your fist",
"weight": "250-350 g",
"location": "Behind the sternum, slightly left",
"function": "Circulates oxygenated blood",
"blood_supply": "Left and right coronary arteries",
"tissue": "Cardiac muscle tissue",
"daily_fact": "Beats about 100,000 times",
"medical": "Its electrical rhythm coordinates every heartbeat.",
"conditions": ["Coronary artery disease", "Arrhythmia"]
},
"recitation": "The heart sits behind the breastbone, tipped left…",
"mode": "recitation",
"level": "student",
"prescan": {
"word_count": 210,
"terms_total": 12,
"terms_hit": ["coronary", "sternum"],
"terms_missed": ["myocardium"],
"criteria_touched": ["location", "blood_supply"]
},
"clipped": false,
"clip_note": ""
}
# input.json holds the body shown above.
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: study-heart-$(date +%s)" \
-d @input.json | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
# the grader's JSON object arrives as a string at data.output.output
echo "$JOB" | jq -r '.data.output.output' | jq '{verdict, score, headline}'
import time, uuid
payload = {
"organ": "heart",
"organ_brief": organ_brief, # the atlas reference data for this organ
"recitation": recitation,
"mode": "recitation",
"level": "student",
"prescan": prescan, # word_count / terms_* / criteria_touched
"clipped": False,
"clip_note": "",
}
job_id = api("POST", "/run", payload,
**{"Idempotency-Key": f"study-heart-{uuid.uuid4()}"})["job_id"]
while True:
job = api("GET", f"/jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
if job["status"] == "failed":
raise RuntimeError(job.get("error", "run failed"))
study = parse_study(job["output"]) # see step 7
print(study["verdict"], study["score"], study["headline"])
const payload = {
organ: "heart",
organ_brief: organBrief, // the atlas reference data for this organ
recitation,
mode: "recitation", // "recitation" | "notes" | "exam"
level: "student", // "beginner" | "student" | "clinical"
prescan, // word_count / terms_* / criteria_touched
clipped: false,
clip_note: "",
};
const { job_id } = await api("POST", "/run", payload, {
"Idempotency-Key": `study-heart-${crypto.randomUUID()}`,
});
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error ?? "run failed");
const study = parseStudy(job.output); // see step 7
console.log(study.verdict, study.score, study.headline);
payload := map[string]any{
"organ": "heart",
"organ_brief": organBrief,
"recitation": recitation,
"mode": "recitation",
"level": "student",
"prescan": prescan,
"clipped": false,
"clip_note": "",
}
// call() does not set Idempotency-Key; add it to the request in your own
// wrapper — one stable key per distinct recitation.
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
log.Fatal(err)
}
var job struct {
Status string `json:"status"`
Error string `json:"error"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
for {
if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
log.Fatal(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
log.Fatal(job.Error)
}
// job.Output.Output is the grader's JSON object as a string — see step 7.
String body = """
{"organ":"heart","organ_brief":%s,"recitation":%s,
"mode":"recitation","level":"student","prescan":%s,
"clipped":false,"clip_note":""}
""".formatted(organBriefJson, toJsonString(recitation), prescanJson);
// Add the idempotency header in your own overload of api():
// .header("Idempotency-Key", "study-heart-" + UUID.randomUUID())
String envelope = api("POST", "/run", body);
String jobId = /* data.job_id via your JSON library */;
String job;
while (true) {
job = api("GET", "/jobs/" + jobId, null);
String status = /* data.status */;
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(1500);
}
// the grader's JSON object is the string at data.output.output — see step 7.
require "securerandom"
payload = {
organ: "heart",
organ_brief: organ_brief,
recitation: recitation,
mode: "recitation",
level: "student",
prescan: prescan,
clipped: false,
clip_note: "",
}
started = api("POST", "/run", payload,
{ "Idempotency-Key" => "study-heart-#{SecureRandom.uuid}" })
job = nil
loop do
job = api("GET", "/jobs/#{started["job_id"]}")
break if %w[succeeded failed].include?(job["status"])
sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"
study = parse_study(job["output"]) # see step 7
puts "#{study["verdict"]} #{study["score"]}: #{study["headline"]}"
$payload = [
"organ" => "heart",
"organ_brief" => $organBrief,
"recitation" => $recitation,
"mode" => "recitation",
"level" => "student",
"prescan" => $prescan,
"clipped" => false,
"clip_note" => "",
];
$started = api("POST", "/run", $payload, [
"Idempotency-Key: study-heart-" . bin2hex(random_bytes(8)),
]);
do {
sleep(2);
$job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] === "failed") {
throw new Exception($job["error"] ?? "run failed");
}
$study = parse_study($job["output"]); // see step 7
echo "{$study['verdict']} {$study['score']}: {$study['headline']}\n";
var payload = new {
organ = "heart",
organ_brief = organBrief,
recitation,
mode = "recitation",
level = "student",
prescan,
clipped = false,
clip_note = "",
};
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload,
new[] { ("Idempotency-Key", $"study-heart-{Guid.NewGuid()}") });
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(1500);
}
// The grader's JSON object is the string at job.output.output — see step 7.
var raw = job.GetProperty("output").GetProperty("output").GetString()!;
A finished job also reports charged_credits — the settled cost, which is at most
the hold_credits from step 4 and usually less. The unused hold is released
automatically.
Step 6 — The same run, streamed (SSE)
Identical body to /run, but the response is text/event-stream so you
can show the rubric filling in as it is written — this is the path the app itself uses
(runStream). Send the same Idempotency-Key discipline; on an
idempotent replay the endpoint may answer with a plain JSON envelope instead of a stream, so
check the response Content-Type before reaching for a reader.
| Event | Data |
|---|---|
job | {job_id} — the run was accepted and the hold placed. |
delta | {text} — the next chunk of the grader's output. Concatenate for a live preview only. |
done / pending | {job_id, status, charged_credits, output}. |
error | {code, message, job_id} — the codes from the table at the top of this page. |
The terminal done payload is authoritative. Deltas can drop the
tail of the reply, so a rubric assembled purely from concatenated deltas may be missing its
last criteria, its flashcards, or its closing brace. Render deltas
optimistically if you like — the app parses partial text in tolerant mode to paint sections as
they land — but take the value you keep, export or store from done.output.output.
curl -sN -X POST "$API/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: study-heart-$(date +%s)" \
-d @input.json
# event: job data: {"job_id":"job_…"}
# event: delta data: {"text":"{\"verdict\":\"developing\""}
# event: delta data: {"text":",\"score\":68,\"headline\":\"…"}
# …
# event: done data: {"job_id":"job_…","status":"succeeded","charged_credits":318,
# "output":{"output":"{\"verdict\":\"developing\",…}"}}
res = requests.post(API + "/run-stream", json=payload, stream=True,
headers={"Authorization": f"Bearer {TOKEN}",
"Idempotency-Key": f"study-heart-{uuid.uuid4()}"})
event, done, preview = None, None, []
for line in res.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data = json.loads(line[5:])
if event == "delta":
preview.append(data.get("text", "")) # live paint only
elif event in ("done", "pending"):
done = data # authoritative
elif event == "error":
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
study = parse_study(done["output"]) # never from "".join(preview) — see step 7
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": `study-heart-${crypto.randomUUID()}`,
},
body: JSON.stringify(payload),
});
// An idempotent replay can answer with plain JSON instead of a stream:
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json();
return parseStudy(json.data.output);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", event = "message", preview = "", done;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) {
const data = JSON.parse(line.slice(5));
if (event === "delta") preview += data.text ?? ""; // live paint only
else if (event === "done" || event === "pending") done = data;
else if (event === "error") throw new Error(`${data.code}: ${data.message}`);
}
}
}
const study = parseStudy(done.output); // authoritative — see step 7
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "study-heart-"+idempotencySuffix)
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 1<<20), 1<<20)
event, doneData := "", []byte(nil)
var preview strings.Builder
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "event:") {
event = strings.TrimSpace(line[6:])
} else if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(line[5:])
switch event {
case "delta":
var d struct{ Text string `json:"text"` }
json.Unmarshal([]byte(data), &d)
preview.WriteString(d.Text) // live paint only
case "done", "pending":
doneData = []byte(data) // authoritative
case "error":
log.Fatal(data)
}
}
}
// Unmarshal doneData → .output.output (a JSON string) → parseStudy (step 7).
// Never unmarshal preview.String(): deltas can drop the tail.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "study-heart-" + UUID.randomUUID())
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
var lines = HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body();
final String[] event = {""};
StringBuilder preview = new StringBuilder();
StringBuilder doneData = new StringBuilder();
lines.forEach(line -> {
if (line.startsWith("event:")) event[0] = line.substring(6).trim();
else if (line.startsWith("data:")) {
String data = line.substring(5).trim();
if (event[0].equals("delta")) preview.append(/* parse {"text"} */ data);
else if (event[0].equals("done") || event[0].equals("pending")) doneData.append(data);
else if (event[0].equals("error")) throw new RuntimeException(data);
}
});
// Parse doneData → output.output (a JSON string) → the rubric (step 7).
// The concatenated preview is for display only; it can be truncated.
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "study-heart-#{SecureRandom.uuid}"
req.body = payload.to_json
event, done, preview, buf = nil, nil, +"", +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n"))
line = buf.slice!(0..i).chomp
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:")
data = JSON.parse(line[5..])
preview << data["text"].to_s if event == "delta" # live paint only
done = data if %w[done pending].include?(event)
raise "#{data["code"]}: #{data["message"]}" if event == "error"
end
end
end
end
end
study = parse_study(done["output"]) # authoritative — see step 7
$event = ""; $done = null; $preview = ""; $buf = "";
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: study-heart-" . bin2hex(random_bytes(8)),
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done, &$preview, &$buf) {
$buf .= $chunk;
while (($i = strpos($buf, "\n")) !== false) {
$line = rtrim(substr($buf, 0, $i));
$buf = substr($buf, $i + 1);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$data = json_decode(substr($line, 5), true);
if ($event === "delta") $preview .= $data["text"] ?? ""; // display only
if ($event === "done" || $event === "pending") $done = $data;
if ($event === "error") throw new Exception($data["message"] ?? "stream error");
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$study = parse_study($done["output"]); // authoritative — see step 7
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream")
{ Content = JsonContent.Create(payload) };
req.Headers.Add("Idempotency-Key", $"study-heart-{Guid.NewGuid()}");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? line; string ev = ""; var preview = new StringBuilder(); JsonElement doneEl = default;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:"))
{
var data = JsonDocument.Parse(line[5..]).RootElement.Clone();
if (ev == "delta")
preview.Append(data.TryGetProperty("text", out var t) ? t.GetString() : "");
else if (ev is "done" or "pending") doneEl = data; // authoritative
else if (ev == "error")
throw new Exception(data.GetProperty("message").GetString());
}
}
// Read the result from doneEl, not from preview — deltas can drop the tail.
var raw = doneEl.GetProperty("output").GetProperty("output").GetString()!;
Step 7 — Parse the graded rubric
The grader replies with exactly one JSON object and nothing else, delivered as
a string at output.output (occasionally at output directly).
Models being models, wrap the parse: strip any code fences, take the outermost
{…}, JSON.parse, then normalize. Those are the same steps the app's
own parseStudy() performs before rendering, and reproducing them is what makes
your client's output identical to the app's.
{
"verdict": "mastered",
"score": 84,
"headline": "Strong on function and blood supply; the tissue layer is still thin.",
"rubric": [
{
"id": "location",
"criterion": "Location in the body",
"status": "correct",
"evidence": "\"sits behind the breastbone, tipped left\"",
"correction": ""
}
],
"misconceptions": [
{ "claim": "the heart oxygenates blood", "why_wrong": "…", "correct": "…" }
],
"missed": ["…"],
"strengths": ["…"],
"next_steps": ["…"],
"flashcards": [{ "front": "…", "back": "…" }]
}
Top-level fields
| Field | Type | Notes |
|---|---|---|
verdict | "mastered" | "developing" | "needs_review" | Derived from score: ≥ 80 mastered, 50–79 developing, < 50 needs_review. Anything else is clamped. |
score | integer 0–100 | Roughly 100 × (correct 1, partial 0.5, missing/incorrect 0) / 6, adjusted by at most ±8 for quality of explanation. Coerce to an integer and clamp to the range. |
headline | string | One sentence, specific to this learner's writing. |
rubric | array — exactly six entries | See below. |
misconceptions | {claim, why_wrong, correct}[] | Every incorrect criterion also appears here. May be []. |
missed | string[] | Facts from organ_brief the learner never reached. May be []. |
strengths | string[] | What was genuinely well said. Empty only when the recitation contained nothing correct. |
next_steps | string[] | Concrete study actions. May be []. |
flashcards | {front, back}[] | Typically 3–8 cards, aimed at the gaps the rubric found. May be []. front,back is the app's flashcards.csv export — Anki-importable as-is. |
The six criteria — always all six, always in this order
| # | id | criterion | What earns correct |
|---|---|---|---|
| 1 | location | Location in the body | Places the organ correctly relative to landmarks. |
| 2 | structure | Structure and size | Gross form, chambers/lobes/layers as applicable, and a defensible sense of scale or weight. |
| 3 | function | Function | The primary physiological role, stated causally rather than as a label. |
| 4 | blood_supply | Blood supply | Names or accurately describes the supplying vessels. |
| 5 | tissue | Tissue and microscopic structure | The characteristic tissue type and why it suits the function. |
| 6 | clinical | Clinical relevance | At least one condition, and why the anatomy causes it. |
Each entry is {id, criterion, status, evidence, correction} where
status is correct | partial | missing |
incorrect, evidence is a short verbatim quote from
recitation ("" when the status is missing), and
correction is non-empty for every status except correct.
The normalization rules to reproduce
- Strip code fences and any prose around the object; keep the outermost
{…}. verdictnot in the allowed set → clamp (fall back to the band implied byscore).score→ integer, clamped to 0–100.rubric→ re-ordered tolocation,structure,function,blood_supply,tissue,clinical, and back-filled: a criterion the model omitted becomesstatus:"missing"with an emptyevidence, never an absent entry. Result: always six rows, never a short list. Unknownstatusvalues are clamped tomissing.misconceptions,missed,strengths,next_steps,flashcards→ default to[]when absent or the wrong type. The app prints "None" under the heading rather than an empty list.- If the whole parse fails, retry the run once with the same body plus a
retry_notedescribing what was wrong — that is exactly what the app does.
# Unwrap the envelope, then the output string, then normalize with jq.
IDS='["location","structure","function","blood_supply","tissue","clinical"]'
echo "$JOB" | jq -r '.data.output.output // .data.output' \
| sed -e 's/^```json//' -e 's/^```//' -e 's/```$//' \
| jq --argjson ids "$IDS" '
. as $s
| {
verdict: (if (["mastered","developing","needs_review"] | index($s.verdict))
then $s.verdict
elif ($s.score // 0) >= 80 then "mastered"
elif ($s.score // 0) >= 50 then "developing"
else "needs_review" end),
score: ([[($s.score // 0) | floor, 0] | max, 100] | min),
headline: ($s.headline // ""),
rubric: [ $ids[] as $id
| (($s.rubric // []) | map(select(.id == $id)) | first)
// {id: $id, criterion: $id, status: "missing", evidence: "", correction: ""}
| .status = (if (["correct","partial","missing","incorrect"] | index(.status))
then .status else "missing" end) ],
misconceptions: ($s.misconceptions // []),
missed: ($s.missed // []),
strengths: ($s.strengths // []),
next_steps: ($s.next_steps // []),
flashcards: ($s.flashcards // [])
}'
IDS = ["location", "structure", "function", "blood_supply", "tissue", "clinical"]
LABELS = {"location": "Location in the body", "structure": "Structure and size",
"function": "Function", "blood_supply": "Blood supply",
"tissue": "Tissue and microscopic structure", "clinical": "Clinical relevance"}
VERDICTS = ("mastered", "developing", "needs_review")
STATUSES = ("correct", "partial", "missing", "incorrect")
def parse_study(raw):
# 1 — unwrap {"output": …} and the JSON-string payload
if isinstance(raw, dict) and "output" in raw:
raw = raw["output"]
if isinstance(raw, str):
text = raw.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1].rsplit("```", 1)[0]
start, end = text.find("{"), text.rfind("}")
raw = json.loads(text[start:end + 1])
# 2 — clamp score, then verdict
try:
score = max(0, min(100, int(float(raw.get("score", 0)))))
except (TypeError, ValueError):
score = 0
verdict = raw.get("verdict")
if verdict not in VERDICTS:
verdict = "mastered" if score >= 80 else "developing" if score >= 50 else "needs_review"
# 3 — exactly six rubric rows, in order, back-filled
by_id = {r.get("id"): r for r in raw.get("rubric") or [] if isinstance(r, dict)}
rubric = []
for cid in IDS:
row = by_id.get(cid) or {}
status = row.get("status") if row.get("status") in STATUSES else "missing"
rubric.append({
"id": cid,
"criterion": row.get("criterion") or LABELS[cid],
"status": status,
"evidence": "" if status == "missing" else (row.get("evidence") or ""),
"correction": row.get("correction") or "",
})
# 4 — arrays default to []
def arr(key):
v = raw.get(key)
return v if isinstance(v, list) else []
return {"verdict": verdict, "score": score, "headline": raw.get("headline") or "",
"rubric": rubric, "misconceptions": arr("misconceptions"),
"missed": arr("missed"), "strengths": arr("strengths"),
"next_steps": arr("next_steps"), "flashcards": arr("flashcards")}
const IDS = ["location", "structure", "function", "blood_supply", "tissue", "clinical"];
const LABELS = {
location: "Location in the body", structure: "Structure and size",
function: "Function", blood_supply: "Blood supply",
tissue: "Tissue and microscopic structure", clinical: "Clinical relevance",
};
const VERDICTS = ["mastered", "developing", "needs_review"];
const STATUSES = ["correct", "partial", "missing", "incorrect"];
function parseStudy(rawInput) {
// 1 — unwrap {output: …} and the JSON-string payload
let raw = rawInput?.output ?? rawInput;
if (typeof raw === "string") {
let text = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
raw = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
}
// 2 — clamp score, then verdict
const score = Math.max(0, Math.min(100, Math.trunc(Number(raw.score) || 0)));
const verdict = VERDICTS.includes(raw.verdict)
? raw.verdict
: score >= 80 ? "mastered" : score >= 50 ? "developing" : "needs_review";
// 3 — exactly six rubric rows, in order, back-filled
const byId = new Map((Array.isArray(raw.rubric) ? raw.rubric : []).map((r) => [r?.id, r]));
const rubric = IDS.map((id) => {
const row = byId.get(id) ?? {};
const status = STATUSES.includes(row.status) ? row.status : "missing";
return {
id,
criterion: row.criterion || LABELS[id],
status,
evidence: status === "missing" ? "" : (row.evidence ?? ""),
correction: row.correction ?? "",
};
});
// 4 — arrays default to []
const arr = (k) => (Array.isArray(raw[k]) ? raw[k] : []);
return {
verdict, score, headline: raw.headline ?? "", rubric,
misconceptions: arr("misconceptions"), missed: arr("missed"),
strengths: arr("strengths"), next_steps: arr("next_steps"),
flashcards: arr("flashcards"),
};
}
var criterionIDs = []string{"location", "structure", "function", "blood_supply", "tissue", "clinical"}
var criterionLabels = map[string]string{
"location": "Location in the body", "structure": "Structure and size",
"function": "Function", "blood_supply": "Blood supply",
"tissue": "Tissue and microscopic structure", "clinical": "Clinical relevance",
}
type RubricRow struct {
ID string `json:"id"`
Criterion string `json:"criterion"`
Status string `json:"status"`
Evidence string `json:"evidence"`
Correction string `json:"correction"`
}
type Study struct {
Verdict string `json:"verdict"`
Score int `json:"score"`
Headline string `json:"headline"`
Rubric []RubricRow `json:"rubric"`
Misconceptions []map[string]string `json:"misconceptions"`
Missed []string `json:"missed"`
Strengths []string `json:"strengths"`
NextSteps []string `json:"next_steps"`
Flashcards []map[string]string `json:"flashcards"`
}
func parseStudy(raw string) (*Study, error) {
// 1 — strip fences, keep the outermost object
text := strings.TrimSpace(raw)
text = strings.TrimPrefix(strings.TrimPrefix(text, "```json"), "```")
text = strings.TrimSuffix(strings.TrimSpace(text), "```")
if i, j := strings.Index(text, "{"), strings.LastIndex(text, "}"); i >= 0 && j > i {
text = text[i : j+1]
}
var s Study
if err := json.Unmarshal([]byte(text), &s); err != nil {
return nil, err
}
// 2 — clamp score, then verdict
if s.Score < 0 {
s.Score = 0
} else if s.Score > 100 {
s.Score = 100
}
switch s.Verdict {
case "mastered", "developing", "needs_review":
default:
if s.Score >= 80 {
s.Verdict = "mastered"
} else if s.Score >= 50 {
s.Verdict = "developing"
} else {
s.Verdict = "needs_review"
}
}
// 3 — exactly six rows, in order, back-filled
byID := map[string]RubricRow{}
for _, r := range s.Rubric {
byID[r.ID] = r
}
rows := make([]RubricRow, 0, len(criterionIDs))
for _, id := range criterionIDs {
row := byID[id]
row.ID = id
switch row.Status {
case "correct", "partial", "incorrect":
default:
row.Status = "missing"
row.Evidence = ""
}
if row.Criterion == "" {
row.Criterion = criterionLabels[id]
}
rows = append(rows, row)
}
s.Rubric = rows
// 4 — nil slices become empty slices
if s.Missed == nil {
s.Missed = []string{}
}
if s.Strengths == nil {
s.Strengths = []string{}
}
if s.NextSteps == nil {
s.NextSteps = []string{}
}
if s.Misconceptions == nil {
s.Misconceptions = []map[string]string{}
}
if s.Flashcards == nil {
s.Flashcards = []map[string]string{}
}
return &s, nil
}
// Sketch with your JSON library of choice (Jackson shown conceptually).
static final List<String> IDS = List.of(
"location", "structure", "function", "blood_supply", "tissue", "clinical");
static final Map<String, String> LABELS = Map.of(
"location", "Location in the body",
"structure", "Structure and size",
"function", "Function",
"blood_supply", "Blood supply",
"tissue", "Tissue and microscopic structure",
"clinical", "Clinical relevance");
static final Set<String> VERDICTS = Set.of("mastered", "developing", "needs_review");
static final Set<String> STATUSES = Set.of("correct", "partial", "missing", "incorrect");
static ObjectNode parseStudy(String rawOutput) throws Exception {
// 1 — strip fences, keep the outermost {…}
String text = rawOutput.strip();
if (text.startsWith("```")) {
text = text.substring(text.indexOf('\n') + 1);
int fence = text.lastIndexOf("```");
if (fence >= 0) text = text.substring(0, fence);
}
text = text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1);
ObjectNode s = (ObjectNode) MAPPER.readTree(text);
// 2 — clamp score, then verdict
int score = Math.max(0, Math.min(100, s.path("score").asInt(0)));
s.put("score", score);
if (!VERDICTS.contains(s.path("verdict").asText())) {
s.put("verdict", score >= 80 ? "mastered" : score >= 50 ? "developing" : "needs_review");
}
// 3 — exactly six rows, in order, back-filled
Map<String, JsonNode> byId = new HashMap<>();
s.path("rubric").forEach(r -> byId.put(r.path("id").asText(), r));
ArrayNode rows = MAPPER.createArrayNode();
for (String id : IDS) {
JsonNode row = byId.get(id);
String status = row == null ? "missing" : row.path("status").asText("missing");
if (!STATUSES.contains(status)) status = "missing";
ObjectNode out = MAPPER.createObjectNode()
.put("id", id)
.put("criterion", row == null || row.path("criterion").asText("").isEmpty()
? LABELS.get(id) : row.path("criterion").asText())
.put("status", status)
.put("evidence", status.equals("missing") || row == null
? "" : row.path("evidence").asText(""))
.put("correction", row == null ? "" : row.path("correction").asText(""));
rows.add(out);
}
s.set("rubric", rows);
// 4 — arrays default to []
for (String key : List.of("misconceptions", "missed", "strengths", "next_steps", "flashcards")) {
if (!s.path(key).isArray()) s.set(key, MAPPER.createArrayNode());
}
return s;
}
IDS = %w[location structure function blood_supply tissue clinical].freeze
LABELS = {
"location" => "Location in the body", "structure" => "Structure and size",
"function" => "Function", "blood_supply" => "Blood supply",
"tissue" => "Tissue and microscopic structure", "clinical" => "Clinical relevance",
}.freeze
VERDICTS = %w[mastered developing needs_review].freeze
STATUSES = %w[correct partial missing incorrect].freeze
def parse_study(raw)
# 1 — unwrap {"output" => …} and the JSON-string payload
raw = raw["output"] if raw.is_a?(Hash) && raw.key?("output")
if raw.is_a?(String)
text = raw.strip.sub(/\A```(?:json)?\s*/i, "").sub(/```\s*\z/, "")
raw = JSON.parse(text[text.index("{")..text.rindex("}")])
end
# 2 — clamp score, then verdict
score = raw["score"].to_i.clamp(0, 100)
verdict = VERDICTS.include?(raw["verdict"]) ? raw["verdict"]
: score >= 80 ? "mastered" : score >= 50 ? "developing" : "needs_review"
# 3 — exactly six rows, in order, back-filled
by_id = Array(raw["rubric"]).select { |r| r.is_a?(Hash) }.to_h { |r| [r["id"], r] }
rubric = IDS.map do |id|
row = by_id[id] || {}
status = STATUSES.include?(row["status"]) ? row["status"] : "missing"
{
"id" => id,
"criterion" => (row["criterion"].to_s.empty? ? LABELS[id] : row["criterion"]),
"status" => status,
"evidence" => (status == "missing" ? "" : row["evidence"].to_s),
"correction" => row["correction"].to_s,
}
end
# 4 — arrays default to []
arrays = %w[misconceptions missed strengths next_steps flashcards]
.to_h { |k| [k, raw[k].is_a?(Array) ? raw[k] : []] }
{ "verdict" => verdict, "score" => score, "headline" => raw["headline"].to_s,
"rubric" => rubric }.merge(arrays)
end
const IDS = ["location", "structure", "function", "blood_supply", "tissue", "clinical"];
const LABELS = [
"location" => "Location in the body", "structure" => "Structure and size",
"function" => "Function", "blood_supply" => "Blood supply",
"tissue" => "Tissue and microscopic structure", "clinical" => "Clinical relevance",
];
const VERDICTS = ["mastered", "developing", "needs_review"];
const STATUSES = ["correct", "partial", "missing", "incorrect"];
function parse_study(mixed $raw): array {
// 1 — unwrap ["output" => …] and the JSON-string payload
if (is_array($raw) && array_key_exists("output", $raw)) $raw = $raw["output"];
if (is_string($raw)) {
$text = preg_replace('/^```(?:json)?\s*/i', "", trim($raw));
$text = preg_replace('/```\s*$/', "", $text);
$i = strpos($text, "{");
$j = strrpos($text, "}");
$raw = json_decode(substr($text, $i, $j - $i + 1), true, 512, JSON_THROW_ON_ERROR);
}
// 2 — clamp score, then verdict
$score = max(0, min(100, (int) ($raw["score"] ?? 0)));
$verdict = in_array($raw["verdict"] ?? "", VERDICTS, true)
? $raw["verdict"]
: ($score >= 80 ? "mastered" : ($score >= 50 ? "developing" : "needs_review"));
// 3 — exactly six rows, in order, back-filled
$byId = [];
foreach ((array) ($raw["rubric"] ?? []) as $row) {
if (is_array($row) && isset($row["id"])) $byId[$row["id"]] = $row;
}
$rubric = [];
foreach (IDS as $id) {
$row = $byId[$id] ?? [];
$status = in_array($row["status"] ?? "", STATUSES, true) ? $row["status"] : "missing";
$rubric[] = [
"id" => $id,
"criterion" => $row["criterion"] ?? LABELS[$id],
"status" => $status,
"evidence" => $status === "missing" ? "" : ($row["evidence"] ?? ""),
"correction" => $row["correction"] ?? "",
];
}
// 4 — arrays default to []
$out = ["verdict" => $verdict, "score" => $score,
"headline" => $raw["headline"] ?? "", "rubric" => $rubric];
foreach (["misconceptions", "missed", "strengths", "next_steps", "flashcards"] as $k) {
$out[$k] = is_array($raw[$k] ?? null) ? $raw[$k] : [];
}
return $out;
}
static readonly string[] Ids =
{ "location", "structure", "function", "blood_supply", "tissue", "clinical" };
static readonly Dictionary<string, string> Labels = new() {
["location"] = "Location in the body", ["structure"] = "Structure and size",
["function"] = "Function", ["blood_supply"] = "Blood supply",
["tissue"] = "Tissue and microscopic structure", ["clinical"] = "Clinical relevance",
};
static readonly string[] Verdicts = { "mastered", "developing", "needs_review" };
static readonly string[] Statuses = { "correct", "partial", "missing", "incorrect" };
record RubricRow(string Id, string Criterion, string Status, string Evidence, string Correction);
static (string Verdict, int Score, string Headline, List<RubricRow> Rubric) ParseStudy(string rawOutput)
{
// 1 — strip fences, keep the outermost {…}
var text = rawOutput.Trim();
if (text.StartsWith("```"))
{
text = text[(text.IndexOf('\n') + 1)..];
var fence = text.LastIndexOf("```", StringComparison.Ordinal);
if (fence >= 0) text = text[..fence];
}
text = text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)];
var root = JsonDocument.Parse(text).RootElement;
// 2 — clamp score, then verdict
var score = root.TryGetProperty("score", out var sc) && sc.TryGetInt32(out var n)
? Math.Clamp(n, 0, 100) : 0;
var verdict = root.TryGetProperty("verdict", out var v) && Verdicts.Contains(v.GetString())
? v.GetString()!
: score >= 80 ? "mastered" : score >= 50 ? "developing" : "needs_review";
// 3 — exactly six rows, in order, back-filled
var byId = new Dictionary<string, JsonElement>();
if (root.TryGetProperty("rubric", out var arr) && arr.ValueKind == JsonValueKind.Array)
foreach (var r in arr.EnumerateArray())
if (r.TryGetProperty("id", out var idEl) && idEl.GetString() is string id)
byId[id] = r;
var rubric = Ids.Select(id =>
{
var has = byId.TryGetValue(id, out var row);
string Str(string key) => has && row.TryGetProperty(key, out var p)
? p.GetString() ?? "" : "";
var status = Statuses.Contains(Str("status")) ? Str("status") : "missing";
var criterion = Str("criterion") is { Length: > 0 } c ? c : Labels[id];
return new RubricRow(id, criterion, status,
status == "missing" ? "" : Str("evidence"), Str("correction"));
}).ToList();
// 4 — misconceptions / missed / strengths / next_steps / flashcards:
// read with ValueKind == JsonValueKind.Array checks and fall back to
// an empty list, so a missing section renders as "None".
var headline = root.TryGetProperty("headline", out var h) ? h.GetString() ?? "" : "";
return (verdict, score, headline, rubric);
}
Because the loop is study, then recite again, a second run on the same organ is worth diffing:
the app keeps the last 20 records under the history key
({id, organ, mode, level, score, verdict, when, recitation, result}, newest
first) and renders a score delta against that organ's most recent previous attempt. Rebuild
the same thing in your own client by comparing score and per-criterion
status across runs — keeping organ_brief, mode and
level identical is what keeps the delta meaningful.
This is a study aid, not medical advice. The grader marks only the anatomy in a recitation and will never read it as a description of your own symptoms — do not build a diagnostic flow on top of it.