feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -0,0 +1,116 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { ZAiTutorSettingsUpdate, ZCourseAiTutorOverride } from '@cio/utils/validation/agent';
import { snackbar } from '$features/ui/snackbar/store';
import type {
CourseAiTutorOverride,
CourseAiTutorView,
GetCourseAiTutorRequest,
GetOrgAiTutorRequest,
OrgAiTutorSettings,
UpdateCourseAiTutorRequest,
UpdateOrgAiTutorRequest
} from '../utils/types';
class AiTutorApi extends BaseApiWithErrors {
orgSettings = $state<OrgAiTutorSettings | null>(null);
courseSettings = $state<CourseAiTutorView | null>(null);
loading = $state(false);
saving = $state(false);
async fetchOrgSettings() {
this.loading = true;
try {
await this.execute<GetOrgAiTutorRequest>({
requestFn: () => classroomio.organization['ai-tutor'].$get(),
logContext: 'fetching org AI tutor settings',
onSuccess: (response) => {
this.orgSettings = response.data;
}
});
} finally {
this.loading = false;
}
}
async updateOrgSettings(patch: Partial<OrgAiTutorSettings>) {
const result = ZAiTutorSettingsUpdate.safeParse(patch);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return;
}
this.saving = true;
try {
await this.execute<UpdateOrgAiTutorRequest>({
requestFn: () => classroomio.organization['ai-tutor'].$put({ json: result.data }),
logContext: 'updating org AI tutor settings',
onSuccess: (response) => {
this.orgSettings = response.data;
this.errors = {};
snackbar.success('aiTutor.snackbar.saved');
},
onError: (result) => {
if (typeof result !== 'string' && 'field' in result && result.field) {
this.errors[result.field] = result.error;
}
}
});
} finally {
this.saving = false;
}
}
async fetchCourseSettings(courseId: string) {
this.loading = true;
try {
await this.execute<GetCourseAiTutorRequest>({
requestFn: () => classroomio.course[':courseId']['ai-tutor'].$get({ param: { courseId } }),
logContext: 'fetching course AI tutor settings',
onSuccess: (response) => {
this.courseSettings = response.data;
}
});
} finally {
this.loading = false;
}
}
async updateCourseSettings(courseId: string, patch: CourseAiTutorOverride) {
const result = ZCourseAiTutorOverride.safeParse(patch ?? {});
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return;
}
this.saving = true;
try {
await this.execute<UpdateCourseAiTutorRequest>({
requestFn: () =>
classroomio.course[':courseId']['ai-tutor'].$put({
param: { courseId },
json: result.data
}),
logContext: 'updating course AI tutor settings',
onSuccess: (response) => {
if (this.courseSettings) {
this.courseSettings = { ...this.courseSettings, override: response.data };
}
this.errors = {};
snackbar.success('aiTutor.snackbar.saved');
}
});
} finally {
this.saving = false;
}
}
}
export const aiTutorApi = new AiTutorApi();
@@ -0,0 +1,295 @@
<script lang="ts">
import type { Writable } from 'svelte/store';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import * as Select from '@cio/ui/base/select';
import { t } from '$lib/utils/functions/translations';
import type { AiTutorSettings } from '@cio/ai-assistant/tutor-config';
interface Props {
store: Writable<AiTutorSettings>;
disabled?: boolean;
}
let { store, disabled = false }: Props = $props();
const personaOptions = [
{ value: 'encouraging', labelKey: 'aiTutor.persona.encouraging' as const },
{ value: 'friendly', labelKey: 'aiTutor.persona.friendly' as const },
{ value: 'formal', labelKey: 'aiTutor.persona.formal' as const },
{ value: 'socratic', labelKey: 'aiTutor.persona.socratic' as const },
{ value: 'custom', labelKey: 'aiTutor.persona.custom' as const }
];
const responseLengthOptions = [
{ value: 'short', labelKey: 'aiTutor.responseLength.short' as const },
{ value: 'medium', labelKey: 'aiTutor.responseLength.medium' as const },
{ value: 'long', labelKey: 'aiTutor.responseLength.long' as const }
];
const assessmentModeOptions = [
{ value: 'hint_only', labelKey: 'aiTutor.assessmentMode.hint_only' as const },
{ value: 'direct_answer', labelKey: 'aiTutor.assessmentMode.direct_answer' as const },
{ value: 'block_during_exercise', labelKey: 'aiTutor.assessmentMode.block_during_exercise' as const }
];
const codePolicyOptions = [
{ value: 'hints_only', labelKey: 'aiTutor.codePolicy.hints_only' as const },
{ value: 'allowed', labelKey: 'aiTutor.codePolicy.allowed' as const },
{ value: 'forbidden', labelKey: 'aiTutor.codePolicy.forbidden' as const }
];
const groundingScopeOptions = [
{ value: 'course', labelKey: 'aiTutor.groundingScope.course' as const },
{ value: 'lesson', labelKey: 'aiTutor.groundingScope.lesson' as const },
{ value: 'workspace', labelKey: 'aiTutor.groundingScope.workspace' as const }
];
let forbiddenTopicsInput = $state($store.forbiddenTopics.join(', '));
$effect(() => {
forbiddenTopicsInput = $store.forbiddenTopics.join(', ');
});
function syncForbiddenTopics() {
const list = forbiddenTopicsInput
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
.slice(0, 20);
store.update((value) => ({ ...value, forbiddenTopics: list }));
}
</script>
<Field.Group>
<Field.Set>
<Field.Legend>{$t('aiTutor.section.availability')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.availability_description')}</Field.Description>
<Field.Field orientation="horizontal">
<Switch bind:checked={$store.enabled} {disabled} />
<Field.Label>{$t('aiTutor.field.enabled')}</Field.Label>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('aiTutor.section.personality')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.personality_description')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('aiTutor.field.persona')}</Field.Label>
<Select.Root type="single" bind:value={$store.persona} {disabled}>
<Select.Trigger
>{$t(
personaOptions.find((o) => o.value === $store.persona)?.labelKey ?? 'aiTutor.persona.encouraging'
)}</Select.Trigger
>
<Select.Content>
{#each personaOptions as opt (opt.value)}
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
{#if $store.persona === 'custom'}
<Field.Field>
<Field.Label>{$t('aiTutor.field.customPersona')}</Field.Label>
<Textarea rows={3} bind:value={$store.customPersona} {disabled} />
<Field.Description>{$t('aiTutor.field.customPersona_description')}</Field.Description>
</Field.Field>
{/if}
<Field.Field>
<Field.Label>{$t('aiTutor.field.responseLength')}</Field.Label>
<Select.Root type="single" bind:value={$store.responseLength} {disabled}>
<Select.Trigger
>{$t(
responseLengthOptions.find((o) => o.value === $store.responseLength)?.labelKey ??
'aiTutor.responseLength.medium'
)}</Select.Trigger
>
<Select.Content>
{#each responseLengthOptions as opt (opt.value)}
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.welcomeMessage')}</Field.Label>
<Textarea rows={2} maxlength={500} bind:value={$store.welcomeMessage} {disabled} />
<Field.Description>{$t('aiTutor.field.welcomeMessage_description')}</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.disclaimerFooter')}</Field.Label>
<Input maxlength={200} bind:value={$store.disclaimerFooter} {disabled} />
<Field.Description>{$t('aiTutor.field.disclaimerFooter_description')}</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('aiTutor.section.guidance')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.guidance_description')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('aiTutor.field.thingsToSay')}</Field.Label>
<Textarea rows={3} maxlength={2000} bind:value={$store.thingsToSay} {disabled} />
<Field.Description>{$t('aiTutor.field.thingsToSay_description')}</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.thingsNotToSay')}</Field.Label>
<Textarea rows={3} maxlength={2000} bind:value={$store.thingsNotToSay} {disabled} />
<Field.Description>{$t('aiTutor.field.thingsNotToSay_description')}</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.forbiddenTopics')}</Field.Label>
<Input
bind:value={forbiddenTopicsInput}
onblur={syncForbiddenTopics}
placeholder={$t('aiTutor.field.forbiddenTopics_placeholder')}
{disabled}
/>
<Field.Description>{$t('aiTutor.field.forbiddenTopics_description')}</Field.Description>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={$store.blockOffTopic} {disabled} />
<Label>{$t('aiTutor.field.blockOffTopic')}</Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={$store.profanityFilter} {disabled} />
<Label>{$t('aiTutor.field.profanityFilter')}</Label>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('aiTutor.section.assessment')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.assessment_description')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('aiTutor.field.assessmentMode')}</Field.Label>
<Select.Root type="single" bind:value={$store.assessmentMode} {disabled}>
<Select.Trigger
>{$t(
assessmentModeOptions.find((o) => o.value === $store.assessmentMode)?.labelKey ??
'aiTutor.assessmentMode.hint_only'
)}</Select.Trigger
>
<Select.Content>
{#each assessmentModeOptions as opt (opt.value)}
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.revealSolutionsAfterAttempts')}</Field.Label>
<Input
type="number"
min="0"
max="20"
value={$store.revealSolutionsAfterAttempts}
oninput={(e) => {
const v = Number((e.target as HTMLInputElement).value);
if (Number.isFinite(v)) store.update((s) => ({ ...s, revealSolutionsAfterAttempts: v }));
}}
{disabled}
/>
<Field.Description>{$t('aiTutor.field.revealSolutionsAfterAttempts_description')}</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.codePolicy')}</Field.Label>
<Select.Root type="single" bind:value={$store.codePolicy} {disabled}>
<Select.Trigger
>{$t(
codePolicyOptions.find((o) => o.value === $store.codePolicy)?.labelKey ?? 'aiTutor.codePolicy.hints_only'
)}</Select.Trigger
>
<Select.Content>
{#each codePolicyOptions as opt (opt.value)}
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('aiTutor.section.grounding')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.grounding_description')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('aiTutor.field.groundingScope')}</Field.Label>
<Select.Root type="single" bind:value={$store.groundingScope} {disabled}>
<Select.Trigger
>{$t(
groundingScopeOptions.find((o) => o.value === $store.groundingScope)?.labelKey ??
'aiTutor.groundingScope.course'
)}</Select.Trigger
>
<Select.Content>
{#each groundingScopeOptions as opt (opt.value)}
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={$store.requireCitations} {disabled} />
<Label>{$t('aiTutor.field.requireCitations')}</Label>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('aiTutor.section.escalation')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.escalation_description')}</Field.Description>
<Field.Group>
<Field.Field orientation="horizontal">
<Switch bind:checked={$store.escalation.enabled} {disabled} />
<Label>{$t('aiTutor.field.escalationEnabled')}</Label>
</Field.Field>
<Field.Field>
<Field.Label>{$t('aiTutor.field.escalationEmail')}</Field.Label>
<Input
type="email"
bind:value={$store.escalation.email}
placeholder="instructor@example.com"
disabled={disabled || !$store.escalation.enabled}
/>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
@@ -0,0 +1,3 @@
export { aiTutorApi } from './api/ai-tutor.svelte';
export { default as OrgAiTutorSettingsPage } from './pages/org-settings.svelte';
export { default as CourseAiTutorSettingsPage } from './pages/course-settings.svelte';
@@ -0,0 +1,87 @@
<script lang="ts">
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import { defaultAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
import * as Page from '@cio/ui/base/page';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import { t } from '$lib/utils/functions/translations';
import { aiTutorApi } from '../api/ai-tutor.svelte';
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
interface Props {
courseId: string;
}
let { courseId }: Props = $props();
const formStore = writable<AiTutorSettings>({ ...defaultAiTutorSettings });
let inheritFromOrg = $state(true);
let initialized = $state(false);
onMount(async () => {
await aiTutorApi.fetchCourseSettings(courseId);
const view = aiTutorApi.courseSettings;
if (view) {
formStore.set(view.effective ?? defaultAiTutorSettings);
inheritFromOrg = view.override == null || view.override.inheritFromOrg !== false;
}
initialized = true;
});
async function handleSave() {
if (inheritFromOrg) {
await aiTutorApi.updateCourseSettings(courseId, { inheritFromOrg: true });
return;
}
const data = $formStore;
await aiTutorApi.updateCourseSettings(courseId, { ...data, inheritFromOrg: false });
}
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header isSticky class="ui:bg-background z-10">
<Page.HeaderContent>
<Page.Title>{$t('aiTutor.page.course.title')}</Page.Title>
<Page.Subtitle>{$t('aiTutor.page.course.description')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button loading={aiTutorApi.saving} disabled={aiTutorApi.saving || !initialized} onclick={handleSave}>
{$t('aiTutor.action.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
{#if !initialized}
<p class="ui:text-muted-foreground text-sm">{$t('aiTutor.state.loading')}</p>
{:else}
<Field.Group>
<Field.Set>
<Field.Legend>{$t('aiTutor.section.inheritance')}</Field.Legend>
<Field.Description>{$t('aiTutor.section.inheritance_description')}</Field.Description>
<Field.Field orientation="horizontal">
<Switch bind:checked={inheritFromOrg} />
<Label>{$t('aiTutor.field.inheritFromOrg')}</Label>
</Field.Field>
</Field.Set>
{#if !inheritFromOrg}
<Field.Separator />
<TutorSettingsForm store={formStore} disabled={aiTutorApi.saving} />
{/if}
</Field.Group>
{/if}
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,48 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaultAiTutorSettings } from '@cio/ai-assistant/tutor-config';
import * as Page from '@cio/ui/base/page';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { aiTutorApi } from '../api/ai-tutor.svelte';
import { applyOrgSettings, orgTutorSettingsStore } from '../store/tutor-settings-store';
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
let initialized = $state(false);
onMount(async () => {
await aiTutorApi.fetchOrgSettings();
applyOrgSettings(aiTutorApi.orgSettings ?? defaultAiTutorSettings);
initialized = true;
});
async function handleSave() {
await aiTutorApi.updateOrgSettings($orgTutorSettingsStore);
}
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header isSticky class="ui:bg-background z-10">
<Page.HeaderContent>
<Page.Title>{$t('aiTutor.page.org.title')}</Page.Title>
<Page.Subtitle>{$t('aiTutor.page.org.description')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button loading={aiTutorApi.saving} disabled={aiTutorApi.saving || !initialized} onclick={handleSave}>
{$t('aiTutor.action.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
{#if !initialized}
<p class="ui:text-muted-foreground text-sm">{$t('aiTutor.state.loading')}</p>
{:else}
<TutorSettingsForm store={orgTutorSettingsStore} disabled={aiTutorApi.saving} />
{/if}
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,30 @@
import { writable } from 'svelte/store';
import { defaultAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
export type AiTutorFormState = AiTutorSettings;
export const orgTutorSettingsStore = writable<AiTutorFormState>({ ...defaultAiTutorSettings });
export interface CourseTutorFormState {
override: AiTutorSettings;
inheritFromOrg: boolean;
}
export const courseTutorSettingsStore = writable<CourseTutorFormState>({
override: { ...defaultAiTutorSettings },
inheritFromOrg: true
});
export function applyOrgSettings(settings: AiTutorSettings) {
orgTutorSettingsStore.set({ ...defaultAiTutorSettings, ...settings });
}
export function applyCourseSettings(
effective: AiTutorSettings,
override: (Partial<AiTutorSettings> & { inheritFromOrg?: boolean }) | null
) {
courseTutorSettingsStore.set({
override: { ...effective, ...(override ?? {}) },
inheritFromOrg: override?.inheritFromOrg !== false && override == null ? true : (override?.inheritFromOrg ?? false)
});
}
@@ -0,0 +1,27 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type GetOrgAiTutorRequest = (typeof classroomio.organization)['ai-tutor']['$get'];
export type UpdateOrgAiTutorRequest = (typeof classroomio.organization)['ai-tutor']['$put'];
export type GetCourseAiTutorRequest = (typeof classroomio.course)[':courseId']['ai-tutor']['$get'];
export type UpdateCourseAiTutorRequest = (typeof classroomio.course)[':courseId']['ai-tutor']['$put'];
export type GetOrgAiTutorSuccess = Extract<InferResponseType<GetOrgAiTutorRequest>, { success: true }>;
export type OrgAiTutorSettings = GetOrgAiTutorSuccess['data'];
export type GetCourseAiTutorSuccess = Extract<InferResponseType<GetCourseAiTutorRequest>, { success: true }>;
export type CourseAiTutorView = GetCourseAiTutorSuccess['data'];
export type CourseAiTutorOverride = CourseAiTutorView['override'];
export type GetTutorLeaderboardRequest = (typeof classroomio.agent)['tutor-usage']['leaderboard']['$get'];
export type GetTutorSummaryRequest = (typeof classroomio.agent)['tutor-usage']['summary']['$get'];
export type GetTutorLearnerDetailRequest = (typeof classroomio.agent)['tutor-usage'][':userId']['$get'];
export type TutorLeaderboardSuccess = Extract<InferResponseType<GetTutorLeaderboardRequest>, { success: true }>;
export type TutorLeaderboardData = TutorLeaderboardSuccess['data'];
export type TutorSummarySuccess = Extract<InferResponseType<GetTutorSummaryRequest>, { success: true }>;
export type TutorSummaryData = TutorSummarySuccess['data'];
export type TutorLearnerDetailSuccess = Extract<InferResponseType<GetTutorLearnerDetailRequest>, { success: true }>;
export type TutorLearnerDetailData = TutorLearnerDetailSuccess['data'];