425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
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();
|
|
}
|