feat: add AI-assisted ISAC authoring
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
type AiMechanicAuthoringRequest,
|
||||
type AiMechanicAuthoringResponse,
|
||||
type AiMechanicResponseMeta
|
||||
} from '$lib/ai/mechanic-authoring';
|
||||
import {
|
||||
formatMechanicProgram
|
||||
} from '$lib/calculations/mechanic';
|
||||
import {
|
||||
parseConditionalMechanicDraft
|
||||
} from '$lib/calculations/mechanic-editor';
|
||||
import { TRIGGER_ACTION_KEYS } from '$lib/calculations/conditional';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createHash } from 'node:crypto';
|
||||
import OpenAI from 'openai';
|
||||
import type { Response } from 'openai/resources/responses/responses';
|
||||
import { loadAiMechanicAuthoringContext } from './mechanic-authoring-context';
|
||||
import {
|
||||
AI_MECHANIC_AUTHORING_INSTRUCTIONS,
|
||||
buildAiMechanicAuthoringInput
|
||||
} from './mechanic-authoring-prompt';
|
||||
import {
|
||||
AI_MECHANIC_RESPONSE_SCHEMA,
|
||||
parseRawAiMechanicModelResponse
|
||||
} from './mechanic-authoring-schema';
|
||||
|
||||
const DEFAULT_MODEL = 'gpt-5.6-terra';
|
||||
const MAX_OUTPUT_TOKENS = 12_000;
|
||||
const ALLOWED_REASONING_EFFORT = new Set([
|
||||
'none',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max'
|
||||
]);
|
||||
|
||||
let client: OpenAI | null = null;
|
||||
|
||||
function openAiClient() {
|
||||
const apiKey = env.OPENAI_MECHANIC_API_KEY?.trim();
|
||||
if (!apiKey) throw new AiMechanicConfigurationError('AI mechanic authoring is not configured.');
|
||||
if (!client) client = new OpenAI({ apiKey, timeout: 60_000, maxRetries: 1 });
|
||||
return client;
|
||||
}
|
||||
|
||||
function configuredReasoningEffort() {
|
||||
const configured = env.OPENAI_MECHANIC_REASONING_EFFORT?.trim().toLocaleLowerCase() || 'medium';
|
||||
return ALLOWED_REASONING_EFFORT.has(configured)
|
||||
? configured as 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
|
||||
: 'medium';
|
||||
}
|
||||
|
||||
export class AiMechanicConfigurationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AiMechanicConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AiMechanicGenerationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AiMechanicGenerationError';
|
||||
}
|
||||
}
|
||||
|
||||
function safetyIdentifier(userId: string) {
|
||||
return createHash('sha256').update(`shd-mechanic-authoring:${userId}`).digest('hex');
|
||||
}
|
||||
|
||||
function containsProtocolUpgradeReference(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(containsProtocolUpgradeReference);
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.source === 'equipment'
|
||||
&& record.path === 'protocolTalentUpgrades'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(record).some(containsProtocolUpgradeReference);
|
||||
}
|
||||
|
||||
function responseMeta(
|
||||
response: Response,
|
||||
model: string
|
||||
): AiMechanicResponseMeta {
|
||||
return {
|
||||
requestId: String((response as Response & { _request_id?: string })._request_id ?? response.id),
|
||||
model,
|
||||
inputTokens: Number(response.usage?.input_tokens ?? 0),
|
||||
outputTokens: Number(response.usage?.output_tokens ?? 0),
|
||||
totalTokens: Number(response.usage?.total_tokens ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateAiMechanicDraft(
|
||||
request: AiMechanicAuthoringRequest,
|
||||
userId: string
|
||||
): Promise<AiMechanicAuthoringResponse> {
|
||||
const model = env.OPENAI_MECHANIC_MODEL?.trim() || DEFAULT_MODEL;
|
||||
const catalog = await loadAiMechanicAuthoringContext();
|
||||
const response = await openAiClient().responses.create({
|
||||
model,
|
||||
instructions: AI_MECHANIC_AUTHORING_INSTRUCTIONS,
|
||||
input: buildAiMechanicAuthoringInput({ request, ...catalog }),
|
||||
reasoning: { effort: configuredReasoningEffort() },
|
||||
safety_identifier: safetyIdentifier(userId),
|
||||
store: false,
|
||||
tools: [],
|
||||
max_output_tokens: MAX_OUTPUT_TOKENS,
|
||||
text: {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
name: 'shd_mechanic_authoring_response',
|
||||
description: 'A validated draft, focused clarification questions, or capability gaps.',
|
||||
strict: true,
|
||||
schema: AI_MECHANIC_RESPONSE_SCHEMA
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!response.output_text) {
|
||||
throw new AiMechanicGenerationError('The AI assistant returned no usable response.');
|
||||
}
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(response.output_text);
|
||||
} catch {
|
||||
throw new AiMechanicGenerationError('The AI assistant returned malformed structured data.');
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = parseRawAiMechanicModelResponse(decoded);
|
||||
} catch (error) {
|
||||
throw new AiMechanicGenerationError(
|
||||
error instanceof Error
|
||||
? `The AI assistant returned invalid structured data: ${error.message}`
|
||||
: 'The AI assistant returned invalid structured data.'
|
||||
);
|
||||
}
|
||||
const meta = responseMeta(response, model);
|
||||
if (result.status === 'clarification_required') {
|
||||
return {
|
||||
status: result.status,
|
||||
summary: result.summary,
|
||||
assumptions: result.assumptions,
|
||||
warnings: result.warnings,
|
||||
questions: result.questions,
|
||||
meta
|
||||
};
|
||||
}
|
||||
if (result.status === 'capability_gap') {
|
||||
return {
|
||||
status: result.status,
|
||||
summary: result.summary,
|
||||
assumptions: result.assumptions,
|
||||
warnings: result.warnings,
|
||||
gaps: result.gaps,
|
||||
meta
|
||||
};
|
||||
}
|
||||
let draftValue: unknown;
|
||||
try {
|
||||
draftValue = JSON.parse(result.draftJson as string);
|
||||
} catch {
|
||||
throw new AiMechanicGenerationError('The AI assistant returned malformed mechanic draft JSON.');
|
||||
}
|
||||
if (
|
||||
!request.allowAssignedAbility
|
||||
&& draftValue
|
||||
&& typeof draftValue === 'object'
|
||||
&& Array.isArray((draftValue as { clauses?: unknown }).clauses)
|
||||
&& (draftValue as { clauses: Array<{ outcomes?: Array<{ scope?: unknown }> }> }).clauses
|
||||
.some((clause) => clause.outcomes?.some((outcome) => outcome.scope === 'assigned_ability'))
|
||||
) {
|
||||
throw new AiMechanicGenerationError(
|
||||
'The generated draft used assigned-ability scope for a source that does not support it.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
!request.allowProtocolUpgradeScaling
|
||||
&& containsProtocolUpgradeReference(draftValue)
|
||||
) {
|
||||
throw new AiMechanicGenerationError(
|
||||
'The generated draft used OS Protocol upgrade scaling for a source that does not support it.'
|
||||
);
|
||||
}
|
||||
const parsed = parseConditionalMechanicDraft(draftValue, {
|
||||
variantKeys: request.valueColumns.map((column) => column.variantKey),
|
||||
allowedAttributeIds: new Set(catalog.attributes.map((attribute) => attribute.id)),
|
||||
allowedRecipeKeys: new Set(TRIGGER_ACTION_KEYS),
|
||||
programId: `ai-draft:${request.context.sourceKind}`,
|
||||
programName: request.context.sourceName
|
||||
});
|
||||
if (!parsed.ok) {
|
||||
throw new AiMechanicGenerationError(
|
||||
`The generated draft did not pass deterministic validation: ${parsed.errors.join(' ')}`
|
||||
);
|
||||
}
|
||||
const firstVariant = request.valueColumns[0]?.variantKey;
|
||||
const program = firstVariant ? parsed.value.programs[firstVariant] : undefined;
|
||||
if (!program) throw new AiMechanicGenerationError('The generated draft has no supported value variant.');
|
||||
return {
|
||||
status: 'draft_ready',
|
||||
draft: parsed.value.draft,
|
||||
summary: result.summary,
|
||||
assumptions: result.assumptions,
|
||||
warnings: result.warnings,
|
||||
pseudoScript: formatMechanicProgram(program),
|
||||
meta
|
||||
};
|
||||
}
|
||||
|
||||
export async function countActiveAiMechanicAttributes() {
|
||||
const database = await getDatabase();
|
||||
return Number((await (await database.prepare(
|
||||
'SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1'
|
||||
)).get())?.count ?? 0);
|
||||
}
|
||||
Reference in New Issue
Block a user