diff --git a/flow-state/src/llm/llmTweak.js b/flow-state/src/llm/llmTweak.js index 859f7d8..b40bc1b 100644 --- a/flow-state/src/llm/llmTweak.js +++ b/flow-state/src/llm/llmTweak.js @@ -97,17 +97,17 @@ export async function requestSongTweak({ spec, track, look, hint = '', config = baseURL: isDev ? '/__llm/v1' : cfg.gatewayUrl, apiKey: cfg.apiKey, fetch: isDev ? proxyFetch : undefined, - strictJsonSchema: false, }); const modelId = cfg.model; try { const result = await generateObject({ - model: provider(modelId), + model: provider.chat(modelId), schema: TweakResultSchema, messages, temperature: 0.4, maxRetries: 0, + providerOptions: { openai: { strictJsonSchema: false } }, }); const raw = result.object || {}; diff --git a/flow-state/src/llm/spec.js b/flow-state/src/llm/spec.js index 72a5ae9..1db83b0 100644 --- a/flow-state/src/llm/spec.js +++ b/flow-state/src/llm/spec.js @@ -12,13 +12,13 @@ const ARRANGEMENT_VALUES = Object.keys(ARRANGEMENTS); export { ARRANGEMENT_VALUES }; export const TweakEditsSchema = z.object({ - bpm: z.number().int().min(60).max(180).optional(), - brightness: z.number().min(0).max(1).optional(), - noise: z.number().min(0).max(1).optional(), - noiseColour: z.number().min(0).max(1).nullable().optional(), - dynamics: z.number().min(0).max(1).optional(), - transition: z.number().min(0).max(1).optional(), - arrangement: z.enum(ARRANGEMENT_VALUES).optional(), + bpm: z.number().int().min(60).max(180).nullable(), + brightness: z.number().min(0).max(1).nullable(), + noise: z.number().min(0).max(1).nullable(), + noiseColour: z.number().min(0).max(1).nullable(), + dynamics: z.number().min(0).max(1).nullable(), + transition: z.number().min(0).max(1).nullable(), + arrangement: z.enum(ARRANGEMENT_VALUES).nullable(), }); export const TweakResultSchema = z.object({ @@ -106,8 +106,9 @@ export function buildTweakPrompt({ spec, track, look, lookDigest = null, hint = '- Prefer lowering agitation for breakdowns/intros; keep section count ≥3 if possible.', '- Valid bpm 60-180 int, brightness/noise/dynamics/transition/noiseColour 0-1, arrangement one of: ' + ARRANGEMENT_VALUES.join(', ') + '.', '- noiseColour may be null (follows brightness) or 0-1; leave it null unless you have a reason.', + '- All 7 edit fields MUST be present in edits — use null for unchanged (not omitted). Example: { bpm: null, brightness: 0.6, noise: null, noiseColour: null, dynamics: null, transition: null, arrangement: null }', '- Explain your reasoning in 1-2 sentences (rationale).', - '- If the look is already good, return empty edits {}.', + '- If the look is already good, return all-null edits.', hintBlock, ].join('\n'); diff --git a/flow-state/test/llm.test.js b/flow-state/test/llm.test.js index 19ca39f..0a58c61 100644 --- a/flow-state/test/llm.test.js +++ b/flow-state/test/llm.test.js @@ -52,8 +52,13 @@ test('buildTweakPrompt truncates long hint in system', () => { }); 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 }); + const parsed = TweakResultSchema.parse({ + rationale: 'Lower noise to calm the drop.', + edits: { bpm: null, brightness: null, noise: 0.3, noiseColour: null, dynamics: null, transition: null, arrangement: null }, + confidence: 0.8, + }); assert.equal(parsed.edits.noise, 0.3); + assert.equal(parsed.edits.bpm, null); assert.ok(parsed.rationale.length > 0); });