milwright commited on
Commit
d2c7203
·
2 Parent(s): 2f6bbd8 0aa8e05

Merge branch 'main' into clean-deploy

Browse files
Files changed (12) hide show
  1. .dockerignore +53 -0
  2. .gitignore +10 -0
  3. Makefile +48 -0
  4. README.md +5 -3
  5. docker-compose.yml +27 -0
  6. local-server.py +62 -0
  7. package.json +38 -0
  8. src/aiService.js +26 -1
  9. src/app.js +15 -5
  10. src/chatInterface.js +33 -7
  11. src/clozeGameEngine.js +75 -16
  12. src/styles.css +81 -30
.dockerignore ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Node modules
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+
6
+ # Git
7
+ .git/
8
+ .gitignore
9
+
10
+ # Environment files
11
+ .env
12
+ .env.*
13
+
14
+ # IDE files
15
+ .vscode/
16
+ .idea/
17
+ *.swp
18
+ *.swo
19
+
20
+ # OS files
21
+ .DS_Store
22
+ Thumbs.db
23
+
24
+ # Logs
25
+ *.log
26
+ logs/
27
+
28
+ # Temporary files
29
+ tmp/
30
+ temp/
31
+ .aider*
32
+
33
+ # Documentation
34
+ *.md
35
+ !README.md
36
+
37
+ # Planning documents
38
+ *_PLAN.md
39
+ IMPLEMENTATION_*.md
40
+
41
+ # Python cache
42
+ __pycache__/
43
+ *.pyc
44
+ *.pyo
45
+ *.pyd
46
+
47
+ # Coverage
48
+ coverage/
49
+ .nyc_output/
50
+
51
+ # Build artifacts
52
+ dist/
53
+ build/
.gitignore CHANGED
@@ -55,7 +55,17 @@ tmp/
55
  temp/
56
  .aider*
57
 
 
 
 
 
 
 
58
  # Planning documents
59
  HF_DATASET_INTEGRATION_PLAN.md
60
  *_PLAN.md
61
  IMPLEMENTATION_*.md
 
 
 
 
 
55
  temp/
56
  .aider*
57
 
58
+ # Python
59
+ __pycache__/
60
+ *.py[cod]
61
+ *$py.class
62
+ *.so
63
+
64
  # Planning documents
65
  HF_DATASET_INTEGRATION_PLAN.md
66
  *_PLAN.md
67
  IMPLEMENTATION_*.md
68
+
69
+ # Local configuration files
70
+ *.local
71
+ .env
Makefile ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: help dev dev-python dev-docker build test clean install docker-build docker-run docker-dev
2
+
3
+ help: ## Show this help message
4
+ @echo "Available commands:"
5
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
6
+
7
+ install: ## Install dependencies
8
+ @echo "Installing Python dependencies..."
9
+ pip install -r requirements.txt
10
+ @echo "Installing Node.js dependencies..."
11
+ npm install
12
+
13
+ dev: ## Start local development server (Python HTTP server)
14
+ @echo "Starting local development server on http://localhost:8000"
15
+ python local-server.py 8000
16
+
17
+ dev-python: ## Start FastAPI development server
18
+ @echo "Starting FastAPI server on http://localhost:7860"
19
+ python app.py
20
+
21
+ dev-docker: ## Start development environment with Docker Compose
22
+ docker-compose --profile dev up --build
23
+
24
+ build: ## Build the application (no-op for vanilla JS)
25
+ @echo "No build step needed for vanilla JS application"
26
+
27
+ test: ## Run tests (placeholder)
28
+ @echo "No tests configured yet"
29
+
30
+ clean: ## Clean temporary files
31
+ find . -type f -name "*.pyc" -delete
32
+ find . -type d -name "__pycache__" -delete
33
+ find . -type d -name "node_modules" -exec rm -rf {} + 2>/dev/null || true
34
+
35
+ docker-build: ## Build Docker image
36
+ docker build -t cloze-reader .
37
+
38
+ docker-run: ## Run Docker container
39
+ docker run -p 7860:7860 --env-file .env cloze-reader
40
+
41
+ docker-dev: ## Start with docker-compose
42
+ docker-compose up --build
43
+
44
+ logs: ## Show Docker logs
45
+ docker-compose logs -f
46
+
47
+ stop: ## Stop Docker containers
48
+ docker-compose down
README.md CHANGED
@@ -1,10 +1,12 @@
1
  ---
2
  title: Cloze Reader
3
  emoji: 📚
4
- colorFrom: blue
5
- colorTo: purple
6
  sdk: docker
7
- pinned: false
 
 
8
  ---
9
 
10
  # Cloze Reader
 
1
  ---
2
  title: Cloze Reader
3
  emoji: 📚
4
+ colorFrom: yellow
5
+ colorTo: gray
6
  sdk: docker
7
+ pinned: true
8
+ thumbnail: >-
9
+ https://cdn-uploads.huggingface.co/production/uploads/65a0caa15dfd8b9b1f3aa3d3/3GdgODxZMuycEJvbrtAdm.png
10
  ---
11
 
12
  # Cloze Reader
docker-compose.yml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ cloze-reader:
5
+ build: .
6
+ ports:
7
+ - "7860:7860"
8
+ environment:
9
+ - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
10
+ - HF_API_KEY=${HF_API_KEY:-}
11
+ volumes:
12
+ - ./src:/app/src:ro
13
+ - ./index.html:/app/index.html:ro
14
+ restart: unless-stopped
15
+
16
+ dev-server:
17
+ image: python:3.9-slim
18
+ working_dir: /app
19
+ ports:
20
+ - "8000:8000"
21
+ volumes:
22
+ - .:/app
23
+ command: python -m http.server 8000
24
+ environment:
25
+ - PYTHONUNBUFFERED=1
26
+ profiles:
27
+ - dev
local-server.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Local development server for cloze-reader
4
+ Serves static files with CORS enabled for local testing
5
+ """
6
+
7
+ import http.server
8
+ import socketserver
9
+ import os
10
+ from urllib.parse import urlparse
11
+ import json
12
+
13
+ class LocalHandler(http.server.SimpleHTTPRequestHandler):
14
+ def end_headers(self):
15
+ # Enable CORS for local development
16
+ self.send_header('Access-Control-Allow-Origin', '*')
17
+ self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
18
+ self.send_header('Access-Control-Allow-Headers', 'Content-Type')
19
+ super().end_headers()
20
+
21
+ def do_GET(self):
22
+ # Handle root path
23
+ if self.path == '/':
24
+ self.path = '/index.html'
25
+
26
+ # Handle icon.png (serve local file if exists, otherwise redirect)
27
+ if self.path == '/icon.png':
28
+ if os.path.exists('icon.png'):
29
+ return super().do_GET()
30
+ else:
31
+ self.send_response(302)
32
+ self.send_header('Location', 'https://raw.githubusercontent.com/zmuhls/cloze-reader/main/icon.png')
33
+ self.end_headers()
34
+ return
35
+
36
+ # Serve static files
37
+ return super().do_GET()
38
+
39
+ def do_OPTIONS(self):
40
+ self.send_response(200)
41
+ self.end_headers()
42
+
43
+ def run_server(port=8000):
44
+ handler = LocalHandler
45
+
46
+ try:
47
+ with socketserver.TCPServer(("", port), handler) as httpd:
48
+ print(f"Local development server running at http://localhost:{port}/")
49
+ print("Press Ctrl+C to stop")
50
+ httpd.serve_forever()
51
+ except KeyboardInterrupt:
52
+ print("\nServer stopped")
53
+ except OSError as e:
54
+ if e.errno == 48: # Address already in use
55
+ print(f"Port {port} is already in use. Try a different port.")
56
+ else:
57
+ print(f"Error starting server: {e}")
58
+
59
+ if __name__ == "__main__":
60
+ import sys
61
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
62
+ run_server(port)
package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "cloze-reader",
3
+ "version": "1.0.0",
4
+ "description": "Interactive cloze reading comprehension game",
5
+ "main": "src/app.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "python -m http.server 8000",
9
+ "dev:python": "python app.py",
10
+ "build": "echo 'No build step needed for vanilla JS'",
11
+ "test": "echo 'No tests specified yet'",
12
+ "lint": "echo 'No linting configured yet'",
13
+ "start": "python app.py",
14
+ "docker:build": "docker build -t cloze-reader .",
15
+ "docker:run": "docker run -p 7860:7860 --env-file .env cloze-reader",
16
+ "docker:dev": "docker-compose up --build",
17
+ "docker:stop": "docker-compose down",
18
+ "docker:logs": "docker-compose logs -f",
19
+ "docker:clean": "docker-compose down -v --remove-orphans"
20
+ },
21
+ "keywords": [
22
+ "education",
23
+ "reading",
24
+ "comprehension",
25
+ "cloze",
26
+ "game"
27
+ ],
28
+ "author": "",
29
+ "license": "MIT",
30
+ "devDependencies": {
31
+ "http-server": "^14.1.1"
32
+ },
33
+ "dependencies": {},
34
+ "engines": {
35
+ "node": ">=14.0.0",
36
+ "python": ">=3.9"
37
+ }
38
+ }
src/aiService.js CHANGED
@@ -138,6 +138,19 @@ Passage: "${passage}"`
138
  }
139
 
140
  const data = await response.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  const content = data.choices[0].message.content.trim();
142
 
143
  // Try to parse as JSON array
@@ -207,13 +220,25 @@ Passage: "${passage}"`
207
  }
208
 
209
  const data = await response.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  let content = data.choices[0].message.content.trim();
211
 
212
  // Clean up AI response artifacts
213
  content = content
214
  .replace(/^\s*["']|["']\s*$/g, '') // Remove leading/trailing quotes
215
  .replace(/^\s*[:;]+\s*/, '') // Remove leading colons and semicolons
216
- .replace(/^\s*[a-z]+:\s*/i, '') // Remove any word followed by colon (like "mas:")
217
  .replace(/\*+/g, '') // Remove asterisks (markdown bold/italic)
218
  .replace(/_+/g, '') // Remove underscores (markdown)
219
  .replace(/#+\s*/g, '') // Remove hash symbols (markdown headers)
 
138
  }
139
 
140
  const data = await response.json();
141
+
142
+ // Check for OpenRouter error response
143
+ if (data.error) {
144
+ console.error('OpenRouter API error for word selection:', data.error);
145
+ throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`);
146
+ }
147
+
148
+ // Check if response has expected structure
149
+ if (!data.choices || !data.choices[0] || !data.choices[0].message || !data.choices[0].message.content) {
150
+ console.error('Invalid word selection API response structure:', data);
151
+ throw new Error('API response missing expected content');
152
+ }
153
+
154
  const content = data.choices[0].message.content.trim();
155
 
156
  // Try to parse as JSON array
 
220
  }
221
 
222
  const data = await response.json();
223
+
224
+ // Check for OpenRouter error response
225
+ if (data.error) {
226
+ console.error('OpenRouter API error for contextualization:', data.error);
227
+ throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`);
228
+ }
229
+
230
+ // Check if response has expected structure
231
+ if (!data.choices || !data.choices[0] || !data.choices[0].message || !data.choices[0].message.content) {
232
+ console.error('Invalid contextualization API response structure:', data);
233
+ throw new Error('API response missing expected content');
234
+ }
235
+
236
  let content = data.choices[0].message.content.trim();
237
 
238
  // Clean up AI response artifacts
239
  content = content
240
  .replace(/^\s*["']|["']\s*$/g, '') // Remove leading/trailing quotes
241
  .replace(/^\s*[:;]+\s*/, '') // Remove leading colons and semicolons
 
242
  .replace(/\*+/g, '') // Remove asterisks (markdown bold/italic)
243
  .replace(/_+/g, '') // Remove underscores (markdown)
244
  .replace(/#+\s*/g, '') // Remove hash symbols (markdown headers)
src/app.js CHANGED
@@ -69,10 +69,11 @@ class App {
69
  <strong>${roundData.title}</strong> by ${roundData.author}
70
  `;
71
 
72
- // Show level information without round number
73
  const blanksCount = roundData.blanks.length;
74
  const difficultyText = blanksCount === 1 ? 'Easy' : blanksCount === 2 ? 'Medium' : 'Hard';
75
- this.elements.roundInfo.innerHTML = `Level ${this.game.currentLevel} • ${blanksCount} blank${blanksCount > 1 ? 's' : ''} • ${difficultyText}`;
 
76
 
77
  // Show contextualization from AI agent
78
  this.elements.contextualization.innerHTML = `
@@ -206,15 +207,24 @@ class App {
206
  // Show loading immediately with specific message
207
  this.showLoading(true, 'Loading next passage...');
208
 
209
- // Clear chat history when starting new round
210
  this.chatUI.clearChatHistory();
211
 
212
- const roundData = await this.game.nextRound();
 
 
 
 
 
 
 
 
 
213
  this.displayRound(roundData);
214
  this.resetUI();
215
  this.showLoading(false);
216
  } catch (error) {
217
- console.error('Error loading next round:', error);
218
  this.showError('Could not load next passage. Please try again.');
219
  }
220
  }
 
69
  <strong>${roundData.title}</strong> by ${roundData.author}
70
  `;
71
 
72
+ // Show level information with passage number
73
  const blanksCount = roundData.blanks.length;
74
  const difficultyText = blanksCount === 1 ? 'Easy' : blanksCount === 2 ? 'Medium' : 'Hard';
75
+ const passageInfo = roundData.passageNumber ? `Passage ${roundData.passageNumber}/${roundData.totalPassages} • ` : '';
76
+ this.elements.roundInfo.innerHTML = `Level ${this.game.currentLevel} • ${passageInfo}${blanksCount} blank${blanksCount > 1 ? 's' : ''} • ${difficultyText}`;
77
 
78
  // Show contextualization from AI agent
79
  this.elements.contextualization.innerHTML = `
 
207
  // Show loading immediately with specific message
208
  this.showLoading(true, 'Loading next passage...');
209
 
210
+ // Clear chat history when starting new passage/round
211
  this.chatUI.clearChatHistory();
212
 
213
+ // Check if we should load next passage or next round
214
+ let roundData;
215
+ if (this.game.currentPassageIndex === 0) {
216
+ // Load second passage in current round
217
+ roundData = await this.game.nextPassage();
218
+ } else {
219
+ // Load next round (two new passages)
220
+ roundData = await this.game.nextRound();
221
+ }
222
+
223
  this.displayRound(roundData);
224
  this.resetUI();
225
  this.showLoading(false);
226
  } catch (error) {
227
+ console.error('Error loading next passage:', error);
228
  this.showError('Could not load next passage. Please try again.');
229
  }
230
  }
src/chatInterface.js CHANGED
@@ -44,7 +44,12 @@ class ChatUI {
44
  <!-- Question buttons area -->
45
  <div class="p-4 border-t">
46
  <div class="text-sm text-gray-600 mb-3">Choose a question:</div>
47
- <div id="question-buttons" class="grid grid-cols-2 gap-2">
 
 
 
 
 
48
  <!-- Question buttons will be inserted here -->
49
  </div>
50
  </div>
@@ -107,6 +112,7 @@ class ChatUI {
107
  messagesContainer.innerHTML = `
108
  <div class="text-center text-gray-500 text-sm">
109
  Choose a question below to get help with this word.
 
110
  </div>
111
  `;
112
  }
@@ -159,27 +165,39 @@ class ChatUI {
159
  // Load question buttons with disabled state for used questions
160
  loadQuestionButtons() {
161
  const buttonsContainer = document.getElementById('question-buttons');
 
162
  const questions = this.game.getSuggestedQuestionsForBlank(this.activeChatBlank);
163
 
164
- let html = '';
 
 
 
 
 
165
  questions.forEach(question => {
166
  const isDisabled = question.used;
167
  const buttonClass = isDisabled
168
- ? 'question-btn px-3 py-2 bg-gray-200 text-gray-500 rounded cursor-not-allowed text-sm font-medium'
169
- : 'question-btn px-3 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm font-medium';
170
 
171
- html += `
 
172
  <button class="${buttonClass}"
173
  data-type="${question.type}"
174
  ${isDisabled ? 'disabled' : ''}>
175
  ${question.text}${isDisabled ? ' ✓' : ''}
176
  </button>
177
  `;
 
 
 
 
 
178
  });
179
 
180
- buttonsContainer.innerHTML = html;
181
 
182
- // Add click listeners to individual question buttons (not the container)
183
  buttonsContainer.querySelectorAll('.question-btn').forEach(btn => {
184
  btn.addEventListener('click', (e) => {
185
  if (!btn.disabled) {
@@ -190,6 +208,14 @@ class ChatUI {
190
  }
191
  });
192
  });
 
 
 
 
 
 
 
 
193
  }
194
 
195
  // Ask a specific question
 
44
  <!-- Question buttons area -->
45
  <div class="p-4 border-t">
46
  <div class="text-sm text-gray-600 mb-3">Choose a question:</div>
47
+ <!-- Dropdown for mobile -->
48
+ <select id="question-dropdown" class="w-full p-2 border rounded md:hidden mb-2">
49
+ <option value="">Select a question...</option>
50
+ </select>
51
+ <!-- Button grid for desktop -->
52
+ <div id="question-buttons" class="hidden md:grid grid-cols-2 gap-2">
53
  <!-- Question buttons will be inserted here -->
54
  </div>
55
  </div>
 
112
  messagesContainer.innerHTML = `
113
  <div class="text-center text-gray-500 text-sm">
114
  Choose a question below to get help with this word.
115
+ <br>
116
  </div>
117
  `;
118
  }
 
165
  // Load question buttons with disabled state for used questions
166
  loadQuestionButtons() {
167
  const buttonsContainer = document.getElementById('question-buttons');
168
+ const dropdown = document.getElementById('question-dropdown');
169
  const questions = this.game.getSuggestedQuestionsForBlank(this.activeChatBlank);
170
 
171
+ // Clear existing content
172
+ buttonsContainer.innerHTML = '';
173
+ dropdown.innerHTML = '<option value="">Select a question...</option>';
174
+
175
+ // Build button grid for desktop and dropdown options
176
+ let buttonHtml = '';
177
  questions.forEach(question => {
178
  const isDisabled = question.used;
179
  const buttonClass = isDisabled
180
+ ? 'question-btn px-2 py-1 bg-gray-100 text-gray-500 rounded cursor-not-allowed text-xs'
181
+ : 'question-btn px-2 py-1 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 text-xs border border-blue-200';
182
 
183
+ // Desktop buttons
184
+ buttonHtml += `
185
  <button class="${buttonClass}"
186
  data-type="${question.type}"
187
  ${isDisabled ? 'disabled' : ''}>
188
  ${question.text}${isDisabled ? ' ✓' : ''}
189
  </button>
190
  `;
191
+
192
+ // Mobile dropdown options
193
+ if (!isDisabled) {
194
+ dropdown.innerHTML += `<option value="${question.type}">${question.text}</option>`;
195
+ }
196
  });
197
 
198
+ buttonsContainer.innerHTML = buttonHtml;
199
 
200
+ // Add click listeners to desktop buttons
201
  buttonsContainer.querySelectorAll('.question-btn').forEach(btn => {
202
  btn.addEventListener('click', (e) => {
203
  if (!btn.disabled) {
 
208
  }
209
  });
210
  });
211
+
212
+ // Add change listener to mobile dropdown
213
+ dropdown.addEventListener('change', (e) => {
214
+ if (e.target.value) {
215
+ this.askQuestion(e.target.value);
216
+ e.target.value = ''; // Reset dropdown
217
+ }
218
+ });
219
  }
220
 
221
  // Ask a specific question
src/clozeGameEngine.js CHANGED
@@ -19,6 +19,11 @@ class ClozeGame {
19
  this.hints = [];
20
  this.chatService = new ChatService(aiService);
21
  this.lastResults = null; // Store results for answer revelation
 
 
 
 
 
22
  }
23
 
24
  async initialize() {
@@ -33,20 +38,30 @@ class ClozeGame {
33
 
34
  async startNewRound() {
35
  try {
36
- // Get a random book (now async with HF streaming)
37
- this.currentBook = await bookDataService.getRandomBook();
 
 
 
 
 
 
 
 
 
 
38
 
39
- // Extract a coherent passage avoiding fragmented text
40
- const fullText = this.currentBook.text;
41
- let passage = this.extractCoherentPassage(fullText);
42
 
43
- this.originalText = passage.trim();
 
44
 
45
- // Run AI calls in parallel for faster loading
46
- const [clozeResult, contextualizationResult] = await Promise.all([
47
- this.createClozeText(),
48
- this.generateContextualization()
49
- ]);
50
 
51
  return {
52
  title: this.currentBook.title,
@@ -54,7 +69,9 @@ class ClozeGame {
54
  text: this.clozeText,
55
  blanks: this.blanks,
56
  contextualization: this.contextualization,
57
- hints: this.hints
 
 
58
  };
59
  } catch (error) {
60
  console.error('Error starting new round:', error);
@@ -376,8 +393,8 @@ class ClozeGame {
376
  const inputHtml = `<input type="text"
377
  class="cloze-input"
378
  data-blank-index="${index}"
379
- placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
380
- style="width: ${Math.max(80, blank.originalWord.length * 12)}px;">`;
381
 
382
  html = html.replace(`___BLANK_${index}___`, inputHtml);
383
  });
@@ -452,6 +469,48 @@ class ClozeGame {
452
  }));
453
  }
454
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  nextRound() {
456
  // Check if user passed the previous round
457
  const passed = this.lastResults && this.lastResults.passed;
@@ -547,8 +606,8 @@ class ClozeGame {
547
  <input type="text"
548
  class="cloze-input"
549
  data-blank-index="${index}"
550
- placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
551
- style="width: ${Math.max(80, blank.originalWord.length * 12)}px;">
552
  <button id="${chatButtonId}"
553
  class="chat-button text-blue-500 hover:text-blue-700 text-sm"
554
  data-blank-index="${index}"
 
19
  this.hints = [];
20
  this.chatService = new ChatService(aiService);
21
  this.lastResults = null; // Store results for answer revelation
22
+
23
+ // Two-passage system properties
24
+ this.currentBooks = []; // Array of two books per round
25
+ this.passages = []; // Array of two passages per round
26
+ this.currentPassageIndex = 0; // 0 for first passage, 1 for second
27
  }
28
 
29
  async initialize() {
 
38
 
39
  async startNewRound() {
40
  try {
41
+ // Get two random books for this round
42
+ const book1 = await bookDataService.getRandomBook();
43
+ const book2 = await bookDataService.getRandomBook();
44
+
45
+ // Extract passages from both books
46
+ const passage1 = this.extractCoherentPassage(book1.text);
47
+ const passage2 = this.extractCoherentPassage(book2.text);
48
+
49
+ // Store both books and passages
50
+ this.currentBooks = [book1, book2];
51
+ this.passages = [passage1.trim(), passage2.trim()];
52
+ this.currentPassageIndex = 0;
53
 
54
+ // Start with the first passage
55
+ this.currentBook = book1;
56
+ this.originalText = this.passages[0];
57
 
58
+ // Run AI calls sequentially to avoid rate limiting
59
+ await this.createClozeText();
60
 
61
+ // Add small delay between API calls to avoid rate limiting
62
+ await new Promise(resolve => setTimeout(resolve, 1000));
63
+
64
+ await this.generateContextualization();
 
65
 
66
  return {
67
  title: this.currentBook.title,
 
69
  text: this.clozeText,
70
  blanks: this.blanks,
71
  contextualization: this.contextualization,
72
+ hints: this.hints,
73
+ passageNumber: 1,
74
+ totalPassages: 2
75
  };
76
  } catch (error) {
77
  console.error('Error starting new round:', error);
 
393
  const inputHtml = `<input type="text"
394
  class="cloze-input"
395
  data-blank-index="${index}"
396
+ placeholder="${'_ '.repeat(Math.max(3, blank.originalWord.length)).trim()}"
397
+ style="width: ${Math.max(80, blank.originalWord.length * 16)}px;">`;
398
 
399
  html = html.replace(`___BLANK_${index}___`, inputHtml);
400
  });
 
469
  }));
470
  }
471
 
472
+ async nextPassage() {
473
+ try {
474
+ // Move to the second passage in the current round
475
+ if (this.currentPassageIndex === 0 && this.passages && this.passages.length > 1) {
476
+ this.currentPassageIndex = 1;
477
+ this.currentBook = this.currentBooks[1];
478
+ this.originalText = this.passages[1];
479
+
480
+ // Clear chat conversations for new passage
481
+ this.chatService.clearConversations();
482
+
483
+ // Clear last results
484
+ this.lastResults = null;
485
+
486
+ // Generate new cloze text and contextualization for second passage sequentially
487
+ await this.createClozeText();
488
+
489
+ // Add small delay between API calls to avoid rate limiting
490
+ await new Promise(resolve => setTimeout(resolve, 1000));
491
+
492
+ await this.generateContextualization();
493
+
494
+ return {
495
+ title: this.currentBook.title,
496
+ author: this.currentBook.author,
497
+ text: this.clozeText,
498
+ blanks: this.blanks,
499
+ contextualization: this.contextualization,
500
+ hints: this.hints,
501
+ passageNumber: 2,
502
+ totalPassages: 2
503
+ };
504
+ } else {
505
+ // If we're already on the second passage, move to next round
506
+ return this.nextRound();
507
+ }
508
+ } catch (error) {
509
+ console.error('Error loading next passage:', error);
510
+ throw error;
511
+ }
512
+ }
513
+
514
  nextRound() {
515
  // Check if user passed the previous round
516
  const passed = this.lastResults && this.lastResults.passed;
 
606
  <input type="text"
607
  class="cloze-input"
608
  data-blank-index="${index}"
609
+ placeholder="${'_ '.repeat(Math.max(3, blank.originalWord.length)).trim()}"
610
+ style="width: ${Math.max(80, blank.originalWord.length * 16)}px;">
611
  <button id="${chatButtonId}"
612
  class="chat-button text-blue-500 hover:text-blue-700 text-sm"
613
  data-blank-index="${index}"
src/styles.css CHANGED
@@ -31,14 +31,38 @@
31
  transform: scale(1.1);
32
  }
33
 
34
- .suggestion-btn {
35
  transition: all 0.2s ease;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  }
37
 
38
- .suggestion-btn:hover {
 
 
39
  transform: translateY(-1px);
40
  }
41
 
 
 
 
 
 
 
42
  /* Answer revelation styles */
43
  .revealed-answer {
44
  background-color: #fef3c7 !important;
@@ -138,10 +162,11 @@
138
 
139
  @layer base {
140
  body {
141
- font-family: 'Special Elite', 'Courier New', monospace;
142
  background-color: var(--aged-paper);
143
  color: var(--typewriter-ink);
144
- letter-spacing: 0.05em;
 
145
  background-image:
146
  radial-gradient(circle at 25% 25%, rgba(139, 92, 246, 0.02) 0%, transparent 50%),
147
  radial-gradient(circle at 75% 75%, rgba(139, 92, 246, 0.02) 0%, transparent 50%);
@@ -150,18 +175,17 @@
150
 
151
  @layer components {
152
  .typewriter-text {
153
- font-family: 'Special Elite', 'Courier New', monospace;
154
  color: var(--typewriter-ink);
155
- letter-spacing: 0.1em;
156
- word-spacing: 0.1em;
157
- line-height: 1.8;
158
  }
159
 
160
  .typewriter-subtitle {
161
- font-family: 'Special Elite', 'Courier New', monospace;
162
  color: #666;
163
- letter-spacing: 0.05em;
164
- font-size: 0.95rem;
165
  }
166
 
167
  .paper-sheet {
@@ -197,55 +221,56 @@
197
  }
198
 
199
  .cloze-input {
200
- font-family: 'Special Elite', 'Courier New', monospace;
201
  background-color: transparent;
202
  border: none;
203
- border-bottom: 2px solid black;
204
  color: var(--typewriter-ink);
205
  text-align: center;
206
  outline: none;
207
- padding: 2px 4px;
208
  margin: 0 2px;
209
- min-width: 3ch;
210
  width: auto;
211
- letter-spacing: 0.05em;
212
  font-size: inherit;
213
  line-height: inherit;
214
  transition: all 0.2s ease;
215
  }
216
 
217
  .cloze-input:focus {
218
- border-bottom-color: black;
219
  box-shadow: 0 2px 0 rgba(0, 0, 0, 0.2);
220
  background-color: rgba(0, 0, 0, 0.05);
221
  }
222
 
223
  .cloze-input.correct {
224
- border-bottom-color: #10b981;
225
  background-color: rgba(16, 185, 129, 0.1);
226
  }
227
 
228
  .cloze-input.incorrect {
229
- border-bottom-color: #ef4444;
230
  background-color: rgba(239, 68, 68, 0.1);
231
  }
232
 
233
  .cloze-input::placeholder {
234
  color: rgba(0, 0, 0, 0.4);
235
- font-style: italic;
 
 
236
  }
237
 
238
  .typewriter-button {
239
- font-family: 'Special Elite', 'Courier New', monospace;
240
  min-width: 120px;
241
- min-height: 42px;
242
- padding: 8px 16px;
243
  background-color: var(--aged-paper-dark);
244
  color: var(--typewriter-ink);
245
  border: 2px solid black;
246
- border-radius: 4px;
247
  font-weight: 600;
248
- letter-spacing: 0.05em;
249
  cursor: pointer;
250
  transition: all 0.15s ease;
251
  box-shadow:
@@ -288,11 +313,9 @@
288
  }
289
 
290
  .prose {
291
- font-family: 'Special Elite', 'Courier New', monospace;
292
- font-size: 1.1rem;
293
- line-height: 1.8;
294
- letter-spacing: 0.05em;
295
- word-spacing: 0.1em;
296
  color: var(--typewriter-ink);
297
  overflow-wrap: break-word;
298
  }
@@ -384,6 +407,34 @@
384
  }
385
  }
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  /* Print styles */
388
  @media print {
389
  body {
 
31
  transform: scale(1.1);
32
  }
33
 
34
+ .suggestion-btn, .question-btn {
35
  transition: all 0.2s ease;
36
+ padding: 14px 18px;
37
+ background: #f8f9fa;
38
+ border: 2px solid #e9ecef;
39
+ border-radius: 8px;
40
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
41
+ font-size: 15px;
42
+ font-weight: 500;
43
+ text-align: center;
44
+ cursor: pointer;
45
+ line-height: 1.4;
46
+ min-height: 64px;
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: center;
50
+ width: 100%;
51
+ box-sizing: border-box;
52
  }
53
 
54
+ .suggestion-btn:hover, .question-btn:hover:not(:disabled) {
55
+ background: #e9ecef;
56
+ border-color: #6c757d;
57
  transform: translateY(-1px);
58
  }
59
 
60
+ .suggestion-btn:disabled, .question-btn:disabled {
61
+ opacity: 0.5;
62
+ cursor: not-allowed;
63
+ background: #f8f9fa;
64
+ }
65
+
66
  /* Answer revelation styles */
67
  .revealed-answer {
68
  background-color: #fef3c7 !important;
 
162
 
163
  @layer base {
164
  body {
165
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
166
  background-color: var(--aged-paper);
167
  color: var(--typewriter-ink);
168
+ line-height: 1.6;
169
+ font-size: 16px;
170
  background-image:
171
  radial-gradient(circle at 25% 25%, rgba(139, 92, 246, 0.02) 0%, transparent 50%),
172
  radial-gradient(circle at 75% 75%, rgba(139, 92, 246, 0.02) 0%, transparent 50%);
 
175
 
176
  @layer components {
177
  .typewriter-text {
178
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
179
  color: var(--typewriter-ink);
180
+ font-weight: 600;
181
+ line-height: 1.7;
 
182
  }
183
 
184
  .typewriter-subtitle {
185
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
186
  color: #666;
187
+ font-size: 1rem;
188
+ line-height: 1.5;
189
  }
190
 
191
  .paper-sheet {
 
221
  }
222
 
223
  .cloze-input {
224
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
225
  background-color: transparent;
226
  border: none;
227
+ border-bottom: 2px dotted black;
228
  color: var(--typewriter-ink);
229
  text-align: center;
230
  outline: none;
231
+ padding: 4px 6px;
232
  margin: 0 2px;
233
+ min-width: 4ch;
234
  width: auto;
 
235
  font-size: inherit;
236
  line-height: inherit;
237
  transition: all 0.2s ease;
238
  }
239
 
240
  .cloze-input:focus {
241
+ border-bottom: 2px dotted black;
242
  box-shadow: 0 2px 0 rgba(0, 0, 0, 0.2);
243
  background-color: rgba(0, 0, 0, 0.05);
244
  }
245
 
246
  .cloze-input.correct {
247
+ border-bottom: 2px dotted #10b981;
248
  background-color: rgba(16, 185, 129, 0.1);
249
  }
250
 
251
  .cloze-input.incorrect {
252
+ border-bottom: 2px dotted #ef4444;
253
  background-color: rgba(239, 68, 68, 0.1);
254
  }
255
 
256
  .cloze-input::placeholder {
257
  color: rgba(0, 0, 0, 0.4);
258
+ font-style: normal;
259
+ font-family: monospace;
260
+ letter-spacing: 0.1em;
261
  }
262
 
263
  .typewriter-button {
264
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
265
  min-width: 120px;
266
+ min-height: 44px;
267
+ padding: 10px 18px;
268
  background-color: var(--aged-paper-dark);
269
  color: var(--typewriter-ink);
270
  border: 2px solid black;
271
+ border-radius: 6px;
272
  font-weight: 600;
273
+ font-size: 16px;
274
  cursor: pointer;
275
  transition: all 0.15s ease;
276
  box-shadow:
 
313
  }
314
 
315
  .prose {
316
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
317
+ font-size: 1.125rem;
318
+ line-height: 1.7;
 
 
319
  color: var(--typewriter-ink);
320
  overflow-wrap: break-word;
321
  }
 
407
  }
408
  }
409
 
410
+ /* Compact question buttons for chat interface */
411
+ #chat-modal .question-btn {
412
+ padding: 6px 8px;
413
+ min-height: auto;
414
+ font-size: 12px;
415
+ line-height: 1.3;
416
+ font-weight: 400;
417
+ transition: all 0.15s ease;
418
+ }
419
+
420
+ /* Mobile dropdown styling */
421
+ #question-dropdown {
422
+ background: #f8f9fa;
423
+ border: 2px solid #e9ecef;
424
+ border-radius: 8px;
425
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
426
+ font-size: 15px;
427
+ font-weight: 500;
428
+ color: #495057;
429
+ transition: all 0.2s ease;
430
+ }
431
+
432
+ #question-dropdown:focus {
433
+ outline: none;
434
+ border-color: #007bff;
435
+ box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
436
+ }
437
+
438
  /* Print styles */
439
  @media print {
440
  body {