@magmamath/students-features 1.5.1 → 1.5.3-rc.1

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 (51) hide show
  1. package/dist/commonjs/features/chatbot/index.js +7 -0
  2. package/dist/commonjs/features/chatbot/index.js.map +1 -1
  3. package/dist/commonjs/features/chatbot/model/VoiceTranscriptSnapshotModel.js +99 -0
  4. package/dist/commonjs/features/chatbot/model/VoiceTranscriptSnapshotModel.js.map +1 -0
  5. package/dist/commonjs/features/pmProgress/model/PmProgressModel.js +7 -1
  6. package/dist/commonjs/features/pmProgress/model/PmProgressModel.js.map +1 -1
  7. package/dist/commonjs/features/pmProgress/model/PmRecommendationsModel.js +20 -4
  8. package/dist/commonjs/features/pmProgress/model/PmRecommendationsModel.js.map +1 -1
  9. package/dist/commonjs/features/pmProgress/shared/pmProgress.helpers.js +22 -1
  10. package/dist/commonjs/features/pmProgress/shared/pmProgress.helpers.js.map +1 -1
  11. package/dist/module/features/chatbot/index.js +1 -0
  12. package/dist/module/features/chatbot/index.js.map +1 -1
  13. package/dist/module/features/chatbot/model/VoiceTranscriptSnapshotModel.js +94 -0
  14. package/dist/module/features/chatbot/model/VoiceTranscriptSnapshotModel.js.map +1 -0
  15. package/dist/module/features/pmProgress/model/PmProgressModel.js +7 -1
  16. package/dist/module/features/pmProgress/model/PmProgressModel.js.map +1 -1
  17. package/dist/module/features/pmProgress/model/PmRecommendationsModel.js +20 -4
  18. package/dist/module/features/pmProgress/model/PmRecommendationsModel.js.map +1 -1
  19. package/dist/module/features/pmProgress/shared/pmProgress.helpers.js +20 -0
  20. package/dist/module/features/pmProgress/shared/pmProgress.helpers.js.map +1 -1
  21. package/dist/typescript/commonjs/features/chatbot/index.d.ts +1 -0
  22. package/dist/typescript/commonjs/features/chatbot/index.d.ts.map +1 -1
  23. package/dist/typescript/commonjs/features/chatbot/model/VoiceTranscriptSnapshotModel.d.ts +27 -0
  24. package/dist/typescript/commonjs/features/chatbot/model/VoiceTranscriptSnapshotModel.d.ts.map +1 -0
  25. package/dist/typescript/commonjs/features/chatbot/types/api.types.d.ts +1 -0
  26. package/dist/typescript/commonjs/features/chatbot/types/api.types.d.ts.map +1 -1
  27. package/dist/typescript/commonjs/features/pmProgress/model/PmProgressModel.d.ts.map +1 -1
  28. package/dist/typescript/commonjs/features/pmProgress/model/PmRecommendationsModel.d.ts +9 -38
  29. package/dist/typescript/commonjs/features/pmProgress/model/PmRecommendationsModel.d.ts.map +1 -1
  30. package/dist/typescript/commonjs/features/pmProgress/shared/pmProgress.constants.d.ts +1 -1
  31. package/dist/typescript/commonjs/features/pmProgress/shared/pmProgress.helpers.d.ts +1 -0
  32. package/dist/typescript/commonjs/features/pmProgress/shared/pmProgress.helpers.d.ts.map +1 -1
  33. package/dist/typescript/module/features/chatbot/index.d.ts +1 -0
  34. package/dist/typescript/module/features/chatbot/index.d.ts.map +1 -1
  35. package/dist/typescript/module/features/chatbot/model/VoiceTranscriptSnapshotModel.d.ts +27 -0
  36. package/dist/typescript/module/features/chatbot/model/VoiceTranscriptSnapshotModel.d.ts.map +1 -0
  37. package/dist/typescript/module/features/chatbot/types/api.types.d.ts +1 -0
  38. package/dist/typescript/module/features/chatbot/types/api.types.d.ts.map +1 -1
  39. package/dist/typescript/module/features/pmProgress/model/PmProgressModel.d.ts.map +1 -1
  40. package/dist/typescript/module/features/pmProgress/model/PmRecommendationsModel.d.ts +9 -38
  41. package/dist/typescript/module/features/pmProgress/model/PmRecommendationsModel.d.ts.map +1 -1
  42. package/dist/typescript/module/features/pmProgress/shared/pmProgress.constants.d.ts +1 -1
  43. package/dist/typescript/module/features/pmProgress/shared/pmProgress.helpers.d.ts +1 -0
  44. package/dist/typescript/module/features/pmProgress/shared/pmProgress.helpers.d.ts.map +1 -1
  45. package/package.json +2 -3
  46. package/src/features/chatbot/index.ts +1 -0
  47. package/src/features/chatbot/model/VoiceTranscriptSnapshotModel.ts +128 -0
  48. package/src/features/chatbot/types/api.types.ts +1 -0
  49. package/src/features/pmProgress/model/PmProgressModel.ts +4 -1
  50. package/src/features/pmProgress/model/PmRecommendationsModel.ts +22 -6
  51. package/src/features/pmProgress/shared/pmProgress.helpers.ts +33 -0
@@ -0,0 +1,128 @@
1
+ import { createEffect, sample } from 'effector'
2
+ import {
3
+ AudioTranscriptResponse,
4
+ TranscriptionStatus,
5
+ VoiceRecordCollectionItem,
6
+ } from '../../voice/types'
7
+ import { VoiceRecordModel } from '../../voice/recording/model/VoiceRecord.model'
8
+ import { RequestOptionalConfig } from '../../../lib/effector/createControllerEffect'
9
+
10
+ const TRANSCRIPT_RETRY_INTERVAL_MS = 3000
11
+ const TRANSCRIPT_MAX_RETRIES = 20
12
+
13
+ type GetTranscriptApi = {
14
+ getAudioFileTranscript: (audioFileId: string) => Promise<AudioTranscriptResponse>
15
+ }
16
+
17
+ type CaptureParams = {
18
+ voiceKey: string
19
+ record: VoiceRecordCollectionItem
20
+ }
21
+
22
+ type VoiceTranscriptSnapshotModelProps = {
23
+ voiceRecord: VoiceRecordModel
24
+ api: GetTranscriptApi
25
+ }
26
+
27
+ // Voice key: `${assignmentId}-${problemId}-${attemptsCount}`
28
+ // Chat key: `${assignmentId}-${problemId}` — survives attempt increments and voice deletion.
29
+ const toChatKey = (voiceKey: string): string | null => {
30
+ const lastDashIndex = voiceKey.lastIndexOf('-')
31
+ if (lastDashIndex <= 0) return null
32
+ return voiceKey.slice(0, lastDashIndex)
33
+ }
34
+
35
+ export class VoiceTranscriptSnapshotModel {
36
+ public readonly fetchTranscript = (audioFileId: string): Promise<string | undefined> => {
37
+ const cached = this.transcriptByAudioFileId.get(audioFileId)
38
+ if (cached) return cached
39
+
40
+ const promise = this.pollTranscript(audioFileId).catch(() => undefined)
41
+ this.transcriptByAudioFileId.set(audioFileId, promise)
42
+ return promise
43
+ }
44
+
45
+ public readonly getTranscriptForCurrent = async (): Promise<string | undefined> => {
46
+ const voiceKey = this.voiceRecord.$currentKey.getState()
47
+ if (!voiceKey) return undefined
48
+
49
+ const chatKey = toChatKey(voiceKey)
50
+ if (chatKey) {
51
+ const snapshot = this.snapshotByChatKey.get(chatKey)
52
+ if (snapshot) return snapshot
53
+ }
54
+
55
+ const record = this.voiceRecord.collection.get(voiceKey)
56
+ if (!record) return undefined
57
+
58
+ const audioFileId = await this.resolveAudioFileId(record)
59
+ if (!audioFileId) return undefined
60
+
61
+ return this.fetchTranscript(audioFileId)
62
+ }
63
+
64
+ public readonly wrapRequestHint = <P extends object, R>(
65
+ apiFn: (
66
+ params: P & { voiceTranscript?: string },
67
+ requestConfig?: RequestOptionalConfig,
68
+ ) => Promise<R>,
69
+ ) => {
70
+ return async (params: P, requestConfig?: RequestOptionalConfig): Promise<R> => {
71
+ const voiceTranscript = await this.getTranscriptForCurrent()
72
+ return apiFn(voiceTranscript ? { ...params, voiceTranscript } : params, requestConfig)
73
+ }
74
+ }
75
+
76
+ constructor({ voiceRecord, api }: VoiceTranscriptSnapshotModelProps) {
77
+ this.voiceRecord = voiceRecord
78
+ this.api = api
79
+
80
+ sample({
81
+ clock: voiceRecord.setCurrentRecord,
82
+ source: voiceRecord.$currentKey,
83
+ filter: (_, record): record is VoiceRecordCollectionItem => !!record,
84
+ fn: (voiceKey, record) => ({ voiceKey, record }),
85
+ target: this.captureSnapshotFx,
86
+ })
87
+ }
88
+
89
+ private readonly voiceRecord: VoiceRecordModel
90
+ private readonly api: GetTranscriptApi
91
+ private readonly transcriptByAudioFileId = new Map<string, Promise<string | undefined>>()
92
+ private readonly snapshotByChatKey = new Map<string, Promise<string | undefined>>()
93
+
94
+ private readonly captureSnapshotFx = createEffect(async ({ voiceKey, record }: CaptureParams) => {
95
+ const chatKey = toChatKey(voiceKey)
96
+ if (!chatKey) return
97
+
98
+ const audioFileId = await this.resolveAudioFileId(record)
99
+ if (!audioFileId) return
100
+
101
+ this.snapshotByChatKey.set(chatKey, this.fetchTranscript(audioFileId))
102
+ })
103
+
104
+ private async pollTranscript(audioFileId: string): Promise<string | undefined> {
105
+ for (let attempt = 0; attempt < TRANSCRIPT_MAX_RETRIES; attempt++) {
106
+ await new Promise((resolve) => setTimeout(resolve, TRANSCRIPT_RETRY_INTERVAL_MS))
107
+
108
+ const response = await this.api.getAudioFileTranscript(audioFileId)
109
+
110
+ if (response.status === TranscriptionStatus.COMPLETED) return response.text
111
+ if (response.status === TranscriptionStatus.FAILED) return undefined
112
+ }
113
+ return undefined
114
+ }
115
+
116
+ private async resolveAudioFileId(
117
+ record: VoiceRecordCollectionItem,
118
+ ): Promise<string | undefined> {
119
+ if (record.id) return record.id
120
+ if (!record.audioUploadPromise) return undefined
121
+ try {
122
+ const result = await record.audioUploadPromise
123
+ return result?.id
124
+ } catch {
125
+ return undefined
126
+ }
127
+ }
128
+ }
@@ -130,6 +130,7 @@ export type ChatHintPayload = {
130
130
  studentInput: string
131
131
  answerOptions?: string[]
132
132
  assignmentGrade?: number
133
+ voiceTranscript?: string
133
134
  }
134
135
 
135
136
  export type ChatHintResponse = {
@@ -43,7 +43,10 @@ export class PmProgressModel {
43
43
 
44
44
  sample({
45
45
  clock: this.tree.initTreeFx.done,
46
- fn: ({ result: [tree] }) => tree,
46
+ fn: ({ params: { user }, result: [tree] }) => ({
47
+ tree,
48
+ lockedGrades: user.setting.practiceModeLockedGrades,
49
+ }),
47
50
  target: this.recommendations.initRecommendationsFx,
48
51
  })
49
52
  }
@@ -2,6 +2,12 @@ import { PmProgressApi } from './PmProgressApi'
2
2
  import { createEffect, createEvent, createStore, sample } from 'effector'
3
3
  import { Recommendations, RecsResponse, TreeMappingResponse } from '../shared/pmProgress.types'
4
4
  import { RECOMMENDATIONS_LIMIT_PARAM, SolvingFlow } from '../shared/pmProgress.constants'
5
+ import { filterLockedGradeRecs } from '../shared/pmProgress.helpers'
6
+
7
+ type FetchMagmaRecsParams = {
8
+ tree: TreeMappingResponse
9
+ lockedGrades: number[]
10
+ }
5
11
 
6
12
  const EMPTY_RECS: Recommendations = { items: [], offset: 0, limit: 0, total: 0 }
7
13
 
@@ -22,14 +28,24 @@ export class PmRecommendationsModel {
22
28
  ),
23
29
  )
24
30
 
25
- public readonly fetchMagmaRecsFx = createEffect((tree: TreeMappingResponse) =>
26
- this.fetchRecs(tree, SolvingFlow.MAGMA, () =>
27
- this.api.getMagmaRecsFx({ limit: RECOMMENDATIONS_LIMIT_PARAM }),
28
- ),
31
+ public readonly fetchMagmaRecsFx = createEffect(
32
+ async ({ tree, lockedGrades }: FetchMagmaRecsParams) => {
33
+ const recs = await this.fetchRecs(tree, SolvingFlow.MAGMA, () =>
34
+ this.api.getMagmaRecsFx({ limit: RECOMMENDATIONS_LIMIT_PARAM }),
35
+ )
36
+ return {
37
+ ...recs,
38
+ items: filterLockedGradeRecs(recs.items, tree.treeMapping, lockedGrades),
39
+ }
40
+ },
29
41
  )
30
42
 
31
- public readonly initRecommendationsFx = createEffect((tree: TreeMappingResponse) =>
32
- Promise.all([this.fetchTeacherRecsFx(tree), this.fetchMagmaRecsFx(tree)]),
43
+ public readonly initRecommendationsFx = createEffect(
44
+ ({ tree, lockedGrades }: FetchMagmaRecsParams) =>
45
+ Promise.all([
46
+ this.fetchTeacherRecsFx(tree),
47
+ this.fetchMagmaRecsFx({ tree, lockedGrades }),
48
+ ]),
33
49
  )
34
50
 
35
51
  private fetchRecs = async (
@@ -298,6 +298,39 @@ export const formatGradeLabel = (grade: number, customLabelsMap?: GradeLabelsMap
298
298
  return `${getText('pmProgress.grade')} ${label.name}`
299
299
  }
300
300
 
301
+ const buildSkillGradeMap = (
302
+ treeMapping: TreeMappingResponse['treeMapping'],
303
+ ): Map<string, number> => {
304
+ const map = new Map<string, number>()
305
+
306
+ const collectSkills = (nodeId: string, gradeValue: number) => {
307
+ const node = treeMapping[nodeId]
308
+ if (!node) return
309
+ node.skills.forEach((skillId) => map.set(skillId, gradeValue))
310
+ node.children.forEach((childId) => collectSkills(childId, gradeValue))
311
+ }
312
+
313
+ Object.values(treeMapping)
314
+ .filter((node) => node.type === 'grade')
315
+ .forEach((gradeNode) => collectSkills(gradeNode._id, gradeNode.attributes.grade))
316
+
317
+ return map
318
+ }
319
+
320
+ export const filterLockedGradeRecs = (
321
+ skills: Skill[],
322
+ treeMapping: TreeMappingResponse['treeMapping'],
323
+ lockedGrades: number[],
324
+ ): Skill[] => {
325
+ if (lockedGrades.length === 0) return skills
326
+ const locked = new Set(lockedGrades)
327
+ const skillGradeMap = buildSkillGradeMap(treeMapping)
328
+ return skills.filter((skill) => {
329
+ const grade = skillGradeMap.get(skill._id)
330
+ return grade === undefined || !locked.has(grade)
331
+ })
332
+ }
333
+
301
334
  export const getTreeGrades = (tree: TreeMappingResponse, user: User): Grade[] => {
302
335
  const lockedGrades = new Set(user.setting.practiceModeLockedGrades)
303
336