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) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { AiProvider, useRunJob, useAiProposals, useArtifact } from './react';
|
||||||
|
export type { AiArtifact, AiClaim, AiEvidenceLink, AiModelRun, AiJobResult } from '@insignia/iios-kernel-client';
|
||||||
@@ -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<RestClient | null>(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 <AiContext.Provider value={client}>{children}</AiContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useClient(): RestClient {
|
||||||
|
const c = useContext(AiContext);
|
||||||
|
if (!c) throw new Error('AI hooks must be used within <AiProvider>');
|
||||||
|
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<AiJobResult> {
|
||||||
|
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<void>;
|
||||||
|
accept: (id: string) => Promise<void>;
|
||||||
|
reject: (id: string) => Promise<void>;
|
||||||
|
} {
|
||||||
|
const client = useClient();
|
||||||
|
const [artifacts, setArtifacts] = useState<AiArtifact[]>([]);
|
||||||
|
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<void> } {
|
||||||
|
const client = useClient();
|
||||||
|
const [artifact, setArtifact] = useState<AiArtifact | null>(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 };
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
|
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 {
|
export interface RestConfig {
|
||||||
serviceUrl: string;
|
serviceUrl: string;
|
||||||
@@ -124,6 +124,31 @@ export class RestClient {
|
|||||||
return this.post<RouteDecision>(`/v1/routes/decisions/${id}/deny`, {});
|
return this.post<RouteDecision>(`/v1/routes/decisions/${id}/deny`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── AI proposal layer (P7) ───────────────────────────────────
|
||||||
|
async runAiJob(input: { interactionId: string; jobType: 'CLASSIFY' | 'SUMMARIZE' | 'EXTRACT' }): Promise<AiJobResult> {
|
||||||
|
return this.post<AiJobResult>('/v1/ai/jobs', input);
|
||||||
|
}
|
||||||
|
async listArtifacts(opts?: { interactionId?: string; status?: string }): Promise<AiArtifact[]> {
|
||||||
|
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<AiArtifact> {
|
||||||
|
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<AiArtifact> {
|
||||||
|
return this.post<AiArtifact>(`/v1/ai/artifacts/${id}/accept`, {});
|
||||||
|
}
|
||||||
|
async rejectArtifact(id: string): Promise<AiArtifact> {
|
||||||
|
return this.post<AiArtifact>(`/v1/ai/artifacts/${id}/reject`, {});
|
||||||
|
}
|
||||||
|
|
||||||
private async post<T>(path: string, body: unknown): Promise<T> {
|
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||||
const r = await fetch(this.url(path), { method: 'POST', headers: this.headers(), body: JSON.stringify(body) });
|
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}`);
|
if (!r.ok) throw new Error(`POST ${path} ${r.status}`);
|
||||||
|
|||||||
@@ -102,6 +102,54 @@ export interface RouteDecision {
|
|||||||
routeBinding?: RouteBinding;
|
routeBinding?: RouteBinding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiClaim {
|
||||||
|
id: string;
|
||||||
|
claimType: string;
|
||||||
|
claimJson: Record<string, unknown> | 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. */
|
/** Minimal socket surface so the facade can be unit-tested with a fake. */
|
||||||
export interface SocketLike {
|
export interface SocketLike {
|
||||||
on(event: string, handler: (...args: unknown[]) => void): unknown;
|
on(event: string, handler: (...args: unknown[]) => void): unknown;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { INFERENCE_PORT, LocalDeterministicInference } from './inference.port';
|
|||||||
import { AiBudgetGuard } from './ai-budget.guard';
|
import { AiBudgetGuard } from './ai-budget.guard';
|
||||||
import { AiJobService } from './ai.service';
|
import { AiJobService } from './ai.service';
|
||||||
import { AiProjector } from './ai.projector';
|
import { AiProjector } from './ai.projector';
|
||||||
|
import { AiController } from './ai.controller';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI Interaction SDK (P7). Proposal layer only. `INFERENCE_PORT` binds the local
|
* AI Interaction SDK (P7). Proposal layer only. `INFERENCE_PORT` binds the local
|
||||||
@@ -11,6 +12,7 @@ import { AiProjector } from './ai.projector';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OutboxModule],
|
imports: [OutboxModule],
|
||||||
|
controllers: [AiController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: INFERENCE_PORT, useClass: LocalDeterministicInference },
|
{ provide: INFERENCE_PORT, useClass: LocalDeterministicInference },
|
||||||
AiBudgetGuard,
|
AiBudgetGuard,
|
||||||
|
|||||||
Generated
+19
@@ -151,6 +151,25 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../iios-contracts
|
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:
|
packages/iios-community-web:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@insignia/iios-kernel-client':
|
'@insignia/iios-kernel-client':
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const ALLOWED = {
|
|||||||
'@insignia/iios-message-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'],
|
'@insignia/iios-message-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'],
|
||||||
'@insignia/iios-inbox-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-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-support-web': [
|
||||||
'@insignia/iios-contracts',
|
'@insignia/iios-contracts',
|
||||||
'@insignia/iios-kernel-client',
|
'@insignia/iios-kernel-client',
|
||||||
|
|||||||
Reference in New Issue
Block a user