Spaces:
Running
Running
feat: minimal deployment with essential JavaScript modules and complete CSS
Browse files- Core game modules: clozeGameEngine.js, aiService.js, bookDataService.js
- Chat functionality: chatInterface.js, conversationManager.js
- Main app entry point: app.js
- Complete typewriter styling: styles.css with Tailwind integration
- Proper index.html with CDN and module references
- FastAPI server with static file serving
- app.py +4 -0
- index.html +33 -187
- src/app.js +48 -0
- src/bookDataService.js +471 -0
- src/chatInterface.js +334 -0
- src/clozeGameEngine.js +540 -0
- src/conversationManager.js +237 -0
- src/styles.css +356 -0
app.py
CHANGED
@@ -1,8 +1,12 @@
|
|
1 |
from fastapi import FastAPI
|
2 |
from fastapi.responses import FileResponse
|
|
|
3 |
|
4 |
app = FastAPI()
|
5 |
|
|
|
|
|
|
|
6 |
@app.get("/")
|
7 |
async def read_root():
|
8 |
return FileResponse("index.html")
|
|
|
1 |
from fastapi import FastAPI
|
2 |
from fastapi.responses import FileResponse
|
3 |
+
from fastapi.staticfiles import StaticFiles
|
4 |
|
5 |
app = FastAPI()
|
6 |
|
7 |
+
# Mount static files
|
8 |
+
app.mount("/src", StaticFiles(directory="src"), name="src")
|
9 |
+
|
10 |
@app.get("/")
|
11 |
async def read_root():
|
12 |
return FileResponse("index.html")
|
index.html
CHANGED
@@ -3,209 +3,55 @@
|
|
3 |
<head>
|
4 |
<meta charset="UTF-8">
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
-
<title>Cloze Reader</title>
|
7 |
<script src="https://cdn.tailwindcss.com"></script>
|
8 |
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&display=swap" rel="stylesheet">
|
9 |
-
<
|
10 |
-
body { font-family: 'Special Elite', monospace; background: #faf7f0; }
|
11 |
-
.paper-sheet { background: #fefcf7; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
12 |
-
.cloze-input { border: none; border-bottom: 2px solid #000; background: transparent; text-align: center; font-family: inherit; }
|
13 |
-
.typewriter-button { background: #f5f1e8; border: 2px solid #000; font-family: inherit; padding: 8px 16px; cursor: pointer; }
|
14 |
-
.correct { background-color: rgba(16, 185, 129, 0.1); border-color: #10b981; }
|
15 |
-
.incorrect { background-color: rgba(239, 68, 68, 0.1); border-color: #ef4444; }
|
16 |
-
</style>
|
17 |
</head>
|
18 |
-
<body class="min-h-screen
|
19 |
-
<div class="max-w-4xl
|
20 |
<header class="text-center mb-8">
|
21 |
<div class="flex items-center justify-center gap-3 mb-2">
|
22 |
-
<
|
23 |
-
<h1 class="text-4xl font-bold">Cloze Reader</h1>
|
24 |
</div>
|
25 |
-
<p class="
|
26 |
</header>
|
27 |
|
28 |
-
<main id="game-container">
|
29 |
<div id="loading" class="text-center py-8">
|
30 |
-
<p class="text-lg">Loading passages...</p>
|
31 |
</div>
|
32 |
|
33 |
<div id="game-area" class="paper-sheet rounded-lg p-6 hidden">
|
34 |
-
<div
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
<div class="
|
40 |
-
<div id="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
</div>
|
42 |
-
<div class="flex gap-4 justify-center flex-wrap">
|
43 |
-
<button id="submit-btn" class="typewriter-button">Submit</button>
|
44 |
-
<button id="next-btn" class="typewriter-button hidden">Next Passage</button>
|
45 |
-
<button id="hint-btn" class="typewriter-button">Show Hints</button>
|
46 |
-
</div>
|
47 |
-
<div id="result" class="mt-4 text-center font-semibold"></div>
|
48 |
</div>
|
49 |
</main>
|
50 |
</div>
|
51 |
|
52 |
-
<script>
|
53 |
-
// Simple cloze reader implementation
|
54 |
-
class SimpleClozeReader {
|
55 |
-
constructor() {
|
56 |
-
this.currentLevel = 1;
|
57 |
-
this.currentRound = 1;
|
58 |
-
this.blanks = [];
|
59 |
-
this.hints = [];
|
60 |
-
this.books = [
|
61 |
-
{
|
62 |
-
title: "Pride and Prejudice",
|
63 |
-
author: "Jane Austen",
|
64 |
-
text: "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be on his first entering a neighbourhood, this truth is so well fixed in the minds of the surrounding families, that he is considered the rightful property of some one or other of their daughters."
|
65 |
-
},
|
66 |
-
{
|
67 |
-
title: "The Adventures of Tom Sawyer",
|
68 |
-
author: "Mark Twain",
|
69 |
-
text: "Tom! No answer. Tom! No answer. What's gone with that boy, I wonder? You TOM! No answer. The old lady pulled her spectacles down and looked over them about the room; then she put them up and looked out under them. She seldom or never looked through them for so small a thing as a boy."
|
70 |
-
}
|
71 |
-
];
|
72 |
-
this.init();
|
73 |
-
}
|
74 |
-
|
75 |
-
init() {
|
76 |
-
this.setupEventListeners();
|
77 |
-
this.startNewRound();
|
78 |
-
}
|
79 |
-
|
80 |
-
setupEventListeners() {
|
81 |
-
document.getElementById('submit-btn').onclick = () => this.submitAnswers();
|
82 |
-
document.getElementById('next-btn').onclick = () => this.nextRound();
|
83 |
-
document.getElementById('hint-btn').onclick = () => this.toggleHints();
|
84 |
-
}
|
85 |
-
|
86 |
-
startNewRound() {
|
87 |
-
const book = this.books[Math.floor(Math.random() * this.books.length)];
|
88 |
-
const blanksCount = this.currentLevel <= 2 ? 1 : this.currentLevel <= 4 ? 2 : 3;
|
89 |
-
|
90 |
-
// Simple word selection
|
91 |
-
const words = book.text.split(' ');
|
92 |
-
const selectedIndices = [];
|
93 |
-
const contentWords = ['truth', 'man', 'fortune', 'wife', 'feelings', 'neighbourhood', 'families', 'daughters', 'answer', 'boy', 'lady', 'spectacles', 'room'];
|
94 |
-
|
95 |
-
for (let i = 0; i < blanksCount && i < contentWords.length; i++) {
|
96 |
-
const wordIndex = words.findIndex(w => w.toLowerCase().includes(contentWords[i]));
|
97 |
-
if (wordIndex !== -1) selectedIndices.push(wordIndex);
|
98 |
-
}
|
99 |
-
|
100 |
-
// Create blanks
|
101 |
-
this.blanks = selectedIndices.map((index, i) => ({
|
102 |
-
index: i,
|
103 |
-
originalWord: words[index].replace(/[^\w]/g, ''),
|
104 |
-
wordIndex: index
|
105 |
-
}));
|
106 |
-
|
107 |
-
// Create hints
|
108 |
-
this.hints = this.blanks.map((blank, i) => {
|
109 |
-
const word = blank.originalWord;
|
110 |
-
if (this.currentLevel <= 2) {
|
111 |
-
return `${word.length} letters, starts with "${word[0]}", ends with "${word[word.length-1]}"`;
|
112 |
-
} else {
|
113 |
-
return `${word.length} letters, starts with "${word[0]}"`;
|
114 |
-
}
|
115 |
-
});
|
116 |
-
|
117 |
-
// Create display text
|
118 |
-
let displayText = book.text;
|
119 |
-
this.blanks.forEach((blank, i) => {
|
120 |
-
const word = words[blank.wordIndex];
|
121 |
-
const input = `<input type="text" class="cloze-input w-20 mx-1" data-index="${i}" placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}">`;
|
122 |
-
displayText = displayText.replace(word, input);
|
123 |
-
});
|
124 |
-
|
125 |
-
// Update UI
|
126 |
-
document.getElementById('book-info').innerHTML = `<strong>${book.title}</strong> by ${book.author}`;
|
127 |
-
document.getElementById('round-info').textContent = `Level ${this.currentLevel} • ${blanksCount} blank${blanksCount > 1 ? 's' : ''}`;
|
128 |
-
document.getElementById('contextualization').innerHTML = `📚 Practice with classic literature from ${book.author}'s "${book.title}"`;
|
129 |
-
document.getElementById('passage-content').innerHTML = displayText;
|
130 |
-
document.getElementById('hints-list').innerHTML = this.hints.map((hint, i) => `<div>${i+1}. ${hint}</div>`).join('');
|
131 |
-
|
132 |
-
// Show game area
|
133 |
-
document.getElementById('loading').classList.add('hidden');
|
134 |
-
document.getElementById('game-area').classList.remove('hidden');
|
135 |
-
document.getElementById('hints-section').classList.add('hidden');
|
136 |
-
document.getElementById('result').textContent = '';
|
137 |
-
document.getElementById('submit-btn').classList.remove('hidden');
|
138 |
-
document.getElementById('next-btn').classList.add('hidden');
|
139 |
-
}
|
140 |
-
|
141 |
-
submitAnswers() {
|
142 |
-
const inputs = document.querySelectorAll('.cloze-input');
|
143 |
-
let correct = 0;
|
144 |
-
|
145 |
-
inputs.forEach((input, i) => {
|
146 |
-
const userAnswer = input.value.trim().toLowerCase();
|
147 |
-
const correctAnswer = this.blanks[i].originalWord.toLowerCase();
|
148 |
-
|
149 |
-
if (userAnswer === correctAnswer) {
|
150 |
-
input.classList.add('correct');
|
151 |
-
correct++;
|
152 |
-
} else {
|
153 |
-
input.classList.add('incorrect');
|
154 |
-
// Show correct answer
|
155 |
-
const span = document.createElement('span');
|
156 |
-
span.className = 'text-green-600 font-semibold ml-2 text-sm';
|
157 |
-
span.textContent = `✓ ${this.blanks[i].originalWord}`;
|
158 |
-
input.parentNode.appendChild(span);
|
159 |
-
}
|
160 |
-
input.disabled = true;
|
161 |
-
});
|
162 |
-
|
163 |
-
const percentage = Math.round((correct / this.blanks.length) * 100);
|
164 |
-
const passed = correct >= (this.blanks.length === 1 ? 1 : this.blanks.length - 1);
|
165 |
-
|
166 |
-
let message = `Score: ${correct}/${this.blanks.length} (${percentage}%)`;
|
167 |
-
if (passed) {
|
168 |
-
message += ` - Excellent! Advancing to Level ${this.currentLevel + 1}! 🎉`;
|
169 |
-
document.getElementById('result').className = 'mt-4 text-center font-semibold text-green-600';
|
170 |
-
this.currentLevel++;
|
171 |
-
} else {
|
172 |
-
message += ` - Keep practicing! 💪`;
|
173 |
-
document.getElementById('result').className = 'mt-4 text-center font-semibold text-red-600';
|
174 |
-
}
|
175 |
-
|
176 |
-
document.getElementById('result').textContent = message;
|
177 |
-
document.getElementById('submit-btn').classList.add('hidden');
|
178 |
-
document.getElementById('next-btn').classList.remove('hidden');
|
179 |
-
this.currentRound++;
|
180 |
-
}
|
181 |
-
|
182 |
-
nextRound() {
|
183 |
-
document.querySelectorAll('.cloze-input').forEach(input => {
|
184 |
-
input.classList.remove('correct', 'incorrect');
|
185 |
-
input.disabled = false;
|
186 |
-
input.value = '';
|
187 |
-
});
|
188 |
-
document.querySelectorAll('.text-green-600').forEach(el => el.remove());
|
189 |
-
this.startNewRound();
|
190 |
-
}
|
191 |
-
|
192 |
-
toggleHints() {
|
193 |
-
const hintsSection = document.getElementById('hints-section');
|
194 |
-
const btn = document.getElementById('hint-btn');
|
195 |
-
if (hintsSection.classList.contains('hidden')) {
|
196 |
-
hintsSection.classList.remove('hidden');
|
197 |
-
btn.textContent = 'Hide Hints';
|
198 |
-
} else {
|
199 |
-
hintsSection.classList.add('hidden');
|
200 |
-
btn.textContent = 'Show Hints';
|
201 |
-
}
|
202 |
-
}
|
203 |
-
}
|
204 |
-
|
205 |
-
// Initialize when page loads
|
206 |
-
document.addEventListener('DOMContentLoaded', () => {
|
207 |
-
new SimpleClozeReader();
|
208 |
-
});
|
209 |
-
</script>
|
210 |
</body>
|
211 |
</html>
|
|
|
3 |
<head>
|
4 |
<meta charset="UTF-8">
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>Cloze Reader - Minimal</title>
|
7 |
<script src="https://cdn.tailwindcss.com"></script>
|
8 |
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&display=swap" rel="stylesheet">
|
9 |
+
<link href="./src/styles.css" rel="stylesheet">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
</head>
|
11 |
+
<body class="min-h-screen">
|
12 |
+
<div id="app" class="container mx-auto px-4 py-8 max-w-4xl">
|
13 |
<header class="text-center mb-8">
|
14 |
<div class="flex items-center justify-center gap-3 mb-2">
|
15 |
+
<img src="./icon.png" alt="Cloze Reader" class="w-12 h-12">
|
16 |
+
<h1 class="text-4xl font-bold typewriter-text">Cloze Reader</h1>
|
17 |
</div>
|
18 |
+
<p class="typewriter-subtitle">Fill in the blanks to practice reading comprehension</p>
|
19 |
</header>
|
20 |
|
21 |
+
<main id="game-container" class="space-y-6">
|
22 |
<div id="loading" class="text-center py-8">
|
23 |
+
<p class="text-lg loading-text">Loading passages...</p>
|
24 |
</div>
|
25 |
|
26 |
<div id="game-area" class="paper-sheet rounded-lg p-6 hidden">
|
27 |
+
<div class="paper-content">
|
28 |
+
<div class="flex justify-between items-center mb-4">
|
29 |
+
<div id="book-info" class="biblio-info"></div>
|
30 |
+
<div id="round-info" class="round-badge px-3 py-1 rounded-full text-sm"></div>
|
31 |
+
</div>
|
32 |
+
<div id="contextualization" class="context-box mb-4 p-3 rounded-lg"></div>
|
33 |
+
<div id="passage-content" class="prose max-w-none mb-6"></div>
|
34 |
+
<div id="hints-section" class="hints-box mb-4 p-3 rounded-lg">
|
35 |
+
<div class="text-sm font-semibold mb-2">💡 Hints:</div>
|
36 |
+
<div id="hints-list" class="text-sm space-y-1"></div>
|
37 |
+
</div>
|
38 |
+
<div id="controls" class="flex gap-4 justify-center flex-wrap">
|
39 |
+
<button type="button" id="submit-btn" class="typewriter-button">
|
40 |
+
Submit
|
41 |
+
</button>
|
42 |
+
<button type="button" id="next-btn" class="typewriter-button hidden">
|
43 |
+
Next Passage
|
44 |
+
</button>
|
45 |
+
<button type="button" id="hint-btn" class="typewriter-button">
|
46 |
+
Show Hints
|
47 |
+
</button>
|
48 |
+
</div>
|
49 |
+
<div id="result" class="mt-4 text-center result-text"></div>
|
50 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
51 |
</div>
|
52 |
</main>
|
53 |
</div>
|
54 |
|
55 |
+
<script src="./src/app.js" type="module"></script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
56 |
</body>
|
57 |
</html>
|
src/app.js
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Main application entry point
|
2 |
+
import { ClozeGameEngine } from './clozeGameEngine.js';
|
3 |
+
import { BookDataService } from './bookDataService.js';
|
4 |
+
import { AIService } from './aiService.js';
|
5 |
+
import { ChatInterface } from './chatInterface.js';
|
6 |
+
import { ConversationManager } from './conversationManager.js';
|
7 |
+
|
8 |
+
class ClozeReaderApp {
|
9 |
+
constructor() {
|
10 |
+
this.bookDataService = new BookDataService();
|
11 |
+
this.aiService = new AIService();
|
12 |
+
this.gameEngine = new ClozeGameEngine(this.bookDataService, this.aiService);
|
13 |
+
this.chatInterface = new ChatInterface(this.aiService);
|
14 |
+
this.conversationManager = new ConversationManager(this.aiService);
|
15 |
+
|
16 |
+
this.init();
|
17 |
+
}
|
18 |
+
|
19 |
+
async init() {
|
20 |
+
try {
|
21 |
+
// Initialize services
|
22 |
+
await this.bookDataService.initialize();
|
23 |
+
|
24 |
+
// Start the game
|
25 |
+
await this.gameEngine.initialize();
|
26 |
+
|
27 |
+
// Initialize chat interface
|
28 |
+
this.chatInterface.initialize();
|
29 |
+
|
30 |
+
console.log('Cloze Reader application initialized successfully');
|
31 |
+
} catch (error) {
|
32 |
+
console.error('Failed to initialize application:', error);
|
33 |
+
this.showError('Failed to load the application. Please refresh the page.');
|
34 |
+
}
|
35 |
+
}
|
36 |
+
|
37 |
+
showError(message) {
|
38 |
+
const loadingElement = document.getElementById('loading');
|
39 |
+
if (loadingElement) {
|
40 |
+
loadingElement.innerHTML = `<p class="text-red-600">${message}</p>`;
|
41 |
+
}
|
42 |
+
}
|
43 |
+
}
|
44 |
+
|
45 |
+
// Initialize the application when DOM is loaded
|
46 |
+
document.addEventListener('DOMContentLoaded', () => {
|
47 |
+
new ClozeReaderApp();
|
48 |
+
});
|
src/bookDataService.js
ADDED
@@ -0,0 +1,471 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Hugging Face Project Gutenberg Dataset Service
|
2 |
+
class HuggingFaceDatasetService {
|
3 |
+
constructor() {
|
4 |
+
// Use Hugging Face Datasets API for streaming
|
5 |
+
this.datasetName = 'manu/project_gutenberg';
|
6 |
+
this.apiBase = 'https://datasets-server.huggingface.co';
|
7 |
+
this.books = [];
|
8 |
+
this.isLoaded = false;
|
9 |
+
this.streamingEnabled = false;
|
10 |
+
this.cache = new Map();
|
11 |
+
this.preloadedBooks = [];
|
12 |
+
}
|
13 |
+
|
14 |
+
// Local fallback books for when HF streaming is unavailable
|
15 |
+
getSampleBooks() {
|
16 |
+
return [
|
17 |
+
{
|
18 |
+
id: 1,
|
19 |
+
title: "Pride and Prejudice",
|
20 |
+
author: "Jane Austen",
|
21 |
+
text: "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be on his first entering a neighbourhood, this truth is so well fixed in the minds of the surrounding families, that he is considered the rightful property of some one or other of their daughters. \"My dear Mr. Bennet,\" said his lady to him one day, \"have you heard that Netherfield Park is let at last?\" Mr. Bennet replied that he had not. \"But it is,\" returned she; \"for Mrs. Long has just been here, and she told me all about it.\" Mr. Bennet made no answer. \"Do you not want to know who has taken it?\" cried his wife impatiently. \"You want to tell me, and I have no objection to hearing it.\" This was invitation enough."
|
22 |
+
},
|
23 |
+
{
|
24 |
+
id: 2,
|
25 |
+
title: "The Adventures of Tom Sawyer",
|
26 |
+
author: "Mark Twain",
|
27 |
+
text: "\"Tom!\" No answer. \"Tom!\" No answer. \"What's gone with that boy, I wonder? You TOM!\" No answer. The old lady pulled her spectacles down and looked over them about the room; then she put them up and looked out under them. She seldom or never looked through them for so small a thing as a boy; they were her state pair, the pride of her heart, and were built for \"style,\" not service--she could have seen through a pair of stove-lids just as well. She looked perplexed for a moment, and then said, not fiercely, but still loud enough for the furniture to hear: \"Well, I lay if I get hold of you I'll--\""
|
28 |
+
},
|
29 |
+
{
|
30 |
+
id: 3,
|
31 |
+
title: "Great Expectations",
|
32 |
+
author: "Charles Dickens",
|
33 |
+
text: "My father's family name being Pirrip, and my Christian name Philip, my infant tongue could make of both names nothing longer or more explicit than Pip. So, I called myself Pip, and came to be called Pip. I give Pirrip as my father's family name, on the authority of his tombstone and my sister,--Mrs. Joe Gargery, who married the blacksmith. As I never saw my father or my mother, and never saw any likeness of them (for their days were long before the days of photographs), my first fancies regarding what they were like were unreasonably derived from their tombstones."
|
34 |
+
},
|
35 |
+
{
|
36 |
+
id: 4,
|
37 |
+
title: "Alice's Adventures in Wonderland",
|
38 |
+
author: "Lewis Carroll",
|
39 |
+
text: "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, 'and what is the use of a book,' thought Alice 'without pictures or conversation?' So she was considering in her own mind (as well as she could, for the hot day made her feel very sleepy and stupid), whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies, when suddenly a White Rabbit with pink eyes ran close by her."
|
40 |
+
},
|
41 |
+
{
|
42 |
+
id: 5,
|
43 |
+
title: "The Picture of Dorian Gray",
|
44 |
+
author: "Oscar Wilde",
|
45 |
+
text: "The studio was filled with the rich odour of roses, and when the strong summer wind stirred, amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn. From the corner of the divan of Persian saddle-bags on which he was lying, smoking, as was his custom, innumerable cigarettes, Lord Henry Wotton could just catch the gleam of the honey-sweet and honey-coloured blossoms of a laburnum, whose tremulous branches seemed hardly able to bear the burden of a beauty so flamelike as theirs."
|
46 |
+
},
|
47 |
+
{
|
48 |
+
id: 6,
|
49 |
+
title: "Moby Dick",
|
50 |
+
author: "Herman Melville",
|
51 |
+
text: "Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world. It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul; whenever I find myself involuntarily pausing before coffin warehouses, and bringing up the rear of every funeral I meet; and especially whenever my hypos get such an upper hand of me, that it requires a strong moral principle to prevent me from deliberately stepping into the street, and methodically knocking people's hats off—then, I account it high time to get to sea as soon as possible."
|
52 |
+
},
|
53 |
+
{
|
54 |
+
id: 7,
|
55 |
+
title: "Jane Eyre",
|
56 |
+
author: "Charlotte Bronte",
|
57 |
+
text: "There was no possibility of taking a walk that day. We had been wandering, indeed, in the leafless shrubbery an hour in the morning; but since dinner (Mrs. Reed, when there was no company, dined early) the cold winter wind had brought with it clouds so sombre, and a rain so penetrating, that further out-door exercise was now out of the question. I was glad of it: I never liked long walks, especially on chilly afternoons: dreadful to me was the coming home in the raw twilight, with nipped fingers and toes, and a heart saddened by the chidings of Bessie, the nurse, and humbled by the consciousness of my physical inferiority to Eliza, John, and Georgiana Reed."
|
58 |
+
},
|
59 |
+
{
|
60 |
+
id: 8,
|
61 |
+
title: "The Count of Monte Cristo",
|
62 |
+
author: "Alexandre Dumas",
|
63 |
+
text: "On the first Monday of February, 1815, the watchtower at Marseilles signaled the arrival of the three-master Pharaon from Smyrna, Trieste, and Naples. As was customary, the pilot immediately left the port and steered toward the château d'If to conduct the ship through the narrow passage that leads to the harbor. However, a young sailor of about nineteen or twenty years, standing on the ship's bow, had signaled the pilot even before he had time to ask the traditional questions that are exchanged between the pilot and the captain. The young man had already assumed command, being the ship's owner and captain."
|
64 |
+
},
|
65 |
+
{
|
66 |
+
id: 9,
|
67 |
+
title: "Wuthering Heights",
|
68 |
+
author: "Emily Bronte",
|
69 |
+
text: "I have just returned from a visit to my landlord—the solitary neighbour that I shall be troubled with. This is certainly a beautiful country! In all England, I do not believe that I could have fixed on a situation so completely removed from the stir of society. A perfect misanthropist's Heaven: and Mr. Heathcliff and I are such a suitable pair to divide the desolation between us. A capital fellow! He little imagined how my heart warmed towards him when I beheld his black eyes withdraw so suspiciously under their brows, as I rode up, and when his fingers sheltered themselves, with a jealous resolution, still further in his waistcoat, as I announced my name."
|
70 |
+
},
|
71 |
+
{
|
72 |
+
id: 10,
|
73 |
+
title: "Frankenstein",
|
74 |
+
author: "Mary Shelley",
|
75 |
+
text: "It was on a dreary night of November that I beheld the accomplishment of my toils. With an anxiety that almost amounted to agony, I collected the instruments of life around me, that I might infuse a spark of being into the lifeless thing that lay at my feet. It was already one in the morning; the rain pattered dismally against the panes, and my candle was nearly burnt out, when, by the glimmer of the half-extinguished light, I saw the dull yellow eye of the creature open; it breathed hard, and a convulsive motion agitated its limbs. How can I describe my emotions at this catastrophe, or how delineate the wretch whom with such infinite pains and care I had endeavoured to form?"
|
76 |
+
}
|
77 |
+
];
|
78 |
+
}
|
79 |
+
|
80 |
+
async loadDataset() {
|
81 |
+
try {
|
82 |
+
// Try to connect to HF Datasets API
|
83 |
+
await this.initializeStreaming();
|
84 |
+
|
85 |
+
if (this.streamingEnabled) {
|
86 |
+
// Preload some books for immediate access
|
87 |
+
await this.preloadBooks(100);
|
88 |
+
console.log(`✅ HF Streaming enabled: ${this.preloadedBooks.length} books preloaded`);
|
89 |
+
} else {
|
90 |
+
// Fall back to local samples
|
91 |
+
this.books = this.getSampleBooks();
|
92 |
+
console.log(`⚠️ Using local samples: ${this.books.length} books available`);
|
93 |
+
}
|
94 |
+
|
95 |
+
this.isLoaded = true;
|
96 |
+
return this.books;
|
97 |
+
} catch (error) {
|
98 |
+
console.error('Error loading dataset:', error);
|
99 |
+
// Ensure we always have local fallback
|
100 |
+
this.books = this.getSampleBooks();
|
101 |
+
this.isLoaded = true;
|
102 |
+
return this.books;
|
103 |
+
}
|
104 |
+
}
|
105 |
+
|
106 |
+
async initializeStreaming() {
|
107 |
+
try {
|
108 |
+
// Test HF Datasets API availability
|
109 |
+
const testUrl = `${this.apiBase}/splits?dataset=${this.datasetName}`;
|
110 |
+
const response = await fetch(testUrl);
|
111 |
+
|
112 |
+
if (response.ok) {
|
113 |
+
const data = await response.json();
|
114 |
+
// Check if English split is available
|
115 |
+
const hasEnglish = data.splits?.some(split =>
|
116 |
+
split.split === 'en' && split.config === 'default'
|
117 |
+
);
|
118 |
+
|
119 |
+
this.streamingEnabled = hasEnglish || data.splits?.length > 0;
|
120 |
+
console.log(`🔗 HF Datasets API: ${this.streamingEnabled ? 'Available' : 'Unavailable'}`);
|
121 |
+
}
|
122 |
+
} catch (error) {
|
123 |
+
console.warn('HF Datasets API test failed:', error);
|
124 |
+
this.streamingEnabled = false;
|
125 |
+
}
|
126 |
+
}
|
127 |
+
|
128 |
+
async preloadBooks(count = 100) {
|
129 |
+
if (!this.streamingEnabled) return;
|
130 |
+
|
131 |
+
try {
|
132 |
+
// Fetch a batch of books from HF Datasets (correct API format)
|
133 |
+
const url = `${this.apiBase}/rows?dataset=${this.datasetName}&config=default&split=en&offset=0&length=${count}`;
|
134 |
+
const response = await fetch(url);
|
135 |
+
|
136 |
+
if (response.ok) {
|
137 |
+
const data = await response.json();
|
138 |
+
|
139 |
+
// Process and filter books
|
140 |
+
this.preloadedBooks = data.rows
|
141 |
+
.map(row => this.processHFBook(row.row))
|
142 |
+
.filter(book => this.isValidForCloze(book));
|
143 |
+
|
144 |
+
console.log(`📚 Preloaded ${this.preloadedBooks.length} suitable books`);
|
145 |
+
}
|
146 |
+
} catch (error) {
|
147 |
+
console.warn('Failed to preload books:', error);
|
148 |
+
}
|
149 |
+
}
|
150 |
+
|
151 |
+
processHFBook(rowData) {
|
152 |
+
// Extract and clean book data from HF format
|
153 |
+
const originalText = rowData.text || '';
|
154 |
+
const cleanedText = this.cleanProjectGutenbergText(originalText);
|
155 |
+
|
156 |
+
// Try multiple metadata extraction approaches
|
157 |
+
const extractedMetadata = this.extractMetadata(originalText);
|
158 |
+
|
159 |
+
// Use HF dataset fields if available, otherwise use extracted metadata
|
160 |
+
const title = rowData.title || extractedMetadata.title || 'Classic Literature';
|
161 |
+
const author = rowData.author || extractedMetadata.author || 'Unknown Author';
|
162 |
+
|
163 |
+
return {
|
164 |
+
id: rowData.id || Math.random().toString(36),
|
165 |
+
title: title,
|
166 |
+
author: author,
|
167 |
+
text: cleanedText,
|
168 |
+
language: rowData.language || 'en',
|
169 |
+
source: 'project_gutenberg'
|
170 |
+
};
|
171 |
+
}
|
172 |
+
|
173 |
+
cleanProjectGutenbergText(text) {
|
174 |
+
if (!text) return '';
|
175 |
+
|
176 |
+
let cleaned = text;
|
177 |
+
|
178 |
+
// Remove Project Gutenberg start markers and everything before
|
179 |
+
const startPatterns = [
|
180 |
+
/\*\*\* START OF .*? \*\*\*/i,
|
181 |
+
/\*\*\*START OF .*?\*\*\*/i,
|
182 |
+
/START OF THE PROJECT GUTENBERG/i,
|
183 |
+
/GUTENBERG.*?EBOOK/i
|
184 |
+
];
|
185 |
+
|
186 |
+
for (const pattern of startPatterns) {
|
187 |
+
const match = cleaned.match(pattern);
|
188 |
+
if (match) {
|
189 |
+
const startIndex = match.index + match[0].length;
|
190 |
+
// Skip to next line
|
191 |
+
const nextLine = cleaned.indexOf('\n', startIndex);
|
192 |
+
if (nextLine !== -1) {
|
193 |
+
cleaned = cleaned.substring(nextLine + 1);
|
194 |
+
}
|
195 |
+
break;
|
196 |
+
}
|
197 |
+
}
|
198 |
+
|
199 |
+
// Remove Project Gutenberg end markers and everything after
|
200 |
+
const endPatterns = [
|
201 |
+
/\*\*\* END OF .*? \*\*\*/i,
|
202 |
+
/\*\*\*END OF .*?\*\*\*/i,
|
203 |
+
/END OF THE PROJECT GUTENBERG/i,
|
204 |
+
/End of the Project Gutenberg/i
|
205 |
+
];
|
206 |
+
|
207 |
+
for (const pattern of endPatterns) {
|
208 |
+
const match = cleaned.match(pattern);
|
209 |
+
if (match) {
|
210 |
+
cleaned = cleaned.substring(0, match.index);
|
211 |
+
break;
|
212 |
+
}
|
213 |
+
}
|
214 |
+
|
215 |
+
// Remove common Project Gutenberg artifacts
|
216 |
+
cleaned = cleaned
|
217 |
+
.replace(/\r\n/g, '\n') // Normalize line endings
|
218 |
+
.replace(/produced from images generously.*?\n/gi, '') // Remove scanning notes
|
219 |
+
.replace(/\n\s*\n\s*\n+/g, '\n\n') // Remove excessive line breaks
|
220 |
+
.replace(/^\s*CHAPTER.*$/gm, '') // Remove chapter headers
|
221 |
+
.replace(/^\s*Chapter.*$/gm, '') // Remove chapter headers
|
222 |
+
.replace(/^\s*\d+\s*$/gm, '') // Remove page numbers
|
223 |
+
.replace(/^\s*\[.*?\]\s*$/gm, '') // Remove bracketed notes
|
224 |
+
.replace(/^\s*_.*_\s*$/gm, '') // Remove italic notes
|
225 |
+
.replace(/[_*]/g, '') // Remove underscores and asterisks
|
226 |
+
.trim();
|
227 |
+
|
228 |
+
// Find the actual start of narrative content
|
229 |
+
const lines = cleaned.split('\n');
|
230 |
+
let contentStart = 0;
|
231 |
+
|
232 |
+
for (let i = 0; i < Math.min(50, lines.length); i++) {
|
233 |
+
const line = lines[i].trim();
|
234 |
+
|
235 |
+
// Skip empty lines, title pages, and metadata
|
236 |
+
if (!line ||
|
237 |
+
line.includes('Title:') ||
|
238 |
+
line.includes('Author:') ||
|
239 |
+
line.includes('Release Date:') ||
|
240 |
+
line.includes('Language:') ||
|
241 |
+
line.includes('Character set') ||
|
242 |
+
line.includes('www.gutenberg') ||
|
243 |
+
line.includes('Project Gutenberg') ||
|
244 |
+
line.length < 20) {
|
245 |
+
contentStart = i + 1;
|
246 |
+
continue;
|
247 |
+
}
|
248 |
+
|
249 |
+
// Found actual content
|
250 |
+
break;
|
251 |
+
}
|
252 |
+
|
253 |
+
if (contentStart > 0 && contentStart < lines.length) {
|
254 |
+
cleaned = lines.slice(contentStart).join('\n').trim();
|
255 |
+
}
|
256 |
+
|
257 |
+
return cleaned;
|
258 |
+
}
|
259 |
+
|
260 |
+
extractMetadata(text) {
|
261 |
+
const metadata = { title: 'Classic Literature', author: 'Unknown Author' };
|
262 |
+
|
263 |
+
if (!text) return metadata;
|
264 |
+
|
265 |
+
// Look for the standard Project Gutenberg header format
|
266 |
+
const firstLine = text.split('\n')[0].trim();
|
267 |
+
|
268 |
+
// Parse the standard format: "The Project Gutenberg EBook of [TITLE], by [AUTHOR]"
|
269 |
+
const pgMatch = firstLine.match(/^.*?The Project Gutenberg EBook of (.+?),\s*by\s+(.+?)$/i);
|
270 |
+
if (pgMatch) {
|
271 |
+
const title = pgMatch[1].trim();
|
272 |
+
const author = pgMatch[2].trim();
|
273 |
+
|
274 |
+
if (title && this.isValidTitle(title)) {
|
275 |
+
metadata.title = this.cleanMetadataField(title);
|
276 |
+
}
|
277 |
+
if (author && this.isValidAuthor(author)) {
|
278 |
+
metadata.author = this.cleanMetadataField(author);
|
279 |
+
}
|
280 |
+
|
281 |
+
return metadata;
|
282 |
+
}
|
283 |
+
|
284 |
+
// Fallback: Look for explicit Title: and Author: fields in first 50 lines
|
285 |
+
const lines = text.split('\n').slice(0, 50);
|
286 |
+
|
287 |
+
for (let i = 0; i < lines.length; i++) {
|
288 |
+
const line = lines[i].trim();
|
289 |
+
|
290 |
+
if (line.startsWith('Title:')) {
|
291 |
+
const title = line.replace('Title:', '').trim();
|
292 |
+
if (title && title.length > 1) {
|
293 |
+
metadata.title = this.cleanMetadataField(title);
|
294 |
+
}
|
295 |
+
} else if (line.startsWith('Author:')) {
|
296 |
+
const author = line.replace('Author:', '').trim();
|
297 |
+
if (author && author.length > 1) {
|
298 |
+
metadata.author = this.cleanMetadataField(author);
|
299 |
+
}
|
300 |
+
}
|
301 |
+
}
|
302 |
+
|
303 |
+
return metadata;
|
304 |
+
}
|
305 |
+
|
306 |
+
cleanMetadataField(field) {
|
307 |
+
return field
|
308 |
+
.replace(/\[.*?\]/g, '') // Remove bracketed info
|
309 |
+
.replace(/\s+/g, ' ') // Normalize whitespace
|
310 |
+
.trim();
|
311 |
+
}
|
312 |
+
|
313 |
+
isValidTitle(title) {
|
314 |
+
if (!title || title.length < 3 || title.length > 100) return false;
|
315 |
+
// Avoid fragments that are clearly not titles
|
316 |
+
if (title.includes('Project Gutenberg') ||
|
317 |
+
title.includes('www.') ||
|
318 |
+
title.includes('produced from') ||
|
319 |
+
title.includes('images generously')) return false;
|
320 |
+
return true;
|
321 |
+
}
|
322 |
+
|
323 |
+
isValidAuthor(author) {
|
324 |
+
if (!author || author.length < 3 || author.length > 50) return false;
|
325 |
+
// Basic validation - should look like a name
|
326 |
+
if (author.includes('Project Gutenberg') ||
|
327 |
+
author.includes('www.') ||
|
328 |
+
author.includes('produced from')) return false;
|
329 |
+
return true;
|
330 |
+
}
|
331 |
+
|
332 |
+
isValidForCloze(book) {
|
333 |
+
if (!book.text) return false;
|
334 |
+
|
335 |
+
const textLength = book.text.length;
|
336 |
+
|
337 |
+
// Filter criteria for cloze exercises
|
338 |
+
if (textLength < 5000) return false; // Too short
|
339 |
+
if (textLength > 500000) return false; // Too long for performance
|
340 |
+
|
341 |
+
// Check for excessive formatting (likely reference material)
|
342 |
+
const lineBreakRatio = (book.text.match(/\n\n/g) || []).length / textLength;
|
343 |
+
if (lineBreakRatio > 0.01) return false; // Too fragmented
|
344 |
+
|
345 |
+
// Ensure it has actual narrative content
|
346 |
+
const sentenceCount = (book.text.match(/[.!?]+/g) || []).length;
|
347 |
+
if (sentenceCount < 20) return false; // Too few sentences
|
348 |
+
|
349 |
+
return true;
|
350 |
+
}
|
351 |
+
|
352 |
+
async getRandomBook() {
|
353 |
+
if (!this.isLoaded) {
|
354 |
+
throw new Error('Dataset not loaded');
|
355 |
+
}
|
356 |
+
|
357 |
+
// Prioritize preloaded books for fast access (90% chance)
|
358 |
+
if (this.streamingEnabled && this.preloadedBooks.length > 0 && Math.random() > 0.1) {
|
359 |
+
const randomIndex = Math.floor(Math.random() * this.preloadedBooks.length);
|
360 |
+
return this.preloadedBooks[randomIndex];
|
361 |
+
}
|
362 |
+
|
363 |
+
// Use local samples for remaining 10% + fallback
|
364 |
+
const fallbackBooks = this.books.length > 0 ? this.books : this.getSampleBooks();
|
365 |
+
const randomIndex = Math.floor(Math.random() * fallbackBooks.length);
|
366 |
+
return fallbackBooks[randomIndex];
|
367 |
+
}
|
368 |
+
|
369 |
+
async getStreamingBook() {
|
370 |
+
// Use preloaded books for immediate access
|
371 |
+
if (this.preloadedBooks.length > 0) {
|
372 |
+
const randomIndex = Math.floor(Math.random() * this.preloadedBooks.length);
|
373 |
+
return this.preloadedBooks[randomIndex];
|
374 |
+
}
|
375 |
+
|
376 |
+
// If no preloaded books, try to fetch directly
|
377 |
+
try {
|
378 |
+
const offset = Math.floor(Math.random() * 1000); // Random offset
|
379 |
+
const url = `${this.apiBase}/rows?dataset=${this.datasetName}&config=default&split=en&offset=${offset}&length=1`;
|
380 |
+
const response = await fetch(url);
|
381 |
+
|
382 |
+
if (response.ok) {
|
383 |
+
const data = await response.json();
|
384 |
+
if (data.rows && data.rows.length > 0) {
|
385 |
+
const book = this.processHFBook(data.rows[0].row);
|
386 |
+
if (this.isValidForCloze(book)) {
|
387 |
+
return book;
|
388 |
+
}
|
389 |
+
}
|
390 |
+
}
|
391 |
+
} catch (error) {
|
392 |
+
console.warn('Direct streaming failed:', error);
|
393 |
+
}
|
394 |
+
|
395 |
+
return null;
|
396 |
+
}
|
397 |
+
|
398 |
+
async getBooksByDifficulty(level) {
|
399 |
+
const difficultyRanges = {
|
400 |
+
1: { min: 5000, max: 30000 }, // Short stories/novellas
|
401 |
+
2: { min: 25000, max: 80000 }, // Medium novels
|
402 |
+
3: { min: 60000, max: 200000 } // Long novels
|
403 |
+
};
|
404 |
+
|
405 |
+
const range = difficultyRanges[Math.min(level, 3)];
|
406 |
+
|
407 |
+
if (this.streamingEnabled && this.preloadedBooks.length > 0) {
|
408 |
+
// Filter preloaded books by difficulty
|
409 |
+
const suitable = this.preloadedBooks.filter(book =>
|
410 |
+
book.text.length >= range.min && book.text.length <= range.max
|
411 |
+
);
|
412 |
+
|
413 |
+
if (suitable.length > 0) {
|
414 |
+
const randomIndex = Math.floor(Math.random() * suitable.length);
|
415 |
+
return suitable[randomIndex];
|
416 |
+
}
|
417 |
+
}
|
418 |
+
|
419 |
+
// Fallback to local filtering
|
420 |
+
const fallbackBooks = this.books.length > 0 ? this.books : this.getSampleBooks();
|
421 |
+
const filtered = fallbackBooks.filter(book =>
|
422 |
+
book.text.length >= range.min && book.text.length <= range.max
|
423 |
+
);
|
424 |
+
|
425 |
+
if (filtered.length > 0) {
|
426 |
+
const randomIndex = Math.floor(Math.random() * filtered.length);
|
427 |
+
return filtered[randomIndex];
|
428 |
+
}
|
429 |
+
|
430 |
+
// If no books match difficulty, return any available book
|
431 |
+
return await this.getRandomBook();
|
432 |
+
}
|
433 |
+
|
434 |
+
getBookById(id) {
|
435 |
+
// Search in both preloaded and local books
|
436 |
+
const allBooks = [...this.preloadedBooks, ...this.books];
|
437 |
+
return allBooks.find(book => book.id === id);
|
438 |
+
}
|
439 |
+
|
440 |
+
searchBooks(query) {
|
441 |
+
if (!query) return [...this.preloadedBooks, ...this.books];
|
442 |
+
|
443 |
+
const lowerQuery = query.toLowerCase();
|
444 |
+
const allBooks = [...this.preloadedBooks, ...this.books];
|
445 |
+
return allBooks.filter(book =>
|
446 |
+
book.title.toLowerCase().includes(lowerQuery) ||
|
447 |
+
book.author.toLowerCase().includes(lowerQuery)
|
448 |
+
);
|
449 |
+
}
|
450 |
+
|
451 |
+
// Health check for streaming status
|
452 |
+
getStatus() {
|
453 |
+
return {
|
454 |
+
streamingEnabled: this.streamingEnabled,
|
455 |
+
preloadedBooks: this.preloadedBooks.length,
|
456 |
+
localBooks: this.books.length,
|
457 |
+
totalAvailable: this.preloadedBooks.length + this.books.length,
|
458 |
+
source: this.streamingEnabled ? 'HuggingFace Datasets' : 'Local Samples'
|
459 |
+
};
|
460 |
+
}
|
461 |
+
|
462 |
+
// Refresh preloaded books cache
|
463 |
+
async refreshCache() {
|
464 |
+
if (this.streamingEnabled) {
|
465 |
+
await this.preloadBooks(100);
|
466 |
+
console.log(`🔄 Cache refreshed: ${this.preloadedBooks.length} books`);
|
467 |
+
}
|
468 |
+
}
|
469 |
+
}
|
470 |
+
|
471 |
+
export default new HuggingFaceDatasetService();
|
src/chatInterface.js
ADDED
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Chat UI components for contextual hints
|
2 |
+
class ChatUI {
|
3 |
+
constructor(gameLogic) {
|
4 |
+
this.game = gameLogic;
|
5 |
+
this.activeChatBlank = null;
|
6 |
+
this.chatModal = null;
|
7 |
+
this.isOpen = false;
|
8 |
+
this.messageHistory = new Map(); // blankId -> array of messages for persistent history
|
9 |
+
this.setupChatModal();
|
10 |
+
}
|
11 |
+
|
12 |
+
// Create and setup chat modal
|
13 |
+
setupChatModal() {
|
14 |
+
// Create modal HTML
|
15 |
+
const modalHTML = `
|
16 |
+
<div id="chat-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center">
|
17 |
+
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[80vh] flex flex-col">
|
18 |
+
<!-- Header -->
|
19 |
+
<div class="flex items-center justify-between p-4 border-b">
|
20 |
+
<h3 id="chat-title" class="text-lg font-semibold text-gray-900">
|
21 |
+
Chat about Word #1
|
22 |
+
</h3>
|
23 |
+
<button id="chat-close" class="text-gray-400 hover:text-gray-600">
|
24 |
+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
25 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
26 |
+
</svg>
|
27 |
+
</button>
|
28 |
+
</div>
|
29 |
+
|
30 |
+
<!-- Chat messages area -->
|
31 |
+
<div id="chat-messages" class="flex-1 overflow-y-auto p-4 min-h-[200px] max-h-[400px]">
|
32 |
+
<div class="text-center text-gray-500 text-sm">
|
33 |
+
Ask me anything about this word! I can help with meaning, context, grammar, or give you hints.
|
34 |
+
</div>
|
35 |
+
</div>
|
36 |
+
|
37 |
+
<!-- Suggested questions -->
|
38 |
+
<div id="suggested-questions" class="px-4 py-2 border-t border-gray-100">
|
39 |
+
<div id="suggestion-buttons" class="flex flex-wrap gap-1">
|
40 |
+
<!-- Suggestion buttons will be inserted here -->
|
41 |
+
</div>
|
42 |
+
</div>
|
43 |
+
|
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>
|
51 |
+
</div>
|
52 |
+
</div>
|
53 |
+
`;
|
54 |
+
|
55 |
+
// Insert modal into page
|
56 |
+
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
57 |
+
|
58 |
+
this.chatModal = document.getElementById('chat-modal');
|
59 |
+
this.setupEventListeners();
|
60 |
+
}
|
61 |
+
|
62 |
+
// Setup event listeners for chat modal
|
63 |
+
setupEventListeners() {
|
64 |
+
const closeBtn = document.getElementById('chat-close');
|
65 |
+
|
66 |
+
// Close modal
|
67 |
+
closeBtn.addEventListener('click', () => this.closeChat());
|
68 |
+
this.chatModal.addEventListener('click', (e) => {
|
69 |
+
if (e.target === this.chatModal) this.closeChat();
|
70 |
+
});
|
71 |
+
|
72 |
+
// ESC key to close
|
73 |
+
document.addEventListener('keydown', (e) => {
|
74 |
+
if (e.key === 'Escape' && this.isOpen) this.closeChat();
|
75 |
+
});
|
76 |
+
}
|
77 |
+
|
78 |
+
// Open chat for specific blank
|
79 |
+
async openChat(blankIndex) {
|
80 |
+
this.activeChatBlank = blankIndex;
|
81 |
+
this.isOpen = true;
|
82 |
+
|
83 |
+
// Update title
|
84 |
+
const title = document.getElementById('chat-title');
|
85 |
+
title.textContent = `Help with Word #${blankIndex + 1}`;
|
86 |
+
|
87 |
+
// Restore previous messages or show intro
|
88 |
+
this.restoreMessages(blankIndex);
|
89 |
+
|
90 |
+
// Load question buttons
|
91 |
+
this.loadQuestionButtons();
|
92 |
+
|
93 |
+
// Show modal
|
94 |
+
this.chatModal.classList.remove('hidden');
|
95 |
+
}
|
96 |
+
|
97 |
+
// Close chat modal
|
98 |
+
closeChat() {
|
99 |
+
this.isOpen = false;
|
100 |
+
this.chatModal.classList.add('hidden');
|
101 |
+
this.activeChatBlank = null;
|
102 |
+
}
|
103 |
+
|
104 |
+
// Clear messages and show intro
|
105 |
+
clearMessages() {
|
106 |
+
const messagesContainer = document.getElementById('chat-messages');
|
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 |
+
}
|
113 |
+
|
114 |
+
// Restore messages for a specific blank or show intro
|
115 |
+
restoreMessages(blankIndex) {
|
116 |
+
const messagesContainer = document.getElementById('chat-messages');
|
117 |
+
const blankId = `blank_${blankIndex}`;
|
118 |
+
const history = this.messageHistory.get(blankId);
|
119 |
+
|
120 |
+
if (history && history.length > 0) {
|
121 |
+
// Restore previous messages
|
122 |
+
messagesContainer.innerHTML = '';
|
123 |
+
history.forEach(msg => {
|
124 |
+
this.displayMessage(msg.sender, msg.content, msg.isUser);
|
125 |
+
});
|
126 |
+
} else {
|
127 |
+
// Show intro for new conversation
|
128 |
+
this.clearMessages();
|
129 |
+
}
|
130 |
+
}
|
131 |
+
|
132 |
+
// Display a message without storing it (used for restoration)
|
133 |
+
displayMessage(sender, content, isUser) {
|
134 |
+
const messagesContainer = document.getElementById('chat-messages');
|
135 |
+
const alignment = isUser ? 'flex justify-end' : 'flex justify-start';
|
136 |
+
const messageClass = isUser
|
137 |
+
? 'bg-blue-500 text-white'
|
138 |
+
: 'bg-gray-100 text-gray-900';
|
139 |
+
const displaySender = isUser ? 'You' : sender;
|
140 |
+
|
141 |
+
const messageHTML = `
|
142 |
+
<div class="mb-3 ${alignment}">
|
143 |
+
<div class="${messageClass} rounded-lg px-3 py-2 max-w-[80%]">
|
144 |
+
<div class="text-xs font-medium mb-1">${displaySender}</div>
|
145 |
+
<div class="text-sm">${this.escapeHtml(content)}</div>
|
146 |
+
</div>
|
147 |
+
</div>
|
148 |
+
`;
|
149 |
+
|
150 |
+
messagesContainer.insertAdjacentHTML('beforeend', messageHTML);
|
151 |
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
152 |
+
}
|
153 |
+
|
154 |
+
// Clear all chat history (called when round ends)
|
155 |
+
clearChatHistory() {
|
156 |
+
this.messageHistory.clear();
|
157 |
+
}
|
158 |
+
|
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) {
|
186 |
+
e.preventDefault();
|
187 |
+
e.stopPropagation();
|
188 |
+
const questionType = btn.dataset.type;
|
189 |
+
this.askQuestion(questionType);
|
190 |
+
}
|
191 |
+
});
|
192 |
+
});
|
193 |
+
}
|
194 |
+
|
195 |
+
// Ask a specific question
|
196 |
+
async askQuestion(questionType) {
|
197 |
+
if (this.activeChatBlank === null) return;
|
198 |
+
|
199 |
+
// Get current user input for the blank
|
200 |
+
const currentInput = this.getCurrentBlankInput();
|
201 |
+
|
202 |
+
// Get the actual question text from the button that was clicked
|
203 |
+
const questions = this.game.getSuggestedQuestionsForBlank(this.activeChatBlank);
|
204 |
+
const selectedQuestion = questions.find(q => q.type === questionType);
|
205 |
+
const questionText = selectedQuestion ? selectedQuestion.text : this.getQuestionText(questionType);
|
206 |
+
|
207 |
+
// Show question and loading
|
208 |
+
this.addMessageToChat('You', questionText, true);
|
209 |
+
this.showTypingIndicator();
|
210 |
+
|
211 |
+
try {
|
212 |
+
// Send to chat service with question type
|
213 |
+
const response = await this.game.askQuestionAboutBlank(
|
214 |
+
this.activeChatBlank,
|
215 |
+
questionType,
|
216 |
+
currentInput
|
217 |
+
);
|
218 |
+
|
219 |
+
this.hideTypingIndicator();
|
220 |
+
|
221 |
+
if (response.success) {
|
222 |
+
// Make sure we're displaying the response string, not the object
|
223 |
+
const responseText = typeof response.response === 'string'
|
224 |
+
? response.response
|
225 |
+
: response.response.response || 'Sorry, I had trouble with that question.';
|
226 |
+
this.addMessageToChat('Cluemaster', responseText, false);
|
227 |
+
|
228 |
+
// Refresh question buttons to show the used question as disabled
|
229 |
+
this.loadQuestionButtons();
|
230 |
+
} else {
|
231 |
+
this.addMessageToChat('Cluemaster', response.message || 'Sorry, I had trouble with that question.', false);
|
232 |
+
}
|
233 |
+
|
234 |
+
} catch (error) {
|
235 |
+
this.hideTypingIndicator();
|
236 |
+
console.error('Chat error:', error);
|
237 |
+
this.addMessageToChat('Cluemaster', 'Sorry, I encountered an error. Please try again.', false);
|
238 |
+
}
|
239 |
+
}
|
240 |
+
|
241 |
+
// Get question text for display
|
242 |
+
getQuestionText(questionType) {
|
243 |
+
const questions = {
|
244 |
+
'grammar': 'What type of word is this?',
|
245 |
+
'meaning': 'What does this word mean?',
|
246 |
+
'context': 'Why does this word fit here?',
|
247 |
+
'clue': 'Give me a clue'
|
248 |
+
};
|
249 |
+
return questions[questionType] || questions['clue'];
|
250 |
+
}
|
251 |
+
|
252 |
+
// Get current input for the active blank
|
253 |
+
getCurrentBlankInput() {
|
254 |
+
const input = document.querySelector(`input[data-blank-index="${this.activeChatBlank}"]`);
|
255 |
+
return input ? input.value.trim() : '';
|
256 |
+
}
|
257 |
+
|
258 |
+
// Add message to chat display and store in history
|
259 |
+
addMessageToChat(sender, content, isUser) {
|
260 |
+
// Store message in history for current blank
|
261 |
+
if (this.activeChatBlank !== null) {
|
262 |
+
const blankId = `blank_${this.activeChatBlank}`;
|
263 |
+
if (!this.messageHistory.has(blankId)) {
|
264 |
+
this.messageHistory.set(blankId, []);
|
265 |
+
}
|
266 |
+
|
267 |
+
// Change "Tutor" to "Cluemaster" for display and storage
|
268 |
+
const displaySender = sender === 'Tutor' ? 'Cluemaster' : sender;
|
269 |
+
|
270 |
+
this.messageHistory.get(blankId).push({
|
271 |
+
sender: displaySender,
|
272 |
+
content: content,
|
273 |
+
isUser: isUser,
|
274 |
+
timestamp: Date.now()
|
275 |
+
});
|
276 |
+
}
|
277 |
+
|
278 |
+
// Display the message
|
279 |
+
this.displayMessage(sender === 'Tutor' ? 'Cluemaster' : sender, content, isUser);
|
280 |
+
}
|
281 |
+
|
282 |
+
// Show typing indicator
|
283 |
+
showTypingIndicator() {
|
284 |
+
const messagesContainer = document.getElementById('chat-messages');
|
285 |
+
const typingHTML = `
|
286 |
+
<div id="typing-indicator" class="mb-3 mr-auto max-w-[80%]">
|
287 |
+
<div class="bg-gray-100 text-gray-900 rounded-lg px-3 py-2">
|
288 |
+
<div class="text-xs font-medium mb-1">Cluemaster</div>
|
289 |
+
<div class="text-sm">
|
290 |
+
<span class="typing-dots">
|
291 |
+
<span>.</span><span>.</span><span>.</span>
|
292 |
+
</span>
|
293 |
+
</div>
|
294 |
+
</div>
|
295 |
+
</div>
|
296 |
+
`;
|
297 |
+
|
298 |
+
messagesContainer.insertAdjacentHTML('beforeend', typingHTML);
|
299 |
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
300 |
+
}
|
301 |
+
|
302 |
+
// Hide typing indicator
|
303 |
+
hideTypingIndicator() {
|
304 |
+
const indicator = document.getElementById('typing-indicator');
|
305 |
+
if (indicator) indicator.remove();
|
306 |
+
}
|
307 |
+
|
308 |
+
// Escape HTML to prevent XSS
|
309 |
+
escapeHtml(text) {
|
310 |
+
const div = document.createElement('div');
|
311 |
+
div.textContent = text;
|
312 |
+
return div.innerHTML;
|
313 |
+
}
|
314 |
+
|
315 |
+
// Setup chat buttons for blanks
|
316 |
+
setupChatButtons() {
|
317 |
+
// Remove existing listeners
|
318 |
+
document.querySelectorAll('.chat-button').forEach(btn => {
|
319 |
+
btn.replaceWith(btn.cloneNode(true));
|
320 |
+
});
|
321 |
+
|
322 |
+
// Add new listeners
|
323 |
+
document.querySelectorAll('.chat-button').forEach(btn => {
|
324 |
+
btn.addEventListener('click', (e) => {
|
325 |
+
e.preventDefault();
|
326 |
+
e.stopPropagation();
|
327 |
+
const blankIndex = parseInt(btn.dataset.blankIndex);
|
328 |
+
this.openChat(blankIndex);
|
329 |
+
});
|
330 |
+
});
|
331 |
+
}
|
332 |
+
}
|
333 |
+
|
334 |
+
export default ChatUI;
|
src/clozeGameEngine.js
ADDED
@@ -0,0 +1,540 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Core game logic for minimal cloze reader
|
2 |
+
import bookDataService from './bookDataService.js';
|
3 |
+
import aiService from './aiService.js';
|
4 |
+
import ChatService from './conversationManager.js';
|
5 |
+
|
6 |
+
class ClozeGame {
|
7 |
+
constructor() {
|
8 |
+
this.currentBook = null;
|
9 |
+
this.originalText = '';
|
10 |
+
this.clozeText = '';
|
11 |
+
this.blanks = [];
|
12 |
+
this.userAnswers = [];
|
13 |
+
this.score = 0;
|
14 |
+
this.currentRound = 1;
|
15 |
+
this.currentLevel = 1; // Track difficulty level separately from round
|
16 |
+
this.contextualization = '';
|
17 |
+
this.hints = [];
|
18 |
+
this.chatService = new ChatService(aiService);
|
19 |
+
this.lastResults = null; // Store results for answer revelation
|
20 |
+
}
|
21 |
+
|
22 |
+
async initialize() {
|
23 |
+
try {
|
24 |
+
await bookDataService.loadDataset();
|
25 |
+
console.log('Game initialized successfully');
|
26 |
+
} catch (error) {
|
27 |
+
console.error('Failed to initialize game:', error);
|
28 |
+
throw error;
|
29 |
+
}
|
30 |
+
}
|
31 |
+
|
32 |
+
async startNewRound() {
|
33 |
+
try {
|
34 |
+
// Get a random book (now async with HF streaming)
|
35 |
+
this.currentBook = await bookDataService.getRandomBook();
|
36 |
+
|
37 |
+
// Extract a coherent passage avoiding fragmented text
|
38 |
+
const fullText = this.currentBook.text;
|
39 |
+
let passage = this.extractCoherentPassage(fullText);
|
40 |
+
|
41 |
+
this.originalText = passage.trim();
|
42 |
+
|
43 |
+
// Run AI calls in parallel for faster loading
|
44 |
+
const [clozeResult, contextualizationResult] = await Promise.all([
|
45 |
+
this.createClozeText(),
|
46 |
+
this.generateContextualization()
|
47 |
+
]);
|
48 |
+
|
49 |
+
return {
|
50 |
+
title: this.currentBook.title,
|
51 |
+
author: this.currentBook.author,
|
52 |
+
text: this.clozeText,
|
53 |
+
blanks: this.blanks,
|
54 |
+
contextualization: this.contextualization,
|
55 |
+
hints: this.hints
|
56 |
+
};
|
57 |
+
} catch (error) {
|
58 |
+
console.error('Error starting new round:', error);
|
59 |
+
throw error;
|
60 |
+
}
|
61 |
+
}
|
62 |
+
|
63 |
+
extractCoherentPassage(text) {
|
64 |
+
// Simple elegant solution: start from middle third of book where actual content is
|
65 |
+
const textLength = text.length;
|
66 |
+
const startFromMiddle = Math.floor(textLength * 0.3); // Skip first 30%
|
67 |
+
const endAtThreeQuarters = Math.floor(textLength * 0.8); // Stop before last 20%
|
68 |
+
|
69 |
+
// Random position in the middle section
|
70 |
+
const availableLength = endAtThreeQuarters - startFromMiddle;
|
71 |
+
const randomOffset = Math.floor(Math.random() * Math.max(0, availableLength - 800));
|
72 |
+
const startIndex = startFromMiddle + randomOffset;
|
73 |
+
|
74 |
+
// Extract passage
|
75 |
+
let passage = text.substring(startIndex, startIndex + 800);
|
76 |
+
|
77 |
+
// Clean up start - find first complete sentence
|
78 |
+
const firstSentenceEnd = passage.search(/[.!?]\s+[A-Z]/);
|
79 |
+
if (firstSentenceEnd > 0 && firstSentenceEnd < 200) {
|
80 |
+
passage = passage.substring(firstSentenceEnd + 2);
|
81 |
+
}
|
82 |
+
|
83 |
+
// Clean up end - end at complete sentence
|
84 |
+
const lastSentenceEnd = passage.lastIndexOf('.');
|
85 |
+
if (lastSentenceEnd > 300) {
|
86 |
+
passage = passage.substring(0, lastSentenceEnd + 1);
|
87 |
+
}
|
88 |
+
|
89 |
+
return passage.trim();
|
90 |
+
}
|
91 |
+
|
92 |
+
async createClozeText() {
|
93 |
+
const words = this.originalText.split(' ');
|
94 |
+
// Progressive difficulty: levels 1-2 = 1 blank, levels 3-4 = 2 blanks, level 5+ = 3 blanks
|
95 |
+
let numberOfBlanks;
|
96 |
+
if (this.currentLevel <= 2) {
|
97 |
+
numberOfBlanks = 1;
|
98 |
+
} else if (this.currentLevel <= 4) {
|
99 |
+
numberOfBlanks = 2;
|
100 |
+
} else {
|
101 |
+
numberOfBlanks = 3;
|
102 |
+
}
|
103 |
+
|
104 |
+
// Update chat service with current level
|
105 |
+
this.chatService.setLevel(this.currentLevel);
|
106 |
+
|
107 |
+
// Always use AI for word selection with fallback
|
108 |
+
let significantWords;
|
109 |
+
try {
|
110 |
+
significantWords = await aiService.selectSignificantWords(
|
111 |
+
this.originalText,
|
112 |
+
numberOfBlanks
|
113 |
+
);
|
114 |
+
console.log('AI selected words:', significantWords);
|
115 |
+
} catch (error) {
|
116 |
+
console.warn('AI word selection failed, using manual fallback:', error);
|
117 |
+
significantWords = this.selectWordsManually(words, numberOfBlanks);
|
118 |
+
console.log('Manual selected words:', significantWords);
|
119 |
+
}
|
120 |
+
|
121 |
+
// Ensure we have valid words
|
122 |
+
if (!significantWords || significantWords.length === 0) {
|
123 |
+
console.warn('No words selected, using emergency fallback');
|
124 |
+
significantWords = this.selectWordsManually(words, numberOfBlanks);
|
125 |
+
}
|
126 |
+
|
127 |
+
// Find word indices for selected significant words, distributed throughout passage
|
128 |
+
const selectedIndices = [];
|
129 |
+
const wordsLower = words.map(w => w.toLowerCase().replace(/[^\w]/g, ''));
|
130 |
+
|
131 |
+
// Create sections of the passage to ensure distribution
|
132 |
+
const passageSections = this.dividePassageIntoSections(words.length, numberOfBlanks);
|
133 |
+
|
134 |
+
significantWords.forEach((significantWord, index) => {
|
135 |
+
// Clean the significant word for matching
|
136 |
+
const cleanSignificant = significantWord.toLowerCase().replace(/[^\w]/g, '');
|
137 |
+
|
138 |
+
// Look for the word within the appropriate section for better distribution
|
139 |
+
const sectionStart = passageSections[index] ? passageSections[index].start : 0;
|
140 |
+
const sectionEnd = passageSections[index] ? passageSections[index].end : words.length;
|
141 |
+
|
142 |
+
let wordIndex = -1;
|
143 |
+
|
144 |
+
// First try to find the word in the designated section
|
145 |
+
for (let i = sectionStart; i < sectionEnd; i++) {
|
146 |
+
if (wordsLower[i] === cleanSignificant && !selectedIndices.includes(i)) {
|
147 |
+
wordIndex = i;
|
148 |
+
break;
|
149 |
+
}
|
150 |
+
}
|
151 |
+
|
152 |
+
// If not found in section, look globally
|
153 |
+
if (wordIndex === -1) {
|
154 |
+
wordIndex = wordsLower.findIndex((word, idx) =>
|
155 |
+
word === cleanSignificant && !selectedIndices.includes(idx)
|
156 |
+
);
|
157 |
+
}
|
158 |
+
|
159 |
+
if (wordIndex !== -1) {
|
160 |
+
selectedIndices.push(wordIndex);
|
161 |
+
} else {
|
162 |
+
console.warn(`Could not find word "${significantWord}" in passage`);
|
163 |
+
}
|
164 |
+
});
|
165 |
+
|
166 |
+
// Log the matching results
|
167 |
+
console.log(`Found ${selectedIndices.length} of ${significantWords.length} words in passage`);
|
168 |
+
|
169 |
+
// If no words were matched, fall back to manual selection
|
170 |
+
if (selectedIndices.length === 0) {
|
171 |
+
console.warn('No AI words matched in passage, using manual selection');
|
172 |
+
const manualWords = this.selectWordsManually(words, numberOfBlanks);
|
173 |
+
|
174 |
+
// Try to match manual words
|
175 |
+
manualWords.forEach((manualWord, index) => {
|
176 |
+
const cleanManual = manualWord.toLowerCase().replace(/[^\w]/g, '');
|
177 |
+
const wordIndex = wordsLower.findIndex((word, idx) =>
|
178 |
+
word === cleanManual && !selectedIndices.includes(idx)
|
179 |
+
);
|
180 |
+
|
181 |
+
if (wordIndex !== -1) {
|
182 |
+
selectedIndices.push(wordIndex);
|
183 |
+
}
|
184 |
+
});
|
185 |
+
|
186 |
+
console.log(`After manual fallback: ${selectedIndices.length} words found`);
|
187 |
+
}
|
188 |
+
|
189 |
+
// Sort indices for easier processing
|
190 |
+
selectedIndices.sort((a, b) => a - b);
|
191 |
+
|
192 |
+
// Final safety check - if still no words found, pick random content words
|
193 |
+
if (selectedIndices.length === 0) {
|
194 |
+
console.error('Critical: No words could be selected, using emergency fallback');
|
195 |
+
const contentWords = words.map((word, idx) => ({ word: word.toLowerCase().replace(/[^\w]/g, ''), idx }))
|
196 |
+
.filter(item => item.word.length > 3 && !['the', 'and', 'but', 'for', 'are', 'was'].includes(item.word))
|
197 |
+
.slice(0, numberOfBlanks);
|
198 |
+
|
199 |
+
selectedIndices.push(...contentWords.map(item => item.idx));
|
200 |
+
console.log(`Emergency fallback selected ${selectedIndices.length} words`);
|
201 |
+
}
|
202 |
+
|
203 |
+
// Create blanks array and cloze text
|
204 |
+
this.blanks = [];
|
205 |
+
this.hints = [];
|
206 |
+
const clozeWords = [...words];
|
207 |
+
|
208 |
+
for (let i = 0; i < selectedIndices.length; i++) {
|
209 |
+
const index = selectedIndices[i];
|
210 |
+
const originalWord = words[index];
|
211 |
+
const cleanWord = originalWord.replace(/[^\w]/g, '');
|
212 |
+
|
213 |
+
const blankData = {
|
214 |
+
index: i,
|
215 |
+
originalWord: cleanWord,
|
216 |
+
wordIndex: index
|
217 |
+
};
|
218 |
+
|
219 |
+
this.blanks.push(blankData);
|
220 |
+
|
221 |
+
// Initialize chat context for this word
|
222 |
+
const wordContext = {
|
223 |
+
originalWord: cleanWord,
|
224 |
+
sentence: this.originalText,
|
225 |
+
passage: this.originalText,
|
226 |
+
bookTitle: this.currentBook.title,
|
227 |
+
author: this.currentBook.author,
|
228 |
+
wordPosition: index,
|
229 |
+
difficulty: this.calculateWordDifficulty(cleanWord, index, words)
|
230 |
+
};
|
231 |
+
|
232 |
+
this.chatService.initializeWordContext(`blank_${i}`, wordContext);
|
233 |
+
|
234 |
+
// Generate structural hint based on level
|
235 |
+
let structuralHint;
|
236 |
+
if (this.currentLevel <= 2) {
|
237 |
+
// Levels 1-2: show length, first letter, and last letter
|
238 |
+
structuralHint = `${cleanWord.length} letters, starts with "${cleanWord[0]}", ends with "${cleanWord[cleanWord.length - 1]}"`;
|
239 |
+
} else {
|
240 |
+
// Level 3+: show length and first letter only
|
241 |
+
structuralHint = `${cleanWord.length} letters, starts with "${cleanWord[0]}"`;
|
242 |
+
}
|
243 |
+
this.hints.push({ index: i, hint: structuralHint });
|
244 |
+
|
245 |
+
// Replace word with input field placeholder
|
246 |
+
clozeWords[index] = `___BLANK_${i}___`;
|
247 |
+
}
|
248 |
+
|
249 |
+
this.clozeText = clozeWords.join(' ');
|
250 |
+
this.userAnswers = new Array(this.blanks.length).fill('');
|
251 |
+
|
252 |
+
// Debug: Log the created cloze text
|
253 |
+
console.log('Created cloze text:', this.clozeText);
|
254 |
+
console.log('Number of blanks:', this.blanks.length);
|
255 |
+
|
256 |
+
return true; // Return success indicator
|
257 |
+
}
|
258 |
+
|
259 |
+
dividePassageIntoSections(totalWords, numberOfBlanks) {
|
260 |
+
const sections = [];
|
261 |
+
const sectionSize = Math.floor(totalWords / numberOfBlanks);
|
262 |
+
|
263 |
+
for (let i = 0; i < numberOfBlanks; i++) {
|
264 |
+
const start = i * sectionSize;
|
265 |
+
const end = i === numberOfBlanks - 1 ? totalWords : (i + 1) * sectionSize;
|
266 |
+
sections.push({ start, end });
|
267 |
+
}
|
268 |
+
|
269 |
+
return sections;
|
270 |
+
}
|
271 |
+
|
272 |
+
selectWordsManually(words, numberOfBlanks) {
|
273 |
+
// Fallback manual word selection - avoid function words completely
|
274 |
+
const functionWords = new Set([
|
275 |
+
// Articles
|
276 |
+
'the', 'a', 'an',
|
277 |
+
// Prepositions
|
278 |
+
'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'up', 'about', 'into', 'over', 'after',
|
279 |
+
// Conjunctions
|
280 |
+
'and', 'or', 'but', 'so', 'yet', 'nor', 'because', 'since', 'although', 'if', 'when', 'while',
|
281 |
+
// Pronouns
|
282 |
+
'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them', 'my', 'your', 'his', 'her', 'its', 'our', 'their',
|
283 |
+
'this', 'that', 'these', 'those', 'who', 'what', 'which', 'whom', 'whose',
|
284 |
+
// Auxiliary verbs
|
285 |
+
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did',
|
286 |
+
'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can', 'shall'
|
287 |
+
]);
|
288 |
+
|
289 |
+
// Get content words with their indices for better distribution
|
290 |
+
const contentWordIndices = [];
|
291 |
+
words.forEach((word, index) => {
|
292 |
+
const cleanWord = word.toLowerCase().replace(/[^\w]/g, '');
|
293 |
+
if (cleanWord.length > 3 && !functionWords.has(cleanWord)) {
|
294 |
+
contentWordIndices.push({ word: cleanWord, index });
|
295 |
+
}
|
296 |
+
});
|
297 |
+
|
298 |
+
// Distribute selection across sections
|
299 |
+
const passageSections = this.dividePassageIntoSections(words.length, numberOfBlanks);
|
300 |
+
const selectedWords = [];
|
301 |
+
|
302 |
+
for (let i = 0; i < numberOfBlanks && i < passageSections.length; i++) {
|
303 |
+
const section = passageSections[i];
|
304 |
+
const sectionWords = contentWordIndices.filter(item =>
|
305 |
+
item.index >= section.start && item.index < section.end
|
306 |
+
);
|
307 |
+
|
308 |
+
if (sectionWords.length > 0) {
|
309 |
+
const randomIndex = Math.floor(Math.random() * sectionWords.length);
|
310 |
+
selectedWords.push(sectionWords[randomIndex].word);
|
311 |
+
}
|
312 |
+
}
|
313 |
+
|
314 |
+
// Fill remaining slots if needed
|
315 |
+
while (selectedWords.length < numberOfBlanks && contentWordIndices.length > 0) {
|
316 |
+
const availableWords = contentWordIndices
|
317 |
+
.map(item => item.word)
|
318 |
+
.filter(word => !selectedWords.includes(word));
|
319 |
+
|
320 |
+
if (availableWords.length > 0) {
|
321 |
+
const randomIndex = Math.floor(Math.random() * availableWords.length);
|
322 |
+
selectedWords.push(availableWords[randomIndex]);
|
323 |
+
} else {
|
324 |
+
break;
|
325 |
+
}
|
326 |
+
}
|
327 |
+
|
328 |
+
return selectedWords;
|
329 |
+
}
|
330 |
+
|
331 |
+
async generateContextualization() {
|
332 |
+
// Always use AI for contextualization
|
333 |
+
try {
|
334 |
+
this.contextualization = await aiService.generateContextualization(
|
335 |
+
this.currentBook.title,
|
336 |
+
this.currentBook.author
|
337 |
+
);
|
338 |
+
return this.contextualization;
|
339 |
+
} catch (error) {
|
340 |
+
console.warn('AI contextualization failed, using fallback:', error);
|
341 |
+
this.contextualization = `"${this.currentBook.title}" by ${this.currentBook.author} - A classic work of literature.`;
|
342 |
+
return this.contextualization;
|
343 |
+
}
|
344 |
+
}
|
345 |
+
|
346 |
+
renderClozeText() {
|
347 |
+
let html = this.clozeText;
|
348 |
+
|
349 |
+
this.blanks.forEach((blank, index) => {
|
350 |
+
const inputHtml = `<input type="text"
|
351 |
+
class="cloze-input"
|
352 |
+
data-blank-index="${index}"
|
353 |
+
placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
|
354 |
+
style="width: ${Math.max(80, blank.originalWord.length * 12)}px;">`;
|
355 |
+
|
356 |
+
html = html.replace(`___BLANK_${index}___`, inputHtml);
|
357 |
+
});
|
358 |
+
|
359 |
+
return html;
|
360 |
+
}
|
361 |
+
|
362 |
+
submitAnswers(answers) {
|
363 |
+
this.userAnswers = answers;
|
364 |
+
let correctCount = 0;
|
365 |
+
const results = [];
|
366 |
+
|
367 |
+
this.blanks.forEach((blank, index) => {
|
368 |
+
const userAnswer = answers[index].trim().toLowerCase();
|
369 |
+
const correctAnswer = blank.originalWord.toLowerCase();
|
370 |
+
const isCorrect = userAnswer === correctAnswer;
|
371 |
+
|
372 |
+
if (isCorrect) correctCount++;
|
373 |
+
|
374 |
+
results.push({
|
375 |
+
blankIndex: index,
|
376 |
+
userAnswer: answers[index],
|
377 |
+
correctAnswer: blank.originalWord,
|
378 |
+
isCorrect
|
379 |
+
});
|
380 |
+
});
|
381 |
+
|
382 |
+
const scorePercentage = Math.round((correctCount / this.blanks.length) * 100);
|
383 |
+
this.score = scorePercentage;
|
384 |
+
|
385 |
+
// Calculate pass requirements based on number of blanks
|
386 |
+
const totalBlanks = this.blanks.length;
|
387 |
+
const requiredCorrect = this.calculateRequiredCorrect(totalBlanks);
|
388 |
+
const passed = correctCount >= requiredCorrect;
|
389 |
+
|
390 |
+
const resultsData = {
|
391 |
+
correct: correctCount,
|
392 |
+
total: this.blanks.length,
|
393 |
+
percentage: scorePercentage,
|
394 |
+
passed: passed,
|
395 |
+
results,
|
396 |
+
canAdvanceLevel: passed,
|
397 |
+
shouldRevealAnswers: !passed,
|
398 |
+
requiredCorrect: requiredCorrect,
|
399 |
+
currentLevel: this.currentLevel
|
400 |
+
};
|
401 |
+
|
402 |
+
// Store results for potential answer revelation
|
403 |
+
this.lastResults = resultsData;
|
404 |
+
|
405 |
+
return resultsData;
|
406 |
+
}
|
407 |
+
|
408 |
+
// Calculate required correct answers based on total blanks
|
409 |
+
calculateRequiredCorrect(totalBlanks) {
|
410 |
+
if (totalBlanks === 1) {
|
411 |
+
// Level 1: Must get the single word correct
|
412 |
+
return 1;
|
413 |
+
} else if (totalBlanks % 2 === 1) {
|
414 |
+
// Odd number of blanks (3, 5, etc.): require all but one
|
415 |
+
return totalBlanks - 1;
|
416 |
+
} else {
|
417 |
+
// Even number of blanks: require all correct
|
418 |
+
return totalBlanks;
|
419 |
+
}
|
420 |
+
}
|
421 |
+
|
422 |
+
showAnswers() {
|
423 |
+
return this.blanks.map(blank => ({
|
424 |
+
index: blank.index,
|
425 |
+
word: blank.originalWord
|
426 |
+
}));
|
427 |
+
}
|
428 |
+
|
429 |
+
nextRound() {
|
430 |
+
// Check if user passed the previous round
|
431 |
+
const passed = this.lastResults && this.lastResults.passed;
|
432 |
+
|
433 |
+
// Always increment round counter
|
434 |
+
this.currentRound++;
|
435 |
+
|
436 |
+
// Only advance level if user passed
|
437 |
+
if (passed) {
|
438 |
+
this.currentLevel++;
|
439 |
+
}
|
440 |
+
// If failed, stay at same level
|
441 |
+
|
442 |
+
// Clear chat conversations for new round
|
443 |
+
this.chatService.clearConversations();
|
444 |
+
|
445 |
+
// Clear last results since we're moving to new round
|
446 |
+
this.lastResults = null;
|
447 |
+
|
448 |
+
return this.startNewRound();
|
449 |
+
}
|
450 |
+
|
451 |
+
// Get answers for current round (for revelation when switching passages)
|
452 |
+
getCurrentAnswers() {
|
453 |
+
if (!this.lastResults) return null;
|
454 |
+
|
455 |
+
return {
|
456 |
+
hasResults: true,
|
457 |
+
passed: this.lastResults.passed,
|
458 |
+
shouldRevealAnswers: this.lastResults.shouldRevealAnswers,
|
459 |
+
currentLevel: this.lastResults.currentLevel,
|
460 |
+
requiredCorrect: this.lastResults.requiredCorrect,
|
461 |
+
answers: this.blanks.map(blank => ({
|
462 |
+
index: blank.index,
|
463 |
+
correctAnswer: blank.originalWord,
|
464 |
+
userAnswer: this.lastResults.results[blank.index]?.userAnswer || '',
|
465 |
+
isCorrect: this.lastResults.results[blank.index]?.isCorrect || false
|
466 |
+
}))
|
467 |
+
};
|
468 |
+
}
|
469 |
+
|
470 |
+
// Calculate difficulty of a word based on various factors
|
471 |
+
calculateWordDifficulty(word, position, allWords) {
|
472 |
+
let difficulty = 1;
|
473 |
+
|
474 |
+
// Length factor
|
475 |
+
if (word.length > 8) difficulty += 2;
|
476 |
+
else if (word.length > 5) difficulty += 1;
|
477 |
+
|
478 |
+
// Position factor (middle words might be harder)
|
479 |
+
const relativePosition = position / allWords.length;
|
480 |
+
if (relativePosition > 0.3 && relativePosition < 0.7) difficulty += 1;
|
481 |
+
|
482 |
+
// Complexity factors
|
483 |
+
if (word.includes('ing') || word.includes('ed')) difficulty += 0.5;
|
484 |
+
if (word.includes('tion') || word.includes('sion')) difficulty += 1;
|
485 |
+
|
486 |
+
// Current level factor
|
487 |
+
difficulty += (this.currentLevel - 1) * 0.5;
|
488 |
+
|
489 |
+
return Math.min(5, Math.max(1, Math.round(difficulty)));
|
490 |
+
}
|
491 |
+
|
492 |
+
// Simple, clean hint with just essential info based on level
|
493 |
+
generateContextualFallbackHint(word, wordIndex, allWords) {
|
494 |
+
if (this.currentLevel <= 2) {
|
495 |
+
return `${word.length} letters, starts with "${word[0]}", ends with "${word[word.length - 1]}"`;
|
496 |
+
} else {
|
497 |
+
return `${word.length} letters, starts with "${word[0]}"`;
|
498 |
+
}
|
499 |
+
}
|
500 |
+
|
501 |
+
// Chat functionality methods
|
502 |
+
async askQuestionAboutBlank(blankIndex, questionType, currentInput = '') {
|
503 |
+
const blankId = `blank_${blankIndex}`;
|
504 |
+
return await this.chatService.askQuestion(blankId, questionType, currentInput);
|
505 |
+
}
|
506 |
+
|
507 |
+
getSuggestedQuestionsForBlank(blankIndex) {
|
508 |
+
const blankId = `blank_${blankIndex}`;
|
509 |
+
return this.chatService.getSuggestedQuestions(blankId);
|
510 |
+
}
|
511 |
+
|
512 |
+
// Enhanced render method to include chat buttons
|
513 |
+
renderClozeTextWithChat() {
|
514 |
+
let html = this.clozeText;
|
515 |
+
|
516 |
+
this.blanks.forEach((blank, index) => {
|
517 |
+
const chatButtonId = `chat-btn-${index}`;
|
518 |
+
const inputHtml = `
|
519 |
+
<span class="inline-flex items-center gap-1">
|
520 |
+
<input type="text"
|
521 |
+
class="cloze-input"
|
522 |
+
data-blank-index="${index}"
|
523 |
+
placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
|
524 |
+
style="width: ${Math.max(80, blank.originalWord.length * 12)}px;">
|
525 |
+
<button id="${chatButtonId}"
|
526 |
+
class="chat-button text-blue-500 hover:text-blue-700 text-sm"
|
527 |
+
data-blank-index="${index}"
|
528 |
+
title="Ask question about this word">
|
529 |
+
💬
|
530 |
+
</button>
|
531 |
+
</span>`;
|
532 |
+
|
533 |
+
html = html.replace(`___BLANK_${index}___`, inputHtml);
|
534 |
+
});
|
535 |
+
|
536 |
+
return html;
|
537 |
+
}
|
538 |
+
}
|
539 |
+
|
540 |
+
export default ClozeGame;
|
src/conversationManager.js
ADDED
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Chat service for contextual, personalized hints
|
2 |
+
class ChatService {
|
3 |
+
constructor(aiService) {
|
4 |
+
this.aiService = aiService;
|
5 |
+
this.conversations = new Map(); // blankId -> conversation history
|
6 |
+
this.wordContexts = new Map(); // blankId -> detailed context
|
7 |
+
this.blankQuestions = new Map(); // blankId -> Set of used question types (per-blank tracking)
|
8 |
+
this.currentLevel = 1; // Track current difficulty level
|
9 |
+
|
10 |
+
// Distinct, non-overlapping question set
|
11 |
+
this.questions = [
|
12 |
+
{ text: "What is its part of speech?", type: "part_of_speech" },
|
13 |
+
{ text: "What role does it play in the sentence?", type: "sentence_role" },
|
14 |
+
{ text: "Is it abstract or a person, place, or thing?", type: "word_category" },
|
15 |
+
{ text: "What is a synonym for this word?", type: "synonym" }
|
16 |
+
];
|
17 |
+
}
|
18 |
+
|
19 |
+
// Initialize chat context for a specific blank
|
20 |
+
initializeWordContext(blankId, wordData) {
|
21 |
+
const context = {
|
22 |
+
blankId,
|
23 |
+
targetWord: wordData.originalWord,
|
24 |
+
sentence: wordData.sentence,
|
25 |
+
fullPassage: wordData.passage,
|
26 |
+
bookTitle: wordData.bookTitle,
|
27 |
+
author: wordData.author,
|
28 |
+
wordPosition: wordData.wordPosition,
|
29 |
+
difficulty: wordData.difficulty,
|
30 |
+
previousAttempts: [],
|
31 |
+
userQuestions: [],
|
32 |
+
hintLevel: 0 // Progressive hint difficulty
|
33 |
+
};
|
34 |
+
|
35 |
+
this.wordContexts.set(blankId, context);
|
36 |
+
this.conversations.set(blankId, []);
|
37 |
+
return context;
|
38 |
+
}
|
39 |
+
|
40 |
+
// Per-blank question tracking with level awareness
|
41 |
+
async askQuestion(blankId, questionType, userInput = '') {
|
42 |
+
const context = this.wordContexts.get(blankId);
|
43 |
+
|
44 |
+
if (!context) {
|
45 |
+
return {
|
46 |
+
error: true,
|
47 |
+
message: "Context not found for this word."
|
48 |
+
};
|
49 |
+
}
|
50 |
+
|
51 |
+
// Mark question as used for this specific blank
|
52 |
+
if (!this.blankQuestions.has(blankId)) {
|
53 |
+
this.blankQuestions.set(blankId, new Set());
|
54 |
+
}
|
55 |
+
this.blankQuestions.get(blankId).add(questionType);
|
56 |
+
|
57 |
+
try {
|
58 |
+
const response = await this.generateSpecificResponse(context, questionType, userInput);
|
59 |
+
return {
|
60 |
+
success: true,
|
61 |
+
response: response,
|
62 |
+
questionType: questionType
|
63 |
+
};
|
64 |
+
} catch (error) {
|
65 |
+
console.error('Chat error:', error);
|
66 |
+
return this.getSimpleFallback(context, questionType);
|
67 |
+
}
|
68 |
+
}
|
69 |
+
|
70 |
+
// Generate specific response based on question type
|
71 |
+
async generateSpecificResponse(context, questionType, userInput) {
|
72 |
+
const word = context.targetWord;
|
73 |
+
const sentence = context.sentence;
|
74 |
+
const bookTitle = context.bookTitle;
|
75 |
+
const author = context.author;
|
76 |
+
|
77 |
+
try {
|
78 |
+
// Use the enhanced contextual hint generation
|
79 |
+
const aiResponse = await this.aiService.generateContextualHint(
|
80 |
+
questionType,
|
81 |
+
word,
|
82 |
+
sentence,
|
83 |
+
bookTitle,
|
84 |
+
author
|
85 |
+
);
|
86 |
+
|
87 |
+
if (aiResponse && typeof aiResponse === 'string' && aiResponse.length > 10) {
|
88 |
+
return aiResponse;
|
89 |
+
}
|
90 |
+
} catch (error) {
|
91 |
+
console.warn('AI response failed:', error);
|
92 |
+
}
|
93 |
+
|
94 |
+
// Fallback - return enhanced fallback response
|
95 |
+
return this.aiService.getEnhancedFallback(questionType, word, sentence, bookTitle);
|
96 |
+
}
|
97 |
+
|
98 |
+
// Build focused prompt for specific question types with level awareness
|
99 |
+
buildFocusedPrompt(context, questionType, userInput) {
|
100 |
+
const { sentence, bookTitle, author, targetWord } = context;
|
101 |
+
const level = this.currentLevel;
|
102 |
+
|
103 |
+
// Level 1 questions - Basic identification
|
104 |
+
if (level === 1) {
|
105 |
+
const prompts = {
|
106 |
+
'basic_grammar': `What type of word (person/place/thing or action word) fits in the blank in: "${sentence}"? Keep it simple.`,
|
107 |
+
'letter_hint': `The missing word is "${targetWord}". Tell me it starts with "${targetWord[0]}" without revealing the full word.`,
|
108 |
+
'length_hint': `The missing word has ${targetWord.length} letters. Mention this fact helpfully.`,
|
109 |
+
'category_hint': `Is the missing word in "${sentence}" a person, place, thing, or action? Give a simple answer.`,
|
110 |
+
'basic_clue': `Give a very simple hint about the missing word in: "${sentence}". Make it easy to understand.`
|
111 |
+
};
|
112 |
+
return prompts[questionType] || prompts['basic_clue'];
|
113 |
+
}
|
114 |
+
|
115 |
+
// Level 2 questions - Contextual understanding
|
116 |
+
if (level === 2) {
|
117 |
+
const prompts = {
|
118 |
+
'grammar_analysis': `In "${bookTitle}", what part of speech (noun, verb, adjective, etc.) is needed in: "${sentence}"? Explain simply.`,
|
119 |
+
'contextual_meaning': `What does the missing word mean in this context from "${bookTitle}": "${sentence}"? Explain without revealing it.`,
|
120 |
+
'synonym_clue': `Give a synonym or similar word to the one missing in: "${sentence}". Don't reveal the exact word.`,
|
121 |
+
'narrative_connection': `How does the missing word connect to the story in "${bookTitle}"? Context: "${sentence}"`,
|
122 |
+
'emotional_context': `What feeling or mood does the missing word express in: "${sentence}"?`
|
123 |
+
};
|
124 |
+
return prompts[questionType] || prompts['contextual_meaning'];
|
125 |
+
}
|
126 |
+
|
127 |
+
// Level 3+ questions - Literary analysis
|
128 |
+
const prompts = {
|
129 |
+
'deep_grammar': `Analyze the grammatical function of the missing word in this passage from "${bookTitle}" by ${author}: "${sentence}"`,
|
130 |
+
'literary_analysis': `What literary significance does the missing word have in "${bookTitle}"? Context: "${sentence}"`,
|
131 |
+
'authorial_intent': `What would ${author} intend with the word choice here in "${bookTitle}": "${sentence}"?`,
|
132 |
+
'comparative_analysis': `How does this word usage compare to similar passages in "${bookTitle}"? Context: "${sentence}"`,
|
133 |
+
'style_analysis': `Explain the stylistic choice of the missing word in ${author}'s writing: "${sentence}"`
|
134 |
+
};
|
135 |
+
|
136 |
+
return prompts[questionType] || prompts['literary_analysis'];
|
137 |
+
}
|
138 |
+
|
139 |
+
// Simple fallback responses
|
140 |
+
getSimpleFallback(context, questionType) {
|
141 |
+
// Use the enhanced fallback from HuggingFace service
|
142 |
+
return this.aiService.getEnhancedFallback(
|
143 |
+
questionType,
|
144 |
+
context.targetWord,
|
145 |
+
context.sentence,
|
146 |
+
context.bookTitle
|
147 |
+
);
|
148 |
+
}
|
149 |
+
|
150 |
+
// Helper method to get words before the target word
|
151 |
+
getWordsBefore(sentence, targetWord, count = 3) {
|
152 |
+
const words = sentence.split(/\s+/);
|
153 |
+
const targetIndex = words.findIndex(word =>
|
154 |
+
word.toLowerCase().replace(/[^\w]/g, '') === targetWord.toLowerCase()
|
155 |
+
);
|
156 |
+
|
157 |
+
if (targetIndex === -1) return "";
|
158 |
+
|
159 |
+
const startIndex = Math.max(0, targetIndex - count);
|
160 |
+
return words.slice(startIndex, targetIndex).join(' ');
|
161 |
+
}
|
162 |
+
|
163 |
+
// Helper method to get words after the target word
|
164 |
+
getWordsAfter(sentence, targetWord, count = 3) {
|
165 |
+
const words = sentence.split(/\s+/);
|
166 |
+
const targetIndex = words.findIndex(word =>
|
167 |
+
word.toLowerCase().replace(/[^\w]/g, '') === targetWord.toLowerCase()
|
168 |
+
);
|
169 |
+
|
170 |
+
if (targetIndex === -1) return "";
|
171 |
+
|
172 |
+
const endIndex = Math.min(words.length, targetIndex + count + 1);
|
173 |
+
return words.slice(targetIndex + 1, endIndex).join(' ');
|
174 |
+
}
|
175 |
+
|
176 |
+
// Process AI response to ensure quality and safety
|
177 |
+
processAIResponse(rawResponse, targetWord) {
|
178 |
+
let processed = rawResponse.trim();
|
179 |
+
|
180 |
+
// Remove any accidental word reveals
|
181 |
+
const variations = this.generateWordVariations(targetWord);
|
182 |
+
variations.forEach(variation => {
|
183 |
+
const regex = new RegExp(`\\b${variation}\\b`, 'gi');
|
184 |
+
processed = processed.replace(regex, '[the word]');
|
185 |
+
});
|
186 |
+
|
187 |
+
return processed;
|
188 |
+
}
|
189 |
+
|
190 |
+
// Generate word variations to avoid accidental reveals
|
191 |
+
generateWordVariations(word) {
|
192 |
+
const variations = [word.toLowerCase()];
|
193 |
+
|
194 |
+
// Add common variations
|
195 |
+
if (word.endsWith('ing')) {
|
196 |
+
variations.push(word.slice(0, -3));
|
197 |
+
}
|
198 |
+
if (word.endsWith('ed')) {
|
199 |
+
variations.push(word.slice(0, -2));
|
200 |
+
}
|
201 |
+
if (word.endsWith('s')) {
|
202 |
+
variations.push(word.slice(0, -1));
|
203 |
+
}
|
204 |
+
|
205 |
+
return variations;
|
206 |
+
}
|
207 |
+
|
208 |
+
// Clear conversations and reset tracking
|
209 |
+
clearConversations() {
|
210 |
+
this.conversations.clear();
|
211 |
+
this.wordContexts.clear();
|
212 |
+
this.blankQuestions.clear();
|
213 |
+
}
|
214 |
+
|
215 |
+
// Set current level for question selection
|
216 |
+
setLevel(level) {
|
217 |
+
this.currentLevel = level;
|
218 |
+
}
|
219 |
+
|
220 |
+
// Get suggested questions for a specific blank
|
221 |
+
getSuggestedQuestions(blankId) {
|
222 |
+
const usedQuestions = this.blankQuestions.get(blankId) || new Set();
|
223 |
+
|
224 |
+
return this.questions.map(q => ({
|
225 |
+
...q,
|
226 |
+
used: usedQuestions.has(q.type)
|
227 |
+
}));
|
228 |
+
}
|
229 |
+
|
230 |
+
// Reset for new game (clears everything including across-game state)
|
231 |
+
resetForNewGame() {
|
232 |
+
this.clearConversations();
|
233 |
+
this.currentLevel = 1;
|
234 |
+
}
|
235 |
+
}
|
236 |
+
|
237 |
+
export default ChatService;
|
src/styles.css
ADDED
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
@tailwind base;
|
2 |
+
@tailwind components;
|
3 |
+
@tailwind utilities;
|
4 |
+
|
5 |
+
/* Chat functionality styles */
|
6 |
+
.typing-dots span {
|
7 |
+
animation: typing 1.5s infinite;
|
8 |
+
}
|
9 |
+
|
10 |
+
.typing-dots span:nth-child(2) {
|
11 |
+
animation-delay: 0.5s;
|
12 |
+
}
|
13 |
+
|
14 |
+
.typing-dots span:nth-child(3) {
|
15 |
+
animation-delay: 1s;
|
16 |
+
}
|
17 |
+
|
18 |
+
@keyframes typing {
|
19 |
+
0%, 60%, 100% { opacity: 0.3; }
|
20 |
+
30% { opacity: 1; }
|
21 |
+
}
|
22 |
+
|
23 |
+
.chat-button {
|
24 |
+
transition: all 0.2s ease;
|
25 |
+
border-radius: 4px;
|
26 |
+
padding: 2px 4px;
|
27 |
+
}
|
28 |
+
|
29 |
+
.chat-button:hover {
|
30 |
+
background-color: rgba(59, 130, 246, 0.1);
|
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;
|
45 |
+
font-weight: bold !important;
|
46 |
+
color: #92400e !important;
|
47 |
+
border: 2px solid #f59e0b !important;
|
48 |
+
}
|
49 |
+
|
50 |
+
/* Correct and incorrect answer styles */
|
51 |
+
.cloze-input.correct {
|
52 |
+
background-color: #dcfce7 !important;
|
53 |
+
border-color: #16a34a !important;
|
54 |
+
color: #15803d !important;
|
55 |
+
font-weight: bold !important;
|
56 |
+
}
|
57 |
+
|
58 |
+
.cloze-input.incorrect {
|
59 |
+
background-color: #fef2f2 !important;
|
60 |
+
border-color: #dc2626 !important;
|
61 |
+
color: #dc2626 !important;
|
62 |
+
font-weight: bold !important;
|
63 |
+
}
|
64 |
+
|
65 |
+
.cloze-input:disabled {
|
66 |
+
opacity: 0.8;
|
67 |
+
cursor: not-allowed;
|
68 |
+
}
|
69 |
+
|
70 |
+
/* Custom typewriter color scheme */
|
71 |
+
:root {
|
72 |
+
--aged-paper: #faf7f0;
|
73 |
+
--aged-paper-dark: #f5f1e8;
|
74 |
+
--aged-paper-light: #fefcf7;
|
75 |
+
--typewriter-ink: #2c2c2c;
|
76 |
+
--typewriter-ribbon: #8b5cf6;
|
77 |
+
--paper-shadow: rgba(0, 0, 0, 0.08);
|
78 |
+
}
|
79 |
+
|
80 |
+
@layer base {
|
81 |
+
body {
|
82 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
83 |
+
background-color: var(--aged-paper);
|
84 |
+
color: var(--typewriter-ink);
|
85 |
+
letter-spacing: 0.05em;
|
86 |
+
background-image:
|
87 |
+
radial-gradient(circle at 25% 25%, rgba(139, 92, 246, 0.02) 0%, transparent 50%),
|
88 |
+
radial-gradient(circle at 75% 75%, rgba(139, 92, 246, 0.02) 0%, transparent 50%);
|
89 |
+
}
|
90 |
+
}
|
91 |
+
|
92 |
+
@layer components {
|
93 |
+
.typewriter-text {
|
94 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
95 |
+
color: var(--typewriter-ink);
|
96 |
+
letter-spacing: 0.1em;
|
97 |
+
word-spacing: 0.1em;
|
98 |
+
line-height: 1.8;
|
99 |
+
}
|
100 |
+
|
101 |
+
.typewriter-subtitle {
|
102 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
103 |
+
color: #666;
|
104 |
+
letter-spacing: 0.05em;
|
105 |
+
font-size: 0.95rem;
|
106 |
+
}
|
107 |
+
|
108 |
+
.paper-sheet {
|
109 |
+
background-color: var(--aged-paper-light);
|
110 |
+
border: 1px solid rgba(139, 92, 246, 0.1);
|
111 |
+
box-shadow:
|
112 |
+
0 2px 8px var(--paper-shadow),
|
113 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
114 |
+
position: relative;
|
115 |
+
}
|
116 |
+
|
117 |
+
.paper-sheet::before {
|
118 |
+
content: '';
|
119 |
+
position: absolute;
|
120 |
+
top: 0;
|
121 |
+
left: 0;
|
122 |
+
right: 0;
|
123 |
+
bottom: 0;
|
124 |
+
background: repeating-linear-gradient(
|
125 |
+
to bottom,
|
126 |
+
transparent,
|
127 |
+
transparent 23px,
|
128 |
+
rgba(139, 92, 246, 0.03) 23px,
|
129 |
+
rgba(139, 92, 246, 0.03) 24px
|
130 |
+
);
|
131 |
+
pointer-events: none;
|
132 |
+
z-index: 1;
|
133 |
+
}
|
134 |
+
|
135 |
+
.paper-content {
|
136 |
+
position: relative;
|
137 |
+
z-index: 2;
|
138 |
+
}
|
139 |
+
|
140 |
+
.cloze-input {
|
141 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
142 |
+
background-color: transparent;
|
143 |
+
border: none;
|
144 |
+
border-bottom: 2px solid black;
|
145 |
+
color: var(--typewriter-ink);
|
146 |
+
text-align: center;
|
147 |
+
outline: none;
|
148 |
+
padding: 2px 4px;
|
149 |
+
margin: 0 2px;
|
150 |
+
min-width: 3ch;
|
151 |
+
width: auto;
|
152 |
+
letter-spacing: 0.05em;
|
153 |
+
font-size: inherit;
|
154 |
+
line-height: inherit;
|
155 |
+
transition: all 0.2s ease;
|
156 |
+
}
|
157 |
+
|
158 |
+
.cloze-input:focus {
|
159 |
+
border-bottom-color: black;
|
160 |
+
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.2);
|
161 |
+
background-color: rgba(0, 0, 0, 0.05);
|
162 |
+
}
|
163 |
+
|
164 |
+
.cloze-input.correct {
|
165 |
+
border-bottom-color: #10b981;
|
166 |
+
background-color: rgba(16, 185, 129, 0.1);
|
167 |
+
}
|
168 |
+
|
169 |
+
.cloze-input.incorrect {
|
170 |
+
border-bottom-color: #ef4444;
|
171 |
+
background-color: rgba(239, 68, 68, 0.1);
|
172 |
+
}
|
173 |
+
|
174 |
+
.cloze-input::placeholder {
|
175 |
+
color: rgba(0, 0, 0, 0.4);
|
176 |
+
font-style: italic;
|
177 |
+
}
|
178 |
+
|
179 |
+
.typewriter-button {
|
180 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
181 |
+
min-width: 120px;
|
182 |
+
min-height: 42px;
|
183 |
+
padding: 8px 16px;
|
184 |
+
background-color: var(--aged-paper-dark);
|
185 |
+
color: var(--typewriter-ink);
|
186 |
+
border: 2px solid black;
|
187 |
+
border-radius: 4px;
|
188 |
+
font-weight: 600;
|
189 |
+
letter-spacing: 0.05em;
|
190 |
+
cursor: pointer;
|
191 |
+
transition: all 0.15s ease;
|
192 |
+
box-shadow:
|
193 |
+
0 4px 0 rgba(0, 0, 0, 0.3),
|
194 |
+
0 6px 12px rgba(0, 0, 0, 0.1);
|
195 |
+
position: relative;
|
196 |
+
overflow: hidden;
|
197 |
+
}
|
198 |
+
|
199 |
+
.typewriter-button:hover:not(:disabled) {
|
200 |
+
background-color: rgba(0, 0, 0, 0.05);
|
201 |
+
transform: translateY(-1px);
|
202 |
+
box-shadow:
|
203 |
+
0 5px 0 rgba(0, 0, 0, 0.3),
|
204 |
+
0 8px 16px rgba(0, 0, 0, 0.15);
|
205 |
+
}
|
206 |
+
|
207 |
+
.typewriter-button:active:not(:disabled) {
|
208 |
+
transform: translateY(2px);
|
209 |
+
box-shadow:
|
210 |
+
0 2px 0 rgba(0, 0, 0, 0.3),
|
211 |
+
0 3px 6px rgba(0, 0, 0, 0.1);
|
212 |
+
}
|
213 |
+
|
214 |
+
.typewriter-button:disabled {
|
215 |
+
opacity: 0.6;
|
216 |
+
cursor: not-allowed;
|
217 |
+
transform: translateY(2px);
|
218 |
+
box-shadow:
|
219 |
+
0 2px 0 rgba(0, 0, 0, 0.2),
|
220 |
+
0 3px 6px rgba(0, 0, 0, 0.05);
|
221 |
+
}
|
222 |
+
|
223 |
+
.typewriter-button:focus {
|
224 |
+
outline: none;
|
225 |
+
box-shadow:
|
226 |
+
0 4px 0 rgba(0, 0, 0, 0.3),
|
227 |
+
0 6px 12px rgba(0, 0, 0, 0.1),
|
228 |
+
0 0 0 3px rgba(0, 0, 0, 0.2);
|
229 |
+
}
|
230 |
+
|
231 |
+
.prose {
|
232 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
233 |
+
font-size: 1.1rem;
|
234 |
+
line-height: 1.8;
|
235 |
+
letter-spacing: 0.05em;
|
236 |
+
word-spacing: 0.1em;
|
237 |
+
color: var(--typewriter-ink);
|
238 |
+
overflow-wrap: break-word;
|
239 |
+
}
|
240 |
+
|
241 |
+
.prose p {
|
242 |
+
margin-bottom: 1.5rem;
|
243 |
+
text-align: justify;
|
244 |
+
}
|
245 |
+
|
246 |
+
.biblio-info {
|
247 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
248 |
+
font-size: 0.85rem;
|
249 |
+
color: #666;
|
250 |
+
letter-spacing: 0.03em;
|
251 |
+
font-style: italic;
|
252 |
+
}
|
253 |
+
|
254 |
+
.context-box {
|
255 |
+
background-color: rgba(245, 158, 11, 0.08);
|
256 |
+
border-left: 4px solid #f59e0b;
|
257 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
258 |
+
font-size: 0.9rem;
|
259 |
+
letter-spacing: 0.03em;
|
260 |
+
line-height: 1.6;
|
261 |
+
}
|
262 |
+
|
263 |
+
.hints-box {
|
264 |
+
background-color: rgba(245, 158, 11, 0.08);
|
265 |
+
border-left: 4px solid #f59e0b;
|
266 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
267 |
+
font-size: 0.9rem;
|
268 |
+
letter-spacing: 0.03em;
|
269 |
+
line-height: 1.6;
|
270 |
+
}
|
271 |
+
|
272 |
+
.round-badge {
|
273 |
+
background-color: rgba(245, 158, 11, 0.1);
|
274 |
+
color: #666;
|
275 |
+
border: 1px solid rgba(245, 158, 11, 0.2);
|
276 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
277 |
+
font-weight: 600;
|
278 |
+
letter-spacing: 0.05em;
|
279 |
+
}
|
280 |
+
|
281 |
+
.loading-text {
|
282 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
283 |
+
color: #666;
|
284 |
+
letter-spacing: 0.05em;
|
285 |
+
}
|
286 |
+
|
287 |
+
.result-text {
|
288 |
+
font-family: 'Special Elite', 'Courier New', monospace;
|
289 |
+
font-weight: 600;
|
290 |
+
letter-spacing: 0.05em;
|
291 |
+
}
|
292 |
+
|
293 |
+
/* Responsive adjustments */
|
294 |
+
@media (max-width: 640px) {
|
295 |
+
.prose {
|
296 |
+
font-size: 1rem;
|
297 |
+
}
|
298 |
+
|
299 |
+
.typewriter-text {
|
300 |
+
font-size: 1.75rem;
|
301 |
+
}
|
302 |
+
|
303 |
+
.typewriter-subtitle {
|
304 |
+
font-size: 0.85rem;
|
305 |
+
}
|
306 |
+
|
307 |
+
.typewriter-button {
|
308 |
+
min-width: 100px;
|
309 |
+
min-height: 38px;
|
310 |
+
font-size: 0.9rem;
|
311 |
+
}
|
312 |
+
}
|
313 |
+
|
314 |
+
@media (max-width: 480px) {
|
315 |
+
.prose {
|
316 |
+
font-size: 0.95rem;
|
317 |
+
}
|
318 |
+
|
319 |
+
.typewriter-text {
|
320 |
+
font-size: 1.5rem;
|
321 |
+
}
|
322 |
+
|
323 |
+
.cloze-input {
|
324 |
+
min-width: 2.5ch;
|
325 |
+
}
|
326 |
+
}
|
327 |
+
|
328 |
+
/* Print styles */
|
329 |
+
@media print {
|
330 |
+
body {
|
331 |
+
background: white;
|
332 |
+
color: black;
|
333 |
+
}
|
334 |
+
|
335 |
+
.paper-sheet::before {
|
336 |
+
display: none;
|
337 |
+
}
|
338 |
+
|
339 |
+
.typewriter-button {
|
340 |
+
display: none;
|
341 |
+
}
|
342 |
+
}
|
343 |
+
}
|
344 |
+
|
345 |
+
/* Custom utility classes */
|
346 |
+
@layer utilities {
|
347 |
+
.ink-ribbon-lines {
|
348 |
+
background: repeating-linear-gradient(
|
349 |
+
to bottom,
|
350 |
+
transparent,
|
351 |
+
transparent 23px,
|
352 |
+
rgba(139, 92, 246, 0.03) 23px,
|
353 |
+
rgba(139, 92, 246, 0.03) 24px
|
354 |
+
);
|
355 |
+
}
|
356 |
+
}
|