feat: add AI-assisted ISAC authoring

This commit is contained in:
JB
2026-07-27 14:53:36 -04:00
parent 4eb754e7c8
commit efb0ccdc7e
37 changed files with 3531 additions and 50 deletions
@@ -0,0 +1,111 @@
import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { recordIsacAudit } from '$lib/server/audit';
import {
AiMechanicConfigurationError,
AiMechanicGenerationError
} from '$lib/server/ai/mechanic-authoring-service';
import {
AiMechanicRateLimitError,
reserveAiMechanicGeneration
} from '$lib/server/ai/mechanic-authoring-rate-limit';
import {
AiCalculationRequestError,
parseAiCalculationAuthoringRequest
} from '$lib/server/ai/calculation-authoring-schema';
import { generateAiCalculationDraft } from '$lib/server/ai/calculation-authoring-service';
import { AI_CALCULATION_PROMPT_VERSION } from '$lib/server/ai/calculation-authoring-prompt';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const MAX_REQUEST_BYTES = 350_000;
export const POST: RequestHandler = async ({ cookies, request, url }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'ai.calculation.generate'
);
const origin = request.headers.get('origin');
if (origin && origin !== url.origin) {
return json(
{ message: 'Cross-origin calculation authoring requests are not allowed.' },
{ status: 403 }
);
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
return json({ message: 'The calculation authoring request is too large.' }, { status: 413 });
}
let payload: unknown;
try {
const body = await request.text();
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
return json({ message: 'The calculation authoring request is too large.' }, { status: 413 });
}
payload = JSON.parse(body);
} catch {
return json({ message: 'The calculation authoring request must be valid JSON.' }, { status: 400 });
}
try {
const authoringRequest = parseAiCalculationAuthoringRequest(payload);
const release = reserveAiMechanicGeneration(operator.id);
let response;
try {
response = await generateAiCalculationDraft(authoringRequest, operator.id);
} finally {
release();
}
await recordIsacAudit({
actorId: operator.id,
category: 'ai_assistance',
action: 'calculation_ai.generated',
entityId: authoringRequest.baseRevisionId
? `calculation_revision:${authoringRequest.baseRevisionId}`
: `calculation_revision_draft:${response.meta.requestId}`,
after: {
promptVersion: AI_CALCULATION_PROMPT_VERSION,
baseRevisionId: authoringRequest.baseRevisionId,
responseStatus: response.status,
model: response.meta.model,
requestId: response.meta.requestId,
usage: {
inputTokens: response.meta.inputTokens,
outputTokens: response.meta.outputTokens,
totalTokens: response.meta.totalTokens
},
clarificationCount: response.status === 'clarification_required'
? response.questions.length
: 0,
capabilityGapCount: response.status === 'capability_gap'
? response.gaps.length
: 0,
draftValidated: response.status === 'draft_ready'
}
});
return json(response);
} catch (caught) {
if (caught instanceof AiCalculationRequestError) {
return json({ message: caught.message }, { status: caught.status });
}
if (caught instanceof AiMechanicConfigurationError) {
return json({ message: caught.message }, { status: 503 });
}
if (caught instanceof AiMechanicRateLimitError) {
return json(
{ message: caught.message },
{
status: 429,
headers: { 'retry-after': String(caught.retryAfterSeconds) }
}
);
}
if (caught instanceof AiMechanicGenerationError) {
return json({ message: caught.message }, { status: 422 });
}
console.error('Calculation authoring assistant failed.', caught);
return json(
{ message: 'The AI calculation authoring service is temporarily unavailable.' },
{ status: 502 }
);
}
};
@@ -0,0 +1,110 @@
import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { recordIsacAudit } from '$lib/server/audit';
import {
AiMechanicConfigurationError,
AiMechanicGenerationError,
generateAiMechanicDraft
} from '$lib/server/ai/mechanic-authoring-service';
import { AI_MECHANIC_PROMPT_VERSION } from '$lib/server/ai/mechanic-authoring-prompt';
import {
AiMechanicRequestError,
parseAiMechanicAuthoringRequest
} from '$lib/server/ai/mechanic-authoring-schema';
import {
AiMechanicRateLimitError,
reserveAiMechanicGeneration
} from '$lib/server/ai/mechanic-authoring-rate-limit';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const MAX_REQUEST_BYTES = 100_000;
export const POST: RequestHandler = async ({ cookies, request, url }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'ai.mechanic.generate'
);
const origin = request.headers.get('origin');
if (origin && origin !== url.origin) {
return json(
{ message: 'Cross-origin mechanic authoring requests are not allowed.' },
{ status: 403 }
);
}
const contentLength = Number(request.headers.get('content-length') ?? 0);
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
return json({ message: 'The mechanic authoring request is too large.' }, { status: 413 });
}
let payload: unknown;
try {
const body = await request.text();
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
return json({ message: 'The mechanic authoring request is too large.' }, { status: 413 });
}
payload = JSON.parse(body);
} catch {
return json({ message: 'The mechanic authoring request must be valid JSON.' }, { status: 400 });
}
try {
const authoringRequest = parseAiMechanicAuthoringRequest(payload);
const release = reserveAiMechanicGeneration(operator.id);
let response;
try {
response = await generateAiMechanicDraft(authoringRequest, operator.id);
} finally {
release();
}
await recordIsacAudit({
actorId: operator.id,
category: 'ai_assistance',
action: 'mechanic_ai.generated',
entityId: `${authoringRequest.context.sourceKind}:${response.meta.requestId}`,
after: {
sourceKind: authoringRequest.context.sourceKind,
promptVersion: AI_MECHANIC_PROMPT_VERSION,
responseStatus: response.status,
model: response.meta.model,
requestId: response.meta.requestId,
usage: {
inputTokens: response.meta.inputTokens,
outputTokens: response.meta.outputTokens,
totalTokens: response.meta.totalTokens
},
variantKeys: authoringRequest.valueColumns.map((column) => column.variantKey),
clarificationCount: response.status === 'clarification_required'
? response.questions.length
: 0,
capabilityGapCount: response.status === 'capability_gap'
? response.gaps.length
: 0,
draftValidated: response.status === 'draft_ready'
}
});
return json(response);
} catch (caught) {
if (caught instanceof AiMechanicRequestError) {
return json({ message: caught.message }, { status: caught.status });
}
if (caught instanceof AiMechanicConfigurationError) {
return json({ message: caught.message }, { status: 503 });
}
if (caught instanceof AiMechanicRateLimitError) {
return json(
{ message: caught.message },
{
status: 429,
headers: { 'retry-after': String(caught.retryAfterSeconds) }
}
);
}
if (caught instanceof AiMechanicGenerationError) {
return json({ message: caught.message }, { status: 422 });
}
console.error('Mechanic authoring assistant failed.', caught);
return json(
{ message: 'The AI mechanic authoring service is temporarily unavailable.' },
{ status: 502 }
);
}
};
+2 -1
View File
@@ -90,7 +90,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access,
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration,
SUM(CASE WHEN entity_type='calculation_configuration' THEN 1 ELSE 0 END) AS calculation_configuration
SUM(CASE WHEN entity_type='calculation_configuration' THEN 1 ELSE 0 END) AS calculation_configuration,
SUM(CASE WHEN entity_type='ai_assistance' THEN 1 ELSE 0 END) AS ai_assistance
FROM audit_log`)).get()
]);
const pagination = getPagination(url, Number(total?.total ?? 0));
+1
View File
@@ -34,6 +34,7 @@
{value:'',label:'ALL EVENTS',count:data.summary.total,description:'Complete audit history'},
{value:'game_data',label:'GAME DATA',count:data.summary.game_data,description:'Builder-source mutations'},
{value:'calculation_configuration',label:'CALCULATION RULES',count:data.summary.calculation_configuration,description:'Combat model revisions and calibration'},
{value:'ai_assistance',label:'AI ASSISTANCE',count:data.summary.ai_assistance,description:'Permission-gated mechanic drafting'},
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
+16 -3
View File
@@ -7,6 +7,7 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import MechanicLanguageHelp from '$lib/components/MechanicLanguageHelp.svelte';
import PseudoScript from '$lib/components/PseudoScript.svelte';
import CalculationAuthoringAssistant from '$lib/components/isac/CalculationAuthoringAssistant.svelte';
import { createDraft } from '$lib/builder/loadout';
import { formatWeaponCalculationScript } from '$lib/calculations/calculation-script';
@@ -22,10 +23,10 @@
statAttributeId: string;
stageKey: string;
weight: number | string;
softCap: number | string;
hardCap: number | string;
softCap: number | string | null;
hardCap: number | string | null;
curveType: string;
curveParameter: number | string;
curveParameter: number | string | null;
weaponCategory: string;
};
type SkillProfileEditor = {
@@ -177,6 +178,14 @@
[stages[index], stages[target]] = [stages[target], stages[index]];
revisionEditing.stages = stages.map((stage, stageIndex) => ({ ...stage, displayOrder: stageIndex + 1 }));
}
function applyAiRevision(draft: Omit<RevisionEditor, 'id' | 'basedOnRevisionId'>) {
if (!revisionEditing) return;
revisionEditing = {
...draft,
id: revisionEditing.id,
basedOnRevisionId: revisionEditing.basedOnRevisionId
};
}
$effect(() => {
const token = String(form?.resultToken ?? '');
@@ -243,6 +252,10 @@
<input type="hidden" name="stagesJson" value={JSON.stringify(revisionEditing.stages)}/>
<input type="hidden" name="profilesJson" value={JSON.stringify(revisionEditing.profiles)}/>
<input type="hidden" name="skillProfilesJson" value={JSON.stringify(revisionEditing.skillProfiles)}/>
<CalculationAuthoringAssistant
currentRevision={revisionEditing}
onapply={applyAiRevision}
/>
<div class="calculation-revision-identity"><label class="field isac-record-name">REVISION NAME<input name="name" bind:value={revisionEditing.name} maxlength="120" required/></label><label class="field">NOTES<textarea name="notes" bind:value={revisionEditing.notes} maxlength="4000"></textarea></label></div>
<div class="selection-head"><div><span>ORDERED CALCULATION STAGES</span><small>BASE AND FIRING-CYCLE POSITIONS ARE LOCKED</small></div><b>{revisionEditing.stages.length} STAGES</b></div>
<div class="calculation-stage-list">{#each [...revisionEditing.stages].sort((left, right) => left.displayOrder - right.displayOrder) as stage, index}
+5
View File
@@ -174,6 +174,11 @@
attributes={data.stats}
valueColumns={talentValueColumns}
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
aiContext={{
sourceKind: 'os_protocol_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.description_template ?? ''
}}
allowProtocolUpgradeScaling
bind:rule={editing.talent.conditionalRule}
/>
+2 -2
View File
@@ -31,7 +31,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
}, 'name');
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : '';
const relatedPermissionFilter = filter === 'data'
? " OR p.name LIKE 'calculation.%'"
? " OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%'"
: filter === 'system'
? " OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%'"
: '';
@@ -47,7 +47,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const [summary, filtered, permissions] = await Promise.all([
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'isac.%') THEN 1 ELSE 0 END) AS isac,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'data.%' OR p.name LIKE 'calculation.%')) THEN 1 ELSE 0 END) AS data,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'data.%' OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%')) THEN 1 ELSE 0 END) AS data,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit
,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'system.%' OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%')) THEN 1 ELSE 0 END) AS system
+5
View File
@@ -121,6 +121,11 @@
allowAssignedAbility
valueColumns={talentValueColumns}
idPrefix={`skill-chip-talent-${editing.talent.id || 'new'}`}
aiContext={{
sourceKind: 'skill_chip_talent',
sourceName: editing?.talent?.name ?? '',
sourceDescription: editing?.talent?.description_template ?? ''
}}
bind:rule={editing.talent.conditionalRule}
/>
</section>
@@ -574,6 +574,11 @@
attributes={data.attributes}
valueColumns={conditionalValueColumns}
idPrefix={`specialization-rank-${rank.rank}-conditional-${combatIndex}`}
aiContext={{
sourceKind: 'specialization_tree_rank',
sourceName: combatEffectById(combatEffect.effectId)?.name ?? `${nodeEditing?.name ?? 'Tree node'} · Rank ${rank.rank}`,
sourceDescription: rank?.description ?? ''
}}
bind:rule={combatEffect.conditionalRule}
/>
</section>
+5
View File
@@ -102,6 +102,11 @@
attributes={data.stats}
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
idPrefix={`talent-${talent.id || 'new'}`}
aiContext={{
sourceKind: 'talent',
sourceName: talent?.name ?? '',
sourceDescription: talent?.descriptionTemplate ?? ''
}}
bind:rule={talent.conditionalRule}
/>
{#if form?.talentError}<p class="error">{form.talentError}</p>{/if}
+5
View File
@@ -123,6 +123,11 @@
attributes={data.stats}
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
idPrefix={`weapon-exotic-${editing.exoticTalent.id || 'new'}`}
aiContext={{
sourceKind: 'exotic_weapon_talent',
sourceName: editing?.exoticTalent?.name ?? '',
sourceDescription: editing?.exoticTalent?.descriptionTemplate ?? ''
}}
bind:rule={editing.exoticTalent.conditionalRule}
/>
</section>