import { AI_MECHANIC_SOURCE_KINDS, AI_MECHANIC_SOURCE_PROFILES, type AiMechanicAuthoringRequest, type AiMechanicCapabilityGap, type AiMechanicQuestion, type AiMechanicSourceKind, } from '$lib/ai/mechanic-authoring'; import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; const MAX_DESCRIPTION_LENGTH = 6_000; const MAX_CURRENT_DRAFT_LENGTH = 50_000; const MAX_TRANSCRIPT_ENTRIES = 8; const MAX_TRANSCRIPT_FIELD_LENGTH = 1_200; type ObjectValue = Record; export type RawAiMechanicModelResponse = { status: 'clarification_required' | 'draft_ready' | 'capability_gap'; summary: string; assumptions: string[]; warnings: string[]; questions: AiMechanicQuestion[]; draftJson: string | null; gaps: AiMechanicCapabilityGap[]; }; const isObject = (value: unknown): value is ObjectValue => typeof value === 'object' && value !== null && !Array.isArray(value); const boundedString = ( value: unknown, label: string, max: number, allowEmpty = false, ) => { if (typeof value !== 'string') throw new AiMechanicRequestError(`${label} must be text.`); const result = value.trim(); if (!allowEmpty && !result) throw new AiMechanicRequestError(`${label} is required.`); if (result.length > max) throw new AiMechanicRequestError(`${label} is too long.`); return result; }; const exactArray = (actual: readonly string[], expected: readonly string[]) => actual.length === expected.length && actual.every((value, index) => value === expected[index]); export class AiMechanicRequestError extends Error { status: number; constructor(message: string, status = 400) { super(message); this.name = 'AiMechanicRequestError'; this.status = status; } } export function parseAiMechanicAuthoringRequest( value: unknown, ): AiMechanicAuthoringRequest { if (!isObject(value) || !isObject(value.context)) { throw new AiMechanicRequestError('Mechanic context is required.'); } const sourceKind = value.context.sourceKind; if ( typeof sourceKind !== 'string' || !(AI_MECHANIC_SOURCE_KINDS as readonly string[]).includes(sourceKind) ) { throw new AiMechanicRequestError( 'The mechanic source type is not supported.', ); } const profile = AI_MECHANIC_SOURCE_PROFILES[sourceKind as AiMechanicSourceKind]; if (!Array.isArray(value.valueColumns) || !value.valueColumns.length) { throw new AiMechanicRequestError('Mechanic value variants are required.'); } const valueColumns = value.valueColumns.map((column, index) => { if (!isObject(column)) { throw new AiMechanicRequestError( `Value variant ${index + 1} is invalid.`, ); } return { key: boundedString(column.key, `Value variant ${index + 1} key`, 80), variantKey: boundedString( column.variantKey, `Value variant ${index + 1} identifier`, 80, ), label: boundedString( column.label, `Value variant ${index + 1} label`, 120, ), }; }); if ( !exactArray( valueColumns.map((column) => column.variantKey), profile.variantKeys, ) ) { throw new AiMechanicRequestError( `This mechanic source requires exactly these value variants: ${profile.variantKeys.join(', ')}.`, ); } if (value.allowAssignedAbility !== profile.allowAssignedAbility) { throw new AiMechanicRequestError( 'Assigned-ability scope does not match this mechanic source.', ); } if ( value.allowProtocolUpgradeScaling !== profile.allowProtocolUpgradeScaling ) { throw new AiMechanicRequestError( 'Protocol-upgrade scaling does not match this mechanic source.', ); } if (!isObject(value.currentDraft)) { throw new AiMechanicRequestError('The current mechanic draft is required.'); } if (JSON.stringify(value.currentDraft).length > MAX_CURRENT_DRAFT_LENGTH) { throw new AiMechanicRequestError( 'The current mechanic draft is too large.', ); } if ( !Array.isArray(value.transcript) || value.transcript.length > MAX_TRANSCRIPT_ENTRIES ) { throw new AiMechanicRequestError( `Clarification history may contain at most ${MAX_TRANSCRIPT_ENTRIES} answers.`, ); } const transcript = value.transcript.map((entry, index) => { if (!isObject(entry)) { throw new AiMechanicRequestError( `Clarification answer ${index + 1} is invalid.`, ); } const answer = entry.answer; if ( typeof answer !== 'string' && typeof answer !== 'number' && typeof answer !== 'boolean' ) { throw new AiMechanicRequestError( `Clarification answer ${index + 1} is invalid.`, ); } if ( typeof answer === 'string' && answer.trim().length > MAX_TRANSCRIPT_FIELD_LENGTH ) { throw new AiMechanicRequestError( `Clarification answer ${index + 1} is too long.`, ); } if (typeof answer === 'number' && !Number.isFinite(answer)) { throw new AiMechanicRequestError( `Clarification answer ${index + 1} must be finite.`, ); } return { questionId: boundedString( entry.questionId, `Clarification answer ${index + 1} identifier`, 120, ), question: boundedString( entry.question, `Clarification answer ${index + 1} question`, MAX_TRANSCRIPT_FIELD_LENGTH, ), answer: typeof answer === 'string' ? answer.trim() : answer, }; }); return { context: { sourceKind: sourceKind as AiMechanicSourceKind, sourceName: boundedString(value.context.sourceName, 'Mechanic name', 160, true) || 'Untitled mechanic', sourceDescription: boundedString( value.context.sourceDescription, 'In-game mechanic description', MAX_DESCRIPTION_LENGTH, ), }, currentDraft: value.currentDraft as ConditionalMechanicDraftV1, valueColumns, allowAssignedAbility: profile.allowAssignedAbility, allowProtocolUpgradeScaling: profile.allowProtocolUpgradeScaling, transcript, }; } const nullableString = { anyOf: [{ type: 'string' }, { type: 'null' }] }; export const AI_MECHANIC_RESPONSE_SCHEMA = { type: 'object', additionalProperties: false, required: [ 'status', 'summary', 'assumptions', 'warnings', 'questions', 'draftJson', 'gaps', ], properties: { status: { type: 'string', enum: ['clarification_required', 'draft_ready', 'capability_gap'], }, summary: { type: 'string', maxLength: 1_200 }, assumptions: { type: 'array', maxItems: 12, items: { type: 'string', maxLength: 800 }, }, warnings: { type: 'array', maxItems: 12, items: { type: 'string', maxLength: 800 }, }, questions: { type: 'array', maxItems: 5, items: { type: 'object', additionalProperties: false, required: ['id', 'question', 'reason', 'answerType', 'options'], properties: { id: { type: 'string', maxLength: 120 }, question: { type: 'string', maxLength: 1_200 }, reason: { type: 'string', maxLength: 1_200 }, answerType: { type: 'string', enum: ['text', 'number', 'choice', 'boolean'], }, options: { type: 'array', maxItems: 8, items: { type: 'string', maxLength: 240 }, }, }, }, }, draftJson: nullableString, gaps: { type: 'array', maxItems: 8, items: { type: 'object', additionalProperties: false, required: [ 'area', 'requiredBehavior', 'whyUnsupported', 'developerChange', 'workaround', ], properties: { area: { type: 'string', enum: [ 'event', 'predicate', 'reducer', 'mutation', 'outcome', 'scope', 'value_reference', 'source_variant', 'runtime', ], }, requiredBehavior: { type: 'string', maxLength: 1_600 }, whyUnsupported: { type: 'string', maxLength: 1_600 }, developerChange: { type: 'string', maxLength: 2_400 }, workaround: nullableString, }, }, }, }, } as const; const stringArray = (value: unknown, label: string, max: number) => { if ( !Array.isArray(value) || value.some((item) => typeof item !== 'string') || value.length > max ) { throw new Error(`The model returned invalid ${label}.`); } return value.map(String); }; export function parseRawAiMechanicModelResponse( value: unknown, ): RawAiMechanicModelResponse { if (!isObject(value)) throw new Error('The model returned an invalid response.'); if ( value.status !== 'clarification_required' && value.status !== 'draft_ready' && value.status !== 'capability_gap' ) { throw new Error('The model returned an unsupported response status.'); } const summary = boundedModelString(value.summary, 'summary'); const assumptions = stringArray(value.assumptions, 'assumptions', 12); const warnings = stringArray(value.warnings, 'warnings', 12); if (!Array.isArray(value.questions) || value.questions.length > 5) { throw new Error('The model returned invalid clarification questions.'); } const questions = value.questions.map((question): AiMechanicQuestion => { if (!isObject(question)) throw new Error('The model returned an invalid clarification question.'); if ( !['text', 'number', 'choice', 'boolean'].includes( String(question.answerType), ) ) { throw new Error( 'The model returned an unsupported clarification answer type.', ); } if ( !Array.isArray(question.options) || question.options.some((option) => typeof option !== 'string') ) { throw new Error('The model returned invalid clarification options.'); } return { id: boundedModelString(question.id, 'question identifier'), question: boundedModelString(question.question, 'question'), reason: boundedModelString(question.reason, 'question reason'), answerType: question.answerType as AiMechanicQuestion['answerType'], options: question.options.map(String), }; }); if (value.draftJson !== null && typeof value.draftJson !== 'string') { throw new Error('The model returned an invalid mechanic draft.'); } if (!Array.isArray(value.gaps) || value.gaps.length > 8) { throw new Error('The model returned invalid capability gaps.'); } const validAreas = new Set([ 'event', 'predicate', 'reducer', 'mutation', 'outcome', 'scope', 'value_reference', 'source_variant', 'runtime', ]); const gaps = value.gaps.map((gap): AiMechanicCapabilityGap => { if ( !isObject(gap) || !validAreas.has(gap.area as AiMechanicCapabilityGap['area']) ) { throw new Error('The model returned an invalid capability gap.'); } return { area: gap.area as AiMechanicCapabilityGap['area'], requiredBehavior: boundedModelString( gap.requiredBehavior, 'required behavior', ), whyUnsupported: boundedModelString( gap.whyUnsupported, 'unsupported behavior explanation', ), developerChange: boundedModelString( gap.developerChange, 'developer change', ), workaround: gap.workaround === null ? null : boundedModelString(gap.workaround, 'workaround'), }; }); if (value.status === 'clarification_required') { if (!questions.length || value.draftJson !== null || gaps.length) { throw new Error('The clarification response has conflicting content.'); } } if (value.status === 'draft_ready') { if (!value.draftJson || questions.length || gaps.length) { throw new Error('The draft response has conflicting content.'); } } if (value.status === 'capability_gap') { if (!gaps.length || questions.length || value.draftJson !== null) { throw new Error('The capability-gap response has conflicting content.'); } } return { status: value.status, summary, assumptions, warnings, questions, draftJson: value.draftJson, gaps, }; } function boundedModelString(value: unknown, label: string) { if (typeof value !== 'string' || !value.trim()) { throw new Error(`The model returned an invalid ${label}.`); } return value.trim(); }