Compare commits

...

2 Commits

Author SHA1 Message Date
e73700763d Merge pull request 'Fix #32: gestringifieerde arrays in onboarding-overview output normaliseren' (#33) from fix/issue-32-onboarding-schema into main
All checks were successful
On Push to Main / test (push) Successful in 35s
On Push to Main / publish (push) Successful in 1m12s
On Push to Main / deploy-dev (push) Successful in 3m15s
Reviewed-on: #33
2026-07-13 07:46:53 +00:00
RaymondVerhoef
182fb2357f fix: coerce stringified arrays in onboarding overview output (#32)
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 41s
On Pull Request to Main / publish (pull_request) Successful in 1m15s
On Pull Request to Main / deploy-dev (pull_request) Successful in 3m17s
Opening an onboarding theme could fail with "LLM output failed schema
validation": the fast-tier model occasionally emits the key_points and
topics_covered tool fields as JSON-encoded strings instead of real
arrays. Same failure class the quiz schema already guards against with
z.preprocess.

- onboardingOverviewSchema: parse "[...]" strings back to arrays before
  validation; split a bullet/newline string of key points as a fallback;
  keep the first 5 key points on overage instead of failing the user.
  Genuinely bad output (too few points, non-JSON strings, wrong types)
  still fails validation.
- emit_onboarding_overview tool: descriptions now state the fields must
  be JSON arrays, never strings (prompt-side nudge).
- 5 regression tests reproducing the exact #32 payload shapes
  (npm test 139/139).

Closes #32

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 09:38:46 +02:00
3 changed files with 89 additions and 6 deletions

View File

@@ -9,6 +9,7 @@ import {
customTopicSchema, customTopicSchema,
graphActionsSchema, graphActionsSchema,
proposeGraphDeltaSchema, proposeGraphDeltaSchema,
onboardingOverviewSchema,
} from '../llmSchemas'; } from '../llmSchemas';
const sampleTopic = { const sampleTopic = {
@@ -192,3 +193,56 @@ describe('proposeGraphDeltaSchema', () => {
).toThrow(); ).toThrow();
}); });
}); });
describe('onboardingOverviewSchema (issue #32 — fast-tier stringified arrays)', () => {
const base = {
title: 'Privacy',
what_it_is: 'How we handle data.',
why_it_matters: 'You touch personal data weekly.',
};
const points = ['Point one', 'Point two', 'Point three'];
const topics = [{ topic_id: 'avg', label: 'AVG' }];
it('accepts well-formed output', () => {
const r = onboardingOverviewSchema.parse({ ...base, key_points: points, topics_covered: topics });
expect(r.key_points).toEqual(points);
expect(r.topics_covered).toEqual(topics);
});
it('coerces JSON-stringified arrays back to arrays (the exact #32 failure)', () => {
const r = onboardingOverviewSchema.parse({
...base,
key_points: JSON.stringify(points),
topics_covered: JSON.stringify(topics),
});
expect(r.key_points).toEqual(points);
expect(r.topics_covered).toEqual(topics);
});
it('splits a bullet/newline string of key points as a fallback', () => {
const r = onboardingOverviewSchema.parse({
...base,
key_points: '- Point one\n• Point two\n3. Point three',
topics_covered: topics,
});
expect(r.key_points).toEqual(points);
});
it('keeps the first 5 key points instead of failing on overage', () => {
const seven = Array.from({ length: 7 }, (_, i) => `P${i + 1}`);
const r = onboardingOverviewSchema.parse({ ...base, key_points: seven, topics_covered: topics });
expect(r.key_points).toEqual(['P1', 'P2', 'P3', 'P4', 'P5']);
});
it('still rejects genuinely bad output', () => {
expect(() =>
onboardingOverviewSchema.parse({ ...base, key_points: ['only', 'two'], topics_covered: topics }),
).toThrow();
expect(() =>
onboardingOverviewSchema.parse({ ...base, key_points: points, topics_covered: 'not json at all' }),
).toThrow();
expect(() =>
onboardingOverviewSchema.parse({ ...base, key_points: 42, topics_covered: topics }),
).toThrow();
});
});

View File

@@ -229,14 +229,43 @@ export const themeSessionSchema = z.object({
keyTakeaways: z.array(z.string().min(1)).min(3), keyTakeaways: z.array(z.string().min(1)).min(3),
}); });
// Fast-tier models occasionally emit array tool fields as a JSON-encoded
// string ("[\"a\",\"b\"]") instead of a real array (issue #32). Parse those
// back to arrays before validating; leave anything else untouched so real
// type errors still fail validation.
function coerceStringifiedArray(v) {
if (typeof v === 'string') {
const s = v.trim();
if (s.startsWith('[')) {
try { return JSON.parse(s); } catch { /* keep original, let Zod report */ }
}
}
return v;
}
const onboardingKeyPoints = z.preprocess((v) => {
let out = coerceStringifiedArray(v);
// Fallback: a bullet/newline list as one string → split into points.
if (typeof out === 'string' && out.includes('\n')) {
out = out
.split('\n')
.map((line) => line.replace(/^\s*[-•*\d.]+\s*/, '').trim())
.filter(Boolean);
}
// Overage is trivial — keep the first 5 rather than failing the user.
if (Array.isArray(out) && out.length > 5) out = out.slice(0, 5);
return out;
}, z.array(z.string().min(1)).min(3).max(5));
export const onboardingOverviewSchema = z.object({ export const onboardingOverviewSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
what_it_is: z.string().min(1), what_it_is: z.string().min(1),
why_it_matters: z.string().min(1), why_it_matters: z.string().min(1),
key_points: z.array(z.string().min(1)).min(3).max(5), key_points: onboardingKeyPoints,
topics_covered: z topics_covered: z.preprocess(
.array(z.object({ topic_id: z.string().min(1), label: z.string().min(1) })) coerceStringifiedArray,
.min(1), z.array(z.object({ topic_id: z.string().min(1), label: z.string().min(1) })).min(1),
),
}); });
/** /**

View File

@@ -470,11 +470,11 @@ export const EMIT_ONBOARDING_OVERVIEW_TOOL = {
items: { type: 'string' }, items: { type: 'string' },
minItems: 3, minItems: 3,
maxItems: 5, maxItems: 5,
description: '35 short, concrete takeaways a newcomer should remember about this theme.', description: '35 short, concrete takeaways a newcomer should remember about this theme. Must be a JSON array of strings — never a single string.',
}, },
topics_covered: { topics_covered: {
type: 'array', type: 'array',
description: 'The topics that make up this theme. Reuse the exact topic_id you were given so the UI can link back.', description: 'The topics that make up this theme. Reuse the exact topic_id you were given so the UI can link back. Must be a JSON array of objects — never a string.',
items: { items: {
type: 'object', type: 'object',
properties: { properties: {