← Public packages
@jpfi/stories
Create personalized Teddy & Pinguin bedtime stories for your child and get the finished text back.
src/domain.ts
350 lines · 11.1 KB · TypeScriptexport type ContentLocale = "de" | "en";
export type StoryPhase =
| "planning"
| "writing"
| "reviewing"
| "continuity_memory"
| "finalizing";
declare const brand: unique symbol;
/** Integer in Teddy's supported 5..12 range. Construct only via {@link clampTargetReadMinutes}. */
export type TargetReadMinutes = number & { readonly [brand]: "TargetReadMinutes" };
/** Matches Teddy's /^[A-Za-z0-9._:-]{8,128}$/. Construct only via {@link deriveIdempotencyKey}. */
export type IdempotencyKey = string & { readonly [brand]: "IdempotencyKey" };
export const READ_MINUTES_MIN = 5;
export const READ_MINUTES_MAX = 12;
export const READ_MINUTES_DEFAULT = 8;
// Live generation can take about five minutes. 360 is 20% headroom over that default.
export const DEFAULT_WAIT_SECONDS = 300;
export const MAX_WAIT_SECONDS = 360;
export const ATTACH_WINDOW_MS = 30 * 60_000;
export const TOKEN_SETUP_URL =
"https://kody.codes/account/secrets/new?name=teddyPinguinToken&allowedHosts=www.teddy-pinguin.com";
export type Story = {
id: string;
title: string;
summary: string;
prose: string;
contentLocale: ContentLocale;
readMinutes: number | null;
libraryUrl: string;
series: { id: string; position: number } | null;
};
export type StoryGenerationResult =
| { status: "ready"; runId: string; story: Story }
| {
status: "pending";
runId: string;
phase: StoryPhase | null;
waitedSeconds: number;
nextStep: string;
}
| { status: "declined"; runId: string; code: string; message: string; canRetry: boolean }
| {
status: "unavailable";
runId: string | null;
code: string;
message: string;
retryAfterSeconds: number | null;
}
| { status: "disconnected"; message: string; tokenSetupUrl: string }
| { status: "invalid_input"; field: "storyIdea" | "runId" | "series" | "continueFromStoryId"; message: string };
export type ConnectionStatus =
| { status: "connected"; apiBaseUrl: string; message: string }
| { status: "disconnected"; message: string; tokenSetupUrl: string }
| { status: "unreachable"; apiBaseUrl: string; message: string };
export type GenerateStoryInput = {
storyIdea: string;
contentLocale?: ContentLocale;
targetReadMinutes?: number;
maxWaitSeconds?: number;
series?: { start: true } | { continue: string };
continueFromStoryId?: string;
};
export type GetStoryResultInput = {
runId: string;
maxWaitSeconds?: number;
};
export type CheckConnectionInput = {
contentLocale?: ContentLocale;
};
export type NormalizedRequest = {
storyIdea: string;
contentLocale: ContentLocale;
targetReadMinutes: TargetReadMinutes;
seriesId: string | null;
seriesStart: boolean;
continueFromStoryId: string | null;
};
export type InvalidInput = Extract<StoryGenerationResult, { status: "invalid_input" }>;
const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/;
const POLL_DELAYS_MS = [2_000, 2_000, 3_000, 4_000, 5_000, 6_000, 8_000] as const;
export function clampTargetReadMinutes(value: number | undefined): TargetReadMinutes {
if (value === undefined || !Number.isFinite(value)) {
return READ_MINUTES_DEFAULT as TargetReadMinutes;
}
const rounded = Math.round(value);
const clamped = Math.min(READ_MINUTES_MAX, Math.max(READ_MINUTES_MIN, rounded));
return clamped as TargetReadMinutes;
}
export function normalizeContentLocale(value: unknown): ContentLocale {
return value === "en" ? "en" : "de";
}
export function normalizeStoryRequest(
input: GenerateStoryInput,
): { ok: true; request: NormalizedRequest } | { ok: false; failure: InvalidInput } {
const locale = normalizeContentLocale(input.contentLocale);
const storyIdea = typeof input.storyIdea === "string" ? input.storyIdea.trim() : "";
if (!storyIdea || storyIdea.length > 500) {
return {
ok: false,
failure: {
status: "invalid_input",
field: "storyIdea",
message:
locale === "de"
? "storyIdea muss 1 bis 500 Zeichen haben."
: "storyIdea must contain 1 to 500 characters.",
},
};
}
const continueFromStoryId =
typeof input.continueFromStoryId === "string" ? input.continueFromStoryId.trim() : "";
if (continueFromStoryId && !isUuid(continueFromStoryId)) {
return {
ok: false,
failure: {
status: "invalid_input",
field: "continueFromStoryId",
message:
locale === "de"
? "continueFromStoryId muss eine UUID sein."
: "continueFromStoryId must be a UUID.",
},
};
}
const seriesContinue =
input.series && "continue" in input.series ? input.series.continue.trim() : "";
if (seriesContinue && !isUuid(seriesContinue)) {
return {
ok: false,
failure: {
status: "invalid_input",
field: "series",
message:
locale === "de"
? "series.continue muss eine UUID sein."
: "series.continue must be a UUID.",
},
};
}
return {
ok: true,
request: {
storyIdea,
contentLocale: locale,
targetReadMinutes: clampTargetReadMinutes(input.targetReadMinutes),
seriesId: seriesContinue || null,
seriesStart: Boolean(input.series && "start" in input.series && input.series.start),
continueFromStoryId: continueFromStoryId || null,
},
};
}
function isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
value,
);
}
export function normalizeRunId(
runId: string,
): { ok: true; runId: string } | { ok: false; failure: InvalidInput } {
const trimmed = typeof runId === "string" ? runId.trim() : "";
if (!trimmed) {
return {
ok: false,
failure: {
status: "invalid_input",
field: "runId",
message: "runId is required.",
},
};
}
return { ok: true, runId: trimmed };
}
export function waitBudgetMs(seconds: number | undefined, fallbackSeconds: number): number {
const candidate = seconds === undefined ? fallbackSeconds : seconds;
if (!Number.isFinite(candidate)) {
return fallbackSeconds * 1000;
}
const clamped = Math.min(MAX_WAIT_SECONDS, Math.max(0, Math.trunc(candidate)));
return clamped * 1000;
}
function toHex(bytes: ArrayBuffer): string {
return Array.from(new Uint8Array(bytes), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}
export async function requestFingerprint(
apiBaseUrl: string,
request: NormalizedRequest,
): Promise<string> {
const seriesKey = request.continueFromStoryId
? `from:${request.continueFromStoryId}`
: request.seriesId
? `continue:${request.seriesId}`
: request.seriesStart
? "start"
: "";
const material = `${apiBaseUrl}|${request.storyIdea}|${request.contentLocale}|${request.targetReadMinutes}|${seriesKey}`;
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
return toHex(digest).slice(0, 32);
}
export function deriveIdempotencyKey(fingerprint: string, nowMs: number): IdempotencyKey {
const bucket = Math.floor(nowMs / ATTACH_WINDOW_MS);
const key = `tp-${fingerprint}-${bucket}`;
if (!IDEMPOTENCY_KEY_PATTERN.test(key)) {
throw new Error("derived Idempotency-Key failed Teddy's length and charset check");
}
return key as IdempotencyKey;
}
export function pollDelayMs(attempt: number): number {
const index = Math.min(Math.max(0, attempt), POLL_DELAYS_MS.length - 1);
return POLL_DELAYS_MS[index] + Math.floor(Math.random() * 500);
}
const FAILURE_MESSAGES: Record<string, Record<ContentLocale, string>> = {
STORY_REJECTED: {
de: "Diese Idee hat Teddy nicht als Kindergeschichte angenommen. Formuliere sie ruhiger oder konkreter.",
en: "Teddy did not accept this idea as a children's story. Try a calmer or more specific idea.",
},
STORY_RUN_CANCELLED: {
de: "Die Geschichte wurde abgebrochen.",
en: "This story was cancelled.",
},
STORY_LIMIT_REACHED: {
de: "Dein Kontingent an Geschichten ist aufgebraucht.",
en: "This account has used its story quota.",
},
RATE_LIMITED: {
de: "Zu viele Anfragen. Warte einen Moment und versuche es erneut.",
en: "Too many requests. Wait a moment and try again.",
},
MODEL_UNAVAILABLE: {
de: "Die Geschichte konnte gerade nicht geschrieben werden. Versuche es später.",
en: "Story writing is unavailable right now. Try again later.",
},
STORY_GENERATION_FAILED: {
de: "Die Geschichte ist fehlgeschlagen.",
en: "Story generation failed.",
},
AUTHENTICATION_REQUIRED: {
de: "Kein gültiges Teddy & Pinguin Token. Speichere teddyPinguinToken in Kody.",
en: "No valid Teddy & Pinguin token. Save teddyPinguinToken in Kody.",
},
STORY_RUN_NOT_FOUND: {
de: "Diese Geschichte wurde nicht gefunden.",
en: "This story run was not found.",
},
STORY_NOT_FOUND: {
de: "Die fertige Geschichte konnte nicht geladen werden.",
en: "The finished story could not be loaded.",
},
INVALID_TEDDY_RESPONSE: {
de: "Teddy hat eine unerwartete Antwort geschickt.",
en: "Teddy returned an unexpected response.",
},
TEDDY_UNAVAILABLE: {
de: "Teddy ist gerade nicht erreichbar. Versuche es später.",
en: "Teddy is unavailable. Try again later.",
},
IDEMPOTENCY_KEY_REUSED: {
de: "Diese Anfrage kollidiert mit einer anderen. Ändere die Idee oder warte 30 Minuten.",
en: "This request collides with a different one. Change the idea or wait 30 minutes.",
},
};
export function describeFailure(
code: string,
phase: StoryPhase | "preparing" | null,
locale: ContentLocale,
): string {
const known = FAILURE_MESSAGES[code]?.[locale];
if (known) {
return known;
}
if (phase && locale === "de") {
return `Die Geschichte ist in der Phase ${phase} fehlgeschlagen.`;
}
if (phase) {
return `Story generation failed during the ${phase} phase.`;
}
return locale === "de"
? "Die Geschichte ist fehlgeschlagen."
: "Story generation failed.";
}
export function describePending(
runId: string,
phase: StoryPhase | null,
locale: ContentLocale,
): string {
if (locale === "de") {
const phaseNote = phase ? ` (Phase ${phase})` : "";
return `Die Geschichte wird noch geschrieben${phaseNote}. Rufe get-story-result mit der runId ${runId} auf.`;
}
const phaseNote = phase ? ` (${phase})` : "";
return `The story is still being written${phaseNote}. Call get-story-result with runId ${runId}.`;
}
export function describeConnection(
status: "connected" | "disconnected" | "unreachable",
locale: ContentLocale,
): string {
if (status === "connected") {
return locale === "de"
? "Dein Teddy & Pinguin Konto ist verbunden."
: "Your Teddy & Pinguin account is connected.";
}
if (status === "disconnected") {
return describeFailure("AUTHENTICATION_REQUIRED", null, locale);
}
return locale === "de"
? "Teddy & Pinguin ist gerade nicht erreichbar."
: "Teddy & Pinguin is unreachable right now.";
}
export function storyLibraryUrl(
apiBaseUrl: string,
storyId: string,
locale: ContentLocale,
): string {
const prefix = locale === "de" ? "/de" : "";
return `${apiBaseUrl}${prefix}/my-story-library/${encodeURIComponent(storyId)}`;
}