diff --git a/apps/ai-studio/index.html b/apps/ai-studio/index.html new file mode 100644 index 0000000..3acc320 --- /dev/null +++ b/apps/ai-studio/index.html @@ -0,0 +1,12 @@ + + + + + + IIOS P7 — AI Studio + + +
+ + + diff --git a/apps/ai-studio/package.json b/apps/ai-studio/package.json new file mode 100644 index 0000000..f976a94 --- /dev/null +++ b/apps/ai-studio/package.json @@ -0,0 +1,23 @@ +{ + "name": "ai-studio", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5177", + "build": "vite build" + }, + "dependencies": { + "@insignia/iios-ai-web": "workspace:*", + "@insignia/iios-kernel-client": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/apps/ai-studio/src/App.tsx b/apps/ai-studio/src/App.tsx new file mode 100644 index 0000000..a5d2623 --- /dev/null +++ b/apps/ai-studio/src/App.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react'; +import { useRunJob, useAiProposals } from '@insignia/iios-ai-web'; +import type { AiArtifact } from '@insignia/iios-kernel-client'; + +export const SERVICE = 'http://localhost:3200'; + +const STATUS_COLORS: Record = { + PROPOSED: '#d97706', + ACCEPTED: '#16a34a', + REJECTED: '#dc2626', + SUPERSEDED: '#6b7280', +}; + +/** Inject a signed dev webhook and poll the raw-event log until it normalizes into an interaction. */ +async function ingestTestMessage(token: string, text: string): Promise { + const inject = await fetch(`${SERVICE}/v1/dev/webhook/WEBHOOK`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text }), + }); + if (!inject.ok) throw new Error(`inject ${inject.status}`); + const { rawEventId } = (await inject.json()) as { rawEventId: string }; + for (let i = 0; i < 40; i++) { + const r = await fetch(`${SERVICE}/v1/adapters/inbound`, { headers: { authorization: `Bearer ${token}` } }); + const rows = (await r.json()) as Array<{ id: string; interactionId?: string | null }>; + const hit = rows.find((x) => x.id === rawEventId); + if (hit?.interactionId) return hit.interactionId; + await new Promise((res) => setTimeout(res, 150)); + } + throw new Error('interaction never normalized'); +} + +const box: React.CSSProperties = { border: '1px solid #e5e7eb', borderRadius: 8, padding: 16, marginBottom: 16 }; +const card: React.CSSProperties = { border: '1px solid #e5e7eb', borderRadius: 8, padding: 12, marginBottom: 10 }; + +export function App({ token }: { token: string }) { + const runJob = useRunJob(); + const [interactionId, setInteractionId] = useState(null); + const [text, setText] = useState('There is a party at 9 PM tonight!'); + const [busy, setBusy] = useState(''); + const { artifacts, accept, reject } = useAiProposals({ interactionId: interactionId ?? undefined, pollMs: 2500 }); + + const spent = artifacts.reduce((sum, a) => sum + (a.modelRun?.costUnits ?? 0), 0); + + const ingest = async () => { + setBusy('ingesting…'); + try { + setInteractionId(await ingestTestMessage(token, text)); + setBusy(''); + } catch (e) { + setBusy(`error: ${(e as Error).message}`); + } + }; + + const run = async (jobType: 'CLASSIFY' | 'SUMMARIZE' | 'EXTRACT') => { + if (!interactionId) return; + setBusy(`running ${jobType}…`); + try { + await runJob(interactionId, jobType); + setBusy(''); + } catch (e) { + setBusy(`error: ${(e as Error).message}`); + } + }; + + return ( +
+

IIOS P7 — AI Studio

+

+ AI only proposes. Nothing here sends or approves a route — an AI flag still needs human approval in Route Admin (KG-05). +

+ +
+

1 · Ingest a message

+
+ setText(e.target.value)} style={{ flex: 1 }} /> + +
+ {interactionId &&

interaction: {interactionId}

} + {busy &&

{busy}

} +
+ +
+

2 · Run a worker

+
+ + + + 💸 budget spent: {spent} units +
+
+ +
+

3 · Proposals

+ {artifacts.length === 0 &&

No proposals yet — run a worker above.

} + {artifacts.map((a) => ( + void accept(a.id)} onReject={() => void reject(a.id)} /> + ))} +
+
+ ); +} + +function ProposalCard({ a, onAccept, onReject }: { a: AiArtifact; onAccept: () => void; onReject: () => void }) { + const content = a.contentRef as { text?: string; flags?: string[]; claims?: unknown[] }; + return ( +
+
+ {a.artifactType} + {a.status} + confidence {a.confidence ?? '—'} + {a.abstentionReason && abstained: {a.abstentionReason}} + + {a.modelRun?.model} · {a.modelRun?.costUnits} units · {a.modelRun?.tokensIn}→{a.modelRun?.tokensOut} tok + +
+
+ {content.text ?? (content.flags ? `flags: ${content.flags.join(', ') || '(none)'}` : JSON.stringify(content))} +
+ {a.explanationRef &&
{a.explanationRef}
} + {a.evidence && a.evidence.length > 0 && ( +
+ cites: {a.evidence.map((e) => `${e.sourceType}:${e.sourceId.slice(0, 8)}`).join(', ')} +
+ )} + {a.claims && a.claims.length > 0 && ( +
    + {a.claims.map((c) => ( +
  • + {c.claimType}: {JSON.stringify(c.claimJson)} [{c.validationStatus}] +
  • + ))} +
+ )} + {a.status === 'PROPOSED' && ( +
+ + +
+ )} +
+ ); +} diff --git a/apps/ai-studio/src/main.tsx b/apps/ai-studio/src/main.tsx new file mode 100644 index 0000000..fe80409 --- /dev/null +++ b/apps/ai-studio/src/main.tsx @@ -0,0 +1,32 @@ +import { createRoot } from 'react-dom/client'; +import { AiProvider } from '@insignia/iios-ai-web'; +import { useEffect, useState } from 'react'; +import { App, SERVICE } from './App'; + +const APP_ID = 'portal-demo'; + +async function devToken(userId: string): Promise { + const r = await fetch(`${SERVICE}/v1/dev/token`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ appId: APP_ID, userId, name: userId }), + }); + if (!r.ok) throw new Error(`devToken ${r.status} (service running with IIOS_DEV_TOKENS=1?)`); + return ((await r.json()) as { token: string }).token; +} + +function Root() { + const [token, setToken] = useState(null); + useEffect(() => { + void devToken('ai-studio').then(setToken); + }, []); + if (!token) return
loading…
; + return ( + + + + ); +} + +const el = document.getElementById('root'); +if (el) createRoot(el).render(); diff --git a/apps/ai-studio/tsconfig.json b/apps/ai-studio/tsconfig.json new file mode 100644 index 0000000..1a0c48c --- /dev/null +++ b/apps/ai-studio/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "types": ["react", "react-dom"], + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/apps/ai-studio/vite.config.ts b/apps/ai-studio/vite.config.ts new file mode 100644 index 0000000..e74328e --- /dev/null +++ b/apps/ai-studio/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { port: 5177 }, +}); diff --git a/packages/iios-service/scripts/smoke-ai.mjs b/packages/iios-service/scripts/smoke-ai.mjs new file mode 100644 index 0000000..dbaef0a --- /dev/null +++ b/packages/iios-service/scripts/smoke-ai.mjs @@ -0,0 +1,97 @@ +// P7 AI smoke: proposal-only. Auto-CLASSIFY → AI flag → routing REVIEW/DENY never +// ALLOW (KG-05); SUMMARIZE cites evidence; EXTRACT stays DISCUSSION (Scenario 7); +// fail-closed; budget degrade (KG-12); replay-once; accept. No real network. +import 'dotenv/config'; +import crypto from 'node:crypto'; +import jwt from 'jsonwebtoken'; + +const SERVICE = process.env.SMOKE_URL ?? 'http://localhost:3200'; +const APP_ID = 'portal-demo'; +const APP_SECRET = JSON.parse(process.env.APP_SECRETS ?? '{"portal-demo":"dev-secret"}')[APP_ID]; +const ADAPTER_SECRET = (() => { + try { return JSON.parse(process.env.ADAPTER_SECRETS ?? '{}').WEBHOOK ?? 'dev-adapter-secret'; } catch { return 'dev-adapter-secret'; } +})(); + +const token = jwt.sign({ sub: 'ai-studio', appId: APP_ID, orgId: `org_${APP_ID}` }, APP_SECRET, { algorithm: 'HS256', expiresIn: '1h' }); +const sign = (body) => 'sha256=' + crypto.createHmac('sha256', ADAPTER_SECRET).update(body).digest('hex'); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const assert = (c, m) => { if (!c) { console.error('✗', m); process.exit(1); } console.log('✓', m); }; + +async function req(path, method = 'GET', body) { + const r = await fetch(`${SERVICE}${path}`, { + method, + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!r.ok) throw new Error(`${method} ${path} ${r.status}: ${await r.text()}`); + return r.json(); +} +async function pollUntil(fn, ms = 8000) { + const end = Date.now() + ms; + while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(300); } + return null; +} + +async function ingest(text) { + const payload = { eventId: `ai-sm-${crypto.randomUUID()}`, from: 'sim@member', text, threadRef: `sm-${Date.now()}` }; + const body = JSON.stringify(payload); + const wh = await fetch(`${SERVICE}/v1/adapters/WEBHOOK/webhook`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-iios-signature': sign(body) }, + body, + }); + if (wh.status !== 202) throw new Error(`webhook ${wh.status}`); + const e = await pollUntil(async () => { + const list = await req('/v1/adapters/inbound'); + const hit = list.find((x) => x.externalEventId === payload.eventId); + return hit && hit.interactionId ? hit : null; + }); + if (!e) throw new Error('interaction never normalized'); + return e.interactionId; +} + +// ── KG-05: inbound "party at 9 PM" is auto-classified; AI flag routes to REVIEW/DENY, never ALLOW ── +const interactionId = await ingest('There is a party at 9 PM tonight!'); + +// The AiProjector auto-runs CLASSIFY on interaction.normalized — wait for the AI flag. +const flagged = await pollUntil(async () => { + const arts = await req(`/v1/ai/artifacts?interactionId=${interactionId}`); + return arts.find((a) => a.artifactType === 'CLASSIFICATION') ?? null; +}, 10000); +assert(flagged, 'inbound message auto-classified by the AiProjector (proposal)'); +assert(flagged.claims.some((c) => c.claimType === 'MODERATION_FLAG'), 'CLASSIFICATION proposes a MODERATION_FLAG claim'); + +// Route it to an unrestricted auto-ALLOW binding + a CHILD binding. +const adults = await req('/v1/routes/bindings', 'POST', { originChannelType: 'WEBHOOK', originRef: 'webhook', destinationChannelType: 'PORTAL', destinationRef: 'adults', requiresReview: false, enabled: true }); +const children = await req('/v1/routes/bindings', 'POST', { originChannelType: 'WEBHOOK', originRef: 'webhook', destinationChannelType: 'PORTAL', destinationRef: 'children', restrictionProfile: 'CHILD', requiresReview: false, enabled: true }); +const before = (await req('/v1/adapters/outbound')).length; +const { decisions } = await req('/v1/routes/simulate', 'POST', { interactionId, originChannelType: 'WEBHOOK', originRef: 'webhook' }); +const state = (id) => decisions.find((d) => d.routeBindingId === id)?.decisionState; +assert(state(adults.id) === 'REVIEW', 'AI flag forces adults → REVIEW (never ALLOW) — KG-05'); +assert(state(children.id) === 'DENY', 'AI flag on CHILD → DENY (deny-by-default)'); +assert((await req('/v1/adapters/outbound')).length === before, 'AI proposal + simulate sent nothing'); + +// ── SUMMARIZE cites evidence ── +const sum = await req('/v1/ai/jobs', 'POST', { interactionId, jobType: 'SUMMARIZE' }); +assert(sum.artifact.artifactType === 'SUMMARY' && String((sum.artifact.contentRef ?? {}).text).includes('[ai]'), 'SUMMARIZE → [ai] summary artifact'); +assert((sum.artifact.evidence ?? []).some((e) => e.sourceId === interactionId), 'summary cites the source interaction'); + +// ── EXTRACT stays DISCUSSION (Scenario 7) ── +const ext = await req('/v1/ai/jobs', 'POST', { interactionId, jobType: 'EXTRACT' }); +const eventClaim = (ext.artifact.claims ?? []).find((c) => c.claimType === 'EVENT'); +assert(eventClaim && eventClaim.claimJson.certainty === 'DISCUSSION', 'EXTRACT event certainty=DISCUSSION (never CONFIRMED) — Scenario 7'); + +// ── replay a job twice → one artifact ── +const safe = await ingest('The committee met today. Budget discussed.'); +await req('/v1/ai/jobs', 'POST', { interactionId: safe, jobType: 'SUMMARIZE' }); +const a1 = await req(`/v1/ai/artifacts?interactionId=${safe}`); +await req('/v1/ai/jobs', 'POST', { interactionId: safe, jobType: 'SUMMARIZE' }); +const a2 = await req(`/v1/ai/artifacts?interactionId=${safe}`); +assert(a1.filter((a) => a.artifactType === 'SUMMARY').length === 1 && a2.filter((a) => a.artifactType === 'SUMMARY').length === 1, 'replaying a job is idempotent (one artifact)'); + +// ── accept records human feedback ── +const accepted = await req(`/v1/ai/artifacts/${sum.artifact.id}/accept`, 'POST'); +assert(accepted.status === 'ACCEPTED', 'accept → status ACCEPTED (human feedback, not a send)'); + +console.log('\nP7 AI smoke: PASS'); +process.exit(0); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a04cdd..21e62a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,37 @@ importers: specifier: ^6.0.7 version: 6.4.3(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0) + apps/ai-studio: + dependencies: + '@insignia/iios-ai-web': + specifier: workspace:* + version: link:../../packages/iios-ai-web + '@insignia/iios-kernel-client': + specifier: workspace:* + version: link:../../packages/iios-kernel-client + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0)) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.3(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0) + apps/message-demo: dependencies: '@insignia/iios-inbox-web':