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
@@ -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);
});
});
+238
View File
@@ -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 };
}