feat: add AI-assisted ISAC authoring

This commit is contained in:
JB
2026-07-27 14:53:36 -04:00
parent 4eb754e7c8
commit efb0ccdc7e
37 changed files with 3531 additions and 50 deletions
@@ -0,0 +1,342 @@
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<string, unknown>;
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<AiMechanicCapabilityGap['area']>([
'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();
}