Reading, Telemetry & Learning Loop
Part of: Pickatale Master Build Wiki | Version: v1.7 | Last Updated: 2026-04-18
6. Event System
Source: (B)(C) + code audit | Status: Confirmed (events marked UNVERIFIED are planned, not yet emitted)
Session Identity Model [V1-PRODUCTION]
Every reading session uses two separate identifiers. These must not be conflated:
| Field | Type | Lifetime | Purpose |
|---|---|---|---|
learner_id |
UUID (permanent) | Forever β never changes, never deleted | Canonical identity of the child across all services, schools, and devices. Owned by Account Center. |
device_session_id |
UUID (short-lived) | Single login β logout/timeout/device-reuse | Tracks which physical device is currently active. Issued on PIN login. Closed on logout, 30-min inactivity, or device reuse by another child. |
Rule: All telemetry events include both learner_id and device_session_id. The server resolves device_session_id β learner_id via the device_sessions table. The client never stores or transmits raw learner_id β it uses the session JWT which the server resolves server-side.
device_sessions table
CREATE TABLE device_sessions (
id INT PRIMARY KEY AUTO_INCREMENT,
device_session_id VARCHAR(36) NOT NULL UNIQUE, -- UUID, issued on PIN login
learner_id VARCHAR(36) NOT NULL, -- FK students.learner_id (permanent)
created_at DATETIME NOT NULL DEFAULT NOW(),
last_active DATETIME NOT NULL DEFAULT NOW(),
expired_at DATETIME NULL,
end_reason ENUM('logout', 'timeout', 'device_reuse', 'force_abandon') NULL,
policy_applied ENUM('shared', 'personal') NOT NULL DEFAULT 'shared',
policy_source ENUM('login_type', 'school_policy', 'device_signal') NOT NULL DEFAULT 'login_type',
INDEX(learner_id), INDEX(device_session_id), INDEX(expired_at)
);
Session lifecycle
- Child enters username + PIN β
POST /api/auth/child-loginβ server issues JWT containingdevice_session_id(new UUID) + resolveslearner_idinternally β INSERTdevice_sessionsrow. - All subsequent telemetry events carry the JWT. Server extracts
device_session_idand resolves tolearner_idbefore storing. - Inactivity timeout (30 min default, school-configurable 10β60 min): UPDATE
device_sessions.expired_at=NOW(), end_reason='timeout'. Partial-session threshold check: if β₯3 pages AND β₯50 words read β save miles + progress + bot signal; else discard. - Device reuse (another child logs in on same device): previous session closed transactionally before new
device_session_idissued.
Event Transport
- Client β Server: HTTP POST to Telemetry Service
/api/events - Server β Server: HTTP POST with X-Internal-Key header
- Offline: queued in IndexedDB via Service Worker, replayed on reconnect
Confirmed Events (emitted today)
All events include both device_session_id (short-lived, device-scoped) and learner_id (permanent child UUID). See Session Identity Model above.
ReadingStarted
{
"event_id": "uuid",
"event_type": "book_opened",
"learner_id": "uuid",
"device_session_id": "uuid",
"book_id": "14760-1",
"client_ts": 1713470400000
}
PageTurned
{
"event_id": "uuid",
"event_type": "page_turned",
"learner_id": "uuid",
"device_session_id": "uuid",
"book_id": "14760-1",
"page_number": 3,
"time_on_page_ms": 42000,
"client_ts": 1713470400000
}
WordTapped
{
"event_id": "uuid",
"event_type": "word_tapped",
"learner_id": "uuid",
"device_session_id": "uuid",
"book_id": "14760-1",
"page_number": 3,
"word": "magnificent",
"client_ts": 1713470400000
}
ReadingCompleted (session_ended)
{
"event_id": "uuid",
"event_type": "session_ended",
"learner_id": "uuid",
"device_session_id": "uuid",
"book_id": "14760-1",
"client_ts": 1713470400000
}
Side effect: Triggers POST to Learner Bot vocab-taps + session-summary
BookAbandoned
{
"event_id": "uuid",
"event_type": "book_abandoned",
"learner_id": "uuid",
"device_session_id": "uuid",
"book_id": "14760-1",
"page_number": 5,
"client_ts": 1713470400000
}
QuizAnswered
{
"event_id": "uuid",
"event_type": "quiz_answered",
"learner_id": "uuid",
"device_session_id": "uuid",
"book_id": "14760-1",
"score_pct": 80.0,
"level_recommendation": "hold",
"client_ts": 1713470400000
}
System-Level Events (internal, not client-emitted)
LowScoreDetected
Trigger: Bot detects avg quiz score <65% on 2+ consecutive quizzes Emitted by: Learner Bot nightly run Payload:
{
"event_type": "LowScoreDetected",
"learner_id": "uuid",
"avg_score": 55.0,
"quiz_count": 3,
"objective_id": 42,
"timestamp": "2026-04-18T04:00:00Z"
}
Action: Learner state β struggling; Teacher Portal attention list updated
InterventionTriggered
Trigger: Learner state reaches flagged (no sessions 4+ days, 0 books, low quiz avg)
Emitted by: Intervention Engine
Payload:
{
"event_type": "InterventionTriggered",
"learner_id": "uuid",
"reason": "no_sessions_4_days | zero_books | low_quiz_avg",
"teacher_id": "uuid",
"timestamp": "2026-04-18T04:00:00Z"
}
Action: Teacher Portal attention list updated with reason
ObjectiveMastered
Trigger: Assessment Bot returns level_recommendation = "raise" Emitted by: Orchestrator Payload:
{
"event_type": "ObjectiveMastered",
"learner_id": "uuid",
"objective_id": 42,
"book_id": "14760-1",
"score_pct": 88.0,
"new_reading_level": 4.0,
"timestamp": "2026-04-18T04:00:00Z"
}
Action: Learner Profile reading_level updated; Bot advances to next objective
NightlyBotCompleted
Trigger: Learner Bot nightly run finishes for a learner Emitted by: Learner Bot Payload:
{
"event_type": "NightlyBotCompleted",
"learner_id": "uuid",
"teacher_report_generated": true,
"parent_digest_generated": true,
"gaps_detected": 3,
"timestamp": "2026-04-18T04:00:00Z"
}
7. Core Modules
Source: (A)(B)(C) + code audit 2026-04-18 | Status: Confirmed per module (exceptions noted)
7.1 Reader App
- Domain: app.readingtester.com | Server: :3125 | Client: :5193
- Stack: React 19 + Vite + TypeScript (client) Β· Express + tRPC + Drizzle + MySQL (server)
- Code: /home/ubuntu/reader/
- DB: reader_app on shared MySQL instance (internal host β see ops docs)
- Status: β Live
Features β Confirmed:
- 9,089 books from CDN. Native HTML page renderer (cheerio word-span injection).
- Vertical text-only portrait mode: cream background, 24px serif, line-height 2.2, continuous scroll.
- Finger-Follow: words highlight #FCC128 on touch, TTS speaks on touch, 120ms debounce, iOS audio gate, words counted toward Miles.
- Reading Support Panel (Aa button): Font (Classic serif / Lexend / OpenDyslexic), Bionic Reading, Wide Spacing (WCAG 0.12em letter + 0.16em word), Colour Overlay (yellow/blue/green/pink/grey), Reading Ruler.
- Themes (persisted to localStorage): Day (bg #ffffff), Sepia (bg #F5E6C8), Night (bg #1c1c1e).
- Karaoke TTS: word-by-word highlight, speed 0.5Γβ1.5Γ (0.75Γ default), already-read text dims.
- Speedread RSVP: ages 8+ / FK β₯3.0 only. Max 10 min. Never default. Opt-in. Start at 60 WPM. RSVP words count toward miles/practice only β NOT toward "books read".
- Word tap β vocabulary popup. Logged as word_tapped event.
- Miles + Tokens: 100 words = 1 mile, 10 miles = 1 token. Tokens unlock books. UI reward screen: NOT YET BUILT.
- PWA offline-first: Service Worker (sw.ts) + IndexedDB telemetry queue. Auto-replay on reconnect.
- TTS: ElevenLabs (active). Azure Neural TTS coded and ready β awaiting Sig to provide key via portal.azure.com β Speech Services.
Features β NOT YET BUILT:
- Landscape mode (full-bleed illustration + text overlay)
- Comprehension quiz (post-book)
- Placement test (first login)
- Miles/Tokens reward screen
7.2 Account Center (SSO Hub)
- Domain: account.readingtester.com
- Status: β Auth built (login, register, uc_session cookie, /api/auth/session). learner_profiles schema NOT YET ADDED.
SSO mechanics: Sets uc_session cookie on .readingtester.com domain. All services verify via GET /api/auth/session. Admin creation: CLI only.
Missing: learner_profiles schema; change/reset password; email verification; account deletion (GDPR).
7.3 Telemetry Service
- Domain: telemetry.readingtester.com | Server: :3110 | Client: :5185
- Code: /home/ubuntu/telemetry/
- Status: β Live, wired to Reader App
Confirmed events: book_opened, page_turned, word_tapped, session_ended, quiz_answered, book_abandoned Deduplication: event_id (UUID), 7-day retention window On session_end: fires vocab-taps + session-summary to Learner Bot (5s timeout, fire-and-forget) Privacy: Raw touch data must be anonymised after session analysis. Voice audio MUST NOT be stored. Audio may exist only in-memory during request processing and must be deleted immediately after transcription. No logging, caching, queues, or retries may contain raw audio.
7.4 LRS
- Domain: lrs.readingtester.com | Server: :3111 | Client: :5186
- Code: /home/ubuntu/lrs/
- Standard: xAPI 1.0.3
- Status: β Live
Permanent audit-grade record of all learning events. GDPR export/delete endpoints confirmed.
7.5 Learner Profile Service
- Domain: learner.readingtester.com | Server: :3109 | Client: :5184
- DB: learner on shared MySQL
- Status: β Live β 2 test profiles. No real learner data yet.
Key fields: learner_id (UUID PK), reading_level (DECIMAL 4,2), reading_level_history (JSON), wcpm_history (JSON), cefr_level (VARCHAR), interest_tags (JSON), curriculum_territory (VARCHAR), age (INT), language (VARCHAR).
Note: no per-student consent fields. School DPA covers all enrolled students. See Admin & Compliance.
Data writers confirmed: Fluency Assessment β pushes FK + CEFR. Analytics model updater (02:30 UTC) β updates reading_level. Onboarding β sets interest_tags. Voice Assessment β must push FK (NOT YET WIRED).
7.6 Learner Bot
- Domain: learner-bot.readingtester.com | Server: :3120
- Code: /home/ubuntu/learner-bot/
- DB: learner_bot
- Status: β Code complete. Verified nightly run 2026-04-17 (8 test learners, 0 errors).
Cron: 04:00 UTC nightly run. 03:00 UTC telemetry retention cleanup. Auth: All routes: X-Internal-Key required. Confirmed endpoints: 20 total (see API Contracts section 4.3).
7.7 Adaptive Content Engine
- Domain: adapt.readingtester.com | Server: :3119
- Code: /home/ubuntu/adaptive-content/
- DB: adaptive_content
- Status: β Live. GPT levelling works. Translation unavailable (no DeepL/Google keys).
ZPD offset: child FK level + 0.5 (slightly above current level). Known issue: FK scores sometimes unchanged in practice. GPT prompt tightening required.
7.8 Curriculum Mapper (CM)
- Domain: cm.readingtester.com | Server: :3100 | Client: :5176
- Code: /home/ubuntu/cm/
- Stack: tRPC (REST API does not exist yet β Phase 2 must-build)
- Status: β Live β 3,777 objectives (Turkey 3,692 Β· England 85)
Extraction workers: spawn as detached child processes (survive server reloads).
Critical gap: Orchestrator cannot query CM without REST API. Must build GET /api/v1/objectives.
7.9 Content Service + Recommendation Engine
- Content: content.readingtester.com :3112/:5187 | Rec: recommendation.readingtester.com :3115
- Code: /home/ubuntu/content-meta/
- DB: content_meta
- Status: β Live
Recommendation phases:
- Phase 1 (live): Rules-based β level Β±1 + interest tags β 8 books per learner.
- Phase 2 (not built): Collaborative filtering.
- Phase 3 (not built): ML-based.
7.11 Analytics + ETL + Model Updater
- Domain: analytics.readingtester.com | Server: :3114 | Client: :5189
- Status: β Live
Cron: 02:30 UTC model updater (updates reading_level in Learner Profile). 03:30 UTC GDPR retention.
7.12 Intervention Engine
- Status: β Built (tested 2026-03-25 β 4 alerts in E2E test)
Flags: No placement test completed | 0 books read | avg quiz <65% on 2+ quizzes | 0 sessions in 4+ days. Output: Attention list entries in Teacher Portal.
7.13 Teacher Portal
- Domain: teacher.readingtester.com | Server: :3116 | Client: :5190
- Code: /home/ubuntu/teacher-portal/
- DB: teacher_portal
- Status: β Infrastructure built. 1 teacher, 0 real students.
Known bug: Class name display mismatch β input "Year3Blue" displays as "Year 3 Red" (CLASS ID 4). No transformation code found. Root cause unknown.
Student login cards: name + username + 4-digit PIN (shown once only, not recoverable) + QR code β app.readingtester.com?user=username.
7.14 Parent Portal
- Domain: parents.readingtester.com | Server: :3118 | Client: :5192
- Code: /home/ubuntu/parent-portal/
- Status: β Infrastructure built. No real digests yet.
Confirmed endpoints: GET /api/v1/digest/preview/:child_id Β· GET /api/v1/gdpr/export/:id
7.15 Fluency Assessment
- Domain: fluency.readingtester.com | Server: :3102 | Client: :5178
- Code: /home/ubuntu/fluency/
- Status: β Live β 5 students, 52 passages, 4 scored submissions.
- Critical gap: Results do NOT push to Learner Profile. Scores are siloed. Must wire.
Scoring: Whisper transcribes β GPT-4o scores WCPM + accuracy + automaticity + error breakdown. FK grade + CEFR calculated from WCPM + accuracy + year level. Multilingual learner flag: accuracy_too_low threshold relaxed to 40%, wcpm_too_low to 5 WCPM.
7.16 Services Returning 502 (Must Fix β Phase 3)
| Service | Domain | Code Location | Action |
|---|---|---|---|
| Voice Assessment | voice.readingtester.com | /home/ubuntu/Voice/ | Start service; wire to Learner Profile + Telemetry |
| Decodables | decodable.readingtester.com | disk (Flask + React) | Start service; expose API for Orchestrator |
| Talk AI | talk.readingtester.com | β | Start service; wire to Learner Profile + Telemetry |
42. Event Processing Rules
Source: (D) | Status: Confirmed as spec
42.1 Event Types and Producers
| Event | Producer | Consumer | Delivery |
|---|---|---|---|
| page_turned | Reader App (client) | Telemetry Service | Fire-and-forget HTTP POST |
| word_tapped | Reader App (client) | Telemetry Service | Fire-and-forget HTTP POST |
| session_ended | Reader App (client/SW) | Telemetry Service | HTTP POST (retried offline) |
| vocab-taps | Telemetry | Learner Bot | HTTP POST, 5s timeout, fire-and-forget |
| session-summary | Telemetry | Learner Bot | HTTP POST, 5s timeout, fire-and-forget |
| quiz-result | Reader App | Learner Bot | HTTP POST, 5s timeout, fire-and-forget |
| fluency-result | Fluency Assessment | Learner Bot | HTTP POST, 5s timeout, fire-and-forget |
| entitlement-invalidate | Account Center | All services | HTTP POST broadcast, fire-and-forget |
| nightly-bot-run | Cron (04:00 UTC) | Learner Bot | Internal trigger |
42.2 Retry Logic
Client β Telemetry (offline):
- Service Worker: workbox-background-sync, max 72h, exponential backoff (5s β 30s β 2min β 10min)
- On reconnect: replay all queued events in order
Telemetry β Learner Bot:
- ONE attempt, 5s timeout. No retry.
- Rationale: fire-and-forget. Bot processes on next nightly cycle if missed.
- Log failure to telemetry_errors table (device_session_id, error, ts).
Nightly bot run:
- If individual learner processing fails: log to nightly_errors (learner_id, error, ts). Continue to next learner.
- Entire run failure: INSERT audit_log (action='nightly_run_failed'). Alert cron re-runs at 05:00 UTC once.
42.3 Deduplication
Client events: event_id (UUID) is client-generated. Telemetry inserts with UNIQUE constraint on event_id. Duplicate β silently ignored (return 200, not 409 β client does not need to know).
Idempotency keys: See Section 39.4 for create-resource deduplication.
Nightly bot: Each learner processed once per night. Check nightly_reports.generated_at > date(now()) before processing. If already generated today β skip.
42.4 Ordering Guarantees
No ordering guarantee between Telemetry events from different client sessions. Events from the same device_session_id are ordered by client_ts (client timestamp). Server must order by client_ts, not created_at, when reconstructing session timeline.
session_ended always processed after page_turned / word_tapped for the same session. Enforced by: session-summary query runs on session_ended trigger β any events arriving after session_ended with same device_session_id are still captured (Telemetry inserts them normally; bot has already processed but next nightly cycle will catch the gap).
42.5 LRS Reconciliation
Rule: LRS receives a copy of every completed reading session as an xAPI statement. NOT real-time β reconciliation runs on session_ended.
Flow:
Reader App session_ended β Telemetry
β (also) POST /api/statements (LRS, xAPI format)
Actor: learner_id
Verb: http://adlnet.gov/expapi/verbs/completed
Object: book_id
Result: {score: quiz_score, duration: session_duration}
LRS is write-once for completed sessions. No updates. No deletes (except GDPR erasure).
43. Job / Queue Architecture
Source: (D) | Status: Confirmed as spec. v1 uses in-process scheduling. Queue system is recommended default for v2.**
43.1 Queue System (v1)
v1 uses node-cron (already installed in learner-bot, telemetry) for scheduled jobs. No external queue (no Bull, no RabbitMQ) in v1. Jobs run in-process.
Upgrade path (v2): Bull + Redis for all async jobs. Do NOT build with Bull in v1 β premature. Use in-process cron and HTTP fire-and-forget.
43.2 Job Types
| Job | Service | Schedule | Trigger |
|---|---|---|---|
| Telemetry retention | Telemetry | 03:00 UTC daily | cron |
| Nightly bot run | Learner Bot | 04:00 UTC daily | cron |
| Weekly parent digest | Learner Bot | Sunday 08:00 UTC | cron |
| Subscription expiry check | Account Center | Every hour | cron |
| Session force-abandon | Reader App | Every 6h | cron |
| Invite expiry | Account Center | Every 6h | cron |
| LRS reconciliation | Reader App | 05:00 UTC daily | cron (after bot run) |
43.3 Job Schema
Each cron job must log its run:
CREATE TABLE cron_runs (
id INT PRIMARY KEY AUTO_INCREMENT,
job_name VARCHAR(100) NOT NULL,
started_at DATETIME NOT NULL,
completed_at DATETIME NULL,
state ENUM('running','completed','failed') NOT NULL DEFAULT 'running',
items_total INT NULL,
items_ok INT NULL,
items_failed INT NULL,
error_log JSON NULL,
INDEX(job_name, started_at)
);
43.4 Retry Policy
| Job failure type | Retry |
|---|---|
| Full job crash | Alert via audit_log. Retry once after 1h. Manual re-trigger via admin endpoint. |
| Individual item failure (per-learner) | Log to error_log JSON in cron_runs. Continue next item. No per-item retry. |
| DB write failure | Retry 3Γ with 500ms backoff. On 3rd failure: log + skip item. |
| External API failure (GPT, SendGrid) | Retry 2Γ with exponential backoff (2s, 8s). On failure: log, skip item. |
43.5 Dead-Letter Handling
v1 has no formal dead-letter queue. Failed items are logged in cron_runs.error_log. Admin can:
- View failures via
GET /api/admin/jobs/:job_name/runs(last 10 runs, error details) - Re-trigger failed learners via
POST /api/admin/bot/run-learner/:learner_id - Re-trigger full nightly run via
POST /api/admin/bot/run-nightly(platform_admin only)