feat: add AI-assisted ISAC authoring
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user