diff --git a/docs/plans/modernization-2026-10.md b/docs/plans/modernization-2026-10.md index e1118da..bb8e7c7 100644 --- a/docs/plans/modernization-2026-10.md +++ b/docs/plans/modernization-2026-10.md @@ -34,6 +34,12 @@ production build, and `npm audit --omit=dev` (0 findings): model and the `isLeaf` / `'success'` fallbacks removed, label-integrity report deleted, upgrade path dropped from the docs. - **M08** (partly) Trivy scan of the smoke image in CI. +- **M09** Taxomind contract: types generated from Taxomind's OpenAPI document, + calls through `openapi-fetch`, every response parsed in + `lib/contracts/taxomind.ts`; the stub service checks requests and replies + against the document. Verified with typecheck, lint (0 errors), 48 unit + tests, 77 integration tests (PostgreSQL 16) and a production build. W07's + idempotent start stays open (Taxomind does not deduplicate); see M09. Still open from this plan: @@ -41,7 +47,8 @@ Still open from this plan: with a single reformat commit. - M01: remove hand-written `useMemo`/`useCallback` where the compiler covers them (optional). -- M06, M07, M08 logs/traces, M09, and the "Carried over from hardening" list. +- M06, M07, M08 logs/traces, the open points under M09, and the "Carried over + from hardening" list. - Setting up staging and production from the docs (phase 3) is the maintainer's. @@ -375,10 +382,10 @@ PostgreSQL 18 itself is part of M00 (no upgrade needed on a fresh start). configured. Alerts for failed deploys and stuck AI jobs build on these. - Read-only root filesystem and limits: see "Carried over from hardening". -### M09 Taxomind contract (P2, S) +### M09 Taxomind contract (P2, S; done 21 October 2026) -`lib/ai-labeling.ts` types Taxomind's requests and responses by hand, and -`lib/ai-results.ts` validates results after the fact. If Taxomind publishes +`lib/ai-labeling.ts` typed Taxomind's requests and responses by hand, and +`lib/ai-results.ts` validated results after the fact. If Taxomind publishes an OpenAPI document, generate types with `openapi-typescript` and call it via `openapi-fetch`; otherwise put zod schemas for the boundary in `lib/contracts/taxomind.ts`. Reuse the stub HTTP service from @@ -386,6 +393,62 @@ an OpenAPI document, generate types with `openapi-typescript` and call it via idempotent job start (send `batchId`, let Taxomind deduplicate) belongs here if Taxomind supports it. +**Done.** Taxomind is a FastAPI service and serves its OpenAPI document at +`/openapi.json`, with a response model on every endpoint classiflow uses. + +- `lib/contracts/taxomind.openapi.json` is a snapshot of that document + (Taxomind `f7a9e91`, FastAPI 0.121.3 / Pydantic 2.12.4 as in its + `requirements-prod.txt`). `npm run taxomind:types` regenerates + `taxomind.openapi.ts` from it (`openapi-typescript`; an `overrides` entry lets + it use TypeScript 6). To refresh: save `$AI_LABELING_API_URL/openapi.json` + over the snapshot, regenerate, run typecheck and the integration tests. +- `lib/ai-labeling.ts` calls Taxomind through `openapi-fetch`, keeping the + bearer token, the 3 min / 30 s timeouts and best-effort cancel. Labeling and + learning requests are typed by the generated `LabelingRequest` / + `LearningRequest`. +- `lib/contracts/taxomind.ts` parses every response with zod: the job id of + each creation response (`job_id`, or `jobId` for `/learn`), the status + (`pending`, `running`, `completed`, `failed`, `canceled`) and the labeling + result. Each schema is checked against the generated type, so a regenerated + contract that renames or retypes a field fails typecheck. The old + `lib/ai-results.ts` lives on here as `parseLabelingResults`. It still takes + only the job's own records, drops malformed ones and keeps codes as strings. + It now expects the contract's `sentenceId` and string `nodeCode`. +- Behaviour changes, all from the contract: a malformed status response (not + JSON, unknown or missing status) counts as a failed status request (backoff, + then the poll timeout). The aliases `complete`, `success` and `error` are gone + (Taxomind never sends them). A job Taxomind reports as `canceled` is failed + with that reason instead of being polled until it times out (monitor and + taxonomy sync). +- Contract test: the stub in `tests/integration/ai-job-monitor.test.ts` converts + the document's schemas with `z.fromJSONSchema` and checks every request + classiflow sends and every reply it gives. It covers labeling, learning, + taxonomy sync through the `sync-ai` route and cancel, plus malformed replies + (not JSON, missing or unknown status, start response without a job id, + refused start, partial and empty results), Taxomind-side cancellation, + `sourceSlug` (`AI_SOURCE_SLUG--`), leading zeros and the + unknown codes. + +Still open: + +- **W07 idempotent start:** Taxomind requires `batchId` on `/label` but + creates a new job for every request (`uuid4`) and does not deduplicate. + If an instance dies after `POST /label` succeeded but before it saved + `externalJobId`, recovery sends the batch again. Closing this needs + Taxomind to deduplicate by `(sourceSlug, batchId)` and return the existing + job. `/learn` has no batch id at all. +- **Taxonomy delete notification:** the taxonomy delete route sends + `POST /taxonomies` with `action: 'delete'`. Taxomind accepts only `create` + and answers 422, so `aiNotified` is always false. Either Taxomind gets a + delete action or the call goes. +- **Typed taxonomy requests:** `startAIJob('/taxonomies', …)` still takes an + untyped payload, because the payloads are built in the taxonomy routes + (outside M09's scope). Typing them as `TaxonomyRequest` would surface the + delete mismatch above at compile time. Taxomind ignores the `description` and + `synonyms` the sync route sends. +- **Taxomind end to end** (below): still only the stub; the snapshot makes + the stub match Taxomind's declared contract, not its runtime behaviour. + ## Carried over from hardening Open items from the archived hardening briefs that no M-item covers. Items diff --git a/lib/ai-job-monitor.ts b/lib/ai-job-monitor.ts index c6f526a..dce2454 100644 --- a/lib/ai-job-monitor.ts +++ b/lib/ai-job-monitor.ts @@ -26,10 +26,10 @@ import { hostname } from 'node:os' import { randomUUID } from 'node:crypto' import { prisma } from '@/lib/prisma' import { db, withWorkspace } from '@/lib/workspace/db' -import { startAIJob, fetchAIJobStatus } from './ai-labeling' +import { startLabelingJob, startLearningJob, fetchAIJobStatus } from './ai-labeling' import type { Prisma } from '@/lib/generated/prisma/client' import { AI_JOB_POLL_INTERVAL_MS, AI_JOB_POLL_TIMEOUT_MS, aiSourceSlug, getUnknownCodeForLevel } from './constants' -import { parseLabelingResults } from './ai-results' +import { parseLabelingResults } from './contracts/taxomind' import { buildFieldMap } from './ai-utils' // --------------------------------------------------------------------------- @@ -132,7 +132,7 @@ async function releaseLease(workspaceId: string, jobId: string, kind: JobKind) { type JobRecord = NonNullable>> -const API_PATH: Record = { labeling: '/label', learning: '/learn' } +const API_RESOURCE = { labeling: 'label', learning: 'learn' } as const // --------------------------------------------------------------------------- // Public API @@ -218,7 +218,7 @@ async function startLabeling(job: JobRecord): Promise { if (payloadSentences.length === 0) throw new Error('No valid sentences to process') - const externalJobId = await startAIJob('/label', { + const externalJobId = await startLabelingJob({ taxonomyKey: job.taxonomy.key, sourceSlug: aiSourceSlug(job.workspaceId), batchId: job.id, @@ -281,7 +281,7 @@ async function startLearning(job: JobRecord): Promise { const sentencesPayload = Array.from(sentencesMap.values()) if (sentencesPayload.length === 0) throw new Error('No valid annotations to send') - const externalJobId = await startAIJob('/learn', { + const externalJobId = await startLearningJob({ taxonomyKey: job.taxonomy.key, sourceSlug: aiSourceSlug(job.workspaceId), sentences: sentencesPayload @@ -359,16 +359,15 @@ async function pollUntilComplete( } try { - const status = await fetchAIJobStatus(`${API_PATH[kind]}/${externalJobId}/status`) + const status = await fetchAIJobStatus(API_RESOURCE[kind], externalJobId) consecutiveErrors = 0 - const normalized = (status.status || '').toLowerCase() - const progress = typeof status.progress === 'number' ? status.progress : 0 + const progress = status.progress ?? 0 await updateJobRecord(workspaceId, jobId, kind, { processedSentences: Math.round(progress * totalSentences) }) - if (normalized === 'complete' || normalized === 'completed' || normalized === 'success') { + if (status.status === 'completed') { // Completion is a conditional transition: if the job was cancelled while // we waited for the service, nothing is written. Results and status // commit together, and only while the workspace is active (write: an @@ -382,7 +381,7 @@ async function pollUntilComplete( : await tx.aILabelingJob.updateMany({ where, data }) if (claimed.count === 0) return false if (kind === 'labeling') { - await storeLabelingResults(tx, workspaceId, jobId, taxonomyId, sentenceIds, status.result || status) + await storeLabelingResults(tx, workspaceId, jobId, taxonomyId, sentenceIds, status.result) } return true }, { write: true, timeoutMs: 120_000 }) @@ -396,10 +395,18 @@ async function pollUntilComplete( return } - if (normalized === 'failed' || normalized === 'error') { + if (status.status === 'failed') { await failIfActive(workspaceId, jobId, kind, status.error || 'External AI job failed') return } + + // Cancelled on Taxomind's side. When the cancel came from classiflow, the + // job still ends up cancelled: the cancel routes write that status + // unconditionally, and failIfActive leaves a cancelled job alone. + if (status.status === 'canceled') { + await failIfActive(workspaceId, jobId, kind, status.error || 'External AI job was cancelled in the AI service') + return + } } catch (error) { if (Date.now() - startedAt >= AI_JOB_POLL_TIMEOUT_MS) { throw error @@ -453,10 +460,10 @@ async function failIfActive(workspaceId: string, jobId: string, kind: JobKind, m } /** - * Validates the completed labeling job's result (lib/ai-results.ts) and stores - * the suggestions, inside the completion transaction. Only the job's own - * records are touched, and only codes that exist in the taxonomy (or are the - * level's unknown code) are kept. + * Validates the completed labeling job's result (lib/contracts/taxomind.ts) + * and stores the suggestions, inside the completion transaction. Only the + * job's own records are touched, and only codes that exist in the taxonomy (or + * are the level's unknown code) are kept. */ async function storeLabelingResults( tx: Prisma.TransactionClient, diff --git a/lib/ai-labeling.ts b/lib/ai-labeling.ts index 6812d1a..58ea8fb 100644 --- a/lib/ai-labeling.ts +++ b/lib/ai-labeling.ts @@ -1,7 +1,22 @@ +import createClient from 'openapi-fetch' import { AI_JOB_POLL_INTERVAL_MS, AI_JOB_POLL_TIMEOUT_MS } from '@/lib/constants' +import type { paths } from '@/lib/contracts/taxomind.openapi' +import { + createdJobId, + jobStatusResponse, + parseResponse, + type JobStatusResponse, + type LabelingRequest, + type LearningRequest, + type TaxomindResource, + type TaxonomyRequest, +} from '@/lib/contracts/taxomind' + +// The HTTP boundary to Taxomind. Requests and responses follow its OpenAPI +// contract (lib/contracts/taxomind.ts); every response is parsed there. const API_URL = process.env.AI_LABELING_API_URL const API_KEY = process.env.AI_LABELING_API_KEY @@ -19,100 +34,120 @@ export function ensureAIConfig() { } } -async function requestAI(path: string, options: RequestInit): Promise { - ensureAIConfig() +let taxomindClient: ReturnType> | undefined - const method = (options.method || 'GET').toUpperCase() - const timeoutMs = method === 'POST' ? AI_REQUEST_TIMEOUT_POST_MS : AI_REQUEST_TIMEOUT_GET_MS +function taxomind() { + ensureAIConfig() + taxomindClient ??= createClient({ + baseUrl: API_URL, + headers: { Authorization: `Bearer ${API_KEY}` }, + }) + return taxomindClient +} - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeoutMs) +type Sent = { data?: unknown; error?: unknown; response: Response } +/** + * Sends one request with the method's timeout and returns the body of a 2xx + * response. Non-2xx, timeouts and bodies that are not JSON become errors that + * name the request. + */ +async function send(method: 'GET' | 'POST', path: string, request: (signal: AbortSignal) => Promise): Promise { + const timeoutMs = method === 'POST' ? AI_REQUEST_TIMEOUT_POST_MS : AI_REQUEST_TIMEOUT_GET_MS + let sent: Sent try { - const res = await fetch(`${API_URL}${path}`, { - ...options, - signal: controller.signal, - headers: { - 'Authorization': `Bearer ${API_KEY}`, - 'Content-Type': 'application/json', - ...(options.headers || {}) - } - }) - - if (!res.ok) { - const text = await res.text().catch(() => '') - throw new Error(`AI labeling API error (${res.status}): ${text || res.statusText}`) - } - - return res.json() as Promise + sent = await request(AbortSignal.timeout(timeoutMs)) } catch (error: unknown) { - if (error instanceof DOMException && error.name === 'AbortError') { + if (error instanceof DOMException && (error.name === 'TimeoutError' || error.name === 'AbortError')) { throw new Error(`AI service request timed out after ${timeoutMs / 1000}s (${method} ${path})`) } + if (error instanceof SyntaxError) { + throw new Error(`AI service returned a body that is not JSON (${method} ${path})`) + } throw error - } finally { - clearTimeout(timeoutId) } -} -export async function callAILabelingEndpoint(path: string, payload: unknown): Promise { - return requestAI(path, { - method: 'POST', - body: JSON.stringify(payload) - }) + const { data, error, response } = sent + if (!response.ok) { + const text = typeof error === 'string' ? error : error === undefined ? '' : JSON.stringify(error) + throw new Error(`AI labeling API error (${response.status}): ${text || response.statusText}`) + } + return data } -export type AIJobStatusResponse = { - status?: string - result?: unknown - error?: string - [key: string]: unknown +/** Labeling job: POST /label. Returns Taxomind's job id. */ +export async function startLabelingJob(body: LabelingRequest): Promise { + const data = await send('POST', '/label', signal => taxomind().POST('/label', { body, signal })) + return createdJobId('label', data) } -export type AIJobResult = - | { success: true; data: AIJobStatusResponse } - | { success: false; data?: AIJobStatusResponse; error: string } +/** Learning job: POST /learn. Returns Taxomind's job id. */ +export async function startLearningJob(body: LearningRequest): Promise { + const data = await send('POST', '/learn', signal => taxomind().POST('/learn', { body, signal })) + return createdJobId('learn', data) +} -export async function startAIJob(path: string, payload: unknown): Promise { - const response = await requestAI<{ job_id?: string; jobId?: string }>(path, { - method: 'POST', - body: JSON.stringify(payload) - }) - const jobId = response.job_id || response.jobId - if (!jobId) { - throw new Error('AI job response did not include a job_id') - } - return jobId +/** + * Taxonomy job: POST /taxonomies. The taxonomy routes build the payload; it is + * not narrowed to TaxonomyRequest yet (see M09 in + * docs/plans/modernization-2026-10.md), so Taxomind validates it (422). + */ +export async function startAIJob(path: '/taxonomies', payload: unknown): Promise { + const data = await send('POST', path, signal => taxomind().POST(path, { body: payload as TaxonomyRequest, signal })) + return createdJobId('taxonomies', data) } -export async function fetchAIJobStatus(statusPath: string): Promise { - return requestAI(statusPath, { method: 'GET' }) +export async function fetchAIJobStatus(resource: TaxomindResource, externalJobId: string): Promise { + const path = `/${resource}/${externalJobId}/status` + const params = { path: { job_id: externalJobId } } + const data = await send('GET', path, signal => { + switch (resource) { + case 'label': return taxomind().GET('/label/{job_id}/status', { params, signal }) + case 'learn': return taxomind().GET('/learn/{job_id}/status', { params, signal }) + case 'taxonomies': return taxomind().GET('/taxonomies/{job_id}/status', { params, signal }) + } + }) + return parseResponse(jobStatusResponse, data, `GET ${path}`) } /** * Cancel a running job on the external AI API. * Best-effort: failures are logged but not thrown. */ -export async function cancelAIJob(apiPath: string, externalJobId: string): Promise { +export async function cancelAIJob(apiPath: `/${TaxomindResource}`, externalJobId: string): Promise { + const path = `${apiPath}/${externalJobId}/cancel` + const params = { path: { job_id: externalJobId } } try { - await requestAI(`${apiPath}/${externalJobId}/cancel`, { method: 'POST' }) + await send('POST', path, signal => { + switch (apiPath) { + case '/label': return taxomind().POST('/label/{job_id}/cancel', { params, signal }) + case '/learn': return taxomind().POST('/learn/{job_id}/cancel', { params, signal }) + case '/taxonomies': return taxomind().POST('/taxonomies/{job_id}/cancel', { params, signal }) + } + }) } catch (error) { console.warn(`[ai] Failed to cancel external job ${externalJobId}:`, error) } } -export async function waitForAIJobResult(jobId: string, statusPath: string): Promise { +export type AIJobResult = + | { success: true; data: JobStatusResponse } + | { success: false; data?: JobStatusResponse; error: string } + +export async function waitForAIJobResult(resource: TaxomindResource, jobId: string): Promise { const startedAt = Date.now() while (true) { try { - const status = await fetchAIJobStatus(statusPath) - const normalized = (status.status || '').toLowerCase() - if (normalized === 'complete' || normalized === 'completed' || normalized === 'success') { + const status = await fetchAIJobStatus(resource, jobId) + if (status.status === 'completed') { return { success: true, data: status } } - if (normalized === 'failed' || normalized === 'error') { + if (status.status === 'failed') { return { success: false, data: status, error: status.error || 'AI job failed' } } + if (status.status === 'canceled') { + return { success: false, data: status, error: status.error || 'AI job was cancelled in the AI service' } + } } catch (error: unknown) { if (Date.now() - startedAt >= AI_JOB_POLL_TIMEOUT_MS) { const errorMessage = error instanceof Error ? error.message : String(error) @@ -129,13 +164,14 @@ export async function waitForAIJobResult(jobId: string, statusPath: string): Pro } } +/** Polls `statusPath` (`/{resource}/{jobId}/status`) in the background until the job ends. */ export function monitorAIJob( jobId: string, - statusPath: string, + statusPath: `/${TaxomindResource}/${string}/status`, handler: (result: AIJobResult) => Promise | void ) { - waitForAIJobResult(jobId, statusPath) + const resource = statusPath.split('/')[1] as TaxomindResource + waitForAIJobResult(resource, jobId) .then(handler) .catch(error => handler({ success: false, error: error instanceof Error ? error.message : String(error) })) } - diff --git a/lib/ai-results.test.ts b/lib/ai-results.test.ts deleted file mode 100644 index 57e1896..0000000 --- a/lib/ai-results.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseLabelingResults } from './ai-results' - -describe('parseLabelingResults', () => { - it('accepts only records of the job and well-formed suggestions', () => { - const { bySentence, rejected, serviceErrors } = parseLabelingResults({ - suggestions: [ - { sentenceId: 'a', annotations: [{ level: 1, nodeCode: '01', confidence: 0.9 }, { level: 2, nodeCode: '011', confidence: 1.7 }] }, - { sentence_id: 'b', annotations: [{ level: 1, nodeCode: 2 }] }, - { sentenceId: 'not-in-job', annotations: [{ level: 1, nodeCode: '01' }] }, - { sentenceId: 'c', annotations: [{ level: 1, nodeCode: '01' }, { level: 1, nodeCode: '02' }] }, - { sentenceId: 'c', annotations: [] }, - { sentenceId: 'd', annotations: [{ level: 9, nodeCode: '01' }] }, - 'garbage', - ], - errors: [{ sentenceId: 'e' }], - }, ['a', 'b', 'c', 'd', 'e']) - - expect(Array.from(bySentence.keys())).toEqual(['a', 'b', 'c']) - expect(bySentence.get('a')).toEqual([ - { level: 1, nodeCode: '01', confidence: 0.9 }, - { level: 2, nodeCode: '011', confidence: 1 }, - ]) - expect(bySentence.get('b')).toEqual([{ level: 1, nodeCode: '2', confidence: 0 }]) - expect(bySentence.get('c')).toEqual([]) // duplicate level rejected, then empty list accepted - expect(rejected).toBe(4) - expect(serviceErrors).toBe(1) - }) - - it('handles a missing or malformed response', () => { - expect(parseLabelingResults(null, ['a']).bySentence.size).toBe(0) - expect(parseLabelingResults({ suggestions: 'x' }, ['a']).rejected).toBe(0) - }) -}) diff --git a/lib/ai-results.ts b/lib/ai-results.ts deleted file mode 100644 index fb62a6f..0000000 --- a/lib/ai-results.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Validation of a completed labeling job's result from the AI service. - * - * Only records that belong to the job are accepted (a response can never - * overwrite suggestions of unrelated records). Per record: levels are integers - * 1..5 and unique, codes are non-empty strings (numbers are accepted for - * compatibility and stringified), confidence is clamped to 0..1. Anything else - * is dropped and counted as rejected. - */ - -export type ParsedSuggestion = { level: number; nodeCode: string; confidence: number } - -type RawResponse = { - suggestions?: unknown - errors?: unknown -} - -const MAX_CODE_LENGTH = 100 - -export function parseLabelingResults( - raw: unknown, - allowedSentenceIds: string[] -): { bySentence: Map; rejected: number; serviceErrors: number } { - const response = (raw && typeof raw === 'object' ? raw : {}) as RawResponse - const allowed = new Set(allowedSentenceIds) - const bySentence = new Map() - let rejected = 0 - - const suggestions = Array.isArray(response.suggestions) ? response.suggestions : [] - for (const item of suggestions) { - const s = (item && typeof item === 'object' ? item : {}) as { - sentenceId?: unknown; sentence_id?: unknown; annotations?: unknown - } - const sentenceId = typeof s.sentenceId === 'string' ? s.sentenceId - : typeof s.sentence_id === 'string' ? s.sentence_id : null - if (!sentenceId || !allowed.has(sentenceId) || !Array.isArray(s.annotations) || bySentence.has(sentenceId)) { - rejected++ - continue - } - - const levels = new Set() - const parsed: ParsedSuggestion[] = [] - let valid = true - for (const a of s.annotations as Array>) { - const level = a?.level - const code = typeof a?.nodeCode === 'string' ? a.nodeCode.trim() - : typeof a?.nodeCode === 'number' && Number.isFinite(a.nodeCode) ? String(a.nodeCode) : '' - if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 5 || levels.has(level) - || !code || code.length > MAX_CODE_LENGTH) { - valid = false - break - } - levels.add(level) - const c = typeof a.confidence === 'number' && Number.isFinite(a.confidence) ? a.confidence : 0 - parsed.push({ level, nodeCode: code, confidence: Math.min(Math.max(c, 0), 1) }) - } - if (!valid) { - rejected++ - continue - } - bySentence.set(sentenceId, parsed) - } - - const serviceErrors = Array.isArray(response.errors) ? response.errors.length : 0 - return { bySentence, rejected, serviceErrors } -} diff --git a/lib/contracts/taxomind.openapi.json b/lib/contracts/taxomind.openapi.json new file mode 100644 index 0000000..31bba58 --- /dev/null +++ b/lib/contracts/taxomind.openapi.json @@ -0,0 +1,2627 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "taxomind", + "description": "Multilingual taxonomy classification service with async taxonomy management and hierarchical inference labeling.", + "version": "0.1" + }, + "paths": { + "/health": { + "get": { + "tags": [ + "health" + ], + "summary": "Health Check", + "description": "Health check endpoint for monitoring and container orchestration.", + "operationId": "health_check_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/taxonomies": { + "post": { + "tags": [ + "taxonomies" + ], + "summary": "Create Taxonomy", + "description": "Create a new taxonomy by triggering the taxonomy pipeline asynchronously.\n\nThis endpoint accepts taxonomy data and immediately returns a job ID.\nThe actual processing happens in the background and can take several\nminutes.\n\nUse the GET /taxonomies/{job_id}/status endpoint to check job status.\n\nArgs:\n request: Taxonomy data including action and taxonomy structure\n background_tasks: FastAPI background tasks manager\n service: Taxonomy pipeline service\n job_store: Job status storage\n\nReturns:\n TaxonomyJobResponse with job_id and initial status", + "operationId": "create_taxonomy_taxonomies_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaxonomyRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaxonomyJobResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/taxonomies/{taxonomy_key}/enrich": { + "post": { + "tags": [ + "taxonomies" + ], + "summary": "Enrich Taxonomy", + "description": "Run the `enrich_taxonomy` pipeline asynchronously for one taxonomy.", + "operationId": "enrich_taxonomy_taxonomies__taxonomy_key__enrich_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "taxonomy_key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Taxonomy Key" + } + } + ], + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaxonomyJobResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/taxonomies/{taxonomy_key}/build": { + "post": { + "tags": [ + "taxonomies" + ], + "summary": "Build Taxonomy Index", + "description": "Run the `build_taxonomy` pipeline asynchronously for one taxonomy.", + "operationId": "build_taxonomy_index_taxonomies__taxonomy_key__build_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "taxonomy_key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Taxonomy Key" + } + } + ], + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaxonomyJobResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/taxonomies/{job_id}/cancel": { + "post": { + "tags": [ + "taxonomies" + ], + "summary": "Cancel Taxonomy Job", + "description": "Request cancellation for a taxonomy job.", + "operationId": "cancel_taxonomy_job_taxonomies__job_id__cancel_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaxonomyStatusResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/taxonomies/{job_id}/status": { + "get": { + "tags": [ + "taxonomies" + ], + "summary": "Get Taxonomy Status", + "description": "Get the current status of a taxonomy processing job.\n\nPoll this endpoint to check if the taxonomy pipeline has completed.\n\nArgs:\n job_id: The job ID returned from the POST /taxonomies endpoint\n job_store: Job status storage\n\nReturns:\n TaxonomyStatusResponse with current job status and progress\n\nRaises:\n HTTPException: 404 if job_id is not found", + "operationId": "get_taxonomy_status_taxonomies__job_id__status_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaxonomyStatusResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/classify": { + "post": { + "tags": [ + "inference" + ], + "summary": "Create Inference Job", + "description": "Create an inference job for hierarchical classification.\n\nThis endpoint accepts sentences with field dictionaries, concatenates them into text,\nand classifies them using trained ye models for all hierarchical levels.\n\n**Authentication**: Requires Bearer token in Authorization header.\n\n**Request Format**:\n```json\n{\n \"taxonomyKey\": \"ISCO\",\n \"sentences\": [\n {\n \"sentence_id\": \"unique-id-1\",\n \"fields\": {\n \"Job Description\": \"Primary school teacher\",\n \"Industry Description\": \"Ministry of Education\"\n }\n }\n ]\n}\n```\n\n**Response**: Returns job ID for polling status via `/classify/{jobId}/status`\n\n**Processing**:\n1. Validates payload structure\n2. Concatenates fields into text (e.g., \"Job Description: ..., Industry Description: ...\")\n3. Loads trained models for the taxonomy\n4. Performs hierarchical inference (predicts Level 1, 2, 3, 4)\n5. Returns predictions for all levels\n\n**Notes**:\n- Models must be trained first using `/learn` endpoint\n- Processing is asynchronous; poll for results\n- All field values are concatenated with field names", + "operationId": "create_inference_job_classify_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InferenceRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InferenceJobResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/classify/{job_id}/cancel": { + "post": { + "tags": [ + "inference" + ], + "summary": "Cancel Inference Job", + "description": "Request cancellation for an inference job.", + "operationId": "cancel_inference_job_classify__job_id__cancel_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InferenceStatusResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/classify/{job_id}/status": { + "get": { + "tags": [ + "inference" + ], + "summary": "Get Inference Status", + "description": "Get the status of an inference job.\n\n**Authentication**: Requires Bearer token in Authorization header.\n\n**Job States**:\n- `pending`: Job created, waiting to start\n- `running`: Inference in progress\n- `completed`: Inference finished successfully (includes results)\n- `failed`: Inference failed (includes error message)\n\n**Example Response** (completed):\n```json\n{\n \"jobId\": \"...\",\n \"status\": \"completed\",\n \"taxonomyKey\": \"ISCO\",\n \"message\": \"Inference completed successfully\",\n \"createdAt\": \"2025-01-25T10:30:00Z\",\n \"completedAt\": \"2025-01-25T10:30:05Z\",\n \"result\": {\n \"taxonomyKey\": \"ISCO\",\n \"results\": [\n {\n \"sentence_id\": \"unique-id-1\",\n \"text\": \"Job Description: Primary school teacher, Industry Description: Ministry of Education\",\n \"predictions\": {\n \"1\": \"Professionals\",\n \"2\": \"Teaching Professionals\",\n \"3\": \"Primary School and Early Childhood Teachers\",\n \"4\": \"Primary School Teachers\"\n }\n }\n ]\n }\n}\n```\n\n**Polling Recommendation**: Poll every 2-5 seconds until status is `completed` or `failed`", + "operationId": "get_inference_status_classify__job_id__status_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InferenceStatusResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/label": { + "post": { + "tags": [ + "labeling" + ], + "summary": "Create Labeling Job", + "description": "Create a new labeling job by triggering the inference pipeline asynchronously.\n\nThis endpoint accepts a batch of sentences and immediately returns a\njob ID. The actual classification happens in the background and can\ntake several minutes depending on batch size and model configuration.\n\nUse the GET /label/{job_id}/status endpoint to check job status.\n\nArgs:\n request: Labeling request with taxonomy key, batch ID, and sentences\n background_tasks: FastAPI background tasks manager\n service: Labeling pipeline service\n job_store: Job status storage\n\nReturns:\n LabelingJobResponse with job_id and initial status", + "operationId": "create_labeling_job_label_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabelingRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabelingJobResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/label/{job_id}/cancel": { + "post": { + "tags": [ + "labeling" + ], + "summary": "Cancel Labeling Job", + "description": "Request cancellation for a labeling job.", + "operationId": "cancel_labeling_job_label__job_id__cancel_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabelingStatusResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/label/{job_id}/status": { + "get": { + "tags": [ + "labeling" + ], + "summary": "Get Labeling Status", + "description": "Get the current status of a labeling job.\n\nPoll this endpoint to check if the labeling pipeline has completed.\n\nArgs:\n job_id: The job ID returned from the POST /label endpoint\n job_store: Job status storage\n\nReturns:\n LabelingStatusResponse with current job status, progress,\n and results (if completed)\n\nRaises:\n HTTPException: 404 if job_id is not found", + "operationId": "get_labeling_status_label__job_id__status_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LabelingStatusResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/learn": { + "post": { + "tags": [ + "learning" + ], + "summary": "Create Learning Job", + "description": "Create a new incremental learning job by triggering the learning pipeline asynchronously.\n\nThis endpoint accepts training sentences with annotations and immediately returns a\njob ID. The evidence update happens in the background and can take several\nminutes depending on dataset size.\n\nThe learning process will:\n1. Validate the payload\n2. Embed the corrected texts\n3. Update per-node evidence centroids (no ancestor drift)\n\nUse the GET /learn/{job_id}/status endpoint to poll for job completion.\n\nArgs:\n request: Learning request with taxonomy key and training sentences\n background_tasks: FastAPI background tasks manager\n service: Learning pipeline service\n job_store: Job status storage\n\nReturns:\n LearningJobResponse with job_id and initial status (HTTP 202 Accepted)\n\nExample:\n ```\n POST /learn\n {\n \"taxonomyKey\": \"ISCO\",\n \"sentences\": [\n {\n \"sentenceId\": \"job-001\",\n \"fields\": {\n \"job_title\": \"Software Engineer\",\n \"job_description\": \"Develops web applications\"\n },\n \"annotations\": [\n {\"level\": 1, \"nodeCode\": \"2\"},\n {\"level\": 2, \"nodeCode\": \"25\"},\n {\"level\": 3, \"nodeCode\": \"251\"},\n {\"level\": 4, \"nodeCode\": \"2512\"}\n ]\n }\n ]\n }\n ```", + "operationId": "create_learning_job_learn_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LearningRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LearningJobResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/learn/{job_id}/cancel": { + "post": { + "tags": [ + "learning" + ], + "summary": "Cancel Learning Job", + "description": "Request cancellation for a learning job.", + "operationId": "cancel_learning_job_learn__job_id__cancel_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LearningStatusResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/learn/{job_id}/status": { + "get": { + "tags": [ + "learning" + ], + "summary": "Get Learning Status", + "description": "Get the current status of an incremental training job.\n\nPoll this endpoint to check if the training pipeline has completed.\nRecommended polling interval: 5-10 seconds for jobs expected to complete\nwithin minutes, or 30-60 seconds for longer training runs.\n\nResponse varies by job status:\n- **pending**: Job queued but not yet started\n- **running**: Training in progress (includes progress information)\n- **completed**: Training finished successfully (includes model version and metrics)\n- **failed**: Training failed (includes error message)\n\nArgs:\n job_id: The job ID returned from the POST /learn endpoint\n job_store: Job status storage\n\nReturns:\n LearningStatusResponse with current job status, progress,\n and results (if completed)\n\nRaises:\n HTTPException: 404 if job_id is not found\n\nExample completed response:\n ```\n {\n \"jobId\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"status\": \"completed\",\n \"taxonomyKey\": \"ISCO\",\n \"message\": \"Training completed successfully\",\n \"createdAt\": \"2025-01-24T18:20:45.123Z\",\n \"startedAt\": \"2025-01-24T18:20:47.456Z\",\n \"completedAt\": \"2025-01-24T18:35:12.789Z\",\n \"result\": {\n \"modelVersion\": \"ISCO_v5_20250124T182045\",\n \"trainingMetrics\": {\n \"totalLevels\": 4,\n \"levelsSummary\": {\n \"1\": {\"accuracy\": 0.95, \"f1_score\": 0.94, \"training_mode\": \"standard\"}\n }\n },\n \"trainingDataStats\": {\n \"newSamples\": 15,\n \"totalSamples\": 150,\n \"appendedToExisting\": true\n }\n }\n }\n ```", + "operationId": "get_learning_status_learn__job_id__status_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LearningStatusResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/error-analysis": { + "post": { + "tags": [ + "error-analysis" + ], + "summary": "Run Error Analysis", + "description": "Run the `error_analysis` pipeline asynchronously.", + "operationId": "run_error_analysis_error_analysis_post", + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorAnalysisJobResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/error-analysis/{job_id}/cancel": { + "post": { + "tags": [ + "error-analysis" + ], + "summary": "Cancel Error Analysis Job", + "description": "Request cancellation for an error analysis job.", + "operationId": "cancel_error_analysis_job_error_analysis__job_id__cancel_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorAnalysisStatusResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/error-analysis/{job_id}/status": { + "get": { + "tags": [ + "error-analysis" + ], + "summary": "Get Error Analysis Status", + "description": "Get the current status of an error analysis job.", + "operationId": "get_error_analysis_status_error_analysis__job_id__status_get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorAnalysisStatusResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Annotation": { + "properties": { + "level": { + "type": "integer", + "minimum": 1.0, + "title": "Level", + "description": "Taxonomy level" + }, + "nodeCode": { + "type": "string", + "title": "Nodecode", + "description": "Node code (-99 for unknown)" + }, + "confidence": { + "type": "number", + "maximum": 1.0, + "minimum": 0.0, + "title": "Confidence", + "description": "Confidence score" + } + }, + "type": "object", + "required": [ + "level", + "nodeCode", + "confidence" + ], + "title": "Annotation", + "description": "Single annotation at a specific level.", + "example": { + "confidence": 0.87, + "level": 4, + "nodeCode": "2341" + } + }, + "ErrorAnalysisJobResponse": { + "properties": { + "job_id": { + "type": "string", + "title": "Job Id", + "description": "Unique identifier for the job" + }, + "status": { + "$ref": "#/components/schemas/JobStatus", + "description": "Current status of the job" + }, + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable status message" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At", + "description": "When the job was created" + } + }, + "type": "object", + "required": [ + "job_id", + "status", + "message", + "created_at" + ], + "title": "ErrorAnalysisJobResponse", + "description": "Response returned when an error-analysis job is created.", + "example": { + "created_at": "2026-02-11T12:00:00Z", + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "message": "Error analysis started", + "status": "pending" + } + }, + "ErrorAnalysisStatusResponse": { + "properties": { + "job_id": { + "type": "string", + "title": "Job Id", + "description": "Unique identifier for the job" + }, + "status": { + "$ref": "#/components/schemas/JobStatus", + "description": "Current status of the job" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message", + "description": "Status message" + }, + "progress": { + "anyOf": [ + { + "type": "number", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Progress", + "description": "Progress percentage (0.0 to 1.0)" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error", + "description": "Error message if job failed" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At", + "description": "When the job was created" + }, + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Started At", + "description": "When the job started running" + }, + "completed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Completed At", + "description": "When the job completed or failed" + }, + "failed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Failed At", + "description": "When the job failed" + }, + "result": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Result", + "description": "Error analysis results (only when completed)" + } + }, + "type": "object", + "required": [ + "job_id", + "status", + "created_at" + ], + "title": "ErrorAnalysisStatusResponse", + "description": "Response for error-analysis job status queries.", + "example": { + "completed_at": "2026-02-11T12:00:11Z", + "created_at": "2026-02-11T12:00:00Z", + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "message": "Error analysis completed successfully", + "progress": 1.0, + "result": { + "rows": { + "classifai": 420, + "taxonomy_training": 420, + "training_sentences": 420 + }, + "taxonomy_key_counts": { + "classifai": { + "ISCO": 420 + }, + "taxonomy_training": { + "ISCO": 420 + }, + "training_sentences": { + "ISCO": 420 + } + } + }, + "status": "completed" + } + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "InferenceJobResponse": { + "properties": { + "jobId": { + "type": "string", + "title": "Jobid", + "description": "Unique job identifier" + }, + "status": { + "$ref": "#/components/schemas/JobStatus", + "description": "Job status" + }, + "taxonomyKey": { + "type": "string", + "title": "Taxonomykey", + "description": "Taxonomy used for classification" + }, + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable status message" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "title": "Createdat", + "description": "Job creation timestamp" + } + }, + "type": "object", + "required": [ + "jobId", + "status", + "taxonomyKey", + "message", + "createdAt" + ], + "title": "InferenceJobResponse", + "description": "Response when creating an inference job.", + "example": { + "createdAt": "2025-01-25T10:30:00Z", + "jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "message": "Inference job created", + "status": "pending", + "taxonomyKey": "ISCO" + } + }, + "InferenceRequest": { + "properties": { + "taxonomyKey": { + "type": "string", + "minLength": 1, + "title": "Taxonomykey", + "description": "Taxonomy to use for classification (e.g., 'ISCO')" + }, + "sourceSlug": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Sourceslug", + "description": "Optional source identifier for the caller. If omitted, inferred from Origin/Referer host (fallback: request host; e.g., subdomain.domani1.com -> subdomain-domani1)." + }, + "sentences": { + "items": { + "$ref": "#/components/schemas/InferenceSentence" + }, + "type": "array", + "minItems": 1, + "title": "Sentences", + "description": "List of sentences to classify" + } + }, + "type": "object", + "required": [ + "taxonomyKey", + "sentences" + ], + "title": "InferenceRequest", + "description": "Request payload for classification inference.", + "example": { + "sentences": [ + { + "fields": { + "Industry Description": "agriculture own business", + "Job Description": "mixed vegetable farmer" + }, + "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + }, + { + "fields": { + "Industry Description": "Ministry of Education", + "Job Description": "Primary school teacher" + }, + "sentence_id": "9043b0ea-ebda-4cfb-8b96-cb1db4872417" + } + ], + "sourceSlug": "subdomain-domani1", + "taxonomyKey": "ISCO" + } + }, + "InferenceResult": { + "properties": { + "taxonomyKey": { + "type": "string", + "title": "Taxonomykey", + "description": "Taxonomy used" + }, + "results": { + "items": { + "$ref": "#/components/schemas/PredictionResult" + }, + "type": "array", + "title": "Results", + "description": "Predictions for all sentences" + } + }, + "type": "object", + "required": [ + "taxonomyKey", + "results" + ], + "title": "InferenceResult", + "description": "Complete inference result.", + "example": { + "results": [ + { + "predictions": { + "1": "Skilled Agricultural, Forestry and Fishery Workers", + "2": "Market-oriented Skilled Agricultural Workers", + "3": "Mixed Crop Growers", + "4": "Mixed Crop Growers" + }, + "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc", + "text": "Job Description: mixed vegetable farmer, Industry Description: agriculture own business" + } + ], + "taxonomyKey": "ISCO" + } + }, + "InferenceSentence": { + "properties": { + "sentence_id": { + "type": "string", + "minLength": 1, + "title": "Sentence Id", + "description": "Unique identifier for the sentence" + }, + "fields": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "minProperties": 1, + "title": "Fields", + "description": "Key-value pairs to concatenate into text" + } + }, + "type": "object", + "required": [ + "sentence_id", + "fields" + ], + "title": "InferenceSentence", + "description": "A single sentence to classify.", + "example": { + "fields": { + "Industry Description": "agriculture own business", + "Job Description": "mixed vegetable farmer" + }, + "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + } + }, + "InferenceStatusResponse": { + "properties": { + "jobId": { + "type": "string", + "title": "Jobid", + "description": "Job identifier" + }, + "status": { + "$ref": "#/components/schemas/JobStatus", + "description": "Job status" + }, + "taxonomyKey": { + "type": "string", + "title": "Taxonomykey", + "description": "Taxonomy used" + }, + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable status message" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "title": "Createdat", + "description": "Job creation timestamp" + }, + "progress": { + "anyOf": [ + { + "type": "number", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Progress", + "description": "Progress percentage (0.0 to 1.0)" + }, + "startedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Startedat", + "description": "When job started running" + }, + "completedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Completedat", + "description": "When job completed" + }, + "failedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Failedat", + "description": "When job failed" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error", + "description": "Error message if failed" + }, + "result": { + "anyOf": [ + { + "$ref": "#/components/schemas/InferenceResult" + }, + { + "type": "null" + } + ], + "description": "Inference results if completed" + } + }, + "type": "object", + "required": [ + "jobId", + "status", + "taxonomyKey", + "message", + "createdAt" + ], + "title": "InferenceStatusResponse", + "description": "Response for inference job status query.", + "example": { + "completedAt": "2025-01-25T10:30:05Z", + "createdAt": "2025-01-25T10:30:00Z", + "jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "message": "Inference completed successfully", + "result": { + "results": [ + { + "predictions": { + "1": "Skilled Agricultural, Forestry and Fishery Workers", + "2": "Market-oriented Skilled Agricultural Workers", + "3": "Mixed Crop Growers", + "4": "Mixed Crop Growers" + }, + "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc", + "text": "Job Description: mixed vegetable farmer, Industry Description: agriculture own business" + } + ], + "taxonomyKey": "ISCO" + }, + "startedAt": "2025-01-25T10:30:01Z", + "status": "completed", + "taxonomyKey": "ISCO" + } + }, + "JobStatus": { + "type": "string", + "enum": [ + "pending", + "running", + "completed", + "failed", + "canceled" + ], + "title": "JobStatus", + "description": "Canonical status values for background jobs." + }, + "LabelingJobResponse": { + "properties": { + "job_id": { + "type": "string", + "title": "Job Id", + "description": "Unique identifier for the job" + }, + "batch_id": { + "type": "string", + "title": "Batch Id", + "description": "Batch identifier from request" + }, + "status": { + "$ref": "#/components/schemas/JobStatus", + "description": "Current status of the job" + }, + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable status message" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At", + "description": "When the job was created" + } + }, + "type": "object", + "required": [ + "job_id", + "batch_id", + "status", + "message", + "created_at" + ], + "title": "LabelingJobResponse", + "description": "Response returned when a labeling job is created.", + "example": { + "batch_id": "batch-20260211-001", + "created_at": "2026-02-11T12:00:00Z", + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "message": "Labeling job started", + "status": "pending" + } + }, + "LabelingRequest": { + "properties": { + "taxonomyKey": { + "type": "string", + "title": "Taxonomykey", + "description": "Taxonomy key to use for classification (e.g., ISCO)" + }, + "sourceSlug": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Sourceslug", + "description": "Optional source identifier for the caller. If omitted, inferred from Origin/Referer host (fallback: request host; e.g., subdomain.domani1.com -> subdomain-domani1)." + }, + "batchId": { + "type": "string", + "title": "Batchid", + "description": "Unique identifier for this batch" + }, + "sentences": { + "items": { + "$ref": "#/components/schemas/LabelingSentence" + }, + "type": "array", + "minItems": 1, + "title": "Sentences", + "description": "List of sentences to classify" + } + }, + "type": "object", + "required": [ + "taxonomyKey", + "batchId", + "sentences" + ], + "title": "LabelingRequest", + "description": "Request payload for batch labeling.", + "example": { + "batchId": "batch-20260211-001", + "sentences": [ + { + "fields": { + "Industry Description": "Ministry of Education", + "Job Description": "Primary school teacher" + }, + "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + } + ], + "sourceSlug": "subdomain-domani1", + "taxonomyKey": "ISCO" + } + }, + "LabelingResponse": { + "properties": { + "batchId": { + "type": "string", + "title": "Batchid", + "description": "Batch identifier" + }, + "suggestions": { + "items": { + "$ref": "#/components/schemas/SentenceSuggestion" + }, + "type": "array", + "title": "Suggestions", + "description": "Successfully classified sentences" + }, + "errors": { + "items": { + "$ref": "#/components/schemas/SentenceError" + }, + "type": "array", + "title": "Errors", + "description": "Failed sentences" + } + }, + "type": "object", + "required": [ + "batchId" + ], + "title": "LabelingResponse", + "description": "Final response after labeling is complete.", + "example": { + "batchId": "batch-20260211-001", + "errors": [], + "suggestions": [ + { + "annotations": [ + { + "confidence": 0.95, + "level": 1, + "nodeCode": "2" + }, + { + "confidence": 0.92, + "level": 2, + "nodeCode": "23" + }, + { + "confidence": 0.89, + "level": 3, + "nodeCode": "234" + }, + { + "confidence": 0.87, + "level": 4, + "nodeCode": "2341" + } + ], + "sentenceId": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + } + ] + } + }, + "LabelingSentence": { + "properties": { + "sentence_id": { + "type": "string", + "title": "Sentence Id", + "description": "Unique identifier for the sentence" + }, + "fields": { + "additionalProperties": true, + "type": "object", + "title": "Fields", + "description": "Dynamic fields (e.g., Job Description, Industry)" + } + }, + "type": "object", + "required": [ + "sentence_id", + "fields" + ], + "title": "LabelingSentence", + "description": "Individual sentence to be labeled.", + "example": { + "fields": { + "Industry Description": "Ministry of Education", + "Job Description": "Primary school teacher" + }, + "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + } + }, + "LabelingStatusResponse": { + "properties": { + "job_id": { + "type": "string", + "title": "Job Id", + "description": "Unique identifier for the job" + }, + "batch_id": { + "type": "string", + "title": "Batch Id", + "description": "Batch identifier from request" + }, + "status": { + "$ref": "#/components/schemas/JobStatus", + "description": "Current status of the job" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message", + "description": "Status message" + }, + "progress": { + "anyOf": [ + { + "type": "number", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Progress", + "description": "Progress percentage (0.0 to 1.0)" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error", + "description": "Error message if job failed" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At", + "description": "When the job was created" + }, + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Started At", + "description": "When the job started running" + }, + "completed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Completed At", + "description": "When the job completed or failed" + }, + "failed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Failed At", + "description": "When the job failed" + }, + "result": { + "anyOf": [ + { + "$ref": "#/components/schemas/LabelingResponse" + }, + { + "type": "null" + } + ], + "description": "Labeling results (only when completed)" + } + }, + "type": "object", + "required": [ + "job_id", + "batch_id", + "status", + "created_at" + ], + "title": "LabelingStatusResponse", + "description": "Response for labeling job status queries.", + "example": { + "batch_id": "batch-20260211-001", + "completed_at": "2026-02-11T12:00:08Z", + "created_at": "2026-02-11T12:00:00Z", + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "message": "Labeling completed successfully", + "progress": 1.0, + "result": { + "batchId": "batch-20260211-001", + "errors": [], + "suggestions": [ + { + "annotations": [ + { + "confidence": 0.95, + "level": 1, + "nodeCode": "2" + }, + { + "confidence": 0.92, + "level": 2, + "nodeCode": "23" + }, + { + "confidence": 0.89, + "level": 3, + "nodeCode": "234" + }, + { + "confidence": 0.87, + "level": 4, + "nodeCode": "2341" + } + ], + "sentenceId": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + } + ] + }, + "status": "completed" + } + }, + "LearningJobResponse": { + "properties": { + "jobId": { + "type": "string", + "title": "Jobid", + "description": "Unique job identifier" + }, + "status": { + "$ref": "#/components/schemas/JobStatus", + "description": "Job status" + }, + "taxonomyKey": { + "type": "string", + "title": "Taxonomykey", + "description": "Taxonomy identifier" + }, + "message": { + "type": "string", + "title": "Message", + "description": "Status message" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "title": "Createdat", + "description": "Job creation timestamp" + } + }, + "type": "object", + "required": [ + "jobId", + "status", + "taxonomyKey", + "message", + "createdAt" + ], + "title": "LearningJobResponse", + "description": "Response from POST /learn endpoint with job creation confirmation.\n\nAttributes:\n jobId: Unique identifier for the training job\n status: Current job status (always \"pending\" on creation)\n taxonomyKey: Taxonomy identifier\n message: Human-readable status message\n createdAt: Job creation timestamp", + "example": { + "createdAt": "2026-02-11T12:00:00Z", + "jobId": "550e8400-e29b-41d4-a716-446655440000", + "message": "Learning job initiated for taxonomy ISCO", + "status": "pending", + "taxonomyKey": "ISCO" + } + }, + "LearningRequest": { + "properties": { + "taxonomyKey": { + "type": "string", + "minLength": 1, + "title": "Taxonomykey", + "description": "Taxonomy identifier" + }, + "sourceSlug": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Sourceslug", + "description": "Optional source identifier for the caller. If omitted, inferred from Origin/Referer host (fallback: request host; e.g., subdomain.domani1.com -> subdomain-domani1)." + }, + "sentences": { + "items": { + "$ref": "#/components/schemas/TrainingSentence" + }, + "type": "array", + "minItems": 1, + "title": "Sentences", + "description": "Training sentences with annotations" + } + }, + "type": "object", + "required": [ + "taxonomyKey", + "sentences" + ], + "title": "LearningRequest", + "description": "Request payload for POST /learn endpoint.\n\nAttributes:\n taxonomyKey: Taxonomy identifier (e.g., \"ISCO\")\n sentences: List of training sentences with annotations", + "example": { + "sentences": [ + { + "annotations": [ + { + "level": 1, + "nodeCode": "2" + }, + { + "level": 2, + "nodeCode": "25" + }, + { + "level": 3, + "nodeCode": "251" + }, + { + "level": 4, + "nodeCode": "2512" + } + ], + "fields": { + "job_description": "Develops web applications", + "job_title": "Software Engineer" + }, + "sentenceId": "job-001" + } + ], + "sourceSlug": "subdomain-domani1", + "taxonomyKey": "ISCO" + } + }, + "LearningStatusResponse": { + "properties": { + "jobId": { + "type": "string", + "title": "Jobid", + "description": "Unique job identifier" + }, + "status": { + "$ref": "#/components/schemas/JobStatus", + "description": "Job status" + }, + "taxonomyKey": { + "type": "string", + "title": "Taxonomykey", + "description": "Taxonomy identifier" + }, + "message": { + "type": "string", + "title": "Message", + "description": "Status message" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "title": "Createdat", + "description": "Job creation timestamp" + }, + "startedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Startedat", + "description": "Job start timestamp" + }, + "completedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Completedat", + "description": "Job completion timestamp" + }, + "failedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Failedat", + "description": "Job failure timestamp" + }, + "progress": { + "anyOf": [ + { + "type": "number", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Progress", + "description": "Progress percentage (0.0 to 1.0)" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error", + "description": "Error message if failed" + }, + "result": { + "anyOf": [ + { + "$ref": "#/components/schemas/TrainingResult" + }, + { + "type": "null" + } + ], + "description": "Training results if completed" + } + }, + "type": "object", + "required": [ + "jobId", + "status", + "taxonomyKey", + "message", + "createdAt" + ], + "title": "LearningStatusResponse", + "description": "Response from GET /learn/{jobId}/status endpoint.\n\nAttributes:\n jobId: Unique job identifier\n status: Current job status (pending, running, completed, failed)\n taxonomyKey: Taxonomy identifier\n message: Human-readable status message\n createdAt: Job creation timestamp\n startedAt: Job start timestamp (if running/completed/failed)\n completedAt: Job completion timestamp (if completed)\n failedAt: Job failure timestamp (if failed)\n progress: Numeric progress from 0.0 to 1.0 (if available)\n error: Error message (if failed)\n result: Training results (if completed)", + "example": { + "completedAt": "2026-02-11T12:01:22Z", + "createdAt": "2026-02-11T12:00:00Z", + "jobId": "550e8400-e29b-41d4-a716-446655440000", + "message": "Training completed successfully", + "result": { + "modelVersion": "evidence_only", + "trainingDataStats": { + "receivedSamples": 15, + "taxonomyKey": "ISCO", + "updatedNodes": 9 + }, + "trainingMetrics": { + "levelsSummary": {}, + "totalLevels": 0 + } + }, + "startedAt": "2026-02-11T12:00:05Z", + "status": "completed", + "taxonomyKey": "ISCO" + } + }, + "PredictionResult": { + "properties": { + "sentence_id": { + "type": "string", + "title": "Sentence Id", + "description": "Sentence identifier" + }, + "text": { + "type": "string", + "title": "Text", + "description": "Concatenated text that was classified" + }, + "predictions": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Predictions", + "description": "Predicted labels by level {1: 'label1', 2: 'label2', ...}" + } + }, + "type": "object", + "required": [ + "sentence_id", + "text", + "predictions" + ], + "title": "PredictionResult", + "description": "Prediction result for a single sentence.", + "example": { + "predictions": { + "1": "Skilled Agricultural, Forestry and Fishery Workers", + "2": "Market-oriented Skilled Agricultural Workers", + "3": "Mixed Crop Growers", + "4": "Mixed Crop Growers" + }, + "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc", + "text": "Job Description: mixed vegetable farmer, Industry Description: agriculture own business" + } + }, + "SentenceError": { + "properties": { + "sentenceId": { + "type": "string", + "title": "Sentenceid", + "description": "Sentence identifier" + }, + "error": { + "type": "string", + "title": "Error", + "description": "Error message" + } + }, + "type": "object", + "required": [ + "sentenceId", + "error" + ], + "title": "SentenceError", + "description": "Error information for a failed sentence.", + "example": { + "error": "Unable to classify: empty text after concatenating fields", + "sentenceId": "sentence-002" + } + }, + "SentenceSuggestion": { + "properties": { + "sentenceId": { + "type": "string", + "title": "Sentenceid", + "description": "Sentence identifier" + }, + "annotations": { + "items": { + "$ref": "#/components/schemas/Annotation" + }, + "type": "array", + "title": "Annotations", + "description": "Annotations at each level" + } + }, + "type": "object", + "required": [ + "sentenceId", + "annotations" + ], + "title": "SentenceSuggestion", + "description": "Classification suggestions for a single sentence.", + "example": { + "annotations": [ + { + "confidence": 0.95, + "level": 1, + "nodeCode": "2" + }, + { + "confidence": 0.92, + "level": 2, + "nodeCode": "23" + }, + { + "confidence": 0.89, + "level": 3, + "nodeCode": "234" + }, + { + "confidence": 0.87, + "level": 4, + "nodeCode": "2341" + } + ], + "sentenceId": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + } + }, + "TaxonomyData": { + "properties": { + "key": { + "type": "string", + "title": "Key", + "description": "Canonical key for the taxonomy (e.g., ISCO)" + }, + "maxDepth": { + "type": "integer", + "minimum": 1.0, + "title": "Maxdepth", + "description": "Maximum depth of the taxonomy hierarchy" + }, + "levelNames": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Levelnames", + "description": "Mapping of level numbers to friendly names" + }, + "nodes": { + "items": { + "$ref": "#/components/schemas/TaxonomyNode" + }, + "type": "array", + "minItems": 1, + "title": "Nodes", + "description": "List of taxonomy nodes" + } + }, + "type": "object", + "required": [ + "key", + "maxDepth", + "nodes" + ], + "title": "TaxonomyData", + "description": "Complete taxonomy structure.", + "example": { + "key": "ISCO", + "levelNames": { + "1": "Major Group", + "2": "Sub-major Group", + "3": "Minor Group", + "4": "Unit Group" + }, + "maxDepth": 4, + "nodes": [ + { + "code": "2", + "definition": "", + "examples": "", + "isLeaf": false, + "label": "Professionals", + "level": 1 + }, + { + "code": "25", + "definition": "", + "examples": "", + "isLeaf": false, + "label": "Information and communications technology professionals", + "level": 2, + "parentCode": "2" + } + ] + } + }, + "TaxonomyJobResponse": { + "properties": { + "job_id": { + "type": "string", + "title": "Job Id", + "description": "Unique identifier for the job" + }, + "status": { + "$ref": "#/components/schemas/JobStatus", + "description": "Current status of the job" + }, + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable status message" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At", + "description": "When the job was created" + } + }, + "type": "object", + "required": [ + "job_id", + "status", + "message", + "created_at" + ], + "title": "TaxonomyJobResponse", + "description": "Response returned when a taxonomy job is created.", + "example": { + "created_at": "2026-02-11T12:00:00Z", + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "message": "Taxonomy processing started", + "status": "pending" + } + }, + "TaxonomyNode": { + "properties": { + "code": { + "type": "string", + "title": "Code", + "description": "Unique code for the taxonomy node" + }, + "level": { + "type": "integer", + "minimum": 1.0, + "title": "Level", + "description": "1-indexed depth of the node" + }, + "label": { + "type": "string", + "title": "Label", + "description": "Human-readable label for the node" + }, + "definition": { + "type": "string", + "title": "Definition", + "description": "Definition describing the node", + "default": "" + }, + "examples": { + "type": "string", + "title": "Examples", + "description": "Usage examples or sample descriptions", + "default": "" + }, + "parentCode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parentcode", + "description": "Parent code or null for root nodes" + }, + "isLeaf": { + "type": "boolean", + "title": "Isleaf", + "description": "Whether the node represents a terminal leaf", + "default": false + } + }, + "type": "object", + "required": [ + "code", + "level", + "label" + ], + "title": "TaxonomyNode", + "description": "Individual node in the taxonomy hierarchy.", + "example": { + "code": "2512", + "definition": "Develop and maintain software systems.", + "examples": "Backend developer, frontend developer", + "isLeaf": true, + "label": "Software developers", + "level": 4, + "parentCode": "251" + } + }, + "TaxonomyRequest": { + "properties": { + "action": { + "type": "string", + "const": "create", + "title": "Action", + "description": "Action to perform (currently only 'create' is supported)" + }, + "sourceSlug": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Sourceslug", + "description": "Optional source identifier for the caller. If omitted, inferred from Origin/Referer host (fallback: request host; e.g., subdomain.domani1.com -> subdomain-domani1)." + }, + "taxonomy": { + "$ref": "#/components/schemas/TaxonomyData", + "description": "Taxonomy data to process" + } + }, + "type": "object", + "required": [ + "action", + "taxonomy" + ], + "title": "TaxonomyRequest", + "description": "Request payload for taxonomy operations.", + "example": { + "action": "create", + "sourceSlug": "subdomain-domani1", + "taxonomy": { + "key": "ISCO", + "levelNames": { + "1": "Major Group", + "2": "Sub-major Group", + "3": "Minor Group", + "4": "Unit Group" + }, + "maxDepth": 4, + "nodes": [ + { + "code": "2", + "definition": "", + "examples": "", + "isLeaf": false, + "label": "Professionals", + "level": 1 + }, + { + "code": "25", + "definition": "", + "examples": "", + "isLeaf": false, + "label": "Information and communications technology professionals", + "level": 2, + "parentCode": "2" + } + ] + } + } + }, + "TaxonomyStatusResponse": { + "properties": { + "job_id": { + "type": "string", + "title": "Job Id", + "description": "Unique identifier for the job" + }, + "status": { + "$ref": "#/components/schemas/JobStatus", + "description": "Current status of the job" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message", + "description": "Status message" + }, + "progress": { + "anyOf": [ + { + "type": "number", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Progress", + "description": "Progress percentage (0.0 to 1.0)" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error", + "description": "Error message if job failed" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At", + "description": "When the job was created" + }, + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Started At", + "description": "When the job started running" + }, + "completed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Completed At", + "description": "When the job completed or failed" + }, + "failed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Failed At", + "description": "When the job failed" + } + }, + "type": "object", + "required": [ + "job_id", + "status", + "created_at" + ], + "title": "TaxonomyStatusResponse", + "description": "Response for job status queries.", + "example": { + "completed_at": "2026-02-11T12:01:10Z", + "created_at": "2026-02-11T12:00:00Z", + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "message": "Pipeline completed successfully", + "progress": 1.0, + "status": "completed" + } + }, + "TrainingAnnotation": { + "properties": { + "level": { + "type": "integer", + "minimum": 1.0, + "title": "Level", + "description": "Hierarchical level number" + }, + "nodeCode": { + "type": "string", + "minLength": 1, + "title": "Nodecode", + "description": "Taxonomy node code" + } + }, + "type": "object", + "required": [ + "level", + "nodeCode" + ], + "title": "TrainingAnnotation", + "description": "Annotation for a single hierarchical level.\n\nAttributes:\n level: Hierarchical level number (e.g., 1, 2, 3, 4)\n nodeCode: Taxonomy node code at this level (e.g., \"1\", \"11\", \"112\")", + "example": { + "level": 4, + "nodeCode": "2512" + } + }, + "TrainingResult": { + "properties": { + "modelVersion": { + "type": "string", + "title": "Modelversion", + "description": "Versioned model identifier" + }, + "trainingMetrics": { + "additionalProperties": true, + "type": "object", + "title": "Trainingmetrics", + "description": "Training metrics by level" + }, + "trainingDataStats": { + "additionalProperties": true, + "type": "object", + "title": "Trainingdatastats", + "description": "Training data statistics" + } + }, + "type": "object", + "required": [ + "modelVersion", + "trainingMetrics", + "trainingDataStats" + ], + "title": "TrainingResult", + "description": "Training result data for completed jobs.\n\nAttributes:\n modelVersion: Versioned model identifier (e.g., \"ISCO_v5_20250124T182045\")\n trainingMetrics: Metrics summary per level\n trainingDataStats: Statistics about training data", + "example": { + "modelVersion": "evidence_only", + "trainingDataStats": { + "receivedSamples": 15, + "taxonomyKey": "ISCO", + "updatedNodes": 9 + }, + "trainingMetrics": { + "levelsSummary": {}, + "totalLevels": 0 + } + } + }, + "TrainingSentence": { + "properties": { + "sentenceId": { + "type": "string", + "minLength": 1, + "title": "Sentenceid", + "description": "Unique sentence identifier" + }, + "fields": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "minProperties": 1, + "title": "Fields", + "description": "Text fields (e.g., job_title, job_description)" + }, + "annotations": { + "items": { + "$ref": "#/components/schemas/TrainingAnnotation" + }, + "type": "array", + "minItems": 1, + "title": "Annotations", + "description": "Hierarchical annotations" + } + }, + "type": "object", + "required": [ + "sentenceId", + "fields", + "annotations" + ], + "title": "TrainingSentence", + "description": "Training sentence with text fields and hierarchical annotations.\n\nAttributes:\n sentenceId: Unique identifier for this training sample\n fields: Dictionary of text fields to be concatenated for training\n annotations: List of annotations, one per hierarchical level", + "example": { + "annotations": [ + { + "level": 1, + "nodeCode": "2" + }, + { + "level": 2, + "nodeCode": "25" + }, + { + "level": 3, + "nodeCode": "251" + }, + { + "level": 4, + "nodeCode": "2512" + } + ], + "fields": { + "job_description": "Develops web applications", + "job_title": "Software Engineer" + }, + "sentenceId": "job-001" + } + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + }, + "securitySchemes": { + "HTTPBearer": { + "type": "http", + "scheme": "bearer" + } + } + } +} diff --git a/lib/contracts/taxomind.openapi.ts b/lib/contracts/taxomind.openapi.ts new file mode 100644 index 0000000..f582150 --- /dev/null +++ b/lib/contracts/taxomind.openapi.ts @@ -0,0 +1,2413 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Health Check + * @description Health check endpoint for monitoring and container orchestration. + */ + get: operations["health_check_health_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/taxonomies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Taxonomy + * @description Create a new taxonomy by triggering the taxonomy pipeline asynchronously. + * + * This endpoint accepts taxonomy data and immediately returns a job ID. + * The actual processing happens in the background and can take several + * minutes. + * + * Use the GET /taxonomies/{job_id}/status endpoint to check job status. + * + * Args: + * request: Taxonomy data including action and taxonomy structure + * background_tasks: FastAPI background tasks manager + * service: Taxonomy pipeline service + * job_store: Job status storage + * + * Returns: + * TaxonomyJobResponse with job_id and initial status + */ + post: operations["create_taxonomy_taxonomies_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/taxonomies/{taxonomy_key}/enrich": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Enrich Taxonomy + * @description Run the `enrich_taxonomy` pipeline asynchronously for one taxonomy. + */ + post: operations["enrich_taxonomy_taxonomies__taxonomy_key__enrich_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/taxonomies/{taxonomy_key}/build": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Build Taxonomy Index + * @description Run the `build_taxonomy` pipeline asynchronously for one taxonomy. + */ + post: operations["build_taxonomy_index_taxonomies__taxonomy_key__build_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/taxonomies/{job_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel Taxonomy Job + * @description Request cancellation for a taxonomy job. + */ + post: operations["cancel_taxonomy_job_taxonomies__job_id__cancel_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/taxonomies/{job_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Taxonomy Status + * @description Get the current status of a taxonomy processing job. + * + * Poll this endpoint to check if the taxonomy pipeline has completed. + * + * Args: + * job_id: The job ID returned from the POST /taxonomies endpoint + * job_store: Job status storage + * + * Returns: + * TaxonomyStatusResponse with current job status and progress + * + * Raises: + * HTTPException: 404 if job_id is not found + */ + get: operations["get_taxonomy_status_taxonomies__job_id__status_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/classify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Inference Job + * @description Create an inference job for hierarchical classification. + * + * This endpoint accepts sentences with field dictionaries, concatenates them into text, + * and classifies them using trained ye models for all hierarchical levels. + * + * **Authentication**: Requires Bearer token in Authorization header. + * + * **Request Format**: + * ```json + * { + * "taxonomyKey": "ISCO", + * "sentences": [ + * { + * "sentence_id": "unique-id-1", + * "fields": { + * "Job Description": "Primary school teacher", + * "Industry Description": "Ministry of Education" + * } + * } + * ] + * } + * ``` + * + * **Response**: Returns job ID for polling status via `/classify/{jobId}/status` + * + * **Processing**: + * 1. Validates payload structure + * 2. Concatenates fields into text (e.g., "Job Description: ..., Industry Description: ...") + * 3. Loads trained models for the taxonomy + * 4. Performs hierarchical inference (predicts Level 1, 2, 3, 4) + * 5. Returns predictions for all levels + * + * **Notes**: + * - Models must be trained first using `/learn` endpoint + * - Processing is asynchronous; poll for results + * - All field values are concatenated with field names + */ + post: operations["create_inference_job_classify_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/classify/{job_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel Inference Job + * @description Request cancellation for an inference job. + */ + post: operations["cancel_inference_job_classify__job_id__cancel_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/classify/{job_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Inference Status + * @description Get the status of an inference job. + * + * **Authentication**: Requires Bearer token in Authorization header. + * + * **Job States**: + * - `pending`: Job created, waiting to start + * - `running`: Inference in progress + * - `completed`: Inference finished successfully (includes results) + * - `failed`: Inference failed (includes error message) + * + * **Example Response** (completed): + * ```json + * { + * "jobId": "...", + * "status": "completed", + * "taxonomyKey": "ISCO", + * "message": "Inference completed successfully", + * "createdAt": "2025-01-25T10:30:00Z", + * "completedAt": "2025-01-25T10:30:05Z", + * "result": { + * "taxonomyKey": "ISCO", + * "results": [ + * { + * "sentence_id": "unique-id-1", + * "text": "Job Description: Primary school teacher, Industry Description: Ministry of Education", + * "predictions": { + * "1": "Professionals", + * "2": "Teaching Professionals", + * "3": "Primary School and Early Childhood Teachers", + * "4": "Primary School Teachers" + * } + * } + * ] + * } + * } + * ``` + * + * **Polling Recommendation**: Poll every 2-5 seconds until status is `completed` or `failed` + */ + get: operations["get_inference_status_classify__job_id__status_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/label": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Labeling Job + * @description Create a new labeling job by triggering the inference pipeline asynchronously. + * + * This endpoint accepts a batch of sentences and immediately returns a + * job ID. The actual classification happens in the background and can + * take several minutes depending on batch size and model configuration. + * + * Use the GET /label/{job_id}/status endpoint to check job status. + * + * Args: + * request: Labeling request with taxonomy key, batch ID, and sentences + * background_tasks: FastAPI background tasks manager + * service: Labeling pipeline service + * job_store: Job status storage + * + * Returns: + * LabelingJobResponse with job_id and initial status + */ + post: operations["create_labeling_job_label_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/label/{job_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel Labeling Job + * @description Request cancellation for a labeling job. + */ + post: operations["cancel_labeling_job_label__job_id__cancel_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/label/{job_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Labeling Status + * @description Get the current status of a labeling job. + * + * Poll this endpoint to check if the labeling pipeline has completed. + * + * Args: + * job_id: The job ID returned from the POST /label endpoint + * job_store: Job status storage + * + * Returns: + * LabelingStatusResponse with current job status, progress, + * and results (if completed) + * + * Raises: + * HTTPException: 404 if job_id is not found + */ + get: operations["get_labeling_status_label__job_id__status_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/learn": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Learning Job + * @description Create a new incremental learning job by triggering the learning pipeline asynchronously. + * + * This endpoint accepts training sentences with annotations and immediately returns a + * job ID. The evidence update happens in the background and can take several + * minutes depending on dataset size. + * + * The learning process will: + * 1. Validate the payload + * 2. Embed the corrected texts + * 3. Update per-node evidence centroids (no ancestor drift) + * + * Use the GET /learn/{job_id}/status endpoint to poll for job completion. + * + * Args: + * request: Learning request with taxonomy key and training sentences + * background_tasks: FastAPI background tasks manager + * service: Learning pipeline service + * job_store: Job status storage + * + * Returns: + * LearningJobResponse with job_id and initial status (HTTP 202 Accepted) + * + * Example: + * ``` + * POST /learn + * { + * "taxonomyKey": "ISCO", + * "sentences": [ + * { + * "sentenceId": "job-001", + * "fields": { + * "job_title": "Software Engineer", + * "job_description": "Develops web applications" + * }, + * "annotations": [ + * {"level": 1, "nodeCode": "2"}, + * {"level": 2, "nodeCode": "25"}, + * {"level": 3, "nodeCode": "251"}, + * {"level": 4, "nodeCode": "2512"} + * ] + * } + * ] + * } + * ``` + */ + post: operations["create_learning_job_learn_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/learn/{job_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel Learning Job + * @description Request cancellation for a learning job. + */ + post: operations["cancel_learning_job_learn__job_id__cancel_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/learn/{job_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Learning Status + * @description Get the current status of an incremental training job. + * + * Poll this endpoint to check if the training pipeline has completed. + * Recommended polling interval: 5-10 seconds for jobs expected to complete + * within minutes, or 30-60 seconds for longer training runs. + * + * Response varies by job status: + * - **pending**: Job queued but not yet started + * - **running**: Training in progress (includes progress information) + * - **completed**: Training finished successfully (includes model version and metrics) + * - **failed**: Training failed (includes error message) + * + * Args: + * job_id: The job ID returned from the POST /learn endpoint + * job_store: Job status storage + * + * Returns: + * LearningStatusResponse with current job status, progress, + * and results (if completed) + * + * Raises: + * HTTPException: 404 if job_id is not found + * + * Example completed response: + * ``` + * { + * "jobId": "550e8400-e29b-41d4-a716-446655440000", + * "status": "completed", + * "taxonomyKey": "ISCO", + * "message": "Training completed successfully", + * "createdAt": "2025-01-24T18:20:45.123Z", + * "startedAt": "2025-01-24T18:20:47.456Z", + * "completedAt": "2025-01-24T18:35:12.789Z", + * "result": { + * "modelVersion": "ISCO_v5_20250124T182045", + * "trainingMetrics": { + * "totalLevels": 4, + * "levelsSummary": { + * "1": {"accuracy": 0.95, "f1_score": 0.94, "training_mode": "standard"} + * } + * }, + * "trainingDataStats": { + * "newSamples": 15, + * "totalSamples": 150, + * "appendedToExisting": true + * } + * } + * } + * ``` + */ + get: operations["get_learning_status_learn__job_id__status_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/error-analysis": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Run Error Analysis + * @description Run the `error_analysis` pipeline asynchronously. + */ + post: operations["run_error_analysis_error_analysis_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/error-analysis/{job_id}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Cancel Error Analysis Job + * @description Request cancellation for an error analysis job. + */ + post: operations["cancel_error_analysis_job_error_analysis__job_id__cancel_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/error-analysis/{job_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Error Analysis Status + * @description Get the current status of an error analysis job. + */ + get: operations["get_error_analysis_status_error_analysis__job_id__status_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * Annotation + * @description Single annotation at a specific level. + * @example { + * "confidence": 0.87, + * "level": 4, + * "nodeCode": "2341" + * } + */ + Annotation: { + /** + * Level + * @description Taxonomy level + */ + level: number; + /** + * Nodecode + * @description Node code (-99 for unknown) + */ + nodeCode: string; + /** + * Confidence + * @description Confidence score + */ + confidence: number; + }; + /** + * ErrorAnalysisJobResponse + * @description Response returned when an error-analysis job is created. + * @example { + * "created_at": "2026-02-11T12:00:00Z", + * "job_id": "550e8400-e29b-41d4-a716-446655440000", + * "message": "Error analysis started", + * "status": "pending" + * } + */ + ErrorAnalysisJobResponse: { + /** + * Job Id + * @description Unique identifier for the job + */ + job_id: string; + /** @description Current status of the job */ + status: components["schemas"]["JobStatus"]; + /** + * Message + * @description Human-readable status message + */ + message: string; + /** + * Created At + * Format: date-time + * @description When the job was created + */ + created_at: string; + }; + /** + * ErrorAnalysisStatusResponse + * @description Response for error-analysis job status queries. + * @example { + * "completed_at": "2026-02-11T12:00:11Z", + * "created_at": "2026-02-11T12:00:00Z", + * "job_id": "550e8400-e29b-41d4-a716-446655440000", + * "message": "Error analysis completed successfully", + * "progress": 1, + * "result": { + * "rows": { + * "classifai": 420, + * "taxonomy_training": 420, + * "training_sentences": 420 + * }, + * "taxonomy_key_counts": { + * "classifai": { + * "ISCO": 420 + * }, + * "taxonomy_training": { + * "ISCO": 420 + * }, + * "training_sentences": { + * "ISCO": 420 + * } + * } + * }, + * "status": "completed" + * } + */ + ErrorAnalysisStatusResponse: { + /** + * Job Id + * @description Unique identifier for the job + */ + job_id: string; + /** @description Current status of the job */ + status: components["schemas"]["JobStatus"]; + /** + * Message + * @description Status message + */ + message?: string | null; + /** + * Progress + * @description Progress percentage (0.0 to 1.0) + */ + progress?: number | null; + /** + * Error + * @description Error message if job failed + */ + error?: string | null; + /** + * Created At + * Format: date-time + * @description When the job was created + */ + created_at: string; + /** + * Started At + * @description When the job started running + */ + started_at?: string | null; + /** + * Completed At + * @description When the job completed or failed + */ + completed_at?: string | null; + /** + * Failed At + * @description When the job failed + */ + failed_at?: string | null; + /** + * Result + * @description Error analysis results (only when completed) + */ + result?: { + [key: string]: unknown; + } | null; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** + * InferenceJobResponse + * @description Response when creating an inference job. + * @example { + * "createdAt": "2025-01-25T10:30:00Z", + * "jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + * "message": "Inference job created", + * "status": "pending", + * "taxonomyKey": "ISCO" + * } + */ + InferenceJobResponse: { + /** + * Jobid + * @description Unique job identifier + */ + jobId: string; + /** @description Job status */ + status: components["schemas"]["JobStatus"]; + /** + * Taxonomykey + * @description Taxonomy used for classification + */ + taxonomyKey: string; + /** + * Message + * @description Human-readable status message + */ + message: string; + /** + * Createdat + * Format: date-time + * @description Job creation timestamp + */ + createdAt: string; + }; + /** + * InferenceRequest + * @description Request payload for classification inference. + * @example { + * "sentences": [ + * { + * "fields": { + * "Industry Description": "agriculture own business", + * "Job Description": "mixed vegetable farmer" + * }, + * "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + * }, + * { + * "fields": { + * "Industry Description": "Ministry of Education", + * "Job Description": "Primary school teacher" + * }, + * "sentence_id": "9043b0ea-ebda-4cfb-8b96-cb1db4872417" + * } + * ], + * "sourceSlug": "subdomain-domani1", + * "taxonomyKey": "ISCO" + * } + */ + InferenceRequest: { + /** + * Taxonomykey + * @description Taxonomy to use for classification (e.g., 'ISCO') + */ + taxonomyKey: string; + /** + * Sourceslug + * @description Optional source identifier for the caller. If omitted, inferred from Origin/Referer host (fallback: request host; e.g., subdomain.domani1.com -> subdomain-domani1). + */ + sourceSlug?: string | null; + /** + * Sentences + * @description List of sentences to classify + */ + sentences: components["schemas"]["InferenceSentence"][]; + }; + /** + * InferenceResult + * @description Complete inference result. + * @example { + * "results": [ + * { + * "predictions": { + * "1": "Skilled Agricultural, Forestry and Fishery Workers", + * "2": "Market-oriented Skilled Agricultural Workers", + * "3": "Mixed Crop Growers", + * "4": "Mixed Crop Growers" + * }, + * "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc", + * "text": "Job Description: mixed vegetable farmer, Industry Description: agriculture own business" + * } + * ], + * "taxonomyKey": "ISCO" + * } + */ + InferenceResult: { + /** + * Taxonomykey + * @description Taxonomy used + */ + taxonomyKey: string; + /** + * Results + * @description Predictions for all sentences + */ + results: components["schemas"]["PredictionResult"][]; + }; + /** + * InferenceSentence + * @description A single sentence to classify. + * @example { + * "fields": { + * "Industry Description": "agriculture own business", + * "Job Description": "mixed vegetable farmer" + * }, + * "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + * } + */ + InferenceSentence: { + /** + * Sentence Id + * @description Unique identifier for the sentence + */ + sentence_id: string; + /** + * Fields + * @description Key-value pairs to concatenate into text + */ + fields: { + [key: string]: string; + }; + }; + /** + * InferenceStatusResponse + * @description Response for inference job status query. + * @example { + * "completedAt": "2025-01-25T10:30:05Z", + * "createdAt": "2025-01-25T10:30:00Z", + * "jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + * "message": "Inference completed successfully", + * "result": { + * "results": [ + * { + * "predictions": { + * "1": "Skilled Agricultural, Forestry and Fishery Workers", + * "2": "Market-oriented Skilled Agricultural Workers", + * "3": "Mixed Crop Growers", + * "4": "Mixed Crop Growers" + * }, + * "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc", + * "text": "Job Description: mixed vegetable farmer, Industry Description: agriculture own business" + * } + * ], + * "taxonomyKey": "ISCO" + * }, + * "startedAt": "2025-01-25T10:30:01Z", + * "status": "completed", + * "taxonomyKey": "ISCO" + * } + */ + InferenceStatusResponse: { + /** + * Jobid + * @description Job identifier + */ + jobId: string; + /** @description Job status */ + status: components["schemas"]["JobStatus"]; + /** + * Taxonomykey + * @description Taxonomy used + */ + taxonomyKey: string; + /** + * Message + * @description Human-readable status message + */ + message: string; + /** + * Createdat + * Format: date-time + * @description Job creation timestamp + */ + createdAt: string; + /** + * Progress + * @description Progress percentage (0.0 to 1.0) + */ + progress?: number | null; + /** + * Startedat + * @description When job started running + */ + startedAt?: string | null; + /** + * Completedat + * @description When job completed + */ + completedAt?: string | null; + /** + * Failedat + * @description When job failed + */ + failedAt?: string | null; + /** + * Error + * @description Error message if failed + */ + error?: string | null; + /** @description Inference results if completed */ + result?: components["schemas"]["InferenceResult"] | null; + }; + /** + * JobStatus + * @description Canonical status values for background jobs. + * @enum {string} + */ + JobStatus: "pending" | "running" | "completed" | "failed" | "canceled"; + /** + * LabelingJobResponse + * @description Response returned when a labeling job is created. + * @example { + * "batch_id": "batch-20260211-001", + * "created_at": "2026-02-11T12:00:00Z", + * "job_id": "550e8400-e29b-41d4-a716-446655440000", + * "message": "Labeling job started", + * "status": "pending" + * } + */ + LabelingJobResponse: { + /** + * Job Id + * @description Unique identifier for the job + */ + job_id: string; + /** + * Batch Id + * @description Batch identifier from request + */ + batch_id: string; + /** @description Current status of the job */ + status: components["schemas"]["JobStatus"]; + /** + * Message + * @description Human-readable status message + */ + message: string; + /** + * Created At + * Format: date-time + * @description When the job was created + */ + created_at: string; + }; + /** + * LabelingRequest + * @description Request payload for batch labeling. + * @example { + * "batchId": "batch-20260211-001", + * "sentences": [ + * { + * "fields": { + * "Industry Description": "Ministry of Education", + * "Job Description": "Primary school teacher" + * }, + * "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + * } + * ], + * "sourceSlug": "subdomain-domani1", + * "taxonomyKey": "ISCO" + * } + */ + LabelingRequest: { + /** + * Taxonomykey + * @description Taxonomy key to use for classification (e.g., ISCO) + */ + taxonomyKey: string; + /** + * Sourceslug + * @description Optional source identifier for the caller. If omitted, inferred from Origin/Referer host (fallback: request host; e.g., subdomain.domani1.com -> subdomain-domani1). + */ + sourceSlug?: string | null; + /** + * Batchid + * @description Unique identifier for this batch + */ + batchId: string; + /** + * Sentences + * @description List of sentences to classify + */ + sentences: components["schemas"]["LabelingSentence"][]; + }; + /** + * LabelingResponse + * @description Final response after labeling is complete. + * @example { + * "batchId": "batch-20260211-001", + * "errors": [], + * "suggestions": [ + * { + * "annotations": [ + * { + * "confidence": 0.95, + * "level": 1, + * "nodeCode": "2" + * }, + * { + * "confidence": 0.92, + * "level": 2, + * "nodeCode": "23" + * }, + * { + * "confidence": 0.89, + * "level": 3, + * "nodeCode": "234" + * }, + * { + * "confidence": 0.87, + * "level": 4, + * "nodeCode": "2341" + * } + * ], + * "sentenceId": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + * } + * ] + * } + */ + LabelingResponse: { + /** + * Batchid + * @description Batch identifier + */ + batchId: string; + /** + * Suggestions + * @description Successfully classified sentences + */ + suggestions?: components["schemas"]["SentenceSuggestion"][]; + /** + * Errors + * @description Failed sentences + */ + errors?: components["schemas"]["SentenceError"][]; + }; + /** + * LabelingSentence + * @description Individual sentence to be labeled. + * @example { + * "fields": { + * "Industry Description": "Ministry of Education", + * "Job Description": "Primary school teacher" + * }, + * "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + * } + */ + LabelingSentence: { + /** + * Sentence Id + * @description Unique identifier for the sentence + */ + sentence_id: string; + /** + * Fields + * @description Dynamic fields (e.g., Job Description, Industry) + */ + fields: { + [key: string]: unknown; + }; + }; + /** + * LabelingStatusResponse + * @description Response for labeling job status queries. + * @example { + * "batch_id": "batch-20260211-001", + * "completed_at": "2026-02-11T12:00:08Z", + * "created_at": "2026-02-11T12:00:00Z", + * "job_id": "550e8400-e29b-41d4-a716-446655440000", + * "message": "Labeling completed successfully", + * "progress": 1, + * "result": { + * "batchId": "batch-20260211-001", + * "errors": [], + * "suggestions": [ + * { + * "annotations": [ + * { + * "confidence": 0.95, + * "level": 1, + * "nodeCode": "2" + * }, + * { + * "confidence": 0.92, + * "level": 2, + * "nodeCode": "23" + * }, + * { + * "confidence": 0.89, + * "level": 3, + * "nodeCode": "234" + * }, + * { + * "confidence": 0.87, + * "level": 4, + * "nodeCode": "2341" + * } + * ], + * "sentenceId": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + * } + * ] + * }, + * "status": "completed" + * } + */ + LabelingStatusResponse: { + /** + * Job Id + * @description Unique identifier for the job + */ + job_id: string; + /** + * Batch Id + * @description Batch identifier from request + */ + batch_id: string; + /** @description Current status of the job */ + status: components["schemas"]["JobStatus"]; + /** + * Message + * @description Status message + */ + message?: string | null; + /** + * Progress + * @description Progress percentage (0.0 to 1.0) + */ + progress?: number | null; + /** + * Error + * @description Error message if job failed + */ + error?: string | null; + /** + * Created At + * Format: date-time + * @description When the job was created + */ + created_at: string; + /** + * Started At + * @description When the job started running + */ + started_at?: string | null; + /** + * Completed At + * @description When the job completed or failed + */ + completed_at?: string | null; + /** + * Failed At + * @description When the job failed + */ + failed_at?: string | null; + /** @description Labeling results (only when completed) */ + result?: components["schemas"]["LabelingResponse"] | null; + }; + /** + * LearningJobResponse + * @description Response from POST /learn endpoint with job creation confirmation. + * + * Attributes: + * jobId: Unique identifier for the training job + * status: Current job status (always "pending" on creation) + * taxonomyKey: Taxonomy identifier + * message: Human-readable status message + * createdAt: Job creation timestamp + * @example { + * "createdAt": "2026-02-11T12:00:00Z", + * "jobId": "550e8400-e29b-41d4-a716-446655440000", + * "message": "Learning job initiated for taxonomy ISCO", + * "status": "pending", + * "taxonomyKey": "ISCO" + * } + */ + LearningJobResponse: { + /** + * Jobid + * @description Unique job identifier + */ + jobId: string; + /** @description Job status */ + status: components["schemas"]["JobStatus"]; + /** + * Taxonomykey + * @description Taxonomy identifier + */ + taxonomyKey: string; + /** + * Message + * @description Status message + */ + message: string; + /** + * Createdat + * Format: date-time + * @description Job creation timestamp + */ + createdAt: string; + }; + /** + * LearningRequest + * @description Request payload for POST /learn endpoint. + * + * Attributes: + * taxonomyKey: Taxonomy identifier (e.g., "ISCO") + * sentences: List of training sentences with annotations + * @example { + * "sentences": [ + * { + * "annotations": [ + * { + * "level": 1, + * "nodeCode": "2" + * }, + * { + * "level": 2, + * "nodeCode": "25" + * }, + * { + * "level": 3, + * "nodeCode": "251" + * }, + * { + * "level": 4, + * "nodeCode": "2512" + * } + * ], + * "fields": { + * "job_description": "Develops web applications", + * "job_title": "Software Engineer" + * }, + * "sentenceId": "job-001" + * } + * ], + * "sourceSlug": "subdomain-domani1", + * "taxonomyKey": "ISCO" + * } + */ + LearningRequest: { + /** + * Taxonomykey + * @description Taxonomy identifier + */ + taxonomyKey: string; + /** + * Sourceslug + * @description Optional source identifier for the caller. If omitted, inferred from Origin/Referer host (fallback: request host; e.g., subdomain.domani1.com -> subdomain-domani1). + */ + sourceSlug?: string | null; + /** + * Sentences + * @description Training sentences with annotations + */ + sentences: components["schemas"]["TrainingSentence"][]; + }; + /** + * LearningStatusResponse + * @description Response from GET /learn/{jobId}/status endpoint. + * + * Attributes: + * jobId: Unique job identifier + * status: Current job status (pending, running, completed, failed) + * taxonomyKey: Taxonomy identifier + * message: Human-readable status message + * createdAt: Job creation timestamp + * startedAt: Job start timestamp (if running/completed/failed) + * completedAt: Job completion timestamp (if completed) + * failedAt: Job failure timestamp (if failed) + * progress: Numeric progress from 0.0 to 1.0 (if available) + * error: Error message (if failed) + * result: Training results (if completed) + * @example { + * "completedAt": "2026-02-11T12:01:22Z", + * "createdAt": "2026-02-11T12:00:00Z", + * "jobId": "550e8400-e29b-41d4-a716-446655440000", + * "message": "Training completed successfully", + * "result": { + * "modelVersion": "evidence_only", + * "trainingDataStats": { + * "receivedSamples": 15, + * "taxonomyKey": "ISCO", + * "updatedNodes": 9 + * }, + * "trainingMetrics": { + * "levelsSummary": {}, + * "totalLevels": 0 + * } + * }, + * "startedAt": "2026-02-11T12:00:05Z", + * "status": "completed", + * "taxonomyKey": "ISCO" + * } + */ + LearningStatusResponse: { + /** + * Jobid + * @description Unique job identifier + */ + jobId: string; + /** @description Job status */ + status: components["schemas"]["JobStatus"]; + /** + * Taxonomykey + * @description Taxonomy identifier + */ + taxonomyKey: string; + /** + * Message + * @description Status message + */ + message: string; + /** + * Createdat + * Format: date-time + * @description Job creation timestamp + */ + createdAt: string; + /** + * Startedat + * @description Job start timestamp + */ + startedAt?: string | null; + /** + * Completedat + * @description Job completion timestamp + */ + completedAt?: string | null; + /** + * Failedat + * @description Job failure timestamp + */ + failedAt?: string | null; + /** + * Progress + * @description Progress percentage (0.0 to 1.0) + */ + progress?: number | null; + /** + * Error + * @description Error message if failed + */ + error?: string | null; + /** @description Training results if completed */ + result?: components["schemas"]["TrainingResult"] | null; + }; + /** + * PredictionResult + * @description Prediction result for a single sentence. + * @example { + * "predictions": { + * "1": "Skilled Agricultural, Forestry and Fishery Workers", + * "2": "Market-oriented Skilled Agricultural Workers", + * "3": "Mixed Crop Growers", + * "4": "Mixed Crop Growers" + * }, + * "sentence_id": "670ff0e7-d10e-430c-90e5-729a7e362ecc", + * "text": "Job Description: mixed vegetable farmer, Industry Description: agriculture own business" + * } + */ + PredictionResult: { + /** + * Sentence Id + * @description Sentence identifier + */ + sentence_id: string; + /** + * Text + * @description Concatenated text that was classified + */ + text: string; + /** + * Predictions + * @description Predicted labels by level {1: 'label1', 2: 'label2', ...} + */ + predictions: { + [key: string]: string; + }; + }; + /** + * SentenceError + * @description Error information for a failed sentence. + * @example { + * "error": "Unable to classify: empty text after concatenating fields", + * "sentenceId": "sentence-002" + * } + */ + SentenceError: { + /** + * Sentenceid + * @description Sentence identifier + */ + sentenceId: string; + /** + * Error + * @description Error message + */ + error: string; + }; + /** + * SentenceSuggestion + * @description Classification suggestions for a single sentence. + * @example { + * "annotations": [ + * { + * "confidence": 0.95, + * "level": 1, + * "nodeCode": "2" + * }, + * { + * "confidence": 0.92, + * "level": 2, + * "nodeCode": "23" + * }, + * { + * "confidence": 0.89, + * "level": 3, + * "nodeCode": "234" + * }, + * { + * "confidence": 0.87, + * "level": 4, + * "nodeCode": "2341" + * } + * ], + * "sentenceId": "670ff0e7-d10e-430c-90e5-729a7e362ecc" + * } + */ + SentenceSuggestion: { + /** + * Sentenceid + * @description Sentence identifier + */ + sentenceId: string; + /** + * Annotations + * @description Annotations at each level + */ + annotations: components["schemas"]["Annotation"][]; + }; + /** + * TaxonomyData + * @description Complete taxonomy structure. + * @example { + * "key": "ISCO", + * "levelNames": { + * "1": "Major Group", + * "2": "Sub-major Group", + * "3": "Minor Group", + * "4": "Unit Group" + * }, + * "maxDepth": 4, + * "nodes": [ + * { + * "code": "2", + * "definition": "", + * "examples": "", + * "isLeaf": false, + * "label": "Professionals", + * "level": 1 + * }, + * { + * "code": "25", + * "definition": "", + * "examples": "", + * "isLeaf": false, + * "label": "Information and communications technology professionals", + * "level": 2, + * "parentCode": "2" + * } + * ] + * } + */ + TaxonomyData: { + /** + * Key + * @description Canonical key for the taxonomy (e.g., ISCO) + */ + key: string; + /** + * Maxdepth + * @description Maximum depth of the taxonomy hierarchy + */ + maxDepth: number; + /** + * Levelnames + * @description Mapping of level numbers to friendly names + */ + levelNames?: { + [key: string]: string; + }; + /** + * Nodes + * @description List of taxonomy nodes + */ + nodes: components["schemas"]["TaxonomyNode"][]; + }; + /** + * TaxonomyJobResponse + * @description Response returned when a taxonomy job is created. + * @example { + * "created_at": "2026-02-11T12:00:00Z", + * "job_id": "550e8400-e29b-41d4-a716-446655440000", + * "message": "Taxonomy processing started", + * "status": "pending" + * } + */ + TaxonomyJobResponse: { + /** + * Job Id + * @description Unique identifier for the job + */ + job_id: string; + /** @description Current status of the job */ + status: components["schemas"]["JobStatus"]; + /** + * Message + * @description Human-readable status message + */ + message: string; + /** + * Created At + * Format: date-time + * @description When the job was created + */ + created_at: string; + }; + /** + * TaxonomyNode + * @description Individual node in the taxonomy hierarchy. + * @example { + * "code": "2512", + * "definition": "Develop and maintain software systems.", + * "examples": "Backend developer, frontend developer", + * "isLeaf": true, + * "label": "Software developers", + * "level": 4, + * "parentCode": "251" + * } + */ + TaxonomyNode: { + /** + * Code + * @description Unique code for the taxonomy node + */ + code: string; + /** + * Level + * @description 1-indexed depth of the node + */ + level: number; + /** + * Label + * @description Human-readable label for the node + */ + label: string; + /** + * Definition + * @description Definition describing the node + * @default + */ + definition: string; + /** + * Examples + * @description Usage examples or sample descriptions + * @default + */ + examples: string; + /** + * Parentcode + * @description Parent code or null for root nodes + */ + parentCode?: string | null; + /** + * Isleaf + * @description Whether the node represents a terminal leaf + * @default false + */ + isLeaf: boolean; + }; + /** + * TaxonomyRequest + * @description Request payload for taxonomy operations. + * @example { + * "action": "create", + * "sourceSlug": "subdomain-domani1", + * "taxonomy": { + * "key": "ISCO", + * "levelNames": { + * "1": "Major Group", + * "2": "Sub-major Group", + * "3": "Minor Group", + * "4": "Unit Group" + * }, + * "maxDepth": 4, + * "nodes": [ + * { + * "code": "2", + * "definition": "", + * "examples": "", + * "isLeaf": false, + * "label": "Professionals", + * "level": 1 + * }, + * { + * "code": "25", + * "definition": "", + * "examples": "", + * "isLeaf": false, + * "label": "Information and communications technology professionals", + * "level": 2, + * "parentCode": "2" + * } + * ] + * } + * } + */ + TaxonomyRequest: { + /** + * Action + * @description Action to perform (currently only 'create' is supported) + * @constant + */ + action: "create"; + /** + * Sourceslug + * @description Optional source identifier for the caller. If omitted, inferred from Origin/Referer host (fallback: request host; e.g., subdomain.domani1.com -> subdomain-domani1). + */ + sourceSlug?: string | null; + /** @description Taxonomy data to process */ + taxonomy: components["schemas"]["TaxonomyData"]; + }; + /** + * TaxonomyStatusResponse + * @description Response for job status queries. + * @example { + * "completed_at": "2026-02-11T12:01:10Z", + * "created_at": "2026-02-11T12:00:00Z", + * "job_id": "550e8400-e29b-41d4-a716-446655440000", + * "message": "Pipeline completed successfully", + * "progress": 1, + * "status": "completed" + * } + */ + TaxonomyStatusResponse: { + /** + * Job Id + * @description Unique identifier for the job + */ + job_id: string; + /** @description Current status of the job */ + status: components["schemas"]["JobStatus"]; + /** + * Message + * @description Status message + */ + message?: string | null; + /** + * Progress + * @description Progress percentage (0.0 to 1.0) + */ + progress?: number | null; + /** + * Error + * @description Error message if job failed + */ + error?: string | null; + /** + * Created At + * Format: date-time + * @description When the job was created + */ + created_at: string; + /** + * Started At + * @description When the job started running + */ + started_at?: string | null; + /** + * Completed At + * @description When the job completed or failed + */ + completed_at?: string | null; + /** + * Failed At + * @description When the job failed + */ + failed_at?: string | null; + }; + /** + * TrainingAnnotation + * @description Annotation for a single hierarchical level. + * + * Attributes: + * level: Hierarchical level number (e.g., 1, 2, 3, 4) + * nodeCode: Taxonomy node code at this level (e.g., "1", "11", "112") + * @example { + * "level": 4, + * "nodeCode": "2512" + * } + */ + TrainingAnnotation: { + /** + * Level + * @description Hierarchical level number + */ + level: number; + /** + * Nodecode + * @description Taxonomy node code + */ + nodeCode: string; + }; + /** + * TrainingResult + * @description Training result data for completed jobs. + * + * Attributes: + * modelVersion: Versioned model identifier (e.g., "ISCO_v5_20250124T182045") + * trainingMetrics: Metrics summary per level + * trainingDataStats: Statistics about training data + * @example { + * "modelVersion": "evidence_only", + * "trainingDataStats": { + * "receivedSamples": 15, + * "taxonomyKey": "ISCO", + * "updatedNodes": 9 + * }, + * "trainingMetrics": { + * "levelsSummary": {}, + * "totalLevels": 0 + * } + * } + */ + TrainingResult: { + /** + * Modelversion + * @description Versioned model identifier + */ + modelVersion: string; + /** + * Trainingmetrics + * @description Training metrics by level + */ + trainingMetrics: { + [key: string]: unknown; + }; + /** + * Trainingdatastats + * @description Training data statistics + */ + trainingDataStats: { + [key: string]: unknown; + }; + }; + /** + * TrainingSentence + * @description Training sentence with text fields and hierarchical annotations. + * + * Attributes: + * sentenceId: Unique identifier for this training sample + * fields: Dictionary of text fields to be concatenated for training + * annotations: List of annotations, one per hierarchical level + * @example { + * "annotations": [ + * { + * "level": 1, + * "nodeCode": "2" + * }, + * { + * "level": 2, + * "nodeCode": "25" + * }, + * { + * "level": 3, + * "nodeCode": "251" + * }, + * { + * "level": 4, + * "nodeCode": "2512" + * } + * ], + * "fields": { + * "job_description": "Develops web applications", + * "job_title": "Software Engineer" + * }, + * "sentenceId": "job-001" + * } + */ + TrainingSentence: { + /** + * Sentenceid + * @description Unique sentence identifier + */ + sentenceId: string; + /** + * Fields + * @description Text fields (e.g., job_title, job_description) + */ + fields: { + [key: string]: string; + }; + /** + * Annotations + * @description Hierarchical annotations + */ + annotations: components["schemas"]["TrainingAnnotation"][]; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + health_check_health_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + create_taxonomy_taxonomies_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TaxonomyRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaxonomyJobResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + enrich_taxonomy_taxonomies__taxonomy_key__enrich_post: { + parameters: { + query?: never; + header?: never; + path: { + taxonomy_key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaxonomyJobResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + build_taxonomy_index_taxonomies__taxonomy_key__build_post: { + parameters: { + query?: never; + header?: never; + path: { + taxonomy_key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaxonomyJobResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + cancel_taxonomy_job_taxonomies__job_id__cancel_post: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaxonomyStatusResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_taxonomy_status_taxonomies__job_id__status_get: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaxonomyStatusResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_inference_job_classify_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["InferenceRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InferenceJobResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + cancel_inference_job_classify__job_id__cancel_post: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InferenceStatusResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_inference_status_classify__job_id__status_get: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InferenceStatusResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_labeling_job_label_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LabelingRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LabelingJobResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + cancel_labeling_job_label__job_id__cancel_post: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LabelingStatusResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_labeling_status_label__job_id__status_get: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LabelingStatusResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_learning_job_learn_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LearningRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LearningJobResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + cancel_learning_job_learn__job_id__cancel_post: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LearningStatusResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_learning_status_learn__job_id__status_get: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LearningStatusResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + run_error_analysis_error_analysis_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorAnalysisJobResponse"]; + }; + }; + }; + }; + cancel_error_analysis_job_error_analysis__job_id__cancel_post: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorAnalysisStatusResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_error_analysis_status_error_analysis__job_id__status_get: { + parameters: { + query?: never; + header?: never; + path: { + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorAnalysisStatusResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/lib/contracts/taxomind.test.ts b/lib/contracts/taxomind.test.ts new file mode 100644 index 0000000..0c9e972 --- /dev/null +++ b/lib/contracts/taxomind.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import openapi from './taxomind.openapi.json' +import { createdJobId, jobStatusResponse, parseLabelingResults, parseResponse, TaxomindContractError } from './taxomind' + +const example = (name: keyof typeof openapi.components.schemas): unknown => + (openapi.components.schemas[name] as { example?: unknown }).example + +describe('parseLabelingResults', () => { + it('accepts only records of the job and well-formed suggestions', () => { + const { bySentence, rejected, serviceErrors } = parseLabelingResults({ + suggestions: [ + { sentenceId: 'a', annotations: [{ level: 1, nodeCode: '01', confidence: 0.9 }, { level: 2, nodeCode: '011', confidence: 1.7 }] }, + { sentenceId: 'b', annotations: [{ level: 1, nodeCode: ' -9 ' }] }, + { sentenceId: 'not-in-job', annotations: [{ level: 1, nodeCode: '01' }] }, + { sentenceId: 'c', annotations: [{ level: 1, nodeCode: '01' }, { level: 1, nodeCode: '02' }] }, + { sentenceId: 'c', annotations: [] }, + { sentenceId: 'd', annotations: [{ level: 9, nodeCode: '01' }] }, + { sentenceId: 'd', annotations: [{ level: 1, nodeCode: 1 }] }, // codes are strings, never numbers + { sentence_id: 'd', annotations: [{ level: 1, nodeCode: '01' }] }, // not the contract's key + { sentenceId: 'd', annotations: [{ level: 1, nodeCode: '' }] }, + 'garbage', + ], + errors: [{ sentenceId: 'e', error: 'empty text' }], + }, ['a', 'b', 'c', 'd', 'e']) + + expect(Array.from(bySentence.keys())).toEqual(['a', 'b', 'c']) + expect(bySentence.get('a')).toEqual([ + { level: 1, nodeCode: '01', confidence: 0.9 }, + { level: 2, nodeCode: '011', confidence: 1 }, + ]) + expect(bySentence.get('b')).toEqual([{ level: 1, nodeCode: '-9', confidence: 0 }]) + expect(bySentence.get('c')).toEqual([]) // duplicate level rejected, then empty list accepted + expect(rejected).toBe(7) + expect(serviceErrors).toBe(1) + }) + + it('handles a missing or malformed response', () => { + expect(parseLabelingResults(null, ['a']).bySentence.size).toBe(0) + expect(parseLabelingResults(undefined, ['a']).rejected).toBe(0) + expect(parseLabelingResults({ suggestions: 'x', errors: 3 }, ['a'])).toMatchObject({ rejected: 0, serviceErrors: 0 }) + }) + + it('reads Taxomind\'s documented example', () => { + const result = (example('LabelingStatusResponse') as { result: unknown }).result + const { bySentence, rejected } = parseLabelingResults(result, ['670ff0e7-d10e-430c-90e5-729a7e362ecc']) + expect(rejected).toBe(0) + expect(bySentence.get('670ff0e7-d10e-430c-90e5-729a7e362ecc')?.map(s => s.nodeCode)).toEqual(['2', '23', '234', '2341']) + }) +}) + +describe('job responses', () => { + it('reads the job id of each documented creation response', () => { + expect(createdJobId('label', example('LabelingJobResponse'))).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(createdJobId('learn', example('LearningJobResponse'))).toBe('550e8400-e29b-41d4-a716-446655440000') + expect(createdJobId('taxonomies', example('TaxonomyJobResponse'))).toBe('550e8400-e29b-41d4-a716-446655440000') + }) + + it('rejects a creation response without the resource\'s id key', () => { + expect(() => createdJobId('learn', { job_id: 'x' })).toThrow(TaxomindContractError) + expect(() => createdJobId('label', { job_id: '' })).toThrow('Unexpected response from the AI service (POST /label): job_id') + expect(() => createdJobId('taxonomies', null)).toThrow(TaxomindContractError) + }) + + it('reads each documented status response', () => { + for (const name of ['LabelingStatusResponse', 'LearningStatusResponse', 'TaxonomyStatusResponse'] as const) { + expect(parseResponse(jobStatusResponse, example(name), name)).toMatchObject({ status: 'completed' }) + } + }) + + it('rejects a status response without a known status', () => { + for (const body of [{}, { status: 'done' }, { status: 'complete' }, 'completed', null]) { + expect(() => parseResponse(jobStatusResponse, body, 'GET /label/x/status')).toThrow(TaxomindContractError) + } + }) + + it('drops malformed informational fields instead of failing the poll', () => { + expect(parseResponse(jobStatusResponse, { status: 'running', progress: 7, error: 42 }, 'GET /label/x/status')) + .toEqual({ status: 'running', progress: null, error: null }) + expect(parseResponse(jobStatusResponse, { status: 'pending' }, 'GET /label/x/status')).toEqual({ status: 'pending' }) + }) +}) diff --git a/lib/contracts/taxomind.ts b/lib/contracts/taxomind.ts new file mode 100644 index 0000000..05a21ca --- /dev/null +++ b/lib/contracts/taxomind.ts @@ -0,0 +1,139 @@ +import { z } from 'zod' +import type { components } from './taxomind.openapi' + +/** + * The Taxomind HTTP contract (https://github.com/rowsquared/taxomind). + * + * Taxomind is a FastAPI service and publishes its OpenAPI document at + * `/openapi.json`. A snapshot is committed next to this file + * (`taxomind.openapi.json`, Taxomind `f7a9e91`); `taxomind.openapi.ts` is + * generated from it (`npm run taxomind:types`) and types the requests that + * lib/ai-labeling.ts sends through openapi-fetch. + * + * Generated types are not checked at run time, so every response classiflow + * reads is parsed here as well. The schemas cover only the fields classiflow + * uses, and each is checked against the generated type (`satisfies`), so a + * regenerated contract that renames or retypes one of them fails typecheck. + */ + +type Schemas = components['schemas'] + +export type LabelingRequest = Schemas['LabelingRequest'] +export type LearningRequest = Schemas['LearningRequest'] +export type TaxonomyRequest = Schemas['TaxonomyRequest'] + +/** API resources with the job lifecycle POST /x, GET /x/{id}/status, POST /x/{id}/cancel. */ +export type TaxomindResource = 'label' | 'learn' | 'taxonomies' + +export class TaxomindContractError extends Error { + name = 'TaxomindContractError' +} + +/** Parses a response body; a mismatch throws with the endpoint and the first problems. */ +export function parseResponse(schema: S, body: unknown, endpoint: string): z.output { + const result = schema.safeParse(body) + if (result.success) return result.data + const issues = result.error.issues.slice(0, 3) + .map(issue => `${issue.path.join('.') || '(body)'}: ${issue.message}`) + .join('; ') + throw new TaxomindContractError(`Unexpected response from the AI service (${endpoint}): ${issues}`) +} + +// --------------------------------------------------------------------------- +// Job creation (202) — the id key differs per resource +// --------------------------------------------------------------------------- + +const id = z.string().trim().min(1) + +export const jobCreated = { + label: z.object({ job_id: id }) satisfies z.ZodType>, + learn: z.object({ jobId: id }) satisfies z.ZodType>, + taxonomies: z.object({ job_id: id }) satisfies z.ZodType>, +} + +export function createdJobId(resource: TaxomindResource, body: unknown): string { + if (resource === 'learn') return parseResponse(jobCreated.learn, body, 'POST /learn').jobId + return parseResponse(jobCreated[resource], body, `POST /${resource}`).job_id +} + +// --------------------------------------------------------------------------- +// Job status — same shape for all three resources as far as classiflow reads it +// --------------------------------------------------------------------------- + +export const jobStatus = z.enum(['pending', 'running', 'completed', 'failed', 'canceled']) satisfies z.ZodType + +export type JobStatus = z.output + +/** + * `status` must be one of Taxomind's values; anything else is a malformed + * response. `progress` and `error` are informational, so a bad value is + * dropped instead of failing the poll. `result` is checked by its consumer + * (parseLabelingResults), record by record. + */ +export const jobStatusResponse = z.object({ + status: jobStatus, + progress: z.number().min(0).max(1).nullish().catch(null), + error: z.string().nullish().catch(null), + result: z.unknown().optional(), +}) satisfies z.ZodType & StatusFields<'LearningStatusResponse'> & StatusFields<'TaxonomyStatusResponse'>> + +type StatusFields = + Pick + +export type JobStatusResponse = z.output + +// --------------------------------------------------------------------------- +// Labeling results (LabelingStatusResponse.result once completed) +// --------------------------------------------------------------------------- + +export type ParsedSuggestion = { level: number; nodeCode: string; confidence: number } + +const MAX_CODE_LENGTH = 100 + +// Codes stay strings: leading zeros ("011") and the unknown codes "-9".."-99999" +// pass through unchanged; whether a code exists is checked against the taxonomy +// when the results are stored. +const annotation = z.object({ + level: z.int().min(1).max(5), + nodeCode: z.string().trim().min(1).max(MAX_CODE_LENGTH), + confidence: z.number().catch(0).transform(c => Math.min(Math.max(c, 0), 1)), +}) + +const suggestion = z.object({ + sentenceId: z.string(), + annotations: z.array(annotation) + .refine(list => new Set(list.map(a => a.level)).size === list.length, 'levels must be unique'), +}) satisfies z.ZodType + +/** + * Validation of a completed labeling job's result. + * + * Only records that belong to the job are accepted (a response can never + * overwrite suggestions of unrelated records), and each record at most once. + * Per record: levels are integers 1..5 and unique, codes are non-empty strings, + * confidence is clamped to 0..1. A record that fails is dropped and counted as + * rejected; the rest of the result is still used. A missing or malformed + * `suggestions` / `errors` list counts as empty. + */ +export function parseLabelingResults( + raw: unknown, + allowedSentenceIds: string[] +): { bySentence: Map; rejected: number; serviceErrors: number } { + const response = (raw && typeof raw === 'object' ? raw : {}) as { suggestions?: unknown; errors?: unknown } + const allowed = new Set(allowedSentenceIds) + const bySentence = new Map() + let rejected = 0 + + const suggestions = Array.isArray(response.suggestions) ? response.suggestions : [] + for (const item of suggestions) { + const parsed = suggestion.safeParse(item) + if (!parsed.success || !allowed.has(parsed.data.sentenceId) || bySentence.has(parsed.data.sentenceId)) { + rejected++ + continue + } + bySentence.set(parsed.data.sentenceId, parsed.data.annotations) + } + + const serviceErrors = Array.isArray(response.errors) ? response.errors.length : 0 + return { bySentence, rejected, serviceErrors } +} diff --git a/package-lock.json b/package-lock.json index f848ad4..f801136 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "csv-parse": "7.0.3", "lucide-react": "1.61.0", "next": "16.4.2", + "openapi-fetch": "0.17.0", "pg": "8.23.1", "prisma": "7.10.1", "react": "19.3.1", @@ -33,6 +34,7 @@ "babel-plugin-react-compiler": "1.0.0", "eslint": "10.13.0", "eslint-config-next": "16.4.2", + "openapi-typescript": "7.13.0", "postcss": "8.5.6", "tailwindcss": "4.3.3", "typescript": "6.0.3", @@ -2412,6 +2414,89 @@ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.21", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.21.tgz", + "integrity": "sha512-yXMiBFYJbP5JOaxPzx2tJhK+v8h2Wu2uhad1Ro6GzVT8E3F4GcQ7sbLPJ9w4zvGjfxroD0bIh5B0pDtnLV/qUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.3.2", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz", @@ -4550,6 +4635,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -4567,6 +4662,23 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -5173,6 +5285,13 @@ "node": ">=18" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -5249,6 +5368,13 @@ "node": ">=6" } }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -7129,6 +7255,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -7175,6 +7315,19 @@ "node": ">=0.8.19" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -7735,6 +7888,16 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7742,6 +7905,29 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9254,6 +9440,42 @@ "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", "license": "MIT" }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -9348,6 +9570,24 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9550,6 +9790,16 @@ "node": ">=20" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -10754,6 +11004,19 @@ } } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -10954,6 +11217,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -11297,6 +11573,13 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/use-sync-external-store": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", @@ -12001,6 +12284,23 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 8a5c1a4..41ed021 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "start": "next start", "lint": "eslint .", "postinstall": "prisma generate", - "db:migrate": "prisma migrate deploy" + "db:migrate": "prisma migrate deploy", + "taxomind:types": "openapi-typescript lib/contracts/taxomind.openapi.json -o lib/contracts/taxomind.openapi.ts" }, "packageManager": "npm@11.19.1", "engines": { @@ -29,6 +30,7 @@ "csv-parse": "7.0.3", "lucide-react": "1.61.0", "next": "16.4.2", + "openapi-fetch": "0.17.0", "pg": "8.23.1", "prisma": "7.10.1", "react": "19.3.1", @@ -48,6 +50,7 @@ "babel-plugin-react-compiler": "1.0.0", "eslint": "10.13.0", "eslint-config-next": "16.4.2", + "openapi-typescript": "7.13.0", "postcss": "8.5.6", "tailwindcss": "4.3.3", "typescript": "6.0.3", @@ -58,5 +61,10 @@ "@prisma/client": true, "prisma": true, "unrs-resolver": true + }, + "overrides": { + "openapi-typescript": { + "typescript": "$typescript" + } } } diff --git a/tests/integration/ai-job-monitor.test.ts b/tests/integration/ai-job-monitor.test.ts index bf71839..58c2531 100644 --- a/tests/integration/ai-job-monitor.test.ts +++ b/tests/integration/ai-job-monitor.test.ts @@ -1,13 +1,21 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import type { AddressInfo } from 'node:net' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' import type { Taxonomy, User } from '@/lib/generated/prisma/client' +import openapi from '@/lib/contracts/taxomind.openapi.json' import { db } from '@/lib/workspace/db' -import { prisma, wsId, inWorkspace, createWorkspace, createSentences, createTaxonomy, createUser, resetDatabase } from './helpers' - -// The monitor against a stub Taxomind on a local port. Poll fast; the module -// reads these at load time, so lib/ai-job-monitor is imported after the stub -// listens (beforeAll). +import { prisma, wsId, inWorkspace, createWorkspace, createSentences, createTaxonomy, createUser, params, request, resetDatabase, signInAs } from './helpers' + +// The contract test: the monitor and the taxonomy sync against a stub Taxomind +// on a local port. The stub checks every request classiflow sends, and every +// reply it gives, against Taxomind's OpenAPI document +// (lib/contracts/taxomind.openapi.json); a request that breaks the contract is +// answered 422 as Taxomind would and fails the test. Tests that need a +// malformed reply send it `raw`, past the check. +// +// Poll fast; the modules read these at load time, so they are imported after +// the stub listens (beforeAll). vi.hoisted(() => { process.env.AI_JOB_POLL_INTERVAL_MS = '20' process.env.AI_JOB_POLL_TIMEOUT_MS = '10000' @@ -15,18 +23,58 @@ vi.hoisted(() => { process.env.AI_SOURCE_SLUG = 'stub-deployment' }) -type StubJob = { path: string; body: Record; polls: number } -type StatusReply = (job: StubJob) => { code?: number; body: unknown } +type SchemaName = keyof typeof openapi.components.schemas +const defs = JSON.parse(JSON.stringify(openapi.components.schemas).replaceAll('#/components/schemas/', '#/$defs/')) +const contract = (name: SchemaName) => z.fromJSONSchema({ $defs: defs, ...defs[name] }) + +type Resource = 'label' | 'learn' | 'taxonomies' +const SCHEMAS: Record = { + label: { request: 'LabelingRequest', created: 'LabelingJobResponse', status: 'LabelingStatusResponse' }, + learn: { request: 'LearningRequest', created: 'LearningJobResponse', status: 'LearningStatusResponse' }, + taxonomies: { request: 'TaxonomyRequest', created: 'TaxonomyJobResponse', status: 'TaxonomyStatusResponse' }, +} + +type StubJob = { resource: Resource; body: Record; polls: number } +/** Status fields to send (merged into a complete status response), or a `raw` body sent as is. */ +type Reply = { code?: number; body?: Record; raw?: string } +type StatusReply = (job: StubJob) => Reply let server: Server let monitor: typeof import('@/lib/ai-job-monitor') +let ai: typeof import('@/lib/ai-labeling') +let syncRoute: typeof import('@/app/api/workspaces/[workspaceId]/taxonomies/[key]/sync-ai/route') const started: StubJob[] = [] let statusReply: StatusReply +let createdReply: ((resource: Resource) => Reply) | undefined let authHeaders: string[] = [] +let violations: string[] = [] + +const now = () => new Date().toISOString() -function send(res: ServerResponse, code: number, body: unknown) { +function send(res: ServerResponse, code: number, body: unknown, raw?: string) { res.writeHead(code, { 'content-type': 'application/json' }) - res.end(JSON.stringify(body)) + res.end(raw ?? JSON.stringify(body)) +} + +/** Sends a reply that must itself satisfy the contract, so the stub cannot drift from Taxomind. */ +function reply(res: ServerResponse, schema: SchemaName, body: Record, code = 200) { + const checked = contract(schema).safeParse(body) + if (!checked.success) violations.push(`stub reply ${schema}: ${checked.error.message}`) + send(res, code, body) +} + +function createdBody(resource: Resource, job: StubJob, id: string) { + if (resource === 'learn') { + return { jobId: id, status: 'pending', taxonomyKey: String(job.body.taxonomyKey), message: 'Learning job initiated', createdAt: now() } + } + return { job_id: id, ...(resource === 'label' ? { batch_id: String(job.body.batchId) } : {}), status: 'pending', message: 'Job started', created_at: now() } +} + +function statusBody(resource: Resource, job: StubJob, id: string, fields: Record) { + if (resource === 'learn') { + return { jobId: id, taxonomyKey: String(job.body.taxonomyKey), message: 'Learning', createdAt: now(), ...fields } + } + return { job_id: id, ...(resource === 'label' ? { batch_id: String(job.body.batchId) } : {}), created_at: now(), ...fields } } async function handle(req: IncomingMessage, res: ServerResponse) { @@ -34,20 +82,39 @@ async function handle(req: IncomingMessage, res: ServerResponse) { let raw = '' for await (const chunk of req) raw += chunk const url = req.url || '' - const start = url.match(/^\/(label|learn)$/) + + const start = url.match(/^\/(label|learn|taxonomies)$/) if (req.method === 'POST' && start) { - started.push({ path: url, body: JSON.parse(raw || '{}'), polls: 0 }) - return send(res, 200, { job_id: `ext-${started.length}` }) + const resource = start[1] as Resource + const body = JSON.parse(raw || '{}') + const checked = contract(SCHEMAS[resource].request).safeParse(body) + if (!checked.success) { + violations.push(`POST ${url}: ${checked.error.message}`) + return send(res, 422, { detail: checked.error.issues }) + } + const job: StubJob = { resource, body, polls: 0 } + started.push(job) + const override = createdReply?.(resource) + if (override) return send(res, override.code ?? 202, override.body, override.raw) + return reply(res, SCHEMAS[resource].created, createdBody(resource, job, `ext-${started.length}`), 202) } - const status = url.match(/^\/(label|learn)\/ext-(\d+)\/status$/) - if (req.method === 'GET' && status) { - const job = started[Number(status[2]) - 1] - job.polls++ - const reply = statusReply(job) - return send(res, reply.code ?? 200, reply.body) + + const status = url.match(/^\/(label|learn|taxonomies)\/ext-(\d+)\/(status|cancel)$/) + const job = status && started[Number(status[2]) - 1] + if (status && job && job.resource === status[1]) { + if (status[3] === 'cancel' && req.method === 'POST') { + return reply(res, SCHEMAS[job.resource].status, statusBody(job.resource, job, `ext-${status[2]}`, { status: 'canceled' })) + } + if (status[3] === 'status' && req.method === 'GET') { + job.polls++ + const answer = statusReply(job) + if (answer.raw !== undefined || (answer.code ?? 200) !== 200) { + return send(res, answer.code ?? 200, answer.body, answer.raw) + } + return reply(res, SCHEMAS[job.resource].status, statusBody(job.resource, job, `ext-${status[2]}`, answer.body ?? {})) + } } - if (req.method === 'POST' && url.endsWith('/cancel')) return send(res, 200, {}) - send(res, 404, { error: 'unknown route' }) + send(res, 404, { detail: 'Not Found' }) } beforeAll(async () => { @@ -55,6 +122,8 @@ beforeAll(async () => { await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) process.env.AI_LABELING_API_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}` monitor = await import('@/lib/ai-job-monitor') + ai = await import('@/lib/ai-labeling') + syncRoute = await import('@/app/api/workspaces/[workspaceId]/taxonomies/[key]/sync-ai/route') }) afterAll(async () => { @@ -71,10 +140,17 @@ beforeEach(async () => { await createWorkspace() started.length = 0 authHeaders = [] + violations = [] + createdReply = undefined admin = await createUser('admin') taxonomy = await createTaxonomy('ISCO') }) +afterEach(() => { + expect(violations).toEqual([]) + expect(authHeaders.every(h => h === 'Bearer stub-key')).toBe(true) +}) + const ACTIVE = ['pending', 'processing'] async function labelingJob(sentenceIds: string[], extra: Record = {}) { @@ -100,18 +176,23 @@ async function settle(id: string, done = (status: string) => !ACTIVE.includes(st throw new Error(`job ${id} did not settle`) } -const completedWith = (suggestions: unknown) => () => ({ body: { status: 'completed', result: { suggestions } } }) +const completedWith = (suggestions: unknown, errors: unknown[] = []): StatusReply => job => + ({ body: { status: 'completed', progress: 1, result: { batchId: job.body.batchId, suggestions, errors } } }) + +const storedSuggestions = async () => + (await prisma.sentenceAISuggestion.findMany({ orderBy: [{ sentenceId: 'asc' }, { level: 'asc' }] })) + .map(s => [s.sentenceId, s.level, s.nodeCode, s.confidenceScore]) describe('AI job monitor against a stub service', () => { it('sends the records, polls to completion and stores only valid suggestions', async () => { const [a, b] = await createSentences(2) statusReply = job => (job.polls < 3 - ? { body: { status: 'processing', progress: 0.5 } } + ? { body: { status: 'running', progress: 0.5 } } : completedWith([ - { sentence_id: a.id, annotations: [{ level: 1, nodeCode: '01', confidence: 0.9 }, { level: 2, nodeCode: '011', confidence: 0.8 }] }, - { sentence_id: b.id, annotations: [{ level: 1, nodeCode: '99', confidence: 0.7 }] }, // not in the taxonomy - { sentence_id: 'someone-else', annotations: [{ level: 1, nodeCode: '02' }] }, // foreign record - ])()) + { sentenceId: a.id, annotations: [{ level: 1, nodeCode: '01', confidence: 0.9 }, { level: 2, nodeCode: '011', confidence: 0.8 }] }, + { sentenceId: b.id, annotations: [{ level: 1, nodeCode: '99', confidence: 0.7 }] }, // not in the taxonomy + { sentenceId: 'someone-else', annotations: [{ level: 1, nodeCode: '02', confidence: 0.5 }] }, // foreign record + ])(job)) const job = await labelingJob([a.id, b.id]) monitor.launchJob(job.id, wsId()) @@ -122,14 +203,35 @@ describe('AI job monitor against a stub service', () => { expect(done.failedSentences).toBe(1) expect(done.leaseOwner).toBeNull() expect(started).toHaveLength(1) - expect(started[0].body).toMatchObject({ taxonomyKey: 'ISCO', batchId: job.id }) - expect(started[0].body.sentences).toEqual([ - { sentence_id: a.id, fields: { text: 'record 0' } }, - { sentence_id: b.id, fields: { text: 'record 1' } }, + expect(started[0].body).toEqual({ + taxonomyKey: 'ISCO', + sourceSlug: `stub-deployment--${wsId()}`, + batchId: job.id, + sentences: [ + { sentence_id: a.id, fields: { text: 'record 0' } }, + { sentence_id: b.id, fields: { text: 'record 1' } }, + ], + }) + expect(await storedSuggestions()).toEqual([[a.id, 1, '01', 0.9], [a.id, 2, '011', 0.8]]) + }) + + it('keeps codes as strings: leading zeros and the unknown codes', async () => { + const [a, b] = await createSentences(2) + statusReply = completedWith([ + { sentenceId: a.id, annotations: [{ level: 1, nodeCode: '01', confidence: 0.6 }, { level: 2, nodeCode: '012', confidence: 0.4 }] }, + { sentenceId: b.id, annotations: [{ level: 1, nodeCode: '-9', confidence: 0.2 }, { level: 2, nodeCode: '-99', confidence: 0.1 }] }, ]) - expect(authHeaders.every(h => h === 'Bearer stub-key')).toBe(true) - const stored = await prisma.sentenceAISuggestion.findMany({ orderBy: [{ sentenceId: 'asc' }, { level: 'asc' }] }) - expect(stored.map(s => [s.sentenceId, s.level, s.nodeCode])).toEqual([[a.id, 1, '01'], [a.id, 2, '011']]) + const job = await labelingJob([a.id, b.id]) + + monitor.launchJob(job.id, wsId()) + expect((await settle(job.id)).status).toBe('completed') + + const stored = await storedSuggestions() + expect(stored).toHaveLength(4) + expect(stored).toEqual(expect.arrayContaining([ + [a.id, 1, '01', 0.6], [a.id, 2, '012', 0.4], + [b.id, 1, '-9', 0.2], [b.id, 2, '-99', 0.1], + ])) }) it('marks the job failed with the service error', async () => { @@ -144,12 +246,24 @@ describe('AI job monitor against a stub service', () => { expect(done.errorMessage).toBe('model not trained') }) + it('marks the job failed when Taxomind cancelled it', async () => { + const [a] = await createSentences(1) + statusReply = () => ({ body: { status: 'canceled' } }) + const job = await labelingJob([a.id]) + + monitor.launchJob(job.id, wsId()) + const done = await settle(job.id) + + expect(done.status).toBe('failed') + expect(done.errorMessage).toBe('External AI job was cancelled in the AI service') + }) + it('keeps a job cancelled while polling cancelled and writes no suggestions', async () => { const [a] = await createSentences(1) let cancelled = false - statusReply = () => (cancelled - ? completedWith([{ sentence_id: a.id, annotations: [{ level: 1, nodeCode: '02' }] }])() - : { body: { status: 'processing' } }) + statusReply = job => (cancelled + ? completedWith([{ sentenceId: a.id, annotations: [{ level: 1, nodeCode: '02', confidence: 1 }] }])(job) + : { body: { status: 'running' } }) const job = await labelingJob([a.id]) monitor.launchJob(job.id, wsId()) @@ -165,8 +279,8 @@ describe('AI job monitor against a stub service', () => { it('keeps polling through failed status requests', async () => { const [a] = await createSentences(1) statusReply = job => (job.polls <= 2 - ? { code: 503, body: { error: 'busy' } } - : completedWith([{ sentence_id: a.id, annotations: [{ level: 1, nodeCode: '02', confidence: 1 }] }])()) + ? { code: 503, body: { detail: 'busy' } } + : completedWith([{ sentenceId: a.id, annotations: [{ level: 1, nodeCode: '02', confidence: 1 }] }])(job)) const job = await labelingJob([a.id]) monitor.launchJob(job.id, wsId()) @@ -176,6 +290,95 @@ describe('AI job monitor against a stub service', () => { expect(started[0].polls).toBe(3) }) + it('treats malformed status responses like failed requests and keeps polling', async () => { + const [a] = await createSentences(1) + const malformed: Reply[] = [ + { raw: 'Internal Server Error' }, // not JSON + { raw: JSON.stringify({ job_id: 'ext-1' }) }, // no status + { raw: JSON.stringify({ status: 'done' }) }, // not one of Taxomind's statuses + { raw: JSON.stringify({ status: 'success' }) }, // an old alias Taxomind never sends + ] + statusReply = job => malformed[job.polls - 1] + ?? completedWith([{ sentenceId: a.id, annotations: [{ level: 1, nodeCode: '02', confidence: 1 }] }])(job) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const job = await labelingJob([a.id]) + + monitor.launchJob(job.id, wsId()) + const done = await settle(job.id) + + expect(done.status).toBe('completed') + expect(started[0].polls).toBe(5) + const messages = warn.mock.calls.map(call => String(call[0])) + expect(messages.some(m => m.includes('not JSON'))).toBe(true) + expect(messages.some(m => m.includes('Unexpected response from the AI service (GET /label/ext-1/status): status'))).toBe(true) + warn.mockRestore() + }) + + it('stores what is valid from a partial result', async () => { + const [a, b, c] = await createSentences(3) + // Taxomind validates its result, so this reply is off-contract on purpose: + // one usable record, one with a numeric code, one missing. + statusReply = () => ({ + raw: JSON.stringify({ + job_id: 'ext-1', batch_id: 'x', status: 'completed', created_at: now(), + result: { + suggestions: [ + { sentenceId: a.id, annotations: [{ level: 1, nodeCode: '01', confidence: 0.5 }] }, + { sentenceId: b.id, annotations: [{ level: 1, nodeCode: 1, confidence: 0.5 }] }, + ], + }, + }), + }) + const job = await labelingJob([a.id, b.id, c.id]) + + monitor.launchJob(job.id, wsId()) + const done = await settle(job.id) + + expect(done.status).toBe('completed') + expect(done.failedSentences).toBe(1) + expect(await storedSuggestions()).toEqual([[a.id, 1, '01', 0.5]]) + }) + + it('completes without suggestions when a completed job has no result', async () => { + const [a] = await createSentences(1) + statusReply = () => ({ body: { status: 'completed', result: null } }) + const job = await labelingJob([a.id]) + + monitor.launchJob(job.id, wsId()) + const done = await settle(job.id) + + expect(done.status).toBe('completed') + expect(done.failedSentences).toBe(0) + expect(await prisma.sentenceAISuggestion.count()).toBe(0) + }) + + it('fails the job when the start response has no job id', async () => { + const [a] = await createSentences(1) + createdReply = () => ({ body: { jobId: 'wrong-key', status: 'pending' } }) + vi.spyOn(console, 'error').mockImplementationOnce(() => {}) + const job = await labelingJob([a.id]) + + monitor.launchJob(job.id, wsId()) + const done = await settle(job.id) + + expect(done.status).toBe('failed') + expect(done.externalJobId).toBeNull() + expect(done.errorMessage).toBe('Unexpected response from the AI service (POST /label): job_id: Invalid input: expected string, received undefined') + }) + + it('fails the job with the service\'s answer when the start request is refused', async () => { + const [a] = await createSentences(1) + createdReply = () => ({ code: 401, body: { detail: 'Invalid token' } }) + vi.spyOn(console, 'error').mockImplementationOnce(() => {}) + const job = await labelingJob([a.id]) + + monitor.launchJob(job.id, wsId()) + const done = await settle(job.id) + + expect(done.status).toBe('failed') + expect(done.errorMessage).toBe('AI labeling API error (401): {"detail":"Invalid token"}') + }) + it('leaves a job alone while another instance holds its lease', async () => { const [a] = await createSentences(1) statusReply = completedWith([]) @@ -195,8 +398,8 @@ describe('AI job monitor against a stub service', () => { it('takes over a dead instance\'s job and resumes polling without resending it', async () => { const [a] = await createSentences(1) // Another instance started the external job, then died: its lease expired. - started.push({ path: '/label', body: {}, polls: 0 }) - statusReply = completedWith([{ sentence_id: a.id, annotations: [{ level: 1, nodeCode: '02', confidence: 0.5 }] }]) + started.push({ resource: 'label', body: { taxonomyKey: 'ISCO', batchId: 'earlier' }, polls: 0 }) + statusReply = completedWith([{ sentenceId: a.id, annotations: [{ level: 1, nodeCode: '02', confidence: 0.5 }] }]) const job = await labelingJob([a.id], { status: 'processing', externalJobId: 'ext-1', @@ -214,8 +417,11 @@ describe('AI job monitor against a stub service', () => { it('runs a learning job and records the taxonomy\'s last learning', async () => { const [a] = await createSentences(1) - await inWorkspace(() => db().sentenceAnnotation.create({ - data: { workspaceId: wsId(), sentenceId: a.id, taxonomyId: taxonomy.id, level: 1, nodeCode: '02', source: 'user', createdById: admin.id }, + await inWorkspace(() => db().sentenceAnnotation.createMany({ + data: [ + { workspaceId: wsId(), sentenceId: a.id, taxonomyId: taxonomy.id, level: 1, nodeCode: '01', source: 'user', createdById: admin.id }, + { workspaceId: wsId(), sentenceId: a.id, taxonomyId: taxonomy.id, level: 2, nodeCode: '011', source: 'user', createdById: admin.id }, + ], })) statusReply = () => ({ body: { status: 'completed' } }) const job = await inWorkspace(() => db().aILearningJob.create({ @@ -223,22 +429,70 @@ describe('AI job monitor against a stub service', () => { })) monitor.launchLearningJob(job.id, wsId()) - for (let i = 0; i < 250; i++) { - if ((await prisma.aILearningJob.findUniqueOrThrow({ where: { id: job.id } })).status === 'completed') break - await new Promise(r => setTimeout(r, 20)) - } + await expect.poll(async () => (await prisma.aILearningJob.findUniqueOrThrow({ where: { id: job.id } })).status) + .toBe('completed') - expect((await prisma.aILearningJob.findUniqueOrThrow({ where: { id: job.id } })).status).toBe('completed') - expect(started[0].path).toBe('/learn') - expect(started[0].body.sentences).toEqual([ - { sentenceId: a.id, fields: { text: 'record 0' }, annotations: [{ level: 1, nodeCode: '02' }] }, - ]) + const done = await prisma.aILearningJob.findUniqueOrThrow({ where: { id: job.id } }) + expect(done.externalJobId).toBe('ext-1') // read from `jobId`, the learning endpoint's key + expect(started[0].resource).toBe('learn') + expect(started[0].body).toEqual({ + taxonomyKey: 'ISCO', + sourceSlug: `stub-deployment--${wsId()}`, + sentences: [{ + sentenceId: a.id, + fields: { text: 'record 0' }, + annotations: expect.arrayContaining([{ level: 1, nodeCode: '01' }, { level: 2, nodeCode: '011' }]), + }], + }) // Recorded right after the job's completion, outside its transaction. await expect.poll(async () => (await prisma.taxonomy.findUniqueOrThrow({ where: { id: taxonomy.id } })).lastLearningStatus) .toBe('completed') }) }) +describe('taxonomy sync against a stub service', () => { + const sync = () => syncRoute.POST( + request(`/api/workspaces/${wsId()}/taxonomies/ISCO/sync-ai`, { method: 'POST' }), + params({ key: 'ISCO' }) + ) + const syncStatus = async () => (await prisma.taxonomy.findUniqueOrThrow({ where: { id: taxonomy.id } })).lastAISyncStatus + + it('sends the taxonomy as the contract describes it and records completion', async () => { + signInAs(admin) + statusReply = job => ({ body: job.polls < 2 ? { status: 'running' } : { status: 'completed', progress: 1 } }) + + const res = await sync() + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true, jobId: 'ext-1' }) + + await expect.poll(syncStatus).toBe('completed') + const body = started[0].body as { action: string; sourceSlug: string; taxonomy: { key: string; nodes: Array<{ code: string }> } } + expect(body).toMatchObject({ action: 'create', sourceSlug: `stub-deployment--${wsId()}`, taxonomy: { key: 'ISCO' } }) + expect(body.taxonomy.nodes.map(n => n.code).sort()).toEqual(['01', '011', '012', '02']) + }) + + it('records a sync that Taxomind cancelled as failed', async () => { + signInAs(admin) + statusReply = () => ({ body: { status: 'canceled' } }) + + expect((await sync()).status).toBe(200) + + await expect.poll(syncStatus).toBe('failed') + expect((await prisma.taxonomy.findUniqueOrThrow({ where: { id: taxonomy.id } })).lastAISyncError) + .toBe('AI job was cancelled in the AI service') + }) + + it('cancels jobs on Taxomind best-effort', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await ai.cancelAIJob('/label', 'ext-404') // unknown job: 404, logged, not thrown + expect(String(warn.mock.calls[0]?.[1])).toContain('AI labeling API error (404)') + warn.mockRestore() + started.push({ resource: 'taxonomies', body: {}, polls: 0 }) + await ai.cancelAIJob('/taxonomies', 'ext-1') + expect(authHeaders).toHaveLength(2) + }) +}) + describe('poll backoff', () => { it('doubles the interval per failed request, capped', () => { expect(monitor.pollDelay(0)).toBe(20)