Build a demand plan from your own scripts
Send one paste — per-SKU demand history like SKU-A: 120, 118, 131, ...
(oldest period first) plus the scenario prose around it: lead times, MOQs, planned
promotions, service targets, a new item with no history — and get back the plan a
senior demand planner would write. Plain text, in a fixed shape: a coverage call, the
horizon, a confidence integer, a summary, then a pattern read per series, a forecast method
chosen to fit each pattern, safety stock and reorder points with the z-value, lead time and
standard deviation stated in the same row, plus risks, actions and open questions. Every
computable safety stock and reorder point carries its arithmetic inline in backticks, so a
caller can re-run the expression and confirm it reproduces the number printed beside it
— the check the web app runs in the browser, and the one
step 7 shows you how to run yourself. One paste in, one plan out, no
follow-up calls and no session state to carry.
| Status | Meaning |
|---|---|
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest planning a very large paste). |
404 | Unknown job or record id. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
Step 0 — A tiny client
Every task below is a single HTTP call, so start with a short helper that adds the auth
header, sends JSON and unwraps the data envelope. The later steps reuse it.
The base URL is https://api.skillsafe.ai/v1/app-api and the paths are
/guest, /me, /estimate, /run,
/run-stream and /jobs/{job_id} — there is no per-app path
segment, because the app slug is bound to the token when you mint it in step 1.
export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN" # see step 1
# every call looks like:
# curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # see step 1 — or os.environ["SKILLSAFE_TOKEN"] in real code
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:
raise RuntimeError(payload.get("error", {}).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 shell environment 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?.message ?? res.statusText);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
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{ Message string `json:"message"` } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 {
return fmt.Errorf("api %s %s: %s", method, path, 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 SkillSafe {
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["SKILLSAFE_TOKEN"] # see step 1
def api(method, path, body = nil)
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
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)
raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
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): mixed {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
],
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"]["message"] ?? "HTTP $status");
}
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)
{
var req = new HttpRequestMessage(method, Api + path);
if (body != null) req.Content = JsonContent.Create(body);
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 1 — Get a token
A guest token lets you check balances and estimate costs for free. For metered plan runs
billed to your own account, use your personal token: open the
token page, sign in with SkillSafe, and press
Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your
clipboard, which every example below reads. Treat the token like a password: it can spend
your credits. For fully headless scripts, POST /guest mints a guest token with
no browser involved. The slug in the body is what binds the token to this app,
which is why no later path carries the app name.
curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"demand-desk"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "demand-desk"})["token"]
const { token } = await api("POST", "/guest", { slug: "demand-desk" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "demand-desk"}, &guest)
String envelope = api("POST", "/guest", """
{"slug":"demand-desk"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "demand-desk" })["token"]
$token = api("POST", "/guest", ["slug" => "demand-desk"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
new { slug = "demand-desk" });
var token = guest.GetProperty("token").GetString();
The app stores this browser's token under the localStorage key
skillsafe_app_token:demand-desk, on the app's own origin. The
token page reads and manages it for you — you never need
to open developer tools.
Step 2 — Check who you are and your balance
Returns subject_type ("user" or "guest"),
subject_id and your credits balance. Check this before planning a
long paste or sweeping a whole category of SKUs.
curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");
Step 3 — The input object, and what a run will cost
The request body for /estimate, /run and /run-stream
is the input object itself — not wrapped in an input
key. Send exactly what you would send to /run; the response's
hold_credits is the worst-case cost. Nothing is charged and no job is created,
so estimating is free — useful when you are about to plan a whole category and want a
ceiling before spending credits. The estimate also carries model,
model_alias, markup_bps and min_credits, which is
what the app's own cost meter renders under the paste box.
| Input field | Type | Notes |
|---|---|---|
data | string, required | The paste. Per-item demand history lines like SKU-A: 120, 118, 131, ... with the oldest period first, one line per item, plus the scenario prose around them: lead times and lead-time changes, MOQs and pack sizes, planned promotions, service-level targets, new items with no history. Freeform notes with numbers work too. The web client clips this at 40,000 characters and leaves a marker where text was removed; text either side of the marker is verbatim, and the plan treats the removed span as unknown rather than guessing at it. An API caller can send up to the same limit. |
notes | string, optional | Business context and constraints, up to 6,000 characters: what you actually need out of the run, budget or open-to-buy caps, warehouse space, "the supplier will not budge on the MOQ", the failure you are most worried about. When the notes say you already know about an issue, the plan still lists the risk but attaches your caveat to it. |
task | string, required | Exactly one of Full plan, Forecast review, Safety stock and reorder, Promotion planning or New item launch. Full plan covers everything the data supports; the narrower tasks shift depth of attention but never suppress a serious risk from another area — a stockout the numbers predict is still reported on a Promotion planning run. |
facts | string, optional | A plain-text mechanical scan of the paste: one line per detected series with its period count, mean, standard deviation, coefficient of variation, volatility class, zero-period count and a naive trend read. The web client computes this in the browser and ships it as a cross-check hint, never a verdict — where the plan's own reading of the paste disagrees with a scanned series (the scanner misparsed a line), the reading wins. API callers may omit the field entirely or supply their own scan in the same shape. |
retry_note | string, optional | A formatting-only instruction the app sends on an automatic second attempt after a first reply failed to parse. It restates the required output shape and nothing else: it never changes the planning task, the data, the method or the grounding rules — only the shape of the reply. Leave it out on a first call. |
cat > input.json <<'JSON'
{
"data": "Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.\n\nKIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882\nGPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241\nAQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7\nCHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201\n\nScenario:\n1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month. Nothing else changes lead time.\n2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.\n3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.\n4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN) and 95 percent for the rest.\n5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; buying wants GPS-TRK-COLLAR as the analog.\n\nCHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
"notes": "I need a 13-week forecast by SKU plus a buy plan: order quantities, order timing and the safety stock assumed for each item. Hard constraint is 240,000 dollars of open-to-buy for this category through end of quarter. Call out explicitly how the 2-to-3 week lead time move changes the cover I need on the two Northbrook items. If the elk chew history needs the week 6 promo cleaned out before you fit a baseline, do that and say so.",
"task": "Full plan",
"facts": "Mechanical scan of the paste (arithmetic, not judgement):\n- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable; half-over-half trend +0%; naive 4-period MA 882/period.\n- GPS-TRK-COLLAR: 12 periods, mean 174.7, sd 40.1, CV 0.23, class variable; half-over-half trend +59%; naive 4-period MA 222/period."
}
JSON
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @input.json | jq '.data | {model, model_alias, markup_bps, hold_credits, min_credits}'
history = (
"Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.\n\n"
"KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882\n"
"GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241\n"
"AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7\n"
"CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201\n\n"
"Scenario:\n"
"1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week "
"lead time next month. Nothing else changes lead time.\n"
"2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.\n"
"3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.\n"
"4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN) and "
"95 percent for the rest.\n"
"5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; buying wants "
"GPS-TRK-COLLAR as the analog.\n\n"
"CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough."
)
payload = {
"data": history,
"notes": "I need a 13-week forecast by SKU plus a buy plan: order quantities, order "
"timing and the safety stock assumed for each item. Hard constraint is "
"240,000 dollars of open-to-buy for this category through end of quarter. "
"Call out explicitly how the 2-to-3 week lead time move changes the cover I "
"need on the two Northbrook items. If the elk chew history needs the week 6 "
"promo cleaned out before you fit a baseline, do that and say so.",
"task": "Full plan",
# optional — omit it and the plan simply reads the paste itself
"facts": "Mechanical scan of the paste (arithmetic, not judgement):\n"
"- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable.\n"
"- GPS-TRK-COLLAR: 12 periods, mean 174.7, sd 40.1, CV 0.23, class variable.",
}
est = api("POST", "/estimate", payload)
print(est["model"], est.get("model_alias"), "markup", est.get("markup_bps"), "bps")
print("worst case:", est.get("hold_credits", est.get("credits")),
"credits; floor", est.get("min_credits"))
const history = [
"Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.",
"",
"KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882",
"GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241",
"AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7",
"CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201",
"",
"Scenario:",
"1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.",
"2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.",
"3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.",
"4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.",
"5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; buying wants GPS-TRK-COLLAR as the analog.",
"",
"CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
].join("\n");
const payload = {
data: history,
notes:
"I need a 13-week forecast by SKU plus a buy plan: order quantities, order timing and " +
"the safety stock assumed for each item. Hard constraint is 240,000 dollars of " +
"open-to-buy for this category through end of quarter. Call out explicitly how the " +
"2-to-3 week lead time move changes the cover I need on the two Northbrook items. If " +
"the elk chew history needs the week 6 promo cleaned out before you fit a baseline, " +
"do that and say so.",
task: "Full plan",
// optional: a mechanical scan of the paste, used only as a cross-check hint
facts:
"Mechanical scan of the paste (arithmetic, not judgement):\n" +
"- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable.\n" +
"- GPS-TRK-COLLAR: 12 periods, mean 174.7, sd 40.1, CV 0.23, class variable.",
};
const est = await api("POST", "/estimate", payload);
console.log(est.model, est.model_alias, "markup", est.markup_bps, "bps");
console.log("worst case:", est.hold_credits ?? est.credits, "credits; floor", est.min_credits);
history := strings.Join([]string{
"Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.",
"",
"KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882",
"GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241",
"AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7",
"CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201",
"",
"Scenario:",
"1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.",
"2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.",
"3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.",
"4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.",
"5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; buying wants GPS-TRK-COLLAR as the analog.",
"",
"CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
}, "\n")
payload := map[string]any{
"data": history,
"notes": "I need a 13-week forecast by SKU plus a buy plan: order quantities, order timing " +
"and the safety stock assumed for each item. Hard constraint is 240,000 dollars of " +
"open-to-buy for this category through end of quarter. Call out explicitly how the " +
"2-to-3 week lead time move changes the cover I need on the two Northbrook items.",
"task": "Full plan",
// "facts" is optional — a mechanical scan used only as a cross-check hint.
"facts": "Mechanical scan of the paste (arithmetic, not judgement):\n" +
"- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable.",
}
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"`
}
err := call("POST", "/estimate", payload, &est)
// A text block keeps the JSON readable; \\n stays a JSON escape, not a real newline.
String jsonPayload = """
{"data": "Weekly units, Wk 1 (oldest) through Wk 12.\\n\\nKIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882\\nGPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241\\nAQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7\\nCHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201\\n\\nScenario:\\n1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.\\n2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.\\n3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.\\n4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.\\n5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; buying wants GPS-TRK-COLLAR as the analog.\\n\\nCHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
"notes": "I need a 13-week forecast by SKU plus a buy plan. Hard constraint is 240,000 dollars of open-to-buy through end of quarter. Call out how the 2-to-3 week lead time move changes the cover on the two Northbrook items.",
"task": "Full plan",
"facts": "Mechanical scan of the paste (arithmetic, not judgement):\\n- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable."}
""";
String envelope = api("POST", "/estimate", jsonPayload);
// data.model, data.model_alias, data.markup_bps, data.hold_credits, data.min_credits
// hold_credits is the worst-case reservation; nothing is charged by /estimate.
history = [
"Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.",
"",
"KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882",
"GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241",
"AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7",
"CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201",
"",
"Scenario:",
"1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.",
"2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.",
"3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.",
"4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.",
"5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; the analog is GPS-TRK-COLLAR.",
"",
"CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough."
].join("\n")
payload = {
data: history,
notes: "I need a 13-week forecast by SKU plus a buy plan: order quantities, order timing " \
"and the safety stock assumed for each item. Hard constraint is 240,000 dollars of " \
"open-to-buy through end of quarter. Call out explicitly how the 2-to-3 week lead " \
"time move changes the cover I need on the two Northbrook items.",
task: "Full plan",
facts: "Mechanical scan of the paste (arithmetic, not judgement):\n" \
"- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable."
}
est = api("POST", "/estimate", payload)
puts "#{est["model"]} (#{est["model_alias"]}), markup #{est["markup_bps"]} bps"
puts "worst case: #{est["hold_credits"] || est["credits"]} credits, floor #{est["min_credits"]}"
$history = implode("\n", [
"Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.",
"",
"KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882",
"GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241",
"AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7",
"CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201",
"",
"Scenario:",
"1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.",
"2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.",
"3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.",
"4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.",
"5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; the analog is GPS-TRK-COLLAR.",
"",
"CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
]);
$payload = [
"data" => $history,
"notes" => "I need a 13-week forecast by SKU plus a buy plan: order quantities, order "
. "timing and the safety stock assumed for each item. Hard constraint is 240,000 "
. "dollars of open-to-buy through end of quarter. Call out explicitly how the "
. "2-to-3 week lead time move changes the cover on the two Northbrook items.",
"task" => "Full plan",
"facts" => "Mechanical scan of the paste (arithmetic, not judgement):\n"
. "- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable.",
];
$est = api("POST", "/estimate", $payload);
echo "{$est['model']} ({$est['model_alias']}), markup {$est['markup_bps']} bps\n";
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits, "
. "floor {$est['min_credits']}\n";
var history = string.Join("\n", new[] {
"Weekly units, Wk 1 (oldest) through Wk 12, all channels, net of returns.",
"",
"KIB-SALM-24: 872, 905, 861, 893, 878, 866, 899, 884, 870, 891, 875, 882",
"GPS-TRK-COLLAR: 120, 134, 128, 145, 152, 161, 178, 190, 203, 215, 229, 241",
"AQ-HEAT-200W: 0, 0, 42, 0, 0, 18, 0, 0, 0, 65, 0, 7",
"CHW-ELK-6IN: 198, 205, 196, 202, 207, 560, 148, 199, 203, 196, 208, 201",
"",
"Scenario:",
"1. Northbrook (KIB-SALM-24 and CHW-ELK-6IN) moves from a 2 week to a 3 week lead time next month.",
"2. GPS-TRK-COLLAR has a supplier MOQ of 480 units per PO.",
"3. A 20 percent sitewide price cut on KIB-SALM-24 is locked in 3 weeks from now.",
"4. Service target is 97.5 percent for A items (KIB-SALM-24, CHW-ELK-6IN), 95 percent for the rest.",
"5. GPS-TRK-COLLAR-XL launches in 4 weeks with zero history; the analog is GPS-TRK-COLLAR.",
"",
"CHW-ELK-6IN week 6 was a buy-one-get-one email promo; week 7 is the post-promo trough.",
});
var payload = new {
data = history,
notes = "I need a 13-week forecast by SKU plus a buy plan: order quantities, order timing " +
"and the safety stock assumed for each item. Hard constraint is 240,000 dollars of " +
"open-to-buy through end of quarter. Call out explicitly how the 2-to-3 week lead " +
"time move changes the cover on the two Northbrook items.",
task = "Full plan",
facts = "Mechanical scan of the paste (arithmetic, not judgement):\n" +
"- KIB-SALM-24: 12 periods, mean 881.3, sd 13.6, CV 0.02, class stable.",
};
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("model")} markup {est.GetProperty("markup_bps")} bps");
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits, " +
$"floor {est.GetProperty("min_credits")}");
facts is a hint, not an instruction. It is pure arithmetic on the pasted text
— period counts, mean, standard deviation, CV, volatility class, a naive trend read
— and the plan is free to disagree with it when your own reading of the paste says
the scanner misparsed a line. Omit it and nothing is lost except that cross-check.
Step 4 — Run the plan and wait for the result
/run takes the same body as /estimate — the input object
directly — places a credit hold and returns {"data":{"job_id":"…"}}.
Poll /jobs/{job_id} every 1–2 seconds until status is
succeeded or failed. Always send an Idempotency-Key
header so a network retry can't start a second, double-charged run — see
step 5 for how to choose the key. The plan text is in
output — usually nested as output.output, and it is
plain text, not JSON, so keep it as a string and hand it to the parser in
step 6.
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: dd-$(date +%s)" \
-d @input.json | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
# the plan is plain text — write it out verbatim, do not try to parse it as JSON
echo "$JOB" | jq -r '.data.output.output // .data.output' > plan.md
head -4 plan.md # COVERAGE / HORIZON / CONFIDENCE / SUMMARY
grep -n '^## ' plan.md # the six sections, in order
# fail the pipeline when the paste was not plannable
grep -q '^COVERAGE: Not enough to plan' plan.md \
&& { echo "not enough to plan"; exit 1; } || true
import time
job_id = api("POST", "/run", payload,
**{"Idempotency-Key": "dd-001"})["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"))
raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
raw = raw["output"]
plan_text = raw if isinstance(raw, str) else str(raw)
with open("plan.md", "w", encoding="utf-8") as fh:
fh.write(plan_text)
for line in plan_text.splitlines()[:4]:
print(line)
import { writeFileSync } from "node:fs";
const { job_id } = await api("POST", "/run", payload,
{ "Idempotency-Key": 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");
// plain text, not JSON — keep it as a string
const planText = job.output?.output ?? job.output;
writeFileSync("plan.md", planText);
console.log(planText.split("\n").slice(0, 4).join("\n"));
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 json.RawMessage `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)
}
// job.Output is {"output": "<the plan text>"} — the plan is plain text, never JSON.
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
planText := wrapper.Output
os.WriteFile("plan.md", []byte(planText), 0o644)
for i, line := range strings.SplitN(planText, "\n", 5) {
if i < 4 {
fmt.Println(line)
}
}
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;
String job;
String status;
while (true) {
job = api("GET", "/jobs/" + jobId, null);
status = /* data.status */;
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(1500);
}
// The plan is at data.output.output as a PLAIN TEXT string — do not parse it as
// JSON. Pull it out with your JSON library and keep it verbatim:
String planText = /* data.output.output */;
Files.writeString(Path.of("plan.md"), planText);
// The first four lines are COVERAGE:, HORIZON:, CONFIDENCE: and SUMMARY:,
// then the six "## " sections. Step 6 shows the parse.
planText.lines().limit(4).forEach(System.out::println);
started = api("POST", "/run", payload)
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"
raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
plan_text = raw.to_s # plain text, not JSON
File.write("plan.md", plan_text)
puts plan_text.lines.first(4).join
$started = api("POST", "/run", $payload);
do {
sleep(2);
$job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));
if ($job["status"] === "failed") {
throw new Exception($job["error"] ?? "run failed");
}
$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$planText = (string) $raw; // plain text, not JSON
file_put_contents("plan.md", $planText);
echo implode("\n", array_slice(explode("\n", $planText), 0, 4)), "\n";
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
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);
}
// plain text, not JSON
var planText = job.GetProperty("output").GetProperty("output").GetString()!;
await File.WriteAllTextAsync("plan.md", planText);
foreach (var line in planText.Split('\n').Take(4)) Console.WriteLine(line);
A reply that breaks the output contract is discarded by the app rather than half-rendered,
and a second attempt is sent carrying retry_note — a formatting-only
instruction that restates the required shape and changes nothing about the analysis. If you
implement the same fallback, send the identical input object plus
retry_note, and treat the second reply exactly like the first.
Step 5 — Stream the plan as it is written
/run-stream takes exactly the same body as /run but answers with
server-sent events, so you can show progress instead of a spinner. This app's own progress
panel is this endpoint. Events are separated by a blank line; each has an
event: line and a data: line carrying JSON.
| Event | Payload | Meaning |
|---|---|---|
job | {job_id, status} | Sent once, when the job is accepted — show "starting". |
delta | {text} | A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). The app advances its step list by watching for the ## Demand assessment, ## Forecast plan, ## Inventory parameters and later headings as they arrive. |
done | {job_id, status, charged_credits, output} | The final, authoritative result — read the plan text from output.output rather than trusting concatenated deltas, and the settled price from charged_credits. |
error | {code, message} | Replaces done when the run fails. |
The Idempotency-Key header
/run-stream (and /run) accepts an Idempotency-Key
header, and the way to build it matters. The app derives the key from a hash of the input
object plus a nonce minted once per user gesture — per press of the run
button. So every transport-level retry of that one gesture, whether it is your own retry
loop or a dropped connection you re-open, carries the same key and is deduplicated: the
server returns the original run's result and you are billed once. A deliberate second run
of the same input is a new gesture, gets a fresh nonce and therefore a fresh key, and does
execute and does charge — which is what you want when you re-run because a supplier
just changed a lead time and you want the plan rebuilt. Reuse a key for retries; mint a new
one for intentional re-runs.
# One key per gesture: hash the input, then salt it with a nonce you mint once.
NONCE=$(uuidgen)
KEY="dd-$(cat input.json <(echo "$NONCE") | shasum -a 256 | cut -c1-32)"
# -N disables buffering so events print as they arrive.
# Re-running THIS command reuses $KEY and is deduplicated (no second charge).
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @input.json
# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"COVERAGE: Partial - gaps noted\nHORIZON: next 13 weeks"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":612,"output":{"output":"COVERAGE: ..."}}
import hashlib, json, uuid, requests
# One key per gesture: input hash + a nonce minted once for this attempt.
nonce = uuid.uuid4().hex
body = json.dumps(payload, sort_keys=True).encode("utf-8")
key = "dd-" + hashlib.sha256(body + nonce.encode()).hexdigest()[:32]
result = None
with requests.post(
API + "/run-stream",
headers={"Authorization": f"Bearer {TOKEN}", "Idempotency-Key": key},
json=payload,
stream=True,
) as r:
r.raise_for_status()
event = None
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("event:"):
event = line[len("event:"):].strip()
elif line.startswith("data:"):
data = json.loads(line[len("data:"):].strip())
if event == "delta":
print(".", end="", flush=True) # live progress
elif event == "done":
result = data
elif event == "error":
raise RuntimeError(data.get("message", "run failed"))
plan_text = result["output"]["output"] # authoritative
print("\ncharged:", result["charged_credits"])
print("\n".join(plan_text.splitlines()[:4]))
with open("plan.md", "w", encoding="utf-8") as fh:
fh.write(plan_text)
import { createHash, randomUUID } from "node:crypto";
// One key per gesture: hash the input, salt with a nonce minted once.
const nonce = randomUUID();
const key = "dd-" + createHash("sha256")
.update(JSON.stringify(payload) + nonce).digest("hex").slice(0, 32);
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop();
for (const frame of frames) {
const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
const data = /^data:\s*(.+)$/m.exec(frame)?.[1];
if (!name || !data) continue;
const payloadJson = JSON.parse(data);
if (name === "delta") process.stdout.write("."); // live progress
if (name === "done") done = payloadJson;
if (name === "error") throw new Error(payloadJson.message ?? "run failed");
}
}
const planText = done.output.output;
console.log(`\n${done.charged_credits} credits`);
console.log(planText.split("\n").slice(0, 4).join("\n"));
writeFileSync("plan.md", planText);
// One key per gesture: hash of the body plus a nonce minted once.
bodyBytes, _ := json.Marshal(payload)
nonce := fmt.Sprintf("%d", time.Now().UnixNano())
sum := sha256.Sum256(append(bodyBytes, []byte(nonce)...))
key := "dd-" + hex.EncodeToString(sum[:])[:32]
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
var data map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
switch event {
case "delta":
fmt.Print(".") // live progress
case "done":
final = data
case "error":
log.Fatal(data["message"])
}
}
}
planText := final["output"].(map[string]any)["output"].(string)
os.WriteFile("plan.md", []byte(planText), 0o644)
// Java 17+ — read the stream line by line instead of buffering the body.
// One key per gesture: hash the body, salt with a nonce minted once.
String nonce = java.util.UUID.randomUUID().toString();
var digest = java.security.MessageDigest.getInstance("SHA-256")
.digest((jsonPayload + nonce).getBytes(java.nio.charset.StandardCharsets.UTF_8));
String key = "dd-" + java.util.HexFormat.of().formatHex(digest).substring(0, 32);
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
if (line.startsWith("event:")) {
event = line.substring(6).trim();
} else if (line.startsWith("data:")) {
String data = line.substring(5).trim();
if ("delta".equals(event)) System.out.print("."); // live progress
else if ("done".equals(event)) done = data;
else if ("error".equals(event)) throw new RuntimeException(data);
}
}
// Parse `done`, then read data.output.output — a PLAIN TEXT plan, not JSON.
// Retrying this whole block with the SAME key returns the first run's result
// and is not billed again; mint a new nonce for a deliberate re-run.
require "digest"
require "net/http"
require "json"
require "securerandom"
# One key per gesture: input hash salted with a nonce minted once.
nonce = SecureRandom.hex(16)
key = "dd-" + Digest::SHA256.hexdigest(payload.to_json + nonce)[0, 32]
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = payload.to_json
event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.strip
if line.start_with?("event:")
event = line.delete_prefix("event:").strip
elsif line.start_with?("data:")
data = JSON.parse(line.delete_prefix("data:").strip)
case event
when "delta" then print "." # live progress
when "done" then done = data
when "error" then raise (data["message"] || "run failed")
end
end
end
end
end
end
plan_text = done["output"]["output"]
puts "\n#{done["charged_credits"]} credits"
File.write("plan.md", plan_text)
// One key per gesture: input hash salted with a nonce minted once.
$nonce = bin2hex(random_bytes(16));
$key = "dd-" . substr(hash("sha256", json_encode($payload) . $nonce), 0, 32);
$event = null;
$done = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: $key",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
foreach (explode("\n", $chunk) as $line) {
$line = trim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$data = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") { echo "."; } // live progress
elseif ($event === "done") { $done = $data; }
elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$planText = $done["output"]["output"];
echo "\n{$done['charged_credits']} credits\n";
file_put_contents("plan.md", $planText);
using System.Security.Cryptography;
using System.Text;
// One key per gesture: input hash salted with a nonce minted once.
var nonce = Guid.NewGuid().ToString("N");
var bodyJson = JsonSerializer.Serialize(payload);
var hash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(bodyJson + nonce)));
var key = "dd-" + hash[..32].ToLowerInvariant();
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", key);
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:"))
{
var data = line[5..].Trim();
if (evt == "delta") Console.Write("."); // live progress
else if (evt == "done") done = data;
else if (evt == "error") throw new Exception(data);
}
}
using var final = JsonDocument.Parse(done!);
var planText = final.RootElement.GetProperty("output").GetProperty("output").GetString()!;
Console.WriteLine($"\n{final.RootElement.GetProperty("charged_credits")} credits");
await File.WriteAllTextAsync("plan.md", planText);
In a browser, the native EventSource only speaks GET, and this endpoint is a
POST — read the fetch response body incrementally, as the JavaScript
sample above does. On an idempotent replay the server may answer with a plain JSON
envelope instead of an event stream; check the Content-Type before you start
parsing frames.
Step 6 — The output contract, and how to parse it
The reply is plain text, not JSON, and it always has the same shape. The
first four lines are tag lines; then six ## sections in a fixed order, each
containing only - bullets. A bullet may wrap onto indented continuation
lines. A section with nothing to report contains the single bullet - None.
| Line | Value |
|---|---|
COVERAGE: | The first line. Exactly one of Sufficient, Partial - gaps noted or Not enough to plan. Sufficient means the paste supports the requested task end to end; Partial - gaps noted means some items or some of the task are plannable and the gaps are named under Open questions; Not enough to plan means there is no usable demand signal at all. |
HORIZON: | The horizon the forecast covers — taken from the paste or notes when stated ("plan the next 6 weeks"), otherwise the shortest horizon the data honestly supports, or exactly Not stated when no forecasting was possible. |
CONFIDENCE: | A bare integer from 0 to 100. No percent sign, no range, no words. |
SUMMARY: | Two to four sentences: what was supplied, the headline reads and what the plan says to do. It may wrap over several lines and ends at the first blank line — join the wrapped lines with a space. |
The six sections, in exactly this order:
| Heading | Bullet shape |
|---|---|
## Demand assessment | Free-text bullets, one per item or demand signal: the pattern read and the evidence for it. When real series were supplied, every one of them is named here. |
## Forecast plan | Exactly four | -separated fields: item (as named in the paste) | method ("4-week moving average", "exponential smoothing with trend", "Croston for intermittent demand", "analog to SKU-X") | why that method fits the pattern | expected demand over the horizon, with its unit. No pipes inside a field. |
## Inventory parameters | Exactly four | -separated fields: item | safety stock, with the assumption that produced it | reorder point | order quantity, with its constraint applied ("480 - MOQ binds; cycle need is about 320"). Any field the paste cannot support reads not computable, and what is missing appears under Open questions. |
## Risks and watchouts | Free-text bullets, one per risk the data or scenario exposes. |
## Recommended actions | Free-text bullets, each starting with a verb, with its timing where one is stated or implied. |
## Open questions | Free-text bullets, one per thing that must be supplied or confirmed before the plan is executable. |
Two cross-rules are worth asserting on in a pipeline: when COVERAGE is
Not enough to plan, both ## Forecast plan and
## Inventory parameters are - None. and ## Open
questions carries at least one bullet saying what to paste; and conversely, a real
row under either table means COVERAGE is not Not enough to plan.
A real reply for the pet-hardlines paste above, trimmed for length:
COVERAGE: Partial - gaps noted
HORIZON: next 13 weeks
CONFIDENCE: 55
SUMMARY: Four SKUs with 12 weeks of weekly sell-through plus a Q3 scenario were supplied,
along with a $240,000 OTB cap. `KIB-SALM-24` is flat and easy to forecast, `GPS-TRK-COLLAR`
is on a real upward trend that its 480-unit MOQ will eventually outrun, `AQ-HEAT-200W` is
intermittent, and `CHW-ELK-6IN`'s history is clean once the wk6 promo and wk7 trough are
pulled out. The plan cannot compute safety stock for `GPS-TRK-COLLAR` or `AQ-HEAT-200W`
because their current lead time is never stated.
## Demand assessment
- `KIB-SALM-24`: flat, low-variability demand - 12 weeks averaging 881/week with sd 13.6
(CV 0.02) and no visible trend.
- `AQ-HEAT-200W`: intermittent demand with real zeros - 8 of 12 weeks at zero, with four
lumpy events of 42, 18, 65 and 7 units; a plain average would smear these into a phantom
steady baseline.
## Forecast plan
- `KIB-SALM-24` | simple exponential smoothing / short moving average | 12 weeks show no
trend and negligible variance, so the simplest method that tracks a flat mean fits |
about 11,450 units baseline over 13 weeks (881/week)
- `AQ-HEAT-200W` | Croston-style intermittent (event size x interval) | 8 of 12 weeks are
zero with four lumpy events averaging 33 units roughly every 3 weeks | about 140 units
over 13 weeks across roughly 4 restock events
## Inventory parameters
- `KIB-SALM-24` | 38 units at 97.5% service, z=1.96, current 2-week lead time
(`1.96 x 13.6 x sqrt(2)`), rising to 46 units once the lead time moves to 3 weeks
(`1.96 x 13.6 x sqrt(3)`) | 1,800 units at the current 2-week lead time
(`881.3 x 2 + 38`), rising to 2,690 units at the new 3-week lead time
(`881.3 x 3 + 46`) | not computable - no MOQ, pack size or per-unit cost is stated
- `GPS-TRK-COLLAR` | not computable - current lead time for this SKU is never stated |
not computable - reorder point needs the same missing lead time | 480 - MOQ binds every
order; at 250-380 units/week this covers roughly 1.3-1.9 weeks per order
## Risks and watchouts
- OTB exposure unverifiable: the paste has no per-unit cost for any SKU, so this unit-based
plan cannot be reconciled against the $240,000 cap.
## Recommended actions
- Confirm the current lead time for `GPS-TRK-COLLAR` and `AQ-HEAT-200W` so their safety
stock and reorder points can be computed.
## Open questions
- Current lead time for `GPS-TRK-COLLAR` and `AQ-HEAT-200W`.
- Per-unit cost for all lines, to check the plan against the $240,000 OTB cap.
A parser for this is a few dozen lines in any language: read the four tag lines, then walk
the rest switching on ## headings and collecting - bullets,
joining wrapped continuation lines into the bullet above them. Split the two table sections
on | .
# The four tag lines
sed -n '1,4p' plan.md
# Every heading, in the order they appear — expect exactly these six
grep -n '^## ' plan.md
# One section's bullets (awk keeps wrapped continuation lines attached)
awk '/^## Inventory parameters$/{f=1;next} /^## /{f=0} f && NF' plan.md
# The inventory table as TSV: item, safety stock, reorder point, order quantity
awk '/^## Inventory parameters$/{f=1;next} /^## /{f=0}
f && /^- /{if(b)print b; b=substr($0,3); next}
f && NF{b=b" "$0; next}
END{if(b)print b}' plan.md \
| awk -F' \\| ' '{printf "%s\t%s\t%s\t%s\n", $1, $2, $3, $4}'
# Rows the plan could not compute
grep -c 'not computable' plan.md
import re
SECTIONS = ["Demand assessment", "Forecast plan", "Inventory parameters",
"Risks and watchouts", "Recommended actions", "Open questions"]
def parse_plan(text):
plan = {"coverage": "", "horizon": "", "confidence": None, "summary": "",
"sections": {name: [] for name in SECTIONS}}
current, summary, bullets = None, [], None
for line in text.replace("\r\n", "\n").split("\n"):
head = re.match(r"^##\s+(.*?)\s*$", line)
if head:
current = head.group(1) if head.group(1) in SECTIONS else None
bullets = plan["sections"].get(current) if current else None
continue
for tag, key in (("COVERAGE", "coverage"), ("HORIZON", "horizon")):
m = re.match(rf"^{tag}\s*:\s*(.*)$", line)
if m and not plan[key]:
plan[key], current, bullets = m.group(1).strip(), None, None
m = re.match(r"^CONFIDENCE\s*:\s*(\d{1,3})\s*$", line)
if m and plan["confidence"] is None:
plan["confidence"], current, bullets = int(m.group(1)), None, None
continue
m = re.match(r"^SUMMARY\s*:\s*(.*)$", line)
if m and not summary:
summary.append(m.group(1).strip())
current, bullets = "__summary__", None
continue
if current == "__summary__":
if not line.strip():
current = None
else:
summary.append(line.strip())
continue
if bullets is None:
continue
if line.startswith("- "):
bullets.append(line[2:].strip())
elif line.strip() and bullets:
bullets[-1] += " " + line.strip() # wrapped continuation
plan["summary"] = " ".join(summary).strip()
for key in ("Forecast plan", "Inventory parameters"):
rows = plan["sections"][key]
plan[key] = [] if rows == ["None."] else [
[f.strip() for f in row.split(" | ")] for row in rows]
return plan
plan = parse_plan(plan_text)
print(plan["coverage"], "|", plan["horizon"], "|", plan["confidence"])
print(plan["summary"])
for item, ss, rop, oq in plan["Inventory parameters"]:
print(f" {item}\n ss: {ss}\n rop: {rop}\n oq: {oq}")
assert plan["coverage"] in ("Sufficient", "Partial - gaps noted", "Not enough to plan")
if plan["coverage"] == "Not enough to plan":
assert not plan["Forecast plan"] and not plan["Inventory parameters"]
const SECTIONS = ["Demand assessment", "Forecast plan", "Inventory parameters",
"Risks and watchouts", "Recommended actions", "Open questions"];
function parsePlan(text) {
const plan = { coverage: "", horizon: "", confidence: null, summary: "", sections: {} };
for (const name of SECTIONS) plan.sections[name] = [];
let current = null, bullets = null;
const summary = [];
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
const head = /^##\s+(.*?)\s*$/.exec(line);
if (head) {
current = SECTIONS.includes(head[1]) ? head[1] : null;
bullets = current ? plan.sections[current] : null;
continue;
}
let m;
if (!plan.coverage && (m = /^COVERAGE\s*:\s*(.*)$/.exec(line))) {
plan.coverage = m[1].trim(); current = bullets = null; continue;
}
if (!plan.horizon && (m = /^HORIZON\s*:\s*(.*)$/.exec(line))) {
plan.horizon = m[1].trim(); current = bullets = null; continue;
}
if (plan.confidence === null && (m = /^CONFIDENCE\s*:\s*(\d{1,3})\s*$/.exec(line))) {
plan.confidence = Number(m[1]); current = bullets = null; continue;
}
if (!summary.length && (m = /^SUMMARY\s*:\s*(.*)$/.exec(line))) {
if (m[1].trim()) summary.push(m[1].trim());
current = "__summary__"; bullets = null; continue;
}
if (current === "__summary__") {
if (!line.trim()) current = null; else summary.push(line.trim());
continue;
}
if (!bullets) continue;
if (line.startsWith("- ")) bullets.push(line.slice(2).trim());
else if (line.trim() && bullets.length) bullets[bullets.length - 1] += " " + line.trim();
}
plan.summary = summary.join(" ").trim();
for (const key of ["Forecast plan", "Inventory parameters"]) {
const rows = plan.sections[key];
plan[key] = rows.length === 1 && /^none\.?$/i.test(rows[0])
? []
: rows.map((r) => r.split(" | ").map((f) => f.trim()));
}
return plan;
}
const plan = parsePlan(planText);
console.log(`${plan.coverage} | ${plan.horizon} | ${plan.confidence}`);
console.log(plan.summary);
for (const [item, ss, rop, oq] of plan["Inventory parameters"]) {
console.log(` ${item}\n ss: ${ss}\n rop: ${rop}\n oq: ${oq}`);
}
var sectionNames = []string{
"Demand assessment", "Forecast plan", "Inventory parameters",
"Risks and watchouts", "Recommended actions", "Open questions",
}
type Plan struct {
Coverage string
Horizon string
Confidence int
Summary string
Sections map[string][]string
}
var (
reHead = regexp.MustCompile(`^##\s+(.*?)\s*$`)
reCov = regexp.MustCompile(`^COVERAGE\s*:\s*(.*)$`)
reHor = regexp.MustCompile(`^HORIZON\s*:\s*(.*)$`)
reConf = regexp.MustCompile(`^CONFIDENCE\s*:\s*(\d{1,3})\s*$`)
reSum = regexp.MustCompile(`^SUMMARY\s*:\s*(.*)$`)
)
func parsePlan(text string) Plan {
p := Plan{Confidence: -1, Sections: map[string][]string{}}
for _, n := range sectionNames {
p.Sections[n] = []string{}
}
known := func(n string) bool {
for _, s := range sectionNames {
if s == n {
return true
}
}
return false
}
current, inSummary := "", false
var summary []string
for _, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
if m := reHead.FindStringSubmatch(line); m != nil {
inSummary = false
if known(m[1]) {
current = m[1]
} else {
current = ""
}
continue
}
if m := reCov.FindStringSubmatch(line); m != nil && p.Coverage == "" {
p.Coverage, current, inSummary = strings.TrimSpace(m[1]), "", false
continue
}
if m := reHor.FindStringSubmatch(line); m != nil && p.Horizon == "" {
p.Horizon, current, inSummary = strings.TrimSpace(m[1]), "", false
continue
}
if m := reConf.FindStringSubmatch(line); m != nil && p.Confidence < 0 {
p.Confidence, _ = strconv.Atoi(m[1])
current, inSummary = "", false
continue
}
if m := reSum.FindStringSubmatch(line); m != nil && len(summary) == 0 {
summary = append(summary, strings.TrimSpace(m[1]))
current, inSummary = "", true
continue
}
if inSummary {
if strings.TrimSpace(line) == "" {
inSummary = false
} else {
summary = append(summary, strings.TrimSpace(line))
}
continue
}
if current == "" {
continue
}
if strings.HasPrefix(line, "- ") {
p.Sections[current] = append(p.Sections[current], strings.TrimSpace(line[2:]))
} else if strings.TrimSpace(line) != "" && len(p.Sections[current]) > 0 {
last := len(p.Sections[current]) - 1
p.Sections[current][last] += " " + strings.TrimSpace(line)
}
}
p.Summary = strings.TrimSpace(strings.Join(summary, " "))
return p
}
// rows("Inventory parameters") -> [item, safety stock, reorder point, order quantity]
func rows(p Plan, section string) [][]string {
src := p.Sections[section]
if len(src) == 1 && strings.EqualFold(strings.TrimSuffix(src[0], "."), "None") {
return nil
}
out := make([][]string, 0, len(src))
for _, b := range src {
fields := strings.Split(b, " | ")
for i := range fields {
fields[i] = strings.TrimSpace(fields[i])
}
out = append(out, fields)
}
return out
}
// Java 17+ — the whole contract is line-oriented, so no dependencies are needed.
import java.util.*;
import java.util.regex.*;
record Plan(String coverage, String horizon, int confidence, String summary,
Map<String, List<String>> sections) {}
static final List<String> SECTIONS = List.of(
"Demand assessment", "Forecast plan", "Inventory parameters",
"Risks and watchouts", "Recommended actions", "Open questions");
static Plan parsePlan(String text) {
Map<String, List<String>> sections = new LinkedHashMap<>();
SECTIONS.forEach(s -> sections.put(s, new ArrayList<>()));
String coverage = "", horizon = "", current = null;
int confidence = -1;
boolean inSummary = false;
var summary = new ArrayList<String>();
for (String line : text.replace("\r\n", "\n").split("\n", -1)) {
Matcher m;
if ((m = Pattern.compile("^##\\s+(.*?)\\s*$").matcher(line)).matches()) {
current = SECTIONS.contains(m.group(1)) ? m.group(1) : null;
inSummary = false;
continue;
}
if (coverage.isEmpty() && line.startsWith("COVERAGE:")) {
coverage = line.substring(9).trim(); current = null; inSummary = false; continue;
}
if (horizon.isEmpty() && line.startsWith("HORIZON:")) {
horizon = line.substring(8).trim(); current = null; inSummary = false; continue;
}
if (confidence < 0 && line.startsWith("CONFIDENCE:")) {
confidence = Integer.parseInt(line.substring(11).trim());
current = null; inSummary = false; continue;
}
if (summary.isEmpty() && line.startsWith("SUMMARY:")) {
summary.add(line.substring(8).trim()); current = null; inSummary = true; continue;
}
if (inSummary) {
if (line.isBlank()) inSummary = false; else summary.add(line.trim());
continue;
}
if (current == null) continue;
var bullets = sections.get(current);
if (line.startsWith("- ")) bullets.add(line.substring(2).trim());
else if (!line.isBlank() && !bullets.isEmpty())
bullets.set(bullets.size() - 1, bullets.get(bullets.size() - 1) + " " + line.trim());
}
return new Plan(coverage, horizon, confidence, String.join(" ", summary).trim(), sections);
}
// Split a table section into its four fields:
// for (String b : plan.sections().get("Inventory parameters"))
// String[] f = b.split(" \\| "); // item, safety stock, reorder point, order qty
// A section holding the single bullet "None." has nothing to report.
SECTIONS = ["Demand assessment", "Forecast plan", "Inventory parameters",
"Risks and watchouts", "Recommended actions", "Open questions"].freeze
def parse_plan(text)
plan = { coverage: "", horizon: "", confidence: nil, summary: "",
sections: SECTIONS.to_h { |s| [s, []] } }
current = nil
summary = []
text.gsub("\r\n", "\n").split("\n").each do |line|
if (m = line.match(/\A##\s+(.*?)\s*\z/))
current = SECTIONS.include?(m[1]) ? m[1] : nil
next
end
if plan[:coverage].empty? && (m = line.match(/\ACOVERAGE\s*:\s*(.*)\z/))
plan[:coverage] = m[1].strip; current = nil; next
end
if plan[:horizon].empty? && (m = line.match(/\AHORIZON\s*:\s*(.*)\z/))
plan[:horizon] = m[1].strip; current = nil; next
end
if plan[:confidence].nil? && (m = line.match(/\ACONFIDENCE\s*:\s*(\d{1,3})\s*\z/))
plan[:confidence] = m[1].to_i; current = nil; next
end
if summary.empty? && (m = line.match(/\ASUMMARY\s*:\s*(.*)\z/))
summary << m[1].strip
current = "__summary__"
next
end
if current == "__summary__"
line.strip.empty? ? (current = nil) : (summary << line.strip)
next
end
next unless current
bullets = plan[:sections][current]
if line.start_with?("- ")
bullets << line[2..].strip
elsif !line.strip.empty? && !bullets.empty?
bullets[-1] = "#{bullets[-1]} #{line.strip}"
end
end
plan[:summary] = summary.join(" ").strip
["Forecast plan", "Inventory parameters"].each do |key|
rows = plan[:sections][key]
plan[key] = rows == ["None."] ? [] : rows.map { |r| r.split(" | ").map(&:strip) }
end
plan
end
plan = parse_plan(plan_text)
puts "#{plan[:coverage]} | #{plan[:horizon]} | #{plan[:confidence]}"
plan["Inventory parameters"].each do |item, ss, rop, oq|
puts " #{item}\n ss: #{ss}\n rop: #{rop}\n oq: #{oq}"
end
const SECTIONS = ["Demand assessment", "Forecast plan", "Inventory parameters",
"Risks and watchouts", "Recommended actions", "Open questions"];
function parse_plan(string $text): array {
$plan = ["coverage" => "", "horizon" => "", "confidence" => null,
"summary" => "", "sections" => []];
foreach (SECTIONS as $s) { $plan["sections"][$s] = []; }
$current = null;
$summary = [];
foreach (explode("\n", str_replace("\r\n", "\n", $text)) as $line) {
if (preg_match('/^##\s+(.*?)\s*$/', $line, $m)) {
$current = in_array($m[1], SECTIONS, true) ? $m[1] : null;
continue;
}
if ($plan["coverage"] === "" && preg_match('/^COVERAGE\s*:\s*(.*)$/', $line, $m)) {
$plan["coverage"] = trim($m[1]); $current = null; continue;
}
if ($plan["horizon"] === "" && preg_match('/^HORIZON\s*:\s*(.*)$/', $line, $m)) {
$plan["horizon"] = trim($m[1]); $current = null; continue;
}
if ($plan["confidence"] === null && preg_match('/^CONFIDENCE\s*:\s*(\d{1,3})\s*$/', $line, $m)) {
$plan["confidence"] = (int) $m[1]; $current = null; continue;
}
if (!$summary && preg_match('/^SUMMARY\s*:\s*(.*)$/', $line, $m)) {
$summary[] = trim($m[1]); $current = "__summary__"; continue;
}
if ($current === "__summary__") {
if (trim($line) === "") { $current = null; } else { $summary[] = trim($line); }
continue;
}
if ($current === null) { continue; }
if (str_starts_with($line, "- ")) {
$plan["sections"][$current][] = trim(substr($line, 2));
} elseif (trim($line) !== "" && $plan["sections"][$current]) {
$last = count($plan["sections"][$current]) - 1;
$plan["sections"][$current][$last] .= " " . trim($line);
}
}
$plan["summary"] = trim(implode(" ", $summary));
foreach (["Forecast plan", "Inventory parameters"] as $key) {
$rows = $plan["sections"][$key];
$plan[$key] = $rows === ["None."]
? []
: array_map(fn($r) => array_map("trim", explode(" | ", $r)), $rows);
}
return $plan;
}
$plan = parse_plan($planText);
echo "{$plan['coverage']} | {$plan['horizon']} | {$plan['confidence']}\n";
foreach ($plan["Inventory parameters"] as [$item, $ss, $rop, $oq]) {
echo " $item\n ss: $ss\n rop: $rop\n oq: $oq\n";
}
using System.Text.RegularExpressions;
static readonly string[] Sections = {
"Demand assessment", "Forecast plan", "Inventory parameters",
"Risks and watchouts", "Recommended actions", "Open questions" };
record Plan(string Coverage, string Horizon, int Confidence, string Summary,
Dictionary<string, List<string>> SectionBullets);
static Plan ParsePlan(string text)
{
var sections = Sections.ToDictionary(s => s, _ => new List<string>());
string coverage = "", horizon = "", current = null!;
int confidence = -1;
bool inSummary = false;
var summary = new List<string>();
foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
{
var head = Regex.Match(line, @"^##\s+(.*?)\s*$");
if (head.Success)
{
current = Sections.Contains(head.Groups[1].Value) ? head.Groups[1].Value : null!;
inSummary = false;
continue;
}
Match m;
if (coverage.Length == 0 && (m = Regex.Match(line, @"^COVERAGE\s*:\s*(.*)$")).Success)
{ coverage = m.Groups[1].Value.Trim(); current = null!; inSummary = false; continue; }
if (horizon.Length == 0 && (m = Regex.Match(line, @"^HORIZON\s*:\s*(.*)$")).Success)
{ horizon = m.Groups[1].Value.Trim(); current = null!; inSummary = false; continue; }
if (confidence < 0 && (m = Regex.Match(line, @"^CONFIDENCE\s*:\s*(\d{1,3})\s*$")).Success)
{ confidence = int.Parse(m.Groups[1].Value); current = null!; inSummary = false; continue; }
if (summary.Count == 0 && (m = Regex.Match(line, @"^SUMMARY\s*:\s*(.*)$")).Success)
{ summary.Add(m.Groups[1].Value.Trim()); current = null!; inSummary = true; continue; }
if (inSummary)
{
if (line.Trim().Length == 0) inSummary = false; else summary.Add(line.Trim());
continue;
}
if (current is null) continue;
var bullets = sections[current];
if (line.StartsWith("- ")) bullets.Add(line[2..].Trim());
else if (line.Trim().Length > 0 && bullets.Count > 0)
bullets[^1] += " " + line.Trim();
}
return new Plan(coverage, horizon, confidence,
string.Join(" ", summary).Trim(), sections);
}
// Table rows: one bullet, four fields.
static List<string[]> Rows(Plan plan, string section)
{
var src = plan.SectionBullets[section];
if (src.Count == 1 && src[0].TrimEnd('.').Equals("None", StringComparison.OrdinalIgnoreCase))
return new();
return src.Select(b => b.Split(" | ").Select(f => f.Trim()).ToArray()).ToList();
}
A stray code fence around the whole reply is always possible. Strip a leading
``` line and a trailing one before parsing — that is what the app does
before it falls back to a retry_note reformat run. The tag lines also survive
a bold or italic wrapper (**COVERAGE:** Sufficient), so trim leading and
trailing asterisks before matching if you want to be as forgiving as the app is.
Step 7 — Verify the arithmetic yourself
This is the app's headline feature, and it is available to API callers too. Whenever a safety stock or reorder point is computable, the reply states the arithmetic inline in backticks, in a fixed form:
| Field | Expression form | Example field text |
|---|---|---|
| Safety stock | z x sd x sqrt(lead time) | 38 units at 97.5% service, z=1.96, current 2-week lead time (`1.96 x 13.6 x sqrt(2)`) |
| Reorder point | mean x lead time + safety stock | 1,800 units at the current 2-week lead time (`881.3 x 2 + 38`) |
The multiplication sign is a literal x, numbers are plain decimals (thousands
separators may appear in the stated result), and sqrt(...) is spelled
that way. A field may carry two expressions when two lead times are planned — a
current one and a changed one — each with its own stated number. Where a value is
not computable, there is no expression at all.
The app re-runs every one of these expressions in the reader's browser and flags any that
does not reproduce the number printed beside it. Do the same in four moves: pull the
backticked spans out of the field, match them against the two expression forms, evaluate,
and check that some number stated in the same field lands within tolerance of the
result. Tolerance is max(1, 2% of the computed value) — the reply states
rounded integers derived from decimals. Matching against any number in the field rather
than a positionally-nearest one is deliberate: a field routinely carries two lead-time
cases.
This is a self-consistency check on the reply's own stated formula and
inputs. It confirms the arithmetic, not the provenance: it cannot prove the standard
deviation came from your paste. That is by design — a good plan often uses a
promo-cleaned sd that a raw scan of the paste never sees, and checking against
the raw scan would fail exactly the careful work you want.
# Extract every backticked expression from the Inventory parameters section and
# re-run it with awk. Prints FAIL when the expression does not reproduce a number
# stated in the same field (tolerance: max(1, 2%)).
awk '/^## Inventory parameters$/{f=1;next} /^## /{f=0}
f && /^- /{if(b)print b; b=substr($0,3); next}
f && NF{b=b" "$0; next}
END{if(b)print b}' plan.md \
| while IFS= read -r row; do
item=${row%%" | "*}
# each " | " field, checked on its own
echo "$row" | awk -F' \\| ' -v item="$item" '{
for (i = 2; i <= 3; i++) {
field = $i
# every number stated in this field, for the comparison pool
n = 0; tmp = field
while (match(tmp, /[0-9][0-9,]*(\.[0-9]+)?/)) {
v = substr(tmp, RSTART, RLENGTH); gsub(",", "", v)
pool[++n] = v + 0
tmp = substr(tmp, RSTART + RLENGTH)
}
# every `backticked` span
tmp = field
while (match(tmp, /`[^`]+`/)) {
expr = substr(tmp, RSTART + 1, RLENGTH - 2)
tmp = substr(tmp, RSTART + RLENGTH)
e = expr; gsub(",", "", e); gsub(/ /, "", e)
got = ""
if (match(e, /^[0-9.]+x[0-9.]+xsqrt\([0-9.]+\)$/)) {
split(e, p, /x?sqrt\(|x|\)/)
got = p[1] * p[2] * sqrt(p[3])
} else if (match(e, /^[0-9.]+x[0-9.]+\+[0-9.]+$/)) {
split(e, p, /x|\+/)
got = p[1] * p[2] + p[3]
}
if (got == "") continue
tol = (got * 0.02 > 1) ? got * 0.02 : 1
best = 1e18
for (k = 1; k <= n; k++) { d = (pool[k] - got); if (d < 0) d = -d; if (d < best) { best = d; claim = pool[k] } }
printf "%s `%s` = %.1f stated %s %s\n", item, expr, got, claim, (best <= tol ? "ok" : "FAIL")
}
delete pool
}
}'
done
import math, re
SS_EXPR = re.compile(r"^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*$", re.I)
ROP_EXPR = re.compile(r"^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*$")
NUMBER = re.compile(r"\d[\d,]*(?:\.\d+)?")
BACKTICK = re.compile(r"`([^`]+)`")
def num(s):
return float(str(s).replace(",", ""))
def check_field(text, kind):
"""Re-run every backticked expression in one field. -> [{expr, expected, claimed, ok}]"""
pool = [num(m.group(0)) for m in NUMBER.finditer(text)]
pattern = ROP_EXPR if kind == "rop" else SS_EXPR
out = []
for expr in BACKTICK.findall(text):
m = pattern.match(expr)
if not m:
continue
a, b, c = num(m.group(1)), num(m.group(2)), num(m.group(3))
expected = a * b + c if kind == "rop" else a * b * math.sqrt(c)
tol = max(1.0, abs(expected) * 0.02)
claimed = min(pool, key=lambda v: abs(v - expected)) if pool else None
out.append({"expr": expr, "kind": kind, "expected": expected,
"claimed": claimed,
"ok": claimed is not None and abs(claimed - expected) <= tol})
return out
def verify(plan):
checked = failed = 0
for item, ss, rop, _oq in plan["Inventory parameters"]:
for c in check_field(ss, "ss") + check_field(rop, "rop"):
checked += 1
if not c["ok"]:
failed += 1
print(f' {"ok " if c["ok"] else "FAIL"} {item}: `{c["expr"]}` '
f'= {c["expected"]:.1f}, stated {c["claimed"]}')
return checked, failed
checked, failed = verify(plan)
print(f"{checked} expressions re-run, {failed} disagree with the number beside them")
if failed:
raise SystemExit("recheck the flagged rows before ordering")
const SS_EXPR = /^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*$/i;
const ROP_EXPR = /^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*$/;
const num = (s) => parseFloat(String(s).replace(/,/g, ""));
const numbersIn = (t) => (String(t).match(/\d[\d,]*(?:\.\d+)?/g) ?? []).map(num);
const backticked = (t) => [...String(t).matchAll(/`([^`]+)`/g)].map((m) => m[1]);
// Re-run every backticked expression in one field.
function checkField(text, kind) {
const re = kind === "rop" ? ROP_EXPR : SS_EXPR;
const pool = numbersIn(text);
const out = [];
for (const expr of backticked(text)) {
const m = re.exec(expr);
if (!m) continue;
const [a, b, c] = [num(m[1]), num(m[2]), num(m[3])];
const expected = kind === "rop" ? a * b + c : a * b * Math.sqrt(c);
if (!isFinite(expected)) continue;
const tol = Math.max(1, Math.abs(expected) * 0.02);
let claimed = null, best = Infinity;
for (const v of pool) {
const d = Math.abs(v - expected);
if (d < best) { best = d; claimed = v; }
}
out.push({ expr, kind, expected, claimed, ok: best <= tol });
}
return out;
}
let checked = 0, failed = 0;
for (const [item, ss, rop] of plan["Inventory parameters"]) {
for (const c of [...checkField(ss, "ss"), ...checkField(rop, "rop")]) {
checked++;
if (!c.ok) failed++;
console.log(` ${c.ok ? "ok " : "FAIL"} ${item}: \`${c.expr}\` ` +
`= ${c.expected.toFixed(1)}, stated ${c.claimed}`);
}
}
console.log(`${checked} expressions re-run, ${failed} disagree`);
if (failed) process.exitCode = 1;
var (
ssExpr = regexp.MustCompile(`(?i)^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*$`)
ropExpr = regexp.MustCompile(`^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*$`)
numRe = regexp.MustCompile(`\d[\d,]*(?:\.\d+)?`)
tickRe = regexp.MustCompile("`([^`]+)`")
)
func toNum(s string) float64 {
v, _ := strconv.ParseFloat(strings.ReplaceAll(s, ",", ""), 64)
return v
}
type Check struct {
Expr string
Expected float64
Claimed float64
OK bool
}
// checkField re-runs every backticked expression in one field.
func checkField(text, kind string) []Check {
re := ssExpr
if kind == "rop" {
re = ropExpr
}
var pool []float64
for _, s := range numRe.FindAllString(text, -1) {
pool = append(pool, toNum(s))
}
var out []Check
for _, m := range tickRe.FindAllStringSubmatch(text, -1) {
expr := m[1]
p := re.FindStringSubmatch(expr)
if p == nil {
continue
}
a, b, c := toNum(p[1]), toNum(p[2]), toNum(p[3])
expected := a * b * math.Sqrt(c)
if kind == "rop" {
expected = a*b + c
}
tol := math.Max(1, math.Abs(expected)*0.02)
best, claimed := math.Inf(1), 0.0
for _, v := range pool {
if d := math.Abs(v - expected); d < best {
best, claimed = d, v
}
}
out = append(out, Check{expr, expected, claimed, best <= tol})
}
return out
}
checked, failed := 0, 0
for _, row := range rows(plan, "Inventory parameters") {
if len(row) < 4 {
continue
}
checks := append(checkField(row[1], "ss"), checkField(row[2], "rop")...)
for _, c := range checks {
checked++
status := "ok "
if !c.OK {
failed++
status = "FAIL"
}
fmt.Printf(" %s %s: `%s` = %.1f, stated %.0f\n", status, row[0], c.Expr, c.Expected, c.Claimed)
}
}
fmt.Printf("%d expressions re-run, %d disagree\n", checked, failed)
// Java 17+ — re-run the reply's own arithmetic and compare with what it printed.
static final Pattern SS_EXPR = Pattern.compile(
"^\\s*([\\d.,]+)\\s*x\\s*([\\d.,]+)\\s*x\\s*sqrt\\(\\s*([\\d.,]+)\\s*\\)\\s*$",
Pattern.CASE_INSENSITIVE);
static final Pattern ROP_EXPR = Pattern.compile(
"^\\s*([\\d.,]+)\\s*x\\s*([\\d.,]+)\\s*\\+\\s*([\\d.,]+)\\s*$");
static final Pattern NUMBER = Pattern.compile("\\d[\\d,]*(?:\\.\\d+)?");
static final Pattern BACKTICK = Pattern.compile("`([^`]+)`");
static double num(String s) { return Double.parseDouble(s.replace(",", "")); }
record Check(String expr, double expected, Double claimed, boolean ok) {}
static List<Check> checkField(String text, String kind) {
Pattern re = kind.equals("rop") ? ROP_EXPR : SS_EXPR;
var pool = NUMBER.matcher(text).results().map(r -> num(r.group())).toList();
var out = new ArrayList<Check>();
var ticks = BACKTICK.matcher(text);
while (ticks.find()) {
String expr = ticks.group(1);
Matcher m = re.matcher(expr);
if (!m.matches()) continue;
double a = num(m.group(1)), b = num(m.group(2)), c = num(m.group(3));
double expected = kind.equals("rop") ? a * b + c : a * b * Math.sqrt(c);
double tol = Math.max(1, Math.abs(expected) * 0.02);
Double claimed = null;
double best = Double.MAX_VALUE;
for (double v : pool) {
double d = Math.abs(v - expected);
if (d < best) { best = d; claimed = v; }
}
out.add(new Check(expr, expected, claimed, best <= tol));
}
return out;
}
int checked = 0, failed = 0;
for (String bullet : plan.sections().get("Inventory parameters")) {
String[] f = bullet.split(" \\| ");
if (f.length < 4) continue;
var checks = new ArrayList<Check>(checkField(f[1], "ss"));
checks.addAll(checkField(f[2], "rop"));
for (Check c : checks) {
checked++;
if (!c.ok()) failed++;
System.out.printf(" %s %s: `%s` = %.1f, stated %s%n",
c.ok() ? "ok " : "FAIL", f[0], c.expr(), c.expected(), c.claimed());
}
}
System.out.printf("%d expressions re-run, %d disagree%n", checked, failed);
SS_EXPR = /\A\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*\z/i
ROP_EXPR = /\A\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*\z/
NUMBER = /\d[\d,]*(?:\.\d+)?/
def to_num(s) = s.to_s.delete(",").to_f
# Re-run every backticked expression in one field.
def check_field(text, kind)
re = kind == "rop" ? ROP_EXPR : SS_EXPR
pool = text.scan(NUMBER).map { |s| to_num(s) }
text.scan(/`([^`]+)`/).flatten.filter_map do |expr|
m = expr.match(re)
next unless m
a, b, c = to_num(m[1]), to_num(m[2]), to_num(m[3])
expected = kind == "rop" ? a * b + c : a * b * Math.sqrt(c)
tol = [1.0, expected.abs * 0.02].max
claimed = pool.min_by { |v| (v - expected).abs }
{ expr: expr, expected: expected, claimed: claimed,
ok: !claimed.nil? && (claimed - expected).abs <= tol }
end
end
checked = 0
failed = 0
plan["Inventory parameters"].each do |item, ss, rop, _oq|
(check_field(ss, "ss") + check_field(rop, "rop")).each do |c|
checked += 1
failed += 1 unless c[:ok]
printf(" %s %s: `%s` = %.1f, stated %s\n",
c[:ok] ? "ok " : "FAIL", item, c[:expr], c[:expected], c[:claimed])
end
end
puts "#{checked} expressions re-run, #{failed} disagree"
exit 1 if failed.positive?
const SS_EXPR = '/^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*$/i';
const ROP_EXPR = '/^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*$/';
function to_num(string $s): float { return (float) str_replace(",", "", $s); }
/** Re-run every backticked expression in one field. */
function check_field(string $text, string $kind): array {
$re = $kind === "rop" ? ROP_EXPR : SS_EXPR;
preg_match_all('/\d[\d,]*(?:\.\d+)?/', $text, $nums);
$pool = array_map("to_num", $nums[0]);
preg_match_all('/`([^`]+)`/', $text, $ticks);
$out = [];
foreach ($ticks[1] as $expr) {
if (!preg_match($re, $expr, $m)) { continue; }
[$a, $b, $c] = [to_num($m[1]), to_num($m[2]), to_num($m[3])];
$expected = $kind === "rop" ? $a * $b + $c : $a * $b * sqrt($c);
$tol = max(1.0, abs($expected) * 0.02);
$claimed = null;
$best = INF;
foreach ($pool as $v) {
$d = abs($v - $expected);
if ($d < $best) { $best = $d; $claimed = $v; }
}
$out[] = ["expr" => $expr, "expected" => $expected,
"claimed" => $claimed, "ok" => $best <= $tol];
}
return $out;
}
$checked = 0;
$failed = 0;
foreach ($plan["Inventory parameters"] as [$item, $ss, $rop, $oq]) {
foreach (array_merge(check_field($ss, "ss"), check_field($rop, "rop")) as $c) {
$checked++;
if (!$c["ok"]) { $failed++; }
printf(" %s %s: `%s` = %.1f, stated %s\n",
$c["ok"] ? "ok " : "FAIL", $item, $c["expr"], $c["expected"], $c["claimed"]);
}
}
echo "$checked expressions re-run, $failed disagree\n";
using System.Text.RegularExpressions;
static readonly Regex SsExpr = new(
@"^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*x\s*sqrt\(\s*([\d.,]+)\s*\)\s*$", RegexOptions.IgnoreCase);
static readonly Regex RopExpr = new(@"^\s*([\d.,]+)\s*x\s*([\d.,]+)\s*\+\s*([\d.,]+)\s*$");
static readonly Regex NumberRe = new(@"\d[\d,]*(?:\.\d+)?");
static readonly Regex TickRe = new("`([^`]+)`");
static double Num(string s) => double.Parse(s.Replace(",", ""));
record Check(string Expr, double Expected, double? Claimed, bool Ok);
// Re-run every backticked expression in one field.
static List<Check> CheckField(string text, string kind)
{
var re = kind == "rop" ? RopExpr : SsExpr;
var pool = NumberRe.Matches(text).Select(m => Num(m.Value)).ToList();
var outp = new List<Check>();
foreach (Match tick in TickRe.Matches(text))
{
var expr = tick.Groups[1].Value;
var m = re.Match(expr);
if (!m.Success) continue;
double a = Num(m.Groups[1].Value), b = Num(m.Groups[2].Value), c = Num(m.Groups[3].Value);
var expected = kind == "rop" ? a * b + c : a * b * Math.Sqrt(c);
var tol = Math.Max(1, Math.Abs(expected) * 0.02);
double? claimed = null;
var best = double.MaxValue;
foreach (var v in pool)
{
var d = Math.Abs(v - expected);
if (d < best) { best = d; claimed = v; }
}
outp.Add(new Check(expr, expected, claimed, best <= tol));
}
return outp;
}
int checked = 0, failed = 0;
foreach (var row in Rows(plan, "Inventory parameters"))
{
if (row.Length < 4) continue;
var checks = CheckField(row[1], "ss").Concat(CheckField(row[2], "rop"));
foreach (var c in checks)
{
checked++;
if (!c.Ok) failed++;
Console.WriteLine($" {(c.Ok ? "ok " : "FAIL")} {row[0]}: `{c.Expr}` " +
$"= {c.Expected:F1}, stated {c.Claimed}");
}
}
Console.WriteLine($"{checked} expressions re-run, {failed} disagree");
This is AI-generated demand planning from the text you pasted, not a guarantee of service
level or margin: it sees only what you sent, never your live on-hand, your open POs or your
supplier's real capacity. A failed arithmetic check means the reply contradicted itself and
the row must be rechecked before anyone orders against it; a passed check means the stated
formula reproduces the stated number, nothing more. Read ## Open questions
first — anything the paste could not support arrives there rather than as a silently
assumed industry default — and keep a planner in the loop.