Skip to content
← Public packages

@jpfi/stories

Create personalized Teddy & Pinguin bedtime stories for your child and get the finished text back.

FollowFork

src/domain.ts

350 lines · 11.1 KB · TypeScript
Raw
export 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)}`;
}