diff --git a/pom-dependency-tree.txt b/pom-dependency-tree.txt index a72ca2ca8..ca2c41403 100644 --- a/pom-dependency-tree.txt +++ b/pom-dependency-tree.txt @@ -1,4 +1,4 @@ -ai.elimu:webapp:war:2.6.15-SNAPSHOT +ai.elimu:webapp:war:2.6.16-SNAPSHOT +- ai.elimu:model:jar:model-2.0.97:compile | \- com.google.code.gson:gson:jar:2.13.0:compile | \- com.google.errorprone:error_prone_annotations:jar:2.37.0:compile diff --git a/src/main/java/ai/elimu/dao/LetterSoundAssessmentEventDao.java b/src/main/java/ai/elimu/dao/LetterSoundAssessmentEventDao.java new file mode 100644 index 000000000..3e8349baf --- /dev/null +++ b/src/main/java/ai/elimu/dao/LetterSoundAssessmentEventDao.java @@ -0,0 +1,12 @@ +package ai.elimu.dao; + +import java.util.List; + +import org.springframework.dao.DataAccessException; + +import ai.elimu.entity.analytics.LetterSoundAssessmentEvent; + +public interface LetterSoundAssessmentEventDao extends GenericDao { + + List readAll(String androidId) throws DataAccessException; +} diff --git a/src/main/java/ai/elimu/dao/jpa/LetterSoundAssessmentEventDaoJpa.java b/src/main/java/ai/elimu/dao/jpa/LetterSoundAssessmentEventDaoJpa.java new file mode 100644 index 000000000..067e22e56 --- /dev/null +++ b/src/main/java/ai/elimu/dao/jpa/LetterSoundAssessmentEventDaoJpa.java @@ -0,0 +1,22 @@ +package ai.elimu.dao.jpa; + +import java.util.List; + +import org.springframework.dao.DataAccessException; + +import ai.elimu.dao.LetterSoundAssessmentEventDao; +import ai.elimu.entity.analytics.LetterSoundAssessmentEvent; + +public class LetterSoundAssessmentEventDaoJpa extends GenericDaoJpa implements LetterSoundAssessmentEventDao { + + @Override + public List readAll(String androidId) throws DataAccessException { + return em.createQuery( + "SELECT event " + + "FROM LetterSoundAssessmentEvent event " + + "WHERE event.androidId = :androidId " + + "ORDER BY event.timestamp") + .setParameter("androidId", androidId) + .getResultList(); + } +} diff --git a/src/main/java/ai/elimu/entity/analytics/AssessmentEvent.java b/src/main/java/ai/elimu/entity/analytics/AssessmentEvent.java new file mode 100644 index 000000000..5489899d4 --- /dev/null +++ b/src/main/java/ai/elimu/entity/analytics/AssessmentEvent.java @@ -0,0 +1,57 @@ +package ai.elimu.entity.analytics; + +import ai.elimu.entity.BaseEntity; +import ai.elimu.entity.application.Application; +import jakarta.persistence.Column; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.Temporal; +import jakarta.persistence.TemporalType; +import jakarta.validation.constraints.NotNull; +import java.util.Calendar; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@MappedSuperclass +public abstract class AssessmentEvent extends BaseEntity { + + @NotNull + @Temporal(TemporalType.TIMESTAMP) + private Calendar timestamp; + + /** + * A 64-bit number (expressed as a hexadecimal string), unique to each combination of + * app-signing key, user, and device. + * + * See https://developer.android.com/reference/android/provider/Settings.Secure#ANDROID_ID + */ + @NotNull + private String androidId; + + /** + * The package name of the {@link #application} where the assessment event occurred. + * E.g. ai.elimu.soundcards. + */ + @NotNull + private String packageName; + + /** + * This field will only be populated if a corresponding {@link Application} can be + * found in the database for the {@link #packageName}. + */ + @ManyToOne + private Application application; + + /** + * Any additional data should be stored in the format of a JSON object. + * + * Example: + *
+   * {'word_ids_presented': [1,2,3], 'word_id_selected': 2}
+   * 
+ */ + @Column(length = 1024) + private String additionalData; +} diff --git a/src/main/java/ai/elimu/entity/analytics/LearningEvent.java b/src/main/java/ai/elimu/entity/analytics/LearningEvent.java index 40dc92f54..fdf069259 100644 --- a/src/main/java/ai/elimu/entity/analytics/LearningEvent.java +++ b/src/main/java/ai/elimu/entity/analytics/LearningEvent.java @@ -25,6 +25,9 @@ public abstract class LearningEvent extends BaseEntity { private Calendar timestamp; /** + * A 64-bit number (expressed as a hexadecimal string), unique to each combination of + * app-signing key, user, and device. + * * See https://developer.android.com/reference/android/provider/Settings.Secure#ANDROID_ID */ @NotNull diff --git a/src/main/java/ai/elimu/entity/analytics/LetterSoundAssessmentEvent.java b/src/main/java/ai/elimu/entity/analytics/LetterSoundAssessmentEvent.java new file mode 100644 index 000000000..1e59fcafa --- /dev/null +++ b/src/main/java/ai/elimu/entity/analytics/LetterSoundAssessmentEvent.java @@ -0,0 +1,39 @@ +package ai.elimu.entity.analytics; + +import jakarta.persistence.Entity; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +public class LetterSoundAssessmentEvent extends AssessmentEvent { + + /** + * The sequence of letters. E.g. "sh". + */ + private String letterSoundLetters; + + /** + * The sequence of sounds (IPA values). E.g. "ʃ". + */ + private String letterSoundSounds; + + /** + * This field might not be included, e.g. if the assessment task was done in a + * 3rd-party app that did not load the content from the elimu.ai Content Provider. + * In this case, the {@link #letterSoundId} will be {@code null}. + */ + private Long letterSoundId; + + /** + * A value in the range [0.0, 1.0]. + */ + private Float masteryScore; + + /** + * The number of milliseconds passed between the student opening the assessment task + * and submitting a response. E.g. 15000. + */ + private Long timeSpentMs; +} diff --git a/src/main/java/ai/elimu/rest/v2/analytics/LetterSoundAssessmentEventsRestController.java b/src/main/java/ai/elimu/rest/v2/analytics/LetterSoundAssessmentEventsRestController.java new file mode 100644 index 000000000..9c18a80bb --- /dev/null +++ b/src/main/java/ai/elimu/rest/v2/analytics/LetterSoundAssessmentEventsRestController.java @@ -0,0 +1,91 @@ +package ai.elimu.rest.v2.analytics; + +import ai.elimu.model.v2.enums.Language; +import ai.elimu.util.AnalyticsHelper; +import ai.elimu.util.ConfigHelper; +import java.io.File; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +import org.apache.commons.io.FileUtils; +import org.json.JSONObject; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * REST API endpoint for receiving letter-sound assessment events from the + * Analytics application. + */ +@RestController +@RequestMapping(value = "/rest/v2/analytics/letter-sound-assessment-events/csv", produces = MediaType.APPLICATION_JSON_VALUE) +@Slf4j +public class LetterSoundAssessmentEventsRestController { + + @PostMapping + public String handleUploadCsvRequest( + @RequestParam("file") MultipartFile multipartFile, + HttpServletResponse response + ) { + log.info("handleUploadCsvRequest"); + + JSONObject jsonResponseObject = new JSONObject(); + try { + String contentType = multipartFile.getContentType(); + log.info("contentType: " + contentType); + + long size = multipartFile.getSize(); + log.info("size: " + size); + if (size == 0) { + throw new IllegalArgumentException("Empty file"); + } + + // Expected format: "7161a85a0e4751cd_3002023_letter-sound-assessment-events_2025-05-28.csv" + String originalFilename = multipartFile.getOriginalFilename(); + log.info("originalFilename: " + originalFilename); + if (originalFilename.length() != "7161a85a0e4751cd_3002023_letter-sound-assessment-events_2025-05-28.csv".length()) { + throw new IllegalArgumentException("Unexpected filename"); + } + + String androidIdExtractedFromFilename = AnalyticsHelper.extractAndroidIdFromCsvFilename(originalFilename); + log.info("androidIdExtractedFromFilename: \"" + androidIdExtractedFromFilename + "\""); + + Integer versionCodeExtractedFromFilename = AnalyticsHelper.extractVersionCodeFromCsvFilename(originalFilename); + log.info("versionCodeExtractedFromFilename: " + versionCodeExtractedFromFilename); + + byte[] bytes = multipartFile.getBytes(); + log.info("bytes.length: " + bytes.length); + + // Store the original CSV file on the filesystem + File elimuAiDir = new File(System.getProperty("user.home"), ".elimu-ai"); + File languageDir = new File(elimuAiDir, "lang-" + Language.valueOf(ConfigHelper.getProperty("content.language"))); + File analyticsDir = new File(languageDir, "analytics"); + File androidIdDir = new File(analyticsDir, "android-id-" + androidIdExtractedFromFilename); + File versionCodeDir = new File(androidIdDir, "version-code-" + versionCodeExtractedFromFilename); + File letterSoundAssessmentEventsDir = new File(versionCodeDir, "letter-sound-assessment-events"); + letterSoundAssessmentEventsDir.mkdirs(); + File csvFile = new File(letterSoundAssessmentEventsDir, originalFilename); + log.info("Storing CSV file at " + csvFile); + FileUtils.writeByteArrayToFile(csvFile, bytes); + log.info("csvFile.exists(): " + csvFile.exists()); + + jsonResponseObject.put("result", "success"); + jsonResponseObject.put("successMessage", "The CSV file was uploaded"); + response.setStatus(HttpStatus.OK.value()); + } catch (Exception ex) { + log.error(ex.getMessage()); + + jsonResponseObject.put("result", "error"); + jsonResponseObject.put("errorMessage", ex.getMessage()); + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + } + + String jsonResponse = jsonResponseObject.toString(); + log.info("jsonResponse: " + jsonResponse); + return jsonResponse; + } +} diff --git a/src/main/java/ai/elimu/web/analytics/students/LetterSoundAssessmentEventCsvExportController.java b/src/main/java/ai/elimu/web/analytics/students/LetterSoundAssessmentEventCsvExportController.java new file mode 100644 index 000000000..fe8421b6c --- /dev/null +++ b/src/main/java/ai/elimu/web/analytics/students/LetterSoundAssessmentEventCsvExportController.java @@ -0,0 +1,93 @@ +package ai.elimu.web.analytics.students; + +import ai.elimu.dao.LetterSoundAssessmentEventDao; +import ai.elimu.dao.StudentDao; +import ai.elimu.entity.analytics.LetterSoundAssessmentEvent; +import ai.elimu.entity.analytics.students.Student; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.OutputStream; +import java.io.StringWriter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/analytics/students/{studentId}/letter-sound-assessment-events.csv") +@RequiredArgsConstructor +@Slf4j +public class LetterSoundAssessmentEventCsvExportController { + + private final StudentDao studentDao; + + private final LetterSoundAssessmentEventDao letterSoundAssessmentEventDao; + + @GetMapping + public void handleRequest( + @PathVariable Long studentId, + HttpServletResponse response, + OutputStream outputStream + ) throws IOException { + log.info("handleRequest"); + + Student student = studentDao.read(studentId); + log.info("student.getAndroidId(): " + student.getAndroidId()); + + List letterSoundAssessmentEvents = letterSoundAssessmentEventDao.readAll(student.getAndroidId()); + log.info("letterSoundAssessmentEvents.size(): " + letterSoundAssessmentEvents.size()); + + CSVFormat csvFormat = CSVFormat.DEFAULT.builder() + .setHeader( + "id", + "timestamp", + "package_name", + "letter_sound_letters", + "letter_sound_sounds", + "letter_sound_id", + "mastery_score", + "time_spent_ms", + "additional_data" + ) + .build(); + + StringWriter stringWriter = new StringWriter(); + CSVPrinter csvPrinter = new CSVPrinter(stringWriter, csvFormat); + + for (LetterSoundAssessmentEvent letterSoundAssessmentEvent : letterSoundAssessmentEvents) { + log.info("letterSoundAssessmentEvent.getId(): " + letterSoundAssessmentEvent.getId()); + + csvPrinter.printRecord( + letterSoundAssessmentEvent.getId(), + letterSoundAssessmentEvent.getTimestamp().getTimeInMillis(), + letterSoundAssessmentEvent.getPackageName(), + letterSoundAssessmentEvent.getLetterSoundLetters(), + letterSoundAssessmentEvent.getLetterSoundSounds(), + letterSoundAssessmentEvent.getLetterSoundId(), + letterSoundAssessmentEvent.getMasteryScore(), + letterSoundAssessmentEvent.getTimeSpentMs(), + letterSoundAssessmentEvent.getAdditionalData() + ); + } + csvPrinter.flush(); + csvPrinter.close(); + + String csvFileContent = stringWriter.toString(); + + response.setContentType("text/csv"); + byte[] bytes = csvFileContent.getBytes(); + response.setContentLength(bytes.length); + try { + outputStream.write(bytes); + outputStream.flush(); + outputStream.close(); + } catch (IOException ex) { + log.error(ex.getMessage()); + } + } +} diff --git a/src/main/java/ai/elimu/web/analytics/students/StudentController.java b/src/main/java/ai/elimu/web/analytics/students/StudentController.java index 74e9b6ee3..f7f446c0b 100644 --- a/src/main/java/ai/elimu/web/analytics/students/StudentController.java +++ b/src/main/java/ai/elimu/web/analytics/students/StudentController.java @@ -1,13 +1,19 @@ package ai.elimu.web.analytics.students; +import ai.elimu.dao.LetterSoundAssessmentEventDao; +import ai.elimu.dao.LetterSoundLearningEventDao; import ai.elimu.dao.StoryBookLearningEventDao; import ai.elimu.dao.StudentDao; import ai.elimu.dao.VideoLearningEventDao; import ai.elimu.dao.WordLearningEventDao; +import ai.elimu.entity.analytics.LetterSoundAssessmentEvent; +import ai.elimu.entity.analytics.LetterSoundLearningEvent; import ai.elimu.entity.analytics.StoryBookLearningEvent; import ai.elimu.entity.analytics.VideoLearningEvent; import ai.elimu.entity.analytics.WordLearningEvent; import ai.elimu.entity.analytics.students.Student; +import ai.elimu.model.v2.enums.content.LiteracySkill; +import ai.elimu.model.v2.enums.content.NumeracySkill; import ai.elimu.util.AnalyticsHelper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -33,6 +39,9 @@ public class StudentController { private final StudentDao studentDao; + private final LetterSoundAssessmentEventDao letterSoundAssessmentEventDao; + private final LetterSoundLearningEventDao letterSoundLearningEventDao; + private final WordLearningEventDao wordLearningEventDao; private final StoryBookLearningEventDao storyBookLearningEventDao; @@ -46,6 +55,17 @@ public String handleRequest(@PathVariable Long studentId, Model model) { Student student = studentDao.read(studentId); log.info("student.getAndroidId(): " + student.getAndroidId()); + + model.addAttribute("literacySkills", LiteracySkill.values()); + model.addAttribute("numeracySkills", NumeracySkill.values()); + + + List letterSoundAssessmentEvents = letterSoundAssessmentEventDao.readAll(); + model.addAttribute("letterSoundAssessmentEvents", letterSoundAssessmentEvents); + + List letterSoundLearningEvents = letterSoundLearningEventDao.readAll(); + model.addAttribute("letterSoundLearningEvents", letterSoundLearningEvents); + // Prepare chart data - WordLearningEvents List wordLearningEvents = wordLearningEventDao.readAll(); diff --git a/src/main/resources/META-INF/jpa-schema-export.sql b/src/main/resources/META-INF/jpa-schema-export.sql index 96cf69fae..1ff92627e 100644 --- a/src/main/resources/META-INF/jpa-schema-export.sql +++ b/src/main/resources/META-INF/jpa-schema-export.sql @@ -43,6 +43,8 @@ drop table if exists LetterSound_Sound; + drop table if exists LetterSoundAssessmentEvent; + drop table if exists LetterSoundContributionEvent; drop table if exists LetterSoundLearningEvent; @@ -304,6 +306,21 @@ primary key (LetterSound_id, sounds_ORDER) ) type=MyISAM; + create table LetterSoundAssessmentEvent ( + id bigint not null auto_increment, + additionalData text, + androidId varchar(255), + packageName varchar(255), + timestamp datetime, + letterSoundId bigint, + letterSoundLetters varchar(255), + letterSoundSounds varchar(255), + masteryScore float(23), + timeSpentMs bigint, + application_id bigint, + primary key (id) + ) type=MyISAM; + create table LetterSoundContributionEvent ( id bigint not null auto_increment, comment text, @@ -755,6 +772,11 @@ foreign key (LetterSound_id) references LetterSound (id); + alter table LetterSoundAssessmentEvent + add constraint FKehf1rjbixnmdjol91didaj4b5 + foreign key (application_id) + references Application (id); + alter table LetterSoundContributionEvent add constraint FK5uk320agfa13pvh52v6n6ncbs foreign key (contributor_id) diff --git a/src/main/webapp/WEB-INF/jsp/analytics/students/id.jsp b/src/main/webapp/WEB-INF/jsp/analytics/students/id.jsp index 7d1af0b44..92dd8a66a 100644 --- a/src/main/webapp/WEB-INF/jsp/analytics/students/id.jsp +++ b/src/main/webapp/WEB-INF/jsp/analytics/students/id.jsp @@ -1,16 +1,151 @@ - 🎓 Student ${student.id} + 🎓 Student #${student.id} -

-
${student.androidId}
+
+
+
+ " /> + + + +
+
+

Student ID

+ #${student.id}
+
+

Android ID

+ ${student.androidId} +
+
+
+
+
+ + + +
+
+
+ +
+
Literacy skills
+
+ + + + + + + + + + + + + + + +
SkillMastery
+ ${literacySkill} + + 0% + +
+
+
+
+
+
+
+
Numeracy skills
+
+ + + + + + + + + + + + + + + +
SkillMastery
+ ${numeracySkill} + + 0% + +
+
+
+
+
+
+
- +
🎼 Letter-sounds
+
+ + Export to CSVvertical_align_bottom + + +
Assessment events (${fn:length(letterSoundAssessmentEvents)})
+ ... + +
+ +
Learning events (${fn:length(letterSoundLearningEvents)})
+ ... +
+
+
🔤 Words
-
🔤 Word learning events (${fn:length(wordLearningEvents)})
- +
Assessment events (${fn:length(wordAssessmentEvents)})
+ ... + +
+ +
Learning events (${fn:length(wordLearningEvents)})
+
+
🔢 Numbers
-
📚 Storybook learning events
- +
Assessment events (${fn:length(numberAssessmentEvents)})
+ ... + +
+ +
Learning events (${fn:length(numberLearningEvents)})
+ ... +
+
+ +
📚 Storybooks
+
+
Assessment events (${fn:length(storyBookAssessmentEvents)})
+ ... + +
+ +
Learning events (${fn:length(storyBookLearningEvents)})
+
+
🎬 Videos
-
🎬 Video learning events
+
Assessment events (${fn:length(videoAssessmentEvents)})
+ ... + +
+
Learning events (${fn:length(videoLearningEvents)})