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