From 881b37afd0ad79f841c357e9bb3579f94b130af8 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 1 Jul 2026 16:42:07 +0530 Subject: [PATCH] feat(p7): /v1/ai REST + kernel-client AI methods + @insignia/iios-ai-web SDK Task 7.5: AiController exposes POST /v1/ai/jobs, GET /v1/ai/artifacts(+/:id), POST accept/reject (session-auth). kernel-client RestClient gains runAiJob/ listArtifacts/getArtifact/acceptArtifact/rejectArtifact + AiArtifact/AiClaim/ AiEvidenceLink/AiModelRun types. New @insignia/iios-ai-web (AiProvider, useRunJob, useAiProposals, useArtifact). Boundary allowlist: ai-web -> contracts + kernel-client (fails on ai-web->service). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/iios-ai-web/package.json | 27 ++++++ packages/iios-ai-web/src/index.ts | 2 + packages/iios-ai-web/src/react.tsx | 83 +++++++++++++++++++ packages/iios-ai-web/tsconfig.json | 14 ++++ packages/iios-ai-web/tsup.config.ts | 9 ++ packages/iios-kernel-client/src/rest.ts | 27 +++++- packages/iios-kernel-client/src/types.ts | 48 +++++++++++ packages/iios-service/src/ai/ai.controller.ts | 55 ++++++++++++ packages/iios-service/src/ai/ai.dto.ts | 8 ++ packages/iios-service/src/ai/ai.module.ts | 2 + pnpm-lock.yaml | 19 +++++ scripts/check-import-boundary.mjs | 1 + 12 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 packages/iios-ai-web/package.json create mode 100644 packages/iios-ai-web/src/index.ts create mode 100644 packages/iios-ai-web/src/react.tsx create mode 100644 packages/iios-ai-web/tsconfig.json create mode 100644 packages/iios-ai-web/tsup.config.ts create mode 100644 packages/iios-service/src/ai/ai.controller.ts create mode 100644 packages/iios-service/src/ai/ai.dto.ts diff --git a/packages/iios-ai-web/package.json b/packages/iios-ai-web/package.json new file mode 100644 index 0000000..5f03714 --- /dev/null +++ b/packages/iios-ai-web/package.json @@ -0,0 +1,27 @@ +{ + "name": "@insignia/iios-ai-web", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "files": ["dist"], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@insignia/iios-kernel-client": "workspace:*" + }, + "peerDependencies": { + "react": ">=18" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "react": "^19.0.0", + "tsup": "^8.3.5", + "typescript": "^5.7.3" + } +} diff --git a/packages/iios-ai-web/src/index.ts b/packages/iios-ai-web/src/index.ts new file mode 100644 index 0000000..b7f6a46 --- /dev/null +++ b/packages/iios-ai-web/src/index.ts @@ -0,0 +1,2 @@ +export { AiProvider, useRunJob, useAiProposals, useArtifact } from './react'; +export type { AiArtifact, AiClaim, AiEvidenceLink, AiModelRun, AiJobResult } from '@insignia/iios-kernel-client'; diff --git a/packages/iios-ai-web/src/react.tsx b/packages/iios-ai-web/src/react.tsx new file mode 100644 index 0000000..13f9e8a --- /dev/null +++ b/packages/iios-ai-web/src/react.tsx @@ -0,0 +1,83 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { RestClient, type AiArtifact, type AiJobResult } from '@insignia/iios-kernel-client'; + +const AiContext = createContext(null); + +export function AiProvider({ + serviceUrl, + token, + children, +}: { + serviceUrl: string; + token: string; + children: React.ReactNode; +}): React.ReactElement { + const client = useMemo(() => new RestClient({ serviceUrl, token }), [serviceUrl, token]); + return {children}; +} + +function useClient(): RestClient { + const c = useContext(AiContext); + if (!c) throw new Error('AI hooks must be used within '); + return c; +} + +/** Run an AI worker (proposal only). Returns the job + proposed artifact (or null on abstain/fail). */ +export function useRunJob(): (interactionId: string, jobType: 'CLASSIFY' | 'SUMMARIZE' | 'EXTRACT') => Promise { + const client = useClient(); + return (interactionId, jobType) => client.runAiJob({ interactionId, jobType }); +} + +/** List AI proposals + human accept/reject feedback. */ +export function useAiProposals(opts?: { interactionId?: string; status?: string; pollMs?: number }): { + artifacts: AiArtifact[]; + refresh: () => Promise; + accept: (id: string) => Promise; + reject: (id: string) => Promise; +} { + const client = useClient(); + const [artifacts, setArtifacts] = useState([]); + const refresh = useCallback(async () => { + try { + setArtifacts(await client.listArtifacts({ interactionId: opts?.interactionId, status: opts?.status })); + } catch { + /* transient */ + } + }, [client, opts?.interactionId, opts?.status]); + useEffect(() => { + void refresh(); + if (!opts?.pollMs) return; + const t = setInterval(() => void refresh(), opts.pollMs); + return () => clearInterval(t); + }, [refresh, opts?.pollMs]); + const accept = async (id: string) => { + await client.acceptArtifact(id); + await refresh(); + }; + const reject = async (id: string) => { + await client.rejectArtifact(id); + await refresh(); + }; + return { artifacts, refresh, accept, reject }; +} + +/** Full artifact detail (claims + evidence + model run) for explainability. */ +export function useArtifact(id: string | null): { artifact: AiArtifact | null; refresh: () => Promise } { + const client = useClient(); + const [artifact, setArtifact] = useState(null); + const refresh = useCallback(async () => { + if (!id) { + setArtifact(null); + return; + } + try { + setArtifact(await client.getArtifact(id)); + } catch { + /* transient */ + } + }, [client, id]); + useEffect(() => { + void refresh(); + }, [refresh]); + return { artifact, refresh }; +} diff --git a/packages/iios-ai-web/tsconfig.json b/packages/iios-ai-web/tsconfig.json new file mode 100644 index 0000000..4a4d5c4 --- /dev/null +++ b/packages/iios-ai-web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "types": ["react"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/iios-ai-web/tsup.config.ts b/packages/iios-ai-web/tsup.config.ts new file mode 100644 index 0000000..1cf9cbf --- /dev/null +++ b/packages/iios-ai-web/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, + external: ['react', 'react-dom', '@insignia/iios-kernel-client'], +}); diff --git a/packages/iios-kernel-client/src/rest.ts b/packages/iios-kernel-client/src/rest.ts index 3fb1339..b61698a 100644 --- a/packages/iios-kernel-client/src/rest.ts +++ b/packages/iios-kernel-client/src/rest.ts @@ -1,5 +1,5 @@ import type { IngestInteractionRequest } from '@insignia/iios-contracts'; -import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision } from './types'; +import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult } from './types'; export interface RestConfig { serviceUrl: string; @@ -124,6 +124,31 @@ export class RestClient { return this.post(`/v1/routes/decisions/${id}/deny`, {}); } + // ─── AI proposal layer (P7) ─────────────────────────────────── + async runAiJob(input: { interactionId: string; jobType: 'CLASSIFY' | 'SUMMARIZE' | 'EXTRACT' }): Promise { + return this.post('/v1/ai/jobs', input); + } + async listArtifacts(opts?: { interactionId?: string; status?: string }): Promise { + const q = new URLSearchParams(); + if (opts?.interactionId) q.set('interactionId', opts.interactionId); + if (opts?.status) q.set('status', opts.status); + const qs = q.toString(); + const r = await fetch(this.url(`/v1/ai/artifacts${qs ? `?${qs}` : ''}`), { headers: this.headers() }); + if (!r.ok) throw new Error(`listArtifacts ${r.status}`); + return (await r.json()) as AiArtifact[]; + } + async getArtifact(id: string): Promise { + const r = await fetch(this.url(`/v1/ai/artifacts/${id}`), { headers: this.headers() }); + if (!r.ok) throw new Error(`getArtifact ${r.status}`); + return (await r.json()) as AiArtifact; + } + async acceptArtifact(id: string): Promise { + return this.post(`/v1/ai/artifacts/${id}/accept`, {}); + } + async rejectArtifact(id: string): Promise { + return this.post(`/v1/ai/artifacts/${id}/reject`, {}); + } + private async post(path: string, body: unknown): Promise { const r = await fetch(this.url(path), { method: 'POST', headers: this.headers(), body: JSON.stringify(body) }); if (!r.ok) throw new Error(`POST ${path} ${r.status}`); diff --git a/packages/iios-kernel-client/src/types.ts b/packages/iios-kernel-client/src/types.ts index dd93be0..052cb5d 100644 --- a/packages/iios-kernel-client/src/types.ts +++ b/packages/iios-kernel-client/src/types.ts @@ -102,6 +102,54 @@ export interface RouteDecision { routeBinding?: RouteBinding; } +export interface AiClaim { + id: string; + claimType: string; + claimJson: Record | unknown; + confidence?: number | null; + validationStatus: string; +} + +export interface AiEvidenceLink { + id: string; + sourceType: string; + sourceId: string; + spanRef?: string | null; + relevanceScore?: number | null; +} + +export interface AiModelRun { + provider: string; + model: string; + modelVersion: string; + promptVersion: string; + tokensIn: number; + tokensOut: number; + costUnits: number; + latencyMs: number; +} + +export interface AiArtifact { + id: string; + jobId: string; + artifactType: string; + status: string; + confidence?: number | null; + contentRef: unknown; + explanationRef?: string | null; + abstentionReason?: string | null; + createdAt: string; + claims?: AiClaim[]; + evidence?: AiEvidenceLink[]; + modelRun?: AiModelRun; + job?: { id: string; jobType: string; interactionId?: string | null; status: string }; +} + +export interface AiJobResult { + job: { id: string; jobType: string; status: string; abstentionReason?: string | null }; + artifact: AiArtifact | null; +} + /** Minimal socket surface so the facade can be unit-tested with a fake. */ export interface SocketLike { on(event: string, handler: (...args: unknown[]) => void): unknown; diff --git a/packages/iios-service/src/ai/ai.controller.ts b/packages/iios-service/src/ai/ai.controller.ts new file mode 100644 index 0000000..6df0205 --- /dev/null +++ b/packages/iios-service/src/ai/ai.controller.ts @@ -0,0 +1,55 @@ +import { BadRequestException, Body, Controller, Get, Headers, Param, Post, Query } from '@nestjs/common'; +import { IiosAiJobType } from '@prisma/client'; +import { AiJobService } from './ai.service'; +import { SessionVerifier } from '../platform/session.verifier'; +import type { MessagePrincipal } from '../identity/actor.resolver'; +import { RunJobDto } from './ai.dto'; + +@Controller('v1/ai') +export class AiController { + constructor( + private readonly ai: AiJobService, + private readonly session: SessionVerifier, + ) {} + + @Post('jobs') + async runJob(@Body() body: RunJobDto, @Headers('authorization') auth?: string) { + return this.ai.runJob({ + interactionId: body.interactionId, + jobType: body.jobType as IiosAiJobType, + principal: this.principal(auth), + }); + } + + @Get('artifacts') + async listArtifacts( + @Query('interactionId') interactionId?: string, + @Query('status') status?: string, + @Headers('authorization') auth?: string, + ) { + this.principal(auth); + return this.ai.listArtifacts({ interactionId, status }); + } + + @Get('artifacts/:id') + async getArtifact(@Param('id') id: string, @Headers('authorization') auth?: string) { + this.principal(auth); + return this.ai.getArtifact(id); + } + + @Post('artifacts/:id/accept') + async accept(@Param('id') id: string, @Headers('authorization') auth?: string) { + return this.ai.accept(id, this.principal(auth)); + } + + @Post('artifacts/:id/reject') + async reject(@Param('id') id: string, @Headers('authorization') auth?: string) { + return this.ai.reject(id, this.principal(auth)); + } + + private principal(authorization?: string): MessagePrincipal { + const token = (authorization ?? '').replace(/^Bearer\s+/i, ''); + if (!token) throw new BadRequestException('Authorization bearer token is required'); + return this.session.verify(token); + } +} diff --git a/packages/iios-service/src/ai/ai.dto.ts b/packages/iios-service/src/ai/ai.dto.ts new file mode 100644 index 0000000..b0d1c98 --- /dev/null +++ b/packages/iios-service/src/ai/ai.dto.ts @@ -0,0 +1,8 @@ +import { IsIn, IsString } from 'class-validator'; + +const JOB_TYPES = ['CLASSIFY', 'SUMMARIZE', 'EXTRACT'] as const; + +export class RunJobDto { + @IsString() interactionId!: string; + @IsIn(JOB_TYPES) jobType!: (typeof JOB_TYPES)[number]; +} diff --git a/packages/iios-service/src/ai/ai.module.ts b/packages/iios-service/src/ai/ai.module.ts index aaf6f73..6711685 100644 --- a/packages/iios-service/src/ai/ai.module.ts +++ b/packages/iios-service/src/ai/ai.module.ts @@ -4,6 +4,7 @@ import { INFERENCE_PORT, LocalDeterministicInference } from './inference.port'; import { AiBudgetGuard } from './ai-budget.guard'; import { AiJobService } from './ai.service'; import { AiProjector } from './ai.projector'; +import { AiController } from './ai.controller'; /** * AI Interaction SDK (P7). Proposal layer only. `INFERENCE_PORT` binds the local @@ -11,6 +12,7 @@ import { AiProjector } from './ai.projector'; */ @Module({ imports: [OutboxModule], + controllers: [AiController], providers: [ { provide: INFERENCE_PORT, useClass: LocalDeterministicInference }, AiBudgetGuard, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94a6e06..0a04cdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,25 @@ importers: specifier: workspace:* version: link:../iios-contracts + packages/iios-ai-web: + dependencies: + '@insignia/iios-kernel-client': + specifier: workspace:* + version: link:../iios-kernel-client + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + react: + specifier: ^19.0.0 + version: 19.2.7 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/iios-community-web: dependencies: '@insignia/iios-kernel-client': diff --git a/scripts/check-import-boundary.mjs b/scripts/check-import-boundary.mjs index 79c58ce..b1ccf87 100644 --- a/scripts/check-import-boundary.mjs +++ b/scripts/check-import-boundary.mjs @@ -22,6 +22,7 @@ const ALLOWED = { '@insignia/iios-message-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], '@insignia/iios-inbox-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], '@insignia/iios-community-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], + '@insignia/iios-ai-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], '@insignia/iios-support-web': [ '@insignia/iios-contracts', '@insignia/iios-kernel-client',