feat(client,challenge-parser): support audio and transcript in quiz questions (#65711)

This commit is contained in:
Huyen Nguyen
2026-02-11 23:52:49 -08:00
committed by GitHub
parent 5202f95af4
commit 1108d25883
20 changed files with 769 additions and 7 deletions
@@ -0,0 +1,36 @@
# --quizzes--
## --quiz--
### --question--
#### --text--
Question with audio but empty transcript
#### --audio--
```json
{
"audio": {
"filename": "audio-with-empty-transcript.mp3"
},
"transcript": []
}
```
#### --distractors--
Distractor 1
---
Distractor 2
---
Distractor 3
#### --answer--
Answer
@@ -0,0 +1,42 @@
# --quizzes--
## --quiz--
### --question--
#### --text--
Question with invalid JSON audio
#### --audio--
```json
{
"audio": {
"filename": "audio.mp3"
},
"transcript": [
{
"character": "A",
"text": "Hello"
}
]
// missing comma
}
```
#### --distractors--
Distractor 1
---
Distractor 2
---
Distractor 3
#### --answer--
Answer
@@ -0,0 +1,39 @@
# --quizzes--
## --quiz--
### --question--
#### --text--
Question with audio missing filename
#### --audio--
```json
{
"audio": {},
"transcript": [
{
"character": "A",
"text": "Hello"
}
]
}
```
#### --distractors--
Distractor 1
---
Distractor 2
---
Distractor 3
#### --answer--
Answer
@@ -0,0 +1,35 @@
# --quizzes--
## --quiz--
### --question--
#### --text--
Question with audio but no transcript
#### --audio--
```json
{
"audio": {
"filename": "audio-without-transcript.mp3"
}
}
```
#### --distractors--
Distractor 1
---
Distractor 2
---
Distractor 3
#### --answer--
Answer
@@ -0,0 +1,29 @@
# --quizzes--
## --quiz--
### --question--
#### --text--
Question with audio not in JSON code block
#### --audio--
Some plain text audio data
#### --distractors--
Distractor 1
---
Distractor 2
---
Distractor 3
#### --answer--
Answer
@@ -0,0 +1,40 @@
# --quizzes--
## --quiz--
### --question--
#### --text--
Question with audio transcript line missing character
#### --audio--
```json
{
"audio": {
"filename": "audio.mp3"
},
"transcript": [
{
"text": "Hello"
}
]
}
```
#### --distractors--
Distractor 1
---
Distractor 2
---
Distractor 3
#### --answer--
Answer
@@ -0,0 +1,40 @@
# --quizzes--
## --quiz--
### --question--
#### --text--
Question with audio transcript line missing text
#### --audio--
```json
{
"audio": {
"filename": "audio.mp3"
},
"transcript": [
{
"character": "A"
}
]
}
```
#### --distractors--
Distractor 1
---
Distractor 2
---
Distractor 3
#### --answer--
Answer
@@ -0,0 +1,36 @@
# --quizzes--
## --quiz--
### --question--
#### --text--
Question with audio transcript not array
#### --audio--
```json
{
"audio": {
"filename": "audio.mp3"
},
"transcript": "not an array"
}
```
#### --distractors--
Distractor 1
---
Distractor 2
---
Distractor 3
#### --answer--
Answer
@@ -0,0 +1,107 @@
# --quizzes--
## --quiz--
### --question--
#### --text--
Quiz 1, question 1 with audio timestamps
#### --audio--
```json
{
"audio": {
"filename": "audio-with-timestamps.mp3",
"startTimestamp": 1.5,
"finishTimestamp": 3.8
},
"transcript": [
{
"character": "Maria",
"text": "Hello, how are you?"
},
{
"character": "Tom",
"text": "I'm doing well, thank you."
}
]
}
```
#### --distractors--
Quiz 1, question 1, distractor 1
---
Quiz 1, question 1, distractor 2
---
Quiz 1, question 1, distractor 3
#### --answer--
Quiz 1, question 1, answer
### --question--
#### --text--
Quiz 1, question 2 with audio but no timestamps
#### --audio--
```json
{
"audio": {
"filename": "audio-full-file.mp3"
},
"transcript": [
{
"character": "Speaker",
"text": "This is the full audio transcript."
}
]
}
```
#### --distractors--
Quiz 1, question 2, distractor 1
---
Quiz 1, question 2, distractor 2
---
Quiz 1, question 2, distractor 3
#### --answer--
Quiz 1, question 2, answer
### --question--
#### --text--
Quiz 1, question 3 without audio
#### --distractors--
Quiz 1, question 3, distractor 1
---
Quiz 1, question 3, distractor 2
---
Quiz 1, question 3, distractor 3
#### --answer--
Quiz 1, question 3, answer
@@ -21,7 +21,7 @@ function plugin() {
});
}
function getQuestion(textNodes, distractorNodes, answerNodes) {
function getQuestion(textNodes, distractorNodes, answerNodes, audioNodes) {
const text = toHtml(textNodes);
const distractors = getDistractors(distractorNodes);
const answer = toHtml(answerNodes);
@@ -31,7 +31,52 @@ function plugin() {
throw Error('--distractors-- are missing from quiz question');
if (!answer) throw Error('--answer-- is missing from quiz question');
return { text, distractors, answer };
const questionData = { text, distractors, answer };
// Extract audio data if present
if (audioNodes.length > 0) {
// Audio should be in a JSON code block
if (audioNodes[0].type !== 'code' || audioNodes[0].lang !== 'json') {
throw Error('--audio-- section must contain a ```json code block');
}
try {
const audioData = JSON.parse(audioNodes[0].value);
// Validate structure
if (!audioData.audio || !audioData.audio.filename) {
throw Error('--audio-- section must contain audio.filename');
}
if (!audioData.transcript || !Array.isArray(audioData.transcript)) {
throw Error(
'--audio-- section must contain transcript as an array'
);
}
if (audioData.transcript.length === 0) {
throw Error('--audio-- section transcript array cannot be empty');
}
// Validate each transcript line
audioData.transcript.forEach((line, index) => {
if (!line.character || !line.text) {
throw Error(
`--audio-- transcript line ${index} must have character and text properties`
);
}
});
questionData.audioData = audioData;
} catch (error) {
if (error instanceof SyntaxError) {
throw Error('--audio-- section must contain valid JSON');
}
throw error;
}
}
return questionData;
}
if (quizzesNodes.length > 0) {
@@ -60,9 +105,10 @@ function plugin() {
const textNodes = getSection(questionTree, '--text--');
const distractorNodes = getSection(questionTree, '--distractors--');
const answerNodes = getSection(questionTree, '--answer--');
const audioNodes = getSection(questionTree, '--audio--');
quizQuestions.push(
getQuestion(textNodes, distractorNodes, answerNodes)
getQuestion(textNodes, distractorNodes, answerNodes, audioNodes)
);
});
@@ -5,12 +5,46 @@ import addQuizzes from './add-quizzes';
describe('add-quizzes plugin', () => {
let mockQuizzesAST;
let chineseQuizzesAST;
let quizzesWithAudioAST;
let quizzesWithAudioInvalidJsonAST;
let quizzesWithAudioMissingFilenameAST;
let quizzesWithAudioTranscriptNotArrayAST;
let quizzesWithAudioEmptyTranscriptAST;
let quizzesWithAudioMissingTranscriptAST;
let quizzesWithAudioTranscriptMissingCharacterAST;
let quizzesWithAudioTranscriptMissingTextAST;
let quizzesWithAudioNotJsonCodeBlockAST;
const plugin = addQuizzes();
let file = { data: {} };
beforeAll(async () => {
mockQuizzesAST = await parseFixture('with-quizzes.md');
chineseQuizzesAST = await parseFixture('with-chinese-quizzes.md');
quizzesWithAudioAST = await parseFixture('with-quizzes-audio.md');
quizzesWithAudioInvalidJsonAST = await parseFixture(
'with-quizzes-audio-invalid-json.md'
);
quizzesWithAudioMissingFilenameAST = await parseFixture(
'with-quizzes-audio-missing-filename.md'
);
quizzesWithAudioTranscriptNotArrayAST = await parseFixture(
'with-quizzes-audio-transcript-not-array.md'
);
quizzesWithAudioEmptyTranscriptAST = await parseFixture(
'with-quizzes-audio-empty-transcript.md'
);
quizzesWithAudioMissingTranscriptAST = await parseFixture(
'with-quizzes-audio-missing-transcript.md'
);
quizzesWithAudioTranscriptMissingCharacterAST = await parseFixture(
'with-quizzes-audio-transcript-missing-character.md'
);
quizzesWithAudioTranscriptMissingTextAST = await parseFixture(
'with-quizzes-audio-transcript-missing-text.md'
);
quizzesWithAudioNotJsonCodeBlockAST = await parseFixture(
'with-quizzes-audio-not-json-code-block.md'
);
});
beforeEach(() => {
@@ -135,4 +169,111 @@ describe('add-quizzes plugin', () => {
'<p>Quiz 1, question 3, answer with <span class="highlighted-text">zhōng wén</span></p>'
);
});
it('should parse audio sections in quiz questions', () => {
plugin(quizzesWithAudioAST, file);
const quizzes = file.data.quizzes;
expect(Array.isArray(quizzes)).toBe(true);
expect(quizzes.length).toBe(1);
const firstQuiz = quizzes[0];
const firstQuestion = firstQuiz.questions[0];
const secondQuestion = firstQuiz.questions[1];
const thirdQuestion = firstQuiz.questions[2];
// First question has audio with timestamps
expect(firstQuestion).toHaveProperty('audioData');
expect(firstQuestion.audioData.audio.filename).toBe(
'audio-with-timestamps.mp3'
);
expect(firstQuestion.audioData.audio.startTimestamp).toBe(1.5);
expect(firstQuestion.audioData.audio.finishTimestamp).toBe(3.8);
expect(firstQuestion.audioData.transcript).toEqual([
{
character: 'Maria',
text: 'Hello, how are you?'
},
{
character: 'Tom',
text: "I'm doing well, thank you."
}
]);
expect(firstQuestion.text).toBe(
'<p>Quiz 1, question 1 with audio timestamps</p>'
);
expect(firstQuestion.distractors.length).toBe(3);
expect(firstQuestion.answer).toBe('<p>Quiz 1, question 1, answer</p>');
// Second question has audio without timestamps
expect(secondQuestion).toHaveProperty('audioData');
expect(secondQuestion.audioData.audio.filename).toBe('audio-full-file.mp3');
expect(secondQuestion.audioData.audio.startTimestamp).toBeUndefined();
expect(secondQuestion.audioData.audio.finishTimestamp).toBeUndefined();
expect(secondQuestion.audioData.transcript).toEqual([
{
character: 'Speaker',
text: 'This is the full audio transcript.'
}
]);
expect(secondQuestion.text).toBe(
'<p>Quiz 1, question 2 with audio but no timestamps</p>'
);
// Third question has no audio
expect(thirdQuestion.audioData).toBeUndefined();
expect(thirdQuestion.text).toBe('<p>Quiz 1, question 3 without audio</p>');
});
it('should throw error for audio section not in JSON code block', () => {
expect(() => plugin(quizzesWithAudioNotJsonCodeBlockAST, file)).toThrow(
'--audio-- section must contain a ```json code block'
);
});
it('should throw error for invalid JSON in audio section', () => {
expect(() => plugin(quizzesWithAudioInvalidJsonAST, file)).toThrow(
'--audio-- section must contain valid JSON'
);
});
it('should throw error for missing audio filename', () => {
expect(() => plugin(quizzesWithAudioMissingFilenameAST, file)).toThrow(
'--audio-- section must contain audio.filename'
);
});
it('should throw error for transcript not being an array', () => {
expect(() => plugin(quizzesWithAudioTranscriptNotArrayAST, file)).toThrow(
'--audio-- section must contain transcript as an array'
);
});
it('should throw error for empty transcript array', () => {
expect(() => plugin(quizzesWithAudioEmptyTranscriptAST, file)).toThrow(
'--audio-- section transcript array cannot be empty'
);
});
it('should throw error for missing transcript', () => {
expect(() => plugin(quizzesWithAudioMissingTranscriptAST, file)).toThrow(
'--audio-- section must contain transcript as an array'
);
});
it('should throw error for transcript line missing character', () => {
expect(() =>
plugin(quizzesWithAudioTranscriptMissingCharacterAST, file)
).toThrow(
'--audio-- transcript line 0 must have character and text properties'
);
});
it('should throw error for transcript line missing text', () => {
expect(() =>
plugin(quizzesWithAudioTranscriptMissingTextAST, file)
).toThrow(
'--audio-- transcript line 0 must have character and text properties'
);
});
});
@@ -41,6 +41,7 @@ const VALID_MARKERS = [
// Level 4
'#### --answer--',
'#### --audio--',
'#### --distractors--',
'#### --text--'
];