Merge pull request 'feat/adding-h5p-tasks' (#10) from feat/adding-h5p-tasks into main
All checks were successful
CI Pipeline / lint-and-test (push) Successful in 2m35s
All checks were successful
CI Pipeline / lint-and-test (push) Successful in 2m35s
Reviewed-on: https://owgit.duckdns.org/DrunkenPinguin/LernApp/pulls/10 Reviewed-by: mt <aldemir.mt@icloud.com>
This commit is contained in:
commit
c8032cbf62
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,3 +11,4 @@ build/
|
||||||
*.iws
|
*.iws
|
||||||
*.ipr
|
*.ipr
|
||||||
out/
|
out/
|
||||||
|
build/
|
||||||
|
|
|
||||||
118
README.md
118
README.md
|
|
@ -13,8 +13,9 @@ Kinder können durch das Lösen von Lernaufgaben, beispielsweise Mathematikaufga
|
||||||
| Feature | Beschreibung |
|
| Feature | Beschreibung |
|
||||||
| :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **JWT-Authentifizierung** | Sichere API-Endpunkte für authentifizierte Benutzer über JSON Web Tokens (JWT). |
|
| **JWT-Authentifizierung** | Sichere API-Endpunkte für authentifizierte Benutzer über JSON Web Tokens (JWT). |
|
||||||
| **Lernaufgaben (Tasks)** | Abrufen von interaktiven Lernaufgaben mit vordefinierten Antwortmöglichkeiten und Belohnungen in Minuten. |
|
| **Lernaufgaben (Tasks)** | Abrufen von interaktiven Lernaufgaben mit vordefinierten Antwortmöglichkeiten, Filterung nach Fach und Alter sowie Belohnungen in Minuten. |
|
||||||
| **Antwort-Verifizierung** | Überprüfung der vom Kind ausgewählten Antworten. Bei korrekter Antwort wird die Belohnung direkt auf die verbleibende Bildschirmzeit aufgerechnet. |
|
| **Antwort-Verifizierung** | Überprüfung der vom Kind ausgewählten Antworten. Bei korrekter Antwort wird die Belohnung direkt auf die verbleibende Bildschirmzeit aufgerechnet. |
|
||||||
|
| **OER / H5P Ingestion Pipeline** | Automatisierter Live-Crawler und Batch-Import von freien Bildungsinhalten (ZUM.de / H5P) inklusive HTML-Bereinigung und Lizenzattributierung (CC-BY). |
|
||||||
| **Bildschirmzeit-Synchronisation** | Laufender Datenabgleich der verbrauchten Bildschirmzeit. Sinkt die verbleibende Zeit auf oder unter null, wird der Status automatisch auf `isBlocked = true` gesetzt. |
|
| **Bildschirmzeit-Synchronisation** | Laufender Datenabgleich der verbrauchten Bildschirmzeit. Sinkt die verbleibende Zeit auf oder unter null, wird der Status automatisch auf `isBlocked = true` gesetzt. |
|
||||||
| **Zwei-Ebenen-Speicher** | PostgreSQL als persistente Datenbank, kombiniert mit Redis-Caching für schnelle Screentime-Abfragen. |
|
| **Zwei-Ebenen-Speicher** | PostgreSQL als persistente Datenbank, kombiniert mit Redis-Caching für schnelle Screentime-Abfragen. |
|
||||||
| **Fallback-Mechanismus** | Ist Redis nicht erreichbar, fällt das System automatisch und transparent auf PostgreSQL zurück. |
|
| **Fallback-Mechanismus** | Ist Redis nicht erreichbar, fällt das System automatisch und transparent auf PostgreSQL zurück. |
|
||||||
|
|
@ -98,7 +99,8 @@ Prüft, ob der Backend-Server ordnungsgemäß läuft.
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsIn...",
|
"token": "eyJhbGciOiJIUzI1NiIsIn...",
|
||||||
"familyName": "Familie Schmidt"
|
"userId": "6380776f-88d3-4e9f-a2be-0d9cacae447e",
|
||||||
|
"message": "Registrierung erfolgreich"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -128,7 +130,9 @@ Prüft, ob der Backend-Server ordnungsgemäß läuft.
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsIn...",
|
"token": "eyJhbGciOiJIUzI1NiIsIn...",
|
||||||
"familyName": "Familie Schmidt"
|
"userId": "6380776f-88d3-4e9f-a2be-0d9cacae447e",
|
||||||
|
"familyName": "Familie Schmidt",
|
||||||
|
"childAge": 8
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -156,9 +160,9 @@ Authorization: Bearer <Token>
|
||||||
|
|
||||||
**Beschreibung:**
|
**Beschreibung:**
|
||||||
|
|
||||||
Liefert die aktuell verbleibende Bildschirmzeit sowie den aktuellen Sperrstatus.
|
Liefert die aktuell verbleibende Bildschirmzeit sowie den aktuellen Sperrstatus des authentifizierten Benutzers.
|
||||||
|
|
||||||
**Antwort:**
|
**Antwort – `200 OK`:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -181,29 +185,28 @@ Liefert die aktuell verbleibende Bildschirmzeit sowie den aktuellen Sperrstatus.
|
||||||
|
|
||||||
**Beschreibung:**
|
**Beschreibung:**
|
||||||
|
|
||||||
Zieht die seit dem letzten Abgleich verbrauchten Sekunden von der verfügbaren Bildschirmzeit ab.
|
Zieht die seit dem letzten Abgleich vom Endgerät verbrauchten Sekunden von der verfügbaren Bildschirmzeit ab.
|
||||||
|
|
||||||
**Request-Body:**
|
**Request-Body:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"userId": "default_user",
|
"usedSecondsSinceLastSync": 30
|
||||||
"usedSecondsSinceLastSync": 200
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Antwort:**
|
**Antwort – `200 OK`:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"remainingSeconds": 1600,
|
"remainingSeconds": 1770,
|
||||||
"isBlocked": false
|
"isBlocked": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### 6. Alle verfügbaren Aufgaben abrufen
|
#### 6. Lernaufgaben abrufen (mit Fach- & Altersfilter)
|
||||||
|
|
||||||
**Methode:** `GET`
|
**Methode:** `GET`
|
||||||
|
|
||||||
|
|
@ -213,24 +216,42 @@ Zieht die seit dem letzten Abgleich verbrauchten Sekunden von der verfügbaren B
|
||||||
/api/v1/tasks
|
/api/v1/tasks
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Optionale Query-Parameter:**
|
||||||
|
|
||||||
|
| Parameter | Typ | Standardwert | Beschreibung |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `subject` | String | `null` | Schulfach (z. B. `Mathematik`, `Deutsch`, `Sachunterricht`, `Englisch`, `Biologie`, `Geographie`, `Geschichte`). |
|
||||||
|
| `age` | Integer | `null` | Zielalter des Kindes (filtert nach `minAge <= age AND maxAge >= age`). |
|
||||||
|
| `limit` | Integer | `10` | Maximale Anzahl an zurückgegebenen Aufgaben. |
|
||||||
|
|
||||||
|
**Beispiel-Anfrage:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/tasks?subject=Mathematik&age=8&limit=5
|
||||||
|
```
|
||||||
|
|
||||||
**Beschreibung:**
|
**Beschreibung:**
|
||||||
|
|
||||||
Liefert eine Liste aller verfügbaren Lernaufgaben.
|
Liefert kuratierte oder aus OER/H5P importierte Lernaufgaben. Die korrekte Lösung (`correctAnswer`) wird zum Schutz vor Manipulationen serverseitig geheim gehalten und niemals an den Client übertragen.
|
||||||
|
|
||||||
**Antwort:**
|
**Antwort – `200 OK`:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "t1",
|
"id": "zum-math-001",
|
||||||
"question": "Was ist 12 + 15?",
|
"subject": "Mathematik",
|
||||||
|
"minAge": 8,
|
||||||
|
"maxAge": 9,
|
||||||
|
"question": "Was ist das Ergebnis von 6 × 8?",
|
||||||
"options": [
|
"options": [
|
||||||
"25",
|
"42",
|
||||||
"27",
|
"48",
|
||||||
"30",
|
"54",
|
||||||
"22"
|
"56"
|
||||||
],
|
],
|
||||||
"rewardMinutes": 10
|
"rewardMinutes": 5,
|
||||||
|
"license": "CC BY-SA 4.0"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
@ -251,22 +272,22 @@ Liefert eine Liste aller verfügbaren Lernaufgaben.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"taskId": "t1",
|
"taskId": "zum-math-001",
|
||||||
"selectedAnswer": "27"
|
"selectedAnswer": "48"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Antwort – korrekte Lösung:**
|
**Antwort – korrekte Lösung (`200 OK`):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"isCorrect": true,
|
"isCorrect": true,
|
||||||
"earnedMinutes": 10,
|
"earnedMinutes": 5,
|
||||||
"message": "Super gemacht! Du hast 10 Minuten gewonnen."
|
"message": "Super gemacht! Du hast 5 Minuten gewonnen."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Antwort – falsche Lösung:**
|
**Antwort – falsche Lösung (`200 OK`):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -278,6 +299,38 @@ Liefert eine Liste aller verfügbaren Lernaufgaben.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## H5P / ZUM.de Aufgaben-Import (Standalone CLI)
|
||||||
|
|
||||||
|
Das Backend verfügt über ein integriertes CLI-Tool zum automatisierten Importieren und Crawlen interaktiver Lernaufgaben von **ZUM.de / OER (H5P)**.
|
||||||
|
|
||||||
|
### Batch-Import ausführen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle Fächer importieren (z. B. 100 Aufgaben)
|
||||||
|
./gradlew runIngestion --args="--limit=100"
|
||||||
|
|
||||||
|
# Gezielt ein Schulfach importieren (z. B. Mathematik)
|
||||||
|
./gradlew runIngestion --args="--subject=Mathematik --limit=50"
|
||||||
|
|
||||||
|
# Deutsch für Grundschüler importieren
|
||||||
|
./gradlew runIngestion --args="--subject=Deutsch --limit=30"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI-Optionen
|
||||||
|
|
||||||
|
| Argument | Standardwert | Beschreibung |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `--subject=<Name>` | *(alle Fächer)* | Filtert Aufgaben nach Fach (z. B. `Mathematik`, `Deutsch`, `Englisch`, `Sachunterricht`). |
|
||||||
|
| `--limit=<Zahl>` | `50` | Maximale Anzahl an Aufgaben, die im Durchlauf importiert/aktualisiert werden. |
|
||||||
|
|
||||||
|
### Funktionsweise der Ingestion-Pipeline
|
||||||
|
1. **Live-Crawler & Discovery:** Durchsucht `apps.zum.de` nach interaktiven Inhalten (`H5P.MultiChoice`, `H5P.TrueFalse`, `H5P.QuestionSet`).
|
||||||
|
2. **Textbereinigung:** Entfernt störende HTML-Tags (`<p>`, `<strong>`, ` `) für native mobile Darstellung.
|
||||||
|
3. **Taxonomie- & Alters-Mapping:** Weist Altersgrenzen (`minAge`, `maxAge`) basierend auf Klassenstufen automatisch zu.
|
||||||
|
4. **Idempotenter Upsert:** Speichert Fragen, Optionen, geheime Lösungen und CC-Lizenzen direkt relational in PostgreSQL (`TasksTable`). Duplikate werden vermieden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Projektkonfiguration und Umgebungsvariablen
|
## Projektkonfiguration und Umgebungsvariablen
|
||||||
|
|
||||||
Die Anwendung wird über folgende Konfigurationsdatei konfiguriert:
|
Die Anwendung wird über folgende Konfigurationsdatei konfiguriert:
|
||||||
|
|
@ -517,3 +570,16 @@ MIT License
|
||||||
```
|
```
|
||||||
|
|
||||||
Eine entsprechende `LICENSE`-Datei sollte sich im Root-Verzeichnis des Repositorys befinden.
|
Eine entsprechende `LICENSE`-Datei sollte sich im Root-Verzeichnis des Repositorys befinden.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kidio Ökosystem
|
||||||
|
Für Kinder (Der spielerische Teil - "Kid"):
|
||||||
|
|
||||||
|
* Kidio Learn: Interaktive Lernspiele (Mathe, Sprachen, Logik), die sich dem Alter und Lernfortschritt des Kindes anpassen.
|
||||||
|
|
||||||
|
* Kidio Create: Tools und Plattformen, um eigene Ideen, Kunstwerke oder kleine Projekte zu entwickeln und zu teilen.
|
||||||
|
|
||||||
|
* Kidio Media: Ein sicherer Bereich mit kindgerechten Videos, Hörspielen und Büchern (ähnlich wie YouTube Kids).
|
||||||
|

|
||||||
|
|
@ -11,6 +11,12 @@ version = "1.0.0-SNAPSHOT"
|
||||||
application {
|
application {
|
||||||
mainClass = "io.ktor.server.netty.EngineMain"
|
mainClass = "io.ktor.server.netty.EngineMain"
|
||||||
}
|
}
|
||||||
|
ktor {
|
||||||
|
openApi {
|
||||||
|
enabled = true // Aktiviert die OpenAPI-Code-Generierung
|
||||||
|
codeInferenceEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain(21)
|
||||||
|
|
@ -20,7 +26,7 @@ dependencies {
|
||||||
implementation(ktorLibs.server.config.yaml)
|
implementation(ktorLibs.server.config.yaml)
|
||||||
implementation(ktorLibs.server.core)
|
implementation(ktorLibs.server.core)
|
||||||
implementation(ktorLibs.server.netty)
|
implementation(ktorLibs.server.netty)
|
||||||
|
implementation("io.ktor:ktor-server-openapi")
|
||||||
// JWT Authentication
|
// JWT Authentication
|
||||||
implementation("io.ktor:ktor-server-auth")
|
implementation("io.ktor:ktor-server-auth")
|
||||||
implementation("io.ktor:ktor-server-auth-jwt")
|
implementation("io.ktor:ktor-server-auth-jwt")
|
||||||
|
|
@ -46,3 +52,11 @@ dependencies {
|
||||||
// redis
|
// redis
|
||||||
implementation("redis.clients:jedis:5.1.2")
|
implementation("redis.clients:jedis:5.1.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.register<JavaExec>("runIngestion") {
|
||||||
|
group = "application"
|
||||||
|
description = "Runs the standalone H5P/ZUM task ingestion CLI tool"
|
||||||
|
mainClass.set("com.oliver.kidio.backend.ingestion.H5PIngestionCli")
|
||||||
|
classpath = sourceSets["main"].runtimeClasspath
|
||||||
|
standardInput = System.`in`
|
||||||
|
}
|
||||||
|
|
|
||||||
23
docs/.swagger-codegen-ignore
Normal file
23
docs/.swagger-codegen-ignore
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Swagger Codegen Ignore
|
||||||
|
# Generated by swagger-codegen https://github.com/swagger-api/swagger-codegen
|
||||||
|
|
||||||
|
# Use this file to prevent files from being overwritten by the generator.
|
||||||
|
# The patterns follow closely to .gitignore or .dockerignore.
|
||||||
|
|
||||||
|
# As an example, the C# client generator defines ApiClient.cs.
|
||||||
|
# You can make changes and tell Swagger Codgen to ignore just this file by uncommenting the following line:
|
||||||
|
#ApiClient.cs
|
||||||
|
|
||||||
|
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
|
||||||
|
#foo/*/qux
|
||||||
|
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
|
||||||
|
|
||||||
|
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
|
||||||
|
#foo/**/qux
|
||||||
|
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
|
||||||
|
|
||||||
|
# You can also negate patterns with an exclamation (!).
|
||||||
|
# For example, you can ignore all files in a docs folder with the file extension .md:
|
||||||
|
#docs/*.md
|
||||||
|
# Then explicitly reverse the ignore rule for a single file:
|
||||||
|
#!docs/README.md
|
||||||
1
docs/.swagger-codegen/VERSION
Normal file
1
docs/.swagger-codegen/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.0.81
|
||||||
4987
docs/index.html
Normal file
4987
docs/index.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -7,13 +7,24 @@ import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransacti
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
|
|
||||||
object TasksTable : Table("tasks") {
|
object TasksTable : Table("tasks") {
|
||||||
val id = varchar("id", 50)
|
val id = varchar("id", 64)
|
||||||
|
val externalSource = varchar("external_source", 32).default("local")
|
||||||
|
val sourceUrl = text("source_url").nullable()
|
||||||
|
val license = varchar("license", 100).default("CC BY-SA 4.0")
|
||||||
|
val subject = varchar("subject", 64).default("Mathematik")
|
||||||
|
val minAge = integer("min_age").default(6)
|
||||||
|
val maxAge = integer("max_age").default(12)
|
||||||
|
val taskType = varchar("task_type", 32).default("MULTIPLE_CHOICE")
|
||||||
val question = text("question")
|
val question = text("question")
|
||||||
val options = text("options")
|
val options = text("options")
|
||||||
val correctAnswer = varchar("correct_answer", 255)
|
val correctAnswer = varchar("correct_answer", 255)
|
||||||
val rewardMinutes = integer("reward_minutes")
|
val rewardMinutes = integer("reward_minutes").default(5)
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
|
||||||
|
init {
|
||||||
|
index("idx_tasks_filter", false, subject, minAge, maxAge)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object ScreentimeTable : Table("user_screentime") {
|
object ScreentimeTable : Table("user_screentime") {
|
||||||
|
|
@ -27,9 +38,9 @@ object UserTable : Table("users") {
|
||||||
val id = varchar("id", 36)
|
val id = varchar("id", 36)
|
||||||
val email = varchar("email", 255).uniqueIndex()
|
val email = varchar("email", 255).uniqueIndex()
|
||||||
val passwordHash = varchar("password_hash", 255)
|
val passwordHash = varchar("password_hash", 255)
|
||||||
val familyName = varchar("family_name", 100)
|
val familyName = varchar("family_name", 100).default("Familie")
|
||||||
val childAge = integer("child_age")
|
val childAge = integer("child_age").default(8)
|
||||||
val parentPin = varchar("parent_pin", 4)
|
val parentPin = varchar("parent_pin", 4).default("1234")
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
}
|
}
|
||||||
|
|
@ -55,11 +66,42 @@ object DatabaseFactory {
|
||||||
)
|
)
|
||||||
|
|
||||||
transaction(database) {
|
transaction(database) {
|
||||||
SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
|
// Fill existing NULL values if columns exist before SchemaUtils alters constraints
|
||||||
|
exec(
|
||||||
|
"""
|
||||||
|
DO ${'$'}${'$'}
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='users') THEN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='family_name') THEN
|
||||||
|
UPDATE users SET family_name = 'Familie' WHERE family_name IS NULL;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='child_age') THEN
|
||||||
|
UPDATE users SET child_age = 8 WHERE child_age IS NULL;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='parent_pin') THEN
|
||||||
|
UPDATE users SET parent_pin = '1234' WHERE parent_pin IS NULL;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='tasks') THEN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='tasks' AND column_name='subject') THEN
|
||||||
|
UPDATE tasks SET subject = 'Mathematik' WHERE subject IS NULL;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END ${'$'}${'$'};
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
SchemaUtils.createMissingTablesAndColumns(TasksTable, ScreentimeTable, UserTable)
|
||||||
|
|
||||||
if (TasksTable.selectAll().empty()) {
|
if (TasksTable.selectAll().empty()) {
|
||||||
TasksTable.insert {
|
TasksTable.insert {
|
||||||
it[id] = "t1"
|
it[id] = "t1"
|
||||||
|
it[externalSource] = "local"
|
||||||
|
it[license] = "CC BY-SA 4.0"
|
||||||
|
it[subject] = "Mathematik"
|
||||||
|
it[minAge] = 6
|
||||||
|
it[maxAge] = 10
|
||||||
|
it[taskType] = "MULTIPLE_CHOICE"
|
||||||
it[question] = "Was ist 12 + 15?"
|
it[question] = "Was ist 12 + 15?"
|
||||||
it[options] = "25,27,30,22"
|
it[options] = "25,27,30,22"
|
||||||
it[correctAnswer] = "27"
|
it[correctAnswer] = "27"
|
||||||
|
|
@ -67,6 +109,12 @@ object DatabaseFactory {
|
||||||
}
|
}
|
||||||
TasksTable.insert {
|
TasksTable.insert {
|
||||||
it[id] = "t2"
|
it[id] = "t2"
|
||||||
|
it[externalSource] = "local"
|
||||||
|
it[license] = "CC BY-SA 4.0"
|
||||||
|
it[subject] = "Mathematik"
|
||||||
|
it[minAge] = 7
|
||||||
|
it[maxAge] = 12
|
||||||
|
it[taskType] = "MULTIPLE_CHOICE"
|
||||||
it[question] = "Was ist 8 x 7?"
|
it[question] = "Was ist 8 x 7?"
|
||||||
it[options] = "54,56,64,48"
|
it[options] = "54,56,64,48"
|
||||||
it[correctAnswer] = "56"
|
it[correctAnswer] = "56"
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,29 @@ import kotlinx.serialization.Serializable
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Task(
|
data class Task(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
val subject: String = "Mathematik",
|
||||||
|
val minAge: Int = 6,
|
||||||
|
val maxAge: Int = 12,
|
||||||
val question: String,
|
val question: String,
|
||||||
val options: List<String>,
|
val options: List<String>,
|
||||||
val rewardMinutes: Int,
|
val rewardMinutes: Int,
|
||||||
|
val license: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Interne Datenklasse für importierte Aufgaben (Ingestion)
|
||||||
|
data class ImportedTask(
|
||||||
|
val id: String,
|
||||||
|
val externalSource: String = "zum.de",
|
||||||
|
val sourceUrl: String? = null,
|
||||||
|
val license: String = "CC BY-SA 4.0",
|
||||||
|
val subject: String,
|
||||||
|
val minAge: Int,
|
||||||
|
val maxAge: Int,
|
||||||
|
val taskType: String = "MULTIPLE_CHOICE",
|
||||||
|
val question: String,
|
||||||
|
val options: List<String>,
|
||||||
|
val correctAnswer: String,
|
||||||
|
val rewardMinutes: Int = 5,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Anfrage des Kindes, wenn es eine Aufgabe löst
|
// Anfrage des Kindes, wenn es eine Aufgabe löst
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.oliver.kidio.backend.domain.repository
|
package com.oliver.kidio.backend.domain.repository
|
||||||
|
|
||||||
|
import com.oliver.kidio.backend.ImportedTask
|
||||||
import com.oliver.kidio.backend.Task
|
import com.oliver.kidio.backend.Task
|
||||||
import com.oliver.kidio.backend.User
|
import com.oliver.kidio.backend.User
|
||||||
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
import com.oliver.kidio.backend.data.database.DatabaseFactory
|
||||||
|
|
@ -8,10 +9,27 @@ import com.oliver.kidio.backend.data.database.ScreentimeTable
|
||||||
import com.oliver.kidio.backend.data.database.TasksTable
|
import com.oliver.kidio.backend.data.database.TasksTable
|
||||||
import com.oliver.kidio.backend.data.database.UserTable
|
import com.oliver.kidio.backend.data.database.UserTable
|
||||||
import org.jetbrains.exposed.sql.*
|
import org.jetbrains.exposed.sql.*
|
||||||
|
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||||
|
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greaterEq
|
||||||
|
import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq
|
||||||
|
import org.jetbrains.exposed.sql.lowerCase
|
||||||
import org.mindrot.jbcrypt.BCrypt
|
import org.mindrot.jbcrypt.BCrypt
|
||||||
|
|
||||||
interface KidioRepository {
|
interface KidioRepository {
|
||||||
suspend fun getAllTasks(): List<Task>
|
suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
||||||
|
|
||||||
|
suspend fun getUserById(userId: String): User?
|
||||||
|
|
||||||
|
suspend fun getTasks(
|
||||||
|
subject: String? = null,
|
||||||
|
age: Int? = null,
|
||||||
|
limit: Int = 10,
|
||||||
|
): List<Task>
|
||||||
|
|
||||||
|
// Mit suspend markiert, damit es zur DatabaseFactory.dbQuery Architektur passt
|
||||||
|
suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set<String>
|
||||||
|
|
||||||
|
suspend fun upsertTask(task: ImportedTask): Boolean
|
||||||
|
|
||||||
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
|
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
|
||||||
|
|
||||||
|
|
@ -33,18 +51,103 @@ interface KidioRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExposedKidioRepository : KidioRepository {
|
class ExposedKidioRepository : KidioRepository {
|
||||||
override suspend fun getAllTasks(): List<Task> =
|
override suspend fun getTasks(
|
||||||
|
subject: String?,
|
||||||
|
age: Int?,
|
||||||
|
limit: Int,
|
||||||
|
): List<Task> =
|
||||||
DatabaseFactory.dbQuery {
|
DatabaseFactory.dbQuery {
|
||||||
TasksTable.selectAll().map {
|
var query = TasksTable.selectAll()
|
||||||
|
if (!subject.isNullOrBlank()) {
|
||||||
|
query = query.where { TasksTable.subject.lowerCase() eq subject.trim().lowercase() }
|
||||||
|
}
|
||||||
|
if (age != null) {
|
||||||
|
query = query.andWhere { (TasksTable.minAge lessEq age) and (TasksTable.maxAge greaterEq age) }
|
||||||
|
}
|
||||||
|
val safeLimit = if (limit <= 0) 10 else limit
|
||||||
|
query.limit(safeLimit).map {
|
||||||
Task(
|
Task(
|
||||||
id = it[TasksTable.id],
|
id = it[TasksTable.id],
|
||||||
|
subject = it[TasksTable.subject],
|
||||||
|
minAge = it[TasksTable.minAge],
|
||||||
|
maxAge = it[TasksTable.maxAge],
|
||||||
question = it[TasksTable.question],
|
question = it[TasksTable.question],
|
||||||
options = it[TasksTable.options].split(",").map { opt -> opt.trim() },
|
options = it[TasksTable.options].split(",").map { opt -> opt.trim() },
|
||||||
rewardMinutes = it[TasksTable.rewardMinutes],
|
rewardMinutes = it[TasksTable.rewardMinutes],
|
||||||
|
license = it[TasksTable.license],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getUserById(userId: String): User? =
|
||||||
|
DatabaseFactory.dbQuery {
|
||||||
|
UserTable.selectAll()
|
||||||
|
.where { UserTable.id eq userId }
|
||||||
|
.map {
|
||||||
|
User(
|
||||||
|
id = it[UserTable.id],
|
||||||
|
email = it[UserTable.email],
|
||||||
|
passwordHash = it[UserTable.passwordHash],
|
||||||
|
familyName = it[UserTable.familyName],
|
||||||
|
childAge = it[UserTable.childAge],
|
||||||
|
parentPin = it[UserTable.parentPin],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
||||||
|
|
||||||
|
// NEU: Implementierung für Exposed (PostgreSQL)
|
||||||
|
override suspend fun getExistingTaskIds(externalSource: String): Set<String> =
|
||||||
|
DatabaseFactory.dbQuery {
|
||||||
|
TasksTable
|
||||||
|
.select(TasksTable.id) // Wir brauchen nur die ID-Spalte
|
||||||
|
.where { TasksTable.externalSource eq externalSource }
|
||||||
|
.map { it[TasksTable.id] }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upsertTask(task: ImportedTask): Boolean =
|
||||||
|
DatabaseFactory.dbQuery {
|
||||||
|
try {
|
||||||
|
val updated =
|
||||||
|
TasksTable.update({ TasksTable.id eq task.id }) {
|
||||||
|
it[externalSource] = task.externalSource
|
||||||
|
it[sourceUrl] = task.sourceUrl
|
||||||
|
it[license] = task.license
|
||||||
|
it[subject] = task.subject
|
||||||
|
it[minAge] = task.minAge
|
||||||
|
it[maxAge] = task.maxAge
|
||||||
|
it[taskType] = task.taskType
|
||||||
|
it[question] = task.question
|
||||||
|
it[options] = task.options.joinToString(",")
|
||||||
|
it[correctAnswer] = task.correctAnswer
|
||||||
|
it[rewardMinutes] = task.rewardMinutes
|
||||||
|
}
|
||||||
|
if (updated == 0) {
|
||||||
|
TasksTable.insert {
|
||||||
|
it[id] = task.id
|
||||||
|
it[externalSource] = task.externalSource
|
||||||
|
it[sourceUrl] = task.sourceUrl
|
||||||
|
it[license] = task.license
|
||||||
|
it[subject] = task.subject
|
||||||
|
it[minAge] = task.minAge
|
||||||
|
it[maxAge] = task.maxAge
|
||||||
|
it[taskType] = task.taskType
|
||||||
|
it[question] = task.question
|
||||||
|
it[options] = task.options.joinToString(",")
|
||||||
|
it[correctAnswer] = task.correctAnswer
|
||||||
|
it[rewardMinutes] = task.rewardMinutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("FEHLER beim Upsert von Task ${task.id}: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun registerUser(user: User): Boolean =
|
override suspend fun registerUser(user: User): Boolean =
|
||||||
DatabaseFactory.dbQuery {
|
DatabaseFactory.dbQuery {
|
||||||
try {
|
try {
|
||||||
|
|
@ -164,13 +267,34 @@ class ExposedKidioRepository : KidioRepository {
|
||||||
class InMemoryKidioRepository : KidioRepository {
|
class InMemoryKidioRepository : KidioRepository {
|
||||||
private val tasks =
|
private val tasks =
|
||||||
mutableListOf(
|
mutableListOf(
|
||||||
Task("t1", "Was ist 12 + 15?", listOf("25", "27", "30", "22"), 10),
|
ImportedTask(
|
||||||
Task("t2", "Was ist 8 x 7?", listOf("54", "56", "64", "48"), 15),
|
id = "t1",
|
||||||
)
|
externalSource = "manual",
|
||||||
private val answers =
|
subject = "Mathematik",
|
||||||
mutableMapOf(
|
minAge = 6,
|
||||||
"t1" to ("27" to 10),
|
maxAge = 10,
|
||||||
"t2" to ("56" to 15),
|
taskType = "MULTIPLE_CHOICE",
|
||||||
|
question = "Was ist 12 + 15?",
|
||||||
|
options = listOf("25", "27", "30", "22"),
|
||||||
|
correctAnswer = "27",
|
||||||
|
rewardMinutes = 10,
|
||||||
|
license = "CC BY-SA 4.0",
|
||||||
|
sourceUrl = "local",
|
||||||
|
),
|
||||||
|
ImportedTask(
|
||||||
|
id = "t2",
|
||||||
|
externalSource = "manual",
|
||||||
|
subject = "Mathematik",
|
||||||
|
minAge = 7,
|
||||||
|
maxAge = 12,
|
||||||
|
taskType = "MULTIPLE_CHOICE",
|
||||||
|
question = "Was ist 8 x 7?",
|
||||||
|
options = listOf("54", "56", "64", "48"),
|
||||||
|
correctAnswer = "56",
|
||||||
|
rewardMinutes = 15,
|
||||||
|
license = "CC BY-SA 4.0",
|
||||||
|
sourceUrl = "local",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
private val screentimes =
|
private val screentimes =
|
||||||
mutableMapOf(
|
mutableMapOf(
|
||||||
|
|
@ -178,9 +302,54 @@ class InMemoryKidioRepository : KidioRepository {
|
||||||
)
|
)
|
||||||
private val users = mutableListOf<User>()
|
private val users = mutableListOf<User>()
|
||||||
|
|
||||||
override suspend fun getAllTasks(): List<Task> = tasks
|
override suspend fun getAllTasks(): List<Task> = getTasks(limit = 100)
|
||||||
|
|
||||||
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = answers[taskId]
|
override suspend fun getTasks(
|
||||||
|
subject: String?,
|
||||||
|
age: Int?,
|
||||||
|
limit: Int,
|
||||||
|
): List<Task> {
|
||||||
|
val safeLimit = if (limit <= 0) 10 else limit
|
||||||
|
return tasks.filter { task ->
|
||||||
|
val matchSubject = subject.isNullOrBlank() || task.subject.equals(subject.trim(), ignoreCase = true)
|
||||||
|
val matchAge = age == null || (task.minAge <= age && task.maxAge >= age)
|
||||||
|
matchSubject && matchAge
|
||||||
|
}.take(safeLimit).map {
|
||||||
|
Task(
|
||||||
|
id = it.id,
|
||||||
|
subject = it.subject,
|
||||||
|
minAge = it.minAge,
|
||||||
|
maxAge = it.maxAge,
|
||||||
|
question = it.question,
|
||||||
|
options = it.options,
|
||||||
|
rewardMinutes = it.rewardMinutes,
|
||||||
|
license = it.license,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getUserById(userId: String): User? {
|
||||||
|
return users.find { it.id == userId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEU: Implementierung für InMemory (Mock-Daten)
|
||||||
|
override suspend fun getExistingTaskIds(externalSource: String): Set<String> {
|
||||||
|
return tasks
|
||||||
|
.filter { it.externalSource == externalSource }
|
||||||
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upsertTask(task: ImportedTask): Boolean {
|
||||||
|
tasks.removeIf { it.id == task.id }
|
||||||
|
tasks.add(task)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? {
|
||||||
|
val task = tasks.find { it.id == taskId } ?: return null
|
||||||
|
return task.correctAnswer to task.rewardMinutes
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
|
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
package com.oliver.kidio.backend.ingestion
|
||||||
|
|
||||||
|
import com.oliver.kidio.backend.ImportedTask
|
||||||
|
import com.oliver.kidio.backend.data.database.TasksTable
|
||||||
|
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository
|
||||||
|
import com.oliver.kidio.backend.domain.repository.KidioRepository
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.jetbrains.exposed.sql.Database
|
||||||
|
import org.jetbrains.exposed.sql.SchemaUtils
|
||||||
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
|
||||||
|
object H5PIngestionCli {
|
||||||
|
/**
|
||||||
|
* Executes batch ingestion pipeline and upserts tasks into the target repository.
|
||||||
|
*/
|
||||||
|
fun ingestBatch(
|
||||||
|
repository: KidioRepository,
|
||||||
|
client: ZumApiClient = ZumApiClient(),
|
||||||
|
subject: String? = null,
|
||||||
|
limit: Int = 50,
|
||||||
|
): Int =
|
||||||
|
runBlocking {
|
||||||
|
// Bereits vorhandene Task-IDs aus der Datenbank auslesen
|
||||||
|
val existingIds = repository.getExistingTaskIds("zum.de")
|
||||||
|
println("Bereits in DB vorhandene ZUM-Aufgaben: ${existingIds.size}")
|
||||||
|
|
||||||
|
var importedCount = 0
|
||||||
|
// existingIds an fetchLiveOerItems übergeben, um Duplikate vor dem Download zu filtern
|
||||||
|
val items =
|
||||||
|
client.fetchLiveOerItems(
|
||||||
|
subject = subject,
|
||||||
|
limit = limit,
|
||||||
|
existingIds = existingIds,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (item in items) {
|
||||||
|
var parsedList = emptyList<ParsedH5PTask>()
|
||||||
|
|
||||||
|
// 1. Try parsing JSON content directly (fast, no ZIP decompression needed)
|
||||||
|
if (item.rawJsonContent != null) {
|
||||||
|
parsedList =
|
||||||
|
H5PParser.parseAllTasks(
|
||||||
|
contentJson = item.rawJsonContent,
|
||||||
|
library = item.library ?: "H5P.MultiChoice",
|
||||||
|
taskId = item.id,
|
||||||
|
title = item.title,
|
||||||
|
license = item.license,
|
||||||
|
sourceUrl = item.downloadUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to downloading .h5p ZIP archive if remote archive is specified
|
||||||
|
if (parsedList.isEmpty() && item.downloadUrl.endsWith(".h5p", ignoreCase = true)) {
|
||||||
|
val h5pBytes = client.downloadH5P(item.downloadUrl)
|
||||||
|
if (h5pBytes != null) {
|
||||||
|
val single = H5PParser.parseH5PZip(ByteArrayInputStream(h5pBytes), item.id, item.downloadUrl)
|
||||||
|
if (single != null) {
|
||||||
|
parsedList = listOf(single)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (parsed in parsedList) {
|
||||||
|
val (minAge, maxAge) = ZumApiClient.mapGradeToAgeRange(item.grade)
|
||||||
|
val task =
|
||||||
|
ImportedTask(
|
||||||
|
id = parsed.id,
|
||||||
|
externalSource = "zum.de",
|
||||||
|
sourceUrl = parsed.sourceUrl ?: item.downloadUrl,
|
||||||
|
license = parsed.license,
|
||||||
|
subject = ZumApiClient.mapSubject(item.subject),
|
||||||
|
minAge = minAge,
|
||||||
|
maxAge = maxAge,
|
||||||
|
taskType = if (parsed.options.size == 2 && parsed.options.contains("Wahr")) "TRUE_FALSE" else "MULTIPLE_CHOICE",
|
||||||
|
question = parsed.question,
|
||||||
|
options = parsed.options,
|
||||||
|
correctAnswer = parsed.correctAnswer,
|
||||||
|
rewardMinutes = 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
val success = repository.upsertTask(task)
|
||||||
|
if (success) {
|
||||||
|
importedCount++
|
||||||
|
val shortQ = task.question.take(30)
|
||||||
|
val ageRange = "${task.minAge}-${task.maxAge}"
|
||||||
|
println(" -> [Importiert] ${task.id} (${task.subject}, $ageRange): \"$shortQ...\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Ingestion completed: $importedCount tasks successfully imported/updated.")
|
||||||
|
importedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
println("=== Kidio H5P/ZUM Ingestion Tool ===")
|
||||||
|
val host = System.getenv("DB_HOST") ?: "localhost"
|
||||||
|
val port = System.getenv("DB_PORT") ?: "5432"
|
||||||
|
val dbName = System.getenv("DB_NAME") ?: "kidio_db"
|
||||||
|
val user = System.getenv("DB_USER") ?: "kidio_admin"
|
||||||
|
val password = System.getenv("DB_PASSWORD") ?: "learning"
|
||||||
|
|
||||||
|
var subject: String? = null
|
||||||
|
var limit = 50
|
||||||
|
|
||||||
|
for (arg in args) {
|
||||||
|
when {
|
||||||
|
arg.startsWith("--subject=") -> subject = arg.substringAfter("--subject=")
|
||||||
|
arg.startsWith("--limit=") -> limit = arg.substringAfter("--limit=").toIntOrNull() ?: 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val jdbcUrl = "jdbc:postgresql://$host:$port/$dbName"
|
||||||
|
println("Connecting to database: $jdbcUrl")
|
||||||
|
|
||||||
|
val database =
|
||||||
|
Database.connect(
|
||||||
|
url = jdbcUrl,
|
||||||
|
driver = "org.postgresql.Driver",
|
||||||
|
user = user,
|
||||||
|
password = password,
|
||||||
|
)
|
||||||
|
|
||||||
|
transaction(database) {
|
||||||
|
SchemaUtils.createMissingTablesAndColumns(TasksTable)
|
||||||
|
}
|
||||||
|
|
||||||
|
val repository = ExposedKidioRepository()
|
||||||
|
ingestBatch(repository = repository, subject = subject, limit = limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
270
src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PParser.kt
Normal file
270
src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PParser.kt
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
package com.oliver.kidio.backend.ingestion
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
data class ParsedH5PTask(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val library: String,
|
||||||
|
val question: String,
|
||||||
|
val options: List<String>,
|
||||||
|
val correctAnswer: String,
|
||||||
|
val license: String = "CC BY-SA 4.0",
|
||||||
|
val sourceUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
object H5PParser {
|
||||||
|
private val json =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips HTML tags and decodes common HTML entities to provide clean text.
|
||||||
|
*/
|
||||||
|
fun stripHtml(html: String): String {
|
||||||
|
var text = html.replace(Regex("(?i)<(br|p|div|li|tr|h[1-6])[^>]*>"), " ")
|
||||||
|
text = text.replace(Regex("<[^>]*>"), "")
|
||||||
|
text =
|
||||||
|
text
|
||||||
|
.replace(" ", " ")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("×", "×")
|
||||||
|
.replace("„", "„")
|
||||||
|
.replace("“", "“")
|
||||||
|
.replace("ä", "ä")
|
||||||
|
.replace("Ä", "Ä")
|
||||||
|
.replace("ö", "ö")
|
||||||
|
.replace("Ö", "Ö")
|
||||||
|
.replace("ü", "ü")
|
||||||
|
.replace("Ü", "Ü")
|
||||||
|
.replace("ß", "ß")
|
||||||
|
text = text.replace(Regex("\\s+([?.!,:;])"), "$1")
|
||||||
|
return text.replace(Regex("\\s+"), " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an .h5p ZIP archive stream.
|
||||||
|
*/
|
||||||
|
fun parseH5PZip(
|
||||||
|
inputStream: InputStream,
|
||||||
|
taskId: String,
|
||||||
|
sourceUrl: String? = null,
|
||||||
|
): ParsedH5PTask? {
|
||||||
|
val zip = ZipInputStream(inputStream)
|
||||||
|
var h5pJsonString: String? = null
|
||||||
|
var contentJsonString: String? = null
|
||||||
|
|
||||||
|
var entry = zip.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val name = entry.name
|
||||||
|
if (name == "h5p.json" || name.endsWith("/h5p.json")) {
|
||||||
|
h5pJsonString = zip.readBytes().toString(Charsets.UTF_8)
|
||||||
|
} else if (name == "content/content.json" || name == "content.json" || name.endsWith("/content.json")) {
|
||||||
|
contentJsonString = zip.readBytes().toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
zip.closeEntry()
|
||||||
|
entry = zip.nextEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentJsonString == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
var library = "H5P.MultiChoice"
|
||||||
|
var title = "H5P Task"
|
||||||
|
var license = "CC BY-SA 4.0"
|
||||||
|
|
||||||
|
if (h5pJsonString != null) {
|
||||||
|
try {
|
||||||
|
val h5pTree = json.parseToJsonElement(h5pJsonString).jsonObject
|
||||||
|
library = h5pTree["mainLibrary"]?.jsonPrimitive?.contentOrNull ?: library
|
||||||
|
title = h5pTree["title"]?.jsonPrimitive?.contentOrNull ?: title
|
||||||
|
license = h5pTree["license"]?.jsonPrimitive?.contentOrNull ?: license
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseContentJson(
|
||||||
|
contentJson = contentJsonString,
|
||||||
|
library = library,
|
||||||
|
taskId = taskId,
|
||||||
|
title = title,
|
||||||
|
license = license,
|
||||||
|
sourceUrl = sourceUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses content.json representation for supported H5P libraries (MultiChoice, TrueFalse).
|
||||||
|
*/
|
||||||
|
fun parseContentJson(
|
||||||
|
contentJson: String,
|
||||||
|
library: String = "H5P.MultiChoice",
|
||||||
|
taskId: String = "task-1",
|
||||||
|
title: String = "H5P Task",
|
||||||
|
license: String = "CC BY-SA 4.0",
|
||||||
|
sourceUrl: String? = null,
|
||||||
|
): ParsedH5PTask? {
|
||||||
|
val element =
|
||||||
|
try {
|
||||||
|
json.parseToJsonElement(contentJson).jsonObject
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (library.contains("TrueFalse", ignoreCase = true) || (element.containsKey("correct") && !element.containsKey("answers"))) {
|
||||||
|
return parseTrueFalse(element, taskId, title, library, license, sourceUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseMultiChoice(element, taskId, title, library, license, sourceUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses one or multiple tasks from content.json (supports MultiChoice, TrueFalse, and QuestionSet).
|
||||||
|
*/
|
||||||
|
fun parseAllTasks(
|
||||||
|
contentJson: String,
|
||||||
|
library: String = "H5P.MultiChoice",
|
||||||
|
taskId: String = "task-1",
|
||||||
|
title: String = "H5P Task",
|
||||||
|
license: String = "CC BY-SA 4.0",
|
||||||
|
sourceUrl: String? = null,
|
||||||
|
): List<ParsedH5PTask> {
|
||||||
|
val element =
|
||||||
|
try {
|
||||||
|
json.parseToJsonElement(contentJson).jsonObject
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (library.contains("QuestionSet", ignoreCase = true) && element.containsKey("questions")) {
|
||||||
|
val questionsArray = element["questions"]?.jsonArray ?: return emptyList()
|
||||||
|
val results = mutableListOf<ParsedH5PTask>()
|
||||||
|
|
||||||
|
for ((idx, qElement) in questionsArray.withIndex()) {
|
||||||
|
val qObj = qElement.jsonObject
|
||||||
|
val subLib = qObj["library"]?.jsonPrimitive?.contentOrNull ?: "H5P.MultiChoice"
|
||||||
|
val subParams = qObj["params"]?.jsonObject ?: continue
|
||||||
|
val subId = "$taskId-q${idx + 1}"
|
||||||
|
|
||||||
|
val isTrueFalse =
|
||||||
|
subLib.contains("TrueFalse", ignoreCase = true) ||
|
||||||
|
(subParams.containsKey("correct") && !subParams.containsKey("answers"))
|
||||||
|
val isMultiChoice =
|
||||||
|
subLib.contains("MultiChoice", ignoreCase = true) || subParams.containsKey("answers")
|
||||||
|
|
||||||
|
val parsedSub =
|
||||||
|
if (isTrueFalse) {
|
||||||
|
parseTrueFalse(subParams, subId, "$title #${idx + 1}", subLib, license, sourceUrl)
|
||||||
|
} else if (isMultiChoice) {
|
||||||
|
parseMultiChoice(subParams, subId, "$title #${idx + 1}", subLib, license, sourceUrl)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedSub != null) {
|
||||||
|
results.add(parsedSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
val single = parseContentJson(contentJson, library, taskId, title, license, sourceUrl)
|
||||||
|
return if (single != null) listOf(single) else emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMultiChoice(
|
||||||
|
element: JsonObject,
|
||||||
|
taskId: String,
|
||||||
|
title: String,
|
||||||
|
library: String,
|
||||||
|
license: String,
|
||||||
|
sourceUrl: String?,
|
||||||
|
): ParsedH5PTask? {
|
||||||
|
val questionRaw =
|
||||||
|
element["question"]?.jsonPrimitive?.contentOrNull
|
||||||
|
?: element["text"]?.jsonPrimitive?.contentOrNull
|
||||||
|
?: return null
|
||||||
|
val questionClean = stripHtml(questionRaw)
|
||||||
|
if (questionClean.isBlank()) return null
|
||||||
|
|
||||||
|
val answersArray = element["answers"]?.jsonArray ?: return null
|
||||||
|
val options = mutableListOf<String>()
|
||||||
|
var correctAnswer: String? = null
|
||||||
|
|
||||||
|
for (item in answersArray) {
|
||||||
|
val itemObj = item.jsonObject
|
||||||
|
val textRaw = itemObj["text"]?.jsonPrimitive?.contentOrNull ?: continue
|
||||||
|
val textClean = stripHtml(textRaw)
|
||||||
|
if (textClean.isNotBlank()) {
|
||||||
|
options.add(textClean)
|
||||||
|
val isCorrect = itemObj["correct"]?.jsonPrimitive?.booleanOrNull ?: false
|
||||||
|
if (isCorrect && correctAnswer == null) {
|
||||||
|
correctAnswer = textClean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.isEmpty() || correctAnswer == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParsedH5PTask(
|
||||||
|
id = taskId,
|
||||||
|
title = title,
|
||||||
|
library = library,
|
||||||
|
question = questionClean,
|
||||||
|
options = options,
|
||||||
|
correctAnswer = correctAnswer,
|
||||||
|
license = license,
|
||||||
|
sourceUrl = sourceUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTrueFalse(
|
||||||
|
element: JsonObject,
|
||||||
|
taskId: String,
|
||||||
|
title: String,
|
||||||
|
library: String,
|
||||||
|
license: String,
|
||||||
|
sourceUrl: String?,
|
||||||
|
): ParsedH5PTask? {
|
||||||
|
val questionRaw =
|
||||||
|
element["question"]?.jsonPrimitive?.contentOrNull
|
||||||
|
?: element["text"]?.jsonPrimitive?.contentOrNull
|
||||||
|
?: return null
|
||||||
|
val questionClean = stripHtml(questionRaw)
|
||||||
|
if (questionClean.isBlank()) return null
|
||||||
|
|
||||||
|
val correctPrimitive = element["correct"]?.jsonPrimitive
|
||||||
|
val isTrueCorrect =
|
||||||
|
when {
|
||||||
|
correctPrimitive?.booleanOrNull != null -> correctPrimitive.boolean
|
||||||
|
correctPrimitive?.contentOrNull != null -> correctPrimitive.content.equals("true", ignoreCase = true)
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
val options = listOf("Wahr", "Falsch")
|
||||||
|
val correctAnswer = if (isTrueCorrect) "Wahr" else "Falsch"
|
||||||
|
|
||||||
|
return ParsedH5PTask(
|
||||||
|
id = taskId,
|
||||||
|
title = title,
|
||||||
|
library = library,
|
||||||
|
question = questionClean,
|
||||||
|
options = options,
|
||||||
|
correctAnswer = correctAnswer,
|
||||||
|
license = license,
|
||||||
|
sourceUrl = sourceUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,514 @@
|
||||||
|
package com.oliver.kidio.backend.ingestion
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.net.http.HttpClient
|
||||||
|
import java.net.http.HttpRequest
|
||||||
|
import java.net.http.HttpResponse
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
data class ZumOerItem(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val subject: String,
|
||||||
|
val grade: Int? = null,
|
||||||
|
val downloadUrl: String,
|
||||||
|
val license: String = "CC BY-SA 4.0",
|
||||||
|
val library: String? = null,
|
||||||
|
val rawJsonContent: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ZumApiClient(
|
||||||
|
private val httpClient: HttpClient =
|
||||||
|
HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build(),
|
||||||
|
) {
|
||||||
|
private val json =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun mapSubject(raw: String): String {
|
||||||
|
val lower = raw.trim().lowercase()
|
||||||
|
return when {
|
||||||
|
lower.contains("mathe") -> "Mathematik"
|
||||||
|
lower.contains("deutsch") || lower.contains("german") -> "Deutsch"
|
||||||
|
lower.contains("sach") || lower.contains("hsu") || lower.contains("natur") || lower.contains("heimat") -> "Sachunterricht"
|
||||||
|
lower.contains("englisch") || lower.contains("english") -> "Englisch"
|
||||||
|
lower.contains("bio") -> "Biologie"
|
||||||
|
lower.contains("geo") || lower.contains("erdkunde") -> "Geographie"
|
||||||
|
lower.contains("gesch") -> "Geschichte"
|
||||||
|
lower.contains("chemie") -> "Chemie"
|
||||||
|
lower.contains("physik") -> "Physik"
|
||||||
|
lower.contains("musik") -> "Musik"
|
||||||
|
lower.contains("kunst") -> "Kunst"
|
||||||
|
lower.contains("informatik") -> "Informatik"
|
||||||
|
else -> raw.trim().replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapGradeToAgeRange(grade: Int?): Pair<Int, Int> {
|
||||||
|
return when (grade) {
|
||||||
|
1 -> 6 to 7
|
||||||
|
2 -> 7 to 8
|
||||||
|
3 -> 8 to 9
|
||||||
|
4 -> 9 to 10
|
||||||
|
5 -> 10 to 11
|
||||||
|
6 -> 11 to 12
|
||||||
|
7 -> 12 to 13
|
||||||
|
8 -> 13 to 14
|
||||||
|
9 -> 14 to 15
|
||||||
|
10 -> 15 to 16
|
||||||
|
11, 12, 13 -> 16 to 18
|
||||||
|
else -> 6 to 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads an .h5p package file from a remote URL.
|
||||||
|
*/
|
||||||
|
fun downloadH5P(downloadUrl: String): ByteArray? {
|
||||||
|
return try {
|
||||||
|
val request =
|
||||||
|
HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(downloadUrl))
|
||||||
|
.timeout(Duration.ofSeconds(15))
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (compatible; KidioBot/1.0; +https://kidio.oliver.com)")
|
||||||
|
.GET()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray())
|
||||||
|
if (response.statusCode() == 200) {
|
||||||
|
response.body()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("WARNUNG: Download fehlgeschlagen für $downloadUrl: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches live OER items from apps.zum.de via search and Drupal settings extraction.
|
||||||
|
* Skips items whose IDs are already present in existingIds.
|
||||||
|
* Falls back to curated items if remote catalog is unreachable or returns 0 matching interactive items.
|
||||||
|
*/
|
||||||
|
fun fetchLiveOerItems(
|
||||||
|
subject: String? = null,
|
||||||
|
limit: Int = 50,
|
||||||
|
maxPages: Int = 15,
|
||||||
|
existingIds: Set<String> = emptySet(),
|
||||||
|
): List<ZumOerItem> {
|
||||||
|
val results = mutableListOf<ZumOerItem>()
|
||||||
|
val seenAppIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
println("[ZUM-Apps Live] Starte Live-Abfrage auf apps.zum.de (Fach: ${subject ?: "Alle Fächer"}, Limit: $limit)...")
|
||||||
|
|
||||||
|
for (page in 0 until maxPages) {
|
||||||
|
if (results.size >= limit) break
|
||||||
|
|
||||||
|
val searchUrl =
|
||||||
|
if (subject.isNullOrBlank()) {
|
||||||
|
"https://apps.zum.de/apps?page=$page"
|
||||||
|
} else {
|
||||||
|
val encoded = URLEncoder.encode(subject, "UTF-8")
|
||||||
|
"https://apps.zum.de/apps?keys=$encoded&page=$page"
|
||||||
|
}
|
||||||
|
|
||||||
|
val html = fetchString(searchUrl)
|
||||||
|
if (html == null) {
|
||||||
|
println("[ZUM-Apps Live] Warnung: Seite $page konnte nicht geladen werden ($searchUrl)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtert bereits verarbeitete oder in DB vorhandene Apps heraus
|
||||||
|
val appIds =
|
||||||
|
Regex("""href=["']/apps/(\d+)["']""")
|
||||||
|
.findAll(html)
|
||||||
|
.map { it.groupValues[1] }
|
||||||
|
.distinct()
|
||||||
|
.filter { appId ->
|
||||||
|
val fullId = "zum-$appId"
|
||||||
|
!seenAppIds.contains(appId) && !existingIds.contains(fullId) && !existingIds.contains(appId)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
if (appIds.isEmpty()) {
|
||||||
|
println("[ZUM-Apps Live] Seite $page enthält keine neuen Aufgaben. Blättere weiter...")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (appId in appIds) {
|
||||||
|
if (results.size >= limit) break
|
||||||
|
seenAppIds.add(appId)
|
||||||
|
|
||||||
|
val item = fetchAppDetails(appId, subject)
|
||||||
|
if (item != null) {
|
||||||
|
results.add(item)
|
||||||
|
val lib = item.library ?: "H5P"
|
||||||
|
val gr = item.grade ?: "k.A."
|
||||||
|
println(
|
||||||
|
"[ZUM-Apps Live] Neue App #$appId: \"${item.title}\" ($lib) [${item.subject}, Kl. $gr]",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.isEmpty()) {
|
||||||
|
println("[ZUM-Apps Live] Keine neuen Live-Inhalte gefunden oder Seite nicht erreichbar. Nutze kuratierte OER-Fallback-Daten.")
|
||||||
|
return getCuratedOerItems(subject = subject, limit = limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
println("[ZUM-Apps Live] ${results.size} neue interaktive Aufgaben erfolgreich von apps.zum.de geladen.")
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchAppDetails(
|
||||||
|
appId: String,
|
||||||
|
fallbackSubject: String?,
|
||||||
|
): ZumOerItem? {
|
||||||
|
val appUrl = "https://apps.zum.de/apps/$appId"
|
||||||
|
val html = fetchString(appUrl) ?: return null
|
||||||
|
|
||||||
|
val settingsMatch = Regex("""data-drupal-selector="drupal-settings-json">([^<]+)<""").find(html) ?: return null
|
||||||
|
val jsonStr = settingsMatch.groupValues[1]
|
||||||
|
|
||||||
|
val drupalSettings =
|
||||||
|
try {
|
||||||
|
json.parseToJsonElement(jsonStr).jsonObject
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val h5pContents =
|
||||||
|
drupalSettings["h5p"]?.jsonObject
|
||||||
|
?.get("H5PIntegration")?.jsonObject
|
||||||
|
?.get("contents")?.jsonObject
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
val contentObj =
|
||||||
|
h5pContents["cid-$appId"]?.jsonObject
|
||||||
|
?: h5pContents.values.firstOrNull()?.jsonObject
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
val library = contentObj["library"]?.jsonPrimitive?.contentOrNull ?: ""
|
||||||
|
if (!library.contains("MultiChoice", ignoreCase = true) &&
|
||||||
|
!library.contains("TrueFalse", ignoreCase = true) &&
|
||||||
|
!library.contains("QuestionSet", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsonContent = contentObj["jsonContent"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||||
|
val metadata = contentObj["metadata"]?.jsonObject
|
||||||
|
val title =
|
||||||
|
metadata?.get("title")?.jsonPrimitive?.contentOrNull
|
||||||
|
?: Regex("""<title>(.*?)(?:\|.*)?</title>""").find(html)?.groupValues?.get(1)?.trim()
|
||||||
|
?: "ZUM App #$appId"
|
||||||
|
|
||||||
|
val rawLicense = metadata?.get("license")?.jsonPrimitive?.contentOrNull
|
||||||
|
val license =
|
||||||
|
when {
|
||||||
|
rawLicense == "U" || rawLicense == null || rawLicense.isBlank() -> "CC BY-SA 4.0"
|
||||||
|
rawLicense.contains("CC", ignoreCase = true) -> rawLicense
|
||||||
|
else -> "CC BY-SA 4.0 ($rawLicense)"
|
||||||
|
}
|
||||||
|
|
||||||
|
val exportUrl = contentObj["exportUrl"]?.jsonPrimitive?.contentOrNull ?: appUrl
|
||||||
|
|
||||||
|
// Breitgefächerter Abruf aller HTML-Linktexte (Kategorien, Schulstufen, Tags)
|
||||||
|
val tags =
|
||||||
|
Regex("""<a[^>]*>([^<]+)</a>""")
|
||||||
|
.findAll(html)
|
||||||
|
.map { it.groupValues[1].trim() }
|
||||||
|
.filter { it.isNotBlank() && it.length < 60 }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
val subject = inferSubject(title, tags, fallbackSubject)
|
||||||
|
val grade = inferGrade(title, html, tags)
|
||||||
|
|
||||||
|
return ZumOerItem(
|
||||||
|
id = "zum-$appId",
|
||||||
|
title = title,
|
||||||
|
subject = subject,
|
||||||
|
grade = grade,
|
||||||
|
downloadUrl = exportUrl,
|
||||||
|
license = license,
|
||||||
|
library = library,
|
||||||
|
rawJsonContent = jsonContent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inferSubject(
|
||||||
|
title: String,
|
||||||
|
tags: List<String>,
|
||||||
|
fallbackSubject: String?,
|
||||||
|
): String {
|
||||||
|
for (tag in tags) {
|
||||||
|
val mapped = mapSubject(tag)
|
||||||
|
if (mapped != tag ||
|
||||||
|
tag.equals("Mathematik", ignoreCase = true) ||
|
||||||
|
tag.equals("Deutsch", ignoreCase = true) ||
|
||||||
|
tag.equals("Biologie", ignoreCase = true) ||
|
||||||
|
tag.equals("Englisch", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fallbackSubject.isNullOrBlank()) {
|
||||||
|
return mapSubject(fallbackSubject)
|
||||||
|
}
|
||||||
|
return mapSubject(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inferGrade(
|
||||||
|
title: String,
|
||||||
|
html: String,
|
||||||
|
tags: List<String>,
|
||||||
|
): Int? {
|
||||||
|
val primaryText = (tags + title).joinToString(" ")
|
||||||
|
|
||||||
|
// 1. Muster: "3. Klasse", "3. Schuljahr", "3. Kl."
|
||||||
|
Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||||
|
|
||||||
|
// 2. Muster: "Klasse 3", "Kl. 3", "Schuljahr 3"
|
||||||
|
Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||||
|
|
||||||
|
// 3. Muster: "Klasse 3-4", "Klasse 3/4", "3.-4. Klasse"
|
||||||
|
Regex("""\b(?:Klasse|Kl\.)?\s*(\d+)\s*[-/]\s*(\d+)\b""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||||
|
|
||||||
|
// 4. Fallback: Gesamten HTML-Text der Seite nach Klassenangaben absuchen
|
||||||
|
Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||||
|
|
||||||
|
Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it }
|
||||||
|
|
||||||
|
// 5. Fallback: Schulstufen-Schlüsselwörter
|
||||||
|
val combinedLower = (primaryText + " " + html).lowercase()
|
||||||
|
if (combinedLower.contains("primarstufe") || combinedLower.contains("grundschule")) return 3
|
||||||
|
val isSek1 =
|
||||||
|
combinedLower.contains("sekundarstufe i") ||
|
||||||
|
combinedLower.contains("sek i") ||
|
||||||
|
combinedLower.contains("realschule") ||
|
||||||
|
combinedLower.contains("hauptschule")
|
||||||
|
if (isSek1) return 6
|
||||||
|
|
||||||
|
val isSek2 =
|
||||||
|
combinedLower.contains("sekundarstufe ii") ||
|
||||||
|
combinedLower.contains("sek ii") ||
|
||||||
|
combinedLower.contains("gymnasium") ||
|
||||||
|
combinedLower.contains("abitur")
|
||||||
|
if (isSek2) return 10
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchString(url: String): String? {
|
||||||
|
return try {
|
||||||
|
val request =
|
||||||
|
HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.timeout(Duration.ofSeconds(12))
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (compatible; KidioBot/1.0; +https://kidio.oliver.com)")
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
.header("Accept-Language", "de-DE,de;q=0.9,en;q=0.8")
|
||||||
|
.GET()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8))
|
||||||
|
if (response.statusCode() == 200) {
|
||||||
|
response.body()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns curated ZUM/OER sample metadata items, filterable by subject.
|
||||||
|
*/
|
||||||
|
fun getCuratedOerItems(
|
||||||
|
subject: String? = null,
|
||||||
|
limit: Int = 20,
|
||||||
|
): List<ZumOerItem> {
|
||||||
|
val allItems =
|
||||||
|
listOf(
|
||||||
|
ZumOerItem(
|
||||||
|
id = "zum-math-001",
|
||||||
|
title = "Grundrechenarten: Multiplikation 3. Klasse",
|
||||||
|
subject = "Mathematik",
|
||||||
|
grade = 3,
|
||||||
|
downloadUrl = "https://apps.zum.de/api/h5p/math-001.h5p",
|
||||||
|
license = "CC BY-SA 4.0",
|
||||||
|
rawJsonContent =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"question": "<p>Was ist das Ergebnis von <strong>6 × 8</strong>?</p>",
|
||||||
|
"answers": [
|
||||||
|
{"text": "42", "correct": false},
|
||||||
|
{"text": "48", "correct": true},
|
||||||
|
{"text": "54", "correct": false},
|
||||||
|
{"text": "56", "correct": false}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
ZumOerItem(
|
||||||
|
id = "zum-math-002",
|
||||||
|
title = "Zahlenraum bis 100: Addition",
|
||||||
|
subject = "Mathematik",
|
||||||
|
grade = 2,
|
||||||
|
downloadUrl = "https://apps.zum.de/api/h5p/math-002.h5p",
|
||||||
|
license = "CC BY-SA 4.0",
|
||||||
|
rawJsonContent =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"question": "<p>Berechne: 34 + 28</p>",
|
||||||
|
"answers": [
|
||||||
|
{"text": "52", "correct": false},
|
||||||
|
{"text": "62", "correct": true},
|
||||||
|
{"text": "64", "correct": false}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
ZumOerItem(
|
||||||
|
id = "zum-math-003",
|
||||||
|
title = "Geometrie: Formen erkennen",
|
||||||
|
subject = "Mathematik",
|
||||||
|
grade = 4,
|
||||||
|
downloadUrl = "https://apps.zum.de/api/h5p/math-003.h5p",
|
||||||
|
license = "CC BY-SA 4.0",
|
||||||
|
rawJsonContent =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"question": "<p>Ein Quadrat hat vier gleich lange Seiten und vier rechte Winkel.</p>",
|
||||||
|
"correct": true
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
ZumOerItem(
|
||||||
|
id = "zum-de-001",
|
||||||
|
title = "Wortarten: Nomen erkennen",
|
||||||
|
subject = "Deutsch",
|
||||||
|
grade = 2,
|
||||||
|
downloadUrl = "https://apps.zum.de/api/h5p/de-001.h5p",
|
||||||
|
license = "CC BY-SA 4.0",
|
||||||
|
rawJsonContent =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"question": "<p>Welches der folgenden Wörter ist ein <strong>Nomen (Namenwort)</strong>?</p>",
|
||||||
|
"answers": [
|
||||||
|
{"text": "schnell", "correct": false},
|
||||||
|
{"text": "laufen", "correct": false},
|
||||||
|
{"text": "Schule", "correct": true},
|
||||||
|
{"text": "schön", "correct": false}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
ZumOerItem(
|
||||||
|
id = "zum-de-002",
|
||||||
|
title = "Rechtschreibung: Doppel-s oder ss",
|
||||||
|
subject = "Deutsch",
|
||||||
|
grade = 4,
|
||||||
|
downloadUrl = "https://apps.zum.de/api/h5p/de-002.h5p",
|
||||||
|
license = "CC BY-SA 4.0",
|
||||||
|
rawJsonContent =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"question": "<p>Wie wird das Wort richtig geschrieben? „Der Flu... ist tief.“</p>",
|
||||||
|
"answers": [
|
||||||
|
{"text": "Flus", "correct": false},
|
||||||
|
{"text": "Fluss", "correct": true},
|
||||||
|
{"text": "Fluß", "correct": false}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
ZumOerItem(
|
||||||
|
id = "zum-sach-001",
|
||||||
|
title = "Natur & Tiere: Winterschlaf",
|
||||||
|
subject = "Sachunterricht",
|
||||||
|
grade = 3,
|
||||||
|
downloadUrl = "https://apps.zum.de/api/h5p/sach-001.h5p",
|
||||||
|
license = "CC BY-SA 4.0",
|
||||||
|
rawJsonContent =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"question": "<p>Welches dieser Tiere hält einen echten <strong>Winterschlaf</strong>?</p>",
|
||||||
|
"answers": [
|
||||||
|
{"text": "Igel", "correct": true},
|
||||||
|
{"text": "Eichhörnchen", "correct": false},
|
||||||
|
{"text": "Amsel", "correct": false},
|
||||||
|
{"text": "Fuchs", "correct": false}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
ZumOerItem(
|
||||||
|
id = "zum-sach-002",
|
||||||
|
title = "Planetensystem: Sonnennähe",
|
||||||
|
subject = "Sachunterricht",
|
||||||
|
grade = 4,
|
||||||
|
downloadUrl = "https://apps.zum.de/api/h5p/sach-002.h5p",
|
||||||
|
license = "CC BY-SA 4.0",
|
||||||
|
rawJsonContent =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"question": "<p>Welcher Planet ist der Sonne am nächsten?</p>",
|
||||||
|
"answers": [
|
||||||
|
{"text": "Venus", "correct": false},
|
||||||
|
{"text": "Mars", "correct": false},
|
||||||
|
{"text": "Merkur", "correct": true},
|
||||||
|
{"text": "Jupiter", "correct": false}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
ZumOerItem(
|
||||||
|
id = "zum-eng-001",
|
||||||
|
title = "English Vocabulary: Colors & Animals",
|
||||||
|
subject = "Englisch",
|
||||||
|
grade = 4,
|
||||||
|
downloadUrl = "https://apps.zum.de/api/h5p/eng-001.h5p",
|
||||||
|
license = "CC BY-SA 4.0",
|
||||||
|
rawJsonContent =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"question": "<p>What is the English word for „Hund“?</p>",
|
||||||
|
"answers": [
|
||||||
|
{"text": "Cat", "correct": false},
|
||||||
|
{"text": "Dog", "correct": true},
|
||||||
|
{"text": "Bird", "correct": false},
|
||||||
|
{"text": "Rabbit", "correct": false}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val normalizedSubject = subject?.let { mapSubject(it) }
|
||||||
|
val filtered =
|
||||||
|
if (normalizedSubject.isNullOrBlank()) {
|
||||||
|
allItems
|
||||||
|
} else {
|
||||||
|
allItems.filter { it.subject.equals(normalizedSubject, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.take(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.auth.authenticate
|
import io.ktor.server.auth.authenticate
|
||||||
import io.ktor.server.auth.principal
|
import io.ktor.server.auth.principal
|
||||||
|
import io.ktor.server.plugins.openapi.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
|
@ -38,6 +39,8 @@ fun Application.configureRouting(
|
||||||
jwtService: JwtService,
|
jwtService: JwtService,
|
||||||
) {
|
) {
|
||||||
routing {
|
routing {
|
||||||
|
openAPI(path = "openapi")
|
||||||
|
|
||||||
post("/api/v1/auth/register") {
|
post("/api/v1/auth/register") {
|
||||||
val registerRequest = call.receive<RegisterRequest>()
|
val registerRequest = call.receive<RegisterRequest>()
|
||||||
if (registerRequest.email.isBlank() || registerRequest.passwordPlain.isBlank()) {
|
if (registerRequest.email.isBlank() || registerRequest.passwordPlain.isBlank()) {
|
||||||
|
|
@ -119,9 +122,21 @@ fun Application.configureRouting(
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticate("auth-jwt") {
|
authenticate("auth-jwt") {
|
||||||
// 1. Alle verfügbaren Aufgaben abrufen
|
// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
|
||||||
get("/api/v1/tasks") {
|
get("/api/v1/tasks") {
|
||||||
val tasks = repository.getAllTasks()
|
val principal = call.principal<UserPrincipal>()
|
||||||
|
val userId = principal?.userId
|
||||||
|
|
||||||
|
val subject = call.request.queryParameters["subject"]
|
||||||
|
|
||||||
|
// Expliziter Query-Parameter 'age' ODER Fallback auf das Alter des Users
|
||||||
|
val queryAge = call.request.queryParameters["age"]?.toIntOrNull()
|
||||||
|
val userAge = userId?.let { repository.getUserById(it)?.childAge }
|
||||||
|
val effectiveAge = queryAge ?: userAge
|
||||||
|
|
||||||
|
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 10
|
||||||
|
|
||||||
|
val tasks = repository.getTasks(subject = subject, age = effectiveAge, limit = limit)
|
||||||
call.respond(tasks)
|
call.respond(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,6 +189,7 @@ fun Application.configureRouting(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/api/v1/screentime") {
|
get("/api/v1/screentime") {
|
||||||
val principal = call.principal<UserPrincipal>()
|
val principal = call.principal<UserPrincipal>()
|
||||||
val userid =
|
val userid =
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
ktor:
|
ktor:
|
||||||
deployment:
|
deployment:
|
||||||
port: 8080
|
port: 8081
|
||||||
application:
|
application:
|
||||||
modules:
|
modules:
|
||||||
- MainKt.module
|
- MainKt.module
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
driverClassName: "org.postgresql.Driver"
|
driverClassName: "org.postgresql.Driver"
|
||||||
host: "82.165.11.162"
|
host: "localhost"
|
||||||
port: "5432"
|
port: "5432"
|
||||||
database: "kidio_db"
|
database: "kidio_db"
|
||||||
username: "kidio_admin"
|
username: "kidio_admin"
|
||||||
|
|
|
||||||
178
src/test/kotlin/H5PParserTest.kt
Normal file
178
src/test/kotlin/H5PParserTest.kt
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
package com.oliver.kidio.backend
|
||||||
|
|
||||||
|
import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
|
||||||
|
import com.oliver.kidio.backend.ingestion.H5PIngestionCli
|
||||||
|
import com.oliver.kidio.backend.ingestion.H5PParser
|
||||||
|
import com.oliver.kidio.backend.ingestion.ZumApiClient
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class H5PParserTest {
|
||||||
|
@Test
|
||||||
|
fun `test HTML stripping and entity decoding`() {
|
||||||
|
val html = "<p>Was ist <strong>12 × 4</strong>? „Test“ äöü</p>"
|
||||||
|
val clean = H5PParser.stripHtml(html)
|
||||||
|
assertTrue(clean.contains("Was ist 12"))
|
||||||
|
assertTrue(clean.contains("äöü"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test parse MultiChoice content json`() {
|
||||||
|
val json =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"question": "<p>Was ist die Hauptstadt von <strong>Frankreich</strong>?</p>",
|
||||||
|
"answers": [
|
||||||
|
{"text": "<div>Berlin</div>", "correct": false},
|
||||||
|
{"text": "<div>Paris</div>", "correct": true},
|
||||||
|
{"text": "<div>Rom</div>", "correct": false}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parsed = H5PParser.parseContentJson(json, library = "H5P.MultiChoice", taskId = "test-1")
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("test-1", parsed.id)
|
||||||
|
assertEquals("Was ist die Hauptstadt von Frankreich?", parsed.question)
|
||||||
|
assertEquals(3, parsed.options.size)
|
||||||
|
assertEquals("Paris", parsed.correctAnswer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test parse TrueFalse content json`() {
|
||||||
|
val json =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"question": "<p>Die Erde ist eine Scheibe.</p>",
|
||||||
|
"correct": false
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parsed = H5PParser.parseContentJson(json, library = "H5P.TrueFalse", taskId = "tf-1")
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("tf-1", parsed.id)
|
||||||
|
assertEquals("Die Erde ist eine Scheibe.", parsed.question)
|
||||||
|
assertEquals(listOf("Wahr", "Falsch"), parsed.options)
|
||||||
|
assertEquals("Falsch", parsed.correctAnswer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test parse ZIP stream with h5p metadata and content`() {
|
||||||
|
val baos = ByteArrayOutputStream()
|
||||||
|
ZipOutputStream(baos).use { zip ->
|
||||||
|
// Add h5p.json
|
||||||
|
val h5pEntry = ZipEntry("h5p.json")
|
||||||
|
zip.putNextEntry(h5pEntry)
|
||||||
|
zip.write(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"title": "Mathematik Quiz",
|
||||||
|
"mainLibrary": "H5P.MultiChoice",
|
||||||
|
"license": "CC BY-SA 4.0"
|
||||||
|
}
|
||||||
|
""".trimIndent().toByteArray(Charsets.UTF_8),
|
||||||
|
)
|
||||||
|
zip.closeEntry()
|
||||||
|
|
||||||
|
// Add content/content.json
|
||||||
|
val contentEntry = ZipEntry("content/content.json")
|
||||||
|
zip.putNextEntry(contentEntry)
|
||||||
|
zip.write(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"question": "<p>Wie viele Seiten hat ein Dreieck?</p>",
|
||||||
|
"answers": [
|
||||||
|
{"text": "3", "correct": true},
|
||||||
|
{"text": "4", "correct": false},
|
||||||
|
{"text": "5", "correct": false}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent().toByteArray(Charsets.UTF_8),
|
||||||
|
)
|
||||||
|
zip.closeEntry()
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = H5PParser.parseH5PZip(ByteArrayInputStream(baos.toByteArray()), "zip-task-1")
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals("zip-task-1", parsed.id)
|
||||||
|
assertEquals("Mathematik Quiz", parsed.title)
|
||||||
|
assertEquals("Wie viele Seiten hat ein Dreieck?", parsed.question)
|
||||||
|
assertEquals("3", parsed.correctAnswer)
|
||||||
|
assertEquals(listOf("3", "4", "5"), parsed.options)
|
||||||
|
assertEquals("CC BY-SA 4.0", parsed.license)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test parse QuestionSet with multiple questions`() {
|
||||||
|
val json =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"library": "H5P.MultiChoice 1.16",
|
||||||
|
"params": {
|
||||||
|
"question": "<p>Wie viel ist 5 + 5?</p>",
|
||||||
|
"answers": [
|
||||||
|
{"text": "10", "correct": true},
|
||||||
|
{"text": "11", "correct": false}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"library": "H5P.TrueFalse 1.8",
|
||||||
|
"params": {
|
||||||
|
"question": "<p>Wasser kocht bei 100 Grad Celsius.</p>",
|
||||||
|
"correct": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val parsedList = H5PParser.parseAllTasks(json, library = "H5P.QuestionSet", taskId = "qs-1", title = "Mini Quiz")
|
||||||
|
assertEquals(2, parsedList.size)
|
||||||
|
|
||||||
|
val q1 = parsedList[0]
|
||||||
|
assertEquals("qs-1-q1", q1.id)
|
||||||
|
assertEquals("Wie viel ist 5 + 5?", q1.question)
|
||||||
|
assertEquals("10", q1.correctAnswer)
|
||||||
|
|
||||||
|
val q2 = parsedList[1]
|
||||||
|
assertEquals("qs-1-q2", q2.id)
|
||||||
|
assertEquals("Wasser kocht bei 100 Grad Celsius.", q2.question)
|
||||||
|
assertEquals("Wahr", q2.correctAnswer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test taxonomy and grade mapping`() {
|
||||||
|
assertEquals("Mathematik", ZumApiClient.mapSubject("mathe"))
|
||||||
|
assertEquals("Deutsch", ZumApiClient.mapSubject("german language"))
|
||||||
|
assertEquals("Sachunterricht", ZumApiClient.mapSubject("Sachkunde"))
|
||||||
|
assertEquals("Englisch", ZumApiClient.mapSubject("english"))
|
||||||
|
|
||||||
|
assertEquals(6 to 7, ZumApiClient.mapGradeToAgeRange(1))
|
||||||
|
assertEquals(8 to 9, ZumApiClient.mapGradeToAgeRange(3))
|
||||||
|
assertEquals(6 to 12, ZumApiClient.mapGradeToAgeRange(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test ingestion CLI batch process with in-memory repository`() =
|
||||||
|
runBlocking {
|
||||||
|
val repository = InMemoryKidioRepository()
|
||||||
|
val importedCount = H5PIngestionCli.ingestBatch(repository = repository, limit = 10)
|
||||||
|
|
||||||
|
assertTrue(importedCount > 0)
|
||||||
|
val mathTasks = repository.getTasks(subject = "Mathematik", age = 8)
|
||||||
|
assertTrue(mathTasks.isNotEmpty())
|
||||||
|
|
||||||
|
val deTasks = repository.getTasks(subject = "Deutsch")
|
||||||
|
assertTrue(deTasks.isNotEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -210,7 +210,29 @@ class ServerTest {
|
||||||
assertEquals("t1", tasksJson[0].id)
|
assertEquals("t1", tasksJson[0].id)
|
||||||
assertEquals("Was ist 12 + 15?", tasksJson[0].question)
|
assertEquals("Was ist 12 + 15?", tasksJson[0].question)
|
||||||
|
|
||||||
// 2. Verify Task correct answer
|
// 2. Filter nach Fach & Alter
|
||||||
|
val filteredResponse =
|
||||||
|
client.get("/api/v1/tasks?subject=Mathematik&age=8") {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.OK, filteredResponse.status)
|
||||||
|
val filteredJson = Json.decodeFromString<List<Task>>(filteredResponse.bodyAsText())
|
||||||
|
assertEquals(2, filteredJson.size)
|
||||||
|
|
||||||
|
// 3. Filter mit Alter außerhalb des Bereichs
|
||||||
|
val age15Response =
|
||||||
|
client.get("/api/v1/tasks?subject=Mathematik&age=15") {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.OK, age15Response.status)
|
||||||
|
val age15Json = Json.decodeFromString<List<Task>>(age15Response.bodyAsText())
|
||||||
|
assertEquals(0, age15Json.size)
|
||||||
|
|
||||||
|
// 4. Antwort enthält NIEMALS correctAnswer
|
||||||
|
assertFalse(tasksResponse.bodyAsText().contains("correctAnswer"))
|
||||||
|
assertFalse(tasksResponse.bodyAsText().contains("correct_answer"))
|
||||||
|
|
||||||
|
// 5. Verify Task correct answer
|
||||||
val verifyResponse =
|
val verifyResponse =
|
||||||
client.post("/api/v1/tasks/verify") {
|
client.post("/api/v1/tasks/verify") {
|
||||||
header(HttpHeaders.Authorization, "Bearer $token")
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
|
|
@ -222,7 +244,7 @@ class ServerTest {
|
||||||
assertTrue(verifyJson.isCorrect)
|
assertTrue(verifyJson.isCorrect)
|
||||||
assertEquals(10, verifyJson.earnedMinutes)
|
assertEquals(10, verifyJson.earnedMinutes)
|
||||||
|
|
||||||
// 3. Verify Task incorrect answer
|
// 6. Verify Task incorrect answer
|
||||||
val verifyWrongResponse =
|
val verifyWrongResponse =
|
||||||
client.post("/api/v1/tasks/verify") {
|
client.post("/api/v1/tasks/verify") {
|
||||||
header(HttpHeaders.Authorization, "Bearer $token")
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue