LLM song-tweak: AI Gateway via Vercel AI SDK, budgeted tune flow

Add a ✨ tune button that lets an LLM inspect the proposed
visualization and tweak the proc-song knobs so the next generation
looks better. Edits only the 7 songbank params (bpm, brightness,
noise, noiseColour, dynamics, transition, arrangement) — never the
Look — then re-synthesizes and re-generates locally for preview.

- ai + @ai-sdk/openai + zod (library per plan) against the Vercel AI
  Gateway (OpenAI-compatible, configurable via
  VITE_AI_GATEWAY_URL/MODEL/API_KEY, localStorage override,
  .env.example).
- Structured output (TweakResultSchema) + clampEdits enforces ranges;
  prompt ≤ ~2.5k tokens, max 2-3 knobs ±0.15 / bpm ±8.
- Budget caps: 3/load + 10/session (sessionStorage), checked before
  network, NO_EDITS refunded, offline/auth/rate-limit mapped.
- Prompt split into src/llm/spec.js (pure, node-testable) + llmTweak.js
  (gateway + digest) to keep `node --test` off the Vite JSON path.
- UI: ⚙️ gateway/model/key dialog (localStorage flow-state:llm),
  confirm(prompt) with hint, diff modal with Apply/Dismiss; Apply
  synthesizes → FeatureTrack → generateLook → Show.useTrack.
- Tests: 7 new LLM cases (clamp, schema, prompt, budget), 18/18 pass.
  Build + lint:scenes green.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-09-13 20:03:27 +02:00
co-authored by Claude
parent 7736390f9c
commit b7c3537fcb
12 changed files with 980 additions and 2 deletions
+72
View File
@@ -0,0 +1,72 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { clampEdits, buildTweakPrompt, TweakResultSchema, ALLOWED_KEYS } from '../src/llm/spec.js';
import { LLM_BUDGET, getBudgetCounts, canInvoke, remainingText } from '../src/llm/budget.js';
import { LLM_DEFAULTS, LLM_STORAGE_KEY } from '../src/llm/config.js';
test('clampEdits clamps to valid ranges and drops invalid arrangement', () => {
const out = clampEdits({ bpm: 300, brightness: 1.5, noise: -1, arrangement: 'nope', dynamics: 0.1234 });
assert.equal(out.bpm, 180);
assert.equal(out.brightness, 1);
assert.equal(out.noise, 0);
assert.equal(out.arrangement, undefined);
assert.equal(out.dynamics, 0.12);
});
test('clampEdits preserves noiseColour null and rounds', () => {
assert.deepEqual(clampEdits({ noiseColour: null }), { noiseColour: null });
assert.deepEqual(clampEdits({ noiseColour: 0.3333 }), { noiseColour: 0.33 });
assert.deepEqual(clampEdits({ arrangement: 'classic' }), { arrangement: 'classic' });
});
test('ALLOWED_KEYS covers the 7 song knobs', () => {
assert.deepEqual([...ALLOWED_KEYS].sort(), ['arrangement', 'bpm', 'brightness', 'dynamics', 'noise', 'noiseColour', 'transition'].sort());
});
test('buildTweakPrompt returns system+user messages and respects hint', () => {
const fakeDigest = {
describe: 'seed abc · geometer · triad',
director: 'geometer',
paletteScheme: 'triad',
sections: [{ index: 0, kind: 'intro', name: 'Shot', layers: 'Shot' }],
validation: null,
};
const msgs = buildTweakPrompt({
spec: { bpm: 124, brightness: 0.5, noise: 0.2, noiseColour: null, dynamics: 0.55, transition: 0.6, arrangement: 'classic', duration: 120, seed: 42 },
track: { summary: { bpm: 124, meanCentroid: 0.5, meanFlatness: 0.2, dynamicRange: 0.55, meanLoudness: 0.4, tempoConfidence: 0.9 }, sections: [{ kind: 'intro', start: 0, end: 15, energy: 0.2 }], duration: 120 },
lookDigest: fakeDigest,
hint: 'make the drop calmer',
});
assert.equal(msgs.length, 2);
assert.equal(msgs[0].role, 'system');
assert.equal(msgs[1].role, 'user');
assert.ok(msgs[0].content.includes('make the drop calmer') || msgs[0].content.includes('User hint'));
assert.ok(msgs[1].content.includes('songSpec'));
});
test('buildTweakPrompt truncates long hint in system', () => {
const long = 'x'.repeat(1000);
const msgs = buildTweakPrompt({ spec: null, track: null, lookDigest: null, hint: long });
assert.ok(msgs[0].content.length < 5000);
});
test('TweakResultSchema validates a minimal valid result', () => {
const parsed = TweakResultSchema.parse({ rationale: 'Lower noise to calm the drop.', edits: { noise: 0.3 }, confidence: 0.8 });
assert.equal(parsed.edits.noise, 0.3);
assert.ok(parsed.rationale.length > 0);
});
test('budget defaults are sane and canInvoke reports not-configured without a key', () => {
assert.equal(LLM_BUDGET.perLoad, 3);
assert.equal(LLM_BUDGET.perSession, 10);
assert.equal(LLM_STORAGE_KEY, 'flow-state:llm');
assert.equal(LLM_DEFAULTS.gatewayUrl, 'https://ai-gateway.vercel.sh/v1');
const counts = getBudgetCounts();
assert.ok(counts.perLoad === 3 && counts.perSession === 10);
assert.ok(typeof counts.loadCount === 'number' && typeof counts.sessionCount === 'number');
const gate = canInvoke();
assert.equal(gate.ok, false);
assert.equal(gate.reason, 'not-configured');
assert.ok(typeof remainingText() === 'string');
});