diff --git a/packages/graphql/src/graphql/ops/QGetKbKnowledgeGraphConfig.graphql b/packages/graphql/src/graphql/ops/QGetKbKnowledgeGraphConfig.graphql index 28098ee285..82c24e9642 100644 --- a/packages/graphql/src/graphql/ops/QGetKbKnowledgeGraphConfig.graphql +++ b/packages/graphql/src/graphql/ops/QGetKbKnowledgeGraphConfig.graphql @@ -9,6 +9,7 @@ query GetKbKnowledgeGraphConfig($kbId: ID!) { sourceContentDigest activeBuildId publishedBuildId + elementGenerationReady isStale startedAt finishedAt diff --git a/packages/graphql/src/public/schema.graphql b/packages/graphql/src/public/schema.graphql index b5301812af..8b655f9a02 100644 --- a/packages/graphql/src/public/schema.graphql +++ b/packages/graphql/src/public/schema.graphql @@ -2215,6 +2215,7 @@ type KBKnowledgeGraphConfig { costCurrency: String costStatus: KBGraphCostStatus createdAt: Date + elementGenerationReady: Boolean! estimatedCostMinorUnits: Int finishedAt: Date highEstimateMinorUnits: Int diff --git a/packages/graphql/src/schema/kbKnowledgeGraph.ts b/packages/graphql/src/schema/kbKnowledgeGraph.ts index 9b38bfa3ea..d31158b3a0 100644 --- a/packages/graphql/src/schema/kbKnowledgeGraph.ts +++ b/packages/graphql/src/schema/kbKnowledgeGraph.ts @@ -38,6 +38,7 @@ export const KBKnowledgeGraphConfigType = KBKnowledgeGraphConfigRef.implement({ }), activeBuildId: t.exposeID('activeBuildId', { nullable: true }), publishedBuildId: t.exposeID('publishedBuildId', { nullable: true }), + elementGenerationReady: t.exposeBoolean('elementGenerationReady'), isStale: t.exposeBoolean('isStale'), startedAt: t.expose('startedAt', { type: 'Date', nullable: true }), finishedAt: t.expose('finishedAt', { type: 'Date', nullable: true }), diff --git a/packages/graphql/src/services/elementGenerationGraphReadiness.ts b/packages/graphql/src/services/elementGenerationGraphReadiness.ts new file mode 100644 index 0000000000..b054989f78 --- /dev/null +++ b/packages/graphql/src/services/elementGenerationGraphReadiness.ts @@ -0,0 +1,39 @@ +import * as DB from '@klicker-uzh/prisma/client' + +export interface ElementGenerationGraphBundle { + status: DB.KBGraphBuildStatus + graphBundleContainerName: string | null + graphBundleBlobPrefix: string | null + graphBundleStorageName: string | null + graphBundleSha256: string | null + graphSha256: string | null + graphManifestSchemaVersion: number | null + graphManifestArtifact: DB.Prisma.JsonValue | null +} + +type ReadyElementGenerationGraphBundle = + T & { + status: typeof DB.KBGraphBuildStatus.SUCCEEDED + graphBundleContainerName: string + graphBundleBlobPrefix: string + graphBundleStorageName: string + graphBundleSha256: string + graphSha256: string + graphManifestSchemaVersion: 2 + graphManifestArtifact: Exclude + } + +export function isElementGenerationGraphBundleReady< + T extends ElementGenerationGraphBundle, +>(build: T | null | undefined): build is ReadyElementGenerationGraphBundle { + return ( + build?.status === DB.KBGraphBuildStatus.SUCCEEDED && + build.graphBundleContainerName !== null && + build.graphBundleBlobPrefix !== null && + build.graphBundleStorageName !== null && + build.graphBundleSha256 !== null && + build.graphSha256 !== null && + build.graphManifestSchemaVersion === 2 && + build.graphManifestArtifact !== null + ) +} diff --git a/packages/graphql/src/services/knowledge.ts b/packages/graphql/src/services/knowledge.ts index 17391fdf4d..6047892b88 100644 --- a/packages/graphql/src/services/knowledge.ts +++ b/packages/graphql/src/services/knowledge.ts @@ -33,6 +33,7 @@ import { GraphQLError } from 'graphql' import { validate as validateUuid } from 'uuid' import type { ContextWithUser } from '../lib/context.js' import { assertManageAiEnabled } from '../lib/manageAiFeatureGate.js' +import { isElementGenerationGraphBundleReady } from './elementGenerationGraphReadiness.js' import { getKBGraphBundleCoordinates } from './kbGraphBundleCoordinates.js' import { getKBGraphRemainingQuota, @@ -1792,6 +1793,7 @@ export interface KBKnowledgeGraphConfig { sourceContentDigest: string | null activeBuildId: string | null publishedBuildId: string | null + elementGenerationReady: boolean isStale: boolean startedAt: Date | null finishedAt: Date | null @@ -1893,7 +1895,8 @@ export function getKBGraphBuildConfig( reservedMinorUnits: number settledMinorUnits: number } | null, - costConfiguration: ReturnType + costConfiguration: ReturnType, + elementGenerationReady: boolean ): KBKnowledgeGraphConfig { const quotaConfigurationMatches = quota === null || @@ -1920,6 +1923,7 @@ export function getKBGraphBuildConfig( sourceContentDigest: build?.sourceContentDigest ?? null, activeBuildId: kb.activeGraphBuildId, publishedBuildId: kb.publishedGraphBuildId, + elementGenerationReady, isStale, startedAt: build?.startedAt ?? null, finishedAt: build?.finishedAt ?? null, @@ -1971,7 +1975,17 @@ export async function getKbKnowledgeGraphConfig( kbId: kb.id, status: DB.KBGraphBuildStatus.SUCCEEDED, }, - select: { sourceContentDigest: true }, + select: { + sourceContentDigest: true, + status: true, + graphBundleContainerName: true, + graphBundleBlobPrefix: true, + graphBundleStorageName: true, + graphBundleSha256: true, + graphSha256: true, + graphManifestSchemaVersion: true, + graphManifestArtifact: true, + }, }) : Promise.resolve(null), ]) @@ -1994,7 +2008,14 @@ export async function getKbKnowledgeGraphConfig( ? publishedBuild.sourceContentDigest !== (await computeKBContentDigest(ctx.prisma, kb.id)) : false - return getKBGraphBuildConfig(kb, build, isStale, quota, costConfiguration) + return getKBGraphBuildConfig( + kb, + build, + isStale, + quota, + costConfiguration, + isElementGenerationGraphBundleReady(publishedBuild) + ) } async function readOwnedPublishedKBGraph( @@ -2295,11 +2316,31 @@ export async function rebuildKbKnowledgeGraph( settledMinorUnits: true, }, }) + const publishedBuild = result.kb.publishedGraphBuildId + ? await ctx.prisma.kBGraphBuild.findFirst({ + where: { + id: result.kb.publishedGraphBuildId, + kbId, + status: DB.KBGraphBuildStatus.SUCCEEDED, + }, + select: { + status: true, + graphBundleContainerName: true, + graphBundleBlobPrefix: true, + graphBundleStorageName: true, + graphBundleSha256: true, + graphSha256: true, + graphManifestSchemaVersion: true, + graphManifestArtifact: true, + }, + }) + : null return getKBGraphBuildConfig( result.kb, result.build, isStale, quota, - costConfiguration + costConfiguration, + isElementGenerationGraphBundleReady(publishedBuild) ) } diff --git a/packages/graphql/src/services/questionGenerationGraph.ts b/packages/graphql/src/services/questionGenerationGraph.ts index 7940f49339..016931f2c3 100644 --- a/packages/graphql/src/services/questionGenerationGraph.ts +++ b/packages/graphql/src/services/questionGenerationGraph.ts @@ -2,7 +2,7 @@ import { getPublishedKnowledgeGraph, KnowledgeGraphNotPublishedError, } from '@klicker-uzh/knowledge-graph' -import * as DB from '@klicker-uzh/prisma/client' +import type * as DB from '@klicker-uzh/prisma/client' import type { KBGraphSourceSnapshot, QuestionGenerationArtifactRef, @@ -10,6 +10,7 @@ import type { import { QUESTION_GENERATION_CAPABILITIES } from '@klicker-uzh/types' import type { ContextWithUser } from '../lib/context.js' import { assertManageAiEnabled } from '../lib/manageAiFeatureGate.js' +import { isElementGenerationGraphBundleReady } from './elementGenerationGraphReadiness.js' export type QuestionGenerationGraphErrorCode = | 'KB_GRAPH_VERSION_NOT_ELIGIBLE' @@ -116,6 +117,8 @@ const nativeBuildSelect = { kbId: true, status: true, graphName: true, + graphBundleContainerName: true, + graphBundleBlobPrefix: true, graphBundleStorageName: true, graphBundleSha256: true, graphSha256: true, @@ -143,14 +146,7 @@ function asGenerationGraph( build: NativeBuild, isStale: boolean ): QuestionGenerationGraph { - if ( - build.status !== DB.KBGraphBuildStatus.SUCCEEDED || - build.graphBundleStorageName === null || - build.graphBundleSha256 === null || - build.graphSha256 === null || - build.graphManifestSchemaVersion !== 2 || - build.graphManifestArtifact === null - ) { + if (!isElementGenerationGraphBundleReady(build)) { throw graphError( 'KB_GRAPH_VERSION_NOT_ELIGIBLE', 'Published knowledge graph does not have a generation bundle' diff --git a/packages/graphql/test/elementGenerationGraphReadiness.test.ts b/packages/graphql/test/elementGenerationGraphReadiness.test.ts new file mode 100644 index 0000000000..0390bc3885 --- /dev/null +++ b/packages/graphql/test/elementGenerationGraphReadiness.test.ts @@ -0,0 +1,39 @@ +import { KBGraphBuildStatus, type Prisma } from '@klicker-uzh/prisma/client' +import { describe, expect, it } from 'vitest' +import { isElementGenerationGraphBundleReady } from '../src/services/elementGenerationGraphReadiness.js' + +const readyBuild = { + status: KBGraphBuildStatus.SUCCEEDED, + graphBundleContainerName: 'kg-graph-artifacts', + graphBundleBlobPrefix: 'graph-artifacts/build/build', + graphBundleStorageName: 'bundle-storage', + graphBundleSha256: 'a'.repeat(64), + graphSha256: 'b'.repeat(64), + graphManifestSchemaVersion: 2, + graphManifestArtifact: { + containerName: 'graphs', + blobName: 'manifest.json', + sha256: 'c'.repeat(64), + } satisfies Prisma.JsonObject, +} + +describe('element generation graph readiness', () => { + it('accepts a succeeded native graph build with a complete v2 bundle', () => { + expect(isElementGenerationGraphBundleReady(readyBuild)).toBe(true) + }) + + it.each([ + { status: KBGraphBuildStatus.PROCESSING }, + { graphBundleContainerName: null }, + { graphBundleBlobPrefix: null }, + { graphBundleStorageName: null }, + { graphBundleSha256: null }, + { graphSha256: null }, + { graphManifestSchemaVersion: 1 }, + { graphManifestArtifact: null }, + ])('rejects an ineligible native build: %o', (override) => { + expect( + isElementGenerationGraphBundleReady({ ...readyBuild, ...override }) + ).toBe(false) + }) +}) diff --git a/packages/graphql/test/knowledgeGraphConfig.test.ts b/packages/graphql/test/knowledgeGraphConfig.test.ts index 99c2d6a3a9..b1711f3cc1 100644 --- a/packages/graphql/test/knowledgeGraphConfig.test.ts +++ b/packages/graphql/test/knowledgeGraphConfig.test.ts @@ -64,12 +64,14 @@ describe('KB knowledge graph config', () => { reservedMinorUnits: 100, settledMinorUnits: 50, }, - costConfiguration + costConfiguration, + true ) expect(result.costConfigurationReady).toBe(false) expect(result.costCurrency).toBe('EUR') expect(result.quotaCurrency).toBe('USD') expect(result.remainingSemesterQuotaMinorUnits).toBe(750) + expect(result.elementGenerationReady).toBe(true) }) })