Add ISAC mechanic gap handoff actions
This commit is contained in:
@@ -2041,6 +2041,11 @@ its state to assistive technology. Focus remains in the active field when the
|
|||||||
generated script refreshes, and reduced-motion preferences remove expansion
|
generated script refreshes, and reduced-motion preferences remove expansion
|
||||||
and replacement transitions.
|
and replacement transitions.
|
||||||
|
|
||||||
|
The recipe starter opens with no recipe selected. Keep its count, duration, and
|
||||||
|
distance parameters hidden until the author chooses a recipe. Disable `ADD
|
||||||
|
RECIPE BRANCH` while the selection is empty, and show `REPLACE PROGRAM` only
|
||||||
|
after the author selects a recipe and the current program contains a branch.
|
||||||
|
|
||||||
#### AI mechanic drafting
|
#### AI mechanic drafting
|
||||||
|
|
||||||
`MechanicProgramEditor` keeps its complete manual authoring controls available
|
`MechanicProgramEditor` keeps its complete manual authoring controls available
|
||||||
@@ -2075,6 +2080,16 @@ The panel presents five explicit states:
|
|||||||
- `DRAFT PREVIEW` shows assumptions, warnings, readiness, and the generated
|
- `DRAFT PREVIEW` shows assumptions, warnings, readiness, and the generated
|
||||||
script before an explicit `APPLY TO EDITOR` action.
|
script before an explicit `APPLY TO EDITOR` action.
|
||||||
|
|
||||||
|
The capability-gap state offers `COPY DEVELOPER PROMPT` and `SUBMIT TO ADMIN
|
||||||
|
TEAM` beside `REVISE SOURCE WORDING`. The copied prompt includes the exact
|
||||||
|
analyzed wording, clarification answers, gap evidence, and implementation
|
||||||
|
acceptance criteria. Submission sends one mandatory ISAC notification to each
|
||||||
|
active full administrator and records the report in the `ai_assistance` audit
|
||||||
|
history. Disable duplicate submission for the current assistant request.
|
||||||
|
Clipboard or network failures keep the handoff visible and show a specific
|
||||||
|
recovery message. Neither action changes the mechanic editor or saves the
|
||||||
|
parent record.
|
||||||
|
|
||||||
Applying a draft fills the structured fields in the current unsaved editor.
|
Applying a draft fills the structured fields in the current unsaved editor.
|
||||||
It does not save, publish, submit a proposal, close the editor, or discard
|
It does not save, publish, submit a proposal, close the editor, or discard
|
||||||
manual changes without confirmation. Show a concise before-and-after summary
|
manual changes without confirmation. Show a concise before-and-after summary
|
||||||
|
|||||||
@@ -484,6 +484,17 @@ The model returns one structured result:
|
|||||||
representation, an optional workaround, and the schema, runtime, editor,
|
representation, an optional workaround, and the schema, runtime, editor,
|
||||||
reference, and test changes a developer would need to add support.
|
reference, and test changes a developer would need to add support.
|
||||||
|
|
||||||
|
For a mechanic capability gap, the client builds a developer prompt from the
|
||||||
|
exact analyzed wording, clarification transcript, source profile, and
|
||||||
|
structured gap response. The author can copy that prompt without another model
|
||||||
|
request. The author can also submit the gap to active full administrators.
|
||||||
|
The submission endpoint validates bounded fields, confirms that the request ID
|
||||||
|
belongs to the author's audited `capability_gap` generation, suppresses repeat
|
||||||
|
submissions, sends mandatory ISAC notifications, and appends a
|
||||||
|
`mechanic_gap.submitted` event to the `ai_assistance` audit category. The
|
||||||
|
notification links to the filtered audit evidence. Submission does not mutate
|
||||||
|
the mechanic draft or parent game-data record.
|
||||||
|
|
||||||
The server validates `draft_ready` output against the response schema and the
|
The server validates `draft_ready` output against the response schema and the
|
||||||
same deterministic mechanic parser used by manual authoring. Unknown events,
|
same deterministic mechanic parser used by manual authoring. Unknown events,
|
||||||
Attributes, value models, scopes, operators, reducers, or outcomes fail
|
Attributes, value models, scopes, operators, reducers, or outcomes fail
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildMechanicDeveloperPrompt, type AiMechanicDeveloperHandoff } from './mechanic-developer-handoff';
|
||||||
|
|
||||||
|
const handoff: AiMechanicDeveloperHandoff = {
|
||||||
|
context: {
|
||||||
|
sourceKind: 'talent',
|
||||||
|
sourceName: 'Chain Reaction',
|
||||||
|
sourceDescription: 'Every third critical hit transfers the mark to a nearby enemy.'
|
||||||
|
},
|
||||||
|
valueColumns: [
|
||||||
|
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
|
||||||
|
{ key: 'tierII', variantKey: 'II', label: 'TIER II' }
|
||||||
|
],
|
||||||
|
allowAssignedAbility: false,
|
||||||
|
allowProtocolUpgradeScaling: false,
|
||||||
|
transcript: [{
|
||||||
|
questionId: 'transfer-target',
|
||||||
|
question: 'How is the next target selected?',
|
||||||
|
answer: 'Nearest enemy'
|
||||||
|
}],
|
||||||
|
response: {
|
||||||
|
status: 'capability_gap',
|
||||||
|
summary: 'The runtime cannot select a nearby transfer target.',
|
||||||
|
assumptions: ['The third hit consumes the current mark.'],
|
||||||
|
warnings: ['Target selection must be deterministic.'],
|
||||||
|
gaps: [{
|
||||||
|
area: 'runtime',
|
||||||
|
requiredBehavior: 'Select the nearest eligible enemy.',
|
||||||
|
whyUnsupported: 'The event has no ordered nearby-target collection.',
|
||||||
|
developerChange: 'Add a deterministic nearby-target selector.',
|
||||||
|
workaround: null
|
||||||
|
}],
|
||||||
|
meta: {
|
||||||
|
requestId: 'req_gap_123',
|
||||||
|
model: 'test-model',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 50,
|
||||||
|
totalTokens: 150
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('mechanic developer handoff prompt', () => {
|
||||||
|
it('includes the exact analyzed source, clarification evidence, and required code change', () => {
|
||||||
|
const prompt = buildMechanicDeveloperPrompt(handoff);
|
||||||
|
|
||||||
|
expect(prompt).toContain(handoff.context.sourceDescription);
|
||||||
|
expect(prompt).toContain('How is the next target selected?');
|
||||||
|
expect(prompt).toContain('Answer: Nearest enemy');
|
||||||
|
expect(prompt).toContain('Add a deterministic nearby-target selector.');
|
||||||
|
expect(prompt).toContain('Assistant request ID: req_gap_123');
|
||||||
|
expect(prompt).toContain('Persistence round-trips the new representation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits empty optional sections', () => {
|
||||||
|
const prompt = buildMechanicDeveloperPrompt({
|
||||||
|
...handoff,
|
||||||
|
transcript: [],
|
||||||
|
response: { ...handoff.response, assumptions: [], warnings: [] }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).not.toContain('## Clarification history');
|
||||||
|
expect(prompt).not.toContain('## Assumptions');
|
||||||
|
expect(prompt).not.toContain('## Warnings');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type {
|
||||||
|
AiMechanicCapabilityGapResponse,
|
||||||
|
AiMechanicContext,
|
||||||
|
AiMechanicTranscriptEntry,
|
||||||
|
AiMechanicValueColumn
|
||||||
|
} from '$lib/ai/mechanic-authoring';
|
||||||
|
|
||||||
|
export type AiMechanicDeveloperHandoff = {
|
||||||
|
context: AiMechanicContext;
|
||||||
|
valueColumns: AiMechanicValueColumn[];
|
||||||
|
allowAssignedAbility: boolean;
|
||||||
|
allowProtocolUpgradeScaling: boolean;
|
||||||
|
transcript: AiMechanicTranscriptEntry[];
|
||||||
|
response: AiMechanicCapabilityGapResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
function section(title: string, items: string[]) {
|
||||||
|
return items.length ? `\n## ${title}\n\n${items.join('\n')}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMechanicDeveloperPrompt(handoff: AiMechanicDeveloperHandoff) {
|
||||||
|
const { context, valueColumns, allowAssignedAbility, allowProtocolUpgradeScaling, transcript, response } = handoff;
|
||||||
|
const variants = valueColumns.map((column) => `${column.label} (${column.variantKey})`).join(', ');
|
||||||
|
const clarifications = transcript.map((entry) =>
|
||||||
|
`- ${entry.question}\n Answer: ${String(entry.answer)}`
|
||||||
|
);
|
||||||
|
const assumptions = response.assumptions.map((value) => `- ${value}`);
|
||||||
|
const warnings = response.warnings.map((value) => `- ${value}`);
|
||||||
|
const gaps = response.gaps.map((gap, index) => [
|
||||||
|
`### Gap ${index + 1}: ${gap.area}`,
|
||||||
|
`- Required behavior: ${gap.requiredBehavior}`,
|
||||||
|
`- Why unsupported: ${gap.whyUnsupported}`,
|
||||||
|
`- Developer change: ${gap.developerChange}`,
|
||||||
|
gap.workaround ? `- Current workaround: ${gap.workaround}` : '- Current workaround: None'
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
return `Implement support for the following ISAC mechanic-language gap in the SHD.build repository.
|
||||||
|
|
||||||
|
Start by tracing the existing typed mechanic grammar, persistence adapter, runtime, editor, AI authoring context, and tests. Extend the smallest reusable layer that can represent the behavior exactly. Do not invent combat behavior or weaken deterministic validation. Preserve manual mechanic authoring and existing saved game data. Update shared registries, types, validation, persistence, runtime evaluation, editor controls, AI prompt context, tests, and maintained architecture/design documentation wherever the implementation requires them. Follow the repository's audit, database-preservation, accessibility, and development-server rules.
|
||||||
|
|
||||||
|
## Source mechanic
|
||||||
|
|
||||||
|
- Source type: ${context.sourceKind}
|
||||||
|
- Source name: ${context.sourceName || 'Untitled mechanic'}
|
||||||
|
- Supported variants: ${variants || 'None supplied'}
|
||||||
|
- Assigned-ability scope allowed: ${allowAssignedAbility ? 'Yes' : 'No'}
|
||||||
|
- OS Protocol upgrade scaling allowed: ${allowProtocolUpgradeScaling ? 'Yes' : 'No'}
|
||||||
|
- Assistant request ID: ${response.meta.requestId}
|
||||||
|
|
||||||
|
Exact analyzed wording:
|
||||||
|
|
||||||
|
${context.sourceDescription}
|
||||||
|
|
||||||
|
## Assistant diagnosis
|
||||||
|
|
||||||
|
${response.summary}${section('Clarification history', clarifications)}${section('Assumptions', assumptions)}${section('Warnings', warnings)}
|
||||||
|
|
||||||
|
## Required language changes
|
||||||
|
|
||||||
|
${gaps.join('\n\n')}
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- The mechanic can be authored without a workaround and passes deterministic validation.
|
||||||
|
- Persistence round-trips the new representation without losing meaning.
|
||||||
|
- The calculation runtime produces verified results for the new behavior.
|
||||||
|
- Existing mechanics continue to parse and calculate unchanged.
|
||||||
|
- Focused tests cover valid behavior, invalid input, boundary cases, and any new editor state.
|
||||||
|
- The AI assistant context advertises the capability only after the application supports it.`;
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
AiMechanicTranscriptEntry,
|
AiMechanicTranscriptEntry,
|
||||||
AiMechanicValueColumn
|
AiMechanicValueColumn
|
||||||
} from '$lib/ai/mechanic-authoring';
|
} from '$lib/ai/mechanic-authoring';
|
||||||
|
import {
|
||||||
|
buildMechanicDeveloperPrompt,
|
||||||
|
type AiMechanicDeveloperHandoff
|
||||||
|
} from '$lib/ai/mechanic-developer-handoff';
|
||||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||||
import { onDestroy, tick, untrack } from 'svelte';
|
import { onDestroy, tick, untrack } from 'svelte';
|
||||||
|
|
||||||
@@ -46,6 +50,11 @@
|
|||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
let activityMessage = $state('');
|
let activityMessage = $state('');
|
||||||
let confirmApply = $state(false);
|
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 resultRegion = $state<HTMLElement>();
|
||||||
let requestController: AbortController | undefined;
|
let requestController: AbortController | undefined;
|
||||||
let trackedContextKey = $state('');
|
let trackedContextKey = $state('');
|
||||||
@@ -129,6 +138,11 @@
|
|||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
activityMessage = '';
|
activityMessage = '';
|
||||||
confirmApply = false;
|
confirmApply = false;
|
||||||
|
gapHandoff = null;
|
||||||
|
gapReportPending = false;
|
||||||
|
gapReportSubmitted = false;
|
||||||
|
handoffMessage = '';
|
||||||
|
handoffMessageKind = 'success';
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAnswer(questionId: string, answer: ClarificationAnswer | undefined) {
|
function setAnswer(questionId: string, answer: ClarificationAnswer | undefined) {
|
||||||
@@ -154,20 +168,24 @@
|
|||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
activityMessage = '';
|
activityMessage = '';
|
||||||
confirmApply = false;
|
confirmApply = false;
|
||||||
|
gapHandoff = null;
|
||||||
|
gapReportSubmitted = false;
|
||||||
|
handoffMessage = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const request = await fetch('/api/isac/mechanic-assistant', {
|
const authoringRequest = {
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
signal: requestController.signal,
|
|
||||||
body: JSON.stringify({
|
|
||||||
context: { ...context, sourceDescription: sourceText.trim() },
|
context: { ...context, sourceDescription: sourceText.trim() },
|
||||||
currentDraft,
|
currentDraft,
|
||||||
valueColumns,
|
valueColumns,
|
||||||
allowAssignedAbility,
|
allowAssignedAbility,
|
||||||
allowProtocolUpgradeScaling,
|
allowProtocolUpgradeScaling,
|
||||||
transcript: nextTranscript
|
transcript: nextTranscript
|
||||||
})
|
};
|
||||||
|
const request = await fetch('/api/isac/mechanic-assistant', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
signal: requestController.signal,
|
||||||
|
body: JSON.stringify(authoringRequest)
|
||||||
});
|
});
|
||||||
const contentType = request.headers.get('content-type') ?? '';
|
const contentType = request.headers.get('content-type') ?? '';
|
||||||
const payload = contentType.includes('application/json')
|
const payload = contentType.includes('application/json')
|
||||||
@@ -189,6 +207,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
response = payload as AiMechanicAuthoringResponse;
|
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 = {};
|
answers = {};
|
||||||
activityMessage = response.status === 'clarification_required'
|
activityMessage = response.status === 'clarification_required'
|
||||||
? 'Clarification required before a reliable draft can be created.'
|
? 'Clarification required before a reliable draft can be created.'
|
||||||
@@ -226,6 +254,9 @@
|
|||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
activityMessage = '';
|
activityMessage = '';
|
||||||
confirmApply = false;
|
confirmApply = false;
|
||||||
|
gapHandoff = null;
|
||||||
|
gapReportSubmitted = false;
|
||||||
|
handoffMessage = '';
|
||||||
void requestDraft([]);
|
void requestDraft([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +266,53 @@
|
|||||||
confirmApply = false;
|
confirmApply = false;
|
||||||
activityMessage = 'AI draft applied to the manual editor. Review it before saving this item.';
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="authoring-assistant" class:locked={!canGenerate} aria-labelledby={`${contextKey}-assistant-title`}>
|
<section class="authoring-assistant" class:locked={!canGenerate} aria-labelledby={`${contextKey}-assistant-title`}>
|
||||||
@@ -414,11 +492,36 @@
|
|||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="result-actions">
|
<div class="result-actions handoff-actions">
|
||||||
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
|
<button type="button" class="secondary" onclick={() => resetAssistant(sourceText)}>
|
||||||
REVISE SOURCE WORDING
|
REVISE SOURCE WORDING
|
||||||
</button>
|
</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>
|
||||||
|
</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'}
|
{:else if response?.status === 'draft_ready'}
|
||||||
<div class="result-heading ready">
|
<div class="result-heading ready">
|
||||||
<div>
|
<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}
|
.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}
|
.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)}
|
.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 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,.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}
|
.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}
|
.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}
|
.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)}
|
.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){
|
@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}
|
.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}
|
.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}
|
.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}
|
.apply-confirm>div:last-child{display:grid;grid-template-columns:1fr 1fr}
|
||||||
.replacement-summary{grid-template-columns:1fr}
|
.replacement-summary{grid-template-columns:1fr}
|
||||||
.gap-card dl,.review-notes{grid-template-columns:1fr}
|
.gap-card dl,.review-notes{grid-template-columns:1fr}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let recipeKey = $state<TriggerActionKey>('same_target_hits_within_window');
|
let recipeKey = $state<TriggerActionKey | ''>('');
|
||||||
let recipeCount = $state(9);
|
let recipeCount = $state(9);
|
||||||
let recipeWindow = $state(3);
|
let recipeWindow = $state(3);
|
||||||
let recipeDistance = $state(10);
|
let recipeDistance = $state(10);
|
||||||
@@ -118,13 +118,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyRecipe(replace = false) {
|
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;
|
if (result.status !== 'supported') return;
|
||||||
const clauses = result.seed.clauses.map((seed, index): ConditionalMechanicClauseDraft => ({
|
const clauses = result.seed.clauses.map((seed, index): ConditionalMechanicClauseDraft => ({
|
||||||
key: `${slug(recipeKey)}-${rule.clauses.length + index + 1}`,
|
key: `${slug(selectedRecipeKey)}-${rule.clauses.length + index + 1}`,
|
||||||
label: seed.label ?? TRIGGER_ACTION_REGISTRY[recipeKey].label,
|
label: seed.label ?? TRIGGER_ACTION_REGISTRY[selectedRecipeKey].label,
|
||||||
event: seed.when,
|
event: seed.when,
|
||||||
recipeKey,
|
recipeKey: selectedRecipeKey,
|
||||||
if: seed.if ?? { kind: 'true' },
|
if: seed.if ?? { kind: 'true' },
|
||||||
reducer: seed.reducer,
|
reducer: seed.reducer,
|
||||||
cooldownSeconds: seed.cooldownSeconds,
|
cooldownSeconds: seed.cooldownSeconds,
|
||||||
@@ -519,6 +521,7 @@
|
|||||||
mode="single"
|
mode="single"
|
||||||
compact
|
compact
|
||||||
subjectLabel="RECIPE"
|
subjectLabel="RECIPE"
|
||||||
|
emptySelectionLabel="NO RECIPE SELECTED"
|
||||||
bind:value={recipeKey}
|
bind:value={recipeKey}
|
||||||
/>
|
/>
|
||||||
{#if ['same_target_hits_within_window', 'distinct_enemies_within_window', 'consecutive_same_target_hits'].includes(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)}
|
{#if ['target_hit_within_distance', 'within_distance'].includes(recipeKey)}
|
||||||
<label class="field">METERS<input type="number" min="0" step="any" bind:value={recipeDistance}/></label>
|
<label class="field">METERS<input type="number" min="0" step="any" bind:value={recipeDistance}/></label>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="button" class="secondary" onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
|
<button type="button" class="secondary" disabled={!recipeKey} onclick={() => applyRecipe(false)}>ADD RECIPE BRANCH +</button>
|
||||||
{#if rule.clauses.length}
|
{#if rule.clauses.length && recipeKey}
|
||||||
<button type="button" class="danger-quiet" onclick={() => replaceConfirmation = true}>REPLACE PROGRAM</button>
|
<button type="button" class="danger-quiet" onclick={() => replaceConfirmation = true}>REPLACE PROGRAM</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
generated: true,
|
||||||
|
existing: false,
|
||||||
|
recipients: [{ id: 'admin-1' }],
|
||||||
|
notifications: [] as Array<Record<string, unknown>>,
|
||||||
|
audits: [] as Array<Record<string, unknown>>
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/database', () => ({
|
||||||
|
getDatabase: async () => ({
|
||||||
|
prepare: (sql: string) => ({
|
||||||
|
get: async () => sql.includes("action='mechanic_ai.generated'")
|
||||||
|
? (mocks.generated ? { id: 'generation-audit' } : undefined)
|
||||||
|
: sql.includes("action='mechanic_gap.submitted'")
|
||||||
|
? (mocks.existing ? { id: 'existing-report' } : undefined)
|
||||||
|
: undefined,
|
||||||
|
all: async () => sql.includes('SELECT DISTINCT users.id') ? mocks.recipients : []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
vi.mock('$lib/server/notifications', () => ({
|
||||||
|
createNotification: async (input: Record<string, unknown>) => {
|
||||||
|
mocks.notifications.push(input);
|
||||||
|
return `notification-${mocks.notifications.length}`;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
vi.mock('$lib/server/audit', () => ({
|
||||||
|
recordIsacAudit: async (input: Record<string, unknown>) => {
|
||||||
|
mocks.audits.push(input);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
MechanicGapReportError,
|
||||||
|
parseMechanicGapReport,
|
||||||
|
submitMechanicGapReport
|
||||||
|
} from './mechanic-gap-report';
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
context: {
|
||||||
|
sourceKind: 'talent',
|
||||||
|
sourceName: 'Chain Reaction',
|
||||||
|
sourceDescription: 'Every third hit transfers the mark.'
|
||||||
|
},
|
||||||
|
valueColumns: [
|
||||||
|
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
|
||||||
|
{ key: 'tierII', variantKey: 'II', label: 'TIER II' }
|
||||||
|
],
|
||||||
|
allowAssignedAbility: false,
|
||||||
|
allowProtocolUpgradeScaling: false,
|
||||||
|
transcript: [],
|
||||||
|
response: {
|
||||||
|
status: 'capability_gap',
|
||||||
|
summary: 'The runtime cannot select a transfer target.',
|
||||||
|
assumptions: [],
|
||||||
|
warnings: [],
|
||||||
|
gaps: [{
|
||||||
|
area: 'runtime',
|
||||||
|
requiredBehavior: 'Select the nearest eligible enemy.',
|
||||||
|
whyUnsupported: 'No target selector is available.',
|
||||||
|
developerChange: 'Add a deterministic nearby-target selector.',
|
||||||
|
workaround: null
|
||||||
|
}],
|
||||||
|
meta: {
|
||||||
|
requestId: 'req_gap_123',
|
||||||
|
model: 'test-model',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 50,
|
||||||
|
totalTokens: 150
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('mechanic gap report validation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.generated = true;
|
||||||
|
mocks.existing = false;
|
||||||
|
mocks.recipients = [{ id: 'admin-1' }];
|
||||||
|
mocks.notifications.length = 0;
|
||||||
|
mocks.audits.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a bounded capability-gap handoff that matches the source profile', () => {
|
||||||
|
expect(parseMechanicGapReport(report)).toMatchObject({
|
||||||
|
context: report.context,
|
||||||
|
response: {
|
||||||
|
status: 'capability_gap',
|
||||||
|
gaps: [{ area: 'runtime' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-gap assistant response', () => {
|
||||||
|
expect(() => parseMechanicGapReport({
|
||||||
|
...report,
|
||||||
|
response: { ...report.response, status: 'draft_ready' }
|
||||||
|
})).toThrow(MechanicGapReportError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects client-selected capabilities that do not match the source profile', () => {
|
||||||
|
expect(() => parseMechanicGapReport({
|
||||||
|
...report,
|
||||||
|
allowAssignedAbility: true
|
||||||
|
})).toThrow('Assigned-ability scope does not match the mechanic source.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unbounded source wording', () => {
|
||||||
|
expect(() => parseMechanicGapReport({
|
||||||
|
...report,
|
||||||
|
context: { ...report.context, sourceDescription: 'x'.repeat(12_001) }
|
||||||
|
})).toThrow('Source wording is too long.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies active administrators and records complete audit evidence after submission', async () => {
|
||||||
|
const result = await submitMechanicGapReport('author-1', parseMechanicGapReport(report));
|
||||||
|
|
||||||
|
expect(result).toEqual({ alreadySubmitted: false, recipientCount: 1 });
|
||||||
|
expect(mocks.notifications).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 'admin-1',
|
||||||
|
category: 'isac',
|
||||||
|
mandatory: true
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(mocks.audits).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
actorId: 'author-1',
|
||||||
|
category: 'ai_assistance',
|
||||||
|
action: 'mechanic_gap.submitted',
|
||||||
|
entityId: 'mechanic_gap:req_gap_123',
|
||||||
|
after: expect.objectContaining({
|
||||||
|
requestId: 'req_gap_123',
|
||||||
|
sourceKind: 'talent',
|
||||||
|
recipientCount: 1,
|
||||||
|
gaps: report.response.gaps
|
||||||
|
})
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses repeat notifications for the same author and assistant request', async () => {
|
||||||
|
mocks.existing = true;
|
||||||
|
|
||||||
|
expect(await submitMechanicGapReport('author-1', parseMechanicGapReport(report)))
|
||||||
|
.toEqual({ alreadySubmitted: true, recipientCount: 0 });
|
||||||
|
expect(mocks.notifications).toHaveLength(0);
|
||||||
|
expect(mocks.audits).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import {
|
||||||
|
AI_MECHANIC_SOURCE_PROFILES,
|
||||||
|
AI_MECHANIC_SOURCE_KINDS,
|
||||||
|
type AiMechanicCapabilityGap
|
||||||
|
} from '$lib/ai/mechanic-authoring';
|
||||||
|
import type { AiMechanicDeveloperHandoff as DeveloperHandoff } from '$lib/ai/mechanic-developer-handoff';
|
||||||
|
import { recordIsacAudit } from '$lib/server/audit';
|
||||||
|
import { getDatabase } from '$lib/server/database';
|
||||||
|
import { createNotification } from '$lib/server/notifications';
|
||||||
|
|
||||||
|
const GAP_AREAS = new Set<AiMechanicCapabilityGap['area']>([
|
||||||
|
'event',
|
||||||
|
'predicate',
|
||||||
|
'reducer',
|
||||||
|
'mutation',
|
||||||
|
'outcome',
|
||||||
|
'scope',
|
||||||
|
'value_reference',
|
||||||
|
'source_variant',
|
||||||
|
'runtime'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class MechanicGapReportError extends Error {
|
||||||
|
constructor(message: string, readonly status = 400) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'MechanicGapReportError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new MechanicGapReportError(`${label} must be an object.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, label: string, maxLength: number, allowEmpty = false) {
|
||||||
|
if (typeof value !== 'string') throw new MechanicGapReportError(`${label} must be text.`);
|
||||||
|
const result = value.trim();
|
||||||
|
if (!allowEmpty && !result) throw new MechanicGapReportError(`${label} is required.`);
|
||||||
|
if (result.length > maxLength) throw new MechanicGapReportError(`${label} is too long.`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringList(value: unknown, label: string, maxItems: number) {
|
||||||
|
if (!Array.isArray(value) || value.length > maxItems) {
|
||||||
|
throw new MechanicGapReportError(`${label} must contain at most ${maxItems} items.`);
|
||||||
|
}
|
||||||
|
return value.map((item, index) => text(item, `${label} item ${index + 1}`, 2_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMechanicGapReport(value: unknown): DeveloperHandoff {
|
||||||
|
const input = record(value, 'Mechanic gap report');
|
||||||
|
const contextInput = record(input.context, 'Context');
|
||||||
|
const sourceKind = text(contextInput.sourceKind, 'Source type', 80);
|
||||||
|
if (!AI_MECHANIC_SOURCE_KINDS.includes(sourceKind as (typeof AI_MECHANIC_SOURCE_KINDS)[number])) {
|
||||||
|
throw new MechanicGapReportError('Source type is not supported.');
|
||||||
|
}
|
||||||
|
const sourceProfile = AI_MECHANIC_SOURCE_PROFILES[
|
||||||
|
sourceKind as (typeof AI_MECHANIC_SOURCE_KINDS)[number]
|
||||||
|
];
|
||||||
|
const columnsInput = input.valueColumns;
|
||||||
|
if (!Array.isArray(columnsInput) || !columnsInput.length || columnsInput.length > 8) {
|
||||||
|
throw new MechanicGapReportError('Value columns must contain between one and eight items.');
|
||||||
|
}
|
||||||
|
const transcriptInput = input.transcript;
|
||||||
|
if (!Array.isArray(transcriptInput) || transcriptInput.length > 20) {
|
||||||
|
throw new MechanicGapReportError('Clarification history must contain at most 20 items.');
|
||||||
|
}
|
||||||
|
const responseInput = record(input.response, 'Capability gap response');
|
||||||
|
if (responseInput.status !== 'capability_gap') {
|
||||||
|
throw new MechanicGapReportError('Only capability-gap responses can be submitted.');
|
||||||
|
}
|
||||||
|
const gapsInput = responseInput.gaps;
|
||||||
|
if (!Array.isArray(gapsInput) || !gapsInput.length || gapsInput.length > 12) {
|
||||||
|
throw new MechanicGapReportError('Capability gaps must contain between one and 12 items.');
|
||||||
|
}
|
||||||
|
const metaInput = record(responseInput.meta, 'Response metadata');
|
||||||
|
|
||||||
|
const valueColumns = columnsInput.map((column, index) => {
|
||||||
|
const item = record(column, `Value column ${index + 1}`);
|
||||||
|
return {
|
||||||
|
key: text(item.key, `Value column ${index + 1} key`, 100),
|
||||||
|
variantKey: text(item.variantKey, `Value column ${index + 1} variant`, 100),
|
||||||
|
label: text(item.label, `Value column ${index + 1} label`, 100)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
valueColumns.length !== sourceProfile.variantKeys.length
|
||||||
|
|| valueColumns.some((column, index) => column.variantKey !== sourceProfile.variantKeys[index])
|
||||||
|
) {
|
||||||
|
throw new MechanicGapReportError('Value columns do not match the mechanic source.');
|
||||||
|
}
|
||||||
|
if (input.allowAssignedAbility !== sourceProfile.allowAssignedAbility) {
|
||||||
|
throw new MechanicGapReportError('Assigned-ability scope does not match the mechanic source.');
|
||||||
|
}
|
||||||
|
if (input.allowProtocolUpgradeScaling !== sourceProfile.allowProtocolUpgradeScaling) {
|
||||||
|
throw new MechanicGapReportError('Protocol upgrade scaling does not match the mechanic source.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: {
|
||||||
|
sourceKind: sourceKind as DeveloperHandoff['context']['sourceKind'],
|
||||||
|
sourceName: text(contextInput.sourceName, 'Source name', 200, true),
|
||||||
|
sourceDescription: text(contextInput.sourceDescription, 'Source wording', 12_000)
|
||||||
|
},
|
||||||
|
valueColumns,
|
||||||
|
allowAssignedAbility: sourceProfile.allowAssignedAbility,
|
||||||
|
allowProtocolUpgradeScaling: sourceProfile.allowProtocolUpgradeScaling,
|
||||||
|
transcript: transcriptInput.map((entry, index) => {
|
||||||
|
const item = record(entry, `Clarification ${index + 1}`);
|
||||||
|
const answer = item.answer;
|
||||||
|
if (!['string', 'number', 'boolean'].includes(typeof answer)) {
|
||||||
|
throw new MechanicGapReportError(`Clarification ${index + 1} answer is invalid.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
questionId: text(item.questionId, `Clarification ${index + 1} ID`, 120),
|
||||||
|
question: text(item.question, `Clarification ${index + 1} question`, 1_000),
|
||||||
|
answer: typeof answer === 'string'
|
||||||
|
? text(answer, `Clarification ${index + 1} answer`, 2_000)
|
||||||
|
: answer as number | boolean
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
status: 'capability_gap',
|
||||||
|
summary: text(responseInput.summary, 'Gap summary', 4_000),
|
||||||
|
assumptions: stringList(responseInput.assumptions, 'Assumptions', 20),
|
||||||
|
warnings: stringList(responseInput.warnings, 'Warnings', 20),
|
||||||
|
gaps: gapsInput.map((gap, index) => {
|
||||||
|
const item = record(gap, `Gap ${index + 1}`);
|
||||||
|
const area = text(item.area, `Gap ${index + 1} area`, 80);
|
||||||
|
if (!GAP_AREAS.has(area as AiMechanicCapabilityGap['area'])) {
|
||||||
|
throw new MechanicGapReportError(`Gap ${index + 1} area is not supported.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
area: area as AiMechanicCapabilityGap['area'],
|
||||||
|
requiredBehavior: text(item.requiredBehavior, `Gap ${index + 1} required behavior`, 4_000),
|
||||||
|
whyUnsupported: text(item.whyUnsupported, `Gap ${index + 1} unsupported reason`, 4_000),
|
||||||
|
developerChange: text(item.developerChange, `Gap ${index + 1} developer change`, 4_000),
|
||||||
|
workaround: item.workaround === null
|
||||||
|
? null
|
||||||
|
: text(item.workaround, `Gap ${index + 1} workaround`, 4_000)
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
meta: {
|
||||||
|
requestId: text(metaInput.requestId, 'Assistant request ID', 255),
|
||||||
|
model: text(metaInput.model, 'Assistant model', 120),
|
||||||
|
inputTokens: Math.max(0, Number(metaInput.inputTokens) || 0),
|
||||||
|
outputTokens: Math.max(0, Number(metaInput.outputTokens) || 0),
|
||||||
|
totalTokens: Math.max(0, Number(metaInput.totalTokens) || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationBody(report: DeveloperHandoff) {
|
||||||
|
const source = report.context.sourceName || 'Untitled mechanic';
|
||||||
|
const changes = report.response.gaps.map((gap, index) =>
|
||||||
|
`${index + 1}. ${gap.area}: ${gap.developerChange}`
|
||||||
|
).join('\n');
|
||||||
|
return `${source} (${report.context.sourceKind}) needs mechanic-language support.
|
||||||
|
|
||||||
|
${report.response.summary}
|
||||||
|
|
||||||
|
Developer changes:
|
||||||
|
${changes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitMechanicGapReport(actorId: string, report: DeveloperHandoff) {
|
||||||
|
const database = await getDatabase();
|
||||||
|
const requestId = report.response.meta.requestId;
|
||||||
|
const generationEntityId = `${report.context.sourceKind}:${requestId}`;
|
||||||
|
const reportEntityId = `mechanic_gap:${requestId}`;
|
||||||
|
const generated = await (await database.prepare(`SELECT id FROM audit_log
|
||||||
|
WHERE actor_id=? AND entity_type='ai_assistance' AND action='mechanic_ai.generated'
|
||||||
|
AND entity_id=? AND json_extract(after_json,'$.responseStatus')='capability_gap'
|
||||||
|
LIMIT 1`)).get(actorId, generationEntityId);
|
||||||
|
if (!generated) {
|
||||||
|
throw new MechanicGapReportError('This mechanic gap is not linked to your assistant session.', 409);
|
||||||
|
}
|
||||||
|
const existing = await (await database.prepare(`SELECT id FROM audit_log
|
||||||
|
WHERE actor_id=? AND entity_type='ai_assistance' AND action='mechanic_gap.submitted'
|
||||||
|
AND entity_id=? LIMIT 1`)).get(actorId, reportEntityId);
|
||||||
|
if (existing) return { alreadySubmitted: true, recipientCount: 0 };
|
||||||
|
|
||||||
|
const recipients = await (await database.prepare(`
|
||||||
|
SELECT DISTINCT users.id
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id
|
||||||
|
LEFT JOIN user_status status ON status.user_id=users.id
|
||||||
|
LEFT JOIN user_roles ON user_roles.user_id=users.id
|
||||||
|
LEFT JOIN role_permissions ON role_permissions.role_id=user_roles.role_id
|
||||||
|
LEFT JOIN permissions role_permission ON role_permission.id=role_permissions.permission_id
|
||||||
|
LEFT JOIN user_permissions ON user_permissions.user_id=users.id
|
||||||
|
LEFT JOIN permissions direct_permission ON direct_permission.id=user_permissions.permission_id
|
||||||
|
WHERE deleted.user_id IS NULL AND status.blocked_at IS NULL
|
||||||
|
AND (status.banned_until IS NULL OR status.banned_until<=?)
|
||||||
|
AND (users.role='admin' OR role_permission.name='system.admin'
|
||||||
|
OR direct_permission.name='system.admin')
|
||||||
|
`)).all(new Date().toISOString());
|
||||||
|
if (!recipients.length) {
|
||||||
|
throw new MechanicGapReportError('No active administrators are available to receive this report.', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditLink = `/isac/audit?category=ai_assistance&action=mechanic_gap.submitted&q=${encodeURIComponent(requestId)}`;
|
||||||
|
const notificationIds: string[] = [];
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const notificationId = await createNotification({
|
||||||
|
userId: String(recipient.id),
|
||||||
|
category: 'isac',
|
||||||
|
source: 'ISAC AUTHORING ASSIST',
|
||||||
|
title: `Mechanic language gap: ${report.context.sourceName || report.context.sourceKind}`,
|
||||||
|
body: notificationBody(report),
|
||||||
|
link: auditLink,
|
||||||
|
actions: [{ label: 'OPEN AUDIT EVIDENCE', href: auditLink }],
|
||||||
|
mandatory: true
|
||||||
|
});
|
||||||
|
if (notificationId) notificationIds.push(notificationId);
|
||||||
|
}
|
||||||
|
await recordIsacAudit({
|
||||||
|
actorId,
|
||||||
|
category: 'ai_assistance',
|
||||||
|
action: 'mechanic_gap.submitted',
|
||||||
|
entityId: reportEntityId,
|
||||||
|
after: {
|
||||||
|
requestId,
|
||||||
|
sourceKind: report.context.sourceKind,
|
||||||
|
sourceName: report.context.sourceName,
|
||||||
|
sourceDescription: report.context.sourceDescription,
|
||||||
|
summary: report.response.summary,
|
||||||
|
assumptions: report.response.assumptions,
|
||||||
|
warnings: report.response.warnings,
|
||||||
|
gaps: report.response.gaps,
|
||||||
|
recipientCount: notificationIds.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { alreadySubmitted: false, recipientCount: notificationIds.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { getSessionUser } from '$lib/server/auth/session';
|
||||||
|
import { requirePermission } from '$lib/server/auth/authorization';
|
||||||
|
import {
|
||||||
|
MechanicGapReportError,
|
||||||
|
parseMechanicGapReport,
|
||||||
|
submitMechanicGapReport
|
||||||
|
} from '$lib/server/ai/mechanic-gap-report';
|
||||||
|
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 gap reports 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 gap report is too large.' }, { status: 413 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = await request.text();
|
||||||
|
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
|
||||||
|
return json({ message: 'The mechanic gap report is too large.' }, { status: 413 });
|
||||||
|
}
|
||||||
|
const report = parseMechanicGapReport(JSON.parse(body));
|
||||||
|
return json(await submitMechanicGapReport(operator.id, report));
|
||||||
|
} catch (caught) {
|
||||||
|
if (caught instanceof SyntaxError) {
|
||||||
|
return json({ message: 'The mechanic gap report must be valid JSON.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (caught instanceof MechanicGapReportError) {
|
||||||
|
return json({ message: caught.message }, { status: caught.status });
|
||||||
|
}
|
||||||
|
console.error('Mechanic gap report submission failed.', caught);
|
||||||
|
return json(
|
||||||
|
{ message: 'The mechanic gap could not be submitted to the admin team.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user