-
-
-
+
{{ $t('tutor.syllabusStep.description') }}
+
+
-
- {{ $t('tutor.thirdStep.feedbackHistory', { n: feedbackHistory.length }) }}
-
- - {{ item }}
-
-
-
-
+
+
+
+
+ {{ $t('tutor.syllabusStep.feedbackHistory', { n: feedbackHistory.length }) }}
+
+
+ - {{ item }}
+
+
+
+
+ {{ $t('tutor.syllabusStep.feedbackError') }}
+
+
+
+
+
-
diff --git a/src/localisation/en.ts b/src/localisation/en.ts
index c4b0d48..b3ec93a 100644
--- a/src/localisation/en.ts
+++ b/src/localisation/en.ts
@@ -364,66 +364,120 @@ export const en = {
},
textLengthFeedback: 'Please add more text to improve search results.',
tutor: {
- typeDoc:
- 'This feature is currently under development. For the best results, we recommend using scientific articles or book chapters.',
- articleToAddExample: 'Click to view an example document',
- restartSyllabusCreation: 'Start a new syllabus creation',
- syllabusSteps: 'Syllabus steps:',
BIG_FILE: 'File size exceeds 5 MB.',
BAD_EXTENSION: 'File type not supported. Only PDF, TXT, and DOCX files are allowed.',
- retry: {
- title: 'Please try again',
- description: 'An issue occurred while extracting the provided content, please try again.',
- button: 'retry',
- stop: 'cancel'
+ TOO_MANY_FILES: 'You can add up to 3 files.',
+ edit: 'Edit',
+ collapse: 'Close',
+ steps: {
+ documents: 'Documents',
+ summary: 'Summary',
+ resources: 'Resources',
+ syllabus: 'Syllabus'
+ },
+ documentsStep: {
+ title: 'Add your course materials',
+ description:
+ 'Upload 1 to 3 documents your course is based on (articles, book chapters…). WeLearn builds the syllabus from them.',
+ dropzone: 'Drop files here or',
+ browse: 'browse',
+ limits: 'PDF, DOCX or TXT · 5 MB max · up to 3 files',
+ hint: 'This feature is in beta: scientific articles and book chapters give the best results.',
+ exampleLink: 'See an example document',
+ removeFile: 'Remove {name}',
+ noFile: 'Add at least one document to continue.',
+ languageLabel: 'Syllabus language',
+ detailsTitle: 'Tell us about your course',
+ recommended: 'Strongly recommended',
+ detailsWhy:
+ 'The more you tell us, the better the syllabus fits your course. Without these details it will stay generic.',
+ titleLabel: 'Course title',
+ titlePlaceholder: 'e.g. Introduction to sociolinguistics',
+ levelLabel: 'Level of study',
+ levelPlaceholder: 'e.g. First-year undergraduate',
+ durationLabel: 'Duration',
+ durationPlaceholder: 'e.g. 6 weeks, 2 hours per week',
+ descriptionLabel: 'Course description and goals',
+ descriptionPlaceholder:
+ 'e.g. Students discover the key concepts of sociolinguistics and learn to analyse language use in their own community.',
+ nudge: 'Without course details, your syllabus will be generic.',
+ addDetails: 'Add details',
+ continueAnyway: 'Continue anyway',
+ action: 'Analyse my documents',
+ recap: '{files} · {lang}'
+ },
+ summaryStep: {
+ title: 'Your document summary',
+ description:
+ "Here's what WeLearn understood from each document. You can adjust it if needed: it guides the search for related resources.",
+ noFileName: 'Document {n}',
+ action: 'Find related resources',
+ recap: 'No document summarized | 1 document summarized | {n} documents summarized'
+ },
+ resourcesStep: {
+ title: 'Choose resources to include',
+ description:
+ 'These WeLearn resources match your documents and help connect your course to the Sustainable Development Goals. Click a resource to select it.',
+ selected: 'No resource selected | 1 resource selected | {n} resources selected',
+ allHint: 'Nothing selected: WeLearn will use all the resources above.',
+ action: 'Generate syllabus',
+ noSources: 'No related resources found',
+ noSourcesDescription:
+ 'You can still generate a syllabus from your documents only, but it will make fewer links to sustainability. To get resources, go back and try other documents.',
+ searchError:
+ 'The search for resources failed. You can still generate a syllabus from your documents.',
+ recapAll: 'All resources'
+ },
+ syllabusStep: {
+ title: 'Your syllabus is ready',
+ description:
+ 'Click the text to edit it, ask WeLearn for changes, then download it as a Word file.',
+ feedbackLabel: 'Ask for changes',
+ feedbackPlaceholder:
+ 'e.g. Add a week on assessment methods, shorten the reading list, make it more hands-on…',
+ regenerate: 'Regenerate with my changes',
+ feedbackError: "Your changes couldn't be applied. Please try again.",
+ feedbackHistory: 'Changes you already requested ({n})',
+ openLink: 'Open',
+ download: 'Download (.docx)',
+ restart: 'Start a new syllabus'
},
loading: {
- wait: 'Please wait',
extract: {
- title: 'Document content extraction',
- description: 'WeLearn extracts the content from your documents to generate a summary.'
+ title: 'Reading your documents',
+ description: 'WeLearn is identifying the main ideas of each document.'
},
search: {
- title: 'Finding related resources',
+ title: 'Searching for related resources',
description:
- 'Processing your documents and searching for relevant resources in the WeLearn database.'
+ 'WeLearn is looking through its library for resources that match your documents.'
},
syllabus: {
- title: 'Generating syllabus',
+ title: 'Writing your syllabus',
description:
- 'WeLearn is generating your syllabus from the documents provided. This step may take a few minutes.'
+ "WeLearn is combining your documents, course details and resources. This can take up to a minute: please don't close or reload this page."
},
- default: {
- title: 'Loading',
- description: 'WeLearn is getting ready for the next step.'
+ feedback: {
+ title: 'Applying your changes',
+ description: 'WeLearn is updating the syllabus based on your request.'
}
},
+ error: {
+ title: 'Something went wrong',
+ extract: "We couldn't read your documents.",
+ search: "We couldn't search for related resources.",
+ syllabus: "We couldn't write the syllabus.",
+ kept: 'Your inputs are kept.',
+ retry: 'Try again',
+ cancel: 'Cancel'
+ },
+ // used by the hidden /tutor_test version
summaries: {
noFileName: 'No file name provided',
title: 'Uploaded documents summaries',
description:
'WeLearn has extracted the following summaries from your uploaded documents. You can review them before proceeding to the next step.'
},
- firstStep: {
- syllabusLanguage: 'Syllabus language',
- acceptedFiles: 'Accepted file types: PDF, TXT, DOCX',
- searchError: 'No results were found for the provided document.',
- title: 'Add documents',
- description:
- 'To get started, upload one or more reference documents your course is based on. They will be used to generate your syllabus.',
- cursusDescriptionTitle: 'Course information',
- cursusDescriptionDescription:
- 'Fill in the information about your course. It will be used to generate a syllabus that adapts to your needs.',
- cursusTitleLabel: 'Title',
- cursusTitlePlaceholder: 'Sociolinguistics 101',
- cursusLevelLabel: 'Level of study',
- cursusLevelPlaceholder: 'Undergraduate',
- cursusDurationLabel: 'Duration',
- cursusDurationPlaceholder: '6 weeks',
- cursusDescriptionLabel: 'Brief description of the course',
- cursusDescriptionPlaceholder:
- 'This course covers the fundamentals of sociolinguistics, mainly key concepts and theories.'
- },
secondStep: {
title: 'Select additional resources',
description:
@@ -431,14 +485,6 @@ export const en = {
noSources: 'No resources related to your documents were found.',
noSourcesDescription:
'You can proceed with your uploaded documents only, but sustainability integration in the generated syllabus may be limited.\nTo explore more resources, return to the previous step to modify your uploaded documents.'
- },
- thirdStep: {
- title: 'Your syllabus',
- description:
- 'You can edit directly on this page, download a copy, and submit feedback that WeLearn will use to regenerate the syllabus — in any order, and as many times as you like.',
- giveFeedback: 'Provide feedback on the generated syllabus',
- sendFeedback: 'Submit feedback',
- feedbackHistory: 'Changes you already requested ({n})'
}
},
microlearning: {
diff --git a/src/localisation/fr.ts b/src/localisation/fr.ts
index 1485c60..b838cfc 100644
--- a/src/localisation/fr.ts
+++ b/src/localisation/fr.ts
@@ -367,68 +367,122 @@ export const fr = {
},
textLengthFeedback: 'Veuillez ajouter davantage de texte pour lancer la recherche.',
tutor: {
- typeDoc:
- "Cette fonctionnalité est actuellement en cours de développement. Pour obtenir les meilleurs résultats, nous recommandons d'utiliser des articles scientifiques ou des chapitres d'ouvrages.",
- articleToAddExample: 'Consulter un exemple de document',
- restartSyllabusCreation: 'Commencer un nouveau syllabus',
- syllabusSteps: 'Étapes du syllabus :',
BIG_FILE: 'La taille du fichier dépasse 5 Mo.',
BAD_EXTENSION:
'Type de fichier non pris en charge. Seuls les fichiers PDF, TXT et DOCX sont acceptés.',
- retry: {
- title: 'Veuillez réessayer',
+ TOO_MANY_FILES: "Vous pouvez ajouter jusqu'à 3 fichiers.",
+ edit: 'Modifier',
+ collapse: 'Fermer',
+ steps: {
+ documents: 'Documents',
+ summary: 'Résumé',
+ resources: 'Ressources',
+ syllabus: 'Syllabus'
+ },
+ documentsStep: {
+ title: 'Ajoutez vos supports de cours',
+ description:
+ "Importez 1 à 3 documents sur lesquels repose votre cours (articles, chapitres d'ouvrage…). WeLearn construit le syllabus à partir de ceux-ci.",
+ dropzone: 'Déposez vos fichiers ici ou',
+ browse: 'parcourez',
+ limits: 'PDF, DOCX ou TXT · 5 Mo max · 3 fichiers maximum',
+ hint: "Cette fonctionnalité est en bêta : les articles scientifiques et les chapitres d'ouvrage donnent les meilleurs résultats.",
+ exampleLink: 'Voir un exemple de document',
+ removeFile: 'Retirer {name}',
+ noFile: 'Ajoutez au moins un document pour continuer.',
+ languageLabel: 'Langue du syllabus',
+ detailsTitle: 'Parlez-nous de votre cours',
+ recommended: 'Fortement recommandé',
+ detailsWhy:
+ 'Plus vous en dites, plus le syllabus sera adapté à votre cours. Sans ces informations, il restera générique.',
+ titleLabel: 'Titre du cours',
+ titlePlaceholder: 'ex. : Introduction à la sociolinguistique',
+ levelLabel: "Niveau d'études",
+ levelPlaceholder: 'ex. : Licence 1',
+ durationLabel: 'Durée',
+ durationPlaceholder: 'ex. : 6 semaines, 2 heures par semaine',
+ descriptionLabel: 'Description et objectifs du cours',
+ descriptionPlaceholder:
+ 'ex. : Les étudiants découvrent les concepts clés de la sociolinguistique et apprennent à analyser les usages de la langue dans leur environnement.',
+ nudge: 'Sans informations sur le cours, votre syllabus sera générique.',
+ addDetails: 'Ajouter des informations',
+ continueAnyway: 'Continuer quand même',
+ action: 'Analyser mes documents',
+ recap: '{files} · {lang}'
+ },
+ summaryStep: {
+ title: 'Le résumé de vos documents',
description:
- "Un problème est survenu lors de l'extraction du contenu renseigné, veuillez relancer.",
- button: 'relancer la requête',
- stop: 'annuler'
+ "Voici ce que WeLearn a retenu de chaque document. Vous pouvez l'ajuster si besoin : il oriente la recherche de ressources.",
+ noFileName: 'Document {n}',
+ action: 'Trouver des ressources',
+ recap: 'Aucun document résumé | 1 document résumé | {n} documents résumés'
+ },
+ resourcesStep: {
+ title: 'Choisissez des ressources à intégrer',
+ description:
+ 'Ces ressources WeLearn correspondent à vos documents et aident à relier votre cours aux Objectifs de développement durable. Cliquez sur une ressource pour la sélectionner.',
+ selected:
+ 'Aucune ressource sélectionnée | 1 ressource sélectionnée | {n} ressources sélectionnées',
+ allHint: 'Aucune sélection : WeLearn utilisera toutes les ressources ci-dessus.',
+ action: 'Générer le syllabus',
+ noSources: 'Aucune ressource associée trouvée',
+ noSourcesDescription:
+ "Vous pouvez tout de même générer un syllabus à partir de vos documents seulement, mais il fera moins de liens avec la durabilité. Pour obtenir des ressources, revenez en arrière et essayez d'autres documents.",
+ searchError:
+ 'La recherche de ressources a échoué. Vous pouvez tout de même générer un syllabus à partir de vos documents.',
+ recapAll: 'Toutes les ressources'
+ },
+ syllabusStep: {
+ title: 'Votre syllabus est prêt',
+ description:
+ 'Cliquez sur le texte pour le modifier, demandez des modifications à WeLearn, puis téléchargez-le au format Word.',
+ feedbackLabel: 'Demander des modifications',
+ feedbackPlaceholder:
+ "ex. : Ajouter une semaine sur l'évaluation, raccourcir la bibliographie, rendre le cours plus pratique…",
+ regenerate: 'Régénérer avec mes modifications',
+ feedbackError: "Vos modifications n'ont pas pu être appliquées. Veuillez réessayer.",
+ feedbackHistory: 'Modifications déjà demandées ({n})',
+ openLink: 'Ouvrir',
+ download: 'Télécharger (.docx)',
+ restart: 'Créer un nouveau syllabus'
},
loading: {
- wait: 'Veuillez patienter',
extract: {
- title: 'Extraction du contenu du document',
- description: 'WeLearn extrait le contenu de vos documents pour générer un résumé.'
+ title: 'Lecture de vos documents',
+ description: 'WeLearn repère les idées principales de chaque document.'
},
search: {
title: 'Recherche de ressources associées',
description:
- 'Traitement de vos documents et recherche de ressources pertinentes dans la base de données WeLearn.'
+ 'WeLearn parcourt sa bibliothèque pour trouver des ressources liées à vos documents.'
},
syllabus: {
- title: 'Génération du syllabus',
+ title: 'Rédaction de votre syllabus',
description:
- 'WeLearn génère votre syllabus à partir des documents fournis. Cette étape peut prendre quelques minutes.'
+ 'WeLearn combine vos documents, les informations du cours et les ressources. Cela peut prendre jusqu’à une minute : merci de ne pas fermer ni recharger la page.'
},
- default: {
- title: 'Chargement',
- description: 'WeLearn se prépare pour la prochaine étape.'
+ feedback: {
+ title: 'Application de vos modifications',
+ description: 'WeLearn met à jour le syllabus selon votre demande.'
}
},
+ error: {
+ title: 'Un problème est survenu',
+ extract: 'Impossible de lire vos documents.',
+ search: 'Impossible de rechercher des ressources associées.',
+ syllabus: 'Impossible de rédiger le syllabus.',
+ kept: 'Vos informations sont conservées.',
+ retry: 'Réessayer',
+ cancel: 'Annuler'
+ },
+ // used by the hidden /tutor_test version
summaries: {
noFileName: 'Aucun nom de fichier fourni',
title: 'Résumés des documents importés',
description:
'WeLearn a extrait les points clés de vos documents importés pour générer le syllabus.'
},
- firstStep: {
- syllabusLanguage: 'Langue du syllabus',
- acceptedFiles: 'Types de fichiers acceptés : PDF, TXT, DOCX',
- searchError: 'Aucun résultat trouvé pour le document fourni.',
- title: 'Ajoutez des documents',
- description:
- 'Pour commencer, importez un ou plusieurs documents de référence sur lesquels s’appuie votre cours. Ils serviront à générer votre syllabus.',
- cursusDescriptionTitle: 'Informations sur le cours',
- cursusDescriptionDescription:
- "Renseignez les informations sur votre cours. Elle seront utilisées pour générer un syllabus qui s'adapte a vos besoins.",
- cursusTitleLabel: 'Titre',
- cursusTitlePlaceholder: 'Introduction à la sociolinguistique',
- cursusLevelLabel: 'Niveau d’études',
- cursusLevelPlaceholder: 'Licence',
- cursusDurationLabel: 'Durée',
- cursusDurationPlaceholder: '6 semaines',
- cursusDescriptionLabel: 'Ajoutez une brève description pour améliorer le syllabus',
- cursusDescriptionPlaceholder:
- 'Ce cours couvre les fondamentaux de la sociolinguistique, notamment les concepts et théories clés.'
- },
secondStep: {
title: 'Sélectionnez des ressources additionnelles',
description:
@@ -436,14 +490,6 @@ export const fr = {
noSources: 'Aucune ressource en lien avec vos documents n’a été trouvée.',
noSourcesDescription:
'Vous pouvez poursuivre uniquement avec vos documents importés, mais l’intégration de la durabilité dans votre syllabus pourrait être limitée.\nPour parcourir des ressources additionnelles différentes, revenez à l’étape précédente pour modifier vos documents importés.'
- },
- thirdStep: {
- title: 'Votre syllabus',
- description:
- 'Vous pouvez modifier directement cette page, télécharger une copie et envoyer un retour que WeLearn utilisera pour régénérer le syllabus — dans l’ordre de votre choix, autant de fois que nécessaire.',
- giveFeedback: 'Donner un retour sur le syllabus généré',
- sendFeedback: 'Envoyer le retour',
- feedbackHistory: 'Modifications déjà demandées ({n})'
}
},
microlearning: {
diff --git a/src/stores/tutor.ts b/src/stores/tutor.ts
index 14201ce..e74188b 100644
--- a/src/stores/tutor.ts
+++ b/src/stores/tutor.ts
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
-import { scrollToAnchor } from '@/utils/navigation';
+import axios from 'axios';
import { convertMarkdownToDocx, downloadDocx } from '@/utils/md-to-docx';
import _isequal from 'lodash.isequal';
import { type Ref, ref } from 'vue';
@@ -7,14 +7,29 @@ import { type Document, type TutorSearch, type TutorSyllabus } from '@/types';
import { basePostAxios } from '@/utils/fetch';
import i18n from '@/localisation/i18n';
+const MAX_FILES = 3;
+const MAX_FILE_SIZE = 5 * 1024 * 1024;
+
+// the LLM sometimes writes a literal "\n" (backslash + n): inside a table cell it
+// means a line break (
), anywhere else a real new line
+export const cleanSyllabus = (content: string) =>
+ content
+ .split('\n')
+ .map((line) =>
+ line.trim().startsWith('|') ? line.replace(/\\n/g, '
') : line.replace(/\\n/g, '\n')
+ )
+ .join('\n');
+
+export type FileErrorReason = 'BIG_FILE' | 'BAD_EXTENSION' | 'TOO_MANY_FILES';
+
export const useTutorStore = defineStore('tutor', () => {
const tutorSearch: Ref
= ref(undefined);
const syllabi: Ref<{ content: string; source: string } | undefined> = ref(undefined);
+ // keyed by file name, so the same file can't be added twice
const newFilesToSearch: Ref> = ref({});
const searchedFiles: Ref = ref([]);
const isLoading: Ref = ref(false);
const step: Ref = ref(1);
- const hasNewSearch: Ref = ref(false);
const level: Ref = ref('');
const duration: Ref = ref('');
const description: Ref = ref('');
@@ -24,7 +39,6 @@ export const useTutorStore = defineStore('tutor', () => {
// feedback the user already sent for the current syllabus, oldest first
const feedbackHistory: Ref = ref([]);
- const goBack = () => (step.value = step.value - 1);
const goNext = () => (step.value = step.value + 1);
const setStep = (newStep: number) => (step.value = newStep);
@@ -32,61 +46,47 @@ export const useTutorStore = defineStore('tutor', () => {
const shouldRetryAction: Ref = ref(false);
const hasSearchError: Ref = ref(false);
const reloadError: Ref = ref(false);
- const hasSyllabusError: Ref = ref(false);
- const fileError: Ref<{ state: boolean; reason: 'BIG_FILE' | 'BAD_EXTENSION' | null }> = ref({
+ const fileError: Ref<{ state: boolean; reason: FileErrorReason | null }> = ref({
state: false,
reason: null
});
- const addFile = (e: any, input_id: string) => {
- const targetFile = e.target.files[0];
-
- if (targetFile && targetFile.size > 5 * 1024 * 1024) {
- fileError.value = {
- state: true,
- reason: 'BIG_FILE'
- };
- return;
- }
+ // one request at a time: closing the loading modal aborts it
+ let controller: AbortController | undefined;
+ const startRequest = () => {
+ controller?.abort();
+ controller = new AbortController();
+ shouldRetryAction.value = false;
+ isLoading.value = true;
+ return { signal: controller.signal };
+ };
- if (
- targetFile &&
- !(
- targetFile.type === 'application/pdf' ||
- targetFile.type.startsWith('text/') ||
- targetFile.type ===
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
- )
- ) {
- fileError.value = {
- state: true,
- reason: 'BAD_EXTENSION'
- };
- return;
+ const isAllowedType = (file: File) =>
+ file.type === 'application/pdf' ||
+ file.type.startsWith('text/') ||
+ file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+
+ const addFiles = (files: FileList | File[]) => {
+ let reason: FileErrorReason | null = null;
+ const next = { ...newFilesToSearch.value };
+ for (const file of Array.from(files)) {
+ if (file.size > MAX_FILE_SIZE) reason = 'BIG_FILE';
+ else if (!isAllowedType(file)) reason = 'BAD_EXTENSION';
+ else if (!next[file.name] && Object.keys(next).length >= MAX_FILES) reason = 'TOO_MANY_FILES';
+ else next[file.name] = file;
}
-
- newFilesToSearch.value = {
- ...newFilesToSearch.value,
- [input_id]: targetFile
- };
-
- fileError.value = {
- state: false,
- reason: null
- };
+ newFilesToSearch.value = next;
+ fileError.value = { state: !!reason, reason };
};
- const removeFile = (input_id: string) => {
- if (newFilesToSearch.value[input_id]) {
- delete newFilesToSearch.value[input_id];
- }
- fileError.value = {
- state: false,
- reason: null
- };
+ const removeFile = (name: string) => {
+ const rest = { ...newFilesToSearch.value };
+ delete rest[name];
+ newFilesToSearch.value = rest;
+ fileError.value = { state: false, reason: null };
};
- const summaries = ref<[string]>([]);
+ const summaries = ref([]);
const updateSummary = (index: number, content: string) => {
summaries.value[index] = content;
@@ -102,77 +102,66 @@ export const useTutorStore = defineStore('tutor', () => {
const syllabusLanguage: Ref = ref(i18n.global.locale.value);
const selectSyllabusLanguage = (lang: string) => {
syllabusLanguage.value = lang;
+ // summaries are extracted in this language, so they must be redone
+ searchedFiles.value = [];
};
const getFilesContent = async (arg: File[]) => {
- isLoading.value = true;
- shouldRetryAction.value = false;
+ const { signal } = startRequest();
const formData = new FormData();
arg.forEach((file) => {
if (file) {
formData.append('files', file);
}
});
- try {
- const resp = await basePostAxios(
- `/tutor/files/content?lang=${syllabusLanguage.value}`,
- formData,
- {
- headers: { 'content-type': 'multipart/form-data' }
- }
- );
- if (resp.status === 204) {
- shouldRetryAction.value = true;
- throw new Error('retry getFilesContent');
- } else {
- extracts.value = resp.data.extracts;
- const red_summaries = resp.data.extracts.reduce(
- (acc: string[], curr: { summary: string }) => {
- acc = [...acc, curr.summary];
- return acc;
- },
- []
- );
- summaries.value = red_summaries;
- goNext();
- isLoading.value = false;
+ const resp = await basePostAxios(
+ `/tutor/files/content?lang=${syllabusLanguage.value}`,
+ formData,
+ {
+ headers: { 'content-type': 'multipart/form-data' },
+ signal
}
- } catch (error: any) {
- shouldRetryAction.value = true;
- throw new Error(error);
+ );
+ if (resp.status === 204) {
+ throw new Error('retry getFilesContent');
}
+ extracts.value = resp.data.extracts;
+ summaries.value = resp.data.extracts.map((e: { summary: string }) => e.summary);
+ isLoading.value = false;
};
const stopAction = () => {
+ controller?.abort();
isLoading.value = false;
+ shouldRetryAction.value = false;
};
- const retrieveTutorSearch = async (arg: File[]) => {
+ const retrieveTutorSearch = async () => {
setStep(2);
- isLoading.value = true;
+ const { signal } = startRequest();
try {
- const resp = await basePostAxios('/tutor/search_extracts', { summaries: summaries.value });
+ const resp = await basePostAxios(
+ '/tutor/search_extracts',
+ { summaries: summaries.value },
+ { signal }
+ );
if (resp.status === 204) {
shouldRetryAction.value = true;
- } else {
- tutorSearch.value = resp.data;
- hasSearchError.value = false;
- isLoading.value = false;
- shouldRetryAction.value = false;
-
- goNext();
- scrollToAnchor('target-3');
+ return;
}
+ tutorSearch.value = resp.data;
+ hasSearchError.value = false;
+ isLoading.value = false;
+ setStep(3);
} catch (error: any) {
+ if (axios.isCancel(error)) return;
console.error('Error during tutor search:', error);
hasSearchError.value = true;
+ shouldRetryAction.value = true;
if (error.code === 'ERR_NETWORK') {
reloadError.value = true;
}
- } finally {
- setStep(3);
- searchedFiles.value = arg;
}
};
@@ -190,14 +179,14 @@ export const useTutorStore = defineStore('tutor', () => {
setStep(1);
reloadError.value = false;
selectedSources.value = [];
- const arg = Object.values(newFilesToSearch.value).filter((e) => e);
+ const arg = Object.values(newFilesToSearch.value);
if (!arg.length) {
console.error('No files selected');
return;
}
+ // same files as last time: keep the summaries the user may have edited
if (_isequal(searchedFiles.value, arg)) {
- hasNewSearch.value = false;
goNext();
return;
}
@@ -205,40 +194,22 @@ export const useTutorStore = defineStore('tutor', () => {
tutorSearch.value = undefined;
try {
await getFilesContent(arg);
- hasNewSearch.value = true;
- scrollToAnchor('target-2');
- // goNext();
- } catch (error) {
- console.error('get files content did not work');
- }
- };
-
- const handleSearch = async () => {
- reloadError.value = false;
- selectedSources.value = [];
- const arg = Object.values(newFilesToSearch.value).filter((e) => e);
- if (!arg.length) {
- console.error('No files selected');
- return;
- }
-
- if (_isequal(searchedFiles.value, arg) && !shouldRetryAction.value) {
- hasNewSearch.value = false;
+ searchedFiles.value = arg;
goNext();
- return;
+ } catch (error) {
+ if (axios.isCancel(error)) return;
+ console.error('get files content did not work', error);
+ shouldRetryAction.value = true;
}
-
- tutorSearch.value = undefined;
- await retrieveTutorSearch(arg);
- hasNewSearch.value = true;
};
const restart = () => {
setStep(1);
- scrollToAnchor('target-1');
//reset all refs
tutorSearch.value = undefined;
+ syllabi.value = undefined;
+ summaries.value = [];
selectedSources.value = [];
courseTitle.value = '';
level.value = '';
@@ -248,77 +219,88 @@ export const useTutorStore = defineStore('tutor', () => {
searchedFiles.value = [];
extracts.value = [];
feedbackHistory.value = [];
+ hasSearchError.value = false;
+ fileError.value = { state: false, reason: null };
};
- const retrieveSyllabus = async () => {
- if (!tutorSearch.value) {
- throw new Error('Body is empty');
- }
- isLoading.value = true;
+ // with nothing selected, all found resources are used
+ const documentsToUse = () =>
+ selectedSources.value.length ? selectedSources.value : tutorSearch.value?.documents || [];
+
+ const retrieveSyllabus = async (): Promise => {
+ const { signal } = startRequest();
try {
- const resp = await basePostAxios(`/tutor/syllabus?lang=${syllabusLanguage.value}`, {
- ...tutorSearch.value,
- documents: selectedSources.value.length
- ? selectedSources.value
- : tutorSearch.value.documents,
- extracts: extracts.value,
- ...(courseTitle.value && { course_title: courseTitle.value }),
- ...(level.value && { level: level.value }),
- ...(duration.value && { duration: duration.value }),
- ...(description.value && { description: description.value })
- });
+ const resp = await basePostAxios(
+ `/tutor/syllabus?lang=${syllabusLanguage.value}`,
+ {
+ ...tutorSearch.value,
+ documents: documentsToUse(),
+ extracts: extracts.value,
+ ...(courseTitle.value && { course_title: courseTitle.value }),
+ ...(level.value && { level: level.value }),
+ ...(duration.value && { duration: duration.value }),
+ ...(description.value && { description: description.value })
+ },
+ { signal }
+ );
const data = resp.data as TutorSyllabus;
//keep only the syllabus from pedagogical engineer
- syllabi.value = data.syllabus.filter(({ source }) =>
+ const syllabus = data.syllabus.filter(({ source }) =>
source.toLowerCase().includes('pedagogicalengineeragent')
)[0];
-
- hasSyllabusError.value = false;
- scrollToAnchor('target-4');
+ syllabi.value = syllabus && { ...syllabus, content: cleanSyllabus(syllabus.content) };
+ isLoading.value = false;
+ return true;
} catch (error) {
+ if (axios.isCancel(error)) return false;
console.error('Error during syllabus retrieval:', error);
- hasSyllabusError.value = true;
- } finally {
- isLoading.value = false;
+ shouldRetryAction.value = true;
+ return false;
}
};
const handleCreateSyllabus = async () => {
setStep(3);
- if (
- !tutorSearch.value ||
- (!tutorSearch.value.documents.length && !searchedFiles.value.length)
- ) {
+ if (!tutorSearch.value) {
console.error('No documents found');
return;
}
- await retrieveSyllabus();
- hasNewSearch.value = false;
- goNext();
+ if (await retrieveSyllabus()) {
+ feedbackHistory.value = [];
+ goNext();
+ }
};
- const giveFeedback = async (feedback: string): Promise => {
+ // true = applied, false = failed, undefined = cancelled by the user
+ const giveFeedback = async (feedback: string): Promise => {
if (!tutorSearch.value || !syllabi.value) {
throw new Error('Body is empty');
}
- isLoading.value = true;
+ const { signal } = startRequest();
try {
- const resp = await basePostAxios('/tutor/syllabus/feedback', {
- feedback: feedback,
- syllabus: [syllabi.value],
- ...tutorSearch.value,
- documents: selectedSources.value
- });
+ const resp = await basePostAxios(
+ '/tutor/syllabus/feedback',
+ {
+ feedback: feedback,
+ syllabus: [syllabi.value],
+ ...tutorSearch.value,
+ // known behaviour: feedback only relies on the current syllabus and the request
+ documents: selectedSources.value
+ },
+ { signal }
+ );
- syllabi.value = resp.data.syllabus[0];
+ const syllabus = resp.data.syllabus[0];
+ syllabi.value = { ...syllabus, content: cleanSyllabus(syllabus.content) };
feedbackHistory.value.push(feedback);
return true;
} catch (error) {
+ if (axios.isCancel(error)) return undefined;
console.error('Error during feedback submission:', error);
return false;
} finally {
@@ -359,16 +341,14 @@ export const useTutorStore = defineStore('tutor', () => {
syllabusLanguage,
selectSyllabusLanguage,
step,
- goBack,
goNext,
setStep,
syllabi,
- addFile,
+ addFiles,
removeFile,
fileError,
restart,
reloadError,
- handleSearch,
retrieveTutorSearch,
tutorSearch,
appendSource,
@@ -376,7 +356,6 @@ export const useTutorStore = defineStore('tutor', () => {
updateSummary,
handleCreateSyllabus,
hasSearchError,
- hasSyllabusError,
isLoading,
searchedFiles,
giveFeedback,
diff --git a/src/utils/__tests__/md-to-docx.spec.ts b/src/utils/__tests__/md-to-docx.spec.ts
new file mode 100644
index 0000000..f31c9b7
--- /dev/null
+++ b/src/utils/__tests__/md-to-docx.spec.ts
@@ -0,0 +1,63 @@
+import { describe, it, expect } from 'vitest';
+import { ExternalHyperlink, TextRun } from 'docx';
+import { processInlineText } from '@/utils/md-to-docx/helpers';
+import { convertMarkdownToDocx } from '@/utils/md-to-docx';
+import JSZip from 'jszip';
+
+describe('processInlineText', () => {
+ it('keeps the text around links and turns every link into a hyperlink', () => {
+ const children = processInlineText(
+ 'Smith (2020). [Paper](https://a.org/x) and https://b.org/y'
+ );
+ expect(children[0]).toBeInstanceOf(TextRun);
+ expect(children.filter((c) => c instanceof ExternalHyperlink)).toHaveLength(2);
+ });
+
+ it('understands the HTML links the LLM writes', () => {
+ const children = processInlineText(
+ 'OMS (2021) [lien].'
+ );
+ expect(children.filter((c) => c instanceof ExternalHyperlink)).toHaveLength(1);
+ });
+
+ it('leaves lines without links as formatted text', () => {
+ const children = processInlineText('Plain **bold** text');
+ expect(children.every((c) => c instanceof TextRun)).toBe(true);
+ });
+
+ it('builds a Word file from a syllabus with references', async () => {
+ const md =
+ '## References\n\n- Smith (2020). [Paper](https://a.org/x)\n- **Doe** (2021). https://b.org/y\n\nSee [site](https://c.org).';
+ await expect(convertMarkdownToDocx(md)).resolves.toBeTruthy();
+ });
+
+ it('renders headings, tables, separators and links as real Word formatting', async () => {
+ // shaped like a syllabus saved after an edit ("| --- |" separators, [[lien]] links)
+ const md = [
+ '## 5. **Méthodes d’évaluation**',
+ '',
+ '| Type | Poids | Description |',
+ '| --- | --- | --- |',
+ '| **Participation** | 10% | Débats sur les ODD |',
+ '| **Examen final** | 25% | Ligne 1
- Ligne 2 |',
+ '',
+ '---',
+ '',
+ '#### **Ouvrages de référence :**',
+ '',
+ '- Géopolitique – *Pascal Gauchon* [[lien]](https://www.eyrolles.com/Geopolitique/)',
+ '- Limites à la croissance [[lien]](https://www.clubofrome.org/)'
+ ].join('\n');
+ const blob = await convertMarkdownToDocx(md);
+ const zip = await JSZip.loadAsync(blob);
+ const xml = await zip.file('word/document.xml')!.async('string');
+ const text = xml.replace(/<[^>]+>/g, '');
+
+ expect((xml.match(//g) || []).length).toBe(1);
+ expect(text).not.toContain('**');
+ expect(text).not.toContain('| ---');
+ expect(text).not.toContain('---');
+ expect(text).not.toContain('](');
+ expect((xml.match(/
new TableCell({
- children: [
- new Paragraph({
- children: [
- new TextRun({
- text: cell,
- color: '000000'
- })
- ]
- })
- ]
+ // one paragraph per
, with bold and links kept
+ children: cell
+ .split(/
/i)
+ .map((part) => new Paragraph({ children: processInlineText(part.trim()) }))
})
)
})
@@ -116,12 +112,44 @@ export function processTable(tableData: TableData, documentType: 'document' | 'r
* @param style - The style configuration
* @returns The processed paragraph
*/
+// links, in the 3 forms the LLM writes them:
+// markdown [text](url) (text may hold brackets, e.g. [[lien]](url)), HTML text, bare URL
+const LINK_REGEX =
+ /\[((?:[^[\]]|\[[^\]]*\])+)\]\(([^)\s]+)\)|]*href="([^"]+)"[^>]*>(.*?)<\/a>|(https?:\/\/[^\s)<>"]+)/g;
+
+/**
+ * Formats a line (bold, italic, code) and turns its links into real hyperlinks,
+ * keeping the text around them
+ */
+export function processInlineText(line: string): ParagraphChild[] {
+ const children: ParagraphChild[] = [];
+ let last = 0;
+ for (const match of line.matchAll(LINK_REGEX)) {
+ const index = match.index ?? 0;
+ if (index > last) children.push(...processFormattedText(line.slice(last, index)));
+ const url = match[2] || match[3] || match[5];
+ children.push(
+ new ExternalHyperlink({
+ children: [
+ new TextRun({
+ text: match[1] || match[4] || url,
+ color: '0000FF',
+ underline: { type: 'single' }
+ })
+ ],
+ link: url
+ })
+ );
+ last = index + match[0].length;
+ }
+ if (last < line.length) children.push(...processFormattedText(line.slice(last)));
+ return children;
+}
+
export function processListItem(config: ListItemConfig, style: Style): Paragraph {
- const children: TextRun[] = [
- new TextRun({
- text: config.text + (config.boldText ? '\n' : ''),
- color: '000000'
- })
+ const children: ParagraphChild[] = [
+ ...processInlineText(config.text),
+ ...(config.boldText ? [new TextRun({ text: '\n', color: '000000' })] : [])
];
if (config.boldText) {
@@ -307,13 +335,17 @@ export function processFormattedText(line: string): TextRun[] {
* @param lines - The markdown lines
* @returns An array of table data
*/
+// separator line under a table header: |---|---| or | --- | :-: | (both are valid markdown)
+export const isTableSeparator = (line: string) =>
+ /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?$/.test(line.trim());
+
export function collectTables(lines: string[]): TableData[] {
const tables: TableData[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
- if (i + 1 < lines.length && lines[i + 1].includes('|-')) {
+ if (i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
const headers = line
.split('|')
.filter(Boolean)
diff --git a/src/views/TutorPage.vue b/src/views/TutorPage.vue
index ae50f78..98f9c05 100644
--- a/src/views/TutorPage.vue
+++ b/src/views/TutorPage.vue
@@ -1,42 +1,26 @@
-
-
- {{ $t('tutor.syllabusSteps') }}
-
-
-
+ scrollToAnchor(`target-${i + 1}`)"
/>
-
-
-
-
+
+
+
+ ✓
+ {{ stepLabels[n - 1] }}
+ {{ recap[n as 1 | 2 | 3] }}
+
+ {{ expanded[n] ? $t('tutor.collapse') : $t('tutor.edit') }}
+
+
+
+
+
+
+
+
+
+
+
@@ -132,21 +147,96 @@ onMounted(() => {
overflow: hidden;
}
+.steps-bar {
+ width: 100%;
+ padding: 0 20%;
+ border-bottom: 1px solid var(--neutral-20);
+}
+
+/* compact version of the micro-learning progress bar */
+.steps-bar :deep(.progress-wrapper) {
+ margin: 0.75rem 0 0.5rem;
+}
+.steps-bar :deep(.steps) {
+ margin-top: 0.5rem;
+}
+.steps-bar :deep(.step) {
+ flex-direction: row;
+ justify-content: center;
+ gap: 0.5rem;
+}
+.steps-bar :deep(.circle) {
+ width: 1.5rem;
+ height: 1.5rem;
+ font-size: 0.75rem;
+ border-width: 1.5px;
+}
+.steps-bar :deep(.check) {
+ width: 0.8rem;
+ height: 0.8rem;
+}
+.steps-bar :deep(.label) {
+ margin-top: 0;
+ max-width: none;
+ font-size: 0.8rem;
+}
+@media (max-width: 640px) {
+ /* only the current step keeps its name */
+ .steps-bar :deep(.step:not(.active) .label) {
+ display: none;
+ }
+}
+
.layout-flex {
width: 100%;
overflow-y: auto;
padding: 1rem 20%;
}
+.step-section {
+ padding: 1.5rem 0;
+ scroll-margin-top: 0.5rem;
+}
+
+.step-section + .step-section {
+ border-top: 1px solid var(--neutral-20);
+}
+
+.recap {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ color: var(--neutral-70);
+}
+
+.recap.open {
+ margin-bottom: 1.5rem;
+}
+
+.recap-check {
+ color: var(--primary-dark);
+ font-weight: bold;
+}
+
+.recap-text {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
@media (max-width: 1024px) {
- .layout-flex {
+ .layout-flex,
+ .steps-bar {
padding-inline: 10%;
}
}
@media (max-width: 768px) {
- .layout-flex {
- padding-inline: 5%;
+ .layout-flex,
+ .steps-bar {
+ padding-inline: 1rem;
}
}
diff --git a/src/views/__tests__/__snapshots__/TutorPage.test.ts.snap b/src/views/__tests__/__snapshots__/TutorPage.test.ts.snap
index 8fc2abf..84758e6 100644
--- a/src/views/__tests__/__snapshots__/TutorPage.test.ts.snap
+++ b/src/views/__tests__/__snapshots__/TutorPage.test.ts.snap
@@ -6,420 +6,394 @@ exports[`TutorPage > renders properly 1`] = `
data-v-8a8621cb=""
>
-
- tutor.syllabusSteps
-
-
-
-
-
-
-
- 1 - tutor.firstStep.title
-
-
- tutor.firstStep.description
-
-
+
+
+
+
+
+ 4
+
+
- tutor.firstStep.acceptedFiles
+ Syllabus
-
-
- +
-
+
+