@tinyweb_dev/oe-exam-sdk 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +56 -11
  2. package/dist/api/result-review.d.ts +2 -1
  3. package/dist/api/result-review.js +1 -1
  4. package/dist/api/result-review.types.d.ts +69 -3
  5. package/dist/api/result-review.types.js +1 -1
  6. package/dist/components/exams/take/ExamTakingPageContainer.js +1 -1
  7. package/dist/components/exams/take/components/speaking/renderers/SpeakingDescribeImageRenderer.js +1 -1
  8. package/dist/components/exams/take/components/speaking/renderers/SpeakingReadDisplayedContentRenderer.d.ts +0 -17
  9. package/dist/components/exams/take/components/speaking/renderers/SpeakingReadDisplayedContentRenderer.js +1 -1
  10. package/dist/components/results/ResultReviewPageContainer.d.ts +15 -3
  11. package/dist/components/results/ResultReviewPageContainer.js +1 -1
  12. package/dist/components/results/ReviewQuestionRenderer.d.ts +11 -1
  13. package/dist/components/results/ReviewQuestionRenderer.js +1 -1
  14. package/dist/components/results/hooks/useResultReviewPolling.d.ts +12 -2
  15. package/dist/components/results/hooks/useResultReviewPolling.js +1 -1
  16. package/dist/components/results/index.d.ts +34 -2
  17. package/dist/components/results/index.js +1 -1
  18. package/dist/components/results/renderers/QuestionExplanations.d.ts +22 -0
  19. package/dist/components/results/renderers/QuestionExplanations.js +1 -0
  20. package/dist/components/results/renderers/ReviewChooseAdjectiveRenderer.d.ts +13 -0
  21. package/dist/components/results/renderers/ReviewChooseAdjectiveRenderer.js +1 -0
  22. package/dist/components/results/renderers/ReviewChooseBestAnswerRenderer.d.ts +22 -0
  23. package/dist/components/results/renderers/ReviewChooseBestAnswerRenderer.js +1 -0
  24. package/dist/components/results/renderers/ReviewColoringRenderer.d.ts +16 -0
  25. package/dist/components/results/renderers/ReviewColoringRenderer.js +1 -0
  26. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +18 -0
  27. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +1 -0
  28. package/dist/components/results/renderers/ReviewListenChooseAnswerGroupRenderer.d.ts +25 -0
  29. package/dist/components/results/renderers/ReviewListenChooseAnswerGroupRenderer.js +1 -0
  30. package/dist/components/results/renderers/ReviewListenChooseObjectsRenderer.d.ts +25 -0
  31. package/dist/components/results/renderers/ReviewListenChooseObjectsRenderer.js +1 -0
  32. package/dist/components/results/renderers/ReviewListenDragObjectsRenderer.d.ts +25 -0
  33. package/dist/components/results/renderers/ReviewListenDragObjectsRenderer.js +1 -0
  34. package/dist/components/results/renderers/ReviewListeningHeader.d.ts +44 -0
  35. package/dist/components/results/renderers/ReviewListeningHeader.js +1 -0
  36. package/dist/components/results/renderers/ReviewSpeakingAIPanel.d.ts +17 -0
  37. package/dist/components/results/renderers/ReviewSpeakingAIPanel.js +1 -0
  38. package/dist/components/results/renderers/ReviewSpeakingMedia.d.ts +39 -0
  39. package/dist/components/results/renderers/ReviewSpeakingMedia.js +1 -0
  40. package/dist/components/results/renderers/ReviewSpeakingQuestionItem.d.ts +5 -0
  41. package/dist/components/results/renderers/ReviewSpeakingQuestionItem.js +1 -0
  42. package/dist/components/results/renderers/ReviewSpeakingRenderer.d.ts +3 -0
  43. package/dist/components/results/renderers/ReviewSpeakingRenderer.js +1 -0
  44. package/dist/components/results/renderers/ReviewSpeakingRenderer.types.d.ts +80 -0
  45. package/dist/components/results/renderers/ReviewSpeakingRenderer.types.js +1 -0
  46. package/dist/components/results/renderers/review-question-body-dedicated.d.ts +15 -0
  47. package/dist/components/results/renderers/review-question-body-dedicated.js +1 -0
  48. package/dist/components/results/renderers/review-question-body-movers.d.ts +17 -0
  49. package/dist/components/results/renderers/review-question-body-movers.js +1 -0
  50. package/dist/components/results/renderers/review-speaking-map.d.ts +7 -0
  51. package/dist/components/results/renderers/review-speaking-map.js +1 -0
  52. package/dist/components/results/report/BadgeTitle.d.ts +5 -0
  53. package/dist/components/results/report/BadgeTitle.js +1 -0
  54. package/dist/components/results/report/ExamSkillRow.d.ts +14 -0
  55. package/dist/components/results/report/ExamSkillRow.js +1 -0
  56. package/dist/components/results/report/OceanEduExamReport.d.ts +31 -0
  57. package/dist/components/results/report/OceanEduExamReport.js +1 -0
  58. package/dist/components/results/report/OceanEduSpeakingReport.d.ts +26 -0
  59. package/dist/components/results/report/OceanEduSpeakingReport.js +1 -0
  60. package/dist/components/results/report/OverallAssessmentSection.d.ts +24 -0
  61. package/dist/components/results/report/OverallAssessmentSection.js +1 -0
  62. package/dist/components/results/report/ResultErrorState.d.ts +14 -0
  63. package/dist/components/results/report/ResultErrorState.js +1 -0
  64. package/dist/components/results/report/ResultLoadingState.d.ts +11 -0
  65. package/dist/components/results/report/ResultLoadingState.js +1 -0
  66. package/dist/components/results/report/ResultNotPublicState.d.ts +16 -0
  67. package/dist/components/results/report/ResultNotPublicState.js +1 -0
  68. package/dist/components/results/report/SpeakingReportParts.d.ts +4 -0
  69. package/dist/components/results/report/SpeakingReportParts.js +1 -0
  70. package/dist/components/results/report/SpeakingReportShared.d.ts +36 -0
  71. package/dist/components/results/report/SpeakingReportShared.js +1 -0
  72. package/dist/components/results/report/SpeakingRubricSection.d.ts +21 -0
  73. package/dist/components/results/report/SpeakingRubricSection.js +1 -0
  74. package/dist/components/results/report/assets/dofu2.png +0 -0
  75. package/dist/components/results/report/assets/dofu4.png +0 -0
  76. package/dist/components/results/report/assets/logo-ocean.png +0 -0
  77. package/dist/components/results/report/assets/penguin-main.png +0 -0
  78. package/dist/components/results/report/assets/penguin-small.png +0 -0
  79. package/dist/components/results/report/speaking-report.utils.d.ts +37 -0
  80. package/dist/components/results/report/speaking-report.utils.js +1 -0
  81. package/dist/components/results/review-data.d.ts +1 -0
  82. package/dist/components/results/review-data.js +1 -1
  83. package/dist/components/results/review-types.d.ts +14 -0
  84. package/dist/components/results/speaking/SpeakingResultAudioPlayer.d.ts +17 -0
  85. package/dist/components/results/speaking/SpeakingResultAudioPlayer.js +1 -0
  86. package/dist/components/results/speaking/SpeakingReviewResult.d.ts +30 -0
  87. package/dist/components/results/speaking/SpeakingReviewResult.js +1 -0
  88. package/dist/components/results/utils/review-score.utils.d.ts +21 -0
  89. package/dist/components/results/utils/review-score.utils.js +1 -0
  90. package/dist/components/results/utils/speaking-summary.d.ts +41 -0
  91. package/dist/components/results/utils/speaking-summary.js +1 -0
  92. package/dist/components/results/utils/transform-speaking-review.d.ts +62 -0
  93. package/dist/components/results/utils/transform-speaking-review.js +1 -0
  94. package/package.json +4 -2
@@ -0,0 +1 @@
1
+ import{ReviewGradingStatus as e}from"../../../api/result-review";import{buildSkillMetrics as t}from"../utils/speaking-summary";export const SKILL_UI_BY_KEY={pronunciation:{icon:"speaker",colorVarClass:"text-[#4CAF50]",barClass:"bg-[#4CAF50]"},fluency:{icon:"bolt",colorVarClass:"text-[#2196F3]",barClass:"bg-[#2196F3]"},vocabulary:{icon:"message",colorVarClass:"text-[#FF9800]",barClass:"bg-[#FF9800]"},grammar:{icon:"check-double",colorVarClass:"text-[#9C27B0]",barClass:"bg-[#9C27B0]"},coherence:{icon:"music",colorVarClass:"text-[#00BCD4]",barClass:"bg-[#00BCD4]"}};const n={READ_DISPLAYED_CONTENT:"Read displayed content",SPEAKING_DESCRIBE_IMAGE:"Describe image",SPONTANEOUS_QA:"Spontaneous Q&A"},i={[e.PENDING]:"Đang chờ chấm điểm",[e.QUEUED]:"Đang xếp hàng chờ chấm điểm",[e.PROCESSING]:"Đang chấm điểm...",[e.MANUAL_REVIEW]:"Đang chờ giáo viên xem xét",[e.FAILED]:"Chấm điểm thất bại — đang xử lý lại"};export function withUi(e){return{...e,...SKILL_UI_BY_KEY[e.key]}}export function getLocalizedText(e){return"string"==typeof e?e:e&&"object"==typeof e?"string"==typeof e.vi&&e.vi.trim()?e.vi:"string"==typeof e.en&&e.en.trim()?e.en:"":""}export function formatScore(e,t=1){if(null==e||Number.isNaN(e))return"—";return e.toFixed(t).replace(/\.0+$/,"").replace(/(\.\d*[1-9])0+$/,"$1")}export function formatTotalScore(e){return null!=e&&Number.isFinite(e)?e<10?e.toFixed(1):String(Math.round(e)):"—"}export function getScoreStroke(e,t,n){if(n||null==e||t<=0)return"conic-gradient(#cbd5e1 0% 100%, #eef5ff 100% 100%)";const i=Math.max(0,Math.min(100,e/t*100));return`conic-gradient(#00AEEF 0% ${i}%, #eef5ff ${i}% 100%)`}export function getQuestionTypeLabel(e){return n[e]??e.replace(/_/g," ")}export function isGradingComplete(t){return t===e.GRADED||t===e.AUTO_GRADED}export function getPendingGradingLabel(t){return t?i[t]??i[e.PENDING]:i[e.PENDING]}export function deriveQuestionSkillMetrics(e){const n=e.aiAssessment??null,i=e.llmAssessment??null;return t({pronunciation:n?.pronunciation?.pronunciationScore??null,fluency:n?.pronunciation?.fluencyScore??null,vocabulary:i?.contentScore??null,grammar:n?.pronunciation?.accuracyScore??null,coherence:n?.pronunciation?.prosodyScore??null}).map(withUi)}export function extractPrompt(e){const t=e.questionContent??{};if("SPEAKING_CUE_CARD"===e.questionType&&"string"==typeof t.cueCardHtml&&t.cueCardHtml.trim())return{text:t.cueCardHtml,isHtml:!0};const n=[getLocalizedText(t.title),getLocalizedText(t.prompt),getLocalizedText(t.question),getLocalizedText(t.questionText),getLocalizedText(t.instruction),getLocalizedText(t.content),getLocalizedText(t.text),getLocalizedText(t.paragraph),getLocalizedText(t.referenceText),getLocalizedText(t.cueCard),getLocalizedText(t.topic)].filter(e=>e.trim().length>0);if(n.length>0)return{text:n[0],isHtml:!1};if("READ_DISPLAYED_CONTENT"===e.questionType){const e=[getLocalizedText(t.displayedContent),getLocalizedText(t.passage),getLocalizedText(t.sentence),getLocalizedText(t.referenceText)].filter(e=>e.trim().length>0);if(e.length>0)return{text:e.join(" "),isHtml:!1}}if("SPONTANEOUS_QA"===e.questionType){const e=(Array.isArray(t.questions)?t.questions:[]).map(e=>{const t=e;return getLocalizedText(t.text||t.question||t.content)}).filter(e=>e.trim().length>0);if(e.length>0)return{text:e.join(" / "),isHtml:!1}}return{text:"Nội dung câu hỏi đang được cập nhật.",isHtml:!1}}export function extractStudentAnswerText(e){const t=e.studentAnswer??{},n=[getLocalizedText(t.transcript),getLocalizedText(t.recognizedText),getLocalizedText(t.answerText),getLocalizedText(t.text),getLocalizedText(t.content),getLocalizedText(t.finalText),getLocalizedText(t.response)].filter(e=>e.trim().length>0);return n.length>0?n[0]:e.studentAnswerKey?"Đã gửi file ghi âm.":"Chưa có câu trả lời."}export function extractQuestionFeedback(e){const t=e.classification??null,n=[getLocalizedText(e.llmAssessment?.feedback),getLocalizedText(e.aiAssessment?.feedback),getLocalizedText(t?.feedback),getLocalizedText(t?.description),getLocalizedText(t?.title)].filter(e=>e.trim().length>0);if(n.length>0)return n[0];if(e.isPendingGrading)return"Câu hỏi này đang chờ chấm điểm chi tiết.";if(null==e.score)return"Chưa có nhận xét chi tiết cho câu hỏi này.";const i=e.maxScore>0?e.score/e.maxScore:0;return i>=.85?"Câu trả lời tốt, tiếp tục duy trì phong độ này.":i>=.6?"Câu trả lời khá ổn, có thể cải thiện thêm độ rõ ràng và tự tin.":"Nên luyện thêm để cải thiện độ chính xác và cách diễn đạt ở câu hỏi này."}export function getPartScore(e){const t=e.questions.reduce((e,t)=>e+(Number.isFinite(t.maxScore)?t.maxScore:0),0),n=e.questions.some(e=>!isGradingComplete(e.gradingStatus)),i=e.questions.filter(e=>isGradingComplete(e.gradingStatus)).map(e=>e.score).filter(e=>"number"==typeof e&&Number.isFinite(e));return{score:i.length>0?i.reduce((e,t)=>e+t,0):null,maxScore:t,hasPending:n}}export function resolveAssetUrl(e,t,n){if(t&&n){return`${t.replace(/\/$/,"")}/${n}`}return"string"==typeof e?e:e.src}
@@ -1,2 +1,3 @@
1
1
  import type { ReviewAnswer, ReviewTransformResult, ResultReviewTemplatePart } from './review-types';
2
2
  export declare function transformResultAnswersForReview(answers: ReviewAnswer[], templateParts?: ResultReviewTemplatePart[]): ReviewTransformResult;
3
+ export declare const transformDetailedResultForReview: typeof transformResultAnswersForReview;
@@ -1 +1 @@
1
- export function transformResultAnswersForReview(r,n=[]){const o=new Map;for(const t of r){const e=[t.partId??"unknown",t.questionGroup?.id??"none",t.questionType].join(":");o.set(e,[...o.get(e)??[],t])}const i={parts:[],studentAnswers:{},isCorrectMap:{},correctAnswerTextMap:{},correctOptionIdMap:{},essayGradingMap:{},partScoresMap:{},rawCorrectAnswerMap:{},rawStudentAnswerMap:{},questionScoreMap:{}},u=[];for(const r of o.values()){r.sort((t,e)=>t.questionNumber-e.questionNumber);const o=r[0];if(!o)continue;const a=n.find(t=>String(t.id)===String(o.partId)),c=o.partNo??s(a?.name)??0;i.parts.push({partId:o.partId,partNo:c,questionType:o.questionType,name:a?.name,instructions:a?.instructions,audioUrl:a?.audioUrl,questions:r.map(t)});const p=r.filter(t=>!t.isExample);u.push({score:p.reduce((t,e)=>t+(e.score??0),0),maxScore:p.reduce((t,e)=>t+e.maxScore,0),correctCount:p.filter(t=>!0===t.isCorrect).length,totalCount:p.length});for(const t of r)e(i,t)}const a=i.parts.map((t,e)=>e).sort((t,e)=>i.parts[t].partNo-i.parts[e].partNo||(i.parts[t].questions[0]?.question_number??0)-(i.parts[e].questions[0]?.question_number??0));return i.parts=a.map(t=>i.parts[t]),a.forEach((t,e)=>{i.partScoresMap[e]=u[t]}),i}function t(t){return{id:t.questionId,content:t.questionContent,correct_answer:t.correctAnswer,total_points:String(t.maxScore),question_type:t.questionType,is_example:t.isExample??!1,explanation:t.explanation??"",difficult_level:"MEDIUM",question_skill_type:"",exam_id:"",part_id:t.partId??"",part_no:t.partNo??0,question_number:t.questionNumber,questionGroup:t.questionGroup??null}}function e(t,e){const s=function(t,e,o){const s=n(o);if(null==o)return;if("CHOOSE_THE_CORRECT_ANSWER"===e)return r(t,o);if(["FILL_IN_BLANK","WRITE_CORRECT_VERB_FORM","ANSWER_THE_QUESTION","TRUE_FALSE","WRITE_SENTENCES"].includes(e))return"string"==typeof o?o:s.answer??o;return"READ_AND_COLOR_OBJECTS"===e||"IMAGE_OBJECT_MATCHING"===e?s.answers??o:e.startsWith("LISTEN_AND_")?o:s.answer??o}(e.questionId,e.questionType,e.studentAnswer);void 0!==s&&(t.studentAnswers[e.questionId]=s),t.isCorrectMap[e.questionId]=e.isCorrect,t.rawCorrectAnswerMap[e.questionId]=e.correctAnswer,t.rawStudentAnswerMap[e.questionId]=e.studentAnswer,t.questionScoreMap[e.questionId]={score:e.score,maxScore:e.maxScore};const i=function(t){const e=n(t);if("string"==typeof e.answer)return e.answer;if("string"==typeof e.correctAnswer)return e.correctAnswer;const r=e.answers;return Array.isArray(r)?String(r[0]??""):r&&"object"==typeof r?String(Object.values(r)[0]??""):""}(e.correctAnswer);i&&(t.correctAnswerTextMap[e.questionId]=i);const u=r(e.questionId,e.correctAnswer);if(u&&(t.correctOptionIdMap[e.questionId]=u),"CHOOSE_CORRECT_ADJECTIVE"===e.questionType){const r=o(e.studentAnswer,"answer"),n=o(e.correctAnswer,"answer");void 0!==r&&(t.studentAnswers[e.questionId]=`${e.questionId}-opt-${r}`),void 0!==n&&(t.correctOptionIdMap[e.questionId]=`${e.questionId}-opt-${n}`)}"WRITE_A_SHORT_LETTER"!==e.questionType&&"WRITE_SHORT_PARAGRAPH"!==e.questionType||(t.essayGradingMap[e.questionId]={score:e.teacherScore??e.score,maxScore:e.maxScore,teacherFeedback:e.teacherFeedback??null})}function r(t,e){const r=n(e).answer;return"number"==typeof r?`${t}-opt-${r}`:"string"==typeof r?r:Array.isArray(r)?r.map(e=>"number"==typeof e?`${t}-opt-${e}`:String(e)).join(","):""}function n(t){return"object"==typeof t&&null!==t?t:{}}function o(t,e){const r=n(t)[e];return"number"==typeof r?r:void 0}function s(t){const e=t?.match(/\d+/);return e?Number(e[0]):void 0}
1
+ const e=new Set(["READ_PASSAGE_AND_ANSWER_QUESTIONS","LOOK_PICTURE_FILL_WORD_HINT","LABEL_THE_PICTURE","READ_AND_COLOR_OBJECTS","IMAGE_OBJECT_MATCHING"]);export function transformResultAnswersForReview(e,s=[]){const o=function(e){const r=new Map;return e.forEach(e=>{const t=[e.partId??"unknown",e.questionGroup?.id??"none",e.questionType].join(":");r.set(t,[...r.get(t)??[],e])}),r}(e),a={parts:[],studentAnswers:{},correctAnswersMap:new Map,isCorrectMap:{},correctAnswerTextMap:{},correctOptionIdMap:{},cellAnswersMap:{},essayGradingMap:{},partScoresMap:{},rawCorrectAnswerMap:{},rawStudentAnswerMap:{},questionScoreMap:{}},c=[];for(const e of o.values()){e.sort((e,r)=>e.questionNumber-r.questionNumber);const o=e[0];if(!o)continue;const u=s.find(e=>String(e.id)===String(o.partId)),p=r(u,o.questionNumber),A=i(u?.name)??o.partNo??0;a.parts.push({partId:o.partId,partNo:A,questionType:o.questionType,name:u?.name,instructions:p?.name||p?.instructions||u?.instructions,audioUrl:u?.audioUrl,questions:e.map(t)});const _=e.filter(e=>!e.isExample);c.push({score:_.reduce((e,r)=>e+(r.score??0),0),maxScore:_.reduce((e,r)=>e+r.maxScore,0),correctCount:_.filter(e=>!0===e.isCorrect).length,totalCount:_.length}),e.forEach(e=>n(a,e))}const u=a.parts.map((e,r)=>r).sort((e,r)=>{const t=a.parts[e],n=a.parts[r];return t.partNo-n.partNo||(t.questions[0]?.question_number??0)-(n.questions[0]?.question_number??0)});return a.parts=u.map(e=>a.parts[e]),u.forEach((e,r)=>{a.partScoresMap[r]=c[e]}),a}export const transformDetailedResultForReview=transformResultAnswersForReview;function r(e,r){const t=r-1;return e?.sections?.find(e=>null!=e.startIndex&&null!=e.endIndex&&t>=e.startIndex&&t<=e.endIndex)}function t(e){const r=o(e.questionContent);return{id:e.questionId,content:{...r,_correctAnswer:e.correctAnswer},correct_answer:e.correctAnswer,total_points:String(e.maxScore),question_type:e.questionType,is_example:e.isExample??!1,explanation:e.explanation??"",difficult_level:"MEDIUM",question_skill_type:"",exam_id:"",part_id:e.partId??"",part_no:e.partNo??0,question_number:e.questionNumber,questionGroup:e.questionGroup??null,score:e.score}}function n(r,t){const n=function(e,r,t){if(null==t)return;const n=o(t);if("CHOOSE_THE_CORRECT_ANSWER"===r||"CHOOSE_CORRECT_ADJECTIVE"===r){const r=s(e,n.answer);return r.length>1?r:r[0]}if("READ_PASSAGE_AND_ANSWER_QUESTIONS"===r&&a(n.subAnswers))return Object.fromEntries(Object.entries(n.subAnswers).map(([e,r])=>[e,"number"==typeof r?`${e}-opt-${r}`:r]));if("LABEL_THE_PICTURE"===r)return n.answers??t;if("LOOK_PICTURE_FILL_WORD_HINT"===r&&"string"==typeof n.answer&&n.answer.includes(","))return Object.fromEntries(n.answer.split(",").map((r,t)=>[`${e}-blank-${t}`,r.trim()]));if("WORD_FILL_PARAGRAPH"===r){if(Array.isArray(n.answers))return n.answers;if(Array.isArray(n.answer))return n.answer;if(Array.isArray(t))return t;if("string"==typeof t)return[t];return Object.keys(n).filter(e=>/^\d+$/.test(e)).sort((e,r)=>Number(e)-Number(r)).map(e=>n[e])}return"READ_AND_COLOR_OBJECTS"===r||"IMAGE_OBJECT_MATCHING"===r?n.answers??t:"WRITE_A_SHORT_LETTER"===r?"string"==typeof t?t:n.answer??"":n.answer??t}(t.questionId,t.questionType,t.studentAnswer);void 0!==n&&(e.has(t.questionType)&&a(n)?Object.assign(r.studentAnswers,n):r.studentAnswers[t.questionId]=n),r.correctAnswersMap.set(t.questionId,t.correctAnswer),r.isCorrectMap[t.questionId]=t.isCorrect,r.rawCorrectAnswerMap[t.questionId]=t.correctAnswer,r.rawStudentAnswerMap[t.questionId]=t.studentAnswer,r.questionScoreMap[t.questionId]={score:t.score,maxScore:t.maxScore},function(e,r){const t=o(r.correctAnswer),n=s(r.questionId,t.answer);n.length&&(e.correctOptionIdMap[r.questionId]=n.join(","));"READ_PASSAGE_AND_ANSWER_QUESTIONS"===r.questionType&&a(t.answers)&&Object.entries(t.answers).forEach(([r,t])=>{"number"==typeof t&&(e.correctOptionIdMap[r]=`${r}-opt-${t}`)});const i=function(e){if(Array.isArray(e.answers))return e.answers.map(String);if(a(e.answers))return Object.values(e.answers).map(String);const r=e.answer??e.correctAnswer;return null==r?[]:[String(r)]}(t);i[0]&&(e.correctAnswerTextMap[r.questionId]=i[0]);"WORD_FILL_PARAGRAPH"===r.questionType&&i.forEach((t,n)=>{e.correctAnswerTextMap[`${r.questionId}-blank-${n}`]=t});"LABEL_THE_PICTURE"===r.questionType&&a(t.labels)&&Object.entries(t.labels).forEach(([r,t])=>{e.correctAnswerTextMap[r]=String(t)});"FILL_MISSING_WORDS_IN_GRID"===r.questionType&&a(t.cells)&&(e.cellAnswersMap[r.questionId]={cells:t.cells,caseSensitive:!0===t.caseSensitive})}(r,t),"WRITE_A_SHORT_LETTER"!==t.questionType&&"WRITE_SHORT_PARAGRAPH"!==t.questionType||(r.essayGradingMap[t.questionId]={score:t.teacherScore??t.score,maxScore:t.maxScore,teacherFeedback:t.teacherFeedback??null})}function s(e,r){return(Array.isArray(r)?r:[r]).filter(e=>"number"==typeof e||"string"==typeof e).map(r=>"number"==typeof r?`${e}-opt-${r}`:r)}function o(e){return a(e)?e:{}}function a(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function i(e){const r=e?.match(/\d+/);return r?Number(r[0]):void 0}
@@ -26,9 +26,16 @@ export interface PartScoreInfo {
26
26
  export interface ReviewTransformResult {
27
27
  parts: ReviewQuestionPart[];
28
28
  studentAnswers: ReviewAnswerMap;
29
+ correctAnswersMap: Map<string, unknown>;
29
30
  isCorrectMap: CorrectMap;
30
31
  correctAnswerTextMap: Record<string, string>;
31
32
  correctOptionIdMap: Record<string, string>;
33
+ cellAnswersMap: Record<string, {
34
+ cells: Record<string, {
35
+ correctAnswer: string;
36
+ }>;
37
+ caseSensitive: boolean;
38
+ }>;
32
39
  essayGradingMap: Record<string, EssayGradingInfo>;
33
40
  partScoresMap: Record<number, PartScoreInfo>;
34
41
  rawCorrectAnswerMap: Record<string, unknown>;
@@ -44,6 +51,13 @@ export interface ResultReviewTemplatePart {
44
51
  instructions?: string;
45
52
  audioUrl?: string;
46
53
  questionType?: string;
54
+ sections?: Array<{
55
+ sectionId?: number;
56
+ name?: string;
57
+ instructions?: string;
58
+ startIndex?: number;
59
+ endIndex?: number;
60
+ }>;
47
61
  }
48
62
  export type ReviewQuestion = ExamTakingQuestion & {
49
63
  explanation?: string | null;
@@ -0,0 +1,17 @@
1
+ export type SpeakingResultAudioPlayerVariant = 'student' | 'admin';
2
+ export interface SpeakingResultAudioPlayerProps {
3
+ /** S3 key or displayable URL for the recorded audio answer */
4
+ audioKey: string;
5
+ /**
6
+ * Visual theme variant.
7
+ * - student (default): ocean-blue
8
+ * - admin: neutral gray
9
+ */
10
+ variant?: SpeakingResultAudioPlayerVariant;
11
+ /**
12
+ * Optional verification token. Hosts that resolve storage keys can pass this
13
+ * through their own fetcher; the default SDK hook is a pass-through.
14
+ */
15
+ token?: string | null;
16
+ }
17
+ export declare function SpeakingResultAudioPlayer({ audioKey, variant, token: _token, }: SpeakingResultAudioPlayerProps): import("react").JSX.Element;
@@ -0,0 +1 @@
1
+ "use client";import{jsx as e,jsxs as t}from"react/jsx-runtime";import{useCallback as r,useRef as a,useState as n}from"react";import{Loader2 as i,Mic as s,Pause as l,Play as o,RefreshCw as d}from"lucide-react";import{usePresignedFileUrl as u}from"../../../shared/lib/hooks/usePresignedFileUrl";import{cn as c}from"../../../shared/lib/utils";function m(e){if(!isFinite(e)||isNaN(e))return"--:--";return`${Math.floor(e/60)}:${Math.floor(e%60).toString().padStart(2,"0")}`}export function SpeakingResultAudioPlayer({audioKey:h,variant:b="student",token:g=null}){const{previewUrl:p,isLoading:x,error:f}=u(h),w=a(null),[y,N]=n(!1),[v,k]=n(0),[_,A]=n(0),j=r(()=>{const e=w.current;e&&p&&(y?e.pause():e.play().catch(e=>{console.error("Audio play failed:",e)}))},[y,p]),T=r(e=>{const t=w.current;if(!t)return;const r=Number(e.target.value);t.currentTime=r,k(r)},[]),P=r(()=>{const e=w.current;e&&k(e.currentTime)},[]),M=r(()=>{const e=w.current;e&&A(e.duration||0)},[]),S=r(()=>{N(!1),k(0);const e=w.current;e&&(e.currentTime=0)},[]),U=_>0?v/_*100:0,$="admin"===b;return t("div",x?{className:c("flex items-center justify-center gap-2 rounded-xl px-4 py-3.5",$?"bg-gray-100":"bg-gradient-to-r from-[#2874A6] to-[#3498DB]"),children:[e(i,{className:c("h-4 w-4 animate-spin",$?"text-gray-500":"text-white/80")}),e("span",{className:c("text-sm font-medium",$?"text-gray-600":"text-white/80"),children:"Đang tải âm thanh..."})]}:f||!p?{className:"flex items-center gap-2.5 rounded-xl border border-red-200 bg-red-50 px-4 py-3",children:[e(d,{className:"h-4 w-4 text-red-400"}),e("span",{className:"text-sm font-medium text-red-600",children:"Không thể phát âm thanh"})]}:{className:"overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-[0_2px_12px_rgba(0,0,0,0.02)] transition-all hover:shadow-[0_4px_16px_rgba(0,0,0,0.04)] ring-1 ring-gray-900/5",children:[e("audio",{ref:w,src:p,onPlay:()=>N(!0),onPause:()=>N(!1),onTimeUpdate:P,onLoadedMetadata:M,onEnded:S,preload:"metadata"}),t("div",{className:"flex items-center gap-3 px-4 py-3",children:[e("div",{className:c("flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition-all",y?"bg-blue-50 ring-4 ring-blue-50/50":"bg-gray-50"),children:e(s,{className:c("h-4 w-4 transition-transform",y?"text-[#2874A6] animate-pulse":"text-gray-400")})}),e("button",{type:"button",onClick:j,className:c("flex h-10 w-10 shrink-0 items-center justify-center rounded-full shadow-sm transition-all active:scale-95",$?y?"bg-gray-600 text-white hover:bg-gray-700":"bg-gray-500 text-white hover:bg-gray-600":y?"bg-[#1A5276] text-white hover:bg-[#154360]":"bg-[#2874A6] text-white hover:bg-[#1A5276]"),"aria-label":y?"Tạm dừng":"Phát",children:y?e(l,{className:"h-4 w-4"}):e(o,{className:"h-4 w-4 ml-0.5"})}),t("div",{className:"flex flex-1 flex-col gap-1.5 px-2",children:[t("div",{className:"flex items-center justify-between",children:[e("span",{className:"text-[10px] font-bold tabular-nums text-gray-500 tracking-wide",children:m(v)}),e("span",{className:"text-[10px] font-bold tabular-nums text-gray-400 tracking-wide",children:_>0?m(_):"--:--"})]}),t("div",{className:"relative flex h-2 items-center",children:[e("input",{type:"range",min:0,max:_||1,step:.1,value:v,onChange:T,className:c("absolute inset-0 z-10 h-1.5 w-full cursor-pointer appearance-none rounded-full bg-transparent","[&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5","[&::-webkit-slider-thumb]:appearance-none","[&::-webkit-slider-thumb]:rounded-full","[&::-webkit-slider-thumb]:bg-white","[&::-webkit-slider-thumb]:shadow-[0_1px_4px_rgba(0,0,0,0.2)]","[&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-gray-200","[&::-webkit-slider-thumb]:transition-transform","[&::-webkit-slider-thumb]:hover:scale-110"),"aria-label":"Tiến trình phát"}),e("div",{className:"pointer-events-none absolute left-0 right-0 h-1.5 rounded-full bg-gray-100"}),e("div",{className:c("pointer-events-none absolute left-0 h-1.5 rounded-full transition-all duration-100 ease-linear",$?"bg-blue-500":"bg-gradient-to-r from-[#2874A6] to-[#3498DB]"),style:{width:`${U}%`}})]})]})]})]})}
@@ -0,0 +1,30 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { SpeakingRubric } from '../../../api/result-review';
3
+ import type { SpeakingReviewData } from '../utils/transform-speaking-review';
4
+ export declare const SPEAKING_REVIEW_POLL_INTERVAL_MS = 5000;
5
+ export interface SpeakingReviewResultProps {
6
+ reviewData: SpeakingReviewData;
7
+ studentName?: string | null;
8
+ studentCode?: string | null;
9
+ branchName?: string | null;
10
+ examDateText?: string;
11
+ examTitle?: string | null;
12
+ showDetailedResults?: boolean;
13
+ speakingRubric?: SpeakingRubric | null;
14
+ reviewUrl?: string;
15
+ assetBaseUrl?: string;
16
+ canExportPdf?: boolean;
17
+ isExporting?: boolean;
18
+ /** True when any question is still in a pollable grading status. */
19
+ hasPendingQuestions?: boolean;
20
+ onBack?: () => void;
21
+ onExportPdf?: () => void | Promise<void>;
22
+ topActionsSlot?: ReactNode;
23
+ beforeReportSlot?: ReactNode;
24
+ afterReportSlot?: ReactNode;
25
+ }
26
+ /**
27
+ * Speaking review page shell (toolbar + banners + report).
28
+ * Source: oe-exam-fe/.../speaking-review/page.tsx (SDK-owned portion only).
29
+ */
30
+ export declare function SpeakingReviewResult({ reviewData, studentName, studentCode, branchName, examDateText, examTitle, showDetailedResults, speakingRubric, reviewUrl, assetBaseUrl, canExportPdf, isExporting: isExportingProp, hasPendingQuestions, onBack, onExportPdf, topActionsSlot, beforeReportSlot, afterReportSlot, }: SpeakingReviewResultProps): import("react").JSX.Element;
@@ -0,0 +1 @@
1
+ "use client";import{jsx as e,jsxs as t,Fragment as a}from"react/jsx-runtime";import{useMemo as s,useState as r}from"react";import{AlertTriangle as i,ChevronLeft as n,Download as l,Loader2 as o,Sparkles as d}from"lucide-react";import{OceanEduSpeakingReport as c}from"../report/OceanEduSpeakingReport";export const SPEAKING_REVIEW_POLL_INTERVAL_MS=5e3;export function SpeakingReviewResult({reviewData:m,studentName:h,studentCode:u,branchName:f,examDateText:p,examTitle:x,showDetailedResults:b,speakingRubric:g,reviewUrl:v,assetBaseUrl:w,canExportPdf:N=!1,isExporting:y,hasPendingQuestions:E=!1,onBack:A,onExportPdf:D,topActionsSlot:k,beforeReportSlot:R,afterReportSlot:S}){const[F,P]=r(!1),T=y??F,j=s(()=>{if(p)return p;if(m.submittedAt){const e=new Date(m.submittedAt);if(!Number.isNaN(e.getTime()))return e.toLocaleDateString("vi-VN")}return(new Date).toLocaleDateString("vi-VN")},[p,m.submittedAt]);return e("div",{className:"min-h-screen bg-[#e0e0e0] py-4 md:py-6",children:t("div",{className:"mx-auto flex w-full max-w-[1160px] flex-col gap-4 px-0 sm:px-4",children:[t("div",{className:"flex items-center justify-between gap-3 px-3 print:hidden sm:px-0",children:[t("button",{type:"button",onClick:()=>{A?A():"undefined"!=typeof window&&window.history.back()},className:"inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50",children:[e(n,{className:"h-4 w-4"}),"Quay lại"]}),t("div",{className:"flex items-center gap-2",children:[N&&D?t("button",{type:"button",onClick:()=>{(async()=>{if(D){void 0===y&&P(!0);try{await D()}finally{void 0===y&&P(!1)}}})()},disabled:T,className:"group relative inline-flex items-center gap-2.5 overflow-hidden rounded-xl bg-gradient-to-r from-[#00AEEF] via-[#0099D6] to-[#00AEEF] bg-[length:200%_100%] px-5 py-3 text-sm font-bold text-white shadow-lg shadow-[#00AEEF]/30 transition-all duration-300 hover:bg-[position:100%_0] hover:shadow-xl hover:shadow-[#00AEEF]/40 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00AEEF] focus-visible:ring-offset-2",children:[e("span",{className:"absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-1000 group-hover:translate-x-full"}),t(a,T?{children:[e(o,{className:"relative z-10 h-4 w-4 animate-spin"}),e("span",{className:"relative z-10",children:"Đang xuất..."})]}:{children:[e(l,{className:"relative z-10 h-4 w-4 transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:scale-110"}),e("span",{className:"relative z-10",children:"Xuất PDF"})]})]}):null,k]})]}),E?t("div",{className:"flex items-center gap-3 rounded-2xl border border-[#b3dfff] bg-gradient-to-r from-[#e8f6ff] to-[#f0f8ff] px-5 py-4 shadow-sm",children:[e("div",{className:"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#d0edff]",children:e(d,{className:"h-5 w-5 animate-pulse text-[#00AEEF]"})}),t("div",{className:"flex-1",children:[e("p",{className:"text-sm font-bold text-[#0054A6]",children:"AI đang chấm điểm..."}),t("p",{className:"text-xs text-[#0078c8]",children:["Trang sẽ tự động cập nhật sau mỗi ",5," giây cho đến khi hoàn tất."]})]})]}):null,m.isPendingGrading?t("div",{className:"flex items-center gap-3 rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 to-yellow-50 px-5 py-4 shadow-sm",children:[e("div",{className:"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-100",children:e(i,{className:"h-5 w-5 text-amber-600"})}),t("div",{children:[e("p",{className:"text-sm font-bold text-amber-800",children:"Đang chờ chấm điểm"}),e("p",{className:"text-xs text-amber-600",children:"Giáo viên sẽ chấm điểm bài thi của bạn sớm nhất có thể."})]})]}):null,R,e(c,{reviewData:m,studentName:h??m.studentName,studentCode:u,branchName:f,examDateText:j,examTitle:x,showDetailedResults:b,reviewUrl:v,speakingRubric:g,assetBaseUrl:w}),S]})})}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Score display helpers for the general (non-speaking) exam result report.
3
+ * Source thresholds mirror oe-exam-fe OceanEduExamReport.
4
+ */
5
+ /** Percentage bands for general exam total-score labels / messages. */
6
+ export declare const ReviewScoreBand: {
7
+ readonly EXCELLENT: 90;
8
+ readonly GOOD: 80;
9
+ readonly FAIR: 65;
10
+ readonly AVERAGE: 50;
11
+ };
12
+ export declare function formatScore(score: number | null | undefined, digits?: number): string;
13
+ /** Format total score for "ĐIỂM TỔNG" display. */
14
+ export declare function formatTotalScore(score: number | null | undefined): string;
15
+ export declare function getScoreStroke(totalScore: number | null, maxScore: number, isPendingGrading: boolean): string;
16
+ export declare function getScoreLabel(totalScore: number | null, maxScore: number, isPendingGrading: boolean): string;
17
+ /**
18
+ * Score-aware mascot message. Mirrors {@link getScoreLabel} thresholds so the
19
+ * encouragement text matches the actual result.
20
+ */
21
+ export declare function getScoreMessage(totalScore: number | null, maxScore: number, isPendingGrading: boolean): string;
@@ -0,0 +1 @@
1
+ export const ReviewScoreBand={EXCELLENT:90,GOOD:80,FAIR:65,AVERAGE:50};export function formatScore(n,e=1){if(null==n||Number.isNaN(n))return"—";return n.toFixed(e).replace(/\.0+$/,"").replace(/(\.\d*[1-9])0+$/,"$1")}export function formatTotalScore(n){return null!=n&&Number.isFinite(n)?n<10?n.toFixed(1):String(Math.round(n)):"—"}export function getScoreStroke(n,e,i){if(i||null==n||e<=0)return"conic-gradient(#cbd5e1 0% 100%, #eef5ff 100% 100%)";const t=Math.max(0,Math.min(100,n/e*100));return`conic-gradient(#00AEEF 0% ${t}%, #eef5ff ${t}% 100%)`}export function getScoreLabel(n,e,i){if(i||null==n||e<=0)return"Đang chấm điểm";const t=n/e*100;return t>=ReviewScoreBand.EXCELLENT?"Xuất sắc":t>=ReviewScoreBand.GOOD?"Giỏi":t>=ReviewScoreBand.FAIR?"Khá":t>=ReviewScoreBand.AVERAGE?"Trung bình":"Cần cố gắng"}export function getScoreMessage(n,e,i){if(i)return"Bài làm của bạn đang có câu hỏi tự luận chờ giáo viên chấm điểm. Điểm số sẽ được cập nhật sớm nhất.";if(null==n||e<=0)return"Đừng nản nhé! Hãy xem lại chi tiết bài làm phía dưới và ôn lại kiến thức nhé.";const t=n/e*100;return t>=ReviewScoreBand.EXCELLENT?"Xuất sắc! Bạn nắm rất vững kiến thức. Xem lại chi tiết phía dưới nhé.":t>=ReviewScoreBand.GOOD?"Làm tốt lắm! Xem lại chi tiết bài làm phía dưới để hoàn thiện hơn nhé.":t>=ReviewScoreBand.FAIR?"Khá tốt! Hãy xem lại các câu chưa đúng phía dưới để tiến bộ hơn nhé.":t>=ReviewScoreBand.AVERAGE?"Bạn đã cố gắng! Xem lại kỹ phần chi tiết phía dưới để cải thiện nhé.":"Đừng nản nhé! Hãy xem lại chi tiết bài làm phía dưới và ôn lại kiến thức nhé."}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared logic for Speaking Review "NHẬN XÉT TỔNG QUAN TỪ AI".
3
+ * Source: oe-exam-fe/.../speaking-review/_utils/speaking-summary.ts
4
+ */
5
+ import type { SpeakingReviewData, SpeakingReviewQuestion } from './transform-speaking-review';
6
+ export type SpeakingSkillKey = 'pronunciation' | 'fluency' | 'vocabulary' | 'grammar' | 'coherence';
7
+ export interface SkillScores {
8
+ pronunciation: number | null;
9
+ fluency: number | null;
10
+ vocabulary: number | null;
11
+ grammar: number | null;
12
+ coherence: number | null;
13
+ }
14
+ export interface SkillMetric {
15
+ key: SpeakingSkillKey;
16
+ label: string;
17
+ sublabel: string;
18
+ score: number;
19
+ color: string;
20
+ }
21
+ export type SummaryPointIcon = 'check' | 'star';
22
+ export interface SummaryPoint {
23
+ icon: SummaryPointIcon;
24
+ text: string;
25
+ highlight?: boolean;
26
+ }
27
+ export declare const SPEAKING_SKILL_COLORS: {
28
+ readonly pronunciation: "#4CAF50";
29
+ readonly fluency: "#2196F3";
30
+ readonly vocabulary: "#FF9800";
31
+ readonly grammar: "#9C27B0";
32
+ readonly coherence: "#00BCD4";
33
+ };
34
+ export declare function deriveSkillScores(reviewData: SpeakingReviewData): SkillScores;
35
+ export declare function buildSkillMetrics(scores: SkillScores): SkillMetric[];
36
+ export declare function deriveSkillMetrics(reviewData: SpeakingReviewData): SkillMetric[];
37
+ export declare function deriveQuestionSkillMetrics(question: SpeakingReviewQuestion): SkillMetric[];
38
+ export declare function getOverallStatusText(totalScore: number | null, maxScore: number, isPendingGrading: boolean): string;
39
+ export declare function getScoreLabel(totalScore: number | null, maxScore: number, isPendingGrading: boolean): string;
40
+ export declare function deriveFeedback(reviewData: SpeakingReviewData): string;
41
+ export declare function deriveSummaryPoints(reviewData: SpeakingReviewData, metrics: SkillMetric[]): SummaryPoint[];
@@ -0,0 +1 @@
1
+ export const SPEAKING_SKILL_COLORS={pronunciation:"#4CAF50",fluency:"#2196F3",vocabulary:"#FF9800",grammar:"#9C27B0",coherence:"#00BCD4"};function n(n){return null!=n&&Number.isFinite(n)?Math.round(Math.max(0,Math.min(100,n))):0}function t(n,t){if(t<=0)return null;return n.reduce((n,t)=>n+("number"==typeof t&&Number.isFinite(t)?t:0),0)/t}function e(n){return n&&"object"==typeof n?n:null}function c(n){const t=e(n);return e(t?.pronunciation)}export function deriveSkillScores(n){const r=function(n){return n.parts.flatMap(n=>n.questions)}(n),i=r.filter(n=>!n.isExample),o=i.length;return{pronunciation:t(i.map(n=>{const t=c(n.aiAssessment)?.pronunciationScore;return"number"==typeof t?t:null}),o),fluency:t(i.map(n=>{const t=c(n.aiAssessment)?.fluencyScore;return"number"==typeof t?t:null}),o),vocabulary:t(i.map(n=>{const t=e(n.llmAssessment)?.contentScore;return"number"==typeof t?t:null}),o),grammar:t(i.map(n=>{const t=c(n.aiAssessment)?.accuracyScore;return"number"==typeof t?t:null}),o),coherence:t(i.map(n=>{const t=c(n.aiAssessment)?.prosodyScore;return"number"==typeof t?t:null}),o)}}export function buildSkillMetrics(t){return[{key:"pronunciation",label:"Pronunciation",sublabel:"Phát âm",score:n(t.pronunciation),color:SPEAKING_SKILL_COLORS.pronunciation},{key:"fluency",label:"Fluency",sublabel:"Độ trôi chảy",score:n(t.fluency),color:SPEAKING_SKILL_COLORS.fluency},{key:"grammar",label:"Accuracy",sublabel:"Chính xác phát âm",score:n(t.grammar),color:SPEAKING_SKILL_COLORS.grammar},{key:"coherence",label:"Prosody",sublabel:"Ngữ điệu",score:n(t.coherence),color:SPEAKING_SKILL_COLORS.coherence},{key:"vocabulary",label:"Content",sublabel:"Nội dung",score:n(t.vocabulary),color:SPEAKING_SKILL_COLORS.vocabulary}]}export function deriveSkillMetrics(n){return buildSkillMetrics(deriveSkillScores(n))}export function deriveQuestionSkillMetrics(n){const t=c(n.aiAssessment),r=e(n.llmAssessment);return buildSkillMetrics({pronunciation:"number"==typeof t?.pronunciationScore?t.pronunciationScore:null,fluency:"number"==typeof t?.fluencyScore?t.fluencyScore:null,vocabulary:"number"==typeof r?.contentScore?r.contentScore:null,grammar:"number"==typeof t?.accuracyScore?t.accuracyScore:null,coherence:"number"==typeof t?.prosodyScore?t.prosodyScore:null})}export function getOverallStatusText(n,t,e){if(e||null==n)return"Đang chờ hoàn tất chấm điểm. Kết quả tổng quan sẽ được cập nhật ngay khi có đủ dữ liệu.";const c=t>0?n/t*100:0;return c>=85?"Trình độ rất tốt. Tiếp tục phát huy nhé!":c>=70?"Kết quả tốt. Hãy tiếp tục luyện tập để bứt phá hơn nữa!":c>=50?"Bạn đang tiến bộ tốt. Hãy luyện tập thêm để cải thiện toàn diện hơn.":"Cần luyện tập thêm để nâng cao kỹ năng nói và sự tự tin khi giao tiếp."}export function getScoreLabel(n,t,e){if(e||null==n)return"ĐANG CHỜ";const c=t>0?n/t*100:0;return c>=85?"XUẤT SẮC":c>=70?"TỐT":c>=50?"KHÁ":"CẦN CỐ GẮNG HƠN"}export function deriveFeedback(n){if(n.isPendingGrading)return"Bài thi đang chờ hệ thống hoặc giáo viên hoàn tất chấm điểm. Kết quả chi tiết sẽ được cập nhật sớm nhất có thể.";const t=n.maxScore>0?(n.totalScore??0)/n.maxScore*100:0;return t>=85?"Tuyệt vời! Bạn đã thể hiện khả năng nói tiếng Anh rất tốt. Hãy tiếp tục phát huy và chinh phục những thử thách cao hơn nhé!":t>=70?"Bạn đã có nền tảng nói tiếng Anh khá tốt. Tiếp tục luyện phát âm, mở rộng vốn từ và duy trì sự tự tin khi trả lời.":t>=50?"Bạn cần cố gắng hơn nữa. Hãy dành thêm thời gian luyện phát âm rõ ràng hơn và trả lời mạch lạc hơn ở từng phần thi.":"Bạn cần cố gắng nhiều hơn. Hãy luyện tập từng ngày để nâng cao khả năng nói, sự trôi chảy và mức độ tự tin khi giao tiếp bằng tiếng Anh."}export function deriveSummaryPoints(n,t){const e=Object.fromEntries(t.map(n=>[n.key,n.score])),c=[];return(e.pronunciation??0)>=75?c.push({icon:"check",text:"Phát âm rõ ràng, tự nhiên, ngữ điệu khá tốt."}):c.push({icon:"check",text:"Cần luyện thêm phát âm từng âm và nhấn trọng âm để nói rõ hơn."}),(e.grammar??0)>=75?c.push({icon:"check",text:"Phát âm từng âm chuẩn xác, ít mắc lỗi sai âm."}):c.push({icon:"check",text:"Cần luyện phát âm chuẩn từng âm tiết, đặc biệt các âm tiếng Anh dễ nhầm."}),(e.fluency??0)>=75?c.push({icon:"check",text:"Diễn đạt trôi chảy, tốc độ nói khá tốt."}):c.push({icon:"check",text:"Cần tăng độ trôi chảy và giảm các khoảng ngập ngừng khi trả lời."}),(e.coherence??0)>=75?c.push({icon:"check",text:"Ngữ điệu tự nhiên, nhấn nhá và nhịp điệu khi nói khá tốt."}):c.push({icon:"check",text:"Nên luyện ngữ điệu lên xuống tự nhiên hơn và chú ý nhịp điệu khi nói."}),(e.vocabulary??0)>=75?c.push({icon:"check",text:"Nội dung trả lời bám sát câu hỏi và đầy đủ ý chính."}):c.push({icon:"check",text:"Cần trả lời đúng trọng tâm câu hỏi và đầy đủ ý chính hơn."}),c.push({icon:"star",text:getOverallStatusText(n.totalScore,n.maxScore,n.isPendingGrading),highlight:!0}),c.slice(0,6)}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Transform DetailedResult API response to SpeakingReviewData format.
3
+ * Source: oe-exam-fe/.../speaking-review/_utils/transform-speaking-review.ts
4
+ */
5
+ import { ReviewGradingStatus, type ResultReviewDetail, type ResultReviewTemplatePart } from '../../../api/result-review';
6
+ export declare const SPEAKING_ANSWER_COMPLETED: "completed";
7
+ export declare const SPEAKING_STORY_IMAGES_TYPE: "LISTEN_AND_SPEAK_WITH_STORY_IMAGES";
8
+ export interface SpeakingReviewQuestion {
9
+ questionId: string;
10
+ /** DB answer record ID — used for individual polling. Falls back to questionId. */
11
+ answerId: string;
12
+ questionNumber: number;
13
+ /** Question prompt audio URL — S3 key that needs presigned URL resolution */
14
+ audioUrl: string;
15
+ /** S3 key for student's recorded answer, "completed", or null */
16
+ studentAnswerKey: string | null;
17
+ score: number | null;
18
+ maxScore: number;
19
+ isPendingGrading: boolean;
20
+ isCorrect: boolean | null;
21
+ /** Whether this is a partner example question (responder: PARTNER) — not graded */
22
+ isExample: boolean;
23
+ /** S3 key for partner's pre-recorded example answer audio */
24
+ partnerAudioUrl: string;
25
+ questionType: string;
26
+ questionContent: unknown;
27
+ studentAnswer: unknown;
28
+ correctAnswer: unknown;
29
+ questionGroup: {
30
+ id?: string | number;
31
+ payload?: unknown;
32
+ } | null;
33
+ aiAssessment?: unknown;
34
+ llmAssessment?: unknown;
35
+ classification?: unknown;
36
+ gradingStatus?: ReviewGradingStatus | string | null;
37
+ }
38
+ export interface SpeakingReviewPart {
39
+ partId: string;
40
+ partNo: number;
41
+ partName: string;
42
+ partSectionName: string;
43
+ instructions: string;
44
+ questionType: string;
45
+ questions: SpeakingReviewQuestion[];
46
+ groupPayload: unknown;
47
+ }
48
+ export interface SpeakingReviewData {
49
+ parts: SpeakingReviewPart[];
50
+ examName: string;
51
+ examSubTitle: string;
52
+ studentName: string;
53
+ submittedAt: string;
54
+ totalScore: number | null;
55
+ maxScore: number;
56
+ isPendingGrading: boolean;
57
+ }
58
+ /**
59
+ * Main transformer: convert ResultReviewDetail to SpeakingReviewData.
60
+ */
61
+ export declare function transformSpeakingResultForReview(result: ResultReviewDetail, studentName: string, templateParts?: ResultReviewTemplatePart[] | null): SpeakingReviewData;
62
+ export declare function isSpeakingTemplateType(type: string | null | undefined): boolean;
@@ -0,0 +1 @@
1
+ import{ResultTemplateType as t}from"../../../api/result-review";export const SPEAKING_ANSWER_COMPLETED="completed";export const SPEAKING_STORY_IMAGES_TYPE="LISTEN_AND_SPEAK_WITH_STORY_IMAGES";function e(t){if(null==t)return null;if("string"==typeof t)return t||null;if("object"==typeof t&&null!==t){const e=t;if("string"==typeof e.answer)return e.answer||null;if("string"==typeof e.audioKey)return e.audioKey||null;if("string"==typeof e.objectId)return"completed"}return null}function n(t){if(!t||"object"!=typeof t)return"";const e=t;return"string"==typeof e.audioUrl?e.audioUrl:"string"==typeof e.audio?e.audio:"string"==typeof e.questionAudio?e.questionAudio:"string"==typeof e.interlocutorAudioUrl?e.interlocutorAudioUrl:""}function o(t){if(!t||"object"!=typeof t)return"";const e=t;return"string"==typeof e.partnerAudioUrl?e.partnerAudioUrl:""}function r(t){const e=null!=t.sectionId?`_${t.sectionId}`:"";return`${t.partId??"unknown"}_${t.questionType}${e}`}function s(t,e,n){if(n<=1)return`Part ${t}`;return`Part ${t}${String.fromCharCode(97+e)}`}export function transformSpeakingResultForReview(t,i,u){const{answers:a,examName:c,submittedAt:l,totalScore:p,maxScore:d,isPendingGrading:f}=t,m=Boolean(f),g=new Map;for(const t of a){const e=r(t),n=g.get(e);n?n.push(t):g.set(e,[t])}const A=new Map,S=new Map;for(const[,t]of g){const e=t[0]?.partNo??0;S.set(e,(S.get(e)??0)+1)}const q=[];for(const[,t]of g){t.sort((t,e)=>t.questionNumber-e.questionNumber);const r=t[0];if(!r)continue;const i=r.partId??"unknown",a=r.partNo??0,c=r.questionType,l=r.sectionId,p=A.get(a)??0;A.set(a,p+1);const d=u?.find(t=>String(t.id)===String(i));let f,g=d?.instructions??"";if(d?.sections&&null!=l){const t=d.sections.find(t=>t.sectionId===l);if(t?.name)f=t.name;else{f=s(a,p,S.get(a)??1)}t?.instructions&&(g=t.instructions)}else{f=s(a,p,S.get(a)??1)}const N=`Part ${a}`,y=t.map((t,r)=>{const s=(i=t.studentAnswer)&&"object"==typeof i?i:null;var i;return{questionId:t.questionId,answerId:t.id??t.questionId,questionNumber:r+1,audioUrl:n(t.questionContent),studentAnswerKey:e(t.studentAnswer),isExample:t.isExample??!1,partnerAudioUrl:o(t.questionContent),score:t.score,maxScore:t.maxScore,isPendingGrading:m,isCorrect:t.isCorrect,questionType:t.questionType,questionContent:t.questionContent,studentAnswer:t.studentAnswer,correctAnswer:t.correctAnswer,questionGroup:t.questionGroup??null,aiAssessment:s?.aiAssessment,llmAssessment:s?.llmAssessment,classification:s?.classification,gradingStatus:t.gradingStatus??null}});let I=r.questionGroup?.payload??null;if(c===SPEAKING_STORY_IMAGES_TYPE&&!I){const t=r.questionContent;t?.images&&(I={images:t.images})}q.push({partId:i,partNo:a,partName:N,partSectionName:f,instructions:g,questionType:c,questions:y,groupPayload:I})}return q.sort((t,e)=>t.partNo!==e.partNo?t.partNo-e.partNo:(t.questions[0]?.questionNumber??0)-(e.questions[0]?.questionNumber??0)),{parts:q,examName:c??"",examSubTitle:t.roomName??"",studentName:i,submittedAt:String(l??""),totalScore:m?null:p,maxScore:d,isPendingGrading:m}}export function isSpeakingTemplateType(e){return e===t.SPEAKING}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyweb_dev/oe-exam-sdk",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Reusable OceanEdu question components.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,7 +21,7 @@
21
21
  "build:compile": "tsc -p tsconfig.build.json",
22
22
  "build:alias": "tsc-alias -p tsconfig.build.json",
23
23
  "build:ts": "npm run build:compile && npm run build:assets && npm run build:alias",
24
- "build:assets": "for d in cambridge-yle english-certification summer-sky spaceship; do [ -d src/components/themes/$d/assets ] && cp -R src/components/themes/$d/assets dist/components/themes/$d/ || true; done; [ -d src/components/shared/audio/assets ] && cp -R src/components/shared/audio/assets dist/components/shared/audio/ || true; (cd src && find . -name '*.module.css' -exec cp --parents {} ../dist/ \\;); cp src/global.d.ts dist/global.d.ts",
24
+ "build:assets": "for d in cambridge-yle english-certification summer-sky spaceship; do [ -d src/components/themes/$d/assets ] && cp -R src/components/themes/$d/assets dist/components/themes/$d/ || true; done; [ -d src/components/shared/audio/assets ] && cp -R src/components/shared/audio/assets dist/components/shared/audio/ || true; [ -d src/components/results/report/assets ] && mkdir -p dist/components/results/report && cp -R src/components/results/report/assets dist/components/results/report/ || true; (cd src && find . -name '*.module.css' -exec cp --parents {} ../dist/ \\;); cp src/global.d.ts dist/global.d.ts",
25
25
  "minify:js": "find dist -name '*.js' -type f -exec sh -c 'for f do terser \"$f\" --compress directives=false --mangle --module --comments false --output \"$f\"; done' sh {} +",
26
26
  "build": "npm run clean && npm run build:ts && npm run minify:js",
27
27
  "pack:local": "npm pack",
@@ -109,6 +109,7 @@
109
109
  "@dnd-kit/core": "^6.3.1",
110
110
  "@dnd-kit/sortable": "^10.0.0",
111
111
  "@dnd-kit/utilities": "^3.2.2",
112
+ "@fortawesome/free-solid-svg-icons": "^7.3.1",
112
113
  "@fortawesome/react-fontawesome": "^3.3.1",
113
114
  "@hookform/resolvers": "^5.2.2",
114
115
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -139,6 +140,7 @@
139
140
  "@tiptap/pm": "^3.22.3",
140
141
  "@tiptap/react": "^3.22.3",
141
142
  "@tiptap/starter-kit": "^3.22.3",
143
+ "@types/qrcode": "^1.5.6",
142
144
  "@xyflow/react": "^12.8.6",
143
145
  "axios": "^1.12.2",
144
146
  "class-variance-authority": "^0.7.1",