Files
shd.build/src/lib/server/ai/mechanic-authoring-service.ts
T

222 lines
6.9 KiB
TypeScript

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);
}