Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
e5a88fb
fixed version and main updates for the super assistant
Sep 12, 2025
e011b4a
extractor of uploaded files
Sep 15, 2025
b5b2892
extractor of uploaded files
Sep 15, 2025
659d58a
added functionalities to the extractor page
Sep 15, 2025
bdf8f92
feat: student side functional
Sep 19, 2025
0f4b63e
fixed duplication on admin side
Sep 20, 2025
c551cd4
fix: clean backend for new UI
Sep 20, 2025
b93e9bc
started updates admin isde
Sep 20, 2025
0e05f5a
chnage for the admin side
Sep 20, 2025
6cace78
feat: UI refactoring on the student side
Sep 20, 2025
dbfece7
Merge branch 'super-assistant-rebase' of https://github.com/expliquea…
Sep 20, 2025
64ce929
Fix: removed "Uploaded Files" tab from admin side
Sep 20, 2025
00ce095
fix: back button from attempt + size of problem bubbles
Sep 20, 2025
afac6e6
fix: color of problem bubbles
Sep 20, 2025
f3a0018
Merge remote-tracking branch 'origin/main' into super-assistant-rebase
Sep 20, 2025
042b9ac
fixed merge with main
Sep 23, 2025
dce98dc
fixed merge with main
Sep 23, 2025
bf1246e
merging main updates Merge remote-tracking branch 'origin/main' into …
Sep 24, 2025
89716a1
updates with main
Sep 24, 2025
3f09453
fix: lint test
Sep 25, 2025
b2dfa4d
updates with main
Sep 25, 2025
b30b9b5
chore: Merge branch 'nightly' into feat/super-assistant
Mw3y Sep 30, 2025
e9c24f0
fix(ci): fix staging BASE_URL
Mw3y Sep 30, 2025
0324a10
feat: create a k8s deployment files per gitlab pipeline definition
Mw3y Sep 30, 2025
bc678bf
fix: update convex deploy command to handle preview cases
Mw3y Sep 30, 2025
85aa24f
feat(lectures): allow direct m3u8 urls
Mw3y Sep 30, 2025
6e98817
fix: do not redirect user to home page when creating a new attempt
Mw3y Sep 30, 2025
3aa512f
feat: remember user last used course
Mw3y Sep 30, 2025
509ce39
chore: merge branch 'nightly' into feat/super-assistant
Mw3y Sep 30, 2025
4b3e653
feat: automatically register superadmin to course when trying to acce…
Mw3y Sep 30, 2025
1e6cd26
fix: lastCourseSlug can be null
Mw3y Oct 1, 2025
4fb70f1
fix: the size and display of the porblems and exercises on the admin …
Oct 2, 2025
0a4a93e
fix: the name of the problems/ assistant
Oct 2, 2025
ad47dab
fix: the format handling for images on the student side and style adm…
Oct 2, 2025
ded5894
feat(ci): add demo deployment
Mw3y Oct 3, 2025
7de21de
feat: use previews subdomain for demo temporarily
Mw3y Oct 3, 2025
df4c32c
fix(lectures): remove m3u8 processing prevention
Mw3y Oct 3, 2025
2501038
chore: comment out demo deployment for now
Mw3y Oct 3, 2025
624e76a
fix(admin): handle missing user account from course registration
Mw3y Oct 4, 2025
648097d
fix:specify thatstudent must upload an image type file
Oct 7, 2025
3b85606
fix:specify thatstudent must upload an image type file
Oct 7, 2025
a29da3d
fix:problems statements and solutions appear as markdown
Oct 7, 2025
d1fa6ef
fix(admin): handle missing user account from course registration
Mw3y Oct 4, 2025
fc9effe
feature: professor can customize instruction
Oct 26, 2025
729885a
feature: students can reset problems
Oct 26, 2025
e90b8b5
fix: student directly starts on chat page and not the popup for a pro…
Oct 27, 2025
1b689c1
chore: merge branch 'main' of https://github.com/expliqueai/explique
Mw3y Nov 14, 2025
31c70f6
feat: add youtube video support
Mw3y Nov 26, 2025
f7d5ce7
chore: merge remote-tracking branch 'refs/remotes/origin/main'
Mw3y Nov 26, 2025
c29ece6
fix: cap submission timeout at 60s in case of clock discrepency
Mw3y Nov 26, 2025
8ae1f48
fix(lectures): remove youtube processing blocking system
Mw3y Dec 1, 2025
9467a73
Updated package
eoula Dec 7, 2025
997520e
after npm audit fix
eoula Dec 7, 2025
8a7179b
fix: restart button grows with exercise blob
Dec 27, 2025
c351d8c
feat: gemini with vercel ai sdk
Dec 28, 2025
50cbe4d
feat: restart button inside chat + chat refuses unrelated image
Dec 29, 2025
20f186b
fix: first image displayed is the correct one
Dec 30, 2025
951132f
chore: merge branch 'main' into feat/super-assistant
Mw3y Jan 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ build-staging:
- export BUILD_BASE_URL="https://${CI_COMMIT_REF_SLUG}.previews.${BASE_DOMAIN}"
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/$DOCKERFILE_PATH --destination $HARBOR_REGISTRY/$REPO_NAME/$IMAGE_NAME:$BUILD_TAG --build-arg CONVEX_DEPLOY_KEY=$CONVEX_DEPLOY_KEY --build-arg SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN --build-arg BASE_URL=$BUILD_BASE_URL --build-arg "JWT_KEY='$JWT_KEY'" --build-arg "ADMIN_API_PUBLIC_KEY='$ADMIN_API_PUBLIC_KEY'" --build-arg IDENTIFIER_SALT=$IDENTIFIER_SALT --build-arg CI_COMMIT_REF_SLUG=$CI_COMMIT_REF_SLUG

# build-staging-demo:
# stage: build
# only:
# refs:
# - main
# environment:
# name: demo
# image:
# name: gcr.io/kaniko-project/executor:debug
# entrypoint: [""]
# script:
# - mkdir -p /kaniko/.docker
# - echo $DOCKER_AUTH_CONFIG > /kaniko/.docker/config.json
# - cat /kaniko/.docker/config.json
# - export BUILD_TAG="staging-demo"
# - export BUILD_BASE_URL="https://demo.previews.${BASE_DOMAIN}"
# - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/$DOCKERFILE_PATH --destination $HARBOR_REGISTRY/$REPO_NAME/$IMAGE_NAME:$BUILD_TAG --build-arg CONVEX_DEPLOY_KEY=$CONVEX_DEPLOY_KEY --build-arg SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN --build-arg BASE_URL=$BUILD_BASE_URL --build-arg "JWT_KEY='$JWT_KEY'" --build-arg "ADMIN_API_PUBLIC_KEY='$ADMIN_API_PUBLIC_KEY'" --build-arg IDENTIFIER_SALT=$IDENTIFIER_SALT

deploy-to-prod:
stage: deploy
when: manual
Expand All @@ -91,6 +109,26 @@ deploy-to-prod:
script:
- *EXEC_SCRIPT

# deploy-to-staging-demo:
# stage: deploy
# only:
# refs:
# - main
# environment:
# name: staging-demo
# url: https://demo.previews.$BASE_DOMAIN
# variables:
# APPLICATION_NAME: "explique-demo"
# TAG: "staging-demo"
# FQDN: "demo.previews.${BASE_DOMAIN}"
# TLS_SECRET_NAME: "previews.explique.ai-tls"
# DEPLOYMENT_FILE: "deployment-staging.yaml"
# image:
# name: ic-registry.epfl.ch/tools/helm-kubectl-docker:latest
# entrypoint: [""]
# script:
# - *EXEC_SCRIPT

deploy-branch:
stage: deploy
only:
Expand Down
6 changes: 6 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ import type * as quiz from "../quiz.js";
import type * as superadmin_courses from "../superadmin/courses.js";
import type * as superadmin_relocation from "../superadmin/relocation.js";
import type * as superadmin_util from "../superadmin/util.js";
import type * as superassistant_attempt from "../superassistant/attempt.js";
import type * as superassistant_messages from "../superassistant/messages.js";
import type * as superassistant_problem from "../superassistant/problem.js";
import type * as video_chat from "../video/chat.js";
import type * as weeks from "../weeks.js";

Expand Down Expand Up @@ -79,6 +82,9 @@ declare const fullApi: ApiFromModules<{
"superadmin/courses": typeof superadmin_courses;
"superadmin/relocation": typeof superadmin_relocation;
"superadmin/util": typeof superadmin_util;
"superassistant/attempt": typeof superassistant_attempt;
"superassistant/messages": typeof superassistant_messages;
"superassistant/problem": typeof superassistant_problem;
"video/chat": typeof video_chat;
weeks: typeof weeks;
}>;
Expand Down
38 changes: 24 additions & 14 deletions convex/admin/exercises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,26 +58,36 @@ export const list = queryWithAuth({
.order("desc")
.collect()

// @TODO Only query the exercises from this course
const exercises = await db.query("exercises").collect()
const exercises = await db.query("exercises").collect();
const problems = await db.query("problems").collect();

const result = []
for (const week of weeks) {
const resultExercises = []
for (const exercise of exercises) {
if (exercise.weekId === week._id) {
resultExercises.push({
id: exercise._id,
name: exercise.name,
image: await getImageForExercise(db, storage, exercise),
})
}
}
const resultExercises = await Promise.all(
exercises
.filter((ex) => ex.weekId === week._id)
.map(async (ex) => ({
type: "exercise",
id: ex._id,
name: ex.name,
image: await getImageForExercise(db, storage, ex),
}))
);

const resultProblems = problems
.filter((p) => p.weekId === week._id)
.map((p) => ({
type: "problem" as const,
id: p._id,
number: p.name,
mandatory: p.mandatory ?? false,
instructions: p.instructions,
}));

result.push({
...week,
exercises: resultExercises,
})
items: [...resultExercises, ...resultProblems],
});
}

return result
Expand Down
58 changes: 39 additions & 19 deletions convex/admin/lectures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,53 @@ const MEDIASPACE_REGEX =
const M3U8_REGEX =
/^https:\/\/vod\.kaltura\.switch\.ch\/hls\/.*\/entryId\/(0_[a-zA-Z0-9]+)\/.*\/index\.m3u8$/

const YOUTUBE_REGEX =
/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/(watch\?v=|embed\/)?([a-zA-Z0-9_-]{11})$/

function validateAndExtractVideoUrl(url: string): {
isValid: boolean
videoId?: string
isDirectM3u8?: boolean
isYoutube?: boolean
error?: string
} {
// Check for MediaSpace URL
const mediaSpaceMatch = url.match(MEDIASPACE_REGEX)
if (mediaSpaceMatch) {
return { isValid: true, videoId: mediaSpaceMatch[1], isDirectM3u8: false }
return {
isValid: true,
videoId: mediaSpaceMatch[1],
isDirectM3u8: false,
isYoutube: false,
}
}

// Check for direct m3u8 URL
const m3u8Match = url.match(M3U8_REGEX)
if (m3u8Match) {
return { isValid: true, videoId: m3u8Match[1], isDirectM3u8: true }
return {
isValid: true,
videoId: m3u8Match[1],
isDirectM3u8: true,
isYoutube: false,
}
}

// Check for YouTube URL
const youtubeMatch = url.match(YOUTUBE_REGEX)
if (youtubeMatch) {
return {
isValid: true,
videoId: youtubeMatch[5],
isDirectM3u8: false,
isYoutube: true,
}
}

return {
isValid: false,
error:
"Please enter a valid EPFL MediaSpace URL (https://mediaspace.epfl.ch/media/.../0_...) or direct m3u8 link (https://vod.kaltura.switch.ch/hls/.../entryId/0_.../...index.m3u8)",
"Please enter a valid EPFL MediaSpace URL (https://mediaspace.epfl.ch/media/.../0_...), a direct m3u8 link (https://vod.kaltura.switch.ch/hls/.../entryId/0_.../...index.m3u8) or a YouTube URL.",
}
}

Expand Down Expand Up @@ -210,6 +235,11 @@ export const create = actionWithAuth({
throw new ConvexError("Invalid week")
}

const urlValidation = validateAndExtractVideoUrl(lecture.url)
if (urlValidation.isYoutube) {
lecture.url = `https://www.youtube.com/embed/${urlValidation.videoId}`
}

const id = await ctx.runAction(
internal.admin.lectures.createInternal,
lecture
Expand All @@ -234,32 +264,22 @@ export const processVideo = internalAction({
// Validate URL and extract video ID
const urlValidation = validateAndExtractVideoUrl(url)
if (!urlValidation.isValid) {
throw new Error(urlValidation.error || "Invalid video URL")
}

// If it's a direct m3u8 URL, use it as-is without processing
if (urlValidation.isDirectM3u8) {
// The URL is already a direct m3u8 link, no need to process
await ctx.runMutation(api.admin.lectures.setStatus, {
lectureId,
status: "READY",
authToken: process.env.VIDEO_PROCESSING_API_TOKEN!,
})
return
throw new ConvexError(urlValidation.error || "Invalid video URL")
}

// For MediaSpace URLs, process them through the video processing service
const processingUrl = process.env.LECTURES_PROCESSING_URL
if (!processingUrl) {
throw new Error(
throw new ConvexError(
"LECTURES_PROCESSING_URL environment variable is not configured"
)
}

const response = await fetch(processingUrl, {
method: "POST",
body: JSON.stringify({
kaltura_video_id: urlValidation.videoId,
...(urlValidation.isYoutube
? { youtube_video_id: urlValidation.videoId }
: { kaltura_video_id: urlValidation.videoId }),
convex_lecture_id: lectureId,
}),
headers: {
Expand All @@ -269,7 +289,7 @@ export const processVideo = internalAction({

if (!response.ok) {
const errorText = await response.text()
throw new Error(
throw new ConvexError(
`Failed to start video processing: ${response.status} ${response.statusText} - ${errorText}`
)
}
Expand Down
18 changes: 13 additions & 5 deletions convex/admin/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ async function formatListElement(
registration: Doc<"registrations">
) {
const user = await db.get(registration.userId)
if (!user) throw new Error("User of registration not found")
if (!user) {
// User not found, possibly manually deleted account
// Only the registration remains
console.error(`User not found for registration: ${registration._id}`)
return null
}

return {
id: registration.userId,
email: user.email,
Expand Down Expand Up @@ -77,11 +83,13 @@ export const list = queryWithAuth({

return {
...registrations,
page: await Promise.all(
registrations.page.map((registration) =>
formatListElement(db, registration)
page: (
await Promise.all(
registrations.page.map((registration) =>
formatListElement(db, registration)
)
)
),
).filter((x): x is NonNullable<typeof x> => x !== null),
}
},
})
Expand Down
13 changes: 10 additions & 3 deletions convex/courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,14 @@ export const getMyRegistrations = queryWithAuth({
.withIndex("by_user", (q) => q.eq("userId", session.user._id))
.collect()

const results = []
const results: Array<{
id: Id<"courses">
code: string
slug: string
name: string
isAdmin: boolean
}> = []

for (const registration of registrations) {
const course = await db.get(registration.courseId)
if (!course) {
Expand All @@ -127,7 +134,7 @@ export const getMyRegistrations = queryWithAuth({

export const getPreferredCourse = queryWithAuth({
args: {
lastCourseSlug: v.optional(v.string()),
lastCourseSlug: v.optional(v.union(v.string(), v.null())),
},
handler: async ({ db, session }, { lastCourseSlug }) => {
if (!session) return { error: "not_logged_in" }
Expand Down Expand Up @@ -166,7 +173,7 @@ export const getPreferredCourse = queryWithAuth({

const course = await db.get(mostRecentRegistration.courseId)
if (!course) {
throw new Error("Course not found.")
throw new ConvexError("Course not found.")
}

return { slug: course.slug }
Expand Down
2 changes: 2 additions & 0 deletions convex/internal/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export default internalMutation(async (ctx) => {
"julie.terrassier@epfl.ch",
"juliane.mercoli@epfl.ch",
"maxence.espagnet@epfl.ch",
"ju.mercoli@gmail.com",
"terrassier.j@gmail.com",
]) {
adminIds.push(
await ctx.db.insert("users", {
Expand Down
58 changes: 58 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,54 @@ export default defineSchema(
),
}).index("by_attempt", ["attemptId"]),

saAttempts: defineTable({
problemId: v.id("problems"),
userId: v.id("users"),
name: v.string(),
images: v.array(v.id("_storage")),
lastModified: v.number(),
validated: v.boolean(),
}).index("by_key", ["userId", "problemId"]),
saMessages: defineTable({
attemptId: v.id("saAttempts"),
role: v.union(
v.literal("user"),
v.literal("system"),
v.literal("assistant"),
),
content: v.union(
v.string(),
v.array(
v.union(
v.object({
type: v.literal("text"),
text: v.string(),
}),
v.object({
type: v.literal("image"),
image: v.string(),
}),
),
),
),
appearance: v.optional(
v.union(
v.literal("finished"),
v.literal("feedback"),
v.literal("typing"),
v.literal("error"),
),
),
streaming: v.optional(v.boolean()),
}).index("by_attempt", ["attemptId"]),
problems: defineTable({
weekId: v.id("weeks"),
name: v.string(),
instructions: v.string(),
solutions: v.optional(v.string()),
mandatory: v.boolean(),
customInstructions: v.optional(v.string()),
}).index("by_week", ["weekId"]),
reports: defineTable({
attemptId: v.id("attempts"),
messageId: v.id("messages"),
Expand All @@ -190,6 +238,16 @@ export default defineSchema(
.index("by_message", ["messageId"])
.index("by_course", ["courseId"]),

saReports: defineTable({
attemptId: v.id("saAttempts"),
messageId: v.id("saMessages"),
courseId: v.id("courses"),
reason: v.string(),
})
.index("by_attempt", ["attemptId"])
.index("by_message", ["messageId"])
.index("by_course", ["courseId"]),

logs: defineTable({
type: v.union(
v.literal("attemptStarted"),
Expand Down
Loading