diff --git a/apps/frontend-manage/src/components/common/Header.tsx b/apps/frontend-manage/src/components/common/Header.tsx index 0bffed40e72..205170378f8 100644 --- a/apps/frontend-manage/src/components/common/Header.tsx +++ b/apps/frontend-manage/src/components/common/Header.tsx @@ -164,8 +164,22 @@ function Header({ user }: { user?: UserProfile | null }): React.ReactElement { icon: faWandMagicSparkles, active: router.pathname.startsWith('/resources/knowledgeBases') || - router.pathname === '/resources/chatbots', + router.pathname === '/resources/chatbots' || + router.pathname === '/elements/generate', elements: [ + { + key: 'element-generation-item', + type: 'link' as const, + label: t('manage.elementGeneration.title'), + onClick: () => router.push('/elements/generate'), + badge: t('manage.general.betaFeatures'), + data: { cy: 'element-generation' }, + className: { + label: 'bg-opacity-100', + text: 'mr-8', + badge: 'bg-green-700 hover:bg-green-800', + }, + }, { key: 'knowledge-bases-item', type: 'link' as const, diff --git a/apps/frontend-manage/src/components/elements/generation/ElementGenerationBuild.tsx b/apps/frontend-manage/src/components/elements/generation/ElementGenerationBuild.tsx new file mode 100644 index 00000000000..03336fb6cac --- /dev/null +++ b/apps/frontend-manage/src/components/elements/generation/ElementGenerationBuild.tsx @@ -0,0 +1,380 @@ +import { useMutation, useQuery } from '@apollo/client' +import { + ElementGenerationBuildDocument, + ElementGenerationBuildStatus, + ElementGenerationCapabilitiesDocument, + type ElementGenerationReviewDecision, + PublishIncompleteElementGenerationDocument, + RetryElementGenerationDocument, + ReviewElementGenerationDocument, + ElementGenerationReviewGate as ReviewGate, +} from '@klicker-uzh/graphql/dist/ops' +import Loader from '@klicker-uzh/shared-components/src/Loader' +import { Button, UserNotification } from '@uzh-bf/design-system' +import { useTranslations } from 'next-intl' +import { useEffect, useState } from 'react' +import ElementGenerationReviewGatePanel from './ElementGenerationReviewGate' +import { + elementGenerationErrorCode, + isElementGenerationSettled, +} from './elementGenerationTypes' +import GeneratedElementReview from './GeneratedElementReview' + +interface ElementGenerationBuildProps { + buildId: string + onNew: () => Promise +} + +export default function ElementGenerationBuild({ + buildId, + onNew, +}: ElementGenerationBuildProps) { + const t = useTranslations('manage.elementGeneration') + const query = useQuery(ElementGenerationBuildDocument, { + variables: { id: buildId }, + fetchPolicy: 'network-only', + notifyOnNetworkStatusChange: true, + }) + const capabilitiesQuery = useQuery(ElementGenerationCapabilitiesDocument) + const [reviewGeneration, reviewState] = useMutation( + ReviewElementGenerationDocument + ) + const [retryGeneration, retryState] = useMutation( + RetryElementGenerationDocument + ) + const [publishIncomplete, publishState] = useMutation( + PublishIncompleteElementGenerationDocument + ) + const [actionError, setActionError] = useState() + const [warningsAcknowledged, setWarningsAcknowledged] = useState(false) + const build = query.data?.elementGenerationBuild + const { startPolling, stopPolling } = query + + useEffect(() => { + if (!build || !isElementGenerationSettled(build.status)) { + startPolling(2500) + return () => stopPolling() + } + stopPolling() + }, [build, startPolling, stopPolling]) + + if (query.loading && !build) { + return ( +
+ +
+ ) + } + + if (query.error || !build) { + return ( +
+ + +
+ ) + } + + const typeCapability = + capabilitiesQuery.data?.elementGenerationCapabilities.typeCapabilities.find( + (capability) => capability.elementType === build.elementType + ) + const progress = Math.min( + 100, + Math.max( + 4, + build.requestedElementCount === 0 + ? 4 + : Math.round( + (build.generatedElementCount / build.requestedElementCount) * 100 + ) + ) + ) + const isProcessing = !isElementGenerationSettled(build.status) + const mutationLoading = + reviewState.loading || retryState.loading || publishState.loading + const currentBuildId = build.id + + async function refresh() { + await query.refetch() + } + + async function runAction(action: () => Promise) { + setActionError(undefined) + try { + await action() + await refresh() + } catch (error) { + const code = elementGenerationErrorCode(error) + setActionError(code ? t('errors.withCode', { code }) : t('errors.action')) + } + } + + async function review( + gate: ReviewGate, + decision: ElementGenerationReviewDecision, + acknowledged: boolean + ) { + await runAction(() => + reviewGeneration({ + variables: { + input: { + buildId: currentBuildId, + gate, + decision, + warningsAcknowledged: acknowledged, + }, + }, + }) + ) + } + + return ( +
+
+
+
+
+ + {build.elementType} + + + {t(`statuses.${build.status}`)} + +
+

+ {t('build.title', { + type: t(`elementTypes.${build.elementType}.label`), + })} +

+

+ {t('build.stage', { stage: build.stage })} +

+
+ +
+ +
+
+ + {t('build.generatedCount', { + generated: build.generatedElementCount, + requested: build.requestedElementCount, + })} + + {progress}% +
+
+
+
+
+ +
+
+
{t('build.generated')}
+
+ {build.generatedElementCount} +
+
+
+
{t('build.unresolved')}
+
+ {build.unresolvedElementCount} +
+
+
+
{t('build.warnings')}
+
+ {build.warningCount} +
+
+
+
{t('build.retries')}
+
+ {build.retryCount} +
+
+
+
+ + {isProcessing ? ( +
+
+ +
+

+ {t('build.processing')} +

+

+ {t('build.processingHelp')} +

+
+ ) : null} + + {build.status === ElementGenerationBuildStatus.WaitingForDesignReview ? ( + + review(ReviewGate.Design, decision, acknowledged) + } + /> + ) : null} + + {build.status === ElementGenerationBuildStatus.WaitingForPlanReview ? ( + + review(ReviewGate.Plan, decision, acknowledged) + } + /> + ) : null} + + {build.status === ElementGenerationBuildStatus.Failed ? ( +
+

{t('build.failed')}

+

+ {build.errorMessage ?? t('build.failedHelp')} +

+ {build.errorCode ? ( +

{build.errorCode}

+ ) : null} + {typeCapability?.supportsRetry && build.errorRetryable ? ( + + ) : null} +
+ ) : null} + + {build.status === + ElementGenerationBuildStatus.AwaitingIncompletePublication ? ( +
+

+ {t('build.incompleteTitle')} +

+

+ {t('build.incompleteHelp', { + generated: build.generatedElementCount, + requested: build.requestedElementCount, + })} +

+ +
+ {typeCapability?.supportsRetry ? ( + + ) : null} + {typeCapability?.supportsIncompletePublication ? ( + + ) : null} +
+
+ ) : null} + + {build.status === ElementGenerationBuildStatus.Rejected ? ( + + ) : null} + + {(build.status === ElementGenerationBuildStatus.Completed || + build.status === ElementGenerationBuildStatus.Incomplete) && + build.drafts.length > 0 ? ( + + ) : null} + + {(build.status === ElementGenerationBuildStatus.Completed || + build.status === ElementGenerationBuildStatus.Incomplete) && + build.drafts.length === 0 ? ( + + ) : null} + + {actionError ? ( + + ) : null} +
+ ) +} diff --git a/apps/frontend-manage/src/components/elements/generation/ElementGenerationConfigure.tsx b/apps/frontend-manage/src/components/elements/generation/ElementGenerationConfigure.tsx new file mode 100644 index 00000000000..b3a541bf74f --- /dev/null +++ b/apps/frontend-manage/src/components/elements/generation/ElementGenerationConfigure.tsx @@ -0,0 +1,680 @@ +import { useMutation, useQuery } from '@apollo/client' +import { + ElementGenerationBloomLevel, + ElementGenerationCapabilitiesDocument, + ElementGenerationDifficultyPreset, + ElementGenerationLanguage, + type ElementGenerationSourceScopeInput, + ElementGenerationSourcesDocument, + GeneratableElementType, + StartElementGenerationDocument, +} from '@klicker-uzh/graphql/dist/ops' +import Loader from '@klicker-uzh/shared-components/src/Loader' +import { Button, UserNotification } from '@uzh-bf/design-system' +import { useFormatter, useTranslations } from 'next-intl' +import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react' +import { + ELEMENT_TYPE_ORDER, + elementGenerationErrorCode, +} from './elementGenerationTypes' + +type SourceScopeValue = ElementGenerationSourceScopeInput & { + selected: boolean + pageFromText: string + pageToText: string +} + +const DEFAULT_BLOOM_LEVELS = [ + ElementGenerationBloomLevel.Understand, + ElementGenerationBloomLevel.Apply, +] + +function scopeValues( + sources: Array<{ resourceId: string }> +): SourceScopeValue[] { + return sources.map(({ resourceId }) => ({ + resourceId, + selected: true, + pageFromText: '', + pageToText: '', + })) +} + +function optionalPage(value: string) { + return value === '' ? undefined : Number(value) +} + +interface ElementGenerationConfigureProps { + preselectedKbId?: string + onStarted: (buildId: string) => Promise +} + +export default function ElementGenerationConfigure({ + preselectedKbId, + onStarted, +}: ElementGenerationConfigureProps) { + const t = useTranslations('manage.elementGeneration') + const format = useFormatter() + const capabilitiesQuery = useQuery(ElementGenerationCapabilitiesDocument) + const sourcesQuery = useQuery(ElementGenerationSourcesDocument) + const [startGeneration] = useMutation(StartElementGenerationDocument) + const [graphBuildId, setGraphBuildId] = useState('') + const [elementType, setElementType] = useState( + GeneratableElementType.Sc + ) + const [language, setLanguage] = useState( + ElementGenerationLanguage.De + ) + const [elementCount, setElementCount] = useState(6) + const [difficulty, setDifficulty] = + useState( + ElementGenerationDifficultyPreset.Mixed + ) + const [bloomLevels, setBloomLevels] = + useState(DEFAULT_BLOOM_LEVELS) + const [sourceScopes, setSourceScopes] = useState([]) + const [objectives, setObjectives] = useState< + Array<{ id: string; text: string }> + >([]) + const [submitting, setSubmitting] = useState(false) + const [validationError, setValidationError] = useState() + const [submissionError, setSubmissionError] = useState() + const idempotencyRef = useRef<{ input: string; key: string } | undefined>( + undefined + ) + + const sources = useMemo( + () => sourcesQuery.data?.elementGenerationSources ?? [], + [sourcesQuery.data] + ) + const capabilities = capabilitiesQuery.data?.elementGenerationCapabilities + const supportedTypes = useMemo( + () => + ELEMENT_TYPE_ORDER.filter((type) => + capabilities?.elementTypes.includes(type) + ), + [capabilities] + ) + const selectedSource = sources.find( + (source) => source.graphBuildId === graphBuildId + ) + const selectedCapability = capabilities?.typeCapabilities.find( + (capability) => capability.elementType === elementType + ) + + useEffect(() => { + if (graphBuildId || sources.length === 0) return + const source = + sources.find((candidate) => candidate.kbId === preselectedKbId) ?? + sources[0] + setGraphBuildId(source.graphBuildId) + setSourceScopes(scopeValues(source.sources)) + }, [graphBuildId, preselectedKbId, sources]) + + useEffect(() => { + if (supportedTypes.length > 0 && !supportedTypes.includes(elementType)) { + setElementType(supportedTypes[0]) + } + }, [elementType, supportedTypes]) + + if (capabilitiesQuery.loading || sourcesQuery.loading) { + return ( +
+ +
+ ) + } + + if (capabilitiesQuery.error || sourcesQuery.error || !capabilities) { + return ( + + ) + } + + if (!capabilities.configured) { + return ( + + ) + } + + if (sources.length === 0 || supportedTypes.length === 0) { + return ( +
+

+ {t('configure.noSources')} +

+

+ {t('configure.noSourcesHelp')} +

+
+ ) + } + + function selectSource(nextGraphBuildId: string) { + const source = sources.find( + (candidate) => candidate.graphBuildId === nextGraphBuildId + ) + if (!source) return + setGraphBuildId(nextGraphBuildId) + setSourceScopes(scopeValues(source.sources)) + } + + function updateScope(index: number, update: Partial) { + setSourceScopes((current) => + current.map((scope, scopeIndex) => + scopeIndex === index ? { ...scope, ...update } : scope + ) + ) + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + setValidationError(undefined) + setSubmissionError(undefined) + + if (!graphBuildId || !selectedCapability) { + setValidationError(t('validation.sourceRequired')) + return + } + if ( + !Number.isInteger(elementCount) || + elementCount < 1 || + elementCount > 20 + ) { + setValidationError(t('validation.countRange')) + return + } + + const selectedScopes = sourceScopes.filter((scope) => scope.selected) + if ( + selectedCapability.supportsSourceScopes && + selectedScopes.length === 0 + ) { + setValidationError(t('validation.sourceScopeRequired')) + return + } + for (const scope of selectedScopes) { + const hasFrom = scope.pageFromText !== '' + const hasTo = scope.pageToText !== '' + if (hasFrom !== hasTo) { + setValidationError(t('validation.pagePair')) + return + } + if (hasFrom && hasTo) { + const from = Number(scope.pageFromText) + const to = Number(scope.pageToText) + if (from < 1 || to < 1 || from > to) { + setValidationError(t('validation.pageRange')) + return + } + } + } + if (selectedCapability.supportsBloomLevels && bloomLevels.length === 0) { + setValidationError(t('validation.bloomRequired')) + return + } + + const input = { + graphBuildId, + elementType, + language, + elementCount, + ...(selectedCapability.supportsDifficulty + ? { difficultyPreset: difficulty } + : {}), + ...(selectedCapability.supportsBloomLevels ? { bloomLevels } : {}), + ...(selectedCapability.supportsSourceScopes + ? { + sourceScopes: selectedScopes.map((scope) => ({ + resourceId: scope.resourceId, + pageFrom: optionalPage(scope.pageFromText), + pageTo: optionalPage(scope.pageToText), + })), + } + : {}), + objectives: objectives + .map(({ text }) => text.trim()) + .filter(Boolean) + .map((text) => ({ text })), + } + const serialized = JSON.stringify(input) + if (idempotencyRef.current?.input !== serialized) { + idempotencyRef.current = { + input: serialized, + key: crypto.randomUUID(), + } + } + + setSubmitting(true) + try { + const result = await startGeneration({ + variables: { + input: { ...input, idempotencyKey: idempotencyRef.current.key }, + }, + }) + const buildId = result.data?.startElementGeneration.id + if (!buildId) throw new Error('Element generation did not return a build') + await onStarted(buildId) + } catch (error) { + const code = elementGenerationErrorCode(error) + setSubmissionError( + code ? t('errors.withCode', { code }) : t('errors.start') + ) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+
+

+ {t('configure.sourceTitle')} +

+

+ {t('configure.sourceHelp')} +

+
+ {sources.map((source) => { + const checked = graphBuildId === source.graphBuildId + return ( + + ) + })} +
+ + {selectedSource && selectedCapability?.supportsSourceScopes ? ( +
+ + {t('configure.sourceDetails')} + +
+ {selectedSource.sources.map((source, index) => { + const scope = sourceScopes[index] + if (!scope) return null + return ( +
+
+ + updateScope(index, { + selected: event.target.checked, + }) + } + className="accent-primary-100 mt-1 h-4 w-4" + data-cy={`element-generation-scope-${index}`} + /> + +
+ {scope.selected && source.pageCount ? ( +
+ + +
+ ) : null} +
+ ) + })} +
+
+ ) : null} +
+ +
+

+ {t('configure.elementTypeTitle')} +

+

+ {t('configure.elementTypeHelp')} +

+
+ {supportedTypes.map((type) => { + const checked = elementType === type + return ( + + ) + })} +
+
+ + {selectedCapability?.supportsBloomLevels ? ( +
+

+ {t('configure.bloomTitle')} +

+

+ {t('configure.bloomHelp')} +

+
+ {capabilities.bloomLevels.map((level) => { + const checked = bloomLevels.includes(level) + return ( + + ) + })} +
+
+ ) : null} + +
+

+ {t('configure.settingsTitle')} +

+
+ + + {selectedCapability?.supportsDifficulty ? ( +
+ + {t('configure.difficulty')} + +
+ {Object.values(ElementGenerationDifficultyPreset).map( + (value) => ( + + ) + )} +
+
+ ) : null} +
+ +
+
+
+

+ {t('configure.objectives')} +

+

+ {t('configure.objectivesHelp')} +

+
+ +
+
+ {objectives.map((objective, index) => ( +
+ + setObjectives((current) => + current.map((item) => + item.id === objective.id + ? { ...item, text: event.target.value } + : item + ) + ) + } + className="min-w-0 flex-1 rounded-md border border-slate-300 px-3 py-2 text-sm" + data-cy={`element-generation-objective-${index}`} + /> + +
+ ))} +
+
+
+
+ + +
+
+ ) +} diff --git a/apps/frontend-manage/src/components/elements/generation/ElementGenerationReviewGate.tsx b/apps/frontend-manage/src/components/elements/generation/ElementGenerationReviewGate.tsx new file mode 100644 index 00000000000..1063e4c8672 --- /dev/null +++ b/apps/frontend-manage/src/components/elements/generation/ElementGenerationReviewGate.tsx @@ -0,0 +1,203 @@ +import { + ElementGenerationReviewDecision, + ElementGenerationReviewGate as ReviewGate, +} from '@klicker-uzh/graphql/dist/ops' +import { Button } from '@uzh-bf/design-system' +import { useTranslations } from 'next-intl' +import { useState } from 'react' +import type { ElementGenerationBuildData } from './elementGenerationTypes' + +interface ElementGenerationReviewGateProps { + build: ElementGenerationBuildData + gate: ReviewGate + loading: boolean + onReview: ( + decision: ElementGenerationReviewDecision, + warningsAcknowledged: boolean + ) => Promise +} + +export default function ElementGenerationReviewGate({ + build, + gate, + loading, + onReview, +}: ElementGenerationReviewGateProps) { + const t = useTranslations('manage.elementGeneration') + const [warningsAcknowledged, setWarningsAcknowledged] = useState(false) + const summary = + gate === ReviewGate.Design ? build.designSummary : build.planSummary + + if (!summary) return null + + const warnings = summary.warnings + const canApprove = warnings.length === 0 || warningsAcknowledged + + return ( +
+
+
+

+ {t('gate.eyebrow')} +

+

+ {t( + gate === ReviewGate.Design ? 'gate.designTitle' : 'gate.planTitle' + )} +

+

+ {t( + gate === ReviewGate.Design + ? 'gate.designDescription' + : 'gate.planDescription' + )} +

+
+ + {t('gate.elementCount', { count: summary.elementCount })} + +
+ + {gate === ReviewGate.Design && build.designSummary ? ( +
+
+

+ {build.designSummary.title} +

+
+ {build.designSummary.modules.map((module) => ( +
+
{module.moduleName}
+
+ {module.elementCount} +
+
+ ))} +
+
+
+

+ {t('gate.objectives')} +

+ {build.designSummary.objectives.length > 0 ? ( +
    + {build.designSummary.objectives.map((objective) => ( +
  • + {objective.text} + {objective.bloomLevel ? ( + + {t(`bloom.${objective.bloomLevel}`)} + + ) : null} +
  • + ))} +
+ ) : ( +

+ {t('gate.noObjectives')} +

+ )} +
+
+ ) : null} + + {gate === ReviewGate.Plan && build.planSummary ? ( +
+ {build.planSummary.elements.map((element, index) => ( +
+
+
+

+ {t('gate.elementNumber', { number: index + 1 })} +

+

+ {element.preview} +

+
+
+ {element.bloomLevel ? ( + + {t(`bloom.${element.bloomLevel}`)} + + ) : null} + {element.targetDifficulty ? ( + + {t('gate.difficulty', { + difficulty: element.targetDifficulty, + })} + + ) : null} +
+
+
+ ))} +
+ ) : null} + + {warnings.length > 0 ? ( +
+

+ {t('gate.warnings', { count: warnings.length })} +

+
    + {warnings.map((warning) => ( +
  • + {warning.message} +
  • + ))} +
+ +
+ ) : null} + +
+ + +
+
+ ) +} diff --git a/apps/frontend-manage/src/components/elements/generation/GeneratedElementReview.tsx b/apps/frontend-manage/src/components/elements/generation/GeneratedElementReview.tsx new file mode 100644 index 00000000000..7356c6f0c44 --- /dev/null +++ b/apps/frontend-manage/src/components/elements/generation/GeneratedElementReview.tsx @@ -0,0 +1,466 @@ +import { useMutation } from '@apollo/client' +import { + DuplicateGeneratedElementDraftDocument, + GeneratableElementType, + GeneratedElementCardType, + GeneratedElementDecision, + SaveGeneratedElementsDocument, + SetGeneratedElementDecisionDocument, + UpdateGeneratedElementDraftDocument, +} from '@klicker-uzh/graphql/dist/ops' +import { Button, UserNotification } from '@uzh-bf/design-system' +import { useTranslations } from 'next-intl' +import { useEffect, useState } from 'react' +import type { + ElementGenerationBuildData, + GeneratedElementDraftData, +} from './elementGenerationTypes' + +interface GeneratedElementCardProps { + draft: GeneratedElementDraftData + onChanged: () => Promise +} + +function GeneratedElementCard({ draft, onChanged }: GeneratedElementCardProps) { + const t = useTranslations('manage.elementGeneration') + const [name, setName] = useState(draft.current.name) + const [prompt, setPrompt] = useState(draft.current.prompt) + const [context, setContext] = useState(draft.current.context ?? '') + const [explanation, setExplanation] = useState( + draft.current.explanation ?? '' + ) + const [cardType, setCardType] = useState( + draft.current.cardType ?? GeneratedElementCardType.Definition + ) + const [tags, setTags] = useState(draft.current.tags.join(', ')) + const [choices, setChoices] = useState(draft.current.choices) + const [error, setError] = useState() + const [updateDraft, updateState] = useMutation( + UpdateGeneratedElementDraftDocument + ) + const [duplicateDraft, duplicateState] = useMutation( + DuplicateGeneratedElementDraftDocument + ) + const [setDecision, decisionState] = useMutation( + SetGeneratedElementDecisionDocument + ) + const isFlashcard = draft.elementType === GeneratableElementType.Flashcard + + useEffect(() => { + setName(draft.current.name) + setPrompt(draft.current.prompt) + setContext(draft.current.context ?? '') + setExplanation(draft.current.explanation ?? '') + setCardType(draft.current.cardType ?? GeneratedElementCardType.Definition) + setTags(draft.current.tags.join(', ')) + setChoices(draft.current.choices) + }, [draft]) + + async function run(action: () => Promise) { + setError(undefined) + try { + await action() + await onChanged() + } catch { + setError(t('review.actionError')) + } + } + + async function saveDraft() { + await run(async () => { + await updateDraft({ + variables: { + input: { + draftId: draft.id, + expectedRevision: draft.revision, + current: isFlashcard + ? { + name: name.trim(), + prompt: prompt.trim(), + explanation: explanation.trim(), + cardType, + tags: tags + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean), + } + : { + name: name.trim(), + prompt: prompt.trim(), + context: context.trim() || null, + explanation: explanation.trim() || null, + choices: choices.map((choice) => ({ + id: choice.id, + label: choice.label, + text: choice.text.trim(), + correct: choice.correct, + feedback: choice.feedback?.trim() || null, + })), + }, + }, + }, + }) + }) + } + + const busy = + updateState.loading || duplicateState.loading || decisionState.loading + + return ( +
+
+
+ + {draft.elementType} + + + {t('review.elementNumber', { number: draft.order + 1 })} + + {draft.duplicationIndex > 0 ? ( + + {t('review.copy', { number: draft.duplicationIndex })} + + ) : null} +
+ + {t(`decisions.${draft.decision}`)} + +
+ +
+ +