Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions wonderweave/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Server-only provider credentials. Production values live in Sites as masked
# secrets. For local live work, keep values in a chmod 600 file outside the repo
# and point LIVE_*_ENV_FILE at it. Never use NEXT_PUBLIC_* for these values.
ANTHROPIC_API_KEY=
OPENAI_API_KEY=

# Pinned production model roles.
LEARNING_MODEL=claude-sonnet-5
REALTIME_MODEL=gpt-realtime-2.1

# Optional: point live tests at separate approved local env files.
LIVE_MODEL_ENV_FILE=
LIVE_REALTIME_ENV_FILE=
50 changes: 50 additions & 0 deletions wonderweave/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage
/test-results/

# next.js
/.next/
/.vinext/
/out/

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env*
!.env.example
.dev.vars*
.secrets/
secrets.local/
*.secret
*.secrets

# vercel
.vercel

# typescript
next-env.d.ts
*.tsbuildinfo
/dist/
/.wrangler/
/outputs/
/work/
5 changes: 5 additions & 0 deletions wonderweave/.openai/hosting.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"project_id": "appgprj_6a7250e1630c819181675062ecacefa4",
"d1": null,
"r2": null
}
9 changes: 9 additions & 0 deletions wonderweave/.vercelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Build output and local tool state from the Cloudflare/vinext target.
# Everything else must stay: tsconfig type-checks `**/*.ts`, and vite.config.ts
# and drizzle.config.ts import from build/, worker/, and db/ — removing those
# would fail `next build` at the TypeScript step.
dist
.wrangler
.vinext
outputs
work
343 changes: 343 additions & 0 deletions wonderweave/README.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions wonderweave/app/anatomy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AnatomyApp } from "../components/AnatomyApp";

export default function AnatomyReferencePage() {
return <AnatomyApp />;
}
110 changes: 110 additions & 0 deletions wonderweave/app/api/generate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { NextResponse } from "next/server";
import {
ARTIFACT_SCHEMA_VERSION,
POLICY_VERSION,
PROMPT_VERSION,
parseGenerationBrief,
type GenerateResponse,
} from "@/app/lib/learning-artifact";
import { generateLearningArtifact, ModelProviderError } from "@/app/lib/model-provider";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const MAX_REQUEST_BYTES = 8_192;

export async function POST(request: Request) {
const traceId = `gen_${crypto.randomUUID()}`;
const startedAt = Date.now();
const responseHeaders = {
"Cache-Control": "no-store, max-age=0",
"X-Trace-Id": traceId,
};

const contentLength = Number(request.headers.get("content-length") || 0);
if (contentLength > MAX_REQUEST_BYTES) {
return errorResponse(413, "REQUEST_TOO_LARGE", "Keep the lesson brief under 8 KB.", traceId, responseHeaders);
}

let input: unknown;
try {
const body = await request.text();
if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES) {
return errorResponse(413, "REQUEST_TOO_LARGE", "Keep the lesson brief under 8 KB.", traceId, responseHeaders);
}
input = JSON.parse(body);
} catch {
return errorResponse(400, "INVALID_JSON", "Send a valid JSON lesson brief.", traceId, responseHeaders);
}

const brief = parseGenerationBrief(input);
if (!brief.ok) {
return NextResponse.json(
{ error: { code: brief.code, message: brief.message, traceId, fields: brief.fields } },
{ status: 400, headers: responseHeaders },
);
}

try {
const generated = await generateLearningArtifact(brief.value);
const payload: GenerateResponse = {
artifact: generated.artifact,
receipt: {
provider: generated.provider,
model: generated.model,
promptVersion: PROMPT_VERSION,
schemaVersion: ARTIFACT_SCHEMA_VERSION,
policyVersion: POLICY_VERSION,
generatedAt: new Date().toISOString(),
traceId,
latencyMs: Date.now() - startedAt,
attempts: generated.attempts,
validation: { schema: "pass", policy: "pass" },
},
};
return NextResponse.json(payload, { status: 200, headers: responseHeaders });
} catch (error) {
const providerError = error instanceof ModelProviderError ? error : null;
const status = providerError?.kind === "rate_limit" ? 429
: providerError?.kind === "timeout" ? 504
: providerError?.kind === "invalid_output" ? 422
: providerError?.kind === "configuration" || providerError?.kind === "authentication" ? 503
: 502;
const code = providerError?.kind === "rate_limit" ? "MODEL_RATE_LIMITED"
: providerError?.kind === "timeout" ? "MODEL_TIMEOUT"
: providerError?.kind === "invalid_output" ? "ARTIFACT_REJECTED"
: providerError?.kind === "configuration" || providerError?.kind === "authentication" ? "MODEL_NOT_CONFIGURED"
: "MODEL_UNAVAILABLE";

// The provider error body, prompt, brief, and credentials are deliberately excluded.
console.error(JSON.stringify({
event: "generation.failed",
traceId,
code,
latencyMs: Date.now() - startedAt,
diagnostic: providerError?.kind === "invalid_output" ? providerError.message : undefined,
validationIssues: providerError?.kind === "invalid_output" ? providerError.issues : undefined,
}));
return errorResponse(status, code, publicMessage(code), traceId, responseHeaders);
}
}

function errorResponse(
status: number,
code: string,
message: string,
traceId: string,
headers: Record<string, string>,
) {
return NextResponse.json({ error: { code, message, traceId } }, { status, headers });
}

function publicMessage(code: string) {
switch (code) {
case "MODEL_RATE_LIMITED": return "The lesson workshop is busy. Please try again in a moment.";
case "MODEL_TIMEOUT": return "The lesson took too long to design. Your brief is saved; please retry.";
case "ARTIFACT_REJECTED": return "The draft did not pass our lesson checks. Please retry or simplify the objective.";
case "MODEL_NOT_CONFIGURED": return "Live lesson generation is not configured on this server.";
default: return "The lesson workshop could not finish this draft. Please try again.";
}
}
119 changes: 119 additions & 0 deletions wonderweave/app/api/realtime/token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { NextResponse } from "next/server";
import { readServerSecret, readServerSetting } from "@/app/lib/server-environment";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const DEFAULT_REALTIME_MODEL = "gpt-realtime-2.1";
const TOKEN_TTL_SECONDS = 600;

type ClientSecretResponse = {
value?: string;
expires_at?: number;
error?: { type?: string };
};

export async function POST() {
const traceId = `voice_${crypto.randomUUID()}`;
const headers = {
"Cache-Control": "no-store, max-age=0",
"X-Trace-Id": traceId,
};
const apiKey = readServerSecret("OPENAI_API_KEY");
const model = readServerSetting("REALTIME_MODEL", DEFAULT_REALTIME_MODEL);

if (!apiKey) {
return NextResponse.json(
{ error: { code: "VOICE_NOT_CONFIGURED", message: "Live voice is not configured on this server.", traceId } },
{ status: 503, headers },
);
}

let response: Response;
try {
response = await fetch("https://api.openai.com/v1/realtime/client_secrets", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
expires_after: {
anchor: "created_at",
seconds: TOKEN_TTL_SECONDS,
},
session: {
type: "realtime",
model,
instructions: [
"You are the warm voice inside Wonderweave, an adult-supervised learning experience for ages 3 to 6.",
"Use one short spoken idea at a time. Never ask for a child's name, location, contact details, school, or private information.",
"Stay inside the teacher-approved lesson supplied by the application. The application state and tools are authoritative.",
].join(" "),
output_modalities: ["audio"],
reasoning: { effort: "low" },
audio: {
input: {
noise_reduction: { type: "near_field" },
turn_detection: {
type: "semantic_vad",
eagerness: "low",
create_response: true,
interrupt_response: true,
},
},
output: {
voice: "marin",
speed: 0.94,
},
},
// Page narration is bounded by our artifact schema, but audio tokens
// accumulate much faster than written words. A numeric ceiling can
// stop a healthy read-aloud mid-sentence, so use the API's unbounded
// per-response setting and enforce brevity in the lesson schema.
max_output_tokens: "inf",
tracing: null,
},
}),
cache: "no-store",
});
} catch {
return NextResponse.json(
{ error: { code: "VOICE_PROVIDER_UNAVAILABLE", message: "The voice guide could not be reached.", traceId } },
{ status: 502, headers },
);
}

let payload: ClientSecretResponse = {};
try {
payload = await response.json() as ClientSecretResponse;
} catch {
// Provider bodies are deliberately not copied into application errors.
}

if (!response.ok || !payload.value || !payload.expires_at) {
console.error(JSON.stringify({
event: "realtime.client_secret_failed",
traceId,
status: response.status,
providerType: payload.error?.type || "unknown",
}));
const status = response.status === 401 || response.status === 403 ? 503
: response.status === 429 ? 429
: 502;
const code = status === 429 ? "VOICE_BUSY"
: status === 503 ? "VOICE_NOT_CONFIGURED"
: "VOICE_PROVIDER_UNAVAILABLE";
const message = status === 429
? "The voice guide is busy. Please try again in a moment."
: status === 503
? "Live voice is not configured on this server."
: "The voice guide could not start.";
return NextResponse.json({ error: { code, message, traceId } }, { status, headers });
}

return NextResponse.json(
{ value: payload.value, expiresAt: payload.expires_at, model },
{ status: 200, headers },
);
}
Loading