222 lines
6.9 KiB
TypeScript
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);
|
|
}
|