Add ISAC mechanic gap handoff actions

This commit is contained in:
JB
2026-07-27 21:10:02 -04:00
parent 6e12b2f3b7
commit 36dd018a4a
9 changed files with 723 additions and 16 deletions
@@ -8,6 +8,10 @@
AiMechanicTranscriptEntry,
AiMechanicValueColumn
} from '$lib/ai/mechanic-authoring';
import {
buildMechanicDeveloperPrompt,
type AiMechanicDeveloperHandoff
} from '$lib/ai/mechanic-developer-handoff';
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
import { onDestroy, tick, untrack } from 'svelte';
@@ -46,6 +50,11 @@
let errorMessage = $state('');
let activityMessage = $state('');
let confirmApply = $state(false);
let gapHandoff = $state<AiMechanicDeveloperHandoff | null>(null);
let gapReportPending = $state(false);
let gapReportSubmitted = $state(false);
let handoffMessage = $state('');
let handoffMessageKind = $state<'success' | 'error'>('success');
let resultRegion = $state<HTMLElement>();
let requestController: AbortController | undefined;
let trackedContextKey = $state('');
@@ -129,6 +138,11 @@
errorMessage = '';
activityMessage = '';
confirmApply = false;
gapHandoff = null;
gapReportPending = false;
gapReportSubmitted = false;
handoffMessage = '';
handoffMessageKind = 'success';
}
function setAnswer(questionId: string, answer: ClarificationAnswer | undefined) {
@@ -154,20 +168,24 @@
errorMessage = '';
activityMessage = '';
confirmApply = false;
gapHandoff = null;
gapReportSubmitted = false;
handoffMessage = '';
try {
const authoringRequest = {
context: { ...context, sourceDescription: sourceText.trim() },
currentDraft,
valueColumns,
allowAssignedAbility,
allowProtocolUpgradeScaling,
transcript: nextTranscript
};
const request = await fetch('/api/isac/mechanic-assistant', {
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: requestController.signal,
body: JSON.stringify({
context: { ...context, sourceDescription: sourceText.trim() },
currentDraft,
valueColumns,
allowAssignedAbility,
allowProtocolUpgradeScaling,
transcript: nextTranscript
})
body: JSON.stringify(authoringRequest)
});
const contentType = request.headers.get('content-type') ?? '';
const payload = contentType.includes('application/json')
@@ -189,6 +207,16 @@
}
response = payload as AiMechanicAuthoringResponse;
if (response.status === 'capability_gap') {
gapHandoff = {
context: authoringRequest.context,
valueColumns: authoringRequest.valueColumns,
allowAssignedAbility: authoringRequest.allowAssignedAbility,
allowProtocolUpgradeScaling: authoringRequest.allowProtocolUpgradeScaling,
transcript: authoringRequest.transcript,
response
};
}
answers = {};
activityMessage = response.status === 'clarification_required'
? 'Clarification required before a reliable draft can be created.'
@@ -226,6 +254,9 @@
errorMessage = '';
activityMessage = '';
confirmApply = false;
gapHandoff = null;
gapReportSubmitted = false;
handoffMessage = '';
void requestDraft([]);
}
@@ -235,6 +266,53 @@
confirmApply = false;
activityMessage = 'AI draft applied to the manual editor. Review it before saving this item.';
}
async function copyDeveloperPrompt() {
if (!gapHandoff) return;
try {
await navigator.clipboard.writeText(buildMechanicDeveloperPrompt(gapHandoff));
handoffMessageKind = 'success';
handoffMessage = 'Developer prompt copied to the clipboard.';
} catch {
handoffMessageKind = 'error';
handoffMessage = 'Clipboard access was blocked. Check this browser’s clipboard permission and try again.';
}
}
async function submitGapReport() {
if (!gapHandoff || gapReportPending || gapReportSubmitted) return;
gapReportPending = true;
handoffMessage = '';
try {
const request = await fetch('/api/isac/mechanic-gap-report', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(gapHandoff)
});
const payload = request.headers.get('content-type')?.includes('application/json')
? await request.json()
: null;
if (!request.ok) {
throw new Error(
typeof payload?.message === 'string'
? payload.message
: `The mechanic gap could not be submitted (${request.status}).`
);
}
gapReportSubmitted = true;
handoffMessageKind = 'success';
handoffMessage = payload?.alreadySubmitted
? 'This mechanic gap was already submitted to the admin team.'
: `Mechanic gap submitted to ${Number(payload?.recipientCount ?? 0)} ${Number(payload?.recipientCount ?? 0) === 1 ? 'administrator' : 'administrators'}.`;
} catch (error) {
handoffMessageKind = 'error';
handoffMessage = error instanceof Error
? error.message
: 'The mechanic gap could not be submitted to the admin team.';
} finally {
gapReportPending = false;
}
}
</script>
<section class="authoring-assistant" class:locked={!canGenerate} aria-labelledby={`${contextKey}-assistant-title`}>
@@ -414,11 +492,36 @@
</article>
{/each}
</div>
<div class="result-actions">
<div class="result-actions handoff-actions">
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
REVISE SOURCE WORDING
</button>
<div>
<button type="button" class="secondary" disabled={!gapHandoff} onclick={copyDeveloperPrompt}>
COPY DEVELOPER PROMPT
</button>
<button
type="button"
class="primary"
disabled={!gapHandoff || gapReportPending || gapReportSubmitted}
aria-busy={gapReportPending}
onclick={submitGapReport}
>
{gapReportPending
? 'SUBMITTING GAP…'
: gapReportSubmitted
? 'SUBMITTED TO ADMIN TEAM'
: 'SUBMIT TO ADMIN TEAM'}
</button>
</div>
</div>
{#if handoffMessage}
<p
class="handoff-message"
class:error={handoffMessageKind === 'error'}
role={handoffMessageKind === 'error' ? 'alert' : 'status'}
>{handoffMessage}</p>
{/if}
{:else if response?.status === 'draft_ready'}
<div class="result-heading ready">
<div>
@@ -524,6 +627,7 @@
.answer-field input{min-height:42px;padding:0 10px;font-size:var(--shd-type-supporting);font-weight:400;letter-spacing:0}
.source-field :is(textarea,input):focus-visible,.answer-field input:focus-visible{border-color:var(--shd-orange);outline:1px solid color-mix(in srgb,var(--shd-orange) 55%,transparent);outline-offset:1px}
.request-actions,.result-actions,.apply-confirm{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm)}
.handoff-actions>div{display:flex;gap:var(--shd-gap-xs)}
.request-actions p{max-width:70ch;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.request-actions button,.result-actions button,.apply-confirm button{min-height:40px;padding:0 13px;font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}
.request-actions button:disabled,.result-actions button:disabled{cursor:wait;opacity:.58}
@@ -552,10 +656,13 @@
.apply-confirm>div:first-child{display:grid;gap:3px}.apply-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.apply-confirm>div:last-child{display:flex;gap:7px}
.replacement-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px;margin-top:4px}.replacement-summary span{display:grid;gap:2px;padding:5px 7px;border:1px solid var(--shd-border-soft);color:var(--shd-text);font-size:var(--shd-type-supporting)}.replacement-summary b{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
.activity-message{padding:7px 9px;border-left:2px solid var(--shd-info);background:var(--shd-surface-raised)}
.handoff-message{margin:0;padding:7px 9px;border-left:2px solid var(--shd-success);background:var(--shd-surface-raised);color:var(--shd-text);font-size:var(--shd-type-supporting);line-height:1.45}
.handoff-message.error{border-left-color:var(--shd-danger);color:var(--shd-danger)}
@media(max-width:760px){
.authoring-assistant>header{grid-template-columns:1fr auto}.authoring-assistant>header b{justify-self:end}.assistant-toggle{grid-column:1/-1;width:100%;min-height:44px}
.source-heading,.result-heading,.request-actions,.result-actions,.apply-confirm{align-items:stretch;flex-direction:column}
.request-actions button,.result-actions button,.apply-confirm button{width:100%;min-height:44px}
.handoff-actions>div{display:grid;grid-template-columns:1fr;width:100%}
.apply-confirm>div:last-child{display:grid;grid-template-columns:1fr 1fr}
.replacement-summary{grid-template-columns:1fr}
.gap-card dl,.review-notes{grid-template-columns:1fr}
@@ -55,7 +55,7 @@
disabled?: boolean;
} = $props();
let recipeKey = $state<TriggerActionKey>('same_target_hits_within_window');
let recipeKey = $state<TriggerActionKey | ''>('');
let recipeCount = $state(9);
let recipeWindow = $state(3);
let recipeDistance = $state(10);
@@ -118,13 +118,15 @@
}
function applyRecipe(replace = false) {
const result = createMechanicRecipe(recipeKey, recipeParams() as never);
if (!recipeKey) return;
const selectedRecipeKey = recipeKey;
const result = createMechanicRecipe(selectedRecipeKey, recipeParams() as never);
if (result.status !== 'supported') return;
const clauses = result.seed.clauses.map((seed, index): ConditionalMechanicClauseDraft => ({
key: `${slug(recipeKey)}-${rule.clauses.length + index + 1}`,
label: seed.label ?? TRIGGER_ACTION_REGISTRY[recipeKey].label,
key: `${slug(selectedRecipeKey)}-${rule.clauses.length + index + 1}`,
label: seed.label ?? TRIGGER_ACTION_REGISTRY[selectedRecipeKey].label,
event: seed.when,
recipeKey,
recipeKey: selectedRecipeKey,
if: seed.if ?? { kind: 'true' },
reducer: seed.reducer,
cooldownSeconds: seed.cooldownSeconds,
@@ -519,6 +521,7 @@
mode="single"
compact
subjectLabel="RECIPE"
emptySelectionLabel="NO RECIPE SELECTED"
bind:value={recipeKey}
/>
{#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'consecutive_same_target_hits'].includes(recipeKey)}
@@ -530,8 +533,8 @@
{#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)}
<label class="field">METERS<input type="number" min="0" step="any" bind:value={recipeDistance}/></label>
{/if}
<button type="button" class="secondary" onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
{#if rule.clauses.length}
<button type="button" class="secondary" disabled={!recipeKey} onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
{#if rule.clauses.length && recipeKey}
<button type="button" class="danger-quiet" onclick={() => replaceConfirmation = true}>REPLACE PROGRAM</button>
{/if}
</div>