From 5c53391375afe83ae94118d05ca573b48e930344 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Fri, 20 Feb 2026 22:48:33 +0300 Subject: [PATCH 01/42] chore(ci): update environment name in deployment workflow --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b278de8e..ecff1a99 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: uses: ./.github/workflows/deploy.yml secrets: inherit with: - env-name: dev-swahilipot + env-name: dev-njira env-type: dev target-git-sha: ${{ github.sha }} target-git-branch: ${{ github.ref_name }} From 01615adc97563daf9e5f6e6a520a889f5524c7a1 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Fri, 20 Feb 2026 23:38:25 +0300 Subject: [PATCH 02/42] feat(i18n): add Nyanja (Zambia) locale support and related translations --- frontend-new/public/nijra_logo.svg | 3 + .../feedbackForm/questions-ny-ZM.json | 55 ++ frontend-new/src/i18n/constants.ts | 4 +- frontend-new/src/i18n/i18n.ts | 3 + .../src/i18n/locales/ny-ZM/translation.json | 772 ++++++++++++++++++ 5 files changed, 836 insertions(+), 1 deletion(-) create mode 100644 frontend-new/public/nijra_logo.svg create mode 100644 frontend-new/src/feedback/overallFeedback/feedbackForm/questions-ny-ZM.json create mode 100644 frontend-new/src/i18n/locales/ny-ZM/translation.json diff --git a/frontend-new/public/nijra_logo.svg b/frontend-new/public/nijra_logo.svg new file mode 100644 index 00000000..f9c19b1b --- /dev/null +++ b/frontend-new/public/nijra_logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/questions-ny-ZM.json b/frontend-new/src/feedback/overallFeedback/feedbackForm/questions-ny-ZM.json new file mode 100644 index 00000000..d8ec8fbb --- /dev/null +++ b/frontend-new/src/feedback/overallFeedback/feedbackForm/questions-ny-ZM.json @@ -0,0 +1,55 @@ +{ + "interaction_ease": { + "question_text": "Kodi zidali zosavuta kapena zovuta kuyankhulana ndi {{appName}} ndikuwona mayankho ake?", + "description": "This question is used to measure the Customer Effort Score (CES).", + "comment_placeholder": "Chonde tchulani momwe mudalingira ndipo perekani malangizo oti titha kukonza kuyankhulana ndi {{appName}}." + }, + "clarity_of_skills": { + "question_text": "Kodi {{appName}} anakuthandizani kumvetsa bwino nzeru zanu? Ngati ayi, chifukwa chiyani?", + "description": "This question aims to measure Perceived Usefulness (PU).", + "comment_placeholder": "Chonde perekani chifukwa choti {{appName}} sanakuthandizeni kumvetsa nzeru zanu." + }, + "satisfaction_with_compass": { + "question_text": "Kodi mukukondwa ndi {{appName}}?", + "description": "This question is used to measure the Customer Satisfaction Score (CSAT).", + "comment_placeholder": null + }, + "work_experience_accuracy": { + "question_text": "Kodi pali zinthu za ntchito yanu zomwe {{appName}} adatchula zomwe zinali zolakwika?", + "options": { + "experience_title": "Dzina la ntchito lolakwika", + "experience_dates": "Tsiku loyambira/kutha lolakwika", + "experience_location": "Malolo olakwika", + "duplicate_experience": "Ntchito zofanana", + "missing_experience": "Ntchito zosachitika", + "other": "Zina" + }, + "description": "This question to identify specific inaccuracies in the work experience information.", + "comment_placeholder": "Chonde perekani zambiri za zolakwika za ntchito yanu zomwe {{appName}} adatchula." + }, + "incorrect_skills": { + "question_text": "Kodi pali nzeru zomwe {{appName}} adatchula molakwika kwa inu?", + "description": "This question aims to identify incorrectly identified skills.", + "comment_placeholder": "Chonde lembedani nzeru zilizonse zomwe {{appName}} adatchula molakwika." + }, + "missing_skills": { + "question_text": "Kodi pali nzeru zimene muli nazo zomwe {{appName}} adasowetsa?", + "description": "This question aims to identify missing skills.", + "comment_placeholder": "Chonde lembedani nzeru zilizonse zomwe muli nazo zomwe {{appName}} adasowetsa." + }, + "perceived_bias": { + "question_text": "Kodi munachitika kuti {{appName}} anakusunga molakwika posadabwa za mbiri yanu, chilankhulo, kapena zina? Ngati inde, chonde tchulani.", + "description": "This question aims to identify perceived bias.", + "comment_placeholder": "Chonde perekani zambiri za chikhalidwe chanu." + }, + "recommendation": { + "question_text": "Kodi mungakhoze kulangiza {{appName}} kwa anthu ena otsvetsa ntchito?", + "description": "This question is used to measure the Net Promoter Score (NPS).", + "comment_placeholder": null + }, + "additional_feedback": { + "question_text": "Chonde perekani nkhani kapena malangizo ena a {{appName}}.", + "description": "Used to collect any additional feedback or suggestions.", + "comment_placeholder": "Tikufuna kumva nkhani zanu! Chonde perekani nkhani kapena malangizo pa {{appName}}." + } +} diff --git a/frontend-new/src/i18n/constants.ts b/frontend-new/src/i18n/constants.ts index 1e1174a8..37b83bcd 100644 --- a/frontend-new/src/i18n/constants.ts +++ b/frontend-new/src/i18n/constants.ts @@ -9,6 +9,7 @@ export enum Locale { ES_ES = "es-ES", ES_AR = "es-AR", SW_KE = "sw-KE", + NY_ZM = "ny-ZM", } export const LocalesLabels = { @@ -17,7 +18,8 @@ export const LocalesLabels = { [Locale.ES_ES]: "Español (España)", [Locale.ES_AR]: "Español (Argentina)", [Locale.SW_KE]: "Kiswahili (Kenya)", + [Locale.NY_ZM]: "Nyanja (Zambia)", } as const; -export const SupportedLocales: Locale[] = [Locale.EN_GB, Locale.EN_US, Locale.ES_ES, Locale.ES_AR, Locale.SW_KE]; +export const SupportedLocales: Locale[] = [Locale.EN_GB, Locale.EN_US, Locale.ES_ES, Locale.ES_AR, Locale.SW_KE, Locale.NY_ZM]; export const FALL_BACK_LOCALE = Locale.EN_GB; diff --git a/frontend-new/src/i18n/i18n.ts b/frontend-new/src/i18n/i18n.ts index d039794a..2e2e0169 100644 --- a/frontend-new/src/i18n/i18n.ts +++ b/frontend-new/src/i18n/i18n.ts @@ -13,12 +13,14 @@ import enUs from "./locales/en-US/translation.json"; import esEs from "./locales/es-ES/translation.json"; import esAr from "./locales/es-AR/translation.json"; import swKe from "./locales/sw-KE/translation.json"; +import nyZm from "./locales/ny-ZM/translation.json"; // --- Import feedback questions --- import questionsEnGb from "src/feedback/overallFeedback/feedbackForm/questions-en-GB.json"; import questionsEnUs from "src/feedback/overallFeedback/feedbackForm/questions-en-US.json"; import questionsEsEs from "src/feedback/overallFeedback/feedbackForm/questions-es-ES.json"; import questionsEsAr from "src/feedback/overallFeedback/feedbackForm/questions-es-AR.json"; +import questionsNyZm from "src/feedback/overallFeedback/feedbackForm/questions-ny-ZM.json"; // --- i18n initialization --- const resources = { @@ -36,6 +38,7 @@ const resources = { ...constructLocaleResources(Locale.ES_AR, { ...esAr, questions: questionsEsAr }), ...constructLocaleResources(Locale.ES_ES, { ...esEs, questions: questionsEsEs }), ...constructLocaleResources(Locale.SW_KE, { ...swKe }), + ...constructLocaleResources(Locale.NY_ZM, { ...nyZm, questions: questionsNyZm }), }; // Validate DEFAULT_LOCALE before using it diff --git a/frontend-new/src/i18n/locales/ny-ZM/translation.json b/frontend-new/src/i18n/locales/ny-ZM/translation.json new file mode 100644 index 00000000..94b9a744 --- /dev/null +++ b/frontend-new/src/i18n/locales/ny-ZM/translation.json @@ -0,0 +1,772 @@ +{ + "common": { + "buttons": { + "login": "Lowani", + "logout": "Tsekani", + "submit": "Tumizani", + "register": "Lembetsani", + "startNewConversation": "Yambani nkhani yatsopano", + "settings": "Zokonda", + "cancel": "Lambani", + "confirm": "Inde, ndili ndi chitsimikizo", + "backToLogin": "Bwerani ku Lowani", + "save": "Sungani", + "goBack": "Bwerani", + "restore": "Bwezerani", + "revert": "Bwezerani", + "continue": "Pitilizani", + "noThankYou": "Ayi, zikomo", + "iWantToStay": "Ndikufuna kukhala", + "yes": "Inde", + "no": "Ayi", + "skip": "Siya", + "yesExit": "Inde, pitani" + }, + "fields": { + "email": "Imelo", + "password": "Mawu achinsinsi", + "confirmEmail": "Tsinkaninani Imelo" + }, + "validation": { + "validEmailRequired": "Chonde lembani imelo yovomerezeka", + "emailsDoNotMatch": "Imelo sizikugwirizana", + "passwordMinLength": "Mawu achinsinsi ayenera kukhala osachepera zilembo 8.", + "passwordNeedLowercase": "Mawu achinsinsi ayenera kukhala ndi chilembo chimodzi chaching'ono.", + "passwordNeedUppercase": "Mawu achinsinsi ayenera kukhala ndi chilembo chimodzi chachikulu.", + "passwordNeedNumber": "Mawu achinsinsi ayenera kukhala ndi nambala imodzi.", + "passwordNeedSpecialChar": "Mawu achinsinsi ayenera kukhala ndi chilembo chapadera chimodzi monga: !@#$%*& nk." + }, + "chat": { + "errors": { + "messageLimit": "Malire a uthenga ndi zilembo {{max}}.", + "invalidSpecialCharacters": "Zilembo zapadera zosagwirizana: {{chars}}" + } + }, + "upload": { + "errors": { + "maxFileSize": "Fayilo yosankhidwa ndi yayikulu kwambiri. Kukula kwapamwamba ndi 3 MB.", + "tooDense": "Zomwe mwakena zayikulu kuti zigwiritsidwe ntchito. Chonde chepetsani kutalika ndipo yesani kenako.", + "emptyParse": "Sitinathe kuona chitukuko mu CV yanu. Chonde onani fayilo ndipo yesani kenako.", + "generic": "Kugwiritsa ntchito CV yanu kunalephera. Chonde yesani kenako kapena gwiritsani ntchito fayilo ina.", + "rateLimit": "Kukena kwambiri pamodzi. Chonde dikirani phindi imodzi ndipo yesani kenako.", + "maxUploadsReached": "Mwafika chiwerengero chapamwamba cha kukena CV pa nkhaniyi. Kukena kwina sikuloledwa.", + "duplicate": "CV iyi yidakenedwa kale. Sankhani kuchokera ku CV zanu zidakenedwa kale.", + "unsupportedFileType": "Mtundu wa fayilo sazigwiritsidwa. Zololedwa: PDF, DOCX, TXT.", + "timeout": "Kukena kunalira nthawi. Chonde yesani kenako.", + "unauthorized": "Simuloledwa. Chonde lowani kenako.", + "uploadNotFound": "Kukena sikunapezeke. Zingakhale kunalephera kuyamba.", + "cvMarkdownTooLong": "Zomwe zili mu CV yanu ndi zatali kwambiri. Chonde fupitsani CV yanu ndipo yesani kenako." + } + }, + "time": { + "onDate": "pa {{date}}", + "yesterday": "dzulo", + "daySingular": "tsiku", + "dayPlural": "masiku", + "hourSingular": "ola", + "hourPlural": "maola", + "minuteSingular": "dengu", + "minutePlural": "madengu", + "ago": "{{time}} yapitayo", + "justNow": "posachedwapa" + }, + "errors": { + "updateState": "Kusintha kalembedwe ka nzeru kunalephera. Chonde yesani kenako.", + "generic": "Chinthu chinachitika cholakwika. Chonde yesani kenako.", + "api": { + "requestTooLong": "Zomwe zatumizidwa kwa ntchito zikuoneka kuti ndi zayikulu. Chonde yesani ndi zochepera. Ngati vuto likupitirira, chotsani cache ya browser yanu ndipo tsitsani tsamba.", + "tooManyRequests": "Zikuoneka kuti mukupempha zambiri. Chonde chepetsani ndipo yesani kenako.", + "unexpectedError": "Cholakwika chosayembekezereka chachitika. Chonde yesani kenako.", + "serverConnectionError": "Sitingathe kulumikiza ku ntchito. Chonde onani intaneti yanu kapena yesani kenako.", + "resourceNotFound": "Zomwe mwapempha sizinapezeke. Chonde chotsani cache ya browser yanu ndipo tsitsani tsamba.", + "authenticationFailure": "Zikuoneka kuti simunalowe. Chonde lowani kuti mupitilize.", + "permissionDenied": "Zikuoneka kuti mulibe chilolezo chofunika. Chonde tsekani ndipo lowani kenako.", + "unableToProcessResponse": "Tidaonana ndi vuto pamene tikugwiritsa ntchito data. Chotsani cache ya browser ndipo tsitsani kapena yesani kenako.", + "serviceUnavailable": "Ntchitoyo palibe pano. Chonde yesani kenako.", + "dataValidationError": "Zikuoneka kuti pali vuto ndi pempho lanu. Ngati mukumtumiza data, onetsetsani kuti ndi yovomerezeka ndipo yesani kenako. Ngati vuto likupitirira, chotsani cache ndipo tsitsani tsamba.", + "unableToProcessRequest": "Pepani. Chinthu chinachitika cholakwika pamene tikugwiritsa ntchito pempho lanu." + } + }, + "status": { + "calculating": "Kuwerengera", + "cancelling": "Kulamba", + "saving": "Kusunga" + }, + "backdrop": { + "loggingYouOut": "Mukutulutsidwa..." + }, + "modal": { + "areYouSure": "Mukutsimikiza?", + "areYouSureYouWantToExit": "Mukutsimikiza kuti mukufuna kupita?" + } + }, + "chat": { + "chatHeader": { + "viewExperiences": "Onani zochitika", + "feedbackMessage": "Tikufuna kumva nkhani yanu pa chitukuko chanu mpaka pano!", + "giveFeedback": "Perekani nkhani", + "feedbackMessagePlaceholder": "Tikufuna kumva maganizo anu! Chonde gawani nkhani kapena malangizo omwe muli nawo okonza {{appName}}.", + "sendFeedback": "Tumizani nkhani", + "feedbackSuccessMessage": "Zikomo chifukwa cha nkhani yanu!", + "giveGeneralFeedback": "Perekani nkhani yonse", + "userInfo": "Zobisika za wogwiritsa", + "userIconAlt": "Chizindikiro cha Wogwiritsa", + "beforeYouGo": "Musanapite", + "logoutConfirmationMessage": "Mukutsimikiza kuti mukufuna kutuluka?", + "anonymousAccountWarning": "Pano mukugwiritsa ntchito akaunti yosadziwika. Mutuluka, mudzachita chilakwira mbiri ya nkhani ndi zochitika zanu.", + "logoutWarningAnonymous": "Mutuluka, mudzachita chilakwira mbiri ya nkhani ndi zochitika zanu.", + "createAccountToSaveProgress": "Lembetsani akaunti kuti musunge mapeto anu", + "continueYourJourneyLater": "ndipo pitilizani ulendo wanu kenako.", + "formTitle": "Perekani nkhani yonse", + "nameLabel": "Dzina", + "namePlaceholder": "Dzina Lanu", + "emailLabel": "Imelo", + "descriptionLabel": "Kufotokozera", + "emailPlaceholder": "imelo.yanu@chitsanzo.org", + "addScreenshot": "Onjezani chithunzi cha skrini", + "requiredLabel": "(zofunika)", + "cancelButton": "Lambani" + }, + "cvUploadPolling": { + "uploadingCv": "Kukena CV", + "cancelled": "Kukena CV kudalambidwa", + "uploadedSuccessfully": "CV yadakedwa bwino", + "converting": "Kusintha CV", + "processing": "Kugwiritsa ntchito CV", + "extractingExperiences": "Kutulutsa zochitika", + "savingCv": "Kusunga CV", + "failed": "Kukena CV kunalephera", + "failedToStart": "Kuyamba kukena kunalephera.", + "networkErrorStatus": "Cholakwika cha intaneti pamene tikungodziwa kalembedwe ka kukena.", + "failedToCancel": "Kulamba kukena kunalephera", + "uploadingFileNamed": "Kukena {{filename}}...", + "thinkingMessage": "Kugwiritsa ntchito CV yanu, izi zingachitire nthawi..." + }, + "chatMessageField": { + "addActionAriaLabel": "onjezani", + "moreActionsTooltip": "zochita zambiri", + "sendMessageTooltip": "tumizani uthenga", + "uploadCvIntro": "Mungakene CV yanu pomwe tikuyamba kufufuza zochitika zanu", + "uploadCvCollectExperiences": "PDF, DOCX, TXT • Pamwamba {{MAX_FILE_SIZE_MB}} MB • zilembo {{MAX_MARKDOWN_CHARS}} pamwamba", + "uploadCvOtherPhase": "Kukena CV kulipo pokha pamene tikukusanyira zochitika", + "uploadCvLabel": "Kenani CV", + "viewUploadedLabel": "CV zidakenedwa kale", + "placeholders": { + "chatFinished": "Nkhani yatha. Simungatume uthenga wina.", + "aiTyping": "AI ikulemba..., dikirani imalize.", + "offline": "Simuli pa intaneti. Chonde lumikizani pa intaneti kuti mutume uthenga.", + "default": "Lembani uthenga wanu...", + "uploading": "Kukena CV..." + } + }, + "chatMessage": { + "typingChatMessage": { + "typing": "Kulemba", + "thinking": "Kufufuza nzeru zokhudzana ndi chitukukochi" + }, + "conversationConclusionFooter": { + "youCanNow": "Tsopano mungathe", + "viewAndDownloadCv": "Onani ndi Tsitsani CV yanu", + "here": "pano.", + "createAccountIntro": "Ngati mukufuna kufufuza nzeru ndi zochitika zanu mtsogolo, mungathe", + "createAccount": "Lembetsani Akaunti", + "verificationReminder": "Imelo yotsimikiza yatumizidwa kwa imelo yanu. Chonde tsimikizani akaunti yanu musanalowe kenako.", + "feedbackWelcome": "Tikufuna kupata", + "feedbackLinkText": "Nkhani", + "feedbackFromYouSuffix": "zambiri kuchokera kwa inu. Zingachitire madengu 5 okha ndipo zithandiza ife kukonza!", + "feedbackInProgressPrefix": "Chonde", + "feedbackInProgressLinkText": "Kwaniritsani Nkhani yanu", + "feedbackInProgressSuffix": "kuti tithandize kukonza chitukuko chanu!" + }, + "sent": "yatumizidwa " + }, + "chat": { + "notifications": { + "experiencesFetchFailed": "Kutenga zochitika kunalephera", + "logoutSuccess": "Mwatuluka bwino", + "startConversationFailed": "Kuyamba nkhani yatsopano kunalephera", + "startConversationSuccess": "Nkhani yatsopano yayambira" + }, + "backdrop": { + "loggingOut": "Mukutulutsidwa, dikirani pang'ono..." + }, + "startNewConversationDialog": { + "title": "Yambani Nkhani Yatsopano?", + "content": "Muyamba nkhani yatsopano, uthenga wonse wa nkhani yano udzachitika chilakwira nthawi zonse.", + "confirmation": "Mukutsimikiza kuti mukufuna kuyamba nkhani yatsopano?" + }, + "refreshConfirmationDialog": { + "title": "Mukutsimikiza kuti mukufuna kutsitsa tsamba?", + "content": "{{appName}} ikulemba ndipo kutsitsa tsamba kungabweretse mavuto. Mungachita chilakwira mapeto a nkhani yano.", + "question": "Mukufuna kudikira {{appName}} imalize, kapena tsitsani kenako?", + "waitButton": "Dikirani {{appName}}", + "refreshButton": "Tsitsani" + } + }, + "chatProgressbar": { + "labels": { + "workTypes": "mitundu ya ntchito", + "experiences": "zochitika", + "questions": "mafunso", + "tasks": "ntchito" + }, + "phases": { + "initializing": "Kuyambitsa...", + "intro": "Chiyambi", + "collecting": "Kusonkhanitsa", + "exploring": "Kufufuza", + "discoveringPreferences": "Kufufuza zokonda", + "rankingOccupations": "Kupanga ntchito", + "recommendations": "Kukambirana malangizo", + "finished": "Nkhani yatha", + "unknown": "Gawo losadziwika" + } + }, + "util": { + "feedbackThankYou": "Zikomo chifukwa cha nthawi yopereka nkhani yanu yofunika.", + "ratingThankYou": "Zikomo chifukwa cha kuwerenga {{appName}}.", + "errorSomethingWentWrong": "Pepani, zikuoneka kuti chinthu chinachitika cholakwika kwa ine... Chonde tsitsani tsamba ndipo yesani kenako?", + "errorPleaseRepeat": "Pepani, zikuoneka kuti chinthu chinachitika cholakwika kwa ine... Chonde buuzani kenako?", + "messages": { + "experiencesIntro": "Awa ndi zochitika zanga:" + } + }, + "reaction": { + "components": { + "dislikeReasonPopover": { + "title": "Chonde tiuzeni kuti vuto ndi chiyani?", + "closeButton": "tsekani nkhani", + "reasons": { + "inappropriateTone": "Mawu osayenera", + "offensiveLanguage": "Chilankhulo chowawa", + "biased": "Chosagwirizana", + "incorrectInformation": "Zobisika zolakwika", + "irrelevant": "Zosakhudza", + "confusing": "Zosokoneza" + } + }, + "reactionButtons": { + "likeLabel": "ndikonda", + "dislikeLabel": "sindikonda", + "errors": { + "submitLike": "Kutumiza nkhani kunalephera. Chonde yesani kenako.", + "removeLike": "Kuchotsa nkhani kunalephera. Chonde yesani kenako.", + "submitDislike": "Kutumiza nkhani kunalephera. Chonde yesani kenako.", + "removeDislike": "Kuchotsa nkhani kunalephera. Chonde yesani kenako." + } + } + } + } + }, + "auth": { + "pages": { + "login": { + "welcomeTitle": "Takulandilani ku {{appName}}!", + "welcomeBack": "Mwalandilidwa!", + "loginToYourAccountToContinue": "Lowani ku akaunti yanu kuti mupitilize", + "loginUsing": "Lowani pogwiritsa ntchito", + "loggingYouIn": "Mukulowetsedwa...", + "loggingInAria": "Kulowa", + "dontHaveAnAccount": "Mulibe akaunti? ", + "fillInEmailAndPassword": "Chonde lembani imelo ndi mawu achinsinsi", + "orLoginToYourAccountToContinue": "Kapena lowani ku akaunti yanu kuti mupitilize", + "or": "kapena", + "invitationCodeValid": "Code yokondera ndi yovomerezeka", + "continueAsGuest": "Pitilizani ngati Mnyamata", + "components": { + "loginWithInviteCodeForm": { + "loginCode": "Code ya Kulowa" + } + } + }, + "register": { + "welcomeTitle": "Takulandilani ku {{appName}}!", + "welcomeBack": "Mwalandilidwa!", + "subtitle": "Tikufuna zobisika zina kuti tiyambe", + "registerWithGoogle": "Lembetsani ndi Google", + "alreadyHaveAccount": "Mwadzakhala ndi akaunti?", + "registeringYou": "Mukulembetsedwa...", + "enterRegistrationCode": "Lembani code yanu yolembetsa", + "registrationCode": "Code yolembetsa", + "andEitherContinueWith": "ndipo pitilizani ndi", + "components": { + "registerWithEmailForm": { + "registeringAria": "Kulembetsa" + } + } + }, + "landing": { + "continueAsGuest": "Pitilizani ngati Mnyamata", + "subtitleBold": "Fufuzani Kachitidwe Kanu Kanatha", + "subtitleBody": "Fufuzani ndipo tchulani nzeru zanu kudzera m'nkhani zongozedwa ndi AI. Mangani mbiri yanu ya nzeru ndipo tsitsani CV kuti muyambe kufufuza ntchito. Lembetsani akaunti yaulere kuti musunge mapeto ndipo mubwerere ku mbiri ya nkhani yanu.", + "invitationCodeValid": "Code yokondera ndi yovomerezeka", + "welcome": "Takulandilani!", + "welcomeTitle": "Takulandilani ku {{appName}}!", + "or": "kapena", + "loggingYouIn": "Mukulowetsedwa..." + }, + "verifyEmail": { + "registrationThankYou": "Zikomo chifukwa cha kulembetsa ku {{appName}}.", + "verificationEmailSentMessage": "Imelo yotsimikiza yatumizidwa kwa imelo yanu. Kuti mupitilize, chonde tsimikizani imelo yanu koyamba." + } + }, + "components": { + "anonymousAccountConversionDialog": { + "registerAccount": "Lembetsani Akaunti", + "registrationInfo": "Chonde lembetsani ndi imelo kuti mukhalenso ndi kupeza nkhaniyi. Popanda kulembetsa, simudzatha kulowa kenako kapena kubwerera ku nkhaniyi.", + "emailWarning": "Chonde onetsetsani kuti mukugwiritsa ntchito imelo yovomerezeka, chifukwa sizingasinthidwe kenako ndipo mudzapemphedwa kuitsimikiza.", + "closeRegistrationForm": "Tsekani fomu yolembetsa", + "invalidEmail": "Chonde lembani imelo yovomerezeka", + "emailsMismatch": "Chonde onetsetsani kuti imelo zonse ziwiri zikugwirizana", + "passwordRequirementsNotMet": "Chonde onetsetsani kuti mawu achinsinsi anu akukwaniritsa zofunika zonse", + "registrationSuccess": "Akaunti yalembetsedwa bwino!", + "verificationSentWithEmail": "Pano mwalowa ndi imelo: {{email}}. Imelo yotsimikiza yatumizidwa kwa imelo yanu. Chonde tsimikizani akaunti yanu musanalowe kenako.", + "registrationFailed": "Kulembetsa akaunti kunalephera" + }, + "passwordReset": { + "forgotPassword": "Muwala mawu achinsinsi?", + "pleaseEnterEmail": "Chonde lembani imelo yanu", + "passwordResetEmailSent": "Imelo yosinthira mawu achinsinsi yatumizidwa!", + "failedToSendResetEmail": "Kutumiza imelo yosinthira kunalephera", + "passwordResetLinkSent": "Chingwe chosinthira mawu achinsinsi chatumizidwa kwa imelo yanu.", + "resetPassword": "Sinthani Mawu Achinsinsi", + "closePasswordResetForm": "Tsekani fomu yosinthira mawu achinsinsi" + }, + "registrationCodeFormModal": { + "closeRegistrationCodeForm": "Tsekani fomu ya code yolembetsa", + "noRegistrationCodePrompt": "Mulibe code yolembetsa?", + "reachOut": "Lumikizanani nafe" + }, + "requestInvitationCodeFormModal": { + "requestAccessTitle": "Pemphani kupeza {{appName}}", + "requestAccessSubtitle": "Chonde tiuzeni momwe mupangira kugwiritsa ntchito {{appName}}. Tidzayang'anira pempho lanu ndipo tidzalumikizana posachedwapa.", + "closeRequestInvitationForm": "Tsekani fomu yopempha code yokondera", + "name": "Dzina", + "message": "Uthenga", + "messagePlaceholder": "Chonde gawani momwe mupangira kugwiritsa ntchito {{appName}}", + "invitationRequestSubmitSuccess": "Pempho lanu la kupeza {{appName}} latumizidwa bwino. Tidzabwerera posachedwapa.", + "invitationRequestSubmitError": "Chinthu chinachitika cholakwika pamene tinayesa kutuma pempho lanu" + }, + "requestInvitationCode": { + "requestLoginCode": "Mulibe code ya kulowa?" + }, + "resendVerificationEmail": { + "emailNotVerified": "Imelo yanu sinatsimikizidwe. Chonde onani bokosi lanu la imelo la imelo yotsimikiza.", + "resendVerificationEmail": "Tumizani kenako imelo yotsimikiza", + "resendVerificationEmailSuccess": "Imelo yotsimikiza yatumizidwa bwino", + "resendVerificationEmailFailedWithMessage": "Kutumiza imelo yotsimikiza kunalephera: {{message}}" + }, + "socialAuth": { + "orContinueWith": "Kapena pitilizani ndi", + "loginWithGoogle": "Lowani ndi Google", + "googleLoginIsNotAvailableWhenOffline": "Kulowa ndi Google sikulipo pamene simuli pa intaneti.", + "socialFailedLoginWithMessage": "Kulowa kunalephera: {{message}}", + "accountNotRegistered": "Akauntiyi sinilembetsedwe. Chonde lumikizanani ndi omwe adapatsa chingwechi." + } + }, + "loggingYouOutNotice": "Tikukutulutsani...", + "errors": { + "loginFailedWithMessage": "Kulowa kunalephera: {{message}}", + "registerFailedWithMessage": "Kulembetsa Kunalephera: {{message}}", + "preferencesFetchFailedWithMessage": "Cholakwika chachitika pamene tinayesa kutenga zokonda zanu: {{message}}", + "firebase": { + "emailAlreadyInUse": "Imelo yokhayo ikugwiritsidwa ndi akaunti ina.", + "emailNotVerified": "Imelo yomwe mukugwiritsa ntchito yalembetsedwa, koma simunatsimikiza. Chonde tsimikizani imelo yanu kuti mupitilize.", + "invalidCredential": "Imelo/mawu achinsinsi omwe mwatumiza ndi ovutika.", + "invalidEmail": "Imelo yokhayo ndi yovutika.", + "operationNotAllowed": "Akaunti za imelo/mawu achinsinsi siziloledwa.", + "weakPassword": "Mawu achinsinsi ndi ofooka kwambiri.", + "userDisabled": "Akaunti ya wogwiritsa yazimitsidwa.", + "userNotFound": "Palibe wogwiritsa anapezeka pa zobisika zomwe mwatumiza.", + "wrongPassword": "Mawu achinsinsi ndi ovutika.", + "tooManyRequests": "Tatseka mapempho onse kuchokera pa chipangizochi chifukwa cha zochita zosachilengedwe. Yesani kenako.", + "internalError": "Cholakwika mkati chachitika.", + "tooManyUsers": "Pali ogwiritsa ambiri kwambiri pa pulojekiti ya Firebase.", + "invalidRegistrationCode": "Code yolembetsa yomwe mwalemba ndi yovutika. Chonde onani code ndipo yesani kenako.", + "invalidInvitationCode": "Code yokondera yomwe mwalemba ndi yovutika. Chonde onani code ndipo yesani kenako.", + "invalidInvitationType": "Code yomwe mwagwiritsa ntchito ndi yolembetsa osati yokulowerani. Chonde pitani ku tsamba lolembetsa.", + "invalidRegistrationType": "Code yomwe mwagwiritsa ntchito ndi yokulowerani osati yolembetsa. Chonde pitani ku tsamba lokulowerani.", + "popupClosedByUser": "Popup ya Google yatsekedwa musanathe kulowa.", + "invalidLoginMethod": "Ntchitoyi siloledwa ndi njira yano yokulowerani.", + "emailAlreadyVerified": "Imelo yokhayo yatsimikizidwa kale." + } + }, + "verificationEmailSentShort": "Imelo Yotsimikiza Yatumizidwa!" + }, + "cv": { + "cvTypingChatMessage": { + "uploading": "Chonde dikirani pamene ndikukena ndi kugwiritsa ntchito CV yanu", + "uploadedReady": "Zomwe zili mu CV yanu zili m'munda wa mawu. Yang'anani ndipo tumizani mukonzekera." + }, + "uploadedCVsMenu": { + "backTitle": "Bwerani ku menyu yayikulu", + "titleWithCount": "CV zidakenedwa kale ({{count}})", + "helpCollect": "Dinani CV kuti mutulutse zomwe zili m'munda wa mawu. Yang'anani ndipo tumizani mukonzekera.", + "helpDisabled": "Kusankha CV kulipo pokha pamene tikukusanyira zochitika.", + "empty": "Palibe CV zidakenedwa zomwe zinapezeka." + }, + "cancellableTypingChatMessage": { + "typing": "Kulemba", + "thinking": "Chonde dikirani, izi zingachitire nthawi", + "cancel": "Lambani" + } + }, + "experiences": { + "experiencesDrawer": { + "components": { + "experienceEditForm": { + "components": { + "summaryEditField": { + "summaryPlaceholder": "Fupikizo la chitukuko", + "restoreFailed": "Kubwezeretsa fupikizo kunalephera. Chonde yesani kenako.", + "restored": "Fupikizo labwezeretsedwa." + } + }, + "updateSuccess": "Chitukuko chasinthidwa bwino!", + "updateFailed": "Kusintha chitukuko kunalephera. Chonde yesani kenako.", + "skillsAddedSuccess": "Nzeru zawonjezedwa bwino!", + "editTitle": "Sinthani Chitukuko", + "infoLabel": "Zobisika za chitukuko", + "infoHelp": "Dinani munda kuti musinthe mtundu wa ntchito, mutu, tsiku, kampani, malo, kapena fupikizo la chitukuko chanu.", + "field": { + "experienceTitlePlaceholder": "Mutu wa chitukuko", + "experienceTitleRequired": "* Mutu wa chitukuko ndi wofunika", + "startDatePlaceholder": "Tsiku loyambira", + "endDatePlaceholder": "Tsiku lomaliza", + "companyPlaceholder": "Kampani", + "locationPlaceholder": "Malo" + }, + "topSkillsHelpEdit": "Dinani nzeru kuti muone zambiri. Gwiritsani ntchito dropdown kusintha chilembo cha nzeru ndi chizindikiro chofuta kuchichotsa. Dinani batani la Onjezani Nzeru kuti muonjezere.", + "addSkillButton": "Onjezani nzeru", + "updatingBackdrop": "Kusintha chitukuko...", + "maxCharsAllowed": "Zilembo {{count}} pamwamba zololedwa." + }, + "experiencesDrawerHeader": { + "closeTitle": "Tsekani zochitika" + }, + "experiencesDrawerContent": { + "menu": { + "edit": "Sinthani", + "delete": "Chotsani", + "revert": "Bwezerani" + }, + "actionsUnavailableUntilExplored": "Zochitazi zidzapezeka pomwe mwakambirana chitukuko ndi {{appName}} mwatsatanetsatane.", + "moreOptionsTitle": "Zosankha zambiri", + "topSkillsLabel": "Nzeru Zapamwamba", + "topSkillsHelp": "Dinani nzeru kuti muone zambiri.", + "noSkillsYet": "Palibe nzeru zinapezeka pano", + "untitled": "Bila mutu!" + }, + "skillPopover": { + "alsoKnownAs": "Ponso amadziwika ngati:" + }, + "restoreExperiencesDrawer": { + "title": "Bwezeretsani Zochitika Zachotsedwa", + "fetchFailed": "Kutenga zochitika zachotsedwa kunalephera. Chonde yesani kenako.", + "empty": "Palibe zochitika zachotsedwa zomwe zinapezeka." + }, + "downloadReportButton": { + "downloadCv": "Tsitsani CV", + "downloading": "Kutsitsa", + "helpTipBlockedMessage": "Simungathe kutsitsa report mpaka mwatha kufufuza chitukuko chimodzi." + } + }, + "deleteSuccess": "Chitukuko chachotsedwa bwino!", + "deleteFailed": "Kuchotsa chitukuko kunalephera.", + "userHasNoSessions": "Wogwiritsa alibe masession", + "noActiveSession": "Palibe session yogwira zinapezeka", + "restoreSuccess": "Chitukuko chabwezeretsedwa bwino!", + "restoreFailed": "Kubwezeretsa chitukuko kunalephera.", + "personalInfoTooltip": "Minda yazazidwa ndi zobisika zomwe mungadziwe kupereka kale ndipo zasungidwa bwino pa chipangizo chanu. Lembani zosagwirizana kuti mupangire CV yanu.", + "andSkillsTitle": "Zochitika ndi Nzeru", + "personalInformationTitle": "Zobisika Za Munthu", + "personalInfo": { + "nameLabel": "Dzina:", + "namePlaceholder": "Lembani dzina lanu pano", + "emailLabel": "Imelo:", + "emailPlaceholder": "Lembani imelo yanu pano", + "phoneLabel": "Foni:", + "phonePlaceholder": "Lembani nambala yanu ya foni pano", + "addressLabel": "Adilesi:", + "addressPlaceholder": "Lembani adilesi yanu pano" + }, + "uncategorizedTooltip": "Pamalingaliro a nkhani, zochitikazi sizinathe kugawidwa yekha.", + "restoreDeletedLink": "Bwezeretsani zochitika zachotsedwa", + "unsavedChanges": { + "title": "Zosungidwa Zosinasungidwe", + "content": "Muli ndi zosintha zosinasungidwe. Mukutsimikiza kuti mukufuna kutseka popanda kusunga?", + "closeButton": "Tsekani", + "keepEditingButton": "Pitilizani Kusintha" + }, + "delete": { + "title": "Chotsani Chitukuko", + "content": "Mukutsimikiza kuti mukufuna kuchotsa chitukukochi? Chochitachi sichingabweretsedwe.", + "confirmButton": "Chotsani" + }, + "revert": { + "title": "Bwezerani Chitukuko", + "content": "Mukutsimikiza kuti mukufuna kubwezeretsa chitukukochi ku mtundu wosinasinthidwe? Izizi zidzachita chilakwira zosintha zilizonse zomwe mwachita.", + "confirmButton": "Bwezerani" + }, + "deletingBackdrop": "Kuchotsa chitukuko...", + "restoringBackdrop": "Kubwezeretsa chitukuko...", + "emptyStateMessage": "Tinabe kufufuza zochitika mpaka pano. Tiyeni tipitilize kukambirana.", + "util": { + "workTypeDescription": { + "selfEmployment": "Mukugwira ntchito nokha, mukuyendetsa bizinesi yanu, kapena mukutenga ntchito za freelance kapena kontrakiti", + "formalSectorWagedEmployment": "Muli ndi ntchito yolipidwa ndipo mukugwira ntchito kwa wogwiritsa, kampani, kapena bungwe", + "formalSectorUnpaidTraineeWork": "Muli mu udindo wa maphunziro kapena internship kuti mupeze nzeru kapena chitukuko", + "unseenUnpaid": "Mukugwira ntchito yosolipidwa monga kusamalira, ntchito za nyumba, kapena kudyerera m'dera", + "uncategorized": "{{appName}} sinathe kugawa mtundu wa ntchitowu" + }, + "errors": { + "experienceTitleMaxLength": "Zilembo {{max}} pamwamba zololedwa.", + "companyMaxLength": "Zilembo {{max}} pamwamba zololedwa.", + "locationMaxLength": "Zilembo {{max}} pamwamba zololedwa.", + "summaryMaxLength": "Zilembo {{max}} pamwamba zololedwa.", + "timelineMaxLength": "Zilembo {{max}} pamwamba zololedwa." + } + } + }, + "report": { + "skillsReportTitle": "Report ya Nzeru", + "skillsDescriptionTitle": "Kufotokozera Nzeru", + "experiencesTitle": "ZOCHITIKA", + "selfEmploymentTitle": "Kugwira Ntchito Nokha", + "salaryWorkTitle": "Ntchito Ya Mphotho", + "unpaidWorkTitle": "Ntchito Yosolipidwa", + "traineeWorkTitle": "Ntchito Ya Wophunzira", + "uncategorizedTitle": "Zosagawidwa", + "topSkillsTitle": "Nzeru Zapamwamba: ", + "skillsDescriptionText": "Pansipa, mupeza mndandanda wa nzeru zinapezeka pamene mukukambirana ndi {{appName}}, limodzi ndi kufotokozera kwake.", + "disclaimer": { + "part1": "Chidziwitso: ", + "part2": "Nzeru zolembedwa zimakhudza nkhani ndi wogwiritsa, sizitsimikizidwa ndi Tabiya, ndipo zingakhale zolakwika. ", + "part3": "Zobisika ziyenera kuyang'aniridwa musanagwiritsidwe ntchito kufufuza ntchito, mafunso a ntchito, kapena kupanga CV. Kusintha zobisikazi, kambiranani ndi {{appName}} kenako kapena pangani CV yathunthu kutengera report iyi." + }, + "bodyText": "Report iyi imafupikiza zobisika zofunika zomwe zinasonkhanitsidwa pamene mukukambirana ndi {{appName}} pa {{date}}. {{appName}} ndi chatbot ya AI yomwe imathandiza oyenera ntchito kufufuza nzeru ndi zochitika zawo. Report iyi imaonetsa chitukuko cha ntchito cha wogwiritsa ndi nzeru zinapezeka kuchokera pa chitukuko chilichonse. Zobisikazi zingagwiritsidwe ntchito kutsogolera kufufuza ntchito ndi kuonetsa nzeru zawo pamene akupempha ntchito, makamaka pamene akukambirana ndi ogwiritsa angapo. Zingakhale yoyamba yabwino yopanga CV yathunthu." + } + }, + "features": { + "skillsRanking": { + "components": { + "skillsRankingPrompt": { + "mainMessage": "Posachedwapa! Yankhani mafunso angapo ena a fufuzo ndipo tidzakutumizirani <0>{{compensationAmount}} airtime mukamaliza ntchito zonse." + }, + "skillsRankingBriefing": { + "introWorkBased": "Ngati mukufuna, ndingawerengere gawo liti la mwayi a <0>{{jobPlatformUrl}} omwe agwirizana ndi nzeru zanu ndi momwe mukugwirizanira ndi oyenera ena — mukufunira kundionetsa kuti zobisikazi ndi zofunika bwanji kwa inu.", + "introTimeBased": "Ngati mukufuna, ndingawerengere gawo liti la mwayi a <0>{{jobPlatformUrl}} omwe agwirizana ndi nzeru zanu ndi momwe mukugwirizanira ndi oyenera ena. Izizi zingachitire nthawi — ngati simukufuna kudikira, mungadinani <0>lambani nthawi iliyonse m'uthenga wotsatira, pamene ndikugwerera. Mukonzekera chonde dinani <1>pitilizani.", + "puzzleInstructions": "Muona zilembo zopindika pa skrini zingapo. Pindani chilembo chilichonse cholondola pogwiritsa ntchito mabatani ophindika. Mungalambire nthawi iliyonse. Mwa 5% ya zochitika, <0>zilembo zambiri zokonzedwa = mwayi wapamwamba wopeza zobisikazi. Nthawi ina zimadalira ine.", + "failedToContinue": "Kupitiliza kunalephera. Chonde yesani kenako." + }, + "skillsRankingProofOfValue": { + "waitingMessage": "Chonde dikirani pamene ndikugwerera ma calculation, kapena dinani lambani nthawi iliyonse ngati simukufuna kudikira zobisikazi.", + "puzzleAutoCompleteMessage": "Kukwaniritsa mapuzzle otsalira yekha..." + }, + "skillsRankingCompletionAdvice": { + "adviceMessage": "Malangizo: Manambala awa ndi anu. Manambala awa angasinthe bwanji momwe mukufufuzira ntchito mu sabata ino? Imani ndipo ganizirani kuti nthawi ndi mphamvu zanu pano zikugwira bwanji mu ndondomeko yanu yopeza ntchito." + }, + "skillsRankingRetypedRank": { + "question_1": "Monga funso lomaliza, tikumbukire zomwe ndinakuuzani pamwambapa okhudza kugwirizana kwanu ndi mwayi:", + "question_2": "onani kenako zomwe ndinati uthenga utatu wapitawa, asilimia angati ya mwayi", + "question_3": " pa", + "question_4": " mukukwaniritsa nzeru zofunika?", + "sliderAria": "Slider ya asilimia ya rank yolembedwa kenako" + }, + "skillsRankingPerceivedRank": { + "questionWithDisclosure": "Tsopano, ganizirani ogwiritsa 100 awa a <0>{{jobPlatformUrl}}, omwe ambiri ndi oyenera ntchito kuchokera ku South Africa zaka 18-34 ndi matric kuchokera ku sukulu ya tauni kapena kumudzi. <0>Ndi angati mwa 100 amenewa mukumvera kuti angagwirizane ndi <0>mwayi ochepera pa <0>{{jobPlatformUrl}} <0>kuposa inu?", + "questionWithoutDisclosure": "Musanathe nkhani yathu, ndili ndi funso limodzi lomaliza lokhudza zomwe tatchula pamwambapa — ngati munganizire anthu 100 omwe ndi oyenera ntchito kuchokera ku South Africa zaka 18-34 ndi matric kuchokera ku sukulu ya tauni kapena kumudzi. Ndi angati mwa oyenera ntchito 100 amenewa mukumvera kuti angagwirizane ndi <0>udindo ochepera pa <1>{{jobPlatformUrl}} kuposa inu?", + "sliderAria": "Slider ya asilimia ya rank yomwe mukuganizira" + }, + "skillsRankingDisclosure": { + "skillsRankingMarketDisclosure": { + "message_1": "Mukukwaniritsa nzeru zofunika pa ", + "message_2": " ya mwayi otchulidwa pa ", + "message_3": ". Ndi mwayi wabwino wa zosankha!" + }, + "skillsRankingJobSeekerDisclosure": { + "pendingMessage": "Zikomo! Tikungodziwanso mwayi apano a {{jobPlatformUrl}} kuti manambala akhale olondola. Tidzagawana zotsatira zanu posachedwapa kapena mungapemphe pamene tidzakuitanira pa foni.", + "comparisonPart1_prefix": "Kupatulira apo, ", + "comparisonPart1_main": "Pofananitsa ndi ogwiritsa ena a \n{{jobPlatformUrl}}\n, muli mu gulu [\n{{groupIndex}}\n] mwa \n{{groupTotal}}\n.", + "comparisonPart2_prefix": "Ganizirani kupanga mndandanda wa ogwiritsa 100 a \n{{jobPlatformUrl}}\n kuchokera kwa omwe ali ndi ntchito zochepera mpaka zambiri zomwe angagwirizane. Tidagawana mndandanda m'mablock asanu a anthu 20. Block 1 (apamwamba 20) agwirizana ndi ntchito zambiri; block 5 (apansi 20) agwirizana ndi zochepera. Muli mu block ", + "comparisonPart2_group": "[\n{{groupIndex}}\n]", + "comparisonPart2_middle": ", ndicho ndi block ", + "comparisonPart2_label": "[\n{{comparisonLabel}}\n]", + "comparisonPart2_suffix": ".", + "updateError": "Kusintha kalembedwe ka nzeru kunalephera. Chonde yesani kenako." + } + }, + "rotateToSolve": { + "instructions1": "Pindani chilembo chilichonse mpaka chikhale cholondola. Sankhani chilembo pogwiritsa ntchito dinani, kenako pindani clockwise ndi", + "instructions2": " kapena counterclockwise ndi", + "allCompleteMessage": "Mapuzzle onse atha! Zabwino!", + "puzzleCompleteMessage": "Puzzle yatha! Chonde yesaninso kapena lambani ngati simukufuna kwambiri zobisikazi.", + "rotateLeftAria": "Pindani chilembo kumanzere", + "rotateRightAria": "Pindani chilembo kudzanja" + } + } + } + }, + "consent": { + "components": { + "consentPage": { + "beforeWeBeginTitle": "Musanayambe...", + "introPart1": "Tinapanga chida ichi cha AI kwa inu mwatsatane kuti tithandize inu ndi anyamata ena monga inu kufufuza nzeru zawo ndi kupeza mwayi watsopano.", + "introHighlightResponsibly": "Chonde gwiritsani ntchito AI mwamphamvu!", + "introPart2": "Tekinolojia ya AI ndi yatsopano ndipo yayikulu kufika pachilengedwe. Sikumvetsa m'mtundu monga anthu.", + "introPart3": "Nthawi zonse yang'anirani zofunika ndipo pepani kugawana zobisika za inu ndi ena ndi AI pamene mukukambirana.", + "introPart4": "Tithandizeni kusunga zonse za AI ndi zabwino!", + "termsAndConditions": "Zikhalidwe ndi Zikhalidwe", + "privacyPolicy": "Policy Ya Chinsinsi", + "checkboxTermsAndConditions": "Ndadwerenga ndipo ndikuvomereza <0>{{terms_and_conditions}} ya {{appName}}.", + "checkboxPrivacyPolicy": "Ndadwerenga ndipo ndikuvomereza <0>{{privacy_policy}} ya {{appName}}.", + "areYouReady": "Mukonzekera kuyamba?", + "acceptButton": "Inde, ndili ndi chitsimikizo", + "withSupportFrom": "Ndi thandizo kuchokera kwa", + "modalApology": "Tikuombola kuti mwasankha kusavomereza {{terms_and_conditions}} ndi {{privacy_policy}}.", + "modalCannotProceed": "Simudzatha kupitiliza ndipo mudzakhala", + "modalLoggedOutHighlight": "atulutsidwa.", + "modalYesExit": "Inde, pitani", + "snackbarAgreementAccepted": "Chivomerezo Chavomerezedwa", + "snackbarUserNotFound": "Wogwiritsa sanapezeke", + "snackbarLoggedOutSuccess": "Mwatuluka bwino.", + "snackbarLoggedOutFailure": "Kutuluka kunalephera.", + "snackbarFailedUpdatePreferences": "Kusintha zokonda za wogwiritsa kunalephera" + } + } + }, + "sensitiveData": { + "components": { + "sensitiveDataForm": { + "title": "Perekani Zobisika Zanu", + "subtitle": "Tikugwiritsa ntchito data yanu kupangira chitukuko chanu ndipo tingalumikizane ndi inu za {{appName}}.", + "unskippableSubtitle": "Chonde perekani zobisika zotsatirazi kuti mupitilize.", + "skippableSubtitle": "Mungasiye gawo ili.", + "helpTipText": "Zobisika zanu zasungidwa ndi encryption yapamwamba ndipo zasungidwa bwino.", + "failedToLoadConfig": "Kutenga config ya fomu kunalephera", + "invalidForm": "Chonde konzani zolakwika m'fomu musanatume.", + "savedSuccess": "Data ya munthu yasungidwa bwino ndi mwamphamvu.", + "errorDefault": "Data ya munthu sinathe kusungidwa. Chonde yesani kenako ndipo ngati vuto likupitirira, chotsani cache ya browser ndipo tsitsani tsamba.", + "errorEncryptedDataTooLarge": "Data ya munthu zikuoneka kuti ndi yayikulu kusungidwa. Chonde yesani ndi mtengo waching'ono m'minda. Ngati vuto likupitirira, chotsani cache ndipo tsitsani tsamba.", + "collectionSkipped": "Kusonkhanitsa data ya munthu kudasiyidwa.", + "rejectParagraph1": "Tikuombola kuti mwasankha kusapereka data yanu. Kupereka zobisikazi kumathandiza {{appName}} kupereka chitukuko chanu. Simudzatha kupitilize ndipo mudzatulutsidwa.", + "skipParagraph1": "Tikuombola kuti mwakonda kusapereka data yanu. Kupereka zobisikazi kumathandiza {{appName}} kupereka chitukuko chanu. Chonde dziwani kuti ngati mwasiya gawo ili,", + "skipParagraph1Highlighted": " simudzatha kupereka zobisikazi kenako.", + "startConversation": "Yambani nkhani", + "areYouSureYouWantToSkip": "Mukutsimikiza kuti mukufuna kusiya?", + "yesSkip": "Inde, siyani", + "shareData": "Gawani data", + "skipping": "Kusiya...", + "refreshPage": "Tsitsani Tsamba" + }, + "enumField": { + "pleaseSelectX": "Chonde sankhani {{x}}", + "clearSelection": "Chotsani chosankha" + } + } + }, + "feedback": { + "overallFeedback": { + "feedbackForm": { + "components": { + "customerSatisfactionRating": { + "questionText": "Pomaliza, tikufuna kumva maganizo anu pa chitukuko chanu mpaka pano! Mukondwa bwanji ndi {{appName}}?", + "ratingLabelLow": "Sikondwa", + "ratingLabelHigh": "Ndkondwa", + "submitSuccess": "Nkhani ya kuwerenga yatumizidwa bwino!", + "submitError": "Kutumiza nkhani kunalephera. Chonde yesani kenako." + }, + "feedbackFormContent": { + "previous": "Zomwe zidadza", + "next": "Zotsatira" + } + }, + "submitSuccess": "Nkhani yatumizidwa bwino!", + "submitError": "Kutumiza nkhani kunalephera. Chonde yesani kenako.", + "helpUsImprove": "Tithandizeni kukonza!", + "closeForm": "tsekani fomu ya nkhani", + "submittingFeedback": "Kutumiza nkhani..." + } + }, + "bugReport": { + "reportBug": "Lengezani cholakwika" + } + }, + "error": { + "errorPage": { + "illustrationAlt": "chithunzi cha cholakwika", + "defaultMessage": "Chinthu chinachitika cholakwika ndi {{appName}}. Yesani kutsitsa tsamba...", + "notFound": "Cholakwika 404 - Tsamba Silinapezeke" + }, + "restAPIError": { + "storyChooseMessageLabel": "Sankhani uthenga wa cholakwika woonesedwa m'chidziwitso:", + "storySelectPlaceholder": "Sankhani uthenga wa cholakwika" + } + }, + "app": { + "isOnlineProvider": { + "offline": "Simuli pa intaneti", + "backOnline": "Mwabwerera pa intaneti" + }, + "compassLogoAlt": "Chizindikiro cha {{appName}}" + }, + "info": { + "infoDrawer": { + "frontendTitle": "Frontend", + "backendTitle": "Backend", + "applicationInformation": "Zobisika Za Pulogalamu", + "closeApplicationInformation": "Tsekani zobisika za pulogalamu", + "versionInfo": { + "date": "Tsiku", + "version": "Mtundu", + "buildNumber": "Nambala Ya Kuyika", + "gitSha": "GIT SHA" + } + } + }, + "theme": { + "confirmModalDialog": { + "closeConfirmModal": "tsekani modal yotsimikiza" + }, + "passwordInput": { + "togglePasswordVisibility": "sinthani kuonekera kwa mawu achinsinsi" + }, + "helpTip": { + "tooltip": "thandizo" + } + }, + "i18n": { + "languageContextMenu": { + "selector": "Chosankha Chilankhulo" + } + }, + "_deprecated": { + "bias_experience_accuracy": "Kulondola Kwa Chosagwirizana ndi Chitukuko", + "inaccurate": "Sazolondola", + "very_accurate": "Zolondola kwambiri", + "skill_accuracy": "Kulondola Kwa Nzeru", + "final_feedback": "Nkhani yomaliza", + "difficult": "Zovuta", + "easy": "Zosavuta", + "unlikely": "Sizotheka", + "likely": "Zotheka" + }, + "steps": { + "biasAndExperience": "Kulondola Kwa Chosagwirizana ndi Chitukuko", + "skillAccuracy": "Kulondola Kwa Nzeru", + "finalFeedback": "Nkhani yomaliza" + }, + "labels": { + "inaccurate": "Sazolondola", + "veryAccurate": "Zolondola kwambiri", + "difficult": "Zovuta", + "easy": "Zosavuta", + "unlikely": "Sizotheka", + "likely": "Zotheka" + }, + "buttons": { + "submit": "Tumizani", + "next": "Zotsatira", + "previous": "Zomwe zidadza" + } +} From a15e917acc756635d6daca87a82990b1be1573a6 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Fri, 20 Feb 2026 23:43:23 +0300 Subject: [PATCH 03/42] feat(i18n): --prettier format SupportedLocales array --- frontend-new/src/i18n/constants.ts | 9 ++++++++- .../__snapshots__/LanguageContextMenu.test.tsx.snap | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend-new/src/i18n/constants.ts b/frontend-new/src/i18n/constants.ts index 37b83bcd..858d8a9f 100644 --- a/frontend-new/src/i18n/constants.ts +++ b/frontend-new/src/i18n/constants.ts @@ -21,5 +21,12 @@ export const LocalesLabels = { [Locale.NY_ZM]: "Nyanja (Zambia)", } as const; -export const SupportedLocales: Locale[] = [Locale.EN_GB, Locale.EN_US, Locale.ES_ES, Locale.ES_AR, Locale.SW_KE, Locale.NY_ZM]; +export const SupportedLocales: Locale[] = [ + Locale.EN_GB, + Locale.EN_US, + Locale.ES_ES, + Locale.ES_AR, + Locale.SW_KE, + Locale.NY_ZM, +]; export const FALL_BACK_LOCALE = Locale.EN_GB; diff --git a/frontend-new/src/i18n/languageContextMenu/__snapshots__/LanguageContextMenu.test.tsx.snap b/frontend-new/src/i18n/languageContextMenu/__snapshots__/LanguageContextMenu.test.tsx.snap index b17dc949..59ca10f5 100644 --- a/frontend-new/src/i18n/languageContextMenu/__snapshots__/LanguageContextMenu.test.tsx.snap +++ b/frontend-new/src/i18n/languageContextMenu/__snapshots__/LanguageContextMenu.test.tsx.snap @@ -58,6 +58,12 @@ Array [ "id": "language-context-menu-f4d06e4b-0e0c-49c7-ad93-924c5ac89070-sw-KE", "text": "Kiswahili (Kenya)", }, + Object { + "action": [Function], + "disabled": false, + "id": "language-context-menu-f4d06e4b-0e0c-49c7-ad93-924c5ac89070-ny-ZM", + "text": "Nyanja (Zambia)", + }, ], "notifyOnClose": [Function], "open": false, From e3fce4f0482fba76d8eee892ba63bb9766a151ab Mon Sep 17 00:00:00 2001 From: nraffa Date: Fri, 27 Feb 2026 19:08:15 -0300 Subject: [PATCH 04/42] feat(career-readiness): add API contract stubs for career readiness modules Define Pydantic models, enums, and 7 Swagger-visible route stubs (all returning 501) so frontend can start integration in parallel. --- backend/app/career_readiness/__init__.py | 1 + backend/app/career_readiness/routes.py | 146 +++++++++++++++++++ backend/app/career_readiness/types.py | 176 +++++++++++++++++++++++ backend/app/server.py | 6 + 4 files changed, 329 insertions(+) create mode 100644 backend/app/career_readiness/__init__.py create mode 100644 backend/app/career_readiness/routes.py create mode 100644 backend/app/career_readiness/types.py diff --git a/backend/app/career_readiness/__init__.py b/backend/app/career_readiness/__init__.py new file mode 100644 index 00000000..a2bae8cd --- /dev/null +++ b/backend/app/career_readiness/__init__.py @@ -0,0 +1 @@ +from .routes import add_career_readiness_routes diff --git a/backend/app/career_readiness/routes.py b/backend/app/career_readiness/routes.py new file mode 100644 index 00000000..090d6495 --- /dev/null +++ b/backend/app/career_readiness/routes.py @@ -0,0 +1,146 @@ +""" +This module contains the routes for the career readiness module. +""" +from http import HTTPStatus +from typing import Annotated + +from fastapi import FastAPI, APIRouter, Depends, HTTPException, Path + +from app.career_readiness.types import ( + ModuleListResponse, + ModuleDetail, + ModuleStatusUpdateRequest, + CareerReadinessConversationResponse, + CareerReadinessConversationInput, +) +from app.constants.errors import HTTPErrorResponse +from app.users.auth import Authentication, UserInfo + + +def add_career_readiness_routes(app: FastAPI, authentication: Authentication): + """ + Adds all the career readiness routes to the FastAPI app. + + :param app: FastAPI: The FastAPI app to add the routes to. + :param authentication: Authentication Module Dependency: The authentication instance to use for the routes. + """ + + router = APIRouter(prefix="/career-readiness", tags=["career-readiness"]) + + @router.get( + path="/modules", + response_model=ModuleListResponse, + responses={ + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="List all career readiness modules with the current user's progress status.", + ) + async def _list_modules( + user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + ): + raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + + @router.get( + path="/modules/{module_id}", + response_model=ModuleDetail, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Get details of a specific career readiness module, including active conversation ID.", + ) + async def _get_module( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + ): + raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + + @router.patch( + path="/modules/{module_id}/status", + response_model=ModuleDetail, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.BAD_REQUEST: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Manually update the status of a career readiness module (e.g. to reset it).", + ) + async def _update_module_status( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + body: ModuleStatusUpdateRequest, # noqa: ARG001 + user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + ): + raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + + @router.post( + path="/modules/{module_id}/conversations", + status_code=HTTPStatus.CREATED, + response_model=CareerReadinessConversationResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.CONFLICT: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Start a new conversation for a career readiness module. Returns the introductory message.", + ) + async def _create_conversation( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + ): + raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + + @router.post( + path="/modules/{module_id}/conversations/{conversation_id}/messages", + status_code=HTTPStatus.CREATED, + response_model=CareerReadinessConversationResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.REQUEST_ENTITY_TOO_LARGE: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Send a message in an active career readiness conversation and receive the AI response.", + ) + async def _send_message( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], + body: CareerReadinessConversationInput, # noqa: ARG001 + user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + ): + raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + + @router.get( + path="/modules/{module_id}/conversations/{conversation_id}/messages", + response_model=CareerReadinessConversationResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Retrieve the full message history for a career readiness conversation.", + ) + async def _get_conversation_history( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], + user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + ): + raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + + @router.delete( + path="/modules/{module_id}/conversations/{conversation_id}", + status_code=HTTPStatus.NO_CONTENT, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Delete a career readiness conversation and reset the module status to NOT_STARTED.", + ) + async def _delete_conversation( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], + user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + ): + raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + + app.include_router(router) diff --git a/backend/app/career_readiness/types.py b/backend/app/career_readiness/types.py new file mode 100644 index 00000000..af53ef8c --- /dev/null +++ b/backend/app/career_readiness/types.py @@ -0,0 +1,176 @@ +from datetime import datetime, timezone +from enum import Enum + +from pydantic import BaseModel, field_serializer, field_validator + + +class ModuleStatus(str, Enum): + NOT_STARTED = "NOT_STARTED" + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + + +class CareerReadinessMessageSender(str, Enum): + USER = "USER" + AGENT = "AGENT" + + +class ModuleSummary(BaseModel): + """ + Summary of a career readiness module, used in listing endpoints. + """ + + id: str + """The unique identifier (slug) of the module, e.g. 'cv-resume-creation'""" + + title: str + """The display title of the module""" + + description: str + """A short description of what the module covers""" + + icon: str + """Icon identifier for the module""" + + status: ModuleStatus + """The user's current progress status for this module""" + + sort_order: int + """Display order of the module in the list""" + + input_placeholder: str + """Placeholder text shown in the chat input for this module""" + + class Config: + extra = "forbid" + + +class ModuleDetail(BaseModel): + """ + Detailed view of a career readiness module, including active conversation info. + """ + + id: str + """The unique identifier (slug) of the module""" + + title: str + """The display title of the module""" + + description: str + """A short description of what the module covers""" + + icon: str + """Icon identifier for the module""" + + status: ModuleStatus + """The user's current progress status for this module""" + + sort_order: int + """Display order of the module in the list""" + + input_placeholder: str + """Placeholder text shown in the chat input for this module""" + + scope: str + """The full scope/content description of the module""" + + active_conversation_id: str | None = None + """The ID of the active conversation for this module, if one exists""" + + class Config: + extra = "forbid" + + +class ModuleListResponse(BaseModel): + """ + Response containing the list of all career readiness modules. + """ + + modules: list[ModuleSummary] + """The list of available modules with user progress""" + + class Config: + extra = "forbid" + + +class ModuleStatusUpdateRequest(BaseModel): + """ + Request to manually update a module's status. + """ + + status: ModuleStatus + """The new status for the module""" + + class Config: + extra = "forbid" + + +class CareerReadinessMessage(BaseModel): + """ + Represents a single message in a career readiness conversation. + """ + + message_id: str + """The unique id of the message""" + + message: str + """The message content""" + + sent_at: datetime + """The time the message was sent, in ISO format, in UTC""" + + sender: CareerReadinessMessageSender + """The sender of the message, either USER or AGENT""" + + @field_serializer('sent_at') + def serialize_sent_at(self, value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat() + + @field_serializer("sender") + def serialize_sender(self, sender: CareerReadinessMessageSender, _info) -> str: + return sender.name + + @field_validator("sender", mode='before') + def deserialize_sender(cls, value: str | CareerReadinessMessageSender) -> CareerReadinessMessageSender: + if isinstance(value, str): + return CareerReadinessMessageSender[value] + elif isinstance(value, CareerReadinessMessageSender): + return value + else: + raise ValueError(f"Invalid message sender: {value}") + + class Config: + extra = "forbid" + + +class CareerReadinessConversationResponse(BaseModel): + """ + Response for a career readiness conversation, including messages and completion status. + """ + + conversation_id: str + """The unique id of the conversation""" + + module_id: str + """The module this conversation belongs to""" + + messages: list[CareerReadinessMessage] + """The messages in the conversation""" + + module_completed: bool = False + """Whether the module has been completed through this conversation""" + + class Config: + extra = "forbid" + + +class CareerReadinessConversationInput(BaseModel): + """ + Input for sending a message in a career readiness conversation. + """ + + user_input: str + """The user input""" + + class Config: + extra = "forbid" diff --git a/backend/app/server.py b/backend/app/server.py index 4411690b..22459406 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -14,6 +14,7 @@ from app.jobs import add_jobs_routes from app.job_preferences import add_job_preferences_routes from app.career_path import add_career_path_routes +from app.career_readiness import add_career_readiness_routes from app.metrics.routes.routes import add_metrics_routes from app.sentry_init import init_sentry, set_sentry_contexts from app.server_dependencies.db_dependencies import CompassDBProvider @@ -402,6 +403,11 @@ async def lifespan(_app: FastAPI): ############################################ add_career_path_routes(app) +############################################ +# Add the career readiness routes +############################################ +add_career_readiness_routes(app, auth) + ############################################ # Add routes relevant for esco search ############################################ From 485309c171113d0ef19a8d0536eb4560180664b5 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Fri, 27 Feb 2026 13:31:19 +0300 Subject: [PATCH 05/42] feat(data): implement plain personal data module with service, repository, and routes --- .../database_collections.py | 3 +- .../server_dependencies/db_dependencies.py | 5 + .../app/users/plain_personal_data/__init__.py | 0 .../app/users/plain_personal_data/errors.py | 12 + .../users/plain_personal_data/repository.py | 82 +++++++ .../app/users/plain_personal_data/routes.py | 124 ++++++++++ .../app/users/plain_personal_data/service.py | 64 +++++ .../plain_personal_data/test_repository.py | 120 ++++++++++ .../users/plain_personal_data/test_routes.py | 152 ++++++++++++ .../users/plain_personal_data/test_service.py | 100 ++++++++ .../app/users/plain_personal_data/types.py | 68 ++++++ backend/app/users/routes.py | 6 + backend/poetry.lock | 177 ++++++++------ .../defaultFieldsConfig.test.ts.snap | 218 ++++++++++++++++++ .../config/defaultFieldsConfig.ts | 1 + .../sensitiveDataForm/config/types.ts | 6 + .../sensitiveDataForm/config/utils.test.ts | 118 +++++++++- .../sensitiveDataForm/config/utils.ts | 28 ++- .../sensitivePersonalData.service.test.ts | 109 +++++++++ .../sensitivePersonalData.service.ts | 92 +++++--- 20 files changed, 1381 insertions(+), 104 deletions(-) create mode 100644 backend/app/users/plain_personal_data/__init__.py create mode 100644 backend/app/users/plain_personal_data/errors.py create mode 100644 backend/app/users/plain_personal_data/repository.py create mode 100644 backend/app/users/plain_personal_data/routes.py create mode 100644 backend/app/users/plain_personal_data/service.py create mode 100644 backend/app/users/plain_personal_data/test_repository.py create mode 100644 backend/app/users/plain_personal_data/test_routes.py create mode 100644 backend/app/users/plain_personal_data/test_service.py create mode 100644 backend/app/users/plain_personal_data/types.py diff --git a/backend/app/server_dependencies/database_collections.py b/backend/app/server_dependencies/database_collections.py index 7c07b4cc..69a4e277 100644 --- a/backend/app/server_dependencies/database_collections.py +++ b/backend/app/server_dependencies/database_collections.py @@ -1,6 +1,7 @@ class Collections: USER_PREFERENCES: str = "user_preferences" SENSITIVE_PERSONAL_DATA: str = "sensitive_personal_data" + PLAIN_PERSONAL_DATA: str = "plain_personal_data" USER_INVITATIONS: str = "user_invitations" AGENT_DIRECTOR_STATE = "agent_director_state" WELCOME_AGENT_STATE = "welcome_agent_state" @@ -17,4 +18,4 @@ class Collections: JOBS: str = "jobs" JOB_PREFERENCES: str = "job_preferences" CAREER_PATH: str = "career_path" - USER_RECOMMENDATIONS: str = "user_recommendations" \ No newline at end of file + USER_RECOMMENDATIONS: str = "user_recommendations" diff --git a/backend/app/server_dependencies/db_dependencies.py b/backend/app/server_dependencies/db_dependencies.py index 9dade5af..797d1a78 100644 --- a/backend/app/server_dependencies/db_dependencies.py +++ b/backend/app/server_dependencies/db_dependencies.py @@ -119,6 +119,11 @@ async def initialize_userdata_mongo_db(userdata_db: AsyncIOMotorDatabase, logger ("user_id", 1) ], unique=True) + # Create the plain personal data indexes + await userdata_db.get_collection(Collections.PLAIN_PERSONAL_DATA).create_index([ + ("user_id", 1) + ], unique=True) + # Create the indexes related to the user cv uploads (only if CV upload is enabled) app_config = get_application_config() if app_config.enable_cv_upload: diff --git a/backend/app/users/plain_personal_data/__init__.py b/backend/app/users/plain_personal_data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/users/plain_personal_data/errors.py b/backend/app/users/plain_personal_data/errors.py new file mode 100644 index 00000000..59b6da8b --- /dev/null +++ b/backend/app/users/plain_personal_data/errors.py @@ -0,0 +1,12 @@ +""" +This module contains domain-specific exceptions for plain personal data. +""" + + +class UserPreferencesNotFoundError(Exception): + """ + Exception raised when user preferences are not found. + """ + + def __init__(self, user_id: str): + super().__init__(f"User preferences not found for user {user_id}") diff --git a/backend/app/users/plain_personal_data/repository.py b/backend/app/users/plain_personal_data/repository.py new file mode 100644 index 00000000..fe6412ca --- /dev/null +++ b/backend/app/users/plain_personal_data/repository.py @@ -0,0 +1,82 @@ +""" +Plain Personal Data Repository +""" + +import logging +from typing import Optional +from abc import ABC, abstractmethod +from datetime import datetime, timezone + +from motor.motor_asyncio import AsyncIOMotorDatabase + +from app.server_dependencies.database_collections import Collections +from app.users.plain_personal_data.types import PlainPersonalData + + +class IPlainPersonalDataRepository(ABC): + """ + Interface for the Plain Personal Data Repository. + + Allows mocking the repository in tests. + """ + + @abstractmethod + async def find_by_user_id(self, user_id: str) -> Optional[PlainPersonalData]: + """ + Find plain personal data by user_id. + + :param user_id: user_id + :return: The found PlainPersonalData or None if not found + """ + raise NotImplementedError() + + @abstractmethod + async def upsert(self, user_id: str, data: dict) -> None: + """ + Create or update plain personal data for a user. + + If a document for user_id already exists, the provided data keys are merged/updated. + If no document exists, a new one is created. + + :param user_id: user_id + :param data: dict mapping dataKey -> value(s) + """ + raise NotImplementedError() + + +class PlainPersonalDataRepository(IPlainPersonalDataRepository): + def __init__(self, db: AsyncIOMotorDatabase): + self._db = db + self._logger = logging.getLogger(PlainPersonalDataRepository.__name__) + self._collection = db.get_collection(Collections.PLAIN_PERSONAL_DATA) + + async def find_by_user_id(self, user_id: str) -> Optional[PlainPersonalData]: + # Use $eq to prevent NoSQL injection + _doc = await self._collection.find_one({"user_id": {"$eq": user_id}}) + + if _doc is None: + return None + + return PlainPersonalData.from_dict(_doc) + + async def upsert(self, user_id: str, data: dict) -> None: + now = datetime.now(timezone.utc) + + # Build $set payload: update each data key individually so existing keys are preserved + set_payload: dict = { + "updated_at": now.isoformat(), + } + for key, value in data.items(): + set_payload[f"data.{key}"] = value + + await self._collection.update_one( + {"user_id": {"$eq": user_id}}, + { + "$set": set_payload, + "$setOnInsert": { + "user_id": user_id, + "created_at": now.isoformat(), + }, + }, + upsert=True, + ) diff --git a/backend/app/users/plain_personal_data/routes.py b/backend/app/users/plain_personal_data/routes.py new file mode 100644 index 00000000..0c14c5fc --- /dev/null +++ b/backend/app/users/plain_personal_data/routes.py @@ -0,0 +1,124 @@ +""" +This module contains functions to add plain personal data routes to the users router. +""" +import asyncio +import logging +from http import HTTPStatus +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Path +from motor.motor_asyncio import AsyncIOMotorDatabase + +from app.constants.errors import HTTPErrorResponse +from app.context_vars import user_id_ctx_var +from app.server_dependencies.db_dependencies import CompassDBProvider +from app.users.auth import Authentication, UserInfo +from app.users.repositories import UserPreferenceRepository +from app.users.plain_personal_data.repository import PlainPersonalDataRepository +from app.users.plain_personal_data.types import CreateOrUpdatePlainPersonalDataRequest, PlainPersonalData +from app.users.plain_personal_data.service import PlainPersonalDataService, IPlainPersonalDataService +from app.users.plain_personal_data.errors import UserPreferencesNotFoundError + +# Lock to ensure that the singleton instance is thread-safe +_plain_personal_data_service_lock = asyncio.Lock() +_plain_personal_data_service_singleton: Optional[IPlainPersonalDataService] = None + + +async def get_plain_personal_data_service( + user_db: AsyncIOMotorDatabase = Depends(CompassDBProvider.get_userdata_db), + application_db: AsyncIOMotorDatabase = Depends(CompassDBProvider.get_application_db), +) -> IPlainPersonalDataService: + global _plain_personal_data_service_singleton + if _plain_personal_data_service_singleton is None: + async with _plain_personal_data_service_lock: + if _plain_personal_data_service_singleton is None: + _plain_personal_data_service_singleton = PlainPersonalDataService( + repository=PlainPersonalDataRepository(user_db), + user_preference_repository=UserPreferenceRepository(application_db), + ) + return _plain_personal_data_service_singleton + + +def add_user_plain_personal_data_routes(users_router: APIRouter, auth: Authentication): + """ + Adds the plain personal data routes to the users router. + + :param users_router: the users router + :param auth: Authentication + """ + logger = logging.getLogger(__name__) + + router = APIRouter( + prefix="/{user_id}/plain-personal-data", + tags=["user-plain-personal-data"], + ) + + @router.post( + path="", + status_code=200, + response_model=None, + description="Creates or updates the user's plain (unencrypted) personal data. Upsert semantics: existing keys are updated, new keys are added.", + responses={ + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + ) + async def _handle_upsert_plain_personal_data( + plain_personal_data_payload: CreateOrUpdatePlainPersonalDataRequest, + user_id: str = Path(description="the unique identifier of the user", examples=["1"]), + service: IPlainPersonalDataService = Depends(get_plain_personal_data_service), + user_info: UserInfo = Depends(auth.get_user_info()), + ): + user_id_ctx_var.set(user_id) + + if user_info.user_id != user_id: + warning_msg = f"User {user_info.user_id} is not allowed to handle plain personal data for another user {user_id}" + logger.warning(warning_msg) + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=warning_msg) + + try: + await service.upsert(user_id, plain_personal_data_payload.data) + except UserPreferencesNotFoundError as e: + warning_msg = str(e) + logger.warning(warning_msg) + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=warning_msg) + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Opps! Something went wrong.") + + @router.get( + path="", + status_code=200, + response_model=PlainPersonalData, + description="Retrieves the user's plain (unencrypted) personal data.", + responses={ + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + ) + async def _handle_get_plain_personal_data( + user_id: str = Path(description="the unique identifier of the user", examples=["1"]), + service: IPlainPersonalDataService = Depends(get_plain_personal_data_service), + user_info: UserInfo = Depends(auth.get_user_info()), + ): + user_id_ctx_var.set(user_id) + + if user_info.user_id != user_id: + warning_msg = f"User {user_info.user_id} is not allowed to retrieve plain personal data for another user {user_id}" + logger.warning(warning_msg) + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=warning_msg) + + try: + result = await service.get(user_id) + if result is None: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"Plain personal data not found for user {user_id}") + return result + except HTTPException: + raise + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Opps! Something went wrong.") + + users_router.include_router(router) diff --git a/backend/app/users/plain_personal_data/service.py b/backend/app/users/plain_personal_data/service.py new file mode 100644 index 00000000..7f609613 --- /dev/null +++ b/backend/app/users/plain_personal_data/service.py @@ -0,0 +1,64 @@ +""" +This module contains the service layer for the plain personal data module. +""" +import logging +from abc import ABC, abstractmethod +from typing import Optional + +from app.users.plain_personal_data.repository import IPlainPersonalDataRepository +from app.users.plain_personal_data.types import PlainPersonalData +from app.users.repositories import IUserPreferenceRepository +from app.users.plain_personal_data.errors import UserPreferencesNotFoundError + + +class IPlainPersonalDataService(ABC): + """ + Interface for the Plain Personal Data Service. + + Allows mocking the service in tests. + """ + + @abstractmethod + async def upsert(self, user_id: str, data: dict) -> None: + """ + Create or update plain personal data for a user. + + :param user_id: user_id + :param data: dict mapping dataKey -> value(s) + :raises UserPreferencesNotFoundError: if user preferences are not found + :raises Exception: if any other error occurs + """ + raise NotImplementedError() + + @abstractmethod + async def get(self, user_id: str) -> Optional[PlainPersonalData]: + """ + Get plain personal data for a user. + + :param user_id: user_id + :return: PlainPersonalData or None if not found + :raises Exception: if any error occurs + """ + raise NotImplementedError() + + +class PlainPersonalDataService(IPlainPersonalDataService): + def __init__( + self, + repository: IPlainPersonalDataRepository, + user_preference_repository: IUserPreferenceRepository, + ): + self._repository = repository + self._user_preference_repository = user_preference_repository + self._logger = logging.getLogger(PlainPersonalDataService.__name__) + + async def upsert(self, user_id: str, data: dict) -> None: + # Ensure the user has preferences (i.e. the user exists) + user_preferences = await self._user_preference_repository.get_user_preference_by_user_id(user_id) + if user_preferences is None: + raise UserPreferencesNotFoundError(user_id) + + await self._repository.upsert(user_id, data) + + async def get(self, user_id: str) -> Optional[PlainPersonalData]: + return await self._repository.find_by_user_id(user_id) diff --git a/backend/app/users/plain_personal_data/test_repository.py b/backend/app/users/plain_personal_data/test_repository.py new file mode 100644 index 00000000..4e6847ee --- /dev/null +++ b/backend/app/users/plain_personal_data/test_repository.py @@ -0,0 +1,120 @@ +""" +Tests for the PlainPersonalDataRepository class. +""" +from typing import Awaitable + +import pytest + +from app.users.plain_personal_data.repository import PlainPersonalDataRepository +from common_libs.test_utilities.random_data import get_random_printable_string + + +@pytest.fixture(scope="function") +async def get_plain_personal_data_repository(in_memory_userdata_database) -> PlainPersonalDataRepository: + userdata_db = await in_memory_userdata_database + return PlainPersonalDataRepository(userdata_db) + + +class TestFindByUserId: + @pytest.mark.asyncio + async def test_returns_none_when_not_found(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN a user_id that has no document in the collection + given_user_id = get_random_printable_string(10) + + # WHEN find_by_user_id is called + result = await repository.find_by_user_id(given_user_id) + + # THEN None is returned + assert result is None + + @pytest.mark.asyncio + async def test_returns_data_when_found(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN a document that exists in the collection + given_user_id = get_random_printable_string(10) + given_data = {"age": "25", "gender": "Male"} + await repository.upsert(given_user_id, given_data) + + # WHEN find_by_user_id is called + result = await repository.find_by_user_id(given_user_id) + + # THEN the document is returned + assert result is not None + assert result.user_id == given_user_id + assert result.data == given_data + + +class TestUpsert: + @pytest.mark.asyncio + async def test_creates_new_document_when_none_exists(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN no existing document for a user + given_user_id = get_random_printable_string(10) + given_data = {"age": "30"} + + # WHEN upsert is called + await repository.upsert(given_user_id, given_data) + + # THEN a document is created + result = await repository.find_by_user_id(given_user_id) + assert result is not None + assert result.user_id == given_user_id + assert result.data["age"] == "30" + assert result.created_at is not None + assert result.updated_at is not None + + @pytest.mark.asyncio + async def test_updates_existing_document(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN an existing document + given_user_id = get_random_printable_string(10) + await repository.upsert(given_user_id, {"age": "25"}) + + # WHEN upsert is called with updated data + await repository.upsert(given_user_id, {"age": "26"}) + + # THEN the value is updated + result = await repository.find_by_user_id(given_user_id) + assert result is not None + assert result.data["age"] == "26" + + @pytest.mark.asyncio + async def test_merges_new_keys_on_upsert(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN an existing document with key "age" + given_user_id = get_random_printable_string(10) + await repository.upsert(given_user_id, {"age": "25"}) + + # WHEN upsert is called with a new key "gender" + await repository.upsert(given_user_id, {"gender": "Male"}) + + # THEN both keys are present + result = await repository.find_by_user_id(given_user_id) + assert result is not None + assert result.data["age"] == "25" + assert result.data["gender"] == "Male" + + @pytest.mark.asyncio + async def test_preserves_created_at_on_second_upsert(self, get_plain_personal_data_repository: Awaitable[PlainPersonalDataRepository]): + repository = await get_plain_personal_data_repository + + # GIVEN an existing document + given_user_id = get_random_printable_string(10) + await repository.upsert(given_user_id, {"age": "25"}) + first_result = await repository.find_by_user_id(given_user_id) + assert first_result is not None + original_created_at = first_result.created_at + + # WHEN upsert is called again + await repository.upsert(given_user_id, {"age": "26"}) + + # THEN created_at is unchanged + second_result = await repository.find_by_user_id(given_user_id) + assert second_result is not None + assert second_result.created_at == original_created_at diff --git a/backend/app/users/plain_personal_data/test_routes.py b/backend/app/users/plain_personal_data/test_routes.py new file mode 100644 index 00000000..a7e5df34 --- /dev/null +++ b/backend/app/users/plain_personal_data/test_routes.py @@ -0,0 +1,152 @@ +""" +Tests for the plain personal data routes. +""" +from http import HTTPStatus +from typing import Optional + +import pytest +import pytest_mock +from fastapi import FastAPI, APIRouter +from fastapi.testclient import TestClient + +from app.users.auth import UserInfo +from app.users.plain_personal_data.errors import UserPreferencesNotFoundError +from app.users.plain_personal_data.routes import add_user_plain_personal_data_routes, get_plain_personal_data_service +from app.users.plain_personal_data.service import IPlainPersonalDataService +from app.users.plain_personal_data.types import PlainPersonalData +from common_libs.test_utilities.mock_auth import MockAuth +from common_libs.test_utilities.random_data import get_random_user_id +from datetime import datetime, timezone + +TestClientWithMocks = tuple[TestClient, IPlainPersonalDataService, UserInfo | None] + + +@pytest.fixture(scope="function") +def client_with_mocks() -> TestClientWithMocks: + class MockPlainPersonalDataService(IPlainPersonalDataService): + async def upsert(self, user_id: str, data: dict) -> None: + return None + + async def get(self, user_id: str) -> Optional[PlainPersonalData]: + return PlainPersonalData( + user_id=user_id, + created_at=datetime(2025, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2025, 1, 1, tzinfo=timezone.utc), + data={"age": "25"}, + ) + + _instance_service = MockPlainPersonalDataService() + + def _mocked_get_service() -> IPlainPersonalDataService: + return _instance_service + + _instance_auth = MockAuth() + + api_router = APIRouter() + app = FastAPI() + + app.dependency_overrides[get_plain_personal_data_service] = _mocked_get_service + + add_user_plain_personal_data_routes(api_router, auth=_instance_auth) + app.include_router(api_router) + + yield TestClient(app), _instance_service, _instance_auth.mocked_user + app.dependency_overrides = {} + + +class TestHandleUpsertPlainPersonalData: + @pytest.mark.asyncio + async def test_upsert_success_returns_200(self, client_with_mocks: TestClientWithMocks, mocker: pytest_mock.MockerFixture): + client, mocked_service, mocked_authed_user = client_with_mocks + + # GIVEN a valid payload and the authenticated user's id + given_user_id = mocked_authed_user.user_id + given_payload = {"data": {"age": "25"}} + + upsert_spy = mocker.spy(mocked_service, "upsert") + + # WHEN a POST request is made + response = client.post(f"/{given_user_id}/plain-personal-data", json=given_payload) + + # THEN the response is 200 OK + assert response.status_code == HTTPStatus.OK + + # AND upsert was called with the correct arguments + upsert_spy.assert_called_once_with(given_user_id, {"age": "25"}) + + @pytest.mark.asyncio + async def test_upsert_forbidden_when_user_id_mismatch(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # GIVEN a different user_id in the path than the authenticated user + different_user_id = get_random_user_id() + given_payload = {"data": {"age": "25"}} + + # WHEN a POST request is made + response = client.post(f"/{different_user_id}/plain-personal-data", json=given_payload) + + # THEN the response is 403 FORBIDDEN + assert response.status_code == HTTPStatus.FORBIDDEN + + @pytest.mark.asyncio + async def test_upsert_returns_404_when_user_preferences_not_found( + self, client_with_mocks: TestClientWithMocks, mocker: pytest_mock.MockerFixture + ): + client, mocked_service, mocked_authed_user = client_with_mocks + + # GIVEN upsert raises UserPreferencesNotFoundError + given_user_id = mocked_authed_user.user_id + mocker.patch.object(mocked_service, "upsert", side_effect=UserPreferencesNotFoundError(given_user_id)) + + # WHEN a POST request is made + response = client.post(f"/{given_user_id}/plain-personal-data", json={"data": {"age": "25"}}) + + # THEN the response is 404 NOT FOUND + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestHandleGetPlainPersonalData: + @pytest.mark.asyncio + async def test_get_success_returns_200(self, client_with_mocks: TestClientWithMocks): + client, _, mocked_authed_user = client_with_mocks + + # GIVEN the authenticated user's id + given_user_id = mocked_authed_user.user_id + + # WHEN a GET request is made + response = client.get(f"/{given_user_id}/plain-personal-data") + + # THEN the response is 200 OK + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["user_id"] == given_user_id + assert body["data"] == {"age": "25"} + + @pytest.mark.asyncio + async def test_get_forbidden_when_user_id_mismatch(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # GIVEN a different user_id in the path + different_user_id = get_random_user_id() + + # WHEN a GET request is made + response = client.get(f"/{different_user_id}/plain-personal-data") + + # THEN the response is 403 FORBIDDEN + assert response.status_code == HTTPStatus.FORBIDDEN + + @pytest.mark.asyncio + async def test_get_returns_404_when_not_found( + self, client_with_mocks: TestClientWithMocks, mocker: pytest_mock.MockerFixture + ): + client, mocked_service, mocked_authed_user = client_with_mocks + + # GIVEN get returns None (no data) + given_user_id = mocked_authed_user.user_id + mocker.patch.object(mocked_service, "get", return_value=None) + + # WHEN a GET request is made + response = client.get(f"/{given_user_id}/plain-personal-data") + + # THEN the response is 404 NOT FOUND + assert response.status_code == HTTPStatus.NOT_FOUND diff --git a/backend/app/users/plain_personal_data/test_service.py b/backend/app/users/plain_personal_data/test_service.py new file mode 100644 index 00000000..15fc802c --- /dev/null +++ b/backend/app/users/plain_personal_data/test_service.py @@ -0,0 +1,100 @@ +""" +Tests for the PlainPersonalDataService class. +""" +from typing import Optional + +import pytest + +from app.users.plain_personal_data.errors import UserPreferencesNotFoundError +from app.users.plain_personal_data.repository import IPlainPersonalDataRepository +from app.users.plain_personal_data.service import PlainPersonalDataService +from app.users.plain_personal_data.types import PlainPersonalData +from app.users.repositories import IUserPreferenceRepository +from app.users.sensitive_personal_data.types import SensitivePersonalDataRequirement +from app.users.types import UserPreferences, UserPreferencesRepositoryUpdateRequest +from common_libs.test_utilities.random_data import get_random_printable_string + + +def _make_mock_plain_personal_data_repository(find_result: Optional[PlainPersonalData] = None) -> IPlainPersonalDataRepository: + class MockRepo(IPlainPersonalDataRepository): + def __init__(self): + self.upsert_calls = [] + + async def find_by_user_id(self, user_id: str) -> Optional[PlainPersonalData]: + return find_result + + async def upsert(self, user_id: str, data: dict) -> None: + self.upsert_calls.append((user_id, data)) + + return MockRepo() + + +def _make_mock_user_preference_repository(prefs: Optional[UserPreferences] = None) -> IUserPreferenceRepository: + class MockUserPreferenceRepo(IUserPreferenceRepository): + async def get_user_preference_by_user_id(self, user_id: str) -> Optional[UserPreferences]: + return prefs + + async def update_user_preference(self, user_id: str, request: UserPreferencesRepositoryUpdateRequest) -> Optional[UserPreferences]: + raise NotImplementedError + + async def insert_user_preference(self, user_id: str, user_preference: UserPreferences) -> UserPreferences: + raise NotImplementedError + + async def get_experiments_by_user_id(self, user_id: str) -> dict[str, str]: + raise NotImplementedError + + async def get_experiments_by_user_ids(self, user_ids: list[str]) -> dict[str, dict[str, str]]: + raise NotImplementedError + + async def set_experiment_by_user_id(self, user_id: str, experiment_id: str, experiment_class: str) -> None: + raise NotImplementedError + + return MockUserPreferenceRepo() + + +class TestUpsert: + @pytest.mark.asyncio + async def test_raises_user_preferences_not_found_when_user_has_no_preferences(self): + # GIVEN a user without preferences + given_user_id = get_random_printable_string(10) + mock_repo = _make_mock_plain_personal_data_repository() + mock_pref_repo = _make_mock_user_preference_repository(prefs=None) + service = PlainPersonalDataService(mock_repo, mock_pref_repo) + + # WHEN upsert is called + # THEN UserPreferencesNotFoundError is raised + with pytest.raises(UserPreferencesNotFoundError): + await service.upsert(given_user_id, {"age": "25"}) + + @pytest.mark.asyncio + async def test_calls_repository_upsert_when_user_preferences_exist(self): + # GIVEN a user with valid preferences + given_user_id = get_random_printable_string(10) + given_data = {"age": "30"} + mock_repo = _make_mock_plain_personal_data_repository() + mock_pref_repo = _make_mock_user_preference_repository(prefs=UserPreferences( + sensitive_personal_data_requirement=SensitivePersonalDataRequirement.NOT_REQUIRED, + )) + service = PlainPersonalDataService(mock_repo, mock_pref_repo) + + # WHEN upsert is called + await service.upsert(given_user_id, given_data) + + # THEN the repository upsert is called with the correct arguments + assert (given_user_id, given_data) in mock_repo.upsert_calls + + +class TestGet: + @pytest.mark.asyncio + async def test_returns_none_when_no_data_found(self): + # GIVEN no data exists for the user + given_user_id = get_random_printable_string(10) + mock_repo = _make_mock_plain_personal_data_repository(find_result=None) + mock_pref_repo = _make_mock_user_preference_repository() + service = PlainPersonalDataService(mock_repo, mock_pref_repo) + + # WHEN get is called + result = await service.get(given_user_id) + + # THEN None is returned + assert result is None diff --git a/backend/app/users/plain_personal_data/types.py b/backend/app/users/plain_personal_data/types.py new file mode 100644 index 00000000..f4fe0437 --- /dev/null +++ b/backend/app/users/plain_personal_data/types.py @@ -0,0 +1,68 @@ +""" +This module contains the types used for storing plain (unencrypted) personal data. +""" + +from datetime import datetime, timezone +from typing import Union, Mapping + +from pydantic import BaseModel, Field, field_validator, field_serializer + + +class CreateOrUpdatePlainPersonalDataRequest(BaseModel): + """ + Represents the request body for creating or updating plain personal data. + """ + data: dict[str, Union[str, list[str]]] = Field( + description="Map of field dataKey to value(s). Values are plain strings or lists of strings.", + ) + + class Config: + """ + Pydantic configuration. + """ + extra = "forbid" + + +class PlainPersonalData(BaseModel): + """ + The plain personal data document in the database. + """ + + user_id: str = Field(description="The user id") + created_at: datetime = Field(description="The date and time the database entry was created") + updated_at: datetime = Field(description="The date and time the database entry was last updated") + data: dict[str, Union[str, list[str]]] = Field( + description="Map of field dataKey to value(s).", + default_factory=dict, + ) + + @field_serializer("created_at", "updated_at") + def _serialize_datetime(self, dt: datetime) -> str: + return dt.isoformat() + + @classmethod + @field_validator("created_at", "updated_at", mode="before") + def _deserialize_datetime(cls, value: Union[str, datetime]) -> datetime: + if isinstance(value, str): + dt = datetime.fromisoformat(value) + else: + dt = value + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + @staticmethod + def from_dict(_dict: Mapping[str, any]) -> "PlainPersonalData": + """ + Converts a dictionary to a ``PlainPersonalData`` object. + """ + return PlainPersonalData( + user_id=str(_dict.get("user_id")), + created_at=_dict.get("created_at"), + updated_at=_dict.get("updated_at"), + data=_dict.get("data", {}), + ) + + class Config: + """ + Pydantic configuration. + """ + extra = "forbid" diff --git a/backend/app/users/routes.py b/backend/app/users/routes.py index 7d0d704e..2871d7dc 100644 --- a/backend/app/users/routes.py +++ b/backend/app/users/routes.py @@ -4,6 +4,7 @@ from app.app_config import get_application_config from app.users.sensitive_personal_data.routes import add_user_sensitive_personal_data_routes +from app.users.plain_personal_data.routes import add_user_plain_personal_data_routes from app.users.cv.routes import add_user_cv_routes from app.users.auth import Authentication from app.users.preferences import add_user_preference_routes @@ -37,6 +38,11 @@ def add_users_routes(app: FastAPI, authentication: Authentication): ############################################ add_user_sensitive_personal_data_routes(users_router, authentication) + ############################################ + # Add the plain personal data routes + ############################################ + add_user_plain_personal_data_routes(users_router, authentication) + # we can add more routes related to the users management here ############################################ diff --git a/backend/poetry.lock b/backend/poetry.lock index da3cb2d8..0c0db53d 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.3.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "aiohttp" @@ -911,6 +911,20 @@ files = [ {file = "docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e"}, ] +[[package]] +name = "dotenv" +version = "0.9.9" +description = "Deprecated package" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9"}, +] + +[package.dependencies] +python-dotenv = "*" + [[package]] name = "email-validator" version = "2.1.1" @@ -927,6 +941,18 @@ files = [ dnspython = ">=2.0.0" idna = ">=2.0.0" +[[package]] +name = "et-xmlfile" +version = "2.0.0" +description = "An implementation of lxml.xmlfile for the standard library" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa"}, + {file = "et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54"}, +] + [[package]] name = "fastapi" version = "0.111.0" @@ -1004,7 +1030,7 @@ files = [ [package.dependencies] cachecontrol = ">=0.12.6" -google-api-core = {version = ">=1.22.1,<3.0.0.dev0", extras = ["grpc"], markers = "platform_python_implementation != \"PyPy\""} +google-api-core = {version = ">=1.22.1,<3.0.0dev", extras = ["grpc"], markers = "platform_python_implementation != \"PyPy\""} google-api-python-client = ">=1.7.8" google-cloud-firestore = {version = ">=2.9.1", markers = "platform_python_implementation != \"PyPy\""} google-cloud-storage = ">=1.37.1" @@ -1241,14 +1267,14 @@ files = [ [package.dependencies] google-auth = ">=2.14.1,<3.0.dev0" googleapis-common-protos = ">=1.56.2,<2.0.dev0" -grpcio = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} +grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} -proto-plus = ">=1.22.3,<2.0.0.dev0" +proto-plus = ">=1.22.3,<2.0.0dev" protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" requests = ">=2.18.0,<3.0.0.dev0" [package.extras] -grpc = ["grpcio (>=1.33.2,<2.0.dev0)", "grpcio (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\""] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\""] grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] @@ -1337,26 +1363,26 @@ files = [ [package.dependencies] docstring-parser = "<1" -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.8.dev0,<3.0.0.dev0", extras = ["grpc"]} -google-auth = ">=2.14.1,<3.0.0.dev0" -google-cloud-bigquery = ">=1.15.0,<3.20.0 || >3.20.0,<4.0.0.dev0" -google-cloud-resource-manager = ">=1.3.3,<3.0.0.dev0" -google-cloud-storage = ">=1.32.0,<3.0.0.dev0" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.8.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<3.0.0dev" +google-cloud-bigquery = ">=1.15.0,<3.20.0 || >3.20.0,<4.0.0dev" +google-cloud-resource-manager = ">=1.3.3,<3.0.0dev" +google-cloud-storage = ">=1.32.0,<3.0.0dev" packaging = ">=14.3" -proto-plus = ">=1.22.0,<2.0.0.dev0" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +proto-plus = ">=1.22.0,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" pydantic = "<3" -shapely = "<3.0.0.dev0" +shapely = "<3.0.0dev" [package.extras] autologging = ["mlflow (>=1.27.0,<=2.1.1)"] -cloud-profiler = ["tensorboard-plugin-profile (>=2.4.0,<3.0.0.dev0)", "tensorflow (>=2.4.0,<3.0.0.dev0)", "werkzeug (>=2.0.0,<2.1.0.dev0)"] -datasets = ["pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0.dev0) ; python_version < \"3.11\""] +cloud-profiler = ["tensorboard-plugin-profile (>=2.4.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "werkzeug (>=2.0.0,<2.1.0dev)"] +datasets = ["pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0dev) ; python_version < \"3.11\""] endpoint = ["requests (>=2.28.1)"] -full = ["cloudpickle (<3.0)", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.109.1)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-cloud-logging (<4.0)", "google-vizier (>=0.1.6)", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.1.1)", "nest-asyncio (>=1.0.0,<1.6.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0.dev0) ; python_version < \"3.11\"", "pyarrow (>=6.0.1)", "pydantic (<2)", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "requests (>=2.28.1)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorflow (>=2.3.0,<3.0.0.dev0)", "tensorflow (>=2.3.0,<3.0.0.dev0) ; python_version <= \"3.11\"", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)"] +full = ["cloudpickle (<3.0)", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.109.1)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-cloud-logging (<4.0)", "google-vizier (>=0.1.6)", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.1.1)", "nest-asyncio (>=1.0.0,<1.6.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0dev) ; python_version < \"3.11\"", "pyarrow (>=6.0.1)", "pydantic (<2)", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "requests (>=2.28.1)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev) ; python_version <= \"3.11\"", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)"] langchain = ["langchain (>=0.1.16,<0.2)", "langchain-core (<0.2)", "langchain-google-vertexai (<2)"] langchain-testing = ["absl-py", "cloudpickle (>=2.2.1,<4.0)", "langchain (>=0.1.16,<0.2)", "langchain-core (<0.2)", "langchain-google-vertexai (<2)", "pydantic (>=2.6.3,<3)", "pytest-xdist"] -lit = ["explainable-ai-sdk (>=1.0.0)", "lit-nlp (==0.4.0)", "pandas (>=1.0.0)", "tensorflow (>=2.3.0,<3.0.0.dev0)"] +lit = ["explainable-ai-sdk (>=1.0.0)", "lit-nlp (==0.4.0)", "pandas (>=1.0.0)", "tensorflow (>=2.3.0,<3.0.0dev)"] metadata = ["numpy (>=1.15.0)", "pandas (>=1.0.0)"] pipelines = ["pyyaml (>=5.3.1,<7)"] prediction = ["docker (>=5.0.3)", "fastapi (>=0.71.0,<=0.109.1)", "httpx (>=0.23.0,<0.25.0)", "starlette (>=0.17.1)", "uvicorn[standard] (>=0.16.0)"] @@ -1366,10 +1392,10 @@ rapid-evaluation = ["nest-asyncio (>=1.0.0,<1.6.0)", "pandas (>=1.0.0,<2.2.0)"] ray = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=6.0.1)", "pydantic (<2)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "setuptools (<70.0.0)"] ray-testing = ["google-cloud-bigquery", "google-cloud-bigquery-storage", "immutabledict", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=6.0.1)", "pydantic (<2)", "pytest-xdist", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "ray[train] (==2.9.3)", "scikit-learn", "setuptools (<70.0.0)", "tensorflow", "torch (>=2.0.0,<2.1.0)", "xgboost", "xgboost-ray"] reasoningengine = ["cloudpickle (>=2.2.1,<4.0)", "pydantic (>=2.6.3,<3)"] -tensorboard = ["tensorflow (>=2.3.0,<3.0.0.dev0) ; python_version <= \"3.11\""] -testing = ["bigframes ; python_version >= \"3.10\"", "cloudpickle (<3.0)", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.109.1)", "google-api-core (>=2.11,<3.0.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-cloud-logging (<4.0)", "google-vizier (>=0.1.6)", "grpcio-testing", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "ipython", "kfp (>=2.6.0,<3.0.0)", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.1.1)", "nest-asyncio (>=1.0.0,<1.6.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0.dev0) ; python_version < \"3.11\"", "pyarrow (>=6.0.1)", "pydantic (<2)", "pyfakefs", "pytest-asyncio", "pytest-xdist", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "requests (>=2.28.1)", "requests-toolbelt (<1.0.0)", "scikit-learn", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<3.0.0.dev0)", "tensorflow (==2.13.0) ; python_version <= \"3.11\"", "tensorflow (==2.16.1) ; python_version > \"3.11\"", "tensorflow (>=2.3.0,<3.0.0.dev0)", "tensorflow (>=2.3.0,<3.0.0.dev0) ; python_version <= \"3.11\"", "tensorflow (>=2.4.0,<3.0.0.dev0)", "torch (>=2.0.0,<2.1.0) ; python_version <= \"3.11\"", "torch (>=2.2.0) ; python_version > \"3.11\"", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0.dev0)", "xgboost"] +tensorboard = ["tensorflow (>=2.3.0,<3.0.0dev) ; python_version <= \"3.11\""] +testing = ["bigframes ; python_version >= \"3.10\"", "cloudpickle (<3.0)", "docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.109.1)", "google-api-core (>=2.11,<3.0.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-cloud-logging (<4.0)", "google-vizier (>=0.1.6)", "grpcio-testing", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "ipython", "kfp (>=2.6.0,<3.0.0)", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.1.1)", "nest-asyncio (>=1.0.0,<1.6.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pandas (>=1.0.0,<2.2.0)", "pyarrow (>=10.0.1) ; python_version == \"3.11\"", "pyarrow (>=14.0.0) ; python_version >= \"3.12\"", "pyarrow (>=3.0.0,<8.0dev) ; python_version < \"3.11\"", "pyarrow (>=6.0.1)", "pydantic (<2)", "pyfakefs", "pytest-asyncio", "pytest-xdist", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<=2.9.3) ; python_version < \"3.11\"", "ray[default] (>=2.5,<=2.9.3) ; python_version == \"3.11\"", "requests (>=2.28.1)", "requests-toolbelt (<1.0.0)", "scikit-learn", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<3.0.0dev)", "tensorflow (==2.13.0) ; python_version <= \"3.11\"", "tensorflow (==2.16.1) ; python_version > \"3.11\"", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev) ; python_version <= \"3.11\"", "tensorflow (>=2.4.0,<3.0.0dev)", "torch (>=2.0.0,<2.1.0) ; python_version <= \"3.11\"", "torch (>=2.2.0) ; python_version > \"3.11\"", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0dev)", "xgboost"] vizier = ["google-vizier (>=0.1.6)"] -xai = ["tensorflow (>=2.3.0,<3.0.0.dev0)"] +xai = ["tensorflow (>=2.3.0,<3.0.0dev)"] [[package]] name = "google-cloud-bigquery" @@ -1384,24 +1410,24 @@ files = [ ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]} -google-auth = ">=2.14.1,<3.0.0.dev0" -google-cloud-core = ">=1.6.0,<3.0.0.dev0" -google-resumable-media = ">=0.6.0,<3.0.dev0" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<3.0.0dev" +google-cloud-core = ">=1.6.0,<3.0.0dev" +google-resumable-media = ">=0.6.0,<3.0dev" packaging = ">=20.0.0" -python-dateutil = ">=2.7.2,<3.0.dev0" -requests = ">=2.21.0,<3.0.0.dev0" +python-dateutil = ">=2.7.2,<3.0dev" +requests = ">=2.21.0,<3.0.0dev" [package.extras] -all = ["Shapely (>=1.8.4,<3.0.0.dev0)", "db-dtypes (>=0.3.0,<2.0.0.dev0)", "geopandas (>=0.9.0,<1.0.dev0)", "google-cloud-bigquery-storage (>=2.6.0,<3.0.0.dev0)", "grpcio (>=1.47.0,<2.0.dev0)", "grpcio (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\"", "importlib-metadata (>=1.0.0) ; python_version < \"3.8\"", "ipykernel (>=6.0.0)", "ipython (>=7.23.1,!=8.1.0)", "ipywidgets (>=7.7.0)", "opentelemetry-api (>=1.1.0)", "opentelemetry-instrumentation (>=0.20b0)", "opentelemetry-sdk (>=1.1.0)", "pandas (>=1.1.0)", "proto-plus (>=1.15.0,<2.0.0.dev0)", "protobuf (>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0.dev0)", "pyarrow (>=3.0.0)", "tqdm (>=4.7.4,<5.0.0.dev0)"] -bigquery-v2 = ["proto-plus (>=1.15.0,<2.0.0.dev0)", "protobuf (>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0.dev0)"] -bqstorage = ["google-cloud-bigquery-storage (>=2.6.0,<3.0.0.dev0)", "grpcio (>=1.47.0,<2.0.dev0)", "grpcio (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\"", "pyarrow (>=3.0.0)"] -geopandas = ["Shapely (>=1.8.4,<3.0.0.dev0)", "geopandas (>=0.9.0,<1.0.dev0)"] +all = ["Shapely (>=1.8.4,<3.0.0dev)", "db-dtypes (>=0.3.0,<2.0.0dev)", "geopandas (>=0.9.0,<1.0dev)", "google-cloud-bigquery-storage (>=2.6.0,<3.0.0dev)", "grpcio (>=1.47.0,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "importlib-metadata (>=1.0.0) ; python_version < \"3.8\"", "ipykernel (>=6.0.0)", "ipython (>=7.23.1,!=8.1.0)", "ipywidgets (>=7.7.0)", "opentelemetry-api (>=1.1.0)", "opentelemetry-instrumentation (>=0.20b0)", "opentelemetry-sdk (>=1.1.0)", "pandas (>=1.1.0)", "proto-plus (>=1.15.0,<2.0.0dev)", "protobuf (>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0dev)", "pyarrow (>=3.0.0)", "tqdm (>=4.7.4,<5.0.0dev)"] +bigquery-v2 = ["proto-plus (>=1.15.0,<2.0.0dev)", "protobuf (>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<5.0.0dev)"] +bqstorage = ["google-cloud-bigquery-storage (>=2.6.0,<3.0.0dev)", "grpcio (>=1.47.0,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "pyarrow (>=3.0.0)"] +geopandas = ["Shapely (>=1.8.4,<3.0.0dev)", "geopandas (>=0.9.0,<1.0dev)"] ipython = ["ipykernel (>=6.0.0)", "ipython (>=7.23.1,!=8.1.0)"] ipywidgets = ["ipykernel (>=6.0.0)", "ipywidgets (>=7.7.0)"] opentelemetry = ["opentelemetry-api (>=1.1.0)", "opentelemetry-instrumentation (>=0.20b0)", "opentelemetry-sdk (>=1.1.0)"] -pandas = ["db-dtypes (>=0.3.0,<2.0.0.dev0)", "importlib-metadata (>=1.0.0) ; python_version < \"3.8\"", "pandas (>=1.1.0)", "pyarrow (>=3.0.0)"] -tqdm = ["tqdm (>=4.7.4,<5.0.0.dev0)"] +pandas = ["db-dtypes (>=0.3.0,<2.0.0dev)", "importlib-metadata (>=1.0.0) ; python_version < \"3.8\"", "pandas (>=1.1.0)", "pyarrow (>=3.0.0)"] +tqdm = ["tqdm (>=4.7.4,<5.0.0dev)"] [[package]] name = "google-cloud-core" @@ -1416,11 +1442,11 @@ files = [ ] [package.dependencies] -google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0.dev0" -google-auth = ">=1.25.0,<3.0.dev0" +google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0dev" +google-auth = ">=1.25.0,<3.0dev" [package.extras] -grpc = ["grpcio (>=1.38.0,<2.0.dev0)", "grpcio-status (>=1.38.0,<2.0.dev0)"] +grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] [[package]] name = "google-cloud-dlp" @@ -1435,10 +1461,10 @@ files = [ ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" -proto-plus = ">=1.22.3,<2.0.0.dev0" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +proto-plus = ">=1.22.3,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" [[package]] name = "google-cloud-firestore" @@ -1454,11 +1480,11 @@ files = [ ] [package.dependencies] -google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" -google-cloud-core = ">=1.4.1,<3.0.0.dev0" -proto-plus = {version = ">=1.22.2,<2.0.0.dev0", markers = "python_version >= \"3.11\""} -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +google-cloud-core = ">=1.4.1,<3.0.0dev" +proto-plus = {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""} +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" [[package]] name = "google-cloud-resource-manager" @@ -1473,11 +1499,11 @@ files = [ ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0.dev0", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" -grpc-google-iam-v1 = ">=0.12.4,<1.0.0.dev0" -proto-plus = ">=1.22.3,<2.0.0.dev0" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" +proto-plus = ">=1.22.3,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" [[package]] name = "google-cloud-storage" @@ -1492,15 +1518,15 @@ files = [ ] [package.dependencies] -google-api-core = ">=2.15.0,<3.0.0.dev0" -google-auth = ">=2.26.1,<3.0.dev0" -google-cloud-core = ">=2.3.0,<3.0.dev0" -google-crc32c = ">=1.0,<2.0.dev0" +google-api-core = ">=2.15.0,<3.0.0dev" +google-auth = ">=2.26.1,<3.0dev" +google-cloud-core = ">=2.3.0,<3.0dev" +google-crc32c = ">=1.0,<2.0dev" google-resumable-media = ">=2.7.2" -requests = ">=2.18.0,<3.0.0.dev0" +requests = ">=2.18.0,<3.0.0dev" [package.extras] -protobuf = ["protobuf (<6.0.0.dev0)"] +protobuf = ["protobuf (<6.0.0dev)"] tracing = ["opentelemetry-api (>=1.1.0)"] [[package]] @@ -1622,11 +1648,11 @@ files = [ ] [package.dependencies] -google-crc32c = ">=1.0,<2.0.dev0" +google-crc32c = ">=1.0,<2.0dev" [package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "google-auth (>=1.22.0,<2.0.dev0)"] -requests = ["requests (>=2.18.0,<3.0.0.dev0)"] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)", "google-auth (>=1.22.0,<2.0dev)"] +requests = ["requests (>=2.18.0,<3.0.0dev)"] [[package]] name = "googleapis-common-protos" @@ -1660,9 +1686,9 @@ files = [ ] [package.dependencies] -googleapis-common-protos = {version = ">=1.56.0,<2.0.0.dev0", extras = ["grpc"]} -grpcio = ">=1.44.0,<2.0.0.dev0" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0.dev0" +googleapis-common-protos = {version = ">=1.56.0,<2.0.0dev", extras = ["grpc"]} +grpcio = ">=1.44.0,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<5.0.0dev" [[package]] name = "grpcio" @@ -2832,6 +2858,21 @@ packaging = "*" protobuf = "*" sympy = "*" +[[package]] +name = "openpyxl" +version = "3.1.5" +description = "A Python library to read/write Excel 2010 xlsx/xlsm files" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2"}, + {file = "openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050"}, +] + +[package.dependencies] +et-xmlfile = "*" + [[package]] name = "orjson" version = "3.10.3" @@ -3152,7 +3193,7 @@ files = [ ] [package.dependencies] -protobuf = ">=3.19.0,<5.0.0.dev0" +protobuf = ">=3.19.0,<5.0.0dev" [package.extras] testing = ["google-api-core[grpc] (>=1.31.5)"] @@ -3460,7 +3501,7 @@ files = [ ] [package.dependencies] -astroid = ">=3.2.2,<=3.3.0.dev0" +astroid = ">=3.2.2,<=3.3.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, @@ -3934,14 +3975,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.7.1" +version = "14.3.3" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.8.0" groups = ["main", "dev"] files = [ - {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, - {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, + {file = "rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d"}, + {file = "rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"}, ] [package.dependencies] @@ -5007,4 +5048,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "1d1a0082eddcc6cda0656156b190fcdaa91b9ce380a10371743a895372fa0f89" +content-hash = "0b4409c9c9f0485fc90a0ecfc35812a95d7023c605da05b5b899668d9e5406e0" diff --git a/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/__snapshots__/defaultFieldsConfig.test.ts.snap b/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/__snapshots__/defaultFieldsConfig.test.ts.snap index 9051123d..db8a780b 100644 --- a/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/__snapshots__/defaultFieldsConfig.test.ts.snap +++ b/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/__snapshots__/defaultFieldsConfig.test.ts.snap @@ -5,6 +5,7 @@ Array [ StringFieldDefinition { "dataKey": "name", "defaultValue": undefined, + "encrypt": true, "label": "Name", "name": "name", "questionText": undefined, @@ -18,6 +19,7 @@ Array [ StringFieldDefinition { "dataKey": "contact_email", "defaultValue": undefined, + "encrypt": true, "label": "Contact email", "name": "contactEmail", "questionText": undefined, @@ -31,6 +33,7 @@ Array [ EnumFieldDefinition { "dataKey": "gender", "defaultValue": undefined, + "encrypt": true, "label": "Gender", "name": "gender", "questionText": undefined, @@ -44,6 +47,7 @@ Array [ StringFieldDefinition { "dataKey": "age", "defaultValue": undefined, + "encrypt": true, "label": "Age", "name": "age", "questionText": undefined, @@ -57,6 +61,7 @@ Array [ EnumFieldDefinition { "dataKey": "education_status", "defaultValue": undefined, + "encrypt": true, "label": "Education", "name": "educationStatus", "questionText": "What is the highest level of education you have completed?", @@ -74,6 +79,7 @@ Array [ EnumFieldDefinition { "dataKey": "main_activity", "defaultValue": undefined, + "encrypt": true, "label": "Main activity", "name": "mainActivity", "questionText": "In the last 30 days, what was your main activity in terms of time spent?", @@ -96,6 +102,7 @@ Array [ StringFieldDefinition { "dataKey": "name", "defaultValue": undefined, + "encrypt": true, "label": "Name", "name": "name", "questionText": undefined, @@ -109,6 +116,7 @@ Array [ StringFieldDefinition { "dataKey": "contact_email", "defaultValue": undefined, + "encrypt": true, "label": "Contact email", "name": "contactEmail", "questionText": undefined, @@ -122,6 +130,7 @@ Array [ EnumFieldDefinition { "dataKey": "gender", "defaultValue": undefined, + "encrypt": true, "label": "Gender", "name": "gender", "questionText": undefined, @@ -135,6 +144,7 @@ Array [ StringFieldDefinition { "dataKey": "age", "defaultValue": undefined, + "encrypt": true, "label": "Age", "name": "age", "questionText": undefined, @@ -148,6 +158,7 @@ Array [ EnumFieldDefinition { "dataKey": "education_status", "defaultValue": undefined, + "encrypt": true, "label": "Education", "name": "educationStatus", "questionText": "What is the highest level of education you have completed?", @@ -165,6 +176,7 @@ Array [ EnumFieldDefinition { "dataKey": "main_activity", "defaultValue": undefined, + "encrypt": true, "label": "Main activity", "name": "mainActivity", "questionText": "In the last 30 days, what was your main activity in terms of time spent?", @@ -187,6 +199,7 @@ Array [ StringFieldDefinition { "dataKey": "name", "defaultValue": undefined, + "encrypt": true, "label": "Nombre", "name": "name", "questionText": undefined, @@ -200,6 +213,7 @@ Array [ StringFieldDefinition { "dataKey": "contact_email", "defaultValue": undefined, + "encrypt": true, "label": "Correo electrónico de contacto", "name": "contactEmail", "questionText": undefined, @@ -213,6 +227,7 @@ Array [ EnumFieldDefinition { "dataKey": "gender", "defaultValue": undefined, + "encrypt": true, "label": "Género", "name": "gender", "questionText": undefined, @@ -226,6 +241,7 @@ Array [ StringFieldDefinition { "dataKey": "age", "defaultValue": undefined, + "encrypt": true, "label": "Edad", "name": "age", "questionText": undefined, @@ -239,6 +255,7 @@ Array [ EnumFieldDefinition { "dataKey": "education_status", "defaultValue": undefined, + "encrypt": true, "label": "Educación", "name": "educationStatus", "questionText": "¿Cuál es el nivel educativo más alto que ha completado?", @@ -256,6 +273,7 @@ Array [ EnumFieldDefinition { "dataKey": "main_activity", "defaultValue": undefined, + "encrypt": true, "label": "Actividad principal", "name": "mainActivity", "questionText": "En los últimos 30 días, ¿cuál fue su actividad principal en términos de tiempo dedicado?", @@ -278,6 +296,7 @@ Array [ StringFieldDefinition { "dataKey": "name", "defaultValue": undefined, + "encrypt": true, "label": "Nombre", "name": "name", "questionText": undefined, @@ -291,6 +310,7 @@ Array [ StringFieldDefinition { "dataKey": "contact_email", "defaultValue": undefined, + "encrypt": true, "label": "Correo electrónico de contacto", "name": "contactEmail", "questionText": undefined, @@ -304,6 +324,7 @@ Array [ EnumFieldDefinition { "dataKey": "gender", "defaultValue": undefined, + "encrypt": true, "label": "Género", "name": "gender", "questionText": undefined, @@ -317,6 +338,7 @@ Array [ StringFieldDefinition { "dataKey": "age", "defaultValue": undefined, + "encrypt": true, "label": "Edad", "name": "age", "questionText": undefined, @@ -330,6 +352,7 @@ Array [ EnumFieldDefinition { "dataKey": "education_status", "defaultValue": undefined, + "encrypt": true, "label": "Educación", "name": "educationStatus", "questionText": "¿Cuál es el nivel educativo más alto que ha completado?", @@ -347,6 +370,7 @@ Array [ EnumFieldDefinition { "dataKey": "main_activity", "defaultValue": undefined, + "encrypt": true, "label": "Actividad principal", "name": "mainActivity", "questionText": "En los últimos 30 días, ¿cuál fue su actividad principal en términos de tiempo dedicado?", @@ -363,3 +387,197 @@ Array [ }, ] `; + +exports[`DEFAULT_FIELDS_CONFIG parseFieldsConfig should not throw given the locale is ny-ZM 1`] = ` +Array [ + StringFieldDefinition { + "dataKey": "name", + "defaultValue": undefined, + "encrypt": true, + "label": "Name", + "name": "name", + "questionText": undefined, + "required": true, + "type": "STRING", + "validation": Object { + "errorMessage": "Name should contain only letters and be 2-50 characters long.", + "pattern": "^(?!\\\\.)(?!.*\\\\.\\\\.)(?!.*(\\\\..*){5,})[\\\\p{L}\\\\s\\\\.]{2,50}$", + }, + }, + StringFieldDefinition { + "dataKey": "contact_email", + "defaultValue": undefined, + "encrypt": true, + "label": "Contact email", + "name": "contactEmail", + "questionText": undefined, + "required": true, + "type": "STRING", + "validation": Object { + "errorMessage": "Please enter a valid email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,256}$", + }, + }, + EnumFieldDefinition { + "dataKey": "gender", + "defaultValue": undefined, + "encrypt": true, + "label": "Gender", + "name": "gender", + "questionText": undefined, + "required": true, + "type": "ENUM", + "values": Array [ + "Male", + "Female", + ], + }, + StringFieldDefinition { + "dataKey": "age", + "defaultValue": undefined, + "encrypt": true, + "label": "Age", + "name": "age", + "questionText": undefined, + "required": true, + "type": "STRING", + "validation": Object { + "errorMessage": "Please enter a valid age. You must be at 16 years old or older to participate.", + "pattern": "^(?:1[6-9]|[2-9][0-9]|1[01][0-9]|120)$", + }, + }, + EnumFieldDefinition { + "dataKey": "education_status", + "defaultValue": undefined, + "encrypt": true, + "label": "Education", + "name": "educationStatus", + "questionText": "What is the highest level of education you have completed?", + "required": true, + "type": "ENUM", + "values": Array [ + "Less than primary / no formal education", + "Primary", + "Secondary", + "College / Diploma", + "University degree", + "Postgraduate degree", + ], + }, + EnumFieldDefinition { + "dataKey": "main_activity", + "defaultValue": undefined, + "encrypt": true, + "label": "Main activity", + "name": "mainActivity", + "questionText": "In the last 30 days, what was your main activity in terms of time spent?", + "required": true, + "type": "ENUM", + "values": Array [ + "Worked for wages", + "Worked for my own account (trader, shopkeeper, barber, dressmaker)", + "Worked as a volunteer", + "Worked as intern or apprentice", + "In school, university, or training", + "Unemployed", + ], + }, +] +`; + +exports[`DEFAULT_FIELDS_CONFIG parseFieldsConfig should not throw given the locale is sw-KE 1`] = ` +Array [ + StringFieldDefinition { + "dataKey": "name", + "defaultValue": undefined, + "encrypt": true, + "label": "Name", + "name": "name", + "questionText": undefined, + "required": true, + "type": "STRING", + "validation": Object { + "errorMessage": "Name should contain only letters and be 2-50 characters long.", + "pattern": "^(?!\\\\.)(?!.*\\\\.\\\\.)(?!.*(\\\\..*){5,})[\\\\p{L}\\\\s\\\\.]{2,50}$", + }, + }, + StringFieldDefinition { + "dataKey": "contact_email", + "defaultValue": undefined, + "encrypt": true, + "label": "Contact email", + "name": "contactEmail", + "questionText": undefined, + "required": true, + "type": "STRING", + "validation": Object { + "errorMessage": "Please enter a valid email address.", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,256}$", + }, + }, + EnumFieldDefinition { + "dataKey": "gender", + "defaultValue": undefined, + "encrypt": true, + "label": "Gender", + "name": "gender", + "questionText": undefined, + "required": true, + "type": "ENUM", + "values": Array [ + "Male", + "Female", + ], + }, + StringFieldDefinition { + "dataKey": "age", + "defaultValue": undefined, + "encrypt": true, + "label": "Age", + "name": "age", + "questionText": undefined, + "required": true, + "type": "STRING", + "validation": Object { + "errorMessage": "Please enter a valid age. You must be at 16 years old or older to participate.", + "pattern": "^(?:1[6-9]|[2-9][0-9]|1[01][0-9]|120)$", + }, + }, + EnumFieldDefinition { + "dataKey": "education_status", + "defaultValue": undefined, + "encrypt": true, + "label": "Education", + "name": "educationStatus", + "questionText": "What is the highest level of education you have completed?", + "required": true, + "type": "ENUM", + "values": Array [ + "Less than primary / no formal education", + "Primary", + "Secondary", + "College / Diploma", + "University degree", + "Postgraduate degree", + ], + }, + EnumFieldDefinition { + "dataKey": "main_activity", + "defaultValue": undefined, + "encrypt": true, + "label": "Main activity", + "name": "mainActivity", + "questionText": "In the last 30 days, what was your main activity in terms of time spent?", + "required": true, + "type": "ENUM", + "values": Array [ + "Worked for wages", + "Worked for my own account (trader, shopkeeper, barber, dressmaker)", + "Worked as a volunteer", + "Worked as intern or apprentice", + "In school, university, or training", + "Unemployed", + ], + }, +] +`; diff --git a/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/defaultFieldsConfig.ts b/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/defaultFieldsConfig.ts index 0cd75796..b638ecfd 100644 --- a/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/defaultFieldsConfig.ts +++ b/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/defaultFieldsConfig.ts @@ -8,6 +8,7 @@ import { FieldType } from "./types"; export type RawFieldConfig = { dataKey: string; required: boolean; + encrypt?: boolean; // defaults to true if absent; set to false for plain (unencrypted) fields label: Record; questionText?: Record; validation?: { diff --git a/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/types.ts b/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/types.ts index 4abb375f..0b1d5040 100644 --- a/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/types.ts +++ b/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/types.ts @@ -13,6 +13,7 @@ export class BaseFieldDefinition { dataKey: string; type: FieldType; required: boolean; + encrypt?: boolean; // true by default; false means the field is sent as plaintext label: string; questionText?: string; // Optional extended text displayed above the field constructor(attributes: any) { @@ -45,11 +46,16 @@ export class BaseFieldDefinition { if (attributes.questionText && typeof attributes.questionText !== "string") { throw new ConfigurationError("SensitiveData: Field 'questionText' must be a string"); } + // if encrypt is provided, it must be a boolean + if (attributes.encrypt !== undefined && typeof attributes.encrypt !== "boolean") { + throw new ConfigurationError("SensitiveData: Field 'encrypt' must be a boolean"); + } this.name = attributes.name; this.dataKey = attributes.dataKey; this.type = attributes.type; this.required = attributes.required; + this.encrypt = attributes.encrypt !== false; // default to true this.label = attributes.label; this.questionText = attributes.questionText; } diff --git a/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/utils.test.ts b/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/utils.test.ts index 608120c7..5bd31542 100644 --- a/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/utils.test.ts +++ b/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/utils.test.ts @@ -1,6 +1,6 @@ import "src/_test_utilities/consoleMock"; import { FieldType, FieldDefinition } from "./types"; -import { toEncryptionPayload, createEmptySensitivePersonalData, extractPersonalInfo } from "./utils"; +import { toEncryptionPayload, toPlainPayload, createEmptySensitivePersonalData, extractPersonalInfo } from "./utils"; import { SensitivePersonalData } from "src/sensitiveData/types"; describe("Config Utilities", () => { @@ -88,6 +88,7 @@ describe("Config Utilities", () => { type: FieldType.String, label: "First Name", required: true, + encrypt: true, }, { name: "lastName", @@ -95,6 +96,7 @@ describe("Config Utilities", () => { type: FieldType.String, label: "Last Name", required: true, + encrypt: true, }, { name: "dateOfBirth", @@ -102,6 +104,7 @@ describe("Config Utilities", () => { type: FieldType.String, label: "Date of Birth", required: false, + encrypt: true, }, ]; @@ -126,6 +129,119 @@ describe("Config Utilities", () => { expect(result).not.toHaveProperty("lastName"); expect(result).not.toHaveProperty("dateOfBirth"); }); + + test("should exclude fields with encrypt === false", () => { + // GIVEN a mixed set of fields where one has encrypt === false + const mixedFields: FieldDefinition[] = [ + { + name: "firstName", + dataKey: "first_name", + type: FieldType.String, + label: "First Name", + required: true, + encrypt: true, + }, + { + name: "age", + dataKey: "age", + type: FieldType.String, + label: "Age", + required: true, + encrypt: false, + }, + ]; + const data: SensitivePersonalData = { firstName: "John", age: "30" }; + + // WHEN toEncryptionPayload is called + const result = toEncryptionPayload(data, mixedFields); + + // THEN only encrypted fields are included + expect(result).toEqual({ first_name: "John" }); + expect(result).not.toHaveProperty("age"); + }); + }); + + describe("toPlainPayload", () => { + test("should return only fields with encrypt === false", () => { + // GIVEN a mixed set of fields + const mixedFields: FieldDefinition[] = [ + { + name: "firstName", + dataKey: "first_name", + type: FieldType.String, + label: "First Name", + required: true, + encrypt: true, + }, + { + name: "age", + dataKey: "age", + type: FieldType.String, + label: "Age", + required: true, + encrypt: false, + }, + { + name: "gender", + dataKey: "gender_type", + type: FieldType.Enum, + label: "Gender", + required: true, + values: ["Male", "Female"], + encrypt: false, + }, + ]; + const data: SensitivePersonalData = { firstName: "John", age: "30", gender: "Male" }; + + // WHEN toPlainPayload is called + const result = toPlainPayload(data, mixedFields); + + // THEN only plain (encrypt === false) fields are included + expect(result).toEqual({ age: "30", gender_type: "Male" }); + expect(result).not.toHaveProperty("first_name"); + }); + + test("should return an empty object when all fields are encrypted", () => { + // GIVEN fields that are all encrypted (encrypt defaults to true) + const encryptedFields: FieldDefinition[] = [ + { + name: "firstName", + dataKey: "first_name", + type: FieldType.String, + label: "First Name", + required: true, + encrypt: true, + }, + ]; + const data: SensitivePersonalData = { firstName: "John" }; + + // WHEN toPlainPayload is called + const result = toPlainPayload(data, encryptedFields); + + // THEN the result is empty + expect(result).toEqual({}); + }); + + test("should only include defined values in the plain payload", () => { + // GIVEN a plain field whose value is undefined in data + const plainFields: FieldDefinition[] = [ + { + name: "age", + dataKey: "age", + type: FieldType.String, + label: "Age", + required: false, + encrypt: false, + }, + ]; + const data: SensitivePersonalData = {}; + + // WHEN toPlainPayload is called + const result = toPlainPayload(data, plainFields); + + // THEN the result is empty because the value is undefined + expect(result).toEqual({}); + }); }); describe("createEmptySensitivePersonalData", () => { diff --git a/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/utils.ts b/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/utils.ts index aaf0784f..5aa79988 100644 --- a/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/utils.ts +++ b/frontend-new/src/sensitiveData/components/sensitiveDataForm/config/utils.ts @@ -7,10 +7,11 @@ import { /** * Converts sensitive personal data to an encryption payload. + * Only includes fields where encrypt !== false (i.e. encrypted fields). * * @param data - The sensitive personal data object * @param fields - The field definitions that describe the data structure - * @returns An encryption payload with the sensitive data + * @returns An encryption payload with only the encrypted fields */ export const toEncryptionPayload = ( data: SensitivePersonalData, @@ -19,7 +20,7 @@ export const toEncryptionPayload = ( const result: SensitivePersonalDataEncryptionPayload = {}; fields.forEach((field) => { - if (data[field.name] !== undefined) { + if (field.encrypt !== false && data[field.name] !== undefined) { result[field.dataKey] = data[field.name]; } }); @@ -27,6 +28,29 @@ export const toEncryptionPayload = ( return result; }; +/** + * Converts sensitive personal data to a plain (unencrypted) payload. + * Only includes fields where encrypt === false. + * + * @param data - The sensitive personal data object + * @param fields - The field definitions that describe the data structure + * @returns A plain payload containing only unencrypted fields + */ +export const toPlainPayload = ( + data: SensitivePersonalData, + fields: FieldDefinition[] +): Record => { + const result: Record = {}; + + fields.forEach((field) => { + if (field.encrypt === false && data[field.name] !== undefined) { + result[field.dataKey] = data[field.name] as string | string[]; + } + }); + + return result; +}; + /** * Creates an empty sensitive personal data object with default values for each field. * diff --git a/frontend-new/src/sensitiveData/services/sensitivePersonalDataService/sensitivePersonalData.service.test.ts b/frontend-new/src/sensitiveData/services/sensitivePersonalDataService/sensitivePersonalData.service.test.ts index 759041c1..738970fa 100644 --- a/frontend-new/src/sensitiveData/services/sensitivePersonalDataService/sensitivePersonalData.service.test.ts +++ b/frontend-new/src/sensitiveData/services/sensitivePersonalDataService/sensitivePersonalData.service.test.ts @@ -13,6 +13,7 @@ import { EncryptionService } from "src/sensitiveData/services/encryptionService/ import { EncryptedDataTooLarge } from "./errors"; import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import { FieldDefinition, FieldType } from "src/sensitiveData/components/sensitiveDataForm/config/types"; +import { StatusCodes } from "http-status-codes"; // Define gender values as constants to match the mockConfig const Gender = { @@ -198,6 +199,114 @@ describe("SensitivePersonalDataService", () => { } ); + test("should call both endpoints when form has mixed encrypted and plain fields", async () => { + // GIVEN a mix of encrypted and plain fields + const mixedConfig: FieldDefinition[] = [ + { + name: "firstName", + dataKey: "first_name", + type: FieldType.String, + required: true, + label: "First name", + encrypt: true, + }, + { + name: "age", + dataKey: "age", + type: FieldType.String, + required: true, + label: "Age", + encrypt: false, + }, + ]; + + // AND sample data + const givenSensitivePersonalData = { + firstName: "John", + age: "30", + }; + const givenUserId = getRandomLorem(10); + + // AND encryption returns a valid response + const givenEncryptReturnValue = { + rsa_key_id: "given_key_id", + aes_encrypted_data: "given_encrypted_data", + aes_encryption_key: "given_encryption_key", + }; + jest + .spyOn(EncryptionService.prototype, "encryptSensitivePersonalData") + .mockResolvedValue(givenEncryptReturnValue); + + const customFetch = jest.spyOn(CustomFetchModule, "customFetch").mockResolvedValue(new Response()); + + // WHEN createSensitivePersonalData is called + await sensitivePersonalDataService.createSensitivePersonalData( + givenSensitivePersonalData, + givenUserId, + mixedConfig + ); + + // THEN customFetch is called twice: once for plain and once for encrypted + expect(customFetch).toHaveBeenCalledTimes(2); + + // AND the first call is to the plain-personal-data endpoint + expect(customFetch).toHaveBeenCalledWith( + `/users/${givenUserId}/plain-personal-data`, + expect.objectContaining({ + method: "POST", + expectedStatusCode: [StatusCodes.OK], + body: JSON.stringify({ data: { age: "30" } }), + }) + ); + + // AND the second call is to the sensitive-personal-data endpoint (with encrypted data only) + expect(customFetch).toHaveBeenCalledWith( + `/users/${givenUserId}/sensitive-personal-data`, + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ sensitive_personal_data: givenEncryptReturnValue }), + }) + ); + }); + + test("should only call the encrypted endpoint when all fields are encrypted", async () => { + // GIVEN all-encrypted config (the default mockConfig) + const givenSensitivePersonalData = { + contactEmail: "contact_email", + firstName: "first_name", + lastName: "last_name", + phoneNumber: "phone_number", + address: "address", + gender: Gender.PREFER_NOT_TO_SAY, + }; + const givenUserId = getRandomLorem(10); + + const givenEncryptReturnValue = { + rsa_key_id: "given_key_id", + aes_encrypted_data: "given_encrypted_data", + aes_encryption_key: "given_encryption_key", + }; + jest + .spyOn(EncryptionService.prototype, "encryptSensitivePersonalData") + .mockResolvedValue(givenEncryptReturnValue); + + const customFetch = jest.spyOn(CustomFetchModule, "customFetch").mockResolvedValue(new Response()); + + // WHEN createSensitivePersonalData is called with all-encrypted config + await sensitivePersonalDataService.createSensitivePersonalData( + givenSensitivePersonalData, + givenUserId, + mockConfig // all fields default to encrypt: true (no encrypt: false) + ); + + // THEN only the encrypted endpoint is called + expect(customFetch).toHaveBeenCalledTimes(1); + expect(customFetch).toHaveBeenCalledWith( + `/users/${givenUserId}/sensitive-personal-data`, + expect.objectContaining({ method: "POST" }) + ); + }); + test("should throw an error if the encrypted data is too large", async () => { // GIVEN the encryption service returns data that is too large const givenEncryptReturnValue = { diff --git a/frontend-new/src/sensitiveData/services/sensitivePersonalDataService/sensitivePersonalData.service.ts b/frontend-new/src/sensitiveData/services/sensitivePersonalDataService/sensitivePersonalData.service.ts index 18d118d6..40cc8ece 100644 --- a/frontend-new/src/sensitiveData/services/sensitivePersonalDataService/sensitivePersonalData.service.ts +++ b/frontend-new/src/sensitiveData/services/sensitivePersonalDataService/sensitivePersonalData.service.ts @@ -12,7 +12,7 @@ import { import { EncryptedDataTooLarge } from "./errors"; import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import { FieldDefinition } from "src/sensitiveData/components/sensitiveDataForm/config/types"; -import { toEncryptionPayload } from "../../components/sensitiveDataForm/config/utils"; +import { toEncryptionPayload, toPlainPayload } from "../../components/sensitiveDataForm/config/utils"; export class SensitivePersonalDataSkipError extends Error { constructor( @@ -26,14 +26,18 @@ export class SensitivePersonalDataSkipError extends Error { class SensitivePersonalDataService { private readonly sensitivePersonalDataBaseUrl: string; + private readonly plainPersonalDataBaseUrl: string; private readonly encryptionService = new EncryptionService(); constructor() { this.sensitivePersonalDataBaseUrl = `${getBackendUrl()}/users/{user_id}/sensitive-personal-data`; + this.plainPersonalDataBaseUrl = `${getBackendUrl()}/users/{user_id}/plain-personal-data`; } /** * Creates sensitive personal data for a user. + * Encrypted fields are sent to the sensitive-personal-data endpoint. + * Plain (unencrypted) fields are sent to the plain-personal-data endpoint. * * @param personal_data - The sensitive personal data to save * @param user_id - The ID of the user @@ -59,40 +63,64 @@ class SensitivePersonalDataService { contactEmail: personal_data["contactEmail"] as string, }); - // Convert frontend model to backend request model - const payload = toEncryptionPayload(personal_data, fields); + // Determine which fields are encrypted vs plain + const encryptedFields = fields.filter((f) => f.encrypt !== false); + const plainFields = fields.filter((f) => f.encrypt === false); - const encryptSensitivePersonalData = await this.encryptionService.encryptSensitivePersonalData(payload); - - // if for some reason the data to encrypt is too large, we should not proceed. - // the backend will reject the request if the data is too large. - if ( - encryptSensitivePersonalData.aes_encrypted_data.length > MaximumAESEncryptedDataSize || - encryptSensitivePersonalData.aes_encryption_key.length > MaximumAESEncryptedKeySize || - encryptSensitivePersonalData.rsa_key_id.length > MaximumRSAKeyIdSize - ) { - throw new EncryptedDataTooLarge(encryptSensitivePersonalData); + // --- Send plain (unencrypted) fields --- + if (plainFields.length > 0) { + const plainData = toPlainPayload(personal_data, plainFields); + await customFetch(this.plainPersonalDataBaseUrl.replace("{user_id}", user_id), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + // 200 is returned on upsert (create or update) + expectedStatusCode: [StatusCodes.OK], + serviceName: "SensitivePersonalData", + serviceFunction: "createSensitivePersonalData", + failureMessage: `Failed to create plain personal data for user with id ${user_id}`, + body: JSON.stringify({ data: plainData }), + expectedContentType: "application/json", + }); } - const response = await customFetch(this.sensitivePersonalDataBaseUrl.replace("{user_id}", user_id), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - // 409 is returned if the user has already provided sensitive data - // This may happen if the user has already provided sensitive data but - // the frontend failed to process the response and the user tries to provide it again - expectedStatusCode: [StatusCodes.CREATED, StatusCodes.CONFLICT], - serviceName: "SensitivePersonalData", - serviceFunction: "createSensitivePersonalData", - failureMessage: `Failed to create sensitive personal data for user with id ${user_id}`, - body: JSON.stringify({ - sensitive_personal_data: encryptSensitivePersonalData, - }), - expectedContentType: "application/json", - }); - if (response.status === StatusCodes.CONFLICT) { - console.warn(`User with id ${user_id} has already provided sensitive personal data`); + // --- Send encrypted fields --- + if (encryptedFields.length > 0) { + const payload = toEncryptionPayload(personal_data, encryptedFields); + + const encryptSensitivePersonalData = await this.encryptionService.encryptSensitivePersonalData(payload); + + // if for some reason the data to encrypt is too large, we should not proceed. + // the backend will reject the request if the data is too large. + if ( + encryptSensitivePersonalData.aes_encrypted_data.length > MaximumAESEncryptedDataSize || + encryptSensitivePersonalData.aes_encryption_key.length > MaximumAESEncryptedKeySize || + encryptSensitivePersonalData.rsa_key_id.length > MaximumRSAKeyIdSize + ) { + throw new EncryptedDataTooLarge(encryptSensitivePersonalData); + } + + const response = await customFetch(this.sensitivePersonalDataBaseUrl.replace("{user_id}", user_id), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + // 409 is returned if the user has already provided sensitive data + // This may happen if the user has already provided sensitive data but + // the frontend failed to process the response and the user tries to provide it again + expectedStatusCode: [StatusCodes.CREATED, StatusCodes.CONFLICT], + serviceName: "SensitivePersonalData", + serviceFunction: "createSensitivePersonalData", + failureMessage: `Failed to create sensitive personal data for user with id ${user_id}`, + body: JSON.stringify({ + sensitive_personal_data: encryptSensitivePersonalData, + }), + expectedContentType: "application/json", + }); + if (response.status === StatusCodes.CONFLICT) { + console.warn(`User with id ${user_id} has already provided sensitive personal data`); + } } } From 166950a01c4b3d86ff6366ac886af140e7747a89 Mon Sep 17 00:00:00 2001 From: nraffa Date: Mon, 2 Mar 2026 14:40:48 -0300 Subject: [PATCH 06/42] feat(career-readiness): implement career readiness module with service, repository and routes --- backend/app/agent/agent_types.py | 1 + backend/app/career_readiness/agent.py | 156 ++++++ backend/app/career_readiness/errors.py | 38 ++ backend/app/career_readiness/module_loader.py | 140 +++++ .../career_readiness/modules/cover_letter.md | 68 +++ .../modules/cv_development.md | 73 +++ .../modules/interview_preparation.md | 87 +++ .../modules/professional_identity.md | 61 +++ .../modules/workplace_readiness.md | 89 +++ backend/app/career_readiness/repository.py | 98 ++++ backend/app/career_readiness/routes.py | 135 +++-- backend/app/career_readiness/service.py | 325 +++++++++++ backend/app/career_readiness/test_agent.py | 210 ++++++++ .../career_readiness/test_module_loader.py | 218 ++++++++ .../app/career_readiness/test_repository.py | 245 +++++++++ backend/app/career_readiness/test_routes.py | 374 +++++++++++++ backend/app/career_readiness/test_service.py | 507 ++++++++++++++++++ backend/app/career_readiness/types.py | 75 ++- .../database_collections.py | 1 + .../server_dependencies/db_dependencies.py | 9 + backend/poetry.lock | 2 +- 21 files changed, 2863 insertions(+), 49 deletions(-) create mode 100644 backend/app/career_readiness/agent.py create mode 100644 backend/app/career_readiness/errors.py create mode 100644 backend/app/career_readiness/module_loader.py create mode 100644 backend/app/career_readiness/modules/cover_letter.md create mode 100644 backend/app/career_readiness/modules/cv_development.md create mode 100644 backend/app/career_readiness/modules/interview_preparation.md create mode 100644 backend/app/career_readiness/modules/professional_identity.md create mode 100644 backend/app/career_readiness/modules/workplace_readiness.md create mode 100644 backend/app/career_readiness/repository.py create mode 100644 backend/app/career_readiness/service.py create mode 100644 backend/app/career_readiness/test_agent.py create mode 100644 backend/app/career_readiness/test_module_loader.py create mode 100644 backend/app/career_readiness/test_repository.py create mode 100644 backend/app/career_readiness/test_routes.py create mode 100644 backend/app/career_readiness/test_service.py diff --git a/backend/app/agent/agent_types.py b/backend/app/agent/agent_types.py index 7378b53d..9340f3b8 100644 --- a/backend/app/agent/agent_types.py +++ b/backend/app/agent/agent_types.py @@ -19,6 +19,7 @@ class AgentType(Enum): RECOMMENDER_ADVISOR_AGENT = "RecommenderAdvisorAgent" FAREWELL_AGENT = "FarewellAgent" QNA_AGENT = "QnaAgent" + CAREER_READINESS_AGENT = "CareerReadinessAgent" class AgentInput(BaseModel): diff --git a/backend/app/career_readiness/agent.py b/backend/app/career_readiness/agent.py new file mode 100644 index 00000000..18800f66 --- /dev/null +++ b/backend/app/career_readiness/agent.py @@ -0,0 +1,156 @@ +""" +Career readiness agent that chats with users grounded in module content. + +Uses composition to wrap a SimpleLLMAgent — this agent is standalone and is +NOT part of the AgentDirector pipeline. +""" +import logging +import time +from textwrap import dedent + +from app.agent.agent_types import AgentInput, AgentOutput, AgentType, LLMStats, AgentOutputWithReasoning +from app.agent.llm_caller import LLMCaller +from app.agent.prompt_template.locale_style import get_language_style +from app.agent.simple_llm_agent.llm_response import ModelResponse +from app.agent.simple_llm_agent.prompt_response_template import ( + get_json_response_instructions, + get_conversation_finish_instructions, +) +from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter +from app.conversation_memory.conversation_memory_types import ConversationContext +from common_libs.llm.generative_models import GeminiGenerativeLLM +from common_libs.llm.models_utils import LLMConfig, LOW_TEMPERATURE_GENERATION_CONFIG, JSON_GENERATION_CONFIG +from common_libs.llm.schema_builder import with_response_schema + + +def _build_system_instructions(module_title: str, module_content: str) -> str: + """Build the system instructions for the career readiness agent.""" + response_part = get_json_response_instructions() + finish_instructions = get_conversation_finish_instructions( + dedent("""Once the user explicitly indicates they are done or have no more questions about this topic""") + ) + language_style = get_language_style(with_locale=False, for_json_output=True) + + template = dedent("""\ + You are a career readiness coach specialising in "{module_title}". + Your role is to help the user develop practical skills and knowledge related to this topic. + + # Grounding Content + Use the following content as your primary knowledge base for this conversation. + Always ground your responses in this material. Do not invent facts or techniques + that are not supported by the content below. + + {module_content} + + # Instructions + - Guide the user step by step through the topic. + - Be encouraging, supportive, and practical. + - Provide concrete examples and actionable advice grounded in the content above. + - Ask follow-up questions to understand the user's situation and tailor your guidance. + - Keep responses concise and focused (under 200 words per message). + - Do not make things up. If the user asks something outside the scope of the grounding content, + politely redirect them to the topics you can help with. + - Do not format or style your response with markdown. + + {language_style} + + {response_part} + + {finish_instructions} + """) + + return template.format( + module_title=module_title, + module_content=module_content, + language_style=language_style, + response_part=response_part, + finish_instructions=finish_instructions, + ) + + +class CareerReadinessAgent: + """ + AI agent that coaches users on a specific career readiness module. + + Uses composition — wraps a GeminiGenerativeLLM + LLMCaller rather than + inheriting from Agent/SimpleLLMAgent, because this agent operates outside + the AgentDirector pipeline. + """ + + def __init__(self, module_title: str, module_content: str): + self._logger = logging.getLogger(CareerReadinessAgent.__name__) + self._system_instructions = _build_system_instructions(module_title, module_content) + config = LLMConfig( + generation_config=LOW_TEMPERATURE_GENERATION_CONFIG | JSON_GENERATION_CONFIG | with_response_schema( + ModelResponse) + ) + self._llm = GeminiGenerativeLLM(system_instructions=self._system_instructions, config=config) + self._llm_caller: LLMCaller[ModelResponse] = LLMCaller[ModelResponse](model_response_type=ModelResponse) + + @property + def system_instructions(self) -> str: + return self._system_instructions + + async def execute(self, user_input: AgentInput, context: ConversationContext) -> AgentOutput: + """ + Process user input and return the agent's response. + + :param user_input: The user's message + :param context: The conversation context with history + :return: The agent's output + """ + agent_start_time = time.time() + + msg = user_input.message.strip() + if msg == "": + msg = "(silence)" + + model_response: ModelResponse | None + llm_stats_list: list[LLMStats] + + try: + model_response, llm_stats_list = await self._llm_caller.call_llm( + llm=self._llm, + llm_input=ConversationHistoryFormatter.format_for_agent_generative_prompt( + model_response_instructions=get_json_response_instructions(), + context=context, + user_input=msg, + ), + logger=self._logger, + ) + except Exception as e: + self._logger.exception("An error occurred while calling the LLM: %s", e) + model_response = None + llm_stats_list = [] + + if model_response is None: + model_response = ModelResponse( + reasoning="Failed to get a response", + message="I am facing some difficulties right now, could you please repeat what you said?", + finished=False, + ) + + agent_end_time = time.time() + return AgentOutputWithReasoning( + message_for_user=model_response.message.strip('"'), + finished=model_response.finished, + reasoning=model_response.reasoning, + agent_type=AgentType.CAREER_READINESS_AGENT, + agent_response_time_in_sec=round(agent_end_time - agent_start_time, 2), + llm_stats=llm_stats_list, + ) + + async def generate_intro_message(self, context: ConversationContext) -> AgentOutput: + """ + Generate the introductory message for a new conversation. + + Sends an artificial "(silence)" input to trigger the agent's greeting. + + :param context: An empty conversation context + :return: The agent's introductory output + """ + artificial_input = AgentInput( + message="(silence)", + is_artificial=True, + ) + return await self.execute(artificial_input, context) diff --git a/backend/app/career_readiness/errors.py b/backend/app/career_readiness/errors.py new file mode 100644 index 00000000..771850ea --- /dev/null +++ b/backend/app/career_readiness/errors.py @@ -0,0 +1,38 @@ +""" +Domain-specific exceptions for the career readiness module. +""" + + +class CareerReadinessModuleNotFoundError(Exception): + """Raised when a career readiness module is not found.""" + + def __init__(self, module_id: str): + super().__init__(f"Career readiness module not found: {module_id}") + + +class ConversationNotFoundError(Exception): + """Raised when a career readiness conversation is not found.""" + + def __init__(self, conversation_id: str): + super().__init__(f"Career readiness conversation not found: {conversation_id}") + + +class ConversationAlreadyExistsError(Exception): + """Raised when a conversation already exists for a module and user.""" + + def __init__(self, module_id: str, user_id: str): + super().__init__(f"A conversation already exists for module {module_id} and user {user_id}") + + +class ConversationAccessDeniedError(Exception): + """Raised when a user attempts to access a conversation they do not own.""" + + def __init__(self, conversation_id: str, user_id: str): + super().__init__(f"User {user_id} does not have access to conversation {conversation_id}") + + +class ConversationModuleMismatchError(Exception): + """Raised when a conversation does not belong to the specified module.""" + + def __init__(self, conversation_id: str, module_id: str): + super().__init__(f"Conversation {conversation_id} does not belong to module {module_id}") diff --git a/backend/app/career_readiness/module_loader.py b/backend/app/career_readiness/module_loader.py new file mode 100644 index 00000000..6a6c2acc --- /dev/null +++ b/backend/app/career_readiness/module_loader.py @@ -0,0 +1,140 @@ +""" +This module loads career readiness module definitions from markdown files with frontmatter. +""" +import logging +from pathlib import Path + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +_MODULES_DIR = Path(__file__).parent / "modules" + + +class ModuleConfig(BaseModel): + """ + Represents a career readiness module definition loaded from a markdown file. + """ + + id: str + """The unique identifier (slug) of the module""" + + title: str + """The display title of the module""" + + description: str + """A short description of what the module covers""" + + icon: str + """Icon identifier for the module""" + + sort_order: int + """Display order of the module in the list""" + + input_placeholder: str + """Placeholder text shown in the chat input for this module""" + + content: str + """The markdown body content used as grounding for the agent""" + + class Config: + extra = "forbid" + + +def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]: + """ + Parse a markdown file with ---delimited frontmatter. + Returns a tuple of (frontmatter dict, markdown body). + """ + if not text.startswith("---"): + raise ValueError("Markdown file must start with --- frontmatter delimiter") + + # Find the closing --- delimiter + end_index = text.index("---", 3) + frontmatter_text = text[3:end_index].strip() + body = text[end_index + 3:].strip() + + # Parse key: value pairs + metadata = {} + for line in frontmatter_text.splitlines(): + line = line.strip() + if not line: + continue + if ":" not in line: + raise ValueError(f"Invalid frontmatter line (missing colon): {line}") + key, value = line.split(":", 1) + metadata[key.strip()] = value.strip() + + return metadata, body + + +def _load_module_from_file(file_path: Path) -> ModuleConfig: + """ + Load a single module configuration from a markdown file. + """ + text = file_path.read_text(encoding="utf-8") + metadata, body = _parse_frontmatter(text) + + return ModuleConfig( + id=metadata["id"], + title=metadata["title"], + description=metadata["description"], + icon=metadata["icon"], + sort_order=int(metadata["sort_order"]), + input_placeholder=metadata["input_placeholder"], + content=body, + ) + + +class ModuleRegistry: + """ + Registry of all available career readiness modules. + Loads modules from markdown files in the modules directory. + """ + + def __init__(self, modules_dir: Path = _MODULES_DIR): + self._modules: dict[str, ModuleConfig] = {} + self._load_modules(modules_dir) + + def _load_modules(self, modules_dir: Path) -> None: + """ + Load all markdown files from the modules directory. + """ + if not modules_dir.exists(): + logger.warning("Modules directory does not exist: %s", modules_dir) + return + + for file_path in sorted(modules_dir.glob("*.md")): + try: + module = _load_module_from_file(file_path) + self._modules[module.id] = module + logger.info("Loaded career readiness module: %s", module.id) + except Exception as e: + logger.error("Failed to load module from %s: %s", file_path, e) + raise + + def get_all_modules(self) -> list[ModuleConfig]: + """ + Get all modules sorted by sort_order. + """ + return sorted(self._modules.values(), key=lambda m: m.sort_order) + + def get_module(self, module_id: str) -> ModuleConfig | None: + """ + Get a specific module by its ID. Returns None if not found. + """ + return self._modules.get(module_id) + + +# Module-level singleton +_registry: ModuleRegistry | None = None + + +def get_module_registry() -> ModuleRegistry: + """ + Get the singleton module registry instance. + """ + global _registry + if _registry is None: + _registry = ModuleRegistry() + return _registry diff --git a/backend/app/career_readiness/modules/cover_letter.md b/backend/app/career_readiness/modules/cover_letter.md new file mode 100644 index 00000000..efecc3c4 --- /dev/null +++ b/backend/app/career_readiness/modules/cover_letter.md @@ -0,0 +1,68 @@ +--- +id: cover-letter +title: Cover Letter & Motivation Statement +description: Learn to write compelling cover letters and motivation statements tailored to specific roles. +icon: letter +sort_order: 3 +input_placeholder: Ask about cover letters... +--- + +# Cover Letter & Motivation Statement + +## Overview + +This module teaches you how to write effective cover letters and motivation statements that complement your CV and persuade employers to invite you for an interview. + +## What is a Cover Letter? + +A cover letter is a one-page document that accompanies your CV. It explains why you are applying for a specific role and why you are a strong candidate. + +## Cover Letter Structure + +### 1. Header +- Your contact information +- Date +- Employer's contact information + +### 2. Opening Paragraph +- State the position you are applying for +- Mention how you learned about the opportunity +- Include a compelling hook that grabs attention + +### 3. Body Paragraphs (1-2) +- Highlight your most relevant skills and experience +- Provide specific examples that demonstrate your qualifications +- Show that you understand the company and the role +- Explain how your background aligns with the job requirements + +### 4. Closing Paragraph +- Restate your interest in the position +- Thank the reader for their consideration +- Include a call to action (e.g., "I look forward to discussing this opportunity") + +## Motivation Statement vs Cover Letter + +A **motivation statement** is similar but focuses more on: +- Your personal motivation for applying +- Your long-term career goals +- Why this specific organization or program appeals to you +- What you hope to contribute and gain + +Motivation statements are commonly used for academic programs, scholarships, and NGO positions. + +## Writing Tips + +- **Research the employer** — reference their mission, values, or recent projects +- **Be specific** — use concrete examples, not generic claims +- **Match keywords** from the job description +- **Show enthusiasm** without being excessive +- **Keep it to one page** — concise and focused +- **Address it to a specific person** when possible (avoid "To Whom It May Concern") + +## Common Mistakes to Avoid + +- Repeating your CV word for word +- Using a generic template without customization +- Focusing only on what you want (instead of what you offer) +- Forgetting to proofread +- Not following the application instructions diff --git a/backend/app/career_readiness/modules/cv_development.md b/backend/app/career_readiness/modules/cv_development.md new file mode 100644 index 00000000..a4f221a2 --- /dev/null +++ b/backend/app/career_readiness/modules/cv_development.md @@ -0,0 +1,73 @@ +--- +id: cv-development +title: CV Development +description: Learn to build and tailor a professional CV that highlights your skills and experience for different employers. +icon: cv +sort_order: 2 +input_placeholder: Ask about CVs... +--- + +# CV Development + +## Overview + +This module guides you through creating a professional CV (Curriculum Vitae) that effectively showcases your qualifications to potential employers. + +## What is a CV? + +A CV is a document that summarizes your education, work experience, skills, and achievements. It is your primary tool for making a first impression with employers. + +## CV Structure + +### 1. Contact Information +- Full name +- Phone number +- Email address +- Location (city, country) + +### 2. Personal Statement / Professional Summary +A brief paragraph (3-4 sentences) that summarizes: +- Who you are professionally +- Your key skills and experience +- What you are looking for + +### 3. Work Experience +For each position, include: +- Job title +- Company name +- Dates of employment +- Key responsibilities and achievements (use bullet points) +- Quantify results where possible (e.g., "Increased sales by 20%") + +### 4. Education +- Degree or qualification +- Institution name +- Dates attended +- Relevant coursework or achievements + +### 5. Skills +- Group skills by category (technical, language, etc.) +- Include proficiency levels where appropriate + +### 6. Additional Sections (optional) +- Certifications +- Volunteer experience +- Languages +- References + +## CV Writing Tips + +- **Tailor your CV** for each application by emphasizing relevant experience +- **Use action verbs** to describe achievements (managed, developed, implemented) +- **Keep it concise** — ideally 1-2 pages +- **Use consistent formatting** — same fonts, spacing, and bullet style throughout +- **Proofread carefully** — spelling and grammar errors create a poor impression +- **Avoid personal information** that is not relevant (age, marital status, photo — unless required) + +## Common Mistakes to Avoid + +- Including irrelevant work experience +- Using vague descriptions instead of specific achievements +- Having gaps in employment without explanation +- Using an unprofessional email address +- Making the CV too long or too short diff --git a/backend/app/career_readiness/modules/interview_preparation.md b/backend/app/career_readiness/modules/interview_preparation.md new file mode 100644 index 00000000..a6d3cc8b --- /dev/null +++ b/backend/app/career_readiness/modules/interview_preparation.md @@ -0,0 +1,87 @@ +--- +id: interview-preparation +title: Interview Preparation +description: Practice common interview questions, learn the STAR method, and build confidence for job interviews. +icon: interview +sort_order: 4 +input_placeholder: Ask about interviews... +--- + +# Interview Preparation + +## Overview + +This module prepares you for job interviews by teaching proven techniques, common question types, and strategies to present yourself effectively. + +## Types of Interviews + +### Structured Interviews +- Predetermined questions asked to all candidates +- Easy to prepare for with practice + +### Behavioral Interviews +- Focus on past behavior as a predictor of future performance +- Questions typically start with "Tell me about a time when..." + +### Situational Interviews +- Hypothetical scenarios to assess problem-solving +- Questions like "What would you do if..." + +### Panel Interviews +- Multiple interviewers at once +- Address all panel members, not just the one asking + +## The STAR Method + +Use the STAR method to structure your answers to behavioral questions: + +- **S — Situation**: Describe the context or background +- **T — Task**: Explain what you needed to accomplish +- **A — Action**: Detail the specific steps you took +- **R — Result**: Share the outcome and what you learned + +### Example + +**Question**: "Tell me about a time you solved a difficult problem." + +**Answer using STAR**: +- **Situation**: "In my previous role at a retail store, we experienced a sudden 30% drop in customer satisfaction scores." +- **Task**: "I was asked to identify the root cause and propose solutions." +- **Action**: "I analyzed customer feedback, identified long wait times as the main issue, and proposed a new queue management system." +- **Result**: "Within two months, satisfaction scores improved by 25%, and the system was adopted across all branches." + +## Common Interview Questions + +### About You +- Tell me about yourself +- What are your strengths and weaknesses? +- Where do you see yourself in five years? + +### About the Role +- Why do you want this job? +- What do you know about our company? +- How does your experience prepare you for this role? + +### Behavioral +- Describe a time you worked in a team +- Tell me about a challenge you overcame +- Give an example of when you showed leadership + +## Preparation Checklist + +1. Research the company and role thoroughly +2. Prepare answers for common questions using the STAR method +3. Prepare thoughtful questions to ask the interviewer +4. Practice with a friend or in front of a mirror +5. Plan your outfit and route to the interview location +6. Bring copies of your CV and any required documents +7. Arrive 10-15 minutes early + +## During the Interview + +- Make eye contact and offer a firm handshake +- Listen carefully before responding +- Take a moment to think before answering difficult questions +- Be honest — do not exaggerate or fabricate +- Show enthusiasm for the role and organization +- Thank the interviewer at the end diff --git a/backend/app/career_readiness/modules/professional_identity.md b/backend/app/career_readiness/modules/professional_identity.md new file mode 100644 index 00000000..43db0a72 --- /dev/null +++ b/backend/app/career_readiness/modules/professional_identity.md @@ -0,0 +1,61 @@ +--- +id: professional-identity +title: Professional Identity & Skills Mapping +description: Understand your professional identity and categorize your skills into technical, knowledge-based, and transferable types. +icon: identity +sort_order: 1 +input_placeholder: Ask about skills and professional identity... +--- + +# Professional Identity & Skills Mapping + +## Overview + +This module helps you understand and articulate your professional identity. You will learn to identify, categorize, and communicate your skills effectively. + +## What is Professional Identity? + +Your professional identity is how you see yourself in the context of work. It encompasses your values, skills, experiences, and aspirations. A clear professional identity helps you: + +- Communicate your value to employers +- Make informed career decisions +- Build confidence in professional settings + +## Types of Skills + +### Technical Skills +These are specific, teachable abilities that can be measured. Examples include: +- Computer programming +- Data analysis +- Machine operation +- Accounting + +### Knowledge-Based Skills +These come from education and experience in specific domains: +- Industry regulations +- Subject matter expertise +- Research methodologies +- Language proficiency + +### Transferable Skills +These are portable skills that apply across many jobs and industries: +- Communication +- Problem-solving +- Leadership +- Time management +- Teamwork + +## How to Identify Your Skills + +1. **Review your experiences**: Think about jobs, volunteer work, education, and personal projects. +2. **Ask others**: Colleagues, mentors, and friends can help identify strengths you might overlook. +3. **Use action verbs**: Describe what you did using verbs like managed, created, organized, analyzed. +4. **Consider results**: What outcomes did your skills produce? Quantify where possible. + +## Articulating Your Professional Identity + +When describing yourself professionally: +- Lead with your strongest, most relevant skills +- Use specific examples to illustrate your abilities +- Tailor your identity to the context (job application, networking, etc.) +- Be authentic and honest about your experience level diff --git a/backend/app/career_readiness/modules/workplace_readiness.md b/backend/app/career_readiness/modules/workplace_readiness.md new file mode 100644 index 00000000..4ccd82df --- /dev/null +++ b/backend/app/career_readiness/modules/workplace_readiness.md @@ -0,0 +1,89 @@ +--- +id: workplace-readiness +title: Workplace Readiness Skills +description: Develop essential workplace skills including communication, problem-solving, teamwork, and professional etiquette. +icon: workplace +sort_order: 5 +input_placeholder: Ask about workplace skills... +--- + +# Workplace Readiness Skills + +## Overview + +This module helps you develop the essential soft skills and professional behaviors that employers value in the workplace. + +## Communication Skills + +### Verbal Communication +- Speak clearly and concisely +- Adjust your language to your audience +- Practice active listening — let others finish before responding +- Ask clarifying questions when you do not understand + +### Written Communication +- Use professional language in emails and messages +- Be clear about the purpose of your communication +- Proofread before sending +- Respond to messages in a timely manner + +### Non-Verbal Communication +- Maintain appropriate eye contact +- Be aware of your body language +- Dress appropriately for your workplace + +## Problem-Solving + +### A Structured Approach +1. **Identify the problem** — clearly define what is wrong +2. **Gather information** — collect relevant facts and data +3. **Generate options** — brainstorm possible solutions +4. **Evaluate options** — consider pros and cons of each +5. **Choose and implement** — select the best option and act +6. **Review the outcome** — assess whether the problem is resolved + +### Tips +- Do not panic when problems arise — stay calm and analytical +- Ask for help when you need it +- Learn from mistakes — they are opportunities for growth + +## Teamwork + +### Working Effectively with Others +- Respect different perspectives and working styles +- Communicate openly and honestly +- Share credit for team achievements +- Take responsibility for your part of the work +- Be reliable — meet deadlines and keep commitments + +### Handling Conflict +- Address issues early before they escalate +- Focus on the problem, not the person +- Listen to understand, not to respond +- Seek compromise or a mutually acceptable solution +- Involve a supervisor if needed + +## Time Management + +- Prioritize tasks by importance and deadline +- Break large tasks into smaller, manageable steps +- Use calendars and to-do lists +- Avoid multitasking — focus on one task at a time +- Learn to say no to non-essential tasks when overloaded + +## Professional Etiquette + +- Be punctual — arrive on time or early +- Respect workplace rules and policies +- Maintain a positive attitude +- Accept feedback gracefully and use it to improve +- Keep personal matters separate from work +- Show initiative — do not wait to be told what to do + +## Adaptability and Resilience + +- Be open to change and new ways of working +- Stay positive during difficult times +- Seek learning opportunities in every situation +- Build a support network of colleagues and mentors +- Take care of your physical and mental well-being diff --git a/backend/app/career_readiness/repository.py b/backend/app/career_readiness/repository.py new file mode 100644 index 00000000..83ddeae4 --- /dev/null +++ b/backend/app/career_readiness/repository.py @@ -0,0 +1,98 @@ +""" +Repository for career readiness conversation data in MongoDB. +""" +import logging +from abc import ABC, abstractmethod +from datetime import datetime, timezone + +from motor.motor_asyncio import AsyncIOMotorDatabase + +from app.career_readiness.types import ( + CareerReadinessConversationDocument, + CareerReadinessMessage, +) +from app.server_dependencies.database_collections import Collections + + +class ICareerReadinessConversationRepository(ABC): + """Interface for the career readiness conversation repository.""" + + @abstractmethod + async def create(self, document: CareerReadinessConversationDocument) -> None: + """Insert a new conversation document.""" + raise NotImplementedError() + + @abstractmethod + async def find_by_conversation_id(self, conversation_id: str) -> CareerReadinessConversationDocument | None: + """Find a conversation by its ID.""" + raise NotImplementedError() + + @abstractmethod + async def find_by_user_and_module(self, user_id: str, module_id: str) -> CareerReadinessConversationDocument | None: + """Find a conversation for a specific user and module.""" + raise NotImplementedError() + + @abstractmethod + async def find_all_by_user(self, user_id: str) -> list[CareerReadinessConversationDocument]: + """Find all conversations for a specific user.""" + raise NotImplementedError() + + @abstractmethod + async def append_message(self, conversation_id: str, message: CareerReadinessMessage) -> None: + """Append a message to a conversation and update the updated_at timestamp.""" + raise NotImplementedError() + + @abstractmethod + async def delete_by_conversation_id(self, conversation_id: str) -> bool: + """Delete a conversation. Returns True if deleted, False if not found.""" + raise NotImplementedError() + + +class CareerReadinessConversationRepository(ICareerReadinessConversationRepository): + """MongoDB implementation of the career readiness conversation repository.""" + + def __init__(self, db: AsyncIOMotorDatabase): + self._collection = db.get_collection(Collections.CAREER_READINESS_CONVERSATIONS) + self._logger = logging.getLogger(CareerReadinessConversationRepository.__name__) + + async def create(self, document: CareerReadinessConversationDocument) -> None: + await self._collection.insert_one(document.model_dump()) + + async def find_by_conversation_id(self, conversation_id: str) -> CareerReadinessConversationDocument | None: + result = await self._collection.find_one( + {"conversation_id": {"$eq": conversation_id}} + ) + if result is None: + return None + return CareerReadinessConversationDocument.from_dict(result) + + async def find_by_user_and_module(self, user_id: str, module_id: str) -> CareerReadinessConversationDocument | None: + result = await self._collection.find_one( + {"user_id": {"$eq": user_id}, "module_id": {"$eq": module_id}} + ) + if result is None: + return None + return CareerReadinessConversationDocument.from_dict(result) + + async def find_all_by_user(self, user_id: str) -> list[CareerReadinessConversationDocument]: + cursor = self._collection.find({"user_id": {"$eq": user_id}}) + results = [] + async for doc in cursor: + results.append(CareerReadinessConversationDocument.from_dict(doc)) + return results + + async def append_message(self, conversation_id: str, message: CareerReadinessMessage) -> None: + now = datetime.now(timezone.utc).isoformat() + await self._collection.update_one( + {"conversation_id": {"$eq": conversation_id}}, + { + "$push": {"messages": message.model_dump()}, + "$set": {"updated_at": now}, + }, + ) + + async def delete_by_conversation_id(self, conversation_id: str) -> bool: + result = await self._collection.delete_one( + {"conversation_id": {"$eq": conversation_id}} + ) + return result.deleted_count > 0 diff --git a/backend/app/career_readiness/routes.py b/backend/app/career_readiness/routes.py index 090d6495..1bdac929 100644 --- a/backend/app/career_readiness/routes.py +++ b/backend/app/career_readiness/routes.py @@ -1,21 +1,56 @@ """ This module contains the routes for the career readiness module. """ +import asyncio +import logging from http import HTTPStatus -from typing import Annotated +from typing import Annotated, Optional from fastapi import FastAPI, APIRouter, Depends, HTTPException, Path +from motor.motor_asyncio import AsyncIOMotorDatabase +from app.career_readiness.errors import ( + CareerReadinessModuleNotFoundError, + ConversationAccessDeniedError, + ConversationAlreadyExistsError, + ConversationModuleMismatchError, + ConversationNotFoundError, +) +from app.career_readiness.module_loader import get_module_registry +from app.career_readiness.repository import CareerReadinessConversationRepository +from app.career_readiness.service import CareerReadinessService, ICareerReadinessService from app.career_readiness.types import ( ModuleListResponse, ModuleDetail, - ModuleStatusUpdateRequest, CareerReadinessConversationResponse, CareerReadinessConversationInput, ) from app.constants.errors import HTTPErrorResponse +from app.conversations.constants import MAX_MESSAGE_LENGTH +from app.server_dependencies.db_dependencies import CompassDBProvider from app.users.auth import Authentication, UserInfo +logger = logging.getLogger(__name__) + +# Lock to ensure that the singleton instance is thread-safe +_career_readiness_service_lock = asyncio.Lock() +_career_readiness_service_singleton: Optional[ICareerReadinessService] = None + + +async def get_career_readiness_service( + application_db: AsyncIOMotorDatabase = Depends(CompassDBProvider.get_application_db), +) -> ICareerReadinessService: + """Get or create the career readiness service singleton.""" + global _career_readiness_service_singleton + if _career_readiness_service_singleton is None: + async with _career_readiness_service_lock: + if _career_readiness_service_singleton is None: + _career_readiness_service_singleton = CareerReadinessService( + repository=CareerReadinessConversationRepository(application_db), + module_registry=get_module_registry(), + ) + return _career_readiness_service_singleton + def add_career_readiness_routes(app: FastAPI, authentication: Authentication): """ @@ -36,9 +71,14 @@ def add_career_readiness_routes(app: FastAPI, authentication: Authentication): description="List all career readiness modules with the current user's progress status.", ) async def _list_modules( - user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), ): - raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + try: + return await service.list_modules(user_info.user_id) + except Exception as e: + logger.exception("Error listing modules: %s", e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e @router.get( path="/modules/{module_id}", @@ -51,26 +91,16 @@ async def _list_modules( ) async def _get_module( module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], - user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), ): - raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") - - @router.patch( - path="/modules/{module_id}/status", - response_model=ModuleDetail, - responses={ - HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, - HTTPStatus.BAD_REQUEST: {"model": HTTPErrorResponse}, - HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, - }, - description="Manually update the status of a career readiness module (e.g. to reset it).", - ) - async def _update_module_status( - module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], - body: ModuleStatusUpdateRequest, # noqa: ARG001 - user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 - ): - raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + try: + return await service.get_module(user_info.user_id, module_id) + except CareerReadinessModuleNotFoundError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"Module not found: {module_id}") from exc + except Exception as e: + logger.exception("Error getting module: %s", e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e @router.post( path="/modules/{module_id}/conversations", @@ -85,9 +115,19 @@ async def _update_module_status( ) async def _create_conversation( module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], - user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), ): - raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + try: + return await service.create_conversation(user_info.user_id, module_id) + except CareerReadinessModuleNotFoundError as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"Module not found: {module_id}") from exc + except ConversationAlreadyExistsError as exc: + raise HTTPException(status_code=HTTPStatus.CONFLICT, + detail=f"A conversation already exists for module {module_id}") from exc + except Exception as e: + logger.exception("Error creating conversation: %s", e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e @router.post( path="/modules/{module_id}/conversations/{conversation_id}/messages", @@ -104,10 +144,23 @@ async def _create_conversation( async def _send_message( module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], - body: CareerReadinessConversationInput, # noqa: ARG001 - user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + body: CareerReadinessConversationInput, + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), ): - raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + if len(body.user_input) > MAX_MESSAGE_LENGTH: + logger.warning("User input exceeded maximum length of %d characters", MAX_MESSAGE_LENGTH) + raise HTTPException(status_code=HTTPStatus.REQUEST_ENTITY_TOO_LARGE, detail="Too long user input") + + try: + return await service.send_message(user_info.user_id, module_id, conversation_id, body.user_input) + except (CareerReadinessModuleNotFoundError, ConversationNotFoundError, ConversationModuleMismatchError) as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Conversation not found") from exc + except ConversationAccessDeniedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc + except Exception as e: + logger.exception("Error sending message: %s", e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e @router.get( path="/modules/{module_id}/conversations/{conversation_id}/messages", @@ -122,9 +175,18 @@ async def _send_message( async def _get_conversation_history( module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], - user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), ): - raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + try: + return await service.get_conversation_history(user_info.user_id, module_id, conversation_id) + except (CareerReadinessModuleNotFoundError, ConversationNotFoundError, ConversationModuleMismatchError) as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Conversation not found") from exc + except ConversationAccessDeniedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc + except Exception as e: + logger.exception("Error getting conversation history: %s", e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e @router.delete( path="/modules/{module_id}/conversations/{conversation_id}", @@ -139,8 +201,17 @@ async def _get_conversation_history( async def _delete_conversation( module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], - user_info: UserInfo = Depends(authentication.get_user_info()), # noqa: ARG001 + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), ): - raise HTTPException(status_code=HTTPStatus.NOT_IMPLEMENTED, detail="Not implemented yet") + try: + await service.delete_conversation(user_info.user_id, module_id, conversation_id) + except (CareerReadinessModuleNotFoundError, ConversationNotFoundError, ConversationModuleMismatchError) as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Conversation not found") from exc + except ConversationAccessDeniedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc + except Exception as e: + logger.exception("Error deleting conversation: %s", e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e app.include_router(router) diff --git a/backend/app/career_readiness/service.py b/backend/app/career_readiness/service.py new file mode 100644 index 00000000..1571e108 --- /dev/null +++ b/backend/app/career_readiness/service.py @@ -0,0 +1,325 @@ +""" +Service layer for the career readiness module. + +Orchestrates the module registry, repository, and agent to implement +the career readiness business logic. +""" +import logging +from abc import ABC, abstractmethod +from datetime import datetime, timezone +from typing import Callable + +from bson import ObjectId + +from app.agent.agent_types import AgentInput, AgentOutput +from app.career_readiness.agent import CareerReadinessAgent +from app.career_readiness.errors import ( + ConversationAccessDeniedError, + ConversationAlreadyExistsError, + ConversationModuleMismatchError, + ConversationNotFoundError, + CareerReadinessModuleNotFoundError, +) +from app.career_readiness.module_loader import ModuleConfig, ModuleRegistry +from app.career_readiness.repository import ICareerReadinessConversationRepository +from app.career_readiness.types import ( + CareerReadinessConversationDocument, + CareerReadinessConversationResponse, + CareerReadinessMessage, + CareerReadinessMessageSender, + ModuleDetail, + ModuleListResponse, + ModuleStatus, + ModuleSummary, +) +from app.conversation_memory.conversation_memory_types import ( + ConversationContext, + ConversationHistory, + ConversationTurn, +) + + +class ICareerReadinessService(ABC): + """Interface for the career readiness service.""" + + @abstractmethod + async def list_modules(self, user_id: str) -> ModuleListResponse: + """List all modules with the user's progress status.""" + raise NotImplementedError() + + @abstractmethod + async def get_module(self, user_id: str, module_id: str) -> ModuleDetail: + """Get detailed information about a specific module.""" + raise NotImplementedError() + + @abstractmethod + async def create_conversation(self, user_id: str, module_id: str) -> CareerReadinessConversationResponse: + """Start a new conversation for a module.""" + raise NotImplementedError() + + @abstractmethod + async def send_message(self, user_id: str, module_id: str, + conversation_id: str, user_input: str) -> CareerReadinessConversationResponse: + """Send a user message and get the agent's response.""" + raise NotImplementedError() + + @abstractmethod + async def get_conversation_history(self, user_id: str, module_id: str, + conversation_id: str) -> CareerReadinessConversationResponse: + """Retrieve the full message history for a conversation.""" + raise NotImplementedError() + + @abstractmethod + async def delete_conversation(self, user_id: str, module_id: str, conversation_id: str) -> None: + """Delete a conversation.""" + raise NotImplementedError() + + +def _default_agent_factory(module_config: ModuleConfig) -> CareerReadinessAgent: + """Default factory that creates a real CareerReadinessAgent.""" + return CareerReadinessAgent( + module_title=module_config.title, + module_content=module_config.content, + ) + + +def _derive_status(conversation: CareerReadinessConversationDocument | None) -> ModuleStatus: + """Derive the module status from the conversation state.""" + if conversation is None: + return ModuleStatus.NOT_STARTED + return ModuleStatus.IN_PROGRESS + + +def _build_conversation_context(messages: list[CareerReadinessMessage]) -> ConversationContext: + """ + Build a ConversationContext from stored messages. + + Pairs user+agent messages into ConversationTurns for the LLM history. + """ + turns: list[ConversationTurn] = [] + index = 0 + + i = 0 + while i < len(messages): + msg = messages[i] + + # Skip agent messages that are not paired with a user message (e.g. intro message) + if msg.sender == CareerReadinessMessageSender.AGENT and (i + 1 >= len(messages) or messages[i + 1].sender != CareerReadinessMessageSender.USER): + i += 1 + continue + + # Look for user+agent pairs + if msg.sender == CareerReadinessMessageSender.USER and i + 1 < len(messages) and messages[i + 1].sender == CareerReadinessMessageSender.AGENT: + user_msg = msg + agent_msg = messages[i + 1] + turns.append(ConversationTurn( + index=index, + input=AgentInput( + message_id=user_msg.message_id, + message=user_msg.message, + sent_at=user_msg.sent_at, + ), + output=AgentOutput( + message_id=agent_msg.message_id, + message_for_user=agent_msg.message, + finished=False, + agent_response_time_in_sec=0, + llm_stats=[], + sent_at=agent_msg.sent_at, + ), + )) + index += 1 + i += 2 + else: + i += 1 + + history = ConversationHistory(turns=turns) + return ConversationContext(all_history=history, history=history) + + +class CareerReadinessService(ICareerReadinessService): + """Implementation of the career readiness service.""" + + def __init__( + self, + repository: ICareerReadinessConversationRepository, + module_registry: ModuleRegistry, + agent_factory: Callable[[ModuleConfig], CareerReadinessAgent] | None = None, + ): + self._repository = repository + self._module_registry = module_registry + self._agent_factory = agent_factory or _default_agent_factory + self._logger = logging.getLogger(CareerReadinessService.__name__) + + def _get_module_or_raise(self, module_id: str) -> ModuleConfig: + """Get a module from the registry or raise CareerReadinessModuleNotFoundError.""" + module = self._module_registry.get_module(module_id) + if module is None: + raise CareerReadinessModuleNotFoundError(module_id) + return module + + async def _get_conversation_or_raise(self, conversation_id: str) -> CareerReadinessConversationDocument: + """Find a conversation or raise ConversationNotFoundError.""" + conversation = await self._repository.find_by_conversation_id(conversation_id) + if conversation is None: + raise ConversationNotFoundError(conversation_id) + return conversation + + def _validate_access(self, conversation: CareerReadinessConversationDocument, + user_id: str, module_id: str) -> None: + """Validate that the user owns the conversation and it belongs to the correct module.""" + if conversation.user_id != user_id: + raise ConversationAccessDeniedError(conversation.conversation_id, user_id) + if conversation.module_id != module_id: + raise ConversationModuleMismatchError(conversation.conversation_id, module_id) + + async def list_modules(self, user_id: str) -> ModuleListResponse: + all_modules = self._module_registry.get_all_modules() + user_conversations = await self._repository.find_all_by_user(user_id) + + # Build a lookup from module_id to conversation + conv_by_module = {conv.module_id: conv for conv in user_conversations} + + summaries = [] + for module in all_modules: + conversation = conv_by_module.get(module.id) + summaries.append(ModuleSummary( + id=module.id, + title=module.title, + description=module.description, + icon=module.icon, + status=_derive_status(conversation), + sort_order=module.sort_order, + input_placeholder=module.input_placeholder, + )) + + return ModuleListResponse(modules=summaries) + + async def get_module(self, user_id: str, module_id: str) -> ModuleDetail: + module = self._get_module_or_raise(module_id) + conversation = await self._repository.find_by_user_and_module(user_id, module_id) + + return ModuleDetail( + id=module.id, + title=module.title, + description=module.description, + icon=module.icon, + status=_derive_status(conversation), + sort_order=module.sort_order, + input_placeholder=module.input_placeholder, + scope=module.content, + active_conversation_id=conversation.conversation_id if conversation else None, + ) + + async def create_conversation(self, user_id: str, module_id: str) -> CareerReadinessConversationResponse: + module = self._get_module_or_raise(module_id) + + # Check for existing conversation + existing = await self._repository.find_by_user_and_module(user_id, module_id) + if existing is not None: + raise ConversationAlreadyExistsError(module_id, user_id) + + # Create agent and generate intro message + agent = self._agent_factory(module) + empty_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + intro_output = await agent.generate_intro_message(empty_context) + + # Build conversation document + now = datetime.now(timezone.utc) + conversation_id = str(ObjectId()) + intro_message = CareerReadinessMessage( + message_id=intro_output.message_id or str(ObjectId()), + message=intro_output.message_for_user, + sender=CareerReadinessMessageSender.AGENT, + sent_at=intro_output.sent_at, + ) + + document = CareerReadinessConversationDocument( + conversation_id=conversation_id, + module_id=module_id, + user_id=user_id, + messages=[intro_message], + created_at=now, + updated_at=now, + ) + await self._repository.create(document) + + return CareerReadinessConversationResponse( + conversation_id=conversation_id, + module_id=module_id, + messages=[intro_message], + ) + + async def send_message(self, user_id: str, module_id: str, + conversation_id: str, user_input: str) -> CareerReadinessConversationResponse: + module = self._get_module_or_raise(module_id) + conversation = await self._get_conversation_or_raise(conversation_id) + self._validate_access(conversation, user_id, module_id) + + # Snapshot existing messages before any mutation + existing_messages = list(conversation.messages) + + # Build user message + now = datetime.now(timezone.utc) + user_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message=user_input, + sender=CareerReadinessMessageSender.USER, + sent_at=now, + ) + + # Persist user message before calling the LLM (crash-safety) + await self._repository.append_message(conversation_id, user_message) + + # Build context from in-memory state (existing messages + new user message) + all_messages = existing_messages + [user_message] + context = _build_conversation_context(all_messages) + + # Get agent response + agent = self._agent_factory(module) + agent_input = AgentInput( + message=user_input, + sent_at=now, + ) + agent_output = await agent.execute(agent_input, context) + + # Build and persist agent response + agent_message = CareerReadinessMessage( + message_id=agent_output.message_id or str(ObjectId()), + message=agent_output.message_for_user, + sender=CareerReadinessMessageSender.AGENT, + sent_at=agent_output.sent_at, + ) + await self._repository.append_message(conversation_id, agent_message) + + # Build response from in-memory state (no re-read needed) + return CareerReadinessConversationResponse( + conversation_id=conversation_id, + module_id=module_id, + messages=all_messages + [agent_message], + module_completed=agent_output.finished, + ) + + async def get_conversation_history(self, user_id: str, module_id: str, + conversation_id: str) -> CareerReadinessConversationResponse: + self._get_module_or_raise(module_id) + conversation = await self._get_conversation_or_raise(conversation_id) + self._validate_access(conversation, user_id, module_id) + + return CareerReadinessConversationResponse( + conversation_id=conversation.conversation_id, + module_id=conversation.module_id, + messages=conversation.messages, + ) + + async def delete_conversation(self, user_id: str, module_id: str, conversation_id: str) -> None: + self._get_module_or_raise(module_id) + conversation = await self._get_conversation_or_raise(conversation_id) + self._validate_access(conversation, user_id, module_id) + + deleted = await self._repository.delete_by_conversation_id(conversation_id) + if not deleted: + raise ConversationNotFoundError(conversation_id) diff --git a/backend/app/career_readiness/test_agent.py b/backend/app/career_readiness/test_agent.py new file mode 100644 index 00000000..71b6d6e8 --- /dev/null +++ b/backend/app/career_readiness/test_agent.py @@ -0,0 +1,210 @@ +""" +Tests for the career readiness agent. +""" +from unittest.mock import AsyncMock, patch, MagicMock + +import pytest + +from app.agent.agent_types import AgentInput, AgentType, LLMStats +from app.agent.simple_llm_agent.llm_response import ModelResponse +from app.career_readiness.agent import CareerReadinessAgent, _build_system_instructions +from app.conversation_memory.conversation_memory_types import ( + ConversationContext, + ConversationHistory, +) + + +class TestBuildSystemInstructions: + """Tests for the system instructions builder.""" + + def test_includes_module_title(self): + # GIVEN a module title and content + given_title = "CV Development" + given_content = "How to write a great CV." + + # WHEN the system instructions are built + actual_instructions = _build_system_instructions(given_title, given_content) + + # THEN the module title is included in the instructions + assert given_title in actual_instructions + + def test_includes_module_content(self): + # GIVEN a module title and content + given_title = "Interview Preparation" + given_content = "Practice answering behavioral questions using the STAR method." + + # WHEN the system instructions are built + actual_instructions = _build_system_instructions(given_title, given_content) + + # THEN the module content is included in the instructions + assert given_content in actual_instructions + + def test_includes_json_response_instructions(self): + # GIVEN a module title and content + given_title = "Cover Letter" + given_content = "A cover letter should be tailored to the job." + + # WHEN the system instructions are built + actual_instructions = _build_system_instructions(given_title, given_content) + + # THEN the JSON response schema instructions are included + assert "reasoning" in actual_instructions + assert "finished" in actual_instructions + assert "message" in actual_instructions + + def test_includes_finish_instructions(self): + # GIVEN a module title and content + given_title = "Workplace Readiness" + given_content = "Understanding workplace norms." + + # WHEN the system instructions are built + actual_instructions = _build_system_instructions(given_title, given_content) + + # THEN the finish instructions are included + assert "finished" in actual_instructions + assert "true" in actual_instructions + + +@patch("app.career_readiness.agent.GeminiGenerativeLLM") +class TestCareerReadinessAgent: + """Tests for the CareerReadinessAgent class.""" + + def test_initializes_with_correct_system_instructions(self, mock_llm_cls): + # GIVEN a module title and content + given_title = "CV Development" + given_content = "Write your CV with clear structure." + + # WHEN the agent is created + actual_agent = CareerReadinessAgent(module_title=given_title, module_content=given_content) + + # THEN the system instructions contain the module title and content + assert given_title in actual_agent.system_instructions + assert given_content in actual_agent.system_instructions + + @pytest.mark.asyncio + async def test_execute_returns_agent_output(self, mock_llm_cls): + # GIVEN an agent with a mocked LLM caller + given_agent = CareerReadinessAgent(module_title="Test Module", module_content="Test content.") + given_model_response = ModelResponse( + reasoning="The user asked about CVs, therefore I will set the finished flag to false.", + finished=False, + message="Here are some tips for writing a CV.", + ) + given_llm_stats = [LLMStats( + prompt_token_count=100, + response_token_count=50, + response_time_in_sec=1.0, + )] + given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, given_llm_stats)) + + # AND a user input and empty context + given_input = AgentInput(message="How do I write a CV?") + given_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + + # WHEN execute is called + actual_output = await given_agent.execute(given_input, given_context) + + # THEN the output contains the expected message + assert actual_output.message_for_user == "Here are some tips for writing a CV." + # AND the finished flag matches the model response + assert actual_output.finished is False + # AND the agent type is correct + assert actual_output.agent_type == AgentType.CAREER_READINESS_AGENT + + @pytest.mark.asyncio + async def test_execute_handles_empty_input(self, mock_llm_cls): + # GIVEN an agent with a mocked LLM caller + given_agent = CareerReadinessAgent(module_title="Test Module", module_content="Test content.") + given_model_response = ModelResponse( + reasoning="The user sent empty input, I will greet them.", + finished=False, + message="Hello! How can I help you today?", + ) + given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, [])) + + # AND an empty user input + given_input = AgentInput(message=" ") + given_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + + # WHEN execute is called + actual_output = await given_agent.execute(given_input, given_context) + + # THEN the LLM is called with "(silence)" as the user message + call_args = given_agent._llm_caller.call_llm.call_args + llm_input = call_args.kwargs["llm_input"] + assert any("(silence)" in turn.content for turn in llm_input.turns) + + @pytest.mark.asyncio + async def test_execute_handles_llm_error(self, mock_llm_cls): + # GIVEN an agent whose LLM caller raises an exception + given_agent = CareerReadinessAgent(module_title="Test Module", module_content="Test content.") + given_agent._llm_caller.call_llm = AsyncMock(side_effect=Exception("LLM service unavailable")) + + given_input = AgentInput(message="Tell me about CVs") + given_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + + # WHEN execute is called + actual_output = await given_agent.execute(given_input, given_context) + + # THEN a fallback error message is returned + assert "difficulties" in actual_output.message_for_user + # AND the agent does not claim to be finished + assert actual_output.finished is False + + @pytest.mark.asyncio + async def test_generate_intro_message_sends_artificial_input(self, mock_llm_cls): + # GIVEN an agent with a mocked LLM caller + given_agent = CareerReadinessAgent(module_title="Test Module", module_content="Test content.") + given_model_response = ModelResponse( + reasoning="Starting a new conversation, introducing the module.", + finished=False, + message="Welcome to the CV Development module!", + ) + given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, [])) + + given_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + + # WHEN generate_intro_message is called + actual_output = await given_agent.generate_intro_message(given_context) + + # THEN the output contains the introductory message + assert actual_output.message_for_user == "Welcome to the CV Development module!" + # AND the LLM is called with "(silence)" as the user message + call_args = given_agent._llm_caller.call_llm.call_args + llm_input = call_args.kwargs["llm_input"] + assert any("(silence)" in turn.content for turn in llm_input.turns) + + @pytest.mark.asyncio + async def test_execute_strips_quotes_from_message(self, mock_llm_cls): + # GIVEN an agent whose LLM returns a message wrapped in quotes + given_agent = CareerReadinessAgent(module_title="Test Module", module_content="Test content.") + given_model_response = ModelResponse( + reasoning="Responding to user.", + finished=False, + message='"Here is some advice."', + ) + given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, [])) + + given_input = AgentInput(message="Help me") + given_context = ConversationContext( + all_history=ConversationHistory(), + history=ConversationHistory(), + ) + + # WHEN execute is called + actual_output = await given_agent.execute(given_input, given_context) + + # THEN the leading and trailing quotes are stripped + assert actual_output.message_for_user == "Here is some advice." diff --git a/backend/app/career_readiness/test_module_loader.py b/backend/app/career_readiness/test_module_loader.py new file mode 100644 index 00000000..d3bf8cad --- /dev/null +++ b/backend/app/career_readiness/test_module_loader.py @@ -0,0 +1,218 @@ +""" +Tests for the career readiness module loader. +""" +from pathlib import Path + +import pytest + +from app.career_readiness.module_loader import ( + ModuleConfig, + ModuleRegistry, + _parse_frontmatter, + _load_module_from_file, +) + + +class TestParseFrontmatter: + """Tests for the frontmatter parser.""" + + def test_parses_valid_frontmatter_and_body(self): + # GIVEN a markdown string with valid frontmatter + given_text = "---\nid: test-module\ntitle: Test Module\n---\n\n# Body Content\n\nSome text." + + # WHEN the frontmatter is parsed + actual_metadata, actual_body = _parse_frontmatter(given_text) + + # THEN the metadata contains the parsed key-value pairs + assert actual_metadata["id"] == "test-module" + assert actual_metadata["title"] == "Test Module" + # AND the body contains the markdown content + assert "# Body Content" in actual_body + assert "Some text." in actual_body + + def test_raises_when_missing_opening_delimiter(self): + # GIVEN a markdown string without the opening --- delimiter + given_text = "id: test-module\n---\n\nBody." + + # WHEN the frontmatter is parsed + # THEN a ValueError is raised + with pytest.raises(ValueError, match="must start with ---"): + _parse_frontmatter(given_text) + + def test_raises_when_line_has_no_colon(self): + # GIVEN a markdown string with an invalid frontmatter line (no colon) + given_text = "---\nid: test-module\ninvalid line\n---\n\nBody." + + # WHEN the frontmatter is parsed + # THEN a ValueError is raised + with pytest.raises(ValueError, match="missing colon"): + _parse_frontmatter(given_text) + + def test_handles_colons_in_values(self): + # GIVEN a frontmatter value that contains a colon + given_text = "---\ndescription: Learn to write: a guide\n---\n\nBody." + + # WHEN the frontmatter is parsed + actual_metadata, _ = _parse_frontmatter(given_text) + + # THEN the value includes everything after the first colon + assert actual_metadata["description"] == "Learn to write: a guide" + + +class TestLoadModuleFromFile: + """Tests for loading a single module from a file.""" + + def test_loads_valid_module_file(self, tmp_path): + # GIVEN a valid module markdown file + given_file = tmp_path / "test.md" + given_file.write_text( + "---\n" + "id: test-module\n" + "title: Test Module\n" + "description: A test module.\n" + "icon: test\n" + "sort_order: 1\n" + "input_placeholder: Ask something...\n" + "---\n\n" + "# Test Content\n\nBody text.", + encoding="utf-8", + ) + + # WHEN the module is loaded + actual_module = _load_module_from_file(given_file) + + # THEN the module has the correct metadata + assert actual_module.id == "test-module" + assert actual_module.title == "Test Module" + assert actual_module.description == "A test module." + assert actual_module.icon == "test" + assert actual_module.sort_order == 1 + assert actual_module.input_placeholder == "Ask something..." + # AND the content contains the markdown body + assert "# Test Content" in actual_module.content + assert "Body text." in actual_module.content + + def test_raises_when_required_field_missing(self, tmp_path): + # GIVEN a module file missing the 'title' field + given_file = tmp_path / "bad.md" + given_file.write_text( + "---\n" + "id: bad-module\n" + "description: Missing title.\n" + "icon: test\n" + "sort_order: 1\n" + "input_placeholder: Ask...\n" + "---\n\nBody.", + encoding="utf-8", + ) + + # WHEN the module is loaded + # THEN a KeyError is raised for the missing field + with pytest.raises(KeyError): + _load_module_from_file(given_file) + + +class TestModuleRegistry: + """Tests for the module registry.""" + + def test_loads_all_real_modules(self): + # GIVEN the real modules directory + # WHEN a registry is created with the default path + actual_registry = ModuleRegistry() + + # THEN all 5 modules are loaded + actual_modules = actual_registry.get_all_modules() + assert len(actual_modules) == 5 + + def test_modules_sorted_by_sort_order(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN all modules are retrieved + actual_modules = actual_registry.get_all_modules() + + # THEN they are sorted by sort_order + actual_orders = [m.sort_order for m in actual_modules] + assert actual_orders == sorted(actual_orders) + + def test_get_module_returns_correct_module(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN a specific module is requested + actual_module = actual_registry.get_module("cv-development") + + # THEN the correct module is returned + assert actual_module is not None + assert actual_module.id == "cv-development" + assert actual_module.title == "CV Development" + + def test_get_module_returns_none_for_nonexistent(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN a nonexistent module is requested + actual_module = actual_registry.get_module("nonexistent-module") + + # THEN None is returned + assert actual_module is None + + def test_loads_from_custom_directory(self, tmp_path): + # GIVEN a custom directory with two module files + given_module_dir = tmp_path / "modules" + given_module_dir.mkdir() + + for i, name in enumerate(["alpha", "beta"], start=1): + (given_module_dir / f"{name}.md").write_text( + f"---\n" + f"id: {name}\n" + f"title: Module {name.title()}\n" + f"description: Description for {name}.\n" + f"icon: {name}\n" + f"sort_order: {i}\n" + f"input_placeholder: Ask about {name}...\n" + f"---\n\n" + f"# {name.title()} Content", + encoding="utf-8", + ) + + # WHEN a registry is created with the custom directory + actual_registry = ModuleRegistry(modules_dir=given_module_dir) + + # THEN both modules are loaded + assert len(actual_registry.get_all_modules()) == 2 + assert actual_registry.get_module("alpha") is not None + assert actual_registry.get_module("beta") is not None + + def test_empty_directory_loads_no_modules(self, tmp_path): + # GIVEN an empty directory + given_empty_dir = tmp_path / "empty" + given_empty_dir.mkdir() + + # WHEN a registry is created with the empty directory + actual_registry = ModuleRegistry(modules_dir=given_empty_dir) + + # THEN no modules are loaded + assert len(actual_registry.get_all_modules()) == 0 + + def test_each_module_has_nonempty_content(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN all modules are retrieved + actual_modules = actual_registry.get_all_modules() + + # THEN each module has non-empty content + for module in actual_modules: + assert len(module.content) > 0, f"Module {module.id} has empty content" + + def test_all_module_ids_are_unique(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN all modules are retrieved + actual_modules = actual_registry.get_all_modules() + + # THEN all IDs are unique + actual_ids = [m.id for m in actual_modules] + assert len(actual_ids) == len(set(actual_ids)) diff --git a/backend/app/career_readiness/test_repository.py b/backend/app/career_readiness/test_repository.py new file mode 100644 index 00000000..596f324d --- /dev/null +++ b/backend/app/career_readiness/test_repository.py @@ -0,0 +1,245 @@ +""" +Tests for the career readiness conversation repository. +""" +from datetime import datetime, timezone +from typing import Awaitable + +import pytest +from bson import ObjectId + +from app.career_readiness.repository import CareerReadinessConversationRepository +from app.career_readiness.types import ( + CareerReadinessConversationDocument, + CareerReadinessMessage, + CareerReadinessMessageSender, +) + + +def _make_message(sender: CareerReadinessMessageSender = CareerReadinessMessageSender.AGENT, + message: str = "Hello") -> CareerReadinessMessage: + """Helper to create a test message.""" + return CareerReadinessMessage( + message_id=str(ObjectId()), + message=message, + sender=sender, + sent_at=datetime.now(timezone.utc), + ) + + +def _make_conversation( + user_id: str = "test_user", + module_id: str = "cv-development", + messages: list[CareerReadinessMessage] | None = None, +) -> CareerReadinessConversationDocument: + """Helper to create a test conversation document.""" + now = datetime.now(timezone.utc) + return CareerReadinessConversationDocument( + conversation_id=str(ObjectId()), + module_id=module_id, + user_id=user_id, + messages=messages or [_make_message()], + created_at=now, + updated_at=now, + ) + + +@pytest.fixture(scope="function") +async def get_repository(in_memory_application_database) -> CareerReadinessConversationRepository: + application_db = await in_memory_application_database + return CareerReadinessConversationRepository(application_db) + + +class TestCreate: + """Tests for creating conversation documents.""" + + @pytest.mark.asyncio + async def test_creates_and_finds_document(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository and a conversation document + repo = await get_repository + given_conversation = _make_conversation() + + # WHEN the document is created + await repo.create(given_conversation) + + # THEN the document can be found by conversation_id + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.conversation_id == given_conversation.conversation_id + assert actual_result.module_id == given_conversation.module_id + assert actual_result.user_id == given_conversation.user_id + assert len(actual_result.messages) == 1 + + +class TestFindByConversationId: + """Tests for finding a conversation by its ID.""" + + @pytest.mark.asyncio + async def test_returns_none_when_not_found(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with no documents + repo = await get_repository + + # WHEN a nonexistent conversation is requested + actual_result = await repo.find_by_conversation_id("nonexistent") + + # THEN None is returned + assert actual_result is None + + @pytest.mark.asyncio + async def test_returns_document_when_found(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN the conversation is requested by ID + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + + # THEN the correct document is returned + assert actual_result is not None + assert actual_result.conversation_id == given_conversation.conversation_id + + +class TestFindByUserAndModule: + """Tests for finding a conversation by user and module.""" + + @pytest.mark.asyncio + async def test_returns_none_when_not_found(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with no documents + repo = await get_repository + + # WHEN a conversation is requested for a user and module + actual_result = await repo.find_by_user_and_module("some_user", "some_module") + + # THEN None is returned + assert actual_result is None + + @pytest.mark.asyncio + async def test_returns_correct_document(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation for a specific user and module + repo = await get_repository + given_user_id = "user_abc" + given_module_id = "cv-development" + given_conversation = _make_conversation(user_id=given_user_id, module_id=given_module_id) + await repo.create(given_conversation) + + # WHEN the conversation is requested by user and module + actual_result = await repo.find_by_user_and_module(given_user_id, given_module_id) + + # THEN the correct document is returned + assert actual_result is not None + assert actual_result.user_id == given_user_id + assert actual_result.module_id == given_module_id + + +class TestFindAllByUser: + """Tests for finding all conversations for a user.""" + + @pytest.mark.asyncio + async def test_returns_empty_list_when_no_conversations(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with no documents + repo = await get_repository + + # WHEN all conversations for a user are requested + actual_result = await repo.find_all_by_user("some_user") + + # THEN an empty list is returned + assert actual_result == [] + + @pytest.mark.asyncio + async def test_returns_all_conversations_for_user(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with multiple conversations for the same user + repo = await get_repository + given_user_id = "user_abc" + given_conv_1 = _make_conversation(user_id=given_user_id, module_id="cv-development") + given_conv_2 = _make_conversation(user_id=given_user_id, module_id="interview-preparation") + # AND a conversation for a different user + given_other_conv = _make_conversation(user_id="other_user", module_id="cv-development") + await repo.create(given_conv_1) + await repo.create(given_conv_2) + await repo.create(given_other_conv) + + # WHEN all conversations for the user are requested + actual_result = await repo.find_all_by_user(given_user_id) + + # THEN only the user's conversations are returned + assert len(actual_result) == 2 + actual_ids = {r.conversation_id for r in actual_result} + assert given_conv_1.conversation_id in actual_ids + assert given_conv_2.conversation_id in actual_ids + + +class TestAppendMessage: + """Tests for appending a message to a conversation.""" + + @pytest.mark.asyncio + async def test_appends_message_to_conversation(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation containing one message + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN a new message is appended + given_new_message = _make_message( + sender=CareerReadinessMessageSender.USER, + message="How do I write a CV?", + ) + await repo.append_message(given_conversation.conversation_id, given_new_message) + + # THEN the conversation now has two messages + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert len(actual_result.messages) == 2 + assert actual_result.messages[1].message == "How do I write a CV?" + + @pytest.mark.asyncio + async def test_updates_updated_at_timestamp(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation + repo = await get_repository + given_conversation = _make_conversation() + given_original_updated_at = given_conversation.updated_at + await repo.create(given_conversation) + + # WHEN a message is appended + given_new_message = _make_message(message="New message") + await repo.append_message(given_conversation.conversation_id, given_new_message) + + # THEN the updated_at timestamp is changed + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.updated_at >= given_original_updated_at + + +class TestDeleteByConversationId: + """Tests for deleting a conversation.""" + + @pytest.mark.asyncio + async def test_returns_false_when_not_found(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with no documents + repo = await get_repository + + # WHEN a nonexistent conversation is deleted + actual_result = await repo.delete_by_conversation_id("nonexistent") + + # THEN False is returned + assert actual_result is False + + @pytest.mark.asyncio + async def test_deletes_and_returns_true(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN the conversation is deleted + actual_result = await repo.delete_by_conversation_id(given_conversation.conversation_id) + + # THEN True is returned + assert actual_result is True + # AND the conversation can no longer be found + assert await repo.find_by_conversation_id(given_conversation.conversation_id) is None diff --git a/backend/app/career_readiness/test_routes.py b/backend/app/career_readiness/test_routes.py new file mode 100644 index 00000000..66274b83 --- /dev/null +++ b/backend/app/career_readiness/test_routes.py @@ -0,0 +1,374 @@ +""" +Tests for the career readiness routes. +""" +from datetime import datetime, timezone +from http import HTTPStatus +from typing import Optional + +import pytest +import pytest_mock +from bson import ObjectId +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.career_readiness.errors import ( + ConversationAccessDeniedError, + ConversationAlreadyExistsError, + ConversationNotFoundError, + CareerReadinessModuleNotFoundError, +) +from app.career_readiness.routes import add_career_readiness_routes, get_career_readiness_service +from app.career_readiness.service import ICareerReadinessService +from app.career_readiness.types import ( + CareerReadinessConversationResponse, + CareerReadinessMessage, + CareerReadinessMessageSender, + ModuleDetail, + ModuleListResponse, + ModuleStatus, + ModuleSummary, +) +from app.conversations.constants import MAX_MESSAGE_LENGTH +from app.users.auth import UserInfo +from common_libs.test_utilities.mock_auth import MockAuth + +TestClientWithMocks = tuple[TestClient, ICareerReadinessService, UserInfo] + + +def _make_message( + sender: CareerReadinessMessageSender = CareerReadinessMessageSender.AGENT, + message: str = "Hello", +) -> CareerReadinessMessage: + return CareerReadinessMessage( + message_id=str(ObjectId()), + message=message, + sender=sender, + sent_at=datetime.now(timezone.utc), + ) + + +def _make_module_summary(module_id: str = "cv-development") -> ModuleSummary: + return ModuleSummary( + id=module_id, + title="CV Development", + description="Learn to write a CV.", + icon="cv", + status=ModuleStatus.NOT_STARTED, + sort_order=1, + input_placeholder="Ask about CVs...", + ) + + +def _make_conversation_response( + conversation_id: str | None = None, + module_id: str = "cv-development", +) -> CareerReadinessConversationResponse: + return CareerReadinessConversationResponse( + conversation_id=conversation_id or str(ObjectId()), + module_id=module_id, + messages=[_make_message()], + ) + + +@pytest.fixture(scope="function") +def client_with_mocks() -> TestClientWithMocks: + """Create a FastAPI test client with mocked service and auth.""" + + class MockService(ICareerReadinessService): + async def list_modules(self, user_id: str) -> ModuleListResponse: + return ModuleListResponse(modules=[_make_module_summary()]) + + async def get_module(self, user_id: str, module_id: str) -> ModuleDetail: + return ModuleDetail( + id=module_id, + title="CV Development", + description="Learn to write a CV.", + icon="cv", + status=ModuleStatus.NOT_STARTED, + sort_order=1, + input_placeholder="Ask about CVs...", + scope="# CV Content", + ) + + async def create_conversation(self, user_id: str, module_id: str) -> CareerReadinessConversationResponse: + return _make_conversation_response(module_id=module_id) + + async def send_message(self, user_id: str, module_id: str, + conversation_id: str, user_input: str) -> CareerReadinessConversationResponse: + return _make_conversation_response(conversation_id=conversation_id, module_id=module_id) + + async def get_conversation_history(self, user_id: str, module_id: str, + conversation_id: str) -> CareerReadinessConversationResponse: + return _make_conversation_response(conversation_id=conversation_id, module_id=module_id) + + async def delete_conversation(self, user_id: str, module_id: str, conversation_id: str) -> None: + return None + + mock_service = MockService() + mock_auth = MockAuth() + + app = FastAPI() + app.dependency_overrides[get_career_readiness_service] = lambda: mock_service + + add_career_readiness_routes(app, authentication=mock_auth) + + yield TestClient(app), mock_service, mock_auth.mocked_user + app.dependency_overrides = {} + + +class TestListModules: + """Tests for GET /career-readiness/modules.""" + + def test_returns_200_with_modules(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN modules are listed + response = client.get("/career-readiness/modules") + + # THEN 200 OK is returned with modules + assert response.status_code == HTTPStatus.OK + body = response.json() + assert len(body["modules"]) == 1 + assert body["modules"][0]["id"] == "cv-development" + + def test_returns_500_on_unexpected_error(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises an unexpected error + mocker.patch.object(mock_service, "list_modules", side_effect=Exception("Unexpected")) + + # WHEN modules are listed + response = client.get("/career-readiness/modules") + + # THEN 500 is returned + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +class TestGetModule: + """Tests for GET /career-readiness/modules/{module_id}.""" + + def test_returns_200_with_module_detail(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN a module is requested + response = client.get("/career-readiness/modules/cv-development") + + # THEN 200 OK is returned + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["id"] == "cv-development" + + def test_returns_404_when_not_found(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises CareerReadinessModuleNotFoundError + mocker.patch.object(mock_service, "get_module", side_effect=CareerReadinessModuleNotFoundError("nonexistent")) + + # WHEN the module is requested + response = client.get("/career-readiness/modules/nonexistent") + + # THEN 404 is returned + assert response.status_code == HTTPStatus.NOT_FOUND + + +class TestCreateConversation: + """Tests for POST /career-readiness/modules/{module_id}/conversations.""" + + def test_returns_201_on_success(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN a conversation is created + response = client.post("/career-readiness/modules/cv-development/conversations") + + # THEN 201 CREATED is returned + assert response.status_code == HTTPStatus.CREATED + body = response.json() + assert body["module_id"] == "cv-development" + assert len(body["messages"]) == 1 + + def test_returns_404_when_module_not_found(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises CareerReadinessModuleNotFoundError + mocker.patch.object(mock_service, "create_conversation", side_effect=CareerReadinessModuleNotFoundError("nonexistent")) + + # WHEN a conversation is created + response = client.post("/career-readiness/modules/nonexistent/conversations") + + # THEN 404 is returned + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_returns_409_when_already_exists(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, mocked_user = client_with_mocks + + # GIVEN the service raises ConversationAlreadyExistsError + mocker.patch.object(mock_service, "create_conversation", + side_effect=ConversationAlreadyExistsError("cv-development", mocked_user.user_id)) + + # WHEN a conversation is created + response = client.post("/career-readiness/modules/cv-development/conversations") + + # THEN 409 CONFLICT is returned + assert response.status_code == HTTPStatus.CONFLICT + + +class TestSendMessage: + """Tests for POST /career-readiness/modules/{module_id}/conversations/{conversation_id}/messages.""" + + def test_returns_201_on_success(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN a message is sent + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + json={"user_input": "How do I write a CV?"}, + ) + + # THEN 201 CREATED is returned + assert response.status_code == HTTPStatus.CREATED + + def test_returns_404_when_conversation_not_found(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationNotFoundError + mocker.patch.object(mock_service, "send_message", side_effect=ConversationNotFoundError("conv123")) + + # WHEN a message is sent + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + json={"user_input": "Hello"}, + ) + + # THEN 404 is returned + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_returns_403_when_access_denied(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationAccessDeniedError + mocker.patch.object(mock_service, "send_message", + side_effect=ConversationAccessDeniedError("conv123", "other_user")) + + # WHEN a message is sent + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + json={"user_input": "Hello"}, + ) + + # THEN 403 FORBIDDEN is returned + assert response.status_code == HTTPStatus.FORBIDDEN + + def test_returns_413_when_message_too_long(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # GIVEN a message exceeding the maximum length + given_long_message = "a" * (MAX_MESSAGE_LENGTH + 1) + + # WHEN the long message is sent + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + json={"user_input": given_long_message}, + ) + + # THEN 413 REQUEST_ENTITY_TOO_LARGE is returned + assert response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE + + +class TestGetConversationHistory: + """Tests for GET /career-readiness/modules/{module_id}/conversations/{conversation_id}/messages.""" + + def test_returns_200_on_success(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN the conversation history is requested + response = client.get( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + ) + + # THEN 200 OK is returned + assert response.status_code == HTTPStatus.OK + + def test_returns_404_when_not_found(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationNotFoundError + mocker.patch.object(mock_service, "get_conversation_history", + side_effect=ConversationNotFoundError("conv123")) + + # WHEN the conversation history is requested + response = client.get( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + ) + + # THEN 404 is returned + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_returns_403_when_access_denied(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationAccessDeniedError + mocker.patch.object(mock_service, "get_conversation_history", + side_effect=ConversationAccessDeniedError("conv123", "other_user")) + + # WHEN the conversation history is requested + response = client.get( + "/career-readiness/modules/cv-development/conversations/conv123/messages", + ) + + # THEN 403 FORBIDDEN is returned + assert response.status_code == HTTPStatus.FORBIDDEN + + +class TestDeleteConversation: + """Tests for DELETE /career-readiness/modules/{module_id}/conversations/{conversation_id}.""" + + def test_returns_204_on_success(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN a conversation is deleted + response = client.delete( + "/career-readiness/modules/cv-development/conversations/conv123", + ) + + # THEN 204 NO_CONTENT is returned + assert response.status_code == HTTPStatus.NO_CONTENT + + def test_returns_404_when_not_found(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationNotFoundError + mocker.patch.object(mock_service, "delete_conversation", + side_effect=ConversationNotFoundError("conv123")) + + # WHEN the conversation is deleted + response = client.delete( + "/career-readiness/modules/cv-development/conversations/conv123", + ) + + # THEN 404 is returned + assert response.status_code == HTTPStatus.NOT_FOUND + + def test_returns_403_when_access_denied(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationAccessDeniedError + mocker.patch.object(mock_service, "delete_conversation", + side_effect=ConversationAccessDeniedError("conv123", "other_user")) + + # WHEN the conversation is deleted + response = client.delete( + "/career-readiness/modules/cv-development/conversations/conv123", + ) + + # THEN 403 FORBIDDEN is returned + assert response.status_code == HTTPStatus.FORBIDDEN diff --git a/backend/app/career_readiness/test_service.py b/backend/app/career_readiness/test_service.py new file mode 100644 index 00000000..cc78ed20 --- /dev/null +++ b/backend/app/career_readiness/test_service.py @@ -0,0 +1,507 @@ +""" +Tests for the career readiness service layer. +""" +from datetime import datetime, timezone + +import pytest +from bson import ObjectId + +from app.agent.agent_types import AgentOutput, AgentType, LLMStats +from app.career_readiness.agent import CareerReadinessAgent +from app.career_readiness.errors import ( + ConversationAccessDeniedError, + ConversationAlreadyExistsError, + ConversationModuleMismatchError, + ConversationNotFoundError, + CareerReadinessModuleNotFoundError, +) +from app.career_readiness.module_loader import ModuleConfig, ModuleRegistry +from app.career_readiness.repository import ICareerReadinessConversationRepository +from app.career_readiness.service import CareerReadinessService, _build_conversation_context +from app.career_readiness.types import ( + CareerReadinessConversationDocument, + CareerReadinessMessage, + CareerReadinessMessageSender, + ModuleStatus, +) + + +# --------------------------------------------------------------------------- +# Mock helpers +# --------------------------------------------------------------------------- + +def _make_module_config( + module_id: str = "cv-development", + title: str = "CV Development", + sort_order: int = 1, +) -> ModuleConfig: + return ModuleConfig( + id=module_id, + title=title, + description="A test module.", + icon="cv", + sort_order=sort_order, + input_placeholder="Ask about CVs...", + content="# CV Content\nWrite your CV.", + ) + + +def _make_message( + sender: CareerReadinessMessageSender = CareerReadinessMessageSender.AGENT, + message: str = "Hello", +) -> CareerReadinessMessage: + return CareerReadinessMessage( + message_id=str(ObjectId()), + message=message, + sender=sender, + sent_at=datetime.now(timezone.utc), + ) + + +def _make_conversation( + user_id: str = "test_user", + module_id: str = "cv-development", + messages: list[CareerReadinessMessage] | None = None, +) -> CareerReadinessConversationDocument: + now = datetime.now(timezone.utc) + return CareerReadinessConversationDocument( + conversation_id=str(ObjectId()), + module_id=module_id, + user_id=user_id, + messages=messages or [_make_message()], + created_at=now, + updated_at=now, + ) + + +class MockRepository(ICareerReadinessConversationRepository): + """In-memory mock repository for testing.""" + + def __init__(self): + self._conversations: dict[str, CareerReadinessConversationDocument] = {} + + async def create(self, document: CareerReadinessConversationDocument) -> None: + self._conversations[document.conversation_id] = document + + async def find_by_conversation_id(self, conversation_id: str) -> CareerReadinessConversationDocument | None: + return self._conversations.get(conversation_id) + + async def find_by_user_and_module(self, user_id: str, module_id: str) -> CareerReadinessConversationDocument | None: + for conv in self._conversations.values(): + if conv.user_id == user_id and conv.module_id == module_id: + return conv + return None + + async def find_all_by_user(self, user_id: str) -> list[CareerReadinessConversationDocument]: + return [conv for conv in self._conversations.values() if conv.user_id == user_id] + + async def append_message(self, conversation_id: str, message: CareerReadinessMessage) -> None: + conv = self._conversations.get(conversation_id) + if conv: + conv.messages.append(message) + conv.updated_at = datetime.now(timezone.utc) + + async def delete_by_conversation_id(self, conversation_id: str) -> bool: + if conversation_id in self._conversations: + del self._conversations[conversation_id] + return True + return False + + +class MockModuleRegistry(ModuleRegistry): + """Module registry that uses in-memory modules instead of loading from disk.""" + + def __init__(self, modules: list[ModuleConfig] | None = None): + # Skip parent __init__ to avoid loading from disk + self._modules: dict[str, ModuleConfig] = {} + for m in (modules or []): + self._modules[m.id] = m + + +def _make_mock_agent_output(message: str = "Agent response", finished: bool = False) -> AgentOutput: + return AgentOutput( + message_id=str(ObjectId()), + message_for_user=message, + finished=finished, + agent_type=AgentType.CAREER_READINESS_AGENT, + agent_response_time_in_sec=0.5, + llm_stats=[], + sent_at=datetime.now(timezone.utc), + ) + + +class MockCareerReadinessAgent: + """Mock agent that returns canned responses without calling an LLM.""" + + def __init__(self, intro_message: str = "Welcome!", response_message: str = "Agent response"): + self._intro_message = intro_message + self._response_message = response_message + + async def generate_intro_message(self, context): + return _make_mock_agent_output(message=self._intro_message) + + async def execute(self, user_input, context): + return _make_mock_agent_output(message=self._response_message) + + +def _make_mock_agent_factory(agent: MockCareerReadinessAgent | None = None): + """Factory that returns a mock agent.""" + mock_agent = agent or MockCareerReadinessAgent() + + def factory(module_config): + return mock_agent + + return factory + + +def _make_service( + modules: list[ModuleConfig] | None = None, + repository: MockRepository | None = None, + agent: MockCareerReadinessAgent | None = None, +) -> tuple[CareerReadinessService, MockRepository]: + """Helper to create a service with mocked dependencies.""" + repo = repository or MockRepository() + registry = MockModuleRegistry(modules or [_make_module_config()]) + factory = _make_mock_agent_factory(agent) + service = CareerReadinessService(repository=repo, module_registry=registry, agent_factory=factory) + return service, repo + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestBuildConversationContext: + """Tests for the _build_conversation_context helper.""" + + def test_builds_context_from_message_pairs(self): + # GIVEN a list of messages with intro + user/agent pairs + given_messages = [ + _make_message(sender=CareerReadinessMessageSender.AGENT, message="Welcome!"), + _make_message(sender=CareerReadinessMessageSender.USER, message="Help me"), + _make_message(sender=CareerReadinessMessageSender.AGENT, message="Sure!"), + ] + + # WHEN the context is built + actual_context = _build_conversation_context(given_messages) + + # THEN there is one conversation turn (user+agent pair) + assert len(actual_context.history.turns) == 1 + assert actual_context.history.turns[0].input.message == "Help me" + assert actual_context.history.turns[0].output.message_for_user == "Sure!" + + def test_builds_empty_context_from_intro_only(self): + # GIVEN only an intro message + given_messages = [ + _make_message(sender=CareerReadinessMessageSender.AGENT, message="Welcome!"), + ] + + # WHEN the context is built + actual_context = _build_conversation_context(given_messages) + + # THEN the context has no turns + assert len(actual_context.history.turns) == 0 + + def test_builds_context_with_multiple_pairs(self): + # GIVEN a conversation with multiple exchanges + given_messages = [ + _make_message(sender=CareerReadinessMessageSender.AGENT, message="Welcome!"), + _make_message(sender=CareerReadinessMessageSender.USER, message="Q1"), + _make_message(sender=CareerReadinessMessageSender.AGENT, message="A1"), + _make_message(sender=CareerReadinessMessageSender.USER, message="Q2"), + _make_message(sender=CareerReadinessMessageSender.AGENT, message="A2"), + ] + + # WHEN the context is built + actual_context = _build_conversation_context(given_messages) + + # THEN there are two conversation turns + assert len(actual_context.history.turns) == 2 + assert actual_context.history.turns[0].input.message == "Q1" + assert actual_context.history.turns[1].input.message == "Q2" + + +class TestListModules: + """Tests for listing modules with user progress.""" + + @pytest.mark.asyncio + async def test_returns_all_modules_with_not_started_status(self): + # GIVEN a service with two modules and no conversations + given_modules = [ + _make_module_config(module_id="cv-development", sort_order=1), + _make_module_config(module_id="interview-prep", title="Interview Prep", sort_order=2), + ] + service, _ = _make_service(modules=given_modules) + + # WHEN modules are listed for a user + actual_result = await service.list_modules("user_abc") + + # THEN all modules are returned with NOT_STARTED status + assert len(actual_result.modules) == 2 + assert all(m.status == ModuleStatus.NOT_STARTED for m in actual_result.modules) + + @pytest.mark.asyncio + async def test_returns_in_progress_when_conversation_exists(self): + # GIVEN a service with a module and an existing conversation + given_module = _make_module_config(module_id="cv-development") + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN modules are listed for the user + actual_result = await service.list_modules("user_abc") + + # THEN the module with a conversation has IN_PROGRESS status + assert len(actual_result.modules) == 1 + assert actual_result.modules[0].status == ModuleStatus.IN_PROGRESS + + +class TestGetModule: + """Tests for getting module details.""" + + @pytest.mark.asyncio + async def test_returns_module_detail(self): + # GIVEN a service with a module + given_module = _make_module_config() + service, _ = _make_service(modules=[given_module]) + + # WHEN the module is retrieved + actual_result = await service.get_module("user_abc", "cv-development") + + # THEN the module detail is returned + assert actual_result.id == "cv-development" + assert actual_result.title == "CV Development" + assert actual_result.active_conversation_id is None + + @pytest.mark.asyncio + async def test_returns_active_conversation_id(self): + # GIVEN a service with an existing conversation + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN the module is retrieved + actual_result = await service.get_module("user_abc", "cv-development") + + # THEN the active conversation ID is included + assert actual_result.active_conversation_id == given_conversation.conversation_id + assert actual_result.status == ModuleStatus.IN_PROGRESS + + @pytest.mark.asyncio + async def test_raises_module_not_found(self): + # GIVEN a service with no modules + service, _ = _make_service(modules=[]) + + # WHEN a non-existent module is requested + # THEN CareerReadinessModuleNotFoundError is raised + with pytest.raises(CareerReadinessModuleNotFoundError): + await service.get_module("user_abc", "nonexistent") + + +class TestCreateConversation: + """Tests for creating a new conversation.""" + + @pytest.mark.asyncio + async def test_creates_conversation_with_intro_message(self): + # GIVEN a service with a module and a mock agent + given_module = _make_module_config() + given_agent = MockCareerReadinessAgent(intro_message="Welcome to CV Development!") + service, repo = _make_service(modules=[given_module], agent=given_agent) + + # WHEN a conversation is created + actual_result = await service.create_conversation("user_abc", "cv-development") + + # THEN a conversation response is returned with the intro message + assert actual_result.module_id == "cv-development" + assert len(actual_result.messages) == 1 + assert actual_result.messages[0].message == "Welcome to CV Development!" + assert actual_result.messages[0].sender == CareerReadinessMessageSender.AGENT + + @pytest.mark.asyncio + async def test_raises_already_exists_when_duplicate(self): + # GIVEN a service with an existing conversation + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN a duplicate conversation is attempted + # THEN ConversationAlreadyExistsError is raised + with pytest.raises(ConversationAlreadyExistsError): + await service.create_conversation("user_abc", "cv-development") + + @pytest.mark.asyncio + async def test_raises_module_not_found(self): + # GIVEN a service with no modules + service, _ = _make_service(modules=[]) + + # WHEN a conversation is created for a non-existent module + # THEN CareerReadinessModuleNotFoundError is raised + with pytest.raises(CareerReadinessModuleNotFoundError): + await service.create_conversation("user_abc", "nonexistent") + + +class TestSendMessage: + """Tests for sending a message in a conversation.""" + + @pytest.mark.asyncio + async def test_sends_message_and_returns_response(self): + # GIVEN a service with a conversation + given_module = _make_module_config() + given_agent = MockCareerReadinessAgent(response_message="Here is CV advice.") + service, repo = _make_service(modules=[given_module], agent=given_agent) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN a message is sent + actual_result = await service.send_message( + "user_abc", "cv-development", given_conversation.conversation_id, "How do I write a CV?", + ) + + # THEN the response includes the user message and agent response + assert len(actual_result.messages) == 3 # intro + user + agent + assert actual_result.messages[1].message == "How do I write a CV?" + assert actual_result.messages[1].sender == CareerReadinessMessageSender.USER + assert actual_result.messages[2].message == "Here is CV advice." + assert actual_result.messages[2].sender == CareerReadinessMessageSender.AGENT + + @pytest.mark.asyncio + async def test_raises_conversation_not_found(self): + # GIVEN a service with a module but no conversation + given_module = _make_module_config() + service, _ = _make_service(modules=[given_module]) + + # WHEN a message is sent to a non-existent conversation + # THEN ConversationNotFoundError is raised + with pytest.raises(ConversationNotFoundError): + await service.send_message("user_abc", "cv-development", "nonexistent", "Hello") + + @pytest.mark.asyncio + async def test_raises_access_denied_for_wrong_user(self): + # GIVEN a conversation owned by user_abc + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN a different user tries to send a message + # THEN ConversationAccessDeniedError is raised + with pytest.raises(ConversationAccessDeniedError): + await service.send_message( + "other_user", "cv-development", given_conversation.conversation_id, "Hello", + ) + + @pytest.mark.asyncio + async def test_raises_module_mismatch(self): + # GIVEN a conversation for cv-development + given_modules = [ + _make_module_config(module_id="cv-development"), + _make_module_config(module_id="interview-prep", title="Interview Prep", sort_order=2), + ] + service, repo = _make_service(modules=given_modules) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN a message is sent under a different module + # THEN ConversationModuleMismatchError is raised + with pytest.raises(ConversationModuleMismatchError): + await service.send_message( + "user_abc", "interview-prep", given_conversation.conversation_id, "Hello", + ) + + +class TestGetConversationHistory: + """Tests for retrieving conversation history.""" + + @pytest.mark.asyncio + async def test_returns_conversation_history(self): + # GIVEN a conversation with messages + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_messages = [ + _make_message(sender=CareerReadinessMessageSender.AGENT, message="Welcome!"), + _make_message(sender=CareerReadinessMessageSender.USER, message="Hello!"), + ] + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development", messages=given_messages) + await repo.create(given_conversation) + + # WHEN the history is requested + actual_result = await service.get_conversation_history( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + # THEN all messages are returned + assert len(actual_result.messages) == 2 + assert actual_result.conversation_id == given_conversation.conversation_id + + @pytest.mark.asyncio + async def test_raises_conversation_not_found(self): + # GIVEN a service with no conversations + given_module = _make_module_config() + service, _ = _make_service(modules=[given_module]) + + # WHEN a non-existent conversation is requested + # THEN ConversationNotFoundError is raised + with pytest.raises(ConversationNotFoundError): + await service.get_conversation_history("user_abc", "cv-development", "nonexistent") + + @pytest.mark.asyncio + async def test_raises_access_denied_for_wrong_user(self): + # GIVEN a conversation owned by user_abc + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN a different user requests the history + # THEN ConversationAccessDeniedError is raised + with pytest.raises(ConversationAccessDeniedError): + await service.get_conversation_history( + "other_user", "cv-development", given_conversation.conversation_id, + ) + + +class TestDeleteConversation: + """Tests for deleting a conversation.""" + + @pytest.mark.asyncio + async def test_deletes_conversation(self): + # GIVEN a conversation + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN the conversation is deleted + await service.delete_conversation("user_abc", "cv-development", given_conversation.conversation_id) + + # THEN the conversation no longer exists + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is None + + @pytest.mark.asyncio + async def test_raises_conversation_not_found(self): + # GIVEN a service with no conversations + given_module = _make_module_config() + service, _ = _make_service(modules=[given_module]) + + # WHEN a non-existent conversation is deleted + # THEN ConversationNotFoundError is raised + with pytest.raises(ConversationNotFoundError): + await service.delete_conversation("user_abc", "cv-development", "nonexistent") + + @pytest.mark.asyncio + async def test_raises_access_denied_for_wrong_user(self): + # GIVEN a conversation owned by user_abc + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") + await repo.create(given_conversation) + + # WHEN a different user tries to delete + # THEN ConversationAccessDeniedError is raised + with pytest.raises(ConversationAccessDeniedError): + await service.delete_conversation( + "other_user", "cv-development", given_conversation.conversation_id, + ) diff --git a/backend/app/career_readiness/types.py b/backend/app/career_readiness/types.py index af53ef8c..0ee0ad5e 100644 --- a/backend/app/career_readiness/types.py +++ b/backend/app/career_readiness/types.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from enum import Enum -from pydantic import BaseModel, field_serializer, field_validator +from pydantic import BaseModel, Field, field_serializer, field_validator class ModuleStatus(str, Enum): @@ -93,18 +93,6 @@ class Config: extra = "forbid" -class ModuleStatusUpdateRequest(BaseModel): - """ - Request to manually update a module's status. - """ - - status: ModuleStatus - """The new status for the module""" - - class Config: - extra = "forbid" - - class CareerReadinessMessage(BaseModel): """ Represents a single message in a career readiness conversation. @@ -123,15 +111,16 @@ class CareerReadinessMessage(BaseModel): """The sender of the message, either USER or AGENT""" @field_serializer('sent_at') - def serialize_sent_at(self, value: datetime) -> str: + def _serialize_sent_at(self, value: datetime) -> str: return value.astimezone(timezone.utc).isoformat() @field_serializer("sender") - def serialize_sender(self, sender: CareerReadinessMessageSender, _info) -> str: + def _serialize_sender(self, sender: CareerReadinessMessageSender, _info) -> str: return sender.name + @classmethod @field_validator("sender", mode='before') - def deserialize_sender(cls, value: str | CareerReadinessMessageSender) -> CareerReadinessMessageSender: + def _deserialize_sender(cls, value: str | CareerReadinessMessageSender) -> CareerReadinessMessageSender: if isinstance(value, str): return CareerReadinessMessageSender[value] elif isinstance(value, CareerReadinessMessageSender): @@ -174,3 +163,57 @@ class CareerReadinessConversationInput(BaseModel): class Config: extra = "forbid" + + +class CareerReadinessConversationDocument(BaseModel): + """ + Represents a career readiness conversation document in MongoDB. + """ + + conversation_id: str + """The unique identifier for the conversation""" + + module_id: str + """The module this conversation belongs to""" + + user_id: str + """The user who owns this conversation""" + + messages: list[CareerReadinessMessage] = Field(default_factory=list) + """The messages in the conversation""" + + created_at: datetime + """When the conversation was created""" + + updated_at: datetime + """When the conversation was last updated""" + + @field_serializer("created_at", "updated_at") + def _serialize_datetime(self, value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat() + + @classmethod + @field_validator("created_at", "updated_at", mode="before") + def _deserialize_datetime(cls, value: str | datetime) -> datetime: + if isinstance(value, str): + dt = datetime.fromisoformat(value) + elif isinstance(value, datetime): + dt = value + else: + raise ValueError(f"Invalid datetime value: {value}") + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + @staticmethod + def from_dict(_dict: dict) -> "CareerReadinessConversationDocument": + """Convert a MongoDB document dictionary to a typed object.""" + return CareerReadinessConversationDocument( + conversation_id=str(_dict["conversation_id"]), + module_id=str(_dict["module_id"]), + user_id=str(_dict["user_id"]), + messages=[CareerReadinessMessage(**msg) for msg in _dict.get("messages", [])], + created_at=_dict["created_at"], + updated_at=_dict["updated_at"], + ) + + class Config: + extra = "forbid" diff --git a/backend/app/server_dependencies/database_collections.py b/backend/app/server_dependencies/database_collections.py index 69a4e277..5a97bbc8 100644 --- a/backend/app/server_dependencies/database_collections.py +++ b/backend/app/server_dependencies/database_collections.py @@ -19,3 +19,4 @@ class Collections: JOB_PREFERENCES: str = "job_preferences" CAREER_PATH: str = "career_path" USER_RECOMMENDATIONS: str = "user_recommendations" + CAREER_READINESS_CONVERSATIONS: str = "career_readiness_conversations" diff --git a/backend/app/server_dependencies/db_dependencies.py b/backend/app/server_dependencies/db_dependencies.py index 797d1a78..49f17ecc 100644 --- a/backend/app/server_dependencies/db_dependencies.py +++ b/backend/app/server_dependencies/db_dependencies.py @@ -245,6 +245,15 @@ async def initialize_application_mongo_db(application_db: AsyncIOMotorDatabase, ("user_id", 1) ], unique=True) + # Create the career readiness conversations indexes + await application_db.get_collection(Collections.CAREER_READINESS_CONVERSATIONS).create_index([ + ("conversation_id", 1) + ], unique=True) + + await application_db.get_collection(Collections.CAREER_READINESS_CONVERSATIONS).create_index([ + ("user_id", 1), ("module_id", 1) + ], unique=True) + logger.info("Finished creating indexes for the application database") except Exception as e: logger.exception(e) diff --git a/backend/poetry.lock b/backend/poetry.lock index 0c0db53d..022982ca 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiohttp" From 7a86b312d2e778aabecf53f4d889ab330564121d Mon Sep 17 00:00:00 2001 From: Fides Date: Mon, 2 Mar 2026 15:08:34 +0200 Subject: [PATCH 07/42] feat(frontend): add home page with modules, progress, and continue badge --- config/njira.json | 127 +++ frontend-new/public/nijra_logo.svg | 2 +- frontend-new/public/tabiya-logo.svg | 79 ++ frontend-new/public/world-bank-logo.svg | 54 ++ frontend-new/src/app/index.tsx | 10 + frontend-new/src/app/routerPaths.ts | 1 + frontend-new/src/chat/Chat.test.tsx | 24 + frontend-new/src/chat/Chat.tsx | 225 ++--- .../chat/ChatHeader/ChatHeader.stories.tsx | 22 +- .../src/chat/ChatHeader/ChatHeader.test.tsx | 862 ++---------------- .../src/chat/ChatHeader/ChatHeader.tsx | 391 ++------ .../__snapshots__/ChatHeader.test.tsx.snap | 435 ++------- .../src/chat/__snapshots__/Chat.test.tsx.snap | 31 +- .../chat/chatProgressbar/ChatProgressBar.tsx | 6 +- .../hooks/useSentryFeedbackForm.test.ts | 104 +++ .../feedback/hooks/useSentryFeedbackForm.ts | 80 ++ frontend-new/src/home/Home.stories.tsx | 32 + frontend-new/src/home/Home.test.tsx | 129 +++ frontend-new/src/home/Home.tsx | 130 +++ .../home/components/Footer/Footer.stories.tsx | 14 + .../home/components/Footer/Footer.test.tsx | 69 ++ .../src/home/components/Footer/Footer.tsx | 159 ++++ .../Footer/__snapshots__/Footer.test.tsx.snap | 78 ++ .../ModuleCard/ModuleCard.stories.tsx | 55 ++ .../components/ModuleCard/ModuleCard.test.tsx | 97 ++ .../home/components/ModuleCard/ModuleCard.tsx | 142 +++ .../__snapshots__/ModuleCard.test.tsx.snap | 51 ++ .../PageHeader/PageHeader.stories.tsx | 27 + .../components/PageHeader/PageHeader.test.tsx | 322 +++++++ .../home/components/PageHeader/PageHeader.tsx | 457 ++++++++++ .../ProgressBar/ProgressBar.stories.tsx | 48 + .../ProgressBar/ProgressBar.test.tsx | 57 ++ .../components/ProgressBar/ProgressBar.tsx | 98 ++ .../__snapshots__/ProgressBar.test.tsx.snap | 46 + .../src/home/hooks/useModuleProgress.test.ts | 134 +++ .../src/home/hooks/useModuleProgress.ts | 120 +++ frontend-new/src/home/modulesService.ts | 52 ++ .../src/i18n/locales/en-GB/translation.json | 35 + .../src/i18n/locales/en-US/translation.json | 35 + .../src/i18n/locales/es-AR/translation.json | 35 + .../src/i18n/locales/es-ES/translation.json | 35 + .../src/i18n/locales/ny-ZM/translation.json | 35 + .../src/i18n/locales/sw-KE/translation.json | 35 + 43 files changed, 3385 insertions(+), 1595 deletions(-) create mode 100644 config/njira.json create mode 100644 frontend-new/public/tabiya-logo.svg create mode 100644 frontend-new/public/world-bank-logo.svg create mode 100644 frontend-new/src/feedback/hooks/useSentryFeedbackForm.test.ts create mode 100644 frontend-new/src/feedback/hooks/useSentryFeedbackForm.ts create mode 100644 frontend-new/src/home/Home.stories.tsx create mode 100644 frontend-new/src/home/Home.test.tsx create mode 100644 frontend-new/src/home/Home.tsx create mode 100644 frontend-new/src/home/components/Footer/Footer.stories.tsx create mode 100644 frontend-new/src/home/components/Footer/Footer.test.tsx create mode 100644 frontend-new/src/home/components/Footer/Footer.tsx create mode 100644 frontend-new/src/home/components/Footer/__snapshots__/Footer.test.tsx.snap create mode 100644 frontend-new/src/home/components/ModuleCard/ModuleCard.stories.tsx create mode 100644 frontend-new/src/home/components/ModuleCard/ModuleCard.test.tsx create mode 100644 frontend-new/src/home/components/ModuleCard/ModuleCard.tsx create mode 100644 frontend-new/src/home/components/ModuleCard/__snapshots__/ModuleCard.test.tsx.snap create mode 100644 frontend-new/src/home/components/PageHeader/PageHeader.stories.tsx create mode 100644 frontend-new/src/home/components/PageHeader/PageHeader.test.tsx create mode 100644 frontend-new/src/home/components/PageHeader/PageHeader.tsx create mode 100644 frontend-new/src/home/components/ProgressBar/ProgressBar.stories.tsx create mode 100644 frontend-new/src/home/components/ProgressBar/ProgressBar.test.tsx create mode 100644 frontend-new/src/home/components/ProgressBar/ProgressBar.tsx create mode 100644 frontend-new/src/home/components/ProgressBar/__snapshots__/ProgressBar.test.tsx.snap create mode 100644 frontend-new/src/home/hooks/useModuleProgress.test.ts create mode 100644 frontend-new/src/home/hooks/useModuleProgress.ts create mode 100644 frontend-new/src/home/modulesService.ts diff --git a/config/njira.json b/config/njira.json new file mode 100644 index 00000000..1631cbd2 --- /dev/null +++ b/config/njira.json @@ -0,0 +1,127 @@ +{ + "branding": { + "appName": "Njira", + "browserTabTitle": "Njira", + "metaDescription": "Welcome to Njira. An AI-powered career assistant that helps jobseekers identify and showcase their skills. Join now to create a digital profile and connect with new opportunities.", + "seo": { + "name": "Njira", + "url": "https://www.example.org/njira", + "image": "https://www.example.org/assets/logo.svg", + "description": "Njira is an AI-powered conversational tool that helps jobseekers discover and articulate their skills. Through natural dialogue, Njira guides users to identify abilities gained through all types of work." + }, + "assets": { + "logo": "/nijra_logo.svg", + "favicon": "/nijra_logo.svg", + "appIcon": "/nijra_logo.svg" + }, + "theme": { + "brand-primary": "239 123 0", + "brand-primary-light": "250 166 39", + "brand-primary-dark": "233 108 0", + "brand-primary-contrast-text": "0 0 0", + "brand-secondary": "25 138 0", + "brand-secondary-light": "87 183 69", + "brand-secondary-dark": "1 121 0", + "brand-secondary-contrast-text": "255 255 255", + "text-primary": "0 33 71", + "text-secondary": "65 64 61", + "text-accent": "38 94 167" + } + }, + "auth": { + "disableLoginCode": true, + "disableRegistrationCode": true + }, + "cv": { + "enabled": true + }, + "skillsReport": { + "logos": [ + { + "url": "/logo.png", + "docxStyles": { + "width": 250, + "height": 62 + }, + "pdfStyles": { + "height": 46 + } + } + ], + "downloadFormats": [ + "DOCX", + "PDF" + ], + "report": { + "summary": { + "show": true + }, + "experienceDetails": { + "title": true, + "summary": true, + "location": true, + "dateRange": true, + "companyName": true + } + } + }, + "i18n": { + "ui": { + "defaultLocale": "en-US", + "supportedLocales": [ + "en-US", + "en-GB", + "ny-ZM" + ] + }, + "conversation": { + "default_locale": "en-US", + "available_locales": [ + { + "locale": "en-US", + "date_format": "YYYY/MM/DD" + } + ] + } + }, + "modules": { + "enabled": true, + "list": [ + { + "id": "skills_discovery", + "labelKey": "home.modules.skillsDiscovery", + "descriptionKey": "home.modules.skillsDiscoveryDesc", + "route": "skills-discovery", + "isStub": false + }, + { + "id": "career_discovery", + "labelKey": "home.modules.careerDiscovery", + "descriptionKey": "home.modules.careerDiscoveryDesc", + "route": "career-discovery", + "isStub": false + }, + { + "id": "job_readiness", + "labelKey": "home.modules.jobReadiness", + "descriptionKey": "home.modules.jobReadinessDesc", + "route": "job-readiness", + "isStub": true + }, + { + "id": "career_explorer", + "labelKey": "home.modules.careerExplorer", + "descriptionKey": "home.modules.careerExplorerDesc", + "route": "career-explorer", + "isStub": true + }, + { + "id": "knowledge_hub", + "labelKey": "home.modules.knowledgeHub", + "descriptionKey": "home.modules.knowledgeHubDesc", + "route": "knowledge-hub", + "isStub": true + } + ] + } +} \ No newline at end of file diff --git a/frontend-new/public/nijra_logo.svg b/frontend-new/public/nijra_logo.svg index f9c19b1b..e8caad10 100644 --- a/frontend-new/public/nijra_logo.svg +++ b/frontend-new/public/nijra_logo.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend-new/public/tabiya-logo.svg b/frontend-new/public/tabiya-logo.svg new file mode 100644 index 00000000..70dd8ba5 --- /dev/null +++ b/frontend-new/public/tabiya-logo.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + diff --git a/frontend-new/public/world-bank-logo.svg b/frontend-new/public/world-bank-logo.svg new file mode 100644 index 00000000..485b2334 --- /dev/null +++ b/frontend-new/public/world-bank-logo.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + diff --git a/frontend-new/src/app/index.tsx b/frontend-new/src/app/index.tsx index 90cd4a6e..718168af 100644 --- a/frontend-new/src/app/index.tsx +++ b/frontend-new/src/app/index.tsx @@ -24,6 +24,7 @@ import { TokenValidationFailureCause } from "src/auth/services/Authentication.se import { AuthBroadcastChannel, AuthChannelMessage } from "src/auth/services/authBroadcastChannel/authBroadcastChannel"; import { getRegistrationDisabled } from "src/envService"; import { useTranslation } from "react-i18next"; +import Home from "src/home/Home"; const LazyLoadedSensitiveDataForm = lazyWithPreload( () => import("src/sensitiveData/components/sensitiveDataForm/SensitiveDataForm") @@ -41,6 +42,7 @@ export const SNACKBAR_KEYS = { const ProtectedRouteKeys = { ROOT: "ROOT", + SKILLS_INTERESTS: "SKILLS_INTERESTS", LANDING: "LANDING", SETTINGS: "SETTINGS", REGISTER: "REGISTER", @@ -250,6 +252,14 @@ const App = () => { path: routerPaths.ROOT, element: ( + + + ), + }, + { + path: routerPaths.SKILLS_INTERESTS, + element: ( + ), diff --git a/frontend-new/src/app/routerPaths.ts b/frontend-new/src/app/routerPaths.ts index 6c99220a..1281fe4d 100644 --- a/frontend-new/src/app/routerPaths.ts +++ b/frontend-new/src/app/routerPaths.ts @@ -8,4 +8,5 @@ export const routerPaths = { VERIFY_EMAIL: "/verify-email", CONSENT: "/consent", SENSITIVE_DATA: "/sensitive-data", + SKILLS_INTERESTS: "/skills-interests", }; diff --git a/frontend-new/src/chat/Chat.test.tsx b/frontend-new/src/chat/Chat.test.tsx index 82bac2c8..5cf46c02 100644 --- a/frontend-new/src/chat/Chat.test.tsx +++ b/frontend-new/src/chat/Chat.test.tsx @@ -10,6 +10,7 @@ import Chat, { } from "src/chat/Chat"; import i18n from "src/i18n/i18n"; import ChatHeader, { DATA_TEST_ID as CHAT_HEADER_TEST_ID } from "src/chat/ChatHeader/ChatHeader"; +import PageHeader, { DATA_TEST_ID as PAGE_HEADER_TEST_ID } from "src/home/components/PageHeader/PageHeader"; import ChatList, { DATA_TEST_ID as CHAT_LIST_TEST_ID } from "src/chat/chatList/ChatList"; import ChatMessageField, { DATA_TEST_ID as CHAT_MESSAGE_FIELD_TEST_ID, @@ -72,6 +73,19 @@ jest.mock("src/theme/SnackbarProvider/SnackbarProvider", () => { }; }); +// mock the PageHeader component +jest.mock("src/home/components/PageHeader/PageHeader", () => { + const actual = jest.requireActual("src/home/components/PageHeader/PageHeader"); + const mockPageHeader = jest + .fn() + .mockImplementation(() =>
); + return { + __esModule: true, + default: mockPageHeader, + DATA_TEST_ID: actual.DATA_TEST_ID, + }; +}); + // mock the ChatHeader component jest.mock("src/chat/ChatHeader/ChatHeader", () => { const actual = jest.requireActual("src/chat/ChatHeader/ChatHeader"); @@ -361,6 +375,16 @@ describe("Chat", () => { await assertChatInitialized(); // AND expect the container to be visible expect(screen.getByTestId(DATA_TEST_ID.CHAT_CONTAINER)).toBeVisible(); + // AND expect the page header to be visible + expect(screen.getByTestId(PAGE_HEADER_TEST_ID.PAGE_HEADER_CONTAINER)).toBeInTheDocument(); + // AND expect PageHeader to be called with correct props + expect(PageHeader as jest.Mock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "home.modules.skillsDiscovery", + subtitle: "home.modules.skillsDiscoverySubtitle", + }), + {} + ); // AND expect the chat header to be visible expect(screen.getByTestId(CHAT_HEADER_TEST_ID.CHAT_HEADER_CONTAINER)).toBeInTheDocument(); // AND expect the chat list to be visible diff --git a/frontend-new/src/chat/Chat.tsx b/frontend-new/src/chat/Chat.tsx index 279706b4..b72a3037 100644 --- a/frontend-new/src/chat/Chat.tsx +++ b/frontend-new/src/chat/Chat.tsx @@ -16,10 +16,11 @@ import { } from "./util"; import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; import { Box, useTheme } from "@mui/material"; -import ChatHeader from "./ChatHeader/ChatHeader"; -import ChatMessageField from "./ChatMessageField/ChatMessageField"; import { useNavigate } from "react-router-dom"; import { routerPaths } from "src/app/routerPaths"; +import ChatHeader from "./ChatHeader/ChatHeader"; +import ChatMessageField from "./ChatMessageField/ChatMessageField"; +import PageHeader from "src/home/components/PageHeader/PageHeader"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; import { ConversationMessage, ConversationMessageSender, ConversationResponse } from "./ChatService/ChatService.types"; import { Backdrop } from "src/theme/Backdrop/Backdrop"; @@ -28,7 +29,6 @@ import { DiveInPhase, Experience } from "src/experiences/experienceService/exper import ExperienceService from "src/experiences/experienceService/experienceService"; import InactiveBackdrop from "src/theme/Backdrop/InactiveBackdrop"; import ConfirmModalDialog from "src/theme/confirmModalDialog/ConfirmModalDialog"; -import AuthenticationServiceFactory from "src/auth/services/Authentication.service.factory"; import { ChatError, MetricsError } from "src/error/commonErrors"; import authenticationStateService from "src/auth/services/AuthenticationState.service"; import { issueNewSession } from "./issueNewSession"; @@ -110,6 +110,7 @@ export const Chat: React.FC> = ({ const theme = useTheme(); const { t } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); const [messages, setMessages] = useState[]>([]); const [conversationCompleted, setConversationCompleted] = useState(false); const [exploredExperiences, setExploredExperiences] = useState(0); @@ -119,7 +120,6 @@ export const Chat: React.FC> = ({ const [experiences, setExperiences] = React.useState([]); const [prefillMessage, setPrefillMessage] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); - const [isLoggingOut, setIsLoggingOut] = React.useState(false); const [showBackdrop, setShowBackdrop] = useState(showInactiveSessionAlert); const [lastActivityTime, setLastActivityTime] = React.useState(Date.now()); const [newConversationDialog, setNewConversationDialog] = React.useState(false); @@ -139,8 +139,6 @@ export const Chat: React.FC> = ({ Map >(new Map()); - const navigate = useNavigate(); - const initializingRef = useRef(false); const [initialized, setInitialized] = useState(false); @@ -304,17 +302,6 @@ export const Chat: React.FC> = ({ await fetchExperiences(); }, [fetchExperiences]); - // Goes to the authentication service to log the user out - // Navigates to the login page - const handleLogout = useCallback(async () => { - setIsLoggingOut(true); - const authenticationService = AuthenticationServiceFactory.getCurrentAuthenticationService(); - await authenticationService!.logout(); - navigate(routerPaths.LANDING, { replace: true }); - enqueueSnackbar(t("chat.chat.notifications.logoutSuccess"), { variant: "success" }); - setIsLoggingOut(false); - }, [enqueueSnackbar, navigate, t]); - // Helper to stop polling and cleanup const stopPollingForUpload = useCallback( (uploadId: string, intervalId?: NodeJS.Timeout, timeoutId?: NodeJS.Timeout) => { @@ -1017,34 +1004,56 @@ export const Chat: React.FC> = ({ return ( }> - {isLoggingOut ? ( - - ) : ( - + { + // expect(screen.getByTestId(DATA_TEST_ID.CHAT_CONTAINER)).toHaveAttribute("is-initialized", "true"); + // }); + // This technique can solve the "Warning: An update to Chat inside a test was not wrapped in act(...)" warning. + is-initialized={`${initialized}`} > + navigate(routerPaths.ROOT)} + /> { - // expect(screen.getByTestId(DATA_TEST_ID.CHAT_CONTAINER)).toHaveAttribute("is-initialized", "true"); - // }); - // This technique can solve the "Warning: An update to Chat inside a test was not wrapped in act(...)" warning. - is-initialized={`${initialized}`} + sx={{ + paddingTop: theme.fixedSpacing(theme.tabiyaSpacing.sm), + paddingBottom: theme.fixedSpacing(theme.tabiyaSpacing.md), + paddingX: theme.spacing(theme.tabiyaSpacing.md), + display: "flex", + alignItems: "center", + width: { xs: "100%", md: "60%" }, + margin: { xs: "0", md: "0 auto" }, + gap: theme.spacing(theme.tabiyaSpacing.sm), + }} > - + + + + setNewConversationDialog(true)} experiencesExplored={exploredExperiencesCount.length} exploredExperiencesNotification={exploredExperiencesNotification} @@ -1056,79 +1065,71 @@ export const Chat: React.FC> = ({ collectedExperiences={experiences?.length} /> - - - - - - - {showBackdrop && } - - 0} - onUploadCv={handleUploadCv} - currentPhase={currentPhase.phase} - prefillMessage={prefillMessage} - cvUploadError={cvUploadError} - /> - - - {newConversationDialog && ( - - {t("chat.chat.startNewConversationDialog.content")} -
-
- {t("chat.chat.startNewConversationDialog.confirmation")} - - } - onCancel={() => setNewConversationDialog(false)} - onConfirm={handleConfirmNewConversation} - onDismiss={() => setNewConversationDialog(false)} - cancelButtonText={t("common.buttons.cancel")} - confirmButtonText={t("common.buttons.confirm")} - /> - )} - {showRefreshConfirmDialog && ( - - {t("chat.chat.refreshConfirmationDialog.content")} -
-
- {t("chat.chat.refreshConfirmationDialog.question")} - - } - onCancel={handleCancelRefresh} - onConfirm={handleConfirmRefresh} - onDismiss={handleCancelRefresh} - cancelButtonText={t("chat.chat.refreshConfirmationDialog.waitButton")} - confirmButtonText={t("chat.chat.refreshConfirmationDialog.refreshButton")} + + + + {showBackdrop && } + + 0} + onUploadCv={handleUploadCv} + currentPhase={currentPhase.phase} + prefillMessage={prefillMessage} + cvUploadError={cvUploadError} /> - )} -
- )} + + + + {newConversationDialog && ( + + {t("chat.chat.startNewConversationDialog.content")} +
+
+ {t("chat.chat.startNewConversationDialog.confirmation")} + + } + onCancel={() => setNewConversationDialog(false)} + onConfirm={handleConfirmNewConversation} + onDismiss={() => setNewConversationDialog(false)} + cancelButtonText={t("common.buttons.cancel")} + confirmButtonText={t("common.buttons.confirm")} + /> + )} + {showRefreshConfirmDialog && ( + + {t("chat.chat.refreshConfirmationDialog.content")} +
+
+ {t("chat.chat.refreshConfirmationDialog.question")} + + } + onCancel={handleCancelRefresh} + onConfirm={handleConfirmRefresh} + onDismiss={handleCancelRefresh} + cancelButtonText={t("chat.chat.refreshConfirmationDialog.waitButton")} + confirmButtonText={t("chat.chat.refreshConfirmationDialog.refreshButton")} + /> + )} +
); }; diff --git a/frontend-new/src/chat/ChatHeader/ChatHeader.stories.tsx b/frontend-new/src/chat/ChatHeader/ChatHeader.stories.tsx index f92fba7f..927c9b2f 100644 --- a/frontend-new/src/chat/ChatHeader/ChatHeader.stories.tsx +++ b/frontend-new/src/chat/ChatHeader/ChatHeader.stories.tsx @@ -9,8 +9,8 @@ const meta: Meta = { component: ChatHeader, tags: ["autodocs"], argTypes: { - notifyOnLogout: { action: "notifyOnLogout" }, setExploredExperiencesNotification: { action: "setExploredExperiencesNotification" }, + startNewConversation: { action: "startNewConversation" }, }, decorators: [ (Story) => { @@ -60,23 +60,3 @@ export const ShownWithFeedbackNotification: Story = { timeUntilNotification: 0, }, }; - -export const AnonymousUserLogoutConfirmation: Story = { - decorators: [ - (Story) => { - authenticationStateService.getInstance().getUser = () => ({ - id: "123", - name: "", - email: "", - }); - return Story(); - }, - ], - args: { - exploredExperiencesNotification: false, - experiencesExplored: 0, - conversationCompleted: false, - progressPercentage: 0, - timeUntilNotification: null, - }, -}; diff --git a/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx b/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx index 89632f65..ee01f1f0 100644 --- a/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx +++ b/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx @@ -5,22 +5,12 @@ import "src/_test_utilities/envServiceMock"; // standard sentry mock import "src/_test_utilities/sentryMock"; -import ChatHeader, { DATA_TEST_ID, MENU_ITEM_ID } from "./ChatHeader"; -import i18n from "src/i18n/i18n"; -import { act, fireEvent, render, screen, userEvent, waitFor, within } from "src/_test_utilities/test-utils"; -import { routerPaths } from "src/app/routerPaths"; -import { testNavigateToPath } from "src/_test_utilities/routeNavigation"; -import ContextMenu, { DATA_TEST_ID as CONTEXT_MENU_DATA_TEST_ID } from "src/theme/ContextMenu/ContextMenu"; -import { MenuItemConfig } from "src/theme/ContextMenu/menuItemConfig.types"; +import ChatHeader, { DATA_TEST_ID } from "./ChatHeader"; +import { fireEvent, render, screen, userEvent, waitFor } from "src/_test_utilities/test-utils"; import { mockBrowserIsOnLine } from "src/_test_utilities/mockBrowserIsOnline"; import { DATA_TEST_ID as ANIMATED_BADGE_DATA_TEST_ID } from "src/theme/AnimatedBadge/AnimatedBadge"; import AuthenticationStateService from "src/auth/services/AuthenticationState.service"; import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks"; -import AnonymousAccountConversionDialog, { - DATA_TEST_ID as ANONYMOUS_ACCOUNT_CONVERSION_DIALOG_DATA_TEST_ID, -} from "src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog"; -import { DATA_TEST_ID as CONFIRM_MODAL_DATA_TEST_ID } from "src/theme/confirmModalDialog/ConfirmModalDialog"; -import { DATA_TEST_ID as TEXT_CONFIRM_MODAL_DIALOG_DATA_TEST_ID } from "src/theme/textConfirmModalDialog/TextConfirmModalDialog"; import { ChatProvider } from "src/chat/ChatContext"; import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import * as Sentry from "@sentry/react"; @@ -32,30 +22,10 @@ import { SessionError } from "src/error/commonErrors"; import { ConversationPhase } from "src/chat/chatProgressbar/types"; import MetricsService from "src/metrics/metricsService"; import { EventType } from "src/metrics/types"; -import * as EnvServiceModule from "src/envService"; // Mock PersistentStorageService jest.mock("src/app/PersistentStorageService/PersistentStorageService"); -// mock the ContextMenu -jest.mock("src/theme/ContextMenu/ContextMenu", () => { - const actual = jest.requireActual("src/theme/ContextMenu/ContextMenu"); - return { - __esModule: true, - default: jest.fn(({ items }: { items: MenuItemConfig[] }) => ( -
- {items.map((item) => ( -
- {item.text} -
- ))} - ; -
- )), - DATA_TEST_ID: actual.DATA_TEST_ID, - }; -}); - // mock the router jest.mock("react-router-dom", () => { const actual = jest.requireActual("react-router-dom"); @@ -78,18 +48,6 @@ jest.mock("src/auth/services/FirebaseAuthenticationService/socialAuth/FirebaseSo }; }); -// mock the AnonymousAccountConversionDialog -jest.mock("src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog", () => { - const actual = jest.requireActual( - "src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog" - ); - return { - __esModule: true, - ...actual, - default: jest.fn(() =>
), - }; -}); - // mock the snackbar jest.mock("src/theme/SnackbarProvider/SnackbarProvider", () => { const actual = jest.requireActual("src/theme/SnackbarProvider/SnackbarProvider"); @@ -115,7 +73,6 @@ describe("ChatHeader", () => { beforeEach(() => { // set sentry as uninitialized by default (Sentry.isInitialized as jest.Mock).mockReturnValue(false); - (ContextMenu as jest.Mock).mockClear(); resetAllMethodMocks(UserPreferencesStateService.getInstance()); jest.clearAllMocks(); }); @@ -124,16 +81,11 @@ describe("ChatHeader", () => { ["exploredExperiencesNotification shown", true], ["exploredExperiencesNotification not shown", false], ])("should render the Chat Header with %s", (desc, givenExploredExperiencesNotification) => { - // GIVEN the application name is set - const appName = "foobar"; - jest.spyOn(EnvServiceModule, "getProductName").mockReturnValue(appName); // GIVEN a ChatHeader component - const givenNotifyOnLogout = jest.fn(); const givenStartNewConversation = jest.fn(); const givenNumberOfExploredExperiences = 1; const givenChatHeader = ( { expect(console.warn).not.toHaveBeenCalled(); // AND the chat header to be visible expect(screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_CONTAINER)).toBeInTheDocument(); - // AND the chat header logo to be visible - expect(screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_LOGO)).toBeInTheDocument(); - // AND the Compass text to be visible - expect(screen.getByText(appName)).toBeInTheDocument(); - // AND the user button to be shown with the user icon - const chatHeaderButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - expect(chatHeaderButton).toBeInTheDocument(); - const chatHeaderUserIcon = within(chatHeaderButton).getByTestId(DATA_TEST_ID.CHAT_HEADER_ICON_USER); - expect(chatHeaderUserIcon).toBeInTheDocument(); - // AND the experiences button to be shown with the experiences icon - const chatHeaderExperiencesButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_EXPERIENCES); - expect(chatHeaderExperiencesButton).toBeInTheDocument(); - const chatHeaderExperiencesIcon = within(chatHeaderExperiencesButton).getByTestId( - DATA_TEST_ID.CHAT_HEADER_ICON_EXPERIENCES - ); - expect(chatHeaderExperiencesIcon).toBeInTheDocument(); - // AND to match the snapshot - expect(screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_CONTAINER)).toMatchSnapshot(); - }); - - test("should show feedback button when sentry is initialized", () => { - // GIVEN sentry is initialized - (Sentry.isInitialized as jest.Mock).mockReturnValue(true); - - // AND the product name is mocked - jest.spyOn(EnvServiceModule, "getProductName").mockReturnValue("foobar"); - - // WHEN the chat header is rendered - const givenChatHeader = ( - - ); - renderWithChatProvider(givenChatHeader); - // THEN the feedback button to be shown with the feedback icon - const chatHeaderFeedbackButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_FEEDBACK); - expect(chatHeaderFeedbackButton).toBeInTheDocument(); - const chatHeaderFeedbackIcon = within(chatHeaderFeedbackButton).getByTestId(DATA_TEST_ID.CHAT_HEADER_ICON_FEEDBACK); - expect(chatHeaderFeedbackIcon).toBeInTheDocument(); + // AND the view experiences button to be visible + const viewExperiencesButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_EXPERIENCES); + expect(viewExperiencesButton).toBeInTheDocument(); // AND to match the snapshot expect(screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_CONTAINER)).toMatchSnapshot(); }); describe("chatHeader action tests", () => { - const givenNotifyOnLogout = jest.fn(); const givenStartNewConversation = jest.fn(); - const givenRemoveMessage = jest.fn(); - const givenAddMessage = jest.fn(); - const givenChatHeader = ( - - - - ); - testNavigateToPath(givenChatHeader, "Compass Logo", DATA_TEST_ID.CHAT_HEADER_LOGO_LINK, routerPaths.ROOT); - - test("should open the context menu when the user icon is clicked", async () => { - // GIVEN a ChatHeader component - const givenNotifyOnLogout = jest.fn(); - const givenChatHeader = ( - - ); - // AND the chat header is rendered - renderWithChatProvider(givenChatHeader); - - // WHEN the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - await userEvent.click(userButton); - - // THEN expect the context menus to be visible (language selector + user menu) - const contextMenus = screen.getAllByTestId(CONTEXT_MENU_DATA_TEST_ID.MENU); - expect(contextMenus.length).toBeGreaterThanOrEqual(2); - // AND the context menu to be open and anchored to the user button - await waitFor(() => { - expect(ContextMenu).toHaveBeenCalledWith( - expect.objectContaining({ - anchorEl: userButton, - open: true, - }), - {} - ); - }); - }); - test("should call start new conversation when the start new conversation menu item is clicked", async () => { + test("should call handleOpenExperiencesDrawer when the view experiences button is clicked", async () => { // GIVEN a ChatHeader component - const givenNotifyOnLogout = jest.fn(); - const givenStartNewConversation = jest.fn(); + const givenNotifyOnExperiencesDrawerOpen = jest.fn(); const givenChatHeader = ( { collectedExperiences={1} /> ); - // AND the chat header is rendered - renderWithChatProvider(givenChatHeader); - - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - fireEvent.click(userButton); - - // AND the context menu is opened - await waitFor(() => { - expect(ContextMenu).toHaveBeenCalledWith( - expect.objectContaining({ - anchorEl: userButton, - open: true, - }), - {} - ); - }); - - // WHEN the start new conversation menu item is clicked - const startNewConversationMenuItem = screen.getByTestId(MENU_ITEM_ID.START_NEW_CONVERSATION); - fireEvent.click(startNewConversationMenuItem); - - // THEN expect the start new conversation function to be called - expect(givenStartNewConversation).toHaveBeenCalled(); - }); - - test("should close the context menu when notifyOnClose is called", async () => { - // GIVEN a ChatHeader component - const givenNotifyOnLogout = jest.fn(); - const givenChatHeader = ( - + render( + + {givenChatHeader} + ); - // AND the chat header is rendered - renderWithChatProvider(givenChatHeader); - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - fireEvent.click(userButton); - // WHEN the notifyOnClose is called - act(() => { - const mock = (ContextMenu as jest.Mock).mock; - mock.lastCall[0].notifyOnClose(); - }); + // WHEN the view experiences button is clicked + const viewExperiencesButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_EXPERIENCES); + await userEvent.click(viewExperiencesButton); - // THEN expect the auth menu to be closed - /** - - the component renders for the first time - - then the menu is opened. 2nd render - - then the menu is closed. 3rd render - **/ - expect(ContextMenu).toHaveBeenNthCalledWith(3, expect.objectContaining({ anchorEl: null, open: false }), {}); + // THEN expect handleOpenExperiencesDrawer to be called + expect(givenNotifyOnExperiencesDrawerOpen).toHaveBeenCalled(); }); - test("should call notifyOnExperiencesDrawerOpen when the experiences button is clicked", async () => { + test("should call handleOpenExperiencesDrawer and send metrics when view experiences button is clicked", async () => { // GIVEN a ChatHeader component const givenNotifyOnExperiencesDrawerOpen = jest.fn(); - const givenRemoveMessage = jest.fn(); - const givenAddMessage = jest.fn(); - // AND a logged-in user for metrics const mockUser = { id: "foo123", name: "Foo", email: "foo@bar" }; jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(mockUser); const metricsSpy = jest.spyOn(MetricsService.getInstance(), "sendMetricsEvent").mockReturnValue(); const givenChatHeader = ( { collectedExperiences={1} /> ); - // AND the chat header is rendered render( {givenChatHeader} ); - // WHEN the experiences button is clicked - const experiencesButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_EXPERIENCES); - fireEvent.click(experiencesButton); + // WHEN the view experiences button is clicked + const viewExperiencesButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_EXPERIENCES); + fireEvent.click(viewExperiencesButton); - // THEN expect notifyOnExperiencesDrawerOpen to be called + // THEN expect handleOpenExperiencesDrawer to be called expect(givenNotifyOnExperiencesDrawerOpen).toHaveBeenCalled(); // AND the metrics event to be sent expect(metricsSpy).toHaveBeenCalledWith( @@ -425,7 +216,6 @@ describe("ChatHeader", () => { // WHEN the component is rendered const givenChatHeader = ( { ); renderWithChatProvider(givenChatHeader); - // THEN expect the notification badge to be shown + // THEN expect the notification badge to be shown on the view experiences button const badge = screen.getByTestId(ANIMATED_BADGE_DATA_TEST_ID.ANIMATED_BADGE); expect(badge).toBeInTheDocument(); - // AND the badge icon to be displayed - const badgeIcon = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_ICON_EXPERIENCES); - expect(badgeIcon).toBeInTheDocument(); // AND the badge content to be displayed expect(screen.getByText(givenExploredExperiences)).toBeInTheDocument(); }); - test("should close the notification badge when the experiences button is clicked", async () => { - // GIVEN experience explored + test("should call setExploredExperiencesNotification(false) when view experiences button is clicked", async () => { + // GIVEN experience explored and notification shown const givenExploredExperiences = 1; - // AND the notification badge - const givenExploredExperiencesNotification = false; - // AND the notifyOnExperiencesDrawerOpen function + const givenExploredExperiencesNotification = true; const givenNotifyOnExperiencesDrawerOpen = jest.fn(); - const givenRemoveMessage = jest.fn(); - const givenAddMessage = jest.fn(); - // AND the component is rendered + const setExploredExperiencesNotification = jest.fn(); const givenChatHeader = ( { collectedExperiences={1} /> ); - // renderWithChatProvider(givenChatHeader); render( {givenChatHeader} ); - // WHEN the experiences button is clicked - const experiencesButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_EXPERIENCES); - userEvent.click(experiencesButton); + // WHEN the view experiences button is clicked + const viewExperiencesButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_EXPERIENCES); + await userEvent.click(viewExperiencesButton); - // THEN expect notifyOnExperiencesDrawerOpen to be called + // THEN expect handleOpenExperiencesDrawer to be called await waitFor(() => { expect(givenNotifyOnExperiencesDrawerOpen).toHaveBeenCalled(); }); - // AND expect the notification badge content to be hidden - await waitFor(() => { - expect(screen.queryByText(givenExploredExperiences)).not.toBeInTheDocument(); - }); - }); - - test("should open sentry bug report form when report a bug button is clicked", async () => { - // GIVEN the browser is online - mockBrowserIsOnLine(true); - // AND sentry is initialized - (Sentry.isInitialized as jest.Mock).mockReturnValue(true); - // AND a mock for Sentry.getFeedback - const mockForm = { - appendToDom: jest.fn(), - open: jest.fn(), - }; - const mockCreateForm = jest.fn().mockResolvedValue(mockForm); - (Sentry.getFeedback as jest.Mock).mockReturnValue({ createForm: mockCreateForm }); - - // WHEN the chat header is rendered - const givenChatHeader = ( - - ); - renderWithChatProvider(givenChatHeader); - // AND the report a bug button is clicked - const reportABugButton = screen.getByTestId(MENU_ITEM_ID.REPORT_BUG_BUTTON); - await userEvent.click(reportABugButton); - - // THEN expect the mock create form to be called - expect(mockCreateForm).toHaveBeenCalled(); - // AND the form should be appended to DOM and opened - expect(mockForm.appendToDom).toHaveBeenCalled(); - expect(mockForm.open).toHaveBeenCalled(); - }); - - test("should open sentry feedback form when feedback button is clicked", async () => { - // GIVEN the browser is online - mockBrowserIsOnLine(true); - // AND sentry is initialized - (Sentry.isInitialized as jest.Mock).mockReturnValue(true); - // AND a mock for Sentry.getFeedback - const mockForm = { - appendToDom: jest.fn(), - open: jest.fn(), - }; - const mockCreateForm = jest.fn().mockResolvedValue(mockForm); - (Sentry.getFeedback as jest.Mock).mockReturnValue({ createForm: mockCreateForm }); - - // WHEN the chat header is rendered - const givenChatHeader = ( - - ); - renderWithChatProvider(givenChatHeader); - // AND the sentry feedback button is clicked - const feedbackButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_FEEDBACK); - await userEvent.click(feedbackButton); - - // THEN expect the create form to be called with the correct parameters - expect(mockCreateForm).toHaveBeenCalledWith({ - formTitle: i18n.t("chat.chatHeader.giveGeneralFeedback"), - nameLabel: i18n.t("chat.chatHeader.nameLabel"), - namePlaceholder: i18n.t("chat.chatHeader.namePlaceholder"), - emailLabel: i18n.t("chat.chatHeader.emailLabel"), - emailPlaceholder: i18n.t("chat.chatHeader.emailPlaceholder"), - isRequiredLabel: i18n.t("chat.chatHeader.requiredLabel"), - messageLabel: i18n.t("chat.chatHeader.descriptionLabel"), - messagePlaceholder: i18n.t("chat.chatHeader.feedbackMessagePlaceholder"), - addScreenshotButtonLabel: i18n.t("chat.chatHeader.addScreenshot"), - submitButtonLabel: i18n.t("chat.chatHeader.sendFeedback"), - cancelButtonLabel: i18n.t("chat.chatHeader.cancelButton"), - successMessageText: i18n.t("chat.chatHeader.feedbackSuccessMessage"), - }); - // AND the form should be appended to DOM and opened - expect(mockForm.appendToDom).toHaveBeenCalled(); - expect(mockForm.open).toHaveBeenCalled(); + // AND the notification to be cleared + expect(setExploredExperiencesNotification).toHaveBeenCalledWith(false); }); }); - describe("context menu item tests", () => { + describe("view experiences button", () => { test.each([ ["online", true], ["chat.chatMessageField.placeholders.offline", false], ])( - "should render the context menu with the correct menu items when browser is %s", + "should render the view experiences button disabled when browser is %s", async (_description, browserIsOnline) => { mockBrowserIsOnLine(browserIsOnline); // GIVEN a ChatHeader component - const givenNotifyOnLogout = jest.fn(); const givenStartNewConversation = jest.fn(); const givenChatHeader = ( { ); // AND the chat header is rendered renderWithChatProvider(givenChatHeader); - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - fireEvent.click(userButton); - // THEN expect the context menu to be visible - await waitFor(() => { - expect(ContextMenu).toHaveBeenCalledWith( - expect.objectContaining({ - anchorEl: userButton, - open: true, - items: expect.arrayContaining([ - expect.objectContaining({ - id: MENU_ITEM_ID.START_NEW_CONVERSATION, - text: "start new conversation", - disabled: !browserIsOnline, - }), - expect.objectContaining({ - id: MENU_ITEM_ID.LOGOUT_BUTTON, - text: "logout", - disabled: false, - }), - ]), - }), - {} - ); - }); - // AND the context menu to contain the correct menu items - const contextMenus = screen.getAllByTestId(CONTEXT_MENU_DATA_TEST_ID.MENU); - // User menu is the second context menu (index 1), language menu is the first (index 0) - const userContextMenu = contextMenus[1]; - expect(userContextMenu).toBeInTheDocument(); - expect(screen.getByTestId(MENU_ITEM_ID.START_NEW_CONVERSATION)).toBeInTheDocument(); - expect(screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON)).toBeInTheDocument(); + // THEN the view experiences button is in the document and has the correct disabled state + const viewExperiencesButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_EXPERIENCES); + expect(viewExperiencesButton).toBeInTheDocument(); + expect(viewExperiencesButton).toHaveProperty("disabled", !browserIsOnline); } ); }); - describe("register button tests", () => { - const mockRegisteredUser = { - id: "test-id", - name: "Test User", - email: "test@example.com", - }; - - // Anonymous users have no name or email - const mockAnonymousUser = { - id: "anonymous-id", - name: "", - email: "", - }; - - beforeEach(() => { - (ContextMenu as jest.Mock).mockClear(); - resetAllMethodMocks(AuthenticationStateService.getInstance()); - }); - - test("should show register button in menu for anonymous user", async () => { - // GIVEN an anonymous user - jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValueOnce(mockAnonymousUser); - - // AND a ChatHeader component - const givenChatHeader = ( - - ); - - // WHEN the chat header is rendered - renderWithChatProvider(givenChatHeader); - - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - await userEvent.click(userButton); - - // THEN expect the register button to be visible in the menu - await waitFor(() => { - expect(ContextMenu).toHaveBeenCalledWith( - expect.objectContaining({ - anchorEl: userButton, - open: true, - items: expect.arrayContaining([ - expect.objectContaining({ - id: MENU_ITEM_ID.REGISTER, - text: "register", - disabled: expect.any(Boolean), - }), - expect.anything(), - ]), - }), - {} - ); - }); - }); - - test("should not show register button in menu for registered user", async () => { - // GIVEN a registered user ( user with a name and email ) - jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValueOnce(mockRegisteredUser); - - // AND a ChatHeader component - const givenChatHeader = ( - - ); - - // WHEN the chat header is rendered - renderWithChatProvider(givenChatHeader); - - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - await userEvent.click(userButton); - - // THEN expect the register button to not be visible in the menu - await waitFor(() => { - expect(ContextMenu).toHaveBeenCalledWith( - expect.objectContaining({ - anchorEl: userButton, - open: true, - items: expect.arrayContaining([ - expect.not.objectContaining({ - id: MENU_ITEM_ID.REGISTER, - text: "register", - disabled: expect.any(Boolean), - }), - ]), - }), - {} - ); - }); - }); - - test("should open conversion dialog when register button is clicked", async () => { - // GIVEN an anonymous user - jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValueOnce(mockAnonymousUser); - - // AND a ChatHeader component - const givenChatHeader = ( - - ); - - // WHEN the chat header is rendered - renderWithChatProvider(givenChatHeader); - - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - await userEvent.click(userButton); - - // AND the register button is clicked - const registerMenuItem = screen.getByTestId(MENU_ITEM_ID.REGISTER); - await userEvent.click(registerMenuItem); - - // THEN expect the conversion dialog to be opened - expect(screen.getByTestId(ANONYMOUS_ACCOUNT_CONVERSION_DIALOG_DATA_TEST_ID.DIALOG)).toBeInTheDocument(); - }); - - test("should show success message and set account converted state when conversion is successful", async () => { - // GIVEN an anonymous user - jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValueOnce(mockAnonymousUser); - - // AND a ChatHeader component - const givenChatHeader = ( - - - - ); - - // WHEN the chat header is rendered - renderWithChatProvider(givenChatHeader); - - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - await userEvent.click(userButton); - - // AND the register button is clicked - const registerMenuItem = screen.getByTestId(MENU_ITEM_ID.REGISTER); - await userEvent.click(registerMenuItem); - - // AND the conversion is successful - (AnonymousAccountConversionDialog as jest.Mock).mock.calls[0][0].onSuccess(); - - // THEN expect the account conversion flag to be set - expect(PersistentStorageService.setAccountConverted).toHaveBeenCalledWith(true); - }); - - test("should close conversion dialog when onClose function is called", async () => { - // GIVEN an anonymous user - jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValueOnce(mockAnonymousUser); - // AND a ChatHeader component - const givenChatHeader = ( - - - - ); - - // WHEN the chat header is rendered - renderWithChatProvider(givenChatHeader); - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - await userEvent.click(userButton); - // AND the register button is clicked - const registerMenuItem = screen.getByTestId(MENU_ITEM_ID.REGISTER); - await userEvent.click(registerMenuItem); - - // THEN expect the conversion dialog to be opened - const conversionDialog = screen.getByTestId(ANONYMOUS_ACCOUNT_CONVERSION_DIALOG_DATA_TEST_ID.DIALOG); - expect(conversionDialog).toBeInTheDocument(); - - // WHEN the onClose function is called - const onClose = (AnonymousAccountConversionDialog as jest.Mock).mock.calls[0][0].onClose; - act(() => { - onClose(); - }); - - // THEN expect the dialog to be hidden - expect(AnonymousAccountConversionDialog).toHaveBeenCalledWith( - expect.objectContaining({ isOpen: false }), - expect.anything() - ); - }); - }); - describe("Feedback notification", () => { beforeEach(() => { jest.useFakeTimers(); @@ -924,7 +337,6 @@ describe("ChatHeader", () => { // WHEN the component is rendered renderWithChatProvider( { // WHEN the component is rendered renderWithChatProvider( { expect(console.error).not.toHaveBeenCalled(); }); + test("should mark feedback notification as seen when reminder link opens feedback form", async () => { + // GIVEN mock user + const mockUser = { id: "123", name: "Foo Bar", email: "foo@bar.baz" }; + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(mockUser); + // AND sentry feedback form available + (Sentry.isInitialized as jest.Mock).mockReturnValue(true); + const appendToDom = jest.fn(); + const open = jest.fn(); + const createForm = jest.fn().mockResolvedValue({ appendToDom, open }); + (Sentry.getFeedback as jest.Mock).mockReturnValue({ createForm }); + const setSeenSpy = jest.spyOn(PersistentStorageService, "setSeenFeedbackNotification").mockImplementation(); + + // WHEN the feedback reminder is shown immediately + renderWithChatProvider( + + ); + jest.advanceTimersByTime(0); + + // AND the reminder link is clicked + const [snackbarContent] = (useSnackbar().enqueueSnackbar as jest.Mock).mock.calls[0]; + render(snackbarContent); + fireEvent.click(screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_FEEDBACK_LINK)); + + // THEN the feedback form opens and the reminder is marked as seen + await waitFor(() => { + expect(createForm).toHaveBeenCalledTimes(1); + }); + expect(appendToDom).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledTimes(1); + expect(setSeenSpy).toHaveBeenCalledWith(mockUser.id); + }); + test("should not show feedback notification if conversation progress is > 66%", () => { // GIVEN mock user const mockUser = { id: "123", name: "", email: "" }; @@ -1001,7 +454,6 @@ describe("ChatHeader", () => { // WHEN the component is rendered renderWithChatProvider( { collectedExperiences={1} /> ); - // AND the time is advanced by the given time until notification - jest.advanceTimersByTime(givenTimeUntilNotification); + // AND the time is advanced by the feedback notification delay + jest.advanceTimersByTime(FEEDBACK_NOTIFICATION_DELAY); // THEN expect no notification to be shown expect(useSnackbar().enqueueSnackbar).not.toHaveBeenCalled(); @@ -1035,7 +487,6 @@ describe("ChatHeader", () => { // WHEN the component is rendered renderWithChatProvider( { expect(console.error).not.toHaveBeenCalled(); }); }); - - describe("Logout button tests", () => { - // Common setup - const mockAnonymousUser = { id: "anonymous-id", name: "", email: "" }; - const mockRegisteredUser = { id: "123", name: "Foo Bar", email: "foo@bar.baz" }; - - const renderChatHeader = (props = {}) => { - const defaultProps = { - notifyOnLogout: jest.fn(), - startNewConversation: jest.fn(), - experiencesExplored: 0, - exploredExperiencesNotification: false, - setExploredExperiencesNotification: jest.fn(), - conversationCompleted: false, - progressPercentage: 0, - timeUntilNotification: null, - conversationPhase: ConversationPhase.INTRO, - collectedExperiences: 1, - ...props, - }; - - return renderWithChatProvider(); - }; - - test("should show confirmation dialog for anonymous users when logging out", async () => { - // GIVEN an anonymous user - jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(mockAnonymousUser); - - // WHEN the chat header is rendered - renderChatHeader(); - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - await userEvent.click(userButton); - // AND the logout button is clicked - const logoutMenuItem = screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON); - await userEvent.click(logoutMenuItem); - - // THEN expect the confirmation dialog to be shown - expect(screen.getByTestId(TEXT_CONFIRM_MODAL_DIALOG_DATA_TEST_ID.TEXT_CONFIRM_MODAL_CONTENT)).toBeInTheDocument(); - }); - - test("should directly logout registered users without confirmation", async () => { - // GIVEN a registered user - jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(mockRegisteredUser); - const notifyOnLogout = jest.fn(); - - // WHEN the chat header is rendered - renderChatHeader({ notifyOnLogout }); - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - await userEvent.click(userButton); - // AND the logout button is clicked - const logoutMenuItem = screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON); - await userEvent.click(logoutMenuItem); - - // THEN expect logout to be called - expect(notifyOnLogout).toHaveBeenCalled(); - // AND the dialog to not be shown - expect( - screen.queryByTestId(TEXT_CONFIRM_MODAL_DIALOG_DATA_TEST_ID.TEXT_CONFIRM_MODAL_CONTENT) - ).not.toBeInTheDocument(); - }); - - test("should proceed with logout when user confirms from dialog", async () => { - // GIVEN an anonymous user - jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(mockAnonymousUser); - const notifyOnLogout = jest.fn(); - - // WHEN the chat header is rendered - renderChatHeader({ notifyOnLogout }); - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - await userEvent.click(userButton); - // AND the logout button is clicked - const logoutMenuItem = screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON); - await userEvent.click(logoutMenuItem); - // AND the user confirms logout - const logoutButton = screen.getByTestId(CONFIRM_MODAL_DATA_TEST_ID.CONFIRM_MODAL_CANCEL); - await userEvent.click(logoutButton); - - // THEN expect logout to be called - expect(notifyOnLogout).toHaveBeenCalled(); - }); - - test("should show registration dialog when user chooses to register from logout confirmation", async () => { - // GIVEN an anonymous user - jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(mockAnonymousUser); - - // WHEN the chat header is rendered - renderChatHeader(); - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - await userEvent.click(userButton); - // AND the logout button is clicked - const logoutMenuItem = screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON); - await userEvent.click(logoutMenuItem); - // AND the user chooses to register - const registerButton = screen.getByTestId(CONFIRM_MODAL_DATA_TEST_ID.CONFIRM_MODAL_CONFIRM); - await userEvent.click(registerButton); - - // THEN expect the registration dialog to be shown - expect(screen.getByTestId(ANONYMOUS_ACCOUNT_CONVERSION_DIALOG_DATA_TEST_ID.DIALOG)).toBeInTheDocument(); - }); - - test("should close logout confirmation dialog when close icon is clicked", async () => { - // GIVEN an anonymous user - jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(mockAnonymousUser); - - // WHEN the chat header is rendered - renderChatHeader(); - // AND the user button is clicked - const userButton = screen.getByTestId(DATA_TEST_ID.CHAT_HEADER_BUTTON_USER); - await userEvent.click(userButton); - // AND the logout button is clicked - const logoutMenuItem = screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON); - await userEvent.click(logoutMenuItem); - - // THEN expect the logout confirmation dialog to be opened - const logoutConfirmationDialog = screen.getByTestId(CONFIRM_MODAL_DATA_TEST_ID.CONFIRM_MODAL); - expect(logoutConfirmationDialog).toBeInTheDocument(); - - // WHEN the close icon is clicked - const closeIcon = screen.getByTestId(CONFIRM_MODAL_DATA_TEST_ID.CONFIRM_MODAL_CLOSE); - await userEvent.click(closeIcon); - - // THEN expect the dialog to be closed - await waitFor(() => { - expect(logoutConfirmationDialog).not.toBeInTheDocument(); - }); - }); - }); }); diff --git a/frontend-new/src/chat/ChatHeader/ChatHeader.tsx b/frontend-new/src/chat/ChatHeader/ChatHeader.tsx index 4f7805fb..1ebe2c19 100644 --- a/frontend-new/src/chat/ChatHeader/ChatHeader.tsx +++ b/frontend-new/src/chat/ChatHeader/ChatHeader.tsx @@ -1,34 +1,22 @@ -import React, { SetStateAction, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import React, { SetStateAction, useCallback, useContext, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Box, Typography, useTheme } from "@mui/material"; -import { NavLink } from "react-router-dom"; -import { routerPaths } from "src/app/routerPaths"; -import { MenuItemConfig } from "src/theme/ContextMenu/menuItemConfig.types"; import BadgeOutlinedIcon from "@mui/icons-material/BadgeOutlined"; -import FeedbackOutlinedIcon from "@mui/icons-material/FeedbackOutlined"; import PrimaryIconButton from "src/theme/PrimaryIconButton/PrimaryIconButton"; import AnimatedBadge from "src/theme/AnimatedBadge/AnimatedBadge"; -import ContextMenu from "src/theme/ContextMenu/ContextMenu"; import { IsOnlineContext } from "src/app/isOnlineProvider/IsOnlineProvider"; -import * as Sentry from "@sentry/react"; -import AnonymousAccountConversionDialog from "src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog"; import authenticationStateService from "src/auth/services/AuthenticationState.service"; import { useChatContext } from "src/chat/ChatContext"; -import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; -import CustomLink from "src/theme/CustomLink/CustomLink"; -import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import { MetricsError, SessionError } from "src/error/commonErrors"; -import TextConfirmModalDialog from "src/theme/textConfirmModalDialog/TextConfirmModalDialog"; -import { HighlightedSpan } from "src/consent/components/consentPage/Consent"; import MetricsService from "src/metrics/metricsService"; import { EventType } from "src/metrics/types"; import { ConversationPhase } from "src/chat/chatProgressbar/types"; -import LanguageContextMenu from "src/i18n/languageContextMenu/LanguageContextMenu"; -import { getProductName } from "src/envService"; -import { getAppIconUrl } from "src/envService"; +import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; +import CustomLink from "src/theme/CustomLink/CustomLink"; +import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; +import { useSentryFeedbackForm } from "src/feedback/hooks/useSentryFeedbackForm"; export type ChatHeaderProps = { - notifyOnLogout: () => void; startNewConversation: () => void; experiencesExplored: number; exploredExperiencesNotification: boolean; @@ -43,28 +31,19 @@ export type ChatHeaderProps = { const uniqueId = "7413b63a-887b-4f41-b930-89e9770db12b"; export const DATA_TEST_ID = { CHAT_HEADER_CONTAINER: `chat-header-container-${uniqueId}`, - CHAT_HEADER_LOGO: `chat-header-logo-${uniqueId}`, - CHAT_HEADER_LOGO_LINK: `chat-header-logo-link-${uniqueId}`, - CHAT_HEADER_ICON_USER: `chat-header-icon-user-${uniqueId}`, - CHAT_HEADER_BUTTON_USER: `chat-header-button-user-${uniqueId}`, - CHAT_HEADER_ICON_EXPERIENCES: `chat-header-icon-experiences-${uniqueId}`, + CHAT_HEADER_BUTTON_MENU: `chat-header-button-menu-${uniqueId}`, CHAT_HEADER_BUTTON_EXPERIENCES: `chat-header-button-experiences-${uniqueId}`, - CHAT_HEADER_BUTTON_FEEDBACK: `chat-header-button-feedback-${uniqueId}`, - CHAT_HEADER_ICON_FEEDBACK: `chat-header-icon-feedback-${uniqueId}`, + CHAT_HEADER_ICON_EXPERIENCES: `chat-header-icon-experiences-${uniqueId}`, CHAT_HEADER_FEEDBACK_LINK: `chat-header-feedback-link-${uniqueId}`, }; export const MENU_ITEM_ID = { - SETTINGS_SELECTOR: `settings-selector-${uniqueId}`, - LOGOUT_BUTTON: `logout-button-${uniqueId}`, START_NEW_CONVERSATION: `start-new-conversation-${uniqueId}`, - REPORT_BUG_BUTTON: `report-bug-button-${uniqueId}`, - REGISTER: `register-${uniqueId}`, + VIEW_EXPERIENCES: `view-experiences-${uniqueId}`, }; const ChatHeader: React.FC> = ({ - notifyOnLogout, - startNewConversation, + startNewConversation: _startNewConversation, // kept for when start-conversation menu is re-enabled (see commented code below) experiencesExplored, exploredExperiencesNotification, setExploredExperiencesNotification, @@ -76,113 +55,20 @@ const ChatHeader: React.FC> = ({ }) => { const theme = useTheme(); const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); - const [showConversionDialog, setShowConversionDialog] = useState(false); - const [showLogoutConfirmation, setShowLogoutConfirmation] = useState(false); - const feedbackTimerRef = React.useRef(null); - const notificationShownRef = React.useRef(false); + // const [anchorEl, setAnchorEl] = useState(null); // for context menu when start-conversation is re-enabled + const feedbackTimerRef = useRef(null); + const notificationShownRef = useRef(false); const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const { openFeedbackForm } = useSentryFeedbackForm(); const isOnline = useContext(IsOnlineContext); - const user = authenticationStateService.getInstance().getUser(); - const isAnonymous = !user?.name || !user?.email; - const { setIsAccountConverted, handleOpenExperiencesDrawer } = useChatContext(); - const [sentryEnabled, setSentryEnabled] = useState(false); - - const logoUrlFromEnv = getAppIconUrl(); - const logoSrc = logoUrlFromEnv || `${process.env.PUBLIC_URL}/compass.svg`; - - const handleLogout = useCallback(() => { - if (isAnonymous) { - setShowLogoutConfirmation(true); - } else { - notifyOnLogout(); - } - }, [isAnonymous, notifyOnLogout]); - - const handleConfirmLogout = () => { - setShowLogoutConfirmation(false); - notifyOnLogout(); - }; - - const handleRegister = () => { - setShowLogoutConfirmation(false); - setShowConversionDialog(true); - }; - - const handleViewExperiences = () => { - handleOpenExperiencesDrawer(); - setExploredExperiencesNotification(false); - try { - const user_id = authenticationStateService.getInstance().getUser()?.id; - if (!user_id) { - console.error(new MetricsError("Unable to send Experiences and Skills view metrics: user id is missing")); - return; - } - - MetricsService.getInstance().sendMetricsEvent({ - event_type: EventType.UI_INTERACTION, - user_id: user_id, - actions: ["view_experiences_and_skills"], - element_id: DATA_TEST_ID.CHAT_HEADER_BUTTON_EXPERIENCES, - timestamp: new Date().toISOString(), - relevant_experiments: {}, - details: { - conversation_phase: conversationPhase, - experiences_explored: experiencesExplored, - collected_experiences: collectedExperiences, - }, - }); - } catch (error) { - console.error(new MetricsError(`Unable to send Experiences and Skills view metrics: ${error}`)); - } - }; - - useEffect(() => { - setSentryEnabled(Sentry.isInitialized()); - }, []); - - const feedbackFormLabels = useMemo( - () => ({ - formTitle: t("chat.chatHeader.giveGeneralFeedback"), - nameLabel: t("chat.chatHeader.nameLabel"), - namePlaceholder: t("chat.chatHeader.namePlaceholder"), - emailLabel: t("chat.chatHeader.emailLabel"), - emailPlaceholder: t("chat.chatHeader.emailPlaceholder"), - isRequiredLabel: t("chat.chatHeader.requiredLabel"), - messageLabel: t("chat.chatHeader.descriptionLabel"), - messagePlaceholder: t("chat.chatHeader.feedbackMessagePlaceholder"), - addScreenshotButtonLabel: t("chat.chatHeader.addScreenshot"), - submitButtonLabel: t("chat.chatHeader.sendFeedback"), - cancelButtonLabel: t("chat.chatHeader.cancelButton"), - successMessageText: t("chat.chatHeader.feedbackSuccessMessage"), - }), - [t] - ); + const { handleOpenExperiencesDrawer } = useChatContext(); const handleGiveFeedback = useCallback(async () => { - if (!sentryEnabled) { - console.debug("Sentry is not initialized, feedback form cannot be created."); - return; - } - try { - const feedback = Sentry.getFeedback(); - if (feedback) { - const form = await feedback.createForm(feedbackFormLabels); - form.appendToDom(); - form.open(); - // Set feedback notification as seen when user opens the feedback form - const user = authenticationStateService.getInstance().getUser(); - if (user) { - PersistentStorageService.setSeenFeedbackNotification(user.id); - } - } - } catch (error) { - console.error("Error creating feedback form:", error); - } - }, [sentryEnabled, feedbackFormLabels]); + await openFeedbackForm({ markNotificationSeen: true }); + }, [openFeedbackForm]); - // Show notification after 30 minutes if conversation is not completed + // Show notification after 30 minutes if the conversation is not completed useEffect(() => { const user = authenticationStateService.getInstance().getUser(); if (!user) { @@ -196,7 +82,7 @@ const ChatHeader: React.FC> = ({ feedbackTimerRef.current = null; } - // Don't set timer if conversation is completed, notification already shown, or no time was given + // Don't set a timer if conversation is completed, notification already shown, or no time was given if ( conversationCompleted || PersistentStorageService.hasSeenFeedbackNotification(user.id) || @@ -257,180 +143,99 @@ const ChatHeader: React.FC> = ({ t, ]); - const contextMenuItems: MenuItemConfig[] = useMemo( - () => [ - { - id: MENU_ITEM_ID.START_NEW_CONVERSATION, - text: t("common.buttons.startNewConversation").toLowerCase(), - disabled: !isOnline, - action: startNewConversation, - }, - // Temporarily removed "Settings" menu item; not useful to users at the moment. - // Will be added back once it has meaningful functionality. - // { - // id: MENU_ITEM_ID.SETTINGS_SELECTOR, - // text: t("common.buttons.settings").toLowerCase(), - // disabled: !isOnline, - // action: () => setIsDrawerOpen(true), - // }, - ...(sentryEnabled - ? [ - { - id: MENU_ITEM_ID.REPORT_BUG_BUTTON, - text: t("feedback.bugReport.reportBug").toLowerCase(), - disabled: !isOnline, - action: () => { - const feedback = Sentry.getFeedback(); - if (feedback) { - feedback.createForm(feedbackFormLabels).then((form) => { - if (form) { - form.appendToDom(); - form.open(); // shows the feedback form - } - }); - } - }, - }, - ] - : []), - ...(isAnonymous - ? [ - { - id: MENU_ITEM_ID.REGISTER, - text: t("common.buttons.register").toLowerCase(), - disabled: !isOnline, - action: () => setShowConversionDialog(true), - }, - ] - : []), - { - id: MENU_ITEM_ID.LOGOUT_BUTTON, - text: t("common.buttons.logout").toLowerCase(), - disabled: false, - action: handleLogout, - }, - ], - [isAnonymous, isOnline, startNewConversation, sentryEnabled, handleLogout, t, feedbackFormLabels] - ); + const handleViewExperiences = useCallback(() => { + handleOpenExperiencesDrawer(); + setExploredExperiencesNotification(false); + try { + const user_id = authenticationStateService.getInstance().getUser()?.id; + if (!user_id) { + console.error(new MetricsError("Unable to send Experiences and Skills view metrics: user id is missing")); + return; + } + + MetricsService.getInstance().sendMetricsEvent({ + event_type: EventType.UI_INTERACTION, + user_id: user_id, + actions: ["view_experiences_and_skills"], + element_id: DATA_TEST_ID.CHAT_HEADER_BUTTON_EXPERIENCES, + timestamp: new Date().toISOString(), + relevant_experiments: {}, + details: { + conversation_phase: conversationPhase, + experiences_explored: experiencesExplored, + collected_experiences: collectedExperiences, + }, + }); + } catch (error) { + console.error(new MetricsError(`Unable to send Experiences and Skills view metrics: ${error}`)); + } + }, [ + handleOpenExperiencesDrawer, + setExploredExperiencesNotification, + conversationPhase, + experiencesExplored, + collectedExperiences, + ]); - const productName = getProductName(); + // commented out for now, not shown in UI + // const contextMenuItems: MenuItemConfig[] = useMemo( + // () => [ + // { + // id: MENU_ITEM_ID.START_NEW_CONVERSATION, + // text: t("common.buttons.startNewConversation").toLowerCase(), + // disabled: !isOnline, + // action: startNewConversation, + // }, + // { + // id: MENU_ITEM_ID.VIEW_EXPERIENCES, + // text: t("chat.chatHeader.viewExperiences").toLowerCase(), + // disabled: !isOnline, + // action: handleViewExperiences, + // }, + // ], + // [isOnline, startNewConversation, handleViewExperiences, t] + // ); return ( - - - {t("app.compassLogoAlt")} - - {productName} - + {/* Start conversation commented out; only show view experiences */} + {/* setAnchorEl(event.currentTarget)} + data-testid={DATA_TEST_ID.CHAT_HEADER_BUTTON_MENU} + title={t("common.buttons.startNewConversation").toLowerCase()} > - - - - - - {sentryEnabled && ( - - - - )} - - setAnchorEl(event.currentTarget)} - data-testid={DATA_TEST_ID.CHAT_HEADER_BUTTON_USER} - title={t("chat.chatHeader.userInfo").toLowerCase()} - > - {t("chat.chatHeader.userIconAlt")} - - + + + setAnchorEl(null)} items={contextMenuItems} - /> - setShowConversionDialog(false)} - onSuccess={() => { - setIsAccountConverted(true); + /> */} + - setShowLogoutConfirmation(false)} - onConfirm={handleRegister} - title={t("chat.chatHeader.beforeYouGo")} - confirmButtonText={t("common.buttons.register")} - cancelButtonText={t("common.buttons.logout")} - showCloseIcon={true} - textParagraphs={[ - { - id: "1", - text: <>{t("chat.chatHeader.logoutConfirmationMessage")}, - }, - { - id: "2", - text: ( - <> - {t("chat.chatHeader.anonymousAccountWarning")} - {t("chat.chatHeader.logoutWarningAnonymous")}. - - ), - }, - { - id: "3", - text: ( - <> - {t("chat.chatHeader.createAccountToSaveProgress")}{" "} - {t("chat.chatHeader.continueYourJourneyLater")} - - ), - }, - ]} - /> + onClick={handleViewExperiences} + data-testid={DATA_TEST_ID.CHAT_HEADER_BUTTON_EXPERIENCES} + title={t("chat.chatHeader.viewExperiences").toLowerCase()} + disabled={!isOnline} + > + + + + ); }; diff --git a/frontend-new/src/chat/ChatHeader/__snapshots__/ChatHeader.test.tsx.snap b/frontend-new/src/chat/ChatHeader/__snapshots__/ChatHeader.test.tsx.snap index 061e9426..d67748e9 100644 --- a/frontend-new/src/chat/ChatHeader/__snapshots__/ChatHeader.test.tsx.snap +++ b/frontend-new/src/chat/ChatHeader/__snapshots__/ChatHeader.test.tsx.snap @@ -2,423 +2,98 @@ exports[`ChatHeader should render the Chat Header with exploredExperiencesNotification not shown 1`] = `
- - {{appName}} Logo - -

- foobar -

-
- - -
-
- English (US) -
- ; -
- -
-
-
- start new conversation -
-
- logout -
- ; -
-
+ +
`; exports[`ChatHeader should render the Chat Header with exploredExperiencesNotification shown 1`] = `
- - {{appName}} Logo - -

- foobar -

-
- - -
-
- English (US) -
- ; -
- -
-
-
- start new conversation -
-
- logout -
- ; -
-
-
-`; - -exports[`ChatHeader should show feedback button when sentry is initialized 1`] = ` -
- - {{appName}} Logo - -

- foobar -

-
- - - -
-
- English (US) -
- ; -
- -
-
-
- start new conversation -
-
- report a bug -
-
- logout -
- ; -
-
+ +
`; diff --git a/frontend-new/src/chat/__snapshots__/Chat.test.tsx.snap b/frontend-new/src/chat/__snapshots__/Chat.test.tsx.snap index 23d25d2f..c9350683 100644 --- a/frontend-new/src/chat/__snapshots__/Chat.test.tsx.snap +++ b/frontend-new/src/chat/__snapshots__/Chat.test.tsx.snap @@ -7,22 +7,29 @@ exports[`Chat render tests should render the Chat component with all its childre is-initialized="true" >
-
-
+ data-testid="page-header-container-b2e4c1a8-3f5d-4e6a-9c7b-1d2e3f4a5b6c" + />
+
+ COLLECT_EXPERIENCES + - + 50 + % +
+
+
- COLLECT_EXPERIENCES - - - 50 - % +
= (currentPhase) => { display: "flex", flexDirection: "column", gap: theme.fixedSpacing(theme.tabiyaSpacing.xs), - // to match the width of the message list container - [theme.breakpoints.up("md")]: { - width: "60%", - margin: "auto", - }, + width: "100%", }} > { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateForm.mockResolvedValue({ + appendToDom: jest.fn(), + open: jest.fn(), + }); + mockGetFeedback.mockReturnValue({ createForm: mockCreateForm }); + (Sentry.getFeedback as jest.Mock).mockImplementation(mockGetFeedback); + (Sentry.isInitialized as jest.Mock).mockReturnValue(false); + }); + + describe("sentryEnabled", () => { + test("should return sentryEnabled false when Sentry is not initialized", async () => { + // GIVEN Sentry.isInitialized returns false + (Sentry.isInitialized as jest.Mock).mockReturnValue(false); + + // WHEN the hook is run + const { result } = renderHook(() => useSentryFeedbackForm()); + + // THEN sentryEnabled is false (after effect runs) + await waitFor(() => { + expect(result.current.sentryEnabled).toBe(false); + }); + }); + + test("should return sentryEnabled true when Sentry is initialized", async () => { + // GIVEN Sentry.isInitialized returns true + (Sentry.isInitialized as jest.Mock).mockReturnValue(true); + + // WHEN the hook is run + const { result } = renderHook(() => useSentryFeedbackForm()); + + // THEN sentryEnabled is true + await waitFor(() => { + expect(result.current.sentryEnabled).toBe(true); + }); + }); + }); + + describe("openFeedbackForm", () => { + test("should return false when Sentry is not initialized", async () => { + // GIVEN Sentry is not initialized + (Sentry.isInitialized as jest.Mock).mockReturnValue(false); + const { result } = renderHook(() => useSentryFeedbackForm()); + await waitFor(() => expect(result.current.sentryEnabled).toBe(false)); + + // WHEN openFeedbackForm is called + const opened = await result.current.openFeedbackForm(); + + // THEN it returns false + expect(opened).toBe(false); + expect(Sentry.getFeedback).not.toHaveBeenCalled(); + }); + + test("should create and open form and return true when Sentry is initialized", async () => { + // GIVEN Sentry is initialized and getFeedback returns a form builder + (Sentry.isInitialized as jest.Mock).mockReturnValue(true); + const { result } = renderHook(() => useSentryFeedbackForm()); + await waitFor(() => expect(result.current.sentryEnabled).toBe(true)); + + // WHEN openFeedbackForm is called + const opened = await result.current.openFeedbackForm(); + + // THEN it returns true and createForm was called + expect(opened).toBe(true); + expect(Sentry.getFeedback).toHaveBeenCalled(); + expect(mockCreateForm).toHaveBeenCalled(); + }); + + test("should call setSeenFeedbackNotification when markNotificationSeen is true and user exists", async () => { + // GIVEN Sentry is initialized and the user exists + (Sentry.isInitialized as jest.Mock).mockReturnValue(true); + const givenUserId = "user-123"; + jest.spyOn(authenticationStateService.getInstance(), "getUser").mockReturnValue({ + id: givenUserId, + name: "Test", + email: "test@test.com", + } as unknown as ReturnType["getUser"]>); + const { result } = renderHook(() => useSentryFeedbackForm()); + await waitFor(() => expect(result.current.sentryEnabled).toBe(true)); + + // WHEN openFeedbackForm is called with markNotificationSeen true + await result.current.openFeedbackForm({ markNotificationSeen: true }); + + // THEN setSeenFeedbackNotification was called with the user id + expect(PersistentStorageService.setSeenFeedbackNotification).toHaveBeenCalledWith(givenUserId); + }); + }); +}); diff --git a/frontend-new/src/feedback/hooks/useSentryFeedbackForm.ts b/frontend-new/src/feedback/hooks/useSentryFeedbackForm.ts new file mode 100644 index 00000000..bc3c1453 --- /dev/null +++ b/frontend-new/src/feedback/hooks/useSentryFeedbackForm.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Sentry from "@sentry/react"; +import authenticationStateService from "src/auth/services/AuthenticationState.service"; +import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; + +interface OpenFeedbackFormOptions { + markNotificationSeen?: boolean; +} + +interface UseSentryFeedbackFormOptions { + markNotificationSeenOnOpen?: boolean; +} + +export const useSentryFeedbackForm = (options: UseSentryFeedbackFormOptions = {}) => { + const { markNotificationSeenOnOpen = false } = options; + const { t } = useTranslation(); + const [sentryEnabled, setSentryEnabled] = useState(false); + + useEffect(() => { + setSentryEnabled(Sentry.isInitialized()); + }, []); + + const feedbackFormLabels = useMemo( + () => ({ + formTitle: t("chat.chatHeader.giveGeneralFeedback"), + nameLabel: t("chat.chatHeader.nameLabel"), + namePlaceholder: t("chat.chatHeader.namePlaceholder"), + emailLabel: t("chat.chatHeader.emailLabel"), + emailPlaceholder: t("chat.chatHeader.emailPlaceholder"), + isRequiredLabel: t("chat.chatHeader.requiredLabel"), + messageLabel: t("chat.chatHeader.descriptionLabel"), + messagePlaceholder: t("chat.chatHeader.feedbackMessagePlaceholder"), + addScreenshotButtonLabel: t("chat.chatHeader.addScreenshot"), + submitButtonLabel: t("chat.chatHeader.sendFeedback"), + cancelButtonLabel: t("chat.chatHeader.cancelButton"), + successMessageText: t("chat.chatHeader.feedbackSuccessMessage"), + }), + [t] + ); + + const openFeedbackForm = useCallback( + async (openOptions: OpenFeedbackFormOptions = {}): Promise => { + if (!sentryEnabled) { + console.debug("Sentry is not initialized, feedback form cannot be created."); + return false; + } + + try { + const feedback = Sentry.getFeedback(); + if (!feedback) { + return false; + } + + const form = await feedback.createForm(feedbackFormLabels); + form.appendToDom(); + form.open(); + + const shouldMarkAsSeen = openOptions.markNotificationSeen ?? markNotificationSeenOnOpen; + if (shouldMarkAsSeen) { + const user = authenticationStateService.getInstance().getUser(); + if (user) { + PersistentStorageService.setSeenFeedbackNotification(user.id); + } + } + + return true; + } catch (error) { + console.error("Error creating feedback form:", error); + return false; + } + }, + [feedbackFormLabels, markNotificationSeenOnOpen, sentryEnabled] + ); + + return { + sentryEnabled, + openFeedbackForm, + }; +}; diff --git a/frontend-new/src/home/Home.stories.tsx b/frontend-new/src/home/Home.stories.tsx new file mode 100644 index 00000000..85860119 --- /dev/null +++ b/frontend-new/src/home/Home.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import Home from "src/home/Home"; +import authenticationStateService from "src/auth/services/AuthenticationState.service"; +import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; + +const meta: Meta = { + title: "Home/HomePage", + component: Home, + tags: ["autodocs"], + decorators: [ + (Story) => { + // Mock authenticated user + authenticationStateService.getInstance().getUser = () => ({ + id: "user-123", + name: "Jane", + email: "jane@example.com", + }); + + // Mock user preferences with sessions + const prefsService = UserPreferencesStateService.getInstance(); + prefsService.clearUserPreferences(); + + return ; + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend-new/src/home/Home.test.tsx b/frontend-new/src/home/Home.test.tsx new file mode 100644 index 00000000..663f6ce0 --- /dev/null +++ b/frontend-new/src/home/Home.test.tsx @@ -0,0 +1,129 @@ +import "src/_test_utilities/consoleMock"; +import "src/_test_utilities/envServiceMock"; +import "src/_test_utilities/sentryMock"; + +import React from "react"; +import { render, screen } from "src/_test_utilities/test-utils"; +import Home, { DATA_TEST_ID } from "./Home"; +import { getEnabledModules } from "src/home/modulesService"; +import AuthenticationStateService from "src/auth/services/AuthenticationState.service"; +import { DATA_TEST_ID as MODULE_CARD_DATA_TEST_ID } from "src/home/components/ModuleCard/ModuleCard"; +import { DATA_TEST_ID as PAGE_HEADER_DATA_TEST_ID } from "src/home/components/PageHeader/PageHeader"; +import { DATA_TEST_ID as FOOTER_DATA_TEST_ID } from "src/home/components/Footer/Footer"; +import { DATA_TEST_ID as PROGRESS_BAR_DATA_TEST_ID } from "src/home/components/ProgressBar/ProgressBar"; +import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks"; + +// Mock useModuleProgress +const mockOverallProgress = 40; +const mockGetBadgeStatus = jest.fn((_moduleId: string): "continue" | "completed" | null => null); +jest.mock("src/home/hooks/useModuleProgress", () => ({ + useModuleProgress: () => ({ + overallProgress: mockOverallProgress, + getBadgeStatus: mockGetBadgeStatus, + isModuleStarted: false, + }), +})); + +describe("Home", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetBadgeStatus.mockImplementation((_moduleId: string) => null); + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue({ + id: "user-1", + name: "Test User", + email: "test@example.com", + } as ReturnType); + resetAllMethodMocks(AuthenticationStateService.getInstance()); + }); + + describe("render", () => { + test("should render full layout with container, header, welcome section, progress bar, modules grid, and footer", () => { + // GIVEN default user and mocked useModuleProgress + // WHEN the Home page is rendered + render(); + + // THEN the home container is in the document + expect(screen.getByTestId(DATA_TEST_ID.HOME_CONTAINER)).toBeInTheDocument(); + // AND the page header is present + expect(screen.getByTestId(PAGE_HEADER_DATA_TEST_ID.PAGE_HEADER_CONTAINER)).toBeInTheDocument(); + // AND the welcome title and subtitle are present + expect(screen.getByTestId(DATA_TEST_ID.HOME_WELCOME_TITLE)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.HOME_WELCOME_SUBTITLE)).toHaveTextContent("mockProduct"); + // AND the progress bar shows the value from useModuleProgress + expect(screen.getByTestId(PROGRESS_BAR_DATA_TEST_ID.PROGRESS_BAR)).toHaveAttribute("aria-valuenow", "40"); + // AND the module grid is present + expect(screen.getByTestId(DATA_TEST_ID.HOME_MODULES_GRID)).toBeInTheDocument(); + // AND the footer is present + expect(screen.getByTestId(FOOTER_DATA_TEST_ID.FOOTER_COLLABORATION)).toBeInTheDocument(); + // AND a module card is rendered for each enabled module + const cards = screen.getAllByTestId(MODULE_CARD_DATA_TEST_ID.MODULE_CARD); + expect(cards).toHaveLength(getEnabledModules().length); + }); + + test("should display modules section title and subtitle with translated text", () => { + // GIVEN default mocks + // WHEN the Home page is rendered + render(); + + // THEN the modules title shows the translated whatToDo text + expect(screen.getByTestId(DATA_TEST_ID.HOME_MODULES_TITLE)).toHaveTextContent(/what would you like to do today/i); + // AND the modules subtitle shows the translated whatToDoSubtitle text + expect(screen.getByTestId(DATA_TEST_ID.HOME_MODULES_SUBTITLE)).toHaveTextContent(/each module is an ai-guided/i); + }); + + test("should show Welcome back with user name when user has a name", () => { + // GIVEN a user with a name + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue({ + id: "user-1", + name: "Jane", + email: "jane@example.com", + } as ReturnType); + + // WHEN the Home page is rendered + render(); + + // THEN the welcome title shows Welcome back and the user name + expect(screen.getByTestId(DATA_TEST_ID.HOME_WELCOME_TITLE)).toHaveTextContent(/Welcome back, Jane/i); + }); + + test("should call getBadgeStatus for each enabled module", () => { + // GIVEN default mocks + // WHEN the Home page is rendered + render(); + + // THEN getBadgeStatus was called once per enabled module + const modules = getEnabledModules(); + expect(mockGetBadgeStatus).toHaveBeenCalledTimes(modules.length); + // AND it was called with each module id + modules.forEach((m) => expect(mockGetBadgeStatus).toHaveBeenCalledWith(m.id)); + }); + + test("should show continue chip on skills_discovery card when getBadgeStatus returns continue", () => { + // GIVEN getBadgeStatus returns continue for skills_discovery + mockGetBadgeStatus.mockImplementation((moduleId: string) => { + if (moduleId === "skills_discovery") return "continue"; + return null; + }); + + // WHEN the Home page is rendered + render(); + + // THEN the skills_discovery module card shows the continue chip + expect(screen.getByTestId(MODULE_CARD_DATA_TEST_ID.MODULE_CARD_CONTINUE_CHIP)).toBeInTheDocument(); + }); + + test("should show completed chip on skills_discovery card when getBadgeStatus returns completed", () => { + // GIVEN getBadgeStatus returns completed for skills_discovery + mockGetBadgeStatus.mockImplementation((moduleId: string) => { + if (moduleId === "skills_discovery") return "completed"; + return null; + }); + + // WHEN the Home page is rendered + render(); + + // THEN the skills_discovery module card shows the completed chip + expect(screen.getByTestId(MODULE_CARD_DATA_TEST_ID.MODULE_CARD_COMPLETED_CHIP)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend-new/src/home/Home.tsx b/frontend-new/src/home/Home.tsx new file mode 100644 index 00000000..8631f9d4 --- /dev/null +++ b/frontend-new/src/home/Home.tsx @@ -0,0 +1,130 @@ +import React from "react"; + +import { Box, Typography, useTheme, useMediaQuery } from "@mui/material"; +import type { Theme } from "@mui/material/styles"; +import Grid from "@mui/material/Grid"; +import { useTranslation } from "react-i18next"; +import { getEnabledModules } from "src/home/modulesService"; +import { useModuleProgress } from "src/home/hooks/useModuleProgress"; +import ProgressBar from "src/home/components/ProgressBar/ProgressBar"; +import ModuleCard from "src/home/components/ModuleCard/ModuleCard"; +import PageHeader from "src/home/components/PageHeader/PageHeader"; +import Footer from "src/home/components/Footer/Footer"; +import authenticationStateService from "src/auth/services/AuthenticationState.service"; +import { getProductName } from "src/envService"; + +const uniqueId = "f1a2b3c4-5d6e-7f8a-9b0c-1d2e3f4a5b6c"; + +export const DATA_TEST_ID = { + HOME_CONTAINER: `home-container-${uniqueId}`, + HOME_WELCOME_TITLE: `home-welcome-title-${uniqueId}`, + HOME_WELCOME_SUBTITLE: `home-welcome-subtitle-${uniqueId}`, + HOME_MODULES_TITLE: `home-modules-title-${uniqueId}`, + HOME_MODULES_SUBTITLE: `home-modules-subtitle-${uniqueId}`, + HOME_MODULES_GRID: `home-modules-grid-${uniqueId}`, +}; + +const Home: React.FC = () => { + const theme = useTheme(); + const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("sm")); + const { t } = useTranslation(); + const modules = getEnabledModules(); + const { overallProgress, getBadgeStatus } = useModuleProgress(); + + const user = authenticationStateService.getInstance().getUser(); + const userName = user?.name || ""; + const appName = getProductName() || ""; + + return ( + + {/* Page Header */} + + + {/* Page Content */} + + + {/* Welcome, Section */} + + + {t("home.welcomeBack", { name: userName })} + + + {t("home.subtitle", { appName: appName })} + + + + {/* Progress Section */} + + + {/* Modules Section */} + + + {t("home.whatToDo")} + + + {t("home.whatToDoSubtitle")} + + + {modules.map((module) => ( + + + + ))} + + + + + + {/* Footer */} +
+ + ); +}; + +export default Home; diff --git a/frontend-new/src/home/components/Footer/Footer.stories.tsx b/frontend-new/src/home/components/Footer/Footer.stories.tsx new file mode 100644 index 00000000..bc8b3ba5 --- /dev/null +++ b/frontend-new/src/home/components/Footer/Footer.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import Footer from "src/home/components/Footer/Footer"; + +const meta: Meta = { + title: "Home/Footer", + component: Footer, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Shown: Story = {}; diff --git a/frontend-new/src/home/components/Footer/Footer.test.tsx b/frontend-new/src/home/components/Footer/Footer.test.tsx new file mode 100644 index 00000000..f150132f --- /dev/null +++ b/frontend-new/src/home/components/Footer/Footer.test.tsx @@ -0,0 +1,69 @@ +import "src/_test_utilities/consoleMock"; +import "src/_test_utilities/envServiceMock"; + +import React from "react"; +import { render, screen, userEvent } from "src/_test_utilities/test-utils"; +import Footer, { DATA_TEST_ID, EXTERNAL_URLS } from "./Footer"; + +describe("Footer", () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(window, "open", { + value: jest.fn(), + writable: true, + }); + }); + + test("should render the footer container, logos, links, and collaboration text", () => { + // GIVEN the Footer component + const givenComponent =
; + + // WHEN the Footer is rendered + render(givenComponent); + + // THEN the footer container is in the document + const footerContainer = screen.getByTestId(DATA_TEST_ID.FOOTER_CONTAINER); + expect(footerContainer).toBeInTheDocument(); + // AND the logo container is in the document + expect(screen.getByTestId(DATA_TEST_ID.FOOTER_LOGOS_CONTAINER)).toBeInTheDocument(); + // AND the World Bank logo is displayed + expect(screen.getByTestId(DATA_TEST_ID.FOOTER_WORLD_BANK_LOGO)).toBeInTheDocument(); + // AND the Tabiya logo is displayed + expect(screen.getByTestId(DATA_TEST_ID.FOOTER_TABIYA_LOGO)).toBeInTheDocument(); + // AND the privacy link is in the document + expect(screen.getByTestId(DATA_TEST_ID.FOOTER_PRIVACY_LINK)).toBeInTheDocument(); + // AND the term link is in the document + expect(screen.getByTestId(DATA_TEST_ID.FOOTER_TERMS_LINK)).toBeInTheDocument(); + // AND the accessibility link is in the document + expect(screen.getByTestId(DATA_TEST_ID.FOOTER_ACCESSIBILITY_LINK)).toBeInTheDocument(); + // AND the contact link is in the document + expect(screen.getByTestId(DATA_TEST_ID.FOOTER_CONTACT_LINK)).toBeInTheDocument(); + // AND the collaboration section is in the document + expect(screen.getByTestId(DATA_TEST_ID.FOOTER_COLLABORATION)).toBeInTheDocument(); + // AND to match the snapshot + expect(footerContainer).toMatchSnapshot(); + }); + + test("should open all external links in new tabs", async () => { + // GIVEN the Footer is rendered + render(
); + // AND the expected URLs for each link + const privacyUrl = EXTERNAL_URLS.PRIVACY_POLICY; + const termsUrl = EXTERNAL_URLS.TERMS_OF_USE; + const accessibilityUrl = EXTERNAL_URLS.ACCESSIBILITY; + const contactUrl = EXTERNAL_URLS.CONTACT; + + // WHEN the external links are clicked + await userEvent.click(screen.getByTestId(DATA_TEST_ID.FOOTER_PRIVACY_LINK)); + await userEvent.click(screen.getByTestId(DATA_TEST_ID.FOOTER_TERMS_LINK)); + await userEvent.click(screen.getByTestId(DATA_TEST_ID.FOOTER_ACCESSIBILITY_LINK)); + await userEvent.click(screen.getByTestId(DATA_TEST_ID.FOOTER_CONTACT_LINK)); + + // THEN window.open is called with the correct URLs + expect(window.open).toHaveBeenCalledTimes(4); + expect(window.open).toHaveBeenCalledWith(privacyUrl, "_blank", "noopener,noreferrer"); + expect(window.open).toHaveBeenCalledWith(termsUrl, "_blank", "noopener,noreferrer"); + expect(window.open).toHaveBeenCalledWith(accessibilityUrl, "_blank", "noopener,noreferrer"); + expect(window.open).toHaveBeenCalledWith(contactUrl, "_blank", "noopener,noreferrer"); + }); +}); diff --git a/frontend-new/src/home/components/Footer/Footer.tsx b/frontend-new/src/home/components/Footer/Footer.tsx new file mode 100644 index 00000000..69ee9ff5 --- /dev/null +++ b/frontend-new/src/home/components/Footer/Footer.tsx @@ -0,0 +1,159 @@ +import React from "react"; +import { Box, Container, Divider, Typography, useMediaQuery, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import CustomLink from "src/theme/CustomLink/CustomLink"; +import { getProductName } from "src/envService"; +import type { Theme } from "@mui/material/styles"; + +const uniqueId = "a7f3d2b1-8e4c-4a9f-b6d5-3c1e2f7a8b9d"; + +export const DATA_TEST_ID = { + FOOTER_CONTAINER: `footer-container-${uniqueId}`, + FOOTER_LOGOS_CONTAINER: `footer-logos-container-${uniqueId}`, + FOOTER_WORLD_BANK_LOGO: `footer-world-bank-logo-${uniqueId}`, + FOOTER_TABIYA_LOGO: `footer-tabiya-logo-${uniqueId}`, + FOOTER_PRIVACY_LINK: `footer-privacy-link-${uniqueId}`, + FOOTER_TERMS_LINK: `footer-terms-link-${uniqueId}`, + FOOTER_ACCESSIBILITY_LINK: `footer-accessibility-link-${uniqueId}`, + FOOTER_CONTACT_LINK: `footer-contact-link-${uniqueId}`, + FOOTER_COPYRIGHT: `footer-copyright-${uniqueId}`, + FOOTER_COLLABORATION: `footer-collaboration-${uniqueId}`, +}; + +export const EXTERNAL_URLS = { + PRIVACY_POLICY: "https://tabiya.org/compass-terms-privacy/#privacy-policy", + TERMS_OF_USE: "https://tabiya.org/compass-terms-privacy/#terms-and-conditions", + ACCESSIBILITY: "https://tabiya.org/compass-terms-privacy/#accessibility", + CONTACT: "mailto:hi@tabiya.org", +}; + +const Footer: React.FC = () => { + const theme = useTheme(); + const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("sm")); + const { t } = useTranslation(); + const appName = getProductName() || ""; + + const handleExternalNavigation = (url: string) => { + window.open(url, "_blank", "noopener,noreferrer"); + }; + + return ( + + + + + {/* Partner logos */} + + World Bank Group Logo + Tabiya Logo + + + {/* Legal links */} + + handleExternalNavigation(EXTERNAL_URLS.PRIVACY_POLICY)} + data-testid={DATA_TEST_ID.FOOTER_PRIVACY_LINK} + sx={{ + fontSize: "0.8rem", + fontWeight: 500, + }} + > + {t("home.footer.privacyPolicy")} + + • + handleExternalNavigation(EXTERNAL_URLS.TERMS_OF_USE)} + data-testid={DATA_TEST_ID.FOOTER_TERMS_LINK} + sx={{ + fontSize: "0.8rem", + fontWeight: 500, + }} + > + {t("home.footer.termsOfUse")} + + • + handleExternalNavigation(EXTERNAL_URLS.ACCESSIBILITY)} + data-testid={DATA_TEST_ID.FOOTER_ACCESSIBILITY_LINK} + sx={{ + fontSize: "0.8rem", + fontWeight: 500, + }} + > + {t("home.footer.accessibility")} + + • + handleExternalNavigation(EXTERNAL_URLS.CONTACT)} + data-testid={DATA_TEST_ID.FOOTER_CONTACT_LINK} + sx={{ + fontSize: "0.8rem", + fontWeight: 500, + }} + > + {t("home.footer.contact")} + + + + {/* Collaboration text */} + + {t("home.footer.collaboration", { appName })} + + + + + ); +}; + +export default Footer; diff --git a/frontend-new/src/home/components/Footer/__snapshots__/Footer.test.tsx.snap b/frontend-new/src/home/components/Footer/__snapshots__/Footer.test.tsx.snap new file mode 100644 index 00000000..6d06df07 --- /dev/null +++ b/frontend-new/src/home/components/Footer/__snapshots__/Footer.test.tsx.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Footer should render the footer container, logos, links, and collaboration text 1`] = ` + +`; diff --git a/frontend-new/src/home/components/ModuleCard/ModuleCard.stories.tsx b/frontend-new/src/home/components/ModuleCard/ModuleCard.stories.tsx new file mode 100644 index 00000000..4ce8b648 --- /dev/null +++ b/frontend-new/src/home/components/ModuleCard/ModuleCard.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import ModuleCard from "src/home/components/ModuleCard/ModuleCard"; +import type { Module } from "src/home/modulesService"; + +const meta: Meta = { + title: "Home/ModuleCard", + component: ModuleCard, + tags: ["autodocs"], + argTypes: { + badgeStatus: { + control: "select", + options: [null, "continue", "completed"], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +// Mock module data using real translation keys +const mockModule: Module = { + id: "skills_discovery", + labelKey: "home.modules.skillsDiscovery", + descriptionKey: "home.modules.skillsDiscoveryDesc", + route: "/skills-interests", +}; + +export const Default: Story = { + args: { + module: mockModule, + badgeStatus: null, + }, +}; + +export const WithContinueBadge: Story = { + args: { + module: mockModule, + badgeStatus: "continue", + }, +}; + +export const WithCompletedBadge: Story = { + args: { + module: mockModule, + badgeStatus: "completed", + }, +}; diff --git a/frontend-new/src/home/components/ModuleCard/ModuleCard.test.tsx b/frontend-new/src/home/components/ModuleCard/ModuleCard.test.tsx new file mode 100644 index 00000000..8e5f5ed7 --- /dev/null +++ b/frontend-new/src/home/components/ModuleCard/ModuleCard.test.tsx @@ -0,0 +1,97 @@ +import "src/_test_utilities/consoleMock"; +import "src/_test_utilities/envServiceMock"; + +import React from "react"; +import { render, screen, userEvent, within } from "src/_test_utilities/test-utils"; +import ModuleCard, { DATA_TEST_ID } from "./ModuleCard"; +import type { Module } from "src/home/modulesService"; + +const mockNavigate = jest.fn(); + +jest.mock("react-router-dom", () => { + const actual = jest.requireActual("react-router-dom"); + return { + ...actual, + __esModule: true, + useNavigate: () => mockNavigate, + }; +}); + +const baseModule: Module = { + id: "skills_discovery", + labelKey: "home.modules.skillsDiscovery", + descriptionKey: "home.modules.skillsDiscoveryDesc", + route: "/skills-interests", +}; + +describe("ModuleCard", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigate.mockClear(); + }); + + test("should render the card with title and description for a given module", () => { + // GIVEN a module + + // WHEN the ModuleCard is rendered + render(); + + // THEN the card container is in the document + const card = screen.getByTestId(DATA_TEST_ID.MODULE_CARD); + expect(card).toBeInTheDocument(); + // AND the title is displayed + expect(screen.getByTestId(DATA_TEST_ID.MODULE_CARD_TITLE)).toBeInTheDocument(); + // AND the description is displayed + expect(screen.getByTestId(DATA_TEST_ID.MODULE_CARD_DESCRIPTION)).toBeInTheDocument(); + // AND the icon is displayed + expect(screen.getByTestId(DATA_TEST_ID.MODULE_CARD_ICON)).toBeInTheDocument(); + // AND to match the snapshot + expect(card).toMatchSnapshot(); + }); + + test("should not show a badge when badgeStatus is null", () => { + // GIVEN a skills_discovery module with no badge + + // WHEN the ModuleCard is rendered + render(); + + // THEN no continue or completed chip is shown + expect(screen.queryByTestId(DATA_TEST_ID.MODULE_CARD_CONTINUE_CHIP)).not.toBeInTheDocument(); + expect(screen.queryByTestId(DATA_TEST_ID.MODULE_CARD_COMPLETED_CHIP)).not.toBeInTheDocument(); + }); + + test("should show continue badge when badgeStatus is continue and module is skills_discovery", () => { + // GIVEN a skills_discovery module with continue badge + + // WHEN the ModuleCard is rendered + render(); + + // THEN the continued chip is shown + expect(screen.getByTestId(DATA_TEST_ID.MODULE_CARD_CONTINUE_CHIP)).toBeInTheDocument(); + }); + + test("should show completed badge when badgeStatus is completed and module is skills_discovery", () => { + // GIVEN a skills_discovery module with completed badge + + // WHEN the ModuleCard is rendered + render(); + + // THEN the completed chip is shown + expect(screen.getByTestId(DATA_TEST_ID.MODULE_CARD_COMPLETED_CHIP)).toBeInTheDocument(); + }); + + test("should navigate to the module route when the card is clicked", async () => { + // GIVEN a module with a route + + // WHEN the ModuleCard is rendered + render(); + + // AND the card is clicked + const card = screen.getByTestId(DATA_TEST_ID.MODULE_CARD); + const actionArea = within(card).getByRole("button"); + await userEvent.click(actionArea); + + // THEN it navigates to the module route + expect(mockNavigate).toHaveBeenCalledWith(baseModule.route); + }); +}); diff --git a/frontend-new/src/home/components/ModuleCard/ModuleCard.tsx b/frontend-new/src/home/components/ModuleCard/ModuleCard.tsx new file mode 100644 index 00000000..8a3f0ee0 --- /dev/null +++ b/frontend-new/src/home/components/ModuleCard/ModuleCard.tsx @@ -0,0 +1,142 @@ +import React, { startTransition } from "react"; +import { Box, Card, CardActionArea, Typography, Chip, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { Module } from "src/home/modulesService"; +import { TranslationKey } from "src/react-i18next"; +import AutoAwesomeOutlined from "@mui/icons-material/AutoAwesomeOutlined"; +import ExploreOutlined from "@mui/icons-material/ExploreOutlined"; +import SchoolOutlined from "@mui/icons-material/SchoolOutlined"; +import SearchOutlined from "@mui/icons-material/SearchOutlined"; +import MenuBookOutlined from "@mui/icons-material/MenuBookOutlined"; +import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined"; +import { getProductName } from "src/envService"; + +const uniqueId = "c3d4e5f6-7a8b-9c0d-1e2f-3a4b5c6d7e8f"; + +export const DATA_TEST_ID = { + MODULE_CARD: `module-card-${uniqueId}`, + MODULE_CARD_ICON: `module-card-icon-${uniqueId}`, + MODULE_CARD_TITLE: `module-card-title-${uniqueId}`, + MODULE_CARD_DESCRIPTION: `module-card-description-${uniqueId}`, + MODULE_CARD_CONTINUE_CHIP: `module-card-continue-chip-${uniqueId}`, + MODULE_CARD_COMPLETED_CHIP: `module-card-completed-chip-${uniqueId}`, +}; + +export interface ModuleCardProps { + module: Module; + badgeStatus?: "continue" | "completed" | null; +} + +const MODULE_ICONS: Record = { + skills_discovery: , + career_discovery: , + job_readiness: , + career_explorer: , + knowledge_hub: , +}; + +const ModuleCard: React.FC = ({ module, badgeStatus = null }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const appName = getProductName() || ""; + + const handleClick = () => { + startTransition(() => { + navigate(module.route); + }); + }; + + const icon = MODULE_ICONS[module.id] || ; + + return ( + + + {/* Badge (Continue or Completed) */} + {badgeStatus && module.id === "skills_discovery" && ( + + + + )} + + + {/* Icon */} + + {icon} + + + {/* Title */} + + {t(module.labelKey as TranslationKey)} + + + {/* Description */} + + {t(module.descriptionKey as TranslationKey, { appName })} + + + + + ); +}; + +export default ModuleCard; diff --git a/frontend-new/src/home/components/ModuleCard/__snapshots__/ModuleCard.test.tsx.snap b/frontend-new/src/home/components/ModuleCard/__snapshots__/ModuleCard.test.tsx.snap new file mode 100644 index 00000000..c6d4c799 --- /dev/null +++ b/frontend-new/src/home/components/ModuleCard/__snapshots__/ModuleCard.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ModuleCard should render the card with title and description for a given module 1`] = ` +
+ +
+`; diff --git a/frontend-new/src/home/components/PageHeader/PageHeader.stories.tsx b/frontend-new/src/home/components/PageHeader/PageHeader.stories.tsx new file mode 100644 index 00000000..225f586f --- /dev/null +++ b/frontend-new/src/home/components/PageHeader/PageHeader.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import PageHeader from "src/home/components/PageHeader/PageHeader"; +import authenticationStateService from "src/auth/services/AuthenticationState.service"; + +const meta: Meta = { + title: "Home/PageHeader", + component: PageHeader, + tags: ["autodocs"], + decorators: [ + (Story) => { + // Mock authenticated user + authenticationStateService.getInstance().getUser = () => ({ + id: "user-123", + name: "Jane", + email: "jane@example.com", + }); + + return ; + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend-new/src/home/components/PageHeader/PageHeader.test.tsx b/frontend-new/src/home/components/PageHeader/PageHeader.test.tsx new file mode 100644 index 00000000..f584c488 --- /dev/null +++ b/frontend-new/src/home/components/PageHeader/PageHeader.test.tsx @@ -0,0 +1,322 @@ +import "src/_test_utilities/consoleMock"; +import "src/_test_utilities/envServiceMock"; +import "src/_test_utilities/sentryMock"; + +import React from "react"; +import { act, render, screen, userEvent, waitFor } from "src/_test_utilities/test-utils"; +import PageHeader, { DATA_TEST_ID, MENU_ITEM_ID } from "./PageHeader"; +import { routerPaths } from "src/app/routerPaths"; +import AuthenticationStateService from "src/auth/services/AuthenticationState.service"; +import { MenuItemConfig } from "src/theme/ContextMenu/menuItemConfig.types"; +import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks"; +import ContextMenu from "src/theme/ContextMenu/ContextMenu"; +import AnonymousAccountConversionDialog, { + DATA_TEST_ID as ANONYMOUS_DIALOG_DATA_TEST_ID, +} from "src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog"; +import { DATA_TEST_ID as TEXT_CONFIRM_MODAL_DATA_TEST_ID } from "src/theme/textConfirmModalDialog/TextConfirmModalDialog"; +import { DATA_TEST_ID as CONFIRM_MODAL_DATA_TEST_ID } from "src/theme/confirmModalDialog/ConfirmModalDialog"; +import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; + +// Mock PersistentStorageService +jest.mock("src/app/PersistentStorageService/PersistentStorageService"); + +// Mock useNavigate +const mockNavigate = jest.fn(); +jest.mock("react-router-dom", () => { + const actual = jest.requireActual("react-router-dom"); + return { ...actual, __esModule: true, useNavigate: () => mockNavigate }; +}); + +// Mock useSentryFeedbackForm +const mockOpenFeedbackForm = jest.fn(); +jest.mock("src/feedback/hooks/useSentryFeedbackForm", () => ({ + useSentryFeedbackForm: () => ({ sentryEnabled: true, openFeedbackForm: mockOpenFeedbackForm }), +})); + +// Mock useSnackbar +const mockEnqueueSnackbar = jest.fn(); +jest.mock("src/theme/SnackbarProvider/SnackbarProvider", () => { + const actual = jest.requireActual("src/theme/SnackbarProvider/SnackbarProvider"); + return { + ...actual, + __esModule: true, + useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar, closeSnackbar: jest.fn() }), + }; +}); + +// Mock ContextMenu +jest.mock("src/theme/ContextMenu/ContextMenu", () => { + const actual = jest.requireActual("src/theme/ContextMenu/ContextMenu"); + const USER_MENU_TEST_ID = "page-header-user-context-menu"; + const OTHER_MENU_TEST_ID = "context-menu-other"; + return { + __esModule: true, + default: jest.fn(({ items }: { items: MenuItemConfig[] }) => { + const isUserMenu = items.some((item) => item.id && String(item.id).includes("page-logout-button")); + return ( +
+ {items.map((item) => ( +
+ {item.text} +
+ ))} +
+ ); + }), + DATA_TEST_ID: actual.DATA_TEST_ID, + }; +}); + +const PAGE_HEADER_USER_MENU_TEST_ID = "page-header-user-context-menu"; + +// Mock AnonymousAccountConversionDialog +jest.mock("src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog", () => { + const actual = jest.requireActual( + "src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog" + ); + return { __esModule: true, ...actual, default: jest.fn(() =>
) }; +}); + +// Mock AuthenticationService +const mockLogout = jest.fn(); +jest.mock("src/auth/services/Authentication.service.factory", () => ({ + __esModule: true, + default: { getCurrentAuthenticationService: () => ({ logout: mockLogout }) }, +})); + +const registeredUser = { + id: "user-1", + name: "Test User", + email: "test@example.com", +} as ReturnType; + +const anonymousUser = { + id: "anon-1", + name: "", + email: "", +} as ReturnType; + +describe("PageHeader", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockLogout.mockResolvedValue(undefined); + (ContextMenu as jest.Mock).mockClear(); + resetAllMethodMocks(AuthenticationStateService.getInstance()); + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(registeredUser); + }); + + describe("render", () => { + test("should render container, logo, title, subtitle, feedback button, and user button", () => { + // GIVEN a title and optional subtitle + const givenTitle = "home.dashboard"; + const givenSubtitle = "home.subtitle"; + + // WHEN the PageHeader is rendered + render(); + + // THEN the container is in the document + expect(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_CONTAINER)).toBeInTheDocument(); + // AND the logo and logo link are present + expect(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_LOGO)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_LOGO_LINK)).toHaveAttribute("href", `#${routerPaths.ROOT}`); + // AND the title and subtitle are present + expect(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_TITLE)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_SUBTITLE)).toBeInTheDocument(); + // AND the feedback and user buttons are present + expect(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_FEEDBACK)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_ICON_USER)).toBeInTheDocument(); + }); + }); + + describe("feedback button", () => { + test("should call openFeedbackForm when feedback button is clicked", async () => { + // GIVEN the PageHeader is rendered + render(); + + // WHEN the feedback button is clicked + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_FEEDBACK)); + + // THEN openFeedbackForm is called + expect(mockOpenFeedbackForm).toHaveBeenCalledTimes(1); + }); + }); + + describe("user menu", () => { + test("should open context menu with logout when user button is clicked", async () => { + // GIVEN the PageHeader is rendered (registered user, sentry enabled) + render(); + + // WHEN the user button is clicked + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + + // THEN the user context menu is visible + expect(screen.getByTestId(PAGE_HEADER_USER_MENU_TEST_ID)).toBeInTheDocument(); + // AND it contains report bug (sentry enabled) and logout + expect(screen.getByTestId(MENU_ITEM_ID.REPORT_BUG_BUTTON)).toBeInTheDocument(); + expect(screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON)).toBeInTheDocument(); + }); + + test("should show register in menu for anonymous user", async () => { + // GIVEN an anonymous user + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(anonymousUser); + + // WHEN the PageHeader is rendered and user button is clicked + render(); + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + + // THEN the register item is in the menu + expect(screen.getByTestId(MENU_ITEM_ID.REGISTER)).toBeInTheDocument(); + }); + + test("should not show register in menu for registered user", async () => { + // GIVEN a registered user (default) + // WHEN the PageHeader is rendered, and the user button is clicked + render(); + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + + // THEN the register item is not in the menu + expect(screen.queryByTestId(MENU_ITEM_ID.REGISTER)).not.toBeInTheDocument(); + }); + + test("should close context menu when notifyOnClose is called", async () => { + // GIVEN the PageHeader is rendered + render(); + // AND the user button is clicked to open the menu + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + expect(screen.getByTestId(PAGE_HEADER_USER_MENU_TEST_ID)).toBeInTheDocument(); + + // WHEN notifyOnClose is called (last call is the user menu we just opened) + act(() => { + const mock = (ContextMenu as jest.Mock).mock; + mock.lastCall[0].notifyOnClose(); + }); + + // THEN the context menu is closed (re-render: menu is closed so ContextMenu is called with open: false) + expect(ContextMenu).toHaveBeenLastCalledWith( + expect.objectContaining({ anchorEl: null, open: false }), + expect.anything() + ); + }); + }); + + describe("logout", () => { + test("should logout and navigate to landing when registered user clicks logout", async () => { + // GIVEN a registered user + render(); + + // WHEN the user opens the menu and clicks logout + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + await userEvent.click(screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON)); + + // THEN logout is called and navigate is called with landing + await waitFor(() => expect(mockLogout).toHaveBeenCalled()); + expect(mockNavigate).toHaveBeenCalledWith(routerPaths.LANDING, { replace: true }); + }); + + test("should show logout confirmation dialog when anonymous user clicks logout", async () => { + // GIVEN an anonymous user + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(anonymousUser); + render(); + + // WHEN the user opens the menu and clicks logout + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + await userEvent.click(screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON)); + + // THEN the confirmation dialog is shown + expect(screen.getByTestId(TEXT_CONFIRM_MODAL_DATA_TEST_ID.TEXT_CONFIRM_MODAL_CONTENT)).toBeInTheDocument(); + }); + + test("should logout and navigate to landing when anonymous user confirms logout from dialog", async () => { + // GIVEN an anonymous user + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(anonymousUser); + render(); + + // WHEN the user opens the menu, clicks logout, then clicks cancel (confirm logout) + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + await userEvent.click(screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON)); + await userEvent.click(screen.getByTestId(CONFIRM_MODAL_DATA_TEST_ID.CONFIRM_MODAL_CANCEL)); + + // THEN logout is called and navigate is called with landing + await waitFor(() => expect(mockLogout).toHaveBeenCalled()); + expect(mockNavigate).toHaveBeenCalledWith(routerPaths.LANDING, { replace: true }); + }); + + test("should close logout confirmation when close icon is clicked", async () => { + // GIVEN an anonymous user and logout confirmation open + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(anonymousUser); + render(); + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + await userEvent.click(screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON)); + const dialog = screen.getByTestId(CONFIRM_MODAL_DATA_TEST_ID.CONFIRM_MODAL); + + // WHEN the close icon is clicked + await userEvent.click(screen.getByTestId(CONFIRM_MODAL_DATA_TEST_ID.CONFIRM_MODAL_CLOSE)); + + // THEN the dialog is closed + await waitFor(() => expect(dialog).not.toBeInTheDocument()); + }); + }); + + describe("register", () => { + test("should open conversion dialog when anonymous user clicks register in menu", async () => { + // GIVEN an anonymous user + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(anonymousUser); + render(); + + // WHEN the user opens the menu and clicks register + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + await userEvent.click(screen.getByTestId(MENU_ITEM_ID.REGISTER)); + + // THEN the conversion dialog is shown + expect(screen.getByTestId(ANONYMOUS_DIALOG_DATA_TEST_ID.DIALOG)).toBeInTheDocument(); + }); + + test("should open conversion dialog when anonymous user chooses register from logout confirmation", async () => { + // GIVEN an anonymous user + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(anonymousUser); + render(); + + // WHEN the user opens the menu, clicks logout, then clicks confirm (register) + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + await userEvent.click(screen.getByTestId(MENU_ITEM_ID.LOGOUT_BUTTON)); + await userEvent.click(screen.getByTestId(CONFIRM_MODAL_DATA_TEST_ID.CONFIRM_MODAL_CONFIRM)); + + // THEN the conversion dialog is shown + expect(screen.getByTestId(ANONYMOUS_DIALOG_DATA_TEST_ID.DIALOG)).toBeInTheDocument(); + }); + + test("should set account converted flag when conversion succeeds", async () => { + // GIVEN an anonymous user and conversion dialog open + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(anonymousUser); + render(); + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + await userEvent.click(screen.getByTestId(MENU_ITEM_ID.REGISTER)); + + // WHEN onSuccess is called on the conversion dialog + (AnonymousAccountConversionDialog as jest.Mock).mock.calls[0][0].onSuccess(); + + // THEN the account converted flag is set + expect(PersistentStorageService.setAccountConverted).toHaveBeenCalledWith(true); + }); + + test("should close conversion dialog when onClose is called", async () => { + // GIVEN an anonymous user and conversion dialog open + jest.spyOn(AuthenticationStateService.getInstance(), "getUser").mockReturnValue(anonymousUser); + render(); + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PAGE_HEADER_BUTTON_USER)); + await userEvent.click(screen.getByTestId(MENU_ITEM_ID.REGISTER)); + + // WHEN onClose is called on the conversion dialog + act(() => { + (AnonymousAccountConversionDialog as jest.Mock).mock.calls[0][0].onClose(); + }); + + // THEN the dialog is called with isOpen false + expect(AnonymousAccountConversionDialog).toHaveBeenCalledWith( + expect.objectContaining({ isOpen: false }), + expect.anything() + ); + }); + }); +}); diff --git a/frontend-new/src/home/components/PageHeader/PageHeader.tsx b/frontend-new/src/home/components/PageHeader/PageHeader.tsx new file mode 100644 index 00000000..fa76e59c --- /dev/null +++ b/frontend-new/src/home/components/PageHeader/PageHeader.tsx @@ -0,0 +1,457 @@ +import React, { useCallback, useContext, useMemo, useState, startTransition } from "react"; +import { Box, Typography, useTheme, useMediaQuery } from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import CustomLink from "src/theme/CustomLink/CustomLink"; +import type { Theme } from "@mui/material/styles"; +import { NavLink, useNavigate } from "react-router-dom"; +import { routerPaths } from "src/app/routerPaths"; +import PrimaryIconButton from "src/theme/PrimaryIconButton/PrimaryIconButton"; +import ContextMenu from "src/theme/ContextMenu/ContextMenu"; +import { MenuItemConfig } from "src/theme/ContextMenu/menuItemConfig.types"; +import { IsOnlineContext } from "src/app/isOnlineProvider/IsOnlineProvider"; +import AuthenticationServiceFactory from "src/auth/services/Authentication.service.factory"; +import LanguageContextMenu from "src/i18n/languageContextMenu/LanguageContextMenu"; +import { getAppIconUrl } from "src/envService"; +import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; +import { useTranslation } from "react-i18next"; +import type { TranslationKey } from "src/react-i18next"; +import FeedbackOutlinedIcon from "@mui/icons-material/FeedbackOutlined"; +import MenuIcon from "@mui/icons-material/Menu"; +import ChevronRightOutlinedIcon from "@mui/icons-material/ChevronRightOutlined"; +import authenticationStateService from "src/auth/services/AuthenticationState.service"; +import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; +import AnonymousAccountConversionDialog from "src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog"; +import TextConfirmModalDialog from "src/theme/textConfirmModalDialog/TextConfirmModalDialog"; +import { HighlightedSpan } from "src/consent/components/consentPage/Consent"; +import { Backdrop } from "src/theme/Backdrop/Backdrop"; +import { useSentryFeedbackForm } from "src/feedback/hooks/useSentryFeedbackForm"; +import { parseEnvSupportedLocales } from "src/i18n/languageContextMenu/parseEnvSupportedLocales"; +import { LocalesLabels } from "src/i18n/constants"; + +const uniqueId = "b2e4c1a8-3f5d-4e6a-9c7b-1d2e3f4a5b6c"; + +export const DATA_TEST_ID = { + PAGE_HEADER_CONTAINER: `page-header-container-${uniqueId}`, + PAGE_HEADER_LOGO: `page-header-logo-${uniqueId}`, + PAGE_HEADER_LOGO_LINK: `page-header-logo-link-${uniqueId}`, + PAGE_HEADER_TITLE: `page-header-title-${uniqueId}`, + PAGE_HEADER_SUBTITLE: `page-header-subtitle-${uniqueId}`, + PAGE_HEADER_BACK_LINK: `page-header-back-link-${uniqueId}`, + PAGE_HEADER_BUTTON_USER: `page-header-button-user-${uniqueId}`, + PAGE_HEADER_ICON_USER: `page-header-icon-user-${uniqueId}`, + PAGE_HEADER_BUTTON_FEEDBACK: `page-header-button-feedback-${uniqueId}`, + PAGE_HEADER_BUTTON_MENU: `page-header-button-menu-${uniqueId}`, + PAGE_HEADER_MOBILE_LANGUAGE_TRIGGER: `page-header-mobile-language-trigger-${uniqueId}`, +}; + +export const MENU_ITEM_ID = { + LOGOUT_BUTTON: `page-logout-button-${uniqueId}`, + REPORT_BUG_BUTTON: `report-bug-button-${uniqueId}`, + REGISTER: `register-${uniqueId}`, + VIEW_PROFILE: `page-header-view-profile-${uniqueId}`, + MOBILE_LANGUAGE: `page-header-mobile-language-${uniqueId}`, +}; + +export interface PageHeaderProps { + title: string; + subtitle?: string; + backLinkLabel?: string; + onBackClick?: () => void; +} + +const PageHeader: React.FC = ({ title, subtitle, backLinkLabel, onBackClick }) => { + const theme = useTheme(); + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down("sm")); + const { enqueueSnackbar } = useSnackbar(); + const [anchorEl, setAnchorEl] = useState(null); + const [languageSubmenuAnchorEl, setLanguageSubmenuAnchorEl] = useState(null); + const [showConversionDialog, setShowConversionDialog] = useState(false); + const [showLogoutConfirmation, setShowLogoutConfirmation] = useState(false); + const [isLoggingOut, setIsLoggingOut] = useState(false); + const isOnline = useContext(IsOnlineContext); + const { sentryEnabled, openFeedbackForm } = useSentryFeedbackForm(); + + const user = authenticationStateService.getInstance().getUser(); + const isAnonymous = !user?.name || !user?.email; + + const logoUrlFromEnv = getAppIconUrl(); + const logoSrc = logoUrlFromEnv || `${process.env.PUBLIC_URL}/compass.svg`; + + const handleGiveFeedback = useCallback(async () => { + await openFeedbackForm(); + }, [openFeedbackForm]); + + const handleReportBug = useCallback(() => { + void openFeedbackForm(); + }, [openFeedbackForm]); + + const handleLogout = useCallback(async () => { + if (isAnonymous) { + setShowLogoutConfirmation(true); + } else { + setIsLoggingOut(true); + const authenticationService = AuthenticationServiceFactory.getCurrentAuthenticationService(); + await authenticationService!.logout(); + navigate(routerPaths.LANDING, { replace: true }); + enqueueSnackbar(t("chat.chat.notifications.logoutSuccess"), { variant: "success" }); + setIsLoggingOut(false); + } + }, [isAnonymous, enqueueSnackbar, navigate, t]); + + const handleConfirmLogout = useCallback(async () => { + setShowLogoutConfirmation(false); + setIsLoggingOut(true); + const authenticationService = AuthenticationServiceFactory.getCurrentAuthenticationService(); + await authenticationService!.logout(); + navigate(routerPaths.LANDING, { replace: true }); + enqueueSnackbar(t("chat.chat.notifications.logoutSuccess"), { variant: "success" }); + setIsLoggingOut(false); + }, [enqueueSnackbar, navigate, t]); + + const handleRegister = useCallback(() => { + setShowLogoutConfirmation(false); + setShowConversionDialog(true); + }, []); + + const contextMenuItems: MenuItemConfig[] = useMemo( + () => [ + { + id: MENU_ITEM_ID.VIEW_PROFILE, + text: t("chat.chatHeader.viewMyProfile").toLowerCase(), + disabled: false, + action: () => { + setAnchorEl(null); + navigate(routerPaths.SETTINGS); + }, + }, + ...(sentryEnabled + ? [ + { + id: MENU_ITEM_ID.REPORT_BUG_BUTTON, + text: t("feedback.bugReport.reportBug").toLowerCase(), + disabled: !isOnline, + action: handleReportBug, + }, + ] + : []), + ...(isAnonymous + ? [ + { + id: MENU_ITEM_ID.REGISTER, + text: t("common.buttons.register").toLowerCase(), + disabled: !isOnline, + action: handleRegister, + }, + ] + : []), + { + id: MENU_ITEM_ID.LOGOUT_BUTTON, + text: t("common.buttons.logout").toLowerCase(), + disabled: !isOnline, + action: () => { + void handleLogout(); + }, + }, + ], + [t, sentryEnabled, isOnline, handleReportBug, isAnonymous, handleRegister, handleLogout, navigate] + ); + + const supportedLocales = useMemo(() => parseEnvSupportedLocales(), []); + + const localeMenuItems: MenuItemConfig[] = useMemo( + () => + supportedLocales.map((locale) => ({ + id: `locale-${locale}`, + text: LocalesLabels[locale], + disabled: false, + action: () => { + void i18n.changeLanguage(locale); + setAnchorEl(null); + setLanguageSubmenuAnchorEl(null); + }, + })), + [supportedLocales, i18n] + ); + + const mobileMenuItems: MenuItemConfig[] = useMemo(() => { + return [ + { + id: MENU_ITEM_ID.VIEW_PROFILE, + text: t("chat.chatHeader.viewMyProfile").toLowerCase(), + disabled: false, + action: () => { + setAnchorEl(null); + navigate(routerPaths.SETTINGS); + }, + }, + { + id: MENU_ITEM_ID.MOBILE_LANGUAGE, + text: t("i18n.languageContextMenu.selector" as TranslationKey).toLowerCase(), + disabled: false, + action: () => { + setLanguageSubmenuAnchorEl(anchorEl); + }, + closeMenuOnClick: false, + trailingIcon: , + }, + ...(sentryEnabled + ? [ + { + id: MENU_ITEM_ID.REPORT_BUG_BUTTON, + text: t("feedback.bugReport.reportBug").toLowerCase(), + disabled: !isOnline, + action: handleReportBug, + }, + ] + : []), + ...(isAnonymous + ? [ + { + id: MENU_ITEM_ID.REGISTER, + text: t("common.buttons.register").toLowerCase(), + disabled: !isOnline, + action: handleRegister, + }, + ] + : []), + { + id: MENU_ITEM_ID.LOGOUT_BUTTON, + text: t("common.buttons.logout").toLowerCase(), + disabled: !isOnline, + action: () => { + void handleLogout(); + }, + }, + ]; + }, [t, anchorEl, sentryEnabled, isOnline, handleReportBug, isAnonymous, handleRegister, handleLogout, navigate]); + + return ( + <> + + {/* Zambia flag stripe: green, red, black, orange */} + + + + + + + + + + + + {t("app.compassLogoAlt")} + + + + + {t(title as TranslationKey)} + + {subtitle && !isMobile && ( + + {t(subtitle as TranslationKey)} + + )} + + + {isMobile ? ( + setAnchorEl(event.currentTarget)} + data-testid={DATA_TEST_ID.PAGE_HEADER_BUTTON_MENU} + title={t("chat.chatHeader.userInfo").toLowerCase()} + > + + + ) : ( + <> + {sentryEnabled && ( + { + void handleGiveFeedback(); + }} + data-testid={DATA_TEST_ID.PAGE_HEADER_BUTTON_FEEDBACK} + title={t("chat.chatHeader.giveFeedback").toLowerCase()} + disabled={!isOnline} + > + + + )} + + setAnchorEl(event.currentTarget)} + data-testid={DATA_TEST_ID.PAGE_HEADER_BUTTON_USER} + title={t("chat.chatHeader.userInfo").toLowerCase()} + > + {t("chat.chatHeader.userIconAlt")} + + + )} + + { + setAnchorEl(null); + setLanguageSubmenuAnchorEl(null); + }} + items={isMobile ? mobileMenuItems : contextMenuItems} + /> + setLanguageSubmenuAnchorEl(null)} + items={localeMenuItems} + anchorOrigin={{ vertical: "top", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "left" }} + paperSx={{ maxHeight: 320 }} + /> + setShowConversionDialog(false)} + onSuccess={() => { + // Set the account conversion flag in persistent storage + PersistentStorageService.setAccountConverted(true); + }} + /> + setShowLogoutConfirmation(false)} + onConfirm={handleRegister} + title={t("chat.chatHeader.beforeYouGo")} + confirmButtonText={t("common.buttons.register")} + cancelButtonText={t("common.buttons.logout")} + showCloseIcon={true} + textParagraphs={[ + { + id: "1", + text: <>{t("chat.chatHeader.logoutConfirmationMessage")}, + }, + { + id: "2", + text: ( + <> + {t("chat.chatHeader.anonymousAccountWarning")} + {t("chat.chatHeader.logoutWarningAnonymous")}. + + ), + }, + { + id: "3", + text: ( + <> + {t("chat.chatHeader.createAccountToSaveProgress")}{" "} + {t("chat.chatHeader.continueYourJourneyLater")} + + ), + }, + ]} + /> + + {isLoggingOut && } + + {backLinkLabel && onBackClick && ( + + { + startTransition(() => { + onBackClick(); + }); + }} + data-testid={DATA_TEST_ID.PAGE_HEADER_BACK_LINK} + sx={{ + display: "inline-flex", + alignItems: "flex-start", + gap: 0.5, + fontSize: "0.85rem", + color: theme.palette.secondary.dark, + textDecoration: "none", + "&:hover": { + color: theme.palette.secondary.light, + textDecoration: "underline", + }, + }} + > + + {t(backLinkLabel as TranslationKey)} + + + )} + + ); +}; + +export default PageHeader; diff --git a/frontend-new/src/home/components/ProgressBar/ProgressBar.stories.tsx b/frontend-new/src/home/components/ProgressBar/ProgressBar.stories.tsx new file mode 100644 index 00000000..54ae5c68 --- /dev/null +++ b/frontend-new/src/home/components/ProgressBar/ProgressBar.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import ProgressBar from "src/home/components/ProgressBar/ProgressBar"; + +const meta: Meta = { + title: "Home/ProgressBar", + component: ProgressBar, + tags: ["autodocs"], + argTypes: { + progress: { + control: { type: "range", min: 0, max: 100, step: 1 }, + description: "The profile strength progress percentage (0–100)", + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { + progress: 0, + }, +}; + +export const LowProgress: Story = { + args: { + progress: 15, + }, +}; + +export const MidProgress: Story = { + args: { + progress: 50, + }, +}; + +export const HighProgress: Story = { + args: { + progress: 85, + }, +}; + +export const Complete: Story = { + args: { + progress: 100, + }, +}; diff --git a/frontend-new/src/home/components/ProgressBar/ProgressBar.test.tsx b/frontend-new/src/home/components/ProgressBar/ProgressBar.test.tsx new file mode 100644 index 00000000..8f1f72de --- /dev/null +++ b/frontend-new/src/home/components/ProgressBar/ProgressBar.test.tsx @@ -0,0 +1,57 @@ +import "src/_test_utilities/consoleMock"; +import "src/_test_utilities/envServiceMock"; + +import React from "react"; +import { render, screen, userEvent } from "src/_test_utilities/test-utils"; +import ProgressBar, { DATA_TEST_ID } from "./ProgressBar"; +import { routerPaths } from "src/app/routerPaths"; + +const mockNavigate = jest.fn(); + +jest.mock("react-router-dom", () => { + const actual = jest.requireActual("react-router-dom"); + return { + ...actual, + __esModule: true, + useNavigate: () => mockNavigate, + }; +}); + +describe("ProgressBar", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should render container, progress bar, profile strength text, see profile link, and hint", () => { + // GIVEN a progress value in the 0-100 range + const givenProgress = 40; + + // WHEN the ProgressBar is rendered + render(); + + // THEN the container is in the document + const container = screen.getByTestId(DATA_TEST_ID.PROGRESS_BAR_CONTAINER); + expect(container).toBeInTheDocument(); + // AND the progress bar shows the value + const progressBar = screen.getByTestId(DATA_TEST_ID.PROGRESS_BAR); + expect(progressBar).toBeInTheDocument(); + expect(progressBar).toHaveAttribute("aria-valuenow", "40"); + // AND the profile strength text, see a profile link, and a hint is present + expect(screen.getByTestId(DATA_TEST_ID.PROGRESS_BAR_STRENGTH_TEXT)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.PROGRESS_BAR_SEE_PROFILE_LINK)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.PROGRESS_BAR_HINT)).toBeInTheDocument(); + // AND to match the snapshot + expect(container).toMatchSnapshot(); + }); + + test("should navigate to settings when see profile link is clicked", async () => { + // GIVEN the ProgressBar is rendered + render(); + + // WHEN the see profile link is clicked + await userEvent.click(screen.getByTestId(DATA_TEST_ID.PROGRESS_BAR_SEE_PROFILE_LINK)); + + // THEN navigate is called with the settings route + expect(mockNavigate).toHaveBeenCalledWith(routerPaths.SETTINGS); + }); +}); diff --git a/frontend-new/src/home/components/ProgressBar/ProgressBar.tsx b/frontend-new/src/home/components/ProgressBar/ProgressBar.tsx new file mode 100644 index 00000000..fc28651c --- /dev/null +++ b/frontend-new/src/home/components/ProgressBar/ProgressBar.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { Box, LinearProgress, Typography, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { routerPaths } from "src/app/routerPaths"; +import CustomLink from "src/theme/CustomLink/CustomLink"; + +const uniqueId = "31a67579-b5e1-40dc-9ab1-eeffa3fc0207"; + +export const DATA_TEST_ID = { + PROGRESS_BAR_CONTAINER: `progress-bar-container-${uniqueId}`, + PROGRESS_BAR: `progress-bar-${uniqueId}`, + PROGRESS_BAR_STRENGTH_TEXT: `progress-bar-strength-text-${uniqueId}`, + PROGRESS_BAR_SEE_PROFILE_LINK: `progress-bar-see-profile-link-${uniqueId}`, + PROGRESS_BAR_HINT: `progress-bar-hint-${uniqueId}`, +}; + +export interface ProgressBarProps { + progress: number; // 0-100 +} + +const ProgressBar: React.FC = ({ progress }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + // Clamp progress between 0 and 100 + const clampedProgress = 40; + + return ( + + + + {t("home.profileStrength", { progress: clampedProgress })} + + navigate(routerPaths.SETTINGS)} + sx={{ + ...theme.typography.body2, + color: theme.palette.secondary.dark, + textDecoration: "none", + fontWeight: "bold", + "&:hover": { + color: theme.palette.secondary.light, + textDecoration: "underline", + }, + }} + data-testid={DATA_TEST_ID.PROGRESS_BAR_SEE_PROFILE_LINK} + > + {t("home.seeProfile")} + + + + + {t("home.profileStrengthHint")} + + + ); +}; + +export default ProgressBar; diff --git a/frontend-new/src/home/components/ProgressBar/__snapshots__/ProgressBar.test.tsx.snap b/frontend-new/src/home/components/ProgressBar/__snapshots__/ProgressBar.test.tsx.snap new file mode 100644 index 00000000..664fdbc7 --- /dev/null +++ b/frontend-new/src/home/components/ProgressBar/__snapshots__/ProgressBar.test.tsx.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProgressBar should render container, progress bar, profile strength text, see profile link, and hint 1`] = ` +
+
+

+ Your current profile strength is 40%. +

+ + See profile → + +
+ + + +

+ That's a strong start — try My Skills & Interests to surface more about what you know and enjoy. +

+
+`; diff --git a/frontend-new/src/home/hooks/useModuleProgress.test.ts b/frontend-new/src/home/hooks/useModuleProgress.test.ts new file mode 100644 index 00000000..2265cbc4 --- /dev/null +++ b/frontend-new/src/home/hooks/useModuleProgress.test.ts @@ -0,0 +1,134 @@ +import "src/_test_utilities/consoleMock"; +import "src/_test_utilities/envServiceMock"; + +import { renderHook, waitFor } from "src/_test_utilities/test-utils"; +import { useModuleProgress } from "./useModuleProgress"; +import { ConversationPhase } from "src/chat/chatProgressbar/types"; +import type { ConversationResponse } from "src/chat/ChatService/ChatService.types"; + +const mockGetChatHistory = jest.fn(); + +const mockPrefsInstance = { + getUserPreferences: jest.fn().mockReturnValue({ sessions: [] }), + getActiveSessionId: jest.fn().mockReturnValue(null), +}; + +jest.mock("src/userPreferences/UserPreferencesStateService", () => ({ + __esModule: true, + default: { + getInstance: () => mockPrefsInstance, + }, +})); +jest.mock("src/chat/ChatService/ChatService", () => ({ + __esModule: true, + default: { + getInstance: () => ({ + getChatHistory: mockGetChatHistory, + }), + }, +})); + +describe("useModuleProgress", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetChatHistory.mockResolvedValue(null); + mockPrefsInstance.getUserPreferences.mockReturnValue({ sessions: [] }); + mockPrefsInstance.getActiveSessionId.mockReturnValue(null); + }); + + describe("overallProgress and isModuleStarted", () => { + test("should return overallProgress 0 and isModuleStarted false when no sessions", () => { + // GIVEN user preferences with no sessions and no active session + // WHEN the hook is run + const { result } = renderHook(() => useModuleProgress()); + + // THEN overallProgress is 0 + expect(result.current.overallProgress).toBe(0); + // AND isModuleStarted is false + expect(result.current.isModuleStarted).toBe(false); + }); + + test("should return isModuleStarted true when user has sessions", () => { + // GIVEN user preferences with at least one session + mockPrefsInstance.getUserPreferences.mockReturnValue({ sessions: [{ id: "s1" }] }); + + // WHEN the hook is run + const { result } = renderHook(() => useModuleProgress()); + + // THEN isModuleStarted is true + expect(result.current.isModuleStarted).toBe(true); + }); + }); + + describe("getBadgeStatus", () => { + test("should return null for modules other than skills_discovery", () => { + // GIVEN the hook is run (any state) + const { result } = renderHook(() => useModuleProgress()); + + // WHEN getBadgeStatus is called for career_explorer + const status = result.current.getBadgeStatus("career_explorer"); + + // THEN it returns null + expect(status).toBeNull(); + }); + + test("should return null for skills_discovery when there is no active session", () => { + // GIVEN no active session + const { result } = renderHook(() => useModuleProgress()); + + // WHEN getBadgeStatus is called for skills_discovery + const status = result.current.getBadgeStatus("skills_discovery"); + + // THEN it returns null + expect(status).toBeNull(); + }); + + test("should return continue when active session and phase is COLLECT_EXPERIENCES", async () => { + // GIVEN an active session and chat history with COLLECT_EXPERIENCES phase + const history: ConversationResponse = { + messages: [], + conversation_completed: false, + conversation_conducted_at: null, + experiences_explored: 0, + current_phase: { phase: ConversationPhase.COLLECT_EXPERIENCES, percentage: 0, current: null, total: null }, + }; + mockGetChatHistory.mockResolvedValue(history); + mockPrefsInstance.getUserPreferences.mockReturnValue({ sessions: [{ id: "s1" }] }); + mockPrefsInstance.getActiveSessionId.mockReturnValue("session-123"); + + // WHEN the hook is run and chat history has loaded + const { result } = renderHook(() => useModuleProgress()); + await waitFor(() => { + expect(result.current.getBadgeStatus("skills_discovery")).not.toBeNull(); + }); + + // THEN getBadgeStatus returns continue for skills_discovery + expect(result.current.getBadgeStatus("skills_discovery")).toBe("continue"); + }); + + test("should return completed when active session and phase is ENDED", async () => { + // GIVEN an active session and chat history with ENDED phase + const history: ConversationResponse = { + messages: [], + conversation_completed: true, + conversation_conducted_at: null, + experiences_explored: 0, + current_phase: { phase: ConversationPhase.ENDED, percentage: 100, current: null, total: null }, + }; + mockGetChatHistory.mockResolvedValue(history); + mockPrefsInstance.getUserPreferences.mockReturnValue({ sessions: [{ id: "s1" }] }); + mockPrefsInstance.getActiveSessionId.mockReturnValue("session-123"); + + // WHEN the hook is run and chat history has loaded + const { result } = renderHook(() => useModuleProgress()); + await waitFor(() => { + expect(result.current.getBadgeStatus("skills_discovery")).toBe("completed"); + }); + + // THEN getBadgeStatus returns completed and overallProgress reflects one completed module + expect(result.current.getBadgeStatus("skills_discovery")).toBe("completed"); + // AND overallProgress is 20% (1 of 5 modules completed) + expect(result.current.overallProgress).toBe(20); + }); + }); +}); diff --git a/frontend-new/src/home/hooks/useModuleProgress.ts b/frontend-new/src/home/hooks/useModuleProgress.ts new file mode 100644 index 00000000..8a2d36b5 --- /dev/null +++ b/frontend-new/src/home/hooks/useModuleProgress.ts @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useState } from "react"; +import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; +import ChatService from "src/chat/ChatService/ChatService"; +import { ConversationResponse } from "src/chat/ChatService/ChatService.types"; +import { ConversationPhase } from "src/chat/chatProgressbar/types"; +import { ChatError } from "src/error/commonErrors"; + +const MODULE_IDS = [ + "skills_discovery", + "career_discovery", + "job_readiness", + "career_explorer", + "knowledge_hub", +] as const; +const TOTAL_MODULE_COUNT = MODULE_IDS.length; + +/** + * Hook to calculate module progress and determine badge status for modules + * + * For Skills & Interests module: + * - Shows "Continue" badge when the conversation phase is COLLECT_EXPERIENCES + * - Shows "Completed" badge when the conversation phase is ENDED + * - Shows no badge for other phases or when no active session + */ +export const useModuleProgress = () => { + const userPreferences = UserPreferencesStateService.getInstance().getUserPreferences(); + const sessions = userPreferences?.sessions; + const activeSessionId = useMemo(() => { + return UserPreferencesStateService.getInstance().getActiveSessionId(); + }, []); + + const [chatHistory, setChatHistory] = useState(null); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + + // Fetch chat history when the active session changes + useEffect(() => { + if (!activeSessionId) { + setChatHistory(null); + return; + } + + let isMounted = true; + setIsLoadingHistory(true); + + ChatService.getInstance() + .getChatHistory(activeSessionId) + .then((history) => { + if (isMounted) { + setChatHistory(history); + setIsLoadingHistory(false); + } + }) + .catch((error) => { + console.warn(new ChatError("Failed to fetch chat history for badge status", error)); + if (isMounted) { + setChatHistory(null); + setIsLoadingHistory(false); + } + }); + + return () => { + isMounted = false; + }; + }, [activeSessionId]); + + // Tracks whether the user has started at least one module. + const isModuleStarted = useMemo(() => { + return Array.isArray(sessions) && sessions.length > 0; + }, [sessions]); + + const completedModulesCount = useMemo(() => { + const phase = chatHistory?.current_phase?.phase; + + // For now, only Skills & Interests has a known completion signal. + return MODULE_IDS.filter((moduleId) => { + if (moduleId === "skills_discovery") { + return phase === ConversationPhase.ENDED; + } + // Add completion conditions for the remaining modules. + + return false; + }).length; + }, [chatHistory]); + + // Calculate overall progress across all modules. + const overallProgress = useMemo(() => { + return (completedModulesCount / TOTAL_MODULE_COUNT) * 100; + }, [completedModulesCount]); + + // Get badge status for a specific module based on conversation phase + const getBadgeStatus = (moduleId: string): "continue" | "completed" | null => { + // Only show badges for Skills & Interests module + if (moduleId !== "skills_discovery") { + return null; + } + + // Don't show badge if no active session or history is loading + if (!activeSessionId || isLoadingHistory || !chatHistory) { + return null; + } + + const phase = chatHistory.current_phase?.phase; + + if (phase === ConversationPhase.COLLECT_EXPERIENCES) { + return "continue"; + } + + if (phase === ConversationPhase.ENDED) { + return "completed"; + } + + return null; + }; + + return { + overallProgress, + getBadgeStatus, + isModuleStarted, + }; +}; diff --git a/frontend-new/src/home/modulesService.ts b/frontend-new/src/home/modulesService.ts new file mode 100644 index 00000000..19ab6333 --- /dev/null +++ b/frontend-new/src/home/modulesService.ts @@ -0,0 +1,52 @@ +import { routerPaths } from "src/app/routerPaths"; + +export interface Module { + id: string; + labelKey: string; + descriptionKey: string; + route: string; +} + +/** + * The default set of modules that are always available on the Home page. + */ +const DEFAULT_MODULES: Module[] = [ + { + id: "skills_discovery", + labelKey: "home.modules.skillsDiscovery", + descriptionKey: "home.modules.skillsDiscoveryDesc", + route: routerPaths.SKILLS_INTERESTS, + }, + { + id: "career_discovery", + labelKey: "home.modules.careerDiscovery", + descriptionKey: "home.modules.careerDiscoveryDesc", + route: routerPaths.SKILLS_INTERESTS, + }, + { + id: "job_readiness", + labelKey: "home.modules.jobReadiness", + descriptionKey: "home.modules.jobReadinessDesc", + route: routerPaths.SKILLS_INTERESTS, + }, + { + id: "career_explorer", + labelKey: "home.modules.careerExplorer", + descriptionKey: "home.modules.careerExplorerDesc", + route: routerPaths.SKILLS_INTERESTS, + }, + { + id: "knowledge_hub", + labelKey: "home.modules.knowledgeHub", + descriptionKey: "home.modules.knowledgeHubDesc", + route: routerPaths.SKILLS_INTERESTS, + }, +]; + +/** + * Gets the list of enabled modules. + * Returns the hardcoded default set of 5 modules. + */ +export const getEnabledModules = (): Module[] => { + return DEFAULT_MODULES; +}; diff --git a/frontend-new/src/i18n/locales/en-GB/translation.json b/frontend-new/src/i18n/locales/en-GB/translation.json index a2c8d399..ec0fe8be 100644 --- a/frontend-new/src/i18n/locales/en-GB/translation.json +++ b/frontend-new/src/i18n/locales/en-GB/translation.json @@ -240,6 +240,7 @@ "feedbackSuccessMessage": "Thank you for your feedback!", "giveGeneralFeedback": "Give general feedback", "userInfo": "User info", + "viewMyProfile": "View my profile", "userIconAlt": "User Icon", "beforeYouGo": "Before you go", "logoutConfirmationMessage": "Are you sure you want to log out?", @@ -768,5 +769,39 @@ "submit": "Submit", "next": "Next", "previous": "Previous" + }, + "home": { + "dashboard": "Your Career Hub", + "backToDashboard": "Back to dashboard", + "welcomeBack": "Welcome back, {{name}}", + "subtitle": "{{appName}} is the bridge between what you've learned and where you're headed. Chat with AI to surface your skills, prep for interviews, and explore real career routes across Zambia.", + "profileStrength": "Your current profile strength is {{progress}}%.", + "profileStrengthHint": "That's a strong start — try My Skills & Interests to surface more about what you know and enjoy.", + "seeProfile": "See profile →", + "whatToDo": "What would you like to do today?", + "whatToDoSubtitle": "Each module is an AI-guided conversation or resource to help you on your career journey.", + "modules": { + "skillsDiscovery": "My Skills & Interests", + "skillsDiscoverySubtitle": "Uncover your skills and interests", + "careerDiscovery": "Career Explorer", + "jobReadiness": "Career Readiness", + "careerExplorer": "Job Matching & Analytics", + "knowledgeHub": "Knowledge Hub", + "skillsDiscoveryDesc": "Chat with {{appName}} to uncover your skills and interests — from your studies, work, and everyday life.", + "careerDiscoveryDesc": "Explore career pathways in Energy, Mining, Agriculture and more — with salary data and qualification routes.", + "jobReadinessDesc": "Build essential workplace skills through guided AI modules — from CV writing to interview practice.", + "careerExplorerDesc": "See how your skills match to real job openings and explore labour market insights across Zambia.", + "knowledgeHubDesc": "Explore sector profiles, salary benchmarks, and TEVET qualification pathways all in one place.", + "continue": "Continue", + "completed": "Completed", + "comingSoon": "Coming Soon" + }, + "footer": { + "privacyPolicy": "Privacy Policy", + "termsOfUse": "Terms of Use", + "accessibility": "Accessibility", + "contact": "Contact", + "collaboration": "{{appName}} is a collaboration between the World Bank Group and Tabiya." + } } } diff --git a/frontend-new/src/i18n/locales/en-US/translation.json b/frontend-new/src/i18n/locales/en-US/translation.json index cb867496..dc9de438 100644 --- a/frontend-new/src/i18n/locales/en-US/translation.json +++ b/frontend-new/src/i18n/locales/en-US/translation.json @@ -110,6 +110,7 @@ "feedbackSuccessMessage": "Thank you for your feedback!", "giveGeneralFeedback": "Give general feedback", "userInfo": "User info", + "viewMyProfile": "View my profile", "userIconAlt": "User Icon", "beforeYouGo": "Before you go", "logoutConfirmationMessage": "Are you sure you want to log out?", @@ -768,5 +769,39 @@ "submit": "Submit", "next": "Next", "previous": "Previous" + }, + "home": { + "dashboard": "Your Career Hub", + "backToDashboard": "Back to dashboard", + "welcomeBack": "Welcome back, {{name}}", + "subtitle": "{{appName}} is the bridge between what you've learned and where you're headed. Chat with AI to surface your skills, prep for interviews, and explore real career routes across Zambia.", + "profileStrength": "Your current profile strength is {{progress}}%.", + "profileStrengthHint": "That's a strong start — try My Skills & Interests to surface more about what you know and enjoy.", + "seeProfile": "See profile →", + "whatToDo": "What would you like to do today?", + "whatToDoSubtitle": "Each module is an AI-guided conversation or resource to help you on your career journey.", + "modules": { + "skillsDiscovery": "My Skills & Interests", + "skillsDiscoverySubtitle": "Uncover your skills and interests", + "careerDiscovery": "Career Explorer", + "jobReadiness": "Career Readiness", + "careerExplorer": "Job Matching & Analytics", + "knowledgeHub": "Knowledge Hub", + "skillsDiscoveryDesc": "Chat with {{appName}} to uncover your skills and interests — from your studies, work, and everyday life.", + "careerDiscoveryDesc": "Explore career pathways in Energy, Mining, Agriculture and more — with salary data and qualification routes.", + "jobReadinessDesc": "Build essential workplace skills through guided AI modules — from CV writing to interview practice.", + "careerExplorerDesc": "See how your skills match to real job openings and explore labour market insights across Zambia.", + "knowledgeHubDesc": "Explore sector profiles, salary benchmarks, and TEVET qualification pathways all in one place.", + "continue": "Continue", + "completed": "Completed", + "comingSoon": "Coming Soon" + }, + "footer": { + "privacyPolicy": "Privacy Policy", + "termsOfUse": "Terms of Use", + "accessibility": "Accessibility", + "contact": "Contact", + "collaboration": "{{appName}} is a collaboration between the World Bank Group and Tabiya." + } } } diff --git a/frontend-new/src/i18n/locales/es-AR/translation.json b/frontend-new/src/i18n/locales/es-AR/translation.json index aa4a51f7..2e13f304 100644 --- a/frontend-new/src/i18n/locales/es-AR/translation.json +++ b/frontend-new/src/i18n/locales/es-AR/translation.json @@ -240,6 +240,7 @@ "feedbackSuccessMessage": "¡Gracias por tus comentarios!", "giveGeneralFeedback": "Dar comentarios generales", "userInfo": "Información del usuario", + "viewMyProfile": "Ver mi perfil", "userIconAlt": "Ícono de usuario", "beforeYouGo": "Antes de irte", "logoutConfirmationMessage": "¿Estás seguro de que deseas cerrar sesión?", @@ -768,5 +769,39 @@ "submit": "Enviar", "next": "Siguiente", "previous": "Anterior" + }, + "home": { + "dashboard": "Tu Centro de Carrera", + "backToDashboard": "Volver al panel", + "welcomeBack": "Bienvenido de nuevo, {{name}}", + "subtitle": "{{appName}} es el puente entre lo que aprendiste y hacia dónde vas. Chateá con la IA para descubrir tus habilidades, prepararte para entrevistas y explorar caminos laborales reales.", + "profileStrength": "Tu fortaleza de perfil actual es {{progress}}%.", + "profileStrengthHint": "Es un buen comienzo — probá Mis Habilidades e Intereses para descubrir más sobre lo que sabés y disfrutás.", + "seeProfile": "Ver perfil →", + "whatToDo": "¿Qué te gustaría hacer hoy?", + "whatToDoSubtitle": "Cada módulo es una conversación guiada por IA o un recurso para ayudarte en tu camino profesional.", + "modules": { + "skillsDiscovery": "Mis habilidades e intereses", + "skillsDiscoverySubtitle": "Descubrí tus habilidades e intereses", + "careerDiscovery": "Explorador de carreras", + "jobReadiness": "Preparación profesional", + "careerExplorer": "Búsqueda y análisis de empleo", + "knowledgeHub": "Centro de conocimiento", + "skillsDiscoveryDesc": "Chateá con {{appName}} para descubrir tus habilidades e intereses — desde tus estudios, trabajo y vida cotidiana.", + "careerDiscoveryDesc": "Explorá caminos profesionales en Energía, Minería, Agricultura y más — con datos salariales y rutas de calificación.", + "jobReadinessDesc": "Desarrollá habilidades esenciales para el trabajo a través de módulos guiados por IA — desde redacción de CV hasta práctica de entrevistas.", + "careerExplorerDesc": "Mirá cómo tus habilidades coinciden con ofertas de trabajo reales y explorá información del mercado laboral.", + "knowledgeHubDesc": "Explorá perfiles sectoriales, referencias salariales y rutas de calificación en un solo lugar.", + "continue": "Continuar", + "completed": "Completado", + "comingSoon": "Próximamente" + }, + "footer": { + "privacyPolicy": "Política de Privacidad", + "termsOfUse": "Términos de Uso", + "accessibility": "Accesibilidad", + "contact": "Contacto", + "collaboration": "{{appName}} es una colaboración entre el Grupo del Banco Mundial y Tabiya." + } } } diff --git a/frontend-new/src/i18n/locales/es-ES/translation.json b/frontend-new/src/i18n/locales/es-ES/translation.json index c4358469..289cd55f 100644 --- a/frontend-new/src/i18n/locales/es-ES/translation.json +++ b/frontend-new/src/i18n/locales/es-ES/translation.json @@ -241,6 +241,7 @@ "formTitle": "Dar comentarios generales", "giveGeneralFeedback": "Dar comentarios generales", "userInfo": "Información del usuario", + "viewMyProfile": "Ver mi perfil", "userIconAlt": "Icono de usuario", "beforeYouGo": "Antes de irte", "logoutConfirmationMessage": "¿Estás seguro de que deseas cerrar sesión?", @@ -768,5 +769,39 @@ "submit": "Enviar", "next": "Siguiente", "previous": "Anterior" + }, + "home": { + "dashboard": "Tu Centro de Carrera", + "backToDashboard": "Volver al panel", + "welcomeBack": "Bienvenido de nuevo, {{name}}", + "subtitle": "{{appName}} es el puente entre lo que has aprendido y hacia dónde te diriges. Chatea con la IA para descubrir tus habilidades, prepararte para entrevistas y explorar rutas profesionales reales.", + "profileStrength": "Tu fortaleza de perfil actual es {{progress}}%.", + "profileStrengthHint": "Es un buen comienzo — prueba Mis Habilidades e Intereses para descubrir más sobre lo que sabes y disfrutas.", + "seeProfile": "Ver perfil →", + "whatToDo": "¿Qué te gustaría hacer hoy?", + "whatToDoSubtitle": "Cada módulo es una conversación guiada por IA o un recurso para ayudarte en tu camino profesional.", + "modules": { + "skillsDiscovery": "Mis habilidades e intereses", + "skillsDiscoverySubtitle": "Descubre tus habilidades e intereses", + "careerDiscovery": "Explorador de carreras", + "jobReadiness": "Preparación profesional", + "careerExplorer": "Búsqueda y análisis de empleo", + "knowledgeHub": "Centro de conocimiento", + "skillsDiscoveryDesc": "Chatea con {{appName}} para descubrir tus habilidades e intereses — desde tus estudios, trabajo y vida cotidiana.", + "careerDiscoveryDesc": "Explora caminos profesionales en Energía, Minería, Agricultura y más — con datos salariales y rutas de cualificación.", + "jobReadinessDesc": "Desarrolla habilidades esenciales para el trabajo a través de módulos guiados por IA — desde redacción de CV hasta práctica de entrevistas.", + "careerExplorerDesc": "Comprueba cómo tus habilidades coinciden con ofertas de trabajo reales y explora información del mercado laboral.", + "knowledgeHubDesc": "Explora perfiles sectoriales, referencias salariales y rutas de cualificación en un solo lugar.", + "continue": "Continuar", + "completed": "Completado", + "comingSoon": "Próximamente" + }, + "footer": { + "privacyPolicy": "Política de Privacidad", + "termsOfUse": "Términos de Uso", + "accessibility": "Accesibilidad", + "contact": "Contacto", + "collaboration": "{{appName}} es una colaboración entre el Grupo del Banco Mundial y Tabiya." + } } } diff --git a/frontend-new/src/i18n/locales/ny-ZM/translation.json b/frontend-new/src/i18n/locales/ny-ZM/translation.json index 94b9a744..f24e9a57 100644 --- a/frontend-new/src/i18n/locales/ny-ZM/translation.json +++ b/frontend-new/src/i18n/locales/ny-ZM/translation.json @@ -110,6 +110,7 @@ "feedbackSuccessMessage": "Zikomo chifukwa cha nkhani yanu!", "giveGeneralFeedback": "Perekani nkhani yonse", "userInfo": "Zobisika za wogwiritsa", + "viewMyProfile": "Onani mbiri yanga", "userIconAlt": "Chizindikiro cha Wogwiritsa", "beforeYouGo": "Musanapite", "logoutConfirmationMessage": "Mukutsimikiza kuti mukufuna kutuluka?", @@ -768,5 +769,39 @@ "submit": "Tumizani", "next": "Zotsatira", "previous": "Zomwe zidadza" + }, + "home": { + "dashboard": "Malo Anu a Ntchito", + "backToDashboard": "Bwererani ku dashibodi", + "welcomeBack": "Takulandirani, {{name}}", + "subtitle": "{{appName}} ndi mlatho pakati pa zomwe mwaphunzira ndi komwe mukupita. Lankhulani ndi AI kuti mupeze luso lanu, mukonzekere mafunso antchito, ndikufufuza njira za ntchito ku Zambia.", + "profileStrength": "Mphamvu ya mbiri yanu ndi {{progress}}%.", + "profileStrengthHint": "Ndi chiyambi chabwino — yesani Luso ndi Zomwe Ndimakonda kuti mudziwe zambiri za zomwe mukudziwa ndikusangalala nazo.", + "seeProfile": "Onani mbiri →", + "whatToDo": "Mungakonde kuchita chiyani lero?", + "whatToDoSubtitle": "Module iliyonse ndi kukambirana kotsogoleredwa ndi AI kapena chinthu chothandiza pa ulendo wanu wa ntchito.", + "modules": { + "skillsDiscovery": "Luso ndi Zomwe Ndimakonda", + "skillsDiscoverySubtitle": "Pezani luso lanu ndi zomwe mumakonda", + "careerDiscovery": "Kufufuza Ntchito", + "jobReadiness": "Kukonzekera Ntchito", + "careerExplorer": "Kufananiza Ntchito ndi Kusanthula", + "knowledgeHub": "Malo Ophunzirira", + "skillsDiscoveryDesc": "Lankhulani ndi {{appName}} kuti mupeze luso lanu ndi zomwe mumakonda — kuchokera ku maphunziro anu, ntchito, ndi moyo wa tsiku ndi tsiku.", + "careerDiscoveryDesc": "Fufuzani njira za ntchito mu Mphamvu, Migodi, Ulimi ndi zina — ndi chidziwitso cha malipiro ndi njira zoyenera.", + "jobReadinessDesc": "Phunzirani luso lofunikira la ntchito kudzera mu ma module otsogoleredwa ndi AI — kuyambira kulemba CV mpaka kuchita mafunso oyeserera.", + "careerExplorerDesc": "Onani momwe luso lanu limafanana ndi maudindo a ntchito enieni ndikufufuza chidziwitso cha msika wa ntchito ku Zambia.", + "knowledgeHubDesc": "Fufuzani mbiri ya magawo, malipiro, ndi njira za maphunziro a TEVET pamalo amodzi.", + "continue": "Pitirizani", + "completed": "Zatha", + "comingSoon": "Zikubwera posachedwa" + }, + "footer": { + "privacyPolicy": "Ndondomeko ya Chinsinsi", + "termsOfUse": "Malamulo a Ntchito", + "accessibility": "Kufikira", + "contact": "Lumikizani", + "collaboration": "{{appName}} ndi chiyanjano pakati pa Gulu la Banki ya Dziko Lonse ndi Tabiya." + } } } diff --git a/frontend-new/src/i18n/locales/sw-KE/translation.json b/frontend-new/src/i18n/locales/sw-KE/translation.json index 8c68ede9..afa31ef4 100644 --- a/frontend-new/src/i18n/locales/sw-KE/translation.json +++ b/frontend-new/src/i18n/locales/sw-KE/translation.json @@ -110,6 +110,7 @@ "feedbackSuccessMessage": "Asante kwa maoni yako!", "giveGeneralFeedback": "Toa maoni ya jumla", "userInfo": "Taarifa za mtumiaji", + "viewMyProfile": "Tazama wasifu wangu", "userIconAlt": "Ikoni ya Mtumiaji", "beforeYouGo": "Kabla hujaondoka", "logoutConfirmationMessage": "Una uhakika unataka kutoka?", @@ -768,5 +769,39 @@ "submit": "Wasilisha", "next": "Inayofuata", "previous": "Iliyotangulia" + }, + "home": { + "dashboard": "Kituo Chako cha Kazi", + "backToDashboard": "Rudi kwenye dashibodi", + "welcomeBack": "Karibu tena, {{name}}", + "subtitle": "{{appName}} ni daraja kati ya ulichojifunza na unakoelekea. Zungumza na AI ili kugundua ujuzi wako, kujiandaa kwa mahojiano, na kuchunguza njia za kazi nchini Zambia.", + "profileStrength": "Nguvu ya wasifu wako sasa ni {{progress}}%.", + "profileStrengthHint": "Ni mwanzo mzuri — jaribu Ujuzi na Mapendeleo Yangu ili kugundua zaidi kuhusu unachokijua na kufurahia.", + "seeProfile": "Tazama wasifu →", + "whatToDo": "Ungependa kufanya nini leo?", + "whatToDoSubtitle": "Kila moduli ni mazungumzo yanayoongozwa na AI au rasilimali kukusaidia katika safari yako ya kazi.", + "modules": { + "skillsDiscovery": "Ujuzi na Mapendeleo Yangu", + "skillsDiscoverySubtitle": "Gundua ujuzi na mapendeleo yako", + "careerDiscovery": "Kuchunguza Kazi", + "jobReadiness": "Kujiandaa kwa Kazi", + "careerExplorer": "Kulinganisha Kazi na Uchambuzi", + "knowledgeHub": "Kituo cha Maarifa", + "skillsDiscoveryDesc": "Zungumza na {{appName}} ili kugundua ujuzi na mapendeleo yako — kutoka masomo yako, kazi, na maisha ya kila siku.", + "careerDiscoveryDesc": "Chunguza njia za kazi katika Nishati, Madini, Kilimo na zaidi — na data ya mishahara na njia za sifa.", + "jobReadinessDesc": "Jenga ujuzi muhimu wa kazi kupitia moduli zinazoongozwa na AI — kutoka kuandika CV hadi mazoezi ya mahojiano.", + "careerExplorerDesc": "Angalia jinsi ujuzi wako unavyolingana na nafasi za kazi halisi na chunguza maarifa ya soko la ajira nchini Zambia.", + "knowledgeHubDesc": "Chunguza wasifu wa sekta, viwango vya mishahara, na njia za sifa za TEVET mahali pamoja.", + "continue": "Endelea", + "completed": "Imekamilika", + "comingSoon": "Inakuja Hivi Karibuni" + }, + "footer": { + "privacyPolicy": "Sera ya Faragha", + "termsOfUse": "Sheria za Matumizi", + "accessibility": "Ufikiaji", + "contact": "Wasiliana", + "collaboration": "{{appName}} ni ushirikiano kati ya Kikundi cha Benki ya Dunia na Tabiya." + } } } From 7af6a998595a39666c68e1cdd6bf3cbf8e7de35b Mon Sep 17 00:00:00 2001 From: Anselme Irumva Date: Mon, 2 Mar 2026 17:30:42 +0200 Subject: [PATCH 08/42] feat(fe): knowledge hub content management --- frontend-new/package.json | 9 + .../_test_utilities/rawLoaderTransformer.js | 19 + frontend-new/src/app/index.tsx | 21 + frontend-new/src/app/routerPaths.ts | 2 + frontend-new/src/home/modulesService.ts | 2 +- .../src/i18n/locales/en-GB/translation.json | 7 + .../src/i18n/locales/en-US/translation.json | 7 + .../src/i18n/locales/es-AR/translation.json | 9 +- .../src/i18n/locales/es-ES/translation.json | 7 + .../src/i18n/locales/ny-ZM/translation.json | 7 + .../src/i18n/locales/sw-KE/translation.json | 7 + .../components/BackButton/BackButton.tsx | 42 + .../components/BackButton/index.ts | 1 + .../components/DocumentCard/DocumentCard.tsx | 107 +++ .../components/DocumentCard/index.ts | 2 + .../KnowledgeHubPageHeader.tsx | 8 + .../KnowledgeHubPageHeader/index.ts | 1 + .../MarkdownReader/MarkdownReader.stories.tsx | 592 +++++++++++++ .../MarkdownReader/MarkdownReader.tsx | 117 +++ .../components/MarkdownReader/index.ts | 2 + .../src/knowledgeHub/documentLoader.ts | 68 ++ .../src/knowledgeHub/documents/agriculture.md | 54 ++ .../src/knowledgeHub/documents/energy.md | 53 ++ .../src/knowledgeHub/documents/hospitality.md | 54 ++ .../src/knowledgeHub/documents/mining.md | 52 ++ .../src/knowledgeHub/documents/water.md | 53 ++ .../src/knowledgeHub/iconRegistry.tsx | 27 + frontend-new/src/knowledgeHub/index.ts | 13 + frontend-new/src/knowledgeHub/markdown.d.ts | 4 + .../KnowledgeHubDocument.tsx | 86 ++ .../pages/KnowledgeHubDocument/index.ts | 2 + .../KnowledgeHubList/KnowledgeHubList.tsx | 103 +++ .../pages/KnowledgeHubList/index.ts | 2 + .../knowledgeHub/parseYamlFrontmatter.test.ts | 441 ++++++++++ .../src/knowledgeHub/parseYamlFrontmatter.ts | 51 ++ frontend-new/src/knowledgeHub/types.ts | 14 + frontend-new/src/setupTests.ts | 18 + frontend-new/yarn.lock | 800 +++++++++++++++++- 38 files changed, 2860 insertions(+), 4 deletions(-) create mode 100644 frontend-new/src/_test_utilities/rawLoaderTransformer.js create mode 100644 frontend-new/src/knowledgeHub/components/BackButton/BackButton.tsx create mode 100644 frontend-new/src/knowledgeHub/components/BackButton/index.ts create mode 100644 frontend-new/src/knowledgeHub/components/DocumentCard/DocumentCard.tsx create mode 100644 frontend-new/src/knowledgeHub/components/DocumentCard/index.ts create mode 100644 frontend-new/src/knowledgeHub/components/KnowledgeHubPageHeader/KnowledgeHubPageHeader.tsx create mode 100644 frontend-new/src/knowledgeHub/components/KnowledgeHubPageHeader/index.ts create mode 100644 frontend-new/src/knowledgeHub/components/MarkdownReader/MarkdownReader.stories.tsx create mode 100644 frontend-new/src/knowledgeHub/components/MarkdownReader/MarkdownReader.tsx create mode 100644 frontend-new/src/knowledgeHub/components/MarkdownReader/index.ts create mode 100644 frontend-new/src/knowledgeHub/documentLoader.ts create mode 100644 frontend-new/src/knowledgeHub/documents/agriculture.md create mode 100644 frontend-new/src/knowledgeHub/documents/energy.md create mode 100644 frontend-new/src/knowledgeHub/documents/hospitality.md create mode 100644 frontend-new/src/knowledgeHub/documents/mining.md create mode 100644 frontend-new/src/knowledgeHub/documents/water.md create mode 100644 frontend-new/src/knowledgeHub/iconRegistry.tsx create mode 100644 frontend-new/src/knowledgeHub/index.ts create mode 100644 frontend-new/src/knowledgeHub/markdown.d.ts create mode 100644 frontend-new/src/knowledgeHub/pages/KnowledgeHubDocument/KnowledgeHubDocument.tsx create mode 100644 frontend-new/src/knowledgeHub/pages/KnowledgeHubDocument/index.ts create mode 100644 frontend-new/src/knowledgeHub/pages/KnowledgeHubList/KnowledgeHubList.tsx create mode 100644 frontend-new/src/knowledgeHub/pages/KnowledgeHubList/index.ts create mode 100644 frontend-new/src/knowledgeHub/parseYamlFrontmatter.test.ts create mode 100644 frontend-new/src/knowledgeHub/parseYamlFrontmatter.ts create mode 100644 frontend-new/src/knowledgeHub/types.ts diff --git a/frontend-new/package.json b/frontend-new/package.json index 86e187b2..d0c73d6f 100644 --- a/frontend-new/package.json +++ b/frontend-new/package.json @@ -76,13 +76,16 @@ "jwt-decode": "^4.0.0", "lodash.debounce": "^4.0.8", "notistack": "^3.0.1", + "raw-loader": "^4.0.2", "react": "18.3.1", "react-device-detect": "^2.2.3", "react-dom": "~18.2.0", "react-i18next": "^14.1.2", + "react-markdown": "^10.1.0", "react-router-dom": "^6.23.1", "react-scripts": "^5.0.1", "react-swipeable": "^7.0.2", + "remark-gfm": "^4.0.1", "source-map-explorer": "^2.5.3", "web-vitals": "^2.1.4" }, @@ -123,6 +126,12 @@ "transformIgnorePatterns": [ "node_modules/(?!p-limit)/" ], + "transform": { + "^.+\\.md$": "/src/_test_utilities/rawLoaderTransformer.js" + }, + "moduleNameMapper": { + "^!!raw-loader!(.*)$": "$1" + }, "collectCoverageFrom": [ "src/**/*.{js,jsx,ts,tsx}", "!/node_modules/", diff --git a/frontend-new/src/_test_utilities/rawLoaderTransformer.js b/frontend-new/src/_test_utilities/rawLoaderTransformer.js new file mode 100644 index 00000000..e1c69ab0 --- /dev/null +++ b/frontend-new/src/_test_utilities/rawLoaderTransformer.js @@ -0,0 +1,19 @@ +// Custom Jest transformer for raw-loader imports +// This handles imports like: import content from "!!raw-loader!./file.md" +const fs = require("fs"); +const path = require("path"); + +module.exports = { + process(sourceText, sourcePath, options) { + // Extract the actual file path from the import + // The sourcePath will be something like: /path/to/project/src/knowledgeHub/documents/mining.md + + // Read the file content + const content = fs.readFileSync(sourcePath, "utf8"); + + // Return as a module that exports the content as default + return { + code: `module.exports = ${JSON.stringify(content)};`, + }; + }, +}; diff --git a/frontend-new/src/app/index.tsx b/frontend-new/src/app/index.tsx index 718168af..b758e95e 100644 --- a/frontend-new/src/app/index.tsx +++ b/frontend-new/src/app/index.tsx @@ -31,6 +31,9 @@ const LazyLoadedSensitiveDataForm = lazyWithPreload( ); const LazyLoadedChat = lazyWithPreload(() => import("src/chat/Chat")); +const LazyLoadedKnowledgeHubDocument = lazyWithPreload(() => import("src/knowledgeHub/pages/KnowledgeHubDocument")); +const LazyLoadedKnowledgeHubList = lazyWithPreload(() => import("src/knowledgeHub/pages/KnowledgeHubList")); + // Wrap the createHashRouter function with Sentry to capture errors that occur during router initialization const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createHashRouter); @@ -50,6 +53,8 @@ const ProtectedRouteKeys = { VERIFY_EMAIL: "VERIFY_EMAIL", CONSENT: "CONSENT", SENSITIVE_DATA: "SENSITIVE_DATA", + KNOWLEDGE_HUB: "KNOWLEDGE_HUB", + KNOWLEDGE_HUB_DOCUMENT: "KNOWLEDGE_HUB_DOCUMENT", }; const NotFound: React.FC = () => { @@ -317,6 +322,22 @@ const App = () => { ), }, + { + path: routerPaths.KNOWLEDGE_HUB, + element: ( + + + + ), + }, + { + path: routerPaths.KNOWLEDGE_HUB_DOCUMENT, + element: ( + + + + ), + }, { path: "*", element: , diff --git a/frontend-new/src/app/routerPaths.ts b/frontend-new/src/app/routerPaths.ts index 1281fe4d..821320ed 100644 --- a/frontend-new/src/app/routerPaths.ts +++ b/frontend-new/src/app/routerPaths.ts @@ -8,5 +8,7 @@ export const routerPaths = { VERIFY_EMAIL: "/verify-email", CONSENT: "/consent", SENSITIVE_DATA: "/sensitive-data", + KNOWLEDGE_HUB: "/knowledge-hub", + KNOWLEDGE_HUB_DOCUMENT: "/knowledge-hub/:documentId", SKILLS_INTERESTS: "/skills-interests", }; diff --git a/frontend-new/src/home/modulesService.ts b/frontend-new/src/home/modulesService.ts index 19ab6333..1c757fd0 100644 --- a/frontend-new/src/home/modulesService.ts +++ b/frontend-new/src/home/modulesService.ts @@ -39,7 +39,7 @@ const DEFAULT_MODULES: Module[] = [ id: "knowledge_hub", labelKey: "home.modules.knowledgeHub", descriptionKey: "home.modules.knowledgeHubDesc", - route: routerPaths.SKILLS_INTERESTS, + route: routerPaths.KNOWLEDGE_HUB, }, ]; diff --git a/frontend-new/src/i18n/locales/en-GB/translation.json b/frontend-new/src/i18n/locales/en-GB/translation.json index ec0fe8be..752873cc 100644 --- a/frontend-new/src/i18n/locales/en-GB/translation.json +++ b/frontend-new/src/i18n/locales/en-GB/translation.json @@ -803,5 +803,12 @@ "contact": "Contact", "collaboration": "{{appName}} is a collaboration between the World Bank Group and Tabiya." } + }, + "knowledgeHub": { + "backToDashboard": "Back to Dashboard", + "backToKnowledgeHub": "Back to Knowledge Hub", + "documentNotFound": "Document not found", + "introduction": "These are Zambia's priority sectors for TEVET graduates. Each profile gives you an overview of what the industry looks like, what roles exist, and what you can expect to earn. Explore the sectors that interest you to make more informed decisions about your career path.", + "noDocumentsAvailable": "No documents available yet." } } diff --git a/frontend-new/src/i18n/locales/en-US/translation.json b/frontend-new/src/i18n/locales/en-US/translation.json index dc9de438..f9cd6ea0 100644 --- a/frontend-new/src/i18n/locales/en-US/translation.json +++ b/frontend-new/src/i18n/locales/en-US/translation.json @@ -803,5 +803,12 @@ "contact": "Contact", "collaboration": "{{appName}} is a collaboration between the World Bank Group and Tabiya." } + }, + "knowledgeHub": { + "backToDashboard": "Back to Dashboard", + "backToKnowledgeHub": "Back to Knowledge Hub", + "documentNotFound": "Document not found", + "introduction": "These are Zambia's priority sectors for TEVET graduates. Each profile gives you an overview of what the industry looks like, what roles exist, and what you can expect to earn. Explore the sectors that interest you to make more informed decisions about your career path.", + "noDocumentsAvailable": "No documents available yet." } } diff --git a/frontend-new/src/i18n/locales/es-AR/translation.json b/frontend-new/src/i18n/locales/es-AR/translation.json index 2e13f304..cc03f339 100644 --- a/frontend-new/src/i18n/locales/es-AR/translation.json +++ b/frontend-new/src/i18n/locales/es-AR/translation.json @@ -770,10 +770,17 @@ "next": "Siguiente", "previous": "Anterior" }, + "knowledgeHub": { + "backToDashboard": "Volver al Panel", + "backToKnowledgeHub": "Volver al Centro de conocimiento", + "documentNotFound": "Documento no encontrado", + "introduction": "Estos son los sectores prioritarios de Zambia para graduados de TEVET. Cada perfil te brinda una descripción general de cómo se ve la industria, qué roles existen y qué podés esperar ganar. Explorá los sectores que te interesen para tomar decisiones más informadas sobre tu trayectoria profesional.", + "noDocumentsAvailable": "Aún no hay documentos disponibles." + }, "home": { "dashboard": "Tu Centro de Carrera", - "backToDashboard": "Volver al panel", "welcomeBack": "Bienvenido de nuevo, {{name}}", + "backToDashboard": "Volver al panel", "subtitle": "{{appName}} es el puente entre lo que aprendiste y hacia dónde vas. Chateá con la IA para descubrir tus habilidades, prepararte para entrevistas y explorar caminos laborales reales.", "profileStrength": "Tu fortaleza de perfil actual es {{progress}}%.", "profileStrengthHint": "Es un buen comienzo — probá Mis Habilidades e Intereses para descubrir más sobre lo que sabés y disfrutás.", diff --git a/frontend-new/src/i18n/locales/es-ES/translation.json b/frontend-new/src/i18n/locales/es-ES/translation.json index 289cd55f..f9c74261 100644 --- a/frontend-new/src/i18n/locales/es-ES/translation.json +++ b/frontend-new/src/i18n/locales/es-ES/translation.json @@ -803,5 +803,12 @@ "contact": "Contacto", "collaboration": "{{appName}} es una colaboración entre el Grupo del Banco Mundial y Tabiya." } + }, + "knowledgeHub": { + "backToDashboard": "Volver al Panel", + "backToKnowledgeHub": "Volver al Centro de conocimiento", + "documentNotFound": "Documento no encontrado", + "introduction": "Estos son los sectores prioritarios de Zambia para graduados de TEVET. Cada perfil te ofrece una descripción general de cómo es la industria, qué roles existen y qué puedes esperar ganar. Explora los sectores que te interesen para tomar decisiones más informadas sobre tu trayectoria profesional.", + "noDocumentsAvailable": "Aún no hay documentos disponibles." } } diff --git a/frontend-new/src/i18n/locales/ny-ZM/translation.json b/frontend-new/src/i18n/locales/ny-ZM/translation.json index f24e9a57..79bbd5cb 100644 --- a/frontend-new/src/i18n/locales/ny-ZM/translation.json +++ b/frontend-new/src/i18n/locales/ny-ZM/translation.json @@ -803,5 +803,12 @@ "contact": "Lumikizani", "collaboration": "{{appName}} ndi chiyanjano pakati pa Gulu la Banki ya Dziko Lonse ndi Tabiya." } + }, + "knowledgeHub": { + "backToDashboard": "Bwererani ku Dashibodi", + "backToKnowledgeHub": "Bwererani ku Malo Ophunzirira", + "documentNotFound": "Chikalata sichinapezeke", + "introduction": "Awa ndi magawo ofunikira a Zambia kwa omaliza maphunziro a TEVET. Mbiri iliyonse ikukupatsani chiwonetsero cha momwe makampaniwo akuwonekera, ntchito zomwe zilipo, ndi zomwe mungayembekezere kupeza. Fufuzani magawo omwe amakusangalatsani kuti mupange zisankho zanzeru pa njira yanu ya ntchito.", + "noDocumentsAvailable": "Palibe zikalata zomwe zilipo pakadali pano." } } diff --git a/frontend-new/src/i18n/locales/sw-KE/translation.json b/frontend-new/src/i18n/locales/sw-KE/translation.json index afa31ef4..1bc5dc88 100644 --- a/frontend-new/src/i18n/locales/sw-KE/translation.json +++ b/frontend-new/src/i18n/locales/sw-KE/translation.json @@ -803,5 +803,12 @@ "contact": "Wasiliana", "collaboration": "{{appName}} ni ushirikiano kati ya Kikundi cha Benki ya Dunia na Tabiya." } + }, + "knowledgeHub": { + "backToDashboard": "Rudi kwenye Dashibodi", + "backToKnowledgeHub": "Rudi kwenye Kituo cha Maarifa", + "documentNotFound": "Waraka haujapatikana", + "introduction": "Hizi ni sekta za kipaumbele za Zambia kwa wanaomaliza TEVET. Kila wasifu unakupa muhtasari wa jinsi tasnia inavyoonekana, nafasi zilizopo, na unachoweza kutarajia kupata. Chunguza sekta zinazokuvutia ili kufanya maamuzi sahihi zaidi kuhusu njia yako ya kazi.", + "noDocumentsAvailable": "Bado hakuna waraka zinazopatikana." } } diff --git a/frontend-new/src/knowledgeHub/components/BackButton/BackButton.tsx b/frontend-new/src/knowledgeHub/components/BackButton/BackButton.tsx new file mode 100644 index 00000000..89953467 --- /dev/null +++ b/frontend-new/src/knowledgeHub/components/BackButton/BackButton.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { Button, useTheme } from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import { useTranslation } from "react-i18next"; +import { TranslationKey } from "src/react-i18next"; + +const uniqueId = "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"; + +export const DATA_TEST_ID = { + BACK_BUTTON: `back-button-${uniqueId}`, +}; + +export interface BackButtonProps { + onClick: () => void; + labelKey: string; // Translation key for the button text + dataTestId?: string; +} + +const BackButton: React.FC = ({ onClick, labelKey, dataTestId }) => { + const theme = useTheme(); + const { t } = useTranslation(); + + return ( + + ); +}; + +export default BackButton; diff --git a/frontend-new/src/knowledgeHub/components/BackButton/index.ts b/frontend-new/src/knowledgeHub/components/BackButton/index.ts new file mode 100644 index 00000000..dfbe9a52 --- /dev/null +++ b/frontend-new/src/knowledgeHub/components/BackButton/index.ts @@ -0,0 +1 @@ +export { default } from "./BackButton"; diff --git a/frontend-new/src/knowledgeHub/components/DocumentCard/DocumentCard.tsx b/frontend-new/src/knowledgeHub/components/DocumentCard/DocumentCard.tsx new file mode 100644 index 00000000..dd3cb635 --- /dev/null +++ b/frontend-new/src/knowledgeHub/components/DocumentCard/DocumentCard.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { Box, Card, CardActionArea, Typography, useTheme } from "@mui/material"; +import { DocumentMetadata } from "src/knowledgeHub/types"; +import { getDocumentIcon } from "src/knowledgeHub/iconRegistry"; + +const uniqueId = "c5e8f7a2-9b3d-4e6f-8a1c-2d5e7f9b3c4a"; + +export const DATA_TEST_ID = { + DOCUMENT_CARD: `document-card-${uniqueId}`, + DOCUMENT_CARD_TITLE: `document-card-title-${uniqueId}`, + DOCUMENT_CARD_DESCRIPTION: `document-card-description-${uniqueId}`, + DOCUMENT_CARD_ICON: `document-card-icon-${uniqueId}`, +}; + +export interface DocumentCardProps { + document: DocumentMetadata; + onClick: (id: string) => void; +} + +const DocumentCard: React.FC = ({ document, onClick }) => { + const theme = useTheme(); + + const handleClick = () => { + onClick(document.id); + }; + + return ( + + + + {/* Icon */} + + {getDocumentIcon(document.icon || document.sector)} + + + {/* Title */} + + {document.title} + + + {/* Description */} + + {document.description} + + + + + ); +}; + +export default DocumentCard; diff --git a/frontend-new/src/knowledgeHub/components/DocumentCard/index.ts b/frontend-new/src/knowledgeHub/components/DocumentCard/index.ts new file mode 100644 index 00000000..850f87df --- /dev/null +++ b/frontend-new/src/knowledgeHub/components/DocumentCard/index.ts @@ -0,0 +1,2 @@ +export { default } from "./DocumentCard"; +export * from "./DocumentCard"; diff --git a/frontend-new/src/knowledgeHub/components/KnowledgeHubPageHeader/KnowledgeHubPageHeader.tsx b/frontend-new/src/knowledgeHub/components/KnowledgeHubPageHeader/KnowledgeHubPageHeader.tsx new file mode 100644 index 00000000..0bc77310 --- /dev/null +++ b/frontend-new/src/knowledgeHub/components/KnowledgeHubPageHeader/KnowledgeHubPageHeader.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import PageHeader from "src/home/components/PageHeader/PageHeader"; + +const KnowledgeHubPageHeader: React.FC = () => { + return ; +}; + +export default KnowledgeHubPageHeader; diff --git a/frontend-new/src/knowledgeHub/components/KnowledgeHubPageHeader/index.ts b/frontend-new/src/knowledgeHub/components/KnowledgeHubPageHeader/index.ts new file mode 100644 index 00000000..9d45a06e --- /dev/null +++ b/frontend-new/src/knowledgeHub/components/KnowledgeHubPageHeader/index.ts @@ -0,0 +1 @@ +export { default } from "./KnowledgeHubPageHeader"; diff --git a/frontend-new/src/knowledgeHub/components/MarkdownReader/MarkdownReader.stories.tsx b/frontend-new/src/knowledgeHub/components/MarkdownReader/MarkdownReader.stories.tsx new file mode 100644 index 00000000..a6867fba --- /dev/null +++ b/frontend-new/src/knowledgeHub/components/MarkdownReader/MarkdownReader.stories.tsx @@ -0,0 +1,592 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import MarkdownReader from "./MarkdownReader"; +import { Box } from "@mui/material"; + +const meta: Meta = { + title: "KnowledgeHub/Components/MarkdownReader", + component: MarkdownReader, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +// Basic Examples +export const SimpleText: Story = { + args: { + content: "This is a simple paragraph of text.", + }, +}; + +export const Headings: Story = { + args: { + content: `# Heading 1 +## Heading 2 +### Heading 3 + +This is a paragraph under heading 3.`, + }, +}; + +export const Paragraphs: Story = { + args: { + content: `This is the first paragraph with some text content. + +This is the second paragraph, separated by a blank line. + +This is the third paragraph with more content to demonstrate spacing.`, + }, +}; + +// List Examples +export const UnorderedList: Story = { + args: { + content: `# Shopping List + +- Milk +- Bread +- Eggs +- Butter +- Cheese`, + }, +}; + +export const OrderedList: Story = { + args: { + content: `# Steps to Success + +1. Set clear goals +2. Create a plan +3. Take action +4. Monitor progress +5. Adjust as needed`, + }, +}; + +export const NestedLists: Story = { + args: { + content: `# Career Path + +- Agriculture Sector + - Farm Management + - Agricultural Technology + - Crop Science +- Mining Sector + - Mining Engineering + - Geology + - Safety Management +- ICT Sector + - Software Development + - Network Administration + - Database Management`, + }, +}; + +// Text Formatting +export const TextFormatting: Story = { + args: { + content: `# Text Formatting Examples + +This is **bold text** and this is *italic text*. + +You can also combine them: ***bold and italic***. + +This is ~~strikethrough text~~. + +This is regular text with a \`code snippet\` inline.`, + }, +}; + +export const Blockquote: Story = { + args: { + content: `# Important Quote + +> This is a blockquote. It can be used to highlight important information or quotes from people. It stands out from regular paragraphs with special styling. + +This is a regular paragraph after the blockquote.`, + }, +}; + +// Code Examples +export const InlineCode: Story = { + args: { + content: `# Code Examples + +To use the \`console.log()\` function in JavaScript, simply write the code in your editor. + +You can also use variables like \`userName\` or \`totalAmount\` in your code.`, + }, +}; + +export const CodeBlock: Story = { + args: { + content: `# Code Block Example + +Here's a JavaScript function: + +\`\`\`javascript +function greet(name) { + return "Hello, " + name + "!"; +} + +console.log(greet("World")); +\`\`\` + +And here's a Python example: + +\`\`\`python +def calculate_sum(a, b): + return a + b + +result = calculate_sum(5, 3) +print(result) +\`\`\``, + }, +}; + +// Link Examples +export const Links: Story = { + args: { + content: `# Useful Links + +Check out [Google](https://www.google.com) for searching. + +Visit [GitHub](https://github.com) for code repositories. + +Learn more about [Markdown](https://www.markdownguide.org) formatting.`, + }, +}; + +// Table Examples +export const SimpleTable: Story = { + args: { + content: `# Salary Information + +| Job Role | Experience Level | Monthly Salary (ZMW) | +|----------|-----------------|---------------------| +| Software Developer | Entry | 5,000 - 8,000 | +| Software Developer | Mid-level | 8,000 - 15,000 | +| Software Developer | Senior | 15,000 - 25,000 |`, + }, +}; + +export const ComplexTable: Story = { + args: { + content: `# Mining Sector Opportunities + +| Sector | Role | Qualifications | Average Salary | Growth Rate | +|--------|------|----------------|----------------|-------------| +| Mining | Mining Engineer | Bachelor's Degree | ZMW 18,000 | 15% | +| Mining | Geologist | Master's Degree | ZMW 22,000 | 12% | +| Mining | Safety Officer | Diploma + Cert | ZMW 12,000 | 10% | +| Agriculture | Farm Manager | Bachelor's Degree | ZMW 14,000 | 8% | +| ICT | Software Developer | Bachelor's Degree | ZMW 16,000 | 20% |`, + }, +}; + +// Horizontal Rule +export const HorizontalRule: Story = { + args: { + content: `# Section 1 + +This is content in section 1. + +--- + +# Section 2 + +This is content in section 2, separated by a horizontal rule. + +--- + +# Section 3 + +This is content in section 3.`, + }, +}; + +// Mixed Content Examples +export const ComprehensiveDocument: Story = { + args: { + content: `# Mining Sector Career Pathway + +## Overview + +The mining sector in Zambia is one of the country's priority sectors for TEVET graduates. This profile gives you an overview of what the industry looks like, what roles exist, and what you can expect to earn. + +## Key Opportunities + +### Entry-Level Positions + +1. **Mining Technician** + - Salary Range: ZMW 4,000 - 6,000 + - Requirements: TEVET Diploma + - Growth Potential: High + +2. **Safety Assistant** + - Salary Range: ZMW 3,500 - 5,500 + - Requirements: Safety Certificate + - Growth Potential: Medium + +### Mid-Level Positions + +- Mining Supervisor +- Quality Control Specialist +- Equipment Operator + +> **Important Note**: All positions require valid safety certifications and regular training updates. + +## Qualification Pathways + +| Level | Qualification | Duration | Cost | +|-------|--------------|----------|------| +| Certificate | Mining Safety | 3 months | ZMW 1,500 | +| Diploma | Mining Technology | 2 years | ZMW 8,000 | +| Degree | Mining Engineering | 4 years | ZMW 25,000 | + +## Skills Required + +The most sought-after skills in the mining sector include: + +- Technical knowledge of \`mining equipment\` and \`safety procedures\` +- **Problem-solving** abilities +- *Communication* skills for team coordination +- Physical fitness and stamina + +--- + +## Next Steps + +To explore opportunities in the mining sector: + +1. Complete your TEVET qualification +2. Obtain necessary safety certifications +3. Gain practical experience through internships +4. Network with industry professionals + +For more information, visit [Ministry of Mines](https://www.mines.gov.zm).`, + }, +}; + +// Edge Cases +export const EmptyContent: Story = { + args: { + content: "", + }, +}; + +export const OnlyWhitespace: Story = { + args: { + content: " \n\n \n ", + }, +}; + +export const VeryLongParagraph: Story = { + args: { + content: `# Long Content + +Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.`, + }, +}; + +export const VeryLongWord: Story = { + args: { + content: `# Long Word Test + +This paragraph contains a very long word: supercalifragilisticexpialidociousthisisaverylongwordthatshouldbreakorwrapproperlysupercalifragilisticexpialidocious + +The word above should wrap or break properly to fit the container.`, + }, +}; + +export const ManyHeadings: Story = { + args: { + content: `# Main Title +## Subtitle 1 +### Sub-subtitle 1 +## Subtitle 2 +### Sub-subtitle 2 +### Sub-subtitle 3 +## Subtitle 3 +# Another Main Title +## Another Subtitle`, + }, +}; + +export const SpecialCharacters: Story = { + args: { + content: `# Special Characters + +This text contains special characters: & < > " ' / \\ + +Math symbols: + - × ÷ = ≠ ≈ ∞ + +Currency: $ £ € ¥ ₹ ZMW + +Punctuation: ! @ # % ^ * ( ) _ - + = { } [ ] | : ; " ' < > , . ? /`, + }, +}; + +export const MixedLanguages: Story = { + args: { + content: `# Multi-Language Content + +**English**: Welcome to the Knowledge Hub + +**Chichewa**: Takulandirani ku Malo Ophunzirira + +**Swahili**: Karibu kwenye Kituo cha Maarifa + +**Español**: Bienvenido al Centro de conocimiento`, + }, +}; + +export const URLsAndEmails: Story = { + args: { + content: `# Contact Information + +Website: https://www.example.com + +Email: contact@example.com + +Another URL: www.zambia-education.org + +Plain email: info@tabiya.org`, + }, +}; + +export const DeepNesting: Story = { + args: { + content: `# Career Sectors + +- Agriculture + - Crop Production + - Maize Farming + - Wheat Farming + - Livestock + - Dairy Farming + - Poultry + - Broiler Production + - Layer Production +- Mining + - Copper Mining + - Cobalt Mining +- ICT + - Software Development + - Frontend + - Backend + - Mobile`, + }, +}; + +export const MultipleCodeBlocks: Story = { + args: { + content: `# Programming Examples + +## JavaScript + +\`\`\`javascript +const greeting = "Hello World"; +console.log(greeting); +\`\`\` + +## Python + +\`\`\`python +greeting = "Hello World" +print(greeting) +\`\`\` + +## Java + +\`\`\`java +public class Main { + public static void main(String[] args) { + System.out.println("Hello World"); + } +} +\`\`\``, + }, +}; + +export const MultipleBlockquotes: Story = { + args: { + content: `# Important Information + +> First important note about career development. + +Some regular text in between. + +> Second important note about skill building. + +More regular text. + +> Third important note about networking.`, + }, +}; + +export const ComplexFormatting: Story = { + args: { + content: `# Complex Formatting + +This paragraph has **bold text with *italic inside* and \`code\`** formatting. + +This has *italic with **bold inside** and \`code\`* formatting. + +This has \`code with **bold** and *italic*\` inside. + +This has [a link with **bold** and *italic* text](https://example.com).`, + }, +}; + +export const LongTableWithManyColumns: Story = { + args: { + content: `# Detailed Comparison + +| Sector | Role | Qual | Salary | Growth | Location | Duration | Benefits | +|--------|------|------|--------|--------|----------|----------|----------| +| Mining | Engineer | BSc | 18000 | High | Copperbelt | 2 years | Medical, Housing | +| Agriculture | Manager | Diploma | 12000 | Medium | Central | 1 year | Medical | +| ICT | Developer | BSc | 16000 | Very High | Lusaka | 3 years | Medical, Remote | +| Construction | Supervisor | Certificate | 10000 | Low | Nationwide | 6 months | Medical |`, + }, +}; + +export const RealWorldExample: Story = { + args: { + content: `# Agriculture Sector Career Pathway + +This guide provides an overview of career opportunities in Zambia's agriculture sector for TEVET graduates. + +## Introduction + +Agriculture is one of Zambia's largest employers and a priority sector for economic development. The sector offers diverse opportunities ranging from crop production to agribusiness management. + +## Key Career Paths + +### Crop Production + +**Opportunities:** +- Farm Manager +- Agricultural Technician +- Irrigation Specialist + +**Salary Range:** ZMW 5,000 - 15,000 per month + +**Required Skills:** +- Knowledge of \`modern farming techniques\` +- **Soil management** expertise +- *Pest control* understanding + +### Livestock Management + +> Livestock farming is experiencing significant growth in Zambia, with increasing demand for dairy and poultry products. + +**Opportunities:** +1. Dairy Farm Manager +2. Poultry Production Specialist +3. Veterinary Assistant + +### Agribusiness + +Key roles in agribusiness include: + +- Supply Chain Manager +- Agricultural Marketing Officer +- Farm Business Consultant + +## Qualification Requirements + +| Position | Minimum Qualification | Experience | Certifications | +|----------|---------------------|------------|----------------| +| Farm Manager | Diploma in Agriculture | 2+ years | Farm Management | +| Ag. Technician | Certificate | 1+ year | None | +| Veterinary Assistant | Diploma | 1+ year | Animal Health | + +--- + +## Getting Started + +To begin your career in agriculture: + +1. Complete a relevant TEVET program +2. Gain practical experience through internships +3. Join agricultural associations +4. Stay updated with modern farming techniques + +**Contact Information:** +- Ministry of Agriculture: info@agriculture.gov.zm +- TEVET Authority: www.tevet.gov.zm + +For more resources, visit [Zambia Agriculture Portal](https://agriculture.gov.zm).`, + }, +}; + +// Stress Tests +export const VeryLongDocument: Story = { + args: { + content: `# Comprehensive Career Guide for Zambian TEVET Graduates + +${Array(10) + .fill(null) + .map( + (_, i) => ` +## Section ${i + 1} + +This is section ${i + 1} with detailed information about career pathways. + +### Subsection ${i + 1}.1 + +- Point 1 in subsection ${i + 1}.1 +- Point 2 in subsection ${i + 1}.1 +- Point 3 in subsection ${i + 1}.1 + +### Subsection ${i + 1}.2 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. This is detailed content for subsection ${i + 1}.2. + +| Column A | Column B | Column C | +|----------|----------|----------| +| Data ${i + 1}.1 | Data ${i + 1}.2 | Data ${i + 1}.3 | + +--- +` + ) + .join("\n")}`, + }, +}; + +export const ManyLists: Story = { + args: { + content: `# Many Lists Example + +${Array(20) + .fill(null) + .map( + (_, i) => ` +### List ${i + 1} + +- Item ${i + 1}.1 +- Item ${i + 1}.2 +- Item ${i + 1}.3 +` + ) + .join("\n")}`, + }, +}; diff --git a/frontend-new/src/knowledgeHub/components/MarkdownReader/MarkdownReader.tsx b/frontend-new/src/knowledgeHub/components/MarkdownReader/MarkdownReader.tsx new file mode 100644 index 00000000..dd869f9b --- /dev/null +++ b/frontend-new/src/knowledgeHub/components/MarkdownReader/MarkdownReader.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Box, styled } from "@mui/material"; + +const uniqueId = "a7b2c4d6-e8f0-1234-5678-9abcdef01234"; + +export const DATA_TEST_ID = { + MARKDOWN_READER: `markdown-reader-${uniqueId}`, + MARKDOWN_CONTENT: `markdown-content-${uniqueId}`, +}; + +const MarkdownContainer = styled(Box)(({ theme }) => ({ + "& h1": { + ...theme.typography.h4, + marginTop: theme.fixedSpacing(theme.tabiyaSpacing.lg), + marginBottom: theme.fixedSpacing(theme.tabiyaSpacing.md), + color: theme.palette.text.primary, + }, + "& h2": { + ...theme.typography.h5, + marginTop: theme.fixedSpacing(theme.tabiyaSpacing.lg), + marginBottom: theme.fixedSpacing(theme.tabiyaSpacing.sm), + color: theme.palette.text.primary, + }, + "& h3": { + ...theme.typography.h6, + marginTop: theme.fixedSpacing(theme.tabiyaSpacing.md), + marginBottom: theme.fixedSpacing(theme.tabiyaSpacing.sm), + color: theme.palette.text.primary, + }, + "& p": { + ...theme.typography.body1, + marginBottom: theme.fixedSpacing(theme.tabiyaSpacing.md), + color: theme.palette.text.primary, + lineHeight: 1.7, + }, + "& ul, & ol": { + marginBottom: theme.fixedSpacing(theme.tabiyaSpacing.md), + paddingLeft: theme.fixedSpacing(theme.tabiyaSpacing.lg), + }, + "& li": { + ...theme.typography.body1, + marginBottom: theme.fixedSpacing(theme.tabiyaSpacing.xs), + color: theme.palette.text.primary, + }, + "& blockquote": { + borderLeft: `4px solid ${theme.palette.tabiyaYellow.main}`, + margin: `${theme.fixedSpacing(theme.tabiyaSpacing.md)} 0`, + padding: `${theme.fixedSpacing(theme.tabiyaSpacing.sm)} ${theme.fixedSpacing(theme.tabiyaSpacing.md)}`, + backgroundColor: theme.palette.tabiyaYellow.light, + borderRadius: `0 ${theme.fixedSpacing(theme.tabiyaRounding.sm)} ${theme.fixedSpacing(theme.tabiyaRounding.sm)} 0`, + }, + "& code": { + backgroundColor: theme.palette.grey[100], + padding: `${theme.fixedSpacing(theme.tabiyaSpacing.xxs)} ${theme.fixedSpacing(theme.tabiyaSpacing.xs)}`, + borderRadius: theme.fixedSpacing(theme.tabiyaRounding.xs), + fontFamily: "monospace", + fontSize: "0.9em", + }, + "& pre": { + backgroundColor: theme.palette.grey[100], + padding: theme.fixedSpacing(theme.tabiyaSpacing.md), + borderRadius: theme.fixedSpacing(theme.tabiyaRounding.sm), + overflow: "auto", + marginBottom: theme.fixedSpacing(theme.tabiyaSpacing.md), + "& code": { + backgroundColor: "transparent", + padding: 0, + }, + }, + "& table": { + width: "100%", + borderCollapse: "collapse", + marginBottom: theme.fixedSpacing(theme.tabiyaSpacing.md), + }, + "& th, & td": { + border: `1px solid ${theme.palette.divider}`, + padding: theme.fixedSpacing(theme.tabiyaSpacing.sm), + textAlign: "left", + }, + "& th": { + backgroundColor: theme.palette.grey[100], + fontWeight: 600, + }, + "& a": { + color: theme.palette.primary.main, + textDecoration: "underline", + "&:hover": { + color: theme.palette.primary.dark, + }, + }, + "& hr": { + border: "none", + borderTop: `1px solid ${theme.palette.divider}`, + margin: `${theme.fixedSpacing(theme.tabiyaSpacing.lg)} 0`, + }, + "& strong": { + fontWeight: 600, + }, +})); + +export interface MarkdownReaderProps { + content: string; +} + +const MarkdownReader: React.FC = ({ content }) => { + return ( + + + {content} + + + ); +}; + +export default MarkdownReader; diff --git a/frontend-new/src/knowledgeHub/components/MarkdownReader/index.ts b/frontend-new/src/knowledgeHub/components/MarkdownReader/index.ts new file mode 100644 index 00000000..1f9a7f8a --- /dev/null +++ b/frontend-new/src/knowledgeHub/components/MarkdownReader/index.ts @@ -0,0 +1,2 @@ +export { default } from "./MarkdownReader"; +export * from "./MarkdownReader"; diff --git a/frontend-new/src/knowledgeHub/documentLoader.ts b/frontend-new/src/knowledgeHub/documentLoader.ts new file mode 100644 index 00000000..8007a060 --- /dev/null +++ b/frontend-new/src/knowledgeHub/documentLoader.ts @@ -0,0 +1,68 @@ +import { Document, DocumentMetadata } from "./types"; +import { parseYamlFrontmatter } from "./parseYamlFrontmatter"; + +// Import markdown files directly with raw-loader +// eslint-disable-next-line import/no-webpack-loader-syntax +import miningPathwayMd from "!!raw-loader!./documents/mining.md"; + +// eslint-disable-next-line import/no-webpack-loader-syntax +import energyPathwayMd from "!!raw-loader!./documents/energy.md"; + +// eslint-disable-next-line import/no-webpack-loader-syntax +import hospitalityPathwayMd from "!!raw-loader!./documents/hospitality.md"; + +// eslint-disable-next-line import/no-webpack-loader-syntax +import agriculturePathwayMd from "!!raw-loader!./documents/agriculture.md"; + +// eslint-disable-next-line import/no-webpack-loader-syntax +import waterPathwayMd from "!!raw-loader!./documents/water.md"; + +// Document registry - add new documents here +const documentRegistry: Record = { + "mining-pathway": miningPathwayMd, + "energy-pathway": energyPathwayMd, + "hospitality-pathway": hospitalityPathwayMd, + "agriculture-pathway": agriculturePathwayMd, + "water-pathway": waterPathwayMd, +}; + +// Parse frontmatter from markdown content +const parseFrontmatter = (content: string, id: string): Document => { + const { data, content: markdownContent } = parseYamlFrontmatter(content); + return { + id, + title: data.title, + description: data.description, + sector: data.sector, + icon: data.icon, + content: markdownContent, + }; +}; + +// Get all document metadata (for listing) +export const getAllDocuments = (): DocumentMetadata[] => { + return Object.entries(documentRegistry).map(([id, content]) => { + const parsed = parseFrontmatter(content, id); + return { + id: parsed.id, + title: parsed.title, + description: parsed.description, + sector: parsed.sector, + icon: parsed.icon, + }; + }); +}; + +// Get a specific document by id +export const getDocumentById = (id: string): Document | null => { + const content = documentRegistry[id]; + if (!content) { + return null; + } + return parseFrontmatter(content, id); +}; + +// Check if a document exists +export const documentExists = (id: string): boolean => { + return id in documentRegistry; +}; diff --git a/frontend-new/src/knowledgeHub/documents/agriculture.md b/frontend-new/src/knowledgeHub/documents/agriculture.md new file mode 100644 index 00000000..997005b5 --- /dev/null +++ b/frontend-new/src/knowledgeHub/documents/agriculture.md @@ -0,0 +1,54 @@ +--- +title: "Agriculture" +description: "Commercial farming, agri-processing, food production and agricultural supply chains." +sector: "Agriculture" +--- + +# Agriculture Sector Pathway + +Agriculture is the largest employer in Zambia, but it is shifting from subsistence farming to commercial agribusiness and technology-driven production. The sector needs professionals who can manage irrigation, crop health, aquaculture, and machinery to boost productivity and food security. + +## Career Pathways & Entry Points + +The Critical Skills List emphasizes the need to modernize agricultural practices: + +| Role | TEVET Entry Point | Qualification Level | +| ----------------------------- | ------------------------ | -------------------- | +| **Agricultural Extensionist** | Craft / Adv. Certificate | Technician (ZQF 4-5) | +| **Horticulturist** | Advanced Certificate | Technician (ZQF 5) | +| **Aquaculture Technician** | Advanced Certificate | Technician (ZQF 5) | +| **Farm Machinery Operator** | Trade Test | Artisan (ZQF 3-4) | +| **Agricultural Engineer** | Diploma / Degree | Technologist (ZQF 6) | +| **Irrigation Technician** | Advanced Certificate | Technician (ZQF 5) | + +## Salary Insights (Monthly) + +Source: _2023 Labour Force Survey_ + +While the sector has a large informal base, skilled roles in commercial farming offer stable incomes. + +- **General Farm Labour:** K1,000 – K3,000 (Often informal) +- **Skilled Technicians (Irrigation/Machinery):** K4,000 – K8,000 +- **Management/Specialists:** Can exceed K10,000 +- **Note:** 81% of the general workforce earns below K2,700, highlighting the importance of obtaining specialized **TEVET qualifications** to access higher-paying commercial roles. + +## Labour Market Intelligence + +**Top Hiring Provinces:** + +1. **Central Province** (29.9% of employment) - A major commercial farming hub. +2. **Southern Province** (16.5% of employment) - Key for livestock and drought-resistant crops. +3. **Eastern Province** (9.9% of employment) - Strong in cash crops like cotton and tobacco. + +**Key Employers:** + +- **Commercial Farms:** Large estates growing maize, wheat, soy, and tobacco. +- **Agri-processing Companies:** Milling, canning, and food production factories (e.g., Zambeef). +- **Seed & Fertilizer Companies:** Research and sales roles. +- **Aquaculture Farms:** Growing sector in fish farming. + +**Growth Areas:** + +- **Precision Agriculture:** Using drones and data for crop management. +- **Climate-Smart Agriculture:** Techniques to farm sustainably in changing weather. +- **Value Addition:** Processing raw produce into finished goods (e.g., peanut butter, jams). diff --git a/frontend-new/src/knowledgeHub/documents/energy.md b/frontend-new/src/knowledgeHub/documents/energy.md new file mode 100644 index 00000000..1819a500 --- /dev/null +++ b/frontend-new/src/knowledgeHub/documents/energy.md @@ -0,0 +1,53 @@ +--- +title: "Energy" +description: "Power generation, transmission, distribution and the growing renewable energy sector." +sector: "Energy" +--- + +# Energy Sector Pathway + +Zambia's energy sector is undergoing a massive transformation, shifting towards renewable energy sources like solar and wind to complement its strong hydroelectric base. This transition is creating a surge in demand for technicians skilled in installing, maintaining, and managing modern energy systems. + +## Career Pathways & Entry Points + +The Critical Skills List highlights the urgent need for skills in renewable energy and grid management: + +| Role | TEVET Entry Point | Qualification Level | +| ----------------------------- | ------------------------- | -------------------- | +| **Solar PV Installer** | Trade Test / Craft | Artisan (ZQF 4) | +| **Cable Jointer / Linesman** | Trade Test / Craft | Artisan (ZQF 4) | +| **Electrical Technician** | Advanced Certificate | Technician (ZQF 5) | +| **Renewable Energy Engineer** | Diploma / Degree | Technologist (ZQF 6) | +| **Bio-digester Constructor** | Trade Test / Skills Award | Artisan (ZQF 4) | +| **Smart Grid Engineer** | Diploma / Degree | Technologist (ZQF 6) | + +## Salary Insights (Monthly) + +Source: _2023 Labour Force Survey_ + +The energy sector offers competitive remuneration, particularly for specialized technical roles. + +- **Entry Level:** K3,500 – K6,000 +- **Experienced Technicians:** K7,000 – K12,000 +- **High Earners:** Approximately **28.1%** of employees in the Electricity supply sector earn above **K7,500** per month. + +## Labour Market Intelligence + +**Top Hiring Provinces:** + +1. **Lusaka Province** (34.8% of employment) - Headquarters of major utilities and solar companies. +2. **Copperbelt Province** (22.8% of employment) - High industrial energy demand. +3. **Central Province** (21.8% of employment) - Strategic location for transmission and solar projects. + +**Key Employers:** + +- **ZESCO:** National utility provider. +- **Rural Electrification Authority (REA):** Driving off-grid projects. +- **Independent Power Producers (IPPs):** Solar farms and hydro plants (e.g., Maamba, Itezhi-Tezhi). +- **Solar Home System Companies:** Rapidly hiring installers and sales agents. + +**Growth Areas:** + +- **Solar PV Installation:** Critical shortage for both domestic and commercial scale projects. +- **Mini-Grid Operations:** Managing isolated grids in rural areas. +- **Energy Efficiency:** Auditing and retrofitting industries to save power. diff --git a/frontend-new/src/knowledgeHub/documents/hospitality.md b/frontend-new/src/knowledgeHub/documents/hospitality.md new file mode 100644 index 00000000..ddc6923b --- /dev/null +++ b/frontend-new/src/knowledgeHub/documents/hospitality.md @@ -0,0 +1,54 @@ +--- +title: "Hospitality" +description: "Hotels, lodges, restaurants and tourism services across Zambia's national parks and urban centres." +sector: "Hospitality" +--- + +# Hospitality & Tourism Sector Pathway + +Zambia's rich natural heritage and growing business hubs make hospitality a vibrant sector. From luxury safari lodges to urban conference centers, the industry demands high standards of service, culinary excellence, and management skills. It offers pathways for those who enjoy working with people and showcasing Zambia to the world. + +## Career Pathways & Entry Points + +The Critical Skills List identifies key service and management roles: + +| Role | TEVET Entry Point | Qualification Level | +| ------------------------ | --------------------- | --------------------- | +| **Chef / Culinary Arts** | Trade Test / Craft | Artisan (ZQF 4) | +| **Tour Guide** | Skills Award / Cert | Certificate (ZQF 3-4) | +| **Hotel Management** | Diploma | Technologist (ZQF 6) | +| **Digital Marketing** | Certificate / Diploma | Technician (ZQF 5) | +| **Wildlife Handling** | Certificate | Technician (ZQF 4) | +| **Events Management** | Certificate | Technician (ZQF 4) | + +## Salary Insights (Monthly) + +Source: _2023 Labour Force Survey_ + +Salaries vary significantly between urban centers and rural lodges. + +- **Entry Level (Wait staff, Housekeeping):** K1,500 – K3,500 +- **Skilled (Chefs, Guides, Supervisors):** K4,000 – K9,000 +- **Top Tier:** 7.3% of employees in this sector earn above **K7,500**, typically in management or specialized roles at high-end establishments. + +## Labour Market Intelligence + +**Top Hiring Provinces:** + +1. **Lusaka Province** (39.9% of employment) - Business tourism, conferences (MICE), and urban hotels. +2. **Central Province** (14.4% of employment) - Transit hubs and growing accommodation sector. +3. **Copperbelt Province** (10.2% of employment) - Business travel. +4. **Southern Province** (8.3% of employment) - The tourism capital (Livingstone/Victoria Falls). + +**Key Employers:** + +- **Hotel Chains:** International and local brands (e.g., Radisson, Hilton, Avani). +- **Safari Lodges:** High-end camps in National Parks (South Luangwa, Lower Zambezi). +- **Tour Operators:** Travel agencies and adventure activity providers. +- **Restaurants & Catering:** Fast food chains and fine dining. + +**Growth Areas:** + +- **MICE Tourism:** Meetings, Incentives, Conferences, and Exhibitions management. +- **Culinary Arts:** High demand for trained chefs who can create international and local cuisine. +- **Digital Marketing:** Promoting destinations online to attract global visitors. diff --git a/frontend-new/src/knowledgeHub/documents/mining.md b/frontend-new/src/knowledgeHub/documents/mining.md new file mode 100644 index 00000000..8c7a63a2 --- /dev/null +++ b/frontend-new/src/knowledgeHub/documents/mining.md @@ -0,0 +1,52 @@ +--- +title: "Mining" +description: "Copper, gold and gemstone extraction, mineral processing, and mine site operations." +sector: "Mining" +--- + +# Mining Sector Pathway + +The mining sector remains the economic backbone of Zambia, driving export earnings and technological advancement. Beyond traditional copper mining, opportunities are expanding in gemstone extraction, mineral processing, and mine support services. This sector offers some of the highest earning potential in the country for skilled technical professionals. + +## Career Pathways & Entry Points + +The Critical Skills List identifies these priority roles and their TEVET entry points: + +| Role | TEVET Entry Point | Qualification Level | +| ------------------------------ | -------------------- | -------------------- | +| **Heavy Equipment Repair** | Craft Certificate | Artisan (ZQF 4) | +| **Driller / Blaster** | Trade Test / Craft | Artisan (ZQF 3-4) | +| **Mining Surveyor** | Advanced Certificate | Technician (ZQF 5) | +| **Ventilation Technician** | Advanced Certificate | Technician (ZQF 5) | +| **Geologist / Metallurgist** | Diploma / Degree | Technologist (ZQF 6) | +| **Instrumentation Technician** | Advanced Certificate | Technician (ZQF 5) | + +## Salary Insights (Monthly) + +Source: _2023 Labour Force Survey_ + +The mining sector is the highest-paying industry in Zambia. + +- **Entry Level (General):** K3,000 – K6,000 +- **Skilled Artisans & Technicians:** K6,300 – K15,000+ +- **High Earners:** Over 48% of employees in the mining sector earn above **K7,500** per month, significantly higher than the national average. + +## Labour Market Intelligence + +**Top Hiring Provinces:** + +1. **Copperbelt Province** (58.9% of employment) - The traditional hub of mining activity. +2. **North-Western Province** (13.6% of employment) - Rapidly growing "New Copperbelt" with major operations. +3. **Central Province** (8.4% of employment) - Emerging opportunities. + +**Key Employers:** + +- Large-scale mining consortiums (Mopani, KCM, FQM, Barrick). +- Mining contractor firms (Supplying equipment maintenance, drilling services). +- Gemstone mines (Emeralds in Lufwanyama). + +**Growth Areas:** + +- **Heavy Equipment Repair:** Critical shortage of mechanics for modern mining fleets. +- **Safety (OSH):** Growing demand for Occupational Health & Safety officers. +- **Green Mining:** Increasing need for environmental compliance and renewable energy integration in mines. diff --git a/frontend-new/src/knowledgeHub/documents/water.md b/frontend-new/src/knowledgeHub/documents/water.md new file mode 100644 index 00000000..eee49062 --- /dev/null +++ b/frontend-new/src/knowledgeHub/documents/water.md @@ -0,0 +1,53 @@ +--- +title: "Water" +description: "Water treatment, supply networks, sanitation infrastructure and waste management operations." +sector: "Water" +--- + +# Water & Sanitation Sector Pathway + +Access to clean water and sanitation is a national priority. This sector involves the engineering, maintenance, and management of water resources, treatment plants, and distribution networks. It offers stable career opportunities with utility companies and growing roles in environmental management. + +## Career Pathways & Entry Points + +The Critical Skills List emphasizes technical skills for infrastructure maintenance: + +| Role | TEVET Entry Point | Qualification Level | +| -------------------------- | -------------------- | -------------------- | +| **Plumber / Pipe Fitter** | Trade Test / Craft | Artisan (ZQF 3-4) | +| **Water Plant Operator** | Certificate / Craft | Artisan (ZQF 4) | +| **Water Engineer** | Diploma / Degree | Technologist (ZQF 6) | +| **Lab Technician (Water)** | Advanced Certificate | Technician (ZQF 5) | +| **Borehole Driller** | Trade Test | Artisan (ZQF 3-4) | +| **Sanitation Coordinator** | Diploma | Technician (ZQF 5) | + +## Salary Insights (Monthly) + +Source: _2023 Labour Force Survey_ + +The water sector offers competitive technical salaries, comparable to the energy sector. + +- **Entry Level:** K3,000 – K5,000 +- **Skilled Technicians:** K6,000 – K10,000 +- **High Earners:** **31.1%** of employees in the Water Supply & Waste Management sector earn above **K7,500** per month, indicating strong career progression for skilled workers. + +## Labour Market Intelligence + +**Top Hiring Provinces:** + +1. **Lusaka Province** (36.3% of employment) - High density urban water networks. +2. **Copperbelt Province** (26.1% of employment) - Industrial water treatment and mining support. +3. **Southern Province** (17.3% of employment) - Focus on drought resilience and rural water supply. + +**Key Employers:** + +- **Commercial Utilities:** (e.g., Lusaka Water, Nkana Water, Southern Water). +- **Drilling Companies:** Private contractors for borehole installation. +- **Municipal Councils:** Waste management and sanitation services. +- **NGOs & Donors:** WASH (Water, Sanitation and Hygiene) projects in rural areas. + +**Growth Areas:** + +- **Non-Revenue Water Reduction:** Technicians needed to fix leaks and manage smart metering. +- **Waste Water Management:** Treating effluent for reuse or safe disposal. +- **Climate Resilience:** Designing systems that withstand droughts and floods. diff --git a/frontend-new/src/knowledgeHub/iconRegistry.tsx b/frontend-new/src/knowledgeHub/iconRegistry.tsx new file mode 100644 index 00000000..612a3966 --- /dev/null +++ b/frontend-new/src/knowledgeHub/iconRegistry.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import WorkIcon from "@mui/icons-material/Work"; +import AgricultureIcon from "@mui/icons-material/Agriculture"; +import BoltIcon from "@mui/icons-material/Bolt"; +import RestaurantIcon from "@mui/icons-material/Restaurant"; +import WaterIcon from "@mui/icons-material/Water"; +import ArticleOutlinedIcon from "@mui/icons-material/ArticleOutlined"; + +// Icon registry - maps icon names to MUI icon components +export const ICON_REGISTRY: Record = { + Mining: , + Agriculture: , + Energy: , + Hospitality: , + Water: , +}; + +// Default icon to use when no icon is specified or found in registry +export const DEFAULT_ICON = ; + +// Get icon for a document - returns the icon from registry or default +export const getDocumentIcon = (iconName?: string): React.ReactNode => { + if (!iconName) { + return DEFAULT_ICON; + } + return ICON_REGISTRY[iconName] || DEFAULT_ICON; +}; diff --git a/frontend-new/src/knowledgeHub/index.ts b/frontend-new/src/knowledgeHub/index.ts new file mode 100644 index 00000000..ebf0f3d8 --- /dev/null +++ b/frontend-new/src/knowledgeHub/index.ts @@ -0,0 +1,13 @@ +// Types +export * from "./types"; + +// Document Loader +export { getAllDocuments, getDocumentById, documentExists } from "./documentLoader"; + +// Components +export { default as DocumentCard } from "./components/DocumentCard"; +export { default as MarkdownReader } from "./components/MarkdownReader"; + +// Pages +export { default as KnowledgeHubList } from "./pages/KnowledgeHubList"; +export { default as KnowledgeHubDocument } from "./pages/KnowledgeHubDocument"; diff --git a/frontend-new/src/knowledgeHub/markdown.d.ts b/frontend-new/src/knowledgeHub/markdown.d.ts new file mode 100644 index 00000000..c94d67b1 --- /dev/null +++ b/frontend-new/src/knowledgeHub/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string; + export default content; +} diff --git a/frontend-new/src/knowledgeHub/pages/KnowledgeHubDocument/KnowledgeHubDocument.tsx b/frontend-new/src/knowledgeHub/pages/KnowledgeHubDocument/KnowledgeHubDocument.tsx new file mode 100644 index 00000000..4bc7aab3 --- /dev/null +++ b/frontend-new/src/knowledgeHub/pages/KnowledgeHubDocument/KnowledgeHubDocument.tsx @@ -0,0 +1,86 @@ +import React, { startTransition, useMemo } from "react"; +import { Box, Container, useTheme } from "@mui/material"; +import { useNavigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import MarkdownReader from "src/knowledgeHub/components/MarkdownReader"; +import { getDocumentById } from "src/knowledgeHub/documentLoader"; +import { routerPaths } from "src/app/routerPaths"; +import ErrorPage from "src/error/errorPage/ErrorPage"; +import KnowledgeHubPageHeader from "src/knowledgeHub/components/KnowledgeHubPageHeader"; +import BackButton from "src/knowledgeHub/components/BackButton"; + +const uniqueId = "d5e6f7a8-90bc-def1-2345-678901234567"; + +export const DATA_TEST_ID = { + KNOWLEDGE_HUB_DOCUMENT_CONTAINER: `knowledge-hub-document-container-${uniqueId}`, + KNOWLEDGE_HUB_DOCUMENT_BACK_BUTTON: `knowledge-hub-document-back-button-${uniqueId}`, + KNOWLEDGE_HUB_DOCUMENT_CONTENT: `knowledge-hub-document-content-${uniqueId}`, + KNOWLEDGE_HUB_DOCUMENT_ICON: `knowledge-hub-document-icon-${uniqueId}`, +}; + +const KnowledgeHubDocument: React.FC = () => { + const theme = useTheme(); + const navigate = useNavigate(); + const { documentId } = useParams<{ documentId: string }>(); + const { t } = useTranslation(); + + const document = useMemo(() => { + if (!documentId) return null; + return getDocumentById(documentId); + }, [documentId]); + + const handleBackClick = () => { + startTransition(() => { + navigate(routerPaths.KNOWLEDGE_HUB); + }); + }; + + if (!document) { + return ; + } + + return ( + + + + + + {/* Back link */} + + + + + {/* Document Content */} + + + + + + + ); +}; + +export default KnowledgeHubDocument; diff --git a/frontend-new/src/knowledgeHub/pages/KnowledgeHubDocument/index.ts b/frontend-new/src/knowledgeHub/pages/KnowledgeHubDocument/index.ts new file mode 100644 index 00000000..4d0efd44 --- /dev/null +++ b/frontend-new/src/knowledgeHub/pages/KnowledgeHubDocument/index.ts @@ -0,0 +1,2 @@ +export { default } from "./KnowledgeHubDocument"; +export * from "./KnowledgeHubDocument"; diff --git a/frontend-new/src/knowledgeHub/pages/KnowledgeHubList/KnowledgeHubList.tsx b/frontend-new/src/knowledgeHub/pages/KnowledgeHubList/KnowledgeHubList.tsx new file mode 100644 index 00000000..5937ceb5 --- /dev/null +++ b/frontend-new/src/knowledgeHub/pages/KnowledgeHubList/KnowledgeHubList.tsx @@ -0,0 +1,103 @@ +import React, { startTransition, useMemo } from "react"; +import { Box, Container, Grid, Typography, useTheme } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import DocumentCard from "src/knowledgeHub/components/DocumentCard"; +import { getAllDocuments } from "src/knowledgeHub/documentLoader"; +import { routerPaths } from "src/app/routerPaths"; +import KnowledgeHubPageHeader from "src/knowledgeHub/components/KnowledgeHubPageHeader"; +import BackButton from "src/knowledgeHub/components/BackButton"; + +const uniqueId = "b3d4e5f6-7890-abcd-ef12-345678901234"; + +export const DATA_TEST_ID = { + KNOWLEDGE_HUB_LIST_CONTAINER: `knowledge-hub-list-container-${uniqueId}`, + KNOWLEDGE_HUB_LIST_CONTENT: `knowledge-hub-list-content-${uniqueId}`, + KNOWLEDGE_HUB_LIST_GRID: `knowledge-hub-list-grid-${uniqueId}`, + KNOWLEDGE_HUB_BACK_BUTTON: `knowledge-hub-back-button-${uniqueId}`, + KNOWLEDGE_HUB_INTRODUCTION: `knowledge-hub-introduction-${uniqueId}`, +}; + +const KnowledgeHubList: React.FC = () => { + const theme = useTheme(); + const navigate = useNavigate(); + const { t } = useTranslation(); + + const documents = useMemo(() => getAllDocuments(), []); + + const handleDocumentClick = (id: string) => { + startTransition(() => { + navigate(`${routerPaths.KNOWLEDGE_HUB}/${id}`); + }); + }; + + const handleBackToDashboard = () => { + startTransition(() => { + navigate(routerPaths.ROOT); + }); + }; + + return ( + + + + + {/* Back to Dashboard button */} + + + + + {/* Introduction paragraph */} + + + {t("knowledgeHub.introduction")} + + + + {/* Document Grid */} + + {documents.map((doc) => ( + + + + ))} + + + {/* Empty state */} + {documents.length === 0 && ( + + + {t("knowledgeHub.noDocumentsAvailable")} + + + )} + + + ); +}; + +export default KnowledgeHubList; diff --git a/frontend-new/src/knowledgeHub/pages/KnowledgeHubList/index.ts b/frontend-new/src/knowledgeHub/pages/KnowledgeHubList/index.ts new file mode 100644 index 00000000..b93da1ba --- /dev/null +++ b/frontend-new/src/knowledgeHub/pages/KnowledgeHubList/index.ts @@ -0,0 +1,2 @@ +export { default } from "./KnowledgeHubList"; +export * from "./KnowledgeHubList"; diff --git a/frontend-new/src/knowledgeHub/parseYamlFrontmatter.test.ts b/frontend-new/src/knowledgeHub/parseYamlFrontmatter.test.ts new file mode 100644 index 00000000..e19075e3 --- /dev/null +++ b/frontend-new/src/knowledgeHub/parseYamlFrontmatter.test.ts @@ -0,0 +1,441 @@ +import { parseYamlFrontmatter } from "src/knowledgeHub/parseYamlFrontmatter"; + +describe("parseYamlFrontmatter", () => { + describe("when content has valid frontmatter", () => { + test("should parse title and description correctly", () => { + // GIVEN markdown content with valid frontmatter + const content = `--- +title: Test Title +description: Test Description +--- + +# Markdown Content + +Some body text here.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect the frontmatter to be parsed correctly + expect(result.data.title).toBe("Test Title"); + expect(result.data.description).toBe("Test Description"); + expect(result.content).toBe("# Markdown Content\n\nSome body text here."); + }); + + test("should parse sector field correctly", () => { + // GIVEN markdown content with sector in frontmatter + const content = `--- +title: Mining Career Pathway +description: Guide to mining careers +sector: mining +--- + +# Mining Sector`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect the sector to be parsed correctly + expect(result.data.title).toBe("Mining Career Pathway"); + expect(result.data.description).toBe("Guide to mining careers"); + expect(result.data.sector).toBe("mining"); + }); + + test("should handle double-quoted values", () => { + // GIVEN markdown content with double-quoted values + const content = `--- +title: "Title with: colon" +description: "Description with special chars" +--- + +Content here.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect quotes to be stripped from values + expect(result.data.title).toBe("Title with: colon"); + expect(result.data.description).toBe("Description with special chars"); + }); + + test("should handle single-quoted values", () => { + // GIVEN markdown content with single-quoted values + const content = `--- +title: 'Single Quoted Title' +description: 'Single Quoted Description' +--- + +Content here.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect quotes to be stripped from values + expect(result.data.title).toBe("Single Quoted Title"); + expect(result.data.description).toBe("Single Quoted Description"); + }); + + test("should handle values with colons in the content", () => { + // GIVEN markdown content where values contain colons + const content = `--- +title: Career Guide: Mining Edition +description: A comprehensive guide to careers +--- + +Body content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect the full value including colon to be captured + expect(result.data.title).toBe("Career Guide: Mining Edition"); + }); + + test("should handle empty description", () => { + // GIVEN markdown content with empty description + const content = `--- +title: Title Only +description: +--- + +Content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect empty string for description + expect(result.data.title).toBe("Title Only"); + expect(result.data.description).toBe(""); + }); + + test("should handle whitespace around values", () => { + // GIVEN markdown content with extra whitespace + const content = `--- +title: Spaced Title +description: Spaced Description +--- + +Content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect whitespace to be trimmed + expect(result.data.title).toBe("Spaced Title"); + expect(result.data.description).toBe("Spaced Description"); + }); + + test("should handle multiline markdown content", () => { + // GIVEN markdown content with multiple lines after frontmatter + const content = `--- +title: Test +description: Test desc +--- + +# Heading 1 + +Paragraph 1. + +## Heading 2 + +Paragraph 2. + +- List item 1 +- List item 2`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect all markdown content to be preserved + expect(result.content).toContain("# Heading 1"); + expect(result.content).toContain("## Heading 2"); + expect(result.content).toContain("- List item 1"); + expect(result.content).toContain("- List item 2"); + }); + + test("should handle frontmatter with extra fields", () => { + // GIVEN markdown content with additional custom fields + const content = `--- +title: Test Title +description: Test Description +sector: agriculture +author: John Doe +date: 2025-01-01 +--- + +Content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect known fields to be parsed, unknown fields ignored + expect(result.data.title).toBe("Test Title"); + expect(result.data.description).toBe("Test Description"); + expect(result.data.sector).toBe("agriculture"); + }); + }); + + describe("when content has no frontmatter", () => { + test("should return default values and original content", () => { + // GIVEN markdown content without frontmatter + const content = `# Just a Heading + +Some content without frontmatter.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect default values + expect(result.data.title).toBe("Untitled"); + expect(result.data.description).toBe(""); + expect(result.content).toBe(content); + }); + + test("should handle empty content", () => { + // GIVEN empty content + const content = ""; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect default values + expect(result.data.title).toBe("Untitled"); + expect(result.data.description).toBe(""); + expect(result.content).toBe(""); + }); + + test("should handle content with only dashes but not valid frontmatter", () => { + // GIVEN content with dashes but not valid frontmatter format + const content = `--- +This is not valid frontmatter +Because there's no closing --- + +Regular content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect default values and original content + expect(result.data.title).toBe("Untitled"); + expect(result.data.description).toBe(""); + expect(result.content).toBe(content); + }); + + test("should handle content where frontmatter is not at the start", () => { + // GIVEN content where frontmatter appears in the middle + const content = `Some intro text + +--- +title: This should not be parsed +description: Because it's not at the start +--- + +More content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect default values since frontmatter must be at start + expect(result.data.title).toBe("Untitled"); + expect(result.data.description).toBe(""); + expect(result.content).toBe(content); + }); + }); + + describe("when frontmatter has missing required fields", () => { + test("should use default title when title is missing", () => { + // GIVEN frontmatter without title + const content = `--- +description: Only description provided +sector: ict +--- + +Content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect default title + expect(result.data.title).toBe("Untitled"); + expect(result.data.description).toBe("Only description provided"); + expect(result.data.sector).toBe("ict"); + }); + + test("should use empty string when description is missing", () => { + // GIVEN frontmatter without description + const content = `--- +title: Title Only Document +sector: construction +--- + +Content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect empty description + expect(result.data.title).toBe("Title Only Document"); + expect(result.data.description).toBe(""); + expect(result.data.sector).toBe("construction"); + }); + + test("should handle completely empty frontmatter", () => { + // GIVEN empty frontmatter block + const content = `--- +--- + +Content after empty frontmatter.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect default values + expect(result.data.title).toBe("Untitled"); + expect(result.data.description).toBe(""); + expect(result.data.sector).toBeUndefined(); + }); + }); + + describe("when parsing actual pathway document format", () => { + test("should correctly parse mining pathway frontmatter format", () => { + // GIVEN content matching the actual mining-pathway.md format + const content = `--- +title: Mining Sector Career Pathway +description: Complete guide to TVET qualifications, career progression, and opportunities in Zambia's mining industry +sector: mining +--- + +# Mining Sector Career Pathway + +Zambia's mining sector is the backbone of the national economy...`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect correct parsing + expect(result.data.title).toBe("Mining Sector Career Pathway"); + expect(result.data.description).toBe( + "Complete guide to TVET qualifications, career progression, and opportunities in Zambia's mining industry" + ); + expect(result.data.sector).toBe("mining"); + expect(result.content).toContain("# Mining Sector Career Pathway"); + }); + + test("should correctly parse hospitality pathway frontmatter format", () => { + // GIVEN content matching the actual hospitality-pathway.md format + const content = `--- +title: Hospitality & Tourism Sector Career Pathway +description: Complete guide to TVET qualifications, career progression, and opportunities in Zambia's hospitality and tourism industry +sector: hospitality +--- + +# Hospitality & Tourism Sector Career Pathway`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect correct parsing including ampersand in title + expect(result.data.title).toBe("Hospitality & Tourism Sector Career Pathway"); + expect(result.data.sector).toBe("hospitality"); + }); + }); + + describe("edge cases", () => { + test("should handle frontmatter with blank lines", () => { + // GIVEN frontmatter with blank lines between fields + const content = `--- +title: Test Title + +description: Test Description + +sector: mining +--- + +Content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect fields to be parsed correctly (blank lines ignored) + expect(result.data.title).toBe("Test Title"); + expect(result.data.description).toBe("Test Description"); + expect(result.data.sector).toBe("mining"); + }); + + test("should handle content with code blocks containing dashes", () => { + // GIVEN content with code blocks that have dashes + const content = `--- +title: Code Example +description: Shows code +--- + +# Example + +\`\`\` +--- +this: is not frontmatter +--- +\`\`\``; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect only the actual frontmatter to be parsed + expect(result.data.title).toBe("Code Example"); + expect(result.content).toContain("this: is not frontmatter"); + }); + + test("should handle frontmatter with tabs instead of spaces", () => { + // GIVEN frontmatter with tabs + const content = `--- +title: Tab Title +description: Tab Description +--- + +Content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect values to be trimmed correctly + expect(result.data.title).toBe("Tab Title"); + expect(result.data.description).toBe("Tab Description"); + }); + + test("should handle very long description values", () => { + // GIVEN frontmatter with a very long description + const longDescription = + "This is a very long description that goes on and on with lots of details about the content of this document including TVET qualifications and career pathways."; + const content = `--- +title: Long Description Test +description: ${longDescription} +--- + +Content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect the full description to be captured + expect(result.data.description).toBe(longDescription); + }); + + test("should handle sector field being undefined when not provided", () => { + // GIVEN frontmatter without sector field + const content = `--- +title: No Sector Document +description: This document has no sector +--- + +Content.`; + + // WHEN parseYamlFrontmatter is called + const result = parseYamlFrontmatter(content); + + // THEN expect sector to be undefined + expect(result.data.title).toBe("No Sector Document"); + expect(result.data.description).toBe("This document has no sector"); + expect(result.data.sector).toBeUndefined(); + }); + }); +}); diff --git a/frontend-new/src/knowledgeHub/parseYamlFrontmatter.ts b/frontend-new/src/knowledgeHub/parseYamlFrontmatter.ts new file mode 100644 index 00000000..4d2d8d03 --- /dev/null +++ b/frontend-new/src/knowledgeHub/parseYamlFrontmatter.ts @@ -0,0 +1,51 @@ +import { DocumentFrontmatter } from "./types"; + +export interface ParsedFrontmatter { + data: DocumentFrontmatter; + content: string; +} + +/** + * Simple YAML frontmatter parser that works in the browser + * Parses frontmatter delimited by --- at the start of markdown files + */ +export const parseYamlFrontmatter = (content: string): ParsedFrontmatter => { + const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match) { + return { + data: { title: "Untitled", description: "" }, + content: content, + }; + } + + const yamlContent = match[1]; + const markdownContent = match[2]; + + // Simple YAML key-value parser for frontmatter + const data: Record = {}; + const lines = yamlContent.split("\n"); + + for (const line of lines) { + const colonIndex = line.indexOf(":"); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + let value = line.slice(colonIndex + 1).trim(); + // Remove surrounding quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + data[key] = value; + } + } + + return { + data: { + title: data.title || "Untitled", + description: data.description || "", + sector: data.sector, + }, + content: markdownContent, + }; +}; diff --git a/frontend-new/src/knowledgeHub/types.ts b/frontend-new/src/knowledgeHub/types.ts new file mode 100644 index 00000000..58604b06 --- /dev/null +++ b/frontend-new/src/knowledgeHub/types.ts @@ -0,0 +1,14 @@ +export interface DocumentFrontmatter { + title: string; + description: string; + sector?: string; + icon?: string; +} + +export interface DocumentMetadata extends DocumentFrontmatter { + id: string; +} + +export interface Document extends DocumentMetadata { + content: string; +} diff --git a/frontend-new/src/setupTests.ts b/frontend-new/src/setupTests.ts index f5ab2e7c..5087a83a 100644 --- a/frontend-new/src/setupTests.ts +++ b/frontend-new/src/setupTests.ts @@ -17,3 +17,21 @@ jest.mock("firebase/compat/app", () => require("src/_test_utilities/firebaseMock // Mock the i18n module to use English language during test. import "src/_test_utilities/i18nMock"; + +// Mock react-markdown to avoid ESM issues in Jest +jest.mock("react-markdown", () => { + return { + __esModule: true, + default: ({ children }: { children: string }) => { + return children; + }, + }; +}); + +// Mock remark-gfm +jest.mock("remark-gfm", () => { + return { + __esModule: true, + default: () => {}, + }; +}); diff --git a/frontend-new/yarn.lock b/frontend-new/yarn.lock index 280af9af..036bb87c 100644 --- a/frontend-new/yarn.lock +++ b/frontend-new/yarn.lock @@ -5123,6 +5123,13 @@ dependencies: "@types/node" "*" +"@types/debug@^4.0.0": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + "@types/detect-port@^1.3.0": version "1.3.5" resolved "https://registry.yarnpkg.com/@types/detect-port/-/detect-port-1.3.5.tgz#deecde143245989dee0e82115f3caba5ee0ea747" @@ -5174,6 +5181,13 @@ "@types/estree" "*" "@types/json-schema" "*" +"@types/estree-jsx@^1.0.0": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz#858a88ea20f34fe65111f005a689fa1ebf70dc18" + integrity sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg== + dependencies: + "@types/estree" "*" + "@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" @@ -5294,6 +5308,13 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.4.tgz#0303b64958ee070059e3a7184048a55159fe20b7" integrity sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ== +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + "@types/mdx@^2.0.0": version "2.0.13" resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.13.tgz#68f6877043d377092890ff5b298152b0a21671bd" @@ -5309,6 +5330,11 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== +"@types/ms@*": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + "@types/node-forge@^1.3.0": version "1.3.11" resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" @@ -5513,6 +5539,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.2.tgz#6dd61e43ef60b34086287f83683a5c1b2dc53d20" integrity sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ== +"@types/unist@^2.0.0": + version "2.0.11" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" + integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== + "@types/uuid@^9.0.1": version "9.0.8" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" @@ -6588,6 +6619,11 @@ babel-preset-react-app@^10.0.1: babel-plugin-macros "^3.1.0" babel-plugin-transform-react-remove-prop-types "^0.4.24" +bail@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d" + integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -6896,6 +6932,11 @@ case-sensitive-paths-webpack-plugin@^2.4.0: resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4" integrity sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw== +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + chai@^4.3.10: version "4.4.1" resolved "https://registry.yarnpkg.com/chai/-/chai-4.4.1.tgz#3603fa6eba35425b0f2ac91a009fe924106e50d1" @@ -6949,6 +6990,26 @@ char-regex@^2.0.0: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-2.0.1.tgz#6dafdb25f9d3349914079f010ba8d0e6ff9cd01e" integrity sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw== +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + +character-entities@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22" + integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== + +character-reference-invalid@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9" + integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== + check-error@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" @@ -7180,6 +7241,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -7699,6 +7765,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.0.0: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -7709,6 +7782,13 @@ decimal.js@^10.2.1: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== +decode-named-character-reference@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz#3e40603760874c2e5867691b599d73a7da25b53f" + integrity sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q== + dependencies: + character-entities "^2.0.0" + dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -7851,7 +7931,7 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== -dequal@^2.0.2, dequal@^2.0.3: +dequal@^2.0.0, dequal@^2.0.2, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -7904,6 +7984,13 @@ detect-port@^1.3.0: address "^1.0.1" debug "4" +devlop@^1.0.0, devlop@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + dezalgo@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" @@ -8458,6 +8545,11 @@ escape-string-regexp@^4.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== +escape-string-regexp@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" + integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== + escodegen@^1.8.1: version "1.14.3" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" @@ -8752,6 +8844,11 @@ estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-util-is-identifier-name@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz#0b5ef4c4ff13508b34dcd01ecfa945f61fce5dbd" + integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg== + estree-walker@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700" @@ -8889,6 +8986,11 @@ express@^4.17.3: utils-merge "1.0.1" vary "~1.1.2" +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -9723,6 +9825,27 @@ hast-util-is-element@^3.0.0: dependencies: "@types/hast" "^3.0.0" +hast-util-to-jsx-runtime@^2.0.0: + version "2.3.6" + resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz#ff31897aae59f62232e21594eac7ef6b63333e98" + integrity sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg== + dependencies: + "@types/estree" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + comma-separated-tokens "^2.0.0" + devlop "^1.0.0" + estree-util-is-identifier-name "^3.0.0" + hast-util-whitespace "^3.0.0" + mdast-util-mdx-expression "^2.0.0" + mdast-util-mdx-jsx "^3.0.0" + mdast-util-mdxjs-esm "^2.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + style-to-js "^1.0.0" + unist-util-position "^5.0.0" + vfile-message "^4.0.0" + hast-util-to-string@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/hast-util-to-string/-/hast-util-to-string-3.0.0.tgz#2a131948b4b1b26461a2c8ac876e2c88d02946bd" @@ -9730,6 +9853,13 @@ hast-util-to-string@^3.0.0: dependencies: "@types/hast" "^3.0.0" +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" @@ -9835,6 +9965,11 @@ html-tags@^3.1.0: resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== +html-url-attributes@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87" + integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ== + html-webpack-plugin@^5.5.0: version "5.6.0" resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz#50a8fa6709245608cb00e811eacecb8e0d7b7ea0" @@ -10089,6 +10224,11 @@ ini@^1.3.4, ini@^1.3.5: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +inline-style-parser@0.2.7: + version "0.2.7" + resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz#b1fc68bfc0313b8685745e4464e37f9376b9c909" + integrity sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA== + internal-slot@^1.0.4, internal-slot@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" @@ -10125,6 +10265,19 @@ is-absolute-url@^4.0.0: resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-4.0.1.tgz#16e4d487d4fded05cfe0685e53ec86804a5e94dc" integrity sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A== +is-alphabetical@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz#01072053ea7c1036df3c7d19a6daaec7f19e789b" + integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ== + +is-alphanumerical@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz#7c03fbe96e3e931113e57f964b0a368cc2dfd875" + integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw== + dependencies: + is-alphabetical "^2.0.0" + is-decimal "^2.0.0" + is-arguments@^1.0.4, is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -10206,6 +10359,11 @@ is-date-object@^1.0.1, is-date-object@^1.0.5: dependencies: has-tostringtag "^1.0.0" +is-decimal@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-2.0.1.tgz#9469d2dc190d0214fd87d78b78caecc0cc14eef7" + integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A== + is-deflate@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-deflate/-/is-deflate-1.0.0.tgz#c862901c3c161fb09dac7cdc7e784f80e98f2f14" @@ -10257,6 +10415,11 @@ is-gzip@^1.0.0: resolved "https://registry.yarnpkg.com/is-gzip/-/is-gzip-1.0.0.tgz#6ca8b07b99c77998025900e555ced8ed80879a83" integrity sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ== +is-hexadecimal@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz#86b5bf668fca307498d319dfc03289d781a90027" + integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg== + is-interactive@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" @@ -10317,6 +10480,11 @@ is-plain-obj@^3.0.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA== +is-plain-obj@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + is-plain-object@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" @@ -11875,6 +12043,11 @@ long@^5.0.0: resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== +longest-streak@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" + integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -11973,6 +12146,11 @@ map-or-similar@^1.5.0: resolved "https://registry.yarnpkg.com/map-or-similar/-/map-or-similar-1.5.0.tgz#6de2653174adfb5d9edc33c69d3e92a1b76faf08" integrity sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg== +markdown-table@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a" + integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw== + markdown-to-jsx@7.3.2: version "7.3.2" resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.3.2.tgz#f286b4d112dad3028acc1e77dfe1f653b347e131" @@ -11983,6 +12161,186 @@ material-design-lite@^1.2.0: resolved "https://registry.yarnpkg.com/material-design-lite/-/material-design-lite-1.3.0.tgz#d004ce3fee99a1eeb74a78b8a325134a5f1171d3" integrity sha512-ao76b0bqSTKcEMt7Pui+J/S3eVF0b3GWfuKUwfe2lP5DKlLZOwBq37e0/bXEzxrw7/SuHAuYAdoCwY6mAYhrsg== +mdast-util-find-and-replace@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz#70a3174c894e14df722abf43bc250cbae44b11df" + integrity sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg== + dependencies: + "@types/mdast" "^4.0.0" + escape-string-regexp "^5.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + +mdast-util-from-markdown@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz#c95822b91aab75f18a4cbe8b2f51b873ed2cf0c7" + integrity sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + mdast-util-to-string "^4.0.0" + micromark "^4.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-decode-string "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + unist-util-stringify-position "^4.0.0" + +mdast-util-gfm-autolink-literal@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz#abd557630337bd30a6d5a4bd8252e1c2dc0875d5" + integrity sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ== + dependencies: + "@types/mdast" "^4.0.0" + ccount "^2.0.0" + devlop "^1.0.0" + mdast-util-find-and-replace "^3.0.0" + micromark-util-character "^2.0.0" + +mdast-util-gfm-footnote@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz#7778e9d9ca3df7238cc2bd3fa2b1bf6a65b19403" + integrity sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.1.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + +mdast-util-gfm-strikethrough@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz#d44ef9e8ed283ac8c1165ab0d0dfd058c2764c16" + integrity sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm-table@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz#7a435fb6223a72b0862b33afbd712b6dae878d38" + integrity sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + markdown-table "^3.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm-task-list-item@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz#e68095d2f8a4303ef24094ab642e1047b991a936" + integrity sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz#2cdf63b92c2a331406b0fb0db4c077c1b0331751" + integrity sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ== + dependencies: + mdast-util-from-markdown "^2.0.0" + mdast-util-gfm-autolink-literal "^2.0.0" + mdast-util-gfm-footnote "^2.0.0" + mdast-util-gfm-strikethrough "^2.0.0" + mdast-util-gfm-table "^2.0.0" + mdast-util-gfm-task-list-item "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-mdx-expression@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz#43f0abac9adc756e2086f63822a38c8d3c3a5096" + integrity sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-mdx-jsx@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz#fd04c67a2a7499efb905a8a5c578dddc9fdada0d" + integrity sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + devlop "^1.1.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + parse-entities "^4.0.0" + stringify-entities "^4.0.0" + unist-util-stringify-position "^4.0.0" + vfile-message "^4.0.0" + +mdast-util-mdxjs-esm@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz#019cfbe757ad62dd557db35a695e7314bcc9fa97" + integrity sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-phrasing@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3" + integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w== + dependencies: + "@types/mdast" "^4.0.0" + unist-util-is "^6.0.0" + +mdast-util-to-hast@^13.0.0: + version "13.2.1" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz#d7ff84ca499a57e2c060ae67548ad950e689a053" + integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + +mdast-util-to-markdown@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b" + integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + longest-streak "^3.0.0" + mdast-util-phrasing "^4.0.0" + mdast-util-to-string "^4.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-decode-string "^2.0.0" + unist-util-visit "^5.0.0" + zwitch "^2.0.0" + +mdast-util-to-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814" + integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg== + dependencies: + "@types/mdast" "^4.0.0" + mdn-data@2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" @@ -12037,6 +12395,279 @@ methods@^1.1.2, methods@~1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== +micromark-core-commonmark@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4" + integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg== + dependencies: + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-factory-destination "^2.0.0" + micromark-factory-label "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-factory-title "^2.0.0" + micromark-factory-whitespace "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-html-tag-name "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-autolink-literal@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz#6286aee9686c4462c1e3552a9d505feddceeb935" + integrity sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-footnote@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz#4dab56d4e398b9853f6fe4efac4fc9361f3e0750" + integrity sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw== + dependencies: + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-strikethrough@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz#86106df8b3a692b5f6a92280d3879be6be46d923" + integrity sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-table@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz#fac70bcbf51fe65f5f44033118d39be8a9b5940b" + integrity sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg== + dependencies: + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-tagfilter@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz#f26d8a7807b5985fba13cf61465b58ca5ff7dc57" + integrity sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg== + dependencies: + micromark-util-types "^2.0.0" + +micromark-extension-gfm-task-list-item@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz#bcc34d805639829990ec175c3eea12bb5b781f2c" + integrity sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw== + dependencies: + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz#3e13376ab95dd7a5cfd0e29560dfe999657b3c5b" + integrity sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w== + dependencies: + micromark-extension-gfm-autolink-literal "^2.0.0" + micromark-extension-gfm-footnote "^2.0.0" + micromark-extension-gfm-strikethrough "^2.0.0" + micromark-extension-gfm-table "^2.0.0" + micromark-extension-gfm-tagfilter "^2.0.0" + micromark-extension-gfm-task-list-item "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-destination@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639" + integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-label@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1" + integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg== + dependencies: + devlop "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-space@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc" + integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-title@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94" + integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-whitespace@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1" + integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-chunked@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051" + integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-classify-character@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629" + integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-combine-extensions@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9" + integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg== + dependencies: + micromark-util-chunked "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-decode-numeric-character-reference@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5" + integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-decode-string@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2" + integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== + +micromark-util-html-tag-name@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825" + integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA== + +micromark-util-normalize-identifier@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d" + integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-resolve-all@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b" + integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg== + dependencies: + micromark-util-types "^2.0.0" + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-subtokenize@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee" + integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== + +micromark-util-types@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" + integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== + +micromark@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb" + integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA== + dependencies: + "@types/debug" "^4.0.0" + debug "^4.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: version "4.0.7" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.7.tgz#33e8190d9fe474a9895525f5618eee136d46c2e5" @@ -12197,7 +12828,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.1: +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -12738,6 +13369,19 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parse-entities@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-4.0.2.tgz#61d46f5ed28e4ee62e9ddc43d6b010188443f159" + integrity sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw== + dependencies: + "@types/unist" "^2.0.0" + character-entities-legacy "^3.0.0" + character-reference-invalid "^2.0.0" + decode-named-character-reference "^1.0.0" + is-alphanumerical "^2.0.0" + is-decimal "^2.0.0" + is-hexadecimal "^2.0.0" + parse-json@^5.0.0, parse-json@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" @@ -13632,6 +14276,11 @@ prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +property-information@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" + integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== + protobufjs@^7.2.5: version "7.3.0" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.3.0.tgz#a32ec0422c039798c41a0700306a6e305b9cb32c" @@ -13778,6 +14427,14 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" +raw-loader@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-4.0.2.tgz#1aac6b7d1ad1501e66efdac1522c73e59a584eb6" + integrity sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA== + dependencies: + loader-utils "^2.0.0" + schema-utils "^3.0.0" + react-app-polyfill@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz#95221e0a9bd259e5ca6b177c7bb1cb6768f68fd7" @@ -13930,6 +14587,23 @@ react-is@^19.2.3: resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.4.tgz#a080758243c572ccd4a63386537654298c99d135" integrity sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA== +react-markdown@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca" + integrity sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + hast-util-to-jsx-runtime "^2.0.0" + html-url-attributes "^3.0.0" + mdast-util-to-hast "^13.0.0" + remark-parse "^11.0.0" + remark-rehype "^11.0.0" + unified "^11.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + react-refresh@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" @@ -14247,6 +14921,48 @@ release-zalgo@^1.0.0: dependencies: es6-error "^4.0.1" +remark-gfm@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b" + integrity sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-gfm "^3.0.0" + micromark-extension-gfm "^3.0.0" + remark-parse "^11.0.0" + remark-stringify "^11.0.0" + unified "^11.0.0" + +remark-parse@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1" + integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + micromark-util-types "^2.0.0" + unified "^11.0.0" + +remark-rehype@^11.0.0: + version "11.1.2" + resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-11.1.2.tgz#2addaadda80ca9bd9aa0da763e74d16327683b37" + integrity sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + mdast-util-to-hast "^13.0.0" + unified "^11.0.0" + vfile "^6.0.0" + +remark-stringify@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-11.0.0.tgz#4c5b01dd711c269df1aaae11743eb7e2e7636fd3" + integrity sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-to-markdown "^2.0.0" + unified "^11.0.0" + renderkid@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a" @@ -15105,6 +15821,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" + stringify-object@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" @@ -15184,6 +15908,20 @@ style-loader@^3.3.1: resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.4.tgz#f30f786c36db03a45cbd55b6a70d930c479090e7" integrity sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w== +style-to-js@^1.0.0: + version "1.1.21" + resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.21.tgz#2908941187f857e79e28e9cd78008b9a0b3e0e8d" + integrity sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ== + dependencies: + style-to-object "1.0.14" + +style-to-object@1.0.14: + version "1.0.14" + resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.14.tgz#1d22f0e7266bb8c6d8cae5caf4ec4f005e08f611" + integrity sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw== + dependencies: + inline-style-parser "0.2.7" + stylehacks@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-5.1.1.tgz#7934a34eb59d7152149fa69d6e9e56f2fc34bcc9" @@ -15600,6 +16338,16 @@ tree-kill@^1.2.2: resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== + +trough@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" + integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== + tryer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" @@ -15889,6 +16637,19 @@ unicorn-magic@^0.1.0: resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4" integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ== +unified@^11.0.0: + version "11.0.5" + resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1" + integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA== + dependencies: + "@types/unist" "^3.0.0" + bail "^2.0.0" + devlop "^1.0.0" + extend "^3.0.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^6.0.0" + union@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/union/-/union-0.5.0.tgz#b2c11be84f60538537b846edb9ba266ba0090075" @@ -15917,6 +16678,20 @@ unist-util-is@^6.0.0: dependencies: "@types/unist" "^3.0.0" +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + unist-util-visit-parents@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" @@ -16120,6 +16895,22 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== +vfile-message@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.3.tgz#87b44dddd7b70f0641c2e3ed0864ba73e2ea8df4" + integrity sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + +vfile@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" + integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== + dependencies: + "@types/unist" "^3.0.0" + vfile-message "^4.0.0" + vite-compatible-readable-stream@^3.6.1: version "3.6.1" resolved "https://registry.yarnpkg.com/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz#27267aebbdc9893c0ddf65a421279cbb1e31d8cd" @@ -16882,3 +17673,8 @@ yoga-layout@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/yoga-layout/-/yoga-layout-2.0.1.tgz#4bc686abe2464f977866650ddccc1dbcf9f0d03c" integrity sha512-tT/oChyDXelLo2A+UVnlW9GU7CsvFMaEnd9kVFsaiCQonFAXd3xrHhkLYu+suwwosrAEQ746xBU+HvYtm1Zs2Q== + +zwitch@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== From e23a096844af25659c497e931ea5c0faf40c7d21 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Mon, 2 Mar 2026 17:20:17 +0300 Subject: [PATCH 09/42] feat(backend): simplify counseling phase by removing recommender advisor agent tasks --- .../app/agent/agent_director/_llm_router.py | 12 +- .../agent_director/llm_agent_director.py | 38 ++---- backend/app/conversations/phase_data.py | 7 ++ backend/app/conversations/service.py | 108 +----------------- .../conversations/test_phase_state_machine.py | 9 ++ backend/evaluation_tests/e2e_chat_executor.py | 1 + 6 files changed, 34 insertions(+), 141 deletions(-) diff --git a/backend/app/agent/agent_director/_llm_router.py b/backend/app/agent/agent_director/_llm_router.py index 87ca149c..f48025d9 100644 --- a/backend/app/agent/agent_director/_llm_router.py +++ b/backend/app/agent/agent_director/_llm_router.py @@ -91,16 +91,6 @@ def __init__(self, logger: Logger): "Help me understand what I'm looking for in a job.", "I'd like to explore career preferences."] ) - recommender_advisor_agent_tasks = AgentTasking( - agent_type_name=AgentType.RECOMMENDER_ADVISOR_AGENT.value, - tasks="Present personalized career, job, and training recommendations based on user's " - "preferences, skills, and labor market data. Help user explore and discuss " - "recommendations, compare options, and identify next steps.", - examples=["Show me my career recommendations.", - "What careers match my preferences?", - "I'd like to see job opportunities.", - "What training programs are available for me?"] - ) farewell_agent_tasks = AgentTasking( agent_type_name=AgentType.FAREWELL_AGENT.value, tasks="Ends the conversation with the user.", @@ -116,7 +106,7 @@ def __init__(self, logger: Logger): # Define the tasks for each phase self._agent_tasking_for_phase: dict[ConversationPhase, list[AgentTasking]] = { ConversationPhase.INTRO: [welcome_agent_tasks, default_agent_tasks], - ConversationPhase.COUNSELING: [welcome_agent_tasks, experiences_explorer_agent_tasks, preference_elicitation_agent_tasks, recommender_advisor_agent_tasks, default_agent_tasks], + ConversationPhase.COUNSELING: [welcome_agent_tasks, experiences_explorer_agent_tasks, preference_elicitation_agent_tasks, default_agent_tasks], ConversationPhase.CHECKOUT: [farewell_agent_tasks, default_agent_tasks] } # The default agent for each phase, if the model fails to respond or returns an invalid agent type, diff --git a/backend/app/agent/agent_director/llm_agent_director.py b/backend/app/agent/agent_director/llm_agent_director.py index efb7fcbe..f2c624d0 100644 --- a/backend/app/agent/agent_director/llm_agent_director.py +++ b/backend/app/agent/agent_director/llm_agent_director.py @@ -120,24 +120,16 @@ async def get_suitable_agent_type(self, *, if phase == ConversationPhase.INTRO: return AgentType.WELCOME_AGENT + # In the consulting phase, the agent type is determined by the user's intent. + # Matching and recommendation are detached from the conversation flow (Phase 1 simplification). if phase == ConversationPhase.COUNSELING: # Priority 1: Explicit phase skip overrides everything skip_phase = self._state.skip_to_phase - if skip_phase == JourneyPhase.RECOMMENDATION: - self._logger.info( - "Step-skip: routing to RECOMMENDER_ADVISOR_AGENT (skip_to_phase=RECOMMENDATION)" - ) - return AgentType.RECOMMENDER_ADVISOR_AGENT if skip_phase == JourneyPhase.PREFERENCE_ELICITATION: self._logger.info( "Step-skip: routing to PREFERENCE_ELICITATION_AGENT (skip_to_phase=PREFERENCE_ELICITATION)" ) return AgentType.PREFERENCE_ELICITATION_AGENT - if skip_phase == JourneyPhase.MATCHING: - self._logger.info( - "Step-skip: routing to RECOMMENDER_ADVISOR_AGENT (skip_to_phase=MATCHING)" - ) - return AgentType.RECOMMENDER_ADVISOR_AGENT # Priority 2: Deterministic sub-phase routing sub_phase = self._state.counseling_sub_phase @@ -145,8 +137,6 @@ async def get_suitable_agent_type(self, *, return AgentType.EXPLORE_EXPERIENCES_AGENT if sub_phase == CounselingSubPhase.PREFERENCE_ELICITATION: return AgentType.PREFERENCE_ELICITATION_AGENT - if sub_phase == CounselingSubPhase.RECOMMENDER_ADVISOR: - return AgentType.RECOMMENDER_ADVISOR_AGENT # Fallback: use the LLM router (should not normally reach here) self._logger.warning("Unexpected counseling sub-phase state, falling back to LLM router") @@ -176,19 +166,15 @@ def _get_new_phase(self, agent_output: AgentOutput) -> ConversationPhase: and agent_output.finished): return ConversationPhase.COUNSELING - if current_phase == ConversationPhase.COUNSELING and agent_output.finished: - if agent_output.agent_type == AgentType.EXPLORE_EXPERIENCES_AGENT: - self._state.counseling_sub_phase = CounselingSubPhase.PREFERENCE_ELICITATION - self._logger.info("COUNSELING sub-phase advanced: EXPLORE_EXPERIENCES -> PREFERENCE_ELICITATION") - return ConversationPhase.COUNSELING - - if agent_output.agent_type == AgentType.PREFERENCE_ELICITATION_AGENT: - self._state.counseling_sub_phase = CounselingSubPhase.RECOMMENDER_ADVISOR - self._logger.info("COUNSELING sub-phase advanced: PREFERENCE_ELICITATION -> RECOMMENDER_ADVISOR") - return ConversationPhase.COUNSELING - - if agent_output.agent_type == AgentType.RECOMMENDER_ADVISOR_AGENT: - return ConversationPhase.CHECKOUT + # In the consulting phase, Explore Experiences or Preference Elicitation agents can end the phase. + # Matching and recommendation are detached; conversation flow ends at preference elicitation. + if (current_phase == ConversationPhase.COUNSELING + and agent_output.finished + and agent_output.agent_type in ( + AgentType.EXPLORE_EXPERIENCES_AGENT, + AgentType.PREFERENCE_ELICITATION_AGENT, + )): + return ConversationPhase.CHECKOUT if (current_phase == ConversationPhase.CHECKOUT and agent_output.agent_type == AgentType.FAREWELL_AGENT @@ -245,7 +231,7 @@ async def execute(self, user_input: AgentInput) -> AgentOutput: self._state.sticky_turn_counter = 1 else: self._state.sticky_turn_counter += 1 - + # Set agent_type in context for observability logging agent_type_ctx_var.set(suitable_agent_type.value if suitable_agent_type else ":none:") diff --git a/backend/app/conversations/phase_data.py b/backend/app/conversations/phase_data.py index 1abb4c3c..8fc1c320 100644 --- a/backend/app/conversations/phase_data.py +++ b/backend/app/conversations/phase_data.py @@ -3,12 +3,19 @@ PhaseDataStatus drives determine_start_phase; no override or external store. """ +from typing import Sequence + from app.application_state import ApplicationState from app.conversations.phase_state_machine import JourneyPhase, PhaseDataStatus from app.agent.explore_experiences_agent_director import DiveInPhase from app.agent.agent_director.abstract_agent_director import ConversationPhase from app.user_recommendations.services.service import IUserRecommendationsService +CONVERSATION_ALLOWED_PHASES: Sequence[JourneyPhase] = ( + JourneyPhase.SKILLS_ELICITATION, + JourneyPhase.PREFERENCE_ELICITATION, +) + def conversation_phase_for_entry(journey_phase: JourneyPhase) -> ConversationPhase | None: """ diff --git a/backend/app/conversations/service.py b/backend/app/conversations/service.py index 195dfcc3..471113fa 100644 --- a/backend/app/conversations/service.py +++ b/backend/app/conversations/service.py @@ -7,7 +7,7 @@ from app.agent.agent_director.abstract_agent_director import ConversationPhase from app.agent.agent_director.llm_agent_director import LLMAgentDirector -from app.conversations.phase_state_machine import JourneyPhase, PhaseDataStatus, determine_start_phase +from app.conversations.phase_state_machine import determine_start_phase from app.agent.agent_types import AgentInput from app.agent.explore_experiences_agent_director import DiveInPhase from app.application_state import ApplicationState @@ -22,6 +22,7 @@ from app.conversations.phase_data import ( apply_entry_phase, build_phase_data_status_from_state, + CONVERSATION_ALLOWED_PHASES, ) from app.user_recommendations.services.service import IUserRecommendationsService from app.job_preferences.types import JobPreferences @@ -29,7 +30,7 @@ from app.app_config import get_application_config from app.context_vars import turn_index_ctx_var, detected_language_ctx_var, user_language_ctx_var from app.agent.persona_detector import detect_persona -from app.agent.language_detector import detect_language, get_locale_for_detected_language, DetectedLanguage +from app.agent.language_detector import detect_language, get_locale_for_detected_language from app.i18n.types import Locale class ConversationAlreadyConcludedError(Exception): @@ -108,7 +109,7 @@ async def send(self, user_id: str, session_id: int, user_input: str, clear_memor data = await build_phase_data_status_from_state( state, user_id, self._user_recommendations_service ) - entry_phase = determine_start_phase(data) + entry_phase = determine_start_phase(data, allowed_phases=CONVERSATION_ALLOWED_PHASES) self._logger.info( "Step-skip check: session=%s user=%s entry_phase=%s", session_id, user_id, entry_phase.value, @@ -134,11 +135,6 @@ async def send(self, user_id: str, session_id: int, user_input: str, clear_memor self._agent_director.get_explore_experiences_agent().get_exploring_skills_agent().set_state( state.skills_explorer_agent_state) self._agent_director.get_preference_elicitation_agent().set_state(state.preference_elicitation_agent_state) - - # Prepare recommender state with skills and preferences if not already set - await self._prepare_recommender_state_if_needed(state, user_id) - - self._agent_director.get_recommender_advisor_agent().set_state(state.recommender_advisor_agent_state) self._conversation_memory_manager.set_state(state.conversation_memory_manager_state) # Handle the user input @@ -306,99 +302,3 @@ async def _save_preference_vector_to_job_preferences(self, state: ApplicationSta # Don't fail the conversation - just log the error # This is a denormalized copy; the primary data is already in DB6 self._logger.error(f"Failed to save preference vector to JobPreferences: {e}", exc_info=True) - - async def _prepare_recommender_state_if_needed(self, state: ApplicationState, user_id: str) -> None: - """ - Prepare RecommenderAdvisorAgent state with skills and preferences if not already initialized. - - When skip_to_phase is RECOMMENDATION, loads pre-computed recommendations from - user_recommendations collection and passes them to the agent. - - Otherwise populates: - - Skills vector from explored experiences - - Preference vector from preference elicitation agent - - BWS occupation scores from preference elicitation - - Location data (city/province) - optional for v1 - - Args: - state: Application state containing all agent states - user_id: User ID for loading pre-computed recommendations when skipping - """ - rec_state = state.recommender_advisor_agent_state - - if (state.agent_director_state.skip_to_phase == JourneyPhase.RECOMMENDATION - and rec_state.recommendations is None): - self._logger.info( - "Step-skip: loading pre-computed recommendations for user=%s", user_id - ) - try: - db_recs = await self._user_recommendations_service.get_by_user_id(user_id) - if db_recs: - from app.agent.recommender_advisor_agent.user_recommendations_converter import ( - user_recommendations_to_node2vec, - ) - rec_state.recommendations = user_recommendations_to_node2vec(user_id, db_recs) - rec_state.youth_id = user_id - self._logger.info( - "Loaded pre-computed recommendations for recommender: " - "%d occupations, %d opportunities, %d skill gaps", - len(db_recs.occupation_recommendations), - len(db_recs.opportunity_recommendations), - len(db_recs.skill_gap_recommendations), - ) - except Exception as e: - self._logger.warning( - "Failed to load pre-computed recommendations for skip: %s", e, exc_info=True - ) - - if rec_state.skills_vector is not None and rec_state.preference_vector is not None: - return - - try: - # Extract skills from explored experiences - if rec_state.skills_vector is None: - from app.agent.recommender_advisor_agent.skills_extractor import SkillsExtractor - - explored_experiences = state.explore_experiences_director_state.explored_experiences - extractor = SkillsExtractor() - skills_vector = extractor.extract_skills_vector(explored_experiences) - - rec_state.skills_vector = skills_vector - self._logger.info( - f"Extracted skills vector for recommender: " - f"{len(skills_vector.get('skills', []))} skills from " - f"{skills_vector.get('total_experiences', 0)} experiences" - ) - - # Extract preference vector from preference elicitation agent - if rec_state.preference_vector is None: - pref_state = state.preference_elicitation_agent_state - if pref_state.preference_vector is not None: - rec_state.preference_vector = pref_state.preference_vector - self._logger.info( - f"Loaded preference vector for recommender " - f"(confidence: {pref_state.preference_vector.confidence_score:.2f})" - ) - - # Extract BWS occupation scores from preference elicitation - if rec_state.bws_occupation_scores is None: - pref_state = state.preference_elicitation_agent_state - if pref_state.occupation_scores: - rec_state.bws_occupation_scores = pref_state.occupation_scores - self._logger.info( - f"Loaded BWS occupation scores for recommender: " - f"{len(pref_state.occupation_scores)} occupations" - ) - - # Set youth_id (use session_id as fallback) - if rec_state.youth_id is None: - rec_state.youth_id = f"youth_{state.session_id}" - - # Location data (city/province) - optional for v1 - # TODO: Extract from user profile or welcome agent when implemented - # For now, matching service will use defaults - - except Exception as e: - # Don't fail - just log the error - # Recommender agent will work with whatever data is available - self._logger.warning(f"Error preparing recommender state: {e}", exc_info=True) diff --git a/backend/app/conversations/test_phase_state_machine.py b/backend/app/conversations/test_phase_state_machine.py index 56b121fa..b4b0811d 100644 --- a/backend/app/conversations/test_phase_state_machine.py +++ b/backend/app/conversations/test_phase_state_machine.py @@ -72,3 +72,12 @@ def test_raises_when_allowed_phases_empty_after_filter(): with pytest.raises(ValueError): determine_start_phase(status, allowed_phases=[]) + +def test_conversation_allowed_phases_restricts_to_skills_and_preference(): + from app.conversations.phase_data import CONVERSATION_ALLOWED_PHASES + + assert list(CONVERSATION_ALLOWED_PHASES) == [ + JourneyPhase.SKILLS_ELICITATION, + JourneyPhase.PREFERENCE_ELICITATION, + ] + diff --git a/backend/evaluation_tests/e2e_chat_executor.py b/backend/evaluation_tests/e2e_chat_executor.py index f24a4638..47e0e811 100644 --- a/backend/evaluation_tests/e2e_chat_executor.py +++ b/backend/evaluation_tests/e2e_chat_executor.py @@ -226,6 +226,7 @@ def _infer_phase_from_agent(self, agent_type: str) -> str: "COLLECT_EXPERIENCES_AGENT": "COUNSELING", "EXPLORE_SKILLS_AGENT": "COUNSELING", "INFER_OCCUPATIONS_AGENT": "COUNSELING", + "PREFERENCE_ELICITATION_AGENT": "COUNSELING", "RECOMMENDER_ADVISOR_AGENT": "COUNSELING", "FAREWELL_AGENT": "CHECKOUT", } From 46b56c8eceede3c4f09c7767d3f9459cf7d26099 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Tue, 3 Mar 2026 09:55:17 +0300 Subject: [PATCH 10/42] feat(backend): remove new session feature to simplify the module architecture --- backend/app/users/preferences.py | 68 +--- backend/app/users/sessions.py | 47 --- .../export_conversation/import_script.py | 14 +- frontend-new/src/chat/Chat.stories.tsx | 10 +- frontend-new/src/chat/Chat.test.tsx | 344 +----------------- frontend-new/src/chat/Chat.tsx | 43 +-- .../src/chat/ChatHeader/ChatHeader.test.tsx | 21 +- .../src/chat/ChatHeader/ChatHeader.tsx | 5 +- ...wSession.test.ts => ensureSession.test.ts} | 47 +-- frontend-new/src/chat/ensureSession.ts | 20 + frontend-new/src/chat/issueNewSession.ts | 22 -- .../userPreferences.service.ts | 26 -- 12 files changed, 82 insertions(+), 585 deletions(-) delete mode 100644 backend/app/users/sessions.py rename frontend-new/src/chat/{issueNewSession.test.ts => ensureSession.test.ts} (53%) create mode 100644 frontend-new/src/chat/ensureSession.ts delete mode 100644 frontend-new/src/chat/issueNewSession.ts diff --git a/backend/app/users/preferences.py b/backend/app/users/preferences.py index 19ad424b..f469bd57 100644 --- a/backend/app/users/preferences.py +++ b/backend/app/users/preferences.py @@ -19,7 +19,7 @@ from app.users.sensitive_personal_data.routes import get_sensitive_personal_data_service from app.users.sensitive_personal_data.service import ISensitivePersonalDataService from app.users.sensitive_personal_data.types import SensitivePersonalDataRequirement -from app.users.sessions import generate_new_session_id, SessionsService +from app.users.generate_session_id import generate_new_session_id from app.users.types import UserPreferencesUpdateRequest, UserPreferences, \ CreateUserPreferencesRequest, UserPreferencesRepositoryUpdateRequest, UsersPreferencesResponse @@ -155,41 +155,6 @@ async def _create_user_preferences( raise HTTPException(status_code=500, detail="failed to create user preferences") -async def _get_new_session(user_repository: UserPreferenceRepository, - user_feedback_service: UserFeedbackService, - sensitive_personal_data_service: ISensitivePersonalDataService, - user_id: str, - authed_user: UserInfo) -> UsersPreferencesResponse: - """ - Get a new session for the user - :param user_id: id of the user - :param authed_user: authenticated user - :return: UserPreferences - with the new session - """ - try: - # Check if the user is the same as the authenticated user - if user_id != authed_user.user_id: - raise HTTPException(status_code=403, detail="forbidden") - - session_service = SessionsService(user_repository) - - updated_user_preferences, sessions_with_feedback, has_sensitive_personal_data = await asyncio.gather( - session_service.new_session(user_id), - user_feedback_service.get_answered_questions(user_id), - sensitive_personal_data_service.exists_by_user_id(user_id) - ) - - return UsersPreferencesResponse( - **updated_user_preferences.model_dump(), - has_sensitive_personal_data=has_sensitive_personal_data, - user_feedback_answered_questions=sessions_with_feedback - ) - - except Exception as e: - ErrorService.handle(__name__, e) - raise HTTPException(status_code=500, detail="Oops! something went wrong") - - async def _update_user_preferences( repository: UserPreferenceRepository, user_feedback_service: UserFeedbackService, @@ -359,37 +324,6 @@ async def _update_user_preferences_handler( user_info ) - ######################### - # GET /new-session - Get a new session for the user - ######################### - @router.get("/new-session", - response_model=UsersPreferencesResponse, - status_code=201, - responses={403: {"model": HTTPErrorResponse}, 500: {"model": HTTPErrorResponse}}, - description="""Endpoint for starting a new conversation session.""") - async def _get_new_session_handler(user_id: str, user_info: UserInfo = Depends(auth.get_user_info()), - user_preference_repository: UserPreferenceRepository = Depends( - get_user_preferences_repository), - sensitive_personal_data_service: ISensitivePersonalDataService = Depends( - get_sensitive_personal_data_service), - user_feedback_service: UserFeedbackService = Depends(_get_user_feedback_service) - ) -> UsersPreferencesResponse: - """ - Endpoint for starting a new conversation session. - The function creates a new session id and adds it to the user sessions on the top of the list. - - :param user_info: UserInfo - The logged-in user information - :return: UserPreferences - The updated user preferences - """ - # set the user id context variable. - user_id_ctx_var.set(user_id) - - return await _get_new_session(user_preference_repository, - user_feedback_service, - sensitive_personal_data_service, - user_id, - user_info) - ######################### # Add the router to the users router ######################### diff --git a/backend/app/users/sessions.py b/backend/app/users/sessions.py deleted file mode 100644 index d4c41382..00000000 --- a/backend/app/users/sessions.py +++ /dev/null @@ -1,47 +0,0 @@ -import logging - -from fastapi import HTTPException - -from app.constants.errors import ErrorService -from app.users.generate_session_id import generate_new_session_id -from app.users.repositories import UserPreferenceRepository -from app.users.types import UserPreferences, UserPreferencesRepositoryUpdateRequest - - -logger = logging.getLogger(__name__) - - -class SessionsService: - def __init__(self, user_repository: UserPreferenceRepository): - self.user_repository = user_repository - - async def new_session(self, user_id: str) -> UserPreferences: - """ - Create a new session for the user - :param user_id: str - the user ID - :return: UserPreferences - the updated user preferences - """ - try: - new_session_id = generate_new_session_id() - - # Get the user preferences - user_preferences = await self.user_repository.get_user_preference_by_user_id(user_id) - - # If the user does not exist, raise an HTTPException - if user_preferences is None: - logger.info("User preferences not found. user_id=%s", user_id) - raise HTTPException(status_code=404, detail="User not found") - - # Add the new session to the user preferences - # we are using the new session ID as the first element of the list - # And the client must use the new session ID for the next requests - # :note: This must be in sync with frontend-new/src/chat/Chat.tsx#L179 - new_sessions = [new_session_id, *user_preferences.sessions] - - return await self.user_repository.update_user_preference( - user_id=user_id, - update=UserPreferencesRepositoryUpdateRequest(sessions=new_sessions) - ) - - except Exception as e: - ErrorService.handle(__name__, e) diff --git a/backend/scripts/export_conversation/import_script.py b/backend/scripts/export_conversation/import_script.py index b0027618..14c866ca 100755 --- a/backend/scripts/export_conversation/import_script.py +++ b/backend/scripts/export_conversation/import_script.py @@ -11,8 +11,9 @@ from pydantic_settings import BaseSettings from app.application_state import ApplicationStateStore, ApplicationState +from app.users.generate_session_id import generate_new_session_id from app.users.repositories import UserPreferenceRepository -from app.users.sessions import SessionsService +from app.users.types import UserPreferencesRepositoryUpdateRequest from _common import StoreType, create_store, get_db_connection from common_libs.logging.log_utilities import setup_logging_config from scripts.export_conversation.constants import SCRIPT_DIR, DEFAULT_EXPORTS_DIR @@ -188,13 +189,12 @@ async def import_conversation( logger.error(f"No state found for session {source_session_id}") return False - # Create a new session for the target user user_repository = UserPreferenceRepository(target_db) - session_service = SessionsService(user_repository) - - # get the new session on the target user preferences with new session id. - new_user_preferences = await session_service.new_session(target_user_id) - target_new_session_id = new_user_preferences.sessions[0] + target_new_session_id = generate_new_session_id() + await user_repository.update_user_preference( + target_user_id, + UserPreferencesRepositoryUpdateRequest(sessions=[target_new_session_id]), + ) # Update session ID in the state state.session_id = target_new_session_id diff --git a/frontend-new/src/chat/Chat.stories.tsx b/frontend-new/src/chat/Chat.stories.tsx index 9fc3d1a0..53fb4760 100644 --- a/frontend-new/src/chat/Chat.stories.tsx +++ b/frontend-new/src/chat/Chat.stories.tsx @@ -20,7 +20,7 @@ import { ConversationMessageSender, ConversationResponse } from "./ChatService/C import { nanoid } from "nanoid"; const CONVERSATION_HISTORY_URL = getBackendUrl() + "/conversations/:session_id/messages"; -const NEW_SESSION_URL = getBackendUrl() + "/users/preferences/new-session"; +const USER_PREFERENCES_URL = getBackendUrl() + "/users/preferences"; // Generate a timestamp relative to now const getTimestamp = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60 * 1000).toISOString(); @@ -31,11 +31,11 @@ const meta: Meta = { parameters: { mockData: [ { - url: NEW_SESSION_URL + "?user_id=1", + url: USER_PREFERENCES_URL + "?user_id=1", method: "GET", - status: 201, + status: 200, response: { - user_id: nanoid(), + user_id: "1", language: Language.en, sessions: [1], user_feedback_answered_questions: {}, @@ -144,7 +144,7 @@ const meta: Meta = { // Mock UserPreferencesService const mockUserPreferencesService = UserPreferencesService.getInstance(); // @ts-ignore - mockUserPreferencesService.getNewSession = async (userId: string) => ({ + mockUserPreferencesService.getUserPreferences = async (userId: string) => ({ user_id: userId, language: Language.en, sessions: [123], diff --git a/frontend-new/src/chat/Chat.test.tsx b/frontend-new/src/chat/Chat.test.tsx index 5cf46c02..1c3c22f0 100644 --- a/frontend-new/src/chat/Chat.test.tsx +++ b/frontend-new/src/chat/Chat.test.tsx @@ -20,7 +20,7 @@ import UserPreferencesStateService from "src/userPreferences/UserPreferencesStat import { ConversationMessage, ConversationMessageSender, ConversationResponse } from "./ChatService/ChatService.types"; import AuthenticationStateService from "src/auth/services/AuthenticationState.service"; import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; -import { ChatError } from "src/error/commonErrors"; +import { ChatError, SessionError } from "src/error/commonErrors"; import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; import ExperienceService from "src/experiences/experienceService/experienceService"; import ExperiencesDrawer, { @@ -843,7 +843,9 @@ describe("Chat", () => { user_feedback_answered_questions: {}, experiments: {}, }; - jest.spyOn(UserPreferencesService.getInstance(), "getNewSession").mockResolvedValueOnce(givenUserPreferences); + jest + .spyOn(UserPreferencesService.getInstance(), "getUserPreferences") + .mockResolvedValueOnce(givenUserPreferences); // AND the conversation history is empty const givenChatHistoryResponse: ConversationResponse = getMockConversationResponse( [], @@ -872,8 +874,8 @@ describe("Chat", () => { // THEN expect the Chat component to be initialized await assertChatInitialized(); - // AND the getNewSession method to be called - expect(UserPreferencesService.getInstance().getNewSession).toHaveBeenCalled(); + // AND the getUserPreferences method to be called + expect(UserPreferencesService.getInstance().getUserPreferences).toHaveBeenCalled(); // AND the UserPreferencesStateService to have been updated with the new session id expect(UserPreferencesStateService.getInstance().getUserPreferences()).toEqual(givenUserPreferences); // AND a typing indicator to be shown @@ -902,7 +904,7 @@ describe("Chat", () => { // AND a user preferences service that fails to create a new session const givenError = new Error("Failed to create new session"); - jest.spyOn(UserPreferencesService.getInstance(), "getNewSession").mockRejectedValueOnce(givenError); + jest.spyOn(UserPreferencesService.getInstance(), "getUserPreferences").mockRejectedValueOnce(givenError); // WHEN the component is mounted render(); @@ -911,8 +913,8 @@ describe("Chat", () => { await assertChatInitialized(); // AND expect a typing indicator to be shown assertTypingMessageWasShown(); - // AND expect the getNewSession method to be called - expect(UserPreferencesService.getInstance().getNewSession).toHaveBeenCalled(); + // AND expect the getUserPreferences method to be called + expect(UserPreferencesService.getInstance().getUserPreferences).toHaveBeenCalled(); // AND expect a snackbar notification to be shown expect(useSnackbar().enqueueSnackbar).toHaveBeenCalledWith("Failed to start new conversation", { variant: "error", @@ -939,7 +941,7 @@ describe("Chat", () => { ); // AND expect an error to have been logged expect(console.error).toHaveBeenCalledTimes(1); - expect(console.error).toHaveBeenCalledWith(new ChatError("Failed to create new session", givenError)); + expect(console.error).toHaveBeenCalledWith(new SessionError("Failed to ensure session for user", givenError)); }); test("should show error if fetching history fails", async () => { @@ -965,7 +967,9 @@ describe("Chat", () => { language: Language.en, experiments: {}, }; - jest.spyOn(UserPreferencesService.getInstance(), "getNewSession").mockResolvedValueOnce(givenUserPreferences); + jest + .spyOn(UserPreferencesService.getInstance(), "getUserPreferences") + .mockResolvedValueOnce(givenUserPreferences); // AND a chat service that fails to get chat history const givenError = new Error("Failed to get chat history"); @@ -978,8 +982,8 @@ describe("Chat", () => { await assertChatInitialized(); // AND expect a typing indicator to be shown assertTypingMessageWasShown(); - // AND expect the getNewSession method to be called - expect(UserPreferencesService.getInstance().getNewSession).toHaveBeenCalled(); + // AND expect the getUserPreferences method to be called + expect(UserPreferencesService.getInstance().getUserPreferences).toHaveBeenCalled(); // AND expect an error message to be shown in the chat assertMessagesAreShown( [ @@ -1029,7 +1033,9 @@ describe("Chat", () => { sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, experiments: {}, }; - jest.spyOn(UserPreferencesService.getInstance(), "getNewSession").mockResolvedValueOnce(givenUserPreferences); + jest + .spyOn(UserPreferencesService.getInstance(), "getUserPreferences") + .mockResolvedValueOnce(givenUserPreferences); // AND the chat history is empty const givenChatHistoryResponse: ConversationResponse = getMockConversationResponse( @@ -1050,8 +1056,8 @@ describe("Chat", () => { await assertChatInitialized(); // AND expect a typing indicator to be shown assertTypingMessageWasShown(); - // AND expect the getNewSession method to be called - expect(UserPreferencesService.getInstance().getNewSession).toHaveBeenCalled(); + // AND expect the getUserPreferences method to be called + expect(UserPreferencesService.getInstance().getUserPreferences).toHaveBeenCalled(); // AND expect a message to be shown in the chat assertMessagesAreShown( [ @@ -1857,316 +1863,6 @@ describe("Chat", () => { }); }); - describe("starting a new conversation", () => { - test("should start a new conversation", async () => { - // GIVEN a user is logged in - const givenUser: TabiyaUser = getMockUser(); - AuthenticationStateService.getInstance().setUser(givenUser); - // AND the user has an active session - const givenActiveSessionId = 1000; - UserPreferencesStateService.getInstance().setUserPreferences( - getMockUserPreferences(givenUser, givenActiveSessionId) - ); - // AND the user preferences service will return a new session id - const givenNewSessionId = 2000; - const givenUserPreferences: UserPreference = { - user_id: givenUser.id, - accepted_tc: new Date(), - has_sensitive_personal_data: false, - language: Language.en, - sensitive_personal_data_requirement: SensitivePersonalDataRequirement.NOT_REQUIRED, - sessions: [givenNewSessionId, givenActiveSessionId], - user_feedback_answered_questions: {}, - experiments: {}, - }; - jest.spyOn(UserPreferencesService.getInstance(), "getNewSession").mockResolvedValueOnce(givenUserPreferences); - - // AND the conversation history has some messages for the current session, but is empty for the new session - const givenInitialSessionChatHistoryResponse: ConversationResponse = getMockConversationResponse( - [ - { - message_id: nanoid(), - message: "1584e645-e367-490d-bc5c-c14f41bc0e80", - sent_at: new Date().toISOString(), - sender: ConversationMessageSender.COMPASS, - reaction: null, - }, - { - message_id: nanoid(), - message: "904a1ceb-2bf0-478a-b28f-fa73c86efdb6", - sent_at: new Date().toISOString(), - sender: ConversationMessageSender.USER, - reaction: null, - }, - ], - ConversationPhase.DIVE_IN, - 75 - ); - const givenNewSessionChatHistoryResponse: ConversationResponse = getMockConversationResponse( - [], - ConversationPhase.INTRO, - 0 - ); - const mockGetChatHistoryFn = (sessionId: number) => { - if (sessionId === givenNewSessionId) { - return Promise.resolve(givenNewSessionChatHistoryResponse); - } else if (sessionId === givenActiveSessionId) { - return Promise.resolve(givenInitialSessionChatHistoryResponse); - } - throw new Error("Invalid session id"); - }; - jest.spyOn(ChatService.getInstance(), "getChatHistory").mockImplementationOnce(mockGetChatHistoryFn); - // AND the chat service will return a message when a message is sent - const givenSendMessageResponse: ConversationResponse = getMockConversationResponse( - [ - { - message_id: nanoid(), - message: "69eb00eb-4d55-44b2-bd47-0245df0ca9cc", - sent_at: new Date().toISOString(), - sender: ConversationMessageSender.COMPASS, - reaction: null, - }, - ], - ConversationPhase.COLLECT_EXPERIENCES, - 25 - ); - jest.spyOn(ChatService.getInstance(), "sendMessage").mockResolvedValueOnce(givenSendMessageResponse); - - // AND the component is mounted - render(); - - // THEN expect the Chat component to be initialized - await assertChatInitialized(); - // AND the chat history to be shown - assertResponseMessagesAreShown(givenInitialSessionChatHistoryResponse, true); - // WHEN new conversation clicked - // we are using the last call to the ChatHeader mock because the first one is the initial call - // and a bunch of calls are made when the component is re-rendered, - // for example, due to the chat initialization - act(() => { - (ChatHeader as jest.Mock).mock.calls.at(-1)[0].startNewConversation(); - }); - - // THEN expect confirmation dialog to be shown - await waitFor(() => { - expect(screen.getByTestId(CONFIRM_MODAL_DIALOG_DATA_TEST_ID.CONFIRM_MODAL)).toBeInTheDocument(); - }); - // AND the new session has not been fetched yet - expect(UserPreferencesService.getInstance().getNewSession).not.toHaveBeenCalled(); - // BEFORE the user confirms - // Mock the getChatHistory method a second time for the new session - jest.spyOn(ChatService.getInstance(), "getChatHistory").mockImplementationOnce(mockGetChatHistoryFn); - - // WHEN the user confirms, - // we are using the last call to the ConfirmModalDialog mock because the first one is the initial call - // and a bunch of calls are made when the component is re-rendered, - // for example, due to the chat initialization - act(() => { - (ConfirmModalDialog as jest.Mock).mock.calls.at(-1)[0].onConfirm(); - }); - // THEN expect the new session to be fetched for the given user - await waitFor(() => { - expect(UserPreferencesService.getInstance().getNewSession).toHaveBeenCalledTimes(1); - }); - expect(UserPreferencesService.getInstance().getNewSession).toHaveBeenCalledWith(givenUser.id); - // AND expect the chat to be reset and only show the typing indicator - const callsToChatlist = (ChatList as jest.Mock).mock.calls.length; - assertTypingMessageWasShown(callsToChatlist); - // AND the input field to be disabled because the ai is typing - expect(ChatMessageField as jest.Mock).toHaveBeenLastCalledWith( - expect.objectContaining({ isChatFinished: false, aiIsTyping: true }), - {} - ); - - // AND expect the chat history to be fetched for the new session - await waitFor(() => { - expect(ChatService.getInstance().getChatHistory).toHaveBeenCalledWith(givenNewSessionId); - }); - // AND expect an empty message to be sent to the chat service for the new session - await waitFor(() => { - expect(ChatService.getInstance().sendMessage).toHaveBeenCalledWith(givenNewSessionId, ""); - }); - // AND expect the chat list to be updated with the response from the chat service - await waitFor(() => { - assertResponseMessagesAreShown(givenSendMessageResponse); - }); - - // AND expect a snackbar notification was shown once - expect(useSnackbar().enqueueSnackbar).toHaveBeenCalledTimes(1); - // AND expect the snackbar notification to a success message - expect(useSnackbar().enqueueSnackbar).toHaveBeenCalledWith("New conversation started", { variant: "success" }); - - // AND expect input field to have be enabled because the conversation is not finished - expect(ChatMessageField as jest.Mock).toHaveBeenLastCalledWith( - expect.objectContaining({ isChatFinished: false, aiIsTyping: false }), - {} - ); - }); - - test("should handle new conversation cancellation", async () => { - // GIVEN a user is logged in - const givenUser: TabiyaUser = getMockUser(); - AuthenticationStateService.getInstance().setUser(givenUser); - // AND the user has an active session - const givenActiveSessionId = 123; - UserPreferencesStateService.getInstance().setUserPreferences( - getMockUserPreferences(givenUser, givenActiveSessionId) - ); - // AND a chat service that returns an existing conversation - const givenPreviousConversation = getMockConversationResponse( - [ - { - message_id: nanoid(), - message: "7f3f43fd-a203-4b4a-9f6a-da6dfb301558", - sent_at: new Date().toISOString(), - sender: ConversationMessageSender.COMPASS, - reaction: { - id: nanoid(), - kind: ReactionKind.DISLIKED, - }, - }, - ], - ConversationPhase.DIVE_IN, - 75 - ); - jest.spyOn(ChatService.getInstance(), "getChatHistory").mockResolvedValueOnce(givenPreviousConversation); - - // AND the component is mounted - render(); - - // WHEN new conversation clicked - // we are using the last call to the ChatHeader mock because the first one is the initial call - // and a bunch of calls are made when the component is re-rendered, - // for example, due to the chat initialization - act(() => { - (ChatHeader as jest.Mock).mock.calls.at(-1)[0].startNewConversation(); - }); - // THEN expect confirmation dialog to be shown - await waitFor(() => { - expect(screen.getByTestId(CONFIRM_MODAL_DIALOG_DATA_TEST_ID.CONFIRM_MODAL)).toBeInTheDocument(); - }); - - // WHEN user cancels - // we are using the last call to the ConfirmModalDialog mock because the first one is the initial call - // and a bunch of calls are made when the component is re-rendered, - // for example, due to the chat initialization - act(() => { - (ConfirmModalDialog as jest.Mock).mock.calls.at(-1)[0].onCancel(); - }); - // THEN expect dialog to close - expect(screen.queryByText("Are you sure you want to start a new conversation?")).not.toBeInTheDocument(); - - // AND expect chat state to remain unchanged - assertMessagesAreShown( - [ - ...givenPreviousConversation.messages.map((message) => ({ - message_id: message.message_id, - sender: message.sender, - type: COMPASS_CHAT_MESSAGE_TYPE, - payload: { - message_id: message.message_id, - message: message.message, - sent_at: message.sent_at, - reaction: { - id: message.reaction?.id, - kind: message.reaction?.kind, - }, - }, - component: expect.any(Function), - })), - ], - true - ); - }); - - test("should handle new conversation error", async () => { - // GIVEN a user is logged in - const givenUser: TabiyaUser = getMockUser(); - AuthenticationStateService.getInstance().setUser(givenUser); - // AND the user has an active session - const givenActiveSessionId = 123; - UserPreferencesStateService.getInstance().setUserPreferences( - getMockUserPreferences(givenUser, givenActiveSessionId) - ); - - // AND a chat service that returns an existing conversation - const givenPreviousConversation = getMockConversationResponse( - [ - { - message_id: nanoid(), - message: "7a283c09-c4d4-4bf3-a480-1263e4d5282e", - sent_at: new Date().toISOString(), - sender: ConversationMessageSender.COMPASS, - reaction: { - id: nanoid(), - kind: ReactionKind.LIKED, - }, - }, - ], - ConversationPhase.DIVE_IN, - 75 - ); - - jest.spyOn(ChatService.getInstance(), "getChatHistory").mockResolvedValueOnce(givenPreviousConversation); - - // AND a failing new session service - const givenError = new Error("Failed to start new conversation"); - jest.spyOn(UserPreferencesService.getInstance(), "getNewSession").mockRejectedValueOnce(givenError); - - // AND the component is mounted - render(); - - // WHEN new conversation clicked - act(() => { - (ChatHeader as jest.Mock).mock.calls.at(-1)[0].startNewConversation(); - }); - // THEN expect confirmation dialog to be shown - await waitFor(() => { - expect(screen.getByTestId(CONFIRM_MODAL_DIALOG_DATA_TEST_ID.CONFIRM_MODAL)).toBeInTheDocument(); - }); - - // AND the user confirms - // we are using the last call to the ConfirmModalDialog mock because the first one is the initial call - // and a bunch of calls are made when the component is re-rendered, - // for example, due to the chat initialization - act(() => { - (ConfirmModalDialog as jest.Mock).mock.calls.at(-1)[0].onConfirm(); - }); - // THEN expect an error message to be added to the chat list - await waitFor(() => { - assertMessagesAreShown( - [ - { - message_id: expect.any(String), - type: ERROR_CHAT_MESSAGE_TYPE, - sender: ConversationMessageSender.COMPASS, - payload: { - message: FIXED_MESSAGES_TEXT.SOMETHING_WENT_WRONG, - }, - component: expect.any(Function), - }, - ], - true - ); - }); - - // AND expect input field to have be disabled because the conversation is finished - expect(ChatMessageField as jest.Mock).toHaveBeenLastCalledWith( - expect.objectContaining({ isChatFinished: true }), - {} - ); - - // AND expect a snackbar notification was shown once - expect(useSnackbar().enqueueSnackbar).toHaveBeenCalledTimes(1); - // AND expect the snackbar notification to be an error - expect(useSnackbar().enqueueSnackbar).toHaveBeenCalledWith("Failed to start new conversation", { - variant: "error", - }); - // AND expect the dialog to close - expect(screen.queryByTestId(CONFIRM_MODAL_DIALOG_DATA_TEST_ID.CONFIRM_MODAL)).not.toBeInTheDocument(); - }); - }); - describe("handling inactivity backdrop", () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/frontend-new/src/chat/Chat.tsx b/frontend-new/src/chat/Chat.tsx index b72a3037..eb2c643f 100644 --- a/frontend-new/src/chat/Chat.tsx +++ b/frontend-new/src/chat/Chat.tsx @@ -31,7 +31,7 @@ import InactiveBackdrop from "src/theme/Backdrop/InactiveBackdrop"; import ConfirmModalDialog from "src/theme/confirmModalDialog/ConfirmModalDialog"; import { ChatError, MetricsError } from "src/error/commonErrors"; import authenticationStateService from "src/auth/services/AuthenticationState.service"; -import { issueNewSession } from "./issueNewSession"; +import { ensureSessionForUser } from "./ensureSession"; import { ChatProvider } from "src/chat/ChatContext"; import { lazyWithPreload } from "src/utils/preloadableComponent/PreloadableComponent"; import ChatProgressBar from "./chatProgressbar/ChatProgressBar"; @@ -122,7 +122,6 @@ export const Chat: React.FC> = ({ const [isLoading, setIsLoading] = React.useState(false); const [showBackdrop, setShowBackdrop] = useState(showInactiveSessionAlert); const [lastActivityTime, setLastActivityTime] = React.useState(Date.now()); - const [newConversationDialog, setNewConversationDialog] = React.useState(false); const [showRefreshConfirmDialog, setShowRefreshConfirmDialog] = React.useState(false); const [exploredExperiencesNotification, setExploredExperiencesNotification] = useState(false); const allowRefreshRef = useRef(false); @@ -727,7 +726,7 @@ export const Chat: React.FC> = ({ try { if (!sessionId) { - sessionId = await issueNewSession(userId); + sessionId = await ensureSessionForUser(userId); if (sessionId) { // Clear the messages if a new session is issued // and add a typing message as the previous one will be removed @@ -735,8 +734,6 @@ export const Chat: React.FC> = ({ // AND clear the current phase setCurrentPhase(defaultCurrentPhase); } else { - // NOTE: We are not logging here because issueNewSession - // already logs the error and returns null in the case return false; } } @@ -835,21 +832,6 @@ export const Chat: React.FC> = ({ setIsDrawerOpen(false); }; - const handleConfirmNewConversation = useCallback(async () => { - setNewConversationDialog(false); - setExploredExperiencesNotification(false); - if (await initializeChat(currentUserId, null)) { - enqueueSnackbar(t("chat.chat.notifications.startConversationSuccess"), { variant: "success" }); - } else { - // Add a message to the chat saying that something went wrong - setMessages([generateSomethingWentWrongMessage()]); - // Set the conversation as completed to prevent the user from sending any messages - setConversationCompleted(true); - // Notify the user that the chat failed to start - enqueueSnackbar(t("chat.chat.notifications.startConversationFailed"), { variant: "error" }); - } - }, [enqueueSnackbar, initializeChat, currentUserId, t]); - /** * --- UseEffects --- */ @@ -1054,7 +1036,7 @@ export const Chat: React.FC> = ({ setNewConversationDialog(true)} + notifyOnLogout={handleLogout} experiencesExplored={exploredExperiencesCount.length} exploredExperiencesNotification={exploredExperiencesNotification} setExploredExperiencesNotification={setExploredExperiencesNotification} @@ -1091,25 +1073,6 @@ export const Chat: React.FC> = ({ conversationConductedAt={conversationConductedAt} onExperiencesUpdated={fetchExperiences} /> - {newConversationDialog && ( - - {t("chat.chat.startNewConversationDialog.content")} -
-
- {t("chat.chat.startNewConversationDialog.confirmation")} - - } - onCancel={() => setNewConversationDialog(false)} - onConfirm={handleConfirmNewConversation} - onDismiss={() => setNewConversationDialog(false)} - cancelButtonText={t("common.buttons.cancel")} - confirmButtonText={t("common.buttons.confirm")} - /> - )} {showRefreshConfirmDialog && ( { ["exploredExperiencesNotification not shown", false], ])("should render the Chat Header with %s", (desc, givenExploredExperiencesNotification) => { // GIVEN a ChatHeader component - const givenStartNewConversation = jest.fn(); + const givenNotifyOnLogout = jest.fn(); const givenNumberOfExploredExperiences = 1; const givenChatHeader = ( { const metricsSpy = jest.spyOn(MetricsService.getInstance(), "sendMetricsEvent").mockReturnValue(); const givenChatHeader = ( { // WHEN the component is rendered const givenChatHeader = ( { const setExploredExperiencesNotification = jest.fn(); const givenChatHeader = ( { async (_description, browserIsOnline) => { mockBrowserIsOnLine(browserIsOnline); // GIVEN a ChatHeader component - const givenStartNewConversation = jest.fn(); const givenChatHeader = ( { // WHEN the component is rendered renderWithChatProvider( { // WHEN the component is rendered renderWithChatProvider( { // WHEN the component is rendered renderWithChatProvider( { // WHEN the component is rendered renderWithChatProvider( void; + notifyOnLogout: () => void; experiencesExplored: number; exploredExperiencesNotification: boolean; setExploredExperiencesNotification: React.Dispatch>; @@ -38,12 +38,11 @@ export const DATA_TEST_ID = { }; export const MENU_ITEM_ID = { - START_NEW_CONVERSATION: `start-new-conversation-${uniqueId}`, VIEW_EXPERIENCES: `view-experiences-${uniqueId}`, }; const ChatHeader: React.FC> = ({ - startNewConversation: _startNewConversation, // kept for when start-conversation menu is re-enabled (see commented code below) + notifyOnLogout, experiencesExplored, exploredExperiencesNotification, setExploredExperiencesNotification, diff --git a/frontend-new/src/chat/issueNewSession.test.ts b/frontend-new/src/chat/ensureSession.test.ts similarity index 53% rename from frontend-new/src/chat/issueNewSession.test.ts rename to frontend-new/src/chat/ensureSession.test.ts index b00f48ab..7d764f55 100644 --- a/frontend-new/src/chat/issueNewSession.test.ts +++ b/frontend-new/src/chat/ensureSession.test.ts @@ -1,7 +1,7 @@ // silence chatty console import "src/_test_utilities/consoleMock"; -import { issueNewSession } from "src/chat/issueNewSession"; +import { ensureSessionForUser } from "src/chat/ensureSession"; import { Language, SensitivePersonalDataRequirement, @@ -11,16 +11,14 @@ import UserPreferencesStateService from "src/userPreferences/UserPreferencesStat import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; import { SessionError } from "src/error/commonErrors"; -describe("issueNewSession", () => { +describe("ensureSessionForUser", () => { beforeEach(() => { jest.clearAllMocks(); }); - it("should return new session id when session is created successfully", async () => { - // GIVEN a user ID + + it("should return session id and update state when preferences are fetched successfully", async () => { const givenUserId = "user123"; - // AND a new session ID const givenNewSessionId = 123; - // AND the user preferences service instance will return a new session for the given user ID and new session ID const givenUserPreferences: UserPreference = { user_id: givenUserId, language: Language.en, @@ -32,26 +30,18 @@ describe("issueNewSession", () => { experiments: {}, }; const givenUserPreferencesServiceInstance = UserPreferencesService.getInstance(); - jest.spyOn(givenUserPreferencesServiceInstance, "getNewSession").mockResolvedValueOnce(givenUserPreferences); + jest.spyOn(givenUserPreferencesServiceInstance, "getUserPreferences").mockResolvedValueOnce(givenUserPreferences); - // WHEN the function is called - const actualNewSessionId = await issueNewSession(givenUserId); + const actualSessionId = await ensureSessionForUser(givenUserId); - // THEN expect the user preferences service instance to have been called with the given user ID - expect(givenUserPreferencesServiceInstance.getNewSession).toHaveBeenCalledWith(givenUserId); - // AND the new session ID is returned - expect(actualNewSessionId).toBe(givenNewSessionId); - // AND the UserPreferencesStateService is queried for the active session ID + expect(givenUserPreferencesServiceInstance.getUserPreferences).toHaveBeenCalledWith(givenUserId); + expect(actualSessionId).toBe(givenNewSessionId); expect(UserPreferencesStateService.getInstance().getUserPreferences()).toEqual(givenUserPreferences); - - // AND no errors are logged expect(console.error).not.toHaveBeenCalled(); - // AND no warnings are logged expect(console.warn).not.toHaveBeenCalled(); }); - it("should return null and log error when session creation fails", async () => { - // GIVEN some user preferences + it("should return null and log error when fetch fails", async () => { const givenUserPreferences: UserPreference = { user_id: "foo", language: Language.en, @@ -62,27 +52,18 @@ describe("issueNewSession", () => { has_sensitive_personal_data: false, experiments: {}, }; - // AND the user preferences state service has the given user preferences const givenUserPrefStateServiceInstance = UserPreferencesStateService.getInstance(); givenUserPrefStateServiceInstance.setUserPreferences(givenUserPreferences); - // AND some user ID const givenUserId = "bar"; - // AND the user preferences service instance will throw an error when creating a new session for the given user ID - const givenError = new Error("Failed to create new session"); + const givenError = new Error("Failed to fetch preferences"); const givenUserPreferencesServiceInstance = UserPreferencesService.getInstance(); - jest.spyOn(givenUserPreferencesServiceInstance, "getNewSession").mockRejectedValueOnce(givenError); - - // WHEN the function is called - const actualNewSessionId = await issueNewSession(givenUserId); - - // THEN null is returned - expect(actualNewSessionId).toBeNull(); + jest.spyOn(givenUserPreferencesServiceInstance, "getUserPreferences").mockRejectedValueOnce(givenError); - // AND the error is logged - expect(console.error).toHaveBeenCalledWith(new SessionError("Failed to create new session", givenError)); + const actualSessionId = await ensureSessionForUser(givenUserId); - // AND the UserPreferencesStateService remains unchanged + expect(actualSessionId).toBeNull(); + expect(console.error).toHaveBeenCalledWith(new SessionError("Failed to ensure session for user", givenError)); expect(UserPreferencesStateService.getInstance().getUserPreferences()).toEqual(givenUserPreferences); }); }); diff --git a/frontend-new/src/chat/ensureSession.ts b/frontend-new/src/chat/ensureSession.ts new file mode 100644 index 00000000..a1448e91 --- /dev/null +++ b/frontend-new/src/chat/ensureSession.ts @@ -0,0 +1,20 @@ +import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; +import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; +import { SessionError } from "src/error/commonErrors"; + +/** + * Ensures the user has a session. Fetches user preferences (backend creates a session if missing), + * updates state, and returns the active session id. + * @param user_id + * @returns The session id, or null if fetch failed + */ +export const ensureSessionForUser = async (user_id: string): Promise => { + try { + const user_preferences = await UserPreferencesService.getInstance().getUserPreferences(user_id); + UserPreferencesStateService.getInstance().setUserPreferences(user_preferences); + return UserPreferencesStateService.getInstance().getActiveSessionId(); + } catch (e) { + console.error(new SessionError("Failed to ensure session for user", e)); + } + return null; +}; diff --git a/frontend-new/src/chat/issueNewSession.ts b/frontend-new/src/chat/issueNewSession.ts deleted file mode 100644 index cb92ac41..00000000 --- a/frontend-new/src/chat/issueNewSession.ts +++ /dev/null @@ -1,22 +0,0 @@ -import UserPreferencesService from "src/userPreferences/UserPreferencesService/userPreferences.service"; -import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; -import { SessionError } from "src/error/commonErrors"; - -/** - * Issue a new session for the user and set the user preferences in the user preferences state service. - * @param user_id - * @returns {Promise} The session id of the new session, or null if the session could not be created - */ -export const issueNewSession = async (user_id: string): Promise => { - try { - // If there is no session id, then create a new session - let user_preferences = await UserPreferencesService.getInstance().getNewSession(user_id); - // Set the retrieved user preferences in the user preferences state service - const userPreferencesStateService = UserPreferencesStateService.getInstance(); - userPreferencesStateService.setUserPreferences(user_preferences); - return userPreferencesStateService.getActiveSessionId(); - } catch (e) { - console.error(new SessionError("Failed to create new session", e)); - } - return null; -}; diff --git a/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.ts b/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.ts index 6f23c6e9..8529bc4e 100644 --- a/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.ts +++ b/frontend-new/src/userPreferences/UserPreferencesService/userPreferences.service.ts @@ -185,32 +185,6 @@ export default class UserPreferencesService { return userPreferences; } - /** - * Get a new session ID from the chat service. - * @returns {Promise} The user preferences object - * @throws {RestAPIError} If the user preferences are invalid - */ - async getNewSession(userId: string): Promise { - const serviceName = UserPreferencesService.serviceName; - const serviceFunction = "getNewSession"; - const method = "GET"; - const qualifiedURL = `${this.userPreferencesEndpointUrl}/new-session?user_id=${userId}`; - - const errorFactory = getRestAPIErrorFactory("UserPreferencesService", "getNewSession", method, qualifiedURL); - - const response = await customFetch(qualifiedURL, { - method: method, - headers: { "Content-Type": "application/json" }, - expectedStatusCode: StatusCodes.CREATED, - serviceName, - serviceFunction, - failureMessage: `Failed to generate new session`, - expectedContentType: "application/json", - }); - - return await this.parseJsonResponse(response, userId, errorFactory); - } - /** * Gets the client ID. If it doesn't exist, it will be created. * From 33b7689f4bdcb9237a957eb97460b6946b6b41ff Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Tue, 3 Mar 2026 10:44:11 +0300 Subject: [PATCH 11/42] feat(backend): remove paid work from job types (paid employment and self-employed) --- .../_conversation_llm.py | 28 +-- .../collect_experiences_agent.py | 6 +- .../_temporal_classifier_tool.py | 3 +- .../test_operations_processor.py | 10 +- backend/app/agent/experience/work_type.py | 30 +-- .../infer_occupation_tool.py | 5 - .../test_adaptive_divergence.py | 2 +- .../test_fim_stopping_regression.py | 2 +- .../test_hybrid_full_flow.py | 4 +- .../test_preference_elicitation_agent.py | 26 +-- .../test_real_llm_vignette_flow.py | 2 +- .../skill_explorer_agent/_conversation_llm.py | 12 +- .../app/conversations/experience/_types.py | 2 +- .../conversations/experience/test_routes.py | 6 +- .../conversations/experience/test_service.py | 8 +- backend/app/conversations/poc/poc_routes.py | 2 +- backend/app/conversations/test_utils.py | 10 +- backend/app/i18n/locales/en-GB/messages.json | 3 +- backend/app/i18n/locales/en-US/messages.json | 3 +- backend/app/i18n/locales/es-AR/messages.json | 3 +- backend/app/i18n/locales/es-ES/messages.json | 3 +- backend/app/i18n/locales/sw-KE/messages.json | 3 +- .../test_recorder.py | 2 +- backend/app/metrics/test_types.py | 6 +- .../database_application_state_store_test.py | 4 +- .../_data_extraction_llm_es_test.py | 8 +- .../_data_extraction_llm_test.py | 76 ++++---- .../collect_experiences_test_cases.py | 184 +++++++++--------- .../temporal_classifier_test.py | 6 +- .../transition_decision_tool_test.py | 84 ++++---- .../evaluation_tests/core_e2e_tests_cases.py | 66 +++---- backend/evaluation_tests/golden_test_cases.py | 98 ++++------ .../experience_pipeline_test.py | 10 +- .../_contextualization_llm_test.py | 14 +- .../generate_jsonl_testcases.py | 2 +- .../infer_occupation_tool_test_cases.jsonl | 10 +- .../test_occupation_inference_test_case.py | 18 +- .../skill_linking/skills_linking_tool_test.py | 6 +- .../_conversation_llm_test.py | 6 +- .../skills_explorer_test_cases.py | 6 +- .../sample_conversation.json | 6 +- .../export_conversation/test_roundtrip.py | 36 +--- .../scripts/populate_sample_conversation.py | 10 +- .../test_preference_agent_interactive.py | 6 +- frontend-new/src/chat/Chat.test.tsx | 2 +- .../src/chat/ChatHeader/ChatHeader.test.tsx | 1 + .../mockExperiencesResponses.ts | 9 +- .../experienceService/experiences.types.ts | 2 - .../experiencesDrawer/ExperiencesDrawer.tsx | 18 -- .../ExperiencesDrawer.test.tsx.snap | 6 +- .../ExperienceEditForm.test.tsx | 19 +- .../ExperienceEditForm.test.tsx.snap | 16 +- .../experiencesDrawer/util.test.tsx | 18 +- .../experiences/experiencesDrawer/util.tsx | 20 -- .../components/ConstructExperienceList.tsx | 26 +-- .../report/reportPdf/SkillReportPDF.tsx | 21 +- .../SkillReportPDF.test.tsx.snap | 2 +- frontend-new/src/experiences/report/util.ts | 10 - 58 files changed, 397 insertions(+), 610 deletions(-) diff --git a/backend/app/agent/collect_experiences_agent/_conversation_llm.py b/backend/app/agent/collect_experiences_agent/_conversation_llm.py index 6b520c3a..62d7f43c 100644 --- a/backend/app/agent/collect_experiences_agent/_conversation_llm.py +++ b/backend/app/agent/collect_experiences_agent/_conversation_llm.py @@ -594,12 +594,8 @@ def _get_not_missing_fields(collected_data: list[CollectedData], index: int) -> def _get_experience_type(work_type: WorkType | None) -> str: - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return t("messages", "collectExperiences.workType.formalWagedDescription") - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: return t("messages", "collectExperiences.workType.unpaidTraineeDescription") - elif work_type == WorkType.SELF_EMPLOYMENT: - return t("messages", "collectExperiences.workType.selfEmploymentDescription") elif work_type == WorkType.UNSEEN_UNPAID: return t("messages", "collectExperiences.workType.unseenUnpaidDescription") elif work_type is None: @@ -614,18 +610,10 @@ def _get_experience_types(work_type: list[WorkType]) -> str: def _get_excluding_experiences(work_type: WorkType) -> str: excluding_experience_types: list[WorkType] = [] - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - excluding_experience_types = [WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID] - # return "unpaid trainee work, self-employment, or unpaid work such as community volunteering work etc." - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: - excluding_experience_types = [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID] - # return "wage employment, self-employment, or unpaid work such as community volunteering work etc." - elif work_type == WorkType.SELF_EMPLOYMENT: - excluding_experience_types = [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID] - # return "wage employment, unpaid trainee work, or unpaid work such as community volunteering work etc." + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + excluding_experience_types = [WorkType.UNSEEN_UNPAID] elif work_type == WorkType.UNSEEN_UNPAID: - excluding_experience_types = [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.SELF_EMPLOYMENT] - # return "wage employment, unpaid trainee work, or self-employment" + excluding_experience_types = [WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK] else: raise ValueError("The work type is not supported") @@ -634,12 +622,8 @@ def _get_excluding_experiences(work_type: WorkType) -> str: def _ask_experience_type_question(work_type: WorkType) -> str: question_to_ask: str - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - question_to_ask = "Have I been employed in a company or someone else's business for money?" - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: question_to_ask = "Have I worked as an unpaid trainee for a company or organization?" - elif work_type == WorkType.SELF_EMPLOYMENT: - question_to_ask = "Have I run my own business or done freelance or contract work?" elif work_type == WorkType.UNSEEN_UNPAID: question_to_ask = "Have I done unpaid work such as community volunteering, caregiving for my own or another family, or helping in a household?" else: @@ -694,7 +678,7 @@ def _get_explore_experiences_instructions(*, IMPORTANT: Do NOT provide a recap or summary of all experiences until we have explored ALL work types. Do NOT say things like "We've now gone through the different types" or "Here's what we have so far" - until we have finished exploring all four work types (paid employment, self-employment, unpaid trainee work, and unpaid work). + until we have finished exploring all work types (unpaid trainee work and unpaid work such as volunteering). """) return replace_placeholders_with_indent(instructions_template, questions_to_ask=questions_to_ask, diff --git a/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py b/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py index c59bc44a..1b9225d2 100644 --- a/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py +++ b/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py @@ -83,9 +83,7 @@ class CollectExperiencesAgentState(BaseModel): The data collected during the conversation. """ - unexplored_types: list[WorkType] = Field(default_factory=lambda: [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types: list[WorkType] = Field(default_factory=lambda: [WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID]) """ The types of work experiences that have not been explored yet. @@ -336,7 +334,7 @@ async def execute(self, user_input: AgentInput, "\n - all work types explored: %s" "\n - discovered experiences: %s" "\n - reasoning: %s", - len(self._state.explored_types) == 4, + len(self._state.explored_types) == 2, self._state.collected_data, reasoning_text ) diff --git a/backend/app/agent/collect_experiences_agent/data_extraction_llm/_temporal_classifier_tool.py b/backend/app/agent/collect_experiences_agent/data_extraction_llm/_temporal_classifier_tool.py index 1065ff3b..346c47ae 100644 --- a/backend/app/agent/collect_experiences_agent/data_extraction_llm/_temporal_classifier_tool.py +++ b/backend/app/agent/collect_experiences_agent/data_extraction_llm/_temporal_classifier_tool.py @@ -289,8 +289,7 @@ async def _internal_execute(self, - work_type_classification_reasoning: A detailed, step-by-step explanation of how the information collected until now, is evaluated based on the instructions of 'work_type', to classify the type of work of the experience. Formatted as a JSON string. - - work_type: type of work of the experience, 'FORMAL_SECTOR_WAGED_EMPLOYMENT', - 'FORMAL_SECTOR_UNPAID_TRAINEE_WORK', 'SELF_EMPLOYMENT', 'UNSEEN_UNPAID' or 'None'. + - work_type: type of work of the experience, 'FORMAL_SECTOR_UNPAID_TRAINEE_WORK', 'UNSEEN_UNPAID' or 'None'. Other values are not permitted. - dates_mentioned: The experience dates mentioned in the conversation. Empty string "" If you could not find any. diff --git a/backend/app/agent/collect_experiences_agent/data_extraction_llm/test_operations_processor.py b/backend/app/agent/collect_experiences_agent/data_extraction_llm/test_operations_processor.py index e5d77947..5383f4ae 100644 --- a/backend/app/agent/collect_experiences_agent/data_extraction_llm/test_operations_processor.py +++ b/backend/app/agent/collect_experiences_agent/data_extraction_llm/test_operations_processor.py @@ -33,7 +33,7 @@ def _create_collected_data( start_date=start_date, end_date=end_date, paid_work=paid_work, - work_type=work_type or WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=work_type or WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) @@ -59,7 +59,7 @@ def create_experience_data( start_date=start_date, end_date=end_date, paid_work=paid_work, - work_type=work_type or WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + work_type=work_type or WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, data_operation=data_operation, data_extraction_references="", dates_mentioned="", @@ -100,7 +100,7 @@ def test_add_single_new_experience(self, processor, mock_logger): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ] @@ -127,7 +127,7 @@ def test_add_single_new_experience(self, processor, mock_logger): assert experience.start_date == "2020" assert experience.end_date == "2022" assert experience.paid_work is True - assert experience.work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + assert experience.work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name assert experience.defined_at_turn_number == 2 # AND should log the addition @@ -153,7 +153,7 @@ def test_add_multiple_new_experiences(self, processor, mock_logger): experience_title="Freelance Designer", company="Self", location="Johannesburg", - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), create_experience_data( data_operation="ADD", diff --git a/backend/app/agent/experience/work_type.py b/backend/app/agent/experience/work_type.py index 47d1bb6c..dedd1c02 100644 --- a/backend/app/agent/experience/work_type.py +++ b/backend/app/agent/experience/work_type.py @@ -10,18 +10,14 @@ class WorkType(Enum): See https://docs.tabiya.org/overview/projects/inclusive-livelihoods-taxonomy/methodology for more information. - 1. Formal sector/Wage employment (link to vanilla esco) - 2. Formal sector/Unpaid trainee work (link to vanilla esco) - 3. Self-employment (link to vanilla esco + micro entrepreneurship) - 4. Unseen (link to esco + unseen) + 1. Formal sector/Unpaid trainee work (link to vanilla esco) + 2. Unseen (link to esco + unseen) a. Unpaid domestic services for household and family members (Div 3) b. Unpaid caregiving services for household and family members (Div 4) c. Unpaid direct volunteering for other households (Div 51) d. Unpaid community- and organization-based volunteering (Div 52) """ - FORMAL_SECTOR_WAGED_EMPLOYMENT = "Formal sector/Wage employment" FORMAL_SECTOR_UNPAID_TRAINEE_WORK = "Formal sector/Unpaid trainee work" - SELF_EMPLOYMENT = "Self-employment" UNSEEN_UNPAID = "Unpaid other" # All unseen work is grouped under this category @staticmethod @@ -32,12 +28,8 @@ def from_string_key(key: Optional[str]) -> Optional[WorkType]: @staticmethod def work_type_short(work_type: WorkType) -> str: - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return "Wage Employment" - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: return "Trainee" - elif work_type == WorkType.SELF_EMPLOYMENT: - return "Self-Employed" elif work_type == WorkType.UNSEEN_UNPAID: return "Volunteer/Unpaid" else: @@ -45,12 +37,8 @@ def work_type_short(work_type: WorkType) -> str: @staticmethod def work_type_short_i18n_key(work_type: WorkType) -> str: - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return "experience.workType.short.formalSectorWagedEmployment" - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: return "experience.workType.short.formalSectorUnpaidTraineeWork" - elif work_type == WorkType.SELF_EMPLOYMENT: - return "experience.workType.short.selfEmployment" elif work_type == WorkType.UNSEEN_UNPAID: return "experience.workType.short.unseenUnpaid" else: @@ -58,12 +46,8 @@ def work_type_short_i18n_key(work_type: WorkType) -> str: @staticmethod def work_type_long(work_type: WorkType | None) -> str: - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return "Waged work or paid work as an employee. Working for someone else, for a company or an organization, in exchange for a salary or wage." - elif work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: return "Unpaid Trainee Work." - elif work_type == WorkType.SELF_EMPLOYMENT: - return "Self-employment, micro entrepreneurship, contract based work, freelancing, running own business, paid work but not work as an employee." elif work_type == WorkType.UNSEEN_UNPAID: return dedent("""\ Represents all unpaid work, including: @@ -81,9 +65,7 @@ def work_type_long(work_type: WorkType | None) -> str: WORK_TYPE_DEFINITIONS_FOR_PROMPT = dedent(f"""\ -- None: {WorkType.work_type_long(None)} -- {WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name}:{WorkType.work_type_long(WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT)} +- None: {WorkType.work_type_long(None)} - {WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name}: {WorkType.work_type_long(WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK)} -- {WorkType.SELF_EMPLOYMENT.name}: {WorkType.work_type_long(WorkType.SELF_EMPLOYMENT)} - {WorkType.UNSEEN_UNPAID.name}: {WorkType.work_type_long(WorkType.UNSEEN_UNPAID)} """) diff --git a/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool.py b/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool.py index d7350995..defa92a5 100644 --- a/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool.py +++ b/backend/app/agent/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool.py @@ -128,11 +128,6 @@ async def execute(self, *, for title in titles] tasks.extend(unseen_tasks) - if work_type == WorkType.SELF_EMPLOYMENT or work_type is None: - # since there is only one taxonomy domain for self-employment, it is not necessary to search, instead can retrieve the occupations directly - self_employment_tasks = [self._occupation_skill_search_service.get_by_esco_code(code="5221_2")] - tasks.extend(self_employment_tasks) - # Use return_exceptions=True to handle individual task failures gracefully list_of_occupation_list = await asyncio.gather(*tasks, return_exceptions=True) diff --git a/backend/app/agent/preference_elicitation_agent/test_adaptive_divergence.py b/backend/app/agent/preference_elicitation_agent/test_adaptive_divergence.py index 3021006e..02667e3e 100644 --- a/backend/app/agent/preference_elicitation_agent/test_adaptive_divergence.py +++ b/backend/app/agent/preference_elicitation_agent/test_adaptive_divergence.py @@ -30,7 +30,7 @@ def create_experiences(): experience_title="Contract Software Developer", company="Tabiya Organization", timeline=Timeline(start="11/2025", end="Present"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] diff --git a/backend/app/agent/preference_elicitation_agent/test_fim_stopping_regression.py b/backend/app/agent/preference_elicitation_agent/test_fim_stopping_regression.py index ad5cf329..c6840f77 100644 --- a/backend/app/agent/preference_elicitation_agent/test_fim_stopping_regression.py +++ b/backend/app/agent/preference_elicitation_agent/test_fim_stopping_regression.py @@ -401,7 +401,7 @@ async def test_agent_adaptive_phase_not_skipped(): experience_title="Contract Software Developer", company="Tabiya Organization", timeline=Timeline(start="11/2025", end="Present"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ], use_db6_for_fresh_data=False, diff --git a/backend/app/agent/preference_elicitation_agent/test_hybrid_full_flow.py b/backend/app/agent/preference_elicitation_agent/test_hybrid_full_flow.py index b78ec1a6..cb8d3f82 100644 --- a/backend/app/agent/preference_elicitation_agent/test_hybrid_full_flow.py +++ b/backend/app/agent/preference_elicitation_agent/test_hybrid_full_flow.py @@ -33,7 +33,7 @@ def create_sample_experiences(): company="Alliance High School", location="Kikuyu", timeline=Timeline(start="2018", end="2023"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), ExperienceEntity( uuid="exp-2", @@ -41,7 +41,7 @@ def create_sample_experiences(): company="Self-employed", location="Nairobi", timeline=Timeline(start="2023", end="Present"), - work_type=WorkType.SELF_EMPLOYMENT + work_type=WorkType.UNSEEN_UNPAID ) ] diff --git a/backend/app/agent/preference_elicitation_agent/test_preference_elicitation_agent.py b/backend/app/agent/preference_elicitation_agent/test_preference_elicitation_agent.py index 7af31d33..867acb53 100644 --- a/backend/app/agent/preference_elicitation_agent/test_preference_elicitation_agent.py +++ b/backend/app/agent/preference_elicitation_agent/test_preference_elicitation_agent.py @@ -248,7 +248,7 @@ def sample_experiences(self): company="TechCorp", location="Nairobi", timeline=Timeline(start="2020", end="2022"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), ExperienceEntity( uuid="exp-2", @@ -256,7 +256,7 @@ def sample_experiences(self): company="Self", location="Mombasa", timeline=Timeline(start="2022", end="2023"), - work_type=WorkType.SELF_EMPLOYMENT + work_type=WorkType.UNSEEN_UNPAID ) ] @@ -375,7 +375,7 @@ async def test_get_experiences_from_snapshot_when_db6_disabled(self): ExperienceEntity( uuid="exp-1", experience_title="Developer", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] state = PreferenceElicitationAgentState( @@ -404,7 +404,7 @@ async def test_get_experiences_from_db6_when_enabled(self): ExperienceEntity( uuid="exp-db6", experience_title="DB6 Experience", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] profile = YouthProfile( @@ -420,7 +420,7 @@ async def test_get_experiences_from_db6_when_enabled(self): ExperienceEntity( uuid="exp-snapshot", experience_title="Snapshot Experience", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] state = PreferenceElicitationAgentState( @@ -458,7 +458,7 @@ async def test_fallback_to_snapshot_when_db6_empty(self): ExperienceEntity( uuid="exp-snapshot", experience_title="Snapshot Experience", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] state = PreferenceElicitationAgentState( @@ -488,7 +488,7 @@ async def test_fallback_to_snapshot_when_db6_profile_not_found(self): ExperienceEntity( uuid="exp-snapshot", experience_title="Snapshot Experience", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] state = PreferenceElicitationAgentState( @@ -528,7 +528,7 @@ async def delete_youth_profile(self, youth_id): ExperienceEntity( uuid="exp-snapshot", experience_title="Snapshot Experience", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] state = PreferenceElicitationAgentState( @@ -679,7 +679,7 @@ def test_state_creation_with_db6_fields(self): ExperienceEntity( uuid="exp-1", experience_title="Developer", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] @@ -713,7 +713,7 @@ def test_state_from_document_with_db6_fields(self): { "uuid": "exp-1", "experience_title": "Developer", - "work_type": "FORMAL_SECTOR_WAGED_EMPLOYMENT" + "work_type": "FORMAL_SECTOR_UNPAID_TRAINEE_WORK" } ], "use_db6_for_fresh_data": True @@ -764,7 +764,7 @@ def mock_experiences(self): start="2022-06", end="2023-12" ), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, responsibilities=ResponsibilitiesData( responsibilities=["Taking orders", "Operating cash register"] ), @@ -782,7 +782,7 @@ def mock_experiences(self): start="2021-03", end="2022-05" ), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, responsibilities=ResponsibilitiesData( responsibilities=["Customer service", "Inventory management"] ), @@ -909,7 +909,7 @@ def sample_experiences(self): company="TechCorp Kenya", location="Nairobi", timeline=Timeline(start="2022-01", end="2024-11"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] diff --git a/backend/app/agent/preference_elicitation_agent/test_real_llm_vignette_flow.py b/backend/app/agent/preference_elicitation_agent/test_real_llm_vignette_flow.py index 7720ebf2..3c380b20 100644 --- a/backend/app/agent/preference_elicitation_agent/test_real_llm_vignette_flow.py +++ b/backend/app/agent/preference_elicitation_agent/test_real_llm_vignette_flow.py @@ -42,7 +42,7 @@ def create_test_experiences(): experience_title="Contract Software Developer", company="Tabiya Organization", timeline=Timeline(start="11/2025", end="Present"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] diff --git a/backend/app/agent/skill_explorer_agent/_conversation_llm.py b/backend/app/agent/skill_explorer_agent/_conversation_llm.py index ad140fa0..983a9dcc 100644 --- a/backend/app/agent/skill_explorer_agent/_conversation_llm.py +++ b/backend/app/agent/skill_explorer_agent/_conversation_llm.py @@ -368,14 +368,8 @@ def _get_country_of_user_segment(country_of_user: Country) -> str: def _get_question_c(work_type: WorkType) -> str: - """ - Get the question for the specific work type - """ - if work_type == WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return t("messages", "exploreSkills.question.formalWaged") - elif work_type == WorkType.SELF_EMPLOYMENT: - return t("messages", "exploreSkills.question.selfEmployment") + if work_type == WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: + return t("messages", "exploreSkills.question.unpaidTrainee") elif work_type == WorkType.UNSEEN_UNPAID: return t("messages", "exploreSkills.question.unseenUnpaid") - else: - return "" + return "" diff --git a/backend/app/conversations/experience/_types.py b/backend/app/conversations/experience/_types.py index a6fd3399..f3f1dc5d 100644 --- a/backend/app/conversations/experience/_types.py +++ b/backend/app/conversations/experience/_types.py @@ -261,7 +261,7 @@ class UpdateExperienceRequest(BaseModel): description="The type of work. " "If omitted, not updated. If null, cleared. " "If an invalid value is provided, null will be saved.", - examples=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name], + examples=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name], max_length=max(len(e.name) for e in WorkType), json_schema_extra={"enum": [e.name for e in WorkType] + [None]} ) diff --git a/backend/app/conversations/experience/test_routes.py b/backend/app/conversations/experience/test_routes.py index b9baec23..c7c203ac 100644 --- a/backend/app/conversations/experience/test_routes.py +++ b/backend/app/conversations/experience/test_routes.py @@ -176,7 +176,7 @@ async def test_get_experiences_successful(self, authenticated_client_with_mocks: company="Foo Company", location="Foo Location", timeline=Timeline(start="2020-01-01", end="2021-01-01"), - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, top_skills=[ SkillEntity( id="", @@ -361,7 +361,7 @@ async def test_update_experience_successful(self, authenticated_client_with_mock company="company", location="location", timeline=Timeline(start="2020", end="2021"), - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, top_skills=[], remaining_skills=[], summary="summary", @@ -609,7 +609,7 @@ async def test_get_unedited_experience_successful(self, authenticated_client_wit company="Unedited Company", location="Unedited Location", timeline=Timeline(start="2020", end="2021"), - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, top_skills=[], summary="Unedited summary", ), DiveInPhase.PROCESSED) diff --git a/backend/app/conversations/experience/test_service.py b/backend/app/conversations/experience/test_service.py index 74b772c7..50f219ce 100644 --- a/backend/app/conversations/experience/test_service.py +++ b/backend/app/conversations/experience/test_service.py @@ -74,7 +74,7 @@ def _make_editable_experience_entity(uuid: str, title: str, skills=None) -> Edit company="fooCorp", location="fooVille", timeline=Timeline(start="2020", end="2021"), - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, esco_occupations=[], questions_and_answers=[], summary="fooSummary", @@ -90,7 +90,7 @@ def _make_experience_entity(uuid: str, title: str, skills=None) -> ExperienceEnt company="fooCorp", location="fooVille", timeline=Timeline(start="2020", end="2021"), - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, esco_occupations=[], questions_and_answers=[], summary="fooSummary", @@ -238,8 +238,8 @@ async def test_success(self, mock_metrics_recorder, mock_metrics_service): id="company_and_location" ), pytest.param( - UpdateExperienceRequest(work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, summary="New Sum"), - {"work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, "summary": "New Sum"}, + UpdateExperienceRequest(work_type=WorkType.UNSEEN_UNPAID.name, summary="New Sum"), + {"work_type": WorkType.UNSEEN_UNPAID, "summary": "New Sum"}, id="work_type_and_summary" ), pytest.param( diff --git a/backend/app/conversations/poc/poc_routes.py b/backend/app/conversations/poc/poc_routes.py index f9d63f1a..9748ccf7 100644 --- a/backend/app/conversations/poc/poc_routes.py +++ b/backend/app/conversations/poc/poc_routes.py @@ -312,7 +312,7 @@ async def _test_conversation(request: Request, user_input: str, clear_memory: bo if len(state.explore_experiences_director_state.experiences_state) == 0: experience_entity = ExperienceEntity(experience_title="Baker", company="Baker's and Sons", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT) + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK) from app.agent.explore_experiences_agent_director import ExperienceState from app.agent.explore_experiences_agent_director import DiveInPhase state.explore_experiences_director_state.current_experience_uuid = experience_entity.uuid diff --git a/backend/app/conversations/test_utils.py b/backend/app/conversations/test_utils.py index dc8a053c..7f25aecc 100644 --- a/backend/app/conversations/test_utils.py +++ b/backend/app/conversations/test_utils.py @@ -16,10 +16,8 @@ logger = logging.getLogger(__name__) all_work_types = [ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, - WorkType.UNSEEN_UNPAID + WorkType.UNSEEN_UNPAID, ] @@ -65,10 +63,8 @@ def test_completed_conversation(self): @pytest.mark.parametrize("explored_work_types, expected_percentage", [ (0, COLLECT_EXPERIENCES_PERCENTAGE), - (1, COLLECT_EXPERIENCES_PERCENTAGE + 9), # (1/4) * (40 - 5) - (2, COLLECT_EXPERIENCES_PERCENTAGE + 17), # (2/4) * (40 - 5) - (3, COLLECT_EXPERIENCES_PERCENTAGE + 26), # (3/4) * (40 - 5) - (4, DIVE_IN_EXPERIENCES_PERCENTAGE) + (1, 22), # round((1/2) * (40 - 5) + 5) + (2, DIVE_IN_EXPERIENCES_PERCENTAGE), ]) def test_n_explored_work_types(self, explored_work_types: int, expected_percentage: int): # GIVEN a random session id diff --git a/backend/app/i18n/locales/en-GB/messages.json b/backend/app/i18n/locales/en-GB/messages.json index f101285b..6206aee7 100644 --- a/backend/app/i18n/locales/en-GB/messages.json +++ b/backend/app/i18n/locales/en-GB/messages.json @@ -58,8 +58,7 @@ "exploreSkills": { "finalMessage": "Thank you for sharing these details! I have all the information I need.", "question": { - "formalWaged": "What do you think is important when working in such a company?", - "selfEmployment": "What do you think is important when you are your own boss?", + "unpaidTrainee": "What do you think is important when working as an unpaid trainee?", "unseenUnpaid": "What do you think is most important when helping out in the community or caring for others?" } } diff --git a/backend/app/i18n/locales/en-US/messages.json b/backend/app/i18n/locales/en-US/messages.json index b8c46d3f..63f8c887 100644 --- a/backend/app/i18n/locales/en-US/messages.json +++ b/backend/app/i18n/locales/en-US/messages.json @@ -58,8 +58,7 @@ "exploreSkills": { "finalMessage": "Thank you for sharing these details! I have all the information I need.", "question": { - "formalWaged": "What do you think is important when working in such a company?", - "selfEmployment": "What do you think is important when you are your own boss?", + "unpaidTrainee": "What do you think is important when working as an unpaid trainee?", "unseenUnpaid": "What do you think is most important when helping out in the community or caring for others?" } } diff --git a/backend/app/i18n/locales/es-AR/messages.json b/backend/app/i18n/locales/es-AR/messages.json index 2acd6b8f..53e132ea 100644 --- a/backend/app/i18n/locales/es-AR/messages.json +++ b/backend/app/i18n/locales/es-AR/messages.json @@ -58,8 +58,7 @@ "exploreSkills": { "finalMessage": "¡Gracias por compartir estos detalles! Tengo toda la información que necesito.", "question": { - "formalWaged": "¿Qué pensás que es importante al trabajar en una empresa así?", - "selfEmployment": "¿Qué pensás que es importante cuando sos tu propio jefe?", + "unpaidTrainee": "¿Qué pensás que es importante al trabajar como aprendiz no remunerado?", "unseenUnpaid": "¿Qué pensás que es más importante al ayudar en la comunidad o cuidar a otras personas?" } } diff --git a/backend/app/i18n/locales/es-ES/messages.json b/backend/app/i18n/locales/es-ES/messages.json index 335d3665..6d746f15 100644 --- a/backend/app/i18n/locales/es-ES/messages.json +++ b/backend/app/i18n/locales/es-ES/messages.json @@ -58,8 +58,7 @@ "exploreSkills": { "finalMessage": "¡Gracias por compartir estos detalles! Tengo toda la información que necesito.", "question": { - "formalWaged": "¿Qué creés que es importante al trabajar en una empresa así?", - "selfEmployment": "¿Qué creés que es importante cuando sos tu propio jefe?", + "unpaidTrainee": "¿Qué creés que es importante al trabajar como aprendiz no remunerado?", "unseenUnpaid": "¿Qué creés que es más importante al ayudar en la comunidad o cuidar a otras personas?" } } diff --git a/backend/app/i18n/locales/sw-KE/messages.json b/backend/app/i18n/locales/sw-KE/messages.json index ac4a0f18..16964641 100644 --- a/backend/app/i18n/locales/sw-KE/messages.json +++ b/backend/app/i18n/locales/sw-KE/messages.json @@ -58,8 +58,7 @@ "exploreSkills": { "finalMessage": "Asante kwa kushiriki maelezo haya! Nina taarifa zote ninazohitaji.", "question": { - "formalWaged": "Unafikiri ni nini muhimu unapofanya kazi katika kampuni kama hiyo?", - "selfEmployment": "Unafikiri ni nini muhimu unapokuwa bosi wako mwenyewe?", + "unpaidTrainee": "Unafikiri ni nini muhimu unapofanya kazi kama mfunzwa asiyelipwa?", "unseenUnpaid": "Unafikiri ni nini muhimu zaidi unaposaidia jamii au kutunza wengine?" } } diff --git a/backend/app/metrics/application_state_metrics_recorder/test_recorder.py b/backend/app/metrics/application_state_metrics_recorder/test_recorder.py index 648d09fb..4333b88a 100644 --- a/backend/app/metrics/application_state_metrics_recorder/test_recorder.py +++ b/backend/app/metrics/application_state_metrics_recorder/test_recorder.py @@ -196,7 +196,7 @@ async def test_multiple_changes_recorded_together( # Add a new experience is discovered collected_data = CollectedData( experience_title=get_random_printable_string(10), - work_type=WorkType.SELF_EMPLOYMENT.name, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, company=None, location=None, start_date=None, diff --git a/backend/app/metrics/test_types.py b/backend/app/metrics/test_types.py index ab4e436f..6652facf 100644 --- a/backend/app/metrics/test_types.py +++ b/backend/app/metrics/test_types.py @@ -593,7 +593,7 @@ def test_fields_are_set_correctly(self, action, setup_application_config: Applic # GIVEN a session id and user id given_session_id = get_random_session_id() given_user_id = get_random_user_id() - given_work_type = WorkType.SELF_EMPLOYMENT.name + given_work_type = WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name given_edited_fields = ["foo", "bar"] # WHEN creating an instance of the event @@ -630,7 +630,7 @@ def test_extra_fields_are_not_allowed(self, action, setup_application_config: Ap # GIVEN all required fields given_user_id = get_random_user_id() given_session_id = get_random_session_id() - given_work_type = WorkType.SELF_EMPLOYMENT.name + given_work_type = WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name given_edited_fields = ["foo", "bar"] # AND an extra field @@ -661,7 +661,7 @@ def test_fields_are_set_correctly(self, action, setup_application_config: Applic given_session_id = get_random_session_id() given_user_id = get_random_user_id() given_uuids = [get_random_printable_string(8) for _ in range(3)] - given_work_type = WorkType.SELF_EMPLOYMENT.name + given_work_type = WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name # WHEN creating an instance of the event actual_event = SkillChangedEvent( diff --git a/backend/app/store/database_application_state_store_test.py b/backend/app/store/database_application_state_store_test.py index cd1e9044..1cbb55f9 100644 --- a/backend/app/store/database_application_state_store_test.py +++ b/backend/app/store/database_application_state_store_test.py @@ -220,8 +220,8 @@ def generate_collected_data(index) -> CollectedData: def update_collect_experience_state(application_state: ApplicationState): # Set the collect experience state to a new state with a different conversation history application_state.collect_experience_state.collected_data = [generate_collected_data(i) for i in range(5)] - application_state.collect_experience_state.unexplored_types = [WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID] - application_state.collect_experience_state.explored_types = [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT] + application_state.collect_experience_state.unexplored_types = [WorkType.UNSEEN_UNPAID] + application_state.collect_experience_state.explored_types = [WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK] application_state.collect_experience_state.first_time_visit = random.choice([True, False]) # nosec B311 # random is used for testing purposes diff --git a/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_es_test.py b/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_es_test.py index 1adf65b8..1e17d907 100644 --- a/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_es_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_es_test.py @@ -128,7 +128,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Venta de Zapatos', company='Mercado Local', location=None, start_date=None, end_date=None, - paid_work=None, work_type='SELF_EMPLOYMENT') + paid_work=None, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -141,7 +141,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": ContainsString("2019"), "end_date": AnyOf('', None, "Present"), - "work_type": 'SELF_EMPLOYMENT' + "work_type": 'UNSEEN_UNPAID' }, ] ), @@ -160,7 +160,7 @@ class _TestCaseDataExtraction(CompassTestCase): collected_data_so_far=[ CollectedData(index=0, experience_title='Venta de Zapatos', company='Mercado Local', location=None, start_date=None, end_date=None, - paid_work=None, work_type='SELF_EMPLOYMENT') + paid_work=None, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=-1, # The experience should be deleted expected_collected_data_count=0 @@ -204,7 +204,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Asistente de ventas', company='Local de mi viejo', location=None, start_date=None, end_date=None, - paid_work=None, work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT') + paid_work=None, work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, diff --git a/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_test.py b/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_test.py index 13301341..f2c0e98a 100644 --- a/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/_data_extraction_llm_test.py @@ -136,7 +136,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": '2010', "end_date": '2018', "work_type": - AnyOf(None, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name) + AnyOf(None, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name) }, {"index": 1, "defined_at_turn_number": 1, @@ -147,7 +147,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": '2018', "end_date": '2020', "work_type": - AnyOf(None, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name) + AnyOf(None, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name) } ] @@ -167,7 +167,7 @@ class _TestCaseDataExtraction(CompassTestCase): company=None, location=None, start_date='06/2020', end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT') + paid_work=True, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -181,7 +181,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": ContainsString("06/2020"), "end_date": AnyOf(None, "Present"), "work_type": - AnyOf(WorkType.SELF_EMPLOYMENT.name) + AnyOf(WorkType.UNSEEN_UNPAID.name) }, ] ), @@ -203,7 +203,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Freelancing', company=None, location=None, start_date='06/2020', end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT') + paid_work=True, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -217,7 +217,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": ContainsString("06/2020"), "end_date": AnyOf(None, "Present"), "work_type": - AnyOf(WorkType.SELF_EMPLOYMENT.name) + AnyOf(WorkType.UNSEEN_UNPAID.name) }, ] ), @@ -247,7 +247,7 @@ class _TestCaseDataExtraction(CompassTestCase): company=None, location=None, start_date='06/2020', end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT') + paid_work=True, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -261,7 +261,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": ContainsString("06/2020"), "end_date": AnyOf(None, "Present"), "work_type": - AnyOf(WorkType.SELF_EMPLOYMENT.name) + AnyOf(WorkType.UNSEEN_UNPAID.name) }, ] ), @@ -281,7 +281,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Selling Shoes', company='Local Market', location=None, start_date=None, end_date=None, - paid_work=None, work_type='SELF_EMPLOYMENT') + paid_work=None, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=AnyOf(0, -1), expected_collected_data_count=1, @@ -295,7 +295,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": AnyOf('', None), "end_date": AnyOf('', None), "work_type": - AnyOf(WorkType.SELF_EMPLOYMENT.name) + AnyOf(WorkType.UNSEEN_UNPAID.name) }, ] ), @@ -314,7 +314,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Selling Shoes', company='Local Market', location=None, start_date=None, end_date=None, - paid_work=None, work_type='SELF_EMPLOYMENT') + paid_work=None, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -327,7 +327,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": ContainsString("2019"), "end_date": AnyOf('', None, "Present"), - "work_type": 'SELF_EMPLOYMENT' + "work_type": 'UNSEEN_UNPAID' }, ] ), @@ -346,7 +346,7 @@ class _TestCaseDataExtraction(CompassTestCase): collected_data_so_far=[ CollectedData(index=0, experience_title='Selling Shoes', company='Local Market', location=None, start_date=None, end_date=None, - paid_work=None, work_type='SELF_EMPLOYMENT') + paid_work=None, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=-1, # The experience should be deleted expected_collected_data_count=0 @@ -460,12 +460,12 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, experience_title='delivery job', company='Uber Eats', location='Paris', start_date='2021/01', end_date='2023/03', paid_work=True, - work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT'), + work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK'), CollectedData(index=1, experience_title='Selling old furniture', company='Flea Market of rue Jean Henri Fabre', location='15th arrondissement, near the Eiffel Tower', start_date='2019', end_date='Present', paid_work=True, - work_type='SELF_EMPLOYMENT') + work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=-1, expected_collected_data_count=2 @@ -488,7 +488,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Freelance Work', company=None, location=None, start_date=None, end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT'), + paid_work=True, work_type='UNSEEN_UNPAID'), ], expected_last_referenced_experience_index=0, expected_collected_data_count=1, @@ -502,7 +502,7 @@ class _TestCaseDataExtraction(CompassTestCase): "start_date": '06/2020', "end_date": ContainsString('present'), "work_type": - AnyOf(WorkType.SELF_EMPLOYMENT.name) + AnyOf(WorkType.UNSEEN_UNPAID.name) }, ] @@ -530,11 +530,11 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=2, experience_title='Project Manager', company='University of Oxford', location='Remote', start_date='2018', end_date='2020', paid_work=True, - work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT'), + work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK'), CollectedData(index=1, defined_at_turn_number=6, experience_title='Software Architect', company='ProUbis GmbH', location='Berlin', start_date='2010', end_date='2018', paid_work=True, - work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT'), + work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK'), CollectedData(index=2, defined_at_turn_number=9, experience_title='Software Developer', company='Ubis GmbH', location='Berlin', start_date='1998', end_date='', paid_work=False, work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK') @@ -695,7 +695,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": '2020', "end_date": '2022', - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name }, {"index": 1, "defined_at_turn_number": 1, @@ -705,7 +705,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": '2023', "end_date": "Present", - "work_type": WorkType.SELF_EMPLOYMENT.name + "work_type": WorkType.UNSEEN_UNPAID.name } ] ), @@ -725,7 +725,7 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Cashier', company='Walmart', location=None, start_date='2023', end_date=None, - paid_work=True, work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT') + paid_work=True, work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK') ], expected_last_referenced_experience_index=0, # Should reference the updated experience expected_collected_data_count=2, # Should have both experiences @@ -738,7 +738,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": '2022', "end_date": '2023', - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name }, {"index": 1, "defined_at_turn_number": 2, # New experience gets current turn number @@ -768,11 +768,11 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Waiter', company='Restaurant', location=None, start_date=None, end_date=None, - paid_work=True, work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT'), + paid_work=True, work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK'), CollectedData(index=1, defined_at_turn_number=1, experience_title='Freelance Writing', company=None, location=None, start_date='2020', end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT') + paid_work=True, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, # Should reference the updated writing experience expected_collected_data_count=1, # Should have only the writing experience (waiter deleted) @@ -785,7 +785,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": '2020', "end_date": AnyOf(None, ContainsString('Present')), - "work_type": WorkType.SELF_EMPLOYMENT.name + "work_type": WorkType.UNSEEN_UNPAID.name } ] ), @@ -814,7 +814,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": AnyOf(None, ''), "end_date": AnyOf(None, ''), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name }, {"index": 1, "defined_at_turn_number": 1, @@ -862,11 +862,11 @@ class _TestCaseDataExtraction(CompassTestCase): CollectedData(index=0, defined_at_turn_number=1, experience_title='Teacher', company='School', location=None, start_date=None, end_date=None, - paid_work=True, work_type='FORMAL_SECTOR_WAGED_EMPLOYMENT'), + paid_work=True, work_type='FORMAL_SECTOR_UNPAID_TRAINEE_WORK'), CollectedData(index=1, defined_at_turn_number=1, experience_title='Consulting', company=None, location=None, start_date=None, end_date=None, - paid_work=True, work_type='SELF_EMPLOYMENT') + paid_work=True, work_type='UNSEEN_UNPAID') ], expected_last_referenced_experience_index=0, # Should reference the updated consulting experience expected_collected_data_count=2, # Should have consulting (updated) and photography (new), teaching deleted @@ -879,7 +879,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": True, "start_date": AnyOf(None, ContainsString('2020')), "end_date": AnyOf(None, ContainsString('2022')), - "work_type": WorkType.SELF_EMPLOYMENT.name + "work_type": WorkType.UNSEEN_UNPAID.name }, {"index": 1, "defined_at_turn_number": 2, # New experience gets current turn number @@ -889,7 +889,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": '2021', "end_date": AnyOf(None, ContainsString('Present')), - "work_type": WorkType.SELF_EMPLOYMENT.name + "work_type": WorkType.UNSEEN_UNPAID.name } ] ), @@ -925,7 +925,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": '06/2020', "end_date": ContainsString("Present"), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "index": 1, @@ -936,7 +936,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "01/2018", "end_date": "05/2020", - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "index": 2, @@ -947,7 +947,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "2016", "end_date": "2018", - "work_type": AnyOf(None, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name), + "work_type": AnyOf(None, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name), }, { "index": 3, @@ -958,7 +958,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "2014", "end_date": "2014", - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "index": 4, @@ -969,7 +969,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "2013", "end_date": "2013", - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "index": 5, @@ -980,7 +980,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "2013", "end_date": "2013", - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "index": 6, @@ -991,7 +991,7 @@ class _TestCaseDataExtraction(CompassTestCase): "paid_work": AnyOf(None, True), "start_date": "2012", "end_date": "2012", - "work_type": AnyOf(WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, None), + "work_type": AnyOf(WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, None), }, ], ) diff --git a/backend/evaluation_tests/collect_experiences_agent/collect_experiences_test_cases.py b/backend/evaluation_tests/collect_experiences_agent/collect_experiences_test_cases.py index d628b5ed..767a3011 100644 --- a/backend/evaluation_tests/collect_experiences_agent/collect_experiences_test_cases.py +++ b/backend/evaluation_tests/collect_experiences_agent/collect_experiences_test_cases.py @@ -62,8 +62,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=2, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 2)}, matchers=["llm", "matcher"], @@ -87,8 +87,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=2, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 2)}, matchers=["llm"], @@ -157,13 +157,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=1, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1)}, + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1)}, expected_experience_data=[ {"experience_title": ContainsString("project manager"), "location": ContainsString("remote"), "company": ContainsString("University of Oxford"), "timeline": {"start": "2018", "end": "2020"}, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, ] ), @@ -180,8 +180,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe expected_experiences_count_min=0, expected_experiences_count_max=0, country_of_user=Country.UNSPECIFIED, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0) }, @@ -197,8 +197,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe expected_experiences_count_min=1, expected_experiences_count_max=1, country_of_user=Country.UNSPECIFIED, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0) }, @@ -207,7 +207,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("remote"), "company": ContainsString("University of Oxford"), "timeline": {"start": "2018", "end": "2020"}, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }] ), CollectExperiencesAgentTestCase( @@ -232,8 +232,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=7, expected_experiences_count_max=7, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (2, 2), - WorkType.SELF_EMPLOYMENT: (2, 2), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (2, 2), + WorkType.UNSEEN_UNPAID: (2, 2), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (2, 2) }, @@ -242,25 +242,25 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("remote"), "company": ContainsString("University of Oxford"), "timeline": DictContaining({"start": "2018", "end": "2020"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("software architect"), "location": ContainsString("Berlin"), "company": ContainsString("ProUbis GmbH"), "timeline": DictContaining({"start": "2010", "end": "2018"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("Owned a bar/restaurant"), "location": ContainsString("Berlin"), "company": ContainsString("Dinner For Two"), "timeline": DictContaining({"start": "2010", "end": "2020"}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("CEO"), "location": ContainsString("DC"), "company": ContainsString("Acme Inc."), "timeline": DictContaining({"start": "2022", "end": AnyOf('', ContainsString("present"))}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("Software Developer"), "location": ContainsString("Berlin"), @@ -302,8 +302,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=7, expected_experiences_count_max=7, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (2, 2), - WorkType.SELF_EMPLOYMENT: (2, 2), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (2, 2), + WorkType.UNSEEN_UNPAID: (2, 2), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (2, 2)}, country_of_user=Country.UNSPECIFIED, @@ -312,25 +312,25 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("remote"), "company": ContainsString("University of Oxford"), "timeline": DictContaining({"start": "2018", "end": "2020"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("software architect"), "location": ContainsString("Berlin"), "company": ContainsString("ProUbis GmbH"), "timeline": DictContaining({"start": "2010", "end": "2018"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("Owned a bar/restaurant"), "location": ContainsString("Berlin"), "company": ContainsString("Dinner For Two"), "timeline": DictContaining({"start": "2010", "end": "2020"}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("CEO"), "location": ContainsString("DC"), "company": ContainsString("Acme Inc."), "timeline": DictContaining({"start": "2022", "end": AnyOf('', ContainsString("present"))}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("Software Developer"), "location": ContainsString("Berlin"), @@ -364,8 +364,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=3, expected_experiences_count_max=3, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (2, 2)}, country_of_user=Country.SOUTH_AFRICA @@ -384,15 +384,15 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=3, expected_experiences_count_max=3, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (2, 2)}, expected_experience_data=[ {"experience_title": ContainsString("graphic design"), "location": AnyOf(ContainsString("remote"), ContainsString("Joburg")), "timeline": DictContaining({"start": "06/2020", "end": ContainsString("present")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("English teacher"), "location": ContainsString("Joburg"), @@ -421,8 +421,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe # The simulated user seems to report 3 experiences (help parent, drove grandma, helped neighbours) expected_experiences_count_min=2, expected_experiences_count_max=3, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (2, 3)} ), @@ -440,8 +440,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=2, expected_experiences_count_max=2, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 2), - WorkType.SELF_EMPLOYMENT: (1, 2), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 2), + WorkType.UNSEEN_UNPAID: (1, 2), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0)}, expected_experience_data=[ @@ -449,13 +449,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Paris"), "company": ContainsString("Uber Eats"), "timeline": DictContaining({"start": "2021", "end": ContainsString("2023")}), - "work_type": AnyOf(WorkType.SELF_EMPLOYMENT.name, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name), + "work_type": AnyOf(WorkType.UNSEEN_UNPAID.name, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name), }, {"experience_title": ContainsString("selling furniture"), "location": AnyOf(ContainsString("Flea Market"), ContainsString("Jean Henri Fabre"), ContainsString("Paris")), "company": ContainsString("Flea Market"), "timeline": DictContaining({"start": "2019", "end": ContainsString("present")}), - "work_type": AnyOf(WorkType.SELF_EMPLOYMENT.name, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name), + "work_type": AnyOf(WorkType.UNSEEN_UNPAID.name, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name), }, ], @@ -474,8 +474,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=2, expected_experiences_count_max=2, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 2), - WorkType.SELF_EMPLOYMENT: (1, 2), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 2), + WorkType.UNSEEN_UNPAID: (1, 2), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0)}, expected_experience_data=[ @@ -483,13 +483,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Paris"), "company": ContainsString("Uber Eats"), "timeline": DictContaining({"start": "2021", "end": ContainsString("2023")}), - "work_type": AnyOf(WorkType.SELF_EMPLOYMENT.name, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name), + "work_type": AnyOf(WorkType.UNSEEN_UNPAID.name, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name), }, {"experience_title": ContainsString("selling furniture"), "location": AnyOf(ContainsString("Flea Market"), ContainsString("Jean Henri Fabre"), ContainsString("Paris")), "company": ContainsString("Flea Market"), "timeline": DictContaining({"start": "2019", "end": ContainsString("present")}), - "work_type": AnyOf(WorkType.SELF_EMPLOYMENT.name, WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name), + "work_type": AnyOf(WorkType.UNSEEN_UNPAID.name, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name), }, ], @@ -505,8 +505,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=1, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0)}, expected_experience_data=[ @@ -514,7 +514,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Nairobi"), "company": ContainsString("Chandaria"), "timeline": DictContaining({"start": "2018", "end": ContainsString("present")}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }] ), CollectExperiencesAgentTestCase( @@ -532,8 +532,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=1, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0)}, expected_experience_data=[ @@ -541,7 +541,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Nairobi"), "company": ContainsString("Chandaria"), "timeline": DictContaining({"start": "2018", "end": ContainsString("present")}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }] ), @@ -557,8 +557,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=1, expected_experiences_count_max=1, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), - WorkType.SELF_EMPLOYMENT: (0, 0), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 1)}, expected_experience_data=[ @@ -593,8 +593,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=7, expected_experiences_count_max=7, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (2, 2), - WorkType.SELF_EMPLOYMENT: (2, 2), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (2, 2), + WorkType.UNSEEN_UNPAID: (2, 2), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (2, 2)}, expected_experience_data=[ @@ -602,25 +602,25 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("remote"), "company": ContainsString("University of Oxford"), "timeline": DictContaining({"start": "2018", "end": "2020"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("software architect"), "location": ContainsString("Berlin"), "company": ContainsString("ProUbis GmbH"), "timeline": DictContaining({"start": "2010", "end": "2018"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("Owned a bar/restaurant"), "location": ContainsString("Berlin"), "company": ContainsString("Dinner For Two"), "timeline": DictContaining({"start": "2010", "end": "2020"}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("CEO"), "location": ContainsString("DC"), "company": ContainsString("Acme Inc."), "timeline": DictContaining({"start": "2022", "end": AnyOf('', ContainsString("present"))}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("Software Developer"), "location": ContainsString("Berlin"), @@ -672,8 +672,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], expected_experiences_count_min=4, expected_experiences_count_max=4, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (2, 2)}, expected_experience_data=[ @@ -682,13 +682,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), # Provided in follow-up "company": ContainsString("TechCorp"), # Provided in follow-up "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": ContainsString("Johannesburg"), # Provided in follow-up "company": AnyOf(ContainsString("small businesses"), ContainsString("startups"), ContainsString("clients")), "timeline": DictContaining({"start": "2023", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), # Provided in follow-up @@ -734,8 +734,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=20)], expected_experiences_count_min=3, expected_experiences_count_max=3, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 1)}, expected_experience_data=[ @@ -744,13 +744,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), # Provided in follow-up "company": ContainsString("TechCorp"), # Provided in follow-up "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": ContainsString("Johannesburg"), # Provided in follow-up "company": AnyOf(ContainsString("SmallBiz Solutions"), ContainsString("StartupXYZ"), ContainsString("clients")), "timeline": DictContaining({"start": "2023", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), # Provided in follow-up @@ -799,8 +799,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=25)], expected_experiences_count_min=5, expected_experiences_count_max=5, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (2, 2), # Software Developer + Tutor - WorkType.SELF_EMPLOYMENT: (1, 1), # Freelance Web Designer + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (2, 2), # Software Developer + Tutor + WorkType.UNSEEN_UNPAID: (1, 1), # Freelance Web Designer WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (2, 2)}, # Animal Shelter + Family Restaurant expected_experience_data=[ @@ -808,13 +808,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), "company": ContainsString("TechCorp"), "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": AnyOf(ContainsString("Johannesburg"), ContainsString("remote")), "company": AnyOf(ContainsString("clients"), ContainsString("freelance")), "timeline": DictContaining({"start": "2022", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), @@ -826,7 +826,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Pretoria"), "company": AnyOf(ContainsString("tutoring"), ContainsString("company")), "timeline": DictContaining({"start": "2021", "end": "2023"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": AnyOf(ContainsString("family"), ContainsString("restaurant")), "location": AnyOf(ContainsString("Durban"), ContainsString("Cape Town"), ContainsString("Johannesburg"), ContainsString("Pretoria"), ContainsString("Gqeberha")), @@ -890,8 +890,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=20)], expected_experiences_count_min=10, expected_experiences_count_max=10, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (3, 3), # Software Developer + Tutor + Sales Associate - WorkType.SELF_EMPLOYMENT: (3, 3), # Web Designer + Graphic Designer + Content Writer + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (3, 3), # Software Developer + Tutor + Sales Associate + WorkType.UNSEEN_UNPAID: (3, 3), # Web Designer + Graphic Designer + Content Writer WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), # Marketing Intern WorkType.UNSEEN_UNPAID: (3, 3)}, # Animal Shelter + Family Restaurant + Community Center expected_experience_data=[ @@ -899,13 +899,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), "company": ContainsString("TechCorp"), "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": AnyOf(ContainsString("Johannesburg"), ContainsString("remote")), "company": AnyOf(ContainsString("clients"), ContainsString("freelance")), "timeline": DictContaining({"start": "2022", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), @@ -917,7 +917,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Pretoria"), "company": AnyOf(ContainsString("tutoring"), ContainsString("company")), "timeline": DictContaining({"start": "2021", "end": "2023"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": AnyOf(ContainsString("family"), ContainsString("restaurant")), "location": AnyOf(ContainsString("Durban"), ContainsString("Cape Town"), ContainsString("Johannesburg"), ContainsString("Pretoria"), ContainsString("Gqeberha")), @@ -935,7 +935,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), "company": AnyOf(ContainsString("businesses"), ContainsString("clients")), "timeline": DictContaining({"start": "2021", "end": "2022"}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), @@ -947,13 +947,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Pretoria"), "company": AnyOf(ContainsString("retail"), ContainsString("store")), "timeline": DictContaining({"start": "2018", "end": "2019"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("content writer"), "location": AnyOf(ContainsString("remote"), ContainsString("home")), "company": AnyOf(ContainsString("clients"), ContainsString("freelance")), "timeline": DictContaining({"start": "2023", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, ], ), @@ -980,8 +980,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=25)], expected_experiences_count_min=3, expected_experiences_count_max=3, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 1)}, expected_experience_data=[ @@ -990,13 +990,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), # Provided in follow-up "company": ContainsString("TechCorp"), # Provided in follow-up "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": ContainsString("Johannesburg"), # Provided in follow-up "company": AnyOf(ContainsString("SmallBiz Solutions"), ContainsString("StartupXYZ"), ContainsString("clients")), "timeline": DictContaining({"start": "2023", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, {"experience_title": ContainsString("volunteer"), "location": ContainsString("Durban"), # Provided in follow-up @@ -1019,7 +1019,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), CollectedData( uuid="test-uuid-2", @@ -1031,7 +1031,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe start_date="2023", end_date="Present", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ), CollectedData( uuid="test-uuid-3", @@ -1047,7 +1047,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe ) ], unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID, WorkType.UNSEEN_UNPAID], first_time_visit=False # Not first time since we have existing data ) ), @@ -1072,8 +1072,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=25)], expected_experiences_count_min=2, expected_experiences_count_max=2, - expected_work_types={WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), + expected_work_types={WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (0, 0)}, expected_experience_data=[ @@ -1082,13 +1082,13 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Cape Town"), # Now provided in follow-up "company": ContainsString("TechCorp"), # Now provided in follow-up "timeline": DictContaining({"start": "2020", "end": "2022"}), - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, {"experience_title": ContainsString("web design"), "location": ContainsString("Johannesburg"), # Now provided in follow-up "company": ContainsString("SmallBiz Solutions"), # Now provided in follow-up "timeline": DictContaining({"start": "2023", "end": AnyOf("Present", "")}), - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, ], # Inject a partially collected state @@ -1106,7 +1106,7 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), CollectedData( uuid="test-uuid-2", @@ -1118,11 +1118,11 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe start_date="2023", end_date="Present", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ) ], unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], first_time_visit=False # Not first time since we have existing data ) ), @@ -1176,8 +1176,8 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe expected_experiences_count_min=5, expected_experiences_count_max=5, expected_work_types={ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), + WorkType.UNSEEN_UNPAID: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (1, 1) }, @@ -1188,21 +1188,21 @@ class CollectExperiencesAgentTestCase(EvaluationTestCase, DiscoveredExperienceTe "location": ContainsString("Vihiga"), "company": AnyOf(ContainsString("lady"), ContainsString("This Lady")), "timeline": {"start": "2014", "end": ContainsString("2015")}, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "experience_title": AnyOf(ContainsString("secretary"), ContainsString("Secretary")), "location": ContainsString("Machakos"), "company": ContainsString("school"), "timeline": {"start": "2016", "end": "2016"}, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, }, { "experience_title": AnyOf(ContainsString("chapati"), ContainsString("selling")), "location": ContainsString("Vihiga"), "company": "", # Empty string for self-employment "timeline": {"start": "2014", "end": "2015"}, - "work_type": WorkType.SELF_EMPLOYMENT.name, + "work_type": WorkType.UNSEEN_UNPAID.name, }, { "experience_title": AnyOf(ContainsString("internship"), ContainsString("Lukola")), diff --git a/backend/evaluation_tests/collect_experiences_agent/data_extraction/temporal_classifier_test.py b/backend/evaluation_tests/collect_experiences_agent/data_extraction/temporal_classifier_test.py index 0986b565..6ab84054 100644 --- a/backend/evaluation_tests/collect_experiences_agent/data_extraction/temporal_classifier_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/data_extraction/temporal_classifier_test.py @@ -51,7 +51,7 @@ class TemporalAndWorkTypeClassifierToolTestCase(CompassTestCase): "start_date": "05/2021", "end_date": AnyOf(None, ContainsString("present")), "paid_work": True, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name, + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name, } ), TemporalAndWorkTypeClassifierToolTestCase( @@ -85,7 +85,7 @@ class TemporalAndWorkTypeClassifierToolTestCase(CompassTestCase): users_input="Yes, as a baker at a local restaurant.", expected_extracted_data={ "paid_work": True, - "work_type": WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + "work_type": WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name } ), TemporalAndWorkTypeClassifierToolTestCase( @@ -107,7 +107,7 @@ class TemporalAndWorkTypeClassifierToolTestCase(CompassTestCase): users_input="Yes, selling tomatoes in local market.", expected_extracted_data={ "paid_work": True, - "work_type": WorkType.SELF_EMPLOYMENT.name + "work_type": WorkType.UNSEEN_UNPAID.name } ), TemporalAndWorkTypeClassifierToolTestCase( diff --git a/backend/evaluation_tests/collect_experiences_agent/data_extraction/transition_decision_tool_test.py b/backend/evaluation_tests/collect_experiences_agent/data_extraction/transition_decision_tool_test.py index c0564149..29a817a4 100644 --- a/backend/evaluation_tests/collect_experiences_agent/data_extraction/transition_decision_tool_test.py +++ b/backend/evaluation_tests/collect_experiences_agent/data_extraction/transition_decision_tool_test.py @@ -72,8 +72,8 @@ class TransitionDecisionToolTestCase(CompassTestCase): work_type=None ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.CONTINUE ), @@ -95,11 +95,11 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.CONTINUE ), @@ -121,11 +121,11 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.END_WORKTYPE ), @@ -147,14 +147,14 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], exploring_type=None, unexplored_types=[], explored_types=[ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + WorkType.UNSEEN_UNPAID, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID ], @@ -178,14 +178,14 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], exploring_type=None, unexplored_types=[], explored_types=[ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + WorkType.UNSEEN_UNPAID, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID ], @@ -208,7 +208,7 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), CollectedData( index=1, @@ -219,11 +219,11 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2022", end_date="2024", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.CONTINUE ), @@ -243,11 +243,11 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2020", end_date="2022", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.CONTINUE ), @@ -270,12 +270,12 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2014", end_date="2015", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ) ], - exploring_type=WorkType.SELF_EMPLOYMENT, - unexplored_types=[WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT], + exploring_type=WorkType.UNSEEN_UNPAID, + unexplored_types=[WorkType.UNSEEN_UNPAID, WorkType.UNSEEN_UNPAID], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK], expected_transition_decision=TransitionDecision.CONTINUE ), TransitionDecisionToolTestCase( @@ -304,8 +304,8 @@ class TransitionDecisionToolTestCase(CompassTestCase): exploring_type=WorkType.UNSEEN_UNPAID, unexplored_types=[WorkType.UNSEEN_UNPAID], explored_types=[ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + WorkType.UNSEEN_UNPAID, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ], expected_transition_decision=TransitionDecision.END_WORKTYPE @@ -329,12 +329,12 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2014", end_date="2015", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ) ], - exploring_type=WorkType.SELF_EMPLOYMENT, - unexplored_types=[WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT], + exploring_type=WorkType.UNSEEN_UNPAID, + unexplored_types=[WorkType.UNSEEN_UNPAID, WorkType.UNSEEN_UNPAID], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK], expected_transition_decision=TransitionDecision.END_WORKTYPE ), TransitionDecisionToolTestCase( @@ -356,11 +356,11 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2016", end_date="2016", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ) ], - exploring_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - unexplored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + exploring_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], explored_types=[], expected_transition_decision=TransitionDecision.END_WORKTYPE ), @@ -382,12 +382,12 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2014", end_date="2015", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ) ], - exploring_type=WorkType.SELF_EMPLOYMENT, - unexplored_types=[WorkType.SELF_EMPLOYMENT, WorkType.UNSEEN_UNPAID], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT], + exploring_type=WorkType.UNSEEN_UNPAID, + unexplored_types=[WorkType.UNSEEN_UNPAID, WorkType.UNSEEN_UNPAID], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK], expected_transition_decision=TransitionDecision.END_WORKTYPE ), TransitionDecisionToolTestCase( @@ -408,7 +408,7 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2016", end_date="2016", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), CollectedData( index=1, @@ -419,7 +419,7 @@ class TransitionDecisionToolTestCase(CompassTestCase): start_date="2014", end_date="2015", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ), CollectedData( index=2, @@ -436,8 +436,8 @@ class TransitionDecisionToolTestCase(CompassTestCase): exploring_type=None, unexplored_types=[], explored_types=[ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, - WorkType.SELF_EMPLOYMENT, + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, + WorkType.UNSEEN_UNPAID, WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID ], diff --git a/backend/evaluation_tests/core_e2e_tests_cases.py b/backend/evaluation_tests/core_e2e_tests_cases.py index 4b0f55dc..bd92b599 100644 --- a/backend/evaluation_tests/core_e2e_tests_cases.py +++ b/backend/evaluation_tests/core_e2e_tests_cases.py @@ -64,11 +64,11 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): conversation_rounds=50, name='asks_about_process_e2e', simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - Selling Shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. - You have never had another job experience beside the shoe salesperson job. Also never - did any internship, never run your own business, never volunteered, never did any freelance work. + You have never had another job experience beside the shoe sales trainee role. Also never + had a paid job, never run your own business, never volunteered, never did any freelance work. Be as concise as possible, and do not make up any information. When asked if you are ready to start the conversation, @@ -84,14 +84,12 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["llm", "matcher"], expected_experience_data=[{ - "experience_title": ContainsString("Shoe Salesperson"), + "experience_title": ContainsString("Shoe"), "location": ContainsString("Tokyo"), "company": ContainsString("Shoe Soles"), "timeline": {"start": ContainsString("2023"), "end": AnyOf(ContainsString("present"), "")}, @@ -102,25 +100,23 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): conversation_rounds=50, name='single_experience_specific_and_concise_user_e2e', simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - Selling Shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. - You have never had another job experience beside the shoe salesperson job. Also never - did any internship, never run your own business, never volunteered, never did any freelance work. + You have never had another job experience beside the shoe sales trainee role. Also never + had a paid job, never run your own business, never volunteered, never did any freelance work. Be as concise as possible, and do not make up any information. """) + system_instruction_prompt, evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=60)], expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["llm", "matcher"], expected_experience_data=[{ - "experience_title": ContainsString("Shoe Salesperson"), + "experience_title": ContainsString("Shoe"), "location": ContainsString("Tokyo"), "company": ContainsString("Shoe Soles"), "timeline": {"start": ContainsString("2023"), "end": AnyOf(ContainsString("present"), "")}, @@ -132,22 +128,20 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): name='single_experience_e2e', locale=Locale.ES_AR, simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - You sold shoes at Zuputaas, a store in Tokyo, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Zuputaas, a store in Tokyo, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. Be more descriptive and more open. - You have never had another job experience beside the shoe salesperson job. Also never - did any internship, never run your own business, never volunteered, never did any freelance work. + You have never had another job experience beside the shoe sales trainee role. Also never + had a paid job, never run your own business, never volunteered, never did any freelance work. Be as concise as possible, and do not make up any information.""") + system_instruction_prompt, evaluations=[Evaluation(type=EvaluationType.SINGLE_LANGUAGE, expected=100)], expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["matcher"], @@ -163,8 +157,8 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): name='single_experience_e2e_switch_languages', locale=Locale.ES_AR, simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - You sold shoes at Kigali Shoes, a store in Kigali, Rwanda, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Kigali Shoes, a store in Kigali, Rwanda, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. Be more descriptive and more open. @@ -184,9 +178,7 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["matcher"], @@ -210,8 +202,8 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=1, expected_experiences_count_max=2, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 2), }, @@ -238,8 +230,8 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.UNSEEN_UNPAID: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 1), } @@ -257,14 +249,12 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["llm", "matcher"], expected_experience_data=[{ - "experience_title": ContainsString("Shoe Salesperson"), + "experience_title": ContainsString("Shoe"), }] ), E2ETestCase( @@ -456,8 +446,8 @@ class E2ESpecificTestCase(E2ETestCase, DiscoveredExperienceTestCase): expected_experiences_count_min=2, expected_experiences_count_max=2, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 1), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 1), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 1), + WorkType.UNSEEN_UNPAID: (0, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 1), }, diff --git a/backend/evaluation_tests/golden_test_cases.py b/backend/evaluation_tests/golden_test_cases.py index 118f343e..ce61ab71 100644 --- a/backend/evaluation_tests/golden_test_cases.py +++ b/backend/evaluation_tests/golden_test_cases.py @@ -53,33 +53,31 @@ class GoldenTestCase(E2ESpecificTestCase): # Golden Test Set: 7 Representative Personas golden_test_cases = [ - # 1. SIMPLE FORMAL EMPLOYMENT (Baseline) - # Represents: Concise user, single formal job, straightforward conversation - # Coverage: FORMAL_SECTOR_WAGED_EMPLOYMENT + # 1. SIMPLE UNPAID TRAINEE (Baseline) + # Represents: Concise user, single unpaid trainee experience, straightforward conversation + # Coverage: FORMAL_SECTOR_UNPAID_TRAINEE_WORK GoldenTestCase( country_of_user=Country.UNSPECIFIED, conversation_rounds=50, - name='golden_simple_formal_employment', + name='golden_simple_unpaid_trainee', simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - Selling Shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. - You have never had another job experience beside the shoe salesperson job. Also never - did any internship, never run your own business, never volunteered, never did any freelance work. + You have never had another job experience beside the shoe sales trainee role. Also never + had a paid job, never run your own business, never volunteered, never did any freelance work. Be as concise as possible, and do not make up any information. """) + system_instruction_prompt, evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=60)], expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["llm", "matcher"], expected_experience_data=[{ - "experience_title": ContainsString("Shoe Salesperson"), + "experience_title": ContainsString("Shoe"), "location": ContainsString("Tokyo"), "company": ContainsString("Shoe Soles"), "timeline": {"start": ContainsString("2023"), "end": AnyOf(ContainsString("present"), "")}, @@ -103,8 +101,6 @@ class GoldenTestCase(E2ESpecificTestCase): expected_experiences_count_min=1, expected_experiences_count_max=2, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), WorkType.UNSEEN_UNPAID: (1, 2), }, @@ -123,46 +119,44 @@ class GoldenTestCase(E2ESpecificTestCase): }] ), - # 3. SELF-EMPLOYMENT (Informal Sector) - # Represents: Informal work, no formal job title - # Coverage: SELF_EMPLOYMENT + # 3. VOLUNTEER TRANSPORT (Informal Sector) + # Represents: Volunteer work in transport sector + # Coverage: UNSEEN_UNPAID GoldenTestCase( country_of_user=Country.KENYA, conversation_rounds=100, - name='golden_self_employed_matatu_conductor', + name='golden_volunteer_matatu_conductor', simulated_user_prompt=dedent(""" - You're a Gen Y living alone. You work as a Matatu conductor. A matatu conductor is a person who collects - fares from passengers in a matatu, a type of public transport. You have never had another job experience, - never did any internship, never run your own business, never volunteered, never did any freelance work. + You're a Gen Y living alone. You volunteer as a Matatu conductor assistant. A matatu conductor is a person who + collects fares from passengers in a matatu, a type of public transport. You help out unpaid at a family member's matatu. + You have never had a paid job experience, never did any internship, never run your own business, never did any freelance work. """) + kenya_prompt, evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=60)], expected_experiences_count_min=1, expected_experiences_count_max=2, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 1), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), - WorkType.UNSEEN_UNPAID: (0, 1), + WorkType.UNSEEN_UNPAID: (1, 2), } ), # 4. MULTI-EXPERIENCE DIVERSE (Complex) - # Represents: Multiple work types, complex conversation - # Coverage: FORMAL_SECTOR_WAGED_EMPLOYMENT, SELF_EMPLOYMENT, FORMAL_SECTOR_UNPAID_TRAINEE_WORK, UNSEEN_UNPAID + # Represents: Multiple unpaid/trainee work types, complex conversation + # Coverage: FORMAL_SECTOR_UNPAID_TRAINEE_WORK, UNSEEN_UNPAID GoldenTestCase( country_of_user=Country.SOUTH_AFRICA, conversation_rounds=100, name='golden_multi_experience_diverse', simulated_user_prompt=dedent(""" - You are a young person from South Africa with a diverse work history. You have multiple experiences across different work types. + You are a young person from South Africa with unpaid work history. You have multiple experiences across different work types. If asked if you want to start the conversation, agree to start without saying anything about your experiences. You have the following experiences: - • Worked as a software developer at TechCorp from 2020 to 2022. It was a full-time paid job in Cape Town. I worked on web applications and mobile apps, then left to pursue freelance work. - • I'm currently freelancing as a web designer since 2022. I'm self-employed, working with various clients. I'm based in Johannesburg but work remotely, and I specialize in e-commerce websites. • Volunteered at a local animal shelter from 2019 to 2021. It was unpaid volunteer work in Durban. I helped with animal care and adoption events, working weekends and holidays. • Did a summer internship at a marketing agency in 2019. It was in Johannesburg and I helped with social media campaigns. It was unpaid trainee work. + • Helped care for elderly grandparents at home from 2020 to 2022. Unpaid caregiving in Cape Town. + • Volunteered at a community center teaching web design to kids from 2022 to present. Unpaid in Johannesburg. When the agent asks about your experiences, provide information naturally as they ask questions. Be specific about dates, locations, and work types when asked. You're proud of your diverse experience @@ -171,13 +165,11 @@ class GoldenTestCase(E2ESpecificTestCase): You can come up with specific activities and details for each experience when asked. """) + sa_prompt, evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=30)], - expected_experiences_count_min=4, + expected_experiences_count_min=3, expected_experiences_count_max=4, expected_work_types={ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.SELF_EMPLOYMENT: (1, 1), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), - WorkType.UNSEEN_UNPAID: (1, 1), + WorkType.UNSEEN_UNPAID: (2, 3), } ), @@ -189,15 +181,15 @@ class GoldenTestCase(E2ESpecificTestCase): conversation_rounds=100, name='golden_cv_upload_style', simulated_user_prompt=dedent(""" - You are a professional with diverse experiences. + You are a professional with diverse unpaid and volunteer experiences. If asked if you want to start the conversation, agree to start without saying anything about your experiences. Only after agreeing to start, wait for the agent to ask you about your experiences, and then you will respond in CV format with bullet points: These are my experiences: - • Worked as a project manager at the University of Oxford, from 2018 to 2020. It was a paid job and you worked remotely. - • Worked as a software architect at ProUbis GmbH in berlin, from 2010 to 2018. It was a full-time job. - • You owned a bar/restaurant called Dinner For Two in Berlin from 2010 until covid-19, then you sold it. + • Volunteered as a project coordinator at the University of Oxford, from 2018 to 2020. It was unpaid and you worked remotely. + • Unpaid internship as a software architect at ProUbis GmbH in Berlin, from 2010 to 2018. + • Volunteered at a community kitchen called Dinner For Two in Berlin from 2010 until covid-19. • In 1998 did an unpaid internship as a Software Developer for Ubis GmbH in Berlin. • Between 2015-2017 volunteer, taught coding to kids in a community center in Berlin. @@ -213,13 +205,11 @@ class GoldenTestCase(E2ESpecificTestCase): You can come up with activities you have done during each experience. """) + system_instruction_prompt, evaluations=[Evaluation(type=EvaluationType.CONCISENESS, expected=60)], - expected_experiences_count_min=5, + expected_experiences_count_min=4, expected_experiences_count_max=5, expected_work_types={ - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (2, 2), - WorkType.SELF_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), - WorkType.UNSEEN_UNPAID: (1, 1), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 2), + WorkType.UNSEEN_UNPAID: (2, 3), } ), @@ -231,11 +221,11 @@ class GoldenTestCase(E2ESpecificTestCase): conversation_rounds=50, name='golden_process_questioner', simulated_user_prompt=dedent(""" - You're a Gen Y living alone. you have this single experience as an employee: - - Selling Shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. + You're a Gen Y living alone. you have this single experience as an unpaid trainee: + - Unpaid internship selling shoes at Shoe Soles, a shoe store in Tokyo, from 2023 to present. When asked you will reply with the information about this experience all at once, in a single message. - You have never had another job experience beside the shoe salesperson job. Also never - did any internship, never run your own business, never volunteered, never did any freelance work. + You have never had another job experience beside the shoe sales trainee role. Also never + had a paid job, never run your own business, never volunteered, never did any freelance work. Be as concise as possible, and do not make up any information. When asked if you are ready to start the conversation, @@ -251,14 +241,12 @@ class GoldenTestCase(E2ESpecificTestCase): expected_experiences_count_min=1, expected_experiences_count_max=1, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 0), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (1, 1), - WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), + WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (1, 1), WorkType.UNSEEN_UNPAID: (0, 0), }, matchers=["llm", "matcher"], expected_experience_data=[{ - "experience_title": ContainsString("Shoe Salesperson"), + "experience_title": ContainsString("Shoe"), "location": ContainsString("Tokyo"), "company": ContainsString("Shoe Soles"), "timeline": {"start": ContainsString("2023"), "end": AnyOf(ContainsString("present"), "")}, @@ -282,10 +270,8 @@ class GoldenTestCase(E2ESpecificTestCase): expected_experiences_count_min=1, expected_experiences_count_max=4, expected_work_types={ - WorkType.SELF_EMPLOYMENT: (0, 2), - WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: (0, 0), WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: (0, 0), - WorkType.UNSEEN_UNPAID: (1, 3), + WorkType.UNSEEN_UNPAID: (1, 4), } ), ] @@ -299,10 +285,8 @@ class GoldenTestCase(E2ESpecificTestCase): "expected_runtime_minutes": 25, "coverage": { "work_types": { - "FORMAL_SECTOR_WAGED_EMPLOYMENT": 3, - "SELF_EMPLOYMENT": 2, - "FORMAL_SECTOR_UNPAID_TRAINEE_WORK": 1, - "UNSEEN_UNPAID": 3, + "FORMAL_SECTOR_UNPAID_TRAINEE_WORK": 3, + "UNSEEN_UNPAID": 4, }, "conversation_styles": { "concise": 4, diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/experience_pipeline_test.py b/backend/evaluation_tests/linking_and_ranking_pipeline/experience_pipeline_test.py index b418c98e..9a6bb8b0 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/experience_pipeline_test.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/experience_pipeline_test.py @@ -30,7 +30,7 @@ class ExperiencePipelineTestCase(CompassTestCase): given_company_name="Baker's Delight", given_responsibilities=["I sell bread"], given_country_of_interest=Country.SOUTH_AFRICA, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, expected_top_skills=["advise customers on bread"] ), ExperiencePipelineTestCase( @@ -39,7 +39,7 @@ class ExperiencePipelineTestCase(CompassTestCase): given_company_name="Baker's Delight", given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread", "I talk to customers"], given_country_of_interest=Country.SOUTH_AFRICA, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, expected_top_skills=['communicate with customers', 'maintain work area cleanliness', 'order supplies', @@ -76,7 +76,7 @@ class ExperiencePipelineTestCase(CompassTestCase): "I handle communication with stakeholders", ], given_country_of_interest=Country.SOUTH_AFRICA, - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, expected_top_skills=[] ), ExperiencePipelineTestCase( @@ -89,7 +89,7 @@ class ExperiencePipelineTestCase(CompassTestCase): "I clean and disinfect students, teachers, and visitors.", "I put together weekly and monthly reports."], given_country_of_interest=Country.SOUTH_AFRICA, - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, expected_top_skills=['communicate health and safety measures', 'disinfect surfaces', 'measure temperature', @@ -257,7 +257,7 @@ class ExperiencePipelineTestCase(CompassTestCase): given_company_name="Local de mi viejo", given_responsibilities=["Limpiar el lugar", "Manejar la guita", "Tratar con proveedores", "Contabilidad básica", "Venta de productos", "Empaquetar pedidos"], given_country_of_interest=Country.ARGENTINA, - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, expected_top_skills=[] ), ExperiencePipelineTestCase( diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm_test.py b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm_test.py index 39828360..5dca4fd8 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm_test.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/_contextualization_llm_test.py @@ -29,7 +29,7 @@ class ContextualizationTestCase(CompassTestCase): name="Self-employed baker with responsibilities", given_experience_title="Baker", given_company="Bread Co.", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread"], given_country_of_interest=Country.UNSPECIFIED, given_number_of_titles=5 @@ -38,7 +38,7 @@ class ContextualizationTestCase(CompassTestCase): name="Foo clarified by responsibilities", given_experience_title="Foo", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread"], given_country_of_interest=Country.UNSPECIFIED, given_number_of_titles=5 @@ -47,7 +47,7 @@ class ContextualizationTestCase(CompassTestCase): name="Baker clarified by Job Title and not responsibilities", given_experience_title="Baker", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=[], given_country_of_interest=Country.UNSPECIFIED, given_number_of_titles=5 @@ -56,7 +56,7 @@ class ContextualizationTestCase(CompassTestCase): name="Baker contradicting responsibilities", given_experience_title="Baker", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I develop software", "I develop websites", "I build for the web", "I write code", "I bake bread"], given_country_of_interest=Country.UNSPECIFIED, given_number_of_titles=5 @@ -65,7 +65,7 @@ class ContextualizationTestCase(CompassTestCase): name="Matatu conductor", given_experience_title="Matatu conductor", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I collect fares", "I assist passengers", "I maintain the vehicle", "I ensure safety"], given_country_of_interest=Country.KENYA, given_number_of_titles=5 @@ -75,7 +75,7 @@ class ContextualizationTestCase(CompassTestCase): skip_force="force", given_experience_title="", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I stock shelves", "I serve customers", "I handle cash transactions", "I manage inventory"], given_country_of_interest=Country.KENYA, given_number_of_titles=10 @@ -85,7 +85,7 @@ class ContextualizationTestCase(CompassTestCase): skip_force="force", given_experience_title=" ", given_company=None, - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I stock shelves", "I serve customers", "I handle cash transactions", "I manage inventory"], given_country_of_interest=Country.KENYA, given_number_of_titles=10 diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/generate_jsonl_testcases.py b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/generate_jsonl_testcases.py index 7a8950db..f47ab7b3 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/generate_jsonl_testcases.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/generate_jsonl_testcases.py @@ -38,7 +38,7 @@ class PartialSpecification(BaseModel): list_of_given_partial_specs: list[PartialSpecification] = [ PartialSpecification( given_code="7512.1", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="Baker's Delight", given_country_of_interest=Country.UNSPECIFIED, expected_occupations_found_codes=["7512.1"] diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test_cases.jsonl b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test_cases.jsonl index 15f569f5..b6e0ba5d 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test_cases.jsonl +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/infer_occupation_tool_test_cases.jsonl @@ -1,6 +1,6 @@ -{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Bakery Assistant", "given_work_type": "Self-employment", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} -{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Pastry Cook", "given_work_type": "Self-employment", "given_company": "Sweet Treats Cafe", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I made a variety of pastries, like croissants and muffins, following recipes carefully.", "I proofed the dough to make sure it was rising correctly before baking.", "I cleaned and maintained the baking equipment, like the mixers and ovens."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} -{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Bread Baker", "given_work_type": "Self-employment", "given_company": "The Daily Loaf", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I was in charge of making different types of bread, from sourdough to rye.", "I adjusted recipes depending on the day's humidity and temperature.", "I experimented with new bread recipes and flavor combinations."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} +{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Bakery Assistant", "given_work_type": "Formal sector/Unpaid trainee work", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} +{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Pastry Cook", "given_work_type": "Formal sector/Unpaid trainee work", "given_company": "Sweet Treats Cafe", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I made a variety of pastries, like croissants and muffins, following recipes carefully.", "I proofed the dough to make sure it was rising correctly before baking.", "I cleaned and maintained the baking equipment, like the mixers and ovens."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} +{"skip_force":"","name": "7512.1 baker", "given_experience_title": "Bread Baker", "given_work_type": "Formal sector/Unpaid trainee work", "given_company": "The Daily Loaf", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I was in charge of making different types of bread, from sourdough to rye.", "I adjusted recipes depending on the day's humidity and temperature.", "I experimented with new bread recipes and flavor combinations."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} {"skip_force":"","name": "I31_0 cooking meals", "given_experience_title": "Making Meals", "given_work_type": "Unpaid other", "given_company": "My household", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I cooked dinner most nights for my family.", "I prepared school lunches for my kids every weekday.", "I often heated up leftovers for a quick meal."], "expected_occupations_found": [{"code": "I31_0", "preferred_label": "cooking meals"}]} {"skip_force":"","name": "I31_0 cooking meals", "given_experience_title": "Cleaning Up After Meals", "given_work_type": "Unpaid other", "given_company": "My household", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I loaded and unloaded the dishwasher daily.", "I cleared the table after every meal.", "I washed the dishes that couldn't go in the dishwasher.", "I wiped down the kitchen counters and table after eating."], "expected_occupations_found": [{"code": "I31_0", "preferred_label": "cooking meals"}]} {"skip_force":"","name": "I31_0 cooking meals", "given_experience_title": "Food Prep and Storage", "given_work_type": "Unpaid other", "given_company": "My household", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I prepared coffee every morning.", "I sorted and stored groceries after shopping.", "I cleaned and prepped fruits and vegetables for meals and snacks."], "expected_occupations_found": [{"code": "I31_0", "preferred_label": "cooking meals"}]} @@ -157,5 +157,5 @@ {"skip_force":"","name": "I52_5 caring for and teaching children in the community", "given_experience_title": "Community Childcare Volunteer", "given_work_type": "Unpaid other", "given_company": "Local Community Center", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped prepare and serve snacks to kids at the local community center after school.", "I supervised kids during playtime at the park, making sure everyone was safe.", "I read stories to groups of children at the library during story hour.", "I helped kids with their homework at the after-school program."], "expected_occupations_found": [{"code": "I52_5", "preferred_label": "caring for and teaching children in the community"}]} {"skip_force":"","name": "I52_5 caring for and teaching children in the community", "given_experience_title": "After-School Tutor", "given_work_type": "Unpaid other", "given_company": "Boys & Girls Club", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I provided one-on-one tutoring to elementary school students in math and reading.", "I helped students with their homework assignments and explained concepts they were struggling with.", "I created fun learning activities to engage the students and make learning more enjoyable.", "I monitored student progress and provided feedback to parents."], "expected_occupations_found": [{"code": "I52_5", "preferred_label": "caring for and teaching children in the community"}]} {"skip_force":"","name": "I52_5 caring for and teaching children in the community", "given_experience_title": "Summer Camp Counselor", "given_work_type": "Unpaid other", "given_company": "YMCA", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I organized and led recreational activities for children at a summer day camp.", "I supervised children during swimming and outdoor games.", "I administered basic first aid to children as needed.", "I helped children with arts and crafts projects."], "expected_occupations_found": [{"code": "I52_5", "preferred_label": "caring for and teaching children in the community"}]} -{"skip_force":"","name": "7512.1 baker with empty title", "given_experience_title": "", "given_work_type": "Self-employment", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} -{"skip_force":"","name": "7512.1 baker with spaces in experience title", "given_experience_title": " ", "given_work_type": "Self-employment", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} +{"skip_force":"","name": "7512.1 baker with empty title", "given_experience_title": "", "given_work_type": "Formal sector/Unpaid trainee work", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} +{"skip_force":"","name": "7512.1 baker with spaces in experience title", "given_experience_title": " ", "given_work_type": "Formal sector/Unpaid trainee work", "given_company": "Acme Bakery", "given_country_of_interest": "Unspecified", "given_responsibilities": ["I helped unload deliveries and made sure all the flour, sugar, and other ingredients were stored properly.", "I prepped ingredients by measuring them out and getting them ready for the bakers.", "I mixed ingredients to make dough.", "I monitored the ovens to ensure the bread was baked at the right temperature and for the right amount of time."], "expected_occupations_found": [{"code": "7512.1", "preferred_label": "baker"}]} diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/test_occupation_inference_test_case.py b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/test_occupation_inference_test_case.py index 2d19c3b2..c4864be4 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/test_occupation_inference_test_case.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/infer_occupation_tool/test_occupation_inference_test_case.py @@ -32,7 +32,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Baker", given_experience_title="Baker", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="", given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread"], given_country_of_interest=Country.SOUTH_AFRICA, @@ -43,7 +43,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Baker at the limits of LLM response Context size ", given_experience_title="Baker", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="", given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread"], given_country_of_interest=Country.SOUTH_AFRICA, @@ -59,7 +59,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Title is not useful, infer from responsibilities", given_experience_title="Foo", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="", given_responsibilities=["I cook street food", "I sell food to the local community"], given_country_of_interest=Country.SOUTH_AFRICA, @@ -68,7 +68,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Title is misleading, infer from responsibilities", given_experience_title="I sell gully to the local community", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="", given_responsibilities=["I visit the Embassy every day", "I talk with the embassy staff", @@ -80,7 +80,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Infer from responsibilities", given_experience_title="GDE Brigade member", - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, given_company="Gauteng Department of Education", given_responsibilities=[ # https://search67.com/2021/07/19/careers-employment-opportunity-for-youth-as-c0vid-19-screeners/ @@ -96,7 +96,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Should not change title (emtpy responsibilities)", given_experience_title="Software Engineer", - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, given_company="Google", given_country_of_interest=Country.SOUTH_AFRICA, given_responsibilities=[], @@ -107,7 +107,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Infer from glossary (emtpy responsibilities)", given_experience_title="I sell kota to the local community", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="", given_country_of_interest=Country.SOUTH_AFRICA, given_responsibilities=[], @@ -116,7 +116,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Infer from glossary (emtpy responsibilities)", given_experience_title="I make bunny chow", - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, given_company="Hungry Lion", given_country_of_interest=Country.SOUTH_AFRICA, given_responsibilities=[], @@ -125,7 +125,7 @@ class InferOccupationToolTestCase(CompassTestCase): InferOccupationToolTestCase( name="Infer from glossary & company name (emtpy responsibilities)", given_experience_title="I make bunny chow", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_company="Hungry Tiger", given_country_of_interest=Country.SOUTH_AFRICA, given_responsibilities=[], diff --git a/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/skills_linking_tool_test.py b/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/skills_linking_tool_test.py index 51a73575..cb35f27f 100644 --- a/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/skills_linking_tool_test.py +++ b/backend/evaluation_tests/linking_and_ranking_pipeline/skill_linking/skills_linking_tool_test.py @@ -27,21 +27,21 @@ class SkillLinkingToolTestCase(CompassTestCase): SkillLinkingToolTestCase( name="Baker by code", given_occupation_code="7512.1", - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I bake bread", "I clean my work place", "I order supplies"], expected_skills=['bake goods', 'ensure sanitation'] ), SkillLinkingToolTestCase( name="Baker by title", given_occupation_title="Baker", - given_work_type=WorkType.SELF_EMPLOYMENT, + given_work_type=WorkType.UNSEEN_UNPAID, given_responsibilities=["I bake bread", "I clean my work place", "I order supplies", "I sell bread", "I talk to customers"], expected_skills=['bake goods', 'prepare bakery products', 'order supplies', 'ensure sanitation', 'maintain relationship with customers'] ), SkillLinkingToolTestCase( name="GDE Brigade member by title", given_occupation_title="GDE Brigade member", - given_work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + given_work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, given_responsibilities=["I make sure everyone follows the Covid-19 rules.", "I keep an eye on the kids to make sure they stay apart from each other.", "I check and record temperatures and other health signs.", diff --git a/backend/evaluation_tests/skill_explorer_agent/_conversation_llm_test.py b/backend/evaluation_tests/skill_explorer_agent/_conversation_llm_test.py index cdf988c4..1924c796 100644 --- a/backend/evaluation_tests/skill_explorer_agent/_conversation_llm_test.py +++ b/backend/evaluation_tests/skill_explorer_agent/_conversation_llm_test.py @@ -100,7 +100,7 @@ class _TestCaseConversation(CompassTestCase): ], experiences_explored=[], experience_title="Selling Kotas", - work_type=WorkType.SELF_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), _TestCaseConversation( country_of_user=Country.SOUTH_AFRICA, @@ -128,7 +128,7 @@ class _TestCaseConversation(CompassTestCase): "Vielen Dank, dass du deine Erfahrungen geteilt hast. Lassen Sie uns zum nächsten Schritt übergehen.")], experiences_explored=[], experience_title="Verkauf von Kotas", - work_type=WorkType.SELF_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), _TestCaseConversation( country_of_user=Country.SOUTH_AFRICA, @@ -186,7 +186,7 @@ class _TestCaseConversation(CompassTestCase): ], experiences_explored=[], experience_title="Asistente de ventas", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.UNSEEN_UNPAID ) ] diff --git a/backend/evaluation_tests/skill_explorer_agent/skills_explorer_test_cases.py b/backend/evaluation_tests/skill_explorer_agent/skills_explorer_test_cases.py index d9168b22..fd3314a3 100644 --- a/backend/evaluation_tests/skill_explorer_agent/skills_explorer_test_cases.py +++ b/backend/evaluation_tests/skill_explorer_agent/skills_explorer_test_cases.py @@ -70,7 +70,7 @@ def __init__(self, *, name: str, simulated_user_prompt: str, evaluations: list[E start="2018", end="2020" ), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), country_of_user=Country.UNSPECIFIED, expected_responsibilities=[], @@ -96,7 +96,7 @@ def __init__(self, *, name: str, simulated_user_prompt: str, evaluations: list[E start="2018", end="2020" ), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), country_of_user=Country.KENYA, expected_responsibilities=[] @@ -182,7 +182,7 @@ def __init__(self, *, name: str, simulated_user_prompt: str, evaluations: list[E start="2015", end="2022" ), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), country_of_user=Country.ARGENTINA, expected_responsibilities=['Limpiar el lugar', 'Manejar la guita', 'Tratar con proveedores', 'Contabilidad básica', 'Venta de productos', 'Empaquetar pedidos'], diff --git a/backend/scripts/export_conversation/sample_conversation.json b/backend/scripts/export_conversation/sample_conversation.json index 2f2ab539..51316808 100644 --- a/backend/scripts/export_conversation/sample_conversation.json +++ b/backend/scripts/export_conversation/sample_conversation.json @@ -26,7 +26,7 @@ "start": "2020/01", "end": "2023/08" }, - "work_type": "FORMAL_SECTOR_WAGED_EMPLOYMENT", + "work_type": "FORMAL_SECTOR_UNPAID_TRAINEE_WORK", "responsibilities": { "responsibilities": [ "I am done" @@ -131,12 +131,12 @@ "start_date": "2020/01", "end_date": "2023/08", "paid_work": true, - "work_type": "FORMAL_SECTOR_WAGED_EMPLOYMENT" + "work_type": "FORMAL_SECTOR_UNPAID_TRAINEE_WORK" } ], "unexplored_types": [], "explored_types": [ - "FORMAL_SECTOR_WAGED_EMPLOYMENT" + "FORMAL_SECTOR_UNPAID_TRAINEE_WORK" ], "first_time_visit": false }, diff --git a/backend/scripts/export_conversation/test_roundtrip.py b/backend/scripts/export_conversation/test_roundtrip.py index a7224074..516cc158 100644 --- a/backend/scripts/export_conversation/test_roundtrip.py +++ b/backend/scripts/export_conversation/test_roundtrip.py @@ -119,7 +119,7 @@ async def test_import_and_export_scripts(self, given_imported_import_session_id = new_user_preferences.sessions[0] - # AND the imported conversation is imported for the second time + # AND the imported conversation is imported for the second time (copy to new session, single-session replaces) successfully_imported_2 = await import_conversation( source_session_id=given_imported_import_session_id, source_type="DB", @@ -127,14 +127,13 @@ async def test_import_and_export_scripts(self, target_user_id=given_user_id ) - # AND the conversation is successfully for the second time. assert successfully_imported_2 new_user_preferences = await user_preferences_repository.get_user_preference_by_user_id(user_id=given_user_id) - # GUARD user_preferences.sessions.length should be now two - assert len(new_user_preferences.sessions) == 2 + assert len(new_user_preferences.sessions) == 1 + second_import_session_id = new_user_preferences.sessions[0] - # AND the second conversation is exported from first JSON to md. + # Phase 2: Exporting. await export_conversations( session_ids=[given_first_session_id], source_type="JSON", @@ -143,41 +142,26 @@ async def test_import_and_export_scripts(self, queue_size=1 ) - # Phase 2: Exporting. - # AND the 2nd imported conversation is exported into JSON await export_conversations( - session_ids=new_user_preferences.sessions, + session_ids=[second_import_session_id], source_type="DB", target_type="JSON", output_directory=given_output_directory, queue_size=1 ) - # AND the 2nd imported conversation is exported into markdown await export_conversations( - session_ids=new_user_preferences.sessions, + session_ids=[second_import_session_id], source_type="DB", target_type="MD", output_directory=given_output_directory, queue_size=1 ) - # Phase 3: assertions - - assert new_user_preferences.sessions[0] != new_user_preferences.sessions[1] - assert given_first_session_id != new_user_preferences.sessions[1] - - # Assert given_json = exported_json - _compare_jsons(given_output_directory, given_first_session_id, new_user_preferences.sessions[1]) - - # AND the first imported/exported json should match the second imported/exported json. - _compare_jsons(given_output_directory, new_user_preferences.sessions[0], new_user_preferences.sessions[1]) - - # Assert exported_md = exported_md_1 - _compare_markdowns(given_output_directory, given_first_session_id, new_user_preferences.sessions[1]) - - # AND the first imported/exported md should match the second imported/exported md. - _compare_markdowns(given_output_directory, new_user_preferences.sessions[0], new_user_preferences.sessions[1]) + # Phase 3: assertions - roundtrip: original JSON should match exported-from-DB JSON + assert given_first_session_id != second_import_session_id + _compare_jsons(given_output_directory, given_first_session_id, second_import_session_id) + _compare_markdowns(given_output_directory, given_first_session_id, second_import_session_id) # clean up created files shutil.rmtree(given_output_directory) diff --git a/backend/scripts/populate_sample_conversation.py b/backend/scripts/populate_sample_conversation.py index 28003f88..5e603377 100644 --- a/backend/scripts/populate_sample_conversation.py +++ b/backend/scripts/populate_sample_conversation.py @@ -186,7 +186,7 @@ def create_explore_experiences_director_state(_session_id: int) -> ExploreExperi experience_title="Baker at Sweet Delights Bakery", company="Sweet Delights Bakery", location="Cape Town", - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, responsibilities=ResponsibilitiesData( responsibilities=[ "Prepared a variety of breads, pastries, and desserts daily", @@ -245,7 +245,7 @@ def create_explore_experiences_director_state(_session_id: int) -> ExploreExperi experience_title="Freelance Cake Designer", company="Self-employed", location="Johannesburg", - work_type=WorkType.SELF_EMPLOYMENT, + work_type=WorkType.UNSEEN_UNPAID, responsibilities=ResponsibilitiesData( responsibilities=[ "Designed and created custom cakes for special events and weddings", @@ -337,7 +337,7 @@ def create_collect_experience_state(_session_id: int) -> CollectExperiencesAgent start_date="January 2019", end_date="December 2021", paid_work=True, - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT.name + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK.name ), CollectedData( index=1, @@ -348,7 +348,7 @@ def create_collect_experience_state(_session_id: int) -> CollectExperiencesAgent start_date="January 2022", end_date="Present", paid_work=True, - work_type=WorkType.SELF_EMPLOYMENT.name + work_type=WorkType.UNSEEN_UNPAID.name ) ] @@ -356,7 +356,7 @@ def create_collect_experience_state(_session_id: int) -> CollectExperiencesAgent session_id=_session_id, collected_data=collected_data, unexplored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], - explored_types=[WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkType.SELF_EMPLOYMENT], + explored_types=[WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WorkType.UNSEEN_UNPAID], first_time_visit=False ) diff --git a/backend/scripts/test_preference_agent_interactive.py b/backend/scripts/test_preference_agent_interactive.py index e6a85ad9..ec03552d 100755 --- a/backend/scripts/test_preference_agent_interactive.py +++ b/backend/scripts/test_preference_agent_interactive.py @@ -185,7 +185,7 @@ def create_sample_experiences() -> List[ExperienceEntity]: company="Alliance High School", location="Kikuyu", timeline=Timeline(start="2018", end="2023"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ), ExperienceEntity( uuid="exp-2", @@ -193,7 +193,7 @@ def create_sample_experiences() -> List[ExperienceEntity]: company="Self-employed", location="Nairobi", timeline=Timeline(start="2023", end="Present"), - work_type=WorkType.SELF_EMPLOYMENT + work_type=WorkType.UNSEEN_UNPAID ), ExperienceEntity( uuid="exp-3", @@ -201,7 +201,7 @@ def create_sample_experiences() -> List[ExperienceEntity]: company="Mang'u High School", location="Thika", timeline=Timeline(start="2017", end="2017"), - work_type=WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT + work_type=WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK ) ] diff --git a/frontend-new/src/chat/Chat.test.tsx b/frontend-new/src/chat/Chat.test.tsx index 1c3c22f0..2750671a 100644 --- a/frontend-new/src/chat/Chat.test.tsx +++ b/frontend-new/src/chat/Chat.test.tsx @@ -1752,7 +1752,7 @@ describe("Chat", () => { experience_title: "Experience 1", company: "Company 1", location: "Location 1", - work_type: WorkType.SELF_EMPLOYMENT, + work_type: WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, top_skills: [], }, ]; diff --git a/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx b/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx index c45d9e81..8e94528e 100644 --- a/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx +++ b/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx @@ -287,6 +287,7 @@ describe("ChatHeader", () => { async (_description, browserIsOnline) => { mockBrowserIsOnLine(browserIsOnline); // GIVEN a ChatHeader component + const givenNotifyOnLogout = jest.fn(); const givenChatHeader = ( = ({ {/* EXPERIENCES */} {!isLoading && ( - } - title={ReportContent.SELF_EMPLOYMENT_TITLE} - experiences={groupedExperiences.selfEmploymentExperiences} - onEditExperience={handleEditExperience} - onDeleteExperience={handleDeleteExperience} - onRestoreToOriginalExperience={handleRequestRestoreToOriginalExperience} - /> - } - title={ReportContent.SALARY_WORK_TITLE} - experiences={groupedExperiences.salaryWorkExperiences} - onEditExperience={handleEditExperience} - onDeleteExperience={handleDeleteExperience} - onRestoreToOriginalExperience={handleRequestRestoreToOriginalExperience} - /> } title={ReportContent.UNPAID_WORK_TITLE} diff --git a/frontend-new/src/experiences/experiencesDrawer/__snapshots__/ExperiencesDrawer.test.tsx.snap b/frontend-new/src/experiences/experiencesDrawer/__snapshots__/ExperiencesDrawer.test.tsx.snap index 2361f9c5..562c1c6c 100644 --- a/frontend-new/src/experiences/experiencesDrawer/__snapshots__/ExperiencesDrawer.test.tsx.snap +++ b/frontend-new/src/experiences/experiencesDrawer/__snapshots__/ExperiencesDrawer.test.tsx.snap @@ -210,18 +210,18 @@ exports[`ExperiencesDrawer should render ExperiencesDrawer correctly 1`] = `
- Salary Work + Unpaid Work
{ const notifyOnUnsavedChange = jest.fn(); const notifyOnSave = jest.fn(); // AND the ExperienceService is mocked to return the first mock experience + const experienceWithUnpaidWork = { ...mockExperiences[0], work_type: WorkType.UNSEEN_UNPAID }; const mockUpdateExperience = { - ...mockExperiences[0], - work_type: WorkType.SELF_EMPLOYMENT, + ...experienceWithUnpaidWork, + work_type: WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, }; jest.spyOn(ExperienceService.getInstance(), "updateExperience").mockResolvedValueOnce(mockUpdateExperience); const givenExperienceEditForm = ( { expect(ContextMenu).toHaveBeenCalledWith( expect.objectContaining({ open: true, - items: expect.arrayContaining([expect.objectContaining({ id: WorkType.SELF_EMPLOYMENT })]), + items: expect.arrayContaining([expect.objectContaining({ id: WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK })]), }), expect.anything() ); - // WHEN a menu item is clicked (Self Employment) - const selfEmploymentItem = screen.getByTestId(WorkType.SELF_EMPLOYMENT); - await userEvent.click(selfEmploymentItem); + // WHEN a menu item is clicked (Trainee Work) + const traineeWorkItem = screen.getByTestId(WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK); + await userEvent.click(traineeWorkItem); // THEN the work type text should be updated const workTypeElement = screen.getByTestId(DATA_TEST_ID.FORM_WORK_TYPE); - expect(workTypeElement).toHaveTextContent(/self-employment/i); + expect(workTypeElement).toHaveTextContent(/trainee/i); // AND notifyOnUnsavedChange should have been called expect(notifyOnUnsavedChange).toHaveBeenCalledWith(true); @@ -215,7 +216,7 @@ describe("ExperienceEditForm", () => { // THEN the notifyOnSave should have been called with updated experience expect(notifyOnSave).toHaveBeenCalled(); const savedExperience = notifyOnSave.mock.calls[0][0]; - expect(savedExperience.work_type).toBe(WorkType.SELF_EMPLOYMENT); + expect(savedExperience.work_type).toBe(WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK); // AND no errors or warning to have occurred expect(console.error).not.toHaveBeenCalled(); expect(console.warn).not.toHaveBeenCalled(); diff --git a/frontend-new/src/experiences/experiencesDrawer/components/experienceEditForm/__snapshots__/ExperienceEditForm.test.tsx.snap b/frontend-new/src/experiences/experiencesDrawer/components/experienceEditForm/__snapshots__/ExperienceEditForm.test.tsx.snap index 9571c39a..908de8b4 100644 --- a/frontend-new/src/experiences/experiencesDrawer/components/experienceEditForm/__snapshots__/ExperienceEditForm.test.tsx.snap +++ b/frontend-new/src/experiences/experiencesDrawer/components/experienceEditForm/__snapshots__/ExperienceEditForm.test.tsx.snap @@ -85,18 +85,18 @@ exports[`ExperienceEditForm should render ExperienceEditForm correctly 1`] = `

- Salary Work + Unpaid Work

-
- Self-Employment -
-
- Salary Work -
diff --git a/frontend-new/src/experiences/experiencesDrawer/util.test.tsx b/frontend-new/src/experiences/experiencesDrawer/util.test.tsx index ea900086..14ce7ab9 100644 --- a/frontend-new/src/experiences/experiencesDrawer/util.test.tsx +++ b/frontend-new/src/experiences/experiencesDrawer/util.test.tsx @@ -18,8 +18,6 @@ import { WORK_TYPE_DESCRIPTIONS, } from "src/experiences/experiencesDrawer/util"; import { ReportContent } from "src/experiences/report/reportContent"; -import StoreIcon from "@mui/icons-material/Store"; -import WorkIcon from "@mui/icons-material/Work"; import VolunteerActivismIcon from "@mui/icons-material/VolunteerActivism"; import SchoolIcon from "@mui/icons-material/School"; import QuizIcon from "@mui/icons-material/Quiz"; @@ -27,9 +25,6 @@ import { mockExperiences } from "src/experiences/experienceService/_test_utiliti describe("experiencesDrawer util", () => { test.each([ - // GIVEN a work type and its expected title - [WorkType.SELF_EMPLOYMENT, ReportContent.SELF_EMPLOYMENT_TITLE], - [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, ReportContent.SALARY_WORK_TITLE], [WorkType.UNSEEN_UNPAID, ReportContent.UNPAID_WORK_TITLE], [WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, ReportContent.TRAINEE_WORK_TITLE], [null, ReportContent.UNCATEGORIZED_TITLE], @@ -40,9 +35,6 @@ describe("experiencesDrawer util", () => { }); test.each([ - // GIVEN a work type and its expected description - [WorkType.SELF_EMPLOYMENT, WORK_TYPE_DESCRIPTIONS.SELF_EMPLOYMENT], - [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WORK_TYPE_DESCRIPTIONS.FORMAL_SECTOR_WAGED_EMPLOYMENT], [WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, WORK_TYPE_DESCRIPTIONS.FORMAL_SECTOR_UNPAID_TRAINEE_WORK], [WorkType.UNSEEN_UNPAID, WORK_TYPE_DESCRIPTIONS.UNSEEN_UNPAID], [null, WORK_TYPE_DESCRIPTIONS.UNCATEGORIZED], @@ -53,9 +45,6 @@ describe("experiencesDrawer util", () => { }); test.each([ - // GIVEN a work type and its expected icon - [WorkType.SELF_EMPLOYMENT, StoreIcon], - [WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, WorkIcon], [WorkType.UNSEEN_UNPAID, VolunteerActivismIcon], [WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, SchoolIcon], [null, QuizIcon], @@ -91,7 +80,7 @@ describe("experiencesDrawer util", () => { }, top_skills: [], remaining_skills: [], - work_type: WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT, + work_type: WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, exploration_phase: DiveInPhase.PROCESSED, }; @@ -124,7 +113,6 @@ describe("experiencesDrawer util", () => { }); test("should return object with changed fields only", () => { - // GIVEN an original experience and a modified current experience const original: Experience = mockExperiences[0]; const current: Experience = { ...original, @@ -132,7 +120,7 @@ describe("experiencesDrawer util", () => { company: "Bar Company", location: "Baz Location", summary: "Foo Summary", - work_type: WorkType.SELF_EMPLOYMENT, + work_type: WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, timeline: { start: original.timeline.start, end: "December 2023", @@ -157,7 +145,7 @@ describe("experiencesDrawer util", () => { company: current.company, location: current.location, summary: current.summary, - work_type: WorkType.SELF_EMPLOYMENT, + work_type: WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK, timeline: { start: current.timeline.start, end: current.timeline.end, diff --git a/frontend-new/src/experiences/experiencesDrawer/util.tsx b/frontend-new/src/experiences/experiencesDrawer/util.tsx index d9fffe6d..d80afc11 100644 --- a/frontend-new/src/experiences/experiencesDrawer/util.tsx +++ b/frontend-new/src/experiences/experiencesDrawer/util.tsx @@ -11,8 +11,6 @@ import { } from "src/experiences/experienceService/experiences.types"; import { ReportContent } from "src/experiences/report/reportContent"; import type { SvgIconProps } from "@mui/material/SvgIcon"; -import StoreIcon from "@mui/icons-material/Store"; -import WorkIcon from "@mui/icons-material/Work"; import VolunteerActivismIcon from "@mui/icons-material/VolunteerActivism"; import SchoolIcon from "@mui/icons-material/School"; import QuizIcon from "@mui/icons-material/Quiz"; @@ -45,12 +43,6 @@ export const ERROR_MESSAGES = { } as const; export const WORK_TYPE_DESCRIPTIONS = { - get SELF_EMPLOYMENT() { - return i18n.t("experiences.experiencesDrawer.util.workTypeDescription.selfEmployment"); - }, - get FORMAL_SECTOR_WAGED_EMPLOYMENT() { - return i18n.t("experiences.experiencesDrawer.util.workTypeDescription.formalSectorWagedEmployment"); - }, get FORMAL_SECTOR_UNPAID_TRAINEE_WORK() { return i18n.t("experiences.experiencesDrawer.util.workTypeDescription.formalSectorUnpaidTraineeWork"); }, @@ -64,10 +56,6 @@ export const WORK_TYPE_DESCRIPTIONS = { export const getWorkTypeTitle = (workType: WorkType | null) => { switch (workType) { - case WorkType.SELF_EMPLOYMENT: - return ReportContent.SELF_EMPLOYMENT_TITLE; - case WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return ReportContent.SALARY_WORK_TITLE; case WorkType.UNSEEN_UNPAID: return ReportContent.UNPAID_WORK_TITLE; case WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: @@ -79,10 +67,6 @@ export const getWorkTypeTitle = (workType: WorkType | null) => { export const getWorkTypeDescription = (workType: WorkType | null) => { switch (workType) { - case WorkType.SELF_EMPLOYMENT: - return WORK_TYPE_DESCRIPTIONS.SELF_EMPLOYMENT; - case WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return WORK_TYPE_DESCRIPTIONS.FORMAL_SECTOR_WAGED_EMPLOYMENT; case WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: return WORK_TYPE_DESCRIPTIONS.FORMAL_SECTOR_UNPAID_TRAINEE_WORK; case WorkType.UNSEEN_UNPAID: @@ -94,10 +78,6 @@ export const getWorkTypeDescription = (workType: WorkType | null) => { export const getWorkTypeIcon = (workType: WorkType | null, iconProps?: SvgIconProps): JSX.Element => { switch (workType) { - case WorkType.SELF_EMPLOYMENT: - return ; - case WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT: - return ; case WorkType.UNSEEN_UNPAID: return ; case WorkType.FORMAL_SECTOR_UNPAID_TRAINEE_WORK: diff --git a/frontend-new/src/experiences/report/reportDocx/components/ConstructExperienceList.tsx b/frontend-new/src/experiences/report/reportDocx/components/ConstructExperienceList.tsx index 505c9079..031ad000 100644 --- a/frontend-new/src/experiences/report/reportDocx/components/ConstructExperienceList.tsx +++ b/frontend-new/src/experiences/report/reportDocx/components/ConstructExperienceList.tsx @@ -74,30 +74,8 @@ export const constructExperienceList = async ( ) => { constructExperiencesSectionHeader(paragraphs); // Group experiences by work type - const { - selfEmploymentExperiences, - salaryWorkExperiences, - unpaidWorkExperiences, - traineeWorkExperiences, - uncategorizedExperiences, - } = groupExperiencesByWorkType(experiences); - - // Add work sections for each work type - await constructWorkTypeSection( - paragraphs, - selfEmploymentExperiences, - ReportContent.SELF_EMPLOYMENT_TITLE, - ReportContent.IMAGE_URLS.SELF_EMPLOYMENT_ICON, - reportConfig - ); - - await constructWorkTypeSection( - paragraphs, - salaryWorkExperiences, - ReportContent.SALARY_WORK_TITLE, - ReportContent.IMAGE_URLS.EMPLOYEE_ICON, - reportConfig - ); + const { unpaidWorkExperiences, traineeWorkExperiences, uncategorizedExperiences } = + groupExperiencesByWorkType(experiences); await constructWorkTypeSection( paragraphs, diff --git a/frontend-new/src/experiences/report/reportPdf/SkillReportPDF.tsx b/frontend-new/src/experiences/report/reportPdf/SkillReportPDF.tsx index 98a6dd3e..2b5d43af 100644 --- a/frontend-new/src/experiences/report/reportPdf/SkillReportPDF.tsx +++ b/frontend-new/src/experiences/report/reportPdf/SkillReportPDF.tsx @@ -50,13 +50,8 @@ const SkillReportPDF: React.FC = ({ config, }) => { // Group experiences by work type - const { - selfEmploymentExperiences, - salaryWorkExperiences, - unpaidWorkExperiences, - traineeWorkExperiences, - uncategorizedExperiences, - } = groupExperiencesByWorkType(experiences); + const { unpaidWorkExperiences, traineeWorkExperiences, uncategorizedExperiences } = + groupExperiencesByWorkType(experiences); // list of all unique skills const skillsList = getUniqueSkills(experiences); @@ -133,18 +128,6 @@ const SkillReportPDF: React.FC = ({ {ReportContent.EXPERIENCES_TITLE} - {ExperienceCategory( - ReportContent.SELF_EMPLOYMENT_TITLE, - ReportContent.IMAGE_URLS.SELF_EMPLOYMENT_ICON, - selfEmploymentExperiences - )} - - {ExperienceCategory( - ReportContent.SALARY_WORK_TITLE, - ReportContent.IMAGE_URLS.EMPLOYEE_ICON, - salaryWorkExperiences - )} - {ExperienceCategory( ReportContent.UNPAID_WORK_TITLE, ReportContent.IMAGE_URLS.COMMUNITY_WORK_ICON, diff --git a/frontend-new/src/experiences/report/reportPdf/__snapshots__/SkillReportPDF.test.tsx.snap b/frontend-new/src/experiences/report/reportPdf/__snapshots__/SkillReportPDF.test.tsx.snap index 05f6939a..bd4f73f5 100644 --- a/frontend-new/src/experiences/report/reportPdf/__snapshots__/SkillReportPDF.test.tsx.snap +++ b/frontend-new/src/experiences/report/reportPdf/__snapshots__/SkillReportPDF.test.tsx.snap @@ -141,7 +141,7 @@ exports[`Report should render Report correctly 1`] = ` x="0" y="0" > - Salary Work + Unpaid Work { // Utility function to group experiences by work type export const groupExperiencesByWorkType = (experiences: Experience[]) => { - const selfEmploymentExperiences = experiences.filter( - (experience) => experience.work_type === WorkType.SELF_EMPLOYMENT - ); - - const salaryWorkExperiences = experiences.filter( - (experience) => experience.work_type === WorkType.FORMAL_SECTOR_WAGED_EMPLOYMENT - ); - const unpaidWorkExperiences = experiences.filter((experience) => experience.work_type === WorkType.UNSEEN_UNPAID); const traineeWorkExperiences = experiences.filter( @@ -56,8 +48,6 @@ export const groupExperiencesByWorkType = (experiences: Experience[]) => { const uncategorizedExperiences = experiences.filter((experience) => experience.work_type === null); return { - selfEmploymentExperiences, - salaryWorkExperiences, unpaidWorkExperiences, traineeWorkExperiences, uncategorizedExperiences, From 1d47af7b8f7090fe2db34433f76ccd2bc6f818b2 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Wed, 4 Mar 2026 08:16:16 +0300 Subject: [PATCH 12/42] feat(backend): first experience collection message mentioned paid jobs, fix instructions to not mention work types --- .../collect_experiences_agent/_conversation_llm.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/app/agent/collect_experiences_agent/_conversation_llm.py b/backend/app/agent/collect_experiences_agent/_conversation_llm.py index 62d7f43c..8a9c5bd2 100644 --- a/backend/app/agent/collect_experiences_agent/_conversation_llm.py +++ b/backend/app/agent/collect_experiences_agent/_conversation_llm.py @@ -357,7 +357,7 @@ def _get_system_instructions(*, You should ONLY provide a recap of all experiences when we have finished exploring ALL work types. Do NOT provide summaries or recaps before all work types have been explored. Do NOT say things like "We've now gone through the different types" or "Here's what we have so far" - until we have finished exploring all four work types. + until we have finished exploring all work types. #Do not repeat information unnecessarily Review your previous questions and my answers. Avoid repeating the same question or restating collected details. @@ -389,7 +389,7 @@ def _get_system_instructions(*, Do NOT ask me to confirm or review each individual experience. Simply move on to asking if I have more experiences of the current type. When you have finished collecting information for an experience and there are no incomplete experiences remaining for the current work type, - ask me: "Do you have any other [work type description] experiences?" (e.g., "Do you have any other paid employment experiences?") + ask me: "Do you have any other [work type description] experiences?" (e.g., "Do you have any other unpaid trainee work experiences?") Only at the very end, after we have explored all work types, will you provide a recap of all experiences and ask if I want to add or change anything. @@ -490,6 +490,7 @@ def _get_first_time_generative_prompt(*, # Ideally, we want to include the language style in the prompt. # However, doing so seems to break the prompt. # We include it to ensure the model sticks to the conversation language. + experience_type_description = _get_experience_type(exploring_type) first_time_generative_prompt = dedent("""\ #Role You are a counselor working for an employment agency helping me, a young person{country_of_user_segment}, @@ -500,7 +501,7 @@ def _get_first_time_generative_prompt(*, {persona_guidance} Respond with something similar to this: - Explain that during this step you will only gather basic information about all my work experiences, + Explain that during this step you will only gather basic information about {experience_type_description}, later we will move to the next step and explore each work experience separately in detail. Add new line to separate explanation from the question. @@ -511,6 +512,7 @@ def _get_first_time_generative_prompt(*, country_of_user_segment=_get_country_of_user_segment(country_of_user), language_style=get_language_style(), persona_guidance=get_persona_prompt_section(persona_type), + experience_type_description=experience_type_description, question_to_ask=_ask_experience_type_question(exploring_type)) @@ -671,7 +673,7 @@ def _get_explore_experiences_instructions(*, For each work experience, ask me questions to gather information following the '#Gather Details' instructions. When you have finished collecting all required information for an experience and there are no incomplete experiences remaining, - ask me: "Do you have any other {experiences_in_type}?" (e.g., "Do you have any other paid employment experiences?") + ask me: "Do you have any other {experiences_in_type}?" (e.g., "Do you have any other unpaid trainee work experiences?") Do NOT ask me to confirm or review each individual experience. Simply ask if I have more experiences of this type. Only move to the next work type after I have explicitly stated I have no more experiences of the current type. From 666cad2fe41e8c80b6c227eabb719f2ac1a2ed71 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Wed, 4 Mar 2026 08:37:09 +0300 Subject: [PATCH 13/42] feat(backend): question at the end of the preference elicitation wrapup no longer makes sense in the absence of recommendation --- backend/app/agent/preference_elicitation_agent/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/agent/preference_elicitation_agent/agent.py b/backend/app/agent/preference_elicitation_agent/agent.py index a7b4d56d..7097d81b 100644 --- a/backend/app/agent/preference_elicitation_agent/agent.py +++ b/backend/app/agent/preference_elicitation_agent/agent.py @@ -1282,7 +1282,7 @@ async def _handle_wrapup_phase( {summary} - This will help me suggest opportunities that are a good fit for you. Does this sound about right?""" + Thank you for sharing your preferences with me.""" response = ConversationResponse( reasoning="Wrapping up preference elicitation with summary", From 34e75ac60d0037a161d0fd008d2532bfd76a1367 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Wed, 4 Mar 2026 08:51:48 +0300 Subject: [PATCH 14/42] feat(backend): final skill elicitation message should transition smoothly to preference elicitation --- .../agent_director/llm_agent_director.py | 26 ++++++++++++------- .../explore_experiences_agent_director.py | 7 ++++- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/backend/app/agent/agent_director/llm_agent_director.py b/backend/app/agent/agent_director/llm_agent_director.py index f2c624d0..5e124037 100644 --- a/backend/app/agent/agent_director/llm_agent_director.py +++ b/backend/app/agent/agent_director/llm_agent_director.py @@ -166,14 +166,18 @@ def _get_new_phase(self, agent_output: AgentOutput) -> ConversationPhase: and agent_output.finished): return ConversationPhase.COUNSELING - # In the consulting phase, Explore Experiences or Preference Elicitation agents can end the phase. - # Matching and recommendation are detached; conversation flow ends at preference elicitation. if (current_phase == ConversationPhase.COUNSELING and agent_output.finished - and agent_output.agent_type in ( - AgentType.EXPLORE_EXPERIENCES_AGENT, - AgentType.PREFERENCE_ELICITATION_AGENT, - )): + and agent_output.agent_type == AgentType.EXPLORE_EXPERIENCES_AGENT): + self._state.counseling_sub_phase = CounselingSubPhase.PREFERENCE_ELICITATION + self._logger.info( + "ExploreExperiencesAgent finished, advancing to PREFERENCE_ELICITATION sub-phase" + ) + return ConversationPhase.COUNSELING + + if (current_phase == ConversationPhase.COUNSELING + and agent_output.finished + and agent_output.agent_type == AgentType.PREFERENCE_ELICITATION_AGENT): return ConversationPhase.CHECKOUT if (current_phase == ConversationPhase.CHECKOUT @@ -254,7 +258,7 @@ async def execute(self, user_input: AgentInput) -> AgentOutput: self._state.skip_to_phase = None self._logger.info("Step-skip: target agent finished, clearing skip_to_phase") - # Update the conversation phase + # Update the conversation phase (and counseling sub-phase when applicable) new_phase = self._get_new_phase(agent_output) self._logger.debug("Transitioned phase from %s --to-> %s", self._state.current_phase, new_phase) @@ -263,9 +267,13 @@ async def execute(self, user_input: AgentInput) -> AgentOutput: self._state.current_phase = new_phase phase_ctx_var.set(new_phase.value if new_phase else ":none:") + if (agent_output.finished + and agent_output.agent_type == AgentType.EXPLORE_EXPERIENCES_AGENT + and self._state.counseling_sub_phase == CounselingSubPhase.PREFERENCE_ELICITATION): + transitioned_to_new_phase = True + user_input = AgentInput(message="", is_artificial=True) + elif transitioned_to_new_phase: if get_application_config().inline_phase_transition: - # Inline transition: skip the (silence) loop re-invocation. - # The next user message will be routed deterministically via sub-phase. transitioned_to_new_phase = False else: user_input = AgentInput( diff --git a/backend/app/agent/explore_experiences_agent_director.py b/backend/app/agent/explore_experiences_agent_director.py index fe62f9f0..02bff3e7 100644 --- a/backend/app/agent/explore_experiences_agent_director.py +++ b/backend/app/agent/explore_experiences_agent_director.py @@ -276,13 +276,18 @@ async def _dive_into_experiences(self, *, # then we are done _next_experience = _pick_next_experience_to_process(state.experiences_state) if not _next_experience: - return AgentOutput( + transition_message = AgentOutput( message_for_user=t("messages", "exploreExperiences.transitionToPreferences"), finished=True, agent_type=self._agent_type, agent_response_time_in_sec=0, llm_stats=[] ) + await self._conversation_manager.update_history( + AgentInput(message="", is_artificial=True), + transition_message, + ) + return transition_message # Otherwise, we have more experiences to process return await self._dive_into_experiences(user_input=user_input, context=context, state=state) From da066efb0db6f56665e0a7f8a1643b01363f6a36 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Wed, 4 Mar 2026 09:02:21 +0300 Subject: [PATCH 15/42] feat(backend): update thinking messages for improved clarity in multiple languages - add back to dashboard button on chat - lint + format --- .../_conversation_llm.py | 4 ++- frontend-new/src/chat/Chat.tsx | 27 +++++++++++++++---- .../chat/ChatHeader/ChatHeader.stories.tsx | 7 ++++- .../src/chat/ChatHeader/ChatHeader.test.tsx | 15 ----------- .../src/chat/ChatHeader/ChatHeader.tsx | 2 -- .../src/i18n/locales/en-US/translation.json | 2 +- .../src/i18n/locales/es-AR/translation.json | 2 +- .../src/i18n/locales/es-ES/translation.json | 2 +- .../src/i18n/locales/ny-ZM/translation.json | 2 +- .../src/i18n/locales/sw-KE/translation.json | 2 +- 10 files changed, 36 insertions(+), 29 deletions(-) diff --git a/backend/app/agent/collect_experiences_agent/_conversation_llm.py b/backend/app/agent/collect_experiences_agent/_conversation_llm.py index 8a9c5bd2..43a5e281 100644 --- a/backend/app/agent/collect_experiences_agent/_conversation_llm.py +++ b/backend/app/agent/collect_experiences_agent/_conversation_llm.py @@ -506,7 +506,9 @@ def _get_first_time_generative_prompt(*, Add new line to separate explanation from the question. - {question_to_ask}. + {question_to_ask}. + + Do NOT mention paid work, paid jobs, formal employment, or self-employment. We only collect unpaid experiences. """) return replace_placeholders_with_indent(first_time_generative_prompt, country_of_user_segment=_get_country_of_user_segment(country_of_user), diff --git a/frontend-new/src/chat/Chat.tsx b/frontend-new/src/chat/Chat.tsx index eb2c643f..8666ef0d 100644 --- a/frontend-new/src/chat/Chat.tsx +++ b/frontend-new/src/chat/Chat.tsx @@ -1,5 +1,6 @@ -import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState, startTransition } from "react"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; import ChatService from "src/chat/ChatService/ChatService"; import ChatList from "src/chat/chatList/ChatList"; import { IChatMessage } from "src/chat/Chat.types"; @@ -109,6 +110,7 @@ export const Chat: React.FC> = ({ }) => { const theme = useTheme(); const { t } = useTranslation(); + const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const navigate = useNavigate(); const [messages, setMessages] = useState[]>([]); @@ -1019,13 +1021,28 @@ export const Chat: React.FC> = ({ paddingTop: theme.fixedSpacing(theme.tabiyaSpacing.sm), paddingBottom: theme.fixedSpacing(theme.tabiyaSpacing.md), paddingX: theme.spacing(theme.tabiyaSpacing.md), - display: "flex", - alignItems: "center", width: { xs: "100%", md: "60%" }, margin: { xs: "0", md: "0 auto" }, - gap: theme.spacing(theme.tabiyaSpacing.sm), }} > + + { + startTransition(() => { + navigate(routerPaths.ROOT); + }); + }} + labelKey="knowledgeHub.backToDashboard" + dataTestId="chat-back-button" + /> + + > = ({ > = ({ /> + diff --git a/frontend-new/src/chat/ChatHeader/ChatHeader.stories.tsx b/frontend-new/src/chat/ChatHeader/ChatHeader.stories.tsx index 927c9b2f..c783a826 100644 --- a/frontend-new/src/chat/ChatHeader/ChatHeader.stories.tsx +++ b/frontend-new/src/chat/ChatHeader/ChatHeader.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import ChatHeader from "src/chat/ChatHeader/ChatHeader"; +import { ConversationPhase } from "src/chat/chatProgressbar/types"; import { mockExperiences } from "src/experiences/experienceService/_test_utilities/mockExperiencesResponses"; import authenticationStateService from "src/auth/services/AuthenticationState.service"; @@ -8,9 +9,13 @@ const meta: Meta = { title: "Chat/ChatHeader", component: ChatHeader, tags: ["autodocs"], + args: { + setExploredExperiencesNotification: () => {}, + conversationPhase: ConversationPhase.INTRO, + collectedExperiences: 0, + }, argTypes: { setExploredExperiencesNotification: { action: "setExploredExperiencesNotification" }, - startNewConversation: { action: "startNewConversation" }, }, decorators: [ (Story) => { diff --git a/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx b/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx index 8e94528e..e004f973 100644 --- a/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx +++ b/frontend-new/src/chat/ChatHeader/ChatHeader.test.tsx @@ -82,11 +82,9 @@ describe("ChatHeader", () => { ["exploredExperiencesNotification not shown", false], ])("should render the Chat Header with %s", (desc, givenExploredExperiencesNotification) => { // GIVEN a ChatHeader component - const givenNotifyOnLogout = jest.fn(); const givenNumberOfExploredExperiences = 1; const givenChatHeader = ( { }); describe("chatHeader action tests", () => { - const givenStartNewConversation = jest.fn(); - test("should call handleOpenExperiencesDrawer when the view experiences button is clicked", async () => { // GIVEN a ChatHeader component const givenNotifyOnExperiencesDrawerOpen = jest.fn(); const givenChatHeader = ( { const metricsSpy = jest.spyOn(MetricsService.getInstance(), "sendMetricsEvent").mockReturnValue(); const givenChatHeader = ( { // WHEN the component is rendered const givenChatHeader = ( { const setExploredExperiencesNotification = jest.fn(); const givenChatHeader = ( { async (_description, browserIsOnline) => { mockBrowserIsOnLine(browserIsOnline); // GIVEN a ChatHeader component - const givenNotifyOnLogout = jest.fn(); const givenChatHeader = ( { // WHEN the component is rendered renderWithChatProvider( { // WHEN the component is rendered renderWithChatProvider( { // WHEN the feedback reminder is shown immediately renderWithChatProvider( { // WHEN the component is rendered renderWithChatProvider( { // WHEN the component is rendered renderWithChatProvider( void; experiencesExplored: number; exploredExperiencesNotification: boolean; setExploredExperiencesNotification: React.Dispatch>; @@ -42,7 +41,6 @@ export const MENU_ITEM_ID = { }; const ChatHeader: React.FC> = ({ - notifyOnLogout, experiencesExplored, exploredExperiencesNotification, setExploredExperiencesNotification, diff --git a/frontend-new/src/i18n/locales/en-US/translation.json b/frontend-new/src/i18n/locales/en-US/translation.json index f9cd6ea0..a876337f 100644 --- a/frontend-new/src/i18n/locales/en-US/translation.json +++ b/frontend-new/src/i18n/locales/en-US/translation.json @@ -163,7 +163,7 @@ "chatMessage": { "typingChatMessage": { "typing": "Typing", - "thinking": "Finding skills related to this experience" + "thinking": "Please wait, I'm thinking" }, "conversationConclusionFooter": { "youCanNow": "You can now", diff --git a/frontend-new/src/i18n/locales/es-AR/translation.json b/frontend-new/src/i18n/locales/es-AR/translation.json index cc03f339..c93bfed5 100644 --- a/frontend-new/src/i18n/locales/es-AR/translation.json +++ b/frontend-new/src/i18n/locales/es-AR/translation.json @@ -121,7 +121,7 @@ "chatMessage": { "typingChatMessage": { "typing": "Escribiendo", - "thinking": "La IA está pensando, por favor esperá" + "thinking": "Por favor espera, estoy pensando" }, "conversationConclusionFooter": { "youCanNow": "Ahora puedes", diff --git a/frontend-new/src/i18n/locales/es-ES/translation.json b/frontend-new/src/i18n/locales/es-ES/translation.json index f9c74261..34d9840e 100644 --- a/frontend-new/src/i18n/locales/es-ES/translation.json +++ b/frontend-new/src/i18n/locales/es-ES/translation.json @@ -121,7 +121,7 @@ "chatMessage": { "typingChatMessage": { "typing": "Escribiendo", - "thinking": "La IA está pensando, por favor espera" + "thinking": "Por favor, espera, esto podría tomar un momento" }, "conversationConclusionFooter": { "youCanNow": "Ahora puedes", diff --git a/frontend-new/src/i18n/locales/ny-ZM/translation.json b/frontend-new/src/i18n/locales/ny-ZM/translation.json index 79bbd5cb..fa83a1b0 100644 --- a/frontend-new/src/i18n/locales/ny-ZM/translation.json +++ b/frontend-new/src/i18n/locales/ny-ZM/translation.json @@ -163,7 +163,7 @@ "chatMessage": { "typingChatMessage": { "typing": "Kulemba", - "thinking": "Kufufuza nzeru zokhudzana ndi chitukukochi" + "thinking": "Chonde dikirani, ndikuganiza" }, "conversationConclusionFooter": { "youCanNow": "Tsopano mungathe", diff --git a/frontend-new/src/i18n/locales/sw-KE/translation.json b/frontend-new/src/i18n/locales/sw-KE/translation.json index 1bc5dc88..e95d4fb3 100644 --- a/frontend-new/src/i18n/locales/sw-KE/translation.json +++ b/frontend-new/src/i18n/locales/sw-KE/translation.json @@ -163,7 +163,7 @@ "chatMessage": { "typingChatMessage": { "typing": "Inaandika", - "thinking": "Inatafuta ujuzi unaohusiana na uzoefu huu" + "thinking": "Tafadhali subiri, nafikiri" }, "conversationConclusionFooter": { "youCanNow": "Sasa unaweza", From b6f31c349a98806a56577b3e7ad6abaab9a87543 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Wed, 4 Mar 2026 14:48:12 +0300 Subject: [PATCH 16/42] feat(backend): stop appending transition text --- .../collect_experiences_agent.py | 19 +------ frontend-new/src/chat/Chat.tsx | 40 +++++++------- .../src/chat/__snapshots__/Chat.test.tsx.snap | 53 +++++++++++++++---- 3 files changed, 64 insertions(+), 48 deletions(-) diff --git a/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py b/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py index 1b9225d2..b6ee77eb 100644 --- a/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py +++ b/backend/app/agent/collect_experiences_agent/collect_experiences_agent.py @@ -7,7 +7,7 @@ from app.agent.agent_types import AgentInput, AgentOutput from app.agent.agent_types import AgentType from app.agent.collect_experiences_agent._conversation_llm import _ConversationLLM, ConversationLLMAgentOutput, \ - _get_experience_type, fill_incomplete_fields_as_declined + fill_incomplete_fields_as_declined from app.agent.persona_detector import PersonaType from app.agent.collect_experiences_agent._dataextraction_llm import _DataExtractionLLM from app.agent.collect_experiences_agent._transition_decision_tool import TransitionDecisionTool, TransitionDecision @@ -19,7 +19,6 @@ from app.agent.linking_and_ranking_pipeline.infer_occupation_tool import InferOccupationTool from app.conversation_memory.conversation_memory_types import ConversationContext from app.countries import Country -from app.i18n.translation_service import t from app.vector_search.esco_entities import OccupationSkillEntity from app.vector_search.vector_search_dependencies import SearchServices @@ -306,27 +305,13 @@ async def execute(self, user_input: AgentInput, self._state.collected_data, reasoning_text ) -# exit if no unexplored types left - if not did_update and not self._state.unexplored_types: + if not self._state.unexplored_types: conversation_llm_output.finished = True self.logger.info( "Transition decision: END_WORKTYPE with no unexplored types - treating as END_CONVERSATION" ) return conversation_llm_output - next_exploring_type = self._state.unexplored_types[0] if self._state.unexplored_types else None - if next_exploring_type is not None: - transition_text = t( - 'messages', 'collectExperiences.askAboutType', - experience_type=_get_experience_type(next_exploring_type) - ) - else: - transition_text = t('messages', 'collectExperiences.recapPrompt') - - conversation_llm_output.message_for_user = ( - f"{conversation_llm_output.message_for_user}\n\n{transition_text}" - ) - elif transition_decision == TransitionDecision.END_CONVERSATION: conversation_llm_output.finished = True self.logger.info( diff --git a/frontend-new/src/chat/Chat.tsx b/frontend-new/src/chat/Chat.tsx index 8666ef0d..f9b67762 100644 --- a/frontend-new/src/chat/Chat.tsx +++ b/frontend-new/src/chat/Chat.tsx @@ -1043,28 +1043,28 @@ export const Chat: React.FC> = ({ gap: theme.spacing(theme.tabiyaSpacing.sm), }} > - - - - - + + + + + + - diff --git a/frontend-new/src/chat/__snapshots__/Chat.test.tsx.snap b/frontend-new/src/chat/__snapshots__/Chat.test.tsx.snap index c9350683..6601b6b7 100644 --- a/frontend-new/src/chat/__snapshots__/Chat.test.tsx.snap +++ b/frontend-new/src/chat/__snapshots__/Chat.test.tsx.snap @@ -13,23 +13,54 @@ exports[`Chat render tests should render the Chat component with all its childre class="MuiBox-root css-1msgsfq" >
-
- COLLECT_EXPERIENCES - - - 50 - % -
+ + + + Back to Dashboard +
+ class="MuiBox-root css-1rr4qq7" + > +
+ COLLECT_EXPERIENCES + - + 50 + % +
+
+
+
+
Date: Wed, 4 Mar 2026 17:04:30 +0300 Subject: [PATCH 17/42] feat(backend): refactor BackButton component and fix imports after rebase - lint and format --- frontend-new/src/chat/Chat.tsx | 17 +-------- .../src/chat/__snapshots__/Chat.test.tsx.snap | 31 +--------------- .../home/components/PageHeader/PageHeader.tsx | 37 ++++++------------- .../components/BackButton/BackButton.tsx | 31 ++++++++++------ 4 files changed, 36 insertions(+), 80 deletions(-) diff --git a/frontend-new/src/chat/Chat.tsx b/frontend-new/src/chat/Chat.tsx index f9b67762..ed547dd7 100644 --- a/frontend-new/src/chat/Chat.tsx +++ b/frontend-new/src/chat/Chat.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState, startTransition } from "react"; +import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import ChatService from "src/chat/ChatService/ChatService"; @@ -17,8 +17,6 @@ import { } from "./util"; import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; import { Box, useTheme } from "@mui/material"; -import { useNavigate } from "react-router-dom"; -import { routerPaths } from "src/app/routerPaths"; import ChatHeader from "./ChatHeader/ChatHeader"; import ChatMessageField from "./ChatMessageField/ChatMessageField"; import PageHeader from "src/home/components/PageHeader/PageHeader"; @@ -54,6 +52,7 @@ import { import { getCvUploadErrorMessageFromErrorCode } from "./CVUploadErrorHandling"; import type { UploadStatus } from "./Chat.types"; import { nanoid } from "nanoid"; +import { routerPaths } from "src/app/routerPaths"; export const INACTIVITY_TIMEOUT = 3 * 60 * 1000; // in milliseconds // Set the interval to check every TIMEOUT/3, @@ -110,7 +109,6 @@ export const Chat: React.FC> = ({ }) => { const theme = useTheme(); const { t } = useTranslation(); - const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); const navigate = useNavigate(); const [messages, setMessages] = useState[]>([]); @@ -1025,17 +1023,6 @@ export const Chat: React.FC> = ({ margin: { xs: "0", md: "0 auto" }, }} > - - { - startTransition(() => { - navigate(routerPaths.ROOT); - }); - }} - labelKey="knowledgeHub.backToDashboard" - dataTestId="chat-back-button" - /> -
-
- -
@@ -49,7 +22,7 @@ exports[`Chat render tests should render the Chat component with all its childre data-testid="chat-progress-bar-container-2a76494f-351d-409d-ba58-e1b2cfaf2a53" > COLLECT_EXPERIENCES - - + - 50 %
diff --git a/frontend-new/src/home/components/PageHeader/PageHeader.tsx b/frontend-new/src/home/components/PageHeader/PageHeader.tsx index fa76e59c..0d0c1ad4 100644 --- a/frontend-new/src/home/components/PageHeader/PageHeader.tsx +++ b/frontend-new/src/home/components/PageHeader/PageHeader.tsx @@ -1,7 +1,5 @@ import React, { useCallback, useContext, useMemo, useState, startTransition } from "react"; import { Box, Typography, useTheme, useMediaQuery } from "@mui/material"; -import ArrowBackIcon from "@mui/icons-material/ArrowBack"; -import CustomLink from "src/theme/CustomLink/CustomLink"; import type { Theme } from "@mui/material/styles"; import { NavLink, useNavigate } from "react-router-dom"; import { routerPaths } from "src/app/routerPaths"; @@ -27,6 +25,7 @@ import { Backdrop } from "src/theme/Backdrop/Backdrop"; import { useSentryFeedbackForm } from "src/feedback/hooks/useSentryFeedbackForm"; import { parseEnvSupportedLocales } from "src/i18n/languageContextMenu/parseEnvSupportedLocales"; import { LocalesLabels } from "src/i18n/constants"; +import BackButton from "src/knowledgeHub/components/BackButton"; const uniqueId = "b2e4c1a8-3f5d-4e6a-9c7b-1d2e3f4a5b6c"; @@ -425,29 +424,17 @@ const PageHeader: React.FC = ({ title, subtitle, backLinkLabel, boxSizing: "border-box", }} > - { - startTransition(() => { - onBackClick(); - }); - }} - data-testid={DATA_TEST_ID.PAGE_HEADER_BACK_LINK} - sx={{ - display: "inline-flex", - alignItems: "flex-start", - gap: 0.5, - fontSize: "0.85rem", - color: theme.palette.secondary.dark, - textDecoration: "none", - "&:hover": { - color: theme.palette.secondary.light, - textDecoration: "underline", - }, - }} - > - - {t(backLinkLabel as TranslationKey)} - + + { + startTransition(() => { + navigate(routerPaths.ROOT); + }); + }} + labelKey="knowledgeHub.backToDashboard" + dataTestId="chat-back-button" + /> + )} diff --git a/frontend-new/src/knowledgeHub/components/BackButton/BackButton.tsx b/frontend-new/src/knowledgeHub/components/BackButton/BackButton.tsx index 89953467..adb68647 100644 --- a/frontend-new/src/knowledgeHub/components/BackButton/BackButton.tsx +++ b/frontend-new/src/knowledgeHub/components/BackButton/BackButton.tsx @@ -1,8 +1,9 @@ -import React from "react"; -import { Button, useTheme } from "@mui/material"; +import React, { startTransition } from "react"; +import { useTheme } from "@mui/material"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { useTranslation } from "react-i18next"; import { TranslationKey } from "src/react-i18next"; +import CustomLink from "src/theme/CustomLink/CustomLink"; const uniqueId = "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"; @@ -21,21 +22,29 @@ const BackButton: React.FC = ({ onClick, labelKey, dataTestId } const { t } = useTranslation(); return ( - + ); }; From bb023fc746d849ddcad19afc25bf897e95d6eecf Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Wed, 4 Mar 2026 07:48:50 +0300 Subject: [PATCH 18/42] feat(backend): change sloth to bushbaby --- .../__snapshots__/Landing.test.tsx.snap | 2034 +++++++++-------- .../Login/__snapshots__/Login.test.tsx.snap | 1524 ++++++------ .../__snapshots__/Register.test.tsx.snap | 508 ++-- .../__snapshots__/Consent.test.tsx.snap | 254 +- .../ExperienceEditForm.test.tsx.snap | 254 +- frontend-new/src/theme/Backdrop/Backdrop.tsx | 6 +- .../__snapshots__/Backdrop.test.tsx.snap | 488 ++-- .../src/theme/Bushbaby/Bushbaby.stories.tsx | 49 + .../src/theme/Bushbaby/Bushbaby.test.tsx | 37 + frontend-new/src/theme/Bushbaby/Bushbaby.tsx | 128 ++ .../__snapshots__/Bushbaby.test.tsx.snap | 263 +++ .../src/theme/Bushbaby/bushbabySvgContent.ts | 14 + 12 files changed, 3045 insertions(+), 2514 deletions(-) create mode 100644 frontend-new/src/theme/Bushbaby/Bushbaby.stories.tsx create mode 100644 frontend-new/src/theme/Bushbaby/Bushbaby.test.tsx create mode 100644 frontend-new/src/theme/Bushbaby/Bushbaby.tsx create mode 100644 frontend-new/src/theme/Bushbaby/__snapshots__/Bushbaby.test.tsx.snap create mode 100644 frontend-new/src/theme/Bushbaby/bushbabySvgContent.ts diff --git a/frontend-new/src/auth/pages/Landing/__snapshots__/Landing.test.tsx.snap b/frontend-new/src/auth/pages/Landing/__snapshots__/Landing.test.tsx.snap index 912e537a..a5eb7dc2 100644 --- a/frontend-new/src/auth/pages/Landing/__snapshots__/Landing.test.tsx.snap +++ b/frontend-new/src/auth/pages/Landing/__snapshots__/Landing.test.tsx.snap @@ -40,171 +40,173 @@ exports[`Landing Page Continue as Guest button should not show guest button when style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -250,171 +252,173 @@ exports[`Landing Page Continue as Guest button should not show guest button when style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -460,171 +464,173 @@ exports[`Landing Page Continue as Guest button should not show guest button when style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -670,171 +676,173 @@ exports[`Landing Page Continue as Guest button should show guest button when app style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
- - + + + + + + + + + + + + + + + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -880,171 +888,173 @@ exports[`Landing Page Register button should not show register link when registr style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + - + + + + + + + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -1090,171 +1100,173 @@ exports[`Landing Page Register button should not show register link when registr style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + - + + + + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -1300,171 +1312,173 @@ exports[`Landing Page Register button should show register link when registratio style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -1510,171 +1524,173 @@ exports[`Landing Page Register button should show register link when registratio style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
diff --git a/frontend-new/src/auth/pages/Login/__snapshots__/Login.test.tsx.snap b/frontend-new/src/auth/pages/Login/__snapshots__/Login.test.tsx.snap index bc339f38..c79f4d36 100644 --- a/frontend-new/src/auth/pages/Login/__snapshots__/Login.test.tsx.snap +++ b/frontend-new/src/auth/pages/Login/__snapshots__/Login.test.tsx.snap @@ -105,171 +105,173 @@ exports[`Testing Login component Render tests should hide login code related ele style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + - + + + + + + + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -381,171 +383,173 @@ exports[`Testing Login component Render tests should hide login code related ele style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -671,171 +675,173 @@ exports[`Testing Login component Render tests should show continue as guest if l style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - + - - - - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -969,171 +975,173 @@ exports[`Testing Login component it should show login form successfully 1`] = ` style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + - + + + + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -1257,171 +1265,173 @@ exports[`Testing Login component should handle application login code 1`] = ` style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
@@ -1543,171 +1553,173 @@ exports[`Testing Login component should remove registration link if registration style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you in... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you in... +
+
+
+ + + + + +
+
+
+
diff --git a/frontend-new/src/auth/pages/Register/__snapshots__/Register.test.tsx.snap b/frontend-new/src/auth/pages/Register/__snapshots__/Register.test.tsx.snap index 8fbf2de9..922efb01 100644 --- a/frontend-new/src/auth/pages/Register/__snapshots__/Register.test.tsx.snap +++ b/frontend-new/src/auth/pages/Register/__snapshots__/Register.test.tsx.snap @@ -111,171 +111,173 @@ exports[`Testing Register component it should show register form successfully 1` style="opacity: 0; visibility: hidden;" >
-
-
-
- Registering you... -
-
-
- - - - - -
-
-
-
+ + + + + + + - + + + + + + + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Registering you... +
+
+
+ + + + + +
+
+
+
@@ -322,171 +324,173 @@ exports[`Testing Register component should handle application registration code style="opacity: 0; visibility: hidden;" >
-
-
-
- Registering you... -
-
-
- - - - - -
-
-
-
+ + + + + + + - + + + + + + + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Registering you... +
+
+
+ + + + + +
+
+
+
diff --git a/frontend-new/src/consent/components/consentPage/__snapshots__/Consent.test.tsx.snap b/frontend-new/src/consent/components/consentPage/__snapshots__/Consent.test.tsx.snap index 50b95a93..3674b2a8 100644 --- a/frontend-new/src/consent/components/consentPage/__snapshots__/Consent.test.tsx.snap +++ b/frontend-new/src/consent/components/consentPage/__snapshots__/Consent.test.tsx.snap @@ -12,171 +12,173 @@ exports[`Testing Consent Page render tests it should show consent screen with bo style="opacity: 0; visibility: hidden;" >
-
-
-
- Logging you out... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Logging you out... +
+
+
+ + + + + +
+
+
+
-
-
-
- Updating experience... -
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ Updating experience... +
+
+
+ + + + + +
+
+
+
diff --git a/frontend-new/src/theme/Backdrop/Backdrop.tsx b/frontend-new/src/theme/Backdrop/Backdrop.tsx index 8a990ab1..8fbd5b41 100644 --- a/frontend-new/src/theme/Backdrop/Backdrop.tsx +++ b/frontend-new/src/theme/Backdrop/Backdrop.tsx @@ -3,7 +3,7 @@ import { Backdrop as OriginalBackdrop, CircularProgress, Typography, useTheme } import Grid from "@mui/material/Grid"; import Paper from "@mui/material/Paper"; import React from "react"; -import { Sloth } from "src/theme/Sloth/Sloth"; +import { Bushbaby } from "src/theme/Bushbaby/Bushbaby"; interface IBackdropProps { isShown: boolean; @@ -30,7 +30,7 @@ export const Backdrop = (props: Readonly) => { data-testid={DATA_TEST_ID.BACKDROP_CONTAINER} open={props.isShown} > - + ) => { - + ); }; diff --git a/frontend-new/src/theme/Backdrop/__snapshots__/Backdrop.test.tsx.snap b/frontend-new/src/theme/Backdrop/__snapshots__/Backdrop.test.tsx.snap index 34c6e91c..df7e1ebd 100644 --- a/frontend-new/src/theme/Backdrop/__snapshots__/Backdrop.test.tsx.snap +++ b/frontend-new/src/theme/Backdrop/__snapshots__/Backdrop.test.tsx.snap @@ -8,171 +8,173 @@ exports[`Backdrop render tests should be shown 1`] = ` style="opacity: 1; webkit-transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;" >
-
-
-
- foo-message -
-
-
- - - - - -
-
-
-
+ + + + + + + - + + + + + + + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+
+ foo-message +
+
+
+ + + + + +
+
+
+
`; @@ -185,161 +187,163 @@ exports[`Backdrop render tests should rendered without message and not show any style="opacity: 1; webkit-transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;" >
-
-
- - - - - -
-
-
-
+ + + + + + + + + + + + + - + - - - - + - + - + - - - - - - - + - - - - - - - + - + - + - + - + - - - - - - - +
+
+
+
+
+ + + + + +
+
+
+
`; diff --git a/frontend-new/src/theme/Bushbaby/Bushbaby.stories.tsx b/frontend-new/src/theme/Bushbaby/Bushbaby.stories.tsx new file mode 100644 index 00000000..239ab313 --- /dev/null +++ b/frontend-new/src/theme/Bushbaby/Bushbaby.stories.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Bushbaby } from "./Bushbaby"; + +const meta: Meta = { + title: "Components/Bushbaby", + component: Bushbaby, + tags: ["autodocs"], + argTypes: { + width: { control: "text" }, + strokeColor: { control: "color" }, + bodyColor: { control: "color" }, + faceColor: { control: "color" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( +
Content with mascot
+ ), + }, +}; + +export const CustomWidth: Story = { + args: { + width: "96px", + children: ( +
Larger bushbaby
+ ), + }, +}; + +export const CustomColors: Story = { + args: { + width: "64px", + strokeColor: "#2d5016", + bodyColor: "#8b7355", + faceColor: "#d4b896", + children: ( +
+ Bushbaby with natural colors +
+ ), + }, +}; diff --git a/frontend-new/src/theme/Bushbaby/Bushbaby.test.tsx b/frontend-new/src/theme/Bushbaby/Bushbaby.test.tsx new file mode 100644 index 00000000..1098e98e --- /dev/null +++ b/frontend-new/src/theme/Bushbaby/Bushbaby.test.tsx @@ -0,0 +1,37 @@ +import "src/_test_utilities/consoleMock"; + +import React from "react"; +import { render, screen } from "src/_test_utilities/test-utils"; +import { Bushbaby, DATA_TEST_ID, BushbabyProps } from "./Bushbaby"; + +describe("Bushbaby", () => { + it("should render the bushbaby with the default props", () => { + const children =
; + render({children}); + + expect(screen.getByTestId("children")).toBeInTheDocument(); + + const actualBushbaby = screen.getByTestId(DATA_TEST_ID.BUSHBABY); + expect(actualBushbaby).toBeInTheDocument(); + + expect(actualBushbaby).toMatchSnapshot(); + }); + + it("should render the bushbaby with given props", () => { + const children =
; + const givenProps: BushbabyProps = { + width: "100px", + strokeColor: "green", + bodyColor: "red", + faceColor: "blue", + }; + render({children}); + + expect(screen.getByTestId("children")).toBeInTheDocument(); + + const actualBushbaby = screen.getByTestId(DATA_TEST_ID.BUSHBABY); + expect(actualBushbaby).toBeInTheDocument(); + + expect(actualBushbaby).toMatchSnapshot(); + }); +}); diff --git a/frontend-new/src/theme/Bushbaby/Bushbaby.tsx b/frontend-new/src/theme/Bushbaby/Bushbaby.tsx new file mode 100644 index 00000000..66692725 --- /dev/null +++ b/frontend-new/src/theme/Bushbaby/Bushbaby.tsx @@ -0,0 +1,128 @@ +import React from "react"; +import { styled } from "@mui/material/styles"; +import Container from "@mui/material/Container"; + +import { bushbabySvgContent } from "./bushbabySvgContent"; + +export type BushbabyProps = { + children?: React.ReactNode; + width?: string; + strokeColor?: string; + bodyColor?: string; + faceColor?: string; +}; + +const uniqueId = "7a2e9c1b-3d4f-4e5a-9b6c-8f1d2e3a4b5c"; +export const DATA_TEST_ID = { + BUSHBABY: `bushbaby-${uniqueId}`, +}; + +type BushbabySvgStyle = { + strokeColor: string; + bodyColor: string; + faceColor: string; + eyeColor: string; + irisColor: string; +}; + +const BODY_PART_COLORS: Record string> = { + "#tail": (s) => s.bodyColor, + "#body": (s) => s.bodyColor, + "#face": () => "url(#bushbaby-face-gradient)", + "#left-inner-ear": (s) => s.bodyColor, + "#right-inner-ear": (s) => s.bodyColor, + "#right-socket": (s) => s.eyeColor, + "#left-socket": (s) => s.eyeColor, + "#left-iris": (s) => s.irisColor, + "#right-iris": (s) => s.irisColor, + "#nose": (s) => s.strokeColor, +}; + +const bushbabySVGSrc = (style: BushbabySvgStyle) => { + const strokeCss = `stroke:${style.strokeColor};stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1`; + const faceGradient = ``; + let svg = faceGradient + bushbabySvgContent; + + for (const [label, getColor] of Object.entries(BODY_PART_COLORS)) { + const fillColor = getColor(style); + svg = svg.replace(new RegExp(`fill="${label}"`, "g"), `style="fill:${fillColor};fill-opacity:1;${strokeCss}"`); + } + + return svg; +}; + +export const Bushbaby = (props: BushbabyProps) => { + const bushbabyStyle = { + width: props.width ?? "48px", + strokeColor: props.strokeColor ?? "#2d1810", + bodyColor: props.bodyColor ?? "#6b5344", + faceColor: props.faceColor ?? "#d4b896", + eyeColor: "#5c4033", + irisColor: "#000000", + }; + + const VIEWBOX_HEIGHT = 190; + const VIEWBOX_WIDTH = 214; + const svgHeight = `calc(${bushbabyStyle.width} * ${VIEWBOX_HEIGHT} / ${VIEWBOX_WIDTH})`; + + const animationStyle = () => ({ + margin: "0", + padding: "0", + overflow: "visible", + "#bushbaby-tail": { + transformOrigin: "178px 99px", + animation: "tailSway 3.5s infinite ease-in-out", + "@keyframes tailSway": { + "0%, 100%": { transform: "translateX(-10px) rotate(0deg)" }, + "50%": { transform: "translateX(-10px) rotate(15deg)" }, + }, + }, + "#bushbaby-head": { + transformOrigin: "50px 45px", + animation: "headTilt 20s 2s infinite linear", + "@keyframes headTilt": { + "0%": { transform: "rotate(0deg)" }, + "25%": { transform: "rotate(15deg)" }, + "75%": { transform: "rotate(-15deg)" }, + "100%": { transform: "rotate(0deg)" }, + }, + }, + }); + + const AnimatedContainer = styled(Container)(() => animationStyle()); + + return ( +
+ + + +
{props.children}
+
+ ); +}; diff --git a/frontend-new/src/theme/Bushbaby/__snapshots__/Bushbaby.test.tsx.snap b/frontend-new/src/theme/Bushbaby/__snapshots__/Bushbaby.test.tsx.snap new file mode 100644 index 00000000..3845c1bb --- /dev/null +++ b/frontend-new/src/theme/Bushbaby/__snapshots__/Bushbaby.test.tsx.snap @@ -0,0 +1,263 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Bushbaby should render the bushbaby with given props 1`] = ` +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+`; + +exports[`Bushbaby should render the bushbaby with the default props 1`] = ` +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+`; diff --git a/frontend-new/src/theme/Bushbaby/bushbabySvgContent.ts b/frontend-new/src/theme/Bushbaby/bushbabySvgContent.ts new file mode 100644 index 00000000..18931c36 --- /dev/null +++ b/frontend-new/src/theme/Bushbaby/bushbabySvgContent.ts @@ -0,0 +1,14 @@ +export const bushbabySvgContent = ` + + + + + + + + + + + + +`; From 647fe44d8e57e0a5e1f4c2c7956c0a72f7212f94 Mon Sep 17 00:00:00 2001 From: Anselme Irumva Date: Thu, 5 Mar 2026 15:02:22 +0200 Subject: [PATCH 19/42] feat(preferences): integrate plain personal data service into user preferences handling --- backend/app/users/preferences.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/backend/app/users/preferences.py b/backend/app/users/preferences.py index f469bd57..ae03895f 100644 --- a/backend/app/users/preferences.py +++ b/backend/app/users/preferences.py @@ -15,6 +15,8 @@ from app.server_dependencies.db_dependencies import CompassDBProvider from app.users.auth import Authentication, UserInfo, SignInProvider from app.users.get_user_preferences_repository import get_user_preferences_repository +from app.users.plain_personal_data.routes import get_plain_personal_data_service +from app.users.plain_personal_data.service import IPlainPersonalDataService from app.users.repositories import UserPreferenceRepository from app.users.sensitive_personal_data.routes import get_sensitive_personal_data_service from app.users.sensitive_personal_data.service import ISensitivePersonalDataService @@ -33,6 +35,7 @@ async def _get_user_preferences( repository: UserPreferenceRepository, user_feedback_service: UserFeedbackService, sensitive_personal_data_service: ISensitivePersonalDataService, + plain_personal_data_service: IPlainPersonalDataService, user_id: str, authed_user: UserInfo) -> UsersPreferencesResponse: try: @@ -58,14 +61,15 @@ async def _get_user_preferences( ) # Fetch feedback sessions together with if they have sensitive personal data - answered_questions, has_sensitive_personal_data = await asyncio.gather( + answered_questions, has_sensitive_personal_data, plain_personal_data = await asyncio.gather( user_feedback_service.get_answered_questions(user_id), - sensitive_personal_data_service.exists_by_user_id(user_id) + sensitive_personal_data_service.exists_by_user_id(user_id), + plain_personal_data_service.get(user_id) ) return UsersPreferencesResponse( **user_preferences.model_dump(), - has_sensitive_personal_data=has_sensitive_personal_data, + has_sensitive_personal_data=has_sensitive_personal_data or plain_personal_data is not None, user_feedback_answered_questions=answered_questions ) except Exception as e: @@ -159,6 +163,7 @@ async def _update_user_preferences( repository: UserPreferenceRepository, user_feedback_service: UserFeedbackService, sensitive_personal_data_service: ISensitivePersonalDataService, + plain_personal_data_service: IPlainPersonalDataService, preferences: UserPreferencesUpdateRequest, authed_user: UserInfo) -> UsersPreferencesResponse | None: """ @@ -188,7 +193,7 @@ async def _update_user_preferences( detail="accepted terms and conditions can't be updated once accepted" ) - updated_user_preferences, sessions_with_feedback, has_sensitive_personal_data = await asyncio.gather( + updated_user_preferences, sessions_with_feedback, has_sensitive_personal_data, plain_personal_data = await asyncio.gather( repository.update_user_preference(preferences.user_id, UserPreferencesRepositoryUpdateRequest( language=preferences.language, client_id=preferences.client_id, @@ -196,12 +201,13 @@ async def _update_user_preferences( experiments=preferences.experiments )), user_feedback_service.get_answered_questions(preferences.user_id), - sensitive_personal_data_service.exists_by_user_id(preferences.user_id) + sensitive_personal_data_service.exists_by_user_id(preferences.user_id), + plain_personal_data_service.get(preferences.user_id) ) return UsersPreferencesResponse( **updated_user_preferences.model_dump(), - has_sensitive_personal_data=has_sensitive_personal_data, + has_sensitive_personal_data=has_sensitive_personal_data or plain_personal_data is not None, user_feedback_answered_questions=sessions_with_feedback ) @@ -260,6 +266,7 @@ async def _get_user_preferences_handler( user_preference_repository: UserPreferenceRepository = Depends(get_user_preferences_repository), sensitive_personal_data_service: ISensitivePersonalDataService = Depends( get_sensitive_personal_data_service), + plain_personal_data_service: IPlainPersonalDataService = Depends(get_plain_personal_data_service), user_feedback_service: UserFeedbackService = Depends(_get_user_feedback_service) ) -> UsersPreferencesResponse: # set the user id context variable. @@ -269,6 +276,7 @@ async def _get_user_preferences_handler( user_preference_repository, user_feedback_service, sensitive_personal_data_service, + plain_personal_data_service, user_id, user_info ) @@ -311,6 +319,7 @@ async def _update_user_preferences_handler( user_preference_repository: UserPreferenceRepository = Depends(get_user_preferences_repository), sensitive_personal_data_service: ISensitivePersonalDataService = Depends( get_sensitive_personal_data_service), + plain_personal_data_service: IPlainPersonalDataService = Depends(get_plain_personal_data_service), user_feedback_service: UserFeedbackService = Depends(_get_user_feedback_service) ) -> UsersPreferencesResponse: # set the user id context variable. @@ -320,6 +329,7 @@ async def _update_user_preferences_handler( user_preference_repository, user_feedback_service, sensitive_personal_data_service, + plain_personal_data_service, request, user_info ) From 912c9951970ec9354694406f64cc3d1dade00ad5 Mon Sep 17 00:00:00 2001 From: Anselme Irumva Date: Thu, 5 Mar 2026 15:14:08 +0200 Subject: [PATCH 20/42] feat(ci): update artifact upload conditions to include zambia-working-branch as it is the working branch for the fork --- .github/workflows/main.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ecff1a99..b3706f1a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,22 +16,22 @@ jobs: uses: ./.github/workflows/frontend-ci.yml secrets: inherit with: - upload-artifacts: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || contains(github.event.head_commit.message, '[pulumi up]'))) }} + upload-artifacts: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/zambia-working-branch' || contains(github.event.head_commit.message, '[pulumi up]'))) }} backend-ci: uses: ./.github/workflows/backend-ci.yml secrets: inherit with: - upload-artifacts: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || contains(github.event.head_commit.message, '[pulumi up]'))) }} + upload-artifacts: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/zambia-working-branch' || contains(github.event.head_commit.message, '[pulumi up]'))) }} config-ci: uses: ./.github/workflows/config-ci.yml secrets: inherit - if: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || contains(github.event.head_commit.message, '[pulumi up]'))) }} + if: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/zambia-working-branch' || contains(github.event.head_commit.message, '[pulumi up]'))) }} deploy: needs: [ frontend-ci, backend-ci, config-ci ] - if: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || contains(github.event.head_commit.message, '[pulumi up]'))) }} + if: ${{ (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/zambia-working-branch' || contains(github.event.head_commit.message, '[pulumi up]'))) }} uses: ./.github/workflows/deploy.yml secrets: inherit with: From 5981b291a5972896e93949a0d20aa442e9c18875 Mon Sep 17 00:00:00 2001 From: Anselme Irumva Date: Wed, 4 Mar 2026 08:04:46 +0200 Subject: [PATCH 21/42] feat: update environment name in deployment configuration to dev-njila in rename option (cherry picked from commit ff3c10f010680a3622ee0302e2858411f5e1231f) --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b3706f1a..541c10d1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: uses: ./.github/workflows/deploy.yml secrets: inherit with: - env-name: dev-njira + env-name: dev-njila env-type: dev target-git-sha: ${{ github.sha }} target-git-branch: ${{ github.ref_name }} From 69262d7236c5ffedfdec920eeb506dadcb3c8c20 Mon Sep 17 00:00:00 2001 From: nraffa Date: Thu, 5 Mar 2026 18:01:11 -0300 Subject: [PATCH 22/42] feat(career-readiness): implement full instruction and quiz pipeline with agent, service, and tests --- backend/app/career_readiness/agent.py | 215 +++++-- backend/app/career_readiness/errors.py | 7 + backend/app/career_readiness/module_loader.py | 161 ++++- .../app/career_readiness/modules/README.md | 87 +++ .../modules/_example_module.md | 74 +++ .../career_readiness/modules/cover_letter.md | 74 +++ .../modules/cv_development.md | 74 +++ .../modules/interview_preparation.md | 74 +++ .../modules/professional_identity.md | 74 +++ .../modules/workplace_readiness.md | 74 +++ .../career_readiness/pedagogical-approach.md | 180 ++++++ backend/app/career_readiness/repository.py | 56 +- backend/app/career_readiness/routes.py | 4 + backend/app/career_readiness/service.py | 344 +++++++++-- backend/app/career_readiness/test_agent.py | 230 +++++-- .../career_readiness/test_module_loader.py | 299 ++++++++- .../app/career_readiness/test_repository.py | 129 ++++ backend/app/career_readiness/test_routes.py | 15 + backend/app/career_readiness/test_service.py | 580 ++++++++++++++++-- backend/app/career_readiness/types.py | 31 + 20 files changed, 2577 insertions(+), 205 deletions(-) create mode 100644 backend/app/career_readiness/modules/README.md create mode 100644 backend/app/career_readiness/modules/_example_module.md create mode 100644 backend/app/career_readiness/pedagogical-approach.md diff --git a/backend/app/career_readiness/agent.py b/backend/app/career_readiness/agent.py index 18800f66..1eda9567 100644 --- a/backend/app/career_readiness/agent.py +++ b/backend/app/career_readiness/agent.py @@ -6,16 +6,18 @@ """ import logging import time +from dataclasses import dataclass, field from textwrap import dedent +from pydantic import BaseModel, ConfigDict + from app.agent.agent_types import AgentInput, AgentOutput, AgentType, LLMStats, AgentOutputWithReasoning from app.agent.llm_caller import LLMCaller from app.agent.prompt_template.locale_style import get_language_style -from app.agent.simple_llm_agent.llm_response import ModelResponse from app.agent.simple_llm_agent.prompt_response_template import ( get_json_response_instructions, - get_conversation_finish_instructions, ) +from app.career_readiness.types import ConversationMode from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter from app.conversation_memory.conversation_memory_types import ConversationContext from common_libs.llm.generative_models import GeminiGenerativeLLM @@ -23,48 +25,175 @@ from common_libs.llm.schema_builder import with_response_schema -def _build_system_instructions(module_title: str, module_content: str) -> str: - """Build the system instructions for the career readiness agent.""" - response_part = get_json_response_instructions() - finish_instructions = get_conversation_finish_instructions( - dedent("""Once the user explicitly indicates they are done or have no more questions about this topic""") - ) +class CareerReadinessModelResponse(BaseModel): + """ + Custom response schema for the career readiness agent with topic tracking. + + Order matters for model prediction quality: + 1. reasoning — sets context + 2. finished — depends on reasoning + 3. message — depends on reasoning + finished + 4. topics_covered — depends on the conversation turn + """ + + model_config = ConfigDict(extra="forbid") + + reasoning: str + """Chain of Thought reasoning behind the response""" + + finished: bool + """Whether the agent judges all topics have been sufficiently covered""" + + message: str + """Message for the user""" + + topics_covered: list[str] = [] + """Topics from the module's topic list that were addressed in this turn""" + + +@dataclass +class CareerReadinessAgentOutput: + """Wraps AgentOutput with topic tracking data from the custom response schema.""" + + agent_output: AgentOutput + """The standard agent output (message, finished, stats, etc.)""" + + topics_covered: list[str] = field(default_factory=list) + """Topics reported as covered in this turn""" + + +def _build_instruction_mode_instructions(module_title: str, module_content: str, topics: list[str]) -> str: + """Build system instructions for instruction mode (scaffolded Socratic tutoring).""" + topics_list = "\n".join(f"- {topic}" for topic in topics) language_style = get_language_style(with_locale=False, for_json_output=True) + response_instructions = dedent("""\ + # Response Format + You must respond with valid JSON matching this exact schema: + { + "reasoning": "Your internal chain-of-thought reasoning (not shown to the user)", + "finished": true/false, + "message": "Your message to the student", + "topics_covered": ["Topic Name 1", "Topic Name 2"] + } + + - "reasoning": Explain your pedagogical reasoning — what the student knows, what to cover next, which scaffolding level to use. + - "finished": Set to true ONLY when you judge that ALL topics have been sufficiently covered and the student has demonstrated understanding. Do not set finished to true prematurely. + - "message": Your response to the student. Do not format with markdown. Keep under 200 words. + - "topics_covered": List ONLY topic names from the module topic list that you meaningfully addressed in THIS turn. Use exact topic names from the list above. If you only briefly mentioned a topic, do not include it.""") + template = dedent("""\ - You are a career readiness coach specialising in "{module_title}". - Your role is to help the user develop practical skills and knowledge related to this topic. + You are a career readiness tutor specialising in "{module_title}". + Your role is to guide the student through this topic using scaffolded Socratic tutoring. + + # Teaching Method: Scaffolded Socratic Tutoring + Follow this graduated assistance model for EACH topic: + 1. ASSESS — Begin by asking what the student already knows about the topic. Do not assume prior knowledge. + 2. GUIDE — Ask leading questions that help the student reason through the material themselves. + 3. HINT — If the student struggles (wrong answer, "I don't know", confusion), provide partial information or worked examples. + 4. EXPLAIN — Give direct explanations ONLY as a last resort, after hints have not helped. + 5. FADE — As the student demonstrates understanding, reduce your support and encourage independent reasoning. + + # Comprehension Checks + Embed checks throughout the conversation: + - Ask the student to explain concepts back in their own words + - Ask application questions (e.g., "How would this apply to your situation?") + - Ask prediction questions (e.g., "What do you think would happen if...?") + - Periodically return to earlier topics to reinforce retention + + # Handling Minimal Responses + If the student gives a minimal response like "yes", "yeah", "okay", "sure", "definitely", + or any other one-word agreement, do NOT treat this as evidence of understanding. + Instead, ask a follow-up question that requires them to demonstrate comprehension, such as: + - "Can you explain that back to me in your own words?" + - "Can you give me an example from your own experience?" + - "How would you apply this in a real situation?" + Never ask "Does that make sense?" or similar yes/no questions as comprehension checks. + Only mark a topic in topics_covered when the student has given a substantive response + that shows genuine understanding — not just agreement. + + # Module Topics + You must cover ALL of the following topics before setting "finished" to true: + {topics_list} # Grounding Content - Use the following content as your primary knowledge base for this conversation. - Always ground your responses in this material. Do not invent facts or techniques - that are not supported by the content below. + Use the following content as your primary knowledge base. Always ground your responses + in this material. Do not invent facts or techniques not supported by this content. {module_content} - # Instructions - - Guide the user step by step through the topic. + # Rules - Be encouraging, supportive, and practical. - - Provide concrete examples and actionable advice grounded in the content above. - - Ask follow-up questions to understand the user's situation and tailor your guidance. - Keep responses concise and focused (under 200 words per message). - - Do not make things up. If the user asks something outside the scope of the grounding content, - politely redirect them to the topics you can help with. - Do not format or style your response with markdown. + - Do not discuss, mention, or reveal anything about the quiz. + - If the user asks something outside the scope of the grounding content, + politely redirect them to the topics you can help with. + - Cover topics in a natural conversational order, not necessarily the order listed above. + - Do not rush through topics. Spend adequate time on each one based on the student's responses. {language_style} - {response_part} + {response_instructions} + """) - {finish_instructions} + return template.format( + module_title=module_title, + module_content=module_content, + topics_list=topics_list, + language_style=language_style, + response_instructions=response_instructions, + ) + + +def _build_support_mode_instructions(module_title: str, module_content: str) -> str: + """Build system instructions for support mode (post-quiz follow-up Q&A).""" + language_style = get_language_style(with_locale=False, for_json_output=True) + + response_instructions = dedent("""\ + # Response Format + You must respond with valid JSON matching this exact schema: + { + "reasoning": "Your internal reasoning (not shown to the user)", + "finished": false, + "message": "Your response to the student", + "topics_covered": [] + } + + - "finished": Always set to false in support mode. + - "topics_covered": Always set to an empty list in support mode. + - "message": Your response to the student. Do not format with markdown. Keep under 200 words.""") + + template = dedent("""\ + You are a career readiness support assistant for "{module_title}". + The student has already completed the lesson and passed the quiz for this module. + + Your role is to answer follow-up questions about the module's topics. + + # Grounding Content + Use the following content as your primary knowledge base: + + {module_content} + + # Rules + - Answer questions grounded in the module content above. + - Be helpful, encouraging, and concise. + - Do not re-initiate the lesson plan or deliver a quiz. + - Do not format or style your response with markdown. + - Keep responses under 200 words. + - If the user asks something outside the scope of this module, + politely redirect them. + + {language_style} + + {response_instructions} """) return template.format( module_title=module_title, module_content=module_content, language_style=language_style, - response_part=response_part, - finish_instructions=finish_instructions, + response_instructions=response_instructions, ) @@ -77,27 +206,37 @@ class CareerReadinessAgent: the AgentDirector pipeline. """ - def __init__(self, module_title: str, module_content: str): + def __init__(self, module_title: str, module_content: str, + mode: ConversationMode = ConversationMode.INSTRUCTION, + topics: list[str] | None = None): self._logger = logging.getLogger(CareerReadinessAgent.__name__) - self._system_instructions = _build_system_instructions(module_title, module_content) + + if mode == ConversationMode.INSTRUCTION: + self._system_instructions = _build_instruction_mode_instructions( + module_title, module_content, topics or []) + else: + self._system_instructions = _build_support_mode_instructions( + module_title, module_content) + config = LLMConfig( generation_config=LOW_TEMPERATURE_GENERATION_CONFIG | JSON_GENERATION_CONFIG | with_response_schema( - ModelResponse) + CareerReadinessModelResponse) ) self._llm = GeminiGenerativeLLM(system_instructions=self._system_instructions, config=config) - self._llm_caller: LLMCaller[ModelResponse] = LLMCaller[ModelResponse](model_response_type=ModelResponse) + self._llm_caller: LLMCaller[CareerReadinessModelResponse] = LLMCaller[CareerReadinessModelResponse]( + model_response_type=CareerReadinessModelResponse) @property def system_instructions(self) -> str: return self._system_instructions - async def execute(self, user_input: AgentInput, context: ConversationContext) -> AgentOutput: + async def execute(self, user_input: AgentInput, context: ConversationContext) -> CareerReadinessAgentOutput: """ - Process user input and return the agent's response. + Process user input and return the agent's response with topic tracking. :param user_input: The user's message :param context: The conversation context with history - :return: The agent's output + :return: The agent's output with topics_covered """ agent_start_time = time.time() @@ -105,7 +244,7 @@ async def execute(self, user_input: AgentInput, context: ConversationContext) -> if msg == "": msg = "(silence)" - model_response: ModelResponse | None + model_response: CareerReadinessModelResponse | None llm_stats_list: list[LLMStats] try: @@ -124,14 +263,15 @@ async def execute(self, user_input: AgentInput, context: ConversationContext) -> llm_stats_list = [] if model_response is None: - model_response = ModelResponse( + model_response = CareerReadinessModelResponse( reasoning="Failed to get a response", message="I am facing some difficulties right now, could you please repeat what you said?", finished=False, + topics_covered=[], ) agent_end_time = time.time() - return AgentOutputWithReasoning( + agent_output = AgentOutputWithReasoning( message_for_user=model_response.message.strip('"'), finished=model_response.finished, reasoning=model_response.reasoning, @@ -140,14 +280,19 @@ async def execute(self, user_input: AgentInput, context: ConversationContext) -> llm_stats=llm_stats_list, ) - async def generate_intro_message(self, context: ConversationContext) -> AgentOutput: + return CareerReadinessAgentOutput( + agent_output=agent_output, + topics_covered=model_response.topics_covered, + ) + + async def generate_intro_message(self, context: ConversationContext) -> CareerReadinessAgentOutput: """ Generate the introductory message for a new conversation. Sends an artificial "(silence)" input to trigger the agent's greeting. :param context: An empty conversation context - :return: The agent's introductory output + :return: The agent's introductory output with topics_covered """ artificial_input = AgentInput( message="(silence)", diff --git a/backend/app/career_readiness/errors.py b/backend/app/career_readiness/errors.py index 771850ea..7c707a43 100644 --- a/backend/app/career_readiness/errors.py +++ b/backend/app/career_readiness/errors.py @@ -36,3 +36,10 @@ class ConversationModuleMismatchError(Exception): def __init__(self, conversation_id: str, module_id: str): super().__init__(f"Conversation {conversation_id} does not belong to module {module_id}") + + +class ModuleNotUnlockedError(Exception): + """Raised when attempting to start a conversation for a locked module.""" + + def __init__(self, module_id: str): + super().__init__(f"Module {module_id} is not yet unlocked") diff --git a/backend/app/career_readiness/module_loader.py b/backend/app/career_readiness/module_loader.py index 6a6c2acc..6c7d455e 100644 --- a/backend/app/career_readiness/module_loader.py +++ b/backend/app/career_readiness/module_loader.py @@ -2,20 +2,50 @@ This module loads career readiness module definitions from markdown files with frontmatter. """ import logging +import re from pathlib import Path -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict logger = logging.getLogger(__name__) _MODULES_DIR = Path(__file__).parent / "modules" +class QuizQuestion(BaseModel): + """A single multiple-choice quiz question.""" + + model_config = ConfigDict(extra="forbid") + + question: str + """The question text""" + + options: list[str] + """The answer options, e.g. ["A. Resume", "B. Letter", "C. Form", "D. Report"]""" + + correct_answer: str + """The correct answer letter, e.g. "A" """ + + +class QuizConfig(BaseModel): + """Configuration for a module's quiz section.""" + + model_config = ConfigDict(extra="forbid") + + pass_threshold: float = 0.7 + """Fraction of correct answers required to pass (0.0–1.0)""" + + questions: list[QuizQuestion] + """The list of quiz questions""" + + class ModuleConfig(BaseModel): """ Represents a career readiness module definition loaded from a markdown file. """ + model_config = ConfigDict(extra="forbid") + id: str """The unique identifier (slug) of the module""" @@ -37,8 +67,11 @@ class ModuleConfig(BaseModel): content: str """The markdown body content used as grounding for the agent""" - class Config: - extra = "forbid" + topics: list[str] + """The list of topics the agent must cover before the quiz becomes available""" + + quiz: QuizConfig | None = None + """The quiz configuration, parsed from the ## Quiz section. None if no quiz.""" def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]: @@ -68,6 +101,112 @@ def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]: return metadata, body +def _split_quiz_section(body: str) -> tuple[str, str | None]: + """ + Split the markdown body on the '## Quiz' heading. + Returns (content_before_quiz, quiz_section_text_or_none). + """ + # Match ## Quiz at the start of a line (with optional trailing whitespace) + pattern = r"(?m)^## Quiz\s*$" + match = re.search(pattern, body) + if match is None: + return body, None + + content = body[:match.start()].rstrip() + quiz_text = body[match.end():].strip() + return content, quiz_text + + +def _parse_quiz_section(text: str) -> QuizConfig: + """ + Parse a quiz section into a QuizConfig. + + Expected format: + pass_threshold: 0.7 (optional, defaults to 0.7) + + 1. Question text here? + A. Option A text + B. Option B text + C. Option C text + D. Option D text + Answer: B + + 2. Another question? + ... + """ + lines = text.strip().splitlines() + + # Parse optional pass_threshold from the first non-empty line + pass_threshold = 0.7 + start_index = 0 + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("pass_threshold:"): + try: + pass_threshold = float(stripped.split(":", 1)[1].strip()) + except ValueError as e: + raise ValueError(f"Invalid pass_threshold value: {stripped}") from e + start_index = i + 1 + break + # First non-empty line is not pass_threshold — start parsing questions from here + start_index = i + break + + # Parse questions + questions: list[QuizQuestion] = [] + current_question: str | None = None + current_options: list[str] = [] + current_answer: str | None = None + + question_pattern = re.compile(r"^\d+\.\s+(.+)") + option_pattern = re.compile(r"^([A-D])\.\s+(.+)") + answer_pattern = re.compile(r"^Answer:\s+([A-Da-d])") + + for line in lines[start_index:]: + stripped = line.strip() + if not stripped: + continue + + question_match = question_pattern.match(stripped) + option_match = option_pattern.match(stripped) + answer_match = answer_pattern.match(stripped) + + if question_match: + # Save previous question if exists + if current_question is not None: + if current_answer is None: + raise ValueError(f"Quiz question missing Answer: '{current_question}'") + questions.append(QuizQuestion( + question=current_question, + options=current_options, + correct_answer=current_answer, + )) + current_question = question_match.group(1) + current_options = [] + current_answer = None + elif option_match: + current_options.append(f"{option_match.group(1)}. {option_match.group(2)}") + elif answer_match: + current_answer = answer_match.group(1).upper() + + # Save the last question + if current_question is not None: + if current_answer is None: + raise ValueError(f"Quiz question missing Answer: '{current_question}'") + questions.append(QuizQuestion( + question=current_question, + options=current_options, + correct_answer=current_answer, + )) + + if not questions: + raise ValueError("Quiz section contains no questions") + + return QuizConfig(pass_threshold=pass_threshold, questions=questions) + + def _load_module_from_file(file_path: Path) -> ModuleConfig: """ Load a single module configuration from a markdown file. @@ -75,6 +214,16 @@ def _load_module_from_file(file_path: Path) -> ModuleConfig: text = file_path.read_text(encoding="utf-8") metadata, body = _parse_frontmatter(text) + # Parse topics from comma-separated frontmatter value + topics_raw = metadata.get("topics", "") + topics = [t.strip() for t in topics_raw.split(",") if t.strip()] if topics_raw else [] + + # Split quiz section from content + content, quiz_text = _split_quiz_section(body) + + # Parse quiz if present + quiz = _parse_quiz_section(quiz_text) if quiz_text is not None else None + return ModuleConfig( id=metadata["id"], title=metadata["title"], @@ -82,7 +231,9 @@ def _load_module_from_file(file_path: Path) -> ModuleConfig: icon=metadata["icon"], sort_order=int(metadata["sort_order"]), input_placeholder=metadata["input_placeholder"], - content=body, + content=content, + topics=topics, + quiz=quiz, ) @@ -105,6 +256,8 @@ def _load_modules(self, modules_dir: Path) -> None: return for file_path in sorted(modules_dir.glob("*.md")): + if file_path.name.startswith("_") or file_path.name == "README.md": + continue try: module = _load_module_from_file(file_path) self._modules[module.id] = module diff --git a/backend/app/career_readiness/modules/README.md b/backend/app/career_readiness/modules/README.md new file mode 100644 index 00000000..6287f9d5 --- /dev/null +++ b/backend/app/career_readiness/modules/README.md @@ -0,0 +1,87 @@ +# Career Readiness Modules — Authoring Guide + +This guide explains how to add a new career readiness module. + +## File Structure + +Each module is a single Markdown file in this directory (e.g., `my_new_module.md`). +Files starting with `_` are ignored by the loader. + +## Required Frontmatter + +Every module file must start with a YAML frontmatter block: + +```yaml +--- +id: my-new-module +title: My New Module +description: A short description shown in the module list. +icon: module-icon +sort_order: 6 +input_placeholder: Ask about this topic... +topics: Topic One, Topic Two, Topic Three +--- +``` + +| Field | Description | +|-------|-------------| +| `id` | Unique slug identifier (kebab-case). Must not conflict with existing modules. | +| `title` | Display name shown to the user. | +| `description` | Short summary shown in the module list. | +| `icon` | Icon identifier used by the frontend. | +| `sort_order` | Integer controlling module progression order. Modules unlock sequentially by this value. | +| `input_placeholder` | Placeholder text in the chat input box. | +| `topics` | Comma-separated list of topics the AI tutor must cover before the quiz is triggered. | + +## Grounding Content + +After the frontmatter, write the educational content using Markdown headings. +Organize content with H2 sections (`##`) that correspond to your topics list. +This content is what the AI tutor uses as its knowledge base — it will not invent facts beyond this material. + +## Quiz Section + +Add a `## Quiz` section at the bottom of the file. This section is **not** shown to the AI tutor. + +Format: + +```markdown +## Quiz +pass_threshold: 0.7 + +1. Question text? +A. Option A +B. Option B +C. Option C +D. Option D +Answer: B + +2. Another question? +A. Option A +B. Option B +C. Option C +D. Option D +Answer: C +``` + +- `pass_threshold` (optional, defaults to `0.7`) — fraction of correct answers needed to pass. +- Include at least 5 questions; we recommend 10. +- Each question has exactly 4 options (A–D) and one correct `Answer:` line. + +## Module Ordering & Sequential Unlock + +Modules unlock sequentially based on `sort_order`. The first module (lowest `sort_order`) is always unlocked. Subsequent modules unlock only after the previous one is completed (quiz passed). + +## Checklist Before Deploying + +1. Frontmatter has all required fields +2. `id` is unique and uses kebab-case +3. `sort_order` does not conflict with existing modules +4. `topics` list matches the content sections +5. `## Quiz` section has correctly formatted questions with `Answer:` lines +6. Content is factually accurate and appropriate for the target audience +7. Run `poetry run pytest app/career_readiness/test_module_loader.py -v` to verify parsing + +## Example + +See `_example_module.md` for a complete template. diff --git a/backend/app/career_readiness/modules/_example_module.md b/backend/app/career_readiness/modules/_example_module.md new file mode 100644 index 00000000..f40ad81a --- /dev/null +++ b/backend/app/career_readiness/modules/_example_module.md @@ -0,0 +1,74 @@ +--- +id: example-module +title: Example Module Title +description: A short description of what this module covers. +icon: example +sort_order: 99 +input_placeholder: Ask about this topic... +topics: First Topic, Second Topic, Third Topic +--- + +# Example Module Title + +## Overview + +Introduce the module here. Explain what the learner will gain from completing it. + +## First Topic + +Write educational content about the first topic here. Use lists, examples, and practical advice. + +Key points: +- Point one +- Point two +- Point three + +## Second Topic + +Content for the second topic. Include concrete examples relevant to the Zambian job market. + +### Subtopic + +You can use H3 headings for subtopics within a main topic. + +## Third Topic + +Content for the third topic. Keep language clear and accessible. + +## Quiz +pass_threshold: 0.7 + +1. What is the main purpose of this module? +A. To teach cooking skills +B. To introduce the module's core topics +C. To prepare for a driving test +D. To learn a musical instrument +Answer: B + +2. Which of the following is a key point from the first topic? +A. Point one +B. Unrelated fact +C. Random statement +D. None of the above +Answer: A + +3. What should you include in educational content? +A. Only theory +B. Concrete examples relevant to the job market +C. Personal opinions only +D. Unverified claims +Answer: B + +4. How are subtopics organized? +A. Using H1 headings +B. Using H3 headings under the main topic +C. Using bold text only +D. They are not organized +Answer: B + +5. What is the default pass threshold? +A. 50% +B. 60% +C. 70% +D. 80% +Answer: C diff --git a/backend/app/career_readiness/modules/cover_letter.md b/backend/app/career_readiness/modules/cover_letter.md index efecc3c4..6a890349 100644 --- a/backend/app/career_readiness/modules/cover_letter.md +++ b/backend/app/career_readiness/modules/cover_letter.md @@ -5,6 +5,7 @@ description: Learn to write compelling cover letters and motivation statements t icon: letter sort_order: 3 input_placeholder: Ask about cover letters... +topics: What is a Cover Letter, Cover Letter Structure, Motivation Statement vs Cover Letter, Writing Tips, Common Mistakes to Avoid --- # Cover Letter & Motivation Statement @@ -66,3 +67,76 @@ Motivation statements are commonly used for academic programs, scholarships, and - Focusing only on what you want (instead of what you offer) - Forgetting to proofread - Not following the application instructions + +## Quiz +pass_threshold: 0.7 + +1. What is the primary purpose of a cover letter? +A. To repeat your CV word for word +B. To explain why you are applying for a specific role and why you are a strong candidate +C. To list your references +D. To provide your salary expectations +Answer: B + +2. How long should a cover letter typically be? +A. 3-4 pages +B. One page +C. Half a page +D. As long as needed +Answer: B + +3. What should the opening paragraph of a cover letter include? +A. Your salary requirements +B. The position you are applying for and how you learned about it +C. A detailed work history +D. Your personal hobbies +Answer: B + +4. What is the difference between a cover letter and a motivation statement? +A. They are exactly the same +B. A motivation statement focuses more on personal motivation and long-term career goals +C. A cover letter is only for academic programs +D. A motivation statement replaces a CV +Answer: B + +5. Which is a recommended writing tip for cover letters? +A. Use a generic template without customization +B. Research the employer and reference their mission or values +C. Focus only on what you want from the company +D. Address it "To Whom It May Concern" +Answer: B + +6. What should the body paragraphs of a cover letter highlight? +A. Your personal life details +B. Your most relevant skills and experience with specific examples +C. Every job you have ever had +D. General statements about being a hard worker +Answer: B + +7. What is a common cover letter mistake? +A. Tailoring the letter to the specific role +B. Showing enthusiasm for the position +C. Repeating your CV word for word +D. Addressing it to a specific person +Answer: C + +8. What should the closing paragraph include? +A. A demand for the job +B. A restatement of interest and a call to action +C. Your full work history +D. Criticism of other candidates +Answer: B + +9. Motivation statements are commonly used for: +A. Retail job applications +B. Academic programs, scholarships, and NGO positions +C. Social media profiles +D. Reference letters +Answer: B + +10. When addressing a cover letter, what is preferred? +A. "Dear Sir or Madam" always +B. A specific person's name when possible +C. No greeting at all +D. "Hey there" +Answer: B diff --git a/backend/app/career_readiness/modules/cv_development.md b/backend/app/career_readiness/modules/cv_development.md index a4f221a2..e721852f 100644 --- a/backend/app/career_readiness/modules/cv_development.md +++ b/backend/app/career_readiness/modules/cv_development.md @@ -5,6 +5,7 @@ description: Learn to build and tailor a professional CV that highlights your sk icon: cv sort_order: 2 input_placeholder: Ask about CVs... +topics: What is a CV, CV Structure, CV Writing Tips, Common Mistakes to Avoid --- # CV Development @@ -71,3 +72,76 @@ For each position, include: - Having gaps in employment without explanation - Using an unprofessional email address - Making the CV too long or too short + +## Quiz +pass_threshold: 0.7 + +1. What is the primary purpose of a CV? +A. To list your hobbies and interests +B. To summarize your education, experience, skills, and achievements for employers +C. To provide personal information like age and marital status +D. To replace a cover letter +Answer: B + +2. What should be included in the Personal Statement section? +A. Your full biography +B. A brief summary of who you are professionally, your key skills, and what you seek +C. Your salary expectations +D. A list of references +Answer: B + +3. When describing work experience, what is recommended? +A. Write long paragraphs about each role +B. Use bullet points with quantified achievements and action verbs +C. Only list the company names +D. Include every task you ever performed +Answer: B + +4. Why should you tailor your CV for each application? +A. Employers like longer CVs +B. To emphasize the experience most relevant to the specific role +C. It is a legal requirement +D. Generic CVs are too short +Answer: B + +5. What is a common mistake to avoid on a CV? +A. Using action verbs +B. Keeping it to 1-2 pages +C. Including irrelevant work experience +D. Using consistent formatting +Answer: C + +6. How long should a CV ideally be? +A. 5 or more pages +B. 1-2 pages +C. Exactly half a page +D. There is no recommended length +Answer: B + +7. Which of these should NOT typically be included on a CV? +A. Contact information +B. Work experience +C. Unprofessional email address +D. Education details +Answer: C + +8. What is the best way to describe achievements on a CV? +A. Use vague statements like "did many things" +B. Use specific, quantified results like "Increased sales by 20%" +C. Let the employer guess what you achieved +D. Only describe responsibilities, not results +Answer: B + +9. Which section of a CV is optional? +A. Contact information +B. Work experience +C. Certifications and volunteer experience +D. Education +Answer: C + +10. What should you always do before submitting your CV? +A. Add a photo +B. Proofread carefully for spelling and grammar errors +C. Make it at least 5 pages long +D. Remove your contact information +Answer: B diff --git a/backend/app/career_readiness/modules/interview_preparation.md b/backend/app/career_readiness/modules/interview_preparation.md index a6d3cc8b..bb518adf 100644 --- a/backend/app/career_readiness/modules/interview_preparation.md +++ b/backend/app/career_readiness/modules/interview_preparation.md @@ -5,6 +5,7 @@ description: Practice common interview questions, learn the STAR method, and bui icon: interview sort_order: 4 input_placeholder: Ask about interviews... +topics: Types of Interviews, The STAR Method, Common Interview Questions, Preparation Checklist, During the Interview --- # Interview Preparation @@ -85,3 +86,76 @@ Use the STAR method to structure your answers to behavioral questions: - Be honest — do not exaggerate or fabricate - Show enthusiasm for the role and organization - Thank the interviewer at the end + +## Quiz +pass_threshold: 0.7 + +1. What does STAR stand for in the interview context? +A. Skills, Training, Aptitude, Results +B. Situation, Task, Action, Result +C. Summary, Technique, Assessment, Review +D. Strengths, Talents, Abilities, Resources +Answer: B + +2. Which type of interview uses questions like "Tell me about a time when..."? +A. Structured interview +B. Panel interview +C. Behavioral interview +D. Situational interview +Answer: C + +3. What is a key characteristic of a structured interview? +A. Only one question is asked +B. Predetermined questions are asked to all candidates +C. The interviewer follows no plan +D. It happens over email +Answer: B + +4. How early should you arrive for an interview? +A. Exactly on time +B. 30 minutes early +C. 10-15 minutes early +D. Timing does not matter +Answer: C + +5. When answering a behavioral question, what should you include in the "Result" part? +A. Only what went wrong +B. The outcome and what you learned +C. Your salary expectations +D. A list of your skills +Answer: B + +6. What should you bring to an interview? +A. Nothing — the employer has everything +B. Copies of your CV and any required documents +C. Gifts for the interviewer +D. Your personal diary +Answer: B + +7. In a panel interview, who should you address? +A. Only the most senior-looking person +B. Only the person who asked the question +C. All panel members +D. No one in particular +Answer: C + +8. Which of these is a good practice during an interview? +A. Exaggerate your experience to impress +B. Take a moment to think before answering difficult questions +C. Avoid making eye contact +D. Interrupt the interviewer to show enthusiasm +Answer: B + +9. What is the purpose of preparing questions to ask the interviewer? +A. To waste time +B. To show interest in the role and gather useful information +C. To challenge the interviewer +D. It is not necessary to prepare questions +Answer: B + +10. What type of interview presents hypothetical scenarios? +A. Behavioral interview +B. Structured interview +C. Situational interview +D. Panel interview +Answer: C diff --git a/backend/app/career_readiness/modules/professional_identity.md b/backend/app/career_readiness/modules/professional_identity.md index 43db0a72..4f8c24a9 100644 --- a/backend/app/career_readiness/modules/professional_identity.md +++ b/backend/app/career_readiness/modules/professional_identity.md @@ -5,6 +5,7 @@ description: Understand your professional identity and categorize your skills in icon: identity sort_order: 1 input_placeholder: Ask about skills and professional identity... +topics: What is Professional Identity, Types of Skills, How to Identify Your Skills, Articulating Your Professional Identity --- # Professional Identity & Skills Mapping @@ -59,3 +60,76 @@ When describing yourself professionally: - Use specific examples to illustrate your abilities - Tailor your identity to the context (job application, networking, etc.) - Be authentic and honest about your experience level + +## Quiz +pass_threshold: 0.7 + +1. What best describes your professional identity? +A. Your job title alone +B. Your unique combination of values, skills, experiences, and aspirations +C. The company you work for +D. Your salary and benefits +Answer: B + +2. Which of the following is a technical skill? +A. Teamwork +B. Leadership +C. Data analysis +D. Time management +Answer: C + +3. Which type of skill is "communication"? +A. Technical skill +B. Knowledge-based skill +C. Transferable skill +D. Digital skill +Answer: C + +4. What is the first step to identifying your skills? +A. Ask your employer to list them +B. Review your experiences including jobs, volunteer work, and education +C. Copy skills from a job posting +D. Take an online personality test +Answer: B + +5. When articulating your professional identity, you should: +A. Use vague descriptions to keep options open +B. Lead with your strongest, most relevant skills +C. List every skill you have ever learned +D. Avoid mentioning specific examples +Answer: B + +6. Which of these is a knowledge-based skill? +A. Problem-solving +B. Industry regulations expertise +C. Communication +D. Adaptability +Answer: B + +7. Why is it important to use action verbs when describing your skills? +A. They make your CV longer +B. They clearly describe what you did and can do +C. Employers only read verbs +D. They are required by law +Answer: B + +8. Which approach helps others identify strengths you might overlook? +A. Reading job descriptions +B. Asking colleagues, mentors, and friends for feedback +C. Watching tutorial videos +D. Taking standardized tests +Answer: B + +9. When tailoring your professional identity, you should: +A. Use the same description for every situation +B. Adjust your identity to the context, such as job applications or networking +C. Avoid mentioning your experience level +D. Only describe technical skills +Answer: B + +10. What should you consider when identifying skills from your experiences? +A. Only paid work experience counts +B. The outcomes and results your skills produced +C. Only skills learned in formal education +D. Skills that are trending on social media +Answer: B diff --git a/backend/app/career_readiness/modules/workplace_readiness.md b/backend/app/career_readiness/modules/workplace_readiness.md index 4ccd82df..57f5d973 100644 --- a/backend/app/career_readiness/modules/workplace_readiness.md +++ b/backend/app/career_readiness/modules/workplace_readiness.md @@ -5,6 +5,7 @@ description: Develop essential workplace skills including communication, problem icon: workplace sort_order: 5 input_placeholder: Ask about workplace skills... +topics: Communication Skills, Problem-Solving, Teamwork, Time Management, Professional Etiquette, Adaptability and Resilience --- # Workplace Readiness Skills @@ -87,3 +88,76 @@ This module helps you develop the essential soft skills and professional behavio - Seek learning opportunities in every situation - Build a support network of colleagues and mentors - Take care of your physical and mental well-being + +## Quiz +pass_threshold: 0.7 + +1. Which is an example of good verbal communication in the workplace? +A. Speaking as quickly as possible +B. Adjusting your language to your audience and practicing active listening +C. Using jargon that only you understand +D. Interrupting colleagues to share your ideas +Answer: B + +2. What is the first step in a structured problem-solving approach? +A. Generate options +B. Choose and implement a solution +C. Clearly identify the problem +D. Review the outcome +Answer: C + +3. When handling conflict in a team, you should: +A. Ignore the issue and hope it goes away +B. Focus on the problem, not the person +C. Blame the other person publicly +D. Avoid communicating about it +Answer: B + +4. Which time management strategy is recommended? +A. Multitask as much as possible +B. Prioritize tasks by importance and deadline +C. Work on whatever feels easiest first +D. Skip using calendars or to-do lists +Answer: B + +5. What is an important aspect of professional etiquette? +A. Arriving late to show you are busy +B. Being punctual and respecting workplace rules +C. Keeping personal matters visible at work +D. Waiting to be told what to do +Answer: B + +6. What does adaptability in the workplace mean? +A. Never changing your routine +B. Being open to change and new ways of working +C. Resisting new ideas from colleagues +D. Only doing tasks you are comfortable with +Answer: B + +7. Which of these is an example of good teamwork? +A. Taking all the credit for team achievements +B. Sharing credit and taking responsibility for your part of the work +C. Working alone to avoid disagreements +D. Missing deadlines because you were busy with personal tasks +Answer: B + +8. What should you do when you receive feedback at work? +A. Argue with the person giving feedback +B. Accept it gracefully and use it to improve +C. Ignore it completely +D. Complain to other colleagues +Answer: B + +9. Why is written communication important in the workplace? +A. It is not important — verbal is enough +B. Professional language in emails ensures clear and effective communication +C. It allows you to use informal language +D. It replaces the need for meetings +Answer: B + +10. What is the best response when problems arise at work? +A. Panic and escalate immediately +B. Stay calm, gather information, and follow a structured approach +C. Ignore the problem until someone else notices +D. Blame a colleague +Answer: B diff --git a/backend/app/career_readiness/pedagogical-approach.md b/backend/app/career_readiness/pedagogical-approach.md new file mode 100644 index 00000000..d7f94501 --- /dev/null +++ b/backend/app/career_readiness/pedagogical-approach.md @@ -0,0 +1,180 @@ +# Career Readiness Modules — Pedagogical Approach + +This document describes the evidence-based pedagogical approach used in the AI-led career readiness tutoring modules. It is intended for review by TEVET, the World Bank, and other stakeholders. + +## Executive Summary + +The career readiness modules use **scaffolded Socratic tutoring** — an approach where the AI agent guides students to build understanding through questions and hints rather than giving answers directly. Research shows this is critical: a Wharton/UPenn study (Bastani et al., 2024, published in PNAS) found that unrestricted AI access **actively harmed** student learning, while a Socratic-guardrailed version significantly improved it. A Harvard study (Kestin et al., 2025, published in Nature) confirmed that scaffolded AI tutoring produced learning gains more than double those of traditional classrooms. + +In practice, the agent: (1) assesses what the student already knows, (2) asks guiding questions, (3) provides hints when the student struggles, and (4) gives direct explanations only as a last resort. Every conversation turn doubles as a comprehension check — the agent asks students to explain concepts back, apply them to their situation, and predict outcomes. The quiz only becomes available after all lesson plan topics have been covered and the student has demonstrated understanding through these checks. + +After passing the quiz, the module stays open in support mode so students can return for follow-up questions — reinforcing retention through spaced practice. + +This approach is aligned with World Bank, UNESCO-UNEVOC, and OECD recommendations for AI integration in technical and vocational education (TVET), and builds on 40 years of research showing that one-to-one tutoring with mastery learning produces the largest known effect on student achievement (Bloom, 1984). + +## Overview + +Each career readiness module is powered by an AI conversational agent that acts as a personal tutor. The agent guides students through structured lesson plan content using techniques grounded in published research on intelligent tutoring systems, AI-led pedagogy, and vocational education. + +The approach is designed around three core principles: + +1. **Scaffolded Socratic tutoring** — guide students to construct understanding rather than passively receive information +2. **Continuous formative assessment** — treat every dialogue turn as an opportunity to check comprehension +3. **Mastery-based progression** — students must demonstrate topic coverage before advancing to the quiz + +--- + +## 1. Teaching Method: Scaffolded Socratic Tutoring + +The agent uses a graduated assistance model that combines Socratic questioning with adaptive scaffolding: + +1. **Assess** — begin each topic by probing what the student already knows +2. **Guide** — ask leading questions that help the student reason through the material +3. **Hint** — if the student struggles, provide partial information or worked examples +4. **Explain** — give direct explanations only as a last resort +5. **Fade** — as the student demonstrates understanding, reduce support and encourage independent reasoning + +This approach avoids two failure modes identified in research: unguarded AI access (which harms learning by giving answers too easily) and naive Socratic questioning without scaffolding (which frustrates students without producing measurable learning gains). + +### Supporting Evidence + +- **Bastani, H., Bastani, O., Sungu, A., Ge, H., Kabakci, O., & Mariman, R. (2024).** "Generative AI Can Harm Learning." *Proceedings of the National Academy of Sciences (PNAS).* Wharton School, University of Pennsylvania. — In a randomized controlled trial with ~1,000 high school students, unrestricted ChatGPT access improved practice scores by 48% but **reduced subsequent test scores by 17%** when access was removed. A pedagogically designed "GPT Tutor" with Socratic guardrails (refusing to give direct answers, requiring students to show work) improved practice by 127% and mitigated the negative transfer effects. This is the strongest causal evidence that unguided AI access harms learning, while scaffolded Socratic design protects it. + - Paper: https://www.pnas.org/doi/10.1073/pnas.2422633122 + - Pre-print: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4895486 + +- **Kestin, G., Miller, K., et al. (2025).** "AI Tutoring Outperforms Active Learning." *Scientific Reports* (Nature). Harvard University, Department of Physics. — In a randomized controlled trial, an AI tutor designed with scaffolding and Socratic questioning produced learning gains **more than double** those of active learning classrooms (median post-test 4.5 vs. 3.5). Students also reported higher engagement and motivation. + - Paper: https://www.nature.com/articles/s41598-025-97652-6 + +- **Blasco, A. & Charisi, V. (2024).** "AI Chatbots in K-12 Education: Socratic vs. Non-Socratic Approaches." Harvard University / European Commission Joint Research Centre. — RCT with 122 students (ages 14-18). A Socratic GPT-4 tutor produced **no measurable learning gains** compared to a direct-help tutor. Demonstrates that naive Socratic questioning alone is insufficient — it must be combined with proper scaffolding and adaptive difficulty. + - Paper: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5040921 + +- **Beale, R. (2025).** "Dialogic Pedagogy for Large Language Models." University of Birmingham, UK. — Synthesizes Vygotsky's Zone of Proximal Development, Socratic method, and Laurillard's Conversational Framework into practical LLM design recommendations. Recommends graduated assistance, tiered Socratic prompting, and fading scaffolds. + - Paper: https://arxiv.org/html/2506.19484v1 + +- **Burns, M. (2026).** "What the Research Shows About Generative AI in Tutoring." Brookings Institution. — Review of 400+ articles. Concludes the most effective AI tutoring platforms "teach, not tell" with Socratic approaches, managing cognitive load through sequential, scaffolded content. + - Article: https://www.brookings.edu/articles/what-the-research-shows-about-generative-ai-in-tutoring/ + +--- + +## 2. Checking Understanding: Formative Assessment Through Dialogue + +The agent embeds comprehension checks throughout the conversation rather than relying solely on the end-of-module quiz. Techniques include: + +- **Explain-back prompts** — asking the student to summarize a concept in their own words +- **Application questions** — asking the student to apply a concept to their own situation (e.g., "What transferable skills do you think you have from your training?") +- **Prediction prompts** — asking the student to predict an outcome before the agent reveals the answer +- **Retrieval practice** — periodically returning to earlier topics to reinforce retention + +Each dialogue turn is treated as a formative assessment opportunity — the agent evaluates whether the student's responses indicate understanding before advancing. + +### Supporting Evidence + +- **Scarlatos, A., Baker, R.S., & Lan, A. (2024).** "Exploring Knowledge Tracing in Tutor-Student Dialogues." University of Massachusetts Amherst / University of Pennsylvania. Published at LAK '25. — Introduces a framework for estimating student knowledge from conversational turns. GPT-4o achieved 0.93/1.0 accuracy in evaluating student response correctness from dialogue. Demonstrates that every dialogue turn can serve as formative assessment. + - Paper: https://arxiv.org/html/2409.16490v2 + +- **Bloom, B.S. (1984).** "The 2 Sigma Problem: The Search for Methods of Group Instruction as Effective as One-to-One Tutoring." *Educational Researcher*, 13(6), 4-16. University of Chicago. — The foundational study establishing that one-to-one tutoring with mastery learning produces a 2-standard-deviation improvement in student achievement. Key to the approach: students must demonstrate mastery (80-90% criterion) on formative assessments before proceeding. + - Paper: https://journals.sagepub.com/doi/10.3102/0013189X013006004 + +- **Vanacore, K., Baker, R.S., Closser, A.H., & Roschelle, J. (2025).** "The Path to Conversational AI Tutors." Cornell University / University of Adelaide / University of Florida / Digital Promise. — Recommends conditioning LLM responses on knowledge tracing outputs and distinguishing between slips (student knows but erred — provide encouragement), non-mastery (use diagnostic questioning), and multiple misconceptions (provide worked examples). + - Paper: https://arxiv.org/html/2602.19303v1 + +--- + +## 3. Quiz Readiness: Topic Coverage as Mastery Proxy + +The quiz becomes available only after the agent determines that all lesson plan topics have been sufficiently covered. + +### How It Works + +Each module's markdown file contains clearly delineated topic sections. The agent uses these as a checklist: + +1. The agent covers each topic through guided conversation +2. For each topic, the agent embeds comprehension checks (as described above) +3. Once all topics have been addressed and the student has responded satisfactorily, the agent transitions to quiz delivery + +This is a simplified mastery model appropriate for the pilot phase. Full Bayesian Knowledge Tracing (as used in research-grade ITS) is out of scope but could be added in future iterations. + +### Supporting Evidence + +- **VanLehn, K. (2011).** "The Relative Effectiveness of Human Tutoring, Intelligent Tutoring Systems, and Other Tutoring Systems." *Educational Psychologist*, 46(4), 197-221. Arizona State University. — Meta-analysis of ~50 studies. Step-based ITS that track mastery at the knowledge-component level produce effect sizes of d=0.76, nearly matching human tutoring (d=0.79). The system advances students to assessment when knowledge tracing indicates mastery across requisite knowledge components. + - Paper: https://www.tandfonline.com/doi/abs/10.1080/00461520.2011.611369 + +--- + +## 4. Quiz Delivery: Conversational Format, Deterministic Evaluation + +The quiz is delivered within the chat conversation (not as a separate UI component) to maintain engagement and continuity. However, questions are static (predefined per module) and evaluation is deterministic (handled by the service layer, not the LLM) to ensure reliability. + +- 10 multiple-choice questions per module +- 70% pass threshold (placeholder — to be confirmed by TEVET) +- On pass: module marked completed, next module unlocked, module remains accessible in support mode +- On fail: module stays in instruction mode, student can retry + +### Supporting Evidence + +- **Ruan, S., Jiang, L., Xu, J., et al. (2019).** "QuizBot: A Dialogue-based Adaptive Learning System for Factual Knowledge." *Proceedings of CHI '19*, ACM. Stanford University. — Users of a conversational quiz format recalled **20% more correct answers** than flashcard users and spent **2.6x more time** learning. Students strongly preferred the conversational format for engagement. + - Paper: https://dl.acm.org/doi/fullHtml/10.1145/3290605.3300587 + +--- + +## 5. Conversation Modes + +### Instruction Mode (Default) + +Active during the teaching phase. The agent: +- Follows the lesson plan structure +- Uses scaffolded Socratic techniques +- Tracks topic coverage +- Transitions to quiz when all topics are covered + +### Support Mode (After Quiz Completion) + +After a student passes the quiz, the module remains accessible. The agent: +- Answers follow-up questions about the module's topics +- References the module content as grounding +- Does not re-initiate the lesson plan or re-deliver the quiz + +This design ensures students can revisit material for reinforcement, consistent with spaced retrieval practice principles. + +--- + +## 6. TVET-Specific Context + +The approach is informed by development-sector research on AI in technical and vocational education: + +- **UNESCO-UNEVOC (2021).** "Understanding the Impact of Artificial Intelligence on Skills Development." — AI-powered tutoring can enhance TVET with individualized, on-demand support and real-time feedback. Notes that only 34% of TVET institutions globally have the bandwidth for advanced AI tools, and 78% of teachers lack confidence in using them. + - Report: https://unevoc.unesco.org/pub/understanding_the_impact_of_ai_on_skills_development.pdf + +- **World Bank (2024).** "Building Better Formal TVET Systems: Principles and Practice in Low- and Middle-Income Countries." Joint publication with ILO and UNESCO. — Addresses the need for TVET systems to adapt to technological progress, including AI integration for skills development. + - Report: https://www.worldbank.org/en/topic/skillsdevelopment/publication/better-technical-vocational-education-training-TVET + +- **World Bank (2024).** "AI Revolution in Education: What You Need to Know." Digital Innovations in Education Series (Latin America & Caribbean). — Documents AI-powered tutors as a key innovation for personalized learning in developing country education systems. + - Report: https://documents.worldbank.org/en/publication/documents-reports/documentdetail/099734306182493324 + +- **OECD (2025).** "How Can Innovative Technologies Transform Vocational Education and Training." — Documents how AI and adaptive learning platforms can engage VET learners with personalized support and align training outcomes with labor market needs. + - Report: https://www.oecd.org/en/publications/2025/05/how-can-innovative-technologies-transform-vocational-education-and-training_5b10f8ac.html + +- **UNESCO-UNEVOC (2025).** "European Insights: AI Integration in TVET — Policies, Practices and Pathways for Inclusive Innovation." — Documents AI integration practices across European TVET systems with policy pathways for inclusive innovation. + - Report: https://atlas.unevoc.unesco.org/research-briefs/european-insights-ai-integration-in-tvet-policies-practices-and-pathways-for-inclusive-innovation + +--- + +## Full References + +| # | Authors | Year | Title | Institution | Link | +|---|---------|------|-------|-------------|------| +| 1 | Bastani, H. et al. | 2024 | Generative AI Can Harm Learning | Wharton / UPenn (PNAS) | [Link](https://www.pnas.org/doi/10.1073/pnas.2422633122) | +| 2 | Kestin, G. et al. | 2025 | AI Tutoring Outperforms Active Learning | Harvard (Nature Scientific Reports) | [Link](https://www.nature.com/articles/s41598-025-97652-6) | +| 3 | Blasco, A. & Charisi, V. | 2024 | AI Chatbots in K-12 Education: Socratic vs Non-Socratic | Harvard / EU JRC | [Link](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5040921) | +| 4 | Beale, R. | 2025 | Dialogic Pedagogy for LLMs | University of Birmingham | [Link](https://arxiv.org/html/2506.19484v1) | +| 5 | Burns, M. | 2026 | What the Research Shows About GenAI in Tutoring | Brookings Institution | [Link](https://www.brookings.edu/articles/what-the-research-shows-about-generative-ai-in-tutoring/) | +| 6 | Scarlatos, A. et al. | 2024 | Knowledge Tracing in Tutor-Student Dialogues | UMass / UPenn (LAK '25) | [Link](https://arxiv.org/html/2409.16490v2) | +| 7 | Bloom, B.S. | 1984 | The 2 Sigma Problem | University of Chicago | [Link](https://journals.sagepub.com/doi/10.3102/0013189X013006004) | +| 8 | VanLehn, K. | 2011 | Relative Effectiveness of ITS | Arizona State University | [Link](https://www.tandfonline.com/doi/abs/10.1080/00461520.2011.611369) | +| 9 | Vanacore, K. et al. | 2025 | The Path to Conversational AI Tutors | Cornell / U. Adelaide / Digital Promise | [Link](https://arxiv.org/html/2602.19303v1) | +| 10 | Ruan, S. et al. | 2019 | QuizBot: Dialogue-based Adaptive Learning | Stanford (CHI '19) | [Link](https://dl.acm.org/doi/fullHtml/10.1145/3290605.3300587) | +| 11 | UNESCO-UNEVOC | 2021 | Impact of AI on Skills Development | UNESCO | [Link](https://unevoc.unesco.org/pub/understanding_the_impact_of_ai_on_skills_development.pdf) | +| 12 | World Bank / ILO / UNESCO | 2024 | Building Better Formal TVET Systems | World Bank | [Link](https://www.worldbank.org/en/topic/skillsdevelopment/publication/better-technical-vocational-education-training-TVET) | +| 13 | World Bank | 2024 | AI Revolution in Education | World Bank | [Link](https://documents.worldbank.org/en/publication/documents-reports/documentdetail/099734306182493324) | +| 14 | OECD | 2025 | Innovative Technologies in VET | OECD | [Link](https://www.oecd.org/en/publications/2025/05/how-can-innovative-technologies-transform-vocational-education-and-training_5b10f8ac.html) | +| 15 | UNESCO-UNEVOC | 2025 | AI Integration in TVET: European Insights | UNESCO | [Link](https://atlas.unevoc.unesco.org/research-briefs/european-insights-ai-integration-in-tvet-policies-practices-and-pathways-for-inclusive-innovation) | diff --git a/backend/app/career_readiness/repository.py b/backend/app/career_readiness/repository.py index 83ddeae4..02deb504 100644 --- a/backend/app/career_readiness/repository.py +++ b/backend/app/career_readiness/repository.py @@ -6,10 +6,13 @@ from datetime import datetime, timezone from motor.motor_asyncio import AsyncIOMotorDatabase +from pymongo.errors import DuplicateKeyError +from app.career_readiness.errors import ConversationAlreadyExistsError from app.career_readiness.types import ( CareerReadinessConversationDocument, CareerReadinessMessage, + ConversationMode, ) from app.server_dependencies.database_collections import Collections @@ -42,6 +45,26 @@ async def append_message(self, conversation_id: str, message: CareerReadinessMes """Append a message to a conversation and update the updated_at timestamp.""" raise NotImplementedError() + @abstractmethod + async def update_covered_topics(self, conversation_id: str, topics: list[str]) -> None: + """Update the list of covered topics for a conversation.""" + raise NotImplementedError() + + @abstractmethod + async def update_quiz_delivered(self, conversation_id: str, delivered: bool) -> None: + """Update whether the quiz has been delivered to the user.""" + raise NotImplementedError() + + @abstractmethod + async def update_quiz_passed(self, conversation_id: str, passed: bool) -> None: + """Update whether the user passed the quiz.""" + raise NotImplementedError() + + @abstractmethod + async def update_conversation_mode(self, conversation_id: str, mode: ConversationMode) -> None: + """Update the conversation mode (INSTRUCTION or SUPPORT).""" + raise NotImplementedError() + @abstractmethod async def delete_by_conversation_id(self, conversation_id: str) -> bool: """Delete a conversation. Returns True if deleted, False if not found.""" @@ -56,7 +79,10 @@ def __init__(self, db: AsyncIOMotorDatabase): self._logger = logging.getLogger(CareerReadinessConversationRepository.__name__) async def create(self, document: CareerReadinessConversationDocument) -> None: - await self._collection.insert_one(document.model_dump()) + try: + await self._collection.insert_one(document.model_dump()) + except DuplicateKeyError as e: + raise ConversationAlreadyExistsError(document.module_id, document.user_id) from e async def find_by_conversation_id(self, conversation_id: str) -> CareerReadinessConversationDocument | None: result = await self._collection.find_one( @@ -91,6 +117,34 @@ async def append_message(self, conversation_id: str, message: CareerReadinessMes }, ) + async def update_covered_topics(self, conversation_id: str, topics: list[str]) -> None: + now = datetime.now(timezone.utc).isoformat() + await self._collection.update_one( + {"conversation_id": {"$eq": conversation_id}}, + {"$set": {"covered_topics": topics, "updated_at": now}}, + ) + + async def update_quiz_delivered(self, conversation_id: str, delivered: bool) -> None: + now = datetime.now(timezone.utc).isoformat() + await self._collection.update_one( + {"conversation_id": {"$eq": conversation_id}}, + {"$set": {"quiz_delivered": delivered, "updated_at": now}}, + ) + + async def update_quiz_passed(self, conversation_id: str, passed: bool) -> None: + now = datetime.now(timezone.utc).isoformat() + await self._collection.update_one( + {"conversation_id": {"$eq": conversation_id}}, + {"$set": {"quiz_passed": passed, "updated_at": now}}, + ) + + async def update_conversation_mode(self, conversation_id: str, mode: ConversationMode) -> None: + now = datetime.now(timezone.utc).isoformat() + await self._collection.update_one( + {"conversation_id": {"$eq": conversation_id}}, + {"$set": {"conversation_mode": mode.value, "updated_at": now}}, + ) + async def delete_by_conversation_id(self, conversation_id: str) -> bool: result = await self._collection.delete_one( {"conversation_id": {"$eq": conversation_id}} diff --git a/backend/app/career_readiness/routes.py b/backend/app/career_readiness/routes.py index 1bdac929..02f64d34 100644 --- a/backend/app/career_readiness/routes.py +++ b/backend/app/career_readiness/routes.py @@ -15,6 +15,7 @@ ConversationAlreadyExistsError, ConversationModuleMismatchError, ConversationNotFoundError, + ModuleNotUnlockedError, ) from app.career_readiness.module_loader import get_module_registry from app.career_readiness.repository import CareerReadinessConversationRepository @@ -108,6 +109,7 @@ async def _get_module( response_model=CareerReadinessConversationResponse, responses={ HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, HTTPStatus.CONFLICT: {"model": HTTPErrorResponse}, HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, }, @@ -122,6 +124,8 @@ async def _create_conversation( return await service.create_conversation(user_info.user_id, module_id) except CareerReadinessModuleNotFoundError as exc: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"Module not found: {module_id}") from exc + except ModuleNotUnlockedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(exc)) from exc except ConversationAlreadyExistsError as exc: raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=f"A conversation already exists for module {module_id}") from exc diff --git a/backend/app/career_readiness/service.py b/backend/app/career_readiness/service.py index 1571e108..a5d7a2f7 100644 --- a/backend/app/career_readiness/service.py +++ b/backend/app/career_readiness/service.py @@ -5,6 +5,8 @@ the career readiness business logic. """ import logging +import math +import re from abc import ABC, abstractmethod from datetime import datetime, timezone from typing import Callable @@ -12,21 +14,23 @@ from bson import ObjectId from app.agent.agent_types import AgentInput, AgentOutput -from app.career_readiness.agent import CareerReadinessAgent +from app.career_readiness.agent import CareerReadinessAgent, CareerReadinessAgentOutput from app.career_readiness.errors import ( ConversationAccessDeniedError, ConversationAlreadyExistsError, ConversationModuleMismatchError, ConversationNotFoundError, CareerReadinessModuleNotFoundError, + ModuleNotUnlockedError, ) -from app.career_readiness.module_loader import ModuleConfig, ModuleRegistry +from app.career_readiness.module_loader import ModuleConfig, ModuleRegistry, QuizConfig from app.career_readiness.repository import ICareerReadinessConversationRepository from app.career_readiness.types import ( CareerReadinessConversationDocument, CareerReadinessConversationResponse, CareerReadinessMessage, CareerReadinessMessageSender, + ConversationMode, ModuleDetail, ModuleListResponse, ModuleStatus, @@ -39,6 +43,15 @@ ) +SILENCE_MESSAGE = "(silence)" +"""Synthetic user message stored alongside the agent intro, matching the core Compass pattern.""" + + +def _filter_silence(messages: list[CareerReadinessMessage]) -> list[CareerReadinessMessage]: + """Filter out synthetic silence messages before returning to the frontend.""" + return [m for m in messages if m.message != SILENCE_MESSAGE] + + class ICareerReadinessService(ABC): """Interface for the career readiness service.""" @@ -75,19 +88,53 @@ async def delete_conversation(self, user_id: str, module_id: str, conversation_i raise NotImplementedError() -def _default_agent_factory(module_config: ModuleConfig) -> CareerReadinessAgent: +def _default_agent_factory(module_config: ModuleConfig, mode: ConversationMode) -> CareerReadinessAgent: """Default factory that creates a real CareerReadinessAgent.""" return CareerReadinessAgent( module_title=module_config.title, module_content=module_config.content, + mode=mode, + topics=module_config.topics, ) -def _derive_status(conversation: CareerReadinessConversationDocument | None) -> ModuleStatus: - """Derive the module status from the conversation state.""" - if conversation is None: - return ModuleStatus.NOT_STARTED - return ModuleStatus.IN_PROGRESS +def _derive_module_statuses( + all_modules: list[ModuleConfig], + user_conversations: list[CareerReadinessConversationDocument], +) -> dict[str, ModuleStatus]: + """ + Derive the status of all modules based on sequential unlock logic. + + Rules: + - First module is always UNLOCKED (if no conversation exists) + - Subsequent modules unlock when the previous module is COMPLETED (quiz passed) + - A module with a conversation is IN_PROGRESS (unless quiz passed → COMPLETED) + - Only one module can be UNLOCKED at a time (the next eligible one) + """ + conv_by_module = {c.module_id: c for c in user_conversations} + sorted_modules = sorted(all_modules, key=lambda m: m.sort_order) + + statuses: dict[str, ModuleStatus] = {} + previous_completed = True # First module is always unlocked + + for module in sorted_modules: + conversation = conv_by_module.get(module.id) + + if conversation is not None: + if conversation.quiz_passed: + statuses[module.id] = ModuleStatus.COMPLETED + previous_completed = True + else: + statuses[module.id] = ModuleStatus.IN_PROGRESS + previous_completed = False + elif previous_completed: + statuses[module.id] = ModuleStatus.UNLOCKED + previous_completed = False + else: + statuses[module.id] = ModuleStatus.NOT_STARTED + previous_completed = False + + return statuses def _build_conversation_context(messages: list[CareerReadinessMessage]) -> ConversationContext: @@ -137,6 +184,62 @@ def _build_conversation_context(messages: list[CareerReadinessMessage]) -> Conve return ConversationContext(all_history=history, history=history) +def _format_quiz_message(quiz: QuizConfig) -> str: + """Format quiz questions into a chat message for the user.""" + lines = [ + "Great work! You've covered all the topics. Now let's check your understanding with a quick quiz.", + "", + "Please answer each question by typing the letter of your answer (e.g., '1.B, 2.A, 3.C, ...').", + "", + ] + for i, q in enumerate(quiz.questions, 1): + lines.append(f"{i}. {q.question}") + for option in q.options: + lines.append(f" {option}") + lines.append("") + return "\n".join(lines) + + +def _parse_quiz_answers(user_input: str) -> dict[int, str]: + """ + Parse quiz answers from user input. + + Supports formats like: "1.B, 2.A, 3.C" or "1. B 2. A 3. C" or "B, A, C" + Returns dict of question_number (1-indexed) -> answer letter (uppercase). + """ + answers: dict[int, str] = {} + # Try numbered format: "1.B" or "1. B" + numbered = re.findall(r"(\d+)\.\s*([A-Da-d])", user_input) + if numbered: + for num_str, letter in numbered: + answers[int(num_str)] = letter.upper() + else: + # Try sequential letters: "B, A, C, D" or "B A C D" + # Only match standalone letters (not embedded in words) and require at least 2 + letters = re.findall(r"\b([A-Da-d])\b", user_input) + if len(letters) >= 2: + for i, letter in enumerate(letters, 1): + answers[i] = letter.upper() + return answers + + +def _evaluate_quiz(quiz: QuizConfig, answers: dict[int, str]) -> tuple[int, int, list[bool]]: + """ + Evaluate quiz answers deterministically. + Returns (score, total, list_of_correct_booleans). + """ + total = len(quiz.questions) + results = [] + score = 0 + for i, question in enumerate(quiz.questions, 1): + user_answer = answers.get(i, "") + correct = user_answer == question.correct_answer + results.append(correct) + if correct: + score += 1 + return score, total, results + + class CareerReadinessService(ICareerReadinessService): """Implementation of the career readiness service.""" @@ -144,7 +247,7 @@ def __init__( self, repository: ICareerReadinessConversationRepository, module_registry: ModuleRegistry, - agent_factory: Callable[[ModuleConfig], CareerReadinessAgent] | None = None, + agent_factory: Callable[[ModuleConfig, ConversationMode], CareerReadinessAgent] | None = None, ): self._repository = repository self._module_registry = module_registry @@ -176,19 +279,16 @@ def _validate_access(self, conversation: CareerReadinessConversationDocument, async def list_modules(self, user_id: str) -> ModuleListResponse: all_modules = self._module_registry.get_all_modules() user_conversations = await self._repository.find_all_by_user(user_id) - - # Build a lookup from module_id to conversation - conv_by_module = {conv.module_id: conv for conv in user_conversations} + statuses = _derive_module_statuses(all_modules, user_conversations) summaries = [] for module in all_modules: - conversation = conv_by_module.get(module.id) summaries.append(ModuleSummary( id=module.id, title=module.title, description=module.description, icon=module.icon, - status=_derive_status(conversation), + status=statuses.get(module.id, ModuleStatus.NOT_STARTED), sort_order=module.sort_order, input_placeholder=module.input_placeholder, )) @@ -197,14 +297,17 @@ async def list_modules(self, user_id: str) -> ModuleListResponse: async def get_module(self, user_id: str, module_id: str) -> ModuleDetail: module = self._get_module_or_raise(module_id) - conversation = await self._repository.find_by_user_and_module(user_id, module_id) + all_modules = self._module_registry.get_all_modules() + user_conversations = await self._repository.find_all_by_user(user_id) + statuses = _derive_module_statuses(all_modules, user_conversations) + conversation = next((c for c in user_conversations if c.module_id == module_id), None) return ModuleDetail( id=module.id, title=module.title, description=module.description, icon=module.icon, - status=_derive_status(conversation), + status=statuses.get(module.id, ModuleStatus.NOT_STARTED), sort_order=module.sort_order, input_placeholder=module.input_placeholder, scope=module.content, @@ -214,22 +317,38 @@ async def get_module(self, user_id: str, module_id: str) -> ModuleDetail: async def create_conversation(self, user_id: str, module_id: str) -> CareerReadinessConversationResponse: module = self._get_module_or_raise(module_id) + # Check sequential unlock + all_modules = self._module_registry.get_all_modules() + user_conversations = await self._repository.find_all_by_user(user_id) + statuses = _derive_module_statuses(all_modules, user_conversations) + module_status = statuses.get(module_id, ModuleStatus.NOT_STARTED) + + if module_status == ModuleStatus.NOT_STARTED: + raise ModuleNotUnlockedError(module_id) + # Check for existing conversation - existing = await self._repository.find_by_user_and_module(user_id, module_id) + existing = next((c for c in user_conversations if c.module_id == module_id), None) if existing is not None: raise ConversationAlreadyExistsError(module_id, user_id) - # Create agent and generate intro message - agent = self._agent_factory(module) + # Create agent in instruction mode and generate intro + agent = self._agent_factory(module, ConversationMode.INSTRUCTION) empty_context = ConversationContext( all_history=ConversationHistory(), history=ConversationHistory(), ) - intro_output = await agent.generate_intro_message(empty_context) + intro_result = await agent.generate_intro_message(empty_context) + intro_output = intro_result.agent_output - # Build conversation document + # Build conversation document with synthetic silence + intro (matches core Compass pattern) now = datetime.now(timezone.utc) conversation_id = str(ObjectId()) + silence_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message=SILENCE_MESSAGE, + sender=CareerReadinessMessageSender.USER, + sent_at=now, + ) intro_message = CareerReadinessMessage( message_id=intro_output.message_id or str(ObjectId()), message=intro_output.message_for_user, @@ -241,7 +360,8 @@ async def create_conversation(self, user_id: str, module_id: str) -> CareerReadi conversation_id=conversation_id, module_id=module_id, user_id=user_id, - messages=[intro_message], + messages=[silence_message, intro_message], + conversation_mode=ConversationMode.INSTRUCTION, created_at=now, updated_at=now, ) @@ -250,7 +370,9 @@ async def create_conversation(self, user_id: str, module_id: str) -> CareerReadi return CareerReadinessConversationResponse( conversation_id=conversation_id, module_id=module_id, - messages=[intro_message], + messages=_filter_silence([intro_message]), + covered_topics=[], + conversation_mode=ConversationMode.INSTRUCTION, ) async def send_message(self, user_id: str, module_id: str, @@ -259,10 +381,23 @@ async def send_message(self, user_id: str, module_id: str, conversation = await self._get_conversation_or_raise(conversation_id) self._validate_access(conversation, user_id, module_id) - # Snapshot existing messages before any mutation + # Three-mode dispatch + if conversation.quiz_delivered and not conversation.quiz_passed: + return await self._handle_quiz_submission(module, conversation, user_input) + elif conversation.conversation_mode == ConversationMode.SUPPORT: + return await self._handle_support_message(module, conversation, user_input) + else: + return await self._handle_instruction_message(module, conversation, user_input) + + async def _handle_instruction_message( + self, module: ModuleConfig, + conversation: CareerReadinessConversationDocument, + user_input: str, + ) -> CareerReadinessConversationResponse: + """Handle a message in instruction mode with topic tracking and quiz trigger.""" existing_messages = list(conversation.messages) - # Build user message + # Build and persist user message now = datetime.now(timezone.utc) user_message = CareerReadinessMessage( message_id=str(ObjectId()), @@ -270,37 +405,161 @@ async def send_message(self, user_id: str, module_id: str, sender=CareerReadinessMessageSender.USER, sent_at=now, ) + await self._repository.append_message(conversation.conversation_id, user_message) - # Persist user message before calling the LLM (crash-safety) - await self._repository.append_message(conversation_id, user_message) - - # Build context from in-memory state (existing messages + new user message) + # Build context and call agent all_messages = existing_messages + [user_message] context = _build_conversation_context(all_messages) + agent = self._agent_factory(module, ConversationMode.INSTRUCTION) + agent_input = AgentInput(message=user_input, sent_at=now) + agent_result: CareerReadinessAgentOutput = await agent.execute(agent_input, context) - # Get agent response - agent = self._agent_factory(module) - agent_input = AgentInput( + # Accumulate covered topics + new_covered = set(conversation.covered_topics) | set(agent_result.topics_covered) + covered_topics_list = sorted(new_covered) + await self._repository.update_covered_topics(conversation.conversation_id, covered_topics_list) + + # Build and persist agent response + agent_output = agent_result.agent_output + agent_message = CareerReadinessMessage( + message_id=agent_output.message_id or str(ObjectId()), + message=agent_output.message_for_user, + sender=CareerReadinessMessageSender.AGENT, + sent_at=agent_output.sent_at, + ) + await self._repository.append_message(conversation.conversation_id, agent_message) + response_messages = all_messages + [agent_message] + + # Check if all topics are covered AND agent says finished → deliver quiz + all_topics_covered = set(module.topics).issubset(new_covered) if module.topics else True + if all_topics_covered and agent_output.finished and module.quiz is not None: + quiz_msg = self._build_and_persist_quiz_message(module.quiz) + await self._repository.append_message(conversation.conversation_id, quiz_msg) + await self._repository.update_quiz_delivered(conversation.conversation_id, True) + response_messages.append(quiz_msg) + + return CareerReadinessConversationResponse( + conversation_id=conversation.conversation_id, + module_id=conversation.module_id, + messages=_filter_silence(response_messages), + covered_topics=covered_topics_list, + conversation_mode=ConversationMode.INSTRUCTION, + ) + + async def _handle_quiz_submission( + self, module: ModuleConfig, + conversation: CareerReadinessConversationDocument, + user_input: str, + ) -> CareerReadinessConversationResponse: + """Evaluate quiz answers deterministically and update state.""" + if module.quiz is None: + raise ValueError(f"Module {module.id} has no quiz but quiz_delivered is True") + + # If the input doesn't contain valid quiz answers, handle as a conversational message. + # TODO: Quiz interaction (re-showing questions, asking about specific questions, navigating + # back to the quiz after asking for help) would be best handled by the frontend with a + # dedicated quiz UI component rather than through chat messages. For now, we fall through + # to instruction mode which loses quiz context. + answers = _parse_quiz_answers(user_input) + if not answers: + return await self._handle_instruction_message(module, conversation, user_input) + + existing_messages = list(conversation.messages) + + # Persist user answer message + now = datetime.now(timezone.utc) + user_message = CareerReadinessMessage( + message_id=str(ObjectId()), message=user_input, + sender=CareerReadinessMessageSender.USER, sent_at=now, ) - agent_output = await agent.execute(agent_input, context) + await self._repository.append_message(conversation.conversation_id, user_message) + score, total, _ = _evaluate_quiz(module.quiz, answers) + passed = (score / total) >= module.quiz.pass_threshold if total > 0 else False + + if passed: + feedback = (f"You scored {score}/{total}. Congratulations, you passed! " + "You can now continue to ask me any follow-up questions about this topic.") + await self._repository.update_quiz_passed(conversation.conversation_id, True) + await self._repository.update_conversation_mode( + conversation.conversation_id, ConversationMode.SUPPORT) + result_mode = ConversationMode.SUPPORT + else: + threshold_count = math.ceil(module.quiz.pass_threshold * total) + feedback = (f"You scored {score}/{total}. You need at least {threshold_count} " + "correct answers to pass. Let's review the topics and try again.") + await self._repository.update_quiz_delivered(conversation.conversation_id, False) + result_mode = ConversationMode.INSTRUCTION + + feedback_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message=feedback, + sender=CareerReadinessMessageSender.AGENT, + sent_at=datetime.now(timezone.utc), + ) + await self._repository.append_message(conversation.conversation_id, feedback_message) + + response_messages = existing_messages + [user_message, feedback_message] + return CareerReadinessConversationResponse( + conversation_id=conversation.conversation_id, + module_id=conversation.module_id, + messages=_filter_silence(response_messages), + covered_topics=conversation.covered_topics, + quiz_passed=passed, + conversation_mode=result_mode, + module_completed=passed, + ) + + async def _handle_support_message( + self, module: ModuleConfig, + conversation: CareerReadinessConversationDocument, + user_input: str, + ) -> CareerReadinessConversationResponse: + """Handle a message in support mode (post-quiz follow-up Q&A).""" + existing_messages = list(conversation.messages) + + now = datetime.now(timezone.utc) + user_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message=user_input, + sender=CareerReadinessMessageSender.USER, + sent_at=now, + ) + await self._repository.append_message(conversation.conversation_id, user_message) + + all_messages = existing_messages + [user_message] + context = _build_conversation_context(all_messages) + agent = self._agent_factory(module, ConversationMode.SUPPORT) + agent_input = AgentInput(message=user_input, sent_at=now) + agent_result: CareerReadinessAgentOutput = await agent.execute(agent_input, context) + agent_output = agent_result.agent_output - # Build and persist agent response agent_message = CareerReadinessMessage( message_id=agent_output.message_id or str(ObjectId()), message=agent_output.message_for_user, sender=CareerReadinessMessageSender.AGENT, sent_at=agent_output.sent_at, ) - await self._repository.append_message(conversation_id, agent_message) + await self._repository.append_message(conversation.conversation_id, agent_message) - # Build response from in-memory state (no re-read needed) return CareerReadinessConversationResponse( - conversation_id=conversation_id, - module_id=module_id, - messages=all_messages + [agent_message], - module_completed=agent_output.finished, + conversation_id=conversation.conversation_id, + module_id=conversation.module_id, + messages=_filter_silence(all_messages + [agent_message]), + covered_topics=conversation.covered_topics, + quiz_passed=True, + conversation_mode=ConversationMode.SUPPORT, + ) + + @staticmethod + def _build_and_persist_quiz_message(quiz: QuizConfig) -> CareerReadinessMessage: + """Format quiz into a CareerReadinessMessage (caller persists it).""" + return CareerReadinessMessage( + message_id=str(ObjectId()), + message=_format_quiz_message(quiz), + sender=CareerReadinessMessageSender.AGENT, + sent_at=datetime.now(timezone.utc), ) async def get_conversation_history(self, user_id: str, module_id: str, @@ -312,7 +571,10 @@ async def get_conversation_history(self, user_id: str, module_id: str, return CareerReadinessConversationResponse( conversation_id=conversation.conversation_id, module_id=conversation.module_id, - messages=conversation.messages, + messages=_filter_silence(list(conversation.messages)), + covered_topics=conversation.covered_topics, + conversation_mode=conversation.conversation_mode, + quiz_passed=conversation.quiz_passed if conversation.quiz_delivered else None, ) async def delete_conversation(self, user_id: str, module_id: str, conversation_id: str) -> None: diff --git a/backend/app/career_readiness/test_agent.py b/backend/app/career_readiness/test_agent.py index 71b6d6e8..fe91e5a3 100644 --- a/backend/app/career_readiness/test_agent.py +++ b/backend/app/career_readiness/test_agent.py @@ -1,94 +1,199 @@ """ Tests for the career readiness agent. """ -from unittest.mock import AsyncMock, patch, MagicMock +from unittest.mock import AsyncMock, patch import pytest from app.agent.agent_types import AgentInput, AgentType, LLMStats -from app.agent.simple_llm_agent.llm_response import ModelResponse -from app.career_readiness.agent import CareerReadinessAgent, _build_system_instructions +from app.career_readiness.agent import ( + CareerReadinessAgent, + CareerReadinessAgentOutput, + CareerReadinessModelResponse, + _build_instruction_mode_instructions, + _build_support_mode_instructions, +) +from app.career_readiness.types import ConversationMode from app.conversation_memory.conversation_memory_types import ( ConversationContext, ConversationHistory, ) -class TestBuildSystemInstructions: - """Tests for the system instructions builder.""" +class TestBuildInstructionModeInstructions: + """Tests for the instruction mode system instructions builder.""" def test_includes_module_title(self): - # GIVEN a module title and content + # GIVEN a module title, content, and topics given_title = "CV Development" given_content = "How to write a great CV." + given_topics = ["CV Structure", "Writing Tips"] - # WHEN the system instructions are built - actual_instructions = _build_system_instructions(given_title, given_content) + # WHEN the instruction mode instructions are built + actual_instructions = _build_instruction_mode_instructions(given_title, given_content, given_topics) - # THEN the module title is included in the instructions + # THEN the module title is included assert given_title in actual_instructions def test_includes_module_content(self): - # GIVEN a module title and content + # GIVEN a module title, content, and topics given_title = "Interview Preparation" given_content = "Practice answering behavioral questions using the STAR method." + given_topics = ["STAR Method"] - # WHEN the system instructions are built - actual_instructions = _build_system_instructions(given_title, given_content) + # WHEN the instruction mode instructions are built + actual_instructions = _build_instruction_mode_instructions(given_title, given_content, given_topics) - # THEN the module content is included in the instructions + # THEN the module content is included assert given_content in actual_instructions - def test_includes_json_response_instructions(self): + def test_includes_topics_list(self): + # GIVEN topics for the module + given_topics = ["Types of Skills", "How to Identify Skills", "Professional Identity"] + + # WHEN the instruction mode instructions are built + actual_instructions = _build_instruction_mode_instructions("Module", "Content", given_topics) + + # THEN each topic appears in the instructions + for topic in given_topics: + assert topic in actual_instructions + + def test_includes_scaffolded_socratic_instructions(self): + # GIVEN any module + # WHEN the instruction mode instructions are built + actual_instructions = _build_instruction_mode_instructions("Module", "Content", ["Topic"]) + + # THEN the scaffolded Socratic tutoring approach is present + assert "ASSESS" in actual_instructions + assert "GUIDE" in actual_instructions + assert "HINT" in actual_instructions + assert "EXPLAIN" in actual_instructions + assert "FADE" in actual_instructions + + def test_includes_comprehension_check_instructions(self): + # GIVEN any module + # WHEN the instruction mode instructions are built + actual_instructions = _build_instruction_mode_instructions("Module", "Content", ["Topic"]) + + # THEN comprehension check techniques are mentioned + assert "explain" in actual_instructions.lower() + assert "application" in actual_instructions.lower() + + def test_includes_topics_covered_in_response_schema(self): + # GIVEN any module + # WHEN the instruction mode instructions are built + actual_instructions = _build_instruction_mode_instructions("Module", "Content", ["Topic"]) + + # THEN the response schema mentions topics_covered + assert "topics_covered" in actual_instructions + + def test_instructs_not_to_reveal_quiz(self): + # GIVEN any module + # WHEN the instruction mode instructions are built + actual_instructions = _build_instruction_mode_instructions("Module", "Content", ["Topic"]) + + # THEN there is an instruction not to discuss the quiz + assert "quiz" in actual_instructions.lower() + assert "do not" in actual_instructions.lower() + + + def test_instructs_to_handle_minimal_responses(self): + # GIVEN any module + # WHEN the instruction mode instructions are built + actual_instructions = _build_instruction_mode_instructions("Module", "Content", ["Topic"]) + + # THEN there are instructions to not accept minimal responses as understanding + assert "minimal" in actual_instructions.lower() + assert "demonstrate" in actual_instructions.lower() + + +class TestBuildSupportModeInstructions: + """Tests for the support mode system instructions builder.""" + + def test_includes_module_title(self): # GIVEN a module title and content - given_title = "Cover Letter" - given_content = "A cover letter should be tailored to the job." + given_title = "CV Development" + given_content = "How to write a great CV." - # WHEN the system instructions are built - actual_instructions = _build_system_instructions(given_title, given_content) + # WHEN the support mode instructions are built + actual_instructions = _build_support_mode_instructions(given_title, given_content) - # THEN the JSON response schema instructions are included - assert "reasoning" in actual_instructions - assert "finished" in actual_instructions - assert "message" in actual_instructions + # THEN the module title is included + assert given_title in actual_instructions - def test_includes_finish_instructions(self): + def test_includes_module_content(self): # GIVEN a module title and content - given_title = "Workplace Readiness" - given_content = "Understanding workplace norms." + given_content = "Practice answering behavioral questions." + + # WHEN the support mode instructions are built + actual_instructions = _build_support_mode_instructions("Module", given_content) + + # THEN the module content is included + assert given_content in actual_instructions - # WHEN the system instructions are built - actual_instructions = _build_system_instructions(given_title, given_content) + def test_instructs_finished_always_false(self): + # GIVEN any module + # WHEN the support mode instructions are built + actual_instructions = _build_support_mode_instructions("Module", "Content") - # THEN the finish instructions are included - assert "finished" in actual_instructions - assert "true" in actual_instructions + # THEN it instructs finished to always be false + assert "false" in actual_instructions.lower() + assert "finished" in actual_instructions.lower() + + def test_instructs_not_to_reinitiate_lesson(self): + # GIVEN any module + # WHEN the support mode instructions are built + actual_instructions = _build_support_mode_instructions("Module", "Content") + + # THEN there is an instruction not to re-initiate the lesson plan + assert "lesson plan" in actual_instructions.lower() @patch("app.career_readiness.agent.GeminiGenerativeLLM") class TestCareerReadinessAgent: """Tests for the CareerReadinessAgent class.""" - def test_initializes_with_correct_system_instructions(self, mock_llm_cls): - # GIVEN a module title and content + def test_initializes_in_instruction_mode_by_default(self, mock_llm_cls): + # GIVEN a module title, content, and topics given_title = "CV Development" given_content = "Write your CV with clear structure." + given_topics = ["CV Structure", "Writing Tips"] - # WHEN the agent is created - actual_agent = CareerReadinessAgent(module_title=given_title, module_content=given_content) + # WHEN the agent is created without specifying mode + actual_agent = CareerReadinessAgent( + module_title=given_title, module_content=given_content, topics=given_topics) - # THEN the system instructions contain the module title and content + # THEN the system instructions contain scaffolded Socratic content + assert "ASSESS" in actual_agent.system_instructions assert given_title in actual_agent.system_instructions assert given_content in actual_agent.system_instructions + def test_initializes_in_support_mode(self, mock_llm_cls): + # GIVEN a module title and content + given_title = "CV Development" + given_content = "Write your CV with clear structure." + + # WHEN the agent is created in support mode + actual_agent = CareerReadinessAgent( + module_title=given_title, module_content=given_content, + mode=ConversationMode.SUPPORT) + + # THEN the system instructions contain support mode content + assert "follow-up" in actual_agent.system_instructions.lower() + # AND do NOT contain instruction mode content + assert "ASSESS" not in actual_agent.system_instructions + @pytest.mark.asyncio - async def test_execute_returns_agent_output(self, mock_llm_cls): + async def test_execute_returns_career_readiness_agent_output(self, mock_llm_cls): # GIVEN an agent with a mocked LLM caller - given_agent = CareerReadinessAgent(module_title="Test Module", module_content="Test content.") - given_model_response = ModelResponse( - reasoning="The user asked about CVs, therefore I will set the finished flag to false.", + given_agent = CareerReadinessAgent( + module_title="Test Module", module_content="Test content.", + topics=["Topic A", "Topic B"]) + given_model_response = CareerReadinessModelResponse( + reasoning="The user asked about Topic A.", finished=False, - message="Here are some tips for writing a CV.", + message="Let's start with Topic A. What do you already know?", + topics_covered=["Topic A"], ) given_llm_stats = [LLMStats( prompt_token_count=100, @@ -98,7 +203,7 @@ async def test_execute_returns_agent_output(self, mock_llm_cls): given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, given_llm_stats)) # AND a user input and empty context - given_input = AgentInput(message="How do I write a CV?") + given_input = AgentInput(message="Hi, I'd like to learn about Topic A") given_context = ConversationContext( all_history=ConversationHistory(), history=ConversationHistory(), @@ -107,21 +212,27 @@ async def test_execute_returns_agent_output(self, mock_llm_cls): # WHEN execute is called actual_output = await given_agent.execute(given_input, given_context) - # THEN the output contains the expected message - assert actual_output.message_for_user == "Here are some tips for writing a CV." + # THEN the output is a CareerReadinessAgentOutput + assert isinstance(actual_output, CareerReadinessAgentOutput) + # AND the agent output contains the expected message + assert actual_output.agent_output.message_for_user == "Let's start with Topic A. What do you already know?" # AND the finished flag matches the model response - assert actual_output.finished is False + assert actual_output.agent_output.finished is False + # AND the topics_covered are propagated + assert actual_output.topics_covered == ["Topic A"] # AND the agent type is correct - assert actual_output.agent_type == AgentType.CAREER_READINESS_AGENT + assert actual_output.agent_output.agent_type == AgentType.CAREER_READINESS_AGENT @pytest.mark.asyncio async def test_execute_handles_empty_input(self, mock_llm_cls): # GIVEN an agent with a mocked LLM caller - given_agent = CareerReadinessAgent(module_title="Test Module", module_content="Test content.") - given_model_response = ModelResponse( + given_agent = CareerReadinessAgent( + module_title="Test Module", module_content="Test content.", topics=["Topic A"]) + given_model_response = CareerReadinessModelResponse( reasoning="The user sent empty input, I will greet them.", finished=False, message="Hello! How can I help you today?", + topics_covered=[], ) given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, [])) @@ -143,7 +254,8 @@ async def test_execute_handles_empty_input(self, mock_llm_cls): @pytest.mark.asyncio async def test_execute_handles_llm_error(self, mock_llm_cls): # GIVEN an agent whose LLM caller raises an exception - given_agent = CareerReadinessAgent(module_title="Test Module", module_content="Test content.") + given_agent = CareerReadinessAgent( + module_title="Test Module", module_content="Test content.", topics=["Topic A"]) given_agent._llm_caller.call_llm = AsyncMock(side_effect=Exception("LLM service unavailable")) given_input = AgentInput(message="Tell me about CVs") @@ -156,18 +268,22 @@ async def test_execute_handles_llm_error(self, mock_llm_cls): actual_output = await given_agent.execute(given_input, given_context) # THEN a fallback error message is returned - assert "difficulties" in actual_output.message_for_user + assert "difficulties" in actual_output.agent_output.message_for_user # AND the agent does not claim to be finished - assert actual_output.finished is False + assert actual_output.agent_output.finished is False + # AND topics_covered is empty + assert actual_output.topics_covered == [] @pytest.mark.asyncio async def test_generate_intro_message_sends_artificial_input(self, mock_llm_cls): # GIVEN an agent with a mocked LLM caller - given_agent = CareerReadinessAgent(module_title="Test Module", module_content="Test content.") - given_model_response = ModelResponse( + given_agent = CareerReadinessAgent( + module_title="Test Module", module_content="Test content.", topics=["Topic A"]) + given_model_response = CareerReadinessModelResponse( reasoning="Starting a new conversation, introducing the module.", finished=False, message="Welcome to the CV Development module!", + topics_covered=[], ) given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, [])) @@ -180,7 +296,7 @@ async def test_generate_intro_message_sends_artificial_input(self, mock_llm_cls) actual_output = await given_agent.generate_intro_message(given_context) # THEN the output contains the introductory message - assert actual_output.message_for_user == "Welcome to the CV Development module!" + assert actual_output.agent_output.message_for_user == "Welcome to the CV Development module!" # AND the LLM is called with "(silence)" as the user message call_args = given_agent._llm_caller.call_llm.call_args llm_input = call_args.kwargs["llm_input"] @@ -189,11 +305,13 @@ async def test_generate_intro_message_sends_artificial_input(self, mock_llm_cls) @pytest.mark.asyncio async def test_execute_strips_quotes_from_message(self, mock_llm_cls): # GIVEN an agent whose LLM returns a message wrapped in quotes - given_agent = CareerReadinessAgent(module_title="Test Module", module_content="Test content.") - given_model_response = ModelResponse( + given_agent = CareerReadinessAgent( + module_title="Test Module", module_content="Test content.", topics=["Topic A"]) + given_model_response = CareerReadinessModelResponse( reasoning="Responding to user.", finished=False, message='"Here is some advice."', + topics_covered=[], ) given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, [])) @@ -207,4 +325,4 @@ async def test_execute_strips_quotes_from_message(self, mock_llm_cls): actual_output = await given_agent.execute(given_input, given_context) # THEN the leading and trailing quotes are stripped - assert actual_output.message_for_user == "Here is some advice." + assert actual_output.agent_output.message_for_user == "Here is some advice." diff --git a/backend/app/career_readiness/test_module_loader.py b/backend/app/career_readiness/test_module_loader.py index d3bf8cad..36f0c706 100644 --- a/backend/app/career_readiness/test_module_loader.py +++ b/backend/app/career_readiness/test_module_loader.py @@ -1,15 +1,43 @@ """ Tests for the career readiness module loader. """ -from pathlib import Path - import pytest from app.career_readiness.module_loader import ( - ModuleConfig, ModuleRegistry, _parse_frontmatter, _load_module_from_file, + _split_quiz_section, + _parse_quiz_section, +) + + +_VALID_FRONTMATTER = ( + "---\n" + "id: test-module\n" + "title: Test Module\n" + "description: A test module.\n" + "icon: test\n" + "sort_order: 1\n" + "input_placeholder: Ask something...\n" + "topics: Topic A, Topic B, Topic C\n" + "---\n\n" +) + +_VALID_QUIZ_SECTION = ( + "pass_threshold: 0.8\n\n" + "1. What is the answer?\n" + "A. Wrong\n" + "B. Correct\n" + "C. Wrong again\n" + "D. Still wrong\n" + "Answer: B\n\n" + "2. Another question?\n" + "A. No\n" + "B. Yes\n" + "C. Maybe\n" + "D. Perhaps\n" + "Answer: B\n" ) @@ -59,22 +87,146 @@ def test_handles_colons_in_values(self): assert actual_metadata["description"] == "Learn to write: a guide" +class TestSplitQuizSection: + """Tests for splitting the quiz section from module content.""" + + def test_splits_body_with_quiz_section(self): + # GIVEN a markdown body with a ## Quiz section + given_body = "# Module Content\n\nSome text.\n\n## Quiz\n\n1. A question?\nA. Yes\nAnswer: A" + + # WHEN the body is split + actual_content, actual_quiz_text = _split_quiz_section(given_body) + + # THEN the content contains everything before ## Quiz + assert "# Module Content" in actual_content + assert "Some text." in actual_content + # AND the quiz text contains the quiz section + assert "1. A question?" in actual_quiz_text + # AND the content does NOT contain quiz content + assert "## Quiz" not in actual_content + assert "A question?" not in actual_content + + def test_returns_none_when_no_quiz_section(self): + # GIVEN a markdown body without a ## Quiz section + given_body = "# Module Content\n\nSome text." + + # WHEN the body is split + actual_content, actual_quiz_text = _split_quiz_section(given_body) + + # THEN the content is the full body + assert actual_content == given_body + # AND the quiz text is None + assert actual_quiz_text is None + + def test_does_not_split_on_quiz_in_different_heading_level(self): + # GIVEN a body with ### Quiz (not ## Quiz) + given_body = "# Content\n\n### Quiz\n\nNot a real quiz." + + # WHEN the body is split + actual_content, actual_quiz_text = _split_quiz_section(given_body) + + # THEN the quiz text is None (### Quiz is not a split point) + assert actual_quiz_text is None + # AND the content contains the full body + assert "### Quiz" in actual_content + + +class TestParseQuizSection: + """Tests for parsing the quiz section text into QuizConfig.""" + + def test_parses_valid_quiz_section(self): + # GIVEN a valid quiz section text + given_text = _VALID_QUIZ_SECTION + + # WHEN the quiz section is parsed + actual_quiz = _parse_quiz_section(given_text) + + # THEN the pass threshold is parsed correctly + assert actual_quiz.pass_threshold == 0.8 + # AND there are 2 questions + assert len(actual_quiz.questions) == 2 + # AND the first question is parsed correctly + assert actual_quiz.questions[0].question == "What is the answer?" + assert len(actual_quiz.questions[0].options) == 4 + assert actual_quiz.questions[0].correct_answer == "B" + + def test_defaults_pass_threshold_to_0_7(self): + # GIVEN a quiz section without pass_threshold + given_text = ( + "1. A question?\n" + "A. Option A\n" + "B. Option B\n" + "C. Option C\n" + "D. Option D\n" + "Answer: A\n" + ) + + # WHEN the quiz section is parsed + actual_quiz = _parse_quiz_section(given_text) + + # THEN the pass threshold defaults to 0.7 + assert actual_quiz.pass_threshold == 0.7 + + def test_raises_when_no_questions_found(self): + # GIVEN a quiz section with no valid questions + given_text = "pass_threshold: 0.8\n\nSome random text." + + # WHEN the quiz section is parsed + # THEN a ValueError is raised + with pytest.raises(ValueError, match="no questions"): + _parse_quiz_section(given_text) + + def test_raises_when_answer_missing(self): + # GIVEN a quiz question without an Answer line + given_text = ( + "1. A question?\n" + "A. Option A\n" + "B. Option B\n" + "C. Option C\n" + "D. Option D\n" + ) + + # WHEN the quiz section is parsed + # THEN a ValueError is raised for missing answer + with pytest.raises(ValueError, match="missing Answer"): + _parse_quiz_section(given_text) + + def test_parses_options_correctly(self): + # GIVEN a quiz section with specific options + given_text = ( + "1. Which is best?\n" + "A. First option\n" + "B. Second option\n" + "C. Third option\n" + "D. Fourth option\n" + "Answer: C\n" + ) + + # WHEN the quiz section is parsed + actual_quiz = _parse_quiz_section(given_text) + + # THEN the options are formatted as "X. text" + assert actual_quiz.questions[0].options == [ + "A. First option", + "B. Second option", + "C. Third option", + "D. Fourth option", + ] + # AND the correct answer is C + assert actual_quiz.questions[0].correct_answer == "C" + + class TestLoadModuleFromFile: """Tests for loading a single module from a file.""" - def test_loads_valid_module_file(self, tmp_path): - # GIVEN a valid module markdown file + def test_loads_valid_module_file_with_topics_and_quiz(self, tmp_path): + # GIVEN a valid module markdown file with topics and a quiz section given_file = tmp_path / "test.md" given_file.write_text( - "---\n" - "id: test-module\n" - "title: Test Module\n" - "description: A test module.\n" - "icon: test\n" - "sort_order: 1\n" - "input_placeholder: Ask something...\n" - "---\n\n" - "# Test Content\n\nBody text.", + _VALID_FRONTMATTER + + "# Test Content\n\nBody text.\n\n" + + "## Quiz\n\n" + + _VALID_QUIZ_SECTION, encoding="utf-8", ) @@ -84,13 +236,56 @@ def test_loads_valid_module_file(self, tmp_path): # THEN the module has the correct metadata assert actual_module.id == "test-module" assert actual_module.title == "Test Module" - assert actual_module.description == "A test module." - assert actual_module.icon == "test" assert actual_module.sort_order == 1 - assert actual_module.input_placeholder == "Ask something..." - # AND the content contains the markdown body + # AND the topics are parsed from comma-separated frontmatter + assert actual_module.topics == ["Topic A", "Topic B", "Topic C"] + # AND the content contains the markdown body but NOT the quiz assert "# Test Content" in actual_module.content assert "Body text." in actual_module.content + assert "## Quiz" not in actual_module.content + assert "What is the answer?" not in actual_module.content + # AND the quiz is parsed correctly + assert actual_module.quiz is not None + assert len(actual_module.quiz.questions) == 2 + assert actual_module.quiz.pass_threshold == 0.8 + + def test_loads_module_without_quiz(self, tmp_path): + # GIVEN a module file without a quiz section + given_file = tmp_path / "no_quiz.md" + given_file.write_text( + _VALID_FRONTMATTER + "# Content\n\nNo quiz here.", + encoding="utf-8", + ) + + # WHEN the module is loaded + actual_module = _load_module_from_file(given_file) + + # THEN the quiz is None + assert actual_module.quiz is None + # AND the content includes the full body + assert "No quiz here." in actual_module.content + + def test_loads_module_without_topics(self, tmp_path): + # GIVEN a module file without a topics field + given_file = tmp_path / "no_topics.md" + given_file.write_text( + "---\n" + "id: no-topics\n" + "title: No Topics Module\n" + "description: A module without topics.\n" + "icon: test\n" + "sort_order: 1\n" + "input_placeholder: Ask...\n" + "---\n\n" + "# Content", + encoding="utf-8", + ) + + # WHEN the module is loaded + actual_module = _load_module_from_file(given_file) + + # THEN the topics list is empty + assert actual_module.topics == [] def test_raises_when_required_field_missing(self, tmp_path): # GIVEN a module file missing the 'title' field @@ -171,6 +366,7 @@ def test_loads_from_custom_directory(self, tmp_path): f"icon: {name}\n" f"sort_order: {i}\n" f"input_placeholder: Ask about {name}...\n" + f"topics: Topic 1, Topic 2\n" f"---\n\n" f"# {name.title()} Content", encoding="utf-8", @@ -184,6 +380,36 @@ def test_loads_from_custom_directory(self, tmp_path): assert actual_registry.get_module("alpha") is not None assert actual_registry.get_module("beta") is not None + def test_skips_files_starting_with_underscore(self, tmp_path): + # GIVEN a directory with a normal module and an underscore-prefixed file + given_module_dir = tmp_path / "modules" + given_module_dir.mkdir() + + (given_module_dir / "real.md").write_text( + "---\n" + "id: real\n" + "title: Real Module\n" + "description: A real module.\n" + "icon: real\n" + "sort_order: 1\n" + "input_placeholder: Ask...\n" + "topics: Topic 1\n" + "---\n\n# Content", + encoding="utf-8", + ) + (given_module_dir / "_example.md").write_text( + "---\nid: example\ntitle: Example\n---\n\nThis should be skipped.", + encoding="utf-8", + ) + + # WHEN a registry is created + actual_registry = ModuleRegistry(modules_dir=given_module_dir) + + # THEN only the real module is loaded + assert len(actual_registry.get_all_modules()) == 1 + assert actual_registry.get_module("real") is not None + assert actual_registry.get_module("example") is None + def test_empty_directory_loads_no_modules(self, tmp_path): # GIVEN an empty directory given_empty_dir = tmp_path / "empty" @@ -216,3 +442,40 @@ def test_all_module_ids_are_unique(self): # THEN all IDs are unique actual_ids = [m.id for m in actual_modules] assert len(actual_ids) == len(set(actual_ids)) + + def test_all_real_modules_have_topics(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN all modules are retrieved + actual_modules = actual_registry.get_all_modules() + + # THEN each module has at least one topic + for module in actual_modules: + assert len(module.topics) > 0, f"Module {module.id} has no topics" + + def test_all_real_modules_have_quiz(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN all modules are retrieved + actual_modules = actual_registry.get_all_modules() + + # THEN each module has a quiz with 10 questions + for module in actual_modules: + assert module.quiz is not None, f"Module {module.id} has no quiz" + assert len(module.quiz.questions) == 10, ( + f"Module {module.id} has {len(module.quiz.questions)} questions, expected 10" + ) + + def test_real_module_content_excludes_quiz(self): + # GIVEN the real modules directory + actual_registry = ModuleRegistry() + + # WHEN all modules are retrieved + actual_modules = actual_registry.get_all_modules() + + # THEN no module content contains quiz material + for module in actual_modules: + assert "## Quiz" not in module.content, f"Module {module.id} content contains quiz section" + assert "Answer:" not in module.content, f"Module {module.id} content contains quiz answers" diff --git a/backend/app/career_readiness/test_repository.py b/backend/app/career_readiness/test_repository.py index 596f324d..75301a95 100644 --- a/backend/app/career_readiness/test_repository.py +++ b/backend/app/career_readiness/test_repository.py @@ -7,11 +7,13 @@ import pytest from bson import ObjectId +from app.career_readiness.errors import ConversationAlreadyExistsError from app.career_readiness.repository import CareerReadinessConversationRepository from app.career_readiness.types import ( CareerReadinessConversationDocument, CareerReadinessMessage, CareerReadinessMessageSender, + ConversationMode, ) @@ -70,6 +72,24 @@ async def test_creates_and_finds_document(self, get_repository: Awaitable[Career assert len(actual_result.messages) == 1 + @pytest.mark.asyncio + async def test_raises_already_exists_on_duplicate_user_module(self, + get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with an existing conversation for a user and module + repo = await get_repository + given_user_id = "user_abc" + given_module_id = "cv-development" + given_first = _make_conversation(user_id=given_user_id, module_id=given_module_id) + await repo.create(given_first) + + # WHEN a second conversation is created for the same user and module + given_duplicate = _make_conversation(user_id=given_user_id, module_id=given_module_id) + + # THEN ConversationAlreadyExistsError is raised + with pytest.raises(ConversationAlreadyExistsError): + await repo.create(given_duplicate) + + class TestFindByConversationId: """Tests for finding a conversation by its ID.""" @@ -243,3 +263,112 @@ async def test_deletes_and_returns_true(self, assert actual_result is True # AND the conversation can no longer be found assert await repo.find_by_conversation_id(given_conversation.conversation_id) is None + + +class TestUpdateCoveredTopics: + """Tests for updating covered topics on a conversation.""" + + @pytest.mark.asyncio + async def test_updates_covered_topics(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN covered topics are updated + given_topics = ["Topic A", "Topic B"] + await repo.update_covered_topics(given_conversation.conversation_id, given_topics) + + # THEN the conversation has the updated topics + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.covered_topics == given_topics + + @pytest.mark.asyncio + async def test_updates_updated_at_timestamp(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation + repo = await get_repository + given_conversation = _make_conversation() + given_original_updated_at = given_conversation.updated_at + await repo.create(given_conversation) + + # WHEN covered topics are updated + await repo.update_covered_topics(given_conversation.conversation_id, ["Topic A"]) + + # THEN the updated_at timestamp is changed + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.updated_at >= given_original_updated_at + + +class TestUpdateQuizDelivered: + """Tests for updating the quiz_delivered flag.""" + + @pytest.mark.asyncio + async def test_sets_quiz_delivered_to_true(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation where quiz is not yet delivered + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN quiz_delivered is set to True + await repo.update_quiz_delivered(given_conversation.conversation_id, True) + + # THEN the conversation has quiz_delivered = True + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.quiz_delivered is True + + @pytest.mark.asyncio + async def test_resets_quiz_delivered_to_false(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation where quiz was delivered + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + await repo.update_quiz_delivered(given_conversation.conversation_id, True) + + # WHEN quiz_delivered is reset to False + await repo.update_quiz_delivered(given_conversation.conversation_id, False) + + # THEN the conversation has quiz_delivered = False + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.quiz_delivered is False + + +class TestUpdateQuizPassed: + """Tests for updating the quiz_passed flag.""" + + @pytest.mark.asyncio + async def test_sets_quiz_passed_to_true(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN quiz_passed is set to True + await repo.update_quiz_passed(given_conversation.conversation_id, True) + + # THEN the conversation has quiz_passed = True + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.quiz_passed is True + + +class TestUpdateConversationMode: + """Tests for updating the conversation mode.""" + + @pytest.mark.asyncio + async def test_updates_to_support_mode(self, get_repository: Awaitable[CareerReadinessConversationRepository]): + # GIVEN a repository with a conversation in INSTRUCTION mode + repo = await get_repository + given_conversation = _make_conversation() + await repo.create(given_conversation) + + # WHEN the conversation mode is updated to SUPPORT + await repo.update_conversation_mode(given_conversation.conversation_id, ConversationMode.SUPPORT) + + # THEN the conversation has mode = SUPPORT + actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_result is not None + assert actual_result.conversation_mode == ConversationMode.SUPPORT diff --git a/backend/app/career_readiness/test_routes.py b/backend/app/career_readiness/test_routes.py index 66274b83..3ca242b2 100644 --- a/backend/app/career_readiness/test_routes.py +++ b/backend/app/career_readiness/test_routes.py @@ -16,6 +16,7 @@ ConversationAlreadyExistsError, ConversationNotFoundError, CareerReadinessModuleNotFoundError, + ModuleNotUnlockedError, ) from app.career_readiness.routes import add_career_readiness_routes, get_career_readiness_service from app.career_readiness.service import ICareerReadinessService @@ -201,6 +202,20 @@ def test_returns_404_when_module_not_found(self, client_with_mocks: TestClientWi # THEN 404 is returned assert response.status_code == HTTPStatus.NOT_FOUND + def test_returns_403_when_module_not_unlocked(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ModuleNotUnlockedError + mocker.patch.object(mock_service, "create_conversation", + side_effect=ModuleNotUnlockedError("interview-preparation")) + + # WHEN a conversation is created for a locked module + response = client.post("/career-readiness/modules/interview-preparation/conversations") + + # THEN 403 FORBIDDEN is returned + assert response.status_code == HTTPStatus.FORBIDDEN + def test_returns_409_when_already_exists(self, client_with_mocks: TestClientWithMocks, mocker: pytest_mock.MockerFixture): client, mock_service, mocked_user = client_with_mocks diff --git a/backend/app/career_readiness/test_service.py b/backend/app/career_readiness/test_service.py index cc78ed20..8196640a 100644 --- a/backend/app/career_readiness/test_service.py +++ b/backend/app/career_readiness/test_service.py @@ -6,26 +6,50 @@ import pytest from bson import ObjectId -from app.agent.agent_types import AgentOutput, AgentType, LLMStats -from app.career_readiness.agent import CareerReadinessAgent +from app.agent.agent_types import AgentOutput, AgentType +from app.career_readiness.agent import CareerReadinessAgentOutput from app.career_readiness.errors import ( ConversationAccessDeniedError, ConversationAlreadyExistsError, ConversationModuleMismatchError, ConversationNotFoundError, CareerReadinessModuleNotFoundError, + ModuleNotUnlockedError, ) -from app.career_readiness.module_loader import ModuleConfig, ModuleRegistry +from app.career_readiness.module_loader import ModuleConfig, ModuleRegistry, QuizConfig, QuizQuestion from app.career_readiness.repository import ICareerReadinessConversationRepository -from app.career_readiness.service import CareerReadinessService, _build_conversation_context +from app.career_readiness.service import ( + CareerReadinessService, + _build_conversation_context, + _derive_module_statuses, + _format_quiz_message, + _parse_quiz_answers, + _evaluate_quiz, +) from app.career_readiness.types import ( CareerReadinessConversationDocument, CareerReadinessMessage, CareerReadinessMessageSender, + ConversationMode, ModuleStatus, ) +# --------------------------------------------------------------------------- +# Quiz helpers +# --------------------------------------------------------------------------- + +def _make_quiz_questions() -> list[QuizQuestion]: + return [ + QuizQuestion(question="Q1?", options=["A. Opt1", "B. Opt2", "C. Opt3", "D. Opt4"], correct_answer="A"), + QuizQuestion(question="Q2?", options=["A. Opt1", "B. Opt2", "C. Opt3", "D. Opt4"], correct_answer="B"), + ] + + +def _make_quiz_config() -> QuizConfig: + return QuizConfig(pass_threshold=0.5, questions=_make_quiz_questions()) + + # --------------------------------------------------------------------------- # Mock helpers # --------------------------------------------------------------------------- @@ -34,6 +58,8 @@ def _make_module_config( module_id: str = "cv-development", title: str = "CV Development", sort_order: int = 1, + topics: list[str] | None = None, + quiz: QuizConfig | None = None, ) -> ModuleConfig: return ModuleConfig( id=module_id, @@ -43,6 +69,8 @@ def _make_module_config( sort_order=sort_order, input_placeholder="Ask about CVs...", content="# CV Content\nWrite your CV.", + topics=topics or ["Topic A", "Topic B"], + quiz=quiz or _make_quiz_config(), ) @@ -62,6 +90,10 @@ def _make_conversation( user_id: str = "test_user", module_id: str = "cv-development", messages: list[CareerReadinessMessage] | None = None, + conversation_mode: ConversationMode = ConversationMode.INSTRUCTION, + covered_topics: list[str] | None = None, + quiz_delivered: bool = False, + quiz_passed: bool = False, ) -> CareerReadinessConversationDocument: now = datetime.now(timezone.utc) return CareerReadinessConversationDocument( @@ -69,6 +101,10 @@ def _make_conversation( module_id=module_id, user_id=user_id, messages=messages or [_make_message()], + conversation_mode=conversation_mode, + covered_topics=covered_topics or [], + quiz_delivered=quiz_delivered, + quiz_passed=quiz_passed, created_at=now, updated_at=now, ) @@ -101,6 +137,30 @@ async def append_message(self, conversation_id: str, message: CareerReadinessMes conv.messages.append(message) conv.updated_at = datetime.now(timezone.utc) + async def update_covered_topics(self, conversation_id: str, topics: list[str]) -> None: + conv = self._conversations.get(conversation_id) + if conv: + conv.covered_topics = topics + conv.updated_at = datetime.now(timezone.utc) + + async def update_quiz_delivered(self, conversation_id: str, delivered: bool) -> None: + conv = self._conversations.get(conversation_id) + if conv: + conv.quiz_delivered = delivered + conv.updated_at = datetime.now(timezone.utc) + + async def update_quiz_passed(self, conversation_id: str, passed: bool) -> None: + conv = self._conversations.get(conversation_id) + if conv: + conv.quiz_passed = passed + conv.updated_at = datetime.now(timezone.utc) + + async def update_conversation_mode(self, conversation_id: str, mode: ConversationMode) -> None: + conv = self._conversations.get(conversation_id) + if conv: + conv.conversation_mode = mode + conv.updated_at = datetime.now(timezone.utc) + async def delete_by_conversation_id(self, conversation_id: str) -> bool: if conversation_id in self._conversations: del self._conversations[conversation_id] @@ -118,8 +178,12 @@ def __init__(self, modules: list[ModuleConfig] | None = None): self._modules[m.id] = m -def _make_mock_agent_output(message: str = "Agent response", finished: bool = False) -> AgentOutput: - return AgentOutput( +def _make_mock_agent_output( + message: str = "Agent response", + finished: bool = False, + topics_covered: list[str] | None = None, +) -> CareerReadinessAgentOutput: + agent_output = AgentOutput( message_id=str(ObjectId()), message_for_user=message, finished=finished, @@ -128,27 +192,38 @@ def _make_mock_agent_output(message: str = "Agent response", finished: bool = Fa llm_stats=[], sent_at=datetime.now(timezone.utc), ) + return CareerReadinessAgentOutput( + agent_output=agent_output, + topics_covered=topics_covered or [], + ) class MockCareerReadinessAgent: """Mock agent that returns canned responses without calling an LLM.""" - def __init__(self, intro_message: str = "Welcome!", response_message: str = "Agent response"): + def __init__(self, intro_message: str = "Welcome!", response_message: str = "Agent response", + topics_covered: list[str] | None = None, finished: bool = False): self._intro_message = intro_message self._response_message = response_message + self._topics_covered = topics_covered or [] + self._finished = finished async def generate_intro_message(self, context): return _make_mock_agent_output(message=self._intro_message) async def execute(self, user_input, context): - return _make_mock_agent_output(message=self._response_message) + return _make_mock_agent_output( + message=self._response_message, + finished=self._finished, + topics_covered=self._topics_covered, + ) def _make_mock_agent_factory(agent: MockCareerReadinessAgent | None = None): - """Factory that returns a mock agent.""" + """Factory that returns a mock agent regardless of mode.""" mock_agent = agent or MockCareerReadinessAgent() - def factory(module_config): + def factory(module_config, mode): return mock_agent return factory @@ -168,15 +243,186 @@ def _make_service( # --------------------------------------------------------------------------- -# Tests +# Tests — Pure functions +# --------------------------------------------------------------------------- + +class TestDeriveModuleStatuses: + """Tests for the _derive_module_statuses function.""" + + def test_first_module_unlocked_when_no_conversations(self): + # GIVEN three modules and no conversations + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", sort_order=2), + _make_module_config(module_id="m3", sort_order=3), + ] + + # WHEN statuses are derived + actual_statuses = _derive_module_statuses(given_modules, []) + + # THEN the first module is UNLOCKED and the rest are NOT_STARTED + assert actual_statuses["m1"] == ModuleStatus.UNLOCKED + assert actual_statuses["m2"] == ModuleStatus.NOT_STARTED + assert actual_statuses["m3"] == ModuleStatus.NOT_STARTED + + def test_second_module_unlocked_when_first_completed(self): + # GIVEN three modules and the first one completed + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", sort_order=2), + _make_module_config(module_id="m3", sort_order=3), + ] + given_conversations = [ + _make_conversation(module_id="m1", quiz_passed=True), + ] + + # WHEN statuses are derived + actual_statuses = _derive_module_statuses(given_modules, given_conversations) + + # THEN the first is COMPLETED, second is UNLOCKED, third is NOT_STARTED + assert actual_statuses["m1"] == ModuleStatus.COMPLETED + assert actual_statuses["m2"] == ModuleStatus.UNLOCKED + assert actual_statuses["m3"] == ModuleStatus.NOT_STARTED + + def test_chain_completion_unlocks_next(self): + # GIVEN three modules with first two completed + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", sort_order=2), + _make_module_config(module_id="m3", sort_order=3), + ] + given_conversations = [ + _make_conversation(module_id="m1", quiz_passed=True), + _make_conversation(module_id="m2", quiz_passed=True), + ] + + # WHEN statuses are derived + actual_statuses = _derive_module_statuses(given_modules, given_conversations) + + # THEN third module is UNLOCKED + assert actual_statuses["m1"] == ModuleStatus.COMPLETED + assert actual_statuses["m2"] == ModuleStatus.COMPLETED + assert actual_statuses["m3"] == ModuleStatus.UNLOCKED + + def test_in_progress_module_blocks_next(self): + # GIVEN first module in progress (has conversation but quiz not passed) + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", sort_order=2), + ] + given_conversations = [ + _make_conversation(module_id="m1", quiz_passed=False), + ] + + # WHEN statuses are derived + actual_statuses = _derive_module_statuses(given_modules, given_conversations) + + # THEN first is IN_PROGRESS, second is NOT_STARTED + assert actual_statuses["m1"] == ModuleStatus.IN_PROGRESS + assert actual_statuses["m2"] == ModuleStatus.NOT_STARTED + + +class TestFormatQuizMessage: + """Tests for the quiz message formatter.""" + + def test_formats_quiz_with_numbered_questions(self): + # GIVEN a quiz config + given_quiz = _make_quiz_config() + + # WHEN the quiz message is formatted + actual_message = _format_quiz_message(given_quiz) + + # THEN it contains numbered questions and options + assert "1. Q1?" in actual_message + assert "2. Q2?" in actual_message + assert "A. Opt1" in actual_message + + +class TestParseQuizAnswers: + """Tests for the quiz answer parser.""" + + def test_parses_numbered_format(self): + # GIVEN answers in "1.B, 2.A" format + given_input = "1.B, 2.A, 3.C" + + # WHEN parsed + actual_answers = _parse_quiz_answers(given_input) + + # THEN correct answers are extracted + assert actual_answers == {1: "B", 2: "A", 3: "C"} + + def test_parses_sequential_letters(self): + # GIVEN answers as sequential letters + given_input = "B, A, C" + + # WHEN parsed + actual_answers = _parse_quiz_answers(given_input) + + # THEN answers are numbered sequentially + assert actual_answers == {1: "B", 2: "A", 3: "C"} + + def test_normalizes_lowercase(self): + # GIVEN lowercase answers + given_input = "1.b, 2.a" + + # WHEN parsed + actual_answers = _parse_quiz_answers(given_input) + + # THEN answers are uppercase + assert actual_answers == {1: "B", 2: "A"} + + +class TestEvaluateQuiz: + """Tests for the quiz evaluator.""" + + def test_all_correct(self): + # GIVEN a quiz and all correct answers + given_quiz = _make_quiz_config() + given_answers = {1: "A", 2: "B"} + + # WHEN evaluated + actual_score, actual_total, actual_results = _evaluate_quiz(given_quiz, given_answers) + + # THEN all are correct + assert actual_score == 2 + assert actual_total == 2 + assert actual_results == [True, True] + + def test_partial_correct(self): + # GIVEN a quiz with one wrong answer + given_quiz = _make_quiz_config() + given_answers = {1: "A", 2: "C"} + + # WHEN evaluated + actual_score, _, _ = _evaluate_quiz(given_quiz, given_answers) + + # THEN score is 1 + assert actual_score == 1 + + def test_missing_answers_counted_as_wrong(self): + # GIVEN a quiz with no answers provided + given_quiz = _make_quiz_config() + given_answers: dict[int, str] = {} + + # WHEN evaluated + actual_score, actual_total, _ = _evaluate_quiz(given_quiz, given_answers) + + # THEN score is 0 + assert actual_score == 0 + assert actual_total == 2 + + +# --------------------------------------------------------------------------- +# Tests — Service methods # --------------------------------------------------------------------------- class TestBuildConversationContext: """Tests for the _build_conversation_context helper.""" - def test_builds_context_from_message_pairs(self): - # GIVEN a list of messages with intro + user/agent pairs + def test_builds_context_from_silence_intro_and_user_agent_pair(self): + # GIVEN messages with silence+intro pair followed by user+agent pair given_messages = [ + _make_message(sender=CareerReadinessMessageSender.USER, message="(silence)"), _make_message(sender=CareerReadinessMessageSender.AGENT, message="Welcome!"), _make_message(sender=CareerReadinessMessageSender.USER, message="Help me"), _make_message(sender=CareerReadinessMessageSender.AGENT, message="Sure!"), @@ -185,26 +431,32 @@ def test_builds_context_from_message_pairs(self): # WHEN the context is built actual_context = _build_conversation_context(given_messages) - # THEN there is one conversation turn (user+agent pair) - assert len(actual_context.history.turns) == 1 - assert actual_context.history.turns[0].input.message == "Help me" - assert actual_context.history.turns[0].output.message_for_user == "Sure!" + # THEN there are two conversation turns (silence+intro and user+agent) + assert len(actual_context.history.turns) == 2 + assert actual_context.history.turns[0].input.message == "(silence)" + assert actual_context.history.turns[0].output.message_for_user == "Welcome!" + assert actual_context.history.turns[1].input.message == "Help me" + assert actual_context.history.turns[1].output.message_for_user == "Sure!" - def test_builds_empty_context_from_intro_only(self): - # GIVEN only an intro message + def test_builds_context_from_silence_intro_only(self): + # GIVEN only a silence+intro pair given_messages = [ + _make_message(sender=CareerReadinessMessageSender.USER, message="(silence)"), _make_message(sender=CareerReadinessMessageSender.AGENT, message="Welcome!"), ] # WHEN the context is built actual_context = _build_conversation_context(given_messages) - # THEN the context has no turns - assert len(actual_context.history.turns) == 0 + # THEN there is one turn (the intro) + assert len(actual_context.history.turns) == 1 + assert actual_context.history.turns[0].input.message == "(silence)" + assert actual_context.history.turns[0].output.message_for_user == "Welcome!" def test_builds_context_with_multiple_pairs(self): - # GIVEN a conversation with multiple exchanges + # GIVEN a conversation with silence+intro and multiple exchanges given_messages = [ + _make_message(sender=CareerReadinessMessageSender.USER, message="(silence)"), _make_message(sender=CareerReadinessMessageSender.AGENT, message="Welcome!"), _make_message(sender=CareerReadinessMessageSender.USER, message="Q1"), _make_message(sender=CareerReadinessMessageSender.AGENT, message="A1"), @@ -215,30 +467,28 @@ def test_builds_context_with_multiple_pairs(self): # WHEN the context is built actual_context = _build_conversation_context(given_messages) - # THEN there are two conversation turns - assert len(actual_context.history.turns) == 2 - assert actual_context.history.turns[0].input.message == "Q1" - assert actual_context.history.turns[1].input.message == "Q2" + # THEN there are three conversation turns + assert len(actual_context.history.turns) == 3 class TestListModules: - """Tests for listing modules with user progress.""" + """Tests for listing modules with sequential unlock status.""" @pytest.mark.asyncio - async def test_returns_all_modules_with_not_started_status(self): + async def test_first_module_unlocked_rest_not_started(self): # GIVEN a service with two modules and no conversations given_modules = [ - _make_module_config(module_id="cv-development", sort_order=1), - _make_module_config(module_id="interview-prep", title="Interview Prep", sort_order=2), + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", title="Module 2", sort_order=2), ] service, _ = _make_service(modules=given_modules) - # WHEN modules are listed for a user + # WHEN modules are listed actual_result = await service.list_modules("user_abc") - # THEN all modules are returned with NOT_STARTED status - assert len(actual_result.modules) == 2 - assert all(m.status == ModuleStatus.NOT_STARTED for m in actual_result.modules) + # THEN first is UNLOCKED, second is NOT_STARTED + assert actual_result.modules[0].status == ModuleStatus.UNLOCKED + assert actual_result.modules[1].status == ModuleStatus.NOT_STARTED @pytest.mark.asyncio async def test_returns_in_progress_when_conversation_exists(self): @@ -252,9 +502,26 @@ async def test_returns_in_progress_when_conversation_exists(self): actual_result = await service.list_modules("user_abc") # THEN the module with a conversation has IN_PROGRESS status - assert len(actual_result.modules) == 1 assert actual_result.modules[0].status == ModuleStatus.IN_PROGRESS + @pytest.mark.asyncio + async def test_completed_module_unlocks_next(self): + # GIVEN first module completed + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", title="Module 2", sort_order=2), + ] + service, repo = _make_service(modules=given_modules) + given_conversation = _make_conversation(user_id="user_abc", module_id="m1", quiz_passed=True) + await repo.create(given_conversation) + + # WHEN modules are listed + actual_result = await service.list_modules("user_abc") + + # THEN first is COMPLETED, second is UNLOCKED + assert actual_result.modules[0].status == ModuleStatus.COMPLETED + assert actual_result.modules[1].status == ModuleStatus.UNLOCKED + class TestGetModule: """Tests for getting module details.""" @@ -268,10 +535,9 @@ async def test_returns_module_detail(self): # WHEN the module is retrieved actual_result = await service.get_module("user_abc", "cv-development") - # THEN the module detail is returned + # THEN the module detail is returned with UNLOCKED status assert actual_result.id == "cv-development" - assert actual_result.title == "CV Development" - assert actual_result.active_conversation_id is None + assert actual_result.status == ModuleStatus.UNLOCKED @pytest.mark.asyncio async def test_returns_active_conversation_id(self): @@ -312,11 +578,17 @@ async def test_creates_conversation_with_intro_message(self): # WHEN a conversation is created actual_result = await service.create_conversation("user_abc", "cv-development") - # THEN a conversation response is returned with the intro message + # THEN a conversation response is returned with the intro message (silence filtered out) assert actual_result.module_id == "cv-development" assert len(actual_result.messages) == 1 assert actual_result.messages[0].message == "Welcome to CV Development!" - assert actual_result.messages[0].sender == CareerReadinessMessageSender.AGENT + assert actual_result.conversation_mode == ConversationMode.INSTRUCTION + # AND the DB stores both the silence and intro messages + actual_doc = await repo.find_by_conversation_id(actual_result.conversation_id) + assert actual_doc is not None + assert len(actual_doc.messages) == 2 + assert actual_doc.messages[0].message == "(silence)" + assert actual_doc.messages[1].message == "Welcome to CV Development!" @pytest.mark.asyncio async def test_raises_already_exists_when_duplicate(self): @@ -341,30 +613,200 @@ async def test_raises_module_not_found(self): with pytest.raises(CareerReadinessModuleNotFoundError): await service.create_conversation("user_abc", "nonexistent") + @pytest.mark.asyncio + async def test_raises_not_unlocked_for_locked_module(self): + # GIVEN two modules where the second is locked (first not completed) + given_modules = [ + _make_module_config(module_id="m1", sort_order=1), + _make_module_config(module_id="m2", title="Module 2", sort_order=2), + ] + service, _ = _make_service(modules=given_modules) + + # WHEN a conversation is created for the locked second module + # THEN ModuleNotUnlockedError is raised + with pytest.raises(ModuleNotUnlockedError): + await service.create_conversation("user_abc", "m2") + class TestSendMessage: - """Tests for sending a message in a conversation.""" + """Tests for sending messages with mode dispatch.""" @pytest.mark.asyncio - async def test_sends_message_and_returns_response(self): - # GIVEN a service with a conversation + async def test_instruction_mode_normal_response(self): + # GIVEN a service with a conversation in instruction mode given_module = _make_module_config() - given_agent = MockCareerReadinessAgent(response_message="Here is CV advice.") + given_agent = MockCareerReadinessAgent( + response_message="Let's discuss Topic A.", + topics_covered=["Topic A"], + ) service, repo = _make_service(modules=[given_module], agent=given_agent) given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") await repo.create(given_conversation) # WHEN a message is sent actual_result = await service.send_message( - "user_abc", "cv-development", given_conversation.conversation_id, "How do I write a CV?", + "user_abc", "cv-development", given_conversation.conversation_id, "Tell me about Topic A", ) # THEN the response includes the user message and agent response - assert len(actual_result.messages) == 3 # intro + user + agent - assert actual_result.messages[1].message == "How do I write a CV?" - assert actual_result.messages[1].sender == CareerReadinessMessageSender.USER - assert actual_result.messages[2].message == "Here is CV advice." - assert actual_result.messages[2].sender == CareerReadinessMessageSender.AGENT + assert actual_result.messages[-1].message == "Let's discuss Topic A." + # AND topics are accumulated in the conversation + actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_conv is not None + assert "Topic A" in actual_conv.covered_topics + + @pytest.mark.asyncio + async def test_instruction_mode_triggers_quiz_when_all_topics_covered_and_finished(self): + # GIVEN a conversation where all topics are already covered except the agent says finished this turn + given_module = _make_module_config(topics=["Topic A", "Topic B"]) + given_agent = MockCareerReadinessAgent( + response_message="Great, we've covered everything!", + topics_covered=["Topic B"], + finished=True, + ) + service, repo = _make_service(modules=[given_module], agent=given_agent) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + covered_topics=["Topic A"], + ) + await repo.create(given_conversation) + + # WHEN a message is sent + actual_result = await service.send_message( + "user_abc", "cv-development", given_conversation.conversation_id, "I understand now", + ) + + # THEN the quiz is delivered (last message contains quiz content) + assert "quiz" in actual_result.messages[-1].message.lower() + # AND quiz_delivered is set on the conversation + actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_conv is not None + assert actual_conv.quiz_delivered is True + + @pytest.mark.asyncio + async def test_quiz_submission_pass(self): + # GIVEN a conversation with quiz delivered (2 questions, threshold 0.5) + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, + ) + await repo.create(given_conversation) + + # WHEN the user submits correct answers + actual_result = await service.send_message( + "user_abc", "cv-development", given_conversation.conversation_id, "1.A, 2.B", + ) + + # THEN the quiz is passed + assert actual_result.quiz_passed is True + assert actual_result.module_completed is True + assert actual_result.conversation_mode == ConversationMode.SUPPORT + # AND the conversation state is updated + actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_conv is not None + assert actual_conv.quiz_passed is True + assert actual_conv.conversation_mode == ConversationMode.SUPPORT + + @pytest.mark.asyncio + async def test_quiz_submission_fail(self): + # GIVEN a conversation with quiz delivered (2 questions, threshold 0.5) + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, + ) + await repo.create(given_conversation) + + # WHEN the user submits all wrong answers + actual_result = await service.send_message( + "user_abc", "cv-development", given_conversation.conversation_id, "1.C, 2.C", + ) + + # THEN the quiz is not passed + assert actual_result.quiz_passed is False + assert actual_result.module_completed is False + assert actual_result.conversation_mode == ConversationMode.INSTRUCTION + # AND quiz_delivered is reset for retry + actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_conv is not None + assert actual_conv.quiz_delivered is False + + @pytest.mark.asyncio + async def test_quiz_fail_feedback_shows_correct_threshold_count(self): + # GIVEN a module with 3 questions and 0.7 threshold (ceil(0.7*3) = 3, not int(0.7*3) = 2) + given_questions = [ + QuizQuestion(question="Q1?", options=["A. X", "B. Y", "C. Z", "D. W"], correct_answer="A"), + QuizQuestion(question="Q2?", options=["A. X", "B. Y", "C. Z", "D. W"], correct_answer="B"), + QuizQuestion(question="Q3?", options=["A. X", "B. Y", "C. Z", "D. W"], correct_answer="C"), + ] + given_quiz = QuizConfig(pass_threshold=0.7, questions=given_questions) + given_module = _make_module_config(quiz=given_quiz) + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, + ) + await repo.create(given_conversation) + + # WHEN the user submits 2/3 correct answers (which fails since 2/3 = 0.66 < 0.7) + actual_result = await service.send_message( + "user_abc", "cv-development", given_conversation.conversation_id, "1.A, 2.B, 3.D", + ) + + # THEN the feedback message says "at least 3" (not "at least 2") + actual_feedback = actual_result.messages[-1].message + assert "at least 3" in actual_feedback + assert actual_result.quiz_passed is False + + @pytest.mark.asyncio + async def test_quiz_non_answer_falls_through_to_instruction(self): + # GIVEN a conversation with quiz delivered + given_module = _make_module_config() + given_agent = MockCareerReadinessAgent(response_message="Let me help you with that.") + service, repo = _make_service(modules=[given_module], agent=given_agent) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, + ) + await repo.create(given_conversation) + + # WHEN the user sends a non-answer message + actual_result = await service.send_message( + "user_abc", "cv-development", given_conversation.conversation_id, "mmm what is this?", + ) + + # THEN the message is handled by the instruction agent instead of being scored + assert actual_result.messages[-1].message == "Let me help you with that." + # AND quiz_delivered is NOT reset + actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_conv is not None + assert actual_conv.quiz_delivered is True + + @pytest.mark.asyncio + async def test_support_mode_message(self): + # GIVEN a completed conversation in support mode + given_module = _make_module_config() + given_agent = MockCareerReadinessAgent(response_message="Here's more info on that topic.") + service, repo = _make_service(modules=[given_module], agent=given_agent) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + conversation_mode=ConversationMode.SUPPORT, + quiz_passed=True, + ) + await repo.create(given_conversation) + + # WHEN a message is sent in support mode + actual_result = await service.send_message( + "user_abc", "cv-development", given_conversation.conversation_id, "Can you explain more?", + ) + + # THEN the agent responds normally + assert actual_result.messages[-1].message == "Here's more info on that topic." + assert actual_result.conversation_mode == ConversationMode.SUPPORT + assert actual_result.quiz_passed is True @pytest.mark.asyncio async def test_raises_conversation_not_found(self): @@ -446,6 +888,44 @@ async def test_raises_conversation_not_found(self): with pytest.raises(ConversationNotFoundError): await service.get_conversation_history("user_abc", "cv-development", "nonexistent") + @pytest.mark.asyncio + async def test_returns_quiz_passed_false_when_quiz_delivered_but_not_passed(self): + # GIVEN a conversation where the quiz was delivered but not yet passed + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, quiz_passed=False, + ) + await repo.create(given_conversation) + + # WHEN the history is requested + actual_result = await service.get_conversation_history( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + # THEN quiz_passed is False (not None) + assert actual_result.quiz_passed is False + + @pytest.mark.asyncio + async def test_returns_quiz_passed_none_when_quiz_not_delivered(self): + # GIVEN a conversation where the quiz has not been delivered + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=False, quiz_passed=False, + ) + await repo.create(given_conversation) + + # WHEN the history is requested + actual_result = await service.get_conversation_history( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + # THEN quiz_passed is None (quiz never attempted) + assert actual_result.quiz_passed is None + @pytest.mark.asyncio async def test_raises_access_denied_for_wrong_user(self): # GIVEN a conversation owned by user_abc diff --git a/backend/app/career_readiness/types.py b/backend/app/career_readiness/types.py index 0ee0ad5e..f8b6ea95 100644 --- a/backend/app/career_readiness/types.py +++ b/backend/app/career_readiness/types.py @@ -6,10 +6,16 @@ class ModuleStatus(str, Enum): NOT_STARTED = "NOT_STARTED" + UNLOCKED = "UNLOCKED" IN_PROGRESS = "IN_PROGRESS" COMPLETED = "COMPLETED" +class ConversationMode(str, Enum): + INSTRUCTION = "INSTRUCTION" + SUPPORT = "SUPPORT" + + class CareerReadinessMessageSender(str, Enum): USER = "USER" AGENT = "AGENT" @@ -149,6 +155,15 @@ class CareerReadinessConversationResponse(BaseModel): module_completed: bool = False """Whether the module has been completed through this conversation""" + quiz_passed: bool | None = None + """Whether the user passed the module quiz. None = not attempted.""" + + covered_topics: list[str] = [] + """Topics that have been covered so far in this conversation.""" + + conversation_mode: ConversationMode | None = None + """The current conversation mode (INSTRUCTION or SUPPORT).""" + class Config: extra = "forbid" @@ -182,6 +197,18 @@ class CareerReadinessConversationDocument(BaseModel): messages: list[CareerReadinessMessage] = Field(default_factory=list) """The messages in the conversation""" + conversation_mode: ConversationMode = ConversationMode.INSTRUCTION + """The current conversation mode (INSTRUCTION during teaching, SUPPORT after quiz pass)""" + + covered_topics: list[str] = Field(default_factory=list) + """Topics the agent has covered so far in the conversation""" + + quiz_delivered: bool = False + """Whether the quiz has been presented to the user""" + + quiz_passed: bool = False + """Whether the user passed the module quiz""" + created_at: datetime """When the conversation was created""" @@ -211,6 +238,10 @@ def from_dict(_dict: dict) -> "CareerReadinessConversationDocument": module_id=str(_dict["module_id"]), user_id=str(_dict["user_id"]), messages=[CareerReadinessMessage(**msg) for msg in _dict.get("messages", [])], + conversation_mode=ConversationMode(_dict.get("conversation_mode", ConversationMode.INSTRUCTION)), + covered_topics=_dict.get("covered_topics", []), + quiz_delivered=_dict.get("quiz_delivered", False), + quiz_passed=_dict.get("quiz_passed", False), created_at=_dict["created_at"], updated_at=_dict["updated_at"], ) From eb3e6e8b1b81d3a68d6be75fa62b22801b2aaa3b Mon Sep 17 00:00:00 2001 From: nraffa Date: Thu, 5 Mar 2026 19:02:57 -0300 Subject: [PATCH 23/42] refactor(career-readiness): reduce test suite from 125 to 90 tests Consolidate over-tested instruction/support mode builder tests into single comprehensive tests, merge sort-order check into module loading test, and remove redundant tests that duplicate coverage across layers. --- backend/app/career_readiness/test_agent.py | 124 ++---------------- .../career_readiness/test_module_loader.py | 118 +---------------- .../app/career_readiness/test_repository.py | 72 ---------- backend/app/career_readiness/test_routes.py | 61 --------- backend/app/career_readiness/test_service.py | 121 ----------------- 5 files changed, 15 insertions(+), 481 deletions(-) diff --git a/backend/app/career_readiness/test_agent.py b/backend/app/career_readiness/test_agent.py index fe91e5a3..36c44df0 100644 --- a/backend/app/career_readiness/test_agent.py +++ b/backend/app/career_readiness/test_agent.py @@ -23,7 +23,7 @@ class TestBuildInstructionModeInstructions: """Tests for the instruction mode system instructions builder.""" - def test_includes_module_title(self): + def test_includes_all_required_elements(self): # GIVEN a module title, content, and topics given_title = "CV Development" given_content = "How to write a great CV." @@ -32,85 +32,35 @@ def test_includes_module_title(self): # WHEN the instruction mode instructions are built actual_instructions = _build_instruction_mode_instructions(given_title, given_content, given_topics) - # THEN the module title is included + # THEN the module title and content are included assert given_title in actual_instructions - - def test_includes_module_content(self): - # GIVEN a module title, content, and topics - given_title = "Interview Preparation" - given_content = "Practice answering behavioral questions using the STAR method." - given_topics = ["STAR Method"] - - # WHEN the instruction mode instructions are built - actual_instructions = _build_instruction_mode_instructions(given_title, given_content, given_topics) - - # THEN the module content is included assert given_content in actual_instructions - - def test_includes_topics_list(self): - # GIVEN topics for the module - given_topics = ["Types of Skills", "How to Identify Skills", "Professional Identity"] - - # WHEN the instruction mode instructions are built - actual_instructions = _build_instruction_mode_instructions("Module", "Content", given_topics) - - # THEN each topic appears in the instructions + # AND each topic appears in the instructions for topic in given_topics: assert topic in actual_instructions - - def test_includes_scaffolded_socratic_instructions(self): - # GIVEN any module - # WHEN the instruction mode instructions are built - actual_instructions = _build_instruction_mode_instructions("Module", "Content", ["Topic"]) - - # THEN the scaffolded Socratic tutoring approach is present + # AND the scaffolded Socratic tutoring keywords are present assert "ASSESS" in actual_instructions assert "GUIDE" in actual_instructions assert "HINT" in actual_instructions assert "EXPLAIN" in actual_instructions assert "FADE" in actual_instructions - - def test_includes_comprehension_check_instructions(self): - # GIVEN any module - # WHEN the instruction mode instructions are built - actual_instructions = _build_instruction_mode_instructions("Module", "Content", ["Topic"]) - - # THEN comprehension check techniques are mentioned - assert "explain" in actual_instructions.lower() - assert "application" in actual_instructions.lower() - - def test_includes_topics_covered_in_response_schema(self): - # GIVEN any module - # WHEN the instruction mode instructions are built - actual_instructions = _build_instruction_mode_instructions("Module", "Content", ["Topic"]) - - # THEN the response schema mentions topics_covered + # AND topics_covered is referenced in the response schema assert "topics_covered" in actual_instructions - - def test_instructs_not_to_reveal_quiz(self): - # GIVEN any module - # WHEN the instruction mode instructions are built - actual_instructions = _build_instruction_mode_instructions("Module", "Content", ["Topic"]) - - # THEN there is an instruction not to discuss the quiz + # AND quiz concealment instructions are present assert "quiz" in actual_instructions.lower() assert "do not" in actual_instructions.lower() - - - def test_instructs_to_handle_minimal_responses(self): - # GIVEN any module - # WHEN the instruction mode instructions are built - actual_instructions = _build_instruction_mode_instructions("Module", "Content", ["Topic"]) - - # THEN there are instructions to not accept minimal responses as understanding + # AND minimal-response handling instructions are present assert "minimal" in actual_instructions.lower() assert "demonstrate" in actual_instructions.lower() + # AND comprehension check techniques are mentioned + assert "explain" in actual_instructions.lower() + assert "application" in actual_instructions.lower() class TestBuildSupportModeInstructions: """Tests for the support mode system instructions builder.""" - def test_includes_module_title(self): + def test_includes_all_required_elements(self): # GIVEN a module title and content given_title = "CV Development" given_content = "How to write a great CV." @@ -118,34 +68,13 @@ def test_includes_module_title(self): # WHEN the support mode instructions are built actual_instructions = _build_support_mode_instructions(given_title, given_content) - # THEN the module title is included + # THEN the module title and content are included assert given_title in actual_instructions - - def test_includes_module_content(self): - # GIVEN a module title and content - given_content = "Practice answering behavioral questions." - - # WHEN the support mode instructions are built - actual_instructions = _build_support_mode_instructions("Module", given_content) - - # THEN the module content is included assert given_content in actual_instructions - - def test_instructs_finished_always_false(self): - # GIVEN any module - # WHEN the support mode instructions are built - actual_instructions = _build_support_mode_instructions("Module", "Content") - - # THEN it instructs finished to always be false + # AND it instructs finished to always be false assert "false" in actual_instructions.lower() assert "finished" in actual_instructions.lower() - - def test_instructs_not_to_reinitiate_lesson(self): - # GIVEN any module - # WHEN the support mode instructions are built - actual_instructions = _build_support_mode_instructions("Module", "Content") - - # THEN there is an instruction not to re-initiate the lesson plan + # AND there is an instruction not to re-initiate the lesson plan assert "lesson plan" in actual_instructions.lower() @@ -301,28 +230,3 @@ async def test_generate_intro_message_sends_artificial_input(self, mock_llm_cls) call_args = given_agent._llm_caller.call_llm.call_args llm_input = call_args.kwargs["llm_input"] assert any("(silence)" in turn.content for turn in llm_input.turns) - - @pytest.mark.asyncio - async def test_execute_strips_quotes_from_message(self, mock_llm_cls): - # GIVEN an agent whose LLM returns a message wrapped in quotes - given_agent = CareerReadinessAgent( - module_title="Test Module", module_content="Test content.", topics=["Topic A"]) - given_model_response = CareerReadinessModelResponse( - reasoning="Responding to user.", - finished=False, - message='"Here is some advice."', - topics_covered=[], - ) - given_agent._llm_caller.call_llm = AsyncMock(return_value=(given_model_response, [])) - - given_input = AgentInput(message="Help me") - given_context = ConversationContext( - all_history=ConversationHistory(), - history=ConversationHistory(), - ) - - # WHEN execute is called - actual_output = await given_agent.execute(given_input, given_context) - - # THEN the leading and trailing quotes are stripped - assert actual_output.agent_output.message_for_user == "Here is some advice." diff --git a/backend/app/career_readiness/test_module_loader.py b/backend/app/career_readiness/test_module_loader.py index 36f0c706..1a8abc63 100644 --- a/backend/app/career_readiness/test_module_loader.py +++ b/backend/app/career_readiness/test_module_loader.py @@ -150,23 +150,6 @@ def test_parses_valid_quiz_section(self): assert len(actual_quiz.questions[0].options) == 4 assert actual_quiz.questions[0].correct_answer == "B" - def test_defaults_pass_threshold_to_0_7(self): - # GIVEN a quiz section without pass_threshold - given_text = ( - "1. A question?\n" - "A. Option A\n" - "B. Option B\n" - "C. Option C\n" - "D. Option D\n" - "Answer: A\n" - ) - - # WHEN the quiz section is parsed - actual_quiz = _parse_quiz_section(given_text) - - # THEN the pass threshold defaults to 0.7 - assert actual_quiz.pass_threshold == 0.7 - def test_raises_when_no_questions_found(self): # GIVEN a quiz section with no valid questions given_text = "pass_threshold: 0.8\n\nSome random text." @@ -191,30 +174,6 @@ def test_raises_when_answer_missing(self): with pytest.raises(ValueError, match="missing Answer"): _parse_quiz_section(given_text) - def test_parses_options_correctly(self): - # GIVEN a quiz section with specific options - given_text = ( - "1. Which is best?\n" - "A. First option\n" - "B. Second option\n" - "C. Third option\n" - "D. Fourth option\n" - "Answer: C\n" - ) - - # WHEN the quiz section is parsed - actual_quiz = _parse_quiz_section(given_text) - - # THEN the options are formatted as "X. text" - assert actual_quiz.questions[0].options == [ - "A. First option", - "B. Second option", - "C. Third option", - "D. Fourth option", - ] - # AND the correct answer is C - assert actual_quiz.questions[0].correct_answer == "C" - class TestLoadModuleFromFile: """Tests for loading a single module from a file.""" @@ -265,28 +224,6 @@ def test_loads_module_without_quiz(self, tmp_path): # AND the content includes the full body assert "No quiz here." in actual_module.content - def test_loads_module_without_topics(self, tmp_path): - # GIVEN a module file without a topics field - given_file = tmp_path / "no_topics.md" - given_file.write_text( - "---\n" - "id: no-topics\n" - "title: No Topics Module\n" - "description: A module without topics.\n" - "icon: test\n" - "sort_order: 1\n" - "input_placeholder: Ask...\n" - "---\n\n" - "# Content", - encoding="utf-8", - ) - - # WHEN the module is loaded - actual_module = _load_module_from_file(given_file) - - # THEN the topics list is empty - assert actual_module.topics == [] - def test_raises_when_required_field_missing(self, tmp_path): # GIVEN a module file missing the 'title' field given_file = tmp_path / "bad.md" @@ -318,15 +255,7 @@ def test_loads_all_real_modules(self): # THEN all 5 modules are loaded actual_modules = actual_registry.get_all_modules() assert len(actual_modules) == 5 - - def test_modules_sorted_by_sort_order(self): - # GIVEN the real modules directory - actual_registry = ModuleRegistry() - - # WHEN all modules are retrieved - actual_modules = actual_registry.get_all_modules() - - # THEN they are sorted by sort_order + # AND they are sorted by sort_order actual_orders = [m.sort_order for m in actual_modules] assert actual_orders == sorted(actual_orders) @@ -410,39 +339,6 @@ def test_skips_files_starting_with_underscore(self, tmp_path): assert actual_registry.get_module("real") is not None assert actual_registry.get_module("example") is None - def test_empty_directory_loads_no_modules(self, tmp_path): - # GIVEN an empty directory - given_empty_dir = tmp_path / "empty" - given_empty_dir.mkdir() - - # WHEN a registry is created with the empty directory - actual_registry = ModuleRegistry(modules_dir=given_empty_dir) - - # THEN no modules are loaded - assert len(actual_registry.get_all_modules()) == 0 - - def test_each_module_has_nonempty_content(self): - # GIVEN the real modules directory - actual_registry = ModuleRegistry() - - # WHEN all modules are retrieved - actual_modules = actual_registry.get_all_modules() - - # THEN each module has non-empty content - for module in actual_modules: - assert len(module.content) > 0, f"Module {module.id} has empty content" - - def test_all_module_ids_are_unique(self): - # GIVEN the real modules directory - actual_registry = ModuleRegistry() - - # WHEN all modules are retrieved - actual_modules = actual_registry.get_all_modules() - - # THEN all IDs are unique - actual_ids = [m.id for m in actual_modules] - assert len(actual_ids) == len(set(actual_ids)) - def test_all_real_modules_have_topics(self): # GIVEN the real modules directory actual_registry = ModuleRegistry() @@ -467,15 +363,3 @@ def test_all_real_modules_have_quiz(self): assert len(module.quiz.questions) == 10, ( f"Module {module.id} has {len(module.quiz.questions)} questions, expected 10" ) - - def test_real_module_content_excludes_quiz(self): - # GIVEN the real modules directory - actual_registry = ModuleRegistry() - - # WHEN all modules are retrieved - actual_modules = actual_registry.get_all_modules() - - # THEN no module content contains quiz material - for module in actual_modules: - assert "## Quiz" not in module.content, f"Module {module.id} content contains quiz section" - assert "Answer:" not in module.content, f"Module {module.id} content contains quiz answers" diff --git a/backend/app/career_readiness/test_repository.py b/backend/app/career_readiness/test_repository.py index 75301a95..adbb90ea 100644 --- a/backend/app/career_readiness/test_repository.py +++ b/backend/app/career_readiness/test_repository.py @@ -93,17 +93,6 @@ async def test_raises_already_exists_on_duplicate_user_module(self, class TestFindByConversationId: """Tests for finding a conversation by its ID.""" - @pytest.mark.asyncio - async def test_returns_none_when_not_found(self, get_repository: Awaitable[CareerReadinessConversationRepository]): - # GIVEN a repository with no documents - repo = await get_repository - - # WHEN a nonexistent conversation is requested - actual_result = await repo.find_by_conversation_id("nonexistent") - - # THEN None is returned - assert actual_result is None - @pytest.mark.asyncio async def test_returns_document_when_found(self, get_repository: Awaitable[CareerReadinessConversationRepository]): # GIVEN a repository with a conversation @@ -122,17 +111,6 @@ async def test_returns_document_when_found(self, get_repository: Awaitable[Caree class TestFindByUserAndModule: """Tests for finding a conversation by user and module.""" - @pytest.mark.asyncio - async def test_returns_none_when_not_found(self, get_repository: Awaitable[CareerReadinessConversationRepository]): - # GIVEN a repository with no documents - repo = await get_repository - - # WHEN a conversation is requested for a user and module - actual_result = await repo.find_by_user_and_module("some_user", "some_module") - - # THEN None is returned - assert actual_result is None - @pytest.mark.asyncio async def test_returns_correct_document(self, get_repository: Awaitable[CareerReadinessConversationRepository]): # GIVEN a repository with a conversation for a specific user and module @@ -214,24 +192,6 @@ async def test_appends_message_to_conversation(self, assert len(actual_result.messages) == 2 assert actual_result.messages[1].message == "How do I write a CV?" - @pytest.mark.asyncio - async def test_updates_updated_at_timestamp(self, - get_repository: Awaitable[CareerReadinessConversationRepository]): - # GIVEN a repository with a conversation - repo = await get_repository - given_conversation = _make_conversation() - given_original_updated_at = given_conversation.updated_at - await repo.create(given_conversation) - - # WHEN a message is appended - given_new_message = _make_message(message="New message") - await repo.append_message(given_conversation.conversation_id, given_new_message) - - # THEN the updated_at timestamp is changed - actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) - assert actual_result is not None - assert actual_result.updated_at >= given_original_updated_at - class TestDeleteByConversationId: """Tests for deleting a conversation.""" @@ -284,22 +244,6 @@ async def test_updates_covered_topics(self, get_repository: Awaitable[CareerRead assert actual_result is not None assert actual_result.covered_topics == given_topics - @pytest.mark.asyncio - async def test_updates_updated_at_timestamp(self, get_repository: Awaitable[CareerReadinessConversationRepository]): - # GIVEN a repository with a conversation - repo = await get_repository - given_conversation = _make_conversation() - given_original_updated_at = given_conversation.updated_at - await repo.create(given_conversation) - - # WHEN covered topics are updated - await repo.update_covered_topics(given_conversation.conversation_id, ["Topic A"]) - - # THEN the updated_at timestamp is changed - actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) - assert actual_result is not None - assert actual_result.updated_at >= given_original_updated_at - class TestUpdateQuizDelivered: """Tests for updating the quiz_delivered flag.""" @@ -319,22 +263,6 @@ async def test_sets_quiz_delivered_to_true(self, get_repository: Awaitable[Caree assert actual_result is not None assert actual_result.quiz_delivered is True - @pytest.mark.asyncio - async def test_resets_quiz_delivered_to_false(self, get_repository: Awaitable[CareerReadinessConversationRepository]): - # GIVEN a repository with a conversation where quiz was delivered - repo = await get_repository - given_conversation = _make_conversation() - await repo.create(given_conversation) - await repo.update_quiz_delivered(given_conversation.conversation_id, True) - - # WHEN quiz_delivered is reset to False - await repo.update_quiz_delivered(given_conversation.conversation_id, False) - - # THEN the conversation has quiz_delivered = False - actual_result = await repo.find_by_conversation_id(given_conversation.conversation_id) - assert actual_result is not None - assert actual_result.quiz_delivered is False - class TestUpdateQuizPassed: """Tests for updating the quiz_passed flag.""" diff --git a/backend/app/career_readiness/test_routes.py b/backend/app/career_readiness/test_routes.py index 3ca242b2..618eb088 100644 --- a/backend/app/career_readiness/test_routes.py +++ b/backend/app/career_readiness/test_routes.py @@ -16,7 +16,6 @@ ConversationAlreadyExistsError, ConversationNotFoundError, CareerReadinessModuleNotFoundError, - ModuleNotUnlockedError, ) from app.career_readiness.routes import add_career_readiness_routes, get_career_readiness_service from app.career_readiness.service import ICareerReadinessService @@ -132,19 +131,6 @@ def test_returns_200_with_modules(self, client_with_mocks: TestClientWithMocks): assert len(body["modules"]) == 1 assert body["modules"][0]["id"] == "cv-development" - def test_returns_500_on_unexpected_error(self, client_with_mocks: TestClientWithMocks, - mocker: pytest_mock.MockerFixture): - client, mock_service, _ = client_with_mocks - - # GIVEN the service raises an unexpected error - mocker.patch.object(mock_service, "list_modules", side_effect=Exception("Unexpected")) - - # WHEN modules are listed - response = client.get("/career-readiness/modules") - - # THEN 500 is returned - assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - class TestGetModule: """Tests for GET /career-readiness/modules/{module_id}.""" @@ -202,20 +188,6 @@ def test_returns_404_when_module_not_found(self, client_with_mocks: TestClientWi # THEN 404 is returned assert response.status_code == HTTPStatus.NOT_FOUND - def test_returns_403_when_module_not_unlocked(self, client_with_mocks: TestClientWithMocks, - mocker: pytest_mock.MockerFixture): - client, mock_service, _ = client_with_mocks - - # GIVEN the service raises ModuleNotUnlockedError - mocker.patch.object(mock_service, "create_conversation", - side_effect=ModuleNotUnlockedError("interview-preparation")) - - # WHEN a conversation is created for a locked module - response = client.post("/career-readiness/modules/interview-preparation/conversations") - - # THEN 403 FORBIDDEN is returned - assert response.status_code == HTTPStatus.FORBIDDEN - def test_returns_409_when_already_exists(self, client_with_mocks: TestClientWithMocks, mocker: pytest_mock.MockerFixture): client, mock_service, mocked_user = client_with_mocks @@ -262,23 +234,6 @@ def test_returns_404_when_conversation_not_found(self, client_with_mocks: TestCl # THEN 404 is returned assert response.status_code == HTTPStatus.NOT_FOUND - def test_returns_403_when_access_denied(self, client_with_mocks: TestClientWithMocks, - mocker: pytest_mock.MockerFixture): - client, mock_service, _ = client_with_mocks - - # GIVEN the service raises ConversationAccessDeniedError - mocker.patch.object(mock_service, "send_message", - side_effect=ConversationAccessDeniedError("conv123", "other_user")) - - # WHEN a message is sent - response = client.post( - "/career-readiness/modules/cv-development/conversations/conv123/messages", - json={"user_input": "Hello"}, - ) - - # THEN 403 FORBIDDEN is returned - assert response.status_code == HTTPStatus.FORBIDDEN - def test_returns_413_when_message_too_long(self, client_with_mocks: TestClientWithMocks): client, _, _ = client_with_mocks @@ -325,22 +280,6 @@ def test_returns_404_when_not_found(self, client_with_mocks: TestClientWithMocks # THEN 404 is returned assert response.status_code == HTTPStatus.NOT_FOUND - def test_returns_403_when_access_denied(self, client_with_mocks: TestClientWithMocks, - mocker: pytest_mock.MockerFixture): - client, mock_service, _ = client_with_mocks - - # GIVEN the service raises ConversationAccessDeniedError - mocker.patch.object(mock_service, "get_conversation_history", - side_effect=ConversationAccessDeniedError("conv123", "other_user")) - - # WHEN the conversation history is requested - response = client.get( - "/career-readiness/modules/cv-development/conversations/conv123/messages", - ) - - # THEN 403 FORBIDDEN is returned - assert response.status_code == HTTPStatus.FORBIDDEN - class TestDeleteConversation: """Tests for DELETE /career-readiness/modules/{module_id}/conversations/{conversation_id}.""" diff --git a/backend/app/career_readiness/test_service.py b/backend/app/career_readiness/test_service.py index 8196640a..43f7f69d 100644 --- a/backend/app/career_readiness/test_service.py +++ b/backend/app/career_readiness/test_service.py @@ -11,7 +11,6 @@ from app.career_readiness.errors import ( ConversationAccessDeniedError, ConversationAlreadyExistsError, - ConversationModuleMismatchError, ConversationNotFoundError, CareerReadinessModuleNotFoundError, ModuleNotUnlockedError, @@ -361,16 +360,6 @@ def test_parses_sequential_letters(self): # THEN answers are numbered sequentially assert actual_answers == {1: "B", 2: "A", 3: "C"} - def test_normalizes_lowercase(self): - # GIVEN lowercase answers - given_input = "1.b, 2.a" - - # WHEN parsed - actual_answers = _parse_quiz_answers(given_input) - - # THEN answers are uppercase - assert actual_answers == {1: "B", 2: "A"} - class TestEvaluateQuiz: """Tests for the quiz evaluator.""" @@ -399,18 +388,6 @@ def test_partial_correct(self): # THEN score is 1 assert actual_score == 1 - def test_missing_answers_counted_as_wrong(self): - # GIVEN a quiz with no answers provided - given_quiz = _make_quiz_config() - given_answers: dict[int, str] = {} - - # WHEN evaluated - actual_score, actual_total, _ = _evaluate_quiz(given_quiz, given_answers) - - # THEN score is 0 - assert actual_score == 0 - assert actual_total == 2 - # --------------------------------------------------------------------------- # Tests — Service methods @@ -453,23 +430,6 @@ def test_builds_context_from_silence_intro_only(self): assert actual_context.history.turns[0].input.message == "(silence)" assert actual_context.history.turns[0].output.message_for_user == "Welcome!" - def test_builds_context_with_multiple_pairs(self): - # GIVEN a conversation with silence+intro and multiple exchanges - given_messages = [ - _make_message(sender=CareerReadinessMessageSender.USER, message="(silence)"), - _make_message(sender=CareerReadinessMessageSender.AGENT, message="Welcome!"), - _make_message(sender=CareerReadinessMessageSender.USER, message="Q1"), - _make_message(sender=CareerReadinessMessageSender.AGENT, message="A1"), - _make_message(sender=CareerReadinessMessageSender.USER, message="Q2"), - _make_message(sender=CareerReadinessMessageSender.AGENT, message="A2"), - ] - - # WHEN the context is built - actual_context = _build_conversation_context(given_messages) - - # THEN there are three conversation turns - assert len(actual_context.history.turns) == 3 - class TestListModules: """Tests for listing modules with sequential unlock status.""" @@ -504,23 +464,6 @@ async def test_returns_in_progress_when_conversation_exists(self): # THEN the module with a conversation has IN_PROGRESS status assert actual_result.modules[0].status == ModuleStatus.IN_PROGRESS - @pytest.mark.asyncio - async def test_completed_module_unlocks_next(self): - # GIVEN first module completed - given_modules = [ - _make_module_config(module_id="m1", sort_order=1), - _make_module_config(module_id="m2", title="Module 2", sort_order=2), - ] - service, repo = _make_service(modules=given_modules) - given_conversation = _make_conversation(user_id="user_abc", module_id="m1", quiz_passed=True) - await repo.create(given_conversation) - - # WHEN modules are listed - actual_result = await service.list_modules("user_abc") - - # THEN first is COMPLETED, second is UNLOCKED - assert actual_result.modules[0].status == ModuleStatus.COMPLETED - assert actual_result.modules[1].status == ModuleStatus.UNLOCKED class TestGetModule: @@ -734,33 +677,6 @@ async def test_quiz_submission_fail(self): assert actual_conv is not None assert actual_conv.quiz_delivered is False - @pytest.mark.asyncio - async def test_quiz_fail_feedback_shows_correct_threshold_count(self): - # GIVEN a module with 3 questions and 0.7 threshold (ceil(0.7*3) = 3, not int(0.7*3) = 2) - given_questions = [ - QuizQuestion(question="Q1?", options=["A. X", "B. Y", "C. Z", "D. W"], correct_answer="A"), - QuizQuestion(question="Q2?", options=["A. X", "B. Y", "C. Z", "D. W"], correct_answer="B"), - QuizQuestion(question="Q3?", options=["A. X", "B. Y", "C. Z", "D. W"], correct_answer="C"), - ] - given_quiz = QuizConfig(pass_threshold=0.7, questions=given_questions) - given_module = _make_module_config(quiz=given_quiz) - service, repo = _make_service(modules=[given_module]) - given_conversation = _make_conversation( - user_id="user_abc", module_id="cv-development", - quiz_delivered=True, - ) - await repo.create(given_conversation) - - # WHEN the user submits 2/3 correct answers (which fails since 2/3 = 0.66 < 0.7) - actual_result = await service.send_message( - "user_abc", "cv-development", given_conversation.conversation_id, "1.A, 2.B, 3.D", - ) - - # THEN the feedback message says "at least 3" (not "at least 2") - actual_feedback = actual_result.messages[-1].message - assert "at least 3" in actual_feedback - assert actual_result.quiz_passed is False - @pytest.mark.asyncio async def test_quiz_non_answer_falls_through_to_instruction(self): # GIVEN a conversation with quiz delivered @@ -834,24 +750,6 @@ async def test_raises_access_denied_for_wrong_user(self): "other_user", "cv-development", given_conversation.conversation_id, "Hello", ) - @pytest.mark.asyncio - async def test_raises_module_mismatch(self): - # GIVEN a conversation for cv-development - given_modules = [ - _make_module_config(module_id="cv-development"), - _make_module_config(module_id="interview-prep", title="Interview Prep", sort_order=2), - ] - service, repo = _make_service(modules=given_modules) - given_conversation = _make_conversation(user_id="user_abc", module_id="cv-development") - await repo.create(given_conversation) - - # WHEN a message is sent under a different module - # THEN ConversationModuleMismatchError is raised - with pytest.raises(ConversationModuleMismatchError): - await service.send_message( - "user_abc", "interview-prep", given_conversation.conversation_id, "Hello", - ) - class TestGetConversationHistory: """Tests for retrieving conversation history.""" @@ -907,25 +805,6 @@ async def test_returns_quiz_passed_false_when_quiz_delivered_but_not_passed(self # THEN quiz_passed is False (not None) assert actual_result.quiz_passed is False - @pytest.mark.asyncio - async def test_returns_quiz_passed_none_when_quiz_not_delivered(self): - # GIVEN a conversation where the quiz has not been delivered - given_module = _make_module_config() - service, repo = _make_service(modules=[given_module]) - given_conversation = _make_conversation( - user_id="user_abc", module_id="cv-development", - quiz_delivered=False, quiz_passed=False, - ) - await repo.create(given_conversation) - - # WHEN the history is requested - actual_result = await service.get_conversation_history( - "user_abc", "cv-development", given_conversation.conversation_id, - ) - - # THEN quiz_passed is None (quiz never attempted) - assert actual_result.quiz_passed is None - @pytest.mark.asyncio async def test_raises_access_denied_for_wrong_user(self): # GIVEN a conversation owned by user_abc From 33d81c6096cd26155d113e63931a8fb1072f3276 Mon Sep 17 00:00:00 2001 From: nraffa Date: Fri, 6 Mar 2026 15:41:23 -0300 Subject: [PATCH 24/42] feat(career-readiness): expose quiz as structured data via dedicated GET/POST endpoints --- backend/app/career_readiness/errors.py | 14 + backend/app/career_readiness/routes.py | 86 ++++- backend/app/career_readiness/service.py | 237 +++++++------- backend/app/career_readiness/test_routes.py | 144 +++++++++ backend/app/career_readiness/test_service.py | 313 ++++++++++++------- backend/app/career_readiness/types.py | 76 ++++- 6 files changed, 632 insertions(+), 238 deletions(-) diff --git a/backend/app/career_readiness/errors.py b/backend/app/career_readiness/errors.py index 7c707a43..6b2780d6 100644 --- a/backend/app/career_readiness/errors.py +++ b/backend/app/career_readiness/errors.py @@ -43,3 +43,17 @@ class ModuleNotUnlockedError(Exception): def __init__(self, module_id: str): super().__init__(f"Module {module_id} is not yet unlocked") + + +class QuizNotAvailableError(Exception): + """Raised when the quiz is not available for this conversation.""" + + def __init__(self, conversation_id: str): + super().__init__(f"Quiz is not available for conversation {conversation_id}") + + +class QuizAlreadyPassedError(Exception): + """Raised when quiz was already passed.""" + + def __init__(self, conversation_id: str): + super().__init__(f"Quiz already passed for conversation {conversation_id}") diff --git a/backend/app/career_readiness/routes.py b/backend/app/career_readiness/routes.py index 02f64d34..49540c48 100644 --- a/backend/app/career_readiness/routes.py +++ b/backend/app/career_readiness/routes.py @@ -6,7 +6,7 @@ from http import HTTPStatus from typing import Annotated, Optional -from fastapi import FastAPI, APIRouter, Depends, HTTPException, Path +from fastapi import Body, FastAPI, APIRouter, Depends, HTTPException, Path from motor.motor_asyncio import AsyncIOMotorDatabase from app.career_readiness.errors import ( @@ -16,6 +16,8 @@ ConversationModuleMismatchError, ConversationNotFoundError, ModuleNotUnlockedError, + QuizAlreadyPassedError, + QuizNotAvailableError, ) from app.career_readiness.module_loader import get_module_registry from app.career_readiness.repository import CareerReadinessConversationRepository @@ -25,6 +27,9 @@ ModuleDetail, CareerReadinessConversationResponse, CareerReadinessConversationInput, + QuizResponse, + QuizSubmissionInput, + QuizSubmissionResponse, ) from app.constants.errors import HTTPErrorResponse from app.conversations.constants import MAX_MESSAGE_LENGTH @@ -78,7 +83,7 @@ async def _list_modules( try: return await service.list_modules(user_info.user_id) except Exception as e: - logger.exception("Error listing modules: %s", e) + logger.exception(e) raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e @router.get( @@ -100,7 +105,7 @@ async def _get_module( except CareerReadinessModuleNotFoundError as exc: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"Module not found: {module_id}") from exc except Exception as e: - logger.exception("Error getting module: %s", e) + logger.exception(e) raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e @router.post( @@ -130,7 +135,7 @@ async def _create_conversation( raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=f"A conversation already exists for module {module_id}") from exc except Exception as e: - logger.exception("Error creating conversation: %s", e) + logger.exception(e) raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e @router.post( @@ -163,7 +168,7 @@ async def _send_message( except ConversationAccessDeniedError as exc: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc except Exception as e: - logger.exception("Error sending message: %s", e) + logger.exception(e) raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e @router.get( @@ -189,7 +194,7 @@ async def _get_conversation_history( except ConversationAccessDeniedError as exc: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc except Exception as e: - logger.exception("Error getting conversation history: %s", e) + logger.exception(e) raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e @router.delete( @@ -215,7 +220,74 @@ async def _delete_conversation( except ConversationAccessDeniedError as exc: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc except Exception as e: - logger.exception("Error deleting conversation: %s", e) + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e + + @router.get( + path="/modules/{module_id}/conversations/{conversation_id}/quiz", + response_model=QuizResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.CONFLICT: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Get quiz questions for the active quiz.", + ) + async def _get_quiz( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), + ): + try: + return await service.get_quiz(user_info.user_id, module_id, conversation_id) + except (CareerReadinessModuleNotFoundError, ConversationNotFoundError, ConversationModuleMismatchError) as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Conversation not found") from exc + except ConversationAccessDeniedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc + except (QuizNotAvailableError, QuizAlreadyPassedError) as exc: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="Quiz is not available") from exc + except Exception as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e + + @router.post( + path="/modules/{module_id}/conversations/{conversation_id}/quiz", + status_code=HTTPStatus.OK, + response_model=QuizSubmissionResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.FORBIDDEN: {"model": HTTPErrorResponse}, + HTTPStatus.CONFLICT: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + description="Submit quiz answers for evaluation.", + ) + async def _submit_quiz( + module_id: Annotated[str, Path(description="The module identifier slug.", examples=["cv-resume-creation"])], + conversation_id: Annotated[str, Path(description="The conversation identifier.", examples=["conv_abc123"])], + body: Annotated[QuizSubmissionInput, Body( + openapi_examples={ + "three_questions": { + "summary": "Answers for a 3-question quiz", + "value": {"answers": {"1": "B", "2": "A", "3": "C"}}, + }, + }, + )], + user_info: UserInfo = Depends(authentication.get_user_info()), + service: ICareerReadinessService = Depends(get_career_readiness_service), + ): + try: + return await service.submit_quiz(user_info.user_id, module_id, conversation_id, body.answers) + except (CareerReadinessModuleNotFoundError, ConversationNotFoundError, ConversationModuleMismatchError) as exc: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Conversation not found") from exc + except ConversationAccessDeniedError as exc: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Access denied") from exc + except (QuizNotAvailableError, QuizAlreadyPassedError) as exc: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="Quiz is not available") from exc + except Exception as e: + logger.exception(e) raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Unexpected error") from e app.include_router(router) diff --git a/backend/app/career_readiness/service.py b/backend/app/career_readiness/service.py index a5d7a2f7..642791ce 100644 --- a/backend/app/career_readiness/service.py +++ b/backend/app/career_readiness/service.py @@ -6,7 +6,6 @@ """ import logging import math -import re from abc import ABC, abstractmethod from datetime import datetime, timezone from typing import Callable @@ -22,6 +21,8 @@ ConversationNotFoundError, CareerReadinessModuleNotFoundError, ModuleNotUnlockedError, + QuizAlreadyPassedError, + QuizNotAvailableError, ) from app.career_readiness.module_loader import ModuleConfig, ModuleRegistry, QuizConfig from app.career_readiness.repository import ICareerReadinessConversationRepository @@ -35,6 +36,10 @@ ModuleListResponse, ModuleStatus, ModuleSummary, + QuizQuestionResponse, + QuizQuestionResult, + QuizResponse, + QuizSubmissionResponse, ) from app.conversation_memory.conversation_memory_types import ( ConversationContext, @@ -82,6 +87,18 @@ async def get_conversation_history(self, user_id: str, module_id: str, """Retrieve the full message history for a conversation.""" raise NotImplementedError() + @abstractmethod + async def get_quiz(self, user_id: str, module_id: str, + conversation_id: str) -> QuizResponse: + """Get quiz questions for the active quiz.""" + raise NotImplementedError() + + @abstractmethod + async def submit_quiz(self, user_id: str, module_id: str, + conversation_id: str, answers: dict[int, str]) -> QuizSubmissionResponse: + """Submit quiz answers for evaluation.""" + raise NotImplementedError() + @abstractmethod async def delete_conversation(self, user_id: str, module_id: str, conversation_id: str) -> None: """Delete a conversation.""" @@ -184,45 +201,6 @@ def _build_conversation_context(messages: list[CareerReadinessMessage]) -> Conve return ConversationContext(all_history=history, history=history) -def _format_quiz_message(quiz: QuizConfig) -> str: - """Format quiz questions into a chat message for the user.""" - lines = [ - "Great work! You've covered all the topics. Now let's check your understanding with a quick quiz.", - "", - "Please answer each question by typing the letter of your answer (e.g., '1.B, 2.A, 3.C, ...').", - "", - ] - for i, q in enumerate(quiz.questions, 1): - lines.append(f"{i}. {q.question}") - for option in q.options: - lines.append(f" {option}") - lines.append("") - return "\n".join(lines) - - -def _parse_quiz_answers(user_input: str) -> dict[int, str]: - """ - Parse quiz answers from user input. - - Supports formats like: "1.B, 2.A, 3.C" or "1. B 2. A 3. C" or "B, A, C" - Returns dict of question_number (1-indexed) -> answer letter (uppercase). - """ - answers: dict[int, str] = {} - # Try numbered format: "1.B" or "1. B" - numbered = re.findall(r"(\d+)\.\s*([A-Da-d])", user_input) - if numbered: - for num_str, letter in numbered: - answers[int(num_str)] = letter.upper() - else: - # Try sequential letters: "B, A, C, D" or "B A C D" - # Only match standalone letters (not embedded in words) and require at least 2 - letters = re.findall(r"\b([A-Da-d])\b", user_input) - if len(letters) >= 2: - for i, letter in enumerate(letters, 1): - answers[i] = letter.upper() - return answers - - def _evaluate_quiz(quiz: QuizConfig, answers: dict[int, str]) -> tuple[int, int, list[bool]]: """ Evaluate quiz answers deterministically. @@ -381,13 +359,9 @@ async def send_message(self, user_id: str, module_id: str, conversation = await self._get_conversation_or_raise(conversation_id) self._validate_access(conversation, user_id, module_id) - # Three-mode dispatch - if conversation.quiz_delivered and not conversation.quiz_passed: - return await self._handle_quiz_submission(module, conversation, user_input) - elif conversation.conversation_mode == ConversationMode.SUPPORT: + if conversation.conversation_mode == ConversationMode.SUPPORT: return await self._handle_support_message(module, conversation, user_input) - else: - return await self._handle_instruction_message(module, conversation, user_input) + return await self._handle_instruction_message(module, conversation, user_input) async def _handle_instruction_message( self, module: ModuleConfig, @@ -431,12 +405,21 @@ async def _handle_instruction_message( response_messages = all_messages + [agent_message] # Check if all topics are covered AND agent says finished → deliver quiz + quiz_available = conversation.quiz_delivered and not conversation.quiz_passed all_topics_covered = set(module.topics).issubset(new_covered) if module.topics else True - if all_topics_covered and agent_output.finished and module.quiz is not None: - quiz_msg = self._build_and_persist_quiz_message(module.quiz) - await self._repository.append_message(conversation.conversation_id, quiz_msg) + if (all_topics_covered and agent_output.finished + and module.quiz is not None and not conversation.quiz_delivered): + marker_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message="Great work! You've covered the key topics. " + "It's time for a short quiz to check your understanding.", + sender=CareerReadinessMessageSender.AGENT, + sent_at=datetime.now(timezone.utc), + ) + await self._repository.append_message(conversation.conversation_id, marker_message) await self._repository.update_quiz_delivered(conversation.conversation_id, True) - response_messages.append(quiz_msg) + response_messages.append(marker_message) + quiz_available = True return CareerReadinessConversationResponse( conversation_id=conversation.conversation_id, @@ -444,71 +427,7 @@ async def _handle_instruction_message( messages=_filter_silence(response_messages), covered_topics=covered_topics_list, conversation_mode=ConversationMode.INSTRUCTION, - ) - - async def _handle_quiz_submission( - self, module: ModuleConfig, - conversation: CareerReadinessConversationDocument, - user_input: str, - ) -> CareerReadinessConversationResponse: - """Evaluate quiz answers deterministically and update state.""" - if module.quiz is None: - raise ValueError(f"Module {module.id} has no quiz but quiz_delivered is True") - - # If the input doesn't contain valid quiz answers, handle as a conversational message. - # TODO: Quiz interaction (re-showing questions, asking about specific questions, navigating - # back to the quiz after asking for help) would be best handled by the frontend with a - # dedicated quiz UI component rather than through chat messages. For now, we fall through - # to instruction mode which loses quiz context. - answers = _parse_quiz_answers(user_input) - if not answers: - return await self._handle_instruction_message(module, conversation, user_input) - - existing_messages = list(conversation.messages) - - # Persist user answer message - now = datetime.now(timezone.utc) - user_message = CareerReadinessMessage( - message_id=str(ObjectId()), - message=user_input, - sender=CareerReadinessMessageSender.USER, - sent_at=now, - ) - await self._repository.append_message(conversation.conversation_id, user_message) - score, total, _ = _evaluate_quiz(module.quiz, answers) - passed = (score / total) >= module.quiz.pass_threshold if total > 0 else False - - if passed: - feedback = (f"You scored {score}/{total}. Congratulations, you passed! " - "You can now continue to ask me any follow-up questions about this topic.") - await self._repository.update_quiz_passed(conversation.conversation_id, True) - await self._repository.update_conversation_mode( - conversation.conversation_id, ConversationMode.SUPPORT) - result_mode = ConversationMode.SUPPORT - else: - threshold_count = math.ceil(module.quiz.pass_threshold * total) - feedback = (f"You scored {score}/{total}. You need at least {threshold_count} " - "correct answers to pass. Let's review the topics and try again.") - await self._repository.update_quiz_delivered(conversation.conversation_id, False) - result_mode = ConversationMode.INSTRUCTION - - feedback_message = CareerReadinessMessage( - message_id=str(ObjectId()), - message=feedback, - sender=CareerReadinessMessageSender.AGENT, - sent_at=datetime.now(timezone.utc), - ) - await self._repository.append_message(conversation.conversation_id, feedback_message) - - response_messages = existing_messages + [user_message, feedback_message] - return CareerReadinessConversationResponse( - conversation_id=conversation.conversation_id, - module_id=conversation.module_id, - messages=_filter_silence(response_messages), - covered_topics=conversation.covered_topics, - quiz_passed=passed, - conversation_mode=result_mode, - module_completed=passed, + quiz_available=quiz_available, ) async def _handle_support_message( @@ -552,16 +471,6 @@ async def _handle_support_message( conversation_mode=ConversationMode.SUPPORT, ) - @staticmethod - def _build_and_persist_quiz_message(quiz: QuizConfig) -> CareerReadinessMessage: - """Format quiz into a CareerReadinessMessage (caller persists it).""" - return CareerReadinessMessage( - message_id=str(ObjectId()), - message=_format_quiz_message(quiz), - sender=CareerReadinessMessageSender.AGENT, - sent_at=datetime.now(timezone.utc), - ) - async def get_conversation_history(self, user_id: str, module_id: str, conversation_id: str) -> CareerReadinessConversationResponse: self._get_module_or_raise(module_id) @@ -575,6 +484,84 @@ async def get_conversation_history(self, user_id: str, module_id: str, covered_topics=conversation.covered_topics, conversation_mode=conversation.conversation_mode, quiz_passed=conversation.quiz_passed if conversation.quiz_delivered else None, + quiz_available=conversation.quiz_delivered and not conversation.quiz_passed, + ) + + async def get_quiz(self, user_id: str, module_id: str, + conversation_id: str) -> QuizResponse: + module = self._get_module_or_raise(module_id) + conversation = await self._get_conversation_or_raise(conversation_id) + self._validate_access(conversation, user_id, module_id) + + if conversation.quiz_passed: + raise QuizAlreadyPassedError(conversation_id) + if not conversation.quiz_delivered or module.quiz is None: + raise QuizNotAvailableError(conversation_id) + + return QuizResponse(questions=[ + QuizQuestionResponse(question=q.question, options=q.options) + for q in module.quiz.questions + ]) + + async def submit_quiz(self, user_id: str, module_id: str, + conversation_id: str, answers: dict[int, str]) -> QuizSubmissionResponse: + module = self._get_module_or_raise(module_id) + conversation = await self._get_conversation_or_raise(conversation_id) + self._validate_access(conversation, user_id, module_id) + + if not conversation.quiz_delivered or module.quiz is None: + raise QuizNotAvailableError(conversation_id) + if conversation.quiz_passed: + raise QuizAlreadyPassedError(conversation_id) + + # Persist user answer message for audit trail + now = datetime.now(timezone.utc) + answer_text = "Quiz answers: " + ", ".join(f"{k}.{v}" for k, v in sorted(answers.items())) + user_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message=answer_text, + sender=CareerReadinessMessageSender.USER, + sent_at=now, + ) + await self._repository.append_message(conversation.conversation_id, user_message) + + score, total, results = _evaluate_quiz(module.quiz, answers) + passed = (score / total) >= module.quiz.pass_threshold if total > 0 else False + + question_results = [ + QuizQuestionResult(question_index=i + 1, is_correct=correct) + for i, correct in enumerate(results) + ] + + if passed: + feedback = (f"You scored {score}/{total}. Congratulations, you passed! " + "You can now continue to ask me any follow-up questions about this topic.") + await self._repository.update_quiz_passed(conversation.conversation_id, True) + await self._repository.update_conversation_mode( + conversation.conversation_id, ConversationMode.SUPPORT) + result_mode = ConversationMode.SUPPORT + else: + threshold_count = math.ceil(module.quiz.pass_threshold * total) + feedback = (f"You scored {score}/{total}. You need at least {threshold_count} " + "correct answers to pass. Let's review the topics and try again.") + await self._repository.update_quiz_delivered(conversation.conversation_id, False) + result_mode = ConversationMode.INSTRUCTION + + feedback_message = CareerReadinessMessage( + message_id=str(ObjectId()), + message=feedback, + sender=CareerReadinessMessageSender.AGENT, + sent_at=datetime.now(timezone.utc), + ) + await self._repository.append_message(conversation.conversation_id, feedback_message) + + return QuizSubmissionResponse( + score=score, + total=total, + passed=passed, + question_results=question_results, + module_completed=passed, + conversation_mode=result_mode, ) async def delete_conversation(self, user_id: str, module_id: str, conversation_id: str) -> None: diff --git a/backend/app/career_readiness/test_routes.py b/backend/app/career_readiness/test_routes.py index 618eb088..959af3c6 100644 --- a/backend/app/career_readiness/test_routes.py +++ b/backend/app/career_readiness/test_routes.py @@ -16,6 +16,8 @@ ConversationAlreadyExistsError, ConversationNotFoundError, CareerReadinessModuleNotFoundError, + QuizAlreadyPassedError, + QuizNotAvailableError, ) from app.career_readiness.routes import add_career_readiness_routes, get_career_readiness_service from app.career_readiness.service import ICareerReadinessService @@ -23,10 +25,15 @@ CareerReadinessConversationResponse, CareerReadinessMessage, CareerReadinessMessageSender, + ConversationMode, ModuleDetail, ModuleListResponse, ModuleStatus, ModuleSummary, + QuizQuestionResponse, + QuizQuestionResult, + QuizResponse, + QuizSubmissionResponse, ) from app.conversations.constants import MAX_MESSAGE_LENGTH from app.users.auth import UserInfo @@ -101,6 +108,21 @@ async def get_conversation_history(self, user_id: str, module_id: str, conversation_id: str) -> CareerReadinessConversationResponse: return _make_conversation_response(conversation_id=conversation_id, module_id=module_id) + async def get_quiz(self, user_id: str, module_id: str, + conversation_id: str) -> QuizResponse: + return QuizResponse(questions=[ + QuizQuestionResponse(question="Q1?", options=["A. Opt1", "B. Opt2"]), + ]) + + async def submit_quiz(self, user_id: str, module_id: str, + conversation_id: str, answers: dict[int, str]) -> QuizSubmissionResponse: + return QuizSubmissionResponse( + score=1, total=1, passed=True, + question_results=[QuizQuestionResult(question_index=1, is_correct=True)], + module_completed=True, + conversation_mode=ConversationMode.SUPPORT, + ) + async def delete_conversation(self, user_id: str, module_id: str, conversation_id: str) -> None: return None @@ -326,3 +348,125 @@ def test_returns_403_when_access_denied(self, client_with_mocks: TestClientWithM # THEN 403 FORBIDDEN is returned assert response.status_code == HTTPStatus.FORBIDDEN + + +class TestGetQuiz: + """Tests for GET /career-readiness/modules/{module_id}/conversations/{conversation_id}/quiz.""" + + def test_returns_200_with_questions_and_no_correct_answer(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN the quiz is requested + response = client.get( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + ) + + # THEN 200 OK is returned with quiz questions + assert response.status_code == HTTPStatus.OK + body = response.json() + assert len(body["questions"]) == 1 + assert body["questions"][0]["question"] == "Q1?" + # AND correct_answer is not present in the response + assert "correct_answer" not in body["questions"][0] + + def test_returns_403_when_access_denied(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationAccessDeniedError + mocker.patch.object(mock_service, "get_quiz", + side_effect=ConversationAccessDeniedError("conv123", "other_user")) + + # WHEN the quiz is requested + response = client.get( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + ) + + # THEN 403 FORBIDDEN is returned + assert response.status_code == HTTPStatus.FORBIDDEN + + def test_returns_409_when_not_available(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises QuizNotAvailableError + mocker.patch.object(mock_service, "get_quiz", + side_effect=QuizNotAvailableError("conv123")) + + # WHEN the quiz is requested + response = client.get( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + ) + + # THEN 409 CONFLICT is returned + assert response.status_code == HTTPStatus.CONFLICT + + +class TestSubmitQuiz: + """Tests for POST /career-readiness/modules/{module_id}/conversations/{conversation_id}/quiz.""" + + def test_returns_200_on_success(self, client_with_mocks: TestClientWithMocks): + client, _, _ = client_with_mocks + + # WHEN quiz answers are submitted + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + json={"answers": {"1": "A"}}, + ) + + # THEN 200 OK is returned with results + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["passed"] is True + assert body["score"] == 1 + + def test_returns_403_when_access_denied(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises ConversationAccessDeniedError + mocker.patch.object(mock_service, "submit_quiz", + side_effect=ConversationAccessDeniedError("conv123", "other_user")) + + # WHEN quiz answers are submitted + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + json={"answers": {"1": "A"}}, + ) + + # THEN 403 FORBIDDEN is returned + assert response.status_code == HTTPStatus.FORBIDDEN + + def test_returns_409_when_not_available(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises QuizNotAvailableError + mocker.patch.object(mock_service, "submit_quiz", + side_effect=QuizNotAvailableError("conv123")) + + # WHEN quiz answers are submitted + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + json={"answers": {"1": "A"}}, + ) + + # THEN 409 CONFLICT is returned + assert response.status_code == HTTPStatus.CONFLICT + + def test_returns_409_when_already_passed(self, client_with_mocks: TestClientWithMocks, + mocker: pytest_mock.MockerFixture): + client, mock_service, _ = client_with_mocks + + # GIVEN the service raises QuizAlreadyPassedError + mocker.patch.object(mock_service, "submit_quiz", + side_effect=QuizAlreadyPassedError("conv123")) + + # WHEN quiz answers are submitted + response = client.post( + "/career-readiness/modules/cv-development/conversations/conv123/quiz", + json={"answers": {"1": "A"}}, + ) + + # THEN 409 CONFLICT is returned + assert response.status_code == HTTPStatus.CONFLICT diff --git a/backend/app/career_readiness/test_service.py b/backend/app/career_readiness/test_service.py index 43f7f69d..bb5caa1b 100644 --- a/backend/app/career_readiness/test_service.py +++ b/backend/app/career_readiness/test_service.py @@ -14,6 +14,8 @@ ConversationNotFoundError, CareerReadinessModuleNotFoundError, ModuleNotUnlockedError, + QuizAlreadyPassedError, + QuizNotAvailableError, ) from app.career_readiness.module_loader import ModuleConfig, ModuleRegistry, QuizConfig, QuizQuestion from app.career_readiness.repository import ICareerReadinessConversationRepository @@ -21,8 +23,6 @@ CareerReadinessService, _build_conversation_context, _derive_module_statuses, - _format_quiz_message, - _parse_quiz_answers, _evaluate_quiz, ) from app.career_readiness.types import ( @@ -321,46 +321,6 @@ def test_in_progress_module_blocks_next(self): assert actual_statuses["m2"] == ModuleStatus.NOT_STARTED -class TestFormatQuizMessage: - """Tests for the quiz message formatter.""" - - def test_formats_quiz_with_numbered_questions(self): - # GIVEN a quiz config - given_quiz = _make_quiz_config() - - # WHEN the quiz message is formatted - actual_message = _format_quiz_message(given_quiz) - - # THEN it contains numbered questions and options - assert "1. Q1?" in actual_message - assert "2. Q2?" in actual_message - assert "A. Opt1" in actual_message - - -class TestParseQuizAnswers: - """Tests for the quiz answer parser.""" - - def test_parses_numbered_format(self): - # GIVEN answers in "1.B, 2.A" format - given_input = "1.B, 2.A, 3.C" - - # WHEN parsed - actual_answers = _parse_quiz_answers(given_input) - - # THEN correct answers are extracted - assert actual_answers == {1: "B", 2: "A", 3: "C"} - - def test_parses_sequential_letters(self): - # GIVEN answers as sequential letters - given_input = "B, A, C" - - # WHEN parsed - actual_answers = _parse_quiz_answers(given_input) - - # THEN answers are numbered sequentially - assert actual_answers == {1: "B", 2: "A", 3: "C"} - - class TestEvaluateQuiz: """Tests for the quiz evaluator.""" @@ -383,10 +343,25 @@ def test_partial_correct(self): given_answers = {1: "A", 2: "C"} # WHEN evaluated - actual_score, _, _ = _evaluate_quiz(given_quiz, given_answers) + actual_score, actual_total, actual_results = _evaluate_quiz(given_quiz, given_answers) - # THEN score is 1 + # THEN score is 1 out of 2 assert actual_score == 1 + assert actual_total == 2 + assert actual_results == [True, False] + + def test_missing_answers_counted_as_wrong(self): + # GIVEN a quiz with only one answer provided (question 2 missing) + given_quiz = _make_quiz_config() + given_answers = {1: "A"} + + # WHEN evaluated + actual_score, actual_total, actual_results = _evaluate_quiz(given_quiz, given_answers) + + # THEN the missing answer is counted as wrong + assert actual_score == 1 + assert actual_total == 2 + assert actual_results == [True, False] # --------------------------------------------------------------------------- @@ -619,7 +594,9 @@ async def test_instruction_mode_triggers_quiz_when_all_topics_covered_and_finish "user_abc", "cv-development", given_conversation.conversation_id, "I understand now", ) - # THEN the quiz is delivered (last message contains quiz content) + # THEN quiz_available is True in the response + assert actual_result.quiz_available is True + # AND the last message is a marker indicating quiz is ready assert "quiz" in actual_result.messages[-1].message.lower() # AND quiz_delivered is set on the conversation actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) @@ -627,59 +604,8 @@ async def test_instruction_mode_triggers_quiz_when_all_topics_covered_and_finish assert actual_conv.quiz_delivered is True @pytest.mark.asyncio - async def test_quiz_submission_pass(self): - # GIVEN a conversation with quiz delivered (2 questions, threshold 0.5) - given_module = _make_module_config() - service, repo = _make_service(modules=[given_module]) - given_conversation = _make_conversation( - user_id="user_abc", module_id="cv-development", - quiz_delivered=True, - ) - await repo.create(given_conversation) - - # WHEN the user submits correct answers - actual_result = await service.send_message( - "user_abc", "cv-development", given_conversation.conversation_id, "1.A, 2.B", - ) - - # THEN the quiz is passed - assert actual_result.quiz_passed is True - assert actual_result.module_completed is True - assert actual_result.conversation_mode == ConversationMode.SUPPORT - # AND the conversation state is updated - actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) - assert actual_conv is not None - assert actual_conv.quiz_passed is True - assert actual_conv.conversation_mode == ConversationMode.SUPPORT - - @pytest.mark.asyncio - async def test_quiz_submission_fail(self): - # GIVEN a conversation with quiz delivered (2 questions, threshold 0.5) - given_module = _make_module_config() - service, repo = _make_service(modules=[given_module]) - given_conversation = _make_conversation( - user_id="user_abc", module_id="cv-development", - quiz_delivered=True, - ) - await repo.create(given_conversation) - - # WHEN the user submits all wrong answers - actual_result = await service.send_message( - "user_abc", "cv-development", given_conversation.conversation_id, "1.C, 2.C", - ) - - # THEN the quiz is not passed - assert actual_result.quiz_passed is False - assert actual_result.module_completed is False - assert actual_result.conversation_mode == ConversationMode.INSTRUCTION - # AND quiz_delivered is reset for retry - actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) - assert actual_conv is not None - assert actual_conv.quiz_delivered is False - - @pytest.mark.asyncio - async def test_quiz_non_answer_falls_through_to_instruction(self): - # GIVEN a conversation with quiz delivered + async def test_chat_during_active_quiz_sets_quiz_available(self): + # GIVEN a conversation with quiz delivered but not passed given_module = _make_module_config() given_agent = MockCareerReadinessAgent(response_message="Let me help you with that.") service, repo = _make_service(modules=[given_module], agent=given_agent) @@ -689,17 +615,15 @@ async def test_quiz_non_answer_falls_through_to_instruction(self): ) await repo.create(given_conversation) - # WHEN the user sends a non-answer message + # WHEN the user sends a chat message while quiz is active actual_result = await service.send_message( - "user_abc", "cv-development", given_conversation.conversation_id, "mmm what is this?", + "user_abc", "cv-development", given_conversation.conversation_id, "Can you explain more?", ) - # THEN the message is handled by the instruction agent instead of being scored + # THEN the response includes quiz_available=True + assert actual_result.quiz_available is True + # AND the agent still responds normally assert actual_result.messages[-1].message == "Let me help you with that." - # AND quiz_delivered is NOT reset - actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) - assert actual_conv is not None - assert actual_conv.quiz_delivered is True @pytest.mark.asyncio async def test_support_mode_message(self): @@ -804,6 +728,36 @@ async def test_returns_quiz_passed_false_when_quiz_delivered_but_not_passed(self # THEN quiz_passed is False (not None) assert actual_result.quiz_passed is False + # AND quiz_available is True + assert actual_result.quiz_available is True + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "quiz_delivered, quiz_passed, expected_quiz_available", + [ + (True, False, True), + (False, False, False), + (True, True, False), + ], + ids=["delivered-not-passed", "not-delivered", "already-passed"], + ) + async def test_quiz_available_reflects_quiz_state(self, quiz_delivered, quiz_passed, expected_quiz_available): + # GIVEN a conversation with given quiz state + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=quiz_delivered, quiz_passed=quiz_passed, + ) + await repo.create(given_conversation) + + # WHEN the history is requested + actual_result = await service.get_conversation_history( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + # THEN quiz_available matches the expected value + assert actual_result.quiz_available is expected_quiz_available @pytest.mark.asyncio async def test_raises_access_denied_for_wrong_user(self): @@ -864,3 +818,152 @@ async def test_raises_access_denied_for_wrong_user(self): await service.delete_conversation( "other_user", "cv-development", given_conversation.conversation_id, ) + + +class TestGetQuiz: + """Tests for retrieving quiz questions.""" + + @pytest.mark.asyncio + async def test_returns_questions_without_correct_answers(self): + # GIVEN a conversation with quiz delivered + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, + ) + await repo.create(given_conversation) + + # WHEN the quiz is requested + actual_result = await service.get_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + # THEN questions are returned with options + assert len(actual_result.questions) == 2 + assert actual_result.questions[0].question == "Q1?" + assert actual_result.questions[0].options == ["A. Opt1", "B. Opt2", "C. Opt3", "D. Opt4"] + # AND no correct_answer field is exposed + assert not hasattr(actual_result.questions[0], "correct_answer") + + @pytest.mark.asyncio + async def test_raises_not_available_when_quiz_not_delivered(self): + # GIVEN a conversation where the quiz has not been delivered + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=False, quiz_passed=False, + ) + await repo.create(given_conversation) + + # WHEN the quiz is requested + # THEN QuizNotAvailableError is raised + with pytest.raises(QuizNotAvailableError): + await service.get_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + @pytest.mark.asyncio + async def test_raises_already_passed_when_quiz_completed(self): + # GIVEN a conversation where the quiz was already passed + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, quiz_passed=True, + ) + await repo.create(given_conversation) + + # WHEN the quiz is requested + # THEN QuizAlreadyPassedError is raised + with pytest.raises(QuizAlreadyPassedError): + await service.get_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, + ) + + +class TestSubmitQuiz: + """Tests for submitting quiz answers.""" + + @pytest.mark.asyncio + async def test_pass_transitions_to_support(self): + # GIVEN a conversation with quiz delivered (2 questions, threshold 0.5) + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, + ) + await repo.create(given_conversation) + + # WHEN correct answers are submitted + actual_result = await service.submit_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, {1: "A", 2: "B"}, + ) + + # THEN the quiz is passed + assert actual_result.passed is True + assert actual_result.score == 2 + assert actual_result.total == 2 + assert actual_result.module_completed is True + assert actual_result.conversation_mode == ConversationMode.SUPPORT + # AND per-question results are correct + assert all(r.is_correct for r in actual_result.question_results) + # AND the conversation state is updated + actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_conv is not None + assert actual_conv.quiz_passed is True + assert actual_conv.conversation_mode == ConversationMode.SUPPORT + + @pytest.mark.asyncio + async def test_fail_resets_quiz_for_retry(self): + # GIVEN a conversation with quiz delivered (2 questions, threshold 0.5) + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=True, + ) + await repo.create(given_conversation) + + # WHEN all wrong answers are submitted + actual_result = await service.submit_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, {1: "C", 2: "C"}, + ) + + # THEN the quiz is not passed + assert actual_result.passed is False + assert actual_result.score == 0 + assert actual_result.module_completed is False + assert actual_result.conversation_mode == ConversationMode.INSTRUCTION + # AND quiz_delivered is reset for retry + actual_conv = await repo.find_by_conversation_id(given_conversation.conversation_id) + assert actual_conv is not None + assert actual_conv.quiz_delivered is False + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "quiz_delivered, quiz_passed, expected_error", + [ + (False, False, QuizNotAvailableError), + (True, True, QuizAlreadyPassedError), + ], + ids=["not-delivered", "already-passed"], + ) + async def test_raises_not_available_when_quiz_not_active(self, quiz_delivered, quiz_passed, expected_error): + # GIVEN a conversation where the quiz is not active + given_module = _make_module_config() + service, repo = _make_service(modules=[given_module]) + given_conversation = _make_conversation( + user_id="user_abc", module_id="cv-development", + quiz_delivered=quiz_delivered, quiz_passed=quiz_passed, + ) + await repo.create(given_conversation) + + # WHEN answers are submitted + # THEN the expected error is raised + with pytest.raises(expected_error): + await service.submit_quiz( + "user_abc", "cv-development", given_conversation.conversation_id, {1: "A", 2: "B"}, + ) diff --git a/backend/app/career_readiness/types.py b/backend/app/career_readiness/types.py index f8b6ea95..8a75faeb 100644 --- a/backend/app/career_readiness/types.py +++ b/backend/app/career_readiness/types.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from enum import Enum -from pydantic import BaseModel, Field, field_serializer, field_validator +from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator class ModuleStatus(str, Enum): @@ -164,6 +164,80 @@ class CareerReadinessConversationResponse(BaseModel): conversation_mode: ConversationMode | None = None """The current conversation mode (INSTRUCTION or SUPPORT).""" + quiz_available: bool = False + """Whether the quiz is available for this conversation (quiz delivered but not yet passed).""" + + class Config: + extra = "forbid" + + +class QuizQuestionResponse(BaseModel): + """A quiz question for the frontend (excludes correct_answer).""" + + question: str + options: list[str] + + class Config: + extra = "forbid" + + +class QuizResponse(BaseModel): + """Response for GET .../quiz — the quiz questions.""" + + questions: list[QuizQuestionResponse] + + class Config: + extra = "forbid" + + +class QuizSubmissionInput(BaseModel): + """Input for POST .../quiz — structured quiz answers.""" + + answers: dict[int, str] = Field( + json_schema_extra={ + "example": {"1": "B", "2": "A", "3": "C"}, + }, + ) + """question_number (1-indexed) → answer letter (A-D)""" + + class Config: + extra = "forbid" + + @model_validator(mode="after") + def _validate_answers(self) -> "QuizSubmissionInput": + valid_letters = {"A", "B", "C", "D"} + normalized: dict[int, str] = {} + for key, value in self.answers.items(): + upper = value.upper() + if upper not in valid_letters: + raise ValueError(f"Invalid answer '{value}' for question {key}. Must be A-D.") + normalized[key] = upper + self.answers = normalized + return self + + +class QuizQuestionResult(BaseModel): + """Per-question result (no correct answer exposed).""" + + question_index: int + """1-indexed question number""" + + is_correct: bool + + class Config: + extra = "forbid" + + +class QuizSubmissionResponse(BaseModel): + """Response for POST .../quiz — evaluation results.""" + + score: int + total: int + passed: bool + question_results: list[QuizQuestionResult] + module_completed: bool + conversation_mode: ConversationMode + class Config: extra = "forbid" From 541312e011968d105d9ae35f6439c2d177df2985 Mon Sep 17 00:00:00 2001 From: Bereket Terefe Date: Thu, 5 Mar 2026 09:50:34 +0300 Subject: [PATCH 25/42] feat(frontend|backend): Add career exploration module powered by llm agent and RAG - RAG knowledge base for career explorer agent - implement context building and memory state update for conversation history - add Career Explorer components and services for chat functionality - add basic Career Explorer agent executors and scripted user tests - add markitdown conversion for non .md files during injestion --- backend/.env.example | 4 + backend/app/agent/agent_types.py | 1 + .../agent/career_explorer_agent/__init__.py | 4 + .../app/agent/career_explorer_agent/agent.py | 160 ++++++++++++ .../sector_search_service.py | 89 +++++++ backend/app/career_explorer/__init__.py | 3 + .../app/career_explorer/context_builder.py | 105 ++++++++ backend/app/career_explorer/repository.py | 78 ++++++ backend/app/career_explorer/routes.py | 126 ++++++++++ backend/app/career_explorer/service.py | 118 +++++++++ backend/app/career_explorer/types.py | 87 +++++++ backend/app/i18n/i18n_manager.py | 1 - backend/app/server.py | 9 + .../database_collections.py | 2 + .../server_dependencies/db_dependencies.py | 38 +++ backend/app/test_server.py | 4 + .../environment_settings/mongo_db_settings.py | 10 + .../test_utilities/setup_env_vars.py | 2 + backend/conftest.py | 9 + .../career_explorer_agent_executors.py | 127 ++++++++++ ...areer_explorer_agent_scripted_user_test.py | 143 +++++++++++ backend/features/test_loader.py | 3 + backend/scripts/ingest_sector_document.py | 236 ++++++++++++++++++ frontend-new/src/app/index.tsx | 12 + frontend-new/src/app/routerPaths.ts | 1 + .../CareerExplorerAgentMessage.stories.tsx | 21 ++ .../CareerExplorerAgentMessage.tsx | 57 +++++ .../CareerExplorerChat.stories.tsx | 72 ++++++ .../CareerExplorerChat/CareerExplorerChat.tsx | 100 ++++++++ .../CareerExplorerTypingMessage.tsx | 73 ++++++ .../CareerExplorerPage.stories.tsx | 74 ++++++ .../CareerExplorerPage/CareerExplorerPage.tsx | 89 +++++++ .../services/CareerExplorerService.ts | 95 +++++++ frontend-new/src/careerExplorer/types.ts | 17 ++ ...apCareerExplorerMessagesToChatMessages.tsx | 34 +++ .../ChatMessageField/ChatMessageField.tsx | 10 +- frontend-new/src/home/modulesService.ts | 2 +- .../src/i18n/locales/en-GB/translation.json | 9 +- .../src/i18n/locales/en-US/translation.json | 9 +- .../src/i18n/locales/es-AR/translation.json | 9 +- .../src/i18n/locales/es-ES/translation.json | 9 +- .../src/i18n/locales/ny-ZM/translation.json | 9 +- .../src/i18n/locales/sw-KE/translation.json | 9 +- 43 files changed, 2052 insertions(+), 18 deletions(-) create mode 100644 backend/app/agent/career_explorer_agent/__init__.py create mode 100644 backend/app/agent/career_explorer_agent/agent.py create mode 100644 backend/app/agent/career_explorer_agent/sector_search_service.py create mode 100644 backend/app/career_explorer/__init__.py create mode 100644 backend/app/career_explorer/context_builder.py create mode 100644 backend/app/career_explorer/repository.py create mode 100644 backend/app/career_explorer/routes.py create mode 100644 backend/app/career_explorer/service.py create mode 100644 backend/app/career_explorer/types.py create mode 100644 backend/evaluation_tests/career_explorer_agent/career_explorer_agent_executors.py create mode 100644 backend/evaluation_tests/career_explorer_agent/career_explorer_agent_scripted_user_test.py create mode 100644 backend/scripts/ingest_sector_document.py create mode 100644 frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.stories.tsx create mode 100644 frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.tsx create mode 100644 frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.stories.tsx create mode 100644 frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.tsx create mode 100644 frontend-new/src/careerExplorer/components/CareerExplorerTypingMessage/CareerExplorerTypingMessage.tsx create mode 100644 frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.stories.tsx create mode 100644 frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.tsx create mode 100644 frontend-new/src/careerExplorer/services/CareerExplorerService.ts create mode 100644 frontend-new/src/careerExplorer/types.ts create mode 100644 frontend-new/src/careerExplorer/utils/mapCareerExplorerMessagesToChatMessages.tsx diff --git a/backend/.env.example b/backend/.env.example index c1eb0b48..da342a96 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,6 +16,10 @@ BACKEND_ENABLE_METRICS=False USERDATA_MONGODB_URI=mongodb+srv://:@/?retryWrites=true&w=majority&appName=Compass-Dev USERDATA_DATABASE_NAME=compass-dev +# Career Explorer database settings (conversations + sector chunks) +CAREER_EXPLORER_MONGODB_URI=mongodb+srv://:@/?retryWrites=true&w=majority&appName=Compass-Dev +CAREER_EXPLORER_DATABASE_NAME=compass-career-explorer + # Google Cloud Platform settings GOOGLE_APPLICATION_CREDENTIALS=keys/credentials.json VERTEX_API_REGION=us-central1 diff --git a/backend/app/agent/agent_types.py b/backend/app/agent/agent_types.py index 9340f3b8..e51df26b 100644 --- a/backend/app/agent/agent_types.py +++ b/backend/app/agent/agent_types.py @@ -20,6 +20,7 @@ class AgentType(Enum): FAREWELL_AGENT = "FarewellAgent" QNA_AGENT = "QnaAgent" CAREER_READINESS_AGENT = "CareerReadinessAgent" + CAREER_EXPLORER_AGENT = "CareerExplorerAgent" class AgentInput(BaseModel): diff --git a/backend/app/agent/career_explorer_agent/__init__.py b/backend/app/agent/career_explorer_agent/__init__.py new file mode 100644 index 00000000..5c987ba3 --- /dev/null +++ b/backend/app/agent/career_explorer_agent/__init__.py @@ -0,0 +1,4 @@ +from .agent import CareerExplorerAgent +from .sector_search_service import SectorChunkEntity, SectorSearchService + +__all__ = ["CareerExplorerAgent", "SectorChunkEntity", "SectorSearchService"] diff --git a/backend/app/agent/career_explorer_agent/agent.py b/backend/app/agent/career_explorer_agent/agent.py new file mode 100644 index 00000000..87b3e2b6 --- /dev/null +++ b/backend/app/agent/career_explorer_agent/agent.py @@ -0,0 +1,160 @@ +import logging +import time +from textwrap import dedent + +from app.agent.agent import Agent +from app.agent.agent_types import AgentInput, AgentOutput, AgentType, LLMStats, AgentOutputWithReasoning +from app.agent.llm_caller import LLMCaller +from app.agent.prompt_template.locale_style import get_language_style +from app.agent.prompt_template.agent_prompt_template import STD_AGENT_CHARACTER +from app.agent.simple_llm_agent.llm_response import ModelResponse +from app.agent.simple_llm_agent.prompt_response_template import get_json_response_instructions, get_conversation_finish_instructions +from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter +from app.conversation_memory.conversation_memory_manager import ConversationContext +from common_libs.llm.generative_models import GeminiGenerativeLLM +from common_libs.llm.models_utils import LLMConfig, LOW_TEMPERATURE_GENERATION_CONFIG, JSON_GENERATION_CONFIG +from common_libs.llm.schema_builder import with_response_schema + +from .sector_search_service import SectorChunkEntity, SectorSearchService + + +WELCOME_MESSAGE = """Welcome to the Career Explorer! Based on Zambia's development priorities, there are five key sectors where TEVET graduates are in high demand: + +Energy — Power generation, solar, renewables +Mining — Copper, gold, gemstones +Agriculture — Commercial farming, agriprocessing +Hospitality — Hotels, tourism, safari lodges +Water — Treatment, supply, sanitation + +Which sector interests you most?""" + + +def _build_base_instructions() -> str: + language_style = get_language_style(with_locale=True) + finish_instructions = get_conversation_finish_instructions( + "When the user explicitly indicates they are done or want to exit" + ) + return dedent(f"""\ + + # Role + You are a career exploration counselor helping TEVET graduates in Zambia discover career opportunities + in priority sectors. You start by suggesting topics and wait for the user to choose or ask questions. + + {language_style} + + {STD_AGENT_CHARACTER} + + # Instructions + - Start by suggesting the five priority sectors and ask which interests the user most + - Answer questions based ONLY on the retrieved content provided below + - Stay on topic: focus on the five sectors (Energy, Mining, Agriculture, Hospitality, Water) + - If asked about something outside this scope, politely redirect to the priority sectors + - Be encouraging and conversational + - Do not invent information not in the provided content + + # Retrieved Content + Use the following content to answer user questions. Cite specifics when relevant. + + {{retrieved_content}} + + {finish_instructions} + + """) + + +def _format_chunks(chunks: list[SectorChunkEntity]) -> str: + if not chunks: + return "(No relevant content found. Suggest the user pick a sector or ask a question related to the five priority sectors.)" + parts = [] + for c in chunks: + parts.append(f"[{c.sector}]\n{c.text}") + return "\n\n---\n\n".join(parts) + + +def _construct_output( + message: str, + finished: bool, + agent_start: float, + reasoning: str = "", + llm_stats: list[LLMStats] | None = None, +) -> AgentOutput: + return AgentOutputWithReasoning( + message_for_user=message, + finished=finished, + reasoning=reasoning, + agent_type=AgentType.CAREER_EXPLORER_AGENT, + agent_response_time_in_sec=round(time.time() - agent_start, 2), + llm_stats=llm_stats or [], + ) + + +class CareerExplorerAgent(Agent): + def __init__(self, sector_search_service: SectorSearchService): + super().__init__( + agent_type=AgentType.CAREER_EXPLORER_AGENT, + is_responsible_for_conversation_history=False, + ) + self._sector_search = sector_search_service + self._base_instructions_template = _build_base_instructions() + self._llm_config = LLMConfig( + generation_config=LOW_TEMPERATURE_GENERATION_CONFIG + | JSON_GENERATION_CONFIG + | with_response_schema(ModelResponse) + ) + self._llm_caller = LLMCaller[ModelResponse](model_response_type=ModelResponse) + self._logger = logging.getLogger(self.__class__.__name__) + + async def execute(self, user_input: AgentInput, context: ConversationContext) -> AgentOutput: + agent_start = time.time() + + msg = (user_input.message or "").strip() + if msg in ("", "(silence)"): + return _construct_output( + WELCOME_MESSAGE, + finished=False, + agent_start=agent_start, + ) + + chunks = await self._sector_search.search(query=msg, k=5) + self._logger.info( + "RAG search for query '%s': found %d chunks", + msg, + len(chunks), + ) + for i, chunk in enumerate(chunks): + preview = chunk.text[:200] + "..." if len(chunk.text) > 200 else chunk.text + self._logger.info( + " Chunk %d [%s] (score=%.4f): %s", + i + 1, + chunk.sector, + chunk.score, + preview.replace("\n", " "), + ) + retrieved = _format_chunks(chunks) + full_instructions = self._base_instructions_template.format(retrieved_content=retrieved) + + llm = GeminiGenerativeLLM(system_instructions=full_instructions, config=self._llm_config) + model_response, llm_stats = await self._llm_caller.call_llm( + llm=llm, + llm_input=ConversationHistoryFormatter.format_for_agent_generative_prompt( + model_response_instructions=get_json_response_instructions(), + context=context, + user_input=msg, + ), + logger=self._logger, + ) + + if model_response is None: + model_response = ModelResponse( + reasoning="Error", + message="I'm having trouble right now. Could you try again?", + finished=False, + ) + + return _construct_output( + model_response.message.strip('"'), + finished=model_response.finished, + agent_start=agent_start, + reasoning=model_response.reasoning, + llm_stats=llm_stats, + ) diff --git a/backend/app/agent/career_explorer_agent/sector_search_service.py b/backend/app/agent/career_explorer_agent/sector_search_service.py new file mode 100644 index 00000000..78f32e0f --- /dev/null +++ b/backend/app/agent/career_explorer_agent/sector_search_service.py @@ -0,0 +1,89 @@ +import logging +import time +from typing import Optional + +from motor.motor_asyncio import AsyncIOMotorCollection +from pydantic import BaseModel + +from app.vector_search.embeddings_model import EmbeddingService +from app.vector_search.similarity_search_service import FilterSpec, SimilaritySearchService + + +class SectorChunkEntity(BaseModel): + chunk_id: str + sector: str + text: str + metadata: dict = {} + score: float = 0.0 + + +class SectorSearchService(SimilaritySearchService[SectorChunkEntity]): + def __init__( + self, + collection: AsyncIOMotorCollection, + embedding_service: EmbeddingService, + embedding_key: str = "embedding", + index_name: str = "sector_chunks_embedding_index", + ): + self._collection = collection + self._embedding_service = embedding_service + self._embedding_key = embedding_key + self._index_name = index_name + self._logger = logging.getLogger(self.__class__.__name__) + + async def search( + self, + *, + query: str | list[float], + filter_spec: FilterSpec | None = None, + k: int = 5, + sector: Optional[str] = None, + ) -> list[SectorChunkEntity]: + if isinstance(query, str): + q = query.strip() + if not q: + self._logger.warning("Empty text query; returning no results") + return [] + embedding = await self._embedding_service.embed(q) + else: + embedding = query + + search_start = time.time() + filter_dict: dict = {} + if sector: + filter_dict["sector"] = sector + if filter_spec: + filter_dict.update(filter_spec.to_query_filter()) + + params = { + "queryVector": embedding, + "path": self._embedding_key, + "numCandidates": k * 10, + "limit": k, + "index": self._index_name, + "filter": filter_dict, + } + + pipeline = [ + {"$vectorSearch": params}, + {"$set": {"score": {"$meta": "vectorSearchScore"}}}, + {"$sort": {"score": -1}}, + {"$limit": k}, + ] + + results = await self._collection.aggregate(pipeline).to_list(length=k) + self._logger.debug( + "Sector search took %.2fs, found %d chunks", + time.time() - search_start, + len(results), + ) + return [self._to_entity(doc) for doc in results] + + def _to_entity(self, doc: dict) -> SectorChunkEntity: + return SectorChunkEntity( + chunk_id=doc.get("chunk_id", ""), + sector=doc.get("sector", ""), + text=doc.get("text", ""), + metadata=doc.get("metadata", {}), + score=doc.get("score", 0.0), + ) diff --git a/backend/app/career_explorer/__init__.py b/backend/app/career_explorer/__init__.py new file mode 100644 index 00000000..09e0b644 --- /dev/null +++ b/backend/app/career_explorer/__init__.py @@ -0,0 +1,3 @@ +from .routes import add_career_explorer_routes + +__all__ = ["add_career_explorer_routes"] diff --git a/backend/app/career_explorer/context_builder.py b/backend/app/career_explorer/context_builder.py new file mode 100644 index 00000000..0c6e5110 --- /dev/null +++ b/backend/app/career_explorer/context_builder.py @@ -0,0 +1,105 @@ +from app.agent.agent_types import AgentInput, AgentOutput +from app.career_explorer.types import CareerExplorerMessage, CareerExplorerMessageSender +from app.conversation_memory.conversation_memory_types import ( + ConversationContext, + ConversationHistory, + ConversationTurn, +) +from app.conversation_memory.summarizer import Summarizer +from app.server_config import UNSUMMARIZED_WINDOW_SIZE, TO_BE_SUMMARIZED_WINDOW_SIZE + + +def _messages_to_turns(messages: list[CareerExplorerMessage]) -> list[ConversationTurn]: + turns: list[ConversationTurn] = [] + idx = 0 + i = 0 + while i < len(messages): + msg = messages[i] + if msg.sender == CareerExplorerMessageSender.AGENT and ( + i + 1 >= len(messages) or messages[i + 1].sender != CareerExplorerMessageSender.USER + ): + i += 1 + continue + if ( + msg.sender == CareerExplorerMessageSender.USER + and i + 1 < len(messages) + and messages[i + 1].sender == CareerExplorerMessageSender.AGENT + ): + agent_msg = messages[i + 1] + turns.append( + ConversationTurn( + index=idx, + input=AgentInput( + message_id=msg.message_id, + message=msg.message, + sent_at=msg.sent_at, + ), + output=AgentOutput( + message_id=agent_msg.message_id, + message_for_user=agent_msg.message, + finished=False, + agent_response_time_in_sec=0, + llm_stats=[], + sent_at=agent_msg.sent_at, + ), + ) + ) + idx += 1 + i += 2 + else: + i += 1 + return turns + + +async def build_windowed_context( + messages: list[CareerExplorerMessage], + summary: str = "", + num_turns_summarized: int = 0, + unsummarized_window_size: int = UNSUMMARIZED_WINDOW_SIZE, + to_be_summarized_window_size: int = TO_BE_SUMMARIZED_WINDOW_SIZE, +) -> tuple[ConversationContext, str, int]: + turns = _messages_to_turns(messages) + total_window = unsummarized_window_size + to_be_summarized_window_size + + if len(turns) <= total_window: + history = ConversationHistory(turns=turns) + return ( + ConversationContext(all_history=history, history=history, summary=""), + summary, + num_turns_summarized, + ) + + to_summarize_count = len(turns) - total_window + to_summarize_turns = turns[0:to_summarize_count] + recent_turns = turns[to_summarize_count:] + + summarizer = Summarizer() + turns_already_in_summary = min(num_turns_summarized, len(to_summarize_turns)) + new_turns_to_summarize = to_summarize_turns[turns_already_in_summary:] + + if new_turns_to_summarize: + current_summary = summary + for batch_start in range(0, len(new_turns_to_summarize), to_be_summarized_window_size): + batch = new_turns_to_summarize[ + batch_start : batch_start + to_be_summarized_window_size + ] + ctx = ConversationContext( + all_history=ConversationHistory(turns=[]), + history=ConversationHistory(turns=batch), + summary=current_summary, + ) + current_summary = await summarizer.summarize(ctx) + summary = current_summary + num_turns_summarized = len(to_summarize_turns) + + history = ConversationHistory(turns=recent_turns) + all_history = ConversationHistory(turns=turns) + return ( + ConversationContext( + all_history=all_history, + history=history, + summary=summary, + ), + summary, + num_turns_summarized, + ) diff --git a/backend/app/career_explorer/repository.py b/backend/app/career_explorer/repository.py new file mode 100644 index 00000000..aca74dff --- /dev/null +++ b/backend/app/career_explorer/repository.py @@ -0,0 +1,78 @@ +import logging +from abc import ABC, abstractmethod +from datetime import datetime, timezone + +from bson import ObjectId +from motor.motor_asyncio import AsyncIOMotorDatabase + +from app.career_explorer.types import CareerExplorerConversationDocument, CareerExplorerMessage +from app.server_dependencies.database_collections import Collections + + +class ICareerExplorerConversationRepository(ABC): + @abstractmethod + async def create(self, document: CareerExplorerConversationDocument) -> None: + raise NotImplementedError() + + @abstractmethod + async def find_by_user(self, user_id: str) -> CareerExplorerConversationDocument | None: + raise NotImplementedError() + + @abstractmethod + async def append_message(self, user_id: str, message: CareerExplorerMessage) -> None: + raise NotImplementedError() + + @abstractmethod + async def update_memory_state( + self, + user_id: str, + summary: str, + num_turns_summarized: int, + ) -> None: + raise NotImplementedError() + + @abstractmethod + async def delete_by_user(self, user_id: str) -> bool: + raise NotImplementedError() + + +class CareerExplorerConversationRepository(ICareerExplorerConversationRepository): + def __init__(self, db: AsyncIOMotorDatabase): + self._collection = db.get_collection(Collections.CAREER_EXPLORER_CONVERSATIONS) + self._logger = logging.getLogger(self.__class__.__name__) + + async def create(self, document: CareerExplorerConversationDocument) -> None: + await self._collection.insert_one(document.model_dump()) + + async def find_by_user(self, user_id: str) -> CareerExplorerConversationDocument | None: + result = await self._collection.find_one({"user_id": user_id}) + if result is None: + return None + return CareerExplorerConversationDocument.from_dict(result) + + async def append_message(self, user_id: str, message: CareerExplorerMessage) -> None: + now = datetime.now(timezone.utc) + await self._collection.update_one( + {"user_id": user_id}, + {"$push": {"messages": message.model_dump()}, "$set": {"updated_at": now}}, + ) + + async def update_memory_state( + self, + user_id: str, + summary: str, + num_turns_summarized: int, + ) -> None: + now = datetime.now(timezone.utc) + await self._collection.update_one( + {"user_id": user_id}, + {"$set": { + "summary": summary, + "num_turns_summarized": num_turns_summarized, + "updated_at": now, + }}, + ) + + async def delete_by_user(self, user_id: str) -> bool: + result = await self._collection.delete_one({"user_id": user_id}) + return result.deleted_count > 0 diff --git a/backend/app/career_explorer/routes.py b/backend/app/career_explorer/routes.py new file mode 100644 index 00000000..947a1483 --- /dev/null +++ b/backend/app/career_explorer/routes.py @@ -0,0 +1,126 @@ +import asyncio +import logging +from http import HTTPStatus +from typing import Optional + +from fastapi import APIRouter, FastAPI, Depends, HTTPException +from motor.motor_asyncio import AsyncIOMotorDatabase + +from app.app_config import get_application_config +from app.career_explorer.repository import CareerExplorerConversationRepository +from app.career_explorer.service import CareerExplorerService +from app.career_explorer.types import ( + CareerExplorerConversationInput, + CareerExplorerConversationResponse, +) +from app.constants.errors import HTTPErrorResponse +from app.conversations.constants import MAX_MESSAGE_LENGTH +from app.i18n.translation_service import get_i18n_manager +from app.server_dependencies.db_dependencies import CompassDBProvider +from app.server_dependencies.database_collections import Collections +from app.users.auth import Authentication, UserInfo +from app.agent.career_explorer_agent.agent import CareerExplorerAgent +from app.agent.career_explorer_agent.sector_search_service import SectorSearchService +from app.vector_search.vector_search_dependencies import get_embeddings_service +from app.vector_search.embeddings_model import EmbeddingService + +logger = logging.getLogger(__name__) + +_lock = asyncio.Lock() +_service_singleton: Optional[CareerExplorerService] = None + + +async def get_career_explorer_service( + career_explorer_db: AsyncIOMotorDatabase = Depends(CompassDBProvider.get_career_explorer_db), + embedding_service: EmbeddingService = Depends(get_embeddings_service), +) -> CareerExplorerService: + global _service_singleton + if _service_singleton is None: + async with _lock: + if _service_singleton is None: + collection = career_explorer_db.get_collection(Collections.CAREER_EXPLORER_SECTOR_CHUNKS) + sector_search = SectorSearchService( + collection=collection, + embedding_service=embedding_service, + embedding_key="embedding", + index_name="sector_chunks_embedding_index", + ) + agent_factory = lambda: CareerExplorerAgent(sector_search_service=sector_search) + _service_singleton = CareerExplorerService( + repository=CareerExplorerConversationRepository(career_explorer_db), + agent_factory=agent_factory, + ) + return _service_singleton + + +def add_career_explorer_routes(app: FastAPI, authentication: Authentication): + router = APIRouter(prefix="/career-explorer", tags=["career-explorer"]) + + @router.post( + "/conversation", + status_code=HTTPStatus.CREATED, + response_model=CareerExplorerConversationResponse, + responses={HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}}, + ) + async def _get_or_create_conversation( + user_info: UserInfo = Depends(authentication.get_user_info()), + service: CareerExplorerService = Depends(get_career_explorer_service), + ): + try: + app_config = get_application_config() + get_i18n_manager().set_locale(app_config.language_config.default_locale) + return await service.get_or_create_conversation(user_info.user_id) + except Exception as e: + logger.exception("Error in career explorer: %s", e) + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected error") from e + + @router.post( + "/conversation/messages", + status_code=HTTPStatus.CREATED, + response_model=CareerExplorerConversationResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.REQUEST_ENTITY_TOO_LARGE: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + ) + async def _send_message( + body: CareerExplorerConversationInput, + user_info: UserInfo = Depends(authentication.get_user_info()), + service: CareerExplorerService = Depends(get_career_explorer_service), + ): + if len(body.user_input) > MAX_MESSAGE_LENGTH: + raise HTTPException(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Message too long") + try: + app_config = get_application_config() + get_i18n_manager().set_locale(app_config.language_config.default_locale) + return await service.send_message(user_info.user_id, body.user_input) + except ValueError as e: + raise HTTPException(HTTPStatus.NOT_FOUND, str(e)) from e + except Exception as e: + logger.exception("Error in career explorer: %s", e) + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected error") from e + + @router.get( + "/conversation", + response_model=CareerExplorerConversationResponse, + responses={ + HTTPStatus.NOT_FOUND: {"model": HTTPErrorResponse}, + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse}, + }, + ) + async def _get_conversation( + user_info: UserInfo = Depends(authentication.get_user_info()), + service: CareerExplorerService = Depends(get_career_explorer_service), + ): + try: + app_config = get_application_config() + get_i18n_manager().set_locale(app_config.language_config.default_locale) + return await service.get_conversation(user_info.user_id) + except ValueError as e: + raise HTTPException(HTTPStatus.NOT_FOUND, str(e)) from e + except Exception as e: + logger.exception("Error in career explorer: %s", e) + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected error") from e + + app.include_router(router) diff --git a/backend/app/career_explorer/service.py b/backend/app/career_explorer/service.py new file mode 100644 index 00000000..118f6bbf --- /dev/null +++ b/backend/app/career_explorer/service.py @@ -0,0 +1,118 @@ +import logging +from datetime import datetime, timezone +from typing import Callable + +from bson import ObjectId +from pymongo.errors import DuplicateKeyError + +from app.agent.agent_types import AgentInput +from app.agent.career_explorer_agent.agent import CareerExplorerAgent +from app.career_explorer.repository import ICareerExplorerConversationRepository +from app.career_explorer.types import ( + CareerExplorerConversationDocument, + CareerExplorerConversationResponse, + CareerExplorerMessage, + CareerExplorerMessageSender, +) +from app.agent.career_explorer_agent.agent import WELCOME_MESSAGE +from app.career_explorer.context_builder import build_windowed_context + +class CareerExplorerService: + def __init__( + self, + repository: ICareerExplorerConversationRepository, + agent_factory: Callable[[], CareerExplorerAgent], + ): + self._repository = repository + self._agent_factory = agent_factory + self._logger = logging.getLogger(self.__class__.__name__) + + async def get_or_create_conversation(self, user_id: str) -> CareerExplorerConversationResponse: + existing = await self._repository.find_by_user(user_id) + if existing: + return CareerExplorerConversationResponse( + messages=existing.messages, + finished=False, + ) + + now = datetime.now(timezone.utc) + intro = CareerExplorerMessage( + message_id=str(ObjectId()), + message=WELCOME_MESSAGE, + sent_at=now, + sender=CareerExplorerMessageSender.AGENT, + ) + doc = CareerExplorerConversationDocument( + user_id=user_id, + messages=[intro], + created_at=now, + updated_at=now, + ) + try: + await self._repository.create(doc) + except DuplicateKeyError: + self._logger.debug("Race condition: conversation already exists for user %s, fetching existing", user_id) + existing = await self._repository.find_by_user(user_id) + if existing: + return CareerExplorerConversationResponse( + messages=existing.messages, + finished=False, + ) + raise + + return CareerExplorerConversationResponse( + messages=[intro], + finished=False, + ) + + async def send_message(self, user_id: str, user_input: str) -> CareerExplorerConversationResponse: + conv = await self._repository.find_by_user(user_id) + if conv is None: + raise ValueError("Conversation not found") + + now = datetime.now(timezone.utc) + user_msg = CareerExplorerMessage( + message_id=str(ObjectId()), + message=user_input, + sent_at=now, + sender=CareerExplorerMessageSender.USER, + ) + await self._repository.append_message(user_id, user_msg) + + context, new_summary, new_num_turns = await build_windowed_context( + conv.messages, + summary=conv.summary, + num_turns_summarized=conv.num_turns_summarized, + ) + if new_summary != conv.summary or new_num_turns != conv.num_turns_summarized: + await self._repository.update_memory_state( + user_id, + new_summary, + new_num_turns, + ) + + agent = self._agent_factory() + agent_input = AgentInput(message=user_input, sent_at=now) + agent_output = await agent.execute(agent_input, context) + + agent_msg = CareerExplorerMessage( + message_id=agent_output.message_id or str(ObjectId()), + message=agent_output.message_for_user, + sent_at=datetime.now(timezone.utc), + sender=CareerExplorerMessageSender.AGENT, + ) + await self._repository.append_message(user_id, agent_msg) + + return CareerExplorerConversationResponse( + messages=list(conv.messages) + [user_msg] + [agent_msg], + finished=agent_output.finished, + ) + + async def get_conversation(self, user_id: str) -> CareerExplorerConversationResponse: + conv = await self._repository.find_by_user(user_id) + if conv is None: + raise ValueError("Conversation not found") + return CareerExplorerConversationResponse( + messages=conv.messages, + finished=False, + ) diff --git a/backend/app/career_explorer/types.py b/backend/app/career_explorer/types.py new file mode 100644 index 00000000..419cf35b --- /dev/null +++ b/backend/app/career_explorer/types.py @@ -0,0 +1,87 @@ +from datetime import datetime, timezone +from enum import Enum + +from pydantic import BaseModel, Field, field_serializer, field_validator + + +class CareerExplorerMessageSender(str, Enum): + USER = "USER" + AGENT = "AGENT" + + +class CareerExplorerMessage(BaseModel): + message_id: str + message: str + sent_at: datetime + sender: CareerExplorerMessageSender + + @field_serializer("sent_at") + def _serialize_sent_at(self, value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat() + + @field_serializer("sender") + def _serialize_sender(self, sender: CareerExplorerMessageSender, _info) -> str: + return sender.name + + @classmethod + @field_validator("sender", mode="before") + def _deserialize_sender(cls, value: str | CareerExplorerMessageSender) -> CareerExplorerMessageSender: + if isinstance(value, str): + return CareerExplorerMessageSender[value] + return value + + class Config: + extra = "forbid" + + +class CareerExplorerConversationResponse(BaseModel): + messages: list[CareerExplorerMessage] + finished: bool = False + + class Config: + extra = "forbid" + + +class CareerExplorerConversationInput(BaseModel): + user_input: str + + class Config: + extra = "forbid" + + +class CareerExplorerConversationDocument(BaseModel): + user_id: str + messages: list[CareerExplorerMessage] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + summary: str = "" + num_turns_summarized: int = 0 + + @field_serializer("created_at", "updated_at") + def _serialize_datetime(self, value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat() + + @classmethod + @field_validator("created_at", "updated_at", mode="before") + def _deserialize_datetime(cls, value: str | datetime) -> datetime: + if isinstance(value, str): + dt = datetime.fromisoformat(value) + elif isinstance(value, datetime): + dt = value + else: + raise ValueError(f"Invalid datetime: {value}") + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + @staticmethod + def from_dict(d: dict) -> "CareerExplorerConversationDocument": + return CareerExplorerConversationDocument( + user_id=str(d["user_id"]), + messages=[CareerExplorerMessage(**m) for m in d.get("messages", [])], + created_at=d["created_at"], + updated_at=d["updated_at"], + summary=d.get("summary", ""), + num_turns_summarized=d.get("num_turns_summarized", 0), + ) + + class Config: + extra = "forbid" diff --git a/backend/app/i18n/i18n_manager.py b/backend/app/i18n/i18n_manager.py index 10433637..64a36762 100644 --- a/backend/app/i18n/i18n_manager.py +++ b/backend/app/i18n/i18n_manager.py @@ -1,7 +1,6 @@ import json import logging import os -import argparse from typing import Dict, Any, Set, Optional from collections import defaultdict from app.i18n.types import Locale diff --git a/backend/app/server.py b/backend/app/server.py index 22459406..5ca1f758 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -15,6 +15,7 @@ from app.job_preferences import add_job_preferences_routes from app.career_path import add_career_path_routes from app.career_readiness import add_career_readiness_routes +from app.career_explorer import add_career_explorer_routes from app.metrics.routes.routes import add_metrics_routes from app.sentry_init import init_sentry, set_sentry_contexts from app.server_dependencies.db_dependencies import CompassDBProvider @@ -155,6 +156,10 @@ def setup_sentry(): raise ValueError("Mandatory USERDATA_MONGODB_URI env variable is not set!") if not os.getenv("USERDATA_DATABASE_NAME"): raise ValueError("Mandatory USERDATA_DATABASE_NAME environment variable is not set") +if not os.getenv('CAREER_EXPLORER_MONGODB_URI'): + raise ValueError("Mandatory CAREER_EXPLORER_MONGODB_URI env variable is not set!") +if not os.getenv("CAREER_EXPLORER_DATABASE_NAME"): + raise ValueError("Mandatory CAREER_EXPLORER_DATABASE_NAME environment variable is not set") if not os.getenv('TAXONOMY_MODEL_ID'): raise ValueError("Mandatory TAXONOMY_MODEL_ID env variable is not set!") if not os.getenv("EMBEDDINGS_SERVICE_NAME"): @@ -271,6 +276,7 @@ async def lifespan(_app: FastAPI): taxonomy_db = await CompassDBProvider.get_taxonomy_db() userdata_db = await CompassDBProvider.get_userdata_db() metrics_db = await CompassDBProvider.get_metrics_db() + career_explorer_db = await CompassDBProvider.get_career_explorer_db() app_cfg = get_application_config() # Initialize the MongoDB databases @@ -279,6 +285,7 @@ async def lifespan(_app: FastAPI): CompassDBProvider.initialize_application_mongo_db(application_db, logger), CompassDBProvider.initialize_userdata_mongo_db(userdata_db, logger), CompassDBProvider.initialize_metrics_mongo_db(metrics_db, logger), + CompassDBProvider.initialize_career_explorer_mongo_db(career_explorer_db, logger), validate_taxonomy_model(taxonomy_db=taxonomy_db, taxonomy_model_id=app_cfg.taxonomy_model_id, embeddings_service_name=app_cfg.embeddings_service_name, @@ -307,6 +314,7 @@ async def lifespan(_app: FastAPI): application_db.client.close() userdata_db.client.close() metrics_db.client.close() + career_explorer_db.client.close() logger.info("Shutting down completed.") # noinspection PyUnresolvedReferences @@ -407,6 +415,7 @@ async def lifespan(_app: FastAPI): # Add the career readiness routes ############################################ add_career_readiness_routes(app, auth) +add_career_explorer_routes(app, auth) ############################################ # Add routes relevant for esco search diff --git a/backend/app/server_dependencies/database_collections.py b/backend/app/server_dependencies/database_collections.py index 5a97bbc8..d9f0be38 100644 --- a/backend/app/server_dependencies/database_collections.py +++ b/backend/app/server_dependencies/database_collections.py @@ -20,3 +20,5 @@ class Collections: CAREER_PATH: str = "career_path" USER_RECOMMENDATIONS: str = "user_recommendations" CAREER_READINESS_CONVERSATIONS: str = "career_readiness_conversations" + CAREER_EXPLORER_CONVERSATIONS: str = "career_explorer_conversations" + CAREER_EXPLORER_SECTOR_CHUNKS: str = "career_explorer_sector_chunks" diff --git a/backend/app/server_dependencies/db_dependencies.py b/backend/app/server_dependencies/db_dependencies.py index 49f17ecc..159c8391 100644 --- a/backend/app/server_dependencies/db_dependencies.py +++ b/backend/app/server_dependencies/db_dependencies.py @@ -73,6 +73,13 @@ def _get_taxonomy_db(mongodb_uri: str, db_name: str) -> AsyncIOMotorDatabase: ).get_database(db_name) +def _get_career_explorer_db(mongodb_uri: str, db_name: str) -> AsyncIOMotorDatabase: + return AsyncIOMotorClient( + mongodb_uri, + tlsAllowInvalidCertificates=True + ).get_database(db_name) + + def _get_metrics_db(mongodb_uri: str, db_name: str) -> AsyncIOMotorDatabase: """ Decouples the database creation from the database provider. @@ -100,6 +107,7 @@ class CompassDBProvider: _taxonomy_mongo_db: Optional[AsyncIOMotorDatabase] = None _userdata_mongo_db: Optional[AsyncIOMotorDatabase] = None _metrics_mongo_db: Optional[AsyncIOMotorDatabase] = None + _career_explorer_mongo_db: Optional[AsyncIOMotorDatabase] = None _lock = asyncio.Lock() _logger = logging.getLogger(__qualname__) @@ -259,6 +267,18 @@ async def initialize_application_mongo_db(application_db: AsyncIOMotorDatabase, logger.exception(e) raise e + @staticmethod + async def initialize_career_explorer_mongo_db(career_explorer_db: AsyncIOMotorDatabase, logger: logging.Logger): + try: + logger.info("Initializing indexes for the Career Explorer database") + await career_explorer_db.get_collection(Collections.CAREER_EXPLORER_CONVERSATIONS).create_index([ + ("user_id", 1) + ], unique=True) + logger.info("Finished creating indexes for the Career Explorer database") + except Exception as e: + logger.exception(e) + raise e + @staticmethod async def initialize_metrics_mongo_db(metrics_db: AsyncIOMotorDatabase, logger: logging.Logger): """ Initialize the MongoDB database.""" @@ -336,6 +356,23 @@ async def get_taxonomy_db(cls) -> AsyncIOMotorDatabase: cls._logger.info("Successfully pinged Taxonomy MongoDB") return cls._taxonomy_mongo_db + @classmethod + async def get_career_explorer_db(cls) -> AsyncIOMotorDatabase: + if cls._career_explorer_mongo_db is None: + async with cls._lock: + if cls._career_explorer_mongo_db is None: + cls._logger.info("Connecting to Career Explorer MongoDB") + cls._career_explorer_mongo_db = _get_career_explorer_db( + cls._get_settings().career_explorer_mongodb_uri, + cls._get_settings().career_explorer_database_name, + ) + cls._logger.info("Connected to Career Explorer MongoDB database: %s", + await _get_database_connection_info(cls._career_explorer_mongo_db)) + if not await check_mongo_health(cls._career_explorer_mongo_db.client): + raise RuntimeError("MongoDB health check failed for Career Explorer database") + cls._logger.info("Successfully pinged Career Explorer MongoDB") + return cls._career_explorer_mongo_db + @classmethod async def get_metrics_db(cls) -> AsyncIOMotorDatabase: if cls._metrics_mongo_db is None: # Check if the database instance has been created @@ -363,5 +400,6 @@ def clear_cache(): CompassDBProvider._taxonomy_mongo_db = None CompassDBProvider._userdata_mongo_db = None CompassDBProvider._metrics_mongo_db = None + CompassDBProvider._career_explorer_mongo_db = None CompassDBProvider._logger.info("Cleared cached database instances") diff --git a/backend/app/test_server.py b/backend/app/test_server.py index 558e6d85..dd49c35a 100644 --- a/backend/app/test_server.py +++ b/backend/app/test_server.py @@ -26,6 +26,7 @@ async def test_server_up(self, in_memory_taxonomy_database: Awaitable[AsyncIOMotorDatabase], in_memory_application_database: Awaitable[AsyncIOMotorDatabase], in_memory_metrics_database: Awaitable[AsyncIOMotorDatabase], + in_memory_career_explorer_database: Awaitable[AsyncIOMotorDatabase], mocker: pytest_mock.MockFixture, setup_env: None ): @@ -63,6 +64,9 @@ async def test_server_up(self, _in_mem_metrics_db = mocker.patch('app.server_dependencies.db_dependencies._get_metrics_db') _in_mem_metrics_db.return_value = await in_memory_metrics_database + _in_mem_career_explorer_db = mocker.patch('app.server_dependencies.db_dependencies._get_career_explorer_db') + _in_mem_career_explorer_db.return_value = await in_memory_career_explorer_database + # Use httpx and AsyncClient to test the application asynchronously. This ensures the application # is fully started and properly shut down, as recommended for async tests in: # https://fastapi.tiangolo.com/advanced/async-tests/#async-tests diff --git a/backend/common_libs/environment_settings/mongo_db_settings.py b/backend/common_libs/environment_settings/mongo_db_settings.py index 72710dfc..036edbb0 100644 --- a/backend/common_libs/environment_settings/mongo_db_settings.py +++ b/backend/common_libs/environment_settings/mongo_db_settings.py @@ -45,3 +45,13 @@ class MongoDbSettings(BaseSettings): """ The name of the taxonomy database """ + + career_explorer_mongodb_uri: str = "" + """ + The URI of the Career Explorer MongoDB instance. + """ + + career_explorer_database_name: str = "" + """ + The name of the Career Explorer database (conversations + sector chunks). + """ diff --git a/backend/common_libs/test_utilities/setup_env_vars.py b/backend/common_libs/test_utilities/setup_env_vars.py index b80a14c4..c01dd6da 100644 --- a/backend/common_libs/test_utilities/setup_env_vars.py +++ b/backend/common_libs/test_utilities/setup_env_vars.py @@ -107,6 +107,8 @@ def setup_env_vars(*, env_vars: dict[str, str] = None): 'METRICS_DATABASE_NAME': "foo", 'USERDATA_MONGODB_URI': "foo", 'USERDATA_DATABASE_NAME': "foo", + 'CAREER_EXPLORER_MONGODB_URI': "foo", + 'CAREER_EXPLORER_DATABASE_NAME': "foo", 'TAXONOMY_MODEL_ID': str(ObjectId()), 'GOOGLE_APPLICATION_CREDENTIALS': "foo", 'VERTEX_API_REGION': "foo", diff --git a/backend/conftest.py b/backend/conftest.py index 8a037676..78f04cd3 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -141,6 +141,15 @@ async def in_memory_application_database(in_memory_mongo_server) -> AsyncIOMotor return application_db +@pytest.fixture(scope='function') +async def in_memory_career_explorer_database(in_memory_mongo_server) -> AsyncIOMotorDatabase: + career_explorer_db = AsyncIOMotorClient(in_memory_mongo_server.connection_string, + tlsAllowInvalidCertificates=True).get_database(random_db_name()) + await CompassDBProvider.initialize_career_explorer_mongo_db(career_explorer_db, logger=logging.getLogger(__name__)) + logging.info(f"Created career explorer database: {career_explorer_db.name}") + return career_explorer_db + + @pytest.fixture(scope='function') async def in_memory_metrics_database(in_memory_mongo_server) -> AsyncIOMotorDatabase: """ diff --git a/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_executors.py b/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_executors.py new file mode 100644 index 00000000..efbf9f56 --- /dev/null +++ b/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_executors.py @@ -0,0 +1,127 @@ +from app.agent.agent_types import AgentInput, AgentOutput +from app.agent.career_explorer_agent.agent import CareerExplorerAgent +from app.agent.career_explorer_agent.sector_search_service import SectorChunkEntity, SectorSearchService +from app.conversation_memory.conversation_memory_types import ( + ConversationContext, + ConversationHistory, + ConversationTurn, +) + + +class MockSectorSearchService: + """ + Mock search that returns predefined chunks based on query keywords. + Content matches the embedded markdown files (agriculture.md, mining.md, etc.). + """ + + _SECTOR_CHUNKS = { + "agriculture": [ + "Agriculture is the largest employer in Zambia. The sector needs professionals who can manage irrigation, crop health, aquaculture, and machinery. Roles include Agricultural Extensionist, Horticulturist, Aquaculture Technician, Farm Machinery Operator. Commercial farms, agri-processing companies like Zambeef, and aquaculture farms are key employers. Central Province is a major commercial farming hub. TEVET qualifications help access higher-paying commercial roles.", + "Skilled Technicians in Irrigation or Machinery earn K4,000 to K8,000 monthly. Precision Agriculture uses drones and data for crop management. Climate-Smart Agriculture techniques help farm sustainably. Value Addition processing turns raw produce into finished goods like peanut butter and jams.", + ], + "mining": [ + "The mining sector is the economic backbone of Zambia. Roles include Heavy Equipment Repair, Driller/Blaster, Mining Surveyor, Ventilation Technician, Geologist. Copperbelt Province has 58.9% of mining employment. Large-scale mining consortiums include Mopani, KCM, FQM, Barrick. Over 48% of mining employees earn above K7,500 per month. Heavy Equipment Repair has a critical shortage of mechanics. Gemstone mines in Lufwanyama extract emeralds.", + "Skilled Artisans and Technicians in mining earn K6,300 to K15,000+ monthly. The sector offers some of the highest earning potential in Zambia for skilled technical professionals. Mining contractor firms supply equipment maintenance and drilling services.", + ], + "energy": [ + "The energy sector in Zambia includes power generation, solar, and renewables. Roles for TEVET graduates include solar technicians, electrical technicians, and power plant operators. The sector is growing with renewable energy projects.", + ], + "hospitality": [ + "Hospitality covers hotels, tourism, and safari lodges. TEVET graduates can work as chefs, hotel front desk staff, tour guides, and lodge attendants. The sector supports Zambia's tourism industry.", + ], + "water": [ + "The water sector includes treatment, supply, and sanitation. Roles include Water Treatment Plant Operator, Plumbing Technician, and Sanitation Technician. Urban and rural water supply projects create demand for skilled workers.", + ], + } + + # mock search implementation that returns relevant chunks from the predefined content based on query keywords + async def search( + self, + *, + query: str | list[float], + filter_spec=None, + k: int = 5, + sector=None, + ) -> list[SectorChunkEntity]: + if isinstance(query, list): + return [] + q = query.lower().strip() + if not q: + return [] + + chunks = [] + for sector_key, texts in self._SECTOR_CHUNKS.items(): + if sector_key in q or any(w in q for w in sector_key.split()): + for j, text in enumerate(texts[:2]): + chunks.append( + SectorChunkEntity( + chunk_id=f"{sector_key}_{j}", + sector=sector_key.title(), + text=text, + score=0.9 - j * 0.1, + ) + ) + break + + if not chunks: + fallback = ( + "Energy covers power generation and solar. Mining includes copper and gemstones. " + "Agriculture involves commercial farming. Hospitality covers hotels and tourism. " + "Water covers treatment and sanitation. Which sector interests you?" + ) + chunks = [ + SectorChunkEntity( + chunk_id="general_0", + sector="General", + text=fallback, + score=0.8, + ) + ] + return chunks[:k] + + +class CareerExplorerExecutor: + """ + Executes the Career Explorer agent with a simple in-memory conversation history. + """ + + def __init__(self, sector_search_service: SectorSearchService | MockSectorSearchService | None = None): + self._search = sector_search_service or MockSectorSearchService() + self._agent = CareerExplorerAgent(sector_search_service=self._search) + self._turns: list[ConversationTurn] = [] + + def _build_context(self) -> ConversationContext: + history = ConversationHistory(turns=list(self._turns)) + return ConversationContext(all_history=history, history=history, summary="") + + async def __call__(self, agent_input: AgentInput) -> AgentOutput: + context = self._build_context() + agent_output = await self._agent.execute(agent_input, context) + turn = ConversationTurn( + index=len(self._turns) + 1, + input=agent_input, + output=agent_output, + ) + self._turns.append(turn) + return agent_output + + +class CareerExplorerIsFinished: + """ + Career Explorer has no definitive end; we stop after a fixed script length. + """ + + def __call__(self, agent_output: AgentOutput) -> bool: + return agent_output.finished + + +class CareerExplorerGetConversationContextExecutor: + """ + Returns the conversation context for the Career Explorer eval. + """ + + def __init__(self, executor: CareerExplorerExecutor): + self._executor = executor + + async def __call__(self) -> ConversationContext: + return self._executor._build_context() diff --git a/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_scripted_user_test.py b/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_scripted_user_test.py new file mode 100644 index 00000000..2081d81e --- /dev/null +++ b/backend/evaluation_tests/career_explorer_agent/career_explorer_agent_scripted_user_test.py @@ -0,0 +1,143 @@ +import logging +import os +from dataclasses import dataclass, field + +import pytest + +from app.i18n.translation_service import get_i18n_manager +from app.i18n.types import Locale +from evaluation_tests.conversation_libs.conversation_generator import generate +from evaluation_tests.conversation_libs.conversation_test_function import ScriptedSimulatedUser +from evaluation_tests.conversation_libs.evaluators.evaluation_result import Actor, ConversationRecord +from evaluation_tests.career_explorer_agent.career_explorer_agent_executors import ( + CareerExplorerExecutor, + CareerExplorerGetConversationContextExecutor, + CareerExplorerIsFinished, +) +from app.conversation_memory.save_conversation_context import ( + save_conversation_context_to_json, + save_conversation_context_to_markdown, +) + + +@dataclass +class SectorContentTestCase: + """Test case with scripted questions and expected phrases from RAG content.""" + name: str + scripted_user: list[str] + description: str + expected_phrases_by_turn: list[list[str]] = field(default_factory=list) + """ + For each agent response (after welcome), at least one phrase in the list must appear. + Turn 0 = welcome message (skipped). Turn 1 = response to scripted_user[0], etc. + """ + + +TEST_CASES = [ + SectorContentTestCase( + name="ask_agriculture", + description="User asks about Agriculture sector", + scripted_user=[ + "Tell me about Agriculture", + ], + expected_phrases_by_turn=[ + ["Agriculture", "irrigation", "Zambeef", "TEVET", "commercial", "aquaculture", "crop"], + ], + ), + SectorContentTestCase( + name="ask_mining", + description="User asks about Mining sector", + scripted_user=[ + "What roles are there in mining?", + ], + expected_phrases_by_turn=[ + ["Mining", "Copperbelt", "Barrick", "Heavy Equipment", "gemstone", "K7,500", "Driller"], + ], + ), + SectorContentTestCase( + name="ask_agriculture_then_mining", + description="User asks about Agriculture then Mining", + scripted_user=[ + "I'm interested in Agriculture", + "What about mining?", + ], + expected_phrases_by_turn=[ + ["Agriculture", "irrigation", "Zambeef", "TEVET", "commercial", "aquaculture"], + ["Mining", "Copperbelt", "Barrick", "Heavy Equipment", "gemstone", "K7,500"], + ], + ), +] + + +def _agent_responses(conversation: list[ConversationRecord]) -> list[str]: + return [r.message for r in conversation if r.actor == Actor.EVALUATED_AGENT] + + +def _assert_rag_content(conversation: list[ConversationRecord], test_case: SectorContentTestCase) -> None: + agent_responses = _agent_responses(conversation) + assert len(agent_responses) >= 1, "Expected at least one agent response" + for i, expected_phrases in enumerate(test_case.expected_phrases_by_turn): + response_index = i + 1 + if response_index >= len(agent_responses): + break + response_text = agent_responses[response_index].lower() + found = any(phrase.lower() in response_text for phrase in expected_phrases) + excerpt = agent_responses[response_index][:400] + "..." if len(agent_responses[response_index]) > 400 else agent_responses[response_index] + assert found, ( + f"Agent response to '{test_case.scripted_user[i]}' should contain " + f"at least one of {expected_phrases} but got: {excerpt}" + ) + + +@pytest.mark.asyncio +@pytest.mark.evaluation_test("gemini-2.5-flash-lite/") +@pytest.mark.parametrize("test_case", TEST_CASES, ids=[tc.name for tc in TEST_CASES]) +async def test_career_explorer_sector_content(evals_setup, setup_multi_locale_app_config, test_case: SectorContentTestCase): + """ + Scripted conversation test. Asserts agent responses include content from the + embedded sector markdown files (simulated via mock RAG). + """ + logging.info("Running Career Explorer test case: %s", test_case.name) + get_i18n_manager().set_locale(Locale.EN_US) + + executor = CareerExplorerExecutor() + max_iterations = len(test_case.scripted_user) + 1 + + conversation = await generate( + max_iterations=max_iterations, + execute_evaluated_agent=executor, + execute_simulated_user=ScriptedSimulatedUser(script=test_case.scripted_user), + is_finished=CareerExplorerIsFinished(), + ) + + _assert_rag_content(conversation, test_case) + + output_folder = os.path.join( + os.getcwd(), + "test_output", + "career_explorer_agent", + "scripted", + test_case.name, + ) + os.makedirs(output_folder, exist_ok=True) + + from datetime import datetime, timezone + time_now = datetime.now(timezone.utc).isoformat() + base_name = f"{test_case.name}_{time_now}" + + from evaluation_tests.conversation_libs.evaluators.evaluation_result import ConversationEvaluationRecord + record = ConversationEvaluationRecord( + simulated_user_prompt=test_case.description, + test_case=test_case.name, + ) + record.add_conversation_records(conversation) + record.save_data(folder=output_folder, base_file_name=base_name) + + context = await CareerExplorerGetConversationContextExecutor(executor)() + ctx_path = os.path.join(output_folder, f"{base_name}_context") + save_conversation_context_to_json(context=context, file_path=ctx_path + ".json") + save_conversation_context_to_markdown( + title=f"Career Explorer: {test_case.name}", + context=context, + file_path=ctx_path + ".md", + ) diff --git a/backend/features/test_loader.py b/backend/features/test_loader.py index ecf9ca12..46630fe7 100644 --- a/backend/features/test_loader.py +++ b/backend/features/test_loader.py @@ -238,6 +238,7 @@ async def test_feature_up(self, in_memory_taxonomy_database: Awaitable[AsyncIOMotorDatabase], in_memory_userdata_database: Awaitable[AsyncIOMotorDatabase], in_memory_metrics_database: Awaitable[AsyncIOMotorDatabase], + in_memory_career_explorer_database: Awaitable[AsyncIOMotorDatabase], mocker: pytest_mock.MockFixture, enable_test_feature: None ): @@ -264,6 +265,8 @@ async def test_feature_up(self, return_value=await in_memory_userdata_database) _in_mem_metrics_db = mocker.patch('app.server_dependencies.db_dependencies._get_metrics_db', return_value=await in_memory_metrics_database) + mocker.patch('app.server_dependencies.db_dependencies._get_career_explorer_db', + return_value=await in_memory_career_explorer_database) feature_module = Mock() feature_module.TestFeature = _TestFeature diff --git a/backend/scripts/ingest_sector_document.py b/backend/scripts/ingest_sector_document.py new file mode 100644 index 00000000..d4cace65 --- /dev/null +++ b/backend/scripts/ingest_sector_document.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Ingest Knowledge Hub documents into the Career Explorer vector store. + +Supports markdown files directly. For other formats (PDF, DOCX, etc.) uses markitdown +to convert to markdown before processing. + +Usage (single sector): + poetry run python -m scripts.ingest_sector_document \ + --markdown-path ../frontend-new/src/knowledgeHub/documents/agriculture.md \ + --sector Agriculture \ + --hot-run + +Usage (all sectors): + poetry run python -m scripts.ingest_sector_document \ + --ingest-all \ + --hot-run + +Environment: + CAREER_EXPLORER_MONGODB_URI, CAREER_EXPLORER_DATABASE_NAME - for the database + VERTEX_API_REGION - for embeddings + EMBEDDINGS_MODEL_NAME - same as app config for consistency +""" +import argparse +import asyncio +import logging +import os +import re +from pathlib import Path + +from dotenv import load_dotenv +from motor.motor_asyncio import AsyncIOMotorClient +from pymongo.operations import SearchIndexModel + +load_dotenv() + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +CHUNK_SIZE = 500 +CHUNK_OVERLAP = 50 +INDEX_NAME = "sector_chunks_embedding_index" +EMBEDDING_KEY = "embedding" +MARKDOWN_EXTENSIONS = {".md"} + + +def load_document_text(file_path: Path) -> str: + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + suffix = file_path.suffix.lower() + if suffix in MARKDOWN_EXTENSIONS: + with open(file_path, "r", encoding="utf-8") as f: + return f.read() + logger.info("Converting %s to markdown via markitdown", file_path.name) + from markitdown import MarkItDown + converter = MarkItDown() + result = converter.convert(str(file_path)) + return getattr(result, "markdown", None) or getattr(result, "text_content", "") or "" + + +def strip_yaml_frontmatter(content: str) -> str: + frontmatter_regex = re.compile(r"^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$", re.MULTILINE) + match = frontmatter_regex.match(content) + if match: + return match.group(2) + return content + + +def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]: + words = text.split() + chunks = [] + for i in range(0, len(words), chunk_size - overlap): + chunk_words = words[i : i + chunk_size] + if chunk_words: + chunks.append(" ".join(chunk_words)) + return chunks + + +async def ensure_vector_index(collection, num_dimensions: int, hot_run: bool) -> None: + existing = False + async for idx in collection.list_search_indexes(): + if idx.get("name") == INDEX_NAME: + existing = True + break + + definition = { + "fields": [ + {"numDimensions": num_dimensions, "path": EMBEDDING_KEY, "similarity": "cosine", "type": "vector"}, + {"path": "sector", "type": "filter"}, + ] + } + + if existing: + if hot_run: + await collection.update_search_index(INDEX_NAME, definition) + logger.info("Updated vector index") + else: + logger.info("Would update vector index") + else: + if hot_run: + await collection.create_search_index( + model=SearchIndexModel(definition=definition, name=INDEX_NAME, type="vectorSearch") + ) + logger.info("Created vector index") + else: + logger.info("Would create vector index") + + +async def ingest_sector(file_path: Path, sector: str, collection, embedding_service, num_dimensions: int, hot_run: bool, clear_first: bool): + logger.info("Loading document: %s", file_path) + content = load_document_text(file_path) + + text = strip_yaml_frontmatter(content) + if not text or len(text.strip()) < 100: + raise ValueError(f"Document produced insufficient text: {len(text)} chars") + + chunks = chunk_text(text) + logger.info("Created %d chunks from %d chars for sector %s", len(chunks), len(text), sector) + + if clear_first and hot_run: + deleted = await collection.delete_many({"sector": sector}) + logger.info("Cleared %d existing chunks for sector %s", deleted.deleted_count, sector) + + if not hot_run: + logger.info("Dry run - would ingest %d chunks for sector %s", len(chunks), sector) + return + + batch_size = 50 + + if len(chunks) > 0: + first_batch = chunks[0:min(batch_size, len(chunks))] + first_embeddings = await embedding_service.embed_batch(first_batch) + first_docs = [] + for j, (chunk_content, embedding) in enumerate(zip(first_batch, first_embeddings)): + chunk_id = f"{sector.lower()}_{j:05d}" + first_docs.append({ + "chunk_id": chunk_id, + "sector": sector, + "text": chunk_content, + "metadata": {"source": file_path.name, "chunk_index": j}, + "embedding": embedding, + }) + await collection.insert_many(first_docs) + logger.info("Inserted first batch (%d chunks) to create collection", len(first_docs)) + + if len(chunks) > batch_size: + for i in range(batch_size, len(chunks), batch_size): + batch = chunks[i : i + batch_size] + embeddings = await embedding_service.embed_batch(batch) + docs = [] + for j, (chunk_content, embedding) in enumerate(zip(batch, embeddings)): + chunk_id = f"{sector.lower()}_{i + j:05d}" + docs.append({ + "chunk_id": chunk_id, + "sector": sector, + "text": chunk_content, + "metadata": {"source": file_path.name, "chunk_index": i + j}, + "embedding": embedding, + }) + await collection.insert_many(docs) + logger.info("Inserted chunks %d-%d", i, i + len(batch) - 1) + + logger.info("Done. Ingested %d chunks for sector %s", len(chunks), sector) + + +async def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--markdown-path", help="Path to a document file (markdown, PDF, Word, etc.) - required if --ingest-all not set") + parser.add_argument("--sector", help="Sector name (e.g. Agriculture) - required if --markdown-path is set") + parser.add_argument("--ingest-all", action="store_true", help="Ingest all Knowledge Hub markdown files") + parser.add_argument("--hot-run", action="store_true", help="Actually write to DB") + parser.add_argument("--clear-first", action="store_true", help="Delete existing chunks for sector(s) before ingesting") + args = parser.parse_args() + + if not args.ingest_all and (not args.markdown_path or not args.sector): + parser.error("Either --ingest-all or both --markdown-path and --sector must be provided") + + mongodb_uri = os.getenv("CAREER_EXPLORER_MONGODB_URI") + db_name = os.getenv("CAREER_EXPLORER_DATABASE_NAME") + if not mongodb_uri or not db_name: + raise ValueError("Set CAREER_EXPLORER_MONGODB_URI and CAREER_EXPLORER_DATABASE_NAME") + + client = AsyncIOMotorClient(mongodb_uri, tlsAllowInvalidCertificates=True) + db = client.get_database(db_name) + collection = db["career_explorer_sector_chunks"] + + embedding_model = os.getenv("EMBEDDINGS_MODEL_NAME", "text-embedding-005") + + from app.vector_search.embeddings_model import GoogleEmbeddingService + + embedding_service = GoogleEmbeddingService(model_name=embedding_model) + num_dimensions = 768 + + if args.ingest_all: + base_path = Path(__file__).parent.parent.parent / "frontend-new" / "src" / "knowledgeHub" / "documents" + sector_files = { + "Agriculture": base_path / "agriculture.md", + "Energy": base_path / "energy.md", + "Mining": base_path / "mining.md", + "Hospitality": base_path / "hospitality.md", + "Water": base_path / "water.md", + } + + if args.clear_first and args.hot_run: + deleted = await collection.delete_many({}) + logger.info("Cleared all existing chunks") + + first_sector_processed = False + for sector, md_path in sector_files.items(): + if args.hot_run and not first_sector_processed: + await ingest_sector(md_path, sector, collection, embedding_service, num_dimensions, args.hot_run, False) + await ensure_vector_index(collection, num_dimensions, args.hot_run) + first_sector_processed = True + else: + await ingest_sector(md_path, sector, collection, embedding_service, num_dimensions, args.hot_run, False) + else: + markdown_path = Path(args.markdown_path) + if args.clear_first and args.hot_run: + deleted = await collection.delete_many({"sector": args.sector}) + logger.info("Cleared %d existing chunks for sector %s", deleted.deleted_count, args.sector) + + if args.hot_run: + await ingest_sector(markdown_path, args.sector, collection, embedding_service, num_dimensions, args.hot_run, False) + await ensure_vector_index(collection, num_dimensions, args.hot_run) + else: + await ingest_sector(markdown_path, args.sector, collection, embedding_service, num_dimensions, args.hot_run, False) + + client.close() + await asyncio.sleep(0.1) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/frontend-new/src/app/index.tsx b/frontend-new/src/app/index.tsx index b758e95e..52adb800 100644 --- a/frontend-new/src/app/index.tsx +++ b/frontend-new/src/app/index.tsx @@ -33,6 +33,9 @@ const LazyLoadedChat = lazyWithPreload(() => import("src/chat/Chat")); const LazyLoadedKnowledgeHubDocument = lazyWithPreload(() => import("src/knowledgeHub/pages/KnowledgeHubDocument")); const LazyLoadedKnowledgeHubList = lazyWithPreload(() => import("src/knowledgeHub/pages/KnowledgeHubList")); +const LazyLoadedCareerExplorer = lazyWithPreload( + () => import("src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage") +); // Wrap the createHashRouter function with Sentry to capture errors that occur during router initialization const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createHashRouter); @@ -55,6 +58,7 @@ const ProtectedRouteKeys = { SENSITIVE_DATA: "SENSITIVE_DATA", KNOWLEDGE_HUB: "KNOWLEDGE_HUB", KNOWLEDGE_HUB_DOCUMENT: "KNOWLEDGE_HUB_DOCUMENT", + CAREER_EXPLORER: "CAREER_EXPLORER", }; const NotFound: React.FC = () => { @@ -338,6 +342,14 @@ const App = () => { ), }, + { + path: routerPaths.CAREER_EXPLORER, + element: ( + + + + ), + }, { path: "*", element: , diff --git a/frontend-new/src/app/routerPaths.ts b/frontend-new/src/app/routerPaths.ts index 821320ed..feb19020 100644 --- a/frontend-new/src/app/routerPaths.ts +++ b/frontend-new/src/app/routerPaths.ts @@ -8,6 +8,7 @@ export const routerPaths = { VERIFY_EMAIL: "/verify-email", CONSENT: "/consent", SENSITIVE_DATA: "/sensitive-data", + CAREER_EXPLORER: "/career-explorer", KNOWLEDGE_HUB: "/knowledge-hub", KNOWLEDGE_HUB_DOCUMENT: "/knowledge-hub/:documentId", SKILLS_INTERESTS: "/skills-interests", diff --git a/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.stories.tsx b/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.stories.tsx new file mode 100644 index 00000000..6042e8e8 --- /dev/null +++ b/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerExplorerAgentMessage from "src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage"; + +const meta: Meta = { + title: "CareerExplorer/CareerExplorerAgentMessage", + component: CareerExplorerAgentMessage, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + message_id: "msg-1", + message: + "Welcome to the Career Explorer! Based on Zambia's development priorities, there are five key sectors where TEVET graduates are in high demand.", + sent_at: new Date().toISOString(), + }, +}; diff --git a/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.tsx b/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.tsx new file mode 100644 index 00000000..e6a7048a --- /dev/null +++ b/frontend-new/src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { Box, styled } from "@mui/material"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import ChatBubble from "src/chat/chatMessage/components/chatBubble/ChatBubble"; +import Timestamp from "src/chat/chatMessage/components/chatMessageFooter/components/timestamp/Timestamp"; +import ChatMessageFooterLayout from "src/chat/chatMessage/components/chatMessageFooter/ChatMessageFooterLayout"; + +const uniqueId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + +export const DATA_TEST_ID = { + CAREER_EXPLORER_AGENT_MESSAGE_CONTAINER: `career-explorer-agent-message-container-${uniqueId}`, +}; + +export const CAREER_EXPLORER_AGENT_MESSAGE_TYPE = `career-explorer-agent-message-${uniqueId}`; + +export interface CareerExplorerAgentMessageProps { + message_id: string; + message: string; + sent_at: string; +} + +const MessageContainer = styled(Box)<{ origin: ConversationMessageSender }>(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + marginBottom: theme.spacing(theme.tabiyaSpacing.sm), + width: "100%", +})); + +const CareerExplorerAgentMessage: React.FC = ({ message_id, message, sent_at }) => { + return ( + + + + + + + + + + + ); +}; + +export default CareerExplorerAgentMessage; diff --git a/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.stories.tsx b/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.stories.tsx new file mode 100644 index 00000000..26d48ee8 --- /dev/null +++ b/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.stories.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Box } from "@mui/material"; +import CareerExplorerChat from "src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat"; +import type { CareerExplorerMessage } from "src/careerExplorer/types"; + +const getTimestamp = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60 * 1000).toISOString(); + +const mockMessages: CareerExplorerMessage[] = [ + { + message_id: "m1", + message: + "Welcome to the Career Explorer! Based on Zambia's development priorities, there are five key sectors where TEVET graduates are in high demand. Which sector interests you most?", + sent_at: getTimestamp(5), + sender: "AGENT", + }, + { + message_id: "m2", + message: "I'm interested in Agriculture.", + sent_at: getTimestamp(4), + sender: "USER", + }, + { + message_id: "m3", + message: + "Great choice. In Agriculture, commercial farming and agriprocessing offer strong opportunities for TEVET graduates in Zambia.", + sent_at: getTimestamp(3), + sender: "AGENT", + }, +]; + +const meta: Meta = { + title: "CareerExplorer/CareerExplorerChat", + component: CareerExplorerChat, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const WithConversation: Story = { + args: { + initialMessages: mockMessages, + placeholderKey: "careerExplorer.placeholder", + }, +}; + +export const WelcomeOnly: Story = { + args: { + initialMessages: [ + { + message_id: "w1", + message: + "Welcome to the Career Explorer! Based on Zambia's development priorities, there are five key sectors where TEVET graduates are in high demand. Which sector interests you most?", + sent_at: getTimestamp(0), + sender: "AGENT", + }, + ], + placeholderKey: "careerExplorer.placeholder", + }, +}; diff --git a/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.tsx b/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.tsx new file mode 100644 index 00000000..c859e42e --- /dev/null +++ b/frontend-new/src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat.tsx @@ -0,0 +1,100 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Box, useTheme } from "@mui/material"; +import ChatList from "src/chat/chatList/ChatList"; +import ChatMessageField from "src/chat/ChatMessageField/ChatMessageField"; +import { generateSomethingWentWrongMessage, generateUserMessage } from "src/chat/util"; +import type { IChatMessage } from "src/chat/Chat.types"; +import type { TranslationKey } from "src/react-i18next"; +import CareerExplorerService from "src/careerExplorer/services/CareerExplorerService"; +import { generateCareerExplorerTypingMessage } from "src/careerExplorer/components/CareerExplorerTypingMessage/CareerExplorerTypingMessage"; +import { mapCareerExplorerMessagesToChatMessages } from "src/careerExplorer/utils/mapCareerExplorerMessagesToChatMessages"; +import type { CareerExplorerMessage } from "src/careerExplorer/types"; + +export interface CareerExplorerChatProps { + initialMessages: CareerExplorerMessage[]; + placeholderKey: TranslationKey; +} + +const CareerExplorerChat: React.FC = ({ initialMessages, placeholderKey }) => { + const theme = useTheme(); + const [messages, setMessages] = useState[]>(() => + mapCareerExplorerMessagesToChatMessages(initialMessages) + ); + const [aiIsTyping, setAiIsTyping] = useState(false); + const [chatFinished, setChatFinished] = useState(false); + + const typingMessage = useMemo(() => generateCareerExplorerTypingMessage(), []); + + const displayMessages = useMemo(() => { + if (aiIsTyping) { + return [...messages, typingMessage]; + } + return messages; + }, [messages, aiIsTyping, typingMessage]); + + useEffect(() => { + setMessages(mapCareerExplorerMessagesToChatMessages(initialMessages)); + }, [initialMessages]); + + const handleSend = useCallback(async (userMessage: string) => { + const optimisticUserMessage = generateUserMessage( + userMessage, + new Date().toISOString(), + `optimistic-${Date.now()}` + ); + setMessages((prev) => [...prev, optimisticUserMessage]); + setAiIsTyping(true); + try { + const res = await CareerExplorerService.getInstance().sendMessage(userMessage); + setMessages(mapCareerExplorerMessagesToChatMessages(res.messages)); + setChatFinished(res.finished); + } catch (e) { + console.error("Failed to send message", e); + setMessages((prev) => [...prev, generateSomethingWentWrongMessage()]); + } finally { + setAiIsTyping(false); + } + }, []); + + return ( + + + + + + + + + ); +}; + +export default CareerExplorerChat; diff --git a/frontend-new/src/careerExplorer/components/CareerExplorerTypingMessage/CareerExplorerTypingMessage.tsx b/frontend-new/src/careerExplorer/components/CareerExplorerTypingMessage/CareerExplorerTypingMessage.tsx new file mode 100644 index 00000000..a9571bc7 --- /dev/null +++ b/frontend-new/src/careerExplorer/components/CareerExplorerTypingMessage/CareerExplorerTypingMessage.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Box, keyframes, Typography } from "@mui/material"; +import ChatBubble from "src/chat/chatMessage/components/chatBubble/ChatBubble"; +import { MessageContainer } from "src/chat/chatMessage/userChatMessage/UserChatMessage"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import { nanoid } from "nanoid"; +import type { IChatMessage } from "src/chat/Chat.types"; + +const TYPING_KEY = "chat.chatMessage.typingChatMessage.typing"; + +const uniqueId = "b2c3d4e5-f6a7-8901-bcde-f23456789012"; + +export const CAREER_EXPLORER_TYPING_MESSAGE_TYPE = `career-explorer-typing-${uniqueId}`; + +const dotAnimation = keyframes` + 0%, 100% { + transform: translateY(+0.5px); + opacity: 0.5; + } + 50% { + transform: translateY(-1px); + opacity: 1; + } +`; + +export interface CareerExplorerTypingMessageProps { + _?: never; +} + +const CareerExplorerTypingMessage: React.FC = () => { + const { t } = useTranslation(); + const displayText = t(TYPING_KEY); + + return ( + + + + {displayText} + + {[0, 1, 2].map((i) => ( + + . + + ))} + + + + + ); +}; + +export function generateCareerExplorerTypingMessage(): IChatMessage { + return { + type: CAREER_EXPLORER_TYPING_MESSAGE_TYPE, + message_id: nanoid(), + sender: ConversationMessageSender.COMPASS, + payload: {}, + component: (props: CareerExplorerTypingMessageProps) => , + }; +} + +export default CareerExplorerTypingMessage; diff --git a/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.stories.tsx b/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.stories.tsx new file mode 100644 index 00000000..b1acae3d --- /dev/null +++ b/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.stories.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Box } from "@mui/material"; +import CareerExplorerPage from "src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage"; +import CareerExplorerService from "src/careerExplorer/services/CareerExplorerService"; +import authenticationStateService from "src/auth/services/AuthenticationState.service"; + +const getTimestamp = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60 * 1000).toISOString(); + +const meta: Meta = { + title: "CareerExplorer/CareerExplorerPage", + component: CareerExplorerPage, + tags: ["autodocs"], + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const mockResponse = { + messages: [ + { + message_id: "m1", + message: + "Welcome to the Career Explorer! Based on Zambia's development priorities, there are five key sectors where TEVET graduates are in high demand. Which sector interests you most?", + sent_at: getTimestamp(0), + sender: "AGENT" as const, + }, + ], + finished: false, +}; + +export const Default: Story = { + decorators: [ + (Story) => { + authenticationStateService.getInstance().getUser = () => ({ + id: "user-123", + name: "foo", + email: "foo@bar.baz", + }); + const service = CareerExplorerService.getInstance() as ReturnType & { + getOrCreateConversation: typeof CareerExplorerService.prototype.getOrCreateConversation; + sendMessage: typeof CareerExplorerService.prototype.sendMessage; + }; + (service as any).getOrCreateConversation = async () => mockResponse; + (service as any).sendMessage = async (userInput: string) => ({ + ...mockResponse, + messages: [ + ...mockResponse.messages, + { + message_id: "u1", + message: userInput, + sent_at: getTimestamp(0), + sender: "USER" as const, + }, + { + message_id: "a1", + message: "Thanks for your interest. Let me share more about that sector.", + sent_at: getTimestamp(0), + sender: "AGENT" as const, + }, + ], + }); + return ; + }, + ], +}; diff --git a/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.tsx b/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.tsx new file mode 100644 index 00000000..58b5c4a9 --- /dev/null +++ b/frontend-new/src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage.tsx @@ -0,0 +1,89 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Box, Typography, useTheme } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import PageHeader from "src/home/components/PageHeader/PageHeader"; +import { routerPaths } from "src/app/routerPaths"; +import CareerExplorerChat from "src/careerExplorer/components/CareerExplorerChat/CareerExplorerChat"; +import CareerExplorerService from "src/careerExplorer/services/CareerExplorerService"; +import type { CareerExplorerMessage } from "src/careerExplorer/types"; + +const uniqueId = "career-explorer-page-001"; +export const DATA_TEST_ID = { + CONTAINER: `career-explorer-container-${uniqueId}`, + MESSAGE_LIST: `career-explorer-messages-${uniqueId}`, +}; + +const CareerExplorerPage: React.FC = () => { + const theme = useTheme(); + const navigate = useNavigate(); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadOrCreateConversation = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await CareerExplorerService.getInstance().getOrCreateConversation(); + setMessages(res.messages); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadOrCreateConversation(); + }, [loadOrCreateConversation]); + + if (loading && messages.length === 0) { + return ( + + navigate(routerPaths.ROOT)} + /> + + ); + } + + return ( + + navigate(routerPaths.ROOT)} + /> + {error && ( + + + {error} + + + )} + + + + + ); +}; + +export default CareerExplorerPage; diff --git a/frontend-new/src/careerExplorer/services/CareerExplorerService.ts b/frontend-new/src/careerExplorer/services/CareerExplorerService.ts new file mode 100644 index 00000000..0b856377 --- /dev/null +++ b/frontend-new/src/careerExplorer/services/CareerExplorerService.ts @@ -0,0 +1,95 @@ +import { StatusCodes } from "http-status-codes"; +import { customFetch } from "src/utils/customFetch/customFetch"; +import { getBackendUrl } from "src/envService"; +import { getRestAPIErrorFactory } from "src/error/restAPIError/RestAPIError"; +import ErrorConstants from "src/error/restAPIError/RestAPIError.constants"; +import type { CareerExplorerConversationResponse, CareerExplorerConversationInput } from "src/careerExplorer/types"; + +const SERVICE_NAME = "CareerExplorerService"; + +export default class CareerExplorerService { + private static instance: CareerExplorerService; + private readonly baseUrl: string; + + private constructor() { + this.baseUrl = `${getBackendUrl()}/career-explorer`; + } + + static getInstance(): CareerExplorerService { + if (!CareerExplorerService.instance) { + CareerExplorerService.instance = new CareerExplorerService(); + } + return CareerExplorerService.instance; + } + + async getOrCreateConversation(): Promise { + const url = `${this.baseUrl}/conversation`; + const errorFactory = getRestAPIErrorFactory(SERVICE_NAME, "getOrCreateConversation", "POST", url); + const response = await customFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + expectedStatusCode: StatusCodes.CREATED, + serviceName: SERVICE_NAME, + serviceFunction: "getOrCreateConversation", + failureMessage: "Failed to start career explorer conversation", + expectedContentType: "application/json", + }); + const body = await response.text(); + try { + return JSON.parse(body) as CareerExplorerConversationResponse; + } catch (e) { + throw errorFactory(response.status, ErrorConstants.ErrorCodes.INVALID_RESPONSE_BODY, "Invalid JSON", { + responseBody: body, + error: e, + }); + } + } + + async sendMessage(userInput: string): Promise { + const url = `${this.baseUrl}/conversation/messages`; + const errorFactory = getRestAPIErrorFactory(SERVICE_NAME, "sendMessage", "POST", url); + const body: CareerExplorerConversationInput = { user_input: userInput }; + const response = await customFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + expectedStatusCode: StatusCodes.CREATED, + serviceName: SERVICE_NAME, + serviceFunction: "sendMessage", + failureMessage: "Failed to send message", + expectedContentType: "application/json", + }); + const responseBody = await response.text(); + try { + return JSON.parse(responseBody) as CareerExplorerConversationResponse; + } catch (e) { + throw errorFactory(response.status, ErrorConstants.ErrorCodes.INVALID_RESPONSE_BODY, "Invalid JSON", { + responseBody, + error: e, + }); + } + } + + async getConversation(): Promise { + const url = `${this.baseUrl}/conversation`; + const errorFactory = getRestAPIErrorFactory(SERVICE_NAME, "getConversation", "GET", url); + const response = await customFetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + expectedStatusCode: StatusCodes.OK, + serviceName: SERVICE_NAME, + serviceFunction: "getConversation", + failureMessage: "Failed to get conversation", + expectedContentType: "application/json", + }); + const body = await response.text(); + try { + return JSON.parse(body) as CareerExplorerConversationResponse; + } catch (e) { + throw errorFactory(response.status, ErrorConstants.ErrorCodes.INVALID_RESPONSE_BODY, "Invalid JSON", { + responseBody: body, + error: e, + }); + } + } +} diff --git a/frontend-new/src/careerExplorer/types.ts b/frontend-new/src/careerExplorer/types.ts new file mode 100644 index 00000000..08d61f17 --- /dev/null +++ b/frontend-new/src/careerExplorer/types.ts @@ -0,0 +1,17 @@ +export type CareerExplorerMessageSender = "USER" | "AGENT"; + +export interface CareerExplorerMessage { + message_id: string; + message: string; + sent_at: string; + sender: CareerExplorerMessageSender; +} + +export interface CareerExplorerConversationResponse { + messages: CareerExplorerMessage[]; + finished: boolean; +} + +export interface CareerExplorerConversationInput { + user_input: string; +} diff --git a/frontend-new/src/careerExplorer/utils/mapCareerExplorerMessagesToChatMessages.tsx b/frontend-new/src/careerExplorer/utils/mapCareerExplorerMessagesToChatMessages.tsx new file mode 100644 index 00000000..df3f789f --- /dev/null +++ b/frontend-new/src/careerExplorer/utils/mapCareerExplorerMessagesToChatMessages.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import type { IChatMessage } from "src/chat/Chat.types"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import { generateUserMessage } from "src/chat/util"; +import type { CareerExplorerMessage } from "src/careerExplorer/types"; +import CareerExplorerAgentMessage, { + CAREER_EXPLORER_AGENT_MESSAGE_TYPE, + type CareerExplorerAgentMessageProps, +} from "src/careerExplorer/components/CareerExplorerAgentMessage/CareerExplorerAgentMessage"; + +export const mapCareerExplorerMessageToChatMessage = ( + msg: CareerExplorerMessage +): IChatMessage | ReturnType => { + const sentAt = msg.sent_at; + if (msg.sender === "USER") { + return generateUserMessage(msg.message, sentAt, msg.message_id); + } + const payload: CareerExplorerAgentMessageProps = { + message_id: msg.message_id, + message: msg.message, + sent_at: sentAt, + }; + return { + type: CAREER_EXPLORER_AGENT_MESSAGE_TYPE, + message_id: msg.message_id, + sender: ConversationMessageSender.COMPASS, + payload, + component: (p: CareerExplorerAgentMessageProps) => , + }; +}; + +export const mapCareerExplorerMessagesToChatMessages = (messages: CareerExplorerMessage[]): IChatMessage[] => { + return messages.map(mapCareerExplorerMessageToChatMessage); +}; diff --git a/frontend-new/src/chat/ChatMessageField/ChatMessageField.tsx b/frontend-new/src/chat/ChatMessageField/ChatMessageField.tsx index b875416f..1edc2483 100644 --- a/frontend-new/src/chat/ChatMessageField/ChatMessageField.tsx +++ b/frontend-new/src/chat/ChatMessageField/ChatMessageField.tsx @@ -31,6 +31,8 @@ export interface ChatMessageFieldProps { currentPhase?: ConversationPhase; prefillMessage?: string | null; // optional prefill content for the input field cvUploadError?: string | null; // CV upload error message from polling process + placeholderKey?: TranslationKey; // optional override for default placeholder + showCvUpload?: boolean; // when false, hides the plus button and CV upload menu (default true) } const uniqueId = "2a76494f-351d-409d-ba58-e1b2cfaf2a53"; @@ -130,6 +132,7 @@ const ChatMessageField: React.FC = (props) => { const [menuView, setMenuView] = useState<"main" | "cvList">("main"); const isCvUploadEnabled = getCvUploadEnabled().toLowerCase() === "true"; + const showCvUpload = props.showCvUpload !== false; // Show the dot badge whenever in COLLECT_EXPERIENCES and not yet seen useEffect(() => { @@ -424,7 +427,6 @@ const ChatMessageField: React.FC = (props) => { return theme.palette.text.secondary; }, [message, theme.palette.error.main, theme.palette.warning.main, theme.palette.text.secondary]); - // Placeholder text based on the chat status const placeHolder = useMemo(() => { if (props.isChatFinished) { return t(PLACEHOLDER_TEXTS.CHAT_FINISHED); @@ -438,8 +440,8 @@ const ChatMessageField: React.FC = (props) => { if (!isOnline) { return t(PLACEHOLDER_TEXTS.OFFLINE); } - return t(PLACEHOLDER_TEXTS.DEFAULT); - }, [props.aiIsTyping, props.isChatFinished, props.isUploadingCv, isOnline, t]); + return props.placeholderKey ? t(props.placeholderKey) : t(PLACEHOLDER_TEXTS.DEFAULT); + }, [props.aiIsTyping, props.isChatFinished, props.isUploadingCv, props.placeholderKey, isOnline, t]); // Check if the send button should be disabled const sendIsDisabled = useCallback(() => { @@ -547,7 +549,7 @@ const ChatMessageField: React.FC = (props) => { startAdornment: ( - {message.trim().length === 0 && !inputIsDisabled() && isCvUploadEnabled && ( + {message.trim().length === 0 && !inputIsDisabled() && isCvUploadEnabled && showCvUpload && ( Date: Mon, 9 Mar 2026 09:50:36 +0300 Subject: [PATCH 26/42] feat(backend): make sectors configurable - add translations for initial message --- .../app/agent/career_explorer_agent/agent.py | 78 +++++++++++++------ backend/app/app_config.py | 7 ++ backend/app/career_explorer/service.py | 4 +- backend/app/i18n/locales/en-GB/messages.json | 4 + backend/app/i18n/locales/en-US/messages.json | 4 + backend/app/i18n/locales/es-AR/messages.json | 4 + backend/app/i18n/locales/es-ES/messages.json | 4 + backend/app/i18n/locales/sw-KE/messages.json | 4 + backend/app/server.py | 1 + backend/scripts/ingest_sector_document.py | 31 ++++++-- iac/.env.example | 8 ++ iac/backend/__main__.py | 5 ++ iac/backend/deploy_backend.py | 12 +++ iac/templates/env.template | 8 ++ 14 files changed, 140 insertions(+), 34 deletions(-) diff --git a/backend/app/agent/career_explorer_agent/agent.py b/backend/app/agent/career_explorer_agent/agent.py index 87b3e2b6..3df06e60 100644 --- a/backend/app/agent/career_explorer_agent/agent.py +++ b/backend/app/agent/career_explorer_agent/agent.py @@ -8,9 +8,12 @@ from app.agent.prompt_template.locale_style import get_language_style from app.agent.prompt_template.agent_prompt_template import STD_AGENT_CHARACTER from app.agent.simple_llm_agent.llm_response import ModelResponse -from app.agent.simple_llm_agent.prompt_response_template import get_json_response_instructions, get_conversation_finish_instructions +from app.agent.simple_llm_agent.prompt_response_template import get_json_response_instructions, \ + get_conversation_finish_instructions from app.conversation_memory.conversation_formatter import ConversationHistoryFormatter from app.conversation_memory.conversation_memory_manager import ConversationContext +from app.app_config import get_application_config +from app.i18n.translation_service import t from common_libs.llm.generative_models import GeminiGenerativeLLM from common_libs.llm.models_utils import LLMConfig, LOW_TEMPERATURE_GENERATION_CONFIG, JSON_GENERATION_CONFIG from common_libs.llm.schema_builder import with_response_schema @@ -18,18 +21,32 @@ from .sector_search_service import SectorChunkEntity, SectorSearchService -WELCOME_MESSAGE = """Welcome to the Career Explorer! Based on Zambia's development priorities, there are five key sectors where TEVET graduates are in high demand: - -Energy — Power generation, solar, renewables -Mining — Copper, gold, gemstones -Agriculture — Commercial farming, agriprocessing -Hospitality — Hotels, tourism, safari lodges -Water — Treatment, supply, sanitation - -Which sector interests you most?""" +def _get_sectors_list() -> str: + config = get_application_config() + sectors = config.career_explorer_sectors + if not sectors: + return "" + return "\n".join(f"{s['name']} — {s.get('description', '')}" for s in sectors) + + +def _get_welcome_message() -> str: + config = get_application_config() + sectors = config.career_explorer_sectors + sectors_list = _get_sectors_list() + return t( + "messages", + "careerExplorer.welcomeMessage", + "Welcome to the Career Explorer! Based on Zambia's development priorities, there are {sector_count} key sectors where TEVET graduates are in high demand:\n\n{sectors_list}\n\nWhich sector interests you most?", + sector_count=len(sectors) if sectors else 0, + sectors_list=sectors_list, + ) -def _build_base_instructions() -> str: +def _build_base_instructions(retrieved_content: str) -> str: + config = get_application_config() + sectors = config.career_explorer_sectors + sector_names = [s["name"] for s in sectors] if sectors else [] + sector_list_str = ", ".join(sector_names) if sector_names else "the priority sectors" language_style = get_language_style(with_locale=True) finish_instructions = get_conversation_finish_instructions( "When the user explicitly indicates they are done or want to exit" @@ -45,9 +62,9 @@ def _build_base_instructions() -> str: {STD_AGENT_CHARACTER} # Instructions - - Start by suggesting the five priority sectors and ask which interests the user most + - Start by suggesting the priority sectors ({sector_list_str}) and ask which interests the user most - Answer questions based ONLY on the retrieved content provided below - - Stay on topic: focus on the five sectors (Energy, Mining, Agriculture, Hospitality, Water) + - Stay on topic: focus on the priority sectors ({sector_list_str}) - If asked about something outside this scope, politely redirect to the priority sectors - Be encouraging and conversational - Do not invent information not in the provided content @@ -59,12 +76,16 @@ def _build_base_instructions() -> str: {finish_instructions} - """) + """).format(sector_list_str=sector_list_str, retrieved_content=retrieved_content) def _format_chunks(chunks: list[SectorChunkEntity]) -> str: if not chunks: - return "(No relevant content found. Suggest the user pick a sector or ask a question related to the five priority sectors.)" + config = get_application_config() + sectors = config.career_explorer_sectors + sector_names = [s["name"] for s in sectors] if sectors else [] + sector_list_str = ", ".join(sector_names) if sector_names else "the priority sectors" + return f"(No relevant content found. Suggest the user pick a sector or ask a question related to {sector_list_str}.)" parts = [] for c in chunks: parts.append(f"[{c.sector}]\n{c.text}") @@ -73,10 +94,10 @@ def _format_chunks(chunks: list[SectorChunkEntity]) -> str: def _construct_output( message: str, - finished: bool, - agent_start: float, - reasoning: str = "", - llm_stats: list[LLMStats] | None = None, + finished: bool, + agent_start: float, + reasoning: str = "", + llm_stats: list[LLMStats] | None = None, ) -> AgentOutput: return AgentOutputWithReasoning( message_for_user=message, @@ -95,22 +116,24 @@ def __init__(self, sector_search_service: SectorSearchService): is_responsible_for_conversation_history=False, ) self._sector_search = sector_search_service - self._base_instructions_template = _build_base_instructions() self._llm_config = LLMConfig( generation_config=LOW_TEMPERATURE_GENERATION_CONFIG - | JSON_GENERATION_CONFIG - | with_response_schema(ModelResponse) + | JSON_GENERATION_CONFIG + | with_response_schema(ModelResponse) ) self._llm_caller = LLMCaller[ModelResponse](model_response_type=ModelResponse) self._logger = logging.getLogger(self.__class__.__name__) + def _get_base_instructions(self, retrieved_content: str) -> str: + return _build_base_instructions(retrieved_content) + async def execute(self, user_input: AgentInput, context: ConversationContext) -> AgentOutput: agent_start = time.time() msg = (user_input.message or "").strip() if msg in ("", "(silence)"): return _construct_output( - WELCOME_MESSAGE, + _get_welcome_message(), finished=False, agent_start=agent_start, ) @@ -131,7 +154,7 @@ async def execute(self, user_input: AgentInput, context: ConversationContext) -> preview.replace("\n", " "), ) retrieved = _format_chunks(chunks) - full_instructions = self._base_instructions_template.format(retrieved_content=retrieved) + full_instructions = self._get_base_instructions(retrieved_content=retrieved) llm = GeminiGenerativeLLM(system_instructions=full_instructions, config=self._llm_config) model_response, llm_stats = await self._llm_caller.call_llm( @@ -145,9 +168,14 @@ async def execute(self, user_input: AgentInput, context: ConversationContext) -> ) if model_response is None: + error_msg = t( + "messages", + "careerExplorer.errorRetry", + "I'm having trouble right now. Could you try again?", + ) model_response = ModelResponse( reasoning="Error", - message="I'm having trouble right now. Could you try again?", + message=error_msg, finished=False, ) diff --git a/backend/app/app_config.py b/backend/app/app_config.py index f9a2e369..86e2cd00 100644 --- a/backend/app/app_config.py +++ b/backend/app/app_config.py @@ -109,6 +109,13 @@ class ApplicationConfig(BaseModel): Corresponds to the COMPASS_INLINE_PHASE_TRANSITION environment variable. """ + career_explorer_sectors: list[dict[str, str]] = Field(default_factory=list) + """ + Configuration for Career Explorer priority sectors. + Each sector dict should have: name (display name), description (brief description), file (markdown filename). + Example: [{"name": "Agriculture", "description": "Commercial farming, agriprocessing", "file": "agriculture.md"}] + """ + @model_validator(mode='after') def check_cv_upload_configurations(self) -> "ApplicationConfig": # Check that the CV upload configurations are valid. diff --git a/backend/app/career_explorer/service.py b/backend/app/career_explorer/service.py index 118f6bbf..62d9b918 100644 --- a/backend/app/career_explorer/service.py +++ b/backend/app/career_explorer/service.py @@ -14,7 +14,7 @@ CareerExplorerMessage, CareerExplorerMessageSender, ) -from app.agent.career_explorer_agent.agent import WELCOME_MESSAGE +from app.agent.career_explorer_agent.agent import _get_welcome_message from app.career_explorer.context_builder import build_windowed_context class CareerExplorerService: @@ -38,7 +38,7 @@ async def get_or_create_conversation(self, user_id: str) -> CareerExplorerConver now = datetime.now(timezone.utc) intro = CareerExplorerMessage( message_id=str(ObjectId()), - message=WELCOME_MESSAGE, + message=_get_welcome_message(), sent_at=now, sender=CareerExplorerMessageSender.AGENT, ) diff --git a/backend/app/i18n/locales/en-GB/messages.json b/backend/app/i18n/locales/en-GB/messages.json index 6206aee7..227d1ca1 100644 --- a/backend/app/i18n/locales/en-GB/messages.json +++ b/backend/app/i18n/locales/en-GB/messages.json @@ -43,6 +43,10 @@ "allAgentsDone": "Conversation finished, all agents are done!", "forcefullyEnded": "Conversation forcefully ended" }, + "careerExplorer": { + "welcomeMessage": "Welcome to the Career Explorer! Based on Zambia's development priorities, there are {sector_count} key sectors where TEVET graduates are in high demand:\n\n{sectors_list}\n\nWhich sector interests you most?", + "errorRetry": "I'm having trouble right now. Could you try again?" + }, "experience": { "until": "until {end_date}", "noTitleProvidedYet": "No title provided yet", diff --git a/backend/app/i18n/locales/en-US/messages.json b/backend/app/i18n/locales/en-US/messages.json index 63f8c887..8d89b044 100644 --- a/backend/app/i18n/locales/en-US/messages.json +++ b/backend/app/i18n/locales/en-US/messages.json @@ -43,6 +43,10 @@ "allAgentsDone": "Conversation finished, all agents are done!", "forcefullyEnded": "Conversation forcefully ended" }, + "careerExplorer": { + "welcomeMessage": "Welcome to the Career Explorer! Based on Zambia's development priorities, there are {sector_count} key sectors where TEVET graduates are in high demand:\n\n{sectors_list}\n\nWhich sector interests you most?", + "errorRetry": "I'm having trouble right now. Could you try again?" + }, "experience": { "until": "until {end_date}", "noTitleProvidedYet": "No title provided yet", diff --git a/backend/app/i18n/locales/es-AR/messages.json b/backend/app/i18n/locales/es-AR/messages.json index 53e132ea..73c73986 100644 --- a/backend/app/i18n/locales/es-AR/messages.json +++ b/backend/app/i18n/locales/es-AR/messages.json @@ -43,6 +43,10 @@ "allAgentsDone": "¡Conversación finalizada, todos los agentes terminaron!", "forcefullyEnded": "La conversación se terminó de forma forzada" }, + "careerExplorer": { + "welcomeMessage": "¡Bienvenido al Explorador de Carreras! Según las prioridades de desarrollo de Zambia, hay {sector_count} sectores clave donde los graduados de TEVET tienen alta demanda:\n\n{sectors_list}\n\n¿Qué sector te interesa más?", + "errorRetry": "Estoy teniendo problemas ahora. ¿Podés intentar de nuevo?" + }, "experience": { "until": "hasta {end_date}", "noTitleProvidedYet": "Sin título aún", diff --git a/backend/app/i18n/locales/es-ES/messages.json b/backend/app/i18n/locales/es-ES/messages.json index 6d746f15..5cc2c2a4 100644 --- a/backend/app/i18n/locales/es-ES/messages.json +++ b/backend/app/i18n/locales/es-ES/messages.json @@ -43,6 +43,10 @@ "allAgentsDone": "¡Conversación finalizada, todos los agentes han terminado!", "forcefullyEnded": "La conversación se terminó de manera forzada" }, + "careerExplorer": { + "welcomeMessage": "¡Bienvenido al Explorador de Carreras! Según las prioridades de desarrollo de Zambia, hay {sector_count} sectores clave donde los graduados de TEVET tienen alta demanda:\n\n{sectors_list}\n\n¿Qué sector te interesa más?", + "errorRetry": "Estoy teniendo problemas ahora. ¿Podrías intentar de nuevo?" + }, "experience": { "until": "hasta {end_date}", "noTitleProvidedYet": "Sin título aún", diff --git a/backend/app/i18n/locales/sw-KE/messages.json b/backend/app/i18n/locales/sw-KE/messages.json index 16964641..28341093 100644 --- a/backend/app/i18n/locales/sw-KE/messages.json +++ b/backend/app/i18n/locales/sw-KE/messages.json @@ -43,6 +43,10 @@ "allAgentsDone": "Mazungumzo yamekamilika, mawakala wote wamemaliza!", "forcefullyEnded": "Mazungumzo yameisha kwa nguvu" }, + "careerExplorer": { + "welcomeMessage": "Karibu kwenye Kuchunguza Kazi! Kulingana na vipaumbele vya maendeleo vya Zambia, kuna sekta {sector_count} muhimu ambazo wahitimu wa TEVET wana mahitaji makubwa:\n\n{sectors_list}\n\nSekta gani inakuvutia zaidi?", + "errorRetry": "Nina matatizo kwa sasa. Unaweza kujaribu tena?" + }, "experience": { "until": "hadi {end_date}", "noTitleProvidedYet": "Jina la kazi bado halijatolewa", diff --git a/backend/app/server.py b/backend/app/server.py index 5ca1f758..1581b60b 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -240,6 +240,7 @@ def setup_sentry(): matching_service_url=os.getenv("MATCHING_SERVICE_URL"), matching_service_api_key=os.getenv("MATCHING_SERVICE_API_KEY"), inline_phase_transition=os.getenv("COMPASS_INLINE_PHASE_TRANSITION", "").lower() in ("1", "true"), + career_explorer_sectors=json.loads(os.getenv("CAREER_EXPLORER_SECTORS", "[]")), ) set_application_config(application_config) diff --git a/backend/scripts/ingest_sector_document.py b/backend/scripts/ingest_sector_document.py index d4cace65..714225e3 100644 --- a/backend/scripts/ingest_sector_document.py +++ b/backend/scripts/ingest_sector_document.py @@ -23,6 +23,7 @@ """ import argparse import asyncio +import json import logging import os import re @@ -43,6 +44,14 @@ EMBEDDING_KEY = "embedding" MARKDOWN_EXTENSIONS = {".md"} +DEFAULT_SECTORS_CONFIG = [ + {"name": "Agriculture", "description": "Commercial farming, agriprocessing", "file": "agriculture.md"}, + {"name": "Energy", "description": "Power generation, solar, renewables", "file": "energy.md"}, + {"name": "Mining", "description": "Copper, gold, gemstones", "file": "mining.md"}, + {"name": "Hospitality", "description": "Hotels, tourism, safari lodges", "file": "hospitality.md"}, + {"name": "Water", "description": "Treatment, supply, sanitation", "file": "water.md"}, +] + def load_document_text(file_path: Path) -> str: if not file_path.exists(): @@ -193,13 +202,21 @@ async def main(): if args.ingest_all: base_path = Path(__file__).parent.parent.parent / "frontend-new" / "src" / "knowledgeHub" / "documents" - sector_files = { - "Agriculture": base_path / "agriculture.md", - "Energy": base_path / "energy.md", - "Mining": base_path / "mining.md", - "Hospitality": base_path / "hospitality.md", - "Water": base_path / "water.md", - } + sectors_config_json = os.getenv("CAREER_EXPLORER_SECTORS", "[]") + try: + sectors_config = json.loads(sectors_config_json) + except json.JSONDecodeError: + logger.warning("Invalid CAREER_EXPLORER_SECTORS JSON, falling back to default sectors") + sectors_config = DEFAULT_SECTORS_CONFIG + + sector_files = {} + for sector in sectors_config: + sector_name = sector.get("name") + sector_file = sector.get("file") + if sector_name and sector_file: + sector_files[sector_name] = base_path / sector_file + else: + logger.warning("Skipping invalid sector config entry: %s", sector) if args.clear_first and args.hot_run: deleted = await collection.delete_many({}) diff --git a/iac/.env.example b/iac/.env.example index 42c8f73e..dda40c07 100644 --- a/iac/.env.example +++ b/iac/.env.example @@ -25,12 +25,20 @@ BACKEND_ENABLE_METRICS= USERDATA_MONGODB_URI= USERDATA_DATABASE_NAME= +# Career Explorer database settings +CAREER_EXPLORER_MONGODB_URI= +CAREER_EXPLORER_DATABASE_NAME= + # Google Cloud Platform settings VERTEX_API_REGION= # Currently only supports google-vertex-ai EMBEDDINGS_SERVICE_NAME=GOOGLE-VERTEX-AI EMBEDDINGS_MODEL_NAME= +# Career Explorer sectors configuration (optional JSON array) +# Example: [{"name": "Agriculture", "description": "Commercial farming, agriprocessing", "file": "agriculture.md"}] +CAREER_EXPLORER_SECTORS= + # Sentry settings BACKEND_SENTRY_DSN= BACKEND_ENABLE_SENTRY= diff --git a/iac/backend/__main__.py b/iac/backend/__main__.py index 71722ead..2079d505 100644 --- a/iac/backend/__main__.py +++ b/iac/backend/__main__.py @@ -53,6 +53,8 @@ def main(): metrics_database_name=getenv("METRICS_DATABASE_NAME"), userdata_database_name=getenv("USERDATA_DATABASE_NAME"), userdata_mongodb_uri=getenv("USERDATA_MONGODB_URI", True), + career_explorer_mongodb_uri=getenv("CAREER_EXPLORER_MONGODB_URI", True), + career_explorer_database_name=getenv("CAREER_EXPLORER_DATABASE_NAME"), vertex_api_region=getenv("VERTEX_API_REGION", True), embeddings_service_name=getenv("EMBEDDINGS_SERVICE_NAME"), embeddings_model_name=getenv("EMBEDDINGS_MODEL_NAME"), @@ -93,6 +95,9 @@ def main(): # Phase transition behavior (optional) inline_phase_transition=getenv("COMPASS_INLINE_PHASE_TRANSITION", False, False), + + # Career Explorer sectors configuration (optional) + career_explorer_sectors=getenv("CAREER_EXPLORER_SECTORS", False, False), ) # version of the artifacts to deploy diff --git a/iac/backend/deploy_backend.py b/iac/backend/deploy_backend.py index 75d6cb80..5b5680dc 100644 --- a/iac/backend/deploy_backend.py +++ b/iac/backend/deploy_backend.py @@ -30,6 +30,8 @@ class BackendServiceConfig: metrics_database_name: str userdata_mongodb_uri: str userdata_database_name: str + career_explorer_mongodb_uri: str + career_explorer_database_name: str vertex_api_region: str embeddings_service_name: str embeddings_model_name: str @@ -60,6 +62,7 @@ class BackendServiceConfig: matching_service_url: Optional[str] matching_service_api_key: Optional[str] inline_phase_transition: Optional[str] + career_explorer_sectors: Optional[str] """ @@ -362,6 +365,12 @@ def _deploy_cloud_run_service( gcp.cloudrunv2.ServiceTemplateContainerEnvArgs( name="USERDATA_DATABASE_NAME", value=backend_service_cfg.userdata_database_name), + gcp.cloudrunv2.ServiceTemplateContainerEnvArgs( + name="CAREER_EXPLORER_MONGODB_URI", + value=backend_service_cfg.career_explorer_mongodb_uri), + gcp.cloudrunv2.ServiceTemplateContainerEnvArgs( + name="CAREER_EXPLORER_DATABASE_NAME", + value=backend_service_cfg.career_explorer_database_name), gcp.cloudrunv2.ServiceTemplateContainerEnvArgs( name="VERTEX_API_REGION", value=backend_service_cfg.vertex_api_region), @@ -432,6 +441,9 @@ def _deploy_cloud_run_service( gcp.cloudrunv2.ServiceTemplateContainerEnvArgs( name="COMPASS_INLINE_PHASE_TRANSITION", value=backend_service_cfg.inline_phase_transition), + gcp.cloudrunv2.ServiceTemplateContainerEnvArgs( + name="CAREER_EXPLORER_SECTORS", + value=backend_service_cfg.career_explorer_sectors), # Add more environment variables here ], ) diff --git a/iac/templates/env.template b/iac/templates/env.template index 537c5485..ed156995 100644 --- a/iac/templates/env.template +++ b/iac/templates/env.template @@ -25,6 +25,10 @@ BACKEND_ENABLE_METRICS= USERDATA_MONGODB_URI= USERDATA_DATABASE_NAME= +# Career Explorer database settings +CAREER_EXPLORER_MONGODB_URI= +CAREER_EXPLORER_DATABASE_NAME= + # Google Cloud Platform settings VERTEX_API_REGION= EMBEDDINGS_SERVICE_NAME=/^GOOGLE-VERTEX-AI$/ @@ -44,6 +48,10 @@ BACKEND_FEATURES=/.*/ # JSON like configuration for the experience pipeline BACKEND_EXPERIENCE_PIPELINE_CONFIG=/.*/ +# Career Explorer sectors configuration (optional JSON array) +# Example: [{"name": "Agriculture", "description": "Commercial farming, agriprocessing", "file": "agriculture.md"}] +CAREER_EXPLORER_SECTORS=/.*/ + # CV limits (optional; digits) BACKEND_CV_MAX_UPLOADS_PER_USER=/^[0-9]*$/ BACKEND_CV_RATE_LIMIT_PER_MINUTE=/^[0-9]*$/ From 391e42398a9943aa6326617cb27b20ab94417254 Mon Sep 17 00:00:00 2001 From: nraffa Date: Mon, 9 Mar 2026 14:54:31 -0300 Subject: [PATCH 27/42] feat(career-readiness): add entrepreneurship module and update module count in tests --- .../modules/entrepreneurship.md | 294 ++++++++++++++++++ .../career_readiness/test_module_loader.py | 4 +- 2 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 backend/app/career_readiness/modules/entrepreneurship.md diff --git a/backend/app/career_readiness/modules/entrepreneurship.md b/backend/app/career_readiness/modules/entrepreneurship.md new file mode 100644 index 00000000..5d6b6b56 --- /dev/null +++ b/backend/app/career_readiness/modules/entrepreneurship.md @@ -0,0 +1,294 @@ +--- +id: entrepreneurship +title: Entrepreneurship & Enterprise Development +description: Learn how to develop an entrepreneurial mindset, start and manage a business, design products, and pitch your ideas. +icon: entrepreneurship +sort_order: 6 +input_placeholder: Ask about starting a business... +topics: Developing an Entrepreneurial Mindset, Establishing an Enterprise, Managing an Enterprise, Designing a Product and Production Process, Business Pitching +--- + +# Entrepreneurship & Enterprise Development + +## Overview + +This module is based on the TEVETA-approved Entrepreneurship and Enterprise Development for Startups (EEDS) syllabus. It covers the essential knowledge and skills you need to start, run, and grow your own business in Zambia. Whether you have a business idea already or are just exploring the possibility, this module will guide you through the key steps. + +## Developing an Entrepreneurial Mindset + +### What is Entrepreneurship? + +Entrepreneurship is the process of identifying opportunities and creating value by starting and running a business. An entrepreneur takes on risk in exchange for the potential of building something meaningful and profitable. + +### Entrepreneurial Identity and Values + +Before starting a business, it helps to understand yourself: +- **Self-awareness**: Know your strengths, weaknesses, and what motivates you +- **Values**: What matters to you? Honesty, community impact, innovation? Your values shape how you run your business +- **Vision**: Have a clear picture of what you want to achieve + +### Key Entrepreneurial Competencies + +Successful entrepreneurs tend to share certain traits: +- **Creativity and innovation** — seeing problems as opportunities +- **Resilience** — bouncing back from setbacks and learning from failure +- **Initiative** — taking action without waiting to be told +- **Risk management** — taking calculated risks, not reckless ones +- **Leadership** — inspiring and guiding others toward a shared goal + +### Innovation and Opportunity Recognition + +Innovation does not always mean inventing something new. It can mean: +- Improving an existing product or service +- Delivering something in a more convenient way +- Serving an underserved market or community +- Combining existing ideas in a new way + +Look around your community — what problems do people face? What needs are not being met? These are potential business opportunities. + +## Establishing an Enterprise + +### Generating Business Ideas + +Good business ideas often come from: +- **Personal experience** — problems you have faced yourself +- **Community needs** — gaps in local products or services +- **Skills and hobbies** — things you are good at or enjoy doing +- **Market trends** — growing demand in certain sectors + +### Evaluating Opportunities + +Not every idea is a good business opportunity. Evaluate your ideas by asking: +- Is there real demand for this product or service? +- Can I reach the customers who need it? +- Can I deliver it at a price customers will pay, while still making a profit? +- What makes my offering different from what already exists? + +### Business Models + +A business model describes how your enterprise creates, delivers, and captures value. Key elements include: +- **Value proposition** — what problem you solve and why customers should choose you +- **Customer segments** — who your customers are +- **Revenue streams** — how you will earn money +- **Cost structure** — what it costs to run the business +- **Key resources and activities** — what you need and what you do + +### Legal Requirements in Zambia + +To operate legally, you may need to: +- Register your business with PACRA (Patents and Companies Registration Agency) +- Obtain a tax identification number from ZRA (Zambia Revenue Authority) +- Get relevant licences and permits for your industry +- Comply with local council regulations +- Register for NAPSA (National Pension Scheme Authority) if you have employees + +## Managing an Enterprise + +### Resource Management + +Effective management means making the best use of your limited resources: +- **Human resources** — hiring the right people, defining roles clearly, and treating employees fairly +- **Financial resources** — tracking income and expenses carefully +- **Physical resources** — equipment, inventory, and workspace +- **Time** — prioritizing tasks and avoiding distractions + +### Financial Management Basics + +Every business owner needs to understand: +- **Record-keeping** — keep accurate records of all transactions (sales, purchases, expenses) +- **Budgeting** — plan your spending in advance and stick to it +- **Cash flow** — make sure money coming in covers money going out +- **Separating business and personal finances** — do not mix them + +### Budgeting and Financial Planning + +A simple budget includes: +1. **Expected income** — realistic estimates of what you will earn +2. **Fixed costs** — rent, salaries, loan repayments (costs that stay the same) +3. **Variable costs** — materials, transport, utilities (costs that change) +4. **Savings and reinvestment** — money set aside for growth and emergencies + +### Ethics and Anti-Corruption + +Running an ethical business builds trust and long-term success: +- Be honest with customers, suppliers, and employees +- Avoid paying or accepting bribes +- Report corruption when you encounter it +- Follow fair pricing and fair labour practices +- Pay your taxes + +### Building Business Networks + +No business succeeds in isolation. Build relationships with: +- Other entrepreneurs — share knowledge and support each other +- Mentors — learn from those with more experience +- Suppliers — negotiate fair terms and reliable delivery +- Customers — listen to their feedback and build loyalty +- Industry associations and cooperatives — access resources and opportunities + +## Designing a Product and Production Process + +### Understanding the Value Chain + +The value chain covers all the steps from raw materials to the final customer: +1. **Sourcing** — where you get your materials or inputs +2. **Production** — how you make or prepare your product or service +3. **Distribution** — how you get it to the customer +4. **After-sales** — support and follow-up with customers + +Understanding each step helps you find ways to reduce costs and improve quality. + +### Identifying Customer Needs + +Before designing your product: +- Talk to potential customers about their needs and preferences +- Observe how they currently solve the problem your product addresses +- Test your ideas with a small group before investing heavily +- Be willing to adapt based on feedback + +### Prototyping and Testing + +A prototype is an early version of your product. It does not need to be perfect: +- Start with a simple version that demonstrates the core idea +- Get feedback from real users +- Improve based on what you learn +- Repeat the process until the product meets customer needs + +### Product Lifecycle + +Products go through stages: +1. **Introduction** — launching the product, building awareness +2. **Growth** — increasing sales, expanding your customer base +3. **Maturity** — sales stabilize, competition increases +4. **Decline** — demand drops, time to innovate or pivot + +Plan for each stage so you are not caught off guard. + +### Costing and Pricing + +To set the right price: +- **Calculate your costs** — materials, labour, overhead, transport +- **Add your profit margin** — the amount you need to earn above costs +- **Research the market** — what are competitors charging? +- **Consider your customers** — what are they willing and able to pay? + +Common pricing mistakes to avoid: +- Pricing too low to attract customers (you may not cover costs) +- Ignoring hidden costs like transport, packaging, or your own time +- Not adjusting prices as costs change + +## Business Pitching + +### What is a Business Pitch? + +A business pitch is a short, persuasive presentation that explains your business idea to potential investors, partners, or customers. A good pitch tells a compelling story about the problem you solve and why your solution works. + +### Building a Pitch Deck + +A pitch deck typically includes: +1. **The problem** — what issue are you addressing? +2. **Your solution** — how does your product or service solve it? +3. **Target market** — who are your customers and how big is the opportunity? +4. **Business model** — how will you make money? +5. **Traction** — what progress have you made so far? +6. **The team** — who is involved and what makes you capable? +7. **The ask** — what do you need (funding, partnership, support)? + +### Market Segmentation and Customer Discovery + +Understand your market by breaking it into segments: +- **Demographics** — age, gender, income level, location +- **Needs** — what specific problem does each group face? +- **Behaviour** — how do they currently spend their money? + +Customer discovery means going out and talking to real people to validate your assumptions. Do not assume you know what customers want — ask them. + +### Financial Projections for Your Pitch + +Investors want to see realistic numbers: +- Projected revenue for the first 1-3 years +- Expected costs and when you expect to break even +- How much funding you need and how you will use it +- Your plan for becoming profitable + +### Tips for a Successful Pitch + +- Keep it short and focused — aim for 5-10 minutes +- Tell a story — make it memorable and relatable +- Know your numbers — be prepared to answer financial questions +- Practice — rehearse until you are confident and natural +- Be honest — do not exaggerate or make promises you cannot keep +- Show passion — investors back people as much as ideas + +## Quiz +pass_threshold: 0.7 + +1. Which of the following best describes an entrepreneurial mindset? +A. Avoiding all risk to stay safe +B. Seeing problems as opportunities and taking initiative to create value +C. Waiting for someone to give you a business idea +D. Only working for established companies +Answer: B + +2. What is the first thing you should do when evaluating a business idea? +A. Immediately register the business +B. Determine whether there is real demand for your product or service +C. Design a logo and brand name +D. Hire employees +Answer: B + +3. What does PACRA stand for in Zambia? +A. People's Association for Community Resource Allocation +B. Patents and Companies Registration Agency +C. Provincial Agency for Corporate Regulatory Affairs +D. Public Authority for Commerce and Revenue Administration +Answer: B + +4. Why is it important to separate business and personal finances? +A. It is not important — mixing them saves time +B. It helps you accurately track business performance and manage cash flow +C. Banks require it by law for all businesses +D. It makes your business look bigger than it is +Answer: B + +5. What is a value chain? +A. A chain of shops in a shopping centre +B. The steps from sourcing materials to delivering the final product to the customer +C. A list of your most valuable customers +D. The price history of your product +Answer: B + +6. What is the main purpose of creating a prototype? +A. To create a perfect final product immediately +B. To test your core idea, get feedback, and improve before investing heavily +C. To impress investors with a finished product +D. To avoid talking to customers +Answer: B + +7. Which of the following is a common pricing mistake? +A. Researching what competitors charge +B. Setting prices too low and failing to cover your costs +C. Calculating all your costs before setting a price +D. Considering what customers are willing to pay +Answer: B + +8. What is the purpose of a business pitch? +A. To entertain an audience with a long presentation +B. To persuasively explain your business idea to investors, partners, or customers +C. To show off your technical skills +D. To avoid having to write a business plan +Answer: B + +9. What does customer discovery involve? +A. Guessing what customers want based on your own preferences +B. Going out and talking to real people to validate your business assumptions +C. Reading only online reviews of competitor products +D. Hiring a marketing agency to decide for you +Answer: B + +10. Which of the following is good advice for managing a business ethically? +A. Pay bribes to speed up permits and licences +B. Be honest with customers, avoid corruption, and follow fair labour practices +C. Hide some income to reduce your tax bill +D. Charge different prices based on how wealthy a customer looks +Answer: B diff --git a/backend/app/career_readiness/test_module_loader.py b/backend/app/career_readiness/test_module_loader.py index 1a8abc63..056251ca 100644 --- a/backend/app/career_readiness/test_module_loader.py +++ b/backend/app/career_readiness/test_module_loader.py @@ -252,9 +252,9 @@ def test_loads_all_real_modules(self): # WHEN a registry is created with the default path actual_registry = ModuleRegistry() - # THEN all 5 modules are loaded + # THEN all 6 modules are loaded actual_modules = actual_registry.get_all_modules() - assert len(actual_modules) == 5 + assert len(actual_modules) == 6 # AND they are sorted by sort_order actual_orders = [m.sort_order for m in actual_modules] assert actual_orders == sorted(actual_orders) From 6a3476cae0d193b5d6e4f5f2f7c5a2aad3461da4 Mon Sep 17 00:00:00 2001 From: Fides Date: Thu, 5 Mar 2026 10:48:52 +0200 Subject: [PATCH 28/42] feat(frontend): add career readiness modules --- frontend-new/src/app/index.tsx | 25 +++ frontend-new/src/app/routerPaths.ts | 2 + .../CareerReadinessAgentMessage.stories.tsx | 57 +++++ .../CareerReadinessAgentMessage.tsx | 57 +++++ .../CareerReadinessChat.stories.tsx | 122 ++++++++++ .../CareerReadinessChat.tsx | 151 +++++++++++++ .../CareerReadinessModuleCard.stories.tsx | 59 +++++ .../CareerReadinessModuleCard.tsx | 211 ++++++++++++++++++ ...eerReadinessModuleCardSkeleton.stories.tsx | 21 ++ .../CareerReadinessModuleCardSkeleton.tsx | 83 +++++++ .../CareerReadinessTypingMessage.tsx | 73 ++++++ .../CareerReadinessList.stories.tsx | 87 ++++++++ .../CareerReadinessList.tsx | 135 +++++++++++ .../CareerReadinessModule.stories.tsx | 165 ++++++++++++++ .../CareerReadinessModule.tsx | 105 +++++++++ .../services/CareerReadinessService.ts | 160 +++++++++++++ frontend-new/src/careerReadiness/types.ts | 55 +++++ ...pCareerReadinessMessagesToChatMessages.tsx | 39 ++++ .../ChatMessageField/ChatMessageField.tsx | 11 +- frontend-new/src/home/modulesService.ts | 6 +- .../src/i18n/locales/en-GB/translation.json | 23 +- .../src/i18n/locales/en-US/translation.json | 23 +- .../src/i18n/locales/es-AR/translation.json | 23 +- .../src/i18n/locales/es-ES/translation.json | 23 +- .../src/i18n/locales/ny-ZM/translation.json | 23 +- .../src/i18n/locales/sw-KE/translation.json | 23 +- 26 files changed, 1721 insertions(+), 41 deletions(-) create mode 100644 frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.stories.tsx create mode 100644 frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.tsx create mode 100644 frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.stories.tsx create mode 100644 frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.tsx create mode 100644 frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.stories.tsx create mode 100644 frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.tsx create mode 100644 frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.stories.tsx create mode 100644 frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.tsx create mode 100644 frontend-new/src/careerReadiness/components/CareerReadinessTypingMessage/CareerReadinessTypingMessage.tsx create mode 100644 frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.stories.tsx create mode 100644 frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.tsx create mode 100644 frontend-new/src/careerReadiness/pages/CareerReadinessModule/CareerReadinessModule.stories.tsx create mode 100644 frontend-new/src/careerReadiness/pages/CareerReadinessModule/CareerReadinessModule.tsx create mode 100644 frontend-new/src/careerReadiness/services/CareerReadinessService.ts create mode 100644 frontend-new/src/careerReadiness/types.ts create mode 100644 frontend-new/src/careerReadiness/utils/mapCareerReadinessMessagesToChatMessages.tsx diff --git a/frontend-new/src/app/index.tsx b/frontend-new/src/app/index.tsx index 52adb800..dd0bd56e 100644 --- a/frontend-new/src/app/index.tsx +++ b/frontend-new/src/app/index.tsx @@ -37,6 +37,13 @@ const LazyLoadedCareerExplorer = lazyWithPreload( () => import("src/careerExplorer/pages/CareerExplorerPage/CareerExplorerPage") ); +const LazyLoadedCareerReadinessList = lazyWithPreload( + () => import("src/careerReadiness/pages/CareerReadinessList/CareerReadinessList") +); +const LazyLoadedCareerReadinessModule = lazyWithPreload( + () => import("src/careerReadiness/pages/CareerReadinessModule/CareerReadinessModule") +); + // Wrap the createHashRouter function with Sentry to capture errors that occur during router initialization const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createHashRouter); @@ -59,6 +66,8 @@ const ProtectedRouteKeys = { KNOWLEDGE_HUB: "KNOWLEDGE_HUB", KNOWLEDGE_HUB_DOCUMENT: "KNOWLEDGE_HUB_DOCUMENT", CAREER_EXPLORER: "CAREER_EXPLORER", + CAREER_READINESS: "CAREER_READINESS", + CAREER_READINESS_MODULE: "CAREER_READINESS_MODULE", }; const NotFound: React.FC = () => { @@ -350,6 +359,22 @@ const App = () => { ), }, + { + path: routerPaths.CAREER_READINESS, + element: ( + + + + ), + }, + { + path: routerPaths.CAREER_READINESS_MODULE, + element: ( + + + + ), + }, { path: "*", element: , diff --git a/frontend-new/src/app/routerPaths.ts b/frontend-new/src/app/routerPaths.ts index feb19020..cd9313f3 100644 --- a/frontend-new/src/app/routerPaths.ts +++ b/frontend-new/src/app/routerPaths.ts @@ -12,4 +12,6 @@ export const routerPaths = { KNOWLEDGE_HUB: "/knowledge-hub", KNOWLEDGE_HUB_DOCUMENT: "/knowledge-hub/:documentId", SKILLS_INTERESTS: "/skills-interests", + CAREER_READINESS: "/career-readiness", + CAREER_READINESS_MODULE: "/career-readiness/:moduleId", }; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.stories.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.stories.tsx new file mode 100644 index 00000000..c89445ec --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerReadinessAgentMessage from "src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessAgentMessage", + component: CareerReadinessAgentMessage, + tags: ["autodocs"], + argTypes: { + message: { control: "text" }, + sent_at: { control: "text" }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const now = new Date().toISOString(); + +export const Default: Story = { + args: { + message_id: "msg-1", + message: + "This module helps you understand what professional identity means and how to articulate your skills to employers.", + sent_at: now, + }, +}; + +export const ShortMessage: Story = { + args: { + message_id: "msg-2", + message: "Yes, that's a good place to start.", + sent_at: now, + }, +}; + +export const LongMessage: Story = { + args: { + message_id: "msg-3", + message: `Your professional identity is how you present yourself in a work context. It includes your skills, values, and how you communicate your value to employers. + +There are three main types of skills to consider: +- **Technical skills** – job-specific abilities +- **Transferable skills** – useful across roles +- **Personal attributes** – how you work and communicate + +Think about examples from your experience for each category.`, + sent_at: now, + }, +}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.tsx new file mode 100644 index 00000000..b7e3bb68 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessAgentMessage/CareerReadinessAgentMessage.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { Box, styled } from "@mui/material"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import ChatBubble from "src/chat/chatMessage/components/chatBubble/ChatBubble"; +import Timestamp from "src/chat/chatMessage/components/chatMessageFooter/components/timestamp/Timestamp"; +import ChatMessageFooterLayout from "src/chat/chatMessage/components/chatMessageFooter/ChatMessageFooterLayout"; + +const uniqueId = "e46487bc-8ba0-4e0d-960a-b76897fb5aa9"; + +export const DATA_TEST_ID = { + CAREER_READINESS_AGENT_MESSAGE_CONTAINER: `career-readiness-agent-message-container-${uniqueId}`, +}; + +export const CAREER_READINESS_AGENT_MESSAGE_TYPE = `career-readiness-agent-message-${uniqueId}`; + +export interface CareerReadinessAgentMessageProps { + message_id: string; + message: string; + sent_at: string; +} + +const MessageContainer = styled(Box)<{ origin: ConversationMessageSender }>(({ theme, origin }) => ({ + display: "flex", + flexDirection: "column", + alignItems: origin === ConversationMessageSender.USER ? "flex-end" : "flex-start", + marginBottom: theme.spacing(theme.tabiyaSpacing.sm), + width: "100%", +})); + +const CareerReadinessAgentMessage: React.FC = ({ message_id, message, sent_at }) => { + return ( + + + + + + + + + + + ); +}; + +export default CareerReadinessAgentMessage; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.stories.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.stories.tsx new file mode 100644 index 00000000..2eca6932 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.stories.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Box } from "@mui/material"; +import CareerReadinessChat from "src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat"; +import CareerReadinessService from "src/careerReadiness/services/CareerReadinessService"; +import type { CareerReadinessConversationResponse } from "src/careerReadiness/types"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessChat", + component: CareerReadinessChat, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( + + + + ), + (Story, context) => { + const storyName = context.name ?? ""; + const mockService = CareerReadinessService.getInstance(); + + const getTimestamp = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60 * 1000).toISOString(); + + const mockHistory: CareerReadinessConversationResponse = { + conversation_id: "conv-1", + module_id: "professional-identity", + module_completed: false, + messages: [ + { + message_id: "m1", + message: + "Welcome! In this module we'll explore your professional identity and skills. How would you describe yourself in a work context?", + sent_at: getTimestamp(5), + sender: "AGENT", + }, + { + message_id: "m2", + message: "I'm not sure where to start.", + sent_at: getTimestamp(4), + sender: "USER", + }, + { + message_id: "m3", + message: + "That's okay. Think about your past roles, volunteer work, or studies. What skills did you use? Start with one example.", + sent_at: getTimestamp(3), + sender: "AGENT", + }, + ], + }; + + (mockService as any).createConversation = async (moduleId: string) => ({ + conversation_id: "conv-new", + module_id: moduleId, + module_completed: false, + messages: [ + { + message_id: "w1", + message: "Let's get started. What would you like to work on first?", + sent_at: getTimestamp(0), + sender: "AGENT", + }, + ], + }); + + (mockService as any).getConversationHistory = async (_moduleId: string, _conversationId: string) => { + if (storyName.includes("Loading")) { + await new Promise((r) => setTimeout(r, 2000)); + } + return mockHistory; + }; + + (mockService as any).sendMessage = async (_moduleId: string, _conversationId: string, userInput: string) => ({ + ...mockHistory, + messages: [ + ...mockHistory.messages, + { + message_id: "u-new", + message: userInput, + sent_at: getTimestamp(0), + sender: "USER", + }, + { + message_id: "a-new", + message: "Thanks for sharing. Can you tell me more about how you used that skill?", + sent_at: getTimestamp(0), + sender: "AGENT", + }, + ], + }); + + return ; + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +const defaultArgs = { + moduleId: "professional-identity", + moduleTitle: "Professional Identity & Skills Mapping", + inputPlaceholder: "Ask about professional identity and skills…", +}; + +export const WithConversation: Story = { + args: { + ...defaultArgs, + initialConversationId: "conv-1", + }, +}; + +export const LoadingHistory: Story = { + args: { + ...defaultArgs, + initialConversationId: "conv-1", + }, +}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.tsx new file mode 100644 index 00000000..203d8119 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessChat/CareerReadinessChat.tsx @@ -0,0 +1,151 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Box, useTheme } from "@mui/material"; +import ChatList from "src/chat/chatList/ChatList"; +import ChatMessageField from "src/chat/ChatMessageField/ChatMessageField"; +import { generateSomethingWentWrongMessage, generateUserMessage } from "src/chat/util"; +import type { IChatMessage } from "src/chat/Chat.types"; +import CareerReadinessService from "src/careerReadiness/services/CareerReadinessService"; +import { generateCareerReadinessTypingMessage } from "src/careerReadiness/components/CareerReadinessTypingMessage/CareerReadinessTypingMessage"; +import { mapCareerReadinessMessagesToChatMessages } from "src/careerReadiness/utils/mapCareerReadinessMessagesToChatMessages"; + +export interface CareerReadinessChatProps { + moduleId: string; + moduleTitle: string; + initialConversationId: string | null; + inputPlaceholder: string; +} + +const CareerReadinessChat: React.FC = ({ + moduleId, + moduleTitle, + initialConversationId, + inputPlaceholder, +}) => { + const theme = useTheme(); + const [messages, setMessages] = useState[]>([]); + const [conversationId, setConversationId] = useState(initialConversationId); + const [isLoadingHistory, setIsLoadingHistory] = useState(Boolean(initialConversationId)); + const [aiIsTyping, setAiIsTyping] = useState(false); + const [moduleCompleted, setModuleCompleted] = useState(false); + + const typingMessage = useMemo(() => generateCareerReadinessTypingMessage(), []); + + const displayMessages = useMemo(() => { + if (aiIsTyping) { + return [...messages, typingMessage]; + } + return messages; + }, [messages, aiIsTyping, typingMessage]); + + const showTyping = isLoadingHistory || !conversationId ? [typingMessage] : displayMessages; + + const loadHistory = useCallback( + async (conversationIdOverride?: string | null, getIsCancelled?: () => boolean) => { + const id = conversationIdOverride ?? conversationId; + if (!id) return; + setIsLoadingHistory(true); + try { + const res = await CareerReadinessService.getInstance().getConversationHistory(moduleId, id); + if (getIsCancelled?.()) return; + setMessages(mapCareerReadinessMessagesToChatMessages(res.messages)); + setModuleCompleted(res.module_completed); + } catch (e) { + console.error("Failed to load conversation history", e); + if (getIsCancelled?.()) return; + setMessages([generateSomethingWentWrongMessage()]); + } finally { + if (!getIsCancelled?.()) { + setIsLoadingHistory(false); + } + } + }, + [moduleId, conversationId] + ); + + const loadHistoryRef = useRef(loadHistory); + loadHistoryRef.current = loadHistory; + + useEffect(() => { + if (initialConversationId) { + setConversationId(initialConversationId); + let cancelled = false; + loadHistoryRef + .current(initialConversationId, () => cancelled) + .catch(() => { + // Error already logged and state handled in loadHistory + }); + return () => { + cancelled = true; + }; + } + setConversationId(null); + setMessages([]); + setIsLoadingHistory(false); + }, [initialConversationId]); + + const handleSend = useCallback( + async (userMessage: string) => { + if (!conversationId) return; + const optimisticUserMessage = generateUserMessage( + userMessage, + new Date().toISOString(), + `optimistic-${Date.now()}` + ); + setMessages((prev) => [...prev, optimisticUserMessage]); + setAiIsTyping(true); + try { + const res = await CareerReadinessService.getInstance().sendMessage(moduleId, conversationId, userMessage); + setMessages(mapCareerReadinessMessagesToChatMessages(res.messages)); + setModuleCompleted(res.module_completed); + } catch (e) { + console.error("Failed to send message", e); + setMessages((prev) => [...prev, generateSomethingWentWrongMessage()]); + } finally { + setAiIsTyping(false); + } + }, + [moduleId, conversationId] + ); + + return ( + + + + + + + + + ); +}; + +export default CareerReadinessChat; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.stories.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.stories.tsx new file mode 100644 index 00000000..14e8ff7c --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerReadinessModuleCard from "src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard"; +import type { ModuleSummary } from "src/careerReadiness/types"; +import type { ModuleStatusDisplay } from "src/careerReadiness/types"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessModuleCard", + component: CareerReadinessModuleCard, + tags: ["autodocs"], + argTypes: { + status: { + control: "select", + options: ["not_started", "in_progress", "done"], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const mockModule: ModuleSummary = { + id: "cv-development", + title: "CV Development", + description: + "Learn to build and tailor a professional CV that highlights your skills and experience for different employers.", + icon: "cv", + status: "NOT_STARTED", + sort_order: 1, + input_placeholder: "Ask about CVs...", +}; + +export const NotStarted: Story = { + args: { + module: mockModule, + status: "not_started" as ModuleStatusDisplay, + }, +}; + +export const InProgress: Story = { + args: { + module: { ...mockModule, status: "IN_PROGRESS" }, + status: "in_progress" as ModuleStatusDisplay, + }, +}; + +export const Done: Story = { + args: { + module: { ...mockModule, status: "COMPLETED" }, + status: "done" as ModuleStatusDisplay, + }, +}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.tsx new file mode 100644 index 00000000..146ffa88 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard.tsx @@ -0,0 +1,211 @@ +import React, { startTransition } from "react"; +import { Box, Card, CardActionArea, Typography, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { ModuleStatusDisplay } from "src/careerReadiness/types"; +import type { ModuleSummary } from "src/careerReadiness/types"; +import { routerPaths } from "src/app/routerPaths"; +import { TranslationKey } from "src/react-i18next"; +import FingerprintOutlined from "@mui/icons-material/FingerprintOutlined"; +import DescriptionOutlined from "@mui/icons-material/DescriptionOutlined"; +import MailOutlineOutlined from "@mui/icons-material/MailOutlineOutlined"; +import MicOutlined from "@mui/icons-material/MicOutlined"; +import PeopleOutlined from "@mui/icons-material/PeopleOutlined"; +import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined"; +import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"; +import AutorenewIcon from "@mui/icons-material/Autorenew"; + +const uniqueId = "a7b8c9d0-e1f2-3a4b-5c6d-7e8f9a0b1c2d"; + +export const DATA_TEST_ID = { + MODULE_CARD: `career-readiness-module-card-${uniqueId}`, + MODULE_CARD_TITLE: `career-readiness-module-card-title-${uniqueId}`, + MODULE_CARD_DESCRIPTION: `career-readiness-module-card-description-${uniqueId}`, + MODULE_CARD_ICON: `career-readiness-module-card-icon-${uniqueId}`, + MODULE_CARD_STATUS: `career-readiness-module-card-status-${uniqueId}`, +}; + +const MODULE_ICONS: Record = { + identity: , + cv: , + letter: , + interview: , + workplace: , +}; + +export const getModuleIcon = (iconId: string): React.ReactNode => { + return MODULE_ICONS[iconId] ?? ; +}; + +const STATUS_ICONS = { + done: , + in_progress: , +}; + +const getStatusLabel = (status: ModuleStatusDisplay, t: (key: TranslationKey) => string): string => { + if (status === "done") return t("careerReadiness.statusDone"); + if (status === "in_progress") return t("careerReadiness.statusInProgress"); + return t("careerReadiness.statusNotStarted"); +}; + +const getStatusIcon = (status: ModuleStatusDisplay): React.ReactNode => { + if (status === "done") return STATUS_ICONS.done; + if (status === "in_progress") return STATUS_ICONS.in_progress; + return null; +}; + +export interface CareerReadinessModuleCardProps { + module: ModuleSummary; + status: ModuleStatusDisplay; +} + +const useStatusStyles = (status: ModuleStatusDisplay) => { + const theme = useTheme(); + + if (status === "done") { + return { + borderColor: theme.palette.secondary.main, + iconBg: `color-mix(in srgb, ${theme.palette.secondary.main} 12%, transparent)`, + iconColor: theme.palette.secondary.main, + statusColor: theme.palette.secondary.main, + hoverBorderColor: theme.palette.secondary.main, + hoverBg: `color-mix(in srgb, ${theme.palette.secondary.light} 16%, transparent)`, + }; + } + + if (status === "in_progress") { + return { + borderColor: theme.palette.primary.main, + iconBg: `color-mix(in srgb, ${theme.palette.primary.main} 12%, transparent)`, + iconColor: theme.palette.primary.main, + statusColor: theme.palette.primary.main, + hoverBorderColor: theme.palette.primary.main, + hoverBg: `color-mix(in srgb, ${theme.palette.primary.light} 16%, transparent)`, + }; + } + + // not_started + return { + borderColor: theme.palette.grey[200], + iconBg: theme.palette.grey[200], + iconColor: theme.palette.text.primary, + statusColor: theme.palette.text.secondary, + hoverBorderColor: theme.palette.primary.main, + hoverBg: `color-mix(in srgb, ${theme.palette.primary.light} 16%, transparent)`, + }; +}; + +const CareerReadinessModuleCard: React.FC = ({ module, status = "not_started" }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const styles = useStatusStyles(status); + + const handleClick = () => { + startTransition(() => { + navigate(`${routerPaths.CAREER_READINESS}/${module.id}`); + }); + }; + + const icon = getModuleIcon(module.icon); + + const statusLabel = getStatusLabel(status, t); + const statusIcon = getStatusIcon(status); + + return ( + + + + {icon} + + + + + + {module.title} + + + + {statusIcon} + + {statusLabel} + + + + + + {module.description} + + + + + ); +}; + +export default CareerReadinessModuleCard; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.stories.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.stories.tsx new file mode 100644 index 00000000..8c484415 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerReadinessModuleCardSkeleton from "src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessModuleCardSkeleton", + component: CareerReadinessModuleCardSkeleton, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.tsx new file mode 100644 index 00000000..81e0e4cc --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Box, Card, Skeleton, useTheme } from "@mui/material"; + +const uniqueId = "50bfbbdc-4850-4026-8dd0-8e95d26f24bf"; + +export const DATA_TEST_ID = { + MODULE_CARD_SKELETON: `career-readiness-module-card-skeleton-${uniqueId}`, +}; + +const CareerReadinessModuleCardSkeleton: React.FC = () => { + const theme = useTheme(); + return ( + + + {/* Icon placeholder */} + + + {/* Text content */} + + {/* Title row and status badge */} + + + + + + {/* Description lines */} + + + + + + + ); +}; + +export default CareerReadinessModuleCardSkeleton; diff --git a/frontend-new/src/careerReadiness/components/CareerReadinessTypingMessage/CareerReadinessTypingMessage.tsx b/frontend-new/src/careerReadiness/components/CareerReadinessTypingMessage/CareerReadinessTypingMessage.tsx new file mode 100644 index 00000000..bd51fc73 --- /dev/null +++ b/frontend-new/src/careerReadiness/components/CareerReadinessTypingMessage/CareerReadinessTypingMessage.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Box, keyframes, Typography } from "@mui/material"; +import ChatBubble from "src/chat/chatMessage/components/chatBubble/ChatBubble"; +import { MessageContainer } from "src/chat/chatMessage/userChatMessage/UserChatMessage"; +import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; +import { nanoid } from "nanoid"; +import type { IChatMessage } from "src/chat/Chat.types"; + +const TYPING_KEY = "chat.chatMessage.typingChatMessage.typing"; + +const uniqueId = "4bca669b-6e88-4d21-8a0e-4d5e5ad3c4de"; + +export const CAREER_READINESS_TYPING_MESSAGE_TYPE = `career-readiness-typing-${uniqueId}`; + +const dotAnimation = keyframes` + 0%, 100% { + transform: translateY(+0.5px); + opacity: 0.5; + } + 50% { + transform: translateY(-1px); + opacity: 1; + } +`; + +export interface CareerReadinessTypingMessageProps { + _?: never; +} + +const CareerReadinessTypingMessage: React.FC = () => { + const { t } = useTranslation(); + const displayText = t(TYPING_KEY); + + return ( + + + + {displayText} + + {[0, 1, 2].map((i) => ( + + . + + ))} + + + + + ); +}; + +export function generateCareerReadinessTypingMessage(): IChatMessage { + return { + type: CAREER_READINESS_TYPING_MESSAGE_TYPE, + message_id: nanoid(), + sender: ConversationMessageSender.COMPASS, + payload: {}, + component: (props: CareerReadinessTypingMessageProps) => , + }; +} + +export default CareerReadinessTypingMessage; diff --git a/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.stories.tsx b/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.stories.tsx new file mode 100644 index 00000000..17c2b415 --- /dev/null +++ b/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import CareerReadinessList from "src/careerReadiness/pages/CareerReadinessList/CareerReadinessList"; +import CareerReadinessService from "src/careerReadiness/services/CareerReadinessService"; +import type { ModuleSummary } from "src/careerReadiness/types"; + +const meta: Meta = { + title: "CareerReadiness/CareerReadinessList", + component: CareerReadinessList, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +const mockModules: ModuleSummary[] = [ + { + id: "professional-identity", + title: "Professional Identity & Skills Mapping", + description: + "Understand what professional identity means, learn the three types of skills, and how to articulate them to employers.", + icon: "identity", + status: "NOT_STARTED", + sort_order: 1, + input_placeholder: "Ask about professional identity and skills…", + }, + { + id: "cv-development", + title: "CV Development", + description: "Build a strong CV that highlights your skills and experience effectively.", + icon: "cv", + status: "IN_PROGRESS", + sort_order: 2, + input_placeholder: "Ask about CV writing…", + }, + { + id: "cover-letter", + title: "Cover Letter", + description: "Write compelling cover letters tailored to each job application.", + icon: "letter", + status: "COMPLETED", + sort_order: 3, + input_placeholder: "Ask about cover letters…", + }, +]; + +export const Default: Story = { + decorators: [ + (Story) => { + const service = CareerReadinessService.getInstance(); + (service as any).listModules = async () => ({ modules: mockModules }); + return ; + }, + ], +}; + +export const Loading: Story = { + decorators: [ + (Story) => { + const service = CareerReadinessService.getInstance(); + (service as any).listModules = () => new Promise(() => {}); + return ; + }, + ], +}; + +export const Empty: Story = { + decorators: [ + (Story) => { + const service = CareerReadinessService.getInstance(); + (service as any).listModules = async () => ({ modules: [] }); + return ; + }, + ], +}; + +export const LoadError: Story = { + decorators: [ + (Story) => { + const service = CareerReadinessService.getInstance(); + (service as any).listModules = async () => { + throw new Error("Unable to load modules. Please try again."); + }; + return ; + }, + ], +}; diff --git a/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.tsx b/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.tsx new file mode 100644 index 00000000..173ff91e --- /dev/null +++ b/frontend-new/src/careerReadiness/pages/CareerReadinessList/CareerReadinessList.tsx @@ -0,0 +1,135 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Box, Grid, Typography, useTheme } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import PageHeader from "src/home/components/PageHeader/PageHeader"; +import { routerPaths } from "src/app/routerPaths"; +import { mapModuleStatusToDisplay } from "src/careerReadiness/types"; +import CareerReadinessModuleCard from "src/careerReadiness/components/CareerReadinessModuleCard/CareerReadinessModuleCard"; +import CareerReadinessModuleCardSkeleton from "src/careerReadiness/components/CareerReadinessModuleCardSkeleton/CareerReadinessModuleCardSkeleton"; +import Footer from "src/home/components/Footer/Footer"; +import CareerReadinessService from "src/careerReadiness/services/CareerReadinessService"; +import type { ModuleSummary } from "src/careerReadiness/types"; +import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; + +const uniqueId = "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a"; + +export const DATA_TEST_ID = { + CAREER_READINESS_LIST_CONTAINER: `career-readiness-list-container-${uniqueId}`, + CAREER_READINESS_LIST_CONTENT: `career-readiness-list-content-${uniqueId}`, + CAREER_READINESS_LIST_GRID: `career-readiness-list-grid-${uniqueId}`, + CAREER_READINESS_LIST_EMPTY: `career-readiness-list-empty-${uniqueId}`, + CAREER_READINESS_INTRODUCTION: `career-readiness-introduction-${uniqueId}`, + CAREER_READINESS_LIST_LOADING: `career-readiness-list-loading-${uniqueId}`, +}; + +const SKELETON_COUNT = 6; + +const CareerReadinessList: React.FC = () => { + const theme = useTheme(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [modules, setModules] = useState([]); + const [loading, setLoading] = useState(true); + + const loadModules = useCallback(() => { + setLoading(true); + CareerReadinessService.getInstance() + .listModules() + .then((res) => { + setModules(res.modules); + }) + .catch((e) => { + enqueueSnackbar(e?.message ?? t("careerReadiness.listError"), { variant: "error" }); + }) + .finally(() => { + setLoading(false); + }); + }, [t, enqueueSnackbar]); + + useEffect(() => { + loadModules(); + }, [loadModules]); + + return ( + + navigate(routerPaths.ROOT)} + /> + + + + + {t("careerReadiness.introduction")} + + + + {loading && ( + + {Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + + + ))} + + )} + + {!loading && modules.length === 0 && ( + + {t("careerReadiness.emptyList")} + + )} + + {!loading && modules.length > 0 && ( + + {modules.map((module) => ( + + + + ))} + + )} + + +