Skip to content

Commit 105acb1

Browse files
authored
feat: letter-sound assessment event (#2195)
2 parents 19dd981 + c30663a commit 105acb1

38 files changed

+708
-15
lines changed

pom-dependency-tree.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
ai.elimu:webapp:war:2.6.15-SNAPSHOT
1+
ai.elimu:webapp:war:2.6.16-SNAPSHOT
22
+- ai.elimu:model:jar:model-2.0.97:compile
33
| \- com.google.code.gson:gson:jar:2.13.0:compile
44
| \- com.google.errorprone:error_prone_annotations:jar:2.37.0:compile
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package ai.elimu.dao;
2+
3+
import java.util.List;
4+
5+
import org.springframework.dao.DataAccessException;
6+
7+
import ai.elimu.entity.analytics.LetterSoundAssessmentEvent;
8+
9+
public interface LetterSoundAssessmentEventDao extends GenericDao<LetterSoundAssessmentEvent> {
10+
11+
List<LetterSoundAssessmentEvent> readAll(String androidId) throws DataAccessException;
12+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package ai.elimu.dao.jpa;
2+
3+
import java.util.List;
4+
5+
import org.springframework.dao.DataAccessException;
6+
7+
import ai.elimu.dao.LetterSoundAssessmentEventDao;
8+
import ai.elimu.entity.analytics.LetterSoundAssessmentEvent;
9+
10+
public class LetterSoundAssessmentEventDaoJpa extends GenericDaoJpa<LetterSoundAssessmentEvent> implements LetterSoundAssessmentEventDao {
11+
12+
@Override
13+
public List<LetterSoundAssessmentEvent> readAll(String androidId) throws DataAccessException {
14+
return em.createQuery(
15+
"SELECT event " +
16+
"FROM LetterSoundAssessmentEvent event " +
17+
"WHERE event.androidId = :androidId " +
18+
"ORDER BY event.timestamp")
19+
.setParameter("androidId", androidId)
20+
.getResultList();
21+
}
22+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package ai.elimu.entity.analytics;
2+
3+
import ai.elimu.entity.BaseEntity;
4+
import ai.elimu.entity.application.Application;
5+
import jakarta.persistence.Column;
6+
import jakarta.persistence.ManyToOne;
7+
import jakarta.persistence.MappedSuperclass;
8+
import jakarta.persistence.Temporal;
9+
import jakarta.persistence.TemporalType;
10+
import jakarta.validation.constraints.NotNull;
11+
import java.util.Calendar;
12+
import lombok.Getter;
13+
import lombok.Setter;
14+
15+
@Getter
16+
@Setter
17+
@MappedSuperclass
18+
public abstract class AssessmentEvent extends BaseEntity {
19+
20+
@NotNull
21+
@Temporal(TemporalType.TIMESTAMP)
22+
private Calendar timestamp;
23+
24+
/**
25+
* A 64-bit number (expressed as a hexadecimal string), unique to each combination of
26+
* app-signing key, user, and device.
27+
*
28+
* See https://developer.android.com/reference/android/provider/Settings.Secure#ANDROID_ID
29+
*/
30+
@NotNull
31+
private String androidId;
32+
33+
/**
34+
* The package name of the {@link #application} where the assessment event occurred.
35+
* E.g. <code>ai.elimu.soundcards</code>.
36+
*/
37+
@NotNull
38+
private String packageName;
39+
40+
/**
41+
* This field will only be populated if a corresponding {@link Application} can be
42+
* found in the database for the {@link #packageName}.
43+
*/
44+
@ManyToOne
45+
private Application application;
46+
47+
/**
48+
* Any additional data should be stored in the format of a JSON object.
49+
*
50+
* Example:
51+
* <pre>
52+
* {'word_ids_presented': [1,2,3], 'word_id_selected': 2}
53+
* </pre>
54+
*/
55+
@Column(length = 1024)
56+
private String additionalData;
57+
}

src/main/java/ai/elimu/entity/analytics/LearningEvent.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ public abstract class LearningEvent extends BaseEntity {
2525
private Calendar timestamp;
2626

2727
/**
28+
* A 64-bit number (expressed as a hexadecimal string), unique to each combination of
29+
* app-signing key, user, and device.
30+
*
2831
* See https://developer.android.com/reference/android/provider/Settings.Secure#ANDROID_ID
2932
*/
3033
@NotNull
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package ai.elimu.entity.analytics;
2+
3+
import jakarta.persistence.Entity;
4+
import lombok.Getter;
5+
import lombok.Setter;
6+
7+
@Getter
8+
@Setter
9+
@Entity
10+
public class LetterSoundAssessmentEvent extends AssessmentEvent {
11+
12+
/**
13+
* The sequence of letters. E.g. <code>"sh"</code>.
14+
*/
15+
private String letterSoundLetters;
16+
17+
/**
18+
* The sequence of sounds (IPA values). E.g. <code>"ʃ"</code>.
19+
*/
20+
private String letterSoundSounds;
21+
22+
/**
23+
* This field might not be included, e.g. if the assessment task was done in a
24+
* 3rd-party app that did not load the content from the elimu.ai Content Provider.
25+
* In this case, the {@link #letterSoundId} will be {@code null}.
26+
*/
27+
private Long letterSoundId;
28+
29+
/**
30+
* A value in the range [0.0, 1.0].
31+
*/
32+
private Float masteryScore;
33+
34+
/**
35+
* The number of milliseconds passed between the student opening the assessment task
36+
* and submitting a response. E.g. <code>15000</code>.
37+
*/
38+
private Long timeSpentMs;
39+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package ai.elimu.rest.v2.analytics;
2+
3+
import ai.elimu.model.v2.enums.Language;
4+
import ai.elimu.util.AnalyticsHelper;
5+
import ai.elimu.util.ConfigHelper;
6+
import java.io.File;
7+
import jakarta.servlet.http.HttpServletResponse;
8+
import lombok.extern.slf4j.Slf4j;
9+
10+
import org.apache.commons.io.FileUtils;
11+
import org.json.JSONObject;
12+
import org.springframework.http.HttpStatus;
13+
import org.springframework.http.MediaType;
14+
import org.springframework.web.bind.annotation.PostMapping;
15+
import org.springframework.web.bind.annotation.RequestMapping;
16+
import org.springframework.web.bind.annotation.RequestParam;
17+
import org.springframework.web.bind.annotation.RestController;
18+
import org.springframework.web.multipart.MultipartFile;
19+
20+
/**
21+
* REST API endpoint for receiving letter-sound assessment events from the
22+
* <a href="https://github.com/elimu-ai/analytics">Analytics</a> application.
23+
*/
24+
@RestController
25+
@RequestMapping(value = "/rest/v2/analytics/letter-sound-assessment-events/csv", produces = MediaType.APPLICATION_JSON_VALUE)
26+
@Slf4j
27+
public class LetterSoundAssessmentEventsRestController {
28+
29+
@PostMapping
30+
public String handleUploadCsvRequest(
31+
@RequestParam("file") MultipartFile multipartFile,
32+
HttpServletResponse response
33+
) {
34+
log.info("handleUploadCsvRequest");
35+
36+
JSONObject jsonResponseObject = new JSONObject();
37+
try {
38+
String contentType = multipartFile.getContentType();
39+
log.info("contentType: " + contentType);
40+
41+
long size = multipartFile.getSize();
42+
log.info("size: " + size);
43+
if (size == 0) {
44+
throw new IllegalArgumentException("Empty file");
45+
}
46+
47+
// Expected format: "7161a85a0e4751cd_3002023_letter-sound-assessment-events_2025-05-28.csv"
48+
String originalFilename = multipartFile.getOriginalFilename();
49+
log.info("originalFilename: " + originalFilename);
50+
if (originalFilename.length() != "7161a85a0e4751cd_3002023_letter-sound-assessment-events_2025-05-28.csv".length()) {
51+
throw new IllegalArgumentException("Unexpected filename");
52+
}
53+
54+
String androidIdExtractedFromFilename = AnalyticsHelper.extractAndroidIdFromCsvFilename(originalFilename);
55+
log.info("androidIdExtractedFromFilename: \"" + androidIdExtractedFromFilename + "\"");
56+
57+
Integer versionCodeExtractedFromFilename = AnalyticsHelper.extractVersionCodeFromCsvFilename(originalFilename);
58+
log.info("versionCodeExtractedFromFilename: " + versionCodeExtractedFromFilename);
59+
60+
byte[] bytes = multipartFile.getBytes();
61+
log.info("bytes.length: " + bytes.length);
62+
63+
// Store the original CSV file on the filesystem
64+
File elimuAiDir = new File(System.getProperty("user.home"), ".elimu-ai");
65+
File languageDir = new File(elimuAiDir, "lang-" + Language.valueOf(ConfigHelper.getProperty("content.language")));
66+
File analyticsDir = new File(languageDir, "analytics");
67+
File androidIdDir = new File(analyticsDir, "android-id-" + androidIdExtractedFromFilename);
68+
File versionCodeDir = new File(androidIdDir, "version-code-" + versionCodeExtractedFromFilename);
69+
File letterSoundAssessmentEventsDir = new File(versionCodeDir, "letter-sound-assessment-events");
70+
letterSoundAssessmentEventsDir.mkdirs();
71+
File csvFile = new File(letterSoundAssessmentEventsDir, originalFilename);
72+
log.info("Storing CSV file at " + csvFile);
73+
FileUtils.writeByteArrayToFile(csvFile, bytes);
74+
log.info("csvFile.exists(): " + csvFile.exists());
75+
76+
jsonResponseObject.put("result", "success");
77+
jsonResponseObject.put("successMessage", "The CSV file was uploaded");
78+
response.setStatus(HttpStatus.OK.value());
79+
} catch (Exception ex) {
80+
log.error(ex.getMessage());
81+
82+
jsonResponseObject.put("result", "error");
83+
jsonResponseObject.put("errorMessage", ex.getMessage());
84+
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
85+
}
86+
87+
String jsonResponse = jsonResponseObject.toString();
88+
log.info("jsonResponse: " + jsonResponse);
89+
return jsonResponse;
90+
}
91+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package ai.elimu.web.analytics.students;
2+
3+
import ai.elimu.dao.LetterSoundAssessmentEventDao;
4+
import ai.elimu.dao.StudentDao;
5+
import ai.elimu.entity.analytics.LetterSoundAssessmentEvent;
6+
import ai.elimu.entity.analytics.students.Student;
7+
import jakarta.servlet.http.HttpServletResponse;
8+
import java.io.IOException;
9+
import java.io.OutputStream;
10+
import java.io.StringWriter;
11+
import java.util.List;
12+
import lombok.RequiredArgsConstructor;
13+
import lombok.extern.slf4j.Slf4j;
14+
import org.apache.commons.csv.CSVFormat;
15+
import org.apache.commons.csv.CSVPrinter;
16+
import org.springframework.stereotype.Controller;
17+
import org.springframework.web.bind.annotation.GetMapping;
18+
import org.springframework.web.bind.annotation.PathVariable;
19+
import org.springframework.web.bind.annotation.RequestMapping;
20+
21+
@Controller
22+
@RequestMapping("/analytics/students/{studentId}/letter-sound-assessment-events.csv")
23+
@RequiredArgsConstructor
24+
@Slf4j
25+
public class LetterSoundAssessmentEventCsvExportController {
26+
27+
private final StudentDao studentDao;
28+
29+
private final LetterSoundAssessmentEventDao letterSoundAssessmentEventDao;
30+
31+
@GetMapping
32+
public void handleRequest(
33+
@PathVariable Long studentId,
34+
HttpServletResponse response,
35+
OutputStream outputStream
36+
) throws IOException {
37+
log.info("handleRequest");
38+
39+
Student student = studentDao.read(studentId);
40+
log.info("student.getAndroidId(): " + student.getAndroidId());
41+
42+
List<LetterSoundAssessmentEvent> letterSoundAssessmentEvents = letterSoundAssessmentEventDao.readAll(student.getAndroidId());
43+
log.info("letterSoundAssessmentEvents.size(): " + letterSoundAssessmentEvents.size());
44+
45+
CSVFormat csvFormat = CSVFormat.DEFAULT.builder()
46+
.setHeader(
47+
"id",
48+
"timestamp",
49+
"package_name",
50+
"letter_sound_letters",
51+
"letter_sound_sounds",
52+
"letter_sound_id",
53+
"mastery_score",
54+
"time_spent_ms",
55+
"additional_data"
56+
)
57+
.build();
58+
59+
StringWriter stringWriter = new StringWriter();
60+
CSVPrinter csvPrinter = new CSVPrinter(stringWriter, csvFormat);
61+
62+
for (LetterSoundAssessmentEvent letterSoundAssessmentEvent : letterSoundAssessmentEvents) {
63+
log.info("letterSoundAssessmentEvent.getId(): " + letterSoundAssessmentEvent.getId());
64+
65+
csvPrinter.printRecord(
66+
letterSoundAssessmentEvent.getId(),
67+
letterSoundAssessmentEvent.getTimestamp().getTimeInMillis(),
68+
letterSoundAssessmentEvent.getPackageName(),
69+
letterSoundAssessmentEvent.getLetterSoundLetters(),
70+
letterSoundAssessmentEvent.getLetterSoundSounds(),
71+
letterSoundAssessmentEvent.getLetterSoundId(),
72+
letterSoundAssessmentEvent.getMasteryScore(),
73+
letterSoundAssessmentEvent.getTimeSpentMs(),
74+
letterSoundAssessmentEvent.getAdditionalData()
75+
);
76+
}
77+
csvPrinter.flush();
78+
csvPrinter.close();
79+
80+
String csvFileContent = stringWriter.toString();
81+
82+
response.setContentType("text/csv");
83+
byte[] bytes = csvFileContent.getBytes();
84+
response.setContentLength(bytes.length);
85+
try {
86+
outputStream.write(bytes);
87+
outputStream.flush();
88+
outputStream.close();
89+
} catch (IOException ex) {
90+
log.error(ex.getMessage());
91+
}
92+
}
93+
}

src/main/java/ai/elimu/web/analytics/students/StudentController.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
package ai.elimu.web.analytics.students;
22

3+
import ai.elimu.dao.LetterSoundAssessmentEventDao;
4+
import ai.elimu.dao.LetterSoundLearningEventDao;
35
import ai.elimu.dao.StoryBookLearningEventDao;
46
import ai.elimu.dao.StudentDao;
57
import ai.elimu.dao.VideoLearningEventDao;
68
import ai.elimu.dao.WordLearningEventDao;
9+
import ai.elimu.entity.analytics.LetterSoundAssessmentEvent;
10+
import ai.elimu.entity.analytics.LetterSoundLearningEvent;
711
import ai.elimu.entity.analytics.StoryBookLearningEvent;
812
import ai.elimu.entity.analytics.VideoLearningEvent;
913
import ai.elimu.entity.analytics.WordLearningEvent;
1014
import ai.elimu.entity.analytics.students.Student;
15+
import ai.elimu.model.v2.enums.content.LiteracySkill;
16+
import ai.elimu.model.v2.enums.content.NumeracySkill;
1117
import ai.elimu.util.AnalyticsHelper;
1218
import lombok.RequiredArgsConstructor;
1319
import lombok.extern.slf4j.Slf4j;
@@ -33,6 +39,9 @@ public class StudentController {
3339

3440
private final StudentDao studentDao;
3541

42+
private final LetterSoundAssessmentEventDao letterSoundAssessmentEventDao;
43+
private final LetterSoundLearningEventDao letterSoundLearningEventDao;
44+
3645
private final WordLearningEventDao wordLearningEventDao;
3746

3847
private final StoryBookLearningEventDao storyBookLearningEventDao;
@@ -46,6 +55,17 @@ public String handleRequest(@PathVariable Long studentId, Model model) {
4655
Student student = studentDao.read(studentId);
4756
log.info("student.getAndroidId(): " + student.getAndroidId());
4857

58+
59+
model.addAttribute("literacySkills", LiteracySkill.values());
60+
model.addAttribute("numeracySkills", NumeracySkill.values());
61+
62+
63+
List<LetterSoundAssessmentEvent> letterSoundAssessmentEvents = letterSoundAssessmentEventDao.readAll();
64+
model.addAttribute("letterSoundAssessmentEvents", letterSoundAssessmentEvents);
65+
66+
List<LetterSoundLearningEvent> letterSoundLearningEvents = letterSoundLearningEventDao.readAll();
67+
model.addAttribute("letterSoundLearningEvents", letterSoundLearningEvents);
68+
4969

5070
// Prepare chart data - WordLearningEvents
5171
List<WordLearningEvent> wordLearningEvents = wordLearningEventDao.readAll();

0 commit comments

Comments
 (0)